diff --git a/api/prisma/migrations/20260727113000_persist_submit_channel_group/migration.sql b/api/prisma/migrations/20260727113000_persist_submit_channel_group/migration.sql new file mode 100644 index 0000000..e65b34c --- /dev/null +++ b/api/prisma/migrations/20260727113000_persist_submit_channel_group/migration.sql @@ -0,0 +1,37 @@ +ALTER TABLE "SmsSubmitRecord" +ADD COLUMN "channelGroupId" TEXT, +ADD COLUMN "channelGroupName" TEXT; + +ALTER TABLE "SmsSubmitRecord" +ADD CONSTRAINT "SmsSubmitRecord_channelGroupId_fkey" +FOREIGN KEY ("channelGroupId") REFERENCES "SmsChannelGroup"("id") +ON DELETE SET NULL ON UPDATE CASCADE; + +CREATE INDEX "SmsSubmitRecord_channelGroupId_idx" +ON "SmsSubmitRecord"("channelGroupId"); + +UPDATE "SmsSubmitRecord" submit +SET + "channelGroupId" = matched."groupId", + "channelGroupName" = matched."groupName" +FROM ( + SELECT DISTINCT ON (submit_record.id) + submit_record.id, + route."groupId", + channel_group.name AS "groupName" + FROM "SmsSubmitRecord" submit_record + JOIN "SmsMessageRecord" message ON message.id = submit_record."messageRecordId" + JOIN "ChannelRouteRule" route + ON route."tenantId" = message."tenantId" + AND route."applicationId" = message."applicationId" + AND route.carrier = message.carrier + AND route."channelId" IS NULL + AND route.province IS NULL + JOIN "SmsChannelGroupItem" group_item + ON group_item."groupId" = route."groupId" + AND group_item."channelId" = submit_record."channelId" + JOIN "SmsChannelGroup" channel_group ON channel_group.id = route."groupId" + WHERE route.status = 'active' + ORDER BY submit_record.id, route.priority ASC, route."createdAt" ASC +) matched +WHERE submit.id = matched.id; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 7f0d19f..8851487 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -881,8 +881,9 @@ model SmsChannelGroup { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - items SmsChannelGroupItem[] - routeRules ChannelRouteRule[] + items SmsChannelGroupItem[] + routeRules ChannelRouteRule[] + submitRecords SmsSubmitRecord[] } model SmsChannelGroupItem { @@ -1447,6 +1448,8 @@ model SmsSubmitRecord { batchTaskId String? messageRecordId String channelId String + channelGroupId String? + channelGroupName String? sessionId String? retryOfSubmitRecordId String? @unique submitId String @unique @@ -1465,6 +1468,7 @@ model SmsSubmitRecord { batchTask SmsBatchTask? @relation(fields: [batchTaskId], references: [id], onDelete: Cascade) messageRecord SmsMessageRecord @relation(fields: [messageRecordId], references: [id], onDelete: Cascade) channel SmsChannel @relation(fields: [channelId], references: [id]) + channelGroup SmsChannelGroup? @relation(fields: [channelGroupId], references: [id], onDelete: SetNull) session CmppSubmitSession? @relation(fields: [sessionId], references: [id]) retryOfSubmitRecord SmsSubmitRecord? @relation("SmsSubmitRetry", fields: [retryOfSubmitRecordId], references: [id]) retrySubmit SmsSubmitRecord? @relation("SmsSubmitRetry") @@ -1474,6 +1478,7 @@ model SmsSubmitRecord { @@index([messageRecordId]) @@index([gatewayMessageId]) @@index([channelId, gatewayMessageId]) + @@index([channelGroupId]) } model DailyReconciliationReport { diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 3ecc26d..948fa05 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -556,7 +556,6 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { submitId, status: 'submit_queued', submitStatus: 'queued', - errorMessage: '运营端通道测试短信', }, }); await this.prisma.smsSubmitRecord.create({ diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 8eb7500..a6f15ea 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -226,7 +226,7 @@ describe('OperationsService', () => { tenant: true, application: true, channel: true, - submitRecords: { include: { channel: true } }, + submitRecords: { include: { channel: true, channelGroup: true } }, receiptRecords: { include: { channel: true } }, downstreamDeliveries: { where: { deliveryType: 'receipt' }, @@ -341,6 +341,13 @@ describe('OperationsService', () => { it('builds dashboard and statistics aggregates', async () => { const prisma = createPrismaMock(); + prisma.$queryRaw.mockResolvedValueOnce([{ + tenantId: 'tenant-1', + tenantName: '租户A', + todaySpendCents: 24000n, + balanceCents: 1000000n, + creditCents: 50000n, + }]); prisma.cmppDownstreamDelivery.count = jest.fn() .mockResolvedValueOnce(3) .mockResolvedValueOnce(2) @@ -365,6 +372,13 @@ describe('OperationsService', () => { total: 5, }, today: expect.objectContaining({ returnedCents: 10 }), + enterpriseSpendRanks: [{ + tenantId: 'tenant-1', + tenantName: '租户A', + todaySpendCents: 24000, + balanceCents: 1000000, + creditCents: 50000, + }], gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }], downstreamDeliverySummary: expect.objectContaining({ pending: 3, @@ -445,6 +459,8 @@ describe('OperationsService', () => { tenantName: '租户A', hasDrainage: false, total: 5, + acceptedCount: 4, + submitFailureCount: 1, successCount: 3, unknownCount: 1, failureCount: 1, @@ -488,7 +504,13 @@ describe('OperationsService', () => { submitFailureRate: 20, successRate: 60, })], - signatures: [expect.objectContaining({ signatureId: 'signature-1', signatureName: '【测试签名】', hasDrainage: false })], + signatures: [expect.objectContaining({ + signatureId: 'signature-1', + signatureName: '【测试签名】', + hasDrainage: false, + acceptedCount: 4, + submitFailureCount: 1, + })], applications: [expect.objectContaining({ applicationId: 'app-1', applicationName: '通知应用', tenantName: '租户A', total: 5 })], }); expect(prisma.$queryRaw).toHaveBeenCalledTimes(4); @@ -868,7 +890,7 @@ describe('OperationsService', () => { messageRecord: undefined, }, include: { channel: true, submitRecord: true }, - orderBy: [{ submitId: 'asc' }, { segmentIndex: 'asc' }], + orderBy: [{ createdAt: 'asc' }, { segmentIndex: 'asc' }, { id: 'asc' }], }); }); }); diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 6bc5cb2..2e2f22d 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -102,7 +102,7 @@ export class OperationsService { tenant: true, application: true, channel: true, - submitRecords: { include: { channel: true } }, + submitRecords: { include: { channel: true, channelGroup: true } }, receiptRecords: { include: { channel: true } }, downstreamDeliveries: { where: { deliveryType: 'receipt' }, @@ -179,6 +179,7 @@ export class OperationsService { async dashboard(query: { tenantId?: string }) { const sinceToday = startOfToday(); + const businessDay = qualityBusinessDay(); const downstreamAlertWindow = downstreamAlertWindows(); const messageWhereClause = messageWhere({ tenantId: query.tenantId }); const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday } }; @@ -194,6 +195,7 @@ export class OperationsService { tenantAccounts, recentTasks, recentRecharges, + enterpriseSpendRows, downstreamPendingCount, downstreamFailedCount, downstreamDeliveredCount, @@ -253,6 +255,29 @@ export class OperationsService { orderBy: { createdAt: 'desc' }, take: 10, }), + this.prisma.$queryRaw>(Prisma.sql` + SELECT + tenant.id AS "tenantId", + tenant.name AS "tenantName", + COALESCE(SUM(billing."amountCents") FILTER (WHERE billing."billingStatus" = 'charged'), 0)::bigint AS "todaySpendCents", + account."balanceCents" AS "balanceCents", + account."creditCents" AS "creditCents" + FROM "TenantAccount" account + JOIN "Tenant" tenant ON tenant.id = account."tenantId" + LEFT JOIN "SmsBillingRecord" billing + ON billing."tenantId" = tenant.id + AND billing."createdAt" >= ${businessDay.startAt} + AND billing."createdAt" < ${businessDay.endAt} + WHERE (${query.tenantId ?? null}::text IS NULL OR tenant.id = ${query.tenantId ?? null}) + GROUP BY tenant.id, tenant.name, account."balanceCents", account."creditCents" + ORDER BY "todaySpendCents" DESC, tenant.name ASC + `), this.prisma.cmppDownstreamDelivery.count({ where: { tenantId: query.tenantId, status: 'pending' }, }), @@ -314,6 +339,13 @@ export class OperationsService { alertCount: downstreamAlertCount, }, accounts: tenantAccounts, + enterpriseSpendRanks: enterpriseSpendRows.map((row) => ({ + tenantId: row.tenantId, + tenantName: row.tenantName, + todaySpendCents: moneyToNumber(row.todaySpendCents), + balanceCents: moneyToNumber(row.balanceCents), + creditCents: moneyToNumber(row.creditCents), + })), recentTasks, recentRecharges, }; @@ -333,6 +365,7 @@ export class OperationsService { pendingAudits: dashboard.pendingAudits, downstreamDeliverySummary: dashboard.downstreamDeliverySummary, accounts: dashboard.accounts.map(clientAccountView), + enterpriseSpendRanks: dashboard.enterpriseSpendRanks, recentTasks: dashboard.recentTasks.map(clientBatchTaskView), recentRecharges: dashboard.recentRecharges.map(clientRechargeView), }; @@ -468,6 +501,8 @@ export class OperationsService { tenantName: string; hasDrainage: boolean; total: number; + acceptedCount: number; + submitFailureCount: number; successCount: number; unknownCount: number; failureCount: number; @@ -479,6 +514,7 @@ export class OperationsService { message."signatureId" AS signature_id, (message."drainageInfoId" IS NOT NULL) AS has_drainage, message.status, + message."submitStatus" AS submit_status, message."receiptStatus" AS receipt_status, CASE WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') @@ -498,23 +534,40 @@ export class OperationsService { tenant.id AS "tenantId", tenant.name AS "tenantName", base.has_drainage AS "hasDrainage", - COUNT(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected')::integer AS total, + COUNT(*)::integer AS total, + COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + )::integer AS "acceptedCount", + COUNT(*) FILTER ( + WHERE base.status = 'submit_failed' + OR base.submit_status IN ('rejected', 'timeout') + )::integer AS "submitFailureCount", COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", COUNT(*) FILTER ( - WHERE COALESCE(base.status, '') <> 'rejected' + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) - AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) + AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) )::integer AS "unknownCount", COUNT(*) FILTER ( - WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) - AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false)) + AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) )::integer AS "failureCount", CASE - WHEN COUNT(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected') = 0 THEN 0 + WHEN COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + ) = 0 THEN 0 ELSE ROUND( COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered') * 100.0 - / COUNT(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected'), + / COUNT(*) FILTER ( + WHERE COALESCE(base.status, '') <> 'submit_failed' + AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') + ), 1 )::double precision END AS "successRate", @@ -1016,7 +1069,7 @@ export class OperationsService { messageRecord: query.messageId ? { messageId: query.messageId } : undefined, }, include: { channel: true, submitRecord: true }, - orderBy: [{ submitId: 'asc' }, { segmentIndex: 'asc' }], + orderBy: [{ createdAt: 'asc' }, { segmentIndex: 'asc' }, { id: 'asc' }], }); } diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 1ad12c3..b1b92d1 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -53,6 +53,7 @@ function createPrismaMock() { status: 'active', group: { id: 'group-1', + name: '默认通道组', carrier: 'mobile', status: 'active', retryEnabled: true, @@ -1753,7 +1754,13 @@ describe('SendChainService', () => { ); expect(prisma.smsSubmitRecord.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ messageRecordId: 'record-1', channelId: 'channel-1', submitStatus: 'queued' }), + data: expect.objectContaining({ + messageRecordId: 'record-1', + channelId: 'channel-1', + channelGroupId: 'group-1', + channelGroupName: '默认通道组', + submitStatus: 'queued', + }), }); expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ where: { id: 'record-1' }, diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index b38805e..e8ea5c1 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -264,6 +264,7 @@ type RoutedChannel = { carrier: string; province?: string | null; groupId: string; + groupName: string; routeScope: 'province' | 'national'; }; @@ -3373,6 +3374,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { batchTaskId: message.batchTaskId, messageRecordId: message.id, channelId: channel.id, + channelGroupId: routed.groupId, + channelGroupName: routed.groupName, sessionId: session.id, retryOfSubmitRecordId, submitId, @@ -3651,6 +3654,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { carrier, province, groupId: route.groupId, + groupName: route.group.name, routeScope: isNationalChannel(selected) ? 'national' : 'province', }; } diff --git a/api/src/sms-config/review-governance.service.spec.ts b/api/src/sms-config/review-governance.service.spec.ts index cd953fc..4e0b677 100644 --- a/api/src/sms-config/review-governance.service.spec.ts +++ b/api/src/sms-config/review-governance.service.spec.ts @@ -4,7 +4,16 @@ import { ReviewGovernanceService } from './review-governance.service'; function signature(overrides: Record = {}) { return { id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', name: '签名A', purpose: null, - drainageInfo: { signatureProfile: { companyName: '企业A', creditCode: '9133', legalPersonName: '法人', responsibleName: '责任人', responsiblePhone: '13800000000', credentialFile: { fileObjectId: 'file-1' } } }, + drainageInfo: { + signatureReportValues: { companyName: '企业A', credentialFile: { fileObjectId: 'file-1' } }, + reportRequirementSnapshot: { + fields: [ + { code: 'companyName', name: '公司名称', required: true, reportTypes: ['signature'] }, + { code: 'credentialFile', name: '资质文件', required: true, reportTypes: ['signature'] }, + { code: 'siteOwner', name: '网站主体', required: true, reportTypes: ['drainage'] }, + ], + }, + }, auditStatus: 'pending', reportStatus: 'waiting_material', rejectReason: null, materialVersion: 1, pendingReport: true, reportChangedAt: new Date(), createdAt: new Date(), updatedAt: new Date('2026-07-21T08:00:00.000Z'), tenant: { id: 'tenant-1', name: '企业A' }, application: { id: 'app-1', name: '应用A' }, materials: [], @@ -32,13 +41,46 @@ function prismaMock() { describe('ReviewGovernanceService', () => { it('blocks approval when required signature qualification is incomplete', async () => { const prisma = prismaMock(); - prisma.smsSignature.findUnique.mockResolvedValue(signature({ applicationId: null, application: null, drainageInfo: {}, materials: [] })); + prisma.smsSignature.findUnique.mockResolvedValue(signature({ + applicationId: null, + application: null, + drainageInfo: { + signatureReportValues: {}, + reportRequirementSnapshot: { + fields: [ + { code: 'companyName', name: '公司名称', required: true, reportTypes: ['signature'] }, + { code: 'credentialFile', name: '资质文件', required: true, reportTypes: ['both'] }, + { code: 'siteOwner', name: '网站主体', required: true, reportTypes: ['drainage'] }, + ], + }, + }, + materials: [], + })); const service = new ReviewGovernanceService(prisma as never); const result = await service.preflight('signature', 'sig-1'); expect(result.allowedActions).toEqual(['reject']); expect(result.blockedReasons).toEqual(expect.arrayContaining(['未绑定短信应用', '缺少公司名称', '缺少资质文件'])); + expect(result.blockedReasons).not.toContain('缺少网站主体'); + }); + + it('does not invent legacy qualification requirements when the configured channel has no signature fields', async () => { + const prisma = prismaMock(); + prisma.smsSignature.findUnique.mockResolvedValue(signature({ + drainageInfo: { + signatureReportValues: {}, + reportRequirementSnapshot: { + fields: [{ code: 'icpNo', name: 'ICP备案号', required: true, reportTypes: ['drainage'] }], + }, + }, + })); + const service = new ReviewGovernanceService(prisma as never); + + const result = await service.preflight('signature', 'sig-1'); + + expect(result.allowedActions).toEqual(['approve', 'reject']); + expect(result.blockedReasons).toEqual([]); }); it('atomically approves the expected version and returns an audit operation id', async () => { diff --git a/api/src/sms-config/review-governance.service.ts b/api/src/sms-config/review-governance.service.ts index 15a25c4..94d056d 100644 --- a/api/src/sms-config/review-governance.service.ts +++ b/api/src/sms-config/review-governance.service.ts @@ -90,21 +90,24 @@ export class ReviewGovernanceService { }); if (!item) throw new NotFoundException('Signature not found'); const payload = asRecord(item.drainageInfo); - const profile = asRecord(payload.signatureProfile); const missing: string[] = []; if (!item.applicationId) missing.push('未绑定短信应用'); - if (!String(profile.companyName ?? '').trim()) missing.push('缺少公司名称'); - if (!String(profile.creditCode ?? '').trim()) missing.push('缺少统一社会信用代码'); - if (!String(profile.legalPersonName ?? '').trim()) missing.push('缺少法人姓名'); - if (!String(profile.responsibleName ?? '').trim()) missing.push('缺少责任人姓名'); - if (!String(profile.responsiblePhone ?? '').trim()) missing.push('缺少责任人手机号'); - const profileHasFile = Object.values(profile).some((value) => Boolean(asRecord(value).fileObjectId)); - if (!profileHasFile && item.materials.length === 0) missing.push('缺少资质文件'); + const signatureValues = asRecord(payload.signatureReportValues); + const requiredFields = reportRequirementFields(payload) + .filter((field) => field.required && field.reportTypes.some((type) => type === 'signature' || type === 'both')); + for (const field of requiredFields) { + if (!hasReportValue(signatureValues[field.code])) missing.push(`缺少${field.name}`); + } return reviewPreflight('signature', item, { identity: { name: item.name, id: item.id, tenant: item.tenant.name, application: item.application?.name ?? '未绑定应用' }, blockedReasons: missing, impacts: ['通过后签名将进入报备资格链路', '已绑定模板和后续发送资格可能受此决定影响'], - materialSummary: { qualificationFiles: item.materials.length + (profileHasFile ? 1 : 0), missingCount: missing.length }, + materialSummary: { + configuredRequiredFields: requiredFields.length, + submittedFields: Object.values(signatureValues).filter(hasReportValue).length, + qualificationFiles: item.materials.length + Object.values(signatureValues).filter((value) => Boolean(asRecord(value).fileObjectId)).length, + missingCount: missing.length, + }, }); } @@ -156,3 +159,30 @@ function normalizeIdempotencyKey(value: string) { function asRecord(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } + +function reportRequirementFields(payload: Record) { + const snapshot = asRecord(payload.reportRequirementSnapshot); + if (!Array.isArray(snapshot.fields)) return []; + return snapshot.fields.flatMap((value) => { + const field = asRecord(value); + const code = typeof field.code === 'string' ? field.code.trim() : ''; + if (!code) return []; + const reportTypes = Array.isArray(field.reportTypes) + ? field.reportTypes.filter((type): type is string => typeof type === 'string') + : []; + return [{ + code, + name: typeof field.name === 'string' && field.name.trim() ? field.name.trim() : code, + required: field.required === true, + reportTypes, + }]; + }); +} + +function hasReportValue(value: unknown) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const record = asRecord(value); + return Boolean(record.fileObjectId || record.fieldValue || record.value); + } + return value !== undefined && value !== null && String(value).trim().length > 0; +} diff --git a/api/src/users/users.service.spec.ts b/api/src/users/users.service.spec.ts index 294e1d1..28f22ea 100644 --- a/api/src/users/users.service.spec.ts +++ b/api/src/users/users.service.spec.ts @@ -250,21 +250,19 @@ describe('UsersService', () => { }); }); - it('forbids disabling the last active administrator of a tenant', async () => { + it('allows disabling the last active administrator of a tenant', async () => { const prisma = createPrismaMock(); prisma.user.findFirst.mockResolvedValue({ id: 'user-1', tenantId: 'tenant-1', username: 'admin', status: 'active', roles: [{ role: { code: 'enterprise_admin' } }], }); - prisma.user.count.mockResolvedValue(1); + prisma.user.update.mockResolvedValue({ + id: 'user-1', tenantId: 'tenant-1', username: 'admin', status: 'disabled', roles: [{ role: { code: 'enterprise_admin' } }], + }); const service = new UsersService(prisma as never); await expect(service.changeStatus('user-1', { status: 'disabled' }, 'tenant-1', 'operator-2')) - .rejects.toMatchObject({ - response: expect.objectContaining({ - code: 'LAST_ENTERPRISE_ADMIN', - message: expect.stringContaining('请先创建或启用该企业的另一名管理员'), - }), - }); + .resolves.toEqual(expect.objectContaining({ id: 'user-1' })); + expect(prisma.user.count).not.toHaveBeenCalled(); }); it('maps duplicate login identifiers to HTTP 409 with the conflicting field', async () => { diff --git a/api/src/users/users.service.ts b/api/src/users/users.service.ts index 31c4e49..8d60882 100644 --- a/api/src/users/users.service.ts +++ b/api/src/users/users.service.ts @@ -380,25 +380,23 @@ export class UsersService { nextTenantId?: string | null, ) { const currentRole = current.roles[0]?.role.code; - if (current.status !== 'active' || !['platform_admin', 'enterprise_admin'].includes(currentRole)) return; + if (current.status !== 'active' || currentRole !== 'platform_admin') return; const remainsSameAdmin = nextStatus === 'active' && nextRoleCode === currentRole - && (currentRole !== 'enterprise_admin' || nextTenantId === current.tenantId); + && nextTenantId === current.tenantId; if (remainsSameAdmin) return; const activeCount = await this.prisma.user.count({ where: { deletedAt: null, status: 'active', - tenantId: currentRole === 'platform_admin' ? null : current.tenantId, + tenantId: null, roles: { some: { role: { code: currentRole } } }, }, }); if (activeCount <= 1) { throw new ConflictException({ - code: currentRole === 'platform_admin' ? 'LAST_PLATFORM_ADMIN' : 'LAST_ENTERPRISE_ADMIN', - message: currentRole === 'platform_admin' - ? '不能删除、禁用或降权最后一个平台管理员;请先创建或启用另一名平台管理员' - : '不能删除、禁用或降权最后一个企业管理员;请先创建或启用该企业的另一名管理员', + code: 'LAST_PLATFORM_ADMIN', + message: '不能删除、禁用或降权最后一个平台管理员;请先创建或启用另一名平台管理员', }); } } diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 653871e..712a349 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -1575,7 +1575,7 @@ 1. 供应商回执优先按内部消息 ID,或 `channelId + gatewayMessageId + DestTerminalId` 对提交记录/长短信分片审计作唯一匹配。若同一供应商账号配置了多个物理通道连接,回执可能从非原提交连接返回,此时仅允许在“账号、Gateway 主机、端口、协议及 CMPP 版本全部一致,且 `gatewayMessageId + DestTerminalId` 只有一个候选提交/分片”时跨连接认领,并仍归属原提交的逻辑通道;任一字段不同或候选不唯一必须拒绝自动匹配。每个回执事件必须以逻辑通道生成数据库唯一键,并发或重复事件不得重复生成记录、退款、计费或企业应用投递。 2. 成功回执统一更新短信主记录状态、回执状态、到达时间、真实通道、通道消息号、原始回执码和文本;失败、超时和未知回执保留可解释状态,最终失败只退款一次。 3. 对账、利润和质量报表统一以短信记录计费条数为发送量,以唯一主记录终态统计成功/失败;收入取扣费流水,返还取退款流水,成本取真实提交通道单价,利润等于收入减成本。到达时长按提交至成功回执计算,延迟回执由 T+1 和 T-4 至 T-1 重算覆盖;查询、页面和 CSV 共用同一聚合表。 -4. 运营端与客户端用户接口使用各自安全 DTO,禁止输出密码散列、会话版本、登录失败内部计数和密钥字段。后端禁止自删除/自停用、禁止删除或降权最后一个平台管理员及企业管理员,并强制校验跨租户操作;唯一冲突返回 HTTP 409 和明确字段。 +4. 运营端与客户端用户接口使用各自安全 DTO,禁止输出密码散列、会话版本、登录失败内部计数和密钥字段。后端禁止自删除/自停用、禁止删除或降权最后一个平台管理员;企业管理员允许删除或停用至零人;所有操作强制校验跨租户范围,唯一冲突返回 HTTP 409 和明确字段。 5. HTTP 单发公开契约使用 `mobile`、`content` 和可选 `clientMessageId`,不要求内部签名或模板 ID;服务端按 CMPP 同一规则识别已审核签名、模板及变量,复用风控、余额、计费、路由和队列。成功返回可查询 messageId,业务拒绝返回对应 4xx,不得在已创建记录后返回“批次不存在”。 6. 客户发送候选只返回 approved 签名和模板,管理视图可查看历史状态。模板变量必须拒绝空变量、未闭合、中文或非法名称、重复名称及超长名称;客户端签名视图仅返回必要报备汇总状态。 7. 报备资料提供官方 XLSX 模板和按当前筛选导出;导入拒绝空文件、错误扩展名、超限文件以及公式/脚本单元格。分析、提交和导出日志记录操作人、文件名、筛选条件、成功数、失败数和 IP,不保存密钥或完整请求体。 @@ -1699,7 +1699,7 @@ 1. 用户逻辑删除后,原用户记录、用户主键及历史审计关联必须继续保留;用户名、邮箱和手机号仅在未删除用户范围内唯一。新建用户可以复用逻辑删除记录曾使用的登录标识,但必须生成新的用户主键,不得继承旧用户的角色、企业归属、密码、会话或权限。 2. 用户登录和按用户名查找必须显式排除逻辑删除记录。活动用户之间的登录标识并发冲突继续由PostgreSQL唯一索引保证,并返回HTTP 409、冲突字段及明确中文提示。 3. 运营端用户管理将用户姓名、登录账号、所属企业、用户角色和状态拆分为独立条件;客户端将用户姓名、登录账号和状态拆分。点击查询或重置时调用真实后端组合查询,条件之间使用AND,登录账号内部对用户名、邮箱和手机号使用OR;不得加载全量数据后仅在浏览器过滤。 -4. 删除、禁用或降权最后一个平台管理员/企业管理员时,后端实时拦截继续作为权威判断。前端必须在当前确认弹窗内以`role=alert`显示失败原因和处理建议,保持弹窗打开,禁止只向浏览器控制台输出未处理Promise;请求期间确认按钮必须禁用。 +4. 删除、禁用或降权最后一个平台管理员时,后端实时拦截继续作为权威判断;企业管理员不设“最后一名”保护,可删除或停用至零人。前端必须在当前确认弹窗内以`role=alert`显示平台管理员保护失败原因和处理建议,保持弹窗打开,禁止只向浏览器控制台输出未处理Promise;请求期间确认按钮必须禁用。 ## 2026-07-26 企业应用停用与回执清算补充要求 - 删除企业前必须检查其企业应用;只要存在`active`或`disabling`应用就阻止删除,并提示先完成应用停用。 @@ -1760,3 +1760,13 @@ 4. 同一短信记录只允许生成一条最终回执投递。CMPP下游投递使用数据库唯一去重键,HTTP Webhook使用稳定事件号和事件/端点唯一关系;重复终态处理返回原投递,不得再次向客户发送。 5. 补发抢占成功、并发复用及下游投递去重必须写结构化日志,至少包含平台消息号、短信记录、来源提交记录、下一跳`submitId`、通道和复用的投递记录。 6. 历史重复提交、通讯报文、回执、退款及客户ACK属于事故审计证据,不得在功能migration中删除或覆盖。历史余额修正必须先完成账户与流水专项对账,再通过可审计冲正处理。 + +## 2026-07-27 签名审核与运营端数据展示补充要求 + +1. 签名审核资格必须读取签名提交时保存的`reportRequirementSnapshot.fields`和`signatureReportValues`,只校验`reportTypes`包含`signature/both`的必填字段;不得再硬编码公司名称、信用代码、法人、责任人和资质文件。仅用于引流报备的字段不能阻断签名审核。 +2. 审核中心内“风控规则”固定放在最后一项,面包屑归属审核中心。 +3. 运营看板今日签名发送统计必须单列展示提交失败;送达失败不得混入提交拒绝或提交超时。今日企业消费排行必须按北京时间当天真实`SmsBillingRecord.billingStatus=charged`金额聚合,不得使用充值记录或仅取最近若干企业拼装。 +4. 每次真实上游提交必须保存当时选中的通道组,短信发送详情展示通道组和通道;历史数据允许在migration时按仍可确认的应用、运营商和通道成员关系回填,后续路由变更不得改写已持久化归因。 +5. 通道测试短信处于回执等待状态时与普通短信使用相同状态样式,不得因“测试短信”说明显示红色失败;发送测试弹窗的接入号和网关密码必须使用独立表单名称及自动填充语义,避免密码管理器串填。 +6. 企业应用的通道组选择控件不得突破卡片宽度;通用输入控件在有无提示文案时控制区顶部对齐。 +7. 分片补偿审计按`createdAt`从早到晚展示,并显示审计时间;同一时刻按分片序号和主键稳定排序。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 84fd59b..bca8db7 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -3668,7 +3668,7 @@ npm run verify:phase8 | TC-RECEIPT-IDEMPOTENT-001 | 顺序和并发重复发送相同 DELIVRD,随后重启服务并再次发送。 | `receiptKey` 唯一约束保证只保存一次、只下发一次客户回执,且不重复计费或退款;重启后行为一致。 | | TC-REPORT-RECALC-001 | 插入一条成功、一条失败及扣费/退款流水,执行指定日期重算两次,再查询应用和通道报表及 CSV。 | 两次结果一致;发送 2、成功 1、失败 1;收入、退款、成本和利润使用相同金额单位且 `利润=收入-成本`;平均到达时长来自真实提交/回执时间。 | | TC-USER-SAFE-001 | 分别查询运营端、客户端用户列表和详情,并尝试跨租户访问。 | 响应不含 `passwordHash`、`sessionVersion`、密钥或认证内部字段;跨租户查询、更新、改密、禁用和删除由 API 拒绝。 | -| TC-USER-CONTINUITY-001 | 当前用户删除/停用自己,删除或降权最后一个平台管理员、最后一个企业管理员,并创建重复用户名。 | 前三类操作返回 403;最后管理员保护生效;唯一冲突返回 409 和冲突字段,不出现 500。 | +| TC-USER-CONTINUITY-001 | 当前用户删除/停用自己,删除或降权最后一个平台管理员、删除或停用最后一个企业管理员,并创建重复用户名。 | 自删除/自停用返回403;最后一个平台管理员保护生效;最后一个企业管理员允许删除或停用;唯一冲突返回409和冲突字段,不出现500。 | | TC-HTTP-SEND-002 | 仅传 `mobile/content`,正文使用已审核签名及 `${code}` 模板,调用公开发送接口并重放同一幂等键。 | 自动识别签名/模板并提取合法变量,经真实发送链返回 202、稳定 messageId;相同请求返回同一结果,不出现批次 404。 | | TC-TEMPLATE-VARIABLE-001 | 提交空变量、未闭合、中文、重复、超长和非法字符变量,再查看发送候选。 | 前后端均拒绝非法变量;候选只含 approved 签名/模板,disabled/rejected 仅在历史管理视图显示。 | | TC-REPORT-MATERIAL-SAFE-001 | 下载官方 XLSX;按筛选导出;导入空文件、错误扩展名、超限文件、含公式/脚本单元格及部分错误行文件。 | 模板和导出为真实 XLSX;危险文件在入库前拒绝,部分失败保留行级原因;日志含操作人、文件名、筛选和计数,不含敏感请求体。 | @@ -3691,7 +3691,7 @@ npm run verify:phase8 | TC-USER-REUSE-002 | 两个未删除用户并发提交相同用户名、邮箱或手机号。 | PostgreSQL仅允许一个请求成功,另一个返回HTTP 409、`USER_DUPLICATE`、冲突字段和中文提示,不产生两个活动账号。 | | TC-USER-FILTER-001 | 在运营端分别及组合填写用户姓名、登录账号、所属企业、用户角色和状态,点击查询,再点击重置。 | 每次操作请求真实`GET /api/admin/users`;条件分别生效,组合使用AND,登录账号匹配用户名/邮箱/手机号;重置返回全部未删除用户。 | | TC-USER-FILTER-002 | 在客户端分别及组合填写用户姓名、登录账号和状态。 | 请求真实`GET /api/client/users`;只返回当前企业管理员,无法通过查询参数跨租户或查询平台管理员。 | -| TC-USER-CONTINUITY-UI-001 | 删除、禁用或降权最后一个平台管理员及某企业最后一个启用管理员。 | 后端返回权威冲突;确认弹窗保持打开并显示红色可访问错误及“先创建或启用另一名管理员”建议,按钮结束忙碌状态,浏览器无未处理Promise。 | +| TC-USER-CONTINUITY-UI-001 | 删除、禁用或降权最后一个平台管理员,再删除或停用某企业最后一个启用管理员。 | 平台管理员操作由后端权威拦截并在确认弹窗显示建议;企业管理员操作成功且可归零;按钮结束忙碌状态,浏览器无未处理Promise。 | | TC-USER-CONTINUITY-UI-002 | 为相同范围增加另一名启用管理员后重复删除或禁用。 | 操作成功、弹窗关闭、列表按当前已应用查询条件刷新,并写入对应OperationLog。 | ### 17.17 2026-07-21 下游连接恢复与历史回执回填 @@ -3930,3 +3930,20 @@ npm run verify:phase8 | TC-CHANNEL-REPORT-STATS-002 | 同一签名有直接短信和多个引流信息,展开具体引流任务 | 签名任务汇总当前通道下该签名全部发送;具体引流任务仅统计自身,不串入同签名其他引流或直接短信 | | TC-CHANNEL-REPORT-STATS-003 | 当前统计范围有历史成功短信但今日无成功短信 | “上次发送成功时间”显示该范围最近一次最终成功时间,不以报备时间、最后更新时间或页面当前时间代替 | | TC-CHANNEL-REPORT-STATS-004 | 报备任务先提交、后由报备记录变为通过 | 列表和详情分别显示真实提交报备时间、最近一次报备成功时间、上次发送成功时间和今日统计;刷新后数据保持一致 | + +## 2026-07-27 签名审核与运营端细节回归用例 + +| 用例编号 | 场景 | 预期结果 | +|---|---|---| +| TC-SIGNATURE-PREFLIGHT-001 | 应用通道只配置引流必填字段,客户端提交不含固定企业资质字段的签名 | 运营审核资格检查不出现公司名称、信用代码、法人、责任人或资质文件缺失,允许通过/驳回 | +| TC-SIGNATURE-PREFLIGHT-002 | 提交快照包含签名必填文本和文件字段,分别缺失后预检 | 只按快照字段名称提示缺失;补齐`signatureReportValues`后允许通过;引流字段不参与 | +| TC-ADMIN-NAV-003 | 展开审核中心并进入风控规则 | 风控规则位于最后一项,面包屑为“审核中心 / 风控规则” | +| TC-SMS-DETAIL-004 | 查看经历首次提交和补发的短信详情,再修改应用路由 | 每次提交显示持久化通道组名称;路由修改后历史归因不变化;旧数据迁移可确认部分已回填 | +| TC-SMS-DETAIL-005 | 测试短信刚进入队列但尚无供应商回执 | 与普通待回执短信一致,不显示红色“运营端通道测试短信”失败框 | +| TC-SEGMENT-AUDIT-006 | 构造多个不同时间和同时间分片审计 | 页面自上而下按审计时间升序,同时间按分片序号和主键稳定排序,并展示审计时间 | +| TC-DASHBOARD-SIGNATURE-007 | 当日同一签名包含accepted、rejected、timeout及回执失败 | 表格独立展示提交失败;送达失败仅包含已受理后的失败,成功率分母为已受理数 | +| TC-DASHBOARD-SPEND-008 | 两企业当日分别产生计费、退款和手工充值 | 排行只汇总当前仍为charged的当日计费;充值不计消费,退款记录不计当前消费,排序与数据库一致 | +| TC-CHANNEL-TEST-AUTOFILL-009 | 浏览器保存过网关密码,打开通道编辑和测试短信弹窗 | 密码只进入网关密码字段,不自动填入测试接入号;接入号可手工正常输入 | +| TC-APP-ROUTE-WIDTH-010 | 企业应用通道组名称很长,分别使用桌面和窄屏 | 选择框及下拉选项不超出通道组卡片,长文本省略且可正常选择 | +| TC-USER-ADMIN-011 | 删除/停用企业最后一个管理员,再删除/停用平台最后一个管理员 | 企业管理员操作成功并写日志;平台管理员操作仍返回`LAST_PLATFORM_ADMIN` | +| TC-INPUT-ALIGN-012 | 打开新增用户弹窗,对比有提示和无提示的文本输入框 | 标签和输入控制区顶部对齐,提示文本仅占自身下方空间 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index eebec9f..12b1e9d 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -2563,3 +2563,13 @@ git diff --check - 部署前PostgreSQL和环境配置分别备份为`/opt/cmpp-deploy-backups/cmpp-20260727-205312.sql`、`/opt/cmpp-deploy-backups/cmpp-env-20260727-205312.env`,旧运行目录保留为`/opt/cmpp-platform.previous-20260727-205312`;标准脚本完成两套依赖安装、安全门禁、Prisma、前端/API/Gateway构建及Gateway先于API重启,73条migration齐全且无待执行项。 - 预发布`.deployed-commit=94aeacd3a20b4c838c59d67ba86ef466ff49ce03`;API、Gateway、Nginx、PostgreSQL、MinIO均active,Redis`PONG`,`12026/17890/8090/3000/6379/5432/9000`监听,内外页面和健康接口HTTP 200,CMPP 17890可连接,Redis Stream消费者1、`pending=0`、`lag=0`,发布后API/Gateway无error级日志。 - 部署后的真实`ChannelsService`查询53个报备任务,4个存在今日真实发送统计,返回成功、未知、回执失败、提交失败和最近成功时间;前端产物包含三项新交付文案。4个启用通道中3个立即恢复`connected 1/1`;“会员营销-富泷”首次鉴权失败后按5分钟慢重试策略于20:59:07自动恢复`connected 1/1`,未人工高频重连。本轮未发送真实测试短信。 + +## 2026-07-27 签名动态资格与运营端十项缺陷修复(发布前) + +- 签名审核资格从固定`signatureProfile`切换为提交时`reportRequirementSnapshot.fields + signatureReportValues`,只校验签名/通用类型必填字段;通道仅要求引流字段时不再误报公司名称、信用代码、法人、责任人和资质文件。 +- 短信提交新增`channelGroupId/channelGroupName`历史归因,migration对仍可唯一确认的历史提交回填;名称快照保证通道组删除后仍可解释历史发送。发送详情展示通道组,分片补偿审计按创建时间升序并展示审计时间。测试短信不再把说明写入`errorMessage`,详情失败框只由真实最终失败状态触发。 +- 运营看板签名统计增加提交失败并从送达失败中拆出;企业消费排行改为北京时间当天真实charged计费聚合,覆盖全部企业账户,不再从最近10条手工充值和最近20个账户拼装。 +- 审核中心把风控规则移到最后并修正面包屑;通道测试密码/接入号增加独立autocomplete/name语义;通用输入控件有无提示时顶部对齐,企业应用通道组Select及选项限制在卡片宽度内。 +- 最后一个企业管理员允许删除、禁用或降权至零人;最后一个平台管理员保护保持不变。 +- Node.js v24.14.0下签名审核、用户、运营统计3 suites / 43 tests,通道1 suite / 41 tests,通道组持久化发送路径专项通过;启动临时本地Redis后API全量26 suites / 354 tests通过,测试结束即关闭临时Redis。API TypeScript build、前端TypeScript和Vite生产构建、Prisma format/generate/validate及`git diff --check`通过。 +- 预发布部署后继续以真实Redis、migration、看板聚合和发送流状态做发布验收。本节提交、推送和预生产部署结果将在完成后追加。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 705378a..e6de39c 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -346,6 +346,7 @@ export type DashboardResponse = { alertCount: number; }; accounts: Array<{ id: string; tenantId: string; balanceCents: number; creditCents: number; status: string; tenant?: TenantOption }>; + enterpriseSpendRanks: Array<{ tenantId: string; tenantName: string; todaySpendCents: number; balanceCents: number; creditCents: number }>; recentTasks: Array>; recentRecharges: Array; }; @@ -374,6 +375,8 @@ export type SignatureQualityStat = { tenantName: string; hasDrainage: boolean; total: number; + acceptedCount: number; + submitFailureCount: number; successCount: number; unknownCount: number; failureCount: number; @@ -639,6 +642,7 @@ export type SmsMessageRecord = { export type SmsSubmitRecord = { id: string; channelId: string; + channelGroupName?: string | null; submitId: string; sequenceId?: number | null; gatewayMessageId?: string | null; @@ -648,6 +652,7 @@ export type SmsSubmitRecord = { submittedAt?: string | null; createdAt: string; channel?: AdminChannel | null; + channelGroup?: { id: string; name: string } | null; }; export type SmsReceiptRecord = { diff --git a/src/apps/admin/AdminChannelsPage.tsx b/src/apps/admin/AdminChannelsPage.tsx index 3df4acc..79d7cd7 100644 --- a/src/apps/admin/AdminChannelsPage.tsx +++ b/src/apps/admin/AdminChannelsPage.tsx @@ -332,6 +332,8 @@ function ChannelFormModal({ setPassword(event.target.value)} placeholder={modal.mode === 'edit' ? '留空不修改' : '请输入网关密码'} required={modal.mode === 'create'} @@ -339,7 +341,7 @@ function ChannelFormModal({ value={password} />
- setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} /> + setAccessNo(event.target.value)} placeholder="请输入通道接入号" value={accessNo} /> setExtensionDigits(event.target.value)} type="number" value={extensionDigits} />
setFlowLimit(event.target.value)} suffix="条/秒" type="number" value={flowLimit} /> @@ -446,7 +448,9 @@ function SmsTestModal({ {content.length} 字符 计费 {billingCount} 条 setAccessNo(event.target.value)} placeholder="请输入接入号" value={accessNo} diff --git a/src/apps/admin/AdminHome.tsx b/src/apps/admin/AdminHome.tsx index f5167d3..225a760 100644 --- a/src/apps/admin/AdminHome.tsx +++ b/src/apps/admin/AdminHome.tsx @@ -70,19 +70,17 @@ export function AdminHome() { }, []); const enterpriseSpendRanks = useMemo(() => { - return (dashboard?.accounts ?? []).map((account) => { - const todaySpend = Math.abs(dashboard?.recentRecharges - .filter((item) => item.tenantId === account.tenantId) - .reduce((sum, item) => sum + item.amountCents, 0) ?? 0) / 10_000; + return (dashboard?.enterpriseSpendRanks ?? []).map((account) => { + const todaySpend = moneyUnitsToYuan(account.todaySpendCents); const availableBalance = moneyUnitsToYuan(account.balanceCents + account.creditCents); return { id: account.tenantId, - enterprise: account.tenant?.name ?? account.tenantId, + enterprise: account.tenantName, todaySpend, availableBalance, balanceStatus: (availableBalance <= 0 ? '欠费' : availableBalance < 100 ? '紧张' : '充足') as EnterpriseSpendRank['balanceStatus'], }; - }).sort((left, right) => right.todaySpend - left.todaySpend); + }); }, [dashboard]); const totalSend = dashboard?.today.sent ?? 0; @@ -144,6 +142,7 @@ export function AdminHome() { { key: 'rank', title: '排名', width: '72px', render: (record) => record.rank }, { key: 'signatureName', title: '签名', render: (record) =>
{record.signatureName}

{record.tenantName}

}, { key: 'total', title: '发送总数', align: 'right', render: (record) => formatCount(record.total) }, + { key: 'submitFailureCount', title: '提交失败', align: 'right', render: (record) => formatCount(record.submitFailureCount) }, { key: 'successCount', title: '成功', align: 'right', render: (record) => formatCount(record.successCount) }, { key: 'unknownCount', title: '未知', align: 'right', render: (record) => formatCount(record.unknownCount) }, { key: 'failureCount', title: '失败', align: 'right', render: (record) => formatCount(record.failureCount) }, diff --git a/src/apps/admin/AdminRiskRulesPage.tsx b/src/apps/admin/AdminRiskRulesPage.tsx index b5669c6..2b4b32e 100644 --- a/src/apps/admin/AdminRiskRulesPage.tsx +++ b/src/apps/admin/AdminRiskRulesPage.tsx @@ -134,7 +134,7 @@ export function AdminRiskRulesPage() { return (
-

风控规则

维护全局阈值,并按企业应用覆盖;确定性号码校验和黑名单拦截不在此配置。

+

风控规则

维护全局阈值,并按企业应用覆盖;确定性号码校验和黑名单拦截不在此配置。

diff --git a/src/apps/admin/AdminSmsRecordsPage.tsx b/src/apps/admin/AdminSmsRecordsPage.tsx index 0867cbb..969c409 100644 --- a/src/apps/admin/AdminSmsRecordsPage.tsx +++ b/src/apps/admin/AdminSmsRecordsPage.tsx @@ -44,6 +44,7 @@ const carrierLabelMap: Record = { type RouteRow = { id: string; channel: string; + channelGroup?: string | null; sentAt?: string | null; receiptAt?: string | null; receiptCode?: string | null; @@ -124,6 +125,7 @@ function getCarrierLabel(carrier?: string | null) { function buildRouteRows(record: SmsMessageRecord, segmentAudits: SmsMessageSegmentAudit[]): RouteRow[] { if (segmentAudits.length > 0) { + const submitById = new Map((record.submitRecords ?? []).map((submit) => [submit.submitId, submit])); const attempts = new Map(); segmentAudits.forEach((segment) => { const current = attempts.get(segment.submitId) ?? []; @@ -143,6 +145,7 @@ function buildRouteRows(record: SmsMessageRecord, segmentAudits: SmsMessageSegme channel: ordered.find((segment) => segment.channel?.name)?.channel?.name ?? ordered.find((segment) => segment.channelId)?.channelId ?? '-', + channelGroup: submitById.get(submitId)?.channelGroupName ?? submitById.get(submitId)?.channelGroup?.name, sentAt: sentTimes.sort()[0], receiptAt: receiptTimes.sort()[receiptTimes.length - 1], receiptCode: receiptCodes.join(' / ') || undefined, @@ -163,6 +166,7 @@ function buildRouteRows(record: SmsMessageRecord, segmentAudits: SmsMessageSegme return { id: submit.id || String(index + 1), channel: submit.channel?.name ?? record.channel?.name ?? submit.channelId ?? '-', + channelGroup: submit.channelGroupName ?? submit.channelGroup?.name, sentAt: submit.submittedAt ?? submit.createdAt, receiptAt: receipt?.deliveredAt, receiptCode: receipt?.rawStatus, @@ -237,6 +241,11 @@ function SendDetailModal({ onClose: () => void; }) { const routeRows = buildRouteRows(record, segmentAudits); + const channelGroupNames = Array.from(new Set(routeRows.map((route) => route.channelGroup).filter(Boolean))); + const orderedSegmentAudits = [...segmentAudits].sort((left, right) => { + const timeDiff = new Date(left.createdAt).getTime() - new Date(right.createdAt).getTime(); + return timeDiff || left.segmentIndex - right.segmentIndex || left.id.localeCompare(right.id); + }); const sentAccessNumber = `${record.channel?.srcId ?? ''}${record.applicationExtension ?? ''}`; const displayStatus = getRecordStatus(record); const receiptNotice = getReceiptNotice(record); @@ -274,6 +283,10 @@ function SendDetailModal({ 号码归属 {record.province ?? '-'} / {getCarrierLabel(record.carrier)}
+
+ 通道组 + {channelGroupNames.join(' / ') || '-'} +
收到的接入号 {record.clientSrcId || '-'} @@ -302,6 +315,7 @@ function SendDetailModal({ {index + 1}
{route.channel} +

通道组:{route.channelGroup ?? '-'}

发送时间
@@ -334,7 +348,7 @@ function SendDetailModal({
提交状态{record.submitStatus ?? '-'}
回执状态{record.receiptStatus ?? '-'}
- {['failed', 'rejected'].includes(record.status) || record.errorMessage || record.errorCode ? ( + {['submit_failed', 'failed', 'rejected'].includes(displayStatus) ? (
失败原因{record.errorMessage ?? record.errorCode ?? '未返回明确失败原因'}
@@ -348,7 +362,7 @@ function SendDetailModal({
暂无分片审计
) : (
- {segmentAudits.map((segment) => ( + {orderedSegmentAudits.map((segment) => (
分片 {segment.segmentIndex}/{segment.segmentTotal} @@ -363,6 +377,7 @@ function SendDetailModal({
提交 ID
{segment.submitId}
网关 MsgId
{segment.gatewayMessageId ?? '-'}
补偿方式
{segment.compensationType ?? '-'}
+
审计时间
{getTime(segment.createdAt)}
错误信息
{segment.errorMessage ?? segment.errorCode ?? '-'}
diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 04293ae..c477e48 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -113,10 +113,10 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) { items: [ { label: '企业认证审核', to: '/admin/enterprise-audit', icon: ShieldCheck }, { label: '短信审核', to: '/admin/sms-audit', icon: MessageSquare }, - { label: '风控规则', to: '/admin/risk-rules', icon: Shield }, { label: '短信模板审核', to: '/admin/templates', icon: FileCheck2 }, { label: '短信签名审核', to: '/admin/signatures', icon: FilePenLine }, { label: '引流信息审核', to: '/admin/drainage-audits', icon: FilePenLine }, + { label: '风控规则', to: '/admin/risk-rules', icon: Shield }, ], }, { diff --git a/src/styles/components.css b/src/styles/components.css index 7568632..27564ca 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -129,8 +129,10 @@ } .ui-field { + align-content: start; display: grid; gap: var(--space-2); + min-width: 0; } .ui-field__label { @@ -167,9 +169,11 @@ display: flex; gap: var(--space-2); min-height: var(--control-height-md); + min-width: 0; padding: 0 var(--control-padding-x); position: relative; transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast); + width: 100%; } .ui-textarea { @@ -656,8 +660,12 @@ font-size: var(--font-size-md); height: 36px; justify-content: flex-start; + min-width: 0; + overflow: hidden; padding: 0 var(--space-3); text-align: left; + text-overflow: ellipsis; + white-space: nowrap; width: 100%; } diff --git a/src/styles/global.css b/src/styles/global.css index 8295aae..e1a45f3 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -8732,6 +8732,12 @@ h3 { color: var(--color-text-strong); } +.admin-app-route-card > .ui-field { + max-width: 100%; + min-width: 0; + width: 100%; +} + .downstream-attempt-timeline { border-top: 0 !important; display: grid !important;