fix: complete first version issue remediation

This commit is contained in:
hectorzhao
2026-07-11 10:14:37 +08:00
parent 709ac97764
commit 208a6c23f8
73 changed files with 1549 additions and 245 deletions
@@ -0,0 +1 @@
ALTER TABLE "User" ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0;
+1
View File
@@ -75,6 +75,7 @@ model User {
displayName String
passwordHash String
status String @default("active")
sessionVersion Int @default(0)
failedLoginCount Int @default(0)
lockedUntil DateTime?
lastLoginAt DateTime?
+9 -2
View File
@@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuditModule } from './audit/audit.module';
import { AuthModule } from './auth/auth.module';
import { SessionValidationMiddleware } from './auth/session-validation.middleware';
import { RequestContextMiddleware } from './common/request-context.middleware';
import { BillingModule } from './billing/billing.module';
import { ChannelsModule } from './channels/channels.module';
import { CertificationModule } from './certification/certification.module';
@@ -38,5 +40,10 @@ import { UsersModule } from './users/users.module';
OperationsModule,
],
controllers: [HealthController],
providers: [RequestContextMiddleware, SessionValidationMiddleware],
})
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestContextMiddleware, SessionValidationMiddleware).forRoutes('*');
}
}
+12 -2
View File
@@ -1,11 +1,13 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { Body, Controller, Get, Post, UnauthorizedException } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from './current-session-user.decorator';
import { AuthService, LoginDto } from './auth.service';
import { UsersService } from '../users/users.service';
@ApiTags('auth')
@Controller()
export class AuthController {
constructor(private readonly auth: AuthService) {}
constructor(private readonly auth: AuthService, private readonly users: UsersService) {}
@Get('admin/auth/captcha')
adminCaptcha() {
@@ -26,4 +28,12 @@ export class AuthController {
clientLogin(@Body() body: LoginDto) {
return this.auth.login(body, 'client');
}
@Post('auth/password')
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
if (!userId) {
throw new UnauthorizedException('登录会话无效,请重新登录');
}
return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? '');
}
}
+1 -1
View File
@@ -40,7 +40,7 @@ describe('AuthService', () => {
it('allows platform admins to login admin portal', async () => {
const users = createUsersMock('platform_admin');
const service = new AuthService(users as never);
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin' }));
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin', accessToken: 'dev-token:user-1:0' }));
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
});
+1 -1
View File
@@ -77,7 +77,7 @@ export class AuthService {
anonymousFailures.delete(login);
return {
accessToken: `dev-token-${user.id}`,
accessToken: `dev-token:${user.id}:${user.sessionVersion ?? 0}`,
tokenType: 'Bearer',
portal,
user: {
@@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { SessionRequest } from './session-validation.middleware';
export const CurrentSessionUserId = createParamDecorator((_: unknown, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest<SessionRequest>();
return request.sessionUserId;
});
@@ -0,0 +1,27 @@
import { UnauthorizedException } from '@nestjs/common';
import { SessionValidationMiddleware, type SessionRequest } from './session-validation.middleware';
function request(authorization?: string): SessionRequest {
return { header: jest.fn().mockReturnValue(authorization) };
}
describe('SessionValidationMiddleware', () => {
it('accepts the current user session version and exposes the session user id', async () => {
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const middleware = new SessionValidationMiddleware(prisma as never);
const currentRequest = request('Bearer dev-token:user-1:3');
const next = jest.fn();
await middleware.use(currentRequest, {} as never, next);
expect(next).toHaveBeenCalledTimes(1);
expect(currentRequest.sessionUserId).toBe('user-1');
});
it('rejects an old session token after the user session version changes', async () => {
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 4 }) } };
const middleware = new SessionValidationMiddleware(prisma as never);
await expect(middleware.use(request('Bearer dev-token:user-1:3'), {} as never, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
});
});
@@ -0,0 +1,33 @@
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
export type SessionRequest = {
header(name: string): string | undefined;
sessionUserId?: string;
};
@Injectable()
export class SessionValidationMiddleware implements NestMiddleware {
constructor(private readonly prisma: PrismaService) {}
async use(request: SessionRequest, _: unknown, next: () => void) {
const authorization = request.header('authorization');
if (!authorization) {
next();
return;
}
const match = /^Bearer dev-token:([^:]+):(\d+)$/.exec(authorization.trim());
if (!match) {
throw new UnauthorizedException('登录会话无效,请重新登录');
}
const user = await this.prisma.user.findUnique({
where: { id: match[1] },
select: { id: true, status: true, deletedAt: true, sessionVersion: true },
});
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== Number(match[2])) {
throw new UnauthorizedException('登录会话已失效,请重新登录');
}
request.sessionUserId = user.id;
next();
}
}
+25
View File
@@ -143,6 +143,31 @@ describe('BillingService', () => {
});
});
it('returns the historical balance after each manual recharge', async () => {
const prisma = createPrismaMock();
prisma.rechargeOrder.findMany.mockResolvedValue([
{ id: 'order-1', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: 2000 },
{ id: 'order-2', tenantId: 'tenant-1', payMethod: 'manual_topup', amountCents: -300 },
]);
prisma.accountTransaction.findMany.mockResolvedValue([
{ relatedId: 'order-1', balanceAfter: 3000 },
{ relatedId: 'order-2', balanceAfter: 2700 },
]);
const service = new BillingService(prisma as never);
await expect(service.listManualRechargeRecords()).resolves.toEqual([
expect.objectContaining({ id: 'order-1', balanceAfterCents: 3000 }),
expect.objectContaining({ id: 'order-2', balanceAfterCents: 2700 }),
]);
expect(prisma.accountTransaction.findMany).toHaveBeenCalledWith({
where: {
relatedType: 'recharge_order',
relatedId: { in: ['order-1', 'order-2'] },
},
select: { relatedId: true, balanceAfter: true },
});
});
it('allows negative manual recharge amounts for balance correction', async () => {
const prisma = createPrismaMock();
const service = new BillingService(prisma as never);
+20 -2
View File
@@ -133,8 +133,8 @@ export class BillingService {
});
}
listManualRechargeRecords(tenantId?: string) {
return this.prisma.rechargeOrder.findMany({
async listManualRechargeRecords(tenantId?: string) {
const orders = await this.prisma.rechargeOrder.findMany({
where: {
tenantId,
payMethod: 'manual_topup',
@@ -143,6 +143,24 @@ export class BillingService {
orderBy: { createdAt: 'desc' },
take: 100,
});
const orderIds = orders.map((order) => order.id);
if (orderIds.length === 0) {
return orders;
}
const transactions = await this.prisma.accountTransaction.findMany({
where: {
relatedType: 'recharge_order',
relatedId: { in: orderIds },
},
select: { relatedId: true, balanceAfter: true },
});
const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, transaction.balanceAfter]));
return orders.map((order) => ({
...order,
balanceAfterCents: balanceAfterByOrderId.get(order.id) ?? null,
}));
}
async createRechargeOrder(data: CreateRechargeOrderDto) {
+25 -3
View File
@@ -183,6 +183,8 @@ describe('ChannelsService', () => {
srcId: '10690000',
desiredConnections: 2,
windowSize: 32,
rateLimitPerSecond: 750,
config: { extensionDigits: 4 },
});
await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitMinutes: 750 });
await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' });
@@ -191,10 +193,10 @@ describe('ChannelsService', () => {
data: expect.objectContaining({
protocol: 'CMPP',
cmppVersion: '2.0',
rateLimitPerSecond: 100,
rateLimitPerSecond: 750,
sendRegion: '全国',
status: 'active',
config: expect.objectContaining({ desiredConnections: 2, windowSize: 32 }),
config: expect.objectContaining({ desiredConnections: 2, windowSize: 32, extensionDigits: 4 }),
}),
});
expect(prisma.cmppConnectionState.create).toHaveBeenCalledWith({
@@ -263,6 +265,23 @@ describe('ChannelsService', () => {
})).rejects.toThrow('cmppVersion must be 2.0 or 3.0');
});
it('rejects invalid channel rate limits and extension digit counts', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
const channel = {
code: 'CMPP-CONFIG',
name: '配置校验通道',
gatewayHost: '127.0.0.1',
gatewayPort: 17890,
account: 'sp',
passwordCipher: 'secret',
srcId: '10690000',
};
await expect(service.createChannel({ ...channel, rateLimitPerSecond: 2001 })).rejects.toThrow('rateLimitPerSecond must be between 1 and 2000');
await expect(service.createChannel({ ...channel, config: { extensionDigits: 3 } })).rejects.toThrow('extensionDigits must be one of 0, 2, 4, or 6');
});
it('updates CMPP channel configuration without requiring password changes', async () => {
const prisma = createPrismaMock();
const service = new ChannelsService(prisma as never);
@@ -277,6 +296,8 @@ describe('ChannelsService', () => {
srcId: '10690001',
desiredConnections: 3,
windowSize: 64,
rateLimitPerSecond: 320,
config: { extensionDigits: 2 },
unitPrice: 4,
})).resolves.toEqual(expect.objectContaining({
id: 'channel-1',
@@ -292,7 +313,8 @@ describe('ChannelsService', () => {
gatewayPort: 27890,
carrier: 'all',
passwordCipher: undefined,
config: expect.objectContaining({ desiredConnections: 3, windowSize: 64 }),
rateLimitPerSecond: 320,
config: expect.objectContaining({ desiredConnections: 3, windowSize: 64, extensionDigits: 2 }),
}),
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
+41 -7
View File
@@ -215,7 +215,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
}
const cmppVersion = normalizeCmppVersion(data.cmppVersion);
const config = normalizeChannelRuntimeConfig(data.config, data.desiredConnections, data.windowSize);
const config = normalizeChannelRuntimeConfig(undefined, data.config, data.desiredConnections, data.windowSize);
const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond);
const channel = await this.prisma.smsChannel.create({
data: {
code: data.code,
@@ -230,7 +231,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
passwordCipher: data.passwordCipher,
srcId: data.srcId,
cmppVersion,
rateLimitPerSecond: data.rateLimitPerSecond ?? 100,
rateLimitPerSecond,
unitPrice: data.unitPrice ?? 0,
status: data.status ?? 'active',
config: config as Prisma.InputJsonValue,
@@ -253,8 +254,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
}
const cmppVersion = data.cmppVersion === undefined ? undefined : normalizeCmppVersion(data.cmppVersion);
const config = data.config !== undefined || data.desiredConnections !== undefined || data.windowSize !== undefined
? normalizeChannelRuntimeConfig(channel.config, data.desiredConnections, data.windowSize)
? normalizeChannelRuntimeConfig(channel.config, data.config, data.desiredConnections, data.windowSize)
: undefined;
const rateLimitPerSecond = data.rateLimitPerSecond === undefined
? undefined
: normalizeChannelRateLimit(data.rateLimitPerSecond);
const updated = await this.prisma.smsChannel.update({
where: { id: channelId },
data: {
@@ -270,7 +274,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
passwordCipher: data.passwordCipher,
srcId: data.srcId,
cmppVersion,
rateLimitPerSecond: data.rateLimitPerSecond,
rateLimitPerSecond,
unitPrice: data.unitPrice,
status: data.status,
config: config as Prisma.InputJsonValue | undefined,
@@ -1287,6 +1291,7 @@ function buildChannelTestSubmitCommand({
cmpp: {
serviceId: getStringConfigValue(channel.config, 'serviceId', 'SMS'),
srcId,
extensionDigits: normalizeExtensionDigits(getConfigValue(channel.config, 'extensionDigits')),
registeredDelivery: 1,
msgFmt: 8,
},
@@ -1380,15 +1385,44 @@ function getDesiredConnections(config?: Prisma.JsonValue | null) {
return 1;
}
function normalizeChannelRuntimeConfig(config?: Prisma.JsonValue | Record<string, unknown> | null, desiredConnections?: number, windowSize?: number) {
const base = config && typeof config === 'object' && !Array.isArray(config)
? { ...(config as Record<string, unknown>) }
function normalizeChannelRuntimeConfig(
existingConfig?: Prisma.JsonValue | Record<string, unknown> | null,
incomingConfig?: Record<string, unknown> | null,
desiredConnections?: number,
windowSize?: number,
) {
const existing = existingConfig && typeof existingConfig === 'object' && !Array.isArray(existingConfig)
? existingConfig as Record<string, unknown>
: {};
const incoming = incomingConfig && typeof incomingConfig === 'object' && !Array.isArray(incomingConfig)
? incomingConfig
: {};
const base = { ...existing, ...incoming };
base.desiredConnections = getPositiveRuntimeInteger(desiredConnections ?? base.desiredConnections, 1, 'desiredConnections');
base.windowSize = getPositiveRuntimeInteger(windowSize ?? base.windowSize, 16, 'windowSize');
base.extensionDigits = normalizeExtensionDigits(base.extensionDigits);
return base;
}
function normalizeChannelRateLimit(value: unknown) {
const normalized = getPositiveRuntimeInteger(value, 100, 'rateLimitPerSecond');
if (normalized > 2000) {
throw new BadRequestException('rateLimitPerSecond must be between 1 and 2000');
}
return normalized;
}
function normalizeExtensionDigits(value: unknown) {
if (value === undefined || value === null || value === '') {
return 0;
}
const normalized = Number(value);
if (![0, 2, 4, 6].includes(normalized)) {
throw new BadRequestException('extensionDigits must be one of 0, 2, 4, or 6');
}
return normalized;
}
function getPositiveRuntimeInteger(value: unknown, fallback: number, fieldName: string) {
if (value === undefined || value === null || value === '') {
return fallback;
@@ -0,0 +1,14 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { requestContext } from './request-context';
type RequestLike = { headers: Record<string, string | string[] | undefined>; socket?: { remoteAddress?: string } };
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
use(request: RequestLike, _response: unknown, next: () => void) {
const forwarded = request.headers['x-forwarded-for'];
const firstForwarded = Array.isArray(forwarded) ? forwarded[0] : forwarded?.split(',')[0];
const ipAddress = (firstForwarded ?? request.socket?.remoteAddress)?.trim().replace(/^::ffff:/, '');
requestContext.run({ ipAddress }, next);
}
}
+3
View File
@@ -0,0 +1,3 @@
import { AsyncLocalStorage } from 'node:async_hooks';
export const requestContext = new AsyncLocalStorage<{ ipAddress?: string }>();
@@ -30,8 +30,8 @@ export class DictionariesController {
}
@Get('phone-carrier-rules')
listPhoneCarrierRules() {
return this.dictionaries.listPhoneCarrierRules();
listPhoneCarrierRules(@Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.dictionaries.listPhoneCarrierRules({ keyword, page: Number(page) || undefined, pageSize: Number(pageSize) || undefined });
}
@Post('phone-carrier-rules')
@@ -5,6 +5,10 @@ function createPrismaMock() {
phoneSegment: {
findMany: jest.fn(),
},
phoneCarrierRule: {
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
sensitiveWord: {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'word-1', ...data })),
@@ -80,6 +84,17 @@ describe('DictionariesService', () => {
}));
});
it('paginates carrier rules with a real database count', async () => {
const prisma = createPrismaMock();
prisma.phoneCarrierRule.findMany.mockResolvedValue([{ id: 'rule-1', carrier: 'mobile', pattern: '^13' }]);
prisma.phoneCarrierRule.count.mockResolvedValue(26);
const service = new DictionariesService(prisma as never);
await expect(service.listPhoneCarrierRules({ keyword: '13', page: 2, pageSize: 25 })).resolves.toEqual(expect.objectContaining({ total: 26, page: 2, pageSize: 25 }));
expect(prisma.phoneCarrierRule.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 25, take: 25, where: { OR: expect.any(Array) } }));
expect(prisma.phoneCarrierRule.count).toHaveBeenCalledWith({ where: { OR: expect.any(Array) } });
});
it('creates and soft deletes blacklist and sensitive word entries with operation logs', async () => {
const prisma = createPrismaMock();
const service = new DictionariesService(prisma as never);
+21 -2
View File
@@ -23,6 +23,12 @@ export interface CreatePhoneCarrierRuleDto {
remark?: string;
}
export interface PageQuery {
keyword?: string;
page?: number;
pageSize?: number;
}
export interface CreateSensitiveWordDto {
word: string;
level?: string;
@@ -98,8 +104,21 @@ export class DictionariesService {
return this.prisma.phoneSegment.create({ data });
}
listPhoneCarrierRules() {
return this.prisma.phoneCarrierRule.findMany({ orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }], take: 200 });
async listPhoneCarrierRules(query: PageQuery = {}) {
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25)));
const where = query.keyword?.trim() ? {
OR: [
{ carrier: { contains: query.keyword.trim() } },
{ pattern: { contains: query.keyword.trim() } },
{ remark: { contains: query.keyword.trim() } },
],
} : undefined;
const [items, total] = await Promise.all([
this.prisma.phoneCarrierRule.findMany({ where, orderBy: [{ priority: 'asc' }, { createdAt: 'desc' }], skip: (page - 1) * pageSize, take: pageSize }),
this.prisma.phoneCarrierRule.count({ where }),
]);
return { items, total, page, pageSize };
}
createPhoneCarrierRule(data: CreatePhoneCarrierRuleDto) {
+32
View File
@@ -49,6 +49,38 @@ describe('FilesService', () => {
});
});
it('restores UTF-8 filenames that multipart parsing exposed as Latin-1', async () => {
const prisma = {
fileObject: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'file-1', ...data })),
},
};
const objectStorage = {
getBucket: jest.fn().mockReturnValue('cmpp-platform'),
putObject: jest.fn().mockResolvedValue({ etag: 'etag-1' }),
presignedPutObject: jest.fn(),
};
const service = new FilesService(prisma as never, objectStorage as never);
const originalname = Buffer.from('营业执照.png', 'utf8').toString('latin1');
await service.upload({ purpose: 'enterprise_photo' }, {
originalname,
mimetype: 'image/png',
size: 12,
buffer: Buffer.from('file-content'),
});
expect(prisma.fileObject.create).toHaveBeenCalledWith({
data: expect.objectContaining({ fileName: '营业执照.png' }),
});
expect(objectStorage.putObject).toHaveBeenCalledWith(
expect.stringMatching(/营业执照\.png$/),
expect.any(Buffer),
12,
'image/png',
);
});
it('downloads file content from object storage by FileObject id', async () => {
const fileObject = {
id: 'file-1',
+12 -2
View File
@@ -66,14 +66,15 @@ export class FilesService {
}
async upload(data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
const safeName = file.originalname.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_');
const fileName = normalizeMultipartFileName(file.originalname);
const safeName = fileName.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_');
const objectKey = `${data.prefix ?? data.purpose}/${Date.now()}-${randomUUID()}-${safeName}`;
await this.objectStorage.putObject(objectKey, file.buffer, file.size, file.mimetype || 'application/octet-stream');
return this.create({
tenantId: data.tenantId,
bucket: this.objectStorage.getBucket(),
objectKey,
fileName: file.originalname,
fileName,
contentType: file.mimetype || 'application/octet-stream',
sizeBytes: file.size,
purpose: data.purpose,
@@ -93,6 +94,15 @@ export class FilesService {
}
}
function normalizeMultipartFileName(value: string) {
if (![...value].some((character) => character.charCodeAt(0) > 0x7f) || [...value].some((character) => character.charCodeAt(0) > 0xff)) {
return value;
}
const decoded = Buffer.from(value, 'latin1').toString('utf8');
return decoded.includes('\uFFFD') ? value : decoded;
}
function serializeFileObject<T extends { sizeBytes: bigint | number | string }>(fileObject: T) {
return {
...fileObject,
@@ -34,9 +34,25 @@ export class AdminOperationsController {
@Query('taskId') taskId?: string,
@Query('messageId') messageId?: string,
@Query('phoneNumber') phoneNumber?: string,
@Query('contentKeyword') contentKeyword?: string,
@Query('channelKeyword') channelKeyword?: string,
@Query('queuedAtFrom') queuedAtFrom?: string,
@Query('queuedAtTo') queuedAtTo?: string,
@Query('status') status?: string,
) {
return this.operations.listMessages({ tenantId, applicationId, channelId, taskId, messageId, phoneNumber, status });
return this.operations.listMessages({
tenantId,
applicationId,
channelId,
taskId,
messageId,
phoneNumber,
contentKeyword,
channelKeyword,
queuedAtFrom,
queuedAtTo,
status,
});
}
@Get('message-segment-audits')
+15 -2
View File
@@ -6,6 +6,9 @@ function createPrismaMock() {
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]),
count: jest.fn().mockResolvedValue(3),
},
smsSendTask: {
count: jest.fn().mockResolvedValue(2),
},
smsMessageRecord: {
findMany: jest.fn().mockResolvedValue([{ messageId: 'MSG-1' }]),
groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }]),
@@ -163,7 +166,7 @@ function createPrismaMock() {
}
describe('OperationsService', () => {
it('filters send-chain messages by tenant, application, channel, task, phone, and status', async () => {
it('filters send-chain messages by tenant, application, channel, content, date, task, phone, and status', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
@@ -171,9 +174,13 @@ describe('OperationsService', () => {
tenantId: 'tenant-1',
applicationId: 'app-1',
channelId: 'channel-1',
channelKeyword: '移动通道',
taskId: 'task-1',
messageId: 'MSG-1',
phoneNumber: '13800000001',
contentKeyword: '验证码',
queuedAtFrom: '2026-07-01',
queuedAtTo: '2026-07-02',
status: 'delivered',
});
@@ -186,6 +193,12 @@ describe('OperationsService', () => {
messageId: 'MSG-1',
phoneNumber: '13800000001',
status: 'delivered',
content: { contains: '验证码', mode: 'insensitive' },
channel: { name: { contains: '移动通道', mode: 'insensitive' } },
queuedAt: {
gte: new Date('2026-07-01T00:00:00+08:00'),
lte: new Date('2026-07-02T23:59:59.999+08:00'),
},
},
include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true },
orderBy: { queuedAt: 'desc' },
@@ -234,7 +247,7 @@ describe('OperationsService', () => {
expect.objectContaining({
taskCount: 3,
uplinkCount: 1,
pendingAuditCount: 6,
pendingAuditCount: 5,
gatewayConnections: [{ status: 'connected', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
downstreamDeliverySummary: expect.objectContaining({
pending: 3,
+21 -1
View File
@@ -6,10 +6,14 @@ export interface MessageQuery {
tenantId?: string;
applicationId?: string;
channelId?: string;
channelKeyword?: string;
taskId?: string;
messageId?: string;
phoneNumber?: string;
contentKeyword?: string;
status?: string;
queuedAtFrom?: string;
queuedAtTo?: string;
}
export interface TraceQuery extends MessageQuery {
@@ -731,7 +735,7 @@ export class OperationsService {
this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }),
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
this.prisma.smsBatchTask.count({ where: { tenantId, auditStatus: 'pending' } }),
this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }),
]).then((counts) => counts.reduce((sum, value) => sum + value, 0));
}
@@ -756,9 +760,25 @@ function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
messageId: query.messageId,
phoneNumber: query.phoneNumber,
status: query.status,
...(query.contentKeyword ? { content: { contains: query.contentKeyword, mode: 'insensitive' } } : {}),
...(query.channelKeyword ? { channel: { name: { contains: query.channelKeyword, mode: 'insensitive' } } } : {}),
...(query.queuedAtFrom || query.queuedAtTo ? {
queuedAt: {
...(query.queuedAtFrom ? { gte: startOfShanghaiDay(query.queuedAtFrom) } : {}),
...(query.queuedAtTo ? { lte: endOfShanghaiDay(query.queuedAtTo) } : {}),
},
} : {}),
};
}
function startOfShanghaiDay(value: string) {
return new Date(`${value}T00:00:00+08:00`);
}
function endOfShanghaiDay(value: string) {
return new Date(`${value}T23:59:59.999+08:00`);
}
function normalizeGroupBy(groupBy?: string) {
if (groupBy === 'tenant' || groupBy === 'tenantId') {
return 'tenantId';
+19
View File
@@ -1,6 +1,7 @@
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import { requestContext } from '../common/request-context';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleDestroy {
@@ -11,6 +12,24 @@ export class PrismaService extends PrismaClient implements OnModuleDestroy {
'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
),
});
const operationLog = this.operationLog;
Object.defineProperty(this, 'operationLog', {
value: new Proxy(operationLog, {
get(target, property, receiver) {
if (property === 'create') {
return (args: { data: Record<string, unknown> }) => {
const ipAddress = requestContext.getStore()?.ipAddress;
return (target.create as (input: unknown) => unknown)({
...args,
data: { ...args.data, ipAddress: typeof args.data.ipAddress === 'string' ? args.data.ipAddress : ipAddress },
});
};
}
const value = Reflect.get(target, property, receiver);
return typeof value === 'function' ? value.bind(target) : value;
},
}),
});
}
async onModuleDestroy() {
+11
View File
@@ -1564,6 +1564,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
? String(channel.config.serviceId)
: 'SMS',
srcId: channel.srcId,
extensionDigits: getNonNegativeConfigInteger(channel.config, 'extensionDigits', 0),
registeredDelivery: 1,
msgFmt: 8,
},
@@ -2411,6 +2412,16 @@ function getPositiveConfigInteger(config: unknown, key: string, fallback: number
return fallback;
}
function getNonNegativeConfigInteger(config: unknown, key: string, fallback: number) {
if (config && typeof config === 'object' && !Array.isArray(config) && key in config) {
const value = Number((config as Record<string, unknown>)[key]);
if (Number.isInteger(value) && value >= 0) {
return value;
}
}
return fallback;
}
function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) {
const normalized = normalizeCarrier(channelCarrier);
return normalized === 'all' || normalized === targetCarrier;
+21 -3
View File
@@ -154,6 +154,7 @@ export class UsersService {
phone: data.phone === undefined ? undefined : normalizeOptional(data.phone),
displayName: data.displayName ?? current.displayName,
status: data.status ?? current.status,
...(data.status === 'disabled' && current.status !== 'disabled' ? { sessionVersion: { increment: 1 } } : {}),
},
include: { tenant: true, roles: { include: { role: true } } },
});
@@ -166,7 +167,7 @@ export class UsersService {
const current = await this.getExisting(id, scopeTenantId);
const updated = await this.prisma.user.update({
where: { id },
data: { status: data.status },
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 });
@@ -180,7 +181,7 @@ export class UsersService {
const current = await this.getExisting(id, scopeTenantId);
const updated = await this.prisma.user.update({
where: { id },
data: { passwordHash: hashPassword(data.password), failedLoginCount: 0, lockedUntil: null },
data: { passwordHash: hashPassword(data.password), failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username });
@@ -191,7 +192,7 @@ export class UsersService {
const current = await this.getExisting(id, scopeTenantId);
const updated = await this.prisma.user.update({
where: { id },
data: { status: 'deleted', deletedAt: new Date() },
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 });
@@ -218,6 +219,23 @@ export class UsersService {
});
}
async changeOwnPassword(id: string, currentPassword: string, password: string) {
if (!currentPassword || !password || password.length < 6) {
throw new BadRequestException('currentPassword and a password of at least 6 characters are required');
}
const current = await this.getExisting(id);
if (current.passwordHash !== hashPassword(currentPassword)) {
throw new BadRequestException('当前密码不正确');
}
const updated = await this.prisma.user.update({
where: { id },
data: { passwordHash: hashPassword(password), failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, id, 'user.password_changed_self', id, { username: current.username });
return updated;
}
listRoles() {
return this.prisma.role.findMany({
include: { permissions: { include: { permission: true } } },