feat: harden CMPP delivery and platform workflows
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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 } } },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' }) }));
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } } },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}】` : '';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user