liu
2026-09-22 0837c3ff473499d8a267924125c776b34cc8b517
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
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(); }
}