feat: harden sessions and track downstream acknowledgements
This commit is contained in:
@@ -1,13 +1,22 @@
|
||||
import { Body, Controller, Get, Post, UnauthorizedException } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentSessionUserId } from './current-session-user.decorator';
|
||||
import { AuthService, LoginDto } from './auth.service';
|
||||
import { SessionService } from './session.service';
|
||||
import type { SessionRequest } from './session-validation.middleware';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
type CookieResponse = {
|
||||
cookie(name: string, value: string, options: Record<string, unknown>): void;
|
||||
clearCookie(name: string, options: Record<string, unknown>): void;
|
||||
};
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller()
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService, private readonly users: UsersService) {}
|
||||
constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get('admin/auth/captcha')
|
||||
adminCaptcha() {
|
||||
@@ -15,8 +24,8 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post('admin/auth/login')
|
||||
adminLogin(@Body() body: LoginDto) {
|
||||
return this.auth.login(body, 'admin');
|
||||
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
return this.finishLogin(await this.auth.login(body, 'admin'), request, response);
|
||||
}
|
||||
|
||||
@Get('client/auth/captcha')
|
||||
@@ -25,15 +34,101 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post('client/auth/login')
|
||||
clientLogin(@Body() body: LoginDto) {
|
||||
return this.auth.login(body, 'client');
|
||||
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
return this.finishLogin(await this.auth.login(body, 'client'), request, response);
|
||||
}
|
||||
|
||||
@Get('auth/session')
|
||||
currentSession(@Req() request: SessionRequest) {
|
||||
this.assertSession(request);
|
||||
return this.sessions.publicSession(request.authSession!);
|
||||
}
|
||||
|
||||
@Post('auth/session/touch')
|
||||
async touch(@Req() request: SessionRequest) {
|
||||
this.assertSession(request);
|
||||
const result = await this.sessions.touch(request.sessionToken!);
|
||||
if (result.status !== 'active') throw new UnauthorizedException({ code: 'SESSION_LOCKED', message: '会话已锁定' });
|
||||
return this.sessions.publicSession(result.record);
|
||||
}
|
||||
|
||||
@Post('auth/session/lock')
|
||||
async lock(@Req() request: SessionRequest) {
|
||||
this.assertSession(request);
|
||||
const record = await this.sessions.lock(request.sessionToken!);
|
||||
if (record) await this.writeLog(request, 'auth.session_locked', { portal: record.portal, reason: 'client_idle_timer' });
|
||||
return { locked: Boolean(record) };
|
||||
}
|
||||
|
||||
@Post('auth/session/unlock')
|
||||
async unlock(@Req() request: SessionRequest, @Body('password') password: string, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
this.assertSession(request);
|
||||
const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password);
|
||||
if (result.status !== 'active' || !('token' in result)) throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
|
||||
this.setCookie(response, result.token);
|
||||
await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal });
|
||||
return this.sessions.publicSession(result.record);
|
||||
}
|
||||
|
||||
@Post('auth/reauthenticate')
|
||||
async reauthenticate(@Req() request: SessionRequest, @Body('password') password: string) {
|
||||
this.assertSession(request);
|
||||
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
|
||||
if (result.status !== 'active') throw new UnauthorizedException({ code: 'SESSION_LOCKED', message: '会话已锁定' });
|
||||
await this.writeLog(request, 'auth.session_reauthenticated', { portal: result.record.portal });
|
||||
return this.sessions.publicSession(result.record);
|
||||
}
|
||||
|
||||
@Post('auth/logout')
|
||||
async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||
if (request.sessionToken) await this.sessions.remove(request.sessionToken);
|
||||
if (request.sessionUserId) await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal });
|
||||
this.clearCookie(response);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('auth/password')
|
||||
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException('登录会话无效,请重新登录');
|
||||
}
|
||||
if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录');
|
||||
return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? '');
|
||||
}
|
||||
|
||||
private async finishLogin(result: Awaited<ReturnType<AuthService['login']>>, request: SessionRequest, response: CookieResponse) {
|
||||
this.setCookie(response, result.sessionToken);
|
||||
await this.prisma.operationLog.create({
|
||||
data: { userId: result.user.id, tenantId: result.user.tenantId, action: 'auth.session_created', resource: 'auth_session', userAgent: request.header('user-agent'), detail: { portal: result.portal } },
|
||||
});
|
||||
const { sessionToken: _, ...publicResult } = result;
|
||||
return publicResult;
|
||||
}
|
||||
|
||||
private writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) {
|
||||
return this.prisma.operationLog.create({
|
||||
data: { userId: request.sessionUserId, action, resource: 'auth_session', userAgent: request.header('user-agent'), detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
|
||||
private assertSession(request: SessionRequest) {
|
||||
if (!request.sessionToken || !request.sessionUserId || !request.authSession) {
|
||||
throw new UnauthorizedException({ code: 'SESSION_INVALID', message: '登录会话无效,请重新登录' });
|
||||
}
|
||||
}
|
||||
|
||||
private setCookie(response: CookieResponse, token: string) {
|
||||
response.cookie(this.sessions.cookieName, token, {
|
||||
httpOnly: true,
|
||||
secure: this.sessions.cookieSecure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
private clearCookie(response: CookieResponse) {
|
||||
response.clearCookie(this.sessions.cookieName, {
|
||||
httpOnly: true,
|
||||
secure: this.sessions.cookieSecure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RecentAuthenticationGuard } from './recent-authentication.guard';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
providers: [
|
||||
AuthService,
|
||||
SessionService,
|
||||
RecentAuthenticationGuard,
|
||||
{ provide: APP_GUARD, useExisting: RecentAuthenticationGuard },
|
||||
],
|
||||
exports: [SessionService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -25,6 +25,17 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionsMock() {
|
||||
const record = {
|
||||
userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1,
|
||||
lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
||||
};
|
||||
return {
|
||||
create: jest.fn().mockResolvedValue({ token: 'opaque-session-token', record }),
|
||||
publicSession: jest.fn().mockReturnValue({ idleTimeoutSeconds: 3600, absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString() }),
|
||||
};
|
||||
}
|
||||
|
||||
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
|
||||
const captcha = service.createCaptcha();
|
||||
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
|
||||
@@ -39,21 +50,23 @@ async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client'
|
||||
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', accessToken: 'dev-token:user-1:0' }));
|
||||
const sessions = createSessionsMock();
|
||||
const service = new AuthService(users as never, sessions as never);
|
||||
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }));
|
||||
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
|
||||
expect(sessions.create).toHaveBeenCalledWith('user-1', 'admin', 0);
|
||||
});
|
||||
|
||||
it('rejects enterprise admins on admin portal', async () => {
|
||||
const users = createUsersMock('enterprise_admin');
|
||||
const service = new AuthService(users as never);
|
||||
const service = new AuthService(users as never, createSessionsMock() as never);
|
||||
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('locks user after five failed password attempts', async () => {
|
||||
const users = createUsersMock('platform_admin');
|
||||
const service = new AuthService(users as never);
|
||||
const service = new AuthService(users as never, createSessionsMock() as never);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { hashPassword, UsersService } from '../users/users.service';
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
export interface LoginDto {
|
||||
login: string;
|
||||
@@ -21,7 +22,7 @@ const anonymousFailures = new Map<string, { count: number; lockedUntil?: number
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly users: UsersService) {}
|
||||
constructor(private readonly users: UsersService, private readonly sessions: SessionService) {}
|
||||
|
||||
createCaptcha() {
|
||||
const left = Math.floor(10 + Math.random() * 40);
|
||||
@@ -76,9 +77,9 @@ export class AuthService {
|
||||
await this.users.recordLoginSuccess(user.id);
|
||||
anonymousFailures.delete(login);
|
||||
|
||||
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
|
||||
return {
|
||||
accessToken: `dev-token:${user.id}:${user.sessionVersion ?? 0}`,
|
||||
tokenType: 'Bearer',
|
||||
sessionToken: token,
|
||||
portal,
|
||||
user: {
|
||||
id: user.id,
|
||||
@@ -90,9 +91,20 @@ export class AuthService {
|
||||
displayName: user.displayName,
|
||||
roles: roleCodes,
|
||||
},
|
||||
...this.sessions.publicSession(record),
|
||||
};
|
||||
}
|
||||
|
||||
async unlock(token: string, userId: string, password: string) {
|
||||
await this.users.verifyCurrentPassword(userId, password);
|
||||
return this.sessions.unlock(token);
|
||||
}
|
||||
|
||||
async reauthenticate(token: string, userId: string, password: string) {
|
||||
await this.users.verifyCurrentPassword(userId, password);
|
||||
return this.sessions.markReauthenticated(token);
|
||||
}
|
||||
|
||||
private verifyCaptcha(captchaId?: string, captchaText?: string) {
|
||||
const record = captchaId ? captchaStore.get(captchaId) : undefined;
|
||||
captchaStore.delete(captchaId ?? '');
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { RecentAuthenticationGuard } from './recent-authentication.guard';
|
||||
|
||||
function context(record?: Record<string, unknown>) {
|
||||
return {
|
||||
getHandler: jest.fn(),
|
||||
getClass: jest.fn(),
|
||||
switchToHttp: () => ({ getRequest: () => ({ authSession: record }) }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('RecentAuthenticationGuard', () => {
|
||||
it('requires a fresh password verification on decorated operations', () => {
|
||||
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(true) };
|
||||
const sessions = { isRecentlyAuthenticated: jest.fn().mockReturnValue(false) };
|
||||
const guard = new RecentAuthenticationGuard(reflector as never, sessions as never);
|
||||
expect(() => guard.canActivate(context({}) as never)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('allows a decorated operation during the recent authentication window', () => {
|
||||
const reflector = { getAllAndOverride: jest.fn().mockReturnValue(true) };
|
||||
const sessions = { isRecentlyAuthenticated: jest.fn().mockReturnValue(true) };
|
||||
const guard = new RecentAuthenticationGuard(reflector as never, sessions as never);
|
||||
expect(guard.canActivate(context({}) as never)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { REQUIRE_RECENT_AUTHENTICATION } from './require-recent-authentication.decorator';
|
||||
import { SessionService } from './session.service';
|
||||
import type { SessionRequest } from './session-validation.middleware';
|
||||
|
||||
@Injectable()
|
||||
export class RecentAuthenticationGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector, private readonly sessions: SessionService) {}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const required = this.reflector.getAllAndOverride<boolean>(REQUIRE_RECENT_AUTHENTICATION, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!required) return true;
|
||||
const request = context.switchToHttp().getRequest<SessionRequest>();
|
||||
if (request.authSession && this.sessions.isRecentlyAuthenticated(request.authSession)) return true;
|
||||
throw new ForbiddenException({ code: 'RECENT_AUTHENTICATION_REQUIRED', message: '该操作需要重新验证当前密码' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const REQUIRE_RECENT_AUTHENTICATION = 'requireRecentAuthentication';
|
||||
export const RequireRecentAuthentication = () => SetMetadata(REQUIRE_RECENT_AUTHENTICATION, true);
|
||||
@@ -1,27 +1,51 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { SessionValidationMiddleware, type SessionRequest } from './session-validation.middleware';
|
||||
|
||||
function request(authorization?: string): SessionRequest {
|
||||
return { header: jest.fn().mockReturnValue(authorization) };
|
||||
const record = {
|
||||
userId: 'user-1', portal: 'admin' as const, sessionVersion: 3, createdAt: 1,
|
||||
lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
||||
};
|
||||
|
||||
function request(path = '/api/admin/users', cookie = 'cmpp_session=opaque-token'): SessionRequest {
|
||||
return {
|
||||
originalUrl: path,
|
||||
header: jest.fn((name: string) => name === 'cookie' ? cookie : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('SessionValidationMiddleware', () => {
|
||||
it('accepts the current user session version and exposes the session user id', async () => {
|
||||
it('accepts an active Redis session and exposes its user and record', 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 sessions = { validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||
const currentRequest = request();
|
||||
const next = jest.fn();
|
||||
|
||||
await middleware.use(currentRequest, {} as never, next);
|
||||
await middleware.use(currentRequest, {}, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(currentRequest.sessionUserId).toBe('user-1');
|
||||
expect(currentRequest.sessionToken).toBe('opaque-token');
|
||||
expect(currentRequest.authSession).toEqual(record);
|
||||
});
|
||||
|
||||
it('rejects an old session token after the user session version changes', async () => {
|
||||
it('rejects a session 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);
|
||||
const sessions = { validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||
|
||||
await expect(middleware.use(request('Bearer dev-token:user-1:3'), {} as never, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(middleware.use(request(), {}, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(sessions.remove).toHaveBeenCalledWith('opaque-token');
|
||||
});
|
||||
|
||||
it('only lets a locked session reach unlock and logout endpoints', async () => {
|
||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
||||
const sessions = { validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() };
|
||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||
|
||||
await expect(middleware.use(request(), {}, jest.fn())).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
const next = jest.fn();
|
||||
await middleware.use(request('/api/auth/session/unlock'), {}, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,33 +1,76 @@
|
||||
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthSessionRecord, DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionService } from './session.service';
|
||||
|
||||
export type SessionRequest = {
|
||||
header(name: string): string | undefined;
|
||||
originalUrl?: string;
|
||||
url?: string;
|
||||
sessionUserId?: string;
|
||||
sessionToken?: string;
|
||||
authSession?: AuthSessionRecord;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SessionValidationMiddleware implements NestMiddleware {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(private readonly prisma: PrismaService, private readonly sessions: SessionService) {}
|
||||
|
||||
async use(request: SessionRequest, _: unknown, next: () => void) {
|
||||
const authorization = request.header('authorization');
|
||||
if (!authorization) {
|
||||
const path = request.originalUrl ?? request.url ?? '';
|
||||
if (/\/(admin|client)\/auth\/(captcha|login)(?:\?|$)/.test(path)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const match = /^Bearer dev-token:([^:]+):(\d+)$/.exec(authorization.trim());
|
||||
if (!match) {
|
||||
throw new UnauthorizedException('登录会话无效,请重新登录');
|
||||
|
||||
const token = this.readCookie(request.header('cookie'));
|
||||
if (!token) {
|
||||
if ((path.includes('/admin/') && !path.includes('/admin/gateway/')) || path.includes('/client/') || path.includes('/auth/')) {
|
||||
throw this.unauthorized('SESSION_INVALID', '请先登录');
|
||||
}
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const result = await this.sessions.validate(token, request.header('x-session-activity') === 'user');
|
||||
if (result.status === 'expired') {
|
||||
throw this.unauthorized(result.code, result.code === 'SESSION_ABSOLUTE_TIMEOUT' ? '登录已满 12 小时,请重新登录' : '登录会话已失效,请重新登录');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: match[1] },
|
||||
where: { id: result.record.userId },
|
||||
select: { id: true, status: true, deletedAt: true, sessionVersion: true },
|
||||
});
|
||||
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== Number(match[2])) {
|
||||
throw new UnauthorizedException('登录会话已失效,请重新登录');
|
||||
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== result.record.sessionVersion) {
|
||||
await this.sessions.remove(token);
|
||||
throw this.unauthorized('SESSION_REVOKED', '登录会话已被撤销,请重新登录');
|
||||
}
|
||||
if ((path.includes('/admin/') && result.record.portal !== 'admin') || (path.includes('/client/') && result.record.portal !== 'client')) {
|
||||
throw this.unauthorized('SESSION_PORTAL_MISMATCH', '登录入口与当前会话不匹配');
|
||||
}
|
||||
|
||||
request.sessionUserId = user.id;
|
||||
request.sessionToken = token;
|
||||
request.authSession = result.record;
|
||||
if (result.status === 'locked' && !path.includes('/auth/session/unlock') && !path.includes('/auth/logout')) {
|
||||
if (result.newlyLocked) {
|
||||
await this.prisma.operationLog.create({
|
||||
data: { userId: user.id, action: 'auth.session_locked', resource: 'auth_session', detail: { portal: result.record.portal, reason: 'idle_timeout' } },
|
||||
});
|
||||
}
|
||||
throw this.unauthorized('SESSION_LOCKED', '由于长时间未操作,会话已安全锁定');
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
private readCookie(cookieHeader?: string) {
|
||||
if (!cookieHeader) return undefined;
|
||||
for (const part of cookieHeader.split(';')) {
|
||||
const [name, ...value] = part.trim().split('=');
|
||||
if (name === SESSION_COOKIE_NAME || name === DEVELOPMENT_SESSION_COOKIE_NAME) return decodeURIComponent(value.join('='));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private unauthorized(code: string, message: string) {
|
||||
return new UnauthorizedException({ code, message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
const values = new Map<string, string>();
|
||||
const redis = {
|
||||
get: jest.fn((key: string) => Promise.resolve(values.get(key) ?? null)),
|
||||
set: jest.fn((key: string, value: string) => { values.set(key, value); return Promise.resolve('OK'); }),
|
||||
del: jest.fn((key: string) => { values.delete(key); return Promise.resolve(1); }),
|
||||
disconnect: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('ioredis', () => jest.fn(() => redis));
|
||||
|
||||
import { SessionService } from './session.service';
|
||||
|
||||
describe('SessionService', () => {
|
||||
let now = 1_700_000_000_000;
|
||||
|
||||
beforeEach(() => {
|
||||
values.clear();
|
||||
jest.clearAllMocks();
|
||||
now = 1_700_000_000_000;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
process.env.ADMIN_SESSION_IDLE_TIMEOUT_MS = '3600000';
|
||||
process.env.CLIENT_SESSION_IDLE_TIMEOUT_MS = '7200000';
|
||||
process.env.SESSION_ABSOLUTE_TIMEOUT_MS = '43200000';
|
||||
process.env.SESSION_LOCK_RECOVERY_MS = '14400000';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
delete process.env.ADMIN_SESSION_IDLE_TIMEOUT_MS;
|
||||
delete process.env.CLIENT_SESSION_IDLE_TIMEOUT_MS;
|
||||
delete process.env.SESSION_ABSOLUTE_TIMEOUT_MS;
|
||||
delete process.env.SESSION_LOCK_RECOVERY_MS;
|
||||
});
|
||||
|
||||
it('locks an admin session after 60 minutes without extending the 12 hour absolute deadline', async () => {
|
||||
const service = new SessionService();
|
||||
const created = await service.create('user-1', 'admin', 2);
|
||||
expect(created.record.absoluteExpiresAt).toBe(now + 12 * 60 * 60 * 1000);
|
||||
|
||||
now += 60 * 60 * 1000 + 1;
|
||||
const result = await service.validate(created.token, false);
|
||||
expect(result.status).toBe('locked');
|
||||
if (result.status === 'locked') expect(result.record.lockedAt).toBe(now);
|
||||
});
|
||||
|
||||
it('keeps a client session active before its 120 minute inactivity limit', async () => {
|
||||
const service = new SessionService();
|
||||
const created = await service.create('user-1', 'client', 2);
|
||||
now += 90 * 60 * 1000;
|
||||
await expect(service.validate(created.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
|
||||
});
|
||||
|
||||
it('requires a full login after a session stays locked for four hours', async () => {
|
||||
const service = new SessionService();
|
||||
const created = await service.create('user-1', 'admin', 2);
|
||||
await service.lock(created.token);
|
||||
now += 4 * 60 * 60 * 1000 + 1;
|
||||
await expect(service.validate(created.token, false)).resolves.toEqual({ status: 'expired', code: 'SESSION_LOCK_TIMEOUT' });
|
||||
});
|
||||
|
||||
it('rotates the opaque token when a password unlock succeeds', async () => {
|
||||
const service = new SessionService();
|
||||
const created = await service.create('user-1', 'admin', 2);
|
||||
await service.lock(created.token);
|
||||
const result = await service.unlock(created.token);
|
||||
expect(result.status).toBe('active');
|
||||
if (result.status === 'active' && 'token' in result) {
|
||||
expect(result.token).not.toBe(created.token);
|
||||
await expect(service.validate(created.token, false)).resolves.toEqual({ status: 'expired', code: 'SESSION_INVALID' });
|
||||
await expect(service.validate(result.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { Injectable, OnModuleDestroy, ServiceUnavailableException } from '@nestjs/common';
|
||||
import IORedis from 'ioredis';
|
||||
|
||||
export type SessionPortal = 'admin' | 'client';
|
||||
|
||||
export type AuthSessionRecord = {
|
||||
userId: string;
|
||||
portal: SessionPortal;
|
||||
sessionVersion: number;
|
||||
createdAt: number;
|
||||
lastActivityAt: number;
|
||||
lastAuthenticatedAt: number;
|
||||
absoluteExpiresAt: number;
|
||||
lockedAt?: number;
|
||||
};
|
||||
|
||||
export type SessionValidationResult =
|
||||
| { status: 'active'; record: AuthSessionRecord }
|
||||
| { status: 'locked'; record: AuthSessionRecord; newlyLocked?: boolean }
|
||||
| { status: 'expired'; code: 'SESSION_INVALID' | 'SESSION_ABSOLUTE_TIMEOUT' | 'SESSION_LOCK_TIMEOUT' };
|
||||
|
||||
const SESSION_PREFIX = 'cmpp:auth:session:';
|
||||
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
|
||||
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
|
||||
|
||||
@Injectable()
|
||||
export class SessionService implements OnModuleDestroy {
|
||||
private redis?: IORedis;
|
||||
|
||||
onModuleDestroy() {
|
||||
this.redis?.disconnect();
|
||||
}
|
||||
|
||||
async create(userId: string, portal: SessionPortal, sessionVersion: number) {
|
||||
const now = Date.now();
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const record: AuthSessionRecord = {
|
||||
userId,
|
||||
portal,
|
||||
sessionVersion,
|
||||
createdAt: now,
|
||||
lastActivityAt: now,
|
||||
lastAuthenticatedAt: now,
|
||||
absoluteExpiresAt: now + this.absoluteTimeoutMs,
|
||||
};
|
||||
await this.write(token, record);
|
||||
return { token, record };
|
||||
}
|
||||
|
||||
async validate(token: string, markActivity: boolean): Promise<SessionValidationResult> {
|
||||
const record = await this.read(token);
|
||||
if (!record) return { status: 'expired', code: 'SESSION_INVALID' };
|
||||
|
||||
const now = Date.now();
|
||||
if (now >= record.absoluteExpiresAt) {
|
||||
await this.remove(token);
|
||||
return { status: 'expired', code: 'SESSION_ABSOLUTE_TIMEOUT' };
|
||||
}
|
||||
if (record.lockedAt && now - record.lockedAt >= this.lockRecoveryMs) {
|
||||
await this.remove(token);
|
||||
return { status: 'expired', code: 'SESSION_LOCK_TIMEOUT' };
|
||||
}
|
||||
if (!record.lockedAt && now - record.lastActivityAt >= this.idleTimeoutMs(record.portal)) {
|
||||
record.lockedAt = now;
|
||||
await this.write(token, record);
|
||||
return { status: 'locked', record, newlyLocked: true };
|
||||
}
|
||||
if (record.lockedAt) return { status: 'locked', record, newlyLocked: false };
|
||||
|
||||
if (markActivity && now - record.lastActivityAt >= 30_000) {
|
||||
record.lastActivityAt = now;
|
||||
await this.write(token, record);
|
||||
}
|
||||
return { status: 'active', record };
|
||||
}
|
||||
|
||||
async lock(token: string) {
|
||||
const record = await this.read(token);
|
||||
if (!record) return null;
|
||||
record.lockedAt = record.lockedAt ?? Date.now();
|
||||
await this.write(token, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
async touch(token: string) {
|
||||
const result = await this.validate(token, false);
|
||||
if (result.status !== 'active') return result;
|
||||
result.record.lastActivityAt = Date.now();
|
||||
await this.write(token, result.record);
|
||||
return result;
|
||||
}
|
||||
|
||||
async unlock(token: string) {
|
||||
const result = await this.validate(token, false);
|
||||
if (result.status === 'expired') return result;
|
||||
const now = Date.now();
|
||||
const nextToken = randomBytes(32).toString('base64url');
|
||||
const record = {
|
||||
...result.record,
|
||||
lockedAt: undefined,
|
||||
lastActivityAt: now,
|
||||
lastAuthenticatedAt: now,
|
||||
};
|
||||
await this.write(nextToken, record);
|
||||
await this.remove(token);
|
||||
return { status: 'active' as const, token: nextToken, record };
|
||||
}
|
||||
|
||||
async markReauthenticated(token: string) {
|
||||
const result = await this.validate(token, false);
|
||||
if (result.status !== 'active') return result;
|
||||
result.record.lastAuthenticatedAt = Date.now();
|
||||
await this.write(token, result.record);
|
||||
return result;
|
||||
}
|
||||
|
||||
remove(token: string) {
|
||||
return this.client.del(this.key(token));
|
||||
}
|
||||
|
||||
isRecentlyAuthenticated(record: AuthSessionRecord) {
|
||||
return Date.now() - record.lastAuthenticatedAt < this.recentAuthenticationMs;
|
||||
}
|
||||
|
||||
publicSession(record: AuthSessionRecord) {
|
||||
return {
|
||||
idleTimeoutSeconds: Math.floor(this.idleTimeoutMs(record.portal) / 1000),
|
||||
lockRecoverySeconds: Math.floor(this.lockRecoveryMs / 1000),
|
||||
absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString(),
|
||||
lastActivityAt: new Date(record.lastActivityAt).toISOString(),
|
||||
recentAuthenticationExpiresAt: new Date(record.lastAuthenticatedAt + this.recentAuthenticationMs).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
get cookieSecure() {
|
||||
return process.env.SESSION_COOKIE_SECURE === 'true' || (process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false');
|
||||
}
|
||||
|
||||
get cookieName() {
|
||||
return this.cookieSecure ? SESSION_COOKIE_NAME : DEVELOPMENT_SESSION_COOKIE_NAME;
|
||||
}
|
||||
|
||||
get absoluteTimeoutMs() {
|
||||
return this.duration('SESSION_ABSOLUTE_TIMEOUT_MS', 12 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
get lockRecoveryMs() {
|
||||
return this.duration('SESSION_LOCK_RECOVERY_MS', 4 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
get recentAuthenticationMs() {
|
||||
return this.duration('SESSION_RECENT_AUTH_MS', 30 * 60 * 1000);
|
||||
}
|
||||
|
||||
idleTimeoutMs(portal: SessionPortal) {
|
||||
return portal === 'admin'
|
||||
? this.duration('ADMIN_SESSION_IDLE_TIMEOUT_MS', 60 * 60 * 1000)
|
||||
: this.duration('CLIENT_SESSION_IDLE_TIMEOUT_MS', 120 * 60 * 1000);
|
||||
}
|
||||
|
||||
private duration(name: string, fallback: number) {
|
||||
const value = Number(process.env[name]);
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
private async read(token: string): Promise<AuthSessionRecord | null> {
|
||||
try {
|
||||
const value = await this.client.get(this.key(token));
|
||||
return value ? JSON.parse(value) as AuthSessionRecord : null;
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('登录会话服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
private async write(token: string, record: AuthSessionRecord) {
|
||||
const ttl = record.absoluteExpiresAt - Date.now();
|
||||
if (ttl <= 0) {
|
||||
await this.remove(token);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.client.set(this.key(token), JSON.stringify(record), 'PX', ttl);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('登录会话服务暂不可用');
|
||||
}
|
||||
}
|
||||
|
||||
private key(token: string) {
|
||||
return `${SESSION_PREFIX}${createHash('sha256').update(token).digest('hex')}`;
|
||||
}
|
||||
|
||||
private get client() {
|
||||
if (!this.redis) {
|
||||
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
||||
enableReadyCheck: true,
|
||||
maxRetriesPerRequest: 1,
|
||||
});
|
||||
}
|
||||
return this.redis;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import {
|
||||
BillingService,
|
||||
BillingActionDto,
|
||||
@@ -54,6 +55,7 @@ export class BillingController {
|
||||
}
|
||||
|
||||
@Post('manual-recharges')
|
||||
@RequireRecentAuthentication()
|
||||
createManualRecharge(@Body() body: CreateManualRechargeDto) {
|
||||
return this.billing.createManualRecharge(body);
|
||||
}
|
||||
@@ -84,11 +86,13 @@ export class BillingController {
|
||||
}
|
||||
|
||||
@Post('refund')
|
||||
@RequireRecentAuthentication()
|
||||
refund(@Body() body: BillingActionDto) {
|
||||
return this.billing.refund(body);
|
||||
}
|
||||
|
||||
@Post('adjust')
|
||||
@RequireRecentAuthentication()
|
||||
adjust(@Body() body: BillingActionDto) {
|
||||
return this.billing.adjust(body);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import {
|
||||
ChannelsService,
|
||||
ChangeChannelStatusDto,
|
||||
@@ -31,31 +32,37 @@ export class ChannelsController {
|
||||
}
|
||||
|
||||
@Post('channels')
|
||||
@RequireRecentAuthentication()
|
||||
createChannel(@Body() body: CreateChannelDto) {
|
||||
return this.channels.createChannel(body);
|
||||
}
|
||||
|
||||
@Put('channels/:id')
|
||||
@RequireRecentAuthentication()
|
||||
updateChannel(@Param('id') channelId: string, @Body() body: UpdateChannelDto) {
|
||||
return this.channels.updateChannel(channelId, body);
|
||||
}
|
||||
|
||||
@Post('channels/:id/test')
|
||||
@RequireRecentAuthentication()
|
||||
testChannel(@Param('id') channelId: string, @Body() body: TestChannelDto) {
|
||||
return this.channels.testChannel(channelId, body);
|
||||
}
|
||||
|
||||
@Post('channels/:id/status')
|
||||
@RequireRecentAuthentication()
|
||||
changeChannelStatus(@Param('id') channelId: string, @Body() body: ChangeChannelStatusDto) {
|
||||
return this.channels.changeChannelStatus(channelId, body);
|
||||
}
|
||||
|
||||
@Post('channels/:id/copy')
|
||||
@RequireRecentAuthentication()
|
||||
copyChannel(@Param('id') channelId: string, @Body() body: CopyChannelDto) {
|
||||
return this.channels.copyChannel(channelId, body);
|
||||
}
|
||||
|
||||
@Delete('channels/:id')
|
||||
@RequireRecentAuthentication()
|
||||
deleteChannel(@Param('id') channelId: string, @Body() body: ChangeChannelStatusDto) {
|
||||
return this.channels.deleteChannel(channelId, body);
|
||||
}
|
||||
@@ -96,21 +103,25 @@ export class ChannelsController {
|
||||
}
|
||||
|
||||
@Post('channel-groups')
|
||||
@RequireRecentAuthentication()
|
||||
createGroup(@Body() body: CreateChannelGroupDto) {
|
||||
return this.channels.createGroup(body);
|
||||
}
|
||||
|
||||
@Put('channel-groups/:id')
|
||||
@RequireRecentAuthentication()
|
||||
updateGroup(@Param('id') groupId: string, @Body() body: UpdateChannelGroupDto) {
|
||||
return this.channels.updateGroup(groupId, body);
|
||||
}
|
||||
|
||||
@Delete('channel-groups/:id')
|
||||
@RequireRecentAuthentication()
|
||||
deleteGroup(@Param('id') groupId: string) {
|
||||
return this.channels.deleteGroup(groupId);
|
||||
}
|
||||
|
||||
@Post('channel-groups/items')
|
||||
@RequireRecentAuthentication()
|
||||
addGroupItem(@Body() body: CreateChannelGroupItemDto) {
|
||||
return this.channels.addGroupItem(body);
|
||||
}
|
||||
@@ -121,6 +132,7 @@ export class ChannelsController {
|
||||
}
|
||||
|
||||
@Post('channel-route-rules')
|
||||
@RequireRecentAuthentication()
|
||||
createRouteRule(@Body() body: CreateRouteRuleDto) {
|
||||
return this.channels.createRouteRule(body);
|
||||
}
|
||||
@@ -156,6 +168,7 @@ export class ChannelsController {
|
||||
}
|
||||
|
||||
@Post('report-tasks/status-change')
|
||||
@RequireRecentAuthentication()
|
||||
changeReportTaskStatuses(@Body() body: ChangeReportTaskStatusesDto) {
|
||||
return this.channels.changeReportTaskStatuses(body);
|
||||
}
|
||||
|
||||
@@ -396,9 +396,13 @@ describe('OperationsService', () => {
|
||||
prisma.cmppDownstreamDelivery.count = jest.fn()
|
||||
.mockResolvedValueOnce(12)
|
||||
.mockResolvedValueOnce(3)
|
||||
.mockResolvedValueOnce(0)
|
||||
.mockResolvedValueOnce(8)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(0)
|
||||
.mockResolvedValueOnce(0)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(0)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(2)
|
||||
.mockResolvedValueOnce(1)
|
||||
@@ -428,15 +432,19 @@ describe('OperationsService', () => {
|
||||
summary: {
|
||||
total: 12,
|
||||
pending: 3,
|
||||
awaitingAck: 0,
|
||||
delivered: 8,
|
||||
failed: 1,
|
||||
unconfirmed: 0,
|
||||
rejected: 0,
|
||||
stalledPending: 1,
|
||||
stalledAck: 0,
|
||||
recentFailed: 1,
|
||||
alertCount: 2,
|
||||
},
|
||||
typeBreakdown: [
|
||||
{ deliveryType: 'receipt', total: 9, pending: 2, delivered: 6, failed: 1 },
|
||||
{ deliveryType: 'uplink', total: 3, pending: 1, delivered: 2, failed: 0 },
|
||||
{ deliveryType: 'receipt', total: 9, pending: 2, awaitingAck: 0, delivered: 6, failed: 1, unconfirmed: 0, rejected: 0 },
|
||||
{ deliveryType: 'uplink', total: 3, pending: 1, awaitingAck: 0, delivered: 2, failed: 0, unconfirmed: 0, rejected: 0 },
|
||||
],
|
||||
retryBuckets: [
|
||||
{ label: '0次', count: 2 },
|
||||
@@ -444,8 +452,8 @@ describe('OperationsService', () => {
|
||||
{ label: '4次及以上', count: 0 },
|
||||
],
|
||||
topApplications: [
|
||||
{ applicationId: 'app-1', name: '应用A', pending: 2, failed: 1, delivered: 5, alertCount: 3 },
|
||||
{ applicationId: 'app-2', name: '应用B', pending: 1, failed: 0, delivered: 3, alertCount: 1 },
|
||||
{ applicationId: 'app-1', name: '应用A', pending: 2, awaitingAck: 0, failed: 1, unconfirmed: 0, rejected: 0, delivered: 5, alertCount: 3 },
|
||||
{ applicationId: 'app-2', name: '应用B', pending: 1, awaitingAck: 0, failed: 0, unconfirmed: 0, rejected: 0, delivered: 3, alertCount: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -408,11 +408,14 @@ export class OperationsService {
|
||||
const scopedWhere = downstreamDeliveryScopedWhere(query);
|
||||
const stalledPendingAt = new Date(Date.now() - downstreamAlertPendingMinutes() * 60_000);
|
||||
const recentFailedAt = new Date(Date.now() - downstreamAlertRecentFailedHours() * 60 * 60_000);
|
||||
const [total, pending, delivered, failed, stalledPending, recentFailed, typeGroups, applicationGroups, retryZero, retryLow, retryHigh] = await Promise.all([
|
||||
const [total, pending, awaitingAck, delivered, failed, unconfirmed, rejected, stalledPending, stalledAck, recentFailed, typeGroups, applicationGroups, retryZero, retryLow, retryHigh] = await Promise.all([
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: scopedWhere }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'pending' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'awaiting_ack' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'delivered' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'failed' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'unconfirmed' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'rejected' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
@@ -420,10 +423,13 @@ export class OperationsService {
|
||||
createdAt: { lte: stalledPendingAt },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { ...scopedWhere, status: 'awaiting_ack', ackDeadlineAt: { lte: new Date() } },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: 'failed',
|
||||
status: { in: ['failed', 'unconfirmed', 'rejected'] },
|
||||
updatedAt: { gte: recentFailedAt },
|
||||
},
|
||||
}),
|
||||
@@ -440,21 +446,21 @@ export class OperationsService {
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['pending', 'failed'] },
|
||||
status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] },
|
||||
retryCount: 0,
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['pending', 'failed'] },
|
||||
status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] },
|
||||
retryCount: { gte: 1, lte: 3 },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['pending', 'failed'] },
|
||||
status: { in: ['pending', 'failed', 'unconfirmed', 'rejected'] },
|
||||
retryCount: { gte: 4 },
|
||||
},
|
||||
}),
|
||||
@@ -474,18 +480,25 @@ export class OperationsService {
|
||||
summary: {
|
||||
total,
|
||||
pending,
|
||||
awaitingAck,
|
||||
delivered,
|
||||
failed,
|
||||
unconfirmed,
|
||||
rejected,
|
||||
stalledPending,
|
||||
stalledAck,
|
||||
recentFailed,
|
||||
alertCount: stalledPending + recentFailed,
|
||||
alertCount: stalledPending + stalledAck + recentFailed,
|
||||
},
|
||||
typeBreakdown: ['receipt', 'uplink'].map((deliveryType) => ({
|
||||
deliveryType,
|
||||
total: groupedByType[deliveryType]?.total ?? 0,
|
||||
pending: groupedByType[deliveryType]?.pending ?? 0,
|
||||
awaitingAck: groupedByType[deliveryType]?.awaitingAck ?? 0,
|
||||
delivered: groupedByType[deliveryType]?.delivered ?? 0,
|
||||
failed: groupedByType[deliveryType]?.failed ?? 0,
|
||||
unconfirmed: groupedByType[deliveryType]?.unconfirmed ?? 0,
|
||||
rejected: groupedByType[deliveryType]?.rejected ?? 0,
|
||||
})),
|
||||
retryBuckets: [
|
||||
{ label: '0次', count: retryZero },
|
||||
@@ -899,15 +912,21 @@ function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all:
|
||||
function groupDownstreamByType(
|
||||
groups: Array<{ deliveryType: string; status: string; _count: { _all: number } }>,
|
||||
) {
|
||||
return groups.reduce<Record<string, { total: number; pending: number; delivered: number; failed: number }>>((accumulator, item) => {
|
||||
const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, delivered: 0, failed: 0 };
|
||||
return groups.reduce<Record<string, { total: number; pending: number; awaitingAck: number; delivered: number; failed: number; unconfirmed: number; rejected: number }>>((accumulator, item) => {
|
||||
const current = accumulator[item.deliveryType] ?? { total: 0, pending: 0, awaitingAck: 0, delivered: 0, failed: 0, unconfirmed: 0, rejected: 0 };
|
||||
current.total += item._count._all;
|
||||
if (item.status === 'pending') {
|
||||
current.pending += item._count._all;
|
||||
} else if (item.status === 'awaiting_ack') {
|
||||
current.awaitingAck += item._count._all;
|
||||
} else if (item.status === 'delivered') {
|
||||
current.delivered += item._count._all;
|
||||
} else if (item.status === 'failed') {
|
||||
current.failed += item._count._all;
|
||||
} else if (item.status === 'unconfirmed') {
|
||||
current.unconfirmed += item._count._all;
|
||||
} else if (item.status === 'rejected') {
|
||||
current.rejected += item._count._all;
|
||||
}
|
||||
accumulator[item.deliveryType] = current;
|
||||
return accumulator;
|
||||
@@ -918,24 +937,33 @@ function groupDownstreamByApplication(
|
||||
groups: Array<{ applicationId: string; status: string; _count: { _all: number } }>,
|
||||
applicationMap: Map<string, string>,
|
||||
) {
|
||||
const summaryMap = new Map<string, { applicationId: string; name: string; pending: number; failed: number; delivered: number; alertCount: number }>();
|
||||
const summaryMap = new Map<string, { applicationId: string; name: string; pending: number; awaitingAck: number; failed: number; unconfirmed: number; rejected: number; delivered: number; alertCount: number }>();
|
||||
groups.forEach((item) => {
|
||||
const current = summaryMap.get(item.applicationId) ?? {
|
||||
applicationId: item.applicationId,
|
||||
name: applicationMap.get(item.applicationId) ?? item.applicationId,
|
||||
pending: 0,
|
||||
awaitingAck: 0,
|
||||
failed: 0,
|
||||
unconfirmed: 0,
|
||||
rejected: 0,
|
||||
delivered: 0,
|
||||
alertCount: 0,
|
||||
};
|
||||
if (item.status === 'pending') {
|
||||
current.pending += item._count._all;
|
||||
} else if (item.status === 'awaiting_ack') {
|
||||
current.awaitingAck += item._count._all;
|
||||
} else if (item.status === 'failed') {
|
||||
current.failed += item._count._all;
|
||||
} else if (item.status === 'unconfirmed') {
|
||||
current.unconfirmed += item._count._all;
|
||||
} else if (item.status === 'rejected') {
|
||||
current.rejected += item._count._all;
|
||||
} else if (item.status === 'delivered') {
|
||||
current.delivered += item._count._all;
|
||||
}
|
||||
current.alertCount = current.pending + current.failed;
|
||||
current.alertCount = current.pending + current.failed + current.unconfirmed + current.rejected;
|
||||
summaryMap.set(item.applicationId, current);
|
||||
});
|
||||
return [...summaryMap.values()];
|
||||
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
GatewayInboundAuthDto,
|
||||
GatewayInboundSubmitDto,
|
||||
GatewayPendingDeliveryQueryDto,
|
||||
GatewayDownstreamAcknowledgedDto,
|
||||
GatewayDownstreamFailureType,
|
||||
GatewayDownstreamSentDto,
|
||||
GatewayDownstreamRecoveryStatusDto,
|
||||
GatewayReceiptEventDto,
|
||||
GatewaySubmitDeadLetterDto,
|
||||
@@ -66,9 +69,19 @@ export class GatewayEventsController {
|
||||
return this.sendChain.markDownstreamDeliveryDelivered(body.id);
|
||||
}
|
||||
|
||||
@Post('downstream/sent')
|
||||
downstreamSent(@Body() body: GatewayDownstreamSentDto) {
|
||||
return this.sendChain.markDownstreamDeliverySent(body);
|
||||
}
|
||||
|
||||
@Post('downstream/acknowledged')
|
||||
downstreamAcknowledged(@Body() body: GatewayDownstreamAcknowledgedDto) {
|
||||
return this.sendChain.acknowledgeDownstreamDelivery(body);
|
||||
}
|
||||
|
||||
@Post('downstream/failed')
|
||||
downstreamFailed(@Body() body: { id: string; errorMessage?: string }) {
|
||||
return this.sendChain.markDownstreamDeliveryFailed(body.id, body.errorMessage);
|
||||
downstreamFailed(@Body() body: { id: string; errorMessage?: string; failureType?: GatewayDownstreamFailureType }) {
|
||||
return this.sendChain.markDownstreamDeliveryFailed(body.id, body.errorMessage, body.failureType);
|
||||
}
|
||||
|
||||
@Post('downstream/recovery-status')
|
||||
|
||||
@@ -213,6 +213,7 @@ function createPrismaMock() {
|
||||
}),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1', deliveryType: 'receipt', ...data })),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
gatewaySubmitDeadLetter: {
|
||||
upsert: jest.fn().mockResolvedValue({ id: 'dead-1' }),
|
||||
@@ -1454,6 +1455,49 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('only marks downstream delivery delivered after a successful CMPP_DELIVER_RESP', async () => {
|
||||
const { service, prisma } = createService();
|
||||
|
||||
await service.markDownstreamDeliverySent({
|
||||
id: 'delivery-1',
|
||||
connectionId: 'conn-1',
|
||||
sequenceId: '37',
|
||||
messageId: '9016479179509871733',
|
||||
sentAt: '2026-07-14T03:40:18.030Z',
|
||||
ackDeadlineAt: '2026-07-14T03:40:48.030Z',
|
||||
});
|
||||
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
where: { id: 'delivery-1', status: { not: 'delivered' } },
|
||||
data: expect.objectContaining({ status: 'awaiting_ack', ackSequenceId: '37', connectionId: 'conn-1' }),
|
||||
}));
|
||||
|
||||
await service.acknowledgeDownstreamDelivery({
|
||||
id: 'delivery-1',
|
||||
connectionId: 'conn-1',
|
||||
sequenceId: '37',
|
||||
messageId: '9016479179509871733',
|
||||
result: 0,
|
||||
acknowledgedAt: '2026-07-14T03:40:18.060Z',
|
||||
});
|
||||
expect(prisma.cmppDownstreamDelivery.updateMany).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'delivered', ackResult: 0, deliveredAt: new Date('2026-07-14T03:40:18.060Z') }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not automatically retry an unacknowledged delivery when its policy snapshot is disabled', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValue({
|
||||
id: 'delivery-1', tenantId: 'tenant-1', applicationId: 'app-1', messageId: 'MSG-1',
|
||||
deliveryType: 'receipt', status: 'awaiting_ack', retryEnabled: false, retryCount: 0,
|
||||
});
|
||||
|
||||
await service.markDownstreamDeliveryFailed('delivery-1', 'CMPP_DELIVER_RESP timeout', 'ack_timeout');
|
||||
|
||||
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ status: 'unconfirmed', retryCount: 1, nextRetryAt: null }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('uses exponential backoff for downstream delivery retries before final failure', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const previousBase = process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS;
|
||||
|
||||
@@ -105,6 +105,32 @@ export interface GatewayPendingDeliveryQueryDto {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamSentDto {
|
||||
id: string;
|
||||
connectionId?: string;
|
||||
sequenceId?: string;
|
||||
messageId?: string;
|
||||
sentAt?: string;
|
||||
ackDeadlineAt?: string;
|
||||
}
|
||||
|
||||
export interface GatewayDownstreamAcknowledgedDto extends GatewayDownstreamSentDto {
|
||||
result: number;
|
||||
acknowledgedAt?: string;
|
||||
}
|
||||
|
||||
export type GatewayDownstreamFailureType = 'send_failed' | 'ack_timeout' | 'ack_rejected' | 'connection_lost';
|
||||
|
||||
type GatewayControlDeliveryResult = {
|
||||
sent?: boolean;
|
||||
delivered?: boolean;
|
||||
connectionId?: string;
|
||||
sequenceId?: string;
|
||||
messageId?: string;
|
||||
sentAt?: string;
|
||||
ackDeadlineAt?: string;
|
||||
};
|
||||
|
||||
export interface GatewaySubmitDeadLetterDto {
|
||||
streamMessageId: string;
|
||||
traceId?: string;
|
||||
@@ -910,6 +936,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!application || application.status !== 'active' || application.tenant.status !== 'active') {
|
||||
throw new BadRequestException('CMPP account is invalid or disabled');
|
||||
}
|
||||
const expiredAcknowledgements = await this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: { applicationId: application.id, status: 'awaiting_ack', ackDeadlineAt: { lte: new Date() } },
|
||||
select: { id: true },
|
||||
take: 500,
|
||||
});
|
||||
for (const expired of expiredAcknowledgements) {
|
||||
await this.markDownstreamDeliveryFailed(expired.id, 'CMPP_DELIVER_RESP timeout recovered after Gateway restart', 'ack_timeout');
|
||||
}
|
||||
return this.prisma.cmppDownstreamDelivery.findMany({
|
||||
where: {
|
||||
applicationId: application.id,
|
||||
@@ -932,19 +966,78 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
async markDownstreamDeliveryFailed(id: string, errorMessage?: string) {
|
||||
async markDownstreamDeliverySent(data: GatewayDownstreamSentDto) {
|
||||
const sentAt = asDateOrNull(data.sentAt) ?? new Date();
|
||||
const ackDeadlineAt = asDateOrNull(data.ackDeadlineAt) ?? new Date(sentAt.getTime() + downstreamAckTimeoutMs());
|
||||
await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: data.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
status: 'awaiting_ack',
|
||||
sentAt,
|
||||
ackDeadlineAt,
|
||||
ackSequenceId: data.sequenceId,
|
||||
ackMessageId: data.messageId,
|
||||
connectionId: data.connectionId,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
}
|
||||
|
||||
async acknowledgeDownstreamDelivery(data: GatewayDownstreamAcknowledgedDto) {
|
||||
const acknowledgedAt = asDateOrNull(data.acknowledgedAt) ?? new Date();
|
||||
if (data.result === 0) {
|
||||
await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: data.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
status: 'delivered',
|
||||
acknowledgedAt,
|
||||
deliveredAt: acknowledgedAt,
|
||||
ackDeadlineAt: null,
|
||||
ackResult: data.result,
|
||||
ackSequenceId: data.sequenceId,
|
||||
ackMessageId: data.messageId,
|
||||
connectionId: data.connectionId,
|
||||
nextRetryAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: data.id } });
|
||||
}
|
||||
await this.prisma.cmppDownstreamDelivery.updateMany({
|
||||
where: { id: data.id, status: { not: 'delivered' } },
|
||||
data: {
|
||||
acknowledgedAt,
|
||||
ackResult: data.result,
|
||||
ackSequenceId: data.sequenceId,
|
||||
ackMessageId: data.messageId,
|
||||
connectionId: data.connectionId,
|
||||
},
|
||||
});
|
||||
return this.markDownstreamDeliveryFailed(data.id, `downstream CMPP_DELIVER_RESP result=${data.result}`, 'ack_rejected');
|
||||
}
|
||||
|
||||
async markDownstreamDeliveryFailed(id: string, errorMessage?: string, failureType: GatewayDownstreamFailureType = 'send_failed') {
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.findUnique({ where: { id } });
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
if (delivery.status === 'delivered') {
|
||||
return delivery;
|
||||
}
|
||||
const retryCount = (delivery.retryCount ?? 0) + 1;
|
||||
const finalFailure = retryCount >= downstreamMaxRetries();
|
||||
const acknowledgementFailure = failureType === 'ack_timeout' || failureType === 'ack_rejected' || failureType === 'connection_lost';
|
||||
const retryAllowed = !acknowledgementFailure || delivery.retryEnabled !== false;
|
||||
const finalFailure = !retryAllowed || retryCount >= downstreamMaxRetries();
|
||||
const finalStatus = failureType === 'ack_rejected' ? 'rejected' : acknowledgementFailure ? 'unconfirmed' : 'failed';
|
||||
const updated = await this.prisma.cmppDownstreamDelivery.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: finalFailure ? 'failed' : 'pending',
|
||||
status: finalFailure ? finalStatus : 'pending',
|
||||
retryCount,
|
||||
nextRetryAt: finalFailure ? null : new Date(Date.now() + downstreamRetryDelayMs(retryCount)),
|
||||
ackDeadlineAt: null,
|
||||
lastError: errorMessage ?? 'downstream delivery failed',
|
||||
},
|
||||
});
|
||||
@@ -960,6 +1053,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
applicationId: updated.applicationId,
|
||||
messageId: updated.messageId,
|
||||
retryCount,
|
||||
failureType,
|
||||
retryEnabled: updated.retryEnabled,
|
||||
errorMessage: updated.lastError,
|
||||
},
|
||||
},
|
||||
@@ -1168,12 +1263,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
account: String(payload.account ?? delivery.application?.cmppAccount ?? ''),
|
||||
...payload,
|
||||
};
|
||||
await this.prisma.cmppDownstreamDelivery.update({
|
||||
where: { id: delivery.id },
|
||||
data: { status: 'pending', retryCount: 0, nextRetryAt: null, ackDeadlineAt: null, lastError: null },
|
||||
});
|
||||
try {
|
||||
const result = await this.postGatewayControl(path, requestPayload) as { delivered?: boolean };
|
||||
if (result.delivered) {
|
||||
return this.markDownstreamDeliveryDelivered(delivery.id);
|
||||
const result = await this.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult;
|
||||
if (result.sent || result.delivered) {
|
||||
return this.markDownstreamDeliverySent({ id: delivery.id, ...result });
|
||||
}
|
||||
return this.markDownstreamDeliveryFailed(delivery.id, 'downstream client is not connected');
|
||||
return this.prisma.cmppDownstreamDelivery.findUnique({ where: { id: delivery.id } });
|
||||
} catch (error) {
|
||||
return this.markDownstreamDeliveryFailed(
|
||||
delivery.id,
|
||||
@@ -1326,7 +1425,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: data.applicationId },
|
||||
select: { cmppAccount: true },
|
||||
select: { cmppAccount: true, downstreamReceiptRetryEnabled: true, downstreamUplinkRetryEnabled: true },
|
||||
});
|
||||
const payload = { account: application?.cmppAccount, applicationId: data.applicationId, ...data.payload };
|
||||
const delivery = await this.prisma.cmppDownstreamDelivery.create({
|
||||
@@ -1337,6 +1436,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
messageId: data.messageId,
|
||||
deliveryType: data.deliveryType,
|
||||
payload,
|
||||
retryEnabled: data.deliveryType === 'uplink'
|
||||
? application?.downstreamUplinkRetryEnabled ?? true
|
||||
: application?.downstreamReceiptRetryEnabled ?? true,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
@@ -1344,11 +1446,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
const result = await this.postGatewayControl(
|
||||
data.deliveryType === 'receipt' ? '/downstream/receipt' : '/downstream/uplink',
|
||||
{ deliveryId: delivery.id, ...payload },
|
||||
) as { delivered?: boolean };
|
||||
if (result.delivered) {
|
||||
await this.markDownstreamDeliveryDelivered(delivery.id);
|
||||
} else {
|
||||
await this.markDownstreamDeliveryFailed(delivery.id, 'downstream client is not connected');
|
||||
) as GatewayControlDeliveryResult;
|
||||
if (result.sent || result.delivered) {
|
||||
await this.markDownstreamDeliverySent({ id: delivery.id, ...result });
|
||||
}
|
||||
} catch (error) {
|
||||
await this.markDownstreamDeliveryFailed(delivery.id, error instanceof Error ? error.message : 'Gateway control delivery failed');
|
||||
@@ -2646,6 +2746,11 @@ function downstreamRetryDelayMs(retryCount = 1) {
|
||||
return Math.min(delay, max);
|
||||
}
|
||||
|
||||
function downstreamAckTimeoutMs() {
|
||||
const configured = Number(process.env.CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS ?? 30);
|
||||
return Math.max(5, Number.isFinite(configured) ? configured : 30) * 1000;
|
||||
}
|
||||
|
||||
function downstreamRetryBaseDelayMs() {
|
||||
const value = Number(process.env.CMPP_DOWNSTREAM_RETRY_DELAY_MS ?? DEFAULT_DOWNSTREAM_RETRY_DELAY_MS);
|
||||
return Number.isFinite(value) && value > 0 ? value : DEFAULT_DOWNSTREAM_RETRY_DELAY_MS;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service';
|
||||
|
||||
@ApiTags('admin-sms-config')
|
||||
@@ -23,16 +24,19 @@ export class AdminSmsConfigController {
|
||||
}
|
||||
|
||||
@Post('enterprise-applications')
|
||||
@RequireRecentAuthentication()
|
||||
createApplication(@Body() body: CreateSmsApplicationDto) {
|
||||
return this.smsConfig.createApplication(body);
|
||||
}
|
||||
|
||||
@Put('enterprise-applications/:id')
|
||||
@RequireRecentAuthentication()
|
||||
updateApplication(@Param('id') applicationId: string, @Body() body: UpdateSmsApplicationDto) {
|
||||
return this.smsConfig.updateApplication(applicationId, body);
|
||||
}
|
||||
|
||||
@Put('enterprise-applications/:id/route-rules')
|
||||
@RequireRecentAuthentication()
|
||||
replaceApplicationRouteRules(@Param('id') applicationId: string, @Body() body: ReplaceApplicationRouteRulesDto) {
|
||||
return this.smsConfig.replaceApplicationRouteRules(applicationId, body);
|
||||
}
|
||||
@@ -78,16 +82,19 @@ export class AdminSmsConfigController {
|
||||
}
|
||||
|
||||
@Post('drainage-infos/:id/approve')
|
||||
@RequireRecentAuthentication()
|
||||
approveDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.approveDrainageInfo(itemId, body);
|
||||
}
|
||||
|
||||
@Post('drainage-infos/:id/reject')
|
||||
@RequireRecentAuthentication()
|
||||
rejectDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.rejectDrainageInfo(itemId, body);
|
||||
}
|
||||
|
||||
@Post('drainage-infos/:id/status')
|
||||
@RequireRecentAuthentication()
|
||||
changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeDrainageInfoStatus(itemId, body);
|
||||
}
|
||||
@@ -113,36 +120,43 @@ export class AdminSmsConfigController {
|
||||
}
|
||||
|
||||
@Post('signatures/:id/approve')
|
||||
@RequireRecentAuthentication()
|
||||
approveSignature(@Param('id') signatureId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.approveSignature(signatureId, body);
|
||||
}
|
||||
|
||||
@Post('signatures/:id/reject')
|
||||
@RequireRecentAuthentication()
|
||||
rejectSignature(@Param('id') signatureId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.rejectSignature(signatureId, body);
|
||||
}
|
||||
|
||||
@Post('templates/:id/approve')
|
||||
@RequireRecentAuthentication()
|
||||
approveTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.approveTemplate(templateId, body);
|
||||
}
|
||||
|
||||
@Post('templates/:id/reject')
|
||||
@RequireRecentAuthentication()
|
||||
rejectTemplate(@Param('id') templateId: string, @Body() body: ReviewDto) {
|
||||
return this.smsConfig.rejectTemplate(templateId, body);
|
||||
}
|
||||
|
||||
@Post('enterprise-applications/:id/status')
|
||||
@RequireRecentAuthentication()
|
||||
changeApplicationStatus(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeApplicationStatus(applicationId, body);
|
||||
}
|
||||
|
||||
@Post('enterprise-signatures/:id/status')
|
||||
@RequireRecentAuthentication()
|
||||
changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeSignatureStatus(signatureId, body);
|
||||
}
|
||||
|
||||
@Post('enterprise-templates/:id/status')
|
||||
@RequireRecentAuthentication()
|
||||
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeTemplateStatus(templateId, body);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import {
|
||||
CreateSignatureMaterialDto,
|
||||
CreateSmsApplicationDto,
|
||||
@@ -39,11 +40,13 @@ export class ClientSmsConfigController {
|
||||
}
|
||||
|
||||
@Post('applications/:id/secret/reset')
|
||||
@RequireRecentAuthentication()
|
||||
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.resetApplicationSecret(applicationId, body);
|
||||
}
|
||||
|
||||
@Post('applications/:id/status')
|
||||
@RequireRecentAuthentication()
|
||||
changeApplicationStatus(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.changeApplicationStatus(applicationId, body);
|
||||
}
|
||||
|
||||
@@ -271,6 +271,8 @@ describe('SmsConfigService', () => {
|
||||
interfaceEnabled: false,
|
||||
interfaceType: 'cmpp20',
|
||||
queuePriority: 'priority',
|
||||
downstreamReceiptRetryEnabled: true,
|
||||
downstreamUplinkRetryEnabled: true,
|
||||
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface CreateSmsApplicationDto {
|
||||
queuePriority?: string;
|
||||
maxPhonesPerTask?: number;
|
||||
templateMismatchMode?: string;
|
||||
downstreamReceiptRetryEnabled?: boolean;
|
||||
downstreamUplinkRetryEnabled?: boolean;
|
||||
ipAllowlist?: string[];
|
||||
}
|
||||
|
||||
@@ -314,6 +316,8 @@ export class SmsConfigService {
|
||||
queuePriority,
|
||||
maxPhonesPerTask: data.maxPhonesPerTask ?? 1000000,
|
||||
templateMismatchMode: data.templateMismatchMode ?? 'reject',
|
||||
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled ?? true,
|
||||
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled ?? true,
|
||||
ipAllowlist: {
|
||||
create: (data.ipAllowlist ?? []).map((ipCidr) => ({ ipCidr })),
|
||||
},
|
||||
@@ -365,6 +369,8 @@ export class SmsConfigService {
|
||||
queuePriority,
|
||||
maxPhonesPerTask: data.maxPhonesPerTask,
|
||||
templateMismatchMode: data.templateMismatchMode,
|
||||
downstreamReceiptRetryEnabled: data.downstreamReceiptRetryEnabled,
|
||||
downstreamUplinkRetryEnabled: data.downstreamUplinkRetryEnabled,
|
||||
status: data.status,
|
||||
ipAllowlist: data.ipAllowlist ? {
|
||||
create: data.ipAllowlist.map((ipCidr) => ({ ipCidr })),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import { CreateTenantDto, TenantsService, UpdateTenantDto } from './tenants.service';
|
||||
|
||||
@ApiTags('tenants')
|
||||
@@ -23,21 +24,25 @@ export class TenantsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireRecentAuthentication()
|
||||
create(@Body() body: CreateTenantDto) {
|
||||
return this.tenants.create(body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequireRecentAuthentication()
|
||||
update(@Param('id') id: string, @Body() body: UpdateTenantDto) {
|
||||
return this.tenants.update(id, body);
|
||||
}
|
||||
|
||||
@Post(':id/status')
|
||||
@RequireRecentAuthentication()
|
||||
changeStatus(@Param('id') id: string, @Body() body: { status: string }) {
|
||||
return this.tenants.changeStatus(id, body.status);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequireRecentAuthentication()
|
||||
delete(@Param('id') id: string) {
|
||||
return this.tenants.delete(id);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
import {
|
||||
AssignPermissionDto,
|
||||
AssignRoleDto,
|
||||
@@ -24,31 +25,37 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Post('admin/users')
|
||||
@RequireRecentAuthentication()
|
||||
create(@Body() body: CreateUserDto) {
|
||||
return this.users.create(body);
|
||||
}
|
||||
|
||||
@Put('admin/users/:id')
|
||||
@RequireRecentAuthentication()
|
||||
update(@Param('id') id: string, @Body() body: UpdateUserDto) {
|
||||
return this.users.update(id, body);
|
||||
}
|
||||
|
||||
@Patch('admin/users/:id')
|
||||
@RequireRecentAuthentication()
|
||||
patch(@Param('id') id: string, @Body() body: UpdateUserDto) {
|
||||
return this.users.update(id, body);
|
||||
}
|
||||
|
||||
@Post('admin/users/:id/status')
|
||||
@RequireRecentAuthentication()
|
||||
changeStatus(@Param('id') id: string, @Body() body: ChangeUserStatusDto) {
|
||||
return this.users.changeStatus(id, body);
|
||||
}
|
||||
|
||||
@Post('admin/users/:id/password')
|
||||
@RequireRecentAuthentication()
|
||||
changePassword(@Param('id') id: string, @Body() body: ChangePasswordDto) {
|
||||
return this.users.changePassword(id, body);
|
||||
}
|
||||
|
||||
@Delete('admin/users/:id')
|
||||
@RequireRecentAuthentication()
|
||||
remove(@Param('id') id: string, @Body('operatorId') operatorId?: string) {
|
||||
return this.users.remove(id, operatorId);
|
||||
}
|
||||
@@ -59,26 +66,31 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Post('client/users')
|
||||
@RequireRecentAuthentication()
|
||||
createClient(@TenantId() tenantId: string | undefined, @Body() body: CreateUserDto) {
|
||||
return this.users.create({ ...body, roleCode: 'enterprise_admin' }, 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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Delete('client/users/:id')
|
||||
@RequireRecentAuthentication()
|
||||
removeClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body('operatorId') operatorId?: string) {
|
||||
return this.users.remove(id, operatorId, tenantId);
|
||||
}
|
||||
@@ -89,6 +101,7 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Post('admin/users/roles')
|
||||
@RequireRecentAuthentication()
|
||||
createRole(@Body() body: CreateRoleDto) {
|
||||
return this.users.createRole(body);
|
||||
}
|
||||
@@ -99,16 +112,19 @@ export class UsersController {
|
||||
}
|
||||
|
||||
@Post('admin/users/permissions')
|
||||
@RequireRecentAuthentication()
|
||||
createPermission(@Body() body: CreatePermissionDto) {
|
||||
return this.users.createPermission(body);
|
||||
}
|
||||
|
||||
@Post('admin/users/roles/assign')
|
||||
@RequireRecentAuthentication()
|
||||
assignRole(@Body() body: AssignRoleDto) {
|
||||
return this.users.assignRole(body);
|
||||
}
|
||||
|
||||
@Post('admin/users/permissions/assign')
|
||||
@RequireRecentAuthentication()
|
||||
assignPermission(@Body() body: AssignPermissionDto) {
|
||||
return this.users.assignPermission(body);
|
||||
}
|
||||
|
||||
@@ -84,4 +84,29 @@ describe('UsersService', () => {
|
||||
data: expect.objectContaining({ action: 'user.created', resource: 'user' }),
|
||||
}));
|
||||
});
|
||||
|
||||
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' }) },
|
||||
user: { update: jest.fn().mockResolvedValue({ id: 'user-1' }) },
|
||||
};
|
||||
const prisma = { $transaction: jest.fn((callback) => callback(tx)) };
|
||||
const service = new UsersService(prisma as never);
|
||||
await service.assignRole({ userId: 'user-1', roleId: 'role-1' });
|
||||
expect(tx.user.update).toHaveBeenCalledWith({ where: { id: 'user-1' }, data: { sessionVersion: { increment: 1 } } });
|
||||
});
|
||||
|
||||
it('revokes every affected user session when role permissions change', async () => {
|
||||
const tx = {
|
||||
rolePermission: { upsert: jest.fn().mockResolvedValue({ roleId: 'role-1', permissionId: 'permission-1' }) },
|
||||
user: { updateMany: jest.fn().mockResolvedValue({ count: 2 }) },
|
||||
};
|
||||
const prisma = { $transaction: jest.fn((callback) => callback(tx)) };
|
||||
const service = new UsersService(prisma as never);
|
||||
await service.assignPermission({ roleId: 'role-1', permissionId: 'permission-1' });
|
||||
expect(tx.user.updateMany).toHaveBeenCalledWith({
|
||||
where: { roles: { some: { roleId: 'role-1' } } },
|
||||
data: { sessionVersion: { increment: 1 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,6 +128,8 @@ export class UsersService {
|
||||
async update(id: string, data: UpdateUserDto, scopeTenantId?: string) {
|
||||
const current = await this.getExisting(id, scopeTenantId);
|
||||
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;
|
||||
this.assertUserInput({
|
||||
tenantId: data.tenantId ?? current.tenantId ?? undefined,
|
||||
username: data.username ?? current.username,
|
||||
@@ -153,7 +155,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 } } : {}),
|
||||
...((data.status === 'disabled' && current.status !== 'disabled') || roleChanged || tenantChanged ? { sessionVersion: { increment: 1 } } : {}),
|
||||
},
|
||||
include: { tenant: true, roles: { include: { role: true } } },
|
||||
});
|
||||
@@ -235,6 +237,17 @@ export class UsersService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async verifyCurrentPassword(id: string, password: string) {
|
||||
if (!password) {
|
||||
throw new BadRequestException('请输入当前密码');
|
||||
}
|
||||
const current = await this.getExisting(id);
|
||||
if (current.passwordHash !== hashPassword(password)) {
|
||||
throw new BadRequestException('当前密码不正确');
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
listRoles() {
|
||||
return this.prisma.role.findMany({
|
||||
include: { permissions: { include: { permission: true } } },
|
||||
@@ -262,18 +275,26 @@ export class UsersService {
|
||||
}
|
||||
|
||||
assignRole(data: AssignRoleDto) {
|
||||
return this.prisma.userRole.upsert({
|
||||
where: { userId_roleId: { userId: data.userId, roleId: data.roleId } },
|
||||
update: {},
|
||||
create: data,
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const assignment = await tx.userRole.upsert({
|
||||
where: { userId_roleId: { userId: data.userId, roleId: data.roleId } },
|
||||
update: {},
|
||||
create: data,
|
||||
});
|
||||
await tx.user.update({ where: { id: data.userId }, data: { sessionVersion: { increment: 1 } } });
|
||||
return assignment;
|
||||
});
|
||||
}
|
||||
|
||||
assignPermission(data: AssignPermissionDto) {
|
||||
return this.prisma.rolePermission.upsert({
|
||||
where: { roleId_permissionId: { roleId: data.roleId, permissionId: data.permissionId } },
|
||||
update: {},
|
||||
create: data,
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const assignment = await tx.rolePermission.upsert({
|
||||
where: { roleId_permissionId: { roleId: data.roleId, permissionId: data.permissionId } },
|
||||
update: {},
|
||||
create: data,
|
||||
});
|
||||
await tx.user.updateMany({ where: { roles: { some: { roleId: data.roleId } } }, data: { sessionVersion: { increment: 1 } } });
|
||||
return assignment;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user