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 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 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 params) { return AjaxResult.error("该通道已停用,请通过班级二维码注册并使用账号密码登录"); } /** 旧微信登录实现(保留代码备小程序上线时改造,暂不接流量) */ private AjaxResult legacyWxLogin(Map 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 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 params, @RequestHeader(value = "Authorization", required = false) String auth) { return AjaxResult.error("该通道已停用,请通过班级二维码注册并使用账号密码登录"); } private AjaxResult legacyBind(Map 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 students = studentMapper.selectCjStudentList(qs); List 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 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 openClassIds = new java.util.HashSet<>(); CjFillTask q = new CjFillTask(); q.setStatus("1"); List tasks = taskMapper.selectCjFillTaskList(q); if (tasks != null) { for (CjFillTask t : tasks) { if ("1".equals(t.getRangeMode())) { openClassIds.addAll(scopeClassIds(t)); } } } List> out = new ArrayList<>(); if (!openClassIds.isEmpty()) { // 全量班级/专业/学院做名称映射 Map classMap = new LinkedHashMap<>(); List allClasses = classMapper.selectCjClassList(new CjClass()); if (allClasses != null) for (CjClass cl : allClasses) classMap.put(cl.getClassId(), cl); Map majorMap = new LinkedHashMap<>(); List allMajors = majorMapper.selectCjMajorList(new CjMajor()); if (allMajors != null) for (CjMajor m : allMajors) majorMap.put(m.getMajorId(), m); Map collegeMap = new LinkedHashMap<>(); List 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 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 params) { return AjaxResult.error("该通道已停用,请通过班级二维码注册"); } private AjaxResult legacyOpenRegister(Map 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 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 list = studentMapper.selectCjStudentList(q); return list.isEmpty() ? null : list.get(0); } private List scopeClassIds(CjFillTask task) { List ids = new ArrayList<>(); try { Map root = om.readValue(task.getScopeJson(), new TypeReference>() { }); 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 ctx = studentContext(sid); Object v = ctx.get("view"); if (v instanceof Map) { @SuppressWarnings("unchecked") Map view = (Map) 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 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 tasks = taskMapper.selectCjFillTaskList(q); List> 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 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 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 form = new LinkedHashMap<>(); fillPrefill(task, ctx, form); CjFillRecord rec = recordMapper.selectByTaskAndStudent(taskId, sid); Map 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 saved = om.readValue(rec.getFormData(), new TypeReference>() { }); 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 body, @RequestHeader("Authorization") String auth) { Long sid = resolveStudentId(auth); if (sid == null) return AjaxResult.error("未登录"); Long taskId = longOf(body.get("taskId")); Map 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 body, @RequestHeader("Authorization") String auth) { Long sid = resolveStudentId(auth); if (sid == null) return AjaxResult.error("未登录"); Long taskId = longOf(body.get("taskId")); Map 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 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 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 form) { if (taskId == null) throw new ServiceException("缺少 taskId"); CjFillTask task = taskMapper.selectCjFillTaskByTaskId(taskId); if (task == null) throw new ServiceException("任务不存在"); Map 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 form, String status, Date submitTime) { CjStudent s = studentMapper.selectCjStudentByStudentId(sid); Map 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 form) { Map schema = parseSchema(task.getSchemaJson()); Object groupsObj = schema.get("groups"); if (!(groupsObj instanceof List)) return "表单配置缺失"; for (Object go : (List) groupsObj) { Map g = (Map) go; Object fs = g.get("fields"); if (!(fs instanceof List)) continue; for (Object fo : (List) fs) { Map f = (Map) 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 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 ctx, Map form) { CjStudent s = (CjStudent) ctx.get("student"); Map schema = parseSchema(task.getSchemaJson()); Object groupsObj = schema.get("groups"); if (groupsObj instanceof List) { for (Object go : (List) groupsObj) { Map g = (Map) go; Object fs = g.get("fields"); if (!(fs instanceof List)) continue; for (Object fo : (List) fs) { Map f = (Map) 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 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 studentContext(Long sid) { Map 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 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 root = om.readValue(scope, new TypeReference>() { }); 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 parseSchema(String schemaJson) { Map res = new LinkedHashMap<>(); res.put("groups", new ArrayList<>()); res.put("attachments", new ArrayList<>()); if (schemaJson == null) return res; try { Map root = om.readValue(schemaJson, new TypeReference>() { }); 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 castMap(Object o) { if (o instanceof Map) return (Map) 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 fileList(Object v) { List 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 resp = client.send(HttpRequest.newBuilder(URI.create(url)).GET().build(), HttpResponse.BodyHandlers.ofString()); Map 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(); } }