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
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
package com.ruoyi.web.controller.miniapp;
 
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.config.RuoYiConfig;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysDictData;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.file.FileUploadUtils;
import com.ruoyi.system.domain.CjClass;
import com.ruoyi.system.domain.CjCollege;
import com.ruoyi.system.domain.CjFillRecord;
import com.ruoyi.system.domain.CjFillTask;
import com.ruoyi.system.domain.CjMajor;
import com.ruoyi.system.domain.CjStudent;
import com.ruoyi.system.domain.CjStudentBind;
import com.ruoyi.system.mapper.CjClassMapper;
import com.ruoyi.system.mapper.CjCollegeMapper;
import com.ruoyi.system.mapper.CjFillRecordMapper;
import com.ruoyi.system.mapper.CjFillTaskMapper;
import com.ruoyi.system.mapper.CjMajorMapper;
import com.ruoyi.system.mapper.CjStudentBindMapper;
import com.ruoyi.system.mapper.CjStudentMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
 
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
 
/**
 * 学生端(小程序/MiniApp)接口控制器 —— 填表任务驱动填报
 * 不依赖 RuoYi 权限体系,用 Redis 自定义 token 会话。
 *
 * 填报对象:填表任务(cj_fill_task)发布后在填报窗口内、且该生班级在任务范围内的任务;
 * 表单按任务 form schema 动态渲染;记录存 cj_fill_record(学生×任务唯一,0草稿/1已提交)。
 */
@RestController
@RequestMapping("/miniapp")
public class MiniappController {
 
    private static final String TOKEN_PREFIX = "miniapp:token:";
    /** 未绑定登录时,临时把 openid 存这里,绑定接口按 token 读取并真正落库 */
    private static final String OPENID_PREFIX = "miniapp:openid:";
    private static final Logger log = LoggerFactory.getLogger(MiniappController.class);
    private static final String PHONE_RE = "^1[3-9]\\d{9}$";
    /** 统一社会信用代码:18位,大写字母+数字,不含 I/O/Z/S/V */
    private static final String CREDIT_CODE_RE = "^[0-9A-HJ-NPQRTUWXY]{18}$";
 
    @Value("${wechat.appid:}") private String wxAppid;
    @Value("${wechat.secret:}") private String wxSecret;
 
    @Autowired private RedisCache redisCache;
    @Autowired private CjStudentMapper studentMapper;
    @Autowired private CjStudentBindMapper bindMapper;
    @Autowired private CjClassMapper classMapper;
    @Autowired private CjMajorMapper majorMapper;
    @Autowired private CjCollegeMapper collegeMapper;
    @Autowired private CjFillTaskMapper taskMapper;
    @Autowired private CjFillRecordMapper recordMapper;
    @Autowired private com.ruoyi.system.service.MiniappTokenService tokenService;
    @Autowired private MiniappCaptchaService captchaService;
    @Autowired private com.ruoyi.system.service.ISysDictTypeService dictTypeService;
 
    private final ObjectMapper om = new ObjectMapper();
 
    /** 文案字典类型(dict_label=用途键名,dict_value=文案正文),见 skill/sql/15_cj_title_dict.sql */
    private static final String TITLE_DICT_TYPE = "cj_fill_title";
 
    // ========== 0. 站点文案(字典驱动,登录/注册页未登录也要显示,故随 /miniapp/** 匿名放行) ==========
    @GetMapping("/site-titles")
    public AjaxResult siteTitles() {
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("platformTitle", dictText("平台标题", "产教融合实践教学管理平台"));
        data.put("platformSubtitle", dictText("平台副标题", "江苏商贸职业学院 · 学生实践教学信息填报"));
        data.put("loginSubtitle", dictText("登录页副标题", "产教融合实践教学管理平台 · 学生账号"));
        return AjaxResult.success(data);
    }
 
    /**
     * 取文案字典中某个用途的正文;字典项缺失/被清空时回退内置默认,页面不会空白。
     * 用 selectDictDataByType 而非 DictUtils:前者缓存未命中会回源数据库并补缓存,
     * 线上跑完 15 号脚本无需重启或手动刷缓存即可生效。
     */
    private String dictText(String label, String fallback) {
        try {
            List<SysDictData> datas = dictTypeService.selectDictDataByType(TITLE_DICT_TYPE);
            if (datas != null) {
                for (SysDictData d : datas) {
                    if (label.equals(d.getDictLabel())) {
                        String v = d.getDictValue();
                        if (v != null && !v.trim().isEmpty()) return v.trim();
                        break;
                    }
                }
            }
        } catch (Exception e) {
            log.warn("读取文案字典失败 label={},回退默认文案", label, e);
        }
        return fallback;
    }
 
    // ========== 1. 登录(openid 通道已下线:花名册废除后统一走账号密码体系) ==========
    @PostMapping("/login")
    public AjaxResult login(@RequestBody Map<String, String> params) {
        return AjaxResult.error("该通道已停用,请通过班级二维码注册并使用账号密码登录");
    }
 
