From a280b4bb2270fe7c8a697457db896568c67f40a9 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Thu, 27 Aug 2026 10:15:08 +0800 Subject: [PATCH] feat: densify sms records and improve uplink matching --- .../migration.sql | 2 + api/prisma/schema.prisma | 31 +- api/src/operations/operations.helpers.ts | 1 + api/src/operations/operations.service.spec.ts | 44 ++ api/src/send-chain/send-chain.contracts.ts | 1 + api/src/send-chain/send-chain.service.spec.ts | 3 +- .../send-downstream-delivery.service.ts | 1 + docs/all-menu-dialog-real-backend-audit.md | 10 +- docs/contracts/admin-sms-records-r11.json | 4 + docs/contracts/examples/uplink-event.json | 2 +- .../gateway-queue-messages.schema.json | 17 +- docs/system-functional-test-cases.md | 31 +- docs/testing-progress.md | 16 + gateway/internal/queue/messages.go | 13 +- gateway/internal/upstream/deliver.go | 13 +- gateway/internal/upstream/deliver_test.go | 48 ++ src/api/types/operations.ts | 1 + src/apps/admin/AdminSmsUplinkRecordsPage.tsx | 70 ++- .../admin/sms-records/AdminSmsRecordsPage.css | 180 +++++--- .../admin/sms-records/SmsRecordFilter.tsx | 18 +- src/apps/admin/sms-records/SmsRecordList.tsx | 71 +-- src/apps/client/ClientUplinkMessagesPage.tsx | 16 +- tools/local/seed-screenshot-materials.mjs | 415 ++++++++++++++++++ .../quality/verify-admin-sms-records-r11.mjs | 2 +- .../spike/validate-gateway-queue-contract.mjs | 3 +- 25 files changed, 859 insertions(+), 154 deletions(-) create mode 100644 api/prisma/migrations/20260826170000_add_uplink_gateway_message_id/migration.sql create mode 100644 tools/local/seed-screenshot-materials.mjs diff --git a/api/prisma/migrations/20260826170000_add_uplink_gateway_message_id/migration.sql b/api/prisma/migrations/20260826170000_add_uplink_gateway_message_id/migration.sql new file mode 100644 index 0000000..0e70098 --- /dev/null +++ b/api/prisma/migrations/20260826170000_add_uplink_gateway_message_id/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "SmsUplinkMessage" +ADD COLUMN "gatewayMessageId" TEXT; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 7188c52..58a44e5 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -2116,21 +2116,22 @@ model SmsReceiptAnomaly { } model SmsUplinkMessage { - id String @id @default(cuid()) - eventId String? @unique - tenantId String? - applicationId String? - channelId String - messageRecordId String? - messageId String? - sequenceId Int? - phoneNumber String - destId String - content String - matchStatus String @default("unmatched") - matchReason String? - receivedAt DateTime - createdAt DateTime @default(now()) + id String @id @default(cuid()) + eventId String? @unique + tenantId String? + applicationId String? + channelId String + messageRecordId String? + messageId String? + gatewayMessageId String? + sequenceId Int? + phoneNumber String + destId String + content String + matchStatus String @default("unmatched") + matchReason String? + receivedAt DateTime + createdAt DateTime @default(now()) tenant Tenant? @relation(fields: [tenantId], references: [id]) application SmsApplication? @relation(fields: [applicationId], references: [id]) diff --git a/api/src/operations/operations.helpers.ts b/api/src/operations/operations.helpers.ts index df37a45..2712999 100644 --- a/api/src/operations/operations.helpers.ts +++ b/api/src/operations/operations.helpers.ts @@ -303,6 +303,7 @@ export function clientUplinkView(message: Record) { applicationId: message.applicationId ?? null, messageRecordId: message.messageRecordId ?? null, messageId: message.messageId ?? null, + gatewayMessageId: message.gatewayMessageId ?? null, phoneNumber: message.phoneNumber, destId: message.destId, content: message.content, diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 978a4e6..ad6326a 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -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 () => { const prisma = createPrismaMock(); prisma.smsMessageRecord.findMany.mockResolvedValue([{ diff --git a/api/src/send-chain/send-chain.contracts.ts b/api/src/send-chain/send-chain.contracts.ts index a618d37..8489c13 100644 --- a/api/src/send-chain/send-chain.contracts.ts +++ b/api/src/send-chain/send-chain.contracts.ts @@ -118,6 +118,7 @@ export interface GatewayUplinkEventDto { eventId?: string; traceId?: string; messageId?: string; + gatewayMessageId?: string; channelId: string; sequenceId?: number; phoneNumber: string; diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 8978004..95d17ed 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -3749,6 +3749,7 @@ describe('SendChainService', () => { }); await service.handleUplink({ messageId: 'MSG-1', + gatewayMessageId: '8412634832294102675', channelId: 'channel-1', sequenceId: 8, phoneNumber: '13800000001', @@ -3761,7 +3762,7 @@ describe('SendChainService', () => { data: expect.objectContaining({ receiptStatus: 'delivered', rawStatus: 'DELIVRD', messageRecordId: 'record-1' }), }); 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' }), }); }); diff --git a/api/src/send-chain/send-downstream-delivery.service.ts b/api/src/send-chain/send-downstream-delivery.service.ts index e1f4c4d..d6e1347 100644 --- a/api/src/send-chain/send-downstream-delivery.service.ts +++ b/api/src/send-chain/send-downstream-delivery.service.ts @@ -45,6 +45,7 @@ export class SendDownstreamDeliveryService { messageRecordId: match.messageRecordId, channelId: data.channelId, messageId: data.messageId, + gatewayMessageId: data.gatewayMessageId, sequenceId: data.sequenceId, phoneNumber: data.phoneNumber, destId: data.destId, diff --git a/docs/all-menu-dialog-real-backend-audit.md b/docs/all-menu-dialog-real-backend-audit.md index aa4094f..9fffe4e 100644 --- a/docs/all-menu-dialog-real-backend-audit.md +++ b/docs/all-menu-dialog-real-backend-audit.md @@ -110,8 +110,9 @@ 修复: - 已接入 `GET /api/client/operations/uplink-messages`。 -- 详情弹窗按上行 `messageId` 查询 `GET /api/client/operations/messages`,展示真实匹配的下发记录。 -- 原无 API 支撑的“添加到应用黑名单”按钮已移除,后续补真实企业黑名单或应用黑名单 API 后再恢复。 +- 详情优先展示上行列表响应中已匹配的真实 `messageRecord`;仅对历史兼容数据在缺少内嵌记录且存在平台 `messageId` 时回查 `GET /api/client/operations/messages`。 +- 页面分开展示供应商 MO 的上行网关消息 ID 与关联平台消息 ID,不再把缺少平台 `messageId` 误判为没有匹配记录。 +- 客户端保持只读,不提供越权的运营黑名单写操作。 ### 运营端短信上行记录仍是静态表 @@ -133,8 +134,9 @@ 修复: - 已接入 `GET /api/admin/operations/uplink-messages`,返回真实上行记录、企业和通道信息。 -- 详情弹窗按上行 `messageId` 查询 `GET /api/admin/operations/messages`,展示真实匹配的下发记录。 -- 原无 API 支撑的“添加到应用黑名单”按钮已移除,后续补真实企业黑名单或应用黑名单 API 后再恢复。 +- 详情优先展示上行列表响应中已匹配的真实 `messageRecord`;仅对历史兼容数据在缺少内嵌记录且存在平台 `messageId` 时回查 `GET /api/admin/operations/messages`。 +- 页面分开展示供应商 MO 的上行网关消息 ID 与关联平台消息 ID,并显示匹配状态和原因。 +- 已匹配或人工认领到企业应用后,恢复“加入应用黑名单”按钮并调用真实 `POST /api/admin/dictionaries/blacklists/enterprise`;未匹配应用时不允许写入。 ### 运营端短信任务进度仍是静态任务 diff --git a/docs/contracts/admin-sms-records-r11.json b/docs/contracts/admin-sms-records-r11.json index f3b01c5..e641cd4 100644 --- a/docs/contracts/admin-sms-records-r11.json +++ b/docs/contracts/admin-sms-records-r11.json @@ -37,6 +37,8 @@ ".admin-sms-record-table-card", ".admin-sms-record-toolbar", ".admin-sms-record-list", + ".admin-sms-record-list__header", + ".admin-sms-record-group-title", ".admin-sms-record-card", ".admin-sms-record-status", ".admin-sms-record-detail-link", @@ -66,10 +68,12 @@ "短信内容", "通道名称", "发送状态", + "是否含引流信息", "查询", "重置", "导出CSV", "查看发送详情", + "分片", "发送详情", "通道发送与回执", "分片补偿审计" diff --git a/docs/contracts/examples/uplink-event.json b/docs/contracts/examples/uplink-event.json index 41c4cfb..fba954e 100644 --- a/docs/contracts/examples/uplink-event.json +++ b/docs/contracts/examples/uplink-event.json @@ -2,10 +2,10 @@ "schemaVersion": "v1", "messageType": "UplinkEvent", "traceId": "trace-20260701-uplink-000001", - "messageId": "uplink-20260701-000001", "channelId": "sms-channel-cmpp-001", "createdAt": "2026-07-01T09:01:00.000Z", "sequenceId": 4096, + "gatewayMessageId": "8412634832294102675", "phoneNumber": "13800138000", "destId": "106900000000", "content": "TD", diff --git a/docs/contracts/gateway-queue-messages.schema.json b/docs/contracts/gateway-queue-messages.schema.json index 696998b..1f9bdee 100644 --- a/docs/contracts/gateway-queue-messages.schema.json +++ b/docs/contracts/gateway-queue-messages.schema.json @@ -24,6 +24,18 @@ "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": { "allOf": [ { "$ref": "#/$defs/Envelope" }, @@ -180,13 +192,14 @@ }, "UplinkEvent": { "allOf": [ - { "$ref": "#/$defs/Envelope" }, + { "$ref": "#/$defs/UplinkEnvelope" }, { "type": "object", - "required": ["messageType", "sequenceId", "phoneNumber", "destId", "content", "receivedAt"], + "required": ["messageType", "sequenceId", "gatewayMessageId", "phoneNumber", "destId", "content", "receivedAt"], "properties": { "messageType": { "const": "UplinkEvent" }, "sequenceId": { "type": "integer", "minimum": 0 }, + "gatewayMessageId": { "type": "string", "minLength": 1 }, "phoneNumber": { "type": "string", "pattern": "^1[3-9][0-9]{9}$" }, "destId": { "type": "string", "minLength": 1 }, "content": { "type": "string", "minLength": 1 }, diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 52dcfd5..58a6395 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -234,9 +234,10 @@ 2. 按手机号和时间查询。 3. 查看上行关联下发记录。 - 预期结果: - - 展示上行内容、接入号、接收时间。 - - 可展示匹配到的下发 messageId。 - - 未匹配上行仍可查询,状态或关联为空。 + - 展示上行内容、接入号、接收时间、上行网关消息 ID、匹配状态和匹配说明。 + - 后端已通过接入号或手机号时间窗匹配时,即使上行事件没有关联平台 `messageId`,详情仍直接展示响应中嵌入的真实下发记录及其平台 `messageId`。 + - 上行网关消息 ID 与关联平台消息 ID 分栏展示,不把供应商 MO `Msg_Id` 误作历史 MT Submit 消息 ID。 + - 未匹配或多候选上行仍可查询,并显示真实状态;多候选提示联系运营人员认领。 ### TC-CLIENT-010 用户管理与企业管理员唯一性 @@ -1139,11 +1140,16 @@ ### TC-SEND-008 上行短信匹配 - 优先级:P1 -- 步骤:模拟带 messageId 的上行事件。 +- 步骤: + 1. 模拟带平台 `messageId` 的兼容上行事件。 + 2. 模拟真实供应商 MO:仅带独立 `gatewayMessageId`,不带历史平台 `messageId`,并分别制造接入号唯一匹配、手机号 72 小时唯一匹配和多候选场景。 + 3. 打开客户端和运营端详情。 - 预期结果: - 创建 SmsUplinkMessage。 - - tenantId 可通过 messageId 关联。 - - 客户端和运营端均可查询。 + - `gatewayMessageId` 原样持久化;兼容事件仍可通过平台 `messageId` 精确关联。 + - 不带平台 `messageId` 时按接入号、手机号 72 小时窗口执行匹配;唯一结果写入 `tenantId/applicationId/messageRecordId`,多候选保留为 `ambiguous`。 + - 客户端和运营端均可查询;详情优先使用 API 响应内嵌 `messageRecord`,不因 `messageId` 为空误报“无法匹配”。 + - 运营端对已匹配或已认领应用显示“加入应用黑名单”,确认后调用真实企业应用黑名单 API;客户端保持只读。 ### TC-SEND-009 未匹配上行短信入库 @@ -1151,6 +1157,7 @@ - 步骤:模拟不带 messageId 或匹配不到下发记录的上行事件。 - 预期结果: - 上行短信仍入库。 + - 供应商 MO `gatewayMessageId` 与平台 `messageId` 分别保存,不能用前者伪造后者的关联。 - tenantId 可为空。 - 运营端可查询并人工判断。 @@ -4890,3 +4897,15 @@ npm run verify:phase8 | 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/3575;2145个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通过。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 4716ee4..62ba612 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -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.3;Prometheus加载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均active,API/Gateway健康;供应商连接`desired=9/connected=9`,命令/结果 Stream 最终均`pending=0/lag=0`。 - 浏览器只读验收已到达预生产运营端登录页,因当前浏览器没有运营端登录会话,没有输入凭据或验证码,故本轮未把登录后的页面截图作为验收证据;监控可用性以 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未实施。 diff --git a/gateway/internal/queue/messages.go b/gateway/internal/queue/messages.go index 05f3180..2765de6 100644 --- a/gateway/internal/queue/messages.go +++ b/gateway/internal/queue/messages.go @@ -19,7 +19,7 @@ type Envelope struct { SchemaVersion string `json:"schemaVersion"` MessageType MessageType `json:"messageType"` TraceID string `json:"traceId"` - MessageID string `json:"messageId"` + MessageID string `json:"messageId,omitempty"` ChannelID string `json:"channelId"` CreatedAt time.Time `json:"createdAt"` } @@ -118,11 +118,12 @@ type ReceiptEvent struct { type UplinkEvent struct { Envelope - SequenceID uint32 `json:"sequenceId"` - PhoneNumber string `json:"phoneNumber"` - DestID string `json:"destId"` - Content string `json:"content"` - ReceivedAt time.Time `json:"receivedAt"` + SequenceID uint32 `json:"sequenceId"` + GatewayMessageID string `json:"gatewayMessageId"` + PhoneNumber string `json:"phoneNumber"` + DestID string `json:"destId"` + Content string `json:"content"` + ReceivedAt time.Time `json:"receivedAt"` } type ConnectChannelCommand struct { diff --git a/gateway/internal/upstream/deliver.go b/gateway/internal/upstream/deliver.go index 683bb5f..504a6ea 100644 --- a/gateway/internal/upstream/deliver.go +++ b/gateway/internal/upstream/deliver.go @@ -124,14 +124,15 @@ func (c *connection) handleDeliver(pkt deliverPacket) error { ChannelID: c.channelID, CreatedAt: time.Now().UTC(), }, - SequenceID: pkt.seqID, - PhoneNumber: strings.TrimSpace(pkt.srcTerminalID), - DestID: strings.TrimSpace(pkt.destID), - Content: content, - ReceivedAt: time.Now().UTC(), + SequenceID: pkt.seqID, + GatewayMessageID: fmt.Sprint(pkt.msgID), + PhoneNumber: strings.TrimSpace(pkt.srcTerminalID), + DestID: strings.TrimSpace(pkt.destID), + Content: content, + ReceivedAt: time.Now().UTC(), } 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 if c.eventPublisher != nil { diff --git a/gateway/internal/upstream/deliver_test.go b/gateway/internal/upstream/deliver_test.go index 15ef770..b9adad9 100644 --- a/gateway/internal/upstream/deliver_test.go +++ b/gateway/internal/upstream/deliver_test.go @@ -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) { for _, stat := range []string{"UNKNOWN", "UNDELIV", "EXPIRED", "DELETED", "REJECTD"} { if got := receiptStatus(stat); got != "undelivered" { diff --git a/src/api/types/operations.ts b/src/api/types/operations.ts index 8a6eff7..9bfa221 100644 --- a/src/api/types/operations.ts +++ b/src/api/types/operations.ts @@ -225,6 +225,7 @@ export type SmsUplinkMessage = { applicationId?: string | null; messageRecordId?: string | null; messageId?: string | null; + gatewayMessageId?: string | null; sequenceId?: number | null; phoneNumber: string; destId: string; diff --git a/src/apps/admin/AdminSmsUplinkRecordsPage.tsx b/src/apps/admin/AdminSmsUplinkRecordsPage.tsx index a976539..e6ed8ef 100644 --- a/src/apps/admin/AdminSmsUplinkRecordsPage.tsx +++ b/src/apps/admin/AdminSmsUplinkRecordsPage.tsx @@ -1,5 +1,5 @@ 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 { Breadcrumb, @@ -40,28 +40,41 @@ function candidateStatusText(status?: string | null) { } function UplinkDetailModal({ + blacklistFeedback, + blacklisting, claimError, claimingId, detailError, matchedRecords, matching, message, + onAddBlacklist, onClaim, onClose, }: { + blacklistFeedback: string; + blacklisting: boolean; claimError: string; claimingId: string; detailError: string; matchedRecords: SmsMessageRecord[]; matching: boolean; message: SmsUplinkMessage; + onAddBlacklist: () => void; onClaim: (candidate: SmsUplinkMatchCandidate) => void; onClose: () => void; }) { const candidates = message.matchCandidates ?? []; return ( 关闭} + footer={<> + {message.tenantId && message.applicationId ? ( + + ) : null} + + } onClose={onClose} open size="xl" @@ -92,13 +105,21 @@ function UplinkDetailModal({ {message.destId || '-'}
- 网关消息ID - {message.messageId || '-'} + 上行网关消息ID + {message.gatewayMessageId || '-'} +
+
+ 关联平台消息ID + {message.messageRecord?.messageId ?? message.messageId ?? '-'}
匹配状态 {matchStatusText(message.matchStatus)}
+
+ 匹配说明 + {message.matchReason || '-'} +
上行内容 {message.content || '-'} @@ -160,11 +181,13 @@ function UplinkDetailModal({

匹配发送记录

+ {blacklistFeedback ?

{blacklistFeedback}

: null} {matching ?

正在查询真实下发记录...

: null} {detailError ?

{detailError}

: null} - {!matching && !message.messageId ?
该上行记录没有网关消息ID,无法匹配下发记录
: null} - {!matching && message.messageId && matchedRecords.length === 0 && !detailError ? ( -
暂无匹配发送记录
+ {!matching && matchedRecords.length === 0 && !detailError ? ( +
+ {message.matchStatus === 'ambiguous' ? '存在多个候选,请先认领正确应用' : '暂无匹配发送记录'} +
) : null} {matchedRecords.map((record) => (
@@ -211,6 +234,8 @@ export function AdminSmsUplinkRecordsPage() { const [error, setError] = useState(''); const [detailError, setDetailError] = useState(''); const [claimError, setClaimError] = useState(''); + const [blacklisting, setBlacklisting] = useState(false); + const [blacklistFeedback, setBlacklistFeedback] = useState(''); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const pageSize = 10; @@ -236,11 +261,12 @@ export function AdminSmsUplinkRecordsPage() { function openDetail(message: SmsUplinkMessage) { setSelectedMessage(message); - setMatchedRecords([]); + setMatchedRecords(message.messageRecord ? [message.messageRecord] : []); setDetailError(''); setClaimError(''); + setBlacklistFeedback(''); - if (!message.messageId) { + if (message.messageRecord || !message.messageId) { return; } @@ -251,6 +277,28 @@ export function AdminSmsUplinkRecordsPage() { .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(() => { loadData(page); }, [page]); @@ -273,6 +321,7 @@ export function AdminSmsUplinkRecordsPage() { .then((updated) => { setMessages((items) => items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item))); setSelectedMessage((current) => (current && current.id === updated.id ? { ...current, ...updated } : current)); + setMatchedRecords(updated.messageRecord ? [updated.messageRecord] : []); loadData(page); }) .catch((reason: Error) => setClaimError(reason.message || '上行认领失败')) @@ -332,12 +381,15 @@ export function AdminSmsUplinkRecordsPage() { {selectedMessage ? ( setSelectedMessage(null)} /> diff --git a/src/apps/admin/sms-records/AdminSmsRecordsPage.css b/src/apps/admin/sms-records/AdminSmsRecordsPage.css index 42855e9..382b53e 100644 --- a/src/apps/admin/sms-records/AdminSmsRecordsPage.css +++ b/src/apps/admin/sms-records/AdminSmsRecordsPage.css @@ -5,14 +5,29 @@ .admin-sms-record-filter { align-items: end; display: grid; - gap: var(--space-5); - grid-template-columns: repeat(4, minmax(180px, 1fr)); + gap: var(--space-4); + 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 { display: grid; 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 { @@ -25,8 +40,8 @@ border-bottom: 1px solid var(--color-border); display: flex; justify-content: flex-end; - min-height: 76px; - padding: var(--space-4) var(--space-6); + min-height: 56px; + padding: var(--space-2) var(--space-4); } .admin-sms-record-table-card .ui-table-wrap { @@ -35,52 +50,64 @@ } .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; - gap: var(--space-2); - padding: var(--space-3); + gap: 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 { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - display: grid; - gap: var(--space-3); - padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); + min-height: 72px; + padding: var(--space-2) var(--space-3); } .admin-sms-record-card:hover { - border-color: color-mix(in srgb, var(--color-selected) 35%, var(--color-border)); - 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); + background: color-mix(in srgb, var(--color-selected) 4%, var(--color-surface)); } .admin-sms-record-card .admin-sms-record-content { - background: var(--color-bg-subtle); - border-radius: var(--radius-md); - display: -webkit-box; - line-height: 1.55; + line-height: 1.45; max-width: none; + min-width: 0; overflow: hidden; - padding: var(--space-2) var(--space-3); - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; + padding: 0; + text-overflow: ellipsis; + white-space: nowrap; } .admin-sms-record-card .admin-sms-record-content.is-drainage { - background: color-mix(in srgb, #f59e0b 13%, var(--color-surface)); - border: 1px solid color-mix(in srgb, #f59e0b 34%, var(--color-border)); + color: #92400e; } .admin-sms-record-content mark { @@ -95,7 +122,7 @@ display: inline-flex; font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); - margin-left: var(--space-3); + margin-left: var(--space-2); padding: 1px var(--space-2); vertical-align: middle; } @@ -111,32 +138,43 @@ color: var(--color-text-muted); } -.admin-sms-record-card__meta { - display: grid; - gap: var(--space-4); - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.admin-sms-record-card__meta > div { +.admin-sms-record-main { display: grid; gap: var(--space-1); + min-width: 0; } -.admin-sms-record-card__meta span, -.admin-sms-record-card__meta small { +.admin-sms-record-context { + 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); } -.admin-sms-record-card__meta strong { +.admin-sms-record-time strong, +.admin-sms-record-billing 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 { @@ -221,11 +259,21 @@ } .admin-sms-record-detail-link { - background: transparent; - border: 0; + align-items: center; + background: var(--color-bg-subtle); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); color: var(--color-selected); - font-weight: var(--font-weight-semibold); + display: inline-flex; + height: 32px; + justify-content: center; 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 { @@ -392,8 +440,6 @@ } @media (max-width: 900px) { - .admin-sms-record-card > header, - .admin-sms-record-card__meta, .admin-sms-detail-overview, .admin-sms-detail-status-grid, .admin-sms-route-list dl, @@ -402,8 +448,11 @@ grid-template-columns: 1fr; } - .admin-sms-record-card > header time { - justify-self: start; + .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: span 6; } } @@ -463,4 +512,11 @@ .admin-sms-route-list dl { 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; + } } diff --git a/src/apps/admin/sms-records/SmsRecordFilter.tsx b/src/apps/admin/sms-records/SmsRecordFilter.tsx index 63c6554..43cd889 100644 --- a/src/apps/admin/sms-records/SmsRecordFilter.tsx +++ b/src/apps/admin/sms-records/SmsRecordFilter.tsx @@ -85,15 +85,15 @@ export function SmsRecordFilter({ }: SmsRecordFilterProps) { return (
- onApplicationChange(event.target.value)} options={applicationOptions} value={application} /> - - onPhoneKeywordChange(event.target.value)} prefix={} value={phoneKeyword} /> - onContentKeywordChange(event.target.value)} value={contentKeyword} /> - onStatusChange(event.target.value)} options={statusOptions} value={status} /> - onEnterpriseChange(event.target.value)} options={enterpriseOptions} value={enterprise} />
+
onPhoneKeywordChange(event.target.value)} prefix={} value={phoneKeyword} />
+
onContentKeywordChange(event.target.value)} value={contentKeyword} />
+
onStatusChange(event.target.value)} options={statusOptions} value={status} />
+