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 人)含义不同';
|
||||
@@ -170,7 +170,7 @@ CREATE TABLE collect_task_org (
|
||||
org_id bigint NOT NULL,
|
||||
head_user_id bigint,
|
||||
deputy_user_ids varchar(256),
|
||||
expected_count integer NOT NULL DEFAULT 0,
|
||||
expected_count integer,
|
||||
status smallint NOT NULL DEFAULT 1,
|
||||
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
@@ -180,7 +180,7 @@ CREATE INDEX idx_tenant_head ON collect_task_org (tenant_id, head_user_id);
|
||||
COMMENT ON TABLE collect_task_org IS '任务覆盖组织与审核人绑定';
|
||||
COMMENT ON COLUMN collect_task_org.head_user_id IS '该班初审人(班主任),为空则取 sys_org.leader_user_id';
|
||||
COMMENT ON COLUMN collect_task_org.deputy_user_ids IS '副班主任/协办人,逗号分隔';
|
||||
COMMENT ON COLUMN collect_task_org.expected_count IS '应填人数(冗余,由名册汇总)';
|
||||
COMMENT ON COLUMN collect_task_org.expected_count IS '应填人数(期望值,管理员填写,可空);仅在与名册实际人数不一致时告警。NULL 表示未设置,与 0(预期 0 人)含义不同';
|
||||
COMMENT ON COLUMN collect_task_org.status IS '0已移除 1生效';
|
||||
|
||||
CREATE TABLE collect_form_schema (
|
||||
|
||||
+68
-2
@@ -48,8 +48,9 @@
|
||||
| M0 后端接口 | ✅ 租户开通与列表、组织 CRUD 与树、PC 登录/登出/当前用户、**账号管理全套(含 Excel 批量导入)** |
|
||||
| M0 验收 | ✅ **`e2e-m0.py` → 36/36**;**`e2e-m0-user.py` → 55/55** |
|
||||
| **M1-01 任务 CRUD 与状态机** | ✅ 建/改/删/发布/暂停/恢复/截止/归档 + 可见范围(班主任只看自己带的班);**`e2e-m1-task.py` → 71/71** |
|
||||
| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务** |
|
||||
| 待做 | M1 剩余(M1-02 覆盖班级、M1-03 表单 schema、M1-04 材料项、M1-06 二维码、M1-07~M1-10 前端);小程序(uni-app)未创建;CI/CD;H5 保底预研 |
|
||||
| **M1-02 覆盖班级与班主任** | ✅ `GET/PUT /tasks/{id}/orgs`(整体替换 + 软移除 + 历史行复用)+ 前端「配置班级」抽屉(勾选班级、主/副班主任、应填人数);**`e2e-m1-orgs.py` → 48/48** |
|
||||
| PC 前端 | ✅ 已换 Gi Admin Pro + 腾讯云主题;已实现:登录 / 工作台 / 组织架构 / 租户管理 / 账号管理 / **收集任务(含配置班级)** |
|
||||
| 待做 | M1 剩余(M1-03 表单 schema、M1-04 材料项、M1-06 二维码、M1-07~M1-10 前端);小程序(uni-app)未创建;CI/CD;H5 保底预研 |
|
||||
|
||||
---
|
||||
|
||||
@@ -153,6 +154,71 @@
|
||||
|
||||
---
|
||||
|
||||
## 一·六、M1-02 覆盖班级与班主任(2026-09-29 上午,未提交)
|
||||
|
||||
> 范围:`GET/PUT /api/v1/tasks/{id}/orgs`(《03-接口设计》§7.6);
|
||||
> 前端「收集任务 → 操作 → 配置班级」抽屉(勾选班级 + 主/副班主任 + 应填人数)。
|
||||
|
||||
**验收**:`python scripts/e2e-m1-orgs.py` → **48/48**;
|
||||
`e2e-m1-task.py` 71/71、`e2e-m0.py` 36/36、`e2e-m0-user.py` 55/55 回归通过;
|
||||
前端 `typecheck` / `eslint` / `lint:style` 0 错、`build` 通过。
|
||||
|
||||
### 三条守住的规则(改动时当成规则)
|
||||
|
||||
| # | 规则 | 为什么 |
|
||||
|:--:|---|---|
|
||||
| 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 只匹配含 `collect_task_org` 字样的行,而 `CONSTRAINT uk_task_org UNIQUE (task_id, org_id)` 那行**没有表名** —— 于是漏掉了,还基于"没有唯一约束"的错误前提写了一个多余的部分唯一索引 `uk_task_org_active`。已撤掉(DROP INDEX + 删 flyway 历史行 + 删文件),并更正了注释:**"复用已移除的行"不是优化而是必须**。教训:**查约束要查 `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__task_org_expected_count_nullable.sql` 改为**可空 + 去掉默认值**,并同步更正了 `docs/db/schema-postgresql.sql` 与 PRD 里的内联 DDL。**NULL ≠ 0 这条规则适用于所有"可选数字"字段** |
|
||||
| 3 | **删掉迁移文件后必须 `mvn clean`,否则它会"复活"** | 我删掉 `V4__task_org_active_unique.sql` 后只跑了 `mvn package`(没有 clean):`target/classes/db/migration/` 里**还留着旧副本**,被打进 jar,Flyway 发现历史里没有 v4 就**又执行了一遍** —— 索引回来了、历史行填回去了。之后 I 又建了同名版本号的新 V4,启动就报 `Found more than one migration with version 4`;清了旧副本后又变成 `Migration checksum mismatch for migration version 4`。**排查绕了好几圈,根源就是一行 `mvn package` 少了 `clean`。** 规则:**动过 `db/migration/` 里的文件(新增、删除、改名),一律 `mvn clean package`** |
|
||||
|
||||
顺带修掉:`views/task/index.vue` 的操作列从 3 个链接涨到 4 个(编辑/配置班级/发布/删除),
|
||||
160px 会挤成竖排 —— 把「进度」并进「状态」列腾出 40px,操作列给到 200px,合计 820px
|
||||
(可用约 824px)。发布被拦时现在会**告诉用户去哪里补**(`BLOCKING_HINTS`),
|
||||
而不是只说"未设置覆盖班级"。
|
||||
|
||||
### E2E 脚本必须"崩了也清理"
|
||||
|
||||
`e2e-m1-orgs.py` 第一次运行时在准备阶段抛了 `TypeError`(我自己写错了参数),
|
||||
**脚本直接退出、清理段没执行**,把两个测试账号留在了库里 —— 直到我在浏览器里
|
||||
看到「班主任」下拉里有个 `E2E班主任688ed2` 才发现。
|
||||
|
||||
已改成:清理逻辑抽成模块级 `cleanup_all()`(幂等),既在 `main()` 末尾调用,
|
||||
也在 `__main__` 的 `try/except BaseException` 里兜一次。
|
||||
**其余三条 E2E 脚本(`e2e-m0.py` / `e2e-m0-user.py` / `e2e-m1-task.py`)还是老样子,
|
||||
崩了会漏数据,下一轮一并按这个模式改。**
|
||||
|
||||
> 另外记一笔:清理残留账号时我写的用户名前缀过滤(`adm`)**把 `admin` 也匹配进去了**,
|
||||
> 是服务端的「不能停用当前登录的账号,请换一个管理员账号操作」把它挡下来的。
|
||||
> 那条护栏当初加的时候是为了防自锁,这次意外挡住了一次误删 —— 护栏的价值往往
|
||||
> 不是在被设计的场景里体现的。
|
||||
|
||||
### ⚠️ 新增两条前端硬规则(来自 `frontend-pc/AGENTS.md` 与 `.agents/skills/`)
|
||||
|
||||
1. **未经用户明确要求,不要 `git commit` / `push`**(AGENTS.md 常驻规则)。
|
||||
本轮改动**未提交**,工作区里留着。
|
||||
2. **`defineEmits` 必须用函数调用签名**,禁止 tuple/对象形式:
|
||||
```ts
|
||||
// ✅
|
||||
const emit = defineEmits<{ (e: 'save-success'): void }>()
|
||||
// ❌ const emit = defineEmits<{ 'save-success': [] }>()
|
||||
```
|
||||
框架自带组件(GiForm / GiButton / GiTable)用的都是前者 —— 这是本项目已确立的写法。
|
||||
我此前写的 5 个组件(`OrgFormModal` / `TaskFormModal` / `TaskOrgDrawer` /
|
||||
`UserFormModal` / `UserImportModal`)都是被禁的 tuple 形式,**本轮已全部改正**。
|
||||
|
||||
改文件前先按 `frontend-pc/AGENTS.md` 的表格加载对应 skill(`.vue` → `code-vue`,样式 → `code-css`,
|
||||
`.ts` → `code-ts`,CRUD → `project-crud`)。
|
||||
|
||||
---
|
||||
|
||||
**关键实现决策(不要在后继开发中改回去)**
|
||||
|
||||
| 决策 | 理由 |
|
||||
|
||||
+2
-2
@@ -1601,7 +1601,7 @@ CREATE TABLE collect_task_org (
|
||||
org_id bigint NOT NULL,
|
||||
head_user_id bigint,
|
||||
deputy_user_ids varchar(256),
|
||||
expected_count integer NOT NULL DEFAULT 0,
|
||||
expected_count integer,
|
||||
status smallint NOT NULL DEFAULT 1,
|
||||
create_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
@@ -1611,7 +1611,7 @@ CREATE INDEX idx_tenant_head ON collect_task_org (tenant_id, head_user_id);
|
||||
COMMENT ON TABLE collect_task_org IS '任务覆盖组织与审核人绑定';
|
||||
COMMENT ON COLUMN collect_task_org.head_user_id IS '该班初审人(班主任),为空则取 sys_org.leader_user_id';
|
||||
COMMENT ON COLUMN collect_task_org.deputy_user_ids IS '副班主任/协办人,逗号分隔';
|
||||
COMMENT ON COLUMN collect_task_org.expected_count IS '应填人数(冗余,由名册汇总)';
|
||||
COMMENT ON COLUMN collect_task_org.expected_count IS '应填人数(期望值,管理员填写,可空);仅在与名册实际人数不一致时告警。NULL 表示未设置,与 0(预期 0 人)含义不同';
|
||||
COMMENT ON COLUMN collect_task_org.status IS '0已移除 1生效';
|
||||
```
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 分页 `{ list, total }`(适配层会补一份 `records`)。
|
||||
*/
|
||||
import http from '@/utils/http'
|
||||
import { getUserList } from './user'
|
||||
|
||||
/** 审核流配置(§3.2) */
|
||||
export interface AuditConfig {
|
||||
@@ -217,3 +218,64 @@ export const NAMING_VARIABLES = [
|
||||
|
||||
/** 默认命名模板(与 DDL 的列默认值一致) */
|
||||
export const DEFAULT_NAMING_TEMPLATE = '{姓名}-{证件号}-{材料名}'
|
||||
|
||||
// ── 覆盖班级与班主任(§7.6)──────────────────────────
|
||||
|
||||
/** 任务覆盖的一个班 */
|
||||
export interface TaskOrgVO {
|
||||
orgId: number
|
||||
orgName: string
|
||||
/**
|
||||
* 该班初审人。
|
||||
*
|
||||
* ⚠️ 为空<b>不是</b>「没人审」,而是「取该班负责人」(`sys_org.leader_user_id`)。
|
||||
* 界面上要把这个区别讲清楚,否则管理员会以为漏配了,
|
||||
* 然后在几百个班上机械地点几百次 —— 而它们本来就等于班级负责人。
|
||||
*/
|
||||
headUserId: number | null
|
||||
headUserName: string | null
|
||||
deputyUserIds: number[]
|
||||
deputyUserNames: string[]
|
||||
/** 应填人数(期望值)。null = 未设置,与 0(预期 0 人)含义不同 */
|
||||
expectedCount: number | null
|
||||
/** 恒为 1(只返回生效行) */
|
||||
status: number
|
||||
}
|
||||
|
||||
/** 一个班的提交项。PUT 是整体替换语义:请求体就是最终答案 */
|
||||
export interface TaskOrgItem {
|
||||
orgId: number
|
||||
headUserId?: number | null
|
||||
deputyUserIds?: number[]
|
||||
/** 不传或传 null 表示"未设置",**不要传 0** —— 那是"预期 0 人" */
|
||||
expectedCount?: number | null
|
||||
}
|
||||
|
||||
export function getTaskOrgs(taskId: number) {
|
||||
return http.get<TaskOrgVO[]>(`/tasks/${taskId}/orgs`)
|
||||
}
|
||||
|
||||
/** 设置覆盖班级与班主任。不在列表里的班级会被软移除 */
|
||||
export function saveTaskOrgs(taskId: number, orgs: TaskOrgItem[]) {
|
||||
return http.put<void>(`/tasks/${taskId}/orgs`, { orgs })
|
||||
}
|
||||
|
||||
/**
|
||||
* 可被指定为初审人的账号。
|
||||
*
|
||||
* 拉两种角色再合并:后端要求初审人持有班主任**或**副班主任角色,
|
||||
* 所以两边的账号都是合法选项。只取启用状态 —— 停用的账号会被后端拒掉,
|
||||
* 列在选项里只会让人白选一次。
|
||||
*/
|
||||
export async function getTeacherOptions(): Promise<Array<{ id: number, label: string }>> {
|
||||
const [head, deputy] = await Promise.all([
|
||||
getUserList({ roleCode: 'HEAD_TEACHER', status: 1, pageSize: 200 }),
|
||||
getUserList({ roleCode: 'DEPUTY_TEACHER', status: 1, pageSize: 200 })
|
||||
])
|
||||
const merged = new Map<number, string>()
|
||||
for (const u of [...(head.data?.list ?? []), ...(deputy.data?.list ?? [])]) {
|
||||
// 带账号:学校里同名同姓很常见,只显示姓名选错了就是把别人的班交给了错误的人
|
||||
merged.set(u.id, u.username ? `${u.realName}(${u.username})` : u.realName)
|
||||
}
|
||||
return Array.from(merged.entries(), ([id, label]) => ({ id, label }))
|
||||
}
|
||||
|
||||
@@ -55,7 +55,9 @@ import { ORG_TYPE_NAMES } from '@/apis/xt/org'
|
||||
*/
|
||||
defineOptions({ name: 'OrgFormModal' })
|
||||
|
||||
const emit = defineEmits<{ 'save-success': [] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detailId = ref<number | null>(null)
|
||||
|
||||
@@ -119,7 +119,9 @@ import { DEFAULT_NAMING_TEMPLATE, NAMING_VARIABLES } from '@/apis/xt/task'
|
||||
*/
|
||||
defineOptions({ name: 'TaskFormModal' })
|
||||
|
||||
const emit = defineEmits<{ 'save-success': [] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const detailId = ref<number | null>(null)
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<a-drawer v-model:visible="visible" :width="820" :mask-closable="false" unmount-on-close
|
||||
:ok-loading="saving" ok-text="保存" @ok="save" @cancel="close">
|
||||
<template #title>
|
||||
<span>配置覆盖班级</span>
|
||||
<span class="org-drawer__task">{{ taskName }}</span>
|
||||
</template>
|
||||
|
||||
<a-alert v-if="readonly" type="normal" :show-icon="false" class="org-drawer__readonly">
|
||||
任务已{{ readonlyReason }},覆盖范围不可再修改,只能查看。
|
||||
</a-alert>
|
||||
|
||||
<a-spin :loading="loading" class="org-drawer__body">
|
||||
<div class="org-drawer__panes">
|
||||
<!-- 左:组织树 -->
|
||||
<div class="org-drawer__tree">
|
||||
<div class="org-drawer__pane-title">
|
||||
选择班级
|
||||
<!-- 只让班级可勾选:把「学校」或「专业部」当成一个班会选错范围,
|
||||
而且错误很隐蔽 —— 界面上只多一行,实际却把整校学生算进了应填范围 -->
|
||||
<span class="org-drawer__pane-hint">只能勾选到班级一层</span>
|
||||
</div>
|
||||
<a-spin :loading="treeLoading" class="org-drawer__tree-spin">
|
||||
<a-tree v-if="treeData.length" v-model:checked-keys="checkedKeys" :data="treeData"
|
||||
:field-names="treeFieldNames" checkable check-strictly :default-expand-all="true"
|
||||
:disabled="readonly" />
|
||||
<a-empty v-else description="还没有班级,请先在「组织架构」里建班级" />
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 右:已选班级 -->
|
||||
<div class="org-drawer__list">
|
||||
<div class="org-drawer__pane-title">
|
||||
已选 <strong>{{ rows.length }}</strong> 个班
|
||||
<span class="org-drawer__pane-hint">主班主任留空 = 由该班负责人初审</span>
|
||||
</div>
|
||||
|
||||
<a-empty v-if="!rows.length" description="左侧勾选本次收集涉及的班级" />
|
||||
|
||||
<div v-for="row in rows" :key="row.orgId" class="org-drawer__row">
|
||||
<div class="org-drawer__row-head">
|
||||
<span class="org-drawer__org">{{ orgName(row.orgId) }}</span>
|
||||
<a-link v-if="!readonly" status="danger" @click="removeRow(row)">移除</a-link>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="11">
|
||||
<div class="org-drawer__label">主班主任(初审人)</div>
|
||||
<a-select v-model="row.headUserId" placeholder="留空 = 取班级负责人" allow-clear
|
||||
:options="teacherOptions" :disabled="readonly" size="small" />
|
||||
<div v-if="!row.headUserId" class="org-drawer__fallback">
|
||||
{{ leaderHint(row.orgId) }}
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="org-drawer__label">应填人数</div>
|
||||
<a-input-number v-model="row.expectedCount" placeholder="未设置" :min="0"
|
||||
:disabled="readonly" size="small" class="org-drawer__num" />
|
||||
<div class="org-drawer__fallback">留空表示不比对,填了才与名册比</div>
|
||||
</a-col>
|
||||
<a-col :span="5">
|
||||
<div class="org-drawer__label">副班主任</div>
|
||||
<a-select v-model="row.deputyUserIds" multiple placeholder="无" allow-clear
|
||||
:options="deputyOptionsOf(row)" :disabled="readonly" size="small" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TreeFieldNames, TreeNodeData } from '@arco-design/web-vue'
|
||||
import type { OrgTreeVO } from '@/apis/xt/org'
|
||||
import type { TaskOrgItem } from '@/apis/xt/task'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import * as orgApi from '@/apis/xt/org'
|
||||
import * as taskApi from '@/apis/xt/task'
|
||||
|
||||
/**
|
||||
* 任务「覆盖班级与班主任」配置抽屉(《03-接口设计》§7.6)。
|
||||
*
|
||||
* 三处刻意的设计:
|
||||
*
|
||||
* 1. **主班主任留空 ≠ 没配**。留空表示"取该班负责人",所以留空时下面会显示出
|
||||
* 负责人是谁。不这么做,管理员会以为漏配了,然后在几百个班上机械地点几百次 ——
|
||||
* 而它们本来就等于班级负责人。这是本产品"少让人做无意义操作"的一个具体体现。
|
||||
*
|
||||
* 2. **只让班级可勾选**。把「学校」或「专业部」当成一个班会选错范围,而且错误很隐蔽:
|
||||
* 界面上只多一行,实际却把整校学生算进了应填范围。服务端也会拒,但让它在界面上
|
||||
* 就点不动,比事后报错好。
|
||||
*
|
||||
* 3. **应填人数留空 = 未设置,不是 0**。0 是"预期 0 人",是个有意义的业务值;
|
||||
* 拿 0 兼职表示"没填"会让每个没填的班都触发"应填 0 人、实际 31 人"的假告警,
|
||||
* 告警变噪音后就没人看了,真正对不上的那次反而被漏掉。
|
||||
*/
|
||||
defineOptions({ name: 'TaskOrgDrawer' })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const treeLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const taskId = ref<number | null>(null)
|
||||
const taskName = ref('')
|
||||
const readonly = ref(false)
|
||||
const readonlyReason = ref('')
|
||||
|
||||
const treeData = ref<TreeNodeData[]>([])
|
||||
const checkedKeys = ref<number[]>([])
|
||||
const teacherOptions = ref<Array<{ id: number, label: string }>>([])
|
||||
|
||||
/** 班级 id → 组织节点,用于取名与取"该班负责人" */
|
||||
const orgIndex = ref<Map<number, OrgTreeVO>>(new Map())
|
||||
|
||||
interface Row {
|
||||
orgId: number
|
||||
/**
|
||||
* 主班主任。
|
||||
*
|
||||
* ⚠️ 用 `undefined` 而不是 `null` 表示"未指定":Arco 的 `a-select` 的 v-model
|
||||
* 只接受 `string | number | (string|number)[] | undefined`,
|
||||
* 绑 `number | null` 会报 TS2322。提交时再转回 `null`(接口约定的"清空"值)。
|
||||
*/
|
||||
headUserId: number | undefined
|
||||
deputyUserIds: number[]
|
||||
/** undefined = 未设置(提交时不传/传 null),不要用 0 代替 —— 0 是"预期 0 人" */
|
||||
expectedCount: number | undefined
|
||||
}
|
||||
|
||||
const rows = ref<Row[]>([])
|
||||
|
||||
const treeFieldNames: TreeFieldNames = {
|
||||
key: 'key',
|
||||
title: 'title',
|
||||
children: 'children',
|
||||
disableCheckbox: 'disableCheckbox'
|
||||
}
|
||||
|
||||
function flatten(nodes: OrgTreeVO[], into: Map<number, OrgTreeVO>) {
|
||||
for (const n of nodes || []) {
|
||||
into.set(n.id, n)
|
||||
flatten(n.children ?? [], into)
|
||||
}
|
||||
}
|
||||
|
||||
function toTreeData(nodes: OrgTreeVO[]): TreeNodeData[] {
|
||||
return (nodes ?? []).map((n) => ({
|
||||
key: n.id,
|
||||
title: n.orgName,
|
||||
// 只有班级可以勾选
|
||||
disableCheckbox: n.orgType !== 3,
|
||||
children: toTreeData(n.children ?? [])
|
||||
}))
|
||||
}
|
||||
|
||||
function orgName(orgId: number) {
|
||||
return orgIndex.value.get(orgId)?.orgName ?? `班级 ${orgId}`
|
||||
}
|
||||
|
||||
/** 留空时告诉用户"将由谁审",而不是留一句让人怀疑的空白 */
|
||||
function leaderHint(orgId: number) {
|
||||
const org = orgIndex.value.get(orgId)
|
||||
return org?.leaderName ? `将由班级负责人 ${org.leaderName} 初审` : '该班未设负责人,建议指定初审人'
|
||||
}
|
||||
|
||||
/** 副班主任选项里排除掉本行已选为主班主任的人(同一人不能既主又副,服务端会拒) */
|
||||
function deputyOptionsOf(row: Row) {
|
||||
return teacherOptions.value.filter((o) => o.id !== row.headUserId)
|
||||
}
|
||||
|
||||
/** 勾选变化 → 行列表变化。rows 是唯一事实来源,勾选框只是它的一个视图 */
|
||||
watch(checkedKeys, (keys) => {
|
||||
const set = new Set(keys as number[])
|
||||
rows.value = rows.value.filter((r) => set.has(r.orgId))
|
||||
for (const id of set) {
|
||||
if (!rows.value.some((r) => r.orgId === id)) {
|
||||
rows.value.push({ orgId: id, headUserId: undefined, deputyUserIds: [], expectedCount: undefined })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function removeRow(row: Row) {
|
||||
rows.value = rows.value.filter((r) => r.orgId !== row.orgId)
|
||||
checkedKeys.value = checkedKeys.value.filter((k) => k !== row.orgId)
|
||||
}
|
||||
|
||||
async function open(task: { id: number, taskName: string, status: number }) {
|
||||
taskId.value = task.id
|
||||
taskName.value = task.taskName
|
||||
// 与后端 EDITABLE_TASK_STATUS 一致:草稿/收集中/已暂停可改,已截止/已归档只读
|
||||
const reason = { 2: '截止', 3: '归档' }[task.status] ?? ''
|
||||
readonly.value = !!reason
|
||||
readonlyReason.value = reason
|
||||
visible.value = true
|
||||
rows.value = []
|
||||
checkedKeys.value = []
|
||||
|
||||
loading.value = true
|
||||
treeLoading.value = true
|
||||
try {
|
||||
const [treeRes, orgRes] = await Promise.all([
|
||||
orgApi.getOrgTree(),
|
||||
taskApi.getTaskOrgs(task.id)
|
||||
])
|
||||
const tree = treeRes.data ?? []
|
||||
treeData.value = toTreeData(tree)
|
||||
const index = new Map<number, OrgTreeVO>()
|
||||
flatten(tree, index)
|
||||
orgIndex.value = index
|
||||
|
||||
for (const o of orgRes.data ?? []) {
|
||||
rows.value.push({
|
||||
orgId: o.orgId,
|
||||
// 后端回 null 表示"未指定";界面上用 undefined 让它显示占位符
|
||||
headUserId: o.headUserId ?? undefined,
|
||||
deputyUserIds: o.deputyUserIds ?? [],
|
||||
expectedCount: o.expectedCount ?? undefined
|
||||
})
|
||||
}
|
||||
checkedKeys.value = rows.value.map((r) => r.orgId)
|
||||
} finally {
|
||||
loading.value = false
|
||||
treeLoading.value = false
|
||||
}
|
||||
|
||||
// 班主任选项不影响主流程,单独加载,失败也不该让抽屉打不开
|
||||
taskApi.getTeacherOptions()
|
||||
.then((opts) => { teacherOptions.value = opts })
|
||||
.catch(() => { teacherOptions.value = [] })
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!taskId.value) return
|
||||
const orgs: TaskOrgItem[] = rows.value.map((r) => ({
|
||||
orgId: r.orgId,
|
||||
headUserId: r.headUserId ?? null,
|
||||
deputyUserIds: r.deputyUserIds ?? [],
|
||||
// 未填就传 null("未设置"),不要传 0
|
||||
expectedCount: r.expectedCount ?? null
|
||||
}))
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await taskApi.saveTaskOrgs(taskId.value, orgs)
|
||||
Message.success(orgs.length ? `已保存 ${orgs.length} 个班的覆盖范围` : '已清空覆盖范围')
|
||||
visible.value = false
|
||||
emit('save-success')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.org-drawer {
|
||||
&__task {
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__readonly {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: block;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
&__panes {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__tree {
|
||||
flex: none;
|
||||
width: 250px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-border-2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__tree-spin {
|
||||
display: block;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__list {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__pane-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
&__pane-hint {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__row {
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--color-fill-1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__org {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__fallback {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
&__num {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -49,7 +49,7 @@
|
||||
留给下一轮:把「手机号」或「状态 / 最后登录」并进「姓名 / 账号」那一列。
|
||||
-->
|
||||
<a-table :data="list" :loading="loading" row-key="id" :pagination="pagination"
|
||||
:scroll="{ x: 840 }" @page-change="onPageChange">
|
||||
:scroll="{ x: 820 }" @page-change="onPageChange">
|
||||
<template #columns>
|
||||
<a-table-column title="任务" :width="230" ellipsis tooltip>
|
||||
<template #cell="{ record }">
|
||||
@@ -72,31 +72,34 @@
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="进度" :width="90">
|
||||
<!--
|
||||
「进度」并进「状态」列,是为了给操作列腾宽度。
|
||||
1366 的屏幕减去 200 侧栏,表格可用约 824px;操作列需要 200px 才放得下
|
||||
「编辑 / 配置班级 / 发布 / 删除」四个链接(160px 会挤成竖排)。
|
||||
进度在 M1 阶段恒为「待导入名册」,并进来不损失信息。
|
||||
-->
|
||||
<a-table-column title="状态 / 进度" :width="110" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="TASK_STATUS[record.status]?.color ?? 'gray'">
|
||||
{{ TASK_STATUS[record.status]?.text ?? '未知' }}
|
||||
</a-tag>
|
||||
<!--
|
||||
stat 在 M1 阶段后端恒为 null(它依赖名册 M2 与提交单 M3)。
|
||||
这里明确显示「待导入名册」,而不是把 null 当成 0 显示成"0%" ——
|
||||
后者会让人以为"没人填",而真相是"还没有分母"。
|
||||
-->
|
||||
<span v-if="!record.stat" class="task__muted">待导入名册</span>
|
||||
<span v-else class="task__muted">{{ record.stat.submitRate }}</span>
|
||||
<div class="task__muted">
|
||||
{{ record.stat ? record.stat.submitRate : '待导入名册' }}
|
||||
</div>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="状态" :width="80" align="center">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="TASK_STATUS[record.status]?.color ?? 'gray'">
|
||||
{{ TASK_STATUS[record.status]?.text ?? '未知' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
|
||||
<a-table-column title="操作" :width="160" align="center" fixed="right">
|
||||
<a-table-column title="操作" :width="200" align="center" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-space v-if="canManage" size="mini" wrap>
|
||||
<template v-if="record.status === TASK_STATUS_DRAFT">
|
||||
<a-link @click="onEdit(record)">编辑</a-link>
|
||||
<a-link @click="onConfigOrgs(record)">配置班级</a-link>
|
||||
<a-link @click="onPublish(record)">发布</a-link>
|
||||
<a-popconfirm content="删除后不可恢复,确认删除这个草稿?" type="warning"
|
||||
@ok="onDelete(record)">
|
||||
@@ -105,11 +108,13 @@
|
||||
</template>
|
||||
|
||||
<template v-else-if="record.status === TASK_STATUS_COLLECTING">
|
||||
<a-link @click="onConfigOrgs(record)">配置班级</a-link>
|
||||
<a-link status="warning" @click="onPause(record)">暂停</a-link>
|
||||
<a-link @click="onClose(record)">截止</a-link>
|
||||
</template>
|
||||
|
||||
<template v-else-if="record.status === TASK_STATUS_PAUSED">
|
||||
<a-link @click="onConfigOrgs(record)">配置班级</a-link>
|
||||
<a-link status="success" @click="onResume(record)">恢复</a-link>
|
||||
<a-link @click="onClose(record)">截止</a-link>
|
||||
</template>
|
||||
@@ -128,6 +133,7 @@
|
||||
</a-card>
|
||||
|
||||
<TaskFormModal ref="formModalRef" @save-success="load" />
|
||||
<TaskOrgDrawer ref="orgDrawerRef" @save-success="load" />
|
||||
</GiPageLayout>
|
||||
</template>
|
||||
|
||||
@@ -145,6 +151,7 @@ import {
|
||||
} from '@/apis/xt/task'
|
||||
import { useUserStore } from '@/stores'
|
||||
import TaskFormModal from './TaskFormModal.vue'
|
||||
import TaskOrgDrawer from './TaskOrgDrawer.vue'
|
||||
|
||||
/**
|
||||
* 收集任务(《03-接口设计》§7.1 ~ §7.5)。
|
||||
@@ -172,6 +179,22 @@ const statusFilter = ref<number | undefined>(undefined)
|
||||
const query = reactive({ pageNum: 1, pageSize: 10, keyword: '', batchNo: '' })
|
||||
|
||||
const formModalRef = useTemplateRef('formModalRef')
|
||||
const orgDrawerRef = useTemplateRef('orgDrawerRef')
|
||||
|
||||
/**
|
||||
* 发布被拦时,每个阻断项**去哪里修**。
|
||||
*
|
||||
* 光说"未设置覆盖班级或班主任"是没用的:用户知道缺什么,但不知道在哪补。
|
||||
* 而这 5 项分属 4 个不同的界面(名册 / 材料规则 / 本页的配置班级 / 编辑弹窗),
|
||||
* 让用户自己找是纯粹的浪费。
|
||||
*/
|
||||
const BLOCKING_HINTS: Record<number, string> = {
|
||||
40016: '请先到「名册管理」导入本任务的报考名册',
|
||||
40017: '名册缺少「姓名」或「身份证号」列,请重新导入名册',
|
||||
40018: '请到「材料规则」里配置至少一项必传材料',
|
||||
40019: '请点本行的「配置班级」,勾选本次收集涉及的班级',
|
||||
40020: '命名规则有问题,请点「编辑」检查文件命名模板'
|
||||
}
|
||||
|
||||
const statusChips = [
|
||||
{ label: '全部', value: undefined },
|
||||
@@ -233,6 +256,10 @@ function onEdit(row: TaskVO) {
|
||||
formModalRef.value?.edit(row)
|
||||
}
|
||||
|
||||
function onConfigOrgs(row: TaskVO) {
|
||||
orgDrawerRef.value?.open(row)
|
||||
}
|
||||
|
||||
async function onDelete(row: TaskVO) {
|
||||
await taskApi.deleteTask(row.id)
|
||||
Message.success('已删除')
|
||||
@@ -259,12 +286,14 @@ async function onPublish(row: TaskVO) {
|
||||
} catch (e) {
|
||||
const err = e as Error & { bizCode?: number }
|
||||
const msg = err?.message ?? ''
|
||||
// 阻断级错误码:把缺项讲清楚,不给 force
|
||||
// 阻断级错误码:把缺项讲清楚 + 告诉他去哪里补,不给 force 入口
|
||||
// (给了也绕不过去,只会让人白试一次)
|
||||
const blocking = [40016, 40017, 40018, 40019, 40020]
|
||||
if (err?.bizCode && blocking.includes(err.bizCode)) {
|
||||
const hint = BLOCKING_HINTS[err.bizCode]
|
||||
Modal.warning({
|
||||
title: '还不能发布',
|
||||
content: `${msg}
|
||||
content: `${msg}${hint ? `\n\n👉 ${hint}` : ''}
|
||||
|
||||
这些是阻断级检查,必须补齐后重新发布(点「确定」不会放行)。`,
|
||||
hideCancel: true
|
||||
|
||||
@@ -153,7 +153,9 @@ import { useUserStore } from '@/stores'
|
||||
*/
|
||||
defineOptions({ name: 'UserFormModal' })
|
||||
|
||||
const emit = defineEmits<{ 'save-success': [] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
|
||||
@@ -177,7 +177,9 @@ import { useUserStore } from '@/stores'
|
||||
*/
|
||||
defineOptions({ name: 'UserImportModal' })
|
||||
|
||||
const emit = defineEmits<{ 'save-success': [] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'save-success'): void
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
M1-02 任务覆盖班级与班主任配置 验收测试。
|
||||
|
||||
覆盖《03-接口设计》§7.6:
|
||||
[x] GET /tasks/{id}/orgs 只返回生效的覆盖关系,班级名与班主任名装配正确
|
||||
[x] PUT /tasks/{id}/orgs 整体替换语义、软移除、历史行复用
|
||||
[x] 校验:班级必须是 orgType=3、必须同租户、同一个班不能重复
|
||||
[x] 校验:初审人必须是本租户内已启用且持班主任角色的账号、主副不能同一人
|
||||
[x] 状态限制:草稿/收集中/已暂停可改,已截止/已归档不可改
|
||||
[x] 权限:班主任无 task:manage,不能改
|
||||
|
||||
为什么这些校验值得逐条断言:
|
||||
「指定初审人」本质上是一次**授权** —— 那个人将能看到该班学生的身份证号与学籍材料。
|
||||
所以"不能把 A 校的班挂到 B 校的任务上""不能把非班主任设为初审人"
|
||||
不是体验问题,是数据隔离与授权边界问题。每一条都要有断言守着。
|
||||
|
||||
用法
|
||||
python scripts/e2e-m1-orgs.py
|
||||
python scripts/e2e-m1-orgs.py --base http://localhost:8080
|
||||
|
||||
依赖:cryptography;且需要能 `docker exec xt-pg`(造发布状态与清理测试租户)
|
||||
退出码:0 = 全部通过
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PUB_KEY = os.path.join(ROOT, "deploy", "rsa-public-key-dev.pem")
|
||||
|
||||
PG_CONTAINER = os.environ.get("XT_PG_CONTAINER", "xt-pg")
|
||||
PG_USER = os.environ.get("PG_USER", "fill")
|
||||
PG_DB = os.environ.get("PG_DB", "filldb")
|
||||
PG_PWD = os.environ.get("PG_PASSWORD", "fill_dev_pwd")
|
||||
|
||||
PASSED: list[str] = []
|
||||
FAILED: list[tuple[str, str]] = []
|
||||
|
||||
CREATED_TASK_IDS: list[int] = []
|
||||
CREATED_USER_IDS: list[int] = []
|
||||
CREATED_TENANT_IDS: list[int] = []
|
||||
|
||||
#: 清理时要用的上下文。放在模块级,是为了让**脚本中途崩溃也能清理**。
|
||||
#:
|
||||
#: ⚠️ 教训:本脚本第一次运行时在准备阶段抛了 TypeError 直接退出,
|
||||
#: 清理段根本没被执行,于是把两个测试账号(hte688ed2 / adm688ed2)留在了库里。
|
||||
#: 脚本崩了就留下垃圾数据,是自动化测试最容易犯也最容易被忽略的问题 ——
|
||||
#: 因为"跑通了"的时候它不会暴露。
|
||||
CTX: dict[str, str | None] = {"base": None, "token": None}
|
||||
|
||||
|
||||
def cleanup_all() -> None:
|
||||
"""
|
||||
清理所有本脚本造出来的数据。**幂等**,可以重复调用;单项失败不影响其余项。
|
||||
|
||||
被 `main()` 末尾与 `__main__` 的 finally 各调用一次 —— 前者的输出好看,
|
||||
后者保证崩溃时也能兜住。
|
||||
"""
|
||||
base = CTX.get("base")
|
||||
token = CTX.get("token")
|
||||
|
||||
if CREATED_TASK_IDS:
|
||||
ids = ",".join(str(i) for i in CREATED_TASK_IDS)
|
||||
for stmt in [
|
||||
f"DELETE FROM collect_task_org WHERE task_id IN ({ids})",
|
||||
f"DELETE FROM collect_material_def WHERE task_id IN ({ids})",
|
||||
f"DELETE FROM collect_roster WHERE task_id IN ({ids})",
|
||||
f"DELETE FROM collect_task WHERE id IN ({ids})",
|
||||
]:
|
||||
sql(stmt)
|
||||
|
||||
if base and token:
|
||||
for uid in CREATED_USER_IDS:
|
||||
try:
|
||||
call("DELETE", f"{base}/api/v1/users/{uid}", token)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for tid in CREATED_TENANT_IDS:
|
||||
for stmt in [
|
||||
f"DELETE FROM sys_user_role WHERE user_id IN (SELECT id FROM sys_user WHERE tenant_id = {tid})",
|
||||
f"DELETE FROM sys_user WHERE tenant_id = {tid}",
|
||||
f"DELETE FROM sys_org WHERE tenant_id = {tid}",
|
||||
f"DELETE FROM sys_role WHERE tenant_id = {tid}",
|
||||
f"DELETE FROM collect_task WHERE tenant_id = {tid}",
|
||||
f"DELETE FROM sys_tenant WHERE id = {tid}",
|
||||
]:
|
||||
sql(stmt)
|
||||
|
||||
|
||||
# ── 断言 ──────────────────────────────────────────────
|
||||
|
||||
def ok(name: str) -> None:
|
||||
PASSED.append(name)
|
||||
print(f" [OK] {name}")
|
||||
|
||||
|
||||
def fail(name: str, detail: str) -> None:
|
||||
FAILED.append((name, detail))
|
||||
print(f" [FAIL] {name}\n {detail}")
|
||||
|
||||
|
||||
def check(name: str, cond: bool, detail: str = "") -> bool:
|
||||
if cond:
|
||||
ok(name)
|
||||
else:
|
||||
fail(name, detail or "断言不成立")
|
||||
return cond
|
||||
|
||||
|
||||
def show(body: dict, indent: int = 9) -> None:
|
||||
pad = " " * indent
|
||||
text = json.dumps(body, ensure_ascii=False)
|
||||
print(f"{pad}完整响应:{text[:400]}{'…' if len(text) > 400 else ''}")
|
||||
|
||||
|
||||
def blocking_hint(body: dict) -> str:
|
||||
"""
|
||||
把 40001 的结构化校验明细摊平成一句人话。
|
||||
|
||||
后端对参数校验失败返回的是 `data.blocking[]`(每项含 field 与 title),
|
||||
而不是只给一句"存在阻断性校验问题" —— 这对前端很重要(能直接高亮出错字段),
|
||||
但脚本只打印 msg 的话就完全看不出是哪个字段错了,排查要多花好几轮。
|
||||
"""
|
||||
items = ((body.get("data") or {}).get("blocking")) or []
|
||||
if not items:
|
||||
return ""
|
||||
parts = [f"{i.get('field')}: {i.get('title')}" for i in items if i]
|
||||
return " 明细[" + ";".join(parts) + "]" if parts else ""
|
||||
|
||||
|
||||
def sql(stmt: str) -> tuple[bool, str]:
|
||||
"""直接执行 SQL —— 只用于造/清测试夹具。"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["docker", "exec", "-e", f"PGPASSWORD={PG_PWD}", PG_CONTAINER,
|
||||
"psql", "-U", PG_USER, "-d", PG_DB, "-t", "-A", "-c", stmt],
|
||||
capture_output=True, text=True, timeout=40)
|
||||
return r.returncode == 0, (r.stdout or r.stderr).strip()
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
# ── HTTP ────────────────────────────────────────────
|
||||
|
||||
def rsa_encrypt(plain: str) -> str:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
with open(PUB_KEY, "rb") as f:
|
||||
pub = serialization.load_pem_public_key(f.read())
|
||||
return base64.b64encode(pub.encrypt(plain.encode(), padding.PKCS1v15())).decode()
|
||||
|
||||
|
||||
def call(method: str, url: str, token: str | None = None,
|
||||
body: dict | None = None, timeout: int = 30) -> tuple[int, dict]:
|
||||
headers = {"Content-Type": "application/json; charset=utf-8"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read().decode("utf-8")
|
||||
try:
|
||||
return e.code, json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return e.code, {"raw": raw}
|
||||
|
||||
|
||||
def login(base: str, username: str, password: str, tenant: str | None = None):
|
||||
payload = {"username": username, "password": rsa_encrypt(password)}
|
||||
if tenant:
|
||||
payload["tenantCode"] = tenant
|
||||
return call("POST", f"{base}/api/v1/auth/login", body=payload)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="M1-02 覆盖班级与班主任 E2E")
|
||||
ap.add_argument("--base", default=os.environ.get("XT_BASE", "http://localhost:8080"))
|
||||
ap.add_argument("--tenant", default=os.environ.get("XT_TENANT", "demo"))
|
||||
ap.add_argument("--password", default="Admin@123")
|
||||
args = ap.parse_args()
|
||||
base = args.base.rstrip("/")
|
||||
|
||||
print("=" * 72)
|
||||
print(f"M1-02 覆盖班级与班主任 E2E base={base} 租户={args.tenant}")
|
||||
print("=" * 72)
|
||||
|
||||
suffix = uuid.uuid4().hex[:6]
|
||||
|
||||
# ── 0. 准备 ────────────────────────────────────────
|
||||
print("\n[0] 准备")
|
||||
status, body = login(base, "admin", args.password, args.tenant)
|
||||
if body.get("code") != 0:
|
||||
fail("管理员登录", f"HTTP {status} code={body.get('code')} msg={body.get('msg')}")
|
||||
return 1
|
||||
token = body["data"]["token"]
|
||||
tenant_id = (body["data"].get("tenant") or {}).get("id")
|
||||
CTX["base"] = base
|
||||
CTX["token"] = token
|
||||
ok(f"管理员登录(租户 id={tenant_id})")
|
||||
|
||||
status, body = call("GET", f"{base}/api/v1/orgs/tree", token)
|
||||
classes: list[dict] = []
|
||||
school: list[dict] = []
|
||||
|
||||
def walk(nodes):
|
||||
for n in nodes or []:
|
||||
if n.get("orgType") == 3:
|
||||
classes.append(n)
|
||||
if n.get("orgType") == 1:
|
||||
school.append(n)
|
||||
walk(n.get("children"))
|
||||
|
||||
walk(body.get("data"))
|
||||
if len(classes) < 2 or not school:
|
||||
fail("准备组织数据", f"demo 租户需要至少 2 个班级和 1 个学校节点,实际 班级={len(classes)} 学校={len(school)}")
|
||||
return 1
|
||||
ok(f"取到班级与学校节点:{classes[0]['orgName']} / {classes[1]['orgName']} / {school[0]['orgName']}")
|
||||
|
||||
# 造一个班主任账号(指定初审人时必须持有班主任角色)
|
||||
# ⚠️ userType 是接口的 @NotNull 字段。界面上"用户类型由角色推导"是前端的职责,
|
||||
# 接口这层仍要求显式传,且服务端会校验它与角色一致(3=班主任,2=学校管理员)
|
||||
ht_user = f"hte{suffix}"
|
||||
status, body = call("POST", f"{base}/api/v1/users", token, {
|
||||
"username": ht_user, "realName": f"E2E班主任{suffix}",
|
||||
"phone": "13900139101", "roleCodes": ["HEAD_TEACHER"], "userType": 3,
|
||||
})
|
||||
if body.get("code") != 0:
|
||||
fail("造班主任账号", f"code={body.get('code')} msg={body.get('msg')}{blocking_hint(body)}")
|
||||
return 1
|
||||
head_id = body["data"]["id"]
|
||||
head_pwd = body["data"]["initialPassword"]
|
||||
CREATED_USER_IDS.append(head_id)
|
||||
ok(f"造班主任账号(id={head_id})")
|
||||
|
||||
# 再造一个非班主任(学校管理员),用于验证"非班主任不能当初审人"
|
||||
admin2_user = f"adm{suffix}"
|
||||
status, body = call("POST", f"{base}/api/v1/users", token, {
|
||||
"username": admin2_user, "realName": f"E2E管理员{suffix}",
|
||||
"phone": "13900139102", "roleCodes": ["SCHOOL_ADMIN"], "userType": 2,
|
||||
})
|
||||
non_teacher_id = None
|
||||
if body.get("code") == 0:
|
||||
non_teacher_id = body["data"]["id"]
|
||||
CREATED_USER_IDS.append(non_teacher_id)
|
||||
ok(f"造非班主任账号(id={non_teacher_id})")
|
||||
else:
|
||||
fail("造非班主任账号", f"code={body.get('code')} msg={body.get('msg')}{blocking_hint(body)}")
|
||||
|
||||
# 造第二个租户(跨租户隔离用)
|
||||
other_org_id = None
|
||||
status, plat = login(base, "platform", "Platform@123")
|
||||
if plat.get("code") != 0:
|
||||
fail("平台管理员登录", f"code={plat.get('code')} msg={plat.get('msg')}")
|
||||
else:
|
||||
plat_token = plat["data"]["token"]
|
||||
other_code = f"e2e{suffix}"
|
||||
status, body = call("POST", f"{base}/api/v1/tenants", plat_token, {
|
||||
"tenantName": f"E2E 隔离学校 {suffix}", "tenantCode": other_code, "tenantType": 1,
|
||||
"contactName": "测试联系人", "contactPhone": "13900139103",
|
||||
"expireTime": "2027-12-31",
|
||||
"adminUsername": "admin", "adminRealName": "E2E 隔离管理员",
|
||||
"adminPhone": "13900139104",
|
||||
})
|
||||
if body.get("code") != 0:
|
||||
fail("开通第二个租户", f"code={body.get('code')} msg={body.get('msg')}")
|
||||
else:
|
||||
other_tenant_id = body["data"]["id"]
|
||||
other_pwd = body["data"]["adminInitialPassword"]
|
||||
CREATED_TENANT_IDS.append(other_tenant_id)
|
||||
status, ob = login(base, "admin", other_pwd, other_code)
|
||||
if ob.get("code") != 0:
|
||||
fail("第二个租户管理员登录", f"code={ob.get('code')} msg={ob.get('msg')}")
|
||||
else:
|
||||
other_token = ob["data"]["token"]
|
||||
# 在新租户里建一个班(先建学校→再建班)
|
||||
status, body = call("POST", f"{base}/api/v1/orgs", other_token, {
|
||||
"parentId": 0, "orgName": f"隔离学校{suffix}", "orgType": 1,
|
||||
"orgCode": f"ISOL{suffix}", "sortNo": 1,
|
||||
})
|
||||
parent = body.get("data")
|
||||
status, body = call("POST", f"{base}/api/v1/orgs", other_token, {
|
||||
"parentId": parent, "orgName": f"隔离班{suffix}", "orgType": 3,
|
||||
"orgCode": f"ISOLC{suffix}", "sortNo": 1,
|
||||
})
|
||||
if body.get("code") == 0:
|
||||
other_org_id = body["data"]
|
||||
ok(f"第二个租户与它的班级已就绪(orgId={other_org_id})")
|
||||
else:
|
||||
fail("在第二个租户里建班级", f"code={body.get('code')} msg={body.get('msg')}")
|
||||
|
||||
# 造一个任务
|
||||
batch = "2001"
|
||||
status, body = call("POST", f"{base}/api/v1/tasks", token, {
|
||||
"taskName": f"E2E覆盖范围 {suffix}", "collectMode": 1, "batchNo": batch,
|
||||
"majorName": "数控加工", "certType": 1,
|
||||
"startTime": "2026-09-28", "endTime": "2026-12-31",
|
||||
})
|
||||
if body.get("code") != 0:
|
||||
fail("造任务", f"code={body.get('code')} msg={body.get('msg')}")
|
||||
return 1
|
||||
task_id = body["data"]["id"]
|
||||
CREATED_TASK_IDS.append(task_id)
|
||||
ok(f"造任务(id={task_id} code={body['data']['taskCode']})")
|
||||
|
||||
# ── 1. 空任务 ───────────────────────────────────────
|
||||
print("\n[1] 空任务的覆盖范围")
|
||||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
|
||||
check("新任务的覆盖范围是空数组(不是 null)", body.get("code") == 0 and body.get("data") == [],
|
||||
f"code={body.get('code')} data={body.get('data')}")
|
||||
|
||||
# ── 2. 设置覆盖班级 ─────────────────────────────────
|
||||
print("\n[2] 设置覆盖班级与班主任")
|
||||
c1, c2 = classes[0]["id"], classes[1]["id"]
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [
|
||||
{"orgId": c1, "headUserId": head_id, "deputyUserIds": [], "expectedCount": 28},
|
||||
{"orgId": c2, "headUserId": None, "deputyUserIds": [head_id], "expectedCount": 31},
|
||||
],
|
||||
})
|
||||
check("保存成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
|
||||
|
||||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
|
||||
rows = body.get("data") or []
|
||||
check("返回 2 个班", len(rows) == 2, f"实际 {len(rows)} 行:{json.dumps(rows, ensure_ascii=False)[:200]}")
|
||||
by_org = {r["orgId"]: r for r in rows}
|
||||
r1 = by_org.get(c1) or {}
|
||||
r2 = by_org.get(c2) or {}
|
||||
check("第一个班:班级名正确", r1.get("orgName") == classes[0]["orgName"],
|
||||
f"{r1.get('orgName')} != {classes[0]['orgName']}")
|
||||
check("第一个班:初审人姓名带账号(同名同姓时能区分)",
|
||||
(r1.get("headUserName") or "").startswith(f"E2E班主任{suffix}")
|
||||
and f"({ht_user})" in (r1.get("headUserName") or ""),
|
||||
f"headUserName={r1.get('headUserName')}")
|
||||
check("第一个班:应填人数保存正确", r1.get("expectedCount") == 28, f"{r1.get('expectedCount')}")
|
||||
check("第一个班:副班主任为空数组(不是 null)",
|
||||
r1.get("deputyUserIds") == [] and r1.get("deputyUserNames") == [],
|
||||
f"{r1.get('deputyUserIds')} / {r1.get('deputyUserNames')}")
|
||||
check("第二个班:初审人为空(表示取班级负责人,不是「没人审」)",
|
||||
r2.get("headUserId") is None and r2.get("headUserName") is None,
|
||||
f"headUserId={r2.get('headUserId')} headUserName={r2.get('headUserName')}")
|
||||
check("第二个班:副班主任姓名已装配",
|
||||
r2.get("deputyUserNames") and r2["deputyUserNames"][0].startswith(f"E2E班主任{suffix}"),
|
||||
f"deputyUserNames={r2.get('deputyUserNames')}")
|
||||
check("status 恒为 1(只返回生效行)", all(r.get("status") == 1 for r in rows),
|
||||
f"{[r.get('status') for r in rows]}")
|
||||
|
||||
# ── 3. 校验(全部拒绝)──────────────────────────────
|
||||
print("\n[3] 校验:不该放行的都要拦住")
|
||||
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": c1}, {"orgId": c1}],
|
||||
})
|
||||
check("同一个班出现两次被拒", body.get("code") == 40001,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": 999999999}],
|
||||
})
|
||||
check("不存在的班级被拒 40001", body.get("code") == 40001,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": school[0]["id"]}],
|
||||
})
|
||||
check("把「学校」当成一个班被拒 40001", body.get("code") == 40001,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
check("拒绝理由讲清楚了「不是班级」(而不是笼统的校验失败)",
|
||||
"不是班级" in (body.get("msg") or ""),
|
||||
f"msg={body.get('msg')}")
|
||||
|
||||
if non_teacher_id:
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": c1, "headUserId": non_teacher_id}],
|
||||
})
|
||||
check("把非班主任设为初审人被拒 40001", body.get("code") == 40001,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
check("拒绝理由讲清楚了「不是班主任」并给出怎么办",
|
||||
"不是班主任" in (body.get("msg") or "") and "账号管理" in (body.get("msg") or ""),
|
||||
f"msg={body.get('msg')}")
|
||||
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": c1, "headUserId": head_id, "deputyUserIds": [head_id]}],
|
||||
})
|
||||
check("主班主任与副班主任同一人被拒 40001", body.get("code") == 40001,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
|
||||
if other_org_id:
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": other_org_id}],
|
||||
})
|
||||
check("跨租户的班级被拒 40001(数据隔离边界)", body.get("code") == 40001,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
check("跨租户的班被当作「不存在」,不透露它存在",
|
||||
"不存在" in (body.get("msg") or ""),
|
||||
f"msg={body.get('msg')}")
|
||||
else:
|
||||
fail("跨租户隔离用例", "第二个租户没造出来,这条最重要的隔离断言没能执行")
|
||||
|
||||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
|
||||
check("以上失败都没有污染数据(仍是 2 个班)", len(body.get("data") or []) == 2,
|
||||
f"实际 {len(body.get('data') or [])} 行")
|
||||
|
||||
# ── 4. 软移除与历史行复用 ───────────────────────────
|
||||
print("\n[4] 软移除与历史行复用")
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28}],
|
||||
})
|
||||
check("只提交 1 个班(整体替换)成功", body.get("code") == 0,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
|
||||
check("另一个班已从覆盖范围消失", len(body.get("data") or []) == 1,
|
||||
f"实际 {len(body.get('data') or [])} 行")
|
||||
|
||||
okk, out = sql(f"SELECT count(*) FROM collect_task_org WHERE task_id = {task_id} "
|
||||
f"AND org_id = {c2} AND status = 0")
|
||||
check("被移除的班是软移除(status=0 的行还在,不是物理删除)", okk and out.strip() == "1",
|
||||
f"查询结果={out}")
|
||||
|
||||
okk, before = sql(f"SELECT count(*) FROM collect_task_org WHERE task_id = {task_id}")
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28},
|
||||
{"orgId": c2, "deputyUserIds": [head_id], "expectedCount": 31}],
|
||||
})
|
||||
check("把移除的班加回来成功", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}")
|
||||
okk, after = sql(f"SELECT count(*) FROM collect_task_org WHERE task_id = {task_id}")
|
||||
check("加回来时复用历史行(行数没有增加)", okk and before.strip() == after.strip(),
|
||||
f"移除后 {before.strip()} 行 → 加回后 {after.strip()} 行")
|
||||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
|
||||
check("加回后仍是 2 个班生效", len(body.get("data") or []) == 2,
|
||||
f"实际 {len(body.get('data') or [])} 行")
|
||||
|
||||
# 清空
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {"orgs": []})
|
||||
check("允许清空(合法中间态,发布时由 40019 拦)", body.get("code") == 0,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
|
||||
check("清空后为空数组", body.get("data") == [], f"{body.get('data')}")
|
||||
|
||||
# ── 5. 状态限制 ─────────────────────────────────────
|
||||
print("\n[5] 状态限制")
|
||||
# 恢复 2 个班,然后造发布前置数据走到"收集中"
|
||||
call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28},
|
||||
{"orgId": c2, "deputyUserIds": [head_id], "expectedCount": 31}],
|
||||
})
|
||||
sql(f"INSERT INTO collect_roster (tenant_id, task_id, org_id, real_name, id_card_no, id_card_hash) "
|
||||
f"VALUES ({tenant_id}, {task_id}, {c1}, 'E2E学生A', 'seed', 'seed')")
|
||||
sql(f"INSERT INTO collect_material_def (tenant_id, task_id, material_code, material_name, required) "
|
||||
f"VALUES ({tenant_id}, {task_id}, 'IDCARD', '身份证', 1)")
|
||||
status, body = call("POST", f"{base}/api/v1/tasks/{task_id}/publish", token, {"force": True})
|
||||
if check("任务已发布(收集中)", body.get("code") == 0,
|
||||
f"code={body.get('code')} msg={body.get('msg')}"):
|
||||
# 故意有一条不传 expectedCount —— 它必须存成"未设置"(null),不能变成 0。
|
||||
# 若变成 0,"期望 0 人、实际 31 人"这种假告警会出现在每个没填的班上。
|
||||
mid_orgs = [{"orgId": c1, "headUserId": head_id, "expectedCount": 28},
|
||||
{"orgId": c2}]
|
||||
if len(classes) > 2:
|
||||
mid_orgs.append({"orgId": classes[2]["id"]})
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {"orgs": mid_orgs})
|
||||
check("收集中允许改覆盖范围(中途加班是真实需求)", body.get("code") == 0,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
|
||||
rows2 = {r["orgId"]: r for r in (body.get("data") or [])}
|
||||
check("不传 expectedCount 时存为 null(不是 0)",
|
||||
c2 in rows2 and rows2[c2].get("expectedCount") is None,
|
||||
f"expectedCount={rows2.get(c2, {}).get('expectedCount')}")
|
||||
|
||||
# 清空已填的应填人数:这一条正是之前触发 500 的路径
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": c1, "headUserId": head_id}],
|
||||
})
|
||||
check("可以把已填的应填人数清空(不再 500)", body.get("code") == 0,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
|
||||
check("清空后读回是 null(清空真的落库了,不是静默忽略)",
|
||||
(body.get("data") or [{}])[0].get("expectedCount") is None,
|
||||
f"{body.get('data')}")
|
||||
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": c1, "headUserId": head_id, "expectedCount": 28}],
|
||||
})
|
||||
check("重新填入应填人数成功", body.get("code") == 0,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
|
||||
call("POST", f"{base}/api/v1/tasks/{task_id}/close", token, {})
|
||||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}", token)
|
||||
closed = (body.get("data") or {}).get("status") == 2
|
||||
check("任务已截止", closed, f"status={(body.get('data') or {}).get('status')}")
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": c1, "headUserId": head_id}],
|
||||
})
|
||||
check("已截止不可改覆盖范围", body.get("code") == 40001,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
|
||||
call("POST", f"{base}/api/v1/tasks/{task_id}/archive", token, {})
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", token, {
|
||||
"orgs": [{"orgId": c1}],
|
||||
})
|
||||
check("已归档不可改覆盖范围", body.get("code") == 40001,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", token)
|
||||
check("已归档仍可读覆盖范围(只读不等于看不见)", body.get("code") == 0 and len(body.get("data") or []) == 1,
|
||||
f"code={body.get('code')} rows={len(body.get('data') or [])}")
|
||||
|
||||
# ── 6. 权限 ────────────────────────────────────────
|
||||
print("\n[6] 权限:班主任只能看不能改")
|
||||
status, body = login(base, ht_user, head_pwd, args.tenant)
|
||||
if check("班主任能登录", body.get("code") == 0, f"code={body.get('code')} msg={body.get('msg')}"):
|
||||
ht_token = body["data"]["token"]
|
||||
status, body = call("PUT", f"{base}/api/v1/tasks/{task_id}/orgs", ht_token, {
|
||||
"orgs": [{"orgId": c1}],
|
||||
})
|
||||
check("班主任改覆盖范围被拒(无 task:manage)", body.get("code") == 40301,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
status, body = call("GET", f"{base}/api/v1/tasks/{task_id}/orgs", ht_token)
|
||||
check("班主任能读覆盖范围(他需要知道自己带哪些班)", body.get("code") == 0,
|
||||
f"code={body.get('code')} msg={body.get('msg')}")
|
||||
|
||||
# ── 7. 清理 ────────────────────────────────────────
|
||||
print("\n[7] 清理测试数据")
|
||||
cleanup_all()
|
||||
ok(f"已清理 {len(CREATED_TASK_IDS)} 个任务、{len(CREATED_USER_IDS)} 个账号、{len(CREATED_TENANT_IDS)} 个测试租户")
|
||||
|
||||
# ── 汇总 ───────────────────────────────────────────
|
||||
print("\n" + "=" * 72)
|
||||
print(f"通过 {len(PASSED)} 失败 {len(FAILED)} 共 {len(PASSED) + len(FAILED)}")
|
||||
if FAILED:
|
||||
print("\n失败项:")
|
||||
for name, detail in FAILED:
|
||||
print(f" · {name}\n {detail}")
|
||||
print("=" * 72)
|
||||
return 1 if FAILED else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 崩溃也要清理:否则脚本一旦在中途抛异常,测试账号与测试租户就会留在库里
|
||||
# (本脚本第一次运行时就是这样漏了两个账号)
|
||||
try:
|
||||
code = main()
|
||||
except BaseException:
|
||||
print("\n ⚠ 脚本异常退出,正在清理已造出的测试数据…")
|
||||
cleanup_all()
|
||||
raise
|
||||
raise SystemExit(code)
|
||||
Reference in New Issue
Block a user