fix: persist sms carrier and province

This commit is contained in:
hectorzhao
2026-07-11 21:55:25 +08:00
parent 37048a4c9a
commit 7b8424d9bc
8 changed files with 76 additions and 12 deletions
@@ -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");
+2
View File
@@ -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)
@@ -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();
+8 -2
View File
@@ -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<RoutedChannel> {
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)
+8
View File
@@ -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/页面验收待部署时执行。
+2
View File
@@ -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;
+7 -3
View File
@@ -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({
<span></span>
<strong>{getTime(record.queuedAt)}</strong>
</div>
<div>
<span></span>
<strong>{record.province ?? '-'} / {getCarrierLabel(record.carrier)}</strong>
</div>
</div>
<section>
<h3><MessageSquare size={18} /> </h3>
@@ -440,7 +444,7 @@ export function AdminSmsRecordsPage() {
<td>
<div className="admin-sms-record-phone">
<strong>{record.phoneNumber}</strong>
<span>{record.channel?.sendRegion ?? '-'} {getCarrierLabel(record.channel?.carrier)}</span>
<span>{record.province ?? '-'} {getCarrierLabel(record.carrier)}</span>
<small>{record.content.length}/{record.billingUnits}</small>
</div>
</td>
+7 -7
View File
@@ -103,7 +103,7 @@ function countMessages(messages: SmsMessageRecord[] | undefined, statuses: strin
function buildCarrierStats(messages: SmsMessageRecord[] | undefined): CarrierStat[] {
const stats = new Map<string, CarrierStat>();
(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<string, RegionStat>();
(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
</div>
<section className="admin-task-card admin-task-card--full">
<h3><Smartphone size={18} /></h3>
<h3><Smartphone size={18} /></h3>
{task.carriers.length === 0 ? (
<div className="admin-uplink-empty-match"></div>
<div className="admin-uplink-empty-match"></div>
) : (
<div className="admin-carrier-grid">
{task.carriers.map((carrier) => {
@@ -301,10 +301,10 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
</section>
<section className="admin-task-card admin-task-card--full">
<h3><MapPin size={18} /></h3>
<h3><MapPin size={18} /></h3>
<Table
columns={[
{ key: 'region', title: '发送地区', render: (record: RegionStat) => <strong>{record.region}</strong> },
{ key: 'region', title: '省份', render: (record: RegionStat) => <strong>{record.region}</strong> },
{ key: 'total', title: '总数', align: 'right', render: (record: RegionStat) => formatNumber(record.total) },
{ key: 'success', title: '成功', align: 'right', render: (record: RegionStat) => <span className="admin-success-text">{formatNumber(record.success)}</span> },
{
@@ -315,7 +315,7 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
},
]}
data={task.regions}
emptyText="暂无已分配通道记录"
emptyText="暂无已识别省份记录"
rowKey={(record) => record.region}
/>
</section>