feat: add HTTP API and complete client workflows
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
SmsConfigService,
|
||||
UpdateSmsTemplateDto,
|
||||
UpdateSmsDrainageInfoDto,
|
||||
UpdateSmsSignatureDto,
|
||||
} from './sms-config.service';
|
||||
|
||||
@ApiTags('client-sms-config')
|
||||
@@ -36,12 +37,12 @@ export class ClientSmsConfigController {
|
||||
|
||||
@Get('applications/:id/report-fields')
|
||||
getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage', @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getApplicationReportFields(applicationId, reportType));
|
||||
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getClientApplicationReportFields(applicationId, reportType));
|
||||
}
|
||||
|
||||
@Get('report-fields/common')
|
||||
getCommonReportFields(@Query('reportType') reportType: 'signature' | 'drainage' = 'drainage') {
|
||||
return this.smsConfig.getApplicationReportFields(undefined, reportType);
|
||||
return this.smsConfig.getClientApplicationReportFields(undefined, reportType);
|
||||
}
|
||||
|
||||
@Post('applications/:id/secret/reset')
|
||||
@@ -58,12 +59,24 @@ export class ClientSmsConfigController {
|
||||
|
||||
@Get('signatures')
|
||||
listSignatures(@TenantId() tenantId?: string) {
|
||||
return this.smsConfig.listSignatures(tenantId);
|
||||
return this.smsConfig.listClientSignatures(tenantId);
|
||||
}
|
||||
|
||||
@Get('signatures-workspace')
|
||||
getSignatureWorkspace(@TenantId() tenantId?: string) {
|
||||
return this.smsConfig.getClientSignatureWorkspace(tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures')
|
||||
createSignature(@Body() body: CreateSmsSignatureDto) {
|
||||
return this.smsConfig.createSignature(body);
|
||||
async createSignature(@Body() body: CreateSmsSignatureDto, @TenantId() tenantId?: string) {
|
||||
const signature = await this.smsConfig.createSignature({ ...body, tenantId: tenantId ?? body.tenantId });
|
||||
return this.smsConfig.getClientSignatureView(signature.id, tenantId ?? body.tenantId);
|
||||
}
|
||||
|
||||
@Put('signatures/:id')
|
||||
async updateSignature(@Param('id') signatureId: string, @Body() body: UpdateSmsSignatureDto, @TenantId() tenantId?: string) {
|
||||
await this.smsConfig.updateClientSignature(signatureId, body, tenantId);
|
||||
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/materials')
|
||||
@@ -73,32 +86,38 @@ export class ClientSmsConfigController {
|
||||
|
||||
@Get('drainage-infos')
|
||||
listDrainageInfos(@TenantId() tenantId?: string) {
|
||||
return this.smsConfig.listDrainageInfos({ tenantId });
|
||||
return this.smsConfig.listClientDrainageInfos(tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/drainage-infos')
|
||||
createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId);
|
||||
async createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
|
||||
const item = await this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId);
|
||||
return this.smsConfig.getClientDrainageInfoView(item.id, tenantId);
|
||||
}
|
||||
|
||||
@Put('drainage-infos/:id')
|
||||
updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId);
|
||||
async updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
|
||||
await this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId);
|
||||
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
|
||||
}
|
||||
|
||||
@Post('drainage-infos/:id/status')
|
||||
changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.changeDrainageInfoStatus(itemId, body, tenantId);
|
||||
async changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
|
||||
await this.smsConfig.changeDrainageInfoStatus(itemId, body, tenantId);
|
||||
if (body.status === 'deleted') return { id: itemId, status: 'deleted' };
|
||||
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/submit')
|
||||
submitSignature(@Param('id') signatureId: string) {
|
||||
return this.smsConfig.submitSignature(signatureId);
|
||||
async submitSignature(@Param('id') signatureId: string, @TenantId() tenantId?: string) {
|
||||
await this.smsConfig.submitSignature(signatureId, tenantId);
|
||||
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/status')
|
||||
changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeSignatureStatus(signatureId, body);
|
||||
async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
|
||||
await this.smsConfig.changeSignatureStatus(signatureId, body, tenantId);
|
||||
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
||||
}
|
||||
|
||||
@Get('templates')
|
||||
|
||||
@@ -65,6 +65,7 @@ function createPrismaMock() {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
smsSignature: {
|
||||
groupBy: jest.fn().mockResolvedValue([]),
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'sig-1',
|
||||
tenantId: 'tenant-1',
|
||||
@@ -224,7 +225,7 @@ describe('SmsConfigService', () => {
|
||||
]);
|
||||
expect(prisma.smsApplication.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ status: 'active', tenant: { name: { contains: '租户' } }, name: { contains: '应用' } }),
|
||||
include: { tenant: true, ipAllowlist: true },
|
||||
include: { tenant: true, ipAllowlist: true, httpConfig: true },
|
||||
}));
|
||||
expect(prisma.smsApplication.findMany.mock.calls[0][0]).not.toHaveProperty('take');
|
||||
expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -661,6 +662,86 @@ describe('SmsConfigService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes channel sources from client report-field responses', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.channelRouteRule.findMany.mockResolvedValue([{
|
||||
id: 'route-1', priority: 10,
|
||||
group: {
|
||||
id: 'group-1', name: '内部通道组',
|
||||
items: [{ channel: { id: 'channel-secret', code: 'SECRET-CH', name: '内部通道', reportFields: [{ status: 'active', required: true, reportType: 'signature', drainageField: { id: 'field-1', code: 'license', name: '营业执照', fieldType: 'file', description: null, status: 'active' } }] } }],
|
||||
},
|
||||
}] as never);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
const fields = await service.getClientApplicationReportFields('app-1', 'signature');
|
||||
|
||||
expect(fields).toEqual([expect.objectContaining({ id: 'field-1', code: 'license', required: true })]);
|
||||
expect(JSON.stringify(fields)).not.toContain('channel-secret');
|
||||
expect(JSON.stringify(fields)).not.toContain('内部通道');
|
||||
expect(fields[0]).not.toHaveProperty('channels');
|
||||
});
|
||||
|
||||
it('returns client signatures without report tasks, channels, or internal requirement snapshots', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSignature.findMany.mockResolvedValue([{
|
||||
id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', purpose: '通知',
|
||||
auditStatus: 'rejected', rejectReason: '请补充资料',
|
||||
drainageInfo: { signatureReportValues: { license: 'file-1' }, reportRequirements: [{ channelId: 'channel-secret', channelName: '内部通道' }] },
|
||||
createdAt: new Date('2026-07-16T01:00:00Z'), updatedAt: new Date('2026-07-16T02:00:00Z'),
|
||||
application: { id: 'app-1', name: '应用A', status: 'active' },
|
||||
materials: [{ id: 'material-1', fileObjectId: 'file-1', materialType: 'license', title: '营业执照', description: null, createdAt: new Date() }],
|
||||
drainageItems: [{ id: 'drainage-1', siteName: '官网', url: 'https://example.com', remark: null, reportValues: { owner: '企业A' }, auditStatus: 'pending', rejectReason: null, submittedAt: new Date(), reviewedAt: null, createdAt: new Date(), updatedAt: new Date() }],
|
||||
_count: { reportMaterials: 2 },
|
||||
reportTasks: [{ channelId: 'channel-secret' }],
|
||||
}] as never);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
const result = await service.listClientSignatures('tenant-1');
|
||||
const serialized = JSON.stringify(result);
|
||||
|
||||
expect(result[0]).toEqual(expect.objectContaining({
|
||||
id: 'sig-1',
|
||||
submittedMaterialCount: 3,
|
||||
reportValues: { license: 'file-1' },
|
||||
drainageInfo: { links: [expect.objectContaining({ id: 'drainage-1', siteName: '官网' })] },
|
||||
}));
|
||||
expect(serialized).not.toContain('channel-secret');
|
||||
expect(serialized).not.toContain('内部通道');
|
||||
expect(result[0]).not.toHaveProperty('reportTasks');
|
||||
expect(result[0]).not.toHaveProperty('reportStatus');
|
||||
});
|
||||
|
||||
it('returns real client signature workspace counts from database grouping', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSignature.findMany.mockResolvedValue([]);
|
||||
prisma.smsSignature.groupBy.mockResolvedValue([
|
||||
{ auditStatus: 'pending', _count: { _all: 2 } },
|
||||
{ auditStatus: 'approved', _count: { _all: 5 } },
|
||||
{ auditStatus: 'rejected', _count: { _all: 1 } },
|
||||
] as never);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.getClientSignatureWorkspace('tenant-1')).resolves.toEqual({
|
||||
items: [],
|
||||
summary: { total: 8, pending: 2, approved: 5, rejected: 1, draft: 0 },
|
||||
});
|
||||
expect(prisma.smsSignature.groupBy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { tenantId: 'tenant-1', auditStatus: { notIn: ['deleted', 'disabled'] } },
|
||||
}));
|
||||
});
|
||||
|
||||
it('selects client drainage information without internal tasks or channels', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsDrainageInfo.findMany.mockResolvedValue([{ id: 'drainage-1', siteName: '官网' }] as never);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.listClientDrainageInfos('tenant-1')).resolves.toEqual([{ id: 'drainage-1', siteName: '官网' }]);
|
||||
const query = prisma.smsDrainageInfo.findMany.mock.calls[0][0];
|
||||
expect(query.where).toEqual({ id: undefined, tenantId: 'tenant-1', auditStatus: { not: 'deleted' } });
|
||||
expect(query.select).not.toHaveProperty('reportTasks');
|
||||
expect(JSON.stringify(query.select)).not.toContain('channel');
|
||||
});
|
||||
|
||||
it('requires common signature fields even when a signature is not bound to an application', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.commonReportField.findMany.mockResolvedValue([{
|
||||
|
||||
@@ -180,6 +180,7 @@ export class SmsConfigService {
|
||||
include: {
|
||||
tenant: true,
|
||||
ipAllowlist: true,
|
||||
httpConfig: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
@@ -221,6 +222,7 @@ export class SmsConfigService {
|
||||
include: {
|
||||
tenant: true,
|
||||
ipAllowlist: true,
|
||||
httpConfig: true,
|
||||
},
|
||||
});
|
||||
if (!application || (tenantId && application.tenantId !== tenantId)) {
|
||||
@@ -350,6 +352,11 @@ export class SmsConfigService {
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
async getClientApplicationReportFields(applicationId?: string, reportType?: 'signature' | 'drainage') {
|
||||
const fields = await this.getApplicationReportFields(applicationId, reportType);
|
||||
return fields.map(({ channels: _channels, commonReportTypes: _commonReportTypes, ...field }) => field);
|
||||
}
|
||||
|
||||
async createApplication(data: CreateSmsApplicationDto) {
|
||||
const secret = normalizeApplicationPassword(data.passwordCipher);
|
||||
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
|
||||
@@ -808,6 +815,128 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
async listClientSignatures(tenantId?: string, signatureId?: string) {
|
||||
const signatures = await this.prisma.smsSignature.findMany({
|
||||
where: { id: signatureId, tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } },
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
name: true,
|
||||
purpose: true,
|
||||
auditStatus: true,
|
||||
rejectReason: true,
|
||||
drainageInfo: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
application: { select: { id: true, name: true, status: true } },
|
||||
materials: {
|
||||
select: { id: true, fileObjectId: true, materialType: true, title: true, description: true, createdAt: true },
|
||||
},
|
||||
drainageItems: {
|
||||
where: { auditStatus: { not: 'deleted' } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
siteName: true,
|
||||
url: true,
|
||||
remark: true,
|
||||
reportValues: true,
|
||||
auditStatus: true,
|
||||
rejectReason: true,
|
||||
submittedAt: true,
|
||||
reviewedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
_count: { select: { reportMaterials: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
return signatures.map((signature) => {
|
||||
const stored = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
|
||||
return {
|
||||
id: signature.id,
|
||||
tenantId: signature.tenantId,
|
||||
applicationId: signature.applicationId,
|
||||
name: signature.name,
|
||||
purpose: signature.purpose,
|
||||
auditStatus: signature.auditStatus,
|
||||
rejectReason: signature.rejectReason,
|
||||
createdAt: signature.createdAt,
|
||||
updatedAt: signature.updatedAt,
|
||||
application: signature.application,
|
||||
materials: signature.materials,
|
||||
submittedMaterialCount: signature.materials.length + signature._count.reportMaterials,
|
||||
reportValues: isRecord(stored.signatureReportValues) ? stored.signatureReportValues : {},
|
||||
drainageInfo: {
|
||||
links: signature.drainageItems.map((item) => ({
|
||||
...item,
|
||||
reportValues: isRecord(item.reportValues) ? item.reportValues : {},
|
||||
})),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getClientSignatureView(signatureId: string, tenantId?: string) {
|
||||
const [signature] = await this.listClientSignatures(tenantId, signatureId);
|
||||
if (!signature) throw new NotFoundException('Signature not found');
|
||||
return signature;
|
||||
}
|
||||
|
||||
async getClientSignatureWorkspace(tenantId?: string) {
|
||||
const [items, statusCounts] = await Promise.all([
|
||||
this.listClientSignatures(tenantId),
|
||||
this.prisma.smsSignature.groupBy({
|
||||
by: ['auditStatus'],
|
||||
where: { tenantId, auditStatus: { notIn: ['deleted', 'disabled'] } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const summary = { total: 0, pending: 0, approved: 0, rejected: 0, draft: 0 };
|
||||
for (const item of statusCounts) {
|
||||
const count = item._count._all;
|
||||
summary.total += count;
|
||||
if (item.auditStatus in summary && item.auditStatus !== 'total') {
|
||||
summary[item.auditStatus as keyof Omit<typeof summary, 'total'>] = count;
|
||||
}
|
||||
}
|
||||
return { items, summary };
|
||||
}
|
||||
|
||||
async listClientDrainageInfos(tenantId?: string, itemId?: string) {
|
||||
return this.prisma.smsDrainageInfo.findMany({
|
||||
where: { id: itemId, tenantId, auditStatus: { not: 'deleted' } },
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
signatureId: true,
|
||||
applicationId: true,
|
||||
siteName: true,
|
||||
url: true,
|
||||
remark: true,
|
||||
reportValues: true,
|
||||
auditStatus: true,
|
||||
rejectReason: true,
|
||||
submittedAt: true,
|
||||
reviewedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
signature: { select: { id: true, name: true, auditStatus: true } },
|
||||
application: { select: { id: true, name: true, status: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async getClientDrainageInfoView(itemId: string, tenantId?: string) {
|
||||
const [item] = await this.listClientDrainageInfos(tenantId, itemId);
|
||||
if (!item) throw new NotFoundException('Drainage info not found');
|
||||
return item;
|
||||
}
|
||||
|
||||
async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) {
|
||||
await this.validateSignatureReportValues(data.applicationId, data.drainageInfo);
|
||||
const drainageInfo = await this.withReportRequirementSnapshot(data.applicationId, data.drainageInfo);
|
||||
@@ -835,9 +964,9 @@ export class SmsConfigService {
|
||||
return signature;
|
||||
}
|
||||
|
||||
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto) {
|
||||
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) {
|
||||
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
await this.validateSignatureReportValues(data.applicationId ?? signature.applicationId ?? undefined, data.drainageInfo);
|
||||
@@ -852,6 +981,7 @@ export class SmsConfigService {
|
||||
name: data.name,
|
||||
purpose: data.purpose,
|
||||
auditStatus: data.auditStatus,
|
||||
rejectReason: data.auditStatus === 'pending' ? null : undefined,
|
||||
drainageInfo: drainageInfo as Prisma.InputJsonValue | undefined,
|
||||
materialVersion: { increment: 1 },
|
||||
pendingReport: true,
|
||||
@@ -863,6 +993,24 @@ export class SmsConfigService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async updateClientSignature(signatureId: string, data: UpdateSmsSignatureDto, tenantId?: string) {
|
||||
const current = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!current || (tenantId && current.tenantId !== tenantId)) throw new NotFoundException('Signature not found');
|
||||
if (!['draft', 'rejected', 'approved'].includes(current.auditStatus)) {
|
||||
throw new BadRequestException('当前审核状态不允许修改签名');
|
||||
}
|
||||
const updated = await this.updateSignature(signatureId, { ...data, auditStatus: 'pending' }, tenantId);
|
||||
await this.createAuditRecord({
|
||||
tenantId: current.tenantId,
|
||||
targetType: 'sms_signature',
|
||||
targetId: signatureId,
|
||||
action: 'client_update_submit',
|
||||
statusBefore: current.auditStatus,
|
||||
statusAfter: 'pending',
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
listDrainageInfos(query: DrainageInfoListQuery = {}) {
|
||||
return this.prisma.smsDrainageInfo.findMany({
|
||||
where: {
|
||||
@@ -1098,9 +1246,9 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
async submitSignature(signatureId: string) {
|
||||
async submitSignature(signatureId: string, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) {
|
||||
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
|
||||
@@ -1285,9 +1433,9 @@ export class SmsConfigService {
|
||||
return this.reviewTemplate(templateId, 'rejected', 'reject', data);
|
||||
}
|
||||
|
||||
async changeSignatureStatus(signatureId: string, data: StatusChangeDto) {
|
||||
async changeSignatureStatus(signatureId: string, data: StatusChangeDto, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) {
|
||||
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
const status = data.status ?? 'deleted';
|
||||
|
||||
Reference in New Issue
Block a user