From 4724b9db6a99bec15350e37978d3fb165ee74bb9 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Sun, 9 Aug 2026 14:27:19 +0800 Subject: [PATCH] feat: improve operations diagnostics and channel management --- .../migration.sql | 2 + api/prisma/schema.prisma | 1 + .../channels/channel-group-routing.service.ts | 80 ++++++++++-- api/src/channels/channels.controller.ts | 5 + api/src/channels/channels.helpers.ts | 16 +-- api/src/channels/channels.service.spec.ts | 60 ++++++++- api/src/channels/channels.service.ts | 4 + api/src/common/report-status.spec.ts | 14 ++ api/src/common/report-status.ts | 29 +++++ .../deletion-governance.service.spec.ts | 23 ++++ .../deletion-governance.service.ts | 6 +- .../dictionaries/dictionaries.controller.ts | 5 + .../dictionaries/dictionaries.service.spec.ts | 22 ++++ api/src/dictionaries/dictionaries.service.ts | 21 +++ .../protocol-logs.service.spec.ts | 15 ++- .../protocol-logs/protocol-logs.service.ts | 10 +- api/src/reports/reports.service.spec.ts | 29 ++++- api/src/reports/reports.service.ts | 54 +++++++- .../drainage-content-detection.spec.ts | 18 ++- .../send-chain/drainage-content-detection.ts | 7 +- api/src/send-chain/send-chain.contracts.ts | 2 + api/src/send-chain/send-chain.service.spec.ts | 53 +++++++- .../send-chain/send-inbound-entry.service.ts | 94 +++++++++---- api/src/sms-config/signature.service.ts | 9 +- docs/codebase-modularization-roadmap.md | 4 +- docs/contracts/admin-api-r1-methods.json | 16 ++- .../admin-enterprise-signatures-r4.json | 2 +- docs/contracts/admin-shared-styles-r11.json | 1 + docs/contracts/channels-r5-methods.json | 17 ++- docs/contracts/send-chain-r8-pure-logic.json | 2 +- docs/contracts/send-chain-r9-submission.json | 2 +- docs/contracts/sms-config-r3-methods.json | 4 +- .../first-version-development-requirements.md | 61 ++++++++- docs/system-functional-test-cases.md | 83 +++++++++++- docs/testing-progress.md | 80 ++++++++++++ gateway/internal/inbound/authentication.go | 24 ++-- gateway/internal/inbound/server_test.go | 2 +- src/api/admin/channels-reports.api.ts | 4 +- src/api/admin/governance.api.ts | 3 +- src/api/admin/operations.api.ts | 8 +- src/api/types/channels-reports.ts | 9 ++ src/api/types/governance.ts | 5 + src/api/types/operations.ts | 23 ++++ src/apps/LoginPage.tsx | 6 +- src/apps/admin/AdminAnalyticsPage.tsx | 123 ++++++++++-------- src/apps/admin/AdminChannelGroupsPage.tsx | 56 ++++++-- src/apps/admin/AdminChannelReportPage.tsx | 9 +- src/apps/admin/AdminCustomerFormPage.tsx | 56 ++++---- src/apps/admin/AdminMonitorPage.tsx | 18 ++- src/apps/admin/AdminProfitReportsPage.tsx | 15 ++- src/apps/admin/AdminQualityReportsPage.tsx | 13 +- .../admin/AdminReconciliationReportsPage.tsx | 15 ++- src/apps/admin/AdminReportMaterialsPage.tsx | 4 +- src/apps/admin/AdminRiskRulesPage.tsx | 2 +- src/apps/admin/AdminSmsRecordsPage.tsx | 27 ++-- src/apps/admin/AdminSystemLogsPage.tsx | 33 ++++- src/apps/admin/channels/ChannelTable.tsx | 13 +- .../signature.helpers.tsx | 8 +- .../admin/sms-records/SmsRecordFilter.tsx | 12 +- src/apps/admin/sms-records/smsRecordTypes.ts | 2 +- src/layouts/AdminLayout.tsx | 15 ++- src/layouts/AppShell.tsx | 25 +++- src/styles/admin.css | 38 ++++++ src/styles/global.css | 68 +++++----- src/utils/successRate.ts | 17 +++ 65 files changed, 1211 insertions(+), 293 deletions(-) create mode 100644 api/prisma/migrations/20260809130000_add_protocol_log_plain_phone/migration.sql create mode 100644 api/src/common/report-status.spec.ts create mode 100644 api/src/common/report-status.ts create mode 100644 src/utils/successRate.ts diff --git a/api/prisma/migrations/20260809130000_add_protocol_log_plain_phone/migration.sql b/api/prisma/migrations/20260809130000_add_protocol_log_plain_phone/migration.sql new file mode 100644 index 0000000..77aaf3c --- /dev/null +++ b/api/prisma/migrations/20260809130000_add_protocol_log_plain_phone/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "ProtocolInteractionLog" +ADD COLUMN "phoneNumber" TEXT; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 2685b7c..393ea28 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -203,6 +203,7 @@ model ProtocolInteractionLog { traceId String? requestId String? phoneMasked String? + phoneNumber String? resultCode String? durationMs Int? payloadBytes Int? diff --git a/api/src/channels/channel-group-routing.service.ts b/api/src/channels/channel-group-routing.service.ts index dfb4277..18cde63 100644 --- a/api/src/channels/channel-group-routing.service.ts +++ b/api/src/channels/channel-group-routing.service.ts @@ -15,6 +15,7 @@ export class ChannelGroupRoutingService { listGroups() { return this.prisma.smsChannelGroup.findMany({ + where: { status: { not: 'deleted' } }, include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, orderBy: { createdAt: 'desc' }, }); @@ -158,23 +159,78 @@ export class ChannelGroupRoutingService { }); } - async deleteGroup(groupId: string) { - const group = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } }); + async getGroupDeletionImpact(groupId: string) { + const group = await this.prisma.smsChannelGroup.findUnique({ + where: { id: groupId }, + select: { id: true, name: true, items: { select: { id: true } } }, + }); if (!group) { throw new NotFoundException('Channel group not found'); } - const boundRoute = await this.prisma.channelRouteRule.findFirst({ - where: { - groupId, - status: 'active', - }, - select: { id: true }, + const routes = await this.prisma.channelRouteRule.findMany({ + where: { groupId, applicationId: { not: null }, status: { not: 'deleted' } }, + select: { applicationId: true }, }); - if (boundRoute) { - throw new BadRequestException('Channel group is used by application route rules and cannot be deleted'); + const applicationIds = [...new Set(routes.flatMap((route) => route.applicationId ? [route.applicationId] : []))]; + const [applications, pendingSupplierSubmitCount] = await Promise.all([ + this.prisma.smsApplication.findMany({ + where: { id: { in: applicationIds } }, + select: { id: true, status: true }, + }), + this.prisma.smsSubmitRecord.count({ + where: { channelGroupId: groupId, submitStatus: 'queued' }, + }), + ]); + const applicationStatusById = new Map(applications.map((application) => [application.id, application.status])); + const deletedApplicationCount = applicationIds.filter((applicationId) => { + const status = applicationStatusById.get(applicationId); + return status === undefined || status === 'deleted'; + }).length; + + return { + groupId: group.id, + groupName: group.name, + normalApplicationCount: applicationIds.length - deletedApplicationCount, + deletedApplicationCount, + channelCount: group.items.length, + pendingSupplierSubmitCount, + }; + } + + async deleteGroup(groupId: string) { + const group = await this.prisma.smsChannelGroup.findUnique({ + where: { id: groupId }, + include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } }, + }); + if (!group) { + throw new NotFoundException('Channel group not found'); } - await this.prisma.smsChannelGroupItem.deleteMany({ where: { groupId } }); - return this.prisma.smsChannelGroup.delete({ where: { id: groupId } }); + if (group.status === 'deleted') { + return group; + } + const impact = await this.getGroupDeletionImpact(groupId); + + // Logical deletion keeps group items and route bindings available for historical + // receipts and uplink access-number matching; new submits already require an active group. + return this.prisma.$transaction(async (tx) => { + const deleted = await tx.smsChannelGroup.update({ + where: { id: groupId }, + data: { status: 'deleted' }, + }); + await tx.operationLog.create({ + data: { + action: 'sms_channel_group.delete', + resource: 'sms_channel_group', + resourceId: groupId, + detail: { + before: channelGroupAuditSnapshot(group), + impact, + deletionMode: 'soft_delete', + } as Prisma.InputJsonValue, + }, + }); + return deleted; + }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); } listRouteRules() { diff --git a/api/src/channels/channels.controller.ts b/api/src/channels/channels.controller.ts index 6d8b133..4efed59 100644 --- a/api/src/channels/channels.controller.ts +++ b/api/src/channels/channels.controller.ts @@ -119,6 +119,11 @@ export class ChannelsController { return this.channels.updateGroup(groupId, body); } + @Get('channel-groups/:id/deletion-impact') + getGroupDeletionImpact(@Param('id') groupId: string) { + return this.channels.getGroupDeletionImpact(groupId); + } + @Delete('channel-groups/:id') @RequireRecentAuthentication() deleteGroup(@Param('id') groupId: string) { diff --git a/api/src/channels/channels.helpers.ts b/api/src/channels/channels.helpers.ts index 33b8fea..8050df7 100644 --- a/api/src/channels/channels.helpers.ts +++ b/api/src/channels/channels.helpers.ts @@ -1,8 +1,13 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { randomUUID } from 'crypto'; +import { summarizeReportStatuses as summarizeCommonReportStatuses } from '../common/report-status'; import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; +export function summarizeReportStatuses(statuses: string[]) { + return summarizeCommonReportStatuses(statuses); +} + /** Constants and pure validation/normalization helpers shared by R5 domains. */ export const GATEWAY_CONNECTION_QUEUE = 'gateway.connection.commands'; @@ -654,17 +659,6 @@ export function normalizeReportType(value?: string) { throw new BadRequestException('reportType must be signature, drainage or both'); } -export function summarizeReportStatuses(statuses: string[]) { - if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 }; - const approved = statuses.filter((status) => status === 'approved').length; - let status = 'pending'; - if (approved === statuses.length) status = 'approved'; - else if (statuses.some((item) => ['failed', 'rejected'].includes(item))) status = 'failed'; - else if (statuses.some((item) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(item)) || approved > 0) status = 'reporting'; - else if (statuses.some((item) => item === 'waiting_material')) status = 'waiting_material'; - return { status, approved, total: statuses.length }; -} - export function normalizeLinkEvent(action: string) { if (action.includes('connect_requested')) { return '连接请求'; diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 9f5a943..f0bf95d 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -81,7 +81,7 @@ function createPrismaMock() { channelHealthMetric: { findMany: jest.fn() }, smsChannelGroup: { findMany: jest.fn(), - findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320 }), + findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [] }), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })), delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }), }, @@ -137,6 +137,7 @@ function createPrismaMock() { }, smsApplication: { findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }), + findMany: jest.fn().mockResolvedValue([]), }, tenant: { findFirst: jest.fn().mockResolvedValue({ id: 'tenant-1', createdAt: new Date('2026-07-09T00:00:00.000Z') }), @@ -153,6 +154,7 @@ function createPrismaMock() { }, smsSubmitRecord: { create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'submit-record-1', ...data })), + count: jest.fn().mockResolvedValue(0), }, cmppConnectionState: { findMany: jest.fn(), @@ -866,16 +868,60 @@ describe('ChannelsService', () => { expect(prisma.channelRouteRule.create).not.toHaveBeenCalled(); }); - it('deletes channel groups only when no active route rule is bound', async () => { + it('counts distinct normal and deleted applications, channels, and queued supplier submits before deletion', async () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); + prisma.smsChannelGroup.findUnique.mockResolvedValueOnce({ id: 'group-1', name: '移动主通道组', items: [{ id: 'item-1' }, { id: 'item-2' }] }); + prisma.channelRouteRule.findMany.mockResolvedValueOnce([ + { applicationId: 'app-active' }, + { applicationId: 'app-active' }, + { applicationId: 'app-deleted' }, + { applicationId: 'app-missing' }, + ]); + prisma.smsApplication.findMany.mockResolvedValueOnce([ + { id: 'app-active', status: 'active' }, + { id: 'app-deleted', status: 'deleted' }, + ]); + prisma.smsSubmitRecord.count.mockResolvedValueOnce(2); - await service.deleteGroup('group-1'); - expect(prisma.smsChannelGroupItem.deleteMany).toHaveBeenCalledWith({ where: { groupId: 'group-1' } }); - expect(prisma.smsChannelGroup.delete).toHaveBeenCalledWith({ where: { id: 'group-1' } }); + await expect(service.getGroupDeletionImpact('group-1')).resolves.toEqual({ + groupId: 'group-1', + groupName: '移动主通道组', + normalApplicationCount: 1, + deletedApplicationCount: 2, + channelCount: 2, + pendingSupplierSubmitCount: 2, + }); + expect(prisma.smsSubmitRecord.count).toHaveBeenCalledWith({ + where: { channelGroupId: 'group-1', submitStatus: 'queued' }, + }); + }); - prisma.channelRouteRule.findFirst.mockResolvedValueOnce({ id: 'route-1' }); - await expect(service.deleteGroup('group-1')).rejects.toThrow('Channel group is used by application route rules'); + it('logically deletes channel groups without removing application bindings or group items', async () => { + const prisma = createPrismaMock(); + const service = new ChannelsService(prisma as never); + const groupUpdate = jest.fn().mockResolvedValue({ id: 'group-1', status: 'deleted' }); + const operationLogCreate = jest.fn(); + prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }]); + prisma.smsApplication.findMany.mockResolvedValue([{ id: 'app-1', status: 'active' }]); + prisma.$transaction.mockImplementationOnce((callback) => callback({ + smsChannelGroup: { update: groupUpdate }, + operationLog: { create: operationLogCreate }, + })); + + await expect(service.deleteGroup('group-1')).resolves.toEqual({ id: 'group-1', status: 'deleted' }); + expect(groupUpdate).toHaveBeenCalledWith({ where: { id: 'group-1' }, data: { status: 'deleted' } }); + expect(prisma.smsChannelGroupItem.deleteMany).not.toHaveBeenCalled(); + expect(prisma.smsChannelGroup.delete).not.toHaveBeenCalled(); + expect(operationLogCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'sms_channel_group.delete', + detail: expect.objectContaining({ + deletionMode: 'soft_delete', + impact: expect.objectContaining({ normalApplicationCount: 1 }), + }), + }), + }); }); it('upserts signature report material per channel field', async () => { diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 88f74fa..0725132 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -114,6 +114,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy { return this.groups.deleteGroup(groupId); } + getGroupDeletionImpact(groupId: string) { + return this.groups.getGroupDeletionImpact(groupId); + } + listRouteRules() { return this.groups.listRouteRules(); } diff --git a/api/src/common/report-status.spec.ts b/api/src/common/report-status.spec.ts new file mode 100644 index 0000000..bb23804 --- /dev/null +++ b/api/src/common/report-status.spec.ts @@ -0,0 +1,14 @@ +import { summarizeReportStatuses } from './report-status'; + +describe('summarizeReportStatuses', () => { + it.each([ + [[], { status: 'not_applicable', approved: 0, total: 0 }], + [['approved', 'approved'], { status: 'approved', approved: 2, total: 2 }], + [['failed', 'rejected'], { status: 'failed', approved: 0, total: 2 }], + [['approved', 'failed'], { status: 'partial_success', approved: 1, total: 2 }], + [['failed', 'pending'], { status: 'reporting', approved: 0, total: 2 }], + [['waiting_material', 'pending'], { status: 'waiting_material', approved: 0, total: 2 }], + ])('summarizes %j without allowing one failure to override other targets', (statuses, expected) => { + expect(summarizeReportStatuses(statuses)).toEqual(expected); + }); +}); diff --git a/api/src/common/report-status.ts b/api/src/common/report-status.ts new file mode 100644 index 0000000..0298f9f --- /dev/null +++ b/api/src/common/report-status.ts @@ -0,0 +1,29 @@ +export type ReportStatusSummary = { + status: string; + approved: number; + total: number; +}; + +const FAILED_REPORT_STATUSES = new Set(['failed', 'rejected']); + +export function summarizeReportStatuses(statuses: string[]): ReportStatusSummary { + if (!statuses.length) return { status: 'not_applicable', approved: 0, total: 0 }; + + const approved = statuses.filter((status) => status === 'approved').length; + const failed = statuses.filter((status) => FAILED_REPORT_STATUSES.has(status)).length; + + if (approved === statuses.length) return { status: 'approved', approved, total: statuses.length }; + + // Overall failure means every current target failed. A single failed channel must not + // erase successful channels or targets that can still finish reporting. + if (failed === statuses.length) return { status: 'failed', approved, total: statuses.length }; + if (approved > 0) return { status: 'partial_success', approved, total: statuses.length }; + if (failed > 0) return { status: 'reporting', approved, total: statuses.length }; + if (statuses.some((status) => ['reporting', 'exporting', 'partial', 'partial_success'].includes(status))) { + return { status: 'reporting', approved, total: statuses.length }; + } + if (statuses.some((status) => status === 'waiting_material')) { + return { status: 'waiting_material', approved, total: statuses.length }; + } + return { status: 'pending', approved, total: statuses.length }; +} diff --git a/api/src/deletion-governance/deletion-governance.service.spec.ts b/api/src/deletion-governance/deletion-governance.service.spec.ts index b6c4c60..c8852c0 100644 --- a/api/src/deletion-governance/deletion-governance.service.spec.ts +++ b/api/src/deletion-governance/deletion-governance.service.spec.ts @@ -68,6 +68,29 @@ describe('DeletionGovernanceService', () => { ])); }); + it('does not classify approved or abandoned report history as unfinished', async () => { + const { service, prisma } = setup(); + prisma.smsSignature.findFirst.mockResolvedValue({ + id: 'signature-1', name: '示例签名', auditStatus: 'approved', updatedAt: now, + tenant: { name: '示例企业' }, application: { name: '验证码应用' }, + templates: [], drainageItems: [], reportTasks: [], + }); + + const result = await service.preflight('signature', 'signature-1', 'tenant-1'); + + expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith(expect.objectContaining({ + include: expect.objectContaining({ + reportTasks: expect.objectContaining({ + where: { status: { notIn: expect.arrayContaining(['approved', 'abandoned']) } }, + }), + }), + })); + expect(result.dependencies).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'report_tasks', count: 0 }), + ])); + expect(result.allowedActions).toEqual(['delete']); + }); + it('requires version, idempotency key and a meaningful reason', async () => { const { service } = setup(); await expect(service.delete('template', 'template-1', {})).rejects.toBeInstanceOf(BadRequestException); diff --git a/api/src/deletion-governance/deletion-governance.service.ts b/api/src/deletion-governance/deletion-governance.service.ts index d4dcdd6..d929f6e 100644 --- a/api/src/deletion-governance/deletion-governance.service.ts +++ b/api/src/deletion-governance/deletion-governance.service.ts @@ -13,6 +13,10 @@ export type DeleteTargetDto = { type Dependency = { kind: string; label: string; count: number; items: string[] }; +// Report tasks use more terminal values than generic send tasks. Keep this explicit so +// completed approval and deliberately abandoned history do not block signature deletion. +const TERMINAL_REPORT_TASK_STATUSES = ['approved', 'completed', 'failed', 'cancelled', 'rejected', 'abandoned', 'partial', 'partial_success']; + export type DeletionPreflight = { type: DeletionTargetType; id: string; @@ -114,7 +118,7 @@ export class DeletionGovernanceService { tenant: { select: { name: true } }, application: { select: { name: true } }, templates: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, name: true } }, drainageItems: { where: { auditStatus: { not: 'deleted' } }, select: { id: true, siteName: true } }, - reportTasks: { where: { status: { notIn: ['completed', 'failed', 'cancelled', 'rejected'] } }, select: { id: true, status: true } }, + reportTasks: { where: { status: { notIn: TERMINAL_REPORT_TASK_STATUSES } }, select: { id: true, status: true } }, }, }); if (!item) throw new NotFoundException('签名不存在或无权访问'); diff --git a/api/src/dictionaries/dictionaries.controller.ts b/api/src/dictionaries/dictionaries.controller.ts index 55473bb..522ca3f 100644 --- a/api/src/dictionaries/dictionaries.controller.ts +++ b/api/src/dictionaries/dictionaries.controller.ts @@ -19,6 +19,11 @@ import { export class DictionariesController { constructor(private readonly dictionaries: DictionariesService) {} + @Get('administrative-regions') + listAdministrativeRegions() { + return this.dictionaries.listAdministrativeRegions(); + } + @Get('phone-segments') listPhoneSegments( @Query('keyword') keyword?: string, diff --git a/api/src/dictionaries/dictionaries.service.spec.ts b/api/src/dictionaries/dictionaries.service.spec.ts index baf94f1..7624663 100644 --- a/api/src/dictionaries/dictionaries.service.spec.ts +++ b/api/src/dictionaries/dictionaries.service.spec.ts @@ -58,6 +58,28 @@ function createPrismaMock() { } describe('DictionariesService', () => { + it('builds the enterprise province and city library from distinct real phone segment regions', async () => { + const prisma = createPrismaMock(); + prisma.phoneSegment.findMany.mockResolvedValue([ + { province: '山东', city: '青岛' }, + { province: '山东', city: '济南' }, + { province: '山东', city: '济南' }, + { province: '江苏', city: '苏州' }, + { province: ' ', city: '无效' }, + ]); + const service = new DictionariesService(prisma as never); + + await expect(service.listAdministrativeRegions()).resolves.toEqual([ + { province: '江苏', cities: ['苏州'] }, + { province: '山东', cities: ['济南', '青岛'] }, + ]); + expect(prisma.phoneSegment.findMany).toHaveBeenCalledWith({ + where: { province: { not: null } }, + select: { province: true, city: true }, + distinct: ['province', 'city'], + }); + }); + it('deletes a phone segment from the real dictionary table', async () => { const prisma = createPrismaMock(); const service = new DictionariesService(prisma as never); diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index e101d0b..c9fad85 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -111,6 +111,27 @@ export class DictionariesService { @Optional() private readonly phoneRoutingLookup?: PhoneRoutingLookupService, ) {} + async listAdministrativeRegions() { + const rows = await this.prisma.phoneSegment.findMany({ + where: { province: { not: null } }, + select: { province: true, city: true }, + distinct: ['province', 'city'], + }); + const citiesByProvince = new Map>(); + for (const row of rows) { + const province = row.province?.trim(); + if (!province) continue; + const cities = citiesByProvince.get(province) ?? new Set(); + const city = row.city?.trim(); + if (city) cities.add(city); + citiesByProvince.set(province, cities); + } + return Array.from(citiesByProvince, ([province, cities]) => ({ + province, + cities: Array.from(cities).sort((left, right) => left.localeCompare(right, 'zh-CN')), + })).sort((left, right) => left.province.localeCompare(right.province, 'zh-CN')); + } + async listPhoneSegments(query: PhoneSegmentListQuery = {}) { const page = Math.max(1, Number(query.page ?? 1)); const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25))); diff --git a/api/src/protocol-logs/protocol-logs.service.spec.ts b/api/src/protocol-logs/protocol-logs.service.spec.ts index 2ed407d..2a9b671 100644 --- a/api/src/protocol-logs/protocol-logs.service.spec.ts +++ b/api/src/protocol-logs/protocol-logs.service.spec.ts @@ -20,7 +20,7 @@ describe('ProtocolLogsService', () => { prisma.protocolInteractionLog.groupBy.mockResolvedValue([]); }); - it('buffers a masked and secret-free business event', async () => { + it('buffers a full-phone and secret-free business event', async () => { const service = new ProtocolLogsService(prisma as never); service.record({ protocol: 'cmpp', @@ -36,7 +36,7 @@ describe('ProtocolLogsService', () => { expect(prisma.protocolInteractionLog.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ - phoneMasked: '188****3795', + phoneNumber: '18821203795', gatewayMessageId: '123', detail: { sequenceId: 7 }, })], @@ -65,4 +65,15 @@ describe('ProtocolLogsService', () => { }), })); }); + + it('queries the full phone number field', async () => { + const service = new ProtocolLogsService(prisma as never); + await service.list({ keyword: '18821203795' }); + + expect(prisma.protocolInteractionLog.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.arrayContaining([{ phoneNumber: { contains: '18821203795' } }]), + }), + })); + }); }); diff --git a/api/src/protocol-logs/protocol-logs.service.ts b/api/src/protocol-logs/protocol-logs.service.ts index 64d82e7..bb53895 100644 --- a/api/src/protocol-logs/protocol-logs.service.ts +++ b/api/src/protocol-logs/protocol-logs.service.ts @@ -77,7 +77,7 @@ export class ProtocolLogsService implements OnModuleInit, OnModuleDestroy { gatewayMessageId: input.gatewayMessageId == null ? null : String(input.gatewayMessageId).slice(0, 128), traceId: clean(input.traceId, 128), requestId: clean(input.requestId, 128), - phoneMasked: maskPhone(input.phone), + phoneNumber: clean(input.phone, 32), resultCode: input.resultCode == null ? null : String(input.resultCode).slice(0, 64), durationMs: safeInteger(input.durationMs), payloadBytes: safeInteger(input.payloadBytes), @@ -102,7 +102,7 @@ export class ProtocolLogsService implements OnModuleInit, OnModuleDestroy { { requestId: { contains: query.keyword } }, { traceId: { contains: query.keyword } }, { account: { contains: query.keyword } }, - { phoneMasked: { contains: query.keyword } }, + { phoneNumber: { contains: query.keyword } }, { resultCode: { contains: query.keyword } }, ] : undefined, }; @@ -151,12 +151,6 @@ function clean(value: unknown, max = 191) { return text ? text.slice(0, max) : null; } -function maskPhone(value: unknown) { - const text = String(value ?? '').replace(/\D/g, ''); - if (!text) return null; - return text.length >= 7 ? `${text.slice(0, 3)}****${text.slice(-4)}` : `***${text.slice(-2)}`; -} - function safeInteger(value: unknown) { const number = Number(value); return Number.isSafeInteger(number) && number >= 0 ? number : null; diff --git a/api/src/reports/reports.service.spec.ts b/api/src/reports/reports.service.spec.ts index 5ea8597..3843ffc 100644 --- a/api/src/reports/reports.service.spec.ts +++ b/api/src/reports/reports.service.spec.ts @@ -8,9 +8,9 @@ describe('ReportsService', () => { $executeRaw: jest.fn(), }; const prisma = { - dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn() }, - dailyProfitReport: { findMany: jest.fn(), count: jest.fn() }, - dailyQualityReport: { findMany: jest.fn(), count: jest.fn() }, + dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() }, + dailyProfitReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() }, + dailyQualityReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() }, $transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)), }; let service: ReportsService; @@ -23,10 +23,13 @@ describe('ReportsService', () => { tx.$executeRaw.mockResolvedValue(0); prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]); prisma.dailyReconciliationReport.count.mockResolvedValue(1); + prisma.dailyReconciliationReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } }); prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1' }]); prisma.dailyProfitReport.count.mockResolvedValue(1); + prisma.dailyProfitReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, revenueCents: BigInt(1000), refundCents: BigInt(100), costCents: BigInt(600), profitCents: BigInt(400) } }); prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]); prisma.dailyQualityReport.count.mockResolvedValue(1); + prisma.dailyQualityReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } }); service = new ReportsService(prisma as never); }); @@ -62,7 +65,7 @@ describe('ReportsService', () => { applicationId: 'app-1', page: 2, pageSize: 500, - })).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100 }); + })).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100, summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } }); expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }), skip: 100, @@ -71,7 +74,9 @@ describe('ReportsService', () => { }); it('keeps application and channel profit filters separate', async () => { - await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' }); + await expect(service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' })).resolves.toEqual(expect.objectContaining({ + summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }), + })); expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ dimensionType: 'channel', @@ -82,9 +87,23 @@ describe('ReportsService', () => { })); }); + it('returns zero full-result totals and rates when a filtered report has no rows', async () => { + prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]); + prisma.dailyProfitReport.count.mockResolvedValueOnce(0); + prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({ + _sum: { submittedUnits: null, sentUnits: null, unknownUnits: null, successUnits: null, failedUnits: null, revenueCents: null, refundCents: null, costCents: null, profitCents: null }, + }); + + await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(expect.objectContaining({ + total: 0, + summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, refundCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 }, + })); + }); + it('sorts quality reports by send volume and keeps the selected dimension', async () => { await expect(service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 })).resolves.toEqual({ items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage', + summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, successRateBps: 7000 }, }); expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }), diff --git a/api/src/reports/reports.service.ts b/api/src/reports/reports.service.ts index f29dddd..8827457 100644 --- a/api/src/reports/reports.service.ts +++ b/api/src/reports/reports.service.ts @@ -45,31 +45,51 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { async listReconciliation(query: ReportListQuery) { const { page, pageSize, skip } = pagination(query); const where = reconciliationWhere(query); - const [items, total] = await Promise.all([ + const [items, total, aggregate] = await Promise.all([ this.prisma.dailyReconciliationReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], skip, take: pageSize }), this.prisma.dailyReconciliationReport.count({ where }), + this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }), ]); - return { items, total, page, pageSize }; + return { items, total, page, pageSize, summary: volumeSummary(aggregate._sum) }; } async listProfit(query: ReportListQuery) { const { page, pageSize, skip } = pagination(query); const { dimensionType, where } = profitWhere(query); - const [items, total] = await Promise.all([ + const [items, total, aggregate] = await Promise.all([ this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }), this.prisma.dailyProfitReport.count({ where }), + this.prisma.dailyProfitReport.aggregate({ + where, + _sum: { ...reportVolumeSumSelection, revenueCents: true, refundCents: true, costCents: true, profitCents: true }, + }), ]); - return { items, total, page, pageSize, dimensionType }; + const summary = { + ...volumeSummary(aggregate._sum), + revenueCents: Number(aggregate._sum.revenueCents ?? 0), + refundCents: Number(aggregate._sum.refundCents ?? 0), + costCents: Number(aggregate._sum.costCents ?? 0), + profitCents: Number(aggregate._sum.profitCents ?? 0), + // 利润率必须用全量筛选结果的合计利润/合计收入重新计算,不能对每日百分比求和或简单平均。 + profitRateBps: ratioBps(Number(aggregate._sum.profitCents ?? 0), Number(aggregate._sum.revenueCents ?? 0)), + }; + return { items, total, page, pageSize, dimensionType, summary }; } async listQuality(query: ReportListQuery) { const { page, pageSize, skip } = pagination(query); const { dimensionType, where } = qualityWhere(query); - const [items, total] = await Promise.all([ + const [items, total, aggregate] = await Promise.all([ this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }), this.prisma.dailyQualityReport.count({ where }), + this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }), ]); - return { items, total, page, pageSize, dimensionType }; + const summary = { + ...volumeSummary(aggregate._sum), + // 成功率按全量筛选结果的成功量/发送量重新计算,避免分页和分组大小导致失真。 + successRateBps: ratioBps(Number(aggregate._sum.successUnits ?? 0), Number(aggregate._sum.sentUnits ?? 0)), + }; + return { items, total, page, pageSize, dimensionType, summary }; } async exportReconciliation(query: ReportListQuery) { @@ -516,6 +536,28 @@ function pagination(query: ReportListQuery) { return { page, pageSize, skip: (page - 1) * pageSize }; } +const reportVolumeSumSelection = { + submittedUnits: true, + sentUnits: true, + unknownUnits: true, + successUnits: true, + failedUnits: true, +} as const; + +function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number | null; unknownUnits?: number | null; successUnits?: number | null; failedUnits?: number | null }) { + return { + submittedUnits: Number(sum.submittedUnits ?? 0), + sentUnits: Number(sum.sentUnits ?? 0), + unknownUnits: Number(sum.unknownUnits ?? 0), + successUnits: Number(sum.successUnits ?? 0), + failedUnits: Number(sum.failedUnits ?? 0), + }; +} + +function ratioBps(numerator: number, denominator: number) { + return denominator === 0 ? 0 : Math.round(numerator * 10_000 / denominator); +} + function reconciliationWhere(query: ReportListQuery): Prisma.DailyReconciliationReportWhereInput { return { reportDate: dateFilter(query.dateFrom, query.dateTo), tenantId: query.tenantId || undefined, applicationId: query.applicationId || undefined }; } diff --git a/api/src/send-chain/drainage-content-detection.spec.ts b/api/src/send-chain/drainage-content-detection.spec.ts index daf3051..d0538d0 100644 --- a/api/src/send-chain/drainage-content-detection.spec.ts +++ b/api/src/send-chain/drainage-content-detection.spec.ts @@ -11,7 +11,6 @@ describe('drainage content detection', () => { ['裸域名', '访问 t.cn/a1 查看详情', 'url'], ['IP 链接', '入口 192.168.1.10:8080/path。', 'url'], ['中文句号拆分域名', '请访问 example。com 领取', 'url'], - ['空格拆分域名', '请访问 ex ample . com 领取', 'url'], ['+86 和空格手机号', '电话 +86 138 0013 8000', 'mobile'], ['短横线手机号', '电话 138-0013-8000', 'mobile'], ['括号区号和分机', '致电(010)8888-8888 转 123', 'landline'], @@ -26,6 +25,23 @@ describe('drainage content detection', () => { expect(detectDrainageContentWithRules('邮箱 13800138000 @ example . com', rules).hasDrainageContent).toBe(false); }); + it.each([' ', '\t', '\n', '\u3000'])('stops a URL match at whitespace %p', (separator) => { + const url = 'https://example.com/path'; + const suffix = '后续字符不属于链接'; + const content = `详情 ${url}${separator}${suffix}`; + const result = detectDrainageContentWithRules(content, rules); + const urlMatches = (result.drainageDetection as { matches: Array<{ category: string; text: string; normalizedText: string }> }) + .matches.filter((item) => item.category === 'url'); + + expect(urlMatches).toHaveLength(1); + expect(urlMatches[0]).toMatchObject({ text: url, normalizedText: url }); + }); + + it('does not join a domain split by spaces into one URL', () => { + const result = detectDrainageContentWithRules('请访问 ex ample . com 领取', rules); + expect(result.hasDrainageContent).toBe(false); + }); + it('keeps original offsets for record-page highlighting', () => { const content = '📨详情请看 example。com/path,谢谢'; const result = detectDrainageContentWithRules(content, rules); diff --git a/api/src/send-chain/drainage-content-detection.ts b/api/src/send-chain/drainage-content-detection.ts index da7f93b..2565f59 100644 --- a/api/src/send-chain/drainage-content-detection.ts +++ b/api/src/send-chain/drainage-content-detection.ts @@ -83,7 +83,10 @@ function normalizeContent(content: string, category: DrainageDetectionCategory): .replace(/[()]/g, (char) => char === '(' ? '(' : ')') .replace(/[+]/g, '+'); if (category === 'url') { - // 链接常被空格或中文句号拆开;句末中文句号也安全地成为正则边界。 + // Whitespace is a URL boundary: removing it would incorrectly join the suffix into the link. + normalized = normalized.replace(/。/g, '.'); + } else if (category === 'email') { + // Email exclusion keeps its broader normalization so spaced emails cannot leak into phone/URL matches. normalized = normalized.replace(/\s+/gu, '').replace(/。/g, '.'); } else if (category === 'mobile' || category === 'landline') { // 电话号码仅在检测副本中去除常见规避分隔符,绝不改写实际发送内容。 @@ -131,7 +134,7 @@ export function detectDrainageContentWithRules( ): DrainageDetectionResult { const matches: DrainageDetectionMatch[] = []; const normalizedByCategory = new Map(); - const emailNormalized = normalizeContent(content, 'url'); + const emailNormalized = normalizeContent(content, 'email'); const originalEmailRanges = emailRanges(emailNormalized).map((range) => sourceRange(emailNormalized, range.start, range.end)); for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) { validateDrainageDetectionPattern(rule.pattern, rule.flags); diff --git a/api/src/send-chain/send-chain.contracts.ts b/api/src/send-chain/send-chain.contracts.ts index 61839e1..976b092 100644 --- a/api/src/send-chain/send-chain.contracts.ts +++ b/api/src/send-chain/send-chain.contracts.ts @@ -25,6 +25,8 @@ export interface GatewayInboundAuthDto { authSource?: string; timestamp?: number; remoteIp?: string; + version?: string; + requestedVersion?: number; } export interface GatewayInboundSubmitDto { diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 628d59f..8be7512 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -983,19 +983,34 @@ describe('SendChainService', () => { expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); }); - it('returns the application enterprise code after Gateway authentication', async () => { - const { service } = createService(); + it('returns the application enterprise code and audits the inbound parameters after Gateway authentication', async () => { + const { service, prisma } = createService(); await expect(service.authenticateInboundApplication({ account: '100001', password: 'secret-hash', remoteIp: '127.0.0.1', + version: 'cmpp30', + requestedVersion: 48, })).resolves.toEqual(expect.objectContaining({ account: '100001', enterpriseCode: 'SP0001', maxConnections: 2, status: 'authenticated', })); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tenantId: 'tenant-1', + action: 'cmpp_connection.connect_requested', + resource: 'cmpp_downstream_connection', + resourceId: 'app-1', + ipAddress: '127.0.0.1', + detail: expect.objectContaining({ + result: 'authenticated', + request: expect.objectContaining({ account: '100001', password: 'secret-hash', version: 'cmpp30', requestedVersion: 48 }), + }), + }), + }); }); it('rejects Gateway authentication when application interface is disabled', async () => { @@ -1015,6 +1030,38 @@ describe('SendChainService', () => { password: 'secret-hash', remoteIp: '127.0.0.1', })).rejects.toThrow('CMPP interface is disabled for this application'); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tenantId: 'tenant-1', + ipAddress: '127.0.0.1', + detail: expect.objectContaining({ result: 'failed', error: 'CMPP interface is disabled for this application' }), + }), + }); + }); + + it('audits an unknown Gateway authentication account with its source IP', async () => { + const { service, prisma } = createService(); + prisma.smsApplication.findFirst.mockResolvedValue(null); + + await expect(service.authenticateInboundApplication({ + account: 'ATTACKER', + authSource: 'invalid-auth-source', + timestamp: 120000000, + remoteIp: '203.0.113.9', + version: 'cmpp30', + requestedVersion: 48, + })).rejects.toThrow('CMPP account is invalid or disabled'); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tenantId: undefined, + resourceId: 'ATTACKER', + ipAddress: '203.0.113.9', + detail: expect.objectContaining({ + result: 'failed', + request: expect.objectContaining({ account: 'ATTACKER', authSource: 'invalid-auth-source' }), + }), + }), + }); }); it('rejects new submissions synchronously when the application interface was disabled after bind', async () => { @@ -3308,6 +3355,8 @@ describe('SendChainService', () => { account: '100001', password: 'secret-hash', remoteIp: '127.0.0.1', + version: 'cmpp30', + requestedVersion: 48, })).resolves.toEqual(expect.objectContaining({ status: 'authenticated' })); await expect(service.submitInboundMessage({ account: '100001', diff --git a/api/src/send-chain/send-inbound-entry.service.ts b/api/src/send-chain/send-inbound-entry.service.ts index ba9e8c3..c8d353e 100644 --- a/api/src/send-chain/send-inbound-entry.service.ts +++ b/api/src/send-chain/send-inbound-entry.service.ts @@ -59,31 +59,77 @@ export class SendInboundEntryService { async authenticateInboundApplication(data: GatewayInboundAuthDto) { - const application = await this.facade.findInboundApplication(data.account); - if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') { - throw new BadRequestException('CMPP account is invalid or disabled'); + let tenantId: string | undefined; + let applicationId: string | undefined; + try { + const application = await this.facade.findInboundApplication(data.account); + tenantId = application?.tenantId; + applicationId = application?.id; + if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') { + throw new BadRequestException('CMPP account is invalid or disabled'); + } + if (!application.interfaceEnabled) { + throw new BadRequestException('CMPP interface is disabled for this application'); + } + if (application.tenant.certificationStatus !== 'approved') { + throw new BadRequestException('Enterprise certification is not approved'); + } + if (!matchesApplicationSecret(data, application.secretHash)) { + throw new BadRequestException('CMPP account or password is invalid'); + } + if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { + throw new BadRequestException('CMPP source IP is not in application allowlist'); + } + await this.recordInboundConnectRequest(data, { tenantId, applicationId, result: 'authenticated' }); + return { + applicationId: application.id, + tenantId: application.tenantId, + account: application.cmppAccount, + enterpriseCode: application.cmppEnterpriseCode, + passwordCipher: application.secretHash, + maxConnections: application.cmppMaxConnections, + status: 'authenticated', + }; + } catch (error) { + await this.recordInboundConnectRequest(data, { + tenantId, + applicationId, + result: 'failed', + error: error instanceof Error ? error.message : 'unknown error', + }); + throw error; } - if (!application.interfaceEnabled) { - throw new BadRequestException('CMPP interface is disabled for this application'); - } - if (application.tenant.certificationStatus !== 'approved') { - throw new BadRequestException('Enterprise certification is not approved'); - } - if (!matchesApplicationSecret(data, application.secretHash)) { - throw new BadRequestException('CMPP account or password is invalid'); - } - if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) { - throw new BadRequestException('CMPP source IP is not in application allowlist'); - } - return { - applicationId: application.id, - tenantId: application.tenantId, - account: application.cmppAccount, - enterpriseCode: application.cmppEnterpriseCode, - passwordCipher: application.secretHash, - maxConnections: application.cmppMaxConnections, - status: 'authenticated', - }; + } + + private recordInboundConnectRequest( + data: GatewayInboundAuthDto, + outcome: { tenantId?: string; applicationId?: string; result: 'authenticated' | 'failed'; error?: string }, + ) { + return this.prisma.operationLog.create({ + data: { + tenantId: outcome.tenantId, + action: 'cmpp_connection.connect_requested', + resource: 'cmpp_downstream_connection', + resourceId: outcome.applicationId ?? data.account, + ipAddress: data.remoteIp?.trim() || undefined, + detail: { + direction: 'client_to_platform', + result: outcome.result, + applicationId: outcome.applicationId ?? null, + request: { + remoteIp: data.remoteIp?.trim() || null, + account: data.account, + // Standard CMPP sends AuthenticatorSource rather than a plaintext password; keep both fields truthful. + password: data.password ?? null, + authSource: data.authSource ?? null, + timestamp: data.timestamp ?? null, + version: data.version ?? null, + requestedVersion: data.requestedVersion ?? null, + }, + error: outcome.error ?? null, + } as Prisma.InputJsonValue, + }, + }); } async submitInboundMessage(data: GatewayInboundSubmitDto) { diff --git a/api/src/sms-config/signature.service.ts b/api/src/sms-config/signature.service.ts index 473cf27..6726a78 100644 --- a/api/src/sms-config/signature.service.ts +++ b/api/src/sms-config/signature.service.ts @@ -10,6 +10,7 @@ import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL import { SmsReportValidationService } from './report-validation.service'; import { SmsAuditService } from './audit.service'; import { shanghaiDateRange } from '../common/shanghai-date-range'; +import { summarizeReportStatuses } from '../common/report-status'; /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsSignatureService { @@ -109,9 +110,7 @@ export class SmsSignatureService { return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { const carrierTargets = targets.filter((channel) => channel.carrier === carrier || channel.carrier === 'all'); const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []); - const approved = statuses.filter((status) => status === 'approved').length; - const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending'; - return [carrier, { status, approved, total: statuses.length }]; + return [carrier, summarizeReportStatuses(statuses)]; }))]; })), carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { @@ -119,9 +118,7 @@ export class SmsSignatureService { const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()]; const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task])); const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); - const approved = statuses.filter((status) => status === 'approved').length; - const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending'; - return [carrier, { status, approved, total: targets.length }]; + return [carrier, summarizeReportStatuses(statuses)]; })), }; }); diff --git a/docs/codebase-modularization-roadmap.md b/docs/codebase-modularization-roadmap.md index 09c6491..a601789 100644 --- a/docs/codebase-modularization-roadmap.md +++ b/docs/codebase-modularization-roadmap.md @@ -548,8 +548,8 @@ api/src/channels/ ``` `docs/contracts/channels-r5-methods.json` 与 -`tools/quality/verify-channels-r5.mjs` 固定 37 个公开方法、14 个内部方法、 -17 个契约及 60 个辅助声明,并专项锁定连接参数重连条件、Gateway +`tools/quality/verify-channels-r5.mjs` 固定 38 个公开方法、14 个内部方法、 +17 个契约及 61 个辅助声明,并专项锁定连接参数重连条件、Gateway 连接/断开路径、定时器、Redis队列、测试短信单次尝试和控制器兼容入口。 ### 版本 R6:拆分 Gateway 入站服务 diff --git a/docs/contracts/admin-api-r1-methods.json b/docs/contracts/admin-api-r1-methods.json index 38bbd04..572bff6 100644 --- a/docs/contracts/admin-api-r1-methods.json +++ b/docs/contracts/admin-api-r1-methods.json @@ -255,7 +255,7 @@ }, { "name": "listReconciliationReports", - "implementationSha256": "7d4dafd20064b8fb5ffec207c7656fc3807c805d8ebb112867751ef79b04c1c0" + "implementationSha256": "09c7c1e5797e2e7f9391db05e0ca8d5140a3496eb7aff34129af572e9cd4d58c" }, { "name": "exportReconciliationReports", @@ -263,7 +263,7 @@ }, { "name": "listProfitReports", - "implementationSha256": "d037e421b83586ada4758e1f3b0a5d1603c31295093b36f58f287c09ce98aed0" + "implementationSha256": "d5cda61c78b0762941c6dd7661501e1aea3cd33615a51dc86d1af66e48d669ea" }, { "name": "exportProfitReports", @@ -271,7 +271,7 @@ }, { "name": "listQualityReports", - "implementationSha256": "293d470b44d6ea12ff90d7497d985c3194386ff3ba1f6cedafb7a6d6925352ad" + "implementationSha256": "ba043613917d325272db8c6e5ca8b6d4fd202b03ac6ed522f1064ba271a36bda" }, { "name": "exportQualityReports", @@ -439,7 +439,11 @@ }, { "name": "updateChannelGroup", - "implementationSha256": "d01b2318706087800997b8f5d2e4dff6677b67bc92c1880211161a7dcea5d04f" + "implementationSha256": "d5f2722429048690cbed422d62729d8eee17bea6de4cc55dc187a629de991710" + }, + { + "name": "getChannelGroupDeletionImpact", + "implementationSha256": "61c7fcb2c90244f97a740a32ae44f6a6d04342d5f50dfcd72c9d51167b3503e2" }, { "name": "deleteChannelGroup", @@ -737,6 +741,10 @@ "name": "listPhoneSegments", "implementationSha256": "3faf0469c44dfdfe69029fa23f77983d7c0c888e181f24e7b8387b1ac87d4c97" }, + { + "name": "listAdministrativeRegions", + "implementationSha256": "3393f57588cef8eaa0e8cfd8a24cedc12fe6fffe4e2b3dadf90921fd1e58fb58" + }, { "name": "createPhoneSegment", "implementationSha256": "e93ff81cbf2a93fd2a50356e671755b28f5335cc4293b8aefd22f4dfbe00bf9f" diff --git a/docs/contracts/admin-enterprise-signatures-r4.json b/docs/contracts/admin-enterprise-signatures-r4.json index 7147600..3e889ab 100644 --- a/docs/contracts/admin-enterprise-signatures-r4.json +++ b/docs/contracts/admin-enterprise-signatures-r4.json @@ -13,7 +13,7 @@ }, { "name": "signatureCardVisual", - "canonicalSha256": "1cea544821c9c03b7c544644d5865e57810ae6f9c8d05d577ed104811ada63ec" + "canonicalSha256": "e4749077295323ea3ce85d7793c78b51ca76748ca7d830265af69f8189904023" }, { "name": "AuditStatusTag", diff --git a/docs/contracts/admin-shared-styles-r11.json b/docs/contracts/admin-shared-styles-r11.json index 6dc418a..75dc903 100644 --- a/docs/contracts/admin-shared-styles-r11.json +++ b/docs/contracts/admin-shared-styles-r11.json @@ -9,6 +9,7 @@ "admin-audit-", "admin-detail-metric-", "admin-report-filter-", + "admin-report-summary", "admin-security-", "admin-split-", "admin-system-", diff --git a/docs/contracts/channels-r5-methods.json b/docs/contracts/channels-r5-methods.json index 8f5374c..327aae8 100644 --- a/docs/contracts/channels-r5-methods.json +++ b/docs/contracts/channels-r5-methods.json @@ -166,7 +166,7 @@ { "name": "listGroups", "signature": "listGroups()", - "canonicalBodySha256": "fc4ae9bd701db6f55108aa057f42a56b283901b3401903edb5464058d5a96374", + "canonicalBodySha256": "3a6cbcd0159f9c2a380a34378007648f636e71be25802189d2821135c65351ba", "originalLines": [ 853, 858 @@ -203,10 +203,20 @@ ], "domain": "groups" }, + { + "name": "getGroupDeletionImpact", + "signature": "getGroupDeletionImpact(groupId: string)", + "canonicalBodySha256": "e57295fe8e1dd0f22b7845b8327a8d535af4d8a44d81e3bcccfcf95fb5269aad", + "originalLines": [ + 162, + 198 + ], + "domain": "groups" + }, { "name": "deleteGroup", "signature": "async deleteGroup(groupId: string)", - "canonicalBodySha256": "ca89f1d6861fa4a5808e1d3e6a21062223faeab172246d5615d8447672105800", + "canonicalBodySha256": "1c993f68537f1b6dbd25e904bfaa4bd65a71c05301f08d5d2f69a3dbb24803c6", "originalLines": [ 998, 1015 @@ -765,7 +775,7 @@ }, { "name": "summarizeReportStatuses", - "sha256": "ca9c3f0eece4b9e04f30cbc317041c52d63dcbb74ef1f87021dae5b20ca154b0" + "sha256": "98abf67ebafb41096119611949ac8105f08705b43abb940ed1e2dbbfa7e66d35" }, { "name": "normalizeLinkEvent", @@ -886,6 +896,7 @@ "createGroup", "addGroupItem", "updateGroup", + "getGroupDeletionImpact", "deleteGroup", "listRouteRules", "createRouteRule" diff --git a/docs/contracts/send-chain-r8-pure-logic.json b/docs/contracts/send-chain-r8-pure-logic.json index 2964d73..eaddce5 100644 --- a/docs/contracts/send-chain-r8-pure-logic.json +++ b/docs/contracts/send-chain-r8-pure-logic.json @@ -15,7 +15,7 @@ { "name": "GatewayInboundAuthDto", "kind": "interface", - "sha256": "1e55f6a2a393cd72c7aca2b4a1e18e293ae20f513412f627197515448c8da166" + "sha256": "bf53b9c9a55d28920d87d4d2a3154e6365d6a1ccbc94d64fc00c1e1059713ff5" }, { "name": "GatewayInboundSubmitDto", diff --git a/docs/contracts/send-chain-r9-submission.json b/docs/contracts/send-chain-r9-submission.json index 41db7a0..66e7d82 100644 --- a/docs/contracts/send-chain-r9-submission.json +++ b/docs/contracts/send-chain-r9-submission.json @@ -61,7 +61,7 @@ }, { "name": "authenticateInboundApplication", - "bodySha256": "2630dd1066b5a3e86c805d13102973e1c928ca2747bd0c3741481d03169eba2f", + "bodySha256": "22fc19937d6e104428f6e112c2881a8096978d46a52b5b883c23eeb58fbf0c1a", "file": "send-inbound-entry.service.ts" }, { diff --git a/docs/contracts/sms-config-r3-methods.json b/docs/contracts/sms-config-r3-methods.json index 0e955fe..7327865 100644 --- a/docs/contracts/sms-config-r3-methods.json +++ b/docs/contracts/sms-config-r3-methods.json @@ -204,8 +204,8 @@ { "name": "listSignatures", "signature": "async listSignatures(queryOrTenantId?: string | SignatureListQuery)", - "bodySha256": "bb4d8d983ed5cda59235b39396d09a568a554ad72c564ef4daab2d92dac6633a", - "canonicalBodySha256": "bb4d8d983ed5cda59235b39396d09a568a554ad72c564ef4daab2d92dac6633a", + "bodySha256": "bb60158b4e709ec24ac12b352c4d781bc35e3e87a51ab50cc63720011c4c910b", + "canonicalBodySha256": "bb60158b4e709ec24ac12b352c4d781bc35e3e87a51ab50cc63720011c4c910b", "originalLines": [ 915, 1025 diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index fde2bc1..cdd350c 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -1688,10 +1688,10 @@ ## 2026-07-24 CMPP/HTTP 通讯交互日志要求 -1. 运营端“系统日志”必须将人员操作审计与协议通讯日志分成两个独立页签。通讯日志至少支持协议、交互方向、事件类型、结果、关键字和时间范围过滤,并展示平台消息号、上游消息号或HTTP请求号、脱敏对象、结果码、耗时和安全详情。 +1. 运营端“系统日志”必须将人员操作审计与协议通讯日志分成两个独立页签。通讯日志至少支持协议、交互方向、事件类型、结果、关键字和时间范围过滤,并展示平台消息号、上游消息号或HTTP请求号、完整手机号或账号、结果码、耗时和安全详情。 2. CMPP应覆盖客户登录/Submit、供应商SubmitResp、状态报告Deliver、上行Deliver及平台下游投递;HTTP应覆盖客户发送请求和平台回执/上行Webhook。数据库中一条记录必须对应一个真实业务报文,不得把同一报文的“入口收到”和“处理成功”拆成两条记录;处理结果、结果码和耗时写在该报文同一条记录中,失败、重试等后续真实交互另行记录。 3. Gateway收到状态报告或上行后,必须对解包/解码失败及转发NestJS失败输出结构化安全日志;NestJS入口把业务处理结果合并回同一报文记录,以便区分“上游未发”“Gateway未收到”“Gateway转发失败”和“API落库失败”。一条正常短短信的供应商侧完整成功闭环应依次展示四个真实报文:平台→通道 `CMPP_SUBMIT`、通道→平台 `CMPP_SUBMIT_RESP`、通道→平台 `CMPP_DELIVER`、平台→通道 `CMPP_DELIVER_RESP`;箭头只表达报文实际传输方向。 -4. 通讯日志不得保存短信正文、密码、密钥、Token、签名鉴权值或完整HTTP请求体;手机号只保存脱敏值。CMPP心跳不得逐包写入数据库,连接健康仍使用连接状态和聚合指标。 +4. 通讯日志不得保存短信正文、密码、密钥、Token、签名鉴权值或完整HTTP请求体;手机号按完整明文保存、展示并支持关键字查询,不做脱敏。CMPP心跳不得逐包写入数据库,连接健康仍使用连接状态和聚合指标。 5. 通讯日志写入不能阻塞短信主链路,默认批量异步写入,缓冲区应有上限和溢出告警;热数据默认保留30天,保留期允许通过环境变量配置。 6. 通讯日志方向固定使用“企业应用 → 平台、平台 → 供应商通道、供应商通道 → 平台、平台 → 企业应用”。供应商长短信每个真实 `SUBMIT` 和 `SUBMIT_RESP` 分片各记一条,企业应用每个真实 `SUBMIT_RESP` 也必须记录;内部 `submit-result` 聚合回调不是协议报文,不得重复生成通讯日志。 7. 供应商长短信回执必须先写入对应 `SmsMessageSegmentAudit`。仅当同一提交尝试的全部分片均为 `delivered` 时,主记录才转 `delivered`;任一分片明确失败可进入最终失败/补发状态,分片尚未齐全时主记录保持 `submitted`,不得由首片成功提前聚合。内部业务终态只聚合一次,但对企业应用的CMPP状态报告必须按其原始Submit分片逐片投递,并分别使用平台当初为该分片返回的`CMPP_SUBMIT_RESP.Msg_Id`;HTTP Webhook仍按原HTTP消息投递一个最终事件。 @@ -1890,7 +1890,7 @@ - 本期只识别、记录、查询和统计短信内容是否含引流信息。引流资料是否已报备、审核状态及报备进度均不得拦截或转人工审核;所有发送入口移除`DRAINAGE_NOT_APPROVED`决策,既有模板、签名、余额、黑名单和其他风控规则保持不变。 - 运营端“系统管理”新增“引流识别规则”页面,规则存储在真实数据库,支持 URL、手机号码、固定电话三类表达式的新增、编辑、启停、优先级和测试。变更保留版本并写操作日志,发送入口按当前启用规则生成识别快照和规则版本。 -- URL 识别覆盖带协议链接、无`http://`的裸域名、短链接、IP 地址及端口/路径,并支持中文标点相邻、空格或中文句号拆分等规避写法;手机号码支持`+86`、空格、短横线和中文标点拆分;固定电话支持区号括号、分隔符和分机号。邮箱地址不属于引流信息。 +- URL 识别覆盖带协议链接、无`http://`的裸域名、短链接、IP 地址及端口/路径,并支持中文标点相邻和中文句号替代域名点号;URL遇到空格、制表符、换行或其他空白字符时必须立即结束,空白后的字符不得拼接进前一个链接,空白拆分的域名也不得恢复成一个URL。手机号码仍支持`+86`、空格、短横线和中文标点拆分;固定电话仍支持区号括号、分隔符和分机号。邮箱地址不属于引流信息。 - 识别规范化只作用于检测副本,不得修改真实短信发送内容。消息记录持久化是否含引流、命中类型、原文位置、规则版本和检测时间;历史未检测数据保留为“未检测”,不得伪造为不含引流。 - 运营端短信记录提供“是否含引流信息”筛选,支持含引流、不含引流和未检测;含引流记录使用提示色底色并高亮原文命中片段,CSV 同步导出该维度。 - 数据统计的签名发送质量明细保留原“通道 × 运营商”整体矩阵,并提供按含引流、不含引流、未检测切分的矩阵视图;整体统计必须直接反映全部提交,不得用分组平均值替代。 @@ -1915,3 +1915,58 @@ - 已按整条级成功形成`delivered`终态后,如果同一提交尝试又收到明确失败回执,平台保留原始回执和分片审计,但不得自动把已送达终态改成失败、重复退款或向客户推送互相矛盾的失败结果;系统按稳定异常键写入`SmsReceiptAnomaly`,重复矛盾回执累加发生次数。 - 运营菜单“Gateway提交异常”更名为“网关异常”,原路由保持兼容。页面使用“提交异常”和“回执异常”两个Tab,均查询真实后端和PostgreSQL;每个Tab必须在标题区说明其数据来源、业务含义、不能代表的结论及人工处理注意事项,避免运营人员间隔较久后误判。 - “提交异常”展示Gateway消费提交命令连续失败且没有明确供应商提交结果的死信,可在严格确认未被供应商接收后重新入队;“回执异常”展示供应商回执与平台既有终态冲突的结构化异常判定。回执异常详情必须指明真实回执保存在回执记录、原始CMPP报文在通讯交互日志,不得用异常摘要替代原始证据。 + +## 运营端休眠唤醒与会话锁定恢复(2026-08-09) + +- 同一单页应用内完成重新登录时,前端必须把本次成功登录视为新的用户活动起点,不得沿用上一会话或电脑休眠前的内存活动时间,避免新会话登录后被立即误锁。 +- 服务端返回`401/SESSION_LOCKED`或前端空闲计时触发锁定后,运营端必须同步暂停当前业务路由和全局待审核角标轮询;锁定期间不得继续请求短信记录、筛选项或`pending-audits`等受保护接口。 +- 全局角标轮询的启停必须跟随当前实时锁定状态,不得只读取布局首次挂载时的`session.locked`快照。浏览器重新获得焦点时,仅在会话处于解锁状态后才允许刷新待审核数量。 +- 密码解锁成功后恢复原路由并重新挂载页面,由真实后端重新读取短信记录和筛选项;不得用锁定前缓存、静态数据或localStorage伪造恢复后的业务数据。 +- 锁定、解锁和跨标签会话事件必须同时更新锁屏界面、业务路由暂停状态和全局轮询状态;重复事件应保持幂等,不得形成额外登录、短信发送或其他业务副作用。 + +## 签名删除预检与多通道报备汇总修正(2026-08-09) + +- 签名删除预检中的“未结束报备任务”只统计仍需处理的过程态任务;`approved`、`completed`、`failed`、`cancelled`、`rejected`、`abandoned`、`partial`和`partial_success`均属于已结束历史,不得仅因这些任务存在而阻止删除。模板、引流信息等其他真实依赖仍按原删除治理规则阻止删除。 +- 签名及运营商报备汇总不得因单个目标通道失败就直接变为整体“报备失败”。全部当前目标通道通过时为“报备成功”;至少一个通过但尚未全部通过时为“部分成功”;没有通过且仍有其他目标待处理时为“报备中”;只有全部当前目标通道均为`failed/rejected`时才为整体“报备失败”。 +- 每个通道的失败事实、失败原因和历史报备记录必须继续保留并展示;汇总状态修正只改变整体归因,不得覆盖或删除通道级失败证据。 + +## 通道组删除风险展示与历史保留(2026-08-09) + +- 运营端删除通道组前,必须通过真实后端和数据库统计并展示:关联正常企业应用数、关联已删除企业应用数、组内通道数、等待供应商提交结果数。企业应用按不同`applicationId`去重;状态不是`deleted`的现存应用计为正常应用,状态为`deleted`或应用记录已不存在的残留关联计为已删除应用。 +- “等待供应商提交结果”固定为该通道组下`SmsSubmitRecord.submitStatus = queued`的记录数,表示平台已选定该组但尚未收到供应商提交结果;该状态不按三个工作日自动完成,不能与最终回执超时口径混用。 +- 正常应用关联、已删除应用残留关联、组内通道和等待提交记录均只作风险展示,不得隐藏、禁用或阻止“确认删除”;弹窗不要求输入通道组名称,不要求填写删除原因,由运营查看真实影响后确认。 +- 删除采用逻辑删除,将通道组状态置为`deleted`并从通道组列表及新短信选路中排除;不得删除组内通道配置、企业应用关联、发送记录、回执或审计数据,确保历史查询、回执处理及上行接入号匹配仍可追溯。 +- 弹窗标题为“删除通道组:{通道组名称}”,正文依次展示上述四项真实数量,并明确:“删除后该通道组不再参与新短信发送,历史配置、发送、回执和审计数据继续保留。”操作仅保留“取消”和“确认删除”。 + +## 发送质量矩阵与成功率色阶统一(2026-08-09) + +- 数据统计“签名通道发送质量”的明细抽屉中,“按引流切分”固定按每个通道三行展示,顺序为“含引流、 不含引流、未检测”;运营商固定为三列,顺序为“移动、联通、电信”。即使某个组合没有真实提交,也必须保留该行列并明确显示`0`,不得省略、错位或用空白代替。 +- 上述固定行列只改变真实统计结果的展示,不改变后端签名、通道、运营商、引流状态、提交量、送达结果和到达时间口径;整体统计页签继续展示全部真实提交。 +- 签名发送质量列表、明细抽屉、短信通道管理列表和通道报备详情中的成功率数字统一使用六档颜色:`0`为红色,`>0且<=25`为橙色,`>25且<=50`为黄色,`>50且<=75`为蓝色,`>75且<96`为绿色,`>=96`为深绿色。小数成功率必须按该连续边界归档。 +- 短信通道管理列表及通道报备详情中的提交失败、回执未知和送达失败比例与数量统一使用黑灰色,不因数值高低显示为红、橙或其他告警色;该展示规则不改变真实失败状态及统计值。 + +## 报表筛选结果全量汇总(2026-08-09) + +- 对账单、利润报表和发送质量报表在每次搜索后都必须展示当前筛选条件匹配的全部结果汇总,不得只对当前分页明细在前端求和。汇总、总数、分页和CSV导出必须复用同一套日期、企业、应用、通道及统计维度筛选口径。 +- 三类报表均汇总提交、发送、未知、成功和失败条数;利润报表另汇总净消费、返还、成本和利润金额。综合成功率必须按合计成功量/合计发送量重算,综合利润率必须按合计利润/合计净消费重算,不得对每行百分比求和或简单平均;分母为0时显示0%。 +- 平均到达时长不属于可加总数据,本汇总区不对各日、各维度均值再求和;明细表仍保留每组的真实P95截尾平均到达时长。 + +## 新建企业省份与地市字典(2026-08-09) + +- 运营端新建和编辑企业的省份、地市必须使用真实后端字典及级联关系,不得在页面写死少量省市选项。本版字典从PostgreSQL `PhoneSegment.province/city`中查询去重后的真实归属关系,与平台手机号段库保持一致。 +- 新增`GET /api/admin/dictionaries/administrative-regions`返回省份及其地市数组;前端选中省份后只展示该省真实地市,切换省份必须清空原地市。字典请求失败时必须明确报错,不得回退到Mock或静态列表。 +- 编辑历史企业时,若原省市值与当前号段字典格式不同或暂无对应项,页面仍必须保留并显示原值,不得因加载字典而静默清空已存档案。 + +## 运营端菜单与查询控件细节修正(2026-08-09) + +- “风控规则”菜单归入“安全控制”业务域,并同步更新页面面包屑;路由、真实规则接口和数据库数据不变。 +- 发送监控页面的通道运营商必须显示中文名称,至少统一映射移动、联通、电信、三网和未识别;无法识别的新增值保留后端原值,避免隐藏真实数据。 +- “待生成报备批次”的两个页签标题固定为“待生成资料”和“已生成批次”,不在标题后展示括号及总数;真实后端分页总数仍用于分页,不改变待生成池和批次查询。 +- 短信记录的通道筛选使用平台通用可搜索下拉控件,选项来自真实通道接口,显示通道名称及已有通道编码,并向短信记录及导出接口传递精确`channelId`;不得使用静态列表、Mock或浏览器本地数据替代。 + +## CMPP客户连接请求诊断日志(2026-08-09) + +- 每一次客户向平台发起的真实CMPP CONNECT尝试,无论账号是否存在、认证是否成功、IP白名单是否命中或应用是否启用,都必须同步写入`OperationLog`,动作使用`cmpp_connection.connect_requested`,并将TCP真实远端IP写入`ipAddress`;未知或恶意账号也必须以请求账号作为资源标识保留,不得因无法关联企业而丢弃。 +- 连接请求详情保存客户实际发送或由Gateway从报文解析的诊断参数,包括远端IP、`Source_Addr`账号、`AuthenticatorSource`、时间戳、协议版本及原始版本值,并保存认证结果、应用ID和失败原因。标准CMPP CONNECT不传输明文密码,页面必须明确说明这一事实,不得把平台配置的密码或密钥伪造成客户请求密码;仅兼容调用真实携带`password`字段时原样保存和展示该字段。 +- 上述连接请求日志必须在认证响应返回前持久化,日志写入失败时不得把未经审计的连接当作认证成功。系统与操作日志列表继续直接显示`ipAddress`,并为`cmpp_connection.connect_requested`提供“查看详情”按钮,展示上述结构化参数。 +- 供应商通道的既有连接操作日志仍保留;客户入站连接使用`cmpp_downstream_connection`资源区分方向。本功能不改变CMPP认证算法、IP白名单、最大连接数或客户连接状态。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 2f0f0ab..b1d6035 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -3678,7 +3678,7 @@ npm run verify:phase8 | TC-DEFECT-004 | 企业应用列表保持相同条件连续点击查询/重置,打开包含长 ID 的 CMPP 连接详情;短信记录同样操作。 | 每次均有真实 API 请求且数据更新;弹窗无水平滚动,长 ID 自动换行。 | | TC-DEFECT-005 | 通过/驳回一条待审短信并查看列表、更多信息及导航角标。 | 审核人/时间由当前会话写库,时间格式正确,列表不额外占列,角标不等待 30 秒轮询即更新。 | | TC-DEFECT-006 | 打开真实短信详情,再新增后删除一条运营商区分规则。 | 详情分别展示 `clientSrcId` 与通道 `srcId + applicationExtension`;运营商显示中文,DELETE API 真实删库并刷新。 | -| TC-DEFECT-007 | 用登录运营用户修改企业应用及其他任一写操作,再查看系统日志;另构造失败请求。 | 成功写操作均有操作人、路径、资源和结果日志;失败请求不写伪成功日志,日志不含请求体、密码或密钥。 | +| TC-DEFECT-007 | 用登录运营用户修改企业应用及其他任一写操作,再查看系统日志;另构造失败请求。 | 成功写操作均有操作人、路径、资源和结果日志;失败请求不写伪成功日志。普通HTTP业务操作日志不保存请求体、密码或密钥;仅`cmpp_connection.connect_requested`按连接诊断要求保存客户实际提交的认证参数,且不得写入平台保存的密钥。 | ### 17.16 2026-07-20 缺陷回归 @@ -3847,7 +3847,8 @@ npm run verify:phase8 - `TC-PROTOCOL-LOG-001`:向Gateway客户认证入口提交不存在的CMPP账号;真实接口返回业务4xx,通讯日志分别出现`received`和`failed`事件,账号可检索、耗时和安全错误可见,数据库无短信业务记录。 - `TC-PROTOCOL-LOG-002`:真实CMPP Submit获得供应商SubmitResp;通讯日志可按CMPP、通道到平台、SubmitResp及平台消息号筛选,展示上游消息号和结果码,不包含短信正文或通道密码;同一个SubmitResp只能落一条最终处理结果,不得同时出现`received`和`success`重复行。 - `TC-PROTOCOL-LOG-003`:供应商发送DELIVER状态报告;Gateway结构化日志出现收到事件,NestJS通讯日志以一条记录展示该报文及最终处理结果。构造解包失败或API拒绝时必须出现对应失败证据,不能静默返回。 -- `TC-PROTOCOL-LOG-004`:通过公开HTTP API提交合法和非法请求;通讯日志展示客户到平台的受理或失败状态、请求号、脱敏手机号、业务码和耗时,鉴权头、密钥和正文不得入库。 +- `TC-PROTOCOL-LOG-004`:通过公开HTTP API提交合法和非法请求;通讯日志展示客户到平台的受理或失败状态、请求号、完整手机号、业务码和耗时,可使用完整号码查询,鉴权头、密钥和正文不得入库。 +- `TC-PROTOCOL-LOG-013`:分别产生CMPP Submit、供应商回执、上行和HTTP发送通讯日志;数据库新记录的`phoneNumber`、列表对象列、详情弹窗及完整号码关键字查询均显示/命中完整手机号,不写新的`phoneMasked`值,且短信正文、密码、密钥和鉴权头仍不入库。 - `TC-PROTOCOL-LOG-005`:平台向客户投递回执或上行Webhook并触发成功、网络失败和重试;通讯日志展示事件ID、HTTP状态或网络错误、耗时、尝试次数及最终状态,真实`HttpWebhookAttempt`状态一致。 - `TC-PROTOCOL-LOG-006`:连续运行CMPP心跳;`ProtocolInteractionLog`行数不随每个ACTIVE_TEST增长,连接状态中的最近心跳仍更新。超过配置保留期的数据被清理,业务表及操作审计不受影响。 - `TC-PROTOCOL-LOG-007`:运营端真实登录后打开系统日志,键盘切换“系统与操作日志/通讯交互日志”,筛选、分页、详情及固定操作列可用;桌面和平板/手机不产生页面级横向溢出,宽表允许容器内滚动,控制台无error/warn。 @@ -4369,7 +4370,8 @@ npm run verify:phase8 | --- | --- | --- | | TC-DRAINAGE-DETECT-001 | 在引流识别规则页新增、编辑、停用规则并刷新 | 所有操作调用真实后端并持久化到 PostgreSQL;版本递增、状态生效并留下操作日志,刷新后不丢失 | | TC-DRAINAGE-DETECT-002 | 分别测试协议 URL、裸域名、短链接、IP:端口/路径及中文标点相邻链接 | 均识别为含引流,命中类型为 URL,保存原始内容位置;检测不会改写短信原文 | -| TC-DRAINAGE-DETECT-003 | 输入被空格、中文句号拆开的域名以及普通邮箱地址 | 规避域名仍命中;完整或带空格规避的邮箱不被当作引流 URL | +| TC-DRAINAGE-DETECT-003 | 输入使用中文句号代替域名点号的URL以及普通邮箱地址 | 中文句号域名仍命中;完整或带空格规避的邮箱不被当作引流URL或手机号 | +| TC-DRAINAGE-DETECT-010 | 分别在协议URL、裸域名或路径后加入空格、制表符、换行、全角空格及后续字符,并输入空格拆分域名 | URL命中原文和位置均在首个空白前结束,空白后字符不属于前一个链接;空格拆分域名不被拼接恢复,短信原文不被改写 | | TC-DRAINAGE-DETECT-004 | 测试`+86 138 0013 8000`、`138-0013-8000`及中文标点拆分手机号 | 均识别为手机号引流,原文片段可在短信记录中正确高亮 | | TC-DRAINAGE-DETECT-005 | 测试`(010)8888-8888 转 123`等固话 | 区号括号、分隔符和分机号均可识别为固定电话引流 | | TC-DRAINAGE-SEND-001 | 使用待审核、驳回或未报备的既有引流资料分别创建客户端批次和 CMPP 入站任务 | 不产生`DRAINAGE_NOT_APPROVED`,不因引流资料状态拒绝或转人工;其他发送校验仍正常执行,禁止用真实短信完成自动测试 | @@ -4399,3 +4401,78 @@ npm run verify:phase8 | TC-RECEIPT-CONFLICT-001 | 整条级成功已经形成`delivered`后,同一提交尝试又收到明确失败回执,并重复输入同一矛盾事件 | 原始失败回执和分片证据保留;主记录仍为`delivered`,不补发、不退款、不向客户推送失败;`SmsReceiptAnomaly`按稳定键只有一条记录并累加发生次数 | | TC-GATEWAY-EXCEPTION-UI-001 | 打开运营端“网关异常”,切换“提交异常”和“回执异常”Tab并刷新、筛选、翻页 | 菜单新名称和两个Tab正常展示且原路由可访问;两个Tab分别调用真实提交死信API和回执异常API,筛选、汇总、总数和分页与PostgreSQL一致,不使用mock、静态数据或localStorage | | TC-GATEWAY-EXCEPTION-UI-002 | 阅读两个Tab标题说明并打开两类详情 | “提交异常”明确说明死信不等于供应商拒绝/送达失败及重入队风险;“回执异常”明确说明其为终态冲突摘要,并指向真实回执记录和通讯交互日志,详情不泄露通道密码或鉴权信息 | + +## 2026-08-09 休眠唤醒与会话锁定恢复用例 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-AUTH-014 | 在运营端短信记录页长时间无操作或让桌面进入锁定/休眠,超过运营端空闲期限后唤醒浏览器并观察网络请求,再输入当前密码解锁 | 锁定后短信记录路由暂停,全局`pending-audits`轮询停止,唤醒产生的焦点事件不继续请求受保护接口;解锁成功后返回短信记录路由并重新请求真实短信记录、企业和应用选项,页面无连续401 | +| TC-AUTH-015 | 让旧会话活动时间超过空闲期限并进入完整登录页,在不刷新浏览器标签的情况下使用正确账号、密码和验证码重新登录 | 登录成功立即建立新的前端活动起点;新会话不会在1至2秒内调用`/auth/session/lock`,可正常打开短信记录并调用真实后端 | +| TC-AUTH-016 | 分别由前端空闲计时、服务端`SESSION_LOCKED`响应和另一标签页锁定事件触发运营端锁屏,再由当前或另一标签页解锁 | 三种入口均同步锁屏、暂停业务路由和角标轮询;解锁后统一恢复,重复锁定/解锁事件幂等,不产生额外业务写入 | + +## 2026-08-09 签名删除预检与多通道报备汇总用例 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-DELETE-SIGNATURE-004 | 对仅存在`approved`、`abandoned`等已结束报备任务,且无模板、引流信息或其他活动依赖的签名执行删除预检 | 已结束报备任务不出现在“未结束报备任务”中,删除预检允许继续;历史任务和记录仍保留 | +| TC-DELETE-SIGNATURE-005 | 对仍存在`pending`、`waiting_material`、`reporting`或`exporting`任务的签名执行删除预检 | 预检列出真实未结束任务ID和状态并阻止删除 | + +## 2026-08-09 通道组删除风险展示与历史保留用例 + +| 用例编号 | 场景 | 预期结果 | +| --- | --- | --- | +| TC-CHANNEL-GROUP-DELETE-001 | 打开同时关联正常应用、已删除应用、多个通道和`queued`提交记录的通道组删除弹窗 | 后端按不同应用ID去重并返回四项真实数量;弹窗标题、数量单位、说明和按钮文案与需求一致 | +| TC-CHANNEL-GROUP-DELETE-002 | 同一正常企业应用存在多条通道组关联 | “关联正常企业应用”只计1个,不按关联规则条数重复累计 | +| TC-CHANNEL-GROUP-DELETE-003 | 关联记录指向状态为`deleted`或已不存在的企业应用 | 两类均计入“关联已删除企业应用”,不计入正常应用 | +| TC-CHANNEL-GROUP-DELETE-004 | 通道组存在正常/已删除应用关联、组内通道或等待供应商提交记录后确认删除 | 所有业务依赖只展示不阻止;后端将通道组状态置为`deleted`并写操作审计,不物理删除关联和历史记录 | +| TC-CHANNEL-GROUP-DELETE-005 | 删除通道组后查询通道组列表并发送新短信 | 默认列表不再显示该组,新短信选路不再选择该组 | +| TC-CHANNEL-GROUP-DELETE-006 | 删除通道组后查询历史发送/回执/审计,或按历史通道接入号处理上行 | 组内通道、应用关联、发送、回执和审计数据仍存在,历史链路可追溯 | +| TC-CHANNEL-GROUP-DELETE-007 | 删除影响数据仍在加载或加载失败 | 加载期间不允许盲目提交;失败时明确展示接口错误,不使用静态数量或本地伪数据 | +| TC-SIGNATURE-REPORT-AGG-001 | 同一签名两个目标通道分别为`approved`和`failed` | 签名整体及对应多目标汇总为“部分成功”,页面显示部分通道通过;失败通道及原因仍可查看,不显示整体报备失败 | +| TC-SIGNATURE-REPORT-AGG-002 | 同一签名两个目标通道分别为`pending`和`failed` | 签名整体保持“报备中”,不因一个通道失败提前结束;失败通道明细继续展示 | +| TC-SIGNATURE-REPORT-AGG-003 | 同一签名所有当前目标通道均为`failed/rejected` | 签名整体为“报备失败”;各通道失败事实和原因均保留 | + +## 2026-08-09 发送质量矩阵与成功率色阶用例 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-ANALYTICS-SIGNATURE-004 | 打开签名通道发送质量明细并切换到“按引流切分”,检查多个通道及三网组合 | 每个通道固定依次展示“含引流、不含引流、未检测”三行,固定依次展示“移动、联通、电信”三列;缺少真实提交的组合显示`0`,其他组合与真实API数据一致 | +| TC-ANALYTICS-SIGNATURE-005 | 分别构造或选择成功率为`0、0.1、25、25.1、50、50.1、75、75.1、95.9、96`的签名统计结果,检查列表、明细总览、运营商概览和矩阵 | 数字颜色依次落入红、橙、橙、黄、黄、蓝、蓝、绿、绿、深绿;所有签名质量展示位置使用同一边界函数,统计值不被前端改写 | +| TC-CHANNEL-QUALITY-COLOR-001 | 在短信通道管理列表和通道报备详情检查上述成功率边界 | 送达成功率数字使用与签名质量相同的六档色阶;列表与详情对同一成功率显示一致 | +| TC-CHANNEL-QUALITY-COLOR-002 | 选择提交失败、回执未知或送达失败比例与数量均非零的通道,检查通道管理列表和报备详情 | 三类非成功指标的比例与数量均为黑灰色,不显示红色、橙色或成功率色阶;真实比例、数量及后端数据保持不变 | + +## 2026-08-09 运营端菜单与查询控件细节用例 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-ADMIN-NAV-006 | 展开“审核中心”和“安全控制”,再打开风控规则页面 | “审核中心”不再显示风控规则,“安全控制”显示且可正常进入;页面面包屑为“安全控制 / 风控规则”,路由和真实规则数据不变 | +| TC-MONITOR-CARRIER-003 | 打开发送监控,检查移动、联通、电信、三网及未知运营商通道 | 已知运营商均显示中文;未知新增值保留后端原值,不显示空白,也不修改通道配置 | +| TC-REPORT-BATCH-008 | 打开待生成报备批次并切换两个页签 | 页签标题仅显示“待生成资料”和“已生成批次”,不含括号及数量;列表分页总数、筛选和真实API请求保持正常 | +| TC-SMS-RECORD-CHANNEL-003 | 打开短信记录通道下拉,输入通道名称或编码搜索并选择后查询、翻页和导出 | 下拉选项来自真实通道接口并支持搜索;查询和导出传递选中通道的精确`channelId`,结果、总数和CSV均只包含该通道记录;重置恢复全部通道 | + +## 2026-08-09 CMPP客户连接请求诊断日志用例 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-CMPP-CONNECT-LOG-001 | 使用正确账号从允许IP发起CMPP 3.0连接,再查询系统与操作日志 | 认证成功;产生一条`cmpp_connection.connect_requested`,资源为`cmpp_downstream_connection`,日志`ipAddress`等于真实TCP来源IP,详情保存账号、AuthenticatorSource、时间戳、`cmpp30`、原始版本值、应用ID和authenticated结果 | +| TC-CMPP-CONNECT-LOG-002 | 从不同IP分别使用未知账号、错误AuthenticatorSource、未在白名单的IP或已停用应用发起连接 | 每次连接均按原认证规则拒绝,同时各自持久化失败日志;未知账号日志仍保存来源IP和请求账号,失败原因与真实拒绝原因一致,不因没有企业ID而丢失 | +| TC-CMPP-CONNECT-LOG-003 | 在系统与操作日志找到上述动作,核对IP列并点击“查看详情” | IP列有值;弹窗展示请求IP、Source_Addr、AuthenticatorSource、时间戳、协议版本、结果及失败原因。标准CMPP请求的密码字段明确显示“不传明文密码”,不得展示平台保存的密钥 | +| TC-CMPP-CONNECT-LOG-004 | 通过兼容Gateway调用显式携带`password`字段进行认证测试 | 日志详情原样保存并展示该请求字段;该兼容行为不改变标准CMPP只传AuthenticatorSource的协议事实,也不把平台配置密钥写入日志 | + +## 2026-08-09 报表筛选结果全量汇总用例 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-REPORT-SUMMARY-001 | 在对账单准备超过一页的多日、多企业应用数据,分别按日期、企业和应用搜索并翻页 | 顶部提交、发送、未知、成功、失败合计等于PostgreSQL中全部筛选结果;翻页不改变汇总,改变筛选条件后同步刷新 | +| TC-REPORT-SUMMARY-002 | 在利润报表分别选择企业应用和通道维度,准备多行金额且至少一行收入为0 | 量类与金额类均按完整筛选结果求和;综合利润率=合计利润/合计净消费,不是行利润率求和或平均,合计收入为0时为0% | +| TC-REPORT-SUMMARY-003 | 在发送质量报表的企业应用、通道、签名、引流信息四个Tab分别搜索和翻页 | 汇总条数来自真实后端全量聚合;综合成功率=合计成功/合计发送,不累加或平均各行成功率;汇总区不对平均到达时长求和 | +| TC-REPORT-SUMMARY-004 | 调用三个报表列表API,对比`items`当前页、`total`、`summary`和相同条件CSV | `summary`与CSV完整结果口径一致且不受`page/pageSize`影响;无匹配数据时所有合计和综合率均为0 | + +## 2026-08-09 新建企业省市字典用例 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-ENTERPRISE-REGION-001 | 在`PhoneSegment`中准备多省多地市且含重复行,调用`GET /api/admin/dictionaries/administrative-regions` | API从真实PostgreSQL查询`province/city`,返回去重、去空值并按中文排序的省份—地市数组,不返回前端静态数据 | +| TC-ENTERPRISE-REGION-002 | 打开运营端新建企业,依次选择两个不同省份并查看地市下拉 | 省份选项来自真实字典API;地市只显示当前省的对应值,切换省份后旧地市立即清空;保存后省市真实写入企业档案 | +| TC-ENTERPRISE-REGION-003 | 编辑一个已存省市值暂未出现在当前号段字典的历史企业 | 页面将档案原值补入当前选项并正常显示,未主动修改时不会被清空 | +| TC-ENTERPRISE-REGION-004 | 断开字典API后打开新建企业 | 页面明确提示省市字典加载失败,不显示Mock、localStorage或旧的写死选项 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index bc4884a..df34814 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -3223,3 +3223,83 @@ git diff --check - 部署后API、Gateway、Nginx、PostgreSQL、Redis、MinIO均active,关键端口均监听;API/Gateway/MinIO内部健康、Redis PONG、公网页面、运营登录、客户端登录、API health和客户Swagger均通过,客户OpenAPI未认证POST返回401,API专用域名的根路径、管理页面和管理API仍返回404,公网CMPP 17890纯TCP连通。 - 5条active供应商通道均在本次重启后产生新状态并恢复`connected 1/1`;Redis Stream仍为消费者1、`pending=0`、`lag=0`,13个通道TPS配置键存在,最近120秒客户下游连接为0。部署后API/Gateway error级journal为0,程序错误关键字无新增;Nginx仅记录正常优雅重启notice。 - npm审计仍报告根项目2项high及API 3项moderate、2项high;专用安全门禁确认PostCSS补丁、React Router RSC未使用和brace expansion边界有效,未执行可能破坏兼容性的自动升级。本次没有发送、补发或重投真实短信,没有修改真实通道账号、密码、启停状态、企业余额或客户连接。 + +## 2026-08-09 运营端休眠唤醒后连续401修复(本地未提交) + +- 生产Nginx只读日志确认`pending-audits`连续401的响应体长度为86字节,对应`SESSION_LOCKED`;2026-08-09 11:10:45运营端登录成功后,前端在11:10:47立即请求`/auth/session/lock`,11:10:49才完成解锁,而11:10:57短信记录、企业和应用选项接口均返回200。根因是同一SPA重新登录未重置上一会话的内存活动时间,以及布局只读取首次挂载的`session.locked`快照,锁定后仍保留业务路由和角标轮询。 +- 登录成功后立即调用`markUserActivity()`,保证新会话使用新的活动起点。`AppShell`在本地空闲、服务端`SESSION_LOCKED`和跨标签事件三条锁定入口中统一写入实时锁定状态、暂停业务路由,并将状态回传给运营布局;解锁时恢复路由、清除锁定请求标记并重新挂载当前页面。 +- 运营端待审核角标现在跟随`AppShell`实时锁定状态启停,锁定后清理30秒定时器和窗口焦点监听;解锁后才重新拉取真实待审核数量。短信记录路由因锁定被卸载,解锁后重新请求真实短信记录及筛选项,不使用缓存或静态数据伪造恢复结果。 +- 使用Node.js v24.14.0分别执行前端TypeScript `--noEmit`和Vite v8.1.5生产构建,2534个模块构建通过;仅保留既有约2.03MB单chunk和CSS插件耗时提示。`git diff --check`通过。 +- 本地`http://127.0.0.1:4173/#/admin/login`浏览器检查通过页面身份、非空渲染、无框架错误覆盖和输入控件交互;本地预览未启动真实API,验证码请求按预期返回502,因此没有伪造登录态,也未在本地完成真实锁定/解锁交互。完整`TC-AUTH-014`至`TC-AUTH-016`仍需代码发布后在预生产使用真实会话复测。 +- 本轮未提交、未推送、未部署,没有发送、补发或重投真实短信,没有修改数据库、企业余额、真实通道配置或客户连接。既有`api/tsconfig.build.tsbuildinfo`、根目录`tsconfig.tsbuildinfo`、`outputs/`和空文件`=`继续保留,不删除、不提交、不归因。 + +## 2026-08-09 签名删除预检与多通道报备汇总修复(本地未提交) + +- 删除预检现已将`approved`、`abandoned`等报备终态排除在“未结束报备任务”之外;预生产只读核对的三条任务`cmrxc3rgk004017nks1bktp9n`、`cmrxc3rgo004217nkw9enwk72`、`cmrxc3rgr004417nk887sg9ef`均为`abandoned`,修复后不会再仅因这三条历史任务阻止签名删除。模板、引流信息和真实过程态任务仍继续阻止删除。 +- 新增统一报备汇总函数并复用于签名总状态、三网摘要和引流报备摘要:全部目标失败才汇总为`failed`;通过与失败并存汇总为`partial_success`;失败与待处理并存保持`reporting`。运营端签名卡片同步调整为只有所有适用目标均失败才显示红色整体失败,部分失败且仍待处理显示橙色,部分通过显示蓝色。 +- 使用Node.js v24.14.0执行本次相关4个API suites,118个测试全部通过;排除受本地Redis影响的`send-chain.service.spec.ts`后,其余API全量32个suites、312个测试全部通过。API与前端TypeScript `--noEmit --incremental false`均通过,Vite v8.1.5生产构建通过(2534个模块,仅保留既有约2.03MB单chunk提示),`git diff --check`通过。 +- API全量运行结果为33 suites中的32个通过、420个测试中的415个通过;未通过的5项全部位于本次未修改的`send-chain.service.spec.ts`,原因是本地Redis `127.0.0.1:6379`未运行导致BullMQ连接失败和5秒超时。该套件单独重跑同样被Redis重连拖至工具超时,因此不把API全量记为通过,也未为本任务修改发送链代码或启动外部依赖。 +- 本轮没有提交、推送或部署,没有修改预生产数据库、发送真实短信、调整通道配置或客户连接。其他会话已有的登录/休眠恢复代码和文档增量继续原样保留;测试命令意外生成且本轮开始前不存在的根目录`pnpm-lock.yaml`已删除,既有`tsbuildinfo`、`outputs/`和空文件`=`仍受保护。 + +## 2026-08-09 发送质量矩阵与成功率色阶统一(本地未提交) + +- 签名通道发送质量明细的“按引流切分”已改为每个通道固定三行“含引流、不含引流、未检测”,并固定三列“移动、联通、电信”;通道集合取整体和引流切分真实数据的并集,缺少真实提交的组合保留位置并显示单个`0`。整体统计页签及后端统计口径未改动。 +- 新增共享成功率色阶函数,签名质量列表、明细总览、运营商概览、矩阵、短信通道管理列表和通道报备详情统一按`0`红、`>0且<=25`橙、`>25且<=50`黄、`>50且<=75`蓝、`>75且<96`绿、`>=96`深绿展示数字。通道列表和报备详情的提交失败、回执未知、送达失败比例及数量恢复为黑灰色。 +- 使用Node.js v24.14.0执行成功率边界校验,`0、0.1、25、25.1、50、50.1、75、75.1、95.9、96`共10个边界值全部符合约定;前端TypeScript `--noEmit --incremental false`通过,Vite v8.1.5生产构建通过(2535个模块),仅保留既有约2.03MB单chunk提示;`git diff --check`通过。 +- 本步骤只修改前端展示、共享色阶工具及需求/用例/进度文档,没有修改后端、数据库或真实统计接口,没有连接预生产、发送/补发/重投短信,也没有修改真实通道、企业余额或客户连接。代码按要求保持未提交、未推送、未部署;其他会话和前一步已有修改、`tsbuildinfo`、`outputs/`及空文件`=`继续保留并保护。 + +## 2026-08-09 运营端菜单与查询控件细节修正(本地未提交) + +- 风控规则已从“审核中心”移动到“安全控制”,页面面包屑同步调整,既有`/admin/risk-rules`路由和真实后端规则接口未改变。发送监控对移动、联通、电信、三网和未识别运营商统一显示中文,未知新值仍原样展示。 +- 待生成报备批次两个页签已移除括号及动态数量,标题固定为“待生成资料”和“已生成批次”;后端返回的`total`继续用于分页。短信记录通道条件已由自由文本改为通用可搜索`Select`,真实调用通道接口加载未删除通道,以名称和编码搜索,查询及导出改传精确`channelId`,重置恢复全部通道。 +- 使用Node.js v24.14.0执行前端TypeScript `--noEmit --incremental false`通过,Vite v8.1.5生产构建通过(2535个模块),仅保留既有约2.03MB单chunk提示;`git diff --check`通过。本步骤不涉及后端业务逻辑,因此未增加或运行API单元测试。 +- 本步骤没有连接预生产、修改数据库、发送/补发/重投短信,也没有修改真实通道配置、企业余额或客户连接。代码保持未提交、未推送、未部署;`AdminLayout.tsx`仅对菜单项位置做局部修改,其他会话已有的会话锁定和轻量轮询增量继续保留且未归因给本步骤。 + +## 2026-08-09 URL空白边界识别修正(本地未提交) + +- 引流检测副本不再删除URL类别中的空白字符。协议链接、裸域名及路径遇普通空格、制表符、换行或全角空格时立即结束命中,空白后的字符不再归入前一个链接;空格拆分域名也不再被拼接成一个URL。短信真实原文、命中位置映射、中文句号域名兼容和数据库默认URL正则保持不变,因此本步骤不需要migration。 +- 邮箱排除改用独立检测副本,继续允许仅为排除目的而规范化带空格邮箱,避免其中的数字本地部分被误判为手机号;手机号和固话类别原有空格、短横线及中文标点规避识别不受URL边界修正影响。 +- 引流检测针对性测试13/13通过,覆盖四类空白边界、空格拆分域名不命中、中文句号域名、原文高亮位置、普通及带空格邮箱排除、手机号和固话识别;规则管理与通道相关2个suites、57个测试通过。API正式构建配置TypeScript检查和`git diff --check`通过。 +- 一次诊断命令误用`api/tsconfig.json --noEmit`,该配置会包含全部`*.spec.ts`但不加载Jest全局类型,因而产生既有测试类型环境错误;随后使用项目正式`api/tsconfig.build.json`重新检查并通过,未修改TypeScript或Jest配置。 +- 本步骤未连接预生产、未修改数据库规则、未发送/补发/重投真实短信,也未修改通道、余额或客户连接;代码保持未提交、未推送、未部署。 + +## 2026-08-09 CMPP客户连接请求诊断日志(本地未提交) + +- Gateway入站CMPP CONNECT鉴权请求新增协议版本和原始版本值,并将真实TCP远端IP、Source_Addr账号、Base64 AuthenticatorSource、时间戳一并传给API。API在认证成功或失败时同步写`cmpp_connection.connect_requested`操作日志,客户入站资源固定为`cmpp_downstream_connection`;未知账号同样以请求账号为资源ID保存,便于定位恶意连接。 +- 日志`ipAddress`直接保存Gateway报告的真实远端IP,结构化详情保存认证结果、应用ID、失败原因和全部客户请求参数。标准CMPP CONNECT报文不含明文密码,因此详情明确显示该协议事实;只有兼容调用真实携带`password`字段时才保存该字段,平台数据库内的应用密钥不会作为客户参数写入日志。 +- 运营端系统与操作日志对`cmpp_connection.connect_requested`增加“查看详情”按钮,弹窗展示请求IP、账号、密码字段说明、AuthenticatorSource、时间戳、协议版本、结果、失败原因及应用ID;既有列表IP列继续读取真实`OperationLog.ipAddress`。 +- Gateway全量`go test ./... -count=1`及`go vet ./...`通过;Gateway入站包测试通过。API Gateway认证针对性3/3通过,覆盖成功、应用禁用失败和未知恶意账号;API正式构建TypeScript、前端TypeScript、Vite生产构建和`git diff --check`通过。Vite仅保留既有约2.03MB单chunk提示,Jest使用`--forceExit`结束既有开放句柄。 +- 本步骤未实际建立、断开或修改预生产客户连接,未连接预生产数据库,未发送短信,也未修改真实账号、密码、IP白名单、连接数、通道或余额;代码保持未提交、未推送、未部署。 + +## 2026-08-09 通讯交互日志完整手机号(本地未提交) + +- `ProtocolInteractionLog`新增可空`phoneNumber`字段及migration`20260809130000_add_protocol_log_plain_phone`。新产生的CMPP/HTTP通讯日志将Gateway或API上报的完整号码写入该字段,不再为新记录生成`phoneMasked`;既有脱敏列暂不删除,仅作为旧行显示兜底,不执行历史号码恢复或回填。 +- 通讯日志关键词查询已从`phoneMasked`切换到`phoneNumber`,运营端列表对象列和详情读取完整号码,筛选提示及页面说明同步明确“完整手机号”。短信正文、密码、密钥、Token、鉴权头和完整请求体仍继续由通讯日志详情清洗逻辑排除。 +- Prisma schema validate和client generate通过;通讯日志服务测试3/3通过,覆盖新日志完整号码持久化、敏感详情排除和完整号码查询。API正式构建TypeScript、前端TypeScript、Vite生产构建和`git diff --check`通过,Vite仅保留既有约2.03MB单chunk提示。 +- 新migration尚未应用到本地或预生产数据库;本步骤未查询或修改历史手机号,未连接预生产、发送短信、修改通道、余额、客户连接或权限配置。代码保持未提交、未推送、未部署。 + +## 2026-08-09 三类报表全量筛选汇总(本地未提交) + +- `GET /api/admin/reports/reconciliation`、`profit`和`quality`在原有分页响应中新增`summary`;后端使用与明细、总数完全相同的`where`对PostgreSQL报表表执行聚合,不从当前页`items`二次求和。 +- 对账、利润、质量页在筛选区后展示“筛选结果汇总”,明确说明不受当前分页影响。三页均展示提交/发送/未知/成功/失败合计;利润页另展示全部金额合计和重算综合利润率,质量页展示重算综合成功率。 +- 成功率按合计成功/合计发送、利润率按合计利润/合计净消费计算,避免求和或平均分组百分比导致失真;平均到达时长不可直接加总,未放入汇总区。 +- 使用Node.js v24.14.0运行`reports.service.spec.ts` 7/7通过,增加无匹配行时合计及综合率全部归零覆盖;API正式构建TypeScript和前端TypeScript均通过。首次测试命令命中系统旧Node导致缺少`node:util/types`,改用工作区Node后专项测试正常;一次在仓库根目录直接运行API Jest未加载`api/jest.config.cjs`,随后在`api`目录按项目配置重跑通过。 +- 本步骤未连接预生产、未修改数据库、未发送/补发/重投短信,也未修改真实通道、余额或客户连接。代码保持未提交、未推送、未部署。 + +## 2026-08-09 运营端新建企业省市字典修正(本地未提交) + +- 确认根因为`AdminCustomerFormPage`前端写死仅12个省级地区,且每省只列出1至3个地市,与平台真实数据库不一致。该静态省市数组已移除。 +- 新增真实字典接口`GET /api/admin/dictionaries/administrative-regions`,从PostgreSQL `PhoneSegment.province/city`执行去重查询,服务层过滤空白值、合并重复地市并按中文排序。本轮不新建静态地区表、不使用Mock或localStorage。 +- 新建/编辑企业页加载上述接口并做真实省—地市级联,切换省份时清空原地市;字典加载失败显式报错。编辑历史档案时会将当前原值补入选项,避免因号段库格式差异静默丢值。 +- 字典服务专项测试16/16通过,覆盖真实查询参数、去重、空值过滤和中文排序;API正式构建TypeScript与前端TypeScript通过。 +- 本步骤未新增migration,未修改`PhoneSegment`数据或任何企业档案,未连接预生产、发送短信、修改通道、余额或客户连接。代码保持未提交、未推送、未部署。 +- 本轮最终组合复核:报表与字典专项共2 suites / 23 tests通过,API正式构建TypeScript、前端TypeScript和Vite v8.1.5生产构建通过(2535个模块);仅保留既有约2.03MB单chunk告警。 + +## 2026-08-09 通道组逻辑删除与真实风险统计(本地验证完成) + +- 新增`GET /api/admin/channel-groups/:id/deletion-impact`,从真实数据库按不同企业应用统计正常/已删除关联,并返回组内通道数及`submitStatus = queued`的等待供应商提交记录数;不使用静态数据、Mock或localStorage。 +- 删除弹窗改为“删除通道组:{名称}”,依次展示“关联正常企业应用、关联已删除企业应用、组内通道、等待供应商提交结果”,并使用约定的历史保留说明;不再要求输入名称或删除原因,业务关联数量不禁用确认删除。 +- 删除接口不再因企业应用关联阻止,也不再物理删除通道组或组内通道;仅将通道组状态置为`deleted`并记录删除前快照、实时影响统计和逻辑删除方式。默认列表排除已删除组,新短信继续只选择活动通道组,历史配置、发送、回执、上行匹配和审计链路保留。 +- 通道专项`channels.service.spec.ts` 1 suite / 43 tests通过;API与前端TypeScript `--noEmit --incremental false`通过;Vite v8.1.5生产构建通过(2535 modules,仅保留既有约2.03MB单chunk提示);Gateway全量`go test ./... -count=1`及`go vet ./...`通过;Prisma schema validate及client generate通过;全部结构契约门禁通过。 +- API全量运行共33 suites / 429 tests,其中32 suites / 424 tests通过;仅`send-chain.service.spec.ts`的5项因本机Redis `127.0.0.1:6379`未运行产生连接拒绝并超时,与此前环境阻塞一致,不是业务断言失败。本轮未为通过测试而伪造Redis或修改发送链逻辑。 +- 本地验证未发送、补发或重投真实短信,未修改真实通道账号、密码、启停状态、企业余额或客户连接。预生产发布结果将在安全备份、migration和部署后只读检查完成后补记。 diff --git a/gateway/internal/inbound/authentication.go b/gateway/internal/inbound/authentication.go index 81e91f4..5cb9be0 100644 --- a/gateway/internal/inbound/authentication.go +++ b/gateway/internal/inbound/authentication.go @@ -13,10 +13,12 @@ import ( ) type authRequest struct { - Account string `json:"account"` - AuthSource string `json:"authSource"` - Timestamp uint32 `json:"timestamp"` - RemoteIP string `json:"remoteIp,omitempty"` + Account string `json:"account"` + AuthSource string `json:"authSource"` + Timestamp uint32 `json:"timestamp"` + RemoteIP string `json:"remoteIp,omitempty"` + Version string `json:"version"` + RequestedVersion uint8 `json:"requestedVersion"` } type authResponse struct { @@ -42,7 +44,7 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger setInboundConnectResponse(response.Packer, cmpp.ErrnoConnVerTooHigh, req.AuthSrc, "", cmpp.V30) return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnVerTooHigh] } - auth, err := s.authenticate(packet.Conn.Conn.RemoteAddr(), account, req.AuthSrc, req.Timestamp) + auth, err := s.authenticate(packet.Conn.Conn.RemoteAddr(), account, req.AuthSrc, req.Timestamp, req.Version) if err != nil { logger.Printf("cmpp inbound auth failed account=%s remote=%s err=%v", account, packet.Conn.Conn.RemoteAddr(), err) setInboundConnectResponse(response.Packer, cmpp.ErrnoConnAuthFailed, req.AuthSrc, "", req.Version) @@ -114,12 +116,14 @@ func cmppVersionName(version cmpp.Type) string { } } -func (s Server) authenticate(remote net.Addr, account string, authSource string, timestamp uint32) (authResponse, error) { +func (s Server) authenticate(remote net.Addr, account string, authSource string, timestamp uint32, version cmpp.Type) (authResponse, error) { payload := authRequest{ - Account: account, - AuthSource: base64.StdEncoding.EncodeToString([]byte(authSource)), - Timestamp: timestamp, - RemoteIP: remoteIP(remote), + Account: account, + AuthSource: base64.StdEncoding.EncodeToString([]byte(authSource)), + Timestamp: timestamp, + RemoteIP: remoteIP(remote), + Version: cmppVersionName(version), + RequestedVersion: uint8(version), } var result authResponse err := s.post(context.Background(), "/gateway/events/inbound/authenticate", payload, &result) diff --git a/gateway/internal/inbound/server_test.go b/gateway/internal/inbound/server_test.go index ab06b4f..073e8cd 100644 --- a/gateway/internal/inbound/server_test.go +++ b/gateway/internal/inbound/server_test.go @@ -228,7 +228,7 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) { case <-time.After(2 * time.Second): t.Fatal("expected downstream acknowledgement callback") } - if gotAuth.Account != account || gotAuth.AuthSource == "" || gotAuth.RemoteIP == "" { + if gotAuth.Account != account || gotAuth.AuthSource == "" || gotAuth.RemoteIP == "" || gotAuth.Version != "cmpp30" || gotAuth.RequestedVersion != uint8(cmpp.V30) { t.Fatalf("unexpected auth payload: %+v", gotAuth) } if gotSubmit.Account != account || gotSubmit.PhoneNumber != "13500002696" || gotSubmit.Content != "测试入站" || diff --git a/src/api/admin/channels-reports.api.ts b/src/api/admin/channels-reports.api.ts index 28e028f..4931039 100644 --- a/src/api/admin/channels-reports.api.ts +++ b/src/api/admin/channels-reports.api.ts @@ -1,5 +1,5 @@ import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; -import type { AdminChannel, ChannelConnectionLogResponse, ChannelGroup, ChannelReportField, ChannelTestResponse, CmppConnectionState, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, DictionaryItem, PagedResult, ReportImportMapping, ReportImportProfile, ReportImportReviewBatch, ReportMaterialBatch, ReportMaterialBatchPreflight, ReportMaterialBatchResult, ReportMaterialPendingItem, ReportRecord, ReportTask } from '../types'; +import type { AdminChannel, ChannelConnectionLogResponse, ChannelGroup, ChannelGroupDeletionImpact, ChannelReportField, ChannelTestResponse, CmppConnectionState, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, DictionaryItem, PagedResult, ReportImportMapping, ReportImportProfile, ReportImportReviewBatch, ReportMaterialBatch, ReportMaterialBatchPreflight, ReportMaterialBatchResult, ReportMaterialPendingItem, ReportRecord, ReportTask } from '../types'; import { assertUploadFileSize } from '@/utils/fileUpload'; // Report generation consumes channel report fields, so these endpoints keep one @@ -36,6 +36,8 @@ export const adminChannelsReportsApi = { 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; retryTimeLimitMinutes?: number; items?: Array> }) => request(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + getChannelGroupDeletionImpact: (id: string) => + request(`/admin/channel-groups/${id}/deletion-impact`), deleteChannelGroup: (id: string) => request(`/admin/channel-groups/${id}`, { method: 'DELETE' }), addChannelGroupItem: (body: Record) => diff --git a/src/api/admin/governance.api.ts b/src/api/admin/governance.api.ts index 7cc7da2..4f2b617 100644 --- a/src/api/admin/governance.api.ts +++ b/src/api/admin/governance.api.ts @@ -1,9 +1,10 @@ import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; -import type { AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, DrainageDetectionResult, DrainageDetectionRule, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types'; +import type { AdministrativeRegion, AuditRecord, ClientSmsSignature, ClientSmsTemplate, CommonReportField, DictionaryItem, DrainageDetectionResult, DrainageDetectionRule, EnterpriseCertification, ManualRechargePreflight, ManualRechargeResult, PagedResult, PhoneFrequencyHit, PhoneFrequencyWhitelistItem, RechargeOrder, ReviewDecisionResult, ReviewPreflight, RiskReviewTask, RiskRuleItem, RiskTaskMessagePage, SmsDrainageInfo, SmsTemplateAudit, TenantAccount } from '../types'; // Review, risk and billing mutations keep their original URLs, payloads and // response types behind one governance boundary. export const adminGovernanceApi = { + listAdministrativeRegions: () => request('/admin/dictionaries/administrative-regions'), listAccounts: () => request('/admin/billing/accounts'), updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) => request(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }), diff --git a/src/api/admin/operations.api.ts b/src/api/admin/operations.api.ts index c6650ef..e4326d1 100644 --- a/src/api/admin/operations.api.ts +++ b/src/api/admin/operations.api.ts @@ -1,5 +1,5 @@ import { request, requestBlob, requestForm, withQuery } from '../core/httpClient'; -import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, PendingAuditCounts, ProtocolInteractionLogResponse, ReceiptAnomalyResponse, SendQualityResponse, SignatureChannelQualityResponse, SmsBatchTask, SmsMessageRecord, SmsMessageSegmentAudit, SmsUplinkMessage, SystemLogExportResult } from '../types'; +import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, PendingAuditCounts, ProfitReportSummary, ProtocolInteractionLogResponse, QualityReportSummary, ReceiptAnomalyResponse, ReconciliationReportSummary, SendQualityResponse, SignatureChannelQualityResponse, SmsBatchTask, SmsMessageRecord, SmsMessageSegmentAudit, SmsUplinkMessage, SystemLogExportResult } from '../types'; // Read-heavy operations endpoints are isolated from configuration mutations. export const adminOperationsApi = { @@ -15,15 +15,15 @@ export const adminOperationsApi = { exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) => request('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }), listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) => - request>(withQuery('/admin/reports/reconciliation', query)), + request & { summary: ReconciliationReportSummary }>(withQuery('/admin/reports/reconciliation', query)), exportReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {}) => requestBlob(withQuery('/admin/reports/reconciliation/export', query)), listProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) => - request & { dimensionType: 'application' | 'channel' }>(withQuery('/admin/reports/profit', query)), + request & { dimensionType: 'application' | 'channel'; summary: ProfitReportSummary }>(withQuery('/admin/reports/profit', query)), exportProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string } = {}) => requestBlob(withQuery('/admin/reports/profit/export', query)), listQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) => - request & { dimensionType: DailyQualityReport['dimensionType'] }>(withQuery('/admin/reports/quality', query)), + request & { dimensionType: DailyQualityReport['dimensionType']; summary: QualityReportSummary }>(withQuery('/admin/reports/quality', query)), exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) => requestBlob(withQuery('/admin/reports/quality/export', query)), listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) => diff --git a/src/api/types/channels-reports.ts b/src/api/types/channels-reports.ts index 6288d93..8b50136 100644 --- a/src/api/types/channels-reports.ts +++ b/src/api/types/channels-reports.ts @@ -66,6 +66,15 @@ export type ChannelGroup = DictionaryItem & { items?: ChannelGroupItem[]; }; +export type ChannelGroupDeletionImpact = { + groupId: string; + groupName: string; + normalApplicationCount: number; + deletedApplicationCount: number; + channelCount: number; + pendingSupplierSubmitCount: number; +}; + export type ChannelGroupItem = DictionaryItem & { groupId: string; channelId: string; diff --git a/src/api/types/governance.ts b/src/api/types/governance.ts index e01180b..b411bf1 100644 --- a/src/api/types/governance.ts +++ b/src/api/types/governance.ts @@ -95,6 +95,11 @@ export type RiskTaskMessagePage = { export type BatchTaskMessagePage = RiskTaskMessagePage; +export type AdministrativeRegion = { + province: string; + cities: string[]; +}; + export type DrainageDetectionRule = { id: string; code: string; diff --git a/src/api/types/operations.ts b/src/api/types/operations.ts index 60567f0..919aac9 100644 --- a/src/api/types/operations.ts +++ b/src/api/types/operations.ts @@ -306,6 +306,7 @@ export type ProtocolInteractionLogItem = { traceId?: string | null; requestId?: string | null; phoneMasked?: string | null; + phoneNumber?: string | null; resultCode?: string | null; durationMs?: number | null; payloadBytes?: number | null; @@ -348,6 +349,28 @@ export type DailyReconciliationReport = { updatedAt: string; }; +export type ReportVolumeSummary = { + submittedUnits: number; + sentUnits: number; + unknownUnits: number; + successUnits: number; + failedUnits: number; +}; + +export type ReconciliationReportSummary = ReportVolumeSummary; + +export type ProfitReportSummary = ReportVolumeSummary & { + revenueCents: number; + refundCents: number; + costCents: number; + profitCents: number; + profitRateBps: number; +}; + +export type QualityReportSummary = ReportVolumeSummary & { + successRateBps: number; +}; + export type DailyProfitReport = { id: string; reportDate: string; diff --git a/src/apps/LoginPage.tsx b/src/apps/LoginPage.tsx index a1c191d..e7ca0aa 100644 --- a/src/apps/LoginPage.tsx +++ b/src/apps/LoginPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi'; -import { consumeSessionRecovery, readSessionRecovery, writeSession, type Portal } from '@/api/session'; +import { consumeSessionRecovery, markUserActivity, readSessionRecovery, writeSession, type Portal } from '@/api/session'; import { Button, Input, Modal } from '@/components/ui'; type LoginPageProps = { @@ -57,6 +57,10 @@ export function LoginPage({ portal }: LoginPageProps) { captchaText, }); writeSession(session); + // A login can happen without a full page reload after the previous session + // expired. Reset the in-memory activity clock so the new session is not + // immediately locked using the previous session's stale idle duration. + markUserActivity(); const target = consumeSessionRecovery(portal)?.returnUrl; navigate(target ?? (isAdmin ? '/admin' : '/client'), { replace: true }); } catch (err) { diff --git a/src/apps/admin/AdminAnalyticsPage.tsx b/src/apps/admin/AdminAnalyticsPage.tsx index e01c8e2..7a1d68e 100644 --- a/src/apps/admin/AdminAnalyticsPage.tsx +++ b/src/apps/admin/AdminAnalyticsPage.tsx @@ -4,14 +4,20 @@ import { adminApi, type SendQualityResponse, type SignatureChannelCarrierQualityStat, - type SignatureChannelCarrierDrainageQualityStat, type SignatureChannelQualityItem, type SignatureChannelQualityResponse, } from '@/api/adminApi'; import { Breadcrumb, Button, Chart, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui'; import { createBarOption, createPieOption } from '@/theme/chartOptions'; +import { successRateClassName } from '@/utils/successRate'; const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown']; +const majorCarrierOrder = ['mobile', 'unicom', 'telecom'] as const; +const drainageStates = [ + { value: 'with', label: '含引流' }, + { value: 'without', label: '不含引流' }, + { value: 'unknown', label: '未检测' }, +] as const; const carrierLabels: Record = { mobile: '移动', unicom: '联通', @@ -308,7 +314,8 @@ function SignatureQualityDrawer({ return (leftRank < 0 ? carrierOrder.length : leftRank) - (rightRank < 0 ? carrierOrder.length : rightRank); }); - const channels = [...new Map(item.breakdowns.map((entry) => [entry.channelId, entry.channelName])).entries()] + const channels = [...new Map([...item.breakdowns, ...item.drainageBreakdowns] + .map((entry) => [entry.channelId, entry.channelName])).entries()] .map(([channelId, channelName]) => ({ channelId, channelName })); const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier)); @@ -330,7 +337,7 @@ function SignatureQualityDrawer({
- +
@@ -349,7 +356,7 @@ function SignatureQualityDrawer({ {carrier.businessMessageCount.toLocaleString('zh-CN')} 条业务短信
-
最终成功率
{carrier.finalSuccessRate.toFixed(1)}%
+
最终成功率
{carrier.finalSuccessRate.toFixed(1)}%
平均到达
{formatDuration(carrier.averageArrivalMs)}
涉及通道
{carrier.channelCount} 个
@@ -362,39 +369,59 @@ function SignatureQualityDrawer({

通道 × 运营商矩阵

-

{matrixMode === 'overall' ? '整体口径展示该组合全部真实提交。' : '引流切分口径分别展示含引流、不含引流和历史未检测数据。'}“—”表示所选日期没有真实提交。

+

{matrixMode === 'overall' + ? '整体口径展示该组合全部真实提交;“—”表示所选日期没有真实提交。' + : '固定按含引流、不含引流、未检测三行及移动、联通、电信三列展示;没有真实提交的组合显示 0。'}

- - - {visibleCarriers.map((carrier) => )} - + {matrixMode === 'overall' ? ( + + + {visibleCarriers.map((carrier) => )} + + ) : ( + + + + {majorCarrierOrder.map((carrier) => )} + + )} - {channels.map((channel) => ( - - - {visibleCarriers.map((carrier) => { - const metric = item.breakdowns.find((entry) => ( - entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier - )); - const drainageMetrics = item.drainageBreakdowns.filter((entry) => ( - entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier - )); - return ( - - ); - })} - - ))} + {matrixMode === 'overall' + ? channels.map((channel) => ( + + + {visibleCarriers.map((carrier) => { + const metric = item.breakdowns.find((entry) => ( + entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier + )); + return ( + + ); + })} + + )) + : channels.flatMap((channel) => drainageStates.map((state, stateIndex) => ( + + {stateIndex === 0 ? : null} + + {majorCarrierOrder.map((carrier) => { + const metric = item.drainageBreakdowns.find((entry) => ( + entry.channelId === channel.channelId + && normalizeCarrier(entry.carrier) === carrier + && entry.drainageState === state.value + )); + return ; + })} + + )))}
通道名称{carrierLabel(carrier)}
通道名称{carrierLabel(carrier)}
通道名称引流类型{carrierLabel(carrier)}
{channel.channelName} - {matrixMode === 'overall' - ? metric ? : - : drainageMetrics.length ? : } -
{channel.channelName} + {metric ? : } +
{channel.channelName}{state.label}
@@ -409,41 +436,37 @@ function SignatureQualityDrawer({ ); } -function QualityMetric({ label, value, tone = 'default' }: { label: string; value: string; tone?: string }) { +function QualityMetric({ label, value, valueClassName }: { label: string; value: string; valueClassName?: string }) { return ( -
+
{label} - {value} + {value}
); } -function MatrixMetric({ metric }: { metric: SignatureChannelCarrierQualityStat }) { +function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureChannelCarrierQualityStat; zeroWhenEmpty?: boolean }) { + const total = metric?.total ?? 0; + const successRate = metric?.successRate ?? 0; + if (zeroWhenEmpty && total === 0) return 0; + return (
- {metric.total.toLocaleString('zh-CN')} 次 - - {metric.successRate.toFixed(1)}% + {total.toLocaleString('zh-CN')} 次 + + {successRate.toFixed(1)}% - {formatDuration(metric.averageArrivalMs)} - {metric.submitFailureCount > 0 ? 提交失败 {metric.submitFailureCount} : null} + {formatDuration(metric?.averageArrivalMs)} + {(metric?.submitFailureCount ?? 0) > 0 ? 提交失败 {metric?.submitFailureCount} : null}
); } -function DrainageMatrixMetrics({ metrics }: { metrics: SignatureChannelCarrierDrainageQualityStat[] }) { - const labels = { with: '含引流', without: '不含引流', unknown: '未检测' }; - return
{(['with', 'without', 'unknown'] as const).map((state) => { - const metric = metrics.find((item) => item.drainageState === state); - return metric ?
{labels[state]}
: null; - })}
; -} - function QualityRate({ value }: { value: number }) { return (
- {value.toFixed(1)}% + {value.toFixed(1)}%
); } @@ -468,12 +491,6 @@ function carrierTagTone(value: string): 'info' | 'accent' | 'warning' | 'neutral return 'neutral'; } -function rateTone(value: number) { - if (value >= 98) return 'success'; - if (value >= 95) return 'warning'; - return 'danger'; -} - function formatDuration(value?: number | null) { if (value == null) return '—'; if (value < 1000) return `${Math.round(value)} 毫秒`; diff --git a/src/apps/admin/AdminChannelGroupsPage.tsx b/src/apps/admin/AdminChannelGroupsPage.tsx index ba3832b..07e206f 100644 --- a/src/apps/admin/AdminChannelGroupsPage.tsx +++ b/src/apps/admin/AdminChannelGroupsPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Clock3, Layers3, Pencil, Plus, RadioTower, Search, Trash2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { Breadcrumb, Button, Input, Modal, Pagination, Tag } from '@/components/ui'; -import { adminApi, type ChannelGroup } from '@/api/adminApi'; +import { adminApi, type ChannelGroup, type ChannelGroupDeletionImpact } from '@/api/adminApi'; type GroupCarrier = 'mobile' | 'unicom' | 'telecom'; @@ -35,6 +35,9 @@ export function AdminChannelGroupsPage() { const [groupName, setGroupName] = useState(''); const [groups, setGroups] = useState([]); const [deleteTarget, setDeleteTarget] = useState(null); + const [deletionImpact, setDeletionImpact] = useState(null); + const [deletionImpactLoading, setDeletionImpactLoading] = useState(false); + const [deleting, setDeleting] = useState(false); const [page, setPage] = useState(1); const [error, setError] = useState(''); const pageSize = 10; @@ -61,14 +64,34 @@ export function AdminChannelGroupsPage() { setPage(1); }, [groupName, groups.length]); + function closeDeleteModal() { + if (deleting) return; + setDeleteTarget(null); + setDeletionImpact(null); + } + + function openDeleteModal(group: ChannelGroup) { + setDeleteTarget(group); + setDeletionImpact(null); + setDeletionImpactLoading(true); + setError(''); + adminApi.getChannelGroupDeletionImpact(group.id) + .then(setDeletionImpact) + .catch((failure: Error) => setError(failure.message || '删除影响数据加载失败')) + .finally(() => setDeletionImpactLoading(false)); + } + function deleteGroup() { - if (!deleteTarget) return; + if (!deleteTarget || !deletionImpact || deleting) return; + setDeleting(true); adminApi.deleteChannelGroup(deleteTarget.id) .then(() => { setDeleteTarget(null); + setDeletionImpact(null); loadData(); }) - .catch((failure: Error) => setError(failure.message || '通道组删除失败')); + .catch((failure: Error) => setError(failure.message || '通道组删除失败')) + .finally(() => setDeleting(false)); } return ( @@ -144,7 +167,7 @@ export function AdminChannelGroupsPage() { -
@@ -167,17 +190,30 @@ export function AdminChannelGroupsPage() { - - + + )} - onClose={() => setDeleteTarget(null)} + onClose={closeDeleteModal} open={Boolean(deleteTarget)} - title="删除通道组" + title={`删除通道组:${deleteTarget?.name ?? ''}`} >
- {deleteTarget?.name} -

删除前会校验真实路由绑定;已被企业应用使用的通道组不会被删除。

+ {deletionImpactLoading ? 正在读取真实关联数据... : null} + {deletionImpact ? ( + <> + 关联正常企业应用:{deletionImpact.normalApplicationCount} 个 + 关联已删除企业应用:{deletionImpact.deletedApplicationCount} 项 + 组内通道:{deletionImpact.channelCount} 个 + 等待供应商提交结果:{deletionImpact.pendingSupplierSubmitCount} 条 +

+ 删除后该通道组不再参与新短信发送,
+ 历史配置、发送、回执和审计数据继续保留。 +

+ + ) : null}
diff --git a/src/apps/admin/AdminChannelReportPage.tsx b/src/apps/admin/AdminChannelReportPage.tsx index dc29b20..e2b2026 100644 --- a/src/apps/admin/AdminChannelReportPage.tsx +++ b/src/apps/admin/AdminChannelReportPage.tsx @@ -4,6 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi'; import { Breadcrumb, Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui'; import { formatDateTime } from '@/utils/dateTime'; +import { successRateClassName } from '@/utils/successRate'; import { ReportFieldMappingModal } from './ReportFieldMappingModal'; type ReportType = 'signature' | 'drainage'; @@ -58,10 +59,10 @@ function DeliveryStats({ task }: { task: ReportTask }) { failureRate: 0, }; return
- 成功{stats.successRate}%{stats.successCount.toLocaleString('zh-CN')} - 未知{stats.unknownRate}%{stats.unknownCount.toLocaleString('zh-CN')} - 回执失败{stats.failureRate}%{stats.failureCount.toLocaleString('zh-CN')} - 提交失败{stats.submitFailureRate}%{stats.submitFailureCount.toLocaleString('zh-CN')} + 成功{stats.successRate}%{stats.successCount.toLocaleString('zh-CN')} + 未知{stats.unknownRate}%{stats.unknownCount.toLocaleString('zh-CN')} + 回执失败{stats.failureRate}%{stats.failureCount.toLocaleString('zh-CN')} + 提交失败{stats.submitFailureRate}%{stats.submitFailureCount.toLocaleString('zh-CN')}
; } diff --git a/src/apps/admin/AdminCustomerFormPage.tsx b/src/apps/admin/AdminCustomerFormPage.tsx index 8a3e310..3d95883 100644 --- a/src/apps/admin/AdminCustomerFormPage.tsx +++ b/src/apps/admin/AdminCustomerFormPage.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { ImagePlus } from 'lucide-react'; -import { adminApi, type FileRef, type TenantOption } from '@/api/adminApi'; +import { adminApi, type AdministrativeRegion, type FileRef, type TenantOption } from '@/api/adminApi'; import { Breadcrumb, Button, FileActions, Input, Select, Textarea } from '@/components/ui'; import { isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency'; @@ -23,26 +23,6 @@ type EnterpriseForm = { type EnterpriseFormErrors = Partial>; -const provinceOptions = [ - { label: '请选择省/直辖市', value: '' }, - ...'北京,上海,广东,山东,河南,江苏,浙江,四川,重庆,湖北,湖南,陕西'.split(',').map((item) => ({ label: item, value: item })), -]; - -const cityOptionsByProvince: Record> = { - 北京: [{ label: '北京市', value: '北京市' }], - 上海: [{ label: '上海市', value: '上海市' }], - 广东: ['广州市', '深圳市', '东莞市'].map((item) => ({ label: item, value: item })), - 山东: ['济南市', '青岛市', '烟台市'].map((item) => ({ label: item, value: item })), - 河南: ['郑州市', '洛阳市', '开封市'].map((item) => ({ label: item, value: item })), - 江苏: ['南京市', '苏州市', '无锡市'].map((item) => ({ label: item, value: item })), - 浙江: ['杭州市', '宁波市', '温州市'].map((item) => ({ label: item, value: item })), - 四川: ['成都市', '绵阳市', '德阳市'].map((item) => ({ label: item, value: item })), - 重庆: [{ label: '重庆市', value: '重庆市' }], - 湖北: ['武汉市', '宜昌市', '襄阳市'].map((item) => ({ label: item, value: item })), - 湖南: ['长沙市', '株洲市', '湘潭市'].map((item) => ({ label: item, value: item })), - 陕西: ['西安市', '咸阳市', '宝鸡市'].map((item) => ({ label: item, value: item })), -}; - const emptyForm: EnterpriseForm = { name: '', creditCode: '', @@ -87,6 +67,16 @@ export function AdminCustomerFormPage() { const [error, setError] = useState(''); const [saving, setSaving] = useState(false); const [uploadingPhoto, setUploadingPhoto] = useState(false); + const [regions, setRegions] = useState([]); + const [regionsLoading, setRegionsLoading] = useState(true); + const [regionError, setRegionError] = useState(''); + + useEffect(() => { + adminApi.listAdministrativeRegions() + .then((items) => { setRegions(items); setRegionError(''); }) + .catch((failure: Error) => { setRegions([]); setRegionError(failure.message || '省市字典加载失败'); }) + .finally(() => setRegionsLoading(false)); + }, []); useEffect(() => { if (!enterpriseId) { @@ -101,10 +91,23 @@ export function AdminCustomerFormPage() { .catch((failure: Error) => setError(failure.message || '企业信息加载失败')); }, [enterpriseId]); - const cityOptions = useMemo(() => [ - { label: '请选择市/区', value: '' }, - ...(cityOptionsByProvince[form.province] ?? []), - ], [form.province]); + const provinceOptions = useMemo(() => { + const values = regions.map((item) => item.province); + if (form.province && !values.includes(form.province)) values.push(form.province); + return [ + { label: regionsLoading ? '正在加载省市字典...' : '请选择省/直辖市', value: '' }, + ...values.map((item) => ({ label: item, value: item })), + ]; + }, [form.province, regions, regionsLoading]); + + const cityOptions = useMemo(() => { + const values = [...(regions.find((item) => item.province === form.province)?.cities ?? [])]; + if (form.city && !values.includes(form.city)) values.push(form.city); + return [ + { label: form.province ? '请选择地市' : '请先选择省份', value: '' }, + ...values.map((item) => ({ label: item, value: item })), + ]; + }, [form.city, form.province, regions]); function updateForm(key: K, value: EnterpriseForm[K]) { setForm((current) => ({ @@ -178,6 +181,7 @@ export function AdminCustomerFormPage() { {error ?

{error}

: null} + {regionError ?

{regionError},请刷新后重试。

: null}
@@ -233,7 +237,7 @@ export function AdminCustomerFormPage() {
updateForm('city', event.target.value)} options={cityOptions} value={form.city} /> +