From 0f223f7f91d1bd24e7a2cc0ce6ce9ae3e3258b10 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Wed, 22 Jul 2026 14:14:55 +0800 Subject: [PATCH] feat: harden platform workflows and UI governance --- api/package-lock.json | 818 ++++++++++++++---- api/package.json | 12 +- .../migration.sql | 69 ++ api/src/app.module.ts | 2 + api/src/auth/auth.controller.ts | 63 +- .../session-validation.middleware.spec.ts | 34 +- api/src/auth/session-validation.middleware.ts | 23 +- api/src/auth/session.service.spec.ts | 8 + api/src/auth/session.service.ts | 9 +- api/src/billing/billing.controller.ts | 11 +- api/src/billing/billing.service.spec.ts | 80 +- api/src/billing/billing.service.ts | 151 +++- api/src/channels/channels.controller.ts | 8 +- api/src/channels/channels.module.ts | 2 + .../deletion-governance.controller.ts | 40 + .../deletion-governance.module.ts | 10 + .../deletion-governance.service.spec.ts | 110 +++ .../deletion-governance.service.ts | 168 ++++ .../dictionaries/dictionaries.service.spec.ts | 17 + api/src/files/client-files.controller.ts | 41 + api/src/files/files.module.ts | 3 +- api/src/files/files.service.spec.ts | 40 + api/src/files/files.service.ts | 39 + .../operations/admin-operations.controller.ts | 5 + .../client-operations.controller.ts | 21 +- api/src/operations/operations.service.spec.ts | 87 ++ api/src/operations/operations.service.ts | 209 ++++- api/src/prisma/prisma.service.spec.ts | 13 + api/src/prisma/prisma.service.ts | 1 + .../report-materials.controller.ts | 5 + .../report-materials.service.spec.ts | 54 +- .../report-materials.service.ts | 212 ++++- api/src/send-chain/send-chain.service.spec.ts | 317 ++++++- api/src/send-chain/send-chain.service.ts | 406 +++++++-- .../sms-config/admin-sms-config.controller.ts | 19 +- .../client-sms-config.controller.ts | 12 +- .../review-governance.controller.ts | 27 + .../review-governance.service.spec.ts | 85 ++ .../sms-config/review-governance.service.ts | 158 ++++ api/src/sms-config/sms-config.module.ts | 8 +- api/src/sms-config/sms-config.service.spec.ts | 67 +- api/src/sms-config/sms-config.service.ts | 44 +- ...20260720-receipt-report-migration-notes.md | 4 + .../first-version-development-requirements.md | 60 ++ docs/system-functional-test-cases.md | 138 +++ docs/testing-progress.md | 104 +++ src/api/adminApi.ts | 284 ++++-- src/api/session.ts | 120 ++- src/apps/LoginPage.tsx | 11 +- src/apps/admin/AdminChannelsPage.tsx | 25 +- src/apps/admin/AdminCustomersPage.tsx | 85 +- .../admin/AdminDownstreamDeliveriesPage.tsx | 2 + .../admin/AdminEnterpriseSignaturesPage.tsx | 12 +- .../admin/AdminEnterpriseTemplatesPage.tsx | 28 +- src/apps/admin/AdminRechargeRecordsPage.tsx | 89 +- src/apps/admin/AdminReportMaterialsPage.tsx | 50 +- src/apps/admin/AdminSignatureAuditPage.tsx | 10 +- src/apps/admin/AdminSystemLogsPage.tsx | 6 +- src/apps/admin/AdminTemplateAuditPage.tsx | 24 +- src/apps/admin/AdminUsersPage.tsx | 2 +- src/apps/client/ClientEnterpriseAuthPage.tsx | 2 +- src/apps/client/ClientHome.tsx | 3 - src/apps/client/ClientSendDetailPage.tsx | 4 +- src/apps/client/ClientSignaturesPage.tsx | 9 +- src/apps/client/ClientSystemLogsPage.tsx | 5 +- src/apps/client/ClientTemplatesPage.tsx | 20 +- src/apps/client/ClientUsersPage.tsx | 2 +- src/components/ui/DeleteRiskAction.tsx | 85 ++ src/components/ui/FileActions.tsx | 7 +- src/components/ui/ManualRechargeDialog.tsx | 162 ++++ src/components/ui/Modal.tsx | 247 +++++- src/components/ui/RiskAction.tsx | 99 +++ src/components/ui/SystemLogExport.tsx | 82 ++ src/components/ui/index.ts | 5 + src/layouts/AdminLayout.tsx | 18 +- src/layouts/AppShell.tsx | 79 +- src/layouts/ClientLayout.tsx | 11 +- src/layouts/PortalSessionBoundary.tsx | 56 ++ src/styles/components.css | 33 +- src/styles/global.css | 231 +++++ 80 files changed, 4958 insertions(+), 764 deletions(-) create mode 100644 api/prisma/migrations/20260721150000_backfill_misattributed_delivery_receipts/migration.sql create mode 100644 api/src/deletion-governance/deletion-governance.controller.ts create mode 100644 api/src/deletion-governance/deletion-governance.module.ts create mode 100644 api/src/deletion-governance/deletion-governance.service.spec.ts create mode 100644 api/src/deletion-governance/deletion-governance.service.ts create mode 100644 api/src/files/client-files.controller.ts create mode 100644 api/src/prisma/prisma.service.spec.ts create mode 100644 api/src/sms-config/review-governance.controller.ts create mode 100644 api/src/sms-config/review-governance.service.spec.ts create mode 100644 api/src/sms-config/review-governance.service.ts create mode 100644 src/components/ui/DeleteRiskAction.tsx create mode 100644 src/components/ui/ManualRechargeDialog.tsx create mode 100644 src/components/ui/RiskAction.tsx create mode 100644 src/components/ui/SystemLogExport.tsx create mode 100644 src/layouts/PortalSessionBoundary.tsx diff --git a/api/package-lock.json b/api/package-lock.json index 8365881..7133a5e 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -13,8 +13,8 @@ "@nestjs/core": "^11.1.28", "@nestjs/platform-express": "^11.1.28", "@nestjs/swagger": "^11.2.3", - "@prisma/adapter-pg": "^7.8.0", - "@prisma/client": "^7.0.1", + "@prisma/adapter-pg": "^7.9.0", + "@prisma/client": "^7.9.0", "bullmq": "^5.79.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", @@ -29,7 +29,7 @@ "@types/jest": "^30.0.0", "@types/node": "^25.9.3", "jest": "^30.4.2", - "prisma": "^7.0.1", + "prisma": "^7.9.0", "ts-jest": "^29.4.11", "ts-node": "^10.9.2", "typescript": "^6.0.3" @@ -586,33 +586,33 @@ } }, "node_modules/@electric-sql/pglite": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", - "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", + "integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", - "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.3.tgz", + "integrity": "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==", "devOptional": true, "license": "Apache-2.0", "bin": { "pglite-server": "dist/scripts/server.js" }, "peerDependencies": { - "@electric-sql/pglite": "0.4.1" + "@electric-sql/pglite": "0.4.3" } }, "node_modules/@electric-sql/pglite-tools": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", - "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.3.tgz", + "integrity": "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==", "devOptional": true, "license": "Apache-2.0", "peerDependencies": { - "@electric-sql/pglite": "0.4.1" + "@electric-sql/pglite": "0.4.3" } }, "node_modules/@emnapi/core": { @@ -690,19 +690,6 @@ "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", "license": "MIT" }, - "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@ioredis/commands": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", @@ -1224,13 +1211,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@kurkle/color": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", - "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", - "devOptional": true, - "license": "MIT" - }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -1503,9 +1483,9 @@ } }, "node_modules/@nodable/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", "funding": [ { "type": "github", @@ -1539,24 +1519,24 @@ } }, "node_modules/@prisma/adapter-pg": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", - "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.9.0.tgz", + "integrity": "sha512-kPYuFvNTlqnaFf2UpXBBG3ycTT3PL76uSZtLFBEwDytjMMUW8ZHrsb9cSNIarzdPW5EXWmBOeOq9/MVjMtbWkA==", "license": "Apache-2.0", "dependencies": { - "@prisma/driver-adapter-utils": "7.8.0", + "@prisma/driver-adapter-utils": "7.9.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "node_modules/@prisma/client": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", - "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.9.0.tgz", + "integrity": "sha512-BTG/mB+WL/1sD2gWwdNc2uuVJjNNBgCDlPFdjco6jJArgbg4IAChtzVeW4debFa/NKBbsGedCjET316sjllWTQ==", "license": "Apache-2.0", "dependencies": { - "@prisma/client-runtime-utils": "7.8.0" + "@prisma/client-runtime-utils": "7.9.0" }, "engines": { "node": "^20.19 || ^22.12 || >=24.0" @@ -1575,15 +1555,15 @@ } }, "node_modules/@prisma/client-runtime-utils": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", - "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.9.0.tgz", + "integrity": "sha512-kMVmS4ZEy3xlkca+TfxOEm/ToVVlOS2x1Tc6/wIRf/HfczBqENtSPcKszy4ZpFNzjJ8SRKvlU5V0rrpoFw2KOg==", "license": "Apache-2.0" }, "node_modules/@prisma/config": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", - "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.9.0.tgz", + "integrity": "sha512-CsoK2mhl0u+N4/8V+XroQMOUNIic4isqD+E2HBG8l1yGEKo62CFDu3FHo0FdwItjl6XkW+omA1STSzeN1DAXlg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -1594,29 +1574,27 @@ } }, "node_modules/@prisma/debug": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", - "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.9.0.tgz", + "integrity": "sha512-i0KdVQuKUE6N9NloHs+sUNAk2c9svR3myBndQbA3BoeoArsSpwtNgTdHZL+wBtCLCcdS2OOC/PKhgTe36jkF5A==", "license": "Apache-2.0" }, "node_modules/@prisma/dev": { - "version": "0.24.3", - "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", - "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "version": "0.24.14", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.14.tgz", + "integrity": "sha512-NhFO49O2JPTdzYiLHvceQn/HiwmcKF/iGV39ko3CpYsoGqS3rz3ko6gzuxFSIeHNwNJeuNcDexyyGeTO3DW80A==", "devOptional": true, "license": "ISC", "dependencies": { - "@electric-sql/pglite": "0.4.1", - "@electric-sql/pglite-socket": "0.1.1", - "@electric-sql/pglite-tools": "0.3.1", - "@hono/node-server": "1.19.11", + "@electric-sql/pglite": "0.4.3", + "@electric-sql/pglite-socket": "0.1.3", + "@electric-sql/pglite-tools": "0.3.3", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", - "@prisma/streams-local": "0.1.2", + "@prisma/streams-local": "0.1.11", + "find-my-way": "9.6.0", "foreground-child": "3.3.1", "get-port-please": "3.2.0", - "hono": "^4.12.8", - "http-status-codes": "2.3.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", @@ -1626,65 +1604,65 @@ } }, "node_modules/@prisma/driver-adapter-utils": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", - "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.9.0.tgz", + "integrity": "sha512-fFXujitfMyjk3kOd1Tbs5FXBm6i2OWwEhaP5lHgkUM99jHpPEQwCWj+z/WKPFq6EDMThE1zGzSlVegtR0Pmu2w==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0" + "@prisma/debug": "7.9.0" } }, "node_modules/@prisma/engines": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", - "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.9.0.tgz", + "integrity": "sha512-lDWJp/pgSWCLfYsupmmNo96jfsbQnH1yjia8XVM2Kh8nRZhD0bQU2jCHuy3ZTPMLR3apRD3k145ybENalAYjYw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0", - "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", - "@prisma/fetch-engine": "7.8.0", - "@prisma/get-platform": "7.8.0" + "@prisma/debug": "7.9.0", + "@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", + "@prisma/fetch-engine": "7.9.0", + "@prisma/get-platform": "7.9.0" } }, "node_modules/@prisma/engines-version": { - "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", - "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad.tgz", + "integrity": "sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", - "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.9.0.tgz", + "integrity": "sha512-4awv6ATdgrHdLms0XKikCyfArn8BrUHZfqg0mtCKrI4+WJe24nmpsdwsypM9ozd03wa846AngY+zSbnngkMrXQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0" + "@prisma/debug": "7.9.0" } }, "node_modules/@prisma/fetch-engine": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", - "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.9.0.tgz", + "integrity": "sha512-F0XlIgjbE3EywRVR/HpCerNI/dxo40vK66tHcWpsWYwH/Jk9+FsICEzATeMsZ7bdnpZz93hkD4sAb5rKLsCCpA==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0", - "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", - "@prisma/get-platform": "7.8.0" + "@prisma/debug": "7.9.0", + "@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", + "@prisma/get-platform": "7.9.0" } }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", - "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.9.0.tgz", + "integrity": "sha512-4awv6ATdgrHdLms0XKikCyfArn8BrUHZfqg0mtCKrI4+WJe24nmpsdwsypM9ozd03wa846AngY+zSbnngkMrXQ==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.8.0" + "@prisma/debug": "7.9.0" } }, "node_modules/@prisma/get-platform": { @@ -1712,9 +1690,9 @@ "license": "Apache-2.0" }, "node_modules/@prisma/streams-local": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", - "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.11.tgz", + "integrity": "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { @@ -1724,19 +1702,28 @@ "proper-lockfile": "^4.1.2" }, "engines": { - "bun": ">=1.3.6", + "bun": ">=1.2.0", "node": ">=22.0.0" } }, "node_modules/@prisma/studio-core": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", - "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.33.0.tgz", + "integrity": "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { "@radix-ui/react-toggle": "1.1.10", - "chart.js": "4.5.1" + "@visx/curve": "4.0.1-alpha.0", + "@visx/event": "4.0.1-alpha.0", + "@visx/grid": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/responsive": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "d3-array": "3.2.4", + "d3-shape": "3.2.0", + "elkjs": "0.11.1" }, "engines": { "node": "^20.19 || ^22.12 || >=24.0", @@ -2043,6 +2030,102 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", + "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", + "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", + "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2081,6 +2164,13 @@ "pretty-format": "^30.0.0" } }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", @@ -2107,7 +2197,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2492,6 +2581,157 @@ "win32" ] }, + "node_modules/@visx/curve": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz", + "integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/event": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz", + "integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/point": "4.0.1-alpha.0" + } + }, + "node_modules/@visx/grid": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz", + "integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/point": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/group": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz", + "integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/point": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz", + "integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@visx/responsive": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz", + "integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/scale": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz", + "integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/shape": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz", + "integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/vendor": "4.0.0-alpha.0", + "classnames": "^2.3.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/vendor": { + "version": "4.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz", + "integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==", + "devOptional": true, + "license": "MIT and ISC", + "dependencies": { + "@types/d3-array": "3.0.3", + "@types/d3-color": "3.1.0", + "@types/d3-delaunay": "6.0.1", + "@types/d3-format": "3.0.1", + "@types/d3-geo": "3.1.0", + "@types/d3-interpolate": "3.0.1", + "@types/d3-path": "3.1.1", + "@types/d3-scale": "4.0.2", + "@types/d3-shape": "3.1.7", + "@types/d3-time": "3.0.0", + "@types/d3-time-format": "2.1.0", + "d3-array": "3.2.1", + "d3-color": "3.1.0", + "d3-delaunay": "6.0.2", + "d3-format": "3.1.0", + "d3-geo": "3.1.0", + "d3-interpolate": "3.0.1", + "d3-path": "3.1.0", + "d3-scale": "4.0.2", + "d3-shape": "3.2.0", + "d3-time": "3.1.0", + "d3-time-format": "4.1.0", + "internmap": "2.0.3" + } + }, + "node_modules/@visx/vendor/node_modules/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2927,9 +3167,9 @@ } }, "node_modules/better-result": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", - "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz", + "integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==", "devOptional": true, "license": "MIT" }, @@ -3019,9 +3259,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -3359,19 +3599,6 @@ "node": ">=10" } }, - "node_modules/chart.js": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", - "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@kurkle/color": "^0.3.0" - }, - "engines": { - "pnpm": ">=8" - } - }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -3428,6 +3655,13 @@ "validator": "^13.15.22" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "devOptional": true, + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3739,8 +3973,145 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, - "license": "MIT", - "peer": true + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", + "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } }, "node_modules/dayjs": { "version": "1.11.21", @@ -3816,6 +4187,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -3994,6 +4375,13 @@ "dev": true, "license": "ISC" }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "devOptional": true, + "license": "EPL-2.0" + }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -4315,6 +4703,13 @@ "node": ">=10.0.0" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4329,6 +4724,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -4336,9 +4741,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "devOptional": true, "funding": [ { @@ -4369,9 +4774,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", - "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", "funding": [ { "type": "github", @@ -4380,17 +4785,32 @@ ], "license": "MIT", "dependencies": { - "@nodable/entities": "^2.2.0", + "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", - "is-unsafe": "^1.0.1", - "path-expression-matcher": "^1.5.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", "strnum": "^2.4.1", - "xml-naming": "^0.1.0" + "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, + "node_modules/fast-xml-parser/node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -4449,6 +4869,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -4698,9 +5133,9 @@ "license": "ISC" }, "node_modules/grammex": { - "version": "3.1.12", - "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", - "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "version": "3.1.13", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.13.tgz", + "integrity": "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==", "devOptional": true, "license": "MIT" }, @@ -4767,16 +5202,6 @@ "node": ">= 0.4" } }, - "node_modules/hono": { - "version": "4.12.27", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", - "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", - "devOptional": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4804,13 +5229,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/http-status-codes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", - "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", - "devOptional": true, - "license": "MIT" - }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4910,6 +5328,16 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ioredis": { "version": "5.11.1", "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", @@ -4995,9 +5423,9 @@ } }, "node_modules/is-unsafe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", - "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", "funding": [ { "type": "github", @@ -6706,9 +7134,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.1.tgz", - "integrity": "sha512-h7bxdzhHk8Knyc4Tj+jMaa7fEEoUJy7p1qtbVgkYg1Uhpe5Np5VuGXCRZnkZvU+Q42M1vStt0ifa3ueykRJPmQ==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", "funding": [ { "type": "github", @@ -7023,17 +7451,17 @@ } }, "node_modules/prisma": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", - "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.9.0.tgz", + "integrity": "sha512-isQTJEK4pyOlAVzm6kBUDjzgdsgs0A/snpB38ycTHeOHW34qfepP+ClQltgDXqjZBnXALhEtE4duh9L3tN5fHw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/config": "7.8.0", - "@prisma/dev": "0.24.3", - "@prisma/engines": "7.8.0", - "@prisma/studio-core": "0.27.3", + "@prisma/config": "7.9.0", + "@prisma/dev": "0.24.14", + "@prisma/engines": "7.9.0", + "@prisma/studio-core": "0.33.0", "mysql2": "3.15.3", "postgres": "3.4.7" }, @@ -7185,9 +7613,9 @@ } }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "devOptional": true, "license": "MIT", "peer": true, @@ -7196,9 +7624,9 @@ } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "devOptional": true, "license": "MIT", "peer": true, @@ -7206,7 +7634,7 @@ "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/react-is-18": { @@ -7354,6 +7782,16 @@ "node": ">=8" } }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -7420,6 +7858,13 @@ "node": "*" } }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "devOptional": true, + "license": "Unlicense" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -7465,6 +7910,29 @@ ], "license": "MIT" }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -8100,9 +8568,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/api/package.json b/api/package.json index df1ba40..b77f07f 100644 --- a/api/package.json +++ b/api/package.json @@ -19,8 +19,8 @@ "@nestjs/core": "^11.1.28", "@nestjs/platform-express": "^11.1.28", "@nestjs/swagger": "^11.2.3", - "@prisma/adapter-pg": "^7.8.0", - "@prisma/client": "^7.0.1", + "@prisma/adapter-pg": "^7.9.0", + "@prisma/client": "^7.9.0", "bullmq": "^5.79.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", @@ -35,20 +35,14 @@ "@types/jest": "^30.0.0", "@types/node": "^25.9.3", "jest": "^30.4.2", - "prisma": "^7.0.1", + "prisma": "^7.9.0", "ts-jest": "^29.4.11", "ts-node": "^10.9.2", "typescript": "^6.0.3" }, - "overrides": { - "@hono/node-server": "1.19.13" - }, "overrides": { "exceljs": { "uuid": "11.1.1" - }, - "@prisma/dev": { - "@hono/node-server": "1.19.13" } } } diff --git a/api/prisma/migrations/20260721150000_backfill_misattributed_delivery_receipts/migration.sql b/api/prisma/migrations/20260721150000_backfill_misattributed_delivery_receipts/migration.sql new file mode 100644 index 0000000..aa7291e --- /dev/null +++ b/api/prisma/migrations/20260721150000_backfill_misattributed_delivery_receipts/migration.sql @@ -0,0 +1,69 @@ +-- Historical receipts created before channel-aware matching could be attached to +-- the right message but retain the wrong channel when two channels shared the +-- same supplier account. Only repair rows that have exactly one submit record +-- for the same message, upstream message id and destination phone number. +WITH unique_receipt_submit AS ( + SELECT + receipt.id AS receipt_id, + MIN(submit."channelId") AS correct_channel_id + FROM "SmsReceiptRecord" receipt + JOIN "SmsMessageRecord" message + ON message.id = receipt."messageRecordId" + JOIN "SmsSubmitRecord" submit + ON submit."messageRecordId" = message.id + AND submit."gatewayMessageId" = receipt."gatewayMessageId" + WHERE receipt."receiptStatus" = 'delivered' + AND receipt."phoneNumber" = message."phoneNumber" + GROUP BY receipt.id + HAVING COUNT(submit.id) = 1 +) +UPDATE "SmsReceiptRecord" receipt +SET "channelId" = matched.correct_channel_id +FROM unique_receipt_submit matched +WHERE receipt.id = matched.receipt_id + AND receipt."channelId" IS DISTINCT FROM matched.correct_channel_id; + +-- Aggregate the corrected successful receipt onto the message master record. +-- DISTINCT ON chooses the first successful receipt for segmented messages while +-- preserving the original submit/gateway identity of the current attempt. +WITH delivered_match AS ( + SELECT DISTINCT ON (message.id) + message.id AS message_record_id, + submit."channelId" AS channel_id, + receipt."gatewayMessageId" AS gateway_message_id, + receipt."rawStatus" AS raw_status, + receipt."errorCode" AS error_code, + receipt."errorMessage" AS error_message, + receipt."deliveredAt" AS delivered_at + FROM "SmsMessageRecord" message + JOIN "SmsReceiptRecord" receipt + ON receipt."messageRecordId" = message.id + AND receipt."receiptStatus" = 'delivered' + AND receipt."phoneNumber" = message."phoneNumber" + JOIN "SmsSubmitRecord" submit + ON submit."messageRecordId" = message.id + AND submit."gatewayMessageId" = receipt."gatewayMessageId" + AND submit."channelId" = receipt."channelId" + WHERE message."gatewayMessageId" = receipt."gatewayMessageId" + ORDER BY message.id, receipt."deliveredAt" ASC, receipt.id ASC +) +UPDATE "SmsMessageRecord" message +SET + status = 'delivered', + "receiptStatus" = 'delivered', + "receiptRawStatus" = matched.raw_status, + "channelId" = matched.channel_id, + "gatewayMessageId" = matched.gateway_message_id, + "errorCode" = matched.error_code, + "errorMessage" = matched.error_message, + "deliveredAt" = matched.delivered_at, + "timeoutAt" = NULL, + "updatedAt" = CURRENT_TIMESTAMP +FROM delivered_match matched +WHERE message.id = matched.message_record_id + AND ( + message.status IS DISTINCT FROM 'delivered' + OR message."receiptStatus" IS DISTINCT FROM 'delivered' + OR message."deliveredAt" IS DISTINCT FROM matched.delivered_at + OR message."channelId" IS DISTINCT FROM matched.channel_id + ); diff --git a/api/src/app.module.ts b/api/src/app.module.ts index f5f8070..ea41adb 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -9,6 +9,7 @@ import { BillingModule } from './billing/billing.module'; import { ChannelsModule } from './channels/channels.module'; import { CertificationModule } from './certification/certification.module'; import { DictionariesModule } from './dictionaries/dictionaries.module'; +import { DeletionGovernanceModule } from './deletion-governance/deletion-governance.module'; import { FilesModule } from './files/files.module'; import { HealthController } from './health.controller'; import { OperationsModule } from './operations/operations.module'; @@ -35,6 +36,7 @@ import { UsersModule } from './users/users.module'; AuditModule, FilesModule, DictionariesModule, + DeletionGovernanceModule, BillingModule, CertificationModule, SmsConfigModule, diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index 6a6ecbb..4861fc7 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Get, Post, Req, Res, UnauthorizedException } from '@n import { ApiTags } from '@nestjs/swagger'; import { CurrentSessionUserId } from './current-session-user.decorator'; import { AuthService, LoginDto } from './auth.service'; -import { SessionService } from './session.service'; +import { DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionPortal, SessionService } from './session.service'; import type { SessionRequest } from './session-validation.middleware'; import { UsersService } from '../users/users.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -38,13 +38,31 @@ export class AuthController { return this.finishLogin(await this.auth.login(body, 'client'), request, response); } - @Get('auth/session') - currentSession(@Req() request: SessionRequest) { + @Get(['admin/auth/session', 'client/auth/session']) + async currentSession(@Req() request: SessionRequest) { this.assertSession(request); - return this.sessions.publicSession(request.authSession!); + const user = await this.prisma.user.findUniqueOrThrow({ + where: { id: request.sessionUserId! }, + include: { tenant: true, roles: { include: { role: true } } }, + }); + return { + portal: request.authSession!.portal, + locked: Boolean(request.authSession!.lockedAt), + user: { + id: user.id, + tenantId: user.tenantId, + tenantName: user.tenant?.name, + username: user.username, + email: user.email, + phone: user.phone, + displayName: user.displayName, + roles: user.roles.map((item) => item.role.code), + }, + ...this.sessions.publicSession(request.authSession!), + }; } - @Post('auth/session/touch') + @Post(['admin/auth/session/touch', 'client/auth/session/touch']) async touch(@Req() request: SessionRequest) { this.assertSession(request); const result = await this.sessions.touch(request.sessionToken!); @@ -52,7 +70,7 @@ export class AuthController { return this.sessions.publicSession(result.record); } - @Post('auth/session/lock') + @Post(['admin/auth/session/lock', 'client/auth/session/lock']) async lock(@Req() request: SessionRequest) { this.assertSession(request); const record = await this.sessions.lock(request.sessionToken!); @@ -60,17 +78,17 @@ export class AuthController { return { locked: Boolean(record) }; } - @Post('auth/session/unlock') + @Post(['admin/auth/session/unlock', 'client/auth/session/unlock']) async unlock(@Req() request: SessionRequest, @Body('password') password: string, @Res({ passthrough: true }) response: CookieResponse) { this.assertSession(request); const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password); if (result.status !== 'active' || !('token' in result)) throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' }); - this.setCookie(response, result.token); + this.setCookie(response, result.record.portal, result.token); await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal }); return this.sessions.publicSession(result.record); } - @Post('auth/reauthenticate') + @Post(['admin/auth/reauthenticate', 'client/auth/reauthenticate']) async reauthenticate(@Req() request: SessionRequest, @Body('password') password: string) { this.assertSession(request); const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password); @@ -79,22 +97,23 @@ export class AuthController { return this.sessions.publicSession(result.record); } - @Post('auth/logout') + @Post(['admin/auth/logout', 'client/auth/logout']) async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) { if (request.sessionToken) await this.sessions.remove(request.sessionToken); if (request.sessionUserId) await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal }); - this.clearCookie(response); + this.clearCookie(response, request.authSession?.portal); return { success: true }; } - @Post('auth/password') + @Post(['admin/auth/password', 'client/auth/password']) changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) { if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录'); return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? ''); } private async finishLogin(result: Awaited>, request: SessionRequest, response: CookieResponse) { - this.setCookie(response, result.sessionToken); + this.setCookie(response, result.portal, result.sessionToken); + this.clearLegacyCookie(response); await this.prisma.operationLog.create({ data: { userId: result.user.id, tenantId: result.user.tenantId, action: 'auth.session_created', resource: 'auth_session', userAgent: request.header('user-agent'), detail: { portal: result.portal } }, }); @@ -114,8 +133,8 @@ export class AuthController { } } - private setCookie(response: CookieResponse, token: string) { - response.cookie(this.sessions.cookieName, token, { + private setCookie(response: CookieResponse, portal: SessionPortal, token: string) { + response.cookie(this.sessions.cookieName(portal), token, { httpOnly: true, secure: this.sessions.cookieSecure, sameSite: 'lax', @@ -123,8 +142,18 @@ export class AuthController { }); } - private clearCookie(response: CookieResponse) { - response.clearCookie(this.sessions.cookieName, { + private clearCookie(response: CookieResponse, portal?: SessionPortal) { + if (!portal) return; + response.clearCookie(this.sessions.cookieName(portal), { + httpOnly: true, + secure: this.sessions.cookieSecure, + sameSite: 'lax', + path: '/', + }); + } + + private clearLegacyCookie(response: CookieResponse) { + response.clearCookie(this.sessions.cookieSecure ? SESSION_COOKIE_NAME : DEVELOPMENT_SESSION_COOKIE_NAME, { httpOnly: true, secure: this.sessions.cookieSecure, sameSite: 'lax', diff --git a/api/src/auth/session-validation.middleware.spec.ts b/api/src/auth/session-validation.middleware.spec.ts index d89cf00..198d2bf 100644 --- a/api/src/auth/session-validation.middleware.spec.ts +++ b/api/src/auth/session-validation.middleware.spec.ts @@ -6,7 +6,7 @@ const record = { lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000, }; -function request(path = '/api/admin/users', cookie = 'cmpp_session=opaque-token'): SessionRequest { +function request(path = '/api/admin/users', cookie = 'cmpp_admin_session=opaque-token'): SessionRequest { return { originalUrl: path, header: jest.fn((name: string) => name === 'cookie' ? cookie : undefined), @@ -14,9 +14,11 @@ function request(path = '/api/admin/users', cookie = 'cmpp_session=opaque-token' } describe('SessionValidationMiddleware', () => { + const cookieName = jest.fn((portal: 'admin' | 'client') => `cmpp_${portal}_session`); + it('accepts an active Redis session and exposes its user and record', async () => { const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } }; - const sessions = { validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() }; + const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() }; const middleware = new SessionValidationMiddleware(prisma as never, sessions as never); const currentRequest = request(); const next = jest.fn(); @@ -31,7 +33,7 @@ describe('SessionValidationMiddleware', () => { it('rejects a session after the user session version changes', async () => { const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 4 }) } }; - const sessions = { validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() }; + const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() }; const middleware = new SessionValidationMiddleware(prisma as never, sessions as never); await expect(middleware.use(request(), {}, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException); @@ -40,12 +42,34 @@ describe('SessionValidationMiddleware', () => { it('only lets a locked session reach unlock and logout endpoints', async () => { const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } }; - const sessions = { validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() }; + const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() }; const middleware = new SessionValidationMiddleware(prisma as never, sessions as never); await expect(middleware.use(request(), {}, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException); const next = jest.fn(); - await middleware.use(request('/api/auth/session/unlock'), {}, next); + await middleware.use(request('/api/admin/auth/session/unlock'), {}, next); expect(next).toHaveBeenCalled(); }); + + it('selects only the cookie belonging to the requested portal', async () => { + const clientRecord = { ...record, portal: 'client' as const }; + const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } }; + const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() }; + const middleware = new SessionValidationMiddleware(prisma as never, sessions as never); + const currentRequest = request('/api/client/users', 'cmpp_admin_session=admin-token; cmpp_client_session=client-token'); + + await middleware.use(currentRequest, {}, jest.fn()); + + expect(sessions.validate).toHaveBeenCalledWith('client-token', false); + expect(currentRequest.sessionToken).toBe('client-token'); + }); + + it('does not accept an admin cookie for a client route', async () => { + const sessions = { cookieName, validate: jest.fn(), remove: jest.fn() }; + const middleware = new SessionValidationMiddleware({} as never, sessions as never); + + await expect(middleware.use(request('/api/client/users', 'cmpp_admin_session=admin-token'), {}, jest.fn())) + .rejects.toBeInstanceOf(UnauthorizedException); + expect(sessions.validate).not.toHaveBeenCalled(); + }); }); diff --git a/api/src/auth/session-validation.middleware.ts b/api/src/auth/session-validation.middleware.ts index f238e3f..3c12a80 100644 --- a/api/src/auth/session-validation.middleware.ts +++ b/api/src/auth/session-validation.middleware.ts @@ -1,6 +1,6 @@ import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { AuthSessionRecord, DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionService } from './session.service'; +import { AuthSessionRecord, SessionPortal, SessionService } from './session.service'; export type SessionRequest = { header(name: string): string | undefined; @@ -22,9 +22,10 @@ export class SessionValidationMiddleware implements NestMiddleware { return; } - const token = this.readCookie(request.header('cookie')); + const portal = this.portalForPath(path); + const token = portal ? this.readCookie(request.header('cookie'), portal) : undefined; if (!token) { - if ((path.includes('/admin/') && !path.includes('/admin/gateway/')) || path.includes('/client/') || path.includes('/auth/')) { + if (portal && !path.includes('/admin/gateway/')) { throw this.unauthorized('SESSION_INVALID', '请先登录'); } next(); @@ -43,14 +44,15 @@ export class SessionValidationMiddleware implements NestMiddleware { await this.sessions.remove(token); throw this.unauthorized('SESSION_REVOKED', '登录会话已被撤销,请重新登录'); } - if ((path.includes('/admin/') && result.record.portal !== 'admin') || (path.includes('/client/') && result.record.portal !== 'client')) { + if (result.record.portal !== portal) { throw this.unauthorized('SESSION_PORTAL_MISMATCH', '登录入口与当前会话不匹配'); } request.sessionUserId = user.id; request.sessionToken = token; request.authSession = result.record; - if (result.status === 'locked' && !path.includes('/auth/session/unlock') && !path.includes('/auth/logout')) { + const isSessionRecoveryRoute = /\/auth\/(?:session(?:\/unlock)?|logout)(?:\?|$)/.test(path); + if (result.status === 'locked' && !isSessionRecoveryRoute) { if (result.newlyLocked) { await this.prisma.operationLog.create({ data: { userId: user.id, action: 'auth.session_locked', resource: 'auth_session', detail: { portal: result.record.portal, reason: 'idle_timeout' } }, @@ -61,11 +63,18 @@ export class SessionValidationMiddleware implements NestMiddleware { next(); } - private readCookie(cookieHeader?: string) { + private portalForPath(path: string): SessionPortal | undefined { + if (/\/admin\//.test(path)) return 'admin'; + if (/\/client\//.test(path)) return 'client'; + return undefined; + } + + private readCookie(cookieHeader: string | undefined, portal: SessionPortal) { if (!cookieHeader) return undefined; + const cookieName = this.sessions.cookieName(portal); for (const part of cookieHeader.split(';')) { const [name, ...value] = part.trim().split('='); - if (name === SESSION_COOKIE_NAME || name === DEVELOPMENT_SESSION_COOKIE_NAME) return decodeURIComponent(value.join('=')); + if (name === cookieName) return decodeURIComponent(value.join('=')); } return undefined; } diff --git a/api/src/auth/session.service.spec.ts b/api/src/auth/session.service.spec.ts index ea0982f..08aa04f 100644 --- a/api/src/auth/session.service.spec.ts +++ b/api/src/auth/session.service.spec.ts @@ -70,4 +70,12 @@ describe('SessionService', () => { await expect(service.validate(result.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' })); } }); + + it('uses separate cookie names for admin and client sessions', () => { + process.env.SESSION_COOKIE_SECURE = 'false'; + const service = new SessionService(); + expect(service.cookieName('admin')).toBe('cmpp_admin_session'); + expect(service.cookieName('client')).toBe('cmpp_client_session'); + delete process.env.SESSION_COOKIE_SECURE; + }); }); diff --git a/api/src/auth/session.service.ts b/api/src/auth/session.service.ts index ef41eed..284b70d 100644 --- a/api/src/auth/session.service.ts +++ b/api/src/auth/session.service.ts @@ -23,6 +23,10 @@ export type SessionValidationResult = const SESSION_PREFIX = 'cmpp:auth:session:'; export const SESSION_COOKIE_NAME = '__Host-cmpp_session'; export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session'; +export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session'; +export const CLIENT_SESSION_COOKIE_NAME = '__Host-cmpp_client_session'; +export const DEVELOPMENT_ADMIN_SESSION_COOKIE_NAME = 'cmpp_admin_session'; +export const DEVELOPMENT_CLIENT_SESSION_COOKIE_NAME = 'cmpp_client_session'; @Injectable() export class SessionService implements OnModuleDestroy { @@ -137,8 +141,9 @@ export class SessionService implements OnModuleDestroy { return process.env.SESSION_COOKIE_SECURE === 'true' || (process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false'); } - get cookieName() { - return this.cookieSecure ? SESSION_COOKIE_NAME : DEVELOPMENT_SESSION_COOKIE_NAME; + cookieName(portal: SessionPortal) { + if (this.cookieSecure) return portal === 'admin' ? ADMIN_SESSION_COOKIE_NAME : CLIENT_SESSION_COOKIE_NAME; + return portal === 'admin' ? DEVELOPMENT_ADMIN_SESSION_COOKIE_NAME : DEVELOPMENT_CLIENT_SESSION_COOKIE_NAME; } get absoluteTimeoutMs() { diff --git a/api/src/billing/billing.controller.ts b/api/src/billing/billing.controller.ts index a892836..1f96b68 100644 --- a/api/src/billing/billing.controller.ts +++ b/api/src/billing/billing.controller.ts @@ -2,10 +2,12 @@ import { Body, Controller, Get, Param, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TenantId } from '../common/tenant-id.decorator'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { BillingService, BillingActionDto, CreateManualRechargeDto, + ManualRechargePreflightDto, CreateBillingRuleDto, CreateSmsBillingRecordDto, CreateTenantAccountDto, @@ -46,8 +48,13 @@ export class BillingController { @Post('manual-recharges') @RequireRecentAuthentication() - createManualRecharge(@Body() body: CreateManualRechargeDto) { - return this.billing.createManualRecharge(body); + createManualRecharge(@Body() body: CreateManualRechargeDto, @CurrentSessionUserId() operatorId?: string) { + return this.billing.createManualRecharge({ ...body, operatorId }); + } + + @Post('manual-recharges/preflight') + manualRechargePreflight(@Body() body: ManualRechargePreflightDto) { + return this.billing.manualRechargePreflight(body); } @Post('estimate') diff --git a/api/src/billing/billing.service.spec.ts b/api/src/billing/billing.service.spec.ts index 0cdfb11..9ff61f6 100644 --- a/api/src/billing/billing.service.spec.ts +++ b/api/src/billing/billing.service.spec.ts @@ -1,13 +1,22 @@ import { BillingService } from './billing.service'; function createPrismaMock() { - const accountState = { id: 'account-1', tenantId: 'tenant-1', balanceCents: 1000, creditCents: 0 }; - return { + const accountState = { id: 'account-1', tenantId: 'tenant-1', balanceCents: 1000, creditCents: 0, updatedAt: new Date('2026-07-21T10:00:00.000Z') }; + const prisma = { accountState, + tenant: { + findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }), + }, tenantAccount: { findMany: jest.fn(), + findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })), create: jest.fn(), upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })), + updateMany: jest.fn().mockImplementation(({ data }) => { + if (data.balanceCents?.increment !== undefined) accountState.balanceCents += data.balanceCents.increment; + accountState.updatedAt = new Date(accountState.updatedAt.getTime() + 1); + return Promise.resolve({ count: 1 }); + }), update: jest.fn().mockImplementation(({ data }) => { if (data.balanceCents !== undefined) accountState.balanceCents = data.balanceCents; if (data.creditCents !== undefined) accountState.creditCents = data.creditCents; @@ -16,10 +25,12 @@ function createPrismaMock() { }, accountTransaction: { findMany: jest.fn(), + findFirst: jest.fn(), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })), }, rechargeOrder: { findMany: jest.fn(), + findUnique: jest.fn(), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'order-1', ...data })), }, smsBillingRecord: { @@ -31,9 +42,14 @@ function createPrismaMock() { create: jest.fn(), }, operationLog: { - create: jest.fn(), + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: 'operation-1' }), }, + $executeRaw: jest.fn(), }; + return Object.assign(prisma, { + $transaction: jest.fn((callback: (client: typeof prisma) => unknown) => callback(prisma)), + }); } describe('BillingService', () => { @@ -129,6 +145,8 @@ describe('BillingService', () => { const order = await service.createManualRecharge({ tenantId: 'tenant-1', amountCents: 2000, + expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z', + idempotencyKey: 'manual-recharge-1', operatorId: 'admin-1', remark: '线下转账人工充值', }); @@ -153,6 +171,7 @@ describe('BillingService', () => { action: 'billing.manual_recharge', resource: 'recharge_order', resourceId: 'order-1', + detail: expect.objectContaining({ idempotencyKey: 'manual-recharge-1', previousBalanceCents: 1000, balanceAfterCents: 3000 }), }), }); }); @@ -189,14 +208,16 @@ describe('BillingService', () => { const order = await service.createManualRecharge({ tenantId: 'tenant-1', amountCents: -300, + expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z', + idempotencyKey: 'manual-correction-1', operatorId: 'admin-1', remark: '人工冲正', }); expect(order).toEqual(expect.objectContaining({ amountCents: -300, payMethod: 'manual_topup', status: 'paid' })); - expect(prisma.tenantAccount.update).toHaveBeenCalledWith({ - where: { tenantId: 'tenant-1' }, - data: { balanceCents: 700 }, + expect(prisma.tenantAccount.updateMany).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', updatedAt: new Date('2026-07-21T10:00:00.000Z') }, + data: { balanceCents: { increment: -300 } }, }); expect(prisma.accountTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ @@ -214,6 +235,53 @@ describe('BillingService', () => { }); }); + it('preflights a manual balance correction with the persisted account version and predicted balance', async () => { + const prisma = createPrismaMock(); + const service = new BillingService(prisma as never); + + await expect(service.manualRechargePreflight({ tenantId: 'tenant-1', amountCents: -250 })).resolves.toEqual( + expect.objectContaining({ + tenant: { id: 'tenant-1', name: '示例企业', code: 'TENANT-1' }, + expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z', + balanceCents: 1000, + amountCents: -250, + balanceAfterCents: 750, + direction: 'correction', + allowedActions: ['confirm'], + }), + ); + }); + + it('replays a manual recharge by idempotency key without creating another order', async () => { + const prisma = createPrismaMock(); + prisma.operationLog.findFirst.mockResolvedValue({ + id: 'operation-existing', tenantId: 'tenant-1', resourceId: 'order-existing', + detail: { idempotencyKey: 'manual-recharge-replay', amountCents: 2000 }, + }); + prisma.rechargeOrder.findUnique.mockResolvedValue({ id: 'order-existing', tenantId: 'tenant-1', amountCents: 2000 }); + prisma.accountTransaction.findFirst.mockResolvedValue({ balanceAfter: 3000 }); + const service = new BillingService(prisma as never); + + await expect(service.createManualRecharge({ + tenantId: 'tenant-1', amountCents: 2000, expectedAccountUpdatedAt: '2026-07-21T10:00:00.000Z', idempotencyKey: 'manual-recharge-replay', + })).resolves.toEqual(expect.objectContaining({ operationId: 'operation-existing', replayed: true, balanceAfterCents: 3000 })); + expect(prisma.rechargeOrder.create).not.toHaveBeenCalled(); + expect(prisma.tenantAccount.updateMany).not.toHaveBeenCalled(); + }); + + it('rejects an outdated account version before creating financial records', async () => { + const prisma = createPrismaMock(); + prisma.tenantAccount.updateMany.mockResolvedValue({ count: 0 }); + const service = new BillingService(prisma as never); + + await expect(service.createManualRecharge({ + tenantId: 'tenant-1', amountCents: 2000, expectedAccountUpdatedAt: '2026-07-21T09:59:00.000Z', idempotencyKey: 'manual-recharge-stale', + })).rejects.toThrow('企业余额已变化,请重新核对后再充值'); + expect(prisma.rechargeOrder.create).not.toHaveBeenCalled(); + expect(prisma.accountTransaction.create).not.toHaveBeenCalled(); + expect(prisma.operationLog.create).not.toHaveBeenCalled(); + }); + it('writes freeze, charge, release, refund, and adjustment transactions', async () => { const prisma = createPrismaMock(); const service = new BillingService(prisma as never); diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index 2a0bef8..2b8a74b 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { assertMoneyUnits, moneyToNumber } from '../common/money'; import { PrismaService } from '../prisma/prisma.service'; @@ -45,10 +45,17 @@ export interface CreateRechargeOrderDto { export interface CreateManualRechargeDto { tenantId: string; amountCents: number; + expectedAccountUpdatedAt: string; + idempotencyKey: string; operatorId?: string; remark?: string; } +export interface ManualRechargePreflightDto { + tenantId: string; + amountCents: number; +} + export interface EstimateSmsCostDto { tenantId: string; applicationId?: string; @@ -187,28 +194,130 @@ export class BillingService { } async createManualRecharge(data: CreateManualRechargeDto) { - const order = await this.createRechargeOrder({ - tenantId: data.tenantId, - amountCents: data.amountCents, - payMethod: 'manual_topup', - operatorId: data.operatorId, - remark: data.remark, + assertMoneyUnits(data.amountCents, '充值金额', { allowNegative: true, allowZero: false }); + const idempotencyKey = data.idempotencyKey?.trim(); + if (!idempotencyKey || idempotencyKey.length < 8 || idempotencyKey.length > 128) { + throw new BadRequestException('人工充值幂等键长度必须为 8 至 128 个字符'); + } + const expectedUpdatedAt = new Date(data.expectedAccountUpdatedAt); + if (Number.isNaN(expectedUpdatedAt.getTime())) throw new BadRequestException('账户版本无效,请重新核对充值信息'); + + const replay = await this.prisma.operationLog.findFirst({ + where: { action: 'billing.manual_recharge', detail: { path: ['idempotencyKey'], equals: idempotencyKey } }, + orderBy: { createdAt: 'desc' }, }); - await this.prisma.operationLog.create({ - data: { - tenantId: data.tenantId, - userId: data.operatorId, - action: 'billing.manual_recharge', - resource: 'recharge_order', - resourceId: order.id, - detail: { + if (replay) return this.manualRechargeReplay(replay, data); + + return this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'manual-recharge:' + idempotencyKey}))`; + const existing = await tx.operationLog.findFirst({ + where: { action: 'billing.manual_recharge', detail: { path: ['idempotencyKey'], equals: idempotencyKey } }, + orderBy: { createdAt: 'desc' }, + }); + if (existing) { + const detail = asRecord(existing.detail); + if (existing.tenantId !== data.tenantId || Number(detail.amountCents) !== data.amountCents) { + throw new ConflictException('该幂等键已用于另一笔人工充值'); + } + const order = existing.resourceId ? await tx.rechargeOrder.findUnique({ where: { id: existing.resourceId } }) : null; + if (!order) throw new ConflictException('幂等充值记录不完整,请联系管理员核查'); + const transaction = await tx.accountTransaction.findFirst({ + where: { relatedType: 'recharge_order', relatedId: order.id }, + orderBy: { createdAt: 'desc' }, + }); + return { ...order, balanceAfterCents: moneyToNumber(transaction?.balanceAfter), operationId: existing.id, replayed: true }; + } + + const tenant = await tx.tenant.findFirst({ where: { id: data.tenantId, status: { not: 'deleted' } }, select: { id: true } }); + if (!tenant) throw new NotFoundException('充值企业不存在或已删除'); + const account = await tx.tenantAccount.findUnique({ where: { tenantId: data.tenantId } }); + if (!account) throw new ConflictException('企业账户尚未初始化,请重新核对充值信息'); + const previousBalanceCents = moneyToNumber(account.balanceCents); + const changed = await tx.tenantAccount.updateMany({ + where: { tenantId: data.tenantId, updatedAt: expectedUpdatedAt }, + data: { balanceCents: { increment: data.amountCents } }, + }); + if (changed.count !== 1) throw new ConflictException('企业余额已变化,请重新核对后再充值'); + const balanceAfterCents = previousBalanceCents + data.amountCents; + const order = await tx.rechargeOrder.create({ + data: { + tenantId: data.tenantId, + orderNo: `MR${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`, amountCents: data.amountCents, - orderNo: order.orderNo, + status: 'paid', + payMethod: 'manual_topup', + paidAt: new Date(), + operatorId: data.operatorId, remark: data.remark, - } as Prisma.InputJsonValue, - }, + }, + }); + await tx.accountTransaction.create({ + data: { + tenantId: data.tenantId, + transactionType: 'recharge', + amountCents: data.amountCents, + balanceAfter: balanceAfterCents, + relatedType: 'recharge_order', + relatedId: order.id, + remark: data.remark, + }, + }); + const operation = await tx.operationLog.create({ + data: { + tenantId: data.tenantId, + userId: data.operatorId, + action: 'billing.manual_recharge', + resource: 'recharge_order', + resourceId: order.id, + detail: { + idempotencyKey, + amountCents: data.amountCents, + previousBalanceCents, + balanceAfterCents, + orderNo: order.orderNo, + remark: data.remark, + } as Prisma.InputJsonValue, + }, + }); + return { ...order, balanceAfterCents, operationId: operation.id, replayed: false }; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + } + + async manualRechargePreflight(data: ManualRechargePreflightDto) { + assertMoneyUnits(data.amountCents, '充值金额', { allowNegative: true, allowZero: false }); + const tenant = await this.prisma.tenant.findFirst({ + where: { id: data.tenantId, status: { not: 'deleted' } }, + select: { id: true, name: true, code: true }, }); - return order; + if (!tenant) throw new NotFoundException('充值企业不存在或已删除'); + const account = await this.getAccountOrCreate(data.tenantId); + const balanceCents = moneyToNumber(account.balanceCents); + return { + tenant, + accountId: account.id, + expectedAccountUpdatedAt: account.updatedAt.toISOString(), + balanceCents, + creditCents: moneyToNumber(account.creditCents), + amountCents: data.amountCents, + balanceAfterCents: balanceCents + data.amountCents, + direction: data.amountCents > 0 ? 'topup' : 'correction', + allowedActions: ['confirm'], + blockedReasons: [], + }; + } + + private async manualRechargeReplay(log: { id: string; tenantId: string | null; resourceId: string | null; detail: Prisma.JsonValue | null }, data: CreateManualRechargeDto) { + const detail = asRecord(log.detail); + if (log.tenantId !== data.tenantId || Number(detail.amountCents) !== data.amountCents) { + throw new ConflictException('该幂等键已用于另一笔人工充值'); + } + const order = log.resourceId ? await this.prisma.rechargeOrder.findUnique({ where: { id: log.resourceId } }) : null; + if (!order) throw new ConflictException('幂等充值记录不完整,请联系管理员核查'); + const transaction = await this.prisma.accountTransaction.findFirst({ + where: { relatedType: 'recharge_order', relatedId: order.id }, + orderBy: { createdAt: 'desc' }, + }); + return { ...order, balanceAfterCents: moneyToNumber(transaction?.balanceAfter), operationId: log.id, replayed: true }; } estimateSmsCost(data: EstimateSmsCostDto) { @@ -375,3 +484,7 @@ function estimateBillingUnits(content: string) { } return Math.ceil(length / 67); } + +function asRecord(value: Prisma.JsonValue | null | undefined): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} diff --git a/api/src/channels/channels.controller.ts b/api/src/channels/channels.controller.ts index 5909681..7454729 100644 --- a/api/src/channels/channels.controller.ts +++ b/api/src/channels/channels.controller.ts @@ -1,6 +1,8 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; +import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service'; import { ChannelsService, ChangeChannelStatusDto, @@ -25,7 +27,7 @@ import { @ApiTags('channels') @Controller('admin') export class ChannelsController { - constructor(private readonly channels: ChannelsService) {} + constructor(private readonly channels: ChannelsService, private readonly deletions: DeletionGovernanceService) {} @Get('channels') listChannels() { @@ -64,8 +66,8 @@ export class ChannelsController { @Delete('channels/:id') @RequireRecentAuthentication() - deleteChannel(@Param('id') channelId: string, @Body() body: ChangeChannelStatusDto) { - return this.channels.deleteChannel(channelId, body); + deleteChannel(@Param('id') channelId: string, @Body() body: DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) { + return this.deletions.delete('channel', channelId, { ...body, operatorId }); } @Get('channels/:id/metrics') diff --git a/api/src/channels/channels.module.ts b/api/src/channels/channels.module.ts index b6c95c4..ac05cb8 100644 --- a/api/src/channels/channels.module.ts +++ b/api/src/channels/channels.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { ChannelsController } from './channels.controller'; import { ChannelsService } from './channels.service'; +import { DeletionGovernanceModule } from '../deletion-governance/deletion-governance.module'; @Module({ + imports: [DeletionGovernanceModule], controllers: [ChannelsController], providers: [ChannelsService], exports: [ChannelsService], diff --git a/api/src/deletion-governance/deletion-governance.controller.ts b/api/src/deletion-governance/deletion-governance.controller.ts new file mode 100644 index 0000000..1cd4e50 --- /dev/null +++ b/api/src/deletion-governance/deletion-governance.controller.ts @@ -0,0 +1,40 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; +import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; +import { TenantId } from '../common/tenant-id.decorator'; +import { DeleteTargetDto, DeletionGovernanceService, DeletionTargetType } from './deletion-governance.service'; + +@ApiTags('deletion-governance') +@Controller('admin/deletions') +export class AdminDeletionGovernanceController { + constructor(private readonly deletions: DeletionGovernanceService) {} + + @Get(':type/:id/preflight') + preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string) { + return this.deletions.preflight(type, id); + } + + @Post(':type/:id') + @RequireRecentAuthentication() + delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) { + return this.deletions.delete(type, id, { ...body, operatorId }); + } +} + +@ApiTags('client-deletion-governance') +@Controller('client/deletions') +export class ClientDeletionGovernanceController { + constructor(private readonly deletions: DeletionGovernanceService) {} + + @Get(':type/:id/preflight') + preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string, @TenantId() tenantId?: string) { + return this.deletions.preflight(type, id, tenantId); + } + + @Post(':type/:id') + @RequireRecentAuthentication() + delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) { + return this.deletions.delete(type, id, { ...body, operatorId }, tenantId); + } +} diff --git a/api/src/deletion-governance/deletion-governance.module.ts b/api/src/deletion-governance/deletion-governance.module.ts new file mode 100644 index 0000000..9157c91 --- /dev/null +++ b/api/src/deletion-governance/deletion-governance.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AdminDeletionGovernanceController, ClientDeletionGovernanceController } from './deletion-governance.controller'; +import { DeletionGovernanceService } from './deletion-governance.service'; + +@Module({ + controllers: [AdminDeletionGovernanceController, ClientDeletionGovernanceController], + providers: [DeletionGovernanceService], + exports: [DeletionGovernanceService], +}) +export class DeletionGovernanceModule {} diff --git a/api/src/deletion-governance/deletion-governance.service.spec.ts b/api/src/deletion-governance/deletion-governance.service.spec.ts new file mode 100644 index 0000000..b6c4c60 --- /dev/null +++ b/api/src/deletion-governance/deletion-governance.service.spec.ts @@ -0,0 +1,110 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import { DeletionGovernanceService } from './deletion-governance.service'; + +describe('DeletionGovernanceService', () => { + const now = new Date('2026-07-21T10:00:00.000Z'); + + function setup() { + const tx = { + operationLog: { findFirst: jest.fn(), create: jest.fn() }, + smsChannel: { updateMany: jest.fn() }, + smsSignature: { updateMany: jest.fn() }, + smsTemplate: { updateMany: jest.fn() }, + }; + const prisma = { + operationLog: { findFirst: jest.fn() }, + smsChannel: { findUnique: jest.fn() }, + smsSignature: { findFirst: jest.fn() }, + smsTemplate: { findFirst: jest.fn() }, + $transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)), + }; + return { service: new DeletionGovernanceService(prisma as never), prisma, tx }; + } + + it('blocks channel deletion when a live group still references it', async () => { + const { service, prisma } = setup(); + prisma.smsChannel.findUnique.mockResolvedValue({ + id: 'channel-1', name: '移动主通道', code: 'CH-1', status: 'active', updatedAt: now, + groupItems: [{ priority: 10, group: { name: '移动主通道组' } }], routeRules: [], connectionStates: [], reportTasks: [], + }); + + const result = await service.preflight('channel', 'channel-1'); + + expect(result.allowedActions).toEqual([]); + expect(result.blockedReasons).toContain('引用该通道的通道组共 1 项,请先解除或完成'); + expect(result.dependencies[0].items).toEqual(['移动主通道组(优先级 10)']); + }); + + it('returns an allowed template preflight scoped to the client tenant', async () => { + const { service, prisma } = setup(); + prisma.smsTemplate.findFirst.mockResolvedValue({ + id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now, + tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: { name: '示例签名' }, + sendTasks: [], batchTasks: [], + }); + + const result = await service.preflight('template', 'template-1', 'tenant-1'); + + expect(prisma.smsTemplate.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'template-1', tenantId: 'tenant-1' } })); + expect(result.allowedActions).toEqual(['delete']); + expect(result.identity.tenant).toBe('示例企业'); + }); + + it('blocks signature deletion and exposes the referencing template and drainage items', async () => { + const { service, prisma } = setup(); + prisma.smsSignature.findFirst.mockResolvedValue({ + id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now, + tenant: { name: '示例企业' }, application: { name: '验证码应用' }, + templates: [{ id: 'template-1', name: '验证码模板' }], + drainageItems: [{ id: 'drainage-1', siteName: '示例站点' }], reportTasks: [], + }); + + const result = await service.preflight('signature', 'signature-1', 'tenant-1'); + + expect(result.allowedActions).toEqual([]); + expect(result.dependencies).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'templates', count: 1, items: ['验证码模板(template-1)'] }), + expect.objectContaining({ kind: 'drainage', count: 1, items: ['示例站点(drainage-1)'] }), + ])); + }); + + it('requires version, idempotency key and a meaningful reason', async () => { + const { service } = setup(); + await expect(service.delete('template', 'template-1', {})).rejects.toBeInstanceOf(BadRequestException); + await expect(service.delete('template', 'template-1', { expectedUpdatedAt: now.toISOString(), idempotencyKey: 'key', reason: '短' })).rejects.toBeInstanceOf(BadRequestException); + }); + + it('soft deletes once and writes an auditable operation number', async () => { + const { service, prisma, tx } = setup(); + prisma.operationLog.findFirst.mockResolvedValue(null); + prisma.smsTemplate.findFirst.mockResolvedValue({ + id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now, + tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null, + sendTasks: [], batchTasks: [], + }); + tx.operationLog.findFirst.mockResolvedValue(null); + tx.smsTemplate.updateMany.mockResolvedValue({ count: 1 }); + tx.operationLog.create.mockResolvedValue({ id: 'operation-1' }); + + const result = await service.delete('template', 'template-1', { + expectedUpdatedAt: now.toISOString(), idempotencyKey: 'delete-template-1', reason: '测试删除治理', operatorId: 'user-1', + }, 'tenant-1'); + + expect(result).toEqual({ operationId: 'operation-1', status: 'deleted', replayed: false }); + expect(tx.smsTemplate.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { auditStatus: 'deleted' } })); + expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'governance.delete', userId: 'user-1' }) })); + }); + + it('rejects a stale optimistic-lock version', async () => { + const { service, prisma } = setup(); + prisma.operationLog.findFirst.mockResolvedValue(null); + prisma.smsTemplate.findFirst.mockResolvedValue({ + id: 'template-1', name: '验证码模板', auditStatus: 'approved', updatedAt: now, + tenant: { name: '示例企业' }, application: { name: '验证码应用' }, signature: null, + sendTasks: [], batchTasks: [], + }); + await expect(service.delete('template', 'template-1', { + expectedUpdatedAt: '2026-07-20T10:00:00.000Z', idempotencyKey: 'stale', reason: '测试版本冲突', + }, 'tenant-1')).rejects.toBeInstanceOf(ConflictException); + }); +}); diff --git a/api/src/deletion-governance/deletion-governance.service.ts b/api/src/deletion-governance/deletion-governance.service.ts new file mode 100644 index 0000000..d4dcdd6 --- /dev/null +++ b/api/src/deletion-governance/deletion-governance.service.ts @@ -0,0 +1,168 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +export type DeletionTargetType = 'channel' | 'signature' | 'template'; + +export type DeleteTargetDto = { + expectedUpdatedAt?: string; + idempotencyKey?: string; + reason?: string; + operatorId?: string; +}; + +type Dependency = { kind: string; label: string; count: number; items: string[] }; + +export type DeletionPreflight = { + type: DeletionTargetType; + id: string; + expectedUpdatedAt: string; + identity: Record; + dependencies: Dependency[]; + impacts: string[]; + blockedReasons: string[]; + allowedActions: Array<'delete'>; + recoverability: { mode: 'soft_delete'; description: string }; +}; + +@Injectable() +export class DeletionGovernanceService { + constructor(private readonly prisma: PrismaService) {} + + async preflight(type: DeletionTargetType, id: string, tenantId?: string): Promise { + this.assertType(type); + if (type === 'channel') return this.channelPreflight(id, tenantId); + if (type === 'signature') return this.signaturePreflight(id, tenantId); + return this.templatePreflight(id, tenantId); + } + + async delete(type: DeletionTargetType, id: string, body: DeleteTargetDto, tenantId?: string) { + this.assertType(type); + const expectedUpdatedAt = body.expectedUpdatedAt?.trim(); + const idempotencyKey = body.idempotencyKey?.trim(); + const reason = body.reason?.trim(); + if (!expectedUpdatedAt || !idempotencyKey) throw new BadRequestException('缺少删除版本或幂等键,请重新执行资格预检'); + if (!reason || reason.length < 4) throw new BadRequestException('请填写至少 4 个字符的删除原因'); + + const replay = await this.prisma.operationLog.findFirst({ + where: { + action: 'governance.delete', resource: type, resourceId: id, + detail: { path: ['idempotencyKey'], equals: idempotencyKey }, + }, + orderBy: { createdAt: 'desc' }, + }); + if (replay) return { operationId: replay.id, status: 'deleted', replayed: true }; + + const preflight = await this.preflight(type, id, tenantId); + if (preflight.expectedUpdatedAt !== expectedUpdatedAt) throw new ConflictException('对象已被其他操作更新,请重新检查删除影响'); + if (!preflight.allowedActions.includes('delete')) { + throw new ConflictException({ message: '当前对象不允许删除', blockedReasons: preflight.blockedReasons }); + } + + return this.prisma.$transaction(async (tx) => { + const existing = await tx.operationLog.findFirst({ + where: { + action: 'governance.delete', resource: type, resourceId: id, + detail: { path: ['idempotencyKey'], equals: idempotencyKey }, + }, + }); + if (existing) return { operationId: existing.id, status: 'deleted', replayed: true }; + + const updated = type === 'channel' + ? await tx.smsChannel.updateMany({ where: { id, updatedAt: new Date(expectedUpdatedAt), status: { not: 'deleted' } }, data: { status: 'deleted' } }) + : type === 'signature' + ? await tx.smsSignature.updateMany({ where: { id, tenantId, updatedAt: new Date(expectedUpdatedAt), auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted', pendingReport: false } }) + : await tx.smsTemplate.updateMany({ where: { id, tenantId, updatedAt: new Date(expectedUpdatedAt), auditStatus: { not: 'deleted' } }, data: { auditStatus: 'deleted' } }); + if (updated.count !== 1) throw new ConflictException('对象状态已变化,请重新执行资格预检'); + + const log = await tx.operationLog.create({ + data: { + tenantId, userId: body.operatorId, action: 'governance.delete', resource: type, resourceId: id, + detail: { idempotencyKey, reason, expectedUpdatedAt, dependencies: preflight.dependencies, impacts: preflight.impacts }, + }, + }); + return { operationId: log.id, status: 'deleted', replayed: false }; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + } + + private async channelPreflight(id: string, tenantId?: string): Promise { + if (tenantId) throw new BadRequestException('客户端无权删除运营通道'); + const item = await this.prisma.smsChannel.findUnique({ + where: { id }, + include: { + groupItems: { where: { group: { status: { not: 'deleted' } } }, include: { group: true } }, + routeRules: { where: { status: 'active' } }, + connectionStates: { where: { status: 'connected', currentConnections: { gt: 0 } } }, + reportTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } } }, + }, + }); + if (!item) throw new NotFoundException('通道不存在'); + const dependencies: Dependency[] = [ + dep('channel_groups', '引用该通道的通道组', item.groupItems.map((row) => `${row.group.name}(优先级 ${row.priority})`)), + dep('route_rules', '直接路由规则', item.routeRules.map((row) => row.id)), + dep('connections', '活动网关连接', item.connectionStates.map((row) => row.connectionId)), + dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => row.id)), + ]; + return buildPreflight('channel', item.id, item.updatedAt, { name: item.name, id: item.id, code: item.code }, item.status, dependencies, + ['删除后不再参与新消息路由', '历史发送、回执和审计记录继续保留']); + } + + private async signaturePreflight(id: string, tenantId?: string): Promise { + const item = await this.prisma.smsSignature.findFirst({ + where: { id, ...(tenantId ? { tenantId } : {}) }, + include: { + tenant: { select: { name: true } }, application: { select: { name: true } }, + templates: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, name: true } }, + drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, siteName: true } }, + reportTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } }, + }, + }); + if (!item) throw new NotFoundException('签名不存在或无权访问'); + const dependencies: Dependency[] = [ + dep('templates', '仍在使用该签名的模板', item.templates.map((row) => `${row.name}(${row.id})`)), + dep('drainage', '关联引流信息', item.drainageItems.map((row) => `${row.siteName}(${row.id})`)), + dep('report_tasks', '未结束报备任务', item.reportTasks.map((row) => `${row.id}(${row.status})`)), + ]; + return buildPreflight('signature', item.id, item.updatedAt, { + name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定', + }, item.auditStatus, dependencies, ['删除后不能用于新模板或发送', '历史消息、审核与报备记录继续保留']); + } + + private async templatePreflight(id: string, tenantId?: string): Promise { + const item = await this.prisma.smsTemplate.findFirst({ + where: { id, ...(tenantId ? { tenantId } : {}) }, + include: { + tenant: { select: { name: true } }, application: { select: { name: true } }, signature: { select: { name: true } }, + sendTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } }, + batchTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } }, + }, + }); + if (!item) throw new NotFoundException('模板不存在或无权访问'); + const dependencies: Dependency[] = [ + dep('send_tasks', '未结束发送任务', item.sendTasks.map((row) => `${row.id}(${row.status})`)), + dep('batch_tasks', '未结束批量任务', item.batchTasks.map((row) => `${row.id}(${row.status})`)), + ]; + return buildPreflight('template', item.id, item.updatedAt, { + name: item.name, id: item.id, tenant: item.tenant.name, application: item.application.name, + signature: item.signature?.name ?? '未绑定', + }, item.auditStatus, dependencies, ['删除后不能用于新发送任务', '历史消息、计费和审核记录继续保留']); + } + + private assertType(type: string): asserts type is DeletionTargetType { + if (!['channel', 'signature', 'template'].includes(type)) throw new BadRequestException('不支持的删除对象类型'); + } +} + +function dep(kind: string, label: string, items: string[]): Dependency { + return { kind, label, count: items.length, items: items.slice(0, 8) }; +} + +function buildPreflight(type: DeletionTargetType, id: string, updatedAt: Date, identity: Record, status: string, dependencies: Dependency[], impacts: string[]): DeletionPreflight { + const blockedReasons = dependencies.filter((item) => item.count > 0).map((item) => `${item.label}共 ${item.count} 项,请先解除或完成`); + if (status === 'deleted') blockedReasons.unshift('对象已经删除,请勿重复操作'); + return { + type, id, expectedUpdatedAt: updatedAt.toISOString(), identity, dependencies, impacts, blockedReasons, + allowedActions: blockedReasons.length ? [] : ['delete'], + recoverability: { mode: 'soft_delete', description: '本次为逻辑删除;历史数据保留,恢复需由运营人员依据审计记录处理。' }, + }; +} diff --git a/api/src/dictionaries/dictionaries.service.spec.ts b/api/src/dictionaries/dictionaries.service.spec.ts index 8755780..925078b 100644 --- a/api/src/dictionaries/dictionaries.service.spec.ts +++ b/api/src/dictionaries/dictionaries.service.spec.ts @@ -1,3 +1,4 @@ +import { ConflictException } from '@nestjs/common'; import { DictionariesService } from './dictionaries.service'; function createPrismaMock() { @@ -173,6 +174,22 @@ describe('DictionariesService', () => { expect(prisma.enterpriseBlacklist.update).toHaveBeenCalledWith({ where: { id: 'enterprise-1' }, data: { status: 'deleted' } }); }); + it.each([ + ['global', 'globalBlacklist', 'createGlobalBlacklist', { phoneNumber: '13800000000', reason: '投诉' }], + ['enterprise', 'enterpriseBlacklist', 'createEnterpriseBlacklist', { + tenantId: 'tenant-1', applicationId: 'app-1', phoneNumber: '13900000000', reason: '退订', + }], + ] as const)('maps a duplicate %s blacklist entry, including a soft-deleted row, to HTTP 409', async (_scope, model, method, input) => { + const prisma = createPrismaMock(); + prisma[model].create.mockRejectedValue({ code: 'P2002', meta: { target: ['phoneNumber'] } }); + const service = new DictionariesService(prisma as never); + + await expect(service[method](input as never)).rejects.toBeInstanceOf(ConflictException); + await expect(service[method](input as never)).rejects.toMatchObject({ + response: expect.objectContaining({ code: 'BLACKLIST_DUPLICATE', field: 'phoneNumber' }), + }); + }); + it('only accepts string, image and file report field types', async () => { const prisma = createPrismaMock(); const service = new DictionariesService(prisma as never); diff --git a/api/src/files/client-files.controller.ts b/api/src/files/client-files.controller.ts new file mode 100644 index 0000000..5d2471a --- /dev/null +++ b/api/src/files/client-files.controller.ts @@ -0,0 +1,41 @@ +import { BadRequestException, Body, Controller, Get, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiTags } from '@nestjs/swagger'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; +import { FilesService } from './files.service'; + +type UploadedMultipartFile = { originalname: string; mimetype: string; size: number; buffer: Buffer }; +type DownloadResponse = { setHeader(name: string, value: number | string): void; send(content: Buffer): void }; + +@ApiTags('client-files') +@Controller('client/files') +export class ClientFilesController { + constructor(private readonly files: FilesService) {} + + @Post('upload') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 } })) + upload( + @CurrentSessionUserId() userId: string | undefined, + @UploadedFile() file: UploadedMultipartFile, + @Body('purpose') purpose: string, + @Body('prefix') prefix?: string, + ) { + if (!file) throw new BadRequestException('Upload file is required'); + return this.files.uploadForClient(userId, { purpose, prefix }, file); + } + + @Get(':id/download') + async download( + @CurrentSessionUserId() userId: string | undefined, + @Param('id') id: string, + @Query('disposition') disposition: string | undefined, + @Res() response: DownloadResponse, + ) { + const { fileObject, content } = await this.files.getClientDownload(userId, id); + const mode = disposition === 'inline' ? 'inline' : 'attachment'; + response.setHeader('Content-Type', fileObject.contentType || 'application/octet-stream'); + response.setHeader('Content-Length', content.length); + response.setHeader('Content-Disposition', `${mode}; filename*=UTF-8''${encodeURIComponent(fileObject.fileName)}`); + response.send(content); + } +} diff --git a/api/src/files/files.module.ts b/api/src/files/files.module.ts index d7ef584..ee3f1fb 100644 --- a/api/src/files/files.module.ts +++ b/api/src/files/files.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { FilesController } from './files.controller'; import { FilesService } from './files.service'; import { ObjectStorageService } from './object-storage.service'; +import { ClientFilesController } from './client-files.controller'; @Module({ - controllers: [FilesController], + controllers: [FilesController, ClientFilesController], providers: [FilesService, ObjectStorageService], exports: [FilesService, ObjectStorageService], }) diff --git a/api/src/files/files.service.spec.ts b/api/src/files/files.service.spec.ts index e965803..3724336 100644 --- a/api/src/files/files.service.spec.ts +++ b/api/src/files/files.service.spec.ts @@ -136,4 +136,44 @@ describe('FilesService', () => { expect(prisma.fileObject.findUnique).toHaveBeenCalledWith({ where: { id: 'file-1' } }); expect(objectStorage.getObject).toHaveBeenCalledWith('signature-materials/sig-1/file.png'); }); + + it('derives client upload tenant from the authenticated user and ignores tenant headers', async () => { + const prisma = { + user: { findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-real' }) }, + fileObject: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'file-client', ...data })) }, + }; + const objectStorage = { + getBucket: jest.fn().mockReturnValue('cmpp-platform'), + putObject: jest.fn().mockResolvedValue({ etag: 'etag' }), + }; + const service = new FilesService(prisma as never, objectStorage as never); + await expect(service.uploadForClient('user-1', { + purpose: 'enterprise_certification', + prefix: 'enterprise-certifications/license', + }, { originalname: 'license.pdf', mimetype: 'application/pdf', size: 4, buffer: Buffer.from('test') })) + .resolves.toEqual(expect.objectContaining({ tenantId: 'tenant-real', purpose: 'enterprise_certification' })); + expect(prisma.user.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'user-1' }) })); + }); + + it('rejects arbitrary client upload purposes and prefixes before object storage writes', async () => { + const prisma = { user: { findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }) } }; + const objectStorage = { putObject: jest.fn() }; + const service = new FilesService(prisma as never, objectStorage as never); + await expect(service.uploadForClient('user-1', { purpose: 'enterprise_certification', prefix: '../admin' }, { + originalname: 'file.pdf', mimetype: 'application/pdf', size: 4, buffer: Buffer.from('test'), + })).rejects.toBeInstanceOf(BadRequestException); + expect(objectStorage.putObject).not.toHaveBeenCalled(); + }); + + it('prevents client users from downloading another tenant file', async () => { + const prisma = { + user: { findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }) }, + fileObject: { findFirst: jest.fn().mockResolvedValue(null) }, + }; + const objectStorage = { getObject: jest.fn() }; + const service = new FilesService(prisma as never, objectStorage as never); + await expect(service.getClientDownload('user-1', 'foreign-file')).rejects.toThrow('File object not found'); + expect(prisma.fileObject.findFirst).toHaveBeenCalledWith({ where: { id: 'foreign-file', tenantId: 'tenant-1' } }); + expect(objectStorage.getObject).not.toHaveBeenCalled(); + }); }); diff --git a/api/src/files/files.service.ts b/api/src/files/files.service.ts index f56319b..882f0b5 100644 --- a/api/src/files/files.service.ts +++ b/api/src/files/files.service.ts @@ -26,6 +26,12 @@ export interface UploadFileDto { prefix?: string; } +const CLIENT_UPLOAD_RULES: Record = { + enterprise_certification: /^enterprise-certifications\/license$/, + signature_report_material: /^signature-materials$/, + drainage_report_material: /^drainage-materials\/[a-zA-Z0-9_-]+$/, +}; + @Injectable() export class FilesService { constructor( @@ -81,6 +87,15 @@ export class FilesService { }); } + async uploadForClient(userId: string | undefined, data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) { + const tenantId = await this.resolveClientTenantId(userId); + const prefixRule = CLIENT_UPLOAD_RULES[data.purpose]; + if (!prefixRule || !data.prefix || !prefixRule.test(data.prefix)) { + throw new BadRequestException('不支持的客户端上传用途或目录'); + } + return this.upload({ tenantId, purpose: data.purpose, prefix: data.prefix }, file); + } + async getDownload(id: string) { const fileObject = await this.prisma.fileObject.findUnique({ where: { id } }); if (!fileObject) { @@ -92,6 +107,30 @@ export class FilesService { content, }; } + + async getClientDownload(userId: string | undefined, id: string) { + const tenantId = await this.resolveClientTenantId(userId); + const fileObject = await this.prisma.fileObject.findFirst({ where: { id, tenantId } }); + if (!fileObject) { + throw new NotFoundException('File object not found'); + } + const content = await this.objectStorage.getObject(fileObject.objectKey); + return { fileObject: serializeFileObject(fileObject), content }; + } + + private async resolveClientTenantId(userId?: string) { + if (!userId) { + throw new NotFoundException('Client user not found'); + } + const user = await this.prisma.user.findFirst({ + where: { id: userId, status: 'active', deletedAt: null, tenantId: { not: null } }, + select: { tenantId: true }, + }); + if (!user?.tenantId) { + throw new NotFoundException('Client tenant not found'); + } + return user.tenantId; + } } const IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024; diff --git a/api/src/operations/admin-operations.controller.ts b/api/src/operations/admin-operations.controller.ts index 937d6d2..81bef58 100644 --- a/api/src/operations/admin-operations.controller.ts +++ b/api/src/operations/admin-operations.controller.ts @@ -274,4 +274,9 @@ export class AdminSystemLogsController { ) { return this.operations.systemLogs({ tenantId, userId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) }); } + + @Post('exports') + export(@Body() body: { tenantId?: string; userId?: string; keyword?: string; level?: string; module?: string; range?: string }) { + return this.operations.exportSystemLogs(body); + } } diff --git a/api/src/operations/client-operations.controller.ts b/api/src/operations/client-operations.controller.ts index c3f954f..49ebcda 100644 --- a/api/src/operations/client-operations.controller.ts +++ b/api/src/operations/client-operations.controller.ts @@ -1,5 +1,6 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { TenantId } from '../common/tenant-id.decorator'; import { OperationsService } from './operations.service'; @@ -10,12 +11,12 @@ export class ClientOperationsController { @Get('batch-tasks') listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) { - return this.operations.listBatchTasks({ tenantId, status }); + return this.operations.listClientBatchTasks({ tenantId, status }); } @Get('batch-tasks/:id/messages') listTaskMessages(@TenantId() tenantId: string | undefined, @Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) { - return this.operations.listMessages({ tenantId, taskId, phoneNumber }); + return this.operations.listClientMessages({ tenantId, taskId, phoneNumber }); } @Get('messages') @@ -27,17 +28,17 @@ export class ClientOperationsController { @Query('phoneNumber') phoneNumber?: string, @Query('status') status?: string, ) { - return this.operations.listMessages({ tenantId, applicationId, taskId, messageId, phoneNumber, status }); + return this.operations.listClientMessages({ tenantId, applicationId, taskId, messageId, phoneNumber, status }); } @Get('uplink-messages') listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string) { - return this.operations.listUplinkMessages({ tenantId, channelId, applicationId, phoneNumber, keyword, startTime, endTime }); + return this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime }); } @Get('dashboard') dashboard(@TenantId() tenantId?: string) { - return this.operations.dashboard({ tenantId }); + return this.operations.clientDashboard({ tenantId }); } @Get('system-logs') @@ -52,4 +53,12 @@ export class ClientOperationsController { ) { return this.operations.systemLogs({ tenantId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) }); } + + @Post('system-logs/exports') + exportSystemLogs( + @CurrentSessionUserId() userId: string | undefined, + @Body() body: { keyword?: string; level?: string; module?: string; range?: string }, + ) { + return this.operations.exportSystemLogs(body, userId); + } } diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 05be7ef..9977d62 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -2,6 +2,9 @@ import { OperationsService } from './operations.service'; function createPrismaMock() { return { + user: { + findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }), + }, smsBatchTask: { findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]), count: jest.fn().mockResolvedValue(3), @@ -250,6 +253,64 @@ describe('OperationsService', () => { }); }); + it('returns client message views without supplier channel, submit, tenant, or gateway internals', async () => { + const prisma = createPrismaMock(); + prisma.smsMessageRecord.findMany.mockResolvedValue([{ + id: 'record-1', + tenantId: 'tenant-1', + batchTaskId: 'task-1', + applicationId: 'app-1', + channelId: 'channel-1', + messageId: 'MSG-1', + phoneNumber: '13800000001', + carrier: 'mobile', + province: '上海', + content: '验证码1234', + billingUnits: 1, + amountCents: 352, + status: 'delivered', + queuedAt: new Date('2026-07-21T01:00:00.000Z'), + application: { id: 'app-1', name: '应用A', secretHash: 'secret' }, + tenant: { id: 'tenant-1', name: '企业A' }, + channel: { id: 'channel-1', account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 }, + submitRecords: [{ id: 'submit-1', gatewayMessageId: 'GW-1', channel: { passwordCipher: 'cipher' } }], + receiptRecords: [{ + id: 'receipt-1', messageId: 'MSG-1', gatewayMessageId: 'GW-1', receiptStatus: 'delivered', rawStatus: 'DELIVRD', + errorCode: null, errorMessage: null, deliveredAt: new Date('2026-07-21T01:00:05.000Z'), createdAt: new Date('2026-07-21T01:00:05.000Z'), + }], + }]); + const service = new OperationsService(prisma as never); + + const [message] = await service.listClientMessages({ tenantId: 'tenant-1' }); + + expect(message).toMatchObject({ + id: 'record-1', messageId: 'MSG-1', carrier: 'mobile', province: '上海', application: { id: 'app-1', name: '应用A' }, + receiptRecords: [expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD' })], + }); + expect(message).not.toHaveProperty('tenant'); + expect(message).not.toHaveProperty('tenantId'); + expect(message).not.toHaveProperty('channel'); + expect(message).not.toHaveProperty('channelId'); + expect(message).not.toHaveProperty('submitRecords'); + expect(JSON.stringify(message)).not.toMatch(/passwordCipher|gatewayMessageId|unitPrice|supplier|cipher/); + }); + + it('returns a client dashboard without gateway state or supplier channel secrets', async () => { + const prisma = createPrismaMock(); + prisma.smsBatchTask.findMany.mockResolvedValue([{ + id: 'task-1', taskNo: 'BATCH-1', tenantId: 'tenant-1', applicationId: 'app-1', phoneTotal: 1, status: 'finished', + createdAt: new Date('2026-07-21T01:00:00.000Z'), application: { id: 'app-1', name: '应用A' }, + messages: [{ channel: { account: 'supplier', passwordCipher: 'cipher', unitPrice: 200 } }], + }]); + const service = new OperationsService(prisma as never); + + const dashboard = await service.clientDashboard({ tenantId: 'tenant-1' }); + + expect(dashboard.gatewayConnections).toEqual([]); + expect(dashboard.recentTasks).toEqual([expect.objectContaining({ id: 'task-1', taskNo: 'BATCH-1' })]); + expect(JSON.stringify(dashboard)).not.toMatch(/passwordCipher|supplier|cipher|unitPrice/); + }); + it('builds dashboard and statistics aggregates', async () => { const prisma = createPrismaMock(); prisma.cmppDownstreamDelivery.count = jest.fn() @@ -390,6 +451,32 @@ describe('OperationsService', () => { }); }); + it('exports filtered operation logs with a traceable operation id', async () => { + const prisma = createPrismaMock(); + const service = new OperationsService(prisma as never); + + await expect(service.exportSystemLogs({ tenantId: 'tenant-1', range: '7d' })).resolves.toEqual(expect.objectContaining({ + operationId: expect.any(String), + status: 'completed', + recordCount: 1, + truncated: false, + content: expect.stringContaining('billing.manual_recharge'), + })); + expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 10_001 })); + }); + + it('derives client log export tenant from session user and removes internal detail and IP columns', async () => { + const prisma = createPrismaMock(); + const service = new OperationsService(prisma as never); + + const exported = await service.exportSystemLogs({ tenantId: 'spoofed-tenant' }, 'client-user'); + + expect(prisma.user.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'client-user' }) })); + expect(prisma.operationLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1' }) })); + expect(exported.content.split('\n')[0]).toBe('时间,级别,模块,操作人,动作,资源ID'); + expect(exported.content).not.toContain('amountCents'); + }); + it('caps legacy audit-log reads with pagination', async () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 1c00d8a..0869c13 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { moneyToNumber } from '../common/money'; +import { randomUUID } from 'node:crypto'; export interface MessageQuery { tenantId?: string; @@ -89,6 +90,11 @@ export class OperationsService { }); } + async listClientBatchTasks(query: { tenantId?: string; status?: string }) { + const items = await this.listBatchTasks(query); + return items.map(clientBatchTaskView); + } + listMessages(query: MessageQuery) { return this.prisma.smsMessageRecord.findMany({ where: messageWhere(query), @@ -97,6 +103,11 @@ export class OperationsService { }); } + async listClientMessages(query: MessageQuery) { + const items = await this.listMessages(query); + return items.map(clientMessageView); + } + listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string }) { return this.prisma.smsUplinkMessage.findMany({ where: { @@ -126,6 +137,11 @@ export class OperationsService { }); } + async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string }) { + const items = await this.listUplinkMessages(query); + return items.map(clientUplinkView); + } + async monitor(query: { tenantId?: string; channelId?: string }) { const where = messageWhere(query); const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([ @@ -293,6 +309,25 @@ export class OperationsService { }; } + async clientDashboard(query: { tenantId?: string }) { + const dashboard = await this.dashboard(query); + return { + taskCount: dashboard.taskCount, + messageStatus: dashboard.messageStatus, + today: dashboard.today, + uplinkCount: dashboard.uplinkCount, + billing: dashboard.billing, + transactions: dashboard.transactions, + gatewayConnections: [], + pendingAuditCount: dashboard.pendingAuditCount, + pendingAudits: dashboard.pendingAudits, + downstreamDeliverySummary: dashboard.downstreamDeliverySummary, + accounts: dashboard.accounts.map(clientAccountView), + recentTasks: dashboard.recentTasks.map(clientBatchTaskView), + recentRecharges: dashboard.recentRecharges.map(clientRechargeView), + }; + } + async statistics(query: { tenantId?: string; groupBy?: string }) { const groupBy = normalizeGroupBy(query.groupBy); if (groupBy === 'tenantId') { @@ -378,6 +413,59 @@ export class OperationsService { }; } + async exportSystemLogs(query: OperationLogQuery, clientUserId?: string) { + const clientTenantId = clientUserId ? await this.resolveClientTenantId(clientUserId) : undefined; + const effectiveQuery = { ...query, tenantId: clientTenantId ?? query.tenantId }; + const where: Prisma.OperationLogWhereInput = { + tenantId: effectiveQuery.tenantId, + userId: effectiveQuery.userId, + createdAt: createdAtRange(effectiveQuery.range), + resource: effectiveQuery.module && effectiveQuery.module !== 'all' ? effectiveQuery.module : undefined, + AND: effectiveQuery.level && effectiveQuery.level !== 'all' ? operationLogLevelWhere(effectiveQuery.level) : undefined, + OR: effectiveQuery.keyword ? [ + { action: { contains: effectiveQuery.keyword } }, + { resource: { contains: effectiveQuery.keyword } }, + { resourceId: { contains: effectiveQuery.keyword } }, + { tenant: { name: { contains: effectiveQuery.keyword } } }, + { user: { displayName: { contains: effectiveQuery.keyword } } }, + { user: { username: { contains: effectiveQuery.keyword } } }, + ] : undefined, + }; + const rows = await this.prisma.operationLog.findMany({ + where, + include: { tenant: true, user: true }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: 10_001, + }); + const truncated = rows.length > 10_000; + const exportedRows = rows.slice(0, 10_000).map(normalizeOperationLog); + const clientExport = Boolean(clientUserId); + const headers = clientExport + ? ['时间', '级别', '模块', '操作人', '动作', '资源ID'] + : ['时间', '级别', '企业', '模块', '操作人', '动作', '资源ID', '详情', 'IP']; + const values = exportedRows.map((item) => clientExport + ? [item.time, item.level, item.module, item.operator, item.action, item.resourceId] + : [item.time, item.level, item.tenant, item.module, item.operator, item.action, item.resourceId, JSON.stringify(item.detail), item.ip]); + return { + operationId: randomUUID(), + status: 'completed' as const, + fileName: `system-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`, + recordCount: exportedRows.length, + truncated, + content: [headers, ...values].map((row) => row.map((cell) => escapeCsvCell(String(cell ?? ''))).join(',')).join('\n'), + filters: { keyword: effectiveQuery.keyword, level: effectiveQuery.level, module: effectiveQuery.module, range: effectiveQuery.range }, + }; + } + + private async resolveClientTenantId(userId: string) { + const user = await this.prisma.user.findFirst({ + where: { id: userId, status: 'active', deletedAt: null, tenantId: { not: null } }, + select: { tenantId: true }, + }); + if (!user?.tenantId) throw new NotFoundException('Client tenant not found'); + return user.tenantId; + } + async listGatewaySubmitDeadLetters(query: GatewaySubmitDeadLetterQuery) { const page = Math.max(1, Number(query.page ?? 1)); const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10))); @@ -989,7 +1077,10 @@ function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) { } function escapeCsvCell(value: string) { - const normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + let normalized = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + if (/^[=+\-@]/.test(normalized)) { + normalized = `'${normalized}`; + } if (normalized.includes(',') || normalized.includes('"') || normalized.includes('\n')) { return `"${normalized.replace(/"/g, '""')}"`; } @@ -1015,6 +1106,122 @@ function formatExportTimestamp(date: Date) { return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`; } +function clientApplicationView(application?: Record | null) { + if (!application) return null; + return { id: application.id, name: application.name }; +} + +function clientReceiptView(receipt: Record) { + return { + id: receipt.id, + messageId: receipt.messageId, + receiptStatus: receipt.receiptStatus, + rawStatus: receipt.rawStatus, + errorCode: receipt.errorCode ?? null, + errorMessage: receipt.errorMessage ?? null, + deliveredAt: receipt.deliveredAt, + createdAt: receipt.createdAt, + }; +} + +function clientMessageView(message: Record) { + return { + id: message.id, + batchTaskId: message.batchTaskId ?? null, + applicationId: message.applicationId ?? null, + messageId: message.messageId, + phoneNumber: message.phoneNumber, + carrier: message.carrier ?? null, + province: message.province ?? null, + content: message.content, + billingUnits: message.billingUnits, + amountCents: moneyToNumber(message.amountCents), + status: message.status, + submitStatus: message.submitStatus ?? null, + receiptStatus: message.receiptStatus ?? null, + errorCode: message.errorCode ?? null, + errorMessage: message.errorMessage ?? null, + queuedAt: message.queuedAt, + submittedAt: message.submittedAt ?? null, + deliveredAt: message.deliveredAt ?? null, + application: clientApplicationView(message.application), + receiptRecords: Array.isArray(message.receiptRecords) ? message.receiptRecords.map(clientReceiptView) : [], + }; +} + +function clientBatchTaskView(task: Record) { + return { + id: task.id, + taskNo: task.taskNo, + applicationId: task.applicationId ?? null, + templateId: task.templateId ?? null, + content: task.content, + category: task.category ?? null, + phoneTotal: task.phoneTotal, + status: task.status, + auditStatus: task.auditStatus ?? null, + reviewReason: task.reviewReason ?? null, + rejectReason: task.rejectReason ?? null, + progressTotal: task.progressTotal, + progressSent: task.progressSent ?? 0, + progressDelivered: task.progressDelivered ?? 0, + progressFailed: task.progressFailed ?? 0, + submittedTotal: task.submittedTotal ?? 0, + successTotal: task.successTotal ?? 0, + failedTotal: task.failedTotal ?? 0, + unknownTotal: task.unknownTotal ?? 0, + timeoutTotal: task.timeoutTotal ?? 0, + scheduledAt: task.scheduledAt ?? null, + canceledAt: task.canceledAt ?? null, + createdAt: task.createdAt, + application: clientApplicationView(task.application), + messages: Array.isArray(task.messages) ? task.messages.map(clientMessageView) : [], + }; +} + +function clientUplinkView(message: Record) { + return { + id: message.id, + applicationId: message.applicationId ?? null, + messageRecordId: message.messageRecordId ?? null, + messageId: message.messageId ?? null, + phoneNumber: message.phoneNumber, + destId: message.destId, + content: message.content, + matchStatus: message.matchStatus, + matchReason: message.matchReason ?? null, + receivedAt: message.receivedAt, + createdAt: message.createdAt, + application: clientApplicationView(message.application), + messageRecord: message.messageRecord ? clientMessageView(message.messageRecord) : null, + }; +} + +function clientAccountView(account: Record) { + return { + id: account.id, + tenantId: account.tenantId, + balanceCents: moneyToNumber(account.balanceCents), + creditCents: moneyToNumber(account.creditCents), + status: account.status, + updatedAt: account.updatedAt, + tenant: account.tenant ? { id: account.tenant.id, name: account.tenant.name, status: account.tenant.status } : null, + }; +} + +function clientRechargeView(order: Record) { + return { + id: order.id, + orderNo: order.orderNo, + amountCents: moneyToNumber(order.amountCents), + status: order.status, + payMethod: order.payMethod, + remark: order.remark ?? null, + createdAt: order.createdAt, + completedAt: order.completedAt ?? null, + }; +} + function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) { return groups.reduce( (summary, group) => { diff --git a/api/src/prisma/prisma.service.spec.ts b/api/src/prisma/prisma.service.spec.ts new file mode 100644 index 0000000..faf2438 --- /dev/null +++ b/api/src/prisma/prisma.service.spec.ts @@ -0,0 +1,13 @@ +import { PrismaService } from './prisma.service'; + +describe('PrismaService', () => { + it('keeps the operation log delegate configurable for transaction client proxies', async () => { + const prisma = new PrismaService(); + + expect(Object.getOwnPropertyDescriptor(prisma, 'operationLog')).toEqual( + expect.objectContaining({ configurable: true }), + ); + + await prisma.$disconnect(); + }); +}); diff --git a/api/src/prisma/prisma.service.ts b/api/src/prisma/prisma.service.ts index df719e4..8317e5a 100644 --- a/api/src/prisma/prisma.service.ts +++ b/api/src/prisma/prisma.service.ts @@ -29,6 +29,7 @@ export class PrismaService extends PrismaClient implements OnModuleDestroy { return typeof value === 'function' ? value.bind(target) : value; }, }), + configurable: true, }); } diff --git a/api/src/report-materials/report-materials.controller.ts b/api/src/report-materials/report-materials.controller.ts index 2e57430..4403b84 100644 --- a/api/src/report-materials/report-materials.controller.ts +++ b/api/src/report-materials/report-materials.controller.ts @@ -67,6 +67,11 @@ export class ReportMaterialsController { return this.service.listBatches(); } + @Post('batches/preflight') + preflightBatch(@Body() body: CreateReportBatchDto) { + return this.service.preflightBatch(body); + } + @Post('batches') @RequireRecentAuthentication() createBatch(@Body() body: CreateReportBatchDto, @CurrentSessionUserId() operatorId?: string) { diff --git a/api/src/report-materials/report-materials.service.spec.ts b/api/src/report-materials/report-materials.service.spec.ts index f164e64..166c536 100644 --- a/api/src/report-materials/report-materials.service.spec.ts +++ b/api/src/report-materials/report-materials.service.spec.ts @@ -59,17 +59,20 @@ describe('ReportMaterialsService', () => { let exportSequence = 0; const channels = [{ id: 'channel-a', name: '通道A', status: 'active' }, { id: 'channel-b', name: '通道B', status: 'active' }]; const prisma = { + $transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)), + $executeRaw: jest.fn().mockResolvedValue(1), + operationLog: { findFirst: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }), create: jest.fn().mockResolvedValue({ id: 'operation-1' }), update: jest.fn().mockResolvedValue({}) }, reportMaterialBatch: { create: jest.fn().mockResolvedValue({ id: 'batch-1', batchNo: 'RB001' }), update: jest.fn().mockImplementation(({ data }: { data: Record }) => Promise.resolve({ id: 'batch-1', batchNo: 'RB001', ...data })), }, smsSignature: { - findUnique: jest.fn().mockResolvedValue({ id: 'signature-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', purpose: '验证码', auditStatus: 'approved', materialVersion: 3, drainageInfo: { signatureReportValues: { license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' } } }, tenant: { name: '测试企业' }, application: { name: '测试应用' } }), + findUnique: jest.fn().mockResolvedValue({ id: 'signature-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', purpose: '验证码', auditStatus: 'approved', pendingReport: true, materialVersion: 3, drainageInfo: { signatureReportValues: { license: { fileObjectId: 'image-1', fileName: 'license.png', contentType: 'image/png' } } }, tenant: { name: '测试企业' }, application: { name: '测试应用', status: 'active' } }), update: jest.fn().mockResolvedValue({}), }, smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() }, - channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: channels.map((channel, index) => ({ priority: index, channel })) } }]) }, - reportMaterialBatchItem: { create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })) }, + channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', group: { status: 'active', items: channels.map((channel, index) => ({ priority: index, carrier: 'mobile', channel })) } }]) }, + reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockImplementation(() => Promise.resolve({ id: `batch-item-${++batchItemSequence}` })) }, smsChannel: { findUnique: jest.fn().mockImplementation(({ where }: { where: { id: string } }) => Promise.resolve(channels.find((channel) => channel.id === where.id))) }, channelReportField: { findMany: jest.fn().mockResolvedValue([ { code: 'sign', name: '短信签名', exportName: '通道签名', required: true, columnWidth: 18, imageWidth: 120, imageHeight: 80, transform: null, defaultValue: null }, @@ -89,7 +92,7 @@ describe('ReportMaterialsService', () => { }; const service = new ReportMaterialsService(prisma as never, files as never, {} as never); - const result = await service.createBatch({ items: [{ reportType: 'signature', signatureId: 'signature-1' }] }); + const result = await service.createBatch({ idempotencyKey: 'report-batch:test-1', items: [{ reportType: 'signature', signatureId: 'signature-1', materialVersion: 3 }] }); expect(result).toMatchObject({ status: 'completed', channelCount: 2, fileCount: 2 }); expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledTimes(2); @@ -107,11 +110,14 @@ describe('ReportMaterialsService', () => { it('keeps material pending and marks the task waiting when the channel has no field configuration', async () => { const prisma = { + $transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)), + $executeRaw: jest.fn().mockResolvedValue(1), + operationLog: { findFirst: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue({ detail: { fingerprint: 'fingerprint' } }), create: jest.fn().mockResolvedValue({ id: 'operation-2' }), update: jest.fn().mockResolvedValue({}) }, reportMaterialBatch: { create: jest.fn().mockResolvedValue({ id: 'batch-2' }), update: jest.fn().mockImplementation(({ data }: { data: Record }) => Promise.resolve(data)) }, - smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'signature-2', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', auditStatus: 'approved', materialVersion: 1, drainageInfo: {}, tenant: { name: '企业' }, application: { name: '应用' } }), update: jest.fn() }, + smsSignature: { findUnique: jest.fn().mockResolvedValue({ id: 'signature-2', tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', auditStatus: 'approved', pendingReport: true, materialVersion: 1, drainageInfo: {}, tenant: { name: '企业' }, application: { name: '应用', status: 'active' } }), update: jest.fn() }, smsDrainageInfo: { findUnique: jest.fn(), update: jest.fn() }, - channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-a', name: '通道A', status: 'active' } }] } }]) }, - reportMaterialBatchItem: { create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }) }, + channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ carrier: 'mobile', group: { status: 'active', items: [{ carrier: 'mobile', channel: { id: 'channel-a', name: '通道A', status: 'active' } }] } }]) }, + reportMaterialBatchItem: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn().mockResolvedValue({ id: 'batch-item-2' }) }, smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-a', name: '通道A' }) }, channelReportField: { findMany: jest.fn().mockResolvedValue([]) }, channelSignatureReportTask: { findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }: { data: Record }) => Promise.resolve({ id: 'task-2', ...data })) }, @@ -122,10 +128,38 @@ describe('ReportMaterialsService', () => { const files = { upload: jest.fn().mockResolvedValue({ id: 'file-2', fileName: 'empty.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) }; const service = new ReportMaterialsService(prisma as never, files as never, {} as never); - const result = await service.createBatch({ items: [{ reportType: 'signature', signatureId: 'signature-2' }] }); + await expect(service.createBatch({ idempotencyKey: 'report-batch:test-2', items: [{ reportType: 'signature', signatureId: 'signature-2', materialVersion: 1 }] })) + .rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_NOT_ELIGIBLE' }) }); - expect(result).toMatchObject({ status: 'partial_failed' }); - expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ status: 'waiting_material', reason: '通道未配置当前资料类型的报备字段' }) }); + expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled(); expect(prisma.smsSignature.update).not.toHaveBeenCalled(); }); + + it('reuses a completed operation for the same idempotency key without generating a second batch', async () => { + const prisma = { + $transaction: jest.fn().mockImplementation((callback: (tx: unknown) => unknown) => callback(prisma)), + $executeRaw: jest.fn().mockResolvedValue(1), + operationLog: { findFirst: jest.fn().mockResolvedValue({ id: 'operation-existing', detail: { status: 'completed', fingerprint: expect.anything(), result: { id: 'batch-existing', batchNo: 'RB-EXISTING', status: 'completed', result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] } } } }) }, + reportMaterialBatch: { create: jest.fn() }, + }; + const service = new ReportMaterialsService(prisma as never, {} as never, {} as never); + const items = [{ reportType: 'signature' as const, signatureId: 'signature-1', materialVersion: 3 }]; + const fingerprint = createFingerprint(items); + prisma.operationLog.findFirst.mockResolvedValueOnce({ id: 'operation-existing', detail: { status: 'completed', fingerprint, result: { id: 'batch-existing', batchNo: 'RB-EXISTING', status: 'completed', result: { successCount: 1, skippedCount: 0, failedCount: 0, items: [] } } } }); + + await expect(service.createBatch({ idempotencyKey: 'report-batch:replay', items })).resolves.toMatchObject({ id: 'batch-existing', replayed: true, operationId: 'operation-existing' }); + expect(prisma.reportMaterialBatch.create).not.toHaveBeenCalled(); + }); + + it('rejects malformed preflight items as a readable 400 before Prisma is called', async () => { + const prisma = { smsSignature: { findUnique: jest.fn() } }; + const service = new ReportMaterialsService(prisma as never, {} as never, {} as never); + await expect(service.preflightBatch({ items: [{ reportType: 'signature', signatureId: '' }] })).rejects.toMatchObject({ response: expect.objectContaining({ code: 'REPORT_BATCH_ITEM_INVALID' }) }); + expect(prisma.smsSignature.findUnique).not.toHaveBeenCalled(); + }); }); + +function createFingerprint(items: Array<{ reportType: string; signatureId: string; materialVersion: number }>) { + const { createHash } = require('node:crypto') as typeof import('node:crypto'); + return createHash('sha256').update(JSON.stringify(items.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: null, materialVersion: item.materialVersion })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex'); +} diff --git a/api/src/report-materials/report-materials.service.ts b/api/src/report-materials/report-materials.service.ts index a5d7e04..8db1ec7 100644 --- a/api/src/report-materials/report-materials.service.ts +++ b/api/src/report-materials/report-materials.service.ts @@ -1,7 +1,7 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import ExcelJS from 'exceljs'; -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { extname } from 'node:path'; import { FilesService } from '../files/files.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -40,9 +40,26 @@ export interface ImportCommitDto { export interface CreateReportBatchDto { createdById?: string; - items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string }>; + idempotencyKey?: string; + items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }>; } +type ReportBatchTarget = { id: string; name: string; carrier: string; businessKey: string; eligible: boolean; blockedReasons: string[]; duplicateBatchId?: string }; +type ReportBatchInspection = { + id: string; + reportType: 'signature' | 'drainage'; + signatureId: string; + drainageItemId?: string; + materialVersion: number; + name: string; + tenantName: string; + applicationId?: string; + applicationName: string; + eligible: boolean; + blockedReasons: string[]; + targets: ReportBatchTarget[]; +}; + type AnalyzeImportOptions = { tenantId: string; applicationId?: string; @@ -296,13 +313,33 @@ export class ReportMaterialsService { async createBatch(data: CreateReportBatchDto) { if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息'); + const idempotencyKey = normalizeBatchIdempotencyKey(data.idempotencyKey); const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()]; + const fingerprint = createHash('sha256').update(JSON.stringify(uniqueItems.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? null, materialVersion: item.materialVersion ?? null })).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))).digest('hex'); + const claimed = await this.claimBatchOperation(idempotencyKey, fingerprint, data.createdById); + if (claimed.replayed) return claimed.result; + + let preflight: Awaited>; + try { + preflight = await this.preflightBatch({ items: uniqueItems }); + } catch (error) { + await this.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '报备资格预检失败'); + throw error; + } + if (preflight.eligibleTargetCount === 0) { + await this.failBatchOperation(claimed.operationId, '没有可生成的报备目标'); + throw new BadRequestException({ code: 'REPORT_BATCH_NOT_ELIGIBLE', message: '所选资料没有可生成的通道,请按资格检查补充后重试', preflight }); + } + const eligibleInspections = preflight.items.filter((item) => item.targets.some((target) => target.eligible)); const batch = await this.prisma.reportMaterialBatch.create({ - data: { batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`, createdById: data.createdById, selectedCount: uniqueItems.length }, + data: { batchNo: `RB${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}${randomUUID().slice(0, 4).toUpperCase()}`, createdById: data.createdById, selectedCount: eligibleInspections.length }, }); try { const prepared = []; - for (const selected of uniqueItems) prepared.push(await this.prepareBatchItem(batch.id, selected)); + for (const inspection of eligibleInspections) { + const selected = uniqueItems.find((item) => item.reportType === inspection.reportType && (item.drainageItemId ?? item.signatureId) === (inspection.drainageItemId ?? inspection.signatureId))!; + prepared.push(await this.prepareBatchItem(batch.id, selected, inspection)); + } const channelMap = new Map>(); for (const item of prepared) { for (const channel of item.channels) { @@ -313,9 +350,11 @@ export class ReportMaterialsService { } const exportedFiles = []; const incomplete = new Set(prepared.filter((item) => item.channels.length === 0).map((item) => item.batchItem.id)); + let failedTargetCount = 0; for (const [channelId, items] of channelMap) { const result = await this.exportChannelBatch(batch.id, channelId, items); exportedFiles.push(result.file); + failedTargetCount += result.incompleteBatchItemIds.length; for (const itemId of result.incompleteBatchItemIds) incomplete.add(itemId); } for (const item of prepared) { @@ -323,13 +362,46 @@ export class ReportMaterialsService { if (item.reportType === 'signature') await this.prisma.smsSignature.update({ where: { id: item.signature.id }, data: { pendingReport: false } }); else await this.prisma.smsDrainageInfo.update({ where: { id: item.drainageInfo!.id }, data: { pendingReport: false } }); } - return this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: incomplete.size ? 'partial_failed' : 'completed', channelCount: channelMap.size, fileCount: exportedFiles.length, completedAt: new Date() }, include: { exportFiles: true, items: true } }); + const completed = await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: incomplete.size ? 'partial_failed' : 'completed', channelCount: channelMap.size, fileCount: exportedFiles.length, completedAt: new Date() }, include: { exportFiles: true, items: true } }); + const result = { + ...completed, + operationId: claimed.operationId, + replayed: false, + result: { + successCount: preflight.eligibleTargetCount - failedTargetCount, + skippedCount: preflight.skippedTargetCount, + failedCount: failedTargetCount, + items: preflight.items, + }, + }; + await this.completeBatchOperation(claimed.operationId, batch.id, result); + return result; } catch (error) { await this.prisma.reportMaterialBatch.update({ where: { id: batch.id }, data: { status: 'failed', errorMessage: error instanceof Error ? error.message : '生成报备批次失败', completedAt: new Date() } }); + await this.failBatchOperation(claimed.operationId, error instanceof Error ? error.message : '生成报备批次失败', batch.id); throw error; } } + async preflightBatch(data: Pick) { + if (!data.items?.length) throw new BadRequestException('请选择需要报备的签名或引流信息'); + for (const item of data.items) { + if (!['signature', 'drainage'].includes(item.reportType) || !item.signatureId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '每条报备资料必须包含有效的资料类型和签名ID' }); + if (item.reportType === 'drainage' && !item.drainageItemId) throw new BadRequestException({ code: 'REPORT_BATCH_ITEM_INVALID', message: '引流资料必须包含引流资料ID' }); + } + const uniqueItems = [...new Map(data.items.map((item) => [`${item.reportType}:${item.drainageItemId ?? item.signatureId}`, item])).values()]; + const items = await Promise.all(uniqueItems.map((item) => this.inspectBatchItem(item))); + return { + checkedAt: new Date().toISOString(), + eligible: items.some((item) => item.eligible), + eligibleItemCount: items.filter((item) => item.eligible).length, + blockedItemCount: items.filter((item) => !item.eligible).length, + eligibleTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => target.eligible).length, 0), + skippedTargetCount: items.reduce((sum, item) => sum + item.targets.filter((target) => !target.eligible).length, 0), + items, + }; + } + private async importSignatureRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record) { const name = mappedCoreValue(mappings, values, 'signatureName'); if (!name) throw new Error('缺少短信签名'); @@ -354,7 +426,7 @@ export class ReportMaterialsService { return this.smsConfig.createDrainageInfo(signature.id, { siteName, url, remark, reportValues }, { initialAuditStatus: 'approved' }); } - private async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number]) { + private async prepareBatchItem(batchId: string, selected: CreateReportBatchDto['items'][number], inspection: ReportBatchInspection) { const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } }); if (!signature || signature.auditStatus !== 'approved') throw new BadRequestException('签名不存在或未审核通过'); const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId @@ -365,15 +437,121 @@ export class ReportMaterialsService { include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } }, orderBy: { priority: 'asc' }, }) : []; - const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active').map((channel) => [channel.id, channel])).values()]; + const eligibleChannelIds = new Set(inspection.targets.filter((target) => target.eligible).map((target) => target.id)); + const channels = [...new Map(routes.flatMap((route) => route.group.items.map((entry) => entry.channel)).filter((channel) => channel.status === 'active' && eligibleChannelIds.has(channel.id)).map((channel) => [channel.id, channel])).values()]; const snapshot = selected.reportType === 'signature' - ? { reportType: 'signature', signature: { id: signature.id, name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) } - : { reportType: 'drainage', signature: { id: signature.id, name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { id: drainageInfo!.id, siteName: drainageInfo!.siteName, url: drainageInfo!.url, remark: drainageInfo!.remark }, values: jsonRecord(drainageInfo!.reportValues) }; + ? { reportType: 'signature', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) } + : { reportType: 'drainage', applicationId: signature.applicationId, businessKeys: inspection.targets.filter((target) => target.eligible).map((target) => target.businessKey), signature: { id: signature.id, name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { id: drainageInfo!.id, siteName: drainageInfo!.siteName, url: drainageInfo!.url, remark: drainageInfo!.remark }, values: jsonRecord(drainageInfo!.reportValues) }; const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo!.materialVersion; const batchItem = await this.prisma.reportMaterialBatchItem.create({ data: { batchId, signatureId: signature.id, drainageItemId: drainageInfo?.id, reportType: selected.reportType, materialVersion, snapshot: snapshot as Prisma.InputJsonValue } }); return { batchItem, signature, drainageInfo, reportType: selected.reportType, snapshot, channels }; } + private async inspectBatchItem(selected: CreateReportBatchDto['items'][number]): Promise { + const signature = await this.prisma.smsSignature.findUnique({ where: { id: selected.signatureId }, include: { tenant: true, application: true } }); + if (!signature) throw new NotFoundException('签名不存在'); + const drainageInfo = selected.reportType === 'drainage' && selected.drainageItemId + ? await this.prisma.smsDrainageInfo.findUnique({ where: { id: selected.drainageItemId } }) : null; + const materialVersion = selected.reportType === 'signature' ? signature.materialVersion : drainageInfo?.materialVersion ?? 0; + const blockedReasons: string[] = []; + if (signature.auditStatus !== 'approved') blockedReasons.push('签名尚未审核通过'); + if (!signature.pendingReport) blockedReasons.push('该签名版本已不在待报备池'); + if (!signature.applicationId || !signature.application) blockedReasons.push('未绑定短信应用'); + else if (signature.application.status !== 'active') blockedReasons.push('短信应用未启用'); + if (selected.materialVersion !== undefined && selected.materialVersion !== materialVersion) blockedReasons.push(`资料版本已变化(当前 V${materialVersion})`); + if (selected.reportType === 'drainage') { + if (!drainageInfo || drainageInfo.signatureId !== signature.id) blockedReasons.push('引流资料不存在或不属于当前签名'); + else { + if (drainageInfo.auditStatus !== 'approved') blockedReasons.push('引流资料尚未审核通过'); + if (!drainageInfo.pendingReport) blockedReasons.push('该引流资料版本已不在待报备池'); + } + } + const snapshot = selected.reportType === 'signature' + ? { signature: { name: signature.name, purpose: signature.purpose, tenantName: signature.tenant.name, applicationName: signature.application?.name }, values: jsonRecord(jsonRecord(signature.drainageInfo).signatureReportValues) } + : { signature: { name: signature.name, tenantName: signature.tenant.name, applicationName: signature.application?.name }, drainage: { siteName: drainageInfo?.siteName, url: drainageInfo?.url, remark: drainageInfo?.remark }, values: jsonRecord(drainageInfo?.reportValues) }; + const routes = signature.applicationId ? await this.prisma.channelRouteRule.findMany({ + where: { applicationId: signature.applicationId, status: 'active' }, + include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } }, + orderBy: { priority: 'asc' }, + }) : []; + const channelCarriers = new Map }>(); + for (const route of routes) { + if (route.group.status !== 'active') continue; + for (const entry of route.group.items) { + if (entry.channel.status !== 'active') continue; + const current = channelCarriers.get(entry.channel.id) ?? { channel: entry.channel, carriers: new Set() }; + current.carriers.add(route.carrier || entry.carrier || entry.channel.carrier || 'all'); + channelCarriers.set(entry.channel.id, current); + } + } + if (blockedReasons.length === 0 && channelCarriers.size === 0) blockedReasons.push('当前应用没有启用且可路由的通道'); + const previous = await this.prisma.reportMaterialBatchItem.findMany({ + where: { signatureId: signature.id, drainageItemId: selected.reportType === 'drainage' ? drainageInfo?.id : null, reportType: selected.reportType, materialVersion, batch: { status: { in: ['completed', 'partial_failed'] } } }, + select: { batchId: true, snapshot: true, exportItems: { select: { exportFile: { select: { channelId: true } } } } }, + orderBy: { createdAt: 'desc' }, + }); + const priorKeys = new Map(); + for (const item of previous) { + const exportedChannelIds = new Set(item.exportItems.map((entry) => entry.exportFile.channelId).filter((value): value is string => Boolean(value))); + for (const key of jsonStringArray(jsonRecord(item.snapshot).businessKeys)) { + if ([...exportedChannelIds].some((channelId) => key.includes(`:channel:${channelId}:`)) && !priorKeys.has(key)) priorKeys.set(key, item.batchId); + } + } + const targets: ReportBatchTarget[] = []; + for (const { channel, carriers } of channelCarriers.values()) { + const carrier = [...carriers].sort().join(','); + const businessKey = `${selected.reportType}:${selected.drainageItemId ?? signature.id}:v${materialVersion}:app:${signature.applicationId}:channel:${channel.id}:carrier:${carrier}`; + const targetReasons = [...blockedReasons]; + const fields = await this.prisma.channelReportField.findMany({ where: { channelId: channel.id, status: 'active', reportType: { in: [selected.reportType, 'both'] } }, orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] }); + if (fields.length === 0) targetReasons.push('通道未配置当前资料类型的报备字段'); + else { + const missing = fields.filter((field) => field.required && !hasValue(resolveExportValue(snapshot, field.code, field.name) ?? field.defaultValue)); + if (missing.length) targetReasons.push(`缺少必填字段:${missing.map((field) => field.exportName || field.name).join('、')}`); + } + const duplicateBatchId = priorKeys.get(businessKey); + if (duplicateBatchId) targetReasons.push(`同一资料版本已在批次 ${duplicateBatchId} 生成`); + targets.push({ id: channel.id, name: channel.name, carrier, businessKey, eligible: targetReasons.length === 0, blockedReasons: targetReasons, duplicateBatchId }); + } + return { + id: `${selected.reportType}:${selected.drainageItemId ?? signature.id}`, + reportType: selected.reportType, + signatureId: signature.id, + drainageItemId: drainageInfo?.id, + materialVersion, + name: selected.reportType === 'signature' ? signature.name : drainageInfo?.siteName ?? '引流资料', + tenantName: signature.tenant.name, + applicationId: signature.applicationId ?? undefined, + applicationName: signature.application?.name ?? '未指定应用', + eligible: targets.some((target) => target.eligible), + blockedReasons: targets.length ? [...new Set(targets.flatMap((target) => target.blockedReasons))] : blockedReasons, + targets, + }; + } + + private async claimBatchOperation(idempotencyKey: string, fingerprint: string, userId?: string) { + return this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${idempotencyKey}))`; + const existing = await tx.operationLog.findFirst({ where: { action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey }, orderBy: { createdAt: 'desc' } }); + if (existing) { + const detail = jsonRecord(existing.detail); + if (detail.fingerprint !== fingerprint) throw new ConflictException({ code: 'IDEMPOTENCY_KEY_REUSED', message: '该幂等键已用于不同的报备范围' }); + if (detail.status === 'completed' && detail.result) return { operationId: existing.id, replayed: true as const, result: { ...jsonRecord(detail.result), replayed: true, operationId: existing.id } }; + throw new ConflictException({ code: 'REPORT_BATCH_IN_PROGRESS', message: detail.status === 'failed' ? '上次生成失败,请使用新的操作单重试' : '该报备操作正在处理中,请勿重复提交' }); + } + const operation = await tx.operationLog.create({ data: { userId, action: 'report_material.batch_generation', resource: 'report_material_batch', resourceId: idempotencyKey, detail: { status: 'processing', fingerprint } } }); + return { operationId: operation.id, replayed: false as const, result: null }; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + } + + private async completeBatchOperation(operationId: string, batchId: string, result: Record) { + await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { status: 'completed', batchId, fingerprint: jsonRecord((await this.prisma.operationLog.findUnique({ where: { id: operationId } }))?.detail).fingerprint, result: jsonSafe(result) } as Prisma.InputJsonValue } }); + } + + private async failBatchOperation(operationId: string, message: string, batchId?: string) { + const operation = await this.prisma.operationLog.findUnique({ where: { id: operationId } }); + await this.prisma.operationLog.update({ where: { id: operationId }, data: { detail: { ...jsonRecord(operation?.detail), status: 'failed', batchId, message } as Prisma.InputJsonValue } }); + } + private async exportChannelBatch(batchId: string, channelId: string, items: Array>>) { const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); if (!channel) throw new NotFoundException('通道不存在'); @@ -602,3 +780,17 @@ function styleHeader(row: ExcelJS.Row) { function normalizeImageExtension(value: string) { const normalized = value.toLowerCase().replace(/^\./, ''); return normalized === 'jpg' ? 'jpeg' : normalized; } function imageContentType(extension: string) { const normalized = normalizeImageExtension(extension); return normalized === 'jpeg' ? 'image/jpeg' : normalized === 'gif' ? 'image/gif' : 'image/png'; } function safeFileName(value: string) { return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 80) || '通道报备'; } + +function normalizeBatchIdempotencyKey(value?: string) { + const key = value?.trim(); + if (!key || key.length > 128 || !/^[A-Za-z0-9._:-]{8,128}$/.test(key)) throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'idempotencyKey 必填且长度为8至128位' }); + return key; +} + +function jsonStringArray(value: unknown) { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; +} + +function jsonSafe(value: unknown): Prisma.InputJsonValue { + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +} diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 220864b..772bc59 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -84,8 +84,10 @@ function createPrismaMock() { id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', + signatureId: 'sig-1', + content: 'hello', auditStatus: 'approved', - signature: { auditStatus: 'approved', reportStatus: 'reporting' }, + signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' }, }), findFirst: jest.fn().mockResolvedValue({ id: 'tpl-1', @@ -112,6 +114,7 @@ function createPrismaMock() { findFirst: jest.fn().mockResolvedValue(task), findMany: jest.fn(), update: jest.fn().mockResolvedValue(task), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, smsApiRequest: { create: jest.fn().mockResolvedValue({ id: 'request-1' }), @@ -214,6 +217,9 @@ function createPrismaMock() { messageId: 'MSG-1', deliveryType: 'receipt', retryCount: 0, + manualRetryCount: 0, + status: 'failed', + updatedAt: new Date('2026-07-21T08:00:00.000Z'), lastError: null, payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' }, application: { cmppAccount: '100001' }, @@ -257,6 +263,7 @@ function createPrismaMock() { }), update: jest.fn().mockResolvedValue({ id: 'dead-1', tenantId: 'tenant-1', streamMessageId: '1710000000000-0', submitId: 'SUB-1', messageId: 'MSG-1' }), updateMany: jest.fn().mockResolvedValue({ count: 1 }), + findMany: jest.fn().mockResolvedValue([]), }, gatewayDownstreamRecoveryStatus: { findUnique: jest.fn().mockResolvedValue(null), @@ -364,6 +371,10 @@ describe('SendChainService', () => { id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}', auditStatus: 'approved', signatureId: 'sig-1', signature: { id: 'sig-1', auditStatus: 'approved' }, }); + prisma.smsTemplate.findUnique.mockResolvedValue({ + id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}', + auditStatus: 'approved', signatureId: 'sig-1', signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' }, + }); await service.createHttpBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码123456', phones: ['13800000001'], @@ -384,16 +395,17 @@ describe('SendChainService', () => { service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); prisma.smsTemplate.findUnique.mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', auditStatus: 'approved', - signature: { id: 'sig-1', auditStatus: 'approved', reportStatus: 'reporting' }, + content: '【签名】详情请访问 https://a.example/landing', + signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved', reportStatus: 'reporting' }, }); prisma.smsDrainageInfo.findMany.mockResolvedValue([ - { id: 'drain-short', url: 'https://a.example', updatedAt: new Date('2026-07-01') }, - { id: 'drain-long', url: 'https://a.example/landing', updatedAt: new Date('2026-07-02') }, + { id: 'drain-short', url: 'https://a.example', auditStatus: 'approved', updatedAt: new Date('2026-07-01') }, + { id: 'drain-long', url: 'https://a.example/landing', auditStatus: 'approved', updatedAt: new Date('2026-07-02') }, ]); await service.createBatchTask({ tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', - content: '详情请访问 https://a.example/landing', phones: ['13800000001'], + content: '【签名】详情请访问 https://a.example/landing', phones: ['13800000001'], }); expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ @@ -401,6 +413,81 @@ describe('SendChainService', () => { }); }); + it('rejects a task when the submitted content no longer matches the selected approved template', async () => { + const { service, prisma, riskReview } = createService(); + prisma.smsTemplate.findUnique.mockResolvedValue({ + id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', + content: '【签名】验证码${code}', auditStatus: 'approved', + signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' }, + }); + + await expect(service.createBatchTask({ + tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', + content: '【签名】被篡改的正文', phones: ['13800000001'], + })).rejects.toThrow('短信内容与选定的审核模板不匹配'); + + expect(riskReview.evaluateTask).not.toHaveBeenCalled(); + expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); + }); + + it('rejects free content without an approved leading signature', async () => { + const { service, prisma } = createService(); + prisma.smsApplication.findUnique.mockResolvedValue({ + id: 'app-1', tenantId: 'tenant-1', status: 'active', interfaceEnabled: true, + customerUnitPrice: 3, queuePriority: 'normal', templateMismatchMode: 'direct_send', + }); + prisma.smsSignature.findFirst.mockResolvedValue(null); + + await expect(service.createBatchTask({ + tenantId: 'tenant-1', applicationId: 'app-1', content: '没有签名的自由内容', phones: ['13800000001'], + })).rejects.toThrow('短信内容未以当前应用已审核通过的签名开头'); + }); + + it('allows signed free content only when the application explicitly uses direct send', async () => { + const { service, prisma } = createService(); + service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); + prisma.smsApplication.findUnique.mockResolvedValue({ + id: 'app-1', tenantId: 'tenant-1', status: 'active', interfaceEnabled: true, + customerUnitPrice: 3, queuePriority: 'normal', templateMismatchMode: 'direct_send', + }); + prisma.smsSignature.findFirst.mockResolvedValue({ id: 'sig-1', name: '【签名】', auditStatus: 'approved' }); + + await expect(service.createBatchTask({ + tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】允许直接发送的自由内容', phones: ['13800000001'], + })).resolves.toBeDefined(); + + expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ signatureId: 'sig-1' })], + }); + }); + + it.each(['pending', 'rejected'])('blocks a matched %s drainage URL and preserves the matched resource on rejected records', async (auditStatus) => { + const { service, prisma, riskReview } = createService(); + prisma.smsTemplate.findUnique.mockResolvedValue({ + id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', + content: '【签名】详情 https://blocked.example', auditStatus: 'approved', + signature: { id: 'sig-1', name: '【签名】', auditStatus: 'approved' }, + }); + prisma.smsDrainageInfo.findMany.mockResolvedValue([ + { id: 'drain-blocked', url: 'https://blocked.example', auditStatus, updatedAt: new Date('2026-07-21') }, + ]); + + await expect(service.createBatchTask({ + tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', + content: '【签名】详情 https://blocked.example', phones: ['13800000001'], + })).resolves.toBeDefined(); + + expect(prisma.smsBatchTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ status: 'rejected', rejectReason: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`) }), + }); + expect(prisma.smsMessageRecord.createMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ + drainageInfoId: 'drain-blocked', status: 'rejected', errorMessage: expect.stringContaining(`drain-blocked 当前为 ${auditStatus}`), + })], + }); + expect(riskReview.evaluateTask).not.toHaveBeenCalled(); + }); + it('creates scheduled tasks without immediate enqueue and dispatches due tasks later', async () => { const { service, prisma, billing } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); @@ -437,6 +524,102 @@ describe('SendChainService', () => { where: { batchTaskId: 'task-1', status: 'scheduled' }, data: { status: 'queued' }, }); + expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ id: 'task-1', status: 'scheduled' }), + data: { status: 'scheduled_dispatching' }, + }); + }); + + it('atomically claims a due scheduled task so concurrent scanners only freeze and enqueue once', async () => { + const { service, prisma, billing } = createService(); + const dueTask = { + id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', + status: 'scheduled', scheduledAt: new Date(Date.now() - 1_000), updatedAt: new Date(Date.now() - 1_000), + }; + prisma.smsBatchTask.findMany.mockResolvedValue([dueTask]); + prisma.smsBatchTask.updateMany + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 0 }); + prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]); + service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); + + const results = await Promise.all([ + service.dispatchDueScheduledTasks(new Date()), + service.dispatchDueScheduledTasks(new Date()), + ]); + + expect(results.map((item) => item.dispatched).sort()).toEqual([0, 1]); + expect(billing.freeze).toHaveBeenCalledTimes(1); + expect(service.enqueueBatchTask).toHaveBeenCalledTimes(1); + }); + + it('recovers a stale claimed task without freezing its balance twice', async () => { + const { service, prisma, billing } = createService(); + const now = new Date(); + prisma.smsBatchTask.findMany.mockResolvedValue([{ + id: 'task-1', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', + status: 'scheduled_dispatching', scheduledAt: new Date(now.getTime() - 300_000), + updatedAt: new Date(now.getTime() - 300_000), + }]); + prisma.accountTransaction.findFirst.mockResolvedValue({ id: 'frozen-transaction-1' }); + prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-1', amountCents: 3, billingUnits: 1 }]); + service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); + + await expect(service.dispatchDueScheduledTasks(now)).resolves.toEqual({ + dispatched: 1, + results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }], + }); + + expect(prisma.smsBatchTask.updateMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ id: 'task-1', status: 'scheduled_dispatching', updatedAt: { lt: expect.any(Date) } }), + data: { status: 'scheduled_recovering' }, + }); + expect(billing.freeze).not.toHaveBeenCalled(); + }); + + it('keeps a zero-fee task recoverable when queue enqueue fails after preparation', async () => { + const { service, prisma } = createService(); + prisma.smsBatchTask.findMany.mockResolvedValue([{ + id: 'task-free', tenantId: 'tenant-1', applicationId: 'app-1', templateId: 'tpl-1', status: 'scheduled', + }]); + prisma.smsMessageRecord.findMany.mockResolvedValue([{ id: 'record-free', amountCents: 0, billingUnits: 1 }]); + service.enqueueBatchTask = jest.fn().mockRejectedValue(new Error('Redis unavailable')); + + await expect(service.dispatchDueScheduledTasks(new Date())).resolves.toEqual({ + dispatched: 0, + results: [{ taskId: 'task-free', status: 'retrying', reason: 'Redis unavailable' }], + }); + + expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({ + where: { id: 'task-free' }, + data: { status: 'scheduled_dispatching', rejectReason: '调度将在超时后恢复:Redis unavailable' }, + }); + }); + + it('automatically scans and dispatches due scheduled tasks after application startup', async () => { + jest.useFakeTimers(); + const previousReceiptEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED; + const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED; + const previousScheduledInterval = process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS; + const { service } = createService(); + const dispatch = jest.spyOn(service, 'dispatchDueScheduledTasks').mockResolvedValue({ dispatched: 0, results: [] }); + try { + process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'false'; + process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'true'; + process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = '60000'; + service.onModuleInit(); + await jest.advanceTimersByTimeAsync(1_000); + expect(dispatch).toHaveBeenCalledTimes(1); + await service.onModuleDestroy(); + } finally { + if (previousReceiptEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED; + else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousReceiptEnabled; + if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED; + else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled; + if (previousScheduledInterval === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS; + else process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = previousScheduledInterval; + jest.useRealTimers(); + } }); it('cancels scheduled tasks before dispatch', async () => { @@ -1155,7 +1338,7 @@ describe('SendChainService', () => { })); expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({ where: { - status: { in: ['pending', 'requeued'] }, + status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] }, OR: [{ submitId: 'SUB-1' }, { messageId: 'MSG-1' }], }, data: expect.objectContaining({ status: 'resolved', resolvedStatus: 'accepted' }), @@ -1734,13 +1917,16 @@ describe('SendChainService', () => { operatorId: 'user-1', }); - expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ submitId: 'SUB-1' })); + expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith( + expect.objectContaining({ submitId: 'SUB-1' }), + 'gateway:submit:requeue:dead-1:1', + ); expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({ where: { id: 'dead-1', status: 'pending' }, data: { status: 'requeueing' }, }); - expect(prisma.gatewaySubmitDeadLetter.update).toHaveBeenCalledWith({ - where: { id: 'dead-1' }, + expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({ + where: { id: 'dead-1', status: 'requeueing' }, data: expect.objectContaining({ status: 'requeued', manualRetryCount: { increment: 1 }, @@ -1778,6 +1964,103 @@ describe('SendChainService', () => { expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled(); }); + it('does not reset a resolved submit exception when Gateway repeats the same dead-letter report', async () => { + const { service, prisma } = createService(); + + await service.recordGatewaySubmitDeadLetter({ + streamMessageId: '1710000000000-0', + failureCode: 'SUBMIT_PROCESSING_FAILED', + failureMessage: 'repeated report', + attempts: 3, + maxAttempts: 3, + }); + + expect(prisma.gatewaySubmitDeadLetter.upsert).toHaveBeenCalledWith(expect.objectContaining({ + update: expect.not.objectContaining({ status: expect.anything(), resolvedAt: expect.anything(), resolvedStatus: expect.anything() }), + })); + }); + + it('recovers a stale submit requeue with the same Redis idempotency key', async () => { + const { service, prisma } = createService(); + const stale = { + ...await prisma.gatewaySubmitDeadLetter.findUnique({ where: { id: 'dead-1' } }), + status: 'requeueing', + updatedAt: new Date('2026-07-21T07:00:00.000Z'), + }; + prisma.gatewaySubmitDeadLetter.findMany.mockResolvedValue([stale]); + const publish = jest.spyOn(service as any, 'publishGatewaySubmitCommand').mockResolvedValue('1710000001000-0'); + + await expect(service.recoverStaleGatewaySubmitRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({ recovered: 1, failed: 0 }); + + expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenNthCalledWith(1, { + where: { id: 'dead-1', status: 'requeueing', updatedAt: stale.updatedAt }, + data: { status: 'requeue_recovering' }, + }); + expect(publish).toHaveBeenCalledWith( + stale.commandPayload, + 'gateway:submit:requeue:dead-1:1', + ); + expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenNthCalledWith(2, { + where: { id: 'dead-1', status: 'requeue_recovering' }, + data: expect.objectContaining({ + status: 'requeued', + manualRetryCount: { increment: 1 }, + lastRetryStreamId: '1710000001000-0', + }), + }); + }); + + it('atomically claims a downstream manual requeue so concurrent requests only call Gateway once', async () => { + const { service, prisma } = createService(); + service['postGatewayControl'] = jest.fn().mockResolvedValue({ sent: true, sequenceId: '11', messageId: '22' }); + prisma.cmppDownstreamDelivery.updateMany + .mockResolvedValueOnce({ count: 1 }) + .mockResolvedValueOnce({ count: 0 }); + + const results = await Promise.allSettled([ + service.requeueDownstreamDelivery('delivery-1'), + service.requeueDownstreamDelivery('delivery-1'), + ]); + + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1); + expect(service['postGatewayControl']).toHaveBeenCalledTimes(1); + expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith({ + where: { + id: 'delivery-1', + status: 'failed', + updatedAt: new Date('2026-07-21T08:00:00.000Z'), + }, + data: expect.objectContaining({ + status: 'manual_requeueing', + manualRetryCount: { increment: 1 }, + }), + }); + }); + + it('recovers a stale downstream manual-requeue claim into the Gateway pending path', async () => { + const { service, prisma } = createService(); + const updatedAt = new Date('2026-07-21T07:00:00.000Z'); + prisma.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'delivery-1', updatedAt }]); + + await expect(service.recoverStaleDownstreamManualRequeues(new Date('2026-07-21T08:00:00.000Z'))).resolves.toEqual({ recovered: 1 }); + + expect(prisma.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith({ + where: { status: 'manual_requeueing', updatedAt: { lt: expect.any(Date) } }, + select: { id: true, updatedAt: true }, + orderBy: { updatedAt: 'asc' }, + take: 500, + }); + expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith({ + where: { id: 'delivery-1', status: 'manual_requeueing', updatedAt }, + data: { + status: 'pending', + nextRetryAt: null, + lastError: '人工重投进程中断,已恢复为待投递', + }, + }); + }); + it('records gateway downstream recovery statuses', async () => { const { service, prisma } = createService(); @@ -2053,6 +2336,7 @@ describe('SendChainService', () => { status: 'failed', retryCount: 3, manualRetryCount: 1, + updatedAt: new Date('2026-07-21T08:00:00.000Z'), lastError: 'downstream client is not connected', payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' }, application: { cmppAccount: '100001' }, @@ -2086,9 +2370,14 @@ describe('SendChainService', () => { resourceId: 'delivery-1', }), }); - expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({ + expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + id: 'delivery-1', + status: 'failed', + updatedAt: new Date('2026-07-21T08:00:00.000Z'), + }), data: expect.objectContaining({ - status: 'pending', + status: 'manual_requeueing', retryCount: 0, manualRetryCount: { increment: 1 }, lastRetriedAt: expect.any(Date), @@ -2122,7 +2411,7 @@ describe('SendChainService', () => { }); await expect(service.requeueDownstreamDelivery('delivery-1')).rejects.toThrow('该记录正在等待客户端确认,不允许并发重投'); - expect(prisma.cmppDownstreamDelivery.update).not.toHaveBeenCalled(); + expect(prisma.cmppDownstreamDelivery.updateMany).not.toHaveBeenCalled(); expect(service['postGatewayControl']).not.toHaveBeenCalled(); }); @@ -2231,11 +2520,13 @@ describe('SendChainService', () => { it('starts the automatic receipt-timeout scan after application startup', async () => { jest.useFakeTimers(); const previousEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED; + const previousScheduledEnabled = process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED; const { service } = createService(); const scan = jest.spyOn(service, 'markUnknownTimeout').mockResolvedValue({ timeout: 0 }); const downstreamScan = jest.spyOn(service, 'markExpiredDownstreamDeliveries').mockResolvedValue({ failed: 0 }); try { process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'true'; + process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = 'false'; service.onModuleInit(); await jest.advanceTimersByTimeAsync(60_000); expect(scan).toHaveBeenCalledWith({}); @@ -2244,6 +2535,8 @@ describe('SendChainService', () => { } finally { if (previousEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED; else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousEnabled; + if (previousScheduledEnabled === undefined) delete process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED; + else process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED = previousScheduledEnabled; jest.useRealTimers(); } }); diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 1743329..b27fa30 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -255,6 +255,12 @@ const DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS = 72; const DEFAULT_RECEIPT_TIMEOUT_HOURS = 72; const DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS = 5 * 60_000; const RECEIPT_TIMEOUT_INITIAL_DELAY_MS = 60_000; +const DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS = 5_000; +const DEFAULT_SCHEDULED_DISPATCH_STALE_MS = 2 * 60_000; +const SCHEDULED_DISPATCH_INITIAL_DELAY_MS = 1_000; +const DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS = 2 * 60_000; +const DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS = 2 * 60_000; +const GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60; const BULLMQ_PRIORITY: Record = { priority: 1, normal: 100, @@ -270,6 +276,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { private receiptTimeoutInitialTimer?: ReturnType; private receiptTimeoutIntervalTimer?: ReturnType; private receiptTimeoutScanRunning = false; + private scheduledDispatchInitialTimer?: ReturnType; + private scheduledDispatchIntervalTimer?: ReturnType; + private scheduledDispatchScanRunning = false; constructor( private readonly prisma: PrismaService, @@ -291,11 +300,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { ); this.receiptTimeoutIntervalTimer.unref?.(); } + if (process.env.SMS_SCHEDULED_DISPATCH_SCAN_ENABLED !== 'false') { + this.scheduledDispatchInitialTimer = setTimeout( + () => void this.runScheduledDispatchScan(), + SCHEDULED_DISPATCH_INITIAL_DELAY_MS, + ); + this.scheduledDispatchInitialTimer.unref?.(); + this.scheduledDispatchIntervalTimer = setInterval( + () => void this.runScheduledDispatchScan(), + positiveInteger(process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS), + ); + this.scheduledDispatchIntervalTimer.unref?.(); + } } async onModuleDestroy() { if (this.receiptTimeoutInitialTimer) clearTimeout(this.receiptTimeoutInitialTimer); if (this.receiptTimeoutIntervalTimer) clearInterval(this.receiptTimeoutIntervalTimer); + if (this.scheduledDispatchInitialTimer) clearTimeout(this.scheduledDispatchInitialTimer); + if (this.scheduledDispatchIntervalTimer) clearInterval(this.scheduledDispatchIntervalTimer); await this.worker?.close(); await this.sendQueue?.close(); await this.gatewayQueue?.close(); @@ -307,21 +330,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { const schedule = parseSchedule(data); await this.validateSendResources(data.tenantId, data.applicationId, data.templateId); const [messageClassification, unitPrice, queuePriority, accessNumber] = await Promise.all([ - this.resolveTemplateMessageClassification(data.templateId, data.content), + this.resolveTemplateMessageClassification(data.tenantId, data.applicationId, data.templateId, data.content), this.resolveUnitPrice(data.tenantId, data.applicationId), this.resolveQueuePriority(data.tenantId, data.applicationId), this.resolveApplicationAccessNumber(data.tenantId, data.applicationId), ]); - const risk = await this.riskReview.evaluateTask({ - tenantId: data.tenantId, - applicationId: data.applicationId, - templateId: data.templateId, - content: data.content, - category: data.category, - phones, - variables: data.variables, - createdById: data.createdById, - }); + const risk = messageClassification.rejectionReason + ? { status: 'rejected', reason: messageClassification.rejectionReason, task: null } + : await this.riskReview.evaluateTask({ + tenantId: data.tenantId, + applicationId: data.applicationId, + templateId: data.templateId, + content: data.content, + category: data.category, + phones, + variables: messageClassification.variables ?? data.variables, + createdById: data.createdById, + }); const billing = this.billing.estimateSmsCost({ tenantId: data.tenantId, applicationId: data.applicationId, @@ -703,33 +728,64 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } async dispatchDueScheduledTasks(now = new Date()) { + const staleCutoff = new Date(now.getTime() - positiveInteger( + process.env.SMS_SCHEDULED_DISPATCH_STALE_MS, + DEFAULT_SCHEDULED_DISPATCH_STALE_MS, + )); const tasks = await this.prisma.smsBatchTask.findMany({ - where: { status: 'scheduled', scheduledAt: { lte: now } }, + where: { + OR: [ + { status: 'scheduled', scheduledAt: { lte: now } }, + { status: { in: ['scheduled_dispatching', 'scheduled_recovering'] }, updatedAt: { lt: staleCutoff } }, + ], + }, orderBy: { scheduledAt: 'asc' }, }); const results: Array<{ taskId: string; status: string; enqueued?: number; reason?: string }> = []; for (const task of tasks) { + const candidateStatus = task.status || 'scheduled'; + const claimedStatus = candidateStatus === 'scheduled_dispatching' ? 'scheduled_recovering' : 'scheduled_dispatching'; + const claimed = await this.prisma.smsBatchTask.updateMany({ + where: { + id: task.id, + status: candidateStatus, + ...(candidateStatus === 'scheduled' ? {} : { updatedAt: { lt: staleCutoff } }), + }, + data: { status: claimedStatus }, + }); + if (claimed.count !== 1) continue; + let reservationEstablished = false; + let dispatchPrepared = false; try { await this.validateSendResources(task.tenantId, task.applicationId ?? undefined, task.templateId ?? undefined); const messages = await this.prisma.smsMessageRecord.findMany({ - where: { batchTaskId: task.id, status: 'scheduled' }, + where: { batchTaskId: task.id, status: { in: ['scheduled', 'queued'] } }, select: { id: true, amountCents: true, billingUnits: true }, take: 100000, }); const amountCents = messages.reduce((sum, message) => sum + moneyToNumber(message.amountCents), 0); - const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents }); - if (!accountCheck.canSend) { - throw new BadRequestException('定时任务到点时企业账户余额不足'); - } - if (amountCents > 0) { - await this.billing.freeze({ - tenantId: task.tenantId, - amountCents, - relatedType: 'sms_batch_task', - relatedId: task.id, - remark: '定时任务到点冻结', - }); + const existingReservation = await this.prisma.accountTransaction.findFirst({ + where: { tenantId: task.tenantId, transactionType: 'frozen', relatedType: 'sms_batch_task', relatedId: task.id }, + select: { id: true }, + }); + reservationEstablished = Boolean(existingReservation); + if (!reservationEstablished) { + const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents }); + if (!accountCheck.canSend) { + throw new BadRequestException('定时任务到点时企业账户余额不足'); + } + if (amountCents > 0) { + await this.billing.freeze({ + tenantId: task.tenantId, + amountCents, + relatedType: 'sms_batch_task', + relatedId: task.id, + remark: '定时任务到点冻结', + }); + reservationEstablished = true; + } } + dispatchPrepared = true; await this.prisma.smsMessageRecord.updateMany({ where: { batchTaskId: task.id, status: 'scheduled' }, data: { status: 'queued' }, @@ -738,6 +794,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { results.push({ taskId: task.id, status: 'queued', enqueued: enqueued.enqueued }); } catch (error) { const reason = error instanceof Error ? error.message : '定时任务到点执行失败'; + if (reservationEstablished || dispatchPrepared) { + await this.prisma.smsBatchTask.update({ + where: { id: task.id }, + data: { status: claimedStatus, rejectReason: `调度将在超时后恢复:${reason}` }, + }); + results.push({ taskId: task.id, status: 'retrying', reason }); + continue; + } await this.prisma.smsMessageRecord.updateMany({ where: { batchTaskId: task.id, status: 'scheduled' }, data: { status: 'rejected', errorMessage: reason }, @@ -752,6 +816,18 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return { dispatched: results.filter((result) => result.status === 'queued').length, results }; } + private async runScheduledDispatchScan() { + if (this.scheduledDispatchScanRunning) return; + this.scheduledDispatchScanRunning = true; + try { + await this.dispatchDueScheduledTasks(); + } catch (error) { + this.logger.error(`Scheduled SMS dispatch scan failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + this.scheduledDispatchScanRunning = false; + } + } + startWorker() { if (this.worker) { return { status: 'already_started' }; @@ -852,7 +928,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } await this.prisma.gatewaySubmitDeadLetter.updateMany({ where: { - status: { in: ['pending', 'requeued'] }, + status: { in: ['pending', 'requeueing', 'requeue_recovering', 'requeued'] }, OR: [ data.submitId ? { submitId: data.submitId } : undefined, data.messageId ? { messageId: data.messageId } : undefined, @@ -1189,15 +1265,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { traceId: data.traceId, messageId: data.messageId, submitId: data.submitId, - status: 'pending', failureCode: data.failureCode, failureMessage: data.failureMessage, attempts: data.attempts, maxAttempts: data.maxAttempts, commandPayload: data.commandPayload as Prisma.InputJsonValue | undefined, rawPayload: data.rawPayload, - resolvedAt: null, - resolvedStatus: null, }, create: { streamMessageId: data.streamMessageId, @@ -1369,19 +1442,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (claimed.count !== 1) { throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试'); } + const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1); let retryStreamMessageId: string; try { - const publishedStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload); + const publishedStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey); if (!publishedStreamMessageId) { throw new Error('Gateway提交异常重新入队未返回Stream消息编号'); } retryStreamMessageId = publishedStreamMessageId; } catch (error) { - await this.prisma.gatewaySubmitDeadLetter.update({ where: { id }, data: { status: 'pending' } }); + await this.prisma.gatewaySubmitDeadLetter.updateMany({ + where: { id, status: 'requeueing' }, + data: { status: 'pending' }, + }); throw error; } - const updated = await this.prisma.gatewaySubmitDeadLetter.update({ - where: { id }, + const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({ + where: { id, status: 'requeueing' }, data: { status: 'requeued', manualRetryCount: { increment: 1 }, @@ -1389,6 +1466,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { lastRetriedAt: new Date(), }, }); + const updated = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } }); + if (!updated) { + throw new NotFoundException('Gateway提交异常记录不存在'); + } + if (finalized.count !== 1 && updated.status !== 'resolved') { + throw new BadRequestException('该提交异常状态已变化,请刷新后确认处理结果'); + } await this.prisma.operationLog.create({ data: { tenantId: updated.tenantId ?? undefined, @@ -1409,6 +1493,69 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return updated; } + async recoverStaleGatewaySubmitRequeues(now = new Date()) { + const staleCutoff = new Date(now.getTime() - positiveInteger( + process.env.GATEWAY_SUBMIT_REQUEUE_STALE_MS, + DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, + )); + const stale = await this.prisma.gatewaySubmitDeadLetter.findMany({ + where: { status: 'requeueing', updatedAt: { lt: staleCutoff } }, + orderBy: { updatedAt: 'asc' }, + take: 100, + }); + let recovered = 0; + let failed = 0; + for (const deadLetter of stale) { + if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) { + await this.prisma.gatewaySubmitDeadLetter.updateMany({ + where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt }, + data: { status: 'pending' }, + }); + failed += 1; + continue; + } + const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({ + where: { id: deadLetter.id, status: 'requeueing', updatedAt: deadLetter.updatedAt }, + data: { status: 'requeue_recovering' }, + }); + if (claimed.count !== 1) continue; + try { + const requeueKey = gatewaySubmitRequeueKey(deadLetter.id, deadLetter.manualRetryCount + 1); + const retryStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload, requeueKey); + if (!retryStreamMessageId) throw new Error('Gateway提交异常恢复未返回Stream消息编号'); + const finalized = await this.prisma.gatewaySubmitDeadLetter.updateMany({ + where: { id: deadLetter.id, status: 'requeue_recovering' }, + data: { + status: 'requeued', + manualRetryCount: { increment: 1 }, + lastRetryStreamId: retryStreamMessageId, + lastRetriedAt: new Date(), + }, + }); + if (finalized.count === 1) { + recovered += 1; + await this.prisma.operationLog.create({ + data: { + tenantId: deadLetter.tenantId ?? undefined, + action: 'gateway.submit_dead_letter_requeue_recovered', + resource: 'gateway_submit_dead_letter', + resourceId: deadLetter.id, + detail: { retryStreamMessageId, requeueKey }, + }, + }); + } + } catch (error) { + failed += 1; + await this.prisma.gatewaySubmitDeadLetter.updateMany({ + where: { id: deadLetter.id, status: 'requeue_recovering' }, + data: { status: 'requeueing' }, + }); + this.logger.error(`Gateway submit requeue recovery failed for ${deadLetter.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return { recovered, failed }; + } + async requeueDownstreamDelivery(id: string) { const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id }, @@ -1440,10 +1587,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { ...payload, }; const retriedAt = new Date(); - const requeued = await this.prisma.cmppDownstreamDelivery.update({ - where: { id: delivery.id }, + const claimed = await this.prisma.cmppDownstreamDelivery.updateMany({ + where: { + id: delivery.id, + status: delivery.status, + updatedAt: delivery.updatedAt, + }, data: { - status: 'pending', + status: 'manual_requeueing', retryCount: 0, manualRetryCount: { increment: 1 }, lastRetriedAt: retriedAt, @@ -1459,6 +1610,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { lastError: null, }, }); + if (claimed.count !== 1) { + throw new BadRequestException('该下游投递记录已被其他操作处理,请刷新后重试'); + } await this.prisma.operationLog.create({ data: { tenantId: delivery.tenantId, @@ -1471,7 +1625,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { messageId: delivery.messageId, previousStatus: delivery.status, previousRetryCount: delivery.retryCount, - manualRetryCount: requeued.manualRetryCount, + manualRetryCount: (delivery.manualRetryCount ?? 0) + 1, lastRetriedAt: retriedAt, }, }, @@ -1494,6 +1648,32 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } } + async recoverStaleDownstreamManualRequeues(now = new Date()) { + const staleCutoff = new Date(now.getTime() - positiveInteger( + process.env.CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, + DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, + )); + const stale = await this.prisma.cmppDownstreamDelivery.findMany({ + where: { status: 'manual_requeueing', updatedAt: { lt: staleCutoff } }, + select: { id: true, updatedAt: true }, + orderBy: { updatedAt: 'asc' }, + take: 500, + }); + let recovered = 0; + for (const delivery of stale) { + const updated = await this.prisma.cmppDownstreamDelivery.updateMany({ + where: { id: delivery.id, status: 'manual_requeueing', updatedAt: delivery.updatedAt }, + data: { + status: 'pending', + nextRetryAt: null, + lastError: '人工重投进程中断,已恢复为待投递', + }, + }); + recovered += updated.count; + } + return { recovered }; + } + async batchRequeueDownstreamDeliveries(ids: string[]) { const uniqueIds = [...new Set(ids.filter(Boolean))]; if (uniqueIds.length === 0) { @@ -1949,7 +2129,17 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { await this.recordCmppFailureReceipt(message, code, reason); }; const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => { - const drainageInfoId = await this.resolveDrainageInfoId(options.signatureId, data.content); + const drainage = await this.resolveDrainageInfoMatch(options.signatureId, data.content); + const drainageInfoId = drainage?.id; + const drainageReason = drainageRejectionReason(drainage); + if (drainageReason) { + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { drainageInfoId, signatureId: options.signatureId }, + }); + await reject('DRAINAGE_NOT_APPROVED', drainageReason); + return; + } const risk = await this.riskReview.evaluateTask({ tenantId: application.tenantId, applicationId: application.id, @@ -2011,6 +2201,24 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (!signature) { await reject('SIGNATURE', '短信内容未识别到已审核通过的签名'); } else { + const drainage = await this.resolveDrainageInfoMatch(signature.id, data.content); + const drainageReason = drainageRejectionReason(drainage); + if (drainageReason) { + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { drainageInfoId: drainage?.id, signatureId: signature.id }, + }); + await reject('DRAINAGE_NOT_APPROVED', drainageReason); + return { + accepted: true, + tenantId: application.tenantId, + applicationId: application.id, + messageId, + messageRecordId: message.id, + taskId: task.id, + status: 'rejected', + }; + } const risk = await this.riskReview.evaluateTask({ tenantId: application.tenantId, applicationId: application.id, @@ -2037,7 +2245,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }); } const reviewTask = risk.status === 'pending_review' && risk.task - ? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id, await this.resolveDrainageInfoId(signature.id, data.content)) + ? await this.attachMessageToReviewTask(risk.task.id, message.id, signature.id, drainage?.id) : await this.riskReview.aggregateTemplateMismatch({ tenantId: application.tenantId, applicationId: application.id, @@ -2143,12 +2351,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (this.receiptTimeoutScanRunning) return; this.receiptTimeoutScanRunning = true; try { - const [receiptResult, downstreamResult] = await Promise.all([ + const [receiptResult, downstreamResult, requeueRecoveryResult, downstreamManualRecoveryResult] = await Promise.all([ this.markUnknownTimeout({}), this.markExpiredDownstreamDeliveries(), + this.recoverStaleGatewaySubmitRequeues(), + this.recoverStaleDownstreamManualRequeues(), ]); if (receiptResult.timeout > 0) this.logger.log(`Marked ${receiptResult.timeout} messages as receipt timeout and refunded charged messages`); if (downstreamResult.failed > 0) this.logger.log(`Terminated ${downstreamResult.failed} expired downstream deliveries`); + if (requeueRecoveryResult.recovered > 0) this.logger.log(`Recovered ${requeueRecoveryResult.recovered} stale Gateway submit requeues`); + if (downstreamManualRecoveryResult.recovered > 0) this.logger.log(`Recovered ${downstreamManualRecoveryResult.recovered} stale downstream manual requeues`); } catch (error) { this.logger.error('Receipt timeout scan failed', error instanceof Error ? error.stack : String(error)); } finally { @@ -2510,21 +2722,67 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }); } - private async resolveTemplateMessageClassification(templateId: string | undefined, content: string) { - if (!templateId) return { signatureId: undefined, drainageInfoId: undefined }; - const template = await this.prisma.smsTemplate.findUnique({ - where: { id: templateId }, - select: { signatureId: true }, - }); - const signatureId = template?.signatureId ?? undefined; - return { signatureId, drainageInfoId: await this.resolveDrainageInfoId(signatureId, content) }; + private async resolveTemplateMessageClassification( + tenantId: string, + applicationId: string | undefined, + templateId: string | undefined, + content: string, + ) { + if (templateId) { + const template = await this.prisma.smsTemplate.findUnique({ + where: { id: templateId }, + include: { signature: true }, + }); + if (!template || template.tenantId !== tenantId || template.applicationId !== applicationId + || template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') { + throw new BadRequestException('短信模板不存在、未通过审核或不属于当前应用'); + } + const variables = matchTemplateContent(template.content, content); + if (variables === null) { + throw new BadRequestException('短信内容与选定的审核模板不匹配'); + } + const drainage = await this.resolveDrainageInfoMatch(template.signatureId, content); + return { + signatureId: template.signatureId, + drainageInfoId: drainage?.id, + variables, + rejectionReason: drainageRejectionReason(drainage), + }; + } + + if (!applicationId) { + throw new BadRequestException('自由内容短信必须关联企业应用'); + } + const [application, signature] = await Promise.all([ + this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + select: { tenantId: true, templateMismatchMode: true }, + }), + this.resolveInboundSignatureCandidate(applicationId, content), + ]); + if (!application || application.tenantId !== tenantId) { + throw new BadRequestException('短信应用不存在或不属于当前企业'); + } + if (!signature) { + throw new BadRequestException('短信内容未以当前应用已审核通过的签名开头'); + } + if (application.templateMismatchMode !== 'direct_send') { + throw new BadRequestException('当前应用未允许无模板自由内容直接发送'); + } + const drainage = await this.resolveDrainageInfoMatch(signature.id, content); + return { + signatureId: signature.id, + drainageInfoId: drainage?.id, + variables: undefined, + rejectionReason: drainageRejectionReason(drainage), + }; } - private async resolveDrainageInfoId(signatureId: string | undefined, content: string) { + private async resolveDrainageInfoMatch(signatureId: string | null | undefined, content: string) { if (!signatureId) return undefined; const candidates = await this.prisma.smsDrainageInfo.findMany({ - where: { signatureId, auditStatus: 'approved' }, - select: { id: true, url: true, updatedAt: true }, + where: { signatureId, auditStatus: { not: 'deleted' } }, + select: { id: true, url: true, auditStatus: true, updatedAt: true }, orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }], }); const matches = candidates @@ -2534,7 +2792,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (matches.length === 0) return undefined; const longestLength = matches[0].normalizedUrl.length; const longestMatches = matches.filter((item) => item.normalizedUrl.length === longestLength); - return longestMatches.length === 1 ? longestMatches[0].id : undefined; + if (longestMatches.length !== 1) { + throw new BadRequestException({ + code: 'DRAINAGE_MATCH_AMBIGUOUS', + message: '短信内容同时匹配多条等长引流地址,无法确定报备资料', + drainageInfoIds: longestMatches.map((item) => item.id), + }); + } + const matched = longestMatches[0]; + return { id: matched.id, auditStatus: matched.auditStatus }; } private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) { @@ -3143,19 +3409,39 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { return response.json().catch(() => ({})); } - private async publishGatewaySubmitCommand(command: unknown) { - return this.getRedis().xadd( - process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM, - '*', - 'messageType', - 'SubmitCommand', - 'data', - JSON.stringify(command), + private async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) { + const redis = this.getRedis(); + const stream = process.env.GATEWAY_SUBMIT_STREAM ?? GATEWAY_SUBMIT_STREAM; + const payload = JSON.stringify(command); + if (!idempotencyKey) { + return redis.xadd(stream, '*', 'messageType', 'SubmitCommand', 'data', payload); + } + const result = await redis.eval( + `local existing = redis.call('GET', KEYS[2]) +if existing then return existing end +local streamId = redis.call('XADD', KEYS[1], '*', 'messageType', 'SubmitCommand', 'data', ARGV[1]) +redis.call('SET', KEYS[2], streamId, 'EX', ARGV[2]) +return streamId`, + 2, + stream, + idempotencyKey, + payload, + String(GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS), ); + return typeof result === 'string' ? result : String(result ?? ''); } } +function gatewaySubmitRequeueKey(deadLetterId: string, attempt: number) { + return `gateway:submit:requeue:${deadLetterId}:${attempt}`; +} + +function drainageRejectionReason(drainage?: { id: string; auditStatus: string }) { + if (!drainage || drainage.auditStatus === 'approved') return undefined; + return `短信内容匹配的引流资料 ${drainage.id} 当前为 ${drainage.auditStatus},必须审核通过后才能发送`; +} + function statusFromRisk(status: string, scheduled: boolean) { if (status === 'rejected') { return 'rejected'; diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index d0b3691..cd62ef0 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -1,12 +1,15 @@ import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; +import { ReviewDecisionDto, ReviewGovernanceService } from './review-governance.service'; +import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service'; import { CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service'; @ApiTags('admin-sms-config') @Controller('admin') export class AdminSmsConfigController { - constructor(private readonly smsConfig: SmsConfigService) {} + constructor(private readonly smsConfig: SmsConfigService, private readonly reviewGovernance: ReviewGovernanceService, private readonly deletions: DeletionGovernanceService) {} @Get('enterprise-applications') listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('status') status?: string) { @@ -126,8 +129,8 @@ export class AdminSmsConfigController { @Post('signatures/:id/approve') @RequireRecentAuthentication() - approveSignature(@Param('id') signatureId: string, @Body() body: ReviewDto) { - return this.smsConfig.approveSignature(signatureId, body); + approveSignature(@Param('id') signatureId: string, @Body() body: ReviewDecisionDto, @CurrentSessionUserId() reviewerId?: string) { + return this.reviewGovernance.decide('signature', signatureId, { ...body, decision: 'approve', reviewerId }); } @Post('signatures/:id/reject') @@ -138,8 +141,8 @@ export class AdminSmsConfigController { @Post('templates/:id/approve') @RequireRecentAuthentication() - approveTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) { - return this.smsConfig.approveTemplate(templateId, body); + approveTemplate(@Param('id') templateId: string, @Body() body: ReviewDecisionDto, @CurrentSessionUserId() reviewerId?: string) { + return this.reviewGovernance.decide('template', templateId, { ...body, decision: 'approve', reviewerId }); } @Post('templates/:id/reject') @@ -156,13 +159,15 @@ export class AdminSmsConfigController { @Post('enterprise-signatures/:id/status') @RequireRecentAuthentication() - changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto) { + changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) { + if (body.status === 'deleted') return this.deletions.delete('signature', signatureId, { ...body, operatorId }); return this.smsConfig.changeSignatureStatus(signatureId, body); } @Post('enterprise-templates/:id/status') @RequireRecentAuthentication() - changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto) { + changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @CurrentSessionUserId() operatorId?: string) { + if (body.status === 'deleted') return this.deletions.delete('template', templateId, { ...body, operatorId }); return this.smsConfig.changeTemplateStatus(templateId, body); } } diff --git a/api/src/sms-config/client-sms-config.controller.ts b/api/src/sms-config/client-sms-config.controller.ts index 40be1a9..867d909 100644 --- a/api/src/sms-config/client-sms-config.controller.ts +++ b/api/src/sms-config/client-sms-config.controller.ts @@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TenantId } from '../common/tenant-id.decorator'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; +import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service'; import { CreateSignatureMaterialDto, CreateSmsApplicationDto, @@ -18,7 +20,7 @@ import { @ApiTags('client-sms-config') @Controller('client') export class ClientSmsConfigController { - constructor(private readonly smsConfig: SmsConfigService) {} + constructor(private readonly smsConfig: SmsConfigService, private readonly deletions: DeletionGovernanceService) {} @Get('applications') listApplications(@TenantId() tenantId?: string) { @@ -115,7 +117,8 @@ export class ClientSmsConfigController { } @Post('signatures/:id/status') - async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) { + async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) { + if (body.status === 'deleted') return this.deletions.delete('signature', signatureId, { ...body, operatorId }, tenantId); await this.smsConfig.changeSignatureStatus(signatureId, body, tenantId); return this.smsConfig.getClientSignatureView(signatureId, tenantId); } @@ -132,7 +135,7 @@ export class ClientSmsConfigController { @Put('templates/:id') updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto, @TenantId() tenantId?: string) { - return this.smsConfig.updateTemplate(templateId, body, tenantId); + return this.smsConfig.updateClientTemplate(templateId, body, tenantId); } @Post('templates/:id/submit') @@ -141,7 +144,8 @@ export class ClientSmsConfigController { } @Post('templates/:id/status') - changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) { + changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) { + if (body.status === 'deleted') return this.deletions.delete('template', templateId, { ...body, operatorId }, tenantId); return this.smsConfig.changeTemplateStatus(templateId, body, tenantId); } } diff --git a/api/src/sms-config/review-governance.controller.ts b/api/src/sms-config/review-governance.controller.ts new file mode 100644 index 0000000..3e5c1de --- /dev/null +++ b/api/src/sms-config/review-governance.controller.ts @@ -0,0 +1,27 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; +import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; +import { ReviewDecisionDto, ReviewGovernanceService, ReviewTargetType } from './review-governance.service'; + +@ApiTags('review-governance') +@Controller('admin/reviews') +export class ReviewGovernanceController { + constructor(private readonly reviews: ReviewGovernanceService) {} + + @Get(':type/:id/preflight') + preflight(@Param('type') type: ReviewTargetType, @Param('id') id: string) { + return this.reviews.preflight(type, id); + } + + @Post(':type/:id/decision') + @RequireRecentAuthentication() + decide( + @Param('type') type: ReviewTargetType, + @Param('id') id: string, + @Body() body: ReviewDecisionDto, + @CurrentSessionUserId() reviewerId?: string, + ) { + return this.reviews.decide(type, id, { ...body, reviewerId }); + } +} diff --git a/api/src/sms-config/review-governance.service.spec.ts b/api/src/sms-config/review-governance.service.spec.ts new file mode 100644 index 0000000..cd953fc --- /dev/null +++ b/api/src/sms-config/review-governance.service.spec.ts @@ -0,0 +1,85 @@ +import { BadRequestException, ConflictException } from '@nestjs/common'; +import { ReviewGovernanceService } from './review-governance.service'; + +function signature(overrides: Record = {}) { + return { + id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', purpose: null, + drainageInfo: { signatureProfile: { companyName: '企业A', creditCode: '9133', legalPersonName: '法人', responsibleName: '责任人', responsiblePhone: '13800000000', credentialFile: { fileObjectId: 'file-1' } } }, + auditStatus: 'pending', reportStatus: 'waiting_material', rejectReason: null, materialVersion: 1, + pendingReport: true, reportChangedAt: new Date(), createdAt: new Date(), updatedAt: new Date('2026-07-21T08:00:00.000Z'), + tenant: { id: 'tenant-1', name: '企业A' }, application: { id: 'app-1', name: '应用A' }, materials: [], + ...overrides, + }; +} + +function prismaMock() { + const current = signature(); + const prisma: Record = { + smsSignature: { + findUnique: jest.fn().mockResolvedValue(current), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + smsTemplate: { findUnique: jest.fn(), updateMany: jest.fn() }, + auditRecord: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: 'audit-1', statusAfter: 'approved' }), + }, + }; + prisma.$transaction = jest.fn(async (callback: (tx: typeof prisma) => unknown) => callback(prisma)); + return prisma; +} + +describe('ReviewGovernanceService', () => { + it('blocks approval when required signature qualification is incomplete', async () => { + const prisma = prismaMock(); + prisma.smsSignature.findUnique.mockResolvedValue(signature({ applicationId: null, application: null, drainageInfo: {}, materials: [] })); + const service = new ReviewGovernanceService(prisma as never); + + const result = await service.preflight('signature', 'sig-1'); + + expect(result.allowedActions).toEqual(['reject']); + expect(result.blockedReasons).toEqual(expect.arrayContaining(['未绑定短信应用', '缺少公司名称', '缺少资质文件'])); + }); + + it('atomically approves the expected version and returns an audit operation id', async () => { + const prisma = prismaMock(); + const service = new ReviewGovernanceService(prisma as never); + + await expect(service.decide('signature', 'sig-1', { + decision: 'approve', expectedUpdatedAt: '2026-07-21T08:00:00.000Z', idempotencyKey: 'review:sig-1:key-1', reviewerId: 'admin-1', + })).resolves.toEqual(expect.objectContaining({ operationId: 'audit-1', replayed: false, status: 'approved' })); + expect(prisma.smsSignature.updateMany).toHaveBeenCalledWith({ + where: { id: 'sig-1', auditStatus: 'pending', updatedAt: new Date('2026-07-21T08:00:00.000Z') }, + data: { auditStatus: 'approved', rejectReason: null }, + }); + expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ reviewerId: 'admin-1', action: 'approve', statusBefore: 'pending', statusAfter: 'approved' }) }); + }); + + it('rejects a concurrent stale decision without overwriting the winner', async () => { + const prisma = prismaMock(); + prisma.smsSignature.updateMany.mockResolvedValue({ count: 0 }); + const service = new ReviewGovernanceService(prisma as never); + + await expect(service.decide('signature', 'sig-1', { + decision: 'approve', expectedUpdatedAt: '2026-07-21T08:00:00.000Z', idempotencyKey: 'review:sig-1:key-2', reviewerId: 'admin-1', + })).rejects.toBeInstanceOf(ConflictException); + expect(prisma.auditRecord.create).not.toHaveBeenCalled(); + }); + + it('replays a completed idempotency key without a second status update', async () => { + const prisma = prismaMock(); + prisma.auditRecord.findFirst.mockResolvedValue({ id: 'audit-existing', action: 'approve', statusAfter: 'approved' }); + const service = new ReviewGovernanceService(prisma as never); + + await expect(service.decide('signature', 'sig-1', { + decision: 'approve', expectedUpdatedAt: '2026-07-21T08:00:00.000Z', idempotencyKey: 'review:sig-1:same', reviewerId: 'admin-1', + })).resolves.toEqual(expect.objectContaining({ operationId: 'audit-existing', replayed: true })); + expect(prisma.smsSignature.updateMany).not.toHaveBeenCalled(); + }); + + it('requires a valid server session reviewer and rejects malformed idempotency keys', async () => { + const service = new ReviewGovernanceService(prismaMock() as never); + await expect(service.decide('signature', 'sig-1', { decision: 'approve', expectedUpdatedAt: new Date().toISOString(), idempotencyKey: '../bad' })) + .rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/api/src/sms-config/review-governance.service.ts b/api/src/sms-config/review-governance.service.ts new file mode 100644 index 0000000..15a25c4 --- /dev/null +++ b/api/src/sms-config/review-governance.service.ts @@ -0,0 +1,158 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma, type SmsSignature, type SmsTemplate } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +export type ReviewTargetType = 'signature' | 'template'; +export type ReviewDecision = 'approve' | 'reject'; + +export interface ReviewDecisionDto { + decision: ReviewDecision; + expectedUpdatedAt: string; + idempotencyKey: string; + reason?: string; + reviewerId?: string; +} + +@Injectable() +export class ReviewGovernanceService { + constructor(private readonly prisma: PrismaService) {} + + async preflight(type: ReviewTargetType, id: string) { + if (type === 'signature') return this.signaturePreflight(id); + if (type === 'template') return this.templatePreflight(id); + throw new BadRequestException('Unsupported review target'); + } + + async decide(type: ReviewTargetType, id: string, data: ReviewDecisionDto) { + const key = normalizeIdempotencyKey(data.idempotencyKey); + const reason = data.reason?.trim(); + if (!data.reviewerId) throw new BadRequestException('Reviewer session is required'); + if (data.decision === 'reject' && !reason) throw new BadRequestException('驳回时必须填写原因'); + const marker = `[idempotency:${key}]`; + const targetType = type === 'signature' ? 'sms_signature' : 'sms_template'; + const replay = await this.prisma.auditRecord.findFirst({ + where: { targetType, targetId: id, reason: { startsWith: marker } }, + orderBy: { createdAt: 'desc' }, + }); + if (replay) { + if (replay.action !== data.decision) { + throw new ConflictException({ code: 'IDEMPOTENCY_KEY_REUSED', message: '该幂等键已用于不同审核决定' }); + } + return { + operationId: replay.id, + replayed: true, + decision: replay.action as ReviewDecision, + status: replay.statusAfter, + item: await this.readTarget(type, id), + }; + } + + const preflight = await this.preflight(type, id); + if (!preflight.allowedActions.includes(data.decision)) { + throw new BadRequestException({ code: 'REVIEW_NOT_ELIGIBLE', message: preflight.blockedReasons.join(';') || '当前对象不可执行该审核动作', preflight }); + } + const expectedUpdatedAt = new Date(data.expectedUpdatedAt); + if (Number.isNaN(expectedUpdatedAt.getTime())) throw new BadRequestException('Invalid expectedUpdatedAt'); + const statusAfter = data.decision === 'approve' ? 'approved' : 'rejected'; + const auditReason = `${marker}${reason ? ` ${reason}` : ' 审核资料及影响摘要已确认'}`; + + return this.prisma.$transaction(async (tx) => { + const model = type === 'signature' ? tx.smsSignature : tx.smsTemplate; + const changed = await (model.updateMany as unknown as (args: unknown) => Promise<{ count: number }>)({ + where: { id, auditStatus: 'pending', updatedAt: expectedUpdatedAt }, + data: { auditStatus: statusAfter, rejectReason: data.decision === 'reject' ? reason : null }, + }); + if (changed.count !== 1) { + throw new ConflictException({ code: 'REVIEW_VERSION_CONFLICT', message: '审核对象已被其他操作更新,请刷新后重试' }); + } + const audit = await tx.auditRecord.create({ + data: { + tenantId: preflight.tenantId, + targetType, + targetId: id, + action: data.decision, + statusBefore: preflight.status, + statusAfter, + reason: auditReason, + reviewerId: data.reviewerId, + }, + }); + const item = type === 'signature' + ? await tx.smsSignature.findUnique({ where: { id }, include: { tenant: true, application: true, materials: true } }) + : await tx.smsTemplate.findUnique({ where: { id }, include: { tenant: true, application: true, signature: true } }); + return { operationId: audit.id, replayed: false, decision: data.decision, status: statusAfter, item }; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); + } + + private async signaturePreflight(id: string) { + const item = await this.prisma.smsSignature.findUnique({ + where: { id }, include: { tenant: true, application: true, materials: true }, + }); + if (!item) throw new NotFoundException('Signature not found'); + const payload = asRecord(item.drainageInfo); + const profile = asRecord(payload.signatureProfile); + const missing: string[] = []; + if (!item.applicationId) missing.push('未绑定短信应用'); + if (!String(profile.companyName ?? '').trim()) missing.push('缺少公司名称'); + if (!String(profile.creditCode ?? '').trim()) missing.push('缺少统一社会信用代码'); + if (!String(profile.legalPersonName ?? '').trim()) missing.push('缺少法人姓名'); + if (!String(profile.responsibleName ?? '').trim()) missing.push('缺少责任人姓名'); + if (!String(profile.responsiblePhone ?? '').trim()) missing.push('缺少责任人手机号'); + const profileHasFile = Object.values(profile).some((value) => Boolean(asRecord(value).fileObjectId)); + if (!profileHasFile && item.materials.length === 0) missing.push('缺少资质文件'); + return reviewPreflight('signature', item, { + identity: { name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定应用' }, + blockedReasons: missing, + impacts: ['通过后签名将进入报备资格链路', '已绑定模板和后续发送资格可能受此决定影响'], + materialSummary: { qualificationFiles: item.materials.length + (profileHasFile ? 1 : 0), missingCount: missing.length }, + }); + } + + private async templatePreflight(id: string) { + const item = await this.prisma.smsTemplate.findUnique({ + where: { id }, include: { tenant: true, application: true, signature: true }, + }); + if (!item) throw new NotFoundException('Template not found'); + const missing: string[] = []; + if (!item.content.trim()) missing.push('模板内容为空'); + if (!item.signatureId) missing.push('未绑定短信签名'); + else if (item.signature?.auditStatus !== 'approved') missing.push('绑定签名尚未审核通过'); + return reviewPreflight('template', item, { + identity: { name: item.name, id: item.id, tenant: item.tenant.name, application: item.application.name }, + blockedReasons: missing, + impacts: ['通过后模板将进入客户端可发送资源候选', '实际发送仍需通过应用、签名、路由和余额校验'], + materialSummary: { contentLength: item.content.length, signature: item.signature?.name ?? '未绑定' }, + }); + } + + private readTarget(type: ReviewTargetType, id: string): Promise { + return type === 'signature' ? this.prisma.smsSignature.findUnique({ where: { id } }) : this.prisma.smsTemplate.findUnique({ where: { id } }); + } +} + +function reviewPreflight(type: ReviewTargetType, item: SmsSignature | SmsTemplate, detail: { identity: Record; blockedReasons: string[]; impacts: string[]; materialSummary: Record }) { + const statusBlocked = item.auditStatus !== 'pending' ? [`当前状态为${item.auditStatus},仅待审核对象可决策`] : []; + const blockedReasons = [...statusBlocked, ...detail.blockedReasons]; + return { + type, + id: item.id, + tenantId: item.tenantId, + status: item.auditStatus, + expectedUpdatedAt: item.updatedAt.toISOString(), + identity: detail.identity, + impacts: detail.impacts, + materialSummary: detail.materialSummary, + blockedReasons, + allowedActions: item.auditStatus === 'pending' ? (detail.blockedReasons.length ? ['reject'] : ['approve', 'reject']) : [], + }; +} + +function normalizeIdempotencyKey(value: string) { + const key = value?.trim(); + if (!key || key.length > 100 || !/^[a-zA-Z0-9:_-]+$/.test(key)) throw new BadRequestException('Invalid idempotencyKey'); + return key; +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} diff --git a/api/src/sms-config/sms-config.module.ts b/api/src/sms-config/sms-config.module.ts index bd6d349..6daadb2 100644 --- a/api/src/sms-config/sms-config.module.ts +++ b/api/src/sms-config/sms-config.module.ts @@ -2,10 +2,14 @@ import { Module } from '@nestjs/common'; import { AdminSmsConfigController } from './admin-sms-config.controller'; import { ClientSmsConfigController } from './client-sms-config.controller'; import { SmsConfigService } from './sms-config.service'; +import { ReviewGovernanceController } from './review-governance.controller'; +import { ReviewGovernanceService } from './review-governance.service'; +import { DeletionGovernanceModule } from '../deletion-governance/deletion-governance.module'; @Module({ - controllers: [ClientSmsConfigController, AdminSmsConfigController], - providers: [SmsConfigService], + imports: [DeletionGovernanceModule], + controllers: [ClientSmsConfigController, AdminSmsConfigController, ReviewGovernanceController], + providers: [SmsConfigService, ReviewGovernanceService], exports: [SmsConfigService], }) export class SmsConfigModule {} diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 2572229..983440e 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -543,7 +543,16 @@ describe('SmsConfigService', () => { }), }); expect(prisma.cmppDownstreamConnection.deleteMany).toHaveBeenCalledWith({ - where: { status: 'connected', lastHeartbeatAt: { lt: new Date('2026-07-11T10:58:30.000Z') } }, + where: { + status: 'connected', + OR: [ + { lastHeartbeatAt: { lt: new Date('2026-07-11T10:58:30.000Z') } }, + { + lastHeartbeatAt: null, + connectedAt: { lt: new Date('2026-07-11T10:58:30.000Z') }, + }, + ], + }, }); expect(prisma.operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ @@ -554,6 +563,27 @@ describe('SmsConfigService', () => { }); }); + it('prunes expired downstream connections whose heartbeat was never recorded', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + const now = new Date('2026-07-21T04:00:00.000Z'); + + await service.markTimedOutDownstreamConnections(now); + + expect(prisma.cmppDownstreamConnection.deleteMany).toHaveBeenCalledWith({ + where: { + status: 'connected', + OR: [ + { lastHeartbeatAt: { lt: new Date('2026-07-21T03:58:30.000Z') } }, + { + lastHeartbeatAt: null, + connectedAt: { lt: new Date('2026-07-21T03:58:30.000Z') }, + }, + ], + }, + }); + }); + it('rejects connection heartbeats after an IP allowlist change or connection-limit reduction', async () => { const prisma = createPrismaMock(); prisma.smsApplication.findUnique.mockResolvedValue({ @@ -866,6 +896,41 @@ describe('SmsConfigService', () => { expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled(); }); + it('resets an approved signature to pending when key content is changed', async () => { + const prisma = createPrismaMock(); + prisma.smsSignature.findUnique.mockResolvedValue({ + id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '【旧签名】', + purpose: '通知', drainageInfo: {}, auditStatus: 'approved', + }); + const service = new SmsConfigService(prisma as never); + + await service.updateSignature('sig-1', { name: '【新签名】' }); + + expect(prisma.smsSignature.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ auditStatus: 'pending', rejectReason: null }), + })); + }); + + it('resets an approved template to pending when key content is changed', async () => { + const prisma = createPrismaMock(); + prisma.smsTemplate.findUnique.mockResolvedValue({ + id: 'tpl-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', + name: '模板A', content: '【签名A】验证码${code}', category: '验证码', auditStatus: 'approved', + }); + const tx = { + templateVariable: { deleteMany: jest.fn().mockResolvedValue({ count: 1 }) }, + smsTemplate: { update: jest.fn().mockResolvedValue({ id: 'tpl-1', auditStatus: 'pending' }) }, + }; + prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx)); + const service = new SmsConfigService(prisma as never); + + await service.updateTemplate('tpl-1', { content: '【签名A】您的验证码为${code}' }); + + expect(tx.smsTemplate.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ auditStatus: 'pending' }), + })); + }); + it('creates real drainage materials and channel tasks after operations approval', async () => { const prisma = createPrismaMock(); const pendingItem = { id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', reportValues: { site_owner: '企业A' }, auditStatus: 'pending' }; diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index d63e2fd..9c63175 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -729,7 +729,13 @@ export class SmsConfigService { const timeoutMs = getPositiveIntegerEnv('CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS', DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS); const cutoff = new Date(now.getTime() - timeoutMs); return this.prisma.cmppDownstreamConnection.deleteMany({ - where: { status: 'connected', lastHeartbeatAt: { lt: cutoff } }, + where: { + status: 'connected', + OR: [ + { lastHeartbeatAt: { lt: cutoff } }, + { lastHeartbeatAt: null, connectedAt: { lt: cutoff } }, + ], + }, }); } @@ -1005,14 +1011,19 @@ export class SmsConfigService { const drainageInfo = data.drainageInfo ? await this.withReportRequirementSnapshot(applicationId, data.drainageInfo) : undefined; + const materialChanged = (data.applicationId !== undefined && data.applicationId !== signature.applicationId) + || (data.name !== undefined && normalizeSmsSignature(data.name) !== normalizeSmsSignature(signature.name)) + || (data.purpose !== undefined && data.purpose !== signature.purpose) + || (data.drainageInfo !== undefined && JSON.stringify(data.drainageInfo) !== JSON.stringify(signature.drainageInfo ?? null)); + const auditStatus = materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus; const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { applicationId: data.applicationId, name: data.name, purpose: data.purpose, - auditStatus: data.auditStatus, - rejectReason: data.auditStatus === 'pending' ? null : undefined, + auditStatus, + rejectReason: auditStatus === 'pending' ? null : undefined, drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, materialVersion: { increment: 1 }, pendingReport: true, @@ -1376,6 +1387,12 @@ export class SmsConfigService { const variables = data.content !== undefined || data.variables !== undefined ? validateAndNormalizeTemplateVariables(data.content ?? template.content, data.variables) : undefined; + const materialChanged = (data.applicationId !== undefined && data.applicationId !== template.applicationId) + || (data.signatureId !== undefined && data.signatureId !== template.signatureId) + || (data.content !== undefined && data.content !== template.content) + || (data.category !== undefined && data.category !== template.category) + || data.variables !== undefined; + const auditStatus = materialChanged && template.auditStatus === 'approved' ? 'pending' : data.auditStatus; return this.prisma.$transaction(async (tx) => { if (variables) { await tx.templateVariable.deleteMany({ where: { templateId } }); @@ -1388,7 +1405,8 @@ export class SmsConfigService { name: data.name, content: data.content, category: data.category, - auditStatus: data.auditStatus, + auditStatus, + rejectReason: auditStatus === 'pending' ? null : undefined, billingUnits: data.content ? estimateBillingUnits(data.content) : undefined, variables: variables ? { create: variables.map((variable) => ({ @@ -1403,6 +1421,24 @@ export class SmsConfigService { }); } + async updateClientTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) { + const current = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); + if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Template not found'); + if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) { + throw new BadRequestException('当前审核状态不允许修改模板'); + } + const updated = await this.updateTemplate(templateId, { ...data, auditStatus: 'pending' }, tenantId); + await this.createAuditRecord({ + tenantId: current.tenantId, + targetType: 'sms_template', + targetId: templateId, + action: 'client_update_submit', + statusBefore: current.auditStatus, + statusAfter: 'pending', + }); + return updated; + } + async submitTemplate(templateId: string, tenantId?: string) { const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); if (!template || (tenantId && template.tenantId !== tenantId)) { diff --git a/docs/20260720-receipt-report-migration-notes.md b/docs/20260720-receipt-report-migration-notes.md index e315207..09fd985 100644 --- a/docs/20260720-receipt-report-migration-notes.md +++ b/docs/20260720-receipt-report-migration-notes.md @@ -6,7 +6,11 @@ 2. 依次应用 `20260720110000_add_receipt_identity`、`20260720113000_add_report_business_metrics` 与 `20260720114500_add_receipt_phone_number`。三者为历史回执生成不冲突的 `receiptKey`,增加回执文本、目的号码和匹配索引,并增加报表失败数及利润退款金额列。历史目的号码先从原关联主记录回填;对已知错绑记录仍须依据生产提交记录、通道和原始 Gateway 日志专项复核,不能仅靠该回填自动改绑。 3. 先重启 Gateway,再重启 API;检查 Redis Stream、活动通道、TPS key、API/Gateway health 和近期错误日志。 4. 对 T-4 至 T-1 及需修复的历史日期重复执行报表重算,核对查询与 CSV 的发送、成功、失败、收入、退款、成本、利润和到达时长。 +5. 追加应用 `20260721150000_backfill_misattributed_delivery_receipts`。该迁移只处理“回执已关联主记录,且主记录 + 上游 Msg_Id + 目的号码只能命中一条提交记录”的成功回执:先把历史错误通道改为真实提交通道,再将成功状态聚合到主记录。零匹配或多匹配记录保持不变。迁移可重复执行且不会创建回执、计费或客户下游投递。 +6. 新迁移完成并启动 API 后,确认启动时 T-4 至 T-1 重算覆盖受影响日期;若发布日期已使目标历史日超出滚动窗口,必须在受控维护命令中显式重算对应日期,不能只修改报表表格或手工填写成功数。 ## 回滚 应用代码可回滚到上一版本,但新增列和索引默认保留,避免丢失已接收的回执身份、错误文本和重算结果。若确认不存在新版本写入且必须做结构回滚,应先备份,再依次删除两个报表新增列、回执新增列及索引;删除 `receiptKey` 唯一约束前必须确认旧代码不会再次以模糊条件消费回执。生产禁止未经审批直接执行破坏性回滚。 + +`20260721150000_backfill_misattributed_delivery_receipts` 是数据纠正迁移,没有安全的自动逆向迁移。回滚应用代码时保留已纠正的回执和主记录;若业务要求恢复迁移前值,只能从发布前 PostgreSQL 备份按明确记录 ID 定向恢复,禁止用账号或日期范围批量反向覆盖。 diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index c9bcd9b..9180c2b 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -280,10 +280,12 @@ - 已实现上游连接断开时的 pending submit 状态补偿第一版:如果某条上游 CMPP 连接在收到 submit resp 前断开,Gateway 会立即唤醒该连接上等待中的 pending submit,请求返回 `timeout/CONNECTION_LOST`,由 NestJS 进入既有补发或释放冻结逻辑,不再只依赖固定超时。 - 已实现“上游可能已受理但 submit resp 丢失”场景的保守补偿第一版:Gateway 在 receipt 事件中补充手机号;NestJS 对无法按 `messageId/gatewayMessageId` 精确命中的回执,只在“同通道、同手机号、72 小时窗口内、且仅存在 1 条 `timeout + gatewayMessageId=null` 的 submit 记录”时才回填并接收该回执,避免误绑到其他短信。 - 已实现 Gateway 提交异常治理:Go Gateway 对多次处理仍失败的 `SubmitCommand` 不再无限滞留在 PEL,而是按阈值写入 NestJS 真实 `GatewaySubmitDeadLetter` 表(数据库表名和内部接口保留技术兼容名,页面统一称“Gateway提交异常”)。运营端 `/admin/gateway-submit-exceptions` 提供真实分页、筛选、汇总、脱敏详情和单条重新入队;原始 payload、密码、密钥不得返回浏览器。重新入队必须要求近期认证、填写原因、勾选“已确认上游未受理”,并校验短信尚未 accepted/submitted/delivered/unknown、通道 active 且 connected、人工次数小于 3;服务端以 pending 到 requeueing 的原子状态抢占防止重复点击,成功写回 Redis Stream 后记录操作人、原因、Stream ID 和时间。收到后续 SubmitResult 时必须将对应异常记录闭环为 resolved。 +- Gateway 提交异常重新入队必须使用“异常记录 ID + 下一次人工次数”的稳定幂等键,通过 Redis Lua 原子完成“检查幂等键、XADD、保存 Stream ID”;API 在 XADD 成功后宕机或数据库落账失败时,超时恢复扫描必须复用同一幂等键完成落账,不能再次产生 Stream 消息。Gateway 重复上报同一个原始 Stream 异常不得把 `requeued/resolved` 回退成 `pending`。 - 已实现 Gateway 通道级 Redis 限速:NestJS 入队前保留业务层通道限速,Go Gateway 在真正调用上游 Submit 前再次按通道 ID 预约发送时隙;连接命令把权威 TPS 写入 Redis,提交按权威值与消息值的较小者执行。普通 Stream 消息在等待期间不 ACK、不转失败,多实例共同使用同一限速状态;worker 对同批消息并发调度,低 TPS 通道等待不阻塞其他通道。 - 已实现 Gateway 重启后的 active 上游通道恢复:部署先重启 Gateway 再重启 API;API 启动后从 PostgreSQL 读取 active 通道,重新下发连接命令,恢复 Gateway 内存连接池、真实连接状态和 Redis 权威 TPS key,不得继续沿用重启前的 connected 状态。 - 已实现客户侧下游投递重试第二版:客户系统负责断线后重连;平台在客户离线或投递失败时把 Deliver Receipt/上行 Deliver 保留在 `CmppDownstreamDelivery`,客户 bind 成功后立即拉取 pending,且 Gateway 会对当前在线账号周期补投;超过重试上限后转 `failed` 并写失败审计。 - 已实现下游投递失败审计与人工重投第一版:运营端后端与页面可分页查看 `CmppDownstreamDelivery` 的 pending/awaiting_ack/failed/unconfirmed/rejected/delivered 记录,支持按状态、类型、应用和关键字筛选,并可对非 `awaiting_ack` 记录执行人工重投,真实调用 Gateway `/downstream/receipt` 或 `/downstream/uplink`。主记录必须分开保存自动重试次数 `retryCount`、人工重投次数 `manualRetryCount` 和最近人工重投时间 `lastRetriedAt`,操作日志保留重投前状态与自动重试次数。 +- 同一下游投递的人工重投必须使用 `id + status + updatedAt` 条件更新原子认领;认领后先进入 `manual_requeueing`,避免运营并发请求或 Gateway pending 恢复扫描同时双发。调用完成后进入 `awaiting_ack` 或失败状态;进程中断超过默认 2 分钟后自动转回 `pending`,由 Gateway 单一路径恢复投递。阈值可通过 `CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS` 调整。 - 已实现下游投递批量重投第一版:运营端可在当前页勾选多条 `pending/failed` 下游投递记录,调用真实批量接口逐条重投并返回成功/失败汇总,不允许用前端循环假装成功。 - 下游投递的 `pending` 展示必须结合真实尝试字段:自动与人工次数均为 0 时显示“待首次投递”,`retryCount > 0` 时显示“等待自动重试”,`manualRetryCount > 0` 时显示“人工重投排队中”。人工重投可重置新一轮自动重试预算,但不得把记录伪装成从未投递。 - 下游投递不得无限停留在 `pending`:Gateway 对未写出的结果必须返回明确的 `retryable/reasonCode/errorMessage`。状态回执在原消息映射已经丢失且缺少 `submitSequenceId` 时属于不可恢复错误,立即转为 `failed` 并保留失败原因和操作日志;客户端暂时离线属于可恢复错误,按既有重试次数与指数退避处理。所有 pending 从创建时间或最近人工重投时间起最多保留 72 小时,可通过 `CMPP_DOWNSTREAM_PENDING_TIMEOUT_HOURS` 调整,超时后自动终结为 `failed`。 @@ -1096,6 +1098,8 @@ ### 14.2 发送能力 - 需要支持定时发送。 +- API 实例启动后必须自动扫描并派发到期任务,管理端手工触发仅作为运维补偿入口,不能作为正常发送的前置操作。 +- 多 API 实例只能有一个实例成功认领同一定时任务;认领后进程退出或 Redis 短暂不可用时,任务超时后必须可恢复,且不得重复冻结余额或重复创建队列作业。0 元短信同样适用恢复和幂等要求。 - 导入号码文件格式支持 CSV、TXT。 - 导入文件最大 20 MB。 - 单任务最大号码数默认 100 万条。 @@ -1563,6 +1567,7 @@ 5. HTTP 单发公开契约使用 `mobile`、`content` 和可选 `clientMessageId`,不要求内部签名或模板 ID;服务端按 CMPP 同一规则识别已审核签名、模板及变量,复用风控、余额、计费、路由和队列。成功返回可查询 messageId,业务拒绝返回对应 4xx,不得在已创建记录后返回“批次不存在”。 6. 客户发送候选只返回 approved 签名和模板,管理视图可查看历史状态。模板变量必须拒绝空变量、未闭合、中文或非法名称、重复名称及超长名称;客户端签名视图仅返回必要报备汇总状态。 7. 报备资料提供官方 XLSX 模板和按当前筛选导出;导入拒绝空文件、错误扩展名、超限文件以及公式/脚本单元格。分析、提交和导出日志记录操作人、文件名、筛选条件、成功数、失败数和 IP,不保存密钥或完整请求体。 +8. 下游客户连接超时清理必须同时覆盖“最后心跳早于阈值”和“最后心跳为空但连接建立时间早于阈值”;刚建立且尚未超过阈值的空心跳连接不得误删。历史共享供应商账号导致的错归回执,只能在同一主记录、同一通道消息号、同一目的号码且唯一提交记录可证明时改绑并聚合;存在歧义时必须保留原数据供人工核查。 ## 2026-07-20 客户端用户管理移动操作可达性要求 @@ -1570,3 +1575,58 @@ 2. 390×844和375×667下四项操作必须全部可见、可聚焦、可命中,触控热区高度至少44px;操作组应具有包含目标用户名称的可访问名称。 3. 删除仍必须走真实客户端用户API与确认流程,不得通过前端隐藏或静态数据冒充;页面验收只打开并取消确认时,不得产生DELETE请求或数据库状态变化。 4. 1440×900、1366×768和768×1024必须同步回归。1366桌面宽表若仍需内部横向滚动,滚动条必须可发现且操作可到达;固定操作列和邮箱列宽另按全站Table整改治理。 + +## 2026-07-21 双门户会话隔离与深链恢复要求 + +1. 运营端和客户端必须分别使用独立的浏览器存储键、跨标签广播频道和 HttpOnly Cookie;后端只允许目标门户的 Cookie 认证对应路由,不能依赖前端隐藏或跳转实现隔离。 +2. 受保护路由必须先向真实会话接口完成初始化,再决定渲染或跳转;刷新和直接打开深链时不得先跳登录页,失效后重新登录必须回到同站点、同门户白名单内的原目标。 +3. 同一浏览器可以同时保持运营端和客户端登录。锁定、解锁、会话过期和主动退出事件仅作用于当前门户;退出一端不得删除、广播或撤销另一端会话。 +4. 会话锁定必须保留目标页面,显示锁定原因、恢复说明和剩余策略;锁定状态下暂停首次业务路由请求,解锁后在原 URL 重新挂载并从真实 API 加载数据。当前标签页内已加载的非敏感草稿不得因跨门户事件被清除。 +5. 旧共享 Cookie 和旧共享 localStorage 只允许做一次同门户迁移或清理,不得继续作为双门户认证来源。生产发布会使旧共享 Cookie 失效时,必须在发布说明中明确需要重新登录。 + +## 2026-07-21 UI/UX A2安全上传与日志导出补充 + +- 客户端文件上传和下载必须使用`/api/client/files`专用接口;后端从当前会话用户反查企业,不接受请求头指定文件归属。客户端仅允许企业认证、签名报备、引流报备三类用途及对应安全目录,跨企业文件统一不可见。 +- 运营端和客户端系统日志导出必须读取PostgreSQL真实筛选结果,具备提交中防重复、结构化成功结果、操作单号、文件下载、失败原地重试和会话恢复后的筛选保留。客户端导出不得包含详情JSON、IP、User-Agent等内部字段。 +- CSV导出最多10000条并明确截断状态;对`= + - @`开头单元格做公式注入防护。瞬时恢复状态可使用按门户隔离的`sessionStorage`,不得作为业务数据源。 + +## 2026-07-21 UI/UX A3审核风险治理补充 + +- 运营端签名、模板“通过”必须先调用后端资格预检,确认层展示对象名称、唯一ID、企业、应用、资料完整度、阻断原因和后续影响;阻断项存在时后端和前端均不得批准。 +- 审核决定必须使用当前登录会话审核人、客户端生成并重试复用的幂等键,以及对象`updatedAt`状态版本。后端仅允许`pending`对象在Serializable事务中原子变更,版本不一致返回409且不得覆盖其他审核员结果。 +- 审核成功必须返回持久化`AuditRecord.id`作为操作单号;同对象同幂等键重试返回原结果且不得重复更新或重复审计。旧的签名/模板批准接口也必须进入同一治理服务,不得保留绕过入口。 + +## 2026-07-21 UI/UX A4报备生成风险治理补充 + +- 待报备资料必须由后端预检应用启用状态、资料审核与待报备状态、资料版本、启用路由/通道、通道字段配置和必填资料;不合格资料在列表中不可选择,并返回可执行的阻断原因。 +- 生成接口提交时必须重新预检,不能信任列表时的前端状态。零个可生成通道组合必须返回业务4xx,不得先创建空批次或显示成功。 +- 业务去重键必须覆盖资料类型、资料ID、资料版本、应用、通道及适用运营商;历史已生成组合返回跳过及既有批次,不得无提示覆盖旧任务或重复导出。 +- 每次生成要求8至128位客户端幂等键。后端通过PostgreSQL事务锁认领操作,相同键同一请求返回原操作单及`replayed=true`,相同键用于不同范围返回409;生成结果按成功、跳过、失败分项返回并提供操作单号。 + +## 2026-07-21 UI/UX A5删除治理补充 + +- 通道、签名和模板删除前必须由后端返回对象身份、活动依赖数量与对象摘要、影响范围、`allowedActions`、`blockedReasons`、状态版本和可恢复说明;前端不得自行推断或只显示通用风险文案。 +- 删除提交必须包含预检版本、8位以上幂等键和至少4字符原因。后端在Serializable事务中重新以`updatedAt`和未删除状态做条件更新,并写入包含原因、依赖、影响及幂等键的`OperationLog`,返回操作单号和重放标识。 +- 客户端只能预检和删除当前会话企业的签名/模板,不得删除运营通道;通道被活动通道组/路由/连接/未结束报备引用,签名被模板/引流/未结束报备引用,模板被未结束发送/批量任务引用时,后端必须阻断。 +- 删除采用逻辑删除,历史发送、回执、计费、审核和审计数据继续保留;恢复需有审计依据。所有旧删除入口必须委托同一治理服务,禁止保留绕过路径。 + +## 2026-07-21 UI/UX A6人工充值治理补充 + +- 充值记录页和企业管理页必须共用同一人工充值组件。取消、右上角关闭或完成后必须销毁未提交金额、备注、预检结果和幂等键;重新打开必须是新草稿,不得自动恢复资金操作输入。 +- 最终入账前必须调用真实后端预检并重复展示企业名称、编码、唯一ID、操作方向、当前现金余额、本次变动、预计现金余额和备注;余额以PostgreSQL账户读取结果为准,不能用前端静态计算冒充资格检查。 +- 人工充值请求必须使用当前会话操作者、8至128位幂等键和账户`updatedAt`版本。相同幂等键同一请求返回原订单/操作单和`replayed=true`;不同范围复用键或账户版本变化必须返回冲突并要求重新核对。 +- RechargeOrder、TenantAccount余额增量、AccountTransaction和OperationLog必须在同一Serializable事务内原子完成;审计记录需包含前余额、变动金额、后余额、订单号、原因和幂等键。正数为充值,负数为冲正,金额精确到小数点后4位且不得为0。 + +## 2026-07-22 UI/UX A7公共Dialog契约 + +- 所有公共Dialog打开后必须把焦点送入弹窗,并将Tab/Shift+Tab约束在当前顶层弹窗;背景内容必须同时不可聚焦、不可被辅助技术读取,页面滚动必须锁定。 +- Dialog必须通过`aria-labelledby`关联可见标题;遮罩不得伪装成可聚焦关闭按钮。右上角、取消、Escape和遮罩关闭必须使用同一关闭协议,关闭后焦点返回触发控件。 +- 可编辑弹窗必须声明dirty状态。存在未保存内容时,任何关闭入口都必须先显示具名确认层;继续编辑保留草稿并恢复原焦点,只有明确放弃后才能销毁草稿。 +- 确认层作为顶层`alertdialog`管理焦点并隔离父弹窗;手机端按钮应安全堆叠,320—1440px内不得产生页面级横向溢出。 + +## 2026-07-22 UI/UX A2/A3收口补充 + +- 客户端上传必须由可见页面操作触发客户端专用接口;租户只能从当前会话用户解析,上传用途、对象目录和租户内下载均由后端校验,真实对象必须写入配置的MinIO并可回读。 +- 企业认证上传区在手机端必须完整显示长文件名;步骤条允许安全横向浏览且默认展示第一步,省市选择不得因固定宽度被裁切。 +- 客户端日志导出必须显示提交中、完成数量、操作单号、下载和失败重试;客户端CSV仅允许时间、级别、模块、操作人、动作、资源ID六列,不得包含详情、IP或嵌套内部字段。 +- 签名和模板审核通过/驳回必须共用资格预检、状态版本、幂等键、事务审计和结构化结果协议。页面必须在最终决定前显示对象唯一标识、资格和影响范围;取消确认不得改变状态或写审计。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index c6db1d2..d1ec8c2 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -1568,6 +1568,32 @@ - 被选候选状态变为 `claimed`,其他 pending 候选变为 `rejected`,认领动作写入操作日志。 - 认领后创建真实 `CmppDownstreamDelivery(deliveryType=uplink)`,并按现有下游投递链路在线推送或离线保留重试。 +### TC-GW-028 下游人工重投并发认领与中断恢复 + +- 优先级:P0 +- 前置条件:真实 PostgreSQL、NestJS API 和 Gateway 控制面可用;准备一条 failed/pending 下游投递记录。 +- 步骤: + 1. 两个运营请求同时重投同一记录,并让 Gateway pending 恢复扫描同时运行。 + 2. 检查数据库状态、人工次数和 Gateway 控制面调用次数。 + 3. 另构造一条停留在 `manual_requeueing` 且超过恢复阈值的记录,运行后台扫描。 +- 预期结果: + - `id + status + updatedAt` 条件更新只允许一个运营请求认领;另一个返回明确冲突。 + - 认领期间状态为 `manual_requeueing`,不进入 Gateway 的 pending 拉取结果,同一轮只调用一次控制面。 + - 人工次数只增加一次;进程中断的陈旧认领自动恢复为 pending,随后由 Gateway 单一路径补投。 + +### TC-GW-029 Gateway提交异常幂等重入队与宕机恢复 + +- 优先级:P0 +- 前置条件:真实 PostgreSQL、Redis Stream 和 NestJS API 可用;存在 pending Gateway 提交异常记录。 +- 步骤: + 1. 人工重新入队,在 Redis XADD 成功后、数据库写回 requeued 前模拟进程退出。 + 2. 等待陈旧 requeueing 恢复扫描,再重复调用相同幂等发布。 + 3. Gateway 再次上报原始 streamMessageId,并在恢复期间回传 SubmitResult。 +- 预期结果: + - 恢复和重复调用返回同一个 Redis Stream ID,Stream 只有一个 SubmitCommand。 + - 数据库最终为 requeued 或被更早 SubmitResult 闭环为 resolved,人工次数只增加一次。 + - 重复异常报告不得把 requeued/resolved 回退为 pending,迟到恢复不得覆盖 resolved。 + ### TC-SEND-021 优先队列插队发送 - 优先级:P0 @@ -2090,6 +2116,32 @@ - 定时任务详情展示计划发送时间、创建时间、创建人、号码总数、预估费用、当前状态。 - 到点执行后的状态变化在客户端和运营端一致。 +### TC-SCHEDULE-007 多实例自动调度和原子认领 + +- 优先级:P0 +- 前置条件:启动两个连接同一 PostgreSQL/Redis 的 API 实例,存在一条已到期 scheduled 任务。 +- 步骤: + 1. 不调用管理端手工派发接口,等待自动扫描周期。 + 2. 两个实例同时扫描同一任务。 + 3. 查询任务、冻结流水、短信记录和 BullMQ 作业。 +- 预期结果: + - 到期任务自动进入 queued/sending。 + - 仅一个实例原子认领成功。 + - 每条短信只存在一个以消息记录 ID 为 jobId 的队列作业,余额只冻结一次。 + +### TC-SCHEDULE-008 调度中断、0 元任务和超时恢复 + +- 优先级:P0 +- 前置条件:存在普通计费任务和客户单价为 0 的免费任务,调度认领超时阈值可缩短用于测试。 +- 步骤: + 1. 分别在冻结后、短信转 queued 后和部分作业入队后模拟进程退出或 Redis 不可用。 + 2. 恢复 API/Redis 并等待认领超时后再次扫描。 + 3. 重复执行恢复扫描。 +- 预期结果: + - 陈旧的 scheduled_dispatching/scheduled_recovering 任务能够被唯一重新认领并完成入队。 + - 已存在冻结流水时不重复冻结;0 元任务入队失败时保留可恢复状态而非错误终结。 + - BullMQ jobId 幂等阻止重复作业,最终任务和消息状态一致。 + ### TC-LOG-001 登录和登出日志 - 优先级:P1 @@ -3553,3 +3605,89 @@ npm run verify:phase8 | TC-UIUX-P0-003 | 在375视口点击删除,读取确认内容后点击取消。 | 确认层显示目标用户名;取消后用户仍在列表,PostgreSQL记录保持active且没有执行删除。 | | TC-UIUX-P0-004 | 依次设置768×1024、1366×768、1440×900并复核同一用户。 | 平板四项操作全部可见且热区≥44px;1440首屏完整;1366即使存在内部横向滚动,滚动后删除必须完整可达,且页面级无横向溢出。 | | TC-UIUX-P0-005 | 完成五视口操作后检查浏览器控制台并执行前端/API构建与用户服务回归。 | 无新增console error/warn;前端与API build通过,用户服务测试通过,`git diff --check`通过。 | + +### 17.17 2026-07-21 下游连接恢复与历史回执回填 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-CMPP-DOWNSTREAM-NULL-001 | 创建一条 `status=connected`、`lastHeartbeatAt=NULL` 且 `connectedAt` 早于心跳阈值的客户接入连接,再触发新连接登记。 | 陈旧记录在连接数校验前删除,新连接不被错误的 `cmppMaxConnections` 拒绝;Gateway 能继续读取 Submit。 | +| TC-CMPP-DOWNSTREAM-NULL-002 | 创建一条刚建立、`lastHeartbeatAt=NULL` 但 `connectedAt` 尚未超过阈值的连接并执行清理。 | 新连接保留,不因首次心跳尚未写入而误删。 | +| TC-RECEIPT-BACKFILL-001 | 构造两个供应商通道共用账号,历史 DELIVRD 被写到错误通道,但同一主记录、Msg_Id、号码只有一条匹配提交记录,执行迁移两次。 | 回执改绑到唯一提交通道;主记录同步为 delivered 并写入回执状态、原始码、通道消息号和到达时间;重复执行结果不变。 | +| TC-RECEIPT-BACKFILL-002 | 构造同一历史回执能匹配零条或多条提交记录的歧义样本。 | 迁移不修改回执和主记录,保留人工核查,不以账号或模糊 Msg_Id 强行归属。 | +| TC-REPORT-BACKFILL-001 | 历史回执迁移后执行 T-4 至 T-1 重算,查询对账、利润、质量及 CSV。 | 历史成功数、收入、成本、利润和平均到达时长反映修复后的主记录与提交/回执;重复重算一致。 | +| TC-DICTIONARY-DUPLICATE-001 | 对活动或逻辑删除的全局/企业黑名单重复创建相同手机号。 | 后端返回 HTTP 409、`BLACKLIST_DUPLICATE` 和 `phoneNumber` 字段提示,不返回 500,也不创建重复数据。 | + +### 17.18 双门户会话隔离、深链与锁定恢复 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-SESSION-DEEPLINK-001 | 未登录直接打开客户端13条受保护路由,完成验证码登录后逐条刷新。 | 登录页说明将恢复目标;登录后返回原深链;13条路由刷新后 URL 和客户端身份不变,数据来自真实 API。 | +| TC-SESSION-PORTAL-001 | 在同一浏览器先后登录运营端与客户端,并分别访问受保护页面。 | 浏览器同时持有独立 admin/client Cookie 和 localStorage;两端显示各自身份,不互相覆盖或错跳门户。 | +| TC-SESSION-LOGOUT-001 | 两端同时登录时退出客户端,再刷新运营端;反向重复。 | 只删除和广播当前门户会话;另一门户会话、页面与 Redis 记录继续有效。 | +| TC-SESSION-LOCK-001 | 锁定运营端会话,同时请求客户端当前会话;刷新锁定页面并输入当前密码解锁。 | 运营端返回 locked、客户端仍 active;锁定页说明解锁后返回当前页;解锁轮换当前门户 Cookie,原 URL 与业务数据恢复。 | +| TC-SESSION-COOKIE-001 | 同时携带 admin/client Cookie 请求两端接口,并仅携带错误门户 Cookie 重试。 | 中间件只读取路径对应 Cookie;错误门户 Cookie 返回401且不会尝试认证或泄露另一门户状态。 | + +## 2026-07-21 UI/UX A2安全上传与日志导出用例 + +- `TC-UIUX-A2-UPLOAD-001`:客户端带伪造`x-tenant-id`上传允许用途文件,数据库`FileObject.tenantId`仍等于当前会话用户企业,MinIO对象可经客户端下载接口读回。 +- `TC-UIUX-A2-UPLOAD-002`:任意用途、目录穿越和跨企业文件下载分别返回4xx,且拒绝发生在对象存储写入前。 +- `TC-UIUX-A2-EXPORT-001`:两端按当前筛选导出真实日志,按钮在请求期间禁用;成功显示记录数、截断提示、操作单号和下载入口,重复点击不产生并发请求。 +- `TC-UIUX-A2-EXPORT-002`:客户端忽略请求体租户,只导出当前企业;CSV表头不包含详情、IP、User-Agent,危险公式前缀被转义。 +- `TC-UIUX-A2-EXPORT-003`:导出失败后页面保留筛选并提供原地重试;会话跳转恢复后可读取当前门户专用瞬时恢复条件,不错跳另一门户。 + +## 2026-07-21 UI/UX A3审核风险治理用例 + +- `TC-UIUX-A3-REVIEW-001`:待审签名缺应用、企业资料或资质文件时,预检返回具体`blockedReasons`,`allowedActions`不含`approve`,直接提交批准同样返回4xx。 +- `TC-UIUX-A3-REVIEW-002`:完整待审签名/模板打开通过确认层,展示名称、ID、企业、应用、资格结果和影响;取消不改变数据库状态。 +- `TC-UIUX-A3-REVIEW-003`:确认后按钮立即进入提交中并禁止重复点击;成功返回审计操作单号,AuditRecord记录当前会话审核人、前后状态和幂等标识。 +- `TC-UIUX-A3-REVIEW-004`:相同幂等键重复请求返回同一操作单号及`replayed=true`,数据库只有一次状态变更;相同键用于不同决定返回409。 +- `TC-UIUX-A3-REVIEW-005`:两个审核员使用同一`expectedUpdatedAt`并发决策,仅一个`updateMany`成功,另一个返回`REVIEW_VERSION_CONFLICT`且不得覆盖赢家。 + +## 2026-07-21 UI/UX A4报备生成风险治理用例 + +- `TC-UIUX-A4-REPORT-001`:已审核资料未绑定应用、应用停用或没有启用路由时调用预检;返回`eligible=false`和具体阻断原因,列表复选框及生成按钮不可用。 +- `TC-UIUX-A4-REPORT-002`:应用路由到启用通道,但通道缺当前资料类型字段或资料缺必填值;预检逐通道返回缺失项,零可生成目标不得创建`ReportMaterialBatch`、任务或文件。 +- `TC-UIUX-A4-REPORT-003`:完整资料打开生成确认层;显示企业、应用、资料版本、预计通道、运营商及成功/跳过计数,取消后数据库无批次、任务和文件变化。 +- `TC-UIUX-A4-REPORT-004`:相同资料版本、应用、通道和运营商已存在成功批次时再次预检;返回既有批次并跳过。资料版本或路由运营商变化后使用新的业务键重新评估。 +- `TC-UIUX-A4-REPORT-005`:相同幂等键并发或重试生成同一范围,仅产生一次批次并返回同一操作单;相同键改换资料范围返回409;按钮在请求中禁止重复提交。 +- `TC-UIUX-A4-REPORT-006`:在1440×900、1366×768、768×1024、390×844和375×667打开确认层;页面无横向溢出,弹窗完整位于视口且成功/跳过/失败、取消和确认操作均可见,控制台无error/warn。 + +## 2026-07-21 UI/UX A5删除治理用例 + +- `TC-UIUX-A5-DELETE-001`:活动通道组引用通道时打开删除确认层;真实预检返回引用数量、组名和优先级,`allowedActions`为空,前后端均禁止删除且通道状态不变。 +- `TC-UIUX-A5-DELETE-002`:签名仍被未删除模板、引流信息或未结束报备任务引用;运营端和客户端均显示租户内依赖摘要并禁止删除,客户端不能读取其他企业对象。 +- `TC-UIUX-A5-DELETE-003`:模板存在未结束发送或批量任务时阻断;无依赖模板填写原因后逻辑删除,返回操作单号,PostgreSQL状态为`deleted`且OperationLog包含原因、依赖、影响和幂等键。 +- `TC-UIUX-A5-DELETE-004`:相同删除幂等键重试返回相同操作单号且不重复审计;旧版本并发提交返回409并要求重新预检;旧删除接口不能绕过治理规则。 +- `TC-UIUX-A5-DELETE-005`:在1440×900、1366×768、768×1024、390×844和375×667打开依赖确认层;对象、依赖、影响和底部操作可滚动到达,无页面级横向溢出,控制台无error/warn。 + +## 2026-07-21 UI/UX A6人工充值治理用例 + +- `TC-UIUX-A6-RECHARGE-001`:两个人工充值入口分别输入金额和备注后点击取消、右上角关闭,再次打开;金额、备注、预检和幂等状态均为空,不产生订单、流水或余额变化。 +- `TC-UIUX-A6-RECHARGE-002`:输入正数或负数金额进入核对;后端返回企业名称/编码/ID、当前余额、方向、变动和预计余额,页面完整展示,返回修改不入账。 +- `TC-UIUX-A6-RECHARGE-003`:最终确认后只生成一个RechargeOrder、一个AccountTransaction和一个OperationLog,账户余额等于前余额加变动金额;响应显示订单号、后余额和操作单号,操作者来自当前会话。 +- `TC-UIUX-A6-RECHARGE-004`:相同幂等键并发或重试同一请求返回相同订单与操作单且`replayed=true`;键用于不同企业/金额返回409。预检后账户版本变化,旧确认请求返回409且不产生部分数据。 +- `TC-UIUX-A6-RECHARGE-005`:在1440×900、1366×768、768×1024、390×844和375×667打开核对层;资金摘要、返回和最终确认可滚动到达,控制台无error/warn。 + +## 2026-07-22 UI/UX A7公共Dialog验收用例 + +| 用例ID | 场景 | 验收标准 | +| --- | --- | --- | +| A7-DIALOG-001 | 打开代表Dialog | 焦点进入弹窗;dialog由可见标题命名;背景具备inert/aria-hidden;body滚动锁定;遮罩不可聚焦 | +| A7-DIALOG-002 | 主Dialog键盘循环 | 最后一个可用控件按Tab回到第一个,首控件按Shift+Tab回到最后一个,焦点不进入背景 | +| A7-DIALOG-003 | dirty表单按Escape/取消/关闭/遮罩 | 四种入口均打开具名alertdialog,不销毁已输入草稿,父Dialog不可交互 | +| A7-DIALOG-004 | dirty确认层键盘与返回 | 确认层Tab循环;按Escape或继续编辑后确认层关闭、草稿保留、焦点返回原字段 | +| A7-DIALOG-005 | 明确放弃 | 两层弹窗关闭、草稿销毁、背景隔离和滚动锁恢复、焦点返回原触发按钮 | +| A7-DIALOG-006 | 两端五视口视觉回归 | 运营与客户端真实登录态页面在1440×900、1366×768、768×1024、390×844、375×667无裁切或横向溢出,控制台无业务error/warn | + +## 2026-07-22 UI/UX A2/A3收口用例 + +| 用例ID | 场景 | 验收标准 | +| --- | --- | --- | +| A23-UPLOAD-001 | 客户端企业认证页选择文件 | 请求进入`/api/client/files/upload`;FileObject租户来自会话;MinIO对象字节数与上传文件一致并可回读 | +| A23-UPLOAD-002 | 伪造租户、非法用途/目录、跨租户下载 | 伪造租户不生效;非法用途或目录在对象存储写入前拒绝;跨租户文件返回404 | +| A23-UPLOAD-003 | 上传成功五视口反馈 | 1440×900、1366×768、768×1024、390×844、375×667均显示完整文件名;页面无横向溢出,步骤一可见,省市选择不裁切 | +| A23-LOG-001 | 客户端真实筛选导出 | 页面显示完成数量、操作单号和下载入口;重复点击期间按钮锁定,失败时原地重试且不跳门户 | +| A23-LOG-002 | 客户端CSV字段安全 | 表头仅六列;任意OperationLog详情、来源IP、供应商或内部字段均不得进入导出内容 | +| A23-REVIEW-001 | 签名/模板点击通过 | 首先显示对象名、唯一ID、企业、应用、资格检查和影响;没有最终确认不得改变pending状态或写AuditRecord | +| A23-REVIEW-002 | 审核并发与幂等 | 使用pending+updatedAt条件更新;同键重放返回同一操作单且仅一条审计;版本变化返回冲突 | +| A23-REVIEW-003 | 审核确认层五视口 | 签名确认层截图覆盖五视口;模板覆盖桌面截图和390px DOM尺寸测量;内容与按钮可达、无横向溢出、console无业务error/warn | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index af33ad8..98e4ff1 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -2060,3 +2060,107 @@ git diff --check - 生产运行提交为`f02c33cbb7248410c189f75502d6e497fff7b355`。`cmpp-gateway`、`cmpp-api`、Nginx、PostgreSQL、Redis和MinIO均active,`12026/17890/8090/3000/9000`监听;API/Gateway health、Redis PONG、PostgreSQL readiness、外部首页、运营登录、客户端登录、API health和Swagger JSON均通过,公网CMPP `8.160.169.106:17890`可连接。根目录/API生产依赖audit均为0漏洞。 - 两个active上游通道均为`connected/currentConnections=1`,权威TPS配置已恢复;Redis Stream `gateway.submit.commands` consumer group为`pending=0、lag=0`。部署后API/Gateway error级日志均为0。未发送或重投真实短信,未执行充值、审核、删除或生产业务数据修改。 - 本地Browser五视口LG2-P0-01证据已通过;生产浏览器烟测被企业网络策略禁止访问该公网HTTP地址,未使用其他浏览器或自动化方式绕过。生产外部HTTP/TCP、真实服务、数据库、Redis和运行产物均已核验,但本次不把生产浏览器交互标记为通过。 + +## 2026-07-21 北向连接名额、历史回执与报表回填复查(未提交、未部署) + +- 生产只读复查确认北向普通/长短信无 `SUBMIT_RESP` 的直接阻塞发生在企业应用下游接入侧:账号 `695829` 的旧连接登记为 `connected`,但 TCP 和 Redis 均无对应在线连接;`lastHeartbeatAt=NULL` 使既有 `lastHeartbeatAt < cutoff` 条件永远不成立,陈旧记录持续占用 `cmppMaxConnections`,新连接在业务层登记时被关闭,Submit 因而未被 Gateway 读取。经用户授权只清理该条已确认无真实连接的陈旧登记,未修改账号、应用或短信数据,连接名额恢复为 0;仍须部署本轮代码后重新做生产普通、长短信和多号码端到端验证。 +- `markTimedOutDownstreamConnections` 已增加安全的 NULL 心跳清理:仅当 `lastHeartbeatAt IS NULL` 且 `connectedAt` 也早于超时窗口时才删除,避免误清刚建立但首个心跳尚未到达的连接。回归测试先在旧实现上失败,修复后 `sms-config.service.spec.ts` 46 项全部通过。 +- 生产历史数据只读核对找到 3 条“已有唯一 DELIVRD、主记录仍 submitted”的旧记录,均为同一上游账号复用两个通道时历史回执 `channelId` 归属反转;2026-07-21 新回执已按唯一提交记录正确归属并聚合,说明当前实时匹配代码已生效,遗留缺口是旧数据回填而不是继续发生的实时抢占。 +- 新增 migration `20260721150000_backfill_misattributed_delivery_receipts`:仅在“同一内部消息、同一 Gateway Msg_Id、同一目的号码恰好只有一条提交记录”时修正回执通道并把 DELIVRD 聚合到短信主记录;零匹配或多匹配保持原样,避免猜测修复。迁移可重复执行;本地真实 PostgreSQL 构造同账号双通道错归属样本后应用及重放均通过,主记录变为 `delivered`,回执状态、原始码/文本、到达时间、通道和通道消息号一致。 +- 在上述真实样本上执行既有 T-4 至 T-1 报表重算,发送数/成功数/失败数为 `1/1/0`,利润成功数为 1,质量成功率为 100%,平均到达时长为 5000ms;证明 7 月 18、19 日历史报表缺口应按“先迁移回填主记录,再重算对应日期”处理。生产尚未应用 migration 或重算,历史页面当前仍会保持旧结果。 +- HTTP 正向发送旧结论经 2026-07-21 较新生产证据纠正:仅传 `mobile/content` 的合法请求已返回 202、自动关联正确签名和模板、返回 MessageId,并通过真实路由和计费;不能再归类为当前未修复。重复用户名生产接口也已返回 409。黑名单 P2002 映射代码原已存在,本轮补充全局/企业及逻辑删除占用的 `BLACKLIST_DUPLICATE` 409 回归测试。 +- 完整验证:API 21 suites/240 tests、Gateway `go test ./...`、API build、前端 TypeScript/Vite build、Prisma generate/validate/status(58 条 migration,schema 最新)均通过;前端仅有既有约 1.9MB chunk warning,Jest 仍需 `--forceExit` 结束既有异步句柄。`git diff --check` 在文档收尾后另行复核。 +- `npm run verify:phase8` 的契约和 Gateway 阶段通过,但共享 Redis 的 BullMQ 15,000 条/500 并发结果为 enqueue 3577.89 TPS、端到端 431.29 TPS,未达到 500 TPS,因此完整命令未通过;临时隔离 Redis 同参数端到端为 567.37 TPS 并通过。该项按共享环境性能阻塞记录,不把共享 Redis 结果标为通过。 +- 本地正式链路启动真实 NestJS API、PostgreSQL、Redis 和 Go Gateway:API `/api/health`、Gateway `/health` 均为 `ok`,Redis `PONG`,Gateway 监听 `127.0.0.1:7890` 且恢复候选数为 0;验收后精确停止本轮 3000/8090/7890 端口进程。未向生产号码发送短信,未部署、未提交、未 push。 + +## 2026-07-21 UI/UX A1双门户会话隔离与深链恢复(未提交、未部署) + +- 修复`LG2-P1-01/02/03/05`:前端会话存储、DOM事件和BroadcastChannel按`admin/client`命名空间隔离;NestJS使用`cmpp_admin_session/cmpp_client_session`(安全Cookie环境使用对应`__Host-`名称),中间件按目标门户路径只读取对应Cookie;所有touch/lock/unlock/reauthenticate/logout/password接口改为门户专用路径。 +- 新增真实会话初始化边界和安全returnUrl:受保护路由先读取后端当前会话,未登录时保存同源、同门户白名单目标;登录后回原深链。当前会话接口只返回安全用户DTO和时序状态,不输出令牌。锁定刷新时暂停首次业务路由和运营看板请求,解锁后原URL重新挂载真实数据;当前标签已加载的路由不会被另一门户广播卸载。 +- 自动化:认证会话2 suites/10 tests、API TypeScript build、前端TypeScript/Vite build及`git diff --check`通过;项目没有前端lint/组件测试脚本,未虚报。前端仍有既有约1.90MB大chunk警告。本批无Prisma schema变更。 +- 真实链路:本地PostgreSQL建立隔离验收用户,Redis真实保存不透明会话;同一Cookie容器同时得到`cmpp_admin_session,cmpp_client_session`。锁定admin时client仍active,admin解锁轮换Cookie,client退出后admin当前会话仍返回`a1_admin`。 +- 浏览器:客户端13/13受保护路由逐条打开和刷新均保持目标URL及客户端身份,console error/warn为0;同浏览器双门户并存、客户端退出后运营端刷新继续有效。运营用户页在1440×900、1366×768、768×1024、390×844、375×667实际视口均显示真实用户且console为0;390×844锁定、刷新、解锁后仍在`/admin/users`且真实用户重新出现,锁定阶段console为0。证据在测试项目`平台LG_UIUX二轮走查证据/A1会话隔离-20260721/`。 +- 未修改生产业务数据,未发送短信、充值、审核、删除或报备;代码未提交、未push、未部署,生产仍运行旧会话实现。生产发布后旧共享Cookie需要重新登录,故台账暂记“待验证”。本地临时API由本会话启动,收尾时精确停止;PostgreSQL/Redis/既有前端进程不属于本会话,不停止。 + +## 2026-07-21 UI/UX A2客户端安全上传与日志导出(未提交、未部署) + +- `LG2-P1-04`改为客户端专用上传/下载接口:租户从当前会话用户反查,不再信任`x-tenant-id`;企业认证、签名报备、引流报备用途和目录白名单由后端强制,跨企业下载返回404。前端客户端预览/下载也不再调用admin端点。 +- `LG2-P1-06`新增两端共用日志导出状态组件:提交中防重复、完成数量/截断提示/操作单号、CSV下载、失败原地重试,并按门户在`sessionStorage`仅保存瞬时恢复筛选。后端从PostgreSQL真实筛选导出;客户端租户由会话反查且CSV只保留时间、级别、模块、操作人、动作、资源ID,另有10000条上限和公式注入防护。 +- 自动化通过:文件/运营服务2 suites、24 tests,API TypeScript build,前端TypeScript/Vite build,Prisma validate/migrate status(本地58条、schema最新)和`git diff --check`。项目仍无前端lint/组件测试脚本;前端仍有既有约1.9MB chunk告警。 +- 真实链路:本地NestJS、PostgreSQL、Redis和MinIO在线;客户端真实验证码登录后,携带伪造租户头上传仍落当前企业`a2-local-tenant`,MinIO对象经客户端接口读回55字节。客户端日志导出真实返回安全6列表头、5条记录;Browser在1440×900点击导出显示3条和操作单号`2aa2521d-111d-48a6-87c2-27cdbcf680be`。本轮未完成console专项读取,不虚报console通过。 +- Browser上传因隐藏file input点击超时未完成页面级验收;1366×768、768×1024、390×844、375×667截图也尚未补齐,因此两项台账均保持“待验证”,不标记已通过。未改生产数据,未提交、未push、未部署;并行会话的短信配置、字典、回执migration及其文档未改动。 + +## 2026-07-21 UI/UX A3审核通过风险治理(未提交、未部署) + +- 新增独立审核治理服务和`RiskAction`组件,签名/模板“通过”从直接终态调用改为“后端资格预检→对象/影响确认→提交中锁定→结构化结果”。签名预检覆盖应用绑定、公司/信用代码、法人、责任人/手机号和资质文件;模板覆盖内容、签名绑定及签名审核状态。 +- 后端从当前会话写审核人,使用`pending + updatedAt`条件更新和Serializable事务阻止并发覆盖;AuditRecord ID作为操作单号,幂等键随审计持久化,同键重试返回原结果。旧批准路由也统一进入治理服务。未修改并行会话占用的`sms-config.service.ts/spec.ts`。 +- 自动化通过:审核治理1 suite/5 tests、API TypeScript build、前端TypeScript/Vite build、Prisma validate及`git diff --check`。前端仍有既有约1.91MB chunk告警;Jest断言通过后仍需`--forceExit`结束既有异步句柄。 +- 真实本地API/PostgreSQL:完整签名预检为`approve,reject`且0阻断,首次批准写入操作单号`cmruefurl0002msyuftczvswt`并变为approved;相同幂等键重放返回同一单号和`replayed=true`。验收企业、应用、签名、审核记录、用户和日志已精确清理。 +- Browser实际打开运营登录页并读取验证码,但登录后受A1未提交会话链的“登录会话已失效”恢复提示阻断,未打开A3确认层;console专项返回空数组。未改生产数据。驳回统一协议、有限撤销/双人复核和五视口页面证据未完成,因此台账记“部分通过”,代码未提交、未push、未部署。 + +## 2026-07-21 定时短信自动派发与多实例幂等(未提交、未部署) + +- 根因:到期任务此前只有 `POST /api/admin/send/scheduled/dispatch-due` 手工入口,API 启动后没有自动扫描;派发前也没有数据库条件更新认领,多实例同时扫描会重复冻结和入队。 +- API 启动后默认 1 秒首次扫描、每 5 秒继续扫描,可通过 `SMS_SCHEDULED_DISPATCH_SCAN_ENABLED` 和 `SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS` 配置;进程内重入保护避免同一实例扫描重叠。 +- 派发使用 `id + status (+ stale updatedAt)` 的 `updateMany` 条件更新原子认领。正常任务进入 `scheduled_dispatching`;超过默认 2 分钟的陈旧认领在 `scheduled_dispatching/scheduled_recovering` 间交替认领,保证多实例仅一个恢复者胜出。 +- 恢复时按 `tenantId + transactionType=frozen + relatedType=sms_batch_task + relatedId` 查询既有冻结流水,存在则不再冻结;BullMQ 继续使用消息记录 ID 作为 jobId。0 元任务在资源和余额检查通过后若 Redis 入队失败,也保留调度中状态等待恢复,不会被错误终结。 +- 回归测试先覆盖旧代码失败,再完成实现;`send-chain.service.spec.ts` 共 67/67 通过,新增自动启动扫描、并发扫描唯一认领、陈旧认领恢复不重复冻结和 0 元任务入队失败可恢复用例。API TypeScript build 通过。 +- 该 Jest 套件断言约 24 秒完成,但仍需 `--forceExit` 结束仓库既有 BullMQ/Redis 异步句柄;未把句柄问题标记为通过。本批没有 Prisma schema/migration 变化,尚未执行真实 PostgreSQL/Redis 双实例故障注入,留待本地链路总验收。 +- 代码和文档均未提交、未 push、未部署;生产仍需发布后验证无需手工接口即可自动派发到期任务。 + +## 2026-07-21 下游人工重投与Gateway提交异常恢复(未提交、未部署) + +- 根因一:`requeueDownstreamDelivery` 原先先读取再普通 `update`,没有状态版本条件;并发请求可重复递增人工次数并多次调用 Gateway。若直接把认领结果写为 pending,Gateway 恢复扫描还可能与同步控制面调用形成双发窗口。 +- 下游人工重投改为 `id + status + updatedAt` 的 `updateMany` 原子认领,认领态为 `manual_requeueing`;Gateway pending 查询不会选中该状态。成功写出后进入 `awaiting_ack`,失败走既有明确状态机;超过默认 2 分钟的陈旧认领自动恢复为 pending,阈值可通过 `CMPP_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS` 配置。 +- 根因二:Gateway提交异常虽然已有 pending→requeueing 抢占,但 Redis XADD 成功、数据库更新 requeued 失败时会永久停留 requeueing;直接重放又可能产生第二条 SubmitCommand。重复异常上报还会无条件把已解决记录重置为 pending。 +- 提交异常重入队现在使用 `gateway:submit:requeue:{deadLetterId}:{nextAttempt}` 稳定键和 Redis Lua,原子执行幂等检查、XADD 和 30 天 Stream ID 保存;陈旧 requeueing 由后台扫描抢占为 requeue_recovering,并复用同一键完成数据库落账。SubmitResult 可从 pending/requeueing/requeue_recovering/requeued 任一在途状态直接闭环 resolved,恢复不会覆盖 resolved;重复上报只更新失败详情,不回退处理状态。 +- 回归测试先在旧实现失败,修复后 `send-chain.service.spec.ts` 71/71 通过;新增重复异常上报不回退、陈旧提交重入队恢复、下游并发唯一认领和陈旧人工认领恢复用例。API TypeScript build 通过;Jest 仍需 `--forceExit` 结束仓库既有 BullMQ/Redis异步句柄。 +- 真实 Redis 验证:同一幂等键并发发布两次返回相同 Stream ID,Stream entry 数为 1,幂等键 TTL 为 2592000 秒;测试 Stream 和 key 已删除。 +- 真实 PostgreSQL 并发验证:两个请求在读取同一版本后同时重投,结果为 1 成功、1 冲突,Gateway 控制面仅调用 1 次,`manualRetryCount=1`,成功记录进入 awaiting_ack;陈旧 manual_requeueing 记录自动恢复为 pending。另以真实 PostgreSQL+Redis 验证陈旧提交异常恢复后 status=requeued、人工次数=1、重复幂等发布仍只有一条 Stream entry。所有本地临时企业、应用、投递、异常、日志、Stream 和 key 均已清理。 +- 本批无 Prisma schema 或 migration 变化,没有修改 Go Gateway。未发送短信,未修改生产数据;代码未提交、未 push、未部署。 + +## 2026-07-21 UI/UX A4报备生成资格、幂等与结果治理(未提交、未部署) + +- `LG2-P1-15`后端新增`POST /api/admin/report-materials/batches/preflight`,逐资料/通道检查审核与待报备状态、应用启用、版本、路由、通道状态、字段配置和必填值;非法资料ID返回可读400,0可生成目标在创建批次前返回`REPORT_BATCH_NOT_ELIGIBLE`。 +- 生成接口要求幂等键,通过PostgreSQL advisory transaction lock认领操作;资料类型/ID/版本/应用/通道/运营商形成持久化业务键。相同请求重放返回原操作单和结果,不同范围复用键返回409;成功响应包含成功、跳过、失败分项及每项阻断原因。 +- 运营端列表由真实预检控制选择资格,明确显示“待补充”及首个阻断原因;生成前再次预检,确认层展示企业、应用、版本、预计通道、运营商和分项计数,提交中锁定,完成后显示批次与操作单号。 +- 自动化:`report-materials.service.spec.ts` 7/7、API TypeScript build、前端TypeScript/Vite build、Prisma validate及migrate status(本地58条、schema最新)均通过,`git diff --check`通过。API build首次被并行会话尚未完成的`send-chain.service.ts`变量错误阻断,未修改该文件;对方完成后收尾重试已通过。项目没有独立前端lint/组件测试脚本;前端仍有既有约1.91MB单chunk告警。 +- 真实API/PostgreSQL:临时已审核但未绑定应用的资料经真实管理员登录、待报备和预检接口返回`eligible=false`、0目标、“未绑定短信应用”;临时企业、用户、签名和日志清理后均为0。没有调用生成接口。 +- Browser只打开到确认层并取消:完整临时应用、路由、通道和字段显示1个可生成组合;1440×900、1366×768、768×1024、390×844、375×667均无横向溢出且弹窗在视口内,console error/warn为0。截图位于测试项目`平台LG_UIUX二轮走查证据/A4_LG2-P1-15_报备预检_*.png`;所有临时数据清理为0。 +- 未修改生产业务数据,未生成报备、发送短信、审核、充值或删除生产对象;代码未提交、未push、未部署,生产仍运行旧实现。 + +## 2026-07-21 UI/UX A5通道/签名/模板删除治理(未提交、未部署) + +- `LG2-P1-20`新增统一`DeletionGovernanceService`、admin/client预检与删除接口及共享`DeleteRiskAction`。确认层展示对象名称/ID、活动依赖数量和对象摘要、阻断原因、影响范围、可恢复说明、原因、提交态及结构化操作结果;客户端查询由会话企业强制裁剪。 +- 后端对通道组/路由/活动连接/报备、签名关联模板/引流/报备、模板发送/批量任务做真实依赖检查。删除使用`updatedAt`乐观锁、Serializable事务、幂等键、逻辑删除和OperationLog操作单,旧通道删除及旧签名/模板删除状态入口统一委托治理服务。 +- 正向真实链路首次暴露`PrismaService.operationLog`代理属性不可配置导致事务客户端访问代理时报500;将属性改为可配置并新增`prisma.service.spec.ts`,修复后同一页面请求成功。真实PostgreSQL模板状态变为`deleted`并写入`governance.delete`日志、原因、依赖、影响和幂等键。 +- 自动化通过:`deletion-governance.service.spec.ts`与`prisma.service.spec.ts`共2 suites / 7 tests、API TypeScript build、前端TypeScript/Vite build、Prisma migrate status(本地58条、schema最新)和`git diff --check`。项目没有独立前端lint/组件测试脚本;前端仍有既有约1.91MB单chunk告警。 +- Browser真实页面:通道被活动组引用时,1440×900、1366×768、768×1024、390×844、375×667均展示1项引用和后端阻断原因,确认按钮断言disabled;无依赖模板填写原因后页面列表变空,数据库和审计同步落账。console error/warn为0。证据位于测试项目`平台LG_UIUX二轮走查证据/整改_A5_LG2-P1-20_20260721/`。 +- 本轮只写入并精确清理本地验收数据,未操作生产对象。代码未提交、未push、未部署;通用Dialog焦点/背景/dirty保护归A7,前端拆包与缓存归C阶段。 + +## 2026-07-21 UI/UX A6人工充值草稿、确认与幂等治理(未提交、未部署) + +- `LG2-P1-21`将充值记录页和企业管理页两个入口收敛到共享`ManualRechargeDialog`。取消、右上角关闭和完成都会销毁金额、备注、预检、结果及幂等键;浏览器分别填写`123.45/88.88`和备注后取消/关闭,重开字段均为空。 +- 新增`POST /api/admin/billing/manual-recharges/preflight`,从真实企业账户返回版本、现金余额、授信、方向和预计余额。确认层显示企业名称/编码/ID、方向、当前余额、变动、预计余额及备注,提交期间防重复,成功显示订单号、余额、操作单和幂等重放状态。 +- 最终接口由当前会话注入操作者,要求8—128位幂等键和账户版本;advisory transaction lock串行同键请求,`updatedAt`条件更新阻止覆盖新余额。订单、余额增量、账户流水和OperationLog在Serializable事务中原子写入。 +- 真实API/PostgreSQL:本地临时账户10.0000元,经页面充值1.2345元后为11.2345元;订单、流水、审计各1条。相同幂等键再次调用真实API返回同订单、同操作单、`replayed=true`,三个计数仍各1。充值记录页和企业管理页均回读11.2345元。 +- Browser五视口1440×900、1366×768、768×1024、390×844、375×667通过;375短屏底部操作可达,console error/warn为0。截图和API日志位于测试项目`平台LG_UIUX二轮走查证据/整改_A6_LG2-P1-21_20260721/`。 +- 自动化通过:`billing.service.spec.ts` 1 suite / 11 tests、API TypeScript build、前端TypeScript/Vite build、Prisma migrate status(58条、schema最新)及`git diff --check`。项目没有独立前端lint/组件测试脚本;约1.92MB单chunk告警归C阶段性能项。 +- 仅修改并精确清理本地验收数据,未操作生产充值。代码未提交、未push、未部署;生产仍运行旧实现,通用Dialog焦点/背景隔离/dirty guard继续由A7处理。 + +## 2026-07-22 UI/UX A7公共Dialog整改(未提交、未部署) + +- 修复`LG2-P1-23`:公共Modal新增初始焦点、顶层焦点栈、Tab/Shift+Tab约束、背景`inert`/`aria-hidden`、滚动锁、`aria-labelledby`和关闭后焦点恢复;遮罩由可聚焦button改为非交互div。 +- 新增dirty关闭协议:右上角、取消、Escape和遮罩统一进入具名`alertdialog`;父弹窗暂时inert,继续编辑保留草稿并恢复原字段焦点,明确放弃后才关闭并销毁。人工充值、删除风险动作和两端模板表单接入该协议。 +- 真实浏览器:本地NestJS/PostgreSQL/Redis会话分别登录运营与客户端;运营企业模板留存1440×900、1366×768、768×1024、390×844、375×667五张截图,客户端模板在1440×900和390×844完成DOM/键盘实测。五视口无横向溢出,主弹窗与确认层焦点循环、草稿保留、背景恢复、触发按钮焦点恢复均通过;两端console error/warn为空。 +- 验证:前端TypeScript/Vite build、API TypeScript build、Prisma validate/migrate status和`git diff --check`通过。项目无独立前端lint/组件/axe脚本,未虚报;构建仍有既有约1.92MB单chunk告警。 +- 未创建模板、未执行审核/删除/充值/发送或其他生产业务写入;本地临时用户、企业、日志和Redis会话已精确清理。代码未提交、未push、未部署,生产仍为旧Dialog实现。 + +## 2026-07-22 UI/UX A2/A3收口(未提交、未部署) + +- `LG2-P1-04`通过真实Browser文件选择完成客户端企业认证材料上传;PostgreSQL FileObject归属当前会话企业,MinIO对象回读93字节。复验发现并修复手机端文件名、步骤条和省市选择的卡片内裁切,五视口重新截图后文件名完整且页面无横向溢出。 +- `LG2-P1-06`在客户端系统日志页真实点击导出,五视口均显示5条和操作单号;独立客户端会话API再次导出7条,表头严格为`时间,级别,模块,操作人,动作,资源ID`,测试注入的详情/IP/供应商内部字段均未泄露。 +- `LG2-P1-14`在运营签名和模板审核页分别打开通过确认层,仅取消时数据库状态仍为pending且审计为0。真实API随后首次批准签名并用相同幂等键重放,两次返回同一操作单`cmrvlgtrq000gakyumhm1iuiv`,重放标志为true且只写一次审核。 +- 浏览器证据覆盖上传页和日志导出页五视口、签名确认层五视口截图,以及模板确认层1440×900截图和390×844 DOM尺寸测量;两端console error/warn为空。证据位于测试项目`平台LG_UIUX二轮走查证据/整改_A2_A3收口_20260722/`。 +- 自动化通过:files、operations、review-governance 3 suites / 31 tests;前端build、API build、Prisma validate/status(58条、schema最新)和`git diff --check`。前端仍有约1.92MB单chunk告警,项目无独立前端lint/组件/axe脚本。 +- 客户端日志列表仍展示内部详情/IP,继续归`LG2-P1-07`,未因导出安全而标记完成。临时数据、MinIO对象和Redis会话已清理;未触碰生产,未提交、未push、未部署。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 0014bb0..b024c20 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -1,11 +1,41 @@ -import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, requestReauthentication, type LoginSession } from './session'; +import { + clearSession, + currentRouteForPortal, + dispatchSessionEvent, + getSessionTenantId, + hasRecentUserActivity, + portalFromPath, + readSession, + redirectToPortalLogin, + requestReauthentication, + saveSessionRecovery, + type LoginSession, + type Portal, +} from './session'; import { assertUploadFileSize } from '@/utils/fileUpload'; type RequestOptions = RequestInit & { tenantId?: string; reauthenticationAttempted?: boolean; + suppressSessionRedirect?: boolean; }; +export type DeletionTargetType = 'channel' | 'signature' | 'template'; +export type DeletionDependency = { kind: string; label: string; count: number; items: string[] }; +export type DeletionPreflight = { + type: DeletionTargetType; + id: string; + expectedUpdatedAt: string; + identity: Record; + dependencies: DeletionDependency[]; + impacts: string[]; + blockedReasons: string[]; + allowedActions: Array<'delete'>; + recoverability: { mode: 'soft_delete'; description: string }; +}; +export type DeleteTargetRequest = { expectedUpdatedAt: string; idempotencyKey: string; reason: string }; +export type DeletionResult = { operationId: string; status: 'deleted'; replayed: boolean }; + type ApiErrorBody = { message?: string | string[]; error?: string; code?: string }; async function readErrorBody(response: Response): Promise { @@ -20,6 +50,35 @@ async function readErrorBody(response: Response): Promise { export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a'; +type SessionTiming = Pick; + +function requestPortal(path: string): Portal | undefined { + return portalFromPath(path); +} + +async function handleSessionFailure(response: Response, portal: Portal | undefined, suppressRedirect = false) { + if (!portal) return false; + const session = readSession(portal); + const body = await readErrorBody(response.clone()); + if (body.code === 'SESSION_LOCKED' && session) { + dispatchSessionEvent(portal, 'locked', { message: body.message }); + throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定'); + } + if (suppressRedirect) return false; + + if (session) { + saveSessionRecovery(portal, { + returnUrl: currentRouteForPortal(portal), + code: body.code, + message: typeof body.message === 'string' ? body.message : '登录会话已失效,请重新登录', + }); + } + clearSession(portal); + dispatchSessionEvent(portal, 'logout', { code: body.code, message: body.message }); + redirectToPortalLogin(portal); + throw new Error('登录会话已失效,请重新登录'); +} + async function readErrorMessage(response: Response) { const fallback = `请求失败(${response.status})`; const text = await response.text(); @@ -40,7 +99,8 @@ async function readErrorMessage(response: Response) { async function request(path: string, options: RequestOptions = {}): Promise { const headers = new Headers(options.headers); headers.set('Content-Type', 'application/json'); - const session = readSession(); + const portal = requestPortal(path); + const session = portal ? readSession(portal) : null; if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined); if (tenantId) { @@ -48,16 +108,8 @@ async function request(path: string, options: RequestOptions = {}): Promise(path: string, options: RequestOptions = {}): Promise { const headers = new Headers(options.headers); - const session = readSession(); + const portal = requestPortal(path); + const session = portal ? readSession(portal) : null; if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined); if (tenantId) { headers.set('x-tenant-id', tenantId); } const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' }); - if (response.status === 401 && session) { - const body = await readErrorBody(response.clone()); - if (body.code === 'SESSION_LOCKED') { - dispatchSessionEvent('locked', { message: body.message }); - throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定'); - } - clearSession(); - dispatchSessionEvent('logout', { code: body.code, message: body.message }); - window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login'); - throw new Error('登录会话已失效,请重新登录'); + if (response.status === 401) { + await handleSessionFailure(response, portal, options.suppressSessionRedirect); } if (response.status === 403 && session && !options.reauthenticationAttempted) { const body = await readErrorBody(response.clone()); @@ -107,18 +152,12 @@ async function requestBlob(path: string, options: RequestOptions = {}): Promise< async function requestForm(path: string, form: FormData, reauthenticationAttempted = false): Promise { const headers = new Headers(); - const session = readSession(); + const portal = requestPortal(path); + const session = portal ? readSession(portal) : null; if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); const response = await fetch(`/api${path}`, { method: 'POST', headers, body: form, credentials: 'same-origin' }); - if (response.status === 401 && session) { - const body = await readErrorBody(response.clone()); - if (body.code === 'SESSION_LOCKED') { - dispatchSessionEvent('locked', { message: body.message }); - throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定'); - } - clearSession(); - dispatchSessionEvent('logout', { code: body.code, message: body.message }); - window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login'); + if (response.status === 401) { + await handleSessionFailure(response, portal); throw new Error('登录会话已失效,请重新登录'); } if (response.status === 403 && session && !reauthenticationAttempted) { @@ -208,6 +247,27 @@ export type SmsTemplateAudit = { tenant?: { name: string }; }; +export type ReviewPreflight = { + type: 'signature' | 'template'; + id: string; + tenantId: string; + status: string; + expectedUpdatedAt: string; + identity: Record; + impacts: string[]; + materialSummary: Record; + blockedReasons: string[]; + allowedActions: Array<'approve' | 'reject'>; +}; + +export type ReviewDecisionResult = { + operationId: string; + replayed: boolean; + decision: 'approve' | 'reject'; + status: string; + item: ClientSmsSignature | SmsTemplateAudit; +}; + export type TenantOption = { id: string; name: string; @@ -305,6 +365,25 @@ export type RechargeOrder = { tenant?: TenantOption; }; +export type ManualRechargePreflight = { + tenant: Pick; + accountId: string; + expectedAccountUpdatedAt: string; + balanceCents: number; + creditCents: number; + amountCents: number; + balanceAfterCents: number; + direction: 'topup' | 'correction'; + allowedActions: Array<'confirm'>; + blockedReasons: string[]; +}; + +export type ManualRechargeResult = RechargeOrder & { + balanceAfterCents: number; + operationId: string; + replayed: boolean; +}; + export type ClientSmsApplication = { id: string; tenantId: string; @@ -683,6 +762,50 @@ export type ReportMaterialPendingItem = { application?: ClientSmsApplication | null; }; +export type ReportMaterialPreflightTarget = { + id: string; + name: string; + carrier: string; + businessKey: string; + eligible: boolean; + blockedReasons: string[]; + duplicateBatchId?: string; +}; + +export type ReportMaterialPreflightItem = { + id: string; + reportType: 'signature' | 'drainage'; + signatureId: string; + drainageItemId?: string; + materialVersion: number; + name: string; + tenantName: string; + applicationId?: string; + applicationName: string; + eligible: boolean; + blockedReasons: string[]; + targets: ReportMaterialPreflightTarget[]; +}; + +export type ReportMaterialBatchPreflight = { + checkedAt: string; + eligible: boolean; + eligibleItemCount: number; + blockedItemCount: number; + eligibleTargetCount: number; + skippedTargetCount: number; + items: ReportMaterialPreflightItem[]; +}; + +export type ReportMaterialBatchResult = Record & { + id: string; + batchNo: string; + status: string; + operationId: string; + replayed: boolean; + result: { successCount: number; skippedCount: number; failedCount: number; items: ReportMaterialPreflightItem[] }; +}; + export type ReportImportMapping = { sourceHeader: string; sourceHeaderPath?: string; @@ -771,8 +894,8 @@ export type FileRef = { contentType?: string; }; -export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment') { - return `/api/admin/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`; +export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment', portal: Portal = 'admin') { + return `/api/${portal}/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`; } export type RiskReviewTask = { @@ -810,6 +933,7 @@ export type TenantAccount = { balanceCents: number; creditCents: number; status: string; + updatedAt?: string; tenant?: TenantOption; }; @@ -835,6 +959,16 @@ export type OperationLogResponse = { modules: string[]; }; +export type SystemLogExportResult = { + operationId: string; + status: 'completed'; + fileName: string; + recordCount: number; + truncated: boolean; + content: string; + filters: { keyword?: string; level?: string; module?: string; range?: string }; +}; + export type PagedResponse = { items: T[]; total: number; @@ -1180,17 +1314,28 @@ function withQuery(path: string, query: Record request(`/${portal}/auth/session`, { suppressSessionRedirect: true }), + touch: (portal: Portal) => request(`/${portal}/auth/session/touch`, { method: 'POST', body: '{}' }), + lock: (portal: Portal) => request<{ locked: boolean }>(`/${portal}/auth/session/lock`, { method: 'POST', body: '{}' }), + unlock: (portal: Portal, password: string) => request(`/${portal}/auth/session/unlock`, { method: 'POST', body: JSON.stringify({ password }) }), + reauthenticate: (portal: Portal, password: string) => request>(`/${portal}/auth/reauthenticate`, { method: 'POST', body: JSON.stringify({ password }), reauthenticationAttempted: true }), + logout: (portal: Portal) => request<{ success: boolean }>(`/${portal}/auth/logout`, { method: 'POST', body: '{}' }), + changeOwnPassword: (portal: Portal, body: { currentPassword: string; password: string }) => + request(`/${portal}/auth/password`, { method: 'POST', body: JSON.stringify(body) }), +}; + export const adminApi = { getCaptcha: () => request('/admin/auth/captcha'), login: (body: { login: string; password: string; captchaId: string; captchaText: string }) => request('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }), - touchSession: () => request>('/auth/session/touch', { method: 'POST', body: '{}' }), - lockSession: () => request<{ locked: boolean }>('/auth/session/lock', { method: 'POST', body: '{}' }), - unlockSession: (password: string) => request>('/auth/session/unlock', { method: 'POST', body: JSON.stringify({ password }) }), - reauthenticate: (password: string) => request>('/auth/reauthenticate', { method: 'POST', body: JSON.stringify({ password }), reauthenticationAttempted: true }), - logout: () => request<{ success: boolean }>('/auth/logout', { method: 'POST', body: '{}' }), + touchSession: () => portalSessionApi.touch('admin'), + lockSession: () => portalSessionApi.lock('admin'), + unlockSession: (password: string) => portalSessionApi.unlock('admin', password), + reauthenticate: (password: string) => portalSessionApi.reauthenticate('admin', password), + logout: () => portalSessionApi.logout('admin'), changeOwnPassword: (body: { currentPassword: string; password: string }) => - request('/auth/password', { method: 'POST', body: JSON.stringify(body) }), + portalSessionApi.changeOwnPassword('admin', body), listTenants: () => request('/admin/tenants'), listTenantManagementRows: () => request('/admin/tenants/management-list'), getTenant: (id: string) => request(`/admin/tenants/${id}`), @@ -1212,12 +1357,16 @@ export const adminApi = { getDashboard: (tenantId?: string) => request(withQuery('/admin/operations/dashboard/statistics', { tenantId })), listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) => request(withQuery('/admin/system-logs', query)), + exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) => + request('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }), listAccounts: () => request('/admin/billing/accounts'), updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) => request(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }), listManualRecharges: (tenantId?: string) => request(withQuery('/admin/billing/manual-recharges', { tenantId })), - createManualRecharge: (body: { tenantId: string; amountCents: number; operatorId?: string; remark?: string }) => - request('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), + preflightManualRecharge: (body: { tenantId: string; amountCents: number }) => + request('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }), + createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) => + request('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) => request(withQuery('/admin/enterprise-applications', query)), getEnterpriseApplication: (id: string) => @@ -1272,6 +1421,10 @@ export const adminApi = { method: 'DELETE', body: JSON.stringify({ reason }), }), + getDeletionPreflight: (type: DeletionTargetType, id: string) => + request(`/admin/deletions/${type}/${id}/preflight`), + deleteGovernedTarget: (type: DeletionTargetType, id: string, body: DeleteTargetRequest) => + request(`/admin/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }), listChannelConnectionLogs: (id: string) => request(`/admin/channels/${id}/connection-logs`), listTemplateAudits: (query: { keyword?: string; status?: string }) => { const params = new URLSearchParams(); @@ -1290,6 +1443,10 @@ export const adminApi = { method: 'POST', body: JSON.stringify({ reason }), }), + getReviewPreflight: (type: 'signature' | 'template', id: string) => + request(`/admin/reviews/${type}/${id}/preflight`), + submitReviewDecision: (type: 'signature' | 'template', id: string, body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string }) => + request(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }), listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) => request(withQuery('/admin/enterprise-signatures', query)), createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }) => @@ -1370,8 +1527,10 @@ export const adminApi = { commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit & { id?: string } }) => request>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }), listReportMaterialBatches: () => request>>('/admin/report-materials/batches'), - createReportMaterialBatch: (body: { createdById?: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string }> }) => - request>('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }), + preflightReportMaterialBatch: (body: { items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }> }) => + request('/admin/report-materials/batches/preflight', { method: 'POST', body: JSON.stringify(body) }), + createReportMaterialBatch: (body: { idempotencyKey: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number }> }) => + request('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }), listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request(withQuery('/admin/report-tasks', query)), createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) => request('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }), @@ -1462,7 +1621,7 @@ export const adminApi = { form.set('prefix', body.prefix); } const headers = new Headers(); - const session = readSession(); + const session = readSession('admin'); if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); if (tenantId) { headers.set('x-tenant-id', tenantId); @@ -1471,11 +1630,11 @@ export const adminApi = { if (response.status === 401 && session) { const error = await readErrorBody(response.clone()); if (error.code === 'SESSION_LOCKED') { - dispatchSessionEvent('locked', { message: error.message }); + dispatchSessionEvent('admin', 'locked', { message: error.message }); } else { - clearSession(); - dispatchSessionEvent('logout', { code: error.code, message: error.message }); - window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login'); + clearSession('admin'); + dispatchSessionEvent('admin', 'logout', { code: error.code, message: error.message }); + redirectToPortalLogin('admin'); } } if (!response.ok) { @@ -1513,6 +1672,8 @@ export const clientApi = { }), listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/operations/system-logs', query), { tenantId }), + exportSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string }) => + request('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }), listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/billing/orders', { tenantId }), listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => @@ -1566,6 +1727,10 @@ export const clientApi = { request(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }), changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/templates/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), + getDeletionPreflight: (type: Exclude, id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/deletions/${type}/${id}/preflight`, { tenantId }), + deleteGovernedTarget: (type: Exclude, id: string, body: DeleteTargetRequest, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/deletions/${type}/${id}`, { method: 'POST', tenantId, body: JSON.stringify(body) }), listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/send/batch-tasks', query), { tenantId }), cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => @@ -1582,9 +1747,7 @@ export const clientApi = { request(withQuery('/client/operations/messages', query), { tenantId }), listUplinkMessages: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/operations/uplink-messages', query), { tenantId }), - createFileObject: (body: { bucket?: string; objectKey: string; fileName: string; contentType: string; sizeBytes: number; purpose: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request('/admin/files', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId, bucket: body.bucket ?? 'cmpp-platform' }) }), - uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => { + uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => { assertUploadFileSize(file); const form = new FormData(); form.set('file', file); @@ -1593,20 +1756,17 @@ export const clientApi = { form.set('prefix', body.prefix); } const headers = new Headers(); - const session = readSession(); + const session = readSession('client'); if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user'); - if (tenantId) { - headers.set('x-tenant-id', tenantId); - } - const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' }); + const response = await fetch('/api/client/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' }); if (response.status === 401 && session) { const error = await readErrorBody(response.clone()); if (error.code === 'SESSION_LOCKED') { - dispatchSessionEvent('locked', { message: error.message }); + dispatchSessionEvent('client', 'locked', { message: error.message }); } else { - clearSession(); - dispatchSessionEvent('logout', { code: error.code, message: error.message }); - window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login'); + clearSession('client'); + dispatchSessionEvent('client', 'logout', { code: error.code, message: error.message }); + redirectToPortalLogin('client'); } } if (!response.ok) { diff --git a/src/api/session.ts b/src/api/session.ts index d3a49dc..fd9bf9b 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -19,32 +19,116 @@ export type LoginSession = { absoluteExpiresAt: string; lastActivityAt: string; recentAuthenticationExpiresAt: string; + locked?: boolean; }; -const sessionKey = 'cmpp-auth-session'; +export type SessionRecovery = { + returnUrl: string; + code?: string; + message?: string; +}; -export function readSession(): LoginSession | null { +const legacySessionKey = 'cmpp-auth-session'; +const sessionKey = (portal: Portal) => `cmpp-auth-session:${portal}`; +const recoveryKey = (portal: Portal) => `cmpp-session-recovery:${portal}`; +const sessionEventName = (portal: Portal, type: 'locked' | 'unlocked' | 'logout') => `cmpp-session-${portal}-${type}`; + +export function portalFromPath(path: string): Portal | undefined { + if (/^\/?(?:api\/)?admin(?:\/|$)/.test(path)) return 'admin'; + if (/^\/?(?:api\/)?client(?:\/|$)/.test(path)) return 'client'; + return undefined; +} + +export function readSession(portal: Portal): LoginSession | null { try { - const raw = window.localStorage.getItem(sessionKey); - return raw ? JSON.parse(raw) as LoginSession : null; + const raw = window.localStorage.getItem(sessionKey(portal)); + if (raw) return JSON.parse(raw) as LoginSession; + + // One-time migration for sessions created before portal storage was isolated. + const legacyRaw = window.localStorage.getItem(legacySessionKey); + if (!legacyRaw) return null; + const legacy = JSON.parse(legacyRaw) as LoginSession; + if (legacy.portal !== portal) return null; + window.localStorage.setItem(sessionKey(portal), legacyRaw); + window.localStorage.removeItem(legacySessionKey); + return legacy; } catch { return null; } } export function writeSession(session: LoginSession) { - window.localStorage.setItem(sessionKey, JSON.stringify(session)); + window.localStorage.setItem(sessionKey(session.portal), JSON.stringify(session)); + window.localStorage.removeItem(legacySessionKey); } -export function clearSession() { - window.localStorage.removeItem(sessionKey); +export function clearSession(portal: Portal) { + window.localStorage.removeItem(sessionKey(portal)); + try { + const legacyRaw = window.localStorage.getItem(legacySessionKey); + if (legacyRaw && (JSON.parse(legacyRaw) as LoginSession).portal === portal) { + window.localStorage.removeItem(legacySessionKey); + } + } catch { + window.localStorage.removeItem(legacySessionKey); + } } -export function updateSessionTiming(timing: Partial>) { - const current = readSession(); +export function updateSessionTiming(portal: Portal, timing: Partial>) { + const current = readSession(portal); if (current) writeSession({ ...current, ...timing }); } +export function safeReturnUrl(portal: Portal, value: string) { + const normalized = value.trim(); + if (!normalized.startsWith(`/${portal}`) || normalized.startsWith(`/${portal}/login`)) return undefined; + if (normalized.startsWith('//') || normalized.includes('\\')) return undefined; + try { + const parsed = new URL(normalized, window.location.origin); + if (parsed.origin !== window.location.origin || !parsed.pathname.startsWith(`/${portal}`)) return undefined; + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return undefined; + } +} + +export function saveSessionRecovery(portal: Portal, recovery: SessionRecovery) { + const returnUrl = safeReturnUrl(portal, recovery.returnUrl); + if (!returnUrl) return; + window.sessionStorage.setItem(recoveryKey(portal), JSON.stringify({ ...recovery, returnUrl })); +} + +export function readSessionRecovery(portal: Portal): SessionRecovery | null { + try { + const raw = window.sessionStorage.getItem(recoveryKey(portal)); + if (!raw) return null; + const recovery = JSON.parse(raw) as SessionRecovery; + const returnUrl = safeReturnUrl(portal, recovery.returnUrl); + return returnUrl ? { ...recovery, returnUrl } : null; + } catch { + return null; + } +} + +export function consumeSessionRecovery(portal: Portal) { + const recovery = readSessionRecovery(portal); + window.sessionStorage.removeItem(recoveryKey(portal)); + return recovery; +} + +export function clearSessionRecovery(portal: Portal) { + window.sessionStorage.removeItem(recoveryKey(portal)); +} + +export function currentRouteForPortal(portal: Portal) { + const hashRoute = window.location.hash.startsWith('#/') ? window.location.hash.slice(1) : ''; + return safeReturnUrl(portal, hashRoute) ?? safeReturnUrl(portal, `${window.location.pathname}${window.location.search}${window.location.hash}`) ?? `/${portal}`; +} + +export function redirectToPortalLogin(portal: Portal) { + window.location.assign(`${window.location.origin}/#/${portal}/login`); +} + let lastUserActivityAt = Date.now(); let reauthenticationHandler: (() => Promise) | undefined; @@ -69,10 +153,10 @@ export function requestReauthentication() { return reauthenticationHandler(); } -export function dispatchSessionEvent(type: 'locked' | 'unlocked' | 'logout', detail?: Record) { - window.dispatchEvent(new CustomEvent(`cmpp-session-${type}`, { detail })); +export function dispatchSessionEvent(portal: Portal, type: 'locked' | 'unlocked' | 'logout', detail?: Record) { + window.dispatchEvent(new CustomEvent(sessionEventName(portal, type), { detail })); try { - const channel = new BroadcastChannel('cmpp-session'); + const channel = new BroadcastChannel(`cmpp-session:${portal}`); channel.postMessage({ type, detail }); channel.close(); } catch { @@ -80,6 +164,14 @@ export function dispatchSessionEvent(type: 'locked' | 'unlocked' | 'logout', det } } -export function getSessionTenantId() { - return readSession()?.user.tenantId ?? undefined; +export function sessionEvent(portal: Portal, type: 'locked' | 'unlocked' | 'logout') { + return sessionEventName(portal, type); +} + +export function sessionChannel(portal: Portal) { + return `cmpp-session:${portal}`; +} + +export function getSessionTenantId() { + return readSession('client')?.user.tenantId ?? undefined; } diff --git a/src/apps/LoginPage.tsx b/src/apps/LoginPage.tsx index 9978437..a1c191d 100644 --- a/src/apps/LoginPage.tsx +++ b/src/apps/LoginPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi'; -import { writeSession, type Portal } from '@/api/session'; +import { consumeSessionRecovery, readSessionRecovery, writeSession, type Portal } from '@/api/session'; import { Button, Input, Modal } from '@/components/ui'; type LoginPageProps = { @@ -31,6 +31,7 @@ export function LoginPage({ portal }: LoginPageProps) { const [alertMessage, setAlertMessage] = useState(''); const [loading, setLoading] = useState(false); const isAdmin = portal === 'admin'; + const recovery = readSessionRecovery(portal); async function refreshCaptcha(options: { clearError?: boolean } = {}) { if (options.clearError ?? true) { @@ -56,7 +57,8 @@ export function LoginPage({ portal }: LoginPageProps) { captchaText, }); writeSession(session); - navigate(isAdmin ? '/admin' : '/client', { replace: true }); + const target = consumeSessionRecovery(portal)?.returnUrl; + navigate(target ?? (isAdmin ? '/admin' : '/client'), { replace: true }); } catch (err) { const message = loginErrorMessage(err); setError(message); @@ -78,6 +80,11 @@ export function LoginPage({ portal }: LoginPageProps) {
+ {recovery ? ( +

+ {recovery.message ?? '登录会话已失效,请重新登录。'} 登录成功后将返回之前访问的页面。 +

+ ) : null} setLogin(event.target.value)} placeholder="请输入用户名、邮箱或手机号" value={login} /> setPassword(event.target.value)} placeholder="请输入密码" type="password" value={password} />
diff --git a/src/apps/admin/AdminChannelsPage.tsx b/src/apps/admin/AdminChannelsPage.tsx index f691178..c0262bb 100644 --- a/src/apps/admin/AdminChannelsPage.tsx +++ b/src/apps/admin/AdminChannelsPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi'; -import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui'; +import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui'; import { formatDateTime } from '@/utils/dateTime'; import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency'; @@ -42,7 +42,7 @@ type ChannelModalState = { }; type ChannelConfirmAction = { - type: 'toggle' | 'delete' | 'copy'; + type: 'toggle' | 'copy'; channel: SmsChannel; }; @@ -532,11 +532,6 @@ export function AdminChannelsPage() { loadChannels(); } - async function deleteChannel(id: string) { - await adminApi.deleteChannel(id, '运营端删除通道'); - setChannels((items) => items.filter((item) => item.id !== id)); - } - async function copyChannel(channel: SmsChannel) { await adminApi.copyChannel(channel.id); loadChannels(); @@ -563,10 +558,6 @@ export function AdminChannelsPage() { void toggleChannel(confirmAction.channel); } - if (confirmAction.type === 'delete') { - void deleteChannel(confirmAction.channel.id); - } - if (confirmAction.type === 'copy') { void copyChannel(confirmAction.channel); } @@ -574,17 +565,13 @@ export function AdminChannelsPage() { setConfirmAction(null); } - const confirmTitle = confirmAction?.type === 'delete' - ? '确认删除通道' - : confirmAction?.type === 'copy' + const confirmTitle = confirmAction?.type === 'copy' ? '确认复制通道' : confirmAction?.channel.status === 'stopped' ? '确认启用通道' : '确认停用通道'; - const confirmDescription = confirmAction?.type === 'delete' - ? '删除后该通道将从列表移除,副本通道的本地记录也会同步清理。' - : confirmAction?.type === 'copy' + const confirmDescription = confirmAction?.type === 'copy' ? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。' : confirmAction?.channel.status === 'stopped' ? '启用后通道会进入连接中状态,后续可继续观察网关连接。' @@ -651,7 +638,7 @@ export function AdminChannelsPage() { - + void loadChannels()} portal="admin" targetId={channel.id} targetType="channel" />
))} @@ -691,7 +678,7 @@ export function AdminChannelsPage() { footer={( <> - + )} onClose={() => setConfirmAction(null)} diff --git a/src/apps/admin/AdminCustomersPage.tsx b/src/apps/admin/AdminCustomersPage.tsx index 28070cd..f6b9540 100644 --- a/src/apps/admin/AdminCustomersPage.tsx +++ b/src/apps/admin/AdminCustomersPage.tsx @@ -2,8 +2,8 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react'; import { adminApi, type TenantManagementRow } from '@/api/adminApi'; -import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui'; -import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency'; +import { Breadcrumb, Button, Input, ManualRechargeDialog, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui'; +import { formatCents } from '@/utils/currency'; type AdminCustomersPageProps = { basePath?: string; @@ -11,11 +11,6 @@ type AdminCustomersPageProps = { type CustomerRow = TenantManagementRow; -type RechargeForm = { - amount: string; - remark: string; -}; - function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) { return ( } onClose={onCancel} open title="操作确认"> @@ -24,13 +19,6 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan ); } -function emptyRechargeForm(): RechargeForm { - return { - amount: '', - remark: '', - }; -} - export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) { const navigate = useNavigate(); const [records, setRecords] = useState([]); @@ -39,9 +27,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto const [filters, setFilters] = useState({ name: '', status: 'all' }); const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null); const [rechargeTarget, setRechargeTarget] = useState(null); - const [rechargeForm, setRechargeForm] = useState(emptyRechargeForm); - const [rechargeError, setRechargeError] = useState(''); - const [recharging, setRecharging] = useState(false); const [error, setError] = useState(''); function loadData() { @@ -108,37 +93,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto function openRechargeModal(record: CustomerRow) { setRechargeTarget(record); - setRechargeForm(emptyRechargeForm()); - setRechargeError(''); - } - - function updateRechargeForm(key: K, value: RechargeForm[K]) { - setRechargeForm((current) => ({ ...current, [key]: value })); - setRechargeError(''); - } - - async function submitRecharge() { - if (!rechargeTarget) return; - const amount = Number(rechargeForm.amount); - if (!Number.isFinite(amount) || !isValidMoneyInput(rechargeForm.amount, { allowNegative: true, allowZero: false })) { - setRechargeError('请填写非 0 的充值金额,支持负数冲正'); - return; - } - setRecharging(true); - try { - await adminApi.createManualRecharge({ - tenantId: rechargeTarget.id, - amountCents: yuanToMoneyUnits(rechargeForm.amount), - remark: rechargeForm.remark, - }); - setRechargeTarget(null); - setRechargeForm(emptyRechargeForm()); - await loadData(); - } catch (failure) { - setRechargeError(failure instanceof Error ? failure.message : '企业充值失败'); - } finally { - setRecharging(false); - } } function submitConfirmAction() { @@ -191,28 +145,19 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto onConfirm={submitConfirmAction} /> ) : null} - {rechargeTarget ? ( - - - - - )} - onClose={() => setRechargeTarget(null)} - open - size="md" - title="企业人工充值" - > -
- - - updateRechargeForm('amount', event.target.value)} prefix="¥" required step="0.0001" type="number" value={rechargeForm.amount} /> -