fix: persist sms carrier and province
This commit is contained in:
@@ -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");
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/页面验收待部署时执行。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user