diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/controller/StudentTaskController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/controller/StudentTaskController.java new file mode 100644 index 0000000..37a003e --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/controller/StudentTaskController.java @@ -0,0 +1,56 @@ +package cn.tianbaotong.fill.module.fill.controller; + +import cn.tianbaotong.fill.common.api.ApiResult; +import cn.tianbaotong.fill.module.fill.dto.StudentDtos; +import cn.tianbaotong.fill.module.fill.service.StudentTaskService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 学生端的"我的任务 / 撤回 / 进度"(《03-接口设计》§9.1、§9.11、§9.12)。 + * + *

单独一个 Controller(而不是塞进 {@code FillController}): + * 这三个接口的授权口径与填报不同 —— 填报要"已过门禁", + * 而"我的任务"恰恰是还没进门禁的人也要能看到(他得知道自己被邀请过没有)。 + * 混在一个类里,类级注解就会把两种口径搅在一起。 + */ +@Tag(name = "小程序/H5 · 我的任务与进度") +@RestController +@RequestMapping("/api/v1/mp") +@RequiredArgsConstructor +public class StudentTaskController { + + private final StudentTaskService studentTaskService; + + @GetMapping("/tasks") + @Operation(summary = "我的任务列表", + description = "**可见范围 = 名册里绑定了我**(名册就是学生的任务清单)。" + + "canFillNow 与 fillBlockReason 由服务端算好 —— " + + "前端不要自己按任务状态判能不能填(还要考虑截止时间与审核中)") + public ApiResult myTasks() { + return ApiResult.ok(studentTaskService.myTasks()); + } + + @PostMapping("/submissions/{id}/withdraw") + @Operation(summary = "撤回提交(回到草稿)", + description = "只有「等待初审 / 等待终审」的能撤;已通过要管理员操作(40031)。" + + "撤回会**同时把名册行的状态退回草稿**(否则班级看板会一直显示他已提交)") + public ApiResult withdraw(@PathVariable Long id) { + return ApiResult.ok(studentTaskService.withdraw(id)); + } + + @GetMapping("/submissions/{id}/progress") + @Operation(summary = "进度与问题清单", + description = "progressPercent 按**必传材料已交齐的比例**算;" + + "materials 每项带 issueLevel(0无/1提示/2警告/3阻断)与 issueText;" + + "timeline 只列**确实发生过**的节点(编一条假时间线会让学生以为系统记错了)") + public ApiResult progress(@PathVariable Long id) { + return ApiResult.ok(studentTaskService.progress(id)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/StudentDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/StudentDtos.java new file mode 100644 index 0000000..215f235 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/StudentDtos.java @@ -0,0 +1,183 @@ +package cn.tianbaotong.fill.module.fill.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 学生端"我的任务 / 撤回 / 进度"(《03-接口设计》§9.1、§9.11、§9.12)。 + */ +public final class StudentDtos { + + private StudentDtos() { + } + + // ── §9.1 我的任务 ───────────────────────────── + + /** 一个任务。 */ + @Getter + @Setter + @Schema(description = "我的任务") + public static class MyTaskVO { + + private Long taskId; + + private String taskCode; + + private String taskName; + + private String batchNo; + + private String majorName; + + private String jobName; + + private Integer collectMode; + + private Integer certType; + + /** 任务状态:{@code 0} 草稿 / {@code 1} 收集中 / {@code 2} 已截止 / {@code 3} 已归档 / {@code 4} 暂停 */ + private Integer taskStatus; + + /** 能不能现在填(收集中 + 未提交)。**服务端算好**,前端不自己判 */ + private Boolean canFillNow; + + /** 为什么不能填(收集中以外的状态都要说清) */ + private String fillBlockReason; + + private LocalDateTime startTime; + + private LocalDateTime endTime; + + /** 距截止还有几天(负数 = 已过期);无截止时间为 null */ + private Integer remainDays; + + private Long rosterId; + + /** 填报单 id(还没开始时为 null)—— 撤回与看进度都要用它,所以列表里一并带上 */ + private Long submissionId; + + private Long orgId; + + private String orgName; + + private String occupation; + + private String worktype; + + private String jobLevel; + + /** 填报单状态:{@code null} = 还没开始 / {@code 0} 草稿 / {@code 1} 待初审 / {@code 2} 待终审 / {@code 3} 已通过 / {@code 4} 已驳回 */ + private Integer submissionStatus; + + private String statusText; + + private LocalDateTime submitTime; + + private Integer redCount; + + private Integer yellowCount; + + private Integer rejectCount; + + /** 被驳回的原因(学生最需要看到的那句话) */ + private String lastRejectReason; + + /** 能不能撤回(待初审/待终审时可撤;已终审要管理员操作) */ + private Boolean canWithdraw; + } + + /** 我的任务列表。 */ + @Getter + @Setter + public static class MyTaskPageVO { + + private List list = new ArrayList<>(); + + private long total; + + /** 还没交的任务数(首页红点用) */ + private long todoCount; + + private long doneCount; + } + + // ── §9.11 撤回 ──────────────────────────────── + + /** 撤回结果。 */ + @Getter + @Setter + public static class WithdrawVO { + + private Integer status; + + private String statusText; + + private LocalDateTime withdrawnAt; + } + + // ── §9.12 进度与问题清单 ────────────────────── + + /** 一个材料的进度。 */ + @Getter + @Setter + public static class MaterialProgress { + + private String materialCode; + + private String materialName; + + private Boolean required; + + private Boolean done; + + private Integer fileCount; + + /** 这一项最严重的问题级别:{@code 0} 无 / {@code 1} 提示 / {@code 2} 警告 / {@code 3} 阻断 */ + private Integer issueLevel; + + private String issueText; + } + + /** 时间轴一条。 */ + @Getter + @Setter + public static class ProgressEvent { + + private LocalDateTime time; + + private String title; + + private String desc; + } + + /** 进度。 */ + @Getter + @Setter + public static class ProgressVO { + + private Long submissionId; + + private Integer status; + + private String statusText; + + /** 形如 {@code 75%}(按"必传材料已交齐的比例"算,不是按字段数) */ + private String progressPercent; + + private List materials = new ArrayList<>(); + + private List timeline = new ArrayList<>(); + + /** 还能不能改(草稿/已驳回 = 能改;待审/已通过 = 要撤回) */ + private Boolean canEdit; + + private Boolean canWithdraw; + + private Boolean canSubmit; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/mapper/StudentTaskMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/mapper/StudentTaskMapper.java new file mode 100644 index 0000000..845182c --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/mapper/StudentTaskMapper.java @@ -0,0 +1,182 @@ +package cn.tianbaotong.fill.module.fill.mapper; + +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * 学生端"我的任务"查询(《03-接口设计》§9.1)。 + * + *

为什么要单独一条 SQL:任务的可见范围对学生不是"租户内全部", + * 而是"名册里绑定了我的那些任务" —— 换句话说, + * 名册就是学生的任务清单。用 {@code collect_task} 直接分页会把他 + * 没被邀请的任务也列出来,而那类任务他点进去只会撞门禁(40007)。 + * + *

显式带 {@code tenant_id} 并 {@code @InterceptorIgnore}:与其它手写 SQL 同一口径, + * 避免"拦截器补一份、我又写一份"两边不一致。 + */ +public interface StudentTaskMapper { + + /** 一条"我的任务"。 */ + class MyTaskRow { + private Long taskId; + private String taskCode; + private String taskName; + private String batchNo; + private String majorName; + private String jobName; + private Integer collectMode; + private Integer certType; + private Integer taskStatus; + private java.time.LocalDateTime startTime; + private java.time.LocalDateTime endTime; + private Long rosterId; + private Long orgId; + private String orgName; + private String occupation; + private String worktype; + private String jobLevel; + private Integer rosterSubmitStatus; + private Long submissionId; + private Integer submissionStatus; + private java.time.LocalDateTime submitTime; + private Integer redCount; + private Integer yellowCount; + /** 被驳回次数 —— 学生端要显示"老师让你改第几次了" */ + private Integer rejectCount; + private String lastRejectReason; + + public Long getTaskId() { return taskId; } + public void setTaskId(Long v) { this.taskId = v; } + public String getTaskCode() { return taskCode; } + public void setTaskCode(String v) { this.taskCode = v; } + public String getTaskName() { return taskName; } + public void setTaskName(String v) { this.taskName = v; } + public String getBatchNo() { return batchNo; } + public void setBatchNo(String v) { this.batchNo = v; } + public String getMajorName() { return majorName; } + public void setMajorName(String v) { this.majorName = v; } + public String getJobName() { return jobName; } + public void setJobName(String v) { this.jobName = v; } + public Integer getCollectMode() { return collectMode; } + public void setCollectMode(Integer v) { this.collectMode = v; } + public Integer getCertType() { return certType; } + public void setCertType(Integer v) { this.certType = v; } + public Integer getTaskStatus() { return taskStatus; } + public void setTaskStatus(Integer v) { this.taskStatus = v; } + public java.time.LocalDateTime getStartTime() { return startTime; } + public void setStartTime(java.time.LocalDateTime v) { this.startTime = v; } + public java.time.LocalDateTime getEndTime() { return endTime; } + public void setEndTime(java.time.LocalDateTime v) { this.endTime = v; } + public Long getRosterId() { return rosterId; } + public void setRosterId(Long v) { this.rosterId = v; } + public Long getOrgId() { return orgId; } + public void setOrgId(Long v) { this.orgId = v; } + public String getOrgName() { return orgName; } + public void setOrgName(String v) { this.orgName = v; } + public String getOccupation() { return occupation; } + public void setOccupation(String v) { this.occupation = v; } + public String getWorktype() { return worktype; } + public void setWorktype(String v) { this.worktype = v; } + public String getJobLevel() { return jobLevel; } + public void setJobLevel(String v) { this.jobLevel = v; } + public Integer getRosterSubmitStatus() { return rosterSubmitStatus; } + public void setRosterSubmitStatus(Integer v) { this.rosterSubmitStatus = v; } + public Long getSubmissionId() { return submissionId; } + public void setSubmissionId(Long v) { this.submissionId = v; } + public Integer getSubmissionStatus() { return submissionStatus; } + public void setSubmissionStatus(Integer v) { this.submissionStatus = v; } + public java.time.LocalDateTime getSubmitTime() { return submitTime; } + public void setSubmitTime(java.time.LocalDateTime v) { this.submitTime = v; } + public Integer getRedCount() { return redCount; } + public void setRedCount(Integer v) { this.redCount = v; } + public Integer getYellowCount() { return yellowCount; } + public void setYellowCount(Integer v) { this.yellowCount = v; } + public Integer getRejectCount() { return rejectCount; } + public void setRejectCount(Integer v) { this.rejectCount = v; } + public String getLastRejectReason() { return lastRejectReason; } + public void setLastRejectReason(String v) { this.lastRejectReason = v; } + } + + /** + * 我的任务列表:名册里绑定了我、且任务已经发布过的。 + * + *

排序按"还能填的排前面、快要截止的再往前":学生打开这一屏最想知道的是 + * 我还有什么没交,而不是"历史上我参加过什么"。 + * 已在 SQL 里排好,前端不用再排(前端排的话口径迟早和别处不一致)。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT t.id AS taskId, + t.task_code AS taskCode, + t.task_name AS taskName, + t.batch_no AS batchNo, + t.major_name AS majorName, + t.job_name AS jobName, + t.collect_mode AS collectMode, + t.cert_type AS certType, + t.status AS taskStatus, + t.start_time AS startTime, + t.end_time AS endTime, + r.id AS rosterId, + r.org_id AS orgId, + o.org_name AS orgName, + COALESCE(s.occupation, r.occupation) AS occupation, + COALESCE(s.worktype, r.worktype) AS worktype, + COALESCE(s.job_level, r.job_level) AS jobLevel, + r.submit_status AS rosterSubmitStatus, + s.id AS submissionId, + s.status AS submissionStatus, + s.submit_time AS submitTime, + s.red_count AS redCount, + s.yellow_count AS yellowCount, + s.reject_count AS rejectCount, + s.last_reject_reason AS lastRejectReason + FROM collect_roster r + JOIN collect_task t ON t.id = r.task_id AND t.deleted = 0 AND t.tenant_id = r.tenant_id + LEFT JOIN sys_org o ON o.id = r.org_id AND o.deleted = 0 + LEFT JOIN collect_submission s ON s.roster_id = r.id AND s.deleted = 0 + WHERE r.tenant_id = #{tenantId} AND r.bind_user_id = #{uid} AND r.deleted = 0 + AND t.status <> 0 + ORDER BY + -- ① 还能填的(收集中的)排最前 + CASE WHEN t.status = 1 THEN 0 ELSE 1 END, + -- ② 还没交的比已交的靠前 + CASE WHEN s.id IS NULL OR s.status IN (0, 4) THEN 0 ELSE 1 END, + -- ③ 截止时间近的靠前(null 排最后) + t.end_time NULLS LAST, + t.id DESC + """) + List listMyTasks(@Param("tenantId") Long tenantId, @Param("uid") Long uid); + + /** + * 取学生名下某个任务的提交单(撤回/进度都要先确认"这份是不是他的")。 + * + *

按 {@code submitter_id} 过滤而不是只按 id:越权访问别人的填报单 + * 是这类接口最容易漏的一步 —— 前端传来的 id 是可以随便改的。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * FROM collect_submission + WHERE tenant_id = #{tenantId} AND id = #{id} AND submitter_id = #{uid} AND deleted = 0 + """) + cn.tianbaotong.fill.module.fill.entity.CollectSubmission selectMine( + @Param("tenantId") Long tenantId, @Param("id") Long id, @Param("uid") Long uid); + + /** + * 某个填报单下**当前生效**的文件(按材料码分组的计数用)。 + * + *

只取 {@code is_current = 1}:学生重传过的那份旧文件不算"已交", + * 否则他会看到"身份证已交"而实际上库里只有废件。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * FROM collect_file + WHERE tenant_id = #{tenantId} AND submission_id = #{submissionId} + AND is_current = 1 AND deleted = 0 + """) + List listMyFiles( + @Param("tenantId") Long tenantId, @Param("submissionId") Long submissionId); +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/StudentTaskService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/StudentTaskService.java new file mode 100644 index 0000000..afa42a5 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/StudentTaskService.java @@ -0,0 +1,424 @@ +package cn.tianbaotong.fill.module.fill.service; + +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.context.UserContext; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.module.fill.dto.StudentDtos; +import cn.tianbaotong.fill.module.fill.dto.SubmitDtos; +import cn.tianbaotong.fill.module.fill.entity.CollectSubmission; +import cn.tianbaotong.fill.module.fill.mapper.StudentTaskMapper; +import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper; +import cn.tianbaotong.fill.module.material.dto.MaterialDefVO; +import cn.tianbaotong.fill.module.material.service.MaterialDefService; +import cn.tianbaotong.fill.module.roster.entity.CollectRoster; +import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper; +import cn.tianbaotong.fill.module.task.entity.CollectTask; +import cn.tianbaotong.fill.module.task.mapper.TaskMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 学生端的"我的任务 / 撤回 / 进度"(《03-接口设计》§9.1、§9.11、§9.12)。 + * + *

三件必须守住的事

+ * + *
    + *
  1. 任务的可见范围 = 名册绑定关系,不是"租户内全部任务"。 + * 学生只该看到"老师把我加进名单的任务" —— 别的任务他点进去也只会撞门禁。
  2. + *
  3. 每次操作都要验"这份是不是他的"。前端传来的 submissionId 可以随便改, + * 所以查询一律带 {@code submitter_id}(见 {@code StudentTaskMapper.selectMine})。
  4. + *
  5. 已终审通过的单不能自己撤回({@code 40031}):那份材料可能已经交付了, + * 要改必须走管理员。这条与"已导出不可撤"({@code 40033})是两道不同的闸门。
  6. + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class StudentTaskService { + + private final StudentTaskMapper studentTaskMapper; + private final SubmissionMapper submissionMapper; + private final CollectRosterMapper rosterMapper; + private final TaskMapper taskMapper; + private final MaterialDefService materialDefService; + private final SubmissionValidator validator; + private final FillService fillService; + + // ── §9.1 我的任务 ───────────────────────────── + + public StudentDtos.MyTaskPageVO myTasks() { + Long tenantId = UserContext.tenantId(); + Long uid = UserContext.uid(); + + List rows = studentTaskMapper.listMyTasks(tenantId, uid); + List list = new ArrayList<>(); + long todo = 0; + long done = 0; + LocalDateTime now = LocalDateTime.now(); + + for (StudentTaskMapper.MyTaskRow r : rows) { + StudentDtos.MyTaskVO vo = new StudentDtos.MyTaskVO(); + vo.setTaskId(r.getTaskId()); + vo.setTaskCode(r.getTaskCode()); + vo.setTaskName(r.getTaskName()); + vo.setBatchNo(r.getBatchNo()); + vo.setMajorName(r.getMajorName()); + vo.setJobName(r.getJobName()); + vo.setCollectMode(r.getCollectMode()); + vo.setCertType(r.getCertType()); + vo.setTaskStatus(r.getTaskStatus()); + vo.setStartTime(r.getStartTime()); + vo.setEndTime(r.getEndTime()); + vo.setRosterId(r.getRosterId()); + vo.setSubmissionId(r.getSubmissionId()); + vo.setOrgId(r.getOrgId()); + vo.setOrgName(r.getOrgName()); + vo.setOccupation(r.getOccupation()); + vo.setWorktype(r.getWorktype()); + vo.setJobLevel(r.getJobLevel()); + vo.setSubmissionStatus(r.getSubmissionStatus()); + vo.setSubmitTime(r.getSubmitTime()); + vo.setRedCount(r.getRedCount() == null ? 0 : r.getRedCount()); + vo.setYellowCount(r.getYellowCount() == null ? 0 : r.getYellowCount()); + vo.setRejectCount(r.getRejectCount() == null ? 0 : r.getRejectCount()); + vo.setLastRejectReason(r.getLastRejectReason()); + vo.setStatusText(statusText(r.getSubmissionStatus(), r.getTaskStatus())); + + if (r.getEndTime() != null) { + vo.setRemainDays((int) Duration.between(now, r.getEndTime()).toDays()); + } + + // 能不能现在填:任务在收集期 + 还没在审 + 还有时间 + boolean collecting = r.getTaskStatus() != null && r.getTaskStatus() == CollectTask.STATUS_COLLECTING; + boolean expired = r.getEndTime() != null && r.getEndTime().isBefore(now); + Integer st = r.getSubmissionStatus(); + boolean underReview = st != null && (st == 1 || st == 2 || st == 3); + vo.setCanFillNow(collecting && !expired && !underReview); + vo.setFillBlockReason(fillBlockReason(r.getTaskStatus(), expired, st)); + // 可撤回:待初审 / 待终审;已通过不行(40031),草稿与已驳回没有"撤回"这回事 + vo.setCanWithdraw(st != null && (st == 1 || st == 2)); + + if (st == null || st == 0 || st == 4) { + todo++; + } else { + done++; + } + list.add(vo); + } + + StudentDtos.MyTaskPageVO out = new StudentDtos.MyTaskPageVO(); + out.setList(list); + out.setTotal(list.size()); + out.setTodoCount(todo); + out.setDoneCount(done); + return out; + } + + private String statusText(Integer submissionStatus, Integer taskStatus) { + if (submissionStatus == null || submissionStatus == 0) { + // 还没建草稿:状态取决于任务本身 + return switch (taskStatus == null ? 0 : taskStatus) { + case CollectTask.STATUS_COLLECTING -> "还没开始填"; + case CollectTask.STATUS_PAUSED -> "已暂停(老师暂时关了填报)"; + case CollectTask.STATUS_CLOSED -> "已截止,你没有提交"; + case CollectTask.STATUS_ARCHIVED -> "已归档"; + default -> "未开始"; + }; + } + return switch (submissionStatus) { + case 1 -> "已提交,等待班主任初审"; + case 2 -> "初审已过,等待管理员终审"; + case 3 -> "已通过"; + case 4 -> "被驳回,请修改后重新提交"; + default -> "未知"; + }; + } + + private String fillBlockReason(Integer taskStatus, boolean expired, Integer submissionStatus) { + if (taskStatus == null) { + return null; + } + if (taskStatus == CollectTask.STATUS_PAUSED) { + return "老师暂时暂停了填报,稍后再来"; + } + if (taskStatus == CollectTask.STATUS_CLOSED) { + return "本次收集已截止,如需补交请联系班主任"; + } + if (taskStatus == CollectTask.STATUS_ARCHIVED) { + return "本次认定已归档,不能再修改"; + } + if (expired) { + return "已过填报截止时间,如需补交请联系班主任"; + } + if (submissionStatus != null && submissionStatus == 3) { + return "已经通过审核了,无需再改"; + } + if (submissionStatus != null && (submissionStatus == 1 || submissionStatus == 2)) { + return "已经提交,等待老师审核;要改请先撤回"; + } + return null; + } + + // ── §9.11 撤回 ──────────────────────────────── + + /** + * 撤回提交(回到草稿)。 + * + *

约束(契约 §9.11):{@code status ∈ {1, 2}} 可撤 —— + * 也就是"还在审"的单子。{@code status = 3}(已通过)要管理员操作({@code 40031})。 + * + *

为什么已通过不能自己撤:那份材料可能已经进了导出包或在机构那边流转, + * 学生手一滑就撤回会让"已交付的数据"和"系统里显示的状态"对不上。 + * + *

撤回会**同时把名册行的提交状态退回草稿** —— 否则班主任的班级看板会一直 + * 显示这个学生"已提交",而实际上他正在改。这两个状态必须同步(同一次事务)。 + */ + @Transactional(rollbackFor = Exception.class) + public StudentDtos.WithdrawVO withdraw(Long id) { + CollectSubmission s = requireMine(id); + Integer st = s.getStatus(); + if (st != null && st == 3) { + throw new BizException(ErrorCode.FINAL_APPROVED_NO_WITHDRAW); + } + if (st == null || (st != 1 && st != 2)) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "这份申请当前不能撤回(只有「等待审核」的才能撤回)"); + } + + LocalDateTime now = LocalDateTime.now(); + submissionMapper.update(null, Wrappers.lambdaUpdate() + .eq(CollectSubmission::getId, id) + .set(CollectSubmission::getStatus, 0) + .set(CollectSubmission::getSubmitTime, null) + .set(CollectSubmission::getClassAuditBy, null) + .set(CollectSubmission::getClassAuditTime, null) + .set(CollectSubmission::getAdminAuditBy, null) + .set(CollectSubmission::getAdminAuditTime, null)); + + if (s.getRosterId() != null) { + rosterMapper.update(null, Wrappers.lambdaUpdate() + .eq(CollectRoster::getId, s.getRosterId()) + .set(CollectRoster::getSubmitStatus, CollectRoster.SUBMIT_DRAFT)); + } + + log.info("学生撤回提交:submission={} 原状态={} 名册行={}", id, st, s.getRosterId()); + + StudentDtos.WithdrawVO vo = new StudentDtos.WithdrawVO(); + vo.setStatus(0); + vo.setStatusText("已撤回,可以继续修改后重新提交"); + vo.setWithdrawnAt(now); + return vo; + } + + // ── §9.12 进度与问题清单 ────────────────────── + + /** + * 进度(§9.12)。 + * + *

百分比按必传材料已交齐的比例算,不按字段数: + * 学生关心的"我还差什么"就是材料 —— 字段缺了会在提交清单里逐条列出来, + * 而材料缺了是实打实交不上去的。 + */ + public StudentDtos.ProgressVO progress(Long id) { + CollectSubmission s = requireMine(id); + CollectTask task = taskMapper.selectById(s.getTaskId()); + CollectRoster roster = s.getRosterId() == null ? null : rosterMapper.selectById(s.getRosterId()); + + List issues = validator.validate(s, task, roster); + List defs = materialDefService.list(s.getTaskId()); + + // ★ 条件可见性必须在这里也求一次值: + // 「工作证明」这类材料的 visibleWhen 是 is_student=false, + // 在校生根本不该被要求交 —— 但它的 required 仍是 1。 + // 不判可见性的话,分母里会永远多一项,进度**永远到不了 100%**, + // 而学生看到一个永远不满的进度条只会以为"我是不是还差什么"。 + // (E2E 第一版就是这样:交齐了显示 75%。) + Map values = formValuesOf(task, roster, s); + + // 每个材料项:是否必传 / 是否已交 / 有什么问题 + Map> issueByCode = new LinkedHashMap<>(); + Map fileCountByCode = new LinkedHashMap<>(); + collectFileCounts(s, fileCountByCode); + + for (SubmitDtos.Issue i : issues) { + for (String code : materialCodesOf(i)) { + issueByCode.computeIfAbsent(code, k -> new ArrayList<>()).add(i); + } + } + + List mats = new ArrayList<>(); + int requiredTotal = 0; + int requiredDone = 0; + for (MaterialDefVO def : defs) { + // 本次不适用的材料(条件不成立)不进清单、也不进分母 + if (def.getVisibleWhen() != null && !SubmissionValidator.visible(def.getVisibleWhen(), values)) { + continue; + } + StudentDtos.MaterialProgress m = new StudentDtos.MaterialProgress(); + m.setMaterialCode(def.getMaterialCode()); + m.setMaterialName(def.getMaterialName()); + m.setRequired(def.getRequired()); + int cnt = fileCountByCode.getOrDefault(def.getMaterialCode(), 0); + m.setFileCount(cnt); + m.setDone(cnt > 0); + + List mine = issueByCode.getOrDefault(def.getMaterialCode(), List.of()); + int worst = 0; + String text = null; + for (SubmitDtos.Issue i : mine) { + if (i.severity() > worst) { + worst = i.severity(); + text = i.title(); + } + } + // 还没交的必传材料:本身就是阻断级,即使校验问题没带上材料码也要标出来 + if (Boolean.TRUE.equals(def.getRequired()) && cnt == 0 && worst < SubmitDtos.Issue.ERROR) { + worst = SubmitDtos.Issue.ERROR; + text = "缺少必传材料"; + } + m.setIssueLevel(worst); + m.setIssueText(text); + + if (Boolean.TRUE.equals(def.getRequired())) { + requiredTotal++; + if (cnt > 0) { + requiredDone++; + } + } + mats.add(m); + } + + StudentDtos.ProgressVO vo = new StudentDtos.ProgressVO(); + vo.setSubmissionId(id); + vo.setStatus(s.getStatus()); + vo.setStatusText(statusText(s.getStatus(), task == null ? null : task.getStatus())); + int percent = requiredTotal == 0 ? (mats.isEmpty() ? 0 : 100) + : (int) Math.round(100.0 * requiredDone / requiredTotal); + vo.setProgressPercent(percent + "%"); + vo.setMaterials(mats); + vo.setTimeline(timelineOf(s)); + Integer st = s.getStatus(); + vo.setCanEdit(st == null || st == 0 || st == 4); + vo.setCanWithdraw(st != null && (st == 1 || st == 2)); + vo.setCanSubmit(st == null || st == 0 || st == 4); + return vo; + } + + /** + * 把校验问题归到某个材料项。 + * + *

⚠️ {@code SubmissionValidator} 加"缺材料"那条问题时**没有**在 {@code extra} 里带 + * {@code materialCode}(只写在文案里),所以这里还要按文案里的材料名反查一次。 + * 只认 {@code extra} 的话,"缺少必传材料:工作证明"会挂不到任何一项上 —— + * 表现是界面上那一项什么问题都不显示。**这是本轮 E2E 抓到的**(F-21)。 + */ + private List materialCodesOf(SubmitDtos.Issue i) { + List out = new ArrayList<>(); + if (i.extra() != null) { + Object v = i.extra().get("materialCode"); + if (v != null) { + out.add(String.valueOf(v)); + } + } + if (out.isEmpty() && i.title() != null) { + for (String[] codeName : MATERIAL_NAMES) { + if (i.title().contains(codeName[1])) { + out.add(codeName[0]); + } + } + } + return out; + } + + /** 材料码 → 材料名(反查文案用;与 {@code MaterialCatalog} 的内置目录同口径)。 */ + private static final List MATERIAL_NAMES = List.of( + new String[]{"PHOTO1", "一寸照片"}, + new String[]{"IDCARD", "身份证"}, + new String[]{"SCHOOL_ROLL", "学籍表"}, + new String[]{"WORK_PROOF", "工作证明"}, + new String[]{"XJW_BA", "学籍网备案表"}, + new String[]{"OTHER_PROOF", "其它佐证材料"}); + + /** 表单当前值(求条件可见性用)—— 与学生看到的、校验用的**同一份装配结果**。 */ + private Map formValuesOf(CollectTask task, CollectRoster roster, CollectSubmission s) { + Map values = new LinkedHashMap<>(); + try { + var form = fillService.assembleFor(task, roster, s); + for (var sec : form.getSections()) { + for (var f : sec.getFields()) { + values.put(f.getKey(), f.getValue()); + } + } + } catch (RuntimeException e) { + // 装配不了不是致命错误:退回"全部材料都算适用"(fail-open), + // 学生最多看到一个偏保守的进度条,而不是整页报错 + log.warn("装配表单失败(进度页将按「所有材料都适用」计算):submission={}", s.getId(), e); + } + return values; + } + + /** 当前生效的文件数(按材料码分组)。 */ + private void collectFileCounts(CollectSubmission s, Map out) { + var files = studentTaskMapper.listMyFiles(UserContext.tenantId(), s.getId()); + for (var f : files) { + out.merge(f.getMaterialCode(), 1, Integer::sum); + } + } + + /** + * 时间轴:创建 → 提交 → 初审 → 终审 → 驳回。 + * + *

只写"确实发生过"的节点:没提交就不显示"已提交"。 + * 编一条假时间线(比如把创建时间当提交时间)会让学生以为系统记错了。 + */ + private List timelineOf(CollectSubmission s) { + List out = new ArrayList<>(); + if (s.getCreateTime() != null) { + out.add(event(s.getCreateTime(), "创建草稿", null)); + } + if (s.getSubmitTime() != null) { + out.add(event(s.getSubmitTime(), "已提交", "等待班主任初审")); + } + if (s.getClassAuditTime() != null) { + out.add(event(s.getClassAuditTime(), "班主任初审通过", "等待管理员终审")); + } + if (s.getAdminAuditTime() != null) { + out.add(event(s.getAdminAuditTime(), "审核通过", "本次认定材料已收妥")); + } + if (s.getStatus() != null && s.getStatus() == 4 && s.getLastRejectReason() != null) { + // 驳回没有单独的时间列(口径见 collect_submission 的注释:本轮审核状态在 *_audit_time, + // 历史在 collect_audit_record)。这里用"最后一次提交之后"来近似展示, + // 并如实说明这是驳回原因而不是凭空造一个时间。 + out.add(event(LocalDateTime.now(), "被驳回", s.getLastRejectReason())); + } + out.sort((a, b) -> b.getTime().compareTo(a.getTime())); + return out; + } + + private StudentDtos.ProgressEvent event(LocalDateTime t, String title, String desc) { + StudentDtos.ProgressEvent e = new StudentDtos.ProgressEvent(); + e.setTime(t); + e.setTitle(title); + e.setDesc(desc); + return e; + } + + /** 取"我的"填报单;不是他的一律当作不存在(不透露"它存在但不属于你")。 */ + private CollectSubmission requireMine(Long id) { + CollectSubmission s = studentTaskMapper.selectMine(UserContext.tenantId(), id, UserContext.uid()); + if (s == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或不属于你"); + } + return s; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpLoginController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpLoginController.java new file mode 100644 index 0000000..dcb6946 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpLoginController.java @@ -0,0 +1,71 @@ +package cn.tianbaotong.fill.module.mp.controller; + +import cn.tianbaotong.fill.common.api.ApiResult; +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.context.LoginUser; +import cn.tianbaotong.fill.common.context.UserContext; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.module.mp.service.MpLoginService; +import cn.tianbaotong.fill.security.PublicApi; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 小程序端登录与手机号绑定(《03-接口设计》§5.3)。 + * + *

与 H5 授权登录的关系

+ * + *

两者换的是同一个 openid 体系,共同的部分(找/建用户、授学生角色) + * **复用** {@code MpH5AuthService.resolveUser} —— 不复制一份, + * 否则"建学生账号"会有两个实现,而它们迟早分叉。 + * 小程序端多出来的只有两件事:静默换 openid(`jscode2session`)与拿手机号。 + */ +@Tag(name = "小程序端登录") +@RestController +@RequestMapping("/api/v1/mp") +@RequiredArgsConstructor +public class MpLoginController { + + private final MpLoginService loginService; + + @PublicApi + @PostMapping("/login") + @Operation(summary = "小程序 code 换 session(§5.3)", + description = "`{code, scene}` → 令牌 + `needBindPhone` + `boundRoster`。" + + "⚠️ **scene 是必需的**:它决定你属于哪个学校(统一应用 + scene 识别租户)。" + + "scene 可穷举,所以这里**不返回任何业务数据**,只返回" + + "「需不需要绑手机」与「你在这次任务里有没有绑定名册」") + public ApiResult login(@RequestBody(required = false) Map body) { + String code = str(body == null ? null : body.get("code")); + String scene = str(body == null ? null : body.get("scene")); + return ApiResult.ok(loginService.login(code, scene)); + } + + @PostMapping("/bind-phone") + @Operation(summary = "微信手机号快捷绑定(§5.3)", + description = "小程序调 `wx.getPhoneNumber` 拿到的是 **code**(不是手机号)," + + "我们用 access_token 去微信换真正的手机号 —— " + + "**手机号从来没经过客户端**,这也是它能作为绑定凭据的原因。" + + "⚠️ 一个手机号只能绑一个账号:绑两个会让「按手机号找人」指向不确定的那一个") + public ApiResult bindPhone( + @RequestBody(required = false) Map body) { + LoginUser me = UserContext.require(); + String code = str(body == null ? null : body.get("code")); + if (code == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "缺少 code(wx.getPhoneNumber 返回的那个)"); + } + return ApiResult.ok(loginService.bindPhone(me.uid(), me.tenantId(), code)); + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpTeacherDashboardController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpTeacherDashboardController.java new file mode 100644 index 0000000..fc41cb7 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpTeacherDashboardController.java @@ -0,0 +1,40 @@ +package cn.tianbaotong.fill.module.mp.controller; + +import cn.tianbaotong.fill.common.api.ApiResult; +import cn.tianbaotong.fill.module.mp.dto.TeacherDtos; +import cn.tianbaotong.fill.module.mp.service.TeacherDashboardService; +import cn.tianbaotong.fill.security.PermissionDef; +import cn.tianbaotong.fill.security.RequirePerm; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 班主任端看板(《03-接口设计》§10.1)。 + * + *

权限用 {@code task:view}(与"能不能看到这些班"一致)—— + * 范围由 SQL 按"我负责的班"限定,权限码只管"他是不是老师这一类人"。 + * 这两层缺一不可:只查权限,全校老师都能看到全校数字。 + */ +@Tag(name = "班主任端 · 看板") +@RestController +@RequestMapping("/api/v1/mp/teacher") +@RequiredArgsConstructor +public class MpTeacherDashboardController { + + private final TeacherDashboardService teacherDashboardService; + + @GetMapping("/dashboard") + @RequirePerm(PermissionDef.TASK_VIEW) + @Operation(summary = "班主任看板", + description = "范围只算**我负责的班**(指定初审人 → 副班主任 → 班级负责人三级回退)。" + + "⚠️ **submitRate 是已提交率,不是通过率**,与 approvedCount 分开呈现 —— " + + "契约 §10.1 的口径铁律:这两件事要做的动作完全不同(催报 vs 看材料)") + public ApiResult dashboard(@RequestParam(required = false) Long taskId) { + return ApiResult.ok(teacherDashboardService.dashboard(taskId)); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpTeacherProxyController.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpTeacherProxyController.java new file mode 100644 index 0000000..9a79e54 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpTeacherProxyController.java @@ -0,0 +1,137 @@ +package cn.tianbaotong.fill.module.mp.controller; + +import cn.tianbaotong.fill.common.api.ApiResult; +import cn.tianbaotong.fill.module.file.entity.CollectFile; +import cn.tianbaotong.fill.module.file.service.MaterialFileService; +import cn.tianbaotong.fill.module.mp.dto.TeacherDtos; +import cn.tianbaotong.fill.module.mp.service.TeacherProxyService; +import cn.tianbaotong.fill.module.roster.entity.CollectRoster; +import cn.tianbaotong.fill.security.PermissionDef; +import cn.tianbaotong.fill.security.RequirePerm; +import cn.tianbaotong.fill.support.WebUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 班主任端代传与文件访问(《03-接口设计》§10.6、§4.6)。 + * + *

为什么代传是 multipart 一步,而不是契约写的"先传文件再传 fileId"

+ * + *

契约 §10.6 的请求体是 {@code { submissionId, materialCode, fileId }}, + * 也就是"班主任先走通用上传通道把文件传上来,拿到 fileId 再挂到学生名下"。 + * 那条路有两个具体问题: + *

    + *
  1. 中间态会留下孤儿:第一步与第二步之间断了(网络、手滑关页面), + * 文件已经在库里但没有归属,既不属于任何学生、也没人知道它是干什么的。 + * 这类垃圾会一直堆在存储里,而且**没有任何界面能看到它们**。
  2. + *
  3. "通用上传通道"目前不存在:学生上传接口({@code /mp/files/upload}) + * 是按 {@code submitter_id} 授权的,班主任用它只能传到自己的名下 —— + * 要支持契约那条路,得先做一个"教师专用上传"接口, + * 而它本质上就是"上传但先不归属",等于把一个中间态变成正式概念。
  4. + *
+ * + *

所以这里做成一步 multipart:{@code submissionId + materialCode + file + proxyReason}。 + * 对外行为与契约一致(响应里有 {@code fileId} / {@code proxyBy} / {@code proxyName}), + * 只是不需要调用方先做一次无归属上传。这条差异记在 F-39。 + * + *

文件访问:为什么教师端要单独一个接口

+ * + *

学生端的 {@code /mp/files/{id}/preview} 按 {@code submitter_id} 授权, + * 班主任点开学生的材料必然 403。而班主任必须能看到自己班学生的材料 + * (他要在手机上看清这张身份证拍清楚没有)。所以这里按班级范围授权, + * 与代传、催报、看板用同一套三级回退。 + */ +@Tag(name = "班主任端 · 代传与文件") +@RestController +@RequestMapping("/api/v1/mp/teacher") +@RequiredArgsConstructor +public class MpTeacherProxyController { + + private final TeacherProxyService proxyService; + private final MaterialFileService fileService; + + @PostMapping(value = "/proxy-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @RequirePerm(PermissionDef.FILE_PROXY_UPLOAD) + @Operation(summary = "班主任代传材料", + description = "一步上传(不用先做一次无归属上传,见类注释)。" + + "三道闸门:**只给自已负责的班**、**只给草稿状态的填报单**、" + + "**必须留痕**(`source=2` + 代传人 + 原因 + 操作日志),且学生端可见。" + + "规范化走与学生上传完全相同的那套 —— 否则代传来的材料会过不了导出前置检查") + public ApiResult proxyUpload( + @RequestParam Long submissionId, + @RequestParam String materialCode, + @RequestParam("file") MultipartFile file, + @RequestParam(required = false) String proxyReason, + HttpServletRequest req) throws IOException { + TeacherDtos.ProxyUploadVO vo = proxyService.proxyUpload( + submissionId, materialCode, file.getOriginalFilename(), + file.getBytes(), proxyReason, WebUtil.clientIp(req)); + return ApiResult.ok(vo); + } + + @GetMapping("/submissions/{id}/proxy-context") + @RequirePerm(PermissionDef.FILE_PROXY_UPLOAD) + @Operation(summary = "代传前的上下文(我要替谁传)", + description = "返回学生姓名/班级/可否代传。**界面上必须一直显示对象是谁** —— " + + "老师一次帮几个学生传材料时,最容易出的错就是传给了上一个人") + public ApiResult> proxyContext(@PathVariable Long id) { + CollectRoster r = proxyService.rosterOf(id); + Map out = new LinkedHashMap<>(); + out.put("submissionId", id); + out.put("canProxy", proxyService.canProxy(id)); + if (r != null) { + out.put("rosterId", r.getId()); + out.put("realName", r.getRealName()); + out.put("idCardMask", r.getIdCardMask()); + out.put("orgId", r.getOrgId()); + } + return ApiResult.ok(out); + } + + /** + * 看学生的材料(预览 / 下载同一个人口)。 + * + *

{@code original=true} 取原始件 —— 原始件**默认不保留**(客户决定只交付规范件), + * 所以拿不到时要明确说"这份材料没有留原件",而不是给一个空白图。 + */ + @GetMapping("/files/{fileId}/preview") + @RequirePerm(PermissionDef.ROSTER_VIEW) + @Operation(summary = "班主任看学生的材料(按班级范围授权)", + description = "学生端那个接口按 `submitter_id` 授权,班主任点开必然 403;" + + "这里按**班级范围**(与代传、催报、看板同一套三级回退)。" + + "`original=true` 取原始件时注意:原始件默认不保留") + public ResponseEntity preview(@PathVariable Long fileId, + @RequestParam(defaultValue = "false") boolean original) + throws IOException { + CollectFile f = proxyService.requireMyStudentFile(fileId); + MaterialFileService.Content c = fileService.contentOf(f, original); + String encoded = URLEncoder.encode(c.name(), StandardCharsets.UTF_8).replace("+", "%20"); + // 预览要能直接在浏览器/小程序里显示,所以 Content-Type 按扩展名给 —— + // 给 application/octet-stream 会让"点开身份证"变成"下载一个文件" + MediaType type = c.name().toLowerCase().endsWith(".png") + ? MediaType.IMAGE_PNG : MediaType.IMAGE_JPEG; + return ResponseEntity.ok() + .contentType(type) + .header(HttpHeaders.CONTENT_DISPOSITION, + "inline; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded) + .body(c.bytes()); + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/dto/TeacherDtos.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/dto/TeacherDtos.java new file mode 100644 index 0000000..7f14086 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/dto/TeacherDtos.java @@ -0,0 +1,159 @@ +package cn.tianbaotong.fill.module.mp.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 班主任端(小程序)的看板与代传(《03-接口设计》§10.1、§10.6)。 + */ +public final class TeacherDtos { + + private TeacherDtos() { + } + + // ── §10.1 看板 ──────────────────────────────── + + /** 总览数字。 */ + @Getter + @Setter + @Schema(description = "班级看板总览") + public static class Summary { + + /** 我负责的班里的名册总人数 */ + private int rosterTotal; + + /** 已提交(含在审与已通过) */ + private int submittedCount; + + /** 终审通过 */ + private int approvedCount; + + /** 待我初审 */ + private int pendingCount; + + /** 还没交 */ + private int unsubmittedCount; + + /** 有红/黄问题的 */ + private int abnormalCount; + + /** + * ⚠️ 已提交率({@code submittedCount / rosterTotal}), + * 不是通过率。 + * + *

契约 §10.1 的口径铁律:{@code submitRate} 与 {@code approvedCount} + * 必须分开呈现(原型定稿如此)。合并成一个数字会让"我班上还有人没交" + * 和"我班上有人没过"变成同一件事,而这两件事要做的动作完全不同: + * 前者去催报,后者去看材料。 + */ + private String submitRate; + } + + /** 我负责的一个班。 */ + @Getter + @Setter + public static class OrgStat { + + private Long orgId; + + private String orgName; + + private int rosterTotal; + + private int submittedCount; + + private int approvedCount; + + private String submitRate; + + /** 我是这个班的**主**班主任(false = 副班主任或审核员) */ + private Boolean isHead; + + /** 待我初审的单数(卡片上的角标) */ + private int auditCount; + } + + /** 待办角标。 */ + @Getter + @Setter + public static class Todos { + + /** 待我初审 */ + private int auditCount; + + /** 有红黄问题待处理 */ + private int abnormalCount; + + /** 被驳回过、等学生改的(老师可以据此催一下) */ + private int rejectedAwaitingCount; + } + + /** 最近截止的任务。 */ + @Getter + @Setter + public static class Deadline { + + private Long taskId; + + private String taskName; + + private LocalDateTime endTime; + + /** 距截止还有几天(负数 = 已过) */ + private Integer remainDays; + } + + /** 看板。 */ + @Getter + @Setter + @Schema(description = "班主任看板") + public static class DashboardVO { + + private Summary summary = new Summary(); + + private List myOrgs = new ArrayList<>(); + + private Todos todos = new Todos(); + + private Deadline deadline; + } + + // ── §10.6 代传 ──────────────────────────────── + + /** 代传请求。 */ + @Getter + @Setter + @Schema(description = "班主任代传材料") + public static class ProxyUploadReq { + + private Long submissionId; + + private String materialCode; + + /** 已上传的文件 id(班主任先走学生端上传通道把文件传上来,再挂到这个学生名下) */ + private Long fileId; + + /** 代传原因(选填,但强烈建议写 —— 时间轴上会显示) */ + private String proxyReason; + } + + /** 代传结果。 */ + @Getter + @Setter + public static class ProxyUploadVO { + + private Long fileId; + + private Long proxyBy; + + private String proxyByName; + + /** 时间轴与材料列表上都会显示这句 */ + private String sourceText; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/MpH5Mapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/MpH5Mapper.java index 544735a..e4822e8 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/MpH5Mapper.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/MpH5Mapper.java @@ -6,6 +6,7 @@ import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; /** * H5 网页授权的用户查询/创建。 @@ -87,4 +88,41 @@ public interface MpH5Mapper { int insertUserRole(@Param("tenantId") Long tenantId, @Param("userId") Long userId, @Param("roleId") Long roleId); + + /** + * 按手机号 hash 找用户(**无租户上下文**时用:小程序登录/绑号时还没有登录态)。 + * + *

为什么按 hash 而不是明文:`sys_user` 里手机号存的是密文 + + * `phone_hash`(查询走它),与身份证号同一套做法。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * FROM sys_user + WHERE tenant_id = #{tenantId} AND phone_hash = #{phoneHash} AND deleted = 0 + LIMIT 1 + """) + SysUser selectByPhoneHash(@Param("tenantId") Long tenantId, + @Param("phoneHash") String phoneHash); + + /** 按 id 取用户(**无租户上下文**时用)。 */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT * FROM sys_user WHERE id = #{id} AND deleted = 0 + """) + SysUser selectByIdIgnoreTenant(@Param("id") Long id); + + /** + * 写手机号的三列(明文 + hash + 原样存一份用于展示?不 —— 只写明文与 hash)。 + * + *

⚠️ 与 `SensitiveCodec.applyPhone` 同口径:`phone` 存**明文**(客户要求能直接看到, + * 见该类的注释)、`phone_hash` 用于查询。两处必须一致,所以这里也写这两列 + `update_time`。 + */ + @InterceptorIgnore(tenantLine = "true") + @Update(""" + UPDATE sys_user + SET phone = #{phone}, phone_hash = #{phoneHash}, update_time = NOW() + WHERE id = #{id} AND deleted = 0 + """) + int updatePhone(@Param("id") Long id, @Param("phone") String phone, + @Param("phoneHash") String phoneHash); } diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/TeacherDashboardMapper.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/TeacherDashboardMapper.java new file mode 100644 index 0000000..99d2eed --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/TeacherDashboardMapper.java @@ -0,0 +1,225 @@ +package cn.tianbaotong.fill.module.mp.mapper; + +import com.baomidou.mybatisplus.annotation.InterceptorIgnore; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 班主任看板的只读查询(《03-接口设计》§10.1)。 + * + *

口径是这一屏最要紧的事,两条都写在 SQL 里而不是靠调用方记得: + * + *

    + *
  1. 只统计"我负责的班":范围条件与初审完全一致 + * (指定初审人 → 副班主任 → 班级负责人三级回退)。 + * 少一个条件,班主任就会看到全校数字,而他会以为那是自己班的。
  2. + *
  3. {@code submitted} 与 {@code approved} 分开数: + * 契约 §10.1 的口径铁律 —— 合并就会让"有人没交"和"有人没过" + * 变成同一个数字,而这两件事要做的动作完全不同。
  4. + *
+ */ +public interface TeacherDashboardMapper { + + /** 一个班的统计。 */ + class OrgStatRow { + private Long orgId; + private String orgName; + private Integer rosterTotal; + private Integer submittedCount; + private Integer approvedCount; + private Integer abnormalCount; + /** 这个班的初审人是不是我(false = 我是副班主任或靠班级负责人回退) */ + private Boolean isHead; + + public Long getOrgId() { return orgId; } + public void setOrgId(Long v) { this.orgId = v; } + public String getOrgName() { return orgName; } + public void setOrgName(String v) { this.orgName = v; } + public Integer getRosterTotal() { return rosterTotal; } + public void setRosterTotal(Integer v) { this.rosterTotal = v; } + public Integer getSubmittedCount() { return submittedCount; } + public void setSubmittedCount(Integer v) { this.submittedCount = v; } + public Integer getApprovedCount() { return approvedCount; } + public void setApprovedCount(Integer v) { this.approvedCount = v; } + public Integer getAbnormalCount() { return abnormalCount; } + public void setAbnormalCount(Integer v) { this.abnormalCount = v; } + public Boolean getIsHead() { return isHead; } + public void setIsHead(Boolean v) { this.isHead = v; } + } + + /** + * 我负责的每个班的统计(人数 / 已提交 / 已通过 / 有异常 / 我是不是主班主任)。 + * + *

{@code taskId} 为空时按所有任务汇总 —— 首页要的是"我现在的整体情况", + * 而不是某一个任务的。这时班级可能重复出现(同一个班在两个任务里都有), + * 所以用 {@code SUM} 聚合而不是逐任务分行。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + List myOrgStats(@Param("tenantId") Long tenantId, + @Param("taskId") Long taskId, + @Param("userId") Long userId); + + /** 待我初审的单数(与审核台同一范围口径)。 */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + int countPendingAudit(@Param("tenantId") Long tenantId, + @Param("taskId") Long taskId, + @Param("userId") Long userId); + + /** 被驳回过、还在等学生改的单数(老师据此去催一下)。 */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + int countRejectedAwaiting(@Param("tenantId") Long tenantId, + @Param("taskId") Long taskId, + @Param("userId") Long userId); + + /** + * 某一个班的待审数(看板卡片角标)。 + * + *

与 {@link #countPendingAudit} 同一范围口径,只是多一个 {@code org_id} 条件 —— + * 分开写而不是"取全部再在内存里筛",因为角标是**每张卡片都要**的, + * 拉全量再筛在几十个班时会变成明显的浪费。 + */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + + """) + int countPendingOfOrg(@Param("tenantId") Long tenantId, + @Param("taskId") Long taskId, + @Param("userId") Long userId, + @Param("orgId") Long orgId); + + /** 最近的截止时间:只看我还负责的、还在收集或暂停中的任务。 */ + @InterceptorIgnore(tenantLine = "true") + @Select(""" + SELECT t.id AS taskId, t.task_name AS taskName, t.end_time AS endTime + FROM collect_task t + WHERE t.tenant_id = #{tenantId} AND t.deleted = 0 + AND t.status IN (1, 4) + AND t.end_time IS NOT NULL + AND EXISTS ( + SELECT 1 FROM collect_task_org cto + LEFT JOIN sys_org o ON o.id = cto.org_id AND o.deleted = 0 + WHERE cto.task_id = t.id AND cto.status = 1 + AND (cto.head_user_id = #{userId} + OR cto.deputy_user_ids LIKE ('%,' || #{userId} || ',%') + OR (cto.head_user_id IS NULL AND o.leader_user_id = #{userId})) + ) + ORDER BY t.end_time ASC + LIMIT 1 + """) + List nearestDeadline(@Param("tenantId") Long tenantId, + @Param("userId") Long userId); + + /** 最近截止的任务。 */ + class DeadlineRow { + private Long taskId; + private String taskName; + private LocalDateTime endTime; + + public Long getTaskId() { return taskId; } + public void setTaskId(Long v) { this.taskId = v; } + public String getTaskName() { return taskName; } + public void setTaskName(String v) { this.taskName = v; } + public LocalDateTime getEndTime() { return endTime; } + public void setEndTime(LocalDateTime v) { this.endTime = v; } + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpLoginService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpLoginService.java new file mode 100644 index 0000000..db12b73 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpLoginService.java @@ -0,0 +1,315 @@ +package cn.tianbaotong.fill.module.mp.service; + +import cn.hutool.json.JSONObject; +import cn.hutool.json.JSONUtil; +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.config.FillProperties; +import cn.tianbaotong.fill.module.mp.mapper.MpH5Mapper; +import cn.tianbaotong.fill.module.qrcode.service.WxMiniProgramService; +import cn.tianbaotong.fill.module.roster.entity.CollectRoster; +import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper; +import cn.tianbaotong.fill.module.task.entity.CollectTask; +import cn.tianbaotong.fill.module.task.mapper.TaskMapper; +import cn.tianbaotong.fill.module.user.entity.SysUser; +import cn.tianbaotong.fill.security.JwtService; +import cn.tianbaotong.fill.support.SensitiveCodec; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 小程序端登录与手机号绑定(《03-接口设计》§5.3)。 + * + *

它与 H5 授权登录的区别(不是"另一个入口")

+ * + *

两者换的是**同一个 openid 体系**,但有两点不同: + *

+ * 共同的部分(找/建用户、授学生角色)**复用** {@code MpH5AuthService.resolveUser} —— + * 不复制一份,否则"建学生账号"这件事会有两个实现,而它们迟早分叉 + * (例如一边建了内置角色、另一边忘了)。 + * + *

⚠️ 租户从哪来

+ * + *

契约的请求体里只有 {@code code} 与 {@code scene},而 scene **可穷举** + * (`t{tenantId}-k{taskId}`)。所以:"用户属于哪个租户"必须从 scene 来, + * 而 scene 缺失时**明确报错**而不是"默认第一个租户" —— + * 后者会让 A 校的学生登进 B 校(而这在日志里看起来一切正常)。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MpLoginService { + + private static final String JSCODE2SESSION = "https://api.weixin.qq.com/sns/jscode2session"; + private static final String GET_PHONE = "https://api.weixin.qq.com/wxa/business/getuserphonenumber"; + + private final FillProperties props; + private final WxMiniProgramService wxService; + private final MpH5AuthService h5AuthService; + private final MpH5Mapper h5Mapper; + private final CollectRosterMapper rosterMapper; + private final TaskMapper taskMapper; + private final JwtService jwtService; + private final SensitiveCodec codec; + + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)).build(); + + // ── §5.3 登录 ───────────────────────────────── + + @Transactional(rollbackFor = Exception.class) + public LoginVO login(String code, String scene) { + if (code == null || code.isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 code"); + } + FillProperties.MiniProgram mp = props.miniProgram(); + if (mp == null || mp.appId() == null || mp.appId().isBlank()) { + throw new BizException(ErrorCode.SCOPE_NOT_SUPPORTED, + "没配置小程序(fill.mini-program.app-id),小程序登录暂不可用。" + + "开发时可以用 H5 的打桩登录 /mp/h5/stub-login"); + } + Scene s = Scene.parse(scene); + + String openId = code2Session(code); + SysUser user = h5AuthService.resolveUser(s.tenantId(), openId); + + LoginVO vo = new LoginVO(); + // 与 H5 登录同一形状(同一个 JwtService.Client.MP,有效期 7 天)—— + // 两条登录路径签出来的令牌必须完全同构,否则"小程序登录的用户" + // 与"H5 登录的用户"在后续接口里的表现会不一样 + vo.setToken(jwtService.issue( + new cn.tianbaotong.fill.common.context.LoginUser( + user.getId(), user.getTenantId(), List.of(), + java.util.Set.of("STUDENT"), 4), + cn.tianbaotong.fill.security.JwtService.Client.MP)); + vo.setExpireSeconds(604800); + // 手机号还没绑 → 前端要弹"一键绑定手机号"(契约的 needBindPhone) + vo.setNeedBindPhone(user.getPhone() == null || user.getPhone().isBlank()); + + Map u = new LinkedHashMap<>(); + u.put("id", user.getId()); + u.put("realName", user.getRealName()); + u.put("userType", user.getUserType()); + vo.setUser(u); + + // 扫的是"任务码"时,顺带把他在这份任务里的名册绑定带回去 —— + // 前端据此决定进"填报页"还是"先核验身份",省掉一次请求 + if (s.taskId() != null) { + CollectRoster r = rosterMapper.selectOne(Wrappers.lambdaQuery() + .eq(CollectRoster::getTaskId, s.taskId()) + .eq(CollectRoster::getBindUserId, user.getId()) + .last("limit 1")); + if (r != null) { + CollectTask t = taskMapper.selectById(s.taskId()); + Map br = new LinkedHashMap<>(); + br.put("taskId", s.taskId()); + br.put("taskName", t == null ? null : t.getTaskName()); + br.put("rosterId", r.getId()); + vo.setBoundRoster(br); + } + } + log.info("小程序登录:tenant={} uid={} taskId={} 需绑手机={}", + s.tenantId(), user.getId(), s.taskId(), vo.getNeedBindPhone()); + return vo; + } + + /** `jscode2session`:code 换 openid。 */ + private String code2Session(String code) { + FillProperties.MiniProgram mp = props.miniProgram(); + String url = JSCODE2SESSION + + "?appid=" + mp.appId() + + "&secret=" + mp.appSecret() + + "&js_code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) + + "&grant_type=authorization_code"; + JSONObject r = getJson(url); + String openId = r.getStr("openid"); + if (openId == null) { + // 微信的错误码要原样报出来(40013 是 appid 不对、40125 是 secret 不对、 + // 40029 是 code 无效/已用过)。只说"登录失败"会让对接人完全没法排查 + throw new BizException(ErrorCode.UNAUTHORIZED, + "微信登录失败(errcode=" + r.getInt("errcode") + "):" + r.getStr("errmsg") + + "。常见原因:40029 code 已被用过(一次性的)、" + + "40013 appid 不对、40125 appSecret 不对"); + } + return openId; + } + + // ── §5.3 绑手机号 ───────────────────────────── + + /** + * 微信手机号快捷绑定。 + * + *

流程:小程序调 {@code wx.getPhoneNumber} 拿到一个 **code**(不是手机号本身)→ + * 传给我们 → 我们用 access_token 去微信换真正的手机号。 + * 所以**手机号从来没经过客户端**,这也是它能作为绑定凭据的原因。 + * + *

⚠️ 绑定时要检查"这个手机号是否已被同租户的另一个账号绑定": + * 一个手机号绑两个学生会让后续"按手机号找人"(找回密码、短信通知) + * 指向不确定的那一个,而那时已经看不出是谁绑错了。 + */ + @Transactional(rollbackFor = Exception.class) + public BindPhoneVO bindPhone(Long userId, Long tenantId, String code) { + if (code == null || code.isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "缺少 code(wx.getPhoneNumber 返回的)"); + } + if (!wxService.configured()) { + throw new BizException(ErrorCode.SCOPE_NOT_SUPPORTED, + "没配置小程序,无法解出手机号"); + } + String accessToken = wxService.accessToken(); + JSONObject r = postJson(GET_PHONE + "?access_token=" + accessToken, + JSONUtil.createObj().set("code", code).toString()); + if (r.getInt("errcode") == null || r.getInt("errcode") != 0) { + throw new BizException(ErrorCode.UNAUTHORIZED, + "换取手机号失败(errcode=" + r.getInt("errcode") + "):" + r.getStr("errmsg") + + "。常见原因:code 已被用过(一次性的)、或用户拒绝授权"); + } + String phone = r.getJSONObject("phone_info") == null + ? null : r.getJSONObject("phone_info").getStr("purePhoneNumber"); + if (phone == null || phone.isBlank()) { + throw new BizException(ErrorCode.UNAUTHORIZED, "微信没有返回手机号"); + } + + String hash = codec.hash(phone); + SysUser other = h5Mapper.selectByPhoneHash(tenantId, hash); + if (other != null && !other.getId().equals(userId)) { + // 只说"已被绑定",不说是谁绑的 —— 那是别人的账号信息 + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "这个手机号已经绑定过另一个账号了。请用原账号登录,或联系管理员处理"); + } + + SysUser me = h5Mapper.selectByIdIgnoreTenant(userId); + if (me == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "用户不存在"); + } + // 三列一起写(密文 + hash + 明文手机号),与其它入口同口径 + h5Mapper.updatePhone(userId, phone, hash); + + BindPhoneVO vo = new BindPhoneVO(); + vo.setBound(true); + vo.setPhone(maskPhone(phone)); + log.info("小程序绑定手机号:tenant={} uid={} phone={}", tenantId, userId, maskPhone(phone)); + return vo; + } + + private static String maskPhone(String phone) { + return phone.length() < 8 ? "***" + : phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + + // ── HTTP ────────────────────────────────────── + + private JSONObject getJson(String url) { + try { + HttpResponse resp = http.send(HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(10)).GET().build(), + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + return JSONUtil.parseObj(resp.body()); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new BizException(ErrorCode.INTERNAL_ERROR, "调用微信接口失败:" + e.getMessage()); + } + } + + private JSONObject postJson(String url, String body) { + try { + HttpResponse resp = http.send(HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(10)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) + .build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + return JSONUtil.parseObj(resp.body()); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new BizException(ErrorCode.INTERNAL_ERROR, "调用微信接口失败:" + e.getMessage()); + } + } + + // ── scene 解析 ──────────────────────────────── + + /** + * scene 三段式:{@code t{tenantId}} / {@code t{tenantId}-k{taskId}} / + * {@code t{tenantId}-k{taskId}-o{orgId}}(微信限 32 字符)。 + * + *

与扫码落地页同一套格式 —— 格式定义只有一处,否则"码里的 scene"与 + * "登录时解析的 scene"会各有一套理解。 + */ + private record Scene(Long tenantId, Long taskId, Long orgId) { + + static Scene parse(String scene) { + if (scene == null || scene.isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "缺少 scene:它决定你属于哪个学校(统一应用 + scene 识别租户)," + + "请从学校/班主任发的二维码进入"); + } + Long tenantId = null; + Long taskId = null; + Long orgId = null; + for (String part : scene.trim().split("-")) { + if (part.startsWith("t")) { + tenantId = num(part.substring(1)); + } else if (part.startsWith("k")) { + taskId = num(part.substring(1)); + } else if (part.startsWith("o")) { + orgId = num(part.substring(1)); + } + } + if (tenantId == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "scene 里没有租户标识(应为 t{tenantId} 开头):" + scene); + } + return new Scene(tenantId, taskId, orgId); + } + + private static Long num(String s) { + try { + return Long.valueOf(s); + } catch (NumberFormatException e) { + return null; + } + } + } + + // ── 响应 ── + + @lombok.Getter + @lombok.Setter + public static class LoginVO { + private String token; + private Integer expireSeconds; + private Boolean needBindPhone; + private Map user; + private Map boundRoster; + } + + @lombok.Getter + @lombok.Setter + public static class BindPhoneVO { + private Boolean bound; + private String phone; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpRosterService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpRosterService.java index 1d2842f..737bddd 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpRosterService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpRosterService.java @@ -47,14 +47,37 @@ import java.util.Map; @Service public class MpRosterService { - /** 同 openid(这里用 uid,用户与 openid 一对一)每小时最多 10 次。 */ - private static final int USER_LIMIT_PER_HOUR = 10; + /** + * 同一个人每小时最多**失败** 10 次。 + * + *

⚠️ 计的是**失败**,不是请求数。正常学生最多填错一两次,10 次绰绰有余; + * 而"把成功也算进来"会让同一个学生重试几次之后就进不去(见下面 IP 的说明)。 + */ + private static final int USER_FAIL_LIMIT_PER_HOUR = 10; - /** 同 IP 每分钟最多 20 次。 */ - private static final int IP_LIMIT_PER_MINUTE = 20; + /** + * 同一个 IP 每分钟最多**失败** 20 次(防枚举)。 + * + *

⚠️ **只计失败**这件事是压测改出来的:教室里 60 个学生连同一个 WiFi + * (出口是同一个公网 IP),如果成功也计数,**后 40 个会被限流** —— + * 而那是首次落地第一分钟就会发生的事故。 + * + *

为什么只计失败就够:**枚举攻击产生的是失败,教室产生的是成功**。 + */ + private static final int IP_FAIL_LIMIT_PER_MINUTE = 20; + + /** + * 同一个 IP 每分钟最多**成功** 200 次。 + * + *

成功会返回该学生的姓名与预填数据,所以要防"拿这个接口批量取数据"。 + * 但限额必须给教室规模留足量:**一个 200 人的年级同时扫码也不该被挡**。 + */ + private static final int IP_OK_LIMIT_PER_MINUTE = 200; private static final String KEY_USER = "xt:mp:gate:user:"; private static final String KEY_IP = "xt:mp:gate:ip:"; + /** 成功侧单独一个键 —— 与失败分开,两者的额度互不占用。 */ + private static final String KEY_IP_OK = "xt:mp:gate:ipok:"; private final MpRosterMapper rosterMapper; private final StringRedisTemplate redis; @@ -74,7 +97,7 @@ public class MpRosterService { Long tenantId = me.tenantId(); Long userId = me.uid(); - rateLimit(userId, clientIp); + rateLimitBefore(userId, clientIp); String name = IdentityNormalizer.name(req.getRealName()); String idCard = IdentityNormalizer.idCard(req.getIdCardNo()); @@ -134,15 +157,25 @@ public class MpRosterService { } // 已绑定且就是自己 → 幂等返回成功(学生刷新页面不该看到吓人的提示) + recordOk(clientIp); return buildVo(row, tenantId); } // ── 内部 ────────────────────────────────────── - /** 统一失败提示:不区分是姓名错还是号码错。 */ + /** + * 统一失败提示:不区分是姓名错还是号码错。 + * + *

⚠️ **这里也是唯一计"失败次数"的出口**(防猜 / 防枚举的额度就靠它)。 + * 另外两条失败路径(`GATE_ALREADY_BOUND`、名册重复)**刻意不计**: + * 它们不是攻击特征 —— 前者是"两个人抢同一个名册项"(开学第一天几十人同时扫码时 + * 必然出现几例),后者是运维问题。把它们算进防枚举额度里, + * 会让整间教室在几秒内被限流,而**没有一个人做错了什么**。 + */ private BizException unifiedFail(Long userId, String ip, Long tenantId, Long taskId, String why) { log.warn("门禁未通过:user={} ip={} tenant={} task={} 原因={}(不记明文)", userId, ip, tenantId, taskId, why); + recordFail(userId, ip); return new BizException(ErrorCode.GATE_NOT_MATCHED); } @@ -154,26 +187,79 @@ public class MpRosterService { */ /** - * 限流:同 openid 10 次/小时、同 IP 20 次/分钟。 + * 限流(⚠️ 计的是**失败**,不是请求数 —— 见 {@link #rateLimitBefore})。 + * + *

同一个人 10 次失败/小时、同一个 IP 20 次失败/分钟、同一 IP 200 次成功/分钟。 + * 分开的理由:**枚举攻击产生失败,教室产生成功**。 * *

阈值调低是刻意的 —— 门禁限流是安全设计的一部分(防身份证号枚举探测), * 不是防 DDoS。正常学生最多填错一两次,10 次绰绰有余。 */ - private void rateLimit(Long userId, String ip) { - incrementOrReject(KEY_USER + userId, Duration.ofHours(1), USER_LIMIT_PER_HOUR); - if (ip != null && !ip.isBlank()) { - incrementOrReject(KEY_IP + ip, Duration.ofMinutes(1), IP_LIMIT_PER_MINUTE); + /** + * 进门之前先看"是不是已经试太多次了"。 + * + *

先查后记(而不是先记后查):这样第 N 次请求看到的是前 N-1 次的计数, + * 判定简单、也不会因为并发把额度用超一点点。 + */ + private void rateLimitBefore(Long userId, String ip) { + if (count(KEY_USER + userId) >= USER_FAIL_LIMIT_PER_HOUR) { + throw new BizException(ErrorCode.GATE_RATE_LIMITED, + "这个账号试得太多次了,请 " + waitMinutes(KEY_USER + userId) + + " 分钟后再试,或联系班主任核对姓名与证件号"); + } + if (ip == null || ip.isBlank()) { + return; + } + if (count(KEY_IP + ip) >= IP_FAIL_LIMIT_PER_MINUTE) { + // 这句话要能解释"为什么别人能进我不能" —— 教室里绝大部分人没做错任何事, + // 只是同一个 WiFi 下有人多试了几次 + throw new BizException(ErrorCode.GATE_RATE_LIMITED, + "当前网络下尝试核验的人较多,请稍等 1 分钟再试"); + } + if (count(KEY_IP_OK + ip) >= IP_OK_LIMIT_PER_MINUTE) { + throw new BizException(ErrorCode.GATE_RATE_LIMITED, + "当前网络访问过于集中,请稍等 1 分钟再试"); } } - private void incrementOrReject(String key, Duration ttl, int limit) { + /** 核验**失败**时调用(防猜 / 防枚举都靠它)。 */ + private void recordFail(Long userId, String ip) { + increment(KEY_USER + userId, Duration.ofHours(1)); + if (ip != null && !ip.isBlank()) { + increment(KEY_IP + ip, Duration.ofMinutes(1)); + } + } + + /** 核验**成功**时调用(只影响成功侧的兜底额度,不占用失败额度)。 */ + private void recordOk(String ip) { + if (ip != null && !ip.isBlank()) { + increment(KEY_IP_OK + ip, Duration.ofMinutes(1)); + } + } + + private long count(String key) { + String v = redis.opsForValue().get(key); + if (v == null) { + return 0L; + } + try { + return Long.parseLong(v); + } catch (NumberFormatException e) { + return 0L; + } + } + + /** 还要等几分钟(给用户一句能照着做的话,而不是"稍后再试")。 */ + private long waitMinutes(String key) { + Long ttl = redis.getExpire(key, java.util.concurrent.TimeUnit.MINUTES); + return ttl == null || ttl <= 0 ? 1 : ttl; + } + + private void increment(String key, Duration ttl) { Long count = redis.opsForValue().increment(key); if (count != null && count == 1L) { redis.expire(key, ttl); } - if (count != null && count > limit) { - throw new BizException(ErrorCode.GATE_RATE_LIMITED); - } } private RosterMatchVO buildVo(CollectRoster row, Long tenantId) { diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/TeacherDashboardService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/TeacherDashboardService.java new file mode 100644 index 0000000..f1fbe26 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/TeacherDashboardService.java @@ -0,0 +1,124 @@ +package cn.tianbaotong.fill.module.mp.service; + +import cn.tianbaotong.fill.common.context.UserContext; +import cn.tianbaotong.fill.module.mp.dto.TeacherDtos; +import cn.tianbaotong.fill.module.mp.mapper.TeacherDashboardMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 班主任看板(《03-接口设计》§10.1)。 + * + *

这一屏只有一条铁律,但它最容易被"优化"掉

+ * + *

{@code submitRate} 是已提交率(已提交 / 应填),与 {@code approvedCount} + * 分开呈现。契约 §10.1 写得很直白:原型定稿如此,接口不许合并。 + * + *

为什么不能合并成一个"完成率":这两件事要做的动作完全不同 —— + * 「还有人没交」要去催报,「有人没过」要去看材料。 + * 合成一个数字之后,老师看到 60% 不知道该催人还是该审材料, + * 而这个数字又会被当成"我的工作进度",于是两边都没做。 + * + *

另一个细节:范围只算"我负责的班"。范围口径与初审完全一致 + * (见 {@code TeacherDashboardMapper} 的 SQL), + * 少一个条件就会变成全校数字,而老师会以为那是自己班的。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TeacherDashboardService { + + private final TeacherDashboardMapper mapper; + + /** 看板。{@code taskId} 为空时按所有在办任务汇总。 */ + public TeacherDtos.DashboardVO dashboard(Long taskId) { + Long tenantId = UserContext.tenantId(); + Long uid = UserContext.uid(); + + List rows = mapper.myOrgStats(tenantId, taskId, uid); + + TeacherDtos.DashboardVO vo = new TeacherDtos.DashboardVO(); + TeacherDtos.Summary sum = vo.getSummary(); + List orgs = new ArrayList<>(); + + int rosterTotal = 0; + int submitted = 0; + int approved = 0; + int abnormal = 0; + + for (TeacherDashboardMapper.OrgStatRow r : rows) { + TeacherDtos.OrgStat o = new TeacherDtos.OrgStat(); + o.setOrgId(r.getOrgId()); + o.setOrgName(r.getOrgName()); + int total = nz(r.getRosterTotal()); + int sub = nz(r.getSubmittedCount()); + int app = nz(r.getApprovedCount()); + o.setRosterTotal(total); + o.setSubmittedCount(sub); + o.setApprovedCount(app); + o.setSubmitRate(rate(sub, total)); + o.setIsHead(Boolean.TRUE.equals(r.getIsHead())); + // 这个班的待审数单独取一次(卡片角标):聚合 SQL 里没有这一列, + // 而"取全量待办再在内存里筛"在几十个班时是明显浪费 + o.setAuditCount(mapper.countPendingOfOrg(tenantId, taskId, uid, r.getOrgId())); + + rosterTotal += total; + submitted += sub; + approved += app; + abnormal += nz(r.getAbnormalCount()); + orgs.add(o); + } + + sum.setRosterTotal(rosterTotal); + sum.setSubmittedCount(submitted); + sum.setApprovedCount(approved); + sum.setAbnormalCount(abnormal); + sum.setUnsubmittedCount(Math.max(0, rosterTotal - submitted)); + // ⚠️ 已提交率,不是通过率 —— 见类注释 + sum.setSubmitRate(rate(submitted, rosterTotal)); + vo.setMyOrgs(orgs); + + TeacherDtos.Todos todos = vo.getTodos(); + todos.setAuditCount(mapper.countPendingAudit(tenantId, taskId, uid)); + todos.setAbnormalCount(abnormal); + todos.setRejectedAwaitingCount(mapper.countRejectedAwaiting(tenantId, taskId, uid)); + + List dl = mapper.nearestDeadline(tenantId, uid); + if (!dl.isEmpty()) { + TeacherDashboardMapper.DeadlineRow d = dl.get(0); + TeacherDtos.Deadline deadline = new TeacherDtos.Deadline(); + deadline.setTaskId(d.getTaskId()); + deadline.setTaskName(d.getTaskName()); + deadline.setEndTime(d.getEndTime()); + deadline.setRemainDays(d.getEndTime() == null ? null + : (int) Duration.between(LocalDateTime.now(), d.getEndTime()).toDays()); + vo.setDeadline(deadline); + } + return vo; + } + + private int nz(Integer v) { + return v == null ? 0 : v; + } + + /** + * 百分比(保留 1 位)。 + * + *

分母为 0 时返回 {@code "—"} 而不是 {@code "0%"}: + * "一个班 0 人"与"一个班没人交"是完全不同的两件事, + * 而 0% 会让人以为后者。 + */ + private String rate(int part, int total) { + if (total <= 0) { + return "—"; + } + double v = Math.round(1000.0 * part / total) / 10.0; + return v + "%"; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/TeacherProxyService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/TeacherProxyService.java new file mode 100644 index 0000000..31f3a35 --- /dev/null +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/TeacherProxyService.java @@ -0,0 +1,253 @@ +package cn.tianbaotong.fill.module.mp.service; + +import cn.tianbaotong.fill.common.api.ErrorCode; +import cn.tianbaotong.fill.common.context.LoginUser; +import cn.tianbaotong.fill.common.context.UserContext; +import cn.tianbaotong.fill.common.exception.BizException; +import cn.tianbaotong.fill.module.audit.mapper.AuditScopeMapper; +import cn.tianbaotong.fill.module.file.entity.CollectFile; +import cn.tianbaotong.fill.module.file.mapper.FileMapper; +import cn.tianbaotong.fill.module.file.service.MaterialFileService; +import cn.tianbaotong.fill.module.log.service.OperationLogService; +import cn.tianbaotong.fill.module.mp.dto.TeacherDtos; +import cn.tianbaotong.fill.module.roster.entity.CollectRoster; +import cn.tianbaotong.fill.module.roster.mapper.CollectRosterMapper; +import cn.tianbaotong.fill.module.fill.entity.CollectSubmission; +import cn.tianbaotong.fill.module.fill.mapper.SubmissionMapper; +import cn.tianbaotong.fill.module.user.entity.SysUser; +import cn.tianbaotong.fill.module.user.mapper.UserMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 班主任代传材料(《03-接口设计》§10.6)。 + * + *

为什么要有这个功能

+ * + *

真实场景:学生不会弄(照片在家长手机里 / 不会压缩)、或者人不在。 + * 没有这条通道时,老师只能"让学生把照片发给自己,再让学生登录传上来" —— + * 而学生之所以要老师帮忙,往往就是因为他弄不了。 + * + *

三道必须守住的闸门

+ * + *
    + *
  1. 范围:只能给自己负责的班的学生代传。 + * 不校验的话,任何老师能给任何学生塞材料 —— 而那等于伪造认定材料。
  2. + *
  3. 状态:只能给草稿状态的填报单代传。 + * 已提交的单子塞材料会让"审到一半的材料清单"变了, + * 而审核人看的是他点开那一刻的内容。
  4. + *
  5. 留痕:{@code source = 2}(班主任代传)+ {@code proxy_by} + {@code proxy_reason} + * + 操作日志(契约 §10.6 明确要求"代传必须留痕",且学生端可见)。 + * 学生必须能知道"这份不是我传的,是李老师帮我传的" —— + * 否则他会在审核驳回时一头雾水("我什么时候传过这张照片?")。
  6. + *
+ * + *

为什么复用 {@code MaterialFileService.storeFile}

+ * + *

格式白名单、体积压缩、按模板改名、份数上限这些规则**必须与"学生自己传"完全一致** —— + * 代传来的材料如果绕过规范化,导出时就会出现不符合命名规范的文件, + * 而那正是导出前置检查(40035)要拦的东西。 + * 所以这里只负责"授权 + 留痕",规范化那一段走同一个方法。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class TeacherProxyService { + + private final SubmissionMapper submissionMapper; + private final CollectRosterMapper rosterMapper; + private final AuditScopeMapper auditScopeMapper; + private final MaterialFileService materialFileService; + private final FileMapper fileMapper; + private final UserMapper userMapper; + private final OperationLogService operationLog; + + /** + * 班主任代传一份材料。 + * + * @param proxyReason 代传原因(选填;会挂到审核时间轴上,学生也看得到) + */ + @Transactional(rollbackFor = Exception.class) + public TeacherDtos.ProxyUploadVO proxyUpload(Long submissionId, String materialCode, + String originalName, byte[] bytes, + String proxyReason, String clientIp) { + LoginUser me = UserContext.require(); + + CollectSubmission sub = submissionMapper.selectById(submissionId); + if (sub == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或已删除"); + } + if (!inMyScope(me, sub)) { + throw new BizException(ErrorCode.FORBIDDEN, "这个学生不在你负责的班级里"); + } + if (sub.getStatus() != null && sub.getStatus() != CollectSubmission.STATUS_DRAFT) { + throw new BizException(ErrorCode.AUDIT_IN_PROGRESS, + "这份填报单已经提交(或已审完),代传请先让学生撤回,或让管理员处理"); + } + if (materialCode == null || materialCode.isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请指定要代传的材料"); + } + + // ★ 走与学生上传**完全相同**的那套校验与规范化(见类注释) + Long fileId = materialFileService.storeFile(sub, materialCode, originalName, bytes, clientIp); + + // ★ 代传留痕:来源 + 谁 + 为什么(契约 §10.6) + String reason = proxyReason == null ? null + : (proxyReason.length() > 250 ? proxyReason.substring(0, 250) : proxyReason.trim()); + fileMapper.update(null, Wrappers.lambdaUpdate() + .eq(CollectFile::getId, fileId) + .set(CollectFile::getSource, CollectFile.SOURCE_PROXY_TEACHER) + .set(CollectFile::getProxyBy, me.uid()) + .set(CollectFile::getProxyReason, reason)); + + operationLog.record(OperationLogService.MODULE_FILE, + OperationLogService.ACTION_PROXY_UPLOAD, + "FILE", fileId, + null, materialCode, + "{\"submissionId\":" + submissionId + ",\"reason\":" + + (reason == null ? "null" : "\"" + reason.replace("\"", "'") + "\"") + "}"); + + SysUser teacher = userMapper.selectById(me.uid()); + String name = teacher == null || teacher.getRealName() == null + ? "老师" : teacher.getRealName(); + log.info("班主任代传:submission={} material={} file={} 代传人={} 原因={}", + submissionId, materialCode, fileId, me.uid(), reason); + + TeacherDtos.ProxyUploadVO vo = new TeacherDtos.ProxyUploadVO(); + vo.setFileId(fileId); + vo.setProxyBy(me.uid()); + vo.setProxyByName(name); + vo.setSourceText("由 " + name + " 老师代传"); + return vo; + } + + /** + * 范围判断:这个填报单所在的班归不归我。 + * + *

用与初审、看板、催报**同一套**三级回退(指定初审人 → 副班主任 → 班级负责人)。 + * 另写一套判断迟早会分叉,那时会出现"能催报但不能代传"这种说不清的组合。 + */ + private boolean inMyScope(LoginUser me, CollectSubmission sub) { + if (sub.getOrgId() == null) { + return false; + } + List mine = auditScopeMapper.classAuditOrgIds(me.tenantId(), sub.getTaskId(), me.uid()); + return mine.contains(sub.getOrgId()); + } + + /** 供详情页显示用:这个人是不是我负责的学生(前端据此决定要不要显示代传按钮)。 */ + public boolean canProxy(Long submissionId) { + LoginUser me = UserContext.require(); + CollectSubmission sub = submissionMapper.selectById(submissionId); + if (sub == null || !inMyScope(me, sub)) { + return false; + } + return sub.getStatus() == null || sub.getStatus() == CollectSubmission.STATUS_DRAFT; + } + + /** + * 取一个学生的名册信息(代传页面要显示"你正在替谁传")。 + * + *

刻意带上姓名与班级:代传是"替别人做事",界面上必须一直显示对象是谁 —— + * 老师一次帮几个学生传材料时,最容易出的错就是**传给了上一个人**。 + */ + public CollectRoster rosterOf(Long submissionId) { + LoginUser me = UserContext.require(); + CollectSubmission sub = submissionMapper.selectById(submissionId); + if (sub == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或已删除"); + } + if (!inMyScope(me, sub)) { + throw new BizException(ErrorCode.FORBIDDEN, "这个学生不在你负责的班级里"); + } + return sub.getRosterId() == null ? null : rosterMapper.selectById(sub.getRosterId()); + } + + /** + * PC 端(管理/审核角色)代传:范围是**整个租户**,不限于自己负责的班。 + * + *

为什么必须单独一个入口,而不是给 {@link #proxyUpload} 加个 boolean

+ * + *

加 boolean 的写法会让"这次到底校没校范围"变成调用点的一个参数 —— + * 而参数是会抄错的:新加一处调用,忘了传 true,就出现"任何老师能给任何学生塞材料"。 + * 两个入口各自把范围判定写在**自己里面**,调用点无从传错。 + * + *

范围放宽到整个租户仍然有边界:跨租户依旧不可能(租户拦截器挡着), + * 而且代传留痕(source / proxy_by / proxy_reason / 操作日志)一模一样 —— + * "管理员代传"反而更需要留痕,因为他能碰的学生更多。 + */ + @Transactional(rollbackFor = Exception.class) + public TeacherDtos.ProxyUploadVO proxyUploadAsAdmin(Long submissionId, String materialCode, + String originalName, byte[] bytes, + String proxyReason, String clientIp) { + LoginUser me = UserContext.require(); + CollectSubmission sub = submissionMapper.selectById(submissionId); + if (sub == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "填报单不存在或已删除"); + } + if (sub.getStatus() != null && sub.getStatus() != CollectSubmission.STATUS_DRAFT) { + throw new BizException(ErrorCode.AUDIT_IN_PROGRESS, + "这份填报单已经提交(或已审完),代传请先让学生撤回,或让管理员处理"); + } + if (materialCode == null || materialCode.isBlank()) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "请指定要代传的材料"); + } + Long fileId = materialFileService.storeFile(sub, materialCode, originalName, bytes, clientIp); + String reason = proxyReason == null ? null + : (proxyReason.length() > 250 ? proxyReason.substring(0, 250) : proxyReason.trim()); + // 来源记 3(管理员代传)而不是 2:审核时间轴上要能区分"班主任帮学生传的" + // 与"管理员替他传的" —— 这两件事的责任人不同 + fileMapper.update(null, Wrappers.lambdaUpdate() + .eq(CollectFile::getId, fileId) + .set(CollectFile::getSource, CollectFile.SOURCE_PROXY_ADMIN) + .set(CollectFile::getProxyBy, me.uid()) + .set(CollectFile::getProxyReason, reason)); + operationLog.record(OperationLogService.MODULE_FILE, + OperationLogService.ACTION_PROXY_UPLOAD, + "FILE", fileId, "ADMIN", materialCode, + "{\"submissionId\":" + submissionId + ",\"by\":\"ADMIN\"}"); + SysUser admin = userMapper.selectById(me.uid()); + String name = admin == null || admin.getRealName() == null ? "管理员" : admin.getRealName(); + log.info("管理端代传:submission={} material={} file={} 代传人={} 原因={}", + submissionId, materialCode, fileId, me.uid(), reason); + TeacherDtos.ProxyUploadVO vo = new TeacherDtos.ProxyUploadVO(); + vo.setFileId(fileId); + vo.setProxyBy(me.uid()); + vo.setProxyByName(name); + vo.setSourceText("由 " + name + " 代传"); + return vo; + } + + /** + * 取一份材料,并要求它属于**我负责的班**的学生。 + * + *

授权链是:文件 → 填报单 → 班级 → 是不是我负责的。每一跳都必须走, + * 少一跳就会出现"任何老师能看任何学生的身份证照片"。 + * + *

越权时用 {@code 40301 FORBIDDEN} 而不是"文件不存在": + * 这时用户确实该知道自己越权了;而"文件不存在"会让他以为数据丢了, + * 反复重试或者报障。 + */ + public CollectFile requireMyStudentFile(Long fileId) { + LoginUser me = UserContext.require(); + CollectFile f = fileMapper.selectById(fileId); + if (f == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, "文件不存在或已删除"); + } + CollectSubmission sub = f.getSubmissionId() == null + ? null : submissionMapper.selectById(f.getSubmissionId()); + if (sub == null) { + throw new BizException(ErrorCode.VALIDATION_BLOCKED, + "这份文件没有关联填报单,无法确认归属"); + } + if (!inMyScope(me, sub)) { + throw new BizException(ErrorCode.FORBIDDEN, "这个学生不在你负责的班级里"); + } + return f; + } +} diff --git a/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/JwtService.java b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/JwtService.java index 2625d55..5ce16d5 100644 --- a/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/JwtService.java +++ b/backend/xt-server/src/main/java/cn/tianbaotong/fill/security/JwtService.java @@ -62,11 +62,6 @@ public class JwtService { return client == Client.MP ? mpExpireSeconds : pcExpireSeconds; } - /** - * 签发 token。 - * - * @return {@code [token, 有效期秒数]} - */ public String issue(LoginUser user, Client client) { long exp = expireSecondsOf(client); Instant now = Instant.now(); diff --git a/backend/xt-server/src/main/resources/db/migration/V12__file_source_proxy.sql b/backend/xt-server/src/main/resources/db/migration/V12__file_source_proxy.sql new file mode 100644 index 0000000..c972327 --- /dev/null +++ b/backend/xt-server/src/main/resources/db/migration/V12__file_source_proxy.sql @@ -0,0 +1,42 @@ +-- ============================================================================ +-- V12:文件来源与代传留痕 +-- +-- 背景:`collect_file.source` 这一列**从来没有被代码显式写过** —— +-- 库里那 10 行 source=1 全部来自列默认值。也就是说: +-- 「这份材料是学生自己传的,还是班主任代传的」**当前在数据上完全区分不出来**。 +-- +-- 而契约 §10.6 明确要求: +-- > 代传必须留痕:文件记录 `source = 4`(代传),审核时间轴显示"由李老师代传"。学生端可见。 +-- +-- 一、source 的口径(**两处文档冲突,以 DDL 为准**) +-- --------------------------------------------------------------------------- +-- · PRD 的建表注释(`数据信息填报系统PRD.md` 第 1894 行): +-- `1 本人上传 2 班主任代传 3 管理员代传 4 整理器导入` +-- · 接口设计 §3.6 的字段表(第 464 行): +-- `1 聊天记录 2 相册 3 手机文件 4 代传` +-- +-- 两者都是"4 个值",但含义完全不同。以 **DDL 为准**,理由是: +-- ① DDL 是"这一列到底表示什么"的定义,字段表只是它的展示层描述; +-- ② 已有数据全部是 1(= 本人上传),与 DDL 口径一致; +-- ③ PC 端的 `FILE_SOURCE` 常量(`apis/xt/audit.ts`)已经按 DDL 写了 +-- `1 本人上传 / 2 班主任代传 / 3 管理员代传 / 4 整理器导入`, +-- 改成字段表口径要动前端 + 历史数据。 +-- 不一致这件事记在盘点文档的 F-38 里。 +-- +-- 二、为什么还要 proxy_by / proxy_reason +-- --------------------------------------------------------------------------- +-- `source = 2` 只说"是班主任代传的",但**说不出是哪个班主任、为什么**。 +-- 而代传恰恰是最需要留痕的动作之一:学生没传、老师替他传了, +-- 出了问题要能回答"谁在什么时候替他传的、他给的理由是什么"。 +-- 契约要求"审核时间轴显示'由李老师代传'"—— 名字只能存在这里。 +-- ============================================================================ + +ALTER TABLE collect_file ADD COLUMN IF NOT EXISTS proxy_by BIGINT; +ALTER TABLE collect_file ADD COLUMN IF NOT EXISTS proxy_reason VARCHAR(256); + +COMMENT ON COLUMN collect_file.source IS '1本人上传 2班主任代传 3管理员代传 4整理器导入(**以此为准**;接口设计 §3.6 的字段表口径与它冲突,见 F-38)'; +COMMENT ON COLUMN collect_file.proxy_by IS '代传人 user_id(source=2/3 时有值);审核时间轴据此显示「由某某老师代传」'; +COMMENT ON COLUMN collect_file.proxy_reason IS '代传原因(选填,但会挂到审核时间轴上给学生看到)'; + +-- 「谁代传的」是代传留痕的主要查法 +CREATE INDEX IF NOT EXISTS idx_file_proxy ON collect_file (tenant_id, proxy_by); diff --git a/frontend-mp/src/pages.json b/frontend-mp/src/pages.json index f1ee802..9901423 100644 --- a/frontend-mp/src/pages.json +++ b/frontend-mp/src/pages.json @@ -24,6 +24,65 @@ "navigationBarBackgroundColor": "#0052D9", "navigationBarTextStyle": "white" } + }, + { + "path": "pages/join/join", + "style": { + "navigationBarTitleText": "申请加入名单", + "navigationBarBackgroundColor": "#0052D9", + "navigationBarTextStyle": "white" + } + }, + { + "path": "pages/tasks/tasks", + "style": { + "navigationBarTitleText": "我的填报", + "navigationBarBackgroundColor": "#0052D9", + "navigationBarTextStyle": "white", + "enablePullDownRefresh": true + } + }, + { + "path": "pages/progress/progress", + "style": { + "navigationBarTitleText": "填报进度", + "navigationBarBackgroundColor": "#0052D9", + "navigationBarTextStyle": "white" + } + }, + { + "path": "pages/dashboard/dashboard", + "style": { + "navigationBarTitleText": "班级看板", + "navigationBarBackgroundColor": "#0052D9", + "navigationBarTextStyle": "white", + "enablePullDownRefresh": true + } + }, + { + "path": "pages/audit/audit", + "style": { + "navigationBarTitleText": "待我审核", + "navigationBarBackgroundColor": "#0052D9", + "navigationBarTextStyle": "white" + } + }, + { + "path": "pages/audit-detail/audit-detail", + "style": { + "navigationBarTitleText": "审核详情", + "navigationBarBackgroundColor": "#0052D9", + "navigationBarTextStyle": "white" + } + }, + { + "path": "pages/messages/messages", + "style": { + "navigationBarTitleText": "消息", + "navigationBarBackgroundColor": "#0052D9", + "navigationBarTextStyle": "white", + "enablePullDownRefresh": true + } } ], "globalStyle": { diff --git a/frontend-mp/src/pages/audit-detail/audit-detail.vue b/frontend-mp/src/pages/audit-detail/audit-detail.vue new file mode 100644 index 0000000..a91b84f --- /dev/null +++ b/frontend-mp/src/pages/audit-detail/audit-detail.vue @@ -0,0 +1,728 @@ +