feat: 增加模板通道拒收策略并修复运营页面

This commit is contained in:
hectorzhao
2026-09-20 15:09:57 +08:00
parent c20c2246b2
commit b24cd7c08d
38 changed files with 2485 additions and 613 deletions
@@ -0,0 +1,4 @@
ALTER TABLE "SmsTemplate" ADD COLUMN "optOutRules" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "SmsTemplate" ADD CONSTRAINT "SmsTemplate_optOutRules_array" CHECK (jsonb_typeof("optOutRules") = 'array');
ALTER TABLE "SmsMessageRecord" ADD COLUMN "originalContent" TEXT;
ALTER TABLE "SmsSubmitRecord" ADD COLUMN "sentContent" TEXT, ADD COLUMN "contentPolicy" JSONB;
+4
View File
@@ -853,6 +853,7 @@ model SignatureMaterial {
}
model SmsTemplate {
optOutRules Json @default("[]")
id String @id @default(cuid())
tenantId String
applicationId String
@@ -1831,6 +1832,7 @@ model SmsDrainageDecision {
}
model SmsMessageRecord {
originalContent String?
channelWordDecisions SmsChannelSensitiveDecision[]
channelWordFinalizationPending Boolean @default(false)
monitorFacts SendingMonitorFact[]
@@ -1924,6 +1926,8 @@ model CmppSubmitSession {
}
model SmsSubmitRecord {
sentContent String?
contentPolicy Json?
drainageGate Json?
id String @id @default(cuid())
tenantId String?
@@ -0,0 +1,51 @@
import { ChannelConfigurationService } from './channel-configuration.service';
import { selectChannelCandidate } from '../send-chain/send-chain.helpers';
describe('carrier capability reduction', () => {
it('preserves group references and avoids reconnecting for a capability-only change', async () => {
const channel = {
id: 'c',
carrier: 'all',
carriers: ['mobile', 'unicom', 'telecom'],
status: 'active',
config: {},
};
const prisma = {
smsChannel: {
findUnique: jest.fn().mockResolvedValue(channel),
update: jest.fn().mockImplementation(({ data }) => ({ ...channel, ...data })),
},
operationLog: { create: jest.fn() },
smsChannelGroupItem: {
findMany: jest.fn().mockResolvedValue([{ group: { name: 'existing' } }]),
deleteMany: jest.fn(),
},
};
const connection = { requestChannelConnection: jest.fn(), requestChannelDisconnection: jest.fn() };
await new ChannelConfigurationService(prisma as never, connection as never).updateChannel('c', {
carriers: ['mobile', 'unicom'],
});
expect(prisma.smsChannel.update).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ carriers: ['mobile', 'unicom'] }) }),
);
expect(prisma.smsChannelGroupItem.deleteMany).not.toHaveBeenCalled();
expect(connection.requestChannelConnection).not.toHaveBeenCalled();
const candidate = {
channelId: 'c',
carrier: 'telecom',
channel: {
...channel,
carrier: 'mobile',
carriers: ['mobile', 'unicom'],
sendRegion: '全国',
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
},
};
expect(
selectChannelCandidate([candidate], {
carrier: 'telecom',
excludedChannelIds: new Set(),
approvedChannelIds: new Set(['c']),
}),
).toBeUndefined();
});
});
@@ -1,17 +1,26 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { assertMoneyUnits, moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, legacyCarrierFromCapabilities, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers';
import { ChannelConnectionService } from './channel-connection.service';
import type { ChangeChannelStatusDto, CreateChannelDto, UpdateChannelDto } from './channels.contracts';
import {
channelConnectionSettingsChanged,
currentShanghaiDayRange,
legacyCarrierFromCapabilities,
normalizeBusinessCarrier,
normalizeChannelCarriers,
normalizeChannelRateLimit,
normalizeChannelRuntimeConfig,
normalizeCmppVersion,
} from './channels.helpers';
/** R5 channel domain service composed behind ChannelsService. */
export class ChannelConfigurationService {
constructor(private readonly prisma: PrismaService, private readonly connection: ChannelConnectionService) {}
constructor(
private readonly prisma: PrismaService,
private readonly connection: ChannelConnectionService,
) {}
listChannels() {
return this.prisma.smsChannel.findMany({
@@ -20,7 +29,13 @@ export class ChannelConfigurationService {
});
}
async listChannelsPage(query: { keyword?: string; carrier?: string; status?: string; page?: number; pageSize?: number }) {
async listChannelsPage(query: {
keyword?: string;
carrier?: string;
status?: string;
page?: number;
pageSize?: number;
}) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const where: Prisma.SmsChannelWhereInput = {
@@ -44,12 +59,18 @@ export class ChannelConfigurationService {
const countByChannel = new Map(counts.map((row) => [row.channelId, Number(row.total)]));
// 排序必须发生在分页前,否则只能重排当前页,翻页后会破坏“今日提交量降序”的业务口径。
const pageIds = candidates
.sort((left, right) => (countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0)
|| left.name.localeCompare(right.name, 'zh-CN')
|| left.id.localeCompare(right.id))
.sort(
(left, right) =>
(countByChannel.get(right.id) ?? 0) - (countByChannel.get(left.id) ?? 0) ||
left.name.localeCompare(right.name, 'zh-CN') ||
left.id.localeCompare(right.id),
)
.slice((page - 1) * pageSize, page * pageSize)
.map((channel) => channel.id);
const pageItems = await this.prisma.smsChannel.findMany({ where: { id: { in: pageIds } }, include: { connectionStates: true } });
const pageItems = await this.prisma.smsChannel.findMany({
where: { id: { in: pageIds } },
include: { connectionStates: true },
});
const itemById = new Map(pageItems.map((item) => [item.id, item]));
const items = pageIds.flatMap((id) => {
const item = itemById.get(id);
@@ -122,11 +143,12 @@ export class ChannelConfigurationService {
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
}
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
const config = data.config !== undefined
|| data.desiredConnections !== undefined
|| data.windowSize !== undefined
|| data.heartbeatIntervalSeconds !== undefined
|| data.heartbeatMissThreshold !== undefined
const config =
data.config !== undefined ||
data.desiredConnections !== undefined ||
data.windowSize !== undefined ||
data.heartbeatIntervalSeconds !== undefined ||
data.heartbeatMissThreshold !== undefined
? normalizeChannelRuntimeConfig(
channel.config,
data.config,
@@ -136,25 +158,13 @@ export class ChannelConfigurationService {
data.heartbeatMissThreshold,
)
: undefined;
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
? undefined
: normalizeChannelRateLimit(data.rateLimitPerSecond);
const rateLimitPerSecond =
data.rateLimitPerSecond === undefined ? undefined : normalizeChannelRateLimit(data.rateLimitPerSecond);
const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier);
const carriers = data.carriers !== undefined || data.carrier !== undefined
const carriers =
data.carriers !== undefined || data.carrier !== undefined
? normalizeChannelCarriers(data.carriers, data.carrier)
: existingCarriers;
if (data.carriers !== undefined || data.carrier !== undefined) {
const removed = existingCarriers.filter((carrier) => !carriers.includes(carrier));
if (removed.length) {
const blockingGroups = await this.prisma.smsChannelGroupItem.findMany({
where: { channelId, group: { status: 'active', carrier: { in: removed } } },
include: { group: true },
});
if (blockingGroups.length) {
throw new BadRequestException(`请先解除以下活动通道组引用:${blockingGroups.map((item) => item.group.name).join('、')}`);
}
}
}
const connectionConfigChanged = channelConnectionSettingsChanged(channel, {
gatewayHost: data.gatewayHost ?? channel.gatewayHost,
gatewayPort: gatewayPort ?? channel.gatewayPort,
@@ -168,7 +178,10 @@ export class ChannelConfigurationService {
data: {
code: data.code,
name: data.name,
carrier: data.carriers !== undefined || data.carrier !== undefined ? legacyCarrierFromCapabilities(carriers) : undefined,
carrier:
data.carriers !== undefined || data.carrier !== undefined
? legacyCarrierFromCapabilities(carriers)
: undefined,
carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined,
sendRegion: data.sendRegion,
protocol: 'CMPP',
+1
View File
@@ -268,6 +268,7 @@ export function clientMessageView(message: Record<string, any>) {
carrier: message.carrier ?? null,
province: message.province ?? null,
content: message.content,
originalContent: message.originalContent ?? null,
drainageGate: message.drainageGate
? {
version: message.drainageGate.version,
@@ -84,6 +84,7 @@ export class OperationsMessageQueries {
carrier: true,
province: true,
content: true,
originalContent: true,
hasDrainageContent: true,
drainageDetection: true,
billingUnits: true,
@@ -115,8 +116,11 @@ export class OperationsMessageQueries {
application: { select: { id: true, name: true } },
channel: { select: { id: true, name: true, srcId: true } },
submitRecords: {
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
select: {
id: true,
sentContent: true,
contentPolicy: true,
submitId: true,
channelId: true,
channelGroupId: true,
+11 -21
View File
@@ -376,6 +376,12 @@ export class OperationsQualityQueries {
message.status,
message."submitStatus" AS submit_status,
message."receiptStatus" AS receipt_status,
CASE
WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN 'success'
WHEN message.status = 'submit_failed' OR message."submitStatus" IN ('rejected', 'timeout') THEN 'submit_failed'
WHEN message."receiptStatus" = 'undelivered' OR (message.status = 'failed' AND message."receiptStatus" IS NOT NULL AND message."receiptStatus" <> 'unknown') THEN 'failure'
ELSE 'unknown'
END AS quality_status,
CASE
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
AND message."submittedAt" IS NOT NULL
@@ -403,27 +409,11 @@ export class OperationsQualityQueries {
tenant.name AS "tenantName",
STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames",
COUNT(base.signature_id)::integer AS total,
COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
)::integer AS "acceptedCount",
COUNT(base.signature_id) FILTER (
WHERE base.status = 'submit_failed'
OR base.submit_status IN ('rejected', 'timeout')
)::integer AS "submitFailureCount",
COUNT(base.signature_id) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "unknownCount",
COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "failureCount",
COUNT(base.signature_id) FILTER (WHERE base.quality_status <> 'submit_failed')::integer AS "acceptedCount",
COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'submit_failed')::integer AS "submitFailureCount",
COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'success')::integer AS "successCount",
COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'unknown')::integer AS "unknownCount",
COUNT(base.signature_id) FILTER (WHERE base.quality_status = 'failure')::integer AS "failureCount",
CASE
WHEN COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
@@ -11,6 +11,14 @@ const items = ['a', 'b'].map((channelId, index) => ({ channelId, carrier: 'mobil
const options = { carrier: 'mobile', excludedChannelIds: new Set<string>(), approvedChannelIds: new Set(['a', 'b']) };
const rule = { id: 'word-a', channelId: 'a', word: '贷款', version: 1 };
describe('channel sensitive routing snapshot', () => {
it('checks rewritten content separately for each candidate', () => {
const snapshot = new ChannelWordSnapshot([{ ...rule, word: '拒收请回复R' }]);
expect(
snapshot.select('m', '正文拒收请回复R', items, options, (id) => (id === 'a' ? '正文' : '正文拒收请回复R'))
.selected?.channelId,
).toBe('a');
expect(snapshot.select('n', '正文', items, options, () => '正文拒收请回复R').selected?.channelId).toBe('b');
});
it('removes only matching eligible channels before original priority selection', () => {
const snapshot = new ChannelWordSnapshot([rule]);
expect(snapshot.select('m', '【签名】贷款业务', items, options).selected?.channelId).toBe('b');
@@ -40,13 +40,16 @@ export class ChannelWordSnapshot {
content: string,
items: T[],
options: Parameters<typeof selectChannelCandidate>[1],
contentForChannel?: (channelId: string) => string,
) {
const candidates = items.filter((item) => selectChannelCandidate([item], options));
const candidateIds = new Set(candidates.map((item) => item.channelId));
const names = new Map(items.map((item) => [item.channelId, (item.channel as { name?: string }).name]));
const hits = this.hits(content)
.filter((hit) => candidateIds.has(hit.channelId))
.map((hit) => ({ ...hit, channelName: names.get(hit.channelId) ?? hit.channelId }));
const hits = (
contentForChannel
? [...candidateIds].flatMap((id) => this.hits(contentForChannel(id)).filter((hit) => hit.channelId === id))
: this.hits(content).filter((hit) => candidateIds.has(hit.channelId))
).map((hit) => ({ ...hit, channelName: names.get(hit.channelId) ?? hit.channelId }));
const excluded = new Set([...options.excludedChannelIds, ...hits.map((hit) => hit.channelId)]);
const selected = selectChannelCandidate(items, { ...options, excludedChannelIds: excluded });
const rejected = !selected && candidates.length > 0 && hits.length > 0;
@@ -59,6 +62,13 @@ export class ChannelWordSnapshot {
readAt: this.readAt,
stage: 'route',
contentHash: createHash('sha256').update(content).digest('hex'),
...(contentForChannel
? {
candidateContentHashes: Object.fromEntries(
[...candidateIds].map((id) => [id, createHash('sha256').update(contentForChannel(id)).digest('hex')]),
),
}
: {}),
candidateChannelIds: [...candidateIds],
excludedChannelIds: hits.map((hit) => hit.channelId),
hits,
@@ -41,10 +41,12 @@ export class DrainageSubmitGuardController {
if (
!submit ||
submit.channelId !== body.channelId ||
createHash('sha256').update(submit.messageRecord.content).digest('hex') !== body.contentHash
createHash('sha256')
.update(submit.sentContent ?? submit.messageRecord.content)
.digest('hex') !== body.contentHash
)
return { allowed: false, code: 'DRN', reason: '提交意图与真实消息不一致' };
let message = submit.messageRecord;
let message = { ...submit.messageRecord, content: submit.sentContent ?? submit.messageRecord.content };
if (!message.signatureId && message.templateId && message.tenantId && message.applicationId) {
const template = await tx.smsTemplate.findFirst({
where: { id: message.templateId, tenantId: message.tenantId, applicationId: message.applicationId },
@@ -258,6 +258,7 @@ export interface SendJob {
export type QueuePriority = 'normal' | 'priority';
export type RoutedChannel = {
contentPolicy?: import('./template-optout-policy').ContentPolicyDecision;
channel: {
id: string;
code: string;
@@ -1,3 +1,4 @@
import { applyOptOutRule, loadOptOutPolicies, policyAudit } from './template-optout-policy';
import { completionContext } from './completion-context';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
@@ -300,7 +301,9 @@ export class SendGatewaySubmitService {
sessionByChannel.set(channelId, await this.getOpenSubmitSessionId(channelId));
}),
);
const prepared = planned.map(({ message, routed }) => {
const prepared = planned.map(({ message: input, routed }) => {
const decision = routed.contentPolicy ?? applyOptOutRule(input);
const message = { ...input, content: decision.content };
const submitId = `SUB-${randomUUID()}`;
const upstreamSrcId = composeUpstreamSrcId(routed.channel.srcId, message.applicationExtension);
return {
@@ -308,6 +311,7 @@ export class SendGatewaySubmitService {
routed,
submitId,
command: this.buildGatewaySubmitCommand(message, routed, 0, submitId, upstreamSrcId),
decision,
sessionId: sessionByChannel.get(routed.channel.id),
};
});
@@ -315,7 +319,9 @@ export class SendGatewaySubmitService {
await this.measureSendStage('submit_transaction', () =>
this.prisma.$transaction(async (tx) => {
await tx.smsSubmitRecord.createMany({
data: prepared.map(({ message, routed, submitId, sessionId }) => ({
data: prepared.map(({ message, routed, submitId, sessionId, decision }) => ({
sentContent: message.content,
contentPolicy: policyAudit(decision),
id: randomUUID(),
tenantId: message.tenantId,
batchTaskId: message.batchTaskId,
@@ -332,15 +338,18 @@ export class SendGatewaySubmitService {
});
const updates = Prisma.join(
prepared.map(
({ message, routed, submitId }) => Prisma.sql`(
({ message, routed, submitId, decision }) => Prisma.sql`(
${message.id}::text, ${routed.channel.id}::text, ${routed.carrier}::text,
${routed.province ?? null}::text, ${submitId}::text
${routed.province ?? null}::text, ${submitId}::text, ${message.content}::text,
${message.originalContent ?? (decision.content !== decision.originalContent ? decision.originalContent : null)}::text
)`,
),
);
await tx.$executeRaw(Prisma.sql`
UPDATE "SmsMessageRecord" AS message
SET "channelId" = updates."channelId",
SET content = updates.content,
"originalContent" = COALESCE(message."originalContent", updates."originalContent"),
"channelId" = updates."channelId",
carrier = updates.carrier,
province = updates.province,
"submitId" = updates."submitId",
@@ -350,7 +359,7 @@ export class SendGatewaySubmitService {
"errorCode" = NULL,
"errorMessage" = NULL,
"updatedAt" = (NOW() AT TIME ZONE 'UTC')
FROM (VALUES ${updates}) AS updates(id, "channelId", carrier, province, "submitId")
FROM (VALUES ${updates}) AS updates(id, "channelId", carrier, province, "submitId", content, "originalContent")
WHERE message.id = updates.id AND message.status = 'queued'
`);
if (writeOutbox) {
@@ -393,6 +402,8 @@ export class SendGatewaySubmitService {
signatureId?: string | null;
phoneNumber: string;
content?: string;
originalContent?: string | null;
billingUnits?: number;
carrier?: string | null;
province?: string | null;
template?: { signature?: { id?: string | null } | null } | null;
@@ -467,6 +478,10 @@ export class SendGatewaySubmitService {
this.prisma,
routes.flatMap((route) => route.group.items.map((item) => item.channelId)),
);
const policies = await loadOptOutPolicies(
this.prisma,
messages.map((m) => ({ ...m, content: m.content ?? '' })),
);
const planned: Array<{ message: T; routed: RoutedChannel }> = [];
const failed: Array<{ message: T; reason: string; code?: string }> = [];
for (const input of routeInputs) {
@@ -485,10 +500,10 @@ export class SendGatewaySubmitService {
input.message.content === undefined
? await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: input.message.id } })
: input.message;
content = stored.content!;
content = stored.originalContent ?? stored.content!;
gate = await evaluateMessageDrainage(
this.prisma,
{ ...input.message, content: stored.content!, signatureId: input.signatureId },
{ ...input.message, content, signatureId: input.signatureId },
input.carrier,
drainageMaterials
.filter(
@@ -530,13 +545,19 @@ export class SendGatewaySubmitService {
(process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel')),
),
);
const { selected, rejected } = channelWords.select(input.message.id, content, approvedItems, {
const { selected, rejected } = channelWords.select(
input.message.id,
content,
approvedItems,
{
carrier: input.carrier,
province: input.province,
excludedChannelIds: new Set(),
approvedChannelIds: new Set(approvedItems.map((item) => item.channelId)),
routingKey: input.message.id,
});
},
(id) => policies({ ...input.message, content }, id).content,
);
if (!selected) {
failed.push({
message: input.message,
@@ -548,6 +569,7 @@ export class SendGatewaySubmitService {
planned.push({
message: input.message,
routed: {
contentPolicy: policies({ ...input.message, content }, selected.channelId),
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
carrier: input.carrier,
province: input.province,
@@ -755,6 +777,8 @@ export class SendGatewaySubmitService {
attempt: number,
retryOfSubmitRecordId?: string,
) {
const decision = routed.contentPolicy ?? applyOptOutRule(message);
const submittedMessage = { ...message, content: decision.content };
const channel = routed.channel;
const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension);
await this.measureSendStage('rate_limit', () =>
@@ -762,7 +786,7 @@ export class SendGatewaySubmitService {
);
const submitId = `SUB-${randomUUID()}`;
const sessionId = await this.getOpenSubmitSessionId(channel.id);
const command = this.buildGatewaySubmitCommand(message, routed, attempt, submitId, upstreamSrcId);
const command = this.buildGatewaySubmitCommand(submittedMessage, routed, attempt, submitId, upstreamSrcId);
const writeOutbox = Boolean(completionContext.getStore()) || this.submitOutboxEnabled();
try {
await this.measureSendStage('submit_transaction', () =>
@@ -777,6 +801,8 @@ export class SendGatewaySubmitService {
channelGroupName: routed.groupName,
sessionId,
retryOfSubmitRecordId,
sentContent: decision.content,
contentPolicy: policyAudit(decision),
submitId,
submitStatus: 'queued',
costUnitPrice: channel.unitPrice ?? 0,
@@ -786,6 +812,8 @@ export class SendGatewaySubmitService {
await tx.smsMessageRecord.update({
where: { id: message.id },
data: {
content: decision.content,
originalContent: decision.content !== decision.originalContent ? decision.originalContent : undefined,
channelId: channel.id,
carrier: routed.carrier,
province: routed.province,
@@ -1118,7 +1146,9 @@ return streamId`;
);
const excluded = new Set(options.excludeChannelIds ?? []);
const stored = await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } });
const gate = await evaluateMessageDrainage(this.prisma, { ...stored, signatureId }, carrier);
const original = { ...stored, content: stored.originalContent ?? stored.content };
const policies = await loadOptOutPolicies(this.prisma, [original]);
const gate = await evaluateMessageDrainage(this.prisma, { ...original, signatureId }, carrier);
const approvedChannelIds = new Set(
route.group.items
.map((item) => item.channelId)
@@ -1130,20 +1160,27 @@ return streamId`;
this.prisma,
route.group.items.map((item) => item.channelId),
);
const { selected, rejected } = channelWords.select(message.id, stored.content, route.group.items, {
const { selected, rejected } = channelWords.select(
message.id,
original.content,
route.group.items,
{
carrier,
province,
forceNational: options.forceNational,
excludedChannelIds: excluded,
approvedChannelIds,
routingKey: message.id,
});
},
(id) => policies(original, id).content,
);
if (!options.previewOnly) await channelWords.persist(this.prisma);
if (rejected) throw new ChannelWordRejection();
if (!selected) {
throw new NotFoundException('无已报备通过且在线的可用通道');
}
return {
contentPolicy: policies(original, selected.channelId),
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
carrier,
province,
@@ -0,0 +1,57 @@
import { applyOptOutRule, gatewayFragmentCount, loadOptOutPolicies, OPT_OUT_SUFFIX } from './template-optout-policy';
import { PrismaService } from '../prisma/prisma.service';
const add = { channelId: 'a', action: 'add' as const };
const remove = { channelId: 'b', action: 'remove' as const };
describe('template opt-out fragment preservation', () => {
test.each([1, 64, 65, 69, 70, 71, 77, 128, 129, 134, 135, 195, 201])(
'preserves billing and wire parts at length %i',
(length) => {
const content = '文'.repeat(length);
const result = applyOptOutRule({ content }, add);
expect(gatewayFragmentCount(result.content)).toBe(gatewayFragmentCount(content));
expect(result.reason).toBe(
gatewayFragmentCount(content + OPT_OUT_SUFFIX) === gatewayFragmentCount(content)
? 'applied'
: 'fragment_count_changed',
);
},
);
it('adds 71 to 77 but skips 69 to 75', () => {
expect(applyOptOutRule({ content: '文'.repeat(69) }, add).content).toHaveLength(69);
expect(applyOptOutRule({ content: '文'.repeat(71) }, add).content).toHaveLength(77);
});
it('does not remove suffix across a fragment boundary or alter inline text', () => {
expect(applyOptOutRule({ content: '文'.repeat(69) + OPT_OUT_SUFFIX }, remove).reason).toBe(
'fragment_count_changed',
);
const content = `正文${OPT_OUT_SUFFIX}。后文`;
expect(applyOptOutRule({ content }, remove).content).toBe(content);
});
it('retains original on alternate-channel retry and never stacks additions', () => {
const originalContent = '文'.repeat(71);
const first = applyOptOutRule({ content: originalContent }, add);
expect(applyOptOutRule({ originalContent, content: first.content }, add).content).toBe(first.content);
expect(applyOptOutRule({ originalContent, content: first.content }, remove).content).toBe(originalContent);
expect(applyOptOutRule({ originalContent, content: first.content }).content).toBe(originalContent);
});
it('preserves UTF-16 parts and skips historical billing mismatch', () => {
const content = '😀'.repeat(34);
expect(applyOptOutRule({ content }, add).reason).toBe('fragment_count_changed');
expect(applyOptOutRule({ content: '文'.repeat(71), billingUnits: 1 }, add).reason).toBe('fragment_count_changed');
expect(gatewayFragmentCount('😀'.repeat(67))).toBe(3);
});
it('matches independently of template admission, scopes tenants/apps and prefers exact text', async () => {
const templates = [
{ id: 'v', tenantId: 't', applicationId: 'app', content: '【测】${name}', optOutRules: [remove] },
{ id: 'e', tenantId: 't', applicationId: 'app', content: '【测】正文', optOutRules: [add] },
];
const db = { smsTemplate: { findMany: jest.fn().mockResolvedValue(templates) } };
const message = { tenantId: 't', applicationId: 'app', content: '【测】正文' };
const policies = await loadOptOutPolicies(db as unknown as PrismaService, [message]);
expect(policies(message, 'a').reason).toBe('applied');
expect(policies({ ...message, tenantId: 'other' }, 'a').reason).toBe('no_policy');
expect(policies({ ...message, content: '其他短信' }, 'a').reason).toBe('no_policy');
expect(policies({ ...message, templateId: 'v' }, 'a').reason).toBe('no_policy');
expect(policies({ ...message, templateId: 'unconfigured-template' }, 'a').reason).toBe('no_policy');
});
});
@@ -0,0 +1,106 @@
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { estimateBillingUnits } from '../sms-config/sms-config.helpers';
import { matchTemplateContent } from './send-chain.helpers';
export const OPT_OUT_SUFFIX = '拒收请回复R';
export type OptOutRule = { channelId: string; action: 'add' | 'remove' };
export type ContentPolicyDecision = {
originalContent: string;
content: string;
templateId: string | null;
action: 'add' | 'remove' | 'none';
reason: 'applied' | 'unchanged' | 'fragment_count_changed' | 'no_policy';
};
type PolicyMessage = {
tenantId?: string | null;
applicationId?: string | null;
templateId?: string | null;
content: string;
originalContent?: string | null;
billingUnits?: number;
};
// Gateway UCS2 uses UTF-16 and never splits a Unicode character across parts.
export function gatewayFragmentCount(content: string) {
if (content.length * 2 <= 140) return 1;
let count = 1,
units = 0;
for (const character of content) {
if (units + character.length > 67) {
count++;
units = 0;
}
units += character.length;
}
return count;
}
export function applyOptOutRule(message: PolicyMessage, rule?: OptOutRule, templateId?: string): ContentPolicyDecision {
const originalContent = message.originalContent ?? message.content;
const result: ContentPolicyDecision = {
originalContent,
content: originalContent,
templateId: templateId ?? null,
action: rule?.action ?? 'none',
reason: 'no_policy',
};
if (!rule) return result;
const content =
rule.action === 'add'
? originalContent.endsWith(OPT_OUT_SUFFIX)
? originalContent
: originalContent + OPT_OUT_SUFFIX
: originalContent.endsWith(OPT_OUT_SUFFIX)
? originalContent.slice(0, -OPT_OUT_SUFFIX.length)
: originalContent;
if (content === originalContent) return { ...result, reason: 'unchanged' };
const originalUnits = estimateBillingUnits(originalContent);
if (
estimateBillingUnits(content) !== originalUnits ||
(message.billingUnits !== undefined && originalUnits !== message.billingUnits) ||
gatewayFragmentCount(content) !== gatewayFragmentCount(originalContent)
) {
return { ...result, reason: 'fragment_count_changed' };
}
return { ...result, content, reason: 'applied' };
}
export async function loadOptOutPolicies(db: PrismaService, messages: PolicyMessage[]) {
const applicationIds = [...new Set(messages.map((m) => m.applicationId).filter((id): id is string => Boolean(id)))];
const templates = applicationIds.length
? await db.smsTemplate.findMany({
where: {
applicationId: { in: applicationIds },
auditStatus: 'approved',
signature: { auditStatus: 'approved' },
NOT: { optOutRules: { equals: [] } },
},
select: { id: true, tenantId: true, applicationId: true, content: true, optOutRules: true },
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
})
: [];
return (message: PolicyMessage, channelId: string) => {
const content = message.originalContent ?? message.content;
const matches = templates.filter(
(t) =>
t.tenantId === message.tenantId &&
t.applicationId === message.applicationId &&
(t.content === content || matchTemplateContent(t.content, content) !== null),
);
const template = message.templateId
? matches.find((t) => t.id === message.templateId)
: (matches.find((t) => t.content === content) ?? matches[0]);
const rules = (template?.optOutRules ?? []) as unknown as OptOutRule[];
return applyOptOutRule(
message,
rules.find((r) => r.channelId === channelId),
template?.id,
);
};
}
export function policyAudit(decision?: ContentPolicyDecision): Prisma.InputJsonValue | undefined {
if (!decision || decision.action === 'none') return undefined;
return { templateId: decision.templateId, action: decision.action, reason: decision.reason, preserveFragments: true };
}
+7 -1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { TemplateOptOutController } from './template-optout.controller';
import { AdminSmsConfigController } from './admin-sms-config.controller';
import { ClientSmsConfigController } from './client-sms-config.controller';
import { SmsConfigService } from './sms-config.service';
@@ -8,7 +9,12 @@ import { DeletionGovernanceModule } from '../deletion-governance/deletion-govern
@Module({
imports: [DeletionGovernanceModule],
controllers: [ClientSmsConfigController, AdminSmsConfigController, ReviewGovernanceController],
controllers: [
ClientSmsConfigController,
AdminSmsConfigController,
ReviewGovernanceController,
TemplateOptOutController,
],
providers: [SmsConfigService, ReviewGovernanceService],
exports: [SmsConfigService],
})
@@ -0,0 +1,21 @@
import { TemplateOptOutController } from './template-optout.controller';
import { PrismaService } from '../prisma/prisma.service';
describe('template opt-out configuration constraints', () => {
const db = { $transaction: jest.fn() };
const controller = new TemplateOptOutController(db as unknown as PrismaService);
it.each([
{ rules: [], preserveFragments: false },
{ rules: 'bad', preserveFragments: true },
{ rules: [{ channelId: 'x', action: 'replace' }], preserveFragments: true },
{
rules: [
{ channelId: 'x', action: 'add' },
{ channelId: 'x', action: 'remove' },
],
preserveFragments: true,
},
])('rejects unsafe input without writing: %j', async (body) => {
await expect(controller.put('template', body)).rejects.toMatchObject({ status: 400 });
expect(db.$transaction).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,87 @@
import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Put } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { PrismaService } from '../prisma/prisma.service';
import { OptOutRule } from '../send-chain/template-optout-policy';
@Controller('admin/enterprise-templates')
export class TemplateOptOutController {
constructor(private readonly db: PrismaService) {}
@Get(':id/opt-out-policy')
async get(@Param('id') id: string) {
const template = await this.db.smsTemplate.findUnique({ where: { id } });
if (!template || template.auditStatus === 'deleted') throw new NotFoundException('模板不存在');
const channels = await this.channels(this.db, template);
return { rules: template.optOutRules, preserveFragments: true, channels };
}
@Put(':id/opt-out-policy')
async put(
@Param('id') id: string,
@Body() body: { rules?: unknown; preserveFragments?: unknown },
@CurrentSessionUserId() operatorId?: string,
) {
if (!body || body.preserveFragments !== true || !Array.isArray(body.rules) || body.rules.length > 500) {
throw new BadRequestException('请提交有效规则,并保持避免影响消息分片数');
}
const rules: OptOutRule[] = [];
for (const value of body.rules) {
if (
!value ||
typeof value.channelId !== 'string' ||
!['add', 'remove'].includes(value.action) ||
rules.some((r) => r.channelId === value.channelId)
) {
throw new BadRequestException('通道规则不合法或存在重复通道');
}
rules.push({ channelId: value.channelId, action: value.action });
}
return this.db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM "SmsTemplate" WHERE id=${id} FOR UPDATE`;
const template = await tx.smsTemplate.findUnique({ where: { id } });
if (!template || template.auditStatus === 'deleted') throw new NotFoundException('模板不存在');
const channels = await this.channels(tx, template);
if (rules.some((rule) => !channels.some((c) => c.id === rule.channelId)))
throw new BadRequestException('只能选择本模板所属应用通道组中的通道');
await tx.smsTemplate.update({ where: { id }, data: { optOutRules: rules } });
await tx.operationLog.create({
data: {
tenantId: template.tenantId,
userId: operatorId,
action: 'sms_template.opt_out_policy.update',
resource: 'sms_template',
resourceId: id,
detail: { before: template.optOutRules, after: rules, preserveFragments: true } as Prisma.InputJsonValue,
},
});
return { rules, preserveFragments: true, channels };
});
}
private async channels(
db: Pick<Prisma.TransactionClient, 'channelRouteRule'>,
template: { applicationId: string; tenantId: string },
) {
const routes = await db.channelRouteRule.findMany({
where: {
applicationId: template.applicationId,
tenantId: template.tenantId,
status: 'active',
channelId: null,
group: { status: 'active' },
},
select: {
group: { select: { name: true, items: { select: { channel: { select: { id: true, name: true } } } } } },
},
});
const channels = new Map<string, { id: string; name: string; groupNames: string[] }>();
for (const route of routes)
for (const { channel } of route.group.items) {
const entry = channels.get(channel.id) ?? { ...channel, groupNames: [] };
if (!entry.groupNames.includes(route.group.name)) entry.groupNames.push(route.group.name);
channels.set(channel.id, entry);
}
return [...channels.values()].sort((a, b) => a.name.localeCompare(b.name));
}
}
+65 -29
View File
@@ -1,20 +1,29 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist';
import { assertMoneyUnits } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import { automaticDeliveryMode } from '../open-api/delivery-mode';
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
import { SmsAuditService } from './audit.service';
import { shanghaiDateRange } from '../common/shanghai-date-range';
import { PrismaService } from '../prisma/prisma.service';
import { SmsAuditService } from './audit.service';
import type {
CreateSmsTemplateDto,
CreateSmsTemplateOptions,
TemplateListQuery,
UpdateSmsTemplateDto,
} from './sms-config.contracts';
import {
estimateBillingUnits,
normalizeSmsSignature,
validateAndNormalizeTemplateVariables,
type TemplateVariableInput,
} from './sms-config.helpers';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsTemplateService {
constructor(private readonly prisma: PrismaService, private readonly audit: SmsAuditService) {}
constructor(
private readonly prisma: PrismaService,
private readonly audit: SmsAuditService,
) {}
listTemplates(queryOrTenantId?: string | TemplateListQuery) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : (queryOrTenantId ?? {});
return this.prisma.smsTemplate.findMany({
where: {
tenantId: query.tenantId,
@@ -24,20 +33,24 @@ export class SmsTemplateService {
name: query.nameKeyword ? { contains: query.nameKeyword } : undefined,
content: query.contentKeyword ? { contains: query.contentKeyword } : undefined,
createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
OR: query.keyword ? [
OR: query.keyword
? [
{ name: { contains: query.keyword } },
{ content: { contains: query.keyword } },
{ category: { contains: query.keyword } },
{ application: { name: { contains: query.keyword } } },
{ tenant: { name: { contains: query.keyword } } },
] : undefined,
]
: undefined,
},
include: { variables: true, application: true, tenant: true, signature: true },
orderBy: { createdAt: 'desc' },
...(query.page && query.pageSize ? {
...(query.page && query.pageSize
? {
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
} : {}),
}
: {}),
});
}
@@ -52,13 +65,15 @@ export class SmsTemplateService {
name: query.nameKeyword ? { contains: query.nameKeyword } : undefined,
content: query.contentKeyword ? { contains: query.contentKeyword } : undefined,
createdAt: shanghaiDateRange(query.submittedAtFrom, query.submittedAtTo),
OR: query.keyword ? [
OR: query.keyword
? [
{ name: { contains: query.keyword } },
{ content: { contains: query.keyword } },
{ category: { contains: query.keyword } },
{ application: { name: { contains: query.keyword } } },
{ tenant: { name: { contains: query.keyword } } },
] : undefined,
]
: undefined,
};
const [items, total] = await Promise.all([
this.listTemplates({ ...query, page, pageSize }),
@@ -73,7 +88,10 @@ export class SmsTemplateService {
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 } });
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');
}
@@ -106,7 +124,10 @@ export class SmsTemplateService {
throw new NotFoundException('Template not found');
}
if (data.applicationId) {
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
const application = await this.prisma.smsApplication.findUnique({
where: { id: data.applicationId },
select: { tenantId: true },
});
if (!application || application.tenantId !== template.tenantId) {
throw new BadRequestException('applicationId does not belong to the template tenant');
}
@@ -119,14 +140,16 @@ export class SmsTemplateService {
data.content ?? template.content,
);
}
const variables = data.content !== undefined || data.variables !== undefined
const variables =
data.content !== undefined || data.variables !== undefined
? validateAndNormalizeTemplateVariables(data.content ?? template.content, data.variables)
: undefined;
const materialChanged = (data.applicationId !== undefined && data.applicationId !== template.applicationId)
|| (data.signatureId !== undefined && data.signatureId !== template.signatureId)
|| (data.content !== undefined && data.content !== template.content)
|| (data.category !== undefined && data.category !== template.category)
|| data.variables !== undefined;
const materialChanged =
(data.applicationId !== undefined && data.applicationId !== template.applicationId) ||
(data.signatureId !== undefined && data.signatureId !== template.signatureId) ||
(data.content !== undefined && data.content !== template.content) ||
(data.category !== undefined && data.category !== template.category) ||
data.variables !== undefined;
const auditStatus = materialChanged && template.auditStatus === 'approved' ? 'pending' : data.auditStatus;
return this.prisma.$transaction(async (tx) => {
if (variables) {
@@ -136,6 +159,7 @@ export class SmsTemplateService {
where: { id: templateId },
data: {
applicationId: data.applicationId,
optOutRules: data.applicationId && data.applicationId !== template.applicationId ? [] : undefined,
signatureId: data.signatureId,
name: data.name,
content: data.content,
@@ -143,13 +167,15 @@ export class SmsTemplateService {
auditStatus,
rejectReason: auditStatus === 'pending' ? null : undefined,
billingUnits: data.content ? estimateBillingUnits(data.content) : undefined,
variables: variables ? {
variables: variables
? {
create: variables.map((variable) => ({
name: variable.name,
example: variable.example,
required: variable.required ?? true,
})),
} : undefined,
}
: undefined,
},
include: { variables: true, application: true, tenant: true, signature: true },
});
@@ -179,7 +205,12 @@ export class SmsTemplateService {
if (!template || template.auditStatus === 'deleted' || (tenantId && template.tenantId !== tenantId)) {
throw new NotFoundException('Template not found');
}
await this.validateTemplateSignature(template.signatureId, template.tenantId, template.applicationId, template.content);
await this.validateTemplateSignature(
template.signatureId,
template.tenantId,
template.applicationId,
template.content,
);
const updated = await this.prisma.smsTemplate.update({
where: { id: templateId },
@@ -196,7 +227,12 @@ export class SmsTemplateService {
return updated;
}
async validateTemplateSignature(signatureId: string | null | undefined, tenantId: string, applicationId: string, content: string) {
async validateTemplateSignature(
signatureId: string | null | undefined,
tenantId: string,
applicationId: string,
content: string,
) {
if (!signatureId) {
throw new BadRequestException('短信模板必须选择短信签名');
}
@@ -2374,3 +2374,13 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
## 2026-09-18 长短信回执终态与归属补充
最终失败(含明确回执超时)与成功必须保持消息、账务、客户通知一致。后续同次失败分片不重复选路或退款;矛盾成功/失败回执只留原始事实并生成异常,不自动改账或重发客户通知。unknown、缺分片、普通提交超时仍可接续。回执须按业务消息、手机号、逻辑通道/上游身份和唯一发送尝试共同匹配;相同Msg_Id不能跨尝试批量更新,有歧义留待匹配。设计见phase-4-send-pipeline-redesign.md第10.13节,测试见TC-RC-20260918-01~07。此次不改协议、数据库结构和线上历史数据。
## 2026-09-20 签名质量、通道能力与模板拒收指令
1. 签名质量成功率条按业务短信总提交数展示已到达、提交失败、回执失败、未收到回执四段,合计100%;灰色未知段的数量、比例仅悬停展示,零提交空轨道,不拆成多条。当前查询和新生成日报将没有明确失败回执的超时归未知;既有冻结日报保留原口径,不因查询重算。
2. 通道允许减少运营商能力;保留通道组引用,发送选路按当前能力排除不支持运营商。不自动改动客户通道组或历史报备。
3. 企业模板管理可按所属应用通道组中的通道配置固定末尾指令“拒收请回复R”的增加/删除;默认保持原文。明确模板不串用另一模板规则;无模板ID时独立匹配有效已审核模板,包括 direct_send 应用,未匹配内容不受影响。
4. “避免影响消息分片数”固定选中,接口不可关闭;增删均保持计费单位与Gateway编码分片数,否则原文发送。只处理末尾精确指令,不修改正文、标点。重试换通道从原文计算,不叠加。
5. 短信列表显示提交通道的实际内容,详情保留原始内容及改写过消息的各次提交快照;客户端保留自身原文查看能力。现有计费、回执和报表单位不变。
6. 设计及兼容边界见 [模板拒收策略方案](template-optout-policy-design-20260920.md)。本轮授权本地修改和提交,不推送或部署。
+5
View File
@@ -363,3 +363,8 @@ a350aca测试环境长短信验收发现两条消息首尝试分别仅写出2/4
- 分片写入限制messageRecordId、submitRecordId/明确submitId及channelId;不得仅凭messageRecordId+gatewayMessageId批量跨尝试更新。提交分片记录同样在明确submitId存在时优先精确匹配,避免OR条件被另一尝试同Msg_Id干扰。
- API和协议不变、鉴权/租户规则不变、无新权限。冲突/未确认回执继续走现有Inbox恢复与人工排查。发布回退只涉及应用;不自动重放已完成事件或历史退款。
- 验收:真实PG验证顺序/并发失败分片仅一次终态选路,矛盾回执账务/通知/分片均不逆转,跨通道与同通道碰撞拒绝或准确关联,同供应商跨连接、早到回执、unknown转成功、旧尝试迟到及工作故障恢复。隔离固定输入比较选路次数;不能将隔离开销降低推算为线上CPU降幅。
## 2026-09-20 模板拒收指令补充
参见 [模板拒收指令策略](template-optout-policy-design-20260920.md)。发送链在选路候选阶段按模板/通道生成内容快照,敏感词按各候选真实内容评估;每次尝试从不可变原文开始。Submit、消息内容与Outbox同事务保存,Gateway授权校验使用对应Submit的内容快照。保留既有计费单位、原收尾状态机及Outbox稳定提交身份,配置变化不改写已生成命令。未配置模板规则时正文保持原样;明确模板ID不串用其他模板规则。
+22
View File
@@ -5693,3 +5693,25 @@ TC-SQA-0114:真实隔离PG覆盖核心日期/日报/长短信/事务/分页
| TC-RC-20260918-07 | 同供应商跨连接唯一匹配、歧义拒绝、身份变更、手机号不符、跨租户关系、历史submitId、候选截断和72小时超时恢复 | receipt-attempt-resolver.spec.ts12项隔离单测;历史分片补关联并齐片完成另有真实PG用例 |
既有TC-RC-20260916收尾并发与故障用例继续执行tools/testing/verify-attempt-completion.mjs(真实PG、双OS进程、事务回滚、fence、一次补发Outbox、非零扣退费、通知持久化)。本轮不启动Gateway、Redis消费者或网络通知投递,不以数据库集成代替线上完整短信链路验收;目标环境与线上CPU改善待另行授权发布后验证。
## 2026-09-20 模板拒收指令及运营界面验收
设计:[模板拒收策略](template-optout-policy-design-20260920.md)。所有写入夹具限本地隔离数据库,不能据此向测试/预生产发送短信。
| 编号 | 场景 | 预期 |
|---|---|---|
| TC-OPT-20260920-01 | 四类混合、零提交、统计不一致 | 单条四段合计100%,未知灰色且仅悬停显示数量比例;零提交空条;不一致明确提示而不编造比例 |
| TC-OPT-20260920-02 | 超时无回执及明确失败回执 | 当前质量查询分别计入未知/回执失败;历史冻结报表不重算 |
| TC-OPT-20260920-03 | 活动通道组仍引用通道,缩减运营商 | 保存成功,组引用保留,被移除运营商不能选该通道,不触发无关重连 |
| TC-OPT-20260920-04 | 策略GET/PUT、未登录/客户端、重复/非法/外应用通道 | 鉴权拒绝非法入口,所属应用范围严格校验,有效保存留审计 |
| TC-OPT-20260920-05 | 固定/变量模板、direct_send、明确其他模板、无匹配 | 指定模板准确命中,direct_send不绕过策略,其他短信原文不变 |
| TC-OPT-20260920-06 | 69→75、71→77、删除跨70字、Unicode代理对 | 同计费单位且同Gateway分片才执行;否则跳过;保护框默认选中且不可取消 |
| TC-OPT-20260920-07 | 正文出现指令、末尾重复添加、补发换通道 | 正文不删、添加不叠加、换通道从原文重新计算 |
| TC-OPT-20260920-08 | 通道敏感词与增删策略同时存在 | 按各候选真实改写内容筛选通道,保存对应内容摘要 |
| TC-OPT-20260920-09 | 单条/微批/补发、事务中断、配置变化 | 消息/Submit/Outbox一致,失败一起回滚,旧命令快照不变化,费用及分片单位不变化 |
| TC-OPT-20260920-10 | 短信列表和详情,历史空字段 | 列表真实提交内容,详情原文及尝试快照,历史空字段正常,客户端仅返回自身消息 |
| TC-OPT-20260920-11 | API失败、重试、通道组移除后失效规则 | 无假成功,输入保留,显式删除失效规则后可保存 |
| TC-OPT-20260920-12 | 1600×1000/1366×768/390×844 | 进度条不换行;模板保存、通道缩减、原文详情、刷新跨路由正常,无控制台异常 |
代码级与真实本地API/PG验收分别见 testing-progress.md;无运营商真实发送授权,因此不将Outbox构造/回滚测试称为真实短信送达验收。
@@ -0,0 +1,28 @@
# 模板拒收指令策略与运营页面修正
日期:2026-09-20。状态:本地已实施,隔离API/PG及三尺寸浏览器验收通过;未推送、未部署、未进行运营商发送。执行证据见 testing-progress.md。本方案补充发送链路设计,不替代其事务、账务、回执和 Outbox 规则。
## 业务规则与影响
- 签名质量列表使用一个四段横向条,按总提交数计算已到达、提交失败、回执失败、未收到回执的占比;未知使用灰色,数量及比例只放悬停提示。保留筛选、分页和详情。零提交显示空轨道。
- 通道缩减运营商能力允许保存,保留已有通道组引用及历史报备;选路实时按通道能力过滤,失去可用通道时沿用既有无路由失败处理,不偷偷迁移客户配置。恢复能力后原引用可继续使用。验收发现既有窄屏查询按钮遮挡与运营商选项溢出,同页CSS增加780px以下单列/换行规则;保持桌面及筛选语义不变,所有权清单更新对应已验收摘要。
- 运营端企业模板列表增加“拒收指令”配置入口。按模板及应用通道组中的通道选择“保持原文 / 末尾增加 / 末尾删除”,固定指令为 `拒收请回复R`。只删除末尾精确匹配的指令,不删除正文相似字样,不改其他文字或标点。重复增加不叠加。
- “避免影响消息分片数”固定选中,后端也不接受关闭。增加和删除都必须同时保持原计费单位和 Gateway 实际编码分片数,否则原文发送并保留跳过原因。不能以本需求修改计费、回执或报表口径。
- 仅匹配当前企业、应用下的有效已审核模板;存在明确模板ID时仅使用该模板规则,不串用其他模板。没有模板ID时按精确正文、变量模板匹配(沿用模板匹配规则和排序)。即使 direct_send 绕过模板准入,发送前仍独立识别策略模板;完全不匹配任何模板的短信保持原文。配置不赋予未审核模板发送权限。
- 每次选路基于不可变原文产生该通道的候选内容;通道敏感词继续检查实际候选内容。换通道补发重新从原文计算,不能累计增删。已持久化的 Submit/Outbox 使用当时快照,不因配置修改而重写。
## 数据与接口
- SmsTemplate 增加 optOutRules JSON(缺省空数组),每项 channelId/action;后台验证动作、重复通道、所属应用的活动通道组成员关系。仅运营端专用 GET/PUT enterprise-templates/:id/opt-out-policy;客户端模板编辑不接受此字段。配置变更留操作审计。
- SmsMessageRecord 增加 nullable originalContent;首次改变时保留输入原文,后续永久保留。content 沿用发送内容字段,在提交事务内与 Submit 和 Outbox 一致更新。
- SmsSubmitRecord 增加 nullable sentContent 及 contentPolicy JSON,记录每次尝试内容、命中模板/动作和应用或跳过原因。迁移不重写历史短信;旧记录字段为空时维持原展示。
- 发送列表展示最近一次提交内容,详情在改写过时另列原始内容;已排队未获得供应商受理不能标称送达。尝试快照用于历史通道发送内容追溯。
- 单条、微批和补发统一使用相同改写函数,所有费用及计费单位保持不变;数据库事务失败不留下单独内容改写,Outbox 重发不重新计算策略。
## 验收与兼容
验证四类加和、零数据、悬停、通道缩减/恢复/不可路由;策略越权及非法参数、变量模板和 direct_send、无匹配不影响其他短信、69→75跳过/71→77执行、删除跨分片跳过、Unicode与编码边界、多通道补发不叠加、事务失败回滚、Outbox 快照稳定、原文详情及三尺寸页面。
使用隔离 PostgreSQL、真实 API/Redis和页面验证;不连接运营商发送,不操作线上客户或通道配置。执行定向及全量回归、类型检查、构建和质量门禁。线上验证和部署独立列为未执行。本轮只提交本轮代码与文档,不推送或部署。
质量分类补充:当前查询及今后生成的日报使用互斥四类,超时无明确失败回执归未知。已发布冻结日报不重算,其历史分类保留。进度条比例均用总提交数,原接口 successRate 字段保留兼容,不改变详情其他既有口径。未改变资金流水、客户回执数量、报表计费单位;本地验证的是生成意图及费用字段、事务一致性,并未执行运营商真实发送和资金结算。每次Submit额外保存一份内容快照用于追溯,归入原提交记录的留存治理范围。
+13
View File
@@ -5186,3 +5186,16 @@ CUA本轮可用,实际后端文档三尺寸1600×1000/1366×768/390×844无页
API全量81套/880项通过并达覆盖率门禁(语句67.73%、分支52.89%、函数68.76%、行70.60%);最终小幅兼容调整后定向2套/150项及八组真实PG、十一组并发恢复复跑通过。TypeScript生产构建通过;本轮文件ESLint零错误、30条既有any警告,Prettier及diff检查通过。新匹配器单测行覆盖100%、分支89.65%。早期旧mock未提供真实关联造成14项失败,补全关系及查询形状后通过;未降低关联约束。真实PG保留pg驱动并发query弃用警告,未出现事务失败。
可重复运行:先构建api并迁移独立本机cmpp_qa_*库,设置COMPLETION_TEST_DATABASE_URL后运行tools/testing/verify-receipt-finality.mjs和verify-attempt-completion.mjs;脚本拒绝非本机或非隔离库。原始日志保留.local-data/receipt-fix-20260918/,不进Git。此轮仅调用真实后端服务/持久层,不启动Gateway、HTTP通知投递或在线发送。前端/Go未变,未重跑其验收;目标环境、Redis/Gateway完整网络闭环、线上CPU降幅未验证。不得把本地通过视作测试/预生产已修复。本地隔离数据库进程收尾关闭,数据及日志保留。
## 2026-09-20 签名质量四段条、通道运营商缩减及模板拒收策略
- 基线:本地main c20c2246b263b22a6a84c8ac10d9f320197a8fb6,实际远端main 5e4d644788b453528e513b1c14c28b120e221b64;开始暂存区为空。保留版本3.0、metrics、发布工具/部署脚本、手机号规则迁移及其他草稿,不纳入本轮提交。
- 需求/设计:first-version-development-requirements.md“2026-09-20”节、template-optout-policy-design-20260920.md、phase-4-send-pipeline-redesign.md本轮补充;用例TC-OPT-20260920-0112。
- 根因:原质量条仅绘制成功比例;通道后端在检测到被活动通道组引用的已移除运营商时直接拒绝;原发送链没有模板/通道内容改写及尝试内容快照。本轮改为互斥四段条、仅悬停未知数据;允许能力缩减并保留原路由能力过滤;模板策略独立匹配且不得改变计费及Gateway分片数,单条/微批/补发共享,原文/Submit/Outbox同事务。Gateway授权按尝试快照核对,短信详情增加原文与尝试内容,客户端只返回自身原文。
- 迁移:新增20260920090000_template_optout_policy,旧内容不重写。初次工作区数据库包含113条迁移;随后复制原已提交迁移及本轮迁移,排除未提交20260918110000_refine_mobile_drainage_prefixes,在独立cmpp_qa_optout_commit_20260920执行112条迁移全部通过,并重新执行真实API/PG和浏览器验收。
- 自动验证:API全量85套/971例通过,覆盖率语句67.95%、分支53.55%、函数68.91%、行70.72%;前端37文件/168例通过,现有覆盖率门禁88.48%/85.09%/84%/88.19%。TypeScript、API构建、前端production构建、npm run lint、format:check、stylelint/CSS治理、bundle:verify及git diff --check通过;lint保留9条既有hooks警告,production大chunk提示但包体门禁通过。Gateway go test ./...及go vet ./...通过,队列契约未改动。
- 初次覆盖率运行并行竞争资源,前端24例触发原5000ms超时;串行安排并限制maxWorkers=2后168例全通过,没有放宽时限或断言。新增通道单测首次缺少required desiredConnections导致类型失败,补齐测试夹具后971例全通过。原失败记录保留。
- 真实验收:tools/testing/verify-template-optout.mjs使用真实NestJS、PostgreSQL16及本机Redis5.0.14.1(运行库提示建议6.2+,已记录环境差异)。验证登录/客户端入口拒绝、外应用/非法策略拒绝、固定分片保护、审计持久化、活动通道组引用下缩减保存、direct_send无templateId仍应用、单条及微批快照、换通道补发恢复原文、费用字段不变、事务回滚及旧Outbox快照不变;Outbox全部pending,未启动Gateway/SMSC或发布器,未实际发短信。
- 浏览器:当前环境无Browser插件,按前端验收技能使用独立Playwright/Edge、真实API及会话,1600×1000、1366×768、390×844分别验证策略保存、固定勾选、四段条、刷新/跨路由、原文/尝试详情、通道弹窗减少运营商后保存及PG回读,无pageerror。发现并修复既有窄屏查询遮挡/弹窗运营商溢出,仅增加同页响应式规则,保持桌面布局;已查看截图核对。
- 证据:.local-data/template-optout-20260920/ 下 api-coverage-final.log、frontend-coverage-final.log、migrations-commit.log、browser-accepted.log、lint-accepted.log、format-accepted.log、api-build-accepted.log、build-accepted.log、bundle-accepted.log、go-test.log、go-vet.log及template/quality/detail/channel三尺寸截图。验证脚本不保存会话或密码,fixture.json仅含隔离业务ID。
- 边界/未执行:未操作测试或预生产配置;未推送、未部署、未执行运营商发送/实际回执推送/资金结算;冻结历史报表不重算。测试证明本地真实持久化和页面,不能冒称线上或实际送达验收。提交仅纳入本轮源文件、迁移和本轮文档增量,提交号以本轮Git提交为准。
+368 -86
View File
@@ -1,5 +1,29 @@
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
import type { AdministrativeRegion, AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, DrainageDetectionResult, DrainageDetectionRule, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types';
import { request, withQuery } from '../core/httpClient';
import type {
AdministrativeRegion,
AuditRecord,
ClientSmsSignature,
ClientSmsTemplate,
CommonReportField,
DictionaryItem,
DrainageDetectionResult,
DrainageDetectionRule,
EnterpriseCertification,
ManualRechargePreflight,
ManualRechargeResult,
PagedResult,
PhoneFrequencyHit,
PhoneFrequencyWhitelistItem,
RechargeOrder,
ReviewDecisionResult,
ReviewPreflight,
RiskReviewTask,
RiskRuleItem,
RiskTaskMessagePage,
SmsDrainageInfo,
SmsTemplateAudit,
TenantAccount,
} from '../types';
// Review, risk and billing mutations keep their original URLs, payloads and
// response types behind one governance boundary.
@@ -7,15 +31,38 @@ export const adminGovernanceApi = {
listAdministrativeRegions: () => request<AdministrativeRegion[]>('/admin/dictionaries/administrative-regions'),
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) =>
request<TenantAccount>(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }),
listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })),
listManualRechargesPage: (query: { enterpriseKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) =>
request<PagedResult<RechargeOrder>>(withQuery('/admin/billing/manual-recharges', query)),
request<TenantAccount>(`/admin/billing/accounts/${tenantId}/credit-limit`, {
method: 'POST',
body: JSON.stringify(body),
}),
listManualRecharges: (tenantId?: string) =>
request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })),
listManualRechargesPage: (query: {
enterpriseKeyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
page: number;
pageSize: number;
}) => request<PagedResult<RechargeOrder>>(withQuery('/admin/billing/manual-recharges', query)),
preflightManualRecharge: (body: { tenantId: string; amountCents: number }) =>
request<ManualRechargePreflight>('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }),
createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) =>
request<ManualRechargePreflight>('/admin/billing/manual-recharges/preflight', {
method: 'POST',
body: JSON.stringify(body),
}),
createManualRecharge: (body: {
tenantId: string;
amountCents: number;
expectedAccountUpdatedAt: string;
idempotencyKey: string;
remark?: string;
}) =>
request<ManualRechargeResult>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
listTemplateAudits: (query: { keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string }) => {
listTemplateAudits: (query: {
keyword?: string;
status?: string;
submittedAtFrom?: string;
submittedAtTo?: string;
}) => {
const params = new URLSearchParams();
if (query.keyword) params.set('keyword', query.keyword);
if (query.status && query.status !== 'all') params.set('status', query.status);
@@ -24,60 +71,187 @@ export const adminGovernanceApi = {
const suffix = params.toString() ? `?${params}` : '';
return request<SmsTemplateAudit[]>(`/admin/enterprise-templates${suffix}`);
},
approveTemplate: (id: string) => request<SmsTemplateAudit>(`/admin/templates/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
rejectTemplate: (id: string, reason = '运营审核驳回') => request<SmsTemplateAudit>(`/admin/templates/${id}/reject`, {
approveTemplate: (id: string) =>
request<SmsTemplateAudit>(`/admin/templates/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
rejectTemplate: (id: string, reason = '运营审核驳回') =>
request<SmsTemplateAudit>(`/admin/templates/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
approveSignature: (id: string) => request<ClientSmsSignature>(`/admin/signatures/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
rejectSignature: (id: string, reason = '运营审核驳回') => request<ClientSmsSignature>(`/admin/signatures/${id}/reject`, {
approveSignature: (id: string) =>
request<ClientSmsSignature>(`/admin/signatures/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
rejectSignature: (id: string, reason = '运营审核驳回') =>
request<ClientSmsSignature>(`/admin/signatures/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
getReviewPreflight: (type: 'signature' | 'template', id: string) =>
request<ReviewPreflight>(`/admin/reviews/${type}/${id}/preflight`),
submitReviewDecision: (type: 'signature' | 'template', id: string, body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string }) =>
request<ReviewDecisionResult>(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }),
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
listEnterpriseSignaturesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string; page: number; pageSize: number }) =>
request<PagedResult<ClientSmsSignature> & { pendingReportMaterialTotal: number; pendingReportDetailTotal: number }>(withQuery('/admin/enterprise-signatures', query)),
submitReviewDecision: (
type: 'signature' | 'template',
id: string,
body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string },
) =>
request<ReviewDecisionResult>(`/admin/reviews/${type}/${id}/decision`, {
method: 'POST',
body: JSON.stringify(body),
}),
listEnterpriseSignatures: (
query: {
tenantId?: string;
keyword?: string;
status?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
signatureKeyword?: string;
drainageKeyword?: string;
submittedAtFrom?: string;
submittedAtTo?: string;
} = {},
) => request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
listEnterpriseSignaturesPage: (query: {
tenantId?: string;
keyword?: string;
status?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
signatureKeyword?: string;
drainageKeyword?: string;
page: number;
pageSize: number;
}) =>
request<PagedResult<ClientSmsSignature> & { pendingReportMaterialTotal: number; pendingReportDetailTotal: number }>(
withQuery('/admin/enterprise-signatures', query),
),
listEnterpriseSignatureOptions: (query: { tenantId?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signature-options', query)),
getEnterpriseSignature: (id: string) => request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`),
getEnterpriseSignatureReportTargets: (id: string) => request<NonNullable<ClientSmsSignature['reportTargets']>>(`/admin/enterprise-signatures/${id}/report-targets`),
getDrainageInfoReportTargets: (id: string) => request<NonNullable<ClientSmsSignature['drainageReportTargets']>[string]>(`/admin/drainage-infos/${id}/report-targets`),
createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) =>
request<ClientSmsSignature>('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }),
updateEnterpriseSignature: (id: string, body: { applicationId?: string | null; name?: string; purpose?: string; auditStatus?: string; drainageInfo?: Record<string, unknown> }) =>
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
getEnterpriseSignatureReportTargets: (id: string) =>
request<NonNullable<ClientSmsSignature['reportTargets']>>(`/admin/enterprise-signatures/${id}/report-targets`),
getDrainageInfoReportTargets: (id: string) =>
request<NonNullable<ClientSmsSignature['drainageReportTargets']>[string]>(
`/admin/drainage-infos/${id}/report-targets`,
),
createEnterpriseSignature: (body: {
tenantId: string;
applicationId?: string;
name: string;
purpose?: string;
drainageInfo?: Record<string, unknown>;
}) => request<ClientSmsSignature>('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }),
updateEnterpriseSignature: (
id: string,
body: {
applicationId?: string | null;
name?: string;
purpose?: string;
auditStatus?: string;
drainageInfo?: Record<string, unknown>;
},
) => request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) =>
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) =>
request<SmsDrainageInfo[]>(withQuery('/admin/drainage-infos', query)),
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}/status`, {
method: 'POST',
body: JSON.stringify({ status, reason }),
}),
listDrainageInfos: (
query: {
tenantId?: string;
signatureId?: string;
keyword?: string;
status?: string;
submittedAtFrom?: string;
submittedAtTo?: string;
} = {},
) => request<SmsDrainageInfo[]>(withQuery('/admin/drainage-infos', query)),
listAuditRecords: (query: { targetType?: string; targetId?: string } = {}) =>
request<AuditRecord[]>(withQuery('/admin/audit-records', query)),
createDrainageInfo: (signatureId: string, body: { url: string; remark?: string; reportValues?: Record<string, unknown> }) =>
request<SmsDrainageInfo>(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, { method: 'POST', body: JSON.stringify(body) }),
createDrainageInfo: (
signatureId: string,
body: { url: string; remark?: string; reportValues?: Record<string, unknown> },
) =>
request<SmsDrainageInfo>(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, {
method: 'POST',
body: JSON.stringify(body),
}),
updateDrainageInfo: (id: string, body: { url?: string; remark?: string; reportValues?: Record<string, unknown> }) =>
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
approveDrainageInfo: (id: string) =>
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
rejectDrainageInfo: (id: string, reason: string) =>
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }),
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
changeDrainageInfoStatus: (id: string, status: string, reason?: string) =>
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string } = {}) =>
request<ClientSmsTemplate[]>(withQuery('/admin/enterprise-templates', query)),
listEnterpriseTemplatesPage: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string; page: number; pageSize: number }) =>
request<PagedResult<ClientSmsTemplate>>(withQuery('/admin/enterprise-templates', query)),
createEnterpriseTemplate: (body: { tenantId: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) =>
request<ClientSmsTemplate>('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }),
updateEnterpriseTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) =>
request<ClientSmsTemplate>(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/status`, {
method: 'POST',
body: JSON.stringify({ status, reason }),
}),
listEnterpriseTemplates: (
query: {
tenantId?: string;
keyword?: string;
status?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
nameKeyword?: string;
contentKeyword?: string;
} = {},
) => request<ClientSmsTemplate[]>(withQuery('/admin/enterprise-templates', query)),
listEnterpriseTemplatesPage: (query: {
tenantId?: string;
keyword?: string;
status?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
nameKeyword?: string;
contentKeyword?: string;
page: number;
pageSize: number;
}) => request<PagedResult<ClientSmsTemplate>>(withQuery('/admin/enterprise-templates', query)),
createEnterpriseTemplate: (body: {
tenantId: string;
applicationId: string;
signatureId?: string;
name: string;
content: string;
category?: string;
variables?: Array<{ name: string; example?: string; required?: boolean }>;
}) => request<ClientSmsTemplate>('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }),
updateEnterpriseTemplate: (
id: string,
body: {
applicationId?: string;
signatureId?: string | null;
name?: string;
content?: string;
category?: string;
auditStatus?: string;
variables?: Array<{ name: string; example?: string; required?: boolean }>;
},
) => request<ClientSmsTemplate>(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeEnterpriseTemplateStatus: (id: string, status: string, reason?: string) =>
request<ClientSmsTemplate>(`/admin/enterprise-templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
listEnterpriseCertifications: (query: { keyword?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string }) => {
request<ClientSmsTemplate>(`/admin/enterprise-templates/${id}/status`, {
method: 'POST',
body: JSON.stringify({ status, reason }),
}),
getTemplateOptOutPolicy: (id: string) =>
request<{
rules: Array<{ channelId: string; action: 'add' | 'remove' }>;
preserveFragments: boolean;
channels: Array<{ id: string; name: string; groupNames: string[] }>;
}>(`/admin/enterprise-templates/${id}/opt-out-policy`),
updateTemplateOptOutPolicy: (
id: string,
body: { rules: Array<{ channelId: string; action: 'add' | 'remove' }>; preserveFragments: true },
) => request(`/admin/enterprise-templates/${id}/opt-out-policy`, { method: 'PUT', body: JSON.stringify(body) }),
listEnterpriseCertifications: (query: {
keyword?: string;
status?: string;
submittedAtFrom?: string;
submittedAtTo?: string;
}) => {
const params = new URLSearchParams();
if (query.keyword) params.set('keyword', query.keyword);
if (query.status && query.status !== 'all') params.set('status', query.status);
@@ -86,16 +260,21 @@ export const adminGovernanceApi = {
const suffix = params.toString() ? `?${params}` : '';
return request<EnterpriseCertification[]>(`/admin/enterprise-certifications${suffix}`);
},
getEnterpriseCertification: (id: string) => request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}`),
approveEnterpriseCertification: (id: string) => request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}/approve`, {
getEnterpriseCertification: (id: string) =>
request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}`),
approveEnterpriseCertification: (id: string) =>
request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}/approve`, {
method: 'POST',
body: JSON.stringify({}),
}),
rejectEnterpriseCertification: (id: string, reason = '运营审核驳回') => request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}/reject`, {
rejectEnterpriseCertification: (id: string, reason = '运营审核驳回') =>
request<EnterpriseCertification>(`/admin/enterprise-certifications/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
listRiskReviewTasks: (query: { tenantId?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {}) => request<RiskReviewTask[]>(withQuery('/admin/risk-review/tasks', query)),
listRiskReviewTasks: (
query: { tenantId?: string; status?: string; submittedAtFrom?: string; submittedAtTo?: string } = {},
) => request<RiskReviewTask[]>(withQuery('/admin/risk-review/tasks', query)),
listRiskRules: (applicationId?: string) =>
request<RiskRuleItem[]>(withQuery('/admin/risk-review/rules', { applicationId })),
createRiskRule: (body: {
@@ -107,14 +286,18 @@ export const adminGovernanceApi = {
priority?: number;
config?: RiskRuleItem['config'];
}) => request<RiskRuleItem>('/admin/risk-review/rules', { method: 'POST', body: JSON.stringify(body) }),
updateRiskRule: (id: string, body: {
updateRiskRule: (
id: string,
body: {
thresholdValue?: number;
action?: RiskRuleItem['action'];
status?: RiskRuleItem['status'];
priority?: number;
config?: RiskRuleItem['config'];
}) => request<RiskRuleItem>(`/admin/risk-review/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
listPhoneFrequencyHits: (query: {
},
) => request<RiskRuleItem>(`/admin/risk-review/rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
listPhoneFrequencyHits: (
query: {
tenantId?: string;
applicationId?: string;
phoneNumber?: string;
@@ -123,13 +306,15 @@ export const adminGovernanceApi = {
createdAtTo?: string;
page?: number;
pageSize?: number;
} = {}) => request<PagedResult<PhoneFrequencyHit>>(withQuery('/admin/risk-review/phone-frequency-hits', query)),
} = {},
) => request<PagedResult<PhoneFrequencyHit>>(withQuery('/admin/risk-review/phone-frequency-hits', query)),
releasePhoneFrequencyHit: (id: string, reason: string) =>
request<PhoneFrequencyHit>(`/admin/risk-review/phone-frequency-hits/${id}/release`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
listPhoneFrequencyWhitelist: (query: {
listPhoneFrequencyWhitelist: (
query: {
phoneNumber?: string;
keyword?: string;
status?: 'active' | 'inactive' | 'deleted';
@@ -137,22 +322,29 @@ export const adminGovernanceApi = {
updatedAtTo?: string;
page?: number;
pageSize?: number;
} = {}) => request<PagedResult<PhoneFrequencyWhitelistItem>>(withQuery('/admin/risk-review/phone-frequency-whitelist', query)),
} = {},
) =>
request<PagedResult<PhoneFrequencyWhitelistItem>>(withQuery('/admin/risk-review/phone-frequency-whitelist', query)),
createPhoneFrequencyWhitelist: (body: {
phoneNumber: string;
reason: string;
remark?: string;
status?: 'active' | 'inactive';
}) => request<PhoneFrequencyWhitelistItem>('/admin/risk-review/phone-frequency-whitelist', {
}) =>
request<PhoneFrequencyWhitelistItem>('/admin/risk-review/phone-frequency-whitelist', {
method: 'POST',
body: JSON.stringify(body),
}),
updatePhoneFrequencyWhitelist: (id: string, body: {
updatePhoneFrequencyWhitelist: (
id: string,
body: {
phoneNumber?: string;
reason?: string;
remark?: string;
status?: 'active' | 'inactive';
}) => request<PhoneFrequencyWhitelistItem>(`/admin/risk-review/phone-frequency-whitelist/${id}`, {
},
) =>
request<PhoneFrequencyWhitelistItem>(`/admin/risk-review/phone-frequency-whitelist/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
@@ -164,58 +356,148 @@ export const adminGovernanceApi = {
listRiskReviewTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) =>
request<RiskTaskMessagePage>(withQuery(`/admin/risk-review/tasks/${id}/messages`, query)),
approveRiskReviewTask: (id: string, reason?: string) =>
request<RiskReviewTask>(`/admin/risk-review/tasks/${id}/approve`, { method: 'POST', body: JSON.stringify({ reason }) }),
request<RiskReviewTask>(`/admin/risk-review/tasks/${id}/approve`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
rejectRiskReviewTask: (id: string, reason?: string) =>
request<RiskReviewTask>(`/admin/risk-review/tasks/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }),
request<RiskReviewTask>(`/admin/risk-review/tasks/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason }),
}),
rejectRiskReviewTasks: (ids: string[], reason: string) =>
request<RiskReviewTask[]>('/admin/risk-review/tasks/batch/reject', { method: 'POST', body: JSON.stringify({ ids, reason }) }),
listSensitiveWords: (query: { keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/sensitive-words', query)),
request<RiskReviewTask[]>('/admin/risk-review/tasks/batch/reject', {
method: 'POST',
body: JSON.stringify({ ids, reason }),
}),
listSensitiveWords: (query: { keyword?: string; status?: string } = {}) =>
request<DictionaryItem[]>(withQuery('/admin/dictionaries/sensitive-words', query)),
createSensitiveWord: (body: { word: string; level?: string; status?: string }) =>
request<DictionaryItem>('/admin/dictionaries/sensitive-words', { method: 'POST', body: JSON.stringify(body) }),
deleteSensitiveWord: (id: string) => request<DictionaryItem>(`/admin/dictionaries/sensitive-words/${id}`, { method: 'DELETE' }),
listGlobalBlacklist: (query: { keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/global', query)),
deleteSensitiveWord: (id: string) =>
request<DictionaryItem>(`/admin/dictionaries/sensitive-words/${id}`, { method: 'DELETE' }),
listGlobalBlacklist: (query: { keyword?: string; status?: string } = {}) =>
request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/global', query)),
createGlobalBlacklist: (body: { phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
request<DictionaryItem>('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }),
deleteGlobalBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }),
listEnterpriseBlacklist: (query: { tenantId?: string; applicationId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; phoneNumber?: string; reasonKeyword?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/enterprise', query)),
createEnterpriseBlacklist: (body: { tenantId: string; applicationId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
request<DictionaryItem>('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }),
deleteEnterpriseBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }),
deleteGlobalBlacklist: (id: string) =>
request<DictionaryItem>(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }),
listEnterpriseBlacklist: (
query: {
tenantId?: string;
applicationId?: string;
keyword?: string;
status?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
phoneNumber?: string;
reasonKeyword?: string;
} = {},
) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/enterprise', query)),
createEnterpriseBlacklist: (body: {
tenantId: string;
applicationId: string;
phoneNumber: string;
reason?: string;
status?: string;
operatorId?: string;
}) =>
request<DictionaryItem>('/admin/dictionaries/blacklists/enterprise', {
method: 'POST',
body: JSON.stringify(body),
}),
deleteEnterpriseBlacklist: (id: string) =>
request<DictionaryItem>(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }),
listPhoneSegments: (query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-segments', query)),
request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(
withQuery('/admin/dictionaries/phone-segments', query),
),
createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
deletePhoneSegment: (id: string) => request<DictionaryItem>(`/admin/dictionaries/phone-segments/${id}`, { method: 'DELETE' }),
deletePhoneSegment: (id: string) =>
request<DictionaryItem>(`/admin/dictionaries/phone-segments/${id}`, { method: 'DELETE' }),
listPhoneCarrierRules: (query: { keyword?: string; page?: number; pageSize?: number } = {}) =>
request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-carrier-rules', query)),
createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) =>
request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(
withQuery('/admin/dictionaries/phone-carrier-rules', query),
),
createPhoneCarrierRule: (body: {
carrier: string;
pattern: string;
priority?: number;
status?: string;
remark?: string;
}) =>
request<DictionaryItem>('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }),
deletePhoneCarrierRule: (id: string) => request<DictionaryItem>(`/admin/dictionaries/phone-carrier-rules/${id}`, { method: 'DELETE' }),
deletePhoneCarrierRule: (id: string) =>
request<DictionaryItem>(`/admin/dictionaries/phone-carrier-rules/${id}`, { method: 'DELETE' }),
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) =>
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
updateDrainageField: (id: string, body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; description?: string }) =>
createDrainageField: (body: {
code: string;
name: string;
fieldType: 'string' | 'image' | 'file';
required?: boolean;
status?: string;
description?: string;
}) => request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
updateDrainageField: (
id: string,
body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; description?: string },
) =>
request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteDrainageField: (id: string) => request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }),
deleteDrainageField: (id: string) =>
request<DictionaryItem>(`/admin/dictionaries/drainage-fields/${id}`, { method: 'DELETE' }),
listDrainageDetectionRules: (query: { keyword?: string; status?: string } = {}) =>
request<DrainageDetectionRule[]>(withQuery('/admin/dictionaries/drainage-detection-rules', query)),
createDrainageDetectionRule: (body: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>) =>
request<DrainageDetectionRule>('/admin/dictionaries/drainage-detection-rules', { method: 'POST', body: JSON.stringify(body) }),
updateDrainageDetectionRule: (id: string, body: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>) =>
request<DrainageDetectionRule>(`/admin/dictionaries/drainage-detection-rules/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
request<DrainageDetectionRule>('/admin/dictionaries/drainage-detection-rules', {
method: 'POST',
body: JSON.stringify(body),
}),
updateDrainageDetectionRule: (
id: string,
body: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>,
) =>
request<DrainageDetectionRule>(`/admin/dictionaries/drainage-detection-rules/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
changeDrainageDetectionRuleStatus: (id: string, status: 'active' | 'inactive') =>
request<DrainageDetectionRule>(`/admin/dictionaries/drainage-detection-rules/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
testDrainageDetectionRule: (body: { content: string; rule?: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'> }) =>
request<DrainageDetectionResult>('/admin/dictionaries/drainage-detection-rules/test', { method: 'POST', body: JSON.stringify(body) }),
request<DrainageDetectionRule>(`/admin/dictionaries/drainage-detection-rules/${id}/status`, {
method: 'POST',
body: JSON.stringify({ status }),
}),
testDrainageDetectionRule: (body: {
content: string;
rule?: Omit<DrainageDetectionRule, 'id' | 'version' | 'createdAt' | 'updatedAt'>;
}) =>
request<DrainageDetectionResult>('/admin/dictionaries/drainage-detection-rules/test', {
method: 'POST',
body: JSON.stringify(body),
}),
listCommonReportFields: () => request<CommonReportField[]>('/admin/dictionaries/common-report-fields'),
createCommonReportField: (body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean; sortOrder?: number }) =>
request<CommonReportField>('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }),
createCommonReportField: (body: {
drainageFieldId: string;
reportType: 'signature' | 'drainage';
required: boolean;
sortOrder?: number;
}) =>
request<CommonReportField>('/admin/dictionaries/common-report-fields', {
method: 'POST',
body: JSON.stringify(body),
}),
reorderCommonReportFields: (body: { reportType: 'signature' | 'drainage'; ids: string[] }) =>
request<CommonReportField[]>('/admin/dictionaries/common-report-fields/order', {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteCommonReportField: (id: string) => request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }),
updateCommonReportField: (id: string, body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean }) =>
request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteCommonReportField: (id: string) =>
request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }),
updateCommonReportField: (
id: string,
body: { drainageFieldId: string; reportType: 'signature' | 'drainage'; required: boolean },
) =>
request<CommonReportField>(`/admin/dictionaries/common-report-fields/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
};
+3
View File
@@ -119,6 +119,7 @@ export type SendQualityResponse = {
};
export type SmsMessageRecord = {
originalContent?: string | null;
channelWordDecisions?: Array<{
id: string;
decidedAt: string;
@@ -185,6 +186,8 @@ export type SmsMessageRecord = {
};
export type SmsSubmitRecord = {
sentContent?: string | null;
contentPolicy?: { templateId: string | null; action: string; reason: string; preserveFragments: boolean } | null;
id: string;
channelId: string;
channelGroupName?: string | null;
+2 -12
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { QualityStatusBar } from './QualityStatusBar';
import { BarChart3, Eye, Search, X } from 'lucide-react';
import {
adminApi,
@@ -229,7 +230,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
key: 'successRate',
title: '成功率',
width: '170px',
render: (record) => <QualityRate value={record.successRate} />,
render: (record) => <QualityStatusBar metric={record} />,
},
{
key: 'averageArrivalMs',
@@ -928,17 +929,6 @@ function MatrixMetric({
);
}
function QualityRate({ value }: { value: number }) {
return (
<div className="signature-quality-rate">
<div>
<span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} />
</div>
<strong className={successRateClassName(value)}>{value.toFixed(1)}%</strong>
</div>
);
}
function normalizeCarrier(value: string) {
const normalized = value.toLowerCase();
if (['mobile', 'cmcc', '移动'].includes(normalized)) return 'mobile';
+252 -77
View File
@@ -1,9 +1,27 @@
import { useEffect, useRef, useState } from 'react';
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
import {
adminApi,
type ClientSmsApplication,
type ClientSmsSignature,
type ClientSmsTemplate,
type TenantOption,
} from '@/api/adminApi';
import {
Breadcrumb,
Button,
DeleteRiskAction,
Input,
Modal,
Pagination,
Select,
Tabs,
Tag,
Textarea,
} from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
import { Edit3, Eye, Plus, Search } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { TemplateOptOutModal } from './TemplateOptOutModal';
type TemplateFormState = {
tenantId: string;
@@ -89,7 +107,7 @@ function TemplateFormModal({
const initialSignature = signatures.find((signature) => signature.id === item?.signatureId);
const initialContent = item?.signatureId
? replaceLeadingSmsSignature(item.content, initialSignature?.name)
: item?.content ?? '';
: (item?.content ?? '');
const [form, setForm] = useState<TemplateFormState>({
tenantId: item?.tenantId ?? '',
applicationId: item?.applicationId ?? '',
@@ -97,16 +115,24 @@ function TemplateFormModal({
name: item?.name ?? '',
content: initialContent,
category: item?.category ?? '行业通知',
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
variables:
item?.variables?.map((variable) => ({
name: variable.name,
example: variable.example ?? undefined,
required: variable.required ?? true,
})) ?? [],
});
const initialForm = useRef(form).current;
const [initialForm] = useState(form);
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
const tenantSignatures = signatures.filter((signature) => (
signature.tenantId === form.tenantId
&& signature.auditStatus !== 'deleted'
&& (!signature.applicationId || signature.applicationId === form.applicationId)
));
const tenantApplications = applications.filter(
(application) => application.tenantId === form.tenantId && application.status !== 'deleted',
);
const tenantSignatures = signatures.filter(
(signature) =>
signature.tenantId === form.tenantId &&
signature.auditStatus !== 'deleted' &&
(!signature.applicationId || signature.applicationId === form.applicationId),
);
const currentVariables = form.variables.length ? form.variables : extractVariables(form.content);
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
@@ -142,7 +168,9 @@ function TemplateFormModal({
}
function updateVariableExample(name: string, example: string) {
const variables = currentVariables.map((variable) => variable.name === name ? { ...variable, example } : variable);
const variables = currentVariables.map((variable) =>
variable.name === name ? { ...variable, example } : variable,
);
update('variables', variables);
}
@@ -151,14 +179,26 @@ function TemplateFormModal({
dirty={dirty}
footer={({ requestClose }) => (
<>
<Button onClick={requestClose} variant="ghost"></Button>
<Button disabled={!form.tenantId || !form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}></Button>
<Button onClick={requestClose} variant="ghost">
</Button>
<Button
disabled={!form.tenantId || !form.applicationId || !form.signatureId || !form.name || !form.content.trim()}
onClick={() => onSubmit({ ...form, variables: currentVariables })}
>
</Button>
</>
)}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2>{item ? '编辑短信模板' : '添加短信模板'}</h2><p></p></div>}
title={
<div className="template-modal-title">
<h2>{item ? '编辑短信模板' : '添加短信模板'}</h2>
<p></p>
</div>
}
>
<div className="template-form">
<Select
@@ -192,8 +232,19 @@ function TemplateFormModal({
required
value={form.signatureId}
/>
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" required value={form.name} />
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
<Input
label="模板名称"
onChange={(event) => update('name', event.target.value)}
placeholder="请输入模板名称"
required
value={form.name}
/>
<Input
label="模板分类"
onChange={(event) => update('category', event.target.value)}
placeholder="行业通知/营销推广/验证码"
value={form.category}
/>
<Textarea
hint="模板内容必须以所选签名开头;选择或切换签名时系统会自动填入或替换完整签名,例如:【XX公司】验证码为${code}。"
label="模板内容"
@@ -208,7 +259,9 @@ function TemplateFormModal({
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
</button>
<span>{form.content.length} {billingUnits(form.content)} </span>
<span>
{form.content.length} {billingUnits(form.content)}
</span>
</div>
{variablesOpen ? (
<div className="template-variable-panel">
@@ -222,14 +275,26 @@ function TemplateFormModal({
</div>
<h3></h3>
<div className="template-custom-variable">
<Input onChange={(event) => setCustomVariable(event.target.value)} placeholder="英文字符或数字" value={customVariable} />
<Button onClick={() => { insertVariable(customVariable); setCustomVariable(''); }}></Button>
<Input
onChange={(event) => setCustomVariable(event.target.value)}
placeholder="英文字符或数字"
value={customVariable}
/>
<Button
onClick={() => {
insertVariable(customVariable);
setCustomVariable('');
}}
>
</Button>
</div>
</div>
) : null}
<div className="template-variable-panel">
<h3></h3>
{currentVariables.length ? currentVariables.map((variable) => (
{currentVariables.length ? (
currentVariables.map((variable) => (
<Input
key={variable.name}
label={`\${${variable.name}}`}
@@ -237,7 +302,10 @@ function TemplateFormModal({
placeholder="请输入变量示例值"
value={variable.example ?? ''}
/>
)) : <p className="muted"></p>}
))
) : (
<p className="muted"></p>
)}
</div>
</div>
</Modal>
@@ -248,36 +316,37 @@ function TemplatePreviewModal({ item, onClose }: { item: ClientSmsTemplate; onCl
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open title="模板预览">
<div className="detail-grid">
<div><span></span><strong>{item.tenant?.name ?? item.tenantId}</strong></div>
<div><span></span><strong>{item.application?.name ?? item.applicationId}</strong></div>
<div><span></span><strong>{item.signature?.name ?? '-'}</strong></div>
<div><span></span><strong>{billingUnits(item.content)} </strong></div>
<div className="detail-grid__wide"><span></span><strong>{item.content}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{item.variables?.map((variable) => `\${${variable.name}}`).join('、') || '-'}</strong></div>
<div>
<span></span>
<strong>{item.tenant?.name ?? item.tenantId}</strong>
</div>
<div>
<span></span>
<strong>{item.application?.name ?? item.applicationId}</strong>
</div>
<div>
<span></span>
<strong>{item.signature?.name ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{billingUnits(item.content)} </strong>
</div>
<div className="detail-grid__wide">
<span></span>
<strong>{item.content}</strong>
</div>
<div className="detail-grid__wide">
<span></span>
<strong>{item.variables?.map((variable) => `\${${variable.name}}`).join('、') || '-'}</strong>
</div>
</div>
</Modal>
);
}
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
return (
<Modal
footer={(
<>
<Button onClick={onCancel} variant="ghost"></Button>
<Button onClick={onConfirm} variant="danger"></Button>
</>
)}
onClose={onCancel}
open
title="删除确认"
>
<p className="admin-confirm-text">{message}</p>
</Modal>
);
}
export function AdminEnterpriseTemplatesPage() {
const [policyTemplate, setPolicyTemplate] = useState<ClientSmsTemplate | null>(null);
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
@@ -300,23 +369,37 @@ export function AdminEnterpriseTemplatesPage() {
const pageSize = 10;
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, nameKeyword: appliedTemplateNameKeyword, contentKeyword: appliedTemplateContentKeyword }, targetPage = page) {
function loadData(
filters = {
enterpriseKeyword: appliedEnterpriseKeyword,
applicationKeyword: appliedApplicationKeyword,
nameKeyword: appliedTemplateNameKeyword,
contentKeyword: appliedTemplateContentKeyword,
},
targetPage = page,
) {
const sequence = ++listRequestSequence.current;
try {
const templateResult = await adminApi.listEnterpriseTemplatesPage({ ...filters, page: targetPage, pageSize });
return adminApi
.listEnterpriseTemplatesPage({ ...filters, page: targetPage, pageSize })
.then((templateResult) => {
if (sequence !== listRequestSequence.current) return;
setTemplates(templateResult.items);
setTotal(templateResult.total);
setError('');
} catch (failure) {
})
.catch((failure) => {
if (sequence !== listRequestSequence.current) return;
setError(failure instanceof Error ? failure.message : '企业模板加载失败');
}
});
}
useEffect(() => {
let cancelled = false;
void Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions(), adminApi.listEnterpriseSignatureOptions()])
void Promise.all([
adminApi.listTenantOptions(),
adminApi.listEnterpriseApplicationOptions(),
adminApi.listEnterpriseSignatureOptions(),
])
.then(([tenantItems, applicationItems, signatureList]) => {
if (cancelled) return;
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
@@ -326,7 +409,9 @@ export function AdminEnterpriseTemplatesPage() {
.catch((failure: Error) => {
if (!cancelled) setError(failure.message || '企业模板选项加载失败');
});
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
@@ -370,30 +455,70 @@ export function AdminEnterpriseTemplatesPage() {
return (
<section className="page-stack admin-customer-split-page">
{policyTemplate ? (
<TemplateOptOutModal template={policyTemplate} onClose={() => setPolicyTemplate(null)} />
) : null}
<div className="page-heading">
<div>
<Breadcrumb items={['客户管理', '企业模板管理']} />
<h1></h1>
</div>
<Button icon={<Plus size={16} />} onClick={() => setTemplateModal(activeTab === 'sms' ? 'new' : null)}></Button>
<Button icon={<Plus size={16} />} onClick={() => setTemplateModal(activeTab === 'sms' ? 'new' : null)}>
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} />
<Input label="模板名称" onChange={(event) => setTemplateNameKeyword(event.target.value)} placeholder="请输入模板名称" prefix={<Search size={16} />} value={templateNameKeyword} />
<Input label="模板内容" onChange={(event) => setTemplateContentKeyword(event.target.value)} placeholder="请输入模板内容" prefix={<Search size={16} />} value={templateContentKeyword} />
<Input
label="企业名称"
onChange={(event) => setEnterpriseKeyword(event.target.value)}
placeholder="请输入企业名称"
prefix={<Search size={16} />}
value={enterpriseKeyword}
/>
<Input
label="企业应用"
onChange={(event) => setApplicationKeyword(event.target.value)}
placeholder="请输入企业应用名称"
prefix={<Search size={16} />}
value={applicationKeyword}
/>
<Input
label="模板名称"
onChange={(event) => setTemplateNameKeyword(event.target.value)}
placeholder="请输入模板名称"
prefix={<Search size={16} />}
value={templateNameKeyword}
/>
<Input
label="模板内容"
onChange={(event) => setTemplateContentKeyword(event.target.value)}
placeholder="请输入模板内容"
prefix={<Search size={16} />}
value={templateContentKeyword}
/>
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => {
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), nameKeyword: templateNameKeyword.trim(), contentKeyword: templateContentKeyword.trim() };
<Button
icon={<Search size={16} />}
onClick={() => {
const filters = {
enterpriseKeyword: enterpriseKeyword.trim(),
applicationKeyword: applicationKeyword.trim(),
nameKeyword: templateNameKeyword.trim(),
contentKeyword: templateContentKeyword.trim(),
};
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedTemplateNameKeyword(filters.nameKeyword);
setAppliedTemplateContentKeyword(filters.contentKeyword);
if (page !== 1) setPage(1);
else void loadData(filters, 1);
}}></Button>
<Button onClick={() => {
}}
>
</Button>
<Button
onClick={() => {
const filters = { enterpriseKeyword: '', applicationKeyword: '', nameKeyword: '', contentKeyword: '' };
setEnterpriseKeyword('');
setApplicationKeyword('');
@@ -405,7 +530,11 @@ export function AdminEnterpriseTemplatesPage() {
setAppliedTemplateContentKeyword('');
if (page !== 1) setPage(1);
else void loadData(filters, 1);
}} variant="ghost"></Button>
}}
variant="ghost"
>
</Button>
</div>
</div>
<div className="surface section-stack">
@@ -413,19 +542,33 @@ export function AdminEnterpriseTemplatesPage() {
onChange={(value) => setActiveTab(value as 'sms' | 'mms')}
value={activeTab}
items={[
{ label: '短信模板', value: 'sms', content: (
{
label: '短信模板',
value: 'sms',
content: (
<div className="admin-enterprise-template-list">
{visibleTemplates.map((template) => (
<article className="admin-enterprise-template-row" key={template.id}>
<div className="admin-enterprise-template-row__identity">
<div className="admin-enterprise-template-row__title">
<strong>{template.name}</strong>
<Tag tone={statusTone(template.auditStatus)}>{auditStatusLabel[template.auditStatus] ?? template.auditStatus}</Tag>
<Tag tone={statusTone(template.auditStatus)}>
{auditStatusLabel[template.auditStatus] ?? template.auditStatus}
</Tag>
</div>
<dl>
<div><dt></dt><dd>{template.tenant?.name ?? template.tenantId}</dd></div>
<div><dt></dt><dd>{template.application?.name ?? template.applicationId}</dd></div>
<div><dt></dt><dd>{template.signature?.name ?? '未绑定'}</dd></div>
<div>
<dt></dt>
<dd>{template.tenant?.name ?? template.tenantId}</dd>
</div>
<div>
<dt></dt>
<dd>{template.application?.name ?? template.applicationId}</dd>
</div>
<div>
<dt></dt>
<dd>{template.signature?.name ?? '未绑定'}</dd>
</div>
</dl>
</div>
<div className="admin-enterprise-template-row__content">
@@ -438,9 +581,31 @@ export function AdminEnterpriseTemplatesPage() {
<strong>{formatDate(template.updatedAt)}</strong>
</div>
<div className="admin-enterprise-template-row__actions">
<Button icon={<Eye size={15} />} onClick={() => setTemplatePreview(template)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={15} />} onClick={() => setTemplateModal(template)} size="sm" variant="ghost"></Button>
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={template.id} targetType="template" />
<Button
icon={<Eye size={15} />}
onClick={() => setTemplatePreview(template)}
size="sm"
variant="ghost"
>
</Button>
<Button
icon={<Edit3 size={15} />}
onClick={() => setTemplateModal(template)}
size="sm"
variant="ghost"
>
</Button>
<Button onClick={() => setPolicyTemplate(template)} size="sm" variant="ghost">
</Button>
<DeleteRiskAction
onCompleted={() => void loadData()}
portal="admin"
targetId={template.id}
targetType="template"
/>
</div>
</article>
))}
@@ -456,8 +621,14 @@ export function AdminEnterpriseTemplatesPage() {
totalPages={totalPages}
/>
</div>
) },
{ label: '彩信模板', pending: true, value: 'mms', content: <div className="ui-table__empty"></div> },
),
},
{
label: '彩信模板',
pending: true,
value: 'mms',
content: <div className="ui-table__empty"></div>,
},
]}
/>
</div>
@@ -467,12 +638,16 @@ export function AdminEnterpriseTemplatesPage() {
applications={applications}
item={templateModal === 'new' ? undefined : templateModal}
onClose={() => setTemplateModal(null)}
onSubmit={(state) => { void saveTemplate(state); }}
onSubmit={(state) => {
void saveTemplate(state);
}}
signatures={signatureItems}
tenants={tenants}
/>
) : null}
{templatePreview ? <TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} /> : null}
{templatePreview ? (
<TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} />
) : null}
</section>
);
}
+41
View File
@@ -0,0 +1,41 @@
.quality-status-bar {
display: flex;
align-items: center;
gap: var(--space-2);
}
.quality-status-bar .quality-status-bar__track {
display: flex;
flex: 1;
min-width: 60px;
height: 8px;
overflow: hidden;
border-radius: 99px;
background: var(--color-surface-muted);
}
.quality-status-bar .quality-status-bar__segment {
flex-shrink: 0;
height: 100%;
}
.quality-status-bar .quality-status-bar__segment--0 {
background: var(--color-success);
}
.quality-status-bar .quality-status-bar__segment--1 {
background: var(--color-warning);
}
.quality-status-bar .quality-status-bar__segment--2 {
background: var(--color-danger);
}
.quality-status-bar .quality-status-bar__segment--3 {
background: var(--color-text-muted);
}
.quality-status-bar strong {
min-width: 50px;
text-align: right;
}
+33
View File
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { QualityStatusBar } from './QualityStatusBar';
describe('quality status composition', () => {
it('shows four segments in one bar and puts unknown only in tooltip', () => {
render(
<QualityStatusBar
metric={{ total: 100, successCount: 60, submitFailureCount: 10, failureCount: 20, unknownCount: 10 }}
/>,
);
const bar = screen.getByRole('img');
expect(bar.children).toHaveLength(4);
expect([...bar.children].map((el) => (el as HTMLElement).style.width)).toEqual(['60%', '10%', '20%', '10%']);
expect(bar.getAttribute('title')).toContain('未收到回执:10 条(10.0%');
expect(screen.queryByText(/未收到回执/)).not.toBeInTheDocument();
expect(screen.getByText('60.0%')).toBeInTheDocument();
});
it('handles no submissions and reports inconsistent counts without invented values', () => {
const view = render(
<QualityStatusBar
metric={{ total: 0, successCount: 0, submitFailureCount: 0, failureCount: 0, unknownCount: 0 }}
/>,
);
expect(screen.getByRole('img')).toHaveAttribute('title', '暂无提交');
view.rerender(
<QualityStatusBar
metric={{ total: 2, successCount: 2, submitFailureCount: 1, failureCount: 0, unknownCount: 0 }}
/>,
);
expect(screen.getByRole('img').children).toHaveLength(0);
expect(screen.getByText('—')).toBeInTheDocument();
});
});
+38
View File
@@ -0,0 +1,38 @@
import './QualityStatusBar.css';
type Counts = {
total: number;
successCount: number;
submitFailureCount: number;
failureCount: number;
unknownCount: number;
};
export function QualityStatusBar({ metric }: { metric: Counts }) {
const counts = [metric.successCount, metric.submitFailureCount, metric.failureCount, metric.unknownCount];
const labels = ['已到达', '提交失败', '回执失败', '未收到回执'];
const valid = counts.every((n) => Number.isFinite(n) && n >= 0) && counts.reduce((a, b) => a + b, 0) === metric.total;
const percentage = (count: number) => (metric.total > 0 ? (count / metric.total) * 100 : 0);
const title = !valid
? '统计数据不一致,请刷新后重试'
: metric.total === 0
? '暂无提交'
: counts
.map((count, i) => `${labels[i]}${count.toLocaleString('zh-CN')} 条(${percentage(count).toFixed(1)}%`)
.join('\n');
return (
<div className="quality-status-bar">
<div className="quality-status-bar__track" title={title} aria-label={title} role="img" tabIndex={0}>
{valid && metric.total > 0
? counts.map((count, i) => (
<span
key={labels[i]}
className={`quality-status-bar__segment quality-status-bar__segment--${i}`}
style={{ width: `${percentage(count)}%` }}
/>
))
: null}
</div>
<strong>{valid ? `${percentage(metric.successCount).toFixed(1)}%` : '—'}</strong>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
.template-optout {
display: grid;
gap: var(--space-4);
}
.template-optout .template-optout__preserve {
display: flex;
align-items: center;
gap: var(--space-2);
}
.template-optout .template-optout__row {
display: grid;
grid-template-columns: 1fr 240px;
align-items: center;
gap: var(--space-4);
padding-block: var(--space-3);
border-top: 1px solid var(--color-border);
}
.template-optout .template-optout__row small {
display: block;
color: var(--color-text-muted);
margin-top: var(--space-1);
overflow-wrap: anywhere;
}
@media (max-width: 600px) {
.template-optout .template-optout__row {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,46 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TemplateOptOutModal } from './TemplateOptOutModal';
const api = vi.hoisted(() => ({ getTemplateOptOutPolicy: vi.fn(), updateTemplateOptOutPolicy: vi.fn() }));
vi.mock('@/api/adminApi', () => ({ adminApi: api }));
const policy = {
rules: [{ channelId: 'a', action: 'add' }],
preserveFragments: true,
channels: [{ id: 'a', name: '通道甲', groupNames: ['移动组'] }],
};
describe('opt-out policy editor', () => {
beforeEach(() => {
vi.clearAllMocks();
api.getTemplateOptOutPolicy.mockResolvedValue(policy);
});
it('keeps failed saves visible and cannot disable fragment protection', async () => {
api.updateTemplateOptOutPolicy.mockRejectedValueOnce(new Error('保存失败')).mockResolvedValueOnce({});
const close = vi.fn();
render(<TemplateOptOutModal template={{ id: 't', name: '模板甲' }} onClose={close} />);
await screen.findByLabelText('通道甲的拒收指令');
expect(screen.getByLabelText('避免影响消息分片数')).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '保存策略' }));
await screen.findByRole('alert');
expect(close).not.toHaveBeenCalled();
expect(api.updateTemplateOptOutPolicy).toHaveBeenCalledWith('t', { rules: policy.rules, preserveFragments: true });
fireEvent.click(screen.getByRole('button', { name: '保存策略' }));
await waitFor(() => expect(close).toHaveBeenCalledTimes(1));
});
it('does not allow saving an unloaded policy and supports retry', async () => {
api.getTemplateOptOutPolicy.mockRejectedValueOnce(new Error('加载失败'));
render(<TemplateOptOutModal template={{ id: 't', name: '模板甲' }} onClose={vi.fn()} />);
await screen.findByRole('alert');
expect(screen.getByRole('button', { name: '保存策略' })).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '重试' }));
await screen.findByLabelText('通道甲的拒收指令');
expect(screen.getByRole('button', { name: '保存策略' })).toBeEnabled();
});
it('requires removing stale channel rules explicitly', async () => {
api.getTemplateOptOutPolicy.mockResolvedValueOnce({ ...policy, channels: [] });
render(<TemplateOptOutModal template={{ id: 't', name: '模板甲' }} onClose={vi.fn()} />);
const remove = await screen.findByRole('button', { name: '移除失效规则' });
expect(screen.getByRole('button', { name: '保存策略' })).toBeDisabled();
fireEvent.click(remove);
expect(screen.getByRole('button', { name: '保存策略' })).toBeEnabled();
});
});
+146
View File
@@ -0,0 +1,146 @@
import { useEffect, useState } from 'react';
import { adminApi } from '@/api/adminApi';
import { Button, Modal, Select } from '@/components/ui';
import './TemplateOptOutModal.css';
type Rule = { channelId: string; action: 'add' | 'remove' };
type Policy = {
rules: Rule[];
preserveFragments: boolean;
channels: { id: string; name: string; groupNames: string[] }[];
};
export function TemplateOptOutModal({
template,
onClose,
}: {
template: { id: string; name: string };
onClose: () => void;
}) {
const [policy, setPolicy] = useState<Policy>();
const [rules, setRules] = useState<Rule[]>([]);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [reload, setReload] = useState(0);
useEffect(() => {
let active = true;
setLoading(true);
setError('');
adminApi
.getTemplateOptOutPolicy(template.id)
.then((value) => {
if (active) {
setPolicy(value);
setRules(value.rules);
}
})
.catch((e) => {
if (active) setError(e instanceof Error ? e.message : '策略加载失败');
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [template.id, reload]);
async function save() {
setSaving(true);
setError('');
try {
await adminApi.updateTemplateOptOutPolicy(template.id, { rules, preserveFragments: true });
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : '策略保存失败');
} finally {
setSaving(false);
}
}
const unavailable = rules.filter((rule) => !policy?.channels.some((c) => c.id === rule.channelId));
return (
<Modal
open
title="模板拒收指令"
size="xl"
onClose={onClose}
dirty={Boolean(policy && JSON.stringify(rules) !== JSON.stringify(policy.rules))}
footer={({ requestClose }) => (
<>
<Button variant="ghost" disabled={saving} onClick={requestClose}>
</Button>
<Button disabled={loading || saving || !policy || unavailable.length > 0} onClick={() => void save()}>
{saving ? '保存中…' : '保存策略'}
</Button>
</>
)}
>
<div className="template-optout">
<p>
<strong>{template.name}</strong>
</p>
<p className="muted">R</p>
<label className="template-optout__preserve">
<input type="checkbox" checked disabled />
</label>
<p className="muted">
</p>
{error ? (
<div role="alert" className="form-error">
{error}
{!policy ? (
<Button variant="ghost" onClick={() => setReload((n) => n + 1)}>
</Button>
) : null}
</div>
) : null}
{loading ? (
<p role="status"></p>
) : policy?.channels.length === 0 ? (
<p></p>
) : null}
{!loading
? policy?.channels.map((channel) => (
<div className="template-optout__row" key={channel.id}>
<div>
<strong>{channel.name}</strong>
<small>{channel.groupNames.join('、')}</small>
</div>
<Select
label={`${channel.name}的拒收指令`}
value={rules.find((r) => r.channelId === channel.id)?.action ?? 'none'}
disabled={saving}
options={[
{ value: 'none', label: '保持原文' },
{ value: 'remove', label: '末尾删除拒收指令' },
{ value: 'add', label: '末尾增加拒收指令' },
]}
onChange={(event) => {
const action = event.target.value;
setRules((current) => [
...current.filter((r) => r.channelId !== channel.id),
...(action === 'none' ? [] : [{ channelId: channel.id, action: action as Rule['action'] }]),
]);
}}
/>
</div>
))
: null}
{unavailable.length ? (
<div role="alert">
<Button
onClick={() => setRules((current) => current.filter((r) => !unavailable.includes(r)))}
variant="ghost"
>
</Button>
</div>
) : null}
</div>
</Modal>
);
}
@@ -478,6 +478,27 @@
}
@media (max-width: 780px) {
.sms-channel-filter-grid,
.sms-channel-form-grid,
.sms-channel-inline-field {
grid-template-columns: minmax(0, 1fr);
}
.sms-channel-form-grid > .ui-field,
.sms-channel-radio-row,
.sms-channel-inline-field {
grid-column: 1;
}
.sms-channel-radio-row {
flex-wrap: wrap;
gap: var(--space-4);
}
.sms-channel-radio-row > span {
flex-basis: 100%;
}
.channel-connection-summary article {
grid-template-columns: 1fr;
}
@@ -156,6 +156,24 @@ export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose
<p className="admin-sms-detail-content">
<DrainageContent record={record} />
</p>
{record.originalContent != null ? (
<>
<h3></h3>
<p className="admin-sms-detail-content">{record.originalContent}</p>
</>
) : null}
{record.originalContent != null
? record.submitRecords
?.filter((submit) => submit.sentContent != null)
.map((submit, index) => (
<div key={submit.id}>
<h3>
{index + 1} · {submit.channel?.name ?? submit.channelId}
</h3>
<p className="admin-sms-detail-content">{submit.sentContent}</p>
</div>
))
: null}
</section>
<section>
+6
View File
@@ -291,6 +291,12 @@ export function ClientSendDetailPage() {
<div className="send-detail-content-block">
<span></span>
<p>{record.content}</p>
{record.originalContent != null ? (
<>
<span></span>
<p>{record.originalContent}</p>
</>
) : null}
</div>
</td>
</tr>
+179 -54
View File
@@ -12,39 +12,59 @@
"files": [
{
"file": "src/apps/admin/AdminHome.css",
"owners": ["src/apps/admin/AdminHome.tsx"],
"owners": [
"src/apps/admin/AdminHome.tsx"
],
"stylelintLegacy": false,
"roots": ["admin-dashboard"]
"roots": [
"admin-dashboard"
]
},
{
"file": "src/apps/admin/AdminAnalyticsPage.css",
"owners": ["src/apps/admin/AdminAnalyticsPage.tsx"],
"owners": [
"src/apps/admin/AdminAnalyticsPage.tsx"
],
"stylelintLegacy": false,
"roots": ["admin-analytics-page"]
"roots": [
"admin-analytics-page"
]
},
{
"file": "src/layouts/AlertNotificationMenu.css",
"owners": ["src/layouts/AlertNotificationMenu.tsx"],
"owners": [
"src/layouts/AlertNotificationMenu.tsx"
],
"stylelintLegacy": false,
"roots": ["alert-notification-menu"]
"roots": [
"alert-notification-menu"
]
},
{
"file": "src/apps/admin/ReportMaterialImportModal.css",
"owners": ["src/apps/admin/ReportMaterialImportModal.tsx"],
"owners": [
"src/apps/admin/ReportMaterialImportModal.tsx"
],
"stylelintLegacy": false,
"roots": ["report-material-import-modal"]
"roots": [
"report-material-import-modal"
]
},
{
"file": "src/apps/admin/channels/AdminChannelsPage.css",
"owners": ["src/apps/admin/AdminChannelsPage.tsx"],
"legacyFingerprint": "fc2d168035c9adac5b330a7526424831ac18725da92a8c2392600e08a0891c69",
"owners": [
"src/apps/admin/AdminChannelsPage.tsx"
],
"legacyFingerprint": "2c5e14e0747d1c05a82155f4b49321b01984cb38007a57322c1c3cf119fa22db",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。 2026-09-20 按模板拒收策略专项方案修复通道页窄屏查询遮挡及运营商选项换行,三尺寸验收后更新基线。",
"removalCondition": "按页面或公共组件确认消费者、根类和真实浏览器等价证据后,移除历史摘要并登记 roots;有意样式变更须附依据和验证后更新基线。"
},
{
"file": "src/apps/admin/enterprise-applications/AdminEnterpriseApplicationsPage.css",
"owners": ["src/apps/admin/AdminEnterpriseApplicationsPage.tsx"],
"owners": [
"src/apps/admin/AdminEnterpriseApplicationsPage.tsx"
],
"legacyFingerprint": "ac5abed49be7edb910ef8746c36f72e953f199b6073d13617594f1158fe4c69b",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -52,7 +72,9 @@
},
{
"file": "src/apps/admin/security-detection/AdminSecurityDetectionPage.css",
"owners": ["src/apps/admin/security-detection/AdminSecurityDetectionPage.tsx"],
"owners": [
"src/apps/admin/security-detection/AdminSecurityDetectionPage.tsx"
],
"legacyFingerprint": "ba5bc7ea3d09615685fee210c625d85ddf1c368d587922fb4953b24591675e06",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -60,7 +82,9 @@
},
{
"file": "src/apps/admin/sms-records/AdminSmsRecordsPage.css",
"owners": ["src/apps/admin/AdminSmsRecordsPage.tsx"],
"owners": [
"src/apps/admin/AdminSmsRecordsPage.tsx"
],
"legacyFingerprint": "a7cdbded6c765662dee0769c9c02159bb01045af08e2fa1842a11f79699fad02",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -68,7 +92,9 @@
},
{
"file": "src/apps/admin/sms-task-progress/AdminSmsTaskProgressPage.css",
"owners": ["src/apps/admin/AdminSmsTaskProgressPage.tsx"],
"owners": [
"src/apps/admin/AdminSmsTaskProgressPage.tsx"
],
"legacyFingerprint": "a222961a94f15a388892a272e42bf64e122ea55f067bf618126e67ed107014b2",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -76,7 +102,9 @@
},
{
"file": "src/apps/admin/system-monitoring/AdminSystemMonitoringPage.css",
"owners": ["src/apps/admin/system-monitoring/AdminSystemMonitoringPage.tsx"],
"owners": [
"src/apps/admin/system-monitoring/AdminSystemMonitoringPage.tsx"
],
"legacyFingerprint": "59d09cdce53ef804773d46b1e5807e0e273f2c3ba7e2aa21b864d0d718f75db2",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -84,7 +112,9 @@
},
{
"file": "src/apps/client/ClientUsersPage.css",
"owners": ["src/apps/client/ClientUsersPage.tsx"],
"owners": [
"src/apps/client/ClientUsersPage.tsx"
],
"legacyFingerprint": "af9cdb6f0229056437dab22fc0533fa9b0df2e3b013b69088a9815c4468518db",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -92,7 +122,9 @@
},
{
"file": "src/styles/admin.css",
"owners": ["src/main.tsx"],
"owners": [
"src/main.tsx"
],
"legacyFingerprint": "b31d1360ca34c0bd5bc81d3687f5db649d5eb775db41faaec58c1ffd358891a1",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -100,7 +132,9 @@
},
{
"file": "src/styles/client.css",
"owners": ["src/main.tsx"],
"owners": [
"src/main.tsx"
],
"legacyFingerprint": "b496831577a2296cca8bd1e3ba5a5c9574068f73f8f4c8176fe023957f803219",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -108,7 +142,9 @@
},
{
"file": "src/styles/components.css",
"owners": ["src/main.tsx"],
"owners": [
"src/main.tsx"
],
"legacyFingerprint": "8adee9fa7adcc5df32137c6428b944d9a79fd7aab0ceb8ae699aca6686294702",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -116,7 +152,9 @@
},
{
"file": "src/styles/domains/01-operations-dashboard.css",
"owners": ["src/styles/domains/index.css"],
"owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "a9b28b65f14fb518f16620c4ae0fe337fe7807202818154c134b612384f9167d",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -124,7 +162,9 @@
},
{
"file": "src/styles/domains/02-client-sending.css",
"owners": ["src/styles/domains/index.css"],
"owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "b84920adb1891089df78af28066fe0bc1c0d2452057a294a798554312be57b41",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -132,7 +172,9 @@
},
{
"file": "src/styles/domains/03-client-records.css",
"owners": ["src/styles/domains/index.css"],
"owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "1e3eb6377c5436bc008f77cf1721575f5ace209cf0296d635002249272dcf794",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -140,7 +182,9 @@
},
{
"file": "src/styles/domains/04-signatures.css",
"owners": ["src/styles/domains/index.css"],
"owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "0ff9be0753940b7d3312042a93b6a97d1cca92d3e045bfa3f13d064467377874",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -148,7 +192,9 @@
},
{
"file": "src/styles/domains/05-templates.css",
"owners": ["src/styles/domains/index.css"],
"owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "79d3e6a2c2e1cef9dcecaac985344cdc36ce30a31ccfae6d5c29f5801152bd76",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -156,7 +202,9 @@
},
{
"file": "src/styles/domains/06-auth-enterprise.css",
"owners": ["src/styles/domains/index.css"],
"owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "f391234e8afe2085bc3d64c4e86d2f89e9f4aa2554d84abf9ea9f6228a35fbc7",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -164,7 +212,9 @@
},
{
"file": "src/styles/domains/07-admin-operations.css",
"owners": ["src/styles/domains/index.css"],
"owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "e8414200ee4b8354130880f1e3c7b71ea3972d278b1b72414897fb14d554958b",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -172,7 +222,9 @@
},
{
"file": "src/styles/domains/08-reporting.css",
"owners": ["src/styles/domains/index.css"],
"owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "db762c6fef81aac2b4a76746b5a78a607e9711aaeb4e509e2800b86b17eee6a5",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -180,7 +232,9 @@
},
{
"file": "src/styles/domains/09-channels.css",
"owners": ["src/styles/domains/index.css"],
"owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "a32eab767f85b49324e17721b70b8dd0c8f0e5a14d6ef2d3502a56c69f9c0e7a",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -188,7 +242,9 @@
},
{
"file": "src/styles/domains/10-signature-quality.css",
"owners": ["src/styles/domains/index.css"],
"owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "9d79afed6c1550e747dcf6c5ab554a4977b62a93d66421a0a01add9f875e2c30",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -196,7 +252,9 @@
},
{
"file": "src/styles/domains/11-deliveries-reporting.css",
"owners": ["src/styles/domains/index.css"],
"owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "9792e6aedb4c74dcd6aeb26925391315e7df662c038a32be5ffcf3eee282374d",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -204,7 +262,9 @@
},
{
"file": "src/styles/domains/12-admin-configuration.css",
"owners": ["src/styles/domains/index.css"],
"owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "60ac29279c18da425f52efe88b2de9a686a20c4e54ec54d6a424d027490c8e81",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -212,7 +272,9 @@
},
{
"file": "src/styles/domains/13-client-signatures.css",
"owners": ["src/styles/domains/index.css"],
"owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "c90bb9ea3bf4ab278379a806b94187a559a46557de344c934be55968fef5287b",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -220,7 +282,9 @@
},
{
"file": "src/styles/domains/14-responsive-requeue.css",
"owners": ["src/styles/domains/index.css"],
"owners": [
"src/styles/domains/index.css"
],
"legacyFingerprint": "3d3daaca99d4efbdaaea4dd1ec6a0239274ed52f6de658ed9f889a6d565ec567",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -228,7 +292,9 @@
},
{
"file": "src/styles/domains/index.css",
"owners": ["src/main.tsx"],
"owners": [
"src/main.tsx"
],
"legacyFingerprint": "3f287af46d6c7e7f921c43913b52a9a727a8e343a80ad46c45d25baaed073177",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -236,7 +302,9 @@
},
{
"file": "src/styles/reset.css",
"owners": ["src/main.tsx"],
"owners": [
"src/main.tsx"
],
"legacyFingerprint": "4be47c806f9a6981a8b73f3844d707fd5cdaaf0d343323de07c6e39089224df1",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -244,7 +312,9 @@
},
{
"file": "src/styles/shell.css",
"owners": ["src/main.tsx"],
"owners": [
"src/main.tsx"
],
"legacyFingerprint": "93e26e9dcaa88447e054aeff8e20d4902cdaea54f0ec5a5b5659c84d9c4b9bd8",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -252,7 +322,9 @@
},
{
"file": "src/styles/tokens.css",
"owners": ["src/main.tsx"],
"owners": [
"src/main.tsx"
],
"legacyFingerprint": "754135f86b0828fa004270a4be6e7a087cb794eefa3860e8d45f89a6eb1f7223",
"stylelintLegacy": true,
"reason": "2026-09-05 现有样式保留已发布级联;不授权向历史文件追加新业务规则。",
@@ -260,50 +332,103 @@
},
{
"file": "src/apps/admin/AdminChannelGroupFormPage.css",
"owners": ["src/apps/admin/AdminChannelGroupFormPage.tsx"],
"owners": [
"src/apps/admin/AdminChannelGroupFormPage.tsx"
],
"stylelintLegacy": false,
"roots": ["channel-group-editor"]
"roots": [
"channel-group-editor"
]
},
{
"file": "src/apps/admin/channel-groups/RouteConfigModal.css",
"owners": ["src/apps/admin/channel-groups/RouteConfigModal.tsx"],
"owners": [
"src/apps/admin/channel-groups/RouteConfigModal.tsx"
],
"stylelintLegacy": false,
"roots": ["channel-route-editor"]
"roots": [
"channel-route-editor"
]
},
{
"file": "src/apps/report-notifications/report-notifications.css",
"owners": ["src/apps/report-notifications/ReportNotificationsPage.tsx"],
"owners": [
"src/apps/report-notifications/ReportNotificationsPage.tsx"
],
"stylelintLegacy": false,
"roots": ["report-notifications-page"]
"roots": [
"report-notifications-page"
]
},
{
"file": "src/apps/admin/AdminMonitorPage.css",
"owners": ["src/apps/admin/AdminMonitorPage.tsx"],
"owners": [
"src/apps/admin/AdminMonitorPage.tsx"
],
"stylelintLegacy": false,
"roots": ["sending-monitor"]
"roots": [
"sending-monitor"
]
},
{
"file": "src/apps/admin/sending-monitor/MonitorRuleManager.css",
"owners": ["src/apps/admin/sending-monitor/MonitorRuleManager.tsx"],
"owners": [
"src/apps/admin/sending-monitor/MonitorRuleManager.tsx"
],
"stylelintLegacy": false,
"roots": ["monitor-rules"]
"roots": [
"monitor-rules"
]
},
{
"file": "src/apps/client/http-docs/HttpDeveloperDocs.css",
"owners": ["src/apps/client/http-docs/HttpDeveloperDocs.tsx", "src/apps/client/http-docs/ClientHttpDocsPage.tsx"],
"owners": [
"src/apps/client/http-docs/HttpDeveloperDocs.tsx",
"src/apps/client/http-docs/ClientHttpDocsPage.tsx"
],
"stylelintLegacy": false,
"roots": ["client-http-docs"]
"roots": [
"client-http-docs"
]
},
{
"file": "src/apps/client/ClientTemplatesPage.css",
"owners": ["src/apps/client/ClientTemplatesPage.tsx"],
"owners": [
"src/apps/client/ClientTemplatesPage.tsx"
],
"stylelintLegacy": false,
"roots": ["client-templates-page"]
"roots": [
"client-templates-page"
]
},
{
"file": "src/apps/shared/http-signature/HttpSignaturePage.css",
"owners": ["src/apps/shared/http-signature/HttpSignaturePage.tsx"],
"roots": ["http-signature-page"]
"owners": [
"src/apps/shared/http-signature/HttpSignaturePage.tsx"
],
"roots": [
"http-signature-page"
]
},
{
"file": "src/apps/admin/QualityStatusBar.css",
"owners": [
"src/apps/admin/QualityStatusBar.tsx"
],
"stylelintLegacy": false,
"roots": [
"quality-status-bar"
]
},
{
"file": "src/apps/admin/TemplateOptOutModal.css",
"owners": [
"src/apps/admin/TemplateOptOutModal.tsx"
],
"stylelintLegacy": false,
"roots": [
"template-optout"
]
}
]
}
+400
View File
@@ -0,0 +1,400 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { randomBytes, randomUUID } from 'node:crypto';
import { writeFileSync } from 'node:fs';
const url = new URL(process.env.OPT_OUT_TEST_DATABASE_URL || '');
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_optout_'));
const redis = new URL(process.env.OPT_OUT_TEST_REDIS_URL || 'redis://127.0.0.1:16436');
assert(['127.0.0.1', 'localhost'].includes(redis.hostname));
Object.assign(process.env, {
NODE_ENV: 'test',
DATABASE_URL: url.toString(),
REDIS_URL: redis.toString(),
HTTP_API_MASTER_KEY: randomBytes(32).toString('hex'),
MINIO_ENDPOINT: '127.0.0.1:19400',
GATEWAY_CONTROL_URL: 'http://127.0.0.1:19401',
SIGNATURE_ANALYTICS_ENABLED: 'false',
HOME_DASHBOARD_ENABLED: 'false',
SEND_SUBMIT_OUTBOX_PUBLISH_ENABLED: 'true',
});
// No workers/publishers/Gateway are started. Commands stay in this isolated database.
const require = createRequire(new URL('../../api/package.json', import.meta.url));
Object.assign(process.env, {
GATEWAY_STARTUP_RECONNECT_DELAY_MS: '3600000',
GATEWAY_CONNECTION_RECONCILER_DISABLED: 'true',
GATEWAY_CONNECTING_TIMEOUT_SCANNER_DISABLED: 'true',
SMS_RECEIPT_TIMEOUT_SCAN_ENABLED: 'false',
SMS_SCHEDULED_DISPATCH_SCAN_ENABLED: 'false',
CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED: 'false',
UPSTREAM_RECEIPT_INBOX_SCAN_ENABLED: 'false',
CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED: 'false',
CMPP_PROCESS_ROLE: 'api',
API_ENABLE_SEND_WORKER: 'false',
CMPP_INBOUND_WORKFLOW_WORKER_ENABLED: 'false',
});
require('reflect-metadata');
Object.defineProperty(BigInt.prototype, 'toJSON', {
value() {
return Number(this);
},
configurable: true,
});
const { NestFactory } = require('@nestjs/core'),
{ AppModule } = require('./dist/app.module');
const { PrismaService } = require('./dist/prisma/prisma.service'),
{ SessionService } = require('./dist/auth/session.service');
const { UsersService } = require('./dist/users/users.service'),
{ SendChainService } = require('./dist/send-chain/send-chain.service');
const { completionContext } = require('./dist/send-chain/completion-context');
const app = await NestFactory.create(AppModule, { logger: ['error'] });
app.setGlobalPrefix('api');
const pass = (name) => console.log('PASS', name);
try {
await app.listen(Number(process.env.OPT_OUT_TEST_PORT || 0), '127.0.0.1');
const base = (await app.getUrl()) + '/api',
db = app.get(PrismaService),
chain = app.get(SendChainService);
const stamp = randomUUID().slice(0, 8),
key = () => randomUUID();
const tenant = await db.tenant.create({ data: { name: '拒收策略验收企业', code: key() } });
const application = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '拒收策略隔离应用',
cmppAccount: key(),
cmppEnterpriseCode: '000001',
secretHash: 'unused',
interfaceEnabled: false,
templateMismatchMode: 'direct_send',
},
});
const signature = await db.smsSignature.create({
data: { tenantId: tenant.id, applicationId: application.id, name: '【拒收验收】', auditStatus: 'approved' },
});
const content = signature.name + '文'.repeat(65); // 71 characters, two parts.
assert.equal(content.length, 71);
const template = await db.smsTemplate.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
name: '拒收策略验收模板' + stamp,
content,
auditStatus: 'approved',
billingUnits: 2,
},
});
const channel = await db.smsChannel.create({
data: {
name: '隔离通道甲' + stamp,
code: key(),
carrier: 'mobile',
carriers: ['mobile', 'unicom'],
status: 'active',
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: key(),
passwordCipher: 'unused',
srcId: '1069',
sendRegion: '全国',
unitPrice: 325,
config: { serviceId: 'SMS' },
},
});
const backup = await db.smsChannel.create({
data: {
name: '隔离通道乙',
code: key(),
carrier: 'mobile',
carriers: ['mobile'],
status: 'active',
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: key(),
passwordCipher: 'unused',
srcId: '1069',
sendRegion: '全国',
unitPrice: 325,
config: { serviceId: 'SMS' },
},
});
for (const c of [channel, backup]) {
await db.cmppConnectionState.create({
data: { channelId: c.id, connectionId: key(), status: 'connected', currentConnections: 1 },
});
await db.channelSignatureReportTask.create({
data: {
tenantId: tenant.id,
signatureId: signature.id,
channelId: c.id,
carrier: 'mobile',
approvalScope: 'carrier_specific',
status: 'approved',
},
});
}
const group = await db.smsChannelGroup.create({
data: {
code: key(),
name: '隔离移动组',
carrier: 'mobile',
items: {
create: [
{ channelId: channel.id, carrier: 'mobile', priority: 1 },
{ channelId: backup.id, carrier: 'mobile', priority: 2 },
],
},
},
});
const unicom = await db.smsChannelGroup.create({
data: {
code: key(),
name: '隔离联通组',
carrier: 'unicom',
items: { create: { channelId: channel.id, carrier: 'unicom' } },
},
});
await db.channelRouteRule.create({
data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier: 'mobile' },
});
await db.channelRouteRule.create({
data: { tenantId: tenant.id, applicationId: application.id, groupId: unicom.id, carrier: 'unicom' },
});
const users = app.get(UsersService),
sessions = app.get(SessionService);
const user = await users.create({
username: 'optout' + stamp,
email: stamp + '@example.invalid',
displayName: '隔离运营验收',
password: randomBytes(24).toString('hex'),
roleCode: 'platform_admin',
});
const session = await sessions.create(user.id, 'admin', 0),
cookie = sessions.cookieName('admin');
const headers = { 'content-type': 'application/json', cookie: cookie + '=' + session.token };
const req = (path, body, method = body === undefined ? 'GET' : 'PUT', head = headers) =>
fetch(base + path, { method, headers: head, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
const path = '/admin/enterprise-templates/' + template.id + '/opt-out-policy';
assert.equal((await req(path, undefined, 'GET', {})).status, 401);
const customer = await users.create({
username: 'client' + stamp,
email: 'c' + stamp + '@example.invalid',
displayName: '隔离客户',
password: randomBytes(24).toString('hex'),
roleCode: 'enterprise_admin',
tenantId: tenant.id,
});
const clientSession = await sessions.create(customer.id, 'client', 0);
assert.equal(
(await req(path, undefined, 'GET', { cookie: sessions.cookieName('client') + '=' + clientSession.token })).status,
401,
);
assert.equal((await req(path, { rules: [], preserveFragments: false })).status, 400);
assert.equal(
(await req(path, { rules: [{ channelId: 'foreign', action: 'add' }], preserveFragments: true })).status,
400,
);
const rules = [
{ channelId: channel.id, action: 'add' },
{ channelId: backup.id, action: 'remove' },
];
assert.equal((await req(path, { rules, preserveFragments: true })).status, 200);
assert.deepEqual((await req(path).then((r) => r.json())).rules, rules);
assert.equal(
await db.operationLog.count({ where: { resourceId: template.id, action: 'sms_template.opt_out_policy.update' } }),
1,
);
pass('real HTTP policy save/read, authentication, scope, mandatory fragment protection and durable audit');
const reduced = await req('/admin/channels/' + channel.id, { carriers: ['mobile'] });
assert.equal(reduced.status, 200, await reduced.text());
assert.deepEqual((await db.smsChannel.findUnique({ where: { id: channel.id } })).carriers, ['mobile']);
assert.equal(await db.smsChannelGroupItem.count({ where: { groupId: unicom.id, channelId: channel.id } }), 1);
pass('carrier reduction saves with active group references preserved');
const batch = await db.smsBatchTask.create({
data: { tenantId: tenant.id, applicationId: application.id, taskNo: key(), content, phoneTotal: 1 },
});
const message = await db.smsMessageRecord.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
batchTaskId: batch.id,
messageId: key(),
phoneNumber: '13800138000',
carrier: 'mobile',
content,
billingUnits: 2,
unitPrice: 425,
amountCents: 850,
},
});
// No templateId intentionally: direct_send must still match its template.
const route = await chain.selectChannelForMessage(message);
assert.equal(route.contentPolicy.content, content + '拒收请回复R');
await chain.submitMessageToGateway(message, route, 0);
const first = await db.smsSubmitRecord.findFirstOrThrow({ where: { messageRecordId: message.id } });
const written = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } });
const outbox = await db.gatewaySubmitOutbox.findUniqueOrThrow({ where: { submitId: first.submitId } });
assert.equal(written.originalContent, content);
assert.equal(written.content, outbox.payload.content);
assert.equal(first.sentContent, written.content);
assert.equal(written.billingUnits, 2);
assert.equal(Number(written.amountCents), 850);
assert.equal(Number(first.costAmountCents), 650);
const route2 = await chain.selectChannelForMessage(written, { excludeChannelIds: [channel.id] });
await chain.submitMessageToGateway(written, route2, 1, first.id);
const retried = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } });
assert.equal(retried.content, content);
assert.equal(retried.originalContent, content);
assert.equal(
(await db.gatewaySubmitOutbox.findUniqueOrThrow({ where: { submitId: first.submitId } })).payload.content,
content + '拒收请回复R',
);
assert.equal(await db.gatewaySubmitOutbox.count({ where: { messageRecordId: message.id, status: 'pending' } }), 2);
pass('single submit and alternate-channel retry: real transactional content/Outbox snapshots and unchanged charges');
const before = await db.smsSubmitRecord.count();
await assert.rejects(
db.$transaction((tx) =>
completionContext.run({ tx, messageRecordId: message.id }, async () => {
await chain.submitMessageToGateway(retried, route, 2);
throw new Error('rollback verification');
}),
),
/rollback verification/,
);
assert.equal(await db.smsSubmitRecord.count(), before);
assert.equal((await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } })).content, content);
pass('failed transaction rolls back content, submit and Outbox together');
const gateway = chain.submission.gatewaySubmit;
const many = [];
for (let i = 0; i < 2; i++)
many.push(
await db.smsMessageRecord.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
batchTaskId: batch.id,
messageId: key(),
phoneNumber: '13800138000',
carrier: 'mobile',
content,
billingUnits: 2,
},
}),
);
await gateway.processSendJobBatch(many.map((m) => ({ messageRecordId: m.id })));
for (const m of many) {
const row = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: m.id } });
assert.equal(row.content, content + '拒收请回复R');
assert.equal(row.originalContent, content);
}
pass('microbatch uses the same policy and persists original text');
for (const state of [
{ status: 'delivered', submitStatus: 'accepted', receiptStatus: 'delivered' },
{ status: 'submit_failed', submitStatus: 'rejected' },
{ status: 'failed', submitStatus: 'accepted', receiptStatus: 'undelivered' },
{ status: 'timeout', submitStatus: 'accepted' },
]) {
await db.smsMessageRecord.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
messageId: key(),
phoneNumber: '13800138000',
content,
...state,
},
});
}
const quality = await req('/admin/operations/signature-quality?keyword=' + encodeURIComponent(tenant.name)).then(
(r) => r.json(),
);
const stat = quality.items.find((i) => i.signatureId === signature.id);
assert.equal(stat.total, stat.successCount + stat.submitFailureCount + stat.failureCount + stat.unknownCount);
assert.equal(stat.failureCount, 1);
assert(stat.unknownCount >= 1);
pass('real quality query yields four exclusive categories, including timeout without receipt');
if (process.env.OPT_OUT_TEST_BROWSER === 'true') {
const {
chromium,
} = require('C:/Users/hectorzhao/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const browser = await chromium.launch({ channel: 'msedge', headless: true });
try {
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
const ui = process.env.OPT_OUT_TEST_UI_URL || 'http://127.0.0.1:17438';
assert.equal(new URL(ui).hostname, '127.0.0.1');
const auth = await req('/admin/auth/session').then((r) => r.json());
await page
.context()
.addCookies([{ name: cookie, value: session.token, url: ui, httpOnly: true, sameSite: 'Lax' }]);
await page.addInitScript((data) => localStorage.setItem('cmpp-auth-session:admin', JSON.stringify(data)), auth);
for (const [width, height] of [
[1600, 1000],
[1366, 768],
[390, 844],
]) {
await page.setViewportSize({ width, height });
await page.goto(ui + '/#/admin/enterprise-templates');
const row = page.locator('.admin-enterprise-template-row').filter({ hasText: template.name }).first();
await row.getByRole('button', { name: '拒收指令', exact: true }).click();
await page.getByLabel(channel.name + '的拒收指令').waitFor();
assert(await page.getByLabel('避免影响消息分片数').isDisabled());
await page.screenshot({ path: `.local-data/template-optout-20260920/template-${width}.png`, fullPage: true });
await page.getByRole('button', { name: '保存策略', exact: true }).click();
await page.getByRole('heading', { name: '模板拒收指令', exact: true }).waitFor({ state: 'hidden' });
await page.goto(ui + '/#/admin/analytics');
await page.locator('.quality-status-bar__track').first().waitFor();
assert.match(await page.locator('.quality-status-bar__track').first().getAttribute('title'), /未收到回执/);
await page.screenshot({ path: `.local-data/template-optout-20260920/quality-${width}.png`, fullPage: true });
await page.reload();
await page.locator('.quality-status-bar__track').first().waitFor();
await page.goto(ui + '/#/admin/sms-records');
await page
.locator('.admin-sms-record-card')
.filter({ hasText: '拒收请回复R' })
.first()
.getByRole('button', { name: '查看发送详情' })
.click();
await page.getByRole('heading', { name: '原始短信内容', exact: true }).waitFor();
await page.getByRole('heading', { name: /第 1 次提交通道内容/ }).waitFor();
await page.screenshot({ path: `.local-data/template-optout-20260920/detail-${width}.png`, fullPage: true });
await req('/admin/channels/' + channel.id, { carriers: ['mobile', 'unicom'] });
await page.goto(ui + '/#/admin/channels');
await page.getByLabel('通道名称', { exact: true }).fill(channel.name);
await page.getByRole('button', { name: '查询', exact: true }).click();
await page
.locator('.sms-channel-table__row')
.filter({ hasText: channel.name })
.getByRole('button', { name: '编辑', exact: true })
.click();
await page.getByRole('heading', { name: '编辑通道', exact: true }).waitFor();
await page.getByRole('checkbox', { name: '联通', exact: true }).uncheck();
await page.screenshot({ path: `.local-data/template-optout-20260920/channel-${width}.png`, fullPage: true });
await page.getByRole('button', { name: '确认', exact: true }).click();
await page.getByRole('heading', { name: '编辑通道', exact: true }).waitFor({ state: 'hidden' });
assert.deepEqual((await db.smsChannel.findUniqueOrThrow({ where: { id: channel.id } })).carriers, ['mobile']);
}
assert.deepEqual(errors, []);
pass('real API browser saves, locked checkbox, route/refresh, four-segment bar and three sizes');
} finally {
await browser.close();
}
}
assert.equal(
await db.gatewaySubmitOutbox.count({
where: { messageRecordId: { in: [message.id, ...many.map((m) => m.id)] }, status: { not: 'pending' } },
}),
0,
);
writeFileSync(
'.local-data/template-optout-20260920/fixture.json',
JSON.stringify({ tenantId: tenant.id, templateId: template.id, messageId: message.id }),
);
pass('no command published, no Gateway/SMSC started, no external SMS sent');
} finally {
await app.close();
}