feat: unify drainage targets and carrier status UI
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('签名资料');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user