feat: remove legacy signature confirmation
This commit is contained in:
+153
@@ -0,0 +1,153 @@
|
||||
-- 最终业务口径不再保留历史人工确认:旧通道级状态按通道能力一次性形成运营商级事实。
|
||||
-- 已有运营商任务代表更新的事实,必须保留且不得被旧任务覆盖。
|
||||
WITH legacy_targets AS (
|
||||
SELECT
|
||||
legacy.id AS "legacyId",
|
||||
legacy."tenantId",
|
||||
legacy."signatureId",
|
||||
legacy."channelId",
|
||||
legacy.status,
|
||||
legacy.reason,
|
||||
legacy."createdById",
|
||||
supported.carrier
|
||||
FROM "ChannelSignatureReportTask" legacy
|
||||
JOIN "SmsChannel" channel ON channel.id = legacy."channelId"
|
||||
CROSS JOIN LATERAL unnest(
|
||||
CASE
|
||||
WHEN cardinality(channel.carriers) > 0 THEN channel.carriers
|
||||
WHEN channel.carrier = 'all' THEN ARRAY['mobile', 'unicom', 'telecom']::TEXT[]
|
||||
WHEN channel.carrier IN ('mobile', 'unicom', 'telecom') THEN ARRAY[channel.carrier]::TEXT[]
|
||||
ELSE ARRAY['mobile']::TEXT[]
|
||||
END
|
||||
) AS supported(carrier)
|
||||
WHERE legacy."reportType" = 'signature'
|
||||
AND legacy."drainageItemId" IS NULL
|
||||
AND legacy.carrier IS NULL
|
||||
AND legacy."approvalScope" = 'legacy_channel'
|
||||
), inserted_tasks AS (
|
||||
INSERT INTO "ChannelSignatureReportTask" (
|
||||
id,
|
||||
"tenantId",
|
||||
"signatureId",
|
||||
"channelId",
|
||||
carrier,
|
||||
"approvedAt",
|
||||
"approvalScope",
|
||||
"reportType",
|
||||
"drainageItemId",
|
||||
status,
|
||||
reason,
|
||||
"createdById",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
)
|
||||
SELECT
|
||||
'legacy-auto-' || md5(target."legacyId" || ':' || target.carrier),
|
||||
target."tenantId",
|
||||
target."signatureId",
|
||||
target."channelId",
|
||||
target.carrier,
|
||||
CASE WHEN target.status = 'approved' THEN CURRENT_TIMESTAMP ELSE NULL END,
|
||||
'carrier_specific',
|
||||
'signature',
|
||||
NULL,
|
||||
target.status,
|
||||
target.reason,
|
||||
target."createdById",
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
FROM legacy_targets target
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id, "channelId", status
|
||||
)
|
||||
INSERT INTO "ChannelSignatureReportRecord" (
|
||||
id,
|
||||
"taskId",
|
||||
"channelId",
|
||||
action,
|
||||
"statusBefore",
|
||||
"statusAfter",
|
||||
reason,
|
||||
"operatorId",
|
||||
"sourceEntry",
|
||||
"createdAt"
|
||||
)
|
||||
SELECT
|
||||
'legacy-auto-record-' || md5(task.id),
|
||||
task.id,
|
||||
task."channelId",
|
||||
'legacy_carrier_auto_split',
|
||||
NULL,
|
||||
task.status,
|
||||
'历史通道级任务按通道运营商能力自动转换',
|
||||
NULL,
|
||||
'migration',
|
||||
CURRENT_TIMESTAMP
|
||||
FROM inserted_tasks task
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
WITH legacy_targets AS (
|
||||
SELECT
|
||||
legacy.id AS "legacyId",
|
||||
legacy."signatureId",
|
||||
legacy."channelId",
|
||||
supported.carrier
|
||||
FROM "ChannelSignatureReportTask" legacy
|
||||
JOIN "SmsChannel" channel ON channel.id = legacy."channelId"
|
||||
CROSS JOIN LATERAL unnest(
|
||||
CASE
|
||||
WHEN cardinality(channel.carriers) > 0 THEN channel.carriers
|
||||
WHEN channel.carrier = 'all' THEN ARRAY['mobile', 'unicom', 'telecom']::TEXT[]
|
||||
WHEN channel.carrier IN ('mobile', 'unicom', 'telecom') THEN ARRAY[channel.carrier]::TEXT[]
|
||||
ELSE ARRAY['mobile']::TEXT[]
|
||||
END
|
||||
) AS supported(carrier)
|
||||
WHERE legacy."reportType" = 'signature'
|
||||
AND legacy."drainageItemId" IS NULL
|
||||
AND legacy.carrier IS NULL
|
||||
AND legacy."approvalScope" = 'legacy_channel'
|
||||
), completed_legacy AS (
|
||||
SELECT target."legacyId"
|
||||
FROM legacy_targets target
|
||||
LEFT JOIN "ChannelSignatureReportTask" exact
|
||||
ON exact."signatureId" = target."signatureId"
|
||||
AND exact."channelId" = target."channelId"
|
||||
AND exact."reportType" = 'signature'
|
||||
AND exact."drainageItemId" IS NULL
|
||||
AND exact.carrier = target.carrier
|
||||
GROUP BY target."legacyId"
|
||||
HAVING COUNT(DISTINCT target.carrier) = COUNT(DISTINCT exact.carrier)
|
||||
), updated_legacy AS (
|
||||
UPDATE "ChannelSignatureReportTask" legacy
|
||||
SET "approvalScope" = 'legacy_split',
|
||||
"updatedAt" = CURRENT_TIMESTAMP
|
||||
FROM completed_legacy completed
|
||||
WHERE legacy.id = completed."legacyId"
|
||||
AND legacy."approvalScope" = 'legacy_channel'
|
||||
RETURNING legacy.id, legacy."channelId", legacy.status
|
||||
)
|
||||
INSERT INTO "ChannelSignatureReportRecord" (
|
||||
id,
|
||||
"taskId",
|
||||
"channelId",
|
||||
action,
|
||||
"statusBefore",
|
||||
"statusAfter",
|
||||
reason,
|
||||
"operatorId",
|
||||
"sourceEntry",
|
||||
"createdAt"
|
||||
)
|
||||
SELECT
|
||||
'legacy-split-record-' || md5(legacy.id),
|
||||
legacy.id,
|
||||
legacy."channelId",
|
||||
'legacy_scope_auto_split',
|
||||
legacy.status,
|
||||
legacy.status,
|
||||
'全部适用运营商已自动形成独立报备任务',
|
||||
NULL,
|
||||
'migration',
|
||||
CURRENT_TIMESTAMP
|
||||
FROM updated_legacy legacy
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -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('不支持的规则类型');
|
||||
}
|
||||
|
||||
@@ -1989,14 +1989,14 @@
|
||||
- 该变化只作用于通道本体的运营商能力集合。`SmsChannelGroup.carrier`、`SmsChannelGroupItem.carrier`、`ChannelRouteRule.carrier`及短信号码实际运营商仍保持移动/联通/电信单值;一个通道只有在能力集合包含对应运营商时,才允许加入该运营商通道组并参与选路。
|
||||
- 同一通道勾选多个运营商时继续共用一个通道单价,不增加分运营商单价;如未来出现分运营商计价需求,必须另立需求并升级为通道运营商明细模型,不能在本需求中隐式扩展。
|
||||
- 签名报备模型同步从“签名 × 通道”升级为“签名 × 通道 × 运营商”。继续以`ChannelSignatureReportTask`保存当前事实、以`ChannelSignatureReportRecord`保存状态轨迹,不另建重复事实表;任务进入`approved`时记录当前连续通过时间,离开通过状态时结束该连续周期。`reportType`和`drainageItemId`只是共享表技术字段,不属于本需求维度;本需求不改造引流信息报备。
|
||||
- 历史通道级任务进入人工拆分弹窗时,三个运营商状态必须默认未选择,不得继承原通道级状态;操作人必须逐项选择真实状态,选择“已通过”时必须填写该运营商真实通过时间,前后端都要阻止缺失或非法时间提交。
|
||||
- 不再提供“历史待确认”页签、人工拆分弹窗或对应管理API。一次性migration按通道能力集合自动转换全部旧签名任务:旧状态为`approved`时,通道支持的全部运营商均记为已通过且通过时间取migration执行当天;旧状态为其他值时分别继承该状态且通过时间为空。已有运营商级任务不覆盖,旧任务完成后改为`legacy_split`并保留历史。企业签名页面以后新建“已通过”运营商任务时继续以保存时刻作为通过时间。
|
||||
- 生产数据迁移原则为:`mobile→[mobile]`、`unicom→[unicom]`、`telecom→[telecom]`、`all→[mobile,unicom,telecom]`,已删除通道也要保留并迁移历史能力;不得根据当前通道组关联、通道名称或近期流量自动缩减旧`all`通道的能力范围。对历史空值使用旧系统实际兼容口径回填为移动,禁止回填为空集合或伪造为三网。
|
||||
- 取消某个已勾选运营商时,如果该通道仍被对应运营商的活动通道组引用,后端必须返回真实影响并阻止保存,不得自动删除通道组成员、路由、报备任务或历史发送记录;新增运营商能力也不得自动加入通道组或自动视为报备通过。
|
||||
- 发布迁移必须采用向前兼容的分阶段顺序:先增加新能力集合、回填并让后端兼容读取,再开放多选写入。出现两个运营商组合后,旧单值代码无法无损解释该数据,回滚下限必须是已经支持新集合的兼容版本,不能直接回滚到仅识别`mobile/unicom/telecom/all`的旧版本。
|
||||
|
||||
## 签名清退预警(2026-08-10,本地实现完成、待验收与分阶段发布)
|
||||
|
||||
- 企业预警按“企业签名 × 运营商”每天检测,通道预警按“签名 × 通道 × 运营商”每天检测。运营商只要存在当前真实报备通过任务就进入对应监控名单,不等待三网全部成功;历史通道级结果未按运营商确认前不得伪造运营商通过事实。
|
||||
- 企业预警按“企业签名 × 运营商”每天检测,通道预警按“签名 × 通道 × 运营商”每天检测。运营商只要存在当前报备通过任务就进入对应监控名单,不等待三网全部成功;历史通道级任务由一次性migration按最终口径自动转换,不再存在人工确认待办。
|
||||
- 企业预警支持移动、联通、电信通用X天/Y条规则和企业应用特殊规则,特殊规则优先;通道预警支持通用规则和通道特殊规则,特殊规则优先。规则修改从下一检测日生效,预警快照保存命中的规则版本和阈值。
|
||||
- 清退活跃量按至少有一次上游接受的业务短信去重统计。企业维度同一业务短信只计一次;通道维度按`messageRecordId + channelId`去重,同一通道断连、超时或重试产生多次提交只计一次,切换到不同通道后各通道分别计一次。提交尝试、上游接受和最终送达必须分开展示,不把`SubmitResp status=0`称为最终送达成功。
|
||||
- 每天按北京时间完整自然日检测`T-X`至`T-1`。当前连续报备通过时间不足X个完整日时不预警;恢复达标后关闭当前预警周期,以后再次低于阈值形成新周期。每日检测必须以数据库唯一维度保证幂等,多实例或重启不得重复生成消息或Webhook。
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# 签名清退预警与运营商级报备改造设计(本地实现稿)
|
||||
|
||||
> 状态:2026-08-10已按确认口径完成本地兼容实现、迁移演练和自动化验证,修改仍未提交、未推送、未部署。严格运营商级发送门禁保持默认关闭;预生产迁移、历史确认、门禁切换和预警启用仍必须分阶段执行。
|
||||
> 状态:2026-08-10已按最终确认口径完成本地修改和迁移演练,修改仍未提交、未推送、未部署。严格运营商级发送门禁保持默认关闭;预生产migration、门禁切换和预警启用仍必须分阶段执行。
|
||||
|
||||
## 0. 实施状态(2026-08-10)
|
||||
|
||||
- 第1至15步已完成本地代码和文档实现;第16步已完成本地PostgreSQL迁移、专项与全量测试、真实聚合SQL、TypeScript、生产构建及授权后的真实验证码登录浏览器验收。
|
||||
- 本地迁移保留旧报备任务为`legacy_channel`,不自动复制为三个运营商通过;发送链优先使用精确运营商任务,并由`SIGNATURE_REPORT_STRICT_CARRIER`控制从兼容双读切换到严格门禁。
|
||||
- 原签名清退预警功能已经过本地PostgreSQL迁移、专项与全量测试、真实聚合SQL、TypeScript、生产构建及授权后的真实验证码登录浏览器验收;本次最终口径删除历史人工确认功能,并新增自动转换migration。
|
||||
- 旧报备任务通过后续一次性迁移自动转为运营商级任务:按通道能力集合分别建任务,旧状态为`approved`时对应运营商全部记为已通过且`approvedAt`取迁移执行时间;其他状态原样复制且不写通过时间。已有运营商级任务优先保留、不覆盖;旧任务退出兼容范围并保留历史记录。
|
||||
- 本地实现未发送真实短信,未修改生产通道、账号、密码、启停状态、企业余额或客户连接;也未提交、推送或部署。
|
||||
|
||||
## 1. 目标与范围
|
||||
@@ -62,19 +62,14 @@
|
||||
|
||||
## 4. 现有生产数据兼容
|
||||
|
||||
旧单运营商通道能力可无损迁移为单元素集合;旧`all`只表示通道能力覆盖三网,不能证明该签名已在三个运营商分别报备通过。
|
||||
旧单运营商通道能力无损迁移为单元素集合,旧`all`迁移为移动、联通、电信三元素集合。按2026-08-10最终业务口径,不再提供“历史待确认”页签或人工拆分API;历史签名任务统一按通道能力集合自动形成运营商级事实:
|
||||
|
||||
历史签名任务先保留为“历史通道级结果”,不得自动复制为三条运营商级`approved`:
|
||||
- 旧任务为`approved`:通道支持的全部运营商均创建为`approved`,`approvedAt`使用迁移当天实际执行时间;三网通道即三网均通过。
|
||||
- 旧任务为其他状态:通道支持的全部运营商继承该状态,`approvedAt`为空。
|
||||
- 已存在运营商级任务:保持现有状态和通过时间,不被旧任务覆盖。
|
||||
- 全部适用运营商处理完成后,旧任务改为`legacy_split`并永久保留,状态轨迹不删除;迁移重复执行不得重复创建任务或覆盖现有事实。
|
||||
|
||||
```text
|
||||
carrier = null
|
||||
approvalScope = legacy_channel
|
||||
页面状态 = 历史通道级通过(运营商未拆分)
|
||||
```
|
||||
|
||||
运营端提供“拆分并确认运营商报备结果”,由操作员依据供应商真实信息分别确认移动、联通、电信的状态和通过时间,并记录操作人、依据和备注。供应商明确一次报备三网同时生效时,可以人工批量确认三个运营商并使用相同时间,但系统不得自动推断。
|
||||
|
||||
兼容期内旧历史任务继续维持现有发送资格,避免上线新字段后中断真实发送;只有全部活动签名和活动通道完成运营商确认并通过数据门禁后,发送链才切换为严格运营商级校验。
|
||||
企业签名页面以后新建运营商级“已通过”任务时,继续以保存时刻作为`approvedAt`,无需额外填写历史时间。自动转换完成后历史兼容读取不再命中,不保留运营端人工确认入口。
|
||||
|
||||
## 5. 预警规则与生命周期
|
||||
|
||||
@@ -142,9 +137,9 @@ approvalScope = legacy_channel
|
||||
|
||||
在签名页、报备任务页和通道报备详情页展示运营商级状态;同一通道可按三行或可展开三运营商展示。三网摘要只汇总真实运营商任务,不从通道级状态推断。
|
||||
|
||||
### 第9步:历史报备拆分确认
|
||||
### 第9步:历史报备自动转换
|
||||
|
||||
上线“历史通道级通过(运营商未拆分)”清单和人工拆分流程。完成活动数据确认,保留原任务和全部操作轨迹,禁止自动伪造三网通过。
|
||||
使用幂等migration按通道能力集合生成运营商级任务;旧任务为已通过时全部适用运营商均按迁移当天记为已通过,其他状态原样转换。已有运营商任务不覆盖,旧任务转为`legacy_split`并保留。预警页面不提供“历史待确认”页签,后端不暴露历史人工确认接口。
|
||||
|
||||
### 第10步:发送链兼容双读
|
||||
|
||||
|
||||
@@ -4499,7 +4499,7 @@ npm run verify:phase8
|
||||
| TC-CHANNEL-CARRIER-MULTI-003 | 将支持移动和联通但不支持电信的通道分别加入三类通道组并发送对应运营商短信 | 仅允许加入移动、联通通道组;电信组前后端均拒绝;发送链不会把电信短信选到该通道,通道组、路由规则和短信实际运营商仍为单值 |
|
||||
| TC-CHANNEL-CARRIER-MULTI-004 | 取消通道已被活动通道组引用的运营商,再尝试保存 | 后端返回对应真实通道组及影响并阻止保存,不自动删除成员、路由、报备任务或历史数据;解除活动引用后才允许取消 |
|
||||
| TC-CHANNEL-CARRIER-MULTI-005 | 多运营商通道参与移动、联通、电信发送及成本统计 | 三个运营商继续共用通道唯一单价,客户计费和平台成本不因多选被重复计算;本需求不产生分运营商价格 |
|
||||
| TC-CHANNEL-CARRIER-MULTI-006 | 检查签名报备任务、报备记录和三网汇总 | 签名任务按“签名 × 通道 × 运营商”保存和汇总;现有“签名 × 通道”历史记录保留为`legacy_channel`范围,不删除、不自动复制为多个运营商通过结果;引流信息报备维度和页面保持不变 |
|
||||
| TC-CHANNEL-CARRIER-MULTI-006 | 对旧单网、三网通道的签名任务执行自动转换,并检查报备记录和三网汇总 | 签名任务按“签名 × 通道 × 运营商”保存和汇总;旧单网任务生成一个运营商任务,旧三网任务生成三个;旧状态为已通过时全部适用运营商均为已通过且通过时间为migration执行时间,其他状态原样继承;旧任务保留并转为`legacy_split`,引流信息报备维度和页面不变 |
|
||||
| TC-CHANNEL-CARRIER-MULTI-007 | 分阶段部署兼容底座后写入仅支持两个运营商的通道,再执行回滚演练 | 只能回滚到能够读取运营商集合的兼容版本;仅识别旧单值的代码不得重新上线并将双运营商数据误判为三网或单网 |
|
||||
|
||||
## 2026-08-10 运营商级签名报备验收用例(本地自动化与浏览器验收完成)
|
||||
@@ -4510,9 +4510,10 @@ npm run verify:phase8
|
||||
| TC-SIGNATURE-CARRIER-REPORT-002 | 分别修改三个运营商任务状态 | 只改变目标运营商状态并写对应任务记录;签名三网摘要按真实任务分别汇总,不由通道级状态复制 |
|
||||
| TC-SIGNATURE-CARRIER-REPORT-003 | 任务首次通过、退出通过、再次通过 | `approvedAt`分别记录每次连续通过周期的开始时间;退出通过时旧时间不再作为当前监控起点,全部历史变化保留在记录表 |
|
||||
| TC-SIGNATURE-CARRIER-REPORT-004 | 打开签名页、任务页和通道报备详情 | 三个入口展示并操作同一份运营商级任务;运营商、状态、当前通过时间、操作轨迹一致 |
|
||||
| TC-SIGNATURE-CARRIER-REPORT-005 | 查看旧三网通道的历史通过任务 | 显示“历史通道级通过(运营商未拆分)”,不显示为移动/联通/电信分别通过,不进入运营商级清退监控 |
|
||||
| TC-SIGNATURE-CARRIER-REPORT-006 | 使用“拆分并确认运营商报备结果”分别确认三网状态和时间 | 只按人工确认创建或更新运营商任务,记录操作人、依据和备注;原历史任务及轨迹不删除 |
|
||||
| TC-SIGNATURE-CARRIER-REPORT-006A | 打开历史通道级已通过任务的“按运营商确认”弹窗,不进行选择 | 三个运营商均默认“请选择”,不得继承为“已通过”;逐项选择前“确认拆分”不可用,选择“已通过”但未填写有效通过时间时前端不可提交且后端直接拒绝 |
|
||||
| TC-SIGNATURE-CARRIER-REPORT-005 | 查看自动转换后的旧三网通道已通过任务 | 移动、联通、电信分别存在运营商级已通过任务,通过时间统一为migration执行时间;旧通道级任务保留为`legacy_split`且不再参与页面待办或发送兼容读取 |
|
||||
| TC-SIGNATURE-CARRIER-REPORT-006 | 重复执行历史自动转换,且部分运营商级任务在转换前已经存在 | 重复执行不重复创建任务;已有运营商任务的状态、通过时间和操作轨迹均不覆盖,只补齐缺少的适用运营商,随后将旧任务转为`legacy_split` |
|
||||
| TC-SIGNATURE-CARRIER-REPORT-006A | 打开签名清退预警页并检查前后端管理接口 | 页面不存在“历史待确认”页签、数量、表格和确认弹窗;后端不再暴露历史任务列表与确认接口,前端不再发起对应请求 |
|
||||
| TC-SIGNATURE-CARRIER-REPORT-006B | 在企业签名页面将没有运营商级任务的目标保存为“已通过” | 创建真实运营商级任务,`approvedAt`取保存时刻;重复保存已通过状态保持当前连续通过时间,不要求额外填写历史时间 |
|
||||
| TC-SIGNATURE-CARRIER-REPORT-007 | 兼容期发送一条目标运营商短信 | 优先使用运营商级通过任务;只有未拆分历史任务才走受控兼容资格并留下可统计命中记录 |
|
||||
| TC-SIGNATURE-CARRIER-REPORT-008 | 历史未拆分数和兼容资格命中数不为0时尝试启用严格门禁 | 后端或发布门禁阻止切换;清零后才允许严格按“签名 × 通道 × 运营商”选路 |
|
||||
| TC-SIGNATURE-CARRIER-REPORT-009 | 通道断连、提交超时、补发并切换通道 | 每次重新选路都校验目标运营商报备;不选择未报备通道,也不因其他运营商已通过而放行 |
|
||||
@@ -4554,9 +4555,9 @@ npm run verify:phase8
|
||||
|
||||
### 2026-08-10 本地执行状态
|
||||
|
||||
- 已通过真实本地PostgreSQL迁移和数据约束检查、Prisma校验及84条迁移状态、API全量35个suite/444项测试、发送链112项测试、专项服务测试、API TypeScript构建、前端生产构建、4份Gateway队列结构契约、Gateway全量Go测试和`git diff --check`;检测统计SQL已对真实本地数据库执行,不使用Mock、静态数据或localStorage。
|
||||
- 通道能力集合、通道组兼容、运营商级报备、历史任务人工确认、发送资格兼容双读、每日检测幂等、规则版本、预警周期、抑制和Webhook安全边界已有自动化或数据库证据;严格运营商级发送门禁默认不启用,必须在历史未拆分和兼容命中清零后另行切换。
|
||||
- 未向外部Webhook投递验收消息,未发送、补发或重投真实短信。经用户授权使用本地专用平台管理员和真实算术验证码登录:预警5个页签、规则及Webhook弹窗、历史拆分、顶部独立计数、通道三运营商复选及零选拦截、运营商级报备文案、两类热力图与已删除统计模块均完成可见验收,控制台日志为0。页面已显示04:00自动检测、08:00发消息口径且不存在手动检测按钮。浏览器发现的历史三网默认全通过问题已修复并复验为三个“请选择”、确认按钮禁用。
|
||||
- 已通过真实本地PostgreSQL迁移和数据约束检查、Prisma校验及85条迁移状态、API全量35个suite/448项测试、专项服务52项测试、API TypeScript构建、前端生产构建、4份Gateway队列结构契约和`git diff --check`;自动转换SQL已对真实本地数据库执行并重复执行验证幂等,不使用Mock、静态数据或localStorage。
|
||||
- 通道能力集合、通道组兼容、运营商级报备、历史任务自动转换、发送资格兼容双读、每日检测幂等、规则版本、预警周期、抑制和Webhook安全边界已有自动化或数据库证据;严格运营商级发送门禁默认不启用,必须在兼容命中清零后另行切换。
|
||||
- 未向外部Webhook投递验收消息,未发送、补发或重投真实短信。经用户授权使用本地专用平台管理员验收:预警页面只保留“预警消息、检测规则、Webhook、抑制管理”4个页签,不存在“历史待确认”页签和残留提示;切换检测规则页签成功,控制台error/warn为0。页面继续显示04:00自动检测、08:00发消息口径且不存在手动检测按钮。
|
||||
|
||||
## 2026-08-09 通道组按通道筛选用例
|
||||
|
||||
|
||||
@@ -3448,3 +3448,13 @@ git diff --check
|
||||
- API、Gateway、Nginx、PostgreSQL、Redis、MinIO均active,无failed systemd unit;12026、17890、8090、3000、6379、5432和9000端口均监听。内部API/Gateway/MinIO健康通过,Redis PONG,`gateway.submit.commands`消费者1、`pending=0`、`lag=0`;公网运营登录、客户端登录、API health和客户Swagger为200,API专用域名根路径、管理页面和管理接口为404,主站未认证清退接口为401,公网CMPP 17890 TCP连通,发布后API/Gateway error级journal为0。
|
||||
- 9条活动通道恢复为6条`connected 1/1`;“会员营销-富泷”“移动物业-富泷”“联电物业-富泷”继续返回既有供应商`authentication / connect response status: auth failed`,本轮未修改账号、密码、启停状态或连接参数。依赖审计仍报告根项目3项high、API项目3项moderate和4项high,专用安全缓解门禁通过,未执行破坏性自动升级。
|
||||
- 最终发布没有发送、补发或重投真实短信,没有配置或投递Webhook,没有修改企业余额或客户连接。功能提交、慢启动修复和本发布记录均只包含有效源码、migration、测试和文档;`api/tsconfig.build.tsbuildinfo`、`tsconfig.tsbuildinfo`、`outputs/`和空文件`=`继续不提交、不删除。
|
||||
|
||||
## 2026-08-10 删除历史待确认并自动转换历史签名任务(未提交、未发布)
|
||||
|
||||
- 按最终业务口径删除签名清退预警页的“历史待确认”页签、数量、表格、人工确认弹窗、前端请求与类型,以及后端历史任务列表/确认DTO、Controller路由和Service逻辑;企业签名报备详情和状态弹窗同步移除“历史待确认”残留提示。
|
||||
- 新增第85条幂等migration `20260810214500_auto_split_legacy_signature_reports`:旧`legacy_channel`签名任务按通道实际运营商集合补齐缺失的`carrier_specific`任务;旧状态为`approved`时三网通道的移动、联通、电信均记为已通过,`approvedAt`取migration执行时刻,其他状态原样转换且通过时间为空。已有运营商级事实不覆盖;全部适用运营商齐全后旧任务改为`legacy_split`并保留记录。
|
||||
- 企业签名页面新建运营商级“已通过”任务的现有逻辑保持不变:`approvedAt`取保存时刻。新增自动化用例明确验证新建任务写入`Date`,避免未来回归成空值或历史任务时间。
|
||||
- migration前已备份本地真实PostgreSQL到`C:\cmpp-platform-local\backups\cmpp-platform-before-legacy-auto-split-20260810-2145.dump`,514656字节,SHA-256=`f776feb92243afb117b648256f630ad826039985030c4fae21ea631f111a20ea`。本地执行后`legacy_channel=0`、`legacy_split=1`、`carrier_specific=10`;新建3条运营商任务和1条旧任务完成记录,已通过运营商任务`approvedAt`空值为0。原SQL再次执行新增任务0、记录0,数量不变,证明幂等。
|
||||
- 清退与通道专项2个suite/52项通过;API全量分组35/35个suite、448/448项通过。API TypeScript构建、前端TypeScript与Vite 8.1.5生产构建、Prisma validate及85条migration状态、4份Gateway队列结构契约和`git diff --check`均通过;前端仅有既有大chunk提示。整体Jest命令受既有未关闭句柄影响未自行退出,按完整suite清单分两组在全部断言通过后`--forceExit`取得明确退出码0。
|
||||
- 本地浏览器使用真实API和PostgreSQL验收:页面只显示“预警消息、检测规则、Webhook、抑制管理”4个页签,“历史待确认”不可见;切换检测规则成功,页面无框架错误覆盖,控制台error/warn为0。仅重置本地专用`codex_local_admin`临时密码以解锁既有本地会话,未新建账号。
|
||||
- 本轮没有修改预生产数据,没有发送、补发或重投短信,没有修改通道账号、密码、启停状态、企业余额或客户连接;代码、migration、测试和文档均未提交、未推送、未部署。受保护的`api/tsconfig.build.tsbuildinfo`、`tsconfig.tsbuildinfo`、`outputs/`和空文件`=`继续不删除、不提交、不归因于本需求。
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { request, withQuery } from '../core/httpClient';
|
||||
import type {
|
||||
LegacySignatureReportTask,
|
||||
PagedResult,
|
||||
SignatureRetirementHeatmapItem,
|
||||
SignatureRetirementHeatmapDimension,
|
||||
@@ -35,7 +34,4 @@ export const adminSignatureRetirementApi = {
|
||||
getSignatureRetirementHeatmap: (date?: string) => request<{ date: string; dimensions: SignatureRetirementHeatmapDimension[]; items: SignatureRetirementHeatmapItem[] }>(withQuery('/admin/signature-retirement/heatmap', { date })),
|
||||
getUnreportedSignatures: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResult<UnreportedSignatureItem> & { date: string }>(withQuery('/admin/signature-retirement/unreported-signatures', query)),
|
||||
listLegacySignatureReportTasks: () => request<LegacySignatureReportTask[]>('/admin/signature-retirement/legacy-report-tasks'),
|
||||
confirmLegacySignatureReportTask: (id: string, body: { results: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; status: string; approvedAt?: string }>; reason?: string }) =>
|
||||
request<{ legacyTaskId: string }>(`/admin/signature-retirement/legacy-report-tasks/${id}/confirm`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { AdminChannel, ReportTask } from './channels-reports';
|
||||
|
||||
export type SignatureRetirementRuleType = 'enterprise_global' | 'enterprise_application' | 'channel_global' | 'channel';
|
||||
export type SignatureRetirementCarrier = 'mobile' | 'unicom' | 'telecom';
|
||||
|
||||
@@ -104,5 +102,3 @@ export type UnreportedSignatureItem = {
|
||||
applicationName?: string | null;
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
export type LegacySignatureReportTask = ReportTask & { channel: AdminChannel };
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Check, Plus, RefreshCw, Search, Settings2, ShieldOff, Trash2 } from 'lucide-react';
|
||||
import { Plus, RefreshCw, Search, Settings2, ShieldOff, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
adminApi,
|
||||
type AdminChannel,
|
||||
type EnterpriseApplication,
|
||||
type LegacySignatureReportTask,
|
||||
type SignatureRetirementMessage,
|
||||
type SignatureRetirementRule,
|
||||
type SignatureRetirementRuleType,
|
||||
@@ -19,11 +18,6 @@ const carrierLabels = { mobile: '移动', unicom: '联通', telecom: '电信' };
|
||||
const ruleTypeLabels: Record<SignatureRetirementRuleType, string> = {
|
||||
enterprise_global: '企业全局规则', enterprise_application: '企业应用特殊规则', channel_global: '通道全局规则', channel: '通道特殊规则',
|
||||
};
|
||||
const statusOptions = [
|
||||
{ value: 'pending', label: '待处理' }, { value: 'waiting_material', label: '待材料' }, { value: 'reporting', label: '报备中' },
|
||||
{ value: 'approved', label: '已通过' }, { value: 'failed', label: '失败' }, { value: 'rejected', label: '驳回' }, { value: 'abandoned', label: '已放弃' },
|
||||
];
|
||||
|
||||
type RuleDraft = {
|
||||
ruleType: SignatureRetirementRuleType; targetId: string; enabled: boolean;
|
||||
mobileWindowDays: string; mobileThreshold: string; unicomWindowDays: string; unicomThreshold: string;
|
||||
@@ -61,7 +55,6 @@ export function AdminSignatureRetirementPage() {
|
||||
const [webhooks, setWebhooks] = useState<SignatureRetirementWebhook[]>([]);
|
||||
const [messages, setMessages] = useState<SignatureRetirementMessage[]>([]);
|
||||
const [suppressions, setSuppressions] = useState<SignatureRetirementSuppression[]>([]);
|
||||
const [legacyTasks, setLegacyTasks] = useState<LegacySignatureReportTask[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
@@ -75,23 +68,21 @@ export function AdminSignatureRetirementPage() {
|
||||
const [ruleDraft, setRuleDraft] = useState<RuleDraft | null>(null);
|
||||
const [webhookOpen, setWebhookOpen] = useState(false);
|
||||
const [webhookDraft, setWebhookDraft] = useState({ name: '', platform: 'wecom' as 'wecom' | 'feishu', url: '' });
|
||||
const [legacyDraft, setLegacyDraft] = useState<LegacySignatureReportTask | null>(null);
|
||||
const [legacyResults, setLegacyResults] = useState<Record<string, { status: string; approvedAt: string }>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadData = useCallback(async (targetPage = 1, filters = defaultMessageFilters()) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [configuration, messageResult, activeSuppressions, history, applicationRows, channelRows, tenantRows] = await Promise.all([
|
||||
const [configuration, messageResult, activeSuppressions, applicationRows, channelRows, tenantRows] = await Promise.all([
|
||||
adminApi.getSignatureRetirementConfiguration(),
|
||||
adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)),
|
||||
adminApi.listSignatureRetirementSuppressions(), adminApi.listLegacySignatureReportTasks(),
|
||||
adminApi.listSignatureRetirementSuppressions(),
|
||||
adminApi.listEnterpriseApplications(), adminApi.listChannels(), adminApi.listTenants(),
|
||||
]);
|
||||
setRules(configuration.rules); setWebhooks(configuration.webhooks.filter((item) => item.status === 'active'));
|
||||
setMessages(messageResult.items); setMessageTotal(messageResult.total); setMessagePage(messageResult.page);
|
||||
setSuppressions(activeSuppressions); setLegacyTasks(history);
|
||||
setSuppressions(activeSuppressions);
|
||||
setApplications(applicationRows); setChannels(channelRows); setTenants(tenantRows); setError('');
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '签名清退预警数据加载失败');
|
||||
@@ -133,13 +124,6 @@ export function AdminSignatureRetirementPage() {
|
||||
{ key: 'reason', title: '原因', render: (item) => item.reason || '-' },
|
||||
{ key: 'actions', title: '操作', align: 'right', render: (item) => <Button icon={<ShieldOff size={15} />} onClick={() => { setActionError(''); setCancelSuppressionDraft({ id: item.id, reason: '' }); }} size="sm" variant="ghost">取消抑制</Button> },
|
||||
];
|
||||
const legacyColumns: Array<TableColumn<LegacySignatureReportTask>> = [
|
||||
{ key: 'signature', title: '签名', render: (item) => <><strong>{item.signature?.name}</strong><br /><small>{item.signature?.tenant?.name}</small></> },
|
||||
{ key: 'channel', title: '历史通道', render: (item) => <>{item.channel?.name}<br /><small>{supportedCarriers(item).map((carrier) => carrierLabels[carrier]).join('、')}</small></> },
|
||||
{ key: 'status', title: '原状态', render: (item) => <Tag tone={item.status === 'approved' ? 'success' : 'neutral'}>{statusLabel(item.status)}</Tag> },
|
||||
{ key: 'actions', title: '操作', align: 'right', render: (item) => <Button icon={<Check size={15} />} onClick={() => openLegacy(item)} size="sm">按运营商确认</Button> },
|
||||
];
|
||||
|
||||
async function saveRule() {
|
||||
if (!ruleDraft) return;
|
||||
setLoading(true);
|
||||
@@ -188,20 +172,6 @@ export function AdminSignatureRetirementPage() {
|
||||
}
|
||||
async function deleteWebhook(id: string) { if (!window.confirm('确认停用该Webhook?')) return; await adminApi.deleteSignatureRetirementWebhook(id); await loadData(messagePage, appliedMessageFilters); }
|
||||
async function readAll() { await adminApi.readAllSignatureRetirementMessagesToday(); await loadMessages(messagePage, appliedMessageFilters); window.dispatchEvent(new Event('cmpp-retirement-count-refresh')); }
|
||||
function openLegacy(task: LegacySignatureReportTask) {
|
||||
setLegacyDraft(task);
|
||||
// 历史通道级结果不能推导任何运营商状态;留空迫使操作人逐项依据供应商结果确认。
|
||||
setLegacyResults(Object.fromEntries(supportedCarriers(task).map((carrier) => [carrier, { status: '', approvedAt: '' }])));
|
||||
}
|
||||
async function confirmLegacy() {
|
||||
if (!legacyDraft) return;
|
||||
await adminApi.confirmLegacySignatureReportTask(legacyDraft.id, {
|
||||
results: supportedCarriers(legacyDraft).map((carrier) => ({ carrier, status: legacyResults[carrier]?.status ?? 'pending', approvedAt: legacyResults[carrier]?.status === 'approved' && legacyResults[carrier]?.approvedAt ? new Date(legacyResults[carrier].approvedAt).toISOString() : undefined })),
|
||||
reason: '历史通道级报备按运营商人工确认',
|
||||
});
|
||||
setLegacyDraft(null); await loadData(messagePage, appliedMessageFilters);
|
||||
}
|
||||
|
||||
const tenantOptions = tenants.map((item) => ({ value: item.id, label: item.name }));
|
||||
const applicationOptions = applications
|
||||
.filter((item) => !messageFilters.tenantId || item.tenantId === messageFilters.tenantId)
|
||||
@@ -220,7 +190,6 @@ export function AdminSignatureRetirementPage() {
|
||||
{ value: 'rules', label: '检测规则', content: <div className="surface"><div className="section-heading"><div><h2>检测规则</h2><p className="muted">特殊规则优先于全局规则;修改后从下一检测日生效。</p></div><Button icon={<Plus size={16} />} onClick={() => setRuleDraft({ ...emptyRule })}>新增规则</Button></div><Table columns={ruleColumns} data={rules} emptyText="暂无规则,未配置规则的维度不会进入检测" pagination={false} rowKey="id" /></div> },
|
||||
{ value: 'webhooks', label: 'Webhook', content: <div className="surface"><div className="section-heading"><div><h2>企业微信 / 飞书通知</h2><p className="muted">地址加密保存,发送失败自动退避重试。</p></div><Button icon={<Plus size={16} />} onClick={() => setWebhookOpen(true)}>新增Webhook</Button></div><div className="settings-list">{webhooks.map((item) => <div className="settings-list__item" key={item.id}><div><strong>{item.name}</strong><p>{item.platform === 'wecom' ? '企业微信' : '飞书'} · {item.urlMasked}</p></div><Button icon={<Trash2 size={15} />} onClick={() => void deleteWebhook(item.id)} variant="ghost">停用</Button></div>)}{!webhooks.length ? <p className="empty-state">暂无Webhook</p> : null}</div></div> },
|
||||
{ value: 'suppressions', label: `抑制管理(${suppressions.length})`, content: <div className="surface"><Table columns={suppressionColumns} data={suppressions} emptyText="暂无有效抑制" pagination={false} rowKey="id" /></div> },
|
||||
{ value: 'legacy', label: `历史待确认(${legacyTasks.length})`, content: <div className="surface"><div className="section-heading"><div><h2>历史运营商待确认</h2><p className="muted">不自动把三网通道解释为三个运营商均已通过;由人工按真实供应商结果拆分。</p></div></div><Table columns={legacyColumns} data={legacyTasks} emptyText="历史通道级报备均已确认" pagination={false} rowKey="id" /></div> },
|
||||
];
|
||||
|
||||
return <section className="page-stack">
|
||||
@@ -234,7 +203,6 @@ export function AdminSignatureRetirementPage() {
|
||||
<Modal dirty={Boolean(cancelSuppressionDraft?.reason.trim())} open={Boolean(cancelSuppressionDraft)} title="取消抑制" onClose={() => { setCancelSuppressionDraft(null); setActionError(''); }} footer={<><Button onClick={() => { setCancelSuppressionDraft(null); setActionError(''); }} variant="ghost">取消</Button><Button disabled={loading || !cancelSuppressionDraft?.reason.trim()} onClick={() => void confirmCancelSuppression()}>确认取消抑制</Button></>}>
|
||||
{cancelSuppressionDraft ? <div className="page-stack"><p className="muted">取消后从下一检测日恢复预警,不补发抑制期间的历史通知。</p><Textarea label="取消原因" maxLength={500} onChange={(event) => setCancelSuppressionDraft((value) => value ? { ...value, reason: event.target.value } : value)} placeholder="请填写取消抑制原因" rows={4} value={cancelSuppressionDraft.reason} />{actionError ? <p className="form-error">{actionError}</p> : null}</div> : null}
|
||||
</Modal>
|
||||
<Modal open={Boolean(legacyDraft)} title="按运营商确认历史报备结果" onClose={() => setLegacyDraft(null)} footer={<><Button onClick={() => setLegacyDraft(null)} variant="ghost">取消</Button><Button disabled={!legacyConfirmationReady(legacyDraft, legacyResults)} onClick={() => void confirmLegacy()}>确认拆分</Button></>}><div className="page-stack"><p className="muted">历史通道级结果不代表任一运营商已通过,请逐项依据供应商真实结果确认;选择“已通过”时必须填写通过时间。</p>{legacyDraft ? supportedCarriers(legacyDraft).map((carrier) => <div className="surface" key={carrier}><strong>{carrierLabels[carrier]}</strong><div className="form-grid"><Select label="报备状态" onChange={(event) => setLegacyResults((value) => ({ ...value, [carrier]: { ...value[carrier], status: event.target.value, approvedAt: event.target.value === 'approved' ? value[carrier]?.approvedAt ?? '' : '' } }))} options={[{ value: '', label: '请选择' }, ...statusOptions]} value={legacyResults[carrier]?.status ?? ''} /><Input disabled={legacyResults[carrier]?.status !== 'approved'} label="通过时间" onChange={(event) => setLegacyResults((value) => ({ ...value, [carrier]: { ...value[carrier], approvedAt: event.target.value } }))} type="datetime-local" value={legacyResults[carrier]?.approvedAt ?? ''} /></div></div>) : null}</div></Modal>
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -247,15 +215,6 @@ function RuleModal({ applications, channels, draft, loading, onChange, onClose,
|
||||
|
||||
function ruleToDraft(rule: SignatureRetirementRule): RuleDraft { return { ruleType: rule.ruleType, targetId: rule.targetId ?? '', enabled: rule.enabled, mobileWindowDays: String(rule.mobileWindowDays), mobileThreshold: String(rule.mobileThreshold), unicomWindowDays: String(rule.unicomWindowDays), unicomThreshold: String(rule.unicomThreshold), telecomWindowDays: String(rule.telecomWindowDays), telecomThreshold: String(rule.telecomThreshold), messageTemplate: rule.messageTemplate ?? '' }; }
|
||||
function targetName(rule: SignatureRetirementRule, applications: EnterpriseApplication[], channels: AdminChannel[]) { if (!rule.targetId) return '全局默认'; return rule.ruleType === 'channel' ? channels.find((item) => item.id === rule.targetId)?.name ?? rule.targetId : applications.find((item) => item.id === rule.targetId)?.name ?? rule.targetId; }
|
||||
function supportedCarriers(task: LegacySignatureReportTask) { const values = task.channel?.carriers?.length ? task.channel.carriers : task.channel?.carrier === 'all' ? [...carriers] : [task.channel?.carrier]; return values.filter((item): item is typeof carriers[number] => carriers.includes(item as typeof carriers[number])); }
|
||||
function statusLabel(status: string) { return statusOptions.find((item) => item.value === status)?.label ?? status; }
|
||||
function legacyConfirmationReady(task: LegacySignatureReportTask | null, results: Record<string, { status: string; approvedAt: string }>) {
|
||||
return Boolean(task && supportedCarriers(task).every((carrier) => {
|
||||
const result = results[carrier];
|
||||
return statusOptions.some((option) => option.value === result?.status) && (result.status !== 'approved' || Boolean(result.approvedAt));
|
||||
}));
|
||||
}
|
||||
function localDateTime(value?: string | null) { if (!value) return ''; const date = new Date(value); const offset = date.getTimezoneOffset() * 60_000; return new Date(date.getTime() - offset).toISOString().slice(0, 16); }
|
||||
function formatDate(value?: string | null) { return value ? new Date(value).toLocaleDateString('zh-CN') : '-'; }
|
||||
function formatDateTime(value?: string | null) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'; }
|
||||
function errorMessage(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; }
|
||||
|
||||
@@ -27,7 +27,7 @@ export function SignatureReportModal({ item, onClose }: { item: ClientSmsSignatu
|
||||
<button className="admin-report-carrier--unicom active" type="button"><strong>联通</strong><span><CarrierReportTag summary={item.carrierReportSummary?.unicom} /></span></button>
|
||||
<button className="admin-report-carrier--telecom active" type="button"><strong>电信</strong><span><CarrierReportTag summary={item.carrierReportSummary?.telecom} /></span></button>
|
||||
</div>
|
||||
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={`${target.channelId}:${target.carrier}`} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({carrierLabel(target.carrier)}){target.approvalScope === 'legacy_channel' ? ' · 历史通道级结果(待确认)' : ''}</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
<div className="page-stack">{(item.reportTargets ?? []).map((target) => <div className="surface" key={`${target.channelId}:${target.carrier}`} style={{ display: 'flex', justifyContent: 'space-between', padding: 12 }}><span>{target.channel.name}({carrierLabel(target.carrier)})</span><CarrierReportTag summary={{ status: target.status, approved: target.status === 'approved' ? 1 : 0, total: 1 }} /></div>)}</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
@@ -49,7 +49,7 @@ export function ChannelReportStatusModal({ item, onClose, onSaved }: { item: Cli
|
||||
return <Modal footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!targets.length || saving} onClick={() => void save()}>{saving ? '保存中...' : '保存状态'}</Button></>} onClose={onClose} open size="xl" title="按通道修改签名报备状态">
|
||||
<div className="page-stack"><div className="signature-alert"><Info size={18} /><span>企业签名只展示汇总结果;这里修改的是每个具体通道的报备任务,保存后会同步通道详情、报备任务和企业签名三网状态。</span></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{targets.length ? targets.map((target) => { const key = `${target.channelId}:${target.carrier}`; return <div className="surface admin-report-target-row" key={key}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.carrier)}{target.approvalScope === 'legacy_channel' ? ' · 历史通道级结果,保存后转为运营商级确认' : ''}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [key]: event.target.value }))} options={reportStatusOptions} value={statuses[key] ?? target.status} /></div>; }) : <div className="empty-state">该企业应用当前没有配置目标通道。</div>}
|
||||
{targets.length ? targets.map((target) => { const key = `${target.channelId}:${target.carrier}`; return <div className="surface admin-report-target-row" key={key}><div><strong>{target.channel.name}</strong><div className="muted">{carrierLabel(target.carrier)}</div></div><Select onChange={(event) => setStatuses((current) => ({ ...current, [key]: event.target.value }))} options={reportStatusOptions} value={statuses[key] ?? target.status} /></div>; }) : <div className="empty-state">该企业应用当前没有配置目标通道。</div>}
|
||||
<Textarea label="修改原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入运营商工单、确认依据或人工处理说明" rows={3} value={reason} />
|
||||
</div>
|
||||
</Modal>;
|
||||
|
||||
Reference in New Issue
Block a user