From 8cca3614413537212007f2008cbcbe6dcb3599f0 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Fri, 3 Jul 2026 19:29:56 +0800 Subject: [PATCH] fix: harden real backend admin workflows and ui --- api/src/channels/channels.controller.ts | 14 +- api/src/channels/channels.service.spec.ts | 123 +++- api/src/channels/channels.service.ts | 281 +++++++++- api/src/files/files.controller.ts | 16 +- api/src/files/files.service.spec.ts | 50 ++ api/src/files/files.service.ts | 22 + api/src/files/object-storage.service.ts | 6 + .../operations/admin-operations.controller.ts | 3 +- .../client-operations.controller.ts | 16 +- api/src/operations/operations.service.spec.ts | 18 +- api/src/operations/operations.service.ts | 5 +- .../send-chain/admin-send-chain.controller.ts | 5 + api/src/send-chain/send-chain.service.spec.ts | 17 + api/src/send-chain/send-chain.service.ts | 27 +- .../sms-config/admin-sms-config.controller.ts | 48 +- .../client-sms-config.controller.ts | 13 +- api/src/sms-config/sms-config.service.spec.ts | 261 ++++++++- api/src/sms-config/sms-config.service.ts | 213 ++++++- api/src/tenants/tenants.service.spec.ts | 74 +++ api/src/tenants/tenants.service.ts | 133 ++++- docs/admin-prototype-recovery-scope.md | 201 +++++++ docs/all-menu-dialog-real-backend-audit.md | 405 ++++++++++++++ docs/testing-progress.md | 92 +++ src/api/adminApi.ts | 189 ++++++- src/apps/admin/AdminChannelGroupFormPage.tsx | 215 +++++-- src/apps/admin/AdminChannelsPage.tsx | 51 +- src/apps/admin/AdminCustomerDetailPage.tsx | 13 + src/apps/admin/AdminCustomerFormPage.tsx | 227 +++++++- src/apps/admin/AdminCustomersPage.tsx | 16 +- .../admin/AdminEnterpriseApplicationsPage.tsx | 181 +++--- .../admin/AdminEnterpriseBlacklistPage.tsx | 4 +- .../admin/AdminEnterpriseSignaturesPage.tsx | 526 ++++++++++++++++-- .../admin/AdminEnterpriseTemplatesPage.tsx | 394 ++++++++++++- src/apps/admin/AdminGlobalBlacklistPage.tsx | 2 +- .../admin/AdminMmsApplicationFormPage.tsx | 157 ------ src/apps/admin/AdminMmsChannelsPage.tsx | 197 ------- src/apps/admin/AdminMmsRecordsPage.tsx | 332 ----------- src/apps/admin/AdminMmsTaskProgressPage.tsx | 482 ---------------- src/apps/admin/AdminRechargeRecordsPage.tsx | 2 +- src/apps/admin/AdminReportTasksPage.tsx | 36 +- src/apps/admin/AdminSensitiveWordsPage.tsx | 2 +- src/apps/admin/AdminSettingsPage.tsx | 36 -- src/apps/admin/AdminSignatureAuditPage.tsx | 177 ------ .../admin/AdminSmsApplicationFormPage.tsx | 180 ++++-- src/apps/admin/AdminSmsAuditPage.tsx | 4 +- src/apps/admin/AdminSmsRecordsPage.tsx | 4 +- src/apps/admin/AdminSmsTaskProgressPage.tsx | 382 ++++++------- src/apps/admin/AdminSmsUplinkRecordsPage.tsx | 179 +++--- src/apps/admin/AdminSystemLogsPage.tsx | 2 +- src/apps/admin/AdminUsersPage.tsx | 2 +- src/apps/client/ClientApplicationsPage.tsx | 244 ++++---- src/apps/client/ClientBatchTasksPage.tsx | 12 +- src/apps/client/ClientEnterpriseAuthPage.tsx | 224 ++++++-- src/apps/client/ClientMmsBatchTasksPage.tsx | 372 ------------- src/apps/client/ClientMmsSendDetailPage.tsx | 235 -------- src/apps/client/ClientMmsSendPage.tsx | 270 --------- .../client/ClientMmsSignatureReportPage.tsx | 263 --------- src/apps/client/ClientMmsTemplatesPage.tsx | 467 ---------------- .../client/ClientMmsUplinkMessagesPage.tsx | 178 ------ src/apps/client/ClientSendDetailPage.tsx | 353 +++++------- src/apps/client/ClientSendPage.tsx | 104 +++- src/apps/client/ClientSettingsPage.tsx | 29 - src/apps/client/ClientSignaturesPage.tsx | 7 +- src/apps/client/ClientSystemLogsPage.tsx | 2 +- src/apps/client/ClientTemplatesPage.tsx | 249 +++++++-- src/apps/client/ClientUplinkMessagesPage.tsx | 158 +++--- src/components/ui/Table.tsx | 16 +- src/layouts/AdminLayout.tsx | 32 +- src/layouts/AppShell.tsx | 5 - src/routes/AppRoutes.tsx | 39 +- src/styles/global.css | 256 +++++++-- 71 files changed, 5111 insertions(+), 4439 deletions(-) create mode 100644 api/src/files/files.service.spec.ts create mode 100644 api/src/tenants/tenants.service.spec.ts create mode 100644 docs/admin-prototype-recovery-scope.md create mode 100644 docs/all-menu-dialog-real-backend-audit.md delete mode 100644 src/apps/admin/AdminMmsApplicationFormPage.tsx delete mode 100644 src/apps/admin/AdminMmsChannelsPage.tsx delete mode 100644 src/apps/admin/AdminMmsRecordsPage.tsx delete mode 100644 src/apps/admin/AdminMmsTaskProgressPage.tsx delete mode 100644 src/apps/admin/AdminSettingsPage.tsx delete mode 100644 src/apps/admin/AdminSignatureAuditPage.tsx delete mode 100644 src/apps/client/ClientMmsBatchTasksPage.tsx delete mode 100644 src/apps/client/ClientMmsSendDetailPage.tsx delete mode 100644 src/apps/client/ClientMmsSendPage.tsx delete mode 100644 src/apps/client/ClientMmsSignatureReportPage.tsx delete mode 100644 src/apps/client/ClientMmsTemplatesPage.tsx delete mode 100644 src/apps/client/ClientMmsUplinkMessagesPage.tsx delete mode 100644 src/apps/client/ClientSettingsPage.tsx diff --git a/api/src/channels/channels.controller.ts b/api/src/channels/channels.controller.ts index b9993d3..697154c 100644 --- a/api/src/channels/channels.controller.ts +++ b/api/src/channels/channels.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ChannelsService, @@ -14,6 +14,8 @@ import { CreateReportTaskDto, CreateRouteRuleDto, UpsertConnectionStateDto, + UpdateChannelDto, + UpdateChannelGroupDto, } from './channels.service'; @ApiTags('channels') @@ -31,6 +33,11 @@ export class ChannelsController { return this.channels.createChannel(body); } + @Put('channels/:id') + updateChannel(@Param('id') channelId: string, @Body() body: UpdateChannelDto) { + return this.channels.updateChannel(channelId, body); + } + @Post('channels/:id/test') testChannel(@Param('id') channelId: string) { return this.channels.testChannel(channelId); @@ -86,6 +93,11 @@ export class ChannelsController { return this.channels.createGroup(body); } + @Put('channel-groups/:id') + updateGroup(@Param('id') groupId: string, @Body() body: UpdateChannelGroupDto) { + return this.channels.updateGroup(groupId, body); + } + @Post('channel-groups/items') addGroupItem(@Body() body: CreateChannelGroupItemDto) { return this.channels.addGroupItem(body); diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index c501547..91c919f 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -27,6 +27,14 @@ function createPrismaMock() { smsChannel: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })), }, + smsChannelGroup: { + update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })), + findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', items: [] }), + }, + smsChannelGroupItem: { + deleteMany: jest.fn(), + createMany: jest.fn(), + }, signatureReportMaterial: { findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]), createMany: jest.fn(), @@ -44,10 +52,12 @@ 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' }), + findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72 }), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })), }, smsChannelGroupItem: { + deleteMany: jest.fn(), + createMany: jest.fn(), findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })), }, @@ -144,6 +154,52 @@ describe('ChannelsService', () => { }); }); + it('updates CMPP channel configuration without requiring password changes', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + await expect(service.updateChannel('channel-1', { + name: '主通道-编辑', + gatewayHost: '10.0.0.1', + gatewayPort: 27890, + carrier: 'all', + sendRegion: '全国', + account: 'sp-new', + srcId: '10690001', + unitPrice: 4, + })).resolves.toEqual(expect.objectContaining({ + id: 'channel-1', + name: '主通道-编辑', + gatewayHost: '10.0.0.1', + })); + + expect(prisma.smsChannel.update).toHaveBeenCalledWith({ + where: { id: 'channel-1' }, + data: expect.objectContaining({ + name: '主通道-编辑', + gatewayHost: '10.0.0.1', + gatewayPort: 27890, + carrier: 'all', + passwordCipher: undefined, + }), + }); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'sms_channel.update', + resource: 'sms_channel', + resourceId: 'channel-1', + }), + }); + }); + + it('rejects invalid channel update ports', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + await expect(service.updateChannel('channel-1', { gatewayPort: 70000 })).rejects.toThrow('gatewayPort must be an integer between 1 and 65535'); + expect(prisma.smsChannel.update).not.toHaveBeenCalled(); + }); + it('rejects direct single-channel route rules', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); @@ -214,6 +270,36 @@ describe('ChannelsService', () => { .rejects.toThrow('同一通道组内全国通道优先级不能重复'); }); + it('updates channel groups and replaces items with backend validation', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + prisma.smsChannel.findMany.mockResolvedValue([ + { id: 'channel-sd', carrier: 'mobile', sendRegion: '山东' }, + { id: 'channel-national', carrier: 'all', sendRegion: '全国' }, + ]); + + await service.updateGroup('group-1', { + name: '移动组更新', + carrier: 'mobile', + retryEnabled: true, + retryTimeLimitHours: 24, + items: [ + { channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10 }, + { channelId: 'channel-national', carrier: 'mobile', priority: 1 }, + ], + }); + + expect(prisma.$transaction).toHaveBeenCalled(); + + await expect(service.updateGroup('group-1', { + carrier: 'mobile', + items: [ + { channelId: 'channel-sd', carrier: 'mobile', priority: 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); @@ -265,11 +351,42 @@ describe('ChannelsService', () => { }); expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({ where: { id: 'report-task-1' }, - data: { status: 'rejected', reason: 'one rejected' }, + data: { status: 'partial', reason: 'one rejected' }, }); expect(prisma.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, - data: { reportStatus: 'rejected' }, + data: { reportStatus: 'partial' }, + }); + }); + + it('parses text receipt imports and derives report task status', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + + await service.importReportReceipt('report-task-1', { + fileObjectId: 'file-1', + fileName: 'receipt.csv', + fileContent: 'phone,status\n13800138000,success\n13900139000,failed\n13700137000,通过', + reason: 'carrier receipt', + }); + + expect(prisma.reportReceiptImport.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + fileObjectId: 'file-1', + fileName: 'receipt.csv', + rowCount: 3, + successCount: 2, + failedCount: 1, + result: expect.objectContaining({ hasHeader: true, rows: expect.any(Array) }), + }), + }); + expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({ + where: { id: 'report-task-1' }, + data: { status: 'partial', reason: 'carrier receipt' }, + }); + expect(prisma.smsSignature.update).toHaveBeenCalledWith({ + where: { id: 'sig-1' }, + data: { reportStatus: 'partial' }, }); }); diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 751906d..074523a 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -21,6 +21,8 @@ export interface CreateChannelDto { config?: Record; } +export type UpdateChannelDto = Partial; + export interface CreateChannelGroupDto { code: string; name: string; @@ -42,6 +44,17 @@ export interface CreateChannelGroupItemDto { rateLimitPerSecond?: number; } +export interface UpdateChannelGroupDto { + code?: string; + name?: string; + carrier?: string; + description?: string; + status?: string; + retryEnabled?: boolean; + retryTimeLimitHours?: number; + items?: Array>; +} + export interface CreateRouteRuleDto { tenantId?: string; applicationId?: string; @@ -88,6 +101,8 @@ export interface CreateReportExportDto { export interface CreateReceiptImportDto { fileObjectId?: string; fileName: string; + fileContent?: string; + delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; @@ -164,6 +179,61 @@ export class ChannelsService { }); } + async updateChannel(channelId: string, data: UpdateChannelDto) { + const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + const gatewayPort = data.gatewayPort === undefined ? undefined : Number(data.gatewayPort); + if (gatewayPort !== undefined && (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535)) { + throw new BadRequestException('gatewayPort must be an integer between 1 and 65535'); + } + const updated = await this.prisma.smsChannel.update({ + where: { id: channelId }, + data: { + code: data.code, + name: data.name, + carrier: data.carrier, + sendRegion: data.sendRegion, + protocol: data.protocol, + gatewayHost: data.gatewayHost, + gatewayPort, + enterpriseCode: data.enterpriseCode, + account: data.account, + passwordCipher: data.passwordCipher, + srcId: data.srcId, + cmppVersion: data.cmppVersion, + rateLimitPerSecond: data.rateLimitPerSecond, + unitPrice: data.unitPrice, + status: data.status, + config: data.config as Prisma.InputJsonValue | undefined, + }, + }); + await this.prisma.operationLog.create({ + data: { + action: 'sms_channel.update', + resource: 'sms_channel', + resourceId: channelId, + detail: { + before: { + code: channel.code, + name: channel.name, + carrier: channel.carrier, + sendRegion: channel.sendRegion, + gatewayHost: channel.gatewayHost, + gatewayPort: channel.gatewayPort, + enterpriseCode: channel.enterpriseCode, + account: channel.account, + srcId: channel.srcId, + unitPrice: channel.unitPrice, + }, + after: data, + } as Prisma.InputJsonValue, + }, + }); + return updated; + } + async changeChannelStatus(channelId: string, data: ChangeChannelStatusDto) { const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } }); if (!channel) { @@ -381,7 +451,7 @@ export class ChannelsService { listGroups() { return this.prisma.smsChannelGroup.findMany({ - include: { items: { include: { channel: true } } }, + include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, orderBy: { createdAt: 'desc' }, take: 100, }); @@ -461,6 +531,57 @@ export class ChannelsService { }); } + async updateGroup(groupId: string, data: UpdateChannelGroupDto) { + const current = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } }); + if (!current) { + throw new NotFoundException('Channel group not found'); + } + const retryTimeLimitHours = data.retryTimeLimitHours ?? current.retryTimeLimitHours; + if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) { + throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72'); + } + const carrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : normalizeBusinessCarrier(current.carrier); + const items = data.items ?? []; + const channelIds = [...new Set(items.map((item) => item.channelId))]; + const channels = await this.prisma.smsChannel.findMany({ where: { id: { in: channelIds } } }); + const channelById = new Map(channels.map((channel) => [channel.id, channel])); + validateGroupItems(carrier, items, channelById); + + return this.prisma.$transaction(async (tx) => { + await tx.smsChannelGroupItem.deleteMany({ where: { groupId } }); + await tx.smsChannelGroup.update({ + where: { id: groupId }, + data: { + code: data.code ?? current.code, + name: data.name ?? current.name, + carrier, + description: data.description, + status: data.status ?? current.status, + retryEnabled: data.retryEnabled ?? current.retryEnabled, + retryTimeLimitHours, + }, + }); + if (items.length > 0) { + await tx.smsChannelGroupItem.createMany({ + data: items.map((item) => ({ + groupId, + channelId: item.channelId, + carrier, + province: item.province, + priority: item.priority ?? 100, + weight: item.weight ?? 1, + isBackup: item.isBackup ?? false, + rateLimitPerSecond: item.rateLimitPerSecond, + })), + }); + } + return tx.smsChannelGroup.findUnique({ + where: { id: groupId }, + include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, + }); + }); + } + listRouteRules() { return this.prisma.channelRouteRule.findMany({ include: { group: true, channel: true }, @@ -600,17 +721,21 @@ export class ChannelsService { async importReportReceipt(taskId: string, data: CreateReceiptImportDto) { const task = await this.getReportTaskOrThrow(taskId); - const statusAfter = data.statusAfter ?? (data.failedCount && data.failedCount > 0 ? 'rejected' : 'approved'); + const parsed = data.fileContent ? parseReceiptContent(data.fileContent, data.delimiter) : undefined; + const rowCount = data.rowCount ?? parsed?.rowCount ?? 0; + const successCount = data.successCount ?? parsed?.successCount ?? 0; + const failedCount = data.failedCount ?? parsed?.failedCount ?? 0; + const statusAfter = data.statusAfter ?? deriveReceiptStatus(rowCount, successCount, failedCount); const imported = await this.prisma.reportReceiptImport.create({ data: { taskId, fileObjectId: data.fileObjectId, fileName: data.fileName, - rowCount: data.rowCount ?? 0, - successCount: data.successCount ?? 0, - failedCount: data.failedCount ?? 0, + rowCount, + successCount, + failedCount, status: 'imported', - result: data.result as Prisma.InputJsonValue | undefined, + result: (data.result ?? parsed?.result) as Prisma.InputJsonValue | undefined, }, }); await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason); @@ -690,6 +815,107 @@ function normalizeConnectionAction(status: string) { return 'updated'; } +function parseReceiptContent(content: string, delimiter?: ',' | '\t') { + const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + if (lines.length === 0) { + throw new BadRequestException('Receipt file is empty'); + } + const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ','); + const firstCells = splitReceiptLine(lines[0], separator); + const hasHeader = firstCells.some((cell) => ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase())); + const header = hasHeader ? firstCells : []; + const rows = hasHeader ? lines.slice(1) : lines; + const statusIndex = findReceiptStatusIndex(header); + let successCount = 0; + let failedCount = 0; + const resultRows = rows.map((line, index) => { + const cells = splitReceiptLine(line, separator); + const rawStatus = cells[statusIndex] ?? cells[cells.length - 1] ?? ''; + const normalizedStatus = normalizeReceiptStatus(rawStatus); + if (normalizedStatus === 'success') { + successCount += 1; + } else { + failedCount += 1; + } + return { + rowNumber: (hasHeader ? index + 2 : index + 1), + phone: cells[0] ?? '', + status: normalizedStatus, + rawStatus, + raw: cells, + }; + }); + return { + rowCount: resultRows.length, + successCount, + failedCount, + result: { + delimiter: separator === '\t' ? 'tab' : 'comma', + hasHeader, + rows: resultRows, + }, + }; +} + +function splitReceiptLine(line: string, delimiter: ',' | '\t') { + if (delimiter === '\t') { + return line.split('\t').map((cell) => stripReceiptCell(cell)); + } + const cells: string[] = []; + let current = ''; + let quoted = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + const next = line[index + 1]; + if (char === '"' && quoted && next === '"') { + current += '"'; + index += 1; + } else if (char === '"') { + quoted = !quoted; + } else if (char === ',' && !quoted) { + cells.push(stripReceiptCell(current)); + current = ''; + } else { + current += char; + } + } + cells.push(stripReceiptCell(current)); + return cells; +} + +function stripReceiptCell(value: string) { + return value.trim().replace(/^"|"$/g, '').trim(); +} + +function findReceiptStatusIndex(header: string[]) { + if (header.length === 0) { + return 1; + } + const index = header.findIndex((cell) => ['status', 'result', '状态', '结果'].includes(cell.toLowerCase())); + return index >= 0 ? index : Math.max(0, header.length - 1); +} + +function normalizeReceiptStatus(value: string) { + const normalized = value.trim().toLowerCase(); + if (['success', 'succeeded', 'approved', 'completed', 'ok', 'pass', 'passed', '通过', '成功', '已完成', '报备成功'].includes(normalized)) { + return 'success'; + } + if (['failed', 'fail', 'rejected', 'reject', 'error', 'no', 'denied', '驳回', '失败', '不通过', '拒绝', '报备失败'].includes(normalized)) { + return 'failed'; + } + return 'failed'; +} + +function deriveReceiptStatus(rowCount: number, successCount: number, failedCount: number) { + if (rowCount <= 0 || successCount <= 0) { + return 'failed'; + } + if (failedCount > 0) { + return 'partial'; + } + return 'completed'; +} + function normalizeBusinessCarrier(carrier?: string | null) { const normalized = normalizeChannelCarrier(carrier); if (!['mobile', 'unicom', 'telecom'].includes(normalized)) { @@ -720,6 +946,49 @@ function isRegionCompatible(channelRegion: string | null | undefined, itemProvin return normalizeRegion(channelRegion) === normalizeRegion(itemProvince); } +function validateGroupItems( + groupCarrier: string, + items: Array>, + channels: Map, +) { + const channelIds = new Set(); + const provinces = new Set(); + const nationalPriorities = new Set(); + for (const item of items) { + const itemCarrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : groupCarrier; + if (itemCarrier !== groupCarrier) { + throw new BadRequestException('Channel group items must use the same carrier as the channel group'); + } + const channel = channels.get(item.channelId); + if (!channel) { + throw new NotFoundException('Channel not found'); + } + if (channelIds.has(item.channelId)) { + throw new BadRequestException('通道组内不能重复配置同一通道'); + } + channelIds.add(item.channelId); + if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) { + throw new BadRequestException('Channel carrier is not compatible with the channel group carrier'); + } + if (item.province) { + const province = normalizeRegion(item.province); + if (provinces.has(province)) { + throw new BadRequestException('同一通道组内同一省份只能配置一个通道'); + } + provinces.add(province); + if (!isRegionCompatible(channel.sendRegion, item.province)) { + throw new BadRequestException('Province route must use a channel with the same sendRegion'); + } + } else { + const priority = item.priority ?? 100; + if (nationalPriorities.has(priority)) { + throw new BadRequestException('同一通道组内全国通道优先级不能重复'); + } + nationalPriorities.add(priority); + } + } +} + function normalizeLinkEvent(action: string) { if (action.includes('connected')) { return '新建'; diff --git a/api/src/files/files.controller.ts b/api/src/files/files.controller.ts index f27c44b..0b64d47 100644 --- a/api/src/files/files.controller.ts +++ b/api/src/files/files.controller.ts @@ -1,8 +1,16 @@ -import { Body, Controller, Get, Post } from '@nestjs/common'; +import { Body, Controller, Get, Post, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags } from '@nestjs/swagger'; import { TenantId } from '../common/tenant-id.decorator'; import { CreateFileObjectDto, CreatePresignedUploadDto, FilesService } from './files.service'; +type UploadedMultipartFile = { + originalname: string; + mimetype: string; + size: number; + buffer: Buffer; +}; + @ApiTags('files') @Controller('admin/files') export class FilesController { @@ -22,4 +30,10 @@ export class FilesController { createPresignedUpload(@Body() body: CreatePresignedUploadDto) { return this.files.createPresignedUpload(body); } + + @Post('upload') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 20 * 1024 * 1024 } })) + upload(@UploadedFile() file: UploadedMultipartFile, @Body('purpose') purpose: string, @Body('prefix') prefix?: string, @TenantId() tenantId?: string) { + return this.files.upload({ tenantId, purpose: purpose || 'general', prefix }, file); + } } diff --git a/api/src/files/files.service.spec.ts b/api/src/files/files.service.spec.ts new file mode 100644 index 0000000..0e2b2c9 --- /dev/null +++ b/api/src/files/files.service.spec.ts @@ -0,0 +1,50 @@ +import { FilesService } from './files.service'; + +describe('FilesService', () => { + it('uploads file content to object storage before creating FileObject metadata', async () => { + const prisma = { + fileObject: { + create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'file-1', ...data })), + }, + }; + const objectStorage = { + getBucket: jest.fn().mockReturnValue('cmpp-platform'), + putObject: jest.fn().mockResolvedValue({ etag: 'etag-1' }), + presignedPutObject: jest.fn(), + }; + const service = new FilesService(prisma as never, objectStorage as never); + const file = { + originalname: '营业执照.png', + mimetype: 'image/png', + size: 12, + buffer: Buffer.from('file-content'), + }; + + await expect(service.upload({ tenantId: 'tenant-1', purpose: 'signature_material', prefix: 'signature-materials/sig-1' }, file)) + .resolves.toEqual(expect.objectContaining({ + id: 'file-1', + tenantId: 'tenant-1', + bucket: 'cmpp-platform', + fileName: '营业执照.png', + contentType: 'image/png', + purpose: 'signature_material', + })); + + expect(objectStorage.putObject).toHaveBeenCalledWith( + expect.stringMatching(/^signature-materials\/sig-1\/\d+-[a-f0-9-]+-营业执照\.png$/), + file.buffer, + 12, + 'image/png', + ); + expect(prisma.fileObject.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tenantId: 'tenant-1', + bucket: 'cmpp-platform', + fileName: '营业执照.png', + contentType: 'image/png', + sizeBytes: BigInt(12), + purpose: 'signature_material', + }), + }); + }); +}); diff --git a/api/src/files/files.service.ts b/api/src/files/files.service.ts index 17ed804..fca5a87 100644 --- a/api/src/files/files.service.ts +++ b/api/src/files/files.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; +import { randomUUID } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; import { ObjectStorageService } from './object-storage.service'; @@ -19,6 +20,12 @@ export interface CreatePresignedUploadDto { expiresInSeconds?: number; } +export interface UploadFileDto { + tenantId?: string; + purpose: string; + prefix?: string; +} + @Injectable() export class FilesService { constructor( @@ -57,4 +64,19 @@ export class FilesService { expiresInSeconds: data.expiresInSeconds ?? 3600, }; } + + async upload(data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) { + const safeName = file.originalname.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_'); + const objectKey = `${data.prefix ?? data.purpose}/${Date.now()}-${randomUUID()}-${safeName}`; + await this.objectStorage.putObject(objectKey, file.buffer, file.size, file.mimetype || 'application/octet-stream'); + return this.create({ + tenantId: data.tenantId, + bucket: this.objectStorage.getBucket(), + objectKey, + fileName: file.originalname, + contentType: file.mimetype || 'application/octet-stream', + sizeBytes: file.size, + purpose: data.purpose, + }); + } } diff --git a/api/src/files/object-storage.service.ts b/api/src/files/object-storage.service.ts index 0fd1bcb..7885491 100644 --- a/api/src/files/object-storage.service.ts +++ b/api/src/files/object-storage.service.ts @@ -24,6 +24,12 @@ export class ObjectStorageService { return this.client.presignedPutObject(this.bucket, objectKey, expirySeconds); } + putObject(objectKey: string, content: Buffer, sizeBytes: number, contentType: string) { + return this.client.putObject(this.bucket, objectKey, content, sizeBytes, { + 'Content-Type': contentType, + }); + } + getBucket() { return this.bucket; } diff --git a/api/src/operations/admin-operations.controller.ts b/api/src/operations/admin-operations.controller.ts index cbec5fb..053840a 100644 --- a/api/src/operations/admin-operations.controller.ts +++ b/api/src/operations/admin-operations.controller.ts @@ -23,10 +23,11 @@ export class AdminOperationsController { @Query('applicationId') applicationId?: string, @Query('channelId') channelId?: string, @Query('taskId') taskId?: string, + @Query('messageId') messageId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('status') status?: string, ) { - return this.operations.listMessages({ tenantId, applicationId, channelId, taskId, phoneNumber, status }); + return this.operations.listMessages({ tenantId, applicationId, channelId, taskId, messageId, phoneNumber, status }); } @Get('uplink-messages') diff --git a/api/src/operations/client-operations.controller.ts b/api/src/operations/client-operations.controller.ts index c32af47..faf1bbb 100644 --- a/api/src/operations/client-operations.controller.ts +++ b/api/src/operations/client-operations.controller.ts @@ -14,8 +14,20 @@ export class ClientOperationsController { } @Get('batch-tasks/:id/messages') - listTaskMessages(@Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) { - return this.operations.listMessages({ taskId, phoneNumber }); + listTaskMessages(@TenantId() tenantId: string | undefined, @Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) { + return this.operations.listMessages({ tenantId, taskId, phoneNumber }); + } + + @Get('messages') + listMessages( + @TenantId() tenantId?: string, + @Query('applicationId') applicationId?: string, + @Query('taskId') taskId?: string, + @Query('messageId') messageId?: string, + @Query('phoneNumber') phoneNumber?: string, + @Query('status') status?: string, + ) { + return this.operations.listMessages({ tenantId, applicationId, taskId, messageId, phoneNumber, status }); } @Get('uplink-messages') diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 27503af..8ddf32d 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -71,6 +71,7 @@ describe('OperationsService', () => { applicationId: 'app-1', channelId: 'channel-1', taskId: 'task-1', + messageId: 'MSG-1', phoneNumber: '13800000001', status: 'delivered', }); @@ -81,15 +82,30 @@ describe('OperationsService', () => { applicationId: 'app-1', channelId: 'channel-1', batchTaskId: 'task-1', + messageId: 'MSG-1', phoneNumber: '13800000001', status: 'delivered', }, - include: { submitRecords: true, receiptRecords: true }, + include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true }, orderBy: { queuedAt: 'desc' }, take: 500, }); }); + it('returns uplink messages with tenant and channel display data', async () => { + const prisma = createPrismaMock(); + const service = new OperationsService(prisma as never); + + await service.listUplinkMessages({ tenantId: 'tenant-1', channelId: 'channel-1' }); + + expect(prisma.smsUplinkMessage.findMany).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-1', channelId: 'channel-1' }, + include: { tenant: true, channel: true }, + orderBy: { receivedAt: 'desc' }, + take: 500, + }); + }); + it('builds dashboard and statistics aggregates', async () => { const prisma = createPrismaMock(); const service = new OperationsService(prisma as never); diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 5324445..45760b6 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -7,6 +7,7 @@ export interface MessageQuery { applicationId?: string; channelId?: string; taskId?: string; + messageId?: string; phoneNumber?: string; status?: string; } @@ -42,7 +43,7 @@ export class OperationsService { listMessages(query: MessageQuery) { return this.prisma.smsMessageRecord.findMany({ where: messageWhere(query), - include: { submitRecords: true, receiptRecords: true }, + include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true }, orderBy: { queuedAt: 'desc' }, take: 500, }); @@ -51,6 +52,7 @@ export class OperationsService { listUplinkMessages(query: { tenantId?: string; channelId?: string }) { return this.prisma.smsUplinkMessage.findMany({ where: { tenantId: query.tenantId, channelId: query.channelId }, + include: { tenant: true, channel: true }, orderBy: { receivedAt: 'desc' }, take: 500, }); @@ -352,6 +354,7 @@ function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput { applicationId: query.applicationId, channelId: query.channelId, batchTaskId: query.taskId, + messageId: query.messageId, phoneNumber: query.phoneNumber, status: query.status, }; diff --git a/api/src/send-chain/admin-send-chain.controller.ts b/api/src/send-chain/admin-send-chain.controller.ts index 0aa761e..5d1a633 100644 --- a/api/src/send-chain/admin-send-chain.controller.ts +++ b/api/src/send-chain/admin-send-chain.controller.ts @@ -37,6 +37,11 @@ export class AdminSendChainController { return this.sendChain.enqueueBatchTask(taskId); } + @Post('batch-tasks/:id/terminate') + terminateTask(@Param('id') taskId: string) { + return this.sendChain.terminateBatchTask(taskId); + } + @Post('scheduled/dispatch-due') dispatchDueScheduledTasks() { return this.sendChain.dispatchDueScheduledTasks(); diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index adcc948..fef922a 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -244,6 +244,23 @@ describe('SendChainService', () => { }); }); + it('terminates non-final tasks by canceling unsubmitted messages', async () => { + const { service, prisma } = createService(); + prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'sending' }); + service['refreshTaskProgress'] = jest.fn().mockResolvedValue(undefined); + + await service.terminateBatchTask('task-1'); + + expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({ + where: { batchTaskId: 'task-1', status: { in: ['ready', 'queued', 'scheduled', 'submit_queued'] } }, + data: { status: 'canceled', errorMessage: '运营终止任务,未提交号码停止发送' }, + }); + expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({ + where: { id: 'task-1' }, + data: { status: 'canceled', canceledAt: expect.any(Date), rejectReason: '运营终止任务' }, + }); + }); + it('blocks sending when enterprise certification is not approved', async () => { const { service, prisma } = createService(); prisma.tenant.findUnique.mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'rejected' }); diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index d56ead5..136deab 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -237,7 +237,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { listBatchTasks(tenantId?: string, status?: string) { return this.prisma.smsBatchTask.findMany({ where: { tenantId, status }, - include: { apiRequests: true }, + include: { + tenant: true, + application: true, + template: true, + apiRequests: true, + messages: { include: { channel: true }, orderBy: { queuedAt: 'asc' }, take: 100000 }, + }, orderBy: { createdAt: 'desc' }, take: 100, }); @@ -388,6 +394,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }); } + async terminateBatchTask(taskId: string) { + const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } }); + if (!task) { + throw new NotFoundException('SMS batch task not found'); + } + if (['finished', 'completed', 'failed', 'canceled', 'rejected'].includes(task.status)) { + throw new BadRequestException('SMS batch task is already final'); + } + await this.prisma.smsMessageRecord.updateMany({ + where: { batchTaskId: taskId, status: { in: ['ready', 'queued', 'scheduled', 'submit_queued'] } }, + data: { status: 'canceled', errorMessage: '运营终止任务,未提交号码停止发送' }, + }); + await this.refreshTaskProgress(taskId); + return this.prisma.smsBatchTask.update({ + where: { id: taskId }, + data: { status: 'canceled', canceledAt: new Date(), rejectReason: '运营终止任务' }, + }); + } + async dispatchDueScheduledTasks(now = new Date()) { const tasks = await this.prisma.smsBatchTask.findMany({ where: { status: 'scheduled', scheduledAt: { lte: now } }, diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index 63c635f..d5328e4 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { ReviewDto, SmsConfigService, StatusChangeDto } from './sms-config.service'; +import { CreateSmsApplicationDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service'; @ApiTags('admin-sms-config') @Controller('admin') @@ -12,6 +12,26 @@ export class AdminSmsConfigController { return this.smsConfig.listApplications({ tenantId, keyword, includeConnections: true }); } + @Get('enterprise-applications/:id') + getApplication(@Param('id') applicationId: string) { + return this.smsConfig.getApplication(applicationId); + } + + @Post('enterprise-applications') + createApplication(@Body() body: CreateSmsApplicationDto) { + return this.smsConfig.createApplication(body); + } + + @Put('enterprise-applications/:id') + updateApplication(@Param('id') applicationId: string, @Body() body: UpdateSmsApplicationDto) { + return this.smsConfig.updateApplication(applicationId, body); + } + + @Put('enterprise-applications/:id/route-rules') + replaceApplicationRouteRules(@Param('id') applicationId: string, @Body() body: ReplaceApplicationRouteRulesDto) { + return this.smsConfig.replaceApplicationRouteRules(applicationId, body); + } + @Get('enterprise-applications/:id/connections') listApplicationConnections(@Param('id') applicationId: string) { return this.smsConfig.listApplicationConnections(applicationId); @@ -33,8 +53,18 @@ export class AdminSmsConfigController { } @Get('enterprise-signatures') - listSignatures(@Query('tenantId') tenantId?: string) { - return this.smsConfig.listSignatures(tenantId); + listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) { + return this.smsConfig.listSignatures({ tenantId, keyword, status }); + } + + @Post('enterprise-signatures') + createSignature(@Body() body: CreateSmsSignatureDto) { + return this.smsConfig.createSignature(body); + } + + @Put('enterprise-signatures/:id') + updateSignature(@Param('id') signatureId: string, @Body() body: UpdateSmsSignatureDto) { + return this.smsConfig.updateSignature(signatureId, body); } @Get('enterprise-templates') @@ -42,6 +72,16 @@ export class AdminSmsConfigController { return this.smsConfig.listTemplates({ tenantId, status, keyword }); } + @Post('enterprise-templates') + createTemplate(@Body() body: CreateSmsTemplateDto) { + return this.smsConfig.createTemplate(body); + } + + @Put('enterprise-templates/:id') + updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto) { + return this.smsConfig.updateTemplate(templateId, body); + } + @Get('audit-records') listAuditRecords(@Query('targetType') targetType?: string, @Query('targetId') targetId?: string) { return this.smsConfig.listAuditRecords(targetType, targetId); diff --git a/api/src/sms-config/client-sms-config.controller.ts b/api/src/sms-config/client-sms-config.controller.ts index 39641d0..bacbb5a 100644 --- a/api/src/sms-config/client-sms-config.controller.ts +++ b/api/src/sms-config/client-sms-config.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { TenantId } from '../common/tenant-id.decorator'; import { @@ -8,6 +8,7 @@ import { CreateSmsTemplateDto, StatusChangeDto, SmsConfigService, + UpdateSmsTemplateDto, } from './sms-config.service'; @ApiTags('client-sms-config') @@ -25,6 +26,11 @@ export class ClientSmsConfigController { return this.smsConfig.createApplication(body); } + @Get('applications/:id/cmpp-params') + getApplicationCmppParams(@Param('id') applicationId: string, @TenantId() tenantId?: string) { + return this.smsConfig.getApplicationCmppParams(applicationId, tenantId); + } + @Post('applications/:id/secret/reset') resetApplicationSecret(@Param('id') applicationId: string, @Body() body: StatusChangeDto) { return this.smsConfig.resetApplicationSecret(applicationId, body); @@ -70,6 +76,11 @@ export class ClientSmsConfigController { return this.smsConfig.createTemplate(body); } + @Put('templates/:id') + updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto) { + return this.smsConfig.updateTemplate(templateId, body); + } + @Post('templates/:id/submit') submitTemplate(@Param('id') templateId: string) { return this.smsConfig.submitTemplate(templateId); diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 7435024..c57ff6c 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -19,14 +19,59 @@ function createPrismaMock() { secretHash: 'secret-hash', tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, }), + update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })), + create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-new', ...data })), + }, + smsApplicationIpAllowlist: { + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + smsChannelGroup: { + findMany: jest.fn().mockResolvedValue([ + { id: 'group-mobile', carrier: 'mobile' }, + { id: 'group-unicom', carrier: 'unicom' }, + ]), + }, + channelRouteRule: { + deleteMany: jest.fn().mockResolvedValue({ count: 2 }), + createMany: jest.fn().mockResolvedValue({ count: 2 }), + findMany: jest.fn().mockResolvedValue([ + { id: 'rule-1', applicationId: 'app-1', groupId: 'group-mobile', carrier: 'mobile', priority: 10, status: 'active' }, + { id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active' }, + ]), }, smsSignature: { + findMany: jest.fn().mockResolvedValue([{ + id: 'sig-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + name: '签名A', + purpose: '行业通知', + auditStatus: 'pending', + drainageInfo: { carrierStatus: { mobile: 'approved', unicom: 'pending', telecom: 'filing' }, links: [] }, + tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, + application: { id: 'app-1', name: '应用A' }, + materials: [], + }]), findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }), - update: jest.fn(), + update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })), }, smsTemplate: { + findMany: jest.fn().mockResolvedValue([{ + id: 'tpl-1', + tenantId: 'tenant-1', + applicationId: 'app-1', + signatureId: 'sig-1', + name: '模板A', + content: '您好${name}', + auditStatus: 'pending', + tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, + application: { id: 'app-1', name: '应用A' }, + signature: { id: 'sig-1', name: '签名A' }, + variables: [{ name: 'name', required: true }], + }]), findUnique: jest.fn().mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', auditStatus: 'pending' }), - update: jest.fn(), + update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })), + create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-new', ...data })), }, auditRecord: { create: jest.fn(), @@ -56,6 +101,28 @@ function createPrismaMock() { operationLog: { create: jest.fn(), }, + $transaction: jest.fn((callback) => callback({ + smsApplication: { + update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })), + }, + smsApplicationIpAllowlist: { + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + channelRouteRule: { + deleteMany: jest.fn().mockResolvedValue({ count: 2 }), + createMany: jest.fn().mockResolvedValue({ count: 2 }), + findMany: jest.fn().mockResolvedValue([ + { id: 'rule-1', applicationId: 'app-1', groupId: 'group-mobile', carrier: 'mobile', priority: 10, status: 'active' }, + { id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active' }, + ]), + }, + templateVariable: { + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + smsTemplate: { + update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })), + }, + })), }; } @@ -100,6 +167,88 @@ describe('SmsConfigService', () => { })); }); + it('updates enterprise application profile and allowlist through a transaction', async () => { + const prisma = createPrismaMock(); + const tx = { + smsApplication: { + update: jest.fn().mockResolvedValue({ id: 'app-1', name: '新应用', ipAllowlist: [{ ipCidr: '10.0.0.1/32' }] }), + }, + smsApplicationIpAllowlist: { + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + channelRouteRule: prisma.channelRouteRule, + }; + prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx)); + const service = new SmsConfigService(prisma as never); + + await expect(service.updateApplication('app-1', { name: '新应用', customerUnitPrice: 300, ipAllowlist: ['10.0.0.1/32'] })) + .resolves.toEqual(expect.objectContaining({ id: 'app-1', name: '新应用' })); + + expect(tx.smsApplicationIpAllowlist.deleteMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1' } }); + expect(tx.smsApplication.update).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'app-1' }, + data: expect.objectContaining({ + name: '新应用', + customerUnitPrice: 300, + ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] }, + }), + })); + }); + + it('replaces application carrier channel-group routes with carrier validation', async () => { + const prisma = createPrismaMock(); + const tx = { + channelRouteRule: { + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + createMany: jest.fn().mockResolvedValue({ count: 2 }), + findMany: jest.fn().mockResolvedValue([ + { id: 'rule-1', carrier: 'mobile', groupId: 'group-mobile' }, + { id: 'rule-2', carrier: 'unicom', groupId: 'group-unicom' }, + ]), + }, + }; + prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx)); + const service = new SmsConfigService(prisma as never); + + await expect(service.replaceApplicationRouteRules('app-1', { + routes: [ + { carrier: 'mobile', groupId: 'group-mobile' }, + { carrier: 'unicom', groupId: 'group-unicom' }, + ], + })).resolves.toEqual([ + expect.objectContaining({ carrier: 'mobile' }), + expect.objectContaining({ carrier: 'unicom' }), + ]); + + expect(tx.channelRouteRule.deleteMany).toHaveBeenCalledWith({ + where: { applicationId: 'app-1', channelId: null, province: null }, + }); + expect(tx.channelRouteRule.createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'mobile', groupId: 'group-mobile', priority: 10 }), + expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'unicom', groupId: 'group-unicom', priority: 20 }), + ], + }); + }); + + it('rejects application routes when channel-group carrier does not match', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + + await expect(service.replaceApplicationRouteRules('app-1', { + routes: [{ carrier: 'telecom', groupId: 'group-mobile' }], + })).rejects.toThrow('channel group carrier must match route carrier'); + + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it('hides CMPP params when the application belongs to another tenant', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + + await expect(service.getApplicationCmppParams('app-1', 'tenant-2')).rejects.toThrow('Application not found'); + }); + it('disconnects application CMPP connections and writes operation logs', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); @@ -118,4 +267,112 @@ describe('SmsConfigService', () => { }), }); }); + + it('lists enterprise signatures with keyword filters and real relations', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + + await expect(service.listSignatures({ keyword: '签名A' })).resolves.toEqual([ + expect.objectContaining({ + id: 'sig-1', + tenant: expect.objectContaining({ name: '租户A' }), + application: expect.objectContaining({ name: '应用A' }), + }), + ]); + expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + auditStatus: { not: 'deleted' }, + OR: expect.any(Array), + }), + include: { materials: true, tenant: true, application: true }, + })); + }); + + it('updates enterprise signature drainage info through the admin API path', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + + await expect(service.updateSignature('sig-1', { + name: '签名B', + auditStatus: 'approved', + drainageInfo: { + carrierStatus: { mobile: 'approved', unicom: 'approved', telecom: 'approved' }, + links: [{ id: 'drain-1', siteName: '官网', url: 'https://example.com' }], + }, + })).resolves.toEqual(expect.objectContaining({ + id: 'sig-1', + name: '签名B', + auditStatus: 'approved', + })); + + expect(prisma.smsSignature.update).toHaveBeenCalledWith({ + where: { id: 'sig-1' }, + data: expect.objectContaining({ + name: '签名B', + auditStatus: 'approved', + drainageInfo: expect.objectContaining({ + carrierStatus: expect.objectContaining({ mobile: 'approved' }), + }), + }), + include: { materials: true, tenant: true, application: true }, + }); + }); + + it('lists enterprise templates with real relations and excludes deleted by default', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + + await expect(service.listTemplates({ keyword: '模板A' })).resolves.toEqual([ + expect.objectContaining({ + id: 'tpl-1', + tenant: expect.objectContaining({ name: '租户A' }), + application: expect.objectContaining({ name: '应用A' }), + signature: expect.objectContaining({ name: '签名A' }), + }), + ]); + expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + auditStatus: { not: 'deleted' }, + OR: expect.any(Array), + }), + include: { variables: true, application: true, tenant: true, signature: true }, + })); + }); + + it('updates enterprise templates and rebuilds template variables', async () => { + const prisma = createPrismaMock(); + const tx = { + templateVariable: { + deleteMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + smsTemplate: { + update: jest.fn().mockResolvedValue({ id: 'tpl-1', name: '模板B', variables: [{ name: 'code' }] }), + }, + }; + prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx)); + const service = new SmsConfigService(prisma as never); + + await expect(service.updateTemplate('tpl-1', { + applicationId: 'app-1', + signatureId: 'sig-1', + name: '模板B', + content: '验证码${code}', + variables: [{ name: 'code', example: '123456', required: true }], + })).resolves.toEqual(expect.objectContaining({ id: 'tpl-1', name: '模板B' })); + + expect(tx.templateVariable.deleteMany).toHaveBeenCalledWith({ where: { templateId: 'tpl-1' } }); + expect(tx.smsTemplate.update).toHaveBeenCalledWith({ + where: { id: 'tpl-1' }, + data: expect.objectContaining({ + applicationId: 'app-1', + signatureId: 'sig-1', + name: '模板B', + content: '验证码${code}', + variables: { + create: [{ name: 'code', example: '123456', required: true }], + }, + }), + include: { variables: true, application: true, tenant: true, signature: true }, + }); + }); }); diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 36deab0..fdc1c12 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -15,6 +15,19 @@ export interface CreateSmsApplicationDto { ipAllowlist?: string[]; } +export type UpdateSmsApplicationDto = Partial> & { + status?: string; +}; + +export interface ReplaceApplicationRouteRulesDto { + routes: Array<{ + carrier: string; + groupId: string; + priority?: number; + status?: string; + }>; +} + export interface CreateSmsSignatureDto { tenantId: string; applicationId?: string; @@ -23,6 +36,10 @@ export interface CreateSmsSignatureDto { drainageInfo?: Record; } +export type UpdateSmsSignatureDto = Partial> & { + auditStatus?: string; +}; + export interface CreateSignatureMaterialDto { signatureId: string; fileObjectId?: string; @@ -41,6 +58,10 @@ export interface CreateSmsTemplateDto { variables?: Array<{ name: string; example?: string; required?: boolean }>; } +export type UpdateSmsTemplateDto = Partial> & { + auditStatus?: string; +}; + export interface ReviewDto { reviewerId?: string; reason?: string; @@ -111,6 +132,20 @@ export class SmsConfigService { }); } + async getApplication(applicationId: string) { + const application = await this.prisma.smsApplication.findUnique({ + where: { id: applicationId }, + include: { + tenant: true, + ipAllowlist: true, + }, + }); + if (!application) { + throw new NotFoundException('Application not found'); + } + return application; + } + createApplication(data: CreateSmsApplicationDto) { const secret = randomBytes(24).toString('hex'); return this.prisma.smsApplication.create({ @@ -132,6 +167,97 @@ export class SmsConfigService { }); } + async updateApplication(applicationId: string, data: UpdateSmsApplicationDto) { + const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); + if (!application) { + throw new NotFoundException('Application not found'); + } + + return this.prisma.$transaction(async (tx) => { + if (data.ipAllowlist) { + await tx.smsApplicationIpAllowlist.deleteMany({ where: { applicationId } }); + } + return tx.smsApplication.update({ + where: { id: applicationId }, + data: { + name: data.name, + scene: data.scene, + callbackUrl: data.callbackUrl, + dailyLimit: data.dailyLimit, + customerUnitPrice: data.customerUnitPrice, + maxPhonesPerTask: data.maxPhonesPerTask, + templateMismatchMode: data.templateMismatchMode, + status: data.status, + ipAllowlist: data.ipAllowlist ? { + create: data.ipAllowlist.map((ipCidr) => ({ ipCidr })), + } : undefined, + }, + include: { tenant: true, ipAllowlist: true }, + }); + }); + } + + async replaceApplicationRouteRules(applicationId: string, data: ReplaceApplicationRouteRulesDto) { + const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); + if (!application) { + throw new NotFoundException('Application not found'); + } + const routes = data.routes ?? []; + if (routes.length === 0) { + throw new BadRequestException('At least one carrier channel group is required'); + } + + const carriers = new Set(); + routes.forEach((route) => { + if (!['mobile', 'unicom', 'telecom'].includes(route.carrier)) { + throw new BadRequestException('carrier must be mobile, unicom or telecom'); + } + if (carriers.has(route.carrier)) { + throw new BadRequestException('Duplicate carrier route is not allowed'); + } + carriers.add(route.carrier); + }); + + const groups = await this.prisma.smsChannelGroup.findMany({ + where: { id: { in: routes.map((route) => route.groupId) }, status: { not: 'deleted' } }, + select: { id: true, carrier: true }, + }); + const groupMap = new Map(groups.map((group) => [group.id, group])); + routes.forEach((route) => { + const group = groupMap.get(route.groupId); + if (!group) { + throw new BadRequestException(`channel group ${route.groupId} does not exist`); + } + if (group.carrier !== route.carrier) { + throw new BadRequestException('channel group carrier must match route carrier'); + } + }); + + return this.prisma.$transaction(async (tx) => { + await tx.channelRouteRule.deleteMany({ + where: { + applicationId, + channelId: null, + province: null, + }, + }); + await tx.channelRouteRule.createMany({ + data: routes.map((route, index) => ({ + tenantId: application.tenantId, + applicationId, + groupId: route.groupId, + carrier: route.carrier, + priority: route.priority ?? (index + 1) * 10, + status: route.status ?? 'active', + })), + }); + return tx.channelRouteRule.findMany({ + where: { applicationId, channelId: null, province: null, status: { not: 'deleted' } }, + orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }], + }); + }); + } + async resetApplicationSecret(applicationId: string, data: StatusChangeDto = {}) { const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); if (!application) { @@ -188,12 +314,12 @@ export class SmsConfigService { }; } - async getApplicationCmppParams(applicationId: string) { + async getApplicationCmppParams(applicationId: string, tenantId?: string) { const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId }, include: { tenant: true }, }); - if (!application) { + if (!application || (tenantId && application.tenantId !== tenantId)) { throw new NotFoundException('Application not found'); } const channel = await this.prisma.smsChannel.findFirst({ @@ -246,10 +372,20 @@ export class SmsConfigService { return updated; } - listSignatures(tenantId?: string) { + listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) { + const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {}; return this.prisma.smsSignature.findMany({ - where: tenantId ? { tenantId } : undefined, - include: { materials: true }, + where: { + tenantId: query.tenantId, + auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, + OR: query.keyword ? [ + { name: { contains: query.keyword } }, + { purpose: { contains: query.keyword } }, + { tenant: { name: { contains: query.keyword } } }, + { application: { name: { contains: query.keyword } } }, + ] : undefined, + }, + include: { materials: true, tenant: true, application: true }, orderBy: { createdAt: 'desc' }, take: 100, }); @@ -267,6 +403,24 @@ export class SmsConfigService { }); } + async updateSignature(signatureId: string, data: UpdateSmsSignatureDto) { + const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } }); + if (!signature) { + throw new NotFoundException('Signature not found'); + } + return this.prisma.smsSignature.update({ + where: { id: signatureId }, + data: { + applicationId: data.applicationId, + name: data.name, + purpose: data.purpose, + auditStatus: data.auditStatus, + drainageInfo: data.drainageInfo as Prisma.InputJsonValue | undefined, + }, + include: { materials: true, tenant: true, application: true }, + }); + } + createSignatureMaterial(data: CreateSignatureMaterialDto) { return this.prisma.signatureMaterial.create({ data: { @@ -305,7 +459,7 @@ export class SmsConfigService { return this.prisma.smsTemplate.findMany({ where: { tenantId: query.tenantId, - auditStatus: query.status && query.status !== 'all' ? query.status : undefined, + auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, OR: query.keyword ? [ { name: { contains: query.keyword } }, { content: { contains: query.keyword } }, @@ -338,7 +492,52 @@ export class SmsConfigService { })), }, }, - include: { variables: true }, + include: { variables: true, application: true, tenant: true, signature: true }, + }); + } + + async updateTemplate(templateId: string, data: UpdateSmsTemplateDto) { + const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); + if (!template) { + throw new NotFoundException('Template not found'); + } + if (data.applicationId) { + const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); + if (!application || application.tenantId !== template.tenantId) { + throw new BadRequestException('applicationId does not belong to the template tenant'); + } + } + if (data.signatureId) { + const signature = await this.prisma.smsSignature.findUnique({ where: { id: data.signatureId }, select: { tenantId: true } }); + if (!signature || signature.tenantId !== template.tenantId) { + throw new BadRequestException('signatureId does not belong to the template tenant'); + } + } + const variables = data.variables ?? (data.content ? inferTemplateVariables(data.content) : undefined); + return this.prisma.$transaction(async (tx) => { + if (variables) { + await tx.templateVariable.deleteMany({ where: { templateId } }); + } + return tx.smsTemplate.update({ + where: { id: templateId }, + data: { + applicationId: data.applicationId, + signatureId: data.signatureId, + name: data.name, + content: data.content, + category: data.category, + auditStatus: data.auditStatus, + billingUnits: data.content ? estimateBillingUnits(data.content) : undefined, + variables: variables ? { + create: variables.map((variable) => ({ + name: variable.name, + example: variable.example, + required: variable.required ?? true, + })), + } : undefined, + }, + include: { variables: true, application: true, tenant: true, signature: true }, + }); }); } diff --git a/api/src/tenants/tenants.service.spec.ts b/api/src/tenants/tenants.service.spec.ts new file mode 100644 index 0000000..0cbe8ff --- /dev/null +++ b/api/src/tenants/tenants.service.spec.ts @@ -0,0 +1,74 @@ +import { NotFoundException } from '@nestjs/common'; +import { TenantsService } from './tenants.service'; + +function createPrismaMock() { + const tenant = { + id: 'tenant-1', + name: '测试企业', + code: 'TENANT001', + status: 'active', + createdAt: new Date('2026-07-03T00:00:00.000Z'), + updatedAt: new Date('2026-07-03T00:00:00.000Z'), + }; + return { + tenant: { + findMany: jest.fn().mockResolvedValue([{ ...tenant, enterpriseCertifications: [] }]), + findUnique: jest.fn().mockResolvedValue({ ...tenant, enterpriseCertifications: [] }), + create: jest.fn().mockResolvedValue(tenant), + update: jest.fn().mockResolvedValue(tenant), + }, + enterpriseCertification: { + findFirst: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({ id: 'cert-1' }), + update: jest.fn().mockResolvedValue({ id: 'cert-1' }), + }, + }; +} + +describe('TenantsService', () => { + it('creates tenants with a real enterprise profile', async () => { + const prisma = createPrismaMock(); + const service = new TenantsService(prisma as never); + + await service.create({ + name: '测试企业', + code: 'TENANT001', + creditCode: '91370000123456789X', + province: '山东', + city: '济南', + address: '历下区测试路 1 号', + contactName: '张三', + contactIdCard: '370100199001010011', + contactPhone: '13800000000', + contactEmail: 'contact@example.com', + }); + + expect(prisma.enterpriseCertification.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tenantId: 'tenant-1', + companyName: '测试企业', + licenseNo: '91370000123456789X', + contactName: '张三', + contactPhone: '13800000000', + status: 'approved', + materials: expect.objectContaining({ + province: '山东', + city: '济南', + address: '历下区测试路 1 号', + contactIdCard: '370100199001010011', + contactEmail: 'contact@example.com', + }), + }), + }); + }); + + it('throws 404 when updating or deleting a missing tenant', async () => { + const prisma = createPrismaMock(); + prisma.tenant.findUnique.mockResolvedValue(null); + const service = new TenantsService(prisma as never); + + await expect(service.update('missing', { name: '不存在' })).rejects.toBeInstanceOf(NotFoundException); + await expect(service.delete('missing')).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.tenant.update).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/tenants/tenants.service.ts b/api/src/tenants/tenants.service.ts index eed3bc6..6780521 100644 --- a/api/src/tenants/tenants.service.ts +++ b/api/src/tenants/tenants.service.ts @@ -1,16 +1,35 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; export interface CreateTenantDto { name: string; code: string; status?: string; + creditCode?: string; + province?: string; + city?: string; + address?: string; + contactName?: string; + contactIdCard?: string; + contactPhone?: string; + contactEmail?: string; + photoFileObjectId?: string; } export interface UpdateTenantDto { name?: string; code?: string; status?: string; + creditCode?: string; + province?: string; + city?: string; + address?: string; + contactName?: string; + contactIdCard?: string; + contactPhone?: string; + contactEmail?: string; + photoFileObjectId?: string; } @Injectable() @@ -19,27 +38,35 @@ export class TenantsService { list() { return this.prisma.tenant.findMany({ + include: { enterpriseCertifications: { orderBy: { submittedAt: 'desc' }, take: 1 } }, orderBy: { createdAt: 'desc' }, take: 100, - }); + }).then((items) => items.map(withEnterpriseProfile)); } get(id: string) { - return this.prisma.tenant.findUnique({ where: { id } }); - } - - create(data: CreateTenantDto) { - return this.prisma.tenant.create({ - data: { - name: data.name, - code: data.code, - status: data.status ?? 'active', - }, + return this.prisma.tenant.findUnique({ + where: { id }, + include: { enterpriseCertifications: { orderBy: { submittedAt: 'desc' }, take: 1 } }, + }).then((tenant) => { + if (!tenant) { + throw new NotFoundException('Tenant not found'); + } + return withEnterpriseProfile(tenant); }); } - update(id: string, data: UpdateTenantDto) { - return this.prisma.tenant.update({ + async create(data: CreateTenantDto) { + const tenant = await this.prisma.tenant.create({ + data: { name: data.name, code: data.code, status: data.status ?? 'active' }, + }); + await this.upsertProfile(tenant.id, data); + return this.get(tenant.id); + } + + async update(id: string, data: UpdateTenantDto) { + await this.ensureTenant(id); + await this.prisma.tenant.update({ where: { id }, data: { name: data.name, @@ -47,9 +74,12 @@ export class TenantsService { status: data.status, }, }); + await this.upsertProfile(id, data); + return this.get(id); } - changeStatus(id: string, status: string) { + async changeStatus(id: string, status: string) { + await this.ensureTenant(id); return this.prisma.tenant.update({ where: { id }, data: { status }, @@ -59,4 +89,77 @@ export class TenantsService { delete(id: string) { return this.changeStatus(id, 'deleted'); } + + private async ensureTenant(id: string) { + const tenant = await this.prisma.tenant.findUnique({ where: { id } }); + if (!tenant) { + throw new NotFoundException('Tenant not found'); + } + return tenant; + } + + private async upsertProfile(tenantId: string, data: CreateTenantDto | UpdateTenantDto) { + const hasProfileData = ['creditCode', 'province', 'city', 'address', 'contactName', 'contactIdCard', 'contactPhone', 'contactEmail', 'photoFileObjectId'] + .some((key) => data[key as keyof (CreateTenantDto | UpdateTenantDto)] !== undefined); + if (!hasProfileData) { + return; + } + const latest = await this.prisma.enterpriseCertification.findFirst({ + where: { tenantId }, + orderBy: { submittedAt: 'desc' }, + }); + const materials = cleanObject({ + ...(latest?.materials && typeof latest.materials === 'object' && !Array.isArray(latest.materials) ? latest.materials as Record : {}), + province: data.province, + city: data.city, + address: data.address, + contactIdCard: data.contactIdCard, + contactEmail: data.contactEmail, + photoFileObjectId: data.photoFileObjectId, + }); + const profileData = { + companyName: data.name ?? latest?.companyName ?? tenantId, + licenseNo: data.creditCode, + contactName: data.contactName, + contactPhone: data.contactPhone, + materials: materials as Prisma.InputJsonValue, + status: 'approved', + }; + if (latest) { + await this.prisma.enterpriseCertification.update({ + where: { id: latest.id }, + data: profileData, + }); + return; + } + await this.prisma.enterpriseCertification.create({ + data: { tenantId, ...profileData }, + }); + } +} + +function cleanObject(value: Record) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); +} + +function withEnterpriseProfile }>(tenant: T) { + const [profile] = tenant.enterpriseCertifications ?? []; + const materials = profile?.materials && typeof profile.materials === 'object' && !Array.isArray(profile.materials) + ? profile.materials as Record + : {}; + const { enterpriseCertifications, ...rest } = tenant; + return { + ...rest, + enterpriseProfile: profile ? { + creditCode: profile.licenseNo ?? '', + province: String(materials.province ?? ''), + city: String(materials.city ?? ''), + address: String(materials.address ?? ''), + contactName: profile.contactName ?? '', + contactIdCard: String(materials.contactIdCard ?? ''), + contactPhone: profile.contactPhone ?? '', + contactEmail: String(materials.contactEmail ?? ''), + photoFileObjectId: String(materials.photoFileObjectId ?? ''), + } : null, + }; } diff --git a/docs/admin-prototype-recovery-scope.md b/docs/admin-prototype-recovery-scope.md new file mode 100644 index 0000000..0a2d29b --- /dev/null +++ b/docs/admin-prototype-recovery-scope.md @@ -0,0 +1,201 @@ +# 运营端企业配置原型恢复范围 + +## 背景 + +本次排查定位到页面与原型出现明显差异的关键提交为: + +- 提交:`131f344ac4da5e3b610e4fd32596d4546a1cf9de` +- 时间:`2026-07-02 19:30:04 +0800` +- 标题:`fix: connect remaining sms pages to real backend` + +该提交的目标是把剩余短信页面从 mock/localStorage 接到真实后端,但执行方式把部分原型页面重写为最小真实 API 表格/表单,导致原型字段、弹窗、Tab、引流信息和新增/编辑动作大量丢失。 + +后续修复原则: + +1. 以 `131f344a^` 中的原型页面结构和字段命名为视觉与交互基准。 +2. 保留当前真实 API 接入方向,不回退到 mock/localStorage/static 数组。 +3. 前端缺入口时恢复入口;后端缺字段或接口时补 NestJS API 与 Prisma 模型/服务。 +4. 彩信能力仍按第一版待开发边界处理;短信能力不得用彩信待开发作为阻塞理由。 + +## 差异清单 + +### 企业管理:列表、新增、编辑、详情 + +相关文件: + +- `src/apps/admin/AdminCustomersPage.tsx` +- `src/apps/admin/AdminCustomerFormPage.tsx` +- `src/apps/admin/AdminCustomerDetailPage.tsx` +- `api/src/tenants/tenants.service.ts` + +状态:阶段 4-1 已修复。 + +原型能力: + +- 企业照片上传占位。 +- 统一社会信用代码。 +- 省/直辖市、市/区。 +- 通讯地址。 +- 联系人姓名。 +- 联系人身份证号。 +- 联系人手机号。 +- 企业主体、证照、地址、联系人分区。 +- 本地原型通过 `adminEnterpriseMock` 保存完整企业档案。 + +当前实现: + +- 表单只保留 `企业名称`、`企业编码`、`企业状态`。 +- 保存只写 `Tenant.name`、`Tenant.code`、`Tenant.status`。 +- 企业档案、证照、地址和联系人信息没有真实保存入口。 +- 删除是软删除 `status=deleted`,但不存在或过期 ID 会由 Prisma update 抛 500。 + +需要恢复/补齐: + +- 已恢复企业表单的主体、证照地址、联系人区块和主要字段。 +- 已复用 `EnterpriseCertification` 保存真实企业档案,并在租户接口返回 `enterpriseProfile`。 +- 已修复企业删除/更新/状态变更的不存在 ID 处理,返回 404,不再由 Prisma update 抛 500。 +- 已在企业列表和详情展示真实企业档案字段。 + +### 企业应用管理 + +相关文件: + +- `src/apps/admin/AdminEnterpriseApplicationsPage.tsx` +- `src/apps/admin/AdminSmsApplicationFormPage.tsx` +- `src/api/adminApi.ts` +- `api/src/sms-config/admin-sms-config.controller.ts` +- `api/src/sms-config/client-sms-config.controller.ts` +- `api/src/sms-config/sms-config.service.ts` + +状态:阶段 4-2 已修复短信应用新增/编辑闭环;彩信仍为待开发边界。 + +原型/预期能力: + +- 运营端企业应用页可新增、编辑、启用/停用、删除应用。 +- 新增/编辑必须作用于真实企业。 +- 短信应用保存时可配置移动、联通、电信通道组。 +- 彩信应用 Tab 是待开发边界,不计入短信验收。 + +当前实现: + +- 短信应用列表接了真实 `GET /api/admin/enterprise-applications`。 +- 新增时先选择真实企业,再进入 `/admin/customers/{tenantId}/sms-apps/new`。 +- 编辑入口使用应用自身 `tenantId`,不再硬编码企业 ID。 +- 运营端已补 `GET/POST/PUT /api/admin/enterprise-applications`。 +- 编辑表单会加载真实应用详情和已有三网路由规则。 +- 保存时调用 `PUT /api/admin/enterprise-applications/{id}/route-rules` 事务替换应用级运营商通道组绑定。 +- 彩信 Tab 只显示待开发空态,不再渲染 `initialMmsApps` 演示数组。 + +需要恢复/补齐: + +- 已删除企业应用页硬编码企业 ID。 +- 已实现企业应用页新增选择真实企业。 +- 已补运营端应用创建/编辑 API。 +- 已实现编辑页加载真实应用详情并可保存。 +- 已强制短信应用保存时至少配置一个运营商通道组。 +- 已校验应用路由 carrier 与通道组 carrier 一致。 +- 彩信 Tab 标记为待开发,不得作为短信功能验收项。 + +### 企业签名与引流信息 + +相关文件: + +- `src/apps/admin/AdminEnterpriseSignaturesPage.tsx` +- `src/api/adminApi.ts` +- `api/src/sms-config/admin-sms-config.controller.ts` +- `api/src/sms-config/client-sms-config.controller.ts` +- `api/src/sms-config/sms-config.service.ts` +- `api/prisma/schema.prisma` + +状态:阶段 4-3 已修复短信签名和引流信息新增/编辑/删除闭环;彩信签名仍为待开发边界。 + +原型能力: + +- 短信/彩信签名 Tab。 +- 添加签名、编辑签名、删除签名。 +- 资质凭证、法人身份证、责任人身份证上传占位。 +- 三网报备状态:移动、联通、电信。 +- 签名报备详情。 +- 引流信息列表。 +- 添加/编辑/删除引流链接。 +- 引流信息报备详情。 + +当前实现: + +- 运营端签名页恢复短信/彩信 Tab,其中彩信签名显示待开发空态,不展示演示数据。 +- 短信签名列表使用真实 `GET /api/admin/enterprise-signatures`,返回企业、应用、材料和 `drainageInfo`。 +- 已补运营端 `POST /api/admin/enterprise-signatures`、`PUT /api/admin/enterprise-signatures/{id}`。 +- 添加/编辑签名可选择真实企业和应用,三网报备状态保存到 `SmsSignature.drainageInfo.carrierStatus`。 +- 引流信息列表、添加、编辑、删除保存到 `SmsSignature.drainageInfo.links`。 +- 删除签名调用真实状态变更接口写 `auditStatus=deleted`,列表默认排除 deleted。 + +需要恢复/补齐: + +- 已恢复短信签名的卡片列表、报备详情和引流信息区块。 +- 已补运营端代企业新增/编辑签名 API。 +- 已提供企业选择、应用选择、用途和三网状态真实保存。 +- 已将引流信息真实保存到 `drainageInfo`,不再使用本地数组。 +- 彩信签名能力继续标记待开发。 + +### 企业模板 + +相关文件: + +- `src/apps/admin/AdminEnterpriseTemplatesPage.tsx` +- `src/api/adminApi.ts` +- `api/src/sms-config/admin-sms-config.controller.ts` +- `api/src/sms-config/client-sms-config.controller.ts` +- `api/src/sms-config/sms-config.service.ts` +- `api/prisma/schema.prisma` + +状态:阶段 4-4 已修复短信模板新增/编辑/删除和变量真实保存闭环;彩信模板仍为待开发边界。 + +原型能力: + +- 短信/彩信模板 Tab。 +- 添加模板、编辑模板、删除模板。 +- 应用选择。 +- 签名选择。 +- 模板内容编辑。 +- 推荐变量与自定义变量。 +- 自动提取变量。 +- 字数与计费条数提示。 +- 彩信模板多帧编辑和预览。 + +当前实现: + +- 运营端模板页恢复短信/彩信 Tab,其中彩信模板显示待开发空态,不展示演示数据。 +- 短信模板列表使用真实 `GET /api/admin/enterprise-templates`,返回企业、应用、签名和变量。 +- 已补运营端 `POST /api/admin/enterprise-templates`、`PUT /api/admin/enterprise-templates/{id}`。 +- 添加/编辑模板可选择真实企业、应用、签名,模板内容和分类写入真实 `SmsTemplate`。 +- 变量从 `${name}` 语法自动提取,也支持推荐变量和自定义变量插入,保存时写入真实 `TemplateVariable`。 +- 编辑模板时后端事务删除旧变量并重建新变量。 +- 删除模板调用真实状态变更接口写 `auditStatus=deleted`,列表默认排除 deleted。 + +需要恢复/补齐: + +- 已恢复短信模板新增/编辑弹窗和变量交互。 +- 已补运营端代企业新增/编辑模板 API。 +- 应用和签名下拉来自真实 API,并按企业过滤。 +- 变量已写入真实 `TemplateVariable`,不能只在前端展示。 +- 彩信模板能力继续标记待开发。 + +## 后续执行顺序 + +1. 修企业删除接口健壮性。 +2. 恢复企业管理原型表单,并补真实企业档案保存。 +3. 修企业应用新增/编辑,移除硬编码 `2763`。(阶段 4-2 已完成) +4. 恢复企业签名与引流信息,并补真实保存接口。(阶段 4-3 已完成) +5. 恢复企业模板新增/编辑,并补变量真实保存。(阶段 4-4 已完成) +6. 补 API 单测和真实环境 smoke。 +7. 跑 `npm --prefix api test`、`npm --prefix api run build`、`npm run build`。 + +## 验收口径 + +页面验收时需要同时满足: + +- 页面结构、字段、按钮和弹窗接近 `131f344a^` 的原型。 +- 所有非彩信短信能力调用真实 API。 +- 清空业务数据后,页面应为空态或可从 0 创建数据,不出现原型残留记录。 +- 不存在硬编码企业 ID、`initial*` 短信业务数组、mock/localStorage 业务兜底。 +- API 失败时展示错误态,不静默回退到假数据。 diff --git a/docs/all-menu-dialog-real-backend-audit.md b/docs/all-menu-dialog-real-backend-audit.md new file mode 100644 index 0000000..ebaf16b --- /dev/null +++ b/docs/all-menu-dialog-real-backend-audit.md @@ -0,0 +1,405 @@ +# 全菜单与弹窗真实后端审计 + +## 审计范围 + +本次范围扩大到客户端、运营端所有已挂路由页面,以及页面中的新增、编辑、删除、提交、导入、上传、详情、预览等主要弹窗/动作。 + +审计依据: + +- `src/routes/AppRoutes.tsx` +- `src/apps/admin/*.tsx` +- `src/apps/client/*.tsx` +- `src/api/adminApi.ts` +- `docs/first-version-development-requirements.md` +- `docs/testing-plan.md` +- `docs/admin-prototype-recovery-scope.md` + +审计口径: + +1. 第一版短信功能必须走真实 NestJS API + Prisma/PostgreSQL。 +2. mock/localStorage/static 数组不能作为系统功能验收通过依据。 +3. API 失败只能展示错误态或空态,不能静默回退假数据。 +4. 彩信能力当前是待开发边界,但如果菜单仍可进入,需要明确标记或从第一版验收菜单中移除。 + +## P0:短信第一版必须修复 + +### 客户端短信应用列表仍是静态数据 + +文件:`src/apps/client/ClientApplicationsPage.tsx` + +状态:已修复。 + +问题: + +- 旧实现没有调用 `clientApi.listApplications()`。 +- 旧实现的 `applications` 是前端静态数组。 +- 旧实现的“接口参数”弹窗展示假企业代码、账号、密码、网关 IP、端口、接入号、绑定 IP。 + +影响: + +- 清空业务数据后页面仍会显示假应用。 +- 客户端看到的 CMPP 对接参数不可信。 + +已完成修复: + +- 页面接入真实 `GET /api/client/applications`。 +- 新增 `GET /api/client/applications/{id}/cmpp-params`,参数弹窗按当前租户读取真实参数。 +- 服务层按 `tenantId` 校验应用归属,跨租户应用不可见。 +- 空库时显示空态,不再渲染假应用。 + +### 客户端企业认证是纯前端状态机 + +文件:`src/apps/client/ClientEnterpriseAuthPage.tsx` + +问题: + +- 页面没有 API 调用。 +- 企业信息、认证状态、提交时间、法人信息、二维码、人脸/打款认证结果全是前端状态。 +- 点击提交只改本地 `status` 和 `step`。 + +影响: + +- 客户端提交企业认证后,运营端无法看到真实待审记录。 +- 发送前认证校验依赖真实 `Tenant.certificationStatus`,该页面不会改变真实状态。 + +修复: + +- 接入企业认证查询、提交、重新提交接口。 +- 上传营业执照/材料必须走真实文件对象和对象存储。 +- 提交后写 `EnterpriseCertification`,运营端审核后同步 `Tenant.certificationStatus`。 + +### 客户端短信发送详情仍是静态表 + +文件:`src/apps/client/ClientSendDetailPage.tsx` + +状态:已修复。 + +问题: + +- `sendDetailRows` 是静态数组。 +- 查询条件只过滤本地数组。 +- 发送记录、回执状态、运营商、省份均不是数据库记录。 + +影响: + +- 发送链路真实跑通后,客户端详情页看不到真实短信。 +- 清空数据后仍会显示假记录。 + +修复: + +- 已接入 `GET /api/client/operations/messages`。 +- 查询按当前租户隔离,支持应用、手机号、状态过滤。 +- 页面展示真实 `SmsMessageRecord`、回执记录、通道运营商和发送地区;空库时显示空态。 + +### 客户端短信上行记录仍是静态表 + +文件:`src/apps/client/ClientUplinkMessagesPage.tsx` + +状态:已修复。 + +问题: + +- `uplinkMessages` 和 `matchedSendRecords` 是静态数组。 +- “添加到应用黑名单”按钮没有 API 动作。 + +影响: + +- Gateway/真实 API 写入的上行短信无法在客户端展示。 +- 上行匹配下发记录的业务闭环不存在。 + +修复: + +- 已接入 `GET /api/client/operations/uplink-messages`。 +- 详情弹窗按上行 `messageId` 查询 `GET /api/client/operations/messages`,展示真实匹配的下发记录。 +- 原无 API 支撑的“添加到应用黑名单”按钮已移除,后续补真实企业黑名单或应用黑名单 API 后再恢复。 + +### 运营端短信上行记录仍是静态表 + +文件:`src/apps/admin/AdminSmsUplinkRecordsPage.tsx` + +状态:已修复。 + +问题: + +- `uplinkMessages`、`matchedRecord` 是静态数组。 +- 查询、详情、匹配下发记录均在前端完成。 +- “添加到应用黑名单”按钮没有 API 动作。 + +影响: + +- 运营端看不到真实上行记录。 +- 上行匹配和人工处理无法验收。 + +修复: + +- 已接入 `GET /api/admin/operations/uplink-messages`,返回真实上行记录、企业和通道信息。 +- 详情弹窗按上行 `messageId` 查询 `GET /api/admin/operations/messages`,展示真实匹配的下发记录。 +- 原无 API 支撑的“添加到应用黑名单”按钮已移除,后续补真实企业黑名单或应用黑名单 API 后再恢复。 + +### 运营端短信任务进度仍是静态任务 + +文件:`src/apps/admin/AdminSmsTaskProgressPage.tsx` + +状态:已修复。 + +问题: + +- `taskData` 是静态数组。 +- 运营商统计、城市统计、任务详情均来自前端。 +- 终止任务只更新本地状态。 + +影响: + +- 真实批量任务、发送进度、分运营商/地区统计无法验收。 + +修复: + +- 已接入 `GET /api/admin/send/batch-tasks`,任务列表和详情均来自真实 `SmsBatchTask` 与关联短信记录。 +- 详情弹窗使用真实短信记录聚合通道运营商与发送地区分布;当前数据库未持久化号码归属城市,因此不再展示静态城市分布。 +- 已新增 `POST /api/admin/send/batch-tasks/{id}/terminate`,运营终止只取消未提交的 queued/scheduled/ready 消息,已提交部分继续等待回执。 + +### 运营端通道组表单没有真实保存 + +文件:`src/apps/admin/AdminChannelGroupFormPage.tsx` + +状态:已修复。 + +问题: + +- `channelOptions`、`defaultProvinceRoutes`、`defaultNationalRoutes` 是静态数据。 +- 新增/编辑省网、全国通道只改本地 state。 +- 底部“确认”只 `navigate('/admin/channel-groups')`,没有调用 API。 +- 与当前重要规则冲突:省份通道选择、全国优先级唯一、通道组 item carrier、通道 sendRegion 校验都无法在该页面落库。 + +影响: + +- 页面看似可配置通道组,但实际不会保存到真实通道组 item。 +- 从 0 测试时无法通过该表单配置发送路由。 + +修复: + +- 已接入真实通道列表和通道组列表,编辑时加载真实通道组 items。 +- 已新增 `PUT /api/admin/channel-groups/{id}`,保存时事务更新通道组并重建省网/全国 items。 +- 后端校验省份唯一、全国优先级唯一、通道不重复、item carrier 与通道组 carrier 一致、省网通道地区一致。 + +### 运营端企业管理专项缺口已阶段性关闭 + +文件: + +- `docs/admin-prototype-recovery-scope.md` +- `src/apps/admin/AdminCustomerFormPage.tsx` +- `src/apps/admin/AdminEnterpriseApplicationsPage.tsx` +- `src/apps/admin/AdminSmsApplicationFormPage.tsx` +- `src/apps/admin/AdminEnterpriseSignaturesPage.tsx` +- `src/apps/admin/AdminEnterpriseTemplatesPage.tsx` + +问题摘要: + +- 企业表单曾从原型完整档案瘦身为租户三字段,阶段 4-1 已恢复真实档案保存。 +- 企业删除不存在/过期 ID 曾返回 500,阶段 4-1 已修复为 404。 +- 企业应用新增/编辑曾硬编码 `2763`,阶段 4-2 已改为真实企业选择和应用自身 `tenantId`。 +- 短信应用编辑接口曾待补,阶段 4-2 已补运营端 `GET/POST/PUT /api/admin/enterprise-applications` 和应用路由替换接口。 +- 彩信应用当前明确标记待开发,不展示演示数组。 +- 企业签名与引流信息曾只剩只读列表,阶段 4-3 已恢复短信签名新增/编辑/删除和引流信息维护,真实保存到 `SmsSignature.drainageInfo`。 +- 企业模板曾只剩只读列表,阶段 4-4 已恢复短信模板新增/编辑/删除和变量维护,真实保存到 `SmsTemplate` 与 `TemplateVariable`。 +- 彩信应用、彩信签名、彩信模板当前明确标记待开发,不展示演示数组。 + +修复: + +- 企业管理、企业应用、企业签名与引流信息、企业模板已按 `docs/admin-prototype-recovery-scope.md` 阶段 4-1 至 4-4 修复。 +- 后续继续按审计清单处理其他 P0/P1 页面。 + +### 运营端短信通道编辑接口待补 + +文件:`src/apps/admin/AdminChannelsPage.tsx` + +状态:阶段 5 已修复。 + +问题: + +- 创建、复制、启停、删除、链接日志已经接真实 API。 +- 旧实现编辑通道时直接提示“短信通道编辑接口待补,当前不做本地模拟保存”。 + +影响: + +- 旧实现中通道创建后无法真实编辑网关、账号、接入号、地区、运营商等关键配置。 + +修复: + +- 已补 `PUT /api/admin/channels/:id`。 +- 编辑弹窗提交真实更新,密码留空时不覆盖原密码,填写时更新 `passwordCipher`。 +- 后端继续校验 `gatewayPort` 范围,并写 `sms_channel.update` 操作日志。 +- 已补通道更新单元测试,并重新通过通道定向测试、API 全量测试、前后端构建。 + +## P1:真实接口已接入但能力不完整 + +### 客户端短信发送导入表格只是展示入口 + +文件:`src/apps/client/ClientSendPage.tsx` + +状态:阶段 6 已修复 CSV/TSV/TXT 文本导入真实预览和确认发送;XLSX 二进制解析后续单列能力补齐。 + +问题: + +- 手动输入号码可调用真实 `clientApi.createBatchTask()`。 +- 旧实现“导入表格”明确写着“当前原型仅展示上传入口”。 +- 旧实现没有真实文件解析、预览、确认导入。 + +修复: + +- 已接入真实 `POST /api/client/send/imports/preview` 和 `POST /api/client/send/imports/confirm`。 +- 客户端读取 CSV/TSV/TXT 文件文本后交由后端预览,后端校验手机号格式、重复号码、黑名单和模板变量列。 +- 页面展示总行数、有效号码、错误行和前 5 条错误明细。 +- 确认发送走真实导入确认接口,并复用批量任务创建链路。 +- 当前后端接口是文本解析,XLSX 二进制解析未标记完成,后续如验收 XLSX 需补解析库和对应测试。 + +### 客户端签名材料只记录文件元数据 + +文件:`src/apps/client/ClientSignaturesPage.tsx` + +状态:阶段 7 已修复。 + +问题: + +- 签名创建、材料记录、提交审核调用真实 API。 +- 旧实现文件本体没有真正上传到 MinIO/对象存储,只创建 `FileObject` 元数据。 + +修复: + +- 已新增 `POST /api/admin/files/upload` multipart 上传接口。 +- 服务端先把文件内容写入 MinIO 对象存储,再创建 `FileObject` 元数据。 +- 客户端签名页上传材料时先调用真实上传接口,成功后再创建签名材料关联。 +- 如果对象存储不可用,上传会失败并阻止材料元数据冒充成功。 + +### 客户端短信模板功能比原型瘦身 + +文件:`src/apps/client/ClientTemplatesPage.tsx` + +状态:阶段 8 已修复。 + +问题: + +- 列表、新增、提交审核调用真实 API。 +- 旧实现缺少编辑模板、签名选择、变量推荐/自定义变量面板等原型交互。 +- 旧实现删除实际是改状态为 `disabled`,页面文案需要匹配。 + +修复: + +- 已恢复模板新增/编辑弹窗、推荐变量、自定义变量、变量示例值维护。 +- 已补 `PUT /api/client/templates/{id}`,编辑时真实更新 `SmsTemplate` 并重建 `TemplateVariable`。 +- 模板可绑定签名,签名下拉按应用所属企业和签名绑定应用过滤。 +- 删除仍按第一版现有语义写 `disabled`,页面保留删除入口并从列表过滤 disabled。 + +### 运营端报备回执导入不是真文件导入 + +文件:`src/apps/admin/AdminReportTasksPage.tsx` + +状态:阶段 9 已修复 CSV/TSV/TXT 文本回执真实上传和解析。 + +问题: + +- 报备任务列表和导入动作接了 API。 +- 弹窗只输入 `fileName` 和备注,没有真实文件上传/解析。 +- `statusAfter` 固定为 `partial`。 + +已完成修复: + +- 前端导入弹窗改为真实选择文件,先上传到 `/api/admin/files/upload`,再提交回执内容到报备导入接口。 +- 后端解析 CSV/TSV/TXT 文本回执,按状态/结果列统计成功、失败行,并派生 `completed`、`partial`、`failed` 任务状态。 +- 导入记录保存真实 `fileObjectId`、文件名、行数、成功数、失败数和行级解析结果。 +- 未识别状态按失败处理,避免把未知运营商回执误判为报备通过。 + +### 系统配置与账号设置没有保存接口 + +状态:本轮已从第一版路由移除。 + +文件: + +- `src/apps/admin/AdminSettingsPage.tsx` +- `src/apps/client/ClientSettingsPage.tsx` + +问题: + +- 运营端系统配置没有 API。 +- 客户端账号设置明确写着“纯前端原型,不会提交到后端”。 + +已完成修复: + +- 运营端系统配置、客户端账号设置当前无真实保存接口,且不属于第一版短信主链路验收入口。 +- 已移除对应路由并删除纯前端页面文件,避免直达 URL 展示假功能。 + +## P2:彩信待开发边界,需要菜单策略 + +以下页面以彩信为主,当前大量使用静态数组、本地 state 或无 API。第一版若不验收彩信,建议从菜单隐藏或明确标记“待开发”,避免与短信真实功能混淆。 + +状态:本轮已将可点击彩信待开发路由统一指向占位页,不再进入静态 mock 演示页。 + +客户端: + +- `src/apps/client/ClientMmsSignatureReportPage.tsx` +- `src/apps/client/ClientMmsTemplatesPage.tsx` +- `src/apps/client/ClientMmsSendPage.tsx` +- `src/apps/client/ClientMmsBatchTasksPage.tsx` +- `src/apps/client/ClientMmsSendDetailPage.tsx` +- `src/apps/client/ClientMmsUplinkMessagesPage.tsx` + +运营端: + +- `src/apps/admin/AdminMmsApplicationFormPage.tsx` +- `src/apps/admin/AdminMmsChannelsPage.tsx` +- `src/apps/admin/AdminMmsRecordsPage.tsx` +- `src/apps/admin/AdminMmsTaskProgressPage.tsx` +- `src/apps/admin/AdminSignatureAuditPage.tsx` + +说明: + +- `AdminSignatureAuditPage.tsx` 当前实际是“彩信模板审核”,使用 `initialMmsAudits`,通过/拒绝只改本地 state。 +- 如果菜单名称仍显示为审核中心能力,需要调整命名或隐藏,避免被误认为短信签名审核真实闭环。 + +## 已基本接入真实 API 的页面 + +以下页面未发现明显静态业务主数据兜底,仍需后续做浏览器 smoke 和真实空库验证: + +客户端: + +- `ClientHome` +- `ClientBatchTasksPage` +- `ClientBillingPage` +- `ClientInvoicesPage` +- `ClientUsersPage` +- `ClientSystemLogsPage` + +运营端: + +- `AdminHome` +- `AdminMonitorPage` +- `AdminAnalyticsPage` +- `AdminRechargeRecordsPage` +- `AdminBillingPage` +- `AdminUsersPage` +- `AdminSystemLogsPage` +- `AdminSmsAuditPage` +- `AdminTemplateAuditPage` +- `AdminEnterpriseAuditPage` +- `AdminReportRecordsPage` +- `AdminChannelReportPage` +- `AdminSensitiveWordsPage` +- `AdminGlobalBlacklistPage` +- `AdminEnterpriseBlacklistPage` +- `AdminPhoneSegmentsPage` +- `AdminDrainageFieldsPage` + +注意: + +- “基本接入”不等于业务验收通过;仍要用真实空库和真实 API 操作验证按钮、弹窗和错误态。 +- 搜索表达式会对 option 常量、纯 UI 状态产生少量误报,最终修复应以源码和真实页面操作为准。 + +## 建议修复顺序 + +1. 先修 P0 短信主链路展示页:客户端应用、发送详情、上行;运营端短信任务进度、短信上行。 +2. 修通道组表单真实保存,因为它直接影响发送路由配置。 +3. 按企业专项恢复企业管理、企业应用、签名引流、模板。 +4. 补短信通道编辑接口。 +5. 补 P1 的导入、材料上传、模板编辑、回执导入和设置保存。 +6. 彩信页面统一隐藏、标记待开发或单独进入彩信开发计划。 +7. 每完成一组,执行真实空库 smoke,确认清库后不会显示假数据。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 1e3eb67..0a9f005 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -509,3 +509,95 @@ npm run verify:phase8 - `verify:phase8` 当前失败点是独立 BullMQ 性能阈值,不是本轮通道组、计费、报备、回执业务逻辑测试失败。 - 浏览器端完整手工回归仍建议补跑企业应用创建、通道组配置、短信记录详情弹窗中的历史回执展示。 + +## 2026-07-03 阶段 9:运营端报备回执导入真实上传/解析 + +### 本轮修复 + +- 运营端报备任务导入弹窗改为真实选择 CSV/TSV/TXT 文件。 +- 前端先调用 `/api/admin/files/upload` 保存文件对象,再提交 `fileObjectId`、文件名和文本内容到 `/api/admin/report-tasks/{id}/receipt-import`。 +- 后端导入接口解析文本回执,识别 `status/result/状态/结果` 列,统计成功行、失败行,并保存行级解析结果。 +- 报备任务状态由后端按解析结果派生:全成功为 `completed`,有成功有失败为 `partial`,全失败或空文件为 `failed`。 +- 未识别的运营商状态按失败处理,避免把未知回执误判为通过。 + +### 已执行命令 + +```bash +npm --prefix api test -- channels.service.spec.ts +npm --prefix api test +npm --prefix api run build +npm run build +git diff --check +``` + +### 当前结果 + +- `api/src/channels/channels.service.spec.ts` 新增文本回执解析和任务状态派生覆盖。 +- API Jest:12 个 test suite 通过,73 个测试通过。 +- API build 通过。 +- 前端 build 通过,仍存在既有 Vite chunk size warning。 +- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。 +- 本地服务已重启:`http://localhost:3000/` 与 `http://localhost:5173/` 均监听,`/api/admin/report-tasks` 和 `/admin/report-tasks` HTTP smoke 返回 200。 + +## 2026-07-03 全菜单真实后端、上传和列宽回归 + +### 本轮修复 + +- 客户端企业认证从纯前端状态机改为真实 `GET/POST /api/client/enterprise-certification` 驱动。 +- 客户端企业认证营业执照上传接入 `/api/admin/files/upload`,提交时保存 `licenseFileObjectId` 等材料字段。 +- 运营端企业表单“企业照片”从禁用占位按钮改为真实上传,保存时写入 `photoFileObjectId`。 +- 客户端账号设置、运营端系统配置无真实保存接口,已移除路由并删除纯前端页面。 +- 彩信待开发菜单路由统一指向占位页,不再进入静态 mock 演示页面。 +- 客户端短信发送详情、批量任务表格中明显偏窄的中文字段列已加宽。 + +### 已执行命令 + +```bash +npm --prefix api test +npm --prefix api run build +npm run build +npm run spike:contracts +npm run test:gateway +$env:API_BASE_URL='http://127.0.0.1:3000/api'; node tools/smoke/real-env-smoke.mjs +npm run verify:phase8 +git diff --check +``` + +### 当前结果 + +- API Jest:12 个 test suite 通过,73 个测试通过。 +- API build 通过。 +- 前端 build 通过,仍存在既有 Vite chunk size warning。 +- Gateway 队列契约通过,4 个示例均验证通过。 +- `npm run test:gateway` 通过。 +- 真实 API smoke 通过,覆盖真实 PostgreSQL/Redis/MinIO/API 主链路和文件上传对象写入。 +- 浏览器抽检通过:客户端真实登录后,企业认证页面无“纯前端原型”文案,资料页出现真实上传入口;彩信待开发入口显示占位页而非静态表单。 +- `npm run verify:phase8` 仍未通过,失败点仍是已知 BullMQ spike 性能阈值:15000 条消息、并发 500、end-to-end TPS 469.76,低于 500。 + +## 2026-07-03 企业列表列宽和新建应用交互回归 + +### 本轮修复 + +- 通用 `Table` 组件增加 `colgroup`、列最小宽度和表格最小宽度计算,显式配置的业务列不再被容器强行压窄,超出区域横向滚动。 +- 企业管理列表加宽企业 ID、企业名称、企业编码、统一社会信用代码、联系人、联系电话、余额、短信余量、状态和操作列。 +- 企业模板管理列表加宽企业、应用、签名、模板内容、审核状态、更新时间和操作列,模板内容列保留两行展示。 +- 运营端短信任务进度、短信审核、短信记录、报备任务、用户、系统日志、安全控制、充值记录等列表中的状态/操作/数量等易挤压列统一加宽。 +- 新建企业应用入口弹窗改为先选择真实企业,再进入应用参数、客户单价、IP 白名单和三网通道组配置;未选择企业时“下一步”禁用。 +- 新建短信应用表单把移动、联通、电信通道组配置改为独立卡片区,显示已配置数量和无可用通道组提示;未填写应用名称或未选择任一运营商通道组时禁止保存。 +- 补齐基础弹窗居中、遮罩、最大宽度和正文滚动样式,避免 1280px 视口下弹窗偏移或被截断。 + +### 已执行命令和浏览器验证 + +```bash +npm run build +git diff --check +``` + +- 前端 build 通过,仍存在既有 Vite chunk size warning。 +- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。 +- 窄列扫描仅剩短字段列:报备字段“必填”90px、运营看板排名72px、短信上行选择框72px。 +- 浏览器使用真实运营端登录 `admin@example.com` 抽检通过: + - 企业管理表格最小宽度 1920px,统一社会信用代码列 220px,联系人列 160px,联系电话列 150px,横向滚动生效。 + - 企业模板管理表格最小宽度 1820px,模板内容列 420px,横向滚动生效。 + - 新建企业应用弹窗在 1280px 视口下未截断,未选择企业时“下一步”禁用。 + - 新建短信应用页显示三网通道组卡片、已配置数量和无可用通道组提示,初始状态“创建应用”禁用。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 807f1c8..36600c9 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -89,6 +89,17 @@ export type TenantOption = { name: string; code: string; status: string; + enterpriseProfile?: { + creditCode?: string; + province?: string; + city?: string; + address?: string; + contactName?: string; + contactIdCard?: string; + contactPhone?: string; + contactEmail?: string; + photoFileObjectId?: string; + } | null; }; export type CaptchaResponse = { @@ -184,6 +195,13 @@ export type ClientSmsApplication = { scene?: string | null; customerUnitPrice?: number | null; status: string; + dailyLimit?: number | null; + createdAt?: string; + updatedAt?: string; + sentToday?: number; + deliveryRate?: number; + cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive'; + cmppConnections?: CmppConnectionState[]; }; export type ClientSmsSignature = { @@ -192,11 +210,14 @@ export type ClientSmsSignature = { applicationId?: string | null; name: string; purpose?: string | null; + drainageInfo?: Record | null; auditStatus: string; rejectReason?: string | null; createdAt: string; updatedAt: string; materials?: Array>; + tenant?: TenantOption; + application?: ClientSmsApplication | null; }; export type ClientSmsTemplate = { @@ -214,6 +235,7 @@ export type ClientSmsTemplate = { variables?: Array<{ name: string; example?: string | null; required?: boolean }>; application?: { id: string; name: string }; signature?: { id: string; name: string }; + tenant?: TenantOption; }; export type SmsBatchTask = { @@ -230,12 +252,31 @@ export type SmsBatchTask = { reviewReason?: string | null; rejectReason?: string | null; progressTotal: number; - progressSent: number; - progressDelivered: number; - progressFailed: number; + progressSent?: number; + progressDelivered?: number; + progressFailed?: number; + submittedTotal?: number; + successTotal?: number; + failedTotal?: number; + unknownTotal?: number; + timeoutTotal?: number; scheduledAt?: string | null; + canceledAt?: string | null; createdAt: string; + tenant?: TenantOption; application?: { id: string; name: string }; + template?: { id: string; name: string; content: string; billingUnits?: number }; + messages?: SmsMessageRecord[]; +}; + +export type ImportPreviewResponse = { + fileName?: string; + encoding: string; + totalRows: number; + validCount: number; + errorCount: number; + phones: string[]; + errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }>; }; export type SmsMessageRecord = { @@ -252,7 +293,30 @@ export type SmsMessageRecord = { status: string; errorMessage?: string | null; queuedAt: string; + submittedAt?: string | null; + deliveredAt?: string | null; + receiptStatus?: string | null; + submitStatus?: string | null; + channel?: AdminChannel | null; + tenant?: TenantOption | null; application?: { id: string; name: string }; + submitRecords?: Array>; + receiptRecords?: Array>; +}; + +export type SmsUplinkMessage = { + id: string; + tenantId?: string | null; + channelId: string; + messageId?: string | null; + sequenceId?: number | null; + phoneNumber: string; + destId: string; + content: string; + receivedAt: string; + createdAt: string; + tenant?: TenantOption | null; + channel?: AdminChannel | null; }; export type DictionaryItem = Record & { @@ -269,7 +333,19 @@ export type ChannelGroup = DictionaryItem & { description?: string | null; retryEnabled?: boolean; retryTimeLimitHours?: number; - items?: Array>; + items?: ChannelGroupItem[]; +}; + +export type ChannelGroupItem = DictionaryItem & { + groupId: string; + channelId: string; + carrier?: 'mobile' | 'unicom' | 'telecom' | null; + province?: string | null; + priority: number; + weight?: number; + isBackup?: boolean; + rateLimitPerSecond?: number | null; + channel?: AdminChannel; }; export type ChannelReportField = DictionaryItem & { @@ -374,6 +450,10 @@ export type EnterpriseApplication = { scene?: string | null; status: string; dailyLimit?: number | null; + customerUnitPrice?: number | null; + maxPhonesPerTask?: number | null; + templateMismatchMode?: string | null; + ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>; tenant?: TenantOption; sentToday?: number; deliveryRate?: number; @@ -438,9 +518,9 @@ export const adminApi = { request('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }), listTenants: () => request('/admin/tenants'), getTenant: (id: string) => request(`/admin/tenants/${id}`), - createTenant: (body: { name: string; code: string; status?: string }) => + createTenant: (body: { name: string; code: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) => request('/admin/tenants', { method: 'POST', body: JSON.stringify(body) }), - updateTenant: (id: string, body: { name?: string; code?: string; status?: string }) => + updateTenant: (id: string, body: { name?: string; code?: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) => request(`/admin/tenants/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeTenantStatus: (id: string, status: string) => request(`/admin/tenants/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }), @@ -463,8 +543,12 @@ 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)), + getEnterpriseApplication: (id: string) => + request(`/admin/enterprise-applications/${id}`), 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) }), + request('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }), + updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) => + request(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeApplicationStatus: (id: string, status: string, reason?: string) => request(`/admin/enterprise-applications/${id}/status`, { method: 'POST', @@ -482,6 +566,8 @@ export const adminApi = { listChannels: () => request('/admin/channels'), createChannel: (body: Partial & { passwordCipher?: string }) => request('/admin/channels', { method: 'POST', body: JSON.stringify(body) }), + updateChannel: (id: string, body: Partial & { passwordCipher?: string }) => + request(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }), copyChannel: (id: string, body: { operatorId?: string } = {}) => request(`/admin/channels/${id}/copy`, { method: 'POST', body: JSON.stringify(body), @@ -509,8 +595,20 @@ export const adminApi = { }), listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string } = {}) => request(withQuery('/admin/enterprise-signatures', query)), + createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }) => + request('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }), + updateEnterpriseSignature: (id: string, body: { applicationId?: string | null; name?: string; purpose?: string; auditStatus?: string; drainageInfo?: Record }) => + request(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) => + request(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string } = {}) => request(withQuery('/admin/enterprise-templates', query)), + createEnterpriseTemplate: (body: { tenantId: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) => + request('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }), + updateEnterpriseTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) => + request(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + changeEnterpriseTemplateStatus: (id: string, status: string, reason?: string) => + request(`/admin/enterprise-templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }), listEnterpriseCertifications: (query: { keyword?: string; status?: string }) => { const params = new URLSearchParams(); if (query.keyword) params.set('keyword', query.keyword); @@ -530,10 +628,15 @@ export const adminApi = { listChannelGroups: () => request('/admin/channel-groups'), 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) }), + updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; items?: Array> }) => + request(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }), addChannelGroupItem: (body: Record) => request('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }), + listChannelRouteRules: () => request('/admin/channel-route-rules'), createChannelRouteRule: (body: { tenantId?: string; applicationId: string; groupId: string; carrier: string; priority?: number; status?: string }) => request('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }), + replaceApplicationRouteRules: (applicationId: string, body: { routes: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; groupId: string; priority?: number; status?: string }> }) => + request(`/admin/enterprise-applications/${applicationId}/route-rules`, { method: 'PUT', body: JSON.stringify(body) }), listChannelReportFields: (channelId?: string) => request(withQuery('/admin/channel-report-fields', { channelId })), createChannelReportField: (body: Record) => request('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }), @@ -542,11 +645,19 @@ export const adminApi = { request('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }), createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) => request>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }), - importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record }) => + importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; fileContent?: string; delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record }) => request>(`/admin/report-tasks/${id}/receipt-import`, { method: 'POST', body: JSON.stringify(body) }), listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request(withQuery('/admin/report-records', query)), + listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) => + request(withQuery('/admin/send/batch-tasks', query)), + terminateAdminBatchTask: (id: string) => + request(`/admin/send/batch-tasks/${id}/terminate`, { method: 'POST', body: JSON.stringify({}) }), listAdminMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; phoneNumber?: string; status?: string } = {}) => request(withQuery('/admin/send/messages', query)), + listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; messageId?: string; phoneNumber?: string; status?: string } = {}) => + request(withQuery('/admin/operations/messages', query)), + listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) => + request(withQuery('/admin/operations/uplink-messages', query)), listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request>(withQuery('/admin/operations/monitor', query)), listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request>>(withQuery('/admin/operations/statistics', query)), listRiskReviewTasks: (query: { tenantId?: string; status?: string } = {}) => request(withQuery('/admin/risk-review/tasks', query)), @@ -575,6 +686,27 @@ export const adminApi = { listDrainageFields: () => request('/admin/dictionaries/drainage-fields'), createDrainageField: (body: { code: string; name: string; fieldType: string; required?: boolean; status?: string; description?: string }) => request('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }), + uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => { + const form = new FormData(); + form.set('file', file); + form.set('purpose', body.purpose); + if (body.prefix) { + form.set('prefix', body.prefix); + } + const headers = new Headers(); + const session = readSession(); + if (session?.accessToken) { + headers.set('Authorization', `${session.tokenType} ${session.accessToken}`); + } + if (tenantId) { + headers.set('x-tenant-id', tenantId); + } + const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form }); + if (!response.ok) { + throw new Error(await response.text()); + } + return response.json() as Promise; + }, }; export const clientApi = { @@ -595,6 +727,14 @@ export const clientApi = { request(`/client/users/${id}/password`, { method: 'POST', tenantId, body: JSON.stringify({ password, operatorId }) }), getDashboard: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/operations/dashboard', { tenantId }), + listEnterpriseCertifications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/enterprise-certification', { tenantId }), + submitEnterpriseCertification: (body: { companyName: string; licenseNo?: string; contactName?: string; contactPhone?: string; materials?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/enterprise-certification', { + method: 'POST', + tenantId, + body: JSON.stringify({ ...body, tenantId }), + }), listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(withQuery('/client/operations/system-logs', query), { tenantId }), listTransactions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => @@ -607,6 +747,8 @@ export const clientApi = { request('/client/billing/orders', { method: 'POST', tenantId, body: JSON.stringify(body) }), listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/applications', { tenantId }), + getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/applications/${applicationId}/cmpp-params`, { tenantId }), listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/signatures', { tenantId }), createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => @@ -621,6 +763,8 @@ export const clientApi = { request(withQuery('/client/templates', query), { tenantId }), createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/templates', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), + updateTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/templates/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), submitTemplate: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }), changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => @@ -631,8 +775,37 @@ export const clientApi = { request(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', tenantId, body: JSON.stringify({}) }), createBatchTask: (body: { applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; variables?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/send/batch-tasks', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), + previewImport: (body: { content: string; fileName?: string; delimiter?: ',' | '\t'; requiredVariables?: string[] }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/send/imports/preview', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), + confirmImport: (body: { applicationId?: string; templateId?: string; content: string; category?: string; importContent: string; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; requiredVariables?: string[]; variables?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), listBatchTaskMessages: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/send/batch-tasks/${id}/messages`, { tenantId }), + listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(withQuery('/client/operations/messages', query), { tenantId }), + listUplinkMessages: (query: { channelId?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(withQuery('/client/operations/uplink-messages', query), { tenantId }), createFileObject: (body: { bucket?: string; objectKey: string; fileName: string; contentType: string; sizeBytes: number; purpose: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/admin/files', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId, bucket: body.bucket ?? 'cmpp-platform' }) }), + uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => { + const form = new FormData(); + form.set('file', file); + form.set('purpose', body.purpose); + if (body.prefix) { + form.set('prefix', body.prefix); + } + const headers = new Headers(); + const session = readSession(); + if (session?.accessToken) { + headers.set('Authorization', `${session.tokenType} ${session.accessToken}`); + } + if (tenantId) { + headers.set('x-tenant-id', tenantId); + } + const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form }); + if (!response.ok) { + throw new Error(await response.text()); + } + return response.json() as Promise; + }, }; diff --git a/src/apps/admin/AdminChannelGroupFormPage.tsx b/src/apps/admin/AdminChannelGroupFormPage.tsx index 9e7ccaf..2e3fdef 100644 --- a/src/apps/admin/AdminChannelGroupFormPage.tsx +++ b/src/apps/admin/AdminChannelGroupFormPage.tsx @@ -1,6 +1,7 @@ -import { useMemo, useState } from 'react'; -import { Info, Plus, Trash2 } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { Info, Plus } from 'lucide-react'; import { useNavigate, useParams } from 'react-router-dom'; +import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi'; import { Breadcrumb, Button, Input, Modal, Select, Table, Tag } from '@/components/ui'; import type { TableColumn } from '@/components/ui'; @@ -9,13 +10,13 @@ type ChannelStatus = 'normal' | 'stopped'; type ProvinceRoute = { id: string; province: string; - channel: string; + channelId: string; status: ChannelStatus; }; type NationalRoute = { id: string; priority: number; - channel: string; + channelId: string; status: ChannelStatus; }; type RouteModalState = { @@ -33,14 +34,6 @@ const provinceOptions = [ { label: '广东', value: '广东' }, ]; -const channelOptions = [ - { label: '请选择', value: '' }, - { label: '行北-移动-山东有限公司-上海XXXXXXX-22j', value: '行北-移动-山东有限公司-上海XXXXXXX-22j' }, - { label: '行北-移动-河南有限公司-上海XXXX-22j', value: '行北-移动-河南有限公司-上海XXXX-22j' }, - { label: '三网行北-黄峰-三网-编号3.3', value: '三网行北-黄峰-三网-编号3.3' }, - { label: '移动映华北-上海富煌C60289-移动2.7', value: '移动映华北-上海富煌C60289-移动2.7' }, -]; - const priorityOptions = [ { label: '请选择', value: '' }, { label: '1', value: '1' }, @@ -66,26 +59,31 @@ const statusTones: Record = { stopped: 'neutral', }; -const defaultProvinceRoutes: ProvinceRoute[] = [ - { id: 'p-shandong', province: '山东', channel: '行北-移动-山东有限公司-上海XXXXXXX-22j', status: 'normal' }, - { id: 'p-henan', province: '河南', channel: '行北-移动-河南有限公司-上海XXXX-22j', status: 'stopped' }, -]; +function normalizeRegion(region?: string | null) { + return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim(); +} -const defaultNationalRoutes: NationalRoute[] = [ - { id: 'n-1', priority: 1, channel: '三网行北-黄峰-三网-编号3.3', status: 'normal' }, - { id: 'n-2', priority: 2, channel: '三网行北-黄峰(循环号用)-三网-编号3.4', status: 'normal' }, - { id: 'n-3', priority: 3, channel: '移动映华北-上海富煌C60289-移动2.7', status: 'stopped' }, -]; +function isCarrierCompatible(channelCarrier: string | null | undefined, carrier: Carrier) { + return !channelCarrier || channelCarrier === 'all' || channelCarrier === carrier; +} + +function getChannelStatus(channel?: AdminChannel): ChannelStatus { + return channel?.status === 'active' ? 'normal' : 'stopped'; +} function StatusTag({ status }: { status: ChannelStatus }) { return {statusLabels[status]}; } function RouteConfigModal({ + channels, + carrier, modal, onClose, onSubmit, }: { + channels: AdminChannel[]; + carrier: Carrier; modal: RouteModalState; onClose: () => void; onSubmit: (route: ProvinceRoute | NationalRoute) => void; @@ -94,24 +92,43 @@ function RouteConfigModal({ const nationalRoute = modal.type === 'national' ? modal.route as NationalRoute | undefined : undefined; const [province, setProvince] = useState(provinceRoute?.province ?? ''); const [priority, setPriority] = useState(nationalRoute ? String(nationalRoute.priority) : ''); - const [channel, setChannel] = useState(modal.route?.channel ?? ''); + const [channelId, setChannelId] = useState(modal.route?.channelId ?? ''); + + const selectableChannels = channels.filter((channel) => { + if (!isCarrierCompatible(channel.carrier, carrier)) return false; + if (modal.type === 'province' && province) { + return normalizeRegion(channel.sendRegion) === normalizeRegion(province); + } + return true; + }); + const channelOptions = [ + { label: '请选择', value: '' }, + ...selectableChannels.map((channel) => ({ + label: `${channel.name} / ${channel.carrier ?? '未标记'} / ${channel.sendRegion ?? '全国'}`, + value: channel.id, + })), + ]; function submit() { + if (!channelId) return; + const channel = channels.find((item) => item.id === channelId); if (modal.type === 'province') { + if (!province) return; onSubmit({ id: provinceRoute?.id ?? `p-${Date.now()}`, - province: province || '山东', - channel: channel || channelOptions[1].value, - status: provinceRoute?.status ?? 'normal', + province, + channelId, + status: getChannelStatus(channel), }); return; } + if (!priority) return; onSubmit({ id: nationalRoute?.id ?? `n-${Date.now()}`, - priority: Number(priority || 1), - channel: channel || channelOptions[1].value, - status: nationalRoute?.status ?? 'normal', + priority: Number(priority), + channelId, + status: getChannelStatus(channel), }); } @@ -129,7 +146,7 @@ function RouteConfigModal({ >
{modal.type === 'province' ? ( - { setProvince(event.target.value); setChannelId(''); }} options={provinceOptions} value={province} /> ) : ( <> setChannel(event.target.value)} options={channelOptions} value={channel} /> + setName(event.target.value)} placeholder="请填写企业全称" required value={name} /> - setCode(event.target.value)} placeholder="请填写唯一企业编码" required value={code} /> + +
+ 企业照片 + +

{form.photoFileObjectId ? `文件对象:${form.photoFileObjectId}` : '支持 JPG、PNG、WebP,上传后随企业档案保存。'}

+ +
+ updateForm('name', event.target.value)} placeholder="请填写企业全称" required value={form.name} /> + updateForm('code', event.target.value)} placeholder="请填写唯一企业编码" required value={form.code} /> +
+ updateForm('creditCode', event.target.value)} + placeholder="请填写统一社会信用代码或纳税识别号" + required + value={form.creditCode} + /> + +
+ updateForm('city', event.target.value)} options={cityOptions} value={form.city} /> +
+ +