diff --git a/furniture-3d-system/docs/workflow/生产部署入口说明.md b/furniture-3d-system/docs/workflow/生产部署入口说明.md index 7b908f17..413b2e35 100644 --- a/furniture-3d-system/docs/workflow/生产部署入口说明.md +++ b/furniture-3d-system/docs/workflow/生产部署入口说明.md @@ -50,6 +50,16 @@ - 准确度回测:`npm run backtest:bom-cost-reference -- --sample-size=30`,只读最新成功镜像,不访问上游;样本限制 20 至 50 个,任一可比较 SKU 超过固定 10% 误差门槛即失败。 - 迁移、真实同步、安装/启用定时器和生产回测都必须另行获得生产操作授权;部署代码本身不代表这些动作已经执行。 +## 操作日志保留策略 + +- 清理脚本:`npm run db:cleanup-operation-logs` 默认只做 dry-run;只有显式传入 `--apply --confirm=cleanup-operation-logs` 才会删除数据。 +- 自动清理范围严格限定为 `OperationLog` 中 `SYSTEM + SUPPLY_CHAIN_PRODUCT_ARCHIVE + details.kind=UPDATED + SUCCESS` 且创建时间早于 30 天的字段变更。 +- 用户操作、商品档案导入、失败/重试日志及其他业务范围日志至少保留 365 天;本次策略不自动删除超过 365 天的审计日志。 +- 生产安装入口:`furniture-3d-system/scripts/install-operation-log-retention-timer.sh`。 +- 生产 systemd 名称:`furniture-operation-log-retention.service` / `furniture-operation-log-retention.timer`,默认每天中国标准时间 `04:30` 执行,附带最多 10 分钟随机延迟。 +- 安装脚本会在 `/opt/furniture-3d-system-prod/shared/operation-log-retention.env` 保存权限为 `600` 的数据库身份门禁和非敏感参数;身份未填写或保留天数不是 30 天时拒绝启用。 +- 首次启用前必须先在生产执行 dry-run,核对候选数量、数据库身份和 systemd 落点;生产安装、首次 `--apply` 和后续代码部署均需另行获得授权。 + ## 明确排除 - `192.168.58.20` 不是当前 `furniture-3d-system` 的生产部署目标。 diff --git a/furniture-3d-system/package.json b/furniture-3d-system/package.json index a3e55f2b..48bcb428 100644 --- a/furniture-3d-system/package.json +++ b/furniture-3d-system/package.json @@ -31,6 +31,8 @@ "db:verify-master-data:production-readonly": "node scripts/verify-master-data-foundation.js --read-only-quality --summary-output .master-data-audits/foundation-production-readonly-current.summary.json --summary-stdout", "db:cleanup-master-data": "node scripts/apply-master-data-cleanup-plan.js", "db:cleanup-master-data:validate": "node scripts/apply-master-data-cleanup-plan.js --validate-plan", + "db:cleanup-operation-logs": "tsx scripts/cleanup-operation-logs.ts", + "db:cleanup-operation-logs:apply": "tsx scripts/cleanup-operation-logs.ts --apply --confirm=cleanup-operation-logs", "db:export-product-category-review": "node scripts/export-non-integrated-product-category-review.js", "db:build-product-category-cleanup-plan": "node scripts/build-non-integrated-product-category-cleanup-plan.js", "db:match-jst-product-category-cleanup-plan": "node scripts/match-jst-product-category-cleanup-plan.js", diff --git a/furniture-3d-system/prisma/migrations/20260903190000_add_operation_log_retention_index/migration.sql b/furniture-3d-system/prisma/migrations/20260903190000_add_operation_log_retention_index/migration.sql new file mode 100644 index 00000000..7d9722c7 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260903190000_add_operation_log_retention_index/migration.sql @@ -0,0 +1,4 @@ +-- Supports the bounded retention scan for system-generated product archive +-- field-change logs without changing the audit data itself. +CREATE INDEX "OperationLog_retention_scope_createdAt_idx" +ON "OperationLog"("workspaceCategory", "source", "status", "createdAt"); diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index 8618c4a9..39535777 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -3192,6 +3192,7 @@ model OperationLog { @@index([actorId, createdAt]) @@index([productSpu, createdAt]) @@index([productId, createdAt]) + @@index([workspaceCategory, source, status, createdAt], map: "OperationLog_retention_scope_createdAt_idx") } // 家具系统发往统一通知中心的本地审计副本。 diff --git a/furniture-3d-system/scripts/cleanup-operation-logs.ts b/furniture-3d-system/scripts/cleanup-operation-logs.ts new file mode 100644 index 00000000..7939cb71 --- /dev/null +++ b/furniture-3d-system/scripts/cleanup-operation-logs.ts @@ -0,0 +1,106 @@ +import { loadEnvConfig } from '@next/env' + +loadEnvConfig(process.cwd()) + +const CONFIRM_TOKEN = 'cleanup-operation-logs' +const DEFAULT_RETENTION_DAYS = 30 +const DEFAULT_BATCH_SIZE = 1000 + +function readArgument(name: string) { + const prefix = `--${name}=` + return ( + process.argv + .slice(2) + .find((argument) => argument.startsWith(prefix)) + ?.slice(prefix.length) ?? '' + ) +} + +function databaseIdentity(databaseUrl: string) { + const parsed = new URL(databaseUrl) + return [ + parsed.protocol.replace(/:$/, ''), + decodeURIComponent(parsed.username || ''), + parsed.hostname, + parsed.port || '', + parsed.pathname.replace(/^\//, ''), + parsed.searchParams.get('schema') ?? '', + ].join('|') +} + +function readPositiveInteger(name: string, fallback: number, max: number) { + const value = Number(readArgument(name) || fallback) + if (!Number.isSafeInteger(value) || value < 1 || value > max) { + throw new Error(`--${name} must be an integer between 1 and ${max}.`) + } + return value +} + +async function main() { + const databaseUrl = process.env.DATABASE_URL?.trim() ?? '' + if (!databaseUrl) throw new Error('DATABASE_URL is required.') + + const retentionDays = readPositiveInteger( + 'retention-days', + Number(process.env.OPERATION_LOG_SYSTEM_FIELD_RETENTION_DAYS || DEFAULT_RETENTION_DAYS), + 365, + ) + if (retentionDays !== DEFAULT_RETENTION_DAYS) { + throw new Error( + `The approved system field-change retention is ${DEFAULT_RETENTION_DAYS} days; do not override it in production.`, + ) + } + + const batchSize = readPositiveInteger( + 'batch-size', + Number(process.env.OPERATION_LOG_CLEANUP_BATCH_SIZE || DEFAULT_BATCH_SIZE), + 5000, + ) + const apply = process.argv.includes('--apply') + if (apply && readArgument('confirm') !== CONFIRM_TOKEN) { + throw new Error(`Pass --confirm=${CONFIRM_TOKEN} when using --apply.`) + } + + const actualDatabaseIdentity = databaseIdentity(databaseUrl) + if ( + apply && + readArgument('expected-database-identity') !== actualDatabaseIdentity + ) { + throw new Error( + `Pass --expected-database-identity=${actualDatabaseIdentity} when using --apply.`, + ) + } + + const [{ prisma }, { operationLogRetentionService }] = await Promise.all([ + import('../src/lib/prisma'), + import('../src/server/services/operation-log-retention.service'), + ]) + + try { + const result = await operationLogRetentionService.cleanup({ + now: new Date(), + batchSize, + apply, + }) + console.log( + JSON.stringify( + { + ...result, + cutoff: result.cutoff.toISOString(), + databaseIdentity: apply ? actualDatabaseIdentity : undefined, + }, + null, + 2, + ), + ) + } finally { + await prisma.$disconnect() + } +} + +void main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +}) + +export { databaseIdentity, readArgument, readPositiveInteger } diff --git a/furniture-3d-system/scripts/install-operation-log-retention-timer.sh b/furniture-3d-system/scripts/install-operation-log-retention-timer.sh new file mode 100644 index 00000000..793fdb82 --- /dev/null +++ b/furniture-3d-system/scripts/install-operation-log-retention-timer.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${FURNITURE_PROD_BASE:-/opt/furniture-3d-system-prod}" +SERVICE_NAME="${OPERATION_LOG_RETENTION_SERVICE_NAME:-furniture-operation-log-retention}" +APP_ROOT="$BASE/current/furniture-3d-system" +SHARED="$BASE/shared" +APP_ENV="$SHARED/.env" +RETENTION_ENV="$SHARED/operation-log-retention.env" +SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME.service" +TIMER_FILE="/etc/systemd/system/$SERVICE_NAME.timer" +RETENTION_CALENDAR="${OPERATION_LOG_RETENTION_CALENDAR:-*-*-* 04:30:00 Asia/Shanghai}" +RANDOMIZED_DELAY_SEC="${OPERATION_LOG_RETENTION_RANDOMIZED_DELAY_SEC:-10min}" +CONFIRM_TOKEN="cleanup-operation-logs" + +if [ "$(id -u)" -ne 0 ]; then + echo "This installer must run as root." >&2 + exit 1 +fi + +if [ ! -d "$APP_ROOT" ]; then + echo "Missing app root: $APP_ROOT" >&2 + exit 1 +fi + +if [ ! -f "$APP_ENV" ]; then + echo "Missing app env: $APP_ENV" >&2 + exit 1 +fi + +mkdir -p "$SHARED" +if [ ! -f "$RETENTION_ENV" ]; then + umask 077 + cat >"$RETENTION_ENV" <<'EOF' +# Operation log retention controls. Do not commit secrets to Git. +# Must match the production DATABASE_URL identity before enabling the timer. +OPERATION_LOG_EXPECTED_DATABASE_IDENTITY= +OPERATION_LOG_SYSTEM_FIELD_RETENTION_DAYS=30 +OPERATION_LOG_CLEANUP_BATCH_SIZE=1000 +EOF + echo "Created env template: $RETENTION_ENV" +fi + +chmod 600 "$RETENTION_ENV" + +if ! grep -Eq '^OPERATION_LOG_EXPECTED_DATABASE_IDENTITY=.+$' "$RETENTION_ENV"; then + echo "Missing OPERATION_LOG_EXPECTED_DATABASE_IDENTITY in $RETENTION_ENV; timer was not installed." >&2 + exit 2 +fi + +if ! grep -Eq '^DATABASE_URL=.+$' "$APP_ENV"; then + echo "Missing DATABASE_URL in $APP_ENV; timer was not installed." >&2 + exit 2 +fi + +if ! grep -Eq '^OPERATION_LOG_SYSTEM_FIELD_RETENTION_DAYS=30$' "$RETENTION_ENV"; then + echo "OPERATION_LOG_SYSTEM_FIELD_RETENTION_DAYS must be 30; timer was not installed." >&2 + exit 2 +fi + +if command -v systemd-analyze >/dev/null 2>&1; then + systemd-analyze calendar "$RETENTION_CALENDAR" >/dev/null +fi + +cat >"$SERVICE_FILE" <"$TIMER_FILE" < ({ + handleRequest: (...args: unknown[]) => mockHandleRequest(...args), + jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args), +})) + +jest.mock('@/server/services/supplier-recommended-products.service', () => ({ + supplierRecommendedProductsService: { + updateSelection: (...args: unknown[]) => mockUpdateSelection(...args), + listSelectionOperators: (...args: unknown[]) => mockListSelectionOperators(...args), + }, +})) + +const { GET, POST } = require('./route') + +describe('POST /api/supply-chain/supplier-recommended-products/selection', () => { + beforeEach(() => { + jest.clearAllMocks() + mockHandleRequest.mockImplementation(async (_request: unknown, handler: (ctx: unknown) => unknown) => handler({ + requestId: 'req-selection', + user: { id: 'operator-1', name: '运营', role: 'USER', businessRoles: ['OPERATIONS'] }, + })) + mockJsonSuccess.mockImplementation((data, status, requestId) => ({ data, status, requestId })) + mockUpdateSelection.mockResolvedValue({ action: 'select', requestedCount: 2, updatedCount: 2 }) + mockListSelectionOperators.mockResolvedValue([{ id: 'operator-2', name: '运营乙', departmentName: '运营中心' }]) + }) + + it('passes the batch selection payload and authenticated user to the service', async () => { + const response = await POST({ + json: async () => ({ ids: ['recommended-1', 'recommended-2'], action: 'select' }), + } as never) + + expect(mockUpdateSelection).toHaveBeenCalledWith( + { ids: ['recommended-1', 'recommended-2'], action: 'select' }, + expect.objectContaining({ id: 'operator-1', businessRoles: ['OPERATIONS'] }), + ) + expect(response).toEqual(expect.objectContaining({ status: 200 })) + }) + + it('returns searchable active operator candidates', async () => { + const response = await GET({ nextUrl: new URL('http://localhost/api/supply-chain/supplier-recommended-products/selection?search=%E8%BF%90%E8%90%A5') }) + + expect(mockListSelectionOperators).toHaveBeenCalledWith('运营', expect.objectContaining({ id: 'operator-1' })) + expect(response).toEqual(expect.objectContaining({ status: 200 })) + }) +}) diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx index e000e96c..96faccd4 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/SupplierRecommendedProductsClient.tsx @@ -221,7 +221,7 @@ export function SupplierRecommendedProductsClient({ initialData, canEdit, canDow {(['all', 'unselected', 'selected'] as const).map((selection) => )} - {canDownload && } + {canEdit && } {canEdit && <> diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.tsx b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.tsx index 5026183c..87b446a6 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.tsx +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/components/SupplierRecommendedProductEditor.tsx @@ -39,6 +39,6 @@ export function SupplierRecommendedProductEditor({ item, onClose, onSaved }: { i event.preventDefault() void uploadImageFiles(files) } - return
{ if (event.target === event.currentTarget && !saving) onClose() }}>

