fix: 限制有效签名名称唯一并保留批量导入补资料
This commit is contained in:
@@ -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;
|
||||||
@@ -776,6 +776,8 @@ model ReportNotificationRead {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model SmsSignature {
|
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())
|
id String @id @default(cuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
applicationId String?
|
applicationId String?
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SmsConfigService } from '../sms-config/sms-config.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 type { ImportCommitDto, ImportMapping, PagedQuery, ReviewImportItemsDto } from './report-materials.contracts';
|
||||||
import {
|
import {
|
||||||
normalizePage,
|
normalizePage,
|
||||||
@@ -310,6 +311,19 @@ export class ReportImportReviewService {
|
|||||||
return { batchId, status, approvedCount, rejectedCount, failedCount: failures.length, failures };
|
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(
|
async stageSignatureRow(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
applicationId: string | undefined,
|
applicationId: string | undefined,
|
||||||
@@ -320,9 +334,7 @@ export class ReportImportReviewService {
|
|||||||
if (!name) throw new Error('缺少短信签名');
|
if (!name) throw new Error('缺少短信签名');
|
||||||
const purpose = mappedCorePatchValue(mappings, values, 'purpose');
|
const purpose = mappedCorePatchValue(mappings, values, 'purpose');
|
||||||
const signatureReportValues = dynamicValues(mappings, values);
|
const signatureReportValues = dynamicValues(mappings, values);
|
||||||
const existing = await this.prisma.smsSignature.findFirst({
|
const existing = await this.findImportSignature(tenantId, applicationId, name);
|
||||||
where: { tenantId, applicationId: applicationId ?? null, name, auditStatus: { not: 'deleted' } },
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
operation: existing ? 'update' : 'create',
|
operation: existing ? 'update' : 'create',
|
||||||
targetId: existing?.id,
|
targetId: existing?.id,
|
||||||
@@ -443,20 +455,22 @@ export class ReportImportReviewService {
|
|||||||
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
|
if (!current || current.auditStatus === 'deleted') throw new Error('原签名已删除,不能应用导入修改');
|
||||||
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
|
await this.smsConfig.updateSignature(targetId, buildBody(current), batch.tenantId);
|
||||||
} else {
|
} else {
|
||||||
const duplicate = await this.prisma.smsSignature.findFirst({
|
const duplicate = await this.findImportSignature(batch.tenantId, applicationId, name);
|
||||||
where: {
|
|
||||||
tenantId: batch.tenantId,
|
|
||||||
applicationId: applicationId ?? null,
|
|
||||||
name,
|
|
||||||
auditStatus: { not: 'deleted' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
targetId = duplicate.id;
|
targetId = duplicate.id;
|
||||||
await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId);
|
await this.smsConfig.updateSignature(targetId, buildBody(duplicate), batch.tenantId);
|
||||||
} else {
|
} else {
|
||||||
|
try {
|
||||||
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
|
const created = await this.smsConfig.createSignature({ tenantId: batch.tenantId, ...buildBody() });
|
||||||
targetId = created.id;
|
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}` });
|
await this.smsConfig.approveSignature(targetId, { reviewerId, reason: `批量导入审核通过:${name}` });
|
||||||
|
|||||||
@@ -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 { 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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { automaticDeliveryMode } from '../open-api/delivery-mode';
|
import type { ReviewDto, StatusChangeDto } from './sms-config.contracts';
|
||||||
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 { SmsApplicationLifecycleService } from './application-lifecycle.service';
|
import { SmsApplicationLifecycleService } from './application-lifecycle.service';
|
||||||
import { SmsReportValidationService } from './report-validation.service';
|
import { SmsReportValidationService } from './report-validation.service';
|
||||||
|
import { writeUniqueSignature } from './signature-uniqueness';
|
||||||
|
|
||||||
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
|
||||||
export class SmsAuditService {
|
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) {
|
listAuditRecords(targetType?: string, targetId?: string) {
|
||||||
return this.prisma.auditRecord.findMany({
|
return this.prisma.auditRecord.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -48,12 +48,21 @@ export class SmsAuditService {
|
|||||||
throw new NotFoundException('Signature not found');
|
throw new NotFoundException('Signature not found');
|
||||||
}
|
}
|
||||||
const status = data.status ?? 'deleted';
|
const status = data.status ?? 'deleted';
|
||||||
const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: status } });
|
const updated = await writeUniqueSignature(this.prisma, { ...signature, auditStatus: status }, () =>
|
||||||
await this.lifecycle.writeOperationLog(signature.tenantId, data.operatorId, `sms_signature.${status}`, 'sms_signature', signatureId, {
|
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,
|
statusBefore: signature.auditStatus,
|
||||||
statusAfter: status,
|
statusAfter: status,
|
||||||
reason: data.reason,
|
reason: data.reason,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,11 +73,18 @@ export class SmsAuditService {
|
|||||||
}
|
}
|
||||||
const status = data.status ?? 'deleted';
|
const status = data.status ?? 'deleted';
|
||||||
const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: status } });
|
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,
|
statusBefore: template.auditStatus,
|
||||||
statusAfter: status,
|
statusAfter: status,
|
||||||
reason: data.reason,
|
reason: data.reason,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,13 +95,15 @@ export class SmsAuditService {
|
|||||||
}
|
}
|
||||||
const reviewerId = await this.resolveReviewerId(data.reviewerId);
|
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 },
|
where: { id: signatureId },
|
||||||
data: {
|
data: {
|
||||||
auditStatus: statusAfter,
|
auditStatus: statusAfter,
|
||||||
rejectReason: statusAfter === 'rejected' ? data.reason : null,
|
rejectReason: statusAfter === 'rejected' ? data.reason : null,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
await this.createAuditRecord({
|
await this.createAuditRecord({
|
||||||
tenantId: signature.tenantId,
|
tenantId: signature.tenantId,
|
||||||
targetType: 'sms_signature',
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { writeUniqueSignature } from './signature-uniqueness';
|
||||||
import { selectDrainageReportTask } from '../common/drainage-report-task';
|
import { selectDrainageReportTask } from '../common/drainage-report-task';
|
||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
@@ -766,7 +767,11 @@ export class SmsSignatureService {
|
|||||||
data.drainageInfo,
|
data.drainageInfo,
|
||||||
);
|
);
|
||||||
const name = validateCompleteSmsSignature(data.name);
|
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: {
|
data: {
|
||||||
tenantId: data.tenantId,
|
tenantId: data.tenantId,
|
||||||
applicationId: data.applicationId,
|
applicationId: data.applicationId,
|
||||||
@@ -775,7 +780,8 @@ export class SmsSignatureService {
|
|||||||
auditStatus: options.initialAuditStatus,
|
auditStatus: options.initialAuditStatus,
|
||||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
await this.reportValidation.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo);
|
await this.reportValidation.syncSignatureReportValues(signature.id, data.applicationId, drainageInfo);
|
||||||
if (options.initialAuditStatus) {
|
if (options.initialAuditStatus) {
|
||||||
await this.audit.createAuditRecord({
|
await this.audit.createAuditRecord({
|
||||||
@@ -805,10 +811,11 @@ export class SmsSignatureService {
|
|||||||
throw new NotFoundException('Signature not found');
|
throw new NotFoundException('Signature not found');
|
||||||
}
|
}
|
||||||
await this.reportValidation.validateSignatureReportValues(
|
await this.reportValidation.validateSignatureReportValues(
|
||||||
data.applicationId ?? signature.applicationId ?? undefined,
|
(data.applicationId !== undefined ? data.applicationId : signature.applicationId) ?? undefined,
|
||||||
data.drainageInfo,
|
data.drainageInfo,
|
||||||
);
|
);
|
||||||
const applicationId = data.applicationId ?? signature.applicationId ?? undefined;
|
const applicationId =
|
||||||
|
(data.applicationId !== undefined ? data.applicationId : signature.applicationId) ?? undefined;
|
||||||
const drainageInfo = data.drainageInfo
|
const drainageInfo = data.drainageInfo
|
||||||
? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo)
|
? await this.reportValidation.withReportRequirementSnapshot(applicationId, data.drainageInfo)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -822,7 +829,16 @@ export class SmsSignatureService {
|
|||||||
const auditStatus =
|
const auditStatus =
|
||||||
options.initialAuditStatus ??
|
options.initialAuditStatus ??
|
||||||
(materialChanged && signature.auditStatus === 'approved' ? 'pending' : data.auditStatus);
|
(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 },
|
where: { id: signatureId },
|
||||||
data: {
|
data: {
|
||||||
applicationId: data.applicationId,
|
applicationId: data.applicationId,
|
||||||
@@ -836,7 +852,8 @@ export class SmsSignatureService {
|
|||||||
reportChangedAt: materialChanged ? new Date() : undefined,
|
reportChangedAt: materialChanged ? new Date() : undefined,
|
||||||
},
|
},
|
||||||
include: { materials: true, tenant: true, application: true },
|
include: { materials: true, tenant: true, application: true },
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
await this.reportValidation.syncSignatureReportValues(
|
await this.reportValidation.syncSignatureReportValues(
|
||||||
signatureId,
|
signatureId,
|
||||||
updated.applicationId ?? undefined,
|
updated.applicationId ?? undefined,
|
||||||
@@ -897,10 +914,12 @@ export class SmsSignatureService {
|
|||||||
throw new NotFoundException('Signature not found');
|
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 },
|
where: { id: signatureId },
|
||||||
data: { auditStatus: 'pending', rejectReason: null },
|
data: { auditStatus: 'pending', rejectReason: null },
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
await this.audit.createAuditRecord({
|
await this.audit.createAuditRecord({
|
||||||
tenantId: signature.tenantId,
|
tenantId: signature.tenantId,
|
||||||
targetType: 'sms_signature',
|
targetType: 'sms_signature',
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ function createPrismaMock() {
|
|||||||
count: jest.fn().mockResolvedValue(0),
|
count: jest.fn().mockResolvedValue(0),
|
||||||
},
|
},
|
||||||
smsSignature: {
|
smsSignature: {
|
||||||
|
findFirst: jest.fn().mockResolvedValue(null),
|
||||||
groupBy: jest.fn().mockResolvedValue([]),
|
groupBy: jest.fn().mockResolvedValue([]),
|
||||||
count: jest.fn().mockResolvedValue(0),
|
count: jest.fn().mockResolvedValue(0),
|
||||||
findMany: jest.fn().mockResolvedValue([
|
findMany: jest.fn().mockResolvedValue([
|
||||||
|
|||||||
@@ -2364,3 +2364,8 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
|
|||||||
## 2026-09-17 首页V2实施修订
|
## 2026-09-17 首页V2实施修订
|
||||||
|
|
||||||
用户最终要求执行[首页方案](homepage-receipt-metrics-redesign-20260917.md)并提交推送:一行三块“今日业务/今日回执/今日营业状况”,原企业消费排行保留,在消费后增加今日返还金额列,不替换排行;企业详情、导出包含返还。统计按今日有效接收回执及原提交日T-3~T,长短信完整成功、缺片补计、业务去重与旧总体成功率按方案执行。此前V1返还区域替换解释与V2“不增返还列”均被本次明确指令替代。仅本地实现和验收,未部署。
|
用户最终要求执行[首页方案](homepage-receipt-metrics-redesign-20260917.md)并提交推送:一行三块“今日业务/今日回执/今日营业状况”,原企业消费排行保留,在消费后增加今日返还金额列,不替换排行;企业详情、导出包含返还。统计按今日有效接收回执及原提交日T-3~T,长短信完整成功、缺片补计、业务去重与旧总体成功率按方案执行。此前V1返还区域替换解释与V2“不增返还列”均被本次明确指令替代。仅本地实现和验收,未部署。
|
||||||
|
|
||||||
|
|
||||||
|
## 2026-09-17 有效签名名称唯一性
|
||||||
|
|
||||||
|
同一企业、同一应用、相同完整签名名称只能存在一条有效签名;未绑定应用单独作为一个范围。有效状态包括草稿、待审、通过、驳回,停用及删除不占用名称;新增、改名、换应用、审核和恢复均不可绕过,接口及数据库同时防重。批量导入仍更新已有资料,保留未映射字段、用途及关联记录,并发创建后重查已有签名补资料。历史重复不自动删除或合并。设计见 [通道与报备方案](phase-4-channel-reporting-plan.md#2026-09-17-有效签名名称唯一性)。本轮授权代码修改及本地提交,不含推送和部署。
|
||||||
|
|||||||
@@ -49,3 +49,14 @@
|
|||||||
- 通道、通道组、路由规则、报备字段、报备任务基础接口存在。
|
- 通道、通道组、路由规则、报备字段、报备任务基础接口存在。
|
||||||
- 报备导出/导入记录和报备状态同步接口存在。
|
- 报备导出/导入记录和报备状态同步接口存在。
|
||||||
- 阶段 4 进度文档记录验证结果。
|
- 阶段 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,不发送短信,不改线上资料;线上历史数据与部署留作未验证项。
|
||||||
|
|||||||
@@ -5661,3 +5661,20 @@ TC-SQA-01~14:真实隔离PG覆盖核心日期/日报/长短信/事务/分页
|
|||||||
### HOME0917 实施验收更新
|
### 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-实施与验收结果)。所有真实截图使用本地隔离验收记录,不表示目标环境已上线。
|
最终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恢复分支;四种有效/两种无效状态和错误分类另由定向单元测试覆盖。线上重复盘点、目标环境迁移及浏览器验收未执行,不等同已发布。
|
||||||
|
|||||||
@@ -5161,3 +5161,15 @@ CUA本轮可用,实际后端文档三尺寸1600×1000/1366×768/390×844无页
|
|||||||
- API854、前端163及覆盖率门禁通过;类型/生产构建/定向ESLint/样式/结构/CSS治理/包体检查通过。真实数据库故障与并发认领、四日及缺片/跨日/重复/退款边界通过;页面无明细预请求,错误保留真值、CSV/详情通过,页面异常0。本地样本2020消息/6000新增片,总览p95约55ms、明细约20ms,不推断线上CPU/容量。
|
- API854、前端163及覆盖率门禁通过;类型/生产构建/定向ESLint/样式/结构/CSS治理/包体检查通过。真实数据库故障与并发认领、四日及缺片/跨日/重复/退款边界通过;页面无明细预请求,错误保留真值、CSV/详情通过,页面异常0。本地样本2020消息/6000新增片,总览p95约55ms、明细约20ms,不推断线上CPU/容量。
|
||||||
- 原始证据 `.local-data/homepage-implementation-20260917/`。数据库端口、Redis旧RDB、浏览器两个定位失败已修正并保留原日志;Redis版本建议保留。尚未验证线上源时间覆盖、真实大数据与现场回退、完整供应商短信闭环。
|
- 原始证据 `.local-data/homepage-implementation-20260917/`。数据库端口、Redis旧RDB、浏览器两个定位失败已修正并保留原日志;Redis版本建议保留。尚未验证线上源时间覆盖、真实大数据与现场回退、完整供应商短信闭环。
|
||||||
- 起点main627fa7e、实际远端4eb7b16、暂存区空;67项原工作已备份并核对保护。仅本轮代码、迁移、方案/UI/截图、验收脚本和文档精确追加进入提交;版本/metrics/发布工具等不夹带。用户授权提交推送,不含部署;测试环境、预生产环境均未改动。最终提交与推送结果单独补记。
|
- 起点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回归;本轮真实导入验收覆盖资料暂存/审核应用层,文件解析路径未改变。
|
||||||
|
- 本地修改、测试和需求/设计/用例同步完成,提交前仅本轮文件及共享文档精确追加进入暂存;其他原始修改保留。提交号在交付回复中报告;推送、测试部署、预生产部署均未执行。
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user