feat: harden CMPP delivery and platform workflows

This commit is contained in:
hectorzhao
2026-07-20 18:07:29 +08:00
parent 80fb5a8f53
commit f02c33cbb7
61 changed files with 1834 additions and 281 deletions
@@ -0,0 +1,2 @@
ALTER TABLE "SmsMessageRecord"
ADD COLUMN "cmppSubmitGroupMessageId" TEXT;
@@ -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");
@@ -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;
@@ -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");
+12
View File
@@ -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 {
+3 -2
View File
@@ -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('*');
}
}
@@ -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();
});
});
@@ -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);
}
@@ -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 });
+28 -9
View File
@@ -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<T>(operation: () => Promise<T>, 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 } } },
+1 -1
View File
@@ -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,
+21
View File
@@ -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',
+14 -1
View File
@@ -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;
+5 -2
View File
@@ -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 });
}
+35
View File
@@ -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;
}
+7 -7
View File
@@ -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' }) }));
});
+2 -4
View File
@@ -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,
@@ -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<string, string>) {
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 12, parts: 13 } }))
analyzeImport(@UploadedFile() file: UploadedWorkbook, @Body() body: Record<string, string>, @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);
}
}
@@ -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<string, unknown> }) => 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);
@@ -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 [];
+35 -10
View File
@@ -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,
@@ -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;
}
+8 -4
View File
@@ -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 } } },
});
}
+179 -7
View File
@@ -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({
+176 -21
View File
@@ -29,6 +29,8 @@ export interface CreateBatchTaskDto {
clientMessageId?: string;
}
export type CreateHttpBatchTaskDto = Omit<CreateBatchTaskDto, 'templateId' | 'variables' | 'sourceType'>;
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<SendJob, unknown, 'send-message'> {
if (!this.sendQueue) {
this.sendQueue = new Queue<SendJob, unknown, 'send-message'>(SEND_QUEUE, { connection: bullmqConnection() });
@@ -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);
}
}
+29 -1
View File
@@ -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();
});
});
+52 -8
View File
@@ -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}` : '';
+9 -1
View File
@@ -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);
+9 -1
View File
@@ -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 },
+32
View File
@@ -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<Record<string, unknown>>;
@ApiPropertyOptional({ type: Object, nullable: true }) tenant?: Record<string, unknown> | 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<Record<string, unknown>>;
}
+25 -21
View File
@@ -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);
}
+104 -2
View File
@@ -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<string, { id: string; code: string; name: string; scope: string }>();
@@ -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' }),
});
});
});
+86 -26
View File
@@ -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<UserRoleCode, { name: string; scope: string }> = {
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<T>(operation: () => Promise<T>): Promise<T> {
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<string, unknown>) {
return this.prisma.operationLog.create({
data: {
@@ -353,6 +408,11 @@ export class UsersService {
}
}
function publicUser<T extends Record<string, any>>(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;
+56
View File
@@ -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;
});
+82
View File
@@ -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;
});