From f02c33cbb7248410c189f75502d6e497fff7b355 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Mon, 20 Jul 2026 18:07:29 +0800 Subject: [PATCH] feat: harden CMPP delivery and platform workflows --- .../migration.sql | 2 + .../migration.sql | 26 +++ .../migration.sql | 9 + .../migration.sql | 11 + api/prisma/schema.prisma | 12 ++ api/src/app.module.ts | 5 +- .../manual-operation-audit.middleware.spec.ts | 41 ++++ .../manual-operation-audit.middleware.ts | 64 ++++++ .../dictionaries/dictionaries.controller.ts | 5 + api/src/dictionaries/dictionaries.service.ts | 37 +++- api/src/files/files.controller.ts | 2 +- api/src/files/files.service.spec.ts | 21 ++ api/src/files/files.service.ts | 15 +- api/src/open-api/open-api.controller.ts | 7 +- api/src/open-api/open-api.dto.ts | 35 ++++ api/src/open-api/open-api.service.spec.ts | 14 +- api/src/open-api/open-api.service.ts | 6 +- .../report-materials.controller.ts | 35 +++- .../report-materials.service.spec.ts | 28 +++ .../report-materials.service.ts | 77 ++++++- api/src/reports/reports.service.ts | 45 +++- .../admin-risk-review.controller.ts | 14 +- api/src/risk-review/risk-review.service.ts | 12 +- api/src/send-chain/send-chain.service.spec.ts | 186 ++++++++++++++++- api/src/send-chain/send-chain.service.ts | 197 ++++++++++++++++-- .../client-sms-config.controller.ts | 20 +- api/src/sms-config/sms-config.service.spec.ts | 30 ++- api/src/sms-config/sms-config.service.ts | 60 +++++- api/src/tenants/tenants.service.spec.ts | 10 +- api/src/tenants/tenants.service.ts | 10 +- api/src/users/user-response.dto.ts | 32 +++ api/src/users/users.controller.ts | 46 ++-- api/src/users/users.service.spec.ts | 106 +++++++++- api/src/users/users.service.ts | 112 +++++++--- api/tools/verify-receipt-api.ts | 56 +++++ api/tools/verify-report-recalculation.ts | 82 ++++++++ ...20260720-receipt-report-migration-notes.md | 12 ++ .../first-version-development-requirements.md | 29 ++- docs/system-functional-test-cases.md | 41 +++- docs/testing-progress.md | 41 ++++ gateway/internal/inbound/server.go | 127 ++++++----- gateway/internal/inbound/server_test.go | 31 ++- src/api/adminApi.ts | 24 ++- src/apps/admin/AdminCustomerFormPage.tsx | 3 +- .../admin/AdminEnterpriseApplicationsPage.tsx | 34 ++- .../admin/AdminEnterpriseSignaturesPage.tsx | 6 +- src/apps/admin/AdminPhoneSegmentsPage.tsx | 30 ++- src/apps/admin/AdminProfitReportsPage.tsx | 10 +- src/apps/admin/AdminQualityReportsPage.tsx | 10 +- .../admin/AdminReconciliationReportsPage.tsx | 10 +- .../admin/AdminSmsApplicationFormPage.tsx | 19 +- src/apps/admin/AdminSmsAuditPage.tsx | 39 +++- src/apps/admin/AdminSmsRecordsPage.tsx | 34 ++- src/apps/client/ClientTemplatesPage.tsx | 2 +- src/apps/client/ClientUsersPage.css | 22 ++ src/apps/client/ClientUsersPage.tsx | 5 +- src/components/ui/FileActions.tsx | 2 +- src/layouts/AdminLayout.tsx | 3 + src/styles/global.css | 84 +++++++- src/utils/fileUpload.ts | 16 ++ src/utils/interfaceParams.ts | 11 +- 61 files changed, 1834 insertions(+), 281 deletions(-) create mode 100644 api/prisma/migrations/20260718130000_add_cmpp_submit_group_message_id/migration.sql create mode 100644 api/prisma/migrations/20260720110000_add_receipt_identity/migration.sql create mode 100644 api/prisma/migrations/20260720113000_add_report_business_metrics/migration.sql create mode 100644 api/prisma/migrations/20260720114500_add_receipt_phone_number/migration.sql create mode 100644 api/src/audit/manual-operation-audit.middleware.spec.ts create mode 100644 api/src/audit/manual-operation-audit.middleware.ts create mode 100644 api/src/open-api/open-api.dto.ts create mode 100644 api/src/users/user-response.dto.ts create mode 100644 api/tools/verify-receipt-api.ts create mode 100644 api/tools/verify-report-recalculation.ts create mode 100644 docs/20260720-receipt-report-migration-notes.md create mode 100644 src/apps/client/ClientUsersPage.css create mode 100644 src/utils/fileUpload.ts diff --git a/api/prisma/migrations/20260718130000_add_cmpp_submit_group_message_id/migration.sql b/api/prisma/migrations/20260718130000_add_cmpp_submit_group_message_id/migration.sql new file mode 100644 index 0000000..9eec6a9 --- /dev/null +++ b/api/prisma/migrations/20260718130000_add_cmpp_submit_group_message_id/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "SmsMessageRecord" +ADD COLUMN "cmppSubmitGroupMessageId" TEXT; diff --git a/api/prisma/migrations/20260720110000_add_receipt_identity/migration.sql b/api/prisma/migrations/20260720110000_add_receipt_identity/migration.sql new file mode 100644 index 0000000..ce84acf --- /dev/null +++ b/api/prisma/migrations/20260720110000_add_receipt_identity/migration.sql @@ -0,0 +1,26 @@ +ALTER TABLE "SmsMessageRecord" + ADD COLUMN "receiptRawStatus" TEXT; + +ALTER TABLE "SmsReceiptRecord" + ADD COLUMN "receiptKey" TEXT, + ADD COLUMN "errorMessage" TEXT; + +UPDATE "SmsReceiptRecord" +SET "receiptKey" = MD5( + COALESCE("channelId", '') || E'\u0001' || + "gatewayMessageId" || E'\u0001' || + "receiptStatus" || E'\u0001' || + "rawStatus" +) || MD5("id"); + +ALTER TABLE "SmsReceiptRecord" + ALTER COLUMN "receiptKey" SET NOT NULL; + +CREATE UNIQUE INDEX "SmsReceiptRecord_receiptKey_key" + ON "SmsReceiptRecord"("receiptKey"); + +CREATE INDEX "SmsSubmitRecord_channelId_gatewayMessageId_idx" + ON "SmsSubmitRecord"("channelId", "gatewayMessageId"); + +CREATE INDEX "SmsReceiptRecord_channelId_gatewayMessageId_idx" + ON "SmsReceiptRecord"("channelId", "gatewayMessageId"); diff --git a/api/prisma/migrations/20260720113000_add_report_business_metrics/migration.sql b/api/prisma/migrations/20260720113000_add_report_business_metrics/migration.sql new file mode 100644 index 0000000..982e5b0 --- /dev/null +++ b/api/prisma/migrations/20260720113000_add_report_business_metrics/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE "DailyReconciliationReport" + ADD COLUMN "failedUnits" INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE "DailyProfitReport" + ADD COLUMN "failedUnits" INTEGER NOT NULL DEFAULT 0, + ADD COLUMN "refundCents" BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE "DailyQualityReport" + ADD COLUMN "failedUnits" INTEGER NOT NULL DEFAULT 0; diff --git a/api/prisma/migrations/20260720114500_add_receipt_phone_number/migration.sql b/api/prisma/migrations/20260720114500_add_receipt_phone_number/migration.sql new file mode 100644 index 0000000..962422d --- /dev/null +++ b/api/prisma/migrations/20260720114500_add_receipt_phone_number/migration.sql @@ -0,0 +1,11 @@ +ALTER TABLE "SmsReceiptRecord" + ADD COLUMN "phoneNumber" TEXT; + +UPDATE "SmsReceiptRecord" receipt +SET "phoneNumber" = message."phoneNumber" +FROM "SmsMessageRecord" message +WHERE receipt."messageRecordId" = message.id + AND receipt."phoneNumber" IS NULL; + +CREATE INDEX "SmsReceiptRecord_channelId_gatewayMessageId_phoneNumber_idx" + ON "SmsReceiptRecord"("channelId", "gatewayMessageId", "phoneNumber"); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index a17def4..0fc4532 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -1324,11 +1324,13 @@ model SmsMessageRecord { submitId String? gatewayMessageId String? cmppSubmitSequenceId String? + cmppSubmitGroupMessageId String? clientSrcId String? applicationExtension String? status String @default("queued") submitStatus String? receiptStatus String? + receiptRawStatus String? errorCode String? errorMessage String? queuedAt DateTime @default(now()) @@ -1407,6 +1409,7 @@ model SmsSubmitRecord { @@index([tenantId, createdAt]) @@index([messageRecordId]) @@index([gatewayMessageId]) + @@index([channelId, gatewayMessageId]) } model DailyReconciliationReport { @@ -1418,6 +1421,7 @@ model DailyReconciliationReport { applicationName String sentUnits Int @default(0) successUnits Int @default(0) + failedUnits Int @default(0) generatedAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1439,7 +1443,9 @@ model DailyProfitReport { channelId String? sentUnits Int @default(0) successUnits Int @default(0) + failedUnits Int @default(0) revenueCents BigInt @default(0) + refundCents BigInt @default(0) costCents BigInt @default(0) profitCents BigInt @default(0) profitRateBps Int @default(0) @@ -1467,6 +1473,7 @@ model DailyQualityReport { drainageInfoId String? sentUnits Int @default(0) successUnits Int @default(0) + failedUnits Int @default(0) successRateBps Int @default(0) avgArrivalMs Int? generatedAt DateTime @default(now()) @@ -1524,13 +1531,16 @@ model SmsReceiptRecord { tenantId String? batchTaskId String? messageRecordId String? + receiptKey String @unique channelId String? messageId String gatewayMessageId String + phoneNumber String? sequenceId Int? receiptStatus String rawStatus String errorCode String? + errorMessage String? deliveredAt DateTime createdAt DateTime @default(now()) @@ -1542,6 +1552,8 @@ model SmsReceiptRecord { @@index([tenantId, createdAt]) @@index([messageId]) @@index([gatewayMessageId]) + @@index([channelId, gatewayMessageId]) + @@index([channelId, gatewayMessageId, phoneNumber]) } model SmsUplinkMessage { diff --git a/api/src/app.module.ts b/api/src/app.module.ts index a270466..f5f8070 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -1,6 +1,7 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AuditModule } from './audit/audit.module'; +import { ManualOperationAuditMiddleware } from './audit/manual-operation-audit.middleware'; import { AuthModule } from './auth/auth.module'; import { SessionValidationMiddleware } from './auth/session-validation.middleware'; import { RequestContextMiddleware } from './common/request-context.middleware'; @@ -46,10 +47,10 @@ import { UsersModule } from './users/users.module'; OpenApiModule, ], controllers: [HealthController], - providers: [RequestContextMiddleware, SessionValidationMiddleware], + providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { - consumer.apply(RequestContextMiddleware, SessionValidationMiddleware).forRoutes('*'); + consumer.apply(RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware).forRoutes('*'); } } diff --git a/api/src/audit/manual-operation-audit.middleware.spec.ts b/api/src/audit/manual-operation-audit.middleware.spec.ts new file mode 100644 index 0000000..b986fb2 --- /dev/null +++ b/api/src/audit/manual-operation-audit.middleware.spec.ts @@ -0,0 +1,41 @@ +import { ManualOperationAuditMiddleware } from './manual-operation-audit.middleware'; + +describe('ManualOperationAuditMiddleware', () => { + it('records a successful authenticated mutation without storing request bodies', async () => { + const prisma = { operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) } }; + const middleware = new ManualOperationAuditMiddleware(prisma as never); + let finish: (() => void) | undefined; + const response = { statusCode: 200, once: (_event: 'finish', listener: () => void) => { finish = listener; } }; + const next = jest.fn(); + + middleware.use({ + method: 'PUT', + originalUrl: '/api/admin/enterprise-applications/c12345678901234567890?view=full', + sessionUserId: 'user-1', + header: () => 'jest', + }, response, next); + finish?.(); + await Promise.resolve(); + + expect(next).toHaveBeenCalled(); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: 'user-1', + action: 'manual_operation.put', + resource: 'enterprise-applications', + resourceId: 'c12345678901234567890', + detail: { method: 'PUT', path: '/api/admin/enterprise-applications/c12345678901234567890', statusCode: 200 }, + }), + }); + }); + + it('does not record unauthenticated, read-only, or failed requests', () => { + const prisma = { operationLog: { create: jest.fn() } }; + const middleware = new ManualOperationAuditMiddleware(prisma as never); + const response = { statusCode: 500, once: (_event: 'finish', listener: () => void) => listener() }; + middleware.use({ method: 'POST', originalUrl: '/api/admin/test', sessionUserId: 'user-1', header: () => undefined }, response, jest.fn()); + middleware.use({ method: 'GET', originalUrl: '/api/admin/test', sessionUserId: 'user-1', header: () => undefined }, response, jest.fn()); + middleware.use({ method: 'POST', originalUrl: '/api/admin/test', header: () => undefined }, response, jest.fn()); + expect(prisma.operationLog.create).not.toHaveBeenCalled(); + }); +}); diff --git a/api/src/audit/manual-operation-audit.middleware.ts b/api/src/audit/manual-operation-audit.middleware.ts new file mode 100644 index 0000000..8e440a1 --- /dev/null +++ b/api/src/audit/manual-operation-audit.middleware.ts @@ -0,0 +1,64 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { requestContext } from '../common/request-context'; +import { PrismaService } from '../prisma/prisma.service'; + +type AuditRequest = { + method?: string; + originalUrl?: string; + url?: string; + sessionUserId?: string; + header(name: string): string | undefined; +}; + +type AuditResponse = { + statusCode?: number; + once(event: 'finish', listener: () => void): void; +}; + +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +@Injectable() +export class ManualOperationAuditMiddleware implements NestMiddleware { + constructor(private readonly prisma: PrismaService) {} + + use(request: AuditRequest, response: AuditResponse, next: () => void) { + const method = (request.method ?? '').toUpperCase(); + const userId = request.sessionUserId; + const path = (request.originalUrl ?? request.url ?? '').split('?')[0]; + if (!userId || !MUTATING_METHODS.has(method)) { + next(); + return; + } + + const { resource, resourceId } = operationResource(path); + const ipAddress = requestContext.getStore()?.ipAddress; + const userAgent = request.header('user-agent'); + response.once('finish', () => { + const statusCode = response.statusCode ?? 200; + if (statusCode >= 400) return; + void this.prisma.operationLog.create({ + data: { + userId, + action: `manual_operation.${method.toLowerCase()}`, + resource, + resourceId, + ipAddress, + userAgent, + detail: { method, path, statusCode }, + }, + }).catch(() => undefined); + }); + next(); + } +} + +function operationResource(path: string) { + const segments = path.split('/').filter(Boolean).filter((segment) => !['api', 'admin', 'client'].includes(segment)); + const resource = segments[0] ?? 'manual_operation'; + const resourceId = segments.find((segment, index) => index > 0 && looksLikeResourceId(segment)); + return { resource, resourceId }; +} + +function looksLikeResourceId(value: string) { + return /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(value) || /^c[a-z0-9]{20,}$/i.test(value); +} diff --git a/api/src/dictionaries/dictionaries.controller.ts b/api/src/dictionaries/dictionaries.controller.ts index e17407b..e3dbc86 100644 --- a/api/src/dictionaries/dictionaries.controller.ts +++ b/api/src/dictionaries/dictionaries.controller.ts @@ -45,6 +45,11 @@ export class DictionariesController { return this.dictionaries.createPhoneCarrierRule(body); } + @Delete('phone-carrier-rules/:id') + deletePhoneCarrierRule(@Param('id') id: string) { + return this.dictionaries.deletePhoneCarrierRule(id); + } + @Get('sensitive-words') listSensitiveWords(@Query('keyword') keyword?: string, @Query('status') status?: string) { return this.dictionaries.listSensitiveWords({ keyword, status }); diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index 9caf005..45438ab 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; @@ -145,6 +145,10 @@ export class DictionariesService { }); } + deletePhoneCarrierRule(id: string) { + return this.prisma.phoneCarrierRule.delete({ where: { id } }); + } + listSensitiveWords(query: DictionaryListQuery = {}) { return this.prisma.sensitiveWord.findMany({ where: { @@ -191,13 +195,13 @@ export class DictionariesService { } async createGlobalBlacklist(data: CreateBlacklistDto) { - const created = await this.prisma.globalBlacklist.create({ - data: { - phoneNumber: data.phoneNumber, - reason: data.reason, - status: data.status ?? 'active', - }, - }); + const created = await this.createBlacklistOrConflict(() => this.prisma.globalBlacklist.create({ + data: { + phoneNumber: data.phoneNumber, + reason: data.reason, + status: data.status ?? 'active', + }, + }), 'global'); await this.writeOperationLog(data.operatorId, 'global_blacklist.create', 'global_blacklist', created.id, { phoneNumber: data.phoneNumber, reason: data.reason, @@ -252,7 +256,7 @@ export class DictionariesService { reason: data.reason, status: data.status ?? 'active', }; - const created = await this.prisma.enterpriseBlacklist.create({ data: createData }); + const created = await this.createBlacklistOrConflict(() => this.prisma.enterpriseBlacklist.create({ data: createData }), 'enterprise'); await this.writeOperationLog(data.operatorId, 'enterprise_blacklist.create', 'enterprise_blacklist', created.id, { tenantId: data.tenantId, applicationId: data.applicationId, @@ -269,6 +273,21 @@ export class DictionariesService { return updated; } + private async createBlacklistOrConflict(operation: () => Promise, scope: 'global' | 'enterprise') { + try { + return await operation(); + } catch (error) { + if ((error as { code?: string }).code !== 'P2002') throw error; + throw new ConflictException({ + code: 'BLACKLIST_DUPLICATE', + field: 'phoneNumber', + message: scope === 'global' + ? '该手机号已存在于全局黑名单;停用或逻辑删除后请恢复原记录' + : '该手机号已存在于当前应用黑名单;停用或逻辑删除后请恢复原记录', + }); + } + } + async listDrainageFields() { const fields = await this.prisma.drainageField.findMany({ include: { _count: { select: { channelReportFields: true, commonReportFields: true } } }, diff --git a/api/src/files/files.controller.ts b/api/src/files/files.controller.ts index 9209e5a..c228e0f 100644 --- a/api/src/files/files.controller.ts +++ b/api/src/files/files.controller.ts @@ -50,7 +50,7 @@ export class FilesController { @Post('upload') @UseInterceptors(FileInterceptor('file', { limits: { - fileSize: 20 * 1024 * 1024, + fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5, diff --git a/api/src/files/files.service.spec.ts b/api/src/files/files.service.spec.ts index 4f784e9..e965803 100644 --- a/api/src/files/files.service.spec.ts +++ b/api/src/files/files.service.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import { FilesService } from './files.service'; describe('FilesService', () => { @@ -81,6 +82,26 @@ describe('FilesService', () => { ); }); + it('rejects images over 2MB and other files over 10MB before object storage writes', async () => { + const prisma = { fileObject: { create: jest.fn() } }; + const objectStorage = { putObject: jest.fn(), getBucket: jest.fn().mockReturnValue('bucket') }; + const service = new FilesService(prisma as never, objectStorage as never); + + await expect(service.upload({ purpose: 'test' }, { + originalname: 'large.png', + mimetype: 'image/png', + size: 2 * 1024 * 1024 + 1, + buffer: Buffer.alloc(0), + })).rejects.toBeInstanceOf(BadRequestException); + await expect(service.upload({ purpose: 'test' }, { + originalname: 'large.pdf', + mimetype: 'application/pdf', + size: 10 * 1024 * 1024 + 1, + buffer: Buffer.alloc(0), + })).rejects.toBeInstanceOf(BadRequestException); + expect(objectStorage.putObject).not.toHaveBeenCalled(); + }); + it('downloads file content from object storage by FileObject id', async () => { const fileObject = { id: 'file-1', diff --git a/api/src/files/files.service.ts b/api/src/files/files.service.ts index 446f208..f56319b 100644 --- a/api/src/files/files.service.ts +++ b/api/src/files/files.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { randomUUID } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; @@ -65,6 +65,7 @@ export class FilesService { } async upload(data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) { + assertUploadSize(file); const fileName = normalizeMultipartFileName(file.originalname); const safeName = fileName.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_'); const objectKey = `${data.prefix ?? data.purpose}/${Date.now()}-${randomUUID()}-${safeName}`; @@ -93,6 +94,18 @@ export class FilesService { } } +const IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024; +const FILE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024; +const IMAGE_FILE_EXTENSION = /\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|webp)$/i; + +function assertUploadSize(file: { originalname: string; mimetype: string; size: number }) { + const image = file.mimetype.toLowerCase().startsWith('image/') || IMAGE_FILE_EXTENSION.test(file.originalname); + const limit = image ? IMAGE_UPLOAD_MAX_BYTES : FILE_UPLOAD_MAX_BYTES; + if (file.size > limit) { + throw new BadRequestException(image ? '图片大小不能超过 2MB' : '文件大小不能超过 10MB'); + } +} + function normalizeMultipartFileName(value: string) { if (![...value].some((character) => character.charCodeAt(0) > 0x7f) || [...value].some((character) => character.charCodeAt(0) > 0xff)) { return value; diff --git a/api/src/open-api/open-api.controller.ts b/api/src/open-api/open-api.controller.ts index 2c377b8..cd33483 100644 --- a/api/src/open-api/open-api.controller.ts +++ b/api/src/open-api/open-api.controller.ts @@ -1,10 +1,11 @@ import { Body, Controller, Get, Headers, HttpCode, Param, Post, Query, Req, UseFilters, UseGuards } from '@nestjs/common'; -import { ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiHeader, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { createHash } from 'node:crypto'; import { OpenApiAuthGuard } from './open-api-auth.guard'; import { OpenApiService } from './open-api.service'; import type { OpenApiRequestLike } from './open-api.types'; import { OpenApiExceptionFilter } from './open-api-exception.filter'; +import { OpenApiSendMessageDto, OpenApiSendMessageResponseDto } from './open-api.dto'; @ApiTags('client-open-api-v1') @ApiHeader({ name: 'X-App-Key', required: true }) @@ -21,7 +22,9 @@ export class OpenApiController { @HttpCode(202) @ApiHeader({ name: 'Idempotency-Key', required: true }) @ApiOperation({ summary: '发送单条短信' }) - sendMessage(@Req() request: OpenApiRequestLike, @Body() body: { mobile?: string; content?: string; templateId?: string; clientMessageId?: string }, @Headers('idempotency-key') idempotencyKey?: string, @Headers('user-agent') userAgent?: string) { + @ApiBody({ type: OpenApiSendMessageDto }) + @ApiResponse({ status: 202, type: OpenApiSendMessageResponseDto }) + sendMessage(@Req() request: OpenApiRequestLike, @Body() body: OpenApiSendMessageDto, @Headers('idempotency-key') idempotencyKey?: string, @Headers('user-agent') userAgent?: string) { return this.service.sendMessage(request.openApiAuth!, body, { idempotencyKey, bodyHash: createHash('sha256').update(request.rawBody ?? Buffer.from(JSON.stringify(body ?? {}))).digest('hex'), userAgent }); } diff --git a/api/src/open-api/open-api.dto.ts b/api/src/open-api/open-api.dto.ts new file mode 100644 index 0000000..8c14092 --- /dev/null +++ b/api/src/open-api/open-api.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class OpenApiSendMessageDto { + @ApiProperty({ example: '13800138000', description: '中国大陆手机号' }) + mobile!: string; + + @ApiProperty({ + example: '【示例签名】您的验证码是123456,5分钟内有效。', + description: '完整短信正文;后端自动识别已审核签名、模板及变量值,不接受内部签名或模板 ID', + }) + content!: string; + + @ApiPropertyOptional({ example: 'order-20260720-0001', maxLength: 128 }) + clientMessageId?: string; +} + +export class OpenApiSendMessageResponseDto { + @ApiProperty({ example: 'ACCEPTED' }) + code!: string; + + @ApiProperty({ example: 'req_7e9a7d85-26df-4cc4-a2af-b61cb46c5cf6' }) + requestId!: string; + + @ApiProperty({ example: 'MSG-7e9a7d85-26df-4cc4-a2af-b61cb46c5cf6' }) + messageId!: string; + + @ApiPropertyOptional({ example: 'order-20260720-0001', nullable: true }) + clientMessageId!: string | null; + + @ApiProperty({ example: 'queued' }) + status!: string; + + @ApiProperty({ example: '2026-07-20T08:00:00.000Z' }) + acceptedAt!: string; +} diff --git a/api/src/open-api/open-api.service.spec.ts b/api/src/open-api/open-api.service.spec.ts index 2fff11e..77ac6e1 100644 --- a/api/src/open-api/open-api.service.spec.ts +++ b/api/src/open-api/open-api.service.spec.ts @@ -15,22 +15,22 @@ describe('OpenApiService', () => { const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'completed', responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' } }) }, }; - const sendChain = { createBatchTask: jest.fn() }; + const sendChain = { createHttpBatchTask: jest.fn() }; const service = new OpenApiService(prisma as never, sendChain as never); const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' }); expect(result).toEqual({ code: 'ACCEPTED', messageId: 'MSG-1' }); - expect(sendChain.createBatchTask).not.toHaveBeenCalled(); + expect(sendChain.createHttpBatchTask).not.toHaveBeenCalled(); }); it('rejects reuse of an idempotency key with a different body', async () => { const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'old', status: 'completed' }) } }; - const service = new OpenApiService(prisma as never, { createBatchTask: jest.fn() } as never); + const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never); await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'new' })).rejects.toBeInstanceOf(ConflictException); }); it('replays the same persisted business rejection', async () => { const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'failed', httpStatus: 422, responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' } }) } }; - const service = new OpenApiService(prisma as never, { createBatchTask: jest.fn() } as never); + const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never); await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' })).rejects.toMatchObject({ status: 422 }); }); @@ -43,10 +43,10 @@ describe('OpenApiService', () => { }, smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) }, }; - const sendChain = { createBatchTask: jest.fn().mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }) }; + const sendChain = { createHttpBatchTask: jest.fn().mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }) }; const service = new OpenApiService(prisma as never, sendChain as never); const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' }); - expect(sendChain.createBatchTask).toHaveBeenCalledWith(expect.objectContaining({ sourceType: 'api', phones: ['18821203795'], clientMessageId: 'client-1' })); + expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith(expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' })); expect(result).toEqual(expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' })); expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }) })); }); @@ -56,7 +56,7 @@ describe('OpenApiService', () => { openApiRequest: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'request-row-1' }), update: jest.fn().mockResolvedValue({}) }, smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) }, }; - const service = new OpenApiService(prisma as never, { createBatchTask: jest.fn().mockRejectedValue(new BadRequestException('短信未匹配模板')) } as never); + const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn().mockRejectedValue(new BadRequestException('短信未匹配模板')) } as never); await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '未匹配模板' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' })).rejects.toMatchObject({ status: 422 }); expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'failed', httpStatus: 422, businessCode: 'SEND_REJECTED' }) })); }); diff --git a/api/src/open-api/open-api.service.ts b/api/src/open-api/open-api.service.ts index 1677a40..c5078a1 100644 --- a/api/src/open-api/open-api.service.ts +++ b/api/src/open-api/open-api.service.ts @@ -151,7 +151,7 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy { return { ...endpoint, ...(secret ? { secret, secretShownOnce: true } : {}) }; } - async sendMessage(auth: OpenApiAuthContext, input: { mobile?: string; content?: string; templateId?: string; clientMessageId?: string }, meta: { idempotencyKey?: string; bodyHash: string; userAgent?: string }) { + async sendMessage(auth: OpenApiAuthContext, input: { mobile?: string; content?: string; clientMessageId?: string }, meta: { idempotencyKey?: string; bodyHash: string; userAgent?: string }) { if (!auth.config.sendEnabled) throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' }); const mobile = String(input.mobile ?? '').trim(); const content = String(input.content ?? ''); @@ -188,13 +188,11 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy { throw error; } try { - const task = await this.sendChain.createBatchTask({ + const task = await this.sendChain.createHttpBatchTask({ tenantId: auth.application.tenantId, applicationId: auth.application.id, - templateId: input.templateId, content, phones: [mobile], - sourceType: 'api', sourceIp: auth.sourceIp, userAgent: meta.userAgent, clientMessageId: input.clientMessageId, diff --git a/api/src/report-materials/report-materials.controller.ts b/api/src/report-materials/report-materials.controller.ts index f7ad8ac..2e57430 100644 --- a/api/src/report-materials/report-materials.controller.ts +++ b/api/src/report-materials/report-materials.controller.ts @@ -1,10 +1,12 @@ -import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Get, Param, Post, Put, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags } from '@nestjs/swagger'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { CreateImportProfileDto, CreateReportBatchDto, ImportCommitDto, ReportMaterialsService } from './report-materials.service'; type UploadedWorkbook = { originalname: string; mimetype: string; size: number; buffer: Buffer }; +type DownloadResponse = { setHeader(name: string, value: string): void; send(content: Buffer): void }; @ApiTags('report-materials') @Controller('admin/report-materials') @@ -16,6 +18,17 @@ export class ReportMaterialsController { return this.service.listPending({ reportType, tenantId, applicationId }); } + @Get('templates/:reportType') + async downloadTemplate(@Param('reportType') reportType: 'signature' | 'drainage', @CurrentSessionUserId() operatorId: string | undefined, @Res() response: DownloadResponse) { + if (!['signature', 'drainage'].includes(reportType)) throw new BadRequestException('reportType must be signature or drainage'); + this.sendWorkbook(response, await this.service.buildOfficialTemplate(reportType, operatorId)); + } + + @Get('pending/export') + async exportPending(@Query('reportType') reportType: 'signature' | 'drainage' | undefined, @Query('tenantId') tenantId: string | undefined, @Query('applicationId') applicationId: string | undefined, @CurrentSessionUserId() operatorId: string | undefined, @Res() response: DownloadResponse) { + this.sendWorkbook(response, await this.service.exportPending({ reportType, tenantId, applicationId }, operatorId)); + } + @Get('import-profiles') listImportProfiles(@Query('reportType') reportType?: 'signature' | 'drainage') { return this.service.listImportProfiles(reportType); @@ -28,8 +41,8 @@ export class ReportMaterialsController { } @Post('imports/analyze') - @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 100 * 1024 * 1024, files: 1, fields: 12, parts: 13 } })) - analyzeImport(@UploadedFile() file: UploadedWorkbook, @Body() body: Record) { + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 12, parts: 13 } })) + analyzeImport(@UploadedFile() file: UploadedWorkbook, @Body() body: Record, @CurrentSessionUserId() operatorId?: string) { if (!file) throw new BadRequestException('请选择 XLSX 文件'); return this.service.analyzeImport(file, { tenantId: body.tenantId, @@ -39,13 +52,14 @@ export class ReportMaterialsController { headerRowCount: Number(body.headerRowCount || 1), dataStartRow: Number(body.dataStartRow || 2), profileId: body.profileId || undefined, + operatorId, }); } @Put('imports/:id/commit') @RequireRecentAuthentication() - commitImport(@Param('id') id: string, @Body() body: ImportCommitDto) { - return this.service.commitImport(id, body); + commitImport(@Param('id') id: string, @Body() body: ImportCommitDto, @CurrentSessionUserId() operatorId?: string) { + return this.service.commitImport(id, { ...body, operatorId }); } @Get('batches') @@ -55,7 +69,14 @@ export class ReportMaterialsController { @Post('batches') @RequireRecentAuthentication() - createBatch(@Body() body: CreateReportBatchDto) { - return this.service.createBatch(body); + createBatch(@Body() body: CreateReportBatchDto, @CurrentSessionUserId() operatorId?: string) { + return this.service.createBatch({ ...body, createdById: operatorId }); + } + + + private sendWorkbook(response: DownloadResponse, exported: { fileName: string; content: Buffer }) { + response.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`); + response.send(exported.content); } } diff --git a/api/src/report-materials/report-materials.service.spec.ts b/api/src/report-materials/report-materials.service.spec.ts index 8f7b52e..f164e64 100644 --- a/api/src/report-materials/report-materials.service.spec.ts +++ b/api/src/report-materials/report-materials.service.spec.ts @@ -2,6 +2,33 @@ import ExcelJS from 'exceljs'; import { ReportMaterialsService } from './report-materials.service'; describe('ReportMaterialsService', () => { + it('builds an official XLSX import template with documented signature columns', async () => { + const operationLog = { create: jest.fn().mockResolvedValue({ id: 'log-template' }) }; + const service = new ReportMaterialsService({ operationLog } as never, {} as never, {} as never); + const exported = await service.buildOfficialTemplate('signature', 'operator-1'); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(exported.content as never); + expect(exported.fileName).toContain('签名报备资料官方模板'); + expect(workbook.worksheets[0].getRow(1).values).toEqual(expect.arrayContaining(['短信签名', '用途说明'])); + expect(operationLog.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: 'operator-1' }) }); + }); + + it('rejects formula cells before storing or importing a workbook', async () => { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('签名资料'); + sheet.addRow(['短信签名']); + sheet.getCell('A2').value = { formula: 'HYPERLINK("https://invalid.example","click")', result: 'click' }; + const buffer = Buffer.from(await workbook.xlsx.writeBuffer()); + const files = { upload: jest.fn() }; + const service = new ReportMaterialsService({ reportMaterialImportProfile: { findUnique: jest.fn() } } as never, files as never, {} as never); + + await expect(service.analyzeImport( + { originalname: 'unsafe.xlsx', mimetype: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: buffer.length, buffer }, + { tenantId: 'tenant-1', reportType: 'signature', headerRowCount: 1, dataStartRow: 2 }, + )).rejects.toThrow('公式或可执行单元格'); + expect(files.upload).not.toHaveBeenCalled(); + }); + it('detects WPS-compatible embedded images and source columns during XLSX analysis', async () => { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('签名资料'); @@ -13,6 +40,7 @@ describe('ReportMaterialsService', () => { const prisma = { reportMaterialImportProfile: { findUnique: jest.fn().mockResolvedValue({ sheetName: '签名资料', columns: [{ sourceHeader: '短信签名', sourceHeaderPath: '短信签名', sourceColumnIndex: 9, targetFieldCode: 'signature_name', targetKind: 'signatureName', fieldType: 'string', required: true, sortOrder: 10 }] }) }, reportMaterialImportBatch: { create: jest.fn().mockImplementation(({ data }: { data: Record }) => Promise.resolve({ id: 'import-1', ...data })) }, + operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) }, }; const files = { upload: jest.fn().mockResolvedValue({ id: 'source-1', fileName: '签名资料.xlsx', contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) }; const service = new ReportMaterialsService(prisma as never, files as never, {} as never); diff --git a/api/src/report-materials/report-materials.service.ts b/api/src/report-materials/report-materials.service.ts index 47d57b4..a5d7e04 100644 --- a/api/src/report-materials/report-materials.service.ts +++ b/api/src/report-materials/report-materials.service.ts @@ -35,6 +35,7 @@ export interface CreateImportProfileDto { export interface ImportCommitDto { mappings: ImportMapping[]; profile?: CreateImportProfileDto; + operatorId?: string; } export interface CreateReportBatchDto { @@ -50,6 +51,7 @@ type AnalyzeImportOptions = { headerRowCount: number; dataStartRow: number; profileId?: string; + operatorId?: string; }; type EmbeddedImage = { row: number; column: number; extension: string; buffer: Buffer }; @@ -62,6 +64,48 @@ export class ReportMaterialsService { private readonly smsConfig: SmsConfigService, ) {} + async buildOfficialTemplate(reportType: 'signature' | 'drainage', operatorId?: string) { + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'CMPP短信平台'; + const sheet = workbook.addWorksheet(reportType === 'signature' ? '签名资料' : '引流信息', { views: [{ state: 'frozen', ySplit: 1 }] }); + const headers = reportType === 'signature' + ? ['短信签名', '用途说明', '营业执照图片', '授权书图片', '备注'] + : ['短信签名', '站点名称', 'URL', '备注', '网站截图']; + sheet.addRow(headers); + sheet.addRow(reportType === 'signature' + ? ['示例签名', '验证码通知', '请在本单元格插入图片', '请在本单元格插入图片', '示例行,导入前请删除'] + : ['示例签名', '官方站点', 'https://example.com', '示例行,导入前请删除', '请在本单元格插入图片']); + styleHeader(sheet.getRow(1)); + sheet.columns.forEach((column) => { column.width = 24; }); + sheet.getRow(2).height = 48; + const content = Buffer.from(await workbook.xlsx.writeBuffer()); + const fileName = `${reportType === 'signature' ? '签名' : '引流信息'}报备资料官方模板.xlsx`; + await this.prisma.operationLog.create({ data: { + userId: operatorId, action: 'report_material.template_downloaded', resource: 'report_material', + detail: { fileName, filters: { reportType }, successCount: 1, failedCount: 0 } as Prisma.InputJsonValue, + } }); + return { fileName, content }; + } + + async exportPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }, operatorId?: string) { + const items = await this.listPending(query); + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('待报备资料', { views: [{ state: 'frozen', ySplit: 1 }] }); + sheet.addRow(['资料类型', '企业', '企业应用', '签名/站点', '详情', '变更时间']); + styleHeader(sheet.getRow(1)); + for (const item of items) sheet.addRow([ + item.reportType === 'signature' ? '签名' : '引流信息', safeSpreadsheetText(item.tenant?.name), + safeSpreadsheetText(item.application?.name), safeSpreadsheetText(item.name), safeSpreadsheetText(item.detail), item.changedAt, + ]); + sheet.columns.forEach((column, index) => { column.width = index === 4 ? 42 : 22; }); + const fileName = `待报备资料-${new Date().toISOString().slice(0, 10)}.xlsx`; + await this.prisma.operationLog.create({ data: { + tenantId: query.tenantId, userId: operatorId, action: 'report_material.pending_export', resource: 'report_material', + detail: { fileName, filters: query, successCount: items.length, failedCount: 0 } as Prisma.InputJsonValue, + } }); + return { fileName, content: Buffer.from(await workbook.xlsx.writeBuffer()) }; + } + async listPending(query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string }) { const [signatures, drainageInfos] = await Promise.all([ query.reportType === 'drainage' ? Promise.resolve([]) : this.prisma.smsSignature.findMany({ @@ -119,6 +163,7 @@ export class ReportMaterialsService { if (!['signature', 'drainage'].includes(options.reportType)) throw new BadRequestException('reportType must be signature or drainage'); if (extname(file.originalname).toLowerCase() !== '.xlsx' || file.buffer[0] !== 0x50 || file.buffer[1] !== 0x4b) throw new BadRequestException('仅支持有效的 XLSX 文件'); const workbook = await loadWorkbook(file.buffer); + assertSafeWorkbook(workbook); const profile = options.profileId ? await this.prisma.reportMaterialImportProfile.findUnique({ where: { id: options.profileId }, include: { columns: { orderBy: { sortOrder: 'asc' } } } }) : null; const selectedSheetName = options.sheetName || profile?.sheetName || undefined; const worksheet = selectedSheetName ? workbook.getWorksheet(selectedSheetName) : workbook.worksheets[0]; @@ -174,6 +219,10 @@ export class ReportMaterialsService { rowCount: Math.max(0, worksheet.rowCount - dataStartRow + 1), }, }); + await this.prisma.operationLog.create({ data: { + tenantId: options.tenantId, userId: options.operatorId, action: 'report_material.import_analyzed', resource: 'report_material_import', resourceId: batch.id, + detail: { fileName: sourceFile.fileName, filters: { applicationId: options.applicationId, reportType: options.reportType, sheetName: worksheet.name }, successCount: previewRows.length, failedCount: 0 } as Prisma.InputJsonValue, + } }); return { ...batch, sourceFile, sheets: workbook.worksheets.map((sheet) => sheet.name), columns, rows: previewRows, imageCount: images.length, suggestedMappings }; } @@ -184,6 +233,7 @@ export class ReportMaterialsService { if (data.profile) await this.saveImportProfile({ ...data.profile, reportType: batch.reportType as 'signature' | 'drainage', columns: data.mappings }); const { content } = await this.files.getDownload(batch.fileObjectId); const workbook = await loadWorkbook(content); + assertSafeWorkbook(workbook); const worksheet = workbook.getWorksheet(batch.sheetName); if (!worksheet) throw new BadRequestException('导入工作表不存在'); const images = readEmbeddedImages(workbook, worksheet); @@ -218,7 +268,7 @@ export class ReportMaterialsService { failures.push({ rowNumber, reason: error instanceof Error ? error.message : '导入失败' }); } } - return this.prisma.reportMaterialImportBatch.update({ + const updated = await this.prisma.reportMaterialImportBatch.update({ where: { id: batchId }, data: { status: failures.length ? (successCount ? 'partial_failed' : 'failed') : 'completed', @@ -229,6 +279,11 @@ export class ReportMaterialsService { completedAt: new Date(), }, }); + await this.prisma.operationLog.create({ data: { + tenantId: batch.tenantId, userId: data.operatorId, action: 'report_material.import_committed', resource: 'report_material_import', resourceId: batch.id, + detail: { fileName: batch.fileName, filters: { applicationId: batch.applicationId, reportType: batch.reportType, sheetName: batch.sheetName }, successCount, failedCount: failures.length } as Prisma.InputJsonValue, + } }); + return updated; } listBatches() { @@ -402,6 +457,26 @@ async function loadWorkbook(buffer: Buffer) { return workbook; } +function assertSafeWorkbook(workbook: ExcelJS.Workbook) { + for (const worksheet of workbook.worksheets) { + worksheet.eachRow((row) => row.eachCell((cell) => { + const value = cell.value; + if (value && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) { + throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`); + } + const text = typeof value === 'string' ? value.trimStart() : ''; + if (/^[=+@]/.test(text) || /^-[^\d.]/.test(text)) { + throw new BadRequestException(`工作表 ${worksheet.name} 包含公式或可执行单元格`); + } + })); + } +} + +function safeSpreadsheetText(value: unknown) { + const text = value == null ? '' : String(value); + return /^[=+@]/.test(text) || /^-[^\d.]/.test(text) ? `'${text}` : text; +} + function readEmbeddedImages(workbook: ExcelJS.Workbook, worksheet: ExcelJS.Worksheet): EmbeddedImage[] { const getImages = (worksheet as unknown as { getImages?: () => Array<{ imageId: number; range: { tl: { nativeRow?: number; nativeCol?: number; row?: number; col?: number } } }> }).getImages; if (!getImages) return []; diff --git a/api/src/reports/reports.service.ts b/api/src/reports/reports.service.ts index fe642b6..6375e18 100644 --- a/api/src/reports/reports.service.ts +++ b/api/src/reports/reports.service.ts @@ -74,19 +74,19 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { async exportReconciliation(query: ReportListQuery) { const items = await this.prisma.dailyReconciliationReport.findMany({ where: reconciliationWhere(query), orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }] }); - return csvExport('对账单', ['发送日期', '企业', '企业应用', '日发送条数', '成功条数', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.tenantName, item.applicationName, item.sentUnits, item.successUnits, formatCsvDate(item.generatedAt)])); + return csvExport('对账单', ['发送日期', '企业', '企业应用', '发送条数', '成功条数', '失败条数', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.tenantName, item.applicationName, item.sentUnits, item.successUnits, item.failedUnits, formatCsvDate(item.generatedAt)])); } async exportProfit(query: ReportListQuery) { const { dimensionType, where } = profitWhere(query); const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] }); - return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '发送条数', '成功条数', '消费金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)])); + return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '发送条数', '成功条数', '失败条数', '净消费金额(元)', '返还金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.refundCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)])); } async exportQuality(query: ReportListQuery) { const { dimensionType, where } = qualityWhere(query); const items = await this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }] }); - return csvExport(`发送质量报表-${dimensionType}`, ['发送日期', '统计对象', '企业', '发送条数', '成功条数', '成功率(%)', '平均到达时长(毫秒)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)])); + return csvExport(`发送质量报表-${dimensionType}`, ['发送日期', '统计对象', '企业', '发送条数', '成功条数', '失败条数', '成功率(%)', '平均到达时长(毫秒)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, item.failedUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)])); } async refreshRollingWindow(now = new Date()) { @@ -119,7 +119,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { await tx.$executeRaw(Prisma.sql` INSERT INTO "DailyReconciliationReport" ( "id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName", - "sentUnits", "successUnits", "generatedAt", "updatedAt" + "sentUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt" ) SELECT CONCAT('recon-', MD5(${day.key} || ':' || tenant.id || ':' || application.id)), @@ -130,6 +130,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { application.name, COALESCE(SUM(message."billingUnits"), 0)::integer, COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer, + COALESCE(SUM(CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END), 0)::integer, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM "SmsMessageRecord" message @@ -142,7 +143,9 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { await tx.$executeRaw(Prisma.sql` WITH billing AS ( - SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue + SELECT "messageId", + SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue, + SUM(CASE WHEN "billingStatus" = 'refunded' THEN "amountCents" ELSE 0 END)::bigint AS refund FROM "SmsBillingRecord" GROUP BY "messageId" ), costs AS ( @@ -154,7 +157,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { INSERT INTO "DailyProfitReport" ( "id", "reportDate", "dimensionType", "dimensionId", "dimensionName", "tenantId", "tenantName", "applicationId", "channelId", - "sentUnits", "successUnits", "revenueCents", "costCents", "profitCents", "profitRateBps", + "sentUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps", "generatedAt", "updatedAt" ) SELECT @@ -169,7 +172,9 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { NULL, COALESCE(SUM(message."billingUnits"), 0)::integer, COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer, + COALESCE(SUM(CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END), 0)::integer, COALESCE(SUM(billing.revenue), 0)::bigint, + COALESCE(SUM(billing.refund), 0)::bigint, COALESCE(SUM(costs.cost), 0)::bigint, (COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0))::bigint, CASE WHEN COALESCE(SUM(billing.revenue), 0) = 0 THEN 0 @@ -188,14 +193,16 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { await tx.$executeRaw(Prisma.sql` WITH billing AS ( - SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue + SELECT "messageId", + SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue, + SUM(CASE WHEN "billingStatus" = 'refunded' THEN "amountCents" ELSE 0 END)::bigint AS refund FROM "SmsBillingRecord" GROUP BY "messageId" ) INSERT INTO "DailyProfitReport" ( "id", "reportDate", "dimensionType", "dimensionId", "dimensionName", "tenantId", "tenantName", "applicationId", "channelId", - "sentUnits", "successUnits", "revenueCents", "costCents", "profitCents", "profitRateBps", + "sentUnits", "successUnits", "failedUnits", "revenueCents", "refundCents", "costCents", "profitCents", "profitRateBps", "generatedAt", "updatedAt" ) SELECT @@ -215,7 +222,14 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy { AND receipt."channelId" = submit."channelId" AND receipt."receiptStatus" = 'delivered' ) THEN message."billingUnits" ELSE 0 END), 0)::integer, + COALESCE(SUM(CASE WHEN EXISTS ( + SELECT 1 FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" = 'undelivered' + ) OR submit."submitStatus" IN ('rejected', 'timeout') THEN message."billingUnits" ELSE 0 END), 0)::integer, COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::bigint, + COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.refund ELSE 0 END), 0)::bigint, COALESCE(SUM(submit."costAmountCents"), 0)::bigint, (COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0))::bigint, CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) = 0 THEN 0 @@ -267,6 +281,7 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat application.id AS application_id, message."billingUnits" AS billing_units, CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END AS success_units, + CASE WHEN message.status IN ('submit_failed', 'failed', 'timeout') OR message."receiptStatus" = 'undelivered' THEN message."billingUnits" ELSE 0 END AS failed_units, CASE WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered') AND message."submittedAt" IS NOT NULL AND message."deliveredAt" >= message."submittedAt" THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000 END AS arrival_ms @@ -284,7 +299,7 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat INSERT INTO "DailyQualityReport" ( "id", "reportDate", "dimensionType", "dimensionId", "dimensionName", "tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId", - "sentUnits", "successUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt" + "sentUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt" ) SELECT CONCAT('quality-', ${dimensionTypeSql}, '-', MD5(${day.key} || ':' || base.dimension_id)), @@ -300,6 +315,7 @@ function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'applicat CASE WHEN ${dimensionTypeSql} = 'drainage' AND base.dimension_id NOT LIKE 'unmatched:%' THEN base.dimension_id ELSE NULL END, SUM(base.billing_units)::integer, SUM(base.success_units)::integer, + SUM(base.failed_units)::integer, CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END, ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL AND base.arrival_ms <= thresholds.p95_ms))::integer, CURRENT_TIMESTAMP, @@ -318,6 +334,7 @@ function qualityByChannelSql(day: BusinessDay) { channel.name AS dimension_name, message."billingUnits" AS billing_units, CASE WHEN receipt."deliveredAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS success_units, + CASE WHEN failed_receipt."failedAt" IS NOT NULL THEN message."billingUnits" ELSE 0 END AS failed_units, CASE WHEN receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt") THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 END AS arrival_ms FROM "SmsSubmitRecord" submit @@ -330,6 +347,13 @@ function qualityByChannelSql(day: BusinessDay) { AND receipt."channelId" = submit."channelId" AND receipt."receiptStatus" = 'delivered' ) receipt ON TRUE + LEFT JOIN LATERAL ( + SELECT MIN(receipt."deliveredAt") AS "failedAt" + FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" = 'undelivered' + ) failed_receipt ON TRUE WHERE submit."submitStatus" = 'accepted' AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt} AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt} @@ -340,7 +364,7 @@ function qualityByChannelSql(day: BusinessDay) { INSERT INTO "DailyQualityReport" ( "id", "reportDate", "dimensionType", "dimensionId", "dimensionName", "tenantId", "tenantName", "applicationId", "channelId", "signatureId", "drainageInfoId", - "sentUnits", "successUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt" + "sentUnits", "successUnits", "failedUnits", "successRateBps", "avgArrivalMs", "generatedAt", "updatedAt" ) SELECT CONCAT('quality-channel-', MD5(${day.key} || ':' || base.dimension_id)), @@ -351,6 +375,7 @@ function qualityByChannelSql(day: BusinessDay) { NULL, NULL, NULL, base.dimension_id, NULL, NULL, SUM(base.billing_units)::integer, SUM(base.success_units)::integer, + SUM(base.failed_units)::integer, CASE WHEN SUM(base.billing_units) = 0 THEN 0 ELSE ROUND(SUM(base.success_units) * 10000.0 / SUM(base.billing_units))::integer END, ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL AND base.arrival_ms <= thresholds.p95_ms))::integer, CURRENT_TIMESTAMP, diff --git a/api/src/risk-review/admin-risk-review.controller.ts b/api/src/risk-review/admin-risk-review.controller.ts index 3ca2196..60a79b7 100644 --- a/api/src/risk-review/admin-risk-review.controller.ts +++ b/api/src/risk-review/admin-risk-review.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { SendChainService } from '../send-chain/send-chain.service'; import { BatchReviewSmsTasksDto, @@ -39,15 +40,16 @@ export class AdminRiskReviewController { } @Post('tasks/:id/approve') - async approveTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto) { - const task = await this.riskReview.approveTask(taskId, body); + async approveTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto, @CurrentSessionUserId() reviewerId?: string) { + const review = { ...body, reviewerId }; + const task = await this.riskReview.approveTask(taskId, review); await this.sendChain.handleReviewDecision(taskId, 'approved', body.reason ?? '运营审核通过'); return task; } @Post('tasks/batch/reject') - async rejectTasks(@Body() body: BatchReviewSmsTasksDto) { - const tasks = await this.riskReview.rejectTasks(body); + async rejectTasks(@Body() body: BatchReviewSmsTasksDto, @CurrentSessionUserId() reviewerId?: string) { + const tasks = await this.riskReview.rejectTasks({ ...body, reviewerId }); const reason = body.reason?.trim() ?? ''; for (const task of tasks) { await this.sendChain.handleReviewDecision(task.id, 'rejected', reason); @@ -56,8 +58,8 @@ export class AdminRiskReviewController { } @Post('tasks/:id/reject') - async rejectTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto) { - const task = await this.riskReview.rejectTask(taskId, body); + async rejectTask(@Param('id') taskId: string, @Body() body: ReviewSmsTaskDto, @CurrentSessionUserId() reviewerId?: string) { + const task = await this.riskReview.rejectTask(taskId, { ...body, reviewerId }); await this.sendChain.handleReviewDecision(taskId, 'rejected', body.reason ?? task.rejectReason ?? '运营审核驳回'); return task; } diff --git a/api/src/risk-review/risk-review.service.ts b/api/src/risk-review/risk-review.service.ts index a7e285c..8f4f175 100644 --- a/api/src/risk-review/risk-review.service.ts +++ b/api/src/risk-review/risk-review.service.ts @@ -173,7 +173,11 @@ export class RiskReviewService { ], } : {}), }, - include: { riskHits: true, _count: { select: { messageRecords: true } } }, + include: { + riskHits: true, + reviewedBy: { select: { id: true, username: true, displayName: true } }, + _count: { select: { messageRecords: true } }, + }, orderBy: { createdAt: 'desc' }, }); } @@ -319,7 +323,7 @@ export class RiskReviewService { } const taskWithHits = await this.prisma.smsSendTask.findUnique({ where: { id: task.id }, - include: { riskHits: true }, + include: { riskHits: true, reviewedBy: { select: { id: true, username: true, displayName: true } } }, }); return { canSubmit: decision.status === 'approved', @@ -346,7 +350,7 @@ export class RiskReviewService { reviewedById: data.reviewerId, reviewedAt: new Date(), }, - include: { riskHits: true }, + include: { riskHits: true, reviewedBy: { select: { id: true, username: true, displayName: true } } }, }); } @@ -366,7 +370,7 @@ export class RiskReviewService { reviewedById: data.reviewerId, reviewedAt: new Date(), }, - include: { riskHits: true }, + include: { riskHits: true, reviewedBy: { select: { id: true, username: true, displayName: true } } }, }); } diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index eba03fd..220864b 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -163,6 +163,8 @@ function createPrismaMock() { }, smsReceiptRecord: { create: jest.fn().mockResolvedValue({ id: 'receipt-1' }), + upsert: jest.fn().mockResolvedValue({ id: 'receipt-1', createdAt: new Date('2026-07-01T10:01:00.000Z') }), + findUnique: jest.fn().mockResolvedValue(null), findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn(), }, @@ -355,6 +357,28 @@ describe('SendChainService', () => { expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1'); }); + it('recognizes an approved template for public HTTP content and reads back the api task', async () => { + const { service, prisma, riskReview } = createService(); + service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); + prisma.smsTemplate.findFirst.mockResolvedValue({ + id: 'tpl-http', tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码${code}', + auditStatus: 'approved', signatureId: 'sig-1', signature: { id: 'sig-1', auditStatus: 'approved' }, + }); + + await service.createHttpBatchTask({ + tenantId: 'tenant-1', applicationId: 'app-1', content: '【签名】验证码123456', phones: ['13800000001'], + sourceIp: '127.0.0.1', clientMessageId: 'client-http-1', + }); + + expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({ + templateId: 'tpl-http', + variables: { code: '123456' }, + })); + expect(prisma.smsBatchTask.findFirst).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ id: 'task-1', sourceType: 'api' }), + })); + }); + it('persists the unique longest approved drainage URL match on new message records', async () => { const { service, prisma } = createService(); service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 }); @@ -576,6 +600,70 @@ describe('SendChainService', () => { }); }); + it('splits every destination in one inbound CMPP Submit into an independent real message record', async () => { + const { service, prisma } = createService(); + prisma.smsApplication.findFirst.mockResolvedValue({ + id: 'app-1', + tenantId: 'tenant-1', + cmppAccount: '100001', + secretHash: 'secret-hash', + status: 'active', + interfaceEnabled: false, + queuePriority: 'normal', + ipAllowlist: [{ ipCidr: '127.0.0.1/32' }], + tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' }, + }); + let taskIndex = 0; + prisma.smsBatchTask.create.mockImplementation(({ data }) => { + taskIndex += 1; + return Promise.resolve({ id: `task-${taskIndex}`, ...data }); + }); + let messageIndex = 0; + prisma.smsMessageRecord.create.mockImplementation(({ data }) => { + messageIndex += 1; + return Promise.resolve({ id: `record-${messageIndex}`, messageId: data.messageId, ...data }); + }); + + const result = await service.submitInboundMessage({ + account: '100001', + phoneNumbers: ['13800000001', '13900000002'], + content: 'hello', + sequenceId: 777823876, + remoteIp: '127.0.0.1', + }); + + expect(result).toEqual(expect.objectContaining({ + accepted: true, + phoneCount: 2, + messages: [ + expect.objectContaining({ phoneNumber: '13800000001', messageRecordId: 'record-1' }), + expect.objectContaining({ phoneNumber: '13900000002', messageRecordId: 'record-2' }), + ], + })); + expect(result.messageId).toBe(result.messages[0].messageId); + expect(prisma.smsBatchTask.create).toHaveBeenCalledTimes(2); + expect(prisma.smsMessageRecord.create).toHaveBeenCalledTimes(2); + expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13800000001', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) }); + expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ phoneNumber: '13900000002', cmppSubmitSequenceId: '777823876', cmppSubmitGroupMessageId: result.messageId }) }); + expect(prisma.smsReceiptRecord.create).toHaveBeenCalledTimes(2); + expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(2); + }); + + it('rejects a multi-destination CMPP Submit before persistence when any destination is invalid', async () => { + const { service, prisma } = createService(); + + await expect(service.submitInboundMessage({ + account: '100001', + phoneNumbers: ['13800000001', 'invalid'], + content: 'hello', + remoteIp: '127.0.0.1', + })).rejects.toThrow('CMPP submit phone number is invalid'); + + expect(prisma.smsApplication.findFirst).not.toHaveBeenCalled(); + expect(prisma.smsBatchTask.create).not.toHaveBeenCalled(); + expect(prisma.smsMessageRecord.create).not.toHaveBeenCalled(); + }); + it('accepts only the filled client Src_Id and snapshots the real application extension', async () => { const { service, prisma } = createService(); prisma.smsApplication.findFirst.mockResolvedValue({ @@ -684,7 +772,10 @@ describe('SendChainService', () => { })).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' })); expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith({ - where: { applicationId: 'app-1', content: { contains: '${' } }, + where: { + applicationId: 'app-1', content: { contains: '${' }, auditStatus: 'approved', + signature: { auditStatus: 'approved' }, + }, include: { signature: true }, orderBy: { updatedAt: 'desc' }, }); @@ -1238,8 +1329,10 @@ describe('SendChainService', () => { it('matches receipt to a unique timed-out submit attempt when the upstream submit response was lost', async () => { const { service, prisma } = createService(); - prisma.smsMessageRecord.findFirst.mockResolvedValue(null); - prisma.smsSubmitRecord.findMany.mockResolvedValue([ + prisma.smsMessageRecord.findUnique.mockResolvedValue(null); + prisma.smsSubmitRecord.findMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ { id: 'submit-timeout-1', channelId: 'channel-1', @@ -1258,7 +1351,7 @@ describe('SendChainService', () => { status: 'timeout', }, }, - ]); + ]); await service.handleReceipt({ messageId: 'receipt-123456789', @@ -1290,10 +1383,89 @@ describe('SendChainService', () => { }); }); + it('matches identical upstream Msg_Id values by channel and destination instead of another channel record', async () => { + const { service, prisma } = createService(); + prisma.smsMessageRecord.findUnique.mockResolvedValue(null); + prisma.smsSubmitRecord.findMany.mockResolvedValueOnce([ + { + id: 'submit-channel-b', + channelId: 'channel-b', + gatewayMessageId: 'SHARED-UPSTREAM-ID', + messageRecord: { + id: 'record-channel-b', + tenantId: 'tenant-1', + batchTaskId: 'task-1', + applicationId: 'app-1', + messageId: 'MSG-B', + phoneNumber: '15601992925', + channelId: 'channel-b', + gatewayMessageId: 'SHARED-UPSTREAM-ID', + status: 'submitted', + }, + }, + ]); + + await service.handleReceipt({ + messageId: 'receipt-SHARED-UPSTREAM-ID', + channelId: 'channel-b', + gatewayMessageId: 'SHARED-UPSTREAM-ID', + phoneNumber: '15601992925', + receiptStatus: 'delivered', + rawStatus: 'DELIVRD', + deliveredAt: '2026-07-01T10:01:00.000Z', + }); + + expect(prisma.smsSubmitRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + channelId: 'channel-b', + gatewayMessageId: 'SHARED-UPSTREAM-ID', + messageRecord: { phoneNumber: '15601992925' }, + }), + })); + expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({ + where: { id: 'record-channel-b' }, + data: expect.objectContaining({ + status: 'delivered', + receiptStatus: 'delivered', + channelId: 'channel-b', + gatewayMessageId: 'SHARED-UPSTREAM-ID', + receiptRawStatus: 'DELIVRD', + }), + }); + }); + + it('treats a repeated DELIVRD event as idempotent and does not redeliver it downstream', async () => { + const { service, prisma } = createService(); + prisma.smsReceiptRecord.findUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: 'receipt-existing', + messageRecordId: 'record-1', + messageRecord: { id: 'record-1', messageId: 'MSG-1', status: 'delivered' }, + }); + + const receipt = { + messageId: 'MSG-1', + channelId: 'channel-1', + gatewayMessageId: 'GW-1', + phoneNumber: '13800000001', + receiptStatus: 'delivered' as const, + rawStatus: 'DELIVRD', + deliveredAt: '2026-07-01T10:01:00.000Z', + }; + await service.handleReceipt(receipt); + await service.handleReceipt(receipt); + + expect(prisma.smsReceiptRecord.create).toHaveBeenCalledTimes(1); + expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledTimes(1); + }); + it('rejects ambiguous receipt heuristic matches to avoid binding to the wrong message', async () => { const { service, prisma } = createService(); - prisma.smsMessageRecord.findFirst.mockResolvedValue(null); - prisma.smsSubmitRecord.findMany.mockResolvedValue([ + prisma.smsMessageRecord.findUnique.mockResolvedValue(null); + prisma.smsSubmitRecord.findMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ { id: 'submit-timeout-1', messageRecord: { id: 'record-1', messageId: 'MSG-1', phoneNumber: '13800000001' }, @@ -1302,7 +1474,7 @@ describe('SendChainService', () => { id: 'submit-timeout-2', messageRecord: { id: 'record-2', messageId: 'MSG-2', phoneNumber: '13800000001' }, }, - ]); + ]); await expect( service.handleReceipt({ diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 0b3cd38..1743329 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -29,6 +29,8 @@ export interface CreateBatchTaskDto { clientMessageId?: string; } +export type CreateHttpBatchTaskDto = Omit; + export interface GatewayInboundAuthDto { account: string; password?: string; @@ -39,7 +41,8 @@ export interface GatewayInboundAuthDto { export interface GatewayInboundSubmitDto { account: string; - phoneNumber: string; + phoneNumber?: string; + phoneNumbers?: string[]; content: string; srcId?: string; destId?: string; @@ -47,6 +50,16 @@ export interface GatewayInboundSubmitDto { remoteIp?: string; } +interface GatewayInboundSingleSubmitResult { + accepted: boolean; + tenantId: string; + applicationId: string; + taskId: string; + messageId: string; + messageRecordId: string; + status: string; +} + export interface GatewaySubmitResultDto { traceId?: string; messageId: string; @@ -80,6 +93,7 @@ export interface GatewayReceiptEventDto { receiptStatus: 'delivered' | 'undelivered' | 'unknown'; rawStatus: string; errorCode?: string; + errorMessage?: string; deliveredAt?: string; } @@ -400,7 +414,27 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { if (batchStatus === 'ready') { await this.enqueueBatchTask(task.id); } - return this.getBatchTask(task.id); + return this.getBatchTask(task.id, undefined, data.sourceType ?? 'client'); + } + + async createHttpBatchTask(data: CreateHttpBatchTaskDto) { + if (!data.applicationId) { + throw new BadRequestException('公开 HTTP 发送必须关联企业应用'); + } + const template = await this.resolveInboundTemplateCandidate(data.applicationId, data.content); + if (!template || template.auditStatus !== 'approved' || template.signature?.auditStatus !== 'approved') { + throw new BadRequestException('短信内容未匹配当前应用已审核通过的签名和模板'); + } + const variables = matchTemplateContent(template.content, data.content); + if (variables === null) { + throw new BadRequestException('短信内容与已审核模板不匹配'); + } + return this.createBatchTask({ + ...data, + templateId: template.id, + variables, + sourceType: 'api', + }); } async listBatchTasks(tenantId?: string, status?: string, sourceType = 'client') { @@ -837,6 +871,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } async handleReceipt(data: GatewayReceiptEventDto) { + const receiptKey = this.receiptEventKey(data); + const existingReceipt = await this.prisma.smsReceiptRecord.findUnique({ + where: { receiptKey }, + include: { messageRecord: true }, + }); + if (existingReceipt?.messageRecord) { + return existingReceipt.messageRecord; + } const resolved = await this.resolveReceiptMessage(data); const message = resolved.message; const deliveredAt = data.deliveredAt ? new Date(data.deliveredAt) : new Date(); @@ -854,21 +896,35 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }, }); } - await this.prisma.smsReceiptRecord.create({ - data: { - tenantId: message.tenantId, - batchTaskId: message.batchTaskId, - messageRecordId: message.id, - channelId: data.channelId, - messageId: resolved.messageId, - gatewayMessageId: data.gatewayMessageId, - sequenceId: data.sequenceId, - receiptStatus: data.receiptStatus, - rawStatus: data.rawStatus, - errorCode: data.errorCode, - deliveredAt, - }, - }); + try { + await this.prisma.smsReceiptRecord.create({ + data: { + tenantId: message.tenantId, + batchTaskId: message.batchTaskId, + messageRecordId: message.id, + receiptKey, + channelId: data.channelId, + messageId: resolved.messageId, + gatewayMessageId: data.gatewayMessageId, + phoneNumber: data.phoneNumber?.trim() || message.phoneNumber, + sequenceId: data.sequenceId, + receiptStatus: data.receiptStatus, + rawStatus: data.rawStatus, + errorCode: data.errorCode, + errorMessage: data.errorMessage, + deliveredAt, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + const duplicate = await this.prisma.smsReceiptRecord.findUnique({ + where: { receiptKey }, + include: { messageRecord: true }, + }); + if (duplicate?.messageRecord) return duplicate.messageRecord; + } + throw error; + } await this.recordReceiptSegment(message, data, deliveredAt, resolved.submitRecordId); const isCurrentAttempt = (!message.channelId || message.channelId === data.channelId) @@ -889,9 +945,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { + channelId: data.channelId, + gatewayMessageId: data.gatewayMessageId, receiptStatus: data.receiptStatus, + receiptRawStatus: data.rawStatus, status, errorCode: data.errorCode, + errorMessage: data.errorMessage ?? (status === 'delivered' ? null : data.rawStatus), deliveredAt, }, }); @@ -910,6 +970,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { rawStatus: data.rawStatus, errorCode: data.errorCode, submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined, + submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined, deliveredAt: deliveredAt.toISOString(), }, }); @@ -1770,6 +1831,49 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } async submitInboundMessage(data: GatewayInboundSubmitDto) { + const phoneNumbers = data.phoneNumbers?.length + ? data.phoneNumbers.map((phoneNumber) => phoneNumber.trim()) + : data.phoneNumber + ? [data.phoneNumber.trim()] + : []; + if (phoneNumbers.length === 0 || phoneNumbers.some((phoneNumber) => !/^1[3-9]\d{9}$/.test(phoneNumber))) { + throw new BadRequestException('CMPP submit phone number is invalid'); + } + + const submitGroupMessageId = `MSG-${randomUUID()}`; + const submissions = phoneNumbers.map((phoneNumber, index) => ({ + phoneNumber, + messageId: index === 0 ? submitGroupMessageId : `MSG-${randomUUID()}`, + })); + const results: GatewayInboundSingleSubmitResult[] = []; + const concurrency = 10; + for (let offset = 0; offset < submissions.length; offset += concurrency) { + const batch = submissions.slice(offset, offset + concurrency); + results.push(...await Promise.all(batch.map((submission) => this.submitInboundSingleMessage({ + ...data, + phoneNumber: submission.phoneNumber, + phoneNumbers: undefined, + }, submission.messageId, submitGroupMessageId)))); + } + const first = results[0]; + return { + ...first, + phoneCount: results.length, + messages: results.map((result, index) => ({ + phoneNumber: phoneNumbers[index], + messageId: result.messageId, + messageRecordId: result.messageRecordId, + taskId: result.taskId, + status: result.status, + })), + }; + } + + private async submitInboundSingleMessage( + data: GatewayInboundSubmitDto & { phoneNumber: string }, + messageId: string, + submitGroupMessageId: string, + ) { const application = await this.findInboundApplication(data.account); if (!application) { throw new BadRequestException('CMPP account is invalid'); @@ -1822,7 +1926,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { batchTaskId: task.id, applicationId: application.id, templateId: template?.id, - messageId: `MSG-${randomUUID()}`, + messageId, phoneNumber: data.phoneNumber, content: data.content, billingUnits: billing.billingUnitsPerMessage, @@ -1830,6 +1934,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { amountCents: billing.amountCents, queuePriority, cmppSubmitSequenceId: data.sequenceId == null ? null : String(data.sequenceId), + cmppSubmitGroupMessageId: submitGroupMessageId, clientSrcId, applicationExtension: application.cmppApplicationExtension, status: 'validating', @@ -2372,6 +2477,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { where: { applicationId, content, + auditStatus: 'approved', + signature: { auditStatus: 'approved' }, }, include: { signature: true }, orderBy: { updatedAt: 'desc' }, @@ -2381,6 +2488,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { where: { applicationId, content: { contains: '${' }, + auditStatus: 'approved', + signature: { auditStatus: 'approved' }, }, include: { signature: true }, orderBy: { updatedAt: 'desc' }, @@ -2445,6 +2554,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { messageId: string; phoneNumber: string; cmppSubmitSequenceId?: string | null; + cmppSubmitGroupMessageId?: string | null; }, errorCode: string, reason: string, @@ -2457,18 +2567,22 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { const deliveredAt = new Date(); await this.prisma.smsMessageRecord.update({ where: { id: message.id }, - data: { status: 'failed', receiptStatus: 'undelivered', errorCode, errorMessage: reason, deliveredAt }, + data: { status: 'failed', receiptStatus: 'undelivered', receiptRawStatus: 'REJECTD', errorCode, errorMessage: reason, deliveredAt }, }); + const gatewayMessageId = `PLATFORM:${message.messageId}`; const receipt = await this.prisma.smsReceiptRecord.create({ data: { tenantId: message.tenantId, batchTaskId: message.batchTaskId, messageRecordId: message.id, + receiptKey: createHash('sha256').update(`platform\u0000${gatewayMessageId}\u0000${message.phoneNumber}\u0000undelivered\u0000REJECTD\u0000${errorCode}`).digest('hex'), messageId: message.messageId, - gatewayMessageId: `PLATFORM:${message.messageId}`, + gatewayMessageId, + phoneNumber: message.phoneNumber, receiptStatus: 'undelivered', rawStatus: 'REJECTD', errorCode, + errorMessage: reason, deliveredAt, }, }); @@ -2487,6 +2601,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { errorCode, errorMessage: reason, submitSequenceId: message.cmppSubmitSequenceId ? Number(message.cmppSubmitSequenceId) : undefined, + submitGroupMessageId: message.cmppSubmitGroupMessageId ?? undefined, deliveredAt: deliveredAt.toISOString(), }, }); @@ -2903,15 +3018,44 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { } private async resolveReceiptMessage(data: GatewayReceiptEventDto) { - const exactMessage = await this.findMessageByGatewayEvent(data.messageId, data.gatewayMessageId); + const exactMessage = data.messageId + ? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } }) + : null; if (exactMessage) { + const submitRecord = await this.prisma.smsSubmitRecord.findFirst({ + where: { + messageRecordId: exactMessage.id, + channelId: data.channelId, + gatewayMessageId: data.gatewayMessageId, + }, + orderBy: { createdAt: 'desc' }, + }); return { message: exactMessage, messageId: exactMessage.messageId, + submitRecordId: submitRecord?.id, }; } const phoneNumber = data.phoneNumber?.trim(); + const exactSubmits = await this.prisma.smsSubmitRecord.findMany({ + where: { + channelId: data.channelId, + gatewayMessageId: data.gatewayMessageId, + ...(phoneNumber ? { messageRecord: { phoneNumber } } : {}), + }, + include: { messageRecord: true }, + orderBy: { createdAt: 'desc' }, + take: 2, + }); + if (exactSubmits.length === 1 && exactSubmits[0]?.messageRecord) { + return { + message: exactSubmits[0].messageRecord, + messageId: exactSubmits[0].messageRecord.messageId, + submitRecordId: exactSubmits[0].id, + }; + } + if (!phoneNumber) { throw new NotFoundException('SMS message record not found'); } @@ -2951,6 +3095,17 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { }; } + private receiptEventKey(data: GatewayReceiptEventDto) { + return createHash('sha256').update([ + data.channelId, + data.gatewayMessageId, + data.phoneNumber?.trim() ?? '', + data.receiptStatus, + data.rawStatus.trim(), + data.errorCode ?? '', + ].join('\u0000')).digest('hex'); + } + private getSendQueue(): Queue { if (!this.sendQueue) { this.sendQueue = new Queue(SEND_QUEUE, { connection: bullmqConnection() }); diff --git a/api/src/sms-config/client-sms-config.controller.ts b/api/src/sms-config/client-sms-config.controller.ts index 86affe4..40be1a9 100644 --- a/api/src/sms-config/client-sms-config.controller.ts +++ b/api/src/sms-config/client-sms-config.controller.ts @@ -121,27 +121,27 @@ export class ClientSmsConfigController { } @Get('templates') - listTemplates(@TenantId() tenantId?: string) { - return this.smsConfig.listTemplates(tenantId); + listTemplates(@TenantId() tenantId?: string, @Query('includeHistory') includeHistory?: string) { + return this.smsConfig.listClientTemplates(tenantId, includeHistory === 'true'); } @Post('templates') - createTemplate(@Body() body: CreateSmsTemplateDto) { - return this.smsConfig.createTemplate(body); + createTemplate(@Body() body: CreateSmsTemplateDto, @TenantId() tenantId?: string) { + return this.smsConfig.createTemplate({ ...body, tenantId: tenantId ?? body.tenantId }); } @Put('templates/:id') - updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto) { - return this.smsConfig.updateTemplate(templateId, body); + updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto, @TenantId() tenantId?: string) { + return this.smsConfig.updateTemplate(templateId, body, tenantId); } @Post('templates/:id/submit') - submitTemplate(@Param('id') templateId: string) { - return this.smsConfig.submitTemplate(templateId); + submitTemplate(@Param('id') templateId: string, @TenantId() tenantId?: string) { + return this.smsConfig.submitTemplate(templateId, tenantId); } @Post('templates/:id/status') - changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto) { - return this.smsConfig.changeTemplateStatus(templateId, body); + changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) { + return this.smsConfig.changeTemplateStatus(templateId, body, tenantId); } } diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 3b9432a..2572229 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import { SmsConfigService } from './sms-config.service'; function createPrismaMock() { @@ -755,7 +756,7 @@ describe('SmsConfigService', () => { expect(serialized).not.toContain('channel-secret'); expect(serialized).not.toContain('内部通道'); expect(result[0]).not.toHaveProperty('reportTasks'); - expect(result[0]).not.toHaveProperty('reportStatus'); + expect(result[0]).toHaveProperty('reportStatus'); }); it('returns real client signature workspace counts from database grouping', async () => { @@ -951,6 +952,17 @@ describe('SmsConfigService', () => { })); }); + it('returns only approved templates from the client send-candidate view by default', async () => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + + await service.listClientTemplates('tenant-1'); + + expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: 'approved' }), + })); + }); + it('creates admin enterprise templates as approved when requested', async () => { const prisma = createPrismaMock(); const service = new SmsConfigService(prisma as never); @@ -1038,4 +1050,20 @@ describe('SmsConfigService', () => { expect(prisma.smsTemplate.create).not.toHaveBeenCalled(); }); + + it.each([ + ['空变量', '【签名A】验证码${}'], + ['中文变量', '【签名A】验证码${中文}'], + ['未闭合变量', '【签名A】验证码${code'], + ['重复变量', '【签名A】${code}-${code}'], + ['超长变量', `【签名A】\${${'a'.repeat(33)}}`], + ])('rejects %s before persisting the template', async (_label, content) => { + const prisma = createPrismaMock(); + const service = new SmsConfigService(prisma as never); + + await expect(service.createTemplate({ + tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'sig-1', name: '非法模板', content, + })).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.smsTemplate.create).not.toHaveBeenCalled(); + }); }); diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 4f73fc1..d63e2fd 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -850,6 +850,9 @@ export class SmsConfigService { name: true, purpose: true, auditStatus: true, + reportStatus: true, + pendingReport: true, + reportChangedAt: true, rejectReason: true, drainageInfo: true, createdAt: true, @@ -888,6 +891,9 @@ export class SmsConfigService { name: signature.name, purpose: signature.purpose, auditStatus: signature.auditStatus, + reportStatus: signature.reportStatus, + pendingReport: signature.pendingReport, + reportChangedAt: signature.reportChangedAt, rejectReason: signature.rejectReason, createdAt: signature.createdAt, updatedAt: signature.updatedAt, @@ -1315,7 +1321,12 @@ export class SmsConfigService { }); } + listClientTemplates(tenantId: string | undefined, includeHistory = false) { + return this.listTemplates({ tenantId, status: includeHistory ? 'all' : 'approved' }); + } + async createTemplate(data: CreateSmsTemplateDto, options: CreateSmsTemplateOptions = {}) { + const variables = validateAndNormalizeTemplateVariables(data.content, data.variables); const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } }); if (!application || application.tenantId !== data.tenantId) { throw new BadRequestException('applicationId does not belong to the template tenant'); @@ -1332,7 +1343,7 @@ export class SmsConfigService { auditStatus: options.initialAuditStatus, billingUnits: estimateBillingUnits(data.content), variables: { - create: (data.variables ?? inferTemplateVariables(data.content)).map((variable: TemplateVariableInput) => ({ + create: variables.map((variable: TemplateVariableInput) => ({ name: variable.name, example: variable.example, required: variable.required ?? true, @@ -1343,9 +1354,9 @@ export class SmsConfigService { }); } - async updateTemplate(templateId: string, data: UpdateSmsTemplateDto) { + async updateTemplate(templateId: string, data: UpdateSmsTemplateDto, tenantId?: string) { const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); - if (!template) { + if (!template || (tenantId && template.tenantId !== tenantId)) { throw new NotFoundException('Template not found'); } if (data.applicationId) { @@ -1362,7 +1373,9 @@ export class SmsConfigService { data.content ?? template.content, ); } - const variables = data.variables ?? (data.content ? inferTemplateVariables(data.content) : undefined); + const variables = data.content !== undefined || data.variables !== undefined + ? validateAndNormalizeTemplateVariables(data.content ?? template.content, data.variables) + : undefined; return this.prisma.$transaction(async (tx) => { if (variables) { await tx.templateVariable.deleteMany({ where: { templateId } }); @@ -1390,9 +1403,9 @@ export class SmsConfigService { }); } - async submitTemplate(templateId: string) { + async submitTemplate(templateId: string, tenantId?: string) { const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); - if (!template) { + if (!template || (tenantId && template.tenantId !== tenantId)) { throw new NotFoundException('Template not found'); } await this.validateTemplateSignature(template.signatureId, template.tenantId, template.applicationId, template.content); @@ -1473,9 +1486,9 @@ export class SmsConfigService { return updated; } - async changeTemplateStatus(templateId: string, data: StatusChangeDto) { + async changeTemplateStatus(templateId: string, data: StatusChangeDto, tenantId?: string) { const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } }); - if (!template) { + if (!template || (tenantId && template.tenantId !== tenantId)) { throw new NotFoundException('Template not found'); } const status = data.status ?? 'deleted'; @@ -1643,6 +1656,37 @@ function inferTemplateVariables(content: string): TemplateVariableInput[] { return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true })); } +function validateAndNormalizeTemplateVariables( + content: string, + supplied?: Array<{ name: string; example?: string; required?: boolean }>, +): TemplateVariableInput[] { + const names: string[] = []; + let cursor = 0; + while (true) { + const start = content.indexOf('${', cursor); + if (start < 0) break; + const end = content.indexOf('}', start + 2); + if (end < 0) throw new BadRequestException('模板变量未闭合'); + const name = content.slice(start + 2, end); + if (!/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name)) { + throw new BadRequestException('模板变量名必须以英文字母开头,仅包含英文字母、数字和下划线,长度1至32位'); + } + if (names.includes(name)) throw new BadRequestException(`模板变量 ${name} 重复`); + names.push(name); + cursor = end + 1; + } + if (!supplied) return names.map((name) => ({ name, required: true })); + const suppliedNames = supplied.map((item) => item.name?.trim()); + if (suppliedNames.some((name) => !name || !/^[A-Za-z][A-Za-z0-9_]{0,31}$/.test(name))) { + throw new BadRequestException('变量配置中包含非法变量名'); + } + if (new Set(suppliedNames).size !== suppliedNames.length) throw new BadRequestException('变量配置中包含重复变量'); + if (suppliedNames.length !== names.length || suppliedNames.some((name) => !names.includes(name))) { + throw new BadRequestException('变量配置必须与模板正文中的占位符完全一致'); + } + return supplied.map((item) => ({ ...item, name: item.name.trim() })); +} + function normalizeSmsSignature(name: string) { const innerName = name.trim().replace(/^[【\[]+|[】\]]+$/g, '').trim(); return innerName ? `【${innerName}】` : ''; diff --git a/api/src/tenants/tenants.service.spec.ts b/api/src/tenants/tenants.service.spec.ts index 19638d9..19554c9 100644 --- a/api/src/tenants/tenants.service.spec.ts +++ b/api/src/tenants/tenants.service.spec.ts @@ -1,4 +1,4 @@ -import { NotFoundException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { TenantsService } from './tenants.service'; function createPrismaMock() { @@ -81,6 +81,14 @@ describe('TenantsService', () => { expect(prisma.tenant.update).not.toHaveBeenCalled(); }); + it('rejects enterprise credit codes containing non-alphanumeric characters', async () => { + const prisma = createPrismaMock(); + const service = new TenantsService(prisma as never); + + await expect(service.create({ name: '测试企业', creditCode: '9137-中文' })).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.tenant.create).not.toHaveBeenCalled(); + }); + it('lists management rows with real account, today spend and actual refund fields', async () => { const prisma = createPrismaMock(); const service = new TenantsService(prisma as never); diff --git a/api/src/tenants/tenants.service.ts b/api/src/tenants/tenants.service.ts index 20f3d8a..a3d9c7a 100644 --- a/api/src/tenants/tenants.service.ts +++ b/api/src/tenants/tenants.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { moneyToNumber } from '../common/money'; import { PrismaService } from '../prisma/prisma.service'; @@ -90,6 +90,7 @@ export class TenantsService { } async create(data: CreateTenantDto) { + assertCreditCode(data.creditCode); const code = data.code?.trim() || generateTenantCode(data); const tenant = await this.prisma.tenant.create({ data: { name: data.name, code, status: data.status ?? 'active' }, @@ -99,6 +100,7 @@ export class TenantsService { } async update(id: string, data: UpdateTenantDto) { + assertCreditCode(data.creditCode); await this.ensureTenant(id); await this.prisma.tenant.update({ where: { id }, @@ -204,6 +206,12 @@ function startOfToday() { return date; } +function assertCreditCode(value?: string) { + if (value !== undefined && value.trim() && !/^[A-Za-z0-9]+$/.test(value.trim())) { + throw new BadRequestException('统一社会信用代码只能包含英文字母和数字'); + } +} + function returnedTransactionWhere(since: Date): Prisma.AccountTransactionWhereInput { return { createdAt: { gte: since }, diff --git a/api/src/users/user-response.dto.ts b/api/src/users/user-response.dto.ts new file mode 100644 index 0000000..3a394ec --- /dev/null +++ b/api/src/users/user-response.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AdminUserResponseDto { + @ApiProperty() id!: string; + @ApiPropertyOptional({ nullable: true }) tenantId!: string | null; + @ApiProperty() username!: string; + @ApiPropertyOptional({ nullable: true }) email!: string | null; + @ApiPropertyOptional({ nullable: true }) phone!: string | null; + @ApiProperty() displayName!: string; + @ApiProperty() status!: string; + @ApiPropertyOptional({ nullable: true }) lockedUntil!: Date | null; + @ApiPropertyOptional({ nullable: true }) lastLoginAt!: Date | null; + @ApiProperty() createdAt!: Date; + @ApiProperty() updatedAt!: Date; + @ApiProperty({ type: [Object] }) roles!: Array>; + @ApiPropertyOptional({ type: Object, nullable: true }) tenant?: Record | null; +} + +export class ClientUserResponseDto { + @ApiProperty() id!: string; + @ApiProperty() tenantId!: string; + @ApiProperty() username!: string; + @ApiPropertyOptional({ nullable: true }) email!: string | null; + @ApiPropertyOptional({ nullable: true }) phone!: string | null; + @ApiProperty() displayName!: string; + @ApiProperty() status!: string; + @ApiPropertyOptional({ nullable: true }) lockedUntil!: Date | null; + @ApiPropertyOptional({ nullable: true }) lastLoginAt!: Date | null; + @ApiProperty() createdAt!: Date; + @ApiProperty() updatedAt!: Date; + @ApiProperty({ type: [Object] }) roles!: Array>; +} diff --git a/api/src/users/users.controller.ts b/api/src/users/users.controller.ts index b71af9b..4a4146f 100644 --- a/api/src/users/users.controller.ts +++ b/api/src/users/users.controller.ts @@ -1,7 +1,9 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { TenantId } from '../common/tenant-id.decorator'; import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; +import { AdminUserResponseDto, ClientUserResponseDto } from './user-response.dto'; import { AssignPermissionDto, AssignRoleDto, @@ -20,78 +22,80 @@ export class UsersController { constructor(private readonly users: UsersService) {} @Get('admin/users') + @ApiOkResponse({ type: [AdminUserResponseDto] }) list(@Query('tenantId') tenantId?: string, @Query('roleCode') roleCode?: string) { return this.users.list(tenantId, roleCode); } @Post('admin/users') @RequireRecentAuthentication() - create(@Body() body: CreateUserDto) { - return this.users.create(body); + create(@Body() body: CreateUserDto, @CurrentSessionUserId() operatorId?: string) { + return this.users.create({ ...body, operatorId }); } @Put('admin/users/:id') @RequireRecentAuthentication() - update(@Param('id') id: string, @Body() body: UpdateUserDto) { - return this.users.update(id, body); + update(@Param('id') id: string, @Body() body: UpdateUserDto, @CurrentSessionUserId() operatorId?: string) { + return this.users.update(id, { ...body, operatorId }); } @Patch('admin/users/:id') @RequireRecentAuthentication() - patch(@Param('id') id: string, @Body() body: UpdateUserDto) { - return this.users.update(id, body); + patch(@Param('id') id: string, @Body() body: UpdateUserDto, @CurrentSessionUserId() operatorId?: string) { + return this.users.update(id, { ...body, operatorId }); } @Post('admin/users/:id/status') @RequireRecentAuthentication() - changeStatus(@Param('id') id: string, @Body() body: ChangeUserStatusDto) { - return this.users.changeStatus(id, body); + changeStatus(@Param('id') id: string, @Body() body: ChangeUserStatusDto, @CurrentSessionUserId() operatorId?: string) { + return this.users.changeStatus(id, { ...body, operatorId }, undefined, operatorId); } @Post('admin/users/:id/password') @RequireRecentAuthentication() - changePassword(@Param('id') id: string, @Body() body: ChangePasswordDto) { - return this.users.changePassword(id, body); + changePassword(@Param('id') id: string, @Body() body: ChangePasswordDto, @CurrentSessionUserId() operatorId?: string) { + return this.users.changePassword(id, { ...body, operatorId }); } @Delete('admin/users/:id') @RequireRecentAuthentication() - remove(@Param('id') id: string, @Body('operatorId') operatorId?: string) { + remove(@Param('id') id: string, @CurrentSessionUserId() operatorId?: string) { return this.users.remove(id, operatorId); } @Get('client/users') + @ApiOkResponse({ type: [ClientUserResponseDto] }) listClient(@TenantId() tenantId?: string) { return this.users.listClientUsers(tenantId); } @Post('client/users') @RequireRecentAuthentication() - createClient(@TenantId() tenantId: string | undefined, @Body() body: CreateUserDto) { - return this.users.create({ ...body, roleCode: 'enterprise_admin' }, tenantId); + createClient(@TenantId() tenantId: string | undefined, @Body() body: CreateUserDto, @CurrentSessionUserId() operatorId?: string) { + return this.users.create({ ...body, roleCode: 'enterprise_admin', operatorId }, tenantId); } @Put('client/users/:id') @RequireRecentAuthentication() - updateClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: UpdateUserDto) { - return this.users.update(id, { ...body, roleCode: 'enterprise_admin' }, tenantId); + updateClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: UpdateUserDto, @CurrentSessionUserId() operatorId?: string) { + return this.users.update(id, { ...body, roleCode: 'enterprise_admin', operatorId }, tenantId); } @Post('client/users/:id/status') @RequireRecentAuthentication() - changeClientStatus(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangeUserStatusDto) { - return this.users.changeStatus(id, body, tenantId); + changeClientStatus(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangeUserStatusDto, @CurrentSessionUserId() operatorId?: string) { + return this.users.changeStatus(id, { ...body, operatorId }, tenantId, operatorId); } @Post('client/users/:id/password') @RequireRecentAuthentication() - changeClientPassword(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangePasswordDto) { - return this.users.changePassword(id, body, tenantId); + changeClientPassword(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangePasswordDto, @CurrentSessionUserId() operatorId?: string) { + return this.users.changePassword(id, { ...body, operatorId }, tenantId); } @Delete('client/users/:id') @RequireRecentAuthentication() - removeClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body('operatorId') operatorId?: string) { + removeClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @CurrentSessionUserId() operatorId?: string) { return this.users.remove(id, operatorId, tenantId); } diff --git a/api/src/users/users.service.spec.ts b/api/src/users/users.service.spec.ts index 5c6611f..b429f5b 100644 --- a/api/src/users/users.service.spec.ts +++ b/api/src/users/users.service.spec.ts @@ -1,5 +1,5 @@ -import { BadRequestException } from '@nestjs/common'; -import { UsersService } from './users.service'; +import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common'; +import { hashPassword, UsersService } from './users.service'; function createPrismaMock() { const roles = new Map(); @@ -10,6 +10,7 @@ function createPrismaMock() { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn(), + count: jest.fn().mockResolvedValue(2), }, role: { upsert: jest.fn().mockImplementation(({ where, create, update }) => { @@ -85,6 +86,24 @@ describe('UsersService', () => { })); }); + it('writes an operation log after a successful login', async () => { + const prisma = createPrismaMock(); + prisma.user.update.mockResolvedValue({ id: 'user-1', tenantId: 'tenant-1', username: 'admin' }); + const service = new UsersService(prisma as never); + + await service.recordLoginSuccess('user-1'); + + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + tenantId: 'tenant-1', + userId: 'user-1', + action: 'auth.login_success', + resource: 'user', + resourceId: 'user-1', + }), + }); + }); + it('revokes the affected user session when a role is assigned', async () => { const tx = { userRole: { upsert: jest.fn().mockResolvedValue({ userId: 'user-1', roleId: 'role-1' }) }, @@ -109,4 +128,87 @@ describe('UsersService', () => { data: { sessionVersion: { increment: 1 } }, }); }); + + it('returns safe user views without authentication internals', async () => { + const prisma = createPrismaMock(); + prisma.user.findMany.mockResolvedValue([{ + id: 'user-1', username: 'admin', email: 'admin@example.com', phone: null, displayName: '管理员', + tenantId: null, status: 'active', passwordHash: 'secret', sessionVersion: 9, failedLoginCount: 3, + lockedUntil: null, lastLoginAt: null, deletedAt: null, createdAt: new Date(), updatedAt: new Date(), + tenant: null, roles: [{ role: { id: 'role-1', code: 'platform_admin', name: '平台管理员' } }], + }]); + const service = new UsersService(prisma as never); + + const [user] = await service.list(); + + expect(user).not.toHaveProperty('passwordHash'); + expect(user).not.toHaveProperty('sessionVersion'); + expect(user).not.toHaveProperty('failedLoginCount'); + }); + + it('returns a safe view after changing the current password', async () => { + const prisma = createPrismaMock(); + prisma.user.findFirst.mockResolvedValue({ + id: 'user-1', tenantId: 'tenant-1', username: 'admin', passwordHash: hashPassword('old-password'), + sessionVersion: 3, failedLoginCount: 0, roles: [{ role: { code: 'enterprise_admin' } }], + }); + prisma.user.update.mockResolvedValue({ + id: 'user-1', tenantId: 'tenant-1', username: 'admin', displayName: '管理员', status: 'active', + passwordHash: 'new-hash', sessionVersion: 4, failedLoginCount: 0, + roles: [{ role: { code: 'enterprise_admin' } }], + }); + const service = new UsersService(prisma as never); + + const user = await service.changeOwnPassword('user-1', 'old-password', 'new-password'); + + expect(user).not.toHaveProperty('passwordHash'); + expect(user).not.toHaveProperty('sessionVersion'); + expect(user).not.toHaveProperty('failedLoginCount'); + }); + + it('forbids deleting the current signed-in user', async () => { + const prisma = createPrismaMock(); + prisma.user.findFirst.mockResolvedValue({ + id: 'user-1', tenantId: null, username: 'admin', roles: [{ role: { code: 'platform_admin' } }], + }); + const service = new UsersService(prisma as never); + + await expect(service.remove('user-1', 'user-1')).rejects.toBeInstanceOf(ForbiddenException); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it('forbids deleting the last active platform administrator', async () => { + const prisma = createPrismaMock(); + prisma.user.findFirst.mockResolvedValue({ + id: 'user-1', tenantId: null, username: 'admin', status: 'active', roles: [{ role: { code: 'platform_admin' } }], + }); + prisma.user.count.mockResolvedValue(1); + const service = new UsersService(prisma as never); + + await expect(service.remove('user-1', 'operator-2')).rejects.toBeInstanceOf(ConflictException); + }); + + it('forbids disabling the last active administrator of a tenant', async () => { + const prisma = createPrismaMock(); + prisma.user.findFirst.mockResolvedValue({ + id: 'user-1', tenantId: 'tenant-1', username: 'admin', status: 'active', roles: [{ role: { code: 'enterprise_admin' } }], + }); + prisma.user.count.mockResolvedValue(1); + const service = new UsersService(prisma as never); + + await expect(service.changeStatus('user-1', { status: 'disabled' }, 'tenant-1', 'operator-2')) + .rejects.toBeInstanceOf(ConflictException); + }); + + it('maps duplicate login identifiers to HTTP 409 with the conflicting field', async () => { + const prisma = createPrismaMock(); + prisma.user.create.mockRejectedValue({ code: 'P2002', meta: { target: ['email'] } }); + const service = new UsersService(prisma as never); + + await expect(service.create({ + displayName: '平台管理员', email: 'admin@example.com', password: 'secret1', roleCode: 'platform_admin', + })).rejects.toMatchObject({ + response: expect.objectContaining({ code: 'USER_DUPLICATE', field: 'email' }), + }); + }); }); diff --git a/api/src/users/users.service.ts b/api/src/users/users.service.ts index 93e53f2..04a4cd1 100644 --- a/api/src/users/users.service.ts +++ b/api/src/users/users.service.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; @@ -70,8 +70,8 @@ const roleNames: Record = { export class UsersService { constructor(private readonly prisma: PrismaService) {} - list(tenantId?: string, roleCode?: string) { - return this.prisma.user.findMany({ + async list(tenantId?: string, roleCode?: string) { + const users = await this.prisma.user.findMany({ where: { deletedAt: null, ...(tenantId ? { tenantId } : {}), @@ -80,6 +80,7 @@ export class UsersService { include: { tenant: true, roles: { include: { role: true } } }, orderBy: { createdAt: 'desc' }, }); + return users.map(publicUser); } listClientUsers(tenantId?: string) { @@ -108,21 +109,21 @@ export class UsersService { this.assertUserInput({ ...data, roleCode }, scopeTenantId, true); const tenantId = scopeTenantId ?? data.tenantId; const role = await this.ensureRole(roleCode); - const user = await this.prisma.user.create({ - data: { - tenantId, - username: data.username ?? data.email ?? data.phone ?? '', - email: normalizeOptional(data.email), - phone: normalizeOptional(data.phone), - displayName: data.displayName, - passwordHash: hashPassword(data.password), - status: data.status ?? 'active', - roles: { create: [{ roleId: role.id }] }, - }, - include: { tenant: true, roles: { include: { role: true } } }, - }); + const user = await this.mapUniqueConflict(() => this.prisma.user.create({ + data: { + tenantId, + username: data.username ?? data.email ?? data.phone ?? '', + email: normalizeOptional(data.email), + phone: normalizeOptional(data.phone), + displayName: data.displayName, + passwordHash: hashPassword(data.password), + status: data.status ?? 'active', + roles: { create: [{ roleId: role.id }] }, + }, + include: { tenant: true, roles: { include: { role: true } } }, + })); await this.writeLog(tenantId, data.operatorId, 'user.created', user.id, { roleCode, username: user.username }); - return user; + return publicUser(user); } async update(id: string, data: UpdateUserDto, scopeTenantId?: string) { @@ -130,6 +131,7 @@ export class UsersService { const roleCode = data.roleCode ?? current.roles[0]?.role.code as UserRoleCode | undefined; const roleChanged = data.roleCode !== undefined && data.roleCode !== current.roles[0]?.role.code; const tenantChanged = data.tenantId !== undefined && data.tenantId !== current.tenantId; + await this.assertAdminContinuity(current, data.status ?? current.status, roleCode, scopeTenantId ?? data.tenantId ?? current.tenantId); this.assertUserInput({ tenantId: data.tenantId ?? current.tenantId ?? undefined, username: data.username ?? current.username, @@ -141,7 +143,7 @@ export class UsersService { }, scopeTenantId, false); const tenantId = scopeTenantId ?? data.tenantId ?? current.tenantId; const role = roleCode ? await this.ensureRole(roleCode) : null; - const updated = await this.prisma.$transaction(async (tx) => { + const updated = await this.mapUniqueConflict(() => this.prisma.$transaction(async (tx) => { if (role) { await tx.userRole.deleteMany({ where: { userId: id } }); await tx.userRole.create({ data: { userId: id, roleId: role.id } }); @@ -159,20 +161,24 @@ export class UsersService { }, include: { tenant: true, roles: { include: { role: true } } }, }); - }); + })); await this.writeLog(tenantId, data.operatorId, 'user.updated', id, { roleCode, username: updated.username }); - return updated; + return publicUser(updated); } - async changeStatus(id: string, data: ChangeUserStatusDto, scopeTenantId?: string) { + async changeStatus(id: string, data: ChangeUserStatusDto, scopeTenantId?: string, currentUserId?: string) { const current = await this.getExisting(id, scopeTenantId); + if (data.status === 'disabled' && currentUserId === id) { + throw new ForbiddenException({ code: 'CANNOT_DISABLE_SELF', message: '不能禁用当前登录用户' }); + } + await this.assertAdminContinuity(current, data.status, current.roles[0]?.role.code, current.tenantId); const updated = await this.prisma.user.update({ where: { id }, data: { status: data.status, ...(data.status === 'disabled' && current.status !== 'disabled' ? { sessionVersion: { increment: 1 } } : {}) }, include: { tenant: true, roles: { include: { role: true } } }, }); await this.writeLog(current.tenantId, data.operatorId, `user.${data.status}`, id, { username: current.username }); - return updated; + return publicUser(updated); } async changePassword(id: string, data: ChangePasswordDto, scopeTenantId?: string) { @@ -186,25 +192,31 @@ export class UsersService { include: { tenant: true, roles: { include: { role: true } } }, }); await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username }); - return updated; + return publicUser(updated); } async remove(id: string, operatorId?: string, scopeTenantId?: string) { const current = await this.getExisting(id, scopeTenantId); + if (operatorId === id) { + throw new ForbiddenException({ code: 'CANNOT_DELETE_SELF', message: '不能删除当前登录用户' }); + } + await this.assertAdminContinuity(current, 'deleted', current.roles[0]?.role.code, current.tenantId); const updated = await this.prisma.user.update({ where: { id }, data: { status: 'deleted', deletedAt: new Date(), sessionVersion: { increment: 1 } }, include: { tenant: true, roles: { include: { role: true } } }, }); await this.writeLog(current.tenantId, operatorId, 'user.deleted', id, { username: current.username }); - return updated; + return publicUser(updated); } async recordLoginSuccess(id: string) { - return this.prisma.user.update({ + const updated = await this.prisma.user.update({ where: { id }, data: { failedLoginCount: 0, lockedUntil: null, lastLoginAt: new Date() }, }); + await this.writeLog(updated.tenantId, updated.id, 'auth.login_success', updated.id, { username: updated.username }); + return publicUser(updated); } async recordLoginFailure(id: string) { @@ -234,7 +246,7 @@ export class UsersService { include: { tenant: true, roles: { include: { role: true } } }, }); await this.writeLog(current.tenantId, id, 'user.password_changed_self', id, { username: current.username }); - return updated; + return publicUser(updated); } async verifyCurrentPassword(id: string, password: string) { @@ -339,6 +351,49 @@ export class UsersService { } } + private async assertAdminContinuity( + current: { id: string; tenantId: string | null; status: string; roles: Array<{ role: { code: string } }> }, + nextStatus: string, + nextRoleCode?: string, + nextTenantId?: string | null, + ) { + const currentRole = current.roles[0]?.role.code; + if (current.status !== 'active' || !['platform_admin', 'enterprise_admin'].includes(currentRole)) return; + const remainsSameAdmin = nextStatus === 'active' + && nextRoleCode === currentRole + && (currentRole !== 'enterprise_admin' || nextTenantId === current.tenantId); + if (remainsSameAdmin) return; + const activeCount = await this.prisma.user.count({ + where: { + deletedAt: null, + status: 'active', + tenantId: currentRole === 'platform_admin' ? null : current.tenantId, + roles: { some: { role: { code: currentRole } } }, + }, + }); + if (activeCount <= 1) { + throw new ConflictException({ + code: currentRole === 'platform_admin' ? 'LAST_PLATFORM_ADMIN' : 'LAST_ENTERPRISE_ADMIN', + message: currentRole === 'platform_admin' ? '不能删除、禁用或降权最后一个平台管理员' : '不能删除、禁用或降权最后一个企业管理员', + }); + } + } + + private async mapUniqueConflict(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + if ((error as { code?: string }).code !== 'P2002') throw error; + const target = (error as { meta?: { target?: string[] | string } }).meta?.target; + const field = Array.isArray(target) ? target[0] : target; + throw new ConflictException({ + code: 'USER_DUPLICATE', + field: field ?? 'login', + message: `用户${field ? `字段 ${field}` : '登录标识'}已存在;逻辑删除后仍永久保留以维持审计关联`, + }); + } + } + private writeLog(tenantId: string | null | undefined, userId: string | undefined, action: string, resourceId: string, detail: Record) { return this.prisma.operationLog.create({ data: { @@ -353,6 +408,11 @@ export class UsersService { } } +function publicUser>(user: T) { + const { passwordHash: _passwordHash, sessionVersion: _sessionVersion, failedLoginCount: _failedLoginCount, ...safe } = user; + return safe; +} + function normalizeOptional(value?: string | null) { const next = value?.trim(); return next ? next : null; diff --git a/api/tools/verify-receipt-api.ts b/api/tools/verify-receipt-api.ts new file mode 100644 index 0000000..9bf9f66 --- /dev/null +++ b/api/tools/verify-receipt-api.ts @@ -0,0 +1,56 @@ +import { PrismaService } from '../src/prisma/prisma.service'; + +const prisma = new PrismaService(); +const mode = process.argv[2]; +const channelA = 'it-receipt-channel-a'; +const channelB = 'it-receipt-channel-b'; +const recordA = 'it-receipt-record-a'; +const recordB = 'it-receipt-record-b'; + +async function cleanup() { + await prisma.smsReceiptRecord.deleteMany({ where: { messageRecordId: { in: [recordA, recordB] } } }); + await prisma.smsSubmitRecord.deleteMany({ where: { messageRecordId: { in: [recordA, recordB] } } }); + await prisma.smsMessageRecord.deleteMany({ where: { id: { in: [recordA, recordB] } } }); + await prisma.smsChannel.deleteMany({ where: { id: { in: [channelA, channelB] } } }); +} + +async function main() { + if (mode === 'seed') { + await cleanup(); + await prisma.smsChannel.createMany({ data: [ + { id: channelA, code: 'IT-RECEIPT-A', name: '回执验证通道A', gatewayHost: '127.0.0.1', gatewayPort: 17890, account: 'shared-account', passwordCipher: 'test-password-a', srcId: '10690000' }, + { id: channelB, code: 'IT-RECEIPT-B', name: '回执验证通道B', gatewayHost: '127.0.0.1', gatewayPort: 17891, account: 'shared-account', passwordCipher: 'test-password-b', srcId: '10690001' }, + ] }); + await prisma.smsMessageRecord.createMany({ data: [ + { id: recordA, messageId: 'IT-RECEIPT-MSG-A', phoneNumber: '13800000001', content: '回执验证A', channelId: channelA, submitId: 'IT-RECEIPT-SUB-A', gatewayMessageId: 'SHARED-UPSTREAM-MSG-ID', status: 'submitted', submitStatus: 'accepted', submittedAt: new Date('2026-07-20T08:00:00.000Z') }, + { id: recordB, messageId: 'IT-RECEIPT-MSG-B', phoneNumber: '13800000002', content: '回执验证B', channelId: channelB, submitId: 'IT-RECEIPT-SUB-B', gatewayMessageId: 'SHARED-UPSTREAM-MSG-ID', status: 'submitted', submitStatus: 'accepted', submittedAt: new Date('2026-07-20T08:00:00.000Z') }, + ] }); + await prisma.smsSubmitRecord.createMany({ data: [ + { id: 'it-receipt-submit-a', messageRecordId: recordA, channelId: channelA, submitId: 'IT-RECEIPT-SUB-A', gatewayMessageId: 'SHARED-UPSTREAM-MSG-ID', submitStatus: 'accepted', submittedAt: new Date('2026-07-20T08:00:00.000Z') }, + { id: 'it-receipt-submit-b', messageRecordId: recordB, channelId: channelB, submitId: 'IT-RECEIPT-SUB-B', gatewayMessageId: 'SHARED-UPSTREAM-MSG-ID', submitStatus: 'accepted', submittedAt: new Date('2026-07-20T08:00:00.000Z') }, + ] }); + console.log(JSON.stringify({ ok: true, mode })); + return; + } + if (mode === 'verify') { + const [a, b, receipts] = await Promise.all([ + prisma.smsMessageRecord.findUnique({ where: { id: recordA } }), + prisma.smsMessageRecord.findUnique({ where: { id: recordB } }), + prisma.smsReceiptRecord.findMany({ where: { messageRecordId: { in: [recordA, recordB] } } }), + ]); + const evidence = { a, b, receiptCount: receipts.length, receipts }; + const valid = a?.status === 'submitted' && a.receiptStatus === null + && b?.status === 'delivered' && b.receiptStatus === 'delivered' && b.channelId === channelB + && b.receiptRawStatus === 'DELIVRD' && receipts.length === 1 && receipts[0]?.messageRecordId === recordB; + console.log(JSON.stringify({ ok: valid, evidence }, (_key, value) => typeof value === 'bigint' ? value.toString() : value)); + await cleanup(); + if (!valid) throw new Error('receipt API identity assertion failed'); + return; + } + throw new Error('usage: verify-receipt-api.ts seed|verify'); +} + +void main().finally(() => prisma.$disconnect()).catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/api/tools/verify-report-recalculation.ts b/api/tools/verify-report-recalculation.ts new file mode 100644 index 0000000..1053cf1 --- /dev/null +++ b/api/tools/verify-report-recalculation.ts @@ -0,0 +1,82 @@ +import { PrismaService } from '../src/prisma/prisma.service'; +import { ReportsService } from '../src/reports/reports.service'; + +const ids = { + tenant: 'it-report-tenant', application: 'it-report-app', channel: 'it-report-channel', + batch: 'it-report-batch', successMessage: 'it-report-message-success', failedMessage: 'it-report-message-failed', +}; + +async function main() { + const prisma = new PrismaService(); + const reports = new ReportsService(prisma); + const queuedAt = new Date('2026-07-18T02:00:00.000Z'); + try { + await prisma.tenant.create({ data: { id: ids.tenant, name: '报表集成验证企业', code: 'IT-REPORT-TENANT' } }); + await prisma.smsApplication.create({ data: { + id: ids.application, tenantId: ids.tenant, name: '报表集成验证应用', cmppAccount: 'it-report-account', + cmppEnterpriseCode: 'ITREPORT', secretHash: 'not-a-real-secret', + } }); + await prisma.smsChannel.create({ data: { + id: ids.channel, code: 'IT-REPORT-CHANNEL', name: '报表集成验证通道', gatewayHost: '127.0.0.1', + gatewayPort: 17890, account: 'it-report-upstream', passwordCipher: 'not-a-real-password', srcId: '10690000', unitPrice: 200n, + } }); + await prisma.smsBatchTask.create({ data: { + id: ids.batch, tenantId: ids.tenant, applicationId: ids.application, taskNo: 'IT-REPORT-BATCH', sourceType: 'api', + content: '报表集成验证', phoneTotal: 2, status: 'finished', createdAt: queuedAt, + } }); + const baseMessage = { + tenantId: ids.tenant, batchTaskId: ids.batch, applicationId: ids.application, content: '报表集成验证', + billingUnits: 1, unitPrice: 352n, amountCents: 352n, channelId: ids.channel, submittedAt: queuedAt, queuedAt, + }; + await prisma.smsMessageRecord.createMany({ data: [ + { ...baseMessage, id: ids.successMessage, messageId: 'IT-REPORT-MSG-SUCCESS', phoneNumber: '13800000001', submitId: 'IT-REPORT-SUB-SUCCESS', gatewayMessageId: 'IT-REPORT-GW-SUCCESS', status: 'delivered', submitStatus: 'accepted', receiptStatus: 'delivered', receiptRawStatus: 'DELIVRD', deliveredAt: new Date(queuedAt.getTime() + 5_000) }, + { ...baseMessage, id: ids.failedMessage, messageId: 'IT-REPORT-MSG-FAILED', phoneNumber: '13800000002', submitId: 'IT-REPORT-SUB-FAILED', gatewayMessageId: 'IT-REPORT-GW-FAILED', status: 'failed', submitStatus: 'accepted', receiptStatus: 'undelivered', receiptRawStatus: 'UNDELIV', deliveredAt: new Date(queuedAt.getTime() + 8_000) }, + ] }); + await prisma.smsSubmitRecord.createMany({ data: [ + { id: 'it-report-submit-success', tenantId: ids.tenant, batchTaskId: ids.batch, messageRecordId: ids.successMessage, channelId: ids.channel, submitId: 'IT-REPORT-SUB-SUCCESS', gatewayMessageId: 'IT-REPORT-GW-SUCCESS', submitStatus: 'accepted', costUnitPrice: 200n, costAmountCents: 200n, submittedAt: queuedAt }, + { id: 'it-report-submit-failed', tenantId: ids.tenant, batchTaskId: ids.batch, messageRecordId: ids.failedMessage, channelId: ids.channel, submitId: 'IT-REPORT-SUB-FAILED', gatewayMessageId: 'IT-REPORT-GW-FAILED', submitStatus: 'accepted', costUnitPrice: 200n, costAmountCents: 200n, submittedAt: queuedAt }, + ] }); + await prisma.smsReceiptRecord.createMany({ data: [ + { id: 'it-report-receipt-success', receiptKey: 'it-report-receipt-key-success', tenantId: ids.tenant, batchTaskId: ids.batch, messageRecordId: ids.successMessage, channelId: ids.channel, messageId: 'IT-REPORT-MSG-SUCCESS', gatewayMessageId: 'IT-REPORT-GW-SUCCESS', receiptStatus: 'delivered', rawStatus: 'DELIVRD', deliveredAt: new Date(queuedAt.getTime() + 5_000) }, + { id: 'it-report-receipt-failed', receiptKey: 'it-report-receipt-key-failed', tenantId: ids.tenant, batchTaskId: ids.batch, messageRecordId: ids.failedMessage, channelId: ids.channel, messageId: 'IT-REPORT-MSG-FAILED', gatewayMessageId: 'IT-REPORT-GW-FAILED', receiptStatus: 'undelivered', rawStatus: 'UNDELIV', deliveredAt: new Date(queuedAt.getTime() + 8_000) }, + ] }); + await prisma.smsBillingRecord.createMany({ data: [ + { id: 'it-report-billing-success', tenantId: ids.tenant, applicationId: ids.application, taskId: ids.batch, messageId: 'IT-REPORT-MSG-SUCCESS', phoneNumber: '13800000001', contentLength: 8, billingUnits: 1, unitPrice: 352n, amountCents: 352n, billingStatus: 'charged', createdAt: queuedAt }, + { id: 'it-report-billing-refunded', tenantId: ids.tenant, applicationId: ids.application, taskId: ids.batch, messageId: 'IT-REPORT-MSG-FAILED', phoneNumber: '13800000002', contentLength: 8, billingUnits: 1, unitPrice: 352n, amountCents: 352n, billingStatus: 'refunded', createdAt: queuedAt }, + ] }); + + await reports.refreshRollingWindow(new Date('2026-07-20T05:00:00.000Z')); + const [reconciliation, applicationProfit, channelProfit, applicationQuality, channelQuality] = await Promise.all([ + prisma.dailyReconciliationReport.findUnique({ where: { reportDate_tenantId_applicationId: { reportDate: new Date('2026-07-18'), tenantId: ids.tenant, applicationId: ids.application } } }), + prisma.dailyProfitReport.findUnique({ where: { reportDate_dimensionType_dimensionId: { reportDate: new Date('2026-07-18'), dimensionType: 'application', dimensionId: ids.application } } }), + prisma.dailyProfitReport.findUnique({ where: { reportDate_dimensionType_dimensionId: { reportDate: new Date('2026-07-18'), dimensionType: 'channel', dimensionId: ids.channel } } }), + prisma.dailyQualityReport.findUnique({ where: { reportDate_dimensionType_dimensionId: { reportDate: new Date('2026-07-18'), dimensionType: 'application', dimensionId: ids.application } } }), + prisma.dailyQualityReport.findUnique({ where: { reportDate_dimensionType_dimensionId: { reportDate: new Date('2026-07-18'), dimensionType: 'channel', dimensionId: ids.channel } } }), + ]); + const evidence = JSON.parse(JSON.stringify({ reconciliation, applicationProfit, channelProfit, applicationQuality, channelQuality }, (_key, value) => typeof value === 'bigint' ? value.toString() : value)); + const valid = reconciliation?.sentUnits === 2 && reconciliation.successUnits === 1 && reconciliation.failedUnits === 1 + && applicationProfit?.revenueCents === 352n && applicationProfit.refundCents === 352n && applicationProfit.costCents === 400n && applicationProfit.profitCents === -48n + && channelProfit?.successUnits === 1 && channelProfit.failedUnits === 1 + && applicationQuality?.avgArrivalMs === 5_000 && channelQuality?.avgArrivalMs === 5_000; + if (!valid) throw new Error(`report recalculation assertion failed: ${JSON.stringify(evidence)}`); + console.log(JSON.stringify({ ok: true, evidence })); + } finally { + await prisma.dailyQualityReport.deleteMany({ where: { OR: [{ tenantId: ids.tenant }, { channelId: ids.channel }] } }); + await prisma.dailyProfitReport.deleteMany({ where: { OR: [{ tenantId: ids.tenant }, { channelId: ids.channel }] } }); + await prisma.dailyReconciliationReport.deleteMany({ where: { tenantId: ids.tenant } }); + await prisma.smsBillingRecord.deleteMany({ where: { tenantId: ids.tenant } }); + await prisma.smsReceiptRecord.deleteMany({ where: { tenantId: ids.tenant } }); + await prisma.smsSubmitRecord.deleteMany({ where: { tenantId: ids.tenant } }); + await prisma.smsMessageRecord.deleteMany({ where: { tenantId: ids.tenant } }); + await prisma.smsBatchTask.deleteMany({ where: { tenantId: ids.tenant } }); + await prisma.smsChannel.deleteMany({ where: { id: ids.channel } }); + await prisma.smsApplication.deleteMany({ where: { id: ids.application } }); + await prisma.tenant.deleteMany({ where: { id: ids.tenant } }); + await prisma.$disconnect(); + } +} + +void main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/docs/20260720-receipt-report-migration-notes.md b/docs/20260720-receipt-report-migration-notes.md new file mode 100644 index 0000000..e315207 --- /dev/null +++ b/docs/20260720-receipt-report-migration-notes.md @@ -0,0 +1,12 @@ +# 2026-07-20 回执身份与报表口径迁移说明 + +## 升级 + +1. 发布前按生产流程备份 PostgreSQL、运行源码和环境配置并校验归档。 +2. 依次应用 `20260720110000_add_receipt_identity`、`20260720113000_add_report_business_metrics` 与 `20260720114500_add_receipt_phone_number`。三者为历史回执生成不冲突的 `receiptKey`,增加回执文本、目的号码和匹配索引,并增加报表失败数及利润退款金额列。历史目的号码先从原关联主记录回填;对已知错绑记录仍须依据生产提交记录、通道和原始 Gateway 日志专项复核,不能仅靠该回填自动改绑。 +3. 先重启 Gateway,再重启 API;检查 Redis Stream、活动通道、TPS key、API/Gateway health 和近期错误日志。 +4. 对 T-4 至 T-1 及需修复的历史日期重复执行报表重算,核对查询与 CSV 的发送、成功、失败、收入、退款、成本、利润和到达时长。 + +## 回滚 + +应用代码可回滚到上一版本,但新增列和索引默认保留,避免丢失已接收的回执身份、错误文本和重算结果。若确认不存在新版本写入且必须做结构回滚,应先备份,再依次删除两个报表新增列、回执新增列及索引;删除 `receiptKey` 唯一约束前必须确认旧代码不会再次以模糊条件消费回执。生产禁止未经审批直接执行破坏性回滚。 diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index b5ac177..c9bcd9b 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -764,7 +764,7 @@ ### 9.5 发送 - sms_batch_task:批量任务。 -- sms_message_record:单号码发送记录,所有平台任务、API 调用、CMPP 对接发送均进入此表。 +- sms_message_record:单号码发送记录,所有平台任务、API 调用、CMPP 对接发送均进入此表。客户 CMPP Submit 的 `DestUsrTl/DestTerminalId` 包含多个号码时,Gateway 和 NestJS 必须按目标号码拆分为多条独立记录,每个号码分别执行模板/签名、风控、余额、计费、路由、上游提交和回执处理;同一客户 Submit 仍只返回一个 CMPP SubmitResp/Msg_Id,后续 Deliver Receipt 以该 Msg_Id 与各自 `DestTerminalId` 区分。所有拆分记录必须持久化同一 Submit 分组消息 ID,使 Gateway 重启后仍能重建客户最初收到的 Msg_Id。任何目标号码格式非法时必须在落库前拒绝整包,禁止返回成功后只处理首号码。 - sms_api_request:API 调用批次记录。 - cmpp_submit_session:CMPP 对接提交会话或批次记录。 - sms_submit_record:通道提交记录。 @@ -1543,3 +1543,30 @@ 4. 参数复制必须兼容平台当前 HTTP 页面:优先使用 Clipboard API;浏览器因非安全上下文或权限拒绝时,使用受控 textarea 复制降级,并向用户明确反馈成功或失败,不得无提示失败。 5. `cmppMaxConnections` 必须在 Gateway 登录时按应用和活动 TCP 会话真实计数并限制,同时由 API 的连接事件校验兜底。连接关闭或异常断开后必须及时释放连接名额并回写断开事件。 6. CMPP IP/CIDR 白名单必须在登录和连接事件中校验;运营端修改白名单、关闭接口、停用应用或降低最大连接数后,Gateway 应在下一次心跳校验时关闭不再符合条件的存量连接,不能只限制后续 Submit。 + +## 2026-07-18 手工验收瑕疵收口要求 + +1. 图片上传单文件不得超过 2MB,其他文件不得超过 10MB;前端选择文件时即时提示,NestJS 接口与 MinIO 入库前必须再次校验,不得只依赖页面限制。 +2. 企业统一社会信用代码仅允许英文字母和数字,前后端同时校验;上传文件名需换行,营业执照预览和下载使用一致的操作样式。 +3. HTTP 由未开通切换为开通时,默认开启发送、状态查询、回执 Webhook、上行查询、上行 Webhook 和客户自助凭据全部能力,回执与上行默认使用 HTTP Webhook;参数复制页须展示业务化投递方式,不直接暴露 `cmpp/http/both/none` 原始值。 +4. 企业应用、短信记录的“查询”和“重置”即使条件未变也必须重新请求真实 API;CMPP 连接详情不得依赖水平滚动,长 AppID/连接 ID 必须可换行。 +5. 短信审核列表时间统一为 `YYYY-MM-DD HH:mm:ss`;审核人和审核时间由当前 HttpOnly 会话在服务端写入,列表用“更多信息”展示;操作成功后待审角标立即重新请求真实仪表盘 API。 +6. 短信详情同时展示客户提交时收到的接入号和平台送往上游的接入号;运营商区分规则显示中文名称并支持真实 DELETE API。 +7. 所有登录用户发起且成功的 POST/PUT/PATCH/DELETE 操作必须写入 PostgreSQL `OperationLog`,至少保存操作人、方法、路径、资源 ID、状态码、IP 和 User-Agent;不得记录密码、密钥或请求体。 + +## 2026-07-20 回执、报表、安全与公开 HTTP 接口收口要求 + +1. 上游回执按内部消息 ID,或 `channelId + gatewayMessageId + DestTerminalId` 对提交记录作唯一匹配;同账号多通道不得互相认领。每个回执事件必须有数据库唯一键,并发或重复事件不得重复生成记录、退款、计费或下游投递。 +2. 成功回执统一更新短信主记录状态、回执状态、到达时间、真实通道、通道消息号、原始回执码和文本;失败、超时和未知回执保留可解释状态,最终失败只退款一次。 +3. 对账、利润和质量报表统一以短信记录计费条数为发送量,以唯一主记录终态统计成功/失败;收入取扣费流水,返还取退款流水,成本取真实提交通道单价,利润等于收入减成本。到达时长按提交至成功回执计算,延迟回执由 T+1 和 T-4 至 T-1 重算覆盖;查询、页面和 CSV 共用同一聚合表。 +4. 运营端与客户端用户接口使用各自安全 DTO,禁止输出密码散列、会话版本、登录失败内部计数和密钥字段。后端禁止自删除/自停用、禁止删除或降权最后一个平台管理员及企业管理员,并强制校验跨租户操作;唯一冲突返回 HTTP 409 和明确字段。 +5. HTTP 单发公开契约使用 `mobile`、`content` 和可选 `clientMessageId`,不要求内部签名或模板 ID;服务端按 CMPP 同一规则识别已审核签名、模板及变量,复用风控、余额、计费、路由和队列。成功返回可查询 messageId,业务拒绝返回对应 4xx,不得在已创建记录后返回“批次不存在”。 +6. 客户发送候选只返回 approved 签名和模板,管理视图可查看历史状态。模板变量必须拒绝空变量、未闭合、中文或非法名称、重复名称及超长名称;客户端签名视图仅返回必要报备汇总状态。 +7. 报备资料提供官方 XLSX 模板和按当前筛选导出;导入拒绝空文件、错误扩展名、超限文件以及公式/脚本单元格。分析、提交和导出日志记录操作人、文件名、筛选条件、成功数、失败数和 IP,不保存密钥或完整请求体。 + +## 2026-07-20 客户端用户管理移动操作可达性要求 + +1. 客户端用户管理在`≤780px`使用键值卡片时,编辑、改密、禁用/启用、删除四项操作不得继续沿用不可换行的桌面横排;采用2×2网格或可访问的“更多操作”菜单,任何允许动作都不得因容器裁切而消失。 +2. 390×844和375×667下四项操作必须全部可见、可聚焦、可命中,触控热区高度至少44px;操作组应具有包含目标用户名称的可访问名称。 +3. 删除仍必须走真实客户端用户API与确认流程,不得通过前端隐藏或静态数据冒充;页面验收只打开并取消确认时,不得产生DELETE请求或数据库状态变化。 +4. 1440×900、1366×768和768×1024必须同步回归。1366桌面宽表若仍需内部横向滚动,滚动条必须可发现且操作可到达;固定操作列和邮箱列宽另按全站Table整改治理。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 599e49f..c6db1d2 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -1159,7 +1159,7 @@ - 步骤: 1. 启动 Go Gateway,确认 `GATEWAY_CMPP_ADDR=0.0.0.0:17890`。 2. 使用 gocmpp 或真实 CMPP 客户端连接 `17890`,`Source_Addr` 填应用 `cmppAccount`,密码填应用 CMPP 参数 `passwordCipher`。 - 3. 分别发送 CMPP 2.0 和 CMPP 3.0 SubmitReq,手机号和内容匹配已审核模板。 + 3. 分别发送 CMPP 2.0 和 CMPP 3.0 单号码 SubmitReq;再发送 `DestUsrTl=2`、两个合法 `DestTerminalId` 的多号码 SubmitReq,手机号和内容均匹配已审核模板。 4. 再发送一条不匹配审核模板的 SubmitReq,检查客户收到的 SubmitResp 和 Gateway 日志。 5. 查询 NestJS 数据库和运营端短信记录。 - 预期结果: @@ -1167,10 +1167,10 @@ - bind 阶段调用真实 NestJS API 校验账号、密码、企业状态、认证状态、应用状态、短信接口开关和 IP 白名单。 - CMPP2.0 和 CMPP3.0 连接分别返回对应版本 ConnectResp,后续 Submit/Deliver 按该 TCP 连接协商版本解包和组包,不发生字段错位。 - 密码错误、应用停用、企业停用、短信接口关闭、IP 不在白名单时 connect/login 被拒绝。 - - submit 被接受后返回 CMPP SubmitResp 成功,并在真实数据库创建 `sourceType=cmpp` 的发送记录,进入真实发送链路。 + - submit 被接受后返回 CMPP SubmitResp 成功,并在真实数据库创建 `sourceType=cmpp` 的发送记录,进入真实发送链路。多号码 Submit 仍只返回一个 SubmitResp/Msg_Id,但必须为全部目标号码分别创建 `SmsMessageRecord` 和内部批次,分别校验、计费、路由和提交;每个号码的 Deliver Receipt 使用同一原 Submit Msg_Id,并以各自 `DestTerminalId` 区分。Gateway 重启后从持久化分组消息 ID 和 Submit Sequence_Id 恢复时,所有号码的回执 Msg_Id 仍必须与原 SubmitResp 完全一致。 - `sourceType=cmpp` 的内部批次不出现在运营端或客户端“短信任务进度”;客户端不能通过任务 ID 读取该内部批次的详情、短信明细或执行取消。 - Submit 应用身份使用 bind 已鉴权账号;`MsgSrc` 使用应用级企业代码并独立校验。企业代码与登录账号不同时仍能正确定位应用,企业代码不匹配时返回失败。 - - 鉴权失败、IP 白名单不符、手机号等协议参数不合法时返回非零 SubmitResp,且不创建短信记录。 + - 鉴权失败、IP 白名单不符、任一目标手机号等协议参数不合法时返回非零 SubmitResp,且整包不创建短信记录;禁止多号码 Submit 返回成功后只保存或发送首号码。 - 已鉴权且参数合法的 Submit 必须先返回成功 SubmitResp 和平台 Msg_Id;内容不匹配审核模板、签名/报备未通过、余额不足、应用在 bind 后停用、无可用通道、上游 Submit 最终失败时,均须真实创建短信记录、`SmsReceiptRecord` 和 `CmppDownstreamDelivery`,并向客户下发 `undelivered/REJECTD` Deliver Receipt,不得仅以 SubmitResp 失败替代回执。 - Gateway 对每次 submit 记录 `submit_received` 和 `submit_accepted`/`submit_rejected`;日志可按账号、IP、sequenceId、号码和 messageId 定位,拒绝时包含 NestJS 真实业务原因和 CMPP result,但不包含明文短信正文。 - CMPP 包在进入 handler 前因长度、命令字、读包或 Unpack 失败时,Gateway 记录 `read/unpack packet failed`、远端地址、协议模式、错误类型和原始错误,不得静默断开。 @@ -3518,3 +3518,38 @@ npm run verify:phase8 | TC-HTTP-WEBHOOK-002 | 回调依次返回 500、429、408、400、302 和 200,并模拟超时。 | 500/429/408/网络错误按既定退避重试,400 和重定向终结,2xx 成功;每次尝试、状态码、耗时和截断响应写 PostgreSQL,可授权手工重投。 | | TC-HTTP-WEBHOOK-003 | 保存指向 localhost、RFC1918、链路本地、共享地址、云元数据 IP、会解析到私网的域名和发生 DNS 重绑定的 URL。 | 保存或投递前被 SSRF 校验拒绝;不跟随重定向;生产 HTTPS 约束开启时 HTTP URL 被拒绝。 | | TC-HTTP-CLIENT-001 | 客户端打开“接口对接”五个页签,切换应用、创建凭据、配置回调、查看文档与日志;API 断开后重试。 | 所有状态来自真实 API/PostgreSQL/Redis;应用卡片显示 HTTP 状态;API 失败展示错误,不使用 localStorage 或前端静态数据伪造成功。 | + +### 17.15 手工验收瑕疵回归 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-DEFECT-001 | 分别选择 2MB 与超过 2MB 的图片、10MB 与超过 10MB 的普通文件,并绕过前端直接请求上传 API。 | 边界值可上传至 MinIO,超限在前端即时拒绝且 API 再次返回 400,MinIO 无超限对象。 | +| TC-DEFECT-002 | 新建/编辑企业,输入中文、标点和英文数字信用代码,上传长文件名执照并预览/下载。 | 非英文数字被拒绝,合法值真实入库;文件名换行且两个操作样式一致。 | +| TC-DEFECT-003 | 编辑一个 HTTP 未开通的应用,切换为开通并保存,刷新后查看/复制 HTTP 参数。 | 六项能力全部开启,回执/上行为 HTTP Webhook,配置真实写入 PostgreSQL,复制内容不显示原始枚举值。 | +| TC-DEFECT-004 | 企业应用列表保持相同条件连续点击查询/重置,打开包含长 ID 的 CMPP 连接详情;短信记录同样操作。 | 每次均有真实 API 请求且数据更新;弹窗无水平滚动,长 ID 自动换行。 | +| TC-DEFECT-005 | 通过/驳回一条待审短信并查看列表、更多信息及导航角标。 | 审核人/时间由当前会话写库,时间格式正确,列表不额外占列,角标不等待 30 秒轮询即更新。 | +| TC-DEFECT-006 | 打开真实短信详情,再新增后删除一条运营商区分规则。 | 详情分别展示 `clientSrcId` 与通道 `srcId + applicationExtension`;运营商显示中文,DELETE API 真实删库并刷新。 | +| TC-DEFECT-007 | 用登录运营用户修改企业应用及其他任一写操作,再查看系统日志;另构造失败请求。 | 成功写操作均有操作人、路径、资源和结果日志;失败请求不写伪成功日志,日志不含请求体、密码或密钥。 | + +### 17.16 2026-07-20 缺陷回归 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-RECEIPT-IDENTITY-001 | 两个通道使用同一下游账号及相同通道 Msg_Id,分别向两个号码提交,再乱序返回回执。 | 以通道、通道 Msg_Id 和号码唯一关联提交记录;主记录写入正确通道、到达时间、原始状态和文本,无跨通道抢占。 | +| TC-RECEIPT-IDEMPOTENT-001 | 顺序和并发重复发送相同 DELIVRD,随后重启服务并再次发送。 | `receiptKey` 唯一约束保证只保存一次、只下发一次客户回执,且不重复计费或退款;重启后行为一致。 | +| TC-REPORT-RECALC-001 | 插入一条成功、一条失败及扣费/退款流水,执行指定日期重算两次,再查询应用和通道报表及 CSV。 | 两次结果一致;发送 2、成功 1、失败 1;收入、退款、成本和利润使用相同金额单位且 `利润=收入-成本`;平均到达时长来自真实提交/回执时间。 | +| TC-USER-SAFE-001 | 分别查询运营端、客户端用户列表和详情,并尝试跨租户访问。 | 响应不含 `passwordHash`、`sessionVersion`、密钥或认证内部字段;跨租户查询、更新、改密、禁用和删除由 API 拒绝。 | +| TC-USER-CONTINUITY-001 | 当前用户删除/停用自己,删除或降权最后一个平台管理员、最后一个企业管理员,并创建重复用户名。 | 前三类操作返回 403;最后管理员保护生效;唯一冲突返回 409 和冲突字段,不出现 500。 | +| TC-HTTP-SEND-002 | 仅传 `mobile/content`,正文使用已审核签名及 `${code}` 模板,调用公开发送接口并重放同一幂等键。 | 自动识别签名/模板并提取合法变量,经真实发送链返回 202、稳定 messageId;相同请求返回同一结果,不出现批次 404。 | +| TC-TEMPLATE-VARIABLE-001 | 提交空变量、未闭合、中文、重复、超长和非法字符变量,再查看发送候选。 | 前后端均拒绝非法变量;候选只含 approved 签名/模板,disabled/rejected 仅在历史管理视图显示。 | +| TC-REPORT-MATERIAL-SAFE-001 | 下载官方 XLSX;按筛选导出;导入空文件、错误扩展名、超限文件、含公式/脚本单元格及部分错误行文件。 | 模板和导出为真实 XLSX;危险文件在入库前拒绝,部分失败保留行级原因;日志含操作人、文件名、筛选和计数,不含敏感请求体。 | + +### 17.16 客户端用户管理移动操作可达性回归 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-UIUX-P0-001 | 使用真实企业管理员登录客户端并打开`/client/users`,依次设置390×844和375×667。 | 真实`/api/client/users`返回的用户以卡片显示;编辑、改密、禁用/启用、删除形成2×2操作网格,全部位于视口和卡片裁切范围内。 | +| TC-UIUX-P0-002 | 在390和375视口测量四个操作按钮并逐项执行命中测试。 | 每项高度至少44px,中心点命中自身按钮,页面根节点无横向溢出;操作组可访问名称包含目标用户。 | +| TC-UIUX-P0-003 | 在375视口点击删除,读取确认内容后点击取消。 | 确认层显示目标用户名;取消后用户仍在列表,PostgreSQL记录保持active且没有执行删除。 | +| TC-UIUX-P0-004 | 依次设置768×1024、1366×768、1440×900并复核同一用户。 | 平板四项操作全部可见且热区≥44px;1440首屏完整;1366即使存在内部横向滚动,滚动后删除必须完整可达,且页面级无横向溢出。 | +| TC-UIUX-P0-005 | 完成五视口操作后检查浏览器控制台并执行前端/API构建与用户服务回归。 | 无新增console error/warn;前端与API build通过,用户服务测试通过,`git diff --check`通过。 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 2464506..5b05704 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -2008,3 +2008,44 @@ git diff --check - 工作区完整功能提交 `faa716b8d07ea77fae3ec41c858b52f6a341e6b9` 和重启恢复修正提交 `23a1f6fa15445dbe6d2e4738b10b545b7c657472` 已 push。两次发布前备份分别位于 `/opt/cmpp-platform/backups/releases/20260716-175500` 和 `/opt/cmpp-platform/backups/releases/20260716-180244`,PostgreSQL、运行源码和环境配置均通过 gzip/tar 完整性及 SHA-256 校验;最终发布包本地与服务器 SHA-256 均为 `c34c0fb627b77aa0c1a3d08169b3aff8d04587544ed9c1d83d8c0cd41b007272`。 - 生产 migration `20260716150000_expand_money_precision_to_four_decimals` 已应用,53 条 migration 齐全,`SmsApplication.customerUnitPrice` 等金额列已为 `BIGINT`。生产 API/Gateway health、PostgreSQL、Redis PONG、MinIO、Nginx 及 `12026/17890/8090/3000/9000` 监听均正常;两个真实通道 TPS key 均为 100,`gateway.submit.commands` 为 `pending=0、lag=0`,部署后 API/Gateway 无 error 级日志。 - 生产公网 CMPP 参数已配置为 `8.160.169.106:17890`。不符合白名单的 `715011 / 183.194.97.158` 陈旧连接在超时窗口后自动清除,合法账号 `910887` 由新 Gateway 建立新连接并持续更新心跳,证明白名单和重启后连接名额恢复逻辑真实生效。Chrome/Playwright 复核运营登录、390px 客户端登录和客户 Swagger 文档均为 200、无横向溢出、无 console/page error;未绕过验证码、未修改账号、未发送短信。 + +## 2026-07-18 CMPP 多号码 Submit 首号码静默丢弃修复(未提交、未部署) + +- 生产只读复现确认:账号 `695829` 的 CMPP 3.0 Submit 日志记录 `dest_count=2`,Gateway 返回 `result=0`,但只将首号码 `188****3795` 传入 NestJS 并创建一条 `SmsMessageRecord`;随后单独提交第二个号码 `131****0092` 才产生第二条记录。核查期间未修改生产配置、数据或进程。 +- 根因为 Gateway 已完整解码 `DestTerminalId[]`,但处理时固定读取下标 `0`;Gateway→NestJS 契约和 `submitInboundMessage` 也只有单数 `phoneNumber`,NestJS 固定以 `phoneTotal=1` 创建内部批次和短信记录。当前行为属于“返回成功但静默丢失后续号码”,不是可接受的单号码范围限制。 +- Gateway 入站契约新增完整 `phoneNumbers`,保留首号码字段兼容;NestJS 在任何业务落库前校验全部目标号码,并以最多 10 个并发的有界批次逐号码复用现有真实模板/签名、风控、余额、计费、队列和失败回执链路。每个号码创建独立 `sourceType=cmpp` 内部批次和 `SmsMessageRecord`,API 返回全部内部 messageId 映射。 +- 一个客户 Submit 仍只返回一个 CMPP SubmitResp/Msg_Id。Gateway 将该 Msg_Id 同时关联到本包全部内部 messageId,后续每个号码的 Deliver Receipt 使用相同原 Submit Msg_Id,并通过自己的 `DestTerminalId` 区分;连接断开时同步清理同一连接下全部消息映射。新增 migration `20260718130000_add_cmpp_submit_group_message_id`,为每条拆分记录持久化同一 `cmppSubmitGroupMessageId`;Gateway 重启恢复时结合该分组 ID 与原 Sequence_Id 重建相同 Msg_Id,不再按各内部 messageId 算出不同结果。 +- 新增 API 回归覆盖两号码分别落库、分别生成失败回执,以及任一号码非法时整包落库前拒绝;Gateway 真实 CMPP 3.0 TCP 集成用例改为一次提交两个号码,并验证第二个内部消息的 Deliver 携带第二个号码且 Msg_Id 与原 SubmitResp 相同,另覆盖重启恢复时两个内部 messageId 仍重建同一 Msg_Id。API 定向测试 1 suite/60 项、API 全量 20 suites/215 项、Gateway `internal/inbound` 定向测试、Gateway 全量 `go test ./...`、API TypeScript build、前端 TypeScript/Vite build、Prisma generate/validate 均通过;本地 PostgreSQL 已应用 54 条 migration 且 schema 最新,前端仅有既有 chunk size warning。 +- 使用真实本地 NestJS API、PostgreSQL 和 Redis 创建临时接口关闭应用,向 `/api/gateway/events/inbound/submit` 一次提交两个合法号码:API 返回 `phoneCount=2` 和两个内部 messageId,数据库真实生成 2 个 `sourceType=cmpp` 内部批次、2 条短信记录、2 条失败回执和 2 条下游投递;两条记录的 Sequence_Id 与 `cmppSubmitGroupMessageId` 分别一致,下游 payload 只有 1 个分组 ID。再提交“一个合法号码 + 一个非法号码”返回 HTTP 400,记录数保持不变。临时企业、应用、白名单、批次、短信、回执和投递已全部清理,未连接上游 Gateway、未发送短信。 + +## 2026-07-18 手工验收瑕疵修复(未提交、未部署) + +- 已实现图片 2MB/其他文件 10MB 前端与 NestJS 双层校验,通用文件和报备材料上传不再允许 20MB/100MB;企业信用代码收紧为英文字母与数字,营业执照操作样式和长文件名换行已修复。 +- 企业应用 HTTP 开通时默认开启六项能力并使用 HTTP Webhook,参数复制改为中文业务文案;应用列表查询/重置每次重请真实 API,CMPP 连接详情改为无水平滚动的自适应卡片;桌面侧栏强制隐藏移动端关闭按钮。 +- 审核 API 从当前登录会话写入审核人,列表时间格式化,审核人/时间收入“更多信息”弹窗,审核成功即时刷新导航角标。短信记录查询/重置重请 API,详情新增客户提交接入号与上游发送接入号。 +- 引流列表移除重复的“引流信息”列并改名“引流url或号码”;运营商规则改为中文标签,新增真实 NestJS/Prisma DELETE 链路。成功登录由用户服务写入 `auth.login_success`,其他会话用户成功写操作由统一 `OperationLog` 中间件兜底,不记录请求体和密钥。 +- 定向验证通过:API TypeScript build;文件、企业、风控审核、用户登录与人工操作日志 5 个 Jest suites/27 项;前端 TypeScript/Vite build;Prisma validate;`git diff --check`。前端仅有既有 chunk size warning。API 全量 Jest 已尝试,21 suites 中 20 suites、219 项中 218 项通过;唯一失败是另一会话正在修改的 `send-chain.service.spec.ts` 用例在本地 Redis `127.0.0.1:6379` 未运行时连接超时,本次未改动该套件。未在生产写入业务数据,未发送短信。 +- 生产只读排查:`MSG-22a27cd0-b671-4ae8-8beb-6608eaf04517` 已有真实未达回执,但无 `CmppDownstreamDelivery`/HTTP Webhook 事件;该问题与另一会话正在修复的 CMPP 多号码 Submit/Msg_Id 分组链路相互耦合,本次未修改 send-chain、Gateway inbound、Prisma schema 及其 migration,避免覆盖并行修复。“今日返还”数据核查发现当前包含发送链在路由/模板校验前的冻结后释放,余额、日限、校验顺序与返还口径待并行发送链修复后再联合回归。 + +## 2026-07-20 LG 缺陷修复与本地真实链路验证(未提交、未部署) + +- 保留并识别并行会话的 CMPP 多号码 Submit、分组 Msg_Id 和 Gateway 入站差异,本轮未覆盖或重写其 Gateway 文件;既有两号码、混合非法号码及重启恢复测试随 Gateway/API 全量测试通过。 +- 回执关联改为内部消息 ID 或 `channelId + gatewayMessageId + phoneNumber` 的唯一提交记录,新增稳定 `receiptKey` 数据库唯一约束和并发冲突兜底。主记录同步保存真实通道、通道消息号、原始状态、文本和到达时间,重复 DELIVRD 不再重复客户投递。 +- 对账、应用/通道利润和质量报表统一补充失败数、退款、真实成本、利润及到达时长,并由既有 T-4 至 T-1 重算覆盖延迟回执。真实 PostgreSQL 重算样本为发送 2、成功 1、失败 1、收入 352、退款 352、成本 400、利润 -48、平均到达 5000ms;重复执行一致,临时数据已清理。 +- 用户接口增加运营端/客户端安全 DTO,移除密码散列、会话版本和认证内部字段;后端阻止自删除/自停用、最后一个平台/企业管理员删除或降权,并将 Prisma 唯一冲突映射为 409。逻辑删除后的用户名继续保留,确保历史审计关联稳定。 +- HTTP 单发改为仅凭 `mobile/content` 自动识别已审核签名、模板和变量,复用 CMPP 发送规则;修复 API 来源批次创建后按客户端来源读取导致的 404,OpenAPI 增加稳定请求 Schema 和示例。客户端发送候选默认只返回 approved 签名/模板,变量拒绝未闭合、空、中文、重复、非法或超长名称。 +- 报备资料新增官方 XLSX 模板和按筛选导出接口,导入拒绝公式及公式注入单元格;分析、提交、导出日志保存当前操作人、文件名、筛选和成功/失败数。黑名单重复唯一冲突返回 409,逻辑删除记录按既定规则恢复。 +- 本地 Redis `PING=PONG`,真实 NestJS API health 返回 ok。使用真实 PostgreSQL、Redis 与 `/api/gateway/events/receipt` 验证同一通道 Msg_Id 双通道场景:A 保持 submitted,B 正确 delivered,重复回执返回同一消息且仅一条回执记录。回执目的号码已持久化并建立三字段匹配索引;Prisma 本地 57 条 migration 已应用且 schema 最新。 +- 验证通过:API 全量 Jest 21 suites/237 tests、回执与报备材料定向 2 suites/68 tests、用户安全与报备材料定向 2 suites/17 tests、API TypeScript build、Gateway `go test ./...`、前端 TypeScript/Vite build。Jest 仍有既有异步句柄退出提示,断言全部通过;前端仍有既有大 chunk 警告。生产未写入数据、未发送短信,代码未提交、未 push、未部署,生产仍需获批发布后复测。 +- `npm run verify:phase8` 两次完整尝试都在同一个 BullMQ 15,000 条性能门槛停止,分别为 375.00 TPS 和 404.53 TPS,未达到 500 TPS;同一脚本隔离复测为 907.93 TPS 并通过。因此功能与契约断言未失败,但完整阶段命令受本机共享 Redis/瞬时负载影响,按“环境性能不稳定”记录,不能标记为完整通过。 +- Browser 插件已打开真实本地运营端,登录页、验证码和前端到 NestJS API 代理正常;登录后报表页面验收受图形验证码安全约束阻塞,本轮未代解或绕过验证码,不能标记为页面通过。本地临时管理员、角色及关联数据已清理,临时 API/前端进程已关闭。 +- 另通过真实本地登录 API、HttpOnly 会话、PostgreSQL 与 NestJS 路由完成后端验收:`GET /admin/users` 找到临时用户且 `passwordHash/sessionVersion/failedLoginCount` 泄漏字段为空;当前用户自删返回 403;官方签名资料模板下载返回 200、XLSX 6952 字节;利润查询返回真实聚合行。数据库 `OperationLog` 记录 `report_material.template_downloaded`、正确操作人、文件名、成功数和非空 IP;所有临时用户、角色、关联和日志随后已清理。 + +## 2026-07-20 平台LG二轮UI/UX阶段A启动与LG2-P0-01修复(未提交、未部署) + +- 已完整读取二轮最终报告、执行进度、基线、页面矩阵、设计规范、可访问性、五视口、性能稳定性、高风险、会话导航、角色边界,以及阶段A相关客户端组件/核心流程、运营核心流程、表单、列表和弹窗专项报告;查看了375/390手机阻断截图与1280桌面列宽截图。在测试文档目录建立45项唯一问题整改台账和阶段A源码/测试/风险映射。 +- 根因确认:公共Table在`≤780px`已经转为卡片,但客户端用户页四个动作继续使用不可换行`.inline-actions`,父卡片`overflow:hidden`把末尾删除裁掉。修复仅修改`ClientUsersPage.tsx`并新增页面专用CSS;手机/平板操作区变为2×2网格、按钮高度44px,未修改已有并发变更的公共`global.css`或用户后端。 +- 真实本地链路验收:启动NestJS、Vite preview、PostgreSQL和Redis,创建唯一临时租户/企业管理员,经真实验证码、登录会话、`/api/client/users`读取数据。375视口点击删除只打开含目标用户名的确认层并取消,记录未删除;随后退出并精确清理临时租户、用户、角色关联和操作日志,复核`cleaned=true`。未发送短信、未充值、未修改生产数据。 +- 五视口Browser验收:375×667、390×844和768×1024四项操作全部可见、中心可命中、高度44px且页面无横向溢出;1440×900全部首屏可见;1366×768保留既有55px表格内部横向滚动,滚动后删除完整可达,该桌面列宽问题继续归入LG2-P1-03/P2-04。控制台`error/warn=0`。截图保存在测试项目`平台LG_UIUX二轮走查证据/整改_20260720_LG2-P0-01/`。 +- 自动化:API用户服务1 suite/11 tests、API TypeScript build、前端TypeScript/Vite build、Prisma validate/migrate status、Redis PONG和`git diff --check`通过;本地共有56条migration且schema最新。项目当前没有前端lint或组件测试脚本,未虚报通过。首次前端build被另一会话新增`includeHistory:boolean`查询类型拦截,已在`adminApi.ts`做字符串序列化的最小兼容,不改变业务语义。 +- `LG2-P0-01`在整改台账标记“已通过”;代码未提交、未推送、未部署,生产仍运行旧版本,生产P0尚未发布。 diff --git a/gateway/internal/inbound/server.go b/gateway/internal/inbound/server.go index ad55b5a..1017e2d 100644 --- a/gateway/internal/inbound/server.go +++ b/gateway/internal/inbound/server.go @@ -44,18 +44,25 @@ type authRequest struct { } type submitRequest struct { - Account string `json:"account"` + Account string `json:"account"` + PhoneNumber string `json:"phoneNumber,omitempty"` + PhoneNumbers []string `json:"phoneNumbers,omitempty"` + Content string `json:"content"` + SrcID string `json:"srcId,omitempty"` + DestID string `json:"destId,omitempty"` + SequenceID uint32 `json:"sequenceId,omitempty"` + RemoteIP string `json:"remoteIp,omitempty"` +} + +type submitResponseMessage struct { PhoneNumber string `json:"phoneNumber"` - Content string `json:"content"` - SrcID string `json:"srcId,omitempty"` - DestID string `json:"destId,omitempty"` - SequenceID uint32 `json:"sequenceId,omitempty"` - RemoteIP string `json:"remoteIp,omitempty"` + MessageID string `json:"messageId"` } type submitResponse struct { - Accepted bool `json:"accepted"` - MessageID string `json:"messageId"` + Accepted bool `json:"accepted"` + MessageID string `json:"messageId"` + Messages []submitResponseMessage `json:"messages,omitempty"` } type authResponse struct { @@ -68,17 +75,18 @@ type authResponse struct { } type DownstreamReceipt struct { - DeliveryID string `json:"deliveryId,omitempty"` - Account string `json:"account,omitempty"` - ApplicationID string `json:"applicationId,omitempty"` - MessageID string `json:"messageId"` - GatewayMessageID string `json:"gatewayMessageId,omitempty"` - PhoneNumber string `json:"phoneNumber,omitempty"` - ReceiptStatus string `json:"receiptStatus"` - RawStatus string `json:"rawStatus,omitempty"` - ErrorCode string `json:"errorCode,omitempty"` - SubmitSequenceID uint32 `json:"submitSequenceId,omitempty"` - DeliveredAt string `json:"deliveredAt,omitempty"` + DeliveryID string `json:"deliveryId,omitempty"` + Account string `json:"account,omitempty"` + ApplicationID string `json:"applicationId,omitempty"` + MessageID string `json:"messageId"` + GatewayMessageID string `json:"gatewayMessageId,omitempty"` + PhoneNumber string `json:"phoneNumber,omitempty"` + ReceiptStatus string `json:"receiptStatus"` + RawStatus string `json:"rawStatus,omitempty"` + ErrorCode string `json:"errorCode,omitempty"` + SubmitSequenceID uint32 `json:"submitSequenceId,omitempty"` + SubmitGroupMessageID string `json:"submitGroupMessageId,omitempty"` + DeliveredAt string `json:"deliveredAt,omitempty"` } type DownstreamUplink struct { @@ -267,9 +275,13 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge setInboundSubmitResponse(response.Packer, 0, 9) return false, nil } + phones := make([]string, len(req.destTerminalIDs)) + for index, destination := range req.destTerminalIDs { + phones[index] = strings.TrimSpace(strings.TrimRight(destination, "\x00")) + } phone := "" - if len(req.destTerminalIDs) > 0 { - phone = strings.TrimRight(req.destTerminalIDs[0], "\x00") + if len(phones) > 0 { + phone = phones[0] } remote := packet.Conn.Conn.RemoteAddr() clientProtocol := defaultString(session.protocol, req.protocol) @@ -290,13 +302,14 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge contentHash := fmt.Sprintf("%x", md5.Sum([]byte(content))) startedAt := time.Now() result, err := s.submit(remote, submitRequest{ - Account: account, - PhoneNumber: phone, - Content: content, - SrcID: req.srcID, - DestID: phone, - SequenceID: req.sequenceID, - RemoteIP: remoteIP(remote), + Account: account, + PhoneNumber: phone, + PhoneNumbers: phones, + Content: content, + SrcID: req.srcID, + DestID: phone, + SequenceID: req.sequenceID, + RemoteIP: remoteIP(remote), }) if err != nil || !result.Accepted { reason := "api returned accepted=false" @@ -312,24 +325,34 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge } gatewayMsgID := messageIDFrom(result.MessageID, req.sequenceID) setInboundSubmitResponse(response.Packer, gatewayMsgID, 0) - rememberDownstream(downstreamSession{ - messageID: result.MessageID, - account: account, - enterpriseCode: session.enterpriseCode, - protocol: clientProtocol, - srcID: strings.TrimSpace(req.srcID), - phoneNumber: phone, - gatewayMsgID: gatewayMsgID, - remoteIP: remoteIP(remote), - connectedAt: time.Now().UTC(), - connectionID: session.connectionID, - conn: packet.Conn, - mu: &sync.Mutex{}, - presence: s.PresenceStore, - instanceID: s.gatewayInstanceID(), - report: session.report, - deliveryReport: session.deliveryReport, - }) + responseMessages := result.Messages + if len(responseMessages) == 0 { + responseMessages = []submitResponseMessage{{PhoneNumber: phone, MessageID: result.MessageID}} + } + for index, acceptedMessage := range responseMessages { + acceptedPhone := strings.TrimSpace(acceptedMessage.PhoneNumber) + if acceptedPhone == "" && index < len(phones) { + acceptedPhone = phones[index] + } + rememberDownstream(downstreamSession{ + messageID: acceptedMessage.MessageID, + account: account, + enterpriseCode: session.enterpriseCode, + protocol: clientProtocol, + srcID: strings.TrimSpace(req.srcID), + phoneNumber: acceptedPhone, + gatewayMsgID: gatewayMsgID, + remoteIP: remoteIP(remote), + connectedAt: time.Now().UTC(), + connectionID: session.connectionID, + conn: packet.Conn, + mu: &sync.Mutex{}, + presence: s.PresenceStore, + instanceID: s.gatewayInstanceID(), + report: session.report, + deliveryReport: session.deliveryReport, + }) + } if current := findSessionByConn(packet.Conn); current != nil && current.report != nil { go current.report(current, "submit", "") } @@ -344,8 +367,8 @@ func (s Server) handleSubmit(response *cmpp.Response, packet *cmpp.Packet, logge }() } logger.Printf( - "cmpp inbound event=submit_accepted protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s result=0 message_id=%s gateway_message_id=%d duration_ms=%d content_chars=%d content_hash=%s", - clientProtocol, req.protocol, account, remote, req.sequenceID, phone, result.MessageID, gatewayMsgID, time.Since(startedAt).Milliseconds(), len([]rune(content)), contentHash, + "cmpp inbound event=submit_accepted protocol=%s packet_type=%s account=%s remote=%s seq=%d phone=%s dest_count=%d accepted_count=%d result=0 message_id=%s gateway_message_id=%d duration_ms=%d content_chars=%d content_hash=%s", + clientProtocol, req.protocol, account, remote, req.sequenceID, phone, len(phones), len(responseMessages), result.MessageID, gatewayMsgID, time.Since(startedAt).Milliseconds(), len([]rune(content)), contentHash, ) return false, nil } @@ -742,9 +765,9 @@ func forgetDownstream(session *downstreamSession) { return } downstreamRegistry.Lock() - if session.messageID != "" { - if current := downstreamRegistry.byMessageID[session.messageID]; current == session { - delete(downstreamRegistry.byMessageID, session.messageID) + for messageID, current := range downstreamRegistry.byMessageID { + if current != nil && current.conn == session.conn { + delete(downstreamRegistry.byMessageID, messageID) } } if session.account != "" { @@ -1003,7 +1026,7 @@ func recoverReceiptSession(event DownstreamReceipt) *downstreamSession { } recovered := *accountSession recovered.messageID = event.MessageID - recovered.gatewayMsgID = messageIDFrom(event.MessageID, event.SubmitSequenceID) + recovered.gatewayMsgID = messageIDFrom(defaultString(event.SubmitGroupMessageID, event.MessageID), event.SubmitSequenceID) return &recovered } diff --git a/gateway/internal/inbound/server_test.go b/gateway/internal/inbound/server_test.go index 283f17a..b096e7f 100644 --- a/gateway/internal/inbound/server_test.go +++ b/gateway/internal/inbound/server_test.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "net/http/httptest" + "reflect" "strings" "sync" "testing" @@ -112,7 +113,13 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) { if err := json.NewDecoder(r.Body).Decode(&gotSubmit); err != nil { t.Fatalf("decode submit: %v", err) } - _ = json.NewEncoder(w).Encode(submitResponse{Accepted: true, MessageID: "MSG-1"}) + _ = json.NewEncoder(w).Encode(submitResponse{ + Accepted: true, MessageID: "MSG-1", + Messages: []submitResponseMessage{ + {PhoneNumber: "13500002696", MessageID: "MSG-1"}, + {PhoneNumber: "13600002696", MessageID: "MSG-2"}, + }, + }) case "/api/gateway/events/downstream/pending": _ = json.NewEncoder(w).Encode([]pendingDelivery{}) case "/api/gateway/events/inbound/connection": @@ -174,8 +181,8 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) { FeeType: "02", FeeCode: "0", SrcId: "10690000", - DestUsrTl: 1, - DestTerminalId: []string{"13500002696"}, + DestUsrTl: 2, + DestTerminalId: []string{"13500002696", "13600002696"}, MsgLength: uint8(len(content)), MsgContent: content, }) @@ -188,8 +195,8 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) { } sendResult, err := PushReceiptWithResult(DownstreamReceipt{ DeliveryID: "delivery-1", - MessageID: "MSG-1", - PhoneNumber: "13500002696", + MessageID: "MSG-2", + PhoneNumber: "13600002696", ReceiptStatus: "delivered", DeliveredAt: time.Now().UTC().Format(time.RFC3339Nano), }) @@ -204,7 +211,7 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) { if err := receipt.Unpack([]byte(deliver.MsgContent)); err != nil { t.Fatalf("unpack pushed receipt: %v", err) } - if receipt.Stat != "DELIVRD" || receipt.DestTerminalId != "13500002696" { + if receipt.Stat != "DELIVRD" || receipt.DestTerminalId != "13600002696" || receipt.MsgId != rsp.MsgId { t.Fatalf("unexpected pushed receipt: %+v", receipt) } if err := client.SendRspPkt(&cmpp.Cmpp3DeliverRspPkt{MsgId: deliver.MsgId, Result: 0}, deliver.SeqId); err != nil { @@ -221,7 +228,8 @@ func TestInboundServerAuthenticatesAndSubmits(t *testing.T) { if gotAuth.Account != account || gotAuth.AuthSource == "" || gotAuth.RemoteIP == "" { t.Fatalf("unexpected auth payload: %+v", gotAuth) } - if gotSubmit.Account != account || gotSubmit.PhoneNumber != "13500002696" || gotSubmit.Content != "测试入站" { + if gotSubmit.Account != account || gotSubmit.PhoneNumber != "13500002696" || gotSubmit.Content != "测试入站" || + !reflect.DeepEqual(gotSubmit.PhoneNumbers, []string{"13500002696", "13600002696"}) { t.Fatalf("unexpected submit payload: %+v", gotSubmit) } } @@ -742,6 +750,15 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi if recovered.gatewayMsgID != messageIDFrom("MSG-NOT-REMEMBERED", 1216579149) || recovered.gatewayMsgID == 0 { t.Fatalf("unexpected recovered Msg_Id: %d", recovered.gatewayMsgID) } + first := recoverReceiptSession(DownstreamReceipt{ + MessageID: "MSG-FIRST", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: 77, + }) + second := recoverReceiptSession(DownstreamReceipt{ + MessageID: "MSG-SECOND", SubmitGroupMessageID: "MSG-GROUP", Account: "100001", SubmitSequenceID: 77, + }) + if first == nil || second == nil || first.gatewayMsgID != second.gatewayMsgID || first.gatewayMsgID != messageIDFrom("MSG-GROUP", 77) { + t.Fatalf("multi-destination recovery did not preserve the original Msg_Id: first=%+v second=%+v", first, second) + } } func TestSendDownstreamRejectsZeroMessageID(t *testing.T) { diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index d730468..0014bb0 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -1,4 +1,5 @@ import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, requestReauthentication, type LoginSession } from './session'; +import { assertUploadFileSize } from '@/utils/fileUpload'; type RequestOptions = RequestInit & { tenantId?: string; @@ -346,8 +347,10 @@ export type ClientSmsSignature = { }; export type ClientSmsSignatureView = Pick & { + pendingReport?: boolean; + reportChangedAt?: string; application?: Pick | null; submittedMaterialCount: number; reportValues: Record; @@ -474,6 +477,8 @@ export type SmsMessageRecord = { carrier?: string | null; province?: string | null; content: string; + clientSrcId?: string | null; + applicationExtension?: string | null; billingUnits: number; amountCents: number; status: string; @@ -794,6 +799,7 @@ export type RiskReviewTask = { rejectReason?: string | null; createdAt: string; reviewedAt?: string | null; + reviewedBy?: { id: string; username: string; displayName: string } | null; riskHits?: Array<{ id: string; ruleName: string; reason: string }>; _count?: { messageRecords: number }; }; @@ -845,6 +851,7 @@ export type DailyReconciliationReport = { applicationName: string; sentUnits: number; successUnits: number; + failedUnits: number; generatedAt: string; updatedAt: string; }; @@ -861,7 +868,9 @@ export type DailyProfitReport = { channelId?: string | null; sentUnits: number; successUnits: number; + failedUnits: number; revenueCents: number; + refundCents: number; costCents: number; profitCents: number; profitRateBps: number; @@ -883,6 +892,7 @@ export type DailyQualityReport = { drainageInfoId?: string | null; sentUnits: number; successUnits: number; + failedUnits: number; successRateBps: number; avgArrivalMs?: number | null; generatedAt: string; @@ -1351,6 +1361,7 @@ export const adminApi = { saveReportImportProfile: (body: Omit & { id?: string }) => request('/admin/report-materials/import-profiles', { method: 'POST', body: JSON.stringify(body) }), analyzeReportMaterialImport: (file: File, body: { tenantId: string; applicationId?: string; reportType: 'signature' | 'drainage'; sheetName?: string; headerRowCount?: number; dataStartRow?: number; profileId?: string }) => { + assertUploadFileSize(file); const form = new FormData(); form.set('file', file); Object.entries(body).forEach(([key, value]) => { if (value !== undefined) form.set(key, String(value)); }); @@ -1433,6 +1444,7 @@ export const adminApi = { request<{ items: DictionaryItem[]; total: number; page: number; pageSize: number }>(withQuery('/admin/dictionaries/phone-carrier-rules', query)), createPhoneCarrierRule: (body: { carrier: string; pattern: string; priority?: number; status?: string; remark?: string }) => request('/admin/dictionaries/phone-carrier-rules', { method: 'POST', body: JSON.stringify(body) }), + deletePhoneCarrierRule: (id: string) => request(`/admin/dictionaries/phone-carrier-rules/${id}`, { method: 'DELETE' }), listDrainageFields: () => request('/admin/dictionaries/drainage-fields'), createDrainageField: (body: { code: string; name: string; fieldType: 'string' | 'image' | 'file'; required?: boolean; status?: string; description?: string }) => request('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }), @@ -1442,6 +1454,7 @@ export const adminApi = { request('/admin/dictionaries/common-report-fields', { method: 'POST', body: JSON.stringify(body) }), deleteCommonReportField: (id: string) => request(`/admin/dictionaries/common-report-fields/${id}`, { method: 'DELETE' }), uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => { + assertUploadFileSize(file); const form = new FormData(); form.set('file', file); form.set('purpose', body.purpose); @@ -1539,8 +1552,12 @@ export const clientApi = { request(`/client/drainage-infos/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), changeDrainageInfoStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/drainage-infos/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }), - listTemplates: (query: { status?: string; keyword?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => - request(withQuery('/client/templates', query), { tenantId }), + listTemplates: (query: { status?: string; keyword?: string; includeHistory?: boolean } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(withQuery('/client/templates', { + status: query.status, + keyword: query.keyword, + includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory), + }), { tenantId }), createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/templates', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), updateTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => @@ -1568,6 +1585,7 @@ export const clientApi = { createFileObject: (body: { bucket?: string; objectKey: string; fileName: string; contentType: string; sizeBytes: number; purpose: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/admin/files', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId, bucket: body.bucket ?? 'cmpp-platform' }) }), uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => { + assertUploadFileSize(file); const form = new FormData(); form.set('file', file); form.set('purpose', body.purpose); diff --git a/src/apps/admin/AdminCustomerFormPage.tsx b/src/apps/admin/AdminCustomerFormPage.tsx index c9abc9c..44767f1 100644 --- a/src/apps/admin/AdminCustomerFormPage.tsx +++ b/src/apps/admin/AdminCustomerFormPage.tsx @@ -119,6 +119,7 @@ export function AdminCustomerFormPage() { const nextErrors: EnterpriseFormErrors = {}; if (!form.name.trim()) nextErrors.name = '请填写企业名称'; if (!form.creditCode.trim()) nextErrors.creditCode = '请填写统一社会信用代码'; + else if (!/^[A-Za-z0-9]+$/.test(form.creditCode.trim())) nextErrors.creditCode = '统一社会信用代码只能包含英文字母和数字'; if (!form.contactName.trim()) nextErrors.contactName = '请填写联系人姓名'; if (!form.contactPhone.trim()) nextErrors.contactPhone = '请填写手机号'; const creditLimit = Number(form.creditLimit); @@ -210,7 +211,7 @@ export function AdminCustomerFormPage() { error={errors.creditCode} hint="修改此项将同步更新该企业档案。" label="统一社会信用代码" - onChange={(event) => updateForm('creditCode', event.target.value)} + onChange={(event) => updateForm('creditCode', event.target.value.replace(/[^A-Za-z0-9]/g, ''))} placeholder="请填写统一社会信用代码或纳税识别号" required value={form.creditCode} diff --git a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx index 7f20986..7268524 100644 --- a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx +++ b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx @@ -252,22 +252,20 @@ function CmppConnectionModal({
AppID{app.appId}
连接状态{app.cmppStatus === 'connected' ? '在线' : app.cmppStatus === 'disconnected' ? '离线' : '未开通'}
- {record.id} }, - { key: 'state', title: '状态', width: '130px', render: (record: CmppConnection) => {connectionStateMeta[record.state].label} }, - { key: 'bindType', title: '绑定类型', width: '120px', render: (record: CmppConnection) => record.bindType }, - { key: 'clientIp', title: '客户端IP', width: '170px', render: (record: CmppConnection) => record.clientIp }, - { key: 'sourceAddr', title: '企业代码', width: '120px', render: (record: CmppConnection) => record.sourceAddr }, - { key: 'establishedAt', title: '连接建立时间', width: '180px', render: (record: CmppConnection) => record.establishedAt }, - { key: 'lastHeartbeatAt', title: '上次心跳', width: '180px', render: (record: CmppConnection) => record.lastHeartbeatAt }, - { key: 'lastSubmitAt', title: '上次提交', width: '180px', render: (record: CmppConnection) => record.lastSubmitAt }, - { key: 'pendingWindow', title: '窗口占用', align: 'right', width: '120px', render: (record: CmppConnection) => record.pendingWindow }, - ]} - data={activeConnectionItems} - emptyText="当前暂无已连接的 CMPP 会话" - rowKey="id" - /> + {activeConnectionItems.length ?
{activeConnectionItems.map((record) => ( +
+
{record.id}{connectionStateMeta[record.state].label}
+
+
绑定类型{record.bindType}
+
客户端 IP{record.clientIp}
+
企业代码{record.sourceAddr}
+
窗口占用{record.pendingWindow}
+
连接建立时间{record.establishedAt}
+
上次心跳{record.lastHeartbeatAt}
+
上次提交{record.lastSubmitAt}
+
+
+ ))}
:
当前暂无已连接的 CMPP 会话
} ); @@ -461,8 +459,8 @@ export function AdminEnterpriseApplicationsPage() { value={status} />
- - + +
diff --git a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx index 54316c0..bb62f99 100644 --- a/src/apps/admin/AdminEnterpriseSignaturesPage.tsx +++ b/src/apps/admin/AdminEnterpriseSignaturesPage.tsx @@ -509,7 +509,7 @@ function DrainageReportModal({ item, onClose, signature }: { item: DrainageInfo; 关闭} onClose={onClose} open size="xl" title="引流信息报备详情">
引流信息{item.siteName}
-
引流信息{item.url}
+
引流url或号码{item.url}
移动
联通
电信
@@ -717,8 +717,7 @@ export function AdminEnterpriseSignaturesPage() { {visibleDrainageLinks.length ? (
- 引流信息 - URL + 引流url或号码 审核状态 移动 联通 @@ -729,7 +728,6 @@ export function AdminEnterpriseSignaturesPage() { const summary = signature.drainageCarrierReportSummary?.[item.id]; return (
- {item.siteName} {item.url} diff --git a/src/apps/admin/AdminPhoneSegmentsPage.tsx b/src/apps/admin/AdminPhoneSegmentsPage.tsx index 1bb450a..0749f5b 100644 --- a/src/apps/admin/AdminPhoneSegmentsPage.tsx +++ b/src/apps/admin/AdminPhoneSegmentsPage.tsx @@ -24,6 +24,12 @@ const carrierTone: Record = 中国电信: 'warning', }; +const ruleCarrierMeta: Record = { + mobile: { label: '中国移动', tone: 'success' }, + unicom: { label: '中国联通', tone: 'info' }, + telecom: { label: '中国电信', tone: 'warning' }, +}; + type PhoneSegmentSummaryProps = { icon: ReactNode; label: string; @@ -69,6 +75,7 @@ export function AdminPhoneSegmentsPage() { const [rulePage, setRulePage] = useState(1); const [reloadKey, setReloadKey] = useState(0); const [deleteTarget, setDeleteTarget] = useState(null); + const [ruleDeleteTarget, setRuleDeleteTarget] = useState(null); useEffect(() => { let cancelled = false; @@ -146,6 +153,16 @@ export function AdminPhoneSegmentsPage() { .catch((failure: Error) => setError(failure.message || '手机号段删除失败')); } + function deleteCarrierRule() { + if (!ruleDeleteTarget) return; + adminApi.deletePhoneCarrierRule(ruleDeleteTarget.id) + .then(() => { + setRuleDeleteTarget(null); + setReloadKey((current) => current + 1); + }) + .catch((failure: Error) => setError(failure.message || '运营商区分规则删除失败')); + } + const columns = useMemo>>(() => [ { key: 'segment', title: '手机号段(前7位)', width: '190px', render: (record) => {record.prefix} }, { key: 'carrier', title: '运营商', width: '130px', render: (record) => record.carrier ? {record.carrier} : '-' }, @@ -156,10 +173,11 @@ export function AdminPhoneSegmentsPage() { ], []); const ruleColumns = useMemo>>(() => [ - { key: 'carrier', title: '运营商', width: '140px', render: (record) => record.carrier ?? '-' }, + { key: 'carrier', title: '运营商', width: '140px', render: (record) => { const meta = ruleCarrierMeta[record.carrier ?? '']; return meta ? {meta.label} : record.carrier ?? '-'; } }, { key: 'pattern', title: '号码前缀正则', render: (record) => {record.pattern} }, { key: 'priority', title: '优先级', width: '120px', render: (record) => record.priority ?? 100 }, { key: 'remark', title: '备注', render: (record) => record.remark ?? '-' }, + { key: 'actions', title: '操作', width: '90px', align: 'right', render: (record) => }, ], []); const queryPanel = ( @@ -304,6 +322,16 @@ export function AdminPhoneSegmentsPage() {

确认删除手机号段“{deleteTarget.prefix}”吗?删除后号码归属识别将不再使用该记录。

) : null} + {ruleDeleteTarget ? ( + } + onClose={() => setRuleDeleteTarget(null)} + open + title="删除运营商区分规则" + > +

确认删除规则“{ruleDeleteTarget.pattern}”吗?删除后运营商识别将不再使用该规则。

+
+ ) : null} ); } diff --git a/src/apps/admin/AdminProfitReportsPage.tsx b/src/apps/admin/AdminProfitReportsPage.tsx index 399be1b..2564ad5 100644 --- a/src/apps/admin/AdminProfitReportsPage.tsx +++ b/src/apps/admin/AdminProfitReportsPage.tsx @@ -74,12 +74,12 @@ export function AdminProfitReportsPage() {
- + - {error ? - : loading ? - : rows.length === 0 ? - : rows.map((row) => )} + {error ? + : loading ? + : rows.length === 0 ? + : rows.map((row) => )}
发送日期{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}日发送条数成功条数消费金额成本金额利润利润率生成时间
发送日期{dimensionType === 'application' ? '企业 / 企业应用' : '通道'}发送成功失败净消费返还成本利润利润率生成时间
{error}
正在加载真实利润数据...
暂无已生成的利润报表
{row.reportDate.slice(0, 10)}{row.dimensionName}{row.tenantName ?
{row.tenantName}
: null}
{row.sentUnits.toLocaleString('zh-CN')}{row.successUnits.toLocaleString('zh-CN')}¥{formatCents(row.revenueCents)}¥{formatCents(row.costCents)}¥{formatCents(row.profitCents)}{(row.profitRateBps / 100).toFixed(2)}%{formatDateTime(row.generatedAt)}
{error}
正在加载真实利润数据...
暂无已生成的利润报表
{row.reportDate.slice(0, 10)}{row.dimensionName}{row.tenantName ?
{row.tenantName}
: null}
{row.sentUnits.toLocaleString('zh-CN')}{row.successUnits.toLocaleString('zh-CN')}{row.failedUnits.toLocaleString('zh-CN')}¥{formatCents(row.revenueCents)}¥{formatCents(row.refundCents)}¥{formatCents(row.costCents)}¥{formatCents(row.profitCents)}{(row.profitRateBps / 100).toFixed(2)}%{formatDateTime(row.generatedAt)}
diff --git a/src/apps/admin/AdminQualityReportsPage.tsx b/src/apps/admin/AdminQualityReportsPage.tsx index c9c2a99..d05a3e8 100644 --- a/src/apps/admin/AdminQualityReportsPage.tsx +++ b/src/apps/admin/AdminQualityReportsPage.tsx @@ -81,12 +81,12 @@ export function AdminQualityReportsPage() {
- + - {error ? - : loading ? - : rows.length === 0 ? - : rows.map((row) => )} + {error ? + : loading ? + : rows.length === 0 ? + : rows.map((row) => )}
发送日期{dimensionLabels[dimension]}发送条数成功条数成功率平均到达时长生成时间
发送日期{dimensionLabels[dimension]}发送条数成功条数失败条数成功率平均到达时长生成时间
{error}
正在加载真实发送质量数据...
暂无已生成的发送质量报表
{row.reportDate.slice(0, 10)}{row.dimensionName}{row.tenantName ?
{row.tenantName}
: null}
{row.sentUnits.toLocaleString('zh-CN')}{row.successUnits.toLocaleString('zh-CN')}{(row.successRateBps / 100).toFixed(2)}%{formatDuration(row.avgArrivalMs)}{formatDateTime(row.generatedAt)}
{error}
正在加载真实发送质量数据...
暂无已生成的发送质量报表
{row.reportDate.slice(0, 10)}{row.dimensionName}{row.tenantName ?
{row.tenantName}
: null}
{row.sentUnits.toLocaleString('zh-CN')}{row.successUnits.toLocaleString('zh-CN')}{row.failedUnits.toLocaleString('zh-CN')}{(row.successRateBps / 100).toFixed(2)}%{formatDuration(row.avgArrivalMs)}{formatDateTime(row.generatedAt)}
diff --git a/src/apps/admin/AdminReconciliationReportsPage.tsx b/src/apps/admin/AdminReconciliationReportsPage.tsx index 90a71d4..981d371 100644 --- a/src/apps/admin/AdminReconciliationReportsPage.tsx +++ b/src/apps/admin/AdminReconciliationReportsPage.tsx @@ -80,12 +80,12 @@ export function AdminReconciliationReportsPage() {
- + - {error ? - : loading ? - : rows.length === 0 ? - : rows.map((row) => )} + {error ? + : loading ? + : rows.length === 0 ? + : rows.map((row) => )}
发送日期企业企业应用日发送条数成功条数生成时间
发送日期企业企业应用发送条数成功条数失败条数生成时间
{error}
正在加载真实对账数据...
暂无已生成的对账单
{row.reportDate.slice(0, 10)}{row.tenantName}{row.applicationName}{row.sentUnits.toLocaleString('zh-CN')}{row.successUnits.toLocaleString('zh-CN')}{formatDateTime(row.generatedAt)}
{error}
正在加载真实对账数据...
暂无已生成的对账单
{row.reportDate.slice(0, 10)}{row.tenantName}{row.applicationName}{row.sentUnits.toLocaleString('zh-CN')}{row.successUnits.toLocaleString('zh-CN')}{row.failedUnits.toLocaleString('zh-CN')}{formatDateTime(row.generatedAt)}
diff --git a/src/apps/admin/AdminSmsApplicationFormPage.tsx b/src/apps/admin/AdminSmsApplicationFormPage.tsx index 74c5326..67c5471 100644 --- a/src/apps/admin/AdminSmsApplicationFormPage.tsx +++ b/src/apps/admin/AdminSmsApplicationFormPage.tsx @@ -54,10 +54,10 @@ export function AdminSmsApplicationFormPage() { const [downstreamUplinkRetryEnabled, setDownstreamUplinkRetryEnabled] = useState(true); const [ipAddress, setIpAddress] = useState(''); const [httpConfig, setHttpConfig] = useState({ - enabled: false, sendEnabled: false, messageQueryEnabled: false, receiptWebhookEnabled: false, - uplinkWebhookEnabled: false, uplinkQueryEnabled: false, credentialSelfServiceEnabled: false, + enabled: false, sendEnabled: true, messageQueryEnabled: true, receiptWebhookEnabled: true, + uplinkWebhookEnabled: true, uplinkQueryEnabled: true, credentialSelfServiceEnabled: true, qpsLimit: 10, timestampToleranceSeconds: 300, maxCredentialCount: 2, uplinkRetentionDays: 90, - maxQueryRangeDays: 31, maxPageSize: 100, receiptDeliveryMode: 'cmpp', uplinkDeliveryMode: 'cmpp', + maxQueryRangeDays: 31, maxPageSize: 100, receiptDeliveryMode: 'http', uplinkDeliveryMode: 'http', webhookRetryEnabled: true, webhookMaxAttempts: 7, webhookTimeoutSeconds: 10, requireHttps: true, allowClientManualRetry: true, allowClientTest: true, }); @@ -354,7 +354,18 @@ export function AdminSmsApplicationFormPage() {

HTTP 接口配置

管理接口能力、机器鉴权、查询限制与 Webhook 投递策略。

- +
{httpConfig.enabled ? (
diff --git a/src/apps/admin/AdminSmsAuditPage.tsx b/src/apps/admin/AdminSmsAuditPage.tsx index 895740b..27b83a5 100644 --- a/src/apps/admin/AdminSmsAuditPage.tsx +++ b/src/apps/admin/AdminSmsAuditPage.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; -import { CalendarDays, Check, Search, X } from 'lucide-react'; +import { CalendarDays, Check, Info, Search, X } from 'lucide-react'; import { adminApi, type RiskReviewTask } from '@/api/adminApi'; import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui'; +import { formatDateTime } from '@/utils/dateTime'; const statusLabel: Record = { pending_review: '待审核', @@ -29,6 +30,11 @@ export function AdminSmsAuditPage() { const [rejectTarget, setRejectTarget] = useState(null); const [rejectReason, setRejectReason] = useState(''); const [selectedIds, setSelectedIds] = useState([]); + const [detailTarget, setDetailTarget] = useState(null); + + function refreshAuditCount() { + window.dispatchEvent(new Event('cmpp-audit-count-refresh')); + } function loadData() { adminApi.listRiskReviewTasks({ status: status === 'all' ? undefined : status }) @@ -57,6 +63,7 @@ export function AdminSmsAuditPage() { await adminApi.approveRiskReviewTask(record.id, '运营审核通过'); setApproveTarget(null); loadData(); + refreshAuditCount(); } async function approveBatch() { @@ -64,6 +71,7 @@ export function AdminSmsAuditPage() { setApproveTarget(null); setSelectedIds([]); loadData(); + refreshAuditCount(); } async function rejectRecord() { @@ -72,6 +80,7 @@ export function AdminSmsAuditPage() { setRejectTarget(null); setRejectReason(''); loadData(); + refreshAuditCount(); } async function rejectBatch() { @@ -81,6 +90,7 @@ export function AdminSmsAuditPage() { setRejectReason(''); setSelectedIds([]); loadData(); + refreshAuditCount(); } const selectableIds = filteredRecords.filter((item) => item.status === 'pending_review').map((item) => item.id); @@ -96,7 +106,7 @@ export function AdminSmsAuditPage() { { key: 'sourceType', title: '审核来源', width: '180px', render: (record) => {sourceLabel(record.sourceType)} }, { key: 'content', title: '短信内容', render: (record) => {record.content} }, { key: 'phoneTotal', title: '聚合号码数', width: '140px', render: (record) => (record._count?.messageRecords ?? record.phoneTotal).toLocaleString('zh-CN') }, - { key: 'createdAt', title: '提交时间', width: '190px', render: (record) => record.createdAt }, + { key: 'createdAt', title: '提交时间', width: '190px', render: (record) => formatDateTime(record.createdAt) }, { key: 'reason', title: '审核原因', render: (record) => record.reviewReason ?? record.rejectReason ?? record.riskHits?.map((item) => item.reason).join(';') ?? '-' }, { key: 'status', @@ -107,14 +117,17 @@ export function AdminSmsAuditPage() { { key: 'actions', title: '操作', - width: '160px', + width: '230px', align: 'right', - render: (record) => record.status === 'pending_review' ? ( + render: (record) => (
- - + + {record.status === 'pending_review' ? <> + + + : null}
- ) : null, + ), }, ]; @@ -168,6 +181,18 @@ export function AdminSmsAuditPage() {

{approveTarget === 'batch' ? `确认通过已选择的 ${selectedIds.length} 条待审核任务?` : '确认通过该短信审核任务?'}

+ {detailTarget ? setDetailTarget(null)}>关闭} onClose={() => setDetailTarget(null)} open title="审核任务更多信息"> +
+
任务编号{detailTarget.taskNo}
+
提交时间{formatDateTime(detailTarget.createdAt)}
+
审核人{detailTarget.reviewedBy?.displayName || detailTarget.reviewedBy?.username || '-'}
+
审核时间{formatDateTime(detailTarget.reviewedAt)}
+
审核原因{detailTarget.reviewReason || '-'}
+
驳回原因{detailTarget.rejectReason || '-'}
+
风控命中{detailTarget.riskHits?.map((item) => item.reason).join(';') || '-'}
+
+
: null} + diff --git a/src/apps/admin/AdminSmsRecordsPage.tsx b/src/apps/admin/AdminSmsRecordsPage.tsx index 4bc2333..877ea7c 100644 --- a/src/apps/admin/AdminSmsRecordsPage.tsx +++ b/src/apps/admin/AdminSmsRecordsPage.tsx @@ -177,6 +177,7 @@ function SendDetailModal({ onClose: () => void; }) { const routeRows = buildRouteRows(record); + const sentAccessNumber = `${record.channel?.srcId ?? ''}${record.applicationExtension ?? ''}`; return ( 关闭} @@ -207,6 +208,14 @@ function SendDetailModal({ 号码归属 {record.province ?? '-'} / {getCarrierLabel(record.carrier)}
+
+ 收到的接入号 + {record.clientSrcId || '-'} +
+
+ 发送的接入号 + {sentAccessNumber || '-'} +

短信内容

@@ -292,8 +301,19 @@ export function AdminSmsRecordsPage() { const [error, setError] = useState(''); const [page, setPage] = useState(1); - function loadData() { - adminApi.listOperationMessages({ + type MessageFilters = { + tenantId?: string; + applicationId?: string; + phoneNumber?: string; + contentKeyword?: string; + channelKeyword?: string; + queuedAtFrom?: string; + queuedAtTo?: string; + status?: string; + }; + + function currentFilters(): MessageFilters { + return { tenantId: enterprise === 'all' ? undefined : enterprise, applicationId: application === 'all' ? undefined : application, phoneNumber: phoneKeyword || undefined, @@ -302,9 +322,14 @@ export function AdminSmsRecordsPage() { queuedAtFrom: dateRange.start, queuedAtTo: dateRange.end, status: status === 'all' ? undefined : status, - }) + }; + } + + function loadData(filters = currentFilters()) { + adminApi.listOperationMessages(filters) .then((items) => { setRecords(items); + setSelectedRecord((current) => current ? items.find((item) => item.id === current.id) ?? null : null); setPage(1); setError(''); }) @@ -373,6 +398,7 @@ export function AdminSmsRecordsPage() { setContentKeyword(''); setChannelKeyword(''); setStatus('all'); + loadData({}); } return ( @@ -412,7 +438,7 @@ export function AdminSmsRecordsPage() { value={status} />
- +
diff --git a/src/apps/client/ClientTemplatesPage.tsx b/src/apps/client/ClientTemplatesPage.tsx index dbac675..dbdde06 100644 --- a/src/apps/client/ClientTemplatesPage.tsx +++ b/src/apps/client/ClientTemplatesPage.tsx @@ -217,7 +217,7 @@ export function ClientTemplatesPage() { function loadData() { setLoading(true); - Promise.all([clientApi.listApplications(), clientApi.listTemplates(), clientApi.listSignatures()]) + Promise.all([clientApi.listApplications(), clientApi.listTemplates({ includeHistory: true }), clientApi.listSignatures()]) .then(([applicationItems, templateItems, signatureItems]) => { setApplications(applicationItems.filter((item) => item.status === 'active')); setTemplates(templateItems.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled')); diff --git a/src/apps/client/ClientUsersPage.css b/src/apps/client/ClientUsersPage.css new file mode 100644 index 0000000..7c09c17 --- /dev/null +++ b/src/apps/client/ClientUsersPage.css @@ -0,0 +1,22 @@ +@media (max-width: 780px) { + .client-users-table-card .client-user-actions { + display: grid; + gap: var(--space-2); + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + + .client-users-table-card .client-user-actions .ui-button { + justify-content: center; + min-height: 44px; + min-width: 0; + padding-inline: var(--space-2); + width: 100%; + } +} + +@media (max-width: 360px) { + .client-users-table-card .ui-table td[data-label] { + grid-template-columns: 76px minmax(0, 1fr); + } +} diff --git a/src/apps/client/ClientUsersPage.tsx b/src/apps/client/ClientUsersPage.tsx index e426a96..6185ad1 100644 --- a/src/apps/client/ClientUsersPage.tsx +++ b/src/apps/client/ClientUsersPage.tsx @@ -4,6 +4,7 @@ import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi'; import { formatDateTime } from '@/utils/dateTime'; import { readSession } from '@/api/session'; import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui'; +import './ClientUsersPage.css'; type UserForm = { displayName: string; @@ -141,7 +142,7 @@ export function ClientUsersPage() { title: '操作', width: '290px', render: (record) => ( -
+
@@ -165,7 +166,7 @@ export function ClientUsersPage() { setKeyword(event.target.value)} placeholder="搜索用户名、邮箱或手机号" prefix={} value={keyword} />
{error ?
{error}
: null} -
+
diff --git a/src/components/ui/FileActions.tsx b/src/components/ui/FileActions.tsx index 74dd6de..3d070ba 100644 --- a/src/components/ui/FileActions.tsx +++ b/src/components/ui/FileActions.tsx @@ -27,7 +27,7 @@ export function FileActions({ file }: FileActionsProps) { return ( event.stopPropagation()}> {isImageFile(file) ? ( - ) : null} diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 116a1ba..7333ea1 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -57,10 +57,13 @@ export function AdminLayout() { loadPendingAuditCount(); const timer = window.setInterval(loadPendingAuditCount, 30000); const onFocus = () => loadPendingAuditCount(); + const onAuditRefresh = () => loadPendingAuditCount(); window.addEventListener('focus', onFocus); + window.addEventListener('cmpp-audit-count-refresh', onAuditRefresh); return () => { window.clearInterval(timer); window.removeEventListener('focus', onFocus); + window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh); }; }, [loadPendingAuditCount, session?.portal]); diff --git a/src/styles/global.css b/src/styles/global.css index fc65c28..16eb2ab 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -2896,7 +2896,7 @@ h3 { align-items: center; display: grid; gap: var(--space-4); - grid-template-columns: minmax(96px, 0.9fr) minmax(180px, 1.5fr) minmax(80px, 0.7fr) repeat(3, minmax(64px, 0.65fr)) minmax(196px, auto); + grid-template-columns: minmax(180px, 1.5fr) minmax(80px, 0.7fr) repeat(3, minmax(64px, 0.65fr)) minmax(196px, auto); min-height: 58px; } @@ -3202,6 +3202,75 @@ h3 { .cmpp-connection-summary strong { color: var(--color-text-strong); font-size: var(--font-size-lg); + overflow-wrap: anywhere; +} + +.cmpp-connection-list { + display: grid; + gap: var(--space-4); +} + +.cmpp-connection-card { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + display: grid; + gap: var(--space-4); + min-width: 0; + padding: var(--space-4); +} + +.cmpp-connection-card__heading { + align-items: center; + display: flex; + gap: var(--space-3); + justify-content: space-between; + min-width: 0; +} + +.cmpp-connection-card__heading > strong { + min-width: 0; + overflow-wrap: anywhere; +} + +.cmpp-connection-card__grid { + display: grid; + gap: var(--space-4); + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.cmpp-connection-card__grid > div { + display: grid; + gap: var(--space-1); + min-width: 0; +} + +.cmpp-connection-card__grid span { + color: var(--color-text-muted); + font-size: var(--font-size-xs); +} + +.cmpp-connection-card__grid strong { + overflow-wrap: anywhere; +} + +@media (max-width: 900px) { + .cmpp-connection-summary, + .cmpp-connection-card__grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 520px) { + .cmpp-connection-summary, + .cmpp-connection-card__grid { + grid-template-columns: 1fr; + } +} + +@media (min-width: 781px) { + .mobile-nav-close { + display: none !important; + } } .cmpp-param-detail { @@ -5251,6 +5320,13 @@ h3 { text-align: center; } +.enterprise-upload-button { + min-width: 0; + overflow-wrap: anywhere; + padding: var(--space-3); + width: 176px; +} + .enterprise-upload-panel p { color: var(--color-text-muted); line-height: var(--line-height-base); @@ -9779,7 +9855,8 @@ h3 { margin-top: 6px; } -.file-action-link { +.file-action-link, +.file-action-preview.ui-button { align-items: center; background: #fff; border: 1px solid var(--border); @@ -9794,7 +9871,8 @@ h3 { text-decoration: none; } -.file-action-link:hover { +.file-action-link:hover, +.file-action-preview.ui-button:hover { border-color: var(--primary); color: var(--primary); } diff --git a/src/utils/fileUpload.ts b/src/utils/fileUpload.ts new file mode 100644 index 0000000..39afadf --- /dev/null +++ b/src/utils/fileUpload.ts @@ -0,0 +1,16 @@ +export const IMAGE_UPLOAD_MAX_BYTES = 2 * 1024 * 1024; +export const FILE_UPLOAD_MAX_BYTES = 10 * 1024 * 1024; + +const IMAGE_FILE_EXTENSION = /\.(?:avif|bmp|gif|heic|heif|jpe?g|png|svg|webp)$/i; + +export function isImageUpload(file: Pick) { + return file.type.toLowerCase().startsWith('image/') || IMAGE_FILE_EXTENSION.test(file.name); +} + +export function assertUploadFileSize(file: Pick) { + const image = isImageUpload(file); + const limit = image ? IMAGE_UPLOAD_MAX_BYTES : FILE_UPLOAD_MAX_BYTES; + if (file.size > limit) { + throw new Error(image ? '图片大小不能超过 2MB' : '文件大小不能超过 10MB'); + } +} diff --git a/src/utils/interfaceParams.ts b/src/utils/interfaceParams.ts index 8a06bc6..8bdf273 100644 --- a/src/utils/interfaceParams.ts +++ b/src/utils/interfaceParams.ts @@ -8,6 +8,13 @@ const capabilityLabels = [ ['uplinkWebhookEnabled', '上行回调'], ] as const; +const deliveryModeLabels: Record = { + cmpp: 'CMPP 长连接', + http: 'HTTP Webhook', + both: 'CMPP 长连接 + HTTP Webhook', + none: '不投递', +}; + export function formatHttpApiParams(response: HttpApiConfigResponse, origin: string) { const config = response.config; const baseUrl = `${origin.replace(/\/$/, '')}/api/openapi/v1`; @@ -20,7 +27,7 @@ export function formatHttpApiParams(response: HttpApiConfigResponse, origin: str `QPS限制: ${config?.qpsLimit ?? '-'}`, `签名时间容差: ${config?.timestampToleranceSeconds ?? '-'}秒`, `HTTP IP白名单: ${response.ipAllowlist.join('、') || '未限制'}`, - `回执投递方式: ${config?.receiptDeliveryMode ?? '-'}`, - `上行投递方式: ${config?.uplinkDeliveryMode ?? '-'}`, + `回执投递方式: ${config?.receiptDeliveryMode ? deliveryModeLabels[config.receiptDeliveryMode] ?? config.receiptDeliveryMode : '-'}`, + `上行投递方式: ${config?.uplinkDeliveryMode ? deliveryModeLabels[config.uplinkDeliveryMode] ?? config.uplinkDeliveryMode : '-'}`, ].join('\n'); }