feat(error): 业务错误码注册表(业务域+错误序)+ BusinessException
- error-codes 单一注册表 + BusinessException + 过滤器识别 - 迁移登录/锁定/待改密/用户名冲突/租户/会话等关键错误 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f747ad5442
commit
8eefa7fa44
@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ConflictException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
@ -11,6 +11,8 @@ import type { RequestContext } from '../common/context/request-context.js';
|
||||
import { AuditLogsService } from '../audit-logs/audit-logs.service.js';
|
||||
import { LoginLogsService } from '../login-logs/login-logs.service.js';
|
||||
import { PLATFORM_ROLE_CODE as SUPER_ROLE, PLATFORM_TENANT_CODE, LOGIN_FAIL_PREFIX, permCacheKey, sessionRevokedKey, userStateCacheKey } from '../common/constants.js';
|
||||
import { BusinessException } from '../common/business-exception.js';
|
||||
import { ErrorCodes } from '../common/error-codes.js';
|
||||
|
||||
const REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800);
|
||||
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
|
||||
@ -116,7 +118,7 @@ export class AuthService {
|
||||
if (matched.length === 0) {
|
||||
await this.recordLoginFail(ip, dto.username);
|
||||
await this.recordLoginLog('', dto.username, 'fail', '账号或密码错误', ip, userAgent);
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
throw new BusinessException(ErrorCodes.AUTH_CREDENTIALS, '账号或密码错误', HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
await this.clearLoginFail(ip, dto.username);
|
||||
if (matched.length === 1) {
|
||||
@ -165,27 +167,27 @@ export class AuthService {
|
||||
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: tenantCode } });
|
||||
if (!tenant || tenant.status !== 'enabled') {
|
||||
await this.recordLoginLog(tenantCode, username, 'fail', '租户不存在或已停用', ip, userAgent);
|
||||
throw new UnauthorizedException('租户不存在或已停用');
|
||||
throw new BusinessException(ErrorCodes.TENANT_DISABLED, '租户不存在或已停用', HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
const user = await this.prisma.client.user.findFirst({
|
||||
where: { tenantCode, username, deletedAt: null },
|
||||
});
|
||||
if (!user) {
|
||||
await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent);
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
throw new BusinessException(ErrorCodes.AUTH_CREDENTIALS, '账号或密码错误', HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
if (user.lockedAt) {
|
||||
await this.recordLoginLog(tenantCode, username, 'fail', '账号已锁定', ip, userAgent);
|
||||
throw new UnauthorizedException('账号已锁定');
|
||||
throw new BusinessException(ErrorCodes.AUTH_LOCKED, '账号已锁定', HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
if (user.status !== 'enabled') {
|
||||
await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用', ip, userAgent);
|
||||
throw new UnauthorizedException('账号已停用');
|
||||
throw new BusinessException(ErrorCodes.AUTH_DISABLED, '账号已停用', HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) {
|
||||
await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent);
|
||||
throw new UnauthorizedException('账号或密码错误');
|
||||
throw new BusinessException(ErrorCodes.AUTH_CREDENTIALS, '账号或密码错误', HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
await this.writePermissionCache(tenantCode, user.id);
|
||||
await this.recordLoginLog(tenantCode, username, 'success', null, ip, userAgent);
|
||||
@ -218,7 +220,7 @@ export class AuthService {
|
||||
const exists = await this.prisma.client.user.findFirst({
|
||||
where: { tenantCode: dto.tenantCode, username: dto.username, deletedAt: null },
|
||||
});
|
||||
if (exists) throw new ConflictException('用户名已存在');
|
||||
if (exists) throw new BusinessException(ErrorCodes.USER_USERNAME_EXISTS, '用户名已存在', HttpStatus.CONFLICT);
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
const user = await this.prisma.client.user.create({
|
||||
data: {
|
||||
@ -240,7 +242,7 @@ export class AuthService {
|
||||
});
|
||||
if (!user) throw new UnauthorizedException('账号不存在');
|
||||
const ok = await bcrypt.compare(dto.oldPassword, user.passwordHash);
|
||||
if (!ok) throw new BadRequestException('原密码错误');
|
||||
if (!ok) throw new BusinessException(ErrorCodes.AUTH_OLD_PWD_WRONG, '原密码错误', HttpStatus.BAD_REQUEST);
|
||||
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
|
||||
await this.prisma.client.user.updateMany({
|
||||
where: { tenantCode, id: userId },
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { IS_PUBLIC_KEY } from './decorators.js';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { BusinessException } from '../common/business-exception.js';
|
||||
import { ErrorCodes } from '../common/error-codes.js';
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
@ -36,15 +38,15 @@ export class JwtAuthGuard implements CanActivate {
|
||||
const { active, mustChangePassword } = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0);
|
||||
if (!active) throw new UnauthorizedException('账号已停用或登录已失效');
|
||||
if (payload.sid && (await this.auth.isSessionRevoked(payload.sid))) {
|
||||
throw new UnauthorizedException('会话已失效,请重新登录');
|
||||
throw new BusinessException(ErrorCodes.AUTH_SESSION_REVOKED, '会话已失效,请重新登录', HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
if (mustChangePassword && !this.isChangePassword(request)) {
|
||||
throw new ForbiddenException('请先修改初始密码');
|
||||
throw new BusinessException(ErrorCodes.AUTH_MUST_CHANGE_PWD, '请先修改初始密码', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser;
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof UnauthorizedException || e instanceof ForbiddenException) throw e;
|
||||
if (e instanceof HttpException) throw e;
|
||||
throw new UnauthorizedException('token 无效或已过期');
|
||||
}
|
||||
}
|
||||
|
||||
8
src/common/business-exception.ts
Normal file
8
src/common/business-exception.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
/** 业务异常:携带 error-codes 注册表内的业务码(与 HTTP 状态解耦)。 */
|
||||
export class BusinessException extends HttpException {
|
||||
constructor(code: number, message: string, status: HttpStatus = HttpStatus.BAD_REQUEST) {
|
||||
super({ code, message }, status);
|
||||
}
|
||||
}
|
||||
33
src/common/error-codes.ts
Normal file
33
src/common/error-codes.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 业务错误码单一事实源:5 位数字 [域码 2 位][序 3 位]。
|
||||
*
|
||||
* 域码:00 通用 · 10 认证 · 11 用户 · 12 角色 · 13 资源 · 14 组织 · 15 租户
|
||||
* 16 字典 · 17 配置 · 18 审计 · 19 登录 · 20 会话 · 21 通知。
|
||||
*
|
||||
* 用法:先在此登记,再 `throw new BusinessException(ErrorCodes.XXX, '...', HttpStatus.XXX)`。
|
||||
* 未使用业务码的异常由 AllExceptionsFilter 回退为 `HTTP 状态 × 100`。
|
||||
*/
|
||||
export const ErrorCodes = {
|
||||
// 认证(10)
|
||||
AUTH_CREDENTIALS: 10001, // 账号或密码错误(401)
|
||||
AUTH_LOCKED: 10002, // 账号已锁定(401)
|
||||
AUTH_DISABLED: 10003, // 账号已停用(401)
|
||||
AUTH_MUST_CHANGE_PWD: 10004, // 需先修改初始密码(403)
|
||||
AUTH_OLD_PWD_WRONG: 10005, // 原密码错误(400)
|
||||
AUTH_TOKEN_INVALID: 10006, // token 无效或已过期(401)
|
||||
AUTH_SESSION_REVOKED: 10007, // 会话已失效(401)
|
||||
|
||||
// 用户(11)
|
||||
USER_NOT_FOUND: 11001, // 用户不存在(404)
|
||||
USER_USERNAME_EXISTS: 11002, // 用户名已存在(409)
|
||||
|
||||
// 租户(15)
|
||||
TENANT_NOT_FOUND: 15001, // 租户不存在(404)
|
||||
TENANT_EXISTS: 15002, // 租户已存在(409)
|
||||
TENANT_DISABLED: 15003, // 租户不存在或已停用(401)
|
||||
|
||||
// 会话(20)
|
||||
SESSION_NOT_FOUND: 20001, // 会话不存在或已下线(404)
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
|
||||
@ -2,9 +2,10 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { Response } from 'express';
|
||||
import { InjectPinoLogger, PinoLogger } from 'nestjs-pino';
|
||||
import { BusinessException } from '../business-exception.js';
|
||||
|
||||
/**
|
||||
* 全局异常过滤器:错误码分段与 HTTP 状态对齐(400xx/401xx/403xx/404xx/409xx/500xx),并记录日志。
|
||||
* 全局异常过滤器:业务异常用注册业务码,其余回退 `HTTP 状态 × 100`,并记录日志。
|
||||
*/
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
@ -16,8 +17,14 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message: string;
|
||||
let code: number | null = null;
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
if (exception instanceof BusinessException) {
|
||||
status = exception.getStatus();
|
||||
const body = exception.getResponse() as { code: number; message: string };
|
||||
code = body.code;
|
||||
message = body.message;
|
||||
} else if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const body = exception.getResponse();
|
||||
message =
|
||||
@ -48,7 +55,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
code: status * 100,
|
||||
code: code ?? status * 100,
|
||||
message,
|
||||
data: null,
|
||||
});
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { RedisService } from '../common/redis/redis.service.js';
|
||||
import { getRequestContext } from '../common/context/request-context.js';
|
||||
import { sessionRevokedKey } from '../common/constants.js';
|
||||
import { BusinessException } from '../common/business-exception.js';
|
||||
import { ErrorCodes } from '../common/error-codes.js';
|
||||
import { ListSessionQuery } from './dto.js';
|
||||
|
||||
/** 与 auth.service 的 access token 有效时长对齐(黑名单 TTL)。 */
|
||||
@ -94,7 +96,7 @@ export class SessionsService {
|
||||
where: { sid, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
if (result.count === 0) throw new NotFoundException('会话不存在或已下线');
|
||||
if (result.count === 0) throw new BusinessException(ErrorCodes.SESSION_NOT_FOUND, '会话不存在或已下线', HttpStatus.NOT_FOUND);
|
||||
await this.redis.setJson(sessionRevokedKey(sid), 1, ACCESS_TTL);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { BaseCrudService } from '../common/base-crud.service.js';
|
||||
@ -6,6 +6,8 @@ import { PrismaService } from '../prisma/prisma.service.js';
|
||||
import { RedisService } from '../common/redis/redis.service.js';
|
||||
import { getRequestContext } from '../common/context/request-context.js';
|
||||
import { TENANT_ADMIN_ROLE_CODE } from '../common/constants.js';
|
||||
import { BusinessException } from '../common/business-exception.js';
|
||||
import { ErrorCodes } from '../common/error-codes.js';
|
||||
import { initTenant } from '../common/tenant-template.js';
|
||||
import { CreateTenantDto, ListTenantQuery, UpdateTenantDto } from './dto.js';
|
||||
|
||||
@ -63,14 +65,14 @@ export class TenantsService extends BaseCrudService {
|
||||
|
||||
async updateTenant(id: string, dto: UpdateTenantDto) {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) throw new NotFoundException('租户不存在');
|
||||
if (!existing) throw new BusinessException(ErrorCodes.TENANT_NOT_FOUND, '租户不存在', HttpStatus.NOT_FOUND);
|
||||
return this.update(id, { ...dto });
|
||||
}
|
||||
|
||||
/** 删除租户并级联清理全部业务数据与 Redis 缓存。 */
|
||||
async removeTenant(id: string) {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) throw new NotFoundException('租户不存在');
|
||||
if (!existing) throw new BusinessException(ErrorCodes.TENANT_NOT_FOUND, '租户不存在', HttpStatus.NOT_FOUND);
|
||||
const code = existing.code;
|
||||
const db = this.prisma.raw;
|
||||
await db.$transaction([
|
||||
|
||||
@ -3,6 +3,7 @@ import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types.js';
|
||||
import { AppModule } from './../src/app.module.js';
|
||||
import { ErrorCodes } from './../src/common/error-codes.js';
|
||||
|
||||
/**
|
||||
* 登录链路 e2e:需要已启动的 MySQL/Redis 且已执行 pnpm seed(默认账号 touyun / Touyun@123)。
|
||||
@ -36,7 +37,7 @@ describe('Auth (e2e)', () => {
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ username: 'e2e_wrong_user', password: 'wrong-password' })
|
||||
.expect(401);
|
||||
expect(res.body.code).toBe(40100);
|
||||
expect(res.body.code).toBe(ErrorCodes.AUTH_CREDENTIALS);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user