feat: remove legacy signature confirmation
This commit is contained in:
@@ -332,9 +332,53 @@ describe('ChannelsService', () => {
|
||||
expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' }),
|
||||
]);
|
||||
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved', sourceEntry: 'enterprise_signature' }) });
|
||||
expect(tx.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
||||
where: { id: 'task-1' },
|
||||
data: expect.objectContaining({ status: 'approved', approvedAt: expect.any(Date) }),
|
||||
});
|
||||
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
|
||||
});
|
||||
|
||||
it('uses the enterprise-signature save time when creating an approved carrier task', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
smsSignature: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
|
||||
},
|
||||
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active' }) },
|
||||
smsDrainageInfo: { findUnique: jest.fn() },
|
||||
channelSignatureReportTask: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'task-new', ...data })),
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'task-new', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'all', carriers: ['mobile', 'unicom', 'telecom'], status: 'active' } },
|
||||
]),
|
||||
},
|
||||
channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) },
|
||||
channelRouteRule: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.changeReportTaskStatuses({
|
||||
items: [{ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', status: 'approved' }],
|
||||
sourceEntry: 'enterprise_signature',
|
||||
});
|
||||
|
||||
expect(tx.channelSignatureReportTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
signatureId: 'sig-1',
|
||||
channelId: 'channel-1',
|
||||
carrier: 'mobile',
|
||||
approvalScope: 'carrier_specific',
|
||||
status: 'approved',
|
||||
approvedAt: expect.any(Date),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('changes a drainage report task without overwriting the signature report summary', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
|
||||
@@ -505,7 +505,7 @@ return streamId`,
|
||||
|
||||
function signatureReportApprovalScopes(carrier: string) {
|
||||
const exact = { carrier, approvalScope: 'carrier_specific' };
|
||||
// 迁移期默认双读;全部历史数据人工拆分后可通过环境开关进入严格运营商口径。
|
||||
// 迁移期保留双读用于平滑发布;自动转换migration完成且兼容命中清零后再切换严格口径。
|
||||
return process.env.SIGNATURE_REPORT_STRICT_CARRIER === 'true'
|
||||
? [exact]
|
||||
: [exact, { carrier: null, approvalScope: 'legacy_channel' }];
|
||||
|
||||
@@ -47,12 +47,3 @@ export interface RetirementMessageQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ConfirmLegacyReportDto {
|
||||
results: Array<{
|
||||
carrier: 'mobile' | 'unicom' | 'telecom';
|
||||
status: 'pending' | 'waiting_material' | 'reporting' | 'approved' | 'failed' | 'rejected' | 'abandoned';
|
||||
approvedAt?: string;
|
||||
}>;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import type { CancelRetirementSuppressionDto, ConfirmLegacyReportDto, CreateRetirementWebhookDto, RetirementMessageQuery, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts';
|
||||
import type { CancelRetirementSuppressionDto, CreateRetirementWebhookDto, RetirementMessageQuery, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts';
|
||||
import { SignatureRetirementService } from './signature-retirement.service';
|
||||
|
||||
@ApiTags('signature-retirement')
|
||||
@@ -97,14 +97,4 @@ export class SignatureRetirementController {
|
||||
return this.service.unreportedSignatures(query);
|
||||
}
|
||||
|
||||
@Get('legacy-report-tasks')
|
||||
listLegacyReportTasks() {
|
||||
return this.service.listLegacyReportTasks();
|
||||
}
|
||||
|
||||
@Post('legacy-report-tasks/:id/confirm')
|
||||
@RequireRecentAuthentication()
|
||||
confirmLegacyReport(@Param('id') id: string, @Body() body: ConfirmLegacyReportDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.service.confirmLegacyReport(id, body, operatorId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { millisecondsUntilShanghaiHour, SignatureRetirementService } from './signature-retirement.service';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
describe('SignatureRetirementService dimensions', () => {
|
||||
const service = new SignatureRetirementService({} as never);
|
||||
@@ -34,15 +33,6 @@ describe('SignatureRetirementService dimensions', () => {
|
||||
expect(dimensions).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects an approved legacy carrier confirmation without an explicit valid approval time', async () => {
|
||||
await expect(service.confirmLegacyReport('legacy-1', {
|
||||
results: [{ carrier: 'mobile', status: 'approved' }],
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(service.confirmLegacyReport('legacy-1', {
|
||||
results: [{ carrier: 'mobile', status: 'approved', approvedAt: 'not-a-date' }],
|
||||
})).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('schedules the next Beijing 04:00 and 08:00 precisely', () => {
|
||||
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T19:00:00.000Z'), 4)).toBe(60 * 60_000);
|
||||
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T21:00:00.000Z'), 4)).toBe(23 * 60 * 60_000);
|
||||
|
||||
@@ -5,11 +5,10 @@ import { decryptSecret, encryptSecret } from '../open-api/open-api.crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { shanghaiDateRange } from '../common/shanghai-date-range';
|
||||
import { normalizeChannelCarriers } from '../channels/channels.helpers';
|
||||
import type { CancelRetirementSuppressionDto, ConfirmLegacyReportDto, CreateRetirementWebhookDto, RetirementMessageQuery, RetirementRuleType, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts';
|
||||
import type { CancelRetirementSuppressionDto, CreateRetirementWebhookDto, RetirementMessageQuery, RetirementRuleType, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const CARRIERS = ['mobile', 'unicom', 'telecom'] as const;
|
||||
const REPORT_STATUSES = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']);
|
||||
const DEFAULT_DELIVERY_INTERVAL_MS = 60_000;
|
||||
const carrierLabels: Record<string, string> = { mobile: '移动', unicom: '联通', telecom: '电信' };
|
||||
|
||||
@@ -396,48 +395,6 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
|
||||
return { notificationDate: notificationKey, created };
|
||||
}
|
||||
|
||||
async listLegacyReportTasks() {
|
||||
return this.prisma.channelSignatureReportTask.findMany({
|
||||
where: { reportType: 'signature', carrier: null, approvalScope: 'legacy_channel', signature: { auditStatus: { not: 'deleted' } }, channel: { status: { not: 'deleted' } } },
|
||||
include: { signature: { include: { tenant: true, application: true } }, channel: true, records: { orderBy: { createdAt: 'desc' }, take: 5 } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async confirmLegacyReport(id: string, data: ConfirmLegacyReportDto, operatorId?: string) {
|
||||
if (!data.results?.length) throw new BadRequestException('至少确认一个运营商结果');
|
||||
const carriers = new Set<string>(data.results.map((item) => item.carrier));
|
||||
if (carriers.size !== data.results.length) throw new BadRequestException('运营商结果不能重复');
|
||||
for (const result of data.results) {
|
||||
if (!REPORT_STATUSES.has(result.status)) throw new BadRequestException('报备状态无效');
|
||||
// 历史通道级通过时间不能代替运营商通过时间,否则仍是在自动伪造运营商事实。
|
||||
if (result.status === 'approved' && !parseApprovedAt(result.approvedAt)) throw new BadRequestException(`${carrierLabels[result.carrier]}通过时间必填且必须有效`);
|
||||
}
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const legacy = await tx.channelSignatureReportTask.findUnique({ where: { id }, include: { channel: true } });
|
||||
if (!legacy || legacy.reportType !== 'signature' || legacy.carrier !== null || legacy.approvalScope !== 'legacy_channel') throw new NotFoundException('历史通道级任务不存在');
|
||||
const supported = normalizeChannelCarriers(legacy.channel.carriers, legacy.channel.carrier);
|
||||
const resultTasks = [];
|
||||
for (const result of data.results) {
|
||||
if (!supported.includes(result.carrier)) throw new BadRequestException('确认运营商不在通道支持范围内');
|
||||
const approvedAt = result.status === 'approved' ? parseApprovedAt(result.approvedAt) : null;
|
||||
const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: legacy.signatureId, channelId: legacy.channelId, carrier: result.carrier, reportType: 'signature', drainageItemId: null } });
|
||||
const task = existing
|
||||
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: result.status, approvedAt, reason: data.reason, approvalScope: 'carrier_specific' } })
|
||||
: await tx.channelSignatureReportTask.create({ data: { tenantId: legacy.tenantId, signatureId: legacy.signatureId, channelId: legacy.channelId, carrier: result.carrier, reportType: 'signature', status: result.status, approvedAt, reason: data.reason, approvalScope: 'carrier_specific', createdById: operatorId } });
|
||||
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'legacy_carrier_confirmed', statusBefore: existing?.status, statusAfter: result.status, reason: data.reason, operatorId, sourceEntry: 'report_task' } });
|
||||
resultTasks.push(task);
|
||||
}
|
||||
await tx.channelSignatureReportRecord.create({ data: { taskId: legacy.id, channelId: legacy.channelId, action: 'legacy_scope_split', statusBefore: legacy.status, statusAfter: legacy.status, reason: data.reason, operatorId, sourceEntry: 'report_task' } });
|
||||
if (supported.every((carrier) => carriers.has(carrier))) {
|
||||
// 全部适用运营商均已人工确认后,旧通道级事实退出发送链兼容读取,避免长期双口径。
|
||||
await tx.channelSignatureReportTask.update({ where: { id: legacy.id }, data: { approvalScope: 'legacy_split' } });
|
||||
}
|
||||
await tx.operationLog.create({ data: { userId: operatorId, action: 'signature_report.legacy_carriers_confirmed', resource: 'channel_signature_report_task', resourceId: legacy.id, detail: { results: data.results, reason: data.reason } as Prisma.InputJsonValue } });
|
||||
return { legacyTaskId: legacy.id, tasks: resultTasks };
|
||||
});
|
||||
}
|
||||
|
||||
private async runStartupCompensation() {
|
||||
const now = new Date();
|
||||
const hour = shanghaiHour(now);
|
||||
@@ -687,13 +644,6 @@ function databaseDate(value: string) {
|
||||
return new Date(`${assertDateKey(value)}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function parseApprovedAt(value?: string) {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) throw new BadRequestException('报备通过时间无效');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function assertRuleType(value: string): asserts value is RetirementRuleType {
|
||||
if (!['enterprise_global', 'enterprise_application', 'channel_global', 'channel'].includes(value)) throw new BadRequestException('不支持的规则类型');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user