任务书 P95 W1/W2/W3/W7。发一张表单原本要 UserBase 超管加一条 /f/{code} 模块行并组授权,
实测成本不可接受(10 张表=10 次跨系统操作),人群判定收回表单中心自解析。
- FillAccessService:audience × 会话身份(roleIds ∪ currentRole)× 管理员两表 → 层集合
ADMIN/MANAGE/FILL/空=NONE,谓词 canFill/canProxy/isAdmin + topTier;与 DataScopeService
读矩阵、FormAdminGuard 管理端入口三条线正交,结论互不供给
- AnswerService.doSubmit:钉版解析之后插第 0 关(先鉴权后业务,幂等重落在闸门后);
requireSubject 吸收行为表——教师×面向学生只能代填本校学生(P33 existsInSchool 保留),
层③代填仍沿 canManage 现路径,层①普通成员代填照旧拒
- GET /api/form/my/fillable:会话校 ∪ 平台级取域、复用 FormDefService.pickByShadowing 做
(app_code,code) 遮蔽去重、仅已发布未软删、NONE 不入集,行带 canFill + myTier 两件事,
deadline 升序 null 在后;新增 MyModels.FillableRow
- h2 登记脚本快应用段 5→4 行:删 /f/ 填报行与学生组授权,保留应用行/管理页面行/
path 空导入导出锚点/学校开通;H2 冻结延续,本轮未在任何环境执行
- 测试:FillAccessServiceTest 16(V48)+ AnswerFillGateIT 14(V49)+ FillableListIT 10(V50),
mvn -B -o clean test 全量 259 绿(教师×面向学生的造数据通道按新口径改代填,
读矩阵与限次等断言判据一字未改)
DataScopeService 与 FormAdminGuard 两个文件本轮未被改动(红线 1/2)。
This commit is contained in:
@@ -3,6 +3,7 @@ package com.yunhai.formcenter.controller.api;
|
||||
import com.yunhai.formcenter.model.MyModels;
|
||||
import com.yunhai.formcenter.model.ReadModels;
|
||||
import com.yunhai.formcenter.service.AnswerReadService;
|
||||
import com.yunhai.formcenter.service.FillableFormService;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
@@ -38,6 +39,7 @@ import java.util.List;
|
||||
public class FormMyAnswerController {
|
||||
|
||||
private final AnswerReadService readService;
|
||||
private final FillableFormService fillableService;
|
||||
|
||||
/** 我的答卷列表(按 submitter 过滤,"我的提交"段) */
|
||||
@GetMapping("/list")
|
||||
@@ -96,6 +98,24 @@ public class FormMyAnswerController {
|
||||
return readService.editBySubmitter(user, body.getAnswerId(), body.getValues(), body.getSummary());
|
||||
}
|
||||
|
||||
/**
|
||||
* 「可填写」列表(P95 W3,V50):<b>填报入口</b>清单,不是答卷清单——
|
||||
* 域=会话校 ∪ 平台级且本校遮蔽平台、仅已发布未软删,逐行按 P95 三层模型过入口层,NONE 不入集。
|
||||
*
|
||||
* <p>这一把查询把 P91 那个"从会话菜单树挑 {@code /f/} 行"的前端推导整条换掉:
|
||||
* 谁能填什么从此由中心按 {@code audience × 身份} 自解析,UserBase 侧不再需要逐表单配模块行
|
||||
* (任务书 P95 §0)。<b>它不看模块行、也不看数据范围矩阵</b>:入口层与那两条线正交
|
||||
* ({@code FillAccessService} 类注释记了三条线的分工)。</p>
|
||||
*
|
||||
* @param appCode 可选,缺省=全部应用;快应用上下文({@code /app/{appCode}/})传它自身以收窄清单
|
||||
*/
|
||||
@GetMapping("/fillable")
|
||||
public List<MyModels.FillableRow> fillable(BoaUser user,
|
||||
@RequestParam(value = "appCode", required = false) String appCode) {
|
||||
requireSession(user);
|
||||
return fillableService.listFillable(user, appCode);
|
||||
}
|
||||
|
||||
private static void requireSession(BoaUser user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
|
||||
|
||||
@@ -45,4 +45,36 @@ public final class MyModels {
|
||||
/** yyyy-MM-dd HH:mm:ss */
|
||||
private String submitTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一行"我可填的表单"(P95 W3,{@code GET /api/form/my/fillable}):填报<b>入口</b>清单的行形状。
|
||||
*
|
||||
* <p><b>它不是答卷行</b>:这一把查询还没写任何数据,只回答"这个人能进哪张表的填报页、
|
||||
* 进完之后按 P95 三层模型他算哪一层"。所以字段是<b>表单</b>身份 + 两个入口层结论,
|
||||
* 与 {@link ItemView} 没有共同祖先。</p>
|
||||
*
|
||||
* <p>{@code canFill} 与 {@code myTier} 是<b>两件事</b>,前端两处消费:
|
||||
* {@code canFill=false} 且 {@code myTier=MANAGE} 的行(教师×面向学生)在「可填写」列表里不出现,
|
||||
* 但要留在"数据管理"tab 的候选里——这正是 P95 把入口层结论下发而不是只下发一个布尔的原因。
|
||||
* 数字口径与定义域一致:{@code audience}/{@code fillLimit} 下发列值(1/2、0/1),
|
||||
* {@code myTier} 下发枚举名(ADMIN/MANAGE/FILL,NONE 的行根本不入集)。</p>
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
public static class FillableRow {
|
||||
private String code;
|
||||
private String name;
|
||||
private String appCode;
|
||||
/** 面向人群:1 学生 / 2 教师(契约 §10 列值) */
|
||||
private Integer audience;
|
||||
/** yyyy-MM-dd HH:mm:ss;null=不限截止 */
|
||||
private String deadline;
|
||||
/** 限填次数:0 不限 / 1 限一次 */
|
||||
private Integer fillLimit;
|
||||
private String groupTag;
|
||||
/** 能否以本人身份填报(层①∪层③) */
|
||||
private Boolean canFill;
|
||||
/** 并集里的最高层:ADMIN > MANAGE > FILL(NONE 不入集,故不会为 null) */
|
||||
private String myTier;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,4 +43,14 @@ public interface FormRepository extends JpaRepository<Form, Long> {
|
||||
List<Form> findResolveCandidates(@Param("code") String code,
|
||||
@Param("appCode") String appCode,
|
||||
@Param("schIds") Collection<Long> schIds);
|
||||
|
||||
/**
|
||||
* 「我可填」列表取域(P95 W3):会话校 ∪ 平台级里<b>已发布且未软删</b>的表单头表。
|
||||
*
|
||||
* <p>{@code status} 写死在 SQL 而 app_code 过滤留在应用层,同 {@link #findLiveBySch} 的口径:
|
||||
* 生命周期是这一把查询的语义(草稿/停用压根不是候选),而 app_code 是可选筛项,
|
||||
* 可空参数在 MySQL 上的类型推断有坑(P10)。遮蔽去重与入口层过滤都在服务侧。</p>
|
||||
*/
|
||||
@Query("select f from Form f where f.schId in (:schIds) and f.isDeleted = 0 and f.status = 1")
|
||||
List<Form> findPublishedInDomain(@Param("schIds") Collection<Long> schIds);
|
||||
}
|
||||
|
||||
@@ -25,19 +25,25 @@ import org.springframework.transaction.support.TransactionTemplate;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 提交链路(契约 §5 / 方案 §5.4)。一次提交在这条流水线上依次过关:
|
||||
*
|
||||
* <pre>
|
||||
* 钉版解析 → 截止 → 幂等命中 → subject 授权+存在性同校(P33) → 值校验(按钉住版本)
|
||||
* → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒) → 落 answer(写 form_uuid)
|
||||
* → 写 answer_index(只写 searchable)
|
||||
* 钉版解析 → <b>第 0 关·填报入口层(P95)</b> → 截止 → 幂等命中 → subject 授权+存在性同校(P33)
|
||||
* → 值校验(按钉住版本) → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒)
|
||||
* → 落 answer(写 form_uuid) → 写 answer_index(只写 searchable)
|
||||
* </pre>
|
||||
*
|
||||
* <p><b>为什么顺序是这样</b>:</p>
|
||||
* <ul>
|
||||
* <li><b>先鉴权后业务</b>(P95):入口层放在钉版之后(要 {@code form.audience} 才能判人群)、
|
||||
* 截止之前(过期表单对无资格的人不该输出"已过期"这种业务信息)。
|
||||
* 幂等重落在闸门<b>之后</b>,所以被闸门拒的人重放同样是 403——这是任务书 W2 明示可接受的取舍;
|
||||
* 外层 {@code DataIntegrityViolationException} 的并发重放路径不再重复过闸(命中
|
||||
* {@code belongsToCaller} 即返回原答卷);</li>
|
||||
* <li>幂等检查必须在限次<b>之前</b>——重放的那一条本来就占着名额,先查限次会把双击重试误判成"超过次数";</li>
|
||||
* <li>限次用"事务内向 {@code fill_quota} 插入、撞唯一键即拒"而不是先 count 再插
|
||||
* (方案 §5.4 v1.4:两个并发提交不可能都通过 count 检查);</li>
|
||||
@@ -46,6 +52,12 @@ import java.util.UUID;
|
||||
* "软删条目仍放行"的口径也就无法表达(契约 §2.1)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>第 0 关与后面两关的分工</b>:{@link FillAccessService} 判"这个身份在这张表单上算哪一层"
|
||||
* (入口层,P95 起取代 {@code /f/{code}} 模块行授权);{@link #requireSubject} 判"subject 能不能指向
|
||||
* 别人"(ADMIN 沿 {@link FormAdminGuard#canManage} 现路径、MANAGE 走 P33 存在性);
|
||||
* {@link DataScopeService} 判"写进去之后谁能看见这行"(读矩阵,与本类无关)。
|
||||
* 三条线各自独立,改一条不动另一条。</p>
|
||||
*
|
||||
* <p><b>事务形态</b>:公开方法不带 {@code @Transactional},写库段用 {@link TransactionTemplate} 包。
|
||||
* 目的是让"幂等键撞 uk"这种并发结局能在<b>事务外</b>重新读取原答卷并按契约返回 200+原 answer_id
|
||||
* (撞键后当前事务已被标记回滚,事务内再读是不可靠的)。</p>
|
||||
@@ -62,6 +74,7 @@ public class AnswerService {
|
||||
private final FormDefService defService;
|
||||
private final FormDictService dictService;
|
||||
private final FormAdminGuard adminGuard;
|
||||
private final FillAccessService fillAccess;
|
||||
private final SubjectDirectory subjectDirectory;
|
||||
private final AnswerRepository answerRepository;
|
||||
private final FillQuotaRepository quotaRepository;
|
||||
@@ -99,12 +112,16 @@ public class AnswerService {
|
||||
Form form = published.form();
|
||||
String schemaJson = published.versionRow().getSchemaJson();
|
||||
|
||||
// 2) 截止(datetime 粒度,过期接口拒绝,契约 §5)
|
||||
// 2) 第 0 关(P95 三层模型):填报入口层——钉版之后才有 audience 可判,截止之前先鉴权
|
||||
Set<FillAccessService.Tier> tiers = fillAccess.resolve(user, form);
|
||||
requireFillEntry(user, tiers, form, req.getSubjectId(), submitterId);
|
||||
|
||||
// 3) 截止(datetime 粒度,过期接口拒绝,契约 §5)
|
||||
if (FormRuntimeRules.isDeadlineReached(form, LocalDateTime.now())) {
|
||||
throw new BizException(ErrorCode.CUSTOM, "已过收集截止时间,不再接收提交:" + form.getCode());
|
||||
}
|
||||
|
||||
// 3) 幂等命中→200+原 answer_id(在限次之前,见类注释)
|
||||
// 4) 幂等命中→200+原 answer_id(在限次之前,见类注释)
|
||||
Answer existing = answerRepository.findByIdempotencyKey(idempotencyKey).orElse(null);
|
||||
if (existing != null) {
|
||||
if (!belongsToCaller(existing, user)) {
|
||||
@@ -114,18 +131,18 @@ public class AnswerService {
|
||||
return toView(existing, true);
|
||||
}
|
||||
|
||||
// 4) subject 授权(方案 §6.3:普通用户只能=自己,有管理权者可代填他人)+ 存在性/同校(P33)
|
||||
Integer subjectId = requireSubject(user, req.getSubjectId(), submitterId, schId);
|
||||
// 5) subject 授权(P95 行为表:谁能代填由第 0 关的层决定)+ 存在性/同校(P33)
|
||||
Integer subjectId = requireSubject(user, tiers, req.getSubjectId(), submitterId, schId);
|
||||
|
||||
// 5) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属
|
||||
// 6) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属
|
||||
String valuesJson = valuesToJson(req.getValues());
|
||||
FormValueValidator.validate(valuesJson, schemaJson, dictCode -> dictService.allowedValues(schId, dictCode));
|
||||
assertAttachmentsOwned(schId, valuesJson, schemaJson);
|
||||
|
||||
// 6) 限次占位(uk 是并发兜底)
|
||||
// 7) 限次占位(uk 是并发兜底)
|
||||
String quotaKey = occupyQuota(form, subjectId, submitterId);
|
||||
|
||||
// 7) 落答卷
|
||||
// 8) 落答卷
|
||||
Answer answer = new Answer();
|
||||
answer.setUuid(newUuid());
|
||||
// P25:钉版溯源的精确坐标=resolvePublished 命中的那张表单(本校遮蔽平台时的胜出行)
|
||||
@@ -147,7 +164,7 @@ public class AnswerService {
|
||||
// 若这一步失败,同事务内的 fill_quota 占位一起回滚,不残留"名额被占但无答卷"的脏状态
|
||||
answer = answerRepository.saveAndFlush(answer);
|
||||
|
||||
// 8) 索引:只写 searchable 字段(V3/V9)
|
||||
// 9) 索引:只写 searchable 字段(V3/V9)
|
||||
indexService.write(answer, schemaJson);
|
||||
|
||||
log.info("[提交] answer={} form={}/{} v{} submitter={} subject={} quotaKey={}",
|
||||
@@ -158,16 +175,45 @@ public class AnswerService {
|
||||
|
||||
// ==================== 各环节 ====================
|
||||
|
||||
private Integer requireSubject(BoaUser user, Integer subjectId, Integer submitterId, Long schId) {
|
||||
/**
|
||||
* 第 0 关(P95 W2 行为表):入口层判"这个身份在这张表单上到底能不能写"。
|
||||
*
|
||||
* <p>两条拒绝各有独立文案,因为它们是两种不同的用户动作失败了:
|
||||
* <b>NONE</b>=压根不在面向人群里(家长/工作号/局领导、学生填教师问卷),直链进来也是 403;
|
||||
* <b>仅 MANAGE 的自填</b>=人在人群口径里、但档位只给代填不给自填(教师×学生面向),
|
||||
* 文案要告诉他"改成代填学生就能提交",否则这条 403 无从下手。</p>
|
||||
*/
|
||||
private void requireFillEntry(BoaUser user, Set<FillAccessService.Tier> tiers, Form form,
|
||||
Integer subjectId, Integer submitterId) {
|
||||
if (tiers.isEmpty()) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "无权填报该表单:表单「" + form.getCode() + "」面向"
|
||||
+ audienceLabel(form) + ",当前身份 " + sessionIdentity(user) + " 不在可填报人群内"
|
||||
+ "(可填=面向人群本人;教师可代填本校学生;管理员不受限)");
|
||||
}
|
||||
boolean proxy = subjectId != null && !subjectId.equals(submitterId);
|
||||
if (!proxy && !fillAccess.canFill(tiers)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "教师不能以本人身份填报学生面向表单,可代填本校学生"
|
||||
+ "(subjectId 传被代填学生 id):表单「" + form.getCode() + "」面向" + audienceLabel(form));
|
||||
}
|
||||
}
|
||||
|
||||
private Integer requireSubject(BoaUser user, Set<FillAccessService.Tier> tiers, Integer subjectId,
|
||||
Integer submitterId, Long schId) {
|
||||
if (subjectId == null || subjectId.equals(submitterId)) {
|
||||
return subjectId;
|
||||
}
|
||||
if (!adminGuard.canManage(user)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"无权为他人填报:subjectId 只能等于本人(当前 " + submitterId + ",请求 " + subjectId + ")");
|
||||
if (fillAccess.isAdmin(tiers)) {
|
||||
// 层③的代填仍走 canManage 现路径(P95 红线 3"canManage 代填路径原样"):
|
||||
// 入口层把 ADMIN 判出来不等于给一条比现状更宽的写通道——没被授权管理模块行的人照旧拒
|
||||
if (!adminGuard.canManage(user)) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, forbiddenProxy(submitterId, subjectId));
|
||||
}
|
||||
} else if (!fillAccess.canProxy(tiers)) {
|
||||
// 层①普通成员(学生×学生面向、教师×教师面向)代填=拒,与 P95 之前的现行为逐字同文案
|
||||
throw new BizException(ErrorCode.FORBIDDEN, forbiddenProxy(submitterId, subjectId));
|
||||
}
|
||||
// P33:代填=把数据挂到别人名下,"关于我的"视图(V18)与按 subject 限次都建立在"id 真实且同校"上。
|
||||
// 本人自填不查(会话里的人本来就存在),只验代填那一个 id。
|
||||
// 本人自填不查(会话里的人本来就存在),只验代填那一个 id。层②与层③共用这一条(红线 3)。
|
||||
if (!subjectDirectory.existsInSchool(subjectId, schId)) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"被代填人不存在或不属于本校:subjectId=" + subjectId + ",schId=" + schId);
|
||||
@@ -175,6 +221,24 @@ public class AnswerService {
|
||||
return subjectId;
|
||||
}
|
||||
|
||||
private static String forbiddenProxy(Integer submitterId, Integer subjectId) {
|
||||
return "无权为他人填报:subjectId 只能等于本人(当前 " + submitterId + ",请求 " + subjectId + ")";
|
||||
}
|
||||
|
||||
private static String audienceLabel(Form form) {
|
||||
return FormEnums.Audience.TEACHER.getCode() == (form.getAudience() == null
|
||||
? FormEnums.Audience.STUDENT.getCode() : form.getAudience()) ? "教师" : "学生";
|
||||
}
|
||||
|
||||
/** 拒绝文案里的身份要素(会话 roleIds + 当前身份,便于运维一眼看出是人群配错还是身份不对) */
|
||||
private static String sessionIdentity(BoaUser user) {
|
||||
StringBuilder sb = new StringBuilder("id=").append(user.getId()).append(" roleIds=").append(user.getRoleIds());
|
||||
if (user.getCurrentRole() != null && user.getCurrentRole().getRoleId() != null) {
|
||||
sb.append("(当前=").append(user.getCurrentRole().getRoleId()).append(")");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 返回占住的 limit_key(null=该表单不限次,未占位) */
|
||||
private String occupyQuota(Form form, Integer subjectId, Integer submitterId) {
|
||||
if (!FormEnums.FillLimit.once(form.getFillLimit())) {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.enums.FormEnums;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 填报<b>入口层</b>解析(P95 任务书 §0.1 三层角色模型):把"会话用户 + 这张表单"解析成一个<b>层集合</b>,
|
||||
* 回答"填报入口给不给这个人开、开到哪一档"。纯解析、无写路径、不碰任何一张表。
|
||||
*
|
||||
* <p><b>三条线的分工(互不越界,P95 红线 1/2)</b>:</p>
|
||||
* <ul>
|
||||
* <li><b>UserBase 模块行</b>管"能不能进页面"——{@code /admin/*} 四行是管理面入口凭证
|
||||
* ({@link FormAdminGuard},P80 口径"入口只认模块行,两表只升范围"一字不动)。
|
||||
* {@code /f/{code}} 填报模块行自 P95 起<b>不再承担填报授权</b>;</li>
|
||||
* <li><b>本类</b>管"填报入口给谁开"(P95 新增)——人群由表单的 {@code audience} × 会话身份 ×
|
||||
* 管理员两表在中心侧自解析,不需要超管逐个表单配模块行;</li>
|
||||
* <li>{@link DataScopeService} 管"进页面后能看哪些行"(读矩阵,一字不动)。</li>
|
||||
* </ul>
|
||||
* 三者判定因子有重叠(都看 roleIds、都看两表),但<b>结论互不供给</b>:本类不给数据面放行,
|
||||
* 数据面也不给填报入口放行。
|
||||
*
|
||||
* <p><b>层与谓词(任务书 §0.1 表原样)</b>:</p>
|
||||
* <pre>
|
||||
* ADMIN 超管(type==1) ∪ school_admin 命中会话校 ∪ form_admin 命中该表单 ∪ roleIds 含 2/7
|
||||
* MANAGE audience=学生 且 roleIds 含 1(教师)——可代填本校学生,数据面仍是"仅本人提交"
|
||||
* FILL (audience=学生 且 roleIds 含 3) ∪ (audience=教师 且 roleIds 含 1)
|
||||
* NONE 其余一切(家长4/局领导5/工作号6/未知角色、学生×教师面向、roleIds 全不命中)= 空集
|
||||
* </pre>
|
||||
*
|
||||
* <p><b>多身份=并集</b>:{@code roleIds}(全部正常态角色)∪ 会话 {@code currentRole.roleId}
|
||||
* 逐层判定后合并(沿 {@link DataScopeService} 的 {@code identityRoleIds} 口径,防 UserBase 两侧不一致漏格)。
|
||||
* 一个层集合可以同时含多层(如教师×学生面向 = {@code {MANAGE}},校领导 = {@code {ADMIN}})。</p>
|
||||
*
|
||||
* <p><b>与任教班级 stub 的关系</b>:层②的<b>数据面</b>语义就是现状(矩阵该格生效=仅本人提交),
|
||||
* 本类只是把"教师可以往本校学生名下写数据"这件事从模块行授权收回到中心解析,
|
||||
* {@code DataScopeService} 的注释与取值一概不动。</p>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FillAccessService {
|
||||
|
||||
/** 超管用户类型(同 {@link FormAdminGuard}/{@link DataScopeService}:不是 roleId,是 UserBase 的用户类型) */
|
||||
private static final int SUPER_ADMIN_TYPE = 1;
|
||||
|
||||
private static final int ROLE_TEACHER = 1;
|
||||
private static final int ROLE_SCHOOL_LEADER = 2;
|
||||
private static final int ROLE_STUDENT = 3;
|
||||
private static final int ROLE_ADMIN = 7;
|
||||
|
||||
private final AdminScopeSource adminScopeSource;
|
||||
|
||||
/** 填报入口层({@link #resolve} 的元素;未命中任何层=空集,语义即任务书里的 NONE) */
|
||||
public enum Tier {
|
||||
/** 层③:全部管理 + 可填 + 可代填 */
|
||||
ADMIN,
|
||||
/** 层②:教师×学生面向,可代填本校学生(数据面维持仅本人提交) */
|
||||
MANAGE,
|
||||
/** 层①:本人身份可填 */
|
||||
FILL
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析该会话用户在这张表单上的层集合。
|
||||
*
|
||||
* @param form 目标表单({@code audience} 决定用哪一列,{@code uuid} 决定 form_admin 命中);
|
||||
* null 按面向学生列解析,且不产生 form_admin 命中(无表单可判),
|
||||
* 与 {@link DataScopeService#resolve} 的 null 口径同形
|
||||
* @return 层集合;空集=NONE(没有任何入口)。<b>永不为 null</b>
|
||||
*/
|
||||
public Set<Tier> resolve(BoaUser user, Form form) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
|
||||
}
|
||||
Set<Tier> tiers = EnumSet.noneOf(Tier.class);
|
||||
List<Integer> roleIds = identityRoleIds(user);
|
||||
if (isSuperAdmin(user) || grantedByAdminTables(user, form) || holdsAdminRole(roleIds)) {
|
||||
tiers.add(Tier.ADMIN);
|
||||
}
|
||||
boolean studentFacing = FormEnums.Audience.STUDENT.getCode()
|
||||
== (form == null || form.getAudience() == null
|
||||
? FormEnums.Audience.STUDENT.getCode() : form.getAudience());
|
||||
if (studentFacing && roleIds.contains(ROLE_TEACHER)) {
|
||||
tiers.add(Tier.MANAGE);
|
||||
}
|
||||
if (studentFacing ? roleIds.contains(ROLE_STUDENT) : roleIds.contains(ROLE_TEACHER)) {
|
||||
tiers.add(Tier.FILL);
|
||||
}
|
||||
return tiers;
|
||||
}
|
||||
|
||||
/** 层①∪层③:能否以<b>本人</b>身份填报(层②只有代填,自填不在这里——由调用侧按行为表拒绝并给文案) */
|
||||
public boolean canFill(Set<Tier> tiers) {
|
||||
return tiers != null && (tiers.contains(Tier.FILL) || tiers.contains(Tier.ADMIN));
|
||||
}
|
||||
|
||||
/** 层②∪层③:能否<b>代填</b>他人(真正能不能落到某个人身上还要过 P33 的 existsInSchool) */
|
||||
public boolean canProxy(Set<Tier> tiers) {
|
||||
return tiers != null && (tiers.contains(Tier.MANAGE) || tiers.contains(Tier.ADMIN));
|
||||
}
|
||||
|
||||
/** 层③:管理员层命中(超管 / 两表 / 校领导(2) / 管理员(7)) */
|
||||
public boolean isAdmin(Set<Tier> tiers) {
|
||||
return tiers != null && tiers.contains(Tier.ADMIN);
|
||||
}
|
||||
|
||||
/** 并集里的最高层(序 ADMIN > MANAGE > FILL;空集返回 null=NONE),供列表的 {@code myTier} 用 */
|
||||
public Tier topTier(Set<Tier> tiers) {
|
||||
if (tiers == null || tiers.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (Tier candidate : Tier.values()) {
|
||||
if (tiers.contains(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员两表是否把这个人抬进层③:{@code school_admin} 命中<b>会话校</b>、
|
||||
* 或 {@code form_admin} 命中<b>这张表单</b>(口径与 {@link DataScopeService} 读侧同源,
|
||||
* 但结论只用在入口层,不改数据范围)。
|
||||
*
|
||||
* <p>与读侧的差别只有一处,且是有意的:两表在这里<b>不看表单归属域</b>。
|
||||
* 读侧要区分"本校表单→全部 / 基础表单→本校提交",而入口层问的是"这个人是不是管理员",
|
||||
* 基础表单的管理员同样是管理员;校域不变式({@code answer.sch_id}=会话校)也不因代填而变。</p>
|
||||
*/
|
||||
private boolean grantedByAdminTables(BoaUser user, Form form) {
|
||||
Long ownSch = sessionSchId(user);
|
||||
if (ownSch != null && adminScopeSource.isSchoolAdmin(ownSch, user.getId())) {
|
||||
return true;
|
||||
}
|
||||
return form != null && adminScopeSource.isFormAdmin(form.getUuid(), user.getId());
|
||||
}
|
||||
|
||||
/** 校领导(2)/管理员(7):平台标准角色里的管理身份(契约 §8 矩阵那两行同一个因子) */
|
||||
private static boolean holdsAdminRole(List<Integer> roleIds) {
|
||||
return roleIds.contains(ROLE_ADMIN) || roleIds.contains(ROLE_SCHOOL_LEADER);
|
||||
}
|
||||
|
||||
/** 参与解析的身份:{@code roleIds}(全部正常态角色)并上会话 {@code currentRole.roleId}(同 DataScopeService) */
|
||||
private static List<Integer> identityRoleIds(BoaUser user) {
|
||||
List<Integer> ids = new ArrayList<>();
|
||||
if (user.getRoleIds() != null) {
|
||||
ids.addAll(user.getRoleIds());
|
||||
}
|
||||
Integer current = user.getCurrentRole() == null ? null : user.getCurrentRole().getRoleId();
|
||||
if (current != null && !ids.contains(current)) {
|
||||
ids.add(current);
|
||||
}
|
||||
return ids.isEmpty() ? Collections.<Integer>emptyList() : ids;
|
||||
}
|
||||
|
||||
private static boolean isSuperAdmin(BoaUser user) {
|
||||
return user.getType() != null && user.getType() == SUPER_ADMIN_TYPE;
|
||||
}
|
||||
|
||||
private static Long sessionSchId(BoaUser user) {
|
||||
Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
|
||||
return schId == null ? null : Long.valueOf(schId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.yunhai.formcenter.service;
|
||||
|
||||
import com.yunhai.formcenter.entity.Form;
|
||||
import com.yunhai.formcenter.model.MyModels;
|
||||
import com.yunhai.formcenter.repository.FormRepository;
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 「我可填」清单解析(P95 W3,{@code GET /api/form/my/fillable}):把"这个会话用户能进哪些表单的填报页"
|
||||
* 算成一张列表——P91 那个列表的数据源,从此<b>不再吃 UserBase 模块树</b>。
|
||||
*
|
||||
* <p><b>四步,一步不多</b>:</p>
|
||||
* <ol>
|
||||
* <li><b>取域</b>:会话校 ∪ 平台级({@code sch_id=0}),只认 {@code status=1} 已发布且未软删的头表;</li>
|
||||
* <li><b>遮蔽去重</b>:同 {@code (app_code, code)} 本校与平台级都在时本校胜出且不回落——
|
||||
* 直接复用 {@link FormDefService#pickByShadowing},与 resolve 同一条 U5 实现(P20 的取舍:
|
||||
* 两处口径必须同源,否则列表里出现的表单点进去可能解析成另一张);</li>
|
||||
* <li><b>逐行过入口层</b>:{@link FillAccessService#resolve} 为空集(NONE)的行不入集;非空即入集,
|
||||
* {@code canFill} 与 {@code myTier} 一起下发——层②(教师×面向学生)的行 {@code canFill=false},
|
||||
* 前端「可填写」列表把它筛掉,但"数据管理"tab 的候选要靠它,所以不能只下发一个布尔;</li>
|
||||
* <li><b>排序</b>:deadline 升序、null(不限)在后、同档按 code。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>不分页</b>:一所学校 + 平台级里可填的表单是个位数到几十张(P95 W3 的量级假设,报告已记),
|
||||
* 一张轻列表不值得引入游标;返回值就是裸数组,不带 total——将来真长大了加分页时不破坏"行形状"这条契约。</p>
|
||||
*
|
||||
* <p><b>与数据面的关系</b>:本类只回答"入口给谁开",不看 {@link DataScopeService}。
|
||||
* 列表里出现某张表单,不代表进去能看到别人的行(层②教师看到的仍是仅本人提交)。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FillableFormService {
|
||||
|
||||
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final FormRepository formRepository;
|
||||
private final FillAccessService fillAccess;
|
||||
|
||||
/**
|
||||
* 我可填清单。
|
||||
*
|
||||
* @param appCode 可选;空/缺省=全部应用(中心 + 所有快应用),非空=只要该 app_code 下的表单
|
||||
* @return 已按 deadline/code 排好序的行集;无权限的人拿到的是空集而不是异常
|
||||
*/
|
||||
public List<MyModels.FillableRow> listFillable(BoaUser user, String appCode) {
|
||||
Long schId = requireSessionSchId(user);
|
||||
String cleanApp = appCode == null ? "" : appCode.trim();
|
||||
List<Long> domain = Form.PLATFORM_SCH_ID == schId
|
||||
? Collections.singletonList(Form.PLATFORM_SCH_ID)
|
||||
: Arrays.asList(schId, Form.PLATFORM_SCH_ID);
|
||||
|
||||
// 先按 (app_code, code) 分组,再逐组走遮蔽规则——resolve 是"一个 code 一次判定",
|
||||
// 列表要把同一条判定摊到多个 code 上,分组就是这条摊法(不是另写一份遮蔽逻辑)
|
||||
Map<String, List<Form>> groups = new LinkedHashMap<>();
|
||||
for (Form form : formRepository.findPublishedInDomain(domain)) {
|
||||
if (!cleanApp.isEmpty() && !cleanApp.equals(form.getAppCode())) {
|
||||
continue;
|
||||
}
|
||||
String key = form.getAppCode() + "#" + form.getCode();
|
||||
List<Form> bucket = groups.get(key);
|
||||
if (bucket == null) {
|
||||
bucket = new ArrayList<>();
|
||||
groups.put(key, bucket);
|
||||
}
|
||||
bucket.add(form);
|
||||
}
|
||||
|
||||
List<Form> visible = new ArrayList<>();
|
||||
for (List<Form> bucket : groups.values()) {
|
||||
Form winner = FormDefService.pickByShadowing(bucket, schId);
|
||||
if (winner == null) {
|
||||
continue;
|
||||
}
|
||||
if (winner.getCurrentVersion() == null) {
|
||||
// status=1 却没有版本指针=定义坏状态,resolve 会以"当前发布版本行缺失"拒绝,
|
||||
// 这里同样不给入口(宁缺勿滥:列表里出现点了报 500 的表单比少一张更糟)
|
||||
log.warn("[我可填] 表单 {}/{} 已发布但当前版本指针为空,跳过", winner.getAppCode(), winner.getCode());
|
||||
continue;
|
||||
}
|
||||
visible.add(winner);
|
||||
}
|
||||
|
||||
Collections.sort(visible, Comparator
|
||||
.comparing((Form form) -> form.getDeadline() == null ? LocalDateTime.MAX : form.getDeadline())
|
||||
.thenComparing(form -> form.getCode() == null ? "" : form.getCode()));
|
||||
|
||||
List<MyModels.FillableRow> rows = new ArrayList<>();
|
||||
for (Form form : visible) {
|
||||
Set<FillAccessService.Tier> tiers = fillAccess.resolve(user, form);
|
||||
if (tiers.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
MyModels.FillableRow row = new MyModels.FillableRow();
|
||||
row.setCode(form.getCode());
|
||||
row.setName(form.getName());
|
||||
row.setAppCode(form.getAppCode());
|
||||
row.setAudience(form.getAudience());
|
||||
row.setDeadline(form.getDeadline() == null ? null : form.getDeadline().format(TS));
|
||||
row.setFillLimit(form.getFillLimit());
|
||||
row.setGroupTag(form.getGroupTag());
|
||||
row.setCanFill(fillAccess.canFill(tiers));
|
||||
row.setMyTier(fillAccess.topTier(tiers).name());
|
||||
rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 会话校(同 {@code AnswerService} 的口径:未登录与无学校归属两段分开拒,文案与 my 面其余端点一致) */
|
||||
private static Long requireSessionSchId(BoaUser user) {
|
||||
if (user == null || user.getId() == null) {
|
||||
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
|
||||
}
|
||||
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属");
|
||||
}
|
||||
return Long.valueOf(user.getCurrentRole().getSchId());
|
||||
}
|
||||
}
|
||||
@@ -656,8 +656,11 @@ public class FormDefService {
|
||||
|
||||
/** 遮蔽规则(U5):同 (app_code, code) 本校与平台级都在时,本校胜出且不回落——
|
||||
* 本校行哪怕处于草稿/停用,也不让请求穿透到平台级(否则"本校遮蔽"会变成可绕过的建议)。
|
||||
* 兜底只认 sch_id=0:取域已限定在本校 ∪ 平台级,多加一层判断是为了让"他校行"永远不可能被当兜底。 */
|
||||
private Form pickByShadowing(List<Form> candidates, Long schId) {
|
||||
* 兜底只认 sch_id=0:取域已限定在本校 ∪ 平台级,多加一层判断是为了让"他校行"永远不可能被当兜底。
|
||||
*
|
||||
* <p>P95 起 {@code FillableFormService} 也要这条规则(「我可填」列表按 (app_code, code) 分组后逐组判),
|
||||
* 所以把它收成<b>包内静态方法</b>而不是复制一份——两条链路共用一条遮蔽实现(同 P20 的取舍)。</p> */
|
||||
static Form pickByShadowing(List<Form> candidates, Long schId) {
|
||||
Form platformFallback = null;
|
||||
for (Form form : candidates) {
|
||||
if (form.getSchId() == null) {
|
||||
|
||||
Reference in New Issue
Block a user