feat(identity): 完成 A1 身份认证与用户管理全栈落地
ci / 质量门禁 (push) Canceled after 0s

背景:
- 开发计划 A1 批次要求登录/刷新/登出、用户生命周期、路由与按钮权限、
  权限三态用例;视觉已选定方案 B(主从分栏 + 分屏登录)。

改动:
- packages/shared:新增 identity 契约(User/Me/Login/Refresh/Department/Role)
  与 user:view 权限点;写接口 strict 校验。
- apps/api:新增 identity 模块与 Prisma expand 迁移(departments/users/roles/
  user_roles/role_permissions/refresh_tokens);Argon2id、JWT access+refresh
  旋转与 jti 吊销、登录失败锁定、首登强改、守卫链、/me、@Audit 上下文、幂等种子。
- apps/web:按方案 B 落地 /users、/users/new、/users/:id、分屏登录与 403;
  usePermission/<Can>、路由守卫、401 单飞刷新、停用会话兜底、Semi + token。
- e2e:新增 A1-QA-01 六类端到端用例(A1_E2E 门控)。
- openapi 与前端生成类型同步;.env.example 登记认证变量;实施报告入库。

影响面:
- 新增运行时依赖 @nestjs/jwt、argon2(计划 A1-BE-02 已批准,版本精确锁定)。
- 迁移为 expand,不改既有表;本地需 prisma migrate 后重新 seed。
- 平台导航「用户与组织」由 /platform/users 调整为 /users。

回滚方式:
- 代码整体回滚本提交;迁移为 expand,回滚代码不需删表;
- 本地如需彻底回退按实施报告 §6 处理。

验证证据:
- pnpm format:check / lint / typecheck 通过;
- pnpm test:shared 95 / api 60 / web 55;
- pnpm test:int:7 个(真实 PG/Redis);
- A1_E2E=1 playwright test e2e/a1-identity.spec.ts:6/6;
- 报告:docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md
This commit is contained in:
Stev_Wang committed 2026-11-03 00:03:33 +08:00
1 parent f0d218bd99
commit 4d9eb980af
120 files changed
+13112 -314

No files matched your search

