feat: densify sms records and improve uplink matching

This commit is contained in:
hectorzhao
2026-08-27 10:15:08 +08:00
parent 898471423f
commit a280b4bb22
25 changed files with 859 additions and 154 deletions
@@ -0,0 +1,2 @@
ALTER TABLE "SmsUplinkMessage"
ADD COLUMN "gatewayMessageId" TEXT;
+16 -15
View File
@@ -2116,21 +2116,22 @@ model SmsReceiptAnomaly {
} }
model SmsUplinkMessage { model SmsUplinkMessage {
id String @id @default(cuid()) id String @id @default(cuid())
eventId String? @unique eventId String? @unique
tenantId String? tenantId String?
applicationId String? applicationId String?
channelId String channelId String
messageRecordId String? messageRecordId String?
messageId String? messageId String?
sequenceId Int? gatewayMessageId String?
phoneNumber String sequenceId Int?
destId String phoneNumber String
content String destId String
matchStatus String @default("unmatched") content String
matchReason String? matchStatus String @default("unmatched")
receivedAt DateTime matchReason String?
createdAt DateTime @default(now()) receivedAt DateTime
createdAt DateTime @default(now())
tenant Tenant? @relation(fields: [tenantId], references: [id]) tenant Tenant? @relation(fields: [tenantId], references: [id])
application SmsApplication? @relation(fields: [applicationId], references: [id]) application SmsApplication? @relation(fields: [applicationId], references: [id])
+1
View File
@@ -303,6 +303,7 @@ export function clientUplinkView(message: Record<string, any>) {
applicationId: message.applicationId ?? null, applicationId: message.applicationId ?? null,
messageRecordId: message.messageRecordId ?? null, messageRecordId: message.messageRecordId ?? null,
messageId: message.messageId ?? null, messageId: message.messageId ?? null,
gatewayMessageId: message.gatewayMessageId ?? null,
phoneNumber: message.phoneNumber, phoneNumber: message.phoneNumber,
destId: message.destId, destId: message.destId,
content: message.content, content: message.content,
@@ -359,6 +359,50 @@ describe('OperationsService', () => {
}); });
}); });
it('returns the matched message record and the distinct uplink gateway message id to the client view', async () => {
const prisma = createPrismaMock();
prisma.smsUplinkMessage.findMany.mockResolvedValue([{
id: 'uplink-1',
tenantId: 'tenant-1',
applicationId: 'app-1',
messageRecordId: 'record-1',
messageId: null,
gatewayMessageId: '8412634832294102675',
phoneNumber: '13800000001',
destId: '10690000',
content: 'TD',
matchStatus: 'matched',
matchReason: '手机号 72 小时窗口唯一匹配',
receivedAt: new Date('2026-08-26T01:00:00.000Z'),
createdAt: new Date('2026-08-26T01:00:00.000Z'),
application: { id: 'app-1', name: '应用A' },
messageRecord: {
id: 'record-1',
applicationId: 'app-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
content: '通知内容',
billingUnits: 1,
amountCents: 325,
status: 'delivered',
queuedAt: new Date('2026-08-25T01:00:00.000Z'),
application: { id: 'app-1', name: '应用A' },
},
matchCandidates: [],
}]);
const service = new OperationsService(prisma as never);
const [uplink] = await service.listClientUplinkMessages({ tenantId: 'tenant-1' });
expect(uplink).toMatchObject({
messageId: null,
gatewayMessageId: '8412634832294102675',
messageRecordId: 'record-1',
matchStatus: 'matched',
messageRecord: { id: 'record-1', messageId: 'MSG-1' },
});
});
it('returns client message views without supplier channel, submit, tenant, or gateway internals', async () => { it('returns client message views without supplier channel, submit, tenant, or gateway internals', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.smsMessageRecord.findMany.mockResolvedValue([{ prisma.smsMessageRecord.findMany.mockResolvedValue([{
@@ -118,6 +118,7 @@ export interface GatewayUplinkEventDto {
eventId?: string; eventId?: string;
traceId?: string; traceId?: string;
messageId?: string; messageId?: string;
gatewayMessageId?: string;
channelId: string; channelId: string;
sequenceId?: number; sequenceId?: number;
phoneNumber: string; phoneNumber: string;
@@ -3749,6 +3749,7 @@ describe('SendChainService', () => {
}); });
await service.handleUplink({ await service.handleUplink({
messageId: 'MSG-1', messageId: 'MSG-1',
gatewayMessageId: '8412634832294102675',
channelId: 'channel-1', channelId: 'channel-1',
sequenceId: 8, sequenceId: 8,
phoneNumber: '13800000001', phoneNumber: '13800000001',
@@ -3761,7 +3762,7 @@ describe('SendChainService', () => {
data: expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD', messageRecordId: 'record-1' }), data: expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD', messageRecordId: 'record-1' }),
}); });
expect(prisma.smsUplinkMessage.create).toHaveBeenCalledWith({ expect(prisma.smsUplinkMessage.create).toHaveBeenCalledWith({
data: expect.objectContaining({ tenantId: 'tenant-1', channelId: 'channel-1', content: 'TD' }), data: expect.objectContaining({ tenantId: 'tenant-1', channelId: 'channel-1', gatewayMessageId: '8412634832294102675', content: 'TD' }),
}); });
}); });
@@ -45,6 +45,7 @@ export class SendDownstreamDeliveryService {
messageRecordId: match.messageRecordId, messageRecordId: match.messageRecordId,
channelId: data.channelId, channelId: data.channelId,
messageId: data.messageId, messageId: data.messageId,
gatewayMessageId: data.gatewayMessageId,
sequenceId: data.sequenceId, sequenceId: data.sequenceId,
phoneNumber: data.phoneNumber, phoneNumber: data.phoneNumber,
destId: data.destId, destId: data.destId,
+6 -4
View File
@@ -110,8 +110,9 @@
修复: 修复:
- 已接入 `GET /api/client/operations/uplink-messages` - 已接入 `GET /api/client/operations/uplink-messages`
- 详情弹窗按上行 `messageId` `GET /api/client/operations/messages`,展示真实匹配的下发记录 - 详情优先展示上行列表响应中已匹配的真实 `messageRecord`;仅对历史兼容数据在缺少内嵌记录且存在平台 `messageId` 时回`GET /api/client/operations/messages`
- 原无 API 支撑的“添加到应用黑名单”按钮已移除,后续补真实企业黑名单或应用黑名单 API 后再恢复 - 页面分开展示供应商 MO 的上行网关消息 ID 与关联平台消息 ID,不再把缺少平台 `messageId` 误判为没有匹配记录
- 客户端保持只读,不提供越权的运营黑名单写操作。
### 运营端短信上行记录仍是静态表 ### 运营端短信上行记录仍是静态表
@@ -133,8 +134,9 @@
修复: 修复:
- 已接入 `GET /api/admin/operations/uplink-messages`,返回真实上行记录、企业和通道信息。 - 已接入 `GET /api/admin/operations/uplink-messages`,返回真实上行记录、企业和通道信息。
- 详情弹窗按上行 `messageId` `GET /api/admin/operations/messages`,展示真实匹配的下发记录 - 详情优先展示上行列表响应中已匹配的真实 `messageRecord`;仅对历史兼容数据在缺少内嵌记录且存在平台 `messageId` 时回`GET /api/admin/operations/messages`
- 原无 API 支撑的“添加到应用黑名单”按钮已移除,后续补真实企业黑名单或应用黑名单 API 后再恢复 - 页面分开展示供应商 MO 的上行网关消息 ID 与关联平台消息 ID,并显示匹配状态和原因
- 已匹配或人工认领到企业应用后,恢复“加入应用黑名单”按钮并调用真实 `POST /api/admin/dictionaries/blacklists/enterprise`;未匹配应用时不允许写入。
### 运营端短信任务进度仍是静态任务 ### 运营端短信任务进度仍是静态任务
@@ -37,6 +37,8 @@
".admin-sms-record-table-card", ".admin-sms-record-table-card",
".admin-sms-record-toolbar", ".admin-sms-record-toolbar",
".admin-sms-record-list", ".admin-sms-record-list",
".admin-sms-record-list__header",
".admin-sms-record-group-title",
".admin-sms-record-card", ".admin-sms-record-card",
".admin-sms-record-status", ".admin-sms-record-status",
".admin-sms-record-detail-link", ".admin-sms-record-detail-link",
@@ -66,10 +68,12 @@
"短信内容", "短信内容",
"通道名称", "通道名称",
"发送状态", "发送状态",
"是否含引流信息",
"查询", "查询",
"重置", "重置",
"导出CSV", "导出CSV",
"查看发送详情", "查看发送详情",
"分片",
"发送详情", "发送详情",
"通道发送与回执", "通道发送与回执",
"分片补偿审计" "分片补偿审计"
+1 -1
View File
@@ -2,10 +2,10 @@
"schemaVersion": "v1", "schemaVersion": "v1",
"messageType": "UplinkEvent", "messageType": "UplinkEvent",
"traceId": "trace-20260701-uplink-000001", "traceId": "trace-20260701-uplink-000001",
"messageId": "uplink-20260701-000001",
"channelId": "sms-channel-cmpp-001", "channelId": "sms-channel-cmpp-001",
"createdAt": "2026-07-01T09:01:00.000Z", "createdAt": "2026-07-01T09:01:00.000Z",
"sequenceId": 4096, "sequenceId": 4096,
"gatewayMessageId": "8412634832294102675",
"phoneNumber": "13800138000", "phoneNumber": "13800138000",
"destId": "106900000000", "destId": "106900000000",
"content": "TD", "content": "TD",
@@ -24,6 +24,18 @@
"createdAt": { "type": "string", "format": "date-time" } "createdAt": { "type": "string", "format": "date-time" }
} }
}, },
"UplinkEnvelope": {
"type": "object",
"required": ["schemaVersion", "messageType", "traceId", "channelId", "createdAt"],
"properties": {
"schemaVersion": { "const": "v1" },
"messageType": { "const": "UplinkEvent" },
"traceId": { "type": "string", "minLength": 8 },
"messageId": { "type": "string", "minLength": 8 },
"channelId": { "type": "string", "minLength": 1 },
"createdAt": { "type": "string", "format": "date-time" }
}
},
"SubmitCommand": { "SubmitCommand": {
"allOf": [ "allOf": [
{ "$ref": "#/$defs/Envelope" }, { "$ref": "#/$defs/Envelope" },
@@ -180,13 +192,14 @@
}, },
"UplinkEvent": { "UplinkEvent": {
"allOf": [ "allOf": [
{ "$ref": "#/$defs/Envelope" }, { "$ref": "#/$defs/UplinkEnvelope" },
{ {
"type": "object", "type": "object",
"required": ["messageType", "sequenceId", "phoneNumber", "destId", "content", "receivedAt"], "required": ["messageType", "sequenceId", "gatewayMessageId", "phoneNumber", "destId", "content", "receivedAt"],
"properties": { "properties": {
"messageType": { "const": "UplinkEvent" }, "messageType": { "const": "UplinkEvent" },
"sequenceId": { "type": "integer", "minimum": 0 }, "sequenceId": { "type": "integer", "minimum": 0 },
"gatewayMessageId": { "type": "string", "minLength": 1 },
"phoneNumber": { "type": "string", "pattern": "^1[3-9][0-9]{9}$" }, "phoneNumber": { "type": "string", "pattern": "^1[3-9][0-9]{9}$" },
"destId": { "type": "string", "minLength": 1 }, "destId": { "type": "string", "minLength": 1 },
"content": { "type": "string", "minLength": 1 }, "content": { "type": "string", "minLength": 1 },
+25 -6
View File
@@ -234,9 +234,10 @@
2. 按手机号和时间查询。 2. 按手机号和时间查询。
3. 查看上行关联下发记录。 3. 查看上行关联下发记录。
- 预期结果: - 预期结果:
- 展示上行内容、接入号、接收时间。 - 展示上行内容、接入号、接收时间、上行网关消息 ID、匹配状态和匹配说明
- 可展示匹配到的下发 messageId。 - 后端已通过接入号或手机号时间窗匹配时,即使上行事件没有关联平台 `messageId`,详情仍直接展示响应中嵌入的真实下发记录及其平台 `messageId`
- 未匹配上行仍可查询,状态或关联为空 - 上行网关消息 ID 与关联平台消息 ID 分栏展示,不把供应商 MO `Msg_Id` 误作历史 MT Submit 消息 ID
- 未匹配或多候选上行仍可查询,并显示真实状态;多候选提示联系运营人员认领。
### TC-CLIENT-010 用户管理与企业管理员唯一性 ### TC-CLIENT-010 用户管理与企业管理员唯一性
@@ -1139,11 +1140,16 @@
### TC-SEND-008 上行短信匹配 ### TC-SEND-008 上行短信匹配
- 优先级:P1 - 优先级:P1
- 步骤:模拟带 messageId 的上行事件。 - 步骤:
1. 模拟带平台 `messageId` 的兼容上行事件。
2. 模拟真实供应商 MO:仅带独立 `gatewayMessageId`,不带历史平台 `messageId`,并分别制造接入号唯一匹配、手机号 72 小时唯一匹配和多候选场景。
3. 打开客户端和运营端详情。
- 预期结果: - 预期结果:
- 创建 SmsUplinkMessage。 - 创建 SmsUplinkMessage。
- tenantId 可通过 messageId 关联。 - `gatewayMessageId` 原样持久化;兼容事件仍可通过平台 `messageId` 精确关联。
- 客户端和运营端均可查询 - 不带平台 `messageId` 时按接入号、手机号 72 小时窗口执行匹配;唯一结果写入 `tenantId/applicationId/messageRecordId`,多候选保留为 `ambiguous`
- 客户端和运营端均可查询;详情优先使用 API 响应内嵌 `messageRecord`,不因 `messageId` 为空误报“无法匹配”。
- 运营端对已匹配或已认领应用显示“加入应用黑名单”,确认后调用真实企业应用黑名单 API;客户端保持只读。
### TC-SEND-009 未匹配上行短信入库 ### TC-SEND-009 未匹配上行短信入库
@@ -1151,6 +1157,7 @@
- 步骤:模拟不带 messageId 或匹配不到下发记录的上行事件。 - 步骤:模拟不带 messageId 或匹配不到下发记录的上行事件。
- 预期结果: - 预期结果:
- 上行短信仍入库。 - 上行短信仍入库。
- 供应商 MO `gatewayMessageId` 与平台 `messageId` 分别保存,不能用前者伪造后者的关联。
- tenantId 可为空。 - tenantId 可为空。
- 运营端可查询并人工判断。 - 运营端可查询并人工判断。
@@ -4890,3 +4897,15 @@ npm run verify:phase8
| TC-CMPP-PHASE5-018 | 同连接并发状态回调 | 同一connectionId的connected与submit并发时幂等upsert且保持在线;不同connectionId超过cmppMaxConnections仍403,不能误断当前连接或漏SubmitResp | | TC-CMPP-PHASE5-018 | 同连接并发状态回调 | 同一connectionId的connected与submit并发时幂等upsert且保持在线;不同connectionId超过cmppMaxConnections仍403,不能误断当前连接或漏SubmitResp |
执行记录:按企业微批发布后,单企业100 TPS为999/999响应、P95/P99=`243/466ms`、首次供应商Submit=`95.85 TPS`;单企业150 TPS冲击为1498/1498、`83/117ms`、首次Submit=`122.87 TPS`;双企业200 TPS冲击为1999/1999、`148/287ms`、首次Submit=`129.39 TPS`。三档最终有效运行均零拒绝、零节流、零连接错误,价格均325。双企业档账务1988 charged/646100、11 refunded/35752145个Submit/Outbox唯一,1950条终态回执投递1950次、重复0,973条离线pending通过零发送客户端排空。多Gateway P2未实施。 执行记录:按企业微批发布后,单企业100 TPS为999/999响应、P95/P99=`243/466ms`、首次供应商Submit=`95.85 TPS`;单企业150 TPS冲击为1498/1498、`83/117ms`、首次Submit=`122.87 TPS`;双企业200 TPS冲击为1999/1999、`148/287ms`、首次Submit=`129.39 TPS`。三档最终有效运行均零拒绝、零节流、零连接错误,价格均325。双企业档账务1988 charged/646100、11 refunded/35752145个Submit/Outbox唯一,1950条终态回执投递1950次、重复0,973条离线pending通过零发送客户端排空。多Gateway P2未实施。
## TC-ADMIN-SMS-RECORD-DENSITY 运营端短信记录高密度列表(2026-08-27)
| 用例ID | 场景 | 预期 |
| --- | --- | --- |
| TC-ADMIN-SMS-RECORD-DENSITY-001 | 打开短信记录搜索区 | 企业、应用、提交日期、手机号码、运营商、短信内容、通道、发送状态、是否含引流信息9项条件均保留,不能新增、删减或合并 |
| TC-ADMIN-SMS-RECORD-DENSITY-002 | 在桌面与窄视口查看搜索区 | 仅控件宽度和布局响应式变化,9项条件、查询和重置行为不变,无控件覆盖或截断 |
| TC-ADMIN-SMS-RECORD-DENSITY-003 | 查看包含单分片和多分片的短信记录 | 列表按日期分组;提交与回执分别显示日期和时分秒;计费列同时显示真实金额、分片数和字数;列表不显示“已补发”标签 |
| TC-ADMIN-SMS-RECORD-DENSITY-004 | 点击任一行最右侧箭头 | 继续打开既有发送详情弹窗,原有短信内容、通道发送与回执、状态信息及分片补偿审计保持不变 |
| TC-ADMIN-SMS-RECORD-DENSITY-005 | 使用真实本地API数据加载、查询、翻页和打开详情 | 页面非空、无异常遮罩,控制台无新增错误;数据仍来自原有真实API,不引入mock或localStorage业务数据 |
执行记录:本地真实API/PostgreSQL渲染通过,9项条件全部存在,1/2分片均显示在计费列;右箭头成功打开原有详情弹窗,干净页面控制台日志为空。R11契约、前后端构建、159项API专项测试及Gateway全包测试/vet通过。
+16
View File
@@ -4021,3 +4021,19 @@ git diff --check
- 预生产最终监控标记和部署标记均为`523481299028d9ded470d7738add145ee2070bd1`。安装版本:Prometheus 3.14.0、Node Exporter 1.12.1、PostgreSQL Exporter 0.20.1、Redis Exporter 1.89.0、Nginx Exporter 1.5.3Prometheus加载9个规则组共83条规则。 - 预生产最终监控标记和部署标记均为`523481299028d9ded470d7738add145ee2070bd1`。安装版本:Prometheus 3.14.0、Node Exporter 1.12.1、PostgreSQL Exporter 0.20.1、Redis Exporter 1.89.0、Nginx Exporter 1.5.3Prometheus加载9个规则组共83条规则。
- 发布后 Prometheus 9个 target 全部`up``pg_up=1``redis_up=1``nginx_up=1`9090/9100/9187/9121/9113及API/Worker metrics均只监听回环。Prometheus、5个 Exporter、MinIO、Nginx、API、Worker、Gateway、PostgreSQL和Redis均activeAPI/Gateway健康;供应商连接`desired=9/connected=9`,命令/结果 Stream 最终均`pending=0/lag=0` - 发布后 Prometheus 9个 target 全部`up``pg_up=1``redis_up=1``nginx_up=1`9090/9100/9187/9121/9113及API/Worker metrics均只监听回环。Prometheus、5个 Exporter、MinIO、Nginx、API、Worker、Gateway、PostgreSQL和Redis均activeAPI/Gateway健康;供应商连接`desired=9/connected=9`,命令/结果 Stream 最终均`pending=0/lag=0`
- 浏览器只读验收已到达预生产运营端登录页,因当前浏览器没有运营端登录会话,没有输入凭据或验证码,故本轮未把登录后的页面截图作为验收证据;监控可用性以 Prometheus targets、PromQL和API进程实际环境变量为当前证据。未发送短信、未压测、未修改余额/应用/企业/白名单/临时号段/通道配置,未操作正式生产,多 Gateway P2 未实施。 - 浏览器只读验收已到达预生产运营端登录页,因当前浏览器没有运营端登录会话,没有输入凭据或验证码,故本轮未把登录后的页面截图作为验收证据;监控可用性以 Prometheus targets、PromQL和API进程实际环境变量为当前证据。未发送短信、未压测、未修改余额/应用/企业/白名单/临时号段/通道配置,未操作正式生产,多 Gateway P2 未实施。
## 2026-08-26 上行匹配展示与应用黑名单入口本地修复及提交前验证
- 只读回查预生产数据库时共有84条上行记录,其中61条已匹配、23条为多候选;82条没有平台`messageId`,但59条已通过接入号或手机号72小时窗口写入`messageRecordId`。因此“全部匹配不到”的直接原因是客户端和运营端详情在`messageId`为空时提前返回,忽略API已返回的`messageRecord`;这不代表后端未匹配。
- CMPP普通MO的Deliver `Msg_Id`是供应商为该上行分配的独立标识,不是历史MT Submit的消息ID。Gateway此前仅尝试用它查询本地Submit跟踪器,正常MO通常得不到平台`messageId`,且原始MO `Msg_Id`只写日志未持久化。本轮新增独立可空字段`SmsUplinkMessage.gatewayMessageId`及迁移,Gateway将MO `Msg_Id`原样随事件上送,API持久化并在两端详情与关联平台消息ID分栏展示;平台`messageId`继续只表示真实关联,不伪造关联。
- 客户端和运营端详情优先使用上行列表响应内嵌的真实`messageRecord`;只有历史兼容记录在缺少内嵌记录且存在平台`messageId`时才回查消息接口。多候选记录给出认领提示。运营端对已匹配或已认领到企业应用的上行恢复“加入应用黑名单”按钮,确认后调用现有真实企业应用黑名单API;客户端保持只读。
- 自动化门禁:Gateway/API队列5份契约样例通过;SendChain与Operations专项2套159项通过;API正式TypeScript构建、前端TypeScript检查、Vite生产构建、Prisma schema校验、Gateway全包`go test ./... -count=1``go vet ./...`均通过。Gateway首次全包测试仅既有限速时序用例偶发一次`delay=0`,该包连续5轮及随后全包复跑均通过,本轮未修改限速实现。
- 本轮功能改造仅发生在本地工作区,没有在远端环境执行数据库迁移,没有发送短信、压测、push或部署。新增迁移必须随未来授权发布执行后,新上行才会保存`gatewayMessageId`;历史记录不会反填供应商MO ID。预生产和正式生产均未改动,多Gateway P2未实施。
## 2026-08-27 运营端短信记录高密度列表本地调整
- 搜索区保留原有9项条件:企业、应用、提交日期、手机号码、运营商、短信内容、通道、发送状态、是否含引流信息;未新增、删除或合并条件,仅改为12列响应式布局并压缩控件宽度。
- 短信记录由大卡片改为按提交日期分组的紧凑行式列表;提交时间和回执时间均按日期、时分秒两行展示,无回执时明确显示“暂无回执”。计费列集中展示金额、分片数和字数,不展示“已补发”标签;最右侧箭头继续调用原有`SendDetailModal`,弹窗实现未修改。
- 本地真实API和PostgreSQL数据渲染验证通过:9项搜索条件全部存在,列表可见1/2分片计费记录,点击右箭头可打开原有发送详情、通道发送与回执、状态信息和分片补偿审计;另开干净页面控制台日志为空。
- 门禁通过:R11页面契约、前端TypeScript检查与Vite生产构建、API正式构建与Prisma校验、队列契约、SendChain/Operations 2套159项、Gateway全包`go test ./... -count=1``go vet ./...`。pnpm包装器因既有`msgpackr-extract`构建脚本未批准而中止,未放宽依赖策略,改用已安装的TypeScript/Vite入口完成等价构建。
- 本轮仅使用本地隔离环境,没有访问或修改预生产/生产,没有发送短信或压测。为渲染验证启动的PostgreSQL、API、前端预览和临时Redis均已停止;多Gateway P2未实施。
+7 -6
View File
@@ -19,7 +19,7 @@ type Envelope struct {
SchemaVersion string `json:"schemaVersion"` SchemaVersion string `json:"schemaVersion"`
MessageType MessageType `json:"messageType"` MessageType MessageType `json:"messageType"`
TraceID string `json:"traceId"` TraceID string `json:"traceId"`
MessageID string `json:"messageId"` MessageID string `json:"messageId,omitempty"`
ChannelID string `json:"channelId"` ChannelID string `json:"channelId"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
@@ -118,11 +118,12 @@ type ReceiptEvent struct {
type UplinkEvent struct { type UplinkEvent struct {
Envelope Envelope
SequenceID uint32 `json:"sequenceId"` SequenceID uint32 `json:"sequenceId"`
PhoneNumber string `json:"phoneNumber"` GatewayMessageID string `json:"gatewayMessageId"`
DestID string `json:"destId"` PhoneNumber string `json:"phoneNumber"`
Content string `json:"content"` DestID string `json:"destId"`
ReceivedAt time.Time `json:"receivedAt"` Content string `json:"content"`
ReceivedAt time.Time `json:"receivedAt"`
} }
type ConnectChannelCommand struct { type ConnectChannelCommand struct {
+7 -6
View File
@@ -124,14 +124,15 @@ func (c *connection) handleDeliver(pkt deliverPacket) error {
ChannelID: c.channelID, ChannelID: c.channelID,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
}, },
SequenceID: pkt.seqID, SequenceID: pkt.seqID,
PhoneNumber: strings.TrimSpace(pkt.srcTerminalID), GatewayMessageID: fmt.Sprint(pkt.msgID),
DestID: strings.TrimSpace(pkt.destID), PhoneNumber: strings.TrimSpace(pkt.srcTerminalID),
Content: content, DestID: strings.TrimSpace(pkt.destID),
ReceivedAt: time.Now().UTC(), Content: content,
ReceivedAt: time.Now().UTC(),
} }
if c.protocolLogPublisher != nil { if c.protocolLogPublisher != nil {
c.emitProtocolLog(protocolLogEvent{Protocol: "cmpp", Direction: "channel_to_platform", EventType: "deliver_uplink", Status: "success", ChannelID: c.channelID, Account: c.config.Account, MessageID: cmd.MessageID, Phone: strings.TrimSpace(pkt.srcTerminalID), Detail: map[string]any{"sequenceId": pkt.seqID}}) c.emitProtocolLog(protocolLogEvent{Protocol: "cmpp", Direction: "channel_to_platform", EventType: "deliver_uplink", Status: "success", ChannelID: c.channelID, Account: c.config.Account, MessageID: cmd.MessageID, GatewayMessageID: fmt.Sprint(pkt.msgID), Phone: strings.TrimSpace(pkt.srcTerminalID), Detail: map[string]any{"sequenceId": pkt.seqID}})
} }
var publishErr error var publishErr error
if c.eventPublisher != nil { if c.eventPublisher != nil {
+48
View File
@@ -92,6 +92,54 @@ func TestHandleCMPP2DeliverReceiptPostsReceiptEvent(t *testing.T) {
} }
} }
func TestHandleCMPP2UplinkPreservesGatewayMessageIDWithoutPretendingItIsASubmitMessage(t *testing.T) {
events := make(chan queue.UplinkEvent, 1)
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/gateway/events/uplink" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var event queue.UplinkEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
t.Fatalf("decode uplink event: %v", err)
}
events <- event
w.WriteHeader(http.StatusOK)
}))
defer api.Close()
conn := &connection{
channelID: "channel-1",
apiBaseURL: api.URL,
httpClient: api.Client(),
tracker: map[uint64]queue.SubmitCommand{},
}
if err := conn.handleDeliver(deliverPacketFromCMPP2(&cmpp.Cmpp2DeliverReqPkt{
SeqId: 8,
MsgId: 8412634832294102675,
DestId: "10690000",
SrcTerminalId: "13800000001",
RegisterDelivery: 0,
MsgContent: "TD",
})); err != nil {
t.Fatalf("handle uplink: %v", err)
}
select {
case event := <-events:
if event.GatewayMessageID != "8412634832294102675" {
t.Fatalf("GatewayMessageID = %q", event.GatewayMessageID)
}
if event.MessageID != "" {
t.Fatalf("MessageID = %q, want empty without a correlated submit", event.MessageID)
}
if event.PhoneNumber != "13800000001" || event.DestID != "10690000" || event.Content != "TD" {
t.Fatalf("unexpected uplink event: %+v", event)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for uplink event")
}
}
func TestReceiptStatusTreatsNonDeliveredFinalStatesAsUndelivered(t *testing.T) { func TestReceiptStatusTreatsNonDeliveredFinalStatesAsUndelivered(t *testing.T) {
for _, stat := range []string{"UNKNOWN", "UNDELIV", "EXPIRED", "DELETED", "REJECTD"} { for _, stat := range []string{"UNKNOWN", "UNDELIV", "EXPIRED", "DELETED", "REJECTD"} {
if got := receiptStatus(stat); got != "undelivered" { if got := receiptStatus(stat); got != "undelivered" {
+1
View File
@@ -225,6 +225,7 @@ export type SmsUplinkMessage = {
applicationId?: string | null; applicationId?: string | null;
messageRecordId?: string | null; messageRecordId?: string | null;
messageId?: string | null; messageId?: string | null;
gatewayMessageId?: string | null;
sequenceId?: number | null; sequenceId?: number | null;
phoneNumber: string; phoneNumber: string;
destId: string; destId: string;
+61 -9
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Search, Smartphone } from 'lucide-react'; import { Search, Smartphone, UserX } from 'lucide-react';
import { adminApi, type SmsMessageRecord, type SmsUplinkMatchCandidate, type SmsUplinkMessage } from '@/api/adminApi'; import { adminApi, type SmsMessageRecord, type SmsUplinkMatchCandidate, type SmsUplinkMessage } from '@/api/adminApi';
import { import {
Breadcrumb, Breadcrumb,
@@ -40,28 +40,41 @@ function candidateStatusText(status?: string | null) {
} }
function UplinkDetailModal({ function UplinkDetailModal({
blacklistFeedback,
blacklisting,
claimError, claimError,
claimingId, claimingId,
detailError, detailError,
matchedRecords, matchedRecords,
matching, matching,
message, message,
onAddBlacklist,
onClaim, onClaim,
onClose, onClose,
}: { }: {
blacklistFeedback: string;
blacklisting: boolean;
claimError: string; claimError: string;
claimingId: string; claimingId: string;
detailError: string; detailError: string;
matchedRecords: SmsMessageRecord[]; matchedRecords: SmsMessageRecord[];
matching: boolean; matching: boolean;
message: SmsUplinkMessage; message: SmsUplinkMessage;
onAddBlacklist: () => void;
onClaim: (candidate: SmsUplinkMatchCandidate) => void; onClaim: (candidate: SmsUplinkMatchCandidate) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const candidates = message.matchCandidates ?? []; const candidates = message.matchCandidates ?? [];
return ( return (
<Modal <Modal
footer={<Button onClick={onClose} variant="ghost"></Button>} footer={<>
{message.tenantId && message.applicationId ? (
<Button disabled={blacklisting} icon={<UserX size={15} />} onClick={onAddBlacklist}>
{blacklisting ? '加入中...' : '加入应用黑名单'}
</Button>
) : null}
<Button onClick={onClose} variant="ghost"></Button>
</>}
onClose={onClose} onClose={onClose}
open open
size="xl" size="xl"
@@ -92,13 +105,21 @@ function UplinkDetailModal({
<strong>{message.destId || '-'}</strong> <strong>{message.destId || '-'}</strong>
</div> </div>
<div> <div>
<span>ID</span> <span>ID</span>
<strong>{message.messageId || '-'}</strong> <strong>{message.gatewayMessageId || '-'}</strong>
</div>
<div>
<span>ID</span>
<strong>{message.messageRecord?.messageId ?? message.messageId ?? '-'}</strong>
</div> </div>
<div> <div>
<span></span> <span></span>
<strong>{matchStatusText(message.matchStatus)}</strong> <strong>{matchStatusText(message.matchStatus)}</strong>
</div> </div>
<div className="admin-uplink-info-grid__full">
<span></span>
<strong>{message.matchReason || '-'}</strong>
</div>
<div className="admin-uplink-info-grid__full"> <div className="admin-uplink-info-grid__full">
<span></span> <span></span>
<strong>{message.content || '-'}</strong> <strong>{message.content || '-'}</strong>
@@ -160,11 +181,13 @@ function UplinkDetailModal({
<section className="admin-uplink-match-section"> <section className="admin-uplink-match-section">
<h3></h3> <h3></h3>
{blacklistFeedback ? <p className={blacklistFeedback.startsWith('已') ? 'form-success' : 'form-error'}>{blacklistFeedback}</p> : null}
{matching ? <p>...</p> : null} {matching ? <p>...</p> : null}
{detailError ? <p className="form-error">{detailError}</p> : null} {detailError ? <p className="form-error">{detailError}</p> : null}
{!matching && !message.messageId ? <div className="admin-uplink-empty-match">ID</div> : null} {!matching && matchedRecords.length === 0 && !detailError ? (
{!matching && message.messageId && matchedRecords.length === 0 && !detailError ? ( <div className="admin-uplink-empty-match">
<div className="admin-uplink-empty-match"></div> {message.matchStatus === 'ambiguous' ? '存在多个候选,请先认领正确应用' : '暂无匹配发送记录'}
</div>
) : null} ) : null}
{matchedRecords.map((record) => ( {matchedRecords.map((record) => (
<article className="admin-uplink-match-card" key={record.id}> <article className="admin-uplink-match-card" key={record.id}>
@@ -211,6 +234,8 @@ export function AdminSmsUplinkRecordsPage() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [detailError, setDetailError] = useState(''); const [detailError, setDetailError] = useState('');
const [claimError, setClaimError] = useState(''); const [claimError, setClaimError] = useState('');
const [blacklisting, setBlacklisting] = useState(false);
const [blacklistFeedback, setBlacklistFeedback] = useState('');
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 10; const pageSize = 10;
@@ -236,11 +261,12 @@ export function AdminSmsUplinkRecordsPage() {
function openDetail(message: SmsUplinkMessage) { function openDetail(message: SmsUplinkMessage) {
setSelectedMessage(message); setSelectedMessage(message);
setMatchedRecords([]); setMatchedRecords(message.messageRecord ? [message.messageRecord] : []);
setDetailError(''); setDetailError('');
setClaimError(''); setClaimError('');
setBlacklistFeedback('');
if (!message.messageId) { if (message.messageRecord || !message.messageId) {
return; return;
} }
@@ -251,6 +277,28 @@ export function AdminSmsUplinkRecordsPage() {
.finally(() => setMatching(false)); .finally(() => setMatching(false));
} }
function handleAddBlacklist() {
if (!selectedMessage?.tenantId || !selectedMessage.applicationId) {
setBlacklistFeedback('请先匹配或认领企业应用');
return;
}
if (!window.confirm(`确认将 ${selectedMessage.phoneNumber} 加入当前应用黑名单?`)) {
return;
}
setBlacklisting(true);
setBlacklistFeedback('');
adminApi.createEnterpriseBlacklist({
tenantId: selectedMessage.tenantId,
applicationId: selectedMessage.applicationId,
phoneNumber: selectedMessage.phoneNumber,
reason: '上行短信人工加入',
status: 'active',
})
.then(() => setBlacklistFeedback('已加入当前应用黑名单'))
.catch((reason: Error) => setBlacklistFeedback(reason.message || '加入应用黑名单失败'))
.finally(() => setBlacklisting(false));
}
useEffect(() => { useEffect(() => {
loadData(page); loadData(page);
}, [page]); }, [page]);
@@ -273,6 +321,7 @@ export function AdminSmsUplinkRecordsPage() {
.then((updated) => { .then((updated) => {
setMessages((items) => items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item))); setMessages((items) => items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)));
setSelectedMessage((current) => (current && current.id === updated.id ? { ...current, ...updated } : current)); setSelectedMessage((current) => (current && current.id === updated.id ? { ...current, ...updated } : current));
setMatchedRecords(updated.messageRecord ? [updated.messageRecord] : []);
loadData(page); loadData(page);
}) })
.catch((reason: Error) => setClaimError(reason.message || '上行认领失败')) .catch((reason: Error) => setClaimError(reason.message || '上行认领失败'))
@@ -332,12 +381,15 @@ export function AdminSmsUplinkRecordsPage() {
{selectedMessage ? ( {selectedMessage ? (
<UplinkDetailModal <UplinkDetailModal
blacklistFeedback={blacklistFeedback}
blacklisting={blacklisting}
claimError={claimError} claimError={claimError}
claimingId={claimingId} claimingId={claimingId}
detailError={detailError} detailError={detailError}
matchedRecords={matchedRecords} matchedRecords={matchedRecords}
matching={matching} matching={matching}
message={selectedMessage} message={selectedMessage}
onAddBlacklist={handleAddBlacklist}
onClaim={handleClaim} onClaim={handleClaim}
onClose={() => setSelectedMessage(null)} onClose={() => setSelectedMessage(null)}
/> />
@@ -5,14 +5,29 @@
.admin-sms-record-filter { .admin-sms-record-filter {
align-items: end; align-items: end;
display: grid; display: grid;
gap: var(--space-5); gap: var(--space-4);
grid-template-columns: repeat(4, minmax(180px, 1fr)); grid-template-columns: repeat(12, minmax(0, 1fr));
}
.admin-sms-record-filter__field {
grid-column: span 2;
min-width: 0;
}
.admin-sms-record-filter__field.is-date,
.admin-sms-record-filter__field.is-content {
grid-column: span 3;
}
.admin-sms-record-filter__field.is-phone {
grid-column: span 2;
} }
.admin-sms-record-filter__actions { .admin-sms-record-filter__actions {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-3);
grid-template-columns: repeat(2, minmax(120px, 1fr)); grid-column: span 3;
grid-template-columns: repeat(2, minmax(88px, 1fr));
} }
.admin-sms-record-table-card { .admin-sms-record-table-card {
@@ -25,8 +40,8 @@
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
min-height: 76px; min-height: 56px;
padding: var(--space-4) var(--space-6); padding: var(--space-2) var(--space-4);
} }
.admin-sms-record-table-card .ui-table-wrap { .admin-sms-record-table-card .ui-table-wrap {
@@ -35,52 +50,64 @@
} }
.admin-sms-record-list { .admin-sms-record-list {
overflow-x: auto;
padding: 0 var(--space-4) var(--space-3);
}
.admin-sms-record-list__header,
.admin-sms-record-card {
align-items: center;
display: grid; display: grid;
gap: var(--space-2); gap: var(--space-3);
padding: var(--space-3); grid-template-columns: 96px 96px minmax(360px, 1fr) 96px 112px 36px;
min-width: 960px;
}
.admin-sms-record-list__header {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
height: 38px;
padding: 0 var(--space-3);
}
.admin-sms-record-list__header span:last-child {
text-align: center;
}
.admin-sms-record-group-title {
background: var(--color-bg-subtle);
border-bottom: 1px solid var(--color-border);
border-top: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
margin: 0 calc(var(--space-4) * -1);
padding: var(--space-2) var(--space-7);
} }
.admin-sms-record-card { .admin-sms-record-card {
background: var(--color-surface); border-bottom: 1px solid var(--color-border);
border: 1px solid var(--color-border); min-height: 72px;
border-radius: var(--radius-lg); padding: var(--space-2) var(--space-3);
display: grid;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
} }
.admin-sms-record-card:hover { .admin-sms-record-card:hover {
border-color: color-mix(in srgb, var(--color-selected) 35%, var(--color-border)); background: color-mix(in srgb, var(--color-selected) 4%, var(--color-surface));
box-shadow: var(--shadow-sm);
}
.admin-sms-record-card > header {
align-items: center;
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(180px, 1fr) auto auto;
}
.admin-sms-record-card > header time {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
} }
.admin-sms-record-card .admin-sms-record-content { .admin-sms-record-card .admin-sms-record-content {
background: var(--color-bg-subtle); line-height: 1.45;
border-radius: var(--radius-md);
display: -webkit-box;
line-height: 1.55;
max-width: none; max-width: none;
min-width: 0;
overflow: hidden; overflow: hidden;
padding: var(--space-2) var(--space-3); padding: 0;
-webkit-box-orient: vertical; text-overflow: ellipsis;
-webkit-line-clamp: 2; white-space: nowrap;
} }
.admin-sms-record-card .admin-sms-record-content.is-drainage { .admin-sms-record-card .admin-sms-record-content.is-drainage {
background: color-mix(in srgb, #f59e0b 13%, var(--color-surface)); color: #92400e;
border: 1px solid color-mix(in srgb, #f59e0b 34%, var(--color-border));
} }
.admin-sms-record-content mark { .admin-sms-record-content mark {
@@ -95,7 +122,7 @@
display: inline-flex; display: inline-flex;
font-size: var(--font-size-xs); font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
margin-left: var(--space-3); margin-left: var(--space-2);
padding: 1px var(--space-2); padding: 1px var(--space-2);
vertical-align: middle; vertical-align: middle;
} }
@@ -111,32 +138,43 @@
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.admin-sms-record-card__meta { .admin-sms-record-main {
display: grid;
gap: var(--space-4);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-sms-record-card__meta > div {
display: grid; display: grid;
gap: var(--space-1); gap: var(--space-1);
min-width: 0;
} }
.admin-sms-record-card__meta span, .admin-sms-record-context {
.admin-sms-record-card__meta small { align-items: center;
color: var(--color-text-muted);
display: flex;
font-size: var(--font-size-xs);
gap: var(--space-2) var(--space-4);
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.admin-sms-record-context span {
overflow: hidden;
text-overflow: ellipsis;
}
.admin-sms-record-time,
.admin-sms-record-billing {
display: grid;
font-size: var(--font-size-xs);
gap: 2px;
}
.admin-sms-record-time span,
.admin-sms-record-billing span {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.admin-sms-record-card__meta strong { .admin-sms-record-time strong,
.admin-sms-record-billing strong {
color: var(--color-text-strong); color: var(--color-text-strong);
overflow-wrap: anywhere;
}
.admin-sms-record-card > footer {
border-top: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
padding-top: var(--space-2);
} }
.admin-sms-record-table { .admin-sms-record-table {
@@ -221,11 +259,21 @@
} }
.admin-sms-record-detail-link { .admin-sms-record-detail-link {
background: transparent; align-items: center;
border: 0; background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-selected); color: var(--color-selected);
font-weight: var(--font-weight-semibold); display: inline-flex;
height: 32px;
justify-content: center;
padding: 0; padding: 0;
width: 32px;
}
.admin-sms-record-detail-link:hover {
background: var(--color-selected-soft);
border-color: color-mix(in srgb, var(--color-selected) 35%, var(--color-border));
} }
.admin-sms-send-detail { .admin-sms-send-detail {
@@ -392,8 +440,6 @@
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.admin-sms-record-card > header,
.admin-sms-record-card__meta,
.admin-sms-detail-overview, .admin-sms-detail-overview,
.admin-sms-detail-status-grid, .admin-sms-detail-status-grid,
.admin-sms-route-list dl, .admin-sms-route-list dl,
@@ -402,8 +448,11 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.admin-sms-record-card > header time { .admin-sms-record-filter__field,
justify-self: start; .admin-sms-record-filter__field.is-date,
.admin-sms-record-filter__field.is-content,
.admin-sms-record-filter__actions {
grid-column: span 6;
} }
} }
@@ -463,4 +512,11 @@
.admin-sms-route-list dl { .admin-sms-route-list dl {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.admin-sms-record-filter__field,
.admin-sms-record-filter__field.is-date,
.admin-sms-record-filter__field.is-content,
.admin-sms-record-filter__actions {
grid-column: auto;
}
} }
@@ -85,15 +85,15 @@ export function SmsRecordFilter({
}: SmsRecordFilterProps) { }: SmsRecordFilterProps) {
return ( return (
<div className="surface admin-sms-record-filter"> <div className="surface admin-sms-record-filter">
<Select label="企业" onChange={(event) => onEnterpriseChange(event.target.value)} options={enterpriseOptions} value={enterprise} /> <div className="admin-sms-record-filter__field is-enterprise"><Select label="企业" onChange={(event) => onEnterpriseChange(event.target.value)} options={enterpriseOptions} value={enterprise} /></div>
<Select label="应用" onChange={(event) => onApplicationChange(event.target.value)} options={applicationOptions} value={application} /> <div className="admin-sms-record-filter__field is-application"><Select label="应用" onChange={(event) => onApplicationChange(event.target.value)} options={applicationOptions} value={application} /></div>
<DateRangeInput label="提交日期" onChange={onDateRangeChange} value={dateRange} /> <div className="admin-sms-record-filter__field is-date"><DateRangeInput label="提交日期" onChange={onDateRangeChange} value={dateRange} /></div>
<Input label="手机号码" onChange={(event) => onPhoneKeywordChange(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} /> <div className="admin-sms-record-filter__field is-phone"><Input label="手机号码" onChange={(event) => onPhoneKeywordChange(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} /></div>
<Select label="运营商" onChange={(event) => onCarrierChange(event.target.value)} options={carrierOptions} value={carrier} /> <div className="admin-sms-record-filter__field is-carrier"><Select label="运营商" onChange={(event) => onCarrierChange(event.target.value)} options={carrierOptions} value={carrier} /></div>
<Input label="短信内容" onChange={(event) => onContentKeywordChange(event.target.value)} value={contentKeyword} /> <div className="admin-sms-record-filter__field is-content"><Input label="短信内容" onChange={(event) => onContentKeywordChange(event.target.value)} value={contentKeyword} /></div>
<Select label="通道" onChange={(event) => onChannelChange(event.target.value)} options={channelOptions} searchable searchPlaceholder="输入通道名称搜索" value={channel} /> <div className="admin-sms-record-filter__field is-channel"><Select label="通道" onChange={(event) => onChannelChange(event.target.value)} options={channelOptions} searchable searchPlaceholder="输入通道名称搜索" value={channel} /></div>
<Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} /> <div className="admin-sms-record-filter__field is-status"><Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} /></div>
<Select label="是否含引流信息" onChange={(event) => onHasDrainageChange(event.target.value)} options={drainageOptions} value={hasDrainage} /> <div className="admin-sms-record-filter__field is-drainage"><Select label="是否含引流信息" onChange={(event) => onHasDrainageChange(event.target.value)} options={drainageOptions} value={hasDrainage} /></div>
<div className="admin-sms-record-filter__actions"> <div className="admin-sms-record-filter__actions">
<Button icon={<Search size={16} />} onClick={onQuery}></Button> <Button icon={<Search size={16} />} onClick={onQuery}></Button>
<Button onClick={onReset} variant="ghost"></Button> <Button onClick={onReset} variant="ghost"></Button>
+46 -25
View File
@@ -1,4 +1,4 @@
import { Download } from 'lucide-react'; import { ChevronRight, Download } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { SmsMessageRecord } from '@/api/adminApi'; import type { SmsMessageRecord } from '@/api/adminApi';
import { Button, CarrierTag, MoneyText, Pagination } from '@/components/ui'; import { Button, CarrierTag, MoneyText, Pagination } from '@/components/ui';
@@ -8,7 +8,6 @@ import {
getDate, getDate,
getRecordStatus, getRecordStatus,
getStatusLabel, getStatusLabel,
getTime,
statusDotClassMap, statusDotClassMap,
} from './smsRecordModel'; } from './smsRecordModel';
@@ -60,36 +59,58 @@ export function SmsRecordList({
onOpenDetail, onOpenDetail,
onPageChange, onPageChange,
}: SmsRecordListProps) { }: SmsRecordListProps) {
let previousDate = '';
return ( return (
<div className="surface admin-sms-record-table-card"> <div className="surface admin-sms-record-table-card">
<div className="admin-sms-record-toolbar"> <div className="admin-sms-record-toolbar">
<Button icon={<Download size={16} />} onClick={onExport} variant="ghost">CSV</Button> <Button icon={<Download size={16} />} onClick={onExport} variant="ghost">CSV</Button>
</div> </div>
<div className="admin-sms-record-list"> <div className="admin-sms-record-list">
{loading ? <div className="ui-table__empty">...</div> : records.length === 0 ? <div className="ui-table__empty"></div> : records.map((record) => ( {loading ? <div className="ui-table__empty">...</div> : records.length === 0 ? <div className="ui-table__empty"></div> : (
<article className="admin-sms-record-card" key={record.id}> <>
<header> <div aria-hidden="true" className="admin-sms-record-list__header">
<div className="admin-sms-record-sender"> <span></span><span></span><span></span><span></span><span></span><span></span>
<strong>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'}</strong>
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
</div>
<StatusLine record={record} />
<time>{getDate(record.queuedAt)} {getClock(record.queuedAt)}</time>
</header>
<p className={`admin-sms-record-content${record.hasDrainageContent ? ' is-drainage' : ''}`}>
<DrainageContent record={record} />
<span className={`admin-sms-record-drainage-badge is-${record.hasDrainageContent === true ? 'yes' : record.hasDrainageContent === false ? 'no' : 'unknown'}`}>
{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}
</span>
</p>
<div className="admin-sms-record-card__meta">
<div><span></span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</small></div>
<div><span></span><strong>{record.billingUnits} / <MoneyText>¥{formatCents(record.amountCents)}</MoneyText></strong><small>{record.content.length} </small></div>
<div><span></span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong><small> {getTime(record.deliveredAt)}</small></div>
</div> </div>
<footer><button className="admin-sms-record-detail-link" onClick={() => onOpenDetail(record)} type="button"></button></footer> {records.map((record) => {
</article> const submitDate = getDate(record.queuedAt);
))} const showDateGroup = submitDate !== previousDate;
previousDate = submitDate;
return (
<div className="admin-sms-record-group" key={record.id}>
{showDateGroup ? <div className="admin-sms-record-group-title">{submitDate}</div> : null}
<article className="admin-sms-record-card">
<StatusLine record={record} />
<time className="admin-sms-record-time" dateTime={record.queuedAt}>
<span>{submitDate}</span><strong>{getClock(record.queuedAt)}</strong>
</time>
<div className="admin-sms-record-main">
<p className={`admin-sms-record-content${record.hasDrainageContent ? ' is-drainage' : ''}`}>
<DrainageContent record={record} />
<span className={`admin-sms-record-drainage-badge is-${record.hasDrainageContent === true ? 'yes' : record.hasDrainageContent === false ? 'no' : 'unknown'}`}>
{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}
</span>
</p>
<div className="admin-sms-record-context">
<span>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'} / {record.application?.name ?? record.applicationId ?? '-'}</span>
<span>{record.phoneNumber} · {record.province ?? '-'} {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</span>
<span>{record.channel?.name ?? record.channelId ?? '-'}</span>
</div>
</div>
<time className="admin-sms-record-time" dateTime={record.deliveredAt ?? undefined}>
{record.deliveredAt ? <><span>{getDate(record.deliveredAt)}</span><strong>{getClock(record.deliveredAt)}</strong></> : <span></span>}
</time>
<div className="admin-sms-record-billing">
<MoneyText>¥{formatCents(record.amountCents)}</MoneyText>
<span>{record.billingUnits} · {record.content.length} </span>
</div>
<button aria-label="查看发送详情" className="admin-sms-record-detail-link" onClick={() => onOpenDetail(record)} title="查看发送详情" type="button"><ChevronRight size={18} /></button>
</article>
</div>
);
})}
</>
)}
</div> </div>
<Pagination <Pagination
nextDisabled={currentPage >= totalPages} nextDisabled={currentPage >= totalPages}
+10 -6
View File
@@ -52,10 +52,10 @@ export function ClientUplinkMessagesPage() {
function openDetail(message: SmsUplinkMessage) { function openDetail(message: SmsUplinkMessage) {
setSelectedMessage(message); setSelectedMessage(message);
setMatchedRecords([]); setMatchedRecords(message.messageRecord ? [message.messageRecord] : []);
setDetailError(''); setDetailError('');
if (!message.messageId) { if (message.messageRecord || !message.messageId) {
return; return;
} }
@@ -142,7 +142,10 @@ export function ClientUplinkMessagesPage() {
{ label: '手机号码', value: selectedMessage.phoneNumber }, { label: '手机号码', value: selectedMessage.phoneNumber },
{ label: '上行时间', value: getTime(selectedMessage.receivedAt) }, { label: '上行时间', value: getTime(selectedMessage.receivedAt) },
{ label: '接入号码', value: selectedMessage.destId }, { label: '接入号码', value: selectedMessage.destId },
{ label: '网关消息ID', value: selectedMessage.messageId || '-' }, { label: '上行网关消息ID', value: selectedMessage.gatewayMessageId || '-' },
{ label: '关联平台消息ID', value: selectedMessage.messageRecord?.messageId ?? selectedMessage.messageId ?? '-' },
{ label: '匹配状态', value: selectedMessage.matchStatus || '-' },
{ label: '匹配说明', value: selectedMessage.matchReason || '-', full: true },
{ label: '上行内容', value: selectedMessage.content, full: true }, { label: '上行内容', value: selectedMessage.content, full: true },
]} ]}
/> />
@@ -151,9 +154,10 @@ export function ClientUplinkMessagesPage() {
<DetailSection title="匹配发送记录"> <DetailSection title="匹配发送记录">
{matching ? <p className="uplink-detail-hint">...</p> : null} {matching ? <p className="uplink-detail-hint">...</p> : null}
{detailError ? <p className="form-error">{detailError}</p> : null} {detailError ? <p className="form-error">{detailError}</p> : null}
{!matching && !selectedMessage.messageId ? <p className="uplink-detail-hint">ID</p> : null} {!matching && matchedRecords.length === 0 && !detailError ? (
{!matching && selectedMessage.messageId && matchedRecords.length === 0 && !detailError ? ( <p className="uplink-detail-hint">
<p className="uplink-detail-hint"></p> {selectedMessage.matchStatus === 'ambiguous' ? '该上行存在多个候选,请联系运营人员认领。' : '未匹配到真实下发记录。'}
</p>
) : null} ) : null}
{matchedRecords.length > 0 ? ( {matchedRecords.length > 0 ? (
<div className="uplink-match-list"> <div className="uplink-match-list">
+415
View File
@@ -0,0 +1,415 @@
import { createHash } from 'node:crypto';
import { PrismaPg } from '../../api/node_modules/@prisma/adapter-pg/dist/index.js';
import { PrismaClient } from '../../api/node_modules/@prisma/client/index.js';
const databaseUrl = process.env.DATABASE_URL
?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public';
const prisma = new PrismaClient({ adapter: new PrismaPg(databaseUrl) });
const PREFIX = 'screenshot-seed-20260822';
const tenantId = `${PREFIX}-tenant`;
const applicationIds = [`${PREFIX}-app-notice`, `${PREFIX}-app-marketing`];
const channelIds = [`${PREFIX}-channel-mobile`, `${PREFIX}-channel-unicom`, `${PREFIX}-channel-telecom`];
const signatureIds = [`${PREFIX}-signature-service`, `${PREFIX}-signature-member`, `${PREFIX}-signature-cloud`];
const drainageIds = [`${PREFIX}-drainage-mall`, `${PREFIX}-drainage-event`];
const tenantName = '星河智联科技有限公司';
const applicationNames = ['星河通知中心', '星河会员营销'];
const channelNames = ['华东移动一号通道', '华北联通优质通道', '全国电信高速通道'];
const signatureNames = ['【星河服务】', '【星河会员】', '【星河云】'];
const now = new Date();
const dayMs = 86_400_000;
function id(suffix) {
return `${PREFIX}-${suffix}`;
}
function sha256(value) {
return createHash('sha256').update(value).digest('hex');
}
function dateDaysAgo(days, hour = 10, minute = 0) {
const value = new Date(now.getTime() - days * dayMs);
value.setHours(hour, minute, 0, 0);
return value;
}
function dateKeyDaysAgo(days) {
const value = dateDaysAgo(days, 0, 0);
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
}
function reportDate(days) {
return new Date(`${dateKeyDaysAgo(days)}T00:00:00.000Z`);
}
async function clearPreviousSeed() {
await prisma.protocolInteractionLog.deleteMany({ where: { id: { startsWith: PREFIX } } });
await prisma.smsMessageRecord.deleteMany({ where: { id: { startsWith: PREFIX } } });
await prisma.smsBatchTask.deleteMany({ where: { id: { startsWith: PREFIX } } });
await prisma.smsSendTask.deleteMany({ where: { id: { startsWith: PREFIX } } });
await prisma.dailyReconciliationReport.deleteMany({ where: { id: { startsWith: PREFIX } } });
await prisma.dailyProfitReport.deleteMany({ where: { id: { startsWith: PREFIX } } });
await prisma.dailyQualityReport.deleteMany({ where: { id: { startsWith: PREFIX } } });
}
async function ensureDimensions() {
await prisma.tenant.upsert({
where: { code: 'SCREENSHOT-DEMO' },
update: { name: tenantName, status: 'active', certificationStatus: 'approved' },
create: { id: tenantId, code: 'SCREENSHOT-DEMO', name: tenantName, status: 'active', certificationStatus: 'approved' },
});
await prisma.tenantAccount.upsert({
where: { tenantId },
update: { balanceCents: 268_560_00n, creditCents: 50_000_00n, status: 'active' },
create: { id: id('tenant-account'), tenantId, balanceCents: 268_560_00n, creditCents: 50_000_00n, status: 'active' },
});
for (let index = 0; index < applicationIds.length; index += 1) {
await prisma.smsApplication.upsert({
where: { cmppAccount: `SCREENSHOT_APP_${index + 1}` },
update: {
tenantId,
name: applicationNames[index],
status: 'active',
interfaceEnabled: true,
customerUnitPrice: BigInt(index === 0 ? 8 : 10),
},
create: {
id: applicationIds[index],
tenantId,
name: applicationNames[index],
scene: index === 0 ? '订单、验证码与服务通知' : '会员权益、节日活动与营销触达',
cmppAccount: `SCREENSHOT_APP_${index + 1}`,
cmppEnterpriseCode: `SG${String(index + 1).padStart(4, '0')}`,
cmppApplicationExtension: String(21 + index),
cmppClientSrcId: `1069008899${index + 1}`,
secretHash: sha256(`screenshot-local-secret-${index + 1}`),
interfaceEnabled: true,
interfaceType: index === 0 ? 'cmpp30' : 'http',
cmppMaxConnections: 3,
cmppWindowSize: 32,
dailyLimit: 500_000,
customerUnitPrice: BigInt(index === 0 ? 8 : 10),
queuePriority: index === 0 ? 'high' : 'normal',
status: 'active',
},
});
}
const channelMeta = [
{ code: 'SCREENSHOT-MOBILE', carrier: 'mobile', carriers: ['mobile'], price: 5 },
{ code: 'SCREENSHOT-UNICOM', carrier: 'unicom', carriers: ['unicom'], price: 5 },
{ code: 'SCREENSHOT-TELECOM', carrier: 'telecom', carriers: ['telecom'], price: 6 },
];
for (let index = 0; index < channelIds.length; index += 1) {
const meta = channelMeta[index];
await prisma.smsChannel.upsert({
where: { code: meta.code },
update: { name: channelNames[index], carrier: meta.carrier, carriers: meta.carriers, status: 'inactive', unitPrice: BigInt(meta.price) },
create: {
id: channelIds[index], code: meta.code, name: channelNames[index], carrier: meta.carrier,
carriers: meta.carriers, sendRegion: '全国', protocol: 'CMPP', gatewayHost: '127.0.0.1', gatewayPort: 17890 + index,
enterpriseCode: `SGCH${index + 1}`, account: `screenshot_channel_${index + 1}`,
passwordCipher: 'local-screenshot-seed-only', srcId: `10690088${index + 1}`, cmppVersion: '3.0',
rateLimitPerSecond: 300 + index * 100, unitPrice: BigInt(meta.price), status: 'inactive',
},
});
}
for (let index = 0; index < signatureIds.length; index += 1) {
await prisma.smsSignature.upsert({
where: { id: signatureIds[index] },
update: { name: signatureNames[index], auditStatus: 'approved', reportStatus: 'approved', pendingReport: false },
create: {
id: signatureIds[index], tenantId, applicationId: applicationIds[index % 2], name: signatureNames[index],
purpose: index === 1 ? '会员营销通知' : '交易与服务通知', auditStatus: 'approved', reportStatus: 'approved',
pendingReport: false, reportChangedAt: dateDaysAgo(45), createdAt: dateDaysAgo(60),
},
});
}
const drainageMeta = [
{ siteName: '星河优选商城', url: 'https://mall.example.test/benefits', remark: '会员积分兑换与新品活动页' },
{ siteName: '星河夏日活动', url: 'https://events.example.test/summer', remark: '夏日专属优惠活动落地页' },
];
for (let index = 0; index < drainageIds.length; index += 1) {
await prisma.smsDrainageInfo.upsert({
where: { id: drainageIds[index] },
update: { ...drainageMeta[index], auditStatus: 'approved', pendingReport: false },
create: {
id: drainageIds[index], tenantId, signatureId: signatureIds[1], applicationId: applicationIds[1],
...drainageMeta[index], reportValues: { ICP备案号: '浙ICP备20260088号', 业务类型: '会员权益活动' },
auditStatus: 'approved', pendingReport: false, reviewedAt: dateDaysAgo(35), submittedAt: dateDaysAgo(40),
},
});
}
}
async function seedSendTasks() {
const statuses = ['approved', 'pending_review', 'approved', 'rejected', 'approved', 'pending_review'];
const decisions = ['allow', 'manual_review', 'allow', 'reject', 'allow', 'manual_review'];
const contents = [
'【星河服务】您的订单已发货,物流单号已更新,请注意查收。',
'【星河会员】您的会员积分将于本月底到期,可登录官网查看权益。',
'【星河云】验证码 726418,5 分钟内有效,请勿告知他人。',
'【星河会员】夏日优选活动已开启,会员可享限时积分兑换权益。',
];
const rows = Array.from({ length: 24 }, (_, index) => {
const status = statuses[index % statuses.length];
return {
id: id(`send-task-${String(index + 1).padStart(3, '0')}`), tenantId,
applicationId: applicationIds[index % 2], taskNo: `ST-SHOT-${dateKeyDaysAgo(index % 8).replaceAll('-', '')}-${String(index + 1).padStart(4, '0')}`,
sourceType: index % 3 === 0 ? 'client' : 'risk', content: contents[index % contents.length],
category: index % 4 === 2 ? '验证码' : index % 2 === 0 ? '行业通知' : '会员营销',
phoneTotal: 180 + index * 37, uniquePhoneTotal: 176 + index * 35,
duplicateRatio: Number(((index % 5) * 0.012).toFixed(3)), illegalRatio: index % 7 === 0 ? 0.006 : 0,
blacklistHitRatio: index % 6 === 0 ? 0.009 : 0, variableIssues: index % 5 === 0 ? { missing: 2, extra: 0 } : null,
status, riskDecision: decisions[index % decisions.length],
reviewReason: status === 'pending_review' ? '命中大批量营销内容人工复核阈值' : null,
rejectReason: status === 'rejected' ? '营销内容缺少有效退订说明' : null,
reviewedAt: status === 'approved' || status === 'rejected' ? dateDaysAgo(index % 8, 11, index) : null,
createdAt: dateDaysAgo(index % 8, 9, index), updatedAt: dateDaysAgo(index % 8, 11, index),
};
});
await prisma.smsSendTask.createMany({ data: rows });
return rows;
}
function messageStatus(index, batchIndex) {
if (batchIndex === 10) return index < 7 ? 'submitted' : index < 12 ? 'queued' : 'scheduled';
if (batchIndex === 11) return index < 5 ? 'submit_failed' : index < 11 ? 'failed' : 'rejected';
const slot = (index * 7 + batchIndex * 3) % 20;
if (slot < 14) return 'delivered';
if (slot < 16) return 'submitted';
if (slot < 18) return 'failed';
if (slot === 18) return 'timeout';
return 'rejected';
}
async function seedBatchesAndMessages(sendTasks) {
const provinces = ['浙江', '江苏', '广东', '北京', '上海', '四川', '湖北', '山东'];
const carriers = ['mobile', 'unicom', 'telecom'];
const contents = [
'【星河服务】您的订单 XH20260822001 已完成支付,感谢您的使用。',
'【星河服务】您的快递已到达服务站,请凭取件码 8216 及时领取。',
'【星河云】登录验证码 726418,5 分钟内有效。',
'【星河会员】本周会员日权益已到账,点击活动页可查看详情。',
'【星河会员】夏日优选活动进行中,会员专享积分兑换已开启。',
];
const batchRows = [];
const messageRows = [];
for (let batchIndex = 0; batchIndex < 12; batchIndex += 1) {
const createdAt = dateDaysAgo(batchIndex % 7, 8 + (batchIndex % 9), batchIndex * 3);
const statuses = Array.from({ length: 20 }, (_, index) => messageStatus(index, batchIndex));
const submittedTotal = statuses.filter((value) => !['queued', 'scheduled', 'rejected'].includes(value)).length;
const successTotal = statuses.filter((value) => value === 'delivered').length;
const failedTotal = statuses.filter((value) => ['failed', 'submit_failed', 'rejected'].includes(value)).length;
const timeoutTotal = statuses.filter((value) => value === 'timeout').length;
const unknownTotal = statuses.filter((value) => value === 'submitted').length;
const batchStatus = batchIndex < 8 ? 'completed' : batchIndex < 10 ? 'sending' : batchIndex === 10 ? 'scheduled' : 'failed';
const batchId = id(`batch-${String(batchIndex + 1).padStart(3, '0')}`);
batchRows.push({
id: batchId, tenantId, applicationId: applicationIds[batchIndex % 2],
taskNo: `BT-SHOT-${dateKeyDaysAgo(batchIndex % 7).replaceAll('-', '')}-${String(batchIndex + 1).padStart(4, '0')}`,
sourceType: batchIndex % 3 === 0 ? 'http' : 'client', content: contents[batchIndex % contents.length],
category: batchIndex % 2 === 0 ? '行业通知' : '营销通知', phoneTotal: 20, status: batchStatus,
auditStatus: batchIndex === 11 ? 'rejected' : 'approved', progressTotal: 20, submittedTotal,
successTotal, failedTotal, unknownTotal, timeoutTotal,
scheduledAt: batchIndex === 10 ? new Date(now.getTime() + 2 * 60 * 60 * 1000) : null,
rejectReason: batchIndex === 11 ? '模板变量与受众字段不匹配' : null,
createdAt, updatedAt: new Date(createdAt.getTime() + 18 * 60 * 1000),
});
for (let index = 0; index < 20; index += 1) {
const status = statuses[index];
const carrier = carriers[(index + batchIndex) % carriers.length];
const queuedAt = new Date(createdAt.getTime() + index * 41_000);
const submittedAt = ['queued', 'scheduled', 'rejected'].includes(status) ? null : new Date(queuedAt.getTime() + 900 + (index % 5) * 240);
const deliveredAt = status === 'delivered' ? new Date(submittedAt.getTime() + 1800 + (index % 9) * 720) : null;
const units = index % 6 === 0 ? 2 : 1;
const hasDrainage = batchIndex % 2 === 1 && index % 4 === 0;
const messageContent = hasDrainage
? `${contents[batchIndex % contents.length]} 活动地址 https://events.example.test/summer`
: contents[batchIndex % contents.length];
messageRows.push({
id: id(`message-${String(batchIndex + 1).padStart(3, '0')}-${String(index + 1).padStart(3, '0')}`),
tenantId, batchTaskId: batchId, applicationId: applicationIds[batchIndex % 2],
signatureId: signatureIds[batchIndex % signatureIds.length], drainageInfoId: hasDrainage ? drainageIds[(batchIndex + index) % 2] : null,
reviewTaskId: sendTasks[(batchIndex * 2 + index) % sendTasks.length].id,
messageId: `MSG-SHOT-${String(batchIndex + 1).padStart(3, '0')}-${String(index + 1).padStart(4, '0')}`,
clientMessageId: `CLIENT-SHOT-${String(batchIndex + 1).padStart(3, '0')}-${String(index + 1).padStart(4, '0')}`,
phoneNumber: `1380013${String(8000 + batchIndex * 20 + index).slice(-4)}`,
carrier, province: provinces[(index + batchIndex * 2) % provinces.length], content: messageContent,
hasDrainageContent: hasDrainage,
drainageDetection: hasDrainage ? { matches: [{ start: messageContent.indexOf('https://'), end: messageContent.length, type: 'url' }], version: 'screenshot-v1' } : { matches: [], version: 'screenshot-v1' },
drainageDetectionVersion: 'screenshot-v1', drainageEvaluatedAt: queuedAt,
billingUnits: units, unitPrice: BigInt(batchIndex % 2 === 0 ? 8 : 10), amountCents: BigInt(units * (batchIndex % 2 === 0 ? 8 : 10)),
queuePriority: batchIndex % 3 === 0 ? 'high' : 'normal', channelId: channelIds[(index + batchIndex) % channelIds.length],
submitId: submittedAt ? `SUBMIT-SHOT-${batchIndex + 1}-${index + 1}` : null,
gatewayMessageId: submittedAt ? String(8_600_000_000_000 + batchIndex * 100 + index) : null,
status, submitStatus: status === 'rejected' ? 'rejected' : submittedAt ? 'accepted' : null,
receiptStatus: status === 'delivered' ? 'delivered' : status === 'failed' ? 'undelivered' : null,
receiptRawStatus: status === 'delivered' ? 'DELIVRD' : status === 'failed' ? 'UNDELIV' : null,
errorCode: status === 'submit_failed' ? 'CMPP-8' : status === 'failed' ? 'YX:0003' : status === 'timeout' ? 'RECEIPT_TIMEOUT' : status === 'rejected' ? 'RISK_REJECTED' : null,
errorMessage: status === 'submit_failed' ? '供应商通道暂时拒绝' : status === 'failed' ? '号码空号或停机' : status === 'timeout' ? '72 小时未收到最终回执' : status === 'rejected' ? '命中营销风控规则' : null,
queuedAt, submittedAt, deliveredAt, timeoutAt: status === 'timeout' ? new Date(queuedAt.getTime() + 72 * 60 * 60 * 1000) : null,
updatedAt: deliveredAt ?? submittedAt ?? queuedAt,
});
}
}
await prisma.smsBatchTask.createMany({ data: batchRows });
await prisma.smsMessageRecord.createMany({ data: messageRows });
return { batchRows, messageRows };
}
async function seedProtocolLogs(messages) {
const eventTypes = ['submit', 'submit_resp', 'deliver_receipt', 'deliver_resp', 'send_request', 'receipt_webhook', 'connect'];
const directions = ['client_to_platform', 'platform_to_channel', 'channel_to_platform', 'platform_to_client'];
const statuses = ['success', 'accepted', 'success', 'received', 'success', 'retrying', 'failed'];
const rows = Array.from({ length: 180 }, (_, index) => {
const message = messages[index % messages.length];
const eventType = eventTypes[index % eventTypes.length];
const protocol = index % 5 === 0 || index % 5 === 4 ? 'http' : 'cmpp';
const direction = directions[index % directions.length];
const status = statuses[index % statuses.length];
const daysAgo = index < 112 ? 0 : 1 + (index % 6);
const createdAt = dateDaysAgo(daysAgo, 8 + (index % 12), (index * 7) % 60);
return {
id: id(`protocol-${String(index + 1).padStart(4, '0')}`), protocol, direction, eventType, status,
tenantId, applicationId: message.applicationId, channelId: message.channelId,
account: protocol === 'http' ? `AK-SHOT-${(index % 2) + 1}` : `SCREENSHOT_APP_${(index % 2) + 1}`,
messageId: message.messageId, gatewayMessageId: message.gatewayMessageId,
traceId: `TRACE-SHOT-${String(index + 1).padStart(6, '0')}`,
requestId: protocol === 'http' ? `REQ-SHOT-${String(index + 1).padStart(6, '0')}` : null,
phoneNumber: message.phoneNumber, resultCode: status === 'failed' ? (protocol === 'http' ? '429' : '8') : status === 'retrying' ? 'RETRY-1' : '0',
durationMs: 18 + ((index * 37) % 680), payloadBytes: 96 + ((index * 53) % 1200), retryCount: status === 'retrying' ? 1 + (index % 3) : 0,
detail: { gateway: `local-gateway-${(index % 2) + 1}`, windowSize: 32, segmentCount: index % 9 === 0 ? 2 : 1, result: status }, createdAt,
};
});
await prisma.protocolInteractionLog.createMany({ data: rows });
return rows;
}
async function seedReports() {
const reconciliation = [];
const profit = [];
const quality = [];
for (let days = 1; days <= 30; days += 1) {
const date = reportDate(days);
for (let appIndex = 0; appIndex < applicationIds.length; appIndex += 1) {
const submitted = 8200 + ((days * 977 + appIndex * 2231) % 12_000);
const failed = 70 + ((days * 41 + appIndex * 67) % 260);
const unknown = 18 + ((days * 17 + appIndex * 13) % 95);
const success = submitted - failed - unknown;
const sent = success + failed + unknown;
reconciliation.push({
id: id(`recon-${days}-${appIndex}`), reportDate: date, tenantId, tenantName,
applicationId: applicationIds[appIndex], applicationName: applicationNames[appIndex],
submittedUnits: submitted, sentUnits: sent, unknownUnits: unknown, successUnits: success, failedUnits: failed,
generatedAt: dateDaysAgo(days - 1, 2, 12), updatedAt: dateDaysAgo(days - 1, 2, 12),
});
const revenue = BigInt(success * (appIndex === 0 ? 8 : 10));
const cost = BigInt(success * (appIndex === 0 ? 5 : 6));
const appProfit = revenue - cost;
profit.push({
id: id(`profit-app-${days}-${appIndex}`), reportDate: date, dimensionType: 'application', dimensionId: applicationIds[appIndex], dimensionName: applicationNames[appIndex],
tenantId, tenantName, applicationId: applicationIds[appIndex], submittedUnits: submitted, sentUnits: sent, unknownUnits: unknown, successUnits: success, failedUnits: failed,
revenueCents: revenue, refundCents: 0n, costCents: cost, profitCents: appProfit, profitRateBps: Number(appProfit * 10_000n / revenue),
generatedAt: dateDaysAgo(days - 1, 2, 18), updatedAt: dateDaysAgo(days - 1, 2, 18),
});
quality.push({
id: id(`quality-app-${days}-${appIndex}`), reportDate: date, dimensionType: 'application', dimensionId: applicationIds[appIndex], dimensionName: applicationNames[appIndex],
tenantId, tenantName, applicationId: applicationIds[appIndex], submittedUnits: submitted, sentUnits: sent, unknownUnits: unknown, successUnits: success, failedUnits: failed,
successRateBps: Math.round(success * 10_000 / sent), avgArrivalMs: 1800 + ((days * 173 + appIndex * 641) % 5200),
generatedAt: dateDaysAgo(days - 1, 2, 25), updatedAt: dateDaysAgo(days - 1, 2, 25),
});
}
for (let channelIndex = 0; channelIndex < channelIds.length; channelIndex += 1) {
const submitted = 4600 + ((days * 587 + channelIndex * 1871) % 8500);
const failed = 35 + ((days * 29 + channelIndex * 43) % 180);
const unknown = 12 + ((days * 11 + channelIndex * 7) % 72);
const success = submitted - failed - unknown;
const sent = submitted;
const revenue = BigInt(success * 9);
const cost = BigInt(success * (channelIndex === 2 ? 6 : 5));
const channelProfit = revenue - cost;
profit.push({
id: id(`profit-channel-${days}-${channelIndex}`), reportDate: date, dimensionType: 'channel', dimensionId: channelIds[channelIndex], dimensionName: channelNames[channelIndex],
channelId: channelIds[channelIndex], submittedUnits: submitted, sentUnits: sent, unknownUnits: unknown, successUnits: success, failedUnits: failed,
revenueCents: revenue, refundCents: 0n, costCents: cost, profitCents: channelProfit, profitRateBps: Number(channelProfit * 10_000n / revenue),
generatedAt: dateDaysAgo(days - 1, 2, 20), updatedAt: dateDaysAgo(days - 1, 2, 20),
});
quality.push({
id: id(`quality-channel-${days}-${channelIndex}`), reportDate: date, dimensionType: 'channel', dimensionId: channelIds[channelIndex], dimensionName: channelNames[channelIndex],
channelId: channelIds[channelIndex], submittedUnits: submitted, sentUnits: sent, unknownUnits: unknown, successUnits: success, failedUnits: failed,
successRateBps: Math.round(success * 10_000 / sent), avgArrivalMs: 1450 + ((days * 137 + channelIndex * 991) % 4800),
generatedAt: dateDaysAgo(days - 1, 2, 27), updatedAt: dateDaysAgo(days - 1, 2, 27),
});
}
for (let signatureIndex = 0; signatureIndex < signatureIds.length; signatureIndex += 1) {
const sent = 3300 + ((days * 431 + signatureIndex * 1201) % 6200);
const failed = 28 + ((days * 23 + signatureIndex * 31) % 130);
const unknown = 8 + ((days * 7 + signatureIndex * 5) % 45);
const success = sent - failed - unknown;
quality.push({
id: id(`quality-signature-${days}-${signatureIndex}`), reportDate: date, dimensionType: 'signature', dimensionId: signatureIds[signatureIndex], dimensionName: signatureNames[signatureIndex],
tenantId, tenantName, applicationId: applicationIds[signatureIndex % 2], signatureId: signatureIds[signatureIndex],
submittedUnits: sent, sentUnits: sent, unknownUnits: unknown, successUnits: success, failedUnits: failed,
successRateBps: Math.round(success * 10_000 / sent), avgArrivalMs: 1900 + ((days * 149 + signatureIndex * 733) % 5100),
generatedAt: dateDaysAgo(days - 1, 2, 29), updatedAt: dateDaysAgo(days - 1, 2, 29),
});
}
for (let drainageIndex = 0; drainageIndex < drainageIds.length; drainageIndex += 1) {
const sent = 1250 + ((days * 277 + drainageIndex * 881) % 3600);
const failed = 18 + ((days * 13 + drainageIndex * 19) % 85);
const unknown = 5 + ((days * 5 + drainageIndex * 3) % 28);
const success = sent - failed - unknown;
quality.push({
id: id(`quality-drainage-${days}-${drainageIndex}`), reportDate: date, dimensionType: 'drainage', dimensionId: drainageIds[drainageIndex], dimensionName: drainageIndex === 0 ? '星河优选商城' : '星河夏日活动',
tenantId, tenantName, applicationId: applicationIds[1], signatureId: signatureIds[1], drainageInfoId: drainageIds[drainageIndex],
submittedUnits: sent, sentUnits: sent, unknownUnits: unknown, successUnits: success, failedUnits: failed,
successRateBps: Math.round(success * 10_000 / sent), avgArrivalMs: 2300 + ((days * 181 + drainageIndex * 521) % 5800),
generatedAt: dateDaysAgo(days - 1, 2, 31), updatedAt: dateDaysAgo(days - 1, 2, 31),
});
}
}
await prisma.dailyReconciliationReport.createMany({ data: reconciliation, skipDuplicates: true });
await prisma.dailyProfitReport.createMany({ data: profit, skipDuplicates: true });
await prisma.dailyQualityReport.createMany({ data: quality, skipDuplicates: true });
return { reconciliation, profit, quality };
}
async function main() {
await clearPreviousSeed();
await ensureDimensions();
const sendTasks = await seedSendTasks();
const { batchRows, messageRows } = await seedBatchesAndMessages(sendTasks);
const protocolRows = await seedProtocolLogs(messageRows);
const reports = await seedReports();
console.log(JSON.stringify({
seed: PREFIX,
tenant: tenantName,
applications: applicationNames.length,
channels: channelNames.length,
sendTasks: sendTasks.length,
batchTasks: batchRows.length,
messages: messageRows.length,
protocolLogs: protocolRows.length,
reconciliationReports: reports.reconciliation.length,
profitReports: reports.profit.length,
qualityReports: reports.quality.length,
}, null, 2));
}
main()
.finally(async () => prisma.$disconnect())
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -64,7 +64,7 @@ for (const label of contract.interactionLabels) {
requirePattern( requirePattern(
style, style,
/@media\s*\(max-width:\s*900px\)[\s\S]*?\.admin-sms-record-card__meta[\s\S]*?\.admin-sms-segment-card dl/, /@media\s*\(max-width:\s*900px\)[\s\S]*?\.admin-sms-segment-card dl[\s\S]*?\.admin-sms-record-filter__field/,
'900px record/detail responsive rule is missing', '900px record/detail responsive rule is missing',
); );
requirePattern( requirePattern(
@@ -4,6 +4,7 @@ import { join } from 'node:path';
const examplesDir = join(process.cwd(), 'docs', 'contracts', 'examples'); const examplesDir = join(process.cwd(), 'docs', 'contracts', 'examples');
const envelopeFields = ['schemaVersion', 'messageType', 'traceId', 'messageId', 'channelId', 'createdAt']; const envelopeFields = ['schemaVersion', 'messageType', 'traceId', 'messageId', 'channelId', 'createdAt'];
const uplinkEnvelopeFields = envelopeFields.filter((field) => field !== 'messageId');
const requiredByType = { const requiredByType = {
SubmitCommand: [ SubmitCommand: [
...envelopeFields, ...envelopeFields,
@@ -21,7 +22,7 @@ const requiredByType = {
], ],
SubmitResult: [...envelopeFields, 'sequenceId', 'gatewayMessageId', 'submitStatus', 'submittedAt'], SubmitResult: [...envelopeFields, 'sequenceId', 'gatewayMessageId', 'submitStatus', 'submittedAt'],
ReceiptEvent: [...envelopeFields, 'sequenceId', 'gatewayMessageId', 'receiptStatus', 'rawStatus', 'deliveredAt'], ReceiptEvent: [...envelopeFields, 'sequenceId', 'gatewayMessageId', 'receiptStatus', 'rawStatus', 'deliveredAt'],
UplinkEvent: [...envelopeFields, 'sequenceId', 'phoneNumber', 'destId', 'content', 'receivedAt'], UplinkEvent: [...uplinkEnvelopeFields, 'sequenceId', 'gatewayMessageId', 'phoneNumber', 'destId', 'content', 'receivedAt'],
}; };
const validTypes = new Set(Object.keys(requiredByType)); const validTypes = new Set(Object.keys(requiredByType));