fix: 限制有效签名名称唯一并保留批量导入补资料

This commit is contained in:
hectorzhao
2026-09-17 16:49:58 +08:00
parent 5e4d644788
commit 1676cfe622
13 changed files with 711 additions and 174 deletions
@@ -0,0 +1,24 @@
BEGIN;
-- Preserve every historical record. A conflicting installation must be reviewed before release.
LOCK TABLE "SmsSignature" IN SHARE ROW EXCLUSIVE MODE;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM "SmsSignature"
WHERE "auditStatus" NOT IN ('deleted', 'disabled')
GROUP BY "tenantId", "applicationId", "name" HAVING count(*) > 1
) THEN
RAISE EXCEPTION 'Cannot enforce signature uniqueness: duplicate active signatures exist; review tenantId/applicationId/name groups without deleting or merging automatically';
END IF;
END $$;
CREATE UNIQUE INDEX "SmsSignature_active_application_name_key"
ON "SmsSignature" ("tenantId", "applicationId", "name")
WHERE "applicationId" IS NOT NULL AND "auditStatus" NOT IN ('deleted', 'disabled');
CREATE UNIQUE INDEX "SmsSignature_active_unbound_name_key"
ON "SmsSignature" ("tenantId", "name")
WHERE "applicationId" IS NULL AND "auditStatus" NOT IN ('deleted', 'disabled');
COMMIT;
+2
View File
@@ -776,6 +776,8 @@ model ReportNotificationRead {
}
model SmsSignature {
// Active name uniqueness (including null applicationId) is enforced by two partial SQL indexes.
// Owned by migration 20260917120000_signature_active_name_unique; do not replace with @@unique.
id String @id @default(cuid())
tenantId String
applicationId String?
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { FilesService } from '../files/files.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsConfigService } from '../sms-config/sms-config.service';
import { SignatureNameConflict } from '../sms-config/signature-uniqueness';
import type { ImportCommitDto, ImportMapping, PagedQuery, ReviewImportItemsDto } from './report-materials.contracts';
import {
normalizePage,
@@ -310,6 +311,19 @@ export class ReportImportReviewService {
return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures };
}
private async findImportSignature(tenantId: string, applicationId: string | undefined, name: string) {
const where = { tenantId, applicationId: applicationId ?? null, name };
return (
(await this.prisma.smsSignature.findFirst({
where: { ...where, auditStatus: { notIn: ['deleted', 'disabled'] } },
})) ??
this.prisma.smsSignature.findFirst({
where: { ...where, auditStatus: 'disabled' },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
);
}
async stageSignatureRow(
tenantId: string,
applicationId: string | undefined,
@@ -320,9 +334,7 @@ export class ReportImportReviewService {
if (!name) throw new Error('缺少短信签名');
const purpose = mappedCorePatchValue(mappings, values, 'purpose');
const signatureReportValues = dynamicValues(mappings, values);
const existing = await this.prisma.smsSignature.findFirst({
where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } },
});
const existing = await this.findImportSignature(tenantId, applicationId, name);
return {
operation: existing ? 'update' : 'create',
targetId: existing?.id,
@@ -443,20 +455,22 @@ export class ReportImportReviewService {
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
} else {
const duplicate = await this.prisma.smsSignature.findFirst({
where: {
tenantId: batch.tenantId,
applicationId: applicationId ?? null,
name,
auditStatus: { not: 'deleted' },
},
});
const duplicate = await this.findImportSignature(batch.tenantId, applicationId, name);
if (duplicate) {
targetId = duplicate.id;
await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId);
} else {
try {
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
targetId = created.id;
} catch (error) {
if (!(error instanceof SignatureNameConflict)) throw error;
// A concurrent create won. Imports continue to supplement the existing materials.
const current = await this.findImportSignature(batch.tenantId, applicationId, name);
if (!current) throw error;
targetId = current.id;
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
}
}
}
await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` });
+33 -15
View File
@@ -1,18 +1,18 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist';
import { assertMoneyUnits } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import { automaticDeliveryMode } from '../open-api/delivery-mode';
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
import type { ReviewDto, StatusChangeDto } from './sms-config.contracts';
import { SmsApplicationLifecycleService } from './application-lifecycle.service';
import { SmsReportValidationService } from './report-validation.service';
import { writeUniqueSignature } from './signature-uniqueness';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsAuditService {
constructor(private readonly prisma: PrismaService, private readonly lifecycle: SmsApplicationLifecycleService, private readonly reportValidation: SmsReportValidationService) {}
constructor(
private readonly prisma: PrismaService,
private readonly lifecycle: SmsApplicationLifecycleService,
private readonly reportValidation: SmsReportValidationService,
) {}
listAuditRecords(targetType?: string, targetId?: string) {
return this.prisma.auditRecord.findMany({
where: {
@@ -48,12 +48,21 @@ export class SmsAuditService {
throw new NotFoundException('Signature not found');
}
const status = data.status ?? 'deleted';
const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: status } });
await this.lifecycle.writeOperationLog(signature.tenantId, data.operatorId, `sms_signature.${status}`, 'sms_signature', signatureId, {
const updated = await writeUniqueSignature(this.prisma, { ...signature, auditStatus: status }, () =>
this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: status } }),
);
await this.lifecycle.writeOperationLog(
signature.tenantId,
data.operatorId,
`sms_signature.${status}`,
'sms_signature',
signatureId,
{
statusBefore: signature.auditStatus,
statusAfter: status,
reason: data.reason,
});
},
);
return updated;
}
@@ -64,11 +73,18 @@ export class SmsAuditService {
}
const status = data.status ?? 'deleted';
const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: status } });
await this.lifecycle.writeOperationLog(template.tenantId, data.operatorId, `sms_template.${status}`, 'sms_template', templateId, {
await this.lifecycle.writeOperationLog(
template.tenantId,
data.operatorId,
`sms_template.${status}`,
'sms_template',
templateId,
{
statusBefore: template.auditStatus,
statusAfter: status,
reason: data.reason,
});
},
);
return updated;
}
@@ -79,13 +95,15 @@ export class SmsAuditService {
}
const reviewerId = await this.resolveReviewerId(data.reviewerId);
const updated = await this.prisma.smsSignature.update({
const updated = await writeUniqueSignature(this.prisma, { ...signature, auditStatus: statusAfter }, () =>
this.prisma.smsSignature.update({
where: { id: signatureId },
data: {
auditStatus: statusAfter,
rejectReason: statusAfter === 'rejected' ? data.reason : null,
},
});
}),
);
await this.createAuditRecord({
tenantId: signature.tenantId,
targetType: 'sms_signature',
@@ -0,0 +1,103 @@
import { Prisma } from '@prisma/client';
import type { PrismaService } from '../prisma/prisma.service';
import { SignatureNameConflict, writeUniqueSignature } from './signature-uniqueness';
describe('active signature uniqueness', () => {
const identity = { tenantId: 'tenant', applicationId: 'app', name: '【测试】' };
function fixture() {
const findFirst = jest.fn().mockResolvedValue(null);
return { findFirst, prisma: { smsSignature: { findFirst } } as unknown as PrismaService };
}
it('rejects duplicates before writing and returns a business conflict', async () => {
const { prisma, findFirst } = fixture();
findFirst.mockResolvedValue({ id: 'existing' });
const write = jest.fn();
await expect(writeUniqueSignature(prisma, identity, write)).rejects.toBeInstanceOf(SignatureNameConflict);
expect(write).not.toHaveBeenCalled();
expect(new SignatureNameConflict().getStatus()).toBe(409);
});
it('scopes null applications exactly and excludes the current record', async () => {
const { prisma, findFirst } = fixture();
await expect(
writeUniqueSignature(prisma, { ...identity, id: 'self', applicationId: null }, async () => 'ok'),
).resolves.toBe('ok');
expect(findFirst).toHaveBeenCalledWith({
where: {
tenantId: 'tenant',
applicationId: null,
name: '【测试】',
auditStatus: { notIn: ['deleted', 'disabled'] },
id: { not: 'self' },
},
select: { id: true },
});
});
it.each(['draft', 'pending', 'approved', 'rejected'])('reserves names in %s status', async (auditStatus) => {
const { prisma, findFirst } = fixture();
await writeUniqueSignature(prisma, { ...identity, auditStatus }, async () => true);
expect(findFirst).toHaveBeenCalledTimes(1);
});
it.each(['deleted', 'disabled'])('releases names in %s status', async (auditStatus) => {
const { prisma, findFirst } = fixture();
await writeUniqueSignature(prisma, { ...identity, auditStatus }, async () => true);
expect(findFirst).not.toHaveBeenCalled();
});
it.each([
[['tenantId', 'applicationId', 'name']],
[['tenantId', 'name']],
['SmsSignature_active_application_name_key'],
['SmsSignature_active_unbound_name_key'],
])('maps only the signature identity race (%j)', async (target) => {
const { prisma } = fixture();
const error = new Prisma.PrismaClientKnownRequestError('duplicate', {
code: 'P2002',
clientVersion: 'test',
meta: { target },
});
await expect(
writeUniqueSignature(prisma, identity, async () => {
throw error;
}),
).rejects.toBeInstanceOf(SignatureNameConflict);
});
it('preserves unrelated database failures', async () => {
const { prisma } = fixture();
for (const error of [
new Error('offline'),
new Prisma.PrismaClientKnownRequestError('id', {
code: 'P2002',
clientVersion: 'test',
meta: { target: ['id'] },
}),
]) {
await expect(
writeUniqueSignature(prisma, identity, async () => {
throw error;
}),
).rejects.toBe(error);
}
});
it('recognizes the real Prisma pg adapter metadata and quoted identifiers', async () => {
const { prisma } = fixture();
const error = new Prisma.PrismaClientKnownRequestError('duplicate', {
code: 'P2002',
clientVersion: '7.9.0',
meta: {
modelName: 'SmsSignature',
driverAdapterError: { cause: { constraint: { fields: ['"tenantId"', '"applicationId"', 'name'] } } },
},
});
await expect(
writeUniqueSignature(prisma, identity, async () => {
throw error;
}),
).rejects.toBeInstanceOf(SignatureNameConflict);
});
});
@@ -0,0 +1,61 @@
import { ConflictException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { PrismaService } from '../prisma/prisma.service';
export class SignatureNameConflict extends ConflictException {
constructor() {
super('同一企业、同一应用下已存在同名有效签名,请修改已有签名资料');
}
}
type SignatureIdentity = {
id?: string;
tenantId: string;
applicationId?: string | null;
name: string;
auditStatus?: string;
};
function record(value: unknown): Record<string, unknown> {
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {};
}
/** The partial SQL indexes are authoritative when concurrent requests pass the precheck. */
export async function writeUniqueSignature<T>(
prisma: PrismaService,
identity: SignatureIdentity,
write: () => Promise<T>,
): Promise<T> {
if (!['deleted', 'disabled'].includes(identity.auditStatus ?? 'draft')) {
const duplicate = await prisma.smsSignature.findFirst({
where: {
tenantId: identity.tenantId,
applicationId: identity.applicationId ?? null,
name: identity.name,
auditStatus: { notIn: ['deleted', 'disabled'] },
...(identity.id ? { id: { not: identity.id } } : {}),
},
select: { id: true },
});
if (duplicate) throw new SignatureNameConflict();
}
try {
return await write();
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
// Prisma's pg adapter exposes the constraint under driverAdapterError.cause (Prisma 7).
const constraint = record(record(record(error.meta?.driverAdapterError).cause).constraint);
const target = error.meta?.target ?? constraint.fields;
const fields = Array.isArray(target)
? target.map((field: unknown) => (typeof field === 'string' ? field.replace(/^"|"$/g, '') : field))
: [];
if (
(fields.includes('tenantId') && fields.includes('name')) ||
(typeof target === 'string' && /^SmsSignature_active_(application|unbound)_name_key$/.test(target))
) {
throw new SignatureNameConflict();
}
}
throw error;
}
}
+27 -8
View File
@@ -1,3 +1,4 @@
import { writeUniqueSignature } from './signature-uniqueness';
import { selectDrainageReportTask } from '../common/drainage-report-task';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
@@ -766,7 +767,11 @@ export class SmsSignatureService {
data.drainageInfo,
);
const name = validateCompleteSmsSignature(data.name);
const signature = await this.prisma.smsSignature.create({
const signature = await writeUniqueSignature(
this.prisma,
{ ...data, name, auditStatus: options.initialAuditStatus },
() =>
this.prisma.smsSignature.create({
data: {
tenantId: data.tenantId,
applicationId: data.applicationId,
@@ -775,7 +780,8 @@ export class SmsSignatureService {
auditStatus: options.initialAuditStatus,
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
},
});
}),
);
await this.reportValidation.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo);
if (options.initialAuditStatus) {
await this.audit.createAuditRecord({
@@ -805,10 +811,11 @@ export class SmsSignatureService {
throw new NotFoundException('Signature not found');
}
await this.reportValidation.validateSignatureReportValues(
data.applicationId ?? signature.applicationId ?? undefined,
(data.applicationId !== undefined ? data.applicationId : signature.applicationId) ?? undefined,
data.drainageInfo,
);
const applicationId = data.applicationId ?? signature.applicationId ?? undefined;
const applicationId =
(data.applicationId !== undefined ? data.applicationId : signature.applicationId) ?? undefined;
const drainageInfo = data.drainageInfo
? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo)
: undefined;
@@ -822,7 +829,16 @@ export class SmsSignatureService {
const auditStatus =
options.initialAuditStatus ??
(materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus);
const updated = await this.prisma.smsSignature.update({
const updated = await writeUniqueSignature(
this.prisma,
{
...signature,
applicationId: applicationId ?? null,
name: name ?? signature.name,
auditStatus: auditStatus ?? signature.auditStatus,
},
() =>
this.prisma.smsSignature.update({
where: { id: signatureId },
data: {
applicationId: data.applicationId,
@@ -836,7 +852,8 @@ export class SmsSignatureService {
reportChangedAt: materialChanged ? new Date() : undefined,
},
include: { materials: true, tenant: true, application: true },
});
}),
);
await this.reportValidation.syncSignatureReportValues(
signatureId,
updated.applicationId ?? undefined,
@@ -897,10 +914,12 @@ export class SmsSignatureService {
throw new NotFoundException('Signature not found');
}
const updated = await this.prisma.smsSignature.update({
const updated = await writeUniqueSignature(this.prisma, { ...signature, auditStatus: 'pending' }, () =>
this.prisma.smsSignature.update({
where: { id: signatureId },
data: { auditStatus: 'pending', rejectReason: null },
});
}),
);
await this.audit.createAuditRecord({
tenantId: signature.tenantId,
targetType: 'sms_signature',
@@ -88,6 +88,7 @@ function createPrismaMock() {
count: jest.fn().mockResolvedValue(0),
},
smsSignature: {
findFirst: jest.fn().mockResolvedValue(null),
groupBy: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
findMany: jest.fn().mockResolvedValue([
@@ -2364,3 +2364,8 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
## 2026-09-17 首页V2实施修订
用户最终要求执行[首页方案](homepage-receipt-metrics-redesign-20260917.md)并提交推送:一行三块“今日业务/今日回执/今日营业状况”,原企业消费排行保留,在消费后增加今日返还金额列,不替换排行;企业详情、导出包含返还。统计按今日有效接收回执及原提交日T-3~T,长短信完整成功、缺片补计、业务去重与旧总体成功率按方案执行。此前V1返还区域替换解释与V2“不增返还列”均被本次明确指令替代。仅本地实现和验收,未部署。
## 2026-09-17 有效签名名称唯一性
同一企业、同一应用、相同完整签名名称只能存在一条有效签名;未绑定应用单独作为一个范围。有效状态包括草稿、待审、通过、驳回,停用及删除不占用名称;新增、改名、换应用、审核和恢复均不可绕过,接口及数据库同时防重。批量导入仍更新已有资料,保留未映射字段、用途及关联记录,并发创建后重查已有签名补资料。历史重复不自动删除或合并。设计见 [通道与报备方案](phase-4-channel-reporting-plan.md#2026-09-17-有效签名名称唯一性)。本轮授权代码修改及本地提交,不含推送和部署。
+11
View File
@@ -49,3 +49,14 @@
- 通道、通道组、路由规则、报备字段、报备任务基础接口存在。
- 报备导出/导入记录和报备状态同步接口存在。
- 阶段 4 进度文档记录验证结果。
## 2026-09-17 有效签名名称唯一性
本节补充签名新增、修改和状态恢复规则;批量导入仍遵循 [补资料方案](reporting-batch-import-records-remediation-plan-20260902.md) 的字段合并规则。
- 同一企业、同一应用下,完整签名名称精确相同时只能存在一条有效记录。有效指审核状态不是 `deleted``disabled`,包含草稿、待审、通过和驳回。未绑定应用作为独立范围,同企业未绑定应用的同名有效签名也唯一;不同企业、不同应用允许同名。
- 新增、改名、换应用、提交审核、审核及状态恢复均执行接口查重,修改排除自身;冲突返回 HTTP 409 和“同一企业、同一应用下已存在同名有效签名,请修改已有签名资料”。保留既有认证、租户校验、审核及报备资料行为,无新增权限、页面和短信链路变更。
- PostgreSQL 使用两个部分唯一索引:绑定应用的 `(tenantId, applicationId, name)`,未绑定应用的 `(tenantId, name)`,都排除停用及删除状态。并发以数据库最终约束为准,唯一冲突转为相同业务错误;Prisma schema 用注释指明 SQL 所有权,不用普通复合唯一键冒充部分索引。
- 迁移在事务和写锁内检查历史有效重名;发现冲突则明确报错、整体回滚,禁止自动删除、合并或改状态。发布前需只读盘点并另行确认历史治理,不把迁移阻断当作成功;本轮只本地提交,不部署。
- 批量导入仍是补资料:优先匹配同范围有效签名,其次沿用未删除的停用记录;原签名 ID、未映射字段、用途及关联记录保留。导入暂存后新增了同名签名时,审核阶段重新匹配并更新;并发创建冲突时重新读取有效记录转为资料更新,仅重试一次,不吞其他错误。已有指定目标不会暗中改写为另一条记录,恢复冲突明确失败。
- 验收覆盖新增/编辑/换应用/空应用/状态恢复、不同企业应用、并发创建、直接 SQL 防重、历史迁移失败回滚,以及导入暂存与审核后补资料保留。使用本机隔离 PostgreSQL 和真实服务/API,不发送短信,不改线上资料;线上历史数据与部署留作未验证项。
+17
View File
@@ -5661,3 +5661,20 @@ TC-SQA-0114:真实隔离PG覆盖核心日期/日报/长短信/事务/分页
### HOME0917 实施验收更新
最终UI保留企业消费排行并增加今日返还列,替代前述返还模块假设。HOME0917-01~12已通过对应本地真实PG/规则测试与故障注入;HOME0917-13三尺寸真实Nest/PG/Redis页面、按需请求、导出返还、详情、刷新和请求失败真值保留通过。HOME0917-14仅本地2020消息/6000新增片查询样本及110迁移通过,生产规模、线上队列影响和现场回退未执行。证据及边界见[方案第9节](homepage-receipt-metrics-redesign-20260917.md#9-2026-09-17-实施与验收结果)。所有真实截图使用本地隔离验收记录,不表示目标环境已上线。
## 2026-09-17 有效签名唯一性(TC-SIG-UQ-20260917
设计见 [通道与报备方案](phase-4-channel-reporting-plan.md#2026-09-17-有效签名名称唯一性)。验收脚本 `tools/testing/verify-signature-uniqueness.mjs` 仅允许本机 `cmpp_qa_signature_unique_*` 新库;先在 api 目录完成迁移和构建,设置 SIGNATURE_TEST_DATABASE_URL、SIGNATURE_TEST_REDIS_URL 后执行。
| 编号 | 场景及预期 |
| --- | --- |
| 01 | 运营端和客户端新增同企业同应用同名签名返回409;数据库只有一条;未登录401、伪造企业头403、非法签名400。 |
| 02 | 同名在不同企业/应用允许;未绑定应用的同名也唯一;自身原名更新允许,改为已占用名称/应用/空应用返回409;跨企业修改失败。 |
| 03 | 草稿、待审、通过、驳回占用名称;停用/删除释放;恢复、审核、重新提交不得绕过。 |
| 04 | 12路真实HTTP并发新增仅一次201,其余409;直接数据库新增命中绑定和空应用两个唯一索引;真实Prisma适配器P2002转业务冲突。 |
| 05 | 导入暂存识别update,审核后原ID、用途、未映射资料、链接保留;暂存后存在同名记录则更新,优先有效记录而非停用重名。 |
| 06 | 并发导入同一新名称收敛为同一ID,仅一条有效签名;只对名称冲突重查,其他异常继续报错。 |
| 07 | 迁移遇历史有效重复明确失败并回滚;全部历史记录保留,没有留下半套索引;新库全部111迁移成功。 |
本机真实Nest/PG/Redis覆盖01、02、0407及03恢复分支;四种有效/两种无效状态和错误分类另由定向单元测试覆盖。线上重复盘点、目标环境迁移及浏览器验收未执行,不等同已发布。
+12
View File
@@ -5161,3 +5161,15 @@ CUA本轮可用,实际后端文档三尺寸1600×1000/1366×768/390×844无页
- API854、前端163及覆盖率门禁通过;类型/生产构建/定向ESLint/样式/结构/CSS治理/包体检查通过。真实数据库故障与并发认领、四日及缺片/跨日/重复/退款边界通过;页面无明细预请求,错误保留真值、CSV/详情通过,页面异常0。本地样本2020消息/6000新增片,总览p95约55ms、明细约20ms,不推断线上CPU/容量。
- 原始证据 `.local-data/homepage-implementation-20260917/`。数据库端口、Redis旧RDB、浏览器两个定位失败已修正并保留原日志;Redis版本建议保留。尚未验证线上源时间覆盖、真实大数据与现场回退、完整供应商短信闭环。
- 起点main627fa7e、实际远端4eb7b16、暂存区空;67项原工作已备份并核对保护。仅本轮代码、迁移、方案/UI/截图、验收脚本和文档精确追加进入提交;版本/metrics/发布工具等不夹带。用户授权提交推送,不含部署;测试环境、预生产环境均未改动。最终提交与推送结果单独补记。
## 2026-09-17 有效签名唯一性与导入补资料兼容
- 授权:修改并本地提交。起点 main/实际远端均5e4d644,暂存区空;保留版本、metrics、发布工具及文档已有修改,本轮不推送、不部署。
- 根因:创建/更新无名称查重,数据库仅ID唯一;状态审核/恢复也可重新占用名称。按 [通道与报备设计](phase-4-channel-reporting-plan.md#2026-09-17-有效签名名称唯一性) 增加有效状态查重与两个部分唯一索引,空应用独立处理。新增/改名/换应用/审核/提交/恢复均覆盖,并发冲突返回409。补齐Prisma7 pg驱动真实P2002嵌套元信息识别,不吞其他数据库错误;audit.service清理原未使用导入并格式化,业务改动仅签名防重。
- 批量导入保留原ID和字段合并逻辑;优先有效记录,其次停用历史记录;审核时重查及并发创建冲突后补资料,未映射资料、用途、链接保留。已指定的目标不暗中替换为另一条签名。
- 验证:本机独立PG16、Redis16436、完整Nest真实API;新库cmpp_qa_signature_unique_v3全部111迁移成功。verify-signature-uniqueness.mjs七组通过(TC-SIG-UQ-20260917-0107),12路新增仅一条201/其余409,三个并发导入同一ID;两类索引直接写入阻断、状态恢复、空应用变更、租户隔离、真实驱动冲突和临时历史重名表迁移回滚均通过。未发送短信、未创建外部通知消费者业务、不修改线上资料。
- API全量80套868项及覆盖率门禁通过(语句67.90%、分支53.22%、函数68.78%、行70.68%;新防重模块语句/函数/行100%、分支96.66%)。API构建/类型检查、定向ESLint/Prettier及diff检查通过;最终仅清理旧未使用导入后补跑定向测试。前端/Go无改动,未重跑无关测试。
- 原始证据在忽略目录.local-data/signature-uniqueness-20260917/。保留初次测试夹具缺邮箱、映射字段键错误、根目录Prisma配置路径错误及真实驱动冲突识别失败的日志,修正后新库v3验收通过;Redis5.0版本建议及pg查询弃用提示仍存在,未扩展升级依赖。
- 历史兼容:线上有效重名尚未盘点;如存在则迁移明确失败并整体回滚,不能自动删除或合并。未执行测试/预生产部署、线上迁移、浏览器页面验收、文件上传/解析及MinIO回归;本轮真实导入验收覆盖资料暂存/审核应用层,文件解析路径未改变。
- 本地修改、测试和需求/设计/用例同步完成,提交前仅本轮文件及共享文档精确追加进入暂存;其他原始修改保留。提交号在交付回复中报告;推送、测试部署、预生产部署均未执行。
@@ -0,0 +1,250 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs';
const url = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || '');
assert(
['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_signature_unique_'),
'isolated local database required',
);
const redisUrl = new URL(process.env.SIGNATURE_TEST_REDIS_URL || 'redis://127.0.0.1:16436');
assert(['127.0.0.1', 'localhost'].includes(redisUrl.hostname), 'isolated local Redis required');
Object.assign(process.env, {
NODE_ENV: 'test',
DATABASE_URL: url.toString(),
REDIS_URL: redisUrl.toString(),
HTTP_API_MASTER_KEY: randomBytes(32).toString('hex'),
MINIO_ENDPOINT: '127.0.0.1:19400',
GATEWAY_CONTROL_URL: 'http://127.0.0.1:19401',
SIGNATURE_ANALYTICS_ENABLED: 'false',
HOME_DASHBOARD_ENABLED: 'false',
});
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
Object.defineProperty(BigInt.prototype, 'toJSON', {
value() {
return Number(this);
},
configurable: true,
});
const { NestFactory } = require('@nestjs/core');
const { AppModule } = require('./dist/app.module');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { SmsConfigService } = require('./dist/sms-config/sms-config.service');
const { SessionService } = require('./dist/auth/session.service');
const { UsersService } = require('./dist/users/users.service');
const { ReportImportReviewService } = require('./dist/report-materials/import-review.service');
const { FilesService } = require('./dist/files/files.service');
const { ReportImportParserService } = require('./dist/report-materials/import-parser.service');
const { SignatureNameConflict } = require('./dist/sms-config/signature-uniqueness');
const { Client } = require('pg');
const pass = (name) => console.log('PASS', name);
const app = await NestFactory.create(AppModule, { logger: ['error'] });
app.setGlobalPrefix('api');
try {
const db = app.get(PrismaService),
sms = app.get(SmsConfigService);
assert.equal(await db.smsSignature.count(), 0, 'fresh database required; do not delete existing data');
await app.listen(0, '127.0.0.1');
const base = (await app.getUrl()) + '/api';
const stamp = randomUUID().slice(0, 8);
const tenant = await db.tenant.create({ data: { name: '签名唯一性隔离企业', code: stamp } });
const other = await db.tenant.create({ data: { name: '另一个隔离企业', code: stamp + 'b' } });
const makeApplication = (n) =>
db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '隔离应用' + n,
cmppAccount: stamp + n,
cmppEnterpriseCode: 'test',
secretHash: 'unused',
interfaceEnabled: false,
},
});
const a = await makeApplication('a'),
b = await makeApplication('b');
const users = app.get(UsersService),
sessions = app.get(SessionService);
const admin = await users.create({
username: stamp,
email: stamp + '@example.invalid',
displayName: '隔离审核员',
password: randomBytes(24).toString('hex'),
roleCode: 'platform_admin',
});
const client = await users.create({
username: stamp + 'c',
email: stamp + 'c@example.invalid',
displayName: '隔离客户',
password: randomBytes(24).toString('hex'),
roleCode: 'enterprise_admin',
tenantId: tenant.id,
});
async function headersFor(user, portal) {
const session = await sessions.create(user.id, portal, 0);
return { 'content-type': 'application/json', cookie: `${sessions.cookieName(portal)}=${session.token}` };
}
const adminHeaders = await headersFor(admin, 'admin'),
clientHeaders = await headersFor(client, 'client');
const body = { tenantId: tenant.id, applicationId: a.id, name: '【唯一验证】' };
const request = (path, data, headers = adminHeaders, method = 'POST') =>
fetch(base + path, { method, headers, body: JSON.stringify(data) });
const create = (data, headers) => request('/admin/enterprise-signatures', data, headers);
assert.equal((await create(body, {})).status, 401);
const first = await create(body);
assert.equal(first.status, 201);
const signature = await first.json();
const duplicate = await create(body);
assert.equal(duplicate.status, 409);
assert.match((await duplicate.json()).message, /同名有效签名/);
const clientBody = { applicationId: a.id, name: body.name };
assert.equal((await request('/client/signatures', clientBody, clientHeaders)).status, 409);
assert.equal(
(await request('/client/signatures', clientBody, { ...clientHeaders, 'x-tenant-id': other.id })).status,
403,
);
assert.equal((await request('/client/signatures', { name: '无括号' }, clientHeaders)).status, 400);
pass('admin/client HTTP duplicate 409, authentication, tenant header isolation and format validation');
const unbound = await sms.createSignature({ tenantId: tenant.id, name: body.name });
await assert.rejects(() => sms.createSignature({ tenantId: tenant.id, name: body.name }), SignatureNameConflict);
const secondApp = await sms.createSignature({ ...body, applicationId: b.id });
await sms.createSignature({ tenantId: other.id, name: body.name });
await sms.updateSignature(signature.id, { name: body.name, purpose: '保留用途' });
await assert.rejects(() => sms.updateSignature(secondApp.id, { applicationId: a.id }), SignatureNameConflict);
await assert.rejects(() => sms.updateSignature(secondApp.id, { applicationId: null }), SignatureNameConflict);
const renamed = await sms.createSignature({ ...body, name: '【另一个名称】' });
assert.equal(
(await request('/admin/enterprise-signatures/' + renamed.id, { name: body.name }, adminHeaders, 'PUT')).status,
409,
);
await assert.rejects(
() => sms.updateSignature(signature.id, { purpose: '禁止跨租户' }, other.id),
/Signature not found/,
);
pass('self edit, rename, application change, null application scope and cross-tenant separation');
await sms.changeSignatureStatus(unbound.id, { status: 'disabled' });
const replacement = await sms.createSignature({ tenantId: tenant.id, name: body.name });
for (const restore of [
() => sms.changeSignatureStatus(unbound.id, { status: 'approved' }),
() => sms.approveSignature(unbound.id, { reviewerId: admin.id }),
() => sms.submitSignature(unbound.id),
]) {
await assert.rejects(restore, SignatureNameConflict);
}
await sms.changeSignatureStatus(replacement.id, { status: 'deleted' });
await sms.changeSignatureStatus(unbound.id, { status: 'approved' });
pass('disable/delete release names; status restoration, review and submit cannot bypass uniqueness');
const races = await Promise.all(Array.from({ length: 12 }, () => create({ ...body, name: '【并发名称】' })));
assert.equal(races.filter((r) => r.status === 201).length, 1);
assert.equal(races.filter((r) => r.status === 409).length, 11);
assert.equal(
await db.smsSignature.count({ where: { tenantId: tenant.id, applicationId: a.id, name: '【并发名称】' } }),
1,
);
for (const applicationId of [a.id, null]) {
await assert.rejects(
() => db.smsSignature.create({ data: { tenantId: tenant.id, applicationId, name: body.name } }),
(e) => e.code === 'P2002',
);
}
pass('12 concurrent real HTTP creates produce one row; both database indexes reject direct writes');
const files = app.get(FilesService),
importer = new ReportImportReviewService(db, files, sms, new ReportImportParserService(db, files, sms));
await sms.updateSignature(signature.id, {
drainageInfo: {
signatureReportValues: { old: '必须保留', changed: '旧值' },
links: [{ url: 'https://example.invalid' }],
},
});
const mappings = [
{
sourceHeader: '签名',
sourceColumnIndex: 0,
targetFieldCode: 'name',
targetKind: 'signatureName',
fieldType: 'string',
},
{
sourceHeader: '新资料',
sourceColumnIndex: 1,
targetFieldCode: 'changed',
targetKind: 'dynamic',
fieldType: 'string',
},
];
const staged = await importer.stageSignatureRow(tenant.id, a.id, mappings, { name: body.name, changed: '新值' });
assert.equal(staged.operation, 'update');
assert.equal(staged.targetId, signature.id);
const batch = { tenantId: tenant.id, applicationId: a.id, reportType: 'signature' };
const item = { reportType: 'signature', targetId: staged.targetId, payload: staged.payload };
assert.equal(await importer.applyImportItem(batch, item, admin.id), signature.id);
const saved = await db.smsSignature.findUniqueOrThrow({ where: { id: signature.id } });
assert.equal(saved.purpose, '保留用途');
assert.equal(saved.drainageInfo.signatureReportValues.old, '必须保留');
assert.equal(saved.drainageInfo.signatureReportValues.changed, '新值');
assert.equal(saved.drainageInfo.links.length, 1);
assert.equal(await importer.applyImportItem(batch, { ...item, targetId: null }, admin.id), signature.id);
// A stopped historical duplicate must not be chosen over the effective signature.
await db.smsSignature.create({ data: { ...body, auditStatus: 'disabled' } });
assert.equal(
(await importer.stageSignatureRow(tenant.id, a.id, mappings, { name: body.name })).targetId,
signature.id,
);
pass('batch staging/apply preserves ID, unmapped materials, purpose and links; delayed create becomes update');
const concurrentItem = { ...item, targetId: null, payload: { ...item.payload, name: '【并发导入】' } };
const importedIds = await Promise.all(
Array.from({ length: 3 }, () => importer.applyImportItem(batch, concurrentItem, admin.id)),
);
assert.equal(new Set(importedIds).size, 1);
assert.equal(
await db.smsSignature.count({ where: { tenantId: tenant.id, applicationId: a.id, name: '【并发导入】' } }),
1,
);
const { writeUniqueSignature } = require('./dist/sms-config/signature-uniqueness');
const raceIdentity = { ...body, name: '【数据库竞态】' };
await assert.rejects(
() =>
writeUniqueSignature(db, raceIdentity, async () => {
await db.smsSignature.create({ data: raceIdentity });
return db.smsSignature.create({ data: raceIdentity });
}),
SignatureNameConflict,
);
pass('concurrent batch imports converge to one signature and real P2002 maps to HTTP conflict');
// Exercise migration failure on a session-local shadow table, never modify application history.
const pg = new Client({ connectionString: url.toString() });
await pg.connect();
try {
await pg.query(
'CREATE TEMP TABLE "SmsSignature" ("tenantId" text, "applicationId" text, "name" text, "auditStatus" text)',
);
await pg.query(
`INSERT INTO "SmsSignature" VALUES ('history', NULL, 'duplicate', 'approved'), ('history', NULL, 'duplicate', 'pending')`,
);
const sql = readFileSync(
new URL('../../api/prisma/migrations/20260917120000_signature_active_name_unique/migration.sql', import.meta.url),
'utf8',
);
await assert.rejects(() => pg.query(sql), /duplicate active signatures exist/);
await pg.query('ROLLBACK');
assert.equal((await pg.query('SELECT count(*) FROM "SmsSignature"')).rows[0].count, '2');
assert.equal(
(await pg.query("SELECT count(*) FROM pg_indexes WHERE schemaname LIKE 'pg_temp_%' AND tablename='SmsSignature'"))
.rows[0].count,
'0',
);
} finally {
await pg.end();
}
pass('historical duplicates block migration and roll back without modifying records');
console.log('Signature uniqueness acceptance complete; no SMS, external notifications or live environment writes.');
} finally {
await app.close();
}