diff --git a/api/prisma/migrations/20260917120000_signature_active_name_unique/migration.sql b/api/prisma/migrations/20260917120000_signature_active_name_unique/migration.sql new file mode 100644 index 0000000..4955549 --- /dev/null +++ b/api/prisma/migrations/20260917120000_signature_active_name_unique/migration.sql @@ -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; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 80d46a3..20256ad 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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? diff --git a/api/src/report-materials/import-review.service.ts b/api/src/report-materials/import-review.service.ts index b24b008..21e3656 100644 --- a/api/src/report-materials/import-review.service.ts +++ b/api/src/report-materials/import-review.service.ts @@ -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 { - const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() }); - targetId = created.id; + 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}` }); diff --git a/api/src/sms-config/audit.service.ts b/api/src/sms-config/audit.service.ts index 96c0162..2cb373e 100644 --- a/api/src/sms-config/audit.service.ts +++ b/api/src/sms-config/audit.service.ts @@ -1,177 +1,195 @@ -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: { - targetType, - targetId, - }, - include: { - reviewer: { select: { id: true, username: true, displayName: true } }, - }, - orderBy: { createdAt: 'desc' }, - }); - } + return this.prisma.auditRecord.findMany({ + where: { + targetType, + targetId, + }, + include: { + reviewer: { select: { id: true, username: true, displayName: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } approveSignature(signatureId: string, data: ReviewDto) { - return this.reviewSignature(signatureId, 'approved', 'approve', data); - } + return this.reviewSignature(signatureId, 'approved', 'approve', data); + } rejectSignature(signatureId: string, data: ReviewDto) { - return this.reviewSignature(signatureId, 'rejected', 'reject', data); - } + return this.reviewSignature(signatureId, 'rejected', 'reject', data); + } approveTemplate(templateId: string, data: ReviewDto) { - return this.reviewTemplate(templateId, 'approved', 'approve', data); - } + return this.reviewTemplate(templateId, 'approved', 'approve', data); + } rejectTemplate(templateId: string, data: ReviewDto) { - return this.reviewTemplate(templateId, 'rejected', 'reject', data); - } + return this.reviewTemplate(templateId, 'rejected', 'reject', data); + } async changeSignatureStatus(signatureId: string, data: StatusChangeDto, tenantId?: string) { - const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature || (tenantId && signature.tenantId !== tenantId)) { - 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 signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); + if (!signature || (tenantId && signature.tenantId !== tenantId)) { + throw new NotFoundException('Signature not found'); + } + const status = data.status ?? 'deleted'; + 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; - } + }, + ); + return updated; + } async changeTemplateStatus(templateId: string, data: StatusChangeDto, tenantId?: string) { - const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); - if (!template || (tenantId && template.tenantId !== tenantId)) { - throw new NotFoundException('Template not found'); - } - 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, { + const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); + if (!template || (tenantId && template.tenantId !== tenantId)) { + throw new NotFoundException('Template not found'); + } + 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, + { statusBefore: template.auditStatus, statusAfter: status, reason: data.reason, - }); - return updated; - } + }, + ); + return updated; + } async reviewSignature(signatureId: string, statusAfter: string, action: string, data: ReviewDto) { - const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); - if (!signature) { - throw new NotFoundException('Signature not found'); - } - const reviewerId = await this.resolveReviewerId(data.reviewerId); + const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); + if (!signature) { + throw new NotFoundException('Signature not found'); + } + 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', - targetId: signatureId, - action, - statusBefore: signature.auditStatus, - statusAfter, - reason: data.reason, - reviewerId, - }); - return updated; - } + }), + ); + await this.createAuditRecord({ + tenantId: signature.tenantId, + targetType: 'sms_signature', + targetId: signatureId, + action, + statusBefore: signature.auditStatus, + statusAfter, + reason: data.reason, + reviewerId, + }); + return updated; + } async reviewDrainageInfo(itemId: string, statusAfter: string, action: string, data: ReviewDto) { - const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } }); - if (!item) throw new NotFoundException('Drainage info not found'); - if (!['pending', 'rejected'].includes(item.auditStatus)) { - throw new BadRequestException('只有待审核或已驳回的引流信息可以审核'); - } - if (statusAfter === 'rejected' && !data.reason?.trim()) { - throw new BadRequestException('驳回引流信息时必须填写原因'); - } - const reviewerId = await this.resolveReviewerId(data.reviewerId); - const updated = await this.prisma.smsDrainageInfo.update({ - where: { id: itemId }, - data: { - auditStatus: statusAfter, - rejectReason: statusAfter === 'rejected' ? data.reason?.trim() : null, - reviewedAt: new Date(), - }, - include: { tenant: true, signature: true, application: true }, - }); - await this.createAuditRecord({ - tenantId: item.tenantId, - targetType: 'sms_drainage_info', - targetId: itemId, - action, - statusBefore: item.auditStatus, - statusAfter, - reason: data.reason, - reviewerId, - }); - if (statusAfter === 'approved') await this.reportValidation.activateDrainageReporting(itemId); - else await this.reportValidation.suspendDrainageReporting(itemId, data.reason?.trim() || '引流信息运营审核驳回'); - return updated; + const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } }); + if (!item) throw new NotFoundException('Drainage info not found'); + if (!['pending', 'rejected'].includes(item.auditStatus)) { + throw new BadRequestException('只有待审核或已驳回的引流信息可以审核'); } + if (statusAfter === 'rejected' && !data.reason?.trim()) { + throw new BadRequestException('驳回引流信息时必须填写原因'); + } + const reviewerId = await this.resolveReviewerId(data.reviewerId); + const updated = await this.prisma.smsDrainageInfo.update({ + where: { id: itemId }, + data: { + auditStatus: statusAfter, + rejectReason: statusAfter === 'rejected' ? data.reason?.trim() : null, + reviewedAt: new Date(), + }, + include: { tenant: true, signature: true, application: true }, + }); + await this.createAuditRecord({ + tenantId: item.tenantId, + targetType: 'sms_drainage_info', + targetId: itemId, + action, + statusBefore: item.auditStatus, + statusAfter, + reason: data.reason, + reviewerId, + }); + if (statusAfter === 'approved') await this.reportValidation.activateDrainageReporting(itemId); + else await this.reportValidation.suspendDrainageReporting(itemId, data.reason?.trim() || '引流信息运营审核驳回'); + return updated; + } async reviewTemplate(templateId: string, statusAfter: string, action: string, data: ReviewDto) { - const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); - if (!template) { - throw new NotFoundException('Template not found'); - } - const reviewerId = await this.resolveReviewerId(data.reviewerId); - - const updated = await this.prisma.smsTemplate.update({ - where: { id: templateId }, - data: { - auditStatus: statusAfter, - rejectReason: statusAfter === 'rejected' ? data.reason : null, - }, - }); - await this.createAuditRecord({ - tenantId: template.tenantId, - targetType: 'sms_template', - targetId: templateId, - action, - statusBefore: template.auditStatus, - statusAfter, - reason: data.reason, - reviewerId, - }); - return updated; + const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); + if (!template) { + throw new NotFoundException('Template not found'); } + const reviewerId = await this.resolveReviewerId(data.reviewerId); + + const updated = await this.prisma.smsTemplate.update({ + where: { id: templateId }, + data: { + auditStatus: statusAfter, + rejectReason: statusAfter === 'rejected' ? data.reason : null, + }, + }); + await this.createAuditRecord({ + tenantId: template.tenantId, + targetType: 'sms_template', + targetId: templateId, + action, + statusBefore: template.auditStatus, + statusAfter, + reason: data.reason, + reviewerId, + }); + return updated; + } async resolveReviewerId(reviewerId?: string) { - if (!reviewerId) { - return undefined; - } - const reviewer = await this.prisma.user.findUnique({ where: { id: reviewerId }, select: { id: true } }); - if (!reviewer) { - throw new BadRequestException('reviewerId does not reference an existing user'); - } - return reviewerId; + if (!reviewerId) { + return undefined; } + const reviewer = await this.prisma.user.findUnique({ where: { id: reviewerId }, select: { id: true } }); + if (!reviewer) { + throw new BadRequestException('reviewerId does not reference an existing user'); + } + return reviewerId; + } createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) { - return this.prisma.auditRecord.create({ data }); - } + return this.prisma.auditRecord.create({ data }); + } } diff --git a/api/src/sms-config/signature-uniqueness.spec.ts b/api/src/sms-config/signature-uniqueness.spec.ts new file mode 100644 index 0000000..3b82283 --- /dev/null +++ b/api/src/sms-config/signature-uniqueness.spec.ts @@ -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); + }); +}); diff --git a/api/src/sms-config/signature-uniqueness.ts b/api/src/sms-config/signature-uniqueness.ts new file mode 100644 index 0000000..05eefad --- /dev/null +++ b/api/src/sms-config/signature-uniqueness.ts @@ -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 { + return value !== null && typeof value === 'object' ? (value as Record) : {}; +} + +/** The partial SQL indexes are authoritative when concurrent requests pass the precheck. */ +export async function writeUniqueSignature( + prisma: PrismaService, + identity: SignatureIdentity, + write: () => Promise, +): Promise { + 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; + } +} diff --git a/api/src/sms-config/signature.service.ts b/api/src/sms-config/signature.service.ts index ba8dd20..9744ffa 100644 --- a/api/src/sms-config/signature.service.ts +++ b/api/src/sms-config/signature.service.ts @@ -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,16 +767,21 @@ export class SmsSignatureService { data.drainageInfo, ); const name = validateCompleteSmsSignature(data.name); - const signature = await this.prisma.smsSignature.create({ - data: { - tenantId: data.tenantId, - applicationId: data.applicationId, - name, - purpose: data.purpose, - auditStatus: options.initialAuditStatus, - drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, - }, - }); + const signature = await writeUniqueSignature( + this.prisma, + { ...data, name, auditStatus: options.initialAuditStatus }, + () => + this.prisma.smsSignature.create({ + data: { + tenantId: data.tenantId, + applicationId: data.applicationId, + name, + purpose: data.purpose, + 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,21 +829,31 @@ export class SmsSignatureService { const auditStatus = options.initialAuditStatus ?? (materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus); - const updated = await this.prisma.smsSignature.update({ - where: { id: signatureId }, - data: { - applicationId: data.applicationId, - name, - purpose: data.purpose, - auditStatus, - rejectReason: auditStatus === 'pending' || auditStatus === 'approved' ? null : undefined, - drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, - materialVersion: materialChanged ? { increment: 1 } : undefined, - pendingReport: materialChanged ? true : undefined, - reportChangedAt: materialChanged ? new Date() : undefined, + const updated = await writeUniqueSignature( + this.prisma, + { + ...signature, + applicationId: applicationId ?? null, + name: name ?? signature.name, + auditStatus: auditStatus ?? signature.auditStatus, }, - include: { materials: true, tenant: true, application: true }, - }); + () => + this.prisma.smsSignature.update({ + where: { id: signatureId }, + data: { + applicationId: data.applicationId, + name, + purpose: data.purpose, + auditStatus, + rejectReason: auditStatus === 'pending' || auditStatus === 'approved' ? null : undefined, + drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined, + materialVersion: materialChanged ? { increment: 1 } : undefined, + pendingReport: materialChanged ? true : undefined, + 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({ - where: { id: signatureId }, - data: { auditStatus: 'pending', rejectReason: null }, - }); + 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', diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index eb0f6ae..4598b55 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -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([ diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index bfbccb0..ab95474 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -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-有效签名名称唯一性)。本轮授权代码修改及本地提交,不含推送和部署。 diff --git a/docs/phase-4-channel-reporting-plan.md b/docs/phase-4-channel-reporting-plan.md index 0fc0e6c..05f59f9 100644 --- a/docs/phase-4-channel-reporting-plan.md +++ b/docs/phase-4-channel-reporting-plan.md @@ -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,不发送短信,不改线上资料;线上历史数据与部署留作未验证项。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index c403cdb..21766a7 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5661,3 +5661,20 @@ TC-SQA-01~14:真实隔离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、04~07及03恢复分支;四种有效/两种无效状态和错误分类另由定向单元测试覆盖。线上重复盘点、目标环境迁移及浏览器验收未执行,不等同已发布。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index b79a75a..b28c42d 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -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-01~07),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回归;本轮真实导入验收覆盖资料暂存/审核应用层,文件解析路径未改变。 +- 本地修改、测试和需求/设计/用例同步完成,提交前仅本轮文件及共享文档精确追加进入暂存;其他原始修改保留。提交号在交付回复中报告;推送、测试部署、预生产部署均未执行。 diff --git a/tools/testing/verify-signature-uniqueness.mjs b/tools/testing/verify-signature-uniqueness.mjs new file mode 100644 index 0000000..8f54a32 --- /dev/null +++ b/tools/testing/verify-signature-uniqueness.mjs @@ -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(); +}