From 8eefa7fa4499ed9b3c733211794360fad26061b4 Mon Sep 17 00:00:00 2001 From: liuyun Date: Sun, 11 Oct 2026 22:42:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(error):=20=E4=B8=9A=E5=8A=A1=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=A0=81=E6=B3=A8=E5=86=8C=E8=A1=A8=EF=BC=88=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E5=9F=9F+=E9=94=99=E8=AF=AF=E5=BA=8F=EF=BC=89+=20Busi?= =?UTF-8?q?nessException?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - error-codes 单一注册表 + BusinessException + 过滤器识别 - 迁移登录/锁定/待改密/用户名冲突/租户/会话等关键错误 Co-Authored-By: Claude Opus 4.8 --- src/auth/auth.service.ts | 20 +++++++------ src/auth/jwt-auth.guard.ts | 10 ++++--- src/common/business-exception.ts | 8 +++++ src/common/error-codes.ts | 33 +++++++++++++++++++++ src/common/filters/http-exception.filter.ts | 13 ++++++-- src/sessions/sessions.service.ts | 6 ++-- src/tenants/tenants.service.ts | 8 +++-- test/app.e2e-spec.ts | 3 +- 8 files changed, 79 insertions(+), 22 deletions(-) create mode 100644 src/common/business-exception.ts create mode 100644 src/common/error-codes.ts diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index d0812be..2833f50 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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 }, diff --git a/src/auth/jwt-auth.guard.ts b/src/auth/jwt-auth.guard.ts index a0b3e47..624699c 100644 --- a/src/auth/jwt-auth.guard.ts +++ b/src/auth/jwt-auth.guard.ts @@ -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 无效或已过期'); } } diff --git a/src/common/business-exception.ts b/src/common/business-exception.ts new file mode 100644 index 0000000..252531a --- /dev/null +++ b/src/common/business-exception.ts @@ -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); + } +} diff --git a/src/common/error-codes.ts b/src/common/error-codes.ts new file mode 100644 index 0000000..9c9262b --- /dev/null +++ b/src/common/error-codes.ts @@ -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]; diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index 7b927e1..cc309f0 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -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, }); diff --git a/src/sessions/sessions.service.ts b/src/sessions/sessions.service.ts index b2c1e50..16b8bc1 100644 --- a/src/sessions/sessions.service.ts +++ b/src/sessions/sessions.service.ts @@ -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; } diff --git a/src/tenants/tenants.service.ts b/src/tenants/tenants.service.ts index 91d312b..eb012e4 100644 --- a/src/tenants/tenants.service.ts +++ b/src/tenants/tenants.service.ts @@ -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([ diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 0f11aed..6de5d5c 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -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 () => {