    /** 旧微信登录实现(保留代码备小程序上线时改造,暂不接流量) */
    private AjaxResult legacyWxLogin(Map<String, String> params) {
        String code = params.getOrDefault("code", "");
        String openid;
        if ("H5_DEV_CODE".equals(code) || code.isEmpty()) {
            // 旧联调方式:随机(每次不同,仅用于功能调试)
            openid = "mock-openid-" + UUID.randomUUID().toString().substring(0, 8);
        } else if (code.startsWith("H5_DEV_")) {
            // H5 稳定联调:同一个“设备码”永远得到同一 openid → 绑定可被记住
            openid = "h5mock-" + md5Hex(code);
        } else {
            openid = code2session(code);
            if (openid == null) return AjaxResult.error("微信登录失败,请稍后重试");
        }
        CjStudentBind q = new CjStudentBind();
        q.setOpenidHash(openid);
        List<CjStudentBind> list = bindMapper.selectCjStudentBindList(q);
        if (!list.isEmpty()) {
            String token = UUID.randomUUID().toString().replace("-", "");
            redisCache.setCacheObject(TOKEN_PREFIX + token, list.get(0).getStudentId(), 24, TimeUnit.HOURS);
            tokenService.registerSession(list.get(0).getStudentId(), token); // 登记反向索引,供改密/强制下线作废
            return AjaxResult.success("success", Map.of("token", token, "bound", true));
        }
        String token = UUID.randomUUID().toString().replace("-", "");
        redisCache.setCacheObject(TOKEN_PREFIX + token, -1L, 30, TimeUnit.MINUTES);
        // 记住本次微信 openid,供绑定接口使用,保证“微信身份 ↔ 学号”真正绑在一起
        redisCache.setCacheObject(OPENID_PREFIX + token, openid, 30, TimeUnit.MINUTES);
        return AjaxResult.success("need_bind", Map.of("token", token, "bound", false));
    }
 
    // ========== 2. 绑定学生身份(花名册通道已下线,仅保留方法体备查) ==========
    @PostMapping("/bind")
    public AjaxResult bind(@RequestBody Map<String, String> params,
                           @RequestHeader(value = "Authorization", required = false) String auth) {
        return AjaxResult.error("该通道已停用,请通过班级二维码注册并使用账号密码登录");
    }
 
    private AjaxResult legacyBind(Map<String, String> params, String auth) {
        String studentNo = params.get("studentNo") == null ? null : params.get("studentNo").trim();
        String name = params.get("name") == null ? null : params.get("name").trim();
        if (studentNo == null || studentNo.isEmpty() || name == null || name.isEmpty()) return AjaxResult.error("请提供学号和姓名");
        String capErr = captchaService.verify(params.get("code"), params.get("uuid"));
        if (capErr != null) return AjaxResult.error(capErr);
 
        CjStudent qs = new CjStudent();
        qs.setStudentNo(studentNo);
        List<CjStudent> students = studentMapper.selectCjStudentList(qs);
        List<CjStudent> matched = new ArrayList<>();
        for (CjStudent s : students) { if (name.equals(s.getName())) matched.add(s); }
        // 统一文案:不区分"学号不存在/姓名不匹配",配合验证码堵住无门槛枚举
        if (matched.isEmpty()) return AjaxResult.error("学号或姓名不匹配,请核对或联系班级管理人");
        if (matched.size() > 1) return AjaxResult.error("存在相同学号与姓名的多名学生,请联系班级管理人核对");
        CjStudent student = matched.get(0);
        if (!"0".equals(student.getStatus())) return AjaxResult.error("你的账号已停用,请联系班级管理人");
        String existPwd = studentMapper.selectPasswordByStudentId(student.getStudentId());
        if (existPwd != null && !existPwd.isEmpty()) return AjaxResult.error("账号已激活,请使用密码登录");
 
        // 绑定身份:优先用“本次登录”记录下来的微信 openid(真实微信);无登录态时再退回 mock
        String openid = openidOfToken(auth);
        if (openid == null) openid = params.getOrDefault("openid", "mock-openid-" + UUID.randomUUID().toString().substring(0, 8));
 
        CjStudentBind qb = new CjStudentBind();
        qb.setStudentId(student.getStudentId());
        List<CjStudentBind> existsList = bindMapper.selectCjStudentBindList(qb);
        CjStudentBind exists = existsList.isEmpty() ? null : existsList.get(0);
        if (exists != null) {
            exists.setOpenidHash(openid);
            bindMapper.updateCjStudentBind(exists);
        } else {
            CjStudentBind bind = new CjStudentBind();
            bind.setStudentId(student.getStudentId());
            bind.setOpenidHash(openid);
            bind.setBindChannel("1");
            bind.setStatus("0");
            bindMapper.insertCjStudentBind(bind);
        }
        String token = UUID.randomUUID().toString().replace("-", "");
        redisCache.setCacheObject(TOKEN_PREFIX + token, student.getStudentId(), 24, TimeUnit.HOURS);
        tokenService.registerSession(student.getStudentId(), token); // 登记反向索引,供改密/强制下线作废
        return AjaxResult.success("绑定成功", Map.of("token", token, "studentId", student.getStudentId()));
    }
 
