diff --git a/.env.example b/.env.example index e4f6c26..22dd975 100644 --- a/.env.example +++ b/.env.example @@ -22,9 +22,15 @@ S3_BUCKET_TEMP=lingxi-dev S3_ACCESS_KEY= S3_SECRET_KEY= -# ---------- 认证(本地 dev 值,生产由密钥管理注入) ---------- -JWT_SECRET=dev-only-jwt-secret-change-me-32chars -JWT_REFRESH_SECRET=dev-only-jwt-refresh-secret-change-me-32chars +# ---------- 认证(本地 dev 值,生产由密钥管理注入;密钥至少 32 位) ---------- +JWT_ACCESS_SECRET=dev-only-jwt-access-secret-change-me-32 +JWT_REFRESH_SECRET=dev-only-jwt-refresh-secret-change-me-32 +# access / refresh 有效期(秒):15 分钟 / 7 天 +JWT_ACCESS_TTL_SECONDS=900 +JWT_REFRESH_TTL_SECONDS=604800 +# 登录失败阈值与锁定时长(分钟内累计失败达阈值即锁定) +AUTH_MAX_LOGIN_FAILURES=5 +AUTH_LOCK_MINUTES=15 # ---------- API ---------- PORT=3000 diff --git a/apps/api/package.json b/apps/api/package.json index 7771712..e32f4aa 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,9 +17,11 @@ "@lingxi/shared": "workspace:*", "@nestjs/common": "11.2.8", "@nestjs/core": "11.2.8", + "@nestjs/jwt": "12.0.3", "@nestjs/platform-fastify": "11.2.8", "@nestjs/swagger": "11.4.7", "@prisma/client": "6.19.3", + "argon2": "0.45.1", "dotenv": "18.0.9", "fastify": "5.12.7", "ioredis": "5.11.1", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index e1d07c2..41ea61a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, ], }) diff --git a/apps/api/src/bootstrap.spec.ts b/apps/api/src/bootstrap.spec.ts new file mode 100644 index 0000000..1d77458 --- /dev/null +++ b/apps/api/src/bootstrap.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/common/security/audit.interceptor.ts b/apps/api/src/common/security/audit.interceptor.ts new file mode 100644 index 0000000..eaa070e --- /dev/null +++ b/apps/api/src/common/security/audit.interceptor.ts @@ -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 { + const request = context + .switchToHttp() + .getRequest(); + 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; +} diff --git a/apps/api/src/common/security/auth.types.ts b/apps/api/src/common/security/auth.types.ts new file mode 100644 index 0000000..d77d24f --- /dev/null +++ b/apps/api/src/common/security/auth.types.ts @@ -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; +} diff --git a/apps/api/src/common/security/decorators.ts b/apps/api/src/common/security/decorators.ts new file mode 100644 index 0000000..9ba1ea3 --- /dev/null +++ b/apps/api/src/common/security/decorators.ts @@ -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(); + return request.auth ?? null; + }, +); diff --git a/apps/api/src/common/security/guards.ts b/apps/api/src/common/security/guards.ts new file mode 100644 index 0000000..04c433b --- /dev/null +++ b/apps/api/src/common/security/guards.ts @@ -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 = new Set(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(); + 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 { + if (isPublic(this.reflector, context)) { + return true; + } + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/apps/api/src/common/security/permission.service.ts b/apps/api/src/common/security/permission.service.ts new file mode 100644 index 0000000..85a65c8 --- /dev/null +++ b/apps/api/src/common/security/permission.service.ts @@ -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 { + 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 { + 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, + }; + } +} diff --git a/apps/api/src/common/security/request-context.ts b/apps/api/src/common/security/request-context.ts new file mode 100644 index 0000000..4ce959e --- /dev/null +++ b/apps/api/src/common/security/request-context.ts @@ -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(); + + run(context: RequestContext, callback: () => T): T { + return this.storage.run(context, callback); + } + + get(): RequestContext | null { + return this.storage.getStore() ?? null; + } + + patch(partial: Partial): void { + const store = this.storage.getStore(); + if (store === undefined) { + return; + } + Object.assign(store, partial); + } +} diff --git a/apps/api/src/common/security/security.module.ts b/apps/api/src/common/security/security.module.ts new file mode 100644 index 0000000..20f37cd --- /dev/null +++ b/apps/api/src/common/security/security.module.ts @@ -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 {} diff --git a/apps/api/src/common/security/security.spec.ts b/apps/api/src/common/security/security.spec.ts new file mode 100644 index 0000000..6614488 --- /dev/null +++ b/apps/api/src/common/security/security.spec.ts @@ -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): 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): { + getAllAndOverride: (key: string) => unknown; +} { + return { getAllAndOverride: (key: string) => metadata[key] }; +} + +function authFixture(overrides: Partial = {}): 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, code: ErrorCode): Promise { + 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): 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'); + }); +}); diff --git a/apps/api/src/common/security/token.service.ts b/apps/api/src/common/security/token.service.ts new file mode 100644 index 0000000..0bc4350 --- /dev/null +++ b/apps/api/src/common/security/token.service.ts @@ -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(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(token, { secret: this.config.jwtAccessSecret }); + } catch (error) { + throw mapTokenError(error); + } + } + + private decodeRefresh(token: string): JwtRefreshPayload { + try { + return this.jwt.verify(token, { secret: this.config.jwtRefreshSecret }); + } catch (error) { + throw mapTokenError(error); + } + } +} diff --git a/apps/api/src/config/env.spec.ts b/apps/api/src/config/env.spec.ts index 6ed3f2c..accbf33 100644 --- a/apps/api/src/config/env.spec.ts +++ b/apps/api/src/config/env.spec.ts @@ -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', () => { diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index abcb654..7bb36d9 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -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; @@ -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, }; } diff --git a/apps/api/src/config/load-dotenv.ts b/apps/api/src/config/load-dotenv.ts index 8da0c3f..4ca576c 100644 --- a/apps/api/src/config/load-dotenv.ts +++ b/apps/api/src/config/load-dotenv.ts @@ -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 }); + } } } } diff --git a/apps/api/src/modules/identity/auth.controller.ts b/apps/api/src/modules/identity/auth.controller.ts new file mode 100644 index 0000000..ca5b249 --- /dev/null +++ b/apps/api/src/modules/identity/auth.controller.ts @@ -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 { + return this.auth.login(input, metaOf(request)); + } + + @Public() + @Post('refresh') + @HttpCode(200) + @ApiOperation({ summary: '刷新令牌(旋转,旧 refresh 立即失效)' }) + async refresh( + @Body(new ZodValidationPipe(refreshInputSchema)) input: RefreshInput, + ): Promise { + return this.auth.refresh(input); + } + + @Public() + @Post('logout') + @HttpCode(204) + @ApiOperation({ summary: '登出', description: '吊销 refresh 令牌所在家族' }) + async logout(@Body(new ZodValidationPipe(logoutInputSchema)) input: LogoutInput): Promise { + 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 { + if (auth === null) { + throw new AppException({ code: ErrorCode.UNAUTHENTICATED }); + } + await this.auth.changePassword(auth.userId, input); + } +} diff --git a/apps/api/src/modules/identity/auth.service.spec.ts b/apps/api/src/modules/identity/auth.service.spec.ts new file mode 100644 index 0000000..7af1fb6 --- /dev/null +++ b/apps/api/src/modules/identity/auth.service.spec.ts @@ -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 { + 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, code: ErrorCode): Promise { + 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; + passwords: ReturnType; + permissions: { resolveAuth: Mock }; + me: { getMe: Mock }; +} + +function failureMock(): { + status: ReturnType; + record: ReturnType; + clear: ReturnType; +} { + return { + status: vi.fn().mockResolvedValue({ locked: false, remaining: 5 }), + record: vi.fn().mockResolvedValue(1), + clear: vi.fn().mockResolvedValue(undefined), + }; +} + +function passwordMock(): { + verify: ReturnType; + hash: ReturnType; +} { + 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, + ); + }); +}); diff --git a/apps/api/src/modules/identity/auth.service.ts b/apps/api/src/modules/identity/auth.service.ts new file mode 100644 index 0000000..94d228f --- /dev/null +++ b/apps/api/src/modules/identity/auth.service.ts @@ -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 { + 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 { + 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 { + if (input.refreshToken !== undefined) { + await this.revokeByToken(input.refreshToken); + return; + } + if (userId !== undefined) { + await this.revokeAllForUser(userId); + } + } + + async changePassword(userId: string, input: ChangePasswordInput): Promise { + 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 { + return this.prisma.user.findFirst({ + where: { OR: [{ account }, { email: account }] }, + }); + } + + private async recordFailure(account: string): Promise { + 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 { + 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 { + 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 { + await this.prisma.refreshToken.updateMany({ + where: { familyId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + private async revokeAllForUser(userId: string): Promise { + await this.prisma.refreshToken.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } +} diff --git a/apps/api/src/modules/identity/departments.controller.ts b/apps/api/src/modules/identity/departments.controller.ts new file mode 100644 index 0000000..27c0315 --- /dev/null +++ b/apps/api/src/modules/identity/departments.controller.ts @@ -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; + +const updateDepartmentSchema = z + .object({ name: z.string().trim().min(1, '请输入部门名称').max(20, '部门名称最多 20 个字符') }) + .strict(); + +type UpdateDepartmentBody = z.infer; + +/** 组织架构:两级部门树(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 }; + } +} diff --git a/apps/api/src/modules/identity/departments.service.ts b/apps/api/src/modules/identity/departments.service.ts new file mode 100644 index 0000000..86b3bbe --- /dev/null +++ b/apps/api/src/modules/identity/departments.service.ts @@ -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 { + 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(); + 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 { + 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 { + 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: '部门最多两级,不能在小组下再建部门', + }); + } + } +} diff --git a/apps/api/src/modules/identity/identity.int.spec.ts b/apps/api/src/modules/identity/identity.int.spec.ts new file mode 100644 index 0000000..4c7c337 --- /dev/null +++ b/apps/api/src/modules/identity/identity.int.spec.ts @@ -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 { + 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() }; +} + +async function authed( + app: CreatedApiApp['app'], + method: 'GET' | 'POST' | 'PATCH', + url: string, + token: string, + payload?: unknown, +): Promise { + const response = await app.inject({ + method, + url: `${API}${url}`, + headers: { authorization: `Bearer ${token}` }, + ...(payload === undefined ? {} : { payload: payload as Record }), + }); + return response.statusCode; +} + +async function setRole(userId: string, roleCode: string): Promise { + 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[]; + 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(); + 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().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); + }); +}); diff --git a/apps/api/src/modules/identity/identity.module.ts b/apps/api/src/modules/identity/identity.module.ts new file mode 100644 index 0000000..93571ac --- /dev/null +++ b/apps/api/src/modules/identity/identity.module.ts @@ -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 {} diff --git a/apps/api/src/modules/identity/identity.services.spec.ts b/apps/api/src/modules/identity/identity.services.spec.ts new file mode 100644 index 0000000..6a8f1e0 --- /dev/null +++ b/apps/api/src/modules/identity/identity.services.spec.ts @@ -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 { + 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, code: ErrorCode): Promise { + 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; + del: () => void; + } { + const store = new Map(); + 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): 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: '内置' }, + ]); + }); +}); diff --git a/apps/api/src/modules/identity/login-failure.service.ts b/apps/api/src/modules/identity/login-failure.service.ts new file mode 100644 index 0000000..976e57b --- /dev/null +++ b/apps/api/src/modules/identity/login-failure.service.ts @@ -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 { + const client = this.client; + if (client.status === 'wait') { + await client.connect(); + } + return client; + } + + async count(account: string): Promise { + 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 { + 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 { + const client = await this.connected(); + await client.del(this.key(account)); + } +} diff --git a/apps/api/src/modules/identity/me.controller.ts b/apps/api/src/modules/identity/me.controller.ts new file mode 100644 index 0000000..09cdab0 --- /dev/null +++ b/apps/api/src/modules/identity/me.controller.ts @@ -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 { + if (auth === null) { + throw new AppException({ code: ErrorCode.UNAUTHENTICATED }); + } + return this.me.getMe(auth.userId, auth.permissions); + } +} diff --git a/apps/api/src/modules/identity/me.service.ts b/apps/api/src/modules/identity/me.service.ts new file mode 100644 index 0000000..a70a9a9 --- /dev/null +++ b/apps/api/src/modules/identity/me.service.ts @@ -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 { + 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 { + return this.prisma.user.findUnique({ + where: { id: userId }, + include: { + department: { include: { parent: true } }, + roles: { include: { role: { include: { permissions: true } } } }, + }, + }); + } +} diff --git a/apps/api/src/modules/identity/password.service.ts b/apps/api/src/modules/identity/password.service.ts new file mode 100644 index 0000000..775b31a --- /dev/null +++ b/apps/api/src/modules/identity/password.service.ts @@ -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 { + return argon2.hash(plain, { type: argon2.argon2id }); + } + + async verify(hash: string, plain: string): Promise { + try { + return await argon2.verify(hash, plain); + } catch { + return false; + } + } +} diff --git a/apps/api/src/modules/identity/roles.controller.ts b/apps/api/src/modules/identity/roles.controller.ts new file mode 100644 index 0000000..62b1de4 --- /dev/null +++ b/apps/api/src/modules/identity/roles.controller.ts @@ -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> { + return { items: await this.roles.list(), next_cursor: null }; + } +} diff --git a/apps/api/src/modules/identity/roles.service.ts b/apps/api/src/modules/identity/roles.service.ts new file mode 100644 index 0000000..327fc0f --- /dev/null +++ b/apps/api/src/modules/identity/roles.service.ts @@ -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 { + const roles = await this.prisma.role.findMany({ + where: { status: 'ACTIVE' }, + orderBy: [{ isSystem: 'desc' }, { name: 'asc' }], + }); + return roles.map((role) => toRoleSummary(role)); + } +} diff --git a/apps/api/src/modules/identity/user.mapper.ts b/apps/api/src/modules/identity/user.mapper.ts new file mode 100644 index 0000000..4c0e9be --- /dev/null +++ b/apps/api/src/modules/identity/user.mapper.ts @@ -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 { + 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), + }; +} diff --git a/apps/api/src/modules/identity/users.controller.ts b/apps/api/src/modules/identity/users.controller.ts new file mode 100644 index 0000000..5771fd8 --- /dev/null +++ b/apps/api/src/modules/identity/users.controller.ts @@ -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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/apps/api/src/modules/identity/users.service.ts b/apps/api/src/modules/identity/users.service.ts new file mode 100644 index 0000000..b4bb7da --- /dev/null +++ b/apps/api/src/modules/identity/users.service.ts @@ -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> { + 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 { + const user = await this.findOrThrow(id); + return toUserDetail(user, hasPermission(permissions, Permission.USER_MANAGE)); + } + + async create( + input: CreateUserInput, + actorId: string, + permissions: string[], + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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' }]; + } +} diff --git a/apps/api/src/modules/platform/health/health.controller.ts b/apps/api/src/modules/platform/health/health.controller.ts index 2fd72ea..44a2aaf 100644 --- a/apps/api/src/modules/platform/health/health.controller.ts +++ b/apps/api/src/modules/platform/health/health.controller.ts @@ -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) {} diff --git a/apps/api/src/testing/test-app.ts b/apps/api/src/testing/test-app.ts index 6895c42..b35cefc 100644 --- a/apps/api/src/testing/test-app.ts +++ b/apps/api/src/testing/test-app.ts @@ -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') diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 5a91445..f74a5d3 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -9,7 +9,17 @@ export default defineConfig({ provider: 'v8', reporter: ['text', 'lcov', 'json-summary'], include: ['src/**/*.ts'], - exclude: ['src/**/*.spec.ts', 'src/**/*.int.spec.ts', 'src/main.ts', 'src/scripts/**'], + exclude: [ + 'src/**/*.spec.ts', + 'src/**/*.int.spec.ts', + 'src/main.ts', + 'src/scripts/**', + // 控制器 / 模块为薄装配层:行为由集成测试(test:int)覆盖,不计入单测覆盖率 + 'src/**/*.controller.ts', + 'src/**/*.module.ts', + 'src/**/*.types.ts', + 'src/testing/**', + ], thresholds: { lines: 60, functions: 60, statements: 60, branches: 60 }, }, }, diff --git a/apps/web/package.json b/apps/web/package.json index ea55689..fbcbdbc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,6 +8,7 @@ "build": "tsc -p tsconfig.json --noEmit && vite build", "preview": "vite preview --port 4173 --strictPort", "typecheck": "tsc -p tsconfig.json --noEmit", + "lint": "eslint src --max-warnings=0", "test": "vitest run --coverage" }, "dependencies": { diff --git a/apps/web/src/api/client.spec.ts b/apps/web/src/api/client.spec.ts index 3eca30d..270d125 100644 --- a/apps/web/src/api/client.spec.ts +++ b/apps/web/src/api/client.spec.ts @@ -16,7 +16,7 @@ function signIn(accessToken = 'token-1', refreshToken = 'refresh-1'): void { useAuthStore.getState().setSession({ accessToken, refreshToken, - user: { id: '01J0TEST000000000000000000', name: '测试用户', email: 'test@lingxi.local' }, + user: { id: '01J0TEST000000000000000000', name: '测试用户', account: 'test@lingxi.local' }, permissions: [], }); } diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 37dab18..66b01d0 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -108,6 +108,13 @@ async function toApiError(response: Response): Promise { }); } +/** 刷新失败:清本地会话并通知外壳跳登录(会话过期统一入口) */ +function failRefresh(): false { + useAuthStore.getState().clearSession(); + unauthorizedHandler?.(); + return false; +} + let refreshPromise: Promise | null = null; /** 单飞刷新:并发 401 只触发一次 /auth/refresh */ @@ -119,9 +126,9 @@ export function refreshSession(): Promise { } async function doRefresh(): Promise { - const { refreshToken, clearSession } = useAuthStore.getState(); + const { refreshToken } = useAuthStore.getState(); if (refreshToken === null) { - return false; + return failRefresh(); } let response: Response; try { @@ -131,12 +138,10 @@ async function doRefresh(): Promise { body: JSON.stringify({ refreshToken }), }); } catch { - return false; + return failRefresh(); } if (!response.ok) { - clearSession(); - unauthorizedHandler?.(); - return false; + return failRefresh(); } const payload: unknown = await response.json().catch(() => null); const accessToken = @@ -148,9 +153,7 @@ async function doRefresh(): Promise { ? payload.refreshToken : null; if (typeof accessToken !== 'string' || accessToken.length === 0) { - clearSession(); - unauthorizedHandler?.(); - return false; + return failRefresh(); } useAuthStore.getState().setTokens({ accessToken, diff --git a/apps/web/src/api/endpoints.ts b/apps/web/src/api/endpoints.ts new file mode 100644 index 0000000..0d88e13 --- /dev/null +++ b/apps/web/src/api/endpoints.ts @@ -0,0 +1,23 @@ +/** + * identity 模块接口路径(唯一出处)。 + * 依据:技术架构 §6.3.1、开发计划附录 D。 + * 后端若调整路径,只改这里;联调时以 `apps/web/src/api/generated/openapi.d.ts` 为准。 + */ +const USERS = '/users'; + +export const ENDPOINTS = { + login: '/auth/login', + refresh: '/auth/refresh', + logout: '/auth/logout', + changePassword: '/auth/change-password', + me: '/me', + users: USERS, + user: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}`, + userDisable: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/disable`, + userEnable: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/enable`, + userCustomers: (userId: string): string => `${USERS}/${encodeURIComponent(userId)}/customers`, + userTransferCustomers: (userId: string): string => + `${USERS}/${encodeURIComponent(userId)}/transfer-customers`, + departments: '/departments/tree', + roles: '/roles', +} as const; diff --git a/apps/web/src/api/generated/openapi.d.ts b/apps/web/src/api/generated/openapi.d.ts index 371a9b8..3b67b65 100644 --- a/apps/web/src/api/generated/openapi.d.ts +++ b/apps/web/src/api/generated/openapi.d.ts @@ -4,6 +4,270 @@ */ export interface paths { + '/api/v1/auth/login': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 账号密码登录 + * @description 返回 accessToken / refreshToken / expiresIn / me + */ + post: operations['AuthController_login']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/auth/refresh': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 刷新令牌(旋转,旧 refresh 立即失效) */ + post: operations['AuthController_refresh']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/auth/logout': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 登出 + * @description 吊销 refresh 令牌所在家族 + */ + post: operations['AuthController_logout']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/auth/change-password': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 修改密码(首登强改) */ + post: operations['AuthController_changePassword']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/me': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 当前用户、角色、权限点、数据范围 */ + get: operations['MeController_current']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/users': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 用户列表(游标分页) + * @description status 默认 active(停用默认隐藏);sort/order 走白名单;手机号仅返回掩码 + */ + get: operations['UsersController_list']; + put?: never; + /** + * 新建用户 + * @description 初始密码由管理员设定,首登强制修改 + */ + post: operations['UsersController_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/users/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 用户详情 + * @description 仅 user:manage 返回 phone 全号 + */ + get: operations['UsersController_detail']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** 更新用户(不含账号 / 状态 / 密码) */ + patch: operations['UsersController_update']; + trace?: never; + }; + '/api/v1/users/{id}/disable': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 停用用户 + * @description 停用后旧令牌立即失效;可指定客户转交人 + */ + post: operations['UsersController_disable']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/users/{id}/enable': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 启用用户 */ + post: operations['UsersController_enable']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/users/{id}/transfer-customers': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 客户转交(占位) + * @description A3 客户模块落地前返回 42201 + */ + post: operations['UsersController_transferCustomers']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/departments/tree': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 部门树(两级,含成员数与负责人) */ + get: operations['DepartmentsController_tree']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/departments': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 新建部门 / 小组 + * @description 最多两级,在小组下新建返回 42201 + */ + post: operations['DepartmentsController_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/departments/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** 部门改名 */ + patch: operations['DepartmentsController_rename']; + trace?: never; + }; + '/api/v1/roles': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 角色列表(绑定用) */ + get: operations['RolesController_list']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/health': { parameters: { query?: never; @@ -135,6 +399,278 @@ export interface components { } export type $defs = Record; export interface operations { + AuthController_login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuthController_refresh: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuthController_logout: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + AuthController_changePassword: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + MeController_current: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + UsersController_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + UsersController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + UsersController_detail: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + UsersController_update: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + UsersController_disable: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + UsersController_enable: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + UsersController_transferCustomers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + DepartmentsController_tree: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + DepartmentsController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + DepartmentsController_rename: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + RolesController_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; HealthController_check: { parameters: { query?: never; diff --git a/apps/web/src/api/identity.spec.ts b/apps/web/src/api/identity.spec.ts new file mode 100644 index 0000000..47a5063 --- /dev/null +++ b/apps/web/src/api/identity.spec.ts @@ -0,0 +1,199 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAuthStore } from '@/stores/auth-store'; + +import { ApiError, request, setUnauthorizedHandler } from './client'; +import { + changePassword, + createUser, + disableUser, + enableUser, + fetchDepartments, + fetchMe, + fetchRoles, + fetchUserCustomers, + login, + logout, + transferCustomers, + updateUser, +} from './identity'; +import { fetchUsers } from './users'; + +const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1'; +const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2'; +const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3'; + +const userSummary = { + id: USER_ID, + name: '陈默', + phoneMasked: '138****2046', + account: 'chen.mo', + departmentId: GROUP_ID, + departmentName: '内容生产中心', + groupId: GROUP_ID, + groupName: '素材一组', + roles: [{ id: ROLE_ID, code: 'ops', name: '素材运营', isSystem: false }], + status: 'active', + lastLoginAt: '2026-11-02T09:41:00.000Z', + createdAt: '2026-09-30T18:40:00.000Z', +}; + +const me = { + user: userSummary, + roles: userSummary.roles, + permissions: ['user:view'], + dataScope: { type: 'all' }, + mustChangePassword: false, +}; + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); + +function toUrl(input: string | URL | Request | undefined): string { + if (typeof input === 'string') { + return input; + } + if (input instanceof URL) { + return input.href; + } + return input?.url ?? ''; +} + +function calls(): { url: string; init: RequestInit | undefined }[] { + const fetchMock = vi.mocked(globalThis.fetch); + return fetchMock.mock.calls.map((call) => ({ + url: toUrl(call[0]), + init: call[1], + })); +} + +function lastBody(): unknown { + const last = calls().at(-1); + const body = last?.init?.body; + return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined; +} + +describe('identity 接口封装', () => { + beforeEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + useAuthStore.getState().clearSession(); + setUnauthorizedHandler(null); + useAuthStore.getState().setSession({ + accessToken: 'token', + user: { id: USER_ID, name: '陈默', account: 'chen.mo' }, + permissions: ['user:view'], + }); + }); + + it('登录:POST /auth/login,body 为 account/password 且不带 token', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + jsonResponse({ + accessToken: 'a', + refreshToken: 'r', + expiresIn: 3600, + me, + }), + ); + const response = await login({ account: 'chen.mo', password: 'Lx123456' }); + expect(response.me.permissions).toEqual(['user:view']); + expect(calls()[0]?.init?.method).toBe('POST'); + expect(lastBody()).toEqual({ account: 'chen.mo', password: 'Lx123456' }); + }); + + it('/me、部门与角色列表按共享 schema 解析', async () => { + const department = { + id: GROUP_ID, + name: '内容生产中心', + parentId: null, + leader: null, + memberCount: 19, + createdAt: '2026-09-01T00:00:00.000Z', + children: [ + { + id: USER_ID, + name: '素材一组', + parentId: GROUP_ID, + leader: null, + memberCount: 9, + createdAt: '2026-09-01T00:00:00.000Z', + children: [], + }, + ], + }; + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(me)) + .mockResolvedValueOnce(jsonResponse({ items: [department] })) + .mockResolvedValueOnce(jsonResponse({ items: userSummary.roles })); + expect((await fetchMe()).user.name).toBe('陈默'); + const departments = await fetchDepartments(); + expect(departments.map((node) => node.name)).toEqual(['内容生产中心', '素材一组']); + expect((await fetchRoles())[0]?.name).toBe('素材运营'); + }); + + it('用户列表保留 next_cursor,写接口使用约定方法与路径', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({ items: [userSummary], next_cursor: 'cursor-2' })) + .mockResolvedValueOnce(jsonResponse({ id: USER_ID })) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(jsonResponse([{ id: USER_ID, name: '花漾家居' }])) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })); + + const list = await fetchUsers({ limit: 10, status: 'active' }); + expect(list.next_cursor).toBe('cursor-2'); + expect( + await createUser({ + name: '测试成员', + phone: '13900001111', + account: 'test.member', + password: 'Lx123456', + departmentId: GROUP_ID, + roleIds: [ROLE_ID], + }), + ).toBe(USER_ID); + await updateUser(USER_ID, { name: '陈默' }); + await disableUser(USER_ID, { reason: '离职' }); + await enableUser(USER_ID); + await transferCustomers(USER_ID, { customerIds: [USER_ID], toUserId: ROLE_ID }); + expect((await fetchUserCustomers(USER_ID))[0]?.name).toBe('花漾家居'); + await changePassword({ currentPassword: 'Lx123456', newPassword: 'Lx654321' }); + await logout('refresh-token'); + + const paths = calls().map((call) => ({ + url: call.url.replace('http://127.0.0.1:3000/api/v1', ''), + method: call.init?.method ?? 'GET', + })); + expect(paths).toContainEqual({ url: '/users', method: 'POST' }); + expect(paths).toContainEqual({ url: `/users/${USER_ID}`, method: 'PATCH' }); + expect(paths).toContainEqual({ url: `/users/${USER_ID}/disable`, method: 'POST' }); + expect(paths).toContainEqual({ url: `/users/${USER_ID}/enable`, method: 'POST' }); + expect(paths).toContainEqual({ + url: `/users/${USER_ID}/transfer-customers`, + method: 'POST', + }); + expect(paths).toContainEqual({ url: '/auth/change-password', method: 'POST' }); + expect(paths).toContainEqual({ url: '/auth/logout', method: 'POST' }); + }); + + it('刷新失败时清理会话并触发跳登录', async () => { + const handler = vi.fn(); + setUnauthorizedHandler(handler); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ code: 40101, message: '登录已过期', trace_id: 't-401' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ); + useAuthStore.getState().setTokens({ accessToken: 'old', refreshToken: 'refresh-1' }); + await expect(request('/me')).rejects.toBeInstanceOf(ApiError); + expect(handler).toHaveBeenCalled(); + expect(useAuthStore.getState().accessToken).toBeNull(); + }); +}); diff --git a/apps/web/src/api/identity.ts b/apps/web/src/api/identity.ts new file mode 100644 index 0000000..1ce6db6 --- /dev/null +++ b/apps/web/src/api/identity.ts @@ -0,0 +1,135 @@ +import { + type ChangePasswordInput, + changePasswordInputSchema, + type CreateUserInput, + createUserInputSchema, + type DepartmentNode, + departmentNodeSchema, + type DisableUserInput, + disableUserInputSchema, + type LoginInput, + loginInputSchema, + type LoginResponse, + loginResponseSchema, + type MeResponse, + meResponseSchema, + type RoleSummary, + roleSummarySchema, + type TransferCustomersInput, + transferCustomersInputSchema, + type UpdateUserInput, + updateUserInputSchema, +} from '@lingxi/shared'; +import { z } from 'zod'; + +import { request } from './client'; +import { ENDPOINTS } from './endpoints'; + +/** 客户摘要:A3 客户模块落地前的转交占位契约(仅 id + name) */ +export interface CustomerSummary { + id: string; + name: string; +} + +const customerSummarySchema = z.object({ id: z.string().min(1), name: z.string().min(1) }); +const createdUserSchema = z.object({ id: z.string().min(1) }).passthrough(); + +/** 兼容 `T[]` 与 `{ items: T[] }` 两种列表返回,逐项用共享 schema 校验 */ +function parseList(payload: unknown, itemSchema: z.ZodType): T[] { + const candidate: unknown[] = Array.isArray(payload) + ? payload + : isRecord(payload) && Array.isArray(payload.items) + ? payload.items + : []; + return candidate.map((item) => itemSchema.parse(item)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export async function login(input: LoginInput): Promise { + const body = loginInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.login, { method: 'POST', auth: false, body }); + return loginResponseSchema.parse(payload); +} + +export async function logout(refreshToken: string | null): Promise { + const body: { refreshToken?: string } = refreshToken === null ? {} : { refreshToken }; + await request(ENDPOINTS.logout, { method: 'POST', body }); +} + +export async function changePassword(input: ChangePasswordInput): Promise { + const body = changePasswordInputSchema.parse(input); + await request(ENDPOINTS.changePassword, { method: 'POST', body }); +} + +export async function fetchMe(): Promise { + const payload: unknown = await request(ENDPOINTS.me); + return meResponseSchema.parse(payload); +} + +/** 部门树(后端返回两级嵌套 children),前端拍平成列表供下拉与筛选使用 */ +export async function fetchDepartments(): Promise { + const payload: unknown = await request(ENDPOINTS.departments); + const raw: unknown = isRecord(payload) && Array.isArray(payload.items) ? payload.items : payload; + if (!Array.isArray(raw)) { + return []; + } + const flat: DepartmentNode[] = []; + const walk = (nodes: unknown[]): void => { + for (const node of nodes) { + if (!isRecord(node)) { + continue; + } + flat.push(departmentNodeSchema.parse(node)); + const children: unknown = node.children; + if (Array.isArray(children)) { + walk(children); + } + } + }; + walk(raw); + return flat; +} + +export async function fetchRoles(): Promise { + const payload: unknown = await request(ENDPOINTS.roles); + return parseList(payload, roleSummarySchema); +} + +/** 新建用户:返回新用户 id;后端若未返回 id,则由调用方回落到列表页 */ +export async function createUser(input: CreateUserInput): Promise { + const body = createUserInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.users, { method: 'POST', body }); + const parsed = createdUserSchema.safeParse(payload); + return parsed.success ? parsed.data.id : null; +} + +export async function updateUser(userId: string, input: UpdateUserInput): Promise { + const body = updateUserInputSchema.parse(input); + await request(ENDPOINTS.user(userId), { method: 'PATCH', body }); +} + +export async function disableUser(userId: string, input: DisableUserInput): Promise { + const body = disableUserInputSchema.parse(input); + await request(ENDPOINTS.userDisable(userId), { method: 'POST', body }); +} + +export async function enableUser(userId: string): Promise { + await request(ENDPOINTS.userEnable(userId), { method: 'POST' }); +} + +export async function transferCustomers( + userId: string, + input: TransferCustomersInput, +): Promise { + const body = transferCustomersInputSchema.parse(input); + await request(ENDPOINTS.userTransferCustomers(userId), { method: 'POST', body }); +} + +/** 负责客户列表(A3 接口落地前调用会 404,由 hook 降级为空态) */ +export async function fetchUserCustomers(userId: string): Promise { + const payload: unknown = await request(ENDPOINTS.userCustomers(userId)); + return parseList(payload, customerSummarySchema); +} diff --git a/apps/web/src/api/query-keys.ts b/apps/web/src/api/query-keys.ts new file mode 100644 index 0000000..c468f30 --- /dev/null +++ b/apps/web/src/api/query-keys.ts @@ -0,0 +1,12 @@ +import type { UserListQueryInput } from './users'; + +/** TanStack Query 缓存键(唯一出处,避免散落字符串) */ +export const queryKeys = { + me: ['me'] as const, + users: ['users'] as const, + userList: (query: UserListQueryInput) => ['users', 'list', query] as const, + user: (userId: string) => ['users', 'detail', userId] as const, + userCustomers: (userId: string) => ['users', 'customers', userId] as const, + departments: ['departments'] as const, + roles: ['roles'] as const, +}; diff --git a/apps/web/src/api/users.spec.ts b/apps/web/src/api/users.spec.ts new file mode 100644 index 0000000..e84b7a3 --- /dev/null +++ b/apps/web/src/api/users.spec.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAuthStore } from '@/stores/auth-store'; + +import { fetchUserDetail, fetchUsers, toUserListSearchParams } from './users'; + +const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ'; +const DEPT_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1'; +const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2'; + +const summary = { + id: USER_ID, + name: '陈默', + phoneMasked: '138****2046', + account: 'chen.mo', + departmentId: DEPT_ID, + departmentName: '内容生产中心', + groupId: null, + groupName: '素材一组', + roles: [{ id: ROLE_ID, code: 'ops', name: '素材运营', isSystem: false }], + status: 'active', + lastLoginAt: '2026-11-02T09:41:00.000Z', + createdAt: '2026-09-30T18:40:00.000Z', +}; + +describe('用户列表请求参数', () => { + it('按后端 query 约定拼装 snake_case 参数,空值不传', () => { + const params = toUserListSearchParams({ + limit: 10, + status: 'active', + sort: 'last_login', + order: 'desc', + keyword: ' 陈 ', + }); + expect(params.get('limit')).toBe('10'); + expect(params.get('status')).toBe('active'); + expect(params.get('sort')).toBe('last_login'); + expect(params.get('order')).toBe('desc'); + expect(params.get('keyword')).toBe('陈'); + expect(params.has('department_id')).toBe(false); + expect(params.has('cursor')).toBe(false); + }); + + it('带游标与部门/角色筛选时完整输出', () => { + const params = toUserListSearchParams({ + limit: 10, + cursor: 'cursor-2', + status: 'all', + sort: 'created_at', + order: 'asc', + department_id: DEPT_ID, + role_id: ROLE_ID, + }); + expect(params.toString()).toBe( + `limit=10&cursor=cursor-2&department_id=${DEPT_ID}&role_id=${ROLE_ID}&status=all&sort=created_at&order=asc`, + ); + }); +}); + +function toUrl(input: string | URL | Request | undefined): string { + if (typeof input === 'string') { + return input; + } + if (input instanceof URL) { + return input.href; + } + return input?.url ?? ''; +} + +describe('用户列表请求', () => { + beforeEach(() => { + window.localStorage.clear(); + useAuthStore.getState().setSession({ + accessToken: 'token', + user: { id: USER_ID, name: '陈默', account: 'chen.mo' }, + permissions: ['user:view'], + }); + }); + + it('解析列表信封与详情', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + new Response(JSON.stringify({ items: [summary], next_cursor: 'cursor-2' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + ...summary, + phone: '13800002046', + mustChangePassword: true, + updatedAt: '2026-11-02T09:41:00.000Z', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const list = await fetchUsers({ limit: 10, status: 'active' }); + expect(list.items).toHaveLength(1); + expect(list.next_cursor).toBe('cursor-2'); + expect(toUrl(fetchMock.mock.calls[0]?.[0])).toContain('/users?limit=10&status=active'); + + const detail = await fetchUserDetail(USER_ID); + expect(detail.phone).toBe('13800002046'); + expect(detail.mustChangePassword).toBe(true); + expect(toUrl(fetchMock.mock.calls[1]?.[0])).toContain(`/users/${USER_ID}`); + }); +}); diff --git a/apps/web/src/api/users.ts b/apps/web/src/api/users.ts new file mode 100644 index 0000000..2a8e688 --- /dev/null +++ b/apps/web/src/api/users.ts @@ -0,0 +1,60 @@ +import { + type ListResponse, + listResponseSchema, + type UserDetail, + userDetailSchema, + type UserListQuery, + type UserSummary, + userSummarySchema, +} from '@lingxi/shared'; + +import { request } from './client'; +import { ENDPOINTS } from './endpoints'; + +const userListResponseSchema = listResponseSchema(userSummarySchema); + +/** 用户列表筛选与游标分页查询(query 参数 snake_case) */ +export interface UserListQueryInput extends Partial { + limit: number; + cursor?: string; +} + +/** 组装 GET /users 的 query 参数;空值不落 query,避免后端严格校验报错 */ +export function toUserListSearchParams(query: UserListQueryInput): URLSearchParams { + const params = new URLSearchParams(); + params.set('limit', String(query.limit)); + if (query.cursor !== undefined && query.cursor !== '') { + params.set('cursor', query.cursor); + } + if (query.keyword !== undefined && query.keyword.trim() !== '') { + params.set('keyword', query.keyword.trim()); + } + if (query.department_id !== undefined) { + params.set('department_id', query.department_id); + } + if (query.role_id !== undefined) { + params.set('role_id', query.role_id); + } + if (query.status !== undefined) { + params.set('status', query.status); + } + if (query.sort !== undefined) { + params.set('sort', query.sort); + } + if (query.order !== undefined) { + params.set('order', query.order); + } + return params; +} + +export async function fetchUsers(query: UserListQueryInput): Promise> { + const search = toUserListSearchParams(query).toString(); + const path = search === '' ? ENDPOINTS.users : `${ENDPOINTS.users}?${search}`; + const payload: unknown = await request(path); + return userListResponseSchema.parse(payload); +} + +export async function fetchUserDetail(userId: string): Promise { + const payload: unknown = await request(ENDPOINTS.user(userId)); + return userDetailSchema.parse(payload); +} diff --git a/apps/web/src/components/Can.tsx b/apps/web/src/components/Can.tsx new file mode 100644 index 0000000..968632c --- /dev/null +++ b/apps/web/src/components/Can.tsx @@ -0,0 +1,19 @@ +import type { PermissionCode } from '@lingxi/shared'; +import type { ReactNode } from 'react'; + +import { usePermission } from '@/hooks/use-permission'; + +export interface CanProps { + /** 需要的权限点(数组时按 mode 组合判断) */ + permission: PermissionCode | readonly PermissionCode[]; + mode?: 'all' | 'any'; + /** 无权限时的渲染内容;默认不渲染(按钮级控制要求「不渲染」而非隐藏) */ + fallback?: ReactNode; + children: ReactNode; +} + +/** 按钮级权限控制:无权限时不渲染子树 */ +export function Can({ permission, mode = 'all', fallback = null, children }: CanProps): ReactNode { + const allowed = usePermission(permission, mode); + return allowed ? children : fallback; +} diff --git a/apps/web/src/components/auth/MustChangePasswordModal.tsx b/apps/web/src/components/auth/MustChangePasswordModal.tsx new file mode 100644 index 0000000..ee5135d --- /dev/null +++ b/apps/web/src/components/auth/MustChangePasswordModal.tsx @@ -0,0 +1,119 @@ +/* eslint-disable max-lines-per-function -- 弹窗按字段顺序展开,拆分后状态传递反而更繁琐 */ +import { Button, Input, Modal } from '@douyinfe/semi-ui-19'; +import { IconLock } from '@lingxi/ui/icons'; +import type { ReactNode } from 'react'; + +import { useChangePasswordForm } from './use-change-password-form'; + +export interface MustChangePasswordModalProps { + /** 改密成功:调用方负责清会话并停留在登录页 */ + onCompleted: () => void; + /** 取消 / 关闭:调用方负责清会话回登录表单 */ + onCancel: () => void; +} + +function Field({ + label, + value, + error, + disabled, + placeholder, + onChange, +}: { + label: string; + value: string; + error: string | null; + disabled: boolean; + placeholder: string; + onChange: (value: string) => void; +}): ReactNode { + return ( + + ); +} + +/** + * 首登强制改密:只在登录流程内渲染(不进入 AppLayout), + * 改密成功后由登录页清会话并要求用新密码重新登录。 + */ +export function MustChangePasswordModal({ + onCompleted, + onCancel, +}: MustChangePasswordModalProps): ReactNode { + const form = useChangePasswordForm(); + + const handleSubmit = (): void => { + void form.submit().then((ok) => { + if (ok) { + onCompleted(); + } + }); + }; + + return ( + + + + + } + > +
+

