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:
2026-10-20 11:06:29 +08:00
parent 5392345078
commit 459f1adf6d
21 changed files with 2886 additions and 76 deletions

View File

@@ -7,7 +7,7 @@
*/
module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize;
const { STRING, INTEGER, DATE, TEXT, TINYINT } = app.Sequelize;
const Admin = app.model.define('admin', {
// 主键 ID,自增
@@ -30,6 +30,51 @@ module.exports = app => {
allowNull: false,
comment: '管理员密码',
},
// 🔴 R178 最高管理员标记:唯一、不可改权限 / 不可禁用 / 不可删除 / 不可重置口令。
// 判据只认本字段(不认 username,避免改名绕过;也不认 id,避免误伤后续自增行)。
is_super: {
type: TINYINT,
allowNull: false,
defaultValue: 0,
comment: '最高管理员:1=不可变动(唯一)',
},
// 权限点 JSON 数组(见 lib/adminPermissions.js 的 PERMISSIONS)。
// NULL / 空数组 = 无任何业务权限;is_super=1 的账号忽略本列(恒全权)。
permissions: {
type: TEXT,
allowNull: true,
comment: '权限点 JSON 数组',
},
// 1=启用 0=禁用。禁用后:禁止登录,且已签发的令牌在下一次请求即被拒。
status: {
type: TINYINT,
allowNull: false,
defaultValue: 1,
comment: '1=启用 0=禁用',
},
// 令牌版本:改密 / 禁用 / 改权限时自增 ⇒ 旧 JWT 中的 tv 与之不匹配即失效。
// 这是「改密后旧会话立即下线」的唯一判据(本项目无会话表)。
token_version: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '令牌版本(改密/禁用时自增,使旧 JWT 失效)',
},
last_login_at: {
type: DATE,
allowNull: true,
comment: '最近登录时间',
},
last_login_ip: {
type: STRING(64),
allowNull: true,
comment: '最近登录 IP',
},
remark: {
type: STRING(200),
allowNull: true,
comment: '备注(姓名 / 岗位)',
},
// 记录创建时间
created_at: {
type: DATE,

View File

@@ -0,0 +1,72 @@
'use strict';
/**
* 管理员高危操作审计日志 - AdminAuditLog
*
* 只记录**高危写操作**(不是全量写操作):
* 发布/撤回成绩、重置测试数据、删除选手、撤回交卷、
* 管理员账号的增 / 改权限 / 重置口令 / 禁用 / 删除。
*
* 为什么不做全量审计:本项目日志无集中采集,全量写接口约 30 个,
* 日志量增长快且缺少清理策略;而「多管理员下谁改了成绩 / 谁动了账号」
* 才是真正需要回答的问题。故按「能改变比赛结果或权限边界的动作」划定范围。
*
* ⚠️ 写入必须 best-effort:审计失败不得阻断业务(否则日志表故障会连累发布成绩)。
* 统一走 service.admin.recordAudit(),⛔ 不要在控制器里直接 create。
*/
module.exports = app => {
const { STRING, INTEGER, DATE, TEXT, BIGINT } = app.Sequelize;
const AdminAuditLog = app.model.define('adminAuditLog', {
id: {
type: BIGINT,
primaryKey: true,
autoIncrement: true,
comment: '日志唯一标识',
},
admin_id: {
type: INTEGER,
allowNull: false,
comment: '操作人管理员 ID',
},
// 冗余存用户名:账号被删除后仍能回答「当时是谁操作的」
admin_username: {
type: STRING(50),
allowNull: false,
comment: '操作人用户名(冗余,防账号删除后不可追溯)',
},
action: {
type: STRING(64),
allowNull: false,
comment: '动作标识,如 score.publish / admin.create',
},
target: {
type: STRING(200),
allowNull: true,
comment: '操作对象(组别 / 账号名 / 选手 ID)',
},
detail: {
type: TEXT,
allowNull: true,
comment: '补充信息(JSON 文本)',
},
ip: {
type: STRING(64),
allowNull: true,
comment: '来源 IP',
},
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
comment: '发生时间',
},
}, {
tableName: 'admin_audit_logs',
timestamps: false,
underscored: true,
});
return AdminAuditLog;
};

View File

@@ -133,9 +133,13 @@ module.exports = app => {
// 关联定义
Aggregation.associate = function() {
// ⚠️ R176:CompetitionInfo 的运行库结构是 key-value(主键 id),
// 不是原来的三列结构 ⇒ targetKey 必须是 `id`,否则 Sequelize 解析
// `competition_uid` 这个属性时会找不到定义。
// `aggregation.competition_uid` 存的是数字(实测恒为 1),对应 competition_info.id。
app.model.Aggregation.belongsTo(app.model.CompetitionInfo, {
foreignKey: 'competition_uid',
targetKey: 'competition_uid',
targetKey: 'id',
as: 'competition',
});
app.model.Aggregation.belongsTo(app.model.Team, {

View File

@@ -3,56 +3,66 @@
/**
* 竞赛信息模型 - CompetitionInfo
*
* 存储竞赛的基本信息,包括竞赛名称和代码。
* 表名:competition_info
*
* 🔴 2026-10-19(R176)修正:原模型声明的是三列结构
* (`competition_uid` / `competition_name` / `competition_code`),
* 但**运行库实际是 key-value 结构**:
* `id` / `info_key` / `info_value` / `created_at` / `updated_at`(实测 SHOW CREATE TABLE)
* 模型与表不一致的后果:
* ① `findOne()` 默认按模型声明选列 ⇒ `Unknown column 'competition_uid'` 直接 500;
* ② `create()` 会带上 `competition_name`(allowNull:false)⇒ 必然失败。
* 旧代码之所以没暴露,是因为唯一的使用点(导入时取 competition_uid)
* 包在 try/catch 里,失败后静默回落到默认值 1 —— 而 `info_key='competition_uid'`
* 的值恰好也是 1,所以"看起来一直是对的"。
*
* ⚠️ 因此本表**不能按列访问**,必须走 `info_key` 取值。
* 公示表要用的两个键是 `competition_item_name` / `competition_item_code`。
*/
module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize;
const { STRING, INTEGER, TEXT, DATE } = app.Sequelize;
const CompetitionInfo = app.model.define('competition_info', {
// 主键,自增
competition_uid: {
// 主键,自增(运行库真实主键是 id,不是 competition_uid)
id: {
type: INTEGER,
primaryKey: true,
autoIncrement: true,
comment: '竞赛唯一标识',
comment: '自增主键',
},
// 竞赛名称
competition_name: {
type: STRING(200),
allowNull: false,
comment: '竞赛名称',
},
// 竞赛代码(唯一标识码)
competition_code: {
// 配置键名(如 competition_name / competition_code / competition_item_name)
// ⚠️ 运行库**没有**给 info_key 建唯一索引 ⇒ 写侧必须"先查再写",不能依赖 upsert
info_key: {
type: STRING(50),
allowNull: false,
unique: true,
comment: '竞赛代码',
comment: '配置键',
},
// 配置值(统一按字符串存,数字也以文本形式落库)
info_value: {
type: TEXT,
allowNull: true,
comment: '配置值',
},
// 记录创建时间
created_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
allowNull: true,
comment: '创建时间',
},
// 记录更新时间
updated_at: {
type: DATE,
allowNull: false,
defaultValue: app.Sequelize.literal('CURRENT_TIMESTAMP'),
allowNull: true,
comment: '更新时间',
},
}, {
tableName: 'competition_info',
timestamps: false,
underscored: true,
// 索引仅作 Sequelize 元数据声明(sync 时用);⛔ 本项目的表由 SQL 手工维护,
// 不要指望这里加 unique 会同步到运行库
indexes: [
{
unique: true,
fields: ['competition_code'],
fields: ['info_key'],
},
],
});

View File

@@ -74,6 +74,16 @@ module.exports = app => {
word_score: decimalScore('word_score', 'Word题/综合大题得分(可含 2 位小数)'),
// PPT分数(本科/教师组管理员手动打分)
ppt_score: decimalScore('ppt_score', 'PPT得分(本科/教师组,可含 2 位小数)'),
// 指导教师姓名(R176)。来源 = 报名表里 identity 为「指导教师」的行,
// 按 (group, 队伍编号) 归到同组选手身上;同组多人用「、」连接后冗余存一份。
// ⚠️ 语义上是**组级属性**,冗余到每位组员是为了让成绩公示表能直接按行取用,
// 不必每次再回查 aggregation。回填入口见 service/admin.js#backfillTeachers。
teacher_name: {
type: STRING(200),
allowNull: true,
defaultValue: null,
comment: '指导教师姓名(同组多人用、分隔)',
},
// 所属团队 ID(外键关联 teams 表)
team_id: {
type: INTEGER,

View File

@@ -79,6 +79,30 @@ module.exports = app => {
defaultValue: null,
comment: '标签,多个标签用逗号分隔',
},
// 草稿标记(R176)。成绩发布时会自动生成一条「成绩公示」预览新闻,
// 需人工编辑确认后才可对外发布 ⇒ 草稿期间必须前台不可见。
// 🔴 news.list / findById 的公开查询必须显式过滤 is_draft=0(默认值 0 保证历史 15 条不受影响)。
is_draft: {
type: INTEGER,
allowNull: false,
defaultValue: 0,
comment: '草稿标记:0=已发布, 1=草稿(前台不可见)',
},
// 附件(R176)。用于成绩公示表的文件公示:管理员上传 xlsx/pdf 后,
// 详情页渲染成独立下载按钮,不依赖正文富文本里手写链接
// (手写链接在人工编辑正文时极易被误删)。
attachment_url: {
type: STRING(500),
allowNull: true,
defaultValue: null,
comment: '附件下载地址(相对路径 /public/news/xxx)',
},
attachment_name: {
type: STRING(200),
allowNull: true,
defaultValue: null,
comment: '附件显示文件名(下载按钮文案)',
},
// 记录创建时间
created_at: {
type: DATE,