diff --git a/furniture-3d-system/prisma/migrations/20260828100000_extend_store_account_profiles/migration.sql b/furniture-3d-system/prisma/migrations/20260828100000_extend_store_account_profiles/migration.sql new file mode 100644 index 00000000..4b28f58c --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260828100000_extend_store_account_profiles/migration.sql @@ -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"); diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index f22eb25b..8082d745 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -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)]) } - diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/bank-account/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/bank-account/route.ts new file mode 100644 index 00000000..f705340f --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/[id]/bank-account/route.ts @@ -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 }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/import/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/import/route.ts new file mode 100644 index 00000000..40922490 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/import/route.ts @@ -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 = 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>(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 }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/route.ts new file mode 100644 index 00000000..f222a1d0 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/route.ts @@ -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 }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/template/route.ts b/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/template/route.ts new file mode 100644 index 00000000..e71c0b3c --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/store-account-management/profiles/template/route.ts @@ -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 }) +} diff --git a/furniture-3d-system/src/app/users/page.tsx b/furniture-3d-system/src/app/users/page.tsx index 0b03ec09..a96f6a77 100644 --- a/furniture-3d-system/src/app/users/page.tsx +++ b/furniture-3d-system/src/app/users/page.tsx @@ -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 = '产品开发', }: { diff --git a/furniture-3d-system/src/lib/store-account-crypto.ts b/furniture-3d-system/src/lib/store-account-crypto.ts index 0ed4b07f..2140e047 100644 --- a/furniture-3d-system/src/lib/store-account-crypto.ts +++ b/furniture-3d-system/src/lib/store-account-crypto.ts @@ -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 diff --git a/furniture-3d-system/src/server/repositories/store-account-management.repository.ts b/furniture-3d-system/src/server/repositories/store-account-management.repository.ts index 54f7e662..7e241a1a 100644 --- a/furniture-3d-system/src/server/repositories/store-account-management.repository.ts +++ b/furniture-3d-system/src/server/repositories/store-account-management.repository.ts @@ -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 + 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