feat: remove legacy signature confirmation

This commit is contained in:
hectorzhao
2026-08-10 22:40:13 +08:00
parent 827d8921a8
commit 2ecb24cf8d
15 changed files with 237 additions and 162 deletions
@@ -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('不支持的规则类型');
}