Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d13ca0713a | ||
|
|
8e4bc5a20e | ||
|
|
86947827cc | ||
|
|
0c3f820cc9 | ||
|
|
5bcdbb2a03 |
Generated
+20
-1
@@ -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
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<
|
||||
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
@@ -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('敏感词需为1~200个字符');
|
||||
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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
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: [
|
||||
],
|
||||
},
|
||||
{
|
||||
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: [
|
||||
],
|
||||
},
|
||||
{
|
||||
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: [
|
||||
],
|
||||
},
|
||||
{
|
||||
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: [
|
||||
],
|
||||
},
|
||||
{
|
||||
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: [
|
||||
],
|
||||
},
|
||||
{
|
||||
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,11 +282,20 @@ 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 {
|
||||
@@ -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 } }),
|
||||
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 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,
|
||||
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,
|
||||
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)));
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
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'
|
||||
const statusWhere =
|
||||
query.status === 'submit_failed'
|
||||
? { OR: [{ status: 'submit_failed' }, { submitStatus: { in: ['rejected', 'timeout'] } }] }
|
||||
: query.status === 'failed'
|
||||
? { status: 'failed', submitStatus: 'accepted' }
|
||||
@@ -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 ? {
|
||||
...(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 ? [
|
||||
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,
|
||||
]
|
||||
: 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,7 +525,8 @@ 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')
|
||||
const level =
|
||||
result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
|
||||
? 'error'
|
||||
: log.action.includes('warning') || log.action.includes('risk')
|
||||
? 'warning'
|
||||
@@ -482,14 +549,23 @@ 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 ? {
|
||||
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,
|
||||
@@ -497,7 +573,8 @@ export function sanitizeGatewaySubmitException(
|
||||
carrier: channel.carrier,
|
||||
sendRegion: channel.sendRegion,
|
||||
rateLimitPerSecond: channel.rateLimitPerSecond,
|
||||
} : null,
|
||||
}
|
||||
: 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,
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
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 {
|
||||
@@ -94,13 +102,15 @@ async dashboard(query: { tenantId?: string }) {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
this.prisma.$queryRaw<Array<{
|
||||
this.prisma.$queryRaw<
|
||||
Array<{
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
todaySpendCents: bigint;
|
||||
balanceCents: bigint;
|
||||
creditCents: bigint;
|
||||
}>>(Prisma.sql`
|
||||
}>
|
||||
>(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<{
|
||||
this.prisma.$queryRaw<
|
||||
Array<{
|
||||
segmentCount: bigint;
|
||||
deliveredSegmentCount: bigint;
|
||||
billedCents: bigint;
|
||||
costCents: bigint;
|
||||
}>>(Prisma.sql`
|
||||
}>
|
||||
>(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<{
|
||||
this.prisma.$queryRaw<
|
||||
Array<{
|
||||
hour: number;
|
||||
submittedCount: bigint;
|
||||
successCount: bigint;
|
||||
}>>(Prisma.sql`
|
||||
}>
|
||||
>(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<{
|
||||
this.prisma.$queryRaw<
|
||||
Array<{
|
||||
category: string;
|
||||
count: bigint;
|
||||
averageProcessingMs: bigint | null;
|
||||
}>>(Prisma.sql`
|
||||
}>
|
||||
>(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,
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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贷款', 'Ab贷款', '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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,20 +146,25 @@ 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) => {
|
||||
() =>
|
||||
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) => {
|
||||
() =>
|
||||
void this.expireInboundLongMessages().catch((error) => {
|
||||
this.logger.error(`Failed to expire inbound CMPP long messages: ${String(error)}`);
|
||||
}),
|
||||
positiveInteger(
|
||||
@@ -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({
|
||||
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 ? {
|
||||
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,
|
||||
}
|
||||
: 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({
|
||||
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: {
|
||||
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,14 +695,23 @@ 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: {
|
||||
private async evaluateRiskWithPhoneFrequency(
|
||||
input: {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
templateId?: string;
|
||||
@@ -631,7 +719,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
variables?: Record<string, unknown>;
|
||||
phoneNumber: string;
|
||||
sourceType: 'cmpp';
|
||||
}, reservationKey?: string) {
|
||||
},
|
||||
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.
|
||||
@@ -224,21 +227,25 @@ 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}`
|
||||
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;
|
||||
@@ -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({
|
||||
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;
|
||||
}
|
||||
@@ -364,8 +374,11 @@ export class SendDownstreamDeliveryService {
|
||||
take: 10,
|
||||
})
|
||||
: [];
|
||||
const accessApplicationIds = [...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value)))];
|
||||
const accessApplications = accessApplicationIds.length > 0
|
||||
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 },
|
||||
@@ -421,8 +434,7 @@ export class SendDownstreamDeliveryService {
|
||||
return {
|
||||
matchStatus: 'ambiguous',
|
||||
matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`,
|
||||
candidates: matchableRecentMessages
|
||||
.map((message) => ({
|
||||
candidates: matchableRecentMessages.map((message) => ({
|
||||
tenantId: String(message.tenantId),
|
||||
applicationId: String(message.applicationId),
|
||||
messageRecordId: message.id,
|
||||
@@ -454,19 +466,31 @@ 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 },
|
||||
data: {
|
||||
status: 'failed',
|
||||
receiptStatus: 'undelivered',
|
||||
receiptRawStatus: 'REJECTD',
|
||||
errorCode,
|
||||
errorMessage: reason,
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
const gatewayMessageId = `PLATFORM:${message.messageId}`;
|
||||
const receipt = await this.prisma.smsReceiptRecord.create({
|
||||
data: {
|
||||
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: createHash('sha256').update(`platform\u0000${gatewayMessageId}\u0000${message.phoneNumber}\u0000undelivered\u0000REJECTD\u0000${errorCode}`).digest('hex'),
|
||||
receiptKey,
|
||||
messageId: message.messageId,
|
||||
gatewayMessageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
@@ -475,13 +499,15 @@ export class SendDownstreamDeliveryService {
|
||||
errorCode,
|
||||
errorMessage: reason,
|
||||
deliveredAt,
|
||||
},
|
||||
});
|
||||
await queueFinalReceiptDeliveries(
|
||||
this.prisma,
|
||||
(request) => this.facade.queueAndTryDownstreamDelivery(request),
|
||||
{
|
||||
};
|
||||
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,
|
||||
@@ -493,9 +519,15 @@ export class SendDownstreamDeliveryService {
|
||||
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,7 +202,9 @@ export class SendGatewayResultService {
|
||||
message.batchTaskId
|
||||
) {
|
||||
const businessMessage = message as typeof message & { tenantId: string; batchTaskId: string };
|
||||
const retried = await this.facade.retryMessageIfAllowed(
|
||||
const retried = data.errorCode?.startsWith('DRN')
|
||||
? null
|
||||
: await this.facade.retryMessageIfAllowed(
|
||||
businessMessage,
|
||||
data.submitStatus === 'timeout' ? '提交超时补发' : '提交失败补发',
|
||||
submitRecord.id,
|
||||
@@ -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') {
|
||||
|
||||
@@ -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-01~12的修订版本执行: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平台回执码CSW(3字符),状态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)。未推送、未部署预生产,未保存线上通道词或发送短信。
|
||||
@@ -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分开送往不同通道,也不能拼接不同企业/签名的授权。
|
||||
|
||||
报备事实使用ChannelSignatureReportTask:signatureId一致、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字段保留,规范键冲突先报告,不能自动合并、覆盖或继承另一条资料的报备状态。
|
||||
|
||||
消息需要多目标关系与不可变判定快照。建议新增SmsMessageDrainageMatch(messageRecordId、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-01~16,全部待实现/待执行。验收需要保留原文和发送字节对比、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为准。
|
||||
|
||||
@@ -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 九项运营修复执行结果”。本地提交仅包含本轮代码及上述文档增量;测试、预生产版本未变化。
|
||||
@@ -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为准。
|
||||
|
||||
@@ -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项服务active;Gateway/security-agent未重启,7个相关Node服务按计划更新。API健康、102项迁移含`20260910160000_channel_sensitive_words`及前端资源摘要通过;PostgreSQL/Redis/MinIO服务健康。三个Gateway Stream pending/lag均0;消息119509、提交130769,前后未变。受保护文件无变化,journal警告和应用日志新增errorMarkers均0,verify 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/网关;此前网络根因和预生产历史故障未宣称解决。没有推送或预生产部署。
|
||||
@@ -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`。未手改计划、跳过门禁或调用旧发布脚本。
|
||||
|
||||
## 验证与边界
|
||||
|
||||
- 精确提交独立 validate:API 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;未借用预生产容量结论。清理工具、历史资产谱系/恢复验证和治理仍未完成,后续专项处理。
|
||||
@@ -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-01~16的实现范围以专项方案第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。
|
||||
|
||||
@@ -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-01~16 分层记录。
|
||||
|
||||
- Git:main 开工 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/费用流水与完整吞吐未执行。
|
||||
|
||||
@@ -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确认;提交成功可由业务关闭。只读公共弹窗同样采用显式关闭默认,自定义只读质量抽屉保持原交互。
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
DrainageGuardEnabled bool
|
||||
APIBaseURL string
|
||||
EventAPIBaseURL string
|
||||
HTTPClient *http.Client
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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> & {
|
||||
|
||||
@@ -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: [] }));
|
||||
|
||||
@@ -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,11 +394,13 @@ 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) => ({
|
||||
@@ -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">
|
||||
<div className="analytics-activity-filters">
|
||||
{(
|
||||
[
|
||||
['tenantName', '企业'],
|
||||
['applicationName', '企业应用'],
|
||||
['signatureName', '签名'],
|
||||
...(dimensionType === 'channel' ? [['channelName', '通道']] : []),
|
||||
] as Array<[keyof typeof filters, string]>
|
||||
).map(([key, label]) => (
|
||||
<Input
|
||||
aria-label={`${title}搜索通道、企业、企业应用或签名`}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder={dimensionType === 'channel' ? '搜索通道、企业、应用或签名' : '搜索企业、应用或签名'}
|
||||
value={keyword}
|
||||
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>
|
||||
|
||||
@@ -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 filteredItems = useMemo(
|
||||
() =>
|
||||
items.filter((item) => {
|
||||
const text = [item.word, item.level, item.status].join(' ');
|
||||
return !keyword || text.includes(keyword);
|
||||
}), [items, 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) => (
|
||||
{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();
|
||||
});
|
||||
});
|
||||
@@ -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]) => {
|
||||
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) => {
|
||||
})
|
||||
.catch((failure: Error) => {
|
||||
if (!cancelled) setError(failure.message || 'HTTP接口配置加载失败');
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
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,9 +250,10 @@ export function AdminSmsApplicationFormPage() {
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setSaveError('');
|
||||
try {
|
||||
const application = isEdit && appId
|
||||
const application =
|
||||
isEdit && appId
|
||||
? await adminApi.updateEnterpriseApplication(appId, payload)
|
||||
: await adminApi.createEnterpriseApplication({ tenantId: enterpriseId, ...payload });
|
||||
await adminApi.replaceApplicationRouteRules(application.id, {
|
||||
@@ -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>
|
||||
<button className={interfaceEnabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setInterfaceEnabled((current) => !current)} type="button">
|
||||
</div>
|
||||
<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>
|
||||
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
|
||||
<Input disabled hint="企业代码始终与 CMPP 6位账号一致;账号留空自动生成时,保存后自动生成相同企业代码。" label="企业代码" placeholder="跟随 CMPP 6位账号自动生成" value={cmppAccount} />
|
||||
</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
|
||||
hint="真实扩展码会追加到上游通道基础接入号后,例如基础号 1069999999、扩展码 0001,最终发送号为 10699999990001。留空则继续使用通道基础号。"
|
||||
label="应用扩展码"
|
||||
@@ -344,40 +437,121 @@ 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>
|
||||
{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} />
|
||||
</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}
|
||||
/>
|
||||
<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 className="admin-app-protocol-empty">CMPP 接口未开通,账号、接入号和长连接参数已收起。</div>}
|
||||
</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>
|
||||
<button className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'} onClick={() => setHttpConfig((current) => current.enabled ? { ...current, enabled: false } : {
|
||||
</div>
|
||||
<button
|
||||
className={httpConfig.enabled ? 'admin-switch is-on' : 'admin-switch'}
|
||||
onClick={() =>
|
||||
setHttpConfig((current) =>
|
||||
current.enabled
|
||||
? { ...current, enabled: false }
|
||||
: {
|
||||
...current,
|
||||
enabled: true,
|
||||
sendEnabled: true,
|
||||
@@ -388,30 +562,121 @@ export function AdminSmsApplicationFormPage() {
|
||||
credentialSelfServiceEnabled: true,
|
||||
receiptDeliveryMode: 'http',
|
||||
uplinkDeliveryMode: 'http',
|
||||
})} type="button"><span />{httpConfig.enabled ? '已开通' : '未开通'}</button>
|
||||
},
|
||||
)
|
||||
}
|
||||
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 className="admin-app-form-tip">
|
||||
<Info size={17} />
|
||||
<span>访问密钥由客户端“接口对接”页面按权限创建;HTTP 白名单与 CMPP 白名单完全独立。</span>
|
||||
</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>}
|
||||
<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>
|
||||
)}
|
||||
{httpConfig.enabled ? (
|
||||
<div className="admin-app-form-grid admin-app-protocol-body">
|
||||
<Input
|
||||
hint="留空不推送;HTTP接口开通后按该地址推送状态回执。"
|
||||
@@ -428,9 +693,15 @@ export function AdminSmsApplicationFormPage() {
|
||||
value={uplinkWebhookUrl}
|
||||
/>
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<div className="admin-app-form-tip"><Info size={17} /><span>投递通道由接口开通状态自动决定:CMPP开通则走CMPP,HTTP开通且地址非空则走HTTP,两者都开通时双投;运营端无需另选投递方式。</span></div>
|
||||
<div className="admin-app-form-tip">
|
||||
<Info size={17} />
|
||||
<span>
|
||||
投递通道由接口开通状态自动决定:CMPP开通则走CMPP,HTTP开通且地址非空则走HTTP,两者都开通时双投;运营端无需另选投递方式。
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
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((e) => setError(e.message)),
|
||||
[page, state],
|
||||
);
|
||||
} 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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
if (!disks.length)
|
||||
return (
|
||||
<article className="surface system-monitoring-metric">
|
||||
<HardDrive size={19} />
|
||||
<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}
|
||||
<span>磁盘</span>
|
||||
<strong>暂无数据</strong>
|
||||
</div>
|
||||
</article>;
|
||||
})}</>;
|
||||
</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'
|
||||
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)));
|
||||
: { 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,13 +188,18 @@ 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' } },
|
||||
},
|
||||
@@ -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 [
|
||||
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: '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: '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>,
|
||||
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,7 +292,8 @@ export function AdminSystemMonitoringPage() {
|
||||
const requestSequence = useRef(0);
|
||||
const pendingRequests = useRef(0);
|
||||
|
||||
const loadData = useCallback(async (supersede = false) => {
|
||||
const loadData = useCallback(
|
||||
async (supersede = false) => {
|
||||
if (!supersede && pendingRequests.current > 0) return;
|
||||
pendingRequests.current += 1;
|
||||
const sequence = ++requestSequence.current;
|
||||
@@ -226,7 +311,9 @@ export function AdminSystemMonitoringPage() {
|
||||
if (sequence === requestSequence.current) setLoading(false);
|
||||
pendingRequests.current -= 1;
|
||||
}
|
||||
}, [range]);
|
||||
},
|
||||
[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 ? {
|
||||
setOverview((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
alerts: current.alerts.map((item) => item.fingerprint === result.fingerprint && Date.parse(item.startedAt) === Date.parse(result.activeAt)
|
||||
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);
|
||||
: 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) => ({
|
||||
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: [
|
||||
}),
|
||||
[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]);
|
||||
}),
|
||||
[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">
|
||||
默认近7天,最多31天;读取 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user