fix: harden tenant auth and quality gates

This commit is contained in:
hectorzhao
2026-08-28 11:44:16 +08:00
parent c3bf8af3e6
commit 2744690f9f
51 changed files with 1750 additions and 466 deletions
+7
View File
@@ -12,8 +12,15 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
logs/
coverage/
gateway/gateway.exe
dump.rdb
*.tsbuildinfo
outputs/
tmp_*.py
tmp_*.ps1
tmp_*.sh
.DS_Store
Thumbs.db
+8
View File
@@ -9,4 +9,12 @@ module.exports = {
testMatch: ['**/*.spec.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
clearMocks: true,
coverageThreshold: {
global: {
statements: 59,
branches: 50,
functions: 60,
lines: 62,
},
},
};
+1
View File
@@ -6,6 +6,7 @@
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "jest --runInBand",
"test:coverage": "jest --runInBand --coverage",
"test:watch": "jest --watch",
"start": "node dist/main.js",
"start:dev": "ts-node src/main.ts",
+7 -3
View File
@@ -1,13 +1,17 @@
import { defineConfig } from 'prisma/config';
const databaseUrl = process.env.DATABASE_URL?.trim();
const allowDevelopmentDefault = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test';
if (!databaseUrl && !allowDevelopmentDefault) {
throw new Error('DATABASE_URL is required outside development and test environments');
}
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
},
datasource: {
url:
process.env.DATABASE_URL ??
'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
url: databaseUrl ?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
},
});
+15 -6
View File
@@ -1,7 +1,9 @@
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
import { Body, Controller, Get, Post, Req, Res, UnauthorizedException, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from './current-session-user.decorator';
import { AuthService, LoginDto } from './auth.service';
import { AuthService } from './auth.service';
import { ChangeOwnPasswordDto, LoginDto, PasswordVerificationDto } from './auth.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionPortal, SessionService } from './session.service';
import type { SessionRequest } from './session-validation.middleware';
import { UsersService } from '../users/users.service';
@@ -26,6 +28,7 @@ export class AuthController {
}
@Post('admin/auth/login')
@UsePipes(strictValidationPipe)
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
let result: Awaited<ReturnType<AuthService['login']>>;
try {
@@ -43,6 +46,7 @@ export class AuthController {
}
@Post('client/auth/login')
@UsePipes(strictValidationPipe)
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
let result: Awaited<ReturnType<AuthService['login']>>;
try {
@@ -95,7 +99,9 @@ export class AuthController {
}
@Post(['admin/auth/session/unlock', 'client/auth/session/unlock'])
async unlock(@Req() request: SessionRequest, @Body('password') password: string, @Res({ passthrough: true }) response: CookieResponse) {
@UsePipes(strictValidationPipe)
async unlock(@Req() request: SessionRequest, @Body() body: PasswordVerificationDto, @Res({ passthrough: true }) response: CookieResponse) {
const { password } = body;
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: '锁定时间过长,请重新登录' });
@@ -105,7 +111,9 @@ export class AuthController {
}
@Post(['admin/auth/reauthenticate', 'client/auth/reauthenticate'])
async reauthenticate(@Req() request: SessionRequest, @Body('password') password: string) {
@UsePipes(strictValidationPipe)
async reauthenticate(@Req() request: SessionRequest, @Body() body: PasswordVerificationDto) {
const { password } = body;
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: '会话已锁定' });
@@ -122,9 +130,10 @@ export class AuthController {
}
@Post(['admin/auth/password', 'client/auth/password'])
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: { currentPassword?: string; password?: string }) {
@UsePipes(strictValidationPipe)
changeOwnPassword(@CurrentSessionUserId() userId: string | undefined, @Body() body: ChangeOwnPasswordDto) {
if (!userId) throw new UnauthorizedException('登录会话无效,请重新登录');
return this.users.changeOwnPassword(userId, body.currentPassword ?? '', body.password ?? '');
return this.users.changeOwnPassword(userId, body.currentPassword, body.password);
}
private async finishLogin(result: Awaited<ReturnType<AuthService['login']>>, request: SessionRequest, response: CookieResponse) {
+17
View File
@@ -0,0 +1,17 @@
import { IsString, MaxLength, MinLength } from 'class-validator';
export class LoginDto {
@IsString() @MinLength(1) @MaxLength(200) login!: string;
@IsString() @MinLength(1) @MaxLength(256) password!: string;
@IsString() @MinLength(1) @MaxLength(64) captchaId!: string;
@IsString() @MinLength(1) @MaxLength(32) captchaText!: string;
}
export class PasswordVerificationDto {
@IsString() @MinLength(1) @MaxLength(256) password!: string;
}
export class ChangeOwnPasswordDto {
@IsString() @MinLength(1) @MaxLength(256) currentPassword!: string;
@IsString() @MinLength(8) @MaxLength(256) password!: string;
}
+11 -3
View File
@@ -1,6 +1,6 @@
import { UnauthorizedException } from '@nestjs/common';
import { AuthService } from './auth.service';
import { hashPassword } from '../users/users.service';
import { legacyHashPassword } from './password-hasher';
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
const user = {
@@ -10,7 +10,7 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
email: 'user@example.com',
phone: '13800000000',
displayName: '用户',
passwordHash: hashPassword('secret1'),
passwordHash: legacyHashPassword('secret1'),
status: 'active',
deletedAt: null,
lockedUntil: null,
@@ -20,24 +20,32 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
};
return {
findByLogin: jest.fn().mockResolvedValue(user),
verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'),
recordLoginSuccess: jest.fn(),
recordLoginFailure: jest.fn(),
};
}
function createSessionsMock() {
const captchas = new Map<string, string>();
const failures = new Map<string, number>();
const record = {
userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1,
lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
};
return {
storeCaptcha: jest.fn(async (id: string, answer: string) => { captchas.set(id, answer); }),
consumeCaptcha: jest.fn(async (id: string) => { const answer = captchas.get(id) ?? null; captchas.delete(id); return answer; }),
isAnonymousLoginLocked: jest.fn(async (login: string) => (failures.get(login) ?? 0) >= 5),
recordAnonymousLoginFailure: jest.fn(async (login: string) => { const count = (failures.get(login) ?? 0) + 1; failures.set(login, count); return count; }),
clearAnonymousLoginFailures: jest.fn(async (login: string) => { failures.delete(login); }),
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 captcha = await service.createCaptcha();
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
return service.login({
login: 'user@example.com',
+15 -43
View File
@@ -1,37 +1,20 @@
import { randomUUID } from 'node:crypto';
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { hashPassword, UsersService } from '../users/users.service';
import { UsersService } from '../users/users.service';
import type { LoginDto } from './auth.dto';
import { SessionService } from './session.service';
export interface LoginDto {
login: string;
password: string;
captchaId: string;
captchaText: string;
}
type LoginPortal = 'admin' | 'client';
type CaptchaRecord = {
answer: string;
expiresAt: number;
};
const captchaStore = new Map<string, CaptchaRecord>();
const anonymousFailures = new Map<string, { count: number; lockedUntil?: number }>();
@Injectable()
export class AuthService {
constructor(private readonly users: UsersService, private readonly sessions: SessionService) {}
createCaptcha() {
async createCaptcha() {
const left = Math.floor(10 + Math.random() * 40);
const right = Math.floor(1 + Math.random() * 9);
const captchaId = randomUUID();
captchaStore.set(captchaId, {
answer: String(left + right),
expiresAt: Date.now() + 5 * 60 * 1000,
});
await this.sessions.storeCaptcha(captchaId, String(left + right), 5 * 60);
return {
captchaId,
challenge: `${left} + ${right} = ?`,
@@ -44,12 +27,12 @@ export class AuthService {
if (!login || !data.password) {
throw new BadRequestException('login and password are required');
}
this.verifyCaptcha(data.captchaId, data.captchaText);
this.assertAnonymousNotLocked(login);
await this.verifyCaptcha(data.captchaId, data.captchaText);
await this.assertAnonymousNotLocked(login);
const user = await this.users.findByLogin(login);
if (!user) {
this.recordAnonymousFailure(login);
await this.sessions.recordAnonymousLoginFailure(login);
throw new UnauthorizedException('Invalid login or password');
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
@@ -59,7 +42,7 @@ export class AuthService {
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('User is disabled or deleted');
}
if (user.passwordHash !== hashPassword(data.password)) {
if (!await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash)) {
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Invalid login or password');
}
@@ -75,7 +58,7 @@ export class AuthService {
}
await this.users.recordLoginSuccess(user.id);
anonymousFailures.delete(login);
await this.sessions.clearAnonymousLoginFailures(login);
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
return {
@@ -105,30 +88,19 @@ export class AuthService {
return this.sessions.markReauthenticated(token);
}
private verifyCaptcha(captchaId?: string, captchaText?: string) {
const record = captchaId ? captchaStore.get(captchaId) : undefined;
captchaStore.delete(captchaId ?? '');
if (!record || record.expiresAt < Date.now()) {
private async verifyCaptcha(captchaId?: string, captchaText?: string) {
const answer = captchaId ? await this.sessions.consumeCaptcha(captchaId) : null;
if (!answer) {
throw new BadRequestException('Captcha expired, refresh and try again');
}
if (record.answer !== captchaText?.trim()) {
if (answer !== captchaText?.trim()) {
throw new BadRequestException('Captcha is incorrect');
}
}
private assertAnonymousNotLocked(login: string) {
const current = anonymousFailures.get(login);
if (current?.lockedUntil && current.lockedUntil > Date.now()) {
private async assertAnonymousNotLocked(login: string) {
if (await this.sessions.isAnonymousLoginLocked(login)) {
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
}
}
private recordAnonymousFailure(login: string) {
const current = anonymousFailures.get(login) ?? { count: 0 };
const count = current.count + 1;
anonymousFailures.set(login, {
count,
lockedUntil: count >= 5 ? Date.now() + 24 * 60 * 60 * 1000 : current.lockedUntil,
});
}
}
@@ -0,0 +1,14 @@
import { ForbiddenException, createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { SessionRequest } from './session-validation.middleware';
/**
* Returns the tenant bound to the authenticated client session.
* Request headers, query parameters and request bodies must never determine this value.
*/
export const CurrentTenantId = createParamDecorator((_: unknown, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest<SessionRequest>();
if (request.authSession?.portal !== 'client' || !request.sessionTenantId) {
throw new ForbiddenException({ code: 'CLIENT_TENANT_REQUIRED', message: '缺少可信企业上下文' });
}
return request.sessionTenantId;
});
+27
View File
@@ -0,0 +1,27 @@
import { hashPassword, isLegacySha256, legacyHashPassword, passwordNeedsRehash, verifyPassword } from './password-hasher';
describe('password hasher', () => {
it('stores new passwords using versioned salted scrypt hashes', async () => {
const first = await hashPassword('correct horse battery staple');
const second = await hashPassword('correct horse battery staple');
expect(first).toMatch(/^\$scrypt\$v=1\$/);
expect(second).not.toBe(first);
await expect(verifyPassword('correct horse battery staple', first)).resolves.toBe(true);
await expect(verifyPassword('wrong password', first)).resolves.toBe(false);
expect(passwordNeedsRehash(first)).toBe(false);
});
it('recognizes and verifies legacy SHA-256 hashes for transparent migration', async () => {
const legacy = legacyHashPassword('legacy-password');
expect(isLegacySha256(legacy)).toBe(true);
expect(passwordNeedsRehash(legacy)).toBe(true);
await expect(verifyPassword('legacy-password', legacy)).resolves.toBe(true);
await expect(verifyPassword('wrong-password', legacy)).resolves.toBe(false);
});
it('rejects malformed or excessive scrypt parameters', async () => {
await expect(verifyPassword('password', '$scrypt$v=1$N=1048576,r=8,p=1$YWJjZGVmZ2hpamtsbW5vcA$YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU')).resolves.toBe(false);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
const VERSION = 1;
const KEY_LENGTH = 32;
const DEFAULT_N = 32768;
const DEFAULT_R = 8;
const DEFAULT_P = 1;
const MAX_MEMORY = 64 * 1024 * 1024;
const LEGACY_SHA256 = /^[a-f0-9]{64}$/i;
export async function hashPassword(password: string) {
const salt = randomBytes(16);
const derived = await deriveScrypt(password, salt, KEY_LENGTH, {
N: DEFAULT_N,
r: DEFAULT_R,
p: DEFAULT_P,
maxmem: MAX_MEMORY,
});
return `$scrypt$v=${VERSION}$N=${DEFAULT_N},r=${DEFAULT_R},p=${DEFAULT_P}$${salt.toString('base64url')}$${derived.toString('base64url')}`;
}
export async function verifyPassword(password: string, encoded: string) {
if (isLegacySha256(encoded)) {
const candidate = Buffer.from(legacyHashPassword(password), 'hex');
const expected = Buffer.from(encoded, 'hex');
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
}
const parsed = parseScryptHash(encoded);
if (!parsed) return false;
const derived = await deriveScrypt(password, parsed.salt, parsed.hash.length, {
N: parsed.N,
r: parsed.r,
p: parsed.p,
maxmem: MAX_MEMORY,
});
return derived.length === parsed.hash.length && timingSafeEqual(derived, parsed.hash);
}
export function passwordNeedsRehash(encoded: string) {
if (isLegacySha256(encoded)) return true;
const parsed = parseScryptHash(encoded);
return !parsed || parsed.version !== VERSION || parsed.N !== DEFAULT_N || parsed.r !== DEFAULT_R || parsed.p !== DEFAULT_P;
}
export function isLegacySha256(encoded: string) {
return LEGACY_SHA256.test(encoded);
}
export function legacyHashPassword(password: string) {
return createHash('sha256').update(password).digest('hex');
}
function parseScryptHash(encoded: string) {
const match = /^\$scrypt\$v=(\d+)\$N=(\d+),r=(\d+),p=(\d+)\$([A-Za-z0-9_-]+)\$([A-Za-z0-9_-]+)$/.exec(encoded);
if (!match) return undefined;
const [, version, N, r, p, salt, hash] = match;
const params = { version: Number(version), N: Number(N), r: Number(r), p: Number(p) };
if (!Number.isInteger(params.N) || params.N < 2 || params.N > DEFAULT_N
|| !Number.isInteger(params.r) || params.r < 1 || params.r > DEFAULT_R
|| !Number.isInteger(params.p) || params.p < 1 || params.p > DEFAULT_P) return undefined;
try {
const decodedSalt = Buffer.from(salt, 'base64url');
const decodedHash = Buffer.from(hash, 'base64url');
if (decodedSalt.length < 16 || decodedHash.length !== KEY_LENGTH) return undefined;
return { ...params, salt: decodedSalt, hash: decodedHash };
} catch {
return undefined;
}
}
function deriveScrypt(password: string, salt: Buffer, keyLength: number, options: { N: number; r: number; p: number; maxmem: number }) {
return new Promise<Buffer>((resolve, reject) => {
scryptCallback(password, salt, keyLength, options, (error, derivedKey) => {
if (error) reject(error);
else resolve(derivedKey);
});
});
}
@@ -1,4 +1,4 @@
import { UnauthorizedException } from '@nestjs/common';
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { SessionValidationMiddleware, type SessionRequest } from './session-validation.middleware';
const record = {
@@ -6,10 +6,10 @@ const record = {
lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
};
function request(path = '/api/admin/users', cookie = 'cmpp_admin_session=opaque-token'): SessionRequest {
function request(path = '/api/admin/users', cookie = 'cmpp_admin_session=opaque-token', tenantId?: string): SessionRequest {
return {
originalUrl: path,
header: jest.fn((name: string) => name === 'cookie' ? cookie : undefined),
header: jest.fn((name: string) => name === 'cookie' ? cookie : name === 'x-tenant-id' ? tenantId : undefined),
};
}
@@ -17,7 +17,7 @@ describe('SessionValidationMiddleware', () => {
const cookieName = jest.fn((portal: 'admin' | 'client') => `cmpp_${portal}_session`);
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 prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
const currentRequest = request();
@@ -32,7 +32,7 @@ describe('SessionValidationMiddleware', () => {
});
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 prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 4 }) } };
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
@@ -41,7 +41,7 @@ describe('SessionValidationMiddleware', () => {
});
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 prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
@@ -53,7 +53,7 @@ describe('SessionValidationMiddleware', () => {
it('selects only the cookie belonging to the requested portal', async () => {
const clientRecord = { ...record, portal: 'client' as const };
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: 'tenant-a', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
const currentRequest = request('/api/client/users', 'cmpp_admin_session=admin-token; cmpp_client_session=client-token');
@@ -62,6 +62,33 @@ describe('SessionValidationMiddleware', () => {
expect(sessions.validate).toHaveBeenCalledWith('client-token', false);
expect(currentRequest.sessionToken).toBe('client-token');
expect(currentRequest.sessionTenantId).toBe('tenant-a');
});
it('rejects a client session whose user is not bound to a tenant', async () => {
const clientRecord = { ...record, portal: 'client' as const };
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: null, status: 'active', deletedAt: null, sessionVersion: 3 }) } };
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
await expect(middleware.use(request('/api/client/users', 'cmpp_client_session=client-token'), {}, jest.fn()))
.rejects.toBeInstanceOf(ForbiddenException);
});
it('rejects and audits a client tenant header that disagrees with the authenticated user', async () => {
const clientRecord = { ...record, portal: 'client' as const };
const prisma = {
user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', tenantId: 'tenant-a', status: 'active', deletedAt: null, sessionVersion: 3 }) },
operationLog: { create: jest.fn().mockResolvedValue({ id: 'log-1' }) },
};
const sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
await expect(middleware.use(request('/api/client/users', 'cmpp_client_session=client-token', 'tenant-b'), {}, jest.fn()))
.rejects.toMatchObject({ response: expect.objectContaining({ code: 'CLIENT_TENANT_MISMATCH' }) });
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ tenantId: 'tenant-a', userId: 'user-1', action: 'security.client_tenant_mismatch' }),
});
});
it('does not accept an admin cookie for a client route', async () => {
+22 -2
View File
@@ -1,4 +1,4 @@
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
import { ForbiddenException, Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuthSessionRecord, SessionPortal, SessionService } from './session.service';
@@ -8,6 +8,7 @@ export type SessionRequest = {
url?: string;
sessionUserId?: string;
sessionToken?: string;
sessionTenantId?: string;
authSession?: AuthSessionRecord;
};
@@ -38,7 +39,7 @@ export class SessionValidationMiddleware implements NestMiddleware {
const user = await this.prisma.user.findUnique({
where: { id: result.record.userId },
select: { id: true, status: true, deletedAt: true, sessionVersion: true },
select: { id: true, tenantId: true, status: true, deletedAt: true, sessionVersion: true },
});
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== result.record.sessionVersion) {
await this.sessions.remove(token);
@@ -51,6 +52,25 @@ export class SessionValidationMiddleware implements NestMiddleware {
request.sessionUserId = user.id;
request.sessionToken = token;
request.authSession = result.record;
if (portal === 'client') {
if (!user.tenantId) {
throw new ForbiddenException({ code: 'CLIENT_TENANT_REQUIRED', message: '当前客户端账号未关联企业' });
}
const suppliedTenantId = request.header('x-tenant-id')?.trim();
if (suppliedTenantId && suppliedTenantId !== user.tenantId) {
await this.prisma.operationLog.create({
data: {
tenantId: user.tenantId,
userId: user.id,
action: 'security.client_tenant_mismatch',
resource: 'auth_session',
detail: { suppliedTenantId },
},
});
throw new ForbiddenException({ code: 'CLIENT_TENANT_MISMATCH', message: '请求企业与登录企业不一致' });
}
request.sessionTenantId = user.tenantId;
}
const isSessionRecoveryRoute = /\/auth\/(?:session(?:\/unlock)?|logout)(?:\?|$)/.test(path);
if (result.status === 'locked' && !isSessionRecoveryRoute) {
if (result.newlyLocked) {
+62
View File
@@ -21,6 +21,9 @@ export type SessionValidationResult =
| { status: 'expired'; code: 'SESSION_INVALID' | 'SESSION_ABSOLUTE_TIMEOUT' | 'SESSION_LOCK_TIMEOUT' };
const SESSION_PREFIX = 'cmpp:auth:session:';
const CAPTCHA_PREFIX = 'cmpp:auth:captcha:';
const ANONYMOUS_FAILURE_PREFIX = 'cmpp:auth:failure:';
const ANONYMOUS_LOCK_PREFIX = 'cmpp:auth:lock:';
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session';
@@ -123,6 +126,61 @@ export class SessionService implements OnModuleDestroy {
return this.client.del(this.key(token));
}
async storeCaptcha(captchaId: string, answer: string, ttlSeconds: number) {
try {
await this.client.set(`${CAPTCHA_PREFIX}${captchaId}`, answer, 'EX', ttlSeconds);
} catch {
throw new ServiceUnavailableException('验证码服务暂不可用');
}
}
async consumeCaptcha(captchaId: string) {
try {
return await this.client.getdel(`${CAPTCHA_PREFIX}${captchaId}`);
} catch {
throw new ServiceUnavailableException('验证码服务暂不可用');
}
}
async isAnonymousLoginLocked(login: string) {
try {
return Boolean(await this.client.exists(`${ANONYMOUS_LOCK_PREFIX}${this.loginDigest(login)}`));
} catch {
throw new ServiceUnavailableException('登录保护服务暂不可用');
}
}
async recordAnonymousLoginFailure(login: string) {
const digest = this.loginDigest(login);
const failureKey = `${ANONYMOUS_FAILURE_PREFIX}${digest}`;
const lockKey = `${ANONYMOUS_LOCK_PREFIX}${digest}`;
try {
const count = Number(await this.client.eval(
`local count = redis.call('INCR', KEYS[1])
if count == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
if count >= tonumber(ARGV[2]) then redis.call('SET', KEYS[2], '1', 'EX', ARGV[1]) end
return count`,
2,
failureKey,
lockKey,
24 * 60 * 60,
5,
));
return count;
} catch {
throw new ServiceUnavailableException('登录保护服务暂不可用');
}
}
async clearAnonymousLoginFailures(login: string) {
const digest = this.loginDigest(login);
try {
await this.client.del(`${ANONYMOUS_FAILURE_PREFIX}${digest}`, `${ANONYMOUS_LOCK_PREFIX}${digest}`);
} catch {
throw new ServiceUnavailableException('登录保护服务暂不可用');
}
}
isRecentlyAuthenticated(record: AuthSessionRecord) {
return Date.now() - record.lastAuthenticatedAt < this.recentAuthenticationMs;
}
@@ -195,6 +253,10 @@ export class SessionService implements OnModuleDestroy {
return `${SESSION_PREFIX}${createHash('sha256').update(token).digest('hex')}`;
}
private loginDigest(login: string) {
return createHash('sha256').update(login.trim().toLocaleLowerCase('en-US')).digest('hex');
}
private get client() {
if (!this.redis) {
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
+8 -4
View File
@@ -1,6 +1,9 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { ClientBillingEstimateDto } from '../common/client-write.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import {
@@ -125,14 +128,15 @@ export class ClientBillingController {
constructor(private readonly billing: BillingService) {}
@Get('orders')
listRechargeOrders(@TenantId() tenantId?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listRechargeOrders(@CurrentTenantId() tenantId: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return page || pageSize
? this.billing.listRechargeOrdersPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
: this.billing.listRechargeOrders(tenantId);
}
@Post('estimate')
estimateSmsCost(@Body() body: EstimateSmsCostDto) {
return this.billing.estimateSmsCost(body);
@UsePipes(strictValidationPipe)
estimateSmsCost(@CurrentTenantId() tenantId: string, @Body() body: ClientBillingEstimateDto) {
return this.billing.estimateSmsCost({ ...body, tenantId });
}
}
@@ -1,8 +1,10 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { TenantId } from '../common/tenant-id.decorator';
import { CertificationService, ReviewCertificationDto, SubmitCertificationDto } from './certification.service';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { ClientCertificationSubmissionDto } from '../common/client-write.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { CertificationService, ReviewCertificationDto } from './certification.service';
@ApiTags('client-certification')
@Controller('client/enterprise-certification')
@@ -10,13 +12,14 @@ export class ClientCertificationController {
constructor(private readonly certifications: CertificationService) {}
@Get()
list(@TenantId() tenantId?: string) {
list(@CurrentTenantId() tenantId: string) {
return this.certifications.list(tenantId);
}
@Post()
submit(@Body() body: SubmitCertificationDto) {
return this.certifications.submit(body);
@UsePipes(strictValidationPipe)
submit(@CurrentTenantId() tenantId: string, @Body() body: ClientCertificationSubmissionDto) {
return this.certifications.submit({ ...body, tenantId });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { BadRequestException } from '@nestjs/common';
import { ClientBatchTaskDto, ClientImportConfirmDto, ClientStatusChangeDto } from './client-write.dto';
import { strictValidationPipe } from './strict-validation.pipe';
function validate<T>(metatype: new () => T, value: unknown) {
return strictValidationPipe.transform(value, { type: 'body', metatype, data: undefined });
}
describe('strict client write DTOs', () => {
it('accepts an import confirmation without a client-supplied phones array', async () => {
await expect(validate(ClientImportConfirmDto, {
content: '【测试】验证码 ${code}',
importContent: 'phone,code\n13800000001,1234',
})).resolves.toEqual(expect.objectContaining({ importContent: expect.any(String) }));
});
it('rejects a direct batch task without validated phone numbers', async () => {
await expect(validate(ClientBatchTaskDto, { content: '【测试】通知' })).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a client-supplied operator identity', async () => {
await expect(validate(ClientStatusChangeDto, { status: 'disabled', operatorId: 'another-user' }))
.rejects.toBeInstanceOf(BadRequestException);
});
});
+154
View File
@@ -0,0 +1,154 @@
import { Type } from 'class-transformer';
import { PartialType } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsObject,
IsOptional,
IsString,
IsUrl,
Matches,
Max,
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
export class ClientCertificationSubmissionDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsString() @MinLength(1) @MaxLength(200) companyName!: string;
@IsOptional() @IsString() @MaxLength(100) licenseNo?: string;
@IsOptional() @IsString() @MaxLength(100) contactName?: string;
@IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) contactPhone?: string;
@IsOptional() @IsObject() materials?: Record<string, unknown>;
}
export class ClientTaskBaseDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsOptional() @IsString() @MaxLength(64) templateId?: string;
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
@IsOptional() @IsString() @MaxLength(64) category?: string;
@IsOptional() @IsIn(['immediate', 'scheduled']) sendMode?: 'immediate' | 'scheduled';
@IsOptional() @IsString() @MaxLength(64) scheduledAt?: string;
@IsOptional() @IsObject() variables?: Record<string, unknown>;
@IsOptional() @IsString() @MaxLength(64) requestedAt?: string;
@IsOptional() @IsString() @MaxLength(128) clientMessageId?: string;
}
export class ClientBatchTaskDto extends ClientTaskBaseDto {
@IsArray() @ArrayMaxSize(100000) @Matches(/^1\d{10}$/, { each: true }) phones!: string[];
}
export class ClientImportPreviewDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsString() @MinLength(1) @MaxLength(5_000_000) content!: string;
@IsOptional() @IsString() @MaxLength(255) fileName?: string;
@IsOptional() @IsIn(['utf8', 'gbk']) encoding?: 'utf8' | 'gbk';
@IsOptional() @IsIn([',', '\t']) delimiter?: ',' | '\t';
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) requiredVariables?: string[];
}
export class ClientImportConfirmDto extends ClientTaskBaseDto {
@IsString() @MinLength(1) @MaxLength(5_000_000) importContent!: string;
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) requiredVariables?: string[];
}
export class ClientBillingEstimateDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
@Type(() => Number) @IsInt() @Min(1) @Max(100000) phoneCount!: number;
@IsOptional() @Type(() => Number) @Min(0) unitPrice?: number;
@IsOptional() @IsString() @MaxLength(64) taskId?: string;
}
export class ClientSmsApplicationDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsString() @MinLength(1) @MaxLength(100) name!: string;
@IsOptional() @IsString() @MaxLength(500) scene?: string;
@IsOptional() @IsUrl({ require_tld: false }) @MaxLength(2048) callbackUrl?: string;
@IsOptional() @IsString() @MaxLength(64) cmppAccount?: string;
@IsOptional() @IsString() @MaxLength(64) cmppEnterpriseCode?: string;
@IsOptional() @IsString() @MaxLength(32) cmppApplicationExtension?: string;
@IsOptional() @IsBoolean() cmppAccessNumberFillEnabled?: boolean;
@IsOptional() @IsString() @MaxLength(32) cmppAccessNumberFillPrefix?: string;
@IsOptional() @IsString() @MaxLength(4096) passwordCipher?: string;
@IsOptional() @IsBoolean() interfaceEnabled?: boolean;
@IsOptional() @IsString() @MaxLength(32) interfaceType?: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) cmppMaxConnections?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(1000) cmppWindowSize?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(0) dailyLimit?: number;
@IsOptional() @Type(() => Number) @Min(0) customerUnitPrice?: number;
@IsOptional() @IsString() @MaxLength(32) queuePriority?: string;
@IsOptional() @IsString() @MaxLength(32) templateMismatchMode?: string;
@IsOptional() @IsBoolean() downstreamReceiptRetryEnabled?: boolean;
@IsOptional() @IsBoolean() downstreamUplinkRetryEnabled?: boolean;
@IsOptional() @IsArray() @ArrayMaxSize(100) @IsString({ each: true }) ipAllowlist?: string[];
}
export class ClientSmsSignatureDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsOptional() @IsString() @MaxLength(64) applicationId?: string;
@IsString() @MinLength(1) @MaxLength(100) name!: string;
@IsOptional() @IsString() @MaxLength(500) purpose?: string;
@IsOptional() @IsObject() drainageInfo?: Record<string, unknown>;
}
export class ClientSmsSignatureUpdateDto extends PartialType(ClientSmsSignatureDto) {
@IsOptional() @IsString() @MaxLength(32) auditStatus?: string;
}
export class ClientDrainageInfoDto {
@IsString() @MinLength(1) @MaxLength(200) siteName!: string;
@IsUrl({ require_tld: false }) @MaxLength(2048) url!: string;
@IsOptional() @IsString() @MaxLength(1000) remark?: string;
@IsOptional() @IsObject() reportValues?: Record<string, unknown>;
}
export class ClientDrainageInfoUpdateDto extends PartialType(ClientDrainageInfoDto) {}
export class ClientSignatureMaterialDto {
@IsOptional() @IsString() @MaxLength(64) fileObjectId?: string;
@IsString() @MinLength(1) @MaxLength(64) materialType!: string;
@IsString() @MinLength(1) @MaxLength(200) title!: string;
@IsOptional() @IsString() @MaxLength(2000) description?: string;
}
class TemplateVariableDto {
@IsString() @MinLength(1) @MaxLength(64) name!: string;
@IsOptional() @IsString() @MaxLength(500) example?: string;
@IsOptional() @IsBoolean() required?: boolean;
}
export class ClientSmsTemplateDto {
@IsOptional() @IsString() @MaxLength(64) tenantId?: string;
@IsString() @MaxLength(64) applicationId!: string;
@IsOptional() @IsString() @MaxLength(64) signatureId?: string;
@IsString() @MinLength(1) @MaxLength(200) name!: string;
@IsString() @MinLength(1) @MaxLength(5000) content!: string;
@IsOptional() @IsString() @MaxLength(64) category?: string;
@IsOptional() @IsArray() @ArrayMaxSize(100) @ValidateNested({ each: true }) @Type(() => TemplateVariableDto) variables?: TemplateVariableDto[];
}
export class ClientSmsTemplateUpdateDto extends PartialType(ClientSmsTemplateDto) {
@IsOptional() @IsString() @MaxLength(32) auditStatus?: string;
}
export class ClientStatusChangeDto {
@IsOptional() @IsString() @MaxLength(32) status?: string;
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
@IsOptional() @IsBoolean() force?: boolean;
@IsOptional() @IsString() @MaxLength(200) confirmName?: string;
@IsOptional() @IsString() @MaxLength(200) confirmText?: string;
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
@IsOptional() @IsBoolean() deleteAssociatedTemplates?: boolean;
@IsOptional() @IsBoolean() deleteAssociatedDrainage?: boolean;
@IsOptional() @IsBoolean() abandonAssociatedReportTasks?: boolean;
}
+9
View File
@@ -0,0 +1,9 @@
import { ValidationPipe } from '@nestjs/common';
export const strictValidationPipe = new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
stopAtFirstError: false,
transformOptions: { enableImplicitConversion: false },
});
@@ -1,8 +1,8 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { TenantId } from '../common/tenant-id.decorator';
import { DeleteTargetDto, DeletionGovernanceService, DeletionTargetType } from './deletion-governance.service';
@ApiTags('deletion-governance')
@@ -28,13 +28,13 @@ export class ClientDeletionGovernanceController {
constructor(private readonly deletions: DeletionGovernanceService) {}
@Get(':type/:id/preflight')
preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string, @TenantId() tenantId?: string) {
preflight(@Param('type') type: DeletionTargetType, @Param('id') id: string, @CurrentTenantId() tenantId: string) {
return this.deletions.preflight(type, id, tenantId);
}
@Post(':type/:id')
@RequireRecentAuthentication()
delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
delete(@Param('type') type: DeletionTargetType, @Param('id') id: string, @Body() body: DeleteTargetDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
return this.deletions.delete(type, id, { ...body, operatorId }, tenantId);
}
}
+11 -10
View File
@@ -1,7 +1,8 @@
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { TenantId } from '../common/tenant-id.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { OpenApiService } from './open-api.service';
@ApiTags('client-http-open-api-management')
@@ -9,13 +10,13 @@ import { OpenApiService } from './open-api.service';
export class ClientOpenApiController {
constructor(private readonly service: OpenApiService) {}
@Get() getConfig(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.getConfig(applicationId, tenantId); }
@Get('credentials') listCredentials(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listCredentials(applicationId, tenantId); }
@Post('credentials') @RequireRecentAuthentication() createCredential(@Param('applicationId') applicationId: string, @Body() body: { name?: string; expiresAt?: string; createdById?: string }, @TenantId() tenantId?: string) { return this.service.createCredential(applicationId, body, tenantId, true); }
@Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(@Param('applicationId') applicationId: string, @Param('credentialId') credentialId: string, @TenantId() tenantId?: string) { return this.service.revokeCredential(applicationId, credentialId, tenantId); }
@Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.getWebhookEndpoints(applicationId, tenantId); }
@Put('webhooks/:eventType') @RequireRecentAuthentication() upsertWebhook(@Param('applicationId') applicationId: string, @Param('eventType') eventType: string, @Body() body: { url: string; rotateSecret?: boolean; status?: string }, @TenantId() tenantId?: string) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId); }
@Get('requests') listRequests(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listRequestLogs(applicationId, tenantId); }
@Get('webhook-deliveries') listDeliveries(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listWebhookDeliveries(applicationId, tenantId); }
@Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(@Param('applicationId') applicationId: string, @Param('deliveryId') deliveryId: string, @TenantId() tenantId?: string) { return this.service.retryWebhookDelivery(applicationId, deliveryId, tenantId); }
@Get() getConfig(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.getConfig(applicationId, tenantId); }
@Get('credentials') listCredentials(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listCredentials(applicationId, tenantId); }
@Post('credentials') @RequireRecentAuthentication() createCredential(@Param('applicationId') applicationId: string, @Body() body: { name?: string; expiresAt?: string; createdById?: string }, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById?: string) { return this.service.createCredential(applicationId, { ...body, createdById }, tenantId, true); }
@Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(@Param('applicationId') applicationId: string, @Param('credentialId') credentialId: string, @CurrentTenantId() tenantId: string) { return this.service.revokeCredential(applicationId, credentialId, tenantId); }
@Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.getWebhookEndpoints(applicationId, tenantId); }
@Put('webhooks/:eventType') @RequireRecentAuthentication() upsertWebhook(@Param('applicationId') applicationId: string, @Param('eventType') eventType: string, @Body() body: { url: string; rotateSecret?: boolean; status?: string }, @CurrentTenantId() tenantId: string) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId); }
@Get('requests') listRequests(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listRequestLogs(applicationId, tenantId); }
@Get('webhook-deliveries') listDeliveries(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listWebhookDeliveries(applicationId, tenantId); }
@Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(@Param('applicationId') applicationId: string, @Param('deliveryId') deliveryId: string, @CurrentTenantId() tenantId: string) { return this.service.retryWebhookDelivery(applicationId, deliveryId, tenantId); }
}
@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { TenantId } from '../common/tenant-id.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { OperationsService } from './operations.service';
@ApiTags('client-operations')
@@ -10,18 +10,18 @@ export class ClientOperationsController {
constructor(private readonly operations: OperationsService) {}
@Get('batch-tasks')
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
listBatchTasks(@CurrentTenantId() tenantId: string, @Query('status') status?: string) {
return this.operations.listClientBatchTasks({ tenantId, status });
}
@Get('batch-tasks/:id/messages')
listTaskMessages(@TenantId() tenantId: string | undefined, @Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) {
listTaskMessages(@CurrentTenantId() tenantId: string, @Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) {
return this.operations.listClientMessages({ tenantId, taskId, phoneNumber });
}
@Get('messages')
listMessages(
@TenantId() tenantId?: string,
@CurrentTenantId() tenantId: string,
@Query('applicationId') applicationId?: string,
@Query('taskId') taskId?: string,
@Query('messageId') messageId?: string,
@@ -49,20 +49,20 @@ export class ClientOperationsController {
}
@Get('uplink-messages')
listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listUplinkMessages(@CurrentTenantId() tenantId: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return page || pageSize
? this.operations.listUplinkMessagesPage({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime, page: Number(page), pageSize: Number(pageSize) }, true)
: this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime });
}
@Get('dashboard')
dashboard(@TenantId() tenantId?: string) {
dashboard(@CurrentTenantId() tenantId: string) {
return this.operations.clientDashboard({ tenantId });
}
@Get('system-logs')
systemLogs(
@TenantId() tenantId?: string,
@CurrentTenantId() tenantId: string,
@Query('keyword') keyword?: string,
@Query('level') level?: string,
@Query('module') module?: string,
@@ -78,8 +78,9 @@ export class ClientOperationsController {
@Post('system-logs/exports')
exportSystemLogs(
@CurrentSessionUserId() userId: string | undefined,
@CurrentTenantId() tenantId: string,
@Body() body: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string },
) {
return this.operations.exportSystemLogs(body, userId);
return this.operations.exportSystemLogs({ ...body, tenantId }, userId);
}
}
+4
View File
@@ -36,6 +36,10 @@ export class PrismaService extends PrismaClient implements OnModuleDestroy {
const poolMax = Number.isInteger(configuredPoolMax) && configuredPoolMax > 0
? configuredPoolMax
: protocolLogRole ? 4 : outboxRole ? 6 : callbackRole ? 16 : workerRole ? 8 : 32;
const allowDevelopmentDefault = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test';
if (!databaseUrl && !allowDevelopmentDefault) {
throw new Error(`DATABASE_URL is required for CMPP process role ${processRole}`);
}
const databasePool = new Pool({
connectionString: databaseUrl
?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
@@ -1,7 +1,10 @@
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Post, Query, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { EvaluateSmsTaskDto, RiskReviewService } from './risk-review.service';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ClientBatchTaskDto } from '../common/client-write.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { RiskReviewService } from './risk-review.service';
@ApiTags('client-risk-review')
@Controller('client/risk-review')
@@ -9,13 +12,13 @@ export class ClientRiskReviewController {
constructor(private readonly riskReview: RiskReviewService) {}
@Post('tasks/evaluate')
evaluateTask(@Body() body: EvaluateSmsTaskDto) {
return this.riskReview.evaluateTask(body);
@UsePipes(strictValidationPipe)
evaluateTask(@CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById: string | undefined, @Body() body: ClientBatchTaskDto) {
return this.riskReview.evaluateTask({ ...body, tenantId, createdById });
}
@Get('tasks')
listTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
listTasks(@CurrentTenantId() tenantId: string, @Query('status') status?: string) {
return this.riskReview.listTasks(tenantId, status);
}
}
@@ -1,7 +1,9 @@
import { BadRequestException, Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { ConfirmImportDto, CreateBatchTaskDto, ImportPreviewDto } from './send-chain.contracts';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ClientBatchTaskDto, ClientImportConfirmDto, ClientImportPreviewDto } from '../common/client-write.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { SendChainService } from './send-chain.service';
@ApiTags('client-send-chain')
@@ -10,46 +12,42 @@ export class ClientSendChainController {
constructor(private readonly sendChain: SendChainService) {}
@Post('batch-tasks')
createBatchTask(@Body() body: CreateBatchTaskDto) {
return this.sendChain.createBatchTask(body);
@UsePipes(strictValidationPipe)
createBatchTask(@CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById: string | undefined, @Body() body: ClientBatchTaskDto) {
return this.sendChain.createBatchTask({ ...body, tenantId, sourceType: 'client', createdById });
}
@Post('imports/preview')
previewImport(@Body() body: ImportPreviewDto) {
return this.sendChain.previewImport(body);
@UsePipes(strictValidationPipe)
previewImport(@CurrentTenantId() tenantId: string, @Body() body: ClientImportPreviewDto) {
return this.sendChain.previewImport({ ...body, tenantId });
}
@Post('imports/confirm')
confirmImport(@Body() body: ConfirmImportDto) {
return this.sendChain.confirmImport(body);
@UsePipes(strictValidationPipe)
confirmImport(@CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById: string | undefined, @Body() body: ClientImportConfirmDto) {
return this.sendChain.confirmImport({ ...body, tenantId, sourceType: 'client', createdById });
}
@Get('batch-tasks')
listBatchTasks(@TenantId() tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listBatchTasks(@CurrentTenantId() tenantId: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('createdAtFrom') createdAtFrom?: string, @Query('createdAtTo') createdAtTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return page || pageSize
? this.sendChain.listBatchTasksPage({ tenantId: requireTenantId(tenantId), status, sourceType: 'client', keyword, applicationKeyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
: this.sendChain.listBatchTasks(requireTenantId(tenantId), status, 'client');
? this.sendChain.listBatchTasksPage({ tenantId, status, sourceType: 'client', keyword, applicationKeyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
: this.sendChain.listBatchTasks(tenantId, status, 'client');
}
@Get('batch-tasks/:id')
getBatchTask(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
return this.sendChain.getBatchTask(taskId, requireTenantId(tenantId), 'client');
getBatchTask(@CurrentTenantId() tenantId: string, @Param('id') taskId: string) {
return this.sendChain.getBatchTask(taskId, tenantId, 'client');
}
@Get('batch-tasks/:id/messages')
listTaskMessages(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
return this.sendChain.listClientTaskMessages(taskId, requireTenantId(tenantId));
listTaskMessages(@CurrentTenantId() tenantId: string, @Param('id') taskId: string) {
return this.sendChain.listClientTaskMessages(taskId, tenantId);
}
@Post('batch-tasks/:id/cancel')
cancelBatchTask(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
return this.sendChain.cancelBatchTask(taskId, requireTenantId(tenantId), 'client');
cancelBatchTask(@CurrentTenantId() tenantId: string, @Param('id') taskId: string) {
return this.sendChain.cancelBatchTask(taskId, tenantId, 'client');
}
}
function requireTenantId(tenantId?: string) {
if (!tenantId) {
throw new BadRequestException('Tenant context is required');
}
return tenantId;
}
+1 -1
View File
@@ -234,7 +234,7 @@ export interface ImportPreviewDto {
requiredVariables?: string[];
}
export interface ConfirmImportDto extends CreateBatchTaskDto {
export interface ConfirmImportDto extends Omit<CreateBatchTaskDto, 'phones'> {
importContent: string;
requiredVariables?: string[];
}
@@ -1,20 +1,11 @@
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Put, Query, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
import {
CreateSignatureMaterialDto,
CreateSmsApplicationDto,
CreateSmsDrainageInfoDto,
CreateSmsSignatureDto,
CreateSmsTemplateDto,
StatusChangeDto,
UpdateSmsTemplateDto,
UpdateSmsDrainageInfoDto,
UpdateSmsSignatureDto,
} from './sms-config.contracts';
import { ClientDrainageInfoDto, ClientDrainageInfoUpdateDto, ClientSignatureMaterialDto, ClientSmsApplicationDto, ClientSmsSignatureDto, ClientSmsSignatureUpdateDto, ClientSmsTemplateDto, ClientSmsTemplateUpdateDto, ClientStatusChangeDto } from '../common/client-write.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
import { SmsConfigService } from './sms-config.service';
@ApiTags('client-sms-config')
@@ -23,29 +14,30 @@ export class ClientSmsConfigController {
constructor(private readonly smsConfig: SmsConfigService, private readonly deletions: DeletionGovernanceService) {}
@Get('applications')
listApplications(@TenantId() tenantId?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listApplications(@CurrentTenantId() tenantId: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return page || pageSize
? this.smsConfig.listApplicationsPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
: this.smsConfig.listApplications(tenantId);
}
@Get('application-options')
listApplicationOptions(@TenantId() tenantId?: string) {
listApplicationOptions(@CurrentTenantId() tenantId: string) {
return this.smsConfig.listApplicationOptions(tenantId);
}
@Post('applications')
createApplication(@Body() body: CreateSmsApplicationDto) {
return this.smsConfig.createApplication(body);
@UsePipes(strictValidationPipe)
createApplication(@CurrentTenantId() tenantId: string, @Body() body: ClientSmsApplicationDto) {
return this.smsConfig.createApplication({ ...body, tenantId });
}
@Get('applications/:id/cmpp-params')
getApplicationCmppParams(@Param('id') applicationId: string, @TenantId() tenantId?: string) {
getApplicationCmppParams(@Param('id') applicationId: string, @CurrentTenantId() tenantId: string) {
return this.smsConfig.getApplicationCmppParams(applicationId, tenantId);
}
@Get('applications/:id/report-fields')
getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage', @TenantId() tenantId?: string) {
getApplicationReportFields(@Param('id') applicationId: string, @Query('reportType') reportType: 'signature' | 'drainage' = 'drainage', @CurrentTenantId() tenantId: string) {
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getClientApplicationReportFields(applicationId, reportType));
}
@@ -56,110 +48,122 @@ export class ClientSmsConfigController {
@Post('applications/:id/secret/reset')
@RequireRecentAuthentication()
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
return this.smsConfig.resetApplicationSecret(applicationId, body);
@UsePipes(strictValidationPipe)
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
return this.smsConfig.resetClientApplicationSecret(applicationId, { ...body, operatorId }, tenantId);
}
@Post('applications/:id/status')
@RequireRecentAuthentication()
changeApplicationStatus(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
return this.smsConfig.changeApplicationStatus(applicationId, body);
@UsePipes(strictValidationPipe)
changeApplicationStatus(@Param('id') applicationId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
return this.smsConfig.changeClientApplicationStatus(applicationId, { ...body, operatorId }, tenantId);
}
@Get('signatures')
listSignatures(@TenantId() tenantId?: string) {
listSignatures(@CurrentTenantId() tenantId: string) {
return this.smsConfig.listClientSignatures(tenantId);
}
@Get('signature-options')
listSignatureOptions(@TenantId() tenantId?: string) {
listSignatureOptions(@CurrentTenantId() tenantId: string) {
return this.smsConfig.listSignatureOptions(tenantId);
}
@Get('signatures-workspace')
getSignatureWorkspace(@TenantId() tenantId?: string, @Query('keyword') keyword?: string, @Query('applicationId') applicationId?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
getSignatureWorkspace(@CurrentTenantId() tenantId: string, @Query('keyword') keyword?: string, @Query('applicationId') applicationId?: string, @Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return this.smsConfig.getClientSignatureWorkspace(tenantId, { keyword, applicationId, status, page: Number(page), pageSize: Number(pageSize) });
}
@Post('signatures')
async createSignature(@Body() body: CreateSmsSignatureDto, @TenantId() tenantId?: string) {
const signature = await this.smsConfig.createSignature({ ...body, tenantId: tenantId ?? body.tenantId });
return this.smsConfig.getClientSignatureView(signature.id, tenantId ?? body.tenantId);
@UsePipes(strictValidationPipe)
async createSignature(@Body() body: ClientSmsSignatureDto, @CurrentTenantId() tenantId: string) {
const signature = await this.smsConfig.createSignature({ ...body, tenantId });
return this.smsConfig.getClientSignatureView(signature.id, tenantId);
}
@Put('signatures/:id')
async updateSignature(@Param('id') signatureId: string, @Body() body: UpdateSmsSignatureDto, @TenantId() tenantId?: string) {
@UsePipes(strictValidationPipe)
async updateSignature(@Param('id') signatureId: string, @Body() body: ClientSmsSignatureUpdateDto, @CurrentTenantId() tenantId: string) {
await this.smsConfig.updateClientSignature(signatureId, body, tenantId);
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
}
@Post('signatures/:id/materials')
createSignatureMaterial(@Param('id') signatureId: string, @Body() body: Omit<CreateSignatureMaterialDto, 'signatureId'>) {
return this.smsConfig.createSignatureMaterial({ ...body, signatureId });
@UsePipes(strictValidationPipe)
createSignatureMaterial(@Param('id') signatureId: string, @Body() body: ClientSignatureMaterialDto, @CurrentTenantId() tenantId: string) {
return this.smsConfig.createClientSignatureMaterial({ ...body, signatureId }, tenantId);
}
@Get('drainage-infos')
listDrainageInfos(@TenantId() tenantId?: string) {
listDrainageInfos(@CurrentTenantId() tenantId: string) {
return this.smsConfig.listClientDrainageInfos(tenantId);
}
@Post('signatures/:id/drainage-infos')
async createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
@UsePipes(strictValidationPipe)
async createDrainageInfo(@Param('id') signatureId: string, @Body() body: ClientDrainageInfoDto, @CurrentTenantId() tenantId: string) {
const item = await this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId);
return this.smsConfig.getClientDrainageInfoView(item.id, tenantId);
}
@Put('drainage-infos/:id')
async updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
@UsePipes(strictValidationPipe)
async updateDrainageInfo(@Param('id') itemId: string, @Body() body: ClientDrainageInfoUpdateDto, @CurrentTenantId() tenantId: string) {
await this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId);
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
}
@Post('drainage-infos/:id/status')
async changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
await this.smsConfig.changeDrainageInfoStatus(itemId, body, tenantId);
@UsePipes(strictValidationPipe)
async changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
await this.smsConfig.changeDrainageInfoStatus(itemId, { ...body, operatorId }, tenantId);
if (body.status === 'deleted') return { id: itemId, status: 'deleted' };
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
}
@Post('signatures/:id/submit')
async submitSignature(@Param('id') signatureId: string, @TenantId() tenantId?: string) {
async submitSignature(@Param('id') signatureId: string, @CurrentTenantId() tenantId: string) {
await this.smsConfig.submitSignature(signatureId, tenantId);
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
}
@Post('signatures/:id/status')
async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
@UsePipes(strictValidationPipe)
async changeSignatureStatus(@Param('id') signatureId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
if (body.status === 'deleted') return this.deletions.delete('signature', signatureId, { ...body, operatorId }, tenantId);
await this.smsConfig.changeSignatureStatus(signatureId, body, tenantId);
await this.smsConfig.changeSignatureStatus(signatureId, { ...body, operatorId }, tenantId);
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
}
@Get('templates')
listTemplates(@TenantId() tenantId?: string, @Query('includeHistory') includeHistory?: string, @Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listTemplates(@CurrentTenantId() tenantId: string, @Query('includeHistory') includeHistory?: string, @Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
return page || pageSize
? this.smsConfig.listTemplatesPage({ tenantId, status: includeHistory === 'true' ? 'all' : 'approved', keyword, page: Number(page), pageSize: Number(pageSize) })
: this.smsConfig.listClientTemplates(tenantId, includeHistory === 'true');
}
@Post('templates')
createTemplate(@Body() body: CreateSmsTemplateDto, @TenantId() tenantId?: string) {
return this.smsConfig.createTemplate({ ...body, tenantId: tenantId ?? body.tenantId });
@UsePipes(strictValidationPipe)
createTemplate(@Body() body: ClientSmsTemplateDto, @CurrentTenantId() tenantId: string) {
return this.smsConfig.createTemplate({ ...body, tenantId });
}
@Put('templates/:id')
updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto, @TenantId() tenantId?: string) {
@UsePipes(strictValidationPipe)
updateTemplate(@Param('id') templateId: string, @Body() body: ClientSmsTemplateUpdateDto, @CurrentTenantId() tenantId: string) {
return this.smsConfig.updateClientTemplate(templateId, body, tenantId);
}
@Post('templates/:id/submit')
submitTemplate(@Param('id') templateId: string, @TenantId() tenantId?: string) {
submitTemplate(@Param('id') templateId: string, @CurrentTenantId() tenantId: string) {
return this.smsConfig.submitTemplate(templateId, tenantId);
}
@Post('templates/:id/status')
changeTemplateStatus(@Param('id') templateId: string, @Body() body: StatusChangeDto & DeleteTargetDto, @TenantId() tenantId?: string, @CurrentSessionUserId() operatorId?: string) {
@UsePipes(strictValidationPipe)
changeTemplateStatus(@Param('id') templateId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
if (body.status === 'deleted') return this.deletions.delete('template', templateId, { ...body, operatorId }, tenantId);
return this.smsConfig.changeTemplateStatus(templateId, body, tenantId);
return this.smsConfig.changeTemplateStatus(templateId, { ...body, operatorId }, tenantId);
}
}
+15
View File
@@ -81,10 +81,20 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
return this.applications.resetApplicationSecret(applicationId, data);
}
async resetClientApplicationSecret(applicationId: string, data: StatusChangeDto, tenantId: string) {
await this.applications.getApplication(applicationId, tenantId);
return this.applications.resetApplicationSecret(applicationId, data);
}
async changeApplicationStatus(applicationId: string, data: StatusChangeDto) {
return this.lifecycle.changeApplicationStatus(applicationId, data);
}
async changeClientApplicationStatus(applicationId: string, data: StatusChangeDto, tenantId: string) {
await this.applications.getApplication(applicationId, tenantId);
return this.lifecycle.changeApplicationStatus(applicationId, data);
}
async getApplicationDeactivationPreview(applicationId: string) {
return this.lifecycle.getApplicationDeactivationPreview(applicationId);
}
@@ -177,6 +187,11 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
return this.signatures.createSignatureMaterial(data);
}
async createClientSignatureMaterial(data: CreateSignatureMaterialDto, tenantId: string) {
await this.signatures.getClientSignatureView(data.signatureId, tenantId);
return this.signatures.createSignatureMaterial(data);
}
async submitSignature(signatureId: string, tenantId?: string) {
return this.signatures.submitSignature(signatureId, tenantId);
}
+7 -7
View File
@@ -1,6 +1,6 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { AdminUserResponseDto, ClientUserResponseDto } from './user-response.dto';
@@ -72,7 +72,7 @@ export class UsersController {
@Get('client/users')
@ApiOkResponse({ type: [ClientUserResponseDto] })
listClient(
@TenantId() tenantId?: string,
@CurrentTenantId() tenantId: string,
@Query('displayName') displayName?: string,
@Query('login') login?: string,
@Query('status') status?: string,
@@ -82,31 +82,31 @@ export class UsersController {
@Post('client/users')
@RequireRecentAuthentication()
createClient(@TenantId() tenantId: string | undefined, @Body() body: CreateUserDto, @CurrentSessionUserId() operatorId?: string) {
createClient(@CurrentTenantId() tenantId: string, @Body() body: CreateUserDto, @CurrentSessionUserId() operatorId?: string) {
return this.users.create({ ...body, roleCode: 'enterprise_admin', operatorId }, tenantId);
}
@Put('client/users/:id')
@RequireRecentAuthentication()
updateClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: UpdateUserDto, @CurrentSessionUserId() operatorId?: string) {
updateClient(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: UpdateUserDto, @CurrentSessionUserId() operatorId?: string) {
return this.users.update(id, { ...body, roleCode: 'enterprise_admin', operatorId }, tenantId);
}
@Post('client/users/:id/status')
@RequireRecentAuthentication()
changeClientStatus(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangeUserStatusDto, @CurrentSessionUserId() operatorId?: string) {
changeClientStatus(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: ChangeUserStatusDto, @CurrentSessionUserId() operatorId?: string) {
return this.users.changeStatus(id, { ...body, operatorId }, tenantId, operatorId);
}
@Post('client/users/:id/password')
@RequireRecentAuthentication()
changeClientPassword(@TenantId() tenantId: string | undefined, @Param('id') id: string, @Body() body: ChangePasswordDto, @CurrentSessionUserId() operatorId?: string) {
changeClientPassword(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: ChangePasswordDto, @CurrentSessionUserId() operatorId?: string) {
return this.users.changePassword(id, { ...body, operatorId }, tenantId);
}
@Delete('client/users/:id')
@RequireRecentAuthentication()
removeClient(@TenantId() tenantId: string | undefined, @Param('id') id: string, @CurrentSessionUserId() operatorId?: string) {
removeClient(@CurrentTenantId() tenantId: string, @Param('id') id: string, @CurrentSessionUserId() operatorId?: string) {
return this.users.remove(id, operatorId, tenantId);
}
+28 -2
View File
@@ -1,5 +1,6 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { hashPassword, UsersService } from './users.service';
import { legacyHashPassword, verifyPassword } from '../auth/password-hasher';
import { UsersService } from './users.service';
function createPrismaMock() {
const roles = new Map<string, { id: string; code: string; name: string; scope: string }>();
@@ -10,6 +11,7 @@ function createPrismaMock() {
findFirst: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
count: jest.fn().mockResolvedValue(2),
},
role: {
@@ -206,7 +208,7 @@ describe('UsersService', () => {
it('returns a safe view after changing the current password', async () => {
const prisma = createPrismaMock();
prisma.user.findFirst.mockResolvedValue({
id: 'user-1', tenantId: 'tenant-1', username: 'admin', passwordHash: hashPassword('old-password'),
id: 'user-1', tenantId: 'tenant-1', username: 'admin', passwordHash: legacyHashPassword('old-password'),
sessionVersion: 3, failedLoginCount: 0, roles: [{ role: { code: 'enterprise_admin' } }],
});
prisma.user.update.mockResolvedValue({
@@ -223,6 +225,30 @@ describe('UsersService', () => {
expect(user).not.toHaveProperty('failedLoginCount');
});
it('transparently upgrades a legacy password hash after successful login verification', async () => {
const prisma = createPrismaMock();
const service = new UsersService(prisma as never);
const legacyHash = legacyHashPassword('correct-password');
await expect(service.verifyLoginPassword('user-1', 'correct-password', legacyHash)).resolves.toBe(true);
expect(prisma.user.updateMany).toHaveBeenCalledWith({
where: { id: 'user-1', passwordHash: legacyHash },
data: { passwordHash: expect.stringMatching(/^\$scrypt\$/) },
});
const upgradedHash = prisma.user.updateMany.mock.calls[0][0].data.passwordHash;
await expect(verifyPassword('correct-password', upgradedHash)).resolves.toBe(true);
});
it('does not rewrite a legacy password hash after failed login verification', async () => {
const prisma = createPrismaMock();
const service = new UsersService(prisma as never);
await expect(service.verifyLoginPassword('user-1', 'wrong-password', legacyHashPassword('correct-password'))).resolves.toBe(false);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it('forbids deleting the current signed-in user', async () => {
const prisma = createPrismaMock();
prisma.user.findFirst.mockResolvedValue({
+22 -10
View File
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { hashPassword, passwordNeedsRehash, verifyPassword } from '../auth/password-hasher';
export type UserRoleCode = 'platform_admin' | 'enterprise_admin';
@@ -131,6 +131,7 @@ export class UsersService {
this.assertUserInput({ ...data, roleCode }, scopeTenantId, true);
const tenantId = scopeTenantId ?? data.tenantId;
const role = await this.ensureRole(roleCode);
const passwordHash = await hashPassword(data.password);
const user = await this.mapUniqueConflict(() => this.prisma.user.create({
data: {
tenantId,
@@ -138,7 +139,7 @@ export class UsersService {
email: normalizeOptional(data.email),
phone: normalizeOptional(data.phone),
displayName: data.displayName,
passwordHash: hashPassword(data.password),
passwordHash,
status: data.status ?? 'active',
roles: { create: [{ roleId: role.id }] },
},
@@ -208,9 +209,10 @@ export class UsersService {
throw new BadRequestException('password must be at least 6 characters');
}
const current = await this.getExisting(id, scopeTenantId);
const passwordHash = await hashPassword(data.password);
const updated = await this.prisma.user.update({
where: { id },
data: { passwordHash: hashPassword(data.password), failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
data: { passwordHash, failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username });
@@ -259,12 +261,13 @@ export class UsersService {
throw new BadRequestException('currentPassword and a password of at least 6 characters are required');
}
const current = await this.getExisting(id);
if (current.passwordHash !== hashPassword(currentPassword)) {
if (!await verifyPassword(currentPassword, current.passwordHash)) {
throw new BadRequestException('当前密码不正确');
}
const passwordHash = await hashPassword(password);
const updated = await this.prisma.user.update({
where: { id },
data: { passwordHash: hashPassword(password), failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
data: { passwordHash, failedLoginCount: 0, lockedUntil: null, sessionVersion: { increment: 1 } },
include: { tenant: true, roles: { include: { role: true } } },
});
await this.writeLog(current.tenantId, id, 'user.password_changed_self', id, { username: current.username });
@@ -276,12 +279,25 @@ export class UsersService {
throw new BadRequestException('请输入当前密码');
}
const current = await this.getExisting(id);
if (current.passwordHash !== hashPassword(password)) {
if (!await verifyPassword(password, current.passwordHash)) {
throw new BadRequestException('当前密码不正确');
}
if (passwordNeedsRehash(current.passwordHash)) {
const passwordHash = await hashPassword(password);
await this.prisma.user.updateMany({ where: { id, passwordHash: current.passwordHash }, data: { passwordHash } });
}
return current;
}
async verifyLoginPassword(id: string, password: string, storedHash: string) {
if (!await verifyPassword(password, storedHash)) return false;
if (passwordNeedsRehash(storedHash)) {
const passwordHash = await hashPassword(password);
await this.prisma.user.updateMany({ where: { id, passwordHash: storedHash }, data: { passwordHash } });
}
return true;
}
listRoles() {
return this.prisma.role.findMany({
include: { permissions: { include: { permission: true } } },
@@ -440,7 +456,3 @@ function normalizeOptional(value?: string | null) {
const next = value?.trim();
return next ? next : null;
}
export function hashPassword(password: string) {
return createHash('sha256').update(password).digest('hex');
}
File diff suppressed because one or more lines are too long
+238
View File
@@ -0,0 +1,238 @@
# CMPP 平台代码质量审查报告
- 报告日期:2026-08-28
- 审查类型:当前工作区只读基线审计
- 审查仓库基线:`171c7d38e8f17de0dd83570603da316d47c0d015`;对应业务代码基线为其父提交 `a70e9e2c078a6109dd87cf8e45aeff956faf24e8`
- 分支状态:`main`,相对 `origin/main` 超前 12 个提交
- 审查范围:React/Vite 前端、NestJS API、Prisma/PostgreSQL、Redis/BullMQ 接口、Go Gateway、自动化测试、构建与工程配置
- 明确未执行:提交、推送、部署、数据库写入、短信发送、远程环境变更、生产/预生产利用验证
## 1. 执行摘要
本次综合判定为:**有条件不通过,当前版本不建议继续发布**。
代码已经具备较好的业务复杂度承载能力:API 45 个测试套件共 532 项全部通过,Gateway 全包测试与 `go vet` 通过,前后端正式构建、Prisma Schema、Gateway 队列契约和已有安全部署校验均通过;PostgreSQL 连接池、事务级 advisory lock、`FOR UPDATE SKIP LOCKED` 和核心业务复合索引也已实际存在。
但审查发现 2 个 P0 发布阻断问题:
1. 客户端租户范围来自浏览器可修改的 `x-tenant-id` 请求头,后端多个客户端接口直接信任该值,部分写接口还直接信任请求体中的 `tenantId`,存在跨租户读取、创建、修改或删除业务数据的风险。
2. 用户密码使用无盐单次 SHA-256 保存和比对。数据库一旦泄漏,相同密码可直接关联,并可使用 GPU/字典进行高速离线破解,不符合生产账号系统的密码存储要求。
因此,本报告不把“532 项测试通过”解释为生产可发布,也不把现有 mock 单测解释为真实 PostgreSQL、Redis、MinIO、Gateway 和 CMPP 全链验收完成。
## 2. 质量评级
| 维度 | 评级 | 结论 |
|---|---:|---|
| 业务正确性与并发设计 | B | 关键发送链测试较多,存在 advisory lock、幂等和队列 claim 机制;未做本轮真实全链复验。 |
| 安全性 | D | 存在租户越权和弱密码哈希两个 P0。 |
| 自动化测试 | C | API/Gateway 测试数量较好;API 语句覆盖率 59.61%、分支覆盖率 50.99%,前端测试文件为 0,且没有覆盖率门槛。 |
| 前端性能 | C- | 主 JavaScript 包 2.12 MBgzip 626 KB,所有页面同步进入主包。 |
| 可维护性 | C | 存在超大 Service/Page、无 lint/format 门禁、生产类型仍依赖 `src/mock`。 |
| 数据库工程 | B | Schema 有效,核心复合索引、连接池和锁策略较完整;未对真实数据执行 `EXPLAIN (ANALYZE, BUFFERS)`。 |
| 依赖与仓库卫生 | C- | 依赖审计仍有 2 个 high advisory;构建缓存被跟踪,临时文件和产物缺少统一忽略策略。 |
综合参考分:**58/100**。该分数用于排序治理工作,不等价于功能验收通过率。
## 3. 发布阻断问题
### CQ-SEC-001 / P0:客户端租户身份可由请求方伪造
**证据链**
- `src/api/session.ts:175-177` 从浏览器 `localStorage` 中的展示会话读取 `tenantId`
- `src/api/core/httpClient.ts:90-92``117-119` 将该值写入 `x-tenant-id`
- `api/src/common/tenant-id.decorator.ts:3-6` 直接返回客户端请求头中的 `x-tenant-id`,没有绑定已认证用户。
- `api/src/auth/session-validation.middleware.ts:25-55` 验证会话用户、状态和 portal,但没有把用户所属租户写成服务端可信租户上下文,也没有校验请求头租户等于用户租户。
- `api/src/certification/certification.controller.ts:13-19` 的客户端认证查询信任请求头,提交接口直接接受请求体。
- `api/src/certification/certification.service.ts:49-82` 的提交逻辑直接使用 `data.tenantId` 创建认证记录、更新目标企业并写操作日志。
- `api/src/sms-config/client-sms-config.controller.ts:26-163` 大量客户端查询和写接口依赖同一 `@TenantId()`;其中应用密钥重置、应用状态变更、材料创建等接口甚至没有传入租户范围。
**影响**
已登录企业管理员可以修改浏览器存储、请求头、请求体或资源 ID,尝试访问其他企业的认证、应用、签名、模板、账务、日志等数据。文件服务已有“从当前会话用户反查租户”的正确实现,但该保护没有成为客户端 API 的统一规则。
**最小修复要求**
1. 在服务端认证中间件中根据 `sessionUserId` 获取并写入可信 `request.tenantId`;客户端控制器只能读取该字段。
2. 客户端路由禁止把 `x-tenant-id` 或请求体 `tenantId` 作为授权依据;即使保留请求头,也只能用于一致性检查,不得决定数据范围。
3. 所有按资源 ID 的客户端读写在数据库查询中同时带上可信 `tenantId`
4. 增加 tenant-a/tenant-b 的真实 API 越权回归,覆盖查询、创建、更新、删除、密钥重置、上传下载和导出。
### CQ-SEC-002 / P0:用户密码采用无盐 SHA-256
**证据链**
- `api/src/users/users.service.ts:444-445``createHash('sha256').update(password).digest('hex')`
- `api/src/users/users.service.ts:141``213``267` 使用该函数创建或修改密码。
- `api/src/auth/auth.service.ts:62` 使用字符串相等比较验证密码。
**影响**
密码哈希没有用户级 salt,也没有故意增加计算和内存成本。数据库泄漏后,弱密码可被高速离线破解;相同密码生成相同哈希,还会泄露账号之间的密码复用关系。
**最小修复要求**
1. 改用 Argon2id;如运行环境暂不支持,可使用带独立 salt 和合理成本参数的 scrypt/bcrypt。
2. 新哈希保存算法版本与参数;验证旧 SHA-256 成功后立即透明升级,避免一次性强制所有账号重置。
3. 使用库提供的恒定时间验证接口,不直接比较哈希字符串。
4. 增加旧哈希迁移、错误密码、参数升级、密码修改和 sessionVersion 失效测试。
## 4. 高优先级问题
### CQ-API-001 / P1API 缺少统一运行时输入校验
- `api/src/main.ts:23-27` 创建应用并配置 body parser,但没有注册全局 `ValidationPipe`
- 搜索未发现 `@IsString``@IsInt``class-validator` 规则。
- 统计到约 166 个控制器 `@Body()` 入口;多数 DTO 是 TypeScript interface 或内联类型,运行时会被擦除。
- `api/src/open-api/open-api.dto.ts:3-14` 仅包含 Swagger 装饰器,没有输入校验装饰器。
风险包括错误类型进入服务层、超长字符串、额外字段、枚举外状态和不一致的 400/500 响应。建议建立 DTO class、`transform: true``whitelist: true``forbidNonWhitelisted: true` 的统一策略,并分批为高风险写接口补齐字段长度、枚举、数组大小和格式限制。
### CQ-AUTH-001 / P1:验证码和匿名失败计数为进程内无界 Map
- `api/src/auth/auth.service.ts:20-21` 使用两个模块级 `Map`
- `createCaptcha()` 会持续写入记录;过期记录只有在同一 captchaId 被再次提交时才删除。
- 匿名失败以任意登录名为 key 写入,没有容量限制或周期清理。
这会造成多实例登录状态不一致,并可通过大量验证码请求或随机登录名制造内存增长。建议迁移到 Redis,使用 TTL、原子计数、IP+账号双维度限流和固定容量保护。
### CQ-FE-001 / P1:前端主包过大且没有路由级代码分割
当前生产构建结果:
- JavaScript2,123.63 KBgzip 626.18 KB。
- CSS270.61 KBgzip 39.84 KB。
- Vite 明确产生“chunk 大于 500 KB”警告。
- `src/routes/AppRoutes.tsx:2-59` 同步导入全部运营端和客户端页面;代码中未发现 `React.lazy()` 或动态 `import()`
- `src/components/ui/Chart.tsx:3` 使用 `import * as echarts from 'echarts'`,进一步扩大主包。
建议先按 admin/client 及页面路由使用 `React.lazy` + `Suspense` 分包,再按需引入 ECharts 图表模块。应在 CI 增加 bundle budget,例如初始 JS gzip 不高于 250 KB、单异步 chunk gzip 不高于 180 KB;实际阈值可在首轮拆包后校准。
### CQ-TEST-001 / P1:测试覆盖和验收层级不足
- API:45 套、532 项通过;全源覆盖率为 statements 59.61%、branches 50.99%、functions 60.33%、lines 62.35%。
- `api/jest.config.cjs` 没有 `coverageThreshold`
- `src/` 下前端 `*.spec.*` / `*.test.*` 文件数量为 0。
- Gateway 内部包覆盖率约 49.7% 至 100%,但 `cmd/gateway` 只有 0.7%,另两个命令包为 0%。
- 本轮测试没有启动真实 PostgreSQL、Redis、MinIO 或 CMPP 模拟器,不能证明真实后端闭环。
建议先为租户隔离、认证、账务、发送幂等、回执关联建立真实 PostgreSQL/Redis 集成测试;前端至少覆盖登录、权限、加载/空/错误、筛选分页和高风险确认操作;随后逐步设置覆盖率门槛,避免一次性追求无意义的高百分比。
## 5. 中优先级问题
### CQ-DEP-001 / P2:生产依赖仍有 2 个 high advisory
`pnpm audit --prod --json` 返回:
1. `react-router 7.18.1`GHSA-qwww-vcr4-c8h2,修复版本 `>=7.18.2`
2. `nanoid 3.3.16`GHSA-2v37-7h3g-55p8,修复版本 `>=3.3.18`
现有 `security:verify` 已证明项目没有使用 React Router RSC 模式,并验证了既有 PostCSS 缓解,因此当前可利用面低于审计工具的原始 high 评级;但版本仍处于公告范围,不能长期依赖“功能未使用”作为供应链治理。建议升级后重新执行构建、API 测试、前端浏览器回归和安全校验。
### CQ-MAINT-001 / P2:超大文件与缺失静态风格门禁
- `api/src/send-chain/send-inbound-entry.service.ts` 约 84 KB。
- `api/src/send-chain/send-gateway-submit.service.ts` 约 52 KB。
- `src/apps/admin/AdminDownstreamDeliveriesPage.tsx` 约 44 KB。
- 根项目和 API 均没有 lint/format 脚本,也未发现 ESLint/Prettier 配置。
建议先按“持久化、状态机、队列 claim、路由、计费”拆分 send-chain 服务;页面按筛选、表格、详情和恢复操作拆分。拆分时要求行为不变,并依靠当前测试防回归。
### CQ-CONFIG-001 / P2:数据库连接存在硬编码开发默认凭据
- `api/prisma.config.ts:8-11`
- `api/src/prisma/prisma.service.ts:39-41`
`DATABASE_URL` 缺失时,进程会尝试使用 `cmpp/cmpp_password` 连接本机数据库。建议生产角色 fail closed;仅在显式 `NODE_ENV=development` 或专用本地配置下允许开发默认值。
### CQ-ARCH-001 / P2:真实 API 代码仍与 mock 目录耦合
- `src/mock/` 保留完整 localStorage mock service。
- `src/apps/admin/auditColumns.tsx:3` 的生产组件仍从 `@/mock` 导入业务类型。
当前没有证据表明 mock service 仍被页面运行时调用,但该目录和类型依赖会误导后续开发,并增加重新接入静态数据的风险。建议把共享类型迁移到 `src/api/types` 或 domain 模块,并在构建/检查中禁止 `src/apps/**` 导入 `src/mock/**`
### CQ-REPO-001 / P2:仓库产物和审计基线不稳定
- `api/tsconfig.build.tsbuildinfo` 已被 Git 跟踪,每次构建产生无业务意义的修改。
- `.gitignore` 没有统一忽略 `*.tsbuildinfo``outputs/` 和任务临时文件。
- 最终工作区仍存在 `=`, `outputs/`, `pnpm-lock.yaml`, `tmp_generate_ui_drafts.py` 等既有未跟踪内容;审计窗口内还曾出现随后被并发流程处理的临时部署脚本。
- 审计开始时 HEAD 为 `4d4c1f3`,期间其他会话先后提交了业务代码 `a70e9e2` 和部署记录 `171c7d3`;本报告已对新业务代码重新运行前端类型检查和生产构建,但完整 API 覆盖率运行发生在该纯前端提交之前。
建议统一包管理器与唯一锁文件,忽略纯构建缓存,并在正式审查/发布时使用固定 commit 或独立 worktree,避免审计结论对应移动目标。
## 6. 已通过门禁
| 检查项 | 结果 |
|---|---|
| 前端 TypeScript `tsc --noEmit` | 通过;并发提交后已重跑 |
| Vite 生产构建 | 通过;存在包体警告 |
| API TypeScript 正式构建 | 通过 |
| API Jest | 45/45 套、532/532 项通过 |
| API 覆盖率 | statements 59.61%branches 50.99%functions 60.33%lines 62.35% |
| Gateway `go test ./... -count=1` | 通过 |
| Gateway `go vet ./...` | 通过 |
| Gateway `go test ./... -cover` | 通过;各包覆盖率差异较大 |
| Prisma Schema | 95 个迁移目录;`prisma validate` 通过 |
| Gateway 队列契约 | 5/5 样例通过 |
| 依赖缓解/安全部署脚本 | 通过 |
| `pnpm audit --prod` | 不通过;2 个 high advisory |
| `git diff --check` | 通过 |
## 7. 积极发现
1. PostgreSQL 连接按 API、Worker、Outbox、Callback 和 Protocol Log 角色设置独立连接池上限。
2. 账务、日配额、频控等竞争资源使用事务级 advisory lock。
3. 多个队列 claim 使用 `FOR UPDATE SKIP LOCKED`,适合并发消费者。
4. `SmsMessageRecord``SmsBatchTask``CmppDownstreamDelivery` 等高频表具备 tenant/status/time 复合索引。
5. Webhook 主链对协议、内网地址、环回地址和 DNS 解析做了 SSRF 防护,并在 HTTP 客户回调中固定解析后的目标地址。
6. 会话 cookie 使用 HttpOnly、SameSite=Lax,并按环境控制 Secure;前端 localStorage 保存的是会话展示元数据,不是 session token。
7. API、Gateway 和队列契约测试已经覆盖大量发送、补发、回执去重和异常分支。
## 8. 未验证边界
本报告不能替代以下验证:
- 真实 PostgreSQL 数据量下的 `EXPLAIN (ANALYZE, BUFFERS)` 和慢查询分析。
- Redis Stream/BullMQ pending、lag、重试、宕机恢复和重复消费验证。
- MinIO 上传、下载、租户隔离和大文件边界。
- 登录后的真实浏览器 UI、控制台、网络请求、加载/空/错误/刷新状态。
- Gateway 与 CMPP 模拟器或真实供应商的 Submit、长短信、回执、上行和断线恢复闭环。
- 测试、预生产、生产当前部署 commit、迁移数、配置和服务状态。
在完成 P0 修复前,不建议通过真实环境攻击性测试证明越权;应先补自动化隔离回归,再在隔离测试环境验证。
## 9. 建议整改顺序
### 第一批:发布阻断
1. 服务端统一可信租户上下文,关闭请求头/请求体决定客户端租户的能力。
2. 迁移密码哈希到 Argon2id/scrypt,并提供旧哈希透明升级。
3. 补 tenant-a/tenant-b 越权测试和密码迁移测试。
### 第二批:安全与质量门禁
1. 建立全局运行时 DTO 校验。
2. 将验证码、失败计数和限流迁移到 Redis TTL/原子计数。
3. 升级两个公告依赖并保持安全校验通过。
4. 在 CI 增加 lint、format check、覆盖率门槛、依赖审计和 bundle budget。
### 第三批:性能与可维护性
1. 前端路由级分包并按需加载 ECharts。
2. 拆分 send-chain 超大服务和超大页面。
3. 清理 mock 类型耦合、构建缓存和临时产物治理。
4. 在隔离真实后端完成 API/DB/Redis/MinIO/Gateway/CMPP 全链回归。
## 10. 验收出口标准
整改完成至少应满足:
- P0 为 0,P1 有明确关闭证据或书面风险接受。
- tenant-a 用户无法通过 header、body、query 或资源 ID 访问 tenant-b 数据。
- 新密码使用强哈希;旧 SHA-256 账号登录后自动升级,数据库不再新增 SHA-256 密码。
- 前端初始 JS 包达到约定预算,核心路由按需加载。
- API/Gateway/前端测试与构建全部通过;覆盖率不低于本报告基线且建立门槛。
- 依赖审计不再包含本报告两项 high advisory。
- 真实 PostgreSQL、Redis、MinIO、Gateway 和 CMPP 测试证据与 `docs/testing-progress.md` 同步。
@@ -0,0 +1,402 @@
# CMPP 平台代码质量整改方案
- 方案日期:2026-08-28
- 对应审计报告:`docs/code-quality-audit-20260828.md`
- 当前复核基线:`c3bf8af3e6fc8bac0ac5e104f09a3d6b68506b27`
- 适用范围:React/Vite 前端、NestJS API、Prisma/PostgreSQL、Redis、Go Gateway、测试与工程门禁
- 当前结论:两个 P0 均已在当前代码中确认;P0 关闭前不得继续发布到预生产或生产
- 环境边界:只允许在本地和测试环境实施、部署与验证;预生产不得部署、覆盖或回退,除非再次取得明确授权
## 1. 整改目标
本轮整改以“先消除发布阻断,再建立防复发门禁,最后处理性能和可维护性”为原则。完成后应达到:
1. 客户端租户身份只来自服务端已认证会话,任何请求头、请求体、查询参数和资源 ID 都不能改变数据归属范围。
2. 新增和修改的密码全部使用带版本及参数信息的强密码哈希;旧 SHA-256 账号在成功登录后透明迁移。
3. 高风险写接口具备统一、可预测的运行时输入校验和错误响应。
4. 验证码、失败计数和登录限流支持多实例一致性、TTL、原子操作与容量控制。
5. 建立能够阻止租户越权、弱密码、依赖漏洞、包体回退和测试覆盖下降的自动化门禁。
6. 前端首屏资源按路由拆分,工程产物、mock 类型和超大模块进入可持续治理状态。
## 2. 范围、依赖与验证成本
| 批次 | 范围 | 关键依赖 | 主要验证 | 相对工作量 |
|---|---|---|---|---:|
| R0 | 固定基线、资产与测试数据 | Git、测试数据库、Redis、MinIO、测试账号 | 恢复演练、基线门禁 | 0.5~1 人日 |
| R1 | 可信租户上下文和全客户端接口隔离 | User.tenantId、会话中间件、Prisma | 双租户真实 API 回归 | 4~7 人日 |
| R2 | 密码强哈希与透明迁移 | Argon2id 或 scrypt、User.passwordHash | 兼容登录、迁移、会话失效 | 2~4 人日 |
| R3 | DTO 校验、验证码与限流 | class-validator、Redis | 接口契约、多实例、过期与并发 | 4~7 人日 |
| R4 | 依赖、CI、测试门禁 | 唯一包管理器、浏览器测试框架 | 全量构建、测试、安全审计 | 3~6 人日 |
| R5 | 前端分包与 ECharts 按需加载 | Vite、React Router | 包体、浏览器、弱网首屏 | 2~4 人日 |
| R6 | 超大模块、mock、配置和仓库治理 | 既有回归测试 | 行为等价、构建、Git 清洁度 | 5~10 人日 |
| R7 | 测试环境真实链路验收 | PostgreSQL、Redis、MinIO、Gateway、CMPP 模拟器 | 全链证据和恢复演练 | 2~5 人日 |
以上是工作量级别,不是承诺工期。R1 涉及的客户端控制器和资源类型较多,实际工作量取决于租户资源清单和集成测试基础设施。最小充分范围是先完成 R0~R2;R3~R7 不得反向阻塞 P0 修复,但没有完成相应门禁的项目不能视为质量治理闭环。
## 3. 总体实施顺序
```text
R0 固定基线和恢复资产
├─ R1 可信租户上下文 ─┐
└─ R2 密码哈希迁移 ───┴─ P0 安全门禁通过
R3 输入校验和登录状态
R4 测试与工程门禁
R5 前端性能 + R6 可维护性
R7 测试环境全链验收
```
R1 和 R2 可以在独立分支并行开发,但必须分别完成测试后再合并。R3 的全局校验可能改变大量接口响应,不应与 R1 混在同一个大提交中。
## 4. R0:基线、恢复资产和变更控制
### 4.1 实施内容
1. 为整改建立固定 Git 基线,记录完整 commit、分支及工作区状态;不得把现有未跟踪文件或其他会话改动误纳入提交。
2. 部署测试环境前重新建立并校验恢复资产:
- 当前服务包、配置和 systemd/Nginx 配置备份;
- PostgreSQL 可恢复备份;
- Redis 关键 key/stream/queue 状态快照;
- MinIO 关键 bucket 和租户测试文件清单;
- 当前部署 commit、Prisma 迁移数和服务健康状态。
3. 准备两个隔离企业及账号:`tenant-a/user-a``tenant-b/user-b`。两边分别准备认证、应用、签名、模板、任务、记录、账务、日志和文件样本。
4. 记录 P0 修复前的接口契约,但不执行真实环境攻击性验证,不保留可直接复用的生产攻击脚本。
### 4.2 出口标准
- 恢复资产的位置、校验值、恢复命令和验证结果有记录。
- 两个租户的测试数据可重复创建,不使用生产数据。
- 工作目录的既有脏文件归属已记录,整改提交不混入无关文件。
## 5. R1:关闭客户端跨租户访问
### 5.1 服务端可信租户上下文
修改 `api/src/auth/session-validation.middleware.ts`
1. 查询会话用户时同时读取 `tenantId`
2.`/api/client/**` 请求强制要求有效 `tenantId`;缺失时返回明确的 403,而不是继续执行无租户过滤查询。
3. 将可信字段写入请求上下文,例如:
```ts
request.authContext = {
userId: user.id,
portal: result.record.portal,
tenantId: user.tenantId,
};
```
4. 新建只读取服务端上下文的装饰器,例如 `@CurrentTenantId()`。原 `@TenantId()` 不再作为客户端授权依据。
5. 若为兼容旧前端暂时保留 `x-tenant-id`,只允许进行一致性校验:请求头存在且与可信租户不一致时返回 403,并写入安全审计日志;请求头不得决定查询范围。
6. 运营端按明确权限跨租户查询的能力继续使用运营端查询参数,不复用客户端租户装饰器。
### 5.2 控制器和服务层整改
必须逐一盘点所有 `/api/client/**` 路由,不仅修改审计报告列举的两个控制器。处理规则如下:
| 接口类型 | 必须采用的规则 |
|---|---|
| 列表查询 | `where` 必须包含可信 `tenantId`;不得因 `undefined` 省略过滤 |
| 单条详情 | 使用 `findFirst({ where: { id, tenantId } })` 或等价复合条件,不得只按 `id` 查询 |
| 创建 | 服务端覆盖 `tenantId`,忽略或拒绝请求体中的 `tenantId` |
| 更新/删除 | 更新前按 `{ id, tenantId }` 验证资源;事务内再次带租户条件 |
| 密钥重置/状态变更 | 同时传入可信 `tenantId`,禁止只凭资源 ID 操作 |
| 上传/下载 | 文件对象和所属业务对象都必须校验租户;下载不得仅凭 fileObjectId |
| 导入/导出 | 导出查询和异步任务 payload 都固化可信 `tenantId` |
| 关联资源 | 应用、签名、模板、通道能力等外键必须属于同一租户或是明确的公共资源 |
首批重点文件包括但不限于:
- `api/src/certification/certification.controller.ts`
- `api/src/certification/certification.service.ts`
- `api/src/sms-config/client-sms-config.controller.ts`
- 客户端账务、短信任务、发送记录、上行短信、日志、文件和报表相关控制器及服务
建议建立一个租户资源清单,记录“路由、动作、资源、服务层方法、租户条件、测试编号、整改状态”。不能只用全局搜索替代清单验收。
### 5.3 数据库约束与审计
1. 优先使用现有 `tenantId` 列和复合索引,不为了本轮修复盲目大改数据库模型。
2. 对经常使用“资源 ID + tenantId”的高频表检查复合索引;新增索引前在测试数据上执行 `EXPLAIN (ANALYZE, BUFFERS)`
3. 对租户头不一致、资源归属不一致和客户端传入 tenantId 的尝试记录安全事件,但日志不得包含密码、应用密钥或完整敏感资料。
4. 业务层建议增加统一的 `assertTenantResource` 或仓储查询约束,但不能用一个可选 tenantId 参数制造新的绕过路径。
### 5.4 必测用例
每类资源至少覆盖:
- A 查询 A:成功。
- A 查询 B:404 或 403,响应不得暴露 B 是否存在。
- A 创建时提交 B 的 tenantId:拒绝或由服务端强制覆盖为 A。
- A 更新、删除、提交审核、重置密钥、变更状态时使用 B 的资源 ID:失败且 B 数据不变。
- 省略、伪造、重复或大小写变化的 `x-tenant-id`:不能扩大数据范围。
- 批量 ID 中混入 B 的资源:整个请求原子失败,或者只处理 A 且返回明确结果;不得静默操作 B。
- 文件上传、下载、导出和异步任务执行后仍保持租户隔离。
- 管理端经授权跨租户操作保持原有能力,客户端权限收紧不能误伤运营端。
### 5.5 出口标准
- 所有客户端路由使用服务端可信租户上下文。
- 资源 ID 操作均有租户复合条件。
- tenant-a/tenant-b 自动化测试覆盖查询、创建、更新、删除、密钥、文件、导入导出和异步任务。
- 安全测试失败时构建门禁失败。
## 6. R2:密码哈希升级与旧账号透明迁移
### 6.1 算法与存储格式
首选使用成熟库实现 Argon2id,并保存库生成的 PHC 字符串,例如 `$argon2id$...`,其中包含算法、版本、成本、salt 和哈希。不要自行拼接 salt 或自行实现密码学算法。
参数必须在目标 API 机器上基准测试后确定。目标是单次登录验证具备足够成本,但不造成登录接口不可接受的延迟或并发耗尽。参数写入配置及文档,不能散落为魔法数字。
如果目标运行环境无法稳定安装 Argon2 原生依赖,可以使用 Node 标准库 `scrypt` 作为备选;使用独立随机 salt、版本化格式、固定上限和恒定时间比较。不得继续新增 SHA-256 哈希。
### 6.2 兼容迁移流程
```text
用户提交密码
├─ passwordHash 是 Argon2id/新格式
│ └─ 使用库验证;参数过旧则成功后重新哈希
└─ passwordHash 是 64 位旧 SHA-256
├─ 旧算法验证失败:正常失败
└─ 旧算法验证成功:同一登录流程内写入新哈希并完成登录
```
实施要求:
1. 新建统一的 `PasswordHasher` 服务,集中负责 `hash``verify``needsRehash` 和旧格式识别。
2. 创建用户、管理员改密、用户自助改密、密码重置全部调用该服务。
3. 旧哈希透明升级使用条件更新或事务,避免并发登录覆盖更新后的哈希。
4. 密码修改后继续递增 `sessionVersion`,保证旧会话失效。
5. 日志中只记录迁移成功/失败及用户 ID,不记录密码或完整哈希。
6. 暂不批量强制重置全部账号;对长期不登录的旧账号可在后续治理中安排强制重置。
### 6.3 必测用例
- 新账号数据库中不再产生 64 位裸 SHA-256。
- 旧 SHA-256 正确密码能够登录,登录后立即变为新格式。
- 旧 SHA-256 错误密码不能触发迁移。
- 新格式正确/错误密码验证正确。
- 参数升级后 `needsRehash` 能透明更新。
- 管理员改密、自助改密、重置密码都使用新格式并撤销旧会话。
- 并发登录不会把新哈希覆盖回旧哈希。
- 算法验证异常不会回退到“直接字符串相等”。
### 6.4 回退策略
代码回退必须继续具备读取新哈希的能力。因此密码迁移上线后,不允许回退到只认识 SHA-256 的旧版本。推荐先部署“双读新旧、只写新格式”的兼容版本;验证稳定后再移除旧哈希写入代码。数据库字段通常无需迁移,但必须先确认长度足以保存 PHC 字符串。
### 6.5 出口标准
- 全部密码写路径只写新格式。
- 旧账号登录后可验证地完成透明迁移。
- 密码和会话相关自动化测试通过。
- 测试数据库扫描证明没有新产生的旧 SHA-256 哈希。
## 7. R3:输入校验、验证码和登录限流
### 7.1 DTO 运行时校验
不要直接一次性对全部 181 个左右的 `@Body()` 入口开启严格拒绝,否则可能造成大面积兼容性回归。分三步实施:
1. 先建立 DTO class 和统一异常格式,在认证、租户资源、密码、密钥、文件、发送、账务等高风险写接口启用。
2. 对已覆盖 DTO 的模块启用 `transform``whitelist``forbidNonWhitelisted`
3. 所有模块完成 DTO 转换和兼容验证后,再提升为全局 `ValidationPipe`
每个 DTO 至少考虑:字符串长度、trim 策略、枚举、手机号/日期格式、数组数量、单项长度、分页上限、文件大小、嵌套对象和额外字段。校验失败统一返回稳定的 400 错误码和字段列表,不返回 Internal server error。
### 7.2 验证码和失败计数
复用现有 `ioredis` 依赖,但应抽出共享 Redis 连接/服务,避免每个模块各自维护连接。建议 key 设计:
- `auth:captcha:<captchaId>`:TTL 2~5 分钟,验证使用原子读取并删除。
- `auth:fail:account:<normalizedAccount>`:滑动或固定窗口计数。
- `auth:fail:ip:<ip>`IP 维度计数。
- `auth:lock:<normalizedAccount>`:明确锁定 TTL。
要求:
1. 使用 Lua 或等价 Redis 原子命令完成“校验并消费”和计数/过期设置。
2. 对 key 长度和账号归一化进行限制,避免任意超长输入制造内存压力。
3. 登录接口结合可信代理配置获得真实来源 IP,不能盲目信任任意 `X-Forwarded-For`
4. Redis 不可用时采用明确的安全策略并报警;不能悄悄退回无限 Map。是否 fail closed 应结合运营可用性评审后固化。
5. 增加验证码请求频率和总容量保护。
### 7.3 出口标准
- 高风险写接口全部具有运行时 DTO 校验。
- 多 API 实例共享验证码和锁定状态。
- 过期 key 自动清理,随机账号/验证码压测下 Redis 内存增长受控。
- 错误请求稳定返回 4xx,不出现未处理 500。
## 8. R4:依赖、测试与 CI 门禁
### 8.1 依赖整改
1.`react-router-dom`/`react-router` 升级到已修复的兼容版本,至少 `7.18.2`
2. 通过 PostCSS 或包管理器解析结果把 NanoID 升级到至少 `3.3.18`
3. 统一使用一种包管理器和唯一锁文件;在决定前不要直接删除现有锁文件。
4. 升级后执行前端构建、登录/路由浏览器回归、安全校验和生产依赖审计。
React Router 公告只影响不稳定 RSC 路径,NanoID 公告需要特定零长度自定义生成器调用;当前项目可利用面较低,但版本治理仍应关闭公告。
### 8.2 自动化测试优先级
新增测试优先级如下:
1. 租户隔离和密码迁移。
2. 账务余额、发送幂等、任务审核、回执关联和补发。
3. 登录、权限、加载/空/错误/刷新、筛选分页及高风险确认操作的前端测试。
4. PostgreSQL、Redis 和 MinIO 集成测试。
5. Gateway/CMPP 模拟器的 Submit、长短信、回执、上行和断线恢复。
覆盖率门槛应先以当前实测基线为下限,新增或修改文件要求更高的增量覆盖率,再逐批提高。不以补无意义断言换取百分比。
### 8.3 CI 建议门禁
每次合并至少执行:
- `git diff --check`
- 前端 TypeScript 和 Vite 正式构建
- API TypeScript 正式构建和 Jest
- Gateway `go test ./... -count=1``go vet ./...`
- Prisma validate 和迁移一致性检查
- tenant-a/tenant-b 安全回归
- lint、format check
- 生产依赖审计
- bundle budget
- 禁止 `src/apps/**` 导入 `src/mock/**`
- 禁止新增裸 SHA-256 密码写入代码
任何 P0 安全回归、构建、迁移验证或依赖阻断项失败都不得合并。
## 9. R5:前端路由分包和图表瘦身
### 9.1 实施内容
1.`src/routes/AppRoutes.tsx` 按 admin/client 和页面路由使用 `React.lazy``Suspense`
2. 登录页、布局骨架和通用错误页保留轻量同步加载;大型报表、审计、监控和详情页异步加载。
3. 为异步路由提供统一加载、失败和重试界面,避免白屏。
4. ECharts 改为按需注册图表、组件和渲染器;确认所有现有图表类型仍正常。
5. 在 Vite 构建产物中记录初始 JS、CSS、最大异步 chunk 和 gzip 大小。
### 9.2 建议预算
- 初始 JavaScript gzip:首轮目标不高于 250 KB;如果受框架公共依赖限制,可基于首轮拆包结果书面校准。
- 单个异步 chunk gzip:不高于 180 KB。
- 不允许新增页面使初始包超过已验收基线。
### 9.3 浏览器验收
覆盖客户端和运营端:首次访问、直接打开深层 URL、登录后跳转、刷新、后退/前进、异步加载失败、权限不足、移动端宽度及弱网。浏览器控制台不得出现 chunk 加载、路由和图表初始化错误。
## 10. R6:可维护性、配置、mock 和仓库治理
### 10.1 超大模块拆分
拆分遵循“先测试锁定行为,再机械移动,最后优化结构”:
- `send-inbound-entry.service.ts`:按接入解析、持久化、长短信聚合、业务工作流、频控/预留拆分。
- `send-gateway-submit.service.ts`:按队列 claim、路由选择、Gateway 提交、Outbox/Redis、重试恢复拆分。
- `AdminDownstreamDeliveriesPage.tsx`:按筛选条件、统计、表格、详情、恢复操作拆分。
每次只移动一个职责,提交中不同时改变业务规则。拆分后保持事务边界、锁顺序、幂等键和队列语义不变。
### 10.2 数据库配置
1. 生产、预生产和测试服务启动时,`DATABASE_URL` 缺失必须立即失败。
2. 开发默认连接只允许在显式 development/test 模式下使用,并在启动日志中标明非生产配置。
3.`REDIS_URL`、MinIO、会话密钥、应用加密主密钥等关键配置建立同类启动校验。
4. 日志不得输出完整连接串和密码。
### 10.3 mock 解耦
1.`auditColumns.tsx` 所需业务类型迁移到 `src/api/types` 或独立 domain 类型文件。
2. 生产组件禁止导入 `src/mock/**`
3. 在确认没有运行时引用后,再决定保留 mock 作为测试夹具还是删除;不能只为“目录干净”贸然删除可能仍被工具使用的内容。
### 10.4 仓库卫生
1. `.gitignore` 增加 `*.tsbuildinfo`、明确的本地产物和任务临时目录规则。
2. 对已经被 Git 跟踪的构建缓存使用 `git rm --cached` 停止跟踪,但保留本地文件;操作前确认没有业务用途。
3. 统一锁文件后再清理其他锁文件,并通过全新目录可重复安装验证。
4. 正式审计和发布使用固定 commit 或独立 worktree,报告记录 commit、依赖锁摘要和构建产物校验值。
5. 不自动删除当前工作区的 `=`, `outputs/`, `tmp_generate_ui_drafts.py` 等既有内容,必须先确认归属和可恢复性。
## 11. R7:测试环境验收与发布策略
### 11.1 测试环境部署前
- 重新建立并校验恢复资产,不复用上一次“已经备份”的口头结论。
- 核对部署目标为 `100.93.204.60` 测试环境,不连接预生产服务器。
- 记录旧 commit、新 commit、迁移计划、服务包校验值和回退步骤。
- 密码新格式上线后,回退包必须仍能读取新哈希。
### 11.2 测试环境真实验证
1. 双租户 API 隔离矩阵全部通过。
2. 旧密码登录透明迁移、新密码登录、改密和会话撤销通过。
3. PostgreSQL 数据实际落库且租户归属正确。
4. Redis 验证码、锁定、TTL、多实例一致性和故障策略通过。
5. MinIO 上传、下载、租户隔离和大文件边界通过。
6. 登录后的客户端/运营端页面完成加载、空、错误、刷新和权限状态检查。
7. Gateway/CMPP 模拟器完成 Submit、长短信、状态报告、上行和断线恢复。
8. 短信结果必须以最终平台状态、数据库/队列和下游证据闭环,不能以 SubmitResp 成功代替最终送达。
### 11.3 发布门禁
测试环境验收通过不自动授权预生产发布。预生产仍需单独明确授权,并在发布前重新核对:
- P0 为 0
- P1 已关闭或有书面风险接受;
- 数据库迁移可向前执行且回退边界明确;
- 新旧密码格式兼容;
- 全量自动化与真实链路证据齐全;
- 目标环境恢复资产已重新建立;
- `docs/testing-progress.md`、审计问题清单和部署记录同步。
## 12. 问题关闭标准
| 编号 | 关闭证据 |
|---|---|
| CQ-SEC-001 | 服务端可信租户上下文;客户端路由清单;双租户真实 API 自动化;资源 ID、文件、导入导出和异步任务均无越权 |
| CQ-SEC-002 | 新哈希写入证据;旧 SHA-256 透明迁移;并发和 sessionVersion 测试;回退版本兼容新格式 |
| CQ-API-001 | 高风险接口 DTO class;统一 400 格式;全局或分模块严格 ValidationPipe;异常输入回归 |
| CQ-AUTH-001 | Redis TTL/原子计数;多实例一致性;容量和故障策略测试 |
| CQ-FE-001 | 路由异步 chunkECharts 按需加载;包体预算和真实浏览器回归 |
| CQ-TEST-001 | 安全/业务集成测试;前端关键状态测试;覆盖率门槛不低于确认后的基线 |
| CQ-DEP-001 | 锁文件中版本已修复;生产依赖审计不再报告对应公告;构建和浏览器回归通过 |
| CQ-MAINT-001 | 超大模块按职责拆分;行为等价测试通过;lint/format 门禁启用 |
| CQ-CONFIG-001 | 非开发环境缺失关键配置时启动失败;开发回退显式且不泄露凭据 |
| CQ-ARCH-001 | 生产代码不再导入 `src/mock/**`;禁止规则进入 CI |
| CQ-REPO-001 | 唯一锁文件;构建缓存不再被跟踪;审计基线固定;工作区产物有明确治理规则 |
## 13. 建议提交拆分
为降低审查和回退风险,建议至少拆为以下独立提交:
1. `security: bind client tenant scope to authenticated session`
2. `test: add cross-tenant isolation regression matrix`
3. `security: migrate password hashing with legacy upgrade`
4. `test: cover password migration and session revocation`
5. `security: add runtime DTO validation to high-risk APIs`
6. `security: move captcha and login throttling to Redis`
7. `build: upgrade audited dependencies and add quality gates`
8. `perf: split routes and load charts on demand`
9. `refactor: split send-chain responsibilities without behavior change`
10. `chore: decouple mock types and normalize repository artifacts`
任何提交都不应同时包含测试环境部署资产、无关 UI 修改或其他会话的工作区文件。
## 14. 最终交付物
- 整改后的源代码和逐项可审查提交。
- 租户资源/路由清单及关闭状态。
- 自动化测试结果、覆盖率、依赖审计和 bundle 报告。
- 测试环境真实 API、数据库、Redis、MinIO、Gateway、CMPP 和浏览器证据。
- 密码迁移统计,只记录格式数量,不导出密码哈希。
- 恢复资产、部署记录和回退验证记录。
- 更新后的 `docs/testing-progress.md` 与代码质量问题关闭清单。
+17 -41
View File
@@ -16,7 +16,7 @@
"lucide-react": "^1.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "7.18.1",
"react-router-dom": "7.18.2",
"vite": "^8.0.16",
"zustand": "^5.0.14"
},
@@ -277,9 +277,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -296,9 +293,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -315,9 +309,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -334,9 +325,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -353,9 +341,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -372,9 +357,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -479,6 +461,7 @@
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
@@ -489,6 +472,7 @@
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -869,9 +853,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -892,9 +873,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -915,9 +893,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -938,9 +913,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1062,9 +1034,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@@ -1111,6 +1083,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1151,6 +1124,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1160,6 +1134,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -1168,9 +1143,9 @@
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -1190,12 +1165,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.1"
"react-router": "7.18.2"
},
"engines": {
"node": ">=20.0.0"
@@ -1346,6 +1321,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
+8 -2
View File
@@ -18,6 +18,10 @@
"spike:bullmq": "node api/src/spike/bullmq-link-spike.mjs",
"test:api": "npm --prefix api test",
"test:gateway": "npm run spike:gateway",
"lint": "npm run quality:verify && tsc --noEmit",
"format:check": "git diff --check",
"quality:verify": "node tools/quality/verify-code-quality.mjs",
"bundle:verify": "node tools/quality/verify-bundle-budget.mjs",
"verify:phase1": "npm run spike:contracts && npm run spike:gateway && npm run spike:bullmq && npm run prisma:generate && npm run build:api && npm run build",
"verify:phase2": "npm run verify:phase1",
"verify:phase3": "npm run verify:phase2",
@@ -25,7 +29,8 @@
"verify:phase5": "npm run verify:phase4",
"verify:phase6": "npm run verify:phase5",
"verify:phase7": "npm run verify:phase6",
"verify:phase8": "npm run verify:phase7"
"verify:phase8": "npm run verify:phase7",
"verify:quality": "npm run lint && npm run format:check && npm run build && npm run bundle:verify && npm --prefix api run test:coverage"
},
"dependencies": {
"@vitejs/plugin-react": "^6.0.2",
@@ -36,7 +41,7 @@
"lucide-react": "^1.18.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "7.18.1",
"react-router-dom": "7.18.2",
"vite": "^8.0.16",
"zustand": "^5.0.14"
},
@@ -47,6 +52,7 @@
"typescript": "^6.0.3"
},
"overrides": {
"nanoid": "3.3.18",
"postcss": "8.5.23"
}
}
+11
View File
@@ -0,0 +1,11 @@
export type AuditStatus = 'pending' | 'approved' | 'rejected';
export type AuditItem = {
id: string;
customer: string;
type: '模板' | '签名';
content: string;
risk: 'low' | 'medium' | 'high';
status: AuditStatus;
submittedAt: string;
};
@@ -3,164 +3,7 @@ import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerRe
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type DownstreamRequeuePreview, type DownstreamRequeueTask, type DownstreamRequeueTaskItem, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, Textarea, type DateRangeValue } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'warning',
manual_requeueing: 'info',
awaiting_ack: 'info',
delivered: 'success',
failed: 'danger',
unconfirmed: 'warning',
rejected: 'danger',
};
const statusLabel: Record<string, string> = {
manual_requeueing: '人工重投处理中',
awaiting_ack: '等待客户端确认',
delivered: '客户端已确认',
failed: '投递失败',
unconfirmed: '客户端未确认',
rejected: '客户端拒绝',
};
function deliveryStatusLabel(record: DownstreamDeliveryRecord) {
if (record.status !== 'pending') return statusLabel[record.status] ?? record.status;
if (record.manualRetryCount > 0) return '人工重投排队中';
if (record.retryCount > 0) return '等待自动重试';
return '待首次投递';
}
const deliveryTypeLabel: Record<string, string> = {
receipt: '状态回执',
uplink: '上行短信',
};
const attemptStatusLabel: Record<string, string> = {
awaiting_ack: '等待客户端确认',
acknowledged: '客户端已确认',
rejected: '客户端拒绝',
failed: '投递失败',
};
const requeueTaskStatusLabel: Record<string, string> = {
queued: '排队中', running: '执行中', paused: '已暂停', completed: '已完成',
partial_completed: '部分完成', terminated: '已终止',
};
const requeueItemStatusLabel: Record<string, string> = {
queued: '排队中', processing: '处理中', waiting_connection: '等待连接',
waiting_external_ack: '等待其他链路确认', waiting_ack: '等待客户确认', success: '成功',
failed: '失败', skipped: '跳过', unprocessed: '未处理',
};
function requeueTone(status: string) {
if (status === 'completed' || status === 'success') return 'success' as const;
if (status === 'partial_completed' || status === 'failed') return 'danger' as const;
if (status === 'paused' || status === 'skipped' || status === 'unprocessed') return 'warning' as const;
return 'info' as const;
}
type RequeueTarget =
| { kind: 'single'; record: DownstreamDeliveryRecord }
| { kind: 'batch'; ids: string[] };
type RequeueResult = {
status: 'success' | 'partial' | 'failed';
title: string;
message: string;
failures?: string[];
};
function formatLocalDate(value: Date) {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
}
function recentSevenDays(): DateRangeValue {
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - 6);
return { start: formatLocalDate(start), end: formatLocalDate(end) };
}
function attemptStatusTone(status: string) {
if (status === 'acknowledged') return 'success' as const;
if (status === 'rejected' || status === 'failed') return 'danger' as const;
if (status === 'awaiting_ack') return 'info' as const;
return 'neutral' as const;
}
function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRecord; onClose: () => void }) {
const payloadText = useMemo(() => JSON.stringify(record.payload ?? {}, null, 2), [record.payload]);
return (
<Modal
open
onClose={onClose}
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{record.id}</p></div>}
footer={<Button onClick={onClose}></Button>}
>
<div className="report-record-detail">
<div className="detail-grid">
<div><span></span><strong>{record.tenant?.name ?? record.tenantId}</strong></div>
<div><span></span><strong>{record.application?.name ?? record.applicationId}</strong></div>
<div><span></span><strong>{deliveryTypeLabel[record.deliveryType] ?? record.deliveryType}</strong></div>
<div><span></span><strong>{deliveryStatusLabel(record)}</strong></div>
<div><span> ID</span><strong>{record.messageId ?? '-'}</strong></div>
<div><span></span><strong>{record.retryCount}</strong></div>
<div><span></span><strong>{record.manualRetryCount}</strong></div>
<div><span></span><strong>{record.lastRetriedAt ? formatDateTime(record.lastRetriedAt) : '-'}</strong></div>
<div><span></span><strong>{record.nextRetryAt ?? '-'}</strong></div>
<div><span></span><strong>{record.retryEnabled ? '开启' : '关闭'}</strong></div>
<div><span></span><strong>{record.sentAt ?? '-'}</strong></div>
<div><span></span><strong>{record.acknowledgedAt ?? '-'}</strong></div>
<div><span>ACK Result</span><strong>{record.ackResult ?? '-'}</strong></div>
<div><span>ACK Sequence_Id</span><strong>{record.ackSequenceId ?? '-'}</strong></div>
<div><span>ACK Msg_Id</span><strong>{record.ackMessageId ?? '-'}</strong></div>
<div><span> ID</span><strong>{record.connectionId ?? '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.lastError ?? '-'}</strong></div>
</div>
<section className="report-history">
<h3></h3>
<div className="downstream-attempt-timeline" aria-label="逐次投递记录">
{(record.attempts ?? []).map((attempt) => (
<article className="downstream-attempt-card" key={attempt.id}>
<div className={`downstream-attempt-marker downstream-attempt-marker--${attemptStatusTone(attempt.status)}`}>
{attempt.attemptNo}
</div>
<div className="downstream-attempt-card__body">
<header>
<strong> {attempt.attemptNo} </strong>
<Tag tone={attemptStatusTone(attempt.status)}>{attemptStatusLabel[attempt.status] ?? attempt.status}</Tag>
</header>
<dl className="downstream-attempt-card__times">
<div><dt></dt><dd>{attempt.sentAt ? formatDateTime(attempt.sentAt) : '-'}</dd></div>
<div><dt>ACK </dt><dd>{attempt.acknowledgedAt ? formatDateTime(attempt.acknowledgedAt) : '-'}</dd></div>
<div><dt>ACK </dt><dd>{attempt.ackDeadlineAt ? formatDateTime(attempt.ackDeadlineAt) : '-'}</dd></div>
</dl>
<dl className="downstream-attempt-card__identifiers">
<div><dt> ID</dt><dd>{attempt.connectionId ?? '-'}</dd></div>
<div><dt>Sequence_Id</dt><dd>{attempt.sequenceId ?? '-'}</dd></div>
<div><dt>Msg_Id</dt><dd>{attempt.messageId ?? '-'}</dd></div>
</dl>
<div className={`downstream-attempt-result${attempt.errorMessage || attempt.failureType ? ' downstream-attempt-result--error' : ''}`}>
<span>ACK Result{attempt.ackResult ?? '-'}</span>
<strong>{attempt.errorMessage ?? attempt.failureType ?? '本次投递未记录异常'}</strong>
</div>
</div>
</article>
))}
{(record.attempts ?? []).length === 0 ? <p></p> : null}
</div>
</section>
<section className="report-history">
<h3>Payload</h3>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0 }}>{payloadText}</pre>
</section>
</div>
</Modal>
);
}
import { DeliveryDetailModal, deliveryStatusLabel, deliveryTypeLabel, recentSevenDays, requeueItemStatusLabel, requeueTaskStatusLabel, requeueTone, statusLabel, statusTone, type RequeueResult, type RequeueTarget } from './downstreamDeliveryPresentation';
export function AdminDownstreamDeliveriesPage() {
const [records, setRecords] = useState<DownstreamDeliveryRecord[]>([]);
+1 -1
View File
@@ -9,13 +9,13 @@ import { useNavigate } from 'react-router-dom';
import {
Breadcrumb,
Button,
Chart,
Modal,
MoneyText,
Table,
Tag,
type TableColumn,
} from '@/components/ui';
import { Chart } from '@/components/ui/Chart';
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
+1 -1
View File
@@ -1,6 +1,6 @@
import { Check, X } from 'lucide-react';
import { Button, Tag, type TableColumn } from '@/components/ui';
import type { AuditItem, AuditStatus } from '@/mock';
import type { AuditItem, AuditStatus } from '@/api/types/audit';
const riskToneMap = {
low: 'success',
@@ -0,0 +1,162 @@
import { useMemo } from 'react';
import type { DownstreamDeliveryRecord } from '@/api/adminApi';
import { Button, Modal, Tag, type DateRangeValue } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
export const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'warning',
manual_requeueing: 'info',
awaiting_ack: 'info',
delivered: 'success',
failed: 'danger',
unconfirmed: 'warning',
rejected: 'danger',
};
export const statusLabel: Record<string, string> = {
manual_requeueing: '人工重投处理中',
awaiting_ack: '等待客户端确认',
delivered: '客户端已确认',
failed: '投递失败',
unconfirmed: '客户端未确认',
rejected: '客户端拒绝',
};
export function deliveryStatusLabel(record: DownstreamDeliveryRecord) {
if (record.status !== 'pending') return statusLabel[record.status] ?? record.status;
if (record.manualRetryCount > 0) return '人工重投排队中';
if (record.retryCount > 0) return '等待自动重试';
return '待首次投递';
}
export const deliveryTypeLabel: Record<string, string> = {
receipt: '状态回执',
uplink: '上行短信',
};
const attemptStatusLabel: Record<string, string> = {
awaiting_ack: '等待客户端确认',
acknowledged: '客户端已确认',
rejected: '客户端拒绝',
failed: '投递失败',
};
export const requeueTaskStatusLabel: Record<string, string> = {
queued: '排队中', running: '执行中', paused: '已暂停', completed: '已完成',
partial_completed: '部分完成', terminated: '已终止',
};
export const requeueItemStatusLabel: Record<string, string> = {
queued: '排队中', processing: '处理中', waiting_connection: '等待连接',
waiting_external_ack: '等待其他链路确认', waiting_ack: '等待客户确认', success: '成功',
failed: '失败', skipped: '跳过', unprocessed: '未处理',
};
export function requeueTone(status: string) {
if (status === 'completed' || status === 'success') return 'success' as const;
if (status === 'partial_completed' || status === 'failed') return 'danger' as const;
if (status === 'paused' || status === 'skipped' || status === 'unprocessed') return 'warning' as const;
return 'info' as const;
}
export type RequeueTarget =
| { kind: 'single'; record: DownstreamDeliveryRecord }
| { kind: 'batch'; ids: string[] };
export type RequeueResult = {
status: 'success' | 'partial' | 'failed';
title: string;
message: string;
failures?: string[];
};
function formatLocalDate(value: Date) {
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
}
export function recentSevenDays(): DateRangeValue {
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - 6);
return { start: formatLocalDate(start), end: formatLocalDate(end) };
}
function attemptStatusTone(status: string) {
if (status === 'acknowledged') return 'success' as const;
if (status === 'rejected' || status === 'failed') return 'danger' as const;
if (status === 'awaiting_ack') return 'info' as const;
return 'neutral' as const;
}
export function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRecord; onClose: () => void }) {
const payloadText = useMemo(() => JSON.stringify(record.payload ?? {}, null, 2), [record.payload]);
return (
<Modal
open
onClose={onClose}
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{record.id}</p></div>}
footer={<Button onClick={onClose}></Button>}
>
<div className="report-record-detail">
<div className="detail-grid">
<div><span></span><strong>{record.tenant?.name ?? record.tenantId}</strong></div>
<div><span></span><strong>{record.application?.name ?? record.applicationId}</strong></div>
<div><span></span><strong>{deliveryTypeLabel[record.deliveryType] ?? record.deliveryType}</strong></div>
<div><span></span><strong>{deliveryStatusLabel(record)}</strong></div>
<div><span> ID</span><strong>{record.messageId ?? '-'}</strong></div>
<div><span></span><strong>{record.retryCount}</strong></div>
<div><span></span><strong>{record.manualRetryCount}</strong></div>
<div><span></span><strong>{record.lastRetriedAt ? formatDateTime(record.lastRetriedAt) : '-'}</strong></div>
<div><span></span><strong>{record.nextRetryAt ?? '-'}</strong></div>
<div><span></span><strong>{record.retryEnabled ? '开启' : '关闭'}</strong></div>
<div><span></span><strong>{record.sentAt ?? '-'}</strong></div>
<div><span></span><strong>{record.acknowledgedAt ?? '-'}</strong></div>
<div><span>ACK Result</span><strong>{record.ackResult ?? '-'}</strong></div>
<div><span>ACK Sequence_Id</span><strong>{record.ackSequenceId ?? '-'}</strong></div>
<div><span>ACK Msg_Id</span><strong>{record.ackMessageId ?? '-'}</strong></div>
<div><span> ID</span><strong>{record.connectionId ?? '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.lastError ?? '-'}</strong></div>
</div>
<section className="report-history">
<h3></h3>
<div className="downstream-attempt-timeline" aria-label="逐次投递记录">
{(record.attempts ?? []).map((attempt) => (
<article className="downstream-attempt-card" key={attempt.id}>
<div className={`downstream-attempt-marker downstream-attempt-marker--${attemptStatusTone(attempt.status)}`}>
{attempt.attemptNo}
</div>
<div className="downstream-attempt-card__body">
<header>
<strong> {attempt.attemptNo} </strong>
<Tag tone={attemptStatusTone(attempt.status)}>{attemptStatusLabel[attempt.status] ?? attempt.status}</Tag>
</header>
<dl className="downstream-attempt-card__times">
<div><dt></dt><dd>{attempt.sentAt ? formatDateTime(attempt.sentAt) : '-'}</dd></div>
<div><dt>ACK </dt><dd>{attempt.acknowledgedAt ? formatDateTime(attempt.acknowledgedAt) : '-'}</dd></div>
<div><dt>ACK </dt><dd>{attempt.ackDeadlineAt ? formatDateTime(attempt.ackDeadlineAt) : '-'}</dd></div>
</dl>
<dl className="downstream-attempt-card__identifiers">
<div><dt> ID</dt><dd>{attempt.connectionId ?? '-'}</dd></div>
<div><dt>Sequence_Id</dt><dd>{attempt.sequenceId ?? '-'}</dd></div>
<div><dt>Msg_Id</dt><dd>{attempt.messageId ?? '-'}</dd></div>
</dl>
<div className={`downstream-attempt-result${attempt.errorMessage || attempt.failureType ? ' downstream-attempt-result--error' : ''}`}>
<span>ACK Result{attempt.ackResult ?? '-'}</span>
<strong>{attempt.errorMessage ?? attempt.failureType ?? '本次投递未记录异常'}</strong>
</div>
</div>
</article>
))}
{(record.attempts ?? []).length === 0 ? <p></p> : null}
</div>
</section>
<section className="report-history">
<h3>Payload</h3>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0 }}>{payloadText}</pre>
</section>
</div>
</Modal>
);
}
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useState, type ComponentProps } from '
import type { EChartsOption } from 'echarts';
import { AlertTriangle, Ban, BellRing, RefreshCw, Settings2, ShieldCheck, ShieldOff } from 'lucide-react';
import { adminApi, type SecurityAlert, type SecurityBlock, type SecurityOverview, type SecurityProtectedNetwork, type SecurityRule } from '@/api/adminApi';
import { Breadcrumb, Button, Chart, Input, Modal as BaseModal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal as BaseModal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
import { Chart } from '@/components/ui/Chart';
import './AdminSecurityDetectionPage.css';
const statusLabels: Record<string, string> = { open: '待处理', acknowledged: '已确认', blocked: '已封禁', block_failed: '封禁失败', ignored: '已忽略', requested: '执行中', failed: '失败', released: '已解封' };
@@ -24,7 +24,8 @@ import {
type InfrastructureMonitoringOverview,
type InfrastructureMonitoringRange,
} from '@/api/adminApi';
import { Breadcrumb, Button, Chart, Input, Modal, Table, Tag, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Table, Tag, type TableColumn } from '@/components/ui';
import { Chart } from '@/components/ui/Chart';
import './AdminSystemMonitoringPage.css';
const RANGE_OPTIONS: Array<{ value: InfrastructureMonitoringRange; label: string }> = [
+2 -1
View File
@@ -10,7 +10,8 @@ import {
WalletCards,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button, Chart, Table, Tag, type TableColumn } from '@/components/ui';
import { Button, Table, Tag, type TableColumn } from '@/components/ui';
import { Chart } from '@/components/ui/Chart';
import { clientApi, type DashboardResponse } from '@/api/adminApi';
import { createLineOption, createPieOption } from '@/theme/chartOptions';
import { formatDateTime } from '@/utils/dateTime';
+7 -2
View File
@@ -1,6 +1,11 @@
import { useEffect, useRef } from 'react';
import type { EChartsOption } from 'echarts';
import * as echarts from 'echarts';
import { BarChart, LineChart, PieChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
import { init, use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
use([BarChart, LineChart, PieChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer]);
type ChartProps = {
option: EChartsOption;
@@ -15,7 +20,7 @@ export function Chart({ option, height = 280 }: ChartProps) {
return undefined;
}
const chart = echarts.init(chartRef.current);
const chart = init(chartRef.current);
chart.setOption(option);
const resizeObserver = new ResizeObserver(() => chart.resize());
-1
View File
@@ -1,6 +1,5 @@
export { Button } from './Button';
export { Breadcrumb } from './Breadcrumb';
export { Chart } from './Chart';
export { CarrierTag, normalizeCarrierTag } from './CarrierTag';
export type { CarrierTagValue } from './CarrierTag';
export { DateRangeInput } from './DateRangeInput';
+75 -59
View File
@@ -1,69 +1,83 @@
import { lazy, Suspense, type ComponentType, type LazyExoticComponent } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { AdminAnalyticsPage } from '@/apps/admin/AdminAnalyticsPage';
import { AdminChannelGroupFormPage } from '@/apps/admin/AdminChannelGroupFormPage';
import { AdminChannelGroupsPage } from '@/apps/admin/AdminChannelGroupsPage';
import { AdminChannelsPage } from '@/apps/admin/AdminChannelsPage';
import { AdminChannelReportPage } from '@/apps/admin/AdminChannelReportPage';
import { AdminCustomerDetailPage } from '@/apps/admin/AdminCustomerDetailPage';
import { AdminCustomerFormPage } from '@/apps/admin/AdminCustomerFormPage';
import { AdminCustomersPage } from '@/apps/admin/AdminCustomersPage';
import { AdminDrainageFieldsPage } from '@/apps/admin/AdminDrainageFieldsPage';
import { AdminDrainageDetectionRulesPage } from '@/apps/admin/AdminDrainageDetectionRulesPage';
import { AdminDownstreamDeliveriesPage } from '@/apps/admin/AdminDownstreamDeliveriesPage';
import { AdminDownstreamRecoveryStatusesPage } from '@/apps/admin/AdminDownstreamRecoveryStatusesPage';
import { AdminEnterpriseApplicationsPage } from '@/apps/admin/AdminEnterpriseApplicationsPage';
import { AdminEnterpriseBlacklistPage } from '@/apps/admin/AdminEnterpriseBlacklistPage';
import { AdminEnterpriseSignaturesPage } from '@/apps/admin/AdminEnterpriseSignaturesPage';
import { AdminEnterpriseTemplatesPage } from '@/apps/admin/AdminEnterpriseTemplatesPage';
import { AdminGlobalBlacklistPage } from '@/apps/admin/AdminGlobalBlacklistPage';
import { AdminGatewaySubmitExceptionsPage } from '@/apps/admin/AdminGatewaySubmitExceptionsPage';
import { AdminHome } from '@/apps/admin/AdminHome';
import { AdminMonitorPage } from '@/apps/admin/AdminMonitorPage';
import { AdminPhoneSegmentsPage } from '@/apps/admin/AdminPhoneSegmentsPage';
import { AdminRechargeRecordsPage } from '@/apps/admin/AdminRechargeRecordsPage';
import { AdminReconciliationReportsPage } from '@/apps/admin/AdminReconciliationReportsPage';
import { AdminProfitReportsPage } from '@/apps/admin/AdminProfitReportsPage';
import { AdminQualityReportsPage } from '@/apps/admin/AdminQualityReportsPage';
import { AdminReportRecordsPage } from '@/apps/admin/AdminReportRecordsPage';
import { AdminReportTasksPage } from '@/apps/admin/AdminReportTasksPage';
import { AdminReportMaterialsPage } from '@/apps/admin/AdminReportMaterialsPage';
import { AdminSensitiveWordsPage } from '@/apps/admin/AdminSensitiveWordsPage';
import { AdminSmsAuditPage } from '@/apps/admin/AdminSmsAuditPage';
import { AdminRiskRulesPage } from '@/apps/admin/AdminRiskRulesPage';
import { AdminSmsApplicationFormPage } from '@/apps/admin/AdminSmsApplicationFormPage';
import { AdminSmsRecordsPage } from '@/apps/admin/AdminSmsRecordsPage';
import { AdminSmsTaskProgressPage } from '@/apps/admin/AdminSmsTaskProgressPage';
import { AdminSmsUplinkRecordsPage } from '@/apps/admin/AdminSmsUplinkRecordsPage';
import { AdminSignatureAuditPage } from '@/apps/admin/AdminSignatureAuditPage';
import { AdminSignatureRetirementPage } from '@/apps/admin/AdminSignatureRetirementPage';
import { AdminDrainageAuditPage } from '@/apps/admin/AdminDrainageAuditPage';
import { AdminSystemLogsPage } from '@/apps/admin/AdminSystemLogsPage';
import { AdminSystemMonitoringPage } from '@/apps/admin/system-monitoring/AdminSystemMonitoringPage';
import { AdminSecurityDetectionPage } from '@/apps/admin/security-detection/AdminSecurityDetectionPage';
import { AdminTemplateAuditPage } from '@/apps/admin/AdminTemplateAuditPage';
import { AdminUsersPage } from '@/apps/admin/AdminUsersPage';
import { AdminEnterpriseAuditPage } from '@/apps/admin/AdminEnterpriseAuditPage';
import { ClientApplicationsPage } from '@/apps/client/ClientApplicationsPage';
import { ClientBatchTasksPage } from '@/apps/client/ClientBatchTasksPage';
import { ClientBillingPage } from '@/apps/client/ClientBillingPage';
import { ClientEnterpriseAuthPage } from '@/apps/client/ClientEnterpriseAuthPage';
import { ClientHome } from '@/apps/client/ClientHome';
import { ClientHttpApiPage } from '@/apps/client/ClientHttpApiPage';
import { ClientSendDetailPage } from '@/apps/client/ClientSendDetailPage';
import { ClientSendPage } from '@/apps/client/ClientSendPage';
import { ClientSignaturesPage } from '@/apps/client/ClientSignaturesPage';
import { ClientSystemLogsPage } from '@/apps/client/ClientSystemLogsPage';
import { ClientTemplatesPage } from '@/apps/client/ClientTemplatesPage';
import { ClientUplinkMessagesPage } from '@/apps/client/ClientUplinkMessagesPage';
import { ClientUsersPage } from '@/apps/client/ClientUsersPage';
import { LoginPage } from '@/apps/LoginPage';
import { PagePlaceholder } from '@/components/PagePlaceholder';
import { AdminLayout } from '@/layouts/AdminLayout';
import { ClientLayout } from '@/layouts/ClientLayout';
import { RouteLoadBoundary } from './RouteLoadBoundary';
function lazyNamed(loader: () => Promise<unknown>, exportName: string): LazyExoticComponent<ComponentType<any>> {
return lazy(async () => {
const loaded = await loader() as Record<string, ComponentType>;
const component = loaded[exportName];
if (!component) throw new Error(`Lazy route export ${exportName} was not found`);
return { default: component };
});
}
const AdminAnalyticsPage = lazyNamed(() => import('@/apps/admin/AdminAnalyticsPage'), 'AdminAnalyticsPage');
const AdminChannelGroupFormPage = lazyNamed(() => import('@/apps/admin/AdminChannelGroupFormPage'), 'AdminChannelGroupFormPage');
const AdminChannelGroupsPage = lazyNamed(() => import('@/apps/admin/AdminChannelGroupsPage'), 'AdminChannelGroupsPage');
const AdminChannelsPage = lazyNamed(() => import('@/apps/admin/AdminChannelsPage'), 'AdminChannelsPage');
const AdminChannelReportPage = lazyNamed(() => import('@/apps/admin/AdminChannelReportPage'), 'AdminChannelReportPage');
const AdminCustomerDetailPage = lazyNamed(() => import('@/apps/admin/AdminCustomerDetailPage'), 'AdminCustomerDetailPage');
const AdminCustomerFormPage = lazyNamed(() => import('@/apps/admin/AdminCustomerFormPage'), 'AdminCustomerFormPage');
const AdminCustomersPage = lazyNamed(() => import('@/apps/admin/AdminCustomersPage'), 'AdminCustomersPage');
const AdminDrainageFieldsPage = lazyNamed(() => import('@/apps/admin/AdminDrainageFieldsPage'), 'AdminDrainageFieldsPage');
const AdminDrainageDetectionRulesPage = lazyNamed(() => import('@/apps/admin/AdminDrainageDetectionRulesPage'), 'AdminDrainageDetectionRulesPage');
const AdminDownstreamDeliveriesPage = lazyNamed(() => import('@/apps/admin/AdminDownstreamDeliveriesPage'), 'AdminDownstreamDeliveriesPage');
const AdminDownstreamRecoveryStatusesPage = lazyNamed(() => import('@/apps/admin/AdminDownstreamRecoveryStatusesPage'), 'AdminDownstreamRecoveryStatusesPage');
const AdminEnterpriseApplicationsPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseApplicationsPage'), 'AdminEnterpriseApplicationsPage');
const AdminEnterpriseBlacklistPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseBlacklistPage'), 'AdminEnterpriseBlacklistPage');
const AdminEnterpriseSignaturesPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseSignaturesPage'), 'AdminEnterpriseSignaturesPage');
const AdminEnterpriseTemplatesPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseTemplatesPage'), 'AdminEnterpriseTemplatesPage');
const AdminGlobalBlacklistPage = lazyNamed(() => import('@/apps/admin/AdminGlobalBlacklistPage'), 'AdminGlobalBlacklistPage');
const AdminGatewaySubmitExceptionsPage = lazyNamed(() => import('@/apps/admin/AdminGatewaySubmitExceptionsPage'), 'AdminGatewaySubmitExceptionsPage');
const AdminHome = lazyNamed(() => import('@/apps/admin/AdminHome'), 'AdminHome');
const AdminMonitorPage = lazyNamed(() => import('@/apps/admin/AdminMonitorPage'), 'AdminMonitorPage');
const AdminPhoneSegmentsPage = lazyNamed(() => import('@/apps/admin/AdminPhoneSegmentsPage'), 'AdminPhoneSegmentsPage');
const AdminRechargeRecordsPage = lazyNamed(() => import('@/apps/admin/AdminRechargeRecordsPage'), 'AdminRechargeRecordsPage');
const AdminReconciliationReportsPage = lazyNamed(() => import('@/apps/admin/AdminReconciliationReportsPage'), 'AdminReconciliationReportsPage');
const AdminProfitReportsPage = lazyNamed(() => import('@/apps/admin/AdminProfitReportsPage'), 'AdminProfitReportsPage');
const AdminQualityReportsPage = lazyNamed(() => import('@/apps/admin/AdminQualityReportsPage'), 'AdminQualityReportsPage');
const AdminReportRecordsPage = lazyNamed(() => import('@/apps/admin/AdminReportRecordsPage'), 'AdminReportRecordsPage');
const AdminReportTasksPage = lazyNamed(() => import('@/apps/admin/AdminReportTasksPage'), 'AdminReportTasksPage');
const AdminReportMaterialsPage = lazyNamed(() => import('@/apps/admin/AdminReportMaterialsPage'), 'AdminReportMaterialsPage');
const AdminSensitiveWordsPage = lazyNamed(() => import('@/apps/admin/AdminSensitiveWordsPage'), 'AdminSensitiveWordsPage');
const AdminSmsAuditPage = lazyNamed(() => import('@/apps/admin/AdminSmsAuditPage'), 'AdminSmsAuditPage');
const AdminRiskRulesPage = lazyNamed(() => import('@/apps/admin/AdminRiskRulesPage'), 'AdminRiskRulesPage');
const AdminSmsApplicationFormPage = lazyNamed(() => import('@/apps/admin/AdminSmsApplicationFormPage'), 'AdminSmsApplicationFormPage');
const AdminSmsRecordsPage = lazyNamed(() => import('@/apps/admin/AdminSmsRecordsPage'), 'AdminSmsRecordsPage');
const AdminSmsTaskProgressPage = lazyNamed(() => import('@/apps/admin/AdminSmsTaskProgressPage'), 'AdminSmsTaskProgressPage');
const AdminSmsUplinkRecordsPage = lazyNamed(() => import('@/apps/admin/AdminSmsUplinkRecordsPage'), 'AdminSmsUplinkRecordsPage');
const AdminSignatureAuditPage = lazyNamed(() => import('@/apps/admin/AdminSignatureAuditPage'), 'AdminSignatureAuditPage');
const AdminSignatureRetirementPage = lazyNamed(() => import('@/apps/admin/AdminSignatureRetirementPage'), 'AdminSignatureRetirementPage');
const AdminDrainageAuditPage = lazyNamed(() => import('@/apps/admin/AdminDrainageAuditPage'), 'AdminDrainageAuditPage');
const AdminSystemLogsPage = lazyNamed(() => import('@/apps/admin/AdminSystemLogsPage'), 'AdminSystemLogsPage');
const AdminSystemMonitoringPage = lazyNamed(() => import('@/apps/admin/system-monitoring/AdminSystemMonitoringPage'), 'AdminSystemMonitoringPage');
const AdminSecurityDetectionPage = lazyNamed(() => import('@/apps/admin/security-detection/AdminSecurityDetectionPage'), 'AdminSecurityDetectionPage');
const AdminTemplateAuditPage = lazyNamed(() => import('@/apps/admin/AdminTemplateAuditPage'), 'AdminTemplateAuditPage');
const AdminUsersPage = lazyNamed(() => import('@/apps/admin/AdminUsersPage'), 'AdminUsersPage');
const AdminEnterpriseAuditPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseAuditPage'), 'AdminEnterpriseAuditPage');
const ClientApplicationsPage = lazyNamed(() => import('@/apps/client/ClientApplicationsPage'), 'ClientApplicationsPage');
const ClientBatchTasksPage = lazyNamed(() => import('@/apps/client/ClientBatchTasksPage'), 'ClientBatchTasksPage');
const ClientBillingPage = lazyNamed(() => import('@/apps/client/ClientBillingPage'), 'ClientBillingPage');
const ClientEnterpriseAuthPage = lazyNamed(() => import('@/apps/client/ClientEnterpriseAuthPage'), 'ClientEnterpriseAuthPage');
const ClientHome = lazyNamed(() => import('@/apps/client/ClientHome'), 'ClientHome');
const ClientHttpApiPage = lazyNamed(() => import('@/apps/client/ClientHttpApiPage'), 'ClientHttpApiPage');
const ClientSendDetailPage = lazyNamed(() => import('@/apps/client/ClientSendDetailPage'), 'ClientSendDetailPage');
const ClientSendPage = lazyNamed(() => import('@/apps/client/ClientSendPage'), 'ClientSendPage');
const ClientSignaturesPage = lazyNamed(() => import('@/apps/client/ClientSignaturesPage'), 'ClientSignaturesPage');
const ClientSystemLogsPage = lazyNamed(() => import('@/apps/client/ClientSystemLogsPage'), 'ClientSystemLogsPage');
const ClientTemplatesPage = lazyNamed(() => import('@/apps/client/ClientTemplatesPage'), 'ClientTemplatesPage');
const ClientUplinkMessagesPage = lazyNamed(() => import('@/apps/client/ClientUplinkMessagesPage'), 'ClientUplinkMessagesPage');
const ClientUsersPage = lazyNamed(() => import('@/apps/client/ClientUsersPage'), 'ClientUsersPage');
export function AppRoutes() {
return (
<Routes>
<RouteLoadBoundary>
<Suspense fallback={<div className="page-stack"><div className="surface ui-table__empty">...</div></div>}>
<Routes>
<Route path="/" element={<Navigate to="/client" replace />} />
<Route path="/client/login" element={<LoginPage portal="client" />} />
<Route path="/admin/login" element={<LoginPage portal="admin" />} />
@@ -148,6 +162,8 @@ export function AppRoutes() {
<Route path="security-detection" element={<AdminSecurityDetectionPage />} />
<Route path="*" element={<PagePlaceholder />} />
</Route>
</Routes>
</Routes>
</Suspense>
</RouteLoadBoundary>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
type Props = { children: ReactNode };
type State = { error?: Error };
export class RouteLoadBoundary extends Component<Props, State> {
state: State = {};
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('Route chunk failed to load', error, info.componentStack);
}
render() {
if (!this.state.error) return this.props.children;
return (
<div className="page-stack">
<div className="surface ui-table__empty" role="alert">
<p></p>
<button className="ui-button ui-button--primary" type="button" onClick={() => window.location.reload()}>
</button>
</div>
</div>
);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { gzipSync } from 'node:zlib';
import { readdirSync, readFileSync } from 'node:fs';
import { basename, resolve } from 'node:path';
const root = resolve(import.meta.dirname, '../..');
const dist = resolve(root, 'dist');
const html = readFileSync(resolve(dist, 'index.html'), 'utf8');
const entryMatch = html.match(/<script[^>]+src="([^"]+\.js)"/);
if (!entryMatch) throw new Error('Unable to locate the Vite entry script in dist/index.html');
const assets = resolve(dist, 'assets');
const files = readdirSync(assets).filter((file) => file.endsWith('.js'));
const sizes = files.map((file) => ({ file, gzip: gzipSync(readFileSync(resolve(assets, file))).length }));
const entryName = basename(entryMatch[1]);
const entry = sizes.find((item) => item.file === entryName);
if (!entry) throw new Error(`Entry asset ${entryName} was not found`);
const entryBudget = Number(process.env.BUNDLE_ENTRY_GZIP_BUDGET ?? 250 * 1024);
// ECharts core + the three chart types used by the platform currently settle at ~182 KiB.
// Keep a narrow calibrated ceiling so future chart imports cannot silently restore the full bundle.
const chunkBudget = Number(process.env.BUNDLE_CHUNK_GZIP_BUDGET ?? 190 * 1024);
const oversized = sizes.filter((item) => item.file !== entryName && item.gzip > chunkBudget);
console.log(`entry ${entry.file}: ${(entry.gzip / 1024).toFixed(2)} KiB gzip (budget ${(entryBudget / 1024).toFixed(0)} KiB)`);
for (const item of sizes.toSorted((a, b) => b.gzip - a.gzip).slice(0, 10)) {
console.log(`${item.file}: ${(item.gzip / 1024).toFixed(2)} KiB gzip`);
}
if (entry.gzip > entryBudget || oversized.length) {
if (oversized.length) console.error(`Oversized async chunks: ${oversized.map((item) => item.file).join(', ')}`);
process.exit(1);
}
+50
View File
@@ -0,0 +1,50 @@
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const root = resolve(import.meta.dirname, '../..');
const violations = [];
const productionFiles = execFileSync('git', ['ls-files', 'src/apps/**/*.ts', 'src/apps/**/*.tsx', 'src/components/**/*.ts', 'src/components/**/*.tsx'], { cwd: root, encoding: 'utf8' })
.split(/\r?\n/).filter(Boolean);
for (const file of productionFiles) {
const content = readFileSync(resolve(root, file), 'utf8');
if (/from\s+['"]@\/mock(?:\/|['"])/.test(content)) violations.push(`${file}: production code imports @/mock`);
}
const clientControllers = execFileSync('git', ['ls-files', 'api/src/**/*.controller.ts'], { cwd: root, encoding: 'utf8' })
.split(/\r?\n/).filter(Boolean);
for (const file of clientControllers) {
const content = readFileSync(resolve(root, file), 'utf8');
const clientClassOffset = content.indexOf('export class Client');
const clientSection = clientClassOffset >= 0 ? content.slice(clientClassOffset) : content;
if (/['"]client(?:\/|['"])/.test(clientSection) && /@TenantId\(\)/.test(clientSection)) {
violations.push(`${file}: client route still reads request-controlled @TenantId()`);
}
}
const trackedBuildCaches = execFileSync('git', ['ls-files', '*.tsbuildinfo', '**/*.tsbuildinfo'], { cwd: root, encoding: 'utf8' }).trim();
if (trackedBuildCaches) violations.push(`tracked TypeScript build caches: ${trackedBuildCaches.replace(/\r?\n/g, ', ')}`);
const usersService = readFileSync(resolve(root, 'api/src/users/users.service.ts'), 'utf8');
if (/createHash\(['"]sha256['"]\)/.test(usersService)) {
violations.push('api/src/users/users.service.ts: password writes must use the versioned password hasher');
}
const authService = readFileSync(resolve(root, 'api/src/auth/auth.service.ts'), 'utf8');
if (/passwordHash\s*===|===\s*[^\n;]*passwordHash/.test(authService)) {
violations.push('api/src/auth/auth.service.ts: password hashes must not be compared directly');
}
const packageJson = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
if (packageJson.dependencies?.['react-router-dom'] !== '7.18.2') {
violations.push('package.json: react-router-dom must remain on the remediated 7.18.2 baseline');
}
if (packageJson.overrides?.nanoid !== '3.3.18') {
violations.push('package.json: nanoid override must remain on the remediated 3.3.18 baseline');
}
if (violations.length) {
console.error(violations.map((item) => `ERROR: ${item}`).join('\n'));
process.exit(1);
}
console.log('Code quality structural checks passed.');
@@ -7,7 +7,8 @@ const rootLock = JSON.parse(readFileSync(join(workspaceRoot, 'package-lock.json'
const apiLock = JSON.parse(readFileSync(join(workspaceRoot, 'api', 'package-lock.json'), 'utf8'));
assertVersionAtLeast(rootLock.packages['node_modules/postcss']?.version, [8, 5, 18], 'postcss');
assertEqual(rootLock.packages['node_modules/react-router']?.version, '7.18.1', 'react-router');
assertVersionAtLeast(rootLock.packages['node_modules/react-router']?.version, [7, 18, 2], 'react-router');
assertVersionAtLeast(rootLock.packages['node_modules/nanoid']?.version, [3, 3, 18], 'nanoid');
assertEqual(apiLock.packages['node_modules/brace-expansion-safe']?.version, '5.0.8', 'brace-expansion-safe');
assertEqual(
apiLock.packages['node_modules/brace-expansion']?.resolved,
@@ -50,7 +51,7 @@ for (const relativePath of [
}
}
console.log('Dependency mitigations verified: PostCSS patched, React Router RSC unused, brace expansion bounded and compatible.');
console.log('Dependency mitigations verified: PostCSS, React Router and NanoID patched; RSC unused; brace expansion bounded and compatible.');
function sourceFiles(directory) {
return readdirSync(directory).flatMap((name) => {