From ad86e2d521db6e1bd5f8bdab59162f4369aa533d Mon Sep 17 00:00:00 2001
From: lovife <474239041@qq.com>
Date: Wed, 30 Sep 2026 09:02:52 +0800
Subject: [PATCH] =?UTF-8?q?feat(mp):=20=E5=AD=A6=E7=94=9F=E7=AB=AF?=
=?UTF-8?q?=E3=80=8C=E6=88=91=E7=9A=84=E5=A1=AB=E6=8A=A5=20/=20=E6=92=A4?=
=?UTF-8?q?=E5=9B=9E=20/=20=E8=BF=9B=E5=BA=A6=E3=80=8D=E4=B8=8E=E7=8F=AD?=
=?UTF-8?q?=E4=B8=BB=E4=BB=BB=E7=AB=AF=E7=9C=8B=E6=9D=BF=E3=80=81=E5=AE=A1?=
=?UTF-8?q?=E6=A0=B8=E5=8D=A1=E7=89=87=E6=B5=81=E3=80=81=E4=BB=A3=E4=BC=A0?=
=?UTF-8?q?=E6=9D=90=E6=96=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
学生端:待办数、状态、驳回原因、截止提醒、撤回;进度页的材料清单与进度条
分母用「这个人本次真的要交什么」(不适用的必传材料不进分母,否则永远填不满)。
班主任端:已交率与已通过分开呈现(已提交 ≠ 已通过)、右滑通过 / 左滑不绑驳回、
一键通过只带无问题项;代传走一步 multipart,三道闸门(只给自负责的班 / 只给草稿单 /
必须留痕且学生可见),V12 给 collect_file 补 proxy_by / proxy_reason。
验证:e2e-m3-mytask 42/42、e2e-m5-teacher-dashboard 24/24、e2e-m5-proxy 33/33;
H5 build:h5 产物含新增各页。
---
.../controller/StudentTaskController.java | 56 ++
.../fill/module/fill/dto/StudentDtos.java | 183 +++++
.../module/fill/mapper/StudentTaskMapper.java | 182 +++++
.../fill/service/StudentTaskService.java | 424 ++++++++++
.../mp/controller/MpLoginController.java | 71 ++
.../MpTeacherDashboardController.java | 40 +
.../controller/MpTeacherProxyController.java | 137 ++++
.../fill/module/mp/dto/TeacherDtos.java | 159 ++++
.../fill/module/mp/mapper/MpH5Mapper.java | 38 +
.../mp/mapper/TeacherDashboardMapper.java | 225 ++++++
.../module/mp/service/MpLoginService.java | 315 ++++++++
.../module/mp/service/MpRosterService.java | 116 ++-
.../mp/service/TeacherDashboardService.java | 124 +++
.../mp/service/TeacherProxyService.java | 253 ++++++
.../tianbaotong/fill/security/JwtService.java | 5 -
.../db/migration/V12__file_source_proxy.sql | 42 +
frontend-mp/src/pages.json | 59 ++
.../src/pages/audit-detail/audit-detail.vue | 728 ++++++++++++++++++
frontend-mp/src/pages/audit/audit.vue | 517 +++++++++++++
frontend-mp/src/pages/dashboard/dashboard.vue | 404 ++++++++++
frontend-mp/src/pages/index/index.vue | 16 +
frontend-mp/src/pages/progress/progress.vue | 392 ++++++++++
frontend-mp/src/pages/tasks/tasks.vue | 531 +++++++++++++
23 files changed, 4997 insertions(+), 20 deletions(-)
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/controller/StudentTaskController.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/dto/StudentDtos.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/mapper/StudentTaskMapper.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/fill/service/StudentTaskService.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpLoginController.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpTeacherDashboardController.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/controller/MpTeacherProxyController.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/dto/TeacherDtos.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/mapper/TeacherDashboardMapper.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/MpLoginService.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/TeacherDashboardService.java
create mode 100644 backend/xt-server/src/main/java/cn/tianbaotong/fill/module/mp/service/TeacherProxyService.java
create mode 100644 backend/xt-server/src/main/resources/db/migration/V12__file_source_proxy.sql
create mode 100644 frontend-mp/src/pages/audit-detail/audit-detail.vue
create mode 100644 frontend-mp/src/pages/audit/audit.vue
create mode 100644 frontend-mp/src/pages/dashboard/dashboard.vue
create mode 100644 frontend-mp/src/pages/progress/progress.vue
create mode 100644 frontend-mp/src/pages/tasks/tasks.vue
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)。
+ *
+ * 三件必须守住的事
+ *
+ *
+ * 任务的可见范围 = 名册绑定关系 ,不是"租户内全部任务"。
+ * 学生只该看到"老师把我加进名单的任务" —— 别的任务他点进去也只会撞门禁。
+ * 每次操作都要验"这份是不是他的" 。前端传来的 submissionId 可以随便改,
+ * 所以查询一律带 {@code submitter_id}(见 {@code StudentTaskMapper.selectMine})。
+ * 已终审通过的单不能自己撤回 ({@code 40031}):那份材料可能已经交付了,
+ * 要改必须走管理员。这条与"已导出不可撤"({@code 40033})是两道不同的闸门。
+ *
+ */
+@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 再挂到学生名下"。
+ * 那条路有两个具体问题:
+ *
+ * 中间态会留下孤儿 :第一步与第二步之间断了(网络、手滑关页面),
+ * 文件已经在库里但没有归属,既不属于任何学生、也没人知道它是干什么的。
+ * 这类垃圾会一直堆在存储里,而且**没有任何界面能看到它们**。
+ * "通用上传通道"目前不存在 :学生上传接口({@code /mp/files/upload})
+ * 是按 {@code submitter_id} 授权的,班主任用它只能传到自己的名下 ——
+ * 要支持契约那条路,得先做一个"教师专用上传"接口,
+ * 而它本质上就是"上传但先不归属",等于把一个中间态变成正式概念。
+ *
+ *
+ * 所以这里做成一步 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 里而不是靠调用方记得:
+ *
+ *
+ * 只统计"我负责的班" :范围条件与初审完全一致
+ * (指定初审人 → 副班主任 → 班级负责人三级回退)。
+ * 少一个条件,班主任就会看到全校数字,而他会以为那是自己班的。
+ * {@code submitted} 与 {@code approved} 分开数 :
+ * 契约 §10.1 的口径铁律 —— 合并就会让"有人没交"和"有人没过"
+ * 变成同一个数字,而这两件事要做的动作完全不同。
+ *
+ */
+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 jscode2session}(不需要用户点"允许",静默拿 openid),
+ * H5 用网页授权(要点同意,还要配回调域名);
+ * 小程序能**直接拿手机号**({@code getPhoneNumber} 的 code 换 phone),
+ * H5 不能 —— 所以手机号绑定只在小程序端有。
+ *
+ * 共同的部分(找/建用户、授学生角色)**复用** {@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)。
+ *
+ *
为什么要有这个功能
+ *
+ * 真实场景:学生不会弄(照片在家长手机里 / 不会压缩)、或者人不在。
+ * 没有这条通道时,老师只能"让学生把照片发给自己,再让学生登录传上来" ——
+ * 而学生之所以要老师帮忙,往往就是因为他弄不了。
+ *
+ *
三道必须守住的闸门
+ *
+ *
+ * 范围 :只能给自己负责的班 的学生代传。
+ * 不校验的话,任何老师能给任何学生塞材料 —— 而那等于伪造认定材料。
+ * 状态 :只能给草稿 状态的填报单代传。
+ * 已提交的单子塞材料会让"审到一半的材料清单"变了,
+ * 而审核人看的是他点开那一刻的内容。
+ * 留痕 :{@code source = 2}(班主任代传)+ {@code proxy_by} + {@code proxy_reason}
+ * + 操作日志(契约 §10.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 @@
+
+
+
+ 正在读取…
+
+
+
+ {{ d.realName }}
+
+ {{ d.orgName || '未分班' }} · {{ d.taskName || '' }}
+
+
+ {{ d.statusText }}
+ 红 {{ d.redCount }}
+ 黄 {{ d.yellowCount }}
+ 已驳 {{ d.rejectCount }} 次
+
+
+
+
+
+ 有 {{ redTitles.length }} 项阻断级问题,不能通过 :
+ {{ redTitles.join(';') }}
+
+
+
+ 上次驳回:{{ d.lastRejectText || d.lastRejectCode }}
+
+
+
+
+ 材料
+
+
+ {{ g.materialName }}
+ 必传
+
+ {{ g.done ? '已交' : '未交' }}
+
+
+
+
+
+
+ {{ f.standardName || f.originalName || '(无文件名)' }}
+
+ {{ f.finalSizeKb ? `${f.finalSizeKb}KB` : '' }}
+ · {{ f.width }}×{{ f.height }}
+ · {{ sourceText(f.source) }}
+
+
+ {{ i.title }}
+
+
+
+
+ 没有文件
+
+
+
+
+
+
+ 校验问题({{ (d.issues || []).length }})
+ 现场重算
+
+
+
+ [{{ i.ruleCode }}] {{ i.title }}
+
+ {{ i.detail }}
+
+ 没有校验问题
+
+
+
+
+ 学生改动({{ d.changedDetail.length }} 项)
+
+ {{ c.label || c.field }}
+ 名册:{{ c.rosterValue || '—' }} → 学生:{{ c.studentValue || '—' }}
+
+
+
+
+
+ 驳回
+
+ {{ canApprove ? '通过' : '不能通过(有红问题)' }}
+
+
+
+
+
+ 帮他传材料
+
+ 学生不会弄(照片在家长手机里 / 不会压缩)时用这里。
+ 这份材料会标注「由你代传」 ,学生也看得到。
+
+
+
+ {{ g.materialName }}{{ g.done ? '(已交)' : '' }}
+
+
+
+
+ {{ proxyBusy ? '正在上传…' : '选择照片并代传' }}
+
+
+
+
+
+
+
+ 驳回这一单
+ 原因会原样发给学生看,所以要说清"哪里不对、该怎么办"。
+
+
+ {{ r.text }}
+
+
+
+
+ 取消
+
+ 确认驳回
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend-mp/src/pages/audit/audit.vue b/frontend-mp/src/pages/audit/audit.vue
new file mode 100644
index 0000000..98710ec
--- /dev/null
+++ b/frontend-mp/src/pages/audit/audit.vue
@@ -0,0 +1,517 @@
+
+
+
+
+
+ 待审 {{ list.length }} 张
+
+ 一键通过无问题({{ greenCount }})
+
+
+
+ 正在读取…
+
+ 没有待你初审的单子了
+ 学生提交后会自动出现在这里。
+
+
+
+
+
+
+
+
+ {{ cur.realName }}
+ {{ levelText }}
+
+
+ 班级
+ {{ cur.orgName || '—' }}
+
+
+ 材料
+
+ {{ cur.materialCount }} 项(有预览)
+
+
+
+ 提交
+ {{ fmt(cur.submitTime) }}
+
+ 阻断:{{ cur.redSummary }}
+
+ 红 {{ cur.redCount }} · 黄 {{ cur.yellowCount }}
+
+
+ 学生改了 {{ cur.changedFields.length }} 项预填内容
+
+
+
+
+
+
+
+
+
+
+ 右滑 → 通过
+ 驳回请点下面的按钮(要选原因,不能滑)
+
+
+
+ 驳回
+ 通过
+
+
+ 看完整材料与问题清单
+
+
+
+
+
+
+
+
diff --git a/frontend-mp/src/pages/dashboard/dashboard.vue b/frontend-mp/src/pages/dashboard/dashboard.vue
new file mode 100644
index 0000000..e2b3fe8
--- /dev/null
+++ b/frontend-mp/src/pages/dashboard/dashboard.vue
@@ -0,0 +1,404 @@
+
+
+
+
+ 我带的班
+
+ 应填 {{ s.rosterTotal }} 人 · 已交 {{ s.submittedCount }} 人 · 已通过 {{ s.approvedCount }} 人
+
+
+
+ {{ s.submitRate }}
+ 已交率
+
+
+
+
+
+
+ {{ t.auditCount }}
+ 待我初审
+
+
+ {{ s.unsubmittedCount }}
+ 还没交
+
+
+ {{ t.rejectedAwaitingCount }}
+ 驳回等改
+
+
+ {{ s.abnormalCount }}
+ 有异常
+
+
+
+
+
+ 最近截止:{{ d.taskName }}
+ {{ fmtDate(d.endTime) }} {{ remainText }}
+
+
+ 我的班级
+ 正在读取…
+
+ 你还没有被指定为任何班级的初审人。请让管理员在「收集任务 → 配置班级」里指定。
+
+
+
+
+ {{ o.orgName }}
+ 主班主任
+ 副班主任
+
+
+ 应填 {{ o.rosterTotal }} · 已交 {{ o.submittedCount }}
+ 已通过 {{ o.approvedCount }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend-mp/src/pages/index/index.vue b/frontend-mp/src/pages/index/index.vue
index 109fc47..eda0165 100644
--- a/frontend-mp/src/pages/index/index.vue
+++ b/frontend-mp/src/pages/index/index.vue
@@ -89,6 +89,15 @@ const isError = ref(false)
const loading = ref(false)
const title = ref('材料填报')
+/** 取登录态(授权回调把 token 放在 URL fragment 里,落地页存下来的)。 */
+function token(): string {
+ try {
+ return uni.getStorageSync('token') || ''
+ } catch {
+ return ''
+ }
+}
+
/**
* 当前 URL 里的 scene(授权回调回来时在 hash 路由的 query 里)。
*
@@ -146,6 +155,13 @@ function pickScene(options?: Record): string {
async function load() {
if (!scene.value) {
+ // ★ 没有 scene 但有登录态 → 说明他是**回来看进度的**,不是扫码进来的。
+ // 以前这种情况一律报错"请扫描二维码进入",于是学生一旦找不到老师发的那条消息
+ // 就再也进不来了(通知渠道还没做,他只能靠历史消息)。现在直接送去「我的填报」。
+ if (token()) {
+ uni.redirectTo({ url: '/pages/tasks/tasks' })
+ return
+ }
loading.value = false
isError.value = true
message.value = '请扫描班主任发的「填报二维码」进入,或让班主任重新发一次'
diff --git a/frontend-mp/src/pages/progress/progress.vue b/frontend-mp/src/pages/progress/progress.vue
new file mode 100644
index 0000000..4ffe658
--- /dev/null
+++ b/frontend-mp/src/pages/progress/progress.vue
@@ -0,0 +1,392 @@
+
+
+
+
+ {{ taskName || '本次填报' }}
+ {{ p.progressPercent || '—' }}
+ {{ p.statusText || '' }}
+
+
+ 正在读取…
+
+
+
+
+ 材料
+
+
+ {{ m.materialName }}
+ 必传
+
+ {{ m.done ? `已交 ${m.fileCount} 份` : '未交' }}
+
+
+
+ {{ m.issueText }}
+
+
+ 本次没有需要上传的材料
+
+
+
+
+ 进展
+
+ {{ e.title }}
+ {{ fmt(e.time) }}
+ {{ e.desc }}
+
+ 还没有进展
+
+
+
+
+ {{ p.status === 4 ? '修改后重新提交' : '继续填写' }}
+
+
+ 撤回修改
+
+
+ 刷新
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend-mp/src/pages/tasks/tasks.vue b/frontend-mp/src/pages/tasks/tasks.vue
new file mode 100644
index 0000000..d717073
--- /dev/null
+++ b/frontend-mp/src/pages/tasks/tasks.vue
@@ -0,0 +1,531 @@
+
+
+
+
+
+ 我的填报
+
+ 共 {{ data.total }} 项,其中 {{ data.todoCount }} 项待完成
+
+
+
+
+
+ 我的消息
+ {{ unread }}
+ 暂时没有未读
+
+
+
+
+ 我是老师 · 进班级看板
+ 看待审、右滑通过、看班上谁还没交
+
+
+ 正在读取…
+
+
+ 老师还没有把你加入任何一次认定的名单。
+
+ 如果你已经拿到填报二维码,扫一下就能核对身份;核对不上可以在那个页面申请加入名单。
+
+
+
+
+
+ {{ t.taskName }}
+ {{ t.statusText }}
+
+
+
+ 报考
+
+ {{ [t.occupation, t.worktype, t.jobLevel].filter(Boolean).join(' · ') || '—' }}
+
+
+
+ 班级
+ {{ t.orgName || '—' }}
+
+
+ 截止
+ {{ fmtDate(t.endTime) }}{{ remainText(t) }}
+
+
+
+
+ 被驳回: {{ t.lastRejectReason }}
+
+
+
+
+ {{ t.fillBlockReason }}
+
+
+
+
+ {{ t.submissionStatus === 4 ? '修改后重新提交' : (t.submissionStatus === 0 ? '继续填写' : '开始填写') }}
+
+
+ 查看进度
+
+
+ 撤回修改
+
+
+
+
+
+
+
+
+
+
+
+