import { ForbiddenException, HttpStatus, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service.js'; import { RedisService } from '../common/redis/redis.service.js'; import { getRequestContext } from '../common/context/request-context.js'; import { RESERVED_ROLE_CODES, sessionRevokedKey } from '../common/constants.js'; import { BusinessException } from '../common/business-exception.js'; import { ErrorCodes } from '../common/error-codes.js'; import { ListSessionQuery } from './dto.js'; /** 与 auth.service 的 access token 有效时长对齐(黑名单 TTL)。 */ 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 = { 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 = { 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 { const ctx = getRequestContext()!; const session = await this.prisma.client.refreshToken.findFirst({ where: { sid, revokedAt: null }, select: { userId: true }, }); if (!session) throw new BusinessException(ErrorCodes.SESSION_NOT_FOUND, '会话不存在或已下线', HttpStatus.NOT_FOUND); const roles = await this.prisma.client.userRole.findMany({ where: { tenantCode: ctx.tenantCode, userId: session.userId }, select: { roleCode: true }, }); if (roles.some((r) => RESERVED_ROLE_CODES.includes(r.roleCode))) { throw new ForbiddenException('保留角色账号的会话不可强退'); } const result = await this.prisma.client.refreshToken.updateMany({ where: { sid, revokedAt: null }, data: { revokedAt: new Date() }, }); if (result.count === 0) throw new BusinessException(ErrorCodes.SESSION_NOT_FOUND, '会话不存在或已下线', HttpStatus.NOT_FOUND); await this.redis.setJson(sessionRevokedKey(sid), 1, ACCESS_TTL); return null; } }