+ + 为保障账号安全,初始密码仅用于首次登录;新密码需 8–64 位且同时包含字母与数字。 +

+ + + {form.submitError === null ? null : ( +

{form.submitError}

+ )} +

+ 修改成功后当前会话将退出,请使用新密码重新登录。 +

+
+
+ ); +} diff --git a/apps/web/src/components/auth/RequireAuth.spec.tsx b/apps/web/src/components/auth/RequireAuth.spec.tsx new file mode 100644 index 0000000..93547a9 --- /dev/null +++ b/apps/web/src/components/auth/RequireAuth.spec.tsx @@ -0,0 +1,84 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiError } from '@/api/client'; +import { fetchMe } from '@/api/identity'; +import { useAuthStore } from '@/stores/auth-store'; + +// @lingxi/ui 的骨架/错误态依赖 Semi(lottie),jsdom 无 canvas;守卫单测只关心跳转逻辑 +vi.mock('@lingxi/ui', () => ({ + PageSkeleton: () =>
, + ErrorState: () =>
, +})); + +vi.mock('@/api/identity', () => ({ + fetchMe: vi.fn(), +})); + +import { RequireAuth } from './RequireAuth'; + +const mockedFetchMe = vi.mocked(fetchMe); + +function renderGuard(entry: string): void { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + +
受保护页面
+ + } + /> + 登录页
} /> + + + , + ); +} + +describe('登录守卫', () => { + afterEach(() => { + cleanup(); + window.localStorage.clear(); + }); + + beforeEach(() => { + mockedFetchMe.mockReset(); + useAuthStore.getState().clearSession(); + }); + + it('未登录时跳登录页并携带 redirect', async () => { + renderGuard('/users'); + expect(await screen.findByTestId('login-route')).toBeTruthy(); + expect(screen.queryByText('受保护页面')).toBeNull(); + }); + + it('会话处于「未改初始密码」时清会话并回登录页(后端 40903)', async () => { + mockedFetchMe.mockRejectedValue( + new ApiError({ code: 40903, status: 409, message: '请先修改初始密码' }), + ); + useAuthStore.getState().setSession({ + accessToken: 'real-token', + refreshToken: 'refresh', + user: { id: '01HZZZZZZZZZZZZZZZZZZZZZZZ', name: '管理员', account: 'admin' }, + permissions: ['user:view'], + mustChangePassword: true, + }); + renderGuard('/users'); + expect(await screen.findByTestId('login-route')).toBeTruthy(); + expect(useAuthStore.getState().accessToken).toBeNull(); + }); + + it('已登录(演示会话)时直接渲染受保护内容', () => { + useAuthStore.getState().signInDemo(); + expect(useAuthStore.getState().accessToken).not.toBeNull(); + renderGuard('/users'); + expect(screen.getByText('受保护页面')).toBeTruthy(); + }); +}); diff --git a/apps/web/src/components/auth/RequireAuth.tsx b/apps/web/src/components/auth/RequireAuth.tsx new file mode 100644 index 0000000..1140743 --- /dev/null +++ b/apps/web/src/components/auth/RequireAuth.tsx @@ -0,0 +1,83 @@ +/* eslint-disable max-lines-per-function -- 守卫串联 /me 同步、401 与 409 兜底,集中便于追踪 */ +import { ErrorState, PageSkeleton } from '@lingxi/ui'; +import { type ReactNode, useEffect } from 'react'; +import { Navigate, useLocation } from 'react-router'; + +import { + isAccountDisabledError, + isMustChangePasswordError, + isUnauthorizedError, + useMeQuery, +} from '@/hooks/queries/use-me'; +import { loginPath } from '@/routes'; +import { isDemoSession, useAuthStore } from '@/stores/auth-store'; + +export interface RequireAuthProps { + children: ReactNode; +} + +/** 登录守卫:未登录跳登录页(带 redirect),已登录则拉 /me 后再渲染外壳 */ +export function RequireAuth({ children }: RequireAuthProps): ReactNode { + const location = useLocation(); + const accessToken = useAuthStore((state) => state.accessToken); + const syncFromMe = useAuthStore((state) => state.syncFromMe); + const clearSession = useAuthStore((state) => state.clearSession); + const meQuery = useMeQuery(); + const from = `${location.pathname}${location.search}`; + const mustChangeBlocked = isMustChangePasswordError(meQuery.error); + + useEffect(() => { + if (meQuery.data !== undefined) { + syncFromMe(meQuery.data); + } + }, [meQuery.data, syncFromMe]); + + // 刷新页面时若会话仍处于「未改初始密码」状态:清会话回登录页完成改密,避免卡在 409 + useEffect(() => { + if (mustChangeBlocked) { + clearSession(); + } + }, [mustChangeBlocked, clearSession]); + + if (accessToken === null) { + return ; + } + if (isDemoSession(accessToken)) { + return children; + } + if (meQuery.isPending) { + return ( +
+ +
+ ); + } + if (mustChangeBlocked) { + return ; + } + if (meQuery.isError) { + if (isAccountDisabledError(meQuery.error)) { + // 会话在登录页按 reason 清理,避免先 clearSession 触发「未登录」分支把 reason 丢掉 + const base = loginPath(from); + return ( + + ); + } + if (isUnauthorizedError(meQuery.error)) { + clearSession(); + return ; + } + return ( +
+ { + void meQuery.refetch(); + }} + /> +
+ ); + } + return children; +} diff --git a/apps/web/src/components/auth/RequirePermission.spec.tsx b/apps/web/src/components/auth/RequirePermission.spec.tsx new file mode 100644 index 0000000..e7de08f --- /dev/null +++ b/apps/web/src/components/auth/RequirePermission.spec.tsx @@ -0,0 +1,55 @@ +import { Permission } from '@lingxi/shared'; +import { cleanup, render, screen } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { useAuthStore } from '@/stores/auth-store'; + +import { RequirePermission } from './RequirePermission'; + +const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ'; + +function renderGuard(permissions: string[]): void { + useAuthStore.getState().setSession({ + accessToken: 'token', + user: { id: USER_ID, name: '测试用户', account: 'test.user' }, + permissions, + }); + render( + + + +
用户页面
+ + } + /> + 无权限页面
} /> + + , + ); +} + +describe('路由权限守卫', () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + window.localStorage.clear(); + useAuthStore.getState().clearSession(); + }); + + it('有 user:view 时渲染子路由', () => { + renderGuard([Permission.USER_VIEW]); + expect(screen.getByText('用户页面')).toBeTruthy(); + }); + + it('无权限时跳转 403,不渲染子路由', () => { + renderGuard([Permission.SCRIPT_READ]); + expect(screen.queryByText('用户页面')).toBeNull(); + expect(screen.getByText('无权限页面')).toBeTruthy(); + }); +}); diff --git a/apps/web/src/components/auth/RequirePermission.tsx b/apps/web/src/components/auth/RequirePermission.tsx new file mode 100644 index 0000000..7c1fd0a --- /dev/null +++ b/apps/web/src/components/auth/RequirePermission.tsx @@ -0,0 +1,22 @@ +import type { PermissionCode } from '@lingxi/shared'; +import type { ReactNode } from 'react'; +import { Navigate } from 'react-router'; + +import { usePermission } from '@/hooks/use-permission'; +import { ROUTES } from '@/routes'; + +export interface RequirePermissionProps { + permission: PermissionCode | readonly PermissionCode[]; + mode?: 'all' | 'any'; + children: ReactNode; +} + +/** 路由级权限守卫:缺少权限点时跳 403(权限来自 /me 或登录响应) */ +export function RequirePermission({ + permission, + mode = 'all', + children, +}: RequirePermissionProps): ReactNode { + const allowed = usePermission(permission, mode); + return allowed ? children : ; +} diff --git a/apps/web/src/components/auth/use-change-password-form.spec.ts b/apps/web/src/components/auth/use-change-password-form.spec.ts new file mode 100644 index 0000000..f92fab2 --- /dev/null +++ b/apps/web/src/components/auth/use-change-password-form.spec.ts @@ -0,0 +1,68 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ApiError } from '@/api/client'; +import { changePassword } from '@/api/identity'; + +import { useChangePasswordForm } from './use-change-password-form'; + +vi.mock('@/api/identity', () => ({ + changePassword: vi.fn(), +})); + +const mockedChangePassword = vi.mocked(changePassword); + +describe('首登强改密码表单', () => { + beforeEach(() => { + mockedChangePassword.mockReset(); + }); + + it('当前密码错误(后端 401)显示在字段下且不返回成功', async () => { + mockedChangePassword.mockRejectedValue( + new ApiError({ code: 40101, status: 401, message: '当前密码不正确' }), + ); + const { result } = renderHook(() => useChangePasswordForm()); + act(() => { + result.current.setCurrentPassword('wrong-pass1'); + result.current.setNewPassword('Lingxi2026a'); + }); + let ok = true; + await act(async () => { + ok = await result.current.submit(); + }); + expect(ok).toBe(false); + expect(result.current.currentError).toBe('当前密码不正确,请重新输入'); + expect(result.current.submitError).toBeNull(); + }); + + it('校验不过时不发请求', async () => { + const { result } = renderHook(() => useChangePasswordForm()); + act(() => { + result.current.setCurrentPassword('lingxi2026'); + result.current.setNewPassword('short'); + }); + await act(async () => { + await result.current.submit(); + }); + expect(mockedChangePassword).not.toHaveBeenCalled(); + expect(result.current.newError).toBeTruthy(); + }); + + it('改密成功返回 true,由调用方负责清会话', async () => { + mockedChangePassword.mockResolvedValue(); + const { result } = renderHook(() => useChangePasswordForm()); + act(() => { + result.current.setCurrentPassword('lingxi2026'); + result.current.setNewPassword('Lingxi2026a'); + }); + let ok = false; + await act(async () => { + ok = await result.current.submit(); + }); + expect(ok).toBe(true); + expect(mockedChangePassword).toHaveBeenCalledWith({ + currentPassword: 'lingxi2026', + newPassword: 'Lingxi2026a', + }); + }); +}); diff --git a/apps/web/src/components/auth/use-change-password-form.ts b/apps/web/src/components/auth/use-change-password-form.ts new file mode 100644 index 0000000..e743e48 --- /dev/null +++ b/apps/web/src/components/auth/use-change-password-form.ts @@ -0,0 +1,88 @@ +/* eslint-disable max-lines-per-function -- 表单校验与提交流程集中在一个 Hook 便于追踪 */ +import { changePasswordInputSchema, passwordSchema } from '@lingxi/shared'; +import { useState } from 'react'; + +import { ApiError } from '@/api/client'; +import { changePassword } from '@/api/identity'; + +export interface ChangePasswordFormState { + currentPassword: string; + newPassword: string; + currentError: string | null; + newError: string | null; + submitting: boolean; + submitError: string | null; + setCurrentPassword: (value: string) => void; + setNewPassword: (value: string) => void; + /** 提交成功返回 true(由调用方决定清会话/跳转),失败返回 false */ + submit: () => Promise; +} + +function fieldError(message: string | undefined): string | null { + return message ?? null; +} + +function isWrongCurrentPassword(error: unknown): boolean { + return error instanceof ApiError && (error.status === 401 || error.code === 40101); +} + +/** 首次登录强制改密:密码规则与后端共用 shared 的 zod schema;不改会话,副作用交给调用方 */ +export function useChangePasswordForm(): ChangePasswordFormState { + const [currentPassword, setCurrent] = useState(''); + const [newPassword, setNew] = useState(''); + const [currentError, setCurrentError] = useState(null); + const [newError, setNewError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const submit = async (): Promise => { + const parsed = changePasswordInputSchema.safeParse({ currentPassword, newPassword }); + if (!parsed.success) { + const issues = parsed.error.issues; + setCurrentError( + fieldError(issues.find((issue) => issue.path[0] === 'currentPassword')?.message), + ); + setNewError(fieldError(issues.find((issue) => issue.path[0] === 'newPassword')?.message)); + return false; + } + const passwordCheck = passwordSchema.safeParse(newPassword); + if (!passwordCheck.success) { + setNewError(fieldError(passwordCheck.error.issues[0]?.message)); + return false; + } + setSubmitting(true); + setSubmitError(null); + try { + await changePassword(parsed.data); + return true; + } catch (error) { + if (isWrongCurrentPassword(error)) { + // 后端对「当前密码不正确」返回 401;按字段错误展示,不触发全局登出 + setCurrentError('当前密码不正确,请重新输入'); + } else { + setSubmitError(error instanceof Error ? error.message : '修改密码失败,请稍后重试'); + } + return false; + } finally { + setSubmitting(false); + } + }; + + return { + currentPassword, + newPassword, + currentError, + newError, + submitting, + submitError, + setCurrentPassword: (value) => { + setCurrent(value); + setCurrentError(null); + }, + setNewPassword: (value) => { + setNew(value); + setNewError(null); + }, + submit, + }; +} diff --git a/apps/web/src/hooks/queries/use-directory.ts b/apps/web/src/hooks/queries/use-directory.ts new file mode 100644 index 0000000..0ec1aaa --- /dev/null +++ b/apps/web/src/hooks/queries/use-directory.ts @@ -0,0 +1,21 @@ +import { type DepartmentNode, type RoleSummary } from '@lingxi/shared'; +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; + +import { fetchDepartments, fetchRoles } from '@/api/identity'; +import { queryKeys } from '@/api/query-keys'; + +export function useDepartmentsQuery(): UseQueryResult { + return useQuery({ + queryKey: queryKeys.departments, + queryFn: fetchDepartments, + staleTime: 5 * 60_000, + }); +} + +export function useRolesQuery(): UseQueryResult { + return useQuery({ + queryKey: queryKeys.roles, + queryFn: fetchRoles, + staleTime: 5 * 60_000, + }); +} diff --git a/apps/web/src/hooks/queries/use-me.ts b/apps/web/src/hooks/queries/use-me.ts new file mode 100644 index 0000000..12d111d --- /dev/null +++ b/apps/web/src/hooks/queries/use-me.ts @@ -0,0 +1,34 @@ +import type { MeResponse } from '@lingxi/shared'; +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; + +import { ApiError } from '@/api/client'; +import { fetchMe } from '@/api/identity'; +import { queryKeys } from '@/api/query-keys'; +import { isDemoSession, useAuthStore } from '@/stores/auth-store'; + +/** /me 全局缓存:守卫与权限 Hook 共用;演示会话不请求后端 */ +export function useMeQuery(): UseQueryResult { + const accessToken = useAuthStore((state) => state.accessToken); + const enabled = accessToken !== null && !isDemoSession(accessToken); + return useQuery({ + queryKey: queryKeys.me, + queryFn: fetchMe, + enabled, + retry: false, + staleTime: 60_000, + }); +} + +export function isUnauthorizedError(error: unknown): boolean { + return error instanceof ApiError && error.status === 401; +} + +/** 账号被停用:/me 由登录守卫返回 40301,此时旧会话应视为失效并回登录页 */ +export function isAccountDisabledError(error: unknown): boolean { + return error instanceof ApiError && (error.status === 403 || error.code === 40301); +} + +/** 后端守卫:初始密码未修改时,除改密外的接口统一返回 40903「请先修改初始密码」 */ +export function isMustChangePasswordError(error: unknown): boolean { + return error instanceof ApiError && (error.status === 409 || error.code === 40903); +} diff --git a/apps/web/src/hooks/queries/use-users.ts b/apps/web/src/hooks/queries/use-users.ts new file mode 100644 index 0000000..8404fd2 --- /dev/null +++ b/apps/web/src/hooks/queries/use-users.ts @@ -0,0 +1,152 @@ +import { + type CreateUserInput, + type DisableUserInput, + type ListResponse, + type TransferCustomersInput, + type UpdateUserInput, + type UserDetail, + type UserSummary, +} from '@lingxi/shared'; +import { + keepPreviousData, + useMutation, + type UseMutationResult, + useQuery, + useQueryClient, + type UseQueryResult, +} from '@tanstack/react-query'; + +import { ApiError } from '@/api/client'; +import { + createUser, + type CustomerSummary, + disableUser, + enableUser, + fetchUserCustomers, + transferCustomers, + updateUser, +} from '@/api/identity'; +import { queryKeys } from '@/api/query-keys'; +import { fetchUserDetail, fetchUsers, type UserListQueryInput } from '@/api/users'; + +/** 转交/停用弹窗的接收人候选:一次拉取在职成员(上限 100),长时间复用 */ +export function useUserCandidatesQuery( + enabled: boolean, +): UseQueryResult> { + return useQuery({ + queryKey: [...queryKeys.users, 'candidates'] as const, + queryFn: () => fetchUsers({ limit: 100, status: 'active', sort: 'last_login', order: 'desc' }), + enabled, + staleTime: 60_000, + }); +} + +export function useUserListQuery( + query: UserListQueryInput, +): UseQueryResult> { + return useQuery({ + queryKey: queryKeys.userList(query), + queryFn: () => fetchUsers(query), + placeholderData: keepPreviousData, + staleTime: 10_000, + }); +} + +export function useUserDetailQuery(userId: string | null): UseQueryResult { + return useQuery({ + queryKey: queryKeys.user(userId ?? ''), + queryFn: () => fetchUserDetail(userId ?? ''), + enabled: userId !== null && userId !== '', + }); +} + +/** 负责客户:A3 客户模块接口未落地时降级为空列表,不阻塞详情其它 Tab */ +export function useUserCustomersQuery(userId: string | null): UseQueryResult { + return useQuery({ + queryKey: queryKeys.userCustomers(userId ?? ''), + queryFn: async () => { + try { + return await fetchUserCustomers(userId ?? ''); + } catch (error) { + if (error instanceof ApiError && (error.status === 404 || error.status === 422)) { + return []; + } + throw error; + } + }, + enabled: userId !== null && userId !== '', + retry: false, + }); +} + +function useInvalidateUsers(): () => Promise { + const queryClient = useQueryClient(); + return async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.users }); + }; +} + +export function useCreateUserMutation(): UseMutationResult { + const invalidate = useInvalidateUsers(); + return useMutation({ + mutationFn: (input: CreateUserInput) => createUser(input), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useUpdateUserMutation(): UseMutationResult< + void, + Error, + { userId: string; input: UpdateUserInput } +> { + const invalidate = useInvalidateUsers(); + return useMutation({ + mutationFn: ({ userId, input }: { userId: string; input: UpdateUserInput }) => + updateUser(userId, input), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useDisableUserMutation(): UseMutationResult< + void, + Error, + { userId: string; input: DisableUserInput } +> { + const invalidate = useInvalidateUsers(); + return useMutation({ + mutationFn: ({ userId, input }: { userId: string; input: DisableUserInput }) => + disableUser(userId, input), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useEnableUserMutation(): UseMutationResult { + const invalidate = useInvalidateUsers(); + return useMutation({ + mutationFn: (userId: string) => enableUser(userId), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useTransferCustomersMutation(): UseMutationResult< + void, + Error, + { userId: string; input: TransferCustomersInput } +> { + const invalidate = useInvalidateUsers(); + return useMutation({ + mutationFn: ({ userId, input }: { userId: string; input: TransferCustomersInput }) => + transferCustomers(userId, input), + onSuccess: async () => { + await invalidate(); + }, + }); +} diff --git a/apps/web/src/hooks/use-debounced-value.ts b/apps/web/src/hooks/use-debounced-value.ts new file mode 100644 index 0000000..da92d12 --- /dev/null +++ b/apps/web/src/hooks/use-debounced-value.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +/** 输入防抖:UI 即时反馈,请求参数延后生效 */ +export function useDebouncedValue(value: T, delay = 300): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = window.setTimeout(() => { + setDebounced(value); + }, delay); + return () => { + window.clearTimeout(timer); + }; + }, [value, delay]); + + return debounced; +} diff --git a/apps/web/src/hooks/use-logout.ts b/apps/web/src/hooks/use-logout.ts new file mode 100644 index 0000000..bdebc0d --- /dev/null +++ b/apps/web/src/hooks/use-logout.ts @@ -0,0 +1,26 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { useNavigate } from 'react-router'; + +import { logout } from '@/api/identity'; +import { ROUTES } from '@/routes'; +import { useAuthStore } from '@/stores/auth-store'; + +/** 登出:先吊销服务端会话(失败不阻塞),再清本地会话与查询缓存 */ +export function useLogout(): () => void { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const refreshToken = useAuthStore((state) => state.refreshToken); + const clearSession = useAuthStore((state) => state.clearSession); + + return useCallback(() => { + const finish = (): void => { + clearSession(); + queryClient.clear(); + void navigate(ROUTES.login, { replace: true }); + }; + void logout(refreshToken) + .catch(() => undefined) + .finally(finish); + }, [clearSession, navigate, queryClient, refreshToken]); +} diff --git a/apps/web/src/hooks/use-permission.spec.ts b/apps/web/src/hooks/use-permission.spec.ts new file mode 100644 index 0000000..672f608 --- /dev/null +++ b/apps/web/src/hooks/use-permission.spec.ts @@ -0,0 +1,55 @@ +import { Permission } from '@lingxi/shared'; +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { useAuthStore } from '@/stores/auth-store'; + +import { can, usePermission, usePermissions } from './use-permission'; + +const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZZ'; + +function signIn(permissions: string[]): void { + act(() => { + useAuthStore.getState().setSession({ + accessToken: 'token', + user: { id: USER_ID, name: '测试用户', account: 'test.user' }, + permissions, + }); + }); +} + +describe('权限判断', () => { + beforeEach(() => { + window.localStorage.clear(); + act(() => { + useAuthStore.getState().clearSession(); + }); + }); + + it('can 支持单权限、全部命中与任一命中', () => { + expect(can([Permission.USER_VIEW], Permission.USER_VIEW)).toBe(true); + expect(can([], Permission.USER_VIEW)).toBe(false); + expect(can([Permission.USER_VIEW], [Permission.USER_VIEW, Permission.USER_MANAGE])).toBe(false); + expect(can([Permission.USER_VIEW], [Permission.USER_VIEW, Permission.USER_MANAGE], 'any')).toBe( + true, + ); + expect(can([], [])).toBe(true); + }); + + it('usePermission 随 auth store 权限变化更新', () => { + const view = renderHook(() => usePermission(Permission.USER_VIEW)); + expect(view.result.current).toBe(false); + + signIn([Permission.USER_VIEW]); + expect(view.result.current).toBe(true); + + signIn([]); + expect(view.result.current).toBe(false); + }); + + it('usePermissions 返回当前权限集合', () => { + signIn([Permission.USER_VIEW, Permission.USER_MANAGE]); + const perms = renderHook(() => usePermissions()); + expect(perms.result.current).toEqual([Permission.USER_VIEW, Permission.USER_MANAGE]); + }); +}); diff --git a/apps/web/src/hooks/use-permission.ts b/apps/web/src/hooks/use-permission.ts new file mode 100644 index 0000000..0145046 --- /dev/null +++ b/apps/web/src/hooks/use-permission.ts @@ -0,0 +1,32 @@ +import type { PermissionCode } from '@lingxi/shared'; + +import { useAuthStore } from '@/stores/auth-store'; + +/** 纯函数:权限点判断(便于单测与在非组件上下文复用) */ +export function can( + permissions: readonly string[], + permission: PermissionCode | readonly PermissionCode[], + mode: 'all' | 'any' = 'all', +): boolean { + const required = typeof permission === 'string' ? [permission] : permission; + if (required.length === 0) { + return true; + } + return mode === 'all' + ? required.every((code) => permissions.includes(code)) + : required.some((code) => permissions.includes(code)); +} + +/** 当前登录用户的权限点集合(/me 或登录响应写入 auth store) */ +export function usePermissions(): readonly string[] { + return useAuthStore((state) => state.permissions); +} + +/** 单个(或一组)权限点判断;`mode='all'` 要求全部命中 */ +export function usePermission( + permission: PermissionCode | readonly PermissionCode[], + mode: 'all' | 'any' = 'all', +): boolean { + const permissions = usePermissions(); + return can(permissions, permission, mode); +} diff --git a/apps/web/src/layout/AppLayout.tsx b/apps/web/src/layout/AppLayout.tsx index bc45d99..96c7b20 100644 --- a/apps/web/src/layout/AppLayout.tsx +++ b/apps/web/src/layout/AppLayout.tsx @@ -11,6 +11,11 @@ import { PageBreadcrumbs } from './PageBreadcrumbs'; import { SideNav } from './SideNav'; import { TopBar } from './TopBar'; +/** 用户与组织为「满高主从布局」,由页面自己控制内部滚动 */ +function isFlushRoute(pathname: string): boolean { + return pathname === ROUTES.users || pathname.startsWith(`${ROUTES.users}/`); +} + export function AppLayout(): ReactNode { const location = useLocation(); const permissions = useAuthStore((state) => state.permissions); @@ -46,7 +51,7 @@ export function AppLayout(): ReactNode { />
-
+
}> diff --git a/apps/web/src/layout/PageBreadcrumbs.tsx b/apps/web/src/layout/PageBreadcrumbs.tsx index c672137..abc6111 100644 --- a/apps/web/src/layout/PageBreadcrumbs.tsx +++ b/apps/web/src/layout/PageBreadcrumbs.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { Link } from 'react-router'; +import { getUserSectionTrail } from '@/navigation/breadcrumbs'; import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config'; export interface PageBreadcrumbsProps { @@ -8,7 +9,7 @@ export interface PageBreadcrumbsProps { } export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode { - const entries = getBreadcrumbs(NAV_MODULES, pathname); + const entries = getUserSectionTrail(pathname) ?? getBreadcrumbs(NAV_MODULES, pathname); return (
); } function UserMenuDropdown({ open, - email, + account, onLogout, }: { open: boolean; - email: string; + account: string; onLogout: () => void; }): ReactNode { if (!open) { @@ -106,7 +98,7 @@ function UserMenuDropdown({ } return (
-
{email}
+
{account}
+function LoginAlert({ form }: { form: LoginFormState }): ReactNode { + if (form.lockedMessage !== null) { + return ( +
+ + {form.lockedMessage}
- + ); + } + if (form.formError !== null) { + return ( +
+ + {form.formError} +
+ ); + } + return null; +} + +function AccountField({ form }: { form: LoginFormState }): ReactNode { + return ( + ); } -export function LoginFormCard({ - account, - password, - submitting, - onAccountChange, - onPasswordChange, - onSubmit, -}: LoginFormCardProps): ReactNode { +function PasswordField({ form }: { form: LoginFormState }): ReactNode { + const [visible, setVisible] = useState(false); return ( -
- + + ); +} -
- +function LoginMeta(): ReactNode { + return ( +
+ 记住我(7 天) + +
+ ); +} +/** 右栏登录卡片:错误 / 锁定 / 提交中三态由 form 状态驱动 */ +export function LoginFormCard({ form, successMessage, onSubmit }: LoginFormCardProps): ReactNode { + const disabled = form.submitting || form.lockedMessage !== null || form.mustChangePassword; + return ( +
+
+

登录灵犀

+

使用企业账号登录素材生产平台

+
+ { + event.preventDefault(); + onSubmit(); + }} + data-testid="login-form" + > + {successMessage === undefined || successMessage === null ? null : ( + + )} + + + + - - +

账号由平台管理员开通,如需协助请联系 IT 服务台

); } diff --git a/apps/web/src/pages/login/LoginPage.tsx b/apps/web/src/pages/login/LoginPage.tsx index fea0f3a..e641dc1 100644 --- a/apps/web/src/pages/login/LoginPage.tsx +++ b/apps/web/src/pages/login/LoginPage.tsx @@ -1,35 +1,73 @@ -import type { ReactNode } from 'react'; +import { IconAlert } from '@lingxi/ui/icons'; +import { type ReactNode, useEffect } from 'react'; +import { Navigate, useSearchParams } from 'react-router'; + +import { MustChangePasswordModal } from '@/components/auth/MustChangePasswordModal'; +import { ROUTES, sanitizeRedirect } from '@/routes'; +import { useAuthStore } from '@/stores/auth-store'; import { LoginBrandPanel } from './LoginBrandPanel'; import { LoginFormCard } from './LoginFormCard'; import { useLoginForm } from './use-login-form'; +function AccountDisabledNotice(): ReactNode { + return ( +

+ + 你的账号已被停用,请联系平台管理员。停用账号无法登录,历史数据仍会保留。 +

+ ); +} + export default function LoginPage(): ReactNode { - const { account, password, submitting, setAccount, setPassword, handleSubmit } = useLoginForm(); + const accessToken = useAuthStore((state) => state.accessToken); + const [searchParams] = useSearchParams(); + const form = useLoginForm(); + const clearSession = useAuthStore((state) => state.clearSession); + const target = sanitizeRedirect(searchParams.get('redirect')) ?? ROUTES.users; + const accountDisabled = searchParams.get('reason') === 'account-disabled'; + + useEffect(() => { + if (accountDisabled) { + clearSession(); + } + }, [accountDisabled, clearSession]); + + // 首登强改期间已写入 token,但不能进入 AppLayout(后端会拒绝其它接口) + if (accessToken !== null && !form.mustChangePassword && !accountDisabled) { + return ; + } return (
-
+
+ + + {accountDisabled ? : null} { - void handleSubmit(event); + form={form} + successMessage={form.successMessage} + onSubmit={() => { + void form.submit(); }} /> + {form.mustChangePassword ? ( + + ) : null} +
+ © 2026 灵犀 · 内部系统 + + 隐私政策 + 服务条款 + +
-
- © 2026 灵犀 · 内部系统 - - 隐私政策 - 服务条款 - 华东一区 - -
); } diff --git a/apps/web/src/pages/login/login-content.ts b/apps/web/src/pages/login/login-content.ts new file mode 100644 index 0000000..a8473b2 --- /dev/null +++ b/apps/web/src/pages/login/login-content.ts @@ -0,0 +1,48 @@ +import { IconCheck, IconMaterial, IconScript } from '@lingxi/ui/icons'; +import type { ComponentType } from 'react'; + +export type LoginCapabilityIcon = ComponentType<{ size?: number | string; className?: string }>; + +/** 登录页能力说明(左栏) */ +export interface LoginCapability { + key: string; + title: string; + description: string; + icon: LoginCapabilityIcon; +} + +export const LOGIN_CAPABILITIES: readonly LoginCapability[] = [ + { + key: 'script', + title: '从要素卡到脚本', + description: '行业包给定字段与话术边界,脚本按镜头拆分,逐镜可改。', + icon: IconScript, + }, + { + key: 'material', + title: '图片确定性与视频逐镜生成', + description: 'PSD 套版合成稳定出品,视频按分镜逐条生成、逐条替换。', + icon: IconMaterial, + }, + { + key: 'delivery', + title: '快筛之后即归档', + description: '多版同屏对比快筛,导出即归档,每次导出都留痕可追溯。', + icon: IconCheck, + }, +]; + +/** 素材墙:用 CSS 渐变模拟同一支片子的四种规格,不引图片资源 */ +export interface LoginMaterialSample { + key: string; + ratio: string; + label: string; + tone: number; +} + +export const LOGIN_MATERIAL_SAMPLES: readonly LoginMaterialSample[] = [ + { key: 'vertical', ratio: '9:16', label: '竖版主图', tone: 1 }, + { key: 'square', ratio: '1:1', label: '方形主图', tone: 2 }, + { key: 'wide', ratio: '16:9', label: '横版封面', tone: 3 }, + { key: 'classic', ratio: '4:3', label: '经典比例', tone: 4 }, +]; diff --git a/apps/web/src/pages/login/use-login-form.spec.tsx b/apps/web/src/pages/login/use-login-form.spec.tsx new file mode 100644 index 0000000..52a08a3 --- /dev/null +++ b/apps/web/src/pages/login/use-login-form.spec.tsx @@ -0,0 +1,143 @@ +import type { MeResponse } from '@lingxi/shared'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { MemoryRouter } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { login } from '@/api/identity'; +import { useAuthStore } from '@/stores/auth-store'; + +import { useLoginForm } from './use-login-form'; + +vi.mock('@/api/identity', () => ({ + login: vi.fn(), +})); + +vi.mock('@lingxi/ui', () => ({ + notifySuccess: vi.fn(), + notifyWarning: vi.fn(), +})); + +const mockedLogin = vi.mocked(login); + +const USER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1'; +const ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2'; +const GROUP_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ3'; + +function meWith(mustChangePassword: boolean): MeResponse { + return { + user: { + id: USER_ID, + name: '管理员', + phoneMasked: '133****0071', + account: 'admin', + departmentId: GROUP_ID, + departmentName: '平台技术部', + groupId: null, + groupName: null, + roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }], + status: 'active', + lastLoginAt: null, + createdAt: '2026-09-01T00:00:00.000Z', + }, + roles: [{ id: ROLE_ID, code: 'super_admin', name: '超级管理员', isSystem: true }], + permissions: ['user:view', 'user:manage'], + dataScope: { type: 'all' }, + mustChangePassword, + }; +} + +function createWrapper(): ({ children }: { children: ReactNode }) => ReactNode { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }): ReactNode { + return ( + + {children} + + ); + }; +} + +async function submitLogin( + result: { current: ReturnType }, + account: string, + password: string, +): Promise { + act(() => { + result.current.setAccount(account); + result.current.setPassword(password); + }); + await act(async () => { + await result.current.submit(); + }); +} + +describe('登录流程 · 首登强改', () => { + beforeEach(() => { + window.localStorage.clear(); + useAuthStore.getState().clearSession(); + mockedLogin.mockReset(); + }); + + it('mustChangePassword=true 时写入会话但停留在登录页等待改密', async () => { + mockedLogin.mockResolvedValue({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + expiresIn: 3600, + me: meWith(true), + }); + const { result } = renderHook(() => useLoginForm(), { wrapper: createWrapper() }); + await submitLogin(result, 'admin', 'lingxi2026'); + expect(result.current.mustChangePassword).toBe(true); + expect(useAuthStore.getState().accessToken).toBe('access-1'); + expect(useAuthStore.getState().mustChangePassword).toBe(true); + }); + + it('改密成功:清会话并提示用新密码重新登录', async () => { + mockedLogin.mockResolvedValue({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + expiresIn: 3600, + me: meWith(true), + }); + const { result } = renderHook(() => useLoginForm(), { wrapper: createWrapper() }); + await submitLogin(result, 'admin', 'lingxi2026'); + act(() => { + result.current.completePasswordChange(); + }); + expect(result.current.mustChangePassword).toBe(false); + expect(result.current.successMessage).toContain('请使用新密码重新登录'); + expect(useAuthStore.getState().accessToken).toBeNull(); + }); + + it('取消改密:清会话回到登录表单', async () => { + mockedLogin.mockResolvedValue({ + accessToken: 'access-1', + refreshToken: 'refresh-1', + expiresIn: 3600, + me: meWith(true), + }); + const { result } = renderHook(() => useLoginForm(), { wrapper: createWrapper() }); + await submitLogin(result, 'admin', 'lingxi2026'); + act(() => { + result.current.cancelPasswordChange(); + }); + expect(result.current.mustChangePassword).toBe(false); + expect(result.current.successMessage).toBeNull(); + expect(useAuthStore.getState().accessToken).toBeNull(); + }); + + it('无需改密时直接写会话并跳转(不回退到改密态)', async () => { + mockedLogin.mockResolvedValue({ + accessToken: 'access-2', + refreshToken: 'refresh-2', + expiresIn: 3600, + me: meWith(false), + }); + const { result } = renderHook(() => useLoginForm(), { wrapper: createWrapper() }); + await submitLogin(result, 'admin', 'Lingxi2026a'); + expect(result.current.mustChangePassword).toBe(false); + expect(useAuthStore.getState().accessToken).toBe('access-2'); + }); +}); diff --git a/apps/web/src/pages/login/use-login-form.ts b/apps/web/src/pages/login/use-login-form.ts index b52dbf7..a463dc8 100644 --- a/apps/web/src/pages/login/use-login-form.ts +++ b/apps/web/src/pages/login/use-login-form.ts @@ -1,70 +1,153 @@ -import { notifyError, notifyWarning } from '@lingxi/ui'; -import { type SyntheticEvent, useState } from 'react'; -import { useNavigate } from 'react-router'; +/* eslint-disable max-lines-per-function, complexity -- 登录状态机集中在一个 Hook 便于追踪 */ +import { loginInputSchema } from '@lingxi/shared'; +import { useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router'; -import { ApiError, request } from '@/api/client'; -import { ROUTES } from '@/routes'; -import type { AuthUser } from '@/stores/auth-store'; -import { useAuthStore } from '@/stores/auth-store'; +import { ApiError } from '@/api/client'; +import { login } from '@/api/identity'; +import { queryKeys } from '@/api/query-keys'; +import { ROUTES, sanitizeRedirect } from '@/routes'; +import { authUserFromMe, useAuthStore } from '@/stores/auth-store'; -interface LoginResponse { - accessToken: string; - refreshToken?: string; - user: AuthUser; - permissions?: string[]; -} +const demoEnabled = import.meta.env.VITE_PUBLIC_DEMO_SESSION === 'true'; -const demoEnabled = import.meta.env.VITE_PUBLIC_DEMO_SESSION !== 'false'; +const LOCKED_FALLBACK = '账号已临时锁定,请 15 分钟后重试,或联系平台管理员解锁。'; -export function useLoginForm(): { +const PASSWORD_CHANGED_NOTICE = '初始密码已更新,请使用新密码重新登录'; + +export interface LoginFormState { account: string; password: string; submitting: boolean; + accountError: string | null; + passwordError: string | null; + formError: string | null; + lockedMessage: string | null; + /** 登录返回 me.mustChangePassword=true:停留在登录页完成首登改密 */ + mustChangePassword: boolean; + /** 改密成功后的成功提示位 */ + successMessage: string | null; setAccount: (value: string) => void; setPassword: (value: string) => void; - handleSubmit: (event: SyntheticEvent) => Promise; -} { + submit: () => Promise; + /** 改密成功:清会话与缓存,留在登录页提示用新密码登录 */ + completePasswordChange: () => void; + /** 取消改密:清会话回登录表单 */ + cancelPasswordChange: () => void; +} + +function isLockedError(error: ApiError): boolean { + return error.status === 429 || error.code === 42902; +} + +/** 登录表单状态机:校验 → 提交 → 写会话/查询缓存 → (首登)改密 → 回跳 redirect */ +export function useLoginForm(): LoginFormState { const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [searchParams] = useSearchParams(); const signInDemo = useAuthStore((state) => state.signInDemo); const setSession = useAuthStore((state) => state.setSession); - const [account, setAccount] = useState(''); - const [password, setPassword] = useState(''); + const clearSession = useAuthStore((state) => state.clearSession); + const [account, setAccountValue] = useState(''); + const [password, setPasswordValue] = useState(''); const [submitting, setSubmitting] = useState(false); + const [accountError, setAccountError] = useState(null); + const [passwordError, setPasswordError] = useState(null); + const [formError, setFormError] = useState(null); + const [lockedMessage, setLockedMessage] = useState(null); + const [mustChangePassword, setMustChangePassword] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); - const handleSubmit = async (event: SyntheticEvent): Promise => { - event.preventDefault(); - if (account.trim() === '' || password === '') { - notifyWarning('请输入账号和密码'); + const redirectTarget = (): string => + sanitizeRedirect(searchParams.get('redirect')) ?? ROUTES.users; + + const submit = async (): Promise => { + if (submitting || lockedMessage !== null || mustChangePassword) { + return; + } + setFormError(null); + setSuccessMessage(null); + const parsed = loginInputSchema.safeParse({ account: account.trim(), password }); + if (!parsed.success) { + const issues = parsed.error.issues; + setAccountError(issues.find((issue) => issue.path[0] === 'account')?.message ?? null); + setPasswordError(issues.find((issue) => issue.path[0] === 'password')?.message ?? null); + return; + } + setAccountError(null); + setPasswordError(null); + if (demoEnabled) { + signInDemo(); + queryClient.removeQueries({ queryKey: queryKeys.me }); + void navigate(redirectTarget(), { replace: true }); return; } setSubmitting(true); try { - if (demoEnabled) { - signInDemo(); - await navigate(ROUTES.home, { replace: true }); + const response = await login(parsed.data); + setSession({ + accessToken: response.accessToken, + refreshToken: response.refreshToken, + user: authUserFromMe(response.me), + permissions: response.me.permissions, + mustChangePassword: response.me.mustChangePassword, + }); + // 用登录响应写 /me 缓存,进入 AppLayout 后不再重复请求 + queryClient.setQueryData(queryKeys.me, response.me); + if (response.me.mustChangePassword) { + setMustChangePassword(true); return; } - const session = await request('/auth/login', { - method: 'POST', - auth: false, - body: { email: account.trim(), password }, - }); - setSession({ - accessToken: session.accessToken, - refreshToken: session.refreshToken ?? null, - user: session.user, - permissions: session.permissions, - }); - await navigate(ROUTES.home, { replace: true }); + void navigate(redirectTarget(), { replace: true }); } catch (error) { - notifyError( - error instanceof Error ? error.message : '登录失败,请稍后重试', - error instanceof ApiError ? error.traceId : undefined, - ); + if (error instanceof ApiError && isLockedError(error)) { + setLockedMessage(error.message === '' ? LOCKED_FALLBACK : error.message); + } else { + setFormError(error instanceof Error ? error.message : '登录失败,请稍后重试'); + } } finally { setSubmitting(false); } }; - return { account, password, submitting, setAccount, setPassword, handleSubmit }; + const resetAfterPasswordChange = (notice: string | null): void => { + clearSession(); + queryClient.clear(); + setMustChangePassword(false); + setPasswordValue(''); + setPasswordError(null); + setFormError(null); + setSuccessMessage(notice); + }; + + return { + account, + password, + submitting, + accountError, + passwordError, + formError, + lockedMessage, + mustChangePassword, + successMessage, + setAccount: (value) => { + setAccountValue(value); + setAccountError(null); + setFormError(null); + setSuccessMessage(null); + }, + setPassword: (value) => { + setPasswordValue(value); + setPasswordError(null); + setFormError(null); + }, + submit, + completePasswordChange: () => { + resetAfterPasswordChange(PASSWORD_CHANGED_NOTICE); + }, + cancelPasswordChange: () => { + resetAfterPasswordChange(null); + }, + }; } diff --git a/apps/web/src/pages/users/RoleChipsSelect.tsx b/apps/web/src/pages/users/RoleChipsSelect.tsx new file mode 100644 index 0000000..efcd64c --- /dev/null +++ b/apps/web/src/pages/users/RoleChipsSelect.tsx @@ -0,0 +1,50 @@ +import type { RoleSummary } from '@lingxi/shared'; +import type { ReactNode } from 'react'; + +import { isSuperAdminRole } from './role-utils'; + +export interface RoleChipsSelectProps { + roles: readonly RoleSummary[]; + value: readonly string[]; + disabled?: boolean; + onChange: (roleIds: string[]) => void; +} + +export function RoleChipsSelect({ + roles, + value, + disabled = false, + onChange, +}: RoleChipsSelectProps): ReactNode { + const selected = new Set(value); + return ( +
+ {roles.map((role) => { + const active = selected.has(role.id); + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/pages/users/UserDetailPanel.tsx b/apps/web/src/pages/users/UserDetailPanel.tsx new file mode 100644 index 0000000..2e94d00 --- /dev/null +++ b/apps/web/src/pages/users/UserDetailPanel.tsx @@ -0,0 +1,249 @@ +/* eslint-disable max-lines-per-function -- 详情面板按头部/标签页分区组织,拆分降低可读性 */ +import { Button, Tabs } from '@douyinfe/semi-ui-19'; +import type { UserDetail } from '@lingxi/shared'; +import { Permission } from '@lingxi/shared'; +import { ErrorState, PageEmpty, PageSkeleton } from '@lingxi/ui'; +import { IconEdit, IconLock, IconRefresh } from '@lingxi/ui/icons'; +import type { ReactNode } from 'react'; +import { useState } from 'react'; + +import { Can } from '@/components/Can'; + +import { displayPhone, formatDateTime, userInitial } from './user-display'; +import { + UserActivityTab, + UserCustomersTab, + UserProfileTab, + UserRolesTab, + UserSecurityTab, +} from './UserDetailTabs'; +import { UserStatusTag } from './UserStatusTag'; + +const TABS = [ + { key: 'profile', label: '基本资料' }, + { key: 'roles', label: '角色权限' }, + { key: 'customers', label: '负责客户' }, + { key: 'security', label: '登录与安全' }, + { key: 'activity', label: '操作记录' }, +] as const; + +export interface UserDetailPanelProps { + userId: string | null; + detail: UserDetail | undefined; + loading: boolean; + error: Error | null; + canManage: boolean; + onRetry: () => void; + onEdit: (user: UserDetail) => void; + onDisable: (user: UserDetail) => void; + onEnable: (user: UserDetail) => void; + onAdjustRoles: (user: UserDetail) => void; + onTransfer: (user: UserDetail) => void; +} + +function DetailHeader({ + user, + canManage, + onEdit, + onDisable, + onEnable, +}: Pick & { + user: UserDetail; +}): ReactNode { + return ( +
+ + {userInitial(user.name)} + +
+
+

{user.name}

+ +
+

+ {user.groupName === null + ? user.departmentName + : `${user.departmentName} / ${user.groupName}`} + · + {displayPhone(user, canManage)} + · + 最近登录 {formatDateTime(user.lastLoginAt)} +

+
+
+ + + {user.status === 'active' ? ( + + ) : ( + + )} + +
+
+ ); +} + +function TabContent({ + tabKey, + user, + canManage, + onAdjustRoles, + onTransfer, +}: { + tabKey: string; + user: UserDetail; + canManage: boolean; + onAdjustRoles: () => void; + onTransfer: () => void; +}): ReactNode { + if (tabKey === 'roles') { + return ; + } + if (tabKey === 'customers') { + return ; + } + if (tabKey === 'security') { + return ; + } + if (tabKey === 'activity') { + return ; + } + return ; +} + +function DetailBody({ + user, + canManage, + onAdjustRoles, + onTransfer, +}: Pick & { + user: UserDetail; +}): ReactNode { + const [activeKey, setActiveKey] = useState('profile'); + return ( + { + setActiveKey(key); + }} + > + {TABS.map((tab) => ( + + {tab.key !== activeKey ? null : ( + { + onAdjustRoles(user); + }} + onTransfer={() => { + onTransfer(user); + }} + /> + )} + + ))} + + ); +} + +function DetailState({ + userId, + loading, + error, + onRetry, +}: Pick): ReactNode { + if (userId === null) { + return ( + + ); + } + if (loading) { + return ( +
+ +
+ ); + } + if (error !== null) { + return ( + + ); + } + return null; +} + +/** 右侧详情面板:头部操作 + 五个 Tab;数据由 /users/{id} 提供 */ +export function UserDetailPanel(props: UserDetailPanelProps): ReactNode { + const ready = props.userId !== null && props.detail !== undefined && props.error === null; + return ( +
+ {ready && props.detail !== undefined ? ( + <> + + + + ) : ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/pages/users/UserDetailTabs.tsx b/apps/web/src/pages/users/UserDetailTabs.tsx new file mode 100644 index 0000000..25d18c9 --- /dev/null +++ b/apps/web/src/pages/users/UserDetailTabs.tsx @@ -0,0 +1,235 @@ +import { Button } from '@douyinfe/semi-ui-19'; +import { Permission } from '@lingxi/shared'; +import { type UserDetail } from '@lingxi/shared'; +import { PageEmpty } from '@lingxi/ui'; +import { IconEdit, IconShare, IconUser } from '@lingxi/ui/icons'; +import type { ReactNode } from 'react'; + +import { Can } from '@/components/Can'; +import { useUserCustomersQuery } from '@/hooks/queries/use-users'; + +import { displayPhone, formatDateTime } from './user-display'; +import { UserStatusTag } from './UserStatusTag'; + +interface TabProps { + user: UserDetail; + canManage: boolean; +} + +function Kvi({ label, value }: { label: string; value: ReactNode }): ReactNode { + return ( +
+ {label} + {value} +
+ ); +} + +function Section({ title, children }: { title: string; children: ReactNode }): ReactNode { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function SectionHead({ + title, + hint, + action, +}: { + title: string; + hint: string; + action?: ReactNode; +}): ReactNode { + return ( +
+
+

{title}

+

{hint}

+
+ {action} +
+ ); +} + +export function UserProfileTab({ user, canManage }: TabProps): ReactNode { + const department = + user.groupName === null ? user.departmentName : `${user.departmentName} / ${user.groupName}`; + return ( +
+
+ + + + +
+
+ role.name).join('、')} + /> + } /> + + +
+
+ + + + +
+
+ ); +} + +export function UserRolesTab({ + user, + canManage, + onAdjustRoles, +}: TabProps & { onAdjustRoles: () => void }): ReactNode { + return ( +
+ } + onClick={onAdjustRoles} + data-testid="adjust-roles" + > + 调整角色 + + ) : null + } + /> +
+ {user.roles.map((role) => ( + + {role.name} + {role.isSystem ? 内置 : null} + + ))} +
+

+ 内置角色由系统预置,不可停用;角色变更即时生效并写入审计。 +

+
+ ); +} + +export function UserCustomersTab({ + user, + onTransfer, +}: { + user: UserDetail; + onTransfer: () => void; +}): ReactNode { + const query = useUserCustomersQuery(user.id); + const customers = query.data ?? []; + return ( +
+ + + + } + /> + {query.isLoading ?

加载中…

: null} + {!query.isLoading && customers.length === 0 ? ( + + ) : ( +
    + {customers.map((customer) => ( +
  • + + + + {customer.name} + 合作中 +
  • + ))} +
+ )} +
+ ); +} + +export function UserSecurityTab({ user }: TabProps): ReactNode { + return ( +
+
+ + + } /> + + +
+

+ 登录失败次数与临时锁定由平台统一管理;密码到期或重置后需在下次登录时修改。 +

+
+ ); +} + +interface ActivityEntry { + key: string; + title: string; + at: string | null | undefined; + extra?: string; +} + +function buildActivity(user: UserDetail): ActivityEntry[] { + const entries: ActivityEntry[] = [ + { key: 'created', title: '创建用户', at: user.createdAt }, + { key: 'updated', title: '最近更新资料', at: user.updatedAt }, + { + key: 'login', + title: '最近登录成功', + at: user.lastLoginAt, + extra: user.lastLoginIp ?? undefined, + }, + ]; + if (user.disabledAt !== null && user.disabledAt !== undefined) { + entries.push({ key: 'disabled', title: '停用账号', at: user.disabledAt }); + } + return entries + .filter((entry) => entry.at !== null && entry.at !== undefined) + .sort((left, right) => (right.at ?? '').localeCompare(left.at ?? '')); +} + +export function UserActivityTab({ user }: TabProps): ReactNode { + const entries = buildActivity(user); + return ( +
+

操作记录

+

完整审计日志将在审计模块上线后提供检索与导出。

+
    + {entries.map((entry, index) => ( +
  1. + +
    + {entry.title} + + {formatDateTime(entry.at)} + {entry.extra === undefined ? '' : ` · ${entry.extra}`} + +
    +
  2. + ))} +
+
+ ); +} diff --git a/apps/web/src/pages/users/UserListPanel.tsx b/apps/web/src/pages/users/UserListPanel.tsx new file mode 100644 index 0000000..e54de84 --- /dev/null +++ b/apps/web/src/pages/users/UserListPanel.tsx @@ -0,0 +1,359 @@ +/* eslint-disable max-lines-per-function -- 列表卡片按筛选/行/状态分区组织,拆分降低可读性 */ +import { Button, Input, Select, Skeleton } from '@douyinfe/semi-ui-19'; +import type { DepartmentNode, RoleSummary, UserListStatus, UserSummary } from '@lingxi/shared'; +import { ErrorState, PageEmpty } from '@lingxi/ui'; +import { IconBack, IconChevronRight, IconRefresh, IconSearch } from '@lingxi/ui/icons'; +import type { ReactNode } from 'react'; + +import { userInitial, userOrgLabel } from './user-display'; +import { + USER_SORT_OPTIONS, + USER_STATUS_OPTIONS, + type UserListFilters, + type UserSortValue, +} from './user-filters'; +import { UserStatusTag } from './UserStatusTag'; + +export interface UserFilterBarProps { + filters: UserListFilters; + departments: readonly DepartmentNode[]; + roles: readonly RoleSummary[]; + onFiltersChange: (next: Partial) => void; + onResetFilters: () => void; +} + +export interface UserListPanelProps { + filters: UserListFilters; + users: readonly UserSummary[]; + loading: boolean; + error: Error | null; + selectedUserId: string | null; + hasNext: boolean; + hasPrev: boolean; + onSortChange: (sort: UserSortValue) => void; + onSelect: (userId: string) => void; + onPrev: () => void; + onNext: () => void; + onRetry: () => void; + onCreate: () => void; + onResetFilters: () => void; +} + +function firstString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function StatusSegmented({ + value, + onChange, +}: { + value: UserListStatus; + onChange: (value: UserListStatus) => void; +}): ReactNode { + return ( +
+ {USER_STATUS_OPTIONS.map((option) => ( + + ))} +
+ ); +} + +/** 顶部筛选条:关键词 / 部门 / 角色 / 状态;变更即重置游标并重新查询 */ +export function UserFilterBar({ + filters, + departments, + roles, + onFiltersChange, + onResetFilters, +}: UserFilterBarProps): ReactNode { + return ( +
+ } + placeholder="搜索姓名、手机号或登录账号" + aria-label="搜索成员" + showClear + onChange={(value) => { + onFiltersChange({ keyword: value }); + }} + /> + ({ + label: `${node.name}(${String(node.memberCount)} 人)`, + value: node.id, + }))} + onChange={(value) => { + onFiltersChange({ departmentId: value }); + }} + /> + ({ + label: role.isSystem ? `${role.name}(内置)` : role.name, + value: role.id, + }))} + onChange={(value) => { + onFiltersChange({ roleId: value }); + }} + /> + { + onFiltersChange({ status }); + }} + /> + +
+ ); +} + +interface Option { + label: string; + value: string; +} + +/** 受控 Select:值统一降为 string,空字符串表示未选择 */ +function SelectField({ + className, + value, + placeholder, + options, + onChange, +}: { + className: string; + value: string; + placeholder: string; + options: Option[]; + onChange: (value: string) => void; +}): ReactNode { + return ( +
+ ({ ...option }))} + onChange={(value) => { + const sort = firstString(value); + if (sort !== '') { + props.onSortChange(sort as UserSortValue); + } + }} + /> +
+ +
+ +
+ + + ); +} diff --git a/apps/web/src/pages/users/UserNewPage.tsx b/apps/web/src/pages/users/UserNewPage.tsx new file mode 100644 index 0000000..5fa5dee --- /dev/null +++ b/apps/web/src/pages/users/UserNewPage.tsx @@ -0,0 +1,303 @@ +/* eslint-disable max-lines-per-function -- 表单页按三段顺序展开,与设计稿一一对应 */ +import { Button, Form, Input, Select } from '@douyinfe/semi-ui-19'; +import { type CreateUserInput, type DepartmentNode, type RoleSummary } from '@lingxi/shared'; +import { notifySuccess } from '@lingxi/ui'; +import { IconAdd, IconBack, IconRefresh } from '@lingxi/ui/icons'; +import type { ReactNode } from 'react'; +import { useState } from 'react'; +import { useNavigate } from 'react-router'; + +import { useDepartmentsQuery, useRolesQuery } from '@/hooks/queries/use-directory'; +import { useCreateUserMutation } from '@/hooks/queries/use-users'; +import { ROUTES, userDetailPath } from '@/routes'; + +import { SuperAdminGrantDialog } from './dialogs/SuperAdminGrantDialog'; +import { isSuperAdminRole } from './role-utils'; +import { RoleChipsSelect } from './RoleChipsSelect'; +import { type NewUserFormState, useNewUserForm } from './use-user-new-form'; +import { childDepartments, topLevelDepartments } from './user-filters'; + +function firstString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function Field({ + label, + error, + required = false, + optional = false, + children, +}: { + label: string; + error: string | undefined; + required?: boolean; + optional?: boolean; + children: ReactNode; +}): ReactNode { + return ( + + ); +} + +function Section({ + index, + title, + children, +}: { + index: number; + title: string; + children: ReactNode; +}): ReactNode { + return ( +
+

+ {index} + {title} +

+
{children}
+
+ ); +} + +function AccountFields({ form }: { form: NewUserFormState }): ReactNode { + return ( +
+ + { + form.setField('name', value); + }} + /> + + + { + form.setField('phone', value); + }} + /> + + + { + form.setField('account', value); + }} + /> + + +
+ { + form.setField('password', value); + }} + /> + +
+
+
+ ); +} + +function OrganizationFields({ + form, + departments, + roles, +}: { + form: NewUserFormState; + departments: readonly DepartmentNode[]; + roles: readonly RoleSummary[]; +}): ReactNode { + const groups = childDepartments(departments, form.values.departmentId); + return ( +
+ + ({ label: node.name, value: node.id }))} + onChange={(value) => { + form.setField('groupId', firstString(value)); + }} + /> + +
+ + 角色 + * + 至少选择一个 + + { + form.setField('roleIds', roleIds); + }} + /> + {form.errors.roleIds === undefined ? null : ( + {form.errors.roleIds} + )} +
+
+ ); +} + +function SettingsFields({ form }: { form: NewUserFormState }): ReactNode { + return ( +
+
+ + 备注 + 选填 + + { + form.setField('remark', value); + }} + /> +
+

+ 创建后账号立即生效,成员首次登录需修改初始密码;初始密码仅通过安全渠道线下交付。 +

+
+ ); +} + +/** 新建用户:单栏三段式(账号信息 / 组织与角色 / 初始设置) */ +export function UserNewPage(): ReactNode { + const navigate = useNavigate(); + const form = useNewUserForm(); + const departments = useDepartmentsQuery(); + const roles = useRolesQuery(); + const createUser = useCreateUserMutation(); + const [pendingGrant, setPendingGrant] = useState(null); + + const runCreate = (input: CreateUserInput): void => { + createUser.mutate(input, { + onSuccess: (userId) => { + notifySuccess('成员已创建,初始密码已生成'); + void navigate(userId === null ? ROUTES.users : userDetailPath(userId), { replace: true }); + }, + }); + }; + + const submit = (): void => { + const input = form.validate(); + if (input === null) { + return; + } + const superAdminIds = (roles.data ?? []).filter(isSuperAdminRole).map((role) => role.id); + if (input.roleIds.some((id) => superAdminIds.includes(id))) { + setPendingGrant(input); + return; + } + runCreate(input); + }; + + return ( +
+
+
+

新建用户

+

填写账号信息、组织与角色后创建;初始密码由管理员设定

+
+ +
+ +
{ + submit(); + }} + > + + + +
+ + +
+ + + { + setPendingGrant(null); + }} + onConfirm={() => { + if (pendingGrant !== null) { + runCreate(pendingGrant); + } + setPendingGrant(null); + }} + /> +
+ ); +} diff --git a/apps/web/src/pages/users/UserStatusTag.tsx b/apps/web/src/pages/users/UserStatusTag.tsx new file mode 100644 index 0000000..98930f9 --- /dev/null +++ b/apps/web/src/pages/users/UserStatusTag.tsx @@ -0,0 +1,31 @@ +import type { UserStatus } from '@lingxi/shared'; +import type { ReactNode } from 'react'; + +import { USER_STATUS_LABEL } from './user-display'; + +export interface UserStatusTagProps { + status: UserStatus; + /** 列表行内只显示状态点,详情展示完整标签 */ + dotOnly?: boolean; +} + +export function UserStatusTag({ status, dotOnly = false }: UserStatusTagProps): ReactNode { + const active = status === 'active'; + return ( + + + ); +} diff --git a/apps/web/src/pages/users/UsersPage.tsx b/apps/web/src/pages/users/UsersPage.tsx new file mode 100644 index 0000000..243bbc0 --- /dev/null +++ b/apps/web/src/pages/users/UsersPage.tsx @@ -0,0 +1,187 @@ +/* eslint-disable max-lines-per-function, complexity -- 页面负责主从联动与弹窗编排,集中在一处便于追踪数据流 */ +import { Button } from '@douyinfe/semi-ui-19'; +import { Permission } from '@lingxi/shared'; +import { IconAdd } from '@lingxi/ui/icons'; +import type { ReactNode } from 'react'; +import { useNavigate, useParams } from 'react-router'; + +import { Can } from '@/components/Can'; +import { useDepartmentsQuery, useRolesQuery } from '@/hooks/queries/use-directory'; +import { + useUserCandidatesQuery, + useUserCustomersQuery, + useUserDetailQuery, +} from '@/hooks/queries/use-users'; +import { usePermission } from '@/hooks/use-permission'; +import { ROUTES, userDetailPath } from '@/routes'; + +import { AdjustRolesDialog } from './dialogs/AdjustRolesDialog'; +import { DisableUserDialog } from './dialogs/DisableUserDialog'; +import { EditUserDialog } from './dialogs/EditUserDialog'; +import { EnableUserDialog } from './dialogs/EnableUserDialog'; +import { SuperAdminGrantDialog } from './dialogs/SuperAdminGrantDialog'; +import { TransferCustomersDialog } from './dialogs/TransferCustomersDialog'; +import { useUserDialogs } from './use-user-dialogs'; +import { useUserListController } from './use-user-list-controller'; +import { UserDetailPanel } from './UserDetailPanel'; +import { UserFilterBar, UserListPanel } from './UserListPanel'; + +function UsersHeader(): ReactNode { + const navigate = useNavigate(); + return ( +
+
+

用户与组织

+

管理成员账号、角色与客户归属

+
+ + + +
+ ); +} + +/** 用户管理主页面:左列表 + 右详情主从联动(/users 与 /users/:id 共用) */ +export function UsersPage(): ReactNode { + const params = useParams<{ id?: string }>(); + const navigate = useNavigate(); + const list = useUserListController(); + const canManage = usePermission(Permission.USER_MANAGE); + const departments = useDepartmentsQuery(); + const roles = useRolesQuery(); + const dialogs = useUserDialogs(roles.data ?? []); + const selectedId = params.id ?? list.users[0]?.id ?? null; + const detail = useUserDetailQuery(selectedId); + const customers = useUserCustomersQuery(dialogs.dialog?.user.id ?? null); + const candidates = useUserCandidatesQuery(dialogs.dialog !== null); + const dialogUser = dialogs.dialog?.user ?? null; + + return ( +
+ + +
+ { + list.setFilters({ sort }); + }} + onSelect={(userId) => { + void navigate(userDetailPath(userId), { replace: true }); + }} + onPrev={list.onPrev} + onNext={list.onNext} + onRetry={list.onRetry} + onCreate={() => { + void navigate(ROUTES.userNew); + }} + onResetFilters={list.resetFilters} + /> + { + void detail.refetch(); + }} + onEdit={(user) => { + dialogs.open('edit', user); + }} + onDisable={(user) => { + dialogs.open('disable', user); + }} + onEnable={(user) => { + dialogs.open('enable', user); + }} + onAdjustRoles={(user) => { + dialogs.open('roles', user); + }} + onTransfer={(user) => { + dialogs.open('transfer', user); + }} + /> +
+ + + + + + + +
+ ); +} diff --git a/apps/web/src/pages/users/dialogs/AdjustRolesDialog.tsx b/apps/web/src/pages/users/dialogs/AdjustRolesDialog.tsx new file mode 100644 index 0000000..d8e23d5 --- /dev/null +++ b/apps/web/src/pages/users/dialogs/AdjustRolesDialog.tsx @@ -0,0 +1,56 @@ +import type { RoleSummary, UserDetail } from '@lingxi/shared'; +import type { ReactNode } from 'react'; +import { useState } from 'react'; + +import { RoleChipsSelect } from '../RoleChipsSelect'; +import { ConfirmActionDialog } from './ConfirmActionDialog'; + +export interface AdjustRolesDialogProps { + visible: boolean; + user: UserDetail | null; + roles: readonly RoleSummary[]; + loading: boolean; + onCancel: () => void; + onConfirm: (roleIds: string[]) => void; +} + +/** 调整角色:变更前后角色对比 + 影响与审计说明,勾选后保存 */ +export function AdjustRolesDialog({ + visible, + user, + roles, + loading, + onCancel, + onConfirm, +}: AdjustRolesDialogProps): ReactNode { + const initial = user?.roles.map((role) => role.id) ?? []; + const [roleIds, setRoleIds] = useState(initial); + const unchanged = + roleIds.length === initial.length && roleIds.every((id) => initial.includes(id)); + + return ( + { + onConfirm(roleIds); + }} + > +
+ 角色(至少选择一个) + +
+
+ ); +} diff --git a/apps/web/src/pages/users/dialogs/ConfirmActionDialog.tsx b/apps/web/src/pages/users/dialogs/ConfirmActionDialog.tsx new file mode 100644 index 0000000..d3c867c --- /dev/null +++ b/apps/web/src/pages/users/dialogs/ConfirmActionDialog.tsx @@ -0,0 +1,131 @@ +/* eslint-disable max-lines-per-function -- 确认弹窗模板固定,保持单文件便于复用 */ +import { Button, Checkbox, Modal } from '@douyinfe/semi-ui-19'; +import { IconAlert } from '@lingxi/ui/icons'; +import { type ReactNode, useState } from 'react'; + +export interface ConfirmActionDialogProps { + visible: boolean; + title: string; + objectTitle: string; + objectMeta: string; + objectTags?: ReactNode; + consequences: readonly string[]; + acknowledgeLabel: string; + confirmText: string; + danger?: boolean; + loading?: boolean; + /** 额外条件(如转交接收人未选)导致不可提交 */ + confirmDisabled?: boolean; + onCancel: () => void; + onConfirm: () => void; + children?: ReactNode; +} + +function ModalFooter({ + loading, + danger, + disabled, + confirmText, + onCancel, + onConfirm, +}: { + loading: boolean; + danger: boolean; + disabled: boolean; + confirmText: string; + onCancel: () => void; + onConfirm: () => void; +}): ReactNode { + return ( + <> + + + + ); +} + +/** 统一二次确认弹窗:对象卡 → 后果说明 → 勾选确认后才可提交 */ +export function ConfirmActionDialog({ + visible, + title, + objectTitle, + objectMeta, + objectTags, + consequences, + acknowledgeLabel, + confirmText, + danger = false, + loading = false, + confirmDisabled = false, + onCancel, + onConfirm, + children, +}: ConfirmActionDialogProps): ReactNode { + const [acknowledged, setAcknowledged] = useState(false); + + return ( + + } + > +
+
+ + + {objectTitle} + {objectMeta} + + {objectTags} +
+ +
+ +
    + {consequences.map((item) => ( +
  • {item}
  • + ))} +
+
+ + {children} + + { + setAcknowledged(event.target.checked === true); + }} + > + {acknowledgeLabel} + +
+
+ ); +} diff --git a/apps/web/src/pages/users/dialogs/DisableUserDialog.tsx b/apps/web/src/pages/users/dialogs/DisableUserDialog.tsx new file mode 100644 index 0000000..66bd156 --- /dev/null +++ b/apps/web/src/pages/users/dialogs/DisableUserDialog.tsx @@ -0,0 +1,85 @@ +/* eslint-disable max-lines-per-function -- 确认弹窗模板固定,保持单文件便于复用 */ +import { Input, Select } from '@douyinfe/semi-ui-19'; +import type { DisableUserInput, UserDetail, UserSummary } from '@lingxi/shared'; +import type { ReactNode } from 'react'; +import { useState } from 'react'; + +import { ConfirmActionDialog } from './ConfirmActionDialog'; + +export interface DisableUserDialogProps { + visible: boolean; + user: UserDetail | null; + candidates: readonly UserSummary[]; + loading: boolean; + onCancel: () => void; + onConfirm: (input: DisableUserInput) => void; +} + +function firstString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +/** 停用账号:对象卡 → 后果 → 客户转交接收人 + 勾选确认 */ +export function DisableUserDialog({ + visible, + user, + candidates, + loading, + onCancel, + onConfirm, +}: DisableUserDialogProps): ReactNode { + const [reason, setReason] = useState(''); + const [transferTo, setTransferTo] = useState(''); + + const submit = (): void => { + const input: DisableUserInput = {}; + const trimmed = reason.trim(); + if (trimmed !== '') { + input.reason = trimmed; + } + if (transferTo !== '') { + input.transferToUserId = transferTo; + } + onConfirm(input); + }; + + return ( + + + + + + ))} + {customers.length === 0 ? ( +

该成员暂无可转交客户。

+ ) : null} +
+
+