From 41e962da37d0522b70ba03bcb44cd6ac302c3046 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 8 Sep 2026 09:01:37 +0800 Subject: [PATCH 1/9] feat(oa-board): make workflow link cells clickable to open product detail workflow link cells on the oa board detail table now render as anchors when the value is an http(s) url, opening the linked product detail in a new tab with platform link styling and hover underline; empty or non-url text still renders the dash placeholder. --- .../src/app/intake/oa-board/OaBoardClient.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx b/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx index c36aebec..84affecf 100644 --- a/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx +++ b/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx @@ -2530,7 +2530,19 @@ function DetailTableRow({ maxWidth: COLUMN_WIDTHS.workflowLink, }} > - + {/^https?:\/\//i.test(item.formSummary.workflowLink?.trim() ?? '') ? ( + + {item.formSummary.workflowLink?.trim()} + + ) : ( + + )} Date: Tue, 8 Sep 2026 10:21:19 +0800 Subject: [PATCH 2/9] fix(product-development-todo): route missing purchaser reminders and use plain text --- .../product-development-todo-rules.test.ts | 81 +++++++++++++ .../product-development-todo.service.test.ts | 106 ++++++++++++++---- ...ct-development-todo-preparation.service.ts | 24 +--- .../product-development-todo-rules.ts | 63 ++++++++--- .../product-development-todo.service.ts | 4 - 5 files changed, 217 insertions(+), 61 deletions(-) diff --git a/furniture-3d-system/src/server/services/__tests__/product-development-todo-rules.test.ts b/furniture-3d-system/src/server/services/__tests__/product-development-todo-rules.test.ts index 05e2cc18..239bea6d 100644 --- a/furniture-3d-system/src/server/services/__tests__/product-development-todo-rules.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/product-development-todo-rules.test.ts @@ -69,6 +69,16 @@ function completeFactoryRow() { } } +function completeSampleMeasurementRow() { + return { + skuId: 'sku-1', + lengthCm: '10', + widthCm: '20', + heightCm: '30', + weightKg: '4', + } +} + describe('evaluateProductDevelopmentTodos', () => { it('keeps an earlier missing node and also activates a later node whose predecessor completed', () => { const input = product({ @@ -146,6 +156,77 @@ describe('evaluateProductDevelopmentTodos', () => { expect(pricing?.recipientNames).toEqual(['徐霞客', '依依']) }) + it('routes the sampling handoff to the structure designer when purchaser is missing', () => { + const input = product({ + ...approval(['产品模型']), + owners: { + appearanceDesigner: '外观', + structureDesigner: '结构设计师', + purchaser: '', + }, + }) + + const handoff = evaluateProductDevelopmentTodos(input, now) + .find((item) => item.ruleId === 'product_information_sampling_handoff') + + expect(handoff).toMatchObject({ + nodeLabel: '产品资料已完成,需要填写采购负责人', + workspace: 'basic-info', + recipientRole: 'structureDesigner', + recipientNames: ['结构设计师'], + }) + }) + + it.each([ + { + ruleId: 'product_factory_unfinished', + nodeLabel: '产品定厂未完成', + overrides: { + ...approval(['基础信息']), + }, + }, + { + ruleId: 'sample_measurement_unfinished', + nodeLabel: '样包测量未完成', + overrides: { + ...approval(['产品定厂']), + factoryRows: [completeFactoryRow()], + }, + }, + { + ruleId: 'product_pricing_unfinished', + nodeLabel: '产品核价未完成', + overrides: { + ...approval(['产品定厂', '样包测量']), + factoryRows: [completeFactoryRow()], + sampleMeasurementRows: [completeSampleMeasurementRow()], + }, + }, + ])('routes missing purchaser for $ruleId to the structure designer', ({ + ruleId, + nodeLabel, + overrides, + }) => { + const input = product({ + ...overrides, + owners: { + appearanceDesigner: '外观', + structureDesigner: '结构设计师', + purchaser: '', + }, + }) + + const todo = evaluateProductDevelopmentTodos(input, now) + .find((item) => item.ruleId === ruleId) + + expect(todo).toMatchObject({ + nodeLabel: `${nodeLabel},需要填写采购负责人`, + workspace: 'basic-info', + recipientRole: 'structureDesigner', + recipientNames: ['结构设计师'], + }) + }) + it.each(['宁珂', '松柏'])('adds both merchandisers for purchaser %s', (purchaser) => { const input = product({ ...approval(['基础信息']), diff --git a/furniture-3d-system/src/server/services/__tests__/product-development-todo.service.test.ts b/furniture-3d-system/src/server/services/__tests__/product-development-todo.service.test.ts index ab04f903..667f5afe 100644 --- a/furniture-3d-system/src/server/services/__tests__/product-development-todo.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/product-development-todo.service.test.ts @@ -92,15 +92,17 @@ describe('ProductDevelopmentTodoService', () => { itemCount: 2, deliverable: true, }) - expect(result.groups[0]?.content).toContain('XJ0100 · 开品参考未确认') - expect(result.groups[0]?.content).toContain('XJ0100 · 产品资料未完成') - expect(result.groups[0]?.items.map((item) => item.actionUrl)).toEqual([ - expect.stringContaining('workspace=opening-reference'), - expect.stringContaining('workspace=product-data'), + expect(result.groups[0]?.content.split('\n\n')).toEqual([ + expect.stringMatching(/^XJ0100 · 开品参考未确认 · 已用/), + expect.stringMatching(/^XJ0100 · 产品资料未完成 · 已用/), ]) + expect(result.groups[0]?.content).not.toMatch(/查看|https?:\/\/|\[[^\]]*\]/) + expect(result.groups[0]?.content).not.toContain('徐霞客') + expect(result.groups[0]).not.toHaveProperty('actionUrl') + expect(result.groups[0]?.items[0]).not.toHaveProperty('actionUrl') }) - it('shows a visible preview issue when the purchaser is missing', async () => { + it('routes a missing purchaser reminder to the structure designer', async () => { mockFindEligibleProducts.mockResolvedValue([ { ...record({ @@ -117,18 +119,25 @@ describe('ProductDevelopmentTodoService', () => { profile: { purchaseOwner: '', structureDesigner: '结构' }, }, ]) - mockFindUsersByNames.mockResolvedValue([]) + mockFindUsersByNames.mockResolvedValue([{ + ...user, + id: 'structure-user', + name: '结构', + }]) const result = await new ProductDevelopmentTodoService().preview({ now }) - const issue = result.groups.find((group) => group.recipientName === '未配置采购') + const reminder = result.groups.find((group) => group.recipientName === '结构') - expect(issue).toMatchObject({ - deliverable: false, - issue: 'XJ0100 的流程没有配置采购', + expect(reminder).toMatchObject({ + deliverable: true, + itemCount: 1, }) + expect(reminder?.content).toMatch(/^XJ0100 · 产品资料已完成,需要填写采购负责人 · 已用/m) + expect(reminder?.content).not.toMatch(/查看|https?:\/\/|\[/) + expect(reminder?.items[0]).not.toHaveProperty('actionUrl') }) - it('aggregates all SPUs into the missing purchaser issue', async () => { + it('routes missing purchasers for multiple products to their structure designers', async () => { mockFindEligibleProducts.mockResolvedValue([ { ...record({ @@ -151,12 +160,27 @@ describe('ProductDevelopmentTodoService', () => { profile: { purchaseOwner: '', structureDesigner: '结构' }, }, ]) - mockFindUsersByNames.mockResolvedValue([]) + mockFindUsersByNames.mockResolvedValue([{ + ...user, + id: 'structure-user', + name: '结构', + }]) const result = await new ProductDevelopmentTodoService().preview({ now }) - const issue = result.groups.find((group) => group.recipientName === '未配置采购') + const reminder = result.groups.find((group) => group.recipientName === '结构') - expect(issue?.issue).toBe('2 个流程没有配置采购:XJ0100、XJ0101') + expect(reminder).toMatchObject({ + recipientName: '结构', + itemCount: 2, + deliverable: true, + }) + expect(reminder?.content).toContain('XJ0100') + expect(reminder?.content).toContain('XJ0101') + expect(reminder?.content.split('\n\n')).toEqual([ + expect.stringMatching(/^XJ0100 · 产品定厂未完成,需要填写采购负责人 · 已用/), + expect.stringMatching(/^XJ0101 · 产品定厂未完成,需要填写采购负责人 · 已用/), + ]) + expect(reminder?.content).not.toMatch(/查看|https?:\/\/|\[/) }) it('claims one-time handoffs before sending and records a failed undeliverable group', async () => { @@ -176,7 +200,11 @@ describe('ProductDevelopmentTodoService', () => { profile: { purchaseOwner: '', structureDesigner: '结构' }, }, ]) - mockFindUsersByNames.mockResolvedValue([]) + mockFindUsersByNames.mockResolvedValue([{ + ...user, + id: 'structure-user', + name: '结构', + }]) const result = await new ProductDevelopmentTodoService().send({ now }) @@ -186,12 +214,48 @@ describe('ProductDevelopmentTodoService', () => { productId: 'product-1', }), ]) - expect(mockRecordUndeliverable).toHaveBeenCalledWith(expect.objectContaining({ - errorCode: 'RECIPIENT_OWNER_MISSING', - title: '待办(1)', + expect(mockRecordUndeliverable).not.toHaveBeenCalled() + expect(mockSendToEmployee).toHaveBeenCalledWith(expect.objectContaining({ + recipientUserId: 'structure-user', })) - expect(mockSendToEmployee).not.toHaveBeenCalled() - expect(result.failedCount).toBe(1) + expect(mockSendToEmployee.mock.calls[0]?.[0]).not.toHaveProperty('actionTitle') + expect(mockSendToEmployee.mock.calls[0]?.[0]).not.toHaveProperty('actionUrl') + expect(result.failedCount).toBe(0) + }) + + it('keeps a 98-item recipient summary within the text-only payload limit', async () => { + mockFindEligibleProducts.mockResolvedValue( + Array.from({ length: 98 }, (_, index) => ({ + ...record({ + projectStatus: 'approved', + documentWorkspaceConfirmations: { 基础信息: true }, + documentWorkspaceApprovedAt: { 基础信息: '2026-08-20T02:00:00.000Z' }, + }), + id: `product-${index + 1}`, + spu: `HM${String(index + 1).padStart(3, '0')}`, + purchaser: '宁珂', + profile: { purchaseOwner: '宁珂', structureDesigner: '结构' }, + })), + ) + mockFindUsersByNames.mockResolvedValue([ + user, + { ...user, id: 'purchaser-user', name: '宁珂', dingtalkUserId: 'ding-purchaser' }, + { ...user, id: 'merchandiser-1', name: '雁兰', dingtalkUserId: 'ding-merchandiser-1' }, + { ...user, id: 'merchandiser-2', name: '槐米', dingtalkUserId: 'ding-merchandiser-2' }, + { ...user, id: 'structure-user', name: '结构', dingtalkUserId: 'ding-structure' }, + ]) + + const result = await new ProductDevelopmentTodoService().preview({ now }) + const huaiMi = result.groups.find((group) => group.recipientName === '槐米') + + expect(huaiMi).toMatchObject({ + deliverable: true, + itemCount: 98, + }) + expect(huaiMi?.content.split('\n\n')).toHaveLength(98) + expect(huaiMi?.content.length).toBeLessThanOrEqual(4_600) + expect(huaiMi?.content).not.toMatch(/查看|https?:\/\/|\[|徐霞客/) + expect(huaiMi?.items.every((item) => !('actionUrl' in item))).toBe(true) }) it('does not retry a one-time handoff that already has an attempt', async () => { diff --git a/furniture-3d-system/src/server/services/product-development-todo-preparation.service.ts b/furniture-3d-system/src/server/services/product-development-todo-preparation.service.ts index 5ee6412a..c0e59b75 100644 --- a/furniture-3d-system/src/server/services/product-development-todo-preparation.service.ts +++ b/furniture-3d-system/src/server/services/product-development-todo-preparation.service.ts @@ -18,10 +18,7 @@ import type { } from '@/server/services/product-development-todo.types' const EXTERNAL_ACCOUNT_ROLES = new Set(['SUPPLIER', 'EXTERNAL_INTAKE']) -const PRODUCT_DETAIL_ORIGIN = 'https://cp.jiansun.vip' - export type PreparedTodoItem = ProductDevelopmentTodoCandidate & { - actionUrl: string elapsed: string onceAttemptKey: string | null } @@ -39,8 +36,6 @@ export type PreparedRecipientGroup = { items: PreparedTodoItem[] title: string content: string - actionTitle: string - actionUrl: string idempotencyKey: string onceAttemptKeys: string[] } @@ -91,12 +86,6 @@ function toRuleProduct(record: ProductDevelopmentTodoSourceRecord): ProductDevel } } -function buildActionUrl(item: ProductDevelopmentTodoCandidate) { - const path = `/intake/non-integrated/${encodeURIComponent(item.productId)}` - const query = new URLSearchParams({ workspace: item.workspace, mode: 'edit' }) - return `${PRODUCT_DETAIL_ORIGIN}${path}?${query.toString()}` -} - function recipientKey(value: string) { return createHash('sha256').update(value).digest('hex').slice(0, 24) } @@ -168,14 +157,14 @@ function missingOwnerResolution(item: ProductDevelopmentTodoCandidate): Recipien } } -function buildMessage(recipientName: string, items: PreparedTodoItem[]) { +function buildMessage(items: PreparedTodoItem[]) { const title = `待办(${items.length})` const lines = items.map((item) => ( - `${item.spu} · ${item.nodeLabel} · 已用${item.elapsed} · [查看 >](${item.actionUrl})` + `${item.spu} · ${item.nodeLabel} · 已用${item.elapsed}` )) return { title, - content: `${recipientName},今日有 ${items.length} 项待处理。\n\n${lines.join('\n')}`, + content: lines.join('\n\n'), } } @@ -189,13 +178,11 @@ export function toProductDevelopmentTodoPreviewGroup( itemCount: group.items.length, title: group.title, content: group.content, - actionUrl: group.actionUrl, items: group.items.map((item) => ({ productId: item.productId, spu: item.spu, nodeLabel: item.nodeLabel, elapsed: item.elapsed, - actionUrl: item.actionUrl, })), } } @@ -240,7 +227,6 @@ export async function prepareProductDevelopmentTodos(input: { recipient, item: { ...item, - actionUrl: buildActionUrl(item), elapsed: formatProductDevelopmentTodoElapsed(item.activatedAt, input.now), onceAttemptKey, } satisfies PreparedTodoItem, @@ -273,9 +259,7 @@ export async function prepareProductDevelopmentTodos(input: { return { recipient: resolvedRecipient, items, - ...buildMessage(recipient.name, items), - actionTitle: '查看待办', - actionUrl: items[0]!.actionUrl, + ...buildMessage(items), idempotencyKey: `product-todo:daily:${dateKey}:${recipientKey(recipient.key)}`, onceAttemptKeys: items.flatMap((item) => ( item.onceAttemptKey ? [item.onceAttemptKey] : [] diff --git a/furniture-3d-system/src/server/services/product-development-todo-rules.ts b/furniture-3d-system/src/server/services/product-development-todo-rules.ts index 60be5bf0..b6d8feff 100644 --- a/furniture-3d-system/src/server/services/product-development-todo-rules.ts +++ b/furniture-3d-system/src/server/services/product-development-todo-rules.ts @@ -119,6 +119,25 @@ function purchaserRecipients(purchaser: string, extraNames: string[] = []) { ])) } +function purchaserRecipientConfig( + product: ProductDevelopmentTodoProduct, + extraNames: string[] = [], +) { + const purchaser = product.owners.purchaser.trim() + if (purchaser) { + return { + recipientRole: 'purchaser' as const, + recipientNames: purchaserRecipients(purchaser, extraNames), + needsPurchaserAssignment: false, + } + } + return { + recipientRole: 'structureDesigner' as const, + recipientNames: splitNames(product.owners.structureDesigner), + needsPurchaserAssignment: true, + } +} + function candidate(input: Omit & { product: ProductDevelopmentTodoProduct }): ProductDevelopmentTodoCandidate { @@ -189,13 +208,16 @@ export function evaluateProductDevelopmentTodos( } if (nodeApproved(product, '产品模型') && !nodeApproved(product, '基础信息')) { + const recipient = purchaserRecipientConfig(product) items.push(candidate({ product, ruleId: 'product_information_sampling_handoff', - nodeLabel: '产品资料已完成,需要打样', - workspace: 'product-data', - recipientRole: 'purchaser', - recipientNames: purchaserRecipients(product.owners.purchaser), + nodeLabel: recipient.needsPurchaserAssignment + ? '产品资料已完成,需要填写采购负责人' + : '产品资料已完成,需要打样', + workspace: recipient.needsPurchaserAssignment ? 'basic-info' : 'product-data', + recipientRole: recipient.recipientRole, + recipientNames: recipient.recipientNames, activatedAt: productInformationApprovedAt!, deliveryMode: 'once', })) @@ -219,39 +241,48 @@ export function evaluateProductDevelopmentTodos( } if (nodeApproved(product, '基础信息') && !productFactoryApproved(product)) { + const recipient = purchaserRecipientConfig(product) items.push(candidate({ product, ruleId: 'product_factory_unfinished', - nodeLabel: '产品定厂未完成', - workspace: 'product-factory', - recipientRole: 'purchaser', - recipientNames: purchaserRecipients(product.owners.purchaser), + nodeLabel: recipient.needsPurchaserAssignment + ? '产品定厂未完成,需要填写采购负责人' + : '产品定厂未完成', + workspace: recipient.needsPurchaserAssignment ? 'basic-info' : 'product-factory', + recipientRole: recipient.recipientRole, + recipientNames: recipient.recipientNames, activatedAt: basicInformationApprovedAt!, deliveryMode: 'daily', })) } if (productFactoryApproved(product) && !sampleMeasurementApproved(product)) { + const recipient = purchaserRecipientConfig(product) items.push(candidate({ product, ruleId: 'sample_measurement_unfinished', - nodeLabel: '样包测量未完成', - workspace: 'sample-measurement', - recipientRole: 'purchaser', - recipientNames: purchaserRecipients(product.owners.purchaser), + nodeLabel: recipient.needsPurchaserAssignment + ? '样包测量未完成,需要填写采购负责人' + : '样包测量未完成', + workspace: recipient.needsPurchaserAssignment ? 'basic-info' : 'sample-measurement', + recipientRole: recipient.recipientRole, + recipientNames: recipient.recipientNames, activatedAt: productFactoryApprovedAt!, deliveryMode: 'daily', })) } if (sampleMeasurementApproved(product) && !productPricingApproved(product)) { + const recipient = purchaserRecipientConfig(product, ['依依']) items.push(candidate({ product, ruleId: 'product_pricing_unfinished', - nodeLabel: '产品核价未完成', - workspace: 'product-pricing', - recipientRole: 'purchaser', - recipientNames: purchaserRecipients(product.owners.purchaser, ['依依']), + nodeLabel: recipient.needsPurchaserAssignment + ? '产品核价未完成,需要填写采购负责人' + : '产品核价未完成', + workspace: recipient.needsPurchaserAssignment ? 'basic-info' : 'product-pricing', + recipientRole: recipient.recipientRole, + recipientNames: recipient.recipientNames, activatedAt: sampleMeasurementApprovedAt!, deliveryMode: 'daily', })) diff --git a/furniture-3d-system/src/server/services/product-development-todo.service.ts b/furniture-3d-system/src/server/services/product-development-todo.service.ts index 6da5bfa3..17660bcb 100644 --- a/furniture-3d-system/src/server/services/product-development-todo.service.ts +++ b/furniture-3d-system/src/server/services/product-development-todo.service.ts @@ -71,8 +71,6 @@ class ProductDevelopmentTodoService { }, title: group.title, content: group.content, - actionTitle: group.actionTitle, - actionUrl: group.actionUrl, errorCode: group.recipient.errorCode, errorMessage: group.recipient.errorMessage ?? '收件人不可投递', }) @@ -99,8 +97,6 @@ class ProductDevelopmentTodoService { recipientUserId: group.recipient.user!.id, title: group.title, content: group.content, - actionTitle: group.actionTitle, - actionUrl: group.actionUrl, retryExisting: false, }) await productDevelopmentTodoRepository.updateAttempts(group.onceAttemptKeys, { -- 2.47.2 From d2f71b372337d683286a64069ae0bb56f05f13cd Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 8 Sep 2026 12:26:44 +0800 Subject: [PATCH 3/9] feat(intake): add product abandon action to the product list the product list rows gain an abandon action next to edit/copy/delete for admins and product owners (operations brand / spu / appearance / structure / purchase / merchandiser / planner / space owners matched by name); abandoning sets projectStatus to rejected which makes every workspace read-only and the existing daily todo push already skips rejected products, so abandoned products drop out of daily pushes automatically; the server enforces the same permission via a new POST abandon endpoint. Abandoned rows keep the restore action to come back. --- .../non-integrated/[id]/abandon/route.ts | 32 ++++++ .../NonIntegratedPendingDetail.tsx | 108 ++++++++++++++++-- .../intake-non-integrated-product.service.ts | 66 +++++++++++ 3 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 furniture-3d-system/src/app/api/intake/non-integrated/[id]/abandon/route.ts diff --git a/furniture-3d-system/src/app/api/intake/non-integrated/[id]/abandon/route.ts b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/abandon/route.ts new file mode 100644 index 00000000..b557cbe8 --- /dev/null +++ b/furniture-3d-system/src/app/api/intake/non-integrated/[id]/abandon/route.ts @@ -0,0 +1,32 @@ +import { NextRequest } from 'next/server' +import { AppError, ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { intakeNonIntegratedProductService } from '@/server/services/intake-non-integrated-product.service' + +type RouteContext = { + params: Promise<{ id: string }> +} + +export async function POST(request: NextRequest, context: RouteContext) { + return handleRequest( + request, + async (ctx) => { + const { id } = await context.params + const decodedId = decodeURIComponent(id) + + try { + const result = await intakeNonIntegratedProductService.abandonProduct( + decodedId, + ctx.user, + ) + return jsonSuccess(result, 200, ctx.requestId) + } catch (error) { + if (error instanceof AppError) { + return jsonError(error.code, error.message, error.statusCode, ctx.requestId) + } + throw error + } + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/intake/product-development/NonIntegratedPendingDetail.tsx b/furniture-3d-system/src/app/intake/product-development/NonIntegratedPendingDetail.tsx index df00a8de..059f45c4 100644 --- a/furniture-3d-system/src/app/intake/product-development/NonIntegratedPendingDetail.tsx +++ b/furniture-3d-system/src/app/intake/product-development/NonIntegratedPendingDetail.tsx @@ -2,7 +2,7 @@ import { useRouter } from "next/navigation"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; -import { Copy, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react"; +import { Ban, Copy, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react"; import Button from "@/components/ui/Button"; import { CopyProductDialog, @@ -62,8 +62,11 @@ import { getDocumentWorkspaceCountByCategory } from "@/app/intake/product-develo import { ProductDevelopmentCategoryFilterField } from "@/app/intake/product-development/ProductDevelopmentCategoryFilterField"; import { displayPerson, + getCurrentUserResponsibleOwnerValues, getNonIntegratedListPeople, + isAdminUser, isCurrentUserResponsibleForNonIntegratedProduct, + matchesCurrentUserName, productMatchesText, type NonIntegratedListPermissionUser, } from "@/app/intake/product-development/nonIntegratedProductListLogic"; @@ -113,9 +116,23 @@ function createRestoredNonIntegratedProduct( }; } +function createAbandonedNonIntegratedProduct( + product: NonIntegratedProductRecord, +): NonIntegratedProductRecord { + return { + ...product, + projectStatus: "rejected", + openingReference: { + ...product.openingReference, + currentStage: "废弃不开", + }, + updatedAt: new Date().toISOString(), + }; +} + const PENDING_PAGE_SIZE = 50; const TABLE_ACTION_BUTTON_BASE = - "!h-8 !min-h-8 !w-8 !rounded-apple-md !px-0 !py-0 !shadow-none max-md:!h-7 max-md:!min-h-7 max-md:!w-7 max-md:!min-w-7"; + "!h-7 !min-h-7 !w-7 !min-w-7 !rounded-apple-md !px-0 !py-0 !shadow-none max-md:!h-6 max-md:!min-h-6 max-md:!w-6 max-md:!min-w-6"; const TABLE_ACTION_BUTTON_SECONDARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-200 !bg-white !text-zinc-500 hover:!border-zinc-300 hover:!bg-zinc-50 hover:!text-zinc-950 focus:!border-zinc-400 disabled:!border-zinc-100 disabled:!bg-zinc-50 disabled:!text-zinc-300`; const TABLE_ACTION_BUTTON_PRIMARY = `${TABLE_ACTION_BUTTON_BASE} !border-zinc-950 !bg-zinc-950 !text-white hover:!border-zinc-800 hover:!bg-zinc-800 focus:!border-zinc-600 disabled:!border-zinc-200 disabled:!bg-zinc-200 disabled:!text-zinc-400`; const INTAKE_PRIMARY_BUTTON_CLASS = @@ -622,6 +639,50 @@ export function NonIntegratedPendingDetail({ } } + async function handleAbandon(product: NonIntegratedProductRecord) { + const productLabel = product.spu?.trim() || "当前产品"; + + if ( + !window.confirm( + `确认废弃「${productLabel}」吗?\n废弃后资料将变为只读,不能继续编辑,且不再触发每日推送提醒。`, + ) + ) { + return; + } + + try { + if (shouldUseSharedNonIntegratedApi) { + const response = await fetch( + `/api/intake/non-integrated/${encodeURIComponent(product.id)}/abandon`, + { method: "POST", credentials: "same-origin" }, + ); + const payload = (await response.json().catch(() => null)) as { + success?: boolean; + error?: { message?: string }; + } | null; + if (!response.ok || !payload?.success) { + throw new Error(payload?.error?.message || "废弃失败,请稍后再试"); + } + } else { + saveNonIntegratedProduct( + createAbandonedNonIntegratedProduct(product), + ); + } + + const nextProducts = products.map((currentProduct) => + currentProduct.id === product.id + ? { ...currentProduct, projectStatus: "rejected" } + : currentProduct, + ) as NonIntegratedProductRecord[]; + setProducts(nextProducts); + onProductsChange?.(nextProducts); + } catch (error) { + window.alert( + error instanceof Error ? error.message : "废弃失败,请稍后再试", + ); + } + } + function getAvailableCopyBrands(product: NonIntegratedProductRecord) { const configuredBrands = Array.from( new Set([ @@ -1439,18 +1500,18 @@ export function NonIntegratedPendingDetail({ > - + 序号 - + 成品预览图 {isSeriesDevelopmentList ? ( - + 系列名称 ) : null} - + {showOpeningProductName ? ( @@ -1525,6 +1586,15 @@ export function NonIntegratedPendingDetail({ getDocumentWorkspaceCountByCategory(product); const isFlowAbandoned = product.projectStatus === "rejected"; + const canAbandonCurrentRow = ( + row: NonIntegratedProductRecord, + ) => + row.projectStatus !== "rejected" && + (isAdminUser(currentUser) || + isCurrentUserResponsibleForNonIntegratedProduct( + row, + currentUser, + )); const people = getNonIntegratedListPeople(product); const canEditProduct = canEditNonIntegratedProduct( product, @@ -1553,12 +1623,12 @@ export function NonIntegratedPendingDetail({ key={product.id} className="border-b border-apple-separator" > - + {pageInfo?.sequenceById?.[product.id] ?? orderedFilteredProducts.sequenceById[product.id] ?? (safeCurrentPage - 1) * PENDING_PAGE_SIZE + index + 1} - + {productPreviewImage ? ( {isSeriesDevelopmentList ? ( - + {seriesDisplayName} ) : null} - + ) : null} + {!isFlowAbandoned && + canEditProduct && + canAbandonCurrentRow(product) ? ( + + ) : null} {isFlowAbandoned && canEditProduct ? ( - )} + {row.password || (row.passwordConfigured ? "••••••" : "未设置")} ) : field.key === "boundBankAccountNumber" ? ( - {revealedBankAccounts[row.id] !== undefined - ? revealedBankAccounts[row.id] || "未设置" - : row.boundBankAccountNumberConfigured + {row.boundBankAccountNumber || + (row.boundBankAccountNumberConfigured ? "••••••" - : "未设置"} - {row.boundBankAccountNumberConfigured && ( - - )} + : "未设置")} ) : field.key === "createdAt" || field.key === "updatedAt" ? ( diff --git a/furniture-3d-system/src/lib/store-account-management.ts b/furniture-3d-system/src/lib/store-account-management.ts index 87a11cab..84232deb 100644 --- a/furniture-3d-system/src/lib/store-account-management.ts +++ b/furniture-3d-system/src/lib/store-account-management.ts @@ -193,8 +193,10 @@ export type StoreAccountRow = { subject: string; accountType: StoreAccountType; account: string; + password: string; passwordConfigured: boolean; canRevealPassword: boolean; + boundBankAccountNumber: string; boundBankAccountNumberConfigured: boolean; boundPhone: string; verificationContact: string; diff --git a/furniture-3d-system/src/server/services/store-account-management.service.ts b/furniture-3d-system/src/server/services/store-account-management.service.ts index ac1d3a1c..25e5ebb7 100644 --- a/furniture-3d-system/src/server/services/store-account-management.service.ts +++ b/furniture-3d-system/src/server/services/store-account-management.service.ts @@ -897,6 +897,14 @@ export class StoreAccountManagementService { }); } + private tryDecrypt(value: string) { + try { + return decryptStoreAccountPassword(value); + } catch { + return ""; + } + } + private toRow( record: StoreAccountRecord, access: StoreAccountFieldAccess, @@ -904,6 +912,16 @@ export class StoreAccountManagementService { ) { const value = (key: StoreAccountFieldKey, fallback = "") => access[key] === "HIDDEN" ? "" : fallback; + const canSeePassword = + Boolean(record.passwordCiphertext) && + canRevealStoreAccountPassword({ + scope: passwordScope, + storeName: record.storeName, + defaultAllowed: access.password !== "HIDDEN", + }); + const canSeeBankAccount = + access.boundBankAccountNumber !== "HIDDEN" && + Boolean(record.boundBankAccountNumberCiphertext); return { id: record.id, storeName: value("storeName", record.storeName), @@ -917,18 +935,16 @@ export class StoreAccountManagementService { subject: value("subject", record.subject), accountType: value("accountType", record.accountType) as StoreAccountType, account: value("account", record.account), + password: canSeePassword + ? this.tryDecrypt(record.passwordCiphertext) + : "", passwordConfigured: access.password !== "HIDDEN" && Boolean(record.passwordCiphertext), - canRevealPassword: - Boolean(record.passwordCiphertext) && - canRevealStoreAccountPassword({ - scope: passwordScope, - storeName: record.storeName, - defaultAllowed: access.password !== "HIDDEN", - }), - boundBankAccountNumberConfigured: - access.boundBankAccountNumber !== "HIDDEN" && - Boolean(record.boundBankAccountNumberCiphertext), + canRevealPassword: canSeePassword, + boundBankAccountNumber: canSeeBankAccount + ? this.tryDecrypt(record.boundBankAccountNumberCiphertext) + : "", + boundBankAccountNumberConfigured: canSeeBankAccount, boundPhone: value("boundPhone", record.boundPhone), verificationContact: value( "verificationContact", -- 2.47.2 From 4deea6518a6552b6b6570bde36ce85c188c3354a Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 8 Sep 2026 14:43:37 +0800 Subject: [PATCH 5/9] feat(product-archive): add select-all to header filter option lists --- .../ProductArchiveFilterOptionList.tsx | 27 ++++++++++++++++--- .../ProductArchiveHeaderFilter.tsx | 3 +++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveFilterOptionList.tsx b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveFilterOptionList.tsx index f5c2718a..374e2274 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveFilterOptionList.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveFilterOptionList.tsx @@ -16,12 +16,14 @@ export function ProductArchiveFilterOptionList({ filters, selectedValues, onToggle, + onSelectAll, }: { field: ProductArchiveFilterOptionKey placeholder: string filters: ProductArchiveFilters selectedValues: string[] onToggle: (value: string) => void + onSelectAll: (values: string[]) => void }) { const requestIdRef = useRef(0) const [searchText, setSearchText] = useState('') @@ -86,9 +88,18 @@ export function ProductArchiveFilterOptionList({ )} - {selectedValues.length > 0 && ( -
已选 {selectedValues.length} 项
- )} +
+ {selectedValues.length > 0 ? `已选 ${selectedValues.length} 项` : `共 ${visibleOptions.length} 项`} + {visibleOptions.length > 0 && ( + + )} +
{ @@ -132,7 +143,15 @@ export function ProductArchiveFilterOptionList({ display: inline-flex; align-items: center; justify-content: center; padding: 0; border: none; background: transparent; color: #a1a1aa; cursor: pointer; } - .archive-header-filter-selected-summary { padding: 8px 3px 5px; color: #52525b; font-size: 12px; font-weight: 600; } + .archive-header-filter-selected-summary { + display: flex; align-items: center; justify-content: space-between; gap: 6px; + padding: 8px 3px 5px; color: #52525b; font-size: 12px; font-weight: 600; + } + .archive-header-filter-select-all { + padding: 0; border: none; background: transparent; color: #2563eb; + font-size: 12px; font-weight: 600; cursor: pointer; + } + .archive-header-filter-select-all:hover { text-decoration: underline; } .archive-header-filter-options { display: flex; max-height: 254px; flex-direction: column; gap: 2px; margin-top: 8px; overflow-y: auto; } diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveHeaderFilter.tsx b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveHeaderFilter.tsx index b8097fad..3b56efb3 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveHeaderFilter.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveHeaderFilter.tsx @@ -164,6 +164,9 @@ export function ProductArchiveHeaderFilter({ filters={filters} selectedValues={selectedValues} onToggle={toggleOption} + onSelectAll={(values) => { + setSelectedValues((current) => [...new Set([...current, ...values])]) + }} /> )} -- 2.47.2 From 811926e67b7e3e1937984a3bc752e4e8d2152d97 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 8 Sep 2026 15:06:43 +0800 Subject: [PATCH 6/9] feat(store-accounts): store password and bank account as plaintext --- .../finance-store-account-password-reveal.md | 2 + ...igrate-store-account-plaintext-20260908.js | 123 ++++++++++++++++++ .../src/lib/store-account-crypto.ts | 20 ++- ...re-account-password-reveal.service.test.ts | 2 +- 4 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 furniture-3d-system/scripts/migrate-store-account-plaintext-20260908.js diff --git a/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md b/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md index 2371e4e7..78f308e2 100644 --- a/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md +++ b/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md @@ -2,6 +2,8 @@ 状态:代码和本地测试交付;生产迁移、配置、发布及真实接口验收须单独批准。 +> 2026-09-08 存储变更:按管理层决定,店铺账号密码/银行卡号取消数据库加密,改为明文落库;存量 `v1:` 密文已由 `scripts/migrate-store-account-plaintext-20260908.js` 迁移。本契约的读取链路兼容明文与历史密文,认证、审计、限流要求不变;下文"存储事实与授权边界"中的加密描述保留为历史设计记录。 + ## 存储事实与授权边界 - 密码真相源是 `public."StoreAccount"."passwordCiphertext"`,使用现有 `src/lib/store-account-crypto.ts` 的 AES-256-GCM(随机 12 字节 IV、认证标签、`v1:` 封装)。不是明文或不可逆 Hash。 diff --git a/furniture-3d-system/scripts/migrate-store-account-plaintext-20260908.js b/furniture-3d-system/scripts/migrate-store-account-plaintext-20260908.js new file mode 100644 index 00000000..12961a21 --- /dev/null +++ b/furniture-3d-system/scripts/migrate-store-account-plaintext-20260908.js @@ -0,0 +1,123 @@ +/** + * 一次性迁移:把 StoreAccount 的历史 v1: 密文解密改写为明文。 + * + * 背景:2026-09-08 按管理层决定,店铺账号密码/银行卡号取消数据库加密存储。 + * 应用侧读取已兼容两种格式,本脚本负责把存量密文统一改写为明文。 + * 幂等:只处理 LIKE 'v1:%' 的行;先全部解密成功后再写入,任一失败则整批不落库。 + * + * 用法(在应用根目录): + * node scripts/migrate-store-account-plaintext-20260908.js + * 环境变量:DATABASE_URL(缺省时依次读取 .env.local / .env), + * STORE_ACCOUNT_ENCRYPTION_KEY(生产必填,本地回退 JWT_SECRET)。 + */ +const fs = require('fs') +const path = require('path') +const { createDecipheriv, createHash } = require('node:crypto') + +const PREFIX = 'v1:' + +function loadEnvFile(repoRoot) { + for (const name of ['.env.local', '.env']) { + const file = path.join(repoRoot, name) + if (!fs.existsSync(file)) continue + for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { + const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/) + if (m && !(m[1] in process.env)) { + process.env[m[1]] = m[2].replace(/^["']|["']$/g, '') + } + } + } +} + +function getKey() { + const secret = (process.env.STORE_ACCOUNT_ENCRYPTION_KEY || '').trim() + || (process.env.NODE_ENV === 'production' ? '' : (process.env.JWT_SECRET || '').trim()) + if (!secret || secret.length < 32) { + throw new Error('缺少可用的解密密钥:生产需 STORE_ACCOUNT_ENCRYPTION_KEY,本地回退 JWT_SECRET(至少 32 字符)') + } + return createHash('sha256').update(secret).digest() +} + +function decryptLegacy(value) { + if (!value.startsWith(PREFIX)) return value + const [ivPart, tagPart, dataPart] = value.slice(PREFIX.length).split('.') + if (!ivPart || !tagPart || !dataPart) throw new Error('密文格式不合法') + const decipher = createDecipheriv('aes-256-gcm', getKey(), Buffer.from(ivPart, 'base64url')) + decipher.setAuthTag(Buffer.from(tagPart, 'base64url')) + return Buffer.concat([decipher.update(Buffer.from(dataPart, 'base64url')), decipher.final()]).toString('utf8') +} + +async function main() { + const repoRoot = path.resolve(__dirname, '..') + loadEnvFile(repoRoot) + const connectionString = process.env.DATABASE_URL + if (!connectionString) throw new Error('缺少 DATABASE_URL') + + const { Client } = require('pg') + const client = new Client({ connectionString }) + await client.connect() + + try { + const { rows } = await client.query( + "SELECT id, \"storeName\", \"passwordCiphertext\", \"boundBankAccountNumberCiphertext\"" + + " FROM \"StoreAccount\" WHERE \"passwordCiphertext\" LIKE 'v1:%' OR \"boundBankAccountNumberCiphertext\" LIKE 'v1:%'" + ) + if (rows.length === 0) { + console.log('没有需要迁移的 v1: 密文行,完成。') + return + } + + const failures = [] + const updates = rows.map((row) => { + try { + return { + id: row.id, + storeName: row.storeName, + password: row.passwordCiphertext && row.passwordCiphertext.startsWith(PREFIX) + ? decryptLegacy(row.passwordCiphertext) + : row.passwordCiphertext, + bank: row.boundBankAccountNumberCiphertext && row.boundBankAccountNumberCiphertext.startsWith(PREFIX) + ? decryptLegacy(row.boundBankAccountNumberCiphertext) + : row.boundBankAccountNumberCiphertext, + } + } catch (e) { + failures.push({ id: row.id, storeName: row.storeName, reason: e.message }) + return null + } + }).filter(Boolean) + + if (failures.length > 0) { + console.error('以下行解密失败,本次不写入任何数据:') + for (const f of failures) console.error(' - ' + f.id + ' ' + f.storeName + ':' + f.reason) + process.exitCode = 1 + return + } + + await client.query('BEGIN') + for (const u of updates) { + await client.query( + 'UPDATE "StoreAccount" SET "passwordCiphertext" = $2, "boundBankAccountNumberCiphertext" = $3 WHERE id = $1', + [u.id, u.password, u.bank] + ) + } + const after = await client.query( + "SELECT COUNT(*)::int AS n FROM \"StoreAccount\" WHERE \"passwordCiphertext\" LIKE 'v1:%' OR \"boundBankAccountNumberCiphertext\" LIKE 'v1:%'" + ) + if (after.rows[0].n !== 0) { + throw new Error('迁移后仍存在 v1: 行,回滚') + } + await client.query('COMMIT') + + const verify = await client.query( + "SELECT COUNT(*)::int AS n FROM \"StoreAccount\" WHERE (\"passwordCiphertext\" <> '' AND \"passwordCiphertext\" NOT LIKE 'v1:%')" + ) + console.log('迁移完成:改写行数 ' + updates.length + ',剩余 v1: 行 ' + after.rows[0].n + ',当前非空明文密码行 ' + verify.rows[0].n + '。') + } finally { + await client.end() + } +} + +main().catch((e) => { + console.error('迁移失败:' + e.message) + process.exit(1) +}) diff --git a/furniture-3d-system/src/lib/store-account-crypto.ts b/furniture-3d-system/src/lib/store-account-crypto.ts index 2140e047..65273121 100644 --- a/furniture-3d-system/src/lib/store-account-crypto.ts +++ b/furniture-3d-system/src/lib/store-account-crypto.ts @@ -1,9 +1,11 @@ import 'server-only' -import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' +import { createDecipheriv, createHash } from 'node:crypto' -const PREFIX = 'v1:' +// 2026-09-08 按管理层决定,店铺账号密码与银行卡号改为明文落库: +// 写入不再加密;读取对历史 v1: 密文行继续解密,其余值原样返回。 +const LEGACY_PREFIX = 'v1:' -function getKey() { +function getLegacyKey() { const secret = process.env.STORE_ACCOUNT_ENCRYPTION_KEY?.trim() || (process.env.NODE_ENV === 'production' ? '' : process.env.JWT_SECRET?.trim()) if (!secret || secret.length < 32) { @@ -14,19 +16,15 @@ function getKey() { export function encryptStoreAccountPassword(value: string) { if (!value) return '' - const iv = randomBytes(12) - const cipher = createCipheriv('aes-256-gcm', getKey(), iv) - const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]) - const tag = cipher.getAuthTag() - return `${PREFIX}${iv.toString('base64url')}.${tag.toString('base64url')}.${ciphertext.toString('base64url')}` + return value } export function decryptStoreAccountPassword(value: string) { if (!value) return '' - if (!value.startsWith(PREFIX)) throw new Error('Unsupported store account password format') - const [ivPart, tagPart, dataPart] = value.slice(PREFIX.length).split('.') + if (!value.startsWith(LEGACY_PREFIX)) return value + const [ivPart, tagPart, dataPart] = value.slice(LEGACY_PREFIX.length).split('.') if (!ivPart || !tagPart || !dataPart) throw new Error('Invalid store account password') - const decipher = createDecipheriv('aes-256-gcm', getKey(), Buffer.from(ivPart, 'base64url')) + const decipher = createDecipheriv('aes-256-gcm', getLegacyKey(), Buffer.from(ivPart, 'base64url')) decipher.setAuthTag(Buffer.from(tagPart, 'base64url')) return Buffer.concat([decipher.update(Buffer.from(dataPart, 'base64url')), decipher.final()]).toString('utf8') } diff --git a/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts b/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts index 58f6c8d0..353aa1c3 100644 --- a/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts +++ b/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts @@ -102,7 +102,7 @@ describe('finance password reveal security boundary', () => { }) it('records decryption failure without including the exception or credential', async () => { - tx.findPassword.mockResolvedValue('invalid-envelope') + tx.findPassword.mockResolvedValue('v1:invalid-envelope') const result = await service.reveal(request()) expect(result.status).toBe(503) expect(events[0].status).toBe(503) -- 2.47.2 From caa960d70ac984a3b9e6886a84f9c90f8bcad2a9 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 8 Sep 2026 17:19:01 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=B4=A2=E5=8A=A1?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E9=A1=B5=E6=89=B9=E9=87=8F=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=E5=BA=97=E9=93=BA=E5=AF=86=E7=A0=81=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../finance-store-account-password-reveal.md | 30 ++++++++-- .../[id]/password/reveal/route.test.ts | 26 ++++++++- .../[id]/password/reveal/route.ts | 38 ++++++++---- ...tore-account-password-reveal.repository.ts | 41 ++++++++++++- .../store-account-password-reveal.contract.ts | 14 +++++ ...re-account-password-reveal.service.test.ts | 46 +++++++++++++++ .../store-account-password-reveal.service.ts | 58 ++++++++++++++++++- 7 files changed, 232 insertions(+), 21 deletions(-) diff --git a/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md b/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md index 78f308e2..6c2f3862 100644 --- a/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md +++ b/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md @@ -1,4 +1,4 @@ -# 财务系统店铺密码按需读取契约 +# 财务系统店铺密码直接显示契约 状态:代码和本地测试交付;生产迁移、配置、发布及真实接口验收须单独批准。 @@ -11,7 +11,7 @@ - 2026-09-08 对 `furniture3d_prod` 的只读核验:`accounts_v1` COUNT=958、DISTINCT id=958;957 条非空密文均可在生产主机内存中解密,空值 1、解密失败 0。过程只输出聚合,不导出密码或密文。 - 普通 `store_account_reader` 可读视图、不能读取基表或密码列。此次迁移不增加其基表权限。 - 本接口只信任专用服务身份 `finance-store-accounts`。财务系统必须在自身后端校验用户登录态、查看/复制权限及店铺范围,然后从可信会话中取 `requesterId`。不得把客户端传入的任意人员 ID 当成操作者。服务凭据仅保存在财务后端,禁止交给浏览器。 -- 不提供列表、批量密码导出或自动预取。列表只读取 `passwordConfigured`;每次明确的查看/复制操作单独调用接口。上游审计记录的用户 ID 是专用财务服务的身份断言,未授权请求中的 ID 不可信,`serviceId=null`。 +- 不提供密码列表或批量导出。财务页面可在已通过自身页面权限校验后,自动读取当前可见页最多 50 个账号;CSV 和共享镜像仍只读取 `passwordConfigured`。复制操作继续走单账号接口。上游审计记录的用户 ID 是专用财务服务的身份断言,未授权请求中的 ID 不可信,`serviceId=null`。 ## HTTP 契约 @@ -19,6 +19,8 @@ `POST https://cp.jiansun.vip/internal/store-accounts/{id}/password/reveal` +`POST https://cp.jiansun.vip/internal/store-accounts/visible-page/password/reveal` + `id` 为 `accounts_v1.id`,仅接受单个 1–128 位字母、数字、下划线或连字符。无查询参数;禁止在 URL、请求体、请求头中传待读取密码。 请求头: @@ -36,12 +38,30 @@ Content-Type: application/json `purpose` 仅允许 `view` 或 `copy`。`requesterId` 是财务会话中的稳定人员标识,1–128 位 `[A-Za-z0-9_.:@-]`。不接受批量 ID、查询参数、普通登录 Cookie/JWT、普通数据库凭据或其他内部任务密钥。 +当前页批量请求严格只接受以下字段,正文最大 8192 字节;`accountIds` 为 1 至 50 个互不重复且符合单账号 ID 规则的值: + +```json +{ "requesterId": "finance-user-123", "purpose": "view", "accountIds": ["account-1", "account-2"] } +``` + 成功 `200`: ```json { "requestId": "<服务端生成的 UUID>", "password": "<仅本次响应体中的密码>" } ``` +当前页批量成功的 HTTP 状态为 `200`,每个账号分别返回 `200/404/429/503`。每项都有独立 UUID;只有状态 `200` 的项包含密码,并按请求顺序完整返回: + +```json +{ + "requestId": "<批次 UUID>", + "items": [ + { "accountId": "account-1", "requestId": "<项目 UUID>", "status": 200, "password": "<密码>" }, + { "accountId": "account-2", "requestId": "<项目 UUID>", "status": 404 } + ] +} +``` + 所有状态均返回 `Cache-Control: no-store`、`Pragma: no-cache`、`X-Content-Type-Options: nosniff`。`X-Request-Id` 与响应体、审计的追踪 ID 相同。不会在响应头中放密码。 错误体: @@ -61,7 +81,7 @@ Content-Type: application/json ## 限流和超时 -- 数据库滚动窗口:服务全部账号 30 次/分钟、300 次/24 小时;同一人员 10 次/分钟;同一账号 5 次/分钟。查看和复制共享额度,轮换令牌不重置服务身份或额度。 +- 单账号数据库滚动窗口:服务全部账号 30 次/分钟、300 次/24 小时;同一人员 10 次/分钟;同一账号 5 次/分钟。当前页批次另按批次标记限制为服务 10 次/分钟、500 次/24 小时、同一人员 5 次/分钟;批次中的每个账号仍受同一账号 5 次/分钟限制。查看和复制共享账号额度,轮换令牌不重置服务身份或额度。 - 已认证的成功、404、参数拒绝和解密失败均占额度;429 不延长窗口。额度检查与审计在同一数据库事务中用事务级锁串行化,跨进程/重启共享状态。 - 429 不携带剩余数量或精确窗口,避免推测其他用户行为。客户端不得自动重试密码读取;提示用户稍后再试,持续超限联系管理员。 - 财务客户端设置总超时 10 秒、禁用自动重定向和重试。后端池上限 4、连接获取 1 秒、SQL 执行 1.5 秒/客户端查询 2 秒、锁等待 1 秒,路由运行预算 10 秒。网关必须配置请求体 1 秒和总请求截止,防止慢请求占用。 @@ -77,6 +97,7 @@ Content-Type: application/json | STORE_ACCOUNT_REVEAL_EDGE_TOKEN | 上游与可信 TLS 网关之间的独立随机凭据,base64url 43–128 字符;与财务令牌完全分离,不提供给财务系统;缺失时拒绝全部调用 | | DATABASE_URL | 上游现有数据库连接;仅专用不输出查询/异常的连接池读取密文、写审计 | | STORE_ACCOUNT_REVEAL_URL | 财务后端配置完整 HTTPS 地址模板 | +| STORE_ACCOUNT_REVEAL_VISIBLE_PAGE_URL | 财务后端配置当前页批量 HTTPS 地址;必须使用精确 `visible-page` 路径 | | STORE_ACCOUNT_REVEAL_TOKEN | 财务后端保存与上游专用令牌对应的值 | | STORE_REVEAL_TEST_ADMIN_URL | 仅本地集成测试;必须指向回环地址、有建测试库/角色权限的 PostgreSQL | | STORE_REVEAL_TEST_POSTGRES_CONTAINER | 仅本地日志验收,指定本地 PostgreSQL 容器以只读扫描测试期间日志 | @@ -88,7 +109,7 @@ Content-Type: application/json 3. 对精确路径 `/internal/store-accounts/*/password/reveal` 禁止网关缓存、响应落盘缓冲、CDN缓存、请求/响应体及 Authorization 采集;包括 APM、WAF、异常上报、链路追踪和调试抓包。网关日志只允许时间、方法、路径(不带参数)、状态、耗时、追踪 ID。不要采集响应对象。 4. 网关自己的 403/404/429/503 也使用脱敏 JSON、`Cache-Control: no-store` 和追踪 ID;不得返回上游诊断页。禁止将该路由接入通用 `withApi`/request logger;目前路由直接返回白名单响应,不记录异常对象。 5. 数据库不执行明文解密,SQL 参数不包含明文密码。现有密码写入或密钥轮换时,关闭 PostgreSQL/pgAudit 的绑定参数及错误参数记录,防止记录密文。不要启用该连接池的 SQL 结果或参数追踪。 -6. 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器仅在用户点击后获取,避免 localStorage/sessionStorage/Service Worker 持久化;显示或复制完清除页面内存状态,不把密码放入通知、埋点或消息队列。 +6. 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器在进入已授权页面后只读取当前可见页,避免 localStorage/sessionStorage/Service Worker 持久化;页面隐藏或离开时清除页面密码,不把密码放入通知、埋点或消息队列。 ## 视图与审计迁移 @@ -121,6 +142,7 @@ Content-Type: application/json ## 本次验证记录 +- 当前页批量扩展的服务、HTTP 路由和代理边界共 3 个测试套件、41 项测试通过;`npm run type-check` 通过。测试覆盖 1–50 个账号、重复账号拒绝、部分 404/429/503、批次与每账号审计,以及失败时不返回明文。 - 单元/HTTP/代理路由测试:34 项通过,含伪造 HTTPS URL/转发头和错误网关凭据拒绝。 - 追加现有代理路由的 7 项回归,与 10 项集成测试合计 6 套件、51 项全部通过。 - 隔离本地 PostgreSQL 集成测试:10 项通过,覆盖完整加解密与 HTTP handler、已提交审计、403/404/429/no-store、并发/跨实例限流、分钟/24小时配额、审计失败拒绝泄露、原字段及筛选保留、958/958 与 boolean 957/1/0、普通只读角色拒绝基表/密码/审计读取。 diff --git a/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.test.ts b/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.test.ts index 597a81b8..20dbfcef 100644 --- a/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.test.ts +++ b/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.test.ts @@ -1,11 +1,15 @@ /** @jest-environment node */ -import { randomBytes } from 'node:crypto' +import { randomBytes, randomUUID } from 'node:crypto' import { POST, GET } from './route' jest.mock('server-only', () => ({})) const mockReveal = jest.fn() +const mockRevealVisiblePage = jest.fn() jest.mock('@/server/services/store-account-password-reveal.service', () => ({ - StoreAccountPasswordRevealService: jest.fn().mockImplementation(() => ({ reveal: (...args: unknown[]) => mockReveal(...args) })), + StoreAccountPasswordRevealService: jest.fn().mockImplementation(() => ({ + reveal: (...args: unknown[]) => mockReveal(...args), + revealVisiblePage: (...args: unknown[]) => mockRevealVisiblePage(...args), + })), })) const path = 'https://example.test/internal/store-accounts/test-id/password/reveal' @@ -15,7 +19,7 @@ function request(url = path, body = JSON.stringify({ requesterId: 'u', purpose: } describe('secret HTTP response boundary', () => { - beforeEach(() => { mockReveal.mockReset(); delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN }) + beforeEach(() => { mockReveal.mockReset(); mockRevealVisiblePage.mockReset(); delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN }) afterAll(() => { delete process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN }) it.each([200, 403, 404, 429, 503])('sets no-store for status %i and places no credential in headers', async status => { @@ -85,4 +89,20 @@ describe('secret HTTP response boundary', () => { await POST(request(path, body), context) expect(mockReveal.mock.calls[0][0].body).toBeNull() }) + + it('routes the reserved visible-page id to the bounded batch service without exposing passwords in headers', async () => { + const password = randomBytes(24).toString('base64url') + mockRevealVisiblePage.mockResolvedValue({ status: 200, items: [ + { accountId: 'account-1', requestId: randomUUID(), status: 200, password }, + ] }) + const visible = request(path.replace('test-id', 'visible-page'), JSON.stringify({ + requesterId: 'finance-user-1', purpose: 'view', accountIds: ['account-1'], + })) + const response = await POST(visible, { params: Promise.resolve({ id: 'visible-page' }) }) + expect(response.status).toBe(200) + expect(mockReveal).not.toHaveBeenCalled() + expect(mockRevealVisiblePage).toHaveBeenCalledWith(expect.objectContaining({ post: true })) + expect(JSON.stringify([...response.headers]).includes(password)).toBe(false) + expect((await response.json()).items[0].password === password).toBe(true) + }) }) diff --git a/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.ts b/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.ts index 4835b803..201d03ac 100644 --- a/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.ts +++ b/furniture-3d-system/src/app/internal/store-accounts/[id]/password/reveal/route.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto' import { StoreAccountPasswordRevealService } from '@/server/services/store-account-password-reveal.service' import { hasTrustedRevealTransport } from '@/server/services/store-account-password-reveal-transport' +import { VISIBLE_PAGE_ACCOUNT_ID } from '@/server/services/store-account-password-reveal.contract' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -13,7 +14,7 @@ const errors = { 503: ['UNAVAILABLE', '服务暂不可用'], } as const -async function readBody(request: Request): Promise { +async function readBody(request: Request, maxBytes = 1024): Promise { if (request.method !== 'POST' || !/^application\/json(?:\s*;|$)/i.test(request.headers.get('content-type') || '')) return null const reader = request.body?.getReader() if (!reader) return null @@ -29,7 +30,7 @@ async function readBody(request: Request): Promise { const { done, value } = await reader.read() if (done) break size += value.byteLength - if (size > 1024) { await reader.cancel(); return null } + if (size > maxBytes) { await reader.cancel(); return null } chunks.push(value) } return timedOut ? null : JSON.parse(Buffer.concat(chunks).toString('utf8')) @@ -40,16 +41,25 @@ export async function POST(request: Request, context: { params: Promise<{ id: st const requestId = randomUUID() const url = new URL(request.url) const secure = hasTrustedRevealTransport(request.headers) - const result = await service.reveal({ - accountId: (await context.params).id, body: await readBody(request), - authorization: request.headers.get('authorization'), + const accountId = (await context.params).id + const body = await readBody(request, accountId === VISIBLE_PAGE_ACCOUNT_ID ? 8192 : 1024) + const common = { + body, authorization: request.headers.get('authorization'), secure: secure && !url.search, post: request.method === 'POST', requestId, - }) - const headers = { - 'Cache-Control': 'no-store', 'Pragma': 'no-cache', - 'X-Content-Type-Options': 'nosniff', 'X-Request-Id': requestId, - 'Strict-Transport-Security': 'max-age=31536000', } + if (accountId === VISIBLE_PAGE_ACCOUNT_ID) { + const result = await service.revealVisiblePage(common) + const headers = responseHeaders(requestId) + if (result.status === 200) { + return Response.json({ requestId, items: result.items }, { status: 200, headers }) + } + const [code, message] = errors[result.status as keyof typeof errors] + return Response.json({ requestId, error: { code, message } }, { status: result.status, headers }) + } + const result = await service.reveal({ + accountId, ...common, + }) + const headers = responseHeaders(requestId) if (result.status === 200) { return Response.json({ requestId, password: result.password }, { status: 200, headers }) } @@ -57,6 +67,14 @@ export async function POST(request: Request, context: { params: Promise<{ id: st return Response.json({ requestId, error: { code, message } }, { status: result.status, headers }) } +function responseHeaders(requestId: string) { + return { + 'Cache-Control': 'no-store', 'Pragma': 'no-cache', + 'X-Content-Type-Options': 'nosniff', 'X-Request-Id': requestId, + 'Strict-Transport-Security': 'max-age=31536000', + } +} + export const GET = POST export const PUT = POST export const PATCH = POST diff --git a/furniture-3d-system/src/server/repositories/store-account-password-reveal.repository.ts b/furniture-3d-system/src/server/repositories/store-account-password-reveal.repository.ts index 8f54d054..cea7bf45 100644 --- a/furniture-3d-system/src/server/repositories/store-account-password-reveal.repository.ts +++ b/furniture-3d-system/src/server/repositories/store-account-password-reveal.repository.ts @@ -1,7 +1,8 @@ import 'server-only' import { Pool, type PoolClient } from 'pg' import { - REVEAL_LIMITS, + FINANCE_SERVICE_ID, REVEAL_LIMITS, + VISIBLE_PAGE_LIMITS, type RevealAudit, type RevealRepository, type RevealTransaction, } from '@/server/services/store-account-password-reveal.contract' @@ -41,6 +42,33 @@ class Transaction implements RevealTransaction { .some(key => counts[key] >= REVEAL_LIMITS[key]) } + async limitedVisiblePage(event: RevealAudit) { + await this.client.query('SELECT pg_advisory_xact_lock(1937010546, 1)') + const { rows: [counts] } = await this.client.query<{ + serviceMinute: number; serviceDay: number; requesterMinute: number + }>(`SELECT + count(*) FILTER (WHERE "createdAt" > clock_timestamp() - interval '1 minute')::int AS "serviceMinute", + count(*)::int AS "serviceDay", + count(*) FILTER (WHERE "createdAt" > clock_timestamp() - interval '1 minute' AND "requesterId" = $2)::int AS "requesterMinute" + FROM public."StoreAccountPasswordAccessAudit" + WHERE "serviceId" = $1 AND "accountId" IS NULL AND status <> 429 + AND "createdAt" > clock_timestamp() - interval '24 hours'`, + [event.serviceId, event.requesterId]) + return (Object.keys(VISIBLE_PAGE_LIMITS) as Array) + .some(key => counts[key] >= VISIBLE_PAGE_LIMITS[key]) + } + + async limitedAccounts(accountIds: string[]) { + const { rows } = await this.client.query<{ accountId: string }>(`SELECT "accountId" + FROM public."StoreAccountPasswordAccessAudit" + WHERE "serviceId" = $1 AND status <> 429 + AND "createdAt" > clock_timestamp() - interval '1 minute' + AND "accountId" = ANY($2::text[]) + GROUP BY "accountId" HAVING count(*) >= $3`, + [FINANCE_SERVICE_ID, accountIds, REVEAL_LIMITS.accountMinute]) + return new Set(rows.map(row => row.accountId)) + } + async findPassword(accountId: string) { const { rows } = await this.client.query<{ passwordCiphertext: string }>( 'SELECT "passwordCiphertext" FROM public."StoreAccount" WHERE id = $1', [accountId], @@ -48,6 +76,13 @@ class Transaction implements RevealTransaction { return rows[0]?.passwordCiphertext || null } + async findPasswords(accountIds: string[]) { + const { rows } = await this.client.query<{ id: string; passwordCiphertext: string }>( + 'SELECT id, "passwordCiphertext" FROM public."StoreAccount" WHERE id = ANY($1::text[])', [accountIds], + ) + return new Map(rows.filter(row => row.passwordCiphertext).map(row => [row.id, row.passwordCiphertext])) + } + async audit(event: RevealAudit) { // Explicit allowlist: never spread request, response, account record or exception. await this.client.query(`INSERT INTO public."StoreAccountPasswordAccessAudit" @@ -55,6 +90,10 @@ class Transaction implements RevealTransaction { VALUES ($1, $2, $3, $4, $5, $6)`, [event.requestId, event.requesterId, event.serviceId, event.accountId, event.purpose, event.status]) } + + async auditMany(events: RevealAudit[]) { + for (const event of events) await this.audit(event) + } } export class StoreAccountPasswordRevealRepository implements RevealRepository { diff --git a/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts b/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts index 28ee8baf..d8619e90 100644 --- a/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts +++ b/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts @@ -5,9 +5,17 @@ export const revealInputSchema = z.object({ purpose: z.enum(['view', 'copy']), }).strict() export const accountIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,128}$/) +export const revealVisiblePageInputSchema = z.object({ + requesterId: z.string().regex(/^[A-Za-z0-9_.:@-]{1,128}$/), + purpose: z.literal('view'), + accountIds: z.array(accountIdSchema).min(1).max(50) + .refine(ids => new Set(ids).size === ids.length), +}).strict() export const FINANCE_SERVICE_ID = 'finance-store-accounts' +export const VISIBLE_PAGE_ACCOUNT_ID = 'visible-page' export const REVEAL_LIMITS = { serviceMinute: 30, serviceDay: 300, requesterMinute: 10, accountMinute: 5 } as const +export const VISIBLE_PAGE_LIMITS = { serviceMinute: 10, serviceDay: 500, requesterMinute: 5 } as const export type RevealInput = z.infer export type RevealStatus = 200 | 403 | 404 | 429 | 503 export type RevealAudit = { @@ -19,10 +27,16 @@ export type RevealAudit = { status: RevealStatus } export type RevealResult = { status: RevealStatus; password?: string } +export type RevealVisiblePageItem = { accountId: string; requestId: string; status: RevealStatus; password?: string } +export type RevealVisiblePageResult = { status: RevealStatus; items?: RevealVisiblePageItem[] } export interface RevealTransaction { limited(audit: RevealAudit): Promise + limitedVisiblePage(audit: RevealAudit): Promise + limitedAccounts(accountIds: string[]): Promise> findPassword(accountId: string): Promise + findPasswords(accountIds: string[]): Promise> audit(event: RevealAudit): Promise + auditMany(events: RevealAudit[]): Promise } export interface RevealRepository { transaction(run: (tx: RevealTransaction) => Promise): Promise diff --git a/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts b/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts index 353aa1c3..1aacd475 100644 --- a/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts +++ b/furniture-3d-system/src/server/services/store-account-password-reveal.service.test.ts @@ -24,8 +24,14 @@ describe('finance password reveal security boundary', () => { events = [] tx = { limited: jest.fn().mockResolvedValue(false), + limitedVisiblePage: jest.fn().mockResolvedValue(false), + limitedAccounts: jest.fn().mockResolvedValue(new Set()), findPassword: jest.fn().mockResolvedValue(encryptStoreAccountPassword(syntheticPassword)), + findPasswords: jest.fn().mockResolvedValue(new Map([ + ['synthetic-account', encryptStoreAccountPassword(syntheticPassword)], + ])), audit: jest.fn(async event => { events.push(event) }), + auditMany: jest.fn(async batch => { events.push(...batch) }), } service = new StoreAccountPasswordRevealService({ transaction: run => run(tx) }) }) @@ -76,6 +82,46 @@ describe('finance password reveal security boundary', () => { expect((await service.reveal(request())).status).toBe(403) }) + it('returns up to one visible page with a batch marker and one password-free audit per account', async () => { + const result = await service.revealVisiblePage({ + body: { requesterId: 'finance-user-1', purpose: 'view', accountIds: ['synthetic-account'] }, + authorization: `Bearer ${token}`, secure: true, post: true, requestId: randomUUID(), + }) + expect(result.status).toBe(200) + expect(result.items).toHaveLength(1) + expect(result.items?.[0].password === syntheticPassword).toBe(true) + expect(tx.findPasswords).toHaveBeenCalledWith(['synthetic-account']) + expect(events).toHaveLength(2) + expect(events[0].accountId).toBeNull() + expect(events[1]).toMatchObject({ accountId: 'synthetic-account', status: 200 }) + expect(JSON.stringify(events).includes(syntheticPassword)).toBe(false) + }) + + it('returns per-account not-found and rate-limit states without exposing another account password', async () => { + tx.limitedAccounts.mockResolvedValue(new Set(['limited-account'])) + tx.findPasswords.mockResolvedValue(new Map()) + const result = await service.revealVisiblePage({ + body: { requesterId: 'finance-user-1', purpose: 'view', accountIds: ['limited-account', 'missing-account'] }, + authorization: `Bearer ${token}`, secure: true, post: true, requestId: randomUUID(), + }) + expect(result).toMatchObject({ status: 200, items: [ + { accountId: 'limited-account', status: 429 }, { accountId: 'missing-account', status: 404 }, + ] }) + expect(tx.findPasswords).toHaveBeenCalledWith(['missing-account']) + }) + + it.each([ + { accountIds: [] }, { accountIds: ['a', 'a'] }, { accountIds: ['../a'] }, + { accountIds: Array.from({ length: 51 }, (_, index) => `a-${index}`) }, + ])('rejects invalid visible-page batches without selecting credentials (%#)', async fields => { + const result = await service.revealVisiblePage({ + body: { requesterId: 'finance-user-1', purpose: 'view', ...fields }, + authorization: `Bearer ${token}`, secure: true, post: true, requestId: randomUUID(), + }) + expect(result.status).toBe(403) + expect(tx.findPasswords).not.toHaveBeenCalled() + }) + it.each([null, ''])('returns the same 404 for missing or empty passwords', async value => { tx.findPassword.mockResolvedValue(value) expect((await service.reveal(request())).status).toBe(404) diff --git a/furniture-3d-system/src/server/services/store-account-password-reveal.service.ts b/furniture-3d-system/src/server/services/store-account-password-reveal.service.ts index 7a455e0b..a4816372 100644 --- a/furniture-3d-system/src/server/services/store-account-password-reveal.service.ts +++ b/furniture-3d-system/src/server/services/store-account-password-reveal.service.ts @@ -1,10 +1,10 @@ import 'server-only' -import { createHash, timingSafeEqual } from 'node:crypto' +import { createHash, randomUUID, timingSafeEqual } from 'node:crypto' import { decryptStoreAccountPassword } from '@/lib/store-account-crypto' import { StoreAccountPasswordRevealRepository } from '@/server/repositories/store-account-password-reveal.repository' import { - accountIdSchema, FINANCE_SERVICE_ID, revealInputSchema, - type RevealAudit, type RevealRepository, type RevealResult, + accountIdSchema, FINANCE_SERVICE_ID, revealInputSchema, revealVisiblePageInputSchema, + type RevealAudit, type RevealRepository, type RevealResult, type RevealVisiblePageResult, } from './store-account-password-reveal.contract' function authenticate(authorization: string | null) { @@ -68,4 +68,56 @@ export class StoreAccountPasswordRevealService { return { status: 503 } } } + + async revealVisiblePage(input: { + body: unknown; authorization: string | null + secure: boolean; post: boolean; requestId: string + }): Promise { + const body = revealVisiblePageInputSchema.safeParse(input.body) + const authenticated = authenticate(input.authorization) + const batchEvent: RevealAudit = { + requestId: input.requestId, + requesterId: body.success ? body.data.requesterId : null, + serviceId: authenticated ? FINANCE_SERVICE_ID : null, + accountId: null, + purpose: body.success ? 'view' : 'unknown', status: 403, + } + try { + return await this.repository.transaction(async tx => { + let result: RevealVisiblePageResult = { status: 403 } + if (authenticated && await tx.limitedVisiblePage(batchEvent)) { + result = { status: 429 } + } else if (authenticated && input.secure && input.post && body.success) { + const limited = await tx.limitedAccounts(body.data.accountIds) + const allowed = body.data.accountIds.filter(id => !limited.has(id)) + const encrypted = await tx.findPasswords(allowed) + const items = body.data.accountIds.map(accountId => { + const requestId = randomUUID() + if (limited.has(accountId)) return { accountId, requestId, status: 429 as const } + const ciphertext = encrypted.get(accountId) + if (!ciphertext) return { accountId, requestId, status: 404 as const } + try { + const password = this.decrypt(ciphertext) + return password + ? { accountId, requestId, status: 200 as const, password } + : { accountId, requestId, status: 404 as const } + } catch { return { accountId, requestId, status: 503 as const } } + }) + await tx.auditMany([ + { ...batchEvent, status: 200 }, + ...items.map(item => ({ + requestId: item.requestId, requesterId: body.data.requesterId, + serviceId: FINANCE_SERVICE_ID, accountId: item.accountId, + purpose: 'view' as const, status: item.status, + })), + ]) + return { status: 200, items } + } + await tx.audit({ ...batchEvent, status: result.status }) + return result + }) + } catch { + return { status: 503 } + } + } } -- 2.47.2 From 2224112c29accd6deb748aaaced90d1383966d82 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 8 Sep 2026 18:11:34 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E4=BF=9D=E6=8C=81=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E9=A1=B5=E5=88=86=E5=9D=97=E8=AF=BB=E5=8F=96=E9=99=90=E6=B5=81?= =?UTF-8?q?=E9=A2=9D=E5=BA=A6=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integrations/finance-store-account-password-reveal.md | 2 +- .../services/store-account-password-reveal.contract.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md b/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md index 6c2f3862..b818a8d7 100644 --- a/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md +++ b/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md @@ -81,7 +81,7 @@ Content-Type: application/json ## 限流和超时 -- 单账号数据库滚动窗口:服务全部账号 30 次/分钟、300 次/24 小时;同一人员 10 次/分钟;同一账号 5 次/分钟。当前页批次另按批次标记限制为服务 10 次/分钟、500 次/24 小时、同一人员 5 次/分钟;批次中的每个账号仍受同一账号 5 次/分钟限制。查看和复制共享账号额度,轮换令牌不重置服务身份或额度。 +- 单账号数据库滚动窗口:服务全部账号 30 次/分钟、300 次/24 小时;同一人员 10 次/分钟;同一账号 5 次/分钟。公网 TLS 网关保留 1 KiB 敏感请求正文上限,财务端把 50 行当前页拆成两个最多 25 个账号的批次;批次标记因此按服务 20 次/分钟、1000 次/24 小时、同一人员 10 次/分钟限流,等价于原每页额度。批次中的每个账号仍受同一账号 5 次/分钟限制。查看和复制共享账号额度,轮换令牌不重置服务身份或额度。 - 已认证的成功、404、参数拒绝和解密失败均占额度;429 不延长窗口。额度检查与审计在同一数据库事务中用事务级锁串行化,跨进程/重启共享状态。 - 429 不携带剩余数量或精确窗口,避免推测其他用户行为。客户端不得自动重试密码读取;提示用户稍后再试,持续超限联系管理员。 - 财务客户端设置总超时 10 秒、禁用自动重定向和重试。后端池上限 4、连接获取 1 秒、SQL 执行 1.5 秒/客户端查询 2 秒、锁等待 1 秒,路由运行预算 10 秒。网关必须配置请求体 1 秒和总请求截止,防止慢请求占用。 diff --git a/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts b/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts index d8619e90..c173f992 100644 --- a/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts +++ b/furniture-3d-system/src/server/services/store-account-password-reveal.contract.ts @@ -15,7 +15,10 @@ export const revealVisiblePageInputSchema = z.object({ export const FINANCE_SERVICE_ID = 'finance-store-accounts' export const VISIBLE_PAGE_ACCOUNT_ID = 'visible-page' export const REVEAL_LIMITS = { serviceMinute: 30, serviceDay: 300, requesterMinute: 10, accountMinute: 5 } as const -export const VISIBLE_PAGE_LIMITS = { serviceMinute: 10, serviceDay: 500, requesterMinute: 5 } as const +// The public TLS gateway keeps request bodies below 1 KiB, so a 50-row page is +// sent as two 25-account chunks. These limits preserve the original per-page +// allowance after chunking while accountMinute still applies to every item. +export const VISIBLE_PAGE_LIMITS = { serviceMinute: 20, serviceDay: 1000, requesterMinute: 10 } as const export type RevealInput = z.infer export type RevealStatus = 200 | 403 | 404 | 429 | 503 export type RevealAudit = { -- 2.47.2 From 149cf46b63079ca5c6f4f8af911b15bd59cf2f9e Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 9 Sep 2026 11:01:42 +0800 Subject: [PATCH 9/9] fix(store-accounts): align password status with plaintext storage --- .../finance-store-account-password-reveal.md | 30 +++--- .../migration.sql | 92 +++++++++++++++++++ .../store-account-password-reveal.test.ts | 79 +++++++++++++--- 3 files changed, 171 insertions(+), 30 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260909100000_fix_store_account_password_configured_plaintext/migration.sql diff --git a/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md b/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md index b818a8d7..58c32a30 100644 --- a/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md +++ b/furniture-3d-system/docs/integrations/finance-store-account-password-reveal.md @@ -1,14 +1,12 @@ # 财务系统店铺密码直接显示契约 -状态:代码和本地测试交付;生产迁移、配置、发布及真实接口验收须单独批准。 - -> 2026-09-08 存储变更:按管理层决定,店铺账号密码/银行卡号取消数据库加密,改为明文落库;存量 `v1:` 密文已由 `scripts/migrate-store-account-plaintext-20260908.js` 迁移。本契约的读取链路兼容明文与历史密文,认证、审计、限流要求不变;下文"存储事实与授权边界"中的加密描述保留为历史设计记录。 +状态:生产接口已上线;2026-09-09 按当前明文存储规则修正 `passwordConfigured`。 ## 存储事实与授权边界 -- 密码真相源是 `public."StoreAccount"."passwordCiphertext"`,使用现有 `src/lib/store-account-crypto.ts` 的 AES-256-GCM(随机 12 字节 IV、认证标签、`v1:` 封装)。不是明文或不可逆 Hash。 -- 生产密钥来自 `STORE_ACCOUNT_ENCRYPTION_KEY`,不从数据库读取,不复用财务服务认证凭据。已有银行账号加密也使用此密钥,轮换时必须同时处理。 -- 2026-09-08 对 `furniture3d_prod` 的只读核验:`accounts_v1` COUNT=958、DISTINCT id=958;957 条非空密文均可在生产主机内存中解密,空值 1、解密失败 0。过程只输出聚合,不导出密码或密文。 +- 密码真相源是 `public."StoreAccount"."passwordCiphertext"`。字段名是历史遗留;当前规则将新值以明文保存,读取链路对历史 `v1:` AES-256-GCM 值保留兼容。 +- 存量 `v1:` 值已由 `scripts/migrate-store-account-plaintext-20260908.js` 迁移。`STORE_ACCOUNT_ENCRYPTION_KEY` 仅用于可能残留或恢复出的历史 `v1:` 值,不参与当前明文值读取。 +- `passwordConfigured` 只表示存储值是否非空,不校验字符集或密码强度;因此包含 `+`、`/`、`=` 等字符的密码也会正确返回 true。它不返回密码本身。 - 普通 `store_account_reader` 可读视图、不能读取基表或密码列。此次迁移不增加其基表权限。 - 本接口只信任专用服务身份 `finance-store-accounts`。财务系统必须在自身后端校验用户登录态、查看/复制权限及店铺范围,然后从可信会话中取 `requesterId`。不得把客户端传入的任意人员 ID 当成操作者。服务凭据仅保存在财务后端,禁止交给浏览器。 - 不提供密码列表或批量导出。财务页面可在已通过自身页面权限校验后,自动读取当前可见页最多 50 个账号;CSV 和共享镜像仍只读取 `passwordConfigured`。复制操作继续走单账号接口。上游审计记录的用户 ID 是专用财务服务的身份断言,未授权请求中的 ID 不可信,`serviceId=null`。 @@ -91,7 +89,7 @@ Content-Type: application/json | 名称 | 所在系统与要求 | |---|---| -| STORE_ACCOUNT_ENCRYPTION_KEY | 上游已有独立加密密钥,至少 32 字符;生产不回退到 JWT_SECRET | +| STORE_ACCOUNT_ENCRYPTION_KEY | 仅用于兼容历史 `v1:` 密文,至少 32 字符;生产不回退到 JWT_SECRET | | STORE_ACCOUNT_FINANCE_TOKEN | 上游专用服务令牌,密码学随机至少 32 字节,base64url 编码,43–128 字符;缺失时拒绝全部调用 | | STORE_ACCOUNT_FINANCE_TOKEN_PREVIOUS | 上游可选旧令牌,仅用于短时轮换;主令牌缺失时旧令牌也不生效 | | STORE_ACCOUNT_REVEAL_EDGE_TOKEN | 上游与可信 TLS 网关之间的独立随机凭据,base64url 43–128 字符;与财务令牌完全分离,不提供给财务系统;缺失时拒绝全部调用 | @@ -108,25 +106,25 @@ Content-Type: application/json 2. 应用不以 URL 或未经认证的转发头证明 TLS。必须由受控网关在实际 HTTPS 入口覆盖 `X-Forwarded-Proto: https` 及 `X-Store-Reveal-Edge-Token`(值来自独立 `STORE_ACCOUNT_REVEAL_EDGE_TOKEN`),剥除客户端原有同名头;HTTP 入口直接拒绝,不能注入上述凭据。应用对网关凭据做定时安全比较,缺失/错误或协议不是 https 时拒绝。凭据仅上游和网关持有,不提供给财务系统;配置文件 0600,不通过 `nginx -T` 等诊断输出展示。仍需封闭应用直连并逐跳验证 TLS,独立凭据不代替网络加密。 3. 对精确路径 `/internal/store-accounts/*/password/reveal` 禁止网关缓存、响应落盘缓冲、CDN缓存、请求/响应体及 Authorization 采集;包括 APM、WAF、异常上报、链路追踪和调试抓包。网关日志只允许时间、方法、路径(不带参数)、状态、耗时、追踪 ID。不要采集响应对象。 4. 网关自己的 403/404/429/503 也使用脱敏 JSON、`Cache-Control: no-store` 和追踪 ID;不得返回上游诊断页。禁止将该路由接入通用 `withApi`/request logger;目前路由直接返回白名单响应,不记录异常对象。 -5. 数据库不执行明文解密,SQL 参数不包含明文密码。现有密码写入或密钥轮换时,关闭 PostgreSQL/pgAudit 的绑定参数及错误参数记录,防止记录密文。不要启用该连接池的 SQL 结果或参数追踪。 +5. 数据库查询结果包含当前明文密码;严禁记录 SQL 结果、绑定参数或异常对象,不要启用该连接池的参数/结果追踪。密码写入及历史格式迁移期间同样关闭 PostgreSQL/pgAudit 的绑定参数和错误参数记录。 6. 财务后端同样禁用正文采集、缓存及异常对象打印。浏览器在进入已授权页面后只读取当前可见页,避免 localStorage/sessionStorage/Service Worker 持久化;页面隐藏或离开时清除页面密码,不把密码放入通知、埋点或消息队列。 ## 视图与审计迁移 -迁移:`prisma/migrations/20260908120000_store_account_password_reveal/migration.sql`。 +初始迁移:`prisma/migrations/20260908120000_store_account_password_reveal/migration.sql`。明文状态修复:`prisma/migrations/20260909100000_fix_store_account_password_configured_plaintext/migration.sql`。 - 新增 `StoreAccountPasswordAccessAudit`,固定字段为请求追踪 ID、人员标识、服务身份、账号 ID、用途、HTTP状态、数据库时间;不关联用户/店铺外键,以保留未授权、不存在和删除账号的失败审计。状态 200 表示成功,其他状态表示失败。 - 只在原视图存在时,以原视图查询作为子查询,在末尾追加 `passwordConfigured boolean`,保留原字段、筛选、每行内容、ID、所有者与 ACL;`EXISTS` 不会产生重复行。未配置外部视图的环境不擅自创建外部读入口。 -- `passwordConfigured` 表示存在格式完整的、非空 AES-GCM 密码封装。SQL 不接触密钥,所以不在每次列表查询时验证 GCM 标签/密钥健康;生产预检必须做主机内存中的解密聚合验证。密钥丢失或密文损坏属于 503 运维故障,不能把它伪装成未设置密码。 -- 无历史明文迁移,无现有密码或银行账号数据变更,无额外只读权限。审计数据增量写入,上线后按安全审计要求保留至少 365 天;此 PR 不创建自动删除任务。 +- `passwordConfigured` 的当前定义为 `passwordCiphertext <> ''`。修复迁移仅替换这个布尔表达式,并在事务内核对原字段逐行内容、视图所有者和 ACL 未变;不读取或输出密码。 +- 明文迁移已由独立脚本完成。本状态修复不改任何密码或银行账号数据,也不增加普通只读账号权限。审计数据至少保留 365 天。 迁移前由 DBA 只读确认目标库、视图定义/所有者/ACL、字段顺序、行数与唯一性、密码聚合、普通读取账号和其成员角色权限。若生产仍以本次基线验收,COUNT 与 DISTINCT id 必须均为 958;若业务新增/删除则暂停并重新确认基线,不改数据凑数。 备份与执行顺序(生产必须在明确批准后执行): 1. 备份当前 `main`/部署 SHA、现有视图 DDL/ACL/所有者及 `_prisma_migrations` 状态。生成目标库 `pg_dump -Fc` 备份并验证可读取;备份进入生产主机权限 0700 目录,文件 0600,不进入 Git 或共享日志。备份可能含密文,按敏感材料管理。 -2. 本次不换加密密钥;核实独立密钥在受限凭据存储中有可恢复副本,不在命令行、文档中显示。记录备份校验和、时间及恢复演练证据。 -3. 保持新财务令牌未配置,执行批准的该迁移,视图短时获得 DDL 锁(最多等 5 秒,执行预算 30 秒),新增审计表及索引;不回放未审查的其他迁移。 +2. 本次不修改任何密码、银行卡号或服务令牌;备份不得进入 Git 或普通日志。 +3. 执行明文状态修复迁移,视图短时获得 DDL 锁(最多等 5 秒,执行预算 30 秒);不回放未审查的其他迁移。 4. 在同一只读快照比较迁移前后原字段行集、顺序/类型、行数/唯一性和权限;运行 `scripts/sql/check-store-account-password-reveal.sql`,确认最后字段 boolean,报告 true/false/null 聚合。预计当前数据为 957/1/0,但必须执行后实测。 5. 按项目主线发布流程部署,完成逐跳 TLS、网关日志/限流/缓存检查后再配令牌。生产虚构账号的创建、调用与删除属于额外生产写入,需单独批准;不要调用真实账号作为测试。 @@ -138,7 +136,7 @@ Content-Type: application/json 网关凭据:暂停此接口,在网关及上游同时更新 `STORE_ACCOUNT_REVEAL_EDGE_TOKEN` 后验收并恢复;不支持旧值并行。不得将网关凭据与服务令牌设成相同值。 -数据加密密钥:现有 `v1:` 不含 key ID,不能直接替换环境变量。必须另行批准维护窗口,暂停所有店铺密码及银行账号的写入/读取,备份对应表和旧密钥;用受控迁移进程在内存中旧钥解密、新钥重新 AES-GCM 加密,并在同一数据库事务中更新两类密文字段(SQL 永不接收明文)。验证全量解密聚合、行数及虚构账号后,协调所有应用实例切换新环境密钥,再恢复流量;失败时同时恢复密文备份和旧钥。关闭 SQL 参数/异常参数、APM及备份内容输出;本 PR 不执行此轮换,也不引入第二套密钥或把新旧密钥存入数据库。需要无停机轮换时,应另行实现带 key ID 的 Envelope Encryption/KMS 版本迁移。 +历史兼容密钥:当前明文规则不需要为新值轮换数据密钥。若确认数据库中不存在 `v1:` 值并且备份保留策略允许,可另行评估退役历史解密密钥;退役前必须检查备份恢复场景,不能直接删除。 ## 本次验证记录 @@ -148,8 +146,8 @@ Content-Type: application/json - 隔离本地 PostgreSQL 集成测试:10 项通过,覆盖完整加解密与 HTTP handler、已提交审计、403/404/429/no-store、并发/跨实例限流、分钟/24小时配额、审计失败拒绝泄露、原字段及筛选保留、958/958 与 boolean 957/1/0、普通只读角色拒绝基表/密码/审计读取。 - 开启本地测试连接的 PostgreSQL statement 日志,只在内存扫描 `docker logs`;应用日志、审计行、SQL请求与真实数据库日志均未检出随机测试密码。测试无需打印测试密码,断言只输出布尔结果。 - Type-check 通过;范围内 ESLint 通过。全仓库主数据边界巡检有 6 项已有违规,均位于本次未修改模块;不得据此声称全仓库通过。 -- 生产仅完成只读基线核查;生产 `passwordConfigured` 尚未落库,生产接口尚未发布,因此没有生产新增字段/新接口的验收结论。 +- 2026-09-09 生产只读复核确认:958 行、958 个唯一 ID、957 条非空密码、1 条空密码;旧正则导致状态误报为全 false。本迁移用于恢复预期聚合 true/false/null = 957/1/0。 运行:`npx jest --runInBand --runTestsByPath src/server/services/store-account-password-reveal.service.test.ts src/proxy-store-account-reveal.test.ts "src/app/internal/store-accounts/[id]/password/reveal/route.test.ts" tests/integration/store-account-password-reveal.test.ts`。未配置本地测试库变量时,集成套件明确 skip,不能算真实数据库验收通过。 -收口:**passed with warnings**。这是自审,不是独立复审。Route → Service → Repository 边界保持;未修改 UI;复用密码真相源、加密和专用服务认证,未通过明文视图绕开根因。剩余门禁为生产批准、备份/TLS拓扑/网关配置及发布后真实验收。 +收口:**passed with warnings**。这是自审,不是独立复审。Route → Service → Repository 边界保持;未修改 UI;状态字段与当前明文存储规则对齐,专用认证、审计和限流边界不变。 diff --git a/furniture-3d-system/prisma/migrations/20260909100000_fix_store_account_password_configured_plaintext/migration.sql b/furniture-3d-system/prisma/migrations/20260909100000_fix_store_account_password_configured_plaintext/migration.sql new file mode 100644 index 00000000..9fbb3954 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260909100000_fix_store_account_password_configured_plaintext/migration.sql @@ -0,0 +1,92 @@ +BEGIN; +SET LOCAL lock_timeout = '5s'; +SET LOCAL statement_timeout = '30s'; + +DO $preflight$ +DECLARE + actual_columns TEXT[]; + expected_columns CONSTANT TEXT[] := ARRAY[ + 'id', 'storeName', 'operationOwner', 'operationDepartmentHead', + 'operationDirector', 'profitCenter', 'subject', 'accountType', 'account', + 'boundPhone', 'verificationContact', 'jstOrderService', 'accountGroupName', + 'createdAt', 'updatedAt', 'passwordConfigured' + ]; +BEGIN + IF to_regclass('external_store_accounts.accounts_v1') IS NULL THEN + RETURN; + END IF; + + SELECT array_agg(column_name ORDER BY ordinal_position) + INTO actual_columns + FROM information_schema.columns + WHERE table_schema = 'external_store_accounts' + AND table_name = 'accounts_v1'; + + IF actual_columns IS DISTINCT FROM expected_columns THEN + RAISE EXCEPTION 'External account contract needs preflight review'; + END IF; +END $preflight$; + +DO $migration$ +DECLARE + original_owner OID; + original_acl ACLITEM[]; +BEGIN + IF to_regclass('external_store_accounts.accounts_v1') IS NULL THEN + RETURN; + END IF; + + SELECT relowner, relacl + INTO original_owner, original_acl + FROM pg_class + WHERE oid = 'external_store_accounts.accounts_v1'::regclass; + + CREATE TEMP TABLE store_account_view_rows_before ON COMMIT DROP AS + SELECT to_jsonb(account_row) - 'passwordConfigured' AS row_value + FROM external_store_accounts.accounts_v1 account_row; + + CREATE OR REPLACE VIEW external_store_accounts.accounts_v1 AS + SELECT + source.id, + source."storeName", + source."operationOwner", + source."operationDepartmentHead", + source."operationDirector", + source."profitCenter", + source.subject, + source."accountType", + source.account, + source."boundPhone", + source."verificationContact", + source."jstOrderService", + source."accountGroupName", + source."createdAt", + source."updatedAt", + (source."passwordCiphertext" <> '')::boolean AS "passwordConfigured" + FROM public."StoreAccount" source; + + IF EXISTS ( + (SELECT row_value FROM store_account_view_rows_before + EXCEPT ALL + SELECT to_jsonb(account_row) - 'passwordConfigured' + FROM external_store_accounts.accounts_v1 account_row) + UNION ALL + (SELECT to_jsonb(account_row) - 'passwordConfigured' + FROM external_store_accounts.accounts_v1 account_row + EXCEPT ALL + SELECT row_value FROM store_account_view_rows_before) + ) THEN + RAISE EXCEPTION 'External account rows changed while fixing password status'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM pg_class + WHERE oid = 'external_store_accounts.accounts_v1'::regclass + AND (relowner <> original_owner OR relacl IS DISTINCT FROM original_acl) + ) THEN + RAISE EXCEPTION 'External account owner or ACL changed'; + END IF; +END $migration$; + +COMMIT; diff --git a/furniture-3d-system/tests/integration/store-account-password-reveal.test.ts b/furniture-3d-system/tests/integration/store-account-password-reveal.test.ts index 3a50cf93..6f7f1572 100644 --- a/furniture-3d-system/tests/integration/store-account-password-reveal.test.ts +++ b/furniture-3d-system/tests/integration/store-account-password-reveal.test.ts @@ -20,11 +20,11 @@ jest.mock('@/server/services/store-account-password-reveal.service', () => ({ // Explicit opt-in, loopback only. Creates and removes only its own random fixture DB/role. const adminUrl = process.env.STORE_REVEAL_TEST_ADMIN_URL const integration = adminUrl ? describe : describe.skip -integration('finance reveal with real PostgreSQL, migration and crypto', () => { +integration('finance reveal with real PostgreSQL and plaintext storage', () => { const suffix = randomBytes(8).toString('hex') const dbName = `store_reveal_test_${suffix}` const readerName = `store_reveal_reader_${suffix}` - const password = randomBytes(24).toString('base64url') + const password = `synthetic-${randomBytes(18).toString('base64')}=+` const token = randomBytes(32).toString('base64url') const edgeToken = randomBytes(32).toString('base64url') const startedAt = new Date().toISOString() @@ -52,17 +52,34 @@ integration('finance reveal with real PostgreSQL, migration and crypto', () => { beforeAll(async () => { const target = new URL(adminUrl!) if (!['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)) throw new Error('Local test database required') - admin = new Pool({ connectionString: adminUrl, max: 1, connectionTimeoutMillis: 2000 }) - await admin.query(`CREATE DATABASE "${dbName}"`) + admin = new Pool({ connectionString: adminUrl, max: 1, connectionTimeoutMillis: 10000 }) + if (process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER) { + const created = spawnSync('docker', [ + 'exec', process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER, + 'createdb', '-U', decodeURIComponent(target.username), dbName, + ], { encoding: 'utf8' }) + if (created.status !== 0) throw new Error('Failed to create isolated local test database') + } else { + await admin.query(`CREATE DATABASE "${dbName}"`) + } databaseCreated = true - await admin.query(`CREATE ROLE "${readerName}" NOLOGIN`) + if (process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER) { + const createdRole = spawnSync('docker', [ + 'exec', process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER, + 'psql', '-U', decodeURIComponent(target.username), '-d', target.pathname.slice(1), + '-v', 'ON_ERROR_STOP=1', '-c', `CREATE ROLE "${readerName}" NOLOGIN`, + ], { encoding: 'utf8' }) + if (createdRole.status !== 0) throw new Error('Failed to create isolated local test role') + } else { + await admin.query(`CREATE ROLE "${readerName}" NOLOGIN`) + } roleCreated = true target.pathname = `/${dbName}` db = new Pool({ connectionString: target.toString(), max: 8, // Enable actual server statement logs in this isolated test session only. // Never log bind values, even on errors. options: '-c log_statement=all -c log_parameter_max_length=0 -c log_parameter_max_length_on_error=0 -c log_min_error_statement=panic', - statement_timeout: 3000, connectionTimeoutMillis: 2000, + statement_timeout: 3000, connectionTimeoutMillis: 10000, }) db.on('connect', client => { const original = client.query.bind(client) @@ -79,23 +96,38 @@ integration('finance reveal with real PostgreSQL, migration and crypto', () => { process.env.STORE_ACCOUNT_ENCRYPTION_KEY = randomBytes(32).toString('base64url') process.env.STORE_ACCOUNT_FINANCE_TOKEN = token process.env.STORE_ACCOUNT_REVEAL_EDGE_TOKEN = edgeToken - await db.query(`CREATE TABLE public."StoreAccount" (id TEXT PRIMARY KEY, "storeName" TEXT, - "passwordCiphertext" TEXT NOT NULL DEFAULT '', visible BOOLEAN NOT NULL); + await db.query(`CREATE TABLE public."StoreAccount" ( + id TEXT PRIMARY KEY, "storeName" TEXT, + "operationOwner" TEXT NOT NULL DEFAULT '', "operationDepartmentHead" TEXT NOT NULL DEFAULT '', + "operationDirector" TEXT NOT NULL DEFAULT '', "profitCenter" TEXT NOT NULL DEFAULT '', + subject TEXT NOT NULL DEFAULT '', "accountType" TEXT NOT NULL DEFAULT '', account TEXT NOT NULL DEFAULT '', + "passwordCiphertext" TEXT NOT NULL DEFAULT '', "boundPhone" TEXT NOT NULL DEFAULT '', + "verificationContact" TEXT NOT NULL DEFAULT '', "jstOrderService" TEXT NOT NULL DEFAULT '', + "accountGroupName" TEXT NOT NULL DEFAULT '', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), visible BOOLEAN NOT NULL); CREATE SCHEMA external_store_accounts; CREATE VIEW external_store_accounts.accounts_v1 AS - SELECT id, "storeName" FROM public."StoreAccount" WHERE visible; + SELECT id, "storeName", "operationOwner", "operationDepartmentHead", "operationDirector", + "profitCenter", subject, "accountType", account, "boundPhone", "verificationContact", + "jstOrderService", "accountGroupName", "createdAt", "updatedAt" + FROM public."StoreAccount"; GRANT USAGE ON SCHEMA external_store_accounts TO "${readerName}"; GRANT SELECT ON external_store_accounts.accounts_v1 TO "${readerName}";`) - await db.query(`INSERT INTO public."StoreAccount" + await db.query(`INSERT INTO public."StoreAccount" (id, "storeName", "passwordCiphertext", visible) SELECT 'synthetic-' || n, 'Synthetic store ' || n, - CASE WHEN n <= 957 THEN $1 ELSE '' END, n <= 958 FROM generate_series(1,960) n`, + CASE WHEN n <= 957 THEN $1 ELSE '' END, true FROM generate_series(1,958) n`, [encryptStoreAccountPassword(password)]) - initialRows = JSON.stringify((await db.query('SELECT * FROM external_store_accounts.accounts_v1 ORDER BY id')).rows) + initialRows = JSON.stringify((await db.query('SELECT id, "storeName" FROM external_store_accounts.accounts_v1 ORDER BY id')).rows) initialColumns = (await db.query(`SELECT column_name FROM information_schema.columns WHERE table_schema='external_store_accounts' AND table_name='accounts_v1' ORDER BY ordinal_position`)).rows.map(r => r.column_name) await db.query(readFileSync(resolve('prisma/migrations/20260908120000_store_account_password_reveal/migration.sql'), 'utf8')) + await db.query(readFileSync(resolve('prisma/migrations/20260909100000_fix_store_account_password_configured_plaintext/migration.sql'), 'utf8')) const { StoreAccountPasswordRevealService } = jest.requireActual('@/server/services/store-account-password-reveal.service') mockService = new StoreAccountPasswordRevealService(new StoreAccountPasswordRevealRepository(() => db)) + // Fixture creation necessarily binds the synthetic plaintext under the current + // storage rule. The acceptance window begins after setup and covers reveal reads. + sqlContainsPassword = false + consoleContainsPassword = false }, 20000) beforeEach(async () => { await db.query('TRUNCATE public."StoreAccountPasswordAccessAudit"') }) @@ -108,8 +140,27 @@ integration('finance reveal with real PostgreSQL, migration and crypto', () => { if (db) await db.end() if (admin) { // Names are generated above, never accepted from env or user input. - if (databaseCreated) await admin.query(`DROP DATABASE "${dbName}"`) - if (roleCreated) await admin.query(`DROP ROLE "${readerName}"`) + if (databaseCreated && process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER) { + const target = new URL(adminUrl!) + const dropped = spawnSync('docker', [ + 'exec', process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER, + 'dropdb', '-U', decodeURIComponent(target.username), dbName, + ], { encoding: 'utf8' }) + if (dropped.status !== 0) throw new Error('Failed to remove isolated local test database') + } else if (databaseCreated) { + await admin.query(`DROP DATABASE "${dbName}"`) + } + if (roleCreated && process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER) { + const target = new URL(adminUrl!) + const droppedRole = spawnSync('docker', [ + 'exec', process.env.STORE_REVEAL_TEST_POSTGRES_CONTAINER, + 'psql', '-U', decodeURIComponent(target.username), '-d', target.pathname.slice(1), + '-v', 'ON_ERROR_STOP=1', '-c', `DROP ROLE "${readerName}"`, + ], { encoding: 'utf8' }) + if (droppedRole.status !== 0) throw new Error('Failed to remove isolated local test role') + } else if (roleCreated) { + await admin.query(`DROP ROLE "${readerName}"`) + } await admin.end() } }) -- 2.47.2