fix: enforce carrier-specific channel group routing
This commit is contained in:
@@ -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");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "SmsApplication" ADD COLUMN "customerUnitPrice" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 '新建';
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 (;;) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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:通道健康指标。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 模拟器。
|
||||
|
||||
|
||||
@@ -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 <inline channel-group rule HTTP smoke>
|
||||
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 性能阈值,不是本轮通道组、计费、报备、回执业务逻辑测试失败。
|
||||
- 浏览器端完整手工回归仍建议补跑企业应用创建、通道组配置、短信记录详情弹窗中的历史回执展示。
|
||||
|
||||
+4
-2
@@ -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<string, unknown> & {
|
||||
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<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string } = {}) =>
|
||||
request<EnterpriseApplication[]>(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<EnterpriseApplication>('/client/applications', { method: 'POST', tenantId: body.tenantId, body: JSON.stringify(body) }),
|
||||
changeApplicationStatus: (id: string, status: string, reason?: string) =>
|
||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
|
||||
@@ -526,7 +528,7 @@ export const adminApi = {
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
listChannelGroups: () => request<ChannelGroup[]>('/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<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }),
|
||||
addChannelGroupItem: (body: Record<string, unknown>) =>
|
||||
request<DictionaryItem>('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }),
|
||||
|
||||
@@ -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<Carrier>('mobile');
|
||||
const [retryEnabled, setRetryEnabled] = useState(false);
|
||||
const [provinceRoutes, setProvinceRoutes] = useState(defaultProvinceRoutes);
|
||||
|
||||
@@ -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<GroupCarrier, string> = {
|
||||
mobile: '移动',
|
||||
unicom: '联通',
|
||||
telecom: '电信',
|
||||
};
|
||||
|
||||
export function AdminChannelGroupsPage() {
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [groups, setGroups] = useState<ChannelGroup[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [carrier, setCarrier] = useState<GroupCarrier>('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() {
|
||||
<Layers3 size={18} />
|
||||
<strong>{group.name}</strong>
|
||||
</div>
|
||||
<span title="运营商">{carrierLabels[group.carrier] ?? group.carrier}</span>
|
||||
<span title="包含通道数"><UsersRound size={16} />{group.items?.length ?? 0}</span>
|
||||
</header>
|
||||
<div className="channel-group-card__body">
|
||||
@@ -95,6 +112,15 @@ export function AdminChannelGroupsPage() {
|
||||
<div className="admin-system-modal-form">
|
||||
<Input label="通道组编码" onChange={(event) => setCode(event.target.value)} value={code} />
|
||||
<Input label="通道组名称" onChange={(event) => setName(event.target.value)} value={name} />
|
||||
<div className="channel-group-radio-row">
|
||||
<span>运营商</span>
|
||||
{carrierOptions.map((item) => (
|
||||
<label key={item.value}>
|
||||
<input checked={carrier === item.value} onChange={() => setCarrier(item.value)} type="radio" />
|
||||
{item.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<Input label="应用名称" onChange={(event) => setAppName(event.target.value)} placeholder="请输入应用名称" required value={appName} />
|
||||
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
|
||||
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
|
||||
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required value={customerUnitPrice} />
|
||||
<Input label="每任务最大号码数" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
|
||||
<Select
|
||||
label="不符合模板的短信"
|
||||
@@ -107,9 +110,9 @@ export function AdminSmsApplicationFormPage() {
|
||||
value={mismatchPolicy}
|
||||
/>
|
||||
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="例如 192.168.1.100/32" value={ipAddress} />
|
||||
<Select label="移动通道组" onChange={(event) => setMobileGroupId(event.target.value)} options={groupOptions} value={mobileGroupId} />
|
||||
<Select label="联通通道组" onChange={(event) => setUnicomGroupId(event.target.value)} options={groupOptions} value={unicomGroupId} />
|
||||
<Select label="电信通道组" onChange={(event) => setTelecomGroupId(event.target.value)} options={groupOptions} value={telecomGroupId} />
|
||||
<Select label="移动通道组" onChange={(event) => setMobileGroupId(event.target.value)} options={groupOptionsByCarrier('mobile')} value={mobileGroupId} />
|
||||
<Select label="联通通道组" onChange={(event) => setUnicomGroupId(event.target.value)} options={groupOptionsByCarrier('unicom')} value={unicomGroupId} />
|
||||
<Select label="电信通道组" onChange={(event) => setTelecomGroupId(event.target.value)} options={groupOptionsByCarrier('telecom')} value={telecomGroupId} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user