feat: densify sms records and improve uplink matching

This commit is contained in:
hectorzhao
2026-08-27 10:15:08 +08:00
parent 898471423f
commit a280b4bb22
25 changed files with 859 additions and 154 deletions
@@ -0,0 +1,2 @@
ALTER TABLE "SmsUplinkMessage"
ADD COLUMN "gatewayMessageId" TEXT;
+16 -15
View File
@@ -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])
+1
View File
@@ -303,6 +303,7 @@ export function clientUplinkView(message: Record<string, any>) {
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,
@@ -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([{
@@ -118,6 +118,7 @@ export interface GatewayUplinkEventDto {
eventId?: string;
traceId?: string;
messageId?: string;
gatewayMessageId?: string;
channelId: string;
sequenceId?: number;
phoneNumber: string;
@@ -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' }),
});
});
@@ -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,
+6 -4
View File
@@ -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`;未匹配应用时不允许写入。
### 运营端短信任务进度仍是静态任务
@@ -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",
"查看发送详情",
"分片",
"发送详情",
"通道发送与回执",
"分片补偿审计"
+1 -1
View File
@@ -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",
@@ -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 },
+25 -6
View File
@@ -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/35752145个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通过。
+16
View File
@@ -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.3Prometheus加载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均activeAPI/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未实施。
+7 -6
View File
@@ -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 {
+7 -6
View File
@@ -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 {
+48
View File
@@ -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" {
+1
View File
@@ -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;
+61 -9
View File
@@ -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 (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
footer={<>
{message.tenantId && message.applicationId ? (
<Button disabled={blacklisting} icon={<UserX size={15} />} onClick={onAddBlacklist}>
{blacklisting ? '加入中...' : '加入应用黑名单'}
</Button>
) : null}
<Button onClick={onClose} variant="ghost"></Button>
</>}
onClose={onClose}
open
size="xl"
@@ -92,13 +105,21 @@ function UplinkDetailModal({
<strong>{message.destId || '-'}</strong>
</div>
<div>
<span>ID</span>
<strong>{message.messageId || '-'}</strong>
<span>ID</span>
<strong>{message.gatewayMessageId || '-'}</strong>
</div>
<div>
<span>ID</span>
<strong>{message.messageRecord?.messageId ?? message.messageId ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{matchStatusText(message.matchStatus)}</strong>
</div>
<div className="admin-uplink-info-grid__full">
<span></span>
<strong>{message.matchReason || '-'}</strong>
</div>
<div className="admin-uplink-info-grid__full">
<span></span>
<strong>{message.content || '-'}</strong>
@@ -160,11 +181,13 @@ function UplinkDetailModal({
<section className="admin-uplink-match-section">
<h3></h3>
{blacklistFeedback ? <p className={blacklistFeedback.startsWith('已') ? 'form-success' : 'form-error'}>{blacklistFeedback}</p> : null}
{matching ? <p>...</p> : null}
{detailError ? <p className="form-error">{detailError}</p> : null}
{!matching && !message.messageId ? <div className="admin-uplink-empty-match">ID</div> : null}
{!matching && message.messageId && matchedRecords.length === 0 && !detailError ? (
<div className="admin-uplink-empty-match"></div>
{!matching && matchedRecords.length === 0 && !detailError ? (
<div className="admin-uplink-empty-match">
{message.matchStatus === 'ambiguous' ? '存在多个候选,请先认领正确应用' : '暂无匹配发送记录'}
</div>
) : null}
{matchedRecords.map((record) => (
<article className="admin-uplink-match-card" key={record.id}>
@@ -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 ? (
<UplinkDetailModal
blacklistFeedback={blacklistFeedback}
blacklisting={blacklisting}
claimError={claimError}
claimingId={claimingId}
detailError={detailError}
matchedRecords={matchedRecords}
matching={matching}
message={selectedMessage}
onAddBlacklist={handleAddBlacklist}
onClaim={handleClaim}
onClose={() => setSelectedMessage(null)}
/>
@@ -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;
}
}
@@ -85,15 +85,15 @@ export function SmsRecordFilter({
}: SmsRecordFilterProps) {
return (
<div className="surface admin-sms-record-filter">
<Select label="企业" onChange={(event) => onEnterpriseChange(event.target.value)} options={enterpriseOptions} value={enterprise} />
<Select label="应用" onChange={(event) => onApplicationChange(event.target.value)} options={applicationOptions} value={application} />
<DateRangeInput label="提交日期" onChange={onDateRangeChange} value={dateRange} />
<Input label="手机号码" onChange={(event) => onPhoneKeywordChange(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
<Select label="运营商" onChange={(event) => onCarrierChange(event.target.value)} options={carrierOptions} value={carrier} />
<Input label="短信内容" onChange={(event) => onContentKeywordChange(event.target.value)} value={contentKeyword} />
<Select label="通道" onChange={(event) => onChannelChange(event.target.value)} options={channelOptions} searchable searchPlaceholder="输入通道名称搜索" value={channel} />
<Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} />
<Select label="是否含引流信息" onChange={(event) => onHasDrainageChange(event.target.value)} options={drainageOptions} value={hasDrainage} />
<div className="admin-sms-record-filter__field is-enterprise"><Select label="企业" onChange={(event) => onEnterpriseChange(event.target.value)} options={enterpriseOptions} value={enterprise} /></div>
<div className="admin-sms-record-filter__field is-application"><Select label="应用" onChange={(event) => onApplicationChange(event.target.value)} options={applicationOptions} value={application} /></div>
<div className="admin-sms-record-filter__field is-date"><DateRangeInput label="提交日期" onChange={onDateRangeChange} value={dateRange} /></div>
<div className="admin-sms-record-filter__field is-phone"><Input label="手机号码" onChange={(event) => onPhoneKeywordChange(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} /></div>
<div className="admin-sms-record-filter__field is-carrier"><Select label="运营商" onChange={(event) => onCarrierChange(event.target.value)} options={carrierOptions} value={carrier} /></div>
<div className="admin-sms-record-filter__field is-content"><Input label="短信内容" onChange={(event) => onContentKeywordChange(event.target.value)} value={contentKeyword} /></div>
<div className="admin-sms-record-filter__field is-channel"><Select label="通道" onChange={(event) => onChannelChange(event.target.value)} options={channelOptions} searchable searchPlaceholder="输入通道名称搜索" value={channel} /></div>
<div className="admin-sms-record-filter__field is-status"><Select label="发送状态" onChange={(event) => onStatusChange(event.target.value)} options={statusOptions} value={status} /></div>
<div className="admin-sms-record-filter__field is-drainage"><Select label="是否含引流信息" onChange={(event) => onHasDrainageChange(event.target.value)} options={drainageOptions} value={hasDrainage} /></div>
<div className="admin-sms-record-filter__actions">
<Button icon={<Search size={16} />} onClick={onQuery}></Button>
<Button onClick={onReset} variant="ghost"></Button>
+46 -25
View File
@@ -1,4 +1,4 @@
import { Download } from 'lucide-react';
import { ChevronRight, Download } from 'lucide-react';
import type { ReactNode } from 'react';
import type { SmsMessageRecord } from '@/api/adminApi';
import { Button, CarrierTag, MoneyText, Pagination } from '@/components/ui';
@@ -8,7 +8,6 @@ import {
getDate,
getRecordStatus,
getStatusLabel,
getTime,
statusDotClassMap,
} from './smsRecordModel';
@@ -60,36 +59,58 @@ export function SmsRecordList({
onOpenDetail,
onPageChange,
}: SmsRecordListProps) {
let previousDate = '';
return (
<div className="surface admin-sms-record-table-card">
<div className="admin-sms-record-toolbar">
<Button icon={<Download size={16} />} onClick={onExport} variant="ghost">CSV</Button>
</div>
<div className="admin-sms-record-list">
{loading ? <div className="ui-table__empty">...</div> : records.length === 0 ? <div className="ui-table__empty"></div> : records.map((record) => (
<article className="admin-sms-record-card" key={record.id}>
<header>
<div className="admin-sms-record-sender">
<strong>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'}</strong>
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
</div>
<StatusLine record={record} />
<time>{getDate(record.queuedAt)} {getClock(record.queuedAt)}</time>
</header>
<p className={`admin-sms-record-content${record.hasDrainageContent ? ' is-drainage' : ''}`}>
<DrainageContent record={record} />
<span className={`admin-sms-record-drainage-badge is-${record.hasDrainageContent === true ? 'yes' : record.hasDrainageContent === false ? 'no' : 'unknown'}`}>
{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}
</span>
</p>
<div className="admin-sms-record-card__meta">
<div><span></span><strong>{record.phoneNumber}</strong><small>{record.province ?? '-'} {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</small></div>
<div><span></span><strong>{record.billingUnits} / <MoneyText>¥{formatCents(record.amountCents)}</MoneyText></strong><small>{record.content.length} </small></div>
<div><span></span><strong>{record.channel?.name ?? record.channelId ?? '-'}</strong><small> {getTime(record.deliveredAt)}</small></div>
{loading ? <div className="ui-table__empty">...</div> : records.length === 0 ? <div className="ui-table__empty"></div> : (
<>
<div aria-hidden="true" className="admin-sms-record-list__header">
<span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<footer><button className="admin-sms-record-detail-link" onClick={() => onOpenDetail(record)} type="button"></button></footer>
</article>
))}
{records.map((record) => {
const submitDate = getDate(record.queuedAt);
const showDateGroup = submitDate !== previousDate;
previousDate = submitDate;
return (
<div className="admin-sms-record-group" key={record.id}>
{showDateGroup ? <div className="admin-sms-record-group-title">{submitDate}</div> : null}
<article className="admin-sms-record-card">
<StatusLine record={record} />
<time className="admin-sms-record-time" dateTime={record.queuedAt}>
<span>{submitDate}</span><strong>{getClock(record.queuedAt)}</strong>
</time>
<div className="admin-sms-record-main">
<p className={`admin-sms-record-content${record.hasDrainageContent ? ' is-drainage' : ''}`}>
<DrainageContent record={record} />
<span className={`admin-sms-record-drainage-badge is-${record.hasDrainageContent === true ? 'yes' : record.hasDrainageContent === false ? 'no' : 'unknown'}`}>
{record.hasDrainageContent === true ? '含引流' : record.hasDrainageContent === false ? '不含引流' : '未检测'}
</span>
</p>
<div className="admin-sms-record-context">
<span>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'} / {record.application?.name ?? record.applicationId ?? '-'}</span>
<span>{record.phoneNumber} · {record.province ?? '-'} {record.carrier ? <CarrierTag carrier={record.carrier} /> : '-'}</span>
<span>{record.channel?.name ?? record.channelId ?? '-'}</span>
</div>
</div>
<time className="admin-sms-record-time" dateTime={record.deliveredAt ?? undefined}>
{record.deliveredAt ? <><span>{getDate(record.deliveredAt)}</span><strong>{getClock(record.deliveredAt)}</strong></> : <span></span>}
</time>
<div className="admin-sms-record-billing">
<MoneyText>¥{formatCents(record.amountCents)}</MoneyText>
<span>{record.billingUnits} · {record.content.length} </span>
</div>
<button aria-label="查看发送详情" className="admin-sms-record-detail-link" onClick={() => onOpenDetail(record)} title="查看发送详情" type="button"><ChevronRight size={18} /></button>
</article>
</div>
);
})}
</>
)}
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
+10 -6
View File
@@ -52,10 +52,10 @@ export function ClientUplinkMessagesPage() {
function openDetail(message: SmsUplinkMessage) {
setSelectedMessage(message);
setMatchedRecords([]);
setMatchedRecords(message.messageRecord ? [message.messageRecord] : []);
setDetailError('');
if (!message.messageId) {
if (message.messageRecord || !message.messageId) {
return;
}
@@ -142,7 +142,10 @@ export function ClientUplinkMessagesPage() {
{ label: '手机号码', value: selectedMessage.phoneNumber },
{ label: '上行时间', value: getTime(selectedMessage.receivedAt) },
{ label: '接入号码', value: selectedMessage.destId },
{ label: '网关消息ID', value: selectedMessage.messageId || '-' },
{ label: '上行网关消息ID', value: selectedMessage.gatewayMessageId || '-' },
{ label: '关联平台消息ID', value: selectedMessage.messageRecord?.messageId ?? selectedMessage.messageId ?? '-' },
{ label: '匹配状态', value: selectedMessage.matchStatus || '-' },
{ label: '匹配说明', value: selectedMessage.matchReason || '-', full: true },
{ label: '上行内容', value: selectedMessage.content, full: true },
]}
/>
@@ -151,9 +154,10 @@ export function ClientUplinkMessagesPage() {
<DetailSection title="匹配发送记录">
{matching ? <p className="uplink-detail-hint">...</p> : null}
{detailError ? <p className="form-error">{detailError}</p> : null}
{!matching && !selectedMessage.messageId ? <p className="uplink-detail-hint">ID</p> : null}
{!matching && selectedMessage.messageId && matchedRecords.length === 0 && !detailError ? (
<p className="uplink-detail-hint"></p>
{!matching && matchedRecords.length === 0 && !detailError ? (
<p className="uplink-detail-hint">
{selectedMessage.matchStatus === 'ambiguous' ? '该上行存在多个候选,请联系运营人员认领。' : '未匹配到真实下发记录。'}
</p>
) : null}
{matchedRecords.length > 0 ? (
<div className="uplink-match-list">
+415
View File
@@ -0,0 +1,415 @@
import { createHash } from 'node:crypto';
import { PrismaPg } from '../../api/node_modules/@prisma/adapter-pg/dist/index.js';
import { PrismaClient } from '../../api/node_modules/@prisma/client/index.js';
const databaseUrl = process.env.DATABASE_URL
?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public';
const prisma = new PrismaClient({ adapter: new PrismaPg(databaseUrl) });
const PREFIX = 'screenshot-seed-20260822';
const tenantId = `${PREFIX}-tenant`;
const applicationIds = [`${PREFIX}-app-notice`, `${PREFIX}-app-marketing`];
const channelIds = [`${PREFIX}-channel-mobile`, `${PREFIX}-channel-unicom`, `${PREFIX}-channel-telecom`];
const signatureIds = [`${PREFIX}-signature-service`, `${PREFIX}-signature-member`, `${PREFIX}-signature-cloud`];
const drainageIds = [`${PREFIX}-drainage-mall`, `${PREFIX}-drainage-event`];
const tenantName = '星河智联科技有限公司';
const applicationNames = ['星河通知中心', '星河会员营销'];
const channelNames = ['华东移动一号通道', '华北联通优质通道', '全国电信高速通道'];
const signatureNames = ['【星河服务】', '【星河会员】', '【星河云】'];
const now = new Date();
const dayMs = 86_400_000;
function id(suffix) {
return `${PREFIX}-${suffix}`;
}
function sha256(value) {
return createHash('sha256').update(value).digest('hex');
}
function dateDaysAgo(days, hour = 10, minute = 0) {
const value = new Date(now.getTime() - days * dayMs);
value.setHours(hour, minute, 0, 0);
return value;
}
function dateKeyDaysAgo(days) {
const value = dateDaysAgo(days, 0, 0);
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
}
function reportDate(days) {
return new Date(`${dateKeyDaysAgo(days)}T00:00:00.000Z`);
}
async function clearPreviousSeed() {
await prisma.protocolInteractionLog.deleteMany({ where: { id: { startsWith: PREFIX } } });
await prisma.smsMessageRecord.deleteMany({ where: { id: { startsWith: PREFIX } } });
await prisma.smsBatchTask.deleteMany({ where: { id: { startsWith: PREFIX } } });
await prisma.smsSendTask.deleteMany({ where: { id: { startsWith: PREFIX } } });
await prisma.dailyReconciliationReport.deleteMany({ where: { id: { startsWith: PREFIX } } });
await prisma.dailyProfitReport.deleteMany({ where: { id: { startsWith: PREFIX } } });
await prisma.dailyQualityReport.deleteMany({ where: { id: { startsWith: PREFIX } } });
}
async function ensureDimensions() {
await prisma.tenant.upsert({
where: { code: 'SCREENSHOT-DEMO' },
update: { name: tenantName, status: 'active', certificationStatus: 'approved' },
create: { id: tenantId, code: 'SCREENSHOT-DEMO', name: tenantName, status: 'active', certificationStatus: 'approved' },
});
await prisma.tenantAccount.upsert({
where: { tenantId },
update: { balanceCents: 268_560_00n, creditCents: 50_000_00n, status: 'active' },
create: { id: id('tenant-account'), tenantId, balanceCents: 268_560_00n, creditCents: 50_000_00n, status: 'active' },
});
for (let index = 0; index < applicationIds.length; index += 1) {
await prisma.smsApplication.upsert({
where: { cmppAccount: `SCREENSHOT_APP_${index + 1}` },
update: {
tenantId,
name: applicationNames[index],
status: 'active',
interfaceEnabled: true,
customerUnitPrice: BigInt(index === 0 ? 8 : 10),
},
create: {
id: applicationIds[index],
tenantId,
name: applicationNames[index],
scene: index === 0 ? '订单、验证码与服务通知' : '会员权益、节日活动与营销触达',
cmppAccount: `SCREENSHOT_APP_${index + 1}`,
cmppEnterpriseCode: `SG${String(index + 1).padStart(4, '0')}`,
cmppApplicationExtension: String(21 + index),
cmppClientSrcId: `1069008899${index + 1}`,
secretHash: sha256(`screenshot-local-secret-${index + 1}`),
interfaceEnabled: true,
interfaceType: index === 0 ? 'cmpp30' : 'http',
cmppMaxConnections: 3,
cmppWindowSize: 32,
dailyLimit: 500_000,
customerUnitPrice: BigInt(index === 0 ? 8 : 10),
queuePriority: index === 0 ? 'high' : 'normal',
status: 'active',
},
});
}
const channelMeta = [
{ code: 'SCREENSHOT-MOBILE', carrier: 'mobile', carriers: ['mobile'], price: 5 },
{ code: 'SCREENSHOT-UNICOM', carrier: 'unicom', carriers: ['unicom'], price: 5 },
{ code: 'SCREENSHOT-TELECOM', carrier: 'telecom', carriers: ['telecom'], price: 6 },
];
for (let index = 0; index < channelIds.length; index += 1) {
const meta = channelMeta[index];
await prisma.smsChannel.upsert({
where: { code: meta.code },
update: { name: channelNames[index], carrier: meta.carrier, carriers: meta.carriers, status: 'inactive', unitPrice: BigInt(meta.price) },
create: {
id: channelIds[index], code: meta.code, name: channelNames[index], carrier: meta.carrier,
carriers: meta.carriers, sendRegion: '全国', protocol: 'CMPP', gatewayHost: '127.0.0.1', gatewayPort: 17890 + index,
enterpriseCode: `SGCH${index + 1}`, account: `screenshot_channel_${index + 1}`,
passwordCipher: 'local-screenshot-seed-only', srcId: `10690088${index + 1}`, cmppVersion: '3.0',
rateLimitPerSecond: 300 + index * 100, unitPrice: BigInt(meta.price), status: 'inactive',
},
});
}
for (let index = 0; index < signatureIds.length; index += 1) {
await prisma.smsSignature.upsert({
where: { id: signatureIds[index] },
update: { name: signatureNames[index], auditStatus: 'approved', reportStatus: 'approved', pendingReport: false },
create: {
id: signatureIds[index], tenantId, applicationId: applicationIds[index % 2], name: signatureNames[index],
purpose: index === 1 ? '会员营销通知' : '交易与服务通知', auditStatus: 'approved', reportStatus: 'approved',
pendingReport: false, reportChangedAt: dateDaysAgo(45), createdAt: dateDaysAgo(60),
},
});
}
const drainageMeta = [
{ siteName: '星河优选商城', url: 'https://mall.example.test/benefits', remark: '会员积分兑换与新品活动页' },
{ siteName: '星河夏日活动', url: 'https://events.example.test/summer', remark: '夏日专属优惠活动落地页' },
];
for (let index = 0; index < drainageIds.length; index += 1) {
await prisma.smsDrainageInfo.upsert({
where: { id: drainageIds[index] },
update: { ...drainageMeta[index], auditStatus: 'approved', pendingReport: false },
create: {
id: drainageIds[index], tenantId, signatureId: signatureIds[1], applicationId: applicationIds[1],
...drainageMeta[index], reportValues: { ICP备案号: '浙ICP备20260088号', 业务类型: '会员权益活动' },
auditStatus: 'approved', pendingReport: false, reviewedAt: dateDaysAgo(35), submittedAt: dateDaysAgo(40),
},
});
}
}
async function seedSendTasks() {
const statuses = ['approved', 'pending_review', 'approved', 'rejected', 'approved', 'pending_review'];
const decisions = ['allow', 'manual_review', 'allow', 'reject', 'allow', 'manual_review'];
const contents = [
'【星河服务】您的订单已发货,物流单号已更新,请注意查收。',
'【星河会员】您的会员积分将于本月底到期,可登录官网查看权益。',
'【星河云】验证码 726418,5 分钟内有效,请勿告知他人。',
'【星河会员】夏日优选活动已开启,会员可享限时积分兑换权益。',
];
const rows = Array.from({ length: 24 }, (_, index) => {
const status = statuses[index % statuses.length];
return {
id: id(`send-task-${String(index + 1).padStart(3, '0')}`), tenantId,
applicationId: applicationIds[index % 2], taskNo: `ST-SHOT-${dateKeyDaysAgo(index % 8).replaceAll('-', '')}-${String(index + 1).padStart(4, '0')}`,
sourceType: index % 3 === 0 ? 'client' : 'risk', content: contents[index % contents.length],
category: index % 4 === 2 ? '验证码' : index % 2 === 0 ? '行业通知' : '会员营销',
phoneTotal: 180 + index * 37, uniquePhoneTotal: 176 + index * 35,
duplicateRatio: Number(((index % 5) * 0.012).toFixed(3)), illegalRatio: index % 7 === 0 ? 0.006 : 0,
blacklistHitRatio: index % 6 === 0 ? 0.009 : 0, variableIssues: index % 5 === 0 ? { missing: 2, extra: 0 } : null,
status, riskDecision: decisions[index % decisions.length],
reviewReason: status === 'pending_review' ? '命中大批量营销内容人工复核阈值' : null,
rejectReason: status === 'rejected' ? '营销内容缺少有效退订说明' : null,
reviewedAt: status === 'approved' || status === 'rejected' ? dateDaysAgo(index % 8, 11, index) : null,
createdAt: dateDaysAgo(index % 8, 9, index), updatedAt: dateDaysAgo(index % 8, 11, index),
};
});
await prisma.smsSendTask.createMany({ data: rows });
return rows;
}
function messageStatus(index, batchIndex) {
if (batchIndex === 10) return index < 7 ? 'submitted' : index < 12 ? 'queued' : 'scheduled';
if (batchIndex === 11) return index < 5 ? 'submit_failed' : index < 11 ? 'failed' : 'rejected';
const slot = (index * 7 + batchIndex * 3) % 20;
if (slot < 14) return 'delivered';
if (slot < 16) return 'submitted';
if (slot < 18) return 'failed';
if (slot === 18) return 'timeout';
return 'rejected';
}
async function seedBatchesAndMessages(sendTasks) {
const provinces = ['浙江', '江苏', '广东', '北京', '上海', '四川', '湖北', '山东'];
const carriers = ['mobile', 'unicom', 'telecom'];
const contents = [
'【星河服务】您的订单 XH20260822001 已完成支付,感谢您的使用。',
'【星河服务】您的快递已到达服务站,请凭取件码 8216 及时领取。',
'【星河云】登录验证码 726418,5 分钟内有效。',
'【星河会员】本周会员日权益已到账,点击活动页可查看详情。',
'【星河会员】夏日优选活动进行中,会员专享积分兑换已开启。',
];
const batchRows = [];
const messageRows = [];
for (let batchIndex = 0; batchIndex < 12; batchIndex += 1) {
const createdAt = dateDaysAgo(batchIndex % 7, 8 + (batchIndex % 9), batchIndex * 3);
const statuses = Array.from({ length: 20 }, (_, index) => messageStatus(index, batchIndex));
const submittedTotal = statuses.filter((value) => !['queued', 'scheduled', 'rejected'].includes(value)).length;
const successTotal = statuses.filter((value) => value === 'delivered').length;
const failedTotal = statuses.filter((value) => ['failed', 'submit_failed', 'rejected'].includes(value)).length;
const timeoutTotal = statuses.filter((value) => value === 'timeout').length;
const unknownTotal = statuses.filter((value) => value === 'submitted').length;
const batchStatus = batchIndex < 8 ? 'completed' : batchIndex < 10 ? 'sending' : batchIndex === 10 ? 'scheduled' : 'failed';
const batchId = id(`batch-${String(batchIndex + 1).padStart(3, '0')}`);
batchRows.push({
id: batchId, tenantId, applicationId: applicationIds[batchIndex % 2],
taskNo: `BT-SHOT-${dateKeyDaysAgo(batchIndex % 7).replaceAll('-', '')}-${String(batchIndex + 1).padStart(4, '0')}`,
sourceType: batchIndex % 3 === 0 ? 'http' : 'client', content: contents[batchIndex % contents.length],
category: batchIndex % 2 === 0 ? '行业通知' : '营销通知', phoneTotal: 20, status: batchStatus,
auditStatus: batchIndex === 11 ? 'rejected' : 'approved', progressTotal: 20, submittedTotal,
successTotal, failedTotal, unknownTotal, timeoutTotal,
scheduledAt: batchIndex === 10 ? new Date(now.getTime() + 2 * 60 * 60 * 1000) : null,
rejectReason: batchIndex === 11 ? '模板变量与受众字段不匹配' : null,
createdAt, updatedAt: new Date(createdAt.getTime() + 18 * 60 * 1000),
});
for (let index = 0; index < 20; index += 1) {
const status = statuses[index];
const carrier = carriers[(index + batchIndex) % carriers.length];
const queuedAt = new Date(createdAt.getTime() + index * 41_000);
const submittedAt = ['queued', 'scheduled', 'rejected'].includes(status) ? null : new Date(queuedAt.getTime() + 900 + (index % 5) * 240);
const deliveredAt = status === 'delivered' ? new Date(submittedAt.getTime() + 1800 + (index % 9) * 720) : null;
const units = index % 6 === 0 ? 2 : 1;
const hasDrainage = batchIndex % 2 === 1 && index % 4 === 0;
const messageContent = hasDrainage
? `${contents[batchIndex % contents.length]} 活动地址 https://events.example.test/summer`
: contents[batchIndex % contents.length];
messageRows.push({
id: id(`message-${String(batchIndex + 1).padStart(3, '0')}-${String(index + 1).padStart(3, '0')}`),
tenantId, batchTaskId: batchId, applicationId: applicationIds[batchIndex % 2],
signatureId: signatureIds[batchIndex % signatureIds.length], drainageInfoId: hasDrainage ? drainageIds[(batchIndex + index) % 2] : null,
reviewTaskId: sendTasks[(batchIndex * 2 + index) % sendTasks.length].id,
messageId: `MSG-SHOT-${String(batchIndex + 1).padStart(3, '0')}-${String(index + 1).padStart(4, '0')}`,
clientMessageId: `CLIENT-SHOT-${String(batchIndex + 1).padStart(3, '0')}-${String(index + 1).padStart(4, '0')}`,
phoneNumber: `1380013${String(8000 + batchIndex * 20 + index).slice(-4)}`,
carrier, province: provinces[(index + batchIndex * 2) % provinces.length], content: messageContent,
hasDrainageContent: hasDrainage,
drainageDetection: hasDrainage ? { matches: [{ start: messageContent.indexOf('https://'), end: messageContent.length, type: 'url' }], version: 'screenshot-v1' } : { matches: [], version: 'screenshot-v1' },
drainageDetectionVersion: 'screenshot-v1', drainageEvaluatedAt: queuedAt,
billingUnits: units, unitPrice: BigInt(batchIndex % 2 === 0 ? 8 : 10), amountCents: BigInt(units * (batchIndex % 2 === 0 ? 8 : 10)),
queuePriority: batchIndex % 3 === 0 ? 'high' : 'normal', channelId: channelIds[(index + batchIndex) % channelIds.length],
submitId: submittedAt ? `SUBMIT-SHOT-${batchIndex + 1}-${index + 1}` : null,
gatewayMessageId: submittedAt ? String(8_600_000_000_000 + batchIndex * 100 + index) : null,
status, submitStatus: status === 'rejected' ? 'rejected' : submittedAt ? 'accepted' : null,
receiptStatus: status === 'delivered' ? 'delivered' : status === 'failed' ? 'undelivered' : null,
receiptRawStatus: status === 'delivered' ? 'DELIVRD' : status === 'failed' ? 'UNDELIV' : null,
errorCode: status === 'submit_failed' ? 'CMPP-8' : status === 'failed' ? 'YX:0003' : status === 'timeout' ? 'RECEIPT_TIMEOUT' : status === 'rejected' ? 'RISK_REJECTED' : null,
errorMessage: status === 'submit_failed' ? '供应商通道暂时拒绝' : status === 'failed' ? '号码空号或停机' : status === 'timeout' ? '72 小时未收到最终回执' : status === 'rejected' ? '命中营销风控规则' : null,
queuedAt, submittedAt, deliveredAt, timeoutAt: status === 'timeout' ? new Date(queuedAt.getTime() + 72 * 60 * 60 * 1000) : null,
updatedAt: deliveredAt ?? submittedAt ?? queuedAt,
});
}
}
await prisma.smsBatchTask.createMany({ data: batchRows });
await prisma.smsMessageRecord.createMany({ data: messageRows });
return { batchRows, messageRows };
}
async function seedProtocolLogs(messages) {
const eventTypes = ['submit', 'submit_resp', 'deliver_receipt', 'deliver_resp', 'send_request', 'receipt_webhook', 'connect'];
const directions = ['client_to_platform', 'platform_to_channel', 'channel_to_platform', 'platform_to_client'];
const statuses = ['success', 'accepted', 'success', 'received', 'success', 'retrying', 'failed'];
const rows = Array.from({ length: 180 }, (_, index) => {
const message = messages[index % messages.length];
const eventType = eventTypes[index % eventTypes.length];
const protocol = index % 5 === 0 || index % 5 === 4 ? 'http' : 'cmpp';
const direction = directions[index % directions.length];
const status = statuses[index % statuses.length];
const daysAgo = index < 112 ? 0 : 1 + (index % 6);
const createdAt = dateDaysAgo(daysAgo, 8 + (index % 12), (index * 7) % 60);
return {
id: id(`protocol-${String(index + 1).padStart(4, '0')}`), protocol, direction, eventType, status,
tenantId, applicationId: message.applicationId, channelId: message.channelId,
account: protocol === 'http' ? `AK-SHOT-${(index % 2) + 1}` : `SCREENSHOT_APP_${(index % 2) + 1}`,
messageId: message.messageId, gatewayMessageId: message.gatewayMessageId,
traceId: `TRACE-SHOT-${String(index + 1).padStart(6, '0')}`,
requestId: protocol === 'http' ? `REQ-SHOT-${String(index + 1).padStart(6, '0')}` : null,
phoneNumber: message.phoneNumber, resultCode: status === 'failed' ? (protocol === 'http' ? '429' : '8') : status === 'retrying' ? 'RETRY-1' : '0',
durationMs: 18 + ((index * 37) % 680), payloadBytes: 96 + ((index * 53) % 1200), retryCount: status === 'retrying' ? 1 + (index % 3) : 0,
detail: { gateway: `local-gateway-${(index % 2) + 1}`, windowSize: 32, segmentCount: index % 9 === 0 ? 2 : 1, result: status }, createdAt,
};
});
await prisma.protocolInteractionLog.createMany({ data: rows });
return rows;
}
async function seedReports() {
const reconciliation = [];
const profit = [];
const quality = [];
for (let days = 1; days <= 30; days += 1) {
const date = reportDate(days);
for (let appIndex = 0; appIndex < applicationIds.length; appIndex += 1) {
const submitted = 8200 + ((days * 977 + appIndex * 2231) % 12_000);
const failed = 70 + ((days * 41 + appIndex * 67) % 260);
const unknown = 18 + ((days * 17 + appIndex * 13) % 95);
const success = submitted - failed - unknown;
const sent = success + failed + unknown;
reconciliation.push({
id: id(`recon-${days}-${appIndex}`), reportDate: date, tenantId, tenantName,
applicationId: applicationIds[appIndex], applicationName: applicationNames[appIndex],
submittedUnits: submitted, sentUnits: sent, unknownUnits: unknown, successUnits: success, failedUnits: failed,
generatedAt: dateDaysAgo(days - 1, 2, 12), updatedAt: dateDaysAgo(days - 1, 2, 12),
});
const revenue = BigInt(success * (appIndex === 0 ? 8 : 10));
const cost = BigInt(success * (appIndex === 0 ? 5 : 6));
const appProfit = revenue - cost;
profit.push({
id: id(`profit-app-${days}-${appIndex}`), reportDate: date, dimensionType: 'application', dimensionId: applicationIds[appIndex], dimensionName: applicationNames[appIndex],
tenantId, tenantName, applicationId: applicationIds[appIndex], submittedUnits: submitted, sentUnits: sent, unknownUnits: unknown, successUnits: success, failedUnits: failed,
revenueCents: revenue, refundCents: 0n, costCents: cost, profitCents: appProfit, profitRateBps: Number(appProfit * 10_000n / revenue),
generatedAt: dateDaysAgo(days - 1, 2, 18), updatedAt: dateDaysAgo(days - 1, 2, 18),
});
quality.push({
id: id(`quality-app-${days}-${appIndex}`), reportDate: date, dimensionType: 'application', dimensionId: applicationIds[appIndex], dimensionName: applicationNames[appIndex],
tenantId, tenantName, applicationId: applicationIds[appIndex], submittedUnits: submitted, sentUnits: sent, unknownUnits: unknown, successUnits: success, failedUnits: failed,
successRateBps: Math.round(success * 10_000 / sent), avgArrivalMs: 1800 + ((days * 173 + appIndex * 641) % 5200),
generatedAt: dateDaysAgo(days - 1, 2, 25), updatedAt: dateDaysAgo(days - 1, 2, 25),
});
}
for (let channelIndex = 0; channelIndex < channelIds.length; channelIndex += 1) {
const submitted = 4600 + ((days * 587 + channelIndex * 1871) % 8500);
const failed = 35 + ((days * 29 + channelIndex * 43) % 180);
const unknown = 12 + ((days * 11 + channelIndex * 7) % 72);
const success = submitted - failed - unknown;
const sent = submitted;
const revenue = BigInt(success * 9);
const cost = BigInt(success * (channelIndex === 2 ? 6 : 5));
const channelProfit = revenue - cost;
profit.push({
id: id(`profit-channel-${days}-${channelIndex}`), reportDate: date, dimensionType: 'channel', dimensionId: channelIds[channelIndex], dimensionName: channelNames[channelIndex],
channelId: channelIds[channelIndex], submittedUnits: submitted, sentUnits: sent, unknownUnits: unknown, successUnits: success, failedUnits: failed,
revenueCents: revenue, refundCents: 0n, costCents: cost, profitCents: channelProfit, profitRateBps: Number(channelProfit * 10_000n / revenue),
generatedAt: dateDaysAgo(days - 1, 2, 20), updatedAt: dateDaysAgo(days - 1, 2, 20),
});
quality.push({
id: id(`quality-channel-${days}-${channelIndex}`), reportDate: date, dimensionType: 'channel', dimensionId: channelIds[channelIndex], dimensionName: channelNames[channelIndex],
channelId: channelIds[channelIndex], submittedUnits: submitted, sentUnits: sent, unknownUnits: unknown, successUnits: success, failedUnits: failed,
successRateBps: Math.round(success * 10_000 / sent), avgArrivalMs: 1450 + ((days * 137 + channelIndex * 991) % 4800),
generatedAt: dateDaysAgo(days - 1, 2, 27), updatedAt: dateDaysAgo(days - 1, 2, 27),
});
}
for (let signatureIndex = 0; signatureIndex < signatureIds.length; signatureIndex += 1) {
const sent = 3300 + ((days * 431 + signatureIndex * 1201) % 6200);
const failed = 28 + ((days * 23 + signatureIndex * 31) % 130);
const unknown = 8 + ((days * 7 + signatureIndex * 5) % 45);
const success = sent - failed - unknown;
quality.push({
id: id(`quality-signature-${days}-${signatureIndex}`), reportDate: date, dimensionType: 'signature', dimensionId: signatureIds[signatureIndex], dimensionName: signatureNames[signatureIndex],
tenantId, tenantName, applicationId: applicationIds[signatureIndex % 2], signatureId: signatureIds[signatureIndex],
submittedUnits: sent, sentUnits: sent, unknownUnits: unknown, successUnits: success, failedUnits: failed,
successRateBps: Math.round(success * 10_000 / sent), avgArrivalMs: 1900 + ((days * 149 + signatureIndex * 733) % 5100),
generatedAt: dateDaysAgo(days - 1, 2, 29), updatedAt: dateDaysAgo(days - 1, 2, 29),
});
}
for (let drainageIndex = 0; drainageIndex < drainageIds.length; drainageIndex += 1) {
const sent = 1250 + ((days * 277 + drainageIndex * 881) % 3600);
const failed = 18 + ((days * 13 + drainageIndex * 19) % 85);
const unknown = 5 + ((days * 5 + drainageIndex * 3) % 28);
const success = sent - failed - unknown;
quality.push({
id: id(`quality-drainage-${days}-${drainageIndex}`), reportDate: date, dimensionType: 'drainage', dimensionId: drainageIds[drainageIndex], dimensionName: drainageIndex === 0 ? '星河优选商城' : '星河夏日活动',
tenantId, tenantName, applicationId: applicationIds[1], signatureId: signatureIds[1], drainageInfoId: drainageIds[drainageIndex],
submittedUnits: sent, sentUnits: sent, unknownUnits: unknown, successUnits: success, failedUnits: failed,
successRateBps: Math.round(success * 10_000 / sent), avgArrivalMs: 2300 + ((days * 181 + drainageIndex * 521) % 5800),
generatedAt: dateDaysAgo(days - 1, 2, 31), updatedAt: dateDaysAgo(days - 1, 2, 31),
});
}
}
await prisma.dailyReconciliationReport.createMany({ data: reconciliation, skipDuplicates: true });
await prisma.dailyProfitReport.createMany({ data: profit, skipDuplicates: true });
await prisma.dailyQualityReport.createMany({ data: quality, skipDuplicates: true });
return { reconciliation, profit, quality };
}
async function main() {
await clearPreviousSeed();
await ensureDimensions();
const sendTasks = await seedSendTasks();
const { batchRows, messageRows } = await seedBatchesAndMessages(sendTasks);
const protocolRows = await seedProtocolLogs(messageRows);
const reports = await seedReports();
console.log(JSON.stringify({
seed: PREFIX,
tenant: tenantName,
applications: applicationNames.length,
channels: channelNames.length,
sendTasks: sendTasks.length,
batchTasks: batchRows.length,
messages: messageRows.length,
protocolLogs: protocolRows.length,
reconciliationReports: reports.reconciliation.length,
profitReports: reports.profit.length,
qualityReports: reports.quality.length,
}, null, 2));
}
main()
.finally(async () => prisma.$disconnect())
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -64,7 +64,7 @@ for (const label of contract.interactionLabels) {
requirePattern(
style,
/@media\s*\(max-width:\s*900px\)[\s\S]*?\.admin-sms-record-card__meta[\s\S]*?\.admin-sms-segment-card dl/,
/@media\s*\(max-width:\s*900px\)[\s\S]*?\.admin-sms-segment-card dl[\s\S]*?\.admin-sms-record-filter__field/,
'900px record/detail responsive rule is missing',
);
requirePattern(
@@ -4,6 +4,7 @@ import { join } from 'node:path';
const examplesDir = join(process.cwd(), 'docs', 'contracts', 'examples');
const envelopeFields = ['schemaVersion', 'messageType', 'traceId', 'messageId', 'channelId', 'createdAt'];
const uplinkEnvelopeFields = envelopeFields.filter((field) => field !== 'messageId');
const requiredByType = {
SubmitCommand: [
...envelopeFields,
@@ -21,7 +22,7 @@ const requiredByType = {
],
SubmitResult: [...envelopeFields, 'sequenceId', 'gatewayMessageId', 'submitStatus', 'submittedAt'],
ReceiptEvent: [...envelopeFields, 'sequenceId', 'gatewayMessageId', 'receiptStatus', 'rawStatus', 'deliveredAt'],
UplinkEvent: [...envelopeFields, 'sequenceId', 'phoneNumber', 'destId', 'content', 'receivedAt'],
UplinkEvent: [...uplinkEnvelopeFields, 'sequenceId', 'gatewayMessageId', 'phoneNumber', 'destId', 'content', 'receivedAt'],
};
const validTypes = new Set(Object.keys(requiredByType));