'use strict'; const Service = require('egg').Service; /** * 认证服务 - AuthService * * 封装认证相关的数据库操作和业务逻辑。 * 使用 Contestant 模型进行选手认证,Admin 模型进行管理员认证。 * 密码采用 bcrypt 加盐哈希存储;存量 SHA256(无盐)密码在下次成功登录时透明升级为 bcrypt。 */ class AuthService extends Service { /** * 根据手机号查找选手 * @param {string} phone - 手机号 * @returns {object|null} 选手信息 */ async findByPhone(phone) { const { ctx } = this; const contestant = await ctx.model.Contestant.findOne({ where: { phone }, attributes: ['id', 'id_number', 'name', 'phone', 'group', 'password', 'activated', 'score'], }); return contestant; } /** * 根据身份证号查找选手(用于激活和忘记密码) * @param {string} idNumber - 身份证号 * @returns {object|null} 选手信息 */ async findByAccount(idNumber) { const { ctx } = this; return await ctx.model.Contestant.findOne({ where: { id_number: idNumber }, }); } /** * 构造「可下发给考生端」的用户信息(**唯一出口**)。 * * 2026-09-21 修复(铁律15 成绩泄露全通道封堵 —— 登录通道缺口): * 此前 `POST /api/auth/login` 与 `GET /api/auth/current-user` 直接回传 * `contestant.toJSON()`,其中 `score` 在成绩**未发布**状态下依然被下发 * (实测未发布态 `score=27`),而同期 `GET /api/score` 正确返回 `null`。 * 即门控只覆盖了 4 条已知通道,遗漏了 auth 这两条同源通道。 * * 同源族(**三处**必须共用本出口,禁止各自直接 toJSON): * · controller/auth.js login() * · controller/auth.js currentUser() * · controller/auth.js refresh() (`POST /api/auth/refresh`; 前端 doSilentRefresh 会把它写入 localStorage 与 Pinia) * * 口径(与 GET /api/score 一致,复用 service.admin.isScorePublished 单一判定): * · 该组成绩**已发布** → 保留 score; * · 该组成绩**未发布 / 查询异常** → 剔除 score(安全默认:宁可不显示,不可泄露); * · 分项字段(exam_score / word_score / ppt_score)**任何情况下都不下发** * —— 它们只是总分的组成部分,前端从不使用,留在响应里等于给出第二条推算通道。 * * @param {Model|Object} contestant - Contestant 模型实例或其普通对象 * @returns {Promise} 已脱敏的用户信息 */ async toClientUser(contestant) { const { service } = this; if (!contestant) return null; const userData = typeof contestant.toJSON === 'function' ? contestant.toJSON() : Object.assign({}, contestant); delete userData.password; // 成绩发布门控:查询失败按「未发布」处理,避免异常路径把分数漏出去 let published = false; try { published = await service.admin.isScorePublished(userData.group); } catch (e) { published = false; } if (!published) { delete userData.score; } // 分项成绩一律不下发(前端不使用;保留会造成「总分虽隐藏、分项可加总」的旁路) delete userData.exam_score; delete userData.word_score; delete userData.ppt_score; return userData; } /** * 根据用户名查找管理员 * * @param {string} username - 管理员用户名 * @returns {Promise} Admin 模型对象或 null */ async findAdmin(username) { const { ctx } = this; return await ctx.model.Admin.findOne({ where: { username }, }); } /** * 验证密码 * * 兼容 bcrypt 哈希与存量 SHA256(无盐)哈希,便于平滑迁移。 * * @param {string} inputPassword - 用户输入的明文密码 * @param {string} hashedPassword - 数据库中存储的哈希密码 * @returns {Promise} 密码是否匹配 */ async verifyPassword(inputPassword, hashedPassword) { const { ctx } = this; return ctx.helper.comparePassword(inputPassword, hashedPassword); } /** * 透明升级:若账号仍使用旧版 SHA256 哈希,在验证通过后重写为 bcrypt。 * 存量账号会在下一次成功登录时自动完成迁移,无需批量刷库。 * * @param {Model} account - 含 password 字段的模型实例(contestant 或 admin) * @param {string} plainPassword - 明文密码 * @returns {Promise} */ async upgradePasswordIfLegacy(account, plainPassword) { const { ctx } = this; if (account && account.password && !ctx.helper.isBcryptHash(account.password)) { try { const newHash = await ctx.helper.hashPassword(plainPassword); await account.update({ password: newHash }); } catch (e) { // 升级失败不影响本次登录,仅记录日志,下次登录再尝试 ctx.logger.warn('[密码迁移] bcrypt 升级失败:', e.message); } } } /** * 激活账号 * * 将指定 id 的选手设置为已激活状态,并更新密码。 * * @param {number} id - 选手 id * @param {string} password - bcrypt 加盐哈希后的密码(由 helper.hashPassword 生成, * BCRYPT_ROUNDS=10;调用方 controller/auth.js 的 activate() 已保证此口径) * @returns {Promise} 更新后的 Contestant 模型对象 */ async activateAccount(id, password) { const { ctx } = this; const contestant = await ctx.model.Contestant.findOne({ where: { id }, }); if (!contestant) { return null; } return await contestant.update({ activated: 1, password, }); } } module.exports = AuthService;