feat: harden CMPP delivery and platform workflows
This commit is contained in:
@@ -121,27 +121,27 @@ export class ClientSmsConfigController {
|
||||
}
|
||||
|
||||
@Get('templates')
|
||||
listTemplates(@TenantId() tenantId?: string) {
|
||||
return this.smsConfig.listTemplates(tenantId);
|
||||
listTemplates(@TenantId() tenantId?: string, @Query('includeHistory') includeHistory?: string) {
|
||||
return this.smsConfig.listClientTemplates(tenantId, includeHistory === 'true');
|
||||
}
|
||||
|
||||
@Post('templates')
|
||||
createTemplate(@Body() body: CreateSmsTemplateDto) {
|
||||
return this.smsConfig.createTemplate(body);
|
||||
createTemplate(@Body() body: CreateSmsTemplateDto, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.createTemplate({ ...body, tenantId: tenantId ?? body.tenantId });
|
||||
}
|
||||
|
||||
@Put('templates/:id')
|
||||
updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto) {
|
||||
return this.smsConfig.updateTemplate(templateId, body);
|
||||
updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.updateTemplate(templateId, body, tenantId);
|
||||
}
|
||||
|
||||
@Post('templates/:id/submit')
|
||||
submitTemplate(@Param('id') templateId: string) {
|
||||
return this.smsConfig.submitTemplate(templateId);
|
||||
submitTemplate(@Param('id') templateId: string, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.submitTemplate(templateId, tenantId);
|
||||
}
|
||||
|
||||
@Post('templates/:id/status')
|
||||
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeTemplateStatus(templateId, body);
|
||||
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.changeTemplateStatus(templateId, body, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { SmsConfigService } from './sms-config.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
@@ -755,7 +756,7 @@ describe('SmsConfigService', () => {
|
||||
expect(serialized).not.toContain('channel-secret');
|
||||
expect(serialized).not.toContain('内部通道');
|
||||
expect(result[0]).not.toHaveProperty('reportTasks');
|
||||
expect(result[0]).not.toHaveProperty('reportStatus');
|
||||
expect(result[0]).toHaveProperty('reportStatus');
|
||||
});
|
||||
|
||||
it('returns real client signature workspace counts from database grouping', async () => {
|
||||
@@ -951,6 +952,17 @@ describe('SmsConfigService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns only approved templates from the client send-candidate view by default', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await service.listClientTemplates('tenant-1');
|
||||
|
||||
expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: 'approved' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('creates admin enterprise templates as approved when requested', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
@@ -1038,4 +1050,20 @@ describe('SmsConfigService', () => {
|
||||
|
||||
expect(prisma.smsTemplate.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['空变量', '【签名A】验证码${}'],
|
||||
['中文变量', '【签名A】验证码${中文}'],
|
||||
['未闭合变量', '【签名A】验证码${code'],
|
||||
['重复变量', '【签名A】${code}-${code}'],
|
||||
['超长变量', `【签名A】\${${'a'.repeat(33)}}`],
|
||||
])('rejects %s before persisting the template', async (_label, content) => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.createTemplate({
|
||||
tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', name: '非法模板', content,
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.smsTemplate.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -850,6 +850,9 @@ export class SmsConfigService {
|
||||
name: true,
|
||||
purpose: true,
|
||||
auditStatus: true,
|
||||
reportStatus: true,
|
||||
pendingReport: true,
|
||||
reportChangedAt: true,
|
||||
rejectReason: true,
|
||||
drainageInfo: true,
|
||||
createdAt: true,
|
||||
@@ -888,6 +891,9 @@ export class SmsConfigService {
|
||||
name: signature.name,
|
||||
purpose: signature.purpose,
|
||||
auditStatus: signature.auditStatus,
|
||||
reportStatus: signature.reportStatus,
|
||||
pendingReport: signature.pendingReport,
|
||||
reportChangedAt: signature.reportChangedAt,
|
||||
rejectReason: signature.rejectReason,
|
||||
createdAt: signature.createdAt,
|
||||
updatedAt: signature.updatedAt,
|
||||
@@ -1315,7 +1321,12 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
listClientTemplates(tenantId: string | undefined, includeHistory = false) {
|
||||
return this.listTemplates({ tenantId, status: includeHistory ? 'all' : 'approved' });
|
||||
}
|
||||
|
||||
async createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) {
|
||||
const variables = validateAndNormalizeTemplateVariables(data.content, data.variables);
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
|
||||
if (!application || application.tenantId !== data.tenantId) {
|
||||
throw new BadRequestException('applicationId does not belong to the template tenant');
|
||||
@@ -1332,7 +1343,7 @@ export class SmsConfigService {
|
||||
auditStatus: options.initialAuditStatus,
|
||||
billingUnits: estimateBillingUnits(data.content),
|
||||
variables: {
|
||||
create: (data.variables ?? inferTemplateVariables(data.content)).map((variable: TemplateVariableInput) => ({
|
||||
create: variables.map((variable: TemplateVariableInput) => ({
|
||||
name: variable.name,
|
||||
example: variable.example,
|
||||
required: variable.required ?? true,
|
||||
@@ -1343,9 +1354,9 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateTemplate(templateId: string, data: UpdateSmsTemplateDto) {
|
||||
async updateTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template) {
|
||||
if (!template || (tenantId && template.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
if (data.applicationId) {
|
||||
@@ -1362,7 +1373,9 @@ export class SmsConfigService {
|
||||
data.content ?? template.content,
|
||||
);
|
||||
}
|
||||
const variables = data.variables ?? (data.content ? inferTemplateVariables(data.content) : undefined);
|
||||
const variables = data.content !== undefined || data.variables !== undefined
|
||||
? validateAndNormalizeTemplateVariables(data.content ?? template.content, data.variables)
|
||||
: undefined;
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (variables) {
|
||||
await tx.templateVariable.deleteMany({ where: { templateId } });
|
||||
@@ -1390,9 +1403,9 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
async submitTemplate(templateId: string) {
|
||||
async submitTemplate(templateId: string, tenantId?: string) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template) {
|
||||
if (!template || (tenantId && template.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
await this.validateTemplateSignature(template.signatureId, template.tenantId, template.applicationId, template.content);
|
||||
@@ -1473,9 +1486,9 @@ export class SmsConfigService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async changeTemplateStatus(templateId: string, data: StatusChangeDto) {
|
||||
async changeTemplateStatus(templateId: string, data: StatusChangeDto, tenantId?: string) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template) {
|
||||
if (!template || (tenantId && template.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
const status = data.status ?? 'deleted';
|
||||
@@ -1643,6 +1656,37 @@ function inferTemplateVariables(content: string): TemplateVariableInput[] {
|
||||
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
|
||||
}
|
||||
|
||||
function validateAndNormalizeTemplateVariables(
|
||||
content: string,
|
||||
supplied?: Array<{ name: string; example?: string; required?: boolean }>,
|
||||
): TemplateVariableInput[] {
|
||||
const names: string[] = [];
|
||||
let cursor = 0;
|
||||
while (true) {
|
||||
const start = content.indexOf('${', cursor);
|
||||
if (start < 0) break;
|
||||
const end = content.indexOf('}', start + 2);
|
||||
if (end < 0) throw new BadRequestException('模板变量未闭合');
|
||||
const name = content.slice(start + 2, end);
|
||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name)) {
|
||||
throw new BadRequestException('模板变量名必须以英文字母开头,仅包含英文字母、数字和下划线,长度1至32位');
|
||||
}
|
||||
if (names.includes(name)) throw new BadRequestException(`模板变量 ${name} 重复`);
|
||||
names.push(name);
|
||||
cursor = end + 1;
|
||||
}
|
||||
if (!supplied) return names.map((name) => ({ name, required: true }));
|
||||
const suppliedNames = supplied.map((item) => item.name?.trim());
|
||||
if (suppliedNames.some((name) => !name || !/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name))) {
|
||||
throw new BadRequestException('变量配置中包含非法变量名');
|
||||
}
|
||||
if (new Set(suppliedNames).size !== suppliedNames.length) throw new BadRequestException('变量配置中包含重复变量');
|
||||
if (suppliedNames.length !== names.length || suppliedNames.some((name) => !names.includes(name))) {
|
||||
throw new BadRequestException('变量配置必须与模板正文中的占位符完全一致');
|
||||
}
|
||||
return supplied.map((item) => ({ ...item, name: item.name.trim() }));
|
||||
}
|
||||
|
||||
function normalizeSmsSignature(name: string) {
|
||||
const innerName = name.trim().replace(/^[【\[]+|[】\]]+$/g, '').trim();
|
||||
return innerName ? `【${innerName}】` : '';
|
||||
|
||||
Reference in New Issue
Block a user