feat: unify drainage targets and carrier status UI

This commit is contained in:
hectorzhao
2026-08-28 17:24:01 +08:00
parent 9ac41e9308
commit c68ac7a3db
25 changed files with 265 additions and 65 deletions
+15 -1
View File
@@ -1,5 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { ClientBatchTaskDto, ClientDeleteResourceDto, ClientImportConfirmDto } from './client-write.dto';
import { ClientBatchTaskDto, ClientDeleteResourceDto, ClientDrainageInfoDto, ClientImportConfirmDto } from './client-write.dto';
import { strictValidationPipe } from './strict-validation.pipe';
function validate<T>(metatype: new () => T, value: unknown) {
@@ -45,4 +45,18 @@ describe('strict client write DTOs', () => {
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it.each([
'https://example.com/path',
'13800138000',
'+86 138-0013-8000',
'0755-12345678',
'(010) 12345678-123',
])('accepts a drainage URL or phone number without a separate name: %s', async (url) => {
await expect(validate(ClientDrainageInfoDto, { url })).resolves.toEqual(expect.objectContaining({ url }));
});
it('rejects arbitrary drainage text that is neither a URL nor a phone number', async () => {
await expect(validate(ClientDrainageInfoDto, { url: '品牌官网' })).rejects.toBeInstanceOf(BadRequestException);
});
});
+8 -2
View File
@@ -101,8 +101,14 @@ export class ClientSmsSignatureUpdateDto extends PartialType(ClientSmsSignatureD
}
export class ClientDrainageInfoDto {
@IsString() @MinLength(1) @MaxLength(200) siteName!: string;
@IsUrl({ require_tld: false }) @MaxLength(2048) url!: string;
@IsOptional() @IsString() @MaxLength(200) siteName?: string;
@IsString()
@MinLength(1)
@MaxLength(2048)
@Matches(/^(?:https?:\/\/\S+|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i, {
message: '引流信息必须是 http/https URL、手机号码或固定电话号码',
})
url!: string;
@IsOptional() @IsString() @MaxLength(1000) remark?: string;
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 200, maxDepth: 4 }) reportValues?: Record<string, unknown>;
}
@@ -240,7 +240,7 @@ export class ReportBatchGenerationService {
signatureId: signature.id,
drainageItemId: drainageInfo?.id,
materialVersion,
name: selected.reportType === 'signature' ? signature.name : drainageInfo?.siteName ?? '引流资料',
name: selected.reportType === 'signature' ? signature.name : drainageInfo?.url ?? '引流资料',
tenantName: signature.tenant.name,
applicationId: signature.applicationId ?? undefined,
applicationName: signature.application?.name ?? '未指定应用',
@@ -251,9 +251,8 @@ export class ReportImportReviewService {
async stageDrainageRow(tenantId: string, applicationId: string | undefined, mappings: ImportMapping[], values: Record<string, unknown>) {
const signatureName = mappedCoreValue(mappings, values, 'signatureName');
const siteName = mappedCoreValue(mappings, values, 'siteName');
const url = mappedCoreValue(mappings, values, 'url');
if (!signatureName || !siteName || !url) throw new Error('引流信息必须包含短信签名、站点名称和URL');
if (!signatureName || !url) throw new Error('引流信息必须包含短信签名和引流 URL 或号码');
const signature = await this.prisma.smsSignature.findFirst({ where: { tenantId, applicationId: applicationId ?? null, name: signatureName, auditStatus: 'approved' } });
if (!signature) throw new Error(`未找到已审核签名:${signatureName}`);
const remark = mappedCoreValue(mappings, values, 'remark');
@@ -262,7 +261,7 @@ export class ReportImportReviewService {
return {
operation: existing ? 'update' : 'create',
targetId: existing?.id,
payload: { tenantId, applicationId, signatureId: signature.id, signatureName, siteName, url, remark, reportValues },
payload: { tenantId, applicationId, signatureId: signature.id, signatureName, siteName: url, url, remark, reportValues },
originalSnapshot: existing ? {
id: existing.id,
siteName: existing.siteName,
@@ -311,10 +310,8 @@ export class ReportImportReviewService {
return targetId;
}
const signatureId = String(payload.signatureId ?? '');
const siteName = String(payload.siteName ?? '');
const url = String(payload.url ?? '');
const body = {
siteName,
url,
remark: typeof payload.remark === 'string' ? payload.remark : undefined,
reportValues: jsonRecord(payload.reportValues),
@@ -336,7 +333,7 @@ export class ReportImportReviewService {
targetId = created.id;
}
}
await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${siteName || url}` });
await this.smsConfig.approveDrainageInfo(targetId, { reviewerId, reason: `批量导入审核通过:${url}` });
return targetId;
}
}
@@ -20,11 +20,11 @@ export class ReportOfficialExportService {
const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', { views: [{ state: 'frozen', ySplit: 1 }] });
const headers = reportType === 'signature'
? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注']
: ['短信签名', '站点名称', 'URL', '备注', '网站截图'];
: ['短信签名', '引流 URL 或号码', '备注', '主体证明'];
sheet.addRow(headers);
sheet.addRow(reportType === 'signature'
? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除']
: ['示例签名', '官方站点', 'https://example.com', '示例行,导入前请删除', '请在本单元格插入图片']);
: ['示例签名', 'https://example.com 或 13800138000', '示例行,导入前请删除', '请在本单元格插入图片']);
styleHeader(sheet.getRow(1));
sheet.columns.forEach((column) => { column.width = 24; });
sheet.getRow(2).height = 48;
@@ -67,7 +67,7 @@ export class ReportPendingQueryService {
]);
return [
...signatures.map((item) => ({ id: `signature:${item.id}`, reportType: 'signature', signatureId: item.id, drainageItemId: null, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.name, detail: item.purpose, tenant: item.tenant, application: item.application })),
...drainageInfos.map((item) => ({ id: `drainage:${item.id}`, reportType: 'drainage', signatureId: item.signatureId, drainageItemId: item.id, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.siteName, detail: item.url, signatureName: item.signature.name, tenant: item.tenant, application: item.application })),
...drainageInfos.map((item) => ({ id: `drainage:${item.id}`, reportType: 'drainage', signatureId: item.signatureId, drainageItemId: item.id, materialVersion: item.materialVersion, changedAt: item.reportChangedAt, name: item.url, detail: item.remark, signatureName: item.signature.name, tenant: item.tenant, application: item.application })),
].sort((left, right) => new Date(right.changedAt).getTime() - new Date(left.changedAt).getTime());
}
}
@@ -85,8 +85,8 @@ export function signatureCoreMapping(header: string): { code: string; kind: Impo
export function drainageCoreMapping(header: string): { code: string; kind: ImportMapping['targetKind']; required?: boolean } | undefined {
if (/短信签名|签名名称/.test(header)) return { code: 'signature_name', kind: 'signatureName', required: true };
if (/站点|网站名称/.test(header)) return { code: 'site_name', kind: 'siteName', required: true };
if (/引流地址|网址|url|链接/.test(header)) return { code: 'url', kind: 'url', required: true };
if (/引流.*(?:url|地址|号码)|网址|url|链接|手机号码|固定电话|电话号码/.test(header)) return { code: 'url', kind: 'url', required: true };
if (/站点|网站名称/.test(header)) return { code: 'site_name', kind: 'siteName' };
if (/备注|说明/.test(header)) return { code: 'remark', kind: 'remark' };
return undefined;
}
@@ -13,6 +13,17 @@ describe('ReportMaterialsService', () => {
expect(operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: 'operator-1' }) });
});
it('builds a drainage template without a separate site-name column', async () => {
const operationLog = { create: jest.fn().mockResolvedValue({ id: 'log-drainage-template' }) };
const service = new ReportMaterialsService({ operationLog } as never, {} as never, {} as never);
const exported = await service.buildOfficialTemplate('drainage', 'operator-1');
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(exported.content as never);
const headers = workbook.worksheets[0].getRow(1).values;
expect(headers).toEqual(expect.arrayContaining(['短信签名', '引流 URL 或号码', '备注', '主体证明']));
expect(headers).not.toEqual(expect.arrayContaining(['站点名称']));
});
it('rejects formula cells before storing or importing a workbook', async () => {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('签名资料');
+16 -7
View File
@@ -11,6 +11,16 @@ import { SmsReportValidationService } from './report-validation.service';
import { SmsAuditService } from './audit.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
const DRAINAGE_TARGET_PATTERN = /^(?:https?:\/\/\S+|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i;
function normalizeDrainageTarget(value?: string) {
const target = value?.trim() ?? '';
if (!target || !DRAINAGE_TARGET_PATTERN.test(target)) {
throw new BadRequestException('引流信息必须是 http/https URL、手机号码或固定电话号码');
}
return target;
}
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsDrainageService {
constructor(private readonly prisma: PrismaService, private readonly reportValidation: SmsReportValidationService, private readonly audit: SmsAuditService) {}
@@ -70,7 +80,7 @@ export class SmsDrainageService {
if (!signature) throw new NotFoundException('Signature not found');
if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found');
if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息');
if (!data.siteName?.trim() || !data.url?.trim()) throw new BadRequestException('siteName and url are required');
const target = normalizeDrainageTarget(data.url);
await this.reportValidation.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues);
const auditStatus = options.initialAuditStatus ?? 'pending';
const item = await this.prisma.smsDrainageInfo.create({
@@ -78,8 +88,8 @@ export class SmsDrainageService {
tenantId: signature.tenantId,
signatureId,
applicationId: signature.applicationId,
siteName: data.siteName.trim(),
url: data.url.trim(),
siteName: target,
url: target,
remark: data.remark,
reportValues: data.reportValues as Prisma.InputJsonValue | undefined,
auditStatus,
@@ -104,8 +114,7 @@ export class SmsDrainageService {
if (!current) throw new NotFoundException('Drainage info not found');
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改');
if (data.siteName !== undefined && !data.siteName.trim()) throw new BadRequestException('siteName is required');
if (data.url !== undefined && !data.url.trim()) throw new BadRequestException('url is required');
const target = data.url === undefined ? undefined : normalizeDrainageTarget(data.url);
const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined;
await this.reportValidation.validateDrainageReportValues(applicationId, data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {}));
const auditStatus = options.initialAuditStatus ?? 'pending';
@@ -113,8 +122,8 @@ export class SmsDrainageService {
where: { id: itemId },
data: {
applicationId,
siteName: data.siteName?.trim(),
url: data.url?.trim(),
siteName: target,
url: target,
remark: data.remark,
reportValues: data.reportValues as Prisma.InputJsonValue | undefined,
auditStatus,
+1 -1
View File
@@ -54,7 +54,7 @@ export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantI
};
export interface CreateSmsDrainageInfoDto {
siteName: string;
siteName?: string;
url: string;
remark?: string;
reportValues?: Record<string, unknown>;
@@ -967,7 +967,7 @@ describe('SmsConfigService', () => {
}]);
const service = new SmsConfigService(prisma as never);
await expect(service.createDrainageInfo('sig-1', { siteName: '官网', url: 'https://example.com', reportValues: {} }, {}, 'tenant-1'))
await expect(service.createDrainageInfo('sig-1', { url: 'https://example.com', reportValues: {} }, {}, 'tenant-1'))
.rejects.toThrow('引流信息缺少必填报备资料:网站主体');
expect(prisma.smsDrainageInfo.create).not.toHaveBeenCalled();
});
@@ -1014,9 +1014,12 @@ describe('SmsConfigService', () => {
prisma.channelRouteRule.findMany.mockResolvedValue([]);
const service = new SmsConfigService(prisma as never);
await expect(service.createDrainageInfo('sig-1', { siteName: '官网', url: 'https://example.com', reportValues: {} }, {}, 'tenant-1'))
await expect(service.createDrainageInfo('sig-1', { url: '13800138000', reportValues: {} }, {}, 'tenant-1'))
.resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' }));
expect(prisma.smsDrainageInfo.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ siteName: '13800138000', url: '13800138000' }),
}));
expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ targetType: 'sms_drainage_info', action: 'submit', statusAfter: 'pending' }) });
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
});