4 Commits
54 changed files with 8278 additions and 682 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
coverage
api/dist
api/vendor
package-lock.json
docs
+5
View File
@@ -0,0 +1,5 @@
{
"printWidth": 120,
"singleQuote": true,
"trailingComma": "all"
}
+17
View File
@@ -0,0 +1,17 @@
const base = require('./jest.config.cjs');
module.exports = {
...base,
collectCoverageFrom: [
'src/auth/auth.service.ts',
'src/auth/session.service.ts',
'src/common/bounded-json-object.validator.ts',
'src/files/client-files.dto.ts',
'src/open-api/client-open-api.dto.ts',
'src/operations/client-operations.dto.ts',
'src/users/client-user.dto.ts',
],
coverageThreshold: {
global: { statements: 80, branches: 70, functions: 80, lines: 80 },
},
};
+68 -106
View File
@@ -12,7 +12,7 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.28",
"@nestjs/platform-express": "^11.1.28",
"@nestjs/swagger": "^11.2.3",
"@nestjs/swagger": "11.4.7",
"@prisma/adapter-pg": "^7.9.0",
"@prisma/client": "^7.9.0",
"brace-expansion": "file:vendor/brace-expansion-compat",
@@ -617,35 +617,38 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -732,30 +735,6 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
@@ -1451,20 +1430,20 @@
}
},
"node_modules/@nestjs/swagger": {
"version": "11.4.5",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.5.tgz",
"integrity": "sha512-lvndlJmWBVDOUT0uEtLi6sSpW1syK2/nbAlHBhiELBORMpJGe9+EiWAT9qHtB10jW91L2Jmlwkr0/lttsYZrig==",
"version": "11.4.7",
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.7.tgz",
"integrity": "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==",
"license": "MIT",
"dependencies": {
"@microsoft/tsdoc": "0.16.0",
"@nestjs/mapped-types": "2.1.1",
"js-yaml": "4.3.0",
"js-yaml": "5.3.0",
"lodash": "4.18.1",
"path-to-regexp": "8.4.2",
"swagger-ui-dist": "5.32.8"
"swagger-ui-dist": "5.32.13"
},
"peerDependencies": {
"@fastify/static": "^8.0.0 || ^9.0.0",
"@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"class-transformer": "*",
@@ -2345,9 +2324,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2362,9 +2338,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2379,9 +2352,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2396,9 +2366,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2413,9 +2380,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2430,9 +2394,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2447,9 +2408,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2464,9 +2422,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2481,9 +2436,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2498,9 +2450,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2540,6 +2489,40 @@
"node": ">=14.0.0"
}
},
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@unrs/resolver-binding-win32-arm64-msvc": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz",
@@ -3263,9 +3246,9 @@
},
"node_modules/brace-expansion-safe": {
"name": "brace-expansion",
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -4510,20 +4493,6 @@
"node": ">=8"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -4742,9 +4711,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"devOptional": true,
"funding": [
{
@@ -6170,9 +6139,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"funding": [
{
"type": "github",
@@ -8208,13 +8177,6 @@
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
@@ -8520,9 +8482,9 @@
}
},
"node_modules/swagger-ui-dist": {
"version": "5.32.8",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz",
"integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==",
"version": "5.32.13",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz",
"integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==",
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
@@ -9523,10 +9485,10 @@
"node_modules/zip-stream/node_modules/minimatch/vendor/brace-expansion-compat": {},
"vendor/brace-expansion-compat": {
"name": "brace-expansion",
"version": "5.0.8-compat.1",
"version": "5.0.9-compat.1",
"license": "MIT",
"dependencies": {
"brace-expansion-safe": "npm:brace-expansion@5.0.8"
"brace-expansion-safe": "npm:brace-expansion@5.0.9"
}
}
}
+5 -2
View File
@@ -7,6 +7,7 @@
"build": "tsc -p tsconfig.build.json",
"test": "jest --runInBand",
"test:coverage": "jest --runInBand --coverage",
"test:incremental-coverage": "jest --runInBand --coverage --config jest.incremental.config.cjs",
"test:watch": "jest --watch",
"start": "node dist/main.js",
"start:dev": "ts-node src/main.ts",
@@ -19,7 +20,7 @@
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.28",
"@nestjs/platform-express": "^11.1.28",
"@nestjs/swagger": "^11.2.3",
"@nestjs/swagger": "11.4.7",
"@prisma/adapter-pg": "^7.9.0",
"@prisma/client": "^7.9.0",
"bullmq": "^5.79.2",
@@ -47,6 +48,8 @@
"exceljs": {
"uuid": "11.1.1"
},
"find-my-way": "9.7.0"
"find-my-way": "9.7.0",
"fast-uri": "3.1.5",
"js-yaml": "4.3.1"
}
}
+63 -17
View File
@@ -20,19 +20,29 @@ type CookieResponse = {
@ApiTags('auth')
@Controller()
export class AuthController {
constructor(private readonly auth: AuthService, private readonly users: UsersService, private readonly sessions: SessionService, private readonly prisma: PrismaService, private readonly security: SecurityDetectionService) {}
constructor(
private readonly auth: AuthService,
private readonly users: UsersService,
private readonly sessions: SessionService,
private readonly prisma: PrismaService,
private readonly security: SecurityDetectionService,
) {}
@Get('admin/auth/captcha')
adminCaptcha() {
return this.auth.createCaptcha();
adminCaptcha(@Req() request: SessionRequest) {
return this.auth.createCaptcha(this.sourceIp(request));
}
@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']>>;
try {
result = await this.auth.login(body, 'admin');
result = await this.auth.login(body, 'admin', this.sourceIp(request));
} catch (error) {
await this.recordLoginFailure('admin_login_failure', body.login, request).catch(() => undefined);
throw error;
@@ -41,16 +51,20 @@ export class AuthController {
}
@Get('client/auth/captcha')
clientCaptcha() {
return this.auth.createCaptcha();
clientCaptcha(@Req() request: SessionRequest) {
return this.auth.createCaptcha(this.sourceIp(request));
}
@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']>>;
try {
result = await this.auth.login(body, 'client');
result = await this.auth.login(body, 'client', this.sourceIp(request));
} catch (error) {
await this.recordLoginFailure('client_login_failure', body.login, request).catch(() => undefined);
throw error;
@@ -94,17 +108,23 @@ export class AuthController {
async lock(@Req() request: SessionRequest) {
this.assertSession(request);
const record = await this.sessions.lock(request.sessionToken!);
if (record) await this.writeLog(request, 'auth.session_locked', { portal: record.portal, reason: 'client_idle_timer' });
if (record)
await this.writeLog(request, 'auth.session_locked', { portal: record.portal, reason: 'client_idle_timer' });
return { locked: Boolean(record) };
}
@Post(['admin/auth/session/unlock', 'client/auth/session/unlock'])
@UsePipes(strictValidationPipe)
async unlock(@Req() request: SessionRequest, @Body() body: PasswordVerificationDto, @Res({ passthrough: true }) response: CookieResponse) {
async unlock(
@Req() request: SessionRequest,
@Body() body: PasswordVerificationDto,
@Res({ passthrough: true }) response: CookieResponse,
) {
const { password } = body;
this.assertSession(request);
const result = await this.auth.unlock(request.sessionToken!, request.sessionUserId!, password);
if (result.status !== 'active' || !('token' in result)) throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
if (result.status !== 'active' || !('token' in result))
throw new UnauthorizedException({ code: 'SESSION_LOCK_TIMEOUT', message: '锁定时间过长,请重新登录' });
this.setCookie(response, result.record.portal, result.token);
await this.writeLog(request, 'auth.session_unlocked', { portal: result.record.portal });
return this.sessions.publicSession(result.record);
@@ -124,7 +144,8 @@ export class AuthController {
@Post(['admin/auth/logout', 'client/auth/logout'])
async logout(@Req() request: SessionRequest, @Res({ passthrough: true }) response: CookieResponse) {
if (request.sessionToken) await this.sessions.remove(request.sessionToken);
if (request.sessionUserId) await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal });
if (request.sessionUserId)
await this.writeLog(request, 'auth.session_logged_out', { portal: request.authSession?.portal });
this.clearCookie(response, request.authSession?.portal);
return { success: true };
}
@@ -136,17 +157,32 @@ export class AuthController {
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,
) {
this.setCookie(response, result.portal, result.sessionToken);
this.clearLegacyCookie(response);
await this.prisma.operationLog.create({
data: { userId: result.user.id, tenantId: result.user.tenantId, action: 'auth.session_created', resource: 'auth_session', userAgent: request.header('user-agent'), detail: { portal: result.portal } },
data: {
userId: result.user.id,
tenantId: result.user.tenantId,
action: 'auth.session_created',
resource: 'auth_session',
userAgent: request.header('user-agent'),
detail: { portal: result.portal },
},
});
const { sessionToken: _, ...publicResult } = result;
return publicResult;
}
private recordLoginFailure(ruleCode: 'admin_login_failure' | 'client_login_failure', account: string, request: SessionRequest) {
private recordLoginFailure(
ruleCode: 'admin_login_failure' | 'client_login_failure',
account: string,
request: SessionRequest,
) {
return this.security.recordEvent({
ruleCode,
sourceIp: requestContext.getStore()?.ipAddress ?? '127.0.0.1',
@@ -157,9 +193,19 @@ export class AuthController {
});
}
private sourceIp(request: SessionRequest) {
return requestContext.getStore()?.ipAddress ?? '127.0.0.1';
}
private writeLog(request: SessionRequest, action: string, detail: Record<string, unknown>) {
return this.prisma.operationLog.create({
data: { userId: request.sessionUserId, action, resource: 'auth_session', userAgent: request.header('user-agent'), detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue },
data: {
userId: request.sessionUserId,
action,
resource: 'auth_session',
userAgent: request.header('user-agent'),
detail: JSON.parse(JSON.stringify(detail)) as Prisma.InputJsonValue,
},
});
}
+121 -20
View File
@@ -23,6 +23,7 @@ function createUsersMock(roleCode: string, overrides: Record<string, unknown> =
verifyLoginPassword: jest.fn(async (_id: string, password: string) => password === 'secret1'),
recordLoginSuccess: jest.fn(),
recordLoginFailure: jest.fn(),
verifyCurrentPassword: jest.fn(),
};
}
@@ -30,54 +31,154 @@ function createSessionsMock() {
const captchas = new Map<string, string>();
const failures = new Map<string, number>();
const record = {
userId: 'user-1', portal: 'admin', sessionVersion: 0, createdAt: 1, lastActivityAt: 1,
lastAuthenticatedAt: 1, absoluteExpiresAt: Date.now() + 1000,
userId: 'user-1',
portal: 'admin',
sessionVersion: 0,
createdAt: 1,
lastActivityAt: 1,
lastAuthenticatedAt: 1,
absoluteExpiresAt: Date.now() + 1000,
};
return {
storeCaptcha: jest.fn(async (id: string, answer: string) => { captchas.set(id, answer); }),
consumeCaptcha: jest.fn(async (id: string) => { const answer = captchas.get(id) ?? null; captchas.delete(id); return answer; }),
isAnonymousLoginLocked: jest.fn(async (login: string) => (failures.get(login) ?? 0) >= 5),
recordAnonymousLoginFailure: jest.fn(async (login: string) => { const count = (failures.get(login) ?? 0) + 1; failures.set(login, count); return count; }),
clearAnonymousLoginFailures: jest.fn(async (login: string) => { failures.delete(login); }),
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;
}),
assertCaptchaRequestAllowed: jest.fn().mockResolvedValue(true),
anonymousLoginLockScope: jest.fn(async (login: string) => ((failures.get(login) ?? 0) >= 5 ? 'account' : null)),
recordAnonymousLoginFailure: jest.fn(async (login: string) => {
const count = (failures.get(login) ?? 0) + 1;
failures.set(login, count);
return [count, count, count];
}),
clearAnonymousLoginFailures: jest.fn(async (login: string) => {
failures.delete(login);
}),
create: jest.fn().mockResolvedValue({ token: 'opaque-session-token', record }),
publicSession: jest.fn().mockReturnValue({ idleTimeoutSeconds: 3600, absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString() }),
publicSession: jest
.fn()
.mockReturnValue({
idleTimeoutSeconds: 3600,
absoluteExpiresAt: new Date(record.absoluteExpiresAt).toISOString(),
}),
unlock: jest.fn().mockResolvedValue({ status: 'active' }),
markReauthenticated: jest.fn().mockResolvedValue({ status: 'active' }),
};
}
function createMetricsMock() {
return { recordAuthProtectionResult: jest.fn() };
}
async function loginWithCaptcha(service: AuthService, portal: 'admin' | 'client', password = 'secret1') {
const captcha = await service.createCaptcha();
const answer = captcha.challenge.split('=')[0].split('+').map((part) => Number(part.trim())).reduce((sum, value) => sum + value, 0);
return service.login({
login: 'user@example.com',
password,
captchaId: captcha.captchaId,
captchaText: String(answer),
}, portal);
const captcha = await service.createCaptcha('203.0.113.10');
const answer = captcha.challenge
.split('=')[0]
.split('+')
.map((part) => Number(part.trim()))
.reduce((sum, value) => sum + value, 0);
return service.login(
{
login: 'user@example.com',
password,
captchaId: captcha.captchaId,
captchaText: String(answer),
},
portal,
'203.0.113.10',
);
}
describe('AuthService', () => {
it('allows platform admins to login admin portal', async () => {
const users = createUsersMock('platform_admin');
const sessions = createSessionsMock();
const service = new AuthService(users as never, sessions as never);
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }));
const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
await expect(loginWithCaptcha(service, 'admin')).resolves.toEqual(
expect.objectContaining({ portal: 'admin', sessionToken: 'opaque-session-token' }),
);
expect(users.recordLoginSuccess).toHaveBeenCalledWith('user-1');
expect(sessions.create).toHaveBeenCalledWith('user-1', 'admin', 0);
});
it('rejects enterprise admins on admin portal', async () => {
const users = createUsersMock('enterprise_admin');
const service = new AuthService(users as never, createSessionsMock() as never);
const service = new AuthService(users as never, createSessionsMock() as never, createMetricsMock() as never);
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
expect(users.recordLoginFailure).toHaveBeenCalledWith('user-1');
});
it('locks user after five failed password attempts', async () => {
const users = createUsersMock('platform_admin');
const service = new AuthService(users as never, createSessionsMock() as never);
const service = new AuthService(users as never, createSessionsMock() as never, createMetricsMock() as never);
for (let index = 0; index < 5; index += 1) {
await expect(loginWithCaptcha(service, 'admin', 'bad-password')).rejects.toBeInstanceOf(UnauthorizedException);
}
expect(users.recordLoginFailure).toHaveBeenCalledTimes(5);
});
it('rejects captcha bursts before allocating a captcha', async () => {
const sessions = createSessionsMock();
sessions.assertCaptchaRequestAllowed.mockResolvedValue(false);
const metrics = createMetricsMock();
const service = new AuthService(createUsersMock('platform_admin') as never, sessions as never, metrics as never);
await expect(service.createCaptcha('203.0.113.10')).rejects.toMatchObject({ status: 429 });
expect(sessions.storeCaptcha).not.toHaveBeenCalled();
expect(metrics.recordAuthProtectionResult).toHaveBeenCalledWith('captcha_rejected');
});
it('allows a tenant-bound enterprise admin to login to the client portal', async () => {
const service = new AuthService(
createUsersMock('enterprise_admin') as never,
createSessionsMock() as never,
createMetricsMock() as never,
);
await expect(loginWithCaptcha(service, 'client')).resolves.toEqual(expect.objectContaining({ portal: 'client' }));
});
it('records anonymous failures without disclosing whether an account exists', async () => {
const users = createUsersMock('platform_admin');
users.findByLogin.mockResolvedValue(null);
const sessions = createSessionsMock();
const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
await expect(loginWithCaptcha(service, 'admin')).rejects.toBeInstanceOf(UnauthorizedException);
expect(sessions.recordAnonymousLoginFailure).toHaveBeenCalledWith('user@example.com', '203.0.113.10');
});
it('rejects expired and incorrect one-time captchas', async () => {
const sessions = createSessionsMock();
const service = new AuthService(
createUsersMock('platform_admin') as never,
sessions as never,
createMetricsMock() as never,
);
await expect(
service.login(
{ login: 'user', password: 'secret1', captchaId: 'missing', captchaText: '1' },
'admin',
'203.0.113.10',
),
).rejects.toMatchObject({ status: 400 });
await sessions.storeCaptcha('captcha-wrong', '7');
await expect(
service.login(
{ login: 'user', password: 'secret1', captchaId: 'captcha-wrong', captchaText: '8' },
'admin',
'203.0.113.10',
),
).rejects.toMatchObject({ status: 400 });
});
it('delegates unlock and recent reauthentication to password and session services', async () => {
const users = createUsersMock('platform_admin');
const sessions = createSessionsMock();
const service = new AuthService(users as never, sessions as never, createMetricsMock() as never);
await expect(service.unlock('token', 'user-1', 'secret1')).resolves.toEqual({ status: 'active' });
await expect(service.reauthenticate('token', 'user-1', 'secret1')).resolves.toEqual({ status: 'active' });
expect(users.verifyCurrentPassword).toHaveBeenCalledTimes(2);
});
});
+32 -11
View File
@@ -1,16 +1,26 @@
import { randomUUID } from 'node:crypto';
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { BadRequestException, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import type { LoginDto } from './auth.dto';
import { SessionService } from './session.service';
import { MetricsService } from '../metrics/metrics.service';
type LoginPortal = 'admin' | 'client';
@Injectable()
export class AuthService {
constructor(private readonly users: UsersService, private readonly sessions: SessionService) {}
constructor(
private readonly users: UsersService,
private readonly sessions: SessionService,
private readonly metrics: MetricsService,
) {}
async createCaptcha() {
async createCaptcha(sourceIp: string) {
if (!(await this.sessions.assertCaptchaRequestAllowed(sourceIp))) {
this.metrics.recordAuthProtectionResult('captcha_rejected');
throw new HttpException('验证码请求过于频繁,请稍后再试', HttpStatus.TOO_MANY_REQUESTS);
}
this.metrics.recordAuthProtectionResult('captcha_allowed');
const left = Math.floor(10 + Math.random() * 40);
const right = Math.floor(1 + Math.random() * 9);
const captchaId = randomUUID();
@@ -22,43 +32,48 @@ export class AuthService {
};
}
async login(data: LoginDto, portal: LoginPortal) {
async login(data: LoginDto, portal: LoginPortal, sourceIp: string) {
const login = data.login?.trim();
if (!login || !data.password) {
throw new BadRequestException('login and password are required');
}
await this.verifyCaptcha(data.captchaId, data.captchaText);
await this.assertAnonymousNotLocked(login);
await this.assertAnonymousNotLocked(login, sourceIp);
const user = await this.users.findByLogin(login);
if (!user) {
await this.sessions.recordAnonymousLoginFailure(login);
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
throw new UnauthorizedException('Invalid login or password');
}
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
}
if (user.status !== 'active' || user.deletedAt) {
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('User is disabled or deleted');
}
if (!await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash)) {
if (!(await this.users.verifyLoginPassword(user.id, data.password, user.passwordHash))) {
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Invalid login or password');
}
const roleCodes = user.roles.map((item) => item.role.code);
if (portal === 'admin' && !roleCodes.includes('platform_admin')) {
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Only platform admins can login to admin portal');
}
if (portal === 'client' && (!roleCodes.includes('enterprise_admin') || !user.tenantId)) {
await this.sessions.recordAnonymousLoginFailure(login, sourceIp);
await this.users.recordLoginFailure(user.id);
throw new UnauthorizedException('Only enterprise admins linked to a tenant can login to client portal');
}
await this.users.recordLoginSuccess(user.id);
await this.sessions.clearAnonymousLoginFailures(login);
await this.sessions.clearAnonymousLoginFailures(login, sourceIp);
const { token, record } = await this.sessions.create(user.id, portal, user.sessionVersion ?? 0);
return {
@@ -98,9 +113,15 @@ export class AuthService {
}
}
private async assertAnonymousNotLocked(login: string) {
if (await this.sessions.isAnonymousLoginLocked(login)) {
throw new UnauthorizedException('User is locked for 24 hours after repeated failures');
private async assertAnonymousNotLocked(login: string, sourceIp: string) {
const scope = await this.sessions.anonymousLoginLockScope(login, sourceIp);
if (scope) {
this.metrics.recordAuthProtectionResult('login_locked', scope);
throw new UnauthorizedException(
scope === 'account'
? 'User is locked for 24 hours after repeated failures'
: 'Too many login attempts from this source, try again later',
);
}
}
}
+122 -6
View File
@@ -1,14 +1,43 @@
const values = new Map<string, string>();
const redis = {
get: jest.fn((key: string) => Promise.resolve(values.get(key) ?? null)),
set: jest.fn((key: string, value: string) => { values.set(key, value); return Promise.resolve('OK'); }),
del: jest.fn((key: string) => { values.delete(key); return Promise.resolve(1); }),
getdel: jest.fn((key: string) => {
const value = values.get(key) ?? null;
values.delete(key);
return Promise.resolve(value);
}),
mget: jest.fn((...keys: string[]) => Promise.resolve(keys.map((key) => values.get(key) ?? null))),
set: jest.fn((key: string, value: string) => {
values.set(key, value);
return Promise.resolve('OK');
}),
del: jest.fn((...keys: string[]) => {
keys.forEach((key) => values.delete(key));
return Promise.resolve(keys.length);
}),
eval: jest.fn((_script: string, keyCount: number, ...parts: Array<string | number>) => {
const keys = parts.slice(0, keyCount).map(String);
const args = parts.slice(keyCount).map(Number);
if (keyCount === 1) {
const count = Number(values.get(keys[0]) ?? 0) + 1;
values.set(keys[0], String(count));
return Promise.resolve(count);
}
const counts = keys.slice(0, 3).map((key, index) => {
const count = Number(values.get(key) ?? 0) + 1;
values.set(key, String(count));
if (count >= args[index + 3]) values.set(keys[index + 3], '1');
return count;
});
return Promise.resolve(counts);
}),
disconnect: jest.fn(),
};
jest.mock('ioredis', () => jest.fn(() => redis));
import { SessionService } from './session.service';
import { ServiceUnavailableException } from '@nestjs/common';
describe('SessionService', () => {
let now = 1_700_000_000_000;
@@ -47,7 +76,9 @@ describe('SessionService', () => {
const service = new SessionService();
const created = await service.create('user-1', 'client', 2);
now += 90 * 60 * 1000;
await expect(service.validate(created.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
await expect(service.validate(created.token, false)).resolves.toEqual(
expect.objectContaining({ status: 'active' }),
);
});
it('requires a full login after a session stays locked for four hours', async () => {
@@ -55,7 +86,10 @@ describe('SessionService', () => {
const created = await service.create('user-1', 'admin', 2);
await service.lock(created.token);
now += 4 * 60 * 60 * 1000 + 1;
await expect(service.validate(created.token, false)).resolves.toEqual({ status: 'expired', code: 'SESSION_LOCK_TIMEOUT' });
await expect(service.validate(created.token, false)).resolves.toEqual({
status: 'expired',
code: 'SESSION_LOCK_TIMEOUT',
});
});
it('rotates the opaque token when a password unlock succeeds', async () => {
@@ -66,8 +100,13 @@ describe('SessionService', () => {
expect(result.status).toBe('active');
if (result.status === 'active' && 'token' in result) {
expect(result.token).not.toBe(created.token);
await expect(service.validate(created.token, false)).resolves.toEqual({ status: 'expired', code: 'SESSION_INVALID' });
await expect(service.validate(result.token, false)).resolves.toEqual(expect.objectContaining({ status: 'active' }));
await expect(service.validate(created.token, false)).resolves.toEqual({
status: 'expired',
code: 'SESSION_INVALID',
});
await expect(service.validate(result.token, false)).resolves.toEqual(
expect.objectContaining({ status: 'active' }),
);
}
});
@@ -78,4 +117,81 @@ describe('SessionService', () => {
expect(service.cookieName('client')).toBe('cmpp_client_session');
delete process.env.SESSION_COOKIE_SECURE;
});
it('stores and consumes a captcha exactly once', async () => {
const service = new SessionService();
await service.storeCaptcha('captcha-1', '9', 300);
await expect(service.consumeCaptcha('captcha-1')).resolves.toBe('9');
await expect(service.consumeCaptcha('captcha-1')).resolves.toBeNull();
});
it('expires an absolute session and supports touch plus recent authentication', async () => {
const service = new SessionService();
const expired = await service.create('expired-user', 'admin', 1);
now += 12 * 60 * 60 * 1000 + 1;
await expect(service.validate(expired.token, false)).resolves.toEqual({
status: 'expired',
code: 'SESSION_ABSOLUTE_TIMEOUT',
});
now = 1_700_000_000_000;
const active = await service.create('active-user', 'client', 1);
now += 31_000;
const touched = await service.touch(active.token);
expect(touched.status).toBe('active');
const reauthenticated = await service.markReauthenticated(active.token);
expect(reauthenticated.status).toBe('active');
if (reauthenticated.status === 'active') {
expect(service.isRecentlyAuthenticated(reauthenticated.record)).toBe(true);
expect(service.publicSession(reauthenticated.record)).toEqual(
expect.objectContaining({ idleTimeoutSeconds: 7200 }),
);
}
});
it('uses host-prefixed cookie names when secure cookies are enabled', () => {
process.env.SESSION_COOKIE_SECURE = 'true';
const service = new SessionService();
expect(service.cookieName('admin')).toBe('__Host-cmpp_admin_session');
expect(service.cookieName('client')).toBe('__Host-cmpp_client_session');
delete process.env.SESSION_COOKIE_SECURE;
});
it('rate limits captcha allocation by hashed source IP', async () => {
const service = new SessionService();
for (let index = 0; index < 30; index += 1) {
await expect(service.assertCaptchaRequestAllowed('203.0.113.10')).resolves.toBe(true);
}
await expect(service.assertCaptchaRequestAllowed('203.0.113.10')).resolves.toBe(false);
expect([...values.keys()].some((key) => key.includes('203.0.113.10'))).toBe(false);
});
it('locks anonymous failures independently by account, IP and account-IP pair', async () => {
const service = new SessionService();
for (let index = 0; index < 5; index += 1) {
await service.recordAnonymousLoginFailure('user@example.com', '203.0.113.10');
}
await expect(service.anonymousLoginLockScope('user@example.com', '198.51.100.7')).resolves.toBe('account');
await expect(service.anonymousLoginLockScope('other@example.com', '203.0.113.10')).resolves.toBeNull();
await service.clearAnonymousLoginFailures('user@example.com', '203.0.113.10');
await expect(service.anonymousLoginLockScope('user@example.com', '203.0.113.10')).resolves.toBeNull();
});
it('fails closed when Redis cannot enforce captcha or login protection', async () => {
const service = new SessionService();
redis.eval.mockRejectedValueOnce(new Error('redis unavailable'));
await expect(service.assertCaptchaRequestAllowed('203.0.113.20')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
redis.mget.mockRejectedValueOnce(new Error('redis unavailable'));
await expect(service.anonymousLoginLockScope('user@example.com', '203.0.113.20')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
redis.eval.mockRejectedValueOnce(new Error('redis unavailable'));
await expect(service.recordAnonymousLoginFailure('user@example.com', '203.0.113.20')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
});
+81 -22
View File
@@ -22,8 +22,13 @@ export type SessionValidationResult =
const SESSION_PREFIX = 'cmpp:auth:session:';
const CAPTCHA_PREFIX = 'cmpp:auth:captcha:';
const CAPTCHA_RATE_PREFIX = 'cmpp:auth:captcha-rate:ip:';
const ANONYMOUS_FAILURE_PREFIX = 'cmpp:auth:failure:';
const ANONYMOUS_LOCK_PREFIX = 'cmpp:auth:lock:';
const ANONYMOUS_IP_FAILURE_PREFIX = 'cmpp:auth:failure:ip:';
const ANONYMOUS_IP_LOCK_PREFIX = 'cmpp:auth:lock:ip:';
const ANONYMOUS_PAIR_FAILURE_PREFIX = 'cmpp:auth:failure:pair:';
const ANONYMOUS_PAIR_LOCK_PREFIX = 'cmpp:auth:lock:pair:';
export const SESSION_COOKIE_NAME = '__Host-cmpp_session';
export const DEVELOPMENT_SESSION_COOKIE_NAME = 'cmpp_session';
export const ADMIN_SESSION_COOKIE_NAME = '__Host-cmpp_admin_session';
@@ -142,40 +147,87 @@ export class SessionService implements OnModuleDestroy {
}
}
async isAnonymousLoginLocked(login: string) {
async assertCaptchaRequestAllowed(sourceIp: string) {
const key = `${CAPTCHA_RATE_PREFIX}${this.valueDigest(sourceIp)}`;
try {
return Boolean(await this.client.exists(`${ANONYMOUS_LOCK_PREFIX}${this.loginDigest(login)}`));
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
return count`,
1,
key,
5 * 60,
),
);
return count <= 30;
} catch {
throw new ServiceUnavailableException('验证码服务暂不可用');
}
}
async anonymousLoginLockScope(login: string, sourceIp: string) {
const accountDigest = this.loginDigest(login);
const ipDigest = this.valueDigest(sourceIp);
const pairDigest = this.valueDigest(`${accountDigest}:${ipDigest}`);
try {
const locks = await this.client.mget(
`${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
`${ANONYMOUS_IP_LOCK_PREFIX}${ipDigest}`,
`${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
);
if (locks[0]) return 'account' as const;
if (locks[1]) return 'ip' as const;
if (locks[2]) return 'pair' as const;
return null;
} 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}`;
async recordAnonymousLoginFailure(login: string, sourceIp: string) {
const accountDigest = this.loginDigest(login);
const ipDigest = this.valueDigest(sourceIp);
const pairDigest = this.valueDigest(`${accountDigest}:${ipDigest}`);
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,
const result = await this.client.eval(
`local counts = {}
for i = 1, 3 do
counts[i] = redis.call('INCR', KEYS[i])
if counts[i] == 1 then redis.call('EXPIRE', KEYS[i], ARGV[i]) end
if counts[i] >= tonumber(ARGV[i + 3]) then redis.call('SET', KEYS[i + 3], '1', 'EX', ARGV[i]) end
end
return counts`,
6,
`${ANONYMOUS_FAILURE_PREFIX}${accountDigest}`,
`${ANONYMOUS_IP_FAILURE_PREFIX}${ipDigest}`,
`${ANONYMOUS_PAIR_FAILURE_PREFIX}${pairDigest}`,
`${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
`${ANONYMOUS_IP_LOCK_PREFIX}${ipDigest}`,
`${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
24 * 60 * 60,
15 * 60,
24 * 60 * 60,
5,
));
return count;
30,
5,
);
return (result as number[]).map(Number);
} catch {
throw new ServiceUnavailableException('登录保护服务暂不可用');
}
}
async clearAnonymousLoginFailures(login: string) {
const digest = this.loginDigest(login);
async clearAnonymousLoginFailures(login: string, sourceIp: string) {
const accountDigest = this.loginDigest(login);
const pairDigest = this.valueDigest(`${accountDigest}:${this.valueDigest(sourceIp)}`);
try {
await this.client.del(`${ANONYMOUS_FAILURE_PREFIX}${digest}`, `${ANONYMOUS_LOCK_PREFIX}${digest}`);
await this.client.del(
`${ANONYMOUS_FAILURE_PREFIX}${accountDigest}`,
`${ANONYMOUS_LOCK_PREFIX}${accountDigest}`,
`${ANONYMOUS_PAIR_FAILURE_PREFIX}${pairDigest}`,
`${ANONYMOUS_PAIR_LOCK_PREFIX}${pairDigest}`,
);
} catch {
throw new ServiceUnavailableException('登录保护服务暂不可用');
}
@@ -196,7 +248,10 @@ export class SessionService implements OnModuleDestroy {
}
get cookieSecure() {
return process.env.SESSION_COOKIE_SECURE === 'true' || (process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false');
return (
process.env.SESSION_COOKIE_SECURE === 'true' ||
(process.env.NODE_ENV === 'production' && process.env.SESSION_COOKIE_SECURE !== 'false')
);
}
cookieName(portal: SessionPortal) {
@@ -230,7 +285,7 @@ export class SessionService implements OnModuleDestroy {
private async read(token: string): Promise<AuthSessionRecord | null> {
try {
const value = await this.client.get(this.key(token));
return value ? JSON.parse(value) as AuthSessionRecord : null;
return value ? (JSON.parse(value) as AuthSessionRecord) : null;
} catch {
throw new ServiceUnavailableException('登录会话服务暂不可用');
}
@@ -254,7 +309,11 @@ export class SessionService implements OnModuleDestroy {
}
private loginDigest(login: string) {
return createHash('sha256').update(login.trim().toLocaleLowerCase('en-US')).digest('hex');
return this.valueDigest(login.trim().toLocaleLowerCase('en-US'));
}
private valueDigest(value: string) {
return createHash('sha256').update(value.trim()).digest('hex');
}
private get client() {
@@ -0,0 +1,55 @@
import { registerDecorator, type ValidationArguments, type ValidationOptions } from 'class-validator';
type BoundedJsonOptions = {
maxDepth?: number;
maxKeys?: number;
maxStringLength?: number;
};
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
export function IsBoundedJsonObject(options: BoundedJsonOptions = {}, validationOptions?: ValidationOptions) {
return (target: object, propertyName: string) =>
registerDecorator({
name: 'isBoundedJsonObject',
target: target.constructor,
propertyName,
constraints: [options],
options: validationOptions,
validator: {
validate(value: unknown, args: ValidationArguments) {
if (value === undefined || value === null) return true;
const [constraints] = args.constraints as [BoundedJsonOptions];
return isBoundedJsonValue(value, {
maxDepth: constraints.maxDepth ?? 4,
maxKeys: constraints.maxKeys ?? 100,
maxStringLength: constraints.maxStringLength ?? 2_000,
});
},
defaultMessage(args: ValidationArguments) {
return `${args.property} contains too many, too deeply nested, or unsafe values`;
},
},
});
}
function isBoundedJsonValue(value: unknown, limits: Required<BoundedJsonOptions>) {
let keyCount = 0;
const visit = (current: unknown, depth: number): boolean => {
if (depth > limits.maxDepth) return false;
if (current == null || typeof current === 'boolean' || typeof current === 'number') return true;
if (typeof current === 'string') return current.length <= limits.maxStringLength;
if (Array.isArray(current)) {
keyCount += current.length;
return keyCount <= limits.maxKeys && current.every((item) => visit(item, depth + 1));
}
if (typeof current !== 'object') return false;
const entries = Object.entries(current as Record<string, unknown>);
keyCount += entries.length;
return (
keyCount <= limits.maxKeys &&
entries.every(([key, item]) => key.length <= 128 && !FORBIDDEN_KEYS.has(key) && visit(item, depth + 1))
);
};
return visit(value, 0);
}
+30 -7
View File
@@ -1,5 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { ClientBatchTaskDto, ClientImportConfirmDto, ClientStatusChangeDto } from './client-write.dto';
import { ClientBatchTaskDto, ClientDeleteResourceDto, ClientImportConfirmDto } from './client-write.dto';
import { strictValidationPipe } from './strict-validation.pipe';
function validate<T>(metatype: new () => T, value: unknown) {
@@ -8,10 +8,12 @@ function validate<T>(metatype: new () => T, value: unknown) {
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) }));
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 () => {
@@ -19,7 +21,28 @@ describe('strict client write DTOs', () => {
});
it('rejects a client-supplied operator identity', async () => {
await expect(validate(ClientStatusChangeDto, { status: 'disabled', operatorId: 'another-user' }))
.rejects.toBeInstanceOf(BadRequestException);
await expect(
validate(ClientDeleteResourceDto, { status: 'deleted', operatorId: 'another-user' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a client-supplied tenant identity', async () => {
await expect(
validate(ClientBatchTaskDto, {
tenantId: 'other-tenant',
content: '【测试】通知',
phones: ['13800000001'],
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects deeply nested or prototype-like dynamic values', async () => {
await expect(
validate(ClientBatchTaskDto, {
content: '【测试】通知',
phones: ['13800000001'],
variables: { safe: { nested: { too: { deep: { value: 'x' } } } } },
}),
).rejects.toBeInstanceOf(BadRequestException);
});
});
+43 -14
View File
@@ -10,6 +10,7 @@ import {
IsOptional,
IsString,
IsUrl,
IsDateString,
Matches,
Max,
MaxLength,
@@ -17,26 +18,25 @@ import {
MinLength,
ValidateNested,
} from 'class-validator';
import { IsBoundedJsonObject } from './bounded-json-object.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>;
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) 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() @IsDateString({ strict: true }) scheduledAt?: string;
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) variables?: Record<string, unknown>;
@IsOptional() @IsDateString({ strict: true }) requestedAt?: string;
@IsOptional() @IsString() @MaxLength(128) clientMessageId?: string;
}
@@ -45,7 +45,6 @@ export class ClientBatchTaskDto extends ClientTaskBaseDto {
}
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;
@@ -60,7 +59,6 @@ export class ClientImportConfirmDto extends ClientTaskBaseDto {
}
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;
@@ -69,7 +67,6 @@ export class ClientBillingEstimateDto {
}
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;
@@ -93,11 +90,10 @@ export class ClientSmsApplicationDto {
}
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>;
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 100, maxDepth: 4 }) drainageInfo?: Record<string, unknown>;
}
export class ClientSmsSignatureUpdateDto extends PartialType(ClientSmsSignatureDto) {
@@ -108,7 +104,7 @@ 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>;
@IsOptional() @IsObject() @IsBoundedJsonObject({ maxKeys: 200, maxDepth: 4 }) reportValues?: Record<string, unknown>;
}
export class ClientDrainageInfoUpdateDto extends PartialType(ClientDrainageInfoDto) {}
@@ -127,13 +123,17 @@ class TemplateVariableDto {
}
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[];
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@ValidateNested({ each: true })
@Type(() => TemplateVariableDto)
variables?: TemplateVariableDto[];
}
export class ClientSmsTemplateUpdateDto extends PartialType(ClientSmsTemplateDto) {
@@ -152,3 +152,32 @@ export class ClientStatusChangeDto {
@IsOptional() @IsBoolean() deleteAssociatedDrainage?: boolean;
@IsOptional() @IsBoolean() abandonAssociatedReportTasks?: boolean;
}
export class ClientSecretResetDto {
@IsOptional() @IsString() @MaxLength(1000) reason?: string;
@IsOptional() @IsString() @MaxLength(64) expectedUpdatedAt?: string;
@IsOptional() @IsString() @MaxLength(128) idempotencyKey?: string;
}
export class ClientApplicationStatusDto {
@IsOptional() @IsIn(['active', 'disabled', 'disabling', 'deleted']) 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;
}
export class ClientDeleteResourceDto {
@IsIn(['deleted']) status!: 'deleted';
@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;
}
+18 -4
View File
@@ -1,8 +1,22 @@
import { BadRequestException, Body, Controller, Get, Param, Post, Query, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
Query,
Res,
UploadedFile,
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { FilesService } from './files.service';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientFileUploadDto } from './client-files.dto';
type UploadedMultipartFile = { originalname: string; mimetype: string; size: number; buffer: Buffer };
type DownloadResponse = { setHeader(name: string, value: number | string): void; send(content: Buffer): void };
@@ -14,14 +28,14 @@ export class ClientFilesController {
@Post('upload')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4, parts: 5 } }))
@UsePipes(strictValidationPipe)
upload(
@CurrentSessionUserId() userId: string | undefined,
@UploadedFile() file: UploadedMultipartFile,
@Body('purpose') purpose: string,
@Body('prefix') prefix?: string,
@Body() body: ClientFileUploadDto,
) {
if (!file) throw new BadRequestException('Upload file is required');
return this.files.uploadForClient(userId, { purpose, prefix }, file);
return this.files.uploadForClient(userId, body, file);
}
@Get(':id/download')
+28
View File
@@ -0,0 +1,28 @@
import { BadRequestException } from '@nestjs/common';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientFileUploadDto } from './client-files.dto';
describe('ClientFileUploadDto', () => {
it('accepts bounded client material paths and rejects traversal before storage', async () => {
await expect(
strictValidationPipe.transform(
{ purpose: 'drainage_report_material', prefix: 'drainage-materials/item-1' },
{
type: 'body',
metatype: ClientFileUploadDto,
data: undefined,
},
),
).resolves.toEqual(expect.objectContaining({ purpose: 'drainage_report_material' }));
await expect(
strictValidationPipe.transform(
{ purpose: 'enterprise_certification', prefix: '../admin' },
{
type: 'body',
metatype: ClientFileUploadDto,
data: undefined,
},
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
+13
View File
@@ -0,0 +1,13 @@
import { IsIn, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class ClientFileUploadDto {
@IsString()
@IsIn(['enterprise_certification', 'signature_report_material', 'drainage_report_material'])
purpose!: string;
@IsOptional()
@IsString()
@MaxLength(256)
@Matches(/^[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/)
prefix?: string;
}
+47 -7
View File
@@ -54,7 +54,9 @@ function escapeLabel(value: string) {
function metricLine(name: string, value: number, labels?: Record<string, string>) {
const suffix = labels
? `{${Object.entries(labels).map(([key, item]) => `${key}="${escapeLabel(item)}"`).join(',')}}`
? `{${Object.entries(labels)
.map(([key, item]) => `${key}="${escapeLabel(item)}"`)
.join(',')}}`
: '';
return `${name}${suffix} ${Number.isFinite(value) ? value : 0}`;
}
@@ -78,6 +80,7 @@ export class MetricsService implements OnModuleDestroy {
private inboundWorkflowConfiguredSlots = 0;
private inboundWorkflowInFlightSlots = 0;
private readonly inboundWorkflowResults = new Map<string, number>();
private readonly authProtectionResults = new Map<string, number>();
constructor() {
this.eventLoopDelay.enable();
@@ -176,6 +179,14 @@ export class MetricsService implements OnModuleDestroy {
this.inboundWorkflowResults.set(result, (this.inboundWorkflowResults.get(result) ?? 0) + 1);
}
recordAuthProtectionResult(
event: 'captcha_allowed' | 'captcha_rejected' | 'login_locked',
scope: 'none' | 'account' | 'ip' | 'pair' = 'none',
) {
const key = `${event}\u0000${scope}`;
this.authProtectionResults.set(key, (this.authProtectionResults.get(key) ?? 0) + 1);
}
render() {
const memory = process.memoryUsage();
const uptime = Number(process.hrtime.bigint() - this.startedAt) / 1_000_000_000;
@@ -194,7 +205,10 @@ export class MetricsService implements OnModuleDestroy {
metricLine('cmpp_api_nodejs_heap_total_bytes', memory.heapTotal),
'# HELP cmpp_api_nodejs_event_loop_lag_p99_seconds Event loop delay p99 since the previous scrape.',
'# TYPE cmpp_api_nodejs_event_loop_lag_p99_seconds gauge',
metricLine('cmpp_api_nodejs_event_loop_lag_p99_seconds', this.eventLoopDelay.count ? this.eventLoopDelay.percentile(99) / 1_000_000_000 : 0),
metricLine(
'cmpp_api_nodejs_event_loop_lag_p99_seconds',
this.eventLoopDelay.count ? this.eventLoopDelay.percentile(99) / 1_000_000_000 : 0,
),
'# HELP cmpp_api_http_requests_in_flight Current API requests in flight.',
'# TYPE cmpp_api_http_requests_in_flight gauge',
metricLine('cmpp_api_http_requests_in_flight', this.inFlight),
@@ -226,16 +240,26 @@ export class MetricsService implements OnModuleDestroy {
metricLine('cmpp_worker_inbound_workflow_slots', this.inboundWorkflowInFlightSlots, { state: 'in_flight' }),
'# HELP cmpp_worker_inbound_workflow_oldest_pending_age_seconds Age of the oldest pending durable workflow.',
'# TYPE cmpp_worker_inbound_workflow_oldest_pending_age_seconds gauge',
metricLine('cmpp_worker_inbound_workflow_oldest_pending_age_seconds', this.inboundWorkflowOldestPendingAgeSeconds),
metricLine(
'cmpp_worker_inbound_workflow_oldest_pending_age_seconds',
this.inboundWorkflowOldestPendingAgeSeconds,
),
'# HELP cmpp_worker_inbound_workflow_results_total Durable workflow processing outcomes.',
'# TYPE cmpp_worker_inbound_workflow_results_total counter',
'# HELP cmpp_api_auth_protection_events_total Authentication protection outcomes by bounded event and scope.',
'# TYPE cmpp_api_auth_protection_events_total counter',
];
for (const [key, metric] of this.http) {
const [method, route, status] = key.split('\u0000');
const labels = { method, route, status };
lines.push(metricLine('cmpp_api_http_requests_total', metric.count, labels));
HTTP_DURATION_BUCKETS.forEach((bucket, index) => {
lines.push(metricLine('cmpp_api_http_request_duration_seconds_bucket', metric.buckets[index], { ...labels, le: String(bucket) }));
lines.push(
metricLine('cmpp_api_http_request_duration_seconds_bucket', metric.buckets[index], {
...labels,
le: String(bucket),
}),
);
});
lines.push(metricLine('cmpp_api_http_request_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }));
lines.push(metricLine('cmpp_api_http_request_duration_seconds_sum', metric.durationSum, labels));
@@ -245,9 +269,16 @@ export class MetricsService implements OnModuleDestroy {
const [stage, result] = key.split('\u0000');
const labels = { stage, result };
CMPP_INBOUND_DURATION_BUCKETS.forEach((bucket, index) => {
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_bucket', metric.buckets[index], { ...labels, le: String(bucket) }));
lines.push(
metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_bucket', metric.buckets[index], {
...labels,
le: String(bucket),
}),
);
});
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }));
lines.push(
metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }),
);
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_sum', metric.durationSum, labels));
lines.push(metricLine('cmpp_api_cmpp_inbound_stage_duration_seconds_count', metric.count, labels));
}
@@ -255,7 +286,12 @@ export class MetricsService implements OnModuleDestroy {
const [stage, result] = key.split('\u0000');
const labels = { stage, result };
SEND_WORKER_DURATION_BUCKETS.forEach((bucket, index) => {
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_bucket', metric.buckets[index], { ...labels, le: String(bucket) }));
lines.push(
metricLine('cmpp_worker_send_stage_duration_seconds_bucket', metric.buckets[index], {
...labels,
le: String(bucket),
}),
);
});
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_bucket', metric.count, { ...labels, le: '+Inf' }));
lines.push(metricLine('cmpp_worker_send_stage_duration_seconds_sum', metric.durationSum, labels));
@@ -273,6 +309,10 @@ export class MetricsService implements OnModuleDestroy {
for (const [result, count] of this.inboundWorkflowResults) {
lines.push(metricLine('cmpp_worker_inbound_workflow_results_total', count, { result }));
}
for (const [key, count] of this.authProtectionResults) {
const [event, scope] = key.split('\u0000');
lines.push(metricLine('cmpp_api_auth_protection_events_total', count, { event, scope }));
}
this.eventLoopDelay.reset();
return `${lines.join('\n')}\n`;
}
+54 -10
View File
@@ -1,22 +1,66 @@
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseEnumPipe, Post, Put, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { OpenApiService } from './open-api.service';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientHttpCredentialDto, ClientWebhookDto, ClientWebhookEventType } from './client-open-api.dto';
@ApiTags('client-http-open-api-management')
@Controller('client/applications/:applicationId/http-api')
export class ClientOpenApiController {
constructor(private readonly service: OpenApiService) {}
@Get() getConfig(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.getConfig(applicationId, tenantId); }
@Get('credentials') listCredentials(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listCredentials(applicationId, tenantId); }
@Post('credentials') @RequireRecentAuthentication() createCredential(@Param('applicationId') applicationId: string, @Body() body: { name?: string; expiresAt?: string; createdById?: string }, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() createdById?: string) { return this.service.createCredential(applicationId, { ...body, createdById }, tenantId, true); }
@Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(@Param('applicationId') applicationId: string, @Param('credentialId') credentialId: string, @CurrentTenantId() tenantId: string) { return this.service.revokeCredential(applicationId, credentialId, tenantId); }
@Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.getWebhookEndpoints(applicationId, tenantId); }
@Put('webhooks/:eventType') @RequireRecentAuthentication() upsertWebhook(@Param('applicationId') applicationId: string, @Param('eventType') eventType: string, @Body() body: { url: string; rotateSecret?: boolean; status?: string }, @CurrentTenantId() tenantId: string) { return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId); }
@Get('requests') listRequests(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listRequestLogs(applicationId, tenantId); }
@Get('webhook-deliveries') listDeliveries(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) { return this.service.listWebhookDeliveries(applicationId, tenantId); }
@Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(@Param('applicationId') applicationId: string, @Param('deliveryId') deliveryId: string, @CurrentTenantId() tenantId: string) { return this.service.retryWebhookDelivery(applicationId, deliveryId, tenantId); }
@Get() getConfig(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) {
return this.service.getConfig(applicationId, tenantId);
}
@Get('credentials') listCredentials(
@Param('applicationId') applicationId: string,
@CurrentTenantId() tenantId: string,
) {
return this.service.listCredentials(applicationId, tenantId);
}
@Post('credentials') @RequireRecentAuthentication() @UsePipes(strictValidationPipe) createCredential(
@Param('applicationId') applicationId: string,
@Body() body: ClientHttpCredentialDto,
@CurrentTenantId() tenantId: string,
@CurrentSessionUserId() createdById?: string,
) {
return this.service.createCredential(applicationId, { ...body, createdById }, tenantId, true);
}
@Post('credentials/:credentialId/revoke') @RequireRecentAuthentication() revokeCredential(
@Param('applicationId') applicationId: string,
@Param('credentialId') credentialId: string,
@CurrentTenantId() tenantId: string,
) {
return this.service.revokeCredential(applicationId, credentialId, tenantId);
}
@Get('webhooks') getWebhooks(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) {
return this.service.getWebhookEndpoints(applicationId, tenantId);
}
@Put('webhooks/:eventType') @RequireRecentAuthentication() @UsePipes(strictValidationPipe) upsertWebhook(
@Param('applicationId') applicationId: string,
@Param('eventType', new ParseEnumPipe(ClientWebhookEventType)) eventType: ClientWebhookEventType,
@Body() body: ClientWebhookDto,
@CurrentTenantId() tenantId: string,
) {
return this.service.upsertWebhookEndpoint(applicationId, eventType, body, tenantId);
}
@Get('requests') listRequests(@Param('applicationId') applicationId: string, @CurrentTenantId() tenantId: string) {
return this.service.listRequestLogs(applicationId, tenantId);
}
@Get('webhook-deliveries') listDeliveries(
@Param('applicationId') applicationId: string,
@CurrentTenantId() tenantId: string,
) {
return this.service.listWebhookDeliveries(applicationId, tenantId);
}
@Post('webhook-deliveries/:deliveryId/retry') @RequireRecentAuthentication() retryDelivery(
@Param('applicationId') applicationId: string,
@Param('deliveryId') deliveryId: string,
@CurrentTenantId() tenantId: string,
) {
return this.service.retryWebhookDelivery(applicationId, deliveryId, tenantId);
}
}
@@ -0,0 +1,28 @@
import { BadRequestException } from '@nestjs/common';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientHttpCredentialDto, ClientWebhookDto } from './client-open-api.dto';
function validate<T>(metatype: new () => T, value: unknown) {
return strictValidationPipe.transform(value, { type: 'body', metatype, data: undefined });
}
describe('client HTTP API DTOs', () => {
it('rejects client-supplied operator identity and malformed expiry dates', async () => {
await expect(validate(ClientHttpCredentialDto, { name: '凭据', createdById: 'spoofed' })).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(validate(ClientHttpCredentialDto, { expiresAt: 'tomorrow' })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('accepts a blank webhook URL for deletion and rejects unsafe fields', async () => {
await expect(validate(ClientWebhookDto, { url: ' ' })).resolves.toEqual(expect.objectContaining({ url: '' }));
await expect(validate(ClientWebhookDto, { url: 'javascript:alert(1)' })).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(
validate(ClientWebhookDto, { url: 'https://example.com/hook', status: 'approved' }),
).rejects.toBeInstanceOf(BadRequestException);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { Transform } from 'class-transformer';
import { IsBoolean, IsDateString, IsIn, IsOptional, IsString, IsUrl, MaxLength, ValidateIf } from 'class-validator';
export enum ClientWebhookEventType {
Receipt = 'receipt',
Uplink = 'uplink',
}
export class ClientHttpCredentialDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsDateString({ strict: true })
expiresAt?: string;
}
export class ClientWebhookDto {
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MaxLength(2048)
@ValidateIf(({ url }) => url !== '')
@IsUrl({ require_protocol: true, require_tld: false, protocols: ['http', 'https'] })
url!: string;
@IsOptional()
@IsBoolean()
rotateSecret?: boolean;
@IsOptional()
@IsIn(['active', 'inactive'])
status?: 'active' | 'inactive';
}
+175 -43
View File
@@ -3,7 +3,9 @@ import { decryptSecret, encryptSecret } from './open-api.crypto';
import { OpenApiService } from './open-api.service';
describe('OpenApiService', () => {
beforeAll(() => { process.env.HTTP_API_MASTER_KEY = 'test-master-key-with-at-least-32-characters'; });
beforeAll(() => {
process.env.HTTP_API_MASTER_KEY = 'test-master-key-with-at-least-32-characters';
});
it('encrypts secrets with authenticated encryption', () => {
const encrypted = encryptSecret('customer-secret');
@@ -15,11 +17,15 @@ describe('OpenApiService', () => {
const previous = process.env.HTTP_API_PUBLIC_ORIGIN;
process.env.HTTP_API_PUBLIC_ORIGIN = 'https://api.lisglo.com/';
const prisma = {
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) },
smsApplication: {
findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }),
},
};
try {
const service = new OpenApiService(prisma as never, {} as never);
await expect(service.getConfig('app-1')).resolves.toEqual(expect.objectContaining({ publicOrigin: 'https://api.lisglo.com' }));
await expect(service.getConfig('app-1')).resolves.toEqual(
expect.objectContaining({ publicOrigin: 'https://api.lisglo.com' }),
);
} finally {
if (previous === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN;
else process.env.HTTP_API_PUBLIC_ORIGIN = previous;
@@ -32,13 +38,17 @@ describe('OpenApiService', () => {
process.env.HTTP_API_PUBLIC_ORIGIN = 'http://100.93.204.60:12026/';
delete process.env.HTTP_API_ALLOW_INSECURE_ORIGIN;
const prisma = {
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }) },
smsApplication: {
findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', httpConfig: null, httpIpAllowlist: [] }),
},
};
try {
const service = new OpenApiService(prisma as never, {} as never);
await expect(service.getConfig('app-1')).rejects.toThrow('HTTP_API_ALLOW_INSECURE_ORIGIN');
process.env.HTTP_API_ALLOW_INSECURE_ORIGIN = 'true';
await expect(service.getConfig('app-1')).resolves.toEqual(expect.objectContaining({ publicOrigin: 'http://100.93.204.60:12026' }));
await expect(service.getConfig('app-1')).resolves.toEqual(
expect.objectContaining({ publicOrigin: 'http://100.93.204.60:12026' }),
);
} finally {
if (previousOrigin === undefined) delete process.env.HTTP_API_PUBLIC_ORIGIN;
else process.env.HTTP_API_PUBLIC_ORIGIN = previousOrigin;
@@ -49,25 +59,62 @@ describe('OpenApiService', () => {
it('replays a completed request for the same idempotency key and body', async () => {
const prisma = {
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'completed', responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' } }) },
openApiRequest: {
findUnique: jest
.fn()
.mockResolvedValue({
bodyHash: 'same',
status: 'completed',
responseBody: { code: 'ACCEPTED', messageId: 'MSG-1' },
}),
},
};
const sendChain = { createHttpBatchTask: jest.fn() };
const service = new OpenApiService(prisma as never, sendChain as never);
const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' });
const result = await service.sendMessage(
auth() as never,
{ mobile: '18821203795', content: '【测试】短信' },
{ idempotencyKey: 'idem-0001', bodyHash: 'same' },
);
expect(result).toEqual({ code: 'ACCEPTED', messageId: 'MSG-1' });
expect(sendChain.createHttpBatchTask).not.toHaveBeenCalled();
});
it('rejects reuse of an idempotency key with a different body', async () => {
const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'old', status: 'completed' }) } };
const prisma = {
openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'old', status: 'completed' }) },
};
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never);
await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'new' })).rejects.toBeInstanceOf(ConflictException);
await expect(
service.sendMessage(
auth() as never,
{ mobile: '18821203795', content: '【测试】短信' },
{ idempotencyKey: 'idem-0001', bodyHash: 'new' },
),
).rejects.toBeInstanceOf(ConflictException);
});
it('replays the same persisted business rejection', async () => {
const prisma = { openApiRequest: { findUnique: jest.fn().mockResolvedValue({ bodyHash: 'same', status: 'failed', httpStatus: 422, responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' } }) } };
const prisma = {
openApiRequest: {
findUnique: jest
.fn()
.mockResolvedValue({
bodyHash: 'same',
status: 'failed',
httpStatus: 422,
responseBody: { code: 'SEND_REJECTED', message: '模板不匹配' },
}),
},
};
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn() } as never);
await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信' }, { idempotencyKey: 'idem-0001', bodyHash: 'same' })).rejects.toMatchObject({ status: 422 });
await expect(
service.sendMessage(
auth() as never,
{ mobile: '18821203795', content: '【测试】短信' },
{ idempotencyKey: 'idem-0001', bodyHash: 'same' },
),
).rejects.toMatchObject({ status: 422 });
});
it('uses the real send chain and persists the accepted response', async () => {
@@ -79,47 +126,107 @@ describe('OpenApiService', () => {
},
smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) },
};
const sendChain = { createHttpBatchTask: jest.fn().mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }) };
const sendChain = {
createHttpBatchTask: jest
.fn()
.mockResolvedValue({ status: 'ready', messages: [{ id: 'row-1', messageId: 'MSG-1', status: 'queued' }] }),
};
const service = new OpenApiService(prisma as never, sendChain as never);
const result = await service.sendMessage(auth() as never, { mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' });
expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith(expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' }));
expect(result).toEqual(expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' }));
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }) }));
const result = await service.sendMessage(
auth() as never,
{ mobile: '18821203795', content: '【测试】短信', clientMessageId: 'client-1' },
{ idempotencyKey: 'idem-0001', bodyHash: 'hash' },
);
expect(sendChain.createHttpBatchTask).toHaveBeenCalledWith(
expect.objectContaining({ phones: ['18821203795'], clientMessageId: 'client-1' }),
);
expect(result).toEqual(
expect.objectContaining({ code: 'ACCEPTED', messageId: 'MSG-1', clientMessageId: 'client-1' }),
);
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'completed', httpStatus: 202, messageRecordId: 'row-1' }),
}),
);
});
it('persists a 422 result when the real send chain rejects the business request', async () => {
const prisma = {
openApiRequest: { findUnique: jest.fn().mockResolvedValue(null), create: jest.fn().mockResolvedValue({ id: 'request-row-1' }), update: jest.fn().mockResolvedValue({}) },
openApiRequest: {
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ id: 'request-row-1' }),
update: jest.fn().mockResolvedValue({}),
},
smsMessageRecord: { findFirst: jest.fn().mockResolvedValue(null) },
};
const service = new OpenApiService(prisma as never, { createHttpBatchTask: jest.fn().mockRejectedValue(new BadRequestException('短信未匹配模板')) } as never);
await expect(service.sendMessage(auth() as never, { mobile: '18821203795', content: '未匹配模板' }, { idempotencyKey: 'idem-0001', bodyHash: 'hash' })).rejects.toMatchObject({ status: 422 });
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'failed', httpStatus: 422, businessCode: 'SEND_REJECTED' }) }));
const service = new OpenApiService(
prisma as never,
{ createHttpBatchTask: jest.fn().mockRejectedValue(new BadRequestException('短信未匹配模板')) } as never,
);
await expect(
service.sendMessage(
auth() as never,
{ mobile: '18821203795', content: '未匹配模板' },
{ idempotencyKey: 'idem-0001', bodyHash: 'hash' },
),
).rejects.toMatchObject({ status: 422 });
expect(prisma.openApiRequest.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'failed', httpStatus: 422, businessCode: 'SEND_REJECTED' }),
}),
);
});
it('creates an HTTP webhook event when HTTP and the event capability are enabled', async () => {
const prisma = {
smsApplication: { findUnique: jest.fn().mockResolvedValue({ httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' } }) },
smsApplication: {
findUnique: jest
.fn()
.mockResolvedValue({
httpConfig: { enabled: true, receiptWebhookEnabled: true, receiptDeliveryMode: 'http' },
}),
},
httpWebhookEndpoint: { findUnique: jest.fn().mockResolvedValue({ id: 'endpoint-1', status: 'active' }) },
httpWebhookEvent: { upsert: jest.fn().mockResolvedValue({ id: 'event-row-1' }) },
httpWebhookDelivery: { upsert: jest.fn().mockResolvedValue({ id: 'delivery-1', status: 'pending' }) },
};
const service = new OpenApiService(prisma as never, {} as never);
const input = { tenantId: 'tenant-1', applicationId: 'app-1', eventType: 'receipt' as const, messageRecordId: 'record-1', messageId: 'MSG-1', payload: { receiptStatus: 'delivered' } };
const input = {
tenantId: 'tenant-1',
applicationId: 'app-1',
eventType: 'receipt' as const,
messageRecordId: 'record-1',
messageId: 'MSG-1',
payload: { receiptStatus: 'delivered' },
};
await service.queueWebhookEvent(input);
await service.queueWebhookEvent(input);
expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledWith(expect.objectContaining({
where: { eventId: 'evt_receipt_record-1' },
}));
expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { eventId: 'evt_receipt_record-1' },
}),
);
expect(prisma.httpWebhookEvent.upsert).toHaveBeenCalledTimes(2);
expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith(expect.objectContaining({
create: { eventId: 'event-row-1', endpointId: 'endpoint-1' },
}));
expect(prisma.httpWebhookDelivery.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: { eventId: 'event-row-1', endpointId: 'endpoint-1' },
}),
);
});
it('defaults a newly enabled HTTP interface to all six capabilities and automatic dual delivery', async () => {
const prisma = {
smsApplication: { findFirst: jest.fn().mockResolvedValue({ id: 'app-1', name: '应用A', interfaceEnabled: true, httpConfig: null, httpIpAllowlist: [] }) },
smsApplication: {
findFirst: jest
.fn()
.mockResolvedValue({
id: 'app-1',
name: '应用A',
interfaceEnabled: true,
httpConfig: null,
httpIpAllowlist: [],
}),
},
smsApplicationHttpConfig: { upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve(create)) },
smsApplicationHttpIpAllowlist: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }), createMany: jest.fn() },
$transaction: jest.fn((operations) => Promise.all(operations)),
@@ -128,19 +235,21 @@ describe('OpenApiService', () => {
await service.updateConfig('app-1', { enabled: true });
expect(prisma.smsApplicationHttpConfig.upsert).toHaveBeenCalledWith(expect.objectContaining({
create: expect.objectContaining({
enabled: true,
sendEnabled: true,
messageQueryEnabled: true,
receiptWebhookEnabled: true,
uplinkWebhookEnabled: true,
uplinkQueryEnabled: true,
credentialSelfServiceEnabled: true,
receiptDeliveryMode: 'both',
uplinkDeliveryMode: 'both',
expect(prisma.smsApplicationHttpConfig.upsert).toHaveBeenCalledWith(
expect.objectContaining({
create: expect.objectContaining({
enabled: true,
sendEnabled: true,
messageQueryEnabled: true,
receiptWebhookEnabled: true,
uplinkWebhookEnabled: true,
uplinkQueryEnabled: true,
credentialSelfServiceEnabled: true,
receiptDeliveryMode: 'both',
uplinkDeliveryMode: 'both',
}),
}),
}));
);
});
it('removes a webhook endpoint when an operator saves a blank address', async () => {
@@ -160,12 +269,35 @@ describe('OpenApiService', () => {
};
const service = new OpenApiService(prisma as never, {} as never);
await expect(service.upsertWebhookEndpoint('app-1', 'receipt', { url: ' ' }))
.resolves.toEqual(expect.objectContaining({ eventType: 'receipt', url: '', status: 'inactive', deleted: true }));
await expect(service.upsertWebhookEndpoint('app-1', 'receipt', { url: ' ' })).resolves.toEqual(
expect.objectContaining({ eventType: 'receipt', url: '', status: 'inactive', deleted: true }),
);
expect(prisma.httpWebhookEndpoint.deleteMany).toHaveBeenCalledWith({
where: { applicationId: 'app-1', eventType: 'receipt' },
});
});
it('rejects an already expired credential before writing a secret', async () => {
const prisma = {
smsApplication: {
findFirst: jest
.fn()
.mockResolvedValue({
id: 'app-1',
name: '应用A',
interfaceEnabled: true,
httpConfig: { enabled: true, credentialSelfServiceEnabled: true, maxCredentialCount: 3 },
httpIpAllowlist: [],
}),
},
httpApiCredential: { count: jest.fn().mockResolvedValue(0), create: jest.fn() },
};
const service = new OpenApiService(prisma as never, {} as never);
await expect(
service.createCredential('app-1', { expiresAt: '2020-01-01T00:00:00.000Z' }, 'tenant-1', true),
).rejects.toThrow('凭据过期时间必须晚于当前时间');
expect(prisma.httpApiCredential.create).not.toHaveBeenCalled();
});
});
function auth() {
+538 -130
View File
@@ -1,4 +1,17 @@
import { BadRequestException, ConflictException, ForbiddenException, forwardRef, HttpException, Inject, Injectable, NotFoundException, OnModuleDestroy, OnModuleInit, Optional, UnprocessableEntityException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
ForbiddenException,
forwardRef,
HttpException,
Inject,
Injectable,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
Optional,
UnprocessableEntityException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto';
@@ -59,7 +72,10 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
// Delivery remains owned by the main API process so callback DB/HTTP capacity
// cannot be consumed by slow customer webhook endpoints.
if (process.env.CMPP_PROCESS_ROLE === 'callback') return;
this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), { connection, concurrency: 10 });
this.worker = new Worker(WEBHOOK_QUEUE, (job) => this.deliverWebhook(job.data.deliveryId), {
connection,
concurrency: 10,
});
}
async onModuleDestroy() {
@@ -89,7 +105,13 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
update: data,
}),
this.prisma.smsApplicationHttpIpAllowlist.deleteMany({ where: { applicationId } }),
...(ipAllowlist.length > 0 ? [this.prisma.smsApplicationHttpIpAllowlist.createMany({ data: ipAllowlist.map((ipCidr) => ({ applicationId, ipCidr })) })] : []),
...(ipAllowlist.length > 0
? [
this.prisma.smsApplicationHttpIpAllowlist.createMany({
data: ipAllowlist.map((ipCidr) => ({ applicationId, ipCidr })),
}),
]
: []),
]);
return { applicationId, publicOrigin: httpApiPublicOrigin(), config, ipAllowlist };
}
@@ -98,37 +120,69 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
await this.requireApplication(applicationId, tenantId);
return this.prisma.httpApiCredential.findMany({
where: { applicationId },
select: { id: true, name: true, accessKey: true, secretLast4: true, status: true, expiresAt: true, lastUsedAt: true, lastUsedIp: true, createdAt: true, revokedAt: true },
select: {
id: true,
name: true,
accessKey: true,
secretLast4: true,
status: true,
expiresAt: true,
lastUsedAt: true,
lastUsedIp: true,
createdAt: true,
revokedAt: true,
},
orderBy: { createdAt: 'desc' },
});
}
async createCredential(applicationId: string, data: { name?: string; expiresAt?: string; createdById?: string }, tenantId?: string, selfService = false) {
async createCredential(
applicationId: string,
data: { name?: string; expiresAt?: string; createdById?: string },
tenantId?: string,
selfService = false,
) {
const application = await this.requireApplication(applicationId, tenantId);
const config = application.httpConfig;
if (!config?.enabled) throw new BadRequestException('请先开通该应用的HTTP接口');
if (selfService && !config.credentialSelfServiceEnabled) throw new ForbiddenException('该应用未开通客户端凭据自助管理');
if (selfService && !config.credentialSelfServiceEnabled)
throw new ForbiddenException('该应用未开通客户端凭据自助管理');
const activeCount = await this.prisma.httpApiCredential.count({ where: { applicationId, status: 'active' } });
if (activeCount >= config.maxCredentialCount) throw new BadRequestException(`有效凭据最多允许 ${config.maxCredentialCount}`);
if (activeCount >= config.maxCredentialCount)
throw new BadRequestException(`有效凭据最多允许 ${config.maxCredentialCount}`);
const expiresAt = data.expiresAt ? new Date(data.expiresAt) : undefined;
if (expiresAt && expiresAt.getTime() <= Date.now()) throw new BadRequestException('凭据过期时间必须晚于当前时间');
const secret = randomBytes(32).toString('base64url');
const credential = await this.prisma.httpApiCredential.create({
data: {
applicationId,
name: String(data.name ?? '默认凭据').trim().slice(0, 100) || '默认凭据',
name:
String(data.name ?? '默认凭据')
.trim()
.slice(0, 100) || '默认凭据',
accessKey: `ak_${randomBytes(18).toString('base64url')}`,
secretEncrypted: encryptSecret(secret),
secretLast4: secret.slice(-4),
expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined,
expiresAt,
createdById: data.createdById,
},
select: { id: true, name: true, accessKey: true, secretLast4: true, status: true, expiresAt: true, createdAt: true },
select: {
id: true,
name: true,
accessKey: true,
secretLast4: true,
status: true,
expiresAt: true,
createdAt: true,
},
});
return { ...credential, secret, secretShownOnce: true };
}
async revokeCredential(applicationId: string, credentialId: string, tenantId?: string) {
const application = await this.requireApplication(applicationId, tenantId);
if (tenantId && !application.httpConfig?.credentialSelfServiceEnabled) throw new ForbiddenException('该应用未开通客户端凭据自助管理');
if (tenantId && !application.httpConfig?.credentialSelfServiceEnabled)
throw new ForbiddenException('该应用未开通客户端凭据自助管理');
const result = await this.prisma.httpApiCredential.updateMany({
where: { id: credentialId, applicationId, status: 'active' },
data: { status: 'revoked', revokedAt: new Date() },
@@ -141,14 +195,29 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
await this.requireApplication(applicationId, tenantId);
return this.prisma.httpWebhookEndpoint.findMany({
where: { applicationId },
select: { id: true, eventType: true, url: true, secretLast4: true, status: true, lastTestAt: true, lastTestStatus: true, updatedAt: true },
select: {
id: true,
eventType: true,
url: true,
secretLast4: true,
status: true,
lastTestAt: true,
lastTestStatus: true,
updatedAt: true,
},
orderBy: { eventType: 'asc' },
});
}
async upsertWebhookEndpoint(applicationId: string, eventType: string, data: { url: string; rotateSecret?: boolean; status?: string }, tenantId?: string) {
async upsertWebhookEndpoint(
applicationId: string,
eventType: string,
data: { url: string; rotateSecret?: boolean; status?: string },
tenantId?: string,
) {
const application = await this.requireApplication(applicationId, tenantId);
if (!['receipt', 'uplink'].includes(eventType)) throw new BadRequestException('eventType only supports receipt or uplink');
if (!['receipt', 'uplink'].includes(eventType))
throw new BadRequestException('eventType only supports receipt or uplink');
if (!String(data.url ?? '').trim()) {
await this.prisma.httpWebhookEndpoint.deleteMany({ where: { applicationId, eventType } });
return {
@@ -162,49 +231,103 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
};
}
const url = await validateWebhookUrl(data.url, application.httpConfig?.requireHttps ?? true);
const existing = await this.prisma.httpWebhookEndpoint.findUnique({ where: { applicationId_eventType: { applicationId, eventType } } });
const existing = await this.prisma.httpWebhookEndpoint.findUnique({
where: { applicationId_eventType: { applicationId, eventType } },
});
const secret = !existing || data.rotateSecret ? randomBytes(32).toString('base64url') : undefined;
const endpoint = await this.prisma.httpWebhookEndpoint.upsert({
where: { applicationId_eventType: { applicationId, eventType } },
create: { applicationId, eventType, url, status: data.status ?? 'active', secretEncrypted: encryptSecret(secret!), secretLast4: secret!.slice(-4) },
update: { url, status: data.status ?? existing?.status ?? 'active', ...(secret ? { secretEncrypted: encryptSecret(secret), secretLast4: secret.slice(-4) } : {}) },
create: {
applicationId,
eventType,
url,
status: data.status ?? 'active',
secretEncrypted: encryptSecret(secret!),
secretLast4: secret!.slice(-4),
},
update: {
url,
status: data.status ?? existing?.status ?? 'active',
...(secret ? { secretEncrypted: encryptSecret(secret), secretLast4: secret.slice(-4) } : {}),
},
select: { id: true, eventType: true, url: true, secretLast4: true, status: true, updatedAt: true },
});
return { ...endpoint, ...(secret ? { secret, secretShownOnce: true } : {}) };
}
async sendMessage(auth: OpenApiAuthContext, input: { mobile?: string; content?: string; clientMessageId?: string }, meta: { idempotencyKey?: string; bodyHash: string; userAgent?: string }) {
if (!auth.config.sendEnabled) throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' });
async sendMessage(
auth: OpenApiAuthContext,
input: { mobile?: string; content?: string; clientMessageId?: string },
meta: { idempotencyKey?: string; bodyHash: string; userAgent?: string },
) {
if (!auth.config.sendEnabled)
throw new ForbiddenException({ code: 'SEND_NOT_ENABLED', message: '该应用未开通HTTP短信发送' });
const mobile = String(input.mobile ?? '').trim();
const content = String(input.content ?? '');
if (!/^1[3-9]\d{9}$/.test(mobile)) throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' });
if (!/^1[3-9]\d{9}$/.test(mobile))
throw new BadRequestException({ code: 'MOBILE_INVALID', message: '手机号格式非法' });
if (!content.trim()) throw new BadRequestException({ code: 'CONTENT_REQUIRED', message: '短信内容不能为空' });
const idempotencyKey = String(meta.idempotencyKey ?? '').trim();
if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey)) throw new BadRequestException({ code: 'IDEMPOTENCY_KEY_INVALID', message: 'Idempotency-Key 必填且长度为8至128位' });
const existing = await this.prisma.openApiRequest.findUnique({ where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } } });
if (!/^[A-Za-z0-9._:-]{8,128}$/.test(idempotencyKey))
throw new BadRequestException({
code: 'IDEMPOTENCY_KEY_INVALID',
message: 'Idempotency-Key 必填且长度为8至128位',
});
const existing = await this.prisma.openApiRequest.findUnique({
where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } },
});
if (existing) {
if (existing.bodyHash !== meta.bodyHash) throw new ConflictException({ code: 'IDEMPOTENCY_CONFLICT', message: '同一Idempotency-Key对应的请求内容不一致' });
if (existing.bodyHash !== meta.bodyHash)
throw new ConflictException({
code: 'IDEMPOTENCY_CONFLICT',
message: '同一Idempotency-Key对应的请求内容不一致',
});
if (existing.status === 'completed' && existing.responseBody) return existing.responseBody;
if (existing.status === 'failed' && existing.responseBody && existing.httpStatus) throw new HttpException(existing.responseBody as Record<string, unknown>, existing.httpStatus);
if (existing.status === 'failed' && existing.responseBody && existing.httpStatus)
throw new HttpException(existing.responseBody as Record<string, unknown>, existing.httpStatus);
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
}
if (input.clientMessageId) {
const duplicateClientMessage = await this.prisma.smsMessageRecord.findFirst({ where: { applicationId: auth.application.id, clientMessageId: input.clientMessageId }, select: { messageId: true } });
if (duplicateClientMessage) throw new ConflictException({ code: 'CLIENT_MESSAGE_ID_CONFLICT', message: `clientMessageId已关联短信 ${duplicateClientMessage.messageId}` });
const duplicateClientMessage = await this.prisma.smsMessageRecord.findFirst({
where: { applicationId: auth.application.id, clientMessageId: input.clientMessageId },
select: { messageId: true },
});
if (duplicateClientMessage)
throw new ConflictException({
code: 'CLIENT_MESSAGE_ID_CONFLICT',
message: `clientMessageId已关联短信 ${duplicateClientMessage.messageId}`,
});
}
const requestId = `req_${randomUUID()}`;
const startedAt = Date.now();
let request;
try {
request = await this.prisma.openApiRequest.create({
data: { tenantId: auth.application.tenantId, applicationId: auth.application.id, credentialId: auth.credentialId, requestId, idempotencyKey, bodyHash: meta.bodyHash, clientMessageId: input.clientMessageId, sourceIp: auth.sourceIp, userAgent: meta.userAgent },
data: {
tenantId: auth.application.tenantId,
applicationId: auth.application.id,
credentialId: auth.credentialId,
requestId,
idempotencyKey,
bodyHash: meta.bodyHash,
clientMessageId: input.clientMessageId,
sourceIp: auth.sourceIp,
userAgent: meta.userAgent,
},
});
} catch (error) {
if ((error as { code?: string }).code === 'P2002') {
const raced = await this.prisma.openApiRequest.findUnique({ where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } } });
if (raced?.bodyHash !== meta.bodyHash) throw new ConflictException({ code: 'IDEMPOTENCY_CONFLICT', message: '同一Idempotency-Key对应的请求内容不一致' });
const raced = await this.prisma.openApiRequest.findUnique({
where: { applicationId_idempotencyKey: { applicationId: auth.application.id, idempotencyKey } },
});
if (raced?.bodyHash !== meta.bodyHash)
throw new ConflictException({
code: 'IDEMPOTENCY_CONFLICT',
message: '同一Idempotency-Key对应的请求内容不一致',
});
if (raced?.status === 'completed' && raced.responseBody) return raced.responseBody;
if (raced?.status === 'failed' && raced.responseBody && raced.httpStatus) throw new HttpException(raced.responseBody as Record<string, unknown>, raced.httpStatus);
if (raced?.status === 'failed' && raced.responseBody && raced.httpStatus)
throw new HttpException(raced.responseBody as Record<string, unknown>, raced.httpStatus);
throw new ConflictException({ code: 'REQUEST_PROCESSING', message: '同一请求正在处理中,请稍后查询' });
}
throw error;
@@ -221,10 +344,31 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
});
const message = task.messages?.[0];
if (task.status === 'rejected' || message?.status === 'rejected') {
throw new UnprocessableEntityException({ code: 'SEND_REJECTED', message: message?.errorMessage ?? task.rejectReason ?? '短信未通过业务校验' });
throw new UnprocessableEntityException({
code: 'SEND_REJECTED',
message: message?.errorMessage ?? task.rejectReason ?? '短信未通过业务校验',
});
}
const response = { code: 'ACCEPTED', requestId, messageId: message?.messageId, clientMessageId: input.clientMessageId ?? null, status: message?.status ?? task.status, acceptedAt: new Date().toISOString() };
await this.prisma.openApiRequest.update({ where: { id: request.id }, data: { status: 'completed', httpStatus: 202, businessCode: 'ACCEPTED', responseBody: response, messageRecordId: message?.id, durationMs: Date.now() - startedAt, completedAt: new Date() } });
const response = {
code: 'ACCEPTED',
requestId,
messageId: message?.messageId,
clientMessageId: input.clientMessageId ?? null,
status: message?.status ?? task.status,
acceptedAt: new Date().toISOString(),
};
await this.prisma.openApiRequest.update({
where: { id: request.id },
data: {
status: 'completed',
httpStatus: 202,
businessCode: 'ACCEPTED',
responseBody: response,
messageRecordId: message?.id,
durationMs: Date.now() - startedAt,
completedAt: new Date(),
},
});
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
@@ -245,11 +389,24 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
let outwardError = error;
if (error instanceof HttpException && error.getStatus() === 400) {
const response = error.getResponse();
const message = typeof response === 'object' && response && 'message' in response ? (response as { message: unknown }).message : error.message;
const message =
typeof response === 'object' && response && 'message' in response
? (response as { message: unknown }).message
: error.message;
outwardError = new UnprocessableEntityException({ code: 'SEND_REJECTED', message });
}
const failure = normalizeOpenApiFailure(outwardError);
await this.prisma.openApiRequest.update({ where: { id: request.id }, data: { status: 'failed', httpStatus: failure.httpStatus, businessCode: failure.code, responseBody: failure.responseBody, durationMs: Date.now() - startedAt, completedAt: new Date() } });
await this.prisma.openApiRequest.update({
where: { id: request.id },
data: {
status: 'failed',
httpStatus: failure.httpStatus,
businessCode: failure.code,
responseBody: failure.responseBody,
durationMs: Date.now() - startedAt,
completedAt: new Date(),
},
});
this.protocolLogs?.record({
protocol: 'http',
direction: 'client_to_platform',
@@ -268,21 +425,41 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
}
async getMessage(auth: OpenApiAuthContext, messageId: string) {
if (!auth.config.messageQueryEnabled) throw new ForbiddenException({ code: 'MESSAGE_QUERY_NOT_ENABLED', message: '该应用未开通短信状态查询' });
if (!auth.config.messageQueryEnabled)
throw new ForbiddenException({ code: 'MESSAGE_QUERY_NOT_ENABLED', message: '该应用未开通短信状态查询' });
const message = await this.prisma.smsMessageRecord.findFirst({
where: { applicationId: auth.application.id, OR: [{ messageId }, { clientMessageId: messageId }] },
select: { messageId: true, clientMessageId: true, phoneNumber: true, status: true, submitStatus: true, receiptStatus: true, errorCode: true, errorMessage: true, queuedAt: true, submittedAt: true, deliveredAt: true, updatedAt: true },
select: {
messageId: true,
clientMessageId: true,
phoneNumber: true,
status: true,
submitStatus: true,
receiptStatus: true,
errorCode: true,
errorMessage: true,
queuedAt: true,
submittedAt: true,
deliveredAt: true,
updatedAt: true,
},
});
if (!message) throw new NotFoundException({ code: 'MESSAGE_NOT_FOUND', message: '短信记录不存在' });
return message;
}
async listUplinks(auth: OpenApiAuthContext, query: Record<string, string | undefined>) {
if (!auth.config.uplinkQueryEnabled) throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
if (!auth.config.uplinkQueryEnabled)
throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
const endTime = query.endTime ? new Date(query.endTime) : new Date();
const startTime = query.startTime ? new Date(query.startTime) : new Date(endTime.getTime() - 24 * 3600_000);
if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime) throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '查询时间范围非法' });
if (endTime.getTime() - startTime.getTime() > auth.config.maxQueryRangeDays * 86400_000) throw new BadRequestException({ code: 'TIME_RANGE_TOO_LARGE', message: `单次查询不能超过${auth.config.maxQueryRangeDays}` });
if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime()) || startTime > endTime)
throw new BadRequestException({ code: 'TIME_RANGE_INVALID', message: '查询时间范围非法' });
if (endTime.getTime() - startTime.getTime() > auth.config.maxQueryRangeDays * 86400_000)
throw new BadRequestException({
code: 'TIME_RANGE_TOO_LARGE',
message: `单次查询不能超过${auth.config.maxQueryRangeDays}`,
});
const limit = Math.min(Math.max(Number(query.limit) || 50, 1), auth.config.maxPageSize);
const cursor = decodeCursor(query.cursor);
const rows = await this.prisma.smsUplinkMessage.findMany({
@@ -293,9 +470,22 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
phoneNumber: query.mobile,
destId: query.accessNumber,
content: query.keyword ? { contains: query.keyword } : undefined,
...(cursor ? { OR: [{ receivedAt: { lt: cursor.receivedAt } }, { receivedAt: cursor.receivedAt, id: { lt: cursor.id } }] } : {}),
...(cursor
? {
OR: [{ receivedAt: { lt: cursor.receivedAt } }, { receivedAt: cursor.receivedAt, id: { lt: cursor.id } }],
}
: {}),
},
select: {
id: true,
messageId: true,
phoneNumber: true,
destId: true,
content: true,
matchStatus: true,
matchReason: true,
receivedAt: true,
},
select: { id: true, messageId: true, phoneNumber: true, destId: true, content: true, matchStatus: true, matchReason: true, receivedAt: true },
orderBy: [{ receivedAt: 'desc' }, { id: 'desc' }],
take: limit + 1,
});
@@ -306,29 +496,55 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
}
async getUplink(auth: OpenApiAuthContext, uplinkId: string) {
if (!auth.config.uplinkQueryEnabled) throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
const row = await this.prisma.smsUplinkMessage.findFirst({ where: { id: uplinkId, applicationId: auth.application.id, matchStatus: 'matched' } });
if (!auth.config.uplinkQueryEnabled)
throw new ForbiddenException({ code: 'UPLINK_QUERY_NOT_ENABLED', message: '该应用未开通上行查询' });
const row = await this.prisma.smsUplinkMessage.findFirst({
where: { id: uplinkId, applicationId: auth.application.id, matchStatus: 'matched' },
});
if (!row) throw new NotFoundException({ code: 'UPLINK_NOT_FOUND', message: '上行记录不存在' });
return row;
}
async queueWebhookEvent(data: { tenantId: string; applicationId?: string | null; messageRecordId?: string | null; messageId?: string | null; uplinkMessageId?: string | null; eventType: 'receipt' | 'uplink'; payload: Record<string, unknown> }) {
async queueWebhookEvent(data: {
tenantId: string;
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
uplinkMessageId?: string | null;
eventType: 'receipt' | 'uplink';
payload: Record<string, unknown>;
}) {
if (!data.applicationId) return null;
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, include: { httpConfig: true } });
const application = await this.prisma.smsApplication.findUnique({
where: { id: data.applicationId },
include: { httpConfig: true },
});
const config = application?.httpConfig;
const enabled = data.eventType === 'receipt' ? config?.receiptWebhookEnabled : config?.uplinkWebhookEnabled;
if (!config?.enabled || !enabled) return null;
const endpoint = await this.prisma.httpWebhookEndpoint.findUnique({ where: { applicationId_eventType: { applicationId: data.applicationId, eventType: data.eventType } } });
const endpoint = await this.prisma.httpWebhookEndpoint.findUnique({
where: { applicationId_eventType: { applicationId: data.applicationId, eventType: data.eventType } },
});
if (!endpoint || endpoint.status !== 'active') return null;
const eventId = data.eventType === 'receipt' && data.messageRecordId
? `evt_receipt_${data.messageRecordId}`
: data.eventType === 'uplink' && data.uplinkMessageId
? `evt_uplink_${data.uplinkMessageId}`
: `evt_${randomUUID()}`;
const eventId =
data.eventType === 'receipt' && data.messageRecordId
? `evt_receipt_${data.messageRecordId}`
: data.eventType === 'uplink' && data.uplinkMessageId
? `evt_uplink_${data.uplinkMessageId}`
: `evt_${randomUUID()}`;
const event = await this.prisma.httpWebhookEvent.upsert({
where: { eventId },
update: {},
create: { eventId, tenantId: data.tenantId, applicationId: data.applicationId, eventType: data.eventType, messageRecordId: data.messageRecordId, messageId: data.messageId, uplinkMessageId: data.uplinkMessageId, payload: data.payload as Prisma.InputJsonValue },
create: {
eventId,
tenantId: data.tenantId,
applicationId: data.applicationId,
eventType: data.eventType,
messageRecordId: data.messageRecordId,
messageId: data.messageId,
uplinkMessageId: data.uplinkMessageId,
payload: data.payload as Prisma.InputJsonValue,
},
});
const delivery = await this.prisma.httpWebhookDelivery.upsert({
where: { eventId_endpointId: { eventId: event.id, endpointId: endpoint.id } },
@@ -336,57 +552,135 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
create: { eventId: event.id, endpointId: endpoint.id },
});
if (delivery.status === 'delivered') return delivery;
await this.queue?.add('deliver', { deliveryId: delivery.id }, { jobId: delivery.id, removeOnComplete: 1000, removeOnFail: 1000 });
await this.queue?.add(
'deliver',
{ deliveryId: delivery.id },
{ jobId: delivery.id, removeOnComplete: 1000, removeOnFail: 1000 },
);
return delivery;
}
async listRequestLogs(applicationId: string, tenantId?: string) {
await this.requireApplication(applicationId, tenantId);
return this.prisma.openApiRequest.findMany({ where: { applicationId }, select: { id: true, requestId: true, clientMessageId: true, sourceIp: true, httpStatus: true, businessCode: true, status: true, durationMs: true, createdAt: true, completedAt: true }, orderBy: { createdAt: 'desc' }, take: 100 });
return this.prisma.openApiRequest.findMany({
where: { applicationId },
select: {
id: true,
requestId: true,
clientMessageId: true,
sourceIp: true,
httpStatus: true,
businessCode: true,
status: true,
durationMs: true,
createdAt: true,
completedAt: true,
},
orderBy: { createdAt: 'desc' },
take: 100,
});
}
async listWebhookDeliveries(applicationId: string, tenantId?: string) {
await this.requireApplication(applicationId, tenantId);
return this.prisma.httpWebhookDelivery.findMany({ where: { event: { applicationId } }, include: { event: true, endpoint: { select: { eventType: true, url: true } }, attempts: { orderBy: { attemptNo: 'desc' }, take: 5 } }, orderBy: { createdAt: 'desc' }, take: 100 });
return this.prisma.httpWebhookDelivery.findMany({
where: { event: { applicationId } },
include: {
event: true,
endpoint: { select: { eventType: true, url: true } },
attempts: { orderBy: { attemptNo: 'desc' }, take: 5 },
},
orderBy: { createdAt: 'desc' },
take: 100,
});
}
async retryWebhookDelivery(applicationId: string, deliveryId: string, tenantId?: string) {
const application = await this.requireApplication(applicationId, tenantId);
if (tenantId && !application.httpConfig?.allowClientManualRetry) throw new ForbiddenException('该应用未开通客户端手动重投');
const delivery = await this.prisma.httpWebhookDelivery.findFirst({ where: { id: deliveryId, event: { applicationId } } });
if (tenantId && !application.httpConfig?.allowClientManualRetry)
throw new ForbiddenException('该应用未开通客户端手动重投');
const delivery = await this.prisma.httpWebhookDelivery.findFirst({
where: { id: deliveryId, event: { applicationId } },
});
if (!delivery) throw new NotFoundException('Webhook投递记录不存在');
await this.prisma.httpWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'pending', nextRetryAt: null, lastError: null } });
await this.queue?.add('deliver', { deliveryId }, { jobId: `${deliveryId}:manual:${Date.now()}`, removeOnComplete: 1000, removeOnFail: 1000 });
await this.prisma.httpWebhookDelivery.update({
where: { id: delivery.id },
data: { status: 'pending', nextRetryAt: null, lastError: null },
});
await this.queue?.add(
'deliver',
{ deliveryId },
{ jobId: `${deliveryId}:manual:${Date.now()}`, removeOnComplete: 1000, removeOnFail: 1000 },
);
return { id: deliveryId, status: 'pending' };
}
private async deliverWebhook(deliveryId: string) {
const delivery = await this.prisma.httpWebhookDelivery.findUnique({ where: { id: deliveryId }, include: { event: true, endpoint: true } });
const delivery = await this.prisma.httpWebhookDelivery.findUnique({
where: { id: deliveryId },
include: { event: true, endpoint: true },
});
if (!delivery || delivery.status === 'delivered') return;
const config = await this.prisma.smsApplicationHttpConfig.findUnique({ where: { applicationId: delivery.event.applicationId } });
const config = await this.prisma.smsApplicationHttpConfig.findUnique({
where: { applicationId: delivery.event.applicationId },
});
if (!config) return;
const attemptNo = delivery.attemptCount + 1;
const timestamp = String(Math.floor(Date.now() / 1000));
const body = JSON.stringify({ eventId: delivery.event.eventId, eventType: delivery.event.eventType, occurredAt: delivery.event.createdAt.toISOString(), data: delivery.event.payload });
const signature = createHmac('sha256', decryptSecret(delivery.endpoint.secretEncrypted)).update(`${timestamp}\n${body}`).digest('hex');
const body = JSON.stringify({
eventId: delivery.event.eventId,
eventType: delivery.event.eventType,
occurredAt: delivery.event.createdAt.toISOString(),
data: delivery.event.payload,
});
const signature = createHmac('sha256', decryptSecret(delivery.endpoint.secretEncrypted))
.update(`${timestamp}\n${body}`)
.digest('hex');
const startedAt = Date.now();
let responseStatus: number | undefined;
let responseSummary: string | undefined;
let errorMessage: string | undefined;
try {
const response = await postWebhook(delivery.endpoint.url, body, {
'content-type': 'application/json',
'x-event-id': delivery.event.eventId,
'x-event-type': delivery.event.eventType,
'x-timestamp': timestamp,
'x-signature': `sha256=${signature}`,
}, config.webhookTimeoutSeconds * 1000, config.requireHttps);
const response = await postWebhook(
delivery.endpoint.url,
body,
{
'content-type': 'application/json',
'x-event-id': delivery.event.eventId,
'x-event-type': delivery.event.eventType,
'x-timestamp': timestamp,
'x-signature': `sha256=${signature}`,
},
config.webhookTimeoutSeconds * 1000,
config.requireHttps,
);
responseStatus = response.status;
responseSummary = response.body;
} catch (error) { errorMessage = error instanceof Error ? error.message : 'Webhook request failed'; }
} catch (error) {
errorMessage = error instanceof Error ? error.message : 'Webhook request failed';
}
const success = responseStatus !== undefined && responseStatus >= 200 && responseStatus < 300;
const retryable = errorMessage !== undefined || responseStatus === 408 || responseStatus === 429 || (responseStatus !== undefined && responseStatus >= 500);
await this.prisma.httpWebhookAttempt.create({ data: { deliveryId, attemptNo, responseStatus, responseSummary, errorMessage, durationMs: Date.now() - startedAt, requestHeaders: { 'x-event-id': delivery.event.eventId, 'x-event-type': delivery.event.eventType, 'x-timestamp': timestamp, 'x-signature': 'sha256=***' } } });
const retryable =
errorMessage !== undefined ||
responseStatus === 408 ||
responseStatus === 429 ||
(responseStatus !== undefined && responseStatus >= 500);
await this.prisma.httpWebhookAttempt.create({
data: {
deliveryId,
attemptNo,
responseStatus,
responseSummary,
errorMessage,
durationMs: Date.now() - startedAt,
requestHeaders: {
'x-event-id': delivery.event.eventId,
'x-event-type': delivery.event.eventType,
'x-timestamp': timestamp,
'x-signature': 'sha256=***',
},
},
});
this.protocolLogs?.record({
protocol: 'http',
direction: 'platform_to_client',
@@ -403,22 +697,62 @@ export class OpenApiService implements OnModuleInit, OnModuleDestroy {
detail: { deliveryId, attemptNo, error: errorMessage },
});
if (success) {
await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'delivered', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: null, deliveredAt: new Date(), nextRetryAt: null } });
await this.prisma.httpWebhookDelivery.update({
where: { id: deliveryId },
data: {
status: 'delivered',
attemptCount: attemptNo,
lastHttpStatus: responseStatus,
lastError: null,
deliveredAt: new Date(),
nextRetryAt: null,
},
});
return;
}
const maxAttempts = Math.min(config.webhookMaxAttempts, RETRY_DELAYS_SECONDS.length);
if (config.webhookRetryEnabled && retryable && attemptNo < maxAttempts) {
const delaySeconds = RETRY_DELAYS_SECONDS[attemptNo] ?? RETRY_DELAYS_SECONDS.at(-1)!;
const nextRetryAt = new Date(Date.now() + delaySeconds * 1000);
await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'retrying', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: errorMessage ?? `HTTP ${responseStatus}`, nextRetryAt } });
await this.queue?.add('deliver', { deliveryId }, { jobId: `${deliveryId}:${attemptNo + 1}`, delay: delaySeconds * 1000, removeOnComplete: 1000, removeOnFail: 1000 });
await this.prisma.httpWebhookDelivery.update({
where: { id: deliveryId },
data: {
status: 'retrying',
attemptCount: attemptNo,
lastHttpStatus: responseStatus,
lastError: errorMessage ?? `HTTP ${responseStatus}`,
nextRetryAt,
},
});
await this.queue?.add(
'deliver',
{ deliveryId },
{
jobId: `${deliveryId}:${attemptNo + 1}`,
delay: delaySeconds * 1000,
removeOnComplete: 1000,
removeOnFail: 1000,
},
);
return;
}
await this.prisma.httpWebhookDelivery.update({ where: { id: deliveryId }, data: { status: 'failed', attemptCount: attemptNo, lastHttpStatus: responseStatus, lastError: errorMessage ?? `HTTP ${responseStatus}`, nextRetryAt: null } });
await this.prisma.httpWebhookDelivery.update({
where: { id: deliveryId },
data: {
status: 'failed',
attemptCount: attemptNo,
lastHttpStatus: responseStatus,
lastError: errorMessage ?? `HTTP ${responseStatus}`,
nextRetryAt: null,
},
});
}
private async requireApplication(applicationId: string, tenantId?: string) {
const application = await this.prisma.smsApplication.findFirst({ where: { id: applicationId, tenantId }, include: { httpConfig: true, httpIpAllowlist: true } });
const application = await this.prisma.smsApplication.findFirst({
where: { id: applicationId, tenantId },
include: { httpConfig: true, httpIpAllowlist: true },
});
if (!application) throw new NotFoundException('企业应用不存在');
return application;
}
@@ -428,12 +762,22 @@ function httpApiPublicOrigin() {
const configured = process.env.HTTP_API_PUBLIC_ORIGIN?.trim().replace(/\/+$/, '');
if (!configured) return undefined;
const url = new URL(configured);
const insecureHttpExplicitlyAllowed = process.env.HTTP_API_ALLOW_INSECURE_ORIGIN === 'true' && url.protocol === 'http:';
if ((url.protocol !== 'https:' && !insecureHttpExplicitlyAllowed) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
const insecureHttpExplicitlyAllowed =
process.env.HTTP_API_ALLOW_INSECURE_ORIGIN === 'true' && url.protocol === 'http:';
if (
(url.protocol !== 'https:' && !insecureHttpExplicitlyAllowed) ||
url.username ||
url.password ||
url.pathname !== '/' ||
url.search ||
url.hash
) {
// This value is copied into customer integration parameters, so fail closed instead of
// publishing an insecure or path-dependent endpoint unless an isolated test environment
// has explicitly opted into plain HTTP.
throw new Error('HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址;隔离测试环境如需HTTP须显式启用HTTP_API_ALLOW_INSECURE_ORIGIN');
throw new Error(
'HTTP_API_PUBLIC_ORIGIN必须是无路径、无凭据的HTTPS源地址;隔离测试环境如需HTTP须显式启用HTTP_API_ALLOW_INSECURE_ORIGIN',
);
}
return url.origin;
}
@@ -441,15 +785,22 @@ function httpApiPublicOrigin() {
function normalizeOpenApiFailure(error: unknown) {
if (error instanceof HttpException) {
const value = error.getResponse();
const object = typeof value === 'object' && value ? value as Record<string, unknown> : {};
const object = typeof value === 'object' && value ? (value as Record<string, unknown>) : {};
const rawMessage = object.message ?? error.message;
return {
httpStatus: error.getStatus(),
code: String(object.code ?? 'SEND_REJECTED'),
responseBody: { code: String(object.code ?? 'SEND_REJECTED'), message: Array.isArray(rawMessage) ? rawMessage.join('') : String(rawMessage) } as Prisma.InputJsonValue,
responseBody: {
code: String(object.code ?? 'SEND_REJECTED'),
message: Array.isArray(rawMessage) ? rawMessage.join('') : String(rawMessage),
} as Prisma.InputJsonValue,
};
}
return { httpStatus: 500, code: 'INTERNAL_ERROR', responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue };
return {
httpStatus: 500,
code: 'INTERNAL_ERROR',
responseBody: { code: 'INTERNAL_ERROR', message: 'Internal server error' } as Prisma.InputJsonValue,
};
}
function normalizeConfig(
@@ -458,15 +809,17 @@ function normalizeConfig(
cmppEnabled: boolean,
) {
const enabling = input.enabled === true && existing?.enabled !== true;
const effective = enabling ? {
sendEnabled: true,
messageQueryEnabled: true,
receiptWebhookEnabled: true,
uplinkWebhookEnabled: true,
uplinkQueryEnabled: true,
credentialSelfServiceEnabled: true,
...input,
} : input;
const effective = enabling
? {
sendEnabled: true,
messageQueryEnabled: true,
receiptWebhookEnabled: true,
uplinkWebhookEnabled: true,
uplinkQueryEnabled: true,
credentialSelfServiceEnabled: true,
...input,
}
: input;
const httpEnabled = effective.enabled ?? existing?.enabled ?? false;
const deliveryMode = automaticDeliveryMode(cmppEnabled, httpEnabled);
return {
@@ -496,22 +849,31 @@ function normalizeConfig(
function bounded(value: number | undefined, min: number, max: number, label: string) {
if (value === undefined) return undefined;
if (!Number.isInteger(value) || value < min || value > max) throw new BadRequestException(`${label}必须在${min}${max}之间`);
if (!Number.isInteger(value) || value < min || value > max)
throw new BadRequestException(`${label}必须在${min}${max}之间`);
return value;
}
function normalizeIpAllowlist(values?: string[]) {
return [...new Set((values ?? []).map((item) => item.trim()).filter(Boolean).map((item) => {
const [ip, prefix] = item.split('/');
const version = isIP(ip);
if (!version) throw new BadRequestException(`IP白名单格式非法:${item}`);
if (prefix !== undefined) {
const bits = Number(prefix);
const max = version === 4 ? 32 : 128;
if (!Number.isInteger(bits) || bits < 0 || bits > max) throw new BadRequestException(`CIDR格式非法:${item}`);
}
return item;
}))];
return [
...new Set(
(values ?? [])
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
const [ip, prefix] = item.split('/');
const version = isIP(ip);
if (!version) throw new BadRequestException(`IP白名单格式非法:${item}`);
if (prefix !== undefined) {
const bits = Number(prefix);
const max = version === 4 ? 32 : 128;
if (!Number.isInteger(bits) || bits < 0 || bits > max)
throw new BadRequestException(`CIDR格式非法:${item}`);
}
return item;
}),
),
];
}
async function validateWebhookUrl(value: string, requireHttps: boolean) {
@@ -520,37 +882,54 @@ async function validateWebhookUrl(value: string, requireHttps: boolean) {
async function resolveWebhookTarget(value: string, requireHttps: boolean) {
let url: URL;
try { url = new URL(String(value ?? '').trim()); } catch { throw new BadRequestException('Webhook URL格式非法'); }
try {
url = new URL(String(value ?? '').trim());
} catch {
throw new BadRequestException('Webhook URL格式非法');
}
if (!['http:', 'https:'].includes(url.protocol)) throw new BadRequestException('Webhook仅支持HTTP/HTTPS');
if (requireHttps && url.protocol !== 'https:') throw new BadRequestException('当前应用要求Webhook使用HTTPS');
if (url.username || url.password) throw new BadRequestException('Webhook URL不能包含用户名或密码');
const addresses = isIP(url.hostname) ? [{ address: url.hostname }] : await lookup(url.hostname, { all: true });
if (addresses.some(({ address }) => isPrivateAddress(address))) throw new BadRequestException('Webhook URL不能指向内网、环回或链路本地地址');
if (addresses.some(({ address }) => isPrivateAddress(address)))
throw new BadRequestException('Webhook URL不能指向内网、环回或链路本地地址');
const selected = addresses[0];
if (!selected) throw new BadRequestException('Webhook域名未解析到可用地址');
return { url, address: selected.address, family: isIP(selected.address) };
}
async function postWebhook(urlText: string, body: string, headers: Record<string, string>, timeoutMs: number, requireHttps: boolean) {
async function postWebhook(
urlText: string,
body: string,
headers: Record<string, string>,
timeoutMs: number,
requireHttps: boolean,
) {
const target = await resolveWebhookTarget(urlText, requireHttps);
return new Promise<{ status: number; body: string }>((resolve, reject) => {
const requestFn = target.url.protocol === 'https:' ? httpsRequest : httpRequest;
const request = requestFn(target.url, {
method: 'POST',
headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) },
lookup: (_hostname, _options, callback) => callback(null, target.address, target.family),
}, (response) => {
const chunks: Buffer[] = [];
let size = 0;
response.on('data', (chunk: Buffer) => {
if (size < 1000) {
const buffer = Buffer.from(chunk);
chunks.push(buffer.subarray(0, 1000 - size));
size += buffer.length;
}
});
response.on('end', () => resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }));
});
const request = requestFn(
target.url,
{
method: 'POST',
headers: { ...headers, 'content-length': String(Buffer.byteLength(body)) },
lookup: (_hostname, _options, callback) => callback(null, target.address, target.family),
},
(response) => {
const chunks: Buffer[] = [];
let size = 0;
response.on('data', (chunk: Buffer) => {
if (size < 1000) {
const buffer = Buffer.from(chunk);
chunks.push(buffer.subarray(0, 1000 - size));
size += buffer.length;
}
});
response.on('end', () =>
resolve({ status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
);
},
);
request.setTimeout(timeoutMs, () => request.destroy(new Error('Webhook request timed out')));
request.on('error', reject);
request.end(body);
@@ -559,13 +938,33 @@ async function postWebhook(urlText: string, body: string, headers: Record<string
function isPrivateAddress(address: string) {
const normalized = address.replace(/^::ffff:/, '');
if (normalized === '::1' || normalized === '::' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb')) return true;
if (
normalized === '::1' ||
normalized === '::' ||
normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
normalized.startsWith('fe8') ||
normalized.startsWith('fe9') ||
normalized.startsWith('fea') ||
normalized.startsWith('feb')
)
return true;
if (isIP(normalized) !== 4) return false;
const [a, b] = normalized.split('.').map(Number);
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127);
return (
a === 10 ||
a === 127 ||
a === 0 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127)
);
}
function encodeCursor(receivedAt: Date, id: string) { return Buffer.from(JSON.stringify([receivedAt.toISOString(), id])).toString('base64url'); }
function encodeCursor(receivedAt: Date, id: string) {
return Buffer.from(JSON.stringify([receivedAt.toISOString(), id])).toString('base64url');
}
function decodeCursor(value?: string) {
if (!value) return null;
try {
@@ -573,10 +972,19 @@ function decodeCursor(value?: string) {
const receivedAt = new Date(date);
if (!id || !Number.isFinite(receivedAt.getTime())) throw new Error();
return { receivedAt, id };
} catch { throw new BadRequestException({ code: 'CURSOR_INVALID', message: 'cursor格式非法' }); }
} catch {
throw new BadRequestException({ code: 'CURSOR_INVALID', message: 'cursor格式非法' });
}
}
function bullmqConnection() {
const url = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
return { host: url.hostname, port: Number(url.port || 6379), username: url.username || undefined, password: url.password || undefined, db: Number(url.pathname.slice(1) || 0), maxRetriesPerRequest: null as null };
return {
host: url.hostname,
port: Number(url.port || 6379),
username: url.username || undefined,
password: url.password || undefined,
db: Number(url.pathname.slice(1) || 0),
maxRetriesPerRequest: null as null,
};
}
@@ -1,8 +1,10 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UsePipes } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { OperationsService } from './operations.service';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientSystemLogExportDto } from './client-operations.dto';
@ApiTags('client-operations')
@Controller('client/operations')
@@ -15,7 +17,11 @@ export class ClientOperationsController {
}
@Get('batch-tasks/:id/messages')
listTaskMessages(@CurrentTenantId() tenantId: string, @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 });
}
@@ -49,9 +55,31 @@ export class ClientOperationsController {
}
@Get('uplink-messages')
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) {
listUplinkMessages(
@CurrentTenantId() tenantId: string,
@Query('channelId') channelId?: string,
@Query('applicationId') applicationId?: string,
@Query('phoneNumber') phoneNumber?: string,
@Query('keyword') keyword?: string,
@Query('startTime') startTime?: string,
@Query('endTime') endTime?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return page || pageSize
? this.operations.listUplinkMessagesPage({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime, page: Number(page), pageSize: Number(pageSize) }, true)
? this.operations.listUplinkMessagesPage(
{
tenantId,
applicationId,
phoneNumber,
keyword,
startTime,
endTime,
page: Number(page),
pageSize: Number(pageSize),
},
true,
)
: this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime });
}
@@ -72,14 +100,25 @@ export class ClientOperationsController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.operations.systemLogs({ tenantId, keyword, level, module, range, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) });
return this.operations.systemLogs({
tenantId,
keyword,
level,
module,
range,
createdAtFrom,
createdAtTo,
page: Number(page),
pageSize: Number(pageSize),
});
}
@Post('system-logs/exports')
@UsePipes(strictValidationPipe)
exportSystemLogs(
@CurrentSessionUserId() userId: string | undefined,
@CurrentTenantId() tenantId: string,
@Body() body: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string },
@Body() body: ClientSystemLogExportDto,
) {
return this.operations.exportSystemLogs({ ...body, tenantId }, userId);
}
@@ -0,0 +1,28 @@
import { BadRequestException } from '@nestjs/common';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientSystemLogExportDto } from './client-operations.dto';
describe('ClientSystemLogExportDto', () => {
it('accepts the supported date range and rejects extra or malformed fields', async () => {
await expect(
strictValidationPipe.transform(
{ createdAtFrom: '2026-08-21', createdAtTo: '2026-08-28', level: 'error' },
{
type: 'body',
metatype: ClientSystemLogExportDto,
data: undefined,
},
),
).resolves.toEqual(expect.objectContaining({ level: 'error' }));
await expect(
strictValidationPipe.transform(
{ createdAtFrom: 'last-week', tenantId: 'spoofed' },
{
type: 'body',
metatype: ClientSystemLogExportDto,
data: undefined,
},
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -0,0 +1,10 @@
import { IsDateString, IsIn, IsOptional, IsString, MaxLength } from 'class-validator';
export class ClientSystemLogExportDto {
@IsOptional() @IsString() @MaxLength(200) keyword?: string;
@IsOptional() @IsIn(['all', 'debug', 'info', 'warning', 'error']) level?: string;
@IsOptional() @IsString() @MaxLength(100) module?: string;
@IsOptional() @IsIn(['7d', '30d']) range?: string;
@IsOptional() @IsDateString({ strict: true }) createdAtFrom?: string;
@IsOptional() @IsDateString({ strict: true }) createdAtTo?: string;
}
@@ -3,7 +3,19 @@ import { ApiTags } from '@nestjs/swagger';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { ClientDrainageInfoDto, ClientDrainageInfoUpdateDto, ClientSignatureMaterialDto, ClientSmsApplicationDto, ClientSmsSignatureDto, ClientSmsSignatureUpdateDto, ClientSmsTemplateDto, ClientSmsTemplateUpdateDto, ClientStatusChangeDto } from '../common/client-write.dto';
import {
ClientApplicationStatusDto,
ClientDeleteResourceDto,
ClientDrainageInfoDto,
ClientDrainageInfoUpdateDto,
ClientSecretResetDto,
ClientSignatureMaterialDto,
ClientSmsApplicationDto,
ClientSmsSignatureDto,
ClientSmsSignatureUpdateDto,
ClientSmsTemplateDto,
ClientSmsTemplateUpdateDto,
} from '../common/client-write.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { DeletionGovernanceService } from '../deletion-governance/deletion-governance.service';
import { SmsConfigService } from './sms-config.service';
@@ -11,10 +23,17 @@ import { SmsConfigService } from './sms-config.service';
@ApiTags('client-sms-config')
@Controller('client')
export class ClientSmsConfigController {
constructor(private readonly smsConfig: SmsConfigService, private readonly deletions: DeletionGovernanceService) {}
constructor(
private readonly smsConfig: SmsConfigService,
private readonly deletions: DeletionGovernanceService,
) {}
@Get('applications')
listApplications(@CurrentTenantId() tenantId: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listApplications(
@CurrentTenantId() tenantId: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return page || pageSize
? this.smsConfig.listApplicationsPage({ tenantId, page: Number(page), pageSize: Number(pageSize) })
: this.smsConfig.listApplications(tenantId);
@@ -37,8 +56,14 @@ export class ClientSmsConfigController {
}
@Get('applications/:id/report-fields')
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));
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));
}
@Get('report-fields/common')
@@ -49,14 +74,24 @@ export class ClientSmsConfigController {
@Post('applications/:id/secret/reset')
@RequireRecentAuthentication()
@UsePipes(strictValidationPipe)
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
resetApplicationSecret(
@Param('id') applicationId: string,
@Body() body: ClientSecretResetDto,
@CurrentTenantId() tenantId: string,
@CurrentSessionUserId() operatorId?: string,
) {
return this.smsConfig.resetClientApplicationSecret(applicationId, { ...body, operatorId }, tenantId);
}
@Post('applications/:id/status')
@RequireRecentAuthentication()
@UsePipes(strictValidationPipe)
changeApplicationStatus(@Param('id') applicationId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
changeApplicationStatus(
@Param('id') applicationId: string,
@Body() body: ClientApplicationStatusDto,
@CurrentTenantId() tenantId: string,
@CurrentSessionUserId() operatorId?: string,
) {
return this.smsConfig.changeClientApplicationStatus(applicationId, { ...body, operatorId }, tenantId);
}
@@ -71,8 +106,21 @@ export class ClientSmsConfigController {
}
@Get('signatures-workspace')
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) });
getSignatureWorkspace(
@CurrentTenantId() tenantId: string,
@Query('keyword') keyword?: string,
@Query('applicationId') applicationId?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.smsConfig.getClientSignatureWorkspace(tenantId, {
keyword,
applicationId,
status,
page: Number(page),
pageSize: Number(pageSize),
});
}
@Post('signatures')
@@ -84,14 +132,22 @@ export class ClientSmsConfigController {
@Put('signatures/:id')
@UsePipes(strictValidationPipe)
async updateSignature(@Param('id') signatureId: string, @Body() body: ClientSmsSignatureUpdateDto, @CurrentTenantId() tenantId: string) {
async updateSignature(
@Param('id') signatureId: string,
@Body() body: ClientSmsSignatureUpdateDto,
@CurrentTenantId() tenantId: string,
) {
await this.smsConfig.updateClientSignature(signatureId, body, tenantId);
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
}
@Post('signatures/:id/materials')
@UsePipes(strictValidationPipe)
createSignatureMaterial(@Param('id') signatureId: string, @Body() body: ClientSignatureMaterialDto, @CurrentTenantId() tenantId: string) {
createSignatureMaterial(
@Param('id') signatureId: string,
@Body() body: ClientSignatureMaterialDto,
@CurrentTenantId() tenantId: string,
) {
return this.smsConfig.createClientSignatureMaterial({ ...body, signatureId }, tenantId);
}
@@ -102,21 +158,34 @@ export class ClientSmsConfigController {
@Post('signatures/:id/drainage-infos')
@UsePipes(strictValidationPipe)
async createDrainageInfo(@Param('id') signatureId: string, @Body() body: ClientDrainageInfoDto, @CurrentTenantId() tenantId: string) {
async createDrainageInfo(
@Param('id') signatureId: string,
@Body() body: ClientDrainageInfoDto,
@CurrentTenantId() tenantId: string,
) {
const item = await this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId);
return this.smsConfig.getClientDrainageInfoView(item.id, tenantId);
}
@Put('drainage-infos/:id')
@UsePipes(strictValidationPipe)
async updateDrainageInfo(@Param('id') itemId: string, @Body() body: ClientDrainageInfoUpdateDto, @CurrentTenantId() tenantId: string) {
async updateDrainageInfo(
@Param('id') itemId: string,
@Body() body: ClientDrainageInfoUpdateDto,
@CurrentTenantId() tenantId: string,
) {
await this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId);
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
}
@Post('drainage-infos/:id/status')
@UsePipes(strictValidationPipe)
async changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: ClientStatusChangeDto, @CurrentTenantId() tenantId: string, @CurrentSessionUserId() operatorId?: string) {
async changeDrainageInfoStatus(
@Param('id') itemId: string,
@Body() body: ClientDeleteResourceDto,
@CurrentTenantId() tenantId: string,
@CurrentSessionUserId() operatorId?: string,
) {
await this.smsConfig.changeDrainageInfoStatus(itemId, { ...body, operatorId }, tenantId);
if (body.status === 'deleted') return { id: itemId, status: 'deleted' };
return this.smsConfig.getClientDrainageInfoView(itemId, tenantId);
@@ -130,16 +199,34 @@ export class ClientSmsConfigController {
@Post('signatures/:id/status')
@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);
async changeSignatureStatus(
@Param('id') signatureId: string,
@Body() body: ClientDeleteResourceDto,
@CurrentTenantId() tenantId: string,
@CurrentSessionUserId() operatorId?: string,
) {
if (body.status === 'deleted')
return this.deletions.delete('signature', signatureId, { ...body, operatorId }, tenantId);
await this.smsConfig.changeSignatureStatus(signatureId, { ...body, operatorId }, tenantId);
return this.smsConfig.getClientSignatureView(signatureId, tenantId);
}
@Get('templates')
listTemplates(@CurrentTenantId() tenantId: string, @Query('includeHistory') includeHistory?: string, @Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listTemplates(
@CurrentTenantId() tenantId: string,
@Query('includeHistory') includeHistory?: string,
@Query('keyword') keyword?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return page || pageSize
? this.smsConfig.listTemplatesPage({ tenantId, status: includeHistory === 'true' ? 'all' : 'approved', keyword, page: Number(page), pageSize: Number(pageSize) })
? this.smsConfig.listTemplatesPage({
tenantId,
status: includeHistory === 'true' ? 'all' : 'approved',
keyword,
page: Number(page),
pageSize: Number(pageSize),
})
: this.smsConfig.listClientTemplates(tenantId, includeHistory === 'true');
}
@@ -151,7 +238,11 @@ export class ClientSmsConfigController {
@Put('templates/:id')
@UsePipes(strictValidationPipe)
updateTemplate(@Param('id') templateId: string, @Body() body: ClientSmsTemplateUpdateDto, @CurrentTenantId() tenantId: string) {
updateTemplate(
@Param('id') templateId: string,
@Body() body: ClientSmsTemplateUpdateDto,
@CurrentTenantId() tenantId: string,
) {
return this.smsConfig.updateClientTemplate(templateId, body, tenantId);
}
@@ -162,8 +253,14 @@ export class ClientSmsConfigController {
@Post('templates/:id/status')
@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);
changeTemplateStatus(
@Param('id') templateId: string,
@Body() body: ClientDeleteResourceDto,
@CurrentTenantId() tenantId: string,
@CurrentSessionUserId() operatorId?: string,
) {
if (body.status === 'deleted')
return this.deletions.delete('template', templateId, { ...body, operatorId }, tenantId);
return this.smsConfig.changeTemplateStatus(templateId, { ...body, operatorId }, tenantId);
}
}
+43
View File
@@ -0,0 +1,43 @@
import { BadRequestException } from '@nestjs/common';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import { ClientCreateUserDto, ClientUserPasswordDto } from './client-user.dto';
describe('client user DTOs', () => {
it('rejects tenant, role and operator identity supplied by a client', async () => {
await expect(
strictValidationPipe.transform(
{
displayName: '测试用户',
email: 'user@example.com',
password: 'StrongPass-2026!',
tenantId: 'other',
roleCode: 'platform_admin',
operatorId: 'other-user',
},
{ type: 'body', metatype: ClientCreateUserDto, data: undefined },
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('requires a bounded password', async () => {
await expect(
strictValidationPipe.transform(
{ password: '1234567' },
{
type: 'body',
metatype: ClientUserPasswordDto,
data: undefined,
},
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('normalizes optional blank login fields without rejecting the existing client form', async () => {
await expect(
strictValidationPipe.transform(
{ displayName: '测试用户', username: ' user ', email: ' ', phone: '', password: 'StrongPass-2026!' },
{ type: 'body', metatype: ClientCreateUserDto, data: undefined },
),
).resolves.toEqual(expect.objectContaining({ username: 'user', email: undefined, phone: undefined }));
});
});
+30
View File
@@ -0,0 +1,30 @@
import { Transform } from 'class-transformer';
import { IsEmail, IsIn, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator';
const emptyToUndefined = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() || undefined : value;
export class ClientCreateUserDto {
@Transform(emptyToUndefined) @IsOptional() @IsString() @MaxLength(100) username?: string;
@Transform(emptyToUndefined) @IsOptional() @IsEmail() @MaxLength(200) email?: string;
@Transform(emptyToUndefined) @IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) phone?: string;
@IsString() @MinLength(1) @MaxLength(100) displayName!: string;
@IsString() @MinLength(8) @MaxLength(128) password!: string;
@IsOptional() @IsIn(['active', 'disabled']) status?: 'active' | 'disabled';
}
export class ClientUpdateUserDto {
@Transform(emptyToUndefined) @IsOptional() @IsString() @MaxLength(100) username?: string;
@Transform(emptyToUndefined) @IsOptional() @IsEmail() @MaxLength(200) email?: string;
@Transform(emptyToUndefined) @IsOptional() @Matches(/^\+?[0-9-]{6,24}$/) phone?: string;
@IsOptional() @IsString() @MinLength(1) @MaxLength(100) displayName?: string;
@IsOptional() @IsIn(['active', 'disabled']) status?: 'active' | 'disabled';
}
export class ClientUserStatusDto {
@IsIn(['active', 'disabled']) status!: 'active' | 'disabled';
}
export class ClientUserPasswordDto {
@IsString() @MinLength(8) @MaxLength(128) password!: string;
}
+50 -8
View File
@@ -1,9 +1,16 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UsePipes } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { CurrentTenantId } from '../auth/current-tenant-id.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { AdminUserResponseDto, ClientUserResponseDto } from './user-response.dto';
import { strictValidationPipe } from '../common/strict-validation.pipe';
import {
ClientCreateUserDto,
ClientUpdateUserDto,
ClientUserPasswordDto,
ClientUserStatusDto,
} from './client-user.dto';
import {
AssignPermissionDto,
AssignRoleDto,
@@ -53,13 +60,21 @@ export class UsersController {
@Post('admin/users/:id/status')
@RequireRecentAuthentication()
changeStatus(@Param('id') id: string, @Body() body: ChangeUserStatusDto, @CurrentSessionUserId() operatorId?: string) {
changeStatus(
@Param('id') id: string,
@Body() body: ChangeUserStatusDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.users.changeStatus(id, { ...body, operatorId }, undefined, operatorId);
}
@Post('admin/users/:id/password')
@RequireRecentAuthentication()
changePassword(@Param('id') id: string, @Body() body: ChangePasswordDto, @CurrentSessionUserId() operatorId?: string) {
changePassword(
@Param('id') id: string,
@Body() body: ChangePasswordDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.users.changePassword(id, { ...body, operatorId });
}
@@ -82,31 +97,58 @@ export class UsersController {
@Post('client/users')
@RequireRecentAuthentication()
createClient(@CurrentTenantId() tenantId: string, @Body() body: CreateUserDto, @CurrentSessionUserId() operatorId?: string) {
@UsePipes(strictValidationPipe)
createClient(
@CurrentTenantId() tenantId: string,
@Body() body: ClientCreateUserDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.users.create({ ...body, roleCode: 'enterprise_admin', operatorId }, tenantId);
}
@Put('client/users/:id')
@RequireRecentAuthentication()
updateClient(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: UpdateUserDto, @CurrentSessionUserId() operatorId?: string) {
@UsePipes(strictValidationPipe)
updateClient(
@CurrentTenantId() tenantId: string,
@Param('id') id: string,
@Body() body: ClientUpdateUserDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.users.update(id, { ...body, roleCode: 'enterprise_admin', operatorId }, tenantId);
}
@Post('client/users/:id/status')
@RequireRecentAuthentication()
changeClientStatus(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: ChangeUserStatusDto, @CurrentSessionUserId() operatorId?: string) {
@UsePipes(strictValidationPipe)
changeClientStatus(
@CurrentTenantId() tenantId: string,
@Param('id') id: string,
@Body() body: ClientUserStatusDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.users.changeStatus(id, { ...body, operatorId }, tenantId, operatorId);
}
@Post('client/users/:id/password')
@RequireRecentAuthentication()
changeClientPassword(@CurrentTenantId() tenantId: string, @Param('id') id: string, @Body() body: ChangePasswordDto, @CurrentSessionUserId() operatorId?: string) {
@UsePipes(strictValidationPipe)
changeClientPassword(
@CurrentTenantId() tenantId: string,
@Param('id') id: string,
@Body() body: ClientUserPasswordDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.users.changePassword(id, { ...body, operatorId }, tenantId);
}
@Delete('client/users/:id')
@RequireRecentAuthentication()
removeClient(@CurrentTenantId() tenantId: string, @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);
}
+3 -3
View File
@@ -1,11 +1,11 @@
{
"name": "brace-expansion",
"version": "5.0.8-compat.1",
"version": "5.0.9-compat.1",
"private": true,
"description": "CommonJS compatibility adapter for the bounded brace-expansion 5.0.8 implementation",
"description": "CommonJS compatibility adapter for the bounded brace-expansion 5.0.9 implementation",
"main": "index.cjs",
"license": "MIT",
"dependencies": {
"brace-expansion-safe": "npm:brace-expansion@5.0.8"
"brace-expansion-safe": "npm:brace-expansion@5.0.9"
}
}
@@ -0,0 +1,31 @@
# API 依赖公告治理矩阵(2026-08-28
## 结论
本轮重新执行 `npm audit --omit=dev --json`,审计对象从 9 项降至 5 项:Swagger、`js-yaml``fast-uri``brace-expansion` 的可安全升级路径已完成;剩余 5 项全部属于 Prisma CLI 的同一条开发/迁移工具链,不进入 API 运行时请求处理路径,且审计给出的唯一自动修复是把 Prisma 7.9.0 降级到 6.12.0,属于破坏性主版本变更,因此本轮不执行 `audit fix --force`
## 已修复公告
| 公告/包 | 原路径 | 处理 | 回归要求 |
|---|---|---|---|
| `js-yaml` / GHSA-5p4m-2wfm-xmqj | `@nestjs/swagger -> js-yaml < 4.3.1` | 升级 `@nestjs/swagger` 至 11.4.7,并锁定 `js-yaml` 4.3.1 | API build、Swagger 启动、全量 Jest |
| `fast-uri` / GHSA-7p8r-x3mc-p8w7 | OpenAPI/JSON schema 依赖树 | override 至 3.1.5 | API build、全量 Jest |
| `brace-expansion` / GHSA-rgw5-rvv9-x895 | Excel/归档工具的 minimatch 兼容链 | 兼容适配器升级到有界实现 5.0.9 | `security:verify` 中真实 require 与 legacy minimatch 验证 |
| `@nestjs/swagger` 聚合项 | 由 `js-yaml` 引起 | 随上述升级关闭 | 同上 |
## 暂留公告
| 审计项 | 实际路径 | 运行时暴露面 | 决策 |
|---|---|---|---|
| `prisma` | 根开发依赖 `prisma@7.9.0` | 仅生成客户端和执行迁移;API 运行时使用 `@prisma/client` | 暂留,等待 Prisma 7/8 提供无破坏升级 |
| `@prisma/config` | `prisma -> @prisma/config -> deepmerge-ts` | CLI 解析受控本地配置,不接受 HTTP 用户递归对象 | 暂留并监控上游 |
| `deepmerge-ts` / GHSA-ggr8-5vv4-36mx | 同上 | 不在 API 请求路径调用 | 暂留;禁止用不可信配置执行 Prisma CLI |
| `@prisma/dev` | `prisma -> @prisma/dev -> valibot` | Prisma CLI 开发工具链 | 暂留并监控上游 |
| `valibot` / GHSA-5qjj-4xww-7phc | 同上 | 不在 API 请求路径调用 | 暂留并监控上游 |
## 门禁与复核
- `tools/security/verify-dependency-mitigations.mjs` 固定验证修复版本及 brace CommonJS 兼容行为。
- 部署仍使用受控 `package-lock.json``npm ci`,不执行 `npm audit fix --force`
- 每次依赖升级重新运行两份 lock 的审计、API/前端构建、API 全量测试和真实测试环境健康检查。
- Prisma 上游出现保持主版本兼容的修复后,优先在独立分支验证 `prisma generate`、95 项迁移、PostgreSQL 连接和全量回归,再关闭暂留项。
@@ -0,0 +1,205 @@
# CMPP 平台登录后浏览器验收清单
- 验收环境:测试环境 `http://100.93.204.60:12026`
- 客户端入口:`/#/client/login`
- 运营端入口:`/#/admin/login`
- 禁止范围:不得使用预生产;不得发送、补发或重投短信;不得充值、改余额、改通道账号或启停状态
- 建议浏览器:Edge 或 Chrome 无痕窗口,桌面视口优先使用 1440×900;移动端补测 390×844
## 1. 证据要求
每个失败项至少记录:入口 URL、账号类型、操作步骤、实际结果、期望结果、时间、截图、控制台错误和失败请求。每个通过项至少保留一张能证明最终状态的截图。
验收前打开开发者工具:
1.`F12`
2. 在 Network 勾选 `Preserve log`,清空旧请求。
3. 在 Console 清空旧日志。
4. Network 筛选 `Fetch/XHR`,不要只看页面文字。
统一通过标准:
- 页面不是空白页,没有 Vite/React 错误遮罩。
- Console 没有新的 errorwarning 必须能解释且与本次操作无关。
- 业务接口不能出现未处理的 500、HTML 错误页或裸 `Internal server error`
- API 请求路径必须是 `/api/client/**``/api/admin/**` 对应门户,状态码与页面结果一致。
- 刷新后状态与刷新前一致,不依赖只存在于页面内存的假数据。
## 2. 客户端登录与会话
### CL-AUTH-01 正常登录
1. 打开客户端登录页。
2. 输入客户端账号、密码和当前图形验证码。
3. 点击“登录”。
4. 确认进入客户端首页,而不是停留在登录页或白屏。
5. 在 Network 找到登录请求,确认返回 2xx;后续 `/api/client/**` 初始化请求返回 2xx。
通过标准:页面进入客户端工作台,企业名称和当前账号正确;Console 无 error。
### CL-AUTH-02 刷新与深层链接
1. 在已登录状态按 `Ctrl+R`
2. 直接在地址栏打开账户余额、短信应用、批量任务、短信详情、上行短信各一个深层路由。
3. 每个路由再刷新一次。
通过标准:刷新后仍保持登录;页面正常恢复数据;不先闪现其他企业数据;不错误跳回运营端或登录页。
### CL-AUTH-03 会话隔离
1. 保持客户端已登录,新开标签访问 `/#/admin/...` 任一运营端深层地址。
2. 不输入运营端账号。
通过标准:客户端 Cookie 不能直接获得运营端权限,应进入运营端登录页或返回明确的未授权状态。
## 3. 客户端租户边界(C2
### CL-TENANT-01 请求不携带客户端租户头
1. 已登录客户端后,在 Network 打开任意 `/api/client/**` 请求。
2. 查看 Request Headers。
通过标准:请求中没有 `x-tenant-id`;页面仍能正常显示当前企业的数据。
### CL-TENANT-02 localStorage 不参与授权
1. 在 Application → Local Storage 中记录现有内容。
2. 若存在历史 `tenantId` 字段,先截图,再删除该字段;没有则不新增。
3. 刷新当前客户端页面。
通过标准:当前企业仍由登录会话识别,页面不出现默认租户导致的 403,也不切换到其他企业。
### CL-TENANT-03 资源归属抽查
1. 打开短信应用、签名、模板、账户余额和用户管理。
2. 核对企业名称、应用、签名和余额均属于当前账号企业。
通过标准:不出现其他企业名称、应用、手机号、签名或余额。
> 安全接口矩阵中的“伪造其他租户头返回 403”已由自动化和真实 API 记录覆盖,人工浏览器验收不要求修改请求后重放。
## 4. 客户端核心页面
### CL-PAGE-01 首页与账户余额
1. 打开客户端首页,检查所有金额数字。
2. 打开账户余额页。
3. 检查标题是否为图标+页面标题,顶部不应重复出现单独“账户”标题。
4. 检查金额字体、颜色和字重。
通过标准:金额使用普通大号黑色字体;首页金额与“今日发送”视觉一致;账户状态不显示“余额水位”。
### CL-PAGE-02 短信应用
1. 打开短信应用列表和一个应用详情。
2. 对照运营端同一应用的 CMPP 连接状态。
通过标准:两端连接状态一致;页面没有 `Internal server error`
### CL-PAGE-03 接口对接
1. 打开接口对接页。
2. 依次查看配置、HTTP 凭据、Webhook、请求日志和投递日志。
通过标准:五个区域均能加载真实接口结果;空数据使用中文空态;不能显示裸 `Internal server error`
### CL-PAGE-04 签名与引流信息
1. 打开签名列表及详情。
2. 查看引流信息列表及详情。
通过标准:不显示“已提交资料”和“使用场景”;不显示笼统“是否审核”;只显示移动、联通、电信三网是否可用;运营端“部分通过”和“全部通过”在客户端均显示“报备通过”。
### CL-PAGE-05 时间默认值
分别打开:批量任务、短信发送详情、上行短信。
通过标准:首次进入时开始/结束时间默认为近7天;刷新后仍按近7天发起请求;列表结果与筛选时间一致。
### CL-PAGE-06 发送状态中文化
1. 打开短信发送详情。
2. 抽查成功、失败、处理中、未知等不同状态。
通过标准:页面不显示 `delivered``failed``submit_queued` 等英文内部状态;未知值使用明确中文兜底,不能误映射为成功。
### CL-PAGE-07 顶部导航
通过标准:客户端右上角不存在“待审核任务”按钮。
## 5. 运营端核心页面
### AD-AUTH-01 正常登录、刷新和深层链接
使用运营端账号登录,重复客户端的正常登录、刷新和深层链接检查。
通过标准:运营端会话独立,刷新不丢失;客户端 Cookie 不能替代运营端会话。
### AD-PAGE-01 签名质量通道搜索
1. 打开签名质量检测页面。
2. 在通道搜索输入一个已知通道名称或编号。
3. 清空条件后重复一次。
通过标准:列表请求携带通道条件,结果只保留匹配通道;清空后恢复全部;无匹配时显示正常空态。
### AD-PAGE-02 通道签名活跃度热力图搜索
执行与签名质量页相同的通道搜索。
通过标准:热力图和关联统计同步更新,不只是前端隐藏标签;清空后恢复。
### AD-PAGE-03 短信任务进度状态搜索
1. 打开短信任务进度页面。
2. 依次选择至少两个状态。
3. 清空状态。
通过标准:请求状态参数正确;表格与状态条件一致;清空后恢复全部。
### AD-PAGE-04 系统日志日期区间
1. 打开系统日志页面。
2. 检查默认开始和结束时间。
3. 改为一个更短区间并查询。
通过标准:默认近7天;请求携带正确起止时间;修改区间后数据同步变化。
## 6. 通用异常状态
### COMMON-01 慢请求与 Loading
在 Network 将速度切换为 Slow 3G,刷新一个列表页。
通过标准:显示稳定 Loading,不闪白屏、不出现旧企业数据;完成后 Loading 消失。
### COMMON-02 空结果
使用一个不可能匹配的关键字或安全的空时间区间查询。
通过标准:显示中文空态,不报错,不保留上一次列表冒充新结果。
### COMMON-03 业务错误
在不改变业务数据的前提下触发一个前端可校验错误,例如缺少必填字段。
通过标准:显示中文、可理解的字段或业务提示;不显示裸 500 文案。
### COMMON-04 移动端
使用开发者工具切换到 390×844,检查客户端首页、余额页、列表筛选和详情弹窗。
通过标准:无横向页面滚动;按钮、金额、标题不遮挡;弹窗可滚动且关闭按钮可见。
## 7. 验收结果模板
| 编号 | 结果 | 截图 | Console | 失败请求/备注 |
|---|---|---|---|---|
| CL-AUTH-01 | 通过/失败 | 文件名 | 0 error / 摘要 | 状态码与接口 |
| CL-TENANT-01 | 通过/失败 | 文件名 | 0 error / 摘要 | 是否存在 x-tenant-id |
| CL-PAGE-0107 | 通过/失败 | 文件名 | 0 error / 摘要 | 具体页面 |
| AD-AUTH-01 | 通过/失败 | 文件名 | 0 error / 摘要 | 状态码与接口 |
| AD-PAGE-0104 | 通过/失败 | 文件名 | 0 error / 摘要 | 筛选条件与结果 |
| COMMON-0104 | 通过/失败 | 文件名 | 0 error / 摘要 | 状态与视口 |
所有项目通过后,浏览器验收才可以标记为关闭;仅登录页可见或接口返回 200 不算登录后验收完成。
@@ -0,0 +1,464 @@
# CMPP 平台代码质量持续优化整改方案
- 方案日期:2026-08-28
- 依据报告:`docs/code-quality-reassessment-20260828-v2.md`
- 当前代码基线:`3af145abe5eb8cae567bb18e29116b4940889258`
- 适用范围:React/Vite 客户端、NestJS API、依赖治理、测试与工程门禁
- 实施边界:先在本地完成代码和自动化验证;如需部署,只允许部署到测试环境 `100.93.204.60`,且部署前必须重新建立并校验恢复资产
- 禁止范围:未经新的明确授权,不得访问、部署、覆盖或回退预生产环境
## 1. 背景与当前结论
V2 复评确认上一轮两个 P0 已关闭,代码质量从 58 分提升至 82 分。当前不存在新的运行态 P0,但仍有以下持续优化空间:
1. 少量客户端写接口仍使用内联 TypeScript 类型,缺少运行时严格校验。
2. 前端没有自动化测试,登录、权限、懒加载失败和页面异常状态主要依赖人工验证。
3. API 全源覆盖率刚刚超过门槛,余量不足。
4. 客户端仍发送 `x-tenant-id` 并保留默认租户回退,与服务端可信会话租户的最终设计不一致。
5. 登录保护只有账号维度的主要限流,需要补充 IP 和验证码请求维度。
6. API 依赖树存在审计公告,需要按真实依赖路径和运行时可利用面逐项治理。
7. 当前 `lint` 不是完整 ESLint,格式检查也只依赖 `git diff --check`
8. 两个 SendChain Service 仍然过大,但直接拆分具有较高业务回归风险。
9. 工作区存在双锁文件口径,可能继续误导安装和依赖审计。
本方案遵循“先补小范围安全缺口和测试,再治理供应链和工程门禁,最后拆分发送链”的顺序。
## 2. 整改目标
本轮持续优化完成后应达到:
- 所有客户端写接口具有运行时 DTO 校验,不再使用裸内联 body 类型。
- 客户端租户授权完全依赖服务端认证会话,浏览器不再负责选择或回退租户。
- 建立可持续运行的前端测试框架,并覆盖核心登录、权限、异步路由和异常状态。
- API 全源覆盖率形成合理缓冲,新增代码不能依靠全局低门槛掩盖未测试分支。
- 登录防护具备账号、IP、IP+账号和验证码请求多维度限制与指标。
- API 依赖公告具有逐项路径、影响、处理方式和回归证据。
- `lint``format:check` 成为真实、稳定、可逐步扩展的工程门禁。
- SendChain 重构有行为锁定测试,拆分过程中不改变事务、锁、幂等和队列语义。
- 仓库只保留一种正式包管理器和一种正式锁文件。
## 3. 优先级和最小充分范围
| 批次 | 内容 | 风险 | 预估工作量 | 是否建议下一轮立即实施 |
|---|---|---:|---:|---|
| C1 | 剩余客户端 DTO 和负向测试 | 低 | 0.51.5 人日 | 是 |
| C2 | 删除客户端租户头和默认租户回退 | 中低 | 0.5~1 人日 | 是 |
| C3 | 最小前端测试框架和核心用例 | 中 | 1~3 人日 | 是 |
| C4 | 覆盖率缓冲和增量覆盖门禁 | 低 | 0.5~1 人日 | 是,依赖 C1/C3 |
| C5 | IP/账号/验证码多维登录保护 | 中 | 1~2 人日 | 建议 |
| C6 | API 依赖公告治理矩阵 | 中 | 1~3 人日 | 建议先诊断后升级 |
| C7 | ESLint、Prettier 和真实格式门禁 | 中 | 1~2 人日 | 建议分阶段启用 |
| C8 | SendChain Service 职责拆分 | 高 | 510 人日 | 单独专项 |
| C9 | 唯一包管理器和锁文件 | 中 | 0.5~1 人日 | 需先确认文件归属 |
下一轮最小充分范围为 C1~C4。它们投入较小,能够实质关闭 `CQ-API-001` 的客户端部分并改善 `CQ-TEST-001`,不需要数据库迁移,也不需要修改发送链业务规则。
## 4. C1:补齐客户端写接口 DTO
### 4.1 当前缺口
首批至少覆盖:
- `api/src/open-api/client-open-api.controller.ts`
- 创建 HTTP API 凭据。
- 新增或更新 Webhook。
- `api/src/operations/client-operations.controller.ts`
- 系统日志导出。
- `api/src/files/client-files.controller.ts`
- Multipart 上传的 `purpose``prefix`
### 4.2 实施要求
1. 新增独立 DTO class,不再使用运行时会被擦除的内联 TypeScript 类型。
2. 对上述接口启用现有 `strictValidationPipe`
3. DTO 规则至少包含:
- 凭据名称长度和空白处理。
- `expiresAt` 使用 ISO 日期时间校验,并拒绝已过期时间。
- Webhook `eventType` 只允许平台支持的事件。
- Webhook URL 限制协议、长度和格式;继续由 Service 执行 SSRF 与 HTTPS 策略。
- Webhook `status` 使用明确枚举。
- 日志级别、模块、日期区间使用明确格式和长度限制。
- 上传 `purpose` 使用白名单;`prefix` 限制长度、字符集和路径穿越。
4. 将当前过宽的 `ClientStatusChangeDto` 按应用、签名、模板和引流信息拆成更窄的 DTO,避免无关字段被不同接口接受。
5.`scheduledAt``requestedAt` 等时间字段使用日期格式校验。
6.`variables``materials``reportValues` 等对象补充:
- 最大键数量。
- 最大嵌套深度。
- 键名和字符串值长度。
- 禁止原型污染相关键。
### 4.3 自动化测试
每个接口至少覆盖:
- 合法最小请求成功。
- 额外字段返回 400。
- 超长字符串返回 400。
- 非法 URL、日期和状态返回 400。
- 客户端提交 `createdById/operatorId/tenantId` 等身份字段不能覆盖服务端身份。
- 校验失败时 Service 不被调用。
- 文件 purpose/prefix 校验失败时 MinIO 不发生写入。
### 4.4 出口标准
- 客户端 POST/PUT/PATCH 中不再存在未评审的裸 `@Body()` 内联类型。
- 对应控制器和 DTO 负向测试通过。
- 错误请求稳定返回 400,不返回 500。
- 不改变现有合法前端请求协议。
## 5. C2:移除客户端租户头和默认租户回退
### 5.1 目标设计
客户端租户范围只能来自服务端认证会话:
```text
客户端请求
-> session cookie
-> 服务端查询会话用户
-> request.sessionTenantId
-> CurrentTenantId
-> 数据库 tenantId 复合条件
```
浏览器不再通过 localStorage、默认常量或 `x-tenant-id` 参与客户端授权。
### 5.2 实施内容
1. 修改客户端 HTTP 请求封装,客户端路由默认不发送 `x-tenant-id`
2. 删除 `getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID` 的客户端回退路径。
3. 清理客户端 API 方法中仅为请求头服务的 `tenantId` 参数。
4. 管理端明确跨企业查询所需的租户参数继续保留,并与客户端请求封装分离。
5. 服务端暂时保留“收到客户端租户头时做一致性检查”的兼容逻辑,经过一个测试版本确认没有旧客户端后再决定移除。
6. 质量门禁增加:
- `src/api/client/**` 不得设置 `x-tenant-id`
- 客户端 API 不得引用 `DEFAULT_CLIENT_TENANT_ID`
- 管理端的显式租户参数不受此规则影响。
### 5.3 必测用例
- 不带租户头的客户端全部核心页面和接口正常。
- 浏览器 localStorage 中 tenantId 缺失、错误或过期,不影响服务端租户识别。
- 运营端跨租户查询仍按权限正常工作。
- 旧客户端携带正确租户头仍兼容;错误租户头仍返回 403 并记录安全事件。
- 双租户真实 API 隔离矩阵继续通过。
### 5.4 出口标准
- 客户端代码不再选择租户授权范围。
- 不出现登录后初始化阶段因默认租户错误导致的 403。
- 服务端可信租户门禁继续通过。
## 6. C3:建立最小前端测试集
### 6.1 技术方案
建议采用:
- Vitest:测试运行器。
- React Testing Library:组件和页面行为测试。
- jsdomDOM 环境。
- MSW:模拟真实 HTTP 协议的成功、空、失败和延迟响应。
测试必须围绕用户可见行为和真实请求契约,不以大量快照替代断言。
### 6.2 首批核心用例
1. 客户端登录:成功、密码错误、验证码错误、接口不可用。
2. 未登录访问客户端深层路由:跳转登录页。
3. 权限不足:显示稳定提示,不白屏。
4. `RouteLoadBoundary`:异步 chunk 加载失败、重试和重新加载。
5. 列表页:loading、empty、error、成功、分页和筛选。
6. 高风险操作:删除、重置密钥、创建凭据等确认流程。
7. API 返回 500:页面显示业务化错误,不直接显示裸 `Internal server error`
8. 会话失效:401 后清理展示会话并回登录页。
### 6.3 工程门禁
新增命令建议:
```text
npm run test:frontend
npm run test:frontend:coverage
```
并将 `test:frontend` 纳入 `verify:quality`。首轮覆盖率门槛以核心模块为主,不为追求全局数字测试纯展示组件。
### 6.4 出口标准
- 至少 6~10 个核心行为用例稳定通过。
- 登录、权限、路由失败和列表异常状态均有自动化覆盖。
- 测试可以在全新依赖安装后重复执行。
- CI/质量门禁中前端测试失败会阻止合并。
## 7. C4:覆盖率缓冲和增量门禁
### 7.1 当前问题
当前全源覆盖率虽然通过,但接近门槛:
- statements 59.84%,门槛 59%。
- branches 51.29%,门槛 50%。
- functions 60.15%,门槛 60%。
- lines 62.60%,门槛 62%。
functions 仅有 0.15 个百分点余量,新增少量未测试函数就可能失败。
### 7.2 实施内容
1. 先通过 C1 和 C3 增加有业务价值的测试,不立即盲目提高门槛。
2. 建立修改文件或新增文件覆盖率门禁,建议初始目标:
- statements/lines 不低于 80%。
- branches 不低于 70%。
- functions 不低于 80%。
3. 全源门槛在覆盖率稳定后分批上调,每次不超过 2~3 个百分点。
4. 覆盖率报告明确使用全源 `collectCoverageFrom` 口径,避免再次混用“已加载源码”和“全部源码”。
### 7.3 出口标准
- 新增 DTO、校验器和安全逻辑具有负向分支测试。
- 修改文件覆盖率门禁能够阻止新增无测试逻辑。
- 文档和质量门禁使用同一统计口径。
## 8. C5:多维登录和验证码保护
### 8.1 实施内容
在现有 Redis 原子计数基础上增加:
- 账号维度失败窗口。
- 来源 IP 维度失败窗口。
- IP+账号组合维度。
- 验证码获取频率限制。
- 单 IP 随机账号扫描保护。
- key 数量、失败次数、锁定次数和 Redis 异常指标。
来源 IP 必须基于受信 Nginx/代理链解析,不能直接信任任意客户端 `X-Forwarded-For`
### 8.2 验证
- 多 API 实例共享相同锁定状态。
- 同账号换 IP、同 IP 换账号均能触发对应保护。
- 正常用户偶发输错不会被过度锁定。
- Redis 不可用继续 fail closed,并产生监控告警。
- 随机账号和验证码请求压测后 Redis key 数量能随 TTL 回落。
## 9. C6API 依赖公告治理
### 9.1 原则
依赖审计公告不直接等于业务可利用漏洞。不得直接执行 `npm audit fix --force`。应先建立矩阵:
| 字段 | 内容 |
|---|---|
| 公告编号 | GHSA/CVE |
| 依赖路径 | 直接依赖到受影响包的完整路径 |
| 实际锁定版本 | 以 `api/package-lock.json` 为准 |
| 环境 | 生产运行时、构建期、开发期、可选工具 |
| 不可信输入 | 是否处理客户输入、文件、URL、模板或命令行参数 |
| 当前缓解 | override、功能未启用、输入边界、兼容适配 |
| 修复方案 | 补丁升级、依赖替换、隔离或书面风险接受 |
| 回归范围 | 构建、Excel 导出、Swagger、Prisma、数据库和部署 |
### 9.2 优先顺序
1. 直接生产运行时且处理不可信输入的路径。
2. Excel 导出、压缩、YAML/Swagger 等数据处理路径。
3. Prisma 的生产客户端路径。
4. 仅 CLI、Studio、构建或可选工具链路径。
### 9.3 出口标准
- 7 项公告均有明确依赖路径和处置结论。
- 可安全升级的依赖完成升级并通过全量回归。
- 暂不能升级的项目有代码级缓解门禁和明确复查日期。
- 根项目和 API 子项目审计口径分别记录,不互相替代。
## 10. C7:真实 lint 和格式门禁
### 10.1 分阶段实施
第一阶段只检查新增和修改文件,启用高价值规则:
- 未使用变量和导入。
- 未处理 Promise。
- 不安全的 `any` 和类型断言。
- React Hooks 依赖和调用规则。
- 无效条件、重复分支和不可达代码。
- Node/NestJS 常见异步错误。
第二阶段再逐目录修复历史问题并扩大到全仓库。
Prettier 首轮只执行 `check`,不得在同一提交中格式化全部历史文件。大规模格式化必须独立提交,避免掩盖业务改动。
### 10.2 脚本语义
- `lint`:真实 ESLint。
- `typecheck`:前端 TypeScript。
- `quality:verify`:结构安全门禁。
- `format:check`Prettier check + `git diff --check`
不要继续用 `lint` 名称包装结构检查和 TypeScript,使开发者误判实际门禁能力。
## 11. C8SendChain 超大服务专项拆分
### 11.1 前置条件
拆分前必须补齐行为锁定测试:
- 事务边界。
- advisory lock 和锁顺序。
- 消息、Submit 和 Outbox 幂等键。
- 主备路由和补发。
- 计费冻结、扣费、退款和释放。
- Inbox claim、租约、超时和恢复。
- Redis Stream 发布及重复消费。
- 回执、上行和下游投递。
### 11.2 建议拆分方向
`send-inbound-entry.service.ts`
- 入站协议数据转换。
- Inbox 持久化和 claim。
- 长短信聚合。
- 企业分组微批。
- 业务校验和消息创建。
`send-gateway-submit.service.ts`
- 路由选择。
- Gateway 命令构造。
- Submit Outbox。
- Submit 结果处理。
- 重试和恢复。
### 11.3 实施规则
- 每次只移动一个职责。
- 先机械提取,再优化逻辑。
- 不同时修改事务、SQL、幂等键或状态语义。
- 每个拆分提交都运行 API 全量、Gateway 全量、真实 PostgreSQL/Redis 契约和队列排空检查。
### 11.4 出口标准
- 原 Service 只保留编排职责。
- 行为测试、全链契约和性能基线不下降。
- 不新增重复查询、跨事务状态漂移或锁顺序变化。
## 12. C9:唯一包管理器和锁文件
当前正式跟踪文件为根目录和 API 的 `package-lock.json`,工作区另有未跟踪 `pnpm-lock.yaml`。该文件属于受保护的既有工作区内容,本方案不授权删除。
需要文件所有者明确选择:
### 方案 A:统一 npm(建议)
- 保留两个正式 `package-lock.json`
- 确认后删除或归档旧 `pnpm-lock.yaml`
- 部署、审计和 CI 全部使用 `npm ci`
- 门禁禁止新增 pnpm/yarn 锁文件。
### 方案 B:统一 pnpm
- 重新生成受控 pnpm 锁文件。
- 重写本地、CI、部署和审计命令。
- 在全新目录验证安装、构建、Prisma、测试和生产部署。
- 完成后再移除 npm 锁文件。
不得长期同时维护两种锁文件,也不得只改锁文件而不改部署流程。
## 13. 不建议在下一轮实施的动作
- 不一次性为全部 API 开启全局严格 `ValidationPipe`
- 不直接执行 `npm audit fix --force`
- 不为了 Vite 的 500 KiB 原始体积警告替换整个 ECharts;当前 gzip 包体仍在预算内。
- 不在安全补丁提交中同时拆分 SendChain。
- 不以大量快照测试提高前端覆盖率。
- 不擅自删除 `=`, `pnpm-lock.yaml` 或其他侧边任务文件。
- 不把测试环境健康检查等同于登录后页面和 CMPP 全链验收。
## 14. 验证矩阵
| 层级 | 必须验证 |
|---|---|
| 静态 | TypeScript、ESLint、Prettier、结构安全门禁、`git diff --check` |
| API | DTO 正负向、租户隔离、密码迁移、登录限流、账务和发送幂等 |
| 前端 | 登录、权限、懒加载、loading/empty/error、确认操作、401/500 |
| 数据库 | Prisma validate、95 项 migration 一致性、真实 PostgreSQL 关键查询 |
| Redis | 验证码 TTL、登录锁定、多实例、Stream pending/lag |
| Gateway | `go test ./... -count=1``go vet ./...`、5 份队列契约 |
| 浏览器 | 登录后页面、控制台、请求、刷新、深层链接和移动端 |
| 发布后 | 部署 commit、服务、端口、健康、日志、队列和临时数据清理 |
## 15. 测试环境发布要求
如下一轮包含部署,必须:
1. 明确目标是测试环境 `100.93.204.60`,显式绕过 Clash/系统代理进行健康探测。
2. 发布前重新建立:
- PostgreSQL custom dump。
- 当前运行目录归档。
- 环境、systemd 和 Nginx 配置。
- Redis RDB 和关键 Stream/队列状态。
- MinIO 数据或业务对象清单。
- 当前部署 commit 和 95 项 migration 状态。
3. 校验 `pg_restore --list`、tar 目录、Redis RDB 和 SHA-256。
4. 发布后核对所有相关服务、API/Gateway health、Redis pending/lag 和 error 日志。
5. 清理临时测试账号、凭据、文件和脚本。
6. 测试环境通过不自动授权预生产发布。
## 16. 建议提交拆分
1. `security: validate remaining client write payloads`
2. `test: cover client dto rejection paths`
3. `security: remove client-controlled tenant headers`
4. `test: add frontend auth and route failure baseline`
5. `test: enforce changed-file coverage`
6. `security: add ip-aware login throttling`
7. `build: document and remediate api audit paths`
8. `build: add eslint and prettier checks`
9. `refactor: extract inbound workflow responsibilities`
10. `refactor: extract gateway submit responsibilities`
11. `chore: enforce the selected package manager`
每个提交只处理一个主题,不混入测试环境部署资产、历史文档改写或其他会话的工作区文件。
## 17. 最终关闭标准
| 项目 | 关闭证据 |
|---|---|
| 客户端 DTO | 所有写接口使用 class DTO 和严格管道;负向测试通过 |
| 租户头清理 | 客户端不再发送/回退 tenantId;双租户矩阵通过 |
| 前端测试 | 核心用例进入质量门禁并稳定运行 |
| 覆盖率 | 全源口径稳定且修改文件满足增量门槛 |
| 登录保护 | 账号/IP/组合/验证码限流和指标通过多实例测试 |
| 依赖公告 | 每项有路径、影响、处理和回归证据 |
| lint/format | ESLint 和 Prettier 真实执行,不再仅使用脚本别名 |
| SendChain | 行为锁定后完成职责拆分,全链语义和性能不下降 |
| 锁文件 | 唯一包管理器、唯一正式锁文件、全新安装可重复 |
| 测试环境 | 恢复资产、真实后端、浏览器和发布后状态证据齐全 |
## 18. 建议执行顺序
```text
C1 客户端 DTO
-> C2 租户头清理
-> C3 前端测试
-> C4 覆盖率缓冲
-> C5 登录保护
-> C6 依赖治理
-> C7 lint/format
-> C8 SendChain 专项拆分
-> C9 锁文件统一
-> 测试环境完整验收
```
其中 C1~C4 可作为下一轮独立交付;C8 必须保持为单独的高风险重构任务。
## 19. 2026-08-28 执行补充
- 文件所有者已明确要求完成其余未关闭项,并确认不接受任何 npm 或依赖降级。
- C9 继续采用既有 npm 10.9.4 基线,没有执行 npm、Node、Prisma或业务依赖降级;旧的未跟踪 `pnpm-lock.yaml` 已移除,正式安装仍使用根目录和API目录各自的 `package-lock.json``npm ci`
- C8 本轮只形成专项拆分方案 `docs/sendchain-service-decomposition-plan-20260828.md`,没有实施高风险发送链代码重构。
- 登录后页面由用户按 `docs/code-quality-browser-acceptance-checklist-20260828.md` 人工验收,结果回填前不得标记浏览器验收关闭。
@@ -0,0 +1,190 @@
# CMPP 平台代码质量复评报告 V2
- 报告日期:2026-08-28
- 复评时间:2026-08-28 12:3812:57Asia/Shanghai
- 复评基线:`3af145abe5eb8cae567bb18e29116b4940889258`
- 对比基线:`171c7d38e8f17de0dd83570603da316d47c0d015`
- 分支状态:`main`,本地 `HEAD``origin/main` 一致
- 变更规模:58 个文件,新增 1,953 行、删除 487 行
- 复评范围:React/Vite 前端、NestJS API、Prisma/PostgreSQL、Redis、Go Gateway、自动化测试、依赖与仓库门禁、测试环境只读健康探测
- 明确未执行:提交、推送、部署、服务重启、数据库写入、短信发送、压力测试、MinIO 业务对象写入、预生产或生产环境操作
## 1. 复评结论
本次采用两个相互独立的结论,避免把“代码整改完成”和“环境可发布”混为一谈:
1. **代码整改:有条件通过。** 上一版的两个 P0 已在源码、自动化测试和结构门禁层面关闭;前端分包、覆盖率门槛、Redis 状态存储、数据库配置 fail closed、mock 解耦等整改有效。
2. **发布就绪:待完整验收。** 2026-08-28 12:50 在 Clash 虚拟网卡/代理开启状态下探测测试环境时曾连续返回 HTTP 502;关闭 Clash 后使用 `curl --noproxy '*'` 绕过代理直连,12:56 连续 3 次访问 `/api/health` 均返回 HTTP 200、`status=ok`,TCP 12026 端口可达,连接耗时约 10 ms、总响应约 19~28 ms。此前 502 判定属于本地网络代理干扰,不是测试环境运行故障。
当前代码质量综合参考分:**82/100**,较上一版 **58/100** 提升 24 分。该分数只表示仓库代码治理水平,不等价于功能验收通过率。
测试环境外部 API 健康探测已经恢复为通过;但在重新核对部署版本、内部服务/队列状态并完成真实后端与登录后浏览器复验前,**仍不建议进入预生产发布**。
## 2. 质量评级
| 维度 | 上版 | 本次 | 复评结论 |
|---|---:|---:|---|
| 业务正确性与并发设计 | B | B+ | API/Gateway 回归通过,核心幂等与队列逻辑保持;未重跑真实 CMPP 全链。 |
| 安全性 | D | B+ | 可信租户上下文和强密码哈希已落地;重点写接口校验仍非全覆盖。 |
| 自动化测试 | C | B- | API 47 套 543 项通过并建立覆盖率阈值;前端测试仍为 0。 |
| 前端性能 | C- | B+ | 路由懒加载和 ECharts 按需加载生效,入口 gzip 降至 107.38 KiB。 |
| 可维护性 | C | C+ | mock 解耦和页面辅助逻辑拆分完成;两个 SendChain Service 仍超大,未建立 ESLint/Prettier。 |
| 数据库工程 | B | B+ | Prisma 校验通过,生产/测试缺少 `DATABASE_URL` 时 fail closed;本轮未做真实数据执行计划复测。 |
| 依赖与仓库卫生 | C- | B- | 根正式锁文件审计为 0;API 依赖树仍有 7 项公告,且未跟踪的旧 pnpm 锁文件会造成错误审计口径。 |
| 运行与发布就绪 | 未评 | B- | 绕过本地代理后测试环境外部 `/api/health` 连续 3 次返回 200;本次未独立核对内部服务、队列和登录后业务链。 |
## 3. 上一版问题关闭状态
| 编号 | 原级别 | 本次状态 | 判定依据 |
|---|---:|---|---|
| CQ-SEC-001 客户端租户可伪造 | P0 | **已关闭(代码级)** | 会话中间件从数据库用户写入 `sessionTenantId`;客户端装饰器只读取可信上下文;伪造 header 返回 `CLIENT_TENANT_MISMATCH`;相关租户隔离测试通过。 |
| CQ-SEC-002 无盐 SHA-256 密码 | P0 | **已关闭(代码级)** | 使用随机 salt 的版本化 scrypt、恒定时间比较、参数上限解析;旧 SHA-256 只读兼容并在正确登录后透明升级;写路径和工具门禁已覆盖。 |
| CQ-API-001 缺少运行时输入校验 | P1 | **部分关闭** | 已新增严格 `ValidationPipe` 和重点客户端写 DTO;但未全局注册,部分凭据、Webhook、日志导出等写接口仍使用内联类型和裸 `@Body()`。 |
| CQ-AUTH-001 验证码/失败计数进程内 Map | P1 | **基本关闭** | 已迁移 Redis TTL、`GETDEL` 和 Lua 原子计数,Redis 异常 fail closed;仍建议补 IP+账号双维度限流与容量指标。 |
| CQ-FE-001 主包过大、无路由分包 | P1 | **已关闭** | 全部业务页采用懒加载;ECharts 按需注册;入口 gzip 107.38 KiB,低于 250 KiB 门槛,最大异步图表包 181.64 KiB,低于 190 KiB 门槛。 |
| CQ-TEST-001 测试与验收层级不足 | P1 | **部分关闭** | API 覆盖率阈值已建立且本轮通过;Gateway 测试通过;前端仍无测试,登录后浏览器和真实全链未完成。 |
| CQ-DEP-001 两个 high advisory | P2 | **原问题已关闭** | 正式 `package-lock.json` 使用 React Router 7.18.2、NanoID 3.3.18;按该锁文件重建审计结果为 0。 |
| CQ-MAINT-001 超大文件、无静态风格门禁 | P2 | **部分关闭** | 页面辅助逻辑已拆分,增加结构质量脚本;两个 SendChain Service 仍为 86,253/53,421 字节,`lint` 实际为结构检查加 TypeScript,不是 ESLint/格式门禁。 |
| CQ-CONFIG-001 数据库默认凭据 | P2 | **已关闭** | 非 development/test 环境缺少 `DATABASE_URL` 时启动失败,不再静默使用默认生产连接。 |
| CQ-ARCH-001 生产代码依赖 mock | P2 | **已关闭** | 类型已迁至 `src/api/types`,结构门禁禁止生产页面重新导入 mock。 |
| CQ-REPO-001 仓库产物与审计基线不稳定 | P2 | **部分关闭** | `*.tsbuildinfo` 已停止跟踪并加入忽略;但工作区仍有未跟踪的 `=` 和过期 `pnpm-lock.yaml`。 |
## 4. 关键整改证据
### 4.1 可信租户上下文
- `api/src/auth/session-validation.middleware.ts:70-72`:客户端请求携带的租户头与登录用户租户不一致时返回 403,并将数据库用户租户写入 `request.sessionTenantId`
- `api/src/auth/current-tenant-id.decorator.ts:10-13`:只允许 client portal 读取可信租户上下文,不再回退到请求头。
- 客户端认证、短信配置、账务、发送链、风险复核、用户等控制器已改用 `@CurrentTenantId()`
- 管理端 `@TenantId()` 仍存在于 `admin/files``admin/operation-logs``admin/billing` 等管理路由中,不属于客户端授权依据;结构门禁已禁止客户端控制器回退使用旧装饰器。
判定:原 P0 的根因已消除。测试环境真实双租户结果在整改记录中有描述;本次只复核外部健康接口,没有重跑双租户矩阵,因此仍按代码级关闭记录。
### 4.2 密码存储与迁移
- `api/src/auth/password-hasher.ts:1-82`:版本化 scrypt、随机 salt、`timingSafeEqual`、参数边界和旧 SHA-256 识别均已实现。
- 旧哈希兼容写窗口被限制为最长 2 小时;正常写路径默认生成新格式。
- 登录成功后通过条件更新透明迁移旧哈希,错误密码不迁移。
- 单测覆盖随机盐、错误密码、旧哈希验证/迁移、畸形参数和兼容窗口边界。
判定:满足上一版“强哈希、版本参数、恒定时间比较、透明迁移”的最小关闭标准。测试环境仍保留的历史 SHA-256 数量来自整改记录,本次未直接查询数据库复核。
### 4.3 登录状态与输入校验
- `api/src/auth/session.service.ts:129-163`:验证码使用 Redis TTL,消费使用 `GETDEL`;失败计数使用 Redis Lua 原子逻辑。
- `api/src/common/strict-validation.pipe.ts`:启用 transform、whitelist 和 forbidNonWhitelisted。
- 重点客户端写接口已增加 class-validator DTO 和严格管道。
- `api/src/open-api/client-open-api.controller.ts:15-18` 的凭据/Webhook 写入,以及 `api/src/operations/client-operations.controller.ts:78-84` 的日志导出仍使用内联 body,未套用严格校验管道。
判定:验证码多实例一致性问题已关闭;输入校验只能判为阶段性完成,不能写成全 API 闭环。
### 4.4 前端性能
- `src/routes/AppRoutes.tsx:10-74`:业务页面统一路由级懒加载。
- `src/components/ui/Chart.tsx:3-8`:只注册实际使用的 ECharts 图表、组件和 Canvas renderer。
- 本次生产构建:入口 `index-*.js` 370.54 KiBgzip 107.38 KiB;图表异步包 553.06 KiBgzip 181.64 KiB。
- 包体预算脚本通过:入口不高于 250 KiB gzip,异步包不高于 190 KiB gzip。
判定:原主包 626.18 KiB gzip 的问题已实质关闭。Vite 仍提示图表 chunk 的未压缩体积超过 500 KiB,但其 gzip 体积在当前预算内,作为后续优化项而非阻断项。
### 4.5 数据库与工程配置
- API 正式构建通过。
- Prisma Schema validate 通过;仓库存在 95 个 migration 目录。
- 非开发/测试环境缺少 `DATABASE_URL` 时 fail closed。
- 本轮没有连接真实 PostgreSQL 执行 `EXPLAIN (ANALYZE, BUFFERS)`,也没有独立核对测试库 95/95 migration;相关运行态数据仅见整改记录。
## 5. 本次独立复测结果
| 检查项 | 本次结果 |
|---|---|
| Git 基线 | `HEAD == origin/main == 3af145a...` |
| 工作区 | 仅发现既有未跟踪 `=``pnpm-lock.yaml`;未修改或删除 |
| 前端 TypeScript | 通过 |
| Vite production build | 通过 |
| Bundle budget | 通过;入口 107.38 KiB gzip,最大异步包 181.64 KiB gzip |
| API TypeScript build | 通过 |
| Prisma validate | 通过 |
| API Jest | 47/47 套、543/543 项通过 |
| API 全源覆盖率 | statements 59.84%branches 51.29%functions 60.15%lines 62.60% |
| API 覆盖率门槛 | 通过;门槛分别为 59%、50%、60%、62% |
| Gateway `go test ./... -count=1` | 通过 |
| Gateway `go vet ./...` | 通过 |
| Gateway 队列契约 | 5/5 通过 |
| 代码质量结构检查 | 通过 |
| 依赖缓解/安全部署检查 | 通过 |
| 根正式锁文件生产依赖审计 | 0 个公告 |
| API 正式锁文件生产依赖审计 | 7 个公告:2 moderate、5 high |
| 前端自动化测试 | 0 个测试文件,未通过验收出口标准 |
| 测试环境 `/api/health` | **通过;关闭 Clash 后绕过代理直连,连续 3 次 HTTP 200、`status=ok`** |
| 测试环境 TCP 12026 | **通过;可连接** |
| 测试环境 SSH 只读核验 | 未完成;当前凭据认证失败 |
覆盖率说明:整改结果文档记录的 69.77%/53.47%/71.08%/73.05% 使用 Jest 默认“已加载源码”口径;本报告显式使用 `collectCoverageFrom=src/**/*.ts` 的全源口径,得到 59.84%/51.29%/60.15%/62.60%。两个结果不是同一统计口径,不应直接比较。本次全源结果仍满足已设门槛,并略高于上一版全源基线。
依赖审计说明:工作区未跟踪的 `pnpm-lock.yaml` 固定了旧 React Router 7.18.1 和 NanoID 3.3.16,直接运行 pnpm audit 会错误报出上一版两个 high。按正式、已跟踪的 `package-lock.json` 在临时目录重建锁文件后,根项目审计为 0。该旧锁文件仍应由文件所有者确认后清理或更新,避免 CI/开发者误用。
## 6. 当前阻断项与剩余风险
### 运行探测校正:此前 502 为本地代理干扰,不构成缺陷
**证据链**
- 2026-08-28 12:50,在 Clash 虚拟网卡/代理开启状态下,PowerShell Web 请求曾连续返回 HTTP 502。
- 用户关闭 Clash 后,首次 PowerShell `-NoProxy` 请求出现一次 10 秒超时,随后请求开始恢复。
- 2026-08-28 12:56,使用 `curl --noproxy '*'` 明确绕过代理连续请求 3 次,均返回 HTTP 200 和 `{"status":"ok","service":"cmpp-platform-api"}`
- 三次直连总响应时间分别约 28 ms、23 ms、19 msTCP 12026 检测为可连接。
**校正结论**
此前新增的 `CQ-OPS-001 / P0` 撤销,不计入缺陷和发布阻断。测试环境外部 API 当前可用,原整改记录中的 API 健康结论没有被本次复评推翻。
**仍保留的验证边界**
本次 SSH 凭据认证失败,因此没有独立读取部署标记、11 项服务状态、内部 Gateway health、Redis Stream pending/lag、migration 或服务器日志。这些项目继续引用整改发布记录,预生产前仍应在可用只读凭据下重新核对。后续私网健康探测应显式绕过系统代理,避免 Clash 等本地网络工具造成误判。
### CQ-API-001 / P1:严格输入校验尚未覆盖全部写接口
当前采取“重点接口逐步接入”而非全局管道。凭据创建、Webhook 更新和日志导出等入口仍使用运行时会被擦除的内联 TypeScript 类型。建议先覆盖所有 client POST/PUT/PATCH/DELETE,再评估 admin 高风险写接口;为字段长度、URL、枚举、数组大小和额外字段补负向测试。
### CQ-TEST-001 / P1:前端与真实环境验收未闭环
前端测试文件仍为 0;本轮没有可用登录态,无法验证登录后页面、控制台、请求、加载/空/错误/刷新状态。本次仅确认外部 API health,未重新执行真实 PostgreSQL、Redis、MinIO、Gateway 和 CMPP 联动复验。
### CQ-DEP-002 / P1API 依赖树仍有 7 项生产审计公告
`api/package-lock.json` 重建审计后为 2 moderate、5 high,涉及 ExcelJS 间接 UUID、Prisma 可选工具链、brace-expansion、Swagger 间接 js-yaml 等。公告并不等于当前业务路径全部可利用,但应建立“依赖路径、运行时是否打包/调用、可升级版本、兼容回归”的矩阵,不能只以根项目审计为 0 宣告供应链闭环。
### CQ-MAINT-001 / P2:超大服务和风格门禁仍未治理
`send-inbound-entry.service.ts` 约 84.2 KiB`send-gateway-submit.service.ts` 约 52.2 KiB;职责仍集中。建议先补行为锁定测试,再按入站持久化、关联/去重、重试路由、下游投递和计费拆分。另应增加 ESLint 与 format check;当前 `npm run lint` 名称实际只执行结构脚本和 TypeScript 检查。
### CQ-REPO-001 / P2:双锁文件口径仍可能误导审计
正式锁文件是 `package-lock.json`,但未跟踪 `pnpm-lock.yaml` 仍存在且版本过期。应先确认其归属,再选择唯一包管理器和唯一锁文件;本报告没有擅自删除或更新用户保留文件。
## 7. 发布建议
当前建议为:**代码可进入下一轮验证,但暂停预生产发布。**
最小充分关闭顺序:
1. 使用显式绕过系统代理的方式复核测试环境,并在可用只读凭据下核对实际部署标记、内部服务和队列状态。
2. 在测试环境重跑双租户隔离和旧密码透明迁移,不发送真实短信。
3. 补齐剩余客户端写接口 DTO/严格校验,并增加负向测试。
4. 建立最小前端测试集:登录、权限、路由懒加载失败、列表加载/空/错误、关键确认操作。
5. 对 API 7 项公告建立升级兼容矩阵;优先处理直接运行时路径。
6. 在单独任务中治理 SendChain 超大服务、ESLint/格式门禁和唯一锁文件。
7. 只有在真实 PostgreSQL、Redis、MinIO、Gateway/CMPP 模拟器和登录后浏览器证据齐全后,再申请预生产发布授权。
## 8. 最终判定
- 原 P0 代码问题:**2/2 已关闭**。
- 原 P1:**2 项关闭或基本关闭,2 项部分关闭**。
- 原 P2:**3 项关闭,2 项部分关闭**。
- 新增运行态阻断:**0 项**;此前 502 已确认是本地 Clash 代理干扰并撤销。
- 自动化门禁:除 API 子树依赖审计和前端测试缺失外,其余本次执行项通过。
- 代码质量:**82/100,有条件通过**。
- 当前发布就绪:**待完整验收;无 502 运行态阻断**。
本报告是对固定提交的独立复评和一次当前测试环境只读探测,不替代完整功能验收、生产安全评估或预生产发布审批。
@@ -0,0 +1,232 @@
# SendChain Service 职责拆分专项方案
- 方案日期:2026-08-28
- 对应整改项:`C8 SendChain Service 职责拆分`
- 当前范围:只制定方案,不在本轮实施发送链重构
- 适用环境:本地和测试环境 `100.93.204.60`
- 禁止范围:未经新授权不得部署预生产;不得用生产短信验证重构
## 1. 当前状态与问题
当前发送链已经存在一层门面 `send-submission.service.ts`,但两个实现类仍然过大:
| 文件 | 当前规模 | 集中职责 |
|---|---:|---|
| `send-inbound-entry.service.ts` | 约1852行、84.2 KiB | CMPP认证、入站持久化、长短信、Inbox claim、企业微批、配额、模板/引流匹配、风控、指标 |
| `send-gateway-submit.service.ts` | 约1159行、52.2 KiB | BullMQ Worker、批处理、路由、签名报备、限速、Submit命令、Outbox、任务进度、资源回收 |
| `send-submission.service.ts` | 约280行 | 对外门面、跨服务回调与编排 |
主要风险不是文件长度本身,而是以下行为边界在同一个类中耦合:
- PostgreSQL事务与 advisory lock 顺序。
- Inbox claim、租约、重试与恢复。
- 长短信分片幂等和完整消息恢复。
- 计费冻结、扣费、释放与失败补偿。
- 路由、签名报备、通道限速和主备策略。
- Submit Outbox 的租约、发布、确认和重复消费。
- 消息状态与批量任务进度的单飞/尾随刷新。
因此专项必须以“行为锁定后机械提取”为主,不允许借拆分类的机会同时重写SQL、事务或状态机。
## 2. 拆分目标
1. `SendSubmissionService` 只保留稳定门面和跨子域编排。
2. 每个子服务只有一个主要变化原因,建议控制在500行以内;复杂持久化服务可放宽至700行,但不得继续混合Worker生命周期、业务校验和发布协议。
3. 保持现有公开方法签名、队列名、Stream字段、幂等键、数据库状态和错误码不变。
4. 拆分过程中不改变吞吐参数、路由权重、计费口径和定时器周期。
5. 每一步均可独立回退,不要求一次性完成全部拆分。
## 3. 目标结构
建议新增以下内部服务,目录仍保留在 `api/src/send-chain/`,暂不建立更深目录,避免一次性路径迁移过大。
### 3.1 入站侧
| 目标服务 | 从现有文件提取的职责 | 明确不负责 |
|---|---|---|
| `inbound-authentication.service.ts` | `authenticateInboundApplication`、连接认证结果记录、IP白名单和接口状态检查 | 消息持久化、计费 |
| `inbound-long-message.service.ts` | 分片收集、完成判定、超时清理、已完成结果恢复 | 普通单条业务创建 |
| `inbound-inbox-repository.service.ts` | Inbox持久化、请求哈希、claim、租约续期/完成/失败 | 风控和模板匹配 |
| `inbound-workflow-worker.service.ts` | pump、企业公平分组、微批调度、停止与指标刷新 | SQL细节、消息业务规则 |
| `inbound-message-preparation.service.ts` | 应用快照、模板/签名/引流匹配、号码频控与风控输入组装 | 事务写入和队列发布 |
| `inbound-message-persistence.service.ts` | 单条/微批消息、任务、记录、配额预留和幂等持久化 | Worker生命周期、Gateway Submit |
### 3.2 出站侧
| 目标服务 | 从现有文件提取的职责 | 明确不负责 |
|---|---|---|
| `send-worker-lifecycle.service.ts` | BullMQ Worker启动/停止、并发槽位、微批flush、队列指标 | 路由、计费和命令构造 |
| `message-send-processor.service.ts` | 单条/批量消息处理、状态迁移、失败补偿编排 | Worker定时器和Outbox发布器生命周期 |
| `channel-routing.service.ts` | 运营商/省份识别、应用路由、候选通道、签名报备检查、主备选择 | 发布命令和数据库Outbox |
| `channel-rate-limit.service.ts` | 通道TPS等待和相关低基数指标 | 路由选择 |
| `gateway-submit-command.service.ts` | 命令构造、开放会话ID复用、直接Stream发布契约 | Outbox claim和业务状态变更 |
| `submit-outbox-publisher.service.ts` | Outbox claim、租约、批量发布、确认、重试和恢复 | 消息计费和路由 |
| `task-progress-refresher.service.ts` | 真实总量/状态聚合、单飞和尾随刷新 | Submit或回执业务处理 |
### 3.3 共享边界
- `send-chain.contracts.ts`:只保留稳定输入输出契约,不放实现函数。
- `send-chain.helpers.ts`:只保留无副作用纯函数;需要数据库、Redis、时间器或日志的逻辑必须进入Service。
- 计费继续通过现有 `BillingService`,不得复制金额算法。
- 所有数据库访问保持现有 Prisma 条件和事务客户端传递方式。
- 所有跨服务调用通过构造注入,不通过全局单例或循环引用。
## 4. 拆分前行为锁定
在移动代码前补齐特征测试,测试的是当前语义,不是期望中的新设计。
### 4.1 入站测试
- 认证成功、未知账号、禁用应用、未认证企业、IP不在白名单。
- 同一请求键并发两次只产生一份任务/消息/计费预留。
- 请求键相同但payload不同返回冲突。
- 长短信乱序、重复分片、缺片、超时、重启恢复。
- Inbox claim不重复、租约超时可恢复、失败次数和最终状态一致。
- 企业微批公平性、单企业失败不阻塞其他企业。
- 模板、签名、引流匹配和人工审核关联不改变。
- 配额预留不足时不产生半成品消息或费用。
### 4.2 出站测试
- 普通批量与CMPP内部任务入队语义保持不同且正确。
- Worker微批开启/关闭结果一致。
- 路由优先级、weighted分流、主备切换和运营商过滤不变。
- 签名未报备时的拒绝状态和费用释放一致。
- 通道TPS限制不会跨通道串扰。
- Submit命令字段、Sequence_Id、Msg_Id、Src_Id和幂等键保持一致。
- Outbox重复claim、租约恢复、发布成功/失败、Stream重复消费结果一致。
- 计费冻结、扣费、退款和释放只发生一次。
- 任务进度刷新保持单飞,并在运行中出现新状态时执行一次尾随刷新。
### 4.3 契约快照
拆分前固定以下非敏感契约样本:
- `gateway.submit.commands`
- `gateway.submit.results`
- `gateway.protocol.logs`
- BullMQ `send-message` job data
- Submit Outbox payload
契约测试只固定字段、类型、必填关系和幂等语义,不固定时间戳、随机ID或日志文本。
## 5. 分阶段实施顺序
### 阶段 S0:基线冻结
- 记录Git commit、两个文件行数/哈希、95项migration状态。
- 运行现有SendChain专项、API全量、Gateway全量、队列契约和性能基线。
- 在隔离数据库和Redis记录测试前快照。
出口:所有既有测试通过,所有临时数据带独立前缀且可清理。
### 阶段 S1:提取纯路由与命令构造
先提取 `channel-routing.service.ts``gateway-submit-command.service.ts`。这两部分事务耦合相对较低,最适合验证构造注入和门面调用方式。
规则:只移动代码;输入输出、查询条件、排序和错误文本保持不变。
### 阶段 S2:提取任务进度刷新与限速
提取 `task-progress-refresher.service.ts``channel-rate-limit.service.ts`,保留单飞Map、dirty尾随集合及Redis限速键格式。
出口:并发刷新和跨通道限速特征测试通过。
### 阶段 S3:提取Submit Outbox
提取 `submit-outbox-publisher.service.ts`,将定时器生命周期和claim/publish/ack逻辑封装在同一服务;不得改变租约所有者、批量大小、轮询周期或重试条件。
出口:故障注入后无丢失、无双发,Stream最终 `pending=0/lag=0`
### 阶段 S4:提取Worker生命周期和消息处理
将BullMQ启动/停止/微批flush移入 `send-worker-lifecycle.service.ts`,业务处理移入 `message-send-processor.service.ts`
出口:Worker关闭会等待当前flush;并发槽位与处理指标不变;吞吐基线不下降超过5%。
### 阶段 S5:提取入站认证和长短信
先移动认证,再移动长短信。两者均有明确入口和状态表,避免直接触碰Inbox微批主流程。
出口:认证审计字段、长短信幂等和超时恢复全部一致。
### 阶段 S6:提取Inbox Repository和Worker
先封装原SQL与事务为Repository,再将pump/企业分组/claim循环移入Worker。Repository方法必须接受现有事务客户端,不允许内部偷偷开启嵌套事务。
出口:claim锁顺序、租约、重试、企业公平性和停机等待不变。
### 阶段 S7:提取消息准备与持久化
这是入站侧风险最高的阶段,最后执行。先把纯准备结果定义为显式结构,再机械移动持久化;计费操作继续调用现有BillingService。
出口:消息、任务、计费、审核任务和Inbox状态在成功与每个失败点均与基线一致。
### 阶段 S8:清理门面
删除仅为旧类内部跳转存在的回调,`SendSubmissionService`仅保留公开API编排。清理必须单独提交,不能与任何业务优化合并。
## 6. 每阶段验证矩阵
| 层级 | 必须执行 |
|---|---|
| 静态 | API TypeScript、ESLint、Prettier、结构门禁、`git diff --check` |
| 单元 | 新子服务定向测试、SendChain既有全套特征测试 |
| API | 全量Jest及全源/增量覆盖率 |
| PostgreSQL | Prisma validate、95项migration、真实事务/锁/幂等契约 |
| Redis | BullMQ、限速键、Stream、Outbox幂等和最终pending/lag |
| Gateway | `go test ./... -count=1``go vet ./...`、五份队列契约 |
| 故障注入 | Redis短暂不可用、Gateway响应超时、DB事务失败、Worker中断恢复 |
| 性能 | 与S0同数据和同参数,入口延迟、完整Submit吞吐、DB池等待、队列积压 |
任何阶段出现以下情况立即停止并回退该阶段:
- 重复计费、重复Submit或消息丢失。
- 状态机出现无法收敛的新状态。
- advisory lock顺序或事务边界变化。
- 相同负载完整Submit吞吐下降超过5%,且无法由环境波动解释。
- Stream pending/lag、Inbox processing或Outbox leased无法清零。
## 7. 提交策略
每个阶段至少拆成两个提交:
1. `test(send-chain): lock <responsibility> behavior`
2. `refactor(send-chain): extract <responsibility> service`
禁止在重构提交中混入:
- SQL优化或索引变更。
- 状态、错误码、队列字段变更。
- TPS、批量大小、超时或重试参数调整。
- 依赖大版本升级和全仓格式化。
## 8. 测试环境发布与回退
每个可部署阶段发布前都必须重新建立并校验:PostgreSQL custom dump、运行目录、环境/systemd/Nginx、Redis RDB与Stream状态、MinIO清单、部署commit和migration状态。
建议S1~S3合并为第一个测试版本,S4为第二个,S5~S6为第三个,S7~S8为最终版本。每个版本均使用独立恢复点,禁止复用旧备份冒充当前基线。
回退只允许回退本阶段部署包与对应恢复资产;如已发生业务写入,应优先代码前滚修复,不得盲目恢复数据库覆盖测试期间其他数据。
## 9. 工作量与决策点
| 阶段 | 预计工作量 | 风险 |
|---|---:|---:|
| S0 行为锁定 | 23人日 | 中 |
| S1~S3 出站低/中耦合拆分 | 2~4人日 | 中 |
| S4 Worker拆分 | 12人日 | 中高 |
| S5S6 入站认证/Inbox拆分 | 24人日 | 高 |
| S7~S8 持久化与门面收口 | 2~4人日 | 高 |
总计建议按9~17人日安排,并分至少4个测试版本。开始实施前需要用户再次明确授权C8专项和测试环境发布窗口。
## 10. 最终关闭标准
- 两个超大实现类被删除或只保留不超过300行的兼容转发层。
- 每个目标服务职责单一,构造依赖和公开方法有明确边界。
- 所有行为锁定、故障注入、真实PostgreSQL/Redis和Gateway契约通过。
- 任务、消息、Submit、Outbox、计费和回执语义与S0一致。
- 同口径性能基线下降不超过5%,无新增DB池等待或队列积压。
- 测试环境发布后服务健康、错误日志为0、Stream最终 `pending=0/lag=0`
- 浏览器只需验证受影响的任务/消息状态展示;不得用页面显示替代后端全链证据。
+32
View File
@@ -4104,3 +4104,35 @@ git diff --check
- 采用两阶段密码安全发布并在每次覆盖前重新建恢复资产:`code-quality-before-d85ff859-20260828T035007Z``code-quality-compatible-d85ff859-20260828T035426Z``code-quality-before-fc4a6a7-20260828T040150Z`。三份均含PostgreSQL custom dump、运行目录、配置、Redis RDB、MinIO数据、状态快照、回退说明和SHA-256;恢复清单、pg_restore、tar和Redis校验通过。
- 最终收尾为95/95迁移、11项服务active、入口HTTP 200、API/Gateway健康、Redis PONG、`gateway.submit.results pending=0/lag=0`,发布后7项核心服务error级日志均0。浏览器真实登录页完整且控制台error/warning为0;未自动输入账号密码/验证码,登录后深层页面仍保留为人工浏览器验收边界。
- 本轮没有发送短信、压测、修改余额/应用/通道或写入MinIO业务对象,也没有执行新的CMPP模拟器全链;两个SendChain超大Service未在安全发布中做高风险重构。完整证据和保留项见`docs/code-quality-remediation-result-20260828.md`。只操作测试环境`100.93.204.60`,预生产未访问或修改。
## 2026-08-28 代码质量 V2 复评与测试环境健康探测校正
- 固定`3af145abe5eb8cae567bb18e29116b4940889258`基线重新复评,原两个P0在代码、单测和结构门禁层面均已关闭;API 47套543项、全源覆盖率59.84/51.29/60.15/62.60、前后端构建、bundle budget、Gateway全包测试/go vet、Prisma及队列契约通过。正式报告为`docs/code-quality-reassessment-20260828-v2.md`
- 首次外部健康探测时本机开启Clash虚拟网卡/代理,`100.93.204.60:12026/api/health`曾连续返回HTTP 502。关闭Clash后使用`curl --noproxy '*'`明确绕过代理,连续3次均返回HTTP 200、`status=ok`,总响应约1928ms,TCP 12026可连接;因此撤销“测试环境502”P0判定,确认为本地代理干扰,不是服务端故障。
- 本轮SSH只读认证未成功,未独立读取部署标记、内部服务、Redis Stream、migration和服务器日志;这些运行态项目继续保留为预生产前复核边界。未部署、未重启服务、未写库、未发送短信、未压测,预生产未访问或修改。
## 2026-08-28 代码质量持续优化执行
- 按`docs/code-quality-continuous-optimization-plan-20260828.md`实施非“暂不建议投入”项:补齐客户端凭据、Webhook、导出、文件、用户等运行时DTO与负向校验;浏览器端不再为客户端请求发送或回退租户ID,服务端继续只从认证会话取得客户端租户。
- 登录保护扩展为账号、来源IP和账号+IP三维失败计数,并增加IP维度验证码频率限制;Redis键只保存规范化值的SHA-256摘要。账号锁定仍为24小时,IP阈值为15分钟窗口30次,组合阈值为24小时5次;Prometheus新增有界事件/范围指标。
- 建立Vitest、Testing Library、jsdom、MSW前端测试基座,覆盖登录错误中文化、异步路由异常、客户端租户头禁止、401/403/500、文件/表单请求、用户列表loading/empty/error及删除确认。33项前端测试通过,纳入覆盖范围的核心模块为statements 88.19%、branches 84.51%、functions 84%、lines 87.94%,门禁为80/70/80/80。
- API增量覆盖门禁覆盖本轮新增DTO、动态JSON校验及登录安全逻辑,28项定向测试通过,覆盖率为86.78/78.29/95.83/89.84。API全量51套563项通过,全源覆盖率为70.44/53.77/71.76/73.61,较V2复评继续形成缓冲。
- 引入ESLint flat config、Prettier和实际增量文件门禁;`packageManager`固定为与测试环境一致的npm 10.9.4,正式提交仍只维护根目录/API两份`package-lock.json`。历史受保护的未跟踪`pnpm-lock.yaml`和空文件`=`保持原状且不纳入提交。
- 重新审计API依赖并将9项公告降至5项:Swagger、`js-yaml``fast-uri``brace-expansion`安全升级完成;剩余5项属于Prisma CLI同一开发/迁移链,唯一自动方案为破坏性降级Prisma 7至6,未执行`audit fix --force`。逐项路径和运行时判断见`docs/api-dependency-advisory-matrix-20260828.md`
- 本地前后端TypeScript、Vite生产构建、bundle budget、Gateway全包测试与`go vet`、95项Prisma schema、代码质量、安全部署及依赖缓解门禁均通过。按方案明确排除全局ValidationPipe一次性切换、强制audit修复、ECharts替换和SendChain高风险重构;发布与真实环境证据在后续条目补齐。
- 实施提交为`ad27acad7e02b787185c972c710c73c867429c36`,随后以`226527f1cef122e4dbd163da782485e8c0254dc5`将jsdom降至兼容测试机Node 22.21.1的29.1.1,并用测试机同版npm 10.9.4重新生成两份lock。两项目`npm ci --dry-run --include=dev`、前端34项覆盖测试和API正式编译再次通过。向`origin/main`推送时远端Git服务拒绝当前凭据,故本地提交完成但远端未推送,未以部署成功冒充push成功。
- 第一次发布前恢复点`/opt/cmpp-platform-backups/code-quality-continuous-20260828T063123Z`约651MB且全部校验通过。首次覆盖在根依赖安装后、迁移/构建/重启前被npm 10发现API lock缺少可选依赖并安全停止;旧部署标记保持`fc4a6a7...`11项服务、API和Gateway健康,没有进入迁移或重启阶段。
- 修正后重新建立第二套恢复点`/opt/cmpp-platform-backups/code-quality-continuous-20260828T064405Z`约671MB,重新包含并校验PostgreSQL custom dump、当时运行目录、环境/systemd/Nginx、Redis RDB、三条Stream状态、MinIO对象清单、原部署标记和95项迁移。两套恢复点的SHA-256、`pg_restore --list`、tar目录与Redis RDB均通过,未复用第一次资产冒充第二次发布前基线。
- 最终发布包`outputs/cmpp-quality-226527f-20260828T0646Z.tar.gz`为2596543字节,本地/服务器SHA-256均为`e05f81798e4bbffc0b07ce6f30d10102f650bd50e23f26bb331fcb1708984de3`。测试环境部署标记为`226527f1cef122e4dbd163da782485e8c0254dc5`95项迁移无待执行项。
- 发布后API、Send Worker、Submit Outbox、Gateway Callback、Protocol Log Worker、Gateway、Security Agent、MinIO、Nginx、PostgreSQL和Redis全部activeAPI/Gateway健康、Redis PONG`gateway.submit.commands``gateway.submit.results``gateway.protocol.logs``pending=0/lag=0`,发布窗口error级日志0。Prometheus已暴露新的认证保护指标,临时发布/校验脚本已清理。
- 工作站显式`curl --noproxy '*'`验证首页、客户端登录、运营端登录、API健康和客户端验证码接口均HTTP 200。Chrome真实加载客户端和运营端登录页,客户端显示“返回官网”,页面资源为`index-BvZGIM6l.js/index-DyAqoJbu.css`,两页控制台error/warning均0;未输入密码或代解验证码,登录后页面继续作为人工验收边界。
- 全程只操作测试环境`100.93.204.60`,未访问、回退、覆盖或部署预生产`8.160.169.106`;未发送、补发或重投短信,未修改余额、应用、通道或客户连接。
## 2026-08-28 代码质量持续优化补充关闭
- C2沿用同一运行代码的真实双租户API矩阵证据:客户端无租户header和一致header成功、伪造其他租户header返回`403 CLIENT_TENANT_MISMATCH`、请求体tenantId不能覆盖会话租户、跨租户资源返回404;本轮前端34项测试继续验证`/api/client/**`不发送`x-tenant-id`,管理端显式租户选择不受影响。登录后可见页面由用户按`docs/code-quality-browser-acceptance-checklist-20260828.md`人工验收,结果回填前不把浏览器验收标记为关闭。
- C5新增`tools/testing/verify-auth-redis-multi-instance.mjs`真实Redis契约。两个独立`SessionService`实例共用隔离Redis,验证跨实例会话、验证码一次性消费、验证码第31次拒绝、账号/组合第5次锁定、随机账号扫描同IP第30次锁定、15分钟/24小时TTL及SHA-256键;测试完成后隔离库`dbsize=0`。另补Redis不可用时验证码、锁查询和失败记录均返回服务不可用的fail-closed单测。
- C9不执行任何npm、Node、Prisma或依赖降级;继续使用上一轮已部署验证的npm 10.9.4和根/API两份`package-lock.json`,经文件所有者授权移除旧的未跟踪`pnpm-lock.yaml`。质量门禁由“只检查已跟踪替代锁”收紧为仓库根目录只要出现`pnpm-lock.yaml``yarn.lock`即失败;空文件`=`及其他会话产生的评估报告修改继续保留、不纳入本次提交。
- C8只新增`docs/sendchain-service-decomposition-plan-20260828.md`,按认证、长短信、Inbox Repository/Worker、消息准备/持久化、Worker生命周期、路由、限速、Submit命令、Outbox和任务进度刷新分S0~S8实施;本轮没有修改发送链运行代码。
- 回归通过:认证相关3套26项、API全量Jest、前端34项、API/前端TypeScript、Vite生产构建、真实Redis多实例契约、ESLint、Prettier、结构质量和依赖缓解门禁。首次从仓库根目录启动Jest时误扫描`outputs/`历史副本且未加载API转换配置,随后使用`api/jest.config.cjs`明确项目范围通过;未修改受保护历史资产。
- 本轮运行代码没有变化,测试环境仍运行已验证的`226527f1cef122e4dbd163da782485e8c0254dc5`,因此没有重复建立恢复资产或形式化重新部署。未访问预生产,未写入测试环境业务数据,未发送、补发或重投短信。
+26
View File
@@ -0,0 +1,26 @@
import eslint from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['**/dist/**', '**/node_modules/**', '**/coverage/**', 'api/vendor/**'] },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['src/**/*.{ts,tsx}', 'tools/**/*.mjs'],
languageOptions: { globals: { ...globals.browser, ...globals.node } },
plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh },
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
{
files: ['api/src/**/*.ts'],
languageOptions: { globals: { ...globals.node, ...globals.jest } },
rules: { '@typescript-eslint/no-explicit-any': 'warn' },
},
);
+3794 -7
View File
File diff suppressed because it is too large Load Diff
+21 -4
View File
@@ -2,6 +2,7 @@
"name": "cmpp-platform-frontend",
"version": "0.1.0",
"private": true,
"packageManager": "npm@10.9.4",
"type": "module",
"scripts": {
"dev": "npm run build && vite preview --host 0.0.0.0",
@@ -17,9 +18,12 @@
"spike:gateway": "powershell -NoProfile -ExecutionPolicy Bypass -Command \"$env:Path='C:\\Program Files\\Go\\bin;'+$env:Path; Push-Location gateway; go test ./...; Pop-Location\"",
"spike:bullmq": "node api/src/spike/bullmq-link-spike.mjs",
"test:api": "npm --prefix api test",
"test:frontend": "vitest run",
"test:frontend:coverage": "vitest run --coverage",
"test:auth-redis-integration": "node tools/testing/verify-auth-redis-multi-instance.mjs",
"test:gateway": "npm run spike:gateway",
"lint": "npm run quality:verify && tsc --noEmit",
"format:check": "git diff --check",
"lint": "npm run quality:verify && node tools/quality/run-changed-code-quality.mjs lint && tsc --noEmit",
"format:check": "git diff --check && node tools/quality/run-changed-code-quality.mjs format",
"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",
@@ -30,7 +34,7 @@
"verify:phase6": "npm run verify:phase5",
"verify:phase7": "npm run verify:phase6",
"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"
"verify:quality": "npm run lint && npm run format:check && npm run test:frontend:coverage && npm run build && npm run bundle:verify && npm --prefix api run test:incremental-coverage && npm --prefix api run test:coverage"
},
"dependencies": {
"@vitejs/plugin-react": "^6.0.2",
@@ -46,10 +50,23 @@
"zustand": "^5.0.14"
},
"devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.6",
"@types/node": "^25.9.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"typescript": "^6.0.3"
"@vitest/coverage-v8": "^4.1.11",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^29.1.1",
"msw": "^2.15.0",
"prettier": "^3.9.6",
"typescript": "^6.0.3",
"typescript-eslint": "^8.68.0",
"vitest": "^4.1.11"
},
"overrides": {
"nanoid": "3.3.18",
+312 -121
View File
@@ -1,10 +1,45 @@
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
import type { ApplicationCmppParams, CaptchaResponse, ClientApplicationReportField, ClientSignatureWorkspace, ClientSmsApplication, ClientSmsSignatureView, ClientSmsTemplate, DashboardResponse, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, EnterpriseCertification, FileObject, HttpApiConfigResponse, HttpApiCredential, HttpApiRequestLog, HttpWebhookDelivery, HttpWebhookEndpoint, ImportPreviewResponse, ManagedUser, OperationLogResponse, PagedResult, RechargeOrder, SmsBatchTask, SmsDrainageInfo, SmsMessageRecord, SmsUplinkMessage, SystemLogExportResult, UserPayload } from '../types';
import type {
ApplicationCmppParams,
CaptchaResponse,
ClientApplicationReportField,
ClientSignatureWorkspace,
ClientSmsApplication,
ClientSmsSignatureView,
ClientSmsTemplate,
DashboardResponse,
DeleteTargetRequest,
DeletionPreflight,
DeletionResult,
DeletionTargetType,
EnterpriseCertification,
FileObject,
HttpApiConfigResponse,
HttpApiCredential,
HttpApiRequestLog,
HttpWebhookDelivery,
HttpWebhookEndpoint,
ImportPreviewResponse,
ManagedUser,
OperationLogResponse,
PagedResult,
RechargeOrder,
SmsBatchTask,
SmsDrainageInfo,
SmsMessageRecord,
SmsUplinkMessage,
SystemLogExportResult,
} from '../types';
import { assertUploadFileSize } from '@/utils/fileUpload';
import type { LoginSession } from '../session';
import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, redirectToPortalLogin } from '../session';
import {
clearSession,
dispatchSessionEvent,
hasRecentUserActivity,
readSession,
redirectToPortalLogin,
} from '../session';
import { readErrorBody } from '../core/httpClient';
import { DEFAULT_CLIENT_TENANT_ID } from '../types';
// Client methods moved intact during R1; tenant and session behavior still flows
// through the shared HTTP client and the existing upload path below.
@@ -12,126 +47,277 @@ export const clientApi = {
getCaptcha: () => request<CaptchaResponse>('/client/auth/captcha'),
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
request<LoginSession>('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }),
listUsers: (
query: { displayName?: string; login?: string; status?: string } = {},
tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID,
) => request<ManagedUser[]>(withQuery('/client/users', query), { tenantId }),
createUser: (body: UserPayload, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>('/client/users', { method: 'POST', tenantId, body: JSON.stringify(body) }),
updateUser: (id: string, body: Omit<UserPayload, 'password'>, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>(`/client/users/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
changeUserStatus: (id: string, status: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>(`/client/users/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status, operatorId }) }),
deleteUser: (id: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>(`/client/users/${id}`, { method: 'DELETE', tenantId, body: JSON.stringify({ operatorId }) }),
changeUserPassword: (id: string, password: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ManagedUser>(`/client/users/${id}/password`, { method: 'POST', tenantId, body: JSON.stringify({ password, operatorId }) }),
getDashboard: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<DashboardResponse>('/client/operations/dashboard', { tenantId }),
listEnterpriseCertifications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<EnterpriseCertification[]>('/client/enterprise-certification', { tenantId }),
submitEnterpriseCertification: (body: { companyName: string; licenseNo?: string; contactName?: string; contactPhone?: string; materials?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
listUsers: (query: { displayName?: string; login?: string; status?: string } = {}) =>
request<ManagedUser[]>(withQuery('/client/users', query)),
createUser: (body: {
username?: string;
email?: string;
phone?: string;
displayName: string;
password: string;
status?: string;
}) => request<ManagedUser>('/client/users', { method: 'POST', body: JSON.stringify(body) }),
updateUser: (
id: string,
body: { username?: string; email?: string; phone?: string; displayName?: string; status?: string },
) => request<ManagedUser>(`/client/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeUserStatus: (id: string, status: string) =>
request<ManagedUser>(`/client/users/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
deleteUser: (id: string) => request<ManagedUser>(`/client/users/${id}`, { method: 'DELETE' }),
changeUserPassword: (id: string, password: string) =>
request<ManagedUser>(`/client/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password }) }),
getDashboard: () => request<DashboardResponse>('/client/operations/dashboard'),
listEnterpriseCertifications: () => request<EnterpriseCertification[]>('/client/enterprise-certification'),
submitEnterpriseCertification: (body: {
companyName: string;
licenseNo?: string;
contactName?: string;
contactPhone?: string;
materials?: Record<string, unknown>;
}) =>
request<EnterpriseCertification>('/client/enterprise-certification', {
method: 'POST',
tenantId,
body: JSON.stringify({ ...body, tenantId }),
body: JSON.stringify(body),
}),
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
exportSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) =>
request<SystemLogExportResult>('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
listOrdersPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<RechargeOrder>>(withQuery('/client/billing/orders', query), { tenantId }),
listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsApplication[]>('/client/applications', { tenantId }),
listApplicationsPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<ClientSmsApplication>>(withQuery('/client/applications', query), { tenantId }),
listApplicationOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsApplication[]>('/client/application-options', { tenantId }),
getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`, { tenantId }),
getApplicationHttpApiConfig: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiConfigResponse>(`/client/applications/${applicationId}/http-api`, { tenantId }),
listHttpApiCredentials: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiCredential[]>(`/client/applications/${applicationId}/http-api/credentials`, { tenantId }),
createHttpApiCredential: (applicationId: string, body: { name?: string; expiresAt?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiCredential>(`/client/applications/${applicationId}/http-api/credentials`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
revokeHttpApiCredential: (applicationId: string, credentialId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(`/client/applications/${applicationId}/http-api/credentials/${credentialId}/revoke`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
listHttpWebhooks: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpWebhookEndpoint[]>(`/client/applications/${applicationId}/http-api/webhooks`, { tenantId }),
saveHttpWebhook: (applicationId: string, eventType: 'receipt' | 'uplink', body: { url: string; rotateSecret?: boolean; status?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpWebhookEndpoint>(`/client/applications/${applicationId}/http-api/webhooks/${eventType}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
listHttpApiRequests: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiRequestLog[]>(`/client/applications/${applicationId}/http-api/requests`, { tenantId }),
listHttpWebhookDeliveries: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpWebhookDelivery[]>(`/client/applications/${applicationId}/http-api/webhook-deliveries`, { tenantId }),
retryHttpWebhookDelivery: (applicationId: string, deliveryId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(`/client/applications/${applicationId}/http-api/webhook-deliveries/${deliveryId}/retry`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
listApplicationReportFields: (applicationId: string, reportType: 'signature' | 'drainage' = 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientApplicationReportField[]>(withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }), { tenantId }),
listCommonApplicationReportFields: (reportType: 'signature' | 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType }), { tenantId }),
listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView[]>('/client/signatures', { tenantId }),
listSignatureOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView[]>('/client/signature-options', { tenantId }),
getSignatureWorkspace: (query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSignatureWorkspace>(withQuery('/client/signatures-workspace', query), { tenantId }),
createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView>('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
updateSignature: (id: string, body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView>(`/client/signatures/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
submitSignature: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView>(`/client/signatures/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
changeSignatureStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignatureView>(`/client/signatures/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
createSignatureMaterial: (id: string, body: { fileObjectId?: string; materialType: string; title: string; description?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<Record<string, unknown>>(`/client/signatures/${id}/materials`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsDrainageInfo>(`/client/signatures/${signatureId}/drainage-infos`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsDrainageInfo>(`/client/drainage-infos/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
changeDrainageInfoStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsDrainageInfo>(`/client/drainage-infos/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
listTemplates: (query: { status?: string; keyword?: string; includeHistory?: boolean } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate[]>(withQuery('/client/templates', {
status: query.status,
keyword: query.keyword,
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
}), { tenantId }),
listTemplatesPage: (query: { keyword?: string; includeHistory?: boolean; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<ClientSmsTemplate>>(withQuery('/client/templates', {
keyword: query.keyword,
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
page: query.page,
pageSize: query.pageSize,
}), { tenantId }),
createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate>('/client/templates', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
updateTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate>(`/client/templates/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
submitTemplate: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate>(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate>(`/client/templates/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
getDeletionPreflight: (type: Exclude<DeletionTargetType, 'channel'>, id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<DeletionPreflight>(`/client/deletions/${type}/${id}/preflight`, { tenantId }),
deleteGovernedTarget: (type: Exclude<DeletionTargetType, 'channel'>, id: string, body: DeleteTargetRequest, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<DeletionResult>(`/client/deletions/${type}/${id}`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask[]>(withQuery('/client/send/batch-tasks', query), { tenantId }),
listBatchTasksPage: (query: { status?: string; keyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<SmsBatchTask>>(withQuery('/client/send/batch-tasks', query), { tenantId }),
cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask>(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
createBatchTask: (body: { applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; variables?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask>('/client/send/batch-tasks', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
previewImport: (body: { applicationId?: string; content: string; fileName?: string; delimiter?: ',' | '\t'; requiredVariables?: string[] }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ImportPreviewResponse>('/client/send/imports/preview', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
confirmImport: (body: { applicationId?: string; templateId?: string; content: string; category?: string; importContent: string; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; requiredVariables?: string[]; variables?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask>('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
listBatchTaskMessages: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsMessageRecord[]>(`/client/send/batch-tasks/${id}/messages`, { tenantId }),
listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<SmsMessageRecord>>(withQuery('/client/operations/messages', query), { tenantId }),
listUplinkMessages: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsUplinkMessage[]>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
listUplinkMessagesPage: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<PagedResult<SmsUplinkMessage>>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
listSystemLogs: (query: {
keyword?: string;
level?: string;
module?: string;
range?: string;
createdAtFrom?: string;
createdAtTo?: string;
page?: number;
pageSize?: number;
}) => request<OperationLogResponse>(withQuery('/client/operations/system-logs', query)),
exportSystemLogs: (query: {
keyword?: string;
level?: string;
module?: string;
range?: string;
createdAtFrom?: string;
createdAtTo?: string;
}) =>
request<SystemLogExportResult>('/client/operations/system-logs/exports', {
method: 'POST',
body: JSON.stringify(query),
}),
listOrders: () => request<RechargeOrder[]>('/client/billing/orders'),
listOrdersPage: (query: { page: number; pageSize: number }) =>
request<PagedResult<RechargeOrder>>(withQuery('/client/billing/orders', query)),
listApplications: () => request<ClientSmsApplication[]>('/client/applications'),
listApplicationsPage: (query: { page: number; pageSize: number }) =>
request<PagedResult<ClientSmsApplication>>(withQuery('/client/applications', query)),
listApplicationOptions: () => request<ClientSmsApplication[]>('/client/application-options'),
getApplicationCmppParams: (applicationId: string) =>
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`),
getApplicationHttpApiConfig: (applicationId: string) =>
request<HttpApiConfigResponse>(`/client/applications/${applicationId}/http-api`),
listHttpApiCredentials: (applicationId: string) =>
request<HttpApiCredential[]>(`/client/applications/${applicationId}/http-api/credentials`),
createHttpApiCredential: (applicationId: string, body: { name?: string; expiresAt?: string }) =>
request<HttpApiCredential>(`/client/applications/${applicationId}/http-api/credentials`, {
method: 'POST',
body: JSON.stringify(body),
}),
revokeHttpApiCredential: (applicationId: string, credentialId: string) =>
request<{ id: string; status: string }>(
`/client/applications/${applicationId}/http-api/credentials/${credentialId}/revoke`,
{ method: 'POST', body: JSON.stringify({}) },
),
listHttpWebhooks: (applicationId: string) =>
request<HttpWebhookEndpoint[]>(`/client/applications/${applicationId}/http-api/webhooks`),
saveHttpWebhook: (
applicationId: string,
eventType: 'receipt' | 'uplink',
body: { url: string; rotateSecret?: boolean; status?: string },
) =>
request<HttpWebhookEndpoint>(`/client/applications/${applicationId}/http-api/webhooks/${eventType}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
listHttpApiRequests: (applicationId: string) =>
request<HttpApiRequestLog[]>(`/client/applications/${applicationId}/http-api/requests`),
listHttpWebhookDeliveries: (applicationId: string) =>
request<HttpWebhookDelivery[]>(`/client/applications/${applicationId}/http-api/webhook-deliveries`),
retryHttpWebhookDelivery: (applicationId: string, deliveryId: string) =>
request<{ id: string; status: string }>(
`/client/applications/${applicationId}/http-api/webhook-deliveries/${deliveryId}/retry`,
{ method: 'POST', body: JSON.stringify({}) },
),
listApplicationReportFields: (applicationId: string, reportType: 'signature' | 'drainage' = 'drainage') =>
request<ClientApplicationReportField[]>(
withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }),
),
listCommonApplicationReportFields: (reportType: 'signature' | 'drainage') =>
request<ClientApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType })),
listSignatures: () => request<ClientSmsSignatureView[]>('/client/signatures'),
listSignatureOptions: () => request<ClientSmsSignatureView[]>('/client/signature-options'),
getSignatureWorkspace: (
query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {},
) => request<ClientSignatureWorkspace>(withQuery('/client/signatures-workspace', query)),
createSignature: (body: {
applicationId?: string;
name: string;
purpose?: string;
drainageInfo?: Record<string, unknown>;
}) => request<ClientSmsSignatureView>('/client/signatures', { method: 'POST', body: JSON.stringify(body) }),
updateSignature: (
id: string,
body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record<string, unknown> },
) => request<ClientSmsSignatureView>(`/client/signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
submitSignature: (id: string) =>
request<ClientSmsSignatureView>(`/client/signatures/${id}/submit`, { method: 'POST', body: JSON.stringify({}) }),
changeSignatureStatus: (id: string, status: string) =>
request<ClientSmsSignatureView>(`/client/signatures/${id}/status`, {
method: 'POST',
body: JSON.stringify({ status }),
}),
createSignatureMaterial: (
id: string,
body: { fileObjectId?: string; materialType: string; title: string; description?: string },
) =>
request<Record<string, unknown>>(`/client/signatures/${id}/materials`, {
method: 'POST',
body: JSON.stringify(body),
}),
createDrainageInfo: (
signatureId: string,
body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> },
) =>
request<SmsDrainageInfo>(`/client/signatures/${signatureId}/drainage-infos`, {
method: 'POST',
body: JSON.stringify(body),
}),
updateDrainageInfo: (
id: string,
body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> },
) => request<SmsDrainageInfo>(`/client/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeDrainageInfoStatus: (id: string, status: string) =>
request<SmsDrainageInfo>(`/client/drainage-infos/${id}/status`, {
method: 'POST',
body: JSON.stringify({ status }),
}),
listTemplates: (query: { status?: string; keyword?: string; includeHistory?: boolean } = {}) =>
request<ClientSmsTemplate[]>(
withQuery('/client/templates', {
status: query.status,
keyword: query.keyword,
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
}),
),
listTemplatesPage: (query: { keyword?: string; includeHistory?: boolean; page: number; pageSize: number }) =>
request<PagedResult<ClientSmsTemplate>>(
withQuery('/client/templates', {
keyword: query.keyword,
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
page: query.page,
pageSize: query.pageSize,
}),
),
createTemplate: (body: {
applicationId: string;
signatureId?: string;
name: string;
content: string;
category?: string;
variables?: Array<{ name: string; example?: string; required?: boolean }>;
}) => request<ClientSmsTemplate>('/client/templates', { method: 'POST', body: JSON.stringify(body) }),
updateTemplate: (
id: string,
body: {
applicationId?: string;
signatureId?: string | null;
name?: string;
content?: string;
category?: string;
auditStatus?: string;
variables?: Array<{ name: string; example?: string; required?: boolean }>;
},
) => request<ClientSmsTemplate>(`/client/templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
submitTemplate: (id: string) =>
request<ClientSmsTemplate>(`/client/templates/${id}/submit`, { method: 'POST', body: JSON.stringify({}) }),
changeTemplateStatus: (id: string, status: string) =>
request<ClientSmsTemplate>(`/client/templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
getDeletionPreflight: (type: Exclude<DeletionTargetType, 'channel'>, id: string) =>
request<DeletionPreflight>(`/client/deletions/${type}/${id}/preflight`),
deleteGovernedTarget: (type: Exclude<DeletionTargetType, 'channel'>, id: string, body: DeleteTargetRequest) =>
request<DeletionResult>(`/client/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }),
listBatchTasks: (query: { status?: string } = {}) =>
request<SmsBatchTask[]>(withQuery('/client/send/batch-tasks', query)),
listBatchTasksPage: (query: {
status?: string;
keyword?: string;
applicationKeyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
page: number;
pageSize: number;
}) => request<PagedResult<SmsBatchTask>>(withQuery('/client/send/batch-tasks', query)),
cancelBatchTask: (id: string) =>
request<SmsBatchTask>(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', body: JSON.stringify({}) }),
createBatchTask: (body: {
applicationId?: string;
templateId?: string;
content: string;
category?: string;
phones: string[];
sendMode?: 'immediate' | 'scheduled';
scheduledAt?: string;
variables?: Record<string, unknown>;
}) => request<SmsBatchTask>('/client/send/batch-tasks', { method: 'POST', body: JSON.stringify(body) }),
previewImport: (body: {
applicationId?: string;
content: string;
fileName?: string;
delimiter?: ',' | '\t';
requiredVariables?: string[];
}) => request<ImportPreviewResponse>('/client/send/imports/preview', { method: 'POST', body: JSON.stringify(body) }),
confirmImport: (body: {
applicationId?: string;
templateId?: string;
content: string;
category?: string;
importContent: string;
sendMode?: 'immediate' | 'scheduled';
scheduledAt?: string;
requiredVariables?: string[];
variables?: Record<string, unknown>;
}) => request<SmsBatchTask>('/client/send/imports/confirm', { method: 'POST', body: JSON.stringify(body) }),
listBatchTaskMessages: (id: string) => request<SmsMessageRecord[]>(`/client/send/batch-tasks/${id}/messages`),
listMessages: (
query: {
applicationId?: string;
taskId?: string;
messageId?: string;
phoneNumber?: string;
contentKeyword?: string;
status?: string;
queuedAtFrom?: string;
queuedAtTo?: string;
page?: number;
pageSize?: number;
} = {},
) => request<PagedResult<SmsMessageRecord>>(withQuery('/client/operations/messages', query)),
listUplinkMessages: (
query: {
channelId?: string;
applicationId?: string;
phoneNumber?: string;
keyword?: string;
startTime?: string;
endTime?: string;
} = {},
) => request<SmsUplinkMessage[]>(withQuery('/client/operations/uplink-messages', query)),
listUplinkMessagesPage: (query: {
channelId?: string;
applicationId?: string;
phoneNumber?: string;
keyword?: string;
startTime?: string;
endTime?: string;
page: number;
pageSize: number;
}) => request<PagedResult<SmsUplinkMessage>>(withQuery('/client/operations/uplink-messages', query)),
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => {
assertUploadFileSize(file);
const form = new FormData();
@@ -143,7 +329,12 @@ export const clientApi = {
const headers = new Headers();
const session = readSession('client');
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
const response = await fetch('/api/client/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' });
const response = await fetch('/api/client/files/upload', {
method: 'POST',
headers,
body: form,
credentials: 'same-origin',
});
if (response.status === 401 && session) {
const error = await readErrorBody(response.clone());
if (error.code === 'SESSION_LOCKED') {
+221
View File
@@ -0,0 +1,221 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { fileDownloadUrl, readErrorBody, request, requestBlob, requestForm, withQuery } from './httpClient';
import { setReauthenticationHandler, writeSession } from '../session';
let receivedTenantHeader: string | null = null;
const server = setupServer(
http.get('http://localhost/api/client/users', ({ request: incoming }) => {
receivedTenantHeader = incoming.headers.get('x-tenant-id');
return HttpResponse.json([]);
}),
http.get('http://localhost/api/admin/users', ({ request: incoming }) => {
receivedTenantHeader = incoming.headers.get('x-tenant-id');
return HttpResponse.json([]);
}),
http.get('http://localhost/api/client/failure', () =>
HttpResponse.json({ message: '后端业务失败' }, { status: 500 }),
),
);
const nativeFetch = globalThis.fetch;
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
const interceptedFetch = globalThis.fetch;
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) =>
interceptedFetch(new URL(String(input), 'http://localhost'), init)) as typeof fetch;
});
afterEach(() => {
receivedTenantHeader = null;
server.resetHandlers();
});
afterAll(() => {
server.close();
globalThis.fetch = nativeFetch;
setReauthenticationHandler();
});
describe('request tenant and error boundaries', () => {
it('never forwards an explicit tenant header to a client route', async () => {
await expect(request('/client/users', { tenantId: 'tenant-attacker' })).resolves.toEqual([]);
expect(receivedTenantHeader).toBeNull();
});
it('keeps explicit tenant selection for non-client administrative routes', async () => {
await expect(request('/admin/users', { tenantId: 'tenant-admin-selected' })).resolves.toEqual([]);
expect(receivedTenantHeader).toBe('tenant-admin-selected');
});
it('surfaces a backend JSON error message instead of a generic status', async () => {
await expect(request('/client/failure')).rejects.toThrow('后端业务失败');
});
it.each([
[HttpResponse.json({ message: ['字段一', '字段二'] }, { status: 422 }), '字段一;字段二'],
[HttpResponse.json({ error: '网关错误' }, { status: 502 }), '网关错误'],
[new HttpResponse(null, { status: 503 }), '请求失败(503'],
])('normalizes additional server error body shapes', async (response, expected) => {
server.use(http.get('http://localhost/api/client/error-shape', () => response));
await expect(request('/client/error-shape')).rejects.toThrow(expected);
});
it('parses empty, JSON and plain-text error bodies safely', async () => {
await expect(readErrorBody(new Response())).resolves.toEqual({});
await expect(readErrorBody(HttpResponse.json({ code: 'BAD', message: ['字段一', '字段二'] }))).resolves.toEqual({
code: 'BAD',
message: ['字段一', '字段二'],
});
await expect(readErrorBody(new Response('代理错误'))).resolves.toEqual({ message: '代理错误' });
});
it('does not redirect a rejected login attempt', async () => {
server.use(
http.post('http://localhost/api/client/auth/login', () =>
HttpResponse.json({ message: 'Invalid login or password' }, { status: 401 }),
),
);
await expect(request('/client/auth/login', { method: 'POST', body: '{}' })).rejects.toThrow(
'Invalid login or password',
);
});
it('can suppress a session redirect for a background probe', async () => {
server.use(
http.get('http://localhost/api/client/probe', () => HttpResponse.json({ message: '未登录' }, { status: 401 })),
);
await expect(request('/client/probe', { suppressSessionRedirect: true })).rejects.toThrow('未登录');
});
it('raises the server lock message for an authenticated session', async () => {
writeSession({
portal: 'client',
user: { id: 'u', username: 'u', displayName: 'U', roles: [] },
idleTimeoutSeconds: 1,
lockRecoverySeconds: 1,
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
lastActivityAt: new Date().toISOString(),
recentAuthenticationExpiresAt: new Date().toISOString(),
});
server.use(
http.get('http://localhost/api/client/locked', () =>
HttpResponse.json({ code: 'SESSION_LOCKED', message: '会话测试锁定' }, { status: 401 }),
),
);
await expect(request('/client/locked')).rejects.toThrow('会话测试锁定');
});
it('reauthenticates once and retries a protected request', async () => {
writeSession({
portal: 'client',
user: { id: 'user-1', username: 'user', displayName: '用户', roles: ['enterprise_admin'] },
idleTimeoutSeconds: 7200,
lockRecoverySeconds: 14400,
absoluteExpiresAt: new Date(Date.now() + 3600000).toISOString(),
lastActivityAt: new Date().toISOString(),
recentAuthenticationExpiresAt: new Date().toISOString(),
});
let attempts = 0;
server.use(
http.post('http://localhost/api/client/protected', () => {
attempts += 1;
return attempts === 1
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
: HttpResponse.json({ success: true });
}),
);
const reauthenticate = vi.fn().mockResolvedValue(undefined);
setReauthenticationHandler(reauthenticate);
await expect(request('/client/protected', { method: 'POST', body: '{}' })).resolves.toEqual({ success: true });
expect(reauthenticate).toHaveBeenCalledOnce();
});
it('downloads blobs and preserves server text errors', async () => {
server.use(
http.get('http://localhost/api/client/file-ok', () => new HttpResponse('file-data', { status: 200 })),
http.get('http://localhost/api/client/file-fail', () => new HttpResponse('文件不存在', { status: 404 })),
);
await expect((await requestBlob('/client/file-ok')).text()).resolves.toBe('file-data');
await expect(requestBlob('/client/file-fail')).rejects.toThrow('文件不存在');
});
it('retries blob downloads after recent authentication and supports admin tenant selection', async () => {
writeSession({
portal: 'admin',
user: { id: 'a', username: 'a', displayName: 'A', roles: ['platform_admin'] },
idleTimeoutSeconds: 1,
lockRecoverySeconds: 1,
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
lastActivityAt: new Date().toISOString(),
recentAuthenticationExpiresAt: new Date().toISOString(),
});
let attempts = 0;
server.use(
http.get('http://localhost/api/admin/export', ({ request: incoming }) => {
attempts += 1;
receivedTenantHeader = incoming.headers.get('x-tenant-id');
return attempts === 1
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
: new HttpResponse('csv');
}),
);
setReauthenticationHandler(vi.fn().mockResolvedValue(undefined));
await expect((await requestBlob('/admin/export', { tenantId: 'tenant-1' })).text()).resolves.toBe('csv');
expect(receivedTenantHeader).toBe('tenant-1');
});
it('submits multipart forms without forcing a JSON content type', async () => {
let contentType = '';
server.use(
http.post('http://localhost/api/client/upload', ({ request: incoming }) => {
contentType = incoming.headers.get('content-type') ?? '';
return HttpResponse.json({ id: 'file-1' });
}),
);
const form = new FormData();
form.set('file', new Blob(['data']), 'data.txt');
await expect(requestForm('/client/upload', form)).resolves.toEqual({ id: 'file-1' });
expect(contentType).toContain('multipart/form-data; boundary=');
});
it('retries multipart forms after recent authentication and reports failures', async () => {
writeSession({
portal: 'client',
user: { id: 'u', username: 'u', displayName: 'U', roles: [] },
idleTimeoutSeconds: 1,
lockRecoverySeconds: 1,
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
lastActivityAt: new Date().toISOString(),
recentAuthenticationExpiresAt: new Date().toISOString(),
});
let attempts = 0;
server.use(
http.post('http://localhost/api/client/form-protected', () => {
attempts += 1;
return attempts === 1
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
: HttpResponse.json({ ok: true });
}),
);
setReauthenticationHandler(vi.fn().mockResolvedValue(undefined));
await expect(requestForm('/client/form-protected', new FormData())).resolves.toEqual({ ok: true });
server.use(
http.post('http://localhost/api/client/form-fail', () =>
HttpResponse.json({ error: '上传失败' }, { status: 400 }),
),
);
await expect(requestForm('/client/form-fail', new FormData())).rejects.toThrow('上传失败');
});
it('builds bounded queries and encoded download URLs', () => {
expect(withQuery('/client/messages', { page: 2, status: 'all', keyword: '', level: 'warn' })).toBe(
'/client/messages?page=2&level=warn',
);
expect(fileDownloadUrl('folder/file 1', 'inline', 'client')).toBe(
'/api/client/files/folder%2Ffile%201/download?disposition=inline',
);
expect(withQuery('/client/messages', { status: undefined })).toBe('/client/messages');
expect(fileDownloadUrl('file-1')).toBe('/api/admin/files/file-1/download?disposition=attachment');
});
});
+17 -13
View File
@@ -2,7 +2,6 @@ import {
clearSession,
currentRouteForPortal,
dispatchSessionEvent,
getSessionTenantId,
hasRecentUserActivity,
portalFromPath,
readSession,
@@ -19,7 +18,6 @@ type RequestOptions = RequestInit & {
suppressSessionRedirect?: boolean;
};
type ApiErrorBody = { message?: string | string[]; error?: string; code?: string };
export async function readErrorBody(response: Response): Promise<ApiErrorBody> {
@@ -32,8 +30,14 @@ export async function readErrorBody(response: Response): Promise<ApiErrorBody> {
}
}
export type SessionTiming = Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>;
export type SessionTiming = Pick<
LoginSession,
| 'idleTimeoutSeconds'
| 'lockRecoverySeconds'
| 'absoluteExpiresAt'
| 'lastActivityAt'
| 'recentAuthenticationExpiresAt'
>;
// Authentication failures are handled centrally so every domain API keeps the
// same lock, recovery and redirect behavior as the original adminApi facade.
@@ -87,9 +91,8 @@ export async function request<T>(path: string, options: RequestOptions = {}): Pr
const portal = requestPortal(path);
const session = portal ? readSession(portal) : null;
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
if (tenantId) {
headers.set('x-tenant-id', tenantId);
if (options.tenantId && !path.startsWith('/client')) {
headers.set('x-tenant-id', options.tenantId);
}
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login';
@@ -114,9 +117,8 @@ export async function requestBlob(path: string, options: RequestOptions = {}): P
const portal = requestPortal(path);
const session = portal ? readSession(portal) : null;
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
if (tenantId) {
headers.set('x-tenant-id', tenantId);
if (options.tenantId && !path.startsWith('/client')) {
headers.set('x-tenant-id', options.tenantId);
}
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
if (response.status === 401) {
@@ -156,7 +158,6 @@ export async function requestForm<T>(path: string, form: FormData, reauthenticat
return response.json() as Promise<T>;
}
export function withQuery(path: string, query: Record<string, string | number | undefined>) {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
@@ -168,7 +169,10 @@ export function withQuery(path: string, query: Record<string, string | number |
return `${path}${suffix}`;
}
export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment', portal: Portal = 'admin') {
export function fileDownloadUrl(
fileObjectId: string,
disposition: 'attachment' | 'inline' = 'attachment',
portal: Portal = 'admin',
) {
return `/api/${portal}/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`;
}
+9 -6
View File
@@ -1,14 +1,17 @@
// R1 compatibility types. Keep public names re-exported from src/api/adminApi.ts.
export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
export type DeletionTargetType = 'channel' | 'signature' | 'template';
export type DeletionResolutionAction = 'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks';
export type DeletionResolutionAction =
'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks';
export type DeletionDependency = { kind: string; label: string; count: number; items: string[]; detailsVisible: boolean };
export type DeletionDependency = {
kind: string;
label: string;
count: number;
items: string[];
detailsVisible: boolean;
};
export type DeletionRequiredSelection = {
action: DeletionResolutionAction;
+95
View File
@@ -0,0 +1,95 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LoginPage, loginErrorMessage } from './LoginPage';
const { getCaptcha, login } = vi.hoisted(() => ({ getCaptcha: vi.fn(), login: vi.fn() }));
vi.mock('@/api/adminApi', () => ({
adminApi: { getCaptcha, login },
clientApi: { getCaptcha, login },
}));
vi.mock('@/components/ui', () => ({
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
ClientLoginCanvas: () => <div data-testid="client-canvas" />,
Input: ({ label, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) => (
<label>
{label}
<input {...props} />
</label>
),
Modal: ({ children, open, title }: { children: React.ReactNode; open: boolean; title: string }) =>
open ? (
<div role="dialog" aria-label={title}>
{children}
</div>
) : null,
}));
describe('LoginPage', () => {
beforeEach(() => {
getCaptcha
.mockReset()
.mockResolvedValue({ captchaId: 'captcha-1', challenge: '12 + 3 = ?', expiresInSeconds: 300 });
login.mockReset();
});
it('renders client-only website return action and loads a captcha', async () => {
render(
<MemoryRouter>
<LoginPage portal="client" />
</MemoryRouter>,
);
expect(screen.getByRole('link', { name: '返回官网' })).toHaveAttribute('href', 'https://www.lisglo.com');
expect(await screen.findByRole('button', { name: '12 + 3 = ?' })).toBeEnabled();
});
it('maps backend login errors to Chinese and refreshes the one-time captcha', async () => {
login.mockRejectedValue(new Error('Invalid login or password'));
render(
<MemoryRouter>
<LoginPage portal="client" />
</MemoryRouter>,
);
await screen.findByRole('button', { name: '12 + 3 = ?' });
fireEvent.change(screen.getByLabelText('用户名/登录账号'), { target: { value: 'bad-user' } });
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'bad-password' } });
fireEvent.change(screen.getByLabelText('图形验证码'), { target: { value: '15' } });
fireEvent.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => expect(screen.getAllByText('用户名或密码错误')).toHaveLength(2));
expect(getCaptcha).toHaveBeenCalledTimes(2);
});
it('does not render client artwork or the website link for admin login', async () => {
render(
<MemoryRouter>
<LoginPage portal="admin" />
</MemoryRouter>,
);
await screen.findByRole('button', { name: '12 + 3 = ?' });
expect(screen.queryByTestId('client-canvas')).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: '返回官网' })).not.toBeInTheDocument();
expect(screen.getByText('运营端登录')).toBeVisible();
});
it.each([
['login and password are required', '请输入用户名和密码'],
['Captcha expired', '验证码已过期,请刷新后重试'],
['Captcha is incorrect', '验证码错误,请重新输入'],
['User is locked', '账号已锁定,请 24 小时后再试'],
['Too many login attempts from this source', '登录尝试过于频繁,请稍后再试'],
['User is disabled or deleted', '账号已停用或已删除'],
['Only platform admins can login to admin portal', '该账号不是运营端管理员'],
['Only enterprise admins linked to a tenant can login to client portal', '该账号不是已绑定企业的客户端管理员'],
['unknown backend error', 'unknown backend error'],
])('maps backend error %s to a stable user message', (backend, expected) => {
expect(loginErrorMessage(new Error(backend))).toBe(expected);
});
it('uses a safe fallback for non-error failures', () => {
expect(loginErrorMessage(null)).toBe('登录失败,请检查账号信息后重试');
});
});
+38 -8
View File
@@ -1,23 +1,31 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi';
import { consumeSessionRecovery, markUserActivity, readSessionRecovery, writeSession, type Portal } from '@/api/session';
import {
consumeSessionRecovery,
markUserActivity,
readSessionRecovery,
writeSession,
type Portal,
} from '@/api/session';
import { Button, ClientLoginCanvas, Input, Modal } from '@/components/ui';
type LoginPageProps = {
portal: Portal;
};
function loginErrorMessage(err: unknown) {
export function loginErrorMessage(err: unknown) {
const message = err instanceof Error ? err.message : '';
if (message.includes('Invalid login or password')) return '用户名或密码错误';
if (message.includes('login and password are required')) return '请输入用户名和密码';
if (message.includes('Captcha expired')) return '验证码已过期,请刷新后重试';
if (message.includes('Captcha is incorrect')) return '验证码错误,请重新输入';
if (message.includes('User is locked')) return '账号已锁定,请 24 小时后再试';
if (message.includes('Too many login attempts')) return '登录尝试过于频繁,请稍后再试';
if (message.includes('User is disabled or deleted')) return '账号已停用或已删除';
if (message.includes('Only platform admins can login to admin portal')) return '该账号不是运营端管理员';
if (message.includes('Only enterprise admins linked to a tenant can login to client portal')) return '该账号不是已绑定企业的客户端管理员';
if (message.includes('Only enterprise admins linked to a tenant can login to client portal'))
return '该账号不是已绑定企业的客户端管理员';
return message || '登录失败,请检查账号信息后重试';
}
@@ -90,17 +98,39 @@ export function LoginPage({ portal }: LoginPageProps) {
{recovery.message ?? '登录会话已失效,请重新登录。'} 访
</p>
) : null}
<Input label="用户名/登录账号" onChange={(event) => setLogin(event.target.value)} placeholder="请输入用户名、邮箱或手机号" value={login} />
<Input label="密码" onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" type="password" value={password} />
<Input
label="用户名/登录账号"
onChange={(event) => setLogin(event.target.value)}
placeholder="请输入用户名、邮箱或手机号"
value={login}
/>
<Input
label="密码"
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
type="password"
value={password}
/>
<div className="login-captcha-row">
<Input label="图形验证码" onChange={(event) => setCaptchaText(event.target.value)} placeholder="请输入计算结果" value={captchaText} />
<Input
label="图形验证码"
onChange={(event) => setCaptchaText(event.target.value)}
placeholder="请输入计算结果"
value={captchaText}
/>
<button className="login-captcha" onClick={() => void refreshCaptcha()} type="button">
{captcha?.challenge ?? '刷新'}
</button>
</div>
{error ? <p className="login-error">{error}</p> : null}
<Button disabled={loading} onClick={submit}>{loading ? '登录中...' : '登录'}</Button>
{!isAdmin ? <a className="login-return-link" href="https://www.lisglo.com"></a> : null}
<Button disabled={loading} onClick={submit}>
{loading ? '登录中...' : '登录'}
</Button>
{!isAdmin ? (
<a className="login-return-link" href="https://www.lisglo.com">
</a>
) : null}
</div>
</section>
<Modal
+143
View File
@@ -0,0 +1,143 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ClientUsersPage } from './ClientUsersPage';
const { clientApi } = vi.hoisted(() => ({
clientApi: {
listUsers: vi.fn(),
deleteUser: vi.fn(),
changeUserStatus: vi.fn(),
createUser: vi.fn(),
updateUser: vi.fn(),
changeUserPassword: vi.fn(),
},
}));
vi.mock('@/api/adminApi', () => ({ clientApi }));
vi.mock('@/components/ui', () => ({
Button: ({
children,
icon: _icon,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { icon?: React.ReactNode }) => (
<button {...props}>{children}</button>
),
Input: ({ label, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) => (
<label>
{label}
<input {...props} />
</label>
),
Select: ({
label,
options,
...props
}: React.SelectHTMLAttributes<HTMLSelectElement> & {
label: string;
options: Array<{ label: string; value: string }>;
}) => (
<label>
{label}
<select {...props}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
),
Tag: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
Table: ({
columns,
data,
emptyText,
}: {
columns: Array<{ key: string; render?: (record: never) => React.ReactNode }>;
data: never[];
emptyText: string;
}) =>
data.length ? (
<div>
{data.map((row: never, index: number) => (
<div key={index}>
{columns.map((column) => (
<span key={column.key}>{column.render?.(row)}</span>
))}
</div>
))}
</div>
) : (
<p>{emptyText}</p>
),
Modal: ({
children,
footer,
open,
title,
}: {
children: React.ReactNode;
footer: React.ReactNode;
open: boolean;
title: string;
}) =>
open ? (
<div role="dialog" aria-label={title}>
{children}
{footer}
</div>
) : null,
}));
const user = {
id: 'user-1',
displayName: '截图用户',
username: 'screenshot',
email: 'user@example.com',
phone: '13800000000',
status: 'active',
roles: ['enterprise_admin'],
};
describe('ClientUsersPage states', () => {
beforeEach(() => {
Object.values(clientApi).forEach((mock) => mock.mockReset());
});
it('renders the empty state from a successful real API-shaped response', async () => {
clientApi.listUsers.mockResolvedValue([]);
render(<ClientUsersPage />);
expect(await screen.findByText('暂无用户')).toBeVisible();
});
it('renders a backend loading error', async () => {
clientApi.listUsers.mockRejectedValue(new Error('用户服务暂不可用'));
render(<ClientUsersPage />);
expect(await screen.findByText('用户服务暂不可用')).toBeVisible();
});
it('shows query loading and requires confirmation before deletion', async () => {
let resolveQuery: (value: (typeof user)[]) => void = () => undefined;
clientApi.listUsers
.mockResolvedValueOnce([user])
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveQuery = resolve;
}),
)
.mockResolvedValueOnce([]);
clientApi.deleteUser.mockResolvedValue(user);
render(<ClientUsersPage />);
await screen.findByText('截图用户');
fireEvent.click(screen.getByRole('button', { name: '查询' }));
expect(screen.getByRole('button', { name: '查询中...' })).toBeDisabled();
resolveQuery([user]);
await screen.findByRole('button', { name: '查询' });
fireEvent.click(screen.getByRole('button', { name: '删除' }));
expect(screen.getByRole('dialog', { name: '删除用户' })).toHaveTextContent('确认删除用户 截图用户');
expect(clientApi.deleteUser).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => expect(clientApi.deleteUser).toHaveBeenCalledWith('user-1'));
});
});
+256 -69
View File
@@ -1,8 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react';
import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi';
import { clientApi, type ManagedUser } from '@/api/adminApi';
import { formatDateTime } from '@/utils/dateTime';
import { readSession } from '@/api/session';
import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
import './ClientUsersPage.css';
@@ -42,19 +41,19 @@ const emptyFilters: UserFilters = {
};
function toForm(user?: ManagedUser): UserForm {
return user ? {
displayName: user.displayName,
username: user.username,
email: user.email ?? '',
phone: user.phone ?? '',
status: user.status,
password: '',
} : emptyForm;
return user
? {
displayName: user.displayName,
username: user.username,
email: user.email ?? '',
phone: user.phone ?? '',
status: user.status,
password: '',
}
: emptyForm;
}
export function ClientUsersPage() {
const session = readSession('client');
const tenantId = session?.user.tenantId ?? undefined;
const [users, setUsers] = useState<ManagedUser[]>([]);
const [filters, setFilters] = useState<UserFilters>(emptyFilters);
const [appliedFilters, setAppliedFilters] = useState<UserFilters>(emptyFilters);
@@ -72,16 +71,15 @@ export function ClientUsersPage() {
const [querying, setQuerying] = useState(false);
async function loadUsers(query: UserFilters = appliedFilters) {
if (!tenantId) return;
setUsers(await clientApi.listUsers(query, tenantId));
setUsers(await clientApi.listUsers(query));
}
useEffect(() => {
if (!tenantId) return;
void clientApi.listUsers({}, tenantId)
void clientApi
.listUsers({})
.then(setUsers)
.catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
}, [tenantId]);
}, []);
function updateFilter<Key extends keyof UserFilters>(key: Key, value: UserFilters[Key]) {
setFilters((current) => ({ ...current, [key]: value }));
@@ -122,26 +120,28 @@ export function ClientUsersPage() {
}
async function saveUser() {
if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6)) {
if (
!form.displayName.trim() ||
(!form.email.trim() && !form.phone.trim()) ||
(creating && form.password.length < 6)
) {
setFormError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位');
return;
}
setSaving(true);
setFormError('');
const body: UserPayload = {
const body = {
displayName: form.displayName,
username: form.username || form.email || form.phone,
email: form.email,
phone: form.phone,
status: form.status,
roleCode: 'enterprise_admin',
operatorId: session?.user.id,
};
try {
if (creating) {
await clientApi.createUser({ ...body, password: form.password }, tenantId);
await clientApi.createUser({ ...body, password: form.password });
} else if (editingUser) {
await clientApi.updateUser(editingUser.id, body, tenantId);
await clientApi.updateUser(editingUser.id, body);
}
setCreating(false);
setEditingUser(null);
@@ -159,9 +159,12 @@ export function ClientUsersPage() {
setConfirmError('');
try {
if (confirmAction.type === 'delete') {
await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId);
await clientApi.deleteUser(confirmAction.user.id);
} else {
await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId);
await clientApi.changeUserStatus(
confirmAction.user.id,
confirmAction.user.status === 'active' ? 'disabled' : 'active',
);
}
} catch (failure) {
const detail = failure instanceof Error ? failure.message : '用户操作失败';
@@ -181,55 +184,136 @@ export function ClientUsersPage() {
async function savePassword() {
if (!passwordUser) return;
await clientApi.changeUserPassword(passwordUser.id, newPassword, session?.user.id, tenantId);
await clientApi.changeUserPassword(passwordUser.id, newPassword);
setPasswordUser(null);
setNewPassword('');
}
const columns = useMemo<Array<TableColumn<ManagedUser>>>(() => [
{ key: 'name', title: '用户名', width: '140px', render: (record) => <strong className="text-strong">{record.displayName}</strong> },
{ key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email ?? '-'}</span> },
{ key: 'phone', title: '手机号', width: '160px', render: (record) => <span className="muted">{record.phone ?? '-'}</span> },
{ key: 'role', title: '角色', width: '130px', render: () => <Tag tone="info"></Tag> },
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag> },
{ key: 'lastLoginAt', title: '最后登录时间', width: '190px', render: (record) => <span className="muted">{formatDateTime(record.lastLoginAt)}</span> },
{
key: 'actions',
title: '操作',
width: '290px',
render: (record) => (
<div className="inline-actions client-user-actions" aria-label={`${record.displayName}的用户操作`}>
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost"></Button>
<Button icon={<KeyRound size={15} />} onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost"></Button>
<Button onClick={() => openConfirm({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>{record.status === 'active' ? '禁用' : '启用'}</Button>
<Button icon={<Trash2 size={15} />} onClick={() => openConfirm({ type: 'delete', user: record })} size="sm" variant="danger"></Button>
</div>
),
},
], []);
const columns = useMemo<Array<TableColumn<ManagedUser>>>(
() => [
{
key: 'name',
title: '用户名',
width: '140px',
render: (record) => <strong className="text-strong">{record.displayName}</strong>,
},
{ key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email ?? '-'}</span> },
{
key: 'phone',
title: '手机号',
width: '160px',
render: (record) => <span className="muted">{record.phone ?? '-'}</span>,
},
{ key: 'role', title: '角色', width: '130px', render: () => <Tag tone="info"></Tag> },
{
key: 'status',
title: '状态',
width: '120px',
render: (record) => (
<Tag tone={record.status === 'active' ? 'success' : 'neutral'}>
{record.status === 'active' ? '正常' : '禁用'}
</Tag>
),
},
{
key: 'lastLoginAt',
title: '最后登录时间',
width: '190px',
render: (record) => <span className="muted">{formatDateTime(record.lastLoginAt)}</span>,
},
{
key: 'actions',
title: '操作',
width: '290px',
render: (record) => (
<div className="inline-actions client-user-actions" aria-label={`${record.displayName}的用户操作`}>
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost">
</Button>
<Button
icon={<KeyRound size={15} />}
onClick={() => {
setPasswordUser(record);
setNewPassword('');
}}
size="sm"
variant="ghost"
>
</Button>
<Button
onClick={() => openConfirm({ type: 'status', user: record })}
size="sm"
variant={record.status === 'active' ? 'warning' : 'success'}
>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
<Button
icon={<Trash2 size={15} />}
onClick={() => openConfirm({ type: 'delete', user: record })}
size="sm"
variant="danger"
>
</Button>
</div>
),
},
],
[],
);
return (
<section className="page-stack system-page">
<div className="system-page-toolbar">
<div className="sms-send-title">
<span className="sms-send-title__icon"><Users size={22} /></span>
<span className="sms-send-title__icon">
<Users size={22} />
</span>
<h1></h1>
</div>
<Button icon={<Plus size={16} />} onClick={() => openEditor()} size="sm"></Button>
<Button icon={<Plus size={16} />} onClick={() => openEditor()} size="sm">
</Button>
</div>
<div className="surface system-filter-row client-user-filter">
<Input label="用户姓名" onChange={(event) => updateFilter('displayName', event.target.value)} placeholder="请输入用户姓名" value={filters.displayName} />
<Input label="登录账号" onChange={(event) => updateFilter('login', event.target.value)} placeholder="用户名、邮箱或手机号" value={filters.login} />
<Input
label="用户姓名"
onChange={(event) => updateFilter('displayName', event.target.value)}
placeholder="请输入用户姓名"
value={filters.displayName}
/>
<Input
label="登录账号"
onChange={(event) => updateFilter('login', event.target.value)}
placeholder="用户名、邮箱或手机号"
value={filters.login}
/>
<Select
label="状态"
onChange={(event) => updateFilter('status', event.target.value)}
options={[{ label: '全部状态', value: '' }, { label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]}
options={[
{ label: '全部状态', value: '' },
{ label: '正常', value: 'active' },
{ label: '禁用', value: 'disabled' },
]}
value={filters.status}
/>
<div className="client-user-filter__actions">
<Button disabled={querying} icon={<Search size={16} />} onClick={() => void queryUsers()}>{querying ? '查询中...' : '查询'}</Button>
<Button disabled={querying} onClick={() => { setFilters(emptyFilters); void queryUsers(emptyFilters); }} variant="secondary"></Button>
<Button disabled={querying} icon={<Search size={16} />} onClick={() => void queryUsers()}>
{querying ? '查询中...' : '查询'}
</Button>
<Button
disabled={querying}
onClick={() => {
setFilters(emptyFilters);
void queryUsers(emptyFilters);
}}
variant="secondary"
>
</Button>
</div>
</div>
{error ? <div className="surface empty-state">{error}</div> : null}
@@ -237,38 +321,141 @@ export function ClientUsersPage() {
<Table columns={columns} data={users} emptyText="暂无用户" rowKey="id" />
</div>
{(creating || editingUser) ? (
{creating || editingUser ? (
<Modal
footer={<><Button disabled={saving} onClick={() => { setCreating(false); setEditingUser(null); }} variant="secondary"></Button><Button disabled={saving} onClick={() => void saveUser()}>{saving ? '保存中...' : '保存'}</Button></>}
onClose={() => { setCreating(false); setEditingUser(null); }}
footer={
<>
<Button
disabled={saving}
onClick={() => {
setCreating(false);
setEditingUser(null);
}}
variant="secondary"
>
</Button>
<Button disabled={saving} onClick={() => void saveUser()}>
{saving ? '保存中...' : '保存'}
</Button>
</>
}
onClose={() => {
setCreating(false);
setEditingUser(null);
}}
open
size="xl"
title={creating ? '添加用户' : '编辑用户'}
>
<div className="system-user-form">
<Input label="用户名 *" onChange={(event) => updateField('displayName', event.target.value)} placeholder="请输入用户名" value={form.displayName} />
<Input label="邮箱 *" onChange={(event) => updateField('email', event.target.value)} placeholder="请输入邮箱" value={form.email} />
<Input label="手机号 *" onChange={(event) => updateField('phone', event.target.value)} placeholder="请输入手机号" value={form.phone} />
<Input hint="可用用户名、邮箱或手机号登录" label="用户名/登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
{creating ? <Input label="初始密码 *" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null}
<Select label="状态 *" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
{formError ? <p className="form-error" role="alert">{formError}</p> : null}
<Input
label="用户名 *"
onChange={(event) => updateField('displayName', event.target.value)}
placeholder="请输入用户名"
value={form.displayName}
/>
<Input
label="邮箱 *"
onChange={(event) => updateField('email', event.target.value)}
placeholder="请输入邮箱"
value={form.email}
/>
<Input
label="手机号 *"
onChange={(event) => updateField('phone', event.target.value)}
placeholder="请输入手机号"
value={form.phone}
/>
<Input
hint="可用用户名、邮箱或手机号登录"
label="用户名/登录账号"
onChange={(event) => updateField('username', event.target.value)}
value={form.username}
/>
{creating ? (
<Input
label="初始密码 *"
onChange={(event) => updateField('password', event.target.value)}
type="password"
value={form.password}
/>
) : null}
<Select
label="状态 *"
onChange={(event) => updateField('status', event.target.value)}
options={[
{ label: '正常', value: 'active' },
{ label: '禁用', value: 'disabled' },
]}
value={form.status}
/>
{formError ? (
<p className="form-error" role="alert">
{formError}
</p>
) : null}
</div>
</Modal>
) : null}
{passwordUser ? (
<Modal footer={<><Button onClick={() => setPasswordUser(null)} variant="secondary"></Button><Button onClick={() => void savePassword()}></Button></>} onClose={() => setPasswordUser(null)} open title="修改密码">
<Modal
footer={
<>
<Button onClick={() => setPasswordUser(null)} variant="secondary">
</Button>
<Button onClick={() => void savePassword()}></Button>
</>
}
onClose={() => setPasswordUser(null)}
open
title="修改密码"
>
<div className="system-user-form">
<Input label="新密码" onChange={(event) => setNewPassword(event.target.value)} type="password" value={newPassword} />
<Input
label="新密码"
onChange={(event) => setNewPassword(event.target.value)}
type="password"
value={newPassword}
/>
</div>
</Modal>
) : null}
{confirmAction ? (
<Modal footer={<><Button disabled={confirming} onClick={() => setConfirmAction(null)} variant="secondary"></Button><Button disabled={confirming} onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>{confirming ? '处理中...' : '确认'}</Button></>} onClose={() => { if (!confirming) setConfirmAction(null); }} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
<p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}`}</p>
{confirmError ? <p className="form-error" role="alert">{confirmError}</p> : null}
<Modal
footer={
<>
<Button disabled={confirming} onClick={() => setConfirmAction(null)} variant="secondary">
</Button>
<Button
disabled={confirming}
onClick={() => void runConfirm()}
variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}
>
{confirming ? '处理中...' : '确认'}
</Button>
</>
}
onClose={() => {
if (!confirming) setConfirmAction(null);
}}
open
title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}
>
<p>
{confirmAction.type === 'delete'
? `确认删除用户 ${confirmAction.user.displayName}`
: `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}`}
</p>
{confirmError ? (
<p className="form-error" role="alert">
{confirmError}
</p>
) : null}
</Modal>
) : null}
</section>
+29
View File
@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { RouteLoadBoundary } from './RouteLoadBoundary';
function BrokenRoute(): never {
throw new Error('chunk load failed');
}
describe('RouteLoadBoundary', () => {
it('shows a recoverable Chinese error state when a route chunk throws', () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
render(
<RouteLoadBoundary>
<BrokenRoute />
</RouteLoadBoundary>,
);
expect(screen.getByRole('alert')).toHaveTextContent('页面资源加载失败');
expect(screen.getByRole('button', { name: '重新加载' })).toBeEnabled();
});
it('renders healthy route content unchanged', () => {
render(
<RouteLoadBoundary>
<p></p>
</RouteLoadBoundary>,
);
expect(screen.getByText('正常页面')).toBeVisible();
});
});
+9
View File
@@ -0,0 +1,9 @@
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
window.localStorage.clear();
window.sessionStorage.clear();
});
@@ -0,0 +1,31 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { resolve } from 'node:path';
const root = resolve(import.meta.dirname, '../..');
const mode = process.argv[2];
if (!['lint', 'format'].includes(mode)) throw new Error('Usage: node run-changed-code-quality.mjs <lint|format>');
const base = process.env.QUALITY_BASE_REF ?? 'HEAD';
const tracked = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACMR', base, '--'], {
cwd: root,
encoding: 'utf8',
});
const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: root, encoding: 'utf8' });
const files = [...new Set(`${tracked}\n${untracked}`.split(/\r?\n/).filter(Boolean))].filter((file) =>
/^(?:src|api\/src|tools)\/.+\.(?:[cm]?[jt]sx?)$/.test(file),
);
if (!files.length) {
console.log(`No changed code files require ${mode}.`);
process.exit(0);
}
const executable =
process.platform === 'win32'
? `${mode === 'lint' ? 'eslint' : 'prettier'}.cmd`
: mode === 'lint'
? 'eslint'
: 'prettier';
const args = mode === 'lint' ? files : ['--check', ...files];
const result = spawnSync(resolve(root, 'node_modules/.bin', executable), args, { cwd: root, stdio: 'inherit' });
process.exit(result.status ?? 1);
+63 -12
View File
@@ -1,19 +1,28 @@
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { existsSync, 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);
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);
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');
@@ -23,8 +32,26 @@ for (const file of clientControllers) {
}
}
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 clientApi = readFileSync(resolve(root, 'src/api/client/client.api.ts'), 'utf8');
if (/DEFAULT_CLIENT_TENANT_ID|getSessionTenantId|x-tenant-id/.test(clientApi)) {
violations.push(
'src/api/client/client.api.ts: client requests must derive tenant scope from the authenticated server session',
);
}
const httpClient = readFileSync(resolve(root, 'src/api/core/httpClient.ts'), 'utf8');
if (
/DEFAULT_CLIENT_TENANT_ID|getSessionTenantId/.test(httpClient) ||
!httpClient.includes("options.tenantId && !path.startsWith('/client')")
) {
violations.push('src/api/core/httpClient.ts: tenant headers must be reserved for explicit non-client operations');
}
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)) {
@@ -35,14 +62,20 @@ if (/passwordHash\s*===|===\s*[^\n;]*passwordHash/.test(authService)) {
violations.push('api/src/auth/auth.service.ts: password hashes must not be compared directly');
}
const ensureAdmin = readFileSync(resolve(root, 'tools/deploy/ensure-production-admin.mjs'), 'utf8');
if (/createHash\(['"]sha256['"]\)|function\s+hashPassword\s*\(/.test(ensureAdmin)
|| !ensureAdmin.includes("api/dist/auth/password-hasher.js")) {
violations.push('tools/deploy/ensure-production-admin.mjs: administrative password writes must use the API password hasher');
if (
/createHash\(['"]sha256['"]\)|function\s+hashPassword\s*\(/.test(ensureAdmin) ||
!ensureAdmin.includes('api/dist/auth/password-hasher.js')
) {
violations.push(
'tools/deploy/ensure-production-admin.mjs: administrative password writes must use the API password hasher',
);
}
for (const relativePath of ['tools/deploy/ensure-production-admin.mjs', 'tools/smoke/real-env-smoke.mjs']) {
const content = readFileSync(resolve(root, relativePath), 'utf8');
if (/function\s+hashPassword\s*\(|passwordHash:\s*(?:createHash|legacyHashPassword)/.test(content)
|| !content.includes("api/dist/auth/password-hasher.js")) {
if (
/function\s+hashPassword\s*\(|passwordHash:\s*(?:createHash|legacyHashPassword)/.test(content) ||
!content.includes('api/dist/auth/password-hasher.js')
) {
violations.push(`${relativePath}: user password writes must use the compiled API password hasher`);
}
}
@@ -54,6 +87,24 @@ if (packageJson.dependencies?.['react-router-dom'] !== '7.18.2') {
if (packageJson.overrides?.nanoid !== '3.3.18') {
violations.push('package.json: nanoid override must remain on the remediated 3.3.18 baseline');
}
const trackedAlternativeLocks = execFileSync('git', ['ls-files', 'pnpm-lock.yaml', 'yarn.lock'], {
cwd: root,
encoding: 'utf8',
}).trim();
if (trackedAlternativeLocks) {
violations.push(
`${trackedAlternativeLocks.replace(/\r?\n/g, ', ')}: npm/package-lock.json is the only supported committed dependency lock`,
);
}
const presentAlternativeLocks = ['pnpm-lock.yaml', 'yarn.lock'].filter((file) => existsSync(resolve(root, file)));
if (presentAlternativeLocks.length) {
violations.push(
`${presentAlternativeLocks.join(', ')}: remove alternative lock files; npm/package-lock.json is the only supported dependency lock`,
);
}
if (packageJson.packageManager !== 'npm@10.9.4') {
violations.push('package.json: packageManager must pin the supported npm baseline');
}
if (violations.length) {
console.error(violations.map((item) => `ERROR: ${item}`).join('\n'));
@@ -9,7 +9,10 @@ const apiLock = JSON.parse(readFileSync(join(workspaceRoot, 'api', 'package-lock
assertVersionAtLeast(rootLock.packages['node_modules/postcss']?.version, [8, 5, 18], 'postcss');
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.9', 'brace-expansion-safe');
assertEqual(apiLock.packages['node_modules/fast-uri']?.version, '3.1.5', 'fast-uri');
assertEqual(apiLock.packages['node_modules/js-yaml']?.version, '4.3.1', 'js-yaml');
assertEqual(apiLock.packages['node_modules/@nestjs/swagger']?.version, '11.4.7', '@nestjs/swagger');
assertEqual(
apiLock.packages['node_modules/brace-expansion']?.resolved,
'vendor/brace-expansion-compat',
@@ -35,7 +38,7 @@ for (const filePath of sourceFiles(join(workspaceRoot, 'src'))) {
const apiRequire = createRequire(join(workspaceRoot, 'api', 'package.json'));
const expand = apiRequire('brace-expansion');
if (typeof expand !== 'function' || expand.EXPANSION_MAX_LENGTH !== 4_000_000) {
throw new Error('brace-expansion compatibility adapter is not using the bounded 5.0.8 implementation');
throw new Error('brace-expansion compatibility adapter is not using the bounded 5.0.9 implementation');
}
assertEqual(expand('{a,b}{1,2}').join(','), 'a1,a2,b1,b2', 'brace-expansion legacy API');
@@ -51,7 +54,9 @@ for (const relativePath of [
}
}
console.log('Dependency mitigations verified: PostCSS, React Router and NanoID patched; RSC unused; brace expansion bounded and compatible.');
console.log(
'Dependency mitigations verified: frontend baselines, Swagger YAML/URI parsers and bounded brace expansion are patched.',
);
function sourceFiles(directory) {
return readdirSync(directory).flatMap((name) => {
@@ -64,7 +69,11 @@ function sourceFiles(directory) {
function assertVersionAtLeast(actual, minimum, label) {
if (!actual) throw new Error(`${label} is missing from package-lock.json`);
const parts = actual.split('.').map((part) => Number(part.replace(/\D.*$/u, '')));
if (minimum.some((value, index) => parts[index] < value && minimum.slice(0, index).every((item, i) => parts[i] === item))) {
if (
minimum.some(
(value, index) => parts[index] < value && minimum.slice(0, index).every((item, i) => parts[i] === item),
)
) {
throw new Error(`${label} ${actual} is older than ${minimum.join('.')}`);
}
}
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
const redisUrl = process.env.AUTH_REDIS_INTEGRATION_URL;
if (!redisUrl) {
throw new Error('AUTH_REDIS_INTEGRATION_URL is required; use an isolated Redis database');
}
process.env.REDIS_URL = redisUrl;
const require = createRequire(import.meta.url);
const { SessionService } = require('../../api/dist/auth/session.service.js');
const IORedis = require('../../api/node_modules/ioredis');
const redis = new IORedis(redisUrl, { enableReadyCheck: true, maxRetriesPerRequest: 1 });
const first = new SessionService();
const second = new SessionService();
const runId = randomUUID();
const digest = (value) => createHash('sha256').update(value.trim()).digest('hex');
const keys = new Set();
function remember(...values) {
for (const value of values) keys.add(value);
}
async function ttlInRange(key, minimum, maximum) {
const ttl = await redis.ttl(key);
assert.ok(ttl >= minimum && ttl <= maximum, `${key} TTL ${ttl} is outside ${minimum}..${maximum}`);
}
try {
assert.equal(await redis.ping(), 'PONG');
const { token } = await first.create(`integration-user-${runId}`, 'client', 1);
const sessionKey = `cmpp:auth:session:${digest(token)}`;
remember(sessionKey);
const crossInstanceSession = await second.validate(token, false);
assert.equal(crossInstanceSession.status, 'active');
assert.equal(crossInstanceSession.record.userId, `integration-user-${runId}`);
const captchaId = `integration-captcha-${runId}`;
const captchaKey = `cmpp:auth:captcha:${captchaId}`;
remember(captchaKey);
await first.storeCaptcha(captchaId, '8291', 120);
assert.equal(await second.consumeCaptcha(captchaId), '8291');
assert.equal(await first.consumeCaptcha(captchaId), null);
const captchaIp = `198.51.100.${(Number.parseInt(runId.slice(0, 2), 16) % 200) + 1}`;
const captchaRateKey = `cmpp:auth:captcha-rate:ip:${digest(captchaIp)}`;
remember(captchaRateKey);
for (let index = 0; index < 30; index += 1) {
assert.equal(await (index % 2 === 0 ? first : second).assertCaptchaRequestAllowed(captchaIp), true);
}
assert.equal(await second.assertCaptchaRequestAllowed(captchaIp), false);
await ttlInRange(captchaRateKey, 1, 300);
const pairLogin = `pair-${runId}@integration.invalid`;
const pairIp = '198.51.100.210';
const accountDigest = digest(pairLogin.toLowerCase());
const ipDigest = digest(pairIp);
const pairDigest = digest(`${accountDigest}:${ipDigest}`);
const pairKeys = [
`cmpp:auth:failure:${accountDigest}`,
`cmpp:auth:failure:ip:${ipDigest}`,
`cmpp:auth:failure:pair:${pairDigest}`,
`cmpp:auth:lock:${accountDigest}`,
`cmpp:auth:lock:ip:${ipDigest}`,
`cmpp:auth:lock:pair:${pairDigest}`,
];
remember(...pairKeys);
for (let index = 0; index < 5; index += 1) {
await (index % 2 === 0 ? first : second).recordAnonymousLoginFailure(pairLogin, pairIp);
}
assert.equal(await second.anonymousLoginLockScope(pairLogin, pairIp), 'account');
await ttlInRange(pairKeys[0], 86_000, 86_400);
await ttlInRange(pairKeys[3], 86_000, 86_400);
await ttlInRange(pairKeys[2], 86_000, 86_400);
await ttlInRange(pairKeys[5], 86_000, 86_400);
const scanIp = '198.51.100.211';
const scanIpDigest = digest(scanIp);
const scanFailureKey = `cmpp:auth:failure:ip:${scanIpDigest}`;
const scanLockKey = `cmpp:auth:lock:ip:${scanIpDigest}`;
remember(scanFailureKey, scanLockKey);
for (let index = 0; index < 30; index += 1) {
const login = `scan-${runId}-${index}@integration.invalid`;
const loginDigest = digest(login.toLowerCase());
const loginPairDigest = digest(`${loginDigest}:${scanIpDigest}`);
remember(
`cmpp:auth:failure:${loginDigest}`,
`cmpp:auth:failure:pair:${loginPairDigest}`,
`cmpp:auth:lock:${loginDigest}`,
`cmpp:auth:lock:pair:${loginPairDigest}`,
);
await (index % 2 === 0 ? first : second).recordAnonymousLoginFailure(login, scanIp);
}
assert.equal(await first.anonymousLoginLockScope(`fresh-${runId}@integration.invalid`, scanIp), 'ip');
await ttlInRange(scanFailureKey, 600, 900);
await ttlInRange(scanLockKey, 600, 900);
const listedKeys = await redis.keys(`cmpp:auth:*${runId}*`);
assert.equal(listedKeys.length, 0, 'raw login/run identifiers must not appear in Redis keys');
console.log(
JSON.stringify({
status: 'passed',
sharedSession: true,
oneTimeCaptcha: true,
captchaRateLimit: true,
accountAndPairLock: true,
randomAccountIpLock: true,
ttlVerified: true,
hashedKeysOnly: true,
}),
);
} finally {
if (keys.size > 0) await redis.del(...keys);
await first.onModuleDestroy();
await second.onModuleDestroy();
redis.disconnect();
}
+13 -1
View File
@@ -1,4 +1,4 @@
import { defineConfig } from 'vite';
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
@@ -34,4 +34,16 @@ export default defineConfig({
},
},
},
test: {
environment: 'jsdom',
include: ['src/**/*.test.{ts,tsx}'],
exclude: ['api/**', 'outputs/**', 'dist/**', 'node_modules/**'],
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary'],
include: ['src/api/core/**/*.ts', 'src/apps/LoginPage.tsx', 'src/routes/RouteLoadBoundary.tsx'],
thresholds: { lines: 80, functions: 80, branches: 70, statements: 80 },
},
},
});