This commit is contained in:
@@ -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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 趋势与告警详情
|
||||||
|
|
||||||
|
|||||||
@@ -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-001~004、008:全新不可路由QA对象触发真实数据库事件、同小时2→3→4版本、双端独立已读及重新未读、三尺寸详情、顶栏跳转通过。RRN-005真实跨租户404、伪造头403、匿名401、非法page400通过。其余边界以真实PG隔离事务和单测证据逐项说明,不笼统称全套端到端通过。
|
- RRN-001~004、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-027~029 运行概况与纳管保存回归(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;演示阈值仅存在历史快照,不写业务规则或发送短信,不能用该数据证明自动告警链路或真实投递成功。
|
||||||
|
|||||||
@@ -4638,3 +4638,11 @@ git diff --check
|
|||||||
- 19:57:46最终8项应用服务均active/Result=success/NRestarts=0;API/Gateway健康。监控检查点每5秒更新,complete=true、poolMax=2、pendingReceipts=0,stderr为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=0;API/Gateway健康。监控检查点每5秒更新,complete=true、poolMax=2、pendingReceipts=0,stderr为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条明确标注“测试演示”的历史告警(三种类型×已恢复/已关闭)及趋势快照,演示异常→恢复/关闭,阈值只在历史快照中,不填全局规则,不改余额/客户/通道配置,不发送短信。告警页面真实读取演示行只证明展示和交互,不替代自动采集/告警生命周期验收;发布后记录实际生成与浏览器证据。
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user