merge: release approved store account updates

This commit is contained in:
WuYongJie 2026-08-28 17:49:53 +08:00
commit ea363984a7
9 changed files with 216 additions and 2 deletions

View File

@ -0,0 +1,22 @@
ALTER TABLE "StoreAccount"
ADD COLUMN "subject" TEXT NOT NULL DEFAULT '',
ADD COLUMN "boundBankAccountNumberCiphertext" TEXT NOT NULL DEFAULT '';
CREATE TABLE "StoreAccountStoreProfile" (
"id" TEXT NOT NULL,
"storeName" TEXT NOT NULL,
"platform" TEXT NOT NULL,
"merchantNumber" TEXT NOT NULL DEFAULT '',
"secondaryMerchantNumber" TEXT NOT NULL DEFAULT '',
"merchantStoreId" TEXT NOT NULL DEFAULT '',
"companyId" TEXT NOT NULL DEFAULT '',
"createdById" TEXT,
"updatedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StoreAccountStoreProfile_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "StoreAccountStoreProfile_storeName_platform_key" ON "StoreAccountStoreProfile"("storeName", "platform");
CREATE INDEX "StoreAccountStoreProfile_storeName_idx" ON "StoreAccountStoreProfile"("storeName");
CREATE INDEX "StoreAccountStoreProfile_platform_idx" ON "StoreAccountStoreProfile"("platform");

View File

@ -3499,6 +3499,8 @@ model StoreAccount {
verificationContact String @default("")
jstOrderService String @default("")
accountGroupName String @default("")
subject String @default("")
boundBankAccountNumberCiphertext String @default("") @db.Text
createdById String?
updatedById String?
createdAt DateTime @default(now())
@ -3510,7 +3512,23 @@ model StoreAccount {
@@index([accountType])
@@index([updatedAt])
}
model StoreAccountStoreProfile {
id String @id @default(cuid())
storeName String
platform String
merchantNumber String @default("")
secondaryMerchantNumber String @default("")
merchantStoreId String @default("")
companyId String @default("")
createdById String?
updatedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([storeName, platform])
@@index([storeName])
@@index([platform])
}
model StoreAccountChangeLog {
id String @id @default(cuid())
storeAccountId String?
@ -4042,4 +4060,3 @@ model ScriptMonitorRun {
@@index([scriptId, startedAt(sort: Desc)])
@@index([agentId, startedAt(sort: Desc)])
}

View File

@ -0,0 +1,12 @@
import { NextRequest } from 'next/server'
import { handleRequest, jsonSuccess } from '@/server/core/response'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
type RouteProps = { params: Promise<{ id: string }> }
export async function GET(request: NextRequest, props: RouteProps) {
return handleRequest(request, async (ctx) => {
const { id } = await props.params
return jsonSuccess(await storeAccountManagementService.revealBankAccount(id, ctx.user), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,39 @@
import { NextRequest } from 'next/server'
import * as XLSX from 'xlsx'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
import { STORE_ACCOUNT_PROFILE_FIELDS } from '@/lib/store-account-management'
const HEADER_MAP: Record<string, string> = Object.fromEntries([
['店铺名称', 'storeName'],
...STORE_ACCOUNT_PROFILE_FIELDS.map((field) => [field.label, field.key]),
])
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const form = await request.formData()
const file = form.get('file')
const platform = String(form.get('platform') ?? '')
if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择 Excel 文件', 400, ctx.requestId)
if (file.size > 10 * 1024 * 1024) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件不能超过10MB', 400, ctx.requestId)
const workbook = XLSX.read(Buffer.from(await file.arrayBuffer()), { type: 'buffer', cellDates: false })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
if (!sheet) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, 'Excel 文件没有工作表', 400, ctx.requestId)
const rawRows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: '' })
if (rawRows.length === 0) return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据', 400, ctx.requestId)
if (rawRows.length > 5000) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '单次最多导入5000行', 400, ctx.requestId)
let processed = 0
for (const raw of rawRows) {
const mapped = Object.fromEntries(Object.entries(raw).flatMap(([header, value]) => {
const key = HEADER_MAP[header.trim()]
return key ? [[key, value]] : []
}))
const storeName = String(mapped.storeName ?? '').trim()
if (!storeName) throw new Error(`第${processed + 2}行缺少店铺名称`)
await storeAccountManagementService.saveStoreProfile({ storeName, platform, values: mapped, user: ctx.user })
processed += 1
}
return jsonSuccess({ processed, total: rawRows.length }, 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,26 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { storeAccountManagementService } from '@/server/services/store-account-management.service'
const schema = z.object({
storeName: z.string().trim().min(1).max(240),
platform: z.string().trim().min(1).max(10),
values: z.record(z.string(), z.unknown()).default({}),
}).strict()
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const storeName = request.nextUrl.searchParams.get('storeName') ?? ''
return jsonSuccess(await storeAccountManagementService.listStoreProfiles(storeName, ctx.user), 200, ctx.requestId)
}, { requireAuth: true })
}
export async function PATCH(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const parsed = schema.safeParse(await request.json().catch(() => null))
if (!parsed.success) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '店铺资料内容不正确', 400, ctx.requestId)
return jsonSuccess(await storeAccountManagementService.saveStoreProfile({ ...parsed.data, user: ctx.user }), 200, ctx.requestId)
}, { requireAuth: true })
}

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import ExcelJS from 'exceljs'
import { STORE_ACCOUNT_PROFILE_FIELDS, STORE_ACCOUNT_PROFILE_PLATFORMS, type StoreAccountProfilePlatform } from '@/lib/store-account-management'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError } from '@/server/core/response'
import { storeAccountManagementAccessService } from '@/server/services/store-account-management-access.service'
export async function GET(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const capabilities = await storeAccountManagementAccessService.getCapabilities(ctx.user)
if (!capabilities.pageAccess.edit) return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有编辑店铺资料的权限', 403, ctx.requestId)
const platform = request.nextUrl.searchParams.get('platform')?.toUpperCase() as StoreAccountProfilePlatform
const platformLabel = STORE_ACCOUNT_PROFILE_PLATFORMS.find((item) => item.key === platform)?.label
if (!platformLabel) return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '平台只能是京东或京喜', 400, ctx.requestId)
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet(`${platformLabel}店铺资料`)
sheet.columns = [{ header: '店铺名称', key: 'storeName', width: 28 }, ...STORE_ACCOUNT_PROFILE_FIELDS.map((field) => ({ header: field.label, key: field.key, width: 28 }))]
sheet.views = [{ state: 'frozen', ySplit: 1 }]
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF27272A' } }
const content = Buffer.from(await workbook.xlsx.writeBuffer())
return new NextResponse(new Uint8Array(content), {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(`${platformLabel}店铺资料导入模板.xlsx`)}`,
'Cache-Control': 'private, no-store, max-age=0',
},
})
}, { requireAuth: true })
}

View File

@ -5,7 +5,7 @@ import { RequireAuth } from '@/components/auth/RequireAuth'
import { PermissionGuard } from '@/components/auth/PermissionGuard'
import { UserPermissionCenter } from './components/UserPermissionCenter'
export function UserPermissionManagementPage({
function UserPermissionManagementPage({
backHref: _backHref = '/intake',
backLabel: _backLabel = '产品开发',
}: {

View File

@ -30,3 +30,6 @@ export function decryptStoreAccountPassword(value: string) {
decipher.setAuthTag(Buffer.from(tagPart, 'base64url'))
return Buffer.concat([decipher.update(Buffer.from(dataPart, 'base64url')), decipher.final()]).toString('utf8')
}
export const encryptStoreAccountBankAccount = encryptStoreAccountPassword
export const decryptStoreAccountBankAccount = decryptStoreAccountPassword

View File

@ -15,6 +15,20 @@ export type StoreAccountRecord = {
verificationContact: string
jstOrderService: string
accountGroupName: string
subject: string
boundBankAccountNumberCiphertext: string
createdAt: Date
updatedAt: Date
}
export type StoreAccountProfileRecord = {
id: string
storeName: string
platform: string
merchantNumber: string
secondaryMerchantNumber: string
merchantStoreId: string
companyId: string
createdAt: Date
updatedAt: Date
}
@ -50,6 +64,20 @@ const ACCOUNT_SELECT = {
verificationContact: true,
jstOrderService: true,
accountGroupName: true,
subject: true,
boundBankAccountNumberCiphertext: true,
createdAt: true,
updatedAt: true,
} as const
const PROFILE_SELECT = {
id: true,
storeName: true,
platform: true,
merchantNumber: true,
secondaryMerchantNumber: true,
merchantStoreId: true,
companyId: true,
createdAt: true,
updatedAt: true,
} as const
@ -158,6 +186,42 @@ export class StoreAccountManagementRepository {
})
}
async listProfiles(storeName: string) {
return prisma.storeAccountStoreProfile.findMany({
where: { storeName },
select: PROFILE_SELECT,
orderBy: { platform: 'asc' },
})
}
async upsertProfile(data: {
storeName: string
platform: string
values: Record<string, string>
actorId: string
}) {
return prisma.storeAccountStoreProfile.upsert({
where: {
storeName_platform: {
storeName: data.storeName,
platform: data.platform,
},
},
create: {
...data.values,
storeName: data.storeName,
platform: data.platform,
createdById: data.actorId,
updatedById: data.actorId,
},
update: {
...data.values,
updatedById: data.actorId,
},
select: PROFILE_SELECT,
})
}
async upsertByKey(data: {
storeName: string
accountType: string