diff --git a/api/prisma/migrations/20260711210000_add_message_route_identity/migration.sql b/api/prisma/migrations/20260711210000_add_message_route_identity/migration.sql new file mode 100644 index 0000000..ca90a87 --- /dev/null +++ b/api/prisma/migrations/20260711210000_add_message_route_identity/migration.sql @@ -0,0 +1,21 @@ +ALTER TABLE "SmsMessageRecord" +ADD COLUMN "carrier" TEXT, +ADD COLUMN "province" TEXT; + +UPDATE "SmsMessageRecord" AS message +SET "province" = segment."province" +FROM "PhoneSegment" AS segment +WHERE segment."prefix" = left(message."phoneNumber", 7); + +UPDATE "SmsMessageRecord" AS message +SET "carrier" = COALESCE(( + SELECT rule."carrier" + FROM "PhoneCarrierRule" AS rule + WHERE rule."status" = 'active' + AND message."phoneNumber" ~ rule."pattern" + ORDER BY rule."priority" ASC, rule."createdAt" ASC + LIMIT 1 +), 'mobile'); + +CREATE INDEX "SmsMessageRecord_carrier_province_idx" +ON "SmsMessageRecord"("carrier", "province"); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 6da1a44..596ea73 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -901,6 +901,8 @@ model SmsMessageRecord { templateId String? messageId String @unique phoneNumber String + carrier String? + province String? content String billingUnits Int @default(1) unitPrice Int @default(0) diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 02a2bbe..f6f687a 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -595,6 +595,10 @@ describe('SendChainService', () => { expect(prisma.smsSubmitRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ messageRecordId: 'record-1', channelId: 'channel-1', submitStatus: 'queued' }), }); + expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ + where: { id: 'record-1' }, + data: expect.objectContaining({ channelId: 'channel-1', carrier: 'mobile', province: '山东', status: 'submit_queued' }), + }); expect(gatewayAdd).toHaveBeenCalledWith( 'submit-command', expect.objectContaining({ @@ -613,6 +617,23 @@ describe('SendChainService', () => { expect(service['postGatewayControl']).not.toHaveBeenCalledWith('/upstream/submit', expect.anything()); }); + it('persists identified carrier and province before a route lookup fails', async () => { + const { service, prisma } = createService(); + prisma.channelRouteRule.findFirst.mockResolvedValueOnce(null); + + await expect(service['selectChannelForMessage']({ + id: 'record-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + phoneNumber: '13800000001', + })).rejects.toThrow('企业应用未配置对应运营商通道组'); + + expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ + where: { id: 'record-1' }, + data: { carrier: 'mobile', province: '山东' }, + }); + }); + it('updates submit result status, charges billing, and task progress', async () => { const { service, prisma, billing } = createService(); diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index eb4062b..ea45e11 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -1640,6 +1640,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { where: { id: message.id }, data: { channelId: channel.id, + carrier: routed.carrier, + province: routed.province, submitId, status: 'submit_queued', submitStatus: 'queued', @@ -1747,15 +1749,19 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } private async selectChannelForMessage( - message: { tenantId: string; applicationId?: string | null; phoneNumber: string }, + message: { id: string; tenantId: string; applicationId?: string | null; phoneNumber: string }, options: { forceNational?: boolean; excludeChannelIds?: string[] } = {}, ): Promise { if (!message.applicationId) { throw new BadRequestException('短信应用未配置,无法选择通道组'); } const carrier = await this.identifyCarrier(message.phoneNumber); - const route = await this.findApplicationRoute(message.tenantId, message.applicationId, carrier); const province = await this.identifyProvince(message.phoneNumber); + await this.prisma.smsMessageRecord.update({ + where: { id: message.id }, + data: { carrier, province }, + }); + const route = await this.findApplicationRoute(message.tenantId, message.applicationId, carrier); const excluded = new Set(options.excludeChannelIds ?? []); const items = route.group.items.filter((item) => !excluded.has(item.channelId) diff --git a/docs/testing-progress.md b/docs/testing-progress.md index d0fd90a..5d35916 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1581,3 +1581,11 @@ git diff --check - API 列表/详情查询会将最近心跳超过 `CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS`(默认 90 秒)的会话标记为 `heartbeat_timeout`;运营端展示真实客户端 IP、企业代码与最近心跳。移除了不能真正关闭 TCP 连接的运营端“删除连接”伪操作。 - 已执行 `sms-config.service.spec.ts`(17 项通过)、API 全量测试(13 suites、121 项)、Gateway 全量 `go test ./...`、API build 和前端 build;前端仅有既有 chunk size warning。 - 已部署生产验证:第 26 条 Prisma migration `20260711193000_add_cmpp_downstream_connections` 已成功应用,`CmppDownstreamConnection` 表存在。`cmpp-api`、`cmpp-gateway`、Nginx、MinIO 均为 active,`12026/17890/8090` 监听及 API/Gateway health 均通过。部署时没有保持在线的客户 bind 会话,故新表初始为 0 条;下一次真实 CMPP bind 将作为生产数据验收样本写入该表。 + +## 2026-07-11 短信号码运营商与省份持久化 + +- 确认发送链路在进入应用通道组路由后,会先按真实 `PhoneCarrierRule` 正则识别号码运营商,再按 `PhoneSegment` 号段库识别省份。本轮不扩展地市字段。 +- `SmsMessageRecord` 新增 `carrier/province`,路由阶段完成号码识别后立即持久化;即使后续缺少通道组或无在线通道而失败,短信记录仍保留识别结果。选中通道时与 `channelId/submitId` 再次同步写入;Prisma migration 使用真实号段库和生效运营商规则回填已有短信记录。 +- 运营端短信记录列表、发送详情和 CSV 导出展示记录上的号码省份/运营商,不再以通道发送地区或通道本体 carrier 冒充号码归属。 +- 短信任务进度详情中的“号码运营商分布”和“号码省份分布”均直接聚合任务内真实短信记录的 `carrier/province`。 +- 已执行 `npm --prefix api test -- --runInBand send-chain.service.spec.ts`(35 项通过)、`npm --prefix api run build`、`npm run build` 和 `git diff --check`;前端仅有既有 chunk size warning。尚未部署生产,生产 migration/API/页面验收待部署时执行。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 353978d..12f5a84 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -350,6 +350,8 @@ export type SmsMessageRecord = { channelId?: string | null; messageId: string; phoneNumber: string; + carrier?: string | null; + province?: string | null; content: string; billingUnits: number; amountCents: number; diff --git a/src/apps/admin/AdminSmsRecordsPage.tsx b/src/apps/admin/AdminSmsRecordsPage.tsx index b5fcdb8..ca33337 100644 --- a/src/apps/admin/AdminSmsRecordsPage.tsx +++ b/src/apps/admin/AdminSmsRecordsPage.tsx @@ -143,8 +143,8 @@ function downloadCsv(records: SmsMessageRecord[]) { record.application?.name ?? record.applicationId ?? '', getTime(record.queuedAt), record.phoneNumber, - record.channel?.sendRegion ?? '', - getCarrierLabel(record.channel?.carrier), + record.province ?? '', + getCarrierLabel(record.carrier), record.billingUnits, (record.amountCents / 100).toFixed(3), record.channel?.name ?? record.channelId ?? '', @@ -202,6 +202,10 @@ function SendDetailModal({ 提交时间 {getTime(record.queuedAt)} +
+ 号码归属 + {record.province ?? '-'} / {getCarrierLabel(record.carrier)} +

短信内容

@@ -440,7 +444,7 @@ export function AdminSmsRecordsPage() {
{record.phoneNumber} - {record.channel?.sendRegion ?? '-'} {getCarrierLabel(record.channel?.carrier)} + {record.province ?? '-'} {getCarrierLabel(record.carrier)} {record.content.length}字/{record.billingUnits}条
diff --git a/src/apps/admin/AdminSmsTaskProgressPage.tsx b/src/apps/admin/AdminSmsTaskProgressPage.tsx index 8d28a66..6b7bdef 100644 --- a/src/apps/admin/AdminSmsTaskProgressPage.tsx +++ b/src/apps/admin/AdminSmsTaskProgressPage.tsx @@ -103,7 +103,7 @@ function countMessages(messages: SmsMessageRecord[] | undefined, statuses: strin function buildCarrierStats(messages: SmsMessageRecord[] | undefined): CarrierStat[] { const stats = new Map(); (messages ?? []).forEach((message) => { - const carrier = message.channel?.carrier ?? 'unknown'; + const carrier = message.carrier ?? 'unknown'; const meta = carrierLabels[carrier] ?? { label: carrier || '未知通道', tone: 'mobile' as const }; const current = stats.get(carrier) ?? { name: meta.label, total: 0, success: 0, tone: meta.tone }; current.total += 1; @@ -116,7 +116,7 @@ function buildCarrierStats(messages: SmsMessageRecord[] | undefined): CarrierSta function buildRegionStats(messages: SmsMessageRecord[] | undefined): RegionStat[] { const stats = new Map(); (messages ?? []).forEach((message) => { - const region = message.channel?.sendRegion ?? '未分配通道'; + const region = message.province ?? '未识别省份'; const current = stats.get(region) ?? { region, total: 0, success: 0 }; current.total += 1; if (message.status === 'delivered') current.success += 1; @@ -277,9 +277,9 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
-

通道运营商分布

+

号码运营商分布

{task.carriers.length === 0 ? ( -
暂无已分配通道记录
+
暂无已识别运营商记录
) : (
{task.carriers.map((carrier) => { @@ -301,10 +301,10 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
-

发送地区分布

+

号码省份分布

{record.region} }, + { key: 'region', title: '省份', render: (record: RegionStat) => {record.region} }, { key: 'total', title: '总数', align: 'right', render: (record: RegionStat) => formatNumber(record.total) }, { key: 'success', title: '成功', align: 'right', render: (record: RegionStat) => {formatNumber(record.success)} }, { @@ -315,7 +315,7 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void }, ]} data={task.regions} - emptyText="暂无已分配通道记录" + emptyText="暂无已识别省份记录" rowKey={(record) => record.region} />