feat: harden sessions and track downstream acknowledgements

This commit is contained in:
hectorzhao
2026-07-14 14:18:43 +08:00
parent 3d37adcc9f
commit 8c03663f24
43 changed files with 1733 additions and 150 deletions
@@ -0,0 +1,32 @@
ALTER TABLE "SmsApplication"
ADD COLUMN "downstreamReceiptRetryEnabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "downstreamUplinkRetryEnabled" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "CmppDownstreamDelivery"
ADD COLUMN "retryEnabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "sentAt" TIMESTAMP(3),
ADD COLUMN "acknowledgedAt" TIMESTAMP(3),
ADD COLUMN "ackDeadlineAt" TIMESTAMP(3),
ADD COLUMN "ackResult" INTEGER,
ADD COLUMN "ackSequenceId" TEXT,
ADD COLUMN "ackMessageId" TEXT,
ADD COLUMN "connectionId" TEXT;
UPDATE "CmppDownstreamDelivery"
SET
"status" = 'unconfirmed',
"sentAt" = "deliveredAt",
"deliveredAt" = NULL,
"lastError" = '历史记录仅确认 Gateway 已写出,未留存 CMPP_DELIVER_RESP'
WHERE "status" = 'delivered';
UPDATE "CmppDownstreamDelivery"
SET "retryEnabled" = CASE
WHEN "deliveryType" = 'uplink' THEN app."downstreamUplinkRetryEnabled"
ELSE app."downstreamReceiptRetryEnabled"
END
FROM "SmsApplication" app
WHERE app.id = "CmppDownstreamDelivery"."applicationId";
CREATE INDEX "CmppDownstreamDelivery_status_ackDeadlineAt_idx"
ON "CmppDownstreamDelivery"("status", "ackDeadlineAt");
+11
View File
@@ -360,6 +360,8 @@ model SmsApplication {
queuePriority String @default("normal")
maxPhonesPerTask Int @default(1000000)
templateMismatchMode String @default("reject")
downstreamReceiptRetryEnabled Boolean @default(true)
downstreamUplinkRetryEnabled Boolean @default(true)
status String @default("active")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1188,8 +1190,16 @@ model CmppDownstreamDelivery {
deliveryType String
status String @default("pending")
payload Json
retryEnabled Boolean @default(true)
retryCount Int @default(0)
nextRetryAt DateTime?
sentAt DateTime?
acknowledgedAt DateTime?
ackDeadlineAt DateTime?
ackResult Int?
ackSequenceId String?
ackMessageId String?
connectionId String?
deliveredAt DateTime?
lastError String?
createdAt DateTime @default(now())
@@ -1204,6 +1214,7 @@ model CmppDownstreamDelivery {
@@index([messageId])
@@index([messageRecordId])
@@index([deliveryType, status])
@@index([status, ackDeadlineAt])
}
model GatewaySubmitDeadLetter {
+104 -9
View File
@@ -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: '/',
});
}
}
+10 -1
View File
@@ -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 {}
+17 -4
View File
@@ -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);
}
+15 -3
View File
@@ -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();
});
});
+52 -9
View File
@@ -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 });
}
}
+73
View File
@@ -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' }));
}
});
});
+202
View File
@@ -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;
}
}
+4
View File
@@ -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);
}
+13
View File
@@ -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);
}
+12 -4
View File
@@ -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 },
],
});
});
+38 -10
View File
@@ -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;
+118 -13
View File
@@ -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' }] },
}),
}));
+6
View File
@@ -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 })),
+5
View File
@@ -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);
}
+16
View File
@@ -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);
}
+25
View File
@@ -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 } },
});
});
});
+30 -9
View File
@@ -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;
});
}