fix: harden tenant auth and quality gates
This commit is contained in:
@@ -12,8 +12,15 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
logs/
|
logs/
|
||||||
|
coverage/
|
||||||
gateway/gateway.exe
|
gateway/gateway.exe
|
||||||
dump.rdb
|
dump.rdb
|
||||||
|
*.tsbuildinfo
|
||||||
|
outputs/
|
||||||
|
tmp_*.py
|
||||||
|
tmp_*.ps1
|
||||||
|
tmp_*.sh
|
||||||
|
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -9,4 +9,12 @@ module.exports = {
|
|||||||
testMatch: ['**/*.spec.ts'],
|
testMatch: ['**/*.spec.ts'],
|
||||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
moduleFileExtensions: ['ts', 'js', 'json'],
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
|
coverageThreshold: {
|
||||||
|
global: {
|
||||||
|
statements: 59,
|
||||||
|
branches: 50,
|
||||||
|
functions: 60,
|
||||||
|
lines: 62,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"test": "jest --runInBand",
|
"test": "jest --runInBand",
|
||||||
|
"test:coverage": "jest --runInBand --coverage",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"start:dev": "ts-node src/main.ts",
|
"start:dev": "ts-node src/main.ts",
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { defineConfig } from 'prisma/config';
|
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({
|
export default defineConfig({
|
||||||
schema: 'prisma/schema.prisma',
|
schema: 'prisma/schema.prisma',
|
||||||
migrations: {
|
migrations: {
|
||||||
path: 'prisma/migrations',
|
path: 'prisma/migrations',
|
||||||
},
|
},
|
||||||
datasource: {
|
datasource: {
|
||||||
url:
|
url: databaseUrl ?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
||||||
process.env.DATABASE_URL ??
|
|
||||||
'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentSessionUserId } from './current-session-user.decorator';
|
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 { DEVELOPMENT_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SessionPortal, SessionService } from './session.service';
|
||||||
import type { SessionRequest } from './session-validation.middleware';
|
import type { SessionRequest } from './session-validation.middleware';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
@@ -26,6 +28,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('admin/auth/login')
|
@Post('admin/auth/login')
|
||||||
|
@UsePipes(strictValidationPipe)
|
||||||
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
async adminLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||||
let result: Awaited<ReturnType<AuthService['login']>>;
|
let result: Awaited<ReturnType<AuthService['login']>>;
|
||||||
try {
|
try {
|
||||||
@@ -43,6 +46,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('client/auth/login')
|
@Post('client/auth/login')
|
||||||
|
@UsePipes(strictValidationPipe)
|
||||||
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
async clientLogin(@Body() body: LoginDto, @Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
|
||||||
let result: Awaited<ReturnType<AuthService['login']>>;
|
let result: Awaited<ReturnType<AuthService['login']>>;
|
||||||
try {
|
try {
|
||||||
@@ -95,7 +99,9 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(['admin/auth/session/unlock', 'client/auth/session/unlock'])
|
@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);
|
this.assertSession(request);
|
||||||
const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password);
|
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: '锁定时间过长,请重新登录' });
|
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'])
|
@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);
|
this.assertSession(request);
|
||||||
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
|
const result = await this.auth.reauthenticate(request.sessionToken!, request.sessionUserId!, password);
|
||||||
if (result.status !== 'active') throw new UnauthorizedException({ code: 'SESSION_LOCKED', message: '会话已锁定' });
|
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'])
|
@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('登录会话无效,请重新登录');
|
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) {
|
private async finishLogin(result: Awaited<ReturnType<AuthService['login']>>, request: SessionRequest, response: CookieResponse) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { UnauthorizedException } from '@nestjs/common';
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { hashPassword } from '../users/users.service';
|
import { legacyHashPassword } from './password-hasher';
|
||||||
|
|
||||||
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
|
function createUsersMock(roleCode: string, overrides: Record<string, unknown> = {}) {
|
||||||
const user = {
|
const user = {
|
||||||
@@ -10,7 +10,7 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
|
|||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
phone: '13800000000',
|
phone: '13800000000',
|
||||||
displayName: '用户',
|
displayName: '用户',
|
||||||
passwordHash: hashPassword('secret1'),
|
passwordHash: legacyHashPassword('secret1'),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
lockedUntil: null,
|
lockedUntil: null,
|
||||||
@@ -20,24 +20,32 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
findByLogin: jest.fn().mockResolvedValue(user),
|
findByLogin: jest.fn().mockResolvedValue(user),
|
||||||
|
verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'),
|
||||||
recordLoginSuccess: jest.fn(),
|
recordLoginSuccess: jest.fn(),
|
||||||
recordLoginFailure: jest.fn(),
|
recordLoginFailure: jest.fn(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSessionsMock() {
|
function createSessionsMock() {
|
||||||
|
const captchas = new Map<string, string>();
|
||||||
|
const failures = new Map<string, number>();
|
||||||
const record = {
|
const record = {
|
||||||
userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1,
|
userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1,
|
||||||
lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
||||||
};
|
};
|
||||||
return {
|
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 }),
|
create: jest.fn().mockResolvedValue({ token: 'opaque-session-token', record }),
|
||||||
publicSession: jest.fn().mockReturnValue({ idleTimeoutSeconds: 3600, absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString() }),
|
publicSession: jest.fn().mockReturnValue({ idleTimeoutSeconds: 3600, absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString() }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
|
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);
|
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
|
||||||
return service.login({
|
return service.login({
|
||||||
login: 'user@example.com',
|
login: 'user@example.com',
|
||||||
|
|||||||
@@ -1,37 +1,20 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
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';
|
import { SessionService } from './session.service';
|
||||||
|
|
||||||
export interface LoginDto {
|
|
||||||
login: string;
|
|
||||||
password: string;
|
|
||||||
captchaId: string;
|
|
||||||
captchaText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type LoginPortal = 'admin' | 'client';
|
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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(private readonly users: UsersService, private readonly sessions: SessionService) {}
|
constructor(private readonly users: UsersService, private readonly sessions: SessionService) {}
|
||||||
|
|
||||||
createCaptcha() {
|
async createCaptcha() {
|
||||||
const left = Math.floor(10 + Math.random() * 40);
|
const left = Math.floor(10 + Math.random() * 40);
|
||||||
const right = Math.floor(1 + Math.random() * 9);
|
const right = Math.floor(1 + Math.random() * 9);
|
||||||
const captchaId = randomUUID();
|
const captchaId = randomUUID();
|
||||||
captchaStore.set(captchaId, {
|
await this.sessions.storeCaptcha(captchaId, String(left + right), 5 * 60);
|
||||||
answer: String(left + right),
|
|
||||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
captchaId,
|
captchaId,
|
||||||
challenge: `${left} + ${right} = ?`,
|
challenge: `${left} + ${right} = ?`,
|
||||||
@@ -44,12 +27,12 @@ export class AuthService {
|
|||||||
if (!login || !data.password) {
|
if (!login || !data.password) {
|
||||||
throw new BadRequestException('login and password are required');
|
throw new BadRequestException('login and password are required');
|
||||||
}
|
}
|
||||||
this.verifyCaptcha(data.captchaId, data.captchaText);
|
await this.verifyCaptcha(data.captchaId, data.captchaText);
|
||||||
this.assertAnonymousNotLocked(login);
|
await this.assertAnonymousNotLocked(login);
|
||||||
|
|
||||||
const user = await this.users.findByLogin(login);
|
const user = await this.users.findByLogin(login);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
this.recordAnonymousFailure(login);
|
await this.sessions.recordAnonymousLoginFailure(login);
|
||||||
throw new UnauthorizedException('Invalid login or password');
|
throw new UnauthorizedException('Invalid login or password');
|
||||||
}
|
}
|
||||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||||
@@ -59,7 +42,7 @@ export class AuthService {
|
|||||||
await this.users.recordLoginFailure(user.id);
|
await this.users.recordLoginFailure(user.id);
|
||||||
throw new UnauthorizedException('User is disabled or deleted');
|
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);
|
await this.users.recordLoginFailure(user.id);
|
||||||
throw new UnauthorizedException('Invalid login or password');
|
throw new UnauthorizedException('Invalid login or password');
|
||||||
}
|
}
|
||||||
@@ -75,7 +58,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.users.recordLoginSuccess(user.id);
|
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);
|
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
|
||||||
return {
|
return {
|
||||||
@@ -105,30 +88,19 @@ export class AuthService {
|
|||||||
return this.sessions.markReauthenticated(token);
|
return this.sessions.markReauthenticated(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private verifyCaptcha(captchaId?: string, captchaText?: string) {
|
private async verifyCaptcha(captchaId?: string, captchaText?: string) {
|
||||||
const record = captchaId ? captchaStore.get(captchaId) : undefined;
|
const answer = captchaId ? await this.sessions.consumeCaptcha(captchaId) : null;
|
||||||
captchaStore.delete(captchaId ?? '');
|
if (!answer) {
|
||||||
if (!record || record.expiresAt < Date.now()) {
|
|
||||||
throw new BadRequestException('Captcha expired, refresh and try again');
|
throw new BadRequestException('Captcha expired, refresh and try again');
|
||||||
}
|
}
|
||||||
if (record.answer !== captchaText?.trim()) {
|
if (answer !== captchaText?.trim()) {
|
||||||
throw new BadRequestException('Captcha is incorrect');
|
throw new BadRequestException('Captcha is incorrect');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertAnonymousNotLocked(login: string) {
|
private async assertAnonymousNotLocked(login: string) {
|
||||||
const current = anonymousFailures.get(login);
|
if (await this.sessions.isAnonymousLoginLocked(login)) {
|
||||||
if (current?.lockedUntil && current.lockedUntil > Date.now()) {
|
|
||||||
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
|
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;
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
import { SessionValidationMiddleware, type SessionRequest } from './session-validation.middleware';
|
||||||
|
|
||||||
const record = {
|
const record = {
|
||||||
@@ -6,10 +6,10 @@ const record = {
|
|||||||
lastActivityAt: 1, lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
|
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 {
|
return {
|
||||||
originalUrl: path,
|
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`);
|
const cookieName = jest.fn((portal: 'admin' | 'client') => `cmpp_${portal}_session`);
|
||||||
|
|
||||||
it('accepts an active Redis session and exposes its user and record', async () => {
|
it('accepts an active Redis session and exposes its user and record', async () => {
|
||||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 3 }) } };
|
const 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 sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
||||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
||||||
const currentRequest = request();
|
const currentRequest = request();
|
||||||
@@ -32,7 +32,7 @@ describe('SessionValidationMiddleware', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a session after the user session version changes', async () => {
|
it('rejects a session after the user session version changes', async () => {
|
||||||
const prisma = { user: { findUnique: jest.fn().mockResolvedValue({ id: 'user-1', status: 'active', deletedAt: null, sessionVersion: 4 }) } };
|
const 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 sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record }), remove: jest.fn() };
|
||||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
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 () => {
|
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 sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'locked', record }), remove: jest.fn() };
|
||||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
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 () => {
|
it('selects only the cookie belonging to the requested portal', async () => {
|
||||||
const clientRecord = { ...record, portal: 'client' as const };
|
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 sessions = { cookieName, validate: jest.fn().mockResolvedValue({ status: 'active', record: clientRecord }), remove: jest.fn() };
|
||||||
const middleware = new SessionValidationMiddleware(prisma as never, sessions as never);
|
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');
|
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(sessions.validate).toHaveBeenCalledWith('client-token', false);
|
||||||
expect(currentRequest.sessionToken).toBe('client-token');
|
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 () => {
|
it('does not accept an admin cookie for a client route', async () => {
|
||||||
|
|||||||
@@ -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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuthSessionRecord, SessionPortal, SessionService } from './session.service';
|
import { AuthSessionRecord, SessionPortal, SessionService } from './session.service';
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ export type SessionRequest = {
|
|||||||
url?: string;
|
url?: string;
|
||||||
sessionUserId?: string;
|
sessionUserId?: string;
|
||||||
sessionToken?: string;
|
sessionToken?: string;
|
||||||
|
sessionTenantId?: string;
|
||||||
authSession?: AuthSessionRecord;
|
authSession?: AuthSessionRecord;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ export class SessionValidationMiddleware implements NestMiddleware {
|
|||||||
|
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: result.record.userId },
|
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) {
|
if (!user || user.status !== 'active' || user.deletedAt || user.sessionVersion !== result.record.sessionVersion) {
|
||||||
await this.sessions.remove(token);
|
await this.sessions.remove(token);
|
||||||
@@ -51,6 +52,25 @@ export class SessionValidationMiddleware implements NestMiddleware {
|
|||||||
request.sessionUserId = user.id;
|
request.sessionUserId = user.id;
|
||||||
request.sessionToken = token;
|
request.sessionToken = token;
|
||||||
request.authSession = result.record;
|
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);
|
const isSessionRecoveryRoute = /\/auth\/(?:session(?:\/unlock)?|logout)(?:\?|$)/.test(path);
|
||||||
if (result.status === 'locked' && !isSessionRecoveryRoute) {
|
if (result.status === 'locked' && !isSessionRecoveryRoute) {
|
||||||
if (result.newlyLocked) {
|
if (result.newlyLocked) {
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export type SessionValidationResult =
|
|||||||
| { status: 'expired'; code: 'SESSION_INVALID' | 'SESSION_ABSOLUTE_TIMEOUT' | 'SESSION_LOCK_TIMEOUT' };
|
| { status: 'expired'; code: 'SESSION_INVALID' | 'SESSION_ABSOLUTE_TIMEOUT' | 'SESSION_LOCK_TIMEOUT' };
|
||||||
|
|
||||||
const SESSION_PREFIX = 'cmpp:auth:session:';
|
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 SESSION_COOKIE_NAME = '__Host-cmpp_session';
|
||||||
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
|
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
|
||||||
export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_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));
|
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) {
|
isRecentlyAuthenticated(record: AuthSessionRecord) {
|
||||||
return Date.now() - record.lastAuthenticatedAt < this.recentAuthenticationMs;
|
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')}`;
|
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() {
|
private get client() {
|
||||||
if (!this.redis) {
|
if (!this.redis) {
|
||||||
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
this.redis = new IORedis(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379', {
|
||||||
|
|||||||
@@ -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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { TenantId } from '../common/tenant-id.decorator';
|
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 { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
import {
|
import {
|
||||||
@@ -125,14 +128,15 @@ export class ClientBillingController {
|
|||||||
constructor(private readonly billing: BillingService) {}
|
constructor(private readonly billing: BillingService) {}
|
||||||
|
|
||||||
@Get('orders')
|
@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
|
return page || pageSize
|
||||||
? this.billing.listRechargeOrdersPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
|
? this.billing.listRechargeOrdersPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
|
||||||
: this.billing.listRechargeOrders(tenantId);
|
: this.billing.listRechargeOrders(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('estimate')
|
@Post('estimate')
|
||||||
estimateSmsCost(@Body() body: EstimateSmsCostDto) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.billing.estimateSmsCost(body);
|
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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
import { TenantId } from '../common/tenant-id.decorator';
|
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||||
import { CertificationService, ReviewCertificationDto, SubmitCertificationDto } from './certification.service';
|
import { ClientCertificationSubmissionDto } from '../common/client-write.dto';
|
||||||
|
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||||
|
import { CertificationService, ReviewCertificationDto } from './certification.service';
|
||||||
|
|
||||||
@ApiTags('client-certification')
|
@ApiTags('client-certification')
|
||||||
@Controller('client/enterprise-certification')
|
@Controller('client/enterprise-certification')
|
||||||
@@ -10,13 +12,14 @@ export class ClientCertificationController {
|
|||||||
constructor(private readonly certifications: CertificationService) {}
|
constructor(private readonly certifications: CertificationService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@TenantId() tenantId?: string) {
|
list(@CurrentTenantId() tenantId: string) {
|
||||||
return this.certifications.list(tenantId);
|
return this.certifications.list(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
submit(@Body() body: SubmitCertificationDto) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.certifications.submit(body);
|
submit(@CurrentTenantId() tenantId: string, @Body() body: ClientCertificationSubmissionDto) {
|
||||||
|
return this.certifications.submit({ ...body, tenantId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
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 { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||||
import { TenantId } from '../common/tenant-id.decorator';
|
|
||||||
import { DeleteTargetDto, DeletionGovernanceService, DeletionTargetType } from './deletion-governance.service';
|
import { DeleteTargetDto, DeletionGovernanceService, DeletionTargetType } from './deletion-governance.service';
|
||||||
|
|
||||||
@ApiTags('deletion-governance')
|
@ApiTags('deletion-governance')
|
||||||
@@ -28,13 +28,13 @@ export class ClientDeletionGovernanceController {
|
|||||||
constructor(private readonly deletions: DeletionGovernanceService) {}
|
constructor(private readonly deletions: DeletionGovernanceService) {}
|
||||||
|
|
||||||
@Get(':type/:id/preflight')
|
@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);
|
return this.deletions.preflight(type, id, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':type/:id')
|
@Post(':type/:id')
|
||||||
@RequireRecentAuthentication()
|
@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);
|
return this.deletions.delete(type, id, { ...body, operatorId }, tenantId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
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';
|
import { OpenApiService } from './open-api.service';
|
||||||
|
|
||||||
@ApiTags('client-http-open-api-management')
|
@ApiTags('client-http-open-api-management')
|
||||||
@@ -9,13 +10,13 @@ import { OpenApiService } from './open-api.service';
|
|||||||
export class ClientOpenApiController {
|
export class ClientOpenApiController {
|
||||||
constructor(private readonly service: OpenApiService) {}
|
constructor(private readonly service: OpenApiService) {}
|
||||||
|
|
||||||
@Get() getConfig(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.getConfig(applicationId, tenantId); }
|
@Get() getConfig(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.getConfig(applicationId, tenantId); }
|
||||||
@Get('credentials') listCredentials(@Param('applicationId') applicationId: string, @TenantId() tenantId?: string) { return this.service.listCredentials(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 }, @TenantId() tenantId?: string) { return this.service.createCredential(applicationId, body, tenantId, true); }
|
@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, @TenantId() tenantId?: string) { return this.service.revokeCredential(applicationId, credentialId, tenantId); }
|
@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, @TenantId() tenantId?: string) { return this.service.getWebhookEndpoints(applicationId, 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 }, @TenantId() tenantId?: string) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body, 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, @TenantId() tenantId?: string) { return this.service.listRequestLogs(applicationId, 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, @TenantId() tenantId?: string) { return this.service.listWebhookDeliveries(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, @TenantId() tenantId?: string) { return this.service.retryWebhookDelivery(applicationId, deliveryId, 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 { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
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';
|
import { OperationsService } from './operations.service';
|
||||||
|
|
||||||
@ApiTags('client-operations')
|
@ApiTags('client-operations')
|
||||||
@@ -10,18 +10,18 @@ export class ClientOperationsController {
|
|||||||
constructor(private readonly operations: OperationsService) {}
|
constructor(private readonly operations: OperationsService) {}
|
||||||
|
|
||||||
@Get('batch-tasks')
|
@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 });
|
return this.operations.listClientBatchTasks({ tenantId, status });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('batch-tasks/:id/messages')
|
@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 });
|
return this.operations.listClientMessages({ tenantId, taskId, phoneNumber });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('messages')
|
@Get('messages')
|
||||||
listMessages(
|
listMessages(
|
||||||
@TenantId() tenantId?: string,
|
@CurrentTenantId() tenantId: string,
|
||||||
@Query('applicationId') applicationId?: string,
|
@Query('applicationId') applicationId?: string,
|
||||||
@Query('taskId') taskId?: string,
|
@Query('taskId') taskId?: string,
|
||||||
@Query('messageId') messageId?: string,
|
@Query('messageId') messageId?: string,
|
||||||
@@ -49,20 +49,20 @@ export class ClientOperationsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('uplink-messages')
|
@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
|
return page || pageSize
|
||||||
? this.operations.listUplinkMessagesPage({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime, page: Number(page), pageSize: Number(pageSize) }, true)
|
? 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 });
|
: this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('dashboard')
|
@Get('dashboard')
|
||||||
dashboard(@TenantId() tenantId?: string) {
|
dashboard(@CurrentTenantId() tenantId: string) {
|
||||||
return this.operations.clientDashboard({ tenantId });
|
return this.operations.clientDashboard({ tenantId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('system-logs')
|
@Get('system-logs')
|
||||||
systemLogs(
|
systemLogs(
|
||||||
@TenantId() tenantId?: string,
|
@CurrentTenantId() tenantId: string,
|
||||||
@Query('keyword') keyword?: string,
|
@Query('keyword') keyword?: string,
|
||||||
@Query('level') level?: string,
|
@Query('level') level?: string,
|
||||||
@Query('module') module?: string,
|
@Query('module') module?: string,
|
||||||
@@ -78,8 +78,9 @@ export class ClientOperationsController {
|
|||||||
@Post('system-logs/exports')
|
@Post('system-logs/exports')
|
||||||
exportSystemLogs(
|
exportSystemLogs(
|
||||||
@CurrentSessionUserId() userId: string | undefined,
|
@CurrentSessionUserId() userId: string | undefined,
|
||||||
|
@CurrentTenantId() tenantId: string,
|
||||||
@Body() body: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export class PrismaService extends PrismaClient implements OnModuleDestroy {
|
|||||||
const poolMax = Number.isInteger(configuredPoolMax) && configuredPoolMax > 0
|
const poolMax = Number.isInteger(configuredPoolMax) && configuredPoolMax > 0
|
||||||
? configuredPoolMax
|
? configuredPoolMax
|
||||||
: protocolLogRole ? 4 : outboxRole ? 6 : callbackRole ? 16 : workerRole ? 8 : 32;
|
: 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({
|
const databasePool = new Pool({
|
||||||
connectionString: databaseUrl
|
connectionString: databaseUrl
|
||||||
?? 'postgresql://cmpp:cmpp_password@localhost:5432/cmpp_platform?schema=public',
|
?? '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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { TenantId } from '../common/tenant-id.decorator';
|
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||||
import { EvaluateSmsTaskDto, RiskReviewService } from './risk-review.service';
|
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')
|
@ApiTags('client-risk-review')
|
||||||
@Controller('client/risk-review')
|
@Controller('client/risk-review')
|
||||||
@@ -9,13 +12,13 @@ export class ClientRiskReviewController {
|
|||||||
constructor(private readonly riskReview: RiskReviewService) {}
|
constructor(private readonly riskReview: RiskReviewService) {}
|
||||||
|
|
||||||
@Post('tasks/evaluate')
|
@Post('tasks/evaluate')
|
||||||
evaluateTask(@Body() body: EvaluateSmsTaskDto) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.riskReview.evaluateTask(body);
|
evaluateTask(@CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById: string | undefined, @Body() body: ClientBatchTaskDto) {
|
||||||
|
return this.riskReview.evaluateTask({ ...body, tenantId, createdById });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('tasks')
|
@Get('tasks')
|
||||||
listTasks(@TenantId() tenantId?: string, @Query('status') status?: string) {
|
listTasks(@CurrentTenantId() tenantId: string, @Query('status') status?: string) {
|
||||||
return this.riskReview.listTasks(tenantId, status);
|
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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { TenantId } from '../common/tenant-id.decorator';
|
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
|
||||||
import { ConfirmImportDto, CreateBatchTaskDto, ImportPreviewDto } from './send-chain.contracts';
|
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';
|
import { SendChainService } from './send-chain.service';
|
||||||
|
|
||||||
@ApiTags('client-send-chain')
|
@ApiTags('client-send-chain')
|
||||||
@@ -10,46 +12,42 @@ export class ClientSendChainController {
|
|||||||
constructor(private readonly sendChain: SendChainService) {}
|
constructor(private readonly sendChain: SendChainService) {}
|
||||||
|
|
||||||
@Post('batch-tasks')
|
@Post('batch-tasks')
|
||||||
createBatchTask(@Body() body: CreateBatchTaskDto) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.sendChain.createBatchTask(body);
|
createBatchTask(@CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById: string | undefined, @Body() body: ClientBatchTaskDto) {
|
||||||
|
return this.sendChain.createBatchTask({ ...body, tenantId, sourceType: 'client', createdById });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('imports/preview')
|
@Post('imports/preview')
|
||||||
previewImport(@Body() body: ImportPreviewDto) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.sendChain.previewImport(body);
|
previewImport(@CurrentTenantId() tenantId: string, @Body() body: ClientImportPreviewDto) {
|
||||||
|
return this.sendChain.previewImport({ ...body, tenantId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('imports/confirm')
|
@Post('imports/confirm')
|
||||||
confirmImport(@Body() body: ConfirmImportDto) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.sendChain.confirmImport(body);
|
confirmImport(@CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById: string | undefined, @Body() body: ClientImportConfirmDto) {
|
||||||
|
return this.sendChain.confirmImport({ ...body, tenantId, sourceType: 'client', createdById });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('batch-tasks')
|
@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
|
return page || pageSize
|
||||||
? this.sendChain.listBatchTasksPage({ tenantId: requireTenantId(tenantId), status, sourceType: 'client', keyword, applicationKeyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
|
? this.sendChain.listBatchTasksPage({ tenantId, status, sourceType: 'client', keyword, applicationKeyword, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) })
|
||||||
: this.sendChain.listBatchTasks(requireTenantId(tenantId), status, 'client');
|
: this.sendChain.listBatchTasks(tenantId, status, 'client');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('batch-tasks/:id')
|
@Get('batch-tasks/:id')
|
||||||
getBatchTask(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
|
getBatchTask(@CurrentTenantId() tenantId: string, @Param('id') taskId: string) {
|
||||||
return this.sendChain.getBatchTask(taskId, requireTenantId(tenantId), 'client');
|
return this.sendChain.getBatchTask(taskId, tenantId, 'client');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('batch-tasks/:id/messages')
|
@Get('batch-tasks/:id/messages')
|
||||||
listTaskMessages(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
|
listTaskMessages(@CurrentTenantId() tenantId: string, @Param('id') taskId: string) {
|
||||||
return this.sendChain.listClientTaskMessages(taskId, requireTenantId(tenantId));
|
return this.sendChain.listClientTaskMessages(taskId, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('batch-tasks/:id/cancel')
|
@Post('batch-tasks/:id/cancel')
|
||||||
cancelBatchTask(@TenantId() tenantId: string | undefined, @Param('id') taskId: string) {
|
cancelBatchTask(@CurrentTenantId() tenantId: string, @Param('id') taskId: string) {
|
||||||
return this.sendChain.cancelBatchTask(taskId, requireTenantId(tenantId), 'client');
|
return this.sendChain.cancelBatchTask(taskId, tenantId, 'client');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireTenantId(tenantId?: string) {
|
|
||||||
if (!tenantId) {
|
|
||||||
throw new BadRequestException('Tenant context is required');
|
|
||||||
}
|
|
||||||
return tenantId;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export interface ImportPreviewDto {
|
|||||||
requiredVariables?: string[];
|
requiredVariables?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfirmImportDto extends CreateBatchTaskDto {
|
export interface ConfirmImportDto extends Omit<CreateBatchTaskDto, 'phones'> {
|
||||||
importContent: string;
|
importContent: string;
|
||||||
requiredVariables?: 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 { 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 { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
import { DeleteTargetDto, DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
|
import { ClientDrainageInfoDto, ClientDrainageInfoUpdateDto, ClientSignatureMaterialDto, ClientSmsApplicationDto, ClientSmsSignatureDto, ClientSmsSignatureUpdateDto, ClientSmsTemplateDto, ClientSmsTemplateUpdateDto, ClientStatusChangeDto } from '../common/client-write.dto';
|
||||||
import {
|
import { strictValidationPipe } from '../common/strict-validation.pipe';
|
||||||
CreateSignatureMaterialDto,
|
import { DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
|
||||||
CreateSmsApplicationDto,
|
|
||||||
CreateSmsDrainageInfoDto,
|
|
||||||
CreateSmsSignatureDto,
|
|
||||||
CreateSmsTemplateDto,
|
|
||||||
StatusChangeDto,
|
|
||||||
UpdateSmsTemplateDto,
|
|
||||||
UpdateSmsDrainageInfoDto,
|
|
||||||
UpdateSmsSignatureDto,
|
|
||||||
} from './sms-config.contracts';
|
|
||||||
import { SmsConfigService } from './sms-config.service';
|
import { SmsConfigService } from './sms-config.service';
|
||||||
|
|
||||||
@ApiTags('client-sms-config')
|
@ApiTags('client-sms-config')
|
||||||
@@ -23,29 +14,30 @@ export class ClientSmsConfigController {
|
|||||||
constructor(private readonly smsConfig: SmsConfigService, private readonly deletions: DeletionGovernanceService) {}
|
constructor(private readonly smsConfig: SmsConfigService, private readonly deletions: DeletionGovernanceService) {}
|
||||||
|
|
||||||
@Get('applications')
|
@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
|
return page || pageSize
|
||||||
? this.smsConfig.listApplicationsPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
|
? this.smsConfig.listApplicationsPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
|
||||||
: this.smsConfig.listApplications(tenantId);
|
: this.smsConfig.listApplications(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('application-options')
|
@Get('application-options')
|
||||||
listApplicationOptions(@TenantId() tenantId?: string) {
|
listApplicationOptions(@CurrentTenantId() tenantId: string) {
|
||||||
return this.smsConfig.listApplicationOptions(tenantId);
|
return this.smsConfig.listApplicationOptions(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('applications')
|
@Post('applications')
|
||||||
createApplication(@Body() body: CreateSmsApplicationDto) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.smsConfig.createApplication(body);
|
createApplication(@CurrentTenantId() tenantId: string, @Body() body: ClientSmsApplicationDto) {
|
||||||
|
return this.smsConfig.createApplication({ ...body, tenantId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('applications/:id/cmpp-params')
|
@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);
|
return this.smsConfig.getApplicationCmppParams(applicationId, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('applications/:id/report-fields')
|
@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));
|
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getClientApplicationReportFields(applicationId, reportType));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,110 +48,122 @@ export class ClientSmsConfigController {
|
|||||||
|
|
||||||
@Post('applications/:id/secret/reset')
|
@Post('applications/:id/secret/reset')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.smsConfig.resetApplicationSecret(applicationId, body);
|
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')
|
@Post('applications/:id/status')
|
||||||
@RequireRecentAuthentication()
|
@RequireRecentAuthentication()
|
||||||
changeApplicationStatus(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.smsConfig.changeApplicationStatus(applicationId, body);
|
changeApplicationStatus(@Param('id') applicationId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
|
||||||
|
return this.smsConfig.changeClientApplicationStatus(applicationId, { ...body, operatorId }, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('signatures')
|
@Get('signatures')
|
||||||
listSignatures(@TenantId() tenantId?: string) {
|
listSignatures(@CurrentTenantId() tenantId: string) {
|
||||||
return this.smsConfig.listClientSignatures(tenantId);
|
return this.smsConfig.listClientSignatures(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('signature-options')
|
@Get('signature-options')
|
||||||
listSignatureOptions(@TenantId() tenantId?: string) {
|
listSignatureOptions(@CurrentTenantId() tenantId: string) {
|
||||||
return this.smsConfig.listSignatureOptions(tenantId);
|
return this.smsConfig.listSignatureOptions(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('signatures-workspace')
|
@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) });
|
return this.smsConfig.getClientSignatureWorkspace(tenantId, { keyword, applicationId, status, page: Number(page), pageSize: Number(pageSize) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('signatures')
|
@Post('signatures')
|
||||||
async createSignature(@Body() body: CreateSmsSignatureDto, @TenantId() tenantId?: string) {
|
@UsePipes(strictValidationPipe)
|
||||||
const signature = await this.smsConfig.createSignature({ ...body, tenantId: tenantId ?? body.tenantId });
|
async createSignature(@Body() body: ClientSmsSignatureDto, @CurrentTenantId() tenantId: string) {
|
||||||
return this.smsConfig.getClientSignatureView(signature.id, tenantId ?? body.tenantId);
|
const signature = await this.smsConfig.createSignature({ ...body, tenantId });
|
||||||
|
return this.smsConfig.getClientSignatureView(signature.id, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('signatures/:id')
|
@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);
|
await this.smsConfig.updateClientSignature(signatureId, body, tenantId);
|
||||||
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('signatures/:id/materials')
|
@Post('signatures/:id/materials')
|
||||||
createSignatureMaterial(@Param('id') signatureId: string, @Body() body: Omit<CreateSignatureMaterialDto, 'signatureId'>) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.smsConfig.createSignatureMaterial({ ...body, signatureId });
|
createSignatureMaterial(@Param('id') signatureId: string, @Body() body: ClientSignatureMaterialDto, @CurrentTenantId() tenantId: string) {
|
||||||
|
return this.smsConfig.createClientSignatureMaterial({ ...body, signatureId }, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('drainage-infos')
|
@Get('drainage-infos')
|
||||||
listDrainageInfos(@TenantId() tenantId?: string) {
|
listDrainageInfos(@CurrentTenantId() tenantId: string) {
|
||||||
return this.smsConfig.listClientDrainageInfos(tenantId);
|
return this.smsConfig.listClientDrainageInfos(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('signatures/:id/drainage-infos')
|
@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);
|
const item = await this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId);
|
||||||
return this.smsConfig.getClientDrainageInfoView(item.id, tenantId);
|
return this.smsConfig.getClientDrainageInfoView(item.id, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('drainage-infos/:id')
|
@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);
|
await this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId);
|
||||||
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
|
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('drainage-infos/:id/status')
|
@Post('drainage-infos/:id/status')
|
||||||
async changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
|
@UsePipes(strictValidationPipe)
|
||||||
await this.smsConfig.changeDrainageInfoStatus(itemId, body, tenantId);
|
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' };
|
if (body.status === 'deleted') return { id: itemId, status: 'deleted' };
|
||||||
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
|
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('signatures/:id/submit')
|
@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);
|
await this.smsConfig.submitSignature(signatureId, tenantId);
|
||||||
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('signatures/:id/status')
|
@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);
|
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);
|
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('templates')
|
@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
|
return page || pageSize
|
||||||
? this.smsConfig.listTemplatesPage({ tenantId, status: includeHistory === 'true' ? 'all' : 'approved', keyword, page: Number(page), pageSize: Number(pageSize) })
|
? this.smsConfig.listTemplatesPage({ tenantId, status: includeHistory === 'true' ? 'all' : 'approved', keyword, page: Number(page), pageSize: Number(pageSize) })
|
||||||
: this.smsConfig.listClientTemplates(tenantId, includeHistory === 'true');
|
: this.smsConfig.listClientTemplates(tenantId, includeHistory === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('templates')
|
@Post('templates')
|
||||||
createTemplate(@Body() body: CreateSmsTemplateDto, @TenantId() tenantId?: string) {
|
@UsePipes(strictValidationPipe)
|
||||||
return this.smsConfig.createTemplate({ ...body, tenantId: tenantId ?? body.tenantId });
|
createTemplate(@Body() body: ClientSmsTemplateDto, @CurrentTenantId() tenantId: string) {
|
||||||
|
return this.smsConfig.createTemplate({ ...body, tenantId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('templates/:id')
|
@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);
|
return this.smsConfig.updateClientTemplate(templateId, body, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('templates/:id/submit')
|
@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);
|
return this.smsConfig.submitTemplate(templateId, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('templates/:id/status')
|
@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);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,10 +81,20 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return this.applications.resetApplicationSecret(applicationId, data);
|
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) {
|
async changeApplicationStatus(applicationId: string, data: StatusChangeDto) {
|
||||||
return this.lifecycle.changeApplicationStatus(applicationId, data);
|
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) {
|
async getApplicationDeactivationPreview(applicationId: string) {
|
||||||
return this.lifecycle.getApplicationDeactivationPreview(applicationId);
|
return this.lifecycle.getApplicationDeactivationPreview(applicationId);
|
||||||
}
|
}
|
||||||
@@ -177,6 +187,11 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return this.signatures.createSignatureMaterial(data);
|
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) {
|
async submitSignature(signatureId: string, tenantId?: string) {
|
||||||
return this.signatures.submitSignature(signatureId, tenantId);
|
return this.signatures.submitSignature(signatureId, tenantId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
|
||||||
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
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 { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||||
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
|
||||||
import { AdminUserResponseDto, ClientUserResponseDto } from './user-response.dto';
|
import { AdminUserResponseDto, ClientUserResponseDto } from './user-response.dto';
|
||||||
@@ -72,7 +72,7 @@ export class UsersController {
|
|||||||
@Get('client/users')
|
@Get('client/users')
|
||||||
@ApiOkResponse({ type: [ClientUserResponseDto] })
|
@ApiOkResponse({ type: [ClientUserResponseDto] })
|
||||||
listClient(
|
listClient(
|
||||||
@TenantId() tenantId?: string,
|
@CurrentTenantId() tenantId: string,
|
||||||
@Query('displayName') displayName?: string,
|
@Query('displayName') displayName?: string,
|
||||||
@Query('login') login?: string,
|
@Query('login') login?: string,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@@ -82,31 +82,31 @@ export class UsersController {
|
|||||||
|
|
||||||
@Post('client/users')
|
@Post('client/users')
|
||||||
@RequireRecentAuthentication()
|
@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);
|
return this.users.create({ ...body, roleCode: 'enterprise_admin', operatorId }, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('client/users/:id')
|
@Put('client/users/:id')
|
||||||
@RequireRecentAuthentication()
|
@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);
|
return this.users.update(id, { ...body, roleCode: 'enterprise_admin', operatorId }, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('client/users/:id/status')
|
@Post('client/users/:id/status')
|
||||||
@RequireRecentAuthentication()
|
@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);
|
return this.users.changeStatus(id, { ...body, operatorId }, tenantId, operatorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('client/users/:id/password')
|
@Post('client/users/:id/password')
|
||||||
@RequireRecentAuthentication()
|
@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);
|
return this.users.changePassword(id, { ...body, operatorId }, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('client/users/:id')
|
@Delete('client/users/:id')
|
||||||
@RequireRecentAuthentication()
|
@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);
|
return this.users.remove(id, operatorId, tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
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() {
|
function createPrismaMock() {
|
||||||
const roles = new Map<string, { id: string; code: string; name: string; scope: string }>();
|
const roles = new Map<string, { id: string; code: string; name: string; scope: string }>();
|
||||||
@@ -10,6 +11,7 @@ function createPrismaMock() {
|
|||||||
findFirst: jest.fn(),
|
findFirst: jest.fn(),
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
|
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||||
count: jest.fn().mockResolvedValue(2),
|
count: jest.fn().mockResolvedValue(2),
|
||||||
},
|
},
|
||||||
role: {
|
role: {
|
||||||
@@ -206,7 +208,7 @@ describe('UsersService', () => {
|
|||||||
it('returns a safe view after changing the current password', async () => {
|
it('returns a safe view after changing the current password', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
prisma.user.findFirst.mockResolvedValue({
|
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' } }],
|
sessionVersion: 3, failedLoginCount: 0, roles: [{ role: { code: 'enterprise_admin' } }],
|
||||||
});
|
});
|
||||||
prisma.user.update.mockResolvedValue({
|
prisma.user.update.mockResolvedValue({
|
||||||
@@ -223,6 +225,30 @@ describe('UsersService', () => {
|
|||||||
expect(user).not.toHaveProperty('failedLoginCount');
|
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 () => {
|
it('forbids deleting the current signed-in user', async () => {
|
||||||
const prisma = createPrismaMock();
|
const prisma = createPrismaMock();
|
||||||
prisma.user.findFirst.mockResolvedValue({
|
prisma.user.findFirst.mockResolvedValue({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { hashPassword, passwordNeedsRehash, verifyPassword } from '../auth/password-hasher';
|
||||||
|
|
||||||
export type UserRoleCode = 'platform_admin' | 'enterprise_admin';
|
export type UserRoleCode = 'platform_admin' | 'enterprise_admin';
|
||||||
|
|
||||||
@@ -131,6 +131,7 @@ export class UsersService {
|
|||||||
this.assertUserInput({ ...data, roleCode }, scopeTenantId, true);
|
this.assertUserInput({ ...data, roleCode }, scopeTenantId, true);
|
||||||
const tenantId = scopeTenantId ?? data.tenantId;
|
const tenantId = scopeTenantId ?? data.tenantId;
|
||||||
const role = await this.ensureRole(roleCode);
|
const role = await this.ensureRole(roleCode);
|
||||||
|
const passwordHash = await hashPassword(data.password);
|
||||||
const user = await this.mapUniqueConflict(() => this.prisma.user.create({
|
const user = await this.mapUniqueConflict(() => this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -138,7 +139,7 @@ export class UsersService {
|
|||||||
email: normalizeOptional(data.email),
|
email: normalizeOptional(data.email),
|
||||||
phone: normalizeOptional(data.phone),
|
phone: normalizeOptional(data.phone),
|
||||||
displayName: data.displayName,
|
displayName: data.displayName,
|
||||||
passwordHash: hashPassword(data.password),
|
passwordHash,
|
||||||
status: data.status ?? 'active',
|
status: data.status ?? 'active',
|
||||||
roles: { create: [{ roleId: role.id }] },
|
roles: { create: [{ roleId: role.id }] },
|
||||||
},
|
},
|
||||||
@@ -208,9 +209,10 @@ export class UsersService {
|
|||||||
throw new BadRequestException('password must be at least 6 characters');
|
throw new BadRequestException('password must be at least 6 characters');
|
||||||
}
|
}
|
||||||
const current = await this.getExisting(id, scopeTenantId);
|
const current = await this.getExisting(id, scopeTenantId);
|
||||||
|
const passwordHash = await hashPassword(data.password);
|
||||||
const updated = await this.prisma.user.update({
|
const updated = await this.prisma.user.update({
|
||||||
where: { id },
|
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 } } },
|
include: { tenant: true, roles: { include: { role: true } } },
|
||||||
});
|
});
|
||||||
await this.writeLog(current.tenantId, data.operatorId, 'user.password_changed', id, { username: current.username });
|
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');
|
throw new BadRequestException('currentPassword and a password of at least 6 characters are required');
|
||||||
}
|
}
|
||||||
const current = await this.getExisting(id);
|
const current = await this.getExisting(id);
|
||||||
if (current.passwordHash !== hashPassword(currentPassword)) {
|
if (!await verifyPassword(currentPassword, current.passwordHash)) {
|
||||||
throw new BadRequestException('当前密码不正确');
|
throw new BadRequestException('当前密码不正确');
|
||||||
}
|
}
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
const updated = await this.prisma.user.update({
|
const updated = await this.prisma.user.update({
|
||||||
where: { id },
|
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 } } },
|
include: { tenant: true, roles: { include: { role: true } } },
|
||||||
});
|
});
|
||||||
await this.writeLog(current.tenantId, id, 'user.password_changed_self', id, { username: current.username });
|
await this.writeLog(current.tenantId, id, 'user.password_changed_self', id, { username: current.username });
|
||||||
@@ -276,12 +279,25 @@ export class UsersService {
|
|||||||
throw new BadRequestException('请输入当前密码');
|
throw new BadRequestException('请输入当前密码');
|
||||||
}
|
}
|
||||||
const current = await this.getExisting(id);
|
const current = await this.getExisting(id);
|
||||||
if (current.passwordHash !== hashPassword(password)) {
|
if (!await verifyPassword(password, current.passwordHash)) {
|
||||||
throw new BadRequestException('当前密码不正确');
|
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;
|
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() {
|
listRoles() {
|
||||||
return this.prisma.role.findMany({
|
return this.prisma.role.findMany({
|
||||||
include: { permissions: { include: { permission: true } } },
|
include: { permissions: { include: { permission: true } } },
|
||||||
@@ -440,7 +456,3 @@ function normalizeOptional(value?: string | null) {
|
|||||||
const next = value?.trim();
|
const next = value?.trim();
|
||||||
return next ? next : null;
|
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
@@ -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 MB,gzip 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 / P1:API 缺少统一运行时输入校验
|
||||||
|
|
||||||
|
- `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:前端主包过大且没有路由级代码分割
|
||||||
|
|
||||||
|
当前生产构建结果:
|
||||||
|
|
||||||
|
- JavaScript:2,123.63 KB,gzip 626.18 KB。
|
||||||
|
- CSS:270.61 KB,gzip 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 | 路由异步 chunk;ECharts 按需加载;包体预算和真实浏览器回归 |
|
||||||
|
| 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` 与代码质量问题关闭清单。
|
||||||
Generated
+17
-41
@@ -16,7 +16,7 @@
|
|||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-router-dom": "7.18.1",
|
"react-router-dom": "7.18.2",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
@@ -277,9 +277,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -296,9 +293,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -315,9 +309,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -334,9 +325,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -353,9 +341,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -372,9 +357,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -479,6 +461,7 @@
|
|||||||
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
|
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": ">=7.24.0 <7.24.7"
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
}
|
}
|
||||||
@@ -489,6 +472,7 @@
|
|||||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -869,9 +853,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -892,9 +873,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -915,9 +893,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -938,9 +913,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1062,9 +1034,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.16",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1111,6 +1083,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -1151,6 +1124,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
|
||||||
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -1160,6 +1134,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
|
||||||
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -1168,9 +1143,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.18.1",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||||
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -1190,12 +1165,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.18.1",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz",
|
||||||
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
"integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.18.1"
|
"react-router": "7.18.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -1346,6 +1321,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
|
|||||||
+8
-2
@@ -18,6 +18,10 @@
|
|||||||
"spike:bullmq": "node api/src/spike/bullmq-link-spike.mjs",
|
"spike:bullmq": "node api/src/spike/bullmq-link-spike.mjs",
|
||||||
"test:api": "npm --prefix api test",
|
"test:api": "npm --prefix api test",
|
||||||
"test:gateway": "npm run spike:gateway",
|
"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: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:phase2": "npm run verify:phase1",
|
||||||
"verify:phase3": "npm run verify:phase2",
|
"verify:phase3": "npm run verify:phase2",
|
||||||
@@ -25,7 +29,8 @@
|
|||||||
"verify:phase5": "npm run verify:phase4",
|
"verify:phase5": "npm run verify:phase4",
|
||||||
"verify:phase6": "npm run verify:phase5",
|
"verify:phase6": "npm run verify:phase5",
|
||||||
"verify:phase7": "npm run verify:phase6",
|
"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": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
@@ -36,7 +41,7 @@
|
|||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-router-dom": "7.18.1",
|
"react-router-dom": "7.18.2",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
@@ -47,6 +52,7 @@
|
|||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"nanoid": "3.3.18",
|
||||||
"postcss": "8.5.23"
|
"postcss": "8.5.23"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, Textarea, type DateRangeValue } from '@/components/ui';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
import { DeliveryDetailModal, deliveryStatusLabel, deliveryTypeLabel, recentSevenDays, requeueItemStatusLabel, requeueTaskStatusLabel, requeueTone, statusLabel, statusTone, type RequeueResult, type RequeueTarget } from './downstreamDeliveryPresentation';
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminDownstreamDeliveriesPage() {
|
export function AdminDownstreamDeliveriesPage() {
|
||||||
const [records, setRecords] = useState<DownstreamDeliveryRecord[]>([]);
|
const [records, setRecords] = useState<DownstreamDeliveryRecord[]>([]);
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
Button,
|
Button,
|
||||||
Chart,
|
|
||||||
Modal,
|
Modal,
|
||||||
MoneyText,
|
MoneyText,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
|
import { Chart } from '@/components/ui/Chart';
|
||||||
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
|
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
|
||||||
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
|
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
|
||||||
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
|
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Check, X } from 'lucide-react';
|
import { Check, X } from 'lucide-react';
|
||||||
import { Button, Tag, type TableColumn } from '@/components/ui';
|
import { Button, Tag, type TableColumn } from '@/components/ui';
|
||||||
import type { AuditItem, AuditStatus } from '@/mock';
|
import type { AuditItem, AuditStatus } from '@/api/types/audit';
|
||||||
|
|
||||||
const riskToneMap = {
|
const riskToneMap = {
|
||||||
low: 'success',
|
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 type { EChartsOption } from 'echarts';
|
||||||
import { AlertTriangle, Ban, BellRing, RefreshCw, Settings2, ShieldCheck, ShieldOff } from 'lucide-react';
|
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 { 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';
|
import './AdminSecurityDetectionPage.css';
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = { open: '待处理', acknowledged: '已确认', blocked: '已封禁', block_failed: '封禁失败', ignored: '已忽略', requested: '执行中', failed: '失败', released: '已解封' };
|
const statusLabels: Record<string, string> = { open: '待处理', acknowledged: '已确认', blocked: '已封禁', block_failed: '封禁失败', ignored: '已忽略', requested: '执行中', failed: '失败', released: '已解封' };
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ import {
|
|||||||
type InfrastructureMonitoringOverview,
|
type InfrastructureMonitoringOverview,
|
||||||
type InfrastructureMonitoringRange,
|
type InfrastructureMonitoringRange,
|
||||||
} from '@/api/adminApi';
|
} 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';
|
import './AdminSystemMonitoringPage.css';
|
||||||
|
|
||||||
const RANGE_OPTIONS: Array<{ value: InfrastructureMonitoringRange; label: string }> = [
|
const RANGE_OPTIONS: Array<{ value: InfrastructureMonitoringRange; label: string }> = [
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
WalletCards,
|
WalletCards,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { clientApi, type DashboardResponse } from '@/api/adminApi';
|
||||||
import { createLineOption, createPieOption } from '@/theme/chartOptions';
|
import { createLineOption, createPieOption } from '@/theme/chartOptions';
|
||||||
import { formatDateTime } from '@/utils/dateTime';
|
import { formatDateTime } from '@/utils/dateTime';
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import type { EChartsOption } from 'echarts';
|
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 = {
|
type ChartProps = {
|
||||||
option: EChartsOption;
|
option: EChartsOption;
|
||||||
@@ -15,7 +20,7 @@ export function Chart({ option, height = 280 }: ChartProps) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chart = echarts.init(chartRef.current);
|
const chart = init(chartRef.current);
|
||||||
chart.setOption(option);
|
chart.setOption(option);
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => chart.resize());
|
const resizeObserver = new ResizeObserver(() => chart.resize());
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export { Button } from './Button';
|
export { Button } from './Button';
|
||||||
export { Breadcrumb } from './Breadcrumb';
|
export { Breadcrumb } from './Breadcrumb';
|
||||||
export { Chart } from './Chart';
|
|
||||||
export { CarrierTag, normalizeCarrierTag } from './CarrierTag';
|
export { CarrierTag, normalizeCarrierTag } from './CarrierTag';
|
||||||
export type { CarrierTagValue } from './CarrierTag';
|
export type { CarrierTagValue } from './CarrierTag';
|
||||||
export { DateRangeInput } from './DateRangeInput';
|
export { DateRangeInput } from './DateRangeInput';
|
||||||
|
|||||||
+75
-59
@@ -1,69 +1,83 @@
|
|||||||
|
import { lazy, Suspense, type ComponentType, type LazyExoticComponent } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
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 { LoginPage } from '@/apps/LoginPage';
|
||||||
import { PagePlaceholder } from '@/components/PagePlaceholder';
|
import { PagePlaceholder } from '@/components/PagePlaceholder';
|
||||||
import { AdminLayout } from '@/layouts/AdminLayout';
|
import { AdminLayout } from '@/layouts/AdminLayout';
|
||||||
import { ClientLayout } from '@/layouts/ClientLayout';
|
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() {
|
export function AppRoutes() {
|
||||||
return (
|
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="/" element={<Navigate to="/client" replace />} />
|
||||||
<Route path="/client/login" element={<LoginPage portal="client" />} />
|
<Route path="/client/login" element={<LoginPage portal="client" />} />
|
||||||
<Route path="/admin/login" element={<LoginPage portal="admin" />} />
|
<Route path="/admin/login" element={<LoginPage portal="admin" />} />
|
||||||
@@ -148,6 +162,8 @@ export function AppRoutes() {
|
|||||||
<Route path="security-detection" element={<AdminSecurityDetectionPage />} />
|
<Route path="security-detection" element={<AdminSecurityDetectionPage />} />
|
||||||
<Route path="*" element={<PagePlaceholder />} />
|
<Route path="*" element={<PagePlaceholder />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</RouteLoadBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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'));
|
const apiLock = JSON.parse(readFileSync(join(workspaceRoot, 'api', 'package-lock.json'), 'utf8'));
|
||||||
|
|
||||||
assertVersionAtLeast(rootLock.packages['node_modules/postcss']?.version, [8, 5, 18], 'postcss');
|
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-safe']?.version, '5.0.8', 'brace-expansion-safe');
|
||||||
assertEqual(
|
assertEqual(
|
||||||
apiLock.packages['node_modules/brace-expansion']?.resolved,
|
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) {
|
function sourceFiles(directory) {
|
||||||
return readdirSync(directory).flatMap((name) => {
|
return readdirSync(directory).flatMap((name) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user