feat(session): 在线会话强踢 + 账号锁定/解锁

- JWT 引入 sid,单会话精确强退(sid 黑名单)与按用户踢全部(tokenVersion)
- User 新增 lockedAt/lockReason,登录/刷新/守卫统一拒绝锁定
- sessions 模块:活跃会话列表 + 单会话强退;users 新增 lock/unlock/kick-sessions
- 资源注册 + syncTenantResources 存量租户补齐脚本

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 21:35:43 +08:00
parent 127707ffde
commit 86832ce32d
19 changed files with 385 additions and 76 deletions

View File

@ -26,7 +26,8 @@
"test:cov": "vitest run --coverage",
"test:debug": "vitest --inspect-brk --no-file-parallelism",
"test:e2e": "vitest run --config ./vitest.config.e2e.ts",
"seed": "prisma db seed"
"seed": "prisma db seed",
"sync:resources": "tsx prisma/sync-resources.ts"
},
"dependencies": {
"@nestjs/common": "^12.0.1",

View File

@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE `sys_refresh_token` ADD COLUMN `sid` VARCHAR(36) NULL;
-- AlterTable
ALTER TABLE `sys_user` ADD COLUMN `lock_reason` VARCHAR(255) NULL,
ADD COLUMN `locked_at` DATETIME(3) NULL;
-- CreateIndex
CREATE INDEX `sys_refresh_token_sid_idx` ON `sys_refresh_token`(`sid`);

View File

@ -36,6 +36,8 @@ model User {
avatar String? @db.VarChar(255)
status String @default("enabled") @db.VarChar(16)
tokenVersion Int @default(0) @map("token_version")
lockedAt DateTime? @map("locked_at")
lockReason String? @map("lock_reason") @db.VarChar(255)
orgCode String? @map("org_code") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at")
createdBy String? @map("created_by") @db.VarChar(36)
@ -213,6 +215,7 @@ model RefreshToken {
id String @id @default(uuid(7)) @db.VarChar(36)
tenantCode String @map("tenant_code") @db.VarChar(64)
userId String @map("user_id") @db.VarChar(36)
sid String? @db.VarChar(36)
jti String @unique @db.VarChar(64)
tokenHash String @map("token_hash") @db.VarChar(255)
ip String? @db.VarChar(64)
@ -223,6 +226,7 @@ model RefreshToken {
createdAt DateTime @default(now()) @map("created_at")
@@index([tenantCode, userId])
@@index([sid])
@@map("sys_refresh_token")
}

30
prisma/sync-resources.ts Normal file
View File

@ -0,0 +1,30 @@
import { PrismaClient } from '@prisma/client';
import { PLATFORM_ROLE_CODE, PLATFORM_TENANT_CODE, TENANT_ADMIN_ROLE_CODE } from '../src/common/constants.js';
import { syncTenantResources } from '../src/common/tenant-template.js';
const prisma = new PrismaClient();
/** 为所有存量租户补齐资源/字典/授权(幂等;不触碰用户/密码)。 */
async function main(): Promise<void> {
const tenants = await prisma.tenant.findMany({ select: { code: true } });
for (const t of tenants) {
const isPlatform = t.code === PLATFORM_TENANT_CODE;
await syncTenantResources({
prisma,
tenantCode: t.code,
adminRoleCode: isPlatform ? PLATFORM_ROLE_CODE : TENANT_ADMIN_ROLE_CODE,
includePlatformPerms: isPlatform,
});
console.log(`已同步资源:${t.code}(${isPlatform ? '平台' : '业务'}租户)`);
}
console.log(`syncTenantResources 完成,共 ${tenants.length} 个租户。`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -23,6 +23,7 @@ import { DictsModule } from './dicts/dicts.module.js';
import { ConfigsModule } from './configs/configs.module.js';
import { AuditLogsModule } from './audit-logs/audit-logs.module.js';
import { LoginLogsModule } from './login-logs/login-logs.module.js';
import { SessionsModule } from './sessions/sessions.module.js';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
@ -43,6 +44,7 @@ import { AppService } from './app.service.js';
ConfigsModule,
AuditLogsModule,
LoginLogsModule,
SessionsModule,
],
controllers: [AppController],
providers: [

View File

@ -10,12 +10,11 @@ import type { DataScope } from '../common/enums.js';
import type { RequestContext } from '../common/context/request-context.js';
import { AuditLogsService } from '../audit-logs/audit-logs.service.js';
import { LoginLogsService } from '../login-logs/login-logs.service.js';
import { PLATFORM_ROLE_CODE as SUPER_ROLE, PLATFORM_TENANT_CODE, permCacheKey, userStateCacheKey } from '../common/constants.js';
import { PLATFORM_ROLE_CODE as SUPER_ROLE, PLATFORM_TENANT_CODE, LOGIN_FAIL_PREFIX, permCacheKey, sessionRevokedKey, userStateCacheKey } from '../common/constants.js';
const REFRESH_TTL = Number(process.env.JWT_REFRESH_TTL ?? 604800);
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
const LOGIN_FAIL_PREFIX = 'loginFail:';
const MAX_LOGIN_FAILS = 5;
const MAX_GLOBAL_LOGIN_FAILS = 20;
const LOGIN_LOCK_SECONDS = 900;
@ -103,11 +102,11 @@ export class AuthService {
// 未指定租户:按用户名探测(一个用户名可存在于多个租户)
const users = await this.prisma.client.user.findMany({
where: { username: dto.username, deletedAt: null },
select: { tenantCode: true, passwordHash: true, status: true },
select: { tenantCode: true, passwordHash: true, status: true, lockedAt: true },
});
const matched: { tenantCode: string; tenantName: string }[] = [];
for (const u of users) {
if (u.status !== 'enabled') continue;
if (u.status !== 'enabled' || u.lockedAt) continue;
const tenant = await this.prisma.client.tenant.findUnique({ where: { code: u.tenantCode } });
if (!tenant || tenant.status !== 'enabled') continue;
if (await bcrypt.compare(dto.password, u.passwordHash)) {
@ -175,9 +174,13 @@ export class AuthService {
await this.recordLoginLog(tenantCode, username, 'fail', '账号或密码错误', ip, userAgent);
throw new UnauthorizedException('账号或密码错误');
}
if (user.lockedAt) {
await this.recordLoginLog(tenantCode, username, 'fail', '账号已锁定', ip, userAgent);
throw new UnauthorizedException('账号已锁定');
}
if (user.status !== 'enabled') {
await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用或锁定', ip, userAgent);
throw new UnauthorizedException('账号已停用或锁定');
await this.recordLoginLog(tenantCode, username, 'fail', '账号已停用', ip, userAgent);
throw new UnauthorizedException('账号已停用');
}
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) {
@ -196,7 +199,12 @@ export class AuthService {
ip: ip ?? null,
userAgent: userAgent ?? null,
});
return this.issue(tenantCode, user.id, user.username, user.tokenVersion, ip, userAgent);
return this.issue(tenantCode, user.id, user.username, user.tokenVersion, {
sid: randomUUID(),
loginAt: new Date(),
ip: ip ?? null,
userAgent: userAgent ?? null,
});
}
/** 注册:在指定租户下创建普通用户(无角色,状态启用);禁止注册到平台租户。 */
@ -252,7 +260,7 @@ export class AuthService {
const user = await this.prisma.client.user.findFirst({
where: { tenantCode: record.tenantCode, id: record.userId, deletedAt: null },
});
if (!user || user.status !== 'enabled') throw new UnauthorizedException('账号已失效');
if (!user || user.status !== 'enabled' || user.lockedAt) throw new UnauthorizedException('账号已失效');
// 旋转:作废旧 refresh,并重聚合权限集(权限变更在刷新时生效)
await this.prisma.client.refreshToken.update({
@ -260,7 +268,12 @@ export class AuthService {
data: { revokedAt: new Date() },
});
await this.writePermissionCache(record.tenantCode, user.id);
return this.issue(record.tenantCode, user.id, user.username, user.tokenVersion);
return this.issue(record.tenantCode, user.id, user.username, user.tokenVersion, {
sid: record.sid ?? randomUUID(),
loginAt: record.loginAt,
ip: record.ip,
userAgent: record.userAgent,
});
}
async logout(dto: RefreshDto, ip?: string, userAgent?: string): Promise<null> {
@ -389,20 +402,29 @@ export class AuthService {
return permCacheKey(tenantCode, userId);
}
/** 校验用户当前是否可用(status=enabled 且 tokenVersion 与签发时一致)。 */
/** 校验用户当前是否可用(status=enabled、未锁定且 tokenVersion 与签发时一致)。 */
async checkUserActive(tenantCode: string, userId: string, tokenVersion: number): Promise<boolean> {
const key = userStateCacheKey(tenantCode, userId);
let state = await this.redis.getJson<{ status: string; tokenVersion: number }>(key);
let state = await this.redis.getJson<{ status: string; tokenVersion: number; lockedAt: string | null }>(key);
if (!state) {
const user = await this.prisma.client.user.findFirst({
where: { tenantCode, id: userId, deletedAt: null },
select: { status: true, tokenVersion: true },
select: { status: true, tokenVersion: true, lockedAt: true },
});
if (!user) return false;
state = { status: user.status, tokenVersion: user.tokenVersion };
state = {
status: user.status,
tokenVersion: user.tokenVersion,
lockedAt: user.lockedAt ? user.lockedAt.toISOString() : null,
};
await this.redis.setJson(key, state, ACCESS_TTL);
}
return state.status === 'enabled' && state.tokenVersion === tokenVersion;
return state.status === 'enabled' && state.tokenVersion === tokenVersion && !state.lockedAt;
}
/** 判断会话是否已被强退(sid 黑名单)。 */
async isSessionRevoked(sid: string): Promise<boolean> {
return (await this.redis.getJson(sessionRevokedKey(sid))) != null;
}
/** 聚合权限码与超管标志。 */
@ -482,19 +504,20 @@ export class AuthService {
userId: string,
username: string,
tokenVersion: number,
ip?: string,
userAgent?: string,
session: { sid: string; loginAt: Date; ip?: string | null; userAgent?: string | null },
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number }> {
const accessToken = this.jwt.sign({ sub: userId, tenantCode, username, ver: tokenVersion });
const accessToken = this.jwt.sign({ sub: userId, tenantCode, username, ver: tokenVersion, sid: session.sid });
const jti = randomUUID();
await this.prisma.client.refreshToken.create({
data: {
tenantCode,
userId,
sid: session.sid,
jti,
tokenHash: createHash('sha256').update(jti).digest('hex'),
ip: ip ?? null,
userAgent: userAgent ?? null,
ip: session.ip ?? null,
userAgent: session.userAgent ?? null,
loginAt: session.loginAt,
expireAt: new Date(Date.now() + REFRESH_TTL * 1000),
},
});

View File

@ -32,9 +32,12 @@ export class JwtAuthGuard implements CanActivate {
const token = this.extractToken(request);
if (!token) throw new UnauthorizedException('未登录');
try {
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number }>(token);
const payload = await this.jwt.verifyAsync<{ sub: string; tenantCode: string; username: string; ver?: number; sid?: string }>(token);
const active = await this.auth.checkUserActive(payload.tenantCode, payload.sub, payload.ver ?? 0);
if (!active) throw new UnauthorizedException('账号已停用或登录已失效');
if (payload.sid && (await this.auth.isSessionRevoked(payload.sid))) {
throw new UnauthorizedException('会话已失效,请重新登录');
}
request.user = { userId: payload.sub, tenantCode: payload.tenantCode, username: payload.username } satisfies AuthUser;
return true;
} catch (e) {

View File

@ -20,5 +20,11 @@ export const PLATFORM_PERMISSION_PREFIX = 'tenant:';
/** 权限集缓存 key(Redis,供守卫/授权读取)。 */
export const permCacheKey = (tenantCode: string, userId: string): string => `perm:${tenantCode}:${userId}`;
/** 用户状态缓存 key(Redis,status + tokenVersion,供 JwtAuthGuard 每请求校验)。 */
/** 用户状态缓存 key(Redis,status + tokenVersion + lockedAt,供 JwtAuthGuard 每请求校验)。 */
export const userStateCacheKey = (tenantCode: string, userId: string): string => `user:${tenantCode}:${userId}`;
/** 会话吊销黑名单 key(Redis,sid → 强退标记,供 JwtAuthGuard 每请求校验)。 */
export const sessionRevokedKey = (sid: string): string => `sessionRevoked:${sid}`;
/** 登录失败计数前缀(Redis)。 */
export const LOGIN_FAIL_PREFIX = 'loginFail:';

View File

@ -12,7 +12,6 @@ export type CommonStatus = (typeof CommonStatus)[keyof typeof CommonStatus];
export const UserStatus = {
ENABLED: 'enabled',
DISABLED: 'disabled',
LOCKED: 'locked',
} as const;
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus];

View File

@ -16,6 +16,9 @@ export const Permissions = {
delete: 'user:delete',
resetPwd: 'user:reset-pwd',
assignRole: 'user:assign-role',
kickSessions: 'user:kick-sessions',
lock: 'user:lock',
unlock: 'user:unlock',
},
role: {
list: 'role:list',
@ -48,6 +51,7 @@ export const Permissions = {
update: 'config:update',
delete: 'config:delete',
},
session: { list: 'session:list', kick: 'session:kick' },
audit: { list: 'audit:list' },
loginLog: { list: 'login-log:list' },
} as const;

View File

@ -41,10 +41,15 @@ export class RedisService implements OnModuleDestroy {
/** 按前缀删除(SCAN 而非 KEYS,避免阻塞)。 */
async delByPrefix(prefix: string): Promise<void> {
const fullPrefix = this.prefix + prefix;
await this.delByPattern(`${prefix}*`);
}
/** 按 glob 模式删除(SCAN 而非 KEYS,避免阻塞);pattern 会自动加统一前缀。 */
async delByPattern(pattern: string): Promise<void> {
const fullPattern = this.prefix + pattern;
let cursor = '0';
do {
const [next, keys] = await this.client.scan(cursor, 'MATCH', `${fullPrefix}*`, 'COUNT', 200);
const [next, keys] = await this.client.scan(cursor, 'MATCH', fullPattern, 'COUNT', 200);
cursor = next;
if (keys.length > 0) await this.client.del(...keys);
} while (cursor !== '0');

View File

@ -17,6 +17,7 @@ export const MENUS = [
{ code: 'monitor', name: '系统监控', parent: null, path: '/monitor', component: 'Layout', icon: 'mdi:monitor-dashboard', sort: 4 },
{ code: 'audit', name: '操作日志', parent: 'monitor', path: '/monitor/audit', component: 'monitor/audit/index', icon: 'mdi:clipboard-text', sort: 1 },
{ code: 'login-log', name: '登录日志', parent: 'monitor', path: '/monitor/login-log', component: 'monitor/login-log/index', icon: 'mdi:login', sort: 2 },
{ code: 'session', name: '在线会话', parent: 'monitor', path: '/monitor/session', component: 'monitor/session/index', icon: 'mdi:account-multiple-check', sort: 3 },
];
/** 接口资源(每个受控接口一个,code = 权限码)。 */
@ -54,6 +55,11 @@ export const APIS: { code: string; name: string; method: string; path: string; p
{ code: Permissions.config.delete, name: '删除配置', method: 'DELETE', path: '/api/v1/configs/:id', parent: 'config' },
{ code: Permissions.audit.list, name: '操作日志列表', method: 'GET', path: '/api/v1/audit-logs', parent: 'audit' },
{ code: Permissions.loginLog.list, name: '登录日志列表', method: 'GET', path: '/api/v1/login-logs', parent: 'login-log' },
{ code: Permissions.session.list, name: '会话列表', method: 'GET', path: '/api/v1/sessions', parent: 'session' },
{ code: Permissions.session.kick, name: '强退会话', method: 'POST', path: '/api/v1/sessions/:sid/kick', parent: 'session' },
{ code: Permissions.user.kickSessions, name: '踢全部会话', method: 'POST', path: '/api/v1/users/:id/kick-sessions', parent: 'user' },
{ code: Permissions.user.lock, name: '锁定用户', method: 'POST', path: '/api/v1/users/:id/lock', parent: 'user' },
{ code: Permissions.user.unlock, name: '解锁用户', method: 'POST', path: '/api/v1/users/:id/unlock', parent: 'user' },
];
/** 按钮资源(纯前端按钮,无独立受控接口)。 */
@ -106,33 +112,18 @@ export interface InitTenantOptions {
includePlatformPerms: boolean;
}
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
export async function initTenant(opts: InitTenantOptions): Promise<void> {
const { prisma, tenantCode, tenantName, adminRoleCode, adminRoleName, adminUsername, adminPassword, includePlatformPerms } = opts;
export interface SyncTenantResourcesOptions {
prisma: PrismaClient;
tenantCode: string;
adminRoleCode: string;
includePlatformPerms: boolean;
}
// 1. 根组织
await prisma.org.upsert({
where: { tenantCode_code: { tenantCode, code: tenantCode } },
update: {},
create: { tenantCode, code: tenantCode, name: `${tenantName}总部`, parentCode: null, sort: 0, status: 'enabled' },
});
/** 同步租户资源(菜单/接口/按钮)+ 内置字典 + 顶层角色全量授权(幂等;不触碰用户/密码)。 */
export async function syncTenantResources(opts: SyncTenantResourcesOptions): Promise<void> {
const { prisma, tenantCode, adminRoleCode, includePlatformPerms } = opts;
// 2. 顶层角色
await prisma.role.upsert({
where: { tenantCode_code: { tenantCode, code: adminRoleCode } },
update: {},
create: { tenantCode, code: adminRoleCode, name: adminRoleName, dataScope: 'all', status: 'enabled' },
});
// 3. 管理员账号(update 不重置密码/状态)
const passwordHash = await bcrypt.hash(adminPassword, 10);
const admin = await prisma.user.upsert({
where: { tenantCode_username: { tenantCode, username: adminUsername } },
update: {},
create: { tenantCode, username: adminUsername, passwordHash, nickname: '管理员', status: 'enabled', orgCode: tenantCode },
});
// 4. 资源(菜单 + 接口 + 按钮;业务租户剥离 tenant:* 平台级接口)
// 资源(菜单 + 接口 + 按钮;业务租户剥离 tenant:* 平台级接口)
const apis = includePlatformPerms ? APIS : APIS.filter((a) => !a.code.startsWith('tenant:'));
const allResourceCodes: string[] = [];
for (const m of MENUS) {
@ -160,7 +151,7 @@ export async function initTenant(opts: InitTenantOptions): Promise<void> {
});
}
// 5. 内置字典
// 内置字典
for (const d of DICTS) {
await prisma.dictType.upsert({
where: { tenantCode_code: { tenantCode, code: d.code } },
@ -176,14 +167,7 @@ export async function initTenant(opts: InitTenantOptions): Promise<void> {
}
}
// 6. 绑定管理员到顶层角色
await prisma.userRole.upsert({
where: { tenantCode_userId_roleCode: { tenantCode, userId: admin.id, roleCode: adminRoleCode } },
update: {},
create: { tenantCode, userId: admin.id, roleCode: adminRoleCode },
});
// 7. 顶层角色全量授权
// 顶层角色全量授权
for (const code of allResourceCodes) {
await prisma.roleResource.upsert({
where: { tenantCode_roleCode_resourceCode: { tenantCode, roleCode: adminRoleCode, resourceCode: code } },
@ -192,3 +176,40 @@ export async function initTenant(opts: InitTenantOptions): Promise<void> {
});
}
}
/** 初始化一个租户:根组织、顶层角色、管理员、全量资源、内置字典、授权(幂等)。 */
export async function initTenant(opts: InitTenantOptions): Promise<void> {
const { prisma, tenantCode, tenantName, adminRoleCode, adminRoleName, adminUsername, adminPassword, includePlatformPerms } = opts;
// 1. 根组织
await prisma.org.upsert({
where: { tenantCode_code: { tenantCode, code: tenantCode } },
update: {},
create: { tenantCode, code: tenantCode, name: `${tenantName}总部`, parentCode: null, sort: 0, status: 'enabled' },
});
// 2. 顶层角色
await prisma.role.upsert({
where: { tenantCode_code: { tenantCode, code: adminRoleCode } },
update: {},
create: { tenantCode, code: adminRoleCode, name: adminRoleName, dataScope: 'all', status: 'enabled' },
});
// 3. 管理员账号(update 不重置密码/状态)
const passwordHash = await bcrypt.hash(adminPassword, 10);
const admin = await prisma.user.upsert({
where: { tenantCode_username: { tenantCode, username: adminUsername } },
update: {},
create: { tenantCode, username: adminUsername, passwordHash, nickname: '管理员', status: 'enabled', orgCode: tenantCode },
});
// 4. 绑定管理员到顶层角色
await prisma.userRole.upsert({
where: { tenantCode_userId_roleCode: { tenantCode, userId: admin.id, roleCode: adminRoleCode } },
update: {},
create: { tenantCode, userId: admin.id, roleCode: adminRoleCode },
});
// 5. 资源 + 内置字典 + 全量授权
await syncTenantResources({ prisma, tenantCode, adminRoleCode, includePlatformPerms });
}

15
src/sessions/dto.ts Normal file
View File

@ -0,0 +1,15 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
import { PageQuery } from '../common/page.dto.js';
export class ListSessionQuery extends PageQuery {
@ApiPropertyOptional({ description: '用户名(模糊)' })
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({ description: '设备 IP(模糊)' })
@IsOptional()
@IsString()
ip?: string;
}

View File

@ -0,0 +1,23 @@
import { Controller, Get, Param, Post, Query } from '@nestjs/common';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { ListSessionQuery } from './dto.js';
import { SessionsService } from './sessions.service.js';
@Controller('sessions')
export class SessionsController {
constructor(private readonly service: SessionsService) {}
@Get()
@RequirePermissions(Permissions.session.list)
list(@Query() q: ListSessionQuery) {
return this.service.listSessions(q);
}
@Post(':sid/kick')
@RequirePermissions(Permissions.session.kick)
@AuditLog('session', 'kick')
kick(@Param('sid') sid: string) {
return this.service.kickSession(sid);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SessionsController } from './sessions.controller.js';
import { SessionsService } from './sessions.service.js';
@Module({
controllers: [SessionsController],
providers: [SessionsService],
exports: [SessionsService],
})
export class SessionsModule {}

View File

@ -0,0 +1,101 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service.js';
import { RedisService } from '../common/redis/redis.service.js';
import { getRequestContext } from '../common/context/request-context.js';
import { sessionRevokedKey } from '../common/constants.js';
import { ListSessionQuery } from './dto.js';
/** 与 auth.service 的 access token 有效时长对齐(黑名单 TTL)。 */
const ACCESS_TTL = Number(process.env.JWT_ACCESS_TTL ?? 7200);
interface SessionRow {
sid: string | null;
userId: string;
ip: string | null;
userAgent: string | null;
loginAt: Date;
expireAt: Date;
}
@Injectable()
export class SessionsService {
constructor(
private readonly prisma: PrismaService,
private readonly redis: RedisService,
) {}
/** 活跃会话分页:数据权限过滤可见用户 + 用户名/IP 筛选。 */
async listSessions(q: ListSessionQuery) {
const ctx = getRequestContext()!;
// 1. 数据权限 → 允许的 userId 集合(null=不限)
let userIds: string[] | null = null;
if (ctx.dataScope === 'self') {
userIds = [ctx.userId];
} else if (ctx.orgCodes.length > 0) {
const users = await this.prisma.client.user.findMany({
where: { orgCode: { in: ctx.orgCodes } },
select: { id: true },
});
userIds = users.map((u) => u.id);
if (userIds.length === 0) return { list: [], total: 0, page: q.page, pageSize: q.pageSize };
}
// 2. 用户名筛选 → 进一步收窄 userId 集合
if (q.username) {
const where: Record<string, unknown> = { username: { contains: q.username } };
if (userIds) where.id = { in: userIds };
const users = await this.prisma.client.user.findMany({ where, select: { id: true } });
userIds = users.map((u) => u.id);
if (userIds.length === 0) return { list: [], total: 0, page: q.page, pageSize: q.pageSize };
}
// 3. refresh token 查询(tenantCode 由 Prisma 扩展自动注入)
const where: Record<string, unknown> = { revokedAt: null, expireAt: { gt: new Date() } };
if (userIds) where.userId = { in: userIds };
if (q.ip) where.ip = { contains: q.ip };
const [rows, total] = await Promise.all([
this.prisma.client.refreshToken.findMany({
where,
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
orderBy: { loginAt: 'desc' },
select: { sid: true, userId: true, ip: true, userAgent: true, loginAt: true, expireAt: true },
}),
this.prisma.client.refreshToken.count({ where }),
]);
// 4. 补用户名/昵称
const ids = [...new Set((rows as SessionRow[]).map((r) => r.userId))];
const users = ids.length
? await this.prisma.client.user.findMany({ where: { id: { in: ids } }, select: { id: true, username: true, nickname: true } })
: [];
const userMap = new Map(users.map((u) => [u.id, u]));
const list = (rows as SessionRow[]).map((r) => {
const u = userMap.get(r.userId);
return {
sid: r.sid,
userId: r.userId,
username: u?.username ?? '',
nickname: u?.nickname ?? null,
ip: r.ip,
userAgent: r.userAgent,
loginAt: r.loginAt,
expireAt: r.expireAt,
};
});
return { list, total, page: q.page, pageSize: q.pageSize };
}
/** 单会话强退:吊销该 sid 的 refresh token + 写黑名单使在飞 access token 失效。 */
async kickSession(sid: string): Promise<null> {
const result = await this.prisma.client.refreshToken.updateMany({
where: { sid, revokedAt: null },
data: { revokedAt: new Date() },
});
if (result.count === 0) throw new NotFoundException('会话不存在或已下线');
await this.redis.setJson(sessionRevokedKey(sid), 1, ACCESS_TTL);
return null;
}
}

View File

@ -88,6 +88,13 @@ export class AssignRolesDto {
roleCodes!: string[];
}
export class LockUserDto {
@ApiPropertyOptional({ description: '锁定原因' })
@IsOptional()
@IsString()
reason?: string;
}
export class ListUserQuery extends PageQuery {
@ApiPropertyOptional({ description: '用户名(模糊)' })
@IsOptional()

View File

@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
import { AssignRolesDto, CreateUserDto, ListUserQuery, LockUserDto, ResetPasswordDto, UpdateUserDto } from './dto.js';
import { BatchDeleteDto } from '../common/page.dto.js';
import { UsersService } from './users.service.js';
@ -68,4 +68,25 @@ export class UsersController {
batchDelete(@Body() dto: BatchDeleteDto) {
return this.service.batchDelete(dto.ids);
}
@Post(':id/lock')
@RequirePermissions(Permissions.user.lock)
@AuditLog('user', 'lock')
lock(@Param('id') id: string, @Body() dto: LockUserDto) {
return this.service.lock(id, dto.reason);
}
@Post(':id/unlock')
@RequirePermissions(Permissions.user.unlock)
@AuditLog('user', 'unlock')
unlock(@Param('id') id: string) {
return this.service.unlock(id);
}
@Post(':id/kick-sessions')
@RequirePermissions(Permissions.user.kickSessions)
@AuditLog('user', 'kick-sessions')
kickSessions(@Param('id') id: string) {
return this.service.kickSessions(id);
}
}

View File

@ -4,7 +4,7 @@ import { BaseCrudService } from '../common/base-crud.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { RedisService } from '../common/redis/redis.service.js';
import { getRequestContext } from '../common/context/request-context.js';
import { permCacheKey, userStateCacheKey } from '../common/constants.js';
import { LOGIN_FAIL_PREFIX, permCacheKey, userStateCacheKey } from '../common/constants.js';
import { AssignRolesDto, CreateUserDto, ListUserQuery, ResetPasswordDto, UpdateUserDto } from './dto.js';
@Injectable()
@ -94,6 +94,31 @@ export class UsersService extends BaseCrudService {
return { total: ids.length, success: ids.length - failed.length, failed };
}
/** 锁定用户:记录锁定时间/原因并踢出全部会话。 */
async lock(userId: string, reason?: string): Promise<Record<string, unknown>> {
await this.db().update({ where: { id: userId }, data: { lockedAt: new Date(), lockReason: reason ?? null } });
await this.invalidateSessions(userId);
return this.omitPasswordHash(await this.db().findUnique({ where: { id: userId } }));
}
/** 解锁用户:清空锁定并清除登录失败计数。 */
async unlock(userId: string): Promise<Record<string, unknown>> {
const ctx = getRequestContext()!;
const user = await this.db().findFirst({ where: { id: userId }, select: { username: true } });
await this.db().update({ where: { id: userId }, data: { lockedAt: null, lockReason: null } });
if (user?.username) {
await this.redis.delByPattern(`${LOGIN_FAIL_PREFIX}*:${user.username}`);
}
await this.redis.del(userStateCacheKey(ctx.tenantCode, userId));
return this.omitPasswordHash(await this.db().findUnique({ where: { id: userId } }));
}
/** 踢出用户全部会话。 */
async kickSessions(userId: string): Promise<null> {
await this.invalidateSessions(userId);
return null;
}
/** 吊销该用户所有会话:tokenVersion 自增 + 删 refresh token + 清缓存。 */
private async invalidateSessions(userId: string): Promise<void> {
const ctx = getRequestContext();