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();
|
}
|
}
|