{item ? '编辑供应商新品推荐' : '新增供应商新品推荐'}

供应商推荐信息人工维护,空白字段可保存

set('category', value)} /> set('supplier', value)} /> set('purchaser', value)} /> set('productCode', value)} /> set('sampleRequest', value)} /> set('dimensions', value)} /> set('price', value)} /> set('material', value)} /> set('boardGrade', value)} /> set('weight', value)} /> set('productionCycle', value)} /> set('shippingMode', value)} /> set('dropShippingFee', value)} />
{uploadingImageName && 上传中:{uploadingImageName}}{imageError && {imageError}}
{!uploadingImageName && draft.imageUrl && 图片预览}
+ return
{ if (event.target === event.currentTarget && !saving) onClose() }}>

{item ? '编辑供应商新品推荐' : '新增供应商新品推荐'}

供应商推荐信息人工维护,空白字段可保存

set('category', value)} /> set('supplier', value)} /> set('purchaser', value)} /> set('productCode', value)} /> set('dimensions', value)} /> set('price', value)} /> set('material', value)} /> set('boardGrade', value)} /> set('weight', value)} /> set('productionCycle', value)} /> set('shippingMode', value)} /> set('dropShippingFee', value)} /> set('sampleRequest', value)} />
{uploadingImageName && 上传中:{uploadingImageName}}{imageError && {imageError}}
{!uploadingImageName && draft.imageUrl && 图片预览}
} function Field({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) { return } diff --git a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplierRecommendedProductColumns.ts b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplierRecommendedProductColumns.ts index bc5108d3..708506ea 100644 --- a/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplierRecommendedProductColumns.ts +++ b/furniture-3d-system/src/app/supply-chain/purchase/supplier-recommended-products/supplierRecommendedProductColumns.ts @@ -10,7 +10,6 @@ export const SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS: readonly SupplierRecommendedP { key: 'purchaser', label: '采购', minWidth: 110 }, { key: 'productCode', label: '产品代码(供应商自己的)', minWidth: 190 }, { key: 'imageUrl', label: '图片', minWidth: 86 }, - { key: 'sampleRequest', label: '申请寄样', minWidth: 120 }, { key: 'dimensions', label: '尺寸', minWidth: 150 }, { key: 'price', label: '价格(含税价+专票)', minWidth: 170 }, { key: 'material', label: '材质', minWidth: 140 }, @@ -20,6 +19,7 @@ export const SUPPLIER_RECOMMENDED_PRODUCT_COLUMNS: readonly SupplierRecommendedP { key: 'productionCycle', label: '生产周期', minWidth: 120 }, { key: 'shippingMode', label: '发货模式', minWidth: 120 }, { key: 'dropShippingFee', label: '代发运费(代发情况下)', minWidth: 190 }, + { key: 'sampleRequest', label: '申请寄样', minWidth: 120 }, { key: 'actions', label: '操作', minWidth: 220, locked: true }, ] diff --git a/furniture-3d-system/src/app/users/components/AccountManagementPanel.test.tsx b/furniture-3d-system/src/app/users/components/AccountManagementPanel.test.tsx index 861d89b3..6ee0d1f3 100644 --- a/furniture-3d-system/src/app/users/components/AccountManagementPanel.test.tsx +++ b/furniture-3d-system/src/app/users/components/AccountManagementPanel.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { AccountManagementPanel } from './AccountManagementPanel' +import { AccountManagementPanel, formatLastLoginLabel } from './AccountManagementPanel' import { buildBuiltinPermissionRoles } from '@/lib/permission-roles' import type { PermissionDepartment, PermissionManagedUser } from './usePermissionManagedUsers' @@ -88,4 +88,42 @@ describe('AccountManagementPanel', () => { expect(screen.queryByText('采购乙')).not.toBeInTheDocument() expect(screen.getByText('共 1 个账号')).toBeInTheDocument() }) + + it('renders a last login column that distinguishes never, recent, and stale logins', () => { + render( + , + ) + + expect(screen.getByText('最后登录')).toBeInTheDocument() + expect(screen.getByText('从未登录')).toBeInTheDocument() + expect(screen.getByText('3 天前')).toBeInTheDocument() + expect(screen.getByText('2025-01-06')).toBeInTheDocument() + }) + + it('formats last login labels with relative dates and stale tone', () => { + const now = new Date('2026-09-02T15:00:00+08:00') + + expect(formatLastLoginLabel(null, now)).toEqual({ text: '从未登录', tone: 'muted' }) + expect(formatLastLoginLabel('2026-09-02T09:30:00+08:00', now)).toEqual({ text: '今天 09:30', tone: 'normal' }) + expect(formatLastLoginLabel('2026-09-01T10:00:00+08:00', now)).toEqual({ text: '昨天', tone: 'normal' }) + expect(formatLastLoginLabel('2026-08-31T10:00:00+08:00', now)).toEqual({ text: '2 天前', tone: 'normal' }) + expect(formatLastLoginLabel('2026-08-20T10:00:00+08:00', now)).toEqual({ text: '2026-08-20', tone: 'normal' }) + expect(formatLastLoginLabel('2026-05-01T10:00:00+08:00', now)).toEqual({ text: '2026-05-01', tone: 'stale' }) + }) }) diff --git a/furniture-3d-system/src/app/users/components/AccountManagementPanel.tsx b/furniture-3d-system/src/app/users/components/AccountManagementPanel.tsx index 1bd9bef6..7633b7b8 100644 --- a/furniture-3d-system/src/app/users/components/AccountManagementPanel.tsx +++ b/furniture-3d-system/src/app/users/components/AccountManagementPanel.tsx @@ -19,6 +19,30 @@ const STATUS_STYLES: Record = { SUSPENDED: 'bg-[#fff0ef] text-[#b96864]', } +const LAST_LOGIN_TONE_STYLES = { + normal: 'text-[#657575]', + stale: 'text-[#c07f2f]', + muted: 'text-[#a9b4b3]', +} as const + +export function formatLastLoginLabel(value: string | null | undefined, now: Date = new Date()): { text: string; tone: keyof typeof LAST_LOGIN_TONE_STYLES } { + if (!value) return { text: '从未登录', tone: 'muted' } + const last = new Date(value) + if (Number.isNaN(last.getTime())) return { text: '从未登录', tone: 'muted' } + + const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() + const days = Math.floor((startOfDay(now) - startOfDay(last)) / 86400000) + if (days <= 0) { + const time = `${String(last.getHours()).padStart(2, '0')}:${String(last.getMinutes()).padStart(2, '0')}` + return { text: `今天 ${time}`, tone: 'normal' } + } + if (days === 1) return { text: '昨天', tone: 'normal' } + if (days <= 6) return { text: `${days} 天前`, tone: 'normal' } + + const dateText = `${last.getFullYear()}-${String(last.getMonth() + 1).padStart(2, '0')}-${String(last.getDate()).padStart(2, '0')}` + return days <= 30 ? { text: dateText, tone: 'normal' } : { text: dateText, tone: 'stale' } +} + export function AccountManagementPanel({ users, loading, @@ -196,15 +220,17 @@ export function AccountManagementPanel({ 部门 主岗位 账号状态 + 最后登录 操作 - {loading ? 正在读取账号… : null} - {!loading && filteredUsers.length === 0 ? 没有匹配的账号 : null} + {loading ? 正在读取账号… : null} + {!loading && filteredUsers.length === 0 ? 没有匹配的账号 : null} {!loading ? filteredUsers.map((user) => { const active = selectedUserId === user.id const primaryRoleId = resolveUserPermissionRoleId(user) + const lastLoginLabel = formatLastLoginLabel(user.lastLoginAt) return ( onSelect(user)} className={`cursor-pointer border-b border-[#edf0ef] transition ${active ? 'bg-[#f0f5f3]' : 'bg-white hover:bg-[#fafcfc]'}`}> @@ -219,6 +245,7 @@ export function AccountManagementPanel({ {user.department?.fullPath || user.department?.name || '未归属部门'} {primaryRoleId ? roleLabels.get(primaryRoleId) ?? '已停用岗位' : '未设置'} {STATUS_LABELS[user.status] ?? user.status} + {lastLoginLabel.text}
{user.status === 'PENDING' ? : null} diff --git a/furniture-3d-system/src/app/users/components/usePermissionManagedUsers.ts b/furniture-3d-system/src/app/users/components/usePermissionManagedUsers.ts index f481320c..41221008 100644 --- a/furniture-3d-system/src/app/users/components/usePermissionManagedUsers.ts +++ b/furniture-3d-system/src/app/users/components/usePermissionManagedUsers.ts @@ -17,6 +17,7 @@ export type PermissionManagedUser = { supplierName?: string | null department?: { id?: string; name?: string; fullPath?: string } | null departmentPaths?: string[] + lastLoginAt?: string | null } export type PermissionDepartment = { diff --git a/furniture-3d-system/src/lib/supplier-recommended-products.ts b/furniture-3d-system/src/lib/supplier-recommended-products.ts index cf3367bd..8a56335a 100644 --- a/furniture-3d-system/src/lib/supplier-recommended-products.ts +++ b/furniture-3d-system/src/lib/supplier-recommended-products.ts @@ -7,7 +7,6 @@ export const SUPPLIER_RECOMMENDED_PRODUCT_FIELDS = [ { key: 'purchaser', label: '采购' }, { key: 'productCode', label: '产品代码(供应商自己的)' }, { key: 'imageUrl', label: '图片' }, - { key: 'sampleRequest', label: '申请寄样' }, { key: 'dimensions', label: '尺寸' }, { key: 'price', label: '价格(含税价+专票)' }, { key: 'material', label: '材质' }, @@ -17,8 +16,14 @@ export const SUPPLIER_RECOMMENDED_PRODUCT_FIELDS = [ { key: 'productionCycle', label: '生产周期' }, { key: 'shippingMode', label: '发货模式' }, { key: 'dropShippingFee', label: '代发运费(代发情况下)' }, + { key: 'sampleRequest', label: '申请寄样' }, ] as const +// 导入必填:除申请寄样(可空)外全部必填;日期由系统按导入日期填写,不在文件里要求。 +export const SUPPLIER_RECOMMENDED_PRODUCT_REQUIRED_KEYS: readonly (typeof SUPPLIER_RECOMMENDED_PRODUCT_FIELDS)[number]['key'][] = SUPPLIER_RECOMMENDED_PRODUCT_FIELDS + .filter((field) => field.key !== 'sampleRequest' && field.key !== 'recommendationDate') + .map((field) => field.key) + export type SupplierRecommendedProductFieldKey = typeof SUPPLIER_RECOMMENDED_PRODUCT_FIELDS[number]['key'] export const SUPPLIER_RECOMMENDED_PRODUCT_SELECTION_STATUSES = ['UNSELECTED', 'SELECTED'] as const diff --git a/furniture-3d-system/src/server/repositories/operation-log-retention.repository.ts b/furniture-3d-system/src/server/repositories/operation-log-retention.repository.ts new file mode 100644 index 00000000..61cf2f81 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/operation-log-retention.repository.ts @@ -0,0 +1,64 @@ +import { + OperationLogStatus, + Prisma, +} from '@prisma/client' +import { prisma } from '@/lib/prisma' + +export const OPERATION_LOG_RETENTION_SCOPE = 'SUPPLY_CHAIN_PRODUCT_ARCHIVE' + +export function buildOperationLogRetentionWhere( + cutoff: Date, +): Prisma.OperationLogWhereInput { + return { + createdAt: { lt: cutoff }, + source: 'SYSTEM', + workspaceCategory: OPERATION_LOG_RETENTION_SCOPE, + status: OperationLogStatus.SUCCESS, + details: { + path: ['kind'], + equals: 'UPDATED', + }, + } +} + +export interface OperationLogRetentionRepository { + countEligible(cutoff: Date): Promise + findEligibleIds(input: { + cutoff: Date + batchSize: number + }): Promise + deleteByIds(ids: string[]): Promise +} + +class OperationLogRetentionRepositoryClass + implements OperationLogRetentionRepository +{ + async countEligible(cutoff: Date) { + return prisma.operationLog.count({ + where: buildOperationLogRetentionWhere(cutoff), + }) + } + + async findEligibleIds(input: { cutoff: Date; batchSize: number }) { + const rows = await prisma.operationLog.findMany({ + where: buildOperationLogRetentionWhere(input.cutoff), + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + take: input.batchSize, + select: { id: true }, + }) + return rows.map((row) => row.id) + } + + async deleteByIds(ids: string[]) { + if (ids.length === 0) return 0 + const result = await prisma.operationLog.deleteMany({ + where: { id: { in: ids } }, + }) + return result.count + } +} + +export const operationLogRetentionRepository = + new OperationLogRetentionRepositoryClass() + +export { OperationLogRetentionRepositoryClass } diff --git a/furniture-3d-system/src/server/services/__tests__/supplier-recommended-products.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supplier-recommended-products.service.test.ts index 789fa777..f200748d 100644 --- a/furniture-3d-system/src/server/services/__tests__/supplier-recommended-products.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supplier-recommended-products.service.test.ts @@ -93,20 +93,21 @@ describe('SupplierRecommendedProductsService', () => { await expect(service.importRows([{ recommendationDate: '1999-01-01', + category: '床头柜', supplier: '供应商A', - purchaser: '', - productCode: '', + purchaser: '采购员', + productCode: 'A-001', imageUrl: '/api/storage/object?key=import-image', sampleRequest: '', - dimensions: '', - price: '', - material: '', - boardGrade: '', - weight: '', + dimensions: '40/40/60', + price: '99', + material: '橡胶木', + boardGrade: 'enf', + weight: '10', orderQuantity: 10, - productionCycle: '', - shippingMode: '', - dropShippingFee: '', + productionCycle: '15', + shippingMode: '代发', + dropShippingFee: '15', }], USER, progress)).resolves.toEqual({ created: 1, updated: 0 }) expect(importCreate).toHaveBeenCalledWith( @@ -117,6 +118,58 @@ describe('SupplierRecommendedProductsService', () => { expect(progress).toHaveBeenCalledWith({ processed: 1, total: 1, created: 1, updated: 0 }) }) + it('rejects imported rows missing required fields and reports the row number and labels', async () => { + const importCreate = jest.fn() + const repository = { importCreate } as unknown as SupplierRecommendedProductsRepository + const service = new SupplierRecommendedProductsService(repository) + + await expect(service.importRows([{ + category: '', + supplier: '', + purchaser: '', + productCode: '', + imageUrl: '', + dimensions: '', + price: '', + material: '', + boardGrade: '', + weight: '', + orderQuantity: '', + productionCycle: '', + shippingMode: '代发', + dropShippingFee: '', + }], USER)).rejects.toThrow(/第2行:缺少必填字段:.+类目/) + + expect(importCreate).not.toHaveBeenCalled() + }) + + it('requires drop-shipping fee only when shipping mode is 代发', async () => { + const importCreate = jest.fn().mockResolvedValue({ id: 'recommended-import-1' }) + const repository = { importCreate } as unknown as SupplierRecommendedProductsRepository + const service = new SupplierRecommendedProductsService(repository) + const base = { + category: '床头柜', + supplier: '供应商A', + purchaser: '采购员', + productCode: 'A-001', + imageUrl: '/api/storage/object?key=import-image', + dimensions: '40/40/60', + price: '99', + material: '橡胶木', + boardGrade: 'enf', + weight: '10', + orderQuantity: 10, + productionCycle: '15', + } + + await expect(service.importRows([{ ...base, shippingMode: '自提', dropShippingFee: '', sampleRequest: '' }], USER)) + .resolves.toEqual({ created: 1, updated: 0 }) + + await expect(service.importRows([{ ...base, shippingMode: '代发', dropShippingFee: '', sampleRequest: '' }], USER)) + .rejects.toThrow('缺少必填字段:代发运费(代发情况下)') + expect(importCreate).toHaveBeenCalledTimes(1) + }) + it('requires and persists the selected operator for batch selection', async () => { const updateSelection = jest.fn().mockResolvedValue({ foundCount: 2, updatedCount: 2 }) const repository = { updateSelection } as unknown as SupplierRecommendedProductsRepository diff --git a/furniture-3d-system/src/server/services/operation-log-retention.service.test.ts b/furniture-3d-system/src/server/services/operation-log-retention.service.test.ts new file mode 100644 index 00000000..99234345 --- /dev/null +++ b/furniture-3d-system/src/server/services/operation-log-retention.service.test.ts @@ -0,0 +1,76 @@ +import { + AUDIT_LOG_MIN_RETENTION_DAYS, + DEFAULT_OPERATION_LOG_CLEANUP_BATCH_SIZE, + OperationLogRetentionService, + SYSTEM_FIELD_CHANGE_RETENTION_DAYS, + getOperationLogRetentionCutoff, + getOperationLogRetentionPolicy, +} from './operation-log-retention.service' + +describe('operation log retention service', () => { + it('uses the approved 30-day cutoff and exposes the protected audit policy', () => { + const now = new Date('2026-09-03T00:00:00.000Z') + + expect(getOperationLogRetentionCutoff(now)).toEqual( + new Date('2026-08-04T00:00:00.000Z'), + ) + expect(getOperationLogRetentionPolicy()).toEqual( + expect.objectContaining({ + systemFieldChangeRetentionDays: SYSTEM_FIELD_CHANGE_RETENTION_DAYS, + auditLogMinimumRetentionDays: AUDIT_LOG_MIN_RETENTION_DAYS, + }), + ) + }) + + it('keeps dry-run read-only', async () => { + const repository = { + countEligible: jest.fn().mockResolvedValue(12), + findEligibleIds: jest.fn(), + deleteByIds: jest.fn(), + } + const service = new OperationLogRetentionService(repository) + + const result = await service.cleanup({ + now: new Date('2026-09-03T00:00:00.000Z'), + }) + + expect(result.mode).toBe('dry-run') + expect(result.candidates).toBe(12) + expect(result.deleted).toBe(0) + expect(result.batchSize).toBe(DEFAULT_OPERATION_LOG_CLEANUP_BATCH_SIZE) + expect(repository.findEligibleIds).not.toHaveBeenCalled() + expect(repository.deleteByIds).not.toHaveBeenCalled() + }) + + it('deletes only in bounded batches when apply is explicit', async () => { + const repository = { + countEligible: jest.fn().mockResolvedValue(3), + findEligibleIds: jest + .fn() + .mockResolvedValueOnce(['log-1', 'log-2']) + .mockResolvedValueOnce(['log-3']) + .mockResolvedValueOnce([]), + deleteByIds: jest + .fn() + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(1), + } + const service = new OperationLogRetentionService(repository) + + const result = await service.cleanup({ + now: new Date('2026-09-03T00:00:00.000Z'), + batchSize: 2, + apply: true, + }) + + expect(result.mode).toBe('apply') + expect(result.candidates).toBe(3) + expect(result.deleted).toBe(3) + expect(repository.findEligibleIds).toHaveBeenNthCalledWith(1, { + cutoff: new Date('2026-08-04T00:00:00.000Z'), + batchSize: 2, + }) + expect(repository.deleteByIds).toHaveBeenNthCalledWith(1, ['log-1', 'log-2']) + expect(repository.deleteByIds).toHaveBeenNthCalledWith(2, ['log-3']) + }) +}) diff --git a/furniture-3d-system/src/server/services/operation-log-retention.service.ts b/furniture-3d-system/src/server/services/operation-log-retention.service.ts new file mode 100644 index 00000000..75d7340a --- /dev/null +++ b/furniture-3d-system/src/server/services/operation-log-retention.service.ts @@ -0,0 +1,107 @@ +import { + operationLogRetentionRepository, + type OperationLogRetentionRepository, +} from '@/server/repositories/operation-log-retention.repository' + +export const SYSTEM_FIELD_CHANGE_RETENTION_DAYS = 30 +export const AUDIT_LOG_MIN_RETENTION_DAYS = 365 +export const DEFAULT_OPERATION_LOG_CLEANUP_BATCH_SIZE = 1000 + +const DAY_MS = 24 * 60 * 60 * 1000 + +export type OperationLogRetentionPolicy = { + systemFieldChangeRetentionDays: number + auditLogMinimumRetentionDays: number + protectedCategories: readonly string[] +} + +export type OperationLogCleanupResult = { + mode: 'dry-run' | 'apply' + cutoff: Date + candidates: number + deleted: number + batchSize: number + policy: OperationLogRetentionPolicy +} + +export function getOperationLogRetentionCutoff( + now = new Date(), + retentionDays = SYSTEM_FIELD_CHANGE_RETENTION_DAYS, +) { + if (!Number.isSafeInteger(retentionDays) || retentionDays < 1) { + throw new Error('retentionDays must be a positive integer.') + } + return new Date(now.getTime() - retentionDays * DAY_MS) +} + +export function getOperationLogRetentionPolicy(): OperationLogRetentionPolicy { + return { + systemFieldChangeRetentionDays: SYSTEM_FIELD_CHANGE_RETENTION_DAYS, + auditLogMinimumRetentionDays: AUDIT_LOG_MIN_RETENTION_DAYS, + protectedCategories: [ + 'user operations', + 'import records', + 'failed/retry logs', + 'non-product-archive logs', + ], + } +} + +class OperationLogRetentionService { + constructor( + private readonly repository: OperationLogRetentionRepository = + operationLogRetentionRepository, + ) {} + + async cleanup(input: { + now?: Date + batchSize?: number + apply?: boolean + }): Promise { + const batchSize = normalizeBatchSize(input.batchSize) + const cutoff = getOperationLogRetentionCutoff(input.now) + const candidates = await this.repository.countEligible(cutoff) + const mode = input.apply ? 'apply' : 'dry-run' + + if (!input.apply || candidates === 0) { + return { + mode, + cutoff, + candidates, + deleted: 0, + batchSize, + policy: getOperationLogRetentionPolicy(), + } + } + + let deleted = 0 + while (true) { + const ids = await this.repository.findEligibleIds({ cutoff, batchSize }) + if (ids.length === 0) break + const deletedInBatch = await this.repository.deleteByIds(ids) + if (deletedInBatch === 0) break + deleted += deletedInBatch + } + + return { + mode, + cutoff, + candidates, + deleted, + batchSize, + policy: getOperationLogRetentionPolicy(), + } + } +} + +function normalizeBatchSize(value?: number) { + const batchSize = value ?? DEFAULT_OPERATION_LOG_CLEANUP_BATCH_SIZE + if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 5000) { + throw new Error('batchSize must be an integer between 1 and 5000.') + } + return batchSize +} + +export const operationLogRetentionService = new OperationLogRetentionService() + +export { OperationLogRetentionService } diff --git a/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts b/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts index 49da064c..7c50dcd9 100644 --- a/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts +++ b/furniture-3d-system/src/server/services/supplier-recommended-products.service.ts @@ -1,5 +1,6 @@ import { SUPPLIER_RECOMMENDED_PRODUCT_FIELDS, + SUPPLIER_RECOMMENDED_PRODUCT_REQUIRED_KEYS, supplierRecommendedProductInputSchema, type SupplierRecommendedProductChange, type SupplierRecommendedProductFilters, @@ -226,9 +227,18 @@ export class SupplierRecommendedProductsService { if (!rows.length) throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '导入文件没有可用数据') if (rows.length > 5_000) throw new AppError(ErrorCode.VALIDATION_OUT_OF_RANGE, '单次最多导入5000行') const importDate = currentImportDate() + const requiredFields = SUPPLIER_RECOMMENDED_PRODUCT_FIELDS.filter((field) => SUPPLIER_RECOMMENDED_PRODUCT_REQUIRED_KEYS.includes(field.key)) let created = 0 for (let index = 0; index < rows.length; index += 1) { - const values = parseInput({ ...rows[index], recommendationDate: importDate }, index + 2) + const rowNumber = index + 2 + const rowValues = rows[index] + const shippingMode = String(rowValues.shippingMode ?? '').trim() + const missing = requiredFields + .filter((field) => field.key !== 'dropShippingFee' || shippingMode.includes('代发')) + .filter((field) => String(rowValues[field.key] ?? '').trim() === '') + .map((field) => field.label) + if (missing.length) throw new AppError(ErrorCode.VALIDATION_FAILED, `第${rowNumber}行:缺少必填字段:${missing.join('、')}`) + const values = parseInput({ ...rowValues, recommendationDate: importDate }, rowNumber) await this.repository.importCreate(values, user, changeSet(null, values)) created += 1 onProgress?.({ processed: index + 1, total: rows.length, created, updated: 0 })