feat(task): M1-02 覆盖班级与班主任配置
补上发布前置校验的最后一环(40019),同时确立任务的数据隔离边界 ——
班主任能看到哪些任务、能审哪些单子,全由 collect_task_org 决定。
后端 GET/PUT /tasks/{id}/orgs(整体替换语义 + 软移除 + 历史行复用);
前端「配置班级」抽屉:组织树只允许勾选班级,主班主任留空时显式提示
「将由该班负责人初审」而非留白。发布被拦时给出「去哪里补」的指引
(BLOCKING_HINTS),而不是只说"未设置覆盖班级"。
三条守住的规则:
1) 班级与初审人都必须在本租户内。跨租户的 id 一律当作"不存在或已删除",
不透露"它存在但不属于你" —— 漏了的后果是 A 校能把 B 校的班挂到自己
的任务上,B 校的班主任就出现在 A 校的审批流里。
2) 初审人必须持有班主任/副班主任角色。指定初审人本质上是一次授权,
那个人将能看到该班学生的身份证号与学籍材料;不校验角色就等于把
"谁能看学生隐私"变成任意授权。报错要给出下一步,不能只说"校验失败"。
3) 移除是软移除(status=0),且「移除再加回」必须复用原行。
后者不是优化而是必须:uk_task_org UNIQUE (task_id, org_id) 不带 status
条件,一个班在同一任务下只能有一行,直接 insert 会违反唯一约束。
本轮修掉的 3 个真问题:
1) 我此前误判 uk_task_org 不存在 —— grep 只匹配含表名的行,而
CONSTRAINT uk_task_org UNIQUE (task_id, org_id) 那行没有表名。
基于这个错误前提,我写了一个多余的部分唯一索引 uk_task_org_active
和一段基于错误前提的代码,已全部撤掉并更正注释。
教训:查约束要看 CREATE TABLE 整块,不能只 grep 表名。
2) collect_task_org.expected_count 是 NOT NULL DEFAULT 0,与接口契约矛盾:
DDL 注释说它是"由名册汇总"(系统算的),但《03-接口设计》§7.6 把它放在
PUT 请求体里、说"用于与名册实际人数比对" —— 只有"管理员填的期望值 vs
名册实际值"才需要比对。而 0 是有意义的业务值("预期 0 人"),拿它兼职
表示"没填"会让每个没填的班都触发"应填 0 人、实际 31 人"的假告警;告警
变噪音就没人看了,真正"应填 28、名册却有 31"(名册导错)反而被漏掉,
而这条告警的全部价值就在那一次。已由 V4 迁移改为可空 + 去掉默认值,
并同步更正 docs/db/schema-postgresql.sql 与 PRD 里的内联 DDL。
3) 删掉迁移文件后必须 mvn clean,否则它会"复活":我只跑 mvn package,
target/classes 里的旧副本被打进 jar,Flyway 发现历史里没有 v4 就又执行
了一遍 —— 索引回来了、历史行填回去了。之后又建了同名版本号的新 V4,
于是"两个 v4"→"checksum 冲突",排查绕了好几圈。
顺带修掉:
· 任务列表操作列从 3 个链接涨到 4 个,160px 会挤成竖排 —— 把「进度」并进
「状态」列腾出 40px,操作列给到 200px,合计 820px(可用约 824px)。
· defineEmits 必须用函数调用签名(frontend-pc/.agents/skills/code-vue)。
我此前写的 5 个组件都是被禁的 tuple 形式,一并改正:
OrgFormModal / TaskFormModal / TaskOrgDrawer / UserFormModal / UserImportModal。
· e2e-m1-orgs.py 的清理抽成幂等的 cleanup_all(),并在 __main__ 的 finally
里兜一次 —— 该脚本首次运行时崩在准备阶段、清理段没执行,把两个测试账号
留在了库里(是在浏览器里看到下拉框里有个「E2E班主任」才发现的)。
其余三条 E2E 脚本仍是老样子,下一轮一并改。
验收:e2e-m1-orgs.py 48/48(含跨租户隔离);e2e-m1-task.py 71/71、
e2e-m0-user.py 55/55、e2e-m0.py 36/36 回归通过,共 210 项断言;
前端 typecheck / eslint / lint:style 0 错、build 通过;
浏览器实测抽屉的开合、树的勾选规则与保存落库。
This commit is contained in:
+26
@@ -4,8 +4,11 @@ import cn.tianbaotong.fill.common.api.ApiResult;
|
||||
import cn.tianbaotong.fill.common.api.PageResult;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskActionReq;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskCreateVO;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskOrgSaveReq;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskOrgVO;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskUpsertReq;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskVO;
|
||||
import cn.tianbaotong.fill.module.task.service.TaskOrgService;
|
||||
import cn.tianbaotong.fill.module.task.service.TaskService;
|
||||
import cn.tianbaotong.fill.security.PermissionDef;
|
||||
import cn.tianbaotong.fill.security.RequirePerm;
|
||||
@@ -23,6 +26,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 收集任务(《03-接口设计》§7.1 ~ §7.5)。
|
||||
*
|
||||
@@ -36,6 +41,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class TaskController {
|
||||
|
||||
private final TaskService taskService;
|
||||
private final TaskOrgService taskOrgService;
|
||||
|
||||
@GetMapping
|
||||
@RequirePerm(PermissionDef.TASK_VIEW)
|
||||
@@ -67,6 +73,26 @@ public class TaskController {
|
||||
return ApiResult.ok(taskService.detail(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/orgs")
|
||||
@RequirePerm(PermissionDef.TASK_VIEW)
|
||||
@Operation(summary = "任务覆盖班级与班主任",
|
||||
description = "只返回生效的覆盖关系。headUserId 为空表示取该班负责人(sys_org.leader_user_id),"
|
||||
+ "不是「没人审」")
|
||||
public ApiResult<List<TaskOrgVO>> listOrgs(@PathVariable Long id) {
|
||||
return ApiResult.ok(taskOrgService.list(id));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/orgs")
|
||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
||||
@Operation(summary = "设置任务覆盖班级与班主任",
|
||||
description = "整体替换语义:请求体就是最终答案,不在列表里的班级会被软移除(status=0)。"
|
||||
+ "班级必须是 orgType=3;初审人必须是本租户内已启用且持有班主任/副班主任角色的账号。"
|
||||
+ "草稿、收集中、已暂停状态可改")
|
||||
public ApiResult<Void> saveOrgs(@PathVariable Long id, @Valid @RequestBody TaskOrgSaveReq req) {
|
||||
taskOrgService.save(id, req);
|
||||
return ApiResult.ok();
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@RequirePerm(PermissionDef.TASK_MANAGE)
|
||||
@Operation(summary = "修改任务", description = "已发布的任务不可改收集模式与认定类型")
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package cn.tianbaotong.fill.module.task.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设置任务覆盖班级与班主任(《03-接口设计》§7.6 的 PUT 请求)。
|
||||
*
|
||||
* <p><b>整体替换语义</b>:请求体就是"这个任务最终覆盖哪些班"的完整答案。
|
||||
* 不在列表里的班会被置为"已移除"({@code status = 0}),而不是物理删除 ——
|
||||
* 因为名册、提交单都可能挂在那个班下面,删掉关系会让历史数据丢失归属。
|
||||
*
|
||||
* <p>为什么用整体替换而不是"逐个增删":勾选框的交互天然是"提交最终状态"。
|
||||
* 做成增量接口,界面就得自己算差集,而只要有一次算错,
|
||||
* 就会出现"我明明取消勾选了,它还留在列表里"—— 这种不一致查起来极费时间。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "设置任务覆盖班级与班主任")
|
||||
public class TaskOrgSaveReq {
|
||||
|
||||
/**
|
||||
* 覆盖的班级列表。
|
||||
*
|
||||
* <p>允许为空数组 —— 那是"暂时清空覆盖范围"的合法中间状态,
|
||||
* 发布时会被 {@code 40019} 拦住。**不在这里拦**,是因为
|
||||
* 让人「先清空再重新勾选」比「必须一次点对」友好得多。
|
||||
*/
|
||||
@NotNull(message = "请提供覆盖班级列表")
|
||||
@Valid
|
||||
@Schema(description = "覆盖的班级列表;空数组表示清空(发布时会被 40019 拦住)")
|
||||
private List<Item> orgs = new ArrayList<>();
|
||||
|
||||
/** 一个班一行。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "覆盖班级")
|
||||
public static class Item {
|
||||
|
||||
@NotNull(message = "缺少班级 id")
|
||||
@Schema(description = "班级(sys_org.id,orgType 必须为 3)")
|
||||
private Long orgId;
|
||||
|
||||
/**
|
||||
* 该班初审人(班主任)。
|
||||
*
|
||||
* <p>可空 —— 为空表示取班级负责人。指定时必须是本租户内
|
||||
* <b>持有班主任或副班主任角色</b>的启用账号(见 {@code TaskOrgService})。
|
||||
*/
|
||||
@Schema(description = "初审人;为空表示取班级负责人 sys_org.leader_user_id")
|
||||
private Long headUserId;
|
||||
|
||||
/** 副班主任。同一人不能既是主又是副(服务端会拒)。 */
|
||||
@Schema(description = "副班主任")
|
||||
private List<Long> deputyUserIds = new ArrayList<>();
|
||||
|
||||
@Min(value = 0, message = "应填人数不能为负")
|
||||
@Schema(description = "应填人数,用于与名册实际人数比对告警")
|
||||
private Integer expectedCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package cn.tianbaotong.fill.module.task.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 任务覆盖的班级与班主任(《03-接口设计》§7.6)。
|
||||
*
|
||||
* <p>一行 = 一个班。初审人(班主任)是<b>按班</b>指定的,不是按任务 ——
|
||||
* 这是产品的核心差别:任务覆盖 20 个班,就有 20 个班主任各审各的,
|
||||
* 而不是一个管理员审所有班。
|
||||
*
|
||||
* <p>⚠️ {@code headUserId} 为空的语义是「<b>取该班负责人</b>」,
|
||||
* 不是「没人审」。判定顺序与 DDL 注释一致:
|
||||
* 本表的 {@code head_user_id} 优先,为空则回退 {@code sys_org.leader_user_id}。
|
||||
* 所以界面上不该把空显示成「未指定」就完事 —— 那会让管理员以为漏配了,
|
||||
* 然后在几百个班上机械地点几百次,而它们本来就等于班级负责人。
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@Schema(description = "任务覆盖班级与班主任")
|
||||
public class TaskOrgVO {
|
||||
|
||||
private Long orgId;
|
||||
|
||||
private String orgName;
|
||||
|
||||
/** 该班初审人;为空表示取 {@code sys_org.leader_user_id}。 */
|
||||
private Long headUserId;
|
||||
|
||||
private String headUserName;
|
||||
|
||||
/** 副班主任 / 协办人。 */
|
||||
private List<Long> deputyUserIds = new ArrayList<>();
|
||||
|
||||
private List<String> deputyUserNames = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 应填人数。
|
||||
*
|
||||
* <p>它的用途是<b>与名册实际人数比对</b>,不一致时在任务详情告警
|
||||
* (比如某班应有 28 人、名册里却有 31 人 —— 那说明名册导错了或多导了)。
|
||||
* 所以它是个"期望值",不是"实际值",两者都要能看见才有意义。
|
||||
*/
|
||||
private Integer expectedCount;
|
||||
|
||||
/** 固定为 {@code 1}(本接口只返回生效的覆盖关系)。 */
|
||||
private Integer status;
|
||||
}
|
||||
+460
@@ -0,0 +1,460 @@
|
||||
package cn.tianbaotong.fill.module.task.service;
|
||||
|
||||
import cn.tianbaotong.fill.common.api.ErrorCode;
|
||||
import cn.tianbaotong.fill.common.exception.BizException;
|
||||
import cn.tianbaotong.fill.module.org.entity.SysOrg;
|
||||
import cn.tianbaotong.fill.module.org.mapper.OrgMapper;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskOrgSaveReq;
|
||||
import cn.tianbaotong.fill.module.task.dto.TaskOrgVO;
|
||||
import cn.tianbaotong.fill.module.task.entity.CollectTask;
|
||||
import cn.tianbaotong.fill.module.task.entity.CollectTaskOrg;
|
||||
import cn.tianbaotong.fill.module.task.mapper.TaskMapper;
|
||||
import cn.tianbaotong.fill.module.task.mapper.TaskOrgMapper;
|
||||
import cn.tianbaotong.fill.module.user.entity.SysUser;
|
||||
import cn.tianbaotong.fill.module.user.mapper.UserMapper;
|
||||
import cn.tianbaotong.fill.module.user.mapper.UserRoleMapper;
|
||||
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.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 任务覆盖班级与班主任(《03-接口设计》§7.6)。
|
||||
*
|
||||
* <p>这张表回答两个问题:<b>这次收集涉及哪些班</b>、<b>每个班谁初审</b>。
|
||||
* 它是发布前置校验的 5 项之一({@code 40019}),也是数据隔离的边界 ——
|
||||
* 班主任能看到哪些任务、能审哪些单子,全部由这里决定。
|
||||
*
|
||||
* <p><b>三条必须守住的规则</b>:
|
||||
* <ol>
|
||||
* <li><b>班级与班主任都必须在本租户内</b>。跨租户的 id 一律当作"不存在"处理,
|
||||
* 不透露"它存在但不属于你"。这是最容易漏的一环 ——
|
||||
* 漏了的后果是 A 校可以把 B 校的班挂到自己的任务上,然后 B 校的班主任
|
||||
* 就出现在 A 校的审批流里。</li>
|
||||
* <li><b>初审人必须是班主任</b>({@code HEAD_TEACHER} 或 {@code DEPUTY_TEACHER})。
|
||||
* 指定初审人本质上是一次<b>授权</b>:那个人将能看到该班学生的身份证号、
|
||||
* 学籍材料。放开这一条等于把授权能力变成任意授权。</li>
|
||||
* <li><b>移除是软移除</b>({@code status = 0}),不是物理删除。
|
||||
* 名册与提交单都可能挂在覆盖关系下,删掉行会让历史数据失去归属。</li>
|
||||
* <li><b>「移除再加回」必须复用原来那一行</b>。表上有
|
||||
* {@code uk_task_org UNIQUE (task_id, org_id)},而这个约束<b>不带 status 条件</b> ——
|
||||
* 一个班在同一任务下只能存在一行(生效或已移除都算)。
|
||||
* 所以 {@code persist} 里要先按 orgId 把历史行找出来复用,
|
||||
* 直接 insert 会违反唯一约束。这一点很容易漏:单测"/移除后重新添加"就会炸。</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TaskOrgService {
|
||||
|
||||
/** 可以被指定为初审人的角色。 */
|
||||
private static final Set<String> TEACHER_ROLE_CODES = Set.of("HEAD_TEACHER", "DEPUTY_TEACHER");
|
||||
|
||||
/**
|
||||
* 覆盖范围可改的任务状态:草稿 / 收集中 / 已暂停。
|
||||
*
|
||||
* <p>「收集中」也允许改,是刻意的:中途加一个班是真实需求
|
||||
* ("这个班漏报了,补进来")。已截止(2)之后没意义,已归档(3)整体只读。
|
||||
*/
|
||||
private static final Set<Integer> EDITABLE_TASK_STATUS = Set.of(
|
||||
CollectTask.STATUS_DRAFT,
|
||||
CollectTask.STATUS_COLLECTING,
|
||||
CollectTask.STATUS_PAUSED);
|
||||
|
||||
/** 班级的 {@code sys_org.org_type}。一期只做学校收集,覆盖范围只能是班级。 */
|
||||
private static final int ORG_TYPE_CLASS = 3;
|
||||
|
||||
private static final int STATUS_ACTIVE = 1;
|
||||
private static final int STATUS_REMOVED = 0;
|
||||
|
||||
private final TaskMapper taskMapper;
|
||||
private final TaskOrgMapper taskOrgMapper;
|
||||
private final OrgMapper orgMapper;
|
||||
private final UserMapper userMapper;
|
||||
private final UserRoleMapper userRoleMapper;
|
||||
|
||||
// ── 查询 ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 任务覆盖的班级与班主任。
|
||||
*
|
||||
* <p>只返回生效的({@code status = 1})。已移除的历史行不返回 ——
|
||||
* 界面上的"覆盖范围"是当下的事实,历史轨迹要查库或看审计日志。
|
||||
*/
|
||||
public List<TaskOrgVO> list(Long taskId) {
|
||||
requireTask(taskId);
|
||||
List<CollectTaskOrg> rows = taskOrgMapper.selectList(
|
||||
Wrappers.<CollectTaskOrg>lambdaQuery()
|
||||
.eq(CollectTaskOrg::getTaskId, taskId)
|
||||
.eq(CollectTaskOrg::getStatus, STATUS_ACTIVE)
|
||||
.orderByAsc(CollectTaskOrg::getId));
|
||||
return assemble(rows);
|
||||
}
|
||||
|
||||
// ── 保存(整体替换)──────────────────────────
|
||||
|
||||
/**
|
||||
* 设置覆盖班级与班主任。<b>整体替换语义</b>:请求体就是最终答案。
|
||||
*
|
||||
* <p>为什么是整体替换而不是"逐个增删":勾选框的交互天然是"提交最终状态"。
|
||||
* 做成增量接口,界面就得自己算差集,而只要有一次算错,
|
||||
* 就会出现"我明明取消勾选了,它还留在列表里"。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void save(Long taskId, TaskOrgSaveReq req) {
|
||||
CollectTask task = requireTask(taskId);
|
||||
int status = task.getStatus() == null ? -1 : task.getStatus();
|
||||
if (!EDITABLE_TASK_STATUS.contains(status)) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"任务已" + statusText(status) + ",覆盖班级不可再改");
|
||||
}
|
||||
|
||||
Map<Long, Set<Long>> deputiesOf = normalizeAndValidate(req);
|
||||
|
||||
persist(taskId, req, deputiesOf);
|
||||
|
||||
log.info("任务 {}(id={})覆盖范围已更新:{} 个班", task.getTaskCode(), taskId,
|
||||
req.getOrgs().size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数归一化与全部校验。
|
||||
*
|
||||
* @return 每个班归一化后的副班主任 id 集合(已去重、去掉 null、并校验过与主班主任不重复)
|
||||
*/
|
||||
private Map<Long, Set<Long>> normalizeAndValidate(TaskOrgSaveReq req) {
|
||||
// 同一个班只能出现一次。不拦的话会因为唯一索引报错,而错误信息
|
||||
// 完全指不出是哪个班的问题(PG 只说 duplicate key)
|
||||
Map<Long, TaskOrgSaveReq.Item> wanted = new LinkedHashMap<>();
|
||||
for (TaskOrgSaveReq.Item item : req.getOrgs()) {
|
||||
if (wanted.putIfAbsent(item.getOrgId(), item) != null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"同一个班级出现了两次(orgId=" + item.getOrgId() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
assertOrgsAreClasses(wanted.keySet());
|
||||
|
||||
Map<Long, Set<Long>> deputiesOf = new LinkedHashMap<>();
|
||||
Set<Long> allUserIds = new LinkedHashSet<>();
|
||||
for (Map.Entry<Long, TaskOrgSaveReq.Item> entry : wanted.entrySet()) {
|
||||
TaskOrgSaveReq.Item item = entry.getValue();
|
||||
LinkedHashSet<Long> deputies = new LinkedHashSet<>();
|
||||
if (item.getDeputyUserIds() != null) {
|
||||
deputies.addAll(item.getDeputyUserIds());
|
||||
}
|
||||
deputies.removeIf(Objects::isNull);
|
||||
|
||||
if (item.getHeadUserId() != null && deputies.contains(item.getHeadUserId())) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"同一个班的主班主任与副班主任不能是同一人(userId=" + item.getHeadUserId() + ")");
|
||||
}
|
||||
deputiesOf.put(entry.getKey(), deputies);
|
||||
|
||||
if (item.getHeadUserId() != null) {
|
||||
allUserIds.add(item.getHeadUserId());
|
||||
}
|
||||
allUserIds.addAll(deputies);
|
||||
}
|
||||
|
||||
if (!allUserIds.isEmpty()) {
|
||||
assertAssignableTeachers(allUserIds);
|
||||
}
|
||||
return deputiesOf;
|
||||
}
|
||||
|
||||
/** 班级必须存在、在同一个租户内、且确实是"班级"。 */
|
||||
private void assertOrgsAreClasses(Collection<Long> orgIds) {
|
||||
if (orgIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, SysOrg> found = new HashMap<>();
|
||||
// 租户过滤由 TenantEntity + 租户拦截器保证:别的租户的班会直接查不到,
|
||||
// 于是下面统一报"不存在或已删除" —— 不透露"它存在但不属于你"
|
||||
for (SysOrg org : orgMapper.selectByIds(orgIds)) {
|
||||
found.put(org.getId(), org);
|
||||
}
|
||||
for (Long orgId : orgIds) {
|
||||
SysOrg org = found.get(orgId);
|
||||
if (org == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"班级不存在或已删除(orgId=" + orgId + ")");
|
||||
}
|
||||
if (org.getOrgType() == null || org.getOrgType() != ORG_TYPE_CLASS) {
|
||||
// 把整个"学校"或"专业部"当成一个班是最容易犯的错:
|
||||
// 后果是全校学生都成了本次任务的应填范围,而界面上只显示一行,看不出异常
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"「" + org.getOrgName() + "」是" + orgTypeText(org.getOrgType())
|
||||
+ ",不是班级;任务的覆盖范围只能选到班级一层");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初审人必须是本租户内、已启用、且持有班主任角色的账号。
|
||||
*
|
||||
* <p>为什么角色校验不能省:指定初审人是一次<b>授权</b> ——
|
||||
* 那个人将能看到该班学生的身份证号与学籍材料。不做角色校验,
|
||||
* 就等于把"谁能看学生隐私"这件事变成任意授权。
|
||||
*/
|
||||
private void assertAssignableTeachers(Set<Long> userIds) {
|
||||
Map<Long, SysUser> users = new HashMap<>();
|
||||
for (SysUser user : userMapper.selectByIds(userIds)) {
|
||||
users.put(user.getId(), user);
|
||||
}
|
||||
for (Long userId : userIds) {
|
||||
SysUser user = users.get(userId);
|
||||
if (user == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"指定的班主任不存在或已删除(userId=" + userId + ")");
|
||||
}
|
||||
if (user.getStatus() == null || user.getStatus() != 1) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"「" + user.getRealName() + "」已停用,不能作为初审人");
|
||||
}
|
||||
}
|
||||
|
||||
Map<Long, Set<String>> rolesOf = new HashMap<>();
|
||||
for (UserRoleMapper.UserRoleRow row : userRoleMapper.selectRoleCodesByUserIds(new ArrayList<>(userIds))) {
|
||||
rolesOf.computeIfAbsent(row.getUserId(), key -> new HashSet<>()).add(row.getRoleCode());
|
||||
}
|
||||
for (Long userId : userIds) {
|
||||
Set<String> roles = rolesOf.getOrDefault(userId, Set.of());
|
||||
if (roles.stream().noneMatch(TEACHER_ROLE_CODES::contains)) {
|
||||
SysUser user = users.get(userId);
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED,
|
||||
"「" + user.getRealName() + "」不是班主任,不能作为初审人;"
|
||||
+ "请先在「组织与账号 → 账号管理」里给他分配「班主任」或「副班主任」角色");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 落库:软移除不在请求里的班,新增或更新请求里的班。
|
||||
*
|
||||
* <p>⚠️ <b>更新必须用 {@code UpdateWrapper} 而不是 {@code updateById}。</b>
|
||||
* {@code updateById} 对 {@code null} 字段的策略是"跳过不写",
|
||||
* 而"把主班主任清空(改回取班级负责人)"正是一次<b>把字段置为 null</b> 的操作 ——
|
||||
* 用 {@code updateById} 会静默不生效:接口返回成功、库里没变、界面刷新后又出现,
|
||||
* 这种"保存成功但没保存"是最难查的一类 bug。
|
||||
*/
|
||||
private void persist(Long taskId, TaskOrgSaveReq req, Map<Long, Set<Long>> deputiesOf) {
|
||||
Map<Long, TaskOrgSaveReq.Item> wanted = new LinkedHashMap<>();
|
||||
for (TaskOrgSaveReq.Item item : req.getOrgs()) {
|
||||
wanted.put(item.getOrgId(), item);
|
||||
}
|
||||
|
||||
List<CollectTaskOrg> existing = taskOrgMapper.selectList(
|
||||
Wrappers.<CollectTaskOrg>lambdaQuery().eq(CollectTaskOrg::getTaskId, taskId));
|
||||
|
||||
Map<Long, CollectTaskOrg> activeByOrg = new HashMap<>();
|
||||
Map<Long, CollectTaskOrg> removedByOrg = new HashMap<>();
|
||||
for (CollectTaskOrg row : existing) {
|
||||
if (row.getStatus() != null && row.getStatus() == STATUS_ACTIVE) {
|
||||
activeByOrg.put(row.getOrgId(), row);
|
||||
} else {
|
||||
removedByOrg.put(row.getOrgId(), row);
|
||||
}
|
||||
}
|
||||
|
||||
// 1) 不在请求里的生效行 → 软移除
|
||||
for (Map.Entry<Long, CollectTaskOrg> entry : activeByOrg.entrySet()) {
|
||||
if (!wanted.containsKey(entry.getKey())) {
|
||||
taskOrgMapper.update(null, Wrappers.<CollectTaskOrg>lambdaUpdate()
|
||||
.eq(CollectTaskOrg::getId, entry.getValue().getId())
|
||||
.set(CollectTaskOrg::getStatus, STATUS_REMOVED));
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 请求里的 → 新增或更新
|
||||
for (Map.Entry<Long, TaskOrgSaveReq.Item> entry : wanted.entrySet()) {
|
||||
Long orgId = entry.getKey();
|
||||
TaskOrgSaveReq.Item item = entry.getValue();
|
||||
String deputies = joinIds(deputiesOf.get(orgId));
|
||||
|
||||
CollectTaskOrg reuse = activeByOrg.get(orgId);
|
||||
if (reuse == null) {
|
||||
// ⚠️ 被移除过又加回来时,**必须复用那一行**,不能插新行。
|
||||
// 表上有 uk_task_org UNIQUE (task_id, org_id),而它**不带 status 条件** ——
|
||||
// 一个班在同一任务下只能有一行,无论生效与否。
|
||||
// 直接 insert 会立刻违反唯一约束(表现为 500,且错误信息看不出是"加回"引起的)。
|
||||
reuse = removedByOrg.get(orgId);
|
||||
}
|
||||
|
||||
if (reuse == null) {
|
||||
CollectTaskOrg insert = new CollectTaskOrg();
|
||||
insert.setTaskId(taskId);
|
||||
insert.setOrgId(orgId);
|
||||
insert.setHeadUserId(item.getHeadUserId());
|
||||
insert.setDeputyUserIds(deputies);
|
||||
// expectedCount 为 null 就是"未设置",**不能写成 0** ——
|
||||
// 0 是有意义的业务值("预期 0 人"),兼职表示"没填"会让
|
||||
// "期望 0 人、实际 31 人"这种假告警出现在每个没填的班上,
|
||||
// 告警变噪音后就没人看,真正对不上的那次反而被漏掉。
|
||||
insert.setExpectedCount(item.getExpectedCount());
|
||||
insert.setStatus(STATUS_ACTIVE);
|
||||
taskOrgMapper.insert(insert);
|
||||
} else {
|
||||
taskOrgMapper.update(null, Wrappers.<CollectTaskOrg>lambdaUpdate()
|
||||
.eq(CollectTaskOrg::getId, reuse.getId())
|
||||
.set(CollectTaskOrg::getHeadUserId, item.getHeadUserId())
|
||||
.set(CollectTaskOrg::getDeputyUserIds, deputies)
|
||||
.set(CollectTaskOrg::getExpectedCount, item.getExpectedCount())
|
||||
.set(CollectTaskOrg::getStatus, STATUS_ACTIVE));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── VO 装配 ───────────────────────────────────
|
||||
|
||||
private List<TaskOrgVO> assemble(List<CollectTaskOrg> rows) {
|
||||
if (rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
Set<Long> orgIds = new LinkedHashSet<>();
|
||||
Set<Long> userIds = new LinkedHashSet<>();
|
||||
Map<Long, List<Long>> deputiesOf = new LinkedHashMap<>();
|
||||
for (CollectTaskOrg row : rows) {
|
||||
orgIds.add(row.getOrgId());
|
||||
List<Long> deputies = parseIds(row.getDeputyUserIds());
|
||||
deputiesOf.put(row.getId(), deputies);
|
||||
if (row.getHeadUserId() != null) {
|
||||
userIds.add(row.getHeadUserId());
|
||||
}
|
||||
userIds.addAll(deputies);
|
||||
}
|
||||
|
||||
Map<Long, String> orgNames = loadOrgNames(orgIds);
|
||||
Map<Long, String> userNames = loadUserNames(userIds);
|
||||
|
||||
List<TaskOrgVO> out = new ArrayList<>(rows.size());
|
||||
for (CollectTaskOrg row : rows) {
|
||||
TaskOrgVO vo = new TaskOrgVO();
|
||||
vo.setOrgId(row.getOrgId());
|
||||
vo.setOrgName(orgNames.getOrDefault(row.getOrgId(), "(班级已删除)"));
|
||||
vo.setHeadUserId(row.getHeadUserId());
|
||||
vo.setHeadUserName(userNames.get(row.getHeadUserId()));
|
||||
List<Long> deputies = deputiesOf.get(row.getId());
|
||||
vo.setDeputyUserIds(deputies);
|
||||
vo.setDeputyUserNames(deputies.stream()
|
||||
.map(id -> userNames.getOrDefault(id, "(账号已删除)"))
|
||||
.toList());
|
||||
vo.setExpectedCount(row.getExpectedCount());
|
||||
vo.setStatus(row.getStatus());
|
||||
out.add(vo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<Long, String> loadOrgNames(Set<Long> orgIds) {
|
||||
Map<Long, String> map = new HashMap<>();
|
||||
if (orgIds.isEmpty()) {
|
||||
return map;
|
||||
}
|
||||
for (SysOrg org : orgMapper.selectByIds(orgIds)) {
|
||||
map.put(org.getId(), org.getOrgName());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<Long, String> loadUserNames(Set<Long> userIds) {
|
||||
Map<Long, String> map = new HashMap<>();
|
||||
if (userIds.isEmpty()) {
|
||||
return map;
|
||||
}
|
||||
for (SysUser user : userMapper.selectByIds(userIds)) {
|
||||
// 用"姓名(账号)":学校里同名同姓很常见,只显示姓名会导致
|
||||
// 选人时无法区分,而选错了就是把别人的班交给了错误的人
|
||||
map.put(user.getId(), user.getUsername() == null
|
||||
? user.getRealName()
|
||||
: user.getRealName() + "(" + user.getUsername() + ")");
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ── 小工具 ────────────────────────────────────
|
||||
|
||||
private CollectTask requireTask(Long taskId) {
|
||||
CollectTask task = taskMapper.selectById(taskId);
|
||||
if (task == null) {
|
||||
throw new BizException(ErrorCode.VALIDATION_BLOCKED, "任务不存在或已删除");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逗号分隔 id 的序列化。
|
||||
*
|
||||
* <p>存的是<b>首尾都带逗号</b>的形式({@code ,3,7,}),不是 {@code 3,7}。
|
||||
* 原因:用 {@code LIKE '%,3,%'} 精确匹配时,裸逗号分隔的 {@code 3,17}
|
||||
* 会被子串误命中,让"莫名其妙的协办人能看到别人的班"。
|
||||
*/
|
||||
private static String joinIds(Collection<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(",");
|
||||
for (Long id : ids) {
|
||||
sb.append(id).append(',');
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static List<Long> parseIds(String raw) {
|
||||
List<Long> out = new ArrayList<>();
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return out;
|
||||
}
|
||||
for (String part : raw.split(",")) {
|
||||
String trimmed = part.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
out.add(Long.valueOf(trimmed));
|
||||
} catch (NumberFormatException e) {
|
||||
// 库里存了脏数据不该让整个列表 500;跳过并在日志里留痕
|
||||
log.warn("collect_task_org.deputy_user_ids 含非法 id:{}", trimmed);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String statusText(int status) {
|
||||
return switch (status) {
|
||||
case CollectTask.STATUS_DRAFT -> "草稿";
|
||||
case CollectTask.STATUS_COLLECTING -> "收集中";
|
||||
case CollectTask.STATUS_CLOSED -> "截止";
|
||||
case CollectTask.STATUS_ARCHIVED -> "归档";
|
||||
case CollectTask.STATUS_PAUSED -> "暂停";
|
||||
default -> "未知状态";
|
||||
};
|
||||
}
|
||||
|
||||
private static String orgTypeText(Integer orgType) {
|
||||
if (orgType == null) {
|
||||
return "未知类型";
|
||||
}
|
||||
return switch (orgType) {
|
||||
case 1 -> "学校";
|
||||
case 2 -> "院系/专业";
|
||||
case 3 -> "班级";
|
||||
case 4 -> "报名点";
|
||||
default -> "未知类型(" + orgType + ")";
|
||||
};
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
-- ============================================================================
|
||||
-- collect_task_org.expected_count:允许为空(NULL ≠ 0)
|
||||
-- ============================================================================
|
||||
--
|
||||
-- 背景:V1 把它建成了 `integer NOT NULL DEFAULT 0`,DDL 注释还写着
|
||||
-- '应填人数(冗余,由名册汇总)'
|
||||
-- 也就是"系统从名册算出来的值"。但这与《03-接口设计》§7.6 矛盾:
|
||||
-- · 它出现在 PUT /tasks/{id}/orgs 的**请求体**里(管理员填的,不是系统算的)
|
||||
-- · 原话是"expectedCount 用于与名册实际人数比对,不一致时在任务详情告警"
|
||||
-- 只有"管理员填的期望值 vs 名册实际值"才需要比对。若它等于名册汇总结果,
|
||||
-- 比对永远相等,这条告警不可能触发,也就没有存在的意义。
|
||||
-- 所以权威口径以《03-接口设计》为准:它由管理员填写,是"期望值"。
|
||||
--
|
||||
-- 为什么必须允许 NULL(而不是继续用 0 表示"没填"):
|
||||
-- 0 是一个**有意义的业务值**("这个班预期 0 人"),
|
||||
-- 拿它兼职表示"未设置"会让两者的区别永久丢失。具体后果是:
|
||||
-- 管理员没填这个数字的班,比对时会被算成"期望 0 人、实际 31 人",
|
||||
-- 于是**每个没填的班都告警**。告警一旦变成噪音就没人看了,
|
||||
-- 真正"应填 28 人、名册却有 31 人"(名册导错了)反而会被漏掉 ——
|
||||
-- 而这条告警的全部价值就在那一次真正的对不上。
|
||||
--
|
||||
-- 为什么同时 DROP DEFAULT:
|
||||
-- 留着 DEFAULT 0,新增行仍会悄悄写 0,问题原样保留。
|
||||
--
|
||||
-- ⚠️ 历史行里已有的 0 保持原样,不动。因为无法判断它是"当时没填"还是
|
||||
-- "确实期望 0 人" —— 猜错会把告警吃掉。开发者本地的测试数据由
|
||||
-- scripts/dev-clean-*.py 清理,不需要在这里猜测。
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE collect_task_org
|
||||
ALTER COLUMN expected_count DROP NOT NULL;
|
||||
|
||||
ALTER TABLE collect_task_org
|
||||
ALTER COLUMN expected_count DROP DEFAULT;
|
||||
|
||||
COMMENT ON COLUMN collect_task_org.expected_count IS
|
||||
'应填人数(期望值,管理员填写,可空);仅在与名册实际人数不一致时告警。NULL 表示未设置,与 0(预期 0 人)含义不同';
|
||||
Reference in New Issue
Block a user