package com.ruoyi.web.controller.miniapp; import java.util.ArrayList; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.text.SimpleDateFormat; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.redis.RedisCache; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.system.domain.CjClass; import com.ruoyi.system.domain.CjCollege; import com.ruoyi.system.domain.CjMajor; import com.ruoyi.system.domain.CjStudent; import com.ruoyi.system.mapper.CjClassMapper; import com.ruoyi.system.mapper.CjCollegeMapper; import com.ruoyi.system.mapper.CjMajorMapper; import com.ruoyi.system.mapper.CjStudentMapper; import com.ruoyi.system.service.MiniappTokenService; /** * 学生端账号体系接口:图形验证码 / 注册(花名册激活) / 登录 / 忘记密码(三要素自助重置) / 修改密码。 * * 约定: * - 路径挂在 /miniapp/** 下,SecurityConfig 已整体 permitAll,登录态接口在方法内自校验 token; * - 登录标识 = 学号 + 姓名(学号仅班级内唯一,姓名消歧义,与既有 /miniapp/bind 口径一致); * - 注册即"花名册激活":学生须已在花名册且尚未设置密码;花名册已登记手机号的,注册需手机号校验(防二要素抢注); * - 失败锁定按 学号+姓名 组合计数(固定 10 分钟窗口),避免跨班同学号互锁与滚存锁; * - 会话 token 统一走 MiniappTokenService(含反向索引,支持重置/改密/强制下线时作废全部会话)。 * * @author ruoyi */ @RestController @RequestMapping("/miniapp") public class MiniappAccountController { /** 修改密码时原密码错误计数(按 studentId) */ private static final String CHANGE_ERR_KEY = "miniapp_change_err:"; private static final int PWD_ERR_MAX = 5; private static final int RESET_ERR_MAX = 3; private static final int CHANGE_ERR_MAX = 5; /** 密码复杂度:8-20 位,至少含字母和数字 */ private static final String PWD_RULE_RE = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d!@#$%^&*_.\\-+=?]{8,20}$"; private static final String PHONE_RE = "^1[3-9]\\d{9}$"; private static final String FAIL_IDENTITY_MSG = "校验失败,请核对学号/姓名/手机号;如有疑问请联系班级管理人重置"; private static final String MULTI_MATCH_MSG = "存在相同学号与姓名的多名学生,请联系班级管理人核对"; @Autowired private RedisCache redisCache; @Autowired private CjStudentMapper studentMapper; @Autowired private CjClassMapper classMapper; @Autowired private CjMajorMapper majorMapper; @Autowired private CjCollegeMapper collegeMapper; @Autowired private MiniappTokenService tokenService; @Autowired private MiniappCaptchaService captchaService; // ================================================================= // 1. 图形验证码(与管理端风格一致,key 前缀独立) // ================================================================= @GetMapping("/captcha") public AjaxResult captcha() { return captchaService.generate(); } // ================================================================= // 2. 注册 = 扫班级二维码自助建档(成功即登录;手机号自填,作找回密码验证手段) // ================================================================= @PostMapping("/account/register") public AjaxResult register(@RequestBody Map body) { String bindCode = trim(body.get("bindCode")); String classIdStr = trim(body.get("classId")); String studentNo = trim(body.get("studentNo")); String name = trim(body.get("name")); String phone = trim(body.get("phone")); String password = body.get("password"); if (isEmpty(bindCode) && isEmpty(classIdStr)) return AjaxResult.error("请选择所在班级(或扫描班级二维码进入)"); if (isEmpty(studentNo) || isEmpty(name)) return AjaxResult.error("请输入学号和姓名"); String capErr = captchaService.verify(body.get("code"), body.get("uuid")); if (capErr != null) return AjaxResult.error(capErr); String pwdErr = checkPwdRule(password, studentNo); if (pwdErr != null) return AjaxResult.error(pwdErr); if (isEmpty(phone) || !phone.matches(PHONE_RE)) return AjaxResult.error("请填写手机号(用于找回密码)"); // 班级定位:扫码优先,未扫码则用学生自选的 classId CjClass clazz; if (!isEmpty(bindCode)) { clazz = findClassByBindCode(bindCode); if (clazz == null) return AjaxResult.error("注册码无效或已更换,请向老师确认本班最新二维码"); } else { Long classId; try { classId = Long.valueOf(classIdStr); } catch (NumberFormatException e) { return AjaxResult.error("所选班级不正确"); } clazz = classMapper.selectCjClassByClassId(classId); if (clazz == null) return AjaxResult.error("所选班级不存在,请重新选择"); } if (!"0".equals(clazz.getStatus())) return AjaxResult.error("本班注册已暂停,请联系老师"); CjStudent exists = null; List matched = findRoster(studentNo, name); if (matched.size() > 1) return AjaxResult.error(MULTI_MATCH_MSG); if (!matched.isEmpty()) exists = matched.get(0); if (exists != null && !isEmpty(studentMapper.selectPasswordByStudentId(exists.getStudentId()))) { return AjaxResult.error("该学号已注册过,请直接登录;忘记密码可用“忘记密码”自助重置"); } CjStudent student = exists; if (student == null) { // 全新自助建档 student = new CjStudent(); student.setClassId(clazz.getClassId()); student.setStudentNo(studentNo); student.setName(name); student.setPhone(phone); student.setStatus("0"); student.setDelFlag("0"); student.setCreateBy("self-register"); try { studentMapper.insertCjStudent(student); } catch (org.springframework.dao.DuplicateKeyException e) { return AjaxResult.error("该学号与姓名已被注册"); } } else { // 命中存量记录(如历史数据):激活并按本次选择班级归位,手机号补齐;备注留痕 String trace = "[注册激活 " + stamp() + "] 班级 → " + clazz.getClassName(); CjStudent upd = new CjStudent(); upd.setStudentId(student.getStudentId()); upd.setClassId(clazz.getClassId()); upd.setPhone(phone); upd.setRemark(appendRemark(student.getRemark(), trace)); upd.setUpdateBy("self-register"); studentMapper.updateCjStudent(upd); } studentMapper.updateStudentPassword(student.getStudentId(), SecurityUtils.encryptPassword(password), "0", new Date()); String token = tokenService.createSession(student.getStudentId()); Map data = new LinkedHashMap<>(); data.put("token", token); data.put("mustChange", false); return AjaxResult.success("注册成功", data); } /** * 全部正常状态班级(供未扫码学生注册时自选):[{classId, label:'学院·专业·班级'}] */ @GetMapping("/account/classes") public AjaxResult classes() { List all = classMapper.selectCjClassList(new CjClass()); Map majorMap = new LinkedHashMap<>(); for (CjMajor m : majorMapper.selectCjMajorList(new CjMajor())) majorMap.put(m.getMajorId(), m); Map collegeMap = new LinkedHashMap<>(); for (CjCollege c : collegeMapper.selectCjCollegeList(new CjCollege())) collegeMap.put(c.getCollegeId(), c); List> out = new ArrayList<>(); for (CjClass cl : all) { if (!"0".equals(cl.getStatus())) continue; CjMajor m = majorMap.get(cl.getMajorId()); CjCollege co = m == null ? null : collegeMap.get(m.getCollegeId()); String label = ((co == null ? "" : co.getCollegeName() + "·") + (m == null ? "" : m.getMajorName() + "·") + cl.getClassName()); Map row = new LinkedHashMap<>(); row.put("classId", cl.getClassId()); row.put("label", label); out.add(row); } return AjaxResult.success(out); } /** * 解析注册码 → 班级信息(注册页展示"你正在加入:××学院××专业××班") */ @GetMapping("/account/resolve") public AjaxResult resolve(@RequestParam("bindCode") String bindCode) { CjClass clazz = findClassByBindCode(trim(bindCode)); if (clazz == null) return AjaxResult.error("二维码无效或已更换,请向老师确认"); if (!"0".equals(clazz.getStatus())) return AjaxResult.error("本班注册已暂停,请联系老师"); Map out = new LinkedHashMap<>(); out.put("classId", clazz.getClassId()); out.put("className", clazz.getClassName()); CjMajor m = clazz.getMajorId() == null ? null : majorMapper.selectCjMajorByMajorId(clazz.getMajorId()); out.put("majorName", m == null ? "" : m.getMajorName()); CjCollege co = (m == null || m.getCollegeId() == null) ? null : collegeMapper.selectCjCollegeByCollegeId(m.getCollegeId()); out.put("collegeName", co == null ? "" : co.getCollegeName()); return AjaxResult.success(out); } /** * 重新绑定班级(登录态,扫新班级码);已提交记录按快照保留原班级归属,此处仅移动学生当前班级并留痕 */ @PostMapping("/account/rebind") public AjaxResult rebind(@RequestBody Map body, @RequestHeader(value = "Authorization", required = false) String auth) { Long sid = resolveStudentId(auth); if (sid == null) return AjaxResult.error(401, "未登录或登录已过期"); CjStudent student = studentMapper.selectCjStudentByStudentId(sid); if (student == null) return AjaxResult.error(401, "账号不存在"); CjClass target = findClassByBindCode(trim(body.get("bindCode"))); if (target == null) return AjaxResult.error("二维码无效或已更换,请向老师确认"); if (!"0".equals(target.getStatus())) return AjaxResult.error("目标班级注册已暂停,请联系老师"); if (target.getClassId().equals(student.getClassId())) return AjaxResult.error("你已在该班级,无需换班"); String oldClassName = ""; if (student.getClassId() != null) { CjClass old = classMapper.selectCjClassByClassId(student.getClassId()); oldClassName = old == null ? String.valueOf(student.getClassId()) : old.getClassName(); } String trace = "[换班 " + stamp() + "] " + oldClassName + " → " + target.getClassName(); CjStudent upd = new CjStudent(); upd.setStudentId(sid); upd.setClassId(target.getClassId()); upd.setRemark(appendRemark(student.getRemark(), trace)); upd.setUpdateBy("self-rebind"); studentMapper.updateCjStudent(upd); Map data = new LinkedHashMap<>(); data.put("className", target.getClassName()); return AjaxResult.success("已换班,此前已提交的表单仍归属原班级", data); } // ================================================================= // 3. 登录(学号 + 姓名 + 密码,成功签发 token) // ================================================================= @PostMapping("/account/login") public AjaxResult login(@RequestBody Map body) { String studentNo = trim(body.get("studentNo")); String name = trim(body.get("name")); String password = body.get("password"); if (isEmpty(studentNo) || isEmpty(name) || isEmpty(password)) { return AjaxResult.error("请填写完整的登录信息"); } String capErr = captchaService.verify(body.get("code"), body.get("uuid")); if (capErr != null) return AjaxResult.error(capErr); String lockKey = MiniappTokenService.pwdErrKey(studentNo, name); if (captchaService.failCount(lockKey) >= PWD_ERR_MAX) { return AjaxResult.error("密码错误次数过多,请 10 分钟后再试,或使用“忘记密码”"); } List matched = findRoster(studentNo, name); if (matched.size() > 1) return AjaxResult.error(MULTI_MATCH_MSG); if (matched.isEmpty()) { return AjaxResult.error("学号或姓名不正确"); } CjStudent student = matched.get(0); String stored = studentMapper.selectPasswordByStudentId(student.getStudentId()); if (isEmpty(stored)) { return AjaxResult.error("该账号尚未注册,请先注册"); } if (!SecurityUtils.matchesPassword(password, stored)) { captchaService.incrFail(lockKey, PWD_ERR_MAX); return AjaxResult.error("学号、姓名或密码不正确"); } if (!"0".equals(student.getStatus())) { return AjaxResult.error("你的账号已停用,请联系班级管理人"); } captchaService.clearFail(lockKey); studentMapper.updateStudentLoginDate(student.getStudentId(), new Date()); String token = tokenService.createSession(student.getStudentId()); Map data = new LinkedHashMap<>(); data.put("token", token); data.put("mustChange", "1".equals(student.getPwdResetFlag())); return AjaxResult.success("登录成功", data); } // ================================================================= // 4. 忘记密码 = 身份三要素自助重置(学号 + 姓名 + 花名册登记手机号) // ================================================================= @PostMapping("/account/reset") public AjaxResult reset(@RequestBody Map body) { String studentNo = trim(body.get("studentNo")); String name = trim(body.get("name")); String phone = trim(body.get("phone")); String newPassword = body.get("newPassword"); if (isEmpty(studentNo) || isEmpty(name) || isEmpty(phone) || isEmpty(newPassword)) { return AjaxResult.error("请填写完整的找回信息"); } String capErr = captchaService.verify(body.get("code"), body.get("uuid")); if (capErr != null) return AjaxResult.error(capErr); String lockKey = MiniappTokenService.resetErrKey(studentNo, name); if (captchaService.failCount(lockKey) >= RESET_ERR_MAX) { return AjaxResult.error("自助重置尝试次数过多,请联系班级管理人"); } String pwdErr = checkPwdRule(newPassword, studentNo); if (pwdErr != null) return AjaxResult.error(pwdErr); List matched = findRoster(studentNo, name); if (matched.size() > 1) return AjaxResult.error(FAIL_IDENTITY_MSG); // 不泄露多命中细节 CjStudent student = matched.isEmpty() ? null : matched.get(0); boolean ok = student != null && !isEmpty(student.getPhone()) && phone.equals(student.getPhone().trim()) && "0".equals(student.getStatus()); if (!ok) { captchaService.incrFail(lockKey, RESET_ERR_MAX); return AjaxResult.error(FAIL_IDENTITY_MSG); } boolean firstActivate = isEmpty(studentMapper.selectPasswordByStudentId(student.getStudentId())); studentMapper.updateStudentPassword(student.getStudentId(), SecurityUtils.encryptPassword(newPassword), "0", firstActivate ? new Date() : null); tokenService.invalidateAll(student.getStudentId()); captchaService.clearFail(lockKey); captchaService.clearFail(MiniappTokenService.pwdErrKey(studentNo, name)); return AjaxResult.success("密码重置成功,请使用新密码登录"); } // ================================================================= // 5. 修改密码(登录态;作废旧会话、签发新 token,即“改密踢其他设备”) // ================================================================= @PostMapping("/account/change") public AjaxResult change(@RequestBody Map body, @RequestHeader(value = "Authorization", required = false) String auth) { Long sid = resolveStudentId(auth); if (sid == null) return AjaxResult.error(401, "未登录或登录已过期"); String oldPassword = body.get("oldPassword"); String newPassword = body.get("newPassword"); if (isEmpty(oldPassword) || isEmpty(newPassword)) { return AjaxResult.error("请填写原密码与新密码"); } String changeLock = CHANGE_ERR_KEY + sid; if (captchaService.failCount(changeLock) >= CHANGE_ERR_MAX) { return AjaxResult.error("原密码错误次数过多,请 10 分钟后再试"); } CjStudent student = studentMapper.selectCjStudentByStudentId(sid); if (student == null) return AjaxResult.error(401, "账号不存在"); String stored = studentMapper.selectPasswordByStudentId(sid); if (isEmpty(stored) || !SecurityUtils.matchesPassword(oldPassword, stored)) { captchaService.incrFail(changeLock, CHANGE_ERR_MAX); return AjaxResult.error("原密码不正确"); } if (oldPassword.equals(newPassword)) { return AjaxResult.error("新密码不能与原密码相同"); } String pwdErr = checkPwdRule(newPassword, student.getStudentNo()); if (pwdErr != null) return AjaxResult.error(pwdErr); studentMapper.updateStudentPassword(sid, SecurityUtils.encryptPassword(newPassword), "0", null); captchaService.clearFail(changeLock); tokenService.invalidateAll(sid); String token = tokenService.createSession(sid); Map data = new LinkedHashMap<>(); data.put("token", token); return AjaxResult.success("密码修改成功,其他设备已退出登录", data); } // ================================================================= // 私有工具 // ================================================================= /** 花名册匹配(按 学号+姓名):返回命中列表(可能 0/1/多条);null = 同键多条且无法由调用方消歧 */ private List findRoster(String studentNo, String name) { CjStudent q = new CjStudent(); q.setStudentNo(studentNo); List list = studentMapper.selectCjStudentList(q); return list.stream().filter(s -> name.equals(s.getName())).collect(Collectors.toList()); } /** 班级注册码 → 班级(复用建表预留的 bind_code 列,32 位随机码不可枚举) */ private CjClass findClassByBindCode(String bindCode) { if (isEmpty(bindCode)) return null; CjClass q = new CjClass(); q.setBindCode(bindCode); List list = classMapper.selectCjClassList(q); return list.isEmpty() ? null : list.get(0); } /** 留痕时间戳 */ private String stamp() { return new SimpleDateFormat("yyyy-MM-dd HH:mm").format(new Date()); } /** 备注追加流水(限长保护,remark 列 varchar(500)) */ private String appendRemark(String old, String line) { String base = old == null ? "" : old; String merged = base.isEmpty() ? line : base + ";" + line; return merged.length() > 500 ? merged.substring(merged.length() - 500) : merged; } /** 密码规则校验;通过返回 null */ private String checkPwdRule(String password, String studentNo) { if (isEmpty(password)) return "请输入密码"; if (!password.matches(PWD_RULE_RE)) return "密码须为 8-20 位,且同时包含字母和数字"; if (password.equals(studentNo)) return "密码不能与学号相同"; return null; } /** 与 MiniappController 相同口径的 token 校验 */ private Long resolveStudentId(String auth) { if (auth == null || !auth.startsWith("Bearer ")) return null; Object val = redisCache.getCacheObject(MiniappTokenService.TOKEN_PREFIX + auth.substring(7)); if (val instanceof Long) { Long sid = (Long) val; return sid > 0 ? sid : null; } return null; } private String trim(String s) { return s == null ? null : s.trim(); } private boolean isEmpty(String s) { return s == null || s.trim().isEmpty(); } }