+4
View File
@@ -2,10 +2,12 @@ import { REQUEST_ID_HEADER, ulid } from '@lingxi/shared';
import { Module } from '@nestjs/common';
import { LoggerModule } from 'nestjs-pino';
import { SecurityModule } from './common/security/security.module';
import { APP_CONFIG, AppConfigModule } from './config/config.module';
import type { AppConfig } from './config/env';
import { PrismaModule } from './infra/prisma/prisma.module';
import { RedisModule } from './infra/redis/redis.module';
import { IdentityModule } from './modules/identity/identity.module';
import { PlatformModule } from './modules/platform/platform.module';
@Module({
@@ -45,6 +47,8 @@ import { PlatformModule } from './modules/platform/platform.module';
}),
PrismaModule,
RedisModule,
SecurityModule,
IdentityModule,
PlatformModule,
],
})
+28
View File
@@ -0,0 +1,28 @@
import 'reflect-metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createApp, type CreatedApiApp } from './bootstrap';
import { loadDotenv } from './config/load-dotenv';
/** 装配冒烟:不监听端口,验证 env / 模块 / 守卫链 / Swagger 装配可完成 */
describe('bootstrap.createApp', () => {
let created: CreatedApiApp;
beforeAll(async () => {
loadDotenv();
created = await createApp();
await created.app.init();
});
afterAll(async () => {
await created.app.close();
});
it('应用装配成功,健康检查公开(不因守卫 401)', async () => {
expect(created.config.port).toBeGreaterThan(0);
expect(created.config.jwtAccessSecret.length).toBeGreaterThanOrEqual(32);
const response = await created.app.inject({ method: 'GET', url: '/health' });
expect([200, 503]).toContain(response.statusCode);
});
});
@@ -0,0 +1,106 @@
import {
type CallHandler,
type ExecutionContext,
Inject,
Injectable,
Logger,
type NestInterceptor,
} from '@nestjs/common';
import { defer, type Observable } from 'rxjs';
import { tap } from 'rxjs';
import { AUDIT_ACTION_KEY } from './decorators';
import {
buildRequestContext,
type RequestContext,
RequestContextService,
type RequestWithTrace,
} from './request-context';
interface AuditParams {
id?: string;
}
interface AuditPayload {
action: string;
result: 'success' | 'failure';
actorId: string | null;
account: string | null;
targetId: string | null;
traceId: string;
durationMs: number;
error?: string;
}
function actionOf(context: ExecutionContext): string | null {
const action = Reflect.getMetadata(AUDIT_ACTION_KEY, context.getHandler()) as unknown;
return typeof action === 'string' ? action : null;
}
/**
* 全局请求上下文 + @Audit 结构化日志。
* A1 先写日志(Pino);A5 审计表落地后在此追加落库,装饰器签名不变。
*/
@Injectable()
export class AuditInterceptor implements NestInterceptor {
private readonly logger = new Logger('Audit');
constructor(
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context
.switchToHttp()
.getRequest<RequestWithTrace & { params?: AuditParams }>();
const ctx = buildRequestContext(request);
const action = actionOf(context);
const targetId = request.params?.id ?? null;
return defer(() =>
this.requestContext.run(ctx, () => {
if (action === null) {
return next.handle();
}
return next.handle().pipe(
tap({
next: () => {
this.emit(ctx, action, 'success', targetId);
},
error: (error: unknown) => {
this.emit(ctx, action, 'failure', targetId, error);
},
}),
);
}),
);
}
private emit(
ctx: RequestContext,
action: string,
result: AuditPayload['result'],
targetId: string | null,
error?: unknown,
): void {
const payload: AuditPayload = {
action,
result,
actorId: ctx.userId,
account: ctx.account,
targetId,
traceId: ctx.traceId,
durationMs: Date.now() - ctx.startedAt,
...(error === undefined
? {}
: { error: error instanceof Error ? error.message : '未知错误' }),
};
this.logger.log(JSON.stringify(payload));
}
}
/** 供 @Audit 断言测试使用:读取处理器上的审计动作名 */
export function auditActionOf(handler: unknown): string | null {
const action = Reflect.getMetadata(AUDIT_ACTION_KEY, handler as object) as unknown;
return typeof action === 'string' ? action : null;
}
@@ -0,0 +1,38 @@
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
import type { FastifyRequest } from 'fastify';
/** access token 载荷:只放用户 id,权限每请求按 DB 解析(角色变更即时生效) */
export interface JwtAccessPayload {
sub: string;
typ: 'access';
}
/** refresh token 载荷:jti 对应 refresh_tokens 主键,fam 为旋转家族(重放时整族吊销) */
export interface JwtRefreshPayload {
sub: string;
typ: 'refresh';
jti: string;
fam: string;
}
/** 守卫解析出的鉴权上下文(挂在 request.auth,并写入 AsyncLocalStorage) */
export interface AuthContext {
userId: string;
account: string;
status: UserStatus;
roles: RoleSummary[];
permissions: string[];
dataScope: DataScope;
mustChangePassword: boolean;
}
export interface AuthenticatedRequest extends FastifyRequest {
auth?: AuthContext;
}
/** 登录 / 刷新时随令牌一起返回的调用上下文(用于日志与 @Audit) */
export interface AuthRequestMeta {
ip: string | null;
userAgent: string | null;
traceId: string;
}
@@ -0,0 +1,25 @@
import { createParamDecorator, type ExecutionContext, SetMetadata } from '@nestjs/common';
import type { AuthContext, AuthenticatedRequest } from './auth.types';
export const IS_PUBLIC_KEY = 'auth:public';
/** 标记公开接口(登录、刷新、健康检查);其余接口默认必须登录 */
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
export const PERMISSIONS_KEY = 'auth:permissions';
/** 声明接口所需权限点(任一满足即通过,多个权限点之间为 OR) */
export const RequirePermissions = (...permissions: string[]): MethodDecorator & ClassDecorator =>
SetMetadata(PERMISSIONS_KEY, permissions);
export const AUDIT_ACTION_KEY = 'audit:action';
/** 关键动作审计:先写结构化日志(A5 落库与检索接审计表后再扩展) */
export const Audit = (action: string): MethodDecorator & ClassDecorator =>
SetMetadata(AUDIT_ACTION_KEY, action);
/** 取当前登录上下文;未登录(@Public 接口)返回 null */
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): AuthContext | null => {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
return request.auth ?? null;
},
);
+174
View File
@@ -0,0 +1,174 @@
import { ErrorCode, SPECIAL_PERMISSIONS } from '@lingxi/shared';
import { type CanActivate, type ExecutionContext, Inject, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AppException } from '../errors/app-exception';
import type { AuthContext, AuthenticatedRequest } from './auth.types';
import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
import { PermissionService } from './permission.service';
import { RequestContextService } from './request-context';
import { TokenService } from './token.service';
const MUST_CHANGE_ALLOWED_PATHS: readonly string[] = [
'/api/v1/me',
'/api/v1/auth/change-password',
'/api/v1/auth/logout',
'/api/v1/auth/refresh',
];
const SPECIAL_PERMISSION_SET: ReadonlySet<string> = new Set<string>(SPECIAL_PERMISSIONS);
function extractBearer(header: string | undefined): string | null {
if (!header?.startsWith('Bearer ')) {
return null;
}
const token = header.slice('Bearer '.length).trim();
return token.length > 0 ? token : null;
}
function isMustChangeAllowed(url: string): boolean {
const path = url.split('?')[0] ?? url;
return MUST_CHANGE_ALLOWED_PATHS.includes(path);
}
function isPublic(reflector: Reflector, context: ExecutionContext): boolean {
const value: unknown = reflector.getAllAndOverride(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
return value === true;
}
function requiredPermissions(reflector: Reflector, context: ExecutionContext): string[] {
const value: unknown = reflector.getAllAndOverride(PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!Array.isArray(value)) {
return [];
}
return value.filter((code): code is string => typeof code === 'string');
}
function authOf(context: ExecutionContext): AuthContext | null {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
return request.auth ?? null;
}
function requireAuth(context: ExecutionContext): AuthContext {
const auth = authOf(context);
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
return auth;
}
/**
* 1) JWT 校验 → 每请求解析权限 → 强制改密拦截。
* 守卫顺序由 SecurityModule 的 APP_GUARD 注册顺序保证:
* JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
@Inject(Reflector) private readonly reflector: Reflector,
@Inject(TokenService) private readonly tokens: TokenService,
@Inject(PermissionService) private readonly permissions: PermissionService,
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (isPublic(this.reflector, context)) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const token = extractBearer(request.headers.authorization);
if (token === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
const payload = this.tokens.verifyAccess(token);
const auth = await this.permissions.resolveAuth(payload.sub);
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
if (auth.status === 'disabled') {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '账号已停用' });
}
if (auth.mustChangePassword && !isMustChangeAllowed(request.url)) {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '请先修改初始密码' });
}
request.auth = auth;
this.requestContext.patch({
userId: auth.userId,
account: auth.account,
permissions: auth.permissions,
});
return true;
}
}
/** 2) 普通权限点校验;特殊权限留给 SpecialPermissionGuard(40301 与 40303 分开) */
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
if (isPublic(this.reflector, context)) {
return true;
}
const normal = requiredPermissions(this.reflector, context).filter(
(code) => !SPECIAL_PERMISSION_SET.has(code),
);
if (normal.length === 0) {
return true;
}
const auth = requireAuth(context);
const missing = normal.filter((code) => !auth.permissions.includes(code));
if (missing.length > 0) {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, details: { missing } });
}
return true;
}
}
/** 3) 特殊权限(导出 / AI 配置 / 审计导出 / 行业发布 / 客户字段审批)单独校验 */
@Injectable()
export class SpecialPermissionGuard implements CanActivate {
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
if (isPublic(this.reflector, context)) {
return true;
}
const special = requiredPermissions(this.reflector, context).filter((code) =>
SPECIAL_PERMISSION_SET.has(code),
);
if (special.length === 0) {
return true;
}
const auth = requireAuth(context);
const missing = special.filter((code) => !auth.permissions.includes(code));
if (missing.length > 0) {
throw new AppException({
code: ErrorCode.SPECIAL_PERMISSION_REQUIRED,
details: { missing },
});
}
return true;
}
}
/**
* 4) 数据范围守卫骨架:A2 按 dataScope 注入 ScopeContext(部门 / 客户集合)。
* A1 identity 接口不返回客户数据,非全局范围暂直接放行。
*/
@Injectable()
export class ScopeGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const auth = authOf(context);
if (auth !== null && auth.dataScope.type !== 'all') {
// TODO(A2-BE-04):按 dataScope 注入 ScopeContext,并在 Repository 强制带 customer_id 过滤
}
return true;
}
}
@@ -0,0 +1,87 @@
import type { DataScope, RoleSummary, UserStatus } from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import type { AuthContext } from './auth.types';
/** 鉴权解析需要的用户关联:部门(含父级)、角色(含权限点) */
export type UserAuthRecord = Prisma.UserGetPayload<{
include: {
department: { include: { parent: true } };
roles: { include: { role: { include: { permissions: true } } } };
};
}>;
const ALL_DATA_SCOPE_ROLES: readonly string[] = ['super_admin', 'platform_admin'];
function toRoleSummary(role: UserAuthRecord['roles'][number]['role']): RoleSummary {
return {
id: role.id,
code: role.code,
name: role.name,
isSystem: role.isSystem,
...(role.description.length > 0 ? { description: role.description } : {}),
};
}
/** 数据范围骨架:A1 只分「全部 / 本部门及下级」,A2 再落六类范围与客户集合解析 */
export function resolveDataScope(roleCodes: readonly string[]): DataScope {
const isGlobal = roleCodes.some((code) => ALL_DATA_SCOPE_ROLES.includes(code));
return isGlobal ? { type: 'all' } : { type: 'department_and_children' };
}
export function activeRolesOf(user: UserAuthRecord): UserAuthRecord['roles'] {
return user.roles.filter((item) => item.role.status === 'ACTIVE');
}
/**
* 权限解析:**每个请求实时读 DB**,不缓存角色/权限点,
* 保证角色变更在下一次请求立即生效(A1-BE-04 验收项)。
*/
@Injectable()
export class PermissionService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async resolveAuth(userId: string): Promise<AuthContext | null> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
department: { include: { parent: true } },
roles: { include: { role: { include: { permissions: true } } } },
},
});
if (user === null) {
return null;
}
return this.toAuthContext(user);
}
async findAuthRecord(userId: string): Promise<UserAuthRecord | null> {
return this.prisma.user.findUnique({
where: { id: userId },
include: {
department: { include: { parent: true } },
roles: { include: { role: { include: { permissions: true } } } },
},
});
}
toAuthContext(user: UserAuthRecord): AuthContext {
const roles = activeRolesOf(user);
const permissions = [
...new Set(roles.flatMap((item) => item.role.permissions.map((p) => p.permissionCode))),
].sort();
const status: UserStatus = user.status === 'ACTIVE' ? 'active' : 'disabled';
return {
userId: user.id,
account: user.account,
status,
roles: roles.map((item) => toRoleSummary(item.role)),
permissions,
dataScope: resolveDataScope(roles.map((item) => item.role.code)),
mustChangePassword: user.mustChangePassword,
};
}
}
@@ -0,0 +1,66 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { ulid } from '@lingxi/shared';
import { Injectable } from '@nestjs/common';
import type { AuthenticatedRequest } from './auth.types';
/** 单次请求上下文:trace_id / 当前用户 / 权限快照(@Audit 与业务日志复用) */
export interface RequestContext {
traceId: string;
userId: string | null;
account: string | null;
permissions: string[] | null;
ip: string | null;
userAgent: string | null;
method: string;
url: string;
startedAt: number;
}
export type RequestWithTrace = AuthenticatedRequest & { id?: string };
function traceIdOf(request: RequestWithTrace): string {
return typeof request.id === 'string' && request.id.length > 0 ? request.id : ulid();
}
/** 由请求构造上下文(与 Pino requestId 同源,x-request-id 透传) */
export function buildRequestContext(request: RequestWithTrace): RequestContext {
const auth = request.auth;
return {
traceId: traceIdOf(request),
userId: auth?.userId ?? null,
account: auth?.account ?? null,
permissions: auth?.permissions ?? null,
ip: request.ip.length > 0 ? request.ip : null,
userAgent: request.headers['user-agent'] ?? null,
method: request.method,
url: request.url,
startedAt: Date.now(),
};
}
/**
* AsyncLocalStorage 请求上下文:拦截器在进入 handler 前建立,
* 业务 Service 可 `get()` 读取 trace_id / 当前用户,无需逐层透传。
*/
@Injectable()
export class RequestContextService {
private readonly storage = new AsyncLocalStorage<RequestContext>();
run<T>(context: RequestContext, callback: () => T): T {
return this.storage.run(context, callback);
}
get(): RequestContext | null {
return this.storage.getStore() ?? null;
}
patch(partial: Partial<RequestContext>): void {
const store = this.storage.getStore();
if (store === undefined) {
return;
}
Object.assign(store, partial);
}
}
@@ -0,0 +1,35 @@
import { Global, Module } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { JwtModule } from '@nestjs/jwt';
import { AuditInterceptor } from './audit.interceptor';
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
import { PermissionService } from './permission.service';
import { RequestContextService } from './request-context';
import { TokenService } from './token.service';
/**
* 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、JWT 与守卫链。
* 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。
*/
@Global()
@Module({
imports: [JwtModule.register({})],
providers: [
RequestContextService,
PermissionService,
TokenService,
AuditInterceptor,
JwtAuthGuard,
PermissionGuard,
SpecialPermissionGuard,
ScopeGuard,
{ provide: APP_GUARD, useExisting: JwtAuthGuard },
{ provide: APP_GUARD, useExisting: PermissionGuard },
{ provide: APP_GUARD, useExisting: SpecialPermissionGuard },
{ provide: APP_GUARD, useExisting: ScopeGuard },
{ provide: APP_INTERCEPTOR, useExisting: AuditInterceptor },
],
exports: [RequestContextService, PermissionService, TokenService],
})
export class SecurityModule {}
@@ -0,0 +1,314 @@
import 'reflect-metadata';
import { ErrorCode } from '@lingxi/shared';
import type { ExecutionContext } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { describe, expect, it, vi } from 'vitest';
import { testConfig } from '../../testing/test-app';
import { AppException } from '../errors/app-exception';
import type { AuthContext, AuthenticatedRequest } from './auth.types';
import { AUDIT_ACTION_KEY, IS_PUBLIC_KEY, PERMISSIONS_KEY } from './decorators';
import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards';
import type { UserAuthRecord } from './permission.service';
import { PermissionService, resolveDataScope } from './permission.service';
import type { RequestContextService } from './request-context';
import { TokenService } from './token.service';
const jwt = new JwtService({});
const tokens = new TokenService(jwt, testConfig);
function contextOf(request: Partial<AuthenticatedRequest>): ExecutionContext {
const handler = { name: 'handler' };
const cls = { name: 'controller' };
return {
getHandler: () => handler,
getClass: () => cls,
switchToHttp: () => ({ getRequest: () => request }),
} as unknown as ExecutionContext;
}
function reflectorFor(metadata: Record<string, unknown>): {
getAllAndOverride: (key: string) => unknown;
} {
return { getAllAndOverride: (key: string) => metadata[key] };
}
function authFixture(overrides: Partial<AuthContext> = {}): AuthContext {
return {
userId: 'u1',
account: 'chen.mo',
status: 'active',
roles: [],
permissions: ['user:view'],
dataScope: { type: 'department_and_children' },
mustChangePassword: false,
...overrides,
};
}
function expectCode(fn: () => unknown, code: ErrorCode): void {
try {
fn();
expect.unreachable('应抛出异常');
} catch (error) {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
}
}
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
return promise.then(
() => expect.unreachable('应抛出异常'),
(error: unknown) => {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
},
);
}
function authRecord(
status: 'ACTIVE' | 'DISABLED' = 'ACTIVE',
roleCodes: string[] = ['platform_admin'],
): UserAuthRecord {
return {
id: 'u1',
account: 'chen.mo',
name: '陈默',
phone: '13866202046',
email: 'chen.mo@lingxi.cn',
passwordHash: 'hash',
departmentId: 'd1',
jobTitle: null,
status,
mustChangePassword: false,
lastLoginAt: new Date('2026-11-02T01:41:00.000Z'),
lastLoginIp: null,
disabledAt: null,
remark: null,
createdBy: null,
createdAt: new Date('2026-09-30T10:40:00.000Z'),
updatedAt: new Date('2026-11-01T08:22:00.000Z'),
department: {
id: 'd1',
code: 'content-g1',
name: '素材一组',
parentId: 'd0',
leaderUserId: null,
sortOrder: 1,
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
parent: {
id: 'd0',
code: 'content',
name: '内容生产中心',
parentId: null,
leaderUserId: null,
sortOrder: 1,
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
},
},
roles: roleCodes.map((code, index) => ({
userId: 'u1',
roleId: `r${String(index)}`,
createdAt: new Date(),
role: {
id: `r${String(index)}`,
code,
name: code,
description: '',
isSystem: code === 'super_admin',
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
permissions: [
{ roleId: `r${String(index)}`, permissionCode: 'user:view', createdAt: new Date() },
{ roleId: `r${String(index)}`, permissionCode: 'user:manage', createdAt: new Date() },
],
},
})),
};
}
describe('TokenService', () => {
it('access token 可签发并校验,载荷只含 userId', () => {
const token = tokens.issueAccess('u1');
const payload = tokens.verifyAccess(token);
expect(payload.sub).toBe('u1');
expect(payload.typ).toBe('access');
});
it('refresh token 带 jti 与家族,类型不可混用', () => {
const refresh = tokens.issueRefresh('u1', 'jti1', 'fam1');
const payload = tokens.verifyRefresh(refresh);
expect(payload.jti).toBe('jti1');
expect(payload.fam).toBe('fam1');
expectCode(() => tokens.verifyAccess(refresh), ErrorCode.SIGNATURE_INVALID);
});
it('过期 token 返回 40102,篡改 token 返回 40103', () => {
const expired = jwt.sign(
{ sub: 'u1', typ: 'access' },
{ secret: testConfig.jwtAccessSecret, expiresIn: -10 },
);
expectCode(() => tokens.verifyAccess(expired), ErrorCode.TOKEN_EXPIRED);
expectCode(
() => tokens.verifyAccess(`${tokens.issueAccess('u1')}x`),
ErrorCode.SIGNATURE_INVALID,
);
expectCode(() => tokens.verifyAccess('not-a-jwt'), ErrorCode.SIGNATURE_INVALID);
});
it('refresh 哈希稳定且 access TTL 来自配置', () => {
expect(tokens.hashToken('abc')).toBe(tokens.hashToken('abc'));
expect(tokens.hashToken('abc')).not.toBe(tokens.hashToken('abd'));
expect(tokens.accessTtlSeconds).toBe(900);
expect(tokens.refreshTtlSeconds).toBe(604800);
});
});
describe('PermissionService / resolveDataScope', () => {
it('数据范围:平台 / 超管为 all,其余为本部门及下级', () => {
expect(resolveDataScope(['super_admin']).type).toBe('all');
expect(resolveDataScope(['designer']).type).toBe('department_and_children');
});
it('鉴权上下文:过滤停用角色、权限去重排序、状态转小写', () => {
const service = new PermissionService({} as never);
const context = service.toAuthContext(authRecord());
expect(context.permissions).toEqual(['user:manage', 'user:view']);
expect(context.status).toBe('active');
expect(context.roles).toHaveLength(1);
expect(context.dataScope.type).toBe('all');
});
it('resolveAuth 用户不存在返回 null', async () => {
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(null) } };
const service = new PermissionService(prisma as never);
await expect(service.resolveAuth('missing')).resolves.toBeNull();
});
it('resolveAuth 读取关联角色并返回上下文', async () => {
const prisma = {
user: { findUnique: vi.fn().mockResolvedValue(authRecord('ACTIVE', ['designer'])) },
};
const service = new PermissionService(prisma as never);
const context = await service.resolveAuth('u1');
expect(context?.userId).toBe('u1');
expect(context?.dataScope.type).toBe('department_and_children');
expect(context?.roles[0]?.code).toBe('designer');
});
});
describe('JwtAuthGuard', () => {
const permissions = { resolveAuth: vi.fn() };
const patch = vi.fn();
const requestContext = { patch } as unknown as RequestContextService;
function guardOf(metadata: Record<string, unknown>): JwtAuthGuard {
return new JwtAuthGuard(
reflectorFor(metadata) as never,
tokens,
permissions as unknown as PermissionService,
requestContext,
);
}
it('@Public 直接放行', async () => {
const guard = guardOf({ [IS_PUBLIC_KEY]: true });
await expect(guard.canActivate(contextOf({}))).resolves.toBe(true);
});
it('缺少 Bearer token 返回 40101', async () => {
const guard = guardOf({});
await expectRejected(guard.canActivate(contextOf({ headers: {} })), ErrorCode.UNAUTHENTICATED);
});
it('账号停用返回 40301,未改初始密码返回 40903', async () => {
const disabled = guardOf({});
permissions.resolveAuth.mockResolvedValueOnce(authFixture({ status: 'disabled' }));
await expectRejected(
disabled.canActivate(
contextOf({ headers: { authorization: `Bearer ${tokens.issueAccess('u1')}` } }),
),
ErrorCode.PERMISSION_DENIED,
);
const mustChange = guardOf({});
permissions.resolveAuth.mockResolvedValueOnce(authFixture({ mustChangePassword: true }));
await expectRejected(
mustChange.canActivate(
contextOf({
headers: { authorization: `Bearer ${tokens.issueAccess('u1')}` },
url: '/api/v1/users',
}),
),
ErrorCode.STATE_NOT_ALLOWED,
);
});
it('成功时挂载 request.auth 并写入上下文', async () => {
const guard = guardOf({});
permissions.resolveAuth.mockResolvedValueOnce(authFixture());
const request = {
headers: { authorization: `Bearer ${tokens.issueAccess('u1')}` },
url: '/api/v1/users',
} as unknown as AuthenticatedRequest;
await guard.canActivate(contextOf(request));
expect(request.auth?.userId).toBe('u1');
expect(patch).toHaveBeenCalled();
});
});
describe('PermissionGuard / SpecialPermissionGuard / ScopeGuard', () => {
const auth = authFixture();
const request = { auth } as AuthenticatedRequest;
it('缺普通权限点返回 40301,特殊权限留给下一守卫', () => {
const guard = new PermissionGuard(
reflectorFor({ [PERMISSIONS_KEY]: ['user:manage'] }) as never,
);
expect(() => guard.canActivate(contextOf(request))).toThrow(AppException);
try {
guard.canActivate(contextOf(request));
} catch (error) {
expect((error as AppException).code).toBe(ErrorCode.PERMISSION_DENIED);
}
const specialOnly = new PermissionGuard(
reflectorFor({ [PERMISSIONS_KEY]: ['audit:export'] }) as never,
);
expect(specialOnly.canActivate(contextOf(request))).toBe(true);
});
it('特殊权限缺失返回 40303,已授予则通过', () => {
const missing = new SpecialPermissionGuard(
reflectorFor({ [PERMISSIONS_KEY]: ['audit:export'] }) as never,
);
expectCode(
() => missing.canActivate(contextOf(request)),
ErrorCode.SPECIAL_PERMISSION_REQUIRED,
);
const granted = new SpecialPermissionGuard(
reflectorFor({ [PERMISSIONS_KEY]: ['user:view'] }) as never,
);
expect(granted.canActivate(contextOf(request))).toBe(true);
});
it('未登录时普通守卫返回 40101;ScopeGuard 骨架放行', () => {
const guard = new PermissionGuard(reflectorFor({ [PERMISSIONS_KEY]: ['user:view'] }) as never);
expectCode(() => guard.canActivate(contextOf({})), ErrorCode.UNAUTHENTICATED);
expect(new ScopeGuard().canActivate(contextOf(request))).toBe(true);
expect(new ScopeGuard().canActivate(contextOf({}))).toBe(true);
});
it('@Audit 元数据可读取', () => {
const handler = (): void => undefined;
Reflect.defineMetadata(AUDIT_ACTION_KEY, 'identity.user.create', handler);
expect(Reflect.getMetadata(AUDIT_ACTION_KEY, handler)).toBe('identity.user.create');
});
});
@@ -0,0 +1,94 @@
import { createHash } from 'node:crypto';
import { ErrorCode } from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
import { AppException } from '../errors/app-exception';
import type { JwtAccessPayload, JwtRefreshPayload } from './auth.types';
type TokenType = 'access' | 'refresh';
function mapTokenError(error: unknown): AppException {
if (error instanceof AppException) {
return error;
}
if (error instanceof Error && error.name === 'TokenExpiredError') {
return new AppException({ code: ErrorCode.TOKEN_EXPIRED });
}
return new AppException({ code: ErrorCode.SIGNATURE_INVALID });
}
function assertTokenType<T extends { typ: TokenType }>(payload: T, expected: TokenType): T {
if (payload.typ !== expected) {
throw new AppException({ code: ErrorCode.SIGNATURE_INVALID, message: '令牌类型不正确' });
}
return payload;
}
/**
* 令牌签发与校验。
* - access / refresh 使用不同密钥,避免 refresh 当 access 使用;
* - refresh 载荷带 jti + fam,服务端 refresh_tokens 表保存哈希与吊销状态。
*/
@Injectable()
export class TokenService {
constructor(
@Inject(JwtService) private readonly jwt: JwtService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
issueAccess(userId: string): string {
const payload: JwtAccessPayload = { sub: userId, typ: 'access' };
return this.jwt.sign(payload, {
secret: this.config.jwtAccessSecret,
expiresIn: this.config.jwtAccessTtlSeconds,
});
}
issueRefresh(userId: string, jti: string, familyId: string): string {
const payload: JwtRefreshPayload = { sub: userId, typ: 'refresh', jti, fam: familyId };
return this.jwt.sign(payload, {
secret: this.config.jwtRefreshSecret,
expiresIn: this.config.jwtRefreshTtlSeconds,
});
}
verifyAccess(token: string): JwtAccessPayload {
return assertTokenType(this.decodeAccess(token), 'access');
}
verifyRefresh(token: string): JwtRefreshPayload {
return assertTokenType(this.decodeRefresh(token), 'refresh');
}
hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
get accessTtlSeconds(): number {
return this.config.jwtAccessTtlSeconds;
}
get refreshTtlSeconds(): number {
return this.config.jwtRefreshTtlSeconds;
}
private decodeAccess(token: string): JwtAccessPayload {
try {
return this.jwt.verify<JwtAccessPayload>(token, { secret: this.config.jwtAccessSecret });
} catch (error) {
throw mapTokenError(error);
}
}
private decodeRefresh(token: string): JwtRefreshPayload {
try {
return this.jwt.verify<JwtRefreshPayload>(token, { secret: this.config.jwtRefreshSecret });
} catch (error) {
throw mapTokenError(error);
}
}
}
+19
View File
@@ -7,6 +7,8 @@ const validEnv: NodeJS.ProcessEnv = {
DATABASE_URL: 'postgresql://lingxi:lingxi@127.0.0.1:15432/lingxi_test',
REDIS_URL: 'redis://127.0.0.1:16379',
WEB_ORIGIN: 'http://127.0.0.1:5173,http://localhost:5173',
JWT_ACCESS_SECRET: 'test-access-secret-at-least-32-characters',
JWT_REFRESH_SECRET: 'test-refresh-secret-at-least-32-characters',
};
describe('loadEnv', () => {
@@ -20,6 +22,23 @@ describe('loadEnv', () => {
expect(config.maxUploadMb).toBe(500);
expect(config.featureVideo).toBe(false);
expect(config.webOrigins).toEqual(['http://127.0.0.1:5173', 'http://localhost:5173']);
expect(config.jwtAccessTtlSeconds).toBe(900);
expect(config.jwtRefreshTtlSeconds).toBe(604800);
expect(config.authMaxLoginFailures).toBe(5);
expect(config.authLockMinutes).toBe(15);
});
it('缺少 JWT 密钥时抛错并带字段名', () => {
expect(() => loadEnv({ ...validEnv, JWT_ACCESS_SECRET: undefined })).toThrow(
/JWT_ACCESS_SECRET/,
);
expect(() => loadEnv({ ...validEnv, JWT_REFRESH_SECRET: undefined })).toThrow(
/JWT_REFRESH_SECRET/,
);
});
it('JWT 密钥过短会被拒绝', () => {
expect(() => loadEnv({ ...validEnv, JWT_ACCESS_SECRET: 'short' })).toThrow(/JWT_ACCESS_SECRET/);
});
it('字符串数字会被转成 number', () => {
+19
View File
@@ -25,6 +25,13 @@ const envSchema = z.object({
.enum(['true', 'false'])
.default('false')
.transform((value) => value === 'true'),
// ---------- 认证(A1 identity) ----------
JWT_ACCESS_SECRET: z.string().min(32, 'JWT_ACCESS_SECRET 至少 32 位'),
JWT_REFRESH_SECRET: z.string().min(32, 'JWT_REFRESH_SECRET 至少 32 位'),
JWT_ACCESS_TTL_SECONDS: z.coerce.number().int().min(60).max(86400).default(900),
JWT_REFRESH_TTL_SECONDS: z.coerce.number().int().min(3600).max(2592000).default(604800),
AUTH_MAX_LOGIN_FAILURES: z.coerce.number().int().min(1).max(20).default(5),
AUTH_LOCK_MINUTES: z.coerce.number().int().min(1).max(1440).default(15),
});
export type RawEnv = z.infer<typeof envSchema>;
@@ -40,6 +47,12 @@ export interface AppConfig {
webOrigins: string[];
maxUploadMb: number;
featureVideo: boolean;
jwtAccessSecret: string;
jwtRefreshSecret: string;
jwtAccessTtlSeconds: number;
jwtRefreshTtlSeconds: number;
authMaxLoginFailures: number;
authLockMinutes: number;
}
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppConfig {
@@ -64,5 +77,11 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppConfig {
.filter((origin) => origin.length > 0),
maxUploadMb: env.MAX_UPLOAD_MB,
featureVideo: env.FEATURE_VIDEO,
jwtAccessSecret: env.JWT_ACCESS_SECRET,
jwtRefreshSecret: env.JWT_REFRESH_SECRET,
jwtAccessTtlSeconds: env.JWT_ACCESS_TTL_SECONDS,
jwtRefreshTtlSeconds: env.JWT_REFRESH_TTL_SECONDS,
authMaxLoginFailures: env.AUTH_MAX_LOGIN_FAILURES,
authLockMinutes: env.AUTH_LOCK_MINUTES,
};
}
+8 -3
View File
@@ -5,13 +5,18 @@ import { config as loadDotenvFile } from 'dotenv';
/**
* 按工程规范 09 的顺序加载本地环境变量:.env.local 优先,.env 兜底。
* 同时向上查找两级目录,兼容「从 apps/api 或包内跑 vitest」的工作目录差异。
* 只做加载,不做校验(校验见 config/env.ts)。
*/
export function loadDotenv(): void {
const starts = [process.cwd(), resolve(process.cwd(), '..'), resolve(process.cwd(), '..', '..')];
const directories = [...new Set(starts.map((item) => resolve(item)))];
for (const file of ['.env.local', '.env']) {
const path = resolve(process.cwd(), file);
if (existsSync(path)) {
loadDotenvFile({ path });
for (const directory of directories) {
const path = resolve(directory, file);
if (existsSync(path)) {
loadDotenvFile({ path });
}
}
}
}
@@ -0,0 +1,86 @@
import {
changePasswordInputSchema,
ErrorCode,
type LoginInput,
loginInputSchema,
type LoginResponse,
type LogoutInput,
logoutInputSchema,
type RefreshInput,
refreshInputSchema,
type TokenPair,
} from '@lingxi/shared';
import { Body, Controller, HttpCode, Inject, Post, Req } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { FastifyRequest } from 'fastify';
import { AppException } from '../../common/errors/app-exception';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import type { AuthContext, AuthRequestMeta } from '../../common/security/auth.types';
import { Audit, CurrentUser, Public } from '../../common/security/decorators';
import { AuthService } from './auth.service';
function metaOf(request: FastifyRequest & { id?: string }): AuthRequestMeta {
return {
ip: request.ip.length > 0 ? request.ip : null,
userAgent: request.headers['user-agent'] ?? null,
traceId: typeof request.id === 'string' ? request.id : '',
};
}
/** 认证与会话:登录(返回 tokenPair + me)、刷新旋转、登出、强制改密 */
@ApiTags('identity-auth')
@Controller('auth')
export class AuthController {
constructor(@Inject(AuthService) private readonly auth: AuthService) {}
@Public()
@Post('login')
@HttpCode(200)
@ApiOperation({
summary: '账号密码登录',
description: '返回 accessToken / refreshToken / expiresIn / me',
})
async login(
@Body(new ZodValidationPipe(loginInputSchema)) input: LoginInput,
@Req() request: FastifyRequest,
): Promise<LoginResponse> {
return this.auth.login(input, metaOf(request));
}
@Public()
@Post('refresh')
@HttpCode(200)
@ApiOperation({ summary: '刷新令牌(旋转,旧 refresh 立即失效)' })
async refresh(
@Body(new ZodValidationPipe(refreshInputSchema)) input: RefreshInput,
): Promise<TokenPair> {
return this.auth.refresh(input);
}
@Public()
@Post('logout')
@HttpCode(204)
@ApiOperation({ summary: '登出', description: '吊销 refresh 令牌所在家族' })
async logout(@Body(new ZodValidationPipe(logoutInputSchema)) input: LogoutInput): Promise<void> {
await this.auth.logout(input);
}
@Post('change-password')
@HttpCode(204)
@Audit('identity.auth.change-password')
@ApiOperation({ summary: '修改密码(首登强改)' })
async changePassword(
@Body(new ZodValidationPipe(changePasswordInputSchema))
input: {
currentPassword: string;
newPassword: string;
},
@CurrentUser() auth: AuthContext | null,
): Promise<void> {
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
await this.auth.changePassword(auth.userId, input);
}
}
@@ -0,0 +1,266 @@
import { ErrorCode } from '@lingxi/shared';
import { JwtService } from '@nestjs/jwt';
import type { User } from '@prisma/client';
import { describe, expect, it, type Mock, vi } from 'vitest';
import { AppException } from '../../common/errors/app-exception';
import type { PermissionService } from '../../common/security/permission.service';
import { TokenService } from '../../common/security/token.service';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { testConfig } from '../../testing/test-app';
import { AuthService } from './auth.service';
import type { LoginFailureService } from './login-failure.service';
import type { MeService } from './me.service';
const tokens = new TokenService(new JwtService({}), testConfig);
function userFixture(overrides: Partial<User> = {}): User {
const base: User = {
id: 'u1',
account: 'chen.mo',
email: 'chen.mo@lingxi.cn',
name: '陈默',
phone: '13866202046',
passwordHash: 'hash',
departmentId: 'g1',
jobTitle: null,
status: 'ACTIVE',
mustChangePassword: false,
lastLoginAt: null,
lastLoginIp: null,
disabledAt: null,
remark: null,
createdBy: null,
createdAt: new Date('2026-09-30T10:40:00.000Z'),
updatedAt: new Date('2026-11-01T08:22:00.000Z'),
};
return { ...base, ...overrides };
}
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
return promise.then(
() => expect.unreachable('应抛出异常'),
(error: unknown) => {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
},
);
}
interface PrismaMock {
user: { findFirst: Mock; findUnique: Mock; update: Mock };
refreshToken: { create: Mock; findUnique: Mock; updateMany: Mock };
}
interface Harness {
service: AuthService;
prisma: PrismaMock;
failures: ReturnType<typeof failureMock>;
passwords: ReturnType<typeof passwordMock>;
permissions: { resolveAuth: Mock };
me: { getMe: Mock };
}
function failureMock(): {
status: ReturnType<typeof vi.fn>;
record: ReturnType<typeof vi.fn>;
clear: ReturnType<typeof vi.fn>;
} {
return {
status: vi.fn().mockResolvedValue({ locked: false, remaining: 5 }),
record: vi.fn().mockResolvedValue(1),
clear: vi.fn().mockResolvedValue(undefined),
};
}
function passwordMock(): {
verify: ReturnType<typeof vi.fn>;
hash: ReturnType<typeof vi.fn>;
} {
return { verify: vi.fn().mockResolvedValue(true), hash: vi.fn().mockResolvedValue('new-hash') };
}
function harness(): Harness {
const prisma: PrismaMock = {
user: {
findFirst: vi.fn().mockResolvedValue(userFixture()),
findUnique: vi.fn().mockResolvedValue(userFixture()),
update: vi.fn().mockResolvedValue(userFixture()),
},
refreshToken: {
create: vi.fn().mockResolvedValue({}),
findUnique: vi.fn().mockResolvedValue(null),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
},
};
const failures = failureMock();
const passwords = passwordMock();
const permissions = {
resolveAuth: vi.fn().mockResolvedValue({
userId: 'u1',
account: 'chen.mo',
status: 'active',
roles: [],
permissions: ['user:manage'],
dataScope: { type: 'all' },
mustChangePassword: false,
}),
};
const me = {
getMe: vi.fn().mockResolvedValue({
user: { id: 'u1' },
roles: [],
permissions: [],
dataScope: { type: 'all' },
mustChangePassword: false,
}),
};
const service = new AuthService(
prisma as unknown as PrismaService,
testConfig,
tokens,
passwords,
failures as unknown as LoginFailureService,
permissions as unknown as PermissionService,
me as unknown as MeService,
);
return { service, prisma, failures, passwords, permissions, me };
}
const meta = { ip: '127.0.0.1', userAgent: 'vitest', traceId: 'trace-1' };
describe('AuthService.login', () => {
it('成功登录返回 tokenPair + me,并清空失败计数、记录登录时间', async () => {
const { service, prisma, failures } = harness();
const result = await service.login({ account: 'chen.mo', password: 'lingxi2026' }, meta);
expect(result.accessToken.length).toBeGreaterThan(0);
expect(result.refreshToken.length).toBeGreaterThan(0);
expect(result.me).toBeDefined();
expect(failures.clear).toHaveBeenCalledWith('chen.mo');
expect(prisma.user.update).toHaveBeenCalled();
});
it('锁定期间拒绝登录(42901);账号不存在与密码错误统一返回 40101', async () => {
const locked = harness();
locked.failures.status.mockResolvedValueOnce({ locked: true, remaining: 0 });
await expectRejected(
locked.service.login({ account: 'chen.mo', password: 'x' }, meta),
ErrorCode.RATE_LIMITED,
);
const missing = harness();
missing.prisma.user.findFirst.mockResolvedValueOnce(null);
await expectRejected(
missing.service.login({ account: 'nobody', password: 'x' }, meta),
ErrorCode.UNAUTHENTICATED,
);
expect(missing.failures.record).toHaveBeenCalled();
const wrong = harness();
wrong.passwords.verify.mockResolvedValueOnce(false);
await expectRejected(
wrong.service.login({ account: 'chen.mo', password: 'bad-pass1' }, meta),
ErrorCode.UNAUTHENTICATED,
);
});
it('停用账号拒绝登录(40301)', async () => {
const { service, prisma } = harness();
prisma.user.findFirst.mockResolvedValueOnce(userFixture({ status: 'DISABLED' }));
await expectRejected(
service.login({ account: 'chen.mo', password: 'lingxi2026' }, meta),
ErrorCode.PERMISSION_DENIED,
);
});
});
describe('AuthService.refresh / logout', () => {
it('刷新旋转成功并吊销旧 jti', async () => {
const { service, prisma } = harness();
const refreshToken = tokens.issueRefresh('u1', 'jti1', 'fam1');
prisma.refreshToken.findUnique.mockResolvedValueOnce({
id: 'jti1',
userId: 'u1',
familyId: 'fam1',
tokenHash: tokens.hashToken(refreshToken),
expiresAt: new Date(Date.now() + 60_000),
revokedAt: null,
replacedById: null,
ip: null,
userAgent: null,
createdAt: new Date(),
});
const pair = await service.refresh({ refreshToken });
expect(pair.refreshToken).not.toBe(refreshToken);
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
expect(prisma.refreshToken.create).toHaveBeenCalled();
});
it('已吊销 refresh 重放:整族吊销并返回 40101', async () => {
const { service, prisma } = harness();
const refreshToken = tokens.issueRefresh('u1', 'jti1', 'fam1');
prisma.refreshToken.findUnique.mockResolvedValueOnce({
id: 'jti1',
userId: 'u1',
familyId: 'fam1',
tokenHash: tokens.hashToken(refreshToken),
expiresAt: new Date(Date.now() + 60_000),
revokedAt: new Date(),
replacedById: null,
ip: null,
userAgent: null,
createdAt: new Date(),
});
await expectRejected(service.refresh({ refreshToken }), ErrorCode.UNAUTHENTICATED);
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { familyId: 'fam1', revokedAt: null } }),
);
});
it('登出按 refresh 家族吊销;无 token 时按用户吊销', async () => {
const { service, prisma } = harness();
const refreshToken = tokens.issueRefresh('u1', 'jti1', 'fam1');
prisma.refreshToken.findUnique.mockResolvedValueOnce({
id: 'jti1',
userId: 'u1',
familyId: 'fam1',
tokenHash: tokens.hashToken(refreshToken),
expiresAt: new Date(Date.now() + 60_000),
revokedAt: null,
replacedById: null,
ip: null,
userAgent: null,
createdAt: new Date(),
});
await service.logout({ refreshToken });
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
await service.logout({}, 'u1');
expect(prisma.refreshToken.updateMany).toHaveBeenCalledTimes(2);
});
});
describe('AuthService.changePassword', () => {
it('当前密码错误返回 40101;成功则改密并吊销全部会话', async () => {
const wrong = harness();
wrong.passwords.verify.mockResolvedValueOnce(false);
await expectRejected(
wrong.service.changePassword('u1', { currentPassword: 'wrong', newPassword: 'abc12345' }),
ErrorCode.UNAUTHENTICATED,
);
const { service, prisma } = harness();
await service.changePassword('u1', { currentPassword: 'lingxi2026', newPassword: 'abc12345' });
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ mustChangePassword: false }) }),
);
expect(prisma.refreshToken.updateMany).toHaveBeenCalled();
});
it('新旧密码相同返回 42201', async () => {
const { service } = harness();
await expectRejected(
service.changePassword('u1', { currentPassword: 'lingxi2026', newPassword: 'lingxi2026' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
});
});
@@ -0,0 +1,223 @@
import {
type ChangePasswordInput,
ErrorCode,
type LoginInput,
type LoginResponse,
type LogoutInput,
type RefreshInput,
type TokenPair,
ulid,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import type { RefreshToken, User } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { AuthRequestMeta } from '../../common/security/auth.types';
import { PermissionService } from '../../common/security/permission.service';
import { TokenService } from '../../common/security/token.service';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { LoginFailureService } from './login-failure.service';
import { MeService } from './me.service';
import { PasswordService } from './password.service';
/** 认证与会话:登录、刷新旋转、登出、强制改密 */
@Injectable()
export class AuthService {
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
@Inject(TokenService) private readonly tokens: TokenService,
@Inject(PasswordService) private readonly passwords: PasswordService,
@Inject(LoginFailureService) private readonly failures: LoginFailureService,
@Inject(PermissionService) private readonly permissions: PermissionService,
@Inject(MeService) private readonly me: MeService,
) {}
async login(input: LoginInput, meta: AuthRequestMeta): Promise<LoginResponse> {
const account = input.account.trim().toLowerCase();
const status = await this.failures.status(account);
if (status.locked) {
throw this.lockedError();
}
const user = await this.findByAccount(account);
if (user === null) {
await this.failures.record(account);
throw this.badCredentials();
}
const matched = await this.passwords.verify(user.passwordHash, input.password);
if (!matched) {
await this.recordFailure(account);
throw this.badCredentials();
}
if (user.status === 'DISABLED') {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '账号已停用' });
}
await this.failures.clear(account);
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date(), lastLoginIp: meta.ip },
});
const auth = await this.permissions.resolveAuth(user.id);
const { pair } = await this.createTokenPair(user.id, null, meta);
const me = await this.me.getMe(user.id, auth?.permissions ?? []);
return { ...pair, me };
}
async refresh(input: RefreshInput): Promise<TokenPair> {
const payload = this.tokens.verifyRefresh(input.refreshToken);
const record = await this.prisma.refreshToken.findUnique({ where: { id: payload.jti } });
if (record === null) {
throw new AppException({ code: ErrorCode.SIGNATURE_INVALID, message: '刷新令牌无效' });
}
if (record.tokenHash !== this.tokens.hashToken(input.refreshToken)) {
throw new AppException({ code: ErrorCode.SIGNATURE_INVALID, message: '刷新令牌无效' });
}
if (record.revokedAt !== null) {
await this.revokeFamily(record.familyId);
throw new AppException({
code: ErrorCode.UNAUTHENTICATED,
message: '刷新令牌已失效,请重新登录',
});
}
if (record.expiresAt.getTime() <= Date.now()) {
throw new AppException({ code: ErrorCode.TOKEN_EXPIRED });
}
const user = await this.prisma.user.findUnique({ where: { id: record.userId } });
if (user === null || user.status === 'DISABLED') {
throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '账号已停用' });
}
return this.rotate(record);
}
async logout(input: LogoutInput, userId?: string): Promise<void> {
if (input.refreshToken !== undefined) {
await this.revokeByToken(input.refreshToken);
return;
}
if (userId !== undefined) {
await this.revokeAllForUser(userId);
}
}
async changePassword(userId: string, input: ChangePasswordInput): Promise<void> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (user === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
const matched = await this.passwords.verify(user.passwordHash, input.currentPassword);
if (!matched) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '当前密码不正确' });
}
if (input.currentPassword === input.newPassword) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '新密码不能与当前密码相同',
});
}
const passwordHash = await this.passwords.hash(input.newPassword);
await this.prisma.user.update({
where: { id: userId },
data: { passwordHash, mustChangePassword: false },
});
await this.revokeAllForUser(userId);
}
private async findByAccount(account: string): Promise<User | null> {
return this.prisma.user.findFirst({
where: { OR: [{ account }, { email: account }] },
});
}
private async recordFailure(account: string): Promise<void> {
const failures = await this.failures.record(account);
if (failures >= this.config.authMaxLoginFailures) {
throw this.lockedError();
}
}
private badCredentials(): AppException {
return new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '账号或密码不正确' });
}
private lockedError(): AppException {
return new AppException({
code: ErrorCode.RATE_LIMITED,
message: `登录失败次数过多,请 ${String(this.config.authLockMinutes)} 分钟后重试`,
});
}
private async createTokenPair(
userId: string,
familyId: string | null,
meta: AuthRequestMeta,
): Promise<{ pair: TokenPair; jti: string }> {
const jti = ulid();
const family = familyId ?? ulid();
const accessToken = this.tokens.issueAccess(userId);
const refreshToken = this.tokens.issueRefresh(userId, jti, family);
await this.prisma.refreshToken.create({
data: {
id: jti,
userId,
familyId: family,
tokenHash: this.tokens.hashToken(refreshToken),
expiresAt: new Date(Date.now() + this.tokens.refreshTtlSeconds * 1000),
ip: meta.ip,
userAgent: meta.userAgent,
},
});
return { pair: { accessToken, refreshToken, expiresIn: this.tokens.accessTtlSeconds }, jti };
}
/** 刷新旋转:旧 token 原子吊销(revokedAt 为空才成功),新 token 同族;重放则整族吊销 */
private async rotate(record: RefreshToken): Promise<TokenPair> {
const { pair, jti } = await this.createTokenPair(record.userId, record.familyId, {
ip: record.ip,
userAgent: record.userAgent,
traceId: '',
});
const updated = await this.prisma.refreshToken.updateMany({
where: { id: record.id, revokedAt: null },
data: { revokedAt: new Date(), replacedById: jti },
});
if (updated.count === 0) {
await this.revokeFamily(record.familyId);
throw new AppException({
code: ErrorCode.UNAUTHENTICATED,
message: '刷新令牌已失效,请重新登录',
});
}
return pair;
}
private async revokeByToken(token: string): Promise<void> {
let payload;
try {
payload = this.tokens.verifyRefresh(token);
} catch {
return;
}
const record = await this.prisma.refreshToken.findUnique({ where: { id: payload.jti } });
if (record === null) {
return;
}
await this.revokeFamily(record.familyId);
}
private async revokeFamily(familyId: string): Promise<void> {
await this.prisma.refreshToken.updateMany({
where: { familyId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
private async revokeAllForUser(userId: string): Promise<void> {
await this.prisma.refreshToken.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
}
@@ -0,0 +1,60 @@
import { Permission, ulidSchema } from '@lingxi/shared';
import { Body, Controller, Get, Inject, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { z } from 'zod';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { Audit, RequirePermissions } from '../../common/security/decorators';
import { DepartmentsService, type DepartmentTreeNode } from './departments.service';
const createDepartmentSchema = z
.object({
name: z.string().trim().min(1, '请输入部门名称').max(20, '部门名称最多 20 个字符'),
parentId: ulidSchema.nullable().default(null),
})
.strict();
type CreateDepartmentBody = z.infer<typeof createDepartmentSchema>;
const updateDepartmentSchema = z
.object({ name: z.string().trim().min(1, '请输入部门名称').max(20, '部门名称最多 20 个字符') })
.strict();
type UpdateDepartmentBody = z.infer<typeof updateDepartmentSchema>;
/** 组织架构:两级部门树(A1 前端只读;写接口先备好,供后续批次使用) */
@ApiTags('identity-departments')
@ApiBearerAuth()
@Controller('departments')
export class DepartmentsController {
constructor(@Inject(DepartmentsService) private readonly departments: DepartmentsService) {}
@Get('tree')
@RequirePermissions(Permission.USER_VIEW)
@ApiOperation({ summary: '部门树(两级,含成员数与负责人)' })
async tree(): Promise<{ items: DepartmentTreeNode[] }> {
return { items: await this.departments.tree() };
}
@Post()
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.department.create')
@ApiOperation({ summary: '新建部门 / 小组', description: '最多两级,在小组下新建返回 42201' })
async create(
@Body(new ZodValidationPipe(createDepartmentSchema)) input: CreateDepartmentBody,
): Promise<{ id: string }> {
return this.departments.create(input);
}
@Patch(':id')
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.department.rename')
@ApiOperation({ summary: '部门改名' })
async rename(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateDepartmentSchema)) input: UpdateDepartmentBody,
): Promise<{ id: string }> {
await this.departments.rename(id, input.name);
return { id };
}
}
@@ -0,0 +1,127 @@
import { type DepartmentNode, ErrorCode, ulid } from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
/** 部门树节点:契约节点 + 子节点(最多两级) */
export interface DepartmentTreeNode extends DepartmentNode {
children: DepartmentTreeNode[];
}
export interface CreateDepartmentPayload {
name: string;
parentId: string | null;
}
function codeOf(name: string): string {
const normalized = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return `${normalized.length > 0 ? normalized.slice(0, 40) : 'dept'}-${ulid().slice(-6).toLowerCase()}`;
}
function mapDepartmentError(error: unknown): AppException {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '同级部门名称已存在' });
}
return new AppException({ code: ErrorCode.INTERNAL_ERROR, message: '部门保存失败' });
}
/** 组织架构:两级部门树(含成员数与负责人)、新建、改名 */
@Injectable()
export class DepartmentsService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async tree(): Promise<DepartmentTreeNode[]> {
const [departments, counts] = await Promise.all([
this.prisma.department.findMany({
orderBy: [{ parentId: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }],
include: { leader: { select: { id: true, name: true } } },
}),
this.prisma.user.groupBy({
by: ['departmentId'],
where: { status: 'ACTIVE' },
_count: { _all: true },
}),
]);
const byDept = new Map(counts.map((row) => [row.departmentId, row._count._all]));
const nodes = new Map<string, DepartmentTreeNode>();
for (const department of departments) {
nodes.set(department.id, {
id: department.id,
name: department.name,
parentId: department.parentId,
leader: department.leader,
memberCount: byDept.get(department.id) ?? 0,
createdAt: department.createdAt.toISOString(),
children: [],
});
}
const roots: DepartmentTreeNode[] = [];
for (const node of nodes.values()) {
if (node.parentId === null) {
roots.push(node);
continue;
}
const parent = nodes.get(node.parentId);
if (parent !== undefined) {
parent.children.push(node);
}
}
for (const root of roots) {
root.memberCount += root.children.reduce((sum, child) => sum + child.memberCount, 0);
}
return roots;
}
async create(input: CreateDepartmentPayload): Promise<{ id: string }> {
await this.assertParentUsable(input.parentId);
try {
const created = await this.prisma.department.create({
data: {
id: ulid(),
name: input.name,
parentId: input.parentId,
code: codeOf(input.name),
},
select: { id: true },
});
return created;
} catch (error) {
throw mapDepartmentError(error);
}
}
async rename(id: string, name: string): Promise<void> {
const existing = await this.prisma.department.findUnique({ where: { id } });
if (existing === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '部门不存在' });
}
try {
await this.prisma.department.update({ where: { id }, data: { name } });
} catch (error) {
throw mapDepartmentError(error);
}
}
private async assertParentUsable(parentId: string | null): Promise<void> {
if (parentId === null) {
return;
}
const parent = await this.prisma.department.findUnique({ where: { id: parentId } });
if (parent === null) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '上级部门不存在' });
}
if (parent.parentId !== null) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '部门最多两级,不能在小组下再建部门',
});
}
}
}
@@ -0,0 +1,238 @@
import { API_PREFIX, ErrorCode, ulid } from '@lingxi/shared';
import { PrismaClient } from '@prisma/client';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createApp, type CreatedApiApp } from '../../bootstrap';
import { loadDotenv } from '../../config/load-dotenv';
/** 与 prisma/seed.ts 一致的示例账号初始密码 lingxi2026(Argon2id) */
const PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
const PASSWORD = 'lingxi2026';
const ACCOUNT_PREFIX = 'it.';
const API = API_PREFIX;
const prisma = new PrismaClient();
interface FixtureUser {
id: string;
account: string;
}
interface TokenBody {
accessToken: string;
refreshToken: string;
expiresIn: number;
me: { permissions: string[]; user: { id: string; phone?: string }; mustChangePassword: boolean };
}
interface ErrorBody {
code: number;
message: string;
}
async function createFixtureUser(suffix: string, roleCode: string): Promise<FixtureUser> {
const [department, role] = await Promise.all([
prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }),
prisma.role.findUniqueOrThrow({ where: { code: roleCode } }),
]);
const account = `${ACCOUNT_PREFIX}${suffix}`;
const id = ulid();
await prisma.user.create({
data: {
id,
name: `集成测试${suffix}`,
account,
email: `${account}@lingxi.cn`,
phone: `137${String(Date.now()).slice(-8)}`,
passwordHash: PASSWORD_HASH,
departmentId: department.id,
mustChangePassword: false,
roles: { create: [{ roleId: role.id }] },
},
});
return { id, account };
}
async function login(
app: CreatedApiApp['app'],
account: string,
): Promise<{ status: number; body: TokenBody }> {
const response = await app.inject({
method: 'POST',
url: `${API}/auth/login`,
payload: { account, password: PASSWORD },
});
return { status: response.statusCode, body: response.json<TokenBody>() };
}
async function authed(
app: CreatedApiApp['app'],
method: 'GET' | 'POST' | 'PATCH',
url: string,
token: string,
payload?: unknown,
): Promise<number> {
const response = await app.inject({
method,
url: `${API}${url}`,
headers: { authorization: `Bearer ${token}` },
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
});
return response.statusCode;
}
async function setRole(userId: string, roleCode: string): Promise<void> {
const role = await prisma.role.findUniqueOrThrow({ where: { code: roleCode } });
await prisma.userRole.deleteMany({ where: { userId } });
await prisma.userRole.create({ data: { userId, roleId: role.id } });
}
describe('identity 集成(真实 PG / Redis)', () => {
let created: CreatedApiApp;
let manager: FixtureUser;
let viewer: FixtureUser;
beforeAll(async () => {
loadDotenv();
created = await createApp();
await created.app.init();
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
manager = await createFixtureUser('manager', 'platform_admin');
viewer = await createFixtureUser('viewer', 'designer');
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } });
await prisma.$disconnect();
await created.app.close();
});
it('登录返回 tokenPair + me,且权限点来自角色', async () => {
const { status, body } = await login(created.app, manager.account);
expect(status).toBe(200);
expect(body.refreshToken.length).toBeGreaterThan(0);
expect(body.expiresIn).toBeGreaterThan(0);
expect(body.me.permissions).toContain('user:manage');
expect(body.me.mustChangePassword).toBe(false);
expect(body.me.user.phone).toBeDefined();
});
it('/me 与登录上下文一致;列表只回 phoneMasked,详情对 user:manage 回全号', async () => {
const { body } = await login(created.app, manager.account);
const meResponse = await created.app.inject({
method: 'GET',
url: `${API}/me`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(meResponse.statusCode).toBe(200);
const me = meResponse.json<{ user: { account: string } }>();
expect(me.user.account).toBe(manager.account);
const listResponse = await created.app.inject({
method: 'GET',
url: `${API}/users?limit=5`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(listResponse.statusCode).toBe(200);
const list = listResponse.json<{
items: Record<string, unknown>[];
next_cursor: string | null;
}>();
expect(list.items.length).toBeGreaterThan(0);
expect(list.items[0]?.phoneMasked).toMatch(/^\d{3}\*{4}\d{4}$/);
expect(list.items[0]?.phone).toBeUndefined();
expect(list.next_cursor === null || typeof list.next_cursor === 'string').toBe(true);
const detailResponse = await created.app.inject({
method: 'GET',
url: `${API}/users/${manager.id}`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(detailResponse.statusCode).toBe(200);
const detail = detailResponse.json<{ phone?: string }>();
expect(detail.phone).toBeDefined();
});
it('无 user:view 权限返回 40301;授予角色后下一次请求立即生效', async () => {
const { body } = await login(created.app, viewer.account);
expect(await authed(created.app, 'GET', '/users', body.accessToken)).toBe(403);
await setRole(viewer.id, 'platform_admin');
expect(await authed(created.app, 'GET', '/users', body.accessToken)).toBe(200);
});
it('刷新旋转:旧 refresh 立即失效,重放触发整族吊销', async () => {
const { body } = await login(created.app, manager.account);
const refreshed = await created.app.inject({
method: 'POST',
url: `${API}/auth/refresh`,
payload: { refreshToken: body.refreshToken },
});
expect(refreshed.statusCode).toBe(200);
const next = refreshed.json<{ refreshToken: string }>();
expect(next.refreshToken).not.toBe(body.refreshToken);
const replay = await created.app.inject({
method: 'POST',
url: `${API}/auth/refresh`,
payload: { refreshToken: body.refreshToken },
});
expect(replay.statusCode).toBe(401);
const revoked = replay.json<ErrorBody>();
expect(revoked.code).toBe(ErrorCode.UNAUTHENTICATED);
});
it('停用后旧 access token 立即失效,且无法再登录', async () => {
const { body } = await login(created.app, viewer.account);
const managerLogin = await login(created.app, manager.account);
const managerDisabled = await created.app.inject({
method: 'POST',
url: `${API}/users/${viewer.id}/disable`,
headers: { authorization: `Bearer ${managerLogin.body.accessToken}` },
payload: { reason: '集成测试停用' },
});
expect(managerDisabled.statusCode).toBe(200);
const meAfter = await created.app.inject({
method: 'GET',
url: `${API}/me`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(meAfter.statusCode).toBe(403);
expect(meAfter.json<ErrorBody>().code).toBe(ErrorCode.PERMISSION_DENIED);
const relogin = await login(created.app, viewer.account);
expect(relogin.status).toBe(403);
});
it('登出后 refresh 不可用;部门树与角色列表可读', async () => {
const { body } = await login(created.app, manager.account);
const logout = await created.app.inject({
method: 'POST',
url: `${API}/auth/logout`,
payload: { refreshToken: body.refreshToken },
});
expect(logout.statusCode).toBe(204);
const refreshAfter = await created.app.inject({
method: 'POST',
url: `${API}/auth/refresh`,
payload: { refreshToken: body.refreshToken },
});
expect(refreshAfter.statusCode).toBe(401);
const tree = await created.app.inject({
method: 'GET',
url: `${API}/departments/tree`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(tree.statusCode).toBe(200);
const treeBody = tree.json<{ items: { name: string; children: unknown[] }[] }>();
expect(treeBody.items.length).toBeGreaterThanOrEqual(3);
expect(treeBody.items[0]?.children.length).toBeGreaterThan(0);
const roles = await created.app.inject({
method: 'GET',
url: `${API}/roles`,
headers: { authorization: `Bearer ${body.accessToken}` },
});
expect(roles.statusCode).toBe(200);
expect(roles.json<{ items: unknown[] }>().items.length).toBeGreaterThanOrEqual(8);
});
});
@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { DepartmentsController } from './departments.controller';
import { DepartmentsService } from './departments.service';
import { LoginFailureService } from './login-failure.service';
import { MeController } from './me.controller';
import { MeService } from './me.service';
import { PasswordService } from './password.service';
import { RolesController } from './roles.controller';
import { RolesService } from './roles.service';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
/**
* identity 模块(技术架构 §3):组织与账号、认证与会话、角色绑定、/me 权限上下文。
* 守卫链与请求上下文由 @Global SecurityModule 提供,本模块不重复注册。
*/
@Module({
controllers: [
AuthController,
MeController,
UsersController,
DepartmentsController,
RolesController,
],
providers: [
AuthService,
MeService,
PasswordService,
LoginFailureService,
UsersService,
DepartmentsService,
RolesService,
],
})
export class IdentityModule {}
@@ -0,0 +1,399 @@
import { ErrorCode } from '@lingxi/shared';
import { describe, expect, it, vi } from 'vitest';
import { AppException } from '../../common/errors/app-exception';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { DepartmentsService } from './departments.service';
import { LoginFailureService } from './login-failure.service';
import { MeService } from './me.service';
import { PasswordService } from './password.service';
import { RolesService } from './roles.service';
import { maskPhone, toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper';
import { UsersService } from './users.service';
function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelations {
const base: UserWithRelations = {
id: 'u1',
account: 'chen.mo',
email: 'chen.mo@lingxi.cn',
name: '陈默',
phone: '13866202046',
passwordHash: 'hash',
departmentId: 'g1',
jobTitle: '主管',
status: 'ACTIVE',
mustChangePassword: false,
lastLoginAt: new Date('2026-11-02T01:41:00.000Z'),
lastLoginIp: '192.168.12.46',
disabledAt: null,
remark: null,
createdBy: null,
createdAt: new Date('2026-09-30T10:40:00.000Z'),
updatedAt: new Date('2026-11-01T08:22:00.000Z'),
department: {
id: 'g1',
code: 'content-g1',
name: '素材一组',
parentId: 'd1',
leaderUserId: null,
sortOrder: 1,
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
parent: {
id: 'd1',
code: 'content',
name: '内容生产中心',
parentId: null,
leaderUserId: null,
sortOrder: 1,
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
},
},
roles: [
{
userId: 'u1',
roleId: 'r1',
createdAt: new Date(),
role: {
id: 'r1',
code: 'material_operator',
name: '素材运营',
description: '素材生产',
isSystem: false,
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
},
},
{
userId: 'u1',
roleId: 'r2',
createdAt: new Date(),
role: {
id: 'r2',
code: 'account_owner',
name: '客户负责人',
description: '',
isSystem: false,
status: 'DISABLED',
createdAt: new Date(),
updatedAt: new Date(),
},
},
],
};
return { ...base, ...overrides };
}
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
return promise.then(
() => expect.unreachable('应抛出异常'),
(error: unknown) => {
expect(error).toBeInstanceOf(AppException);
expect((error as AppException).code).toBe(code);
},
);
}
describe('user.mapper', () => {
it('手机号脱敏与部门 / 小组拆分', () => {
expect(maskPhone('13866202046')).toBe('138****2046');
const summary = toUserSummary(userFixture());
expect(summary.departmentId).toBe('d1');
expect(summary.departmentName).toBe('内容生产中心');
expect(summary.groupId).toBe('g1');
expect(summary.groupName).toBe('素材一组');
expect(summary.status).toBe('active');
expect(summary.roles).toHaveLength(1);
expect(summary.roles[0]?.code).toBe('material_operator');
expect(summary.lastLoginAt).toBe('2026-11-02T01:41:00.000Z');
});
it('一级部门直属:groupId 为空;详情按权限决定是否给全号', () => {
const direct = toUserSummary(
userFixture({
department: {
...userFixture().department,
id: 'd1',
name: '平台技术部',
parentId: null,
parent: null,
},
}),
);
expect(direct.groupId).toBeNull();
expect(direct.departmentName).toBe('平台技术部');
const withPhone = toUserDetail(userFixture(), true);
const withoutPhone = toUserDetail(userFixture(), false);
expect(withPhone.phone).toBe('13866202046');
expect(withoutPhone.phone).toBeUndefined();
expect(withPhone.mustChangePassword).toBe(false);
});
});
describe('PasswordService', () => {
it('哈希后可校验,错误口令与非法哈希返回 false', async () => {
const service = new PasswordService();
const hash = await service.hash('lingxi2026');
await expect(service.verify(hash, 'lingxi2026')).resolves.toBe(true);
await expect(service.verify(hash, 'wrong-pass1')).resolves.toBe(false);
await expect(service.verify('not-a-hash', 'x')).resolves.toBe(false);
});
});
describe('LoginFailureService', () => {
function fakeRedis(): {
service: LoginFailureService;
store: Map<string, number>;
del: () => void;
} {
const store = new Map<string, number>();
const client = {
status: 'ready',
connect: vi.fn().mockResolvedValue(undefined),
get: vi.fn((key: string) => Promise.resolve(store.has(key) ? String(store.get(key)) : null)),
del: vi.fn((key: string) => {
store.delete(key);
return Promise.resolve(1);
}),
multi: vi.fn(() => {
let key = '';
const api = {
incr: (target: string) => {
key = target;
store.set(key, (store.get(key) ?? 0) + 1);
return api;
},
expire: () => api,
exec: () => Promise.resolve([[null, store.get(key) ?? 0]]),
};
return api;
}),
};
const service = new LoginFailureService(
{ getClient: () => client } as never,
{ authMaxLoginFailures: 3, authLockMinutes: 15 } as never,
);
return {
service,
store,
del: () => {
store.clear();
},
};
}
it('累计失败达阈值后锁定,成功登录清零', async () => {
const { service, store, del } = fakeRedis();
await expect(service.status('Chen.Mo')).resolves.toEqual({ locked: false, remaining: 3 });
await service.record('chen.mo');
await service.record('chen.mo');
await expect(service.count('chen.mo')).resolves.toBe(2);
await expect(service.status('chen.mo')).resolves.toEqual({ locked: false, remaining: 1 });
await service.record('chen.mo');
await expect(service.status('chen.mo')).resolves.toEqual({ locked: true, remaining: 0 });
await service.clear('chen.mo');
await expect(service.count('chen.mo')).resolves.toBe(0);
del();
expect(store.size).toBe(0);
});
});
describe('UsersService', () => {
const passwords = { hash: vi.fn().mockResolvedValue('hashed') };
function serviceOf(prisma: Record<string, unknown>): UsersService {
return new UsersService(
prisma as unknown as PrismaService,
passwords as unknown as PasswordService,
);
}
it('详情:user:manage 才带手机号全号', async () => {
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
const service = serviceOf(prisma);
const withPhone = await service.detail('u1', ['user:manage']);
const withoutPhone = await service.detail('u1', ['user:view']);
expect(withPhone.phone).toBe('13866202046');
expect(withoutPhone.phone).toBeUndefined();
});
it('列表:游标分页、状态默认 active、排序白名单', async () => {
const rows = [userFixture({ id: 'u1' }), userFixture({ id: 'u2' }), userFixture({ id: 'u3' })];
const findMany = vi.fn().mockResolvedValue(rows);
const service = serviceOf({ user: { findMany } });
const result = await service.list({
limit: 2,
status: 'active',
sort: 'last_login',
order: 'desc',
});
expect(result.items).toHaveLength(2);
expect(result.next_cursor).toBe('u2');
const args = findMany.mock.calls[0]?.[0] as {
where: { status: string };
orderBy: unknown[];
take: number;
};
expect(args.where.status).toBe('ACTIVE');
expect(args.take).toBe(3);
expect(args.orderBy).toEqual([{ lastLoginAt: { sort: 'desc', nulls: 'last' } }, { id: 'asc' }]);
});
it('停用:重复停用 40903,停用自己 42201,客户转交占位 42201', async () => {
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
const service = serviceOf(prisma);
await expectRejected(service.disable('u1', {}, 'u1', []), ErrorCode.BUSINESS_RULE_VIOLATION);
const disabled = userFixture({ status: 'DISABLED' });
const service2 = serviceOf({ user: { findUnique: vi.fn().mockResolvedValue(disabled) } });
await expectRejected(service2.disable('u1', {}, 'u2', []), ErrorCode.STATE_NOT_ALLOWED);
expect(() => service.transferCustomers()).toThrow(AppException);
});
it('停用成功会同步吊销 refresh token;启用恢复状态', async () => {
const transaction = vi.fn().mockResolvedValue([]);
const findUnique = vi
.fn()
.mockResolvedValueOnce(userFixture())
.mockResolvedValueOnce(userFixture({ status: 'DISABLED' }));
const service = serviceOf({
user: { findUnique, update: vi.fn().mockResolvedValue(userFixture()) },
refreshToken: { updateMany: vi.fn() },
$transaction: transaction,
});
const detail = await service.disable('u1', { reason: '离职' }, 'u9', ['user:manage']);
expect(detail.status).toBe('disabled');
expect(transaction).toHaveBeenCalledTimes(1);
const enableService = serviceOf({
user: {
findUnique: vi
.fn()
.mockResolvedValueOnce(userFixture({ status: 'DISABLED' }))
.mockResolvedValueOnce(userFixture()),
update: vi.fn().mockResolvedValue(userFixture()),
},
});
const enabled = await enableService.enable('u1', ['user:manage']);
expect(enabled.status).toBe('active');
});
});
describe('DepartmentsService', () => {
it('部门树:两级嵌套且一级成员数含下级', async () => {
const departments = [
{
id: 'd1',
code: 'content',
name: '内容生产中心',
parentId: null,
leaderUserId: null,
sortOrder: 1,
status: 'ACTIVE',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date(),
leader: { id: 'u1', name: '陈默' },
},
{
id: 'g1',
code: 'content-g1',
name: '素材一组',
parentId: 'd1',
leaderUserId: null,
sortOrder: 1,
status: 'ACTIVE',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date(),
leader: null,
},
];
const service = new DepartmentsService({
department: { findMany: vi.fn().mockResolvedValue(departments) },
user: { groupBy: vi.fn().mockResolvedValue([{ departmentId: 'g1', _count: { _all: 5 } }]) },
} as unknown as PrismaService);
const tree = await service.tree();
expect(tree).toHaveLength(1);
expect(tree[0]?.memberCount).toBe(5);
expect(tree[0]?.children[0]?.name).toBe('素材一组');
});
it('新建:父级不存在 40001,超过两级 42201', async () => {
const service = new DepartmentsService({
department: {
findUnique: vi.fn().mockResolvedValueOnce(null),
},
} as unknown as PrismaService);
await expectRejected(
service.create({ name: '新组', parentId: 'missing' }),
ErrorCode.PARAM_INVALID,
);
const service2 = new DepartmentsService({
department: {
findUnique: vi.fn().mockResolvedValue({
id: 'g1',
parentId: 'd1',
}),
},
} as unknown as PrismaService);
await expectRejected(
service2.create({ name: '三级', parentId: 'g1' }),
ErrorCode.BUSINESS_RULE_VIOLATION,
);
});
it('重命名不存在的部门返回 40401', async () => {
const service = new DepartmentsService({
department: { findUnique: vi.fn().mockResolvedValue(null) },
} as unknown as PrismaService);
await expectRejected(service.rename('missing', '新名字'), ErrorCode.NOT_FOUND);
});
});
describe('MeService / RolesService', () => {
it('/me:手机号按 user:manage 返回,角色与数据范围来自 DB', async () => {
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
const service = new MeService(prisma as unknown as PrismaService);
const me = await service.getMe('u1', ['user:manage']);
expect(me.user.phone).toBe('13866202046');
expect(me.permissions).toEqual(['user:manage']);
expect(me.roles).toHaveLength(1);
const meWithout = await service.getMe('u1', []);
expect(meWithout.user.phone).toBeUndefined();
await expectRejected(
new MeService({
user: { findUnique: vi.fn().mockResolvedValue(null) },
} as unknown as PrismaService).getMe('u1', []),
ErrorCode.UNAUTHENTICATED,
);
});
it('角色列表只返回启用角色', async () => {
const prisma = {
role: {
findMany: vi.fn().mockResolvedValue([
{
id: 'r1',
code: 'super_admin',
name: '超级管理员',
description: '内置',
isSystem: true,
status: 'ACTIVE',
createdAt: new Date(),
updatedAt: new Date(),
},
]),
},
};
const roles = await new RolesService(prisma as unknown as PrismaService).list();
expect(roles).toEqual([
{ id: 'r1', code: 'super_admin', name: '超级管理员', isSystem: true, description: '内置' },
]);
});
});
@@ -0,0 +1,69 @@
import { Inject, Injectable } from '@nestjs/common';
import type Redis from 'ioredis';
import { APP_CONFIG } from '../../config/config.module';
import type { AppConfig } from '../../config/env';
import type { RedisService } from '../../infra/redis/redis.service';
import { REDIS } from '../../infra/tokens';
/**
* 登录失败计数(Redis):窗口内累计失败达阈值即锁定,成功登录清零。
* key 按账号归一化,避免大小写绕过;TTL 即锁定时长(默认 15 分钟)。
*/
@Injectable()
export class LoginFailureService {
constructor(
@Inject(REDIS) private readonly redis: RedisService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
) {}
private key(account: string): string {
return `auth:login-fail:${account.trim().toLowerCase()}`;
}
private get client(): Redis {
return this.redis.getClient();
}
/** ioredis 为 lazyConnect:首次命令前显式连接,避免 enableOfflineQueue=false 直接报错 */
private async connected(): Promise<Redis> {
const client = this.client;
if (client.status === 'wait') {
await client.connect();
}
return client;
}
async count(account: string): Promise<number> {
const client = await this.connected();
const value = await client.get(this.key(account));
if (value === null) {
return 0;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
/** 是否已达锁定阈值;未达阈值时返回剩余可尝试次数 */
async status(account: string): Promise<{ locked: boolean; remaining: number }> {
const failures = await this.count(account);
const remaining = Math.max(0, this.config.authMaxLoginFailures - failures);
return { locked: failures >= this.config.authMaxLoginFailures, remaining };
}
async record(account: string): Promise<number> {
const key = this.key(account);
const client = await this.connected();
const multi = client.multi();
multi.incr(key);
multi.expire(key, this.config.authLockMinutes * 60);
const result = await multi.exec();
const incr = result?.[0]?.[1];
return typeof incr === 'number' ? incr : 0;
}
async clear(account: string): Promise<void> {
const client = await this.connected();
await client.del(this.key(account));
}
}
@@ -0,0 +1,25 @@
import { ErrorCode, type MeResponse } from '@lingxi/shared';
import { Controller, Get, Inject } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppException } from '../../common/errors/app-exception';
import type { AuthContext } from '../../common/security/auth.types';
import { CurrentUser } from '../../common/security/decorators';
import { MeService } from './me.service';
/** 当前用户上下文:登录后前端据此做路由 / 按钮级权限控制 */
@ApiTags('identity-me')
@ApiBearerAuth()
@Controller('me')
export class MeController {
constructor(@Inject(MeService) private readonly me: MeService) {}
@Get()
@ApiOperation({ summary: '当前用户、角色、权限点、数据范围' })
async current(@CurrentUser() auth: AuthContext | null): Promise<MeResponse> {
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
return this.me.getMe(auth.userId, auth.permissions);
}
}
@@ -0,0 +1,46 @@
import { ErrorCode, type MeResponse, Permission } from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { AppException } from '../../common/errors/app-exception';
import type { UserAuthRecord } from '../../common/security/permission.service';
import { resolveDataScope } from '../../common/security/permission.service';
import { activeRolesOf } from '../../common/security/permission.service';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { toRoleSummary, toUserSummary } from './user.mapper';
/** /me 与登录响应里的上下文组装:用户摘要 + 角色 + 权限点 + 数据范围 */
@Injectable()
export class MeService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async getMe(userId: string, permissions: string[]): Promise<MeResponse> {
const user = await this.findUser(userId);
if (user === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED, message: '用户不存在或已停用' });
}
const roles = activeRolesOf(user);
const canSeePhone = permissions.includes(Permission.USER_MANAGE);
return {
user: {
...toUserSummary(user),
...(canSeePhone ? { phone: user.phone } : {}),
},
roles: roles.map((item) => toRoleSummary(item.role)),
permissions,
dataScope: resolveDataScope(roles.map((item) => item.role.code)),
mustChangePassword: user.mustChangePassword,
};
}
/** /me 每次都读 DB(不缓存旧权限),角色变更下一次请求即生效 */
private async findUser(userId: string): Promise<UserAuthRecord | null> {
return this.prisma.user.findUnique({
where: { id: userId },
include: {
department: { include: { parent: true } },
roles: { include: { role: { include: { permissions: true } } } },
},
});
}
}
@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import argon2 from 'argon2';
/** Argon2id 口令哈希与校验;参数取库默认(内存 64MB / 3 轮),后续可按环境调优 */
@Injectable()
export class PasswordService {
async hash(plain: string): Promise<string> {
return argon2.hash(plain, { type: argon2.argon2id });
}
async verify(hash: string, plain: string): Promise<boolean> {
try {
return await argon2.verify(hash, plain);
} catch {
return false;
}
}
}
@@ -0,0 +1,21 @@
import { type ListResponse, Permission, type RoleSummary } from '@lingxi/shared';
import { Controller, Get, Inject } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RequirePermissions } from '../../common/security/decorators';
import { RolesService } from './roles.service';
/** 角色列表:供用户管理页做角色多选绑定 */
@ApiTags('identity-roles')
@ApiBearerAuth()
@Controller('roles')
export class RolesController {
constructor(@Inject(RolesService) private readonly roles: RolesService) {}
@Get()
@RequirePermissions(Permission.USER_VIEW)
@ApiOperation({ summary: '角色列表(绑定用)' })
async list(): Promise<ListResponse<RoleSummary>> {
return { items: await this.roles.list(), next_cursor: null };
}
}
@@ -0,0 +1,20 @@
import { type RoleSummary } from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { toRoleSummary } from './user.mapper';
/** 角色列表:A1 只做绑定多选,完整角色管理属 A2 */
@Injectable()
export class RolesService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
async list(): Promise<RoleSummary[]> {
const roles = await this.prisma.role.findMany({
where: { status: 'ACTIVE' },
orderBy: [{ isSystem: 'desc' }, { name: 'asc' }],
});
return roles.map((role) => toRoleSummary(role));
}
}
@@ -0,0 +1,86 @@
import type { RoleSummary, UserDetail, UserSummary } from '@lingxi/shared';
import type { Prisma, Role, UserStatus as PrismaUserStatus } from '@prisma/client';
/** 用户列表 / 详情需要的关联数据(部门含父级、角色) */
export type UserWithRelations = Prisma.UserGetPayload<{
include: {
department: { include: { parent: true } };
roles: { include: { role: true } };
};
}>;
export function iso(value: Date): string {
return value.toISOString();
}
export function isoOrNull(value: Date | null): string | null {
return value === null ? null : value.toISOString();
}
/** 手机号脱敏:138****2046;全号只在具备 user:manage 时由详情接口附带 */
export function maskPhone(phone: string): string {
return `${phone.slice(0, 3)}****${phone.slice(7)}`;
}
export function toRoleSummary(role: Role): RoleSummary {
return {
id: role.id,
code: role.code,
name: role.name,
isSystem: role.isSystem,
...(role.description.length > 0 ? { description: role.description } : {}),
};
}
export function toApiStatus(status: PrismaUserStatus): UserSummary['status'] {
return status === 'ACTIVE' ? 'active' : 'disabled';
}
function departmentFields(
user: UserWithRelations,
): Pick<UserSummary, 'departmentId' | 'departmentName' | 'groupId' | 'groupName'> {
const leaf = user.department;
const parent = leaf.parent;
if (leaf.parentId !== null && parent !== null) {
return {
departmentId: parent.id,
departmentName: parent.name,
groupId: leaf.id,
groupName: leaf.name,
};
}
return {
departmentId: leaf.id,
departmentName: leaf.name,
groupId: null,
groupName: null,
};
}
export function toUserSummary(user: UserWithRelations): UserSummary {
return {
id: user.id,
name: user.name,
phoneMasked: maskPhone(user.phone),
account: user.account,
...departmentFields(user),
roles: user.roles
.filter((item) => item.role.status === 'ACTIVE')
.map((item) => toRoleSummary(item.role)),
status: toApiStatus(user.status),
lastLoginAt: isoOrNull(user.lastLoginAt),
createdAt: iso(user.createdAt),
};
}
/** 详情:phone 全号仅调用方具备 user:manage 时返回(契约铁律) */
export function toUserDetail(user: UserWithRelations, canSeePhone: boolean): UserDetail {
return {
...toUserSummary(user),
...(canSeePhone ? { phone: user.phone } : {}),
mustChangePassword: user.mustChangePassword,
updatedAt: iso(user.updatedAt),
lastLoginIp: user.lastLoginIp,
disabledAt: isoOrNull(user.disabledAt),
};
}
@@ -0,0 +1,124 @@
import {
type CreateUserInput,
createUserInputSchema,
type DisableUserInput,
disableUserInputSchema,
ErrorCode,
type ListResponse,
Permission,
transferCustomersInputSchema,
type UpdateUserInput,
updateUserInputSchema,
type UserDetail,
type UserListQuery,
userListQuerySchema,
type UserSummary,
} from '@lingxi/shared';
import { Body, Controller, Get, HttpCode, Inject, Param, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AppException } from '../../common/errors/app-exception';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import type { AuthContext } from '../../common/security/auth.types';
import { Audit, CurrentUser, RequirePermissions } from '../../common/security/decorators';
import { UsersService } from './users.service';
function requireAuth(auth: AuthContext | null): AuthContext {
if (auth === null) {
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
}
return auth;
}
/** 用户管理:/users 前缀(A1 前端路由同为 /users;全局前缀 /api/v1) */
@ApiTags('identity-users')
@ApiBearerAuth()
@Controller('users')
export class UsersController {
constructor(@Inject(UsersService) private readonly users: UsersService) {}
@Get()
@RequirePermissions(Permission.USER_VIEW)
@ApiOperation({
summary: '用户列表(游标分页)',
description: 'status 默认 active(停用默认隐藏);sort/order 走白名单;手机号仅返回掩码',
})
async list(
@Query(new ZodValidationPipe(userListQuerySchema)) query: UserListQuery,
@CurrentUser() auth: AuthContext | null,
): Promise<ListResponse<UserSummary>> {
requireAuth(auth);
return this.users.list(query);
}
@Get(':id')
@RequirePermissions(Permission.USER_VIEW)
@ApiOperation({ summary: '用户详情', description: '仅 user:manage 返回 phone 全号' })
async detail(
@Param('id') id: string,
@CurrentUser() auth: AuthContext | null,
): Promise<UserDetail> {
return this.users.detail(id, requireAuth(auth).permissions);
}
@Post()
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.user.create')
@ApiOperation({ summary: '新建用户', description: '初始密码由管理员设定,首登强制修改' })
async create(
@Body(new ZodValidationPipe(createUserInputSchema)) input: CreateUserInput,
@CurrentUser() auth: AuthContext | null,
): Promise<UserDetail> {
const current = requireAuth(auth);
return this.users.create(input, current.userId, current.permissions);
}
@Patch(':id')
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.user.update')
@ApiOperation({ summary: '更新用户(不含账号 / 状态 / 密码)' })
async update(
@Param('id') id: string,
@Body(new ZodValidationPipe(updateUserInputSchema)) input: UpdateUserInput,
@CurrentUser() auth: AuthContext | null,
): Promise<UserDetail> {
return this.users.update(id, input, requireAuth(auth).permissions);
}
@Post(':id/disable')
@HttpCode(200)
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.user.disable')
@ApiOperation({ summary: '停用用户', description: '停用后旧令牌立即失效;可指定客户转交人' })
async disable(
@Param('id') id: string,
@Body(new ZodValidationPipe(disableUserInputSchema)) input: DisableUserInput,
@CurrentUser() auth: AuthContext | null,
): Promise<UserDetail> {
const current = requireAuth(auth);
return this.users.disable(id, input, current.userId, current.permissions);
}
@Post(':id/enable')
@HttpCode(200)
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.user.enable')
@ApiOperation({ summary: '启用用户' })
async enable(
@Param('id') id: string,
@CurrentUser() auth: AuthContext | null,
): Promise<UserDetail> {
return this.users.enable(id, requireAuth(auth).permissions);
}
@Post(':id/transfer-customers')
@HttpCode(200)
@RequirePermissions(Permission.USER_MANAGE)
@Audit('identity.user.transfer_customers')
@ApiOperation({ summary: '客户转交(占位)', description: 'A3 客户模块落地前返回 42201' })
transferCustomers(
@Body(new ZodValidationPipe(transferCustomersInputSchema)) _input: unknown,
): never {
return this.users.transferCustomers();
}
}
@@ -0,0 +1,265 @@
import {
type CreateUserInput,
type DisableUserInput,
ErrorCode,
type ListResponse,
Permission,
ulid,
type UpdateUserInput,
type UserDetail,
type UserListQuery,
type UserSummary,
} from '@lingxi/shared';
import { Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AppException } from '../../common/errors/app-exception';
import type { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { PasswordService } from './password.service';
import { toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper';
const USER_INCLUDE = {
department: { include: { parent: true } },
roles: { include: { role: true } },
} satisfies Prisma.UserInclude;
function hasPermission(permissions: string[], code: string): boolean {
return permissions.includes(code);
}
/** 用户管理:列表(游标 + 筛选 + 排序)、详情、新建、更新、停用 / 启用 */
@Injectable()
export class UsersService {
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(PasswordService) private readonly passwords: PasswordService,
) {}
async list(query: UserListQuery): Promise<ListResponse<UserSummary>> {
const where = this.buildWhere(query);
const orderBy = this.buildOrderBy(query);
const cursor = query.cursor;
const rows = await this.prisma.user.findMany({
where,
orderBy,
take: query.limit + 1,
include: USER_INCLUDE,
...(cursor === undefined ? {} : { cursor: { id: cursor }, skip: 1 }),
});
const page = rows.slice(0, query.limit);
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
return { items: page.map((row) => toUserSummary(row)), next_cursor: next };
}
async detail(id: string, permissions: string[]): Promise<UserDetail> {
const user = await this.findOrThrow(id);
return toUserDetail(user, hasPermission(permissions, Permission.USER_MANAGE));
}
async create(
input: CreateUserInput,
actorId: string,
permissions: string[],
): Promise<UserDetail> {
await this.ensureDepartment(input.departmentId);
await this.ensureRoles(input.roleIds);
const account = input.account.trim().toLowerCase();
const email = `${account}@lingxi.cn`;
await this.ensureUnique({ account, phone: input.phone });
const passwordHash = await this.passwords.hash(input.password);
const id = ulid();
try {
await this.prisma.$transaction([
this.prisma.user.create({
data: {
id,
name: input.name,
account,
email,
phone: input.phone,
passwordHash,
departmentId: input.departmentId,
mustChangePassword: true,
createdBy: actorId,
...(input.remark === undefined ? {} : { remark: input.remark }),
roles: { create: input.roleIds.map((roleId) => ({ roleId })) },
},
}),
]);
} catch (error) {
throw this.mapUniqueError(error);
}
return this.detail(id, permissions);
}
async update(id: string, input: UpdateUserInput, permissions: string[]): Promise<UserDetail> {
const existing = await this.findOrThrow(id);
if (input.departmentId !== undefined) {
await this.ensureDepartment(input.departmentId);
}
if (input.roleIds !== undefined) {
await this.ensureRoles(input.roleIds);
}
if (input.phone !== undefined && input.phone !== existing.phone) {
await this.ensureUnique({ phone: input.phone });
}
const data: Prisma.UserUpdateInput = {
...(input.name === undefined ? {} : { name: input.name }),
...(input.phone === undefined ? {} : { phone: input.phone }),
...(input.departmentId === undefined
? {}
: { department: { connect: { id: input.departmentId } } }),
...(input.remark === undefined ? {} : { remark: input.remark }),
};
try {
await this.prisma.$transaction(async (tx) => {
await tx.user.update({ where: { id }, data });
if (input.roleIds !== undefined) {
await tx.userRole.deleteMany({ where: { userId: id } });
await tx.userRole.createMany({
data: input.roleIds.map((roleId) => ({ userId: id, roleId })),
});
}
});
} catch (error) {
throw this.mapUniqueError(error);
}
return this.detail(id, permissions);
}
async disable(
id: string,
input: DisableUserInput,
actorId: string,
permissions: string[],
): Promise<UserDetail> {
const existing = await this.findOrThrow(id);
if (existing.status === 'DISABLED') {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '账号已是停用状态' });
}
if (existing.id === actorId) {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '不能停用当前登录账号',
});
}
await this.prisma.$transaction([
this.prisma.user.update({
where: { id },
data: {
status: 'DISABLED',
disabledAt: new Date(),
...(input.reason === undefined ? {} : { remark: input.reason }),
},
}),
this.prisma.refreshToken.updateMany({
where: { userId: id, revokedAt: null },
data: { revokedAt: new Date() },
}),
]);
return this.detail(id, permissions);
}
async enable(id: string, permissions: string[]): Promise<UserDetail> {
const existing = await this.findOrThrow(id);
if (existing.status === 'ACTIVE') {
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '账号已是启用状态' });
}
await this.prisma.user.update({
where: { id },
data: { status: 'ACTIVE', disabledAt: null },
});
return this.detail(id, permissions);
}
/** 客户转交占位:A3 客户模块落地前按契约返回 42201(不自造 5xx 错误码) */
transferCustomers(): never {
throw new AppException({
code: ErrorCode.BUSINESS_RULE_VIOLATION,
message: '客户转交将在客户模块(A3)提供',
});
}
private async findOrThrow(id: string): Promise<UserWithRelations> {
const user = await this.prisma.user.findUnique({ where: { id }, include: USER_INCLUDE });
if (user === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '用户不存在' });
}
return user;
}
private async ensureDepartment(departmentId: string): Promise<void> {
const department = await this.prisma.department.findUnique({ where: { id: departmentId } });
if (department === null) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '所属部门不存在' });
}
}
private async ensureRoles(roleIds: string[]): Promise<void> {
const count = await this.prisma.role.count({
where: { id: { in: roleIds }, status: 'ACTIVE' },
});
if (count !== new Set(roleIds).size) {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '角色不存在或已停用' });
}
}
private async ensureUnique(input: { account?: string; phone?: string }): Promise<void> {
const existing = await this.prisma.user.findFirst({
where: {
OR: [
...(input.account === undefined ? [] : [{ account: input.account }]),
...(input.phone === undefined ? [] : [{ phone: input.phone }]),
],
},
select: { account: true, phone: true },
});
if (existing !== null) {
throw new AppException({
code: ErrorCode.STATE_NOT_ALLOWED,
message: existing.account === input.account ? '登录账号已存在' : '手机号已存在',
});
}
}
private mapUniqueError(error: unknown): AppException {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
return new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '账号或手机号已存在' });
}
return error instanceof AppException
? error
: new AppException({ code: ErrorCode.INTERNAL_ERROR, message: '用户保存失败' });
}
private buildWhere(query: UserListQuery): Prisma.UserWhereInput {
const where: Prisma.UserWhereInput = {};
if (query.status !== 'all') {
where.status = query.status === 'active' ? 'ACTIVE' : 'DISABLED';
}
if (query.keyword !== undefined && query.keyword.length > 0) {
where.OR = [
{ name: { contains: query.keyword, mode: 'insensitive' } },
{ account: { contains: query.keyword, mode: 'insensitive' } },
{ phone: { contains: query.keyword } },
];
}
if (query.department_id !== undefined) {
where.department = {
OR: [{ id: query.department_id }, { parentId: query.department_id }],
};
}
if (query.role_id !== undefined) {
where.roles = { some: { roleId: query.role_id } };
}
return where;
}
private buildOrderBy(query: UserListQuery): Prisma.UserOrderByWithRelationInput[] {
const direction: Prisma.SortOrder = query.order === 'asc' ? 'asc' : 'desc';
if (query.sort === 'created_at') {
return [{ createdAt: direction }, { id: 'asc' }];
}
return [{ lastLoginAt: { sort: direction, nulls: 'last' } }, { id: 'asc' }];
}
}
@@ -1,10 +1,12 @@
import { Controller, Get, Inject } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../../../common/security/decorators';
import { HealthService } from './health.service';
import { type HealthReport, HealthResponseDto } from './health.types';
@ApiTags('platform')
@Public()
@Controller('health')
export class HealthController {
constructor(@Inject(HealthService) private readonly healthService: HealthService) {}
+6
View File
@@ -23,6 +23,12 @@ export const testConfig: AppConfig = {
webOrigins: ['http://127.0.0.1:5173'],
maxUploadMb: 500,
featureVideo: false,
jwtAccessSecret: 'test-only-jwt-access-secret-32-characters',
jwtRefreshSecret: 'test-only-jwt-refresh-secret-32-characters',
jwtAccessTtlSeconds: 900,
jwtRefreshTtlSeconds: 604800,
authMaxLoginFailures: 5,
authLockMinutes: 15,
};
@Controller('__test')