5 Commits
79 changed files with 7563 additions and 1648 deletions
+20 -1
View File
@@ -25,7 +25,8 @@
"minio": "^8.0.7",
"pg": "^8.22.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
"rxjs": "^7.8.2",
"tldts": "^7.4.12"
},
"devDependencies": {
"@types/jest": "^30.0.0",
@@ -8589,6 +8590,24 @@
"readable-stream": "3"
}
},
"node_modules/tldts": {
"version": "7.4.12",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz",
"integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.12"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.12",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz",
"integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==",
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
+2 -1
View File
@@ -34,7 +34,8 @@
"minio": "^8.0.7",
"pg": "^8.22.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
"rxjs": "^7.8.2",
"tldts": "^7.4.12"
},
"devDependencies": {
"@types/jest": "^30.0.0",
@@ -0,0 +1,30 @@
ALTER TABLE "SmsMessageRecord" ADD COLUMN "drainageGate" JSONB;
ALTER TABLE "SmsSubmitRecord" ADD COLUMN "drainageGate" JSONB;
ALTER TABLE "SmsMessageRecord" ADD COLUMN "drainageReceiptPending" BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX "SmsMessageRecord_drainage_receipt_pending" ON "SmsMessageRecord" ("updatedAt") WHERE "drainageReceiptPending" = true;
CREATE TABLE "SmsDrainageDecision" (
"id" TEXT PRIMARY KEY,
"messageRecordId" TEXT NOT NULL,
"decidedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"snapshot" JSONB NOT NULL
);
CREATE INDEX "SmsDrainageDecision_messageRecordId_decidedAt_idx" ON "SmsDrainageDecision" ("messageRecordId", "decidedAt");
CREATE OR REPLACE FUNCTION drainage_authorization_lock() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'UPDATE' AND OLD."signatureId" IS DISTINCT FROM NEW."signatureId" THEN
PERFORM pg_advisory_xact_lock(hashtextextended(value, 910))
FROM unnest(ARRAY[OLD."signatureId", NEW."signatureId"]) AS ids(value) ORDER BY value;
RETURN NEW;
END IF;
IF TG_OP = 'DELETE' THEN
PERFORM pg_advisory_xact_lock(hashtextextended(OLD."signatureId", 910));
RETURN OLD;
END IF;
PERFORM pg_advisory_xact_lock(hashtextextended(NEW."signatureId", 910));
RETURN NEW;
END $$;
CREATE TRIGGER drainage_material_authorization_lock BEFORE INSERT OR UPDATE OR DELETE ON "SmsDrainageInfo"
FOR EACH ROW EXECUTE FUNCTION drainage_authorization_lock();
CREATE TRIGGER drainage_report_authorization_lock BEFORE INSERT OR UPDATE OR DELETE ON "ChannelSignatureReportTask"
FOR EACH ROW EXECUTE FUNCTION drainage_authorization_lock();
@@ -0,0 +1,20 @@
CREATE TABLE "ChannelSensitiveWord" (
"id" TEXT PRIMARY KEY, "channelId" TEXT NOT NULL, "word" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'active', "remark" TEXT NOT NULL DEFAULT '',
"version" INTEGER NOT NULL DEFAULT 1, "createdBy" TEXT NOT NULL, "updatedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChannelSensitiveWord_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "SmsChannel"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "ChannelSensitiveWord_status_check" CHECK ("status" IN ('active','inactive','deleted')),
CONSTRAINT "ChannelSensitiveWord_word_check" CHECK (char_length("word") BETWEEN 1 AND 200)
);
CREATE UNIQUE INDEX "ChannelSensitiveWord_channelId_word_key" ON "ChannelSensitiveWord"("channelId","word");
CREATE INDEX "ChannelSensitiveWord_channelId_status_idx" ON "ChannelSensitiveWord"("channelId","status");
CREATE TABLE "SmsChannelSensitiveDecision" (
"id" TEXT PRIMARY KEY, "messageRecordId" TEXT NOT NULL, "routeAttemptId" TEXT NOT NULL,
"decidedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "snapshot" JSONB NOT NULL,
CONSTRAINT "SmsChannelSensitiveDecision_messageRecordId_fkey" FOREIGN KEY ("messageRecordId") REFERENCES "SmsMessageRecord"("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "SmsChannelSensitiveDecision_routeAttemptId_key" ON "SmsChannelSensitiveDecision"("routeAttemptId");
CREATE INDEX "SmsChannelSensitiveDecision_messageRecordId_decidedAt_idx" ON "SmsChannelSensitiveDecision"("messageRecordId","decidedAt");
ALTER TABLE "SmsMessageRecord" ADD COLUMN "channelWordFinalizationPending" BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX "SmsMessageRecord_channelWordFinalizationPending_idx" ON "SmsMessageRecord"("updatedAt") WHERE "channelWordFinalizationPending" = true;
+40
View File
@@ -266,6 +266,32 @@ model PhoneCarrierRule {
@@index([status, priority])
}
model ChannelSensitiveWord {
id String @id @default(cuid())
channelId String
channel SmsChannel @relation(fields: [channelId], references: [id])
word String
status String @default("active")
remark String @default("")
version Int @default(1)
createdBy String
updatedBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([channelId, word])
@@index([channelId, status])
}
model SmsChannelSensitiveDecision {
id String @id @default(cuid())
messageRecordId String
messageRecord SmsMessageRecord @relation(fields: [messageRecordId], references: [id])
routeAttemptId String @unique
decidedAt DateTime @default(now())
snapshot Json
@@index([messageRecordId, decidedAt])
}
model SensitiveWord {
id String @id @default(cuid())
word String @unique
@@ -881,6 +907,7 @@ model AuditRecord {
}
model SmsChannel {
sensitiveWords ChannelSensitiveWord[]
id String @id @default(cuid())
code String @unique
name String
@@ -1790,7 +1817,17 @@ model SmsApiRequest {
@@index([batchTaskId])
}
model SmsDrainageDecision {
id String @id @default(cuid())
messageRecordId String
decidedAt DateTime @default(now())
snapshot Json
@@index([messageRecordId, decidedAt])
}
model SmsMessageRecord {
channelWordDecisions SmsChannelSensitiveDecision[]
channelWordFinalizationPending Boolean @default(false)
monitorFacts SendingMonitorFact[]
id String @id @default(cuid())
tenantId String?
@@ -1808,6 +1845,8 @@ model SmsMessageRecord {
content String
hasDrainageContent Boolean?
drainageDetection Json?
drainageGate Json?
drainageReceiptPending Boolean @default(false)
drainageDetectionVersion String?
drainageEvaluatedAt DateTime?
billingUnits Int @default(1)
@@ -1880,6 +1919,7 @@ model CmppSubmitSession {
}
model SmsSubmitRecord {
drainageGate Json?
id String @id @default(cuid())
tenantId String?
batchTaskId String?
+14 -2
View File
@@ -194,7 +194,12 @@ export class ChannelReportingService {
SELECT
submit."channelId" AS channel_id,
message."signatureId" AS signature_id,
message.carrier AS carrier,
message."drainageInfoId" AS drainage_info_id,
CASE WHEN COALESCE(submit."drainageGate", message."drainageGate") IS NULL THEN NULL ELSE ARRAY(
SELECT DISTINCT material_id FROM jsonb_array_elements(COALESCE(COALESCE(submit."drainageGate", message."drainageGate")->'targets', '[]'::jsonb)) target
CROSS JOIN LATERAL jsonb_array_elements_text(target->'materialIds') AS ids(material_id)
) END AS drainage_ids,
submit."submitStatus" AS submit_status,
COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at,
CASE
@@ -238,13 +243,16 @@ export class ChannelReportingService {
AND receipt."receiptStatus" = 'undelivered'
) failed_receipt ON TRUE
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
AND NOT (COALESCE(submit."errorCode", '') LIKE 'DRN%' AND submit."firstWireSubmitAt" IS NULL)
AND submit."channelId" IN (${Prisma.join(channelIds)})
AND message."signatureId" IN (${Prisma.join(signatureIds)})
)
SELECT
channel_id AS "channelId",
signature_id AS "signatureId",
carrier,
drainage_info_id AS "drainageInfoId",
drainage_ids AS "drainageIds",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
)::integer AS total,
@@ -270,7 +278,7 @@ export class ChannelReportingService {
)::integer AS "failureCount",
MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt"
FROM base
GROUP BY channel_id, signature_id, drainage_info_id
GROUP BY channel_id, signature_id, drainage_info_id, carrier, drainage_ids
`);
return tasks.map((task) => {
@@ -278,7 +286,11 @@ export class ChannelReportingService {
(row) =>
row.channelId === task.channelId &&
row.signatureId === task.signatureId &&
((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId),
(!task.carrier || row.carrier === task.carrier) &&
((task.reportType ?? 'signature') === 'signature' ||
(row.drainageIds
? row.drainageIds.includes(task.drainageItemId ?? '')
: row.drainageInfoId === task.drainageItemId)),
);
const deliveryStats = summarizeChannelReportDelivery(taskRows);
return {
+138 -45
View File
@@ -2,7 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { summarizeReportStatuses as summarizeCommonReportStatuses } from '../common/report-status';
import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts';
import type { CreateChannelGroupItemDto, TestChannelDto } from './channels.contracts';
export function summarizeReportStatuses(statuses: string[]) {
return summarizeCommonReportStatuses(statuses);
@@ -141,7 +141,11 @@ export function buildChannelTestSubmitCommand({
account: channel.account,
passwordCipher: channel.passwordCipher,
cmppVersion: channel.cmppVersion,
desiredConnections: getPositiveRuntimeInteger(getConfigValue(channel.config, 'desiredConnections'), 1, 'desiredConnections'),
desiredConnections: getPositiveRuntimeInteger(
getConfigValue(channel.config, 'desiredConnections'),
1,
'desiredConnections',
),
windowSize: getPositiveRuntimeInteger(getConfigValue(channel.config, 'windowSize'), 16, 'windowSize'),
heartbeatIntervalSeconds: getPositiveRuntimeInteger(
getConfigValue(channel.config, 'heartbeatIntervalSeconds'),
@@ -254,23 +258,22 @@ export function getRuntimeConfigInteger(
return Number.isInteger(value) && value > 0 ? value : fallback;
}
export function channelConnectionSettingsChanged(
before: ChannelConnectionSettings,
after: ChannelConnectionSettings,
) {
return before.gatewayHost !== after.gatewayHost
|| before.gatewayPort !== after.gatewayPort
|| before.account !== after.account
|| before.passwordCipher !== after.passwordCipher
|| before.cmppVersion !== after.cmppVersion
|| getRuntimeConfigInteger(before.config, 'desiredConnections', 1)
!== getRuntimeConfigInteger(after.config, 'desiredConnections', 1)
|| getRuntimeConfigInteger(before.config, 'windowSize', 16)
!== getRuntimeConfigInteger(after.config, 'windowSize', 16)
|| getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
!== getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS)
|| getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
!== getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD);
export function channelConnectionSettingsChanged(before: ChannelConnectionSettings, after: ChannelConnectionSettings) {
return (
before.gatewayHost !== after.gatewayHost ||
before.gatewayPort !== after.gatewayPort ||
before.account !== after.account ||
before.passwordCipher !== after.passwordCipher ||
before.cmppVersion !== after.cmppVersion ||
getRuntimeConfigInteger(before.config, 'desiredConnections', 1) !==
getRuntimeConfigInteger(after.config, 'desiredConnections', 1) ||
getRuntimeConfigInteger(before.config, 'windowSize', 16) !==
getRuntimeConfigInteger(after.config, 'windowSize', 16) ||
getRuntimeConfigInteger(before.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) !==
getRuntimeConfigInteger(after.config, 'heartbeatIntervalSeconds', DEFAULT_HEARTBEAT_INTERVAL_SECONDS) ||
getRuntimeConfigInteger(before.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD) !==
getRuntimeConfigInteger(after.config, 'heartbeatMissThreshold', DEFAULT_HEARTBEAT_MISS_THRESHOLD)
);
}
export function channelGroupAuditSnapshot(group: {
@@ -320,19 +323,49 @@ export function normalizeChannelRuntimeConfig(
heartbeatIntervalSeconds?: number,
heartbeatMissThreshold?: number,
) {
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
? existingConfig as Record<string, unknown>
: {};
const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig)
? incomingConfig
: {};
const existing =
existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
? (existingConfig as Record<string, unknown>)
: {};
const incoming =
incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig) ? incomingConfig : {};
const base = { ...existing, ...incoming };
base.desiredConnections = boundedRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 8, 1, 'desiredConnections');
base.desiredConnections = boundedRuntimeInteger(
desiredConnections ?? base.desiredConnections,
1,
8,
1,
'desiredConnections',
);
base.windowSize = boundedRuntimeInteger(windowSize ?? base.windowSize, 1, 64, 16, 'windowSize');
base.connectionWarmupSeconds = boundedRuntimeInteger(base.connectionWarmupSeconds, 0, 300, 30, 'connectionWarmupSeconds');
base.connectionDrainTimeoutSeconds = boundedRuntimeInteger(base.connectionDrainTimeoutSeconds, 1, 600, 60, 'connectionDrainTimeoutSeconds');
base.submitResponseTimeoutSeconds = boundedRuntimeInteger(base.submitResponseTimeoutSeconds, 1, 300, 60, 'submitResponseTimeoutSeconds');
base.connectionFailureCooldownSeconds = boundedRuntimeInteger(base.connectionFailureCooldownSeconds, 1, 300, 30, 'connectionFailureCooldownSeconds');
base.connectionWarmupSeconds = boundedRuntimeInteger(
base.connectionWarmupSeconds,
0,
300,
30,
'connectionWarmupSeconds',
);
base.connectionDrainTimeoutSeconds = boundedRuntimeInteger(
base.connectionDrainTimeoutSeconds,
1,
600,
60,
'connectionDrainTimeoutSeconds',
);
base.submitResponseTimeoutSeconds = boundedRuntimeInteger(
base.submitResponseTimeoutSeconds,
1,
300,
60,
'submitResponseTimeoutSeconds',
);
base.connectionFailureCooldownSeconds = boundedRuntimeInteger(
base.connectionFailureCooldownSeconds,
1,
300,
30,
'connectionFailureCooldownSeconds',
);
base.heartbeatIntervalSeconds = getPositiveRuntimeInteger(
heartbeatIntervalSeconds ?? base.heartbeatIntervalSeconds,
DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
@@ -423,13 +456,19 @@ export function getPositiveIntegerEnv(name: string, fallback: number) {
}
export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const lines = content
.replace(/^\uFEFF/, '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
throw new BadRequestException('Receipt file is empty');
}
const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ',');
const firstCells = splitReceiptLine(lines[0], separator);
const hasHeader = firstCells.some((cell) => ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()));
const hasHeader = firstCells.some((cell) =>
['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()),
);
const header = hasHeader ? firstCells : [];
const rows = hasHeader ? lines.slice(1) : lines;
const statusIndex = findReceiptStatusIndex(header);
@@ -445,7 +484,7 @@ export function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
failedCount += 1;
}
return {
rowNumber: (hasHeader ? index + 2 : index + 1),
rowNumber: hasHeader ? index + 2 : index + 1,
phone: cells[0] ?? '',
status: normalizedStatus,
rawStatus,
@@ -504,10 +543,39 @@ export function findReceiptStatusIndex(header: string[]) {
export function normalizeReceiptStatus(value: string) {
const normalized = value.trim().toLowerCase();
if (['success', 'succeeded', 'approved', 'completed', 'ok', 'pass', 'passed', '通过', '成功', '已完成', '报备成功'].includes(normalized)) {
if (
[
'success',
'succeeded',
'approved',
'completed',
'ok',
'pass',
'passed',
'通过',
'成功',
'已完成',
'报备成功',
].includes(normalized)
) {
return 'success';
}
if (['failed', 'fail', 'rejected', 'reject', 'error', 'no', 'denied', '驳回', '失败', '不通过', '拒绝', '报备失败'].includes(normalized)) {
if (
[
'failed',
'fail',
'rejected',
'reject',
'error',
'no',
'denied',
'驳回',
'失败',
'不通过',
'拒绝',
'报备失败',
].includes(normalized)
) {
return 'failed';
}
return 'failed';
@@ -524,6 +592,8 @@ export function deriveReceiptStatus(rowCount: number, successCount: number, fail
}
export type ChannelReportDeliveryRow = {
drainageIds?: string[] | null;
carrier: string | null;
channelId: string;
signatureId: string;
drainageInfoId: string | null;
@@ -557,10 +627,13 @@ export function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[])
};
}
export function sumReportDelivery(rows: ChannelReportDeliveryRow[], key: keyof Pick<
ChannelReportDeliveryRow,
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
>) {
export function sumReportDelivery(
rows: ChannelReportDeliveryRow[],
key: keyof Pick<
ChannelReportDeliveryRow,
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
>,
) {
return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
}
@@ -580,7 +653,11 @@ export function currentShanghaiDayRange(now = new Date()) {
return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) };
}
export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) {
export function normalizeRetryTimeLimitMinutes(
minutes: number | undefined,
hours: number | undefined,
fallbackMinutes: number,
) {
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
@@ -588,7 +665,12 @@ export function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hour
return value;
}
export function normalizeSpreadsheetSize(value: number | undefined, fallback: number, minimum: number, maximum: number) {
export function normalizeSpreadsheetSize(
value: number | undefined,
fallback: number,
minimum: number,
maximum: number,
) {
if (value === undefined || !Number.isFinite(value)) return fallback;
return Math.min(maximum, Math.max(minimum, Math.round(value)));
}
@@ -602,7 +684,9 @@ export function normalizeBusinessCarrier(carrier?: string | null) {
}
export function normalizeChannelCarrier(carrier?: string | null) {
const value = String(carrier ?? '').trim().toLowerCase();
const value = String(carrier ?? '')
.trim()
.toLowerCase();
if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile';
if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom';
if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom';
@@ -631,12 +715,18 @@ export function legacyCarrierFromCapabilities(carriers: string[]) {
return 'multi';
}
export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string, carriers?: string[] | null) {
export function isChannelCarrierCompatible(
channelCarrier: string | null | undefined,
groupCarrier: string,
carriers?: string[] | null,
) {
return normalizeChannelCarriers(carriers, channelCarrier).includes(normalizeBusinessCarrier(groupCarrier));
}
export function normalizeRegion(region?: string | null) {
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
return String(region ?? '')
.replace(/省|市|自治区|壮族|回族|维吾尔/g, '')
.trim();
}
export function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) {
@@ -646,7 +736,10 @@ export function isRegionCompatible(channelRegion: string | null | undefined, ite
export function validateGroupItems(
groupCarrier: string,
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
channels: Map<string, { id: string; carrier?: string | null; carriers?: string[] | null; sendRegion?: string | null }>,
channels: Map<
string,
{ id: string; carrier?: string | null; carriers?: string[] | null; sendRegion?: string | null }
>,
) {
const channelIds = new Set<string>();
const provinces = new Set<string>();
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -1,8 +1,22 @@
export const DRAINAGE_TARGET_PATTERN = /^(?:(?:https?:\/\/)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:[/?#]\S*)?|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i;
import { parse } from 'tldts';
import { isIP } from 'node:net';
export const DRAINAGE_TARGET_PATTERN =
/^(?:(?:https?:\/\/)?(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})|(?:\d{1,3}\.){3}\d{1,3})(?::\d{1,5})?(?:[/?#]\S*)?|(?:\+?86[\s-]?)?1(?:[\s-]?\d){10}|(?:\+?86[\s-]?)?(?:\(?0\d{2,3}\)?[\s-]?)?\d{7,8}(?:[\s-]?(?:转|ext\.?)?[\s-]?\d{1,6})?)$/i;
export const DRAINAGE_TARGET_ERROR = '引流信息必须是 URL(可不带协议)、手机号码或固定电话号码';
export function normalizeDrainageTarget(value?: string) {
const target = value?.trim() ?? '';
return target && DRAINAGE_TARGET_PATTERN.test(target) ? target : undefined;
const normalized = target.normalize('NFKC');
if (!target || !DRAINAGE_TARGET_PATTERN.test(normalized)) return undefined;
if (/[a-z]/i.test(normalized) && !/ext\.?/i.test(normalized)) {
try {
const host = new URL(/^https?:\/\//i.test(normalized) ? normalized : `https://${normalized}`).hostname;
if (!isIP(host) && !parse(host, { allowPrivateDomains: true }).domain) return undefined;
} catch {
return undefined;
}
}
return target;
}
@@ -0,0 +1,23 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ChannelSensitiveWordsService } from './channel-sensitive-words.service';
@Controller('admin/dictionaries/channel-sensitive-words')
export class ChannelSensitiveWordsController {
constructor(private readonly service: ChannelSensitiveWordsService) {}
@Get() list(@CurrentSessionUserId() userId: string, @Query() query: Record<string, string | undefined>) {
return this.service.list(userId, query);
}
@Post() create(@CurrentSessionUserId() userId: string, @Body() body: unknown) {
return this.service.save(userId, body);
}
@Patch(':id') update(@CurrentSessionUserId() userId: string, @Param('id') id: string, @Body() body: unknown) {
return this.service.save(userId, body, id);
}
@Delete(':id') remove(
@CurrentSessionUserId() userId: string,
@Param('id') id: string,
@Body() body: { version?: unknown },
) {
return this.service.remove(userId, id, body?.version);
}
}
@@ -0,0 +1,40 @@
import { ChannelSensitiveWordsService, validateChannelWord } from './channel-sensitive-words.service';
import { PrismaService } from '../prisma/prisma.service';
const valid = { channelId: 'a', word: ' 贷 款 ', status: 'active', remark: '' };
describe('channel word administration', () => {
it('trims only outer whitespace and requires a version for editing', () => {
expect(validateChannelWord(valid).word).toBe('贷 款');
expect(() => validateChannelWord(valid, true)).toThrow('版本');
expect(validateChannelWord({ ...valid, version: 3 }, true).version).toBe(3);
});
it.each([
null,
[],
{ ...valid, word: ' ' },
{ ...valid, word: 'a'.repeat(201) },
{ ...valid, channelId: '' },
{ ...valid, status: 'deleted' },
{ ...valid, remark: 'a'.repeat(501) },
{ ...valid, operatorId: 'spoof' },
])('rejects invalid runtime data %#', (data) => expect(() => validateChannelWord(data)).toThrow());
it('checks active platform admin permission before data access', async () => {
const prisma = {
user: { findFirst: jest.fn().mockResolvedValue(null) },
channelSensitiveWord: { findMany: jest.fn() },
};
const service = new ChannelSensitiveWordsService(prisma as unknown as PrismaService);
await expect(service.list('client-user', {})).rejects.toMatchObject({ status: 403 });
expect(prisma.channelSensitiveWord.findMany).not.toHaveBeenCalled();
expect(prisma.user.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ deletedAt: null, roles: { some: { role: { code: 'platform_admin' } } } }),
}),
);
});
it('rejects invalid pagination before querying rules', async () => {
const prisma = { user: { findFirst: jest.fn().mockResolvedValue({ id: 'admin' }) } };
const service = new ChannelSensitiveWordsService(prisma as unknown as PrismaService);
for (const query of [{ page: '0' }, { pageSize: '101' }, { page: '1.5' }, { status: 'deleted' }])
await expect(service.list('admin', query)).rejects.toMatchObject({ status: 400 });
});
});
@@ -0,0 +1,157 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export function validateChannelWord(value: unknown, editing = false) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new BadRequestException('规则参数无效');
const data = value as Record<string, unknown>;
if (Object.keys(data).some((key) => !['channelId', 'word', 'status', 'remark', 'version'].includes(key)))
throw new BadRequestException('包含不支持的字段');
if (typeof data.channelId !== 'string' || !data.channelId.trim() || data.channelId.length > 160)
throw new BadRequestException('请选择通道');
if (typeof data.word !== 'string' || !data.word.trim() || data.word.trim().length > 200)
throw new BadRequestException('敏感词需为1200个字符');
if (typeof data.status !== 'string' || !['active', 'inactive'].includes(data.status))
throw new BadRequestException('状态无效');
if (data.remark !== undefined && (typeof data.remark !== 'string' || data.remark.length > 500))
throw new BadRequestException('备注最多500个字符');
if (editing && (!Number.isSafeInteger(data.version) || Number(data.version) < 1))
throw new BadRequestException('请提供规则版本');
return {
channelId: data.channelId.trim(),
word: data.word.trim(),
status: data.status as string,
remark: (data.remark as string | undefined) ?? '',
version: editing ? Number(data.version) : undefined,
};
}
@Injectable()
export class ChannelSensitiveWordsService {
constructor(private readonly prisma: PrismaService) {}
async authorize(userId?: string) {
if (
!userId ||
!(await this.prisma.user.findFirst({
where: { id: userId, status: 'active', deletedAt: null, roles: { some: { role: { code: 'platform_admin' } } } },
select: { id: true },
}))
)
throw new ForbiddenException('无敏感词管理权限');
}
async list(userId: string | undefined, query: Record<string, string | undefined>) {
await this.authorize(userId);
const page = Number(query.page ?? 1),
pageSize = Number(query.pageSize ?? 25);
if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(pageSize) || pageSize < 1 || pageSize > 100)
throw new BadRequestException('分页参数无效');
if (query.status && !['all', 'active', 'inactive'].includes(query.status))
throw new BadRequestException('状态无效');
if (query.keyword && (typeof query.keyword !== 'string' || query.keyword.length > 200))
throw new BadRequestException('搜索词过长');
if (query.channelId && typeof query.channelId !== 'string') throw new BadRequestException('通道参数无效');
const where: Prisma.ChannelSensitiveWordWhereInput = {
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
channelId: query.channelId || undefined,
word: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
};
const [items, total] = await this.prisma.$transaction([
this.prisma.channelSensitiveWord.findMany({
where,
include: { channel: { select: { id: true, name: true, status: true } } },
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.channelSensitiveWord.count({ where }),
]);
return { items, total, page, pageSize };
}
async save(userId: string | undefined, value: unknown, id?: string) {
await this.authorize(userId);
const data = validateChannelWord(value, Boolean(id));
try {
return await this.prisma.$transaction(async (tx) => {
if (
!(await tx.smsChannel.findFirst({
where: { id: data.channelId, status: { not: 'deleted' } },
select: { id: true },
}))
)
throw new BadRequestException('通道不存在或已删除');
const current = id
? await tx.channelSensitiveWord.findUnique({ where: { id } })
: await tx.channelSensitiveWord.findUnique({
where: { channelId_word: { channelId: data.channelId, word: data.word } },
});
if (id && (!current || current.status === 'deleted')) throw new NotFoundException('规则不存在或已删除');
if (!id && current && current.status !== 'deleted') throw new ConflictException('该通道已配置相同敏感词');
const fields = {
channelId: data.channelId,
word: data.word,
status: data.status,
remark: data.remark,
updatedBy: userId!,
};
let saved;
if (current) {
const result = await tx.channelSensitiveWord.updateMany({
where: { id: current.id, version: id ? data.version : current.version },
data: { ...fields, version: { increment: 1 } },
});
if (result.count !== 1) throw new ConflictException('规则已被修改,请刷新后重试');
saved = await tx.channelSensitiveWord.findUniqueOrThrow({ where: { id: current.id } });
} else saved = await tx.channelSensitiveWord.create({ data: { ...fields, createdBy: userId! } });
await tx.operationLog.create({
data: {
userId,
action:
current?.status === 'deleted'
? 'channel_sensitive_word.restore'
: id
? 'channel_sensitive_word.update'
: 'channel_sensitive_word.create',
resource: 'channel_sensitive_word',
resourceId: saved.id,
detail: JSON.parse(JSON.stringify({ before: current, after: saved })),
},
});
return saved;
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002')
throw new ConflictException('该通道已配置相同敏感词');
throw error;
}
}
async remove(userId: string | undefined, id: string, version: unknown) {
await this.authorize(userId);
if (!Number.isSafeInteger(version) || Number(version) < 1) throw new BadRequestException('请提供规则版本');
return this.prisma.$transaction(async (tx) => {
const before = await tx.channelSensitiveWord.findUnique({ where: { id } });
if (!before || before.status === 'deleted') throw new NotFoundException('规则不存在或已删除');
const result = await tx.channelSensitiveWord.updateMany({
where: { id, version: Number(version) },
data: { status: 'deleted', version: { increment: 1 }, updatedBy: userId! },
});
if (!result.count) throw new ConflictException('规则已被修改,请刷新后重试');
const after = await tx.channelSensitiveWord.findUniqueOrThrow({ where: { id } });
await tx.operationLog.create({
data: {
userId,
action: 'channel_sensitive_word.delete',
resource: 'channel_sensitive_word',
resourceId: id,
detail: JSON.parse(JSON.stringify({ before, after })),
},
});
return { deleted: true };
});
}
}
+4 -2
View File
@@ -1,11 +1,13 @@
import { Module } from '@nestjs/common';
import { ChannelSensitiveWordsService } from './channel-sensitive-words.service';
import { ChannelSensitiveWordsController } from './channel-sensitive-words.controller';
import { DictionariesController } from './dictionaries.controller';
import { DictionariesService } from './dictionaries.service';
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
@Module({
controllers: [DictionariesController],
providers: [DictionariesService, PhoneRoutingLookupService],
controllers: [DictionariesController, ChannelSensitiveWordsController],
providers: [DictionariesService, PhoneRoutingLookupService, ChannelSensitiveWordsService],
exports: [DictionariesService, PhoneRoutingLookupService],
})
export class DictionariesModule {}
+2 -1
View File
@@ -1,3 +1,4 @@
import { DrainageSubmitGuardController } from './send-chain/drainage-submit-guard.controller';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BillingService } from './billing/billing.service';
@@ -18,7 +19,7 @@ import { SendChainService } from './send-chain/send-chain.service';
MetricsModule,
ProtocolLogsModule,
],
controllers: [GatewayCallbackController],
controllers: [DrainageSubmitGuardController, GatewayCallbackController],
providers: [
BillingService,
RiskReviewService,
@@ -0,0 +1,45 @@
import { alertHistoryRange, mergeAlertHistory } from './alert-history';
describe('historical alert observation cycles', () => {
it('uses seven Shanghai calendar days and rejects invalid or excessive dates', () => {
expect(alertHistoryRange(undefined, undefined, new Date('2026-09-09T16:30:00Z'))).toMatchObject({
startDate: '2026-09-04',
endDate: '2026-09-10',
});
for (const [from, to] of [
['2026-02-30', '2026-03-01'],
['2026-09-09', '2026-09-08'],
['2026-07-01', '2026-09-09'],
]) {
expect(() => alertHistoryRange(from, to)).toThrow();
}
});
it('retains distinct cycles, merges daily boundaries and excludes stale/nonpositive samples', () => {
const result = new Map();
const metric = { __name__: 'ALERTS_FOR_STATE', alertname: 'CPUHigh', instance: 'host', severity: 'warning' };
mergeAlertHistory(
result,
[
{
metric,
values: [
[110, '100'],
[120, '100'],
[130, '0'],
[140, 'NaN'],
[150, '145'],
[200, '145'],
],
},
],
110,
200,
);
mergeAlertHistory(result, [{ metric, values: [[160, '145']] }], 110, 200);
expect(result.size).toBe(2);
expect([...result.values()].map((item) => item.lastObservedAt)).toEqual([
new Date(120000).toISOString(),
new Date(160000).toISOString(),
]);
});
});
@@ -0,0 +1,68 @@
import { BadRequestException } from '@nestjs/common';
import { createHash } from 'node:crypto';
export function alertHistoryRange(from?: string, to?: string, now = new Date()) {
const dateKey = (date: Date) => new Date(date.getTime() + 8 * 3600_000).toISOString().slice(0, 10);
const endDate = to || dateKey(now);
const startDate = from || dateKey(new Date(now.getTime() - 6 * 86400_000));
const parse = (value: string) => {
const result = new Date(`${value}T00:00:00+08:00`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(result.getTime()) || dateKey(result) !== value) {
throw new BadRequestException('告警日期无效');
}
return result.getTime() / 1000;
};
const start = parse(startDate);
const end = parse(endDate) + 86400;
if (end <= start || end - start > 31 * 86400) throw new BadRequestException('告警日期范围须为1至31天');
return { startDate, endDate, start, end: Math.min(end, now.getTime() / 1000) };
}
export type AlertHistoryItem = {
id: string;
name: string;
severity: string;
service: string;
instance: string;
startedAt: string;
firstObservedAt: string;
lastObservedAt: string;
};
// ALERTS_FOR_STATE stores activeAt as the sample value, separating repeated trigger cycles.
// Observation boundaries are not claimed as exact recovery times.
export function mergeAlertHistory(
target: Map<string, AlertHistoryItem>,
series: Array<{ metric: Record<string, string>; values?: [number, string][] }>,
start: number,
end: number,
) {
for (const { metric, values } of series) {
const labels = Object.entries(metric)
.filter(([key]) => key !== '__name__')
.sort(([a], [b]) => a.localeCompare(b));
const fingerprint = createHash('sha256').update(JSON.stringify(labels)).digest('hex');
for (const [time, rawActiveAt] of values ?? []) {
const activeAt = Number(rawActiveAt);
if (time < start || time >= end || !Number.isFinite(activeAt) || activeAt <= 0 || activeAt > time) continue;
const id = `${fingerprint}:${activeAt}`;
const observed = new Date(time * 1000).toISOString();
const item = target.get(id);
if (item) {
if (observed < item.firstObservedAt) item.firstObservedAt = observed;
if (observed > item.lastObservedAt) item.lastObservedAt = observed;
} else {
target.set(id, {
id,
name: metric.alertname || '未命名告警',
severity: metric.severity || 'info',
service: metric.service || '',
instance: metric.instance || '',
startedAt: new Date(activeAt * 1000).toISOString(),
firstObservedAt: observed,
lastObservedAt: observed,
});
}
}
}
}
@@ -8,7 +8,10 @@ import { InfrastructureMonitoringService } from './infrastructure-monitoring.ser
@ApiTags('infrastructure-monitoring')
@Controller('admin/infrastructure-monitoring')
export class InfrastructureMonitoringController {
constructor(private readonly monitoring: InfrastructureMonitoringService, private readonly settings: InfrastructureAlertSettingsService) {}
constructor(
private readonly monitoring: InfrastructureMonitoringService,
private readonly settings: InfrastructureAlertSettingsService,
) {}
@Get('overview')
overview(@Query('range') range?: string, @CurrentSessionUserId() userId?: string) {
@@ -16,19 +19,35 @@ export class InfrastructureMonitoringController {
}
@Get('notification-summary')
notificationSummary(@CurrentSessionUserId() userId?: string) { return this.monitoring.notificationSummary(userId); }
notificationSummary(@CurrentSessionUserId() userId?: string) {
return this.monitoring.notificationSummary(userId);
}
@Get('alert-history')
alertHistory(@Query('from') from?: string, @Query('to') to?: string, @Query('page') page?: string) {
return this.monitoring.alertHistory(from, to, page);
}
@Post('alerts/:fingerprint/read')
markAlertRead(@Param('fingerprint') fingerprint: string, @Body('activeAt') activeAt: unknown, @CurrentSessionUserId() userId: string) {
markAlertRead(
@Param('fingerprint') fingerprint: string,
@Body('activeAt') activeAt: unknown,
@CurrentSessionUserId() userId: string,
) {
return this.monitoring.markAlertRead(fingerprint, activeAt, userId);
}
@Get('alert-thresholds')
alertThresholds() { return this.settings.get(); }
alertThresholds() {
return this.settings.get();
}
@Put('alert-thresholds')
@RequireRecentAuthentication()
updateAlertThresholds(@Body() body: { configVersion?: number; thresholds?: unknown }, @CurrentSessionUserId() operatorId?: string) {
updateAlertThresholds(
@Body() body: { configVersion?: number; thresholds?: unknown },
@CurrentSessionUserId() operatorId?: string,
) {
return this.settings.update(body, operatorId);
}
}
@@ -1,9 +1,22 @@
import { BadRequestException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { compareMountpoints, FILESYSTEM_LABELS, FILESYSTEM_SELECTOR, FILESYSTEM_USAGE_PERCENT, filesystemIdentity } from './filesystem-metrics';
import { alertHistoryRange, mergeAlertHistory, type AlertHistoryItem } from './alert-history';
import {
compareMountpoints,
FILESYSTEM_LABELS,
FILESYSTEM_SELECTOR,
FILESYSTEM_USAGE_PERCENT,
filesystemIdentity,
} from './filesystem-metrics';
import type {
InfrastructureAlert,
InfrastructureMetricPoint,
@@ -57,7 +70,8 @@ const QUERIES = {
uptimeSeconds: 'time() - node_boot_time_seconds',
lastSampleAt: 'max(timestamp(node_uname_info))',
// PromQL字符串本身需要两个反斜杠才能把正则的“\.”传给RE2;TypeScript字面量因此需要写四个。
services: 'max by (name) (node_systemd_unit_state{name=~"cmpp-api\\\\.service|cmpp-gateway\\\\.service|postgresql\\\\.service|redis(-server)?\\\\.service|cmpp-minio\\\\.service|nginx\\\\.service",state="active"})',
services:
'max by (name) (node_systemd_unit_state{name=~"cmpp-api\\\\.service|cmpp-gateway\\\\.service|postgresql\\\\.service|redis(-server)?\\\\.service|cmpp-minio\\\\.service|nginx\\\\.service",state="active"})',
} as const;
const SERVICE_DEFINITIONS = [
@@ -70,38 +84,62 @@ const SERVICE_DEFINITIONS = [
] as const;
const SERVICE_METRIC_DEFINITIONS = [
{ key: 'api', name: 'API服务', metrics: [
['requestsPerSecond', '请求速率', 'cmpp:service_api:requests_per_second', 'per_second'],
['errorPercent', '5xx错误率', 'cmpp:service_api:error_percent', 'percent'],
['latencyP95', 'P95响应', 'cmpp:service_api:latency_p95_seconds', 'seconds'],
['eventLoopP99', '事件循环P99', 'cmpp:service_api:event_loop_p99_seconds', 'seconds'],
] },
{ key: 'gateway', name: 'Gateway服务', metrics: [
['submitsPerSecond', '提交速率', 'cmpp:service_gateway:submits_per_second', 'per_second'],
['failurePercent', '提交失败率', 'cmpp:service_gateway:failure_percent', 'percent'],
['queuePending', 'Stream pending', 'cmpp:service_gateway:queue_pending', 'count'],
['queueOldestSeconds', '最旧pending', 'cmpp:service_gateway:queue_oldest_seconds', 'seconds'],
] },
{ key: 'postgresql', name: 'PostgreSQL', metrics: [
['connectionPercent', '连接使用率', 'cmpp:service_postgresql:connection_percent', 'percent'],
['deadlocks15m', '15分钟死锁', 'cmpp:service_postgresql:deadlocks_15m', 'count'],
] },
{ key: 'redis', name: 'Redis', metrics: [
['memoryPercent', '内存使用率', 'cmpp:service_redis:memory_percent', 'percent'],
['memoryUsedBytes', '已用内存', 'cmpp:service_redis:memory_used_bytes', 'bytes'],
['connectedClients', '客户端连接', 'cmpp:service_redis:connected_clients', 'count'],
['evictions5m', '5分钟淘汰', 'cmpp:service_redis:evictions_5m', 'count'],
] },
{ key: 'minio', name: 'MinIO', metrics: [
['capacityPercent', '存储容量使用率', 'cmpp:service_minio:capacity_percent', 'percent'],
['usageBytes', '对象数据量', 'cmpp:service_minio:usage_bytes', 'bytes'],
['objects', '对象数', 'cmpp:service_minio:objects', 'count'],
['drivesOffline', '离线存储盘', 'cmpp:service_minio:drives_offline', 'count'],
] },
{ key: 'nginx', name: 'Nginx', metrics: [
['connectionsActive', '活跃连接', 'cmpp:service_nginx:connections_active', 'count'],
['requestsPerSecond', '请求速率', 'cmpp:service_nginx:requests_per_second', 'per_second'],
] },
{
key: 'api',
name: 'API服务',
metrics: [
['requestsPerSecond', '请求速率', 'cmpp:service_api:requests_per_second', 'per_second'],
['errorPercent', '5xx错误率', 'cmpp:service_api:error_percent', 'percent'],
['latencyP95', 'P95响应', 'cmpp:service_api:latency_p95_seconds', 'seconds'],
['eventLoopP99', '事件循环P99', 'cmpp:service_api:event_loop_p99_seconds', 'seconds'],
],
},
{
key: 'gateway',
name: 'Gateway服务',
metrics: [
['submitsPerSecond', '提交速率', 'cmpp:service_gateway:submits_per_second', 'per_second'],
['failurePercent', '提交失败率', 'cmpp:service_gateway:failure_percent', 'percent'],
['queuePending', 'Stream pending', 'cmpp:service_gateway:queue_pending', 'count'],
['queueOldestSeconds', '最旧pending', 'cmpp:service_gateway:queue_oldest_seconds', 'seconds'],
],
},
{
key: 'postgresql',
name: 'PostgreSQL',
metrics: [
['connectionPercent', '连接使用率', 'cmpp:service_postgresql:connection_percent', 'percent'],
['deadlocks15m', '15分钟死锁', 'cmpp:service_postgresql:deadlocks_15m', 'count'],
],
},
{
key: 'redis',
name: 'Redis',
metrics: [
['memoryPercent', '内存使用率', 'cmpp:service_redis:memory_percent', 'percent'],
['memoryUsedBytes', '已用内存', 'cmpp:service_redis:memory_used_bytes', 'bytes'],
['connectedClients', '客户端连接', 'cmpp:service_redis:connected_clients', 'count'],
['evictions5m', '5分钟淘汰', 'cmpp:service_redis:evictions_5m', 'count'],
],
},
{
key: 'minio',
name: 'MinIO',
metrics: [
['capacityPercent', '存储容量使用率', 'cmpp:service_minio:capacity_percent', 'percent'],
['usageBytes', '对象数据量', 'cmpp:service_minio:usage_bytes', 'bytes'],
['objects', '对象数', 'cmpp:service_minio:objects', 'count'],
['drivesOffline', '离线存储盘', 'cmpp:service_minio:drives_offline', 'count'],
],
},
{
key: 'nginx',
name: 'Nginx',
metrics: [
['connectionsActive', '活跃连接', 'cmpp:service_nginx:connections_active', 'count'],
['requestsPerSecond', '请求速率', 'cmpp:service_nginx:requests_per_second', 'per_second'],
],
},
] as const;
const SERVICE_METRICS_QUERY = '{__name__=~"cmpp:service_.*"}';
@@ -165,7 +203,10 @@ export class InfrastructureMonitoringService {
private readonly prometheusUrl: string;
private readonly queryTimeoutMs: number;
constructor(config: ConfigService, private readonly prisma: PrismaService) {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
) {
this.prometheusUrl = normalizePrometheusUrl(config.get('PROMETHEUS_URL'));
this.queryTimeoutMs = Math.min(15_000, Math.max(1_000, Number(config.get('PROMETHEUS_QUERY_TIMEOUT_MS') ?? 5_000)));
}
@@ -202,7 +243,7 @@ export class InfrastructureMonitoringService {
activeAlerts: alerts.length,
},
metrics: instant.metrics,
trends: { ...trends.metrics, diskUsagePercent: rootDisk ? trends.disks.get(rootDisk.id) ?? [] : [] },
trends: { ...trends.metrics, diskUsagePercent: rootDisk ? (trends.disks.get(rootDisk.id) ?? []) : [] },
disks: instant.disks.map((disk) => ({ ...disk, trend: trends.disks.get(disk.id) ?? [] })),
services,
serviceMetrics,
@@ -210,18 +251,28 @@ export class InfrastructureMonitoringService {
};
} catch (error) {
// 页面必须整体清空陈旧指标,但服务端仍需留下不含PromQL/地址/凭据的根因摘要便于运维诊断。
this.logger.warn(`Prometheus monitoring overview unavailable: ${error instanceof Error ? error.message : 'unknown error'}`);
this.logger.warn(
`Prometheus monitoring overview unavailable: ${error instanceof Error ? error.message : 'unknown error'}`,
);
return this.unavailable(range, collectedAt);
}
}
async notificationSummary(userId?: string) {
try {
const alerts = await this.attachReadState(this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts')), userId);
const alerts = await this.attachReadState(
this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts')),
userId,
);
const unreadAlerts = alerts.filter((item) => !item.acknowledged);
return { count: unreadAlerts.length, criticalCount: unreadAlerts.filter((item) => item.severity === 'critical').length };
return {
count: unreadAlerts.length,
criticalCount: unreadAlerts.filter((item) => item.severity === 'critical').length,
};
} catch (error) {
this.logger.warn(`Prometheus notification summary unavailable: ${error instanceof Error ? error.message : 'unknown error'}`);
this.logger.warn(
`Prometheus notification summary unavailable: ${error instanceof Error ? error.message : 'unknown error'}`,
);
throw new ServiceUnavailableException('Prometheus活动告警当前不可用');
}
}
@@ -231,12 +282,21 @@ export class InfrastructureMonitoringService {
const activeAt = new Date(String(rawActiveAt ?? ''));
if (!Number.isFinite(activeAt.getTime())) throw new BadRequestException('告警开始时间无效');
const activeAlerts = this.parseAlerts(await this.getJson<PrometheusAlertResponse>('/api/v1/alerts'));
const current = activeAlerts.find((item) => item.fingerprint === fingerprint && Date.parse(item.startedAt) === activeAt.getTime());
const current = activeAlerts.find(
(item) => item.fingerprint === fingerprint && Date.parse(item.startedAt) === activeAt.getTime(),
);
if (!current) throw new NotFoundException('该次活动告警已结束或已重新触发,请刷新后重试');
const readAt = new Date();
const log = () => this.prisma.operationLog.create({
data: { userId, action: 'monitoring.alert_marked_read', resource: 'infrastructure_alert', resourceId: fingerprint, detail: { activeAt: activeAt.toISOString(), alertName: current.name, severity: current.severity } },
});
const log = () =>
this.prisma.operationLog.create({
data: {
userId,
action: 'monitoring.alert_marked_read',
resource: 'infrastructure_alert',
resourceId: fingerprint,
detail: { activeAt: activeAt.toISOString(), alertName: current.name, severity: current.severity },
},
});
let read;
try {
[read] = await this.prisma.$transaction([
@@ -245,15 +305,57 @@ export class InfrastructureMonitoringService {
]);
} catch (error) {
if (!(error instanceof Prisma.PrismaClientKnownRequestError) || error.code !== 'P2002') throw error;
const existing = await this.prisma.infrastructureAlertRead.findUniqueOrThrow({ where: { fingerprint_userId: { fingerprint, userId } } });
const existing = await this.prisma.infrastructureAlertRead.findUniqueOrThrow({
where: { fingerprint_userId: { fingerprint, userId } },
});
// 同一次触发重复点击不更新readAt也不重复写日志;activeAt变化才代表同指纹的新触发周期。
if (existing.activeAt.getTime() === activeAt.getTime()) read = existing;
else [read] = await this.prisma.$transaction([
this.prisma.infrastructureAlertRead.update({ where: { fingerprint_userId: { fingerprint, userId } }, data: { activeAt, readAt } }),
log(),
]);
else
[read] = await this.prisma.$transaction([
this.prisma.infrastructureAlertRead.update({
where: { fingerprint_userId: { fingerprint, userId } },
data: { activeAt, readAt },
}),
log(),
]);
}
return { fingerprint, activeAt: read.activeAt.toISOString(), acknowledged: true, acknowledgedAt: read.readAt.toISOString() };
return {
fingerprint,
activeAt: read.activeAt.toISOString(),
acknowledged: true,
acknowledgedAt: read.readAt.toISOString(),
};
}
async alertHistory(from?: string, to?: string, rawPage?: string) {
const range = alertHistoryRange(from, to);
const page = rawPage === undefined ? 1 : Number(rawPage);
if (!Number.isSafeInteger(page) || page < 1) throw new BadRequestException('告警页码无效');
const history = new Map<string, AlertHistoryItem>();
try {
// Daily raw range vectors retain short events that a coarse query_range step would miss.
for (let start = range.start; start < range.end; start += 86400) {
const end = Math.min(start + 86400, range.end);
const response = await this.getJson<PrometheusQueryResponse>('/api/v1/query', {
query: `ALERTS_FOR_STATE[${Math.ceil(end - start)}s]`,
time: String(end),
});
mergeAlertHistory(history, response.data?.result ?? [], range.start, range.end);
}
} catch {
throw new ServiceUnavailableException('历史告警查询失败,请稍后重试');
}
const items = [...history.values()].sort(
(a, b) => b.startedAt.localeCompare(a.startedAt) || a.id.localeCompare(b.id),
);
return {
items: items.slice((page - 1) * 25, page * 25),
total: items.length,
page,
pageSize: 25,
startDate: range.startDate,
endDate: range.endDate,
};
}
private parseRange(value?: string): InfrastructureMonitoringRange {
@@ -264,12 +366,21 @@ export class InfrastructureMonitoringService {
private async loadInstantMetrics() {
const keys = Object.keys(emptyMetrics()) as Array<keyof InfrastructureMonitoringOverview['metrics']>;
const responses = await Promise.all([...keys.map((key) => this.query(QUERIES[key])), this.query(QUERIES.lastSampleAt)]);
const responses = await Promise.all([
...keys.map((key) => this.query(QUERIES[key])),
this.query(QUERIES.lastSampleAt),
]);
const metrics = emptyMetrics();
keys.forEach((key, index) => { if (!key.startsWith('disk')) metrics[key] = vectorValue(responses[index]); });
keys.forEach((key, index) => {
if (!key.startsWith('disk')) metrics[key] = vectorValue(responses[index]);
});
const diskSamples = (key: keyof typeof metrics) => responses[keys.indexOf(key)].data?.result ?? [];
const usage = new Map(diskSamples('diskUsagePercent').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])]));
const available = new Map(diskSamples('diskAvailableBytes').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])]));
const usage = new Map(
diskSamples('diskUsagePercent').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])]),
);
const available = new Map(
diskSamples('diskAvailableBytes').map((item) => [filesystemIdentity(item.metric), finiteNumber(item.value?.[1])]),
);
const groups = new Map<string, PrometheusSeries[]>();
for (const item of diskSamples('diskTotalBytes')) {
if (!item.metric.device || !item.metric.mountpoint || (finiteNumber(item.value?.[1]) ?? 0) <= 0) continue;
@@ -278,18 +389,32 @@ export class InfrastructureMonitoringService {
group.push(item);
groups.set(id, group);
}
const disks = [...groups].map(([id, items]) => {
const mountpoints = [...new Set(items.map((item) => item.metric.mountpoint))].sort(compareMountpoints);
const metric = items[0].metric;
return {
id, instance: metric.instance ?? '', device: metric.device, filesystem: metric.fstype ?? '',
mountpoint: mountpoints[0], mountpoints,
// Never sum aliases. Max/min also tolerate slight sampling differences.
totalBytes: Math.max(...items.map((item) => finiteNumber(item.value?.[1])!)),
availableBytes: available.get(id) ?? null, usagePercent: usage.get(id) ?? null,
};
})
.sort((left, right) => left.instance.localeCompare(right.instance) || (left.mountpoint === '/' ? -1 : right.mountpoint === '/' ? 1 : left.mountpoint.localeCompare(right.mountpoint)));
const disks = [...groups]
.map(([id, items]) => {
const mountpoints = [...new Set(items.map((item) => item.metric.mountpoint))].sort(compareMountpoints);
const metric = items[0].metric;
return {
id,
instance: metric.instance ?? '',
device: metric.device,
filesystem: metric.fstype ?? '',
mountpoint: mountpoints[0],
mountpoints,
// Never sum aliases. Max/min also tolerate slight sampling differences.
totalBytes: Math.max(...items.map((item) => finiteNumber(item.value?.[1])!)),
availableBytes: available.get(id) ?? null,
usagePercent: usage.get(id) ?? null,
};
})
.sort(
(left, right) =>
left.instance.localeCompare(right.instance) ||
(left.mountpoint === '/'
? -1
: right.mountpoint === '/'
? 1
: left.mountpoint.localeCompare(right.mountpoint)),
);
const rootDisk = disks.find((disk) => disk.mountpoints.includes('/'));
metrics.diskUsagePercent = rootDisk?.usagePercent ?? null;
metrics.diskTotalBytes = rootDisk?.totalBytes ?? null;
@@ -304,21 +429,32 @@ export class InfrastructureMonitoringService {
const keys = Object.keys(emptyTrends()) as Array<keyof InfrastructureMonitoringOverview['trends']>;
const responses = await Promise.all(keys.map((key) => this.queryRange(QUERIES[key], start, end, config.step)));
return {
metrics: Object.fromEntries(keys.map((key, index) => [key, key === 'diskUsagePercent' ? [] : matrixValues(responses[index])])) as InfrastructureMonitoringOverview['trends'],
disks: new Map((responses[keys.indexOf('diskUsagePercent')].data?.result ?? []).map((item) => [
filesystemIdentity(item.metric), matrixValues({ status: 'success', data: { result: [item] } }),
])),
metrics: Object.fromEntries(
keys.map((key, index) => [key, key === 'diskUsagePercent' ? [] : matrixValues(responses[index])]),
) as InfrastructureMonitoringOverview['trends'],
disks: new Map(
(responses[keys.indexOf('diskUsagePercent')].data?.result ?? []).map((item) => [
filesystemIdentity(item.metric),
matrixValues({ status: 'success', data: { result: [item] } }),
]),
),
};
}
private parseServices(response: PrometheusQueryResponse): InfrastructureServiceStatus[] {
const values = new Map<string, number>();
for (const item of response.data?.result ?? []) {
if (item.metric.name) values.set(item.metric.name, vectorValue({ status: 'success', data: { result: [item] } }) ?? 0);
if (item.metric.name)
values.set(item.metric.name, vectorValue({ status: 'success', data: { result: [item] } }) ?? 0);
}
return SERVICE_DEFINITIONS.map((definition) => {
const present = definition.units.filter((unit) => values.has(unit));
const status = present.length === 0 ? 'unknown' : present.some((unit) => (values.get(unit) ?? 0) >= 1) ? 'healthy' : 'unhealthy';
const status =
present.length === 0
? 'unknown'
: present.some((unit) => (values.get(unit) ?? 0) >= 1)
? 'healthy'
: 'unhealthy';
return { key: definition.key, name: definition.name, unit: present[0] ?? definition.units[0], status };
});
}
@@ -329,7 +465,8 @@ export class InfrastructureMonitoringService {
.map<InfrastructureAlert>((item) => {
const labels = item.labels ?? {};
const annotations = item.annotations ?? {};
const severity: InfrastructureAlert['severity'] = labels.severity === 'critical' ? 'critical' : labels.severity === 'warning' ? 'warning' : 'info';
const severity: InfrastructureAlert['severity'] =
labels.severity === 'critical' ? 'critical' : labels.severity === 'warning' ? 'warning' : 'info';
const identity = JSON.stringify(Object.entries(labels).sort(([left], [right]) => left.localeCompare(right)));
return {
fingerprint: createHash('sha256').update(identity).digest('hex').slice(0, 24),
@@ -348,7 +485,9 @@ export class InfrastructureMonitoringService {
})
.sort((left, right) => {
const priority: Record<InfrastructureAlert['severity'], number> = { critical: 0, warning: 1, info: 2 };
return priority[left.severity] - priority[right.severity] || Date.parse(left.startedAt) - Date.parse(right.startedAt);
return (
priority[left.severity] - priority[right.severity] || Date.parse(left.startedAt) - Date.parse(right.startedAt)
);
});
}
@@ -377,24 +516,46 @@ export class InfrastructureMonitoringService {
key: group.key,
name: group.name,
available: group.metrics.some((metric) => values.has(metric[2])),
metrics: group.metrics.map(([key, label, metricName, unit]) => ({ key, label, value: values.get(metricName) ?? null, unit })),
metrics: group.metrics.map(([key, label, metricName, unit]) => ({
key,
label,
value: values.get(metricName) ?? null,
unit,
})),
}));
}
private unavailable(range: InfrastructureMonitoringRange, collectedAt: string): InfrastructureMonitoringOverview {
const services = SERVICE_DEFINITIONS.map((item) => ({ key: item.key, name: item.name, unit: item.units[0], status: 'unknown' as const }));
const services = SERVICE_DEFINITIONS.map((item) => ({
key: item.key,
name: item.name,
unit: item.units[0],
status: 'unknown' as const,
}));
return {
available: false,
range,
collectedAt,
lastSampleAt: null,
error: 'Prometheus监控数据当前不可用,请检查采集与服务状态',
summary: { overallStatus: 'unknown', serviceTotal: services.length, serviceHealthy: 0, warningAlerts: 0, criticalAlerts: 0, activeAlerts: 0 },
summary: {
overallStatus: 'unknown',
serviceTotal: services.length,
serviceHealthy: 0,
warningAlerts: 0,
criticalAlerts: 0,
activeAlerts: 0,
},
metrics: emptyMetrics(),
disks: [],
trends: emptyTrends(),
services,
serviceMetrics: SERVICE_METRIC_DEFINITIONS.map((group) => ({ key: group.key, name: group.name, available: false, metrics: [] })),
serviceMetrics: SERVICE_METRIC_DEFINITIONS.map((group) => ({
key: group.key,
name: group.name,
available: false,
metrics: [],
})),
alerts: [],
};
}
@@ -404,15 +565,26 @@ export class InfrastructureMonitoringService {
}
private queryRange(query: string, start: number, end: number, step: number) {
return this.getJson<PrometheusQueryResponse>('/api/v1/query_range', { query, start: String(start), end: String(end), step: String(step) });
return this.getJson<PrometheusQueryResponse>('/api/v1/query_range', {
query,
start: String(start),
end: String(end),
step: String(step),
});
}
private async getJson<T extends { status: 'success' | 'error'; error?: string }>(path: string, params: Record<string, string> = {}): Promise<T> {
private async getJson<T extends { status: 'success' | 'error'; error?: string }>(
path: string,
params: Record<string, string> = {},
): Promise<T> {
const url = new URL(`${this.prometheusUrl}${path}`);
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
const response = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(this.queryTimeoutMs) });
const response = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(this.queryTimeoutMs),
});
if (!response.ok) throw new Error(`Prometheus HTTP ${response.status}`);
const result = await response.json() as T;
const result = (await response.json()) as T;
if (result.status !== 'success') throw new Error('Prometheus query failed');
return result;
}
+153 -69
View File
@@ -1,17 +1,22 @@
import { Prisma } from '@prisma/client';
import { BadRequestException } from '@nestjs/common';
import { moneyToNumber } from '../common/money';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from './operations.contracts';
import type {
MessageQuery,
DownstreamDeliveryDashboardQuery,
DownstreamRecoveryStatusQuery,
} from './operations.contracts';
// Pure query builders and response mappers shared by the R2 query domains.
export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
const statusWhere = query.status === 'submit_failed'
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
: query.status === 'failed'
? { status: 'failed', submitStatus: 'accepted' }
: query.status
? { status: query.status }
: {};
const statusWhere =
query.status === 'submit_failed'
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
: query.status === 'failed'
? { status: 'failed', submitStatus: 'accepted' }
: query.status
? { status: query.status }
: {};
return {
tenantId: query.tenantId,
applicationId: query.applicationId,
@@ -21,25 +26,39 @@ export function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereI
phoneNumber: query.phoneNumber,
...carrierWhere(query.carrier),
...statusWhere,
...(query.hasDrainage === 'true' ? { hasDrainageContent: true }
: query.hasDrainage === 'false' ? { hasDrainageContent: false }
: query.hasDrainage === 'unknown' ? { hasDrainageContent: null }
...(query.hasDrainage === 'true'
? { hasDrainageContent: true }
: query.hasDrainage === 'false'
? { hasDrainageContent: false }
: query.hasDrainage === 'unknown'
? { hasDrainageContent: null }
: {}),
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
...(query.queuedAtFrom || query.queuedAtTo ? {
queuedAt: {
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}),
},
} : {}),
...(query.queuedAtFrom || query.queuedAtTo
? {
queuedAt: {
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}),
},
}
: {}),
};
}
export const recognizedCarrierValues = [
'mobile', 'cmcc', '移动', '中国移动',
'unicom', 'cucc', '联通', '中国联通',
'telecom', 'ctcc', '电信', '中国电信',
'mobile',
'cmcc',
'移动',
'中国移动',
'unicom',
'cucc',
'联通',
'中国联通',
'telecom',
'ctcc',
'电信',
'中国电信',
];
export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInput {
if (!carrier) return {};
@@ -48,10 +67,7 @@ export function carrierWhere(carrier?: string): Prisma.SmsMessageRecordWhereInpu
return {
AND: [
{
OR: [
{ carrier: null },
{ carrier: { notIn: recognizedCarrierValues } },
],
OR: [{ carrier: null }, { carrier: { notIn: recognizedCarrierValues } }],
},
],
};
@@ -107,10 +123,7 @@ export function returnedTransactionWhere(since: Date, tenantId?: string): Prisma
return {
tenantId,
createdAt: { gte: since },
OR: [
{ transactionType: 'refunded' },
{ transactionType: 'released', relatedType: 'sms_message_record' },
],
OR: [{ transactionType: 'refunded' }, { transactionType: 'released', relatedType: 'sms_message_record' }],
};
}
export function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined {
@@ -161,13 +174,12 @@ export function downstreamAlertWhere(
export function stalledPendingWhere(cutoff: Date): Prisma.CmppDownstreamDeliveryWhereInput {
return {
status: 'pending',
OR: [
{ lastRetriedAt: null, createdAt: { lte: cutoff } },
{ lastRetriedAt: { lte: cutoff } },
],
OR: [{ lastRetriedAt: null, createdAt: { lte: cutoff } }, { lastRetriedAt: { lte: cutoff } }],
};
}
export function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput {
export function downstreamDeliveryScopedWhere(
query: DownstreamDeliveryDashboardQuery,
): Prisma.CmppDownstreamDeliveryWhereInput {
const createdAtFrom = parseDateBoundary(query.createdAtFrom, false);
const createdAtTo = parseDateBoundary(query.createdAtTo, true);
return {
@@ -191,14 +203,16 @@ export function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQue
state: query.state && query.state !== 'all' ? query.state : undefined,
failureCategory: query.failureCategory && query.failureCategory !== 'all' ? query.failureCategory : undefined,
updatedAt: updatedAtFrom || updatedAtTo ? { gte: updatedAtFrom, lte: updatedAtTo } : undefined,
OR: query.keyword ? [
{ account: { contains: query.keyword } },
{ gatewayInstanceId: { contains: query.keyword } },
{ lastError: { contains: query.keyword } },
{ lastSkipReason: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
] : undefined,
OR: query.keyword
? [
{ account: { contains: query.keyword } },
{ gatewayInstanceId: { contains: query.keyword } },
{ lastError: { contains: query.keyword } },
{ lastSkipReason: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
]
: undefined,
};
}
export function escapeCsvCell(value: string) {
@@ -254,6 +268,21 @@ export function clientMessageView(message: Record<string, any>) {
carrier: message.carrier ?? null,
province: message.province ?? null,
content: message.content,
drainageGate: message.drainageGate
? {
version: message.drainageGate.version,
evaluatedAt: message.drainageGate.evaluatedAt,
reason: message.drainageGate.reason,
reasonCode: message.drainageGate.reasonCode,
targets: (message.drainageGate.targets ?? []).map(
(target: { text: string; category: string; value: string }) => ({
text: target.text,
category: target.category,
value: target.value,
}),
),
}
: null,
billingUnits: message.billingUnits,
amountCents: moneyToNumber(message.amountCents),
status: message.status,
@@ -338,7 +367,13 @@ export function clientRechargeView(order: Record<string, any>) {
completedAt: order.completedAt ?? null,
};
}
export function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) {
export function summarizeMessageGroups(
groups: Array<{
status: string;
_count: { _all: number };
_sum: { amountCents: number | bigint | null; billingUnits: number | null };
}>,
) {
return groups.reduce(
(summary, group) => {
const count = group._count._all;
@@ -360,8 +395,29 @@ export function summarizeMessageGroups(groups: Array<{ status: string; _count: {
export function groupDownstreamByType(
groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>,
) {
return groups.reduce<Record<string, { total: number; pending: number; awaitingAck: number; delivered: number; failed: number; unconfirmed: number; rejected: number }>>((accumulator, item) => {
const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, awaitingAck: 0, delivered: 0, failed: 0, unconfirmed: 0, rejected: 0 };
return groups.reduce<
Record<
string,
{
total: number;
pending: number;
awaitingAck: number;
delivered: number;
failed: number;
unconfirmed: number;
rejected: number;
}
>
>((accumulator, item) => {
const current = accumulator[item.deliveryType] ?? {
total: 0,
pending: 0,
awaitingAck: 0,
delivered: 0,
failed: 0,
unconfirmed: 0,
rejected: 0,
};
current.total += item._count._all;
if (item.status === 'pending') {
current.pending += item._count._all;
@@ -385,7 +441,20 @@ export function groupDownstreamByApplication(
applicationMap: Map<string, string>,
applicationAlertMap: Map<string, number>,
) {
const summaryMap = new Map<string, { applicationId: string; name: string; pending: number; awaitingAck: number; failed: number; unconfirmed: number; rejected: number; delivered: number; alertCount: number }>();
const summaryMap = new Map<
string,
{
applicationId: string;
name: string;
pending: number;
awaitingAck: number;
failed: number;
unconfirmed: number;
rejected: number;
delivered: number;
alertCount: number;
}
>();
groups.forEach((item) => {
const current = summaryMap.get(item.applicationId) ?? {
applicationId: item.applicationId,
@@ -430,10 +499,7 @@ export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereI
],
};
const warning: Prisma.OperationLogWhereInput = {
OR: [
{ action: { contains: 'warning' } },
{ action: { contains: 'risk' } },
],
OR: [{ action: { contains: 'warning' } }, { action: { contains: 'risk' } }],
};
const success: Prisma.OperationLogWhereInput = {
OR: [
@@ -459,13 +525,14 @@ export function operationLogLevelWhere(level: string): Prisma.OperationLogWhereI
export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) {
const detail = (log.detail ?? {}) as Record<string, unknown>;
const result = String(detail.result ?? detail.status ?? '');
const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
? 'error'
: log.action.includes('warning') || log.action.includes('risk')
? 'warning'
: log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected')
? 'success'
: 'info';
const level =
result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
? 'error'
: log.action.includes('warning') || log.action.includes('risk')
? 'warning'
: log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected')
? 'success'
: 'info';
return {
id: log.id,
time: log.createdAt,
@@ -482,22 +549,32 @@ export function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ inclu
}
export function sanitizeGatewaySubmitException(
item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>,
messageState?: { status: string; submitStatus: string | null; receiptStatus: string | null; phoneNumber: string; content: string },
messageState?: {
status: string;
submitStatus: string | null;
receiptStatus: string | null;
phoneNumber: string;
content: string;
},
) {
const { rawPayload, commandPayload, tenant, application, channel, ...record } = item;
return {
...record,
tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null,
application: application ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } : null,
channel: channel ? {
id: channel.id,
code: channel.code,
name: channel.name,
status: channel.status,
carrier: channel.carrier,
sendRegion: channel.sendRegion,
rateLimitPerSecond: channel.rateLimitPerSecond,
} : null,
application: application
? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status }
: null,
channel: channel
? {
id: channel.id,
code: channel.code,
name: channel.name,
status: channel.status,
carrier: channel.carrier,
sendRegion: channel.sendRegion,
rateLimitPerSecond: channel.rateLimitPerSecond,
}
: null,
rawPayloadAvailable: Boolean(rawPayload),
commandPayload: redactGatewayCommandValue(commandPayload),
messageState: messageState ?? null,
@@ -512,8 +589,15 @@ export function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prism
for (const [key, child] of Object.entries(value)) {
const normalizedKey = key.toLowerCase();
redacted[key] = [
'password', 'passwordcipher', 'secret', 'secrethash', 'authsource',
'token', 'apikey', 'accesskey', 'secretkey',
'password',
'passwordcipher',
'secret',
'secrethash',
'authsource',
'token',
'apikey',
'accesskey',
'secretkey',
].includes(normalizedKey)
? '[REDACTED]'
: redactGatewayCommandValue(child as Prisma.JsonValue);
@@ -634,7 +634,7 @@ describe('OperationsService', () => {
},
today: expect.objectContaining({
returnedCents: 10,
segmentCount: 20,
segmentCount: 2,
deliveredSegmentCount: 18,
arrivalRate: 90,
billedCents: 360,
+52 -33
View File
@@ -1,16 +1,24 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { moneyToNumber } from '../../common/money';
import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
import {
messageWhere,
qualityBusinessDay,
returnedTransactionWhere,
downstreamAlertWindows,
stalledPendingWhere,
clientBatchTaskView,
clientAccountView,
clientRechargeView,
summarizeMessageGroups,
} from '../operations.helpers';
// R2 dashboard query domain. Method bodies are preserved byte-for-byte from the facade baseline.
export class OperationsDashboardQueries {
constructor(private readonly prisma: PrismaService) {}
async dashboard(query: { tenantId?: string }) {
async dashboard(query: { tenantId?: string }) {
const businessDay = qualityBusinessDay();
const sinceToday = businessDay.startAt;
const downstreamAlertWindow = downstreamAlertWindows();
@@ -94,13 +102,15 @@ async dashboard(query: { tenantId?: string }) {
orderBy: { createdAt: 'desc' },
take: 10,
}),
this.prisma.$queryRaw<Array<{
tenantId: string;
tenantName: string;
todaySpendCents: bigint;
balanceCents: bigint;
creditCents: bigint;
}>>(Prisma.sql`
this.prisma.$queryRaw<
Array<{
tenantId: string;
tenantName: string;
todaySpendCents: bigint;
balanceCents: bigint;
creditCents: bigint;
}>
>(Prisma.sql`
SELECT
tenant.id AS "tenantId",
tenant.name AS "tenantName",
@@ -118,12 +128,14 @@ async dashboard(query: { tenantId?: string }) {
GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents"
ORDER BY "todaySpendCents" DESC, tenant.name ASC
`),
this.prisma.$queryRaw<Array<{
segmentCount: bigint;
deliveredSegmentCount: bigint;
billedCents: bigint;
costCents: bigint;
}>>(Prisma.sql`
this.prisma.$queryRaw<
Array<{
segmentCount: bigint;
deliveredSegmentCount: bigint;
billedCents: bigint;
costCents: bigint;
}>
>(Prisma.sql`
WITH segment_metrics AS (
SELECT
COUNT(segment.id)::bigint AS "segmentCount",
@@ -208,11 +220,13 @@ async dashboard(query: { tenantId?: string }) {
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
},
}),
this.prisma.$queryRaw<Array<{
hour: number;
submittedCount: bigint;
successCount: bigint;
}>>(Prisma.sql`
this.prisma.$queryRaw<
Array<{
hour: number;
submittedCount: bigint;
successCount: bigint;
}>
>(Prisma.sql`
SELECT
EXTRACT(
HOUR FROM (message."queuedAt" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Shanghai'
@@ -227,11 +241,13 @@ async dashboard(query: { tenantId?: string }) {
ORDER BY 1
`),
// Signature/template tables have no review timestamps, so their latest pending audit is paired with the review audit.
this.prisma.$queryRaw<Array<{
category: string;
count: bigint;
averageProcessingMs: bigint | null;
}>>(Prisma.sql`
this.prisma.$queryRaw<
Array<{
category: string;
count: bigint;
averageProcessingMs: bigint | null;
}>
>(Prisma.sql`
WITH review_samples AS (
SELECT
'enterpriseCertifications'::text AS category,
@@ -302,7 +318,8 @@ async dashboard(query: { tenantId?: string }) {
]);
const todayTotals = summarizeMessageGroups(todayMessageGroups);
const todayBusinessMetrics = todayBusinessMetricsRows[0];
const segmentCount = Number(todayBusinessMetrics?.segmentCount ?? 0);
const supplierSegmentCount = Number(todayBusinessMetrics?.segmentCount ?? 0);
const segmentCount = todayTotals.billingUnits;
const deliveredSegmentCount = Number(todayBusinessMetrics?.deliveredSegmentCount ?? 0);
const billedCents = moneyToNumber(todayBusinessMetrics?.billedCents);
const costCents = moneyToNumber(todayBusinessMetrics?.costCents);
@@ -334,7 +351,8 @@ async dashboard(query: { tenantId?: string }) {
averageProcessingMs: row?.averageProcessingMs == null ? null : Number(row.averageProcessingMs),
};
});
const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount;
const downstreamAlertCount =
downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount;
return {
taskCount,
messageStatus: messageGroups,
@@ -349,7 +367,8 @@ async dashboard(query: { tenantId?: string }) {
billingUnits: todayTotals.billingUnits,
segmentCount,
deliveredSegmentCount,
arrivalRate: segmentCount > 0 ? Number(((deliveredSegmentCount / segmentCount) * 100).toFixed(1)) : 0,
arrivalRate:
supplierSegmentCount > 0 ? Number(((deliveredSegmentCount / supplierSegmentCount) * 100).toFixed(1)) : 0,
billedCents,
profitCents,
profitRate: billedCents > 0 ? Number(((profitCents / billedCents) * 100).toFixed(1)) : 0,
@@ -383,7 +402,7 @@ async dashboard(query: { tenantId?: string }) {
recentRecharges,
};
}
async clientDashboard(query: { tenantId?: string }) {
async clientDashboard(query: { tenantId?: string }) {
const tenantId = query.tenantId;
const [dashboard, tenant, approvedCertification, signatureCount, pendingBatchTaskCount] = await Promise.all([
this.dashboard(query),
@@ -432,7 +451,7 @@ async clientDashboard(query: { tenantId?: string }) {
},
};
}
pendingAudits(tenantId?: string) {
pendingAudits(tenantId?: string) {
return Promise.all([
this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }),
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
@@ -108,6 +108,7 @@ export class OperationsMessageQueries {
const item = await this.prisma.smsMessageRecord.findUnique({
where: { id },
include: {
channelWordDecisions: { orderBy: [{ decidedAt: 'desc' }, { id: 'desc' }], take: 10 },
tenant: { select: { id: true, name: true } },
application: { select: { id: true, name: true } },
channel: { select: { id: true, name: true, srcId: true } },
+1 -1
View File
@@ -539,7 +539,7 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat
JOIN "Tenant" tenant ON tenant.id = message."tenantId"
${applicationJoin}
LEFT JOIN "SmsSignature" signature ON signature.id = message."signatureId"
LEFT JOIN "SmsDrainageInfo" drainage ON drainage.id = message."drainageInfoId"
LEFT JOIN "SmsDrainageInfo" drainage ON ${dimensionType === 'drainage' ? Prisma.sql`(CASE WHEN message."drainageGate" IS NULL THEN drainage.id = message."drainageInfoId" ELSE EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(message."drainageGate"->'targets', '[]'::jsonb)) target WHERE target->'materialIds' ? drainage.id) END)` : Prisma.sql`drainage.id = message."drainageInfoId"`}
WHERE message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
), thresholds AS (
@@ -0,0 +1,81 @@
import { ChannelWordSnapshot, loadChannelWords } from './channel-sensitive-routing';
import { PrismaService } from '../prisma/prisma.service';
const channel = {
status: 'active',
carrier: 'all',
carriers: ['mobile', 'unicom', 'telecom'],
sendRegion: '全国',
connectionStates: [{ status: 'connected', currentConnections: 1, desiredConnections: 1 }],
};
const items = ['a', 'b'].map((channelId, index) => ({ channelId, carrier: 'mobile', priority: index + 1, channel }));
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('removes only matching eligible channels before original priority selection', () => {
const snapshot = new ChannelWordSnapshot([rule]);
expect(snapshot.select('m', '【签名】贷款业务', items, options).selected?.channelId).toBe('b');
expect(snapshot.select('n', '正常业务', items, options).selected?.channelId).toBe('a');
expect(snapshot.decisions[0].snapshot).toEqual(
expect.objectContaining({ excludedChannelIds: ['a'], stage: 'route' }),
);
});
it('keeps original exclusions and distinguishes no original route from all hit', () => {
const snapshot = new ChannelWordSnapshot([rule]);
expect(snapshot.select('m', '贷款', items, { ...options, excludedChannelIds: new Set(['b']) }).rejected).toBe(true);
expect(snapshot.select('m', '贷款', items, { ...options, approvedChannelIds: new Set() }).rejected).toBe(false);
expect(
snapshot.select(
'm',
'贷款',
items.map((item) => ({ ...item, channel: { ...channel, status: 'inactive' } })),
options,
).rejected,
).toBe(false);
});
it('allows national fallback when matching province channel is excluded', () => {
const provincial = [{ ...items[0], province: '上海', channel: { ...channel, sendRegion: '上海' } }, items[1]];
expect(
new ChannelWordSnapshot([rule]).select('m', '贷款', provincial, { ...options, province: '上海' }).selected
?.channelId,
).toBe('b');
});
it('matches original complete content, case sensitively without removing separators', () => {
const snapshot = new ChannelWordSnapshot([{ ...rule, word: 'Ab贷款' }]);
expect(snapshot.hits('【签名】Ab贷款')[0].count).toBe(1);
for (const value of ['ab贷款', 'b贷款', 'Ab贷-款']) expect(snapshot.hits(value)).toHaveLength(0);
});
it('caps sample words but never truncates excluded channels or hit counts', () => {
const rules = Array.from({ length: 21 }, (_, index) => ({ ...rule, id: String(index) }));
const snapshot = new ChannelWordSnapshot([...rules, { ...rule, channelId: 'b' }]);
expect(snapshot.hits('贷款')[0]).toEqual(expect.objectContaining({ count: 21, samples: expect.any(Array) }));
expect(snapshot.hits('贷款')[0].samples).toHaveLength(20);
expect(snapshot.select('m', '贷款', items, options).rejected).toBe(true);
});
it('reads rules once per batch and persists all decisions in one idempotent write', async () => {
const prisma = {
channelSensitiveWord: { findMany: jest.fn().mockResolvedValue([rule]) },
smsChannelSensitiveDecision: { createMany: jest.fn() },
};
const snapshot = await loadChannelWords(prisma as unknown as PrismaService, ['a', 'b', 'a']);
for (let index = 0; index < 100; index++) snapshot.select(String(index), '贷款', items, options);
expect(snapshot.hits('贷款')).toBe(snapshot.hits('贷款'));
await snapshot.persist(prisma as unknown as PrismaService);
expect(prisma.channelSensitiveWord.findMany).toHaveBeenCalledTimes(1);
expect(prisma.smsChannelSensitiveDecision.createMany).toHaveBeenCalledWith({
data: expect.any(Array),
skipDuplicates: true,
});
expect(snapshot.decisions).toHaveLength(100);
expect(new Set(snapshot.decisions.map((value) => value.routeAttemptId)).size).toBe(100);
});
it('fails closed for technical read and persistence errors, not as a word hit', async () => {
const prisma = {
channelSensitiveWord: { findMany: jest.fn().mockRejectedValue(Error('db unavailable')) },
smsChannelSensitiveDecision: { createMany: jest.fn().mockRejectedValue(Error('db unavailable')) },
};
await expect(loadChannelWords(prisma as unknown as PrismaService, ['a'])).rejects.toMatchObject({ status: 503 });
const snapshot = new ChannelWordSnapshot([rule]);
snapshot.select('m', '贷款', items, options);
await expect(snapshot.persist(prisma as unknown as PrismaService)).rejects.toMatchObject({ status: 503 });
});
});
@@ -0,0 +1,93 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import { ChannelSensitiveWord, Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { selectChannelCandidate } from './send-chain.helpers';
export const CHANNEL_WORD_NO_ROUTE = 'CHANNEL_SENSITIVE_WORD_NO_ROUTE';
export class ChannelWordRejection extends BadRequestException {
readonly reasonCode = CHANNEL_WORD_NO_ROUTE;
constructor() {
super('可用通道均命中通道敏感词');
}
}
type Rule = Pick<ChannelSensitiveWord, 'id' | 'channelId' | 'word' | 'version'>;
type Hit = { channelId: string; count: number; samples: Array<{ id: string; word: string; version: number }> };
export class ChannelWordSnapshot {
private readonly matches = new Map<string, Hit[]>();
readonly decisions: Prisma.SmsChannelSensitiveDecisionCreateManyInput[] = [];
constructor(
private readonly rules: Rule[],
readonly readAt = new Date().toISOString(),
) {}
hits(content: string): Hit[] {
const cached = this.matches.get(content);
if (cached) return cached;
const matched = new Map<string, Hit>();
for (const rule of this.rules) {
if (!rule.word || !content.includes(rule.word)) continue;
const hit = matched.get(rule.channelId) ?? { channelId: rule.channelId, count: 0, samples: [] };
hit.count++;
if (hit.samples.length < 20) hit.samples.push({ id: rule.id, word: rule.word, version: rule.version });
matched.set(rule.channelId, hit);
}
const result = [...matched.values()];
this.matches.set(content, result);
return result;
}
select<T extends Parameters<typeof selectChannelCandidate>[0][number]>(
messageId: string,
content: string,
items: T[],
options: Parameters<typeof selectChannelCandidate>[1],
) {
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 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;
const routeAttemptId = randomUUID();
this.decisions.push({
id: randomUUID(),
messageRecordId: messageId,
routeAttemptId,
snapshot: {
readAt: this.readAt,
stage: 'route',
contentHash: createHash('sha256').update(content).digest('hex'),
candidateChannelIds: [...candidateIds],
excludedChannelIds: hits.map((hit) => hit.channelId),
hits,
selectedChannelId: selected?.channelId ?? null,
reason: rejected ? '可用通道均命中通道敏感词' : null,
},
});
return { selected, rejected };
}
async persist(prisma: PrismaService) {
if (!this.decisions.length) return;
try {
await prisma.smsChannelSensitiveDecision.createMany({ data: this.decisions, skipDuplicates: true });
} catch {
throw new ServiceUnavailableException('通道敏感词选路记录保存失败');
}
}
}
export async function loadChannelWords(prisma: PrismaService, channelIds: string[]) {
try {
const rules = channelIds.length
? await prisma.channelSensitiveWord.findMany({
where: { channelId: { in: [...new Set(channelIds)] }, status: 'active' },
select: { id: true, channelId: true, word: true, version: true },
orderBy: { id: 'asc' },
})
: [];
return new ChannelWordSnapshot(rules);
} catch {
throw new ServiceUnavailableException('通道敏感词读取失败');
}
}
@@ -0,0 +1,130 @@
import {
assessDrainage,
drainageHost,
drainageTargets,
materialMatches,
normalizeDrainagePhone,
type DrainageTarget,
type DrainageMaterial,
} from './drainage-authorization';
import { detectDrainageContentWithRules } from './drainage-content-detection';
const target = (value: string): DrainageTarget => ({
key: value,
category: 'url',
value: drainageHost(value)!,
text: value,
start: 0,
end: value.length,
});
const material = (id: string, url: string, channels: string[], auditStatus = 'approved'): DrainageMaterial => ({
id,
url,
auditStatus,
materialVersion: 1,
reportTasks: channels.map((channelId) => ({
id: `${id}-${channelId}`,
channelId,
status: 'approved',
carrier: 'mobile',
})),
});
describe('drainage authorization', () => {
test.each([
'lisglo.cn',
'sms.lisglo.cn',
'a.sms.lisglo.cn/path?x=1',
'https://SMS.LISGLO.CN:8443/other?a=2#fragment',
])('allows registered host and subdomains %s', (url) => {
expect(materialMatches(target(url), 'lisglo.cn')).toBe(true);
});
test.each(['lisglo.cn.evil.com', 'evillisglo.cn', 'evil.com/?next=lisglo.cn', 'https://lisglo.cn@evil.com/'])(
'rejects fake containing URL %s',
(url) => {
expect(materialMatches(target(url), 'lisglo.cn')).toBe(false);
},
);
test.each(['cn', 'com', 'com.cn', 'co.uk'])('does not authorize public suffix %s', (host) =>
expect(drainageHost(host)).toBeNull(),
);
it('does not broaden child registration to siblings or parent', () => {
expect(materialMatches(target('lisglo.cn'), 'sms.lisglo.cn')).toBe(false);
expect(materialMatches(target('other.lisglo.cn'), 'sms.lisglo.cn')).toBe(false);
expect(materialMatches(target('a.sms.lisglo.cn'), 'sms.lisglo.cn')).toBe(true);
});
it('preserves existing path-restricted authorization', () => {
expect(materialMatches(target('lisglo.cn/app/1'), 'https://lisglo.cn/app')).toBe(true);
expect(materialMatches(target('lisglo.cn/other'), 'https://lisglo.cn/app')).toBe(false);
expect(materialMatches(target('lisglo.cn/application'), 'https://lisglo.cn/app')).toBe(false);
});
it('normalizes phone separators without changing original text or dropping area code', () => {
const original = '021-77882277';
expect(normalizeDrainagePhone(original)).toBe('02177882277');
expect(original).toBe('021-77882277');
expect(materialMatches({ ...target('lisglo.cn'), category: 'landline', value: '02177882277' }, '77882277')).toBe(
false,
);
});
it('requires every target and intersects channels, unions alternatives for one target', () => {
const targets = [target('a.lisglo.cn'), target('example.com')];
const rows = [
material('a', 'lisglo.cn', ['1']),
material('a2', 'a.lisglo.cn', ['2']),
material('b', 'example.com', ['2', '3']),
];
expect(assessDrainage(targets, rows, 'mobile').allowedChannelIds).toEqual(['2']);
expect(assessDrainage(targets, rows.slice(0, 2), 'mobile').reasonCode).toBe('DRAINAGE_NOT_REGISTERED');
expect(assessDrainage(targets, rows, 'telecom').reasonCode).toBe('DRAINAGE_CHANNEL_NOT_APPROVED');
});
it('requires approval and never borrows frozen or rejected channel reports', () => {
expect(
assessDrainage([target('lisglo.cn')], [material('a', 'lisglo.cn', ['1'], 'pending')], 'mobile').reasonCode,
).toBe('DRAINAGE_NOT_APPROVED');
const row = material('a', 'lisglo.cn', ['1']);
row.reportTasks[0].status = 'waiting_review';
expect(assessDrainage([target('lisglo.cn')], [row], 'mobile').reasonCode).toBe('DRAINAGE_CHANNEL_NOT_APPROVED');
});
it('extends truncated detector tokens to complete hostile URL before checking', () => {
const rules = [
{
id: 'url',
code: 'URL',
name: 'url',
category: 'url',
priority: 1,
version: 1,
flags: 'giu',
pattern: '[a-z]+\\.[a-z]+',
},
];
for (const content of ['lisglo.cn.evil.com', 'https://lisglo.cn@evil.com/path?next=lisglo.cn']) {
const detected = detectDrainageContentWithRules(content, rules);
const targets = drainageTargets(content, (detected.drainageDetection as any).matches);
expect(targets.length).toBeGreaterThan(0);
expect(targets.every((item) => !materialMatches(item, 'lisglo.cn'))).toBe(true);
}
});
it('does not exclude a URL with userInfo as an email', () => {
const content = 'https://lisglo.cn@evil.com/path';
const rules = [
{
id: 'url',
code: 'URL',
name: 'url',
category: 'url',
priority: 1,
version: 1,
flags: 'giu',
pattern: 'https?://[^\\s]+',
},
];
const detected = detectDrainageContentWithRules(content, rules);
const targets = drainageTargets(
content,
(detected.drainageDetection as { matches: import('./drainage-content-detection').DrainageDetectionMatch[] })
.matches,
);
expect(targets).toHaveLength(1);
expect(materialMatches(targets[0], 'lisglo.cn')).toBe(false);
});
});
@@ -0,0 +1,243 @@
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import { isIP } from 'node:net';
import { parse } from 'tldts';
import type { PrismaService } from '../prisma/prisma.service';
import { detectDrainageContent, normalizeContent, type DrainageDetectionMatch } from './drainage-content-detection';
export const DRAINAGE_POLICY_VERSION = 'domain-boundary-v1';
export class DrainageRejection extends BadRequestException {
constructor(
public readonly reasonCode: string,
reason: string,
) {
super({ code: reasonCode, message: reason });
}
}
export type DrainageTarget = { key: string; category: string; value: string; text: string; start: number; end: number };
export type DrainageMaterial = {
id: string;
url: string;
auditStatus: string;
materialVersion: number;
reportTasks: Array<{ id: string; channelId: string; carrier: string | null; status: string }>;
};
export type DrainageAssessment = {
version: string;
evaluatedAt: string;
targets: Array<DrainageTarget & { materialIds: string[] }>;
materials: DrainageMaterial[];
allowedChannelIds: string[] | null;
reasonCode: string | null;
reason: string | null;
};
export function drainageHost(raw: string) {
const value = raw
.normalize('NFKC')
.replace(/[\u200B-\u200D\u2060\uFEFF]/gu, '')
.replace(/[。。]/g, '.')
.trim();
try {
const parsed = new URL(/^[a-z]+:\/\//i.test(value) ? value : `https://${value}`);
if (!['http:', 'https:'].includes(parsed.protocol)) return null;
const host = parsed.hostname.toLowerCase().replace(/\.$/, '');
if (isIP(host)) return host;
const domain = parse(host, { allowPrivateDomains: true });
return domain.domain && domain.isIcann !== false ? host : domain.domain && domain.isPrivate ? host : null;
} catch {
return null;
}
}
export function normalizeDrainagePhone(value: string) {
return normalizeContent(value, 'landline').text.replace(/[()]/g, '');
}
export function materialMatches(target: DrainageTarget, raw: string) {
if (target.category !== 'url') return normalizeDrainagePhone(raw) === target.value;
const host = drainageHost(raw);
if (!host || !(target.value === host || (!isIP(host) && target.value.endsWith(`.${host}`)))) return false;
// Existing path-specific material does not silently authorize unrelated paths.
const normalizedRaw = normalizeContent(raw, 'url').text.trim();
const registered = new URL(/^[a-z]+:\/\//i.test(normalizedRaw) ? normalizedRaw : `https://${normalizedRaw}`);
if (registered.pathname !== '/' || registered.search) {
const candidate = new URL(/^[a-z]+:\/\//i.test(target.text) ? target.text : `https://${target.text}`);
return (
(candidate.pathname === registered.pathname ||
candidate.pathname.startsWith(`${registered.pathname.replace(/\/$/, '')}/`)) &&
(!registered.search || candidate.search === registered.search)
);
}
return true;
}
export function drainageTargets(content: string, matches: DrainageDetectionMatch[]) {
const urls: DrainageTarget[] = [];
const normalized = normalizeContent(content, 'url');
for (const match of matches.filter((item) => item.category === 'url')) {
let start = normalized.sourceStarts.findIndex((offset) => offset >= match.start);
let end = normalized.sourceEnds.findIndex((offset) => offset >= match.end) + 1;
if (start < 0 || end <= 0) throw new ServiceUnavailableException('引流识别位置无效');
// Extend the entire URL token, including suffix labels, userInfo and query.
const token = /[a-z0-9:/?&=.%_+@#~!$*()[\]-]/i;
while (start > 0 && token.test(normalized.text[start - 1])) start--;
while (end < normalized.text.length && token.test(normalized.text[end])) end++;
const text = normalized.text.slice(start, end).replace(/[.,;!]+$/, '');
const value = drainageHost(text);
if (!value) throw new DrainageRejection('DRAINAGE_INVALID', '引流URL格式无效或不是可登记域名');
urls.push({
key: `url:${value}:${text}`,
category: 'url',
value,
text,
start: normalized.sourceStarts[start],
end: normalized.sourceEnds[end - 1],
});
}
const targets = [...urls];
for (const match of matches.filter((item) => item.category !== 'url')) {
if (urls.some((url) => match.start < url.end && match.end > url.start)) continue;
if (!['mobile', 'landline'].includes(match.category))
throw new ServiceUnavailableException('引流识别类型尚未支持发送校验');
const value = normalizeDrainagePhone(match.normalizedText);
targets.push({
key: `phone:${value}`,
category: match.category,
value,
text: match.text,
start: match.start,
end: match.end,
});
}
return [...new Map(targets.map((target) => [target.key, target])).values()];
}
export function assessDrainage(
targets: DrainageTarget[],
materials: DrainageMaterial[],
carrier?: string,
): DrainageAssessment {
let allowed: Set<string> | null = null;
const assessment: DrainageAssessment = {
version: DRAINAGE_POLICY_VERSION,
evaluatedAt: new Date().toISOString(),
targets: [],
materials: [],
allowedChannelIds: null,
reasonCode: null,
reason: null,
};
for (const target of targets) {
const matching = materials.filter((item) => item.auditStatus !== 'deleted' && materialMatches(target, item.url));
const approved = matching.filter((item) => item.auditStatus === 'approved');
assessment.targets.push({ ...target, materialIds: approved.map((item) => item.id) });
const code = !matching.length ? 'DRAINAGE_NOT_REGISTERED' : !approved.length ? 'DRAINAGE_NOT_APPROVED' : null;
if (code && !assessment.reasonCode) {
assessment.reasonCode = code;
assessment.reason = `引流信息“${target.text.slice(0, 160)}${!matching.length ? '未在当前签名下添加' : '尚未审核通过'}`;
}
const channels = new Set(
approved.flatMap((item) =>
item.reportTasks
.filter((task) => task.status === 'approved' && (!task.carrier || !carrier || task.carrier === carrier))
.map((task) => task.channelId),
),
);
allowed =
allowed === null
? channels
: new Set<string>(Array.from(allowed as Set<string>).filter((id: string) => channels.has(id)));
}
const used = new Set(assessment.targets.flatMap((target) => target.materialIds));
assessment.materials = materials
.filter((item) => used.has(item.id))
.map(({ id, url, auditStatus, materialVersion, reportTasks }) => ({
id,
url,
auditStatus,
materialVersion,
reportTasks,
}));
assessment.allowedChannelIds = allowed === null ? null : [...allowed];
if (targets.length && allowed?.size === 0 && !assessment.reasonCode) {
assessment.reasonCode = 'DRAINAGE_CHANNEL_NOT_APPROVED';
assessment.reason = '当前签名下的全部引流信息没有共同报备通过的通道';
}
return assessment;
}
export async function evaluateMessageDrainage(
prisma: PrismaService,
message: {
id: string;
content: string;
tenantId?: string | null;
applicationId?: string | null;
signatureId?: string | null;
},
carrier?: string,
materials?: DrainageMaterial[],
fresh = false,
) {
let detected;
try {
detected = await detectDrainageContent(prisma, message.content, fresh);
} catch (error) {
throw new ServiceUnavailableException('引流检测暂不可用', { cause: error });
}
const detection = detected.drainageDetection as unknown as {
matches: DrainageDetectionMatch[];
truncated: boolean;
ruleCount: number;
};
if (detection.truncated || detection.ruleCount === 0)
throw new ServiceUnavailableException('引流检测不完整,暂不能发送');
let targets: DrainageTarget[] = [];
let invalid: DrainageRejection | undefined;
try {
targets = drainageTargets(message.content, detection.matches);
} catch (error) {
if (!(error instanceof DrainageRejection)) throw error;
invalid = error;
}
const rows =
materials ??
(targets.length && message.signatureId
? await prisma.smsDrainageInfo.findMany({
where: {
signatureId: message.signatureId,
tenantId: message.tenantId ?? '',
applicationId: message.applicationId ?? '',
auditStatus: { not: 'deleted' },
},
include: {
reportTasks: {
where: { reportType: 'drainage', signatureId: message.signatureId, tenantId: message.tenantId ?? '' },
},
},
})
: []);
const assessment = assessDrainage(targets, rows, carrier);
if (invalid) {
assessment.reasonCode = invalid.reasonCode;
assessment.reason = invalid.message;
assessment.allowedChannelIds = [];
}
// Append-only evidence survives later approval changes and subsequent routing attempts.
await prisma.smsDrainageDecision.create({
data: { messageRecordId: message.id, snapshot: JSON.parse(JSON.stringify(assessment)) },
});
await prisma.smsMessageRecord.update({
where: { id: message.id },
data: {
...detected,
drainageGate: JSON.parse(JSON.stringify(assessment)),
drainageInfoId:
assessment.targets.length === 1 && assessment.targets[0].materialIds.length === 1
? assessment.targets[0].materialIds[0]
: null,
},
});
if (assessment.reasonCode) throw new DrainageRejection(assessment.reasonCode, assessment.reason!);
return assessment;
}
@@ -53,7 +53,8 @@ export function invalidateDrainageDetectionRuleCache() {
export function validateDrainageDetectionPattern(pattern: string, flags = 'giu') {
if (!pattern.trim()) throw new BadRequestException('识别表达式不能为空');
if (pattern.length > MAX_PATTERN_LENGTH) throw new BadRequestException(`识别表达式不能超过 ${MAX_PATTERN_LENGTH} 个字符`);
if (pattern.length > MAX_PATTERN_LENGTH)
throw new BadRequestException(`识别表达式不能超过 ${MAX_PATTERN_LENGTH} 个字符`);
if (!/^[giu]*$/.test(flags) || new Set(flags).size !== flags.length) {
throw new BadRequestException('表达式标志仅支持 g、i、u,且不能重复');
}
@@ -69,18 +70,20 @@ export function validateDrainageDetectionPattern(pattern: string, flags = 'giu')
}
}
function normalizeContent(content: string, category: DrainageDetectionCategory): NormalizedContent {
export function normalizeContent(content: string, category: DrainageDetectionCategory): NormalizedContent {
let text = '';
const sourceStarts: number[] = [];
const sourceEnds: number[] = [];
let sourceIndex = 0;
for (const sourceChar of content.slice(0, MAX_CONTENT_LENGTH)) {
const sourceEnd = sourceIndex + sourceChar.length;
let normalized = sourceChar.normalize('NFKC')
let normalized = sourceChar
.normalize('NFKC')
.replace(/[\u200B-\u200D\u2060\uFEFF]/gu, '')
.replace(/[.。]/g, '.')
.replace(/[:﹕]/g, ':')
.replace(/[]/g, '/')
.replace(/[()]/g, (char) => char === '' ? '(' : ')')
.replace(/[()]/g, (char) => (char === '' ? '(' : ')'))
.replace(/[]/g, '+');
if (category === 'url') {
// Whitespace is a URL boundary: removing it would incorrectly join the suffix into the link.
@@ -116,8 +119,11 @@ function sourceRange(normalized: NormalizedContent, start: number, end: number)
function emailRanges(normalized: NormalizedContent) {
const ranges: Array<{ start: number; end: number }> = [];
const email = /[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+/giu;
const email =
/[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+/giu;
for (const match of normalized.text.matchAll(email)) {
const tokenStart = normalized.text.lastIndexOf(' ', match.index) + 1;
if (/https?:\/\/\S*$/i.test(normalized.text.slice(tokenStart, match.index + match[0].length))) continue;
ranges.push({ start: match.index, end: match.index + match[0].length });
}
return ranges;
@@ -135,7 +141,9 @@ export function detectDrainageContentWithRules(
const matches: DrainageDetectionMatch[] = [];
const normalizedByCategory = new Map<string, NormalizedContent>();
const emailNormalized = normalizeContent(content, 'email');
const originalEmailRanges = emailRanges(emailNormalized).map((range) => sourceRange(emailNormalized, range.start, range.end));
const originalEmailRanges = emailRanges(emailNormalized).map((range) =>
sourceRange(emailNormalized, range.start, range.end),
);
for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) {
validateDrainageDetectionPattern(rule.pattern, rule.flags);
const normalized = normalizedByCategory.get(rule.category) ?? normalizeContent(content, rule.category);
@@ -160,7 +168,12 @@ export function detectDrainageContentWithRules(
start: range.start,
end: range.end,
};
if (!matches.some((item) => item.category === candidate.category && item.start === candidate.start && item.end === candidate.end)) {
if (
!matches.some(
(item) =>
item.category === candidate.category && item.start === candidate.start && item.end === candidate.end,
)
) {
matches.push(candidate);
}
if (matches.length >= MAX_MATCHES) break;
@@ -186,8 +199,8 @@ export function detectDrainageContentWithRules(
};
}
async function activeRules(prisma: PrismaService) {
if (cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules;
async function activeRules(prisma: PrismaService, fresh = false) {
if (!fresh && cachedRules && cachedRules.expiresAt > Date.now()) return cachedRules.rules;
const rules = await prisma.drainageDetectionRule.findMany({
where: { status: 'active' },
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
@@ -206,6 +219,6 @@ async function activeRules(prisma: PrismaService) {
return rules;
}
export async function detectDrainageContent(prisma: PrismaService, content: string) {
return detectDrainageContentWithRules(content, await activeRules(prisma));
export async function detectDrainageContent(prisma: PrismaService, content: string, fresh = false) {
return detectDrainageContentWithRules(content, await activeRules(prisma, fresh));
}
@@ -0,0 +1,29 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { SendChainService } from './send-chain.service';
@Injectable()
export class DrainageReceiptRecoveryService implements OnModuleInit, OnModuleDestroy {
private timer?: ReturnType<typeof setInterval>;
private running = false;
private readonly logger = new Logger(DrainageReceiptRecoveryService.name);
constructor(private readonly sendChain: SendChainService) {}
onModuleInit() {
if (['api', 'callback', 'outbox'].includes(process.env.CMPP_PROCESS_ROLE ?? 'all')) return;
this.timer = setInterval(() => void this.scan(), 10000);
this.timer.unref?.();
}
async scan() {
if (this.running) return;
this.running = true;
try {
await this.sendChain.recoverDrainageFailureReceipts();
} catch (error) {
this.logger.error(`选路拦截回执恢复失败: ${String(error)}`);
} finally {
this.running = false;
}
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
}
@@ -0,0 +1,141 @@
import { Body, Controller, ForbiddenException, Post, Req, BadRequestException } from '@nestjs/common';
type Request = { socket: { remoteAddress?: string }; headers?: Record<string, unknown> };
import { createHash } from 'node:crypto';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { DrainageRejection, evaluateMessageDrainage } from './drainage-authorization';
@Controller('gateway/events')
export class DrainageSubmitGuardController {
constructor(private readonly prisma: PrismaService) {}
@Post('authorize-drainage')
async authorize(
@Req() request: Request,
@Body() body: { submitId?: string; channelId?: string; contentHash?: string },
) {
// This is a local Gateway capability, never a customer-supplied authorization.
if (
!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.socket.remoteAddress ?? '') ||
request.headers?.['x-forwarded-for'] ||
request.headers?.forwarded
)
throw new ForbiddenException();
if (
typeof body.submitId !== 'string' ||
!body.submitId ||
body.submitId.length > 160 ||
typeof body.channelId !== 'string' ||
!body.channelId ||
body.channelId.length > 160 ||
typeof body.contentHash !== 'string' ||
!/^[a-f0-9]{64}$/.test(body.contentHash)
)
throw new BadRequestException('提交校验参数无效');
return this.prisma.$transaction(
async (tx) => {
const submit = await tx.smsSubmitRecord.findUnique({
where: { submitId: body.submitId },
include: { messageRecord: true },
});
if (
!submit ||
submit.channelId !== body.channelId ||
createHash('sha256').update(submit.messageRecord.content).digest('hex') !== body.contentHash
)
return { allowed: false, code: 'DRN', reason: '提交意图与真实消息不一致' };
let message = submit.messageRecord;
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 },
select: { signatureId: true },
});
if (template?.signatureId) message = { ...message, signatureId: template.signatureId };
}
if (
submit.resultProcessedAt ||
['failed', 'delivered', 'unknown', 'cancelled', 'rejected'].includes(message.status)
)
return { allowed: false, code: 'DRN', reason: '提交或消息已终结,不得重复发送' };
if (!message.tenantId && !message.batchTaskId) {
try {
await evaluateMessageDrainage(
tx as unknown as PrismaService,
message,
message.carrier ?? undefined,
undefined,
true,
);
return { allowed: true };
} catch (error) {
if (!(error instanceof DrainageRejection)) throw error;
return { allowed: false, code: 'DRN', reason: error.message };
}
}
if (!message.tenantId || !message.applicationId || !message.signatureId)
return { allowed: false, code: 'DRN', reason: '提交消息未关联企业应用和签名' };
// Shared with approval/material writers through database triggers. Permission
// is linearized at this transaction; already granted wire operations are in-flight.
await tx.$executeRaw`SELECT pg_advisory_xact_lock_shared(hashtextextended(${message.signatureId}, 910))`;
await tx.$queryRaw`SELECT id FROM "SmsSignature" WHERE id = ${message.signatureId} FOR SHARE`;
await tx.$executeRaw`LOCK TABLE "DrainageDetectionRule" IN SHARE MODE`;
const signature = await tx.smsSignature.findFirst({
where: {
id: message.signatureId,
tenantId: message.tenantId,
applicationId: message.applicationId,
auditStatus: 'approved',
},
});
if (!signature) return { allowed: false, code: 'DRN', reason: '签名资格已失效' };
try {
const assessment = await evaluateMessageDrainage(
tx as unknown as PrismaService,
message,
message.carrier ?? undefined,
undefined,
true,
);
const signatureReport = await tx.channelSignatureReportTask.findFirst({
where: {
signatureId: signature.id,
tenantId: message.tenantId,
channelId: submit.channelId,
reportType: 'signature',
status: 'approved',
OR: [
{ carrier: message.carrier },
...(process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true'
? [{ approvalScope: 'legacy_channel' }]
: []),
],
},
});
if (
!signatureReport ||
(assessment.allowedChannelIds !== null && !assessment.allowedChannelIds.includes(submit.channelId))
)
return { allowed: false, code: 'DRN', reason: '最终通道的签名或引流报备资格已失效' };
await tx.smsSubmitRecord.updateMany({
where: { id: submit.id, drainageGate: { equals: Prisma.DbNull } },
data: {
drainageGate: JSON.parse(
JSON.stringify({
...assessment,
channelId: submit.channelId,
carrier: message.carrier,
submitId: submit.submitId,
}),
),
},
});
return { allowed: true };
} catch (error) {
if (!(error instanceof DrainageRejection)) throw error;
return { allowed: false, code: 'DRN', reason: error.message };
}
},
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, timeout: 10000 },
);
}
}
+18 -3
View File
@@ -1,3 +1,4 @@
import { DrainageSubmitGuardController } from './drainage-submit-guard.controller';
import { forwardRef, Module } from '@nestjs/common';
import { BillingModule } from '../billing/billing.module';
import { DictionariesModule } from '../dictionaries/dictionaries.module';
@@ -9,12 +10,26 @@ import { AdminSendChainController } from './admin-send-chain.controller';
import { ClientSendChainController } from './client-send-chain.controller';
import { GatewayEventsController } from './gateway-events.controller';
import { SendChainService } from './send-chain.service';
import { DrainageReceiptRecoveryService } from './drainage-receipt-recovery.service';
import { SecurityDetectionModule } from '../security-detection/security-detection.module';
@Module({
imports: [PrismaModule, BillingModule, DictionariesModule, forwardRef(() => RiskReviewModule), SmsConfigModule, forwardRef(() => OpenApiModule), SecurityDetectionModule],
controllers: [AdminSendChainController, ClientSendChainController, GatewayEventsController],
providers: [SendChainService],
imports: [
PrismaModule,
BillingModule,
DictionariesModule,
forwardRef(() => RiskReviewModule),
SmsConfigModule,
forwardRef(() => OpenApiModule),
SecurityDetectionModule,
],
controllers: [
DrainageSubmitGuardController,
AdminSendChainController,
ClientSendChainController,
GatewayEventsController,
],
providers: [SendChainService, DrainageReceiptRecoveryService],
exports: [SendChainService],
})
export class SendChainModule {}
+169 -2
View File
@@ -3,6 +3,8 @@ import { Prisma } from '@prisma/client';
import { BillingService } from '../billing/billing.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { SendChainService } from './send-chain.service';
import { DrainageRejection } from './drainage-authorization';
import { ChannelWordRejection } from './channel-sensitive-routing';
function createPrismaMock() {
const task = { id: 'task-1', tenantId: 'tenant-1', status: 'ready', phoneTotal: 2 };
@@ -76,6 +78,8 @@ function createPrismaMock() {
},
};
const prisma = {
channelSensitiveWord: { findMany: jest.fn().mockResolvedValue([]) },
smsChannelSensitiveDecision: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
tenant: {
findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }),
},
@@ -134,7 +138,18 @@ function createPrismaMock() {
findMany: jest.fn().mockResolvedValue([]),
},
drainageDetectionRule: {
findMany: jest.fn().mockResolvedValue([]),
findMany: jest.fn().mockResolvedValue([
{
id: 'url',
code: 'URL',
name: 'URL',
category: 'url',
pattern: '[a-z]+\\.[a-z]+',
flags: 'giu',
priority: 1,
version: 1,
},
]),
},
smsSendTask: {
findUnique: jest.fn().mockResolvedValue(null),
@@ -158,6 +173,7 @@ function createPrismaMock() {
findMany: jest.fn().mockResolvedValue([{ id: 'record-1', batchTaskId: 'task-1' }]),
count: jest.fn().mockResolvedValue(1),
findUnique: jest.fn().mockResolvedValue(message),
findUniqueOrThrow: jest.fn().mockResolvedValue(message),
findFirst: jest.fn().mockResolvedValue(message),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...message, ...data })),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
@@ -233,6 +249,7 @@ function createPrismaMock() {
Promise.resolve((where.channelId?.in ?? []).map((channelId: string) => ({ channelId }))),
),
},
smsDrainageDecision: { create: jest.fn().mockResolvedValue({ id: 'decision-1' }) },
smsReceiptRecord: {
create: jest.fn().mockResolvedValue({ id: 'receipt-1' }),
upsert: jest.fn().mockResolvedValue({ id: 'receipt-1', createdAt: new Date('2026-07-01T10:01:00.000Z') }),
@@ -484,6 +501,154 @@ function createService(prisma = createPrismaMock(), openApi?: { queueWebhookEven
}
describe('SendChainService', () => {
it('recovers non-CMPP channel-word finalization without pushing a receipt', async () => {
const { service, prisma } = createService();
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
prisma.smsMessageRecord.findMany.mockResolvedValue([
{ ...message, channelWordFinalizationPending: true, batchTask: { sourceType: 'client' } },
]);
service['releaseMessageReservation'] = jest.fn();
service['recordCmppFailureReceipt'] = jest.fn();
service['refreshTaskProgress'] = jest.fn();
await service.recoverDrainageFailureReceipts();
expect(service['releaseMessageReservation']).toHaveBeenCalledTimes(1);
expect(service['refreshTaskProgress']).toHaveBeenCalledWith('task-1');
expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
where: { id: 'record-1' },
data: { channelWordFinalizationPending: false },
});
});
it.each(['cmpp', 'client', 'http'])(
'fails an all-hit ordinary route without supplier submit (%s)',
async (sourceType) => {
const { service, prisma } = createService();
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
prisma.smsMessageRecord.findUnique.mockResolvedValue({ ...message, batchTask: { id: 'task-1', sourceType } });
service['selectChannelForMessage'] = jest.fn().mockRejectedValue(new ChannelWordRejection());
service['recordCmppFailureReceipt'] = jest.fn();
service['releaseMessageReservation'] = jest.fn();
service['refreshTaskProgress'] = jest.fn();
await service.processSendJob({ messageRecordId: 'record-1' });
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
expect(service['releaseMessageReservation']).toHaveBeenCalledTimes(1);
if (sourceType === 'cmpp')
expect(service['recordCmppFailureReceipt']).toHaveBeenCalledWith(
expect.anything(),
'CSW',
'可用通道均命中通道敏感词',
);
else expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ channelWordFinalizationPending: true, drainageReceiptPending: false }),
}),
);
},
);
it('recovers channel-word delivery intent from an existing receipt and keeps pending on failure', async () => {
const { service, prisma } = createService();
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
const message = {
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
cmppRegisteredDelivery: true,
cmppSubmitSequenceId: '101',
};
service['queueAndTryDownstreamDelivery'] = jest
.fn()
.mockRejectedValueOnce(Error('persistence failed'))
.mockResolvedValue({ id: 'delivery' });
await expect(service['recordCmppFailureReceipt'](message, 'CSW', '可用通道均命中通道敏感词')).rejects.toThrow(
'persistence failed',
);
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
expect.objectContaining({ data: { channelWordFinalizationPending: false } }),
);
await service['recordCmppFailureReceipt'](message, 'CSW', '可用通道均命中通道敏感词');
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
expect(prisma.smsReceiptRecord.upsert).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
where: { id: 'record-1' },
data: { channelWordFinalizationPending: false },
});
});
it('recovers an existing drainage rejection receipt without duplicating it', async () => {
const { service, prisma } = createService();
prisma.smsReceiptRecord.findFirst.mockResolvedValue({ id: 'existing-receipt' });
service['queueAndTryDownstreamDelivery'] = jest.fn().mockResolvedValue({ id: 'delivery' });
service['refreshTaskProgress'] = jest.fn().mockResolvedValue({});
await service['recordCmppFailureReceipt'](
{
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
cmppSubmitSequenceId: '101',
},
'DRN',
'未报备',
);
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
expect(prisma.smsReceiptRecord.upsert).not.toHaveBeenCalled();
expect(service['queueAndTryDownstreamDelivery']).toHaveBeenCalledWith(
expect.objectContaining({ queueCmppDelivery: true, receiptDedupeKey: 'receipt:record-1' }),
);
expect(prisma.smsMessageRecord.update).toHaveBeenLastCalledWith({
where: { id: 'record-1' },
data: { drainageReceiptPending: false },
});
});
it('keeps durable recovery pending when drainage receipt intent persistence fails', async () => {
const { service, prisma } = createService();
service['queueAndTryDownstreamDelivery'] = jest.fn().mockRejectedValue(new Error('queue persistence unavailable'));
await expect(
service['recordCmppFailureReceipt'](
{
id: 'record-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
cmppSubmitSequenceId: '101',
},
'DRN',
'未报备',
),
).rejects.toThrow('queue persistence unavailable');
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
expect.objectContaining({ data: { drainageReceiptPending: false } }),
);
});
it('does not push any drainage rejection receipt for a non-CMPP submission', async () => {
const { service, prisma } = createService();
const message = await prisma.smsMessageRecord.findUnique({ where: { id: 'record-1' } });
prisma.smsMessageRecord.findUnique.mockResolvedValue({
...message,
batchTask: { id: 'task-1', sourceType: 'client' },
});
service['selectChannelForMessage'] = jest
.fn()
.mockRejectedValue(new DrainageRejection('DRAINAGE_NOT_REGISTERED', '未报备'));
service['recordCmppFailureReceipt'] = jest.fn();
service['releaseMessageReservation'] = jest.fn();
service['refreshTaskProgress'] = jest.fn();
await service.processSendJob({ messageRecordId: 'record-1' });
expect(service['recordCmppFailureReceipt']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'failed',
errorCode: 'DRAINAGE_NOT_REGISTERED',
drainageReceiptPending: false,
}),
}),
);
});
it('creates batch tasks, deduplicates phones, creates message records, and enqueues approved tasks', async () => {
const { service, prisma, riskReview, billing } = createService();
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 2 });
@@ -3051,7 +3216,9 @@ describe('SendChainService', () => {
expect(service['identifyCarrier']).not.toHaveBeenCalled();
expect(service['identifyProvince']).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalled();
expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ carrier: expect.any(String) }) }),
);
});
it('updates submit result status, charges billing, and task progress', async () => {
+243 -71
View File
@@ -1,22 +1,63 @@
import { BadRequestException, forwardRef, HttpException, HttpStatus, Inject, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, Optional } from '@nestjs/common';
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
Optional,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { randomUUID } from 'node:crypto';
import { createHash } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service';
import { isIpAllowed } from '../common/ip-allowlist';
import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { PhoneFrequencyService } from '../risk-review/phone-frequency.service';
import { MetricsService } from '../metrics/metrics.service';
import { OpenApiService } from '../open-api/open-api.service';
import type { CreateBatchTaskDto, CreateHttpBatchTaskDto, GatewayInboundAuthDto, GatewayInboundSubmitDto, GatewayInboundSingleSubmitResult, GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto, ImportPreviewDto, ConfirmImportDto, SendJob, QueuePriority, RoutedChannel } from './send-chain.contracts';
import { SEND_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, RECEIPT_TIMEOUT_INITIAL_DELAY_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_STALE_MS, SCHEDULED_DISPATCH_INITIAL_DELAY_MS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS, INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS, DEFAULT_INBOUND_LONG_MESSAGE_PROCESSING_STALE_SECONDS, DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS, UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, GATEWAY_SUBMIT_REQUEUE_IDEMPOTENCY_TTL_SECONDS, BULLMQ_PRIORITY, gatewaySubmitRequeueKey, drainageRejectionReason, statusFromRisk, parseSchedule, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, parseImportRows, splitImportLine, cellByHeader, normalizeCarrier, normalizeQueuePriority, getPositiveConfigInteger, getNonNegativeConfigInteger, isCarrierCompatible, normalizeRegion, matchTemplateContent, escapeRegularExpression, isNationalChannel, isProvinceChannel, validateInboundApplicationSrcId, composeUpstreamSrcId, positiveInteger, parseOptionalSequenceId, normalizeSubmitStatus, normalizeReceiptStatus, downstreamDeliveryAttemptKey, shanghaiDateKey, bullmqConnection, matchesApplicationSecret, octetString, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory } from './send-chain.helpers';
import { aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
import type {
CreateBatchTaskDto,
CreateHttpBatchTaskDto,
GatewayInboundAuthDto,
GatewayInboundSubmitDto,
GatewaySubmitResultDto,
GatewaySubmitSegmentResultDto,
GatewayReceiptEventDto,
GatewayUplinkEventDto,
UplinkMatchCandidateInput,
GatewayPendingDeliveryQueryDto,
GatewayDownstreamSentDto,
GatewayDownstreamAcknowledgedDto,
GatewayDownstreamFailureType,
GatewaySubmitDeadLetterDto,
RequeueGatewaySubmitExceptionDto,
GatewayDownstreamRecoveryStatusDto,
TimeoutUnknownDto,
ImportPreviewDto,
ConfirmImportDto,
SendJob,
QueuePriority,
RoutedChannel,
} from './send-chain.contracts';
import {
DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS,
RECEIPT_TIMEOUT_INITIAL_DELAY_MS,
DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS,
SCHEDULED_DISPATCH_INITIAL_DELAY_MS,
DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS,
INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS,
DEFAULT_UPSTREAM_RECEIPT_INBOX_SCAN_INTERVAL_MS,
UPSTREAM_RECEIPT_INBOX_INITIAL_DELAY_MS,
downstreamPendingTimeoutHours,
positiveInteger,
} from './send-chain.helpers';
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
import { SendSubmissionService, type SendResourceValidationOptions } from './send-submission.service';
import { SendCompletionService, type SendCompletionFacade } from './send-completion.service';
@@ -68,12 +109,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
},
metrics,
);
this.completion = new SendCompletionService(
prisma,
billing,
openApi,
this as unknown as SendCompletionFacade,
);
this.completion = new SendCompletionService(prisma, billing, openApi, this as unknown as SendCompletionFacade);
this.downstreamRequeueTasks = new SendDownstreamRequeueTaskService(prisma, this);
}
@@ -91,7 +127,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
this.submission.startInboundWorkflowWorker();
}
if (process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED !== 'false') {
this.receiptTimeoutInitialTimer = setTimeout(() => void this.runReceiptTimeoutScan(), RECEIPT_TIMEOUT_INITIAL_DELAY_MS);
this.receiptTimeoutInitialTimer = setTimeout(
() => void this.runReceiptTimeoutScan(),
RECEIPT_TIMEOUT_INITIAL_DELAY_MS,
);
this.receiptTimeoutInitialTimer.unref?.();
this.receiptTimeoutIntervalTimer = setInterval(
() => void this.runReceiptTimeoutScan(),
@@ -107,22 +146,27 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
this.scheduledDispatchInitialTimer.unref?.();
this.scheduledDispatchIntervalTimer = setInterval(
() => void this.runScheduledDispatchScan(),
positiveInteger(process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS, DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS),
positiveInteger(
process.env.SMS_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS,
DEFAULT_SCHEDULED_DISPATCH_SCAN_INTERVAL_MS,
),
);
this.scheduledDispatchIntervalTimer.unref?.();
}
if (process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_ENABLED !== 'false') {
this.inboundLongMessageInitialTimer = setTimeout(
() => void this.expireInboundLongMessages().catch((error) => {
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
}),
() =>
void this.expireInboundLongMessages().catch((error) => {
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
}),
INBOUND_LONG_MESSAGE_SCAN_INITIAL_DELAY_MS,
);
this.inboundLongMessageInitialTimer.unref?.();
this.inboundLongMessageIntervalTimer = setInterval(
() => void this.expireInboundLongMessages().catch((error) => {
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
}),
() =>
void this.expireInboundLongMessages().catch((error) => {
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
}),
positiveInteger(
process.env.CMPP_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS,
DEFAULT_INBOUND_LONG_MESSAGE_SCAN_INTERVAL_MS,
@@ -147,7 +191,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
if (process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_ENABLED !== 'false') {
this.downstreamRequeueTaskIntervalTimer = setInterval(
() => void this.downstreamRequeueTasks.runScan().catch((error) => this.logger.error(`Downstream requeue task scan failed: ${String(error)}`)),
() =>
void this.downstreamRequeueTasks
.runScan()
.catch((error) => this.logger.error(`Downstream requeue task scan failed: ${String(error)}`)),
positiveInteger(process.env.CMPP_DOWNSTREAM_REQUEUE_TASK_SCAN_INTERVAL_MS, 1_000),
);
this.downstreamRequeueTaskIntervalTimer.unref?.();
@@ -191,12 +238,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
orderBy: { createdAt: 'desc' },
});
const taskIds = tasks.map((task) => task.id);
const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({
by: ['batchTaskId', 'carrier', 'province', 'status'],
where: { batchTaskId: { in: taskIds } },
_count: { _all: true },
_sum: { billingUnits: true },
}) : [];
const messageStats =
taskIds.length > 0
? await this.prisma.smsMessageRecord.groupBy({
by: ['batchTaskId', 'carrier', 'province', 'status'],
where: { batchTaskId: { in: taskIds } },
_count: { _all: true },
_sum: { billingUnits: true },
})
: [];
return tasks.map((task) => ({
...task,
messageStats: messageStats.filter((item) => item.batchTaskId === task.id),
@@ -232,11 +282,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
sourceType: query.sourceType ?? 'client',
taskNo: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined,
tenant: query.enterpriseKeyword?.trim() ? { name: { contains: query.enterpriseKeyword.trim() } } : undefined,
application: query.applicationKeyword?.trim() ? { name: { contains: query.applicationKeyword.trim() } } : undefined,
createdAt: query.createdAtFrom || query.createdAtTo ? {
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
} : undefined,
application: query.applicationKeyword?.trim()
? { name: { contains: query.applicationKeyword.trim() } }
: undefined,
createdAt:
query.createdAtFrom || query.createdAtTo
? {
gte: query.createdAtFrom ? new Date(`${query.createdAtFrom}T00:00:00+08:00`) : undefined,
lte: query.createdAtTo ? new Date(`${query.createdAtTo}T23:59:59.999+08:00`) : undefined,
}
: undefined,
};
const [tasks, total] = await Promise.all([
this.prisma.smsBatchTask.findMany({
@@ -254,12 +309,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
this.prisma.smsBatchTask.count({ where }),
]);
const taskIds = tasks.map((task) => task.id);
const messageStats = taskIds.length > 0 ? await this.prisma.smsMessageRecord.groupBy({
by: ['batchTaskId', 'carrier', 'province', 'status'],
where: { batchTaskId: { in: taskIds } },
_count: { _all: true },
_sum: { billingUnits: true },
}) : [];
const messageStats =
taskIds.length > 0
? await this.prisma.smsMessageRecord.groupBy({
by: ['batchTaskId', 'carrier', 'province', 'status'],
where: { batchTaskId: { in: taskIds } },
_count: { _all: true },
_sum: { billingUnits: true },
})
: [];
return {
items: tasks.map((task) => ({
...task,
@@ -304,14 +362,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return { items, total, page: normalizedPage, pageSize: normalizedPageSize };
}
listMessages(query: {
tenantId?: string;
applicationId?: string;
channelId?: string;
taskId?: string;
phoneNumber?: string;
status?: string;
} = {}) {
listMessages(
query: {
tenantId?: string;
applicationId?: string;
channelId?: string;
taskId?: string;
phoneNumber?: string;
status?: string;
} = {},
) {
return this.prisma.smsMessageRecord.findMany({
where: {
tenantId: query.tenantId,
@@ -460,7 +520,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async handleReceipt(
data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
incomingIdentity?: {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
},
) {
return this.completion.handleReceipt(data, incomingIdentity);
}
@@ -530,7 +596,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.downstreamRequeueTasks.preview(filter, operatorId);
}
createDownstreamRequeueTask(data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number }, operatorId?: string) {
createDownstreamRequeueTask(
data: { previewToken: string; reason: string; ratePerSecond?: number; consecutiveFailureLimit?: number },
operatorId?: string,
) {
return this.downstreamRequeueTasks.create(data, operatorId);
}
@@ -542,7 +611,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.downstreamRequeueTasks.get(id);
}
listDownstreamRequeueTaskItems(id: string, query: { status?: string; keyword?: string; page?: number; pageSize?: number }) {
listDownstreamRequeueTaskItems(
id: string,
query: { status?: string; keyword?: string; page?: number; pageSize?: number },
) {
return this.downstreamRequeueTasks.listItems(id, query);
}
@@ -592,7 +664,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
requestedMessageIds?: string[],
workflowKey?: string,
) {
return this.submission.submitCompleteInboundMessage(data, phoneNumbers, application, requestedGroupMessageId, requestedMessageIds, workflowKey);
return this.submission.submitCompleteInboundMessage(
data,
phoneNumbers,
application,
requestedGroupMessageId,
requestedMessageIds,
workflowKey,
);
}
private async collectInboundLongMessageFragment(
@@ -616,22 +695,33 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
receiptRejection?: { code: string; reason: string },
workflowItemKey?: string,
) {
return this.submission.submitInboundSingleMessage(data, messageId, submitGroupMessageId, application, synchronousRejection, receiptRejection, workflowItemKey);
return this.submission.submitInboundSingleMessage(
data,
messageId,
submitGroupMessageId,
application,
synchronousRejection,
receiptRejection,
workflowItemKey,
);
}
/**
* CMPP
*
*/
private async evaluateRiskWithPhoneFrequency(input: {
tenantId: string;
applicationId: string;
templateId?: string;
content: string;
variables?: Record<string, unknown>;
phoneNumber: string;
sourceType: 'cmpp';
}, reservationKey?: string) {
private async evaluateRiskWithPhoneFrequency(
input: {
tenantId: string;
applicationId: string;
templateId?: string;
content: string;
variables?: Record<string, unknown>;
phoneNumber: string;
sourceType: 'cmpp';
},
reservationKey?: string,
) {
return this.submission.evaluateRiskWithPhoneFrequency(input, reservationKey);
}
@@ -699,13 +789,29 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
private async selectChannelForMessage(
message: { id: string; tenantId: string; applicationId?: string | null; templateId?: string | null; signatureId?: string | null; phoneNumber: string; carrier?: string | null; province?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null },
message: {
id: string;
tenantId: string;
applicationId?: string | null;
templateId?: string | null;
signatureId?: string | null;
phoneNumber: string;
carrier?: string | null;
province?: string | null;
template?: { signature?: { id?: string | null } | null } | null;
signature?: { id?: string | null } | null;
},
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
): Promise<RoutedChannel> {
return this.submission.selectChannelForMessage(message, options);
}
private async findApplicationRoute(tenantId: string, applicationId: string | undefined, carrier: string, signatureId?: string) {
private async findApplicationRoute(
tenantId: string,
applicationId: string | undefined,
carrier: string,
signatureId?: string,
) {
return this.submission.findApplicationRoute(tenantId, applicationId, carrier, signatureId);
}
@@ -754,10 +860,55 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.submission.resolveDrainageInfoMatch(signatureId, content);
}
private async attachMessageToReviewTask(reviewTaskId: string, messageRecordId: string, signatureId: string, drainageInfoId?: string) {
private async attachMessageToReviewTask(
reviewTaskId: string,
messageRecordId: string,
signatureId: string,
drainageInfoId?: string,
) {
return this.submission.attachMessageToReviewTask(reviewTaskId, messageRecordId, signatureId, drainageInfoId);
}
async recoverDrainageFailureReceipts() {
const messages = await this.prisma.smsMessageRecord.findMany({
where: {
OR: [
{ drainageReceiptPending: true, batchTask: { sourceType: 'cmpp' } },
{ channelWordFinalizationPending: true },
],
status: { in: ['failed', 'submit_failed'] },
},
include: { batchTask: { select: { sourceType: true } } },
orderBy: { updatedAt: 'asc' },
take: 50,
});
for (const message of messages) {
if (!message.tenantId || !message.batchTaskId) continue;
const reason = message.errorMessage ?? '引流发送资格校验未通过';
await this.releaseMessageReservation(
{ ...message, tenantId: message.tenantId, batchTaskId: message.batchTaskId },
reason,
);
if (message.channelWordFinalizationPending && message.batchTask?.sourceType !== 'cmpp') {
await this.refreshTaskProgress(message.batchTaskId);
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { channelWordFinalizationPending: false },
});
continue;
}
await this.recordCmppFailureReceipt(
message,
message.channelWordFinalizationPending
? 'CSW'
: message.errorCode?.startsWith('DRN')
? message.errorCode
: 'DRN',
reason,
);
}
}
private async recordCmppFailureReceipt(
message: {
id: string;
@@ -779,7 +930,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.submission.classifyRejectedPhones(tenantId, applicationId, phones);
}
private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string, options?: SendResourceValidationOptions) {
private async validateSendResources(
tenantId: string,
applicationId?: string,
templateId?: string,
options?: SendResourceValidationOptions,
) {
return this.submission.validateSendResources(tenantId, applicationId, templateId, options);
}
@@ -806,7 +962,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
private async releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
message: {
tenantId: string;
batchTaskId: string;
messageId: string;
amountCents: number | bigint;
billingUnits: number;
},
remark: string,
) {
return this.completion.releaseMessageReservation(message, remark);
@@ -832,7 +994,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return this.submission.ensureSignatureReportedForChannel(message, channelId, carrier);
}
private async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) {
private async resolveMessageSignatureId(message: {
templateId?: string | null;
signatureId?: string | null;
template?: { signature?: { id?: string | null } | null } | null;
signature?: { id?: string | null } | null;
}) {
return this.submission.resolveMessageSignatureId(message);
}
@@ -902,7 +1069,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private async resolveReceiptMessage(
data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
incomingIdentity?: {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
},
) {
return this.completion.resolveReceiptMessage(data, incomingIdentity);
}
@@ -926,5 +1099,4 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
private async publishGatewaySubmitCommand(command: unknown, idempotencyKey?: string) {
return this.submission.publishGatewaySubmitCommand(command, idempotencyKey);
}
}
@@ -1,17 +1,20 @@
import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto';
import { BillingService } from '../billing/billing.service';
import { moneyToNumber } from '../common/money';
import type { OpenApiService } from '../open-api/open-api.service';
import { PrismaService } from '../prisma/prisma.service';
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewayControlDeliveryResult, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
import { positiveInteger, normalizeReceiptStatus, normalizeCarrier, DEFAULT_DOWNSTREAM_RETRY_DELAY_MS, DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS, DEFAULT_DOWNSTREAM_MAX_RETRIES, DEFAULT_DOWNSTREAM_PENDING_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS, DEFAULT_GATEWAY_SUBMIT_REQUEUE_STALE_MS, DEFAULT_DOWNSTREAM_MANUAL_REQUEUE_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_PROCESSING_STALE_MS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_ATTEMPTS, DEFAULT_UPSTREAM_RECEIPT_INBOX_MAX_AGE_HOURS, gatewaySubmitRequeueKey, isObjectRecord, asDateOrNull, downstreamRetryDelayMs, downstreamAckTimeoutMs, downstreamRetryBaseDelayMs, downstreamRetryMaxDelayMs, downstreamMaxRetries, downstreamPendingTimeoutHours, downstreamControlFailureMessage, normalizeSubmitStatus, downstreamDeliveryAttemptKey, hasRecoveryAuditStateChanged, normalizeRecoveryFailureCategory, aggregateReceiptSegmentState, isSameUpstreamEndpointIdentity, receiptEventKey } from './send-chain.helpers';
import type { SendSubmissionService } from './send-submission.service';
import type {
GatewayUplinkEventDto,
UplinkMatchCandidateInput,
GatewayControlDeliveryResult,
} from './send-chain.contracts';
import { downstreamControlFailureMessage } from './send-chain.helpers';
import type { SendCompletionCallbacks, SendCompletionFacade } from './send-completion.service';
import { queueFinalReceiptDeliveries, type DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
/**
* R10 downstreamDelivery implementation.
* Cross-domain calls return through the stable SendChainService seam to preserve locking and test observability.
@@ -28,10 +31,10 @@ export class SendDownstreamDeliveryService {
) {}
async handleUplink(data: GatewayUplinkEventDto) {
if (data.eventId) {
const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } });
if (existing) return existing;
}
if (data.eventId) {
const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } });
if (existing) return existing;
}
const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } });
if (!channel) {
throw new NotFoundException('SMS channel not found');
@@ -39,7 +42,7 @@ export class SendDownstreamDeliveryService {
const match = await this.facade.resolveUplinkMatch(data, channel);
const record = await this.prisma.smsUplinkMessage.create({
data: {
eventId: data.eventId,
eventId: data.eventId,
tenantId: match.tenantId,
applicationId: match.applicationId,
messageRecordId: match.messageRecordId,
@@ -224,24 +227,28 @@ export class SendDownstreamDeliveryService {
payload: data.payload,
});
} catch (error) {
this.logger.error(`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`);
this.logger.error(
`HTTP webhook queue failed for ${data.deliveryType}/${data.messageId ?? '-'}: ${error instanceof Error ? error.message : String(error)}`,
);
if (data.propagateHttpQueueError) throw error;
}
}
if (data.queueCmppDelivery === false) {
return null;
}
if (!application?.cmppAccount || (
application.interfaceEnabled !== true && data.allowBusinessRejectionCmppDelivery !== true
)) {
if (
!application?.cmppAccount ||
(application.interfaceEnabled !== true && data.allowBusinessRejectionCmppDelivery !== true)
) {
return null;
}
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload };
const dedupeKey = data.deliveryType === 'receipt' && data.messageRecordId
? data.receiptDedupeKey ?? `receipt:${data.messageRecordId}`
: data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string'
? `uplink:${data.payload.uplinkMessageId}`
: null;
const dedupeKey =
data.deliveryType === 'receipt' && data.messageRecordId
? (data.receiptDedupeKey ?? `receipt:${data.messageRecordId}`)
: data.deliveryType === 'uplink' && typeof data.payload.uplinkMessageId === 'string'
? `uplink:${data.payload.uplinkMessageId}`
: null;
let delivery;
try {
delivery = await this.prisma.cmppDownstreamDelivery.create({
@@ -253,30 +260,30 @@ export class SendDownstreamDeliveryService {
dedupeKey,
deliveryType: data.deliveryType,
payload,
retryEnabled: cmppDeliveryAllowed && (data.deliveryType === 'uplink'
? application?.downstreamUplinkRetryEnabled ?? true
: application?.downstreamReceiptRetryEnabled ?? true),
retryEnabled:
cmppDeliveryAllowed &&
(data.deliveryType === 'uplink'
? (application?.downstreamUplinkRetryEnabled ?? true)
: (application?.downstreamReceiptRetryEnabled ?? true)),
status: cmppDeliveryAllowed ? 'pending' : 'abandoned',
lastError: cmppDeliveryAllowed ? null : '企业应用已停用,保留回执但不再向客户应用推送',
},
});
} catch (error) {
if (
dedupeKey
&& error instanceof Prisma.PrismaClientKnownRequestError
&& error.code === 'P2002'
) {
if (dedupeKey && error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const existing = await this.prisma.cmppDownstreamDelivery.findUnique({
where: { dedupeKey },
});
if (existing) {
this.logger.warn(`downstream_delivery_deduplicated ${JSON.stringify({
deliveryType: data.deliveryType,
messageRecordId: data.messageRecordId,
messageId: data.messageId,
dedupeKey,
deliveryId: existing.id,
})}`);
this.logger.warn(
`downstream_delivery_deduplicated ${JSON.stringify({
deliveryType: data.deliveryType,
messageRecordId: data.messageRecordId,
messageId: data.messageId,
dedupeKey,
deliveryId: existing.id,
})}`,
);
return existing;
}
}
@@ -300,10 +307,10 @@ export class SendDownstreamDeliveryService {
return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: delivery.id } });
}
try {
const result = await this.facade.postGatewayControl(
const result = (await this.facade.postGatewayControl(
data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink',
{ deliveryId: delivery.id, claimId, ...payload },
) as GatewayControlDeliveryResult;
)) as GatewayControlDeliveryResult;
if (result.sent || result.delivered) {
await this.facade.markDownstreamDeliverySent({ id: delivery.id, claimId, ...result });
} else if (result.reasonCode === 'SUBMIT_RESPONSE_PENDING') {
@@ -322,7 +329,10 @@ export class SendDownstreamDeliveryService {
);
}
} catch (error) {
await this.facade.markDownstreamDeliveryFailed(delivery.id, error instanceof Error ? error.message : 'Gateway control delivery failed');
await this.facade.markDownstreamDeliveryFailed(
delivery.id,
error instanceof Error ? error.message : 'Gateway control delivery failed',
);
}
return delivery;
}
@@ -355,22 +365,25 @@ export class SendDownstreamDeliveryService {
const accessNumber = data.destId || channel.srcId || '';
const accessRoutes = accessNumber
? await this.prisma.channelRouteRule.findMany({
where: {
applicationId: { not: null },
status: 'active',
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
},
select: { applicationId: true },
take: 10,
})
: [];
const accessApplicationIds = [...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value)))];
const accessApplications = accessApplicationIds.length > 0
? await this.prisma.smsApplication.findMany({
where: { id: { in: accessApplicationIds }, status: 'active' },
select: { id: true, tenantId: true, name: true },
})
where: {
applicationId: { not: null },
status: 'active',
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
},
select: { applicationId: true },
take: 10,
})
: [];
const accessApplicationIds = [
...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value))),
];
const accessApplications =
accessApplicationIds.length > 0
? await this.prisma.smsApplication.findMany({
where: { id: { in: accessApplicationIds }, status: 'active' },
select: { id: true, tenantId: true, name: true },
})
: [];
if (accessApplications.length === 1) {
return {
tenantId: accessApplications[0].tenantId,
@@ -421,15 +434,14 @@ export class SendDownstreamDeliveryService {
return {
matchStatus: 'ambiguous',
matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`,
candidates: matchableRecentMessages
.map((message) => ({
tenantId: String(message.tenantId),
applicationId: String(message.applicationId),
messageRecordId: message.id,
matchSource: 'phone_window',
confidence: 55,
reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`,
})),
candidates: matchableRecentMessages.map((message) => ({
tenantId: String(message.tenantId),
applicationId: String(message.applicationId),
messageRecordId: message.id,
matchSource: 'phone_window',
confidence: 55,
reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`,
})),
};
}
return { matchStatus: 'unmatched', matchReason: '未匹配到应用或下发记录', candidates: [] };
@@ -454,36 +466,50 @@ export class SendDownstreamDeliveryService {
const existing = await this.prisma.smsReceiptRecord.findFirst({
where: { messageRecordId: message.id, gatewayMessageId: `PLATFORM:${message.messageId}` },
});
if (existing) return existing;
const recoverableRejection = errorCode.startsWith('DRN') || errorCode === 'CSW';
if (existing && !recoverableRejection) return existing;
const deliveredAt = new Date();
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'failed', receiptStatus: 'undelivered', receiptRawStatus: 'REJECTD', errorCode, errorMessage: reason, deliveredAt },
});
const gatewayMessageId = `PLATFORM:${message.messageId}`;
const receipt = await this.prisma.smsReceiptRecord.create({
data: {
tenantId: message.tenantId,
batchTaskId: message.batchTaskId,
messageRecordId: message.id,
receiptKey: createHash('sha256').update(`platform\u0000${gatewayMessageId}\u0000${message.phoneNumber}\u0000undelivered\u0000REJECTD\u0000${errorCode}`).digest('hex'),
messageId: message.messageId,
gatewayMessageId,
phoneNumber: message.phoneNumber,
status: 'failed',
receiptStatus: 'undelivered',
rawStatus: 'REJECTD',
receiptRawStatus: 'REJECTD',
errorCode,
errorMessage: reason,
deliveredAt,
},
});
await queueFinalReceiptDeliveries(
this.prisma,
(request) => this.facade.queueAndTryDownstreamDelivery(request),
{
message,
allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE',
payload: {
const gatewayMessageId = `PLATFORM:${message.messageId}`;
const receiptKey = createHash('sha256')
.update(
`platform\u0000${gatewayMessageId}\u0000${message.phoneNumber}\u0000undelivered\u0000REJECTD\u0000${errorCode}`,
)
.digest('hex');
const receiptData = {
tenantId: message.tenantId,
batchTaskId: message.batchTaskId,
messageRecordId: message.id,
receiptKey,
messageId: message.messageId,
gatewayMessageId,
phoneNumber: message.phoneNumber,
receiptStatus: 'undelivered',
rawStatus: 'REJECTD',
errorCode,
errorMessage: reason,
deliveredAt,
};
const receipt =
existing ??
(recoverableRejection
? await this.prisma.smsReceiptRecord.upsert({ where: { receiptKey }, update: {}, create: receiptData })
: await this.prisma.smsReceiptRecord.create({ data: receiptData }));
await queueFinalReceiptDeliveries(this.prisma, (request) => this.facade.queueAndTryDownstreamDelivery(request), {
message,
propagateHttpQueueError: recoverableRejection,
allowBusinessRejectionCmppDelivery: errorCode === 'ACCOUNT' || errorCode === 'INTERFACE',
payload: {
messageId: message.messageId,
gatewayMessageId: `PLATFORM:${message.messageId}`,
phoneNumber: message.phoneNumber,
@@ -492,10 +518,16 @@ export class SendDownstreamDeliveryService {
errorCode,
errorMessage: reason,
deliveredAt: deliveredAt.toISOString(),
},
},
);
});
if (message.batchTaskId) await this.facade.refreshTaskProgress(message.batchTaskId);
if (errorCode === 'CSW')
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { channelWordFinalizationPending: false },
});
if (errorCode.startsWith('DRN'))
await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { drainageReceiptPending: false } });
return receipt;
}
@@ -202,11 +202,13 @@ export class SendGatewayResultService {
message.batchTaskId
) {
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
const retried = await this.facade.retryMessageIfAllowed(
businessMessage,
data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发',
submitRecord.id,
);
const retried = data.errorCode?.startsWith('DRN')
? null
: await this.facade.retryMessageIfAllowed(
businessMessage,
data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发',
submitRecord.id,
);
if (retried) {
await this.facade.refreshTaskProgress(businessMessage.batchTaskId);
await this.markSubmitResultProcessed(submitRecord.id, data.eventId, submittedAt);
@@ -231,6 +233,8 @@ export class SendGatewayResultService {
errorMessage: data.errorMessage,
submittedAt,
timeoutAt: data.submitStatus === 'timeout' ? submittedAt : undefined,
drainageReceiptPending:
data.errorCode?.startsWith('DRN') && batchTask?.sourceType === 'cmpp' ? true : undefined,
},
});
if (updated.count === 0 && data.submitStatus === 'accepted') {
+116 -15
View File
@@ -6,6 +6,8 @@ import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service';
import { DrainageRejection, evaluateMessageDrainage } from './drainage-authorization';
import { CHANNEL_WORD_NO_ROUTE, ChannelWordRejection, loadChannelWords } from './channel-sensitive-routing';
import { moneyToNumber } from '../common/money';
import { PhoneRoutingLookupService } from '../dictionaries/phone-routing-lookup.service';
import { MetricsService, SendWorkerQueueState, SendWorkerStage } from '../metrics/metrics.service';
@@ -26,7 +28,6 @@ import {
isNationalChannel,
composeUpstreamSrcId,
bullmqConnection,
selectChannelCandidate,
} from './send-chain.helpers';
import type { SendSubmissionCallbacks, SendSubmissionService } from './send-submission.service';
@@ -390,6 +391,7 @@ export class SendGatewaySubmitService {
templateId?: string | null;
signatureId?: string | null;
phoneNumber: string;
content?: string;
carrier?: string | null;
province?: string | null;
template?: { signature?: { id?: string | null } | null } | null;
@@ -454,8 +456,18 @@ export class SendGatewaySubmitService {
const key = `${route.tenantId}:${route.applicationId}:${normalizeCarrier(route.carrier)}`;
if (!routeByKey.has(key)) routeByKey.set(key, route);
}
const drainageMaterials = signatures.length
? await this.prisma.smsDrainageInfo.findMany({
where: { signatureId: { in: signatures }, auditStatus: { not: 'deleted' } },
include: { reportTasks: { where: { reportType: 'drainage' } } },
})
: [];
const channelWords = await loadChannelWords(
this.prisma,
routes.flatMap((route) => route.group.items.map((item) => item.channelId)),
);
const planned: Array<{ message: T; routed: RoutedChannel }> = [];
const failed: Array<{ message: T; reason: string }> = [];
const failed: Array<{ message: T; reason: string; code?: string }> = [];
for (const input of routeInputs) {
if (!input.message.applicationId) {
failed.push({ message: input.message, reason: '短信应用未配置,无法选择通道组' });
@@ -465,6 +477,37 @@ export class SendGatewaySubmitService {
failed.push({ message: input.message, reason: '短信签名未配置,无法选择已报备通道' });
continue;
}
let gate;
let content: string;
try {
const stored =
input.message.content === undefined
? await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: input.message.id } })
: input.message;
content = stored.content!;
gate = await evaluateMessageDrainage(
this.prisma,
{ ...input.message, content: stored.content!, signatureId: input.signatureId },
input.carrier,
drainageMaterials
.filter(
(item) =>
item.signatureId === input.signatureId &&
item.tenantId === input.message.tenantId &&
item.applicationId === input.message.applicationId,
)
.map((item) => ({
...item,
reportTasks: item.reportTasks.filter(
(task) => task.signatureId === input.signatureId && task.tenantId === input.message.tenantId,
),
})),
);
} catch (error) {
if (!(error instanceof DrainageRejection)) throw error;
failed.push({ message: input.message, reason: error.message, code: error.reasonCode });
continue;
}
const route = routeByKey.get(`${input.message.tenantId}:${input.message.applicationId}:${input.carrier}`);
if (!route) {
failed.push({ message: input.message, reason: '企业应用未配置对应运营商通道组' });
@@ -476,6 +519,7 @@ export class SendGatewaySubmitService {
}
const approvedItems = route.group.items.filter(
(item) =>
(gate.allowedChannelIds === null || gate.allowedChannelIds.includes(item.channelId)) &&
item.channel.status === 'active' &&
item.channel.connectionStates.length > 0 &&
item.channel.reportTasks.some(
@@ -485,7 +529,7 @@ export class SendGatewaySubmitService {
(process.env.SIGNATURE_REPORT_STRICT_CARRIER !== 'true' && task.approvalScope === 'legacy_channel')),
),
);
const selected = selectChannelCandidate(approvedItems, {
const { selected, rejected } = channelWords.select(input.message.id, content, approvedItems, {
carrier: input.carrier,
province: input.province,
excludedChannelIds: new Set(),
@@ -493,7 +537,11 @@ export class SendGatewaySubmitService {
routingKey: input.message.id,
});
if (!selected) {
failed.push({ message: input.message, reason: '无已报备通过且在线的可用通道' });
failed.push({
message: input.message,
reason: rejected ? '可用通道均命中通道敏感词' : '无已报备通过且在线的可用通道',
...(rejected ? { code: CHANNEL_WORD_NO_ROUTE } : {}),
});
continue;
}
planned.push({
@@ -508,6 +556,7 @@ export class SendGatewaySubmitService {
},
});
}
await channelWords.persist(this.prisma);
return { planned, failed };
}
@@ -525,21 +574,38 @@ export class SendGatewaySubmitService {
cmppSubmitGroupMessageId?: string | null;
batchTask?: { sourceType?: string | null; phoneTotal?: number | null } | null;
},
>(failed: Array<{ message: T; reason: string }>, results: Map<string, unknown>) {
>(failed: Array<{ message: T; reason: string; code?: string }>, results: Map<string, unknown>) {
const values = Prisma.join(
failed.map(({ message, reason }) => Prisma.sql`(${message.id}::text, ${reason.slice(0, 1000)}::text)`),
failed.map(
({ message, reason, code }) =>
Prisma.sql`(${message.id}::text, ${reason.slice(0, 1000)}::text, ${code ?? null}::text, ${Boolean(code && message.batchTask?.sourceType === 'cmpp')}::boolean)`,
),
);
await this.prisma.$executeRaw(Prisma.sql`
UPDATE "SmsMessageRecord" AS message
SET status = 'failed', "errorMessage" = failures.reason, "updatedAt" = (NOW() AT TIME ZONE 'UTC')
FROM (VALUES ${values}) AS failures(id, reason)
SET status = 'failed', "errorMessage" = failures.reason, "errorCode" = failures.code,
"drainageReceiptPending" = failures.pending AND failures.code IS DISTINCT FROM ${CHANNEL_WORD_NO_ROUTE},
"channelWordFinalizationPending" = COALESCE(failures.code = ${CHANNEL_WORD_NO_ROUTE}, false), "updatedAt" = (NOW() AT TIME ZONE 'UTC')
FROM (VALUES ${values}) AS failures(id, reason, code, pending)
WHERE message.id = failures.id AND message.status = 'queued'
`);
await Promise.all(
failed.map(async ({ message, reason }) => {
failed.map(async ({ message, reason, code }) => {
await this.releaseMessageReservation(message, reason);
if (message.batchTask?.sourceType === 'cmpp') await this.recordCmppFailureReceipt(message, 'ROUTE', reason);
else await this.facade.refreshTaskProgress(message.batchTaskId);
if (message.batchTask?.sourceType === 'cmpp')
await this.recordCmppFailureReceipt(
message,
code === CHANNEL_WORD_NO_ROUTE ? 'CSW' : code ? 'DRN' : 'ROUTE',
reason,
);
else {
await this.facade.refreshTaskProgress(message.batchTaskId);
if (code === CHANNEL_WORD_NO_ROUTE)
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { channelWordFinalizationPending: false },
});
}
results.set(message.id, { submitted: false, messageRecordId: message.id, status: 'failed', reason });
this.metrics?.recordSendWorkerResult('failed');
}),
@@ -620,19 +686,40 @@ export class SendGatewaySubmitService {
finish(result.submitted ? 'completed' : 'skipped');
return result;
} catch (error) {
if (error instanceof Error && 'getStatus' in error && (error as { getStatus(): number }).getStatus() >= 500)
throw error;
const code =
error instanceof DrainageRejection || error instanceof ChannelWordRejection ? error.reasonCode : undefined;
const reason = error instanceof Error ? error.message : '无可用通道组或通道';
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { status: 'failed', errorMessage: reason },
data: {
status: 'failed',
errorMessage: reason,
errorCode: code,
drainageReceiptPending: Boolean(
code && code !== CHANNEL_WORD_NO_ROUTE && message.batchTask?.sourceType === 'cmpp',
),
channelWordFinalizationPending: code === CHANNEL_WORD_NO_ROUTE,
},
});
await this.releaseMessageReservation(businessMessage, reason);
if (message.batchTask?.sourceType === 'cmpp') {
await this.recordCmppFailureReceipt(businessMessage, 'ROUTE', reason);
await this.recordCmppFailureReceipt(
businessMessage,
code === CHANNEL_WORD_NO_ROUTE ? 'CSW' : code ? 'DRN' : 'ROUTE',
reason,
);
} else {
await this.facade.refreshTaskProgress(
businessMessage.batchTaskId,
message.batchTask?.sourceType === 'cmpp' && message.batchTask.phoneTotal === 1 ? 'failed' : undefined,
);
if (code === CHANNEL_WORD_NO_ROUTE)
await this.prisma.smsMessageRecord.update({
where: { id: message.id },
data: { channelWordFinalizationPending: false },
});
}
finish('failed');
return { submitted: false, messageRecordId: message.id, status: 'failed', reason };
@@ -1029,8 +1116,20 @@ return streamId`;
this.facade.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, carrier, signatureId),
);
const excluded = new Set(options.excludeChannelIds ?? []);
const approvedChannelIds = new Set(route.group.items.map((item) => item.channelId));
const selected = selectChannelCandidate(route.group.items, {
const stored = await this.prisma.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } });
const gate = await evaluateMessageDrainage(this.prisma, { ...stored, signatureId }, carrier);
const approvedChannelIds = new Set(
route.group.items
.map((item) => item.channelId)
.filter((id) => gate.allowedChannelIds === null || gate.allowedChannelIds.includes(id)),
);
if (gate.targets.length && approvedChannelIds.size === 0)
throw new DrainageRejection('DRAINAGE_CHANNEL_NOT_APPROVED', '引流信息未在签名可用通道报备通过');
const channelWords = await loadChannelWords(
this.prisma,
route.group.items.map((item) => item.channelId),
);
const { selected, rejected } = channelWords.select(message.id, stored.content, route.group.items, {
carrier,
province,
forceNational: options.forceNational,
@@ -1038,6 +1137,8 @@ return streamId`;
approvedChannelIds,
routingKey: message.id,
});
await channelWords.persist(this.prisma);
if (rejected) throw new ChannelWordRejection();
if (!selected) {
throw new NotFoundException('无已报备通过且在线的可用通道');
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
import { SendingMonitorService } from './sending-monitor.module';
describe('sending alert read filters', () => {
it.each(['', 'read', 'unread'])(
'applies identical user/state/read criteria to rows and total: %s',
async (readStatus) => {
const prisma = {
$queryRawUnsafe: jest
.fn()
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ total: 0 }]),
};
await new SendingMonitorService(prisma as never).alerts({ state: 'active', readStatus, page: '2' }, 'user-a');
const [list, count] = prisma.$queryRawUnsafe.mock.calls;
expect(list.slice(1)).toEqual(['user-a', 'active', readStatus, 20, 20]);
expect(count.slice(1)).toEqual(['user-a', 'active', readStatus]);
expect(list[0].split('FROM')[1].split('ORDER BY')[0].trim()).toBe(count[0].split('FROM')[1].trim());
},
);
it('rejects unknown read states before querying', async () => {
const prisma = { $queryRawUnsafe: jest.fn() };
await expect(new SendingMonitorService(prisma as never).alerts({ readStatus: 'bogus' }, 'user-a')).rejects.toThrow(
'已读状态无效',
);
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled();
});
});
@@ -312,17 +312,23 @@ export class SendingMonitorService {
size = pageNumber(query.pageSize, 20, 100);
const state = query.state ?? '';
if (state && !['active', 'recovered', 'closed'].includes(state)) throw new BadRequestException('告警状态无效');
const readStatus = query.readStatus ?? '';
if (!['', 'read', 'unread'].includes(readStatus)) throw new BadRequestException('已读状态无效');
const from = `FROM "SendingMonitorAlert" a LEFT JOIN "SendingMonitorAlertRead" r ON r."alertId"=a.id AND r."userId"=$1 WHERE ($2='' OR a.state=$2) AND ($3='' OR ($3='unread' AND r."readAt" IS NULL) OR ($3='read' AND r."readAt" IS NOT NULL))`;
const [items, total] = await Promise.all([
this.prisma.$queryRawUnsafe(
`SELECT a.*,r."readAt" IS NULL unread FROM "SendingMonitorAlert" a LEFT JOIN "SendingMonitorAlertRead" r ON r."alertId"=a.id AND r."userId"=$1 WHERE ($2='' OR a.state=$2) ORDER BY a."openedAt" DESC,a.id LIMIT $3 OFFSET $4`,
`SELECT a.*,r."readAt" IS NULL unread ${from} ORDER BY a."openedAt" DESC,a.id LIMIT $4 OFFSET $5`,
user,
state,
readStatus,
size,
(page - 1) * size,
),
this.prisma.$queryRawUnsafe<Array<{ total: number }>>(
`SELECT count(*)::int total FROM "SendingMonitorAlert" WHERE ($1='' OR state=$1)`,
`SELECT count(*)::int total ${from}`,
user,
state,
readStatus,
),
]);
return { items, total: total[0].total, page, pageSize: size };
@@ -0,0 +1,96 @@
# 通道敏感词需求评估与实施方案
日期:2026-09-10。状态:已实现、本地提交并部署测试环境;应用8e4bc5a,验收与未执行项见测试进度。用户要求在敏感词页面新增“通道敏感词”Tab,按通道配置,短信命中时不走该通道。本方案补充[风控设计](phase-6-risk-review-plan.md)和[发送链路设计](phase-4-send-pipeline-redesign.md),不替代平台全局敏感词或[引流门禁](drainage-send-gating-plan-20260910.md)。
用户已明确基于性能考虑,第一版不做入队后的通道敏感词复核。以下方案已按“仅在选路时过滤”修订,替代初稿的逐片复核、发送授权锁及配置变化触发重选设计;既有引流门禁保持原有行为。
## 1. 结论与当前证据
可实现,属于中等规模的跨前后端、数据库和发送链路改造,不能仅增加页面筛选。核心是通道候选排除规则,不是新增全局拒绝词库。
- 当前main为8694782,应用实现提交0c3f820;实际远端main为6d63eb5452ffc7c802960d044bf598cc8646564d。已有metrics、发布工具、治理/网络/HTTP评估文档修改须保护。
- `src/apps/admin/AdminSensitiveWordsPage.tsx`仅有单列表和新增/删除,通过`/api/admin/dictionaries/sensitive-words`访问真实后端;尚无通道字段或Tab。当前页面无编辑/启停按钮,不能把后端状态接口当作已有完整页面能力。
- Prisma `SensitiveWord`仅有word、level、status等字段,word全局唯一。`risk-review.service.ts/evaluateContent`对active词使用原文`content.includes(word)`;命中统一block,当前不是按低/中/高等级选择不同动作。此事实不代表原级别设计已经正确落地,本轮不顺带改动其语义。
- `send-gateway-submit.service.ts`分别实现微批和普通选路,均先满足企业应用通道组、运营商、签名及引流资格,再调用`selectChannelCandidate`;普通路径已有排除通道集合。必须覆盖两条路径及其降级/重选调用者。
- Gateway `internal/upstream/submit.go``drainage_guard.go`已有引流资格复核,API入口为`drainage-submit-guard.controller.ts`。本需求不扩展该复核,不新增通道词Gateway请求或拒绝码;通道词排除在创建提交意图之前完成。
- 以上是本轮源码核验;未连接目标环境API/数据库验证通道敏感词功能,未启动发送、创建规则或发送短信。历史测试部署不能证明新需求已经实现。
## 2. 业务规则(建议第一版)
1. 现有列表放在“平台敏感词”Tab,保留原搜索、级别、状态和操作语义;新增“通道敏感词”Tab。两份词库独立,禁止把通道词写入全局SensitiveWord,否则会误拦其他通道。
2. 一条通道词绑定一个真实通道。A配置“贷款”、B未配置时,包含“贷款”的短信排除A,B仍需满足原有全部发送资格;剩余候选继续按原优先级、地域、权重等规则选取。不是无条件改走B,也不能越过应用绑定的通道组找其他通道。
3. 同一词可在多个通道独立配置;单通道命中任意一个启用词即排除该通道。三网通道按整个channelId生效,对移动/联通/电信都适用;本需求未要求运营商细分,不增加该配置维度。
4. 平台敏感词仍优先执行原有全局拦截。通道词命中不进入人工审核,不影响其他消息、企业配置、余额规则和报备状态;人工审核通过不豁免通道词过滤。
5. 第一版建议沿用现有敏感词的原文连续包含匹配,区分英文大小写,不支持正则、通配符、分词或自动删除间隔字符。输入词去首尾空白,禁止空词;中间字符原样保留。NFKC、忽略大小写或抗干扰匹配属于可选增强,不能直接沿用引流清洗规则而扩大拦截范围。用户已授权执行本修订方案,第一版按此规则实施。
6. 判断完整最终短信内容(含签名、变量替换后的文本、长短信重组内容),不按单片分别匹配,避免词跨片绕过。不改原文、编码、分片或计费长度。
7. 无配置/全部停用的通道不受该新增规则影响。以本次选路读取的规则快照为准:配置更新影响之后开始读取规则的选路,当前微批已经读取的快照可继续使用。已完成选路并进入提交队列的消息不再复核,即使尚未实际发出,也不因后来新增/编辑/启停/删除词而取消或换通道;已发和历史终态不重新处理。仅进入入口队列、尚未选路的消息仍在选路时检查,不能把“已入队”一概当成免检查。
8. 只有候选被本规则全部排除时,明确失败原因为“可用通道均命中通道敏感词”。原先就无有效通道时仍保留原原因,不能把离线/未报备失败归为敏感词。
9. 无可用通道时不发出,按既有路由失败处理记录、任务进度和费用预留释放;CMPP沿用原请求回执语义生成适用失败回执,非CMPP不新增拒绝回执推送。建议内部原因码`CHANNEL_SENSITIVE_WORD_NO_ROUTE`,客户协议短码独立定义并验证长度,不冒充供应商回执。
## 3. 页面与API
通道Tab独立保持通道、敏感词、启用状态三个搜索条件,查询/重置及服务端分页(默认25)。切换Tab不串筛选或请求结果;通道下拉可搜索,用真实通道ID。列表列为通道名称、敏感词、状态、备注、更新时间、操作;未知/停用通道保留历史名称及状态说明。
新增/编辑表单:通道必选、敏感词必填(建议1~200字符)、备注可选(最多500字符)、启用状态;支持新增、编辑、启用/停用、删除,第一版一次配置一个通道,不扩展导入/导出或批量多通道覆盖。删除为软删除并二次确认。停用或删除后不参与后续匹配,但历史命中快照保留。
复用公共Tab/Select/Table/Pagination/Modal;创建和编辑弹窗只能显式关闭,保留dirty提示;保存失败在弹窗内显示,不能假成功关闭。覆盖加载、空数据、失败、权限不足、停用通道、刷新和跨路由;三尺寸1600×1000、1366×768、390×844。若实施涉及CSS,先完整阅读CSS规范,页面样式归所有者,不扩大全局样式。
建议新增管理API,保持旧接口兼容:
| 接口 | 作用 |
|---|---|
| GET /api/admin/dictionaries/channel-sensitive-words | channelId/keyword/status/page/pageSize查询,返回items/total |
| POST 同路径 | 创建单条规则 |
| PATCH 同路径/:id | 修改词、通道、备注或状态,带期望version |
| DELETE 同路径/:id | 软删除,带期望version |
后端必须使用可运行时验证的DTO,不只依赖TypeScript接口。校验通道存在、状态枚举、非空词、长度、重复和版本冲突;分页上限100。沿用运营端敏感词管理入口权限,并核对全局认证/角色/权限链,客户端和无管理权限管理员不得写入,不能只隐藏按钮。配置属于平台通道,不接受客户端自报tenantId扩大访问;消息处理中的企业/应用/通道资格仍从真实记录取得。操作人从会话取,创建/修改/启停/删除记录变更前后及审计ID,不信任body中的operatorId。
## 4. 数据模型与兼容
建议新增`ChannelSensitiveWord`id、channelId外键、word、status(active/inactive/deleted)、remark、version、createdAt/updatedAt、createdBy/updatedBy。使用(channelId,word)唯一约束;删除记录重加通过受审计的恢复/更新实现,不因软删除绕过唯一约束生成含糊重复。加(channelId,status)索引;通道删除须遵守既有生命周期,不级联抹掉审计快照。
新增追加式`SmsChannelSensitiveDecision`保存messageRecordId、routeAttemptId、contentHash、使用的规则ID/version快照、候选/排除通道及命中词、判定时间、选中通道或失败原因;只记录选路阶段,不建final阶段记录。微批批量落库,同一次选路用唯一routeAttemptId保证重复持久化幂等。短信详情仅增加运营端“通道筛选原因”;成功走B也能看出A为何被排除,不依赖原规则仍存在。分页/详情限制快照规模:保存命中总数和有上限的样例,完整排除channelId集合参与算法不得截断。客户端不得泄露供应商通道词库及路由细节,仅给适用的业务失败原因。
配置变更与审计同事务,version乐观锁防止相互覆盖;数据库异常返回失败。新增表初始为空,不迁移全局敏感词、不改旧规则、不回填历史消息、不复制词到所有通道。应用回退会失去新排除规则,需评估是否允许继续发送;回退不删除决策或自动重投短信。
## 5. 仅选路过滤与性能边界
流程:原有全局风控 → 企业应用/运营商/地域/在线状态/签名/引流资格候选 → 按本次规则快照排除命中通道 → 原选路算法 → 创建提交意图 → 沿用既有发送流程。本需求不在Gateway或实际写供应商前增加通道词检查。
- 建立统一通道词评估服务,普通选路、微批及原有重选/全国通道降级共用;排除集合与原excludeChannelIds取并集。不能只检查最终选中的一个通道后直接终止,也不能仅在客户端入口检查。
- 微批按涉及channelId集合一次取词,作为本批选路快照;同内容和同规则快照复用匹配结果,决策批量持久化,不能每个号码×每个词单独查库。普通选路一次读取其候选通道词。不增加逐片数据库查询、Gateway HTTP请求或通道配置与物理发送之间的锁。第一版不引入跨批长期缓存,不承诺任意词库规模下固定TPS;按实际批大小和词库规模验证SQL次数与吞吐,必要时再优化匹配算法。
- 配置写入仍保留管理端乐观锁和审计事务,防止多人编辑相互覆盖;它不参与发送授权,也不阻塞已取得规则快照的选路。无需新增按channelId的发送共享/独占锁或配置变更触发器。
- 已选A后新增A敏感词:消息继续按原选路结果处理,不触发取消、扫描队列、逐片检查或自动重选。当前批处理期间发生变更,也不重跑本批匹配。这是用户接受的第一版生效边界,不列作绕过缺陷。
- 若现有业务因原有原因进入新的选路尝试(例如原有降级/允许的重试),该次选路使用新读取的快照并执行过滤;如果只是消费同一已确定通道的提交意图,不重复检查。保持原重试资格、次数和部分已发保护,不新增因配置变化产生的发送/补发/重新入队。
- 原有绕过业务选路的直连诊断路径不新增通道词Gateway门禁;实施时列明此覆盖边界,不能宣称所有物理提交都受新规则复核。
- 选路读取规则失败或超时:不得当空词库放行,也不能伪装业务命中;走现有技术异常有限重试/死信与告警。选路命中导致排除不创建对应通道的发送尝试,不计为供应商发送失败。
- 费用幂等释放、终态/任务进度、CMPP适用失败回执和投递意图必须可恢复;当前drainageReceiptPending仅服务引流拒绝,不可不加审查复用于所有原因。实施需为新原因扩展通用恢复标记或独立持久标记、补已有回执缺失投递的恢复测试。终态回执只生成一次,中途排除A后B成功不得提前给客户失败回执。
## 6. 范围、验证成本与实施顺序
范围为一页及其API/types、词库/选路决策迁移、统一评估服务、普通/微批选路及原有重选调用者、无路由失败恢复、运营端详情和相关测试文档。移除通道词最终Gateway门禁、逐片复核、配置与发送并发锁,以及由最终拒绝触发的结果协议/重选改造。没有新增报备、审核、客户自助词库、批量文件导入、通道组级词、运营商细分或正则编辑器;这些须另提需求。
建议先实现配置/迁移及隔离数据库用例,再实现选路过滤/无路由失败处理,最后接UI和真实页面回归。用户已选择仅选路检查,按该边界即可作为第一版完整交付。验证重点为普通/微批选路一致性、规则快照生效边界、批量性能、决策落库和全候选排除时的回执/费用幂等;未做环境及数据量基准,不给固定工时/TPS承诺。
实施验收按[系统用例](system-functional-test-cases.md)TC-CHANNEL-WORD-0112的修订版本执行:API/前端定向和相关全量、类型、production构建及质量门禁;真实PostgreSQL/Redis及生产构建浏览器验收。本方案无Gateway代码变更,若实际改动范围不涉及Go,不为本需求新增Go改造或强制重跑Go门禁。mock仅隔离测试,不能证明供应商零Submit或客户收到回执。实际发送、客户配置写入、提交/推送/两环境部署均另按明确授权,方案和用例存在不构成执行授权。
## 7. 实施前确认建议
已明确需求是独立Tab、按通道配置、命中排除通道,并且第一版仅选路检查,不做入队后复核。建议第一版采用原文连续包含匹配、单channelId覆盖三网、全部候选排除时失败而非待人工审核。若用户要求抗干扰匹配、按运营商区分或无通道时等待恢复,应先修改这些规则。用户随后已授权按本方案实施、提交代码并部署测试环境;未授权推送或预生产部署。网络绕过任务的未完成状态不因本需求实施改变。
## 8. 2026-09-10 实施与验收
已实现独立ChannelSensitiveWord管理API与Tab,运行时校验、平台管理员权限复核、服务端分页、版本乐观锁、软删除/恢复及同事务审计。普通选路和微批共用ChannelWordSnapshot;每批一次读取候选通道active词、按完整原文复用命中结果、一次批量写决策,再使用原通道选择算法。未改Gateway、逐片授权或既有引流最终门禁。
实际数据字段:SmsChannelSensitiveDecision的snapshot JSON保存contentHash、readAt、候选/排除ID、命中总数及每通道最多20个词样例(ID/word/version/name),routeAttemptId唯一防止同次重复落库。运营端详情返回最近10次选路记录,客户端白名单映射不返回本字段。词库为空也记录选路快照;不承诺无限词库规模的固定TPS。
全候选排除内部码CHANNEL_SENSITIVE_WORD_NO_ROUTE,适用CMPP平台回执码CSW3字符),状态REJECTD/undelivered,沿用原Registered_Delivery、长短信和去重规则。新增channelWordFinalizationPending默认false和部分索引,在失败状态持久化时置true;复用已有10秒恢复扫描,保证费用预留释放、任务进度、CMPP适用回执/意图完成后才清除。非CMPP仅恢复释放/进度,不新增拒绝回执推送。迁移新增两表和一列,不转换既有业务配置;回退应用不删数据或重投,回退后新通道词规则不再生效。
本地证据:API全量69套745项通过,后追加非CMPP恢复测试后对应发送链138项通过;前端28套139项通过。API类型/构建、前端production构建、lint(既有28警告,无错误)、格式、CSS、依赖安全、bundle门禁通过。新增tools/testing/verify-channel-sensitive-words.mjs在隔离本地PostgreSQL克隆中验证迁移、真实管理服务并发、普通/100条微批选路、失败SQL耐久标记、原文不变和客户数据隔离;微批通道词SQL读取1次、决策写1次,样本390~441ms包含既有引流处理,不是物理发送TPS。
生产构建+真实Nest API+隔离PostgreSQL/Redis的Edge浏览器通过三尺寸新增/编辑/启停/软删除、Tab筛选保持、显式关闭、刷新/跨路由及历史选路原因;pageerror=0。浏览器鉴权使用真实Redis7隔离前缀,未启用短信Worker或Gateway传输;fixture的在线状态仅用于隔离路由验证,不作为真实供应商连接证据。实际短信发送、供应商零Submit、客户回执ACK、费用流水闭环及实际吞吐未执行,不以隔离结果替代。
发布收尾:测试应用8e4bc5a已完成标准部署和真实页面只读验收,详见[发布报告](release-20260910-test-channel-sensitive-words.md)。未推送、未部署预生产,未保存线上通道词或发送短信。
+179
View File
@@ -0,0 +1,179 @@
# 引流信息拦截与通道报备匹配方案
日期:2026-09-10。状态:已按后续授权实施,本地代码与隔离数据库/API验证完成,提交及测试发布状态见测试进度最新记录。授权为修改、本地提交、测试环境部署;未授权推送、预生产部署或真实短信发送。第2节保留设计阶段基线,第10节为实施事实。
## 1. 结论、范围及设计关系
该需求可实施,属于发送链路与风控规则变更,不是增加一个页面开关。复用已有引流资料、检测规则、签名报备、客户回执和费用处理能力;补齐统一规范化匹配、多引流关联、通道资格交集和可靠拒绝闭环。必须同时覆盖普通与批量快速入口、长短信合并、定时发送、审核释放及最终通道路由,不能只修改 resolveDrainageInfoMatch。
本方案是 [风控审核方案](phase-6-risk-review-plan.md) 的专项补充,关联 [发送链路设计](phase-4-send-pipeline-redesign.md)、[通道报备方案](phase-4-channel-reporting-plan.md)、[计费方案](phase-5-billing-plan.md)。实施生效后,替代 [需求](first-version-development-requirements.md) 中现有“本期只识别、记录、查询和统计”“引流审核/报备不得拦截”的规则,以及对应旧用例中的允许发送断言;历史实施记录保留,检测、高亮、统计能力继续保留。旧规则目前仍是代码现状,写方案不等于已启用拦截。
不新增独立引流登记入口,不自动添加资料或审核通过,不修改短信内容,不改企业余额/路由配置,不恢复关闭账号,不执行发送/补发/重新入队。后续实施、本地提交和测试部署以用户明确指令为准;历史短信重发仍未授权。
## 2. 当前证据与缺口
核验基线:本地 main / HEAD 为 5bcdbb2a03637b1ab1aeda59a4e1db9cc9fc4243;实际 ls-remote origin main 为6d63eb5452ffc7c802960d044bf598cc8646564d,本地领先1提交。暂存区空;保护原有19个跟踪修改和全部未跟踪文件。
| 现有证据 | 代码现状 | 本次缺口 |
|---|---|---|
| api/src/send-chain/drainage-content-detection.ts | 检测副本做NFKC及分类清洗,保留原文位置;记录多个matches;规则缓存30秒;最多20000字符/50命中,有truncated标记 | 检测结果未统一成为发送资格;截断和规则故障不能作为无引流放行 |
| api/src/common/drainage-target.ts | 登记值做trim和格式校验,未形成与检测一致的类型化规范键 | 02177882277与021-77882277在旧匹配中不等价 |
| api/src/send-chain/send-inbound-entry.service.ts | 普通路径使用原文includes,取最长单条;微批路径另有同类实现 | 未命中不拒绝;多项匹配不完整;两套路径易出现差异 |
| api/src/send-chain/send-chain.helpers.ts | drainageRejectionReason明确废弃并返回undefined | 不能简单恢复旧函数就宣称全入口拦截完成 |
| api/prisma/schema.prisma | SmsDrainageInfo含tenantId/signatureId/applicationId/url/auditStatus/materialVersion;消息只有单个drainageInfoId,另有drainageDetection JSON | 单个外键不足以表达多目标、多个匹配资料和每通道资格证据 |
| api/src/send-chain/send-gateway-submit.service.ts | 批量路由读取reportType=signature的approved任务;最终签名检查也有独立方法 | 需接入引流报备资格,不能把存在方法当成所有执行路径均调用 |
| api/src/send-chain/send-downstream-delivery.service.ts | recordCmppFailureReceipt写failed/undelivered/REJECTD和平台回执,再调用queueFinalReceiptDeliveries | 按提交来源区分:CMPP沿用既有分发,非CMPP拦截不推送;需验证长短信及请求回执标记;当前先查再写与已有回执直接返回不能直接宣称并发/崩溃可靠 |
| api/src/sms-config/drainage.service.ts、admin-sms-config.controller.ts | 企业签名下新增引流;真实资料审核及通道报备目标API已存在 | 复用关联,不能使用页面三网汇总、材料齐全或跨签名同值代替发送授权 |
证据为当前源码、Prisma模型与真实远端Git读取。本轮未启动应用服务、连接业务数据库或查询远端API;不作当前数据规模、报备覆盖、运行版本或线上行为结论。PostgreSQL/Redis/MinIO/Gateway状态及数据库实际索引需在实施前只读复核,历史交接数据不代替此次实测。
## 3. 业务判定规则
### 3.1 两道资格检查
1. 先沿用现有认证、企业应用和签名解析,签名必须属于当前企业及应用的有效授权范围;客户端传入的signatureId/drainageInfoId不能作为放行依据。
2. 对最终完整短信内容执行检测,包含模板变量替换及CMPP长短信合并后的内容。未识别出引流且检测完整时,继续原发送流程。
3. 识别出引流后,每个不同目标均须在当前有效签名下找到匹配引流资料。仅其他签名、其他企业或其他应用登记的同值不能借用。
4. “已添加”解决关联存在;平台审核和通道报备是另外的资格。用户已确认沿用既有资料审核链:deleted/pending/rejected不能作为可发资料,只有auditStatus=approved且材料有效的资料可参与通道匹配。仅添加但未审核通过不能发送。
5. 最终通道须同时满足应用当前路由、号码运营商、签名报备及每一个引流目标的报备资格;再走既有连接、地区、优先级、限速、额度等判断。引流通过不豁免其他门禁。
6. 一条短信包含多个目标时全部检查;同一目标重复出现只判定一次,保留全部原文位置。任一目标无对应资料即拒绝整条业务消息,不允许只匹配第一项/最长项。批量发送按每条业务消息记录判定,不牵连其他合法消息。
### 3.2 规范化与原文保护
实现共享、带版本的纯函数,登记侧与检测侧使用同一规则,保留类型、原值、规范值、原文起止位置。NFKC及清洗仅作用于比较副本;SmsMessageRecord.content、模板渲染结果、编码、计费长度、CMPP提交字节均使用原文。高亮位置仍指向原文,覆盖全角和代理对字符。
- 电话类:NFKC后清洗空白、横线及现有电话检测定义的干扰标点,再按完整号码比较。021-77882277与02177882277等价;不使用号码子串contains,不把77882277与02177882277自动等价。国家码、区号和分机不擅自补齐/删除,首期只有明确干扰字符清洗,不做号码归属推断。
- URL类:NFKC及明确的全角标点映射,清理零宽干扰字符的具体白名单需固定测试;保留host中的横线、点和path/query的结构字符,不沿用电话清洗。不把跨空格的两个目标拼成一个URL。协议/host按解析结果处理大小写,path/query保留大小写;不默认解码百分号或把+当空格。
- 邮箱仍沿用现有排除规则,不能从邮箱中截出域名或数字冒充引流;其他检测分类未经定义规范化和匹配策略时视为“不支持判定”,不悄悄忽略。
- 内容超限、命中截断、规则不可用、无有效规则或正则执行异常:不得以空matches判为放行;记录技术校验失败、停止向供应商提交,走有界重试/失败告警,区别于“客户未报备”。不得新增无限扫描或不受约束正则。
### 3.3 URL域名层级匹配(用户已明确)
用户补充确认:报备父域名后,其下三级、四级及更深子域名可使用,后续路径和参数不受影响;lisglo.cn.evil.com不是lisglo.cn的子域名,不允许。此规则取代此前“任意字面包含”的初稿,不再将URL包含边界列为待确认项。
提取完整URL目标,使用URL解析器取得hostname,不对整条URL做includes。缺少协议的目标仅在解析副本中补协议;规范化NFKC、host小写、国际化域名统一转换及可选单个末尾根点后,以标签边界判定:
~~~text
candidateHost === registeredHost
|| candidateHost.endsWith('.' + registeredHost)
~~~
报备lisglo.cn,等于该域或任何层级子域均匹配;报备sms.lisglo.cn,则只匹配它自己及a.sms.lisglo.cn等更深子域,不反向授权lisglo.cn或其他兄弟子域。不要靠字符串中的点数推断“一、二级域名”,以实际登记的合法域名作为授权根;不允许登记cn、com、com.cn等公共后缀来授权无关企业域名,实施时使用可维护的公共后缀数据校验,不能仅维护这几个示例。
对于纯域名报备,协议、端口、路径、query参数及fragment不参与域名资格比较;这仅表示引流域名匹配,仍受已有发送规则约束。例如https://a.sms.lisglo.cn:8443/1yhf7e87?x=1#top可匹配lisglo.cn。userInfo必须由解析器与hostname区分,不能将@前的内容当主机名;畸形URL不通过解析,不使用正则截出其中合法片段放行。
| 登记域名 | 短信中的目标 | 域名匹配结果 |
|---|---|---|
| lisglo.cn | lisglo.cn | 通过 |
| lisglo.cn | sms.lisglo.cn/1yhf7e87 | 通过 |
| lisglo.cn | a.sms.lisglo.cn/path?x=1&y=2#top | 通过 |
| lisglo.cn | lisglo.cn.evil.com | 不通过 |
| lisglo.cn | evillisglo.cn | 不通过 |
| lisglo.cn | evil.com/?next=lisglo.cn | 不通过 |
| lisglo.cn | https://lisglo.cn@evil.com/path | 不通过 |
| sms.lisglo.cn | a.sms.lisglo.cn?x=1 | 通过 |
| sms.lisglo.cn | lisglo.cn或other.lisglo.cn | 不通过 |
检测必须保留完整目标边界,不能把lisglo.cn.evil.com识别成lisglo.cn后再比较;参数内域名也不能成为另一个可独立授权外层URL的匹配结果。检测器与解析器均需相应失败回归。任何“通过”仅指域名关联匹配,尚须审核有效及相应通道报备通过。
现有资料可登记带协议/路径的完整URL:不能在未说明的情况下把历史路径级报备自动扩大为整个host授权。建议新增/确认纯域名报备使用上述规则;历史带路径资料标注为兼容待处理,先盘点,再决定保留路径限定或经业务确认升级为域名级。路径级资料的迁移策略仍待明确,但不影响本次已确认的纯域名报备行为。IP地址不是域名,不适用子域后缀规则,若支持则按完整规范IP相等。不会访问URL、跟随重定向、解析短链或发起HTTP探测。
## 4. 多目标与通道选择
对目标t求出同一签名下全部匹配且有效的资料集合 M(t)。允许多个匹配项,不再用最长匹配及更新时间任选唯一项;一个目标可由其中任一有该通道有效报备的资料证明,但审计必须记录实际使用的资料ID和报备任务ID。
设R是当前应用/运营商的可路由通道集合,S是签名已报备通道集合,D(d)是资料d已报备的通道集合:
```text
C = R ∩ S ∩ 对每个目标t求交集(对d属于M(t)求并集D(d))
```
示例:目标A可走通道1/2,目标B可走通道2/3,签名可走1/2/3,则仅可走通道2。不能把A与B分开送往不同通道,也不能拼接不同企业/签名的授权。
报备事实使用ChannelSignatureReportTasksignatureId一致、channelId一致、reportType=drainage、drainageItemId指向匹配资料、status=approved;不能只看签名任务或前端三网绿色状态。报备批准必须对应当前有效材料,资料修改/删除及waiting_review、pending、rejected、failed、abandoned均不能继续借旧通过记录放行。
报备配置边界(用户已确认):旧通道报备配置由用户负责调整,本次不迁移、不补齐、不推断旧记录应覆盖哪些运营商,也不自动重置报备状态。开发保证现有报备配置页面/API的查看、修改、保存、刷新和发送资格读取正常;按用户当前有效配置及通道支持范围判断,无有效资格则不发送。旧记录保持可查看、可操作,不因字段为空导致页面崩溃或无法保存;旧配置盘点/兼容改造不作为本次实施前置条件。
C为空时区分“引流未报备到可路由通道”和“报备合格但通道离线/限速”等情况,保留不同原因。选中失败、切换通道、自动重试都不得扩展到C之外;从未通过的通道不能成为兜底。
## 5. 处理流程、状态与回执
1. 各入口完成协议/认证校验并按既有耐久受理契约保存消息。入口受理成功与供应商发送成功分开;不能为业务拒绝同时返回不受理又伪造已受理的终态回执。
2. 用统一服务提取全部目标、规范化、校验签名关联并保存决策;必须在首次供应商提交前执行。定时任务到期、审核释放及已有排队消息均按生效策略重新检查,不信任旧单个drainageInfoId。
3. 规划通道时按第4节求交集;最终生成Gateway提交意图前复核资格版本,批量和单条路径共用实现。入口预检用于提前反馈,最终门禁为权威判定。
4. 各入口业务拒绝均保存消息failed和可读原因;CMPP来源再复用平台未送达回执路径,记录receiptStatus=undelivered、receiptRawStatus=REJECTD。非CMPP来源保留失败记录和任务进度,不为本次拦截新增回执记录或推送。建议内部原因码为DRAINAGE_NOT_REGISTERED、DRAINAGE_NOT_APPROVED、DRAINAGE_CHANNEL_NOT_APPROVED;这些是待新增内部码,不能直接把长字符串塞入CMPP固定长度字段,协议短码须核对现有映射并补兼容测试。
5. 消息状态、拒绝证据、费用结算,以及CMPP来源适用的回执投递意图需具备事务/耐久幂等;建议以messageRecordId+终态业务拒绝建立唯一键。已存在回执但尚未创建/发送下游投递时必须能恢复,不能因早返回丢失回执,也不能并发重复退款。
6. 用户已确认按提交来源沿用签名未报备行为:CMPP提交沿用现有失败回执分发、原请求标记、长短信关联和接收配置;非CMPP提交本次不推送回执,即使应用配置了HTTP回调也不得因引流拦截新建投递。实现必须在来源分支处约束,不能对全部入口无条件调用会生成HTTP投递意图的统一回执函数。CMPP来源现有配置允许的分发行为不另行改动;接收方离线等情况留真实投递状态,不伪报成功。不修改其他正常送达回执或上行推送功能。
7. 无供应商Submit不得产生供应商成本。入口尚未扣费则不扣;已扣/冻结的消息按既有签名/路由失败计费策略幂等退回或解冻。上线前对不同入口当前扣费位置逐一对账,不新建独立余额调整捷径。
8. 拒绝后补登记/补报备不自动释放已失败短信。回执重试仅恢复回执投递,不重发短信。短信发送、补发和重新入队须另有专项授权。
## 6. 数据、API与页面
复用SmsDrainageInfo和ChannelSignatureReportTask作为授权事实,不另建重复登记库。建议增加规范化类型、规范值及normalizationVersion用于索引和诊断,初期可由共享函数实时计算;是否落列取决于只读规模与执行计划,不在方案阶段执行迁移。历史原始url字段保留,规范键冲突先报告,不能自动合并、覆盖或继承另一条资料的报备状态。
消息需要多目标关系与不可变判定快照。建议新增SmsMessageDrainageMatchmessageRecordId、targetKey、匹配资料ID、实际采用的报备任务ID、carrier、channelId、资料/规则版本、decisionId),按一次决策与目标/资料建立唯一约束;未登记目标也需由快照保留,不能因无外键而丢证据。快照含原文位置、规范值、全部候选资料、最终采用项、原因、时间和策略版本,独立于既有drainageDetection检测JSON。现有drainageInfoId只保留兼容单目标历史展示,不能作为新门禁真相来源。
多引流会影响报备今日发送、引流质量和统计SQL:按message/submit ID去重后关联相应引流,单条短信可以归属多个引流,但首页消息数/客户分片/计费不得因此倍增。明确交叉归属统计不可直接相加,更新说明与查询,不在旧最长外键上冒充完整多引流统计。
复用接口:POST /api/admin/enterprise-signatures/:id/drainage-infos、PUT /api/admin/drainage-infos/:id、GET /api/admin/drainage-infos/:id/report-targets及对应client入口;继续服务器端租户/应用/签名校验。新增规范字段由服务端计算,客户端不得提交已通过判定或通道白名单。发送API不要求客户新增放行参数;消息详情API增加只读判定结果,历史未判定明确显示“未执行引流资格校验”,不默认通过。
企业签名管理仍负责登记/审核/报备;短信记录保留发送状态、原文和既有详情入口,在失败原因及详情展示具体未登记目标、对应签名、未通过通道原因。客户端只能看本企业证据,跨租户对象按不可见处理。不得输出内部凭据、完整路由配置或不相关客户资料。复用公共Table/Tag/Modal,错误/空态/加载/权限分别呈现,关闭方式沿用显式关闭规范;不改CSS和页面布局作为前置条件。
## 7. 一致性、性能与兼容风险
- 统一判断入口,CMPP微批必须按企业/应用/签名批量读取资料和批准事实,禁止按每号码×每目标×每通道做N+1查询;缓存以版本为键,仅用于候选,不以30秒旧缓存授予最终发送资格。
- 资料审核/修改/删除、通道报备撤销与发送意图创建需共同的锁或版本并发协议;建议同签名授权版本作为一致性锚点,所有写入口包括导入/批量状态变更都递增并参与校验。固定锁顺序,测试多Worker并发。
- 生成意图后到物理发送存在分布式窗口:已在Gateway队列但未发出的旧意图必须复核版本或取消,不能只在API检查后宣称“撤销即刻阻断”。实施需盘点Gateway/Redis提交消费者;若现契约不足,增加版本验证及耐久拒绝返回。已实际发出的短信不能撤回,不能伪造为未发送。切换生效时先暂停领取并处理在途意图,定义明确生效边界。
- 规则异常、数据库/Redis不可用应阻止提交并可恢复,禁止吞错放行;校验技术错误与客户未报备分开计数,避免误记客户违规。重试次数、超时沿用现有调度上限并留告警。
- 旧已终态消息不补判不回写;待发送、定时和待审核消息在生效后执行新判定;已有已发部分分片/提交尝试的消息单独列为在途,不能当首次拒绝统一全额退款。
- 实施按需核对规范化与表/索引规模;不开展旧通道报备配置盘点、自动迁移或补齐,旧配置由用户调整。可用历史内容离线评估,不调用发送入口,不修改短信状态。无覆盖率证据不承诺无影响上线。
- 回退到只识别旧版会绕过新规则。策略启用后回退须停止发送Worker/提交消费,保留决策与失败回执事实;不能因回退恢复已拒绝任务。启用/回退另行遵循标准发布入口和授权。
## 8. 实施拆分与验证成本
| 阶段 | 交付及依赖 | 验证重点 |
|---|---|---|
| A | 落实已确认域名规则、审核和按提交来源的回执规则;核对配置入口 | 保证用户可正常配置,不处理旧报备配置,不发送 |
| B | 共享规范化/多目标匹配及消息证据模型,历史兼容迁移 | 号码、URL、全角干扰、冲突、多项及原文不变;迁移回退 |
| C | 所有入口/快速批量/最终路由/重试接入;并发授权版本 | 多目标通道交集、撤销窗口、定时及审核释放、Redis与Gateway契约 |
| D | 终态、费用、可靠回执及详情/统计展示 | 幂等、崩溃恢复、CMPP回执/非CMPP不推送、租户隔离、统计去重 |
| E | 隔离环境全链路与回归,取得专项许可后发布 | PostgreSQL/Redis/Gateway模拟器、MinIO相关资料验证、浏览器与容量对比 |
复杂度中高,主要成本在多入口一致性、多引流数据模型和拒绝回执/费用闭环,单一正则修改不足。暂不承诺工期/TPS;完成阶段A后按实际消费者数量、存量数据和迁移规模估算。未来实施按测试计划运行API/前端定向及全量、类型/构建、格式/样式/包体门禁;涉及Gateway执行Go测试和vet,涉及迁移/发布执行对应门禁。真实发送模拟验收也须先获得明确专项授权,不能以测试方案存在代替许可。
## 9. 已确认事项、实施核查与完成标准
已确认:纯域名授权自身及所有层级子域,路径/参数不限制;资料必须已经添加到对应签名且平台审核通过;引流拦截参考签名未报备的处理。
以下用业务语言说明,不把内部数据兼容问题转成用户必须理解的审批项:
- 旧资料如果存的是https://lisglo.cn/app,而不是lisglo.cn,问题只是“它是否也允许lisglo.cn/other”。这与新登记纯域名后的子域/参数规则不同。建议按域名使用的目标统一考虑;实施先盘点实际是否存在这种旧资料,再明确处理,不能把尚未证实的数据情况当成阻塞。
- 旧通道报备配置由用户调整,本次只保证配置入口和保存后读取的资格判断正常,不处理旧配置。
- CMPP提交沿用签名失败回执;非CMPP提交不推送回执,是用户确认的正常规则,不是缺陷。将来如需非CMPP拦截回执,由用户另提需求。
### 9.1 本轮补核的签名未报备失败路径(源码证据)
send-gateway-submit.service.ts的批量failRouteBatch及单条路由失败处理均写消息failed和失败原因、释放费用预留。对于sourceType=cmpp,调用recordCmppFailureReceipt,使用ROUTE错误码,记录undelivered/REJECTD平台失败回执,再交由统一回执分发。普通路由原因可能为“无已报备通过且在线的可用通道”,其中包含签名资格/在线状态,不能把所有ROUTE失败都说成签名未报备。
CMPP回执分发按客户原提交及长短信分片的registeredDelivery标记:请求回执才生成对应CMPP投递;历史null按既有兼容默认处理。HTTP分发仍需应用可投递、HTTP回执配置开启及有效地址,沿用现有配置,不自动开启接口或添加地址。
现有非CMPP路由失败分支只刷新任务进度,没有调用统一失败回执方法。用户已确认这是预期行为,撤销初稿将其列为“缺口/闭环修复”的判断;本次不修改该行为。引流拦截同样按提交来源分支:全部记录失败及原因,仅CMPP来源进入适用的既有失败回执流程。此处是源码核验,未执行真实发送复现。费用释放、记录、CMPP回执队列创建与成功送达分别验收,非CMPP验证无新增投递。
验收用例见 [系统功能用例](system-functional-test-cases.md) TC-DRAINAGE-GATE-0116,全部待实现/待执行。验收需要保留原文和发送字节对比、PostgreSQL拒绝/授权/费用事实、供应商Submit为0的证明、CMPP来源适用的下游回执ACK/接收及持久化状态,以及非CMPP来源无拦截回执投递的证据;仅HTTP200、mock或记录failed均不足以证明拦截闭环完成。
## 10. 2026-09-10 实施落地与验收边界
本节描述本地实现,取代上文的待实现状态;不把本地验证写成线上生效。统一服务 drainage-authorization.ts 对全部目标判断,微批一次读取签名资料并按企业/应用/签名隔离,普通路由及换通道共用门禁;旧最长匹配仅作兼容归属,不再决定是否放行。使用 tldts 公共后缀数据检查域名边界,域名按自身或点边界子域匹配;手机号清洗后完整比较。检测副本保留原文位置,不改消息原文、分片长度或计费。配置资料保留原值,不迁移旧通道批准、不扩大历史带路径资料授权范围。
数据实现选择独立的 SmsDrainageDecision 追加式 JSON 决策表,而非逐目标关系表;每份快照保存全部目标、资料版本、报备任务、候选通道、时间和原因,避免单外键表达不了多目标。SmsMessageRecord.drainageGate 保存最新判定供详情使用,SmsSubmitRecord.drainageGate 保存该尝试首次最终许可的快照,后续撤销不覆盖该尝试证据。旧终态消息不回填;历史未判定显示“未执行引流资格校验”。报备统计从尝试快照读取多目标,按运营商聚合;无实际写入的 DRN 拒绝不算通道发送尝试。质量统计只在引流维度展开,其他维度不倍增。
Gateway 每个分片等待可用连接后通过仅本机直连的 POST /api/gateway/events/authorize-drainage 复核真实消息、提交ID、通道及完整内容 SHA256。转发头请求拒绝,客户端不能提交白名单。校验读取新规则、平台审核、签名及引流批准,不使用30秒规则缓存授予最终许可。数据库触发器覆盖资料/报备的新增、修改、删除,与资格事务使用同签名 advisory lock;签名行及规则表有读锁。提交事务取得许可后对应的物理写入属于在途,不能承诺已经取得许可的网络写入被撤回。无引流的独立通道测试保留原路径,含引流但未关联签名仍拒绝。已终结提交不重新授权。
业务拦截内部原因使用 DRAINAGE_*CMPP沿用 REJECTD 与短码 DRN;技术不可用使用 DRNCHK,并在尚未写任何分片时交给现有Worker有限重试/死信策略,不当作客户未报备违规。已有部分分片的尝试保留分片事实,不自动另选通道重发。资料为空、规则为空/异常、检测截断均不能当成无引流放行。
本次修复的回执崩溃窗口:原方法遇已有平台回执直接返回,可能漏建投递。引流业务拒绝在终态写入时同时保存 drainageReceiptPending,沿用原幂等费用释放;回执按 receiptKey upsert,已有回执仍补齐幂等下游意图,持久化失败不清标记。Worker每10秒恢复最多50条本类CMPP拒绝的回执;仅补回执,不重新发送短信。HTTP/CMPP下游各沿用既有唯一键。非CMPP路由拦截不调用该方法,不新增回执或推送。
新增迁移 20260910130000_drainage_send_gate 只加列、索引、决策表及锁触发器,不改既有批准/客户/余额/短信记录。测试发布按标准工具执行,包含此前九项运营修复提交;回退旧程序会失去本门禁,不能未经评估恢复发送。原治理工具草稿和备份/候选均保留。
验证:独立本机 PostgreSQL 克隆库完成新迁移,真实规则/API验证 NFKC号码、全部目标交集、审核撤销、报备撤销、并发锁等待、URL三种伪装拒绝、决策持久化及报备SQL。真实浏览器连接该API验证拦截详情、刷新、路由切换和1600×1000、1366×768、390×844;无Browser插件,使用既有Playwright/Edge。发送Worker与Gateway传输未启动,不以这些证据替代供应商零Submit、客户回执ACK、长短信物理发送、费用对账或容量测试,以上须专项发送授权后验证。自动回归及发布结果以 testing-progress.md 最新记录为准。
@@ -2244,3 +2244,31 @@
### 2026-09-08 夜补充:公共容量控件与顶栏通知可靠性
通道报备明细及签名质量四Tab统一使用公共Pagination,去掉可见“每页数量”文字,仅显示容量选项,保持可访问名称、10/25/50/100、默认25和各Tab独立日期。运营顶栏通知刷新应合并重复触发、限制在途批次、超时取消、隐藏/离线/锁定暂停和有上限的失败退避;计数失败保留上次真实值并明确暂不可用,不能归零冒充成功。实现与浏览器外部注入问题边界见operations-fixes-20260908.md“浏览器异常与通知刷新修复”,不改业务端口、后端计数口径或短信链路。
## 2026-09-09 运营九项修正
用户确认需求及实现范围见 [九项修复设计](operations-fixes-20260909.md)。通道报备发送统计按实际运营商分开;创建/修改弹窗默认仅显式关闭;签名活跃度的企业、应用、签名、通道独立组合筛选;系统监控增加日期可选、默认近7日历史;首页客户分片按唯一业务消息汇总;发送质量告警已读计数与阅读筛选;详情行去通道组重复文案;HTTP地址随开关显示,保存校验错误居中;清退预警展示去“请通知 企业:”。本节客户分片口径替代旧供应商分片总数口径,到达率仍沿用供应商分片分子/分母。
## 2026-09-10 引流发送资格与通道匹配(待实施)
新增需求:完整短信经NFKC及分类干扰字符清洗后检测引流;每个目标须匹配当前企业应用/签名下的登记资料,号码规范值完整匹配,URL按hostname等于登记域名或为其任意层级子域名匹配,路径和参数不限制,排除lisglo.cn.evil.com等伪包含,短信原文不得修改。最终只能选同时满足签名及全部引流信息报备通过的通道;不满足时不向供应商提交,记录原因并走未送达客户回执闭环。方案、旧规则替代范围、URL包含边界与待明确事项见[专项方案](drainage-send-gating-plan-20260910.md)。本需求在实施启用后替代前文“引流只识别不得拦截”,不表示当前已上线;未授权本轮开发/提交/部署。
2026-09-10补充确认:引流资料仅添加不够,必须平台审核通过。回执参考现有签名未报备处理及客户原接收配置;源码核查发现非CMPP路由失败缺少统一失败回执调用,实施时纳入相关闭环修复和回归,不冒称当前HTTP一定推送。详见专项方案9.1。
2026-09-10最终澄清:旧通道报备配置由用户处理,本次不迁移/补齐/推断旧配置,保证配置页面/API可正常操作和资格读取。非CMPP提交遭拦截不推送回执,属于预期规则,撤销前条“纳入闭环修复”的要求;CMPP沿用签名未报备的既有失败回执行为。非CMPP回执需求以后另提。
## 2026-09-10 引流发送资格实施确认
后续已授权修改、本地提交及测试部署。以 drainage-send-gating-plan-20260910.md 第10节为实现说明,替代此前“未授权实施”的阶段性描述。每个引流目标须匹配本企业应用/签名审核通过的资料并满足最终通道报备;NFKC及干扰清洗只用于检测,域名自身与子域按点边界匹配,纯域名不限制路径参数。非CMPP拦截不推送回执;CMPP失败回执需幂等且可恢复。旧报备配置仍由用户处理。是否线上生效及未执行发送验收见 testing-progress.md。
## 2026-09-10 通道敏感词需求评估(待实现)
敏感词页面新增“通道敏感词”Tab,按真实channelId独立配置;完整短信内容命中启用词时仅排除对应通道,剩余通道继续遵守原应用通道组、签名/引流报备、运营商、地域和优先级。用户明确第一版仅在普通/微批选路及原有新选路尝试时过滤,不增加入队后或逐片复核;已选路消息按原结果继续处理,配置变化不主动取消或重选,已读取快照的微批可继续使用。尚未选路的入口队列消息仍在选路时检查。现有平台敏感词保持全局拒绝,两份词库不混用。全部候选被排除时记录原因并沿用适用失败处理,不跨通道组强行发送。建议首版原文连续包含、单通道覆盖三网、无通道时失败;匹配增强等建议待实施确认。完整页面/API/数据模型、选路快照与性能边界、回执及验收见[专项方案](channel-sensitive-words-plan-20260910.md)。仅评估,不构成代码、配置写入或发送/发布授权。
### 通道敏感词实施更新(2026-09-10)
用户已授权执行修订方案、本地提交及测试部署。本地已实现独立Tab/词库、管理审计和版本冲突、普通/微批仅选路过滤、运营端历史解释及独立失败完成恢复标记。无Gateway/逐片复核;配置仅影响后续读取规则的选路,非CMPP不新增拒绝推送。实际实现与验证见[channel-sensitive-words方案第8节](channel-sensitive-words-plan-20260910.md),提交/环境状态以testing-progress.md为准。
+17
View File
@@ -0,0 +1,17 @@
# 2026-09-09 九项运营修复
状态:实现及本地验收完成,线上验收待授权部署;授权为修改并本地提交,不推送、不部署。与既有报备、发送监控、系统监控及清退设计配合,以下新口径替代旧首页分片口径;历史验收记录不改写。
1. 通道报备今日发送:保留供应商提交尝试口径,聚合键加入消息实际运营商;三网报备行分别匹配运营商。引流报备及无运营商的历史通道级报备保留汇总,未知运营商不得分摊给三网。
2. 公共 Modal 默认仅显式关闭按钮关闭,遮罩和 Escape 不关闭;创建、编辑使用统一默认,保留保存成功关闭、未保存确认、焦点陷阱及恢复。
3. 企业/通道活跃度分别提供企业、应用、签名输入;通道维度另有通道输入。条件按 AND 组合,各字段内部模糊匹配;各 Tab 独立,筛选回第一页,保留日期和分页。
4. 系统监控保留活动告警,新增独立历史查询和日期范围(上海时区,默认含今天近7日,最多31日)。读取 Prometheus 保留的真实告警时间序列,明确采样和保留周期边界;采样缺失不等于确定恢复,不伪造已读、解决时间、摘要或历史数据库记录。无数据库迁移,不写监控配置。
5. 今日消息分片数按客户业务消息 queuedAt 的上海自然日汇总 billingUnits,每个消息仅一次;不受多通道提交、补发和供应商分片变化影响。到达率继续使用原供应商分片分子/分母,避免只换分母导致超过100%;供应商分片数仅用于到达率内部计算,利润和收入口径不变。
6. 发送质量告警新增 readStatus=read/unread/空,列表与 total 同条件,并与生命周期筛选 AND。已读只影响当前用户未读活动告警数,不消除真实活动告警;保持幂等及顶栏刷新。
7. 发送详情仅移除通道发送与回执每行通道组文案,保留顶部汇总和真实通道/回执字段。
8. HTTP 开关关闭时回执、上行地址与其他 HTTP 参数一并隐藏,不清空值;保存失败和校验错误用居中 Modal 展示,保留表单内容,不把部分写入冒报为成功。
9. 清退预警明细展示去掉行首“请通知 企业名称:”,兼容已有消息;不改数据库历史内容和外发模板,不发送通知。
验收:定向失败回归、前端/API全量、类型/构建和质量门禁;真实 PostgreSQL 只读对账和真实 API/浏览器三尺寸。禁止发送短信和更改客户/通道/余额,写入场景仅隔离测试;缺少环境明确标记未验证。已有脏文件按开工副本保护,文档仅暂存本轮增量。
执行结果与限制见 [测试进度](testing-progress.md) 的“2026-09-09 九项运营修复执行结果”。本地提交仅包含本轮代码及上述文档增量;测试、预生产版本未变化。
+19
View File
@@ -69,3 +69,22 @@ api/src/risk-review/
- 阈值修改不清空计数,不自动释放待审消息;时间配置在当前夜间结束后生效,界面说明延迟生效。批量任务已有部分正常发送时,保留部分发送进度并标记存在待审核,不覆盖整批消息状态。审核与入队失败不得吞错,续发使用消息ID幂等队列任务。
- 权限沿用管理员风控配置/短信审核入口;应用必须从真实消息与企业关联取得,不能信任客户端自报企业、时间或分类;应用覆盖必须验证对象存在。历史审核记录不重写、不自动重投。回退须先停发送Worker并保留新待审及计数事实,旧版本不能继续绕过新夜间门禁。
- 验收覆盖阈值边界、多入口/多实例并发、跨午夜、应用隔离、幂等、重启、配置覆盖/变更、历史初始化、相同内容聚合、审核范围与并发、定时任务和数据库失败;使用隔离PostgreSQL/Redis证明持久化,不发送真实短信。前后端全量测试、类型/构建/质量门禁、两环境真实API与三尺寸页面验收分别留证。
## 2026-09-10 引流资格门禁(待实施专项)
详细规则、源码差异、多目标通道交集、终态回执/费用、并发与迁移见[引流信息拦截与通道报备匹配方案](drainage-send-gating-plan-20260910.md)。这是新增硬性发送资格,审核通过不得豁免引流检查;沿用既有夜间风控等规则,旧“只识别不拦截”在新功能生效后被替代。当前仅完成设计。
## 2026-09-10 引流发送门禁实施
后续用户已授权实施、本地提交及测试部署。具体数据模型、最终分片复核、并发锁、CMPP回执恢复和非CMPP无推送行为见 drainage-send-gating-plan-20260910.md 第10节;取代此前本主题仅处于设计阶段的状态。旧批准配置不迁移;线上状态与未执行项以测试进度为准。
## 2026-09-10 通道敏感词候选排除(评估阶段)
[通道敏感词方案](channel-sensitive-words-plan-20260910.md)补充独立通道词库及选路排除,保留原平台全局敏感词语义。用户基于性能明确取消第一版入队后复核:只覆盖微批/普通选路及原有新选路尝试,不增加通道词Gateway/逐片检查、发送授权锁或配置变化触发重选。已选路消息及已读取规则快照的微批不追溯更新;尚未选路的消息照常过滤。管理端编辑乐观锁、原重试与部分已发保护保持。无可用通道和技术读取失败分别处理,不能给选路中排除A但最终B可用的消息提前发送失败回执。既有引流门禁不受本次方案调整影响。当前仅完成源码评估与设计,未迁移或实现。
### 通道敏感词实施更新(2026-09-10)
用户已授权执行修订方案、本地提交及测试部署。本地已实现独立Tab/词库、管理审计和版本冲突、普通/微批仅选路过滤、运营端历史解释及独立失败完成恢复标记。无Gateway/逐片复核;配置仅影响后续读取规则的选路,非CMPP不新增拒绝推送。实际实现与验证见[channel-sensitive-words方案第8节](channel-sensitive-words-plan-20260910.md),提交/环境状态以testing-progress.md为准。
+12
View File
@@ -354,3 +354,15 @@ npm run prisma:generate、API 生产构建,以及线上 api/dist、api/node_mo
23:03:45 首页、主 JS、JSX runtime、CSS 四份公网资源均 200 且与磁盘同摘要;匿名三尺寸页面/验证码正常、pageerror=0,实际主包不含开发 React/JSX 标记。首次 CSS 失败原异常丢失;两个 rum POST 为验收主动阻断,另一次外域 GET 的真实 ERR_CONNECTION_CLOSED 来源未保留,网络原因未关闭。公开资源和匿名页面通过不替代登录后业务验收:现有会话锁定待用户解锁,未改账号。
本节更新此前“预生产待发布”的历史状态。生产模式、轮询及分页修复已交付;登录后验收、未分类网络失败与实际恢复演练分别保留未完成。证据见 %TEMP%/cmpp-starttime-pagination-20260908/public-verify 下 run-2026-09-08T15-03-44-416Z/verification.json 和 anonymous-2026-09-08T15-07-16-145Z/review.json。
## 2026-09-10 测试引流门禁发布与安全代理修复
测试100.93.204.60已由809175b544f2526891ba6d2dece1a50eadaf57a0发布至0c3f820cc92eeae8c996ac7d6d8d6db98e049ea7,标准计划20260910T053026-0c3f820cc92e-76f98ff4。用户单独批准修复cmpp-security-agent缺少运行目录:新增/etc/systemd/system/cmpp-security-agent.service.d/runtime-directory.conf,声明RuntimeDirectory=cmpp-security-agent、RuntimeDirectoryMode=0750;目录root:cmpp-security,保留原沙箱限制。模板尚未同步,未整机重启验证;本机drop-in已持久保存并通过服务重启。
[发布证据与完整资产清单](release-20260910-test-drainage.md)记录恢复点、工具摘要、13项服务/数据库/队列/真实页面验收、磁盘11.85GB→10.37GB可用和既有资产保留。测试无独立数据盘,不套用预生产迁盘结论。应用已部署、仅本地提交;未推送、未预生产部署、未发送或补发短信。
## 2026-09-10 通道敏感词测试发布
已按用户授权使用标准入口将测试应用从0c3f820更新为8e4bc5a20e4d98e96fc8572fabbac149ba12c8e6。计划20260910T075122-8e4bc5a20e4d-0e177bfe,前端/API及一项增量迁移,7个Node服务更新,Gateway保持原进程。独立备份、13服务、迁移/资源/日志/队列及真实管理员页面通过;详细恢复路径、容量增量、未执行项见[本次验收](release-20260910-test-channel-sensitive-words.md)。系统盘可用8.81GB(92%已用),资产未清理;此记录不授权清理、推送或预生产。
@@ -249,3 +249,10 @@ type InfrastructureOverview = {
- “已读”只表示某位管理员已查看某一次 Prometheus 活动告警,不是 resolve、silence 或 acknowledge 外部告警管理器;页面活动告警总数与平台健康状态仍按 Prometheus 原始 firing/pending 计算。
- 指纹由排序后的 Prometheus labels 稳定生成,`activeAt`区分同一指纹的不同触发周期。数据库以`fingerprint + userId`唯一,upsert同时更新`activeAt/readAt`;读取时只有数据库 activeAt 与当前 Prometheus activeAt 相同才算已读。
- 标记前必须回读当前 Prometheus 告警并校验指纹和 activeAt,防止客户端伪造或把已经恢复的新周期误标已读。预警中心轻量汇总只扣减当前管理员本次已读项;数据库故障不得用 localStorage 或静态状态替代。
## 12. 历史告警查询(2026-09-09
新增GET /admin/infrastructure-monitoring/alert-history,继承运营端会话鉴权,参数from/to为上海自然日,默认含今天近7日、最多31日,page正整数、每页25条。逐日查询Prometheus原始ALERTS_FOR_STATE范围向量,以标签指纹+activeAt分开触发周期,跨日采样合并;不使用粗粒度步长丢掉短周期,不把等待触发当作已发送告警。保留真实触发时间和范围内最后观测时间,最后观测不代表准确恢复。
历史和活动列表独立;历史不提供伪造恢复状态、旧annotations、批量已读或清理功能。超出Prometheus保留期/采集缺口的历史无法追溯,界面明确说明。失败返回503并展示错误,日期非法返回400;不迁移数据库、不变更阈值或采集配置。完整实现/验收及限制见operations-fixes-20260909.md。
@@ -0,0 +1,117 @@
# 通道敏感词测试发布验收(2026-09-10)
## 交付与授权
应用提交`8e4bc5a20e4d98e96fc8572fabbac149ba12c8e6`已本地提交,并于2026-09-10 16:03:45 CST部署测试机`100.93.204.60`,基线`0c3f820cc92eeae8c996ac7d6d8d6db98e049ea7`。只授权测试环境;未推送,未部署预生产。后续仅文档提交不需要再次切换应用。
[方案](channel-sensitive-words-plan-20260910.md)第一版已实现:敏感词页独立Tab及真实CRUD、通道候选排除、普通/微批同快照检查、运营端历史原因和无路由失败完成恢复。不增加Gateway/逐片复核;已读取规则快照的微批和已选路消息不追溯配置变更。现有全局词库和引流门禁保留。未修改线上词库、原通道/客户配置或余额,没有发送/补发/重投/重新入队短信。
## 版本和标准发布证据
- 标准计划`20260910T075122-8e4bc5a20e4d-0e177bfe`planHash`ac7a5d317f948c75e2f7e4f73745e8f27ea3db51783cf88d9d485bd19bcc702e`;完整执行plan→validate→preflight→prepare→deploy→verify→status→report。
- 应用包SHA256`0357647cf2a80154b7dbf37711b5745618436c8acfedaf12d8df178f2dec8494`,历史基线CRLF格式通过逐文件摘要核验。相对线上28个文件变化:本轮26个文件,加前一文档提交的部署说明/引流验收文档,无额外运行代码。
- 发布工具`tools/release/`仍未提交,toolHash`e0220a990afc6b3d0fb791dd71e9ab37d2687cbd0272ae05c2e0bffd0d06b5d9`,不以应用提交冒称工具版本,也未夹带该草稿。
- 精确提交独立归档validate:前端28套139项、API69套745项,类型、格式、lint、CSS门禁通过。工作区production前端/API构建、安全和bundle门禁通过,lint既有28警告、0错误;未改Go。
- 本地证据`%TEMP%/cmpp-channel-sensitive-implementation-20260910/`,发布工具证据`.local-data/releases/20260910T075122-8e4bc5a20e4d-0e177bfe/`;计划、state、validation和报告均保留。
## 真实功能及环境验收
本地隔离PostgreSQL克隆`cmpp_qa_channel_words_1789025601486`已应用新增迁移,真实管理服务验证参数、权限、唯一约束、并发version冲突、启停/删除恢复和审计;真实普通/100条微批排除A选B,全部命中返回专用原因,保留原文/历史快照且客户端无通道词详情。每批新增词库读1次、决策写1次,390~441ms含既有引流处理,不是实际发送TPS。真实失败SQL在注入费用释放失败后保留独立完成标记;费用与回执幂等/恢复分支有隔离单测。
本地production构建+真实Nest API/PG及隔离Redis认证,Edge三尺寸1600×1000、1366×768、390×844完成新增/编辑/启停/软删除、独立筛选保持、显式关闭、刷新/跨路由、历史原因验收,pageerror=0。没有运行短信Worker/Gateway传输;夹具连接状态仅支持隔离路由验证。
部署后使用已有验收管理员正常登录,`GET /api/admin/dictionaries/channel-sensitive-words?page=1&pageSize=25`返回200、items=[]、total=0;新表初始为空,未保存线上规则。三尺寸新Tab、显式关闭、刷新/跨路由及历史短信详情兼容通过;最终浏览器记录errors=[]。首次观察器因右上角和页脚两个“关闭”按钮同名发生strict selector错误,明确选择页脚按钮后通过,保留原失败记录`live-after.log`及成功`live-after2.log`,不是应用Bug或网络失败。
2026-09-10 16:05:30 CST标准verify回读:13项服务activeGateway/security-agent未重启,7个相关Node服务按计划更新。API健康、102项迁移含`20260910160000_channel_sensitive_words`及前端资源摘要通过;PostgreSQL/Redis/MinIO服务健康。三个Gateway Stream pending/lag均0;消息119509、提交130769,前后未变。受保护文件无变化,journal警告和应用日志新增errorMarkers均0verify warnings=[]。工具的businessAcceptance仍显示“未执行”,原因是第一期没有业务页面验收写入入口;本节独立记录真实浏览器证据,不修改工具状态冒充自动验收。
## 恢复资产和时间
独立备份`/var/backups/cmpp-platform/20260910T075122-8e4bc5a20e4d-0e177bfe-attempt-1789027371632202403`,包含PostgreSQL dump/目录、Redis RDB、原源码/产物及系统配置,清单摘要与可读性通过;未做实际恢复演练。应用回退会失去通道词过滤,回退不删新数据、不自动重投。
| 阶段 | 实测耗时 |
|---|---:|
| 精确提交本地validate | 约7分38秒,含锁定依赖安装和全量测试;与远端服务停机分开 |
| 远端prepare | 56.5秒 |
| 独立备份 | 35.2秒 |
| 停止受影响服务至启动恢复 | 17.0秒(含迁移及切换) |
| 其中迁移 | 1.2秒 |
| deploy内verify | 4.7秒 |
本地实施及真实集成/浏览器验证另行完成,不把整段工作会话称作部署耗时。远端发布各阶段无失败/重试;浏览器选择器修正与登录/观察时间单独留证,未精确拆分的浏览器总耗时不编造。
## 容量与资产保留
测试机只有`/dev/sda2`系统盘,未挂载独立/data。发布前2026-09-10 15:48:29 CST、发布后2026-09-10 16:05:54 CST分别盘点47个根目录及其子目录,见inventory-before.json / inventory-after.json。df:可用10348167168→8812388352字节,减少1535778816字节(约1.54GB),使用率90%→92%。此增量含同时发生的正常数据/日志增长;目录实占不应重复相加。
| 分类/实际路径 | 发布前字节 | 发布后字节 | 增量字节 |
|---|---:|---:|---:|
| `/opt/cmpp-platform` | 1023991808 | 1025716224 | 1724416 |
| `/opt/cmpp-releases` | 6464483328 | 7495319552 | 1030836224 |
| `/var/backups/cmpp-platform` | 4564557824 | 5060235264 | 495677440 |
| `/var/log` | 946245632 | 946380800 | 135168 |
| `/home/hector/.npm` | 611745792 | 611745792 | 0 |
| `/root/.npm` | 622092288 | 622174208 | 81920 |
| `/var/lib/postgresql` | 2658304000 | 2658480128 | 176128 |
| `/var/lib/redis` | 225660928 | 225656832 | -4096 |
| `/var/lib/minio` | 563314688 | 563462144 | 147456 |
本次新增发布目录内资产(与当前程序同一文件系统;以下子目录已包含在上表,不再求和):
| 路径 | 字节 | 版本/保留理由 |
|---|---:|---|
| `/opt/cmpp-releases/20260910T075122-8e4bc5a20e4d-0e177bfe/previous-api-dist` | 4591616 | 0c3f820上一有效版本回退组件 |
| `/opt/cmpp-releases/20260910T075122-8e4bc5a20e4d-0e177bfe/previous-api-node_modules` | 586670080 | 0c3f820上一有效版本回退组件 |
| `/opt/cmpp-releases/20260910T075122-8e4bc5a20e4d-0e177bfe/candidate-1789027246523059732` | 395890688 | 8e4bc5a候选余留/证据,未清理 |
| `/opt/cmpp-releases/20260910T075122-8e4bc5a20e4d-0e177bfe/previous-source` | 2412544 | 0c3f820上一有效版本回退组件 |
| `/opt/cmpp-releases/20260910T075122-8e4bc5a20e4d-0e177bfe/previous-dist` | 36978688 | 0c3f820上一有效版本回退组件 |
| `/opt/cmpp-releases/20260910T075122-8e4bc5a20e4d-0e177bfe` | 1030836224 | 本次发布总目录,含源包/计划/日志/组件 |
源包保留在`/opt/cmpp-releases/20260910T075122-8e4bc5a20e4d-0e177bfe/source.tgz`,绑定上述archiveHash;本机源包和基线包也保留。当前程序为8e4bc5a;上一有效0c3f820的previous-source、previous-dist、previous-api-dist、previous-api-node_modules和成套备份独立保留。candidate剩余依赖供故障分析,未把已切至live的组件重复算为候选。
所有历史旧版本、候选、备份、迁盘/冷副本、缓存和日志均未删除或迁盘;“当前+上一有效版本”的稳态目标仍未完成,第一期工具尚无治理入口。本次发布成功不等于资产治理完成;系统盘已有92%使用率,后续发布应重新预检余量,不能据本次余量自动授权清理。
以下为47个根目录盘点中的历史程序根目录保留清单;未知精确版本的目录不从名字猜测版本,均保留待治理。缓存/数据/日志已在上表列出:
| 历史路径 | 实占字节 | 标记核验 |
|---|---:|---|
| `/opt/cmpp-night-previous-20260907T230742` | 612507648 | 未取得独立部署标记;保留 |
| `/opt/cmpp-platform.previous-reportbatch-068b804-20260903T031400Z` | 988270592 | 068b8047ddbfbacf32b233b7b8cfb4e52fd04a6f |
| `/opt/cmpp-platform-previous-20260906-monitor-442dda7` | 623988736 | 442dda711d5c9f778f3f76fd6d8fd69f14414ce6 |
| `/opt/cmpp-platform.previous-wps-fe3c6e5-20260904T095352Z` | 988004352 | fe3c6e5b589cf475c611f5189e9243e657b5deac |
| `/opt/cmpp-platform-candidate-e4f93f7` | 14458880 | 0e3424c4a726f112385d0c7f3f3ebd90c127195b |
| `/opt/cmpp-platform.previous-css-6508295-20260905T0055` | 988237824 | 65082959c05c975eb7856ee281811a37e1fc284a |
| `/opt/cmpp-platform.previous-report-material-state-dc201bf9-20260903T050717Z` | 987893760 | dc201bf92ef039dd5beb3c16950e622c600f0026 |
| `/opt/cmpp-platform.previous-report-briefs-ad89e8f-20260902T1040Z` | 966549504 | ad89e8fed793857daf18ea6cebb995edfe3b74d4 |
| `/opt/cmpp-night-release-633ba59` | 985272320 | 未取得独立部署标记;保留 |
| `/opt/cmpp-platform-v5-stage-20260820T073310Z` | 10334208 | 未取得独立部署标记;保留 |
| `/opt/cmpp-platform.previous-p4-20260821T024737Z` | 838713344 | fcf6d3e6b439b20ad09f517f4fb11c8142758460+workspace.p4fix.b9de7aafef63 |
| `/opt/cmpp-platform.previous-drainage-20260828T093754Z` | 987291648 | 226527f1cef122e4dbd163da782485e8c0254dc5 |
| `/opt/cmpp-platform-frontend-247fee6` | 14061568 | 未取得独立部署标记;保留 |
| `/opt/cmpp-platform.previous-p2fix-20260820T110500Z` | 840720384 | 99fb346c125b0f620641562c50ad517a7682a10f+workspace.p2.56456e81d3be |
| `/opt/cmpp-platform-candidate-4c21072` | 42749952 | 未取得独立部署标记;保留 |
| `/opt/cmpp-v4-final-20260820T061323Z` | 7843840 | 未取得独立部署标记;保留 |
| `/opt/cmpp-platform.previous-schemeless-20260828T100204Z` | 987258880 | c68ac7a3db82d1bdb867260378df18f93cca0fd2 |
| `/opt/cmpp-platform.previous-p4-20260821T022833Z` | 835203072 | 52028b9bbdea20f0cd02efe792e91b558ba50be9+workspace.p3.7c14cd5f197a |
| `/opt/cmpp-night-candidate-20260907` | 982675456 | 未取得独立部署标记;保留 |
| `/opt/cmpp-platform.previous-channel-groups-20260905T231242-15a1f9d` | 1003593728 | 15a1f9d8eddd4f7233987fbf676a91f4e0bfab08 |
| `/opt/cmpp-platform.previous-p2-20260820T105600Z` | 1037434880 | 229a0b28fd8b84d910c359ff9ac442fc3e843cb4+workspace.p1submitpool.f50505ad9ee9 |
| `/opt/cmpp-platform-previous-step2-20260824T023615Z` | 835596288 | 90345fba22e3ae183e1edb4fdae718fb7cb2d963+workspace.p4progress.d82933c344ec |
| `/opt/cmpp-platform.previous-brief-default-7cb5dd3-20260903T0142Z` | 988180480 | 7cb5dd376e23ff0c8a1e3785ab376e432f2ebf6a |
| `/opt/cmpp-platform-backups` | 23341174784 | 未取得独立部署标记;保留 |
| `/opt/cmpp-platform.previous-high-frequency-final-cd82499-20260902T0851Z` | 987590656 | cd824999f3b604155e09fbad1f5c6258cfe1d918 |
| `/opt/cmpp-platform.failed-p4-20260821T030907Z` | 798334976 | 未取得独立部署标记;保留 |
| `/opt/cmpp-platform.previous-wps-async-41962e7-20260904T1500Z` | 988098560 | 41962e7a6e6cfd34b4313c8bf52d9345d37d8e10 |
| `/opt/cmpp-platform.candidate-report-record-fe3c6e5` | 11743232 | 未取得独立部署标记;保留 |
| `/opt/cmpp-platform.previous-p3-20260821T014803Z` | 835768320 | f4560479d3bcb9f1b1a6fc9b1b2664a997cdf851+workspace.p2fix.51553e0d5555 |
| `/opt/cmpp-v4-stage-20260820T052612Z` | 7843840 | 未取得独立部署标记;保留 |
| `/opt/cmpp-platform.previous-p4-20260821T030956Z` | 835121152 | 3c6f1beed1c4e6cbfc3822ae377de13317e48ded+workspace.p4cb.68ad7a6ef649 |
| `/opt/cmpp-platform.previous-import-review-20260904T122649Z` | 988098560 | bb435fb0ac1e7812fcdb59950a4b13757899ab39 |
| `/opt/cmpp-platform.previous-enterprise-signature-b0deef5` | 987676672 | b0deef5e6ebe6adbfe85591c53dedf11866880a0 |
| `/opt/cmpp-platform.previous-code-quality-20260828T040442Z` | 859160576 | d85ff8599955e8d217b923dd314690f4b18a4dd2 |
| `/opt/cmpp-platform.previous-sixfix-20260831T042224Z` | 987369472 | 5328bb09bf89170b4368407e896dbce9527a7b5c |
| `/opt/cmpp-platform.previous-code-quality-20260828T035307Z` | 1096306688 | 0b84370270c681dff3b15363f91b73566c6954b5 |
| `/opt/cmpp-platform.previous-p4-20260821T024137Z` | 835813376 | 487b5282a66ec11957a3edf4e9e93cc70cd76342+workspace.p4.ee5b0f3d0db8 |
## 未验证项
未发送短信,未做供应商物理Submit/客户CMPP回执ACK、真实费用释放流水对账或完整链路压测。测试环境只做配置页只读与空表验收,规则写入/路由组合在隔离PG完成,线上词库由用户配置。没有修改OpenWrt/网关;此前网络根因和预生产历史故障未宣称解决。没有推送或预生产部署。
+118
View File
@@ -0,0 +1,118 @@
# 2026-09-10 测试环境引流门禁发布验收
本记录补充标准工具报告和引流专项方案,记录本次精确版本的测试发布事实。预生产未操作,未推送。
- 应用提交:`0c3f820cc92eeae8c996ac7d6d8d6db98e049ea7`;原线上版本:`809175b544f2526891ba6d2dece1a50eadaf57a0`。包含此前九项运营修复 `5bcdbb2`,包内不含工作区其他修改。
- 发布编号:`20260910T053026-0c3f820cc92e-76f98ff4`;本地工具报告:`.local-data/releases/20260910T053026-0c3f820cc92e-76f98ff4/report.md`
- 工具摘要:`e0220a990afc6b3d0fb791dd71e9ab37d2687cbd0272ae05c2e0bffd0d06b5d9``tools/release/` 是开工已有未提交工具,本轮保持字节不变;应用提交号不代表工具版本。
- 源包 SHA256`4ac91176db102a351f56fe20aebd7a3fd6775c03a91e1ef38bc45eaa75aaefd7`,使用已由线上文件摘要确认的 CRLF 归档;计划摘要:`05031daba48be655fd59525e3170e8f41211ef4fb1f1564205548cfa24a25011`
- 已按 plan、validate、preflight、prepare、deploy、verify、status、report 执行,最终状态 `deployed-infrastructure-checked`。未手改计划、跳过门禁或调用旧发布脚本。
## 验证与边界
- 精确提交独立 validateAPI 67套722项,前端27文件136项通过;类型、格式、lint、Stylelint、CSS治理、Go测试及vet通过。工作区此前723项含1项其他会话metrics测试,未夹带提交。远端候选production前端/API/Gateway构建和security/deploy/bundle门禁通过。
- 本地真实PostgreSQL/API引流用例、并发撤销锁、多目标统计、号码NFKC与域名边界,以及production浏览器详情已通过;多引流统计最终证据为 `real-gate-statistics2.log`。首轮日报统计夹具未进入已完成日期窗口,修正测试日期参数后验证,并非修改生产统计规则来通过测试。
- 发布后13项服务active且NRestarts为0,含API、Callback、Gateway、Worker、安全代理、PostgreSQL、Redis、MinIO、Nginx;精确部署标记、迁移、HTTP入口和资源摘要与候选一致。三个Gateway Stream消费组pending/lag均0。消息119509条、提交130769条,发布前后不变。保护配置一致,无计划外进程变化。
- journal无新增warning级记录;应用追加日志扫描无新增error标记,非全量历史日志零错误的声明。标准verify复验仍无warnings。
- 真实测试管理员正常验证码登录;通道、签名质量、系统监控、预警中心、清退预警、短信记录六页面真实请求通过,无HTTP错误、requestfailed或pageerror。发送详情展示真实2026-08-27历史记录“未执行引流资格校验”,1600×1000、1366×768、390×844截图及显式关闭通过。未向历史消息补填门禁结果。
- 线上页面证据 `live-after-1789019806762``live-after.log`;本地证据根为 `%TEMP%/cmpp-drainage-implementation-20260910`。标准工具目前无业务验收写入入口,工具报告该栏仍显示未执行,本节独立记录已执行的只读页面验收,不改写工具状态冒充闭环。
- 未执行真实短信发送、长短信物理分片/供应商零Submit证明、客户回执ACK/离线投递、真实费用对账、故障注入或容量压测;须专项授权。未修改既有批准、通道、客户或余额配置,非CMPP不新增拦截回执。MinIO服务active,未上传材料;收尾补查HTTP readiness时SSH连接超时,未据此宣称HTTP探针通过,网络原因仍未关闭。
## 安全代理环境修复
首次preflight被既有cmpp-security-agent故障阻断:226/NAMESPACE/run/cmpp-security-agent不存在;服务ReadWritePaths引用该目录但未声明创建。用户单独批准基础设施修复后,新建 `/etc/systemd/system/cmpp-security-agent.service.d/runtime-directory.conf`
```ini
[Service]
RuntimeDirectory=cmpp-security-agent
RuntimeDirectoryMode=0750
```
执行daemon-reload并仅重启该代理;目录root:cmpp-security/0750、连续观察重启数不再增长,随后重新preflight通过。保留已有安全沙箱限制,未使用临时mkdir掩盖重启丢目录。发布后代理重启正常;未重启整台测试机验证开机。drop-in属于本次授权环境修复,仓库初始化service模板尚未同步,该配置不冒称属于应用提交。证据 `repair-security-runtime.log`
## 耗时
| 阶段 | 开始 | 秒 |
|---|---|---:|
| prepare | 2026-09-10 13:52:31 CST | 77.9 |
| backup | 2026-09-10 13:54:53 CST | 35.7 |
| stop | 2026-09-10 13:55:31 CST | 1.6 |
| migrate | 2026-09-10 13:55:32 CST | 1.3 |
| switch-dist | 2026-09-10 13:55:34 CST | 0.0 |
| switch-api-dist | 2026-09-10 13:55:34 CST | 0.0 |
| switch-api-node_modules | 2026-09-10 13:55:39 CST | 0.0 |
| sources | 2026-09-10 13:55:39 CST | 0.1 |
| start | 2026-09-10 13:55:40 CST | 5.5 |
| marker | 2026-09-10 13:55:45 CST | 0.1 |
| verify | 2026-09-10 13:55:45 CST | 4.5 |
停止服务至全部受影响服务恢复约14.3秒,不能将候选准备/备份/等待时间计作停机。本地精确提交validate约13:30~13:39;此前本地功能验证另记于测试进度。SSH超时、等待用户恢复网络及批准安全代理修复属于独立故障/等待,未计入上表。浏览器验收约13:56执行;详细时间以日志和截图目录为准。
## 恢复资产与容量
本次独立恢复点:`/var/backups/cmpp-platform/20260910T053026-0c3f820cc92e-76f98ff4-attempt-1789019693807379972`,对应原版本`809175b544f2526891ba6d2dece1a50eadaf57a0`。PG custom dump目录检查、Redis RDB头、应用和配置归档可读性及下列摘要均通过;未执行恢复演练。
| 文件 | SHA256 |
|---|---|
| application.tgz | `a9bc6d0485b3707feac8fb9aa91510d4d49947c9d223b45e620766244bb58094` |
| configuration.tgz | `b08cf3f7e920f21c20b449f830fdbfdfd292150c9e1faae38ffbd07b9774fa6b` |
| database.dump | `7519d06275dc865028c2e6f30ce49e0e3867c3d57231b33b5af5baa95ef4ec90` |
| redis.rdb | `d1f02ca99bfdbe0417c8f63fe844fcece8d068fb0dae1308a7cf5a71940fd383` |
盘点时间:发布前2026-09-10 13:51:10 CST,发布后2026-09-10 13:57:55 CST。测试机无独立数据盘或/data挂载,程序、发布目录、备份与历史副本均在/dev/sda2的ext4根文件系统。系统盘已用87,850,401,792→89,328,619,520字节,增加1,478,217,728字节;可用11,850,387,456→10,372,169,728字节,89%→90%。各目录du差值不能简单相加为df差值,包含运行数据变化、文件系统分配及父子重复关系。
| 资产路径 | 发布前字节 | 发布后字节 | 增量 |
|---|---:|---:|---:|
| `/opt/cmpp-platform` | 1018621952 | 1023991808 | +5369856 |
| `/opt/cmpp-releases` | 5437882368 | 6464483328 | +1026600960 |
| `/var/backups/cmpp-platform` | 4069167104 | 4564557824 | +495390720 |
| `/opt/cmpp-platform-backups` | 23341174784 | 23341174784 | +0 |
| `/root/.npm` | 595038208 | 622092288 | +27054080 |
| `/var/log` | 945696768 | 945766400 | +69632 |
当前程序为/opt/cmpp-platform的0c3f820cc92eeae8c996ac7d6d8d6db98e049ea7。上一有效版本809175b544f2526891ba6d2dece1a50eadaf57a0由本次独立备份及/opt/cmpp-releases/20260910T053026-0c3f820cc92e-76f98ff4/previous-*共同保留;应用回退会失去引流门禁,数据库不自动回退,也不得借回退重投短信。
本次发布目录保留previous-dist 35,639,296字节、previous-api-dist 4,517,888字节、previous-api-node_modules 582,897,664字节、previous-source 3,526,656字节、候选剩余395,755,520字节及源包/日志/状态;合计1,026,600,960字节。独立备份新增495,390,720字节。候选和切换组件仍同文件系统,未迁盘。
旧版本根目录盘点如下;无部署标记的版本仅凭目录名不能确定,明确未核实。全部保留,未实施“当前+上一有效版本”清理治理。历史/opt/cmpp-platform-backups占23,341,174,784字节,也未删除。当前logs仍引用/opt/cmpp-platform-previous-20260906-monitor-442dda7/logs,该旧根不能直接删除。
| 旧程序/候选根目录 | 字节 | 可读部署标记 |
|---|---:|---|
| `/opt/cmpp-night-previous-20260907T230742` | 612507648 | 无标记,精确版本未核实 |
| `/opt/cmpp-platform.previous-reportbatch-068b804-20260903T031400Z` | 988270592 | 068b8047ddbfbacf32b233b7b8cfb4e52fd04a6f |
| `/opt/cmpp-platform-previous-20260906-monitor-442dda7` | 623886336 | 442dda711d5c9f778f3f76fd6d8fd69f14414ce6 |
| `/opt/cmpp-platform.previous-wps-fe3c6e5-20260904T095352Z` | 988004352 | fe3c6e5b589cf475c611f5189e9243e657b5deac |
| `/opt/cmpp-platform-candidate-e4f93f7` | 14458880 | 0e3424c4a726f112385d0c7f3f3ebd90c127195b |
| `/opt/cmpp-platform.previous-css-6508295-20260905T0055` | 988237824 | 65082959c05c975eb7856ee281811a37e1fc284a |
| `/opt/cmpp-platform.previous-report-material-state-dc201bf9-20260903T050717Z` | 987893760 | dc201bf92ef039dd5beb3c16950e622c600f0026 |
| `/opt/cmpp-platform.previous-report-briefs-ad89e8f-20260902T1040Z` | 966549504 | ad89e8fed793857daf18ea6cebb995edfe3b74d4 |
| `/opt/cmpp-night-release-633ba59` | 985272320 | 无标记,精确版本未核实 |
| `/opt/cmpp-platform-v5-stage-20260820T073310Z` | 10334208 | 无标记,精确版本未核实 |
| `/opt/cmpp-platform.previous-p4-20260821T024737Z` | 838713344 | fcf6d3e6b439b20ad09f517f4fb11c8142758460+workspace.p4fix.b9de7aafef63 |
| `/opt/cmpp-platform.previous-drainage-20260828T093754Z` | 987291648 | 226527f1cef122e4dbd163da782485e8c0254dc5 |
| `/opt/cmpp-platform-frontend-247fee6` | 14061568 | 无标记,精确版本未核实 |
| `/opt/cmpp-platform.previous-p2fix-20260820T110500Z` | 840720384 | 99fb346c125b0f620641562c50ad517a7682a10f+workspace.p2.56456e81d3be |
| `/opt/cmpp-platform-candidate-4c21072` | 42749952 | 无标记,精确版本未核实 |
| `/opt/cmpp-v4-final-20260820T061323Z` | 7843840 | 无标记,精确版本未核实 |
| `/opt/cmpp-platform.previous-schemeless-20260828T100204Z` | 987258880 | c68ac7a3db82d1bdb867260378df18f93cca0fd2 |
| `/opt/cmpp-platform.previous-p4-20260821T022833Z` | 835203072 | 52028b9bbdea20f0cd02efe792e91b558ba50be9+workspace.p3.7c14cd5f197a |
| `/opt/cmpp-night-candidate-20260907` | 982675456 | 无标记,精确版本未核实 |
| `/opt/cmpp-platform.previous-channel-groups-20260905T231242-15a1f9d` | 1003593728 | 15a1f9d8eddd4f7233987fbf676a91f4e0bfab08 |
| `/opt/cmpp-platform.previous-p2-20260820T105600Z` | 1037434880 | 229a0b28fd8b84d910c359ff9ac442fc3e843cb4+workspace.p1submitpool.f50505ad9ee9 |
| `/opt/cmpp-platform-previous-step2-20260824T023615Z` | 835596288 | 90345fba22e3ae183e1edb4fdae718fb7cb2d963+workspace.p4progress.d82933c344ec |
| `/opt/cmpp-platform.previous-brief-default-7cb5dd3-20260903T0142Z` | 988180480 | 7cb5dd376e23ff0c8a1e3785ab376e432f2ebf6a |
| `/opt/cmpp-platform.previous-high-frequency-final-cd82499-20260902T0851Z` | 987590656 | cd824999f3b604155e09fbad1f5c6258cfe1d918 |
| `/opt/cmpp-platform.failed-p4-20260821T030907Z` | 798334976 | 无标记,精确版本未核实 |
| `/opt/cmpp-platform.previous-wps-async-41962e7-20260904T1500Z` | 988098560 | 41962e7a6e6cfd34b4313c8bf52d9345d37d8e10 |
| `/opt/cmpp-platform.candidate-report-record-fe3c6e5` | 11743232 | 无标记,精确版本未核实 |
| `/opt/cmpp-platform.previous-p3-20260821T014803Z` | 835768320 | f4560479d3bcb9f1b1a6fc9b1b2664a997cdf851+workspace.p2fix.51553e0d5555 |
| `/opt/cmpp-v4-stage-20260820T052612Z` | 7843840 | 无标记,精确版本未核实 |
| `/opt/cmpp-platform.previous-p4-20260821T030956Z` | 835121152 | 3c6f1beed1c4e6cbfc3822ae377de13317e48ded+workspace.p4cb.68ad7a6ef649 |
| `/opt/cmpp-platform.previous-import-review-20260904T122649Z` | 988098560 | bb435fb0ac1e7812fcdb59950a4b13757899ab39 |
| `/opt/cmpp-platform.previous-enterprise-signature-b0deef5` | 987676672 | b0deef5e6ebe6adbfe85591c53dedf11866880a0 |
| `/opt/cmpp-platform.previous-code-quality-20260828T040442Z` | 859160576 | d85ff8599955e8d217b923dd314690f4b18a4dd2 |
| `/opt/cmpp-platform.previous-sixfix-20260831T042224Z` | 987369472 | 5328bb09bf89170b4368407e896dbce9527a7b5c |
| `/opt/cmpp-platform.previous-code-quality-20260828T035307Z` | 1096306688 | 0b84370270c681dff3b15363f91b73566c6954b5 |
| `/opt/cmpp-platform.previous-p4-20260821T024137Z` | 835813376 | 487b5282a66ec11957a3edf4e9e93cc70cd76342+workspace.p4.ee5b0f3d0db8 |
完整47个根路径、挂载、缓存/日志/PG/Redis/MinIO占用、符号链接和回读证据保存在 inventory-before-full.json、inventory-after-full.json、inventory-after-versions.json。历史迁盘冷副本在本测试机未识别到独立/data;未借用预生产容量结论。清理工具、历史资产谱系/恢复验证和治理仍未完成,后续专项处理。
+92
View File
@@ -5335,3 +5335,95 @@ OPS0908-01至07已按本轮范围验证;精确证据见testing-progress.md对
| OPS-PAGE0908-06、通知登录后业务与恢复演练 | 预生产现有会话锁定,待用户解锁后验证;未改账号,不能引用测试环境通过作为此项通过。独立恢复资产已保留,实际恢复演练未执行 |
证据:%TEMP%/cmpp-starttime-pagination-20260908/public-verify/run-2026-09-08T15-03-44-416Z/verification.json 与 public-verify/anonymous-2026-09-08T15-07-16-145Z/review.json。复核状态为 anonymous_ui_verified_with_network_limitation,未宣称预生产业务全部通过或历史 startTime/网络关闭问题全部根除。
## 2026-09-09 九项运营修复验收
设计见 [九项修复](operations-fixes-20260909.md),执行结果单独记testing-progress.md。本节TC-OPS0909-05替代TC-DASHBOARD-FRAGMENT-001的首页分片口径,历史测试结果不改写。
| 用例 | 场景 | 预期 |
|---|---|---|
| TC-OPS0909-01 | 同签名三网通道分别1/2/3次提交,并混入未知运营商;回执状态各异 | 三网独立统计数量、比例及最近成功时间;未知不分摊;历史无运营商通道级及引流仍汇总 |
| TC-OPS0909-02 | 创建/编辑,未修改/已修改,遮罩、内容、Escape、页脚和叉;连续快速关闭 | 非显式动作不关闭;保存成功/关闭按钮正常;dirty确认、焦点恢复无卸载异常 |
| TC-OPS0909-03 | 活跃度企业、应用、签名、通道组合与空条件、跨Tab、刷新、三尺寸 | AND过滤、字段独立、筛选回第一页,日期/分页保持原行为、无窄屏溢出 |
| TC-OPS0909-04 | 历史默认日期、指定日期、重复触发、跨日、分页、非法日期、Prometheus失败 | 近7日真实历史按周期分开;最后采样不假定恢复;失败不假成功,无保留数据明确空态 |
| TC-OPS0909-05 | 同客户长短信经多个通道多次提交,另有未路由消息及跨日记录 | 今日按消息billingUnits一次汇总;换通道不重复,含今日尚未向供应商提交的消息;到达率分母不混换 |
| TC-OPS0909-06 | 当前用户已读、重复已读、另一管理员、read/unread与生命周期组合、快速切换 | 未读活动数恰好扣一次,其他用户不受影响;列表/total同条件,顶栏刷新,慢响应不覆盖新筛选 |
| TC-OPS0909-07 | 短信记录发送详情多个提交/回执 | 每行无通道组文案,顶部汇总及通道/回执数据仍存在 |
| TC-OPS0909-08 | HTTP关/开/关/开;本地及API校验失败 | 地址随HTTP参数隐藏展示且保值;居中错误弹窗保留表单,不伪报保存成功 |
| TC-OPS0909-09 | 历史/新清退预警多行、包含企业名称、非前缀正文 | 仅展示去行首请通知企业文案,保留签名/运营商/数量正文;不改数据库/外发消息 |
## 2026-09-10 引流发送资格验收(全部待实施/待执行)
权威方案见[引流门禁方案](drainage-send-gating-plan-20260910.md)。生效后替代旧引流未登记/未审核仍允许发送的断言;历史结果不改写。真实发送/入队验收需专项授权,本轮未执行。
| 用例 | 场景 | 预期 |
|---|---|---|
| TC-DRAINAGE-GATE-01 | 无引流、检测正常 | 继续原签名/风控/路由,不新增拒绝 |
| TC-DRAINAGE-GATE-02 | 02177882277登记,原文021-77882277及全角/干扰符 | 规范值匹配;数据库原文、计费长度和提交字节不变 |
| TC-DRAINAGE-GATE-03 | 号码子串、区号不同、跨签名/企业/应用同值 | 不得借用或子串放行 |
| TC-DRAINAGE-GATE-04 | 登记lisglo.cn,短信sms.lisglo.cn及带路径 | 两例通过目标关联;仍须合格通道 |
| TC-DRAINAGE-GATE-05 | lisglo.cn.evil.com、evillisglo.cn、query/userInfo伪包含;任意子域+路径参数 | 前四类不通过;hostname等于或点边界子域通过,深层子域不授权父/兄弟域;检测不得截短目标 |
| TC-DRAINAGE-GATE-06 | 一条短信两目标,仅一个登记 | 整条拒绝,另一个目标和原因可追溯;同批合法消息不受影响 |
| TC-DRAINAGE-GATE-07 | 一个目标匹配多条资料,多个目标通道1/2及2/3 | 单目标资料取并集,多目标取交集,仅通道2;记录授权任务 |
| TC-DRAINAGE-GATE-08 | 资料pending/rejected/deleted、修改冻结、旧版本报备 | 不借失效资料通过;兼容策略经确认后执行 |
| TC-DRAINAGE-GATE-09 | 同通道签名通过而引流未通过、运营商不匹配、历史通道级任务 | 严格区分签名/引流资格;旧配置不迁移补齐,用户修改/保存/刷新正常,按当前配置读取资格 |
| TC-DRAINAGE-GATE-10 | 普通HTTP/CMPP/客户端、微批、模板替换、长短信跨片URL | 统一结果,完整内容判定,入口受理不等于发送 |
| TC-DRAINAGE-GATE-11 | 定时到期、审核释放、换通道/重试、启用前排队 | 最终重新校验,不因旧快照/人工批准绕过 |
| TC-DRAINAGE-GATE-12 | 撤销报备与多Worker并发,Gateway意图已排队 | 授权版本/生效边界可证,未发送的失效意图不继续Submit |
| TC-DRAINAGE-GATE-13 | 拦截与回执并发、写状态后崩溃、回调失败/无目标 | 终态/费用幂等;CMPP沿用适用的既有回执且可恢复;非CMPP不创建拦截回执投递,即使配置了HTTP回调 |
| TC-DRAINAGE-GATE-14 | 已扣/未扣/冻结及部分已发历史记录 | 复用原策略不多扣多退,无供应商提交不产生供应商成本 |
| TC-DRAINAGE-GATE-15 | 50命中/20000字符上限、规则为空/故障、Redis/DB故障 | 不因不完整检测判无引流,不吞错放行;技术失败独立记录 |
| TC-DRAINAGE-GATE-16 | 详情/历史未校验/多引流统计/三尺寸与权限 | 保留原文和证据,历史不冒称通过,首页及费用不倍增,跨租户不可见 |
TC-DRAINAGE-GATE-05补充:覆盖host大小写、国际化域名、末尾根点、畸形URL、公共后缀登记拒绝和历史带路径资料迁移边界;纯域名报备不限制下级域名、URL路径或参数。此项已按用户澄清确定域名边界,不再使用字面includes。
TC-DRAINAGE-GATE-08审核要求已获用户确认:已添加但未审核通过不得发送。TC-DRAINAGE-GATE-13补充签名与引流在HTTP/CMPP路由失败的对照回归:当前非CMPP路由失败没有统一失败回执调用,需补闭环后验证,不能以CMPP通过代替HTTP通过;尊重原回执标记及接收配置。
2026-09-10最终澄清替代前条TC-DRAINAGE-GATE-13“补非CMPP回执闭环”要求:非CMPP不推送是预期行为,不作为Bug;CMPP继续沿用签名未报备的回执路径。TC-DRAINAGE-GATE-09不做旧报备配置迁移或补齐,增加旧记录可查看、用户修改保存刷新及当前资格读取回归;旧配置调整由用户负责。
## 2026-09-10 引流门禁实施及验证分层
TC-DRAINAGE-GATE-0116的实现范围以专项方案第10节为准,不再笼统标为全部待实现。新增真实数据库/API脚本 tools/testing/verify-drainage-gate.mjs 只接受本机 cmpp_qa_drainage_ 数字后缀克隆库,不调用发送入口或Gateway传输;覆盖02、04~09、12、16中的规范化、关联、通道交集、审核/报备撤销、并发锁、伪URL及SQL部分。Gateway隔离单元覆盖最终复核与故障拒绝;API单元覆盖CMPP已有回执恢复、持久化失败保留待恢复标记和非CMPP无推送。真实页面覆盖16的三尺寸、关闭、刷新和路由切换。新增用例:转发头不能调用最终资格接口;已终态提交不能重复授予许可;无实际wire的DRN拒绝不计入通道发送尝试。
保留待专项发送验收:10/11的各入口到供应商端到端、12的物理提交边界、13的客户回执ACK/离线重投、14的真实费用及部分分片对账、15的真实基础设施故障恢复及吞吐。单元测试、持久化夹具和浏览器不代替这些证据。旧配置不迁移、不自动补齐;配置入口沿用原API,测试环境只读确认,不保存客户配置。
### 2026-09-10 TC-DRAINAGE-GATE测试发布补充
0c3f820已部署测试。TC-DRAINAGE-GATE相关本地真实数据库/API/并发/统计用例及生产构建页面通过;线上历史短信详情保持“未执行引流资格校验”,三尺寸和显式关闭通过。TC-OPS0909相关六页面真实请求回归通过,不将只读打开页面记作重做全部写操作用例。迁移、13项服务、Gateway Stream与版本摘要检查通过。真实短信/供应商零Submit、长短信、CMPP回执ACK与费用对账仍待专项授权,状态不得升级为全链路通过。证据见[测试发布验收](release-20260910-test-drainage.md)。
## 2026-09-10 通道敏感词待执行用例
方案:[通道敏感词](channel-sensitive-words-plan-20260910.md)。以下按用户“第一版仅选路过滤、不做入队后复核”要求修订,取代初稿的最终授权/逐片检查用例;全部未实现/未执行。测试夹具、脚本不构成实际短信发送或配置改动授权。
| 编号 | 场景 | 预期 |
|---|---|---|
| TC-CHANNEL-WORD-01 | 两Tab与独立查询、刷新、跨路由、三尺寸 | 保留平台词语义;通道/词/状态独立筛选,分页与真实API一致,无假成功 |
| TC-CHANNEL-WORD-02 | 新增、编辑、启停、删除、重复词、失效通道和版本冲突 | 真实持久化/审计;空词/非法参数拒绝;同词跨通道允许;同通道重复受控,软删除历史保留 |
| TC-CHANNEL-WORD-03 | A命中而B未命中,A原优先级更高 | 排除A,从仍满足全部资格的B等候选按原规则选;不自动跨组 |
| TC-CHANNEL-WORD-04 | 所有合格候选命中;原本无合格候选 | 前者明确通道敏感词原因,后者保留离线/未报备等原原因;均不发送 |
| TC-CHANNEL-WORD-05 | 空库/停用词/不同通道/平台全局词 | 空库保持原路由,停用不匹配,配置互不串用,全局命中仍全局拒绝 |
| TC-CHANNEL-WORD-06 | 连续包含、英文大小写、全半角、间隔符、签名/变量及跨长短信分片词 | 按最终确认的匹配规则一致处理;匹配完整最终内容,短信原文/分片/计费长度不变 |
| TC-CHANNEL-WORD-07 | 普通/微批、多入口、全国通道降级、原有重选、三网同通道 | 均执行排除;不绕过运营商/地域/签名/引流限制;同channelId规则覆盖三网 |
| TC-CHANNEL-WORD-08 | 选路快照前/后加词、编辑、启停/删除;入口队列尚未选路 | 后续新读取的选路使用新配置;已读取快照的微批和已选路消息不追溯,不取消或重选;未选路消息仍需检查 |
| TC-CHANNEL-WORD-09 | 初次选路A命中、B可用;重复决策持久化;原有原因进入新选路 | 初次不创建A提交尝试,B按原规则选取且无提前失败回执;同次选路决策幂等,新选路重新读取词库,不新增重试机会 |
| TC-CHANNEL-WORD-10 | 已确定通道意图消费、跨多分片发送过程中配置变更 | 不新增通道词HTTP请求/逐片数据库查询/授权锁;继续原发送流程,保持原部分已发/不确定状态处理,不因词变更自动重发 |
| TC-CHANNEL-WORD-11 | 最终无路由的费用/任务/CMPP回执;非CMPP;崩溃恢复 | 费用释放/进度幂等,适用CMPP失败回执及意图耐久恢复;非CMPP不新增拒绝推送,无重复终态 |
| TC-CHANNEL-WORD-12 | 选路读库失败、多实例/大批量、越权、伪造客户端已检查标记、历史数据 | 技术故障不当空库放行;微批按候选通道集合读词/复用匹配/批量记决策,无N+1;后端真实选路不信客户端标记,权限和历史快照隔离,真实性能留证 |
验收分层:隔离单元/真实PostgreSQL与Redis/真实浏览器/供应商及客户回执全链路分别记录。03/04/09/10/11中的真实物理发送、供应商零Submit、回执ACK和费用闭环需专项授权,不能由mock或静态页面代替。第一版验收不得再要求通道词入队后复核;既有引流校验照常,不将其已有查询计为新增通道词开销。
### TC-CHANNEL-WORD 2026-09-10 实施验证
01/02/05/08/12:真实PostgreSQL管理服务验证权限、参数、筛选、重复、并发冲突、启停/删除恢复、旧快照不追溯;真实Nest浏览器验证无登录401、CRUD落库、独立筛选和分页请求。保存冲突/失败、删除确认及关闭行为补隔离组件测试。
03/04/06/07/09:真实隔离数据库中普通和100条微批均排除A选B,全命中返回专用原因;保留完整原文、历史管理员原因及客户端字段隔离。连续包含/大小写/全半角/分隔符/地域降级/离线资格和样例限额补单元测试。完整短信样本覆盖长文本,不宣称已进行物理分片发送。
10:源码确认未改Gateway,不增加最终通道词复核;规则快照更新边界通过真实数据库检查。物理发送中途配置变更未执行。
11:普通全排除的CMPP/非CMPP分支、既有回执意图恢复失败后保留标记、非CMPP恢复不推送通过隔离单测;真实批量失败SQL在注入释放失败后仍持久保存完成标记。未执行真实余额变更或客户回执投递。
12性能样本:100条完整同文消息,候选词一次读取、决策一次写入,390~441ms含原引流处理;不代表完整短信链路TPS。真实页面三尺寸1600×1000、1366×768、390×844通过,实际测试环境发布后验收另记。
TC-CHANNEL-WORD测试部署验收:应用8e4bc5a;新Tab/API200真实空词库、已有管理员登录、三尺寸弹窗显式关闭、刷新/跨路由和历史短信详情通过。线上未保存规则,写操作/并发/路由组合仍以隔离PG证据为准;真实供应商Submit/客户回执ACK/费用流水用例未执行。详见release-20260910-test-channel-sensitive-words.md。
+89
View File
@@ -4820,3 +4820,92 @@ git diff --check
- 匿名浏览器 1600×1000、1366×768、390×844 页面与截图复核正常,验证码均 200、pageerror 均 0,实际主 JS 不含 react_stack_bottom_frame/JSX dev runtime。两个 /cdn-cgi/rum POST 由只读验收策略主动阻断,单独列为预期;另一次外域 GET 在响应头前真实 ERR_CONNECTION_CLOSED,初始探针未保留具体来源,网络关闭层仍未确定,不归为已修复。原自动化还误要求登录页自发请求 session;经核验页面没有该请求,独立匿名 GET /api/admin/auth/session 返回预期 401,不能冒称执行登录。
- 生产 React、通知轮询及公共分页修复已发布;测试环境的真实业务验收结论保留原环境。预生产现有登录会话因空闲锁定,待用户解锁,登录后分页/通知业务验收尚未完成;未读取新凭据、修改账号配置或恢复管理员。Chrome DevTools startTime 沿用此前注入归因与已合入上游修复的证据,应用不屏蔽异常;不宣称所有 Chrome 或历史网络 CLOSED 问题已解决。
- 证据:主 TEMP/public-verify/run-2026-09-08T15-03-44-416Z/verification.json 与 public-verify/anonymous-2026-09-08T15-07-16-145Z/review.json(主 TEMP=%TEMP%/cmpp-starttime-pagination-20260908)。后者状态为 anonymous_ui_verified_with_network_limitation,明确保留原始自动化失败和人工复核差异,不记为登录后全通过。
## 2026-09-09 九项运营修复执行结果(23:10 CST)
授权:修改并本地提交;未授权推送、测试部署或预生产部署。设计及根因见 [九项运营修复](operations-fixes-20260909.md),用例 TC-OPS0909-01 至09。本轮实现与本地验收完成,线上验收待部署。
- Git 开工核验:main / HEAD / 实际 origin/main 均为 6d63eb5452ffc7c802960d044bf598cc8646564d。开工已有19个 tracked 和21个具体 untracked 文件;40份开工副本逐一校验,既有文件内容保留。metrics、发布工具/脚本、AGENTS及已有治理文档不夹带。需求/UI/监控设计/系统用例/本记录仅提交本轮追加部分。
- 根因:报备 SQL 未按消息运营商聚合,三网任务重复取通道/签名总数;首页错误采用供应商分片,现按 queuedAt 上海自然日客户消息 billingUnits 求和。到达率保留供应商口径,未改变计费。
- 关闭交互:检索74个文件127处公共 Modal 调用,默认禁止遮罩/Escape关闭,并修复卸载时未取消的焦点动画帧;通道编辑的显式旧设置一并收敛。保留显式关闭、dirty确认和成功保存。只读签名详情抽屉非创建/编辑表单,未改其交互。
- 自动验证:前端27文件136测试通过(maxWorkers=2);API66套701测试通过(工作区含原有未提交metrics测试,不将其计作本轮新增/提交)。前后端TypeScript/生产构建、lint、格式、Stylelint、CSS治理15测试、bundle/security/deploy静态门禁通过。lint仍有既有AdminAnalyticsPage loadData依赖警告,0 error;入口gzip107.14KiB,在250KiB预算内。Gateway/队列发送链路未改,未执行发送smoke或压测。
- 真实环境:本地独立克隆库 cmpp_qa_nine_1788964857865 从原本地95迁移版本执行既有迁移到100;未迁移原库或远端。真实PostgreSQL、API和生产构建浏览器登录验收,非mock/假会话。认证使用现有Redis7通过独立DB15及本轮随机key前缀,队列留在独立本地Redis;不接短信发送Worker。Browser插件不可用,采用已安装Playwright + Edge。
- 非零数据:隔离SQL记录经真实API验证三网1/2/3次,未知运营商不分摊;7条业务消息共14客户分片,对应两通道8条提交尝试,首页仍14。 fixture为已拒绝/失败数据,仅SQL写入独立库,无入队/发送。当前用户告警已读数减1,重复读不再扣,read/unread列表与总数一致;其他用户隔离由现有实现及单元测试覆盖。
- 浏览器:企业/通道签名活动独立字段和三尺寸1600×1000、1366×768、390×844;创建通道遮罩/Escape不关闭且显式关闭正常;HTTP地址关/开跟随参数,非法扩展码在居中错误框展示且不保存配置。历史告警默认近7日取得真实Prometheus25个周期,选择今天后请求成功,三尺寸截图;短信历史记录详情不再有每行通道组,顶部保留;独立库清退消息经真实API展示去前缀,数据库原文不变。上述成功运行无未捕获pageerror,未声称逐个手工验收127处弹窗。
- 当前环境只读证据:本轮约22:20预生产今日10257条客户消息、18270客户分片、31482供应商分片;近7日三网尝试13311/13647/13572、未知28,证明口径差异。测试和预生产仍为809175b544f2526891ba6d2dece1a50eadaf57a0,均未发布本轮代码。MinIO/Gateway写路径不在修改范围,未验证文件上传和真实短信链路。
- 原失败保留:前端首轮高并发超时、观察器按钮/label不匹配、日期选定后未点确定、SQL fixture先用非统计状态failed以及清退fixture先缺关联检测记录均修正后重跑,不能记为产品通过。Prometheus隧道曾Connection reset造成一次真实503,重建只读连接后成功;未屏蔽应用异常。Redis5不支持认证GETDEL且原库缺表,后改独立库及真实Redis7进行隔离验收。
- 本地启动影响:首轮API启动曾自动尝试连接两个通道,因本地Gateway不可达失败,写入本地CmppConnectionState运行状态;未发送短信、未修改通道配置。后续延后启动重连、关闭相关扫描器与归档/报表调度。临时QA用户/操作日志/告警和业务fixture及认证前缀按本轮ID清理;不恢复或改写原有运行状态。
- 遗留边界:创建通道390宽原有布局拥挤,关闭按钮可见可用,未扩大为视觉改版;历史告警受Prometheus保留和采样限制,含等待触发周期,最后采样不等于恢复时刻。预生产历史ERR_CONNECTION_CLOSED根因和已有管理员会话锁定未在本轮解决,未尝试恢复管理员。未推送、未测试部署、未预生产部署,线上真实业务验收未执行。
本地证据目录:C:/Users/hectorzhao/AppData/Local/Temp/cmpp-nine-fixes-20260909。自动日志api-full.log、frontend-final.log、build-last.log、api-build-final.log、lint-last.log、security.log等;浏览器成功记录browser-run7.log、browser-extra2.log、browser-retirement2.log、browser-counts2.log及各browser-*目录截图/结果,失败日志保留。敏感认证值不写入文档或Git。
## 2026-09-10 引流拦截需求评估与方案
仅文档授权;本地main为5bcdbb2a03637b1ab1aeda59a4e1db9cc9fc4243,实际ls-remote远端main为6d63eb5452ffc7c802960d044bf598cc8646564d,暂存区空,保护既有19个跟踪修改及全部未跟踪文件。核对检测器、两条原文最长匹配路径、报备路由、Prisma模型和平台失败回执后,新增[专项方案](drainage-send-gating-plan-20260910.md),同步需求/风控索引/16项待执行用例。明确旧只识别规则被新需求替代的生效关系、多目标交集、号码清洗且原文不变、URL字面包含边界、审核和legacy任务兼容、拒绝回执/费用幂等、队列与撤销并发、数据迁移及阶段实施。
证据仅当前源码/模型和真实Git远端读取;未启动服务、未连接业务数据库或远端API,未验证PostgreSQL/Redis/MinIO/Gateway当前状态,未执行业务测试、构建、浏览器或发送。文档完成不代表引流门禁已实现。业务边界待明确项列在方案第9节;本轮运行代码/配置/数据库无修改,未提交、未推送、未测试部署、未预生产部署。文档开工副本及保护核验记录位于%TEMP%/cmpp-drainage-design-20260910。
用户随后明确URL规则:登记父域名授权自身及任意层级子域,路径/参数不限制,排除lisglo.cn.evil.com等伪包含。已将方案3.3改为URL解析后的hostname相等或点边界后缀匹配,同步需求和TC-DRAINAGE-GATE-05;仅历史带路径资料的迁移策略等仍待明确,域名边界已确定。
文档核验完成:4份既有文档保持开工字节前缀不变,专项方案6个相对链接有效,16项验收用例均明确待执行;git diff --check通过,暂存区仍空。src无修改;api仅保留开工已有metrics两文件修改。本轮交付为新增1份方案与4份既有文档追加,无代码实现、业务测试或提交/发布。
2026-09-10解释与补核:用户确认引流资料须平台审核通过,已写入方案及用例。将旧带路径资料/通道级报备术语改为业务示例和实施核查项。只读核对单条/批量路由失败、recordCmppFailureReceipt、queueFinalReceiptDeliveries及HTTP投递配置:CMPP路径生成REJECTD并按请求标记投递;非CMPP路由失败只刷新任务进度,未调用统一失败回执,列为后续闭环修复点。无代码修改或真实发送复现。
2026-09-10用户最终确认:旧通道报备配置由用户调整,仅要求配置功能正常;非CMPP提交本来就不应推送回执,未来另提需求。已修正专项方案的现状评价、来源分支、实施范围和验收条件,同步需求与用例;撤销此前将非CMPP不推送判为缺口及纳入修复的判断。未修改代码/配置/数据,未迁移旧报备,未提交或部署。
## 2026-09-10 引流发送资格实施与本地验收(13:20 CST)
授权:修改、本地提交、测试环境部署;未授权推送、预生产部署、发送/补发/重投/重新入队短信或修改既有客户/通道/余额配置。实施见 drainage-send-gating-plan-20260910.md 第10节,用例 TC-DRAINAGE-GATE-0116 分层记录。
- Gitmain 开工 HEAD 5bcdbb2a03637b1ab1aeda59a4e1db9cc9fc4243,实际远端 main 6d63eb5452ffc7c802960d044bf598cc8646564d。本轮保护42个已有具体文件;发布工具、metrics、AGENTS及治理草稿不提交。本轮变更前副本、日志及浏览器证据位于 %TEMP%/cmpp-drainage-implementation-20260910。
- 实现:全目标规范化与同签名审核校验、通道交集、批量/普通路由、Gateway每分片最终复核、域名边界及伪URL排除、判定快照、CMPP拒绝回执耐久恢复、非CMPP不推送、多引流统计和短信详情。运行时仅新门禁产生拒绝,不处理旧批准或重发旧短信。治理清理未实施。
- 新增 schema 迁移在独立真实 PostgreSQL 克隆库 cmpp_qa_drainage_1789017023546 应用成功;另一个早期迁移试验库保留,均未改本机业务原库。真实规则+资格HTTP接口验证NFKC号码/原文不变、两个目标通道交集、平台审核、报备撤销、写事务与复核并发锁、伪后缀/query/userInfo、转发请求拒绝、持久化决策及报备SQL;结果 real-gate2.log。仅构造隔离数据库夹具,无发送Worker、Gateway transport或提交outbox。
- 自动检查:API全量67套723项通过;最终定向2套152项通过。前端27文件136项通过;TypeScript、API构建、前端production构建通过。Gateway全量Go测试及vet通过。lint(含类型/Stylelint/CSS治理15项)、format:check、security:verify、deploy:verify、bundle:verify通过。原有API metrics未提交测试包含在工作区总数中,精确提交的发布validate独立核验,不冒称所有测试文件均被提交。改动文件必要格式化和未使用导入清理用于满足当前门禁,未触及其他会话源码。
- 浏览器:Browser插件不可用,使用既有Playwright/Edge,真实本地API+独立PostgreSQL库;Redis5仅隔离本机队列,登录使用测试Redis7的DB15独立随机前缀,经SSH转发,结束删除仅本轮认证键和临时QA用户。1600×1000、1366×768、390×844通过,拦截原因、显式关闭、刷新与跨路由正常,无pageerror。首轮在详情请求返回前断言历史占位,已修正测试等待,并为加载中新增明确提示;最终production构建再次验收通过。证据 browser-final.log 及该目录最新 browser-* 截图。
- 环境:13:12重新核验测试SSH和health可达,线上版本仍809175b544f2526891ba6d2dece1a50eadaf57a0。期间两次SSH连接超时,Tailscale通过DERP后恢复;不记作认证失败或网络根因已永久解决。本次测试发布包同时包含此前九项运营修复提交与本功能;不推送远端。
- 未执行:真实短信提交、供应商零Submit/长短信跨分片对账、客户回执ACK/离线送达、费用和部分已发记录对账、TPS/故障注入容量测试;没有专项发送授权,以上不以单元、夹具或浏览器代替。既有通道配置不修改,MinIO材料无变更。提交及标准发布阶段、磁盘/恢复资产与目标页面结果在后续记录补齐。
## 2026-09-10 引流门禁测试部署完成(14:00 CST)
应用0c3f820cc92eeae8c996ac7d6d8d6db98e049ea7已本地提交并通过标准工具部署测试,原版本809175b544f2526891ba6d2dece1a50eadaf57a0;未推送、未部署预生产。精确提交validate为API67套722项、前端27文件136项及相关门禁通过。首次preflight的安全代理运行目录缺失由用户单独授权修复,复检后正常发布。13项服务active、三个Gateway Stream pending/lag=0、消息/提交数不变、迁移/资源摘要/日志验证通过。真实测试管理员和六页面、三尺寸历史短信详情通过,线上不回填旧消息。
[本次发布验收](release-20260910-test-drainage.md)记录完整版本、工具未提交摘要、备份、容量清单与阶段时间。候选77.9秒、备份35.7秒,停服务至恢复约14秒;发布后系统盘可用10.37GB,比发布前减少约1.48GB,旧版本/候选/备份未清理。真实发送、供应商Submit、客户回执ACK和费用对账仍未执行,不能以页面通过代替。原网络超时根因与预生产历史故障未宣称关闭。
## 2026-09-10 通道敏感词需求评估
授权仅评估。main/HEAD为8694782,实际远端main为6d63eb5452ffc7c802960d044bf598cc8646564d,暂存区空;保护全部已有源码、工具、网络及HTTP评估文档。本轮完整阅读UI规范及当前敏感词页、词库/风控和普通/微批路由、最终Gateway门禁与结果处理,新增[channel-sensitive-words方案](channel-sensitive-words-plan-20260910.md),同步需求/风控索引及12项待执行用例。
现状为全局SensitiveWord原文includes命中统一block,无通道模型或Tab。建议独立库、候选排除、原选路算法、最终复核;零写入方可有限重选,部分已发不整体重发,全候选排除才形成适用失败闭环。普通包含/三网统一/全排除失败为建议首版规则,不将未经确认的抗干扰或等待恢复加入实现。中等规模跨模块改造,不能仅改UI;无固定工时或TPS承诺。
本轮未连接真实API/PG/Redis/MinIO/Gateway进行业务验收,未启动或发送短信、未改规则/网络配置。仅文档与源码评估,功能和12项用例均未执行;无代码修改、无提交、无推送、无测试/预生产部署。文档保护副本及核验位于%TEMP%/cmpp-channel-sensitive-assessment-20260910。此前绕过OpenWrt任务仍未通过候选网关出口验证,本评估不宣称该任务完成。
本轮文档核验:四份既有文档的原字节前缀保留,其他开工保护文件摘要未变;专项方案链接和diff检查通过,暂存区为空。
## 2026-09-10 通道敏感词方案缩减为仅选路过滤
用户明确因性能考虑,暂不做入队后通道敏感词复核。已修订专项方案、需求和风控索引,以及TC-CHANNEL-WORD-08/09/10/12等用例:普通/微批及原有新选路时按快照过滤;已读取快照的微批与已选路消息不追溯配置变化。移除新增Gateway/逐片检查、发送授权锁、final决策和配置变化触发重选,保留管理端编辑并发保护及既有引流门禁。前一节初稿的最终复核/零写入新增重选描述由本记录取代,不再作为第一版验收要求。
仅文档修改,未修改代码或网络/业务配置,未提交、未推送、未测试或预生产部署。保护副本位于%TEMP%/cmpp-channel-sensitive-routing-only-20260910;执行文档一致性、链接及diff核验,不重跑业务测试。
## 2026-09-10 通道敏感词实施与本地验收
授权:按修订方案修改、本地提交、部署测试环境;不推送、不部署预生产,不发送/重投短信或改现有线上业务配置。开工main=8694782,真实远端main=6d63eb5;测试版本0c3f820。原metrics、工具、规范、网络/HTTP评估文档受保护,提交只纳入本轮代码及主题文档精确部分。
实现:独立通道词Tab/API、运行时参数/平台管理员校验、version冲突、软删除恢复和事务审计;普通/微批选路快照排除候选,运营端最近10次解释;CSW失败与channelWordFinalizationPending耐久恢复,非CMPP不新增回执。Gateway/逐片检查未改。新增迁移不改历史配置。设计和边界见channel-sensitive-words-plan-20260910.md第8节。
验证:API全量69套745项通过;后追加恢复用例,发送链定向138项通过。前端全量28套139项通过,API构建/类型、production前端构建、lint/格式/CSS/安全/bundle通过(lint既有28警告)。初轮测试选择器及测试类型错误已修正;原页面空依赖列缓存随组件拆分取消,避免旧查询闭包。隔离真实PostgreSQL迁移与并发/路由/失败SQL验证通过;100条微批通道词读写各1次,390~441ms包含既有引流,不是发送TPS。
真实浏览器:production构建、Nest API、隔离PG、Redis7认证前缀,三尺寸CRUD/软删除/独立筛选/关闭/刷新/跨路由/详情通过,pageerror=0;匿名API401。未启动短信发送Worker/Gateway transport。证据%TEMP%/cmpp-channel-sensitive-implementation-20260910,包括real-api-failure.log、browser-1789026246272、各质量门禁日志。实际短信发送/客户回执ACK/费用流水与完整吞吐未执行。
本地实现和验收完成;本地提交、测试部署正在执行,推送/预生产未授权。测试部署成功后另补精确版本、恢复资产、磁盘增量及真实页面验收。
## 2026-09-10 通道敏感词测试交付完成
应用`8e4bc5a20e4d98e96fc8572fabbac149ba12c8e6`已本地提交,并通过标准工具部署测试;精确归档validate为API69套745项、前端28套139项及门禁通过。13项服务active,三个Stream pending/lag=0,消息119509/提交130769前后未变,新增迁移/HTTP产物摘要/日志验证通过。已有管理员正常登录、新Tab真实空词库API200、三尺寸显式关闭/刷新/跨路由/历史详情通过;没有保存线上词库或发送短信。观察器同名关闭按钮修正后通过,原失败保留。
[发布验收与容量清单](release-20260910-test-channel-sensitive-words.md)记录精确版本、工具未提交摘要、独立备份、47目录盘点和时间。prepare56.5秒、备份35.2秒、停止至恢复17.0秒;系统盘可用10.35GB→8.81GB、使用率92%,增量约1.54GB,旧版本/候选/备份均未清理,容量治理未完成。未推送、未部署预生产,实际发送/客户回执ACK/费用流水与完整吞吐未执行。
+5
View File
@@ -142,3 +142,8 @@
### 公共分页容量展示补充(2026-09-08 夜)
公共Pagination中的容量Select只显示“10/25/50/100 条/页”选中项,不显示“每页数量”标题;与翻页控件放在同一操作区。复用sr-only隐藏标签并以唯一id关联,保持键盘和读屏可访问;不要删除其他表单Select的可见标签。签名质量四Tab和通道报备明细沿用该组件,默认25和独立筛选状态保持。顶栏通知接口失败时须说明计数暂不可用,既有真实值只能作为上次数据保留,不能归零伪装无通知。
### 弹窗显式关闭补充(2026-09-09)
公共Modal默认closeOnBackdrop=false、closeOnEscape=false;全部创建/修改表单沿用该默认,点击遮罩、内容空白或Escape不关闭。页脚关闭/取消及右上角叉是显式关闭入口,仍保留dirty确认;提交成功可由业务关闭。只读公共弹窗同样采用显式关闭默认,自定义只读质量抽屉保持原交互。
+1 -1
View File
@@ -44,7 +44,7 @@ func main() {
protocolLogPublisher.SuccessSampleRate = positiveEnvInt("GATEWAY_PROTOCOL_LOG_SUCCESS_SAMPLE_PERCENT", 10)
protocolLogPublisher.MaxLen = int64(positiveEnvInt("GATEWAY_PROTOCOL_LOG_STREAM_MAX_LEN", 200000))
}
upstreamManager := &upstream.Manager{APIBaseURL: apiBaseURL, EventAPIBaseURL: callbackBaseURL, ProtocolLogPublisher: protocolLogPublisher, GatewayInstanceID: gatewayInstanceID}
upstreamManager := &upstream.Manager{DrainageGuardEnabled: true, APIBaseURL: apiBaseURL, EventAPIBaseURL: callbackBaseURL, ProtocolLogPublisher: protocolLogPublisher, GatewayInstanceID: gatewayInstanceID}
var worker *submitworker.Worker
var resultOutbox *resultoutbox.Outbox
channelLimiter, err := ratelimit.New(os.Getenv("REDIS_URL"))
@@ -0,0 +1,70 @@
package upstream
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"cmpp-platform/gateway/internal/queue"
)
type drainageBusinessRejection struct{ reason string }
func (e *drainageBusinessRejection) Error() string { return "引流资格拒绝: " + e.reason }
// Recheck current DB authorization after queue/connection waits. Neither a stale
// command nor a disabled detection flag on a command can grant permission.
func (m *Manager) authorizeDrainage(ctx context.Context, cmd queue.SubmitCommand) error {
if !m.DrainageGuardEnabled {
return nil
}
digest := sha256.Sum256([]byte(cmd.Content))
body, err := json.Marshal(map[string]string{"submitId": cmd.SubmitID, "channelId": cmd.ChannelID, "contentHash": hex.EncodeToString(digest[:])})
if err != nil {
return err
}
base := m.EventAPIBaseURL
if base == "" {
base = m.APIBaseURL
}
if base == "" {
return fmt.Errorf("引流资格校验服务未配置")
}
checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(checkCtx, http.MethodPost, strings.TrimRight(base, "/")+"/gateway/events/authorize-drainage", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := m.HTTPClient
if client == nil {
client = &http.Client{Timeout: 10 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("引流资格校验暂不可用: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("引流资格校验失败: HTTP %d", resp.StatusCode)
}
var result struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 16384)).Decode(&result); err != nil {
return fmt.Errorf("引流资格响应无效: %w", err)
}
if !result.Allowed {
return &drainageBusinessRejection{reason: result.Reason}
}
return nil
}
@@ -0,0 +1,43 @@
package upstream
import (
"cmpp-platform/gateway/internal/queue"
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestDrainageGuardFailClosed(t *testing.T) {
for _, body := range []string{`{"allowed":false,"reason":"未报备"}`, `{}`, `not json`} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(body)) }))
m := &Manager{DrainageGuardEnabled: true, EventAPIBaseURL: server.URL}
if err := m.authorizeDrainage(context.Background(), queue.SubmitCommand{SubmitID: "s", Content: "原文"}); err == nil {
t.Fatalf("unexpected authorization: %s", body)
}
server.Close()
}
}
func TestDrainageGuardRequiresFreshResponse(t *testing.T) {
allowed := true
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/gateway/events/authorize-drainage" {
t.Error(r.URL.Path)
}
if allowed {
w.Write([]byte(`{"allowed":true}`))
} else {
w.WriteHeader(503)
}
}))
defer server.Close()
m := &Manager{DrainageGuardEnabled: true, EventAPIBaseURL: server.URL}
cmd := queue.SubmitCommand{SubmitID: "s", Content: "unchanged"}
if err := m.authorizeDrainage(context.Background(), cmd); err != nil {
t.Fatal(err)
}
allowed = false
if err := m.authorizeDrainage(context.Background(), cmd); err == nil {
t.Fatal("reused stale permission")
}
}
+1
View File
@@ -25,6 +25,7 @@ const (
)
type Manager struct {
DrainageGuardEnabled bool
APIBaseURL string
EventAPIBaseURL string
HTTPClient *http.Client
+19 -1
View File
@@ -32,7 +32,7 @@ func (m *Manager) Submit(ctx context.Context, cmd queue.SubmitCommand) (queue.Su
// This cannot make the supplier/Redis boundary globally atomic, but it avoids
// holding the supplier slot for an API round trip and minimizes untracked sends.
return m.SubmitSegmentPublisher.PublishSubmitSegment(ctx, cmd, segment)
})
}, func() error { return m.authorizeDrainage(ctx, cmd) })
return result, err
}
@@ -40,6 +40,7 @@ func (p *connectionPool) submit(
ctx context.Context,
cmd queue.SubmitCommand,
onSegment func(queue.SubmitSegmentResult) error,
authorize ...func() error,
) (final queue.SubmitResult, finalErr error) {
defer func() {
for _, segment := range final.Segments {
@@ -67,6 +68,23 @@ func (p *connectionPool) submit(
result.Segments = segments
return result, err
}
for _, check := range authorize {
if err := check(); err != nil {
release()
code := "DRNCHK"
status := "rejected"
if _, business := err.(*drainageBusinessRejection); business {
code = "DRN"
} else if len(segments) == 0 {
// No bytes were submitted. Let the existing durable worker retry
// a technical outage with its bounded backoff/dead-letter policy.
status = ""
}
result := submitResult(cmd, 0, "", status, code, err.Error())
result.Segments = segments
return result, err
}
}
supplierStartedAt := time.Now()
seq, gatewayMessageID, result, err := conn.submitPart(ctx, cmd, part)
metrics.ObserveSubmitStage("supplier_rtt", err == nil, time.Since(supplierStartedAt))
@@ -0,0 +1,29 @@
import { request, withQuery } from '../core/httpClient';
import type { PagedResult } from '../types';
export type ChannelWord = {
id: string;
channelId: string;
word: string;
status: string;
remark: string;
version: number;
updatedAt: string;
channel: { id: string; name: string; status: string };
};
export type ChannelWordForm = { channelId: string; word: string; status: string; remark: string };
export type ChannelWordQuery = { channelId: string; keyword: string; status: string; page: number; pageSize: number };
const path = '/admin/dictionaries/channel-sensitive-words';
export const channelWordsApi = {
list: (query: ChannelWordQuery) => request<PagedResult<ChannelWord>>(withQuery(path, query)),
save: (form: ChannelWordForm, item?: ChannelWord) =>
request<ChannelWord>(item ? `${path}/${encodeURIComponent(item.id)}` : path, {
method: item ? 'PATCH' : 'POST',
body: JSON.stringify({ ...form, ...(item ? { version: item.version } : {}) }),
}),
remove: (item: ChannelWord) =>
request<{ deleted: boolean }>(`${path}/${encodeURIComponent(item.id)}`, {
method: 'DELETE',
body: JSON.stringify({ version: item.version }),
}),
};
@@ -7,6 +7,24 @@ import type {
} from '../types';
export const adminInfrastructureMonitoringApi = {
getInfrastructureAlertHistory: (from?: string, to?: string, page = 1) =>
request<{
items: Array<{
id: string;
name: string;
severity: string;
service: string;
instance: string;
startedAt: string;
firstObservedAt: string;
lastObservedAt: string;
}>;
total: number;
page: number;
pageSize: number;
startDate: string;
endDate: string;
}>(withQuery('/admin/infrastructure-monitoring/alert-history', { from, to, page })),
getInfrastructureMonitoringOverview: (range: InfrastructureMonitoringRange) =>
request<InfrastructureMonitoringOverview>(withQuery('/admin/infrastructure-monitoring/overview', { range })),
getInfrastructureMonitoringNotificationSummary: (signal?: AbortSignal) =>
+33 -2
View File
@@ -118,6 +118,20 @@ export type SendQualityResponse = {
};
export type SmsMessageRecord = {
channelWordDecisions?: Array<{
id: string;
decidedAt: string;
snapshot: {
selectedChannelId: string | null;
reason: string | null;
hits: Array<{
channelId: string;
channelName?: string;
count: number;
samples: Array<{ id: string; word: string; version: number }>;
}>;
};
}>;
id: string;
tenantId?: string | null;
batchTaskId?: string | null;
@@ -134,6 +148,13 @@ export type SmsMessageRecord = {
categories?: string[];
truncated?: boolean;
} | null;
drainageGate?: {
version: string;
evaluatedAt: string;
reason: string | null;
reasonCode: string | null;
targets: Array<{ category: string; text: string; value: string; materialIds: string[] }>;
} | null;
drainageDetectionVersion?: string | null;
drainageEvaluatedAt?: string | null;
clientSrcId?: string | null;
@@ -331,7 +352,14 @@ export type SystemLogExportResult = {
recordCount: number;
truncated: boolean;
content: string;
filters: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string };
filters: {
keyword?: string;
level?: string;
module?: string;
range?: string;
createdAtFrom?: string;
createdAtTo?: string;
};
};
export type DailyReconciliationReport = {
@@ -638,7 +666,10 @@ export type GatewaySubmitException = {
updatedAt: string;
tenant?: Pick<TenantOption, 'id' | 'name' | 'code' | 'status'> | null;
application?: Pick<EnterpriseApplication, 'id' | 'tenantId' | 'name' | 'status'> | null;
channel?: Pick<AdminChannel, 'id' | 'code' | 'name' | 'status' | 'carrier' | 'sendRegion' | 'rateLimitPerSecond'> | null;
channel?: Pick<
AdminChannel,
'id' | 'code' | 'name' | 'status' | 'carrier' | 'sendRegion' | 'rateLimitPerSecond'
> | null;
};
export type GatewaySubmitExceptionResponse = PagedResponse<GatewaySubmitException> & {
+17
View File
@@ -10,6 +10,23 @@
display: none;
}
.admin-analytics-page .signature-retirement-heatmap__heading {
flex-wrap: wrap;
}
.admin-analytics-page .analytics-activity-filters {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-3);
width: 100%;
}
.admin-analytics-page .analytics-activity-filters .ui-field {
flex: 1 1 180px;
min-width: 0;
}
@media (width <= 600px) {
.admin-analytics-page .page-heading {
align-items: stretch;
@@ -8,6 +8,47 @@ const { api } = vi.hoisted(() => ({
vi.mock('@/api/adminApi', () => ({ adminApi: api }));
describe('independent analytics tabs', () => {
it('combines separate activity search fields with AND and preserves them across tabs', async () => {
api.getSignatureRetirementHeatmap.mockResolvedValue({
items: [],
dimensions: [
{
dimensionType: 'channel',
signatureId: 'a',
signatureName: '签名甲',
tenantName: '企业甲',
applicationName: '应用甲',
channelName: '通道甲',
channelId: 'c',
carrier: 'mobile',
approvedAt: '2026-08-01',
},
{
dimensionType: 'channel',
signatureId: 'b',
signatureName: '签名乙',
tenantName: '企业甲',
applicationName: '应用乙',
channelName: '通道乙',
channelId: 'd',
carrier: 'unicom',
approvedAt: '2026-08-01',
},
],
});
render(<AdminAnalyticsPage />);
fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' }));
const panel = screen.getByRole('region', { name: '通道签名活跃度' });
await within(panel).findByText('签名甲');
fireEvent.change(within(panel).getByLabelText('企业'), { target: { value: '企业甲' } });
fireEvent.change(within(panel).getByLabelText('企业应用'), { target: { value: '应用甲' } });
await waitFor(() => expect(within(panel).queryByText('签名乙')).not.toBeInTheDocument());
fireEvent.change(within(panel).getByLabelText('通道'), { target: { value: '通道乙' } });
await waitFor(() => expect(within(panel).queryByText('签名甲')).not.toBeInTheDocument());
fireEvent.click(screen.getByRole('tab', { name: '企业签名活跃度' }));
fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' }));
expect(within(panel).getByLabelText('企业应用')).toHaveValue('应用甲');
});
beforeEach(() => {
vi.resetAllMocks();
api.getSignatureQuality.mockImplementation(async (query) => ({ ...query, total: 0, items: [] }));
+29 -17
View File
@@ -382,8 +382,8 @@ function RetirementHeatmap({
title: string;
}) {
const [pageState, setPageState] = useState({ key: '', page: 1 });
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword.trim().toLocaleLowerCase('zh-CN'));
const [filters, setFilters] = useState({ tenantName: '', applicationName: '', signatureName: '', channelName: '' });
const deferredFilters = useDeferredValue(filters);
const visible = items.filter((item) => item.dimensionType === dimensionType);
const dates = previousDateKeys(date, 30);
const cellMap = new Map(
@@ -394,12 +394,14 @@ function RetirementHeatmap({
);
const rows = dimensions
.filter((item) => item.dimensionType === dimensionType)
.filter(
(item) =>
!deferredKeyword ||
[item.channelName, item.tenantName, item.applicationName, item.signatureName].some((value) =>
value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword),
),
.filter((item) =>
Object.entries(deferredFilters).every(
([key, value]) =>
!value.trim() ||
(item[key as keyof typeof deferredFilters] ?? '')
.toLocaleLowerCase('zh-CN')
.includes(value.trim().toLocaleLowerCase('zh-CN')),
),
)
.map((item) => ({
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
@@ -419,7 +421,7 @@ function RetirementHeatmap({
}))
.sort((left, right) => right.total - left.total || left.signatureName.localeCompare(right.signatureName, 'zh-CN'));
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
const paginationKey = JSON.stringify([date, deferredKeyword, dimensionType, dimensions.length, pageSize]);
const paginationKey = JSON.stringify([date, deferredFilters, dimensionType, dimensions.length, pageSize]);
const page = pageState.key === paginationKey ? pageState.page : 1;
const setPage = (value: number) => setPageState({ key: paginationKey, page: value });
const currentPage = Math.min(page, totalPages);
@@ -432,13 +434,23 @@ function RetirementHeatmap({
<h2>{title}</h2>
<p className="muted"></p>
</div>
<div className="signature-retirement-heatmap__actions">
<Input
aria-label={`${title}搜索通道、企业、企业应用或签名`}
onChange={(event) => setKeyword(event.target.value)}
placeholder={dimensionType === 'channel' ? '搜索通道、企业、应用或签名' : '搜索企业应用或签名'}
value={keyword}
/>
<div className="analytics-activity-filters">
{(
[
['tenantName', '企业'],
['applicationName', '企业应用'],
['signatureName', '签名'],
...(dimensionType === 'channel' ? [['channelName', '通道']] : []),
] as Array<[keyof typeof filters, string]>
).map(([key, label]) => (
<Input
key={key}
label={label}
placeholder={`搜索${label}`}
value={filters[key]}
onChange={(event) => setFilters((current) => ({ ...current, [key]: event.target.value }))}
/>
))}
<Tag tone="info">T-1 T-30</Tag>
</div>
</div>
@@ -500,7 +512,7 @@ function RetirementHeatmap({
</>
) : (
<p className="empty-state">
{deferredKeyword
{Object.values(deferredFilters).some((value) => value.trim())
? '没有匹配企业、企业应用或签名的热力图维度。'
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
</p>
+101 -22
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
import { ChannelSensitiveWordsPanel } from './sensitive-words/ChannelSensitiveWordsPanel';
import { adminApi, type DictionaryItem } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
@@ -31,6 +32,36 @@ const levelOptions = [
];
export function AdminSensitiveWordsPage() {
const [tab, setTab] = useState('platform');
const [channelVisited, setChannelVisited] = useState(false);
return (
<section className="page-stack admin-security-page">
<div className="page-heading">
<div>
<Breadcrumb items={['安全控制', '敏感词管理']} />
<h1></h1>
</div>
</div>
<Tabs
value={tab}
onChange={(value) => {
setTab(value);
if (value === 'channel') setChannelVisited(true);
}}
items={[
{ label: '平台敏感词', value: 'platform', content: null },
{ label: '通道敏感词', value: 'channel', content: null },
]}
/>
<div hidden={tab !== 'platform'}>
<PlatformSensitiveWordsPanel />
</div>
<div hidden={tab !== 'channel'}>{channelVisited ? <ChannelSensitiveWordsPanel /> : null}</div>
</section>
);
}
function PlatformSensitiveWordsPanel() {
const [items, setItems] = useState<SensitiveWordItem[]>([]);
const [keyword, setKeyword] = useState('');
const [word, setWord] = useState('');
@@ -39,7 +70,8 @@ export function AdminSensitiveWordsPage() {
const [error, setError] = useState('');
function loadData() {
adminApi.listSensitiveWords({ keyword })
adminApi
.listSensitiveWords({ keyword })
.then((data) => {
setItems(data as SensitiveWordItem[]);
setError('');
@@ -51,15 +83,37 @@ export function AdminSensitiveWordsPage() {
loadData();
}, []);
const filteredItems = useMemo(() => items.filter((item) => {
const text = [item.word, item.level, item.status].join(' ');
return !keyword || text.includes(keyword);
}), [items, keyword]);
const filteredItems = useMemo(
() =>
items.filter((item) => {
const text = [item.word, item.level, item.status].join(' ');
return !keyword || text.includes(keyword);
}),
[items, keyword],
);
const columns = useMemo<Array<TableColumn<SensitiveWordItem>>>(() => [
const columns: Array<TableColumn<SensitiveWordItem>> = [
{ key: 'word', title: '敏感词', width: '180px', render: (record) => <strong>{record.word}</strong> },
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level ?? 'medium'] ?? 'warning'}>{levelLabelMap[record.level ?? 'medium'] ?? record.level}</Tag> },
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : record.status === 'deleted' ? '已删除' : '停用'}</Tag> },
{
key: 'level',
title: '级别',
width: '120px',
render: (record) => (
<Tag tone={levelToneMap[record.level ?? 'medium'] ?? 'warning'}>
{levelLabelMap[record.level ?? 'medium'] ?? record.level}
</Tag>
),
},
{
key: 'status',
title: '状态',
width: '120px',
render: (record) => (
<Tag tone={record.status === 'active' ? 'success' : 'neutral'}>
{record.status === 'active' ? '启用' : record.status === 'deleted' ? '已删除' : '停用'}
</Tag>
),
},
{ key: 'createdAt', title: '创建时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
{
key: 'actions',
@@ -67,15 +121,26 @@ export function AdminSensitiveWordsPage() {
width: '130px',
align: 'right',
render: (record) => (
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteSensitiveWord(record.id).then(loadData).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
<Button
icon={<Trash2 size={15} />}
onClick={() =>
adminApi
.deleteSensitiveWord(record.id)
.then(loadData)
.catch((failure: Error) => setError(failure.message))
}
size="sm"
variant="danger"
>
</Button>
),
},
], []);
];
function addItem() {
adminApi.createSensitiveWord({ word, level, status: 'active' })
adminApi
.createSensitiveWord({ word, level, status: 'active' })
.then(() => {
setWord('');
setLevel('medium');
@@ -89,10 +154,11 @@ export function AdminSensitiveWordsPage() {
<section className="page-stack admin-security-page">
<div className="page-heading">
<div>
<Breadcrumb items={['安全控制', '敏感词管理']} />
<h1></h1>
<h2></h2>
</div>
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}></Button>
<Button icon={<Plus size={16} />} onClick={() => setModalOpen(true)}>
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
@@ -105,8 +171,12 @@ export function AdminSensitiveWordsPage() {
value={keyword}
/>
<div className="admin-security-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => setKeyword('')} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={loadData}>
</Button>
<Button onClick={() => setKeyword('')} variant="ghost">
</Button>
</div>
</div>
@@ -115,19 +185,28 @@ export function AdminSensitiveWordsPage() {
</div>
<Modal
footer={(
footer={
<>
<Button onClick={() => setModalOpen(false)} variant="ghost"></Button>
<Button disabled={!word} onClick={addItem}></Button>
<Button onClick={() => setModalOpen(false)} variant="ghost">
</Button>
<Button disabled={!word} onClick={addItem}>
</Button>
</>
)}
}
onClose={() => setModalOpen(false)}
open={modalOpen}
size="xl"
title="添加敏感词"
>
<div className="admin-security-form">
<Input label="敏感词" onChange={(event) => setWord(event.target.value)} placeholder="请输入敏感词" value={word} />
<Input
label="敏感词"
onChange={(event) => setWord(event.target.value)}
placeholder="请输入敏感词"
value={word}
/>
<Select
label="风险级别"
onChange={(event) => setLevel(event.target.value)}
@@ -197,12 +197,15 @@ export function AdminSignatureRetirementPage() {
{item.dailyGroupKey ? (
<details>
<summary>{item.detections?.length ?? 0} </summary>
{item.content.split('\n').map((line, index) => (
<p key={index}>{line}</p>
))}
{retirementDisplayContent(item.content, item.tenantName)
.split('\n')
.filter(Boolean)
.map((line, index) => (
<p key={index}>{line}</p>
))}
</details>
) : (
item.content
retirementDisplayContent(item.content, item.tenantName)
)}
</div>
</div>
@@ -983,3 +986,17 @@ function differenceInDateKeys(from: string, to: string) {
const toDate = new Date(`${to}T12:00:00+08:00`);
return Math.round((toDate.getTime() - fromDate.getTime()) / 86_400_000);
}
function retirementDisplayContent(content: string, tenantName?: string | null) {
return content
.split('\n')
.map((line) => {
const trimmed = line.trimStart();
if (!tenantName || !trimmed.startsWith('请通知')) return line;
const rest = trimmed.slice(3).trimStart();
if (!rest.startsWith(tenantName)) return line;
const afterName = rest.slice(tenantName.length).trimStart();
return /^[:]/.test(afterName) ? afterName.slice(1).trimStart() : line;
})
.join('\n');
}
@@ -0,0 +1,46 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { AdminSmsApplicationFormPage } from './AdminSmsApplicationFormPage';
vi.mock('@/api/adminApi', () => ({
adminApi: {
listChannelGroups: vi
.fn()
.mockResolvedValue([{ id: 'group', name: '移动测试组', carrier: 'mobile', status: 'active' }]),
},
}));
describe('application form feedback', () => {
it('hides HTTP addresses with the protocol and retains input across toggles; invalid save uses a modal', async () => {
render(
<MemoryRouter initialEntries={['/enterprise/e/new']}>
<Routes>
<Route path="/enterprise/:enterpriseId/new" element={<AdminSmsApplicationFormPage />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByRole('button', { name: '未开通' })).toBeInTheDocument());
expect(screen.queryByLabelText(/^HTTP 回执地址/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '未开通' }));
fireEvent.change(screen.getByLabelText(/^HTTP 回执地址/), { target: { value: 'https://example.test/receipt' } });
expect(screen.getByLabelText(/^HTTP 上行地址/)).toBeInTheDocument();
fireEvent.click(
within(document.querySelector('.admin-app-protocol-section--http') as HTMLElement).getByRole('button', {
name: '已开通',
}),
);
expect(screen.queryByLabelText(/^HTTP 回执地址/)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '未开通' }));
expect(screen.getByLabelText(/^HTTP 回执地址/)).toHaveValue('https://example.test/receipt');
fireEvent.change(screen.getByLabelText(/^应用名称/), { target: { value: '测试应用' } });
fireEvent.click(screen.getByLabelText('移动通道组'));
fireEvent.click(screen.getByRole('option', { name: '移动测试组' }));
fireEvent.change(screen.getByLabelText(/^应用扩展码/), { target: { value: 'invalid' } });
fireEvent.click(screen.getByRole('button', { name: '创建应用' }));
const dialog = screen.getByRole('dialog', { name: '短信应用保存失败' });
expect(within(dialog).getByRole('alert')).toHaveTextContent('应用扩展码只能填写数字');
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
expect(dialog).toBeInTheDocument();
});
});
+415 -118
View File
@@ -1,8 +1,14 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, Globe2, Info, RadioTower, RefreshCw } from 'lucide-react';
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication, type HttpApiConfig } from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, Input, Select, Tag } from '@/components/ui';
import {
adminApi,
type ChannelGroup,
type DictionaryItem,
type EnterpriseApplication,
type HttpApiConfig,
} from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, Input, Modal, Select, Tag } from '@/components/ui';
import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
import { createRandomHex } from '@/utils/randomId';
@@ -47,12 +53,27 @@ export function AdminSmsApplicationFormPage() {
const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true);
const [ipAddress, setIpAddress] = useState('');
const [httpConfig, setHttpConfig] = useState<HttpApiConfig>({
enabled: false, sendEnabled: true, messageQueryEnabled: true, receiptWebhookEnabled: true,
uplinkWebhookEnabled: true, uplinkQueryEnabled: true, credentialSelfServiceEnabled: true,
qpsLimit: 10, timestampToleranceSeconds: 300, maxCredentialCount: 2, uplinkRetentionDays: 90,
maxQueryRangeDays: 31, maxPageSize: 100, receiptDeliveryMode: 'http', uplinkDeliveryMode: 'http',
webhookRetryEnabled: true, webhookMaxAttempts: 7, webhookTimeoutSeconds: 10, requireHttps: true,
allowClientManualRetry: true, allowClientTest: true,
enabled: false,
sendEnabled: true,
messageQueryEnabled: true,
receiptWebhookEnabled: true,
uplinkWebhookEnabled: true,
uplinkQueryEnabled: true,
credentialSelfServiceEnabled: true,
qpsLimit: 10,
timestampToleranceSeconds: 300,
maxCredentialCount: 2,
uplinkRetentionDays: 90,
maxQueryRangeDays: 31,
maxPageSize: 100,
receiptDeliveryMode: 'http',
uplinkDeliveryMode: 'http',
webhookRetryEnabled: true,
webhookMaxAttempts: 7,
webhookTimeoutSeconds: 10,
requireHttps: true,
allowClientManualRetry: true,
allowClientTest: true,
});
const [httpIpAddress, setHttpIpAddress] = useState('');
const [receiptWebhookUrl, setReceiptWebhookUrl] = useState('');
@@ -62,6 +83,7 @@ export function AdminSmsApplicationFormPage() {
const [unicomGroupId, setUnicomGroupId] = useState('');
const [telecomGroupId, setTelecomGroupId] = useState('');
const [error, setError] = useState('');
const [saveError, setSaveError] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -92,7 +114,9 @@ export function AdminSmsApplicationFormPage() {
}
const [groupItems, application, routeRules] = await Promise.all([
adminApi.listChannelGroups(),
isEdit && appId ? adminApi.getEnterpriseApplication(appId) : Promise.resolve<EnterpriseApplication | null>(null),
isEdit && appId
? adminApi.getEnterpriseApplication(appId)
: Promise.resolve<EnterpriseApplication | null>(null),
isEdit ? adminApi.listChannelRouteRules() : Promise.resolve<DictionaryItem[]>([]),
]);
if (cancelled) {
@@ -122,19 +146,20 @@ export function AdminSmsApplicationFormPage() {
useEffect(() => {
if (!appId) return;
let cancelled = false;
Promise.all([
adminApi.getApplicationHttpApiConfig(appId),
adminApi.listApplicationHttpWebhooks(appId),
]).then(([result, webhooks]) => {
if (cancelled) return;
if (result.config) setHttpConfig(result.config);
setHttpIpAddress(result.ipAllowlist.join('\n'));
setReceiptWebhookUrl(webhooks.find((item) => item.eventType === 'receipt')?.url ?? '');
setUplinkWebhookUrl(webhooks.find((item) => item.eventType === 'uplink')?.url ?? '');
}).catch((failure: Error) => {
if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败');
});
return () => { cancelled = true; };
Promise.all([adminApi.getApplicationHttpApiConfig(appId), adminApi.listApplicationHttpWebhooks(appId)])
.then(([result, webhooks]) => {
if (cancelled) return;
if (result.config) setHttpConfig(result.config);
setHttpIpAddress(result.ipAllowlist.join('\n'));
setReceiptWebhookUrl(webhooks.find((item) => item.eventType === 'receipt')?.url ?? '');
setUplinkWebhookUrl(webhooks.find((item) => item.eventType === 'uplink')?.url ?? '');
})
.catch((failure: Error) => {
if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败');
});
return () => {
cancelled = true;
};
}, [appId]);
function goBack() {
@@ -160,12 +185,9 @@ export function AdminSmsApplicationFormPage() {
setDownstreamUplinkRetryEnabled(application.downstreamUplinkRetryEnabled !== false);
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
const activeRules = routeRules.filter((rule) => (
rule.applicationId === application.id
&& rule.status !== 'deleted'
&& !rule.province
&& !rule.channelId
));
const activeRules = routeRules.filter(
(rule) => rule.applicationId === application.id && rule.status !== 'deleted' && !rule.province && !rule.channelId,
);
setMobileGroupId(getRouteGroupId(activeRules, 'mobile'));
setUnicomGroupId(getRouteGroupId(activeRules, 'unicom'));
setTelecomGroupId(getRouteGroupId(activeRules, 'telecom'));
@@ -173,7 +195,7 @@ export function AdminSmsApplicationFormPage() {
async function submit() {
if (!enterpriseId) {
setError('缺少企业 ID');
setSaveError('缺少企业 ID');
return;
}
const selectedGroups = [
@@ -182,29 +204,29 @@ export function AdminSmsApplicationFormPage() {
{ carrier: 'telecom' as Carrier, groupId: telecomGroupId },
].filter((item) => item.groupId);
if (selectedGroups.length === 0) {
setError('请至少配置一个运营商通道组');
setSaveError('请至少配置一个运营商通道组');
return;
}
if (!isValidMoneyInput(customerUnitPrice)) {
setError('客户单价必须是非负金额,且最多保留小数点后 4 位');
setSaveError('客户单价必须是非负金额,且最多保留小数点后 4 位');
return;
}
const normalizedExtension = applicationExtension.trim();
const normalizedFillPrefix = accessNumberFillPrefix.trim();
if (normalizedExtension && !/^\d+$/.test(normalizedExtension)) {
setError('应用扩展码只能填写数字');
setSaveError('应用扩展码只能填写数字');
return;
}
if (accessNumberFillEnabled && !normalizedExtension) {
setError('开启接入号填充时必须填写应用扩展码');
setSaveError('开启接入号填充时必须填写应用扩展码');
return;
}
if (accessNumberFillEnabled && !/^\d+$/.test(normalizedFillPrefix)) {
setError('开启接入号填充时必须填写数字格式的填充前缀');
setSaveError('开启接入号填充时必须填写数字格式的填充前缀');
return;
}
if (`${accessNumberFillEnabled ? normalizedFillPrefix : ''}${normalizedExtension}`.length > 21) {
setError('客户侧接入号不能超过 21 位');
setSaveError('客户侧接入号不能超过 21 位');
return;
}
const payload = {
@@ -228,11 +250,12 @@ export function AdminSmsApplicationFormPage() {
};
setSaving(true);
setError('');
setSaveError('');
try {
const application = isEdit && appId
? await adminApi.updateEnterpriseApplication(appId, payload)
: await adminApi.createEnterpriseApplication({ tenantId: enterpriseId, ...payload });
const application =
isEdit && appId
? await adminApi.updateEnterpriseApplication(appId, payload)
: await adminApi.createEnterpriseApplication({ tenantId: enterpriseId, ...payload });
await adminApi.replaceApplicationRouteRules(application.id, {
routes: selectedGroups.map((item, index) => ({
carrier: item.carrier,
@@ -241,14 +264,17 @@ export function AdminSmsApplicationFormPage() {
status: 'active',
})),
});
await adminApi.updateApplicationHttpApiConfig(application.id, { ...httpConfig, ipAllowlist: parseIpAllowlist(httpIpAddress) });
await adminApi.updateApplicationHttpApiConfig(application.id, {
...httpConfig,
ipAllowlist: parseIpAllowlist(httpIpAddress),
});
await Promise.all([
adminApi.saveApplicationHttpWebhook(application.id, 'receipt', { url: receiptWebhookUrl.trim() }),
adminApi.saveApplicationHttpWebhook(application.id, 'uplink', { url: uplinkWebhookUrl.trim() }),
]);
goBack();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '短信应用保存失败');
setSaveError(failure instanceof Error ? failure.message : '短信应用保存失败');
} finally {
setSaving(false);
}
@@ -273,27 +299,65 @@ export function AdminSmsApplicationFormPage() {
<Breadcrumb items={[isEdit ? '编辑短信应用' : '添加短信应用']} />
<p></p>
</div>
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost"></Button>
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost">
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-app-form-card">
<section className="ui-detail-section">
<div className="ui-detail-section__header"><h3></h3><p></p></div>
<div className="ui-detail-section__header">
<h3></h3>
<p></p>
</div>
<div className="admin-app-form-grid">
<Input label="应用名称" onChange={(event) => setAppName(event.target.value)} placeholder="请输入应用名称" required value={appName} />
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required step="0.0001" type="number" value={customerUnitPrice} />
<Input
label="应用名称"
onChange={(event) => setAppName(event.target.value)}
placeholder="请输入应用名称"
required
value={appName}
/>
<Input
label="应用场景"
onChange={(event) => setScene(event.target.value)}
placeholder="行业通知/营销推广/验证码"
value={scene}
/>
<Input
label="日发送数量限制"
onChange={(event) => setDailyLimit(event.target.value)}
placeholder="100000"
required
value={dailyLimit}
/>
<Input
label="客户单价(元/条)"
onChange={(event) => setCustomerUnitPrice(event.target.value)}
placeholder="0.0300"
required
step="0.0001"
type="number"
value={customerUnitPrice}
/>
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<div className="radio-row">
<label>
<input checked={queuePriority === 'priority'} onChange={() => setQueuePriority('priority')} type="radio" />
<input
checked={queuePriority === 'priority'}
onChange={() => setQueuePriority('priority')}
type="radio"
/>
</label>
<label>
<input checked={queuePriority === 'normal'} onChange={() => setQueuePriority('normal')} type="radio" />
<input
checked={queuePriority === 'normal'}
onChange={() => setQueuePriority('normal')}
type="radio"
/>
</label>
</div>
@@ -319,10 +383,19 @@ export function AdminSmsApplicationFormPage() {
<section className="ui-detail-section admin-app-protocol-section admin-app-protocol-section--cmpp">
<div className="ui-detail-section__header admin-app-protocol-header">
<div className="admin-app-protocol-heading">
<span className="admin-app-protocol-icon"><RadioTower size={19} /></span>
<div><h3>CMPP </h3><p></p></div>
<span className="admin-app-protocol-icon">
<RadioTower size={19} />
</span>
<div>
<h3>CMPP </h3>
<p></p>
</div>
</div>
<button className={interfaceEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setInterfaceEnabled((current) => !current)} type="button">
<button
className={interfaceEnabled ? 'admin-switch is-on' : 'admin-switch'}
onClick={() => setInterfaceEnabled((current) => !current)}
type="button"
>
<span />
{interfaceEnabled ? '已开通' : '未开通'}
</button>
@@ -331,10 +404,30 @@ export function AdminSmsApplicationFormPage() {
<div className="admin-app-form-grid admin-app-protocol-body">
<div className="admin-app-form-row admin-app-form-row--wide">
<span>CMPP </span>
<div className="radio-row"><label><input checked={interfaceType === 'cmpp20'} onChange={() => setInterfaceType('cmpp20')} type="radio" />CMPP2.0</label></div>
<div className="radio-row">
<label>
<input
checked={interfaceType === 'cmpp20'}
onChange={() => setInterfaceType('cmpp20')}
type="radio"
/>
CMPP2.0
</label>
</div>
</div>
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
<Input disabled hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。" label="企业代码" placeholder="跟随 CMPP 6位账号自动生成" value={cmppAccount} />
<Input
label="CMPP 6位账号"
onChange={(event) => setCmppAccount(event.target.value)}
placeholder="留空自动生成"
value={cmppAccount}
/>
<Input
disabled
hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。"
label="企业代码"
placeholder="跟随 CMPP 6位账号自动生成"
value={cmppAccount}
/>
<Input
hint="真实扩展码会追加到上游通道基础接入号后,例如基础号 1069999999、扩展码 0001,最终发送号为 10699999990001。留空则继续使用通道基础号。"
label="应用扩展码"
@@ -344,93 +437,271 @@ export function AdminSmsApplicationFormPage() {
/>
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<button className={accessNumberFillEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setAccessNumberFillEnabled((current) => !current)} type="button"><span />{accessNumberFillEnabled ? '开启' : '关闭'}</button>
<div className="admin-app-form-tip"><Info size={17} /><span> Src_Id </span></div>
<button
className={accessNumberFillEnabled ? 'admin-switch is-on' : 'admin-switch'}
onClick={() => setAccessNumberFillEnabled((current) => !current)}
type="button"
>
<span />
{accessNumberFillEnabled ? '开启' : '关闭'}
</button>
<div className="admin-app-form-tip">
<Info size={17} />
<span>
Src_Id
</span>
</div>
</div>
{accessNumberFillEnabled ? <Input hint="只能填写数字,且只允许作为客户 Src_Id 的开头前缀。" label="填充前缀" onChange={(event) => setAccessNumberFillPrefix(event.target.value)} placeholder="例如 00" required value={accessNumberFillPrefix} /> : null}
<Input disabled hint="客户 CMPP SUBMIT 必须填写该完整值;填充前缀不会发送给上游。" label="客户侧接入号" placeholder="根据填充前缀和应用扩展码自动生成" value={clientSrcIdPreview} />
{accessNumberFillEnabled ? (
<Input
hint="只能填写数字,且只允许作为客户 Src_Id 的开头前缀。"
label="填充前缀"
onChange={(event) => setAccessNumberFillPrefix(event.target.value)}
placeholder="例如 00"
required
value={accessNumberFillPrefix}
/>
) : null}
<Input
disabled
hint="客户 CMPP SUBMIT 必须填写该完整值;填充前缀不会发送给上游。"
label="客户侧接入号"
placeholder="根据填充前缀和应用扩展码自动生成"
value={clientSrcIdPreview}
/>
<Input
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
label="CMPP 接口密码"
onChange={(event) => setPasswordCipher(event.target.value)}
placeholder="16 位接口密码"
suffix={<button aria-label="随机生成接口密码" className="icon-button" onClick={() => setPasswordCipher(generateApplicationPassword())} type="button"><RefreshCw size={15} /></button>}
suffix={
<button
aria-label="随机生成接口密码"
className="icon-button"
onClick={() => setPasswordCipher(generateApplicationPassword())}
type="button"
>
<RefreshCw size={15} />
</button>
}
value={passwordCipher}
/>
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
<Input label="CMPP IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
<Input
label="客户最大连接数"
onChange={(event) => setCmppMaxConnections(event.target.value)}
placeholder="1"
required
value={cmppMaxConnections}
/>
<Input
label="CMPP IP 白名单"
onChange={(event) => setIpAddress(event.target.value)}
placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔"
value={ipAddress}
/>
<div className="admin-app-form-row admin-app-form-row--wide">
<span>CMPP </span>
<div className="radio-row">
<button className={downstreamReceiptRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamReceiptRetryEnabled((current) => !current)} type="button"><span />{downstreamReceiptRetryEnabled ? '开启' : '关闭'}</button>
<button className={downstreamUplinkRetryEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setDownstreamUplinkRetryEnabled((current) => !current)} type="button"><span />{downstreamUplinkRetryEnabled ? '开启' : '关闭'}</button>
<button
className={downstreamReceiptRetryEnabled ? 'admin-switch is-on' : 'admin-switch'}
onClick={() => setDownstreamReceiptRetryEnabled((current) => !current)}
type="button"
>
<span />
{downstreamReceiptRetryEnabled ? '开启' : '关闭'}
</button>
<button
className={downstreamUplinkRetryEnabled ? 'admin-switch is-on' : 'admin-switch'}
onClick={() => setDownstreamUplinkRetryEnabled((current) => !current)}
type="button"
>
<span />
{downstreamUplinkRetryEnabled ? '开启' : '关闭'}
</button>
</div>
<div className="admin-app-form-tip">
<Info size={17} />
<span>
CMPP_DELIVER_RESP
</span>
</div>
<div className="admin-app-form-tip"><Info size={17} /><span> CMPP_DELIVER_RESP </span></div>
</div>
</div>
) : <div className="admin-app-protocol-empty">CMPP </div>}
) : (
<div className="admin-app-protocol-empty">CMPP </div>
)}
</section>
<section className="ui-detail-section admin-app-protocol-section admin-app-protocol-section--http">
<div className="ui-detail-section__header admin-app-protocol-header">
<div className="admin-app-protocol-heading">
<span className="admin-app-protocol-icon"><Globe2 size={19} /></span>
<div><h3>HTTP </h3><p> Webhook </p></div>
<span className="admin-app-protocol-icon">
<Globe2 size={19} />
</span>
<div>
<h3>HTTP </h3>
<p> Webhook </p>
</div>
</div>
<button className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setHttpConfig((current) => current.enabled ? { ...current, enabled: false } : {
...current,
enabled: true,
sendEnabled: true,
messageQueryEnabled: true,
receiptWebhookEnabled: true,
uplinkWebhookEnabled: true,
uplinkQueryEnabled: true,
credentialSelfServiceEnabled: true,
receiptDeliveryMode: 'http',
uplinkDeliveryMode: 'http',
})} type="button"><span />{httpConfig.enabled ? '已开通' : '未开通'}</button>
<button
className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'}
onClick={() =>
setHttpConfig((current) =>
current.enabled
? { ...current, enabled: false }
: {
...current,
enabled: true,
sendEnabled: true,
messageQueryEnabled: true,
receiptWebhookEnabled: true,
uplinkWebhookEnabled: true,
uplinkQueryEnabled: true,
credentialSelfServiceEnabled: true,
receiptDeliveryMode: 'http',
uplinkDeliveryMode: 'http',
},
)
}
type="button"
>
<span />
{httpConfig.enabled ? '已开通' : '未开通'}
</button>
</div>
{httpConfig.enabled ? (
<div className="admin-app-form-grid admin-app-protocol-body">
<div className="admin-app-form-row admin-app-form-row--wide">
<span>HTTP </span>
<div className="radio-row">
{httpCapabilityOptions.map(({ key, label }) => <label key={key}><input checked={Boolean(httpConfig[key])} onChange={() => setHttpConfig((current) => ({ ...current, [key]: !current[key] }))} type="checkbox" />{label}</label>)}
{httpCapabilityOptions.map(({ key, label }) => (
<label key={key}>
<input
checked={Boolean(httpConfig[key])}
onChange={() => setHttpConfig((current) => ({ ...current, [key]: !current[key] }))}
type="checkbox"
/>
{label}
</label>
))}
</div>
<div className="admin-app-form-tip">
<Info size={17} />
<span>访HTTP CMPP </span>
</div>
</div>
<Input
label="HTTP IP 白名单"
onChange={(event) => setHttpIpAddress(event.target.value)}
placeholder="多个 IP/CIDR 可换行填写,留空表示不限制"
value={httpIpAddress}
/>
<Input
label="HTTP QPS"
onChange={(event) =>
setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))
}
value={String(httpConfig.qpsLimit)}
/>
<Input
label="签名时间容差(秒)"
onChange={(event) =>
setHttpConfig((current) => ({
...current,
timestampToleranceSeconds: Number(event.target.value) || 300,
}))
}
value={String(httpConfig.timestampToleranceSeconds)}
/>
<Input
label="最多有效凭据数"
onChange={(event) =>
setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))
}
value={String(httpConfig.maxCredentialCount)}
/>
<Input
label="Webhook 超时(秒)"
onChange={(event) =>
setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))
}
value={String(httpConfig.webhookTimeoutSeconds)}
/>
<Input
label="Webhook 最大尝试次数"
onChange={(event) =>
setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))
}
value={String(httpConfig.webhookMaxAttempts)}
/>
<div className="admin-app-form-row admin-app-form-row--wide">
<span>HTTP </span>
<div className="radio-row">
<label>
<input
checked={httpConfig.requireHttps}
onChange={() => setHttpConfig((current) => ({ ...current, requireHttps: !current.requireHttps }))}
type="checkbox"
/>
HTTPS
</label>
<label>
<input
checked={httpConfig.webhookRetryEnabled}
onChange={() =>
setHttpConfig((current) => ({ ...current, webhookRetryEnabled: !current.webhookRetryEnabled }))
}
type="checkbox"
/>
Webhook
</label>
<label>
<input
checked={httpConfig.allowClientManualRetry}
onChange={() =>
setHttpConfig((current) => ({
...current,
allowClientManualRetry: !current.allowClientManualRetry,
}))
}
type="checkbox"
/>
</label>
</div>
<div className="admin-app-form-tip"><Info size={17} /><span>访HTTP CMPP </span></div>
</div>
<Input label="HTTP IP 白名单" onChange={(event) => setHttpIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可换行填写,留空表示不限制" value={httpIpAddress} />
<Input label="HTTP QPS" onChange={(event) => setHttpConfig((current) => ({ ...current, qpsLimit: Number(event.target.value) || 1 }))} value={String(httpConfig.qpsLimit)} />
<Input label="签名时间容差(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, timestampToleranceSeconds: Number(event.target.value) || 300 }))} value={String(httpConfig.timestampToleranceSeconds)} />
<Input label="最多有效凭据数" onChange={(event) => setHttpConfig((current) => ({ ...current, maxCredentialCount: Number(event.target.value) || 2 }))} value={String(httpConfig.maxCredentialCount)} />
<Input label="Webhook 超时(秒)" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookTimeoutSeconds: Number(event.target.value) || 10 }))} value={String(httpConfig.webhookTimeoutSeconds)} />
<Input label="Webhook 最大尝试次数" onChange={(event) => setHttpConfig((current) => ({ ...current, webhookMaxAttempts: Number(event.target.value) || 7 }))} value={String(httpConfig.webhookMaxAttempts)} />
<div className="admin-app-form-row admin-app-form-row--wide"><span>HTTP </span><div className="radio-row">
<label><input checked={httpConfig.requireHttps} onChange={() => setHttpConfig((current) => ({ ...current, requireHttps: !current.requireHttps }))} type="checkbox" /> HTTPS</label>
<label><input checked={httpConfig.webhookRetryEnabled} onChange={() => setHttpConfig((current) => ({ ...current, webhookRetryEnabled: !current.webhookRetryEnabled }))} type="checkbox" />Webhook </label>
<label><input checked={httpConfig.allowClientManualRetry} onChange={() => setHttpConfig((current) => ({ ...current, allowClientManualRetry: !current.allowClientManualRetry }))} type="checkbox" /></label>
</div></div>
</div>
) : <div className="admin-app-protocol-empty">HTTP </div>}
<div className="admin-app-form-grid admin-app-protocol-body">
<Input
hint="留空不推送;HTTP接口开通后按该地址推送状态回执。"
label="HTTP 回执地址"
onChange={(event) => setReceiptWebhookUrl(event.target.value)}
placeholder="https://example.com/webhooks/sms/receipt"
value={receiptWebhookUrl}
/>
<Input
hint="留空不推送;HTTP接口开通后按该地址推送上行短信。"
label="HTTP 上行地址"
onChange={(event) => setUplinkWebhookUrl(event.target.value)}
placeholder="https://example.com/webhooks/sms/uplink"
value={uplinkWebhookUrl}
/>
<div className="admin-app-form-row admin-app-form-row--wide">
<div className="admin-app-form-tip"><Info size={17} /><span>CMPP开通则走CMPPHTTP开通且地址非空则走HTTP</span></div>
) : (
<div className="admin-app-protocol-empty">HTTP </div>
)}
{httpConfig.enabled ? (
<div className="admin-app-form-grid admin-app-protocol-body">
<Input
hint="留空不推送;HTTP接口开通后按该地址推送状态回执。"
label="HTTP 回执地址"
onChange={(event) => setReceiptWebhookUrl(event.target.value)}
placeholder="https://example.com/webhooks/sms/receipt"
value={receiptWebhookUrl}
/>
<Input
hint="留空不推送;HTTP接口开通后按该地址推送上行短信。"
label="HTTP 上行地址"
onChange={(event) => setUplinkWebhookUrl(event.target.value)}
placeholder="https://example.com/webhooks/sms/uplink"
value={uplinkWebhookUrl}
/>
<div className="admin-app-form-row admin-app-form-row--wide">
<div className="admin-app-form-tip">
<Info size={17} />
<span>
CMPP开通则走CMPPHTTP开通且地址非空则走HTTP
</span>
</div>
</div>
</div>
</div>
) : null}
</section>
<section className="ui-detail-section">
@@ -446,14 +717,23 @@ export function AdminSmsApplicationFormPage() {
const available = groups.filter((group) => group.carrier === card.carrier);
const meta = carrierMeta[card.carrier];
return (
<div className={['admin-app-route-card', card.groupId ? 'is-selected' : ''].filter(Boolean).join(' ')} key={card.carrier}>
<div
className={['admin-app-route-card', card.groupId ? 'is-selected' : ''].filter(Boolean).join(' ')}
key={card.carrier}
>
<header>
<span><RadioTower size={18} /></span>
<span>
<RadioTower size={18} />
</span>
<div>
<strong><CarrierTag carrier={card.carrier} /> </strong>
<strong>
<CarrierTag carrier={card.carrier} />
</strong>
<small>{meta.description}</small>
</div>
<Tag tone={card.groupId ? 'success' : available.length ? 'neutral' : 'warning'}>{card.groupId ? '已选择' : `${available.length} 个可选`}</Tag>
<Tag tone={card.groupId ? 'success' : available.length ? 'neutral' : 'warning'}>
{card.groupId ? '已选择' : `${available.length} 个可选`}
</Tag>
</header>
<Select
label={`${meta.label}通道组`}
@@ -469,10 +749,27 @@ export function AdminSmsApplicationFormPage() {
</section>
<div className="enterprise-form-footer">
<Button disabled={!appName || selectedGroupCount === 0 || saving} onClick={() => { void submit(); }}>{saving ? '保存中...' : isEdit ? '保存应用' : '创建应用'}</Button>
<Button onClick={goBack} variant="ghost"></Button>
<Button
disabled={!appName || selectedGroupCount === 0 || saving}
onClick={() => {
void submit();
}}
>
{saving ? '保存中...' : isEdit ? '保存应用' : '创建应用'}
</Button>
<Button onClick={goBack} variant="ghost">
</Button>
</div>
</div>
<Modal
open={Boolean(saveError)}
title="短信应用保存失败"
onClose={() => setSaveError('')}
footer={<Button onClick={() => setSaveError('')}></Button>}
>
<p role="alert">{saveError}</p>
</Modal>
</section>
);
}
+2 -2
View File
@@ -86,8 +86,8 @@ export function ChannelFormModal({
return (
<Modal
closeOnBackdrop={modal.mode === 'edit'}
closeOnEscape={modal.mode === 'edit'}
closeOnBackdrop={false}
closeOnEscape={false}
footer={
<>
<Button onClick={onClose} variant="ghost">
@@ -1,4 +1,4 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { EChartsOption } from 'echarts';
import { Link } from 'react-router-dom';
import { Button, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
@@ -212,31 +212,37 @@ export function MonitorAlerts() {
const [data, setData] = useState<Page<Alert>>({ items: [], total: 0, page: 1, pageSize: 20 }),
[page, setPage] = useState(1),
[state, setState] = useState(''),
[readStatus, setReadStatus] = useState(''),
[error, setError] = useState('');
const [detail, setDetail] = useState<Alert | null>(null),
[busy, setBusy] = useState(false);
const load = useCallback(
() =>
monitorApi
.alerts(page, state)
.then((r) => {
setData(r);
setError('');
})
.catch((e) => setError(e.message)),
[page, state],
);
const requestSequence = useRef(0);
const load = useCallback(async () => {
const sequence = ++requestSequence.current;
try {
const result = await monitorApi.alerts(page, state, readStatus);
if (sequence !== requestSequence.current) return;
setData(result);
setError('');
} catch (failure) {
if (sequence === requestSequence.current) setError(failure instanceof Error ? failure.message : '告警加载失败');
}
}, [page, state, readStatus]);
useEffect(() => {
void load();
const timer = setInterval(() => {
if (!document.hidden) void load();
}, 30000);
return () => clearInterval(timer);
return () => {
clearInterval(timer);
requestSequence.current += 1;
};
}, [load]);
async function read(row: Alert) {
setBusy(true);
try {
await monitorApi.read(row.id);
setDetail((current) => (current?.id === row.id ? { ...current, unread: false } : current));
window.dispatchEvent(new Event('cmpp-monitor-alert-refresh'));
await load();
} catch (e) {
@@ -312,6 +318,19 @@ export function MonitorAlerts() {
];
return (
<div className="page-stack">
<Select
label="阅读状态"
value={readStatus}
options={[
{ value: '', label: '全部' },
{ value: 'unread', label: '未读' },
{ value: 'read', label: '已读' },
]}
onChange={(event) => {
setReadStatus(event.target.value);
setPage(1);
}}
/>
<Select
label="告警状态"
value={state}
+2 -2
View File
@@ -158,8 +158,8 @@ export const monitorApi = {
request<{ id: string; name: string }[]>(
withQuery('/admin/sending-monitor/options', { kind, ...scope, keyword, page }),
),
alerts: (page: number, state: string, signal?: AbortSignal) =>
request<Page<Alert>>(withQuery('/admin/sending-monitor/alerts', { page, state }), { signal }),
alerts: (page: number, state: string, readStatus = '', signal?: AbortSignal) =>
request<Page<Alert>>(withQuery('/admin/sending-monitor/alerts', { page, state, readStatus }), { signal }),
read: (id: string) => request(`/admin/sending-monitor/alerts/${id}/read`, { method: 'POST' }),
};
@@ -0,0 +1,66 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ChannelSensitiveWordsPanel } from './ChannelSensitiveWordsPanel';
const { api, channels } = vi.hoisted(() => ({
api: { list: vi.fn(), save: vi.fn(), remove: vi.fn() },
channels: vi.fn(),
}));
vi.mock('@/api/admin/channel-sensitive-words.api', () => ({ channelWordsApi: api }));
vi.mock('@/api/adminApi', () => ({ adminApi: { listChannels: channels } }));
const item = {
id: 'rule',
channelId: 'a',
word: '贷款',
status: 'active',
remark: '',
version: 4,
updatedAt: '2026-09-10T00:00:00Z',
channel: { id: 'a', name: '通道A', status: 'active' },
};
describe('channel word panel', () => {
beforeEach(() => {
vi.resetAllMocks();
channels.mockResolvedValue([{ id: 'a', name: '通道A', status: 'active' }]);
api.list.mockResolvedValue({ items: [item], total: 1 });
});
it('keeps save conflicts inside the dialog and does not dismiss on Escape or backdrop', async () => {
api.save.mockRejectedValue(Error('规则已被修改,请刷新后重试'));
render(<ChannelSensitiveWordsPanel />);
fireEvent.click(await screen.findByRole('button', { name: '编辑' }));
const dialog = screen.getByRole('dialog');
fireEvent.change(within(dialog).getByRole('textbox', { name: /敏感词/ }), { target: { value: '理财' } });
fireEvent.keyDown(dialog, { key: 'Escape' });
fireEvent.mouseDown(document.querySelector('.ui-modal-backdrop') ?? document.body);
expect(dialog).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: '保存' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('规则已被修改');
expect(api.save).toHaveBeenCalledWith({ channelId: 'a', word: '理财', status: 'active', remark: '' }, item);
expect(api.list).toHaveBeenCalledTimes(1);
});
it('only deletes after confirmation and keeps failures visible', async () => {
api.remove.mockRejectedValue(Error('删除失败'));
render(<ChannelSensitiveWordsPanel />);
fireEvent.click(await screen.findByRole('button', { name: '删除' }));
expect(api.remove).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
expect(await within(screen.getByRole('dialog')).findByRole('alert')).toHaveTextContent('删除失败');
expect(api.remove).toHaveBeenCalledWith(item);
});
it('keeps keyword and status separate and uses server pagination', async () => {
render(<ChannelSensitiveWordsPanel />);
await screen.findByRole('button', { name: '编辑' });
fireEvent.change(screen.getByLabelText('敏感词'), { target: { value: '理财' } });
fireEvent.click(screen.getByRole('button', { name: '启用状态' }));
fireEvent.click(screen.getByRole('option', { name: '停用' }));
fireEvent.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() =>
expect(api.list).toHaveBeenLastCalledWith({
channelId: '',
keyword: '理财',
status: 'inactive',
page: 1,
pageSize: 25,
}),
);
});
});
@@ -0,0 +1,352 @@
import { useEffect, useRef, useState } from 'react';
import { adminApi, type AdminChannel } from '@/api/adminApi';
import {
channelWordsApi,
type ChannelWord,
type ChannelWordForm,
type ChannelWordQuery,
} from '@/api/admin/channel-sensitive-words.api';
import { Button, Input, Modal, Pagination, QueryPanel, Select, Table, Tag, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const emptyForm: ChannelWordForm = { channelId: '', word: '', status: 'active', remark: '' };
const initialQuery: ChannelWordQuery = { channelId: '', keyword: '', status: 'all', page: 1, pageSize: 25 };
const statuses = [
{ label: '启用', value: 'active' },
{ label: '停用', value: 'inactive' },
];
export function ChannelSensitiveWordsPanel() {
const [channels, setChannels] = useState<AdminChannel[]>([]);
const [filters, setFilters] = useState(initialQuery);
const [query, setQuery] = useState(initialQuery);
const [items, setItems] = useState<ChannelWord[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [channelError, setChannelError] = useState('');
const [reload, setReload] = useState(0);
const [editor, setEditor] = useState<{ item?: ChannelWord; initial: ChannelWordForm } | null>(null);
const [form, setForm] = useState(emptyForm);
const [formError, setFormError] = useState('');
const [saving, setSaving] = useState(false);
const [removing, setRemoving] = useState<ChannelWord | null>(null);
const [removeError, setRemoveError] = useState('');
const busy = useRef(false);
useEffect(() => {
let alive = true;
adminApi
.listChannels()
.then((data) => {
if (alive) {
setChannels(data);
setChannelError('');
}
})
.catch((failure: Error) => {
if (alive) setChannelError(failure.message || '通道加载失败');
});
return () => {
alive = false;
};
}, [reload]);
useEffect(() => {
let alive = true;
setLoading(true);
setError('');
channelWordsApi
.list(query)
.then((data) => {
if (!alive) return;
if (query.page > 1 && !data.items.length) {
setQuery({ ...query, page: Math.max(1, Math.ceil(data.total / query.pageSize)) });
return;
}
setItems(data.items);
setTotal(data.total);
})
.catch((failure: Error) => {
if (alive) {
setError(failure.message || '通道敏感词加载失败');
setItems([]);
setTotal(0);
}
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [query, reload]);
const channelOptions = channels
.filter((channel) => channel.status !== 'deleted')
.map((channel) => ({
value: channel.id,
label: `${channel.name}${channel.status === 'active' ? '' : '(已停用)'}`,
}));
if (editor?.item && !channelOptions.some((option) => option.value === editor.item!.channelId))
channelOptions.push({ value: editor.item.channelId, label: `${editor.item.channel.name}(历史通道)` });
function edit(item?: ChannelWord) {
const initial = item
? { channelId: item.channelId, word: item.word, status: item.status, remark: item.remark }
: { ...emptyForm };
setEditor({ item, initial });
setForm(initial);
setFormError('');
}
async function save() {
if (busy.current || !editor) return;
if (!form.channelId || !form.word.trim()) {
setFormError('请选择通道并填写敏感词');
return;
}
busy.current = true;
setSaving(true);
setFormError('');
try {
await channelWordsApi.save(form, editor.item);
setEditor(null);
setReload((value) => value + 1);
} catch (failure) {
setFormError(failure instanceof Error ? failure.message : '保存失败');
} finally {
busy.current = false;
setSaving(false);
}
}
async function toggle(item: ChannelWord) {
if (busy.current) return;
busy.current = true;
setSaving(true);
setError('');
try {
await channelWordsApi.save(
{
channelId: item.channelId,
word: item.word,
remark: item.remark,
status: item.status === 'active' ? 'inactive' : 'active',
},
item,
);
setReload((value) => value + 1);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '操作失败');
} finally {
busy.current = false;
setSaving(false);
}
}
async function remove() {
if (busy.current || !removing) return;
busy.current = true;
setSaving(true);
setRemoveError('');
try {
await channelWordsApi.remove(removing);
setRemoving(null);
setReload((value) => value + 1);
} catch (failure) {
setRemoveError(failure instanceof Error ? failure.message : '删除失败');
} finally {
busy.current = false;
setSaving(false);
}
}
const columns: Array<TableColumn<ChannelWord>> = [
{
key: 'channel',
title: '通道',
render: (item) => `${item.channel.name}${item.channel.status === 'active' ? '' : '(已停用或删除)'}`,
},
{ key: 'word', title: '敏感词', render: (item) => item.word },
{
key: 'status',
title: '状态',
render: (item) => (
<Tag tone={item.status === 'active' ? 'success' : 'neutral'}>{item.status === 'active' ? '启用' : '停用'}</Tag>
),
},
{ key: 'remark', title: '备注', render: (item) => item.remark || '-' },
{ key: 'updatedAt', title: '更新时间', render: (item) => formatDateTime(item.updatedAt) },
{
key: 'actions',
title: '操作',
render: (item) => (
<div className="admin-security-filter__actions">
<Button size="sm" disabled={saving} onClick={() => edit(item)}>
</Button>
<Button size="sm" variant="ghost" disabled={saving} onClick={() => void toggle(item)}>
{item.status === 'active' ? '停用' : '启用'}
</Button>
<Button
size="sm"
variant="danger"
disabled={saving}
onClick={() => {
setRemoving(item);
setRemoveError('');
}}
>
</Button>
</div>
),
},
];
return (
<div className="page-stack">
<div className="page-heading">
<p></p>
<Button onClick={() => edit()} disabled={Boolean(channelError)}>
</Button>
</div>
<QueryPanel title="筛选通道敏感词">
<Select
label="通道"
searchable
options={[{ label: '全部通道', value: '' }, ...channelOptions]}
value={filters.channelId}
onChange={(event) => setFilters({ ...filters, channelId: event.target.value })}
/>
<Input
label="敏感词"
value={filters.keyword}
maxLength={200}
onChange={(event) => setFilters({ ...filters, keyword: event.target.value })}
/>
<Select
label="启用状态"
options={[{ label: '全部状态', value: 'all' }, ...statuses]}
value={filters.status}
onChange={(event) => setFilters({ ...filters, status: event.target.value })}
/>
<div className="admin-security-filter__actions">
<Button onClick={() => setQuery({ ...filters, page: 1, pageSize: query.pageSize })}></Button>
<Button
variant="ghost"
onClick={() => {
setFilters(initialQuery);
setQuery({ ...initialQuery });
}}
>
</Button>
<Button variant="ghost" onClick={() => setReload((value) => value + 1)}>
</Button>
</div>
</QueryPanel>
{error || channelError ? (
<p className="form-error" role="alert">
{error || channelError}
</p>
) : null}
<div className="surface admin-security-table-card" aria-busy={loading}>
<Table
columns={columns}
data={loading ? [] : items}
rowKey="id"
emptyText={loading ? '加载中…' : error ? '加载失败,请重试' : '暂无通道敏感词'}
/>
<Pagination
total={total}
page={query.page}
pageSize={query.pageSize}
previousDisabled={loading || query.page <= 1}
nextDisabled={loading || query.page * query.pageSize >= total}
onPrevious={() => setQuery({ ...query, page: query.page - 1 })}
onNext={() => setQuery({ ...query, page: query.page + 1 })}
onPageChange={(page) => setQuery({ ...query, page })}
onPageSizeChange={(pageSize) => setQuery({ ...query, pageSize, page: 1 })}
/>
</div>
<Modal
open={Boolean(editor)}
title={editor?.item ? '编辑通道敏感词' : '新增通道敏感词'}
dirty={Boolean(editor && JSON.stringify(form) !== JSON.stringify(editor.initial))}
onClose={() => {
if (!saving) setEditor(null);
}}
footer={({ requestClose }) => (
<>
<Button variant="ghost" disabled={saving} onClick={requestClose}>
</Button>
<Button disabled={saving} onClick={() => void save()}>
{saving ? '保存中…' : '保存'}
</Button>
</>
)}
>
<div className="admin-security-form">
{formError ? (
<p className="form-error" role="alert">
{formError}
</p>
) : null}
<Select
label="通道"
searchable
required
value={form.channelId}
disabled={saving}
options={[{ label: '请选择通道', value: '' }, ...channelOptions]}
onChange={(event) => setForm({ ...form, channelId: event.target.value })}
/>
<Input
label="敏感词"
required
maxLength={200}
disabled={saving}
value={form.word}
hint="按原文连续匹配,区分英文大小写"
onChange={(event) => setForm({ ...form, word: event.target.value })}
/>
<Select
label="启用状态"
options={statuses}
disabled={saving}
value={form.status}
onChange={(event) => setForm({ ...form, status: event.target.value })}
/>
<Input
label="备注"
maxLength={500}
disabled={saving}
value={form.remark}
onChange={(event) => setForm({ ...form, remark: event.target.value })}
/>
</div>
</Modal>
<Modal
open={Boolean(removing)}
title="删除通道敏感词"
onClose={() => {
if (!saving) setRemoving(null);
}}
footer={
<>
<Button variant="ghost" disabled={saving} onClick={() => setRemoving(null)}>
</Button>
<Button variant="danger" disabled={saving} onClick={() => void remove()}>
</Button>
</>
}
>
<p>
{removing?.channel.name} {removing?.word}
</p>
{removeError ? (
<p className="form-error" role="alert">
{removeError}
</p>
) : null}
</Modal>
</div>
);
}
+203 -41
View File
@@ -18,12 +18,7 @@ type SendDetailModalProps = {
onClose: () => void;
};
export function SendDetailModal({
record,
segmentAudits,
segmentLoading,
onClose,
}: SendDetailModalProps) {
export function SendDetailModal({ record, segmentAudits, segmentLoading, onClose }: SendDetailModalProps) {
const routeRows = buildRouteRows(record, segmentAudits);
const channelGroupNames = Array.from(new Set(routeRows.map((route) => route.channelGroup).filter(Boolean)));
const orderedSegmentAudits = [...segmentAudits].sort((left, right) => {
@@ -36,28 +31,118 @@ export function SendDetailModal({
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
footer={
<Button onClick={onClose} variant="ghost">
</Button>
}
onClose={onClose}
open
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{record.messageId}</p></div>}
title={
<div className="template-modal-title">
<h2></h2>
<p>{record.messageId}</p>
</div>
}
>
<div className="admin-sms-send-detail">
<section aria-label="通道筛选原因">
<h3></h3>
{record.channelWordDecisions?.length ? (
record.channelWordDecisions.map((decision) => (
<div key={decision.id}>
<p>
{getTime(decision.decidedAt)} ·{' '}
{decision.snapshot.reason ||
(decision.snapshot.hits.length ? '已排除命中通道,按剩余候选选路' : '候选通道未命中通道敏感词')}
</p>
{decision.snapshot.hits.map((hit) => (
<p key={hit.channelId}>
{hit.channelName || hit.channelId} {hit.count}
{hit.samples.map((sample) => `${sample.word}`).join('、')}
{hit.count > hit.samples.length ? '(仅展示部分)' : ''}
</p>
))}
</div>
))
) : (
<p className="muted">{segmentLoading ? '加载中…' : '暂无通道敏感词选路记录'}</p>
)}
</section>
<section aria-label="引流发送资格">
<h3></h3>
{record.drainageGate ? (
<>
<p>
{record.drainageGate.reason ||
(record.drainageGate.targets.length
? '引流信息资格检查通过;发送仍受通道及其他规则限制'
: '未检测到引流信息')}
</p>
{record.drainageGate.targets.map((target, index) => (
<p key={index}>
{target.text} · {target.materialIds.length ? '已匹配审核通过的资料' : '未匹配有效资料'}
</p>
))}
</>
) : (
<p className="muted">{segmentLoading ? '正在加载引流资格校验结果…' : '未执行引流资格校验'}</p>
)}
</section>
<div className="admin-sms-detail-overview">
<div>
<span></span>
<Tag tone={statusToneMap[displayStatus] ?? 'info'}>{getRecordStatusLabel(record)}</Tag>
</div>
<div><span></span><strong>{record.submitStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.receiptStatus ?? '-'}</strong></div>
<div><span></span><strong>{getTime(record.deliveredAt)}</strong></div>
<div><span></span><Tag tone={record.hasDrainageContent === true ? 'warning' : 'neutral'}>{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}</Tag></div>
<div><span></span><strong>{getTime(record.queuedAt)}</strong></div>
<div><span></span><strong>{record.phoneNumber || '-'}</strong></div>
<div><span></span><strong>{record.province ?? '-'} / {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</strong></div>
<div><span></span><strong>{channelGroupNames.join(' / ') || '-'}</strong></div>
<div><span></span><strong>{record.clientSrcId || '-'}</strong></div>
<div><span></span><strong>{sentAccessNumber || '-'}</strong></div>
<div>
<span></span>
<strong>{record.submitStatus ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{record.receiptStatus ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{getTime(record.deliveredAt)}</strong>
</div>
<div>
<span></span>
<Tag tone={record.hasDrainageContent === true ? 'warning' : 'neutral'}>
{record.hasDrainageContent === true
? '含引流'
: record.hasDrainageContent === false
? '不含引流'
: '未检测'}
</Tag>
</div>
<div>
<span></span>
<strong>{getTime(record.queuedAt)}</strong>
</div>
<div>
<span></span>
<strong>{record.phoneNumber || '-'}</strong>
</div>
<div>
<span></span>
<strong>
{record.province ?? '-'} / {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}
</strong>
</div>
<div>
<span></span>
<strong>{channelGroupNames.join(' / ') || '-'}</strong>
</div>
<div>
<span></span>
<strong>{record.clientSrcId || '-'}</strong>
</div>
<div>
<span></span>
<strong>{sentAccessNumber || '-'}</strong>
</div>
</div>
{receiptNotice ? (
<div className="admin-sms-detail-notice" role="status">
@@ -66,8 +151,12 @@ export function SendDetailModal({
</div>
) : null}
<section>
<h3><MessageSquare size={18} /> </h3>
<p className="admin-sms-detail-content"><DrainageContent record={record} /></p>
<h3>
<MessageSquare size={18} />
</h3>
<p className="admin-sms-detail-content">
<DrainageContent record={record} />
</p>
</section>
<section>
@@ -78,12 +167,23 @@ export function SendDetailModal({
<span>{index + 1}</span>
<div>
<strong>{route.channel}</strong>
<p className="muted">{route.channelGroup ?? '-'}</p>
<dl>
<div><dt></dt><dd>{getTime(route.sentAt)}</dd></div>
<div><dt></dt><dd>{getTime(route.receiptAt)}</dd></div>
<div><dt></dt><dd>{route.receiptCode ?? '-'}</dd></div>
<div><dt></dt><dd>{route.submitStatus ?? '-'}</dd></div>
<div>
<dt></dt>
<dd>{getTime(route.sentAt)}</dd>
</div>
<div>
<dt></dt>
<dd>{getTime(route.receiptAt)}</dd>
</div>
<div>
<dt></dt>
<dd>{route.receiptCode ?? '-'}</dd>
</div>
<div>
<dt></dt>
<dd>{route.submitStatus ?? '-'}</dd>
</div>
</dl>
</div>
</article>
@@ -94,42 +194,104 @@ export function SendDetailModal({
<section>
<h3></h3>
<div className="admin-sms-detail-status-grid">
<div><span></span><strong>{record.messageId}</strong></div>
<div><span></span><strong>{getRecordStatusLabel(record)}</strong></div>
<div><span></span><strong>{record.submitStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.receiptStatus ?? '-'}</strong></div>
<div>
<span></span>
<strong>{record.messageId}</strong>
</div>
<div>
<span></span>
<strong>{getRecordStatusLabel(record)}</strong>
</div>
<div>
<span></span>
<strong>{record.submitStatus ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{record.receiptStatus ?? '-'}</strong>
</div>
</div>
{['submit_failed', 'failed', 'rejected'].includes(displayStatus) ? (
<div className="admin-sms-detail-failure" role="alert">
<AlertTriangle size={20} />
<div><span></span><strong>{record.errorMessage ?? record.errorCode ?? '未返回明确失败原因'}</strong></div>
<div>
<span></span>
<strong>{record.errorMessage ?? record.errorCode ?? '未返回明确失败原因'}</strong>
</div>
</div>
) : null}
</section>
<section>
<h3></h3>
{segmentLoading ? <div className="ui-table__empty">...</div> : segmentAudits.length === 0 ? (
{segmentLoading ? (
<div className="ui-table__empty">...</div>
) : segmentAudits.length === 0 ? (
<div className="ui-table__empty"></div>
) : (
<div className="admin-sms-segment-list">
{orderedSegmentAudits.map((segment) => (
<article className="admin-sms-segment-card" key={segment.id}>
<header>
<strong> {segment.segmentIndex}/{segment.segmentTotal}</strong>
<strong>
{segment.segmentIndex}/{segment.segmentTotal}
</strong>
<div>
<Tag tone={segment.submitStatus === 'accepted' ? 'success' : segment.submitStatus === 'queued' ? 'info' : 'danger'}>{segment.submitStatus}</Tag>
{segment.receiptStatus ? <Tag tone={segment.receiptStatus === 'delivered' ? 'success' : segment.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{segment.receiptStatus}</Tag> : null}
<Tag
tone={
segment.submitStatus === 'accepted'
? 'success'
: segment.submitStatus === 'queued'
? 'info'
: 'danger'
}
>
{segment.submitStatus}
</Tag>
{segment.receiptStatus ? (
<Tag
tone={
segment.receiptStatus === 'delivered'
? 'success'
: segment.receiptStatus === 'unknown'
? 'neutral'
: 'danger'
}
>
{segment.receiptStatus}
</Tag>
) : null}
</div>
</header>
<dl>
<div><dt></dt><dd>{segment.channel?.name ?? segment.channelId ?? '-'}</dd></div>
<div><dt>Sequence</dt><dd>{segment.sequenceId ?? '-'}</dd></div>
<div><dt> ID</dt><dd>{segment.submitId}</dd></div>
<div><dt> MsgId</dt><dd>{segment.gatewayMessageId ?? '-'}</dd></div>
<div><dt></dt><dd>{segment.compensationType ?? '-'}</dd></div>
<div><dt></dt><dd>{getTime(segment.createdAt)}</dd></div>
<div><dt></dt><dd>{segment.errorMessage ?? segment.errorCode ?? '-'}</dd></div>
<div>
<dt></dt>
<dd>{segment.channel?.name ?? segment.channelId ?? '-'}</dd>
</div>
<div>
<dt>Sequence</dt>
<dd>{segment.sequenceId ?? '-'}</dd>
</div>
<div>
<dt> ID</dt>
<dd>{segment.submitId}</dd>
</div>
<div>
<dt> MsgId</dt>
<dd>{segment.gatewayMessageId ?? '-'}</dd>
</div>
<div>
<dt></dt>
<dd>{segment.compensationType ?? '-'}</dd>
</div>
<div>
<dt></dt>
<dd>{getTime(segment.createdAt)}</dd>
</div>
<div>
<dt></dt>
<dd>{segment.errorMessage ?? segment.errorCode ?? '-'}</dd>
</div>
</dl>
</article>
))}
@@ -27,6 +27,7 @@ import {
import { Breadcrumb, Button, Input, Modal, Table, Tag, type TableColumn } from '@/components/ui';
import { Chart } from '@/components/ui/Chart';
import './AdminSystemMonitoringPage.css';
import { AlertHistory } from './AlertHistory';
const RANGE_OPTIONS: Array<{ value: InfrastructureMonitoringRange; label: string }> = [
{ value: '1h', label: '近1小时' },
@@ -62,23 +63,52 @@ function formatRate(value: number | null) {
}
export function DiskMetricCards({ disks }: { disks: InfrastructureMonitoringOverview['disks'] }) {
if (!disks.length) return <article className="surface system-monitoring-metric"><HardDrive size={19} /><div><span></span><strong></strong></div></article>;
return <>{disks.map((disk) => {
const aliases = (disk.mountpoints ?? [disk.mountpoint]).filter((path) => path !== disk.mountpoint);
return <article className="surface system-monitoring-metric" key={disk.id}>
<div className="system-monitoring-metric__icon is-amber"><HardDrive size={19} /></div>
<div>
<span>{disk.mountpoint === '/' ? '系统盘' : '磁盘'} {disk.mountpoint}</span>
<strong>{formatPercent(disk.usagePercent)}</strong>
<small title={`${disk.device} · ${disk.filesystem} · ${disk.instance}`}>{disk.device} · {disk.filesystem}</small>
<small>{formatBytes(disk.availableBytes)} / {formatBytes(disk.totalBytes)}</small>
{aliases.length > 0 ? <details className="system-monitoring-metric__mounts">
<summary>{aliases.length}</summary>
<ul>{aliases.map((path) => <li key={path}>{path}</li>)}</ul>
</details> : null}
</div>
</article>;
})}</>;
if (!disks.length)
return (
<article className="surface system-monitoring-metric">
<HardDrive size={19} />
<div>
<span></span>
<strong></strong>
</div>
</article>
);
return (
<>
{disks.map((disk) => {
const aliases = (disk.mountpoints ?? [disk.mountpoint]).filter((path) => path !== disk.mountpoint);
return (
<article className="surface system-monitoring-metric" key={disk.id}>
<div className="system-monitoring-metric__icon is-amber">
<HardDrive size={19} />
</div>
<div>
<span>
{disk.mountpoint === '/' ? '系统盘' : '磁盘'} {disk.mountpoint}
</span>
<strong>{formatPercent(disk.usagePercent)}</strong>
<small title={`${disk.device} · ${disk.filesystem} · ${disk.instance}`}>
{disk.device} · {disk.filesystem}
</small>
<small>
{formatBytes(disk.availableBytes)} / {formatBytes(disk.totalBytes)}
</small>
{aliases.length > 0 ? (
<details className="system-monitoring-metric__mounts">
<summary>{aliases.length}</summary>
<ul>
{aliases.map((path) => (
<li key={path}>{path}</li>
))}
</ul>
</details>
) : null}
</div>
</article>
);
})}
</>
);
}
function totalNetworkRate(receive: number | null | undefined, transmit: number | null | undefined) {
@@ -105,7 +135,12 @@ function formatServiceMetric(value: number | null, unit: 'percent' | 'seconds' |
function formatTime(value: string | null) {
if (!value) return '暂无采样';
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(new Date(value));
}
@@ -119,10 +154,14 @@ function formatDuration(startedAt: string) {
}
function timeLabels(points: InfrastructureMetricPoint[], range: InfrastructureMonitoringRange) {
return points.map((point) => new Intl.DateTimeFormat('zh-CN', range === '7d'
? { month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false }
: { hour: '2-digit', minute: '2-digit', hour12: false })
.format(new Date(point.timestamp)));
return points.map((point) =>
new Intl.DateTimeFormat(
'zh-CN',
range === '7d'
? { month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false }
: { hour: '2-digit', minute: '2-digit', hour12: false },
).format(new Date(point.timestamp)),
);
}
function makeTrendOption(params: {
@@ -131,12 +170,17 @@ function makeTrendOption(params: {
suffix: string;
maximum?: number;
}): EChartsOption {
const timestamps = [...new Set(params.series.flatMap((series) => series.points.map((point) => point.timestamp)))].sort();
const timestamps = [
...new Set(params.series.flatMap((series) => series.points.map((point) => point.timestamp))),
].sort();
return {
animationDuration: 280,
color: params.series.map((item) => item.color),
grid: { left: 8, right: 16, top: 34, bottom: 4, containLabel: true },
legend: params.series.length > 1 ? { type: 'scroll', top: 0, left: 0, right: 0, textStyle: { color: '#6b7280', fontSize: 12 } } : undefined,
legend:
params.series.length > 1
? { type: 'scroll', top: 0, left: 0, right: 0, textStyle: { color: '#6b7280', fontSize: 12 } }
: undefined,
tooltip: {
trigger: 'axis',
valueFormatter: (value) => `${Number(value).toFixed(1)}${params.suffix}`,
@@ -144,26 +188,31 @@ function makeTrendOption(params: {
xAxis: {
type: 'category',
boundaryGap: false,
data: timeLabels(timestamps.map((timestamp) => ({ timestamp, value: 0 })), params.range),
data: timeLabels(
timestamps.map((timestamp) => ({ timestamp, value: 0 })),
params.range,
),
axisLine: { lineStyle: { color: '#e5e7eb' } },
axisTick: { show: false },
axisLabel: { color: '#9ca3af', hideOverlap: true, margin: 12 },
},
yAxis: {
type: 'value', min: 0, max: params.maximum,
type: 'value',
min: 0,
max: params.maximum,
axisLabel: { color: '#9ca3af', formatter: `{value}${params.suffix}` },
splitLine: { lineStyle: { color: '#eef0f3' } },
},
series: params.series.map((item) => {
const values = new Map(item.points.map((point) => [point.timestamp, point.value]));
return {
name: item.name,
data: timestamps.map((timestamp) => values.get(timestamp) ?? null),
type: 'line',
smooth: true,
showSymbol: false,
lineStyle: { width: 2.5 },
areaStyle: { opacity: 0.07 },
name: item.name,
data: timestamps.map((timestamp) => values.get(timestamp) ?? null),
type: 'line',
smooth: true,
showSymbol: false,
lineStyle: { width: 2.5 },
areaStyle: { opacity: 0.07 },
};
}),
};
@@ -175,23 +224,58 @@ function severityTag(severity: InfrastructureAlert['severity']) {
return <Tag tone="info"></Tag>;
}
function makeAlertColumns(onMarkRead: (alert: InfrastructureAlert) => void, readingFingerprint: string): Array<TableColumn<InfrastructureAlert>> { return [
{ key: 'severity', title: '级别', width: '82px', render: (record) => severityTag(record.severity) },
{
key: 'alert', title: '告警', width: '280px', render: (record) => (
<div className="system-monitoring-alert-copy"><strong>{record.name}</strong><span>{record.summary}</span></div>
),
},
{ key: 'service', title: '服务 / 实例', width: '190px', render: (record) => record.service || record.instance || '主机资源' },
{ key: 'value', title: '当前值 / 阈值', width: '150px', render: (record) => `${record.currentValue || '—'} / ${record.threshold || '—'}` },
{ key: 'startedAt', title: '开始时间', width: '150px', render: (record) => formatTime(record.startedAt) },
{ key: 'duration', title: '持续时间', width: '120px', render: (record) => formatDuration(record.startedAt) },
{
key: 'actions', title: '操作', width: '112px', render: (record) => record.acknowledged
? <Tag tone="neutral"></Tag>
: <Button disabled={readingFingerprint === record.fingerprint} icon={<CheckCircle2 size={14} />} onClick={() => onMarkRead(record)} size="sm" variant="ghost">{readingFingerprint === record.fingerprint ? '处理中' : '标记已读'}</Button>,
},
]; }
function makeAlertColumns(
onMarkRead: (alert: InfrastructureAlert) => void,
readingFingerprint: string,
): Array<TableColumn<InfrastructureAlert>> {
return [
{ key: 'severity', title: '级别', width: '82px', render: (record) => severityTag(record.severity) },
{
key: 'alert',
title: '告警',
width: '280px',
render: (record) => (
<div className="system-monitoring-alert-copy">
<strong>{record.name}</strong>
<span>{record.summary}</span>
</div>
),
},
{
key: 'service',
title: '服务 / 实例',
width: '190px',
render: (record) => record.service || record.instance || '主机资源',
},
{
key: 'value',
title: '当前值 / 阈值',
width: '150px',
render: (record) => `${record.currentValue || '—'} / ${record.threshold || '—'}`,
},
{ key: 'startedAt', title: '开始时间', width: '150px', render: (record) => formatTime(record.startedAt) },
{ key: 'duration', title: '持续时间', width: '120px', render: (record) => formatDuration(record.startedAt) },
{
key: 'actions',
title: '操作',
width: '112px',
render: (record) =>
record.acknowledged ? (
<Tag tone="neutral"></Tag>
) : (
<Button
disabled={readingFingerprint === record.fingerprint}
icon={<CheckCircle2 size={14} />}
onClick={() => onMarkRead(record)}
size="sm"
variant="ghost"
>
{readingFingerprint === record.fingerprint ? '处理中' : '标记已读'}
</Button>
),
},
];
}
export function AdminSystemMonitoringPage() {
const [range, setRange] = useState<InfrastructureMonitoringRange>('24h');
@@ -208,25 +292,28 @@ export function AdminSystemMonitoringPage() {
const requestSequence = useRef(0);
const pendingRequests = useRef(0);
const loadData = useCallback(async (supersede = false) => {
if (!supersede && pendingRequests.current > 0) return;
pendingRequests.current += 1;
const sequence = ++requestSequence.current;
setLoading(true);
try {
const result = await adminApi.getInfrastructureMonitoringOverview(range);
if (sequence !== requestSequence.current) return;
setOverview(result);
setError(result.available ? '' : result.error || '监控数据当前不可用');
} catch (reason) {
if (sequence !== requestSequence.current) return;
setOverview(null);
setError(reason instanceof Error ? reason.message : '监控数据加载失败');
} finally {
if (sequence === requestSequence.current) setLoading(false);
pendingRequests.current -= 1;
}
}, [range]);
const loadData = useCallback(
async (supersede = false) => {
if (!supersede && pendingRequests.current > 0) return;
pendingRequests.current += 1;
const sequence = ++requestSequence.current;
setLoading(true);
try {
const result = await adminApi.getInfrastructureMonitoringOverview(range);
if (sequence !== requestSequence.current) return;
setOverview(result);
setError(result.available ? '' : result.error || '监控数据当前不可用');
} catch (reason) {
if (sequence !== requestSequence.current) return;
setOverview(null);
setError(reason instanceof Error ? reason.message : '监控数据加载失败');
} finally {
if (sequence === requestSequence.current) setLoading(false);
pendingRequests.current -= 1;
}
},
[range],
);
const loadSettings = useCallback(async () => {
try {
@@ -244,7 +331,10 @@ export function AdminSystemMonitoringPage() {
setSavingSettings(true);
setSettingsError('');
try {
const result = await adminApi.updateInfrastructureAlertThresholds({ configVersion: settings.configVersion, thresholds: draftThresholds });
const result = await adminApi.updateInfrastructureAlertThresholds({
configVersion: settings.configVersion,
thresholds: draftThresholds,
});
setSettings(result);
setDraftThresholds(result.thresholds);
setShowSettings(false);
@@ -262,12 +352,18 @@ export function AdminSystemMonitoringPage() {
setReadError('');
try {
const result = await adminApi.markInfrastructureAlertRead(alert.fingerprint, alert.startedAt);
setOverview((current) => current ? {
...current,
alerts: current.alerts.map((item) => item.fingerprint === result.fingerprint && Date.parse(item.startedAt) === Date.parse(result.activeAt)
? { ...item, acknowledged: true, acknowledgedAt: result.acknowledgedAt }
: item),
} : current);
setOverview((current) =>
current
? {
...current,
alerts: current.alerts.map((item) =>
item.fingerprint === result.fingerprint && Date.parse(item.startedAt) === Date.parse(result.activeAt)
? { ...item, acknowledged: true, acknowledgedAt: result.acknowledgedAt }
: item,
),
}
: current,
);
window.dispatchEvent(new Event('cmpp-infrastructure-alert-count-refresh'));
} catch (reason) {
setReadError(reason instanceof Error ? reason.message : '活动告警标记已读失败');
@@ -294,30 +390,63 @@ export function AdminSystemMonitoringPage() {
}, [loadData, loadSettings]);
const status = STATUS_COPY[overview?.summary.overallStatus ?? 'unknown'];
const cpuOption = useMemo(() => makeTrendOption({
range, maximum: 100, suffix: '%', series: [{ name: 'CPU', points: overview?.trends.cpuUsagePercent ?? [], color: '#2563eb' }],
}), [overview?.trends.cpuUsagePercent, range]);
const memoryOption = useMemo(() => makeTrendOption({
range, maximum: 100, suffix: '%', series: [{ name: '内存', points: overview?.trends.memoryUsagePercent ?? [], color: '#7c3aed' }],
}), [overview?.trends.memoryUsagePercent, range]);
const diskOption = useMemo(() => makeTrendOption({
range, maximum: 100, suffix: '%', series: (overview?.disks ?? []).map((disk, index) => ({
name: `${disk.mountpoint} · ${disk.device} · ${disk.instance}`,
points: disk.trend,
color: ['#d97706', '#2563eb', '#0f766e', '#7c3aed', '#dc2626', '#0891b2'][index % 6],
})),
}), [overview?.disks, range]);
const networkOption = useMemo(() => makeTrendOption({
range, suffix: ' B/s', series: [
{ name: '接收', points: overview?.trends.networkReceiveBytesPerSecond ?? [], color: '#0f766e' },
{ name: '发送', points: overview?.trends.networkTransmitBytesPerSecond ?? [], color: '#2563eb' },
],
}), [overview?.trends.networkReceiveBytesPerSecond, overview?.trends.networkTransmitBytesPerSecond, range]);
const cpuOption = useMemo(
() =>
makeTrendOption({
range,
maximum: 100,
suffix: '%',
series: [{ name: 'CPU', points: overview?.trends.cpuUsagePercent ?? [], color: '#2563eb' }],
}),
[overview?.trends.cpuUsagePercent, range],
);
const memoryOption = useMemo(
() =>
makeTrendOption({
range,
maximum: 100,
suffix: '%',
series: [{ name: '内存', points: overview?.trends.memoryUsagePercent ?? [], color: '#7c3aed' }],
}),
[overview?.trends.memoryUsagePercent, range],
);
const diskOption = useMemo(
() =>
makeTrendOption({
range,
maximum: 100,
suffix: '%',
series: (overview?.disks ?? []).map((disk, index) => ({
name: `${disk.mountpoint} · ${disk.device} · ${disk.instance}`,
points: disk.trend,
color: ['#d97706', '#2563eb', '#0f766e', '#7c3aed', '#dc2626', '#0891b2'][index % 6],
})),
}),
[overview?.disks, range],
);
const networkOption = useMemo(
() =>
makeTrendOption({
range,
suffix: ' B/s',
series: [
{ name: '接收', points: overview?.trends.networkReceiveBytesPerSecond ?? [], color: '#0f766e' },
{ name: '发送', points: overview?.trends.networkTransmitBytesPerSecond ?? [], color: '#2563eb' },
],
}),
[overview?.trends.networkReceiveBytesPerSecond, overview?.trends.networkTransmitBytesPerSecond, range],
);
const metrics = overview?.metrics;
const serviceHealthy = overview?.summary.serviceHealthy ?? 0;
const serviceTotal = overview?.summary.serviceTotal ?? 6;
const alertColumns = useMemo(() => makeAlertColumns((alert) => { void markAlertRead(alert); }, readingFingerprint), [markAlertRead, readingFingerprint]);
const alertColumns = useMemo(
() =>
makeAlertColumns((alert) => {
void markAlertRead(alert);
}, readingFingerprint),
[markAlertRead, readingFingerprint],
);
return (
<section className="page-stack admin-system-monitoring-page">
@@ -338,10 +467,17 @@ export function AdminSystemMonitoringPage() {
key={option.value}
onClick={() => setRange(option.value)}
type="button"
>{option.label}</button>
>
{option.label}
</button>
))}
</div>
<Button disabled={loading} icon={<RefreshCw className={loading ? 'is-spinning' : ''} size={16} />} onClick={() => void loadData()} variant="ghost">
<Button
disabled={loading}
icon={<RefreshCw className={loading ? 'is-spinning' : ''} size={16} />}
onClick={() => void loadData()}
variant="ghost"
>
{loading ? '刷新中' : '刷新'}
</Button>
</div>
@@ -350,7 +486,10 @@ export function AdminSystemMonitoringPage() {
{error ? (
<div className="system-monitoring-unavailable" role="alert">
<ShieldAlert size={20} />
<div><strong></strong><span>{error}</span></div>
<div>
<strong></strong>
<span>{error}</span>
</div>
</div>
) : null}
@@ -363,78 +502,321 @@ export function AdminSystemMonitoringPage() {
<strong>{status.label}</strong>
<small> {formatTime(overview?.lastSampleAt ?? null)}</small>
</div>
<div className="system-monitoring-health__fact"><span></span><strong>{serviceHealthy}/{serviceTotal}</strong><small></small></div>
<div className="system-monitoring-health__fact"><span></span><strong>{overview?.summary.activeAlerts ?? 0}</strong><small>{overview?.summary.criticalAlerts ?? 0} · {overview?.summary.warningAlerts ?? 0} </small></div>
<div className="system-monitoring-health__fact"><span></span><strong>{metrics?.load1 === null || metrics?.load1 === undefined ? '—' : metrics.load1.toFixed(2)}</strong><small>1</small></div>
<div className="system-monitoring-health__fact"><span></span><strong>{formatUptime(metrics?.uptimeSeconds ?? null)}</strong><small></small></div>
<div className="system-monitoring-health__fact">
<span></span>
<strong>
{serviceHealthy}/{serviceTotal}
</strong>
<small></small>
</div>
<div className="system-monitoring-health__fact">
<span></span>
<strong>{overview?.summary.activeAlerts ?? 0}</strong>
<small>
{overview?.summary.criticalAlerts ?? 0} · {overview?.summary.warningAlerts ?? 0}
</small>
</div>
<div className="system-monitoring-health__fact">
<span></span>
<strong>{metrics?.load1 === null || metrics?.load1 === undefined ? '—' : metrics.load1.toFixed(2)}</strong>
<small>1</small>
</div>
<div className="system-monitoring-health__fact">
<span></span>
<strong>{formatUptime(metrics?.uptimeSeconds ?? null)}</strong>
<small></small>
</div>
</div>
<div className="system-monitoring-metrics">
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-blue"><Cpu size={19} /></div><div><span>CPU 使</span><strong>{formatPercent(metrics?.cpuUsagePercent ?? null)}</strong><small>5</small></div></article>
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-violet"><MemoryStick size={19} /></div><div><span>使</span><strong>{formatPercent(metrics?.memoryUsagePercent ?? null)}</strong><small>{formatBytes(metrics?.memoryAvailableBytes ?? null)} / {formatBytes(metrics?.memoryTotalBytes ?? null)}</small></div></article>
<article className="surface system-monitoring-metric">
<div className="system-monitoring-metric__icon is-blue">
<Cpu size={19} />
</div>
<div>
<span>CPU 使</span>
<strong>{formatPercent(metrics?.cpuUsagePercent ?? null)}</strong>
<small>5</small>
</div>
</article>
<article className="surface system-monitoring-metric">
<div className="system-monitoring-metric__icon is-violet">
<MemoryStick size={19} />
</div>
<div>
<span>使</span>
<strong>{formatPercent(metrics?.memoryUsagePercent ?? null)}</strong>
<small>
{formatBytes(metrics?.memoryAvailableBytes ?? null)} /{' '}
{formatBytes(metrics?.memoryTotalBytes ?? null)}
</small>
</div>
</article>
<DiskMetricCards disks={overview?.disks ?? []} />
<article className="surface system-monitoring-metric"><div className="system-monitoring-metric__icon is-green"><Network size={19} /></div><div><span></span><strong>{formatRate(totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond))}</strong><small> {formatRate(metrics?.networkReceiveBytesPerSecond ?? null)} · {formatRate(metrics?.networkTransmitBytesPerSecond ?? null)}</small></div></article>
<article className="surface system-monitoring-metric">
<div className="system-monitoring-metric__icon is-green">
<Network size={19} />
</div>
<div>
<span></span>
<strong>
{formatRate(
totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond),
)}
</strong>
<small>
{formatRate(metrics?.networkReceiveBytesPerSecond ?? null)} · {' '}
{formatRate(metrics?.networkTransmitBytesPerSecond ?? null)}
</small>
</div>
</article>
</div>
<div className="system-monitoring-main-grid">
<div className="system-monitoring-chart-stack">
<article className="surface system-monitoring-chart-card"><header><div><Cpu size={17} /><strong>CPU </strong></div><span>{formatPercent(metrics?.cpuUsagePercent ?? null)}</span></header>{overview?.trends.cpuUsagePercent.length ? <Chart height={230} option={cpuOption} /> : <EmptyChart />}</article>
<article className="surface system-monitoring-chart-card"><header><div><MemoryStick size={17} /><strong></strong></div><span>{formatPercent(metrics?.memoryUsagePercent ?? null)}</span></header>{overview?.trends.memoryUsagePercent.length ? <Chart height={230} option={memoryOption} /> : <EmptyChart />}</article>
<article className="surface system-monitoring-chart-card"><header><div><HardDrive size={17} /><strong></strong></div><span>{overview?.disks?.length ?? 0} </span></header>{overview?.disks?.some((disk) => disk.trend.length) ? <Chart height={230} option={diskOption} /> : <EmptyChart />}</article>
<article className="surface system-monitoring-chart-card"><header><div><Activity size={17} /><strong></strong></div><span>{formatRate(totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond))}</span></header>{overview?.trends.networkReceiveBytesPerSecond.length ? <Chart height={230} option={networkOption} /> : <EmptyChart />}</article>
<article className="surface system-monitoring-chart-card">
<header>
<div>
<Cpu size={17} />
<strong>CPU </strong>
</div>
<span>{formatPercent(metrics?.cpuUsagePercent ?? null)}</span>
</header>
{overview?.trends.cpuUsagePercent.length ? <Chart height={230} option={cpuOption} /> : <EmptyChart />}
</article>
<article className="surface system-monitoring-chart-card">
<header>
<div>
<MemoryStick size={17} />
<strong></strong>
</div>
<span>{formatPercent(metrics?.memoryUsagePercent ?? null)}</span>
</header>
{overview?.trends.memoryUsagePercent.length ? <Chart height={230} option={memoryOption} /> : <EmptyChart />}
</article>
<article className="surface system-monitoring-chart-card">
<header>
<div>
<HardDrive size={17} />
<strong></strong>
</div>
<span>{overview?.disks?.length ?? 0} </span>
</header>
{overview?.disks?.some((disk) => disk.trend.length) ? (
<Chart height={230} option={diskOption} />
) : (
<EmptyChart />
)}
</article>
<article className="surface system-monitoring-chart-card">
<header>
<div>
<Activity size={17} />
<strong></strong>
</div>
<span>
{formatRate(
totalNetworkRate(metrics?.networkReceiveBytesPerSecond, metrics?.networkTransmitBytesPerSecond),
)}
</span>
</header>
{overview?.trends.networkReceiveBytesPerSecond.length ? (
<Chart height={230} option={networkOption} />
) : (
<EmptyChart />
)}
</article>
</div>
<aside className="surface system-monitoring-services">
<header><div><Server size={18} /><strong></strong></div><Tag tone={serviceHealthy === serviceTotal && overview?.available ? 'success' : 'neutral'}>{serviceHealthy}/{serviceTotal} </Tag></header>
<header>
<div>
<Server size={18} />
<strong></strong>
</div>
<Tag tone={serviceHealthy === serviceTotal && overview?.available ? 'success' : 'neutral'}>
{serviceHealthy}/{serviceTotal}
</Tag>
</header>
<div className="system-monitoring-service-list">
{(overview?.services ?? []).map((service) => (
<div className="system-monitoring-service" key={service.key}>
<span className={`system-monitoring-service__dot is-${service.status}`} />
<div><strong>{service.name}</strong><small>{service.unit}</small></div>
<div>
<strong>{service.name}</strong>
<small>{service.unit}</small>
</div>
<span>{service.status === 'healthy' ? '正常' : service.status === 'unhealthy' ? '异常' : '未知'}</span>
</div>
))}
{!overview?.services.length ? [
['api', 'API服务'], ['gateway', 'Gateway服务'], ['postgresql', 'PostgreSQL'], ['redis', 'Redis'], ['minio', 'MinIO'], ['nginx', 'Nginx'],
].map(([key, name]) => <div className="system-monitoring-service" key={key}><span className="system-monitoring-service__dot is-unknown" /><div><strong>{name}</strong><small></small></div><span></span></div>) : null}
{!overview?.services.length
? [
['api', 'API服务'],
['gateway', 'Gateway服务'],
['postgresql', 'PostgreSQL'],
['redis', 'Redis'],
['minio', 'MinIO'],
['nginx', 'Nginx'],
].map(([key, name]) => (
<div className="system-monitoring-service" key={key}>
<span className="system-monitoring-service__dot is-unknown" />
<div>
<strong>{name}</strong>
<small></small>
</div>
<span></span>
</div>
))
: null}
</div>
<div className="system-monitoring-collector-note">
<Database size={16} />
<span> Prometheus </span>
</div>
<div className="system-monitoring-collector-note"><Database size={16} /><span> Prometheus </span></div>
</aside>
</div>
<section className="surface system-monitoring-service-metrics">
<header>
<div><Database size={18} /><strong></strong></div>
<div className="system-monitoring-service-actions"><span>ID或SQL文本</span><Button icon={<Settings2 size={15} />} onClick={() => setShowSettings(true)} variant="ghost"></Button></div>
<div>
<Database size={18} />
<strong></strong>
</div>
<div className="system-monitoring-service-actions">
<span>ID或SQL文本</span>
<Button icon={<Settings2 size={15} />} onClick={() => setShowSettings(true)} variant="ghost">
</Button>
</div>
</header>
<div className="system-monitoring-service-metric-grid">
{(overview?.serviceMetrics ?? []).map((group) => (
<article key={group.key}>
<div className="system-monitoring-service-metric-title"><strong>{group.name}</strong><Tag tone={group.available ? 'success' : 'neutral'}>{group.available ? '已采集' : '待采集'}</Tag></div>
{group.metrics.length ? group.metrics.map((metric) => <div className="system-monitoring-service-metric-row" key={metric.key}><span>{metric.label}</span><strong>{formatServiceMetric(metric.value, metric.unit)}</strong></div>) : <div className="system-monitoring-service-metric-empty"></div>}
<div className="system-monitoring-service-metric-title">
<strong>{group.name}</strong>
<Tag tone={group.available ? 'success' : 'neutral'}>{group.available ? '已采集' : '待采集'}</Tag>
</div>
{group.metrics.length ? (
group.metrics.map((metric) => (
<div className="system-monitoring-service-metric-row" key={metric.key}>
<span>{metric.label}</span>
<strong>{formatServiceMetric(metric.value, metric.unit)}</strong>
</div>
))
) : (
<div className="system-monitoring-service-metric-empty"></div>
)}
</article>
))}
</div>
</section>
<AlertHistory />
<section className="surface system-monitoring-alerts" id="active-alerts">
<header><div><AlertTriangle size={18} /><strong></strong><Tag tone={overview?.summary.activeAlerts ? 'warning' : 'success'}>{overview?.summary.activeAlerts ?? 0}</Tag></div><span><Clock3 size={14} /> {formatTime(overview?.collectedAt ?? null)}</span></header>
{readError ? <div className="system-monitoring-unavailable" role="alert"><AlertTriangle size={18} /><div><strong></strong><span>{readError}</span></div></div> : null}
<Table columns={alertColumns} data={overview?.alerts ?? []} emptyText={overview?.available ? '当前没有活动告警' : '监控不可用,无法读取活动告警'} pagination={false} rowKey="fingerprint" />
<header>
<div>
<AlertTriangle size={18} />
<strong></strong>
<Tag tone={overview?.summary.activeAlerts ? 'warning' : 'success'}>
{overview?.summary.activeAlerts ?? 0}
</Tag>
</div>
<span>
<Clock3 size={14} /> {formatTime(overview?.collectedAt ?? null)}
</span>
</header>
{readError ? (
<div className="system-monitoring-unavailable" role="alert">
<AlertTriangle size={18} />
<div>
<strong></strong>
<span>{readError}</span>
</div>
</div>
) : null}
<Table
columns={alertColumns}
data={overview?.alerts ?? []}
emptyText={overview?.available ? '当前没有活动告警' : '监控不可用,无法读取活动告警'}
pagination={false}
rowKey="fingerprint"
/>
</section>
<Modal footer={<><Button onClick={() => setShowSettings(false)} variant="ghost"></Button><Button disabled={savingSettings || !settings} onClick={() => void saveSettings()}>{savingSettings ? '验证并应用中' : '保存并应用'}</Button></>} onClose={() => setShowSettings(false)} open={showSettings} title="Prometheus 告警阈值设置">
<Modal
footer={
<>
<Button onClick={() => setShowSettings(false)} variant="ghost">
</Button>
<Button disabled={savingSettings || !settings} onClick={() => void saveSettings()}>
{savingSettings ? '验证并应用中' : '保存并应用'}
</Button>
</>
}
onClose={() => setShowSettings(false)}
open={showSettings}
title="Prometheus 告警阈值设置"
>
<div className="system-monitoring-threshold-dialog">
<div className="system-monitoring-threshold-note"><ShieldAlert size={17} /><span> promtool Prometheus</span></div>
<div className="system-monitoring-threshold-note">
<ShieldAlert size={17} />
<span> promtool Prometheus</span>
</div>
{settings?.definitions.map((definition) => (
<div className="system-monitoring-threshold-row" key={definition.key}>
<div><strong>{definition.label}</strong><small>{definition.unit}</small></div>
<Input label="警告阈值" max={definition.max} min={definition.min} onChange={(event) => setDraftThresholds((current) => ({ ...current, [definition.key]: { ...current[definition.key], warning: Number(event.target.value) } }))} step={definition.step} type="number" value={draftThresholds[definition.key]?.warning ?? ''} />
<Input label="严重阈值" max={definition.max} min={definition.min} onChange={(event) => setDraftThresholds((current) => ({ ...current, [definition.key]: { ...current[definition.key], critical: Number(event.target.value) } }))} step={definition.step} type="number" value={draftThresholds[definition.key]?.critical ?? ''} />
<div>
<strong>{definition.label}</strong>
<small>{definition.unit}</small>
</div>
<Input
label="警告阈值"
max={definition.max}
min={definition.min}
onChange={(event) =>
setDraftThresholds((current) => ({
...current,
[definition.key]: { ...current[definition.key], warning: Number(event.target.value) },
}))
}
step={definition.step}
type="number"
value={draftThresholds[definition.key]?.warning ?? ''}
/>
<Input
label="严重阈值"
max={definition.max}
min={definition.min}
onChange={(event) =>
setDraftThresholds((current) => ({
...current,
[definition.key]: { ...current[definition.key], critical: Number(event.target.value) },
}))
}
step={definition.step}
type="number"
value={draftThresholds[definition.key]?.critical ?? ''}
/>
</div>
))}
{settings?.applyStatus === 'failed' ? <div className="system-monitoring-unavailable"><AlertTriangle size={18} /><div><strong></strong><span>{settings.lastError}</span></div></div> : null}
{settingsError ? <div className="system-monitoring-unavailable"><AlertTriangle size={18} /><div><strong></strong><span>{settingsError}</span></div></div> : null}
{settings?.applyStatus === 'failed' ? (
<div className="system-monitoring-unavailable">
<AlertTriangle size={18} />
<div>
<strong></strong>
<span>{settings.lastError}</span>
</div>
</div>
) : null}
{settingsError ? (
<div className="system-monitoring-unavailable">
<AlertTriangle size={18} />
<div>
<strong></strong>
<span>{settingsError}</span>
</div>
</div>
) : null}
</div>
</Modal>
</section>
@@ -442,5 +824,10 @@ export function AdminSystemMonitoringPage() {
}
function EmptyChart() {
return <div className="system-monitoring-chart-empty"><Activity size={22} /><span></span></div>;
return (
<div className="system-monitoring-chart-empty">
<Activity size={22} />
<span></span>
</div>
);
}
@@ -0,0 +1,21 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { AlertHistory } from './AlertHistory';
const { get } = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock('@/api/adminApi', () => ({ adminApi: { getInfrastructureAlertHistory: get } }));
describe('alert history', () => {
it('defaults to seven calendar days and sends the selected page; failure is not an empty success', async () => {
get.mockResolvedValue({ items: [], total: 30, page: 1, pageSize: 25 });
render(<AlertHistory />);
await waitFor(() => expect(screen.getByRole('button', { name: '下一页' })).toBeEnabled());
const [from, to, page] = get.mock.calls[0];
expect((Date.parse(to) - Date.parse(from)) / 86400_000).toBe(6);
expect(page).toBe(1);
get.mockRejectedValue(new Error('监控不可用'));
fireEvent.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('监控不可用'));
expect(get).toHaveBeenLastCalledWith(from, to, 2);
expect(screen.getByText('历史告警不可用')).toBeInTheDocument();
});
});
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { adminApi } from '@/api/adminApi';
import { Button, DateRangeInput, Table, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
type History = Awaited<ReturnType<typeof adminApi.getInfrastructureAlertHistory>>;
const columns: TableColumn<History['items'][number]>[] = [
{ key: 'name', title: '告警', width: '280px', render: (row) => row.name },
{
key: 'severity',
title: '级别',
width: '90px',
render: (row) => ({ critical: '严重', warning: '警告', info: '提示' })[row.severity] || row.severity,
},
{ key: 'instance', title: '服务 / 实例', width: '220px', render: (row) => row.service || row.instance || '主机资源' },
{ key: 'startedAt', title: '触发时间', width: '180px', render: (row) => formatDateTime(row.startedAt) },
{
key: 'lastObservedAt',
title: '范围内最后采样',
width: '180px',
render: (row) => formatDateTime(row.lastObservedAt),
},
];
export function AlertHistory() {
const [dates, setDates] = useState(() => {
const key = (time: number) => new Date(time + 8 * 3600_000).toISOString().slice(0, 10);
return { start: key(Date.now() - 6 * 86400_000), end: key(Date.now()) };
});
const [query, setQuery] = useState({ ...dates, page: 1, revision: 0 });
const [response, setResponse] = useState<{ query: typeof query; data: History | null; error: string }>();
const loading = response?.query !== query;
const data = loading ? null : response?.data;
const error = loading ? '' : response?.error;
useEffect(() => {
let current = true;
adminApi
.getInfrastructureAlertHistory(query.start, query.end, query.page)
.then((result) => {
if (current) setResponse({ query, data: result, error: '' });
})
.catch((reason) => {
if (current)
setResponse({ query, data: null, error: reason instanceof Error ? reason.message : '历史告警加载失败' });
});
return () => {
current = false;
};
}, [query]);
return (
<section className="surface system-monitoring-alerts" aria-label="历史告警记录">
<header>
<strong></strong>
</header>
<div className="filter-bar">
<DateRangeInput
label="告警日期"
value={dates}
onChange={(value) => setDates({ start: value.start || '', end: value.end || '' })}
/>
<Button disabled={loading} onClick={() => setQuery({ ...dates, page: 1, revision: query.revision + 1 })}>
</Button>
</div>
<p className="muted">
731 Prometheus
</p>
{error ? (
<p role="alert" className="form-error">
{error}
</p>
) : null}
<Table
columns={columns}
data={loading ? [] : (data?.items ?? [])}
rowKey="id"
pagination={false}
emptyText={loading ? '历史告警加载中…' : error ? '历史告警不可用' : '所选日期没有保留的告警记录'}
/>
<div className="table-footer">
<span>
{data?.total ?? 0} · {data?.page ?? query.page}
</span>
<Button disabled={loading || query.page <= 1} onClick={() => setQuery({ ...query, page: query.page - 1 })}>
</Button>
<Button
disabled={loading || !data || query.page * data.pageSize >= data.total}
onClick={() => setQuery({ ...query, page: query.page + 1 })}
>
</Button>
</div>
</section>
);
}
+27 -1
View File
@@ -16,7 +16,7 @@ describe('Modal close policy', () => {
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(close).toHaveBeenCalledOnce();
});
it('preserves mask closing by default for existing consumers', () => {
it('ignores backdrop, panel and Escape by default, including clean forms', () => {
const close = vi.fn();
render(
<Modal open title="普通弹窗" onClose={close}>
@@ -24,6 +24,32 @@ describe('Modal close policy', () => {
</Modal>,
);
fireEvent.mouseDown(document.querySelector('.ui-modal__mask')!);
fireEvent.mouseDown(screen.getByRole('dialog'));
fireEvent.keyDown(document, { key: 'Escape' });
expect(close).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(close).toHaveBeenCalledOnce();
});
it('keeps the dirty guard for explicit close and allows canceling it', () => {
const close = vi.fn();
render(
<Modal
open
dirty
title="编辑"
onClose={close}
footer={({ requestClose }) => <button onClick={requestClose}></button>}
>
</Modal>,
);
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
expect(close).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
fireEvent.click(screen.getByRole('button', { name: '放弃并关闭' }));
expect(close).toHaveBeenCalledOnce();
});
});
+9 -4
View File
@@ -97,8 +97,8 @@ export function Modal({
size = 'md',
onClose,
dirty = false,
closeOnBackdrop = true,
closeOnEscape = true,
closeOnBackdrop = false,
closeOnEscape = false,
initialFocusRef,
closeGuardTitle = '放弃未保存的修改?',
closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。',
@@ -139,9 +139,10 @@ export function Modal({
lockDocument(layer);
const focusTarget = initialFocusRef?.current ?? focusableElements(panel)[0] ?? panel;
requestAnimationFrame(() => focusTarget.focus());
const focusFrame = requestAnimationFrame(() => focusTarget.focus());
return () => {
cancelAnimationFrame(focusFrame);
const stackIndex = modalStack.lastIndexOf(panel);
if (stackIndex >= 0) modalStack.splice(stackIndex, 1);
unlockDocument();
@@ -197,8 +198,12 @@ export function Modal({
if (!showCloseGuard) return;
const panel = panelRef.current;
if (panel) panel.inert = true;
requestAnimationFrame(() => focusableElements(guardRef.current ?? panelRef.current!)[0]?.focus());
const focusFrame = requestAnimationFrame(() => {
const root = guardRef.current ?? panelRef.current;
if (root) focusableElements(root)[0]?.focus();
});
return () => {
cancelAnimationFrame(focusFrame);
if (panel) panel.inert = false;
const restoreTarget = guardRestoreFocusRef.current;
queueMicrotask(() => {
@@ -0,0 +1,250 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
// Only isolated local QA data. Never starts workers, publishes SMS, changes
// live configuration, or fabricates supplier/customer delivery evidence.
const require = createRequire(resolve('api/package.json'));
for (const file of ['api/.env', '.env']) if (existsSync(file)) process.loadEnvFile(file);
const name = process.env.CMPP_CHANNEL_WORD_QA_DATABASE;
assert.match(name ?? '', /^cmpp_qa_channel_words_\d+$/);
const url = new URL(process.env.DATABASE_URL);
assert(['127.0.0.1', 'localhost'].includes(url.hostname));
url.pathname = `/${name}`;
process.env.DATABASE_URL = url.toString();
process.env.CMPP_PROCESS_ROLE = 'api';
const { PrismaService } = require('./dist/prisma/prisma.service.js');
const { ChannelSensitiveWordsService } = require('./dist/dictionaries/channel-sensitive-words.service.js');
const { SendChainService } = require('./dist/send-chain/send-chain.service.js');
const { loadChannelWords } = require('./dist/send-chain/channel-sensitive-routing.js');
const { OperationsMessageQueries } = require('./dist/operations/queries/messages.queries.js');
const { clientMessageView } = require('./dist/operations/operations.helpers.js');
const db = new PrismaService(),
prefix = `qa-channel-word-${randomUUID()}`,
checks = [];
try {
const role = await db.role.findUniqueOrThrow({ where: { code: 'platform_admin' } });
const admin = await db.user.create({
data: {
username: prefix,
displayName: '隔离验收管理员',
passwordHash: 'isolated-no-login',
roles: { create: { roleId: role.id } },
},
});
const unauthorized = await db.user.create({
data: { username: `${prefix}-unauthorized`, displayName: '隔离无权限用户', passwordHash: 'isolated-no-login' },
});
const tenant = await db.tenant.create({ data: { code: prefix, name: prefix } });
const application = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: prefix,
cmppAccount: prefix,
cmppEnterpriseCode: 'QA',
secretHash: 'isolated-no-login',
},
});
const signature = await db.smsSignature.create({
data: { tenantId: tenant.id, applicationId: application.id, name: prefix, auditStatus: 'approved' },
});
const group = await db.smsChannelGroup.create({ data: { code: prefix, name: prefix, carrier: 'mobile' } });
const channels = [];
for (const index of [0, 1]) {
const channel = await db.smsChannel.create({
data: {
code: `${prefix}-${index}`,
name: `验收通道${index ? 'B' : 'A'}`,
carrier: 'all',
carriers: ['mobile', 'unicom', 'telecom'],
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: prefix,
passwordCipher: 'isolated-no-transport',
srcId: '1069',
},
});
channels.push(channel);
await db.cmppConnectionState.create({
data: {
channelId: channel.id,
connectionId: prefix,
status: 'connected',
currentConnections: 1,
desiredConnections: 1,
},
});
await db.smsChannelGroupItem.create({
data: { groupId: group.id, channelId: channel.id, carrier: 'mobile', priority: index + 1 },
});
await db.channelSignatureReportTask.create({
data: {
channelId: channel.id,
signatureId: signature.id,
tenantId: tenant.id,
carrier: 'mobile',
approvalScope: 'carrier_specific',
reportType: 'signature',
status: 'approved',
},
});
}
await db.channelRouteRule.create({
data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier: 'mobile' },
});
const service = new ChannelSensitiveWordsService(db);
await assert.rejects(service.list(unauthorized.id, {}), /权限/);
await assert.rejects(service.save(admin.id, { channelId: channels[0].id, word: ' ', status: 'active' }), /字符/);
const data = { channelId: channels[0].id, word: ' 贷款 ', status: 'active', remark: 'QA' };
let word = await service.save(admin.id, data);
assert.equal(word.word, '贷款');
await assert.rejects(service.save(admin.id, data), /相同/);
const concurrent = await Promise.allSettled([
service.save(admin.id, { ...data, version: word.version, remark: 'first' }, word.id),
service.save(admin.id, { ...data, version: word.version, remark: 'second' }, word.id),
]);
assert.equal(concurrent.filter((result) => result.status === 'fulfilled').length, 1);
assert.equal(concurrent.filter((result) => result.status === 'rejected').length, 1);
word = await db.channelSensitiveWord.findUniqueOrThrow({ where: { id: word.id } });
assert.equal(word.version, 2);
checks.push('real PostgreSQL validation, permission, unique word, concurrent optimistic version conflict');
const list = await service.list(admin.id, {
channelId: channels[0].id,
keyword: '贷',
page: '1',
pageSize: '25',
status: 'active',
});
assert.equal(list.total, 1);
assert.equal(list.items[0].id, word.id);
const before = await loadChannelWords(
db,
channels.map((channel) => channel.id),
);
word = await service.save(admin.id, { ...data, version: word.version, status: 'inactive' }, word.id);
assert.equal(before.hits('贷款').length, 1);
assert.equal(
(
await loadChannelWords(
db,
channels.map((channel) => channel.id),
)
).hits('贷款').length,
0,
);
await service.remove(admin.id, word.id, word.version);
assert.equal((await service.list(admin.id, { channelId: channels[0].id })).total, 0);
const restored = await service.save(admin.id, data);
assert.equal(restored.id, word.id);
assert(restored.version > word.version);
assert.equal(await db.operationLog.count({ where: { resourceId: word.id, resource: 'channel_sensitive_word' } }), 5);
checks.push('filter, disable, soft delete, audited restore retain ID; old snapshot unaffected by config edits');
const messages = [];
for (let index = 0; index < 100; index++)
messages.push(
await db.smsMessageRecord.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
signatureId: signature.id,
messageId: `${prefix}-${index}`,
content: `【验收】贷款咨询${'字'.repeat(80)}`,
phoneNumber: '13800000000',
carrier: 'mobile',
},
}),
);
const sendChain = new SendChainService(db, {}, {}, {});
const ordinary = await sendChain.selectChannelForMessage(messages[0]);
assert.equal(ordinary.channel.id, channels[1].id);
const started = performance.now();
let reads = 0,
writes = 0;
const originalRead = db.channelSensitiveWord.findMany.bind(db.channelSensitiveWord);
const originalWrite = db.smsChannelSensitiveDecision.createMany.bind(db.smsChannelSensitiveDecision);
db.channelSensitiveWord.findMany = (...args) => {
reads++;
return originalRead(...args);
};
db.smsChannelSensitiveDecision.createMany = (...args) => {
writes++;
return originalWrite(...args);
};
const batch = await sendChain.submission.gatewaySubmit.planRoutesBatch(messages);
assert.equal(batch.planned.length, 100);
assert.equal(batch.failed.length, 0);
assert(batch.planned.every((item) => item.routed.channel.id === channels[1].id));
assert.equal(reads, 1);
assert.equal(writes, 1);
const durationMs = Math.round(performance.now() - started);
checks.push(
`ordinary and 100-message batch choose B, one word SQL and one decision SQL; ${durationMs}ms includes existing drainage work`,
);
await service.save(admin.id, { ...data, channelId: channels[1].id });
const failed = await sendChain.submission.gatewaySubmit.planRoutesBatch([messages[1]]);
assert.equal(failed.failed[0].code, 'CHANNEL_SENSITIVE_WORD_NO_ROUTE');
await assert.rejects(sendChain.selectChannelForMessage(messages[2]), /均命中/);
const detail = await new OperationsMessageQueries(db).getMessage(messages[0].id);
assert(
detail.channelWordDecisions.some((decision) =>
decision.snapshot.hits.some((hit) => hit.channelName === '验收通道A'),
),
);
assert.equal(clientMessageView(detail).channelWordDecisions, undefined);
assert.equal(
(await db.smsMessageRecord.findUniqueOrThrow({ where: { id: messages[0].id } })).content,
messages[0].content,
);
await before.persist(db);
assert.equal(
await db.gatewaySubmitOutbox.count({ where: { messageRecordId: { in: messages.map((message) => message.id) } } }),
0,
);
assert.equal(
await db.smsSubmitRecord.count({ where: { messageRecordId: { in: messages.map((message) => message.id) } } }),
0,
);
checks.push(
'all candidates rejected in ordinary/batch, historical admin explanation, client redaction, original text unchanged, no submit intents',
);
// Exercise the actual failure SQL before an isolated release failure.
const task = await db.smsBatchTask.create({
data: {
tenantId: tenant.id,
applicationId: application.id,
taskNo: prefix,
sourceType: 'client',
content: '贷款',
phoneTotal: 1,
},
});
const pending = await db.smsMessageRecord.update({
where: { id: messages[3].id },
data: { batchTaskId: task.id },
include: { batchTask: true },
});
sendChain.releaseMessageReservation = async () => {
throw Error('isolated release failure');
};
await assert.rejects(
sendChain.submission.gatewaySubmit.failRouteBatch(
[{ message: pending, reason: '可用通道均命中通道敏感词', code: 'CHANNEL_SENSITIVE_WORD_NO_ROUTE' }],
new Map(),
),
/isolated release failure/,
);
const persisted = await db.smsMessageRecord.findUniqueOrThrow({ where: { id: pending.id } });
assert.equal(persisted.status, 'failed');
assert.equal(persisted.channelWordFinalizationPending, true);
assert.equal(persisted.drainageReceiptPending, false);
checks.push(
'real batch failure SQL marks non-CMPP finalization pending before injected release failure; no callback or SMS',
);
console.log(
JSON.stringify({ success: true, database: name, fixturePrefix: prefix, messageId: messages[0].id, checks }),
);
} finally {
await db.$disconnect();
}
+218
View File
@@ -0,0 +1,218 @@
import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
// Only a separately created QA database is accepted. No send entry, worker,
// Gateway transport, balance mutation or remote business configuration is used.
const require = createRequire(resolve('api/package.json'));
for (const file of ['api/.env', '.env']) if (existsSync(file)) process.loadEnvFile(file);
const name = process.env.CMPP_DRAINAGE_QA_DATABASE;
assert.match(name ?? '', /^cmpp_qa_drainage_\d+$/);
const url = new URL(process.env.DATABASE_URL);
assert(['localhost', '127.0.0.1'].includes(url.hostname));
url.pathname = `/${name}`;
process.env.DATABASE_URL = url.toString();
process.env.CMPP_PROCESS_ROLE = 'api';
const { PrismaService } = require('./dist/prisma/prisma.service.js');
const { evaluateMessageDrainage } = require('./dist/send-chain/drainage-authorization.js');
const { DrainageSubmitGuardController } = require('./dist/send-chain/drainage-submit-guard.controller.js');
const { ChannelReportingService } = require('./dist/channels/channel-reporting.service.js');
const { ReportsService } = require('./dist/reports/reports.service.js');
const { Module } = require('@nestjs/common');
const { NestFactory } = require('@nestjs/core');
const db = new PrismaService();
const checks = [];
const prefix = `qa-drainage-${randomUUID()}`;
let app;
try {
const application = await db.smsApplication.findFirstOrThrow({ where: { status: { not: 'deleted' } } });
const channels = await db.smsChannel.findMany({ take: 2 });
assert.equal(channels.length, 2);
const signature = await db.smsSignature.create({
data: {
id: prefix,
tenantId: application.tenantId,
applicationId: application.id,
name: prefix,
auditStatus: 'approved',
},
});
const common = { tenantId: application.tenantId, applicationId: application.id, signatureId: signature.id };
const materials = [];
for (const [index, target] of ['lisglo.cn', '02177882277'].entries()) {
const material = await db.smsDrainageInfo.create({
data: { ...common, siteName: prefix, url: target, auditStatus: 'approved' },
});
materials.push(material);
for (const channel of channels.slice(index))
await db.channelSignatureReportTask.create({
data: {
tenantId: application.tenantId,
signatureId: signature.id,
channelId: channel.id,
carrier: 'mobile',
approvalScope: 'carrier',
reportType: 'drainage',
drainageItemId: material.id,
status: 'approved',
},
});
}
for (const channel of channels)
await db.channelSignatureReportTask.create({
data: {
tenantId: application.tenantId,
signatureId: signature.id,
channelId: channel.id,
carrier: 'mobile',
approvalScope: 'carrier',
reportType: 'signature',
status: 'approved',
},
});
const original = `${prefix}】访问 https://sms.lisglo.cn/path?x=1 或联系 021-77882277`;
const message = await db.smsMessageRecord.create({
data: { ...common, messageId: prefix, content: original, phoneNumber: '13800000000', carrier: 'mobile' },
});
const gate = await evaluateMessageDrainage(db, message, 'mobile', undefined, true);
assert.deepEqual(gate.allowedChannelIds, [channels[1].id]);
assert.equal(gate.targets.length, 2);
assert.equal((await db.smsMessageRecord.findUniqueOrThrow({ where: { id: message.id } })).content, original);
assert.equal(await db.smsDrainageDecision.count({ where: { messageRecordId: message.id } }), 1);
checks.push(
'real rules, NFKC phone, two targets, channel intersection, original content preserved, durable decision',
);
const submit = await db.smsSubmitRecord.create({
data: { messageRecordId: message.id, channelId: channels[1].id, submitId: `${prefix}-submit` },
});
class QAOnlyModule {}
Module({ controllers: [DrainageSubmitGuardController], providers: [{ provide: PrismaService, useValue: db }] })(
QAOnlyModule,
);
app = await NestFactory.create(QAOnlyModule, { logger: false });
await app.listen(0, '127.0.0.1');
const base = await app.getUrl();
const body = {
submitId: submit.submitId,
channelId: channels[1].id,
contentHash: createHash('sha256').update(original).digest('hex'),
};
const check = async (extra = {}) => {
const response = await fetch(`${base}/gateway/events/authorize-drainage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...extra },
body: JSON.stringify(body),
signal: AbortSignal.timeout(15000),
});
assert.equal(response.status, 201);
return response.json();
};
assert.equal((await check()).allowed, true);
await db.channelSignatureReportTask.updateMany({
where: { drainageItemId: materials[1].id },
data: { status: 'rejected' },
});
assert.equal((await check()).allowed, false);
checks.push('real HTTP final permission changes immediately after carrier report revocation');
await db.channelSignatureReportTask.updateMany({
where: { drainageItemId: materials[1].id },
data: { status: 'approved' },
});
await db.smsDrainageInfo.update({ where: { id: materials[0].id }, data: { auditStatus: 'pending' } });
assert.equal((await check()).allowed, false);
await db.smsDrainageInfo.update({ where: { id: materials[0].id }, data: { auditStatus: 'approved' } });
const denied = await fetch(`${base}/gateway/events/authorize-drainage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '198.51.100.1' },
body: JSON.stringify(body),
});
assert.equal(denied.status, 403);
checks.push('platform audit required; proxy-origin requests forbidden');
let acquired;
let release;
const locked = new Promise((resolve) => {
acquired = resolve;
});
const unlock = new Promise((resolve) => {
release = resolve;
});
const writer = db.$transaction(
async (tx) => {
await tx.smsDrainageInfo.update({ where: { id: materials[0].id }, data: { auditStatus: 'pending' } });
acquired();
await unlock;
},
{ timeout: 10000 },
);
await locked;
let finished = false;
const concurrent = check().then((result) => {
finished = true;
return result;
});
await new Promise((resolve) => setTimeout(resolve, 100));
const waitedForWriter = !finished;
release();
await writer;
assert(waitedForWriter);
assert.equal((await concurrent).allowed, false);
checks.push('PostgreSQL writer lock blocks concurrent permission and committed revocation is observed');
await db.smsDrainageInfo.update({ where: { id: materials[0].id }, data: { auditStatus: 'approved' } });
for (const content of [
'https://lisglo.cn.evil.com/p',
'https://evil.com/?next=lisglo.cn',
'https://lisglo.cn@evil.com/p',
]) {
const rejected = await db.smsMessageRecord.create({
data: {
...common,
messageId: `${prefix}-${randomUUID()}`,
content,
phoneNumber: '13800000000',
carrier: 'mobile',
},
});
await assert.rejects(evaluateMessageDrainage(db, rejected, 'mobile', undefined, true), /未在当前签名下添加/);
}
checks.push('real configured detectors reject suffix spoof, query spoof and URL userInfo spoof');
await evaluateMessageDrainage(db, message, 'mobile', undefined, true);
await new ReportsService(db).refreshRollingWindow(new Date(Date.now() + 86400000));
const quality = await db.dailyQualityReport.findMany({
where: { dimensionType: 'drainage', drainageInfoId: { in: materials.map((item) => item.id) } },
});
assert.equal(quality.length, 2);
assert(quality.every((row) => row.submittedUnits === 1));
const today = new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00+08:00');
const customerUnits = await db.smsMessageRecord.aggregate({
where: { applicationId: application.id, queuedAt: { gte: today, lt: new Date(today.getTime() + 86400000) } },
_sum: { billingUnits: true },
});
const applicationQuality = await db.dailyQualityReport.findFirstOrThrow({
where: { dimensionType: 'application', dimensionId: application.id },
orderBy: { reportDate: 'desc' },
});
assert.equal(applicationQuality.submittedUnits, customerUnits._sum.billingUnits);
// Synthetic rejected metadata only, no supplier transmission is performed.
await db.smsSubmitRecord.update({ where: { id: submit.id }, data: { submitStatus: 'rejected', errorCode: 'DRN' } });
const reports = await new ChannelReportingService(db).listReportTasks(
application.tenantId,
undefined,
channels[1].id,
'drainage',
);
const ownReports = reports.filter((row) => row.signatureId === signature.id);
assert.equal(ownReports.length, 2);
assert(ownReports.every((row) => row.deliveryStats.total === 0));
checks.push(
'real quality SQL attributes one message to both targets without multiplying customer units; no-wire rejection counts zero channel attempts',
);
assert.equal(await db.gatewaySubmitOutbox.count({ where: { messageRecordId: message.id } }), 0);
assert.equal(await db.smsReceiptRecord.count({ where: { messageRecordId: message.id } }), 0);
checks.push('no send intent or customer receipt was created by qualification checks');
console.log(JSON.stringify({ success: true, database: name, fixturePrefix: prefix, checks }));
} finally {
if (app) await app.close();
await db.$disconnect();
}