- assignOrgs 校验 orgCodes 存在性(对齐 assignResources) - lock/kick 保护保留角色账号(super_admin/tenant_super_admin) - BatchDeleteDto 加 @ArrayMaxSize(100) - 待改密门禁显式放行 /auth/logout - 分配部门改用独立权限码 role:assign-org Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
117 lines
4.6 KiB
TypeScript
117 lines
4.6 KiB
TypeScript
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<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 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;
|
||
}
|
||
}
|