feat(mp): 学生端「我的填报 / 撤回 / 进度」与班主任端看板、审核卡片流、代传材料
学生端:待办数、状态、驳回原因、截止提醒、撤回;进度页的材料清单与进度条 分母用「这个人本次真的要交什么」(不适用的必传材料不进分母,否则永远填不满)。 班主任端:已交率与已通过分开呈现(已提交 ≠ 已通过)、右滑通过 / 左滑不绑驳回、 一键通过只带无问题项;代传走一步 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 产物含新增各页。
This commit is contained in:
+56
@@ -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)。
|
||||
*
|
||||
* <p>单独一个 Controller(而不是塞进 {@code FillController}):
|
||||
* 这三个接口的授权口径与填报不同 —— 填报要"已过门禁",
|
||||
* 而"我的任务"恰恰是<b>还没进门禁的人也要能看到</b>(他得知道自己被邀请过没有)。
|
||||
* 混在一个类里,类级注解就会把两种口径搅在一起。
|
||||
*/
|
||||
@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<StudentDtos.MyTaskPageVO> myTasks() {
|
||||
return ApiResult.ok(studentTaskService.myTasks());
|
||||
}
|
||||
|
||||
@PostMapping("/submissions/{id}/withdraw")
|
||||
@Operation(summary = "撤回提交(回到草稿)",
|
||||
description = "只有「等待初审 / 等待终审」的能撤;已通过要管理员操作(40031)。"
|
||||
+ "撤回会**同时把名册行的状态退回草稿**(否则班级看板会一直显示他已提交)")
|
||||
public ApiResult<StudentDtos.WithdrawVO> 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<StudentDtos.ProgressVO> progress(@PathVariable Long id) {
|
||||
return ApiResult.ok(studentTaskService.progress(id));
|
||||
}
|
||||
}
|
||||
@@ -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<MyTaskVO> 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<MaterialProgress> materials = new ArrayList<>();
|
||||
|
||||
private List<ProgressEvent> timeline = new ArrayList<>();
|
||||
|
||||
/** 还能不能改(草稿/已驳回 = 能改;待审/已通过 = 要撤回) */
|
||||
private Boolean canEdit;
|
||||
|
||||
private Boolean canWithdraw;
|
||||
|
||||
private Boolean canSubmit;
|
||||
}
|
||||
}
|
||||
+182
@@ -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)。
|
||||
*
|
||||
* <p><b>为什么要单独一条 SQL</b>:任务的可见范围对学生不是"租户内全部",
|
||||
* 而是"<b>名册里绑定了我</b>的那些任务" —— 换句话说,
|
||||
* <b>名册就是学生的任务清单</b>。用 {@code collect_task} 直接分页会把他
|
||||
* 没被邀请的任务也列出来,而那类任务他点进去只会撞门禁(40007)。
|
||||
*
|
||||
* <p>显式带 {@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; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的任务列表:名册里绑定了我、且任务已经发布过的。
|
||||
*
|
||||
* <p>排序按"还能填的排前面、快要截止的再往前":学生打开这一屏最想知道的是
|
||||
* <b>我还有什么没交</b>,而不是"历史上我参加过什么"。
|
||||
* 已在 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<MyTaskRow> listMyTasks(@Param("tenantId") Long tenantId, @Param("uid") Long uid);
|
||||
|
||||
/**
|
||||
* 取学生名下某个任务的提交单(撤回/进度都要先确认"这份是不是他的")。
|
||||
*
|
||||
* <p>按 {@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);
|
||||
|
||||
/**
|
||||
* 某个填报单下**当前生效**的文件(按材料码分组的计数用)。
|
||||
*
|
||||
* <p>只取 {@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<cn.tianbaotong.fill.module.file.entity.CollectFile> listMyFiles(
|
||||
@Param("tenantId") Long tenantId, @Param("submissionId") Long submissionId);
|
||||
}
|
||||
+424
@@ -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)。
|
||||
*
|
||||
* <h3>三件必须守住的事</h3>
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>任务的可见范围 = 名册绑定关系</b>,不是"租户内全部任务"。
|
||||
* 学生只该看到"老师把我加进名单的任务" —— 别的任务他点进去也只会撞门禁。</li>
|
||||
* <li><b>每次操作都要验"这份是不是他的"</b>。前端传来的 submissionId 可以随便改,
|
||||
* 所以查询一律带 {@code submitter_id}(见 {@code StudentTaskMapper.selectMine})。</li>
|
||||
* <li><b>已终审通过的单不能自己撤回</b>({@code 40031}):那份材料可能已经交付了,
|
||||
* 要改必须走管理员。这条与"已导出不可撤"({@code 40033})是两道不同的闸门。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@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<StudentTaskMapper.MyTaskRow> rows = studentTaskMapper.listMyTasks(tenantId, uid);
|
||||
List<StudentDtos.MyTaskVO> 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 撤回 ────────────────────────────────
|
||||
|
||||
/**
|
||||
* 撤回提交(回到草稿)。
|
||||
*
|
||||
* <p>约束(契约 §9.11):{@code status ∈ {1, 2}} 可撤 ——
|
||||
* 也就是"还在审"的单子。{@code status = 3}(已通过)要管理员操作({@code 40031})。
|
||||
*
|
||||
* <p>为什么已通过不能自己撤:那份材料可能已经进了导出包或在机构那边流转,
|
||||
* 学生手一滑就撤回会让"已交付的数据"和"系统里显示的状态"对不上。
|
||||
*
|
||||
* <p>撤回会**同时把名册行的提交状态退回草稿** —— 否则班主任的班级看板会一直
|
||||
* 显示这个学生"已提交",而实际上他正在改。这两个状态必须同步(同一次事务)。
|
||||
*/
|
||||
@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.<CollectSubmission>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.<CollectRoster>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)。
|
||||
*
|
||||
* <p>百分比按<b>必传材料已交齐的比例</b>算,不按字段数:
|
||||
* 学生关心的"我还差什么"就是材料 —— 字段缺了会在提交清单里逐条列出来,
|
||||
* 而材料缺了是实打实交不上去的。
|
||||
*/
|
||||
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<SubmitDtos.Issue> issues = validator.validate(s, task, roster);
|
||||
List<MaterialDefVO> defs = materialDefService.list(s.getTaskId());
|
||||
|
||||
// ★ 条件可见性必须在这里也求一次值:
|
||||
// 「工作证明」这类材料的 visibleWhen 是 is_student=false,
|
||||
// 在校生根本不该被要求交 —— 但它的 required 仍是 1。
|
||||
// 不判可见性的话,分母里会永远多一项,进度**永远到不了 100%**,
|
||||
// 而学生看到一个永远不满的进度条只会以为"我是不是还差什么"。
|
||||
// (E2E 第一版就是这样:交齐了显示 75%。)
|
||||
Map<String, Object> values = formValuesOf(task, roster, s);
|
||||
|
||||
// 每个材料项:是否必传 / 是否已交 / 有什么问题
|
||||
Map<String, List<SubmitDtos.Issue>> issueByCode = new LinkedHashMap<>();
|
||||
Map<String, Integer> 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<StudentDtos.MaterialProgress> 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<SubmitDtos.Issue> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把校验问题归到某个材料项。
|
||||
*
|
||||
* <p>⚠️ {@code SubmissionValidator} 加"缺材料"那条问题时**没有**在 {@code extra} 里带
|
||||
* {@code materialCode}(只写在文案里),所以这里还要按文案里的材料名反查一次。
|
||||
* 只认 {@code extra} 的话,"缺少必传材料:工作证明"会挂不到任何一项上 ——
|
||||
* 表现是界面上那一项什么问题都不显示。**这是本轮 E2E 抓到的**(F-21)。
|
||||
*/
|
||||
private List<String> materialCodesOf(SubmitDtos.Issue i) {
|
||||
List<String> 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<String[]> 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<String, Object> formValuesOf(CollectTask task, CollectRoster roster, CollectSubmission s) {
|
||||
Map<String, Object> 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<String, Integer> out) {
|
||||
var files = studentTaskMapper.listMyFiles(UserContext.tenantId(), s.getId());
|
||||
for (var f : files) {
|
||||
out.merge(f.getMaterialCode(), 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴:创建 → 提交 → 初审 → 终审 → 驳回。
|
||||
*
|
||||
* <p>只写"确实发生过"的节点:没提交就不显示"已提交"。
|
||||
* 编一条假时间线(比如把创建时间当提交时间)会让学生以为系统记错了。
|
||||
*/
|
||||
private List<StudentDtos.ProgressEvent> timelineOf(CollectSubmission s) {
|
||||
List<StudentDtos.ProgressEvent> 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;
|
||||
}
|
||||
}
|
||||
+71
@@ -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)。
|
||||
*
|
||||
* <h3>与 H5 授权登录的关系</h3>
|
||||
*
|
||||
* <p>两者换的是同一个 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<MpLoginService.LoginVO> login(@RequestBody(required = false) Map<String, Object> 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<MpLoginService.BindPhoneVO> bindPhone(
|
||||
@RequestBody(required = false) Map<String, Object> 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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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)。
|
||||
*
|
||||
* <p>权限用 {@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<TeacherDtos.DashboardVO> dashboard(@RequestParam(required = false) Long taskId) {
|
||||
return ApiResult.ok(teacherDashboardService.dashboard(taskId));
|
||||
}
|
||||
}
|
||||
+137
@@ -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)。
|
||||
*
|
||||
* <h3>为什么代传是 multipart 一步,而不是契约写的"先传文件再传 fileId"</h3>
|
||||
*
|
||||
* <p>契约 §10.6 的请求体是 {@code { submissionId, materialCode, fileId }},
|
||||
* 也就是"班主任先走通用上传通道把文件传上来,拿到 fileId 再挂到学生名下"。
|
||||
* 那条路有两个具体问题:
|
||||
* <ol>
|
||||
* <li><b>中间态会留下孤儿</b>:第一步与第二步之间断了(网络、手滑关页面),
|
||||
* 文件已经在库里但没有归属,既不属于任何学生、也没人知道它是干什么的。
|
||||
* 这类垃圾会一直堆在存储里,而且**没有任何界面能看到它们**。</li>
|
||||
* <li><b>"通用上传通道"目前不存在</b>:学生上传接口({@code /mp/files/upload})
|
||||
* 是按 {@code submitter_id} 授权的,班主任用它只能传到自己的名下 ——
|
||||
* 要支持契约那条路,得先做一个"教师专用上传"接口,
|
||||
* 而它本质上就是"上传但先不归属",等于把一个中间态变成正式概念。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>所以这里做成<b>一步 multipart</b>:{@code submissionId + materialCode + file + proxyReason}。
|
||||
* 对外行为与契约一致(响应里有 {@code fileId} / {@code proxyBy} / {@code proxyName}),
|
||||
* 只是不需要调用方先做一次无归属上传。这条差异记在 F-39。
|
||||
*
|
||||
* <h3>文件访问:为什么教师端要单独一个接口</h3>
|
||||
*
|
||||
* <p>学生端的 {@code /mp/files/{id}/preview} 按 {@code submitter_id} 授权,
|
||||
* 班主任点开学生的材料必然 403。而班主任<b>必须</b>能看到自己班学生的材料
|
||||
* (他要在手机上看清这张身份证拍清楚没有)。所以这里按<b>班级范围</b>授权,
|
||||
* 与代传、催报、看板用同一套三级回退。
|
||||
*/
|
||||
@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<TeacherDtos.ProxyUploadVO> 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<Map<String, Object>> proxyContext(@PathVariable Long id) {
|
||||
CollectRoster r = proxyService.rosterOf(id);
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 看学生的材料(预览 / 下载同一个人口)。
|
||||
*
|
||||
* <p>{@code original=true} 取原始件 —— 原始件**默认不保留**(客户决定只交付规范件),
|
||||
* 所以拿不到时要明确说"这份材料没有留原件",而不是给一个空白图。
|
||||
*/
|
||||
@GetMapping("/files/{fileId}/preview")
|
||||
@RequirePerm(PermissionDef.ROSTER_VIEW)
|
||||
@Operation(summary = "班主任看学生的材料(按班级范围授权)",
|
||||
description = "学生端那个接口按 `submitter_id` 授权,班主任点开必然 403;"
|
||||
+ "这里按**班级范围**(与代传、催报、看板同一套三级回退)。"
|
||||
+ "`original=true` 取原始件时注意:原始件默认不保留")
|
||||
public ResponseEntity<byte[]> 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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* ⚠️ <b>已提交率</b>({@code submittedCount / rosterTotal}),
|
||||
* <b>不是</b>通过率。
|
||||
*
|
||||
* <p>契约 §10.1 的口径铁律:{@code submitRate} 与 {@code approvedCount}
|
||||
* <b>必须分开呈现</b>(原型定稿如此)。合并成一个数字会让"我班上还有人没交"
|
||||
* 和"我班上有人没过"变成同一件事,而这两件事要做的动作完全不同:
|
||||
* 前者去催报,后者去看材料。
|
||||
*/
|
||||
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<OrgStat> 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;
|
||||
}
|
||||
}
|
||||
@@ -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 找用户(**无租户上下文**时用:小程序登录/绑号时还没有登录态)。
|
||||
*
|
||||
* <p>为什么按 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)。
|
||||
*
|
||||
* <p>⚠️ 与 `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);
|
||||
}
|
||||
|
||||
+225
@@ -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)。
|
||||
*
|
||||
* <p>口径是这一屏最要紧的事,两条都写在 SQL 里而不是靠调用方记得:
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>只统计"我负责的班"</b>:范围条件与初审完全一致
|
||||
* (指定初审人 → 副班主任 → 班级负责人三级回退)。
|
||||
* 少一个条件,班主任就会看到全校数字,而他会以为那是自己班的。</li>
|
||||
* <li><b>{@code submitted} 与 {@code approved} 分开数</b>:
|
||||
* 契约 §10.1 的口径铁律 —— 合并就会让"有人没交"和"有人没过"
|
||||
* 变成同一个数字,而这两件事要做的动作完全不同。</li>
|
||||
* </ol>
|
||||
*/
|
||||
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; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 我负责的每个班的统计(人数 / 已提交 / 已通过 / 有异常 / 我是不是主班主任)。
|
||||
*
|
||||
* <p>{@code taskId} 为空时按<b>所有任务</b>汇总 —— 首页要的是"我现在的整体情况",
|
||||
* 而不是某一个任务的。这时班级可能重复出现(同一个班在两个任务里都有),
|
||||
* 所以用 {@code SUM} 聚合而不是逐任务分行。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT o.id AS orgId,
|
||||
o.org_name AS orgName,
|
||||
COUNT(r.id) AS rosterTotal,
|
||||
COUNT(s.id) FILTER (WHERE s.id IS NOT NULL) AS submittedCount,
|
||||
COUNT(s.id) FILTER (WHERE s.status = 3) AS approvedCount,
|
||||
COUNT(s.id) FILTER (WHERE s.red_count > 0 OR s.yellow_count > 0) AS abnormalCount,
|
||||
BOOL_OR(cto.head_user_id = #{userId}) AS isHead
|
||||
FROM collect_task_org cto
|
||||
JOIN sys_org o
|
||||
ON o.id = cto.org_id AND o.deleted = 0 AND o.tenant_id = cto.tenant_id
|
||||
LEFT JOIN collect_task t
|
||||
ON t.id = cto.task_id AND t.deleted = 0 AND t.tenant_id = cto.tenant_id
|
||||
LEFT JOIN collect_roster r
|
||||
ON r.task_id = cto.task_id AND r.org_id = cto.org_id AND r.deleted = 0
|
||||
LEFT JOIN collect_submission s
|
||||
ON s.roster_id = r.id AND s.deleted = 0
|
||||
WHERE cto.tenant_id = #{tenantId}
|
||||
AND cto.status = 1
|
||||
AND (t.status IS NULL OR t.status <> 3)
|
||||
<if test="taskId != null">
|
||||
AND cto.task_id = #{taskId}
|
||||
</if>
|
||||
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})
|
||||
)
|
||||
GROUP BY o.id, o.org_name
|
||||
ORDER BY o.org_name
|
||||
</script>
|
||||
""")
|
||||
List<OrgStatRow> myOrgStats(@Param("tenantId") Long tenantId,
|
||||
@Param("taskId") Long taskId,
|
||||
@Param("userId") Long userId);
|
||||
|
||||
/** 待我初审的单数(与审核台同一范围口径)。 */
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT COUNT(*)
|
||||
FROM collect_submission s
|
||||
JOIN collect_task_org cto
|
||||
ON cto.task_id = s.task_id AND cto.org_id = s.org_id
|
||||
AND cto.status = 1 AND cto.tenant_id = s.tenant_id
|
||||
LEFT JOIN sys_org o
|
||||
ON o.id = cto.org_id AND o.deleted = 0 AND o.tenant_id = cto.tenant_id
|
||||
WHERE s.tenant_id = #{tenantId}
|
||||
AND s.deleted = 0
|
||||
AND s.status = 1
|
||||
<if test="taskId != null">
|
||||
AND s.task_id = #{taskId}
|
||||
</if>
|
||||
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})
|
||||
)
|
||||
</script>
|
||||
""")
|
||||
int countPendingAudit(@Param("tenantId") Long tenantId,
|
||||
@Param("taskId") Long taskId,
|
||||
@Param("userId") Long userId);
|
||||
|
||||
/** 被驳回过、还在等学生改的单数(老师据此去催一下)。 */
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT COUNT(*)
|
||||
FROM collect_submission s
|
||||
JOIN collect_task_org cto
|
||||
ON cto.task_id = s.task_id AND cto.org_id = s.org_id
|
||||
AND cto.status = 1 AND cto.tenant_id = s.tenant_id
|
||||
LEFT JOIN sys_org o
|
||||
ON o.id = cto.org_id AND o.deleted = 0 AND o.tenant_id = cto.tenant_id
|
||||
WHERE s.tenant_id = #{tenantId}
|
||||
AND s.deleted = 0
|
||||
AND s.status = 4
|
||||
<if test="taskId != null">
|
||||
AND s.task_id = #{taskId}
|
||||
</if>
|
||||
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})
|
||||
)
|
||||
</script>
|
||||
""")
|
||||
int countRejectedAwaiting(@Param("tenantId") Long tenantId,
|
||||
@Param("taskId") Long taskId,
|
||||
@Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 某一个班的待审数(看板卡片角标)。
|
||||
*
|
||||
* <p>与 {@link #countPendingAudit} 同一范围口径,只是多一个 {@code org_id} 条件 ——
|
||||
* 分开写而不是"取全部再在内存里筛",因为角标是**每张卡片都要**的,
|
||||
* 拉全量再筛在几十个班时会变成明显的浪费。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT COUNT(*)
|
||||
FROM collect_submission s
|
||||
JOIN collect_task_org cto
|
||||
ON cto.task_id = s.task_id AND cto.org_id = s.org_id
|
||||
AND cto.status = 1 AND cto.tenant_id = s.tenant_id
|
||||
LEFT JOIN sys_org o
|
||||
ON o.id = cto.org_id AND o.deleted = 0 AND o.tenant_id = cto.tenant_id
|
||||
WHERE s.tenant_id = #{tenantId}
|
||||
AND s.deleted = 0
|
||||
AND s.status = 1
|
||||
AND s.org_id = #{orgId}
|
||||
<if test="taskId != null">
|
||||
AND s.task_id = #{taskId}
|
||||
</if>
|
||||
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})
|
||||
)
|
||||
</script>
|
||||
""")
|
||||
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<DeadlineRow> 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; }
|
||||
}
|
||||
}
|
||||
+315
@@ -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)。
|
||||
*
|
||||
* <h3>它与 H5 授权登录的区别(不是"另一个入口")</h3>
|
||||
*
|
||||
* <p>两者换的是**同一个 openid 体系**,但有两点不同:
|
||||
* <ul>
|
||||
* <li>小程序用 {@code jscode2session}(不需要用户点"允许",静默拿 openid),
|
||||
* H5 用网页授权(要点同意,还要配回调域名);</li>
|
||||
* <li>小程序能**直接拿手机号**({@code getPhoneNumber} 的 code 换 phone),
|
||||
* H5 不能 —— 所以手机号绑定只在小程序端有。</li>
|
||||
* </ul>
|
||||
* 共同的部分(找/建用户、授学生角色)**复用** {@code MpH5AuthService.resolveUser} ——
|
||||
* 不复制一份,否则"建学生账号"这件事会有两个实现,而它们迟早分叉
|
||||
* (例如一边建了内置角色、另一边忘了)。
|
||||
*
|
||||
* <h3>⚠️ 租户从哪来</h3>
|
||||
*
|
||||
* <p>契约的请求体里只有 {@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<String, Object> 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.<CollectRoster>lambdaQuery()
|
||||
.eq(CollectRoster::getTaskId, s.taskId())
|
||||
.eq(CollectRoster::getBindUserId, user.getId())
|
||||
.last("limit 1"));
|
||||
if (r != null) {
|
||||
CollectTask t = taskMapper.selectById(s.taskId());
|
||||
Map<String, Object> 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 绑手机号 ─────────────────────────────
|
||||
|
||||
/**
|
||||
* 微信手机号快捷绑定。
|
||||
*
|
||||
* <p>流程:小程序调 {@code wx.getPhoneNumber} 拿到一个 **code**(不是手机号本身)→
|
||||
* 传给我们 → 我们用 access_token 去微信换真正的手机号。
|
||||
* 所以**手机号从来没经过客户端**,这也是它能作为绑定凭据的原因。
|
||||
*
|
||||
* <p>⚠️ 绑定时要检查"这个手机号是否已被同租户的另一个账号绑定":
|
||||
* 一个手机号绑两个学生会让后续"按手机号找人"(找回密码、短信通知)
|
||||
* 指向不确定的那一个,而那时已经看不出是谁绑错了。
|
||||
*/
|
||||
@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<String> 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<String> 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 字符)。
|
||||
*
|
||||
* <p>与扫码落地页同一套格式 —— 格式定义只有一处,否则"码里的 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<String, Object> user;
|
||||
private Map<String, Object> boundRoster;
|
||||
}
|
||||
|
||||
@lombok.Getter
|
||||
@lombok.Setter
|
||||
public static class BindPhoneVO {
|
||||
private Boolean bound;
|
||||
private String phone;
|
||||
}
|
||||
}
|
||||
+101
-15
@@ -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 次。
|
||||
*
|
||||
* <p>⚠️ 计的是**失败**,不是请求数。正常学生最多填错一两次,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 次(防枚举)。
|
||||
*
|
||||
* <p>⚠️ **只计失败**这件事是压测改出来的:教室里 60 个学生连同一个 WiFi
|
||||
* (出口是同一个公网 IP),如果成功也计数,**后 40 个会被限流** ——
|
||||
* 而那是首次落地第一分钟就会发生的事故。
|
||||
*
|
||||
* <p>为什么只计失败就够:**枚举攻击产生的是失败,教室产生的是成功**。
|
||||
*/
|
||||
private static final int IP_FAIL_LIMIT_PER_MINUTE = 20;
|
||||
|
||||
/**
|
||||
* 同一个 IP 每分钟最多**成功** 200 次。
|
||||
*
|
||||
* <p>成功会返回该学生的姓名与预填数据,所以要防"拿这个接口批量取数据"。
|
||||
* 但限额必须给教室规模留足量:**一个 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);
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────
|
||||
|
||||
/** 统一失败提示:不区分是姓名错还是号码错。 */
|
||||
/**
|
||||
* 统一失败提示:不区分是姓名错还是号码错。
|
||||
*
|
||||
* <p>⚠️ **这里也是唯一计"失败次数"的出口**(防猜 / 防枚举的额度就靠它)。
|
||||
* 另外两条失败路径(`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})。
|
||||
*
|
||||
* <p>同一个人 10 次失败/小时、同一个 IP 20 次失败/分钟、同一 IP 200 次成功/分钟。
|
||||
* 分开的理由:**枚举攻击产生失败,教室产生成功**。
|
||||
*
|
||||
* <p>阈值调低是刻意的 —— 门禁限流是<b>安全设计的一部分</b>(防身份证号枚举探测),
|
||||
* 不是防 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);
|
||||
/**
|
||||
* 进门之前先看"是不是已经试太多次了"。
|
||||
*
|
||||
* <p>先查后记(而不是先记后查):这样第 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) {
|
||||
|
||||
+124
@@ -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)。
|
||||
*
|
||||
* <h3>这一屏只有一条铁律,但它最容易被"优化"掉</h3>
|
||||
*
|
||||
* <p>{@code submitRate} 是<b>已提交率</b>(已提交 / 应填),与 {@code approvedCount}
|
||||
* <b>分开呈现</b>。契约 §10.1 写得很直白:原型定稿如此,接口不许合并。
|
||||
*
|
||||
* <p>为什么不能合并成一个"完成率":<b>这两件事要做的动作完全不同</b> ——
|
||||
* 「还有人没交」要去催报,「有人没过」要去看材料。
|
||||
* 合成一个数字之后,老师看到 60% 不知道该催人还是该审材料,
|
||||
* 而这个数字又会被当成"我的工作进度",于是两边都没做。
|
||||
*
|
||||
* <p>另一个细节:<b>范围只算"我负责的班"</b>。范围口径与初审完全一致
|
||||
* (见 {@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<TeacherDashboardMapper.OrgStatRow> rows = mapper.myOrgStats(tenantId, taskId, uid);
|
||||
|
||||
TeacherDtos.DashboardVO vo = new TeacherDtos.DashboardVO();
|
||||
TeacherDtos.Summary sum = vo.getSummary();
|
||||
List<TeacherDtos.OrgStat> 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<TeacherDashboardMapper.DeadlineRow> 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 位)。
|
||||
*
|
||||
* <p>分母为 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 + "%";
|
||||
}
|
||||
}
|
||||
+253
@@ -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)。
|
||||
*
|
||||
* <h3>为什么要有这个功能</h3>
|
||||
*
|
||||
* <p>真实场景:学生不会弄(照片在家长手机里 / 不会压缩)、或者人不在。
|
||||
* 没有这条通道时,老师只能"让学生把照片发给自己,再让学生登录传上来" ——
|
||||
* 而学生之所以要老师帮忙,往往就是因为他弄不了。
|
||||
*
|
||||
* <h3>三道必须守住的闸门</h3>
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>范围</b>:只能给<b>自己负责的班</b>的学生代传。
|
||||
* 不校验的话,任何老师能给任何学生塞材料 —— 而那等于伪造认定材料。</li>
|
||||
* <li><b>状态</b>:只能给<b>草稿</b>状态的填报单代传。
|
||||
* 已提交的单子塞材料会让"审到一半的材料清单"变了,
|
||||
* 而审核人看的是他点开那一刻的内容。</li>
|
||||
* <li><b>留痕</b>:{@code source = 2}(班主任代传)+ {@code proxy_by} + {@code proxy_reason}
|
||||
* + 操作日志(契约 §10.6 明确要求"代传必须留痕",且<b>学生端可见</b>)。
|
||||
* 学生必须能知道"这份不是我传的,是李老师帮我传的" ——
|
||||
* 否则他会在审核驳回时一头雾水("我什么时候传过这张照片?")。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <h3>为什么复用 {@code MaterialFileService.storeFile}</h3>
|
||||
*
|
||||
* <p>格式白名单、体积压缩、按模板改名、份数上限这些规则**必须与"学生自己传"完全一致** ——
|
||||
* 代传来的材料如果绕过规范化,导出时就会出现不符合命名规范的文件,
|
||||
* 而那正是导出前置检查(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.<CollectFile>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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 范围判断:这个填报单所在的班归不归我。
|
||||
*
|
||||
* <p>用与初审、看板、催报**同一套**三级回退(指定初审人 → 副班主任 → 班级负责人)。
|
||||
* 另写一套判断迟早会分叉,那时会出现"能催报但不能代传"这种说不清的组合。
|
||||
*/
|
||||
private boolean inMyScope(LoginUser me, CollectSubmission sub) {
|
||||
if (sub.getOrgId() == null) {
|
||||
return false;
|
||||
}
|
||||
List<Long> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取一个学生的名册信息(代传页面要显示"你正在替谁传")。
|
||||
*
|
||||
* <p>刻意带上姓名与班级:代传是"替别人做事",界面上必须一直显示对象是谁 ——
|
||||
* 老师一次帮几个学生传材料时,最容易出的错就是**传给了上一个人**。
|
||||
*/
|
||||
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 端(管理/审核角色)代传:范围是**整个租户**,不限于自己负责的班。
|
||||
*
|
||||
* <h3>为什么必须单独一个入口,而不是给 {@link #proxyUpload} 加个 boolean</h3>
|
||||
*
|
||||
* <p>加 boolean 的写法会让"这次到底校没校范围"变成调用点的一个参数 ——
|
||||
* 而参数是会抄错的:新加一处调用,忘了传 true,就出现"任何老师能给任何学生塞材料"。
|
||||
* 两个入口各自把范围判定写在**自己里面**,调用点无从传错。
|
||||
*
|
||||
* <p>范围放宽到整个租户仍然有边界:跨租户依旧不可能(租户拦截器挡着),
|
||||
* 而且代传留痕(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.<CollectFile>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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取一份材料,并要求它属于**我负责的班**的学生。
|
||||
*
|
||||
* <p>授权链是:文件 → 填报单 → 班级 → 是不是我负责的。每一跳都必须走,
|
||||
* 少一跳就会出现"任何老师能看任何学生的身份证照片"。
|
||||
*
|
||||
* <p>越权时用 {@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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user