feat: 实现发送质量监控与报备状态消息通知
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-06 19:22:49 +08:00
parent 69e3d7368d
commit 457319e627
66 changed files with 6992 additions and 489 deletions
+30 -1
View File
@@ -129,7 +129,7 @@ PROD_ADMIN_CREDENTIAL_FILE="$ADMIN_CREDENTIAL_FILE" node tools/deploy/ensure-pro
chmod 600 "$ADMIN_CREDENTIAL_FILE" || true
echo "[deploy] Ensuring runtime log directories"
install -d -m 0755 "$APP_DIR/logs/api" "$APP_DIR/logs/send-worker" "$APP_DIR/logs/report-material-worker" "$APP_DIR/logs/submit-outbox" "$APP_DIR/logs/gateway-callback" "$APP_DIR/logs/protocol-log-worker" "$APP_DIR/logs/gateway"
install -d -m 0755 "$APP_DIR/logs/api" "$APP_DIR/logs/send-worker" "$APP_DIR/logs/report-material-worker" "$APP_DIR/logs/sending-monitor" "$APP_DIR/logs/submit-outbox" "$APP_DIR/logs/gateway-callback" "$APP_DIR/logs/protocol-log-worker" "$APP_DIR/logs/gateway"
echo "[deploy] Installing split API and send-worker services"
node_bin="$(command -v node)"
@@ -175,6 +175,31 @@ RestartSec=5
StandardOutput=append:$APP_DIR/logs/report-material-worker/stdout.log
StandardError=append:$APP_DIR/logs/report-material-worker/stderr.log
[Install]
WantedBy=multi-user.target
EOF
cat >/etc/systemd/system/cmpp-sending-monitor.service <<EOF
[Unit]
Description=CMPP sending quality projection and evaluation worker
After=network.target postgresql.service
[Service]
User=cmpp-api
Group=cmpp-security
WorkingDirectory=$APP_DIR/api
EnvironmentFile=$ENV_FILE
Environment=TZ=UTC
NoNewPrivileges=true
ProtectHome=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=$APP_DIR/logs/sending-monitor
ExecStart=$node_bin dist/sending-monitor-worker.js
Restart=always
RestartSec=5
StandardOutput=append:$APP_DIR/logs/sending-monitor/stdout.log
StandardError=append:$APP_DIR/logs/sending-monitor/stderr.log
[Install]
WantedBy=multi-user.target
EOF
@@ -241,6 +266,7 @@ EOF
echo "[deploy] Installing restricted security boundary"
bash "$APP_DIR/tools/security/install-security-agent.sh"
install -d -o cmpp-api -g cmpp-security -m 0750 "$APP_DIR/logs/sending-monitor"
echo "[deploy] Ensuring HTTP response compression"
compression_config=/etc/nginx/conf.d/cmpp-compression.conf
@@ -294,6 +320,8 @@ systemctl restart cmpp-gateway
systemctl restart cmpp-api
systemctl restart cmpp-send-worker
systemctl restart cmpp-report-material-worker
systemctl enable --now cmpp-sending-monitor
systemctl restart cmpp-sending-monitor
systemctl restart nginx
echo "[deploy] Health checks"
@@ -314,6 +342,7 @@ wait_for_http() {
wait_for_http "API" "http://127.0.0.1:${API_PORT:-3000}/api/health"
wait_for_http "Send worker metrics" "http://127.0.0.1:${API_WORKER_METRICS_PORT:-9465}/metrics"
systemctl is-active --quiet cmpp-report-material-worker
systemctl is-active --quiet cmpp-sending-monitor
if [[ "${SEND_SUBMIT_OUTBOX_SEPARATE_PROCESS_ENABLED:-false}" == "true" ]]; then
wait_for_http "Submit Outbox metrics" "http://127.0.0.1:${API_OUTBOX_METRICS_PORT:-9467}/metrics"
fi
+12
View File
@@ -257,6 +257,18 @@
"owners": ["src/apps/admin/channel-groups/RouteConfigModal.tsx"],
"stylelintLegacy": false,
"roots": ["channel-route-editor"]
},
{
"file": "src/apps/report-notifications/report-notifications.css",
"owners": ["src/apps/report-notifications/ReportNotificationsPage.tsx"],
"stylelintLegacy": false,
"roots": ["report-notifications-page"]
},
{
"file": "src/apps/admin/AdminMonitorPage.css",
"owners": ["src/apps/admin/AdminMonitorPage.tsx"],
"stylelintLegacy": false,
"roots": ["sending-monitor"]
}
]
}
+252
View File
@@ -0,0 +1,252 @@
import pg from '../../api/node_modules/pg/lib/index.js';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import projection from '../../api/dist/sending-monitor/monitor-projection.js';
import evaluation from '../../api/dist/sending-monitor/monitor-evaluation.js';
const { Client } = pg;
const { projectMessages, keyOf } = projection;
const { evaluateWindow } = evaluation;
process.env.TZ = 'UTC';
async function main() {
const connectionString = process.env.QA_DATABASE_URL || process.env.DATABASE_URL;
if (!connectionString) throw new Error('QA_DATABASE_URL is required');
const db = new Client({ connectionString, application_name: 'cmpp-monitor-isolated-qa' });
const schema = `qa_monitor_${randomUUID().replaceAll('-', '')}`;
if (!/^qa_monitor_[a-f0-9]{32}$/.test(schema)) throw new Error('Invalid isolated schema');
let checks = 0;
await db.connect();
try {
await db.query('BEGIN');
await db.query(`CREATE SCHEMA "${schema}"`);
await db.query(`SET LOCAL search_path TO "${schema}",public`);
await db.query(`SET LOCAL timezone='Asia/Shanghai'`);
for (const table of [
'Tenant',
'SmsApplication',
'SmsSignature',
'SmsDrainageInfo',
'SmsChannel',
'ChannelSignatureReportTask',
'SmsMessageRecord',
'SmsSubmitRecord',
'SmsMessageSegmentAudit',
'UpstreamReceiptInbox',
]) {
await db.query(`CREATE TABLE "${table}" (LIKE public."${table}" INCLUDING ALL)`);
if (['Tenant', 'SmsApplication', 'SmsSignature', 'SmsDrainageInfo', 'SmsChannel'].includes(table))
await db.query(`INSERT INTO "${table}" SELECT * FROM public."${table}"`);
}
for (const table of ['SmsSubmitRecord', 'SmsMessageSegmentAudit'])
await db.query(
`ALTER TABLE "${table}" DROP COLUMN IF EXISTS "firstWireSubmitAt", DROP COLUMN IF EXISTS "wireTimeSource", DROP COLUMN IF EXISTS "receiptRequested"`,
);
await db.query(`ALTER TABLE "UpstreamReceiptInbox" DROP COLUMN IF EXISTS "gatewayReceivedAt"`);
// LIKE copies indexes under generated names; explicit migration index names are unique within the new schema.
for (const path of ['20260906170000_report_readiness_notifications', '20260906171000_sending_monitor'])
await db.query(
readFileSync(resolve(import.meta.dirname, '../../api/prisma/migrations', path, 'migration.sql'), 'utf8'),
);
const sig = (
await db.query(
`SELECT id,"tenantId","applicationId" FROM "SmsSignature" WHERE "applicationId" IS NOT NULL LIMIT 1`,
)
).rows[0];
const channel = (await db.query(`SELECT id FROM "SmsChannel" LIMIT 1`)).rows[0].id;
assert(sig && channel, 'Need existing metadata to copy into isolated fixtures');
await db.query(
`UPDATE "SmsChannel" SET status='active',carrier='all',carriers=ARRAY['mobile','unicom','telecom'],"sendRegion"='全国' WHERE id=$1`,
[channel],
);
for (const carrier of ['mobile', 'unicom', 'telecom']) {
await db.query(
`INSERT INTO "ChannelSignatureReportTask" (id,"tenantId","signatureId","channelId",carrier,"approvalScope",status,"updatedAt") VALUES($1,$2,$3,$4,$5,'carrier_specific','approved',CURRENT_TIMESTAMP)`,
[`qa-${carrier}`, sig.tenantId, sig.id, channel, carrier],
);
assert.equal(
Number((await db.query(`SELECT count(*) n FROM "ReportReadinessEvent"`)).rows[0].n),
carrier === 'telecom' ? 1 : 0,
);
checks++;
}
await db.query(`UPDATE "ChannelSignatureReportTask" SET status='approved'`);
assert.equal(Number((await db.query(`SELECT count(*) n FROM "ReportReadinessEvent"`)).rows[0].n), 1);
checks++;
await db.query(`UPDATE "ChannelSignatureReportTask" SET status='failed'`);
await db.query(`UPDATE "ChannelSignatureReportTask" SET status='approved'`);
assert.equal(Number((await db.query(`SELECT revision FROM "ReportNotificationHour"`)).rows[0].revision), 2);
checks++;
await db.query(`UPDATE "ChannelSignatureReportTask" SET status='failed' WHERE carrier='mobile'`);
await db.query(`UPDATE "ChannelSignatureReportTask" SET status='approved' WHERE carrier='mobile'`);
assert.equal(Number((await db.query(`SELECT count(*) n FROM "ReportReadinessEvent"`)).rows[0].n), 2);
checks++;
await db.query(`UPDATE "SmsChannel" SET "sendRegion"='广东' WHERE id=$1`, [channel]);
assert.equal((await db.query(`SELECT cmpp_report_ready_mask('signature',$1,NULL) mask`, [sig.id])).rows[0].mask, 0);
checks++;
await db.query(`UPDATE "SmsChannel" SET "sendRegion"='全国' WHERE id=$1`, [channel]);
const t = new Date(Math.floor(Date.now() / 300000) * 300000);
const earlier = new Date(t.getTime() - 60000),
late = new Date(t.getTime() - 1000);
await db.query(
`INSERT INTO "SendingMonitorTarget" ("channelId",enabled,version,"effectiveFrom","updatedBy") VALUES($1,true,1,$2,'qa')`,
[channel, new Date(t.getTime() - 3600000)],
);
await db.query(
`INSERT INTO "SendingMonitorTargetVersion" SELECT "channelId",version,enabled,"effectiveFrom","updatedBy" FROM "SendingMonitorTarget"`,
);
const config = { enabled: true, minSamples: 1, thresholds: [90, 95, 98], consecutiveBad: 1, consecutiveGood: 2 };
await db.query(
`INSERT INTO "SendingMonitorRuleVersion" ("ruleId",version,type,scope,config,"effectiveAt","createdBy") VALUES('qa-rule',1,'industry','{}',$1::jsonb,$2,'qa')`,
[JSON.stringify(config), new Date(t.getTime() - 3600000)],
);
// These are isolated, never-enqueued records, explicitly synthetic, stored and queried by actual PostgreSQL.
for (let n = 0; n < 4; n++) {
const id = `qa-message-${n}`,
submitId = `qa-submit-${n}`,
at = n === 3 ? late : earlier;
await db.query(
`INSERT INTO "SmsMessageRecord" (id,"tenantId","applicationId","signatureId","messageId","phoneNumber",carrier,content,"cmppRegisteredDelivery","updatedAt") VALUES($1,$2,$3,$4,$1,'13800000000','mobile','【隔离验收】验证码',true,CURRENT_TIMESTAMP)`,
[id, sig.tenantId, sig.applicationId, sig.id],
);
await db.query(
`INSERT INTO "SmsSubmitRecord" (id,"messageRecordId","channelId","submitId","firstWireSubmitAt","wireTimeSource","receiptRequested","createdAt","updatedAt") VALUES($1,$2,$3,$1,$4,'gateway_write_complete',true,$4,CURRENT_TIMESTAMP)`,
[submitId, id, channel, at],
);
await db.query(
`INSERT INTO "SmsMessageSegmentAudit" (id,"messageRecordId","submitRecordId","channelId","submitId","gatewayMessageId","segmentTotal","segmentIndex","firstWireSubmitAt","wireTimeSource","updatedAt") VALUES($1,$2,$3,$4,$3,$1,1,1,$5,'gateway_write_complete',CURRENT_TIMESTAMP)`,
[`qa-gateway-${n}`, id, submitId, channel, at],
);
const receiptAt = new Date(at.getTime() + [4999, 5000, 5001, 50000][n]);
await db.query(
`INSERT INTO "UpstreamReceiptInbox" (id,"receiptKey","incomingChannelId","upstreamAccount","upstreamHost","upstreamPort",protocol,"protocolVersion","gatewayMessageId","receiptStatus","rawStatus","deliveredAt","gatewayReceivedAt",status,"matchedMessageRecordId","matchedChannelId","updatedAt") VALUES($1,$1,$2,'qa','127.0.0.1',7890,'cmpp','3.0',$3,'delivered','DELIVRD',$4,$4,'matched',$5,$2,CURRENT_TIMESTAMP)`,
[`qa-receipt-${n}`, channel, `qa-gateway-${n}`, receiptAt, id],
);
}
const ids = [0, 1, 2, 3].map((n) => `qa-message-${n}`);
await projectMessages(db, ids);
const facts = (await db.query(`SELECT count(*)::int count FROM "SendingMonitorFact"`)).rows[0].count;
assert.equal(facts, 8);
checks++;
await projectMessages(db, ids);
assert.equal((await db.query(`SELECT count(*)::int count FROM "SendingMonitorFact"`)).rows[0].count, facts);
checks++;
await evaluateWindow(db, 'industry', t, false, true);
const key = keyOf([channel, 'mobile']);
let row = (await db.query(`SELECT * FROM "SendingMonitorSnapshot" WHERE "dimensionKey"=$1`, [key])).rows[0];
assert.equal(row.metrics.total, 4);
assert.deepEqual(
row.metrics.metrics.map((m) => [m.success, m.mature, m.observing]),
[
[2, 3, 1],
[3, 3, 1],
[3, 3, 1],
],
);
checks++;
await evaluateWindow(db, 'industry', t, true, true);
row = (await db.query(`SELECT * FROM "SendingMonitorSnapshot" WHERE "dimensionKey"=$1`, [key])).rows[0];
assert.equal(row.revision, 2);
assert.deepEqual(
row.metrics.metrics.map((m) => [m.success, m.mature, m.observing]),
[
[2, 4, 0],
[3, 4, 0],
[4, 4, 0],
],
);
checks++;
assert.equal(
(await db.query(`SELECT count(*)::int n FROM "SendingMonitorAlert" WHERE state='active'`)).rows[0].n,
1,
);
checks++;
await evaluateWindow(db, 'industry', t, true, true);
assert.equal(
(await db.query(`SELECT revision FROM "SendingMonitorSnapshot" WHERE "dimensionKey"=$1`, [key])).rows[0].revision,
2,
);
checks++;
await evaluateWindow(db, 'verification', t, true, true);
const business = (await db.query(`SELECT metrics,status FROM "SendingMonitorSnapshot" WHERE type='verification'`))
.rows[0];
assert.equal(business.metrics.total, 4);
assert.equal(business.status, 'unconfigured');
checks++;
// Removing enrollment in a later period cannot rewrite a historical window.
await db.query(`INSERT INTO "SendingMonitorTargetVersion" VALUES($1,2,false,$2,'qa')`, [
channel,
new Date(t.getTime() + 300000),
]);
await evaluateWindow(db, 'industry', t, true, true);
assert.equal(
(await db.query(`SELECT metrics FROM "SendingMonitorSnapshot" WHERE "dimensionKey"=$1`, [key])).rows[0].metrics
.total,
4,
);
checks++;
await db.query(`UPDATE "SmsSubmitRecord" SET "receiptRequested"=false WHERE id='qa-submit-0'`);
await projectMessages(db, ['qa-message-0']);
assert.equal(
(await db.query(`SELECT reason FROM "SendingMonitorFact" WHERE id='attempt:qa-submit-0'`)).rows[0].reason,
'receipt_not_requested',
);
checks++;
await db.query(`UPDATE "SmsSubmitRecord" SET "receiptRequested"=true WHERE id='qa-submit-0'`);
await db.query(`UPDATE "UpstreamReceiptInbox" SET "gatewayReceivedAt"=NULL WHERE id='qa-receipt-0'`);
await projectMessages(db, ['qa-message-0']);
assert.equal(
(await db.query(`SELECT reason FROM "SendingMonitorFact" WHERE id='attempt:qa-submit-0'`)).rows[0].reason,
'missing_receipt_time',
);
checks++;
const created = (await db.query(`SELECT min("createdAt") at FROM "ReportReadinessEvent"`)).rows[0].at;
assert(
Math.abs(created.getTime() - Date.now()) < 60000,
'Notification timestamps must remain UTC under Asia/Shanghai session',
);
checks++;
const drainage = (
await db.query(
`SELECT id,"signatureId","tenantId" FROM "SmsDrainageInfo" WHERE "signatureId" IS NOT NULL LIMIT 1`,
)
).rows[0];
if (drainage) {
const before = (
await db.query(`SELECT count(*)::int n FROM "ReportReadinessEvent" WHERE "reportType"='drainage'`)
).rows[0].n;
await db.query(
`INSERT INTO "ChannelSignatureReportTask" (id,"tenantId","signatureId","drainageItemId","reportType","channelId",carrier,"approvalScope",status,"updatedAt") VALUES($1,$2,$3,$4,'drainage',$5,NULL,'legacy_channel','approved',CURRENT_TIMESTAMP)`,
['qa-drainage', drainage.tenantId, drainage.signatureId, drainage.id, channel],
);
assert.equal(
(await db.query(`SELECT count(*)::int n FROM "ReportReadinessEvent" WHERE "reportType"='drainage'`)).rows[0].n,
before + 1,
);
checks++;
}
await db.query('ROLLBACK');
const leftover = (await db.query(`SELECT count(*)::int n FROM pg_namespace WHERE nspname=$1`, [schema])).rows[0].n;
assert.equal(leftover, 0);
checks++;
console.log(
JSON.stringify({
passed: checks,
database: 'real PostgreSQL',
isolation: 'transaction schema rolled back',
smsSent: 0,
}),
);
} catch (error) {
await db.query('ROLLBACK');
throw error;
} finally {
await db.end();
}
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});