    // ========== 2.5 开放登记通道(已下线:学生统一经班级码注册,注册自选班级走 /miniapp/account/classes) ==========
 
    @GetMapping("/registerClasses")
    public AjaxResult registerClasses() {
        return AjaxResult.error("该通道已停用,请通过班级二维码注册并使用账号密码登录");
    }
 
    /** 旧开放登记班级列表(保留代码备查,不再接流量) */
    private AjaxResult legacyRegisterClasses() {
        java.util.Set<Long> openClassIds = new java.util.HashSet<>();
        CjFillTask q = new CjFillTask();
        q.setStatus("1");
        List<CjFillTask> tasks = taskMapper.selectCjFillTaskList(q);
        if (tasks != null) {
            for (CjFillTask t : tasks) {
                if ("1".equals(t.getRangeMode())) {
                    openClassIds.addAll(scopeClassIds(t));
                }
            }
        }
        List<Map<String, Object>> out = new ArrayList<>();
        if (!openClassIds.isEmpty()) {
            // 全量班级/专业/学院做名称映射
            Map<Long, CjClass> classMap = new LinkedHashMap<>();
            List<CjClass> allClasses = classMapper.selectCjClassList(new CjClass());
            if (allClasses != null) for (CjClass cl : allClasses) classMap.put(cl.getClassId(), cl);
            Map<Long, CjMajor> majorMap = new LinkedHashMap<>();
            List<CjMajor> allMajors = majorMapper.selectCjMajorList(new CjMajor());
            if (allMajors != null) for (CjMajor m : allMajors) majorMap.put(m.getMajorId(), m);
            Map<Long, CjCollege> collegeMap = new LinkedHashMap<>();
            List<CjCollege> allColleges = collegeMapper.selectCjCollegeList(new CjCollege());
            if (allColleges != null) for (CjCollege c : allColleges) collegeMap.put(c.getCollegeId(), c);
 
            for (CjClass cl : classMap.values()) {
                if (!openClassIds.contains(cl.getClassId())) 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(Map.of("classes", out));
    }
 
    /**
     * 自登记建档(开放登记已下线:注册统一走 /miniapp/account/register + 班级码)
     */
    @PostMapping("/register")
    public AjaxResult register(@RequestBody Map<String, String> params) {
        return AjaxResult.error("该通道已停用,请通过班级二维码注册");
    }
 
    private AjaxResult legacyOpenRegister(Map<String, String> params) {
        String studentNo = params.get("studentNo");
        String name = params.get("name");
        String phone = params.get("phone");
        Long classId = params.get("classId") == null ? null : Long.valueOf(params.get("classId"));
        if (classId == null || isEmpty(studentNo) || isEmpty(name)) {
            return AjaxResult.error("请完整填写 班级/学号/姓名");
        }
        String capErr = captchaService.verify(params.get("code"), params.get("uuid"));
        if (capErr != null) return AjaxResult.error(capErr);
        CjClass clazz = classId == null ? null : classMapper.selectCjClassByClassId(classId);
        if (clazz == null) return AjaxResult.error("所选班级不存在");
 
        CjStudent dup = findByClassAndNo(classId, studentNo);
        CjStudent student;
        if (dup != null) {
            if (!name.equals(dup.getName())) return AjaxResult.error("该班已存在该学号且姓名不一致,请核对");
            // 已设置登录密码的学生不能再经开放登记免密拿 token
            String dupPwd = studentMapper.selectPasswordByStudentId(dup.getStudentId());
            if (dupPwd != null && !dupPwd.isEmpty()) return AjaxResult.error("账号已激活,请使用密码登录");
            student = dup;
        } else {
            CjStudent s = new CjStudent();
            s.setClassId(classId);
            s.setStudentNo(studentNo.trim());
            s.setName(name.trim());
            s.setPhone(isEmpty(phone) ? null : phone.trim());
            s.setStatus("0");
            s.setDelFlag("0");
            studentMapper.insertCjStudent(s);
            student = s;
        }
 
        // 写入微信绑定并下发 token
        CjStudentBind qb = new CjStudentBind();
        qb.setStudentId(student.getStudentId());
        List<CjStudentBind> existsList = bindMapper.selectCjStudentBindList(qb);
        CjStudentBind exists = existsList.isEmpty() ? null : existsList.get(0);
        String openid = "mock-openid-" + UUID.randomUUID().toString().substring(0, 8);
        if (exists != null) {
            exists.setOpenidHash(openid);
            bindMapper.updateCjStudentBind(exists);
        } else {
            CjStudentBind bind = new CjStudentBind();
            bind.setStudentId(student.getStudentId());
            bind.setOpenidHash(openid);
            bind.setBindChannel("1");
            bind.setStatus("0");
            bindMapper.insertCjStudentBind(bind);
        }
        String token = UUID.randomUUID().toString().replace("-", "");
        redisCache.setCacheObject(TOKEN_PREFIX + token, student.getStudentId(), 24, TimeUnit.HOURS);
        tokenService.registerSession(student.getStudentId(), token); // 登记反向索引,供改密/强制下线作废
        return AjaxResult.success("登记成功", Map.of("token", token, "studentId", student.getStudentId()));
    }
 
    private CjStudent findByClassAndNo(Long classId, String studentNo) {
        CjStudent q = new CjStudent();
        q.setClassId(classId);
        q.setStudentNo(studentNo.trim());
        List<CjStudent> list = studentMapper.selectCjStudentList(q);
        return list.isEmpty() ? null : list.get(0);
    }
 
    private List<Long> scopeClassIds(CjFillTask task) {
        List<Long> ids = new ArrayList<>();
        try {
            Map<String, Object> root = om.readValue(task.getScopeJson(), new TypeReference<Map<String, Object>>() { });
            Object o = root.get("classIds");
            if (o instanceof List) {
                for (Object it : (List<?>) o) ids.add(((Number) it).longValue());
            }
        } catch (Exception ignore) { }
        return ids;
    }
 
    private boolean isEmpty(String s) { return s == null || s.trim().isEmpty(); }
 
    // ========== 3. 我的信息(含班级/专业/学院 + 账号状态) ==========
    @GetMapping("/my-info")
    public AjaxResult myInfo(@RequestHeader("Authorization") String auth) {
        Long sid = resolveStudentId(auth);
        if (sid == null) return AjaxResult.error("未登录");
        Map<String, Object> ctx = studentContext(sid);
        Object v = ctx.get("view");
        if (v instanceof Map) {
            @SuppressWarnings("unchecked")
            Map<String, Object> view = (Map<String, Object>) v;
            CjStudent s = (CjStudent) ctx.get("student");
            String pwd = studentMapper.selectPasswordByStudentId(sid);
            view.put("hasPassword", pwd != null && !pwd.isEmpty());
            view.put("mustChangePwd", s != null && "1".equals(s.getPwdResetFlag()));
        }
        return AjaxResult.success(v);
    }
 
    // ========== 4. 我可填报/已填报的任务列表 ==========
    @GetMapping("/tasks")
    public AjaxResult tasks(@RequestHeader("Authorization") String auth) {
        Long sid = resolveStudentId(auth);
        if (sid == null) return AjaxResult.error("未登录");
        Map<String, Object> ctx = studentContext(sid);
        Long myClassId = (Long) ctx.get("classId");
        if (myClassId == null) return AjaxResult.error("未找到你的班级信息,请联系老师");
        CjStudent student = (CjStudent) ctx.get("student");
 
        CjFillTask q = new CjFillTask();
        q.setStatus("1"); // 只列已发布任务
        List<CjFillTask> tasks = taskMapper.selectCjFillTaskList(q);
        List<Map<String, Object>> out = new ArrayList<>();
        if (tasks != null) {
            for (CjFillTask t : tasks) {
                if (!inScope(t, myClassId)) continue;
                boolean open = isOpen(t);
                CjFillRecord rec = recordMapper.selectByTaskAndStudent(t.getTaskId(), sid);
                // 未在我的填报窗口且没有记录的任务不展示
                if (!open && rec == null) continue;
                Map<String, Object> m = new LinkedHashMap<>();
                m.put("taskId", t.getTaskId());
                m.put("taskName", t.getTaskName());
                m.put("schoolYear", t.getSchoolYear());
                m.put("semester", t.getSemester());
                m.put("fillStartDate", fmtDate(t.getFillStartDate()));
                m.put("fillEndDate", fmtDate(t.getFillEndDate()));
                m.put("needApprove", t.getNeedApprove());
                m.put("open", open);
                m.put("recordStatus", rec == null ? null : rec.getStatus()); // 0草稿 1已提交
                m.put("submitTime", rec == null ? null : fmtTime(rec.getSubmitTime()));
                out.add(m);
            }
        }
        out.sort((a, b) -> orBlank(a.get("fillEndDate")).compareTo(orBlank(b.get("fillEndDate"))));
        return AjaxResult.success(Map.of("studentName", student.getName(), "studentNo", student.getStudentNo(), "tasks", out));
    }
 
    // ========== 5. 任务表单(schema + 预填 + 我的记录) ==========
    @GetMapping("/task/{taskId}")
    public AjaxResult taskForm(@PathVariable("taskId") Long taskId,
                               @RequestHeader("Authorization") String auth) {
        Long sid = resolveStudentId(auth);
        if (sid == null) return AjaxResult.error("未登录");
        CjFillTask task = taskMapper.selectCjFillTaskByTaskId(taskId);
        if (task == null) return AjaxResult.error("任务不存在");
        Map<String, Object> ctx = studentContext(sid);
        Long myClassId = (Long) ctx.get("classId");
        if (myClassId == null || !inScope(task, myClassId)) {
            // 换班后允许回看旧班级下自己的记录(记录快照保留原班级归属,只读)
            CjFillRecord mine = recordMapper.selectByTaskAndStudent(taskId, sid);
            if (mine == null) return AjaxResult.error("该任务不在你的填报范围");
        }
 
        Map<String, Object> form = new LinkedHashMap<>();
        fillPrefill(task, ctx, form);
 
        CjFillRecord rec = recordMapper.selectByTaskAndStudent(taskId, sid);
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("taskId", task.getTaskId());
        data.put("taskName", task.getTaskName());
        data.put("schoolYear", task.getSchoolYear());
        data.put("semester", task.getSemester());
        data.put("termText", termText(task));
        data.put("taskStatus", task.getStatus());
        data.put("fillStartDate", fmtDate(task.getFillStartDate()));
        data.put("fillEndDate", fmtDate(task.getFillEndDate()));
        data.put("needApprove", task.getNeedApprove());
        data.put("open", isOpen(task));
        data.put("readonly", rec != null && "1".equals(rec.getStatus()));
        data.put("recordStatus", rec == null ? null : rec.getStatus());
        data.put("submitTime", rec == null ? null : fmtTime(rec.getSubmitTime()));
        // 草稿/提交值覆盖预填
        if (rec != null && rec.getFormData() != null) {
            try {
                Map<String, Object> saved = om.readValue(rec.getFormData(), new TypeReference<Map<String, Object>>() { });
                if (saved != null) form.putAll(saved);
            } catch (Exception ignore) { }
        }
        data.put("form", form);
        data.put("schema", parseSchema(task.getSchemaJson())); // {groups, attachments}
        return AjaxResult.success(data);
    }
 
    // ========== 6. 保存草稿 ==========
    @PostMapping("/task/draft")
    public AjaxResult saveDraft(@RequestBody Map<String, Object> body,
                                @RequestHeader("Authorization") String auth) {
        Long sid = resolveStudentId(auth);
        if (sid == null) return AjaxResult.error("未登录");
        Long taskId = longOf(body.get("taskId"));
        Map<String, Object> form = castMap(body.get("form"));
        CjFillTask task = checkWritable(sid, taskId, form);
        CjFillRecord rec = buildRecord(task, sid, form, "0", null);
        rec.setRecordId(existingRecordId(taskId, sid));
        if (rec.getRecordId() == null) recordMapper.insertCjFillRecord(rec);
        else recordMapper.updateCjFillRecord(rec);
        return AjaxResult.success("草稿已保存", Map.of("recordId", rec.getRecordId() == null ? 0L : rec.getRecordId()));
    }
 
    // ========== 7. 正式提交 ==========
    @PostMapping("/task/submit")
    public AjaxResult submit(@RequestBody Map<String, Object> body,
                             @RequestHeader("Authorization") String auth) {
        Long sid = resolveStudentId(auth);
        if (sid == null) return AjaxResult.error("未登录");
        Long taskId = longOf(body.get("taskId"));
        Map<String, Object> form = castMap(body.get("form"));
        CjFillTask task = checkWritable(sid, taskId, form);
        String err = validateForm(task, form);
        if (err != null) return AjaxResult.error(err);
        CjFillRecord rec = buildRecord(task, sid, form, "1", new Date());
        rec.setRecordId(existingRecordId(taskId, sid));
        if (rec.getRecordId() == null) recordMapper.insertCjFillRecord(rec);
        else recordMapper.updateCjFillRecord(rec);
        return AjaxResult.success("提交成功", Map.of("recordId", rec.getRecordId() == null ? 0L : rec.getRecordId()));
    }
 
    // ========== 7.5 文件上传(表单「文件上传」字段用,图片/PDF/Office 材料统一入口) ==========
    /** 上传到 profile/upload 下,返回相对路径(/profile/upload/...),GET /profile/** 已匿名放行可回显 */
    @PostMapping("/upload")
    public AjaxResult upload(@RequestParam("file") MultipartFile file,
                             @RequestHeader(value = "Authorization", required = false) String auth) {
        Long sid = resolveStudentId(auth);
        if (sid == null) return AjaxResult.error(401, "未登录或登录已过期");
        if (file == null || file.isEmpty()) return AjaxResult.error("请选择要上传的文件");
        // 表单附件统一限制:单个文件不得大于 10MB(与 spring.servlet.multipart.max-file-size 一致)
        if (file.getSize() > 10L * 1024 * 1024) return AjaxResult.error("文件不得大于 10MB");
        try {
            String fileName = FileUploadUtils.upload(RuoYiConfig.getUploadPath(), file);
            Map<String, Object> data = new LinkedHashMap<>();
            data.put("fileName", fileName);
            data.put("originalName", file.getOriginalFilename());
            return AjaxResult.success(data);
        } catch (Exception e) {
            return AjaxResult.error("上传失败:" + e.getMessage());
        }
    }
 
    // =================================================================
    //                      私有工具
    // =================================================================
 
    // ========== 8. 撤回已提交(回到草稿可改) ==========
    @PostMapping("/task/withdraw")
    public AjaxResult withdraw(@RequestBody Map<String, Object> body,
                               @RequestHeader("Authorization") String auth) {
        Long sid = resolveStudentId(auth);
        if (sid == null) return AjaxResult.error("未登录");
        Long taskId = longOf(body.get("taskId"));
        if (taskId == null) return AjaxResult.error("缺少 taskId");
        CjFillTask task = taskMapper.selectCjFillTaskByTaskId(taskId);
        if (task == null) return AjaxResult.error("任务不存在");
        if (!"1".equals(task.getStatus()) || !isOpen(task)) {
            return AjaxResult.error("仅在填报时间内的任务可撤回,超过时间请联系班级管理人退回");
        }
        CjFillRecord rec = recordMapper.selectByTaskAndStudent(taskId, sid);
        if (rec == null || !"1".equals(rec.getStatus())) {
            return AjaxResult.error("当前没有可撤回的已提交记录");
        }
        recordMapper.rollbackDraft(rec.getRecordId());
        return AjaxResult.success("已撤回,可修改后重新提交", Map.of("taskId", taskId));
    }
 
    /** 检查任务可写:任务发布/填报窗口内/范围内/未提交 */
    private CjFillTask checkWritable(Long sid, Long taskId, Map<String, Object> form) {
        if (taskId == null) throw new ServiceException("缺少 taskId");
        CjFillTask task = taskMapper.selectCjFillTaskByTaskId(taskId);
        if (task == null) throw new ServiceException("任务不存在");
        Map<String, Object> ctx = studentContext(sid);
        Long myClassId = (Long) ctx.get("classId");
        if (myClassId == null || !inScope(task, myClassId)) throw new ServiceException("该任务不在你的填报范围");
        if (!"1".equals(task.getStatus())) throw new ServiceException("任务未发布或已关闭");
        if (!isOpen(task)) throw new ServiceException("不在填报时间范围内");
        CjFillRecord existing = recordMapper.selectByTaskAndStudent(taskId, sid);
        if (existing != null && "1".equals(existing.getStatus())) throw new ServiceException("已提交不可修改");
        return task;
    }
 
    private Long existingRecordId(Long taskId, Long sid) {
        CjFillRecord r = recordMapper.selectByTaskAndStudent(taskId, sid);
        return r == null ? null : r.getRecordId();
    }
 
    /** 组装记录(学生快照 + form 答案 + isOutside 服务端计算) */
    private CjFillRecord buildRecord(CjFillTask task, Long sid, Map<String, Object> form, String status, Date submitTime) {
        CjStudent s = studentMapper.selectCjStudentByStudentId(sid);
        Map<String, Object> ctx = studentContext(sid);
        String province = str(form.get("province"));
        form.put("isOutside", computeOutside(task, province));
 
        CjFillRecord rec = new CjFillRecord();
        rec.setTaskId(task.getTaskId());
        rec.setStudentId(sid);
        rec.setStudentNo(s.getStudentNo());
        rec.setStudentName(s.getName());
        rec.setGenderText(genderText(s.getGender()));
        rec.setPhone(s.getPhone());
        rec.setClassId((Long) ctx.get("classId"));
        rec.setClassName((String) ctx.get("className"));
        rec.setMajorName((String) ctx.get("majorName"));
        rec.setCollegeName((String) ctx.get("collegeName"));
        rec.setStatus(status);
        rec.setSubmitTime(submitTime);
        try {
            rec.setFormData(om.writeValueAsString(form));
        } catch (Exception e) {
            throw new ServiceException("数据序列化失败: " + e.getMessage());
        }
        return rec;
    }
 
    /** 服务端按任务排除集计算“是否外省/境外”,结果 1/0,写回 form */
    private String computeOutside(CjFillTask task, String province) {
        if (province == null || province.trim().isEmpty()) return "";
        String exclude = task.getOutsideExclude();
        if (exclude != null) {
            for (String s : exclude.split(",")) {
                if (s.trim().equals(province.trim())) return "0";
            }
        }
        return "1";
    }
 
    /** 客户端提交前的服务端校验(必填/手机号/信用代码/数值区间) */
    private String validateForm(CjFillTask task, Map<String, Object> form) {
        Map<String, Object> schema = parseSchema(task.getSchemaJson());
        Object groupsObj = schema.get("groups");
        if (!(groupsObj instanceof List)) return "表单配置缺失";
        for (Object go : (List<?>) groupsObj) {
            Map<String, Object> g = (Map<String, Object>) go;
            Object fs = g.get("fields");
            if (!(fs instanceof List)) continue;
            for (Object fo : (List<?>) fs) {
                Map<String, Object> f = (Map<String, Object>) fo;
                String type = str(f.get("type"));
                boolean readonly = Boolean.TRUE.equals(f.get("readonly")) || "server".equals(str(f.get("auto")))
                        || "roster".equals(str(f.get("auto"))) || "task".equals(str(f.get("auto")));
                if (readonly) continue;
                String code = str(f.get("code"));
                String label = str(f.get("label"));
                // 文件类字段(图片/PDF/Office 材料统一):值是路径数组(兼容逗号拼接字符串与历史 image 类型),单独校验
                if ("image".equals(type) || "file".equals(type)) {
                    List<String> files = fileList(form.get(code));
                    if (Boolean.TRUE.equals(f.get("required")) && files.isEmpty()) return "请上传「" + label + "」";
                    int max = 9;
                    Object mc = f.get("maxCount");
                    if (mc instanceof Number) max = ((Number) mc).intValue();
                    else if (mc != null) { try { max = Integer.parseInt(mc.toString().trim()); } catch (NumberFormatException ignore) { } }
                    if (files.size() > max) return "「" + label + "」最多上传 " + max + " 个文件";
                    for (String p : files) {
                        if (!p.startsWith("/profile/upload/")) return "「" + label + "」包含非法文件地址";
                    }
                    continue;
                }
                Object v = form.get(code);
                String val = v == null ? "" : v.toString().trim();
                if (Boolean.TRUE.equals(f.get("required")) && val.isEmpty()) {
                    return "请填写「" + label + "」";
                }
                if (val.isEmpty()) continue;
                if ("phone".equals(type) && !val.matches(PHONE_RE)) return "「" + label + "」格式不正确(11位大陆手机号)";
                if ("creditCode".equals(type) && !val.matches(CREDIT_CODE_RE)) return "「" + label + "」须为18位合法统一社会信用代码";
                if ("number".equals(type)) {
                    try {
                        double d = Double.parseDouble(val);
                        if (f.get("min") != null && d < ((Number) f.get("min")).doubleValue()) return "「" + label + "」不能小于 " + f.get("min");
                        if (f.get("max") != null && d > ((Number) f.get("max")).doubleValue()) return "「" + label + "」不能大于 " + f.get("max");
                    } catch (NumberFormatException e) {
                        return "「" + label + "」必须是数字";
                    }
                }
            }
        }
        return null;
    }
 
    /** 预填系统带出字段(roster/task/server)与手机号 */
    private void fillPrefill(CjFillTask task, Map<String, Object> ctx, Map<String, Object> form) {
        CjStudent s = (CjStudent) ctx.get("student");
        Map<String, Object> schema = parseSchema(task.getSchemaJson());
        Object groupsObj = schema.get("groups");
        if (groupsObj instanceof List) {
            for (Object go : (List<?>) groupsObj) {
                Map<String, Object> g = (Map<String, Object>) go;
                Object fs = g.get("fields");
                if (!(fs instanceof List)) continue;
                for (Object fo : (List<?>) fs) {
                    Map<String, Object> f = (Map<String, Object>) fo;
                    String code = str(f.get("code"));
                    String auto = str(f.get("auto"));
                    if ("roster".equals(auto)) {
                        String val = rosterValue(code, ctx);
                        if (val != null) form.put(code, val);
                    } else if ("task".equals(auto)) {
                        form.put(code, termText(task));
                    } else if ("server".equals(auto)) {
                        form.put(code, "");
                    } else if ("studentPhone".equals(code) && s.getPhone() != null && !s.getPhone().isEmpty()) {
                        form.putIfAbsent(code, s.getPhone());
                    }
                }
            }
        }
    }
 
    private String rosterValue(String code, Map<String, Object> ctx) {
        CjStudent s = (CjStudent) ctx.get("student");
        switch (code) {
            case "collegeName": return (String) ctx.get("collegeName");
            case "majorName": return (String) ctx.get("majorName");
            case "className": return (String) ctx.get("className");
            case "studentName": return s.getName();
            case "studentNo": return s.getStudentNo();
            case "gender": return genderText(s.getGender());
            default: return null;
        }
    }
 
    private Map<String, Object> studentContext(Long sid) {
        Map<String, Object> res = new LinkedHashMap<>();
        CjStudent s = studentMapper.selectCjStudentByStudentId(sid);
        res.put("student", s);
        if (s == null) return res;
        Long cid = s.getClassId();
        CjClass cl = cid == null ? null : classMapper.selectCjClassByClassId(cid);
        if (cl != null) {
            res.put("classId", cl.getClassId());
            res.put("className", cl.getClassName());
            CjMajor m = cl.getMajorId() == null ? null : majorMapper.selectCjMajorByMajorId(cl.getMajorId());
            if (m != null) {
                res.put("majorName", m.getMajorName());
                CjCollege col = m.getCollegeId() == null ? null : collegeMapper.selectCjCollegeByCollegeId(m.getCollegeId());
                res.put("collegeName", col == null ? "" : col.getCollegeName());
            } else {
                res.put("majorName", "");
                res.put("collegeName", "");
            }
        }
        // 前端 view(去重自 studentContext 的直接字段)
        Map<String, Object> view = new LinkedHashMap<>();
        view.put("studentId", s.getStudentId());
        view.put("studentNo", s.getStudentNo());
        view.put("name", s.getName());
        view.put("genderText", genderText(s.getGender()));
        view.put("phone", s.getPhone());
        view.put("classId", res.get("classId"));
        view.put("className", res.get("className"));
        view.put("majorName", res.get("majorName"));
        view.put("collegeName", res.get("collegeName"));
        res.put("view", view);
        return res;
    }
 
    private boolean inScope(CjFillTask task, Long myClassId) {
        String scope = task.getScopeJson();
        if (scope == null) return false;
        try {
            Map<String, Object> root = om.readValue(scope, new TypeReference<Map<String, Object>>() { });
            Object ids = root.get("classIds");
            if (ids instanceof List) {
                for (Object o : (List<?>) ids) {
                    if (((Number) o).longValue() == myClassId.longValue()) return true;
                }
            }
        } catch (Exception ignore) { }
        return false;
    }
 
    private boolean isOpen(CjFillTask task) {
        if (!"1".equals(task.getStatus())) return false;
        if (task.getFillStartDate() == null || task.getFillEndDate() == null) return false;
        LocalDate today = LocalDate.now();
        LocalDate start = task.getFillStartDate().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
        LocalDate end = task.getFillEndDate().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
        return !today.isBefore(start) && !today.isAfter(end);
    }
 
    private Map<String, Object> parseSchema(String schemaJson) {
        Map<String, Object> res = new LinkedHashMap<>();
        res.put("groups", new ArrayList<>());
        res.put("attachments", new ArrayList<>());
        if (schemaJson == null) return res;
        try {
            Map<String, Object> root = om.readValue(schemaJson, new TypeReference<Map<String, Object>>() { });
            if (root.get("groups") != null) res.put("groups", root.get("groups"));
            if (root.get("attachments") != null) res.put("attachments", root.get("attachments"));
        } catch (Exception ignore) { }
        return res;
    }
 
    private String termText(CjFillTask t) {
        StringBuilder sb = new StringBuilder();
        if (t.getSchoolYear() != null) sb.append(t.getSchoolYear());
        sb.append("学年");
        if ("1".equals(t.getSemester())) sb.append("第一学期");
        else if ("2".equals(t.getSemester())) sb.append("第二学期");
        return sb.toString();
    }
 
    private String genderText(String code) {
        if (code == null) return "未知";
        switch (code.trim()) {
            case "0": case "男": return "男";
            case "1": case "女": return "女";
            default: return "未知";
        }
    }
 
    private String orBlank(Object o) { return o == null ? "" : o.toString(); }
 
    /** H5 稳定 openid:对设备码取 MD5,保证同一浏览器身份不变 */
    private String md5Hex(String s) {
        try {
            java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
            byte[] d = md.digest(s.getBytes(java.nio.charset.StandardCharsets.UTF_8));
            StringBuilder sb = new StringBuilder();
            for (byte b : d) { String h = Integer.toHexString(0xff & b); if (h.length() == 1) sb.append('0'); sb.append(h); }
            return sb.toString();
        } catch (Exception e) {
            return Integer.toHexString(s.hashCode());
        }
    }
 
    private String fmtDate(Date d) {
        if (d == null) return null;
        java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
        return sdf.format(d);
    }
    private String fmtTime(Date d) {
        if (d == null) return null;
        java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm");
        return sdf.format(d);
    }
 
    @SuppressWarnings("unchecked")
    private Map<String, Object> castMap(Object o) {
        if (o instanceof Map) return (Map<String, Object>) o;
        throw new ServiceException("form 参数格式错误");
    }
    private Long longOf(Object o) {
        if (o == null) return null;
        return Long.valueOf(o.toString());
    }
    private String str(Object o) { return o == null ? null : o.toString(); }
 
    /** 图片/文件字段的值:路径数组或逗号拼接字符串 → 非空路径列表 */
    private List<String> fileList(Object v) {
        List<String> out = new ArrayList<>();
        if (v instanceof List) {
            for (Object o : (List<?>) v) {
                String s = str(o);
                if (s != null && !s.trim().isEmpty()) out.add(s.trim());
            }
        } else if (v != null) {
            for (String s : v.toString().split(",")) {
                if (!s.trim().isEmpty()) out.add(s.trim());
            }
        }
        return out;
    }
 
    private String code2session(String code) {
        try {
            String url = "https://api.weixin.qq.com/sns/jscode2session?appid=" + wxAppid
                    + "&secret=" + wxSecret + "&js_code=" + code + "&grant_type=authorization_code";
            HttpClient client = HttpClient.newBuilder().connectTimeout(java.time.Duration.ofSeconds(5)).build();
            HttpResponse<String> resp = client.send(HttpRequest.newBuilder(URI.create(url)).GET().build(),
                    HttpResponse.BodyHandlers.ofString());
            Map<String, Object> json = om.readValue(resp.body(), Map.class);
            if (json.get("openid") != null) return (String) json.get("openid");
            log.warn("code2session 失败: {}", json.get("errmsg"));
            return null;
        } catch (Exception e) {
            log.error("调用微信 code2session 异常", e);
            return null;
        }
    }
 
    private Long resolveStudentId(String auth) {
        if (auth == null || !auth.startsWith("Bearer ")) return null;
        Object val = redisCache.getCacheObject(TOKEN_PREFIX + auth.substring(7));
        if (val instanceof Long) {
            Long sid = (Long) val;
            return sid > 0 ? sid : null;
        }
        return null;
    }
 
    /** 从登录态 token 读取“未绑定时暂存的 openid”,用于绑定时关联微信身份 */
    private String openidOfToken(String auth) {
        if (auth == null || !auth.startsWith("Bearer ")) return null;
        Object val = redisCache.getCacheObject(OPENID_PREFIX + auth.substring(7));
        return val == null ? null : val.toString();
    }
}