fix: complete first version issue remediation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE "User" ADD COLUMN "sessionVersion" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -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?
|
||||
|
||||
@@ -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('*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } } },
|
||||
|
||||
Reference in New Issue
Block a user