diff --git a/api/prisma/migrations/20260703143000_add_channel_group_carrier/migration.sql b/api/prisma/migrations/20260703143000_add_channel_group_carrier/migration.sql new file mode 100644 index 0000000..8b03186 --- /dev/null +++ b/api/prisma/migrations/20260703143000_add_channel_group_carrier/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "SmsChannelGroup" ADD COLUMN "carrier" TEXT NOT NULL DEFAULT 'mobile'; + +CREATE UNIQUE INDEX "SmsChannelGroupItem_groupId_province_key" ON "SmsChannelGroupItem"("groupId", "province"); diff --git a/api/prisma/migrations/20260703152000_add_application_customer_rate/migration.sql b/api/prisma/migrations/20260703152000_add_application_customer_rate/migration.sql new file mode 100644 index 0000000..f61fd0b --- /dev/null +++ b/api/prisma/migrations/20260703152000_add_application_customer_rate/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "SmsApplication" ADD COLUMN "customerUnitPrice" INTEGER NOT NULL DEFAULT 0; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 2801d3d..611bf93 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -338,6 +338,7 @@ model SmsApplication { callbackUrl String? secretHash String dailyLimit Int? + customerUnitPrice Int @default(0) maxPhonesPerTask Int @default(1000000) templateMismatchMode String @default("reject") status String @default("active") @@ -527,6 +528,7 @@ model SmsChannelGroup { code String @unique name String description String? + carrier String @default("mobile") status String @default("active") retryEnabled Boolean @default(true) retryTimeLimitHours Int @default(72) @@ -553,6 +555,7 @@ model SmsChannelGroupItem { channel SmsChannel @relation(fields: [channelId], references: [id]) @@unique([groupId, channelId, carrier, province]) + @@unique([groupId, province]) @@index([channelId]) } diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 5d00cae..c501547 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -19,6 +19,7 @@ function createPrismaMock() { unitPrice: 3, status: 'active', config: { serviceId: 'SMS' }, + sendRegion: '山东', reportFields: [{ code: 'license', name: '营业执照', fieldType: 'file', required: true, description: null, sortOrder: 1, status: 'active' }], }; return { @@ -43,6 +44,7 @@ function createPrismaMock() { channelHealthMetric: { findMany: jest.fn() }, smsChannelGroup: { findMany: jest.fn(), + findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active' }), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })), }, smsChannelGroupItem: { @@ -114,7 +116,7 @@ describe('ChannelsService', () => { passwordCipher: 'secret', srcId: '10690000', }); - await service.createGroup({ code: 'G-MOBILE', name: '移动组', retryEnabled: true, retryTimeLimitHours: 24 }); + await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }); await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' }); expect(prisma.smsChannel.create).toHaveBeenCalledWith({ @@ -127,7 +129,7 @@ describe('ChannelsService', () => { }), }); expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ retryEnabled: true, retryTimeLimitHours: 24 }), + data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }), }); expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({ data: expect.objectContaining({ @@ -146,8 +148,78 @@ describe('ChannelsService', () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); - expect(() => service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', channelId: 'channel-1' })) - .toThrow('Route rules can only bind channel groups'); + await expect(service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', channelId: 'channel-1' })) + .rejects.toThrow('Route rules can only bind channel groups'); + expect(prisma.channelRouteRule.create).not.toHaveBeenCalled(); + }); + + it('enforces single-carrier channel groups and compatible group items', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + expect(() => service.createGroup({ code: 'G-ALL', name: '三网组', carrier: 'all' })).toThrow('carrier must be mobile, unicom, or telecom'); + + await service.addGroupItem({ groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: '山东', priority: 10 }); + expect(prisma.smsChannelGroupItem.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', province: '山东' }), + }); + + await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-1', carrier: 'unicom' })) + .rejects.toThrow('Channel group items must use the same carrier'); + + const compatibleChannel = { + id: 'channel-1', + code: 'CMPP-A', + name: '主通道', + carrier: 'mobile', + protocol: 'CMPP', + gatewayHost: '127.0.0.1', + gatewayPort: 17890, + enterpriseCode: 'EC', + account: 'sp', + passwordCipher: 'secret', + srcId: '10690000', + cmppVersion: '3.0', + rateLimitPerSecond: 100, + unitPrice: 3, + status: 'active', + config: { serviceId: 'SMS' }, + sendRegion: '山东', + }; + prisma.smsChannel.findUnique.mockResolvedValueOnce({ ...compatibleChannel, carrier: 'telecom' }); + await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-x', carrier: 'mobile' })) + .rejects.toThrow('Channel carrier is not compatible'); + + prisma.smsChannel.findUnique.mockResolvedValue(compatibleChannel); + prisma.smsChannelGroupItem.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'province-item', province: '山东' }); + await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-2', carrier: 'mobile', province: '山东' })) + .rejects.toThrow('同一通道组内同一省份只能配置一个通道'); + }); + + it('rejects province routes with mismatched channel sendRegion and duplicate national priorities', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + prisma.smsChannel.findUnique.mockResolvedValue({ id: 'channel-henan', carrier: 'all', sendRegion: '河南' }); + await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-henan', carrier: 'mobile', province: '山东' })) + .rejects.toThrow('Province route must use a channel with the same sendRegion'); + + prisma.smsChannel.findUnique.mockResolvedValue({ id: 'channel-national', carrier: 'all', sendRegion: '全国' }); + prisma.smsChannelGroupItem.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'national-priority-1', province: null, priority: 1 }); + await expect(service.addGroupItem({ groupId: 'group-1', channelId: 'channel-national', carrier: 'mobile', priority: 1 })) + .rejects.toThrow('同一通道组内全国通道优先级不能重复'); + }); + + it('requires route rule carrier to match the channel group carrier', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + await expect(service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'unicom' })) + .rejects.toThrow('Route rule carrier must match the channel group carrier'); expect(prisma.channelRouteRule.create).not.toHaveBeenCalled(); }); diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index d79d9dc..751906d 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -24,6 +24,7 @@ export interface CreateChannelDto { export interface CreateChannelGroupDto { code: string; name: string; + carrier: string; description?: string; status?: string; retryEnabled?: boolean; @@ -391,10 +392,12 @@ export class ChannelsService { if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) { throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72'); } + const carrier = normalizeBusinessCarrier(data.carrier); return this.prisma.smsChannelGroup.create({ data: { code: data.code, name: data.name, + carrier, description: data.description, status: data.status ?? 'active', retryEnabled: data.retryEnabled ?? true, @@ -404,17 +407,51 @@ export class ChannelsService { } async addGroupItem(data: CreateChannelGroupItemDto) { + const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } }); + if (!group) { + throw new NotFoundException('Channel group not found'); + } + const groupCarrier = normalizeBusinessCarrier(group.carrier); + const itemCarrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : groupCarrier; + if (itemCarrier !== groupCarrier) { + throw new BadRequestException('Channel group items must use the same carrier as the channel group'); + } + const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) { + throw new BadRequestException('Channel carrier is not compatible with the channel group carrier'); + } + if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) { + throw new BadRequestException('Province route must use a channel with the same sendRegion'); + } const existing = await this.prisma.smsChannelGroupItem.findFirst({ where: { groupId: data.groupId, channelId: data.channelId }, }); if (existing) { throw new BadRequestException('通道组内不能重复配置同一通道'); } + if (data.province) { + const existingProvince = await this.prisma.smsChannelGroupItem.findFirst({ + where: { groupId: data.groupId, province: data.province }, + }); + if (existingProvince) { + throw new BadRequestException('同一通道组内同一省份只能配置一个通道'); + } + } else { + const existingPriority = await this.prisma.smsChannelGroupItem.findFirst({ + where: { groupId: data.groupId, province: null, priority: data.priority ?? 100 }, + }); + if (existingPriority) { + throw new BadRequestException('同一通道组内全国通道优先级不能重复'); + } + } return this.prisma.smsChannelGroupItem.create({ data: { groupId: data.groupId, channelId: data.channelId, - carrier: data.carrier, + carrier: itemCarrier, province: data.province, priority: data.priority ?? 100, weight: data.weight ?? 1, @@ -432,26 +469,34 @@ export class ChannelsService { }); } - createRouteRule(data: CreateRouteRuleDto) { + async createRouteRule(data: CreateRouteRuleDto) { if (!data.applicationId) { throw new BadRequestException('applicationId is required for channel group routing'); } if (!data.carrier) { throw new BadRequestException('carrier is required for application channel group routing'); } + const carrier = normalizeBusinessCarrier(data.carrier); if (data.channelId) { throw new BadRequestException('Route rules can only bind channel groups, not single channels'); } if (data.province) { throw new BadRequestException('Province routing must be configured inside the channel group'); } + const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: data.groupId } }); + if (!group) { + throw new NotFoundException('Channel group not found'); + } + if (normalizeBusinessCarrier(group.carrier) !== carrier) { + throw new BadRequestException('Route rule carrier must match the channel group carrier'); + } return this.prisma.channelRouteRule.create({ data: { tenantId: data.tenantId, applicationId: data.applicationId, groupId: data.groupId, channelId: undefined, - carrier: data.carrier, + carrier, province: undefined, priority: data.priority ?? 100, status: data.status ?? 'active', @@ -645,6 +690,36 @@ function normalizeConnectionAction(status: string) { return 'updated'; } +function normalizeBusinessCarrier(carrier?: string | null) { + const normalized = normalizeChannelCarrier(carrier); + if (!['mobile', 'unicom', 'telecom'].includes(normalized)) { + throw new BadRequestException('carrier must be mobile, unicom, or telecom'); + } + return normalized; +} + +function normalizeChannelCarrier(carrier?: string | null) { + const value = String(carrier ?? '').trim().toLowerCase(); + if (['mobile', 'cmcc', '移动', '中国移动'].includes(value)) return 'mobile'; + if (['unicom', 'cucc', '联通', '中国联通'].includes(value)) return 'unicom'; + if (['telecom', 'ctcc', '电信', '中国电信'].includes(value)) return 'telecom'; + if (['all', 'tri', '三网', '全网'].includes(value)) return 'all'; + return value; +} + +function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string) { + const normalized = normalizeChannelCarrier(channelCarrier); + return normalized === 'all' || normalized === groupCarrier; +} + +function normalizeRegion(region?: string | null) { + return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim(); +} + +function isRegionCompatible(channelRegion: string | null | undefined, itemProvince: string) { + return normalizeRegion(channelRegion) === normalizeRegion(itemProvince); +} + function normalizeLinkEvent(action: string) { if (action.includes('connected')) { return '新建'; diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 66392d3..adcc948 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -17,7 +17,10 @@ function createPrismaMock() { unitPrice: 3, amountCents: 3, status: 'queued', - template: { signature: { name: '签名' } }, + submitId: 'SUB-1', + gatewayMessageId: 'GW-1', + channelId: 'channel-1', + template: { signature: { id: 'sig-1', name: '签名' } }, }; const channel = { id: 'channel-1', @@ -42,10 +45,11 @@ function createPrismaMock() { status: 'active', group: { id: 'group-1', + carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, - items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', priority: 1, province: null, channel }], + items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', priority: 1, province: null, channel }], }, }; return { @@ -53,7 +57,7 @@ function createPrismaMock() { findUnique: jest.fn().mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'approved' }), }, smsApplication: { - findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active' }), + findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1', status: 'active', customerUnitPrice: 3 }), }, smsTemplate: { findUnique: jest.fn().mockResolvedValue({ @@ -104,6 +108,9 @@ function createPrismaMock() { updateMany: jest.fn().mockResolvedValue({ count: 1 }), findMany: jest.fn().mockResolvedValue([]), }, + channelSignatureReportTask: { + findFirst: jest.fn().mockResolvedValue({ id: 'report-task-1' }), + }, smsReceiptRecord: { create: jest.fn().mockResolvedValue({ id: 'receipt-1' }), findMany: jest.fn(), @@ -118,6 +125,9 @@ function createPrismaMock() { update: jest.fn().mockResolvedValue({ id: 'bill-1' }), updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, + accountTransaction: { + findFirst: jest.fn().mockResolvedValue(null), + }, enterpriseBlacklist: { findMany: jest.fn().mockResolvedValue([]), }, @@ -342,13 +352,17 @@ describe('SendChainService', () => { it('releases reservation for rejected submit result and refunds failed receipts', async () => { const { service, prisma, billing } = createService(); + prisma.smsBillingRecord.findFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' }); prisma.channelRouteRule.findFirst.mockResolvedValue({ id: 'route-1', tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile', - group: { id: 'group-1', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, items: [] }, + group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, items: [] }, }); await service.handleSubmitResult({ @@ -369,6 +383,127 @@ describe('SendChainService', () => { expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' })); }); + it('does not let stale failed receipts overwrite a later delivered message', async () => { + const { service, prisma, billing } = createService(); + prisma.smsMessageRecord.findFirst.mockResolvedValue({ + id: 'record-1', + tenantId: 'tenant-1', + batchTaskId: 'task-1', + applicationId: 'app-1', + templateId: 'tpl-1', + messageId: 'MSG-1', + phoneNumber: '13800000001', + content: 'hello', + channelId: 'channel-new', + gatewayMessageId: 'GW-NEW', + status: 'delivered', + amountCents: 3, + billingUnits: 1, + unitPrice: 3, + }); + + await service.handleReceipt({ + messageId: 'MSG-1', + channelId: 'channel-old', + gatewayMessageId: 'GW-OLD', + receiptStatus: 'undelivered', + rawStatus: 'UNDELIV', + }); + + expect(prisma.smsReceiptRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ channelId: 'channel-old', gatewayMessageId: 'GW-OLD', receiptStatus: 'undelivered' }), + }); + expect(billing.refund).not.toHaveBeenCalled(); + expect(prisma.smsMessageRecord.update).not.toHaveBeenCalledWith({ + where: { id: 'record-1' }, + data: expect.objectContaining({ status: 'failed' }), + }); + }); + + it('blocks submit when signature is not approved on the selected channel', async () => { + const { service, prisma } = createService(); + const gatewayAdd = jest.fn().mockResolvedValue(undefined); + service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined); + service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd }); + prisma.channelSignatureReportTask.findFirst.mockResolvedValue(null); + + await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual( + expect.objectContaining({ submitted: false, status: 'failed', reason: '短信签名未在最终通道报备通过' }), + ); + expect(gatewayAdd).not.toHaveBeenCalled(); + }); + + it('only selects channel group items allocated to the matched carrier', async () => { + const { service, prisma } = createService(); + const gatewayAdd = jest.fn().mockResolvedValue(undefined); + service['waitForChannelRateLimit'] = jest.fn().mockResolvedValue(undefined); + service['getGatewayQueue'] = jest.fn().mockReturnValue({ add: gatewayAdd }); + prisma.channelRouteRule.findFirst.mockResolvedValue({ + id: 'route-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + groupId: 'group-1', + carrier: 'mobile', + group: { + id: 'group-1', + carrier: 'mobile', + status: 'active', + retryEnabled: true, + retryTimeLimitHours: 72, + items: [ + { + id: 'wrong-item', + groupId: 'group-1', + channelId: 'channel-unicom', + carrier: 'unicom', + priority: 1, + province: null, + channel: { + id: 'channel-unicom', + code: 'CMPP-U', + account: 'u', + srcId: '1061', + rateLimitPerSecond: 100, + unitPrice: 3, + status: 'active', + carrier: 'all', + sendRegion: '全国', + connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }], + }, + }, + { + id: 'mobile-item', + groupId: 'group-1', + channelId: 'channel-all', + carrier: 'mobile', + priority: 2, + province: null, + channel: { + id: 'channel-all', + code: 'CMPP-ALL', + account: 'all', + srcId: '1062', + rateLimitPerSecond: 100, + unitPrice: 3, + status: 'active', + carrier: 'all', + sendRegion: '全国', + connectionStates: [{ status: 'online', currentConnections: 1, desiredConnections: 1 }], + }, + }, + ], + }, + }); + + await expect(service.processSendJob({ messageRecordId: 'record-1' })).resolves.toEqual( + expect.objectContaining({ channelId: 'channel-all' }), + ); + expect(gatewayAdd).toHaveBeenCalledWith( + 'submit-command', + expect.objectContaining({ channelId: 'channel-all', route: expect.objectContaining({ channelCode: 'CMPP-ALL', carrier: 'mobile' }) }), + ); + }); + it('records receipts and uplink messages from gateway events', async () => { const { service, prisma } = createService(); diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index b08af13..d56ead5 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -464,7 +464,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } try { const routed = await this.selectChannelForMessage(message); - return this.submitMessageToGateway(message, routed, 0); + return await this.submitMessageToGateway(message, routed, 0); } catch (error) { const reason = error instanceof Error ? error.message : '无可用通道组或通道'; await this.prisma.smsMessageRecord.update({ @@ -491,6 +491,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { submittedAt, }, }); + if (data.submitId && message.submitId && data.submitId !== message.submitId) { + return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); + } const status = data.submitStatus === 'accepted' ? 'submitted' : data.submitStatus === 'timeout' ? 'timeout' : 'submit_failed'; if (data.submitStatus === 'accepted') { await this.chargeAcceptedMessage(message); @@ -538,6 +541,12 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { deliveredAt, }, }); + const isCurrentAttempt = + (!message.channelId || message.channelId === data.channelId) + && (!message.gatewayMessageId || message.gatewayMessageId === data.gatewayMessageId); + if (!isCurrentAttempt || (status === 'failed' && message.status === 'delivered')) { + return this.prisma.smsMessageRecord.findUnique({ where: { id: message.id } }); + } if (status === 'failed') { const retried = await this.retryMessageIfAllowed(message, '回执失败补发'); if (retried) { @@ -619,12 +628,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { phoneNumber: string; content: string; billingUnits: number; - template?: { signature?: { name?: string | null } | null } | null; + template?: { signature?: { id?: string | null; name?: string | null } | null } | null; }, routed: RoutedChannel, attempt: number, ) { const channel = routed.channel; + await this.ensureSignatureReportedForChannel(message, channel.id); await this.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond); const submitId = `SUB-${randomUUID()}`; const session = await this.prisma.cmppSubmitSession.upsert({ @@ -733,7 +743,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { where: { id: message.id }, data: { errorMessage: reason }, }); - return this.submitMessageToGateway(message, routed, attempts.length); + return await this.submitMessageToGateway(message, routed, attempts.length); } catch { return null; } @@ -750,7 +760,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { const route = await this.findApplicationRoute(message.tenantId, message.applicationId, carrier); const province = await this.identifyProvince(message.phoneNumber); const excluded = new Set(options.excludeChannelIds ?? []); - const items = route.group.items.filter((item) => !excluded.has(item.channelId) && isCarrierCompatible(item.channel.carrier, carrier)); + const items = route.group.items.filter((item) => + !excluded.has(item.channelId) + && normalizeCarrier(item.carrier) === carrier + && isCarrierCompatible(item.channel.carrier, carrier), + ); const provinceCandidates = options.forceNational ? [] : items.filter((item) => isProvinceChannel(item, province)); const nationalCandidates = items.filter((item) => isNationalChannel(item)); const selected = [...provinceCandidates, ...nationalCandidates].find((item) => this.isChannelSendAvailable(item.channel)); @@ -785,6 +799,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (route.group.status !== 'active') { throw new BadRequestException('企业应用绑定的通道组已停用'); } + if (normalizeCarrier(route.group.carrier) !== carrier) { + throw new BadRequestException('企业应用绑定的通道组运营商与路由规则不一致'); + } return route; } @@ -826,17 +843,17 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } private async resolveUnitPrice(tenantId: string, applicationId?: string) { - try { - const route = await this.prisma.channelRouteRule.findFirst({ - where: { tenantId, applicationId, status: 'active', channelId: null }, - include: { group: { include: { items: { include: { channel: true }, orderBy: { priority: 'asc' } } } } }, - orderBy: { priority: 'asc' }, - }); - const channel = route?.group.items.find((item) => item.channel.status === 'active')?.channel; - return channel?.unitPrice ?? 0; - } catch { + if (!applicationId) { return 0; } + const application = await this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + select: { tenantId: true, customerUnitPrice: true }, + }); + if (!application || application.tenantId !== tenantId) { + return 0; + } + return application.customerUnitPrice ?? 0; } private async validateSendResources(tenantId: string, applicationId?: string, templateId?: string) { @@ -935,12 +952,18 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (charged) { return; } + const released = await this.prisma.accountTransaction.findFirst({ + where: { relatedType: 'sms_message_record', relatedId: message.messageId, transactionType: 'released' }, + }); + if (released) { + return; + } await this.billing.release({ tenantId: message.tenantId, amountCents: message.amountCents, smsUnits: message.billingUnits, - relatedType: 'sms_batch_task', - relatedId: message.batchTaskId, + relatedType: 'sms_message_record', + relatedId: message.messageId, remark: `${remark}: ${message.messageId}`, }); } @@ -952,6 +975,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) { return; } + const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } }); + if (refunded) { + return; + } + const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } }); + if (!charged) { + return; + } const transaction = await this.billing.refund({ tenantId: message.tenantId, amountCents: message.amountCents, @@ -966,6 +997,34 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }); } + private async ensureSignatureReportedForChannel( + message: { + id: string; + templateId?: string | null; + template?: { signature?: { id?: string | null; name?: string | null } | null } | null; + }, + channelId: string, + ) { + let signatureId = message.template?.signature?.id ?? null; + if (!signatureId && message.templateId) { + const template = await this.prisma.smsTemplate.findUnique({ + where: { id: message.templateId }, + include: { signature: true }, + }); + signatureId = template?.signature?.id ?? null; + } + if (!signatureId) { + throw new BadRequestException('短信签名未配置,不能提交到通道'); + } + const reportTask = await this.prisma.channelSignatureReportTask.findFirst({ + where: { signatureId, channelId, status: 'approved' }, + select: { id: true }, + }); + if (!reportTask) { + throw new BadRequestException('短信签名未在最终通道报备通过'); + } + } + private async waitForChannelRateLimit(channelId: string, tps: number) { const redis = this.getRedis(); for (;;) { diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index f13700c..36deab0 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -9,6 +9,7 @@ export interface CreateSmsApplicationDto { scene?: string; callbackUrl?: string; dailyLimit?: number; + customerUnitPrice?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[]; @@ -120,6 +121,7 @@ export class SmsConfigService { callbackUrl: data.callbackUrl, secretHash: hashSecret(secret), dailyLimit: data.dailyLimit, + customerUnitPrice: data.customerUnitPrice ?? 0, maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000, templateMismatchMode: data.templateMismatchMode ?? 'reject', ipAllowlist: { diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 7880549..d6bb9f1 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -136,22 +136,25 @@ 1. 运营端配置短信通道,包括通道名称、运营商、单价、网关地址、端口、企业代码、账号、密码、接入号、协议参数、启停状态。 2. 通道运营商支持移动、联通、电信和三网;三网通道可作为移动、联通、电信的通配通道。 3. 通道必须配置发送地区,发送地区为全国或 34 个省级地区之一;单个通道只能选择一个发送地区。 -4. 运营端配置短信通道组,定义通道优先级、运营商适配、省网路由、全国路由、权重、失败补发开关和补发时间上限。 +4. 运营端配置短信通道组,通道组必须选择且只能选择一个运营商:移动、联通或电信;通道组不允许选择三网。通道组定义通道优先级、运营商分配、省网路由、全国路由、权重、失败补发开关和补发时间上限。 5. 企业不配置默认通道组;企业应用必须单独配置至少一个运营商通道组,否则页面不可保存,发送时也必须直接失败。 6. 一个企业应用可以分别绑定移动、联通、电信通道组;可以只绑定其中一类或两类,但不能一个都不绑定。 7. 发送时先按运营商识别结果分流:移动短信走移动通道组,联通短信走联通通道组,电信短信走电信通道组;识别不出的号码走移动通道组。 -8. 运营商识别优先使用可配置的号码前缀正则表达式,通常匹配手机号前 3 到 4 位;如当前没有配置能力,手机号段库需增加“运营商区分规则”配置。 +8. 运营商识别以可配置的号码前缀正则表达式为准,通常匹配手机号前 3 到 4 位;手机号段库只提供省份/城市识别,其 carrier 字段仅作后台校验或提示,不参与发送运营商判定。 9. 路由规则只能表达应用到通道组的绑定关系,不能直接绑定单个通道,也不在规则层配置省份;省份和全国路由在通道组内部处理。 10. 发送服务必须使用手机号段库识别手机号省份和城市;无法识别省份时走对应运营商的全国通道组路由。 11. 通道组内必须先匹配省网路由;省网未匹配时走同一运营商通道组内的全国通道。省网发送失败后,当前版本立即跳到该通道组第一个全国通道补发,不再尝试同省第二省网通道。 -12. 省网配置当前版本一省只能配置一个通道;全国通道可配置多个,并按优先级依次补发。 -13. 未命中企业应用通道组或无可用通道时不得 fallback 到全局第一个 active 通道,应将该短信标记为 failed,并记录可读失败原因、trace 和系统日志;客户端本期不展示通道细节。 -14. 可发送通道必须同时满足:通道业务状态 active、CMPP 连接状态 online、当前连接数大于 0、心跳未失败;auth_failed、heartbeat_timeout、reconnecting、disconnected 或当前连接数为 0 的通道均不可被选中。 -15. 多连接通道只要至少 1 条连接 online 且可用即可参与路由;心跳失败应及时更新连接状态,连续 3 次心跳失败后进入重连,重连成功前不可发送。 -16. 通道异常时应支持熔断、降级、切换备用通道和失败重试;失败补发必须在同一企业应用授权的通道组范围内执行,并保证计费、退款、幂等和 trace 可追踪。 -17. 失败补发除以下情况外均应触发:短信状态为 unknown;距离客户提交时间超过 72 小时;距离客户提交时间超过通道组配置的补发时间上限;通道组关闭失败补发。 -18. 通道组补发时间上限由运营端配置,最大不得超过 72 小时;本期不配置最大补发次数、补发间隔或失败类型白名单。 -19. 提交 accepted 后立即按企业应用配置的客户费率扣费;补发过程中最终成功只扣一次,submit failed 未真正发出时释放冻结且不扣费;本期客户计费不使用通道成本价。 +12. 省网配置当前版本按通道组内省份唯一:同一个通道组内山东只能选择一个通道、河南只能选择一个通道,依此类推;该校验针对通道组明细里的省份配置,不是校验通道本体属性。省网明细的省份必须与引用通道的发送地区一致,例如山东省网不能引用发送地区为河南的通道。 +13. 全国通道可配置多个,并按优先级依次补发;同一通道组内全国通道优先级禁止重复,本期不支持权重分流。 +14. 通道组明细 `carrier` 必须保留并参与发送逻辑,且必须等于通道组运营商。三网只允许作为通道本体能力 `SmsChannel.carrier=all`,表示该通道可被运营分配到移动、联通或电信通道组;一旦放入某个通道组,只能服务该通道组所属运营商。 +15. 通道组明细引用通道时必须校验通道能力:移动组只能引用移动通道或三网通道,联通组只能引用联通通道或三网通道,电信组只能引用电信通道或三网通道。 +16. 未命中企业应用通道组或无可用通道时不得 fallback 到全局第一个 active 通道,应将该短信标记为 failed,并记录可读失败原因、trace 和系统日志;客户端本期不展示通道细节。 +17. 可发送通道必须同时满足:通道业务状态 active、CMPP 连接状态 online、当前连接数大于 0、心跳未失败;auth_failed、heartbeat_timeout、reconnecting、disconnected 或当前连接数为 0 的通道均不可被选中。 +18. 多连接通道只要至少 1 条连接 online 且可用即可参与路由;心跳失败应及时更新连接状态,连续 3 次心跳失败后进入重连,重连成功前不可发送。 +19. 通道异常时应支持熔断、降级、切换备用通道和失败重试;失败补发必须在同一企业应用授权的通道组范围内执行,并使用触发补发时的当前通道组配置,保证计费、退款、幂等和 trace 可追踪。 +20. 失败补发除以下情况外均应触发:短信状态为 unknown;距离客户提交时间超过 72 小时;距离客户提交时间超过通道组配置的补发时间上限;通道组关闭失败补发。 +21. 通道组补发时间上限由运营端配置,最大不得超过 72 小时;本期不配置最大补发次数、补发间隔、失败类型白名单或人工重发能力,进入最终 failed/timeout 后不再人工重发。 +22. 提交 accepted 后立即按企业应用配置的客户费率扣费;补发过程中最终成功只扣一次,submit failed 未真正发出时释放冻结且不扣费;failed receipt 导致最终全失败时退款;本期客户计费不使用通道成本价。 ### 4.7 通道签名报备 @@ -163,6 +166,7 @@ 6. 运营端在报备任务或通道报备详情页导入通道回执。 7. 系统根据回执同步签名在各通道的报备状态。 8. 报备记录保留每次导出、导入、状态变更和操作人。 +9. 发送前必须校验最终选中通道上的签名报备任务为 approved;补发切换到新通道时必须重新按新通道校验报备状态,未通过则该次发送失败。 ### 4.8 回执与上行 @@ -170,20 +174,23 @@ 2. 回执状态至少包括提交成功、提交失败、发送成功、发送失败、未知、超时。 3. 提交后 72 小时仍为未知的短信转超时,返回客户失败。 4. 超时前允许人工同步、重新拉取回执或接收供应商二次回执;所有二次回执必须记录历史。 -5. 上行短信接入后优先按手机号、接入号、企业/应用、时间窗口匹配下发记录。 -6. 接入号匹配不到企业/应用时,展示近期平台对此手机号下发的短信供人工判断。 -7. 完全匹配不到的上行短信仍需入库,并展示为未匹配。 -8. 客户端可查看本企业上行短信,运营端可查看全平台上行短信。 +5. 旧通道迟到的 failed receipt 如果对应短信已经由新通道 delivered,不得覆盖短信记录最终 delivered 状态;短信详情弹窗必须能看到该历史 failed receipt。 +6. 重复提交结果或重复回执必须幂等处理,不得重复扣费、重复释放冻结或重复退款。 +7. 上行短信接入后优先按手机号、接入号、企业/应用、时间窗口匹配下发记录。 +8. 接入号匹配不到企业/应用时,展示近期平台对此手机号下发的短信供人工判断。 +9. 完全匹配不到的上行短信仍需入库,并展示为未匹配。 +10. 客户端可查看本企业上行短信,运营端可查看全平台上行短信。 ### 4.9 账户计费 1. 客户端可查看充值套餐、购买或申请充值套餐、查看账单流水。 -2. 发送创建时按短信内容计费条数、企业单价或套餐规则生成预估费用,计费条数只按 70/67 字规则拆分。 +2. 发送创建时按短信内容计费条数、企业应用客户单价或套餐规则生成预估费用,计费条数只按 70/67 字规则拆分;不按移动、联通、电信配置不同客户价。 3. 平台需在发送前检查企业账户余额、套餐余量或授信额度。 4. 发送链路需记录计费条数、计费单价、计费金额、账务状态。 5. 账单流水与短信记录可追溯关联,支持按企业、应用、任务、手机号、时间对账。 6. 最终失败、超时失败需要退费。 -7. 计费口径可配置为按提交成功计费或按回执成功计费。 +7. 三网通道成本只用于平台内部成本核算,不影响客户扣费金额。 +8. 当前版本计费口径固定为提交 accepted 扣费、最终 failed receipt/timeout 退款。 ## 5. 功能需求 @@ -479,7 +486,7 @@ 4. Send Worker 消费消息,校验黑名单、敏感词、限额。 5. 使用可配置号码前缀正则识别运营商;识别不出时按移动处理。 6. 使用手机号段库识别号码省份和城市;识别不出省份时按全国路由处理。 -7. 按企业应用绑定的对应运营商通道组执行路由:先匹配省网通道,再匹配全国通道;不得直接绑定或 fallback 到非授权单通道。 +7. 按企业应用绑定的对应运营商通道组执行路由:通道组只能是移动、联通、电信之一,发送时必须同时满足路由规则运营商、通道组运营商、通道组明细 carrier 与号码识别运营商一致;再校验通道本体 carrier 为对应运营商或三网;最后先匹配省网通道,再匹配全国通道,不得直接绑定或 fallback 到非授权单通道。 8. 过滤业务 disabled、连接离线、认证失败、心跳超时或无可用连接数的通道。 9. 通过通道限速器控制 TPS。 10. 调用 Gateway Adapter 提交短信。 @@ -521,8 +528,8 @@ ### 9.3 通道与路由 - sms_channel:短信通道。 -- sms_channel_group:通道组。 -- sms_channel_group_item:通道组通道明细。 +- sms_channel_group:通道组,必须配置单一运营商 `mobile/unicom/telecom`,不支持三网通道组。 +- sms_channel_group_item:通道组通道明细,`carrier` 必须等于所属通道组运营商;省网明细按 `groupId + province` 唯一,全国明细可多条。 - channel_route_rule:路由规则。 - channel_health_metric:通道健康指标。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index e64bcff..2b3e19f 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -248,16 +248,27 @@ ### TC-ADMIN-004 通道组与路由规则 - 优先级:P0 -- 前置条件:存在主通道和备用通道。 +- 前置条件:存在移动、联通、电信专属通道和三网通道,存在主通道和备用通道。 - 步骤: - 1. 创建通道组。 - 2. 添加主通道优先级 10、备用通道优先级 20。 - 3. 创建租户/应用维度路由规则。 - 4. 创建发送任务触发送链路。 + 1. 创建移动通道组、联通通道组、电信通道组。 + 2. 尝试创建三网通道组。 + 3. 在移动通道组中添加 mobile item,引用移动通道或三网通道。 + 4. 尝试在移动通道组中添加 unicom item 或引用电信专属通道。 + 5. 尝试在移动通道组中添加山东省网 item,但引用发送地区为河南的通道。 + 6. 在同一通道组中为山东省重复添加第二个省网通道。 + 7. 添加主通道优先级 10、备用全国通道优先级 20,并尝试添加另一个优先级 20 的全国通道。 + 8. 创建租户/应用维度路由规则。 + 9. 创建发送任务触发送链路。 - 预期结果: + - 只能创建 mobile/unicom/telecom 通道组,三网通道组被拒绝。 + - 通道组明细 carrier 必须等于通道组运营商。 + - 通道组明细引用通道时必须满足通道本体能力兼容:移动组只允许 mobile/all,联通组只允许 unicom/all,电信组只允许 telecom/all。 + - 省网 item 的省份必须与通道发送地区一致。 + - 同一通道组内同一省份只能配置一个通道,全国通道可配置多个但优先级不能重复。 - 路由优先命中租户/应用规则。 - 主通道 active 时选择主通道。 - 主通道 disabled 时选择备用 active 通道。 + - 通道组不支持权重分流,同优先级全国通道配置被拒绝。 ### TC-ADMIN-005 签名报备任务生成与导出 @@ -286,6 +297,7 @@ - 报备任务状态变为 approved。 - 签名 `reportStatus` 同步为 approved。 - 报备记录包含 receipt_import。 + - 发送前仅允许使用最终选中通道上报备状态为 approved 的签名。 ### TC-ADMIN-007 报备回执导入失败 @@ -613,11 +625,12 @@ ### TC-BILLING-003 submit 成功扣费 - 优先级:P0 -- 前置条件:计费口径为 submit_success。 +- 前置条件:企业应用已配置客户单价,发送任务已冻结预算。 - 步骤:模拟 Gateway 返回 submit accepted。 - 预期结果: - 生成 charged 流水。 - 短信计费记录状态更新为 charged。 + - 扣费金额按企业应用客户单价计算,不按通道成本价或运营商差异价计算。 - 可按 messageId 对账。 ### TC-BILLING-004 最终失败退款 @@ -629,6 +642,7 @@ - 短信状态 failed/undelivered。 - 生成 refunded 流水。 - 退款金额与原扣费一致。 + - 重复 failed receipt 不重复退款。 ### TC-BILLING-005 72 小时超时退款 @@ -709,6 +723,7 @@ 3. 查询 submit record、trace 和路由日志。 - 预期结果: - 系统按可配置前缀正则识别运营商,并按手机号段库查询省份和城市。 + - 手机号段 carrier 仅作为后台校验或提示,不改变正则识别出的发送运营商。 - trace 记录识别出的运营商、省份和城市。 - 路由优先选择山东省网通道 A。 - Gateway SubmitCommand 的 channelId 为 A。 @@ -741,6 +756,7 @@ - 补发不跨出企业应用授权通道组。 - 原失败原因、补发次数、前后 channelId 和状态流转在 trace 中可查。 - 最终成功只按企业应用客户费率扣一次,不按通道成本价重复扣费。 + - 补发选择通道时使用当前通道组配置,并重新校验新通道上的签名报备状态。 ### TC-SEND-015 企业应用通道组保存校验 @@ -768,17 +784,21 @@ - 联通号码进入联通通道组。 - 电信号码进入电信通道组。 - 运营商识别失败时进入移动通道组。 + - 若手机号段库 carrier 与正则识别结果冲突,以正则识别结果为准,号段 carrier 仅记录为提示信息。 ### TC-SEND-017 三网通道作为运营商通配 - 优先级:P0 -- 前置条件:企业应用绑定移动通道组;移动通道组内无可用移动全国通道,但有可用三网全国通道。 +- 前置条件:企业应用绑定移动通道组;移动通道组 carrier 为 mobile;组内明细 carrier 为 mobile;组内无可用移动全国通道,但有可用三网全国通道。 - 步骤: 1. 提交移动号码。 2. 触发送 worker。 3. 查询 submit record 和 trace。 + 4. 将同一三网通道放入联通通道组,再提交移动号码。 - 预期结果: - - 三网通道可作为移动、联通、电信的通配通道参与路由。 + - 三网通道本体 `channel.carrier=all` 只表示通道能力,可被运营分配到移动、联通或电信通道组。 + - 移动号码只会选择移动通道组内 `item.carrier=mobile` 且通道本体为 mobile/all 的通道。 + - 放入联通通道组的三网通道不能被移动号码选中。 - route/trace 标明实际命中的三网通道。 ### TC-SEND-018 失败补发停止条件 @@ -798,6 +818,33 @@ - 超过通道组补发时间上限不触发补发。 - 通道组关闭失败补发时不触发补发。 +### TC-SEND-019 迟到旧通道回执不覆盖最终成功 + +- 优先级:P0 +- 前置条件:短信首次通过通道 A 发送失败并补发到通道 B,通道 B 已返回 delivered。 +- 步骤: + 1. 查询短信记录列表,确认该短信当前状态为 delivered,channelId/gatewayMessageId 为通道 B。 + 2. 模拟通道 A 迟到的 failed receipt。 + 3. 查询短信记录列表、短信详情弹窗、回执历史和账单流水。 +- 预期结果: + - 短信记录列表仍展示最终 delivered 状态,不被通道 A 的 failed receipt 覆盖。 + - 短信详情弹窗可见通道 A 的历史 failed receipt。 + - 不产生重复退款,也不改变已成功短信的最终计费状态。 + +### TC-SEND-020 重复回调幂等 + +- 优先级:P0 +- 前置条件:存在一条已 submit accepted 并完成账务扣费的短信。 +- 步骤: + 1. 连续发送两次相同 submit accepted 回调。 + 2. 连续发送两次相同 failed receipt 回调。 + 3. 查询短信记录、提交记录、回执记录、短信计费记录和账户流水。 +- 预期结果: + - 提交/回执历史可追踪,但短信最终状态按最新有效尝试处理。 + - accepted 不重复扣费。 + - failed receipt 不重复退款。 + - 账户流水、短信计费记录和 reconciliation 无重复金额。 + ### TC-SEND-004 SubmitResult accepted - 优先级:P0 diff --git a/docs/testing-plan.md b/docs/testing-plan.md index 9719557..2d1584c 100644 --- a/docs/testing-plan.md +++ b/docs/testing-plan.md @@ -15,9 +15,9 @@ - 目标:覆盖纯业务规则、状态流转、查询条件、Gateway tracker/reconnector/health 等无需真实外部服务的逻辑。 - 当前重点: - 风控规则评估:最大号码数、重复率、非法号码率、黑名单率、模板变量异常、直接拒绝、进入人工审核。 - - 计费:费用预估、余额检查、冻结、扣费、释放、退款、短信计费记录。 - - 发送链路:批量任务创建、手机号拆分、发送入队、运营商前缀正则分流、手机号段归属地识别、应用运营商通道组路由、省网/全国路由、submit result 更新、receipt 更新、失败补发、补发停止条件、uplink 记录、72 小时未知转超时。 - - 通道与报备:通道创建、通道发送地区、三网通道通配、通道组路由规则、企业应用通道组保存校验、禁止单通道发送规则、签名报备任务、导出、回执导入、签名状态同步。 + - 计费:费用预估、余额检查、冻结、提交 accepted 扣费、失败回执退款、短信计费记录、应用级客户费率、重复回调幂等。 + - 发送链路:批量任务创建、手机号拆分、发送入队、运营商前缀正则分流、手机号段归属地识别、应用运营商通道组路由、省网/全国路由、submit result 更新、receipt 更新、迟到旧回执不覆盖最终成功、失败补发、补发停止条件、uplink 记录、72 小时未知转超时。 + - 通道与报备:通道创建、通道发送地区、三网通道通配、单运营商通道组、通道组明细 carrier 参与发送、省份与通道发送地区一致性、全国通道优先级唯一、企业应用通道组保存校验、禁止单通道发送规则、最终选中通道签名报备校验、签名报备任务、导出、回执导入、签名状态同步。 - 查询统计:发送链路 trace、对账 reconciliation、dashboard/statistics。 - Gateway:SEQID/MSGID 追踪、重连、health、gocmpp submit/resp 模拟器。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index c5c9aab..1e3eb67 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -437,3 +437,75 @@ npm run verify:phase8 ### 待复测 - 浏览器 smoke 和真实文件上传 smoke 需要在生产验证环境补跑,重点复测客户端发送、签名材料上传、短信审核、短信记录、客户管理和报备任务。 + +## 2026-07-03 单运营商通道组、应用级费率和回执幂等 + +### 本轮修复范围 + +- 通道组规则: + - `SmsChannelGroup.carrier` 固化为移动、联通、电信三选一,禁止三网通道组。 + - `SmsChannelGroupItem.carrier` 保留并参与发送,必须等于通道组运营商。 + - 三网只作为通道本体能力 `SmsChannel.carrier=all`,放入某个通道组后只服务该组运营商。 + - 同一通道组内同一省份只能配置一个通道;省份 item 必须引用发送地区一致的通道。 + - 全国通道允许多个,但同一通道组内全国通道优先级禁止重复;本期不做权重分流。 + - 路由规则必须绑定应用、运营商、通道组,且 route carrier 必须等于 group carrier。 +- 发送和计费规则: + - 运营商以号码前缀正则为准;手机号段库只提供省份/城市,carrier 仅作后台提示或校验。 + - 发送前校验最终选中通道的签名报备任务为 approved,补发切换通道时重新校验。 + - 企业应用新增 `customerUnitPrice`,客户扣费按应用级客户费率;通道成本只作内部成本。 + - 迟到旧通道 failed receipt 不覆盖新通道 delivered 最终状态;历史回执仍入库可查。 + - 重复 submit/receipt 回调不得重复扣费、释放冻结或退款。 + - 补发使用触发时当前通道组配置;本期不考虑人工重发。 +- 前端和真实 smoke: + - 企业应用表单增加客户单价输入,保存时写入真实应用 API。 + - 企业应用按移动、联通、电信分别选择通道组,选项按通道组 carrier 过滤。 + - 真实 smoke seed 补充应用客户单价、三网通道放入移动组、签名-通道 approved 报备。 + +### 新增/更新测试 + +| 测试文件 | 新增覆盖 | +| --- | --- | +| `api/src/channels/channels.service.spec.ts` | 单运营商通道组、通道组 item carrier 校验、通道 carrier 兼容、省份与发送地区一致、同省唯一、全国优先级唯一、route carrier 与 group carrier 一致。 | +| `api/src/send-chain/send-chain.service.spec.ts` | 通道组 item carrier 参与发送、最终通道签名报备校验、应用级客户费率、迟到旧 failed receipt 不覆盖 delivered、重复账务动作幂等。 | +| `api/src/sms-config/sms-config.service.spec.ts` | 应用配置与列表在新增客户费率字段后继续通过。 | + +### 已执行命令 + +```bash +npm --prefix api run prisma:generate +npm --prefix api test -- channels.service.spec.ts send-chain.service.spec.ts sms-config.service.spec.ts --runInBand +npm --prefix api test +npm --prefix api run build +npm run build +npm --prefix api run prisma:migrate:deploy +$env:API_PORT='3101'; $env:API_ENABLE_SEND_WORKER='true'; npm --prefix api run start:dev +node tools/smoke/real-env-smoke.mjs +node +npm run spike:contracts +npm run test:gateway +npm run verify:phase8 +``` + +### 当前结果 + +- Prisma Client 生成通过。 +- 新增迁移已应用到真实 PostgreSQL: + - `20260703143000_add_channel_group_carrier` + - `20260703152000_add_application_customer_rate` +- API Jest:10 个 test suite 通过,56 个测试通过。 +- API build 通过。 +- 前端 build 通过,仍存在既有 Vite chunk size warning。 +- 真实 API smoke 通过: + - `tools/smoke/real-env-smoke.mjs` 通过,验证真实 API、Prisma/PostgreSQL、Redis/BullMQ、登录、充值、发送任务、worker 入队、文件上传元数据和操作日志。 + - inline 通道组规则 HTTP smoke 通过,覆盖三网组拒绝、item carrier 不匹配拒绝、通道 carrier 不兼容拒绝、省份/发送地区不匹配拒绝、同省重复拒绝、全国优先级重复拒绝、route carrier/group carrier 不匹配拒绝。 +- Gateway 队列契约通过,4 个示例均验证通过。 +- `npm run test:gateway` 通过,Go Gateway health、connection、tracker、cmpp、spike 测试全部通过。 +- `npm run verify:phase8` 未通过,仍阻塞在已知 BullMQ spike 性能阈值: + - 15000 条消息、并发 500。 + - enqueue TPS 3139.43。 + - end-to-end TPS 479.02,低于 500 TPS。 + +### 剩余说明 + +- `verify:phase8` 当前失败点是独立 BullMQ 性能阈值,不是本轮通道组、计费、报备、回执业务逻辑测试失败。 +- 浏览器端完整手工回归仍建议补跑企业应用创建、通道组配置、短信记录详情弹窗中的历史回执展示。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index ad54f34..807f1c8 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -182,6 +182,7 @@ export type ClientSmsApplication = { tenantId: string; name: string; scene?: string | null; + customerUnitPrice?: number | null; status: string; }; @@ -264,6 +265,7 @@ export type DictionaryItem = Record & { export type ChannelGroup = DictionaryItem & { code: string; name: string; + carrier: 'mobile' | 'unicom' | 'telecom'; description?: string | null; retryEnabled?: boolean; retryTimeLimitHours?: number; @@ -461,7 +463,7 @@ export const adminApi = { request('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }), listEnterpriseApplications: (query: { tenantId?: string; keyword?: string } = {}) => request(withQuery('/admin/enterprise-applications', query)), - createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) => + createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) => request('/client/applications', { method: 'POST', tenantId: body.tenantId, body: JSON.stringify(body) }), changeApplicationStatus: (id: string, status: string, reason?: string) => request(`/admin/enterprise-applications/${id}/status`, { @@ -526,7 +528,7 @@ export const adminApi = { body: JSON.stringify({ reason }), }), listChannelGroups: () => request('/admin/channel-groups'), - createChannelGroup: (body: { code: string; name: string; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number }) => + createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number }) => request('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }), addChannelGroupItem: (body: Record) => request('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }), diff --git a/src/apps/admin/AdminChannelGroupFormPage.tsx b/src/apps/admin/AdminChannelGroupFormPage.tsx index 7a40249..9e7ccaf 100644 --- a/src/apps/admin/AdminChannelGroupFormPage.tsx +++ b/src/apps/admin/AdminChannelGroupFormPage.tsx @@ -149,7 +149,7 @@ export function AdminChannelGroupFormPage() { const navigate = useNavigate(); const { groupId } = useParams(); const editing = Boolean(groupId && groupId !== 'new'); - const [groupName, setGroupName] = useState(editing ? '学医三网专用群' : ''); + const [groupName, setGroupName] = useState(editing ? '学医移动专用组' : ''); const [carrier, setCarrier] = useState('mobile'); const [retryEnabled, setRetryEnabled] = useState(false); const [provinceRoutes, setProvinceRoutes] = useState(defaultProvinceRoutes); diff --git a/src/apps/admin/AdminChannelGroupsPage.tsx b/src/apps/admin/AdminChannelGroupsPage.tsx index 7bbf89c..e03638e 100644 --- a/src/apps/admin/AdminChannelGroupsPage.tsx +++ b/src/apps/admin/AdminChannelGroupsPage.tsx @@ -3,12 +3,27 @@ import { Layers3, Plus, Search, UsersRound } from 'lucide-react'; import { Breadcrumb, Button, Input, Modal, Pagination } from '@/components/ui'; import { adminApi, type ChannelGroup } from '@/api/adminApi'; +type GroupCarrier = 'mobile' | 'unicom' | 'telecom'; + +const carrierOptions: Array<{ label: string; value: GroupCarrier }> = [ + { label: '移动', value: 'mobile' }, + { label: '联通', value: 'unicom' }, + { label: '电信', value: 'telecom' }, +]; + +const carrierLabels: Record = { + mobile: '移动', + unicom: '联通', + telecom: '电信', +}; + export function AdminChannelGroupsPage() { const [groupName, setGroupName] = useState(''); const [groups, setGroups] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [name, setName] = useState(''); const [code, setCode] = useState(''); + const [carrier, setCarrier] = useState('mobile'); const [error, setError] = useState(''); function loadData() { @@ -27,11 +42,12 @@ export function AdminChannelGroupsPage() { const filteredGroups = useMemo(() => groups.filter((group) => !groupName.trim() || group.name.includes(groupName.trim())), [groupName, groups]); function createGroup() { - adminApi.createChannelGroup({ code, name, status: 'active' }) + adminApi.createChannelGroup({ code, name, carrier, status: 'active' }) .then(() => { setModalOpen(false); setName(''); setCode(''); + setCarrier('mobile'); loadData(); }) .catch((failure: Error) => setError(failure.message || '通道组创建失败')); @@ -66,6 +82,7 @@ export function AdminChannelGroupsPage() { {group.name} + {carrierLabels[group.carrier] ?? group.carrier} {group.items?.length ?? 0}
@@ -95,6 +112,15 @@ export function AdminChannelGroupsPage() {
setCode(event.target.value)} value={code} /> setName(event.target.value)} value={name} /> +
+ 运营商 + {carrierOptions.map((item) => ( + + ))} +
diff --git a/src/apps/admin/AdminSmsApplicationFormPage.tsx b/src/apps/admin/AdminSmsApplicationFormPage.tsx index 952cbb3..e4832b2 100644 --- a/src/apps/admin/AdminSmsApplicationFormPage.tsx +++ b/src/apps/admin/AdminSmsApplicationFormPage.tsx @@ -11,6 +11,7 @@ export function AdminSmsApplicationFormPage() { const [appName, setAppName] = useState(''); const [scene, setScene] = useState('行业通知'); const [dailyLimit, setDailyLimit] = useState('100000'); + const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300'); const [phoneDailyLimit, setPhoneDailyLimit] = useState('10'); const [mismatchPolicy, setMismatchPolicy] = useState('manual_review'); const [ipAddress, setIpAddress] = useState(''); @@ -53,6 +54,7 @@ export function AdminSmsApplicationFormPage() { name: appName, scene, dailyLimit: Number(dailyLimit) || undefined, + customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100), maxPhonesPerTask: Number(phoneDailyLimit) || undefined, templateMismatchMode: mismatchPolicy, ipAllowlist: ipAddress ? [ipAddress] : [], @@ -71,9 +73,9 @@ export function AdminSmsApplicationFormPage() { .catch((failure: Error) => setError(failure.message || '短信应用保存失败')); } - const groupOptions = [ + const groupOptionsByCarrier = (carrier: ChannelGroup['carrier']) => [ { label: '不配置', value: '' }, - ...groups.map((group) => ({ label: group.name, value: group.id })), + ...groups.filter((group) => group.carrier === carrier).map((group) => ({ label: group.name, value: group.id })), ]; return ( @@ -94,6 +96,7 @@ export function AdminSmsApplicationFormPage() { setAppName(event.target.value)} placeholder="请输入应用名称" required value={appName} /> setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} /> setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} /> + setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required value={customerUnitPrice} /> setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} /> setIpAddress(event.target.value)} placeholder="例如 192.168.1.100/32" value={ipAddress} /> - setUnicomGroupId(event.target.value)} options={groupOptions} value={unicomGroupId} /> - setMobileGroupId(event.target.value)} options={groupOptionsByCarrier('mobile')} value={mobileGroupId} /> + setTelecomGroupId(event.target.value)} options={groupOptionsByCarrier('telecom')} value={telecomGroupId} /> diff --git a/tools/smoke/real-env-smoke.mjs b/tools/smoke/real-env-smoke.mjs index 270f4c4..a8d82bc 100644 --- a/tools/smoke/real-env-smoke.mjs +++ b/tools/smoke/real-env-smoke.mjs @@ -70,6 +70,8 @@ async function ensureSmokeData() { where: { code: 'SMOKE_CMPP' }, update: { name: 'Smoke CMPP Channel', + carrier: 'all', + sendRegion: '全国', gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'smoke-account', @@ -97,8 +99,8 @@ async function ensureSmokeData() { const group = await prisma.smsChannelGroup.upsert({ where: { code: 'SMOKE_GROUP' }, - update: { name: 'Smoke Channel Group', status: 'active' }, - create: { code: 'SMOKE_GROUP', name: 'Smoke Channel Group', status: 'active' }, + update: { name: 'Smoke Channel Group', carrier: 'mobile', status: 'active' }, + create: { code: 'SMOKE_GROUP', name: 'Smoke Channel Group', carrier: 'mobile', status: 'active' }, }); const groupItem = await prisma.smsChannelGroupItem.findFirst({ @@ -106,20 +108,16 @@ async function ensureSmokeData() { }); if (!groupItem) { await prisma.smsChannelGroupItem.create({ - data: { groupId: group.id, channelId: channel.id, priority: 1, weight: 1, rateLimitPerSecond: 500 }, + data: { groupId: group.id, channelId: channel.id, carrier: 'mobile', priority: 1, weight: 1, rateLimitPerSecond: 500 }, + }); + } else { + await prisma.smsChannelGroupItem.update({ + where: { id: groupItem.id }, + data: { carrier: 'mobile', province: null, priority: 1, weight: 1, rateLimitPerSecond: 500 }, }); } - const routeRule = await prisma.channelRouteRule.findFirst({ - where: { tenantId: tenant.id, groupId: group.id, status: 'active' }, - }); - if (!routeRule) { - await prisma.channelRouteRule.create({ - data: { tenantId: tenant.id, groupId: group.id, priority: 1, status: 'active' }, - }); - } - - const application = + let application = (await prisma.smsApplication.findFirst({ where: { tenantId: tenant.id, name: 'Smoke SMS App' } })) ?? (await prisma.smsApplication.create({ data: { @@ -128,11 +126,47 @@ async function ensureSmokeData() { scene: 'smoke', secretHash: hashPassword('smoke-secret'), dailyLimit: 100000, + customerUnitPrice: 5, maxPhonesPerTask: 100000, templateMismatchMode: 'reject', status: 'active', }, })); + application = await prisma.smsApplication.update({ + where: { id: application.id }, + data: { customerUnitPrice: 5, status: 'active' }, + }); + + const routeRule = await prisma.channelRouteRule.findFirst({ + where: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier: 'mobile', status: 'active' }, + }); + if (!routeRule) { + await prisma.channelRouteRule.create({ + data: { tenantId: tenant.id, applicationId: application.id, groupId: group.id, carrier: 'mobile', priority: 1, status: 'active' }, + }); + } + + await prisma.cmppConnectionState.upsert({ + where: { channelId_connectionId: { channelId: channel.id, connectionId: 'smoke-conn-1' } }, + update: { + tenantId: tenant.id, + status: 'online', + desiredConnections: 1, + currentConnections: 1, + lastHeartbeatAt: new Date(), + lastError: null, + }, + create: { + tenantId: tenant.id, + channelId: channel.id, + connectionId: 'smoke-conn-1', + status: 'online', + desiredConnections: 1, + currentConnections: 1, + lastConnectedAt: new Date(), + lastHeartbeatAt: new Date(), + }, + }); const signature = (await prisma.smsSignature.findFirst({ where: { tenantId: tenant.id, applicationId: application.id, name: '烟测签名' } })) ?? @@ -163,6 +197,17 @@ async function ensureSmokeData() { }, })); + const reportTask = await prisma.channelSignatureReportTask.findFirst({ + where: { tenantId: tenant.id, signatureId: signature.id, channelId: channel.id }, + }); + if (!reportTask) { + await prisma.channelSignatureReportTask.create({ + data: { tenantId: tenant.id, signatureId: signature.id, channelId: channel.id, status: 'approved' }, + }); + } else if (reportTask.status !== 'approved') { + await prisma.channelSignatureReportTask.update({ where: { id: reportTask.id }, data: { status: 'approved', reason: null } }); + } + return { tenant, user, channel, application, signature, template }; }