fix: 修复未报备签名统计的中文编码兼容性
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { startOfDay, addDays } from './analytics-date';
|
import { startOfDay, addDays } from './analytics-date';
|
||||||
|
import { leadingSignatureSql } from './signature-extraction';
|
||||||
|
|
||||||
export type ActivityDimension = {
|
export type ActivityDimension = {
|
||||||
dimensionKey: string;
|
dimensionKey: string;
|
||||||
@@ -114,7 +115,7 @@ export async function unreportedRows(db: PrismaService, date: string) {
|
|||||||
}>
|
}>
|
||||||
>(Prisma.sql`
|
>(Prisma.sql`
|
||||||
WITH extracted AS (
|
WITH extracted AS (
|
||||||
SELECT m."tenantId",m."applicationId",SUBSTRING(m.content FROM '^【[^【】]+】') AS name
|
SELECT m."tenantId",m."applicationId",${leadingSignatureSql(Prisma.sql`m.content`)} AS name
|
||||||
FROM "SmsMessageRecord" m WHERE m."queuedAt">=${startOfDay(date)} AND m."queuedAt"<${startOfDay(addDays(date, 1))} AND m."signatureId" IS NULL
|
FROM "SmsMessageRecord" m WHERE m."queuedAt">=${startOfDay(date)} AND m."queuedAt"<${startOfDay(addDays(date, 1))} AND m."signatureId" IS NULL
|
||||||
) SELECT jsonb_build_array(e."tenantId",e."applicationId",e.name)::text AS "dimensionKey",
|
) SELECT jsonb_build_array(e."tenantId",e."applicationId",e.name)::text AS "dimensionKey",
|
||||||
e."tenantId",e."applicationId",e.name AS "signatureName",t.name AS "tenantName",a.name AS "applicationName",COUNT(*)::int AS "messageCount"
|
e."tenantId",e."applicationId",e.name AS "signatureName",t.name AS "tenantName",a.name AS "applicationName",COUNT(*)::int AS "messageCount"
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
/** Keep delimiters as whole strings: SQL_ASCII treats a Chinese character class as bytes. */
|
||||||
|
export function leadingSignatureSql(content: Prisma.Sql): Prisma.Sql {
|
||||||
|
// The lookahead excludes complete brackets without rejecting bytes shared by other Chinese characters.
|
||||||
|
// Noncapturing groups keep SUBSTRING returning the complete signature, including its brackets.
|
||||||
|
return Prisma.sql`SUBSTRING(${content} FROM '^【(?:(?!【|】).)+】')`;
|
||||||
|
}
|
||||||
@@ -139,7 +139,7 @@ describe('SignatureRetirementService dimensions', () => {
|
|||||||
});
|
});
|
||||||
const query = prisma.$queryRaw.mock.calls[0]?.[0] as { strings?: readonly string[] };
|
const query = prisma.$queryRaw.mock.calls[0]?.[0] as { strings?: readonly string[] };
|
||||||
const sql = query.strings?.join('?') ?? '';
|
const sql = query.strings?.join('?') ?? '';
|
||||||
expect(sql).toContain("SUBSTRING(message.content FROM '^【[^【】]+】')");
|
expect(sql).toContain("SUBSTRING(message.content FROM '^【(?:(?!【|】).)+】')");
|
||||||
expect(sql).toContain('message."signatureId" IS NULL');
|
expect(sql).toContain('message."signatureId" IS NULL');
|
||||||
expect(sql).toContain('FROM "SmsSignature" signature');
|
expect(sql).toContain('FROM "SmsSignature" signature');
|
||||||
expect(sql).toContain('signature."applicationId" = extracted.application_id');
|
expect(sql).toContain('signature."applicationId" = extracted.application_id');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { SignatureAnalyticsRead } from '../signature-analytics/analytics-read';
|
import { SignatureAnalyticsRead } from '../signature-analytics/analytics-read';
|
||||||
import { analyticsDate, analyticsPage, todayKey } from '../signature-analytics/analytics-date';
|
import { analyticsDate, analyticsPage, todayKey } from '../signature-analytics/analytics-date';
|
||||||
import { detectRetirement } from '../signature-analytics/retirement-batch';
|
import { detectRetirement } from '../signature-analytics/retirement-batch';
|
||||||
|
import { leadingSignatureSql } from '../signature-analytics/signature-extraction';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -568,7 +569,7 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
|||||||
SELECT
|
SELECT
|
||||||
message."tenantId" AS tenant_id,
|
message."tenantId" AS tenant_id,
|
||||||
message."applicationId" AS application_id,
|
message."applicationId" AS application_id,
|
||||||
SUBSTRING(message.content FROM '^【[^【】]+】') AS signature_name
|
${leadingSignatureSql(Prisma.sql`message.content`)} AS signature_name
|
||||||
FROM "SmsMessageRecord" message
|
FROM "SmsMessageRecord" message
|
||||||
WHERE message."queuedAt" >= ${shanghaiStart(date)}
|
WHERE message."queuedAt" >= ${shanghaiStart(date)}
|
||||||
AND message."queuedAt" < ${shanghaiStart(addDays(date, 1))}
|
AND message."queuedAt" < ${shanghaiStart(addDays(date, 1))}
|
||||||
|
|||||||
@@ -2392,3 +2392,8 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
|
|||||||
- 按已协商的 CMPP 2.0/2.1、3.0 编解码 60/71 字节回执;不得在 3.0 静默使用 2.0 布局。合法序号 0 可恢复下游原 Msg_Id,回绕不得覆盖在途记录;所有非零 CONNECT_RESP 状态失败,合法多号码大包可接收且异常长度有界拒绝。
|
- 按已协商的 CMPP 2.0/2.1、3.0 编解码 60/71 字节回执;不得在 3.0 静默使用 2.0 布局。合法序号 0 可恢复下游原 Msg_Id,回绕不得覆盖在途记录;所有非零 CONNECT_RESP 状态失败,合法多号码大包可接收且异常长度有界拒绝。
|
||||||
- 同一回执并发首次接收只形成一个持久化事实。历史应急记录缺失序号不自动补填,不再次触发通知、补发或账务。
|
- 同一回执并发首次接收只形成一个持久化事实。历史应急记录缺失序号不自动补填,不再次触发通知、补发或账务。
|
||||||
- 详细字段、迁移发布边界及验收依据:[CMPP 字段兼容性方案](cmpp-protocol-field-compatibility-remediation-20260920.md)。本轮授权并完成本地实现/验收/提交,环境上线另行执行标准发布。
|
- 详细字段、迁移发布边界及验收依据:[CMPP 字段兼容性方案](cmpp-protocol-field-compatibility-remediation-20260920.md)。本轮授权并完成本地实现/验收/提交,环境上线另行执行标准发布。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-21 未报备签名统计编码兼容修正
|
||||||
|
|
||||||
|
保持既有未报备签名定义:规范正文开头签名在当前企业应用有效签名库不存在才计入。实时统计和日报聚合须同时兼容现有SQL_ASCII及UTF8数据库;不能因汉字共享编码字节漏统计,也不能放宽空签名、嵌套括号或非规范开头限制。不更改分页、权限、归属、数据库编码或已冻结历史报表。根因和本地验收见[排查及修复记录](unreported-signature-diagnosis-20260921.md)。
|
||||||
|
|||||||
@@ -5730,3 +5730,15 @@ TC-SQA-01~14:真实隔离PG覆盖核心日期/日报/长短信/事务/分页
|
|||||||
| T17~T18 | inbound/protocol_fields_test.go、verify-protocol-fields.mjs | 2.0/3.0 本机 TCP 重连零序号回执与 ACK,历史缺失序号不回填且终态/通知不重开;通过 |
|
| T17~T18 | inbound/protocol_fields_test.go、verify-protocol-fields.mjs | 2.0/3.0 本机 TCP 重连零序号回执与 ACK,历史缺失序号不回填且终态/通知不重开;通过 |
|
||||||
|
|
||||||
后续上线须追加目标 schema/版本、真实供应商/客户互通、队列排空和账务对账证据。当前不标记目标环境完成,也不执行历史回执重投。
|
后续上线须追加目标 schema/版本、真实供应商/客户互通、队列排空和账务对账证据。当前不标记目标环境完成,也不执行历史回执重投。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-21 未报备签名编码回归
|
||||||
|
|
||||||
|
| 用例 | 场景与预期 |
|
||||||
|
|---|---|
|
||||||
|
| TC-UNREPORTED-ENC-001 | 真实SQL_ASCII、UTF8隔离库提取26种输入:目标签名、中文、ASCII、emoji、换行、引号、长文本完整提取;空签名、前导空白/BOM、未关闭和嵌套括号不提取,保留原规范。 |
|
||||||
|
| TC-UNREPORTED-ENC-002 | 64条【宜都市万商市场投资有限公司】无signatureId消息在本应用无签名、其他应用/企业有同名时仍聚合64;当前应用有效或待审核同名排除,deleted不排除;已有signatureId及无应用记录按原逻辑排除。 |
|
||||||
|
| TC-UNREPORTED-ENC-003 | 北京时间日初包含、次日日初排除、日末包含;实时和日报聚合一致,真实日报持久化与HTTP查询一致;13组按每页10条分两页,三类搜索、空结果、非法参数不改变既有语义。 |
|
||||||
|
| TC-UNREPORTED-ENC-004 | 已冻结日报在源记录改变后普通生成跳过、补建覆盖拒绝,原内容保持;隔离测试不创建任何供应商Submit。 |
|
||||||
|
|
||||||
|
执行:本机PostgreSQL16.14两编码各113项提交内迁移,verify-unreported-signature-encoding.mjs全部通过;真实Nest控制器/服务/PG,不含全局登录鉴权、浏览器及在线发送。完整结果和证据见[本轮记录](unreported-signature-diagnosis-20260921.md)。
|
||||||
|
|||||||
@@ -5210,3 +5210,14 @@ API全量81套/880项通过并达覆盖率门禁(语句67.73%、分支52.89%
|
|||||||
- 迁移:从已提交基线排除其他会话未提交 mobile-rule 迁移,新空库部署 113 迁移成功。七表 21 行小样本证明末表负值使此前 DDL 整体回滚,锁等待 5 秒失败回滚,旧值/NULL/7 索引保留;最终有效扩容约 106ms、WAL 81800 字节。生产规模、真实发布备份/恢复与 3.0 供应商非规范布局抽样待上线前补验,禁止把小样本当容量承诺。
|
- 迁移:从已提交基线排除其他会话未提交 mobile-rule 迁移,新空库部署 113 迁移成功。七表 21 行小样本证明末表负值使此前 DDL 整体回滚,锁等待 5 秒失败回滚,旧值/NULL/7 索引保留;最终有效扩容约 106ms、WAL 81800 字节。生产规模、真实发布备份/恢复与 3.0 供应商非规范布局抽样待上线前补验,禁止把小样本当容量承诺。
|
||||||
- 证据:.local-data/protocol-fields-20260920/ 保留原失败和复验日志;最终 real-chain-verified.log、migrate-verified.log、api-coverage-final.log、api-incremental.log、go-final.log、gocmpp-final.log。专用 Redis 5.0.14.1 的真实 Stream 验证有效,但低于 BullMQ 推荐 6.2;PG 并发 query 弃用提示记为存量,未修改依赖。测试脚本要求全新本机 cmpp_qa_* 数据库,避免固定最大 Msg_Id fixture 跨轮冲突。
|
- 证据:.local-data/protocol-fields-20260920/ 保留原失败和复验日志;最终 real-chain-verified.log、migrate-verified.log、api-coverage-final.log、api-incremental.log、go-final.log、gocmpp-final.log。专用 Redis 5.0.14.1 的真实 Stream 验证有效,但低于 BullMQ 推荐 6.2;PG 并发 query 弃用提示记为存量,未修改依赖。测试脚本要求全新本机 cmpp_qa_* 数据库,避免固定最大 Msg_Id fixture 跨轮冲突。
|
||||||
- 方案/需求/用例同步:docs/cmpp-protocol-field-compatibility-remediation-20260920.md 第 10 节完整映射 T01~T18。无前端/CSS改动、未执行浏览器回归;客户端 JSON 类型保持不变。业务状态与资金未通过迁移回填。后续所有相关进程须与 BigInt schema 配套,旧客户端不能读取高位值;应用回退不执行 bigint 缩回 integer。
|
- 方案/需求/用例同步:docs/cmpp-protocol-field-compatibility-remediation-20260920.md 第 10 节完整映射 T01~T18。无前端/CSS改动、未执行浏览器回归;客户端 JSON 类型保持不变。业务状态与资金未通过迁移回填。后续所有相关进程须与 BigInt schema 配套,旧客户端不能读取高位值;应用回退不执行 bigint 缩回 integer。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-21 未报备签名漏统计修复与本地提交
|
||||||
|
|
||||||
|
预生产001d5f2只读诊断发现:【宜都市万商市场投资有限公司】今日64条、当前应用有效同名签名0,SQL_ASCII使中文排除字符集合误判,旧提取全部NULL。用户随后授权修复并提交;新增共用SQL提取函数,实时与日报均按完整括号排除嵌套,保持原日期/应用/签名库/分页及冻结历史规则,无迁移。15:33:24预生产READ ONLY对照旧0/新64,不是线上发布。
|
||||||
|
|
||||||
|
本地真实PG16.14 UTF8/SQL_ASCII新空库各113项已提交迁移;26输入、64条案例、租户应用/签名状态、日期边界、13组两页、三类搜索、参数校验、真实日报落库和Nest控制器HTTP、冻结历史保护均通过,Submit=0。首次分页夹具用不支持的2返回400,改现有10并新库复验通过;旧运行目录initdb缺dict_snowball改用完整既有运行库通过,失败证据保留。API87套990项全通过,覆盖率68.05/53.73/69/70.77达到门禁,生产构建、定向lint/格式通过。
|
||||||
|
|
||||||
|
代码与需求口径一致,相关需求和TC-UNREPORTED-ENC-001~004同步。原始证据.local-data/unreported-signature-20260921/,详情见[诊断及修复记录](unreported-signature-diagnosis-20260921.md)。本轮仅本地修改和提交,无推送、部署或历史重算;不包含其他会话草稿。真实HTTP为最小控制器验收,未注册应用全局登录鉴权,未做浏览器或线上页面验收;前端/Go未变不重跑。预生产仍需单独授权部署后验收。
|
||||||
|
|
||||||
|
提交前从暂存区导出的精确候选另行通过API86套922项、覆盖率68.05/53.73/69.02/70.77和生产构建;候选产物在两种编码新库重新执行完整真实PG/HTTP/日报脚本通过。前述990项含他轮未提交测试,不作为本次精确提交测试数。最终candidate-api-coverage.log、candidate-build.log及real-*-candidate.log留档;专用PG已停止,数据保留。
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# 2026-09-21 预生产未报备签名漏统计只读诊断
|
||||||
|
|
||||||
|
核验时间:2026-09-21 15:09~15:12(北京时间)。授权仅排查,不修复、不部署、不改变线上数据。
|
||||||
|
|
||||||
|
## 当前证据
|
||||||
|
|
||||||
|
- 预生产 8.160.169.106,运行提交 `001d5f2cbdbd19e56c4de914bd3b94a04c3b19c5`,cmpp-api active;实际工作目录 `/opt/cmpp-platform/api`。本地 main 同提交,现有 origin/main 跟踪差异 0/0,未 fetch;暂存区为空,其他会话修改保留。
|
||||||
|
- 真实 PostgreSQL `cmpp_platform` 的 `server_encoding=SQL_ASCII`。查询全部在 READ ONLY 事务内,statement_timeout 12~15 秒,结束回滚。
|
||||||
|
- 今天【宜都市万商市场投资有限公司】共有64条业务短信,企业为“启瑞企业”,应用为“启瑞物业-From老平台”,入队时间北京时间12:12:21.238~14:19:39.114。全部 signatureId 为空,status=failed,errorCode=SIGNATURE,错误说明“短信内容未识别到已审核通过的签名”。未查询或输出收件号码及完整正文。
|
||||||
|
- 当前企业应用有效签名库精确同名记录为0。存在的是另一个名称【宜都市万商商业运营有限公司】,审核/报备均 approved,不能替代用户询问的签名。
|
||||||
|
- 正文从规范黑括号开始,前缀字节为 `e38090`。线上运行产物 `api/dist/signature-retirement/signature-retirement.service.js:520` 使用 `SUBSTRING(message.content FROM '^【[^【】]+】')`;该表达式对全部64条返回NULL。
|
||||||
|
- 只读对照表达式 `^【.+?】` 对同一64条均提取出完整目标签名。对照仅证明现有表达式漏识别,不代表替代表达式已满足异常/嵌套括号等全部验收规则。
|
||||||
|
|
||||||
|
## 根因及范围
|
||||||
|
|
||||||
|
当前数据库按 SQL_ASCII 处理字符串,现有排除字符集合 `[^【】]` 按字节处理中文括号及正文,误排除共享字节的汉字;提取结果为空后被 `signature_name IS NOT NULL` 条件剔除。因此该签名按业务规则应进入统计,实际因统计提取缺陷漏掉,非日期或已登记签名排除。
|
||||||
|
|
||||||
|
需求依据:first-version-development-requirements.md 未报备签名规则;测试依据:system-functional-test-cases.md 的 TC-SIGNATURE-RETIREMENT-022/023/028/029。同一表达式同时存在于实时查询 `api/src/signature-retirement/signature-retirement.service.ts` 与日报聚合 `api/src/signature-analytics/analytics-aggregate.ts`,修复须同时覆盖。历史冻结报表不应未经授权重算。
|
||||||
|
|
||||||
|
15:11~15:12扩大只读对照:今天signatureId为空1327条,原表达式识别629条,对照识别1318条;进一步限定真实企业/应用关联、有效签名库无同名记录,得到655条/61个签名与应用组合的潜在遗漏。此为对照候选量,未逐一核验异常正文,不能冒称全部应补计或历史总影响。
|
||||||
|
|
||||||
|
## 后续最小修复建议与边界
|
||||||
|
|
||||||
|
在保持规范开头签名、企业应用归属和有效签名库判定不变的前提下,修复实时及日报提取对 SQL_ASCII 的兼容;在真实 SQL_ASCII 与 UTF8 隔离数据库验证汉字、非规范开头、嵌套括号、已有签名、分页及数量。无需将迁移数据库编码作为本次最小修复。
|
||||||
|
|
||||||
|
本轮未调用HTTP登录后接口、未做浏览器页面验收;数据库和线上运行表达式已直接复现原因。无业务代码修改、提交、推送、部署、发短信、重投或配置变更,仅新增本记录及进度条目。初次前缀字符截取因SQL_ASCII字节截断产生UTF8转换错误,改为完整字节十六进制核验;扩大统计初次SQL列名歧义,限定x.name后通过,失败事务均未写入数据。
|
||||||
|
|
||||||
|
## 后续授权修复与本地验收(2026-09-21)
|
||||||
|
|
||||||
|
用户在诊断后明确授权“修复、提交代码”,不包含推送、部署或历史重算;上文仅排查边界保留为前一阶段记录。
|
||||||
|
|
||||||
|
最小修复:新增共用 `leadingSignatureSql`,实时查询和日报聚合统一采用 `^【(?:(?!【|】).)+】`。负向前瞻检查完整开闭括号,不再用中文排除字符集合;非捕获分组保证提取结果包含外侧括号。保留非空内容、开头定位、拒绝嵌套括号,以及原租户/应用/有效签名匹配、signatureId、日期、排序和分页逻辑。无数据库迁移、编码变更、接口字段或权限修改;现有需求不变,无需新增业务设计。
|
||||||
|
|
||||||
|
验证结果:
|
||||||
|
|
||||||
|
- PostgreSQL 16.14 两种编码各自新建本机隔离库,使用提交内的113项迁移(排除其他会话未提交的mobile-rule迁移)。`tools/testing/verify-unreported-signature-encoding.mjs` 在UTF8、SQL_ASCII均通过:26种直接提取输入,64条目标案例,日期起止边界、其他企业/应用同名、有效/待审核/已删除签名、已有signatureId、空应用、非规范及嵌套括号。
|
||||||
|
- 真实后端服务生成并持久化昨日日报,两日均得到13个预期聚合组;真实Nest控制器HTTP返回与数据库一致,验证每页10条的两页、三类搜索、空结果、参数化输入及非法日期/页码。测试仅注册所需控制器及服务,不注册整套应用全局认证/授权,不能冒称登录权限或浏览器已验收。没有启动发送/通知调度、Gateway、Redis或MinIO,实际Submit记录为0。
|
||||||
|
- 隔离库验证冻结日期已有报表不重算:源消息改变后普通生成跳过,补建覆盖被拒绝,历史计数仍为1。
|
||||||
|
- API全量87套/990项通过,覆盖率语句68.05%、分支53.73%、函数69%、行70.77%,达到现有门禁;新公共函数覆盖100%。API生产TypeScript构建、五个本轮代码文件ESLint(零错误零警告)、Prettier通过。
|
||||||
|
- 15:33:24北京时间在预生产READ ONLY事务中对同一64条再次对照:旧提取0、新提取64,结束回滚。仅查询表达式对照,不是部署或页面修复。
|
||||||
|
|
||||||
|
原始失败保留:首先使用的旧PostgreSQL16运行目录缺dict_snowball,initdb失败,改用现有完整pgsql运行目录后成功;初次HTTP分页验收传入不支持的pageSize=2,返回400。夹具改为13组、每页10条,并在两种编码新空库重新迁移和完整复验通过,未放宽业务规则。原失败库/日志保留。
|
||||||
|
|
||||||
|
证据目录:`.local-data/unreported-signature-20260921/`,包括 `real-utf8-verified.log`、`real-ascii-verified.log`、两份 `migrate-*-verified.log`、`api-coverage.log`、`build.log`、`lint.log`、`format.log`、`preprod-readonly-comparison.log`。复跑需API构建、全迁移的本机新空库,设置 `SIGNATURE_TEST_DATABASE_URL`(库名须以cmpp_qa_signature_encoding_开头),执行上述脚本;脚本拒绝远端/非隔离库及已有租户库,不自动清空数据。
|
||||||
|
|
||||||
|
本轮交付为本地代码、回归脚本、需求兼容说明、测试用例及实施记录;本地提交号以Git结果为准。未推送、未部署,预生产页面仍运行旧统计逻辑,冻结报表未修正。无前端/Gateway变更,未重跑前端、Go或线上登录页面验收。
|
||||||
|
|
||||||
|
提交前补充精确版本复验:从暂存区导出独立候选,仅共享已安装依赖,未带入其他会话未提交代码/测试。候选API全量86套/922项通过,覆盖率68.05/53.73/69.02/70.77达门禁,生产构建通过;候选编译产物在两种编码的另外两个全新库完整复跑上述真实PG/日报/HTTP脚本均通过。前文87套/990项为工作区验证,包含他轮未提交测试,不能用作本次精确提交测试数。最终证据为 `candidate-api-coverage.log`、`candidate-build.log`、`real-utf8-candidate.log`、`real-ascii-candidate.log` 及对应迁移日志。专用本机PG集群已停止,数据和日志保留。暂存脚本初次读取较大进度文档达到Node默认输出缓冲限制,检查已暂存范围后提高本地缓冲并续接,没有覆盖其他会话内容。
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
// Run once per encoding against a fresh, fully migrated local QA database.
|
||||||
|
const url = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || '');
|
||||||
|
assert(['127.0.0.1', 'localhost'].includes(url.hostname));
|
||||||
|
assert(url.pathname.startsWith('/cmpp_qa_signature_encoding_'));
|
||||||
|
process.env.DATABASE_URL = url.toString();
|
||||||
|
process.env.CMPP_PROCESS_ROLE = 'api';
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
const require = createRequire(new URL('../../api/package.json', import.meta.url));
|
||||||
|
require('reflect-metadata');
|
||||||
|
const { Prisma } = require('@prisma/client');
|
||||||
|
const { Module } = require('@nestjs/common');
|
||||||
|
const { NestFactory } = require('@nestjs/core');
|
||||||
|
const { PrismaService } = require('./dist/prisma/prisma.service');
|
||||||
|
const { leadingSignatureSql } = require('./dist/signature-analytics/signature-extraction');
|
||||||
|
const { unreportedRows } = require('./dist/signature-analytics/analytics-aggregate');
|
||||||
|
const { SignatureRetirementService } = require('./dist/signature-retirement/signature-retirement.service');
|
||||||
|
const { SignatureRetirementController } = require('./dist/signature-retirement/signature-retirement.controller');
|
||||||
|
const { SignatureAnalyticsService } = require('./dist/signature-analytics/signature-analytics.service');
|
||||||
|
const { todayKey, addDays, startOfDay } = require('./dist/signature-analytics/analytics-date');
|
||||||
|
const db = new PrismaService();
|
||||||
|
const service = new SignatureRetirementService(db);
|
||||||
|
const writer = new SignatureAnalyticsService(db);
|
||||||
|
const today = todayKey();
|
||||||
|
const yesterday = addDays(today, -1);
|
||||||
|
const target = '【宜都市万商市场投资有限公司】';
|
||||||
|
const pass = (name) => console.log('PASS', name);
|
||||||
|
let app;
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.equal(await db.tenant.count(), 0, 'A fresh isolated database is required; never clear existing data');
|
||||||
|
const [{ server_encoding: encoding }] = await db.$queryRaw`SHOW server_encoding`;
|
||||||
|
assert(['UTF8', 'SQL_ASCII'].includes(encoding));
|
||||||
|
console.log('Database encoding:', encoding);
|
||||||
|
const cases = [
|
||||||
|
[target + '正文', target],
|
||||||
|
['【湘银物业】正文', '【湘银物业】'],
|
||||||
|
['【A】正文', '【A】'],
|
||||||
|
['【天地中文—123】正文', '【天地中文—123】'],
|
||||||
|
['【😀𠀀】正文', '【😀𠀀】'],
|
||||||
|
["【O'Reilly\\示例】正文", "【O'Reilly\\示例】"],
|
||||||
|
['【一\n二】正文', '【一\n二】'],
|
||||||
|
['【一\r\n二】正文', '【一\r\n二】'],
|
||||||
|
['【 】正文', '【 】'],
|
||||||
|
['【第一】正文【第二】', '【第一】'],
|
||||||
|
['【第一】【第二】', '【第一】'],
|
||||||
|
['【第二】', '【第二】'],
|
||||||
|
['【' + '中'.repeat(10000) + '】正文', '【' + '中'.repeat(10000) + '】'],
|
||||||
|
['', null],
|
||||||
|
['无签名正文', null],
|
||||||
|
[' 正文【签名】', null],
|
||||||
|
[' 【签名】正文', null],
|
||||||
|
['\n【签名】正文', null],
|
||||||
|
['\uFEFF【签名】正文', null],
|
||||||
|
['[签名]正文', null],
|
||||||
|
['【】正文', null],
|
||||||
|
['【未关闭', null],
|
||||||
|
['】错误【签名】', null],
|
||||||
|
['【外【内】外】', null],
|
||||||
|
['【【嵌套】', null],
|
||||||
|
["'; SELECT 1; --【签名】", null],
|
||||||
|
];
|
||||||
|
const extracted = await db.$queryRaw(Prisma.sql`
|
||||||
|
SELECT v.value AS input,${leadingSignatureSql(Prisma.sql`v.value`)} AS signature
|
||||||
|
FROM jsonb_array_elements_text(${JSON.stringify(cases.map(([input]) => input))}::jsonb) WITH ORDINALITY AS v(value,position)
|
||||||
|
ORDER BY v.position`);
|
||||||
|
assert.deepEqual(
|
||||||
|
extracted.map((r) => [r.input, r.signature]),
|
||||||
|
cases,
|
||||||
|
);
|
||||||
|
const [old] = await db.$queryRaw(Prisma.sql`SELECT SUBSTRING(${target} FROM '^【[^【】]+】') AS signature`);
|
||||||
|
assert.equal(old.signature, encoding === 'SQL_ASCII' ? null : target);
|
||||||
|
pass('26 extraction cases; original SQL_ASCII failure reproduced; UTF8 semantics retained');
|
||||||
|
|
||||||
|
const tenant = await db.tenant.create({ data: { name: '编码验收企业', code: randomUUID() } });
|
||||||
|
const foreign = await db.tenant.create({ data: { name: '其他企业', code: randomUUID() } });
|
||||||
|
const createApplication = (owner, name) =>
|
||||||
|
db.smsApplication.create({
|
||||||
|
data: {
|
||||||
|
tenantId: owner.id,
|
||||||
|
name,
|
||||||
|
cmppAccount: randomUUID(),
|
||||||
|
cmppEnterpriseCode: '000001',
|
||||||
|
secretHash: 'isolated-no-login',
|
||||||
|
interfaceEnabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const own = await createApplication(tenant, '应用甲');
|
||||||
|
const other = await createApplication(tenant, '应用乙');
|
||||||
|
const foreignApp = await createApplication(foreign, '其他企业应用');
|
||||||
|
const signature = (application, name, auditStatus = 'approved') =>
|
||||||
|
db.smsSignature.create({
|
||||||
|
data: { tenantId: application.tenantId, applicationId: application.id, name, auditStatus },
|
||||||
|
});
|
||||||
|
const registered = await signature(own, '【已登记无通道报备】');
|
||||||
|
await signature(own, '【待审核签名】', 'pending');
|
||||||
|
await signature(own, '【已删除签名】', 'deleted');
|
||||||
|
await signature(other, target);
|
||||||
|
await signature(foreignApp, target);
|
||||||
|
await signature(own, '【跨应用隔离】');
|
||||||
|
const message = (application, content, queuedAt, signatureId = null) => ({
|
||||||
|
messageId: randomUUID(),
|
||||||
|
tenantId: application.tenantId,
|
||||||
|
applicationId: application.id,
|
||||||
|
signatureId,
|
||||||
|
phoneNumber: '13800000000',
|
||||||
|
content,
|
||||||
|
queuedAt,
|
||||||
|
status: 'failed',
|
||||||
|
errorCode: 'SIGNATURE',
|
||||||
|
});
|
||||||
|
const expected = [
|
||||||
|
{ applicationId: own.id, signatureName: target, messageCount: 64 },
|
||||||
|
{ applicationId: own.id, signatureName: '【已删除签名】', messageCount: 1 },
|
||||||
|
{ applicationId: other.id, signatureName: '【跨应用隔离】', messageCount: 1 },
|
||||||
|
...Array.from({ length: 10 }, (_, i) => ({
|
||||||
|
applicationId: other.id,
|
||||||
|
signatureName: `【分页${i}】`,
|
||||||
|
messageCount: 1,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const summarize = (items) =>
|
||||||
|
items
|
||||||
|
.map(({ applicationId, signatureName, messageCount }) => ({ applicationId, signatureName, messageCount }))
|
||||||
|
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
|
||||||
|
for (const date of [today, yesterday]) {
|
||||||
|
const start = startOfDay(date);
|
||||||
|
const end = startOfDay(addDays(date, 1));
|
||||||
|
const rows = Array.from({ length: 64 }, (_, i) =>
|
||||||
|
message(own, target + '隔离正文', i === 63 ? new Date(end.getTime() - 1) : start),
|
||||||
|
);
|
||||||
|
rows.push(
|
||||||
|
message(own, '【已删除签名】正文', start),
|
||||||
|
message(other, '【跨应用隔离】正文', start),
|
||||||
|
message(own, registered.name + '正文', start),
|
||||||
|
message(own, '【待审核签名】正文', start),
|
||||||
|
message(other, target + '正文', start),
|
||||||
|
message(foreignApp, target + '正文', start),
|
||||||
|
message(own, '【已关联记录】正文', start, registered.id),
|
||||||
|
{ ...message(own, '【无应用】正文', start), applicationId: null },
|
||||||
|
...expected.slice(3).map((item) => message(other, item.signatureName + '正文', start)),
|
||||||
|
...cases.filter(([, expectedSignature]) => expectedSignature === null).map(([text]) => message(own, text, start)),
|
||||||
|
);
|
||||||
|
await db.smsMessageRecord.createMany({ data: rows });
|
||||||
|
assert.deepEqual(summarize((await service.unreportedSignaturesLive({ date })).items), summarize(expected));
|
||||||
|
assert.deepEqual(summarize(await unreportedRows(db, date)), summarize(expected));
|
||||||
|
}
|
||||||
|
await db.smsMessageRecord.createMany({
|
||||||
|
data: [
|
||||||
|
message(own, '【下一天】正文', startOfDay(addDays(today, 1))),
|
||||||
|
message(own, '【前一天】正文', new Date(startOfDay(yesterday).getTime() - 1)),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.deepEqual(summarize((await service.unreportedSignatures({ date: today })).items), summarize(expected));
|
||||||
|
const generated = await writer.generate(yesterday);
|
||||||
|
assert.equal(generated.skipped, false);
|
||||||
|
const historical = await service.unreportedSignatures({ date: yesterday });
|
||||||
|
assert.equal(historical.dataSource, 'report');
|
||||||
|
assert.deepEqual(summarize(historical.items), summarize(expected));
|
||||||
|
assert.equal(await db.unreportedSignatureDaily.count(), expected.length);
|
||||||
|
pass(
|
||||||
|
'64-message reproduction, date boundaries, tenant/application isolation, existing/deleted/linked signatures, real daily persistence',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Real controller and service over HTTP, without the application schedulers or sending processes.
|
||||||
|
// Authentication is unchanged and belongs to the full application, not this isolated controller harness.
|
||||||
|
class Harness {}
|
||||||
|
Module({
|
||||||
|
controllers: [SignatureRetirementController],
|
||||||
|
providers: [
|
||||||
|
{ provide: PrismaService, useValue: db },
|
||||||
|
{ provide: SignatureRetirementService, useValue: service },
|
||||||
|
],
|
||||||
|
})(Harness);
|
||||||
|
app = await NestFactory.create(Harness, { logger: false });
|
||||||
|
await app.listen(0, '127.0.0.1');
|
||||||
|
const base = await app.getUrl();
|
||||||
|
const get = async (query, status = 200) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`${base}/admin/signature-retirement/unreported-signatures?${new URLSearchParams(query)}`,
|
||||||
|
{ signal: AbortSignal.timeout(15000) },
|
||||||
|
);
|
||||||
|
assert.equal(response.status, status);
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
for (const date of [today, yesterday]) {
|
||||||
|
const first = await get({ date, page: '1', pageSize: '10' });
|
||||||
|
const second = await get({ date, page: '2', pageSize: '10' });
|
||||||
|
assert.equal(first.total, expected.length);
|
||||||
|
assert.equal(second.total, expected.length);
|
||||||
|
assert.deepEqual(summarize([...first.items, ...second.items]), summarize(expected));
|
||||||
|
for (const keyword of ['宜都市万商市场投资有限公司', '应用甲', '编码验收企业']) {
|
||||||
|
const result = await get({ date, keyword });
|
||||||
|
const wanted = keyword.startsWith('宜都')
|
||||||
|
? expected.slice(0, 1)
|
||||||
|
: keyword === '应用甲'
|
||||||
|
? expected.slice(0, 2)
|
||||||
|
: expected;
|
||||||
|
assert.deepEqual(summarize(result.items), summarize(wanted));
|
||||||
|
}
|
||||||
|
assert.equal((await get({ date, keyword: '不存在的签名' })).total, 0);
|
||||||
|
assert.equal((await get({ date, keyword: "' OR 1=1 --" })).total, 0);
|
||||||
|
}
|
||||||
|
await get({ date: '2026-02-30' }, 400);
|
||||||
|
await get({ date: today, page: '0' }, 400);
|
||||||
|
pass(
|
||||||
|
'real HTTP live/report results, independent pagination, signature/company/application search, empty results and invalid parameters',
|
||||||
|
);
|
||||||
|
|
||||||
|
const frozenDate = addDays(today, -5);
|
||||||
|
await db.smsMessageRecord.create({ data: message(own, target, startOfDay(frozenDate)) });
|
||||||
|
await writer.generate(frozenDate, true);
|
||||||
|
const before = await service.unreportedSignatures({ date: frozenDate });
|
||||||
|
await db.smsMessageRecord.create({ data: message(own, target, startOfDay(frozenDate)) });
|
||||||
|
assert.equal((await writer.generate(frozenDate)).skipped, true);
|
||||||
|
await assert.rejects(() => writer.generate(frozenDate, true), /历史补建不得覆盖/);
|
||||||
|
const after = await service.unreportedSignatures({ date: frozenDate });
|
||||||
|
assert.deepEqual(after.items, before.items);
|
||||||
|
assert.equal(after.items[0].messageCount, 1);
|
||||||
|
pass('frozen history stays unchanged; no automatic historical rebuild');
|
||||||
|
assert.equal(await db.smsSubmitRecord.count(), 0);
|
||||||
|
pass('no supplier submissions created');
|
||||||
|
} finally {
|
||||||
|
if (app) await app.close();
|
||||||
|
await db.$disconnect();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user