fix: 修复监控纳管保存并移除运行概况最近记录
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-06 20:22:48 +08:00
parent f059674852
commit 4c210723cd
9 changed files with 194 additions and 64 deletions
@@ -0,0 +1,17 @@
import type { PrismaService } from '../../prisma/prisma.service';
import { OperationsUplinkQueries } from './uplink.queries';
describe('runtime monitor summary', () => {
it('keeps tenant/channel status counts without reading recent business records', async () => {
const byStatus = [{ status: 'delivered', _count: { _all: 3 } }];
const groupBy = jest.fn().mockResolvedValue(byStatus);
// No detail delegates: accessing any removed query fails this test.
const queries = new OperationsUplinkQueries({ smsMessageRecord: { groupBy } } as unknown as PrismaService);
expect(await queries.monitor({ tenantId: 'tenant-a', channelId: 'channel-a' })).toEqual({ byStatus });
expect(groupBy).toHaveBeenCalledWith({
by: ['status'],
where: expect.objectContaining({ tenantId: 'tenant-a', channelId: 'channel-a' }),
_count: { _all: true },
});
});
});
+52 -34
View File
@@ -1,16 +1,22 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { moneyToNumber } from '../../common/money';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts'; import { messageWhere, clientUplinkView } from '../operations.helpers';
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';
// R2 uplink query domain. Method bodies are preserved byte-for-byte from the facade baseline. // Uplink record queries and the status-only runtime summary.
export class OperationsUplinkQueries { export class OperationsUplinkQueries {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) { listUplinkMessages(query: {
tenantId?: string;
channelId?: string;
applicationId?: string;
phoneNumber?: string;
keyword?: string;
startTime?: string;
endTime?: string;
page?: number;
pageSize?: number;
}) {
return this.prisma.smsUplinkMessage.findMany({ return this.prisma.smsUplinkMessage.findMany({
where: { where: {
tenantId: query.tenantId, tenantId: query.tenantId,
@@ -18,7 +24,13 @@ listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId
applicationId: query.applicationId, applicationId: query.applicationId,
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined, phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
content: query.keyword ? { contains: query.keyword } : undefined, content: query.keyword ? { contains: query.keyword } : undefined,
receivedAt: query.startTime || query.endTime ? { gte: query.startTime ? new Date(query.startTime) : undefined, lte: query.endTime ? new Date(query.endTime) : undefined } : undefined, receivedAt:
query.startTime || query.endTime
? {
gte: query.startTime ? new Date(query.startTime) : undefined,
lte: query.endTime ? new Date(query.endTime) : undefined,
}
: undefined,
}, },
include: { include: {
tenant: true, tenant: true,
@@ -39,11 +51,33 @@ listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId
take: query.pageSize ?? 500, take: query.pageSize ?? 500,
}); });
} }
async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) { async listClientUplinkMessages(query: {
tenantId?: string;
applicationId?: string;
phoneNumber?: string;
keyword?: string;
startTime?: string;
endTime?: string;
page?: number;
pageSize?: number;
}) {
const items = await this.listUplinkMessages(query); const items = await this.listUplinkMessages(query);
return items.map(clientUplinkView); return items.map(clientUplinkView);
} }
async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }, clientView = false) { async listUplinkMessagesPage(
query: {
tenantId?: string;
channelId?: string;
applicationId?: string;
phoneNumber?: string;
keyword?: string;
startTime?: string;
endTime?: string;
page?: number;
pageSize?: number;
},
clientView = false,
) {
const page = Math.max(1, Math.floor(Number(query.page) || 1)); const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
const where: Prisma.SmsUplinkMessageWhereInput = { const where: Prisma.SmsUplinkMessageWhereInput = {
@@ -52,10 +86,13 @@ async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; app
applicationId: query.applicationId, applicationId: query.applicationId,
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined, phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
content: query.keyword ? { contains: query.keyword } : undefined, content: query.keyword ? { contains: query.keyword } : undefined,
receivedAt: query.startTime || query.endTime ? { receivedAt:
query.startTime || query.endTime
? {
gte: query.startTime ? new Date(query.startTime) : undefined, gte: query.startTime ? new Date(query.startTime) : undefined,
lte: query.endTime ? new Date(query.endTime) : undefined, lte: query.endTime ? new Date(query.endTime) : undefined,
} : undefined, }
: undefined,
}; };
const [rawItems, total] = await Promise.all([ const [rawItems, total] = await Promise.all([
this.listUplinkMessages({ ...query, page, pageSize }), this.listUplinkMessages({ ...query, page, pageSize }),
@@ -68,28 +105,9 @@ async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; app
pageSize, pageSize,
}; };
} }
async monitor(query: { tenantId?: string; channelId?: string }) { async monitor(query: { tenantId?: string; channelId?: string }) {
const where = messageWhere(query); const where = messageWhere(query);
const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([ const byStatus = await this.prisma.smsMessageRecord.groupBy({ by: ['status'], where, _count: { _all: true } });
this.prisma.smsMessageRecord.groupBy({ by: ['status'], where, _count: { _all: true } }), return { byStatus };
this.prisma.smsMessageRecord.findMany({
where,
include: { submitRecords: true, receiptRecords: true },
orderBy: { queuedAt: 'desc' },
take: 20,
}),
this.prisma.smsReceiptRecord.findMany({
where: { tenantId: query.tenantId, channelId: query.channelId },
orderBy: { createdAt: 'desc' },
take: 20,
}),
this.listUplinkMessages({ tenantId: query.tenantId, channelId: query.channelId }),
]);
return {
byStatus,
recentMessages,
recentReceipts,
recentUplinks: recentUplinks.slice(0, 20),
};
} }
} }
@@ -125,7 +125,7 @@ export class SendingMonitorService {
select: { id: true }, select: { id: true },
}); });
if (!channel) throw new NotFoundException('通道不存在'); if (!channel) throw new NotFoundException('通道不存在');
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`monitor-target:${id}`},0))`; await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`monitor-target:${id}`},0))`;
const old = await tx.$queryRawUnsafe<Array<{ version: number; enabled: boolean }>>( const old = await tx.$queryRawUnsafe<Array<{ version: number; enabled: boolean }>>(
`SELECT * FROM "SendingMonitorTarget" WHERE "channelId"=$1`, `SELECT * FROM "SendingMonitorTarget" WHERE "channelId"=$1`,
id, id,
@@ -239,7 +239,7 @@ export class SendingMonitorService {
const scopeKey = JSON.stringify(scope), const scopeKey = JSON.stringify(scope),
type = monitorType(body.type); type = monitorType(body.type);
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`monitor-rule:${type}:${scopeKey}`},0))`; await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`monitor-rule:${type}:${scopeKey}`},0))`;
const old = await tx.$queryRawUnsafe<Array<{ id: string; version: number }>>( const old = await tx.$queryRawUnsafe<Array<{ id: string; version: number }>>(
`SELECT * FROM "SendingMonitorRule" WHERE type=$1 AND "scopeKey"=$2`, `SELECT * FROM "SendingMonitorRule" WHERE type=$1 AND "scopeKey"=$2`,
type, type,
@@ -448,6 +448,7 @@
- 展示总发送量、成功率、通道健康度、待处理审核数。 - 展示总发送量、成功率、通道健康度、待处理审核数。
- 发送监控展示通道状态、发送趋势、失败率、积压队列。 - 发送监控展示通道状态、发送趋势、失败率、积压队列。
- 2026-09-06调整:运行概况不再展示最近短信、最近状态报告、最近上行,聚合接口不再读取或返回这三组明细;独立记录页面保持可用。行业通道纳管与规则保存应真实持久化、记录版本和审计,冲突返回409,不能因事务锁返回类型导致500。
- 数据统计支持按企业、应用、通道、日期统计。 - 数据统计支持按企业、应用、通道、日期统计。
- 运营概览一级菜单下只保留运营看板、发送监控、数据统计;客户管理独立作为一级业务域展示,避免重复菜单。 - 运营概览一级菜单下只保留运营看板、发送监控、数据统计;客户管理独立作为一级业务域展示,避免重复菜单。
- 右上角消息铃铛展示所有待审核任务总数,并按企业认证、短信审核、短信模板审核、签名审核等分类展示;点击分类跳转到对应审核页面。 - 右上角消息铃铛展示所有待审核任务总数,并按企业认证、短信审核、短信模板审核、签名审核等分类展示;点击分类跳转到对应审核页面。
@@ -46,7 +46,7 @@
| [upstream/deliver.go](../gateway/internal/upstream/deliver.go) | 回执事件DeliveredAt赋值time.Now().UTC() | 这是Gateway收到回执的时间,不是供应商DoneTime;不能将字段名直接理解为终端时间 | | [upstream/deliver.go](../gateway/internal/upstream/deliver.go) | 回执事件DeliveredAt赋值time.Now().UTC() | 这是Gateway收到回执的时间,不是供应商DoneTime;不能将字段名直接理解为终端时间 |
| [send-receipt.service.ts](../api/src/send-chain/send-receipt.service.ts) | 入站回执持久化到Inbox,再匹配处理;重复回执有receiptKey | 复用现有持久化与匹配结果,区分Gateway接收时间和API处理时间,避免处理积压扭曲5秒指标 | | [send-receipt.service.ts](../api/src/send-chain/send-receipt.service.ts) | 入站回执持久化到Inbox,再匹配处理;重复回执有receiptKey | 复用现有持久化与匹配结果,区分Gateway接收时间和API处理时间,避免处理积压扭曲5秒指标 |
旧需求[5.10运营看板与监控](first-version-development-requirements.md)及[TC-ADMIN-011](system-functional-test-cases.md)还要求发送趋势、最近发送/回执/上行、通道状态和积压。本次以三类质量监控为主视图,保留“运行概况”和详情跳转入口承接这些功能;不因当前页面未展示某旧需求就擅自删除它。统计逻辑不复用运营看板的分片到达率。 旧需求[5.10运营看板与监控](first-version-development-requirements.md)及[TC-ADMIN-011](system-functional-test-cases.md)由三类质量监控及“运行概况”承接。按2026-09-06用户追加要求,运行概况删除最近发送/回执/上行三组列表,并删除其后端查询与响应字段;保留状态统计、通道列表及基础设施跳转。独立短信记录、状态报告和上行记录页面/API保持原有职责。统计逻辑不复用运营看板的分片到达率。
## 4. 统一统计口径 ## 4. 统一统计口径
@@ -158,7 +158,7 @@ R_h = S_h / N_h × 100% # N_h=0时为null
- 摘要卡:监控维度数、异常维度数、样本不足维度数、窗口提交量(行业用“提交尝试”,其他用“业务短信”)。无数据时显示—/0并区别未配置、无发送、计算中与接口失败。 - 摘要卡:监控维度数、异常维度数、样本不足维度数、窗口提交量(行业用“提交尝试”,其他用“业务短信”)。无数据时显示—/0并区别未配置、无发送、计算中与接口失败。
- 表格以异常优先:维度、窗口提交量、三个到达率、实际阈值/规则来源、状态、趋势/详情。各指标格包含百分比、成功/成熟数;低于阈值的格红色强调,观察中/样本不足中性显示,不只依赖颜色。 - 表格以异常优先:维度、窗口提交量、三个到达率、实际阈值/规则来源、状态、趋势/详情。各指标格包含百分比、成功/成熟数;低于阈值的格红色强调,观察中/样本不足中性显示,不只依赖颜色。
- 用户能筛选全部/异常/正常/样本不足/数据延迟;应用/签名支持带企业名的远程搜索、分页。筛选保存在URL,首次进入、刷新和告警深链接能还原。 - 用户能筛选全部/异常/正常/样本不足/数据延迟;应用/签名支持带企业名的远程搜索、分页。筛选保存在URL,首次进入、刷新和告警深链接能还原。
- 保留“运行概况”入口显示连接积压最近消息/回执/上行,不把业务启用标记等同实际连接健康。详情链接保持权限过滤。 - 保留“运行概况”入口显示状态统计及通道信息,通过基础设施入口查看连接积压;删除最近消息/回执/上行列表及对应明细查询,不把业务启用标记等同实际连接健康。
### 7.2 趋势与告警详情 ### 7.2 趋势与告警详情
+7 -1
View File
@@ -549,7 +549,7 @@
- 预期结果: - 预期结果:
- 看板展示任务数、发送状态分布、上行数、账务聚合,不重复展示通道连接或通道运行数据。 - 看板展示任务数、发送状态分布、上行数、账务聚合,不重复展示通道连接或通道运行数据。
- 看板按当天真实短信记录展示“不含引流/含引流”两组签名统计;每行包含签名、企业、发送总数、成功、未知、失败、成功率和平均到达时长。 - 看板按当天真实短信记录展示“不含引流/含引流”两组签名统计;每行包含签名、企业、发送总数、成功、未知、失败、成功率和平均到达时长。
- 监控展示最近发送、最近回执、最近上行 - 发送监控运行概况保留统计和通道列表,删除最近发送、最近回执、最近上行;聚合API仅返回byStatus,不读取三组明细(2026-09-06用户调整)
- 过滤条件生效。 - 过滤条件生效。
### TC-ADMIN-011A 通道今日质量与日期通道占比 ### TC-ADMIN-011A 通道今日质量与日期通道占比
@@ -5172,3 +5172,9 @@ npm run verify:phase8
- SMR-024:测试环境三尺寸真实规则空态、默认禁用通过;受控请求失败后草稿保留、取消不写入通过。三个监控类型、告警、运行概况共15项页面检查通过;没有真实近期发送样本,不标记高峰或供应商链路验收完成。 - SMR-024:测试环境三尺寸真实规则空态、默认禁用通过;受控请求失败后草稿保留、取消不写入通过。三个监控类型、告警、运行概况共15项页面检查通过;没有真实近期发送样本,不标记高峰或供应商链路验收完成。
- RRN-001004、008:全新不可路由QA对象触发真实数据库事件、同小时2→3→4版本、双端独立已读及重新未读、三尺寸详情、顶栏跳转通过。RRN-005真实跨租户404、伪造头403、匿名401、非法page400通过。其余边界以真实PG隔离事务和单测证据逐项说明,不笼统称全套端到端通过。 - RRN-001004、008:全新不可路由QA对象触发真实数据库事件、同小时2→3→4版本、双端独立已读及重新未读、三尺寸详情、顶栏跳转通过。RRN-005真实跨租户404、伪造头403、匿名401、非法page400通过。其余边界以真实PG隔离事务和单测证据逐项说明,不笼统称全套端到端通过。
- RRN-009:关闭期间迟到结果的新增组件回归通过,修复后真实双端6项交互通过。临时数据/客户端会话已精确清理,持久管理员保留。完整数字、版本、证据路径和未执行项见testing-progress.md最终验收节。 - RRN-009:关闭期间迟到结果的新增组件回归通过,修复后真实双端6项交互通过。临时数据/客户端会话已精确清理,持久管理员保留。完整数字、版本、证据路径和未执行项见testing-progress.md最终验收节。
### SMR-027029 运行概况与纳管保存回归(2026-09-06)
- SMR-027:运行概况三尺寸首次进入、刷新和跨tab切换,三个最近列表均不存在;真实GET /admin/operations/monitor仅含byStatus,状态卡片/通道表/基础设施入口可用,独立记录页面保留。
- SMR-028:真实Prisma加入、移除、重新加入及规则保存,版本和审计持久化;同版本并发仅一次成功,另一次409;不存在通道404,非法enabled/version400;审计失败回滚当前行和版本行。真实PG隔离schema验证,不用mock掩盖void返回类型。
- SMR-029:测试环境人工生成并标注“测试演示”的三类监控已恢复/已关闭告警,列表、筛选、详情和历史曲线来自真实API/PG;演示阈值仅存在历史快照,不写业务规则或发送短信,不能用该数据证明自动告警链路或真实投递成功。
+8
View File
@@ -4638,3 +4638,11 @@ git diff --check
- 19:57:46最终8项应用服务均active/Result=success/NRestarts=0API/Gateway健康。监控检查点每5秒更新,complete=true、poolMax=2、pendingReceipts=0stderr为0字节,API/监控服务发布窗口warning以上journal为空。三个Stream pending/lag均0;命令last-delivered=1787806802603-0/entries-read=130918,结果1787806917609-0/191696,协议日志1787806802967-0/40472。Gateway desired6/connected0保持发布前基线,未擅自调整供应商连接配置。 - 19:57:46最终8项应用服务均active/Result=success/NRestarts=0API/Gateway健康。监控检查点每5秒更新,complete=true、poolMax=2、pendingReceipts=0stderr为0字节,API/监控服务发布窗口warning以上journal为空。三个Stream pending/lag均0;命令last-delivered=1787806802603-0/entries-read=130918,结果1787806917609-0/191696,协议日志1787806802967-0/40472。Gateway desired6/connected0保持发布前基线,未擅自调整供应商连接配置。
- 线上主JS index-CcT-At79.js SHA-256=3efb6da4e5ec852c86930c0a09089d34940170f7e9cf9271ec2c1961e4a8068d;主CSS index-Oaoh-Ppy.css SHA-256=c42f481be579764585c3e993ed91d2ea4b3f3c297ccbfd866b0a3358a34d8a0ae;真实HTTP下载与已构建运行文件一致。自动检查最终前端20套100项、API59套638项、真实PG隔离事务20项、Go全量/vet、类型/构建/格式/样式/CSS治理等通过。 - 线上主JS index-CcT-At79.js SHA-256=3efb6da4e5ec852c86930c0a09089d34940170f7e9cf9271ec2c1961e4a8068d;主CSS index-Oaoh-Ppy.css SHA-256=c42f481be579764585c3e993ed91d2ea4b3f3c297ccbfd866b0a3358a34d8a0ae;真实HTTP下载与已构建运行文件一致。自动检查最终前端20套100项、API59套638项、真实PG隔离事务20项、Go全量/vet、类型/构建/格式/样式/CSS治理等通过。
- 证据目录:本机%TEMP%/cmpp-monitor-20260906内browser-admin.json、browser-notification-again.json、monitor各页/规则/双端通知三尺寸截图及各验证日志;认证不进入报告。低负载只读API样本15~27ms,不能代替高峰P95。未执行真实短信投递、500TPS容量测试、长时间压力或实际备份恢复演练。预生产本轮未访问、未部署。资料池结论仍为调查:已经生成且pendingReport=false的对象不会因组新增通道自动重入池,未修改此规则。 - 证据目录:本机%TEMP%/cmpp-monitor-20260906内browser-admin.json、browser-notification-again.json、monitor各页/规则/双端通知三尺寸截图及各验证日志;认证不进入报告。低负载只读API样本15~27ms,不能代替高峰P95。未执行真实短信投递、500TPS容量测试、长时间压力或实际备份恢复演练。预生产本轮未访问、未部署。资料池结论仍为调查:已经生成且pendingReport=false的对象不会因组新增通道自动重入池,未修改此规则。
## 2026-09-06 运行概况裁剪与监控配置500修复(发布前)
- 用户授权修改、提交、推送和测试发布,并追加测试告警数据;本轮不访问预生产。起始main/本地/远端/测试标记f059674,远端重新fetch后0/0。原8份修改文档、3份未跟踪文件保护;用例/进度只暂存本轮精确变更。
- 20:10只读诊断:测试API日志显示20:05:54及20:09:06的Prisma P2010/UnsupportedNativeDataType void,指向target事务锁;独立只读事务SELECT pg_advisory_xact_lock真实复现,无业务写入。规则保存也有同样写法。浏览器确认运行概况仍有三个最近列表,monitor响应含byStatus和三组recent字段,targets正常返回7项。
- 修复:两处仅用于加事务锁的$queryRaw改为$executeRaw,锁粒度、参数绑定、事务、版本和审计语义保留。删除运行概况三组表格及后端明细查询/响应;保留byStatus聚合与独立记录页面接口。uplink查询文件按现有格式门禁格式化并移除原拆分遗留的47项未使用导入,其他上行查询逻辑不变。无CSS/Gateway/数据库迁移或发送链路修改。
- 验证:前端20套100项、API60套639项,前后端TypeScript/构建、格式/ESLint及结构/安全/部署/包体门禁通过。真实服务+Prisma在独立qa_monitor_config随机schema执行12项:加入、重复版本冲突、并发移除、重新加入、非法参数/不存在通道、规则保存及并发、非法样本、审计失败双路径回滚;结束删除自己的临时schema,公共业务表写入0。旧方式只读复现P2010,修复后真实保存通过,弥补上一轮只验证规则空态和故障拦截的不足。
- 数据计划:按用户授权新增6条明确标注“测试演示”的历史告警(三种类型×已恢复/已关闭)及趋势快照,演示异常→恢复/关闭,阈值只在历史快照中,不填全局规则,不改余额/客户/通道配置,不发送短信。告警页面真实读取演示行只证明展示和交互,不替代自动采集/告警生命周期验收;发布后记录实际生成与浏览器证据。
-23
View File
@@ -115,29 +115,6 @@ export function MonitorRuntimeOverview() {
<div className="surface"> <div className="surface">
<Table columns={columns} data={channels} rowKey="id" /> <Table columns={columns} data={channels} rowKey="id" />
</div> </div>
{[
['recentMessages', '最近短信', '/admin/sms-records'],
['recentReceipts', '最近状态报告', '/admin/sms-records'],
['recentUplinks', '最近上行', '/admin/sms-uplink-records'],
].map(([key, label, to]) => (
<div className="surface" key={key}>
<h2>{label}</h2>
<Link to={to}></Link>
<Table
rowKey="id"
data={Array.isArray(monitor[key]) ? (monitor[key] as Record<string, unknown>[]) : []}
columns={[
{ key: 'messageId', title: '消息编号', render: (r) => String(r.messageId ?? r.gatewayMessageId ?? r.id) },
{ key: 'status', title: '状态', render: (r) => String(r.status ?? r.receiptStatus ?? '—') },
{
key: 'time',
title: '时间',
render: (r) => new Date(String(r.queuedAt ?? r.deliveredAt ?? r.createdAt)).toLocaleString('zh-CN'),
},
]}
/>
</div>
))}
</section> </section>
); );
} }
@@ -0,0 +1,103 @@
// Runs real service/Prisma operations in a uniquely named schema; never writes public business tables.
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import pg from '../../api/node_modules/pg/lib/index.js';
import prisma from '../../api/node_modules/@prisma/client/default.js';
import adapter from '../../api/node_modules/@prisma/adapter-pg/dist/index.js';
import service from '../../api/dist/sending-monitor/sending-monitor.module.js';
process.env.TZ = 'UTC';
const connectionString = process.env.QA_DATABASE_URL || process.env.DATABASE_URL;
if (!connectionString) throw Error('QA_DATABASE_URL is required');
const schema = `qa_monitor_config_${randomUUID().replaceAll('-', '')}`;
assert.match(schema, /^qa_monitor_config_[a-f0-9]{32}$/);
const admin = new pg.Client({ connectionString });
let client;
let checks = 0;
await admin.connect();
try {
await admin.query(`CREATE SCHEMA "${schema}"`);
for (const table of [
'SmsChannel',
'SendingMonitorTarget',
'SendingMonitorTargetVersion',
'SendingMonitorRule',
'SendingMonitorRuleVersion',
'SendingMonitorAlert',
'OperationLog',
])
await admin.query(`CREATE TABLE "${schema}"."${table}" (LIKE public."${table}" INCLUDING ALL)`);
await admin.query(
`INSERT INTO "${schema}"."SmsChannel" SELECT * FROM public."SmsChannel" WHERE status<>'deleted' LIMIT 1`,
);
const pool = new pg.Pool({ connectionString, max: 2, options: `-c search_path=${schema} -c timezone=UTC` });
client = new prisma.PrismaClient({ adapter: new adapter.PrismaPg(pool, { schema, disposeExternalPool: true }) });
assert.equal((await client.$queryRawUnsafe('SELECT current_schema() name'))[0].name, schema);
const sut = new service.SendingMonitorService(client);
const channel = await client.smsChannel.findFirstOrThrow();
const actor = 'qa-monitor-config';
assert.equal(await client.sendingMonitorTarget.count(), 0);
const joined = await sut.target(channel.id, { enabled: true, version: 0 }, actor);
assert.equal(joined[0].enabled, true);
assert.equal(joined[0].version, 1);
assert.equal(await client.sendingMonitorTargetVersion.count(), 1);
checks++;
await assert.rejects(sut.target(channel.id, { enabled: true, version: 0 }, actor), (e) => e.getStatus?.() === 409);
assert.equal(await client.operationLog.count(), 1);
checks++;
const race = await Promise.allSettled([
sut.target(channel.id, { enabled: false, version: 1 }, actor),
sut.target(channel.id, { enabled: false, version: 1 }, actor),
]);
assert.equal(race.filter((r) => r.status === 'fulfilled').length, 1);
assert.equal(race.find((r) => r.status === 'rejected').reason.getStatus(), 409);
assert.equal(await client.sendingMonitorTargetVersion.count(), 2);
checks++;
await sut.target(channel.id, { enabled: true, version: 2 }, actor);
assert.equal((await client.sendingMonitorTarget.findUniqueOrThrow({ where: { channelId: channel.id } })).version, 3);
checks++;
for (const [id, body, status] of [
['missing', { enabled: true, version: 0 }, 404],
[channel.id, { enabled: 'true', version: 0 }, 400],
[channel.id, { enabled: true, version: -1 }, 400],
]) {
await assert.rejects(sut.target(id, body, actor), (e) => e.getStatus?.() === status);
checks++;
}
const config = { enabled: true, minSamples: 100, thresholds: [90, 95, 98], consecutiveBad: 1, consecutiveGood: 2 };
const body = { type: 'industry', scope: {}, config, version: 0 };
const rules = await sut.saveRule(body, actor);
assert.equal(rules[0].version, 1);
assert.equal(await client.sendingMonitorRuleVersion.count(), 1);
checks++;
const ruleRace = await Promise.allSettled([
sut.saveRule({ ...body, version: 1 }, actor),
sut.saveRule({ ...body, version: 1 }, actor),
]);
assert.equal(ruleRace.filter((r) => r.status === 'fulfilled').length, 1);
assert.equal(ruleRace.find((r) => r.status === 'rejected').reason.getStatus(), 409);
assert.equal(await client.sendingMonitorRuleVersion.count(), 2);
assert.equal(await client.operationLog.count(), 5);
checks++;
await assert.rejects(
sut.saveRule({ ...body, config: { ...config, minSamples: 0 } }, actor),
(e) => e.getStatus?.() === 400,
);
checks++;
// A later audit failure must roll back both the current row and immutable version.
await client.$executeRawUnsafe(
`ALTER TABLE "OperationLog" ADD CONSTRAINT qa_reject CHECK ("resource"<>'sending_monitor') NOT VALID`,
);
await assert.rejects(sut.target(channel.id, { enabled: false, version: 3 }, actor));
assert.equal((await client.sendingMonitorTarget.findUniqueOrThrow({ where: { channelId: channel.id } })).version, 3);
assert.equal(await client.sendingMonitorTargetVersion.count(), 3);
checks++;
await assert.rejects(sut.saveRule({ ...body, version: 2 }, actor));
assert.equal(await client.sendingMonitorRuleVersion.count(), 2);
checks++;
console.log(JSON.stringify({ checks, realPrisma: true, schemaIsolated: true, businessWrites: 0 }));
} finally {
if (client) await client.$disconnect();
await admin.query(`DROP SCHEMA "${schema}" CASCADE`);
await admin.end();
}