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?
|
templateId String?
|
||||||
messageId String @unique
|
messageId String @unique
|
||||||
phoneNumber String
|
phoneNumber String
|
||||||
|
carrier String?
|
||||||
|
province String?
|
||||||
content String
|
content String
|
||||||
billingUnits Int @default(1)
|
billingUnits Int @default(1)
|
||||||
unitPrice Int @default(0)
|
unitPrice Int @default(0)
|
||||||
|
|||||||
@@ -595,6 +595,10 @@ describe('SendChainService', () => {
|
|||||||
expect(prisma.smsSubmitRecord.create).toHaveBeenCalledWith({
|
expect(prisma.smsSubmitRecord.create).toHaveBeenCalledWith({
|
||||||
data: expect.objectContaining({ messageRecordId: 'record-1', channelId: 'channel-1', submitStatus: 'queued' }),
|
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(
|
expect(gatewayAdd).toHaveBeenCalledWith(
|
||||||
'submit-command',
|
'submit-command',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -613,6 +617,23 @@ describe('SendChainService', () => {
|
|||||||
expect(service['postGatewayControl']).not.toHaveBeenCalledWith('/upstream/submit', expect.anything());
|
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 () => {
|
it('updates submit result status, charges billing, and task progress', async () => {
|
||||||
const { service, prisma, billing } = createService();
|
const { service, prisma, billing } = createService();
|
||||||
|
|
||||||
|
|||||||
@@ -1640,6 +1640,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
where: { id: message.id },
|
where: { id: message.id },
|
||||||
data: {
|
data: {
|
||||||
channelId: channel.id,
|
channelId: channel.id,
|
||||||
|
carrier: routed.carrier,
|
||||||
|
province: routed.province,
|
||||||
submitId,
|
submitId,
|
||||||
status: 'submit_queued',
|
status: 'submit_queued',
|
||||||
submitStatus: 'queued',
|
submitStatus: 'queued',
|
||||||
@@ -1747,15 +1749,19 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async selectChannelForMessage(
|
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[] } = {},
|
options: { forceNational?: boolean; excludeChannelIds?: string[] } = {},
|
||||||
): Promise<RoutedChannel> {
|
): Promise<RoutedChannel> {
|
||||||
if (!message.applicationId) {
|
if (!message.applicationId) {
|
||||||
throw new BadRequestException('短信应用未配置,无法选择通道组');
|
throw new BadRequestException('短信应用未配置,无法选择通道组');
|
||||||
}
|
}
|
||||||
const carrier = await this.identifyCarrier(message.phoneNumber);
|
const carrier = await this.identifyCarrier(message.phoneNumber);
|
||||||
const route = await this.findApplicationRoute(message.tenantId, message.applicationId, carrier);
|
|
||||||
const province = await this.identifyProvince(message.phoneNumber);
|
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 excluded = new Set(options.excludeChannelIds ?? []);
|
||||||
const items = route.group.items.filter((item) =>
|
const items = route.group.items.filter((item) =>
|
||||||
!excluded.has(item.channelId)
|
!excluded.has(item.channelId)
|
||||||
|
|||||||
@@ -1581,3 +1581,11 @@ git diff --check
|
|||||||
- API 列表/详情查询会将最近心跳超过 `CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS`(默认 90 秒)的会话标记为 `heartbeat_timeout`;运营端展示真实客户端 IP、企业代码与最近心跳。移除了不能真正关闭 TCP 连接的运营端“删除连接”伪操作。
|
- 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。
|
- 已执行 `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 将作为生产数据验收样本写入该表。
|
- 已部署生产验证:第 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;
|
channelId?: string | null;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
|
carrier?: string | null;
|
||||||
|
province?: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
billingUnits: number;
|
billingUnits: number;
|
||||||
amountCents: number;
|
amountCents: number;
|
||||||
|
|||||||
@@ -143,8 +143,8 @@ function downloadCsv(records: SmsMessageRecord[]) {
|
|||||||
record.application?.name ?? record.applicationId ?? '',
|
record.application?.name ?? record.applicationId ?? '',
|
||||||
getTime(record.queuedAt),
|
getTime(record.queuedAt),
|
||||||
record.phoneNumber,
|
record.phoneNumber,
|
||||||
record.channel?.sendRegion ?? '',
|
record.province ?? '',
|
||||||
getCarrierLabel(record.channel?.carrier),
|
getCarrierLabel(record.carrier),
|
||||||
record.billingUnits,
|
record.billingUnits,
|
||||||
(record.amountCents / 100).toFixed(3),
|
(record.amountCents / 100).toFixed(3),
|
||||||
record.channel?.name ?? record.channelId ?? '',
|
record.channel?.name ?? record.channelId ?? '',
|
||||||
@@ -202,6 +202,10 @@ function SendDetailModal({
|
|||||||
<span>提交时间</span>
|
<span>提交时间</span>
|
||||||
<strong>{getTime(record.queuedAt)}</strong>
|
<strong>{getTime(record.queuedAt)}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>号码归属</span>
|
||||||
|
<strong>{record.province ?? '-'} / {getCarrierLabel(record.carrier)}</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<section>
|
<section>
|
||||||
<h3><MessageSquare size={18} /> 短信内容</h3>
|
<h3><MessageSquare size={18} /> 短信内容</h3>
|
||||||
@@ -440,7 +444,7 @@ export function AdminSmsRecordsPage() {
|
|||||||
<td>
|
<td>
|
||||||
<div className="admin-sms-record-phone">
|
<div className="admin-sms-record-phone">
|
||||||
<strong>{record.phoneNumber}</strong>
|
<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>
|
<small>{record.content.length}字/{record.billingUnits}条</small>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ function countMessages(messages: SmsMessageRecord[] | undefined, statuses: strin
|
|||||||
function buildCarrierStats(messages: SmsMessageRecord[] | undefined): CarrierStat[] {
|
function buildCarrierStats(messages: SmsMessageRecord[] | undefined): CarrierStat[] {
|
||||||
const stats = new Map<string, CarrierStat>();
|
const stats = new Map<string, CarrierStat>();
|
||||||
(messages ?? []).forEach((message) => {
|
(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 meta = carrierLabels[carrier] ?? { label: carrier || '未知通道', tone: 'mobile' as const };
|
||||||
const current = stats.get(carrier) ?? { name: meta.label, total: 0, success: 0, tone: meta.tone };
|
const current = stats.get(carrier) ?? { name: meta.label, total: 0, success: 0, tone: meta.tone };
|
||||||
current.total += 1;
|
current.total += 1;
|
||||||
@@ -116,7 +116,7 @@ function buildCarrierStats(messages: SmsMessageRecord[] | undefined): CarrierSta
|
|||||||
function buildRegionStats(messages: SmsMessageRecord[] | undefined): RegionStat[] {
|
function buildRegionStats(messages: SmsMessageRecord[] | undefined): RegionStat[] {
|
||||||
const stats = new Map<string, RegionStat>();
|
const stats = new Map<string, RegionStat>();
|
||||||
(messages ?? []).forEach((message) => {
|
(messages ?? []).forEach((message) => {
|
||||||
const region = message.channel?.sendRegion ?? '未分配通道';
|
const region = message.province ?? '未识别省份';
|
||||||
const current = stats.get(region) ?? { region, total: 0, success: 0 };
|
const current = stats.get(region) ?? { region, total: 0, success: 0 };
|
||||||
current.total += 1;
|
current.total += 1;
|
||||||
if (message.status === 'delivered') current.success += 1;
|
if (message.status === 'delivered') current.success += 1;
|
||||||
@@ -277,9 +277,9 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="admin-task-card admin-task-card--full">
|
<section className="admin-task-card admin-task-card--full">
|
||||||
<h3><Smartphone size={18} />通道运营商分布</h3>
|
<h3><Smartphone size={18} />号码运营商分布</h3>
|
||||||
{task.carriers.length === 0 ? (
|
{task.carriers.length === 0 ? (
|
||||||
<div className="admin-uplink-empty-match">暂无已分配通道记录</div>
|
<div className="admin-uplink-empty-match">暂无已识别运营商记录</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-carrier-grid">
|
<div className="admin-carrier-grid">
|
||||||
{task.carriers.map((carrier) => {
|
{task.carriers.map((carrier) => {
|
||||||
@@ -301,10 +301,10 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="admin-task-card admin-task-card--full">
|
<section className="admin-task-card admin-task-card--full">
|
||||||
<h3><MapPin size={18} />发送地区分布</h3>
|
<h3><MapPin size={18} />号码省份分布</h3>
|
||||||
<Table
|
<Table
|
||||||
columns={[
|
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: '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> },
|
{ 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}
|
data={task.regions}
|
||||||
emptyText="暂无已分配通道记录"
|
emptyText="暂无已识别省份记录"
|
||||||
rowKey={(record) => record.region}
|
rowKey={(record) => record.region}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user