refactor: strengthen client boundaries and quality gates
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
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(),
|
||||
};
|
||||
|
||||
@@ -47,7 +75,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 +85,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 +99,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 +116,63 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user