feat(admin): R176-R180 公示表与新闻撤回 + 管理端多管理员权限(C 层)+ 交卷冻结
- R176 成绩公示表引擎(lib/publicitySheet.js + lib/assets/publicity-template.xlsx 官方模板基底) 与指导教师回填(lib/teacherBackfill.js);赛项名称/编号改为配置项 - R177 新闻「发布 ↔ 撤回」互斥:撤回只置 is_draft=1,不删除数据 - R178 C 层 RBAC:lib/adminPermissions.js(13 权限点 / 70 条路由规则,唯一 SoT) + middleware/auth.js 三段判定(默认拒绝 · is_super 单点 · token_version) + controller/service/adminUser.js + lib/auditLog.js + model/adminAuditLog.js - R178 B1 交卷冻结:service.exam.assertNotSubmitted(bizCode 4006 单一判据源) - R178 B④ 提交口径拆分:formalSubmitted / submitted / answeredCount 三字段 - R178 成绩总览补小组编号:resolveTeamLabels(与选手管理同源,含兜底与三态) - R181 生产迁移配套:admins +7 列、admin_audit_logs(scripts/migrate_r178_admin_rbac.py)
This commit is contained in:
270
app/service/adminUser.js
Normal file
270
app/service/adminUser.js
Normal file
@@ -0,0 +1,270 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 管理员账号管理(R178 · C 层)
|
||||
* ============================================================================
|
||||
*
|
||||
* 职责:多管理员账号的增 / 改权限 / 重置口令 / 禁用启用 / 删除,以及高危操作审计。
|
||||
*
|
||||
* 三条不可协商的边界:
|
||||
*
|
||||
* 1. **最高管理员不可变动**(用户裁定):`is_super=1` 的账号不允许改权限、重置口令、
|
||||
* 禁用、删除。判断集中在 `lib/adminPermissions.isManageable()`,⛔ 不得在各方法里
|
||||
* 各写一遍 `id === 1` —— 那样改一处漏一处,且将来账号 id 变化就失效。
|
||||
*
|
||||
* 2. **口令策略单源**:一律走 `lib/passwordPolicy.checkPassword()`,
|
||||
* ⛔ 不在这里写第二套长度/黑名单规则(第 25 步门禁会比对两套规则是否漂移)。
|
||||
* 生产环境另外拒绝「已知默认口令」,与 adminLogin 的守卫同源。
|
||||
*
|
||||
* 3. **令牌失效靠自增**:改密 / 禁用时 `token_version += 1`。
|
||||
* middleware/auth.js 每请求比对,旧令牌立即 401。本项目没有会话表,
|
||||
* 这是唯一的「强制下线」手段。
|
||||
*/
|
||||
|
||||
const Service = require('egg').Service;
|
||||
const passwordPolicy = require('../lib/passwordPolicy');
|
||||
const adminPermissions = require('../lib/adminPermissions');
|
||||
const { recordAudit, ACTIONS } = require('../lib/auditLog');
|
||||
|
||||
/** 用户名规则:字母数字与 _ . - ,3~50 位。⛔ 不允许中文与空格,避免与登录校验规则不一致。 */
|
||||
const USERNAME_RE = /^[A-Za-z0-9_.-]{3,50}$/;
|
||||
const REMARK_MAX = 200;
|
||||
|
||||
/** 业务错误:由 controller 翻译成 { code, message },不直接改 HTTP 状态。 */
|
||||
function bizError(code, message) {
|
||||
const err = new Error(message);
|
||||
err.bizCode = code;
|
||||
err.bizMessage = message;
|
||||
return err;
|
||||
}
|
||||
|
||||
class AdminUserService extends Service {
|
||||
/**
|
||||
* 管理员列表(脱敏:绝不返回 password)
|
||||
* 排序:最高管理员置顶,其余按 id 升序 —— 与「主账号永远第一行」的视觉预期一致。
|
||||
*/
|
||||
async list() {
|
||||
const { ctx } = this;
|
||||
const rows = await ctx.model.Admin.findAll({
|
||||
attributes: [
|
||||
'id', 'username', 'is_super', 'permissions', 'status',
|
||||
'last_login_at', 'last_login_ip', 'remark', 'created_at', 'updated_at',
|
||||
],
|
||||
order: [['is_super', 'DESC'], ['id', 'ASC']],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
isSuper: Number(r.is_super) === 1,
|
||||
permissions: adminPermissions.parsePermissions(r.permissions),
|
||||
status: Number(r.status) === 1 ? 1 : 0,
|
||||
lastLoginAt: r.last_login_at || null,
|
||||
lastLoginIp: r.last_login_ip || null,
|
||||
remark: r.remark || '',
|
||||
createdAt: r.created_at || null,
|
||||
// 前端据此把「改权限/重置口令/禁用/删除」四个按钮整体置灰
|
||||
manageable: adminPermissions.isManageable(r),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 取一个**可被管理**的目标账号。
|
||||
* @throws bizCode 400 不存在 / 403 最高管理员不可变动
|
||||
*/
|
||||
async _loadManageable(id) {
|
||||
const { ctx } = this;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
throw bizError(400, '管理员 ID 不合法');
|
||||
}
|
||||
const admin = await ctx.model.Admin.findOne({
|
||||
where: { id: numericId },
|
||||
attributes: ['id', 'username', 'is_super', 'permissions', 'status', 'token_version'],
|
||||
raw: true,
|
||||
});
|
||||
if (!admin) throw bizError(400, '该管理员账号不存在');
|
||||
if (!adminPermissions.isManageable(admin)) {
|
||||
throw bizError(403, '最高管理员账号不可修改(权限 / 口令 / 状态 / 删除均被禁止)');
|
||||
}
|
||||
return admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增管理员
|
||||
*
|
||||
* @param {{username:string, password:string, permissions:string[], remark?:string}} data
|
||||
*/
|
||||
async create(data) {
|
||||
const { ctx } = this;
|
||||
const username = String((data && data.username) || '').trim();
|
||||
const password = String((data && data.password) || '');
|
||||
|
||||
if (!USERNAME_RE.test(username)) {
|
||||
throw bizError(400, '用户名需为 3-50 位字母、数字、下划线、点或短横线');
|
||||
}
|
||||
|
||||
const pwdCheck = passwordPolicy.checkPassword(password, { username });
|
||||
if (!pwdCheck.ok) throw bizError(400, pwdCheck.reason);
|
||||
|
||||
// 与 adminLogin 的守卫同源:生产环境不允许把「已知默认口令」分配给新账号
|
||||
if (this.app.config.isProduction && passwordPolicy.DEFAULT_ADMIN_PASSWORDS.includes(password)) {
|
||||
throw bizError(400, '生产环境禁止使用默认口令,请设置强密码');
|
||||
}
|
||||
|
||||
const exists = await ctx.model.Admin.findOne({ where: { username }, attributes: ['id'] });
|
||||
if (exists) throw bizError(400, '该用户名已存在');
|
||||
|
||||
const perms = adminPermissions.sanitizePermissions(data && data.permissions);
|
||||
const remark = data && data.remark ? String(data.remark).slice(0, REMARK_MAX) : null;
|
||||
|
||||
let created;
|
||||
try {
|
||||
created = await ctx.model.Admin.create({
|
||||
username,
|
||||
password: await ctx.helper.hashPassword(password),
|
||||
is_super: 0, // 新增账号永远不是最高管理员(最高管理员只能由初始化脚本产生)
|
||||
permissions: JSON.stringify(perms),
|
||||
status: 1,
|
||||
token_version: 0,
|
||||
remark,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
} catch (e) {
|
||||
// 并发下 findOne 与 create 之间仍可能撞唯一索引
|
||||
if (/ER_DUP_ENTRY/.test(String(e && e.message))) {
|
||||
throw bizError(400, '该用户名已存在');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
await recordAudit(ctx, ACTIONS.ADMIN_CREATE, `admin:${username}`, {
|
||||
id: created.id,
|
||||
permissions: perms,
|
||||
});
|
||||
|
||||
return { id: created.id, username, permissions: perms, remark: remark || '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑权限(含备注)
|
||||
*
|
||||
* ⚠️ 不递增 token_version:权限是每请求查库判定,改完立即生效;
|
||||
* 递增反而会把正在操作的人无谓踢下线。
|
||||
*/
|
||||
async updatePermissions(id, data) {
|
||||
const { ctx } = this;
|
||||
const target = await this._loadManageable(id);
|
||||
|
||||
const perms = adminPermissions.sanitizePermissions(data && data.permissions);
|
||||
const patch = {
|
||||
permissions: JSON.stringify(perms),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (data && data.remark !== undefined) {
|
||||
patch.remark = data.remark ? String(data.remark).slice(0, REMARK_MAX) : null;
|
||||
}
|
||||
|
||||
await ctx.model.Admin.update(patch, { where: { id: target.id } });
|
||||
|
||||
const before = adminPermissions.parsePermissions(target.permissions);
|
||||
await recordAudit(ctx, ACTIONS.ADMIN_PERMISSIONS, `admin:${target.username}`, {
|
||||
before,
|
||||
after: perms,
|
||||
added: perms.filter(p => !before.includes(p)),
|
||||
removed: before.filter(p => !perms.includes(p)),
|
||||
});
|
||||
|
||||
return { id: target.id, permissions: perms };
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置口令(管理员代改,不要求旧口令)
|
||||
* 递增 token_version ⇒ 目标账号所有在线会话立即失效。
|
||||
*/
|
||||
async resetPassword(id, data) {
|
||||
const { ctx } = this;
|
||||
const target = await this._loadManageable(id);
|
||||
|
||||
const password = String((data && data.password) || '');
|
||||
const pwdCheck = passwordPolicy.checkPassword(password, { username: target.username });
|
||||
if (!pwdCheck.ok) throw bizError(400, pwdCheck.reason);
|
||||
|
||||
if (this.app.config.isProduction && passwordPolicy.DEFAULT_ADMIN_PASSWORDS.includes(password)) {
|
||||
throw bizError(400, '生产环境禁止使用默认口令,请设置强密码');
|
||||
}
|
||||
|
||||
const nextVersion = Number(target.token_version || 0) + 1;
|
||||
await ctx.model.Admin.update({
|
||||
password: await ctx.helper.hashPassword(password),
|
||||
token_version: nextVersion,
|
||||
updated_at: new Date(),
|
||||
}, { where: { id: target.id } });
|
||||
|
||||
// ⛔ 审计 detail 不含口令本身,只记事实
|
||||
await recordAudit(ctx, ACTIONS.ADMIN_PASSWORD, `admin:${target.username}`, {
|
||||
tokenVersion: nextVersion,
|
||||
});
|
||||
|
||||
return { id: target.id, tokenVersion: nextVersion };
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用 / 启用
|
||||
* 禁用时递增 token_version ⇒ 该账号已签发的令牌下次请求即 401。
|
||||
* 启用不递增:重新启用后旧令牌早已因禁用期间的版本差异失效,无需再踢。
|
||||
*/
|
||||
async setStatus(id, data) {
|
||||
const { ctx } = this;
|
||||
const target = await this._loadManageable(id);
|
||||
|
||||
const status = Number(data && data.status);
|
||||
if (status !== 0 && status !== 1) throw bizError(400, '状态值只能是 0(禁用)或 1(启用)');
|
||||
|
||||
// 不允许把自己禁用 —— 否则可能一次性把所有可用账号锁死
|
||||
const me = ctx.state.currentAdmin;
|
||||
if (me && Number(me.id) === Number(target.id) && status === 0) {
|
||||
throw bizError(400, '不能禁用自己的账号');
|
||||
}
|
||||
|
||||
const patch = { status, updated_at: new Date() };
|
||||
if (status === 0) patch.token_version = Number(target.token_version || 0) + 1;
|
||||
|
||||
await ctx.model.Admin.update(patch, { where: { id: target.id } });
|
||||
|
||||
await recordAudit(ctx, ACTIONS.ADMIN_STATUS, `admin:${target.username}`, {
|
||||
status,
|
||||
tokenVersion: patch.token_version || target.token_version,
|
||||
});
|
||||
|
||||
return { id: target.id, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账号
|
||||
* 不可删最高管理员(_loadManageable 已挡)、不可删自己。
|
||||
*/
|
||||
async remove(id) {
|
||||
const { ctx } = this;
|
||||
const target = await this._loadManageable(id);
|
||||
|
||||
const me = ctx.state.currentAdmin;
|
||||
if (me && Number(me.id) === Number(target.id)) {
|
||||
throw bizError(400, '不能删除自己的账号');
|
||||
}
|
||||
|
||||
const affected = await ctx.model.Admin.destroy({ where: { id: target.id } });
|
||||
if (affected !== 1) throw bizError(500, '删除失败:目标账号已不存在');
|
||||
|
||||
await recordAudit(ctx, ACTIONS.ADMIN_DELETE, `admin:${target.username}`, {
|
||||
id: target.id,
|
||||
lastPermissions: adminPermissions.parsePermissions(target.permissions),
|
||||
});
|
||||
|
||||
return { id: target.id, deleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AdminUserService;
|
||||
Reference in New Issue
Block a user