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<String, String> 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<CjStudent> 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<String, Object> data = new LinkedHashMap<>();
|
data.put("token", token);
|
data.put("mustChange", false);
|
return AjaxResult.success("注册成功", data);
|
}
|
|
/**
|
* 全部正常状态班级(供未扫码学生注册时自选):[{classId, label:'学院·专业·班级'}]
|
*/
|
@GetMapping("/account/classes")
|
public AjaxResult classes()
|
{
|
List<CjClass> all = classMapper.selectCjClassList(new CjClass());
|
Map<Long, CjMajor> majorMap = new LinkedHashMap<>();
|
for (CjMajor m : majorMapper.selectCjMajorList(new CjMajor())) majorMap.put(m.getMajorId(), m);
|
Map<Long, CjCollege> collegeMap = new LinkedHashMap<>();
|
for (CjCollege c : collegeMapper.selectCjCollegeList(new CjCollege())) collegeMap.put(c.getCollegeId(), c);
|
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, String> 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<String, Object> data = new LinkedHashMap<>();
|
data.put("className", target.getClassName());
|
return AjaxResult.success("已换班,此前已提交的表单仍归属原班级", data);
|
}
|
|
// =================================================================
|
// 3. 登录(学号 + 姓名 + 密码,成功签发 token)
|
// =================================================================
|
@PostMapping("/account/login")
|
public AjaxResult login(@RequestBody Map<String, String> 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<CjStudent> 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<String, Object> 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<String, String> 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<CjStudent> 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<String, String> 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<String, Object> data = new LinkedHashMap<>();
|
data.put("token", token);
|
return AjaxResult.success("密码修改成功,其他设备已退出登录", data);
|
}
|
|
// =================================================================
|
// 私有工具
|
// =================================================================
|
|
/** 花名册匹配(按 学号+姓名):返回命中列表(可能 0/1/多条);null = 同键多条且无法由调用方消歧 */
|
private List<CjStudent> findRoster(String studentNo, String name)
|
{
|
CjStudent q = new CjStudent();
|
q.setStudentNo(studentNo);
|
List<CjStudent> 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<CjClass> 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(); }
|
}
|