feat(read): T4 后端·检索统计+数据范围+导出
build / build-push (push) Successful in 1m18s

四条读出路径(列表/详情/统计/导出)与管理动作行级把关共用 AnswerScopeQueryBuilder
一个谓词构造器(契约 §8 红线);数据范围按 SysRoleEnum 矩阵×audience 解析 +
customId=fc:scope:all 扫描授权模块树,多来源取并集、全不命中回落仅本人提交。
四铁律落地:两段式列表禁 SELECT values_json、keyset 仅提交时间序(游标对外是 uuid)、
覆盖索引化(新增 idx_answer_form_time)、统计缓存键含 form_uuid+version 且仅升版失效+短 TTL。

T3 验收裁决前置三项:P25 answer.form_uuid 精确钉版溯源(删平台级回退启发式)、
P33 代填 subject 存在性+同校(SubjectDirectory 策略接口,生产走 /oapi/user/byId)、
P34 附件 key 与 store_key 一致性比对;P21 FormAdminGuard 整体替换为"授权模块集"口径
(ModuleTreeSource 策略接口,与 scope 解析共用同一棵树)。

新增对外读接口 GET /oapi/form/schema(内联 options+valueSchema)与 /oapi/form/answer
(钉住版本 schema+值文档);导出 easyexcel 流式同步响应,选项列显 label、附件列显 fileName。

自验:mvn -B -o clean package 177 条全绿(本轮新增 56,23 条打真实 MySQL),
覆盖 V9/V11/V23/V27/V28/V37/V42 + V30 读侧半边 + V39 范围半边;
V9 以"发布时补建索引行"实现(方案未指定机制,记 P36)。详见 docs/T4-report.md(问题单 P35~P46)。
This commit is contained in:
chenxing
2026-10-14 19:00:15 +08:00
parent b2778e5d86
commit 49b095eb81
57 changed files with 5402 additions and 249 deletions
@@ -0,0 +1,234 @@
package com.yunhai.formcenter.common;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* 检索算子收敛(契约 §2 最后一句):<b>算子按字段 type 定,不给自由表达式</b>——
* 等值(选项类)、区间(date/number)、前缀模糊(文本类)。
*
* <p>调用方只能提交 {@code {key,value}} 或 {@code {key,from,to}};本类按钉住<b>当前发布版本</b>的
* 字段类型决定它落到 {@code answer_index} 的哪一列(v_str / v_num / v_date)、用哪种算子,
* 形态不对就拒("给 text 传 from/to"、"给 date 传区间外的串"、"给 number 传非数字"都不是筛选语义,
* 是查询注入的入口)。类型正确性在这里保证,V4 的"日期按日期比、不走字典序"也在这里保证。</p>
*/
public final class FormFilterOperators {
/** 算子 */
public enum Kind {
/** 等值(选项类,多值字段一值一行故命中任一项即算匹配) */
EQ,
/** 前缀模糊(文本类;v_str 已截 191,超长部分不可筛,契约 §10 认可) */
PREFIX,
/** 闭区间(date / number) */
RANGE
}
/** 落到的索引列 */
public enum Column {
STR, NUM, DATE
}
private static final Set<String> OPTION_TYPES = new LinkedHashSet<>(Arrays.asList(
"select", "radio", "checkbox", "multiselect"));
private static final Set<String> TEXT_TYPES = new LinkedHashSet<>(Arrays.asList(
"text", "textarea", "phone"));
private FormFilterOperators() {
}
/**
* 一次筛选条件的解析结果(读路径构造器只认这个,不再回看 schema)。
*
* <p>数值/日期<b>恒为闭区间</b>:等值就是两端同值的区间,因此 {@code numFrom/numTo}
* (或 {@code dateFrom/dateTo})总是成对非空——构造器只用一个 {@code between} 分支,
* 不给"漏判一种算子"留余地。文本类才有 {@code strValue}(等值或前缀)。</p>
*/
public static final class Spec {
private final String key;
private final Kind kind;
private final Column column;
private final String strValue;
private final BigDecimal numFrom;
private final BigDecimal numTo;
private final LocalDate dateFrom;
private final LocalDate dateTo;
private Spec(String key, Kind kind, Column column, String strValue,
BigDecimal numFrom, BigDecimal numTo, LocalDate dateFrom, LocalDate dateTo) {
this.key = key;
this.kind = kind;
this.column = column;
this.strValue = strValue;
this.numFrom = numFrom;
this.numTo = numTo;
this.dateFrom = dateFrom;
this.dateTo = dateTo;
}
public String getKey() {
return key;
}
public Kind getKind() {
return kind;
}
public Column getColumn() {
return column;
}
public String getStrValue() {
return strValue;
}
public BigDecimal getNumFrom() {
return numFrom;
}
public BigDecimal getNumTo() {
return numTo;
}
public LocalDate getDateFrom() {
return dateFrom;
}
public LocalDate getDateTo() {
return dateTo;
}
}
/** 该类型的算子名(筛选候选下发给前端,前端按它出控件) */
public static String operatorOf(String type) {
if (OPTION_TYPES.contains(type)) {
return Kind.EQ.name();
}
if (TEXT_TYPES.contains(type)) {
return Kind.PREFIX.name();
}
if ("number".equals(type) || "date".equals(type)) {
return Kind.RANGE.name();
}
return null;
}
/**
* 该类型落地的索引列(分组统计按它取 {@code v_*} 列);无算子类型返回 null(不可分组)。
* 与 {@link #resolve} 的列选择是同一条判断的两种问法,放在这里防两处口径分叉。
*/
public static Column columnOf(String type) {
if (OPTION_TYPES.contains(type) || TEXT_TYPES.contains(type)) {
return Column.STR;
}
if ("number".equals(type)) {
return Column.NUM;
}
if ("date".equals(type)) {
return Column.DATE;
}
return null;
}
/**
* 解析一个筛选条件。
*
* @param field schema 里的字段节点(含 type)
* @param item 请求侧条件
* @throws BizException PARAM_ERROR——类型无算子、形态不匹配、值解析不出正确类型
*/
public static Spec resolve(JsonNode field, ReadModels.FilterItem item) {
String type = field.path("type").asText();
String key = item == null ? "" : item.getKey();
if (OPTION_TYPES.contains(type)) {
return new Spec(key, Kind.EQ, Column.STR, requireText(field, item), null, null, null, null);
}
if (TEXT_TYPES.contains(type)) {
return new Spec(key, Kind.PREFIX, Column.STR, requireText(field, item), null, null, null, null);
}
if ("number".equals(type)) {
BigDecimal from = parseNumber(field, item.getFrom());
BigDecimal to = parseNumber(field, item.getTo());
if (from == null && to == null) {
// 单值给成 value 也算等值("分数=95.5"是常见问法),落到同一列的数值区间
BigDecimal eq = parseNumber(field, item.getValue());
if (eq == null) {
throw fail(field, key, "number 筛选需要 value 或 from/to");
}
from = eq;
to = eq;
} else if (from == null || to == null) {
// 只给一端:另一端补成同值——半开区间不是契约给的算子,不做"默认为无穷"的猜测
BigDecimal bound = from == null ? to : from;
from = bound;
to = bound;
}
return new Spec(key, from.compareTo(to) == 0 ? Kind.EQ : Kind.RANGE, Column.NUM,
null, from, to, null, null);
}
if ("date".equals(type)) {
LocalDate from = parseDate(field, item.getFrom());
LocalDate to = parseDate(field, item.getTo());
if (from == null && to == null) {
LocalDate eq = parseDate(field, item.getValue());
if (eq == null) {
throw fail(field, key, "date 筛选需要 value 或 from/to(yyyy-MM-dd)");
}
from = eq;
to = eq;
} else if (from == null || to == null) {
LocalDate bound = from == null ? to : from;
from = bound;
to = bound;
}
return new Spec(key, from.equals(to) ? Kind.EQ : Kind.RANGE, Column.DATE,
null, null, null, from, to);
}
// upload/image/user/users/address:契约 §2 没给算子(附件那条另见 P28)——不发明算子
throw fail(field, key, "该类型一期无检索算子,不能筛选:" + type);
}
private static String requireText(JsonNode field, ReadModels.FilterItem item) {
String value = item == null ? null : item.getValue();
if (value == null || value.trim().isEmpty()) {
throw fail(field, item == null ? "" : item.getKey(), "筛选值不能为空");
}
return value.trim();
}
private static BigDecimal parseNumber(JsonNode field, String raw) {
if (raw == null || raw.trim().isEmpty()) {
return null;
}
try {
return new BigDecimal(raw.trim());
} catch (NumberFormatException e) {
throw fail(field, field.path("key").asText(), "不是数值:" + raw);
}
}
private static LocalDate parseDate(JsonNode field, String raw) {
if (raw == null || raw.trim().isEmpty()) {
return null;
}
try {
return LocalDate.parse(raw.trim());
} catch (DateTimeParseException e) {
throw fail(field, field.path("key").asText(), "日期格式应为 yyyy-MM-dd:" + raw);
}
}
private static BizException fail(JsonNode field, String key, String reason) {
return new BizException(ErrorCode.PARAM_ERROR, "筛选条件[" + key + "]("
+ field.path("label").asText(field.path("type").asText()) + ")" + reason);
}
}
@@ -0,0 +1,55 @@
package com.yunhai.formcenter.common;
import com.yunhai.platform.utils.model.BoaModule;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 授权模块树的两类"权限键"扁平化(方案 §6.1 第②层与 §6.2 权限锚点)。
*
* <p>模块行有两种身份,<b>必须分开取</b>:</p>
* <ul>
* <li>{@code path} 非空=页面路由行({@code /admin/forms} 这类),管理权按它判(手册 §2.2 表);</li>
* <li>{@code path} 空、{@code customId} 非空=纯权限锚点(按钮位与数据范围扩展位,
* 如 {@code fc:scope:all})——它不进导航,只由后端扫描授权集合消费。</li>
* </ul>
*
* <p>纯函数、不碰库不碰网络:树由 {@code ModuleTreeSource} 提供。</p>
*/
public final class FormModuleKeys {
private FormModuleKeys() {
}
/** 授权页面路由集合(path 空的权限锚点不在内) */
public static Set<String> paths(List<BoaModule> tree) {
Set<String> result = new LinkedHashSet<>();
collect(tree, true, result);
return result;
}
/** 授权 customId 集合(path 空的权限锚点主要靠它表达) */
public static Set<String> customIds(List<BoaModule> tree) {
Set<String> result = new LinkedHashSet<>();
collect(tree, false, result);
return result;
}
private static void collect(List<BoaModule> nodes, boolean wantPath, Set<String> out) {
if (nodes == null) {
return;
}
for (BoaModule node : nodes) {
if (node == null) {
continue;
}
String value = wantPath ? node.getPath() : node.getCustomId();
if (value != null && !value.trim().isEmpty()) {
out.add(value.trim());
}
collect(node.getChildren(), wantPath, out);
}
}
}
@@ -382,6 +382,30 @@ public final class FormSchemaValidator {
return result;
}
/**
* 可筛选字段(key → 字段节点):{@link #searchableKeys} 的<b>筛选候选口径</b>,
* 比索引写入少一类——{@code upload/image} 排除在外。
*
* <p>依据执行手册 §5 <b>P28 裁决</b>:契约 §2 只给三类算子(等值/区间/前缀模糊),附件没有算子,
* 索引侧"不写行只 warn"(T3 已落)、筛选候选侧"不进候选"(本轮)。两处同一个理由,
* 不能让前端拿到一个勾了 searchable 的附件字段、提交筛选后恒返回空集。</p>
*/
public static Map<String, JsonNode> filterableFields(String schemaJson) {
Map<String, JsonNode> result = new LinkedHashMap<>();
Map<String, JsonNode> fields = fieldsByKeys(schemaJson);
for (String key : searchableKeys(schemaJson)) {
JsonNode field = fields.get(key);
if (field == null || NO_OPERATOR_TYPES.contains(field.path("type").asText())) {
continue;
}
result.put(key, field);
}
return result;
}
/** 无检索算子的类型(P28):附件两类一期既不写索引行、也不进筛选候选 */
private static final Set<String> NO_OPERATOR_TYPES = new LinkedHashSet<>(Arrays.asList("upload", "image"));
/** schema 里引用的字典 code({@code dict:{code}} 去掉了前缀),发布前据此查可解析性 */
public static List<String> dictRefCodes(String schemaJson) {
List<String> codes = new ArrayList<>();
@@ -0,0 +1,152 @@
package com.yunhai.formcenter.common;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
/**
* 机读值形状生成器(契约 §4:schema 读接口每个字段附 {@code valueSchema},draft-07 风格片段)。
*
* <p>生成依据是契约 §3 的值形状表,逐类对照:文本类 {@code string}(带 maxLength/pattern)、
* 数值 {@code number}(带声明过的 min/max)、日期 {@code string+format:date}、
* 单选 {@code string+enum}、多选 {@code array(items=string+enum)}、
* 附件 {@code array(items=object{id,fileName,key})}(一期值里没有 URL,故 properties 就这三件、
* {@code additionalProperties:false})。字典引用字段在生成前必须已内联展开(契约 §2.1),
* enum 因此来自展开后的 options。</p>
*
* <p><b>不做的事</b>:不发明契约里没有的约束(不给 number 造 {@code multipleOf}、
* 不给多选加 {@code uniqueItems});一期未放行的类型(address/user/users)不给形状——
* 它们在发布校验就被拦下,出现在这里只会误导消费方按二期语义接线。</p>
*/
public final class FormValueSchemaBuilder {
private FormValueSchemaBuilder() {
}
/**
* 生成该字段的 valueSchema 片段。
*
* @param field 已内联展开 options 的字段节点
* @return 片段;一期未放行的类型返回 null(调用方不写该键)
*/
public static ObjectNode valueSchema(JsonNode field) {
if (field == null) {
return null;
}
ObjectMapper mapper = FormSchemaValidator.mapper();
String type = field.path("type").asText();
ObjectNode node = mapper.createObjectNode();
switch (type) {
case "text":
case "textarea":
case "phone":
node.put("type", "string");
putIfNumber(node, "maxLength", field.get("maxLength"));
putIfText(node, "pattern", field.get("pattern"));
return node;
case "number":
node.put("type", "number");
putIfNumber(node, "minimum", field.get("min"));
putIfNumber(node, "maximum", field.get("max"));
return node;
case "date":
node.put("type", "string");
node.put("format", "date");
return node;
case "select":
case "radio":
node.put("type", "string");
putEnum(node, field.get("options"));
return node;
case "checkbox":
case "multiselect": {
node.put("type", "array");
ObjectNode item = mapper.createObjectNode();
item.put("type", "string");
putEnum(item, field.get("options"));
node.set("items", item);
return node;
}
case "upload":
case "image": {
node.put("type", "array");
ObjectNode file = mapper.createObjectNode();
file.put("type", "object");
ObjectNode properties = mapper.createObjectNode();
properties.set("id", typed(mapper, "integer"));
properties.set("fileName", typed(mapper, "string"));
properties.set("key", typed(mapper, "string"));
file.set("properties", properties);
ArrayNode required = file.putArray("required");
required.add("id");
required.add("fileName");
required.add("key");
file.put("additionalProperties", false);
node.set("items", file);
putIfNumber(node, "maxItems", field.get("maxFiles"));
return node;
}
default:
// address/user/users(二期)与未知类型:不给机读形状,也不瞎造
return null;
}
}
/**
* 整棵 schema 加工(契约 §4 的 groups 形状):每个字段挂上 valueSchema,返回深拷贝,
* 不改写入参(版本行 schema 原文逐字节冻结,V7)。
*/
public static JsonNode withValueSchemas(JsonNode root) {
ObjectMapper mapper = FormSchemaValidator.mapper();
ObjectNode copy = root == null || !root.isObject()
? mapper.createObjectNode() : (ObjectNode) root.deepCopy();
for (JsonNode group : copy.path("groups")) {
if (!group.isObject()) {
continue;
}
for (JsonNode field : group.path("fields")) {
if (!field.isObject()) {
continue;
}
ObjectNode valueSchema = valueSchema(field);
if (valueSchema != null) {
((ObjectNode) field).set("valueSchema", valueSchema);
}
}
}
return copy;
}
private static ObjectNode typed(ObjectMapper mapper, String type) {
return mapper.createObjectNode().put("type", type);
}
private static void putEnum(ObjectNode node, JsonNode options) {
if (options == null || !options.isArray() || options.size() == 0) {
return;
}
ArrayNode enumArray = node.putArray("enum");
for (JsonNode option : options) {
String value = option.path("value").asText(null);
if (value != null) {
enumArray.add(value);
}
}
if (enumArray.size() == 0) {
node.remove("enum");
}
}
private static void putIfNumber(ObjectNode node, String key, JsonNode value) {
if (value != null && value.isNumber()) {
node.set(key, value);
}
}
private static void putIfText(ObjectNode node, String key, JsonNode value) {
if (value != null && value.isTextual() && !value.asText().isEmpty()) {
node.put(key, value.asText());
}
}
}
@@ -92,14 +92,19 @@ public final class FormValueValidator {
return valuesJson;
}
/** 提交值里引用的附件 id(服务层据此做同校归属校验,防把别人的 key 塞进自己的答卷) */
public static List<Long> attachmentFileIds(String valuesJson, String schemaJson) {
List<Long> ids = new ArrayList<>();
/**
* 值文档里引用的附件对象(upload/image 字段数组的每个 {@code {id,fileName,key}} 元素)。
*
* <p>返回整对象而不只返回 id:服务层的归属校验除"id 必须存在且属本校"外,还要比对
* {@code key==form_file.store_key}(P34 裁决)——只给 id 就得把值文档再走一遍。</p>
*/
public static List<JsonNode> attachments(String valuesJson, String schemaJson) {
List<JsonNode> files = new ArrayList<>();
Map<String, JsonNode> fields = FormSchemaValidator.fieldsByKeys(schemaJson);
JsonNode data = FormSchemaValidator.parse(
valuesJson == null || valuesJson.trim().isEmpty() ? "{}" : valuesJson, "表单数据不是合法 JSON 对象");
if (!data.isObject()) {
return ids;
return files;
}
for (Map.Entry<String, JsonNode> entry : fields.entrySet()) {
if (!ATTACHMENT_TYPES.contains(entry.getValue().path("type").asText())) {
@@ -110,13 +115,12 @@ public final class FormValueValidator {
continue;
}
for (JsonNode item : value) {
JsonNode id = item.get("id");
if (id != null && id.isIntegralNumber()) {
ids.add(id.longValue());
if (item != null && item.isObject() && item.get("id") != null && item.get("id").isIntegralNumber()) {
files.add(item);
}
}
}
return ids;
return files;
}
// ==================== 分派 ====================
@@ -44,21 +44,21 @@ public class FormAdminController {
@RequestParam(required = false) Long schId,
@RequestParam(required = false) String appCode,
@RequestParam(required = false) Integer status) {
return defService.list(adminGuard.requireAdmin(user, schId), appCode, status);
return defService.list(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), appCode, status);
}
@GetMapping("/get")
public FormModels.FormView get(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) {
return defService.get(adminGuard.requireAdmin(user, schId), uuid);
return defService.get(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
}
@GetMapping("/versions")
public List<FormModels.VersionView> versions(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) {
return defService.versions(adminGuard.requireAdmin(user, schId), uuid);
return defService.versions(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
}
/** 单个版本行的 schema 正文(编辑回显;已发布行内容逐字节冻结,V7) */
@@ -67,27 +67,28 @@ public class FormAdminController {
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid,
@RequestParam("version") Integer version) {
return defService.version(adminGuard.requireAdmin(user, schId), uuid, version);
return defService.version(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid, version);
}
/**
* 筛选候选列(V3 的接口侧断言点):<b>只有显式声明 searchable 的字段</b>才进候选,
* 取当前发布版本的 schema;未发布时返回空列表(草稿字段不参与筛选)。
* 附件类(upload/image)即便勾了 searchable 也<b>排除</b>——契约 §2 没给它检索算子(P28)。
*/
@GetMapping("/filterFields")
public List<String> filterFields(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) {
FormModels.VersionView current = currentPublishedVersion(adminGuard.requireAdmin(user, schId), uuid);
FormModels.VersionView current = currentPublishedVersion(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
return current == null ? java.util.Collections.emptyList()
: FormSchemaValidator.searchableKeys(current.getSchemaJson());
: new java.util.ArrayList<>(FormSchemaValidator.filterableFields(current.getSchemaJson()).keySet());
}
@PostMapping("/create")
public FormModels.FormView create(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody FormModels.FormCreateRequest body) {
return defService.create(adminGuard.requireAdmin(user, schId), body);
return defService.create(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
}
/** 保存:草稿直改;已发布表单=开新版本行,头表指针不动(V7) */
@@ -95,7 +96,7 @@ public class FormAdminController {
public FormModels.FormView save(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody FormModels.FormSaveRequest body) {
return defService.save(adminGuard.requireAdmin(user, schId), body);
return defService.save(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
}
/** 发布:schema 契约校验 + 字段身份稳定 + 字典可解析,三道都过才钉版本 */
@@ -104,7 +105,7 @@ public class FormAdminController {
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid,
@RequestParam(value = "changeLog", required = false) String changeLog) {
return defService.publish(adminGuard.requireAdmin(user, schId), uuid, changeLog);
return defService.publish(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid, changeLog);
}
/** 停用:只断填报入口,数据可读可导(方案 §5.4) */
@@ -112,7 +113,7 @@ public class FormAdminController {
public FormModels.FormView disable(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) {
return defService.disable(adminGuard.requireAdmin(user, schId), uuid);
return defService.disable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
}
/** 停用恢复:重新发布=恢复当前版本,不产生新版本行 */
@@ -120,7 +121,7 @@ public class FormAdminController {
public FormModels.FormView enable(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) {
return defService.enable(adminGuard.requireAdmin(user, schId), uuid);
return defService.enable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
}
/** 软删(唯一删除形态,无物理删除接口——方案 §5.4) */
@@ -128,7 +129,7 @@ public class FormAdminController {
public void softDelete(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("uuid") String uuid) {
defService.softDelete(adminGuard.requireAdmin(user, schId), uuid);
defService.softDelete(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), uuid);
}
/** 另存为副本(V41):新 code/新 uuid/version 从 1 起/schema 深拷贝、字典引用随行 */
@@ -136,7 +137,7 @@ public class FormAdminController {
public FormModels.FormView copy(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody FormModels.FormCopyRequest body) {
return defService.copy(adminGuard.requireAdmin(user, schId), body);
return defService.copy(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
}
/** 表单级配置:面向人群/限一次/截止时间(只给日期补 23:59:59)/报名号开关/分组标签 */
@@ -144,7 +145,7 @@ public class FormAdminController {
public FormModels.FormView config(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody FormModels.FormConfigRequest body) {
return defService.updateConfig(adminGuard.requireAdmin(user, schId), body);
return defService.updateConfig(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
}
/** 状态定义(V12):statusDefs 传 null=回默认"待处理/已处理" */
@@ -152,7 +153,7 @@ public class FormAdminController {
public FormModels.FormView statusDefs(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody FormModels.StatusDefsRequest body) {
return defService.updateStatusDefs(adminGuard.requireAdmin(user, schId), body);
return defService.updateStatusDefs(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_FORM_DESIGN), body);
}
// ==================== 选项字典(契约 §2.1) ====================
@@ -160,36 +161,36 @@ public class FormAdminController {
/** 本层字典清单(管理 tab:只出本校自己的,不含平台级——平台级可见可用不可改) */
@GetMapping("/dict/list")
public List<FormModels.DictView> dictList(BoaUser user, @RequestParam(required = false) Long schId) {
return dictService.listOwn(adminGuard.requireAdmin(user, schId));
return dictService.listOwn(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DICT));
}
/** 设计器可选字典:域=会话校 ∪ 平台级(0),同 code 本校遮蔽平台,条目只出未删 */
@GetMapping("/dict/selectable")
public List<FormModels.DictView> dictSelectable(BoaUser user, @RequestParam(required = false) Long schId) {
return dictService.listSelectable(adminGuard.requireAdmin(user, schId));
return dictService.listSelectable(adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DICT));
}
@PostMapping("/dict/save")
public FormModels.DictView dictSave(BoaUser user, @RequestBody FormModels.DictSaveRequest body) {
return dictService.saveDict(adminGuard.requireAdmin(user, body.getSchId()), body.getCode(), body.getName());
return dictService.saveDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getCode(), body.getName());
}
@PostMapping("/dict/delete")
public void dictDelete(BoaUser user, @RequestBody FormModels.DictSaveRequest body) {
dictService.deleteDict(adminGuard.requireAdmin(user, body.getSchId()), body.getCode());
dictService.deleteDict(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getCode());
}
/** 条目保存:value 建后不可变(它同时是定位键),本接口只能改 label/sort */
@PostMapping("/dict/item/save")
public FormModels.DictItemView dictItemSave(BoaUser user, @RequestBody FormModels.DictItemRequest body) {
return dictService.saveItem(adminGuard.requireAdmin(user, body.getSchId()),
return dictService.saveItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT),
body.getDictCode(), body.getValue(), body.getLabel(), body.getSort());
}
/** 条目软删:设计器不再可选,历史答卷的该 value 提交仍放行 */
@PostMapping("/dict/item/delete")
public void dictItemDelete(BoaUser user, @RequestBody FormModels.DictItemRequest body) {
dictService.deleteItem(adminGuard.requireAdmin(user, body.getSchId()), body.getDictCode(), body.getValue());
dictService.deleteItem(adminGuard.requireAdmin(user, body.getSchId(), FormAdminGuard.MODULE_DICT), body.getDictCode(), body.getValue());
}
private FormModels.VersionView currentPublishedVersion(Long schId, String uuid) {
@@ -17,11 +17,14 @@ import java.util.List;
* 答卷管理端({@code /api/form/admin/data/**},方案 §5.4 生命周期表的管理动作)。
*
* <p>这里<b>没有</b>编辑/删除的"填表人版本"接口——填表人可用的动作只有提交(V29"无编辑/撤回接口可用"
* 的实现形态就是<b>不存在这些端点</b>,加上管理权守卫给出的 403)。改状态同理:填表人在接口层即被拒(V14)。</p>
*
* <p>也不提供列表/详情读接口:那四条读出路径(列表/详情/统计/导出)必须共用同一个 scope 构造器
* (方案 §6.4 红线,V27/V30),归 T4 一次建成,避免本轮先写一个没有范围过滤的读接口、T4 再补一遍。</p>
* 的实现形态就是<b>不存在这些端点</b>,加上数据管理模块行守卫给出的 403)。改状态同理:
* 填表人在接口层即被拒(V14)。</p>
*
* <p><b>两层权限</b>(T4 / P30 收紧点):控制器用授权模块行把关({@code /admin/data},
* 决定"能不能用数据管理接口",手册 §2.2),服务内每条答卷再过一次<b>行级数据范围</b>
* (四条读出路径共用的 scope 构造器)——所以列表里看不见的答卷在这里也改不动,
* 批量接口逐条判、逐条报(V39 的范围半边)。读接口在 {@link FormAnswerReadController}。</p>
* <p>{@code schId} 参数只是超管跨校的目标域(校级传别的值即拒,同 {@link FormAdminController} 口径);
* 答卷定位按 {@code (域, uuid)},跨校答卷查不到。</p>
*/
@@ -33,13 +36,13 @@ public class FormAnswerAdminController {
private final FormAdminGuard adminGuard;
private final AnswerAdminService adminService;
/** 编辑值文档:按该答卷钉住的 version 校验(V8 同族),写 answer_op_log,索引同步重建 */
/** 编辑值文档:按该答卷钉住的 version 校验(P25 坐标=form_uuid+version),写 op_log、索引重建(V29) */
@PostMapping("/edit")
public void edit(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody AnswerModels.EditRequest body) {
Long domain = adminGuard.requireAdmin(user, schId);
adminService.edit(domain, user.getId(), body);
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
adminService.edit(user, domain, body);
}
/** 软删(唯一删除形态,无物理删除接口——V30);同步删索引行并释放限次名额 */
@@ -47,8 +50,8 @@ public class FormAnswerAdminController {
public void softDelete(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("answerId") String answerId) {
Long domain = adminGuard.requireAdmin(user, schId);
adminService.softDelete(domain, user.getId(), answerId);
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
adminService.softDelete(user, domain, answerId);
}
/** 改状态:填表人不可用(V14);写 status_log,系统内无任何转移规则校验(V15) */
@@ -56,17 +59,17 @@ public class FormAnswerAdminController {
public void changeStatus(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody AnswerModels.ChangeStatusRequest body) {
Long domain = adminGuard.requireAdmin(user, schId);
adminService.changeStatus(domain, user.getId(), body);
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
adminService.changeStatus(user, domain, body);
}
/** 批量软删:逐条单条校验与留痕,每条独立事务,结果逐条返回 */
/** 批量软删:逐条单条校验与留痕(V39),每条独立事务,逐条判范围、逐条返回结果 */
@PostMapping("/batch/softDelete")
public List<AnswerModels.BatchItemView> batchSoftDelete(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody AnswerModels.BatchRequest body) {
Long domain = adminGuard.requireAdmin(user, schId);
return adminService.batchSoftDelete(domain, user.getId(), body);
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
return adminService.batchSoftDelete(user, domain, body);
}
/** 批量改状态:同上 */
@@ -74,7 +77,7 @@ public class FormAnswerAdminController {
public List<AnswerModels.BatchItemView> batchChangeStatus(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody AnswerModels.BatchRequest body) {
Long domain = adminGuard.requireAdmin(user, schId);
return adminService.batchChangeStatus(domain, user.getId(), body);
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
return adminService.batchChangeStatus(user, domain, body);
}
}
@@ -0,0 +1,134 @@
package com.yunhai.formcenter.controller.api;
import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.service.AnswerExportService;
import com.yunhai.formcenter.service.AnswerReadService;
import com.yunhai.formcenter.service.AnswerStatsService;
import com.yunhai.formcenter.service.DataScopeService;
import com.yunhai.formcenter.service.FormAdminGuard;
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.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.util.List;
/**
* 答卷数据面读接口({@code /api/form/admin/data/**},CAS 会话 + 数据管理/统计模块行,T4 四条读出路径的前两条)。
*
* <p><b>权限两层各管一件事,缺一不可</b>(方案 §6.1 三层模型的第②③层):</p>
* <ul>
* <li>能不能进这个接口=授权模块行({@link FormAdminGuard}:列表/详情/导出要 {@code /admin/data},
* 统计要 {@code /admin/stats} 或 {@code /admin/data},手册 §2.2"可并入数据管理,二选一");</li>
* <li>进来后能看哪些行=数据范围({@link DataScopeService} 矩阵 + customId,
* 谓词由 {@code AnswerScopeQueryBuilder} 出,四条路径共用,方案 §6.4 红线)。</li>
* </ul>
*
* <p>所以"教师被授权了数据管理模块行"看到的是他范围内的数据,不是全校——
* 这也是 V26(无授权者后端接口拒)与 V27(范围贯穿)分别的断言点。</p>
*
* <p>身份纪律:租户域只从会话推导({@code schId} 参数是超管的目标域,校级传别的值即拒,同管理面口径);
* 响应只出现 uuid(V5)。列表是两段式的对外形态、导出是流式 xlsx(不走统一 JSON 信封,
* 见 {@link #export} 注释)。</p>
*/
@Slf4j
@RestController
@RequestMapping("/api/form/admin/data")
@RequiredArgsConstructor
public class FormAnswerReadController {
private final FormAdminGuard adminGuard;
private final AnswerReadService readService;
private final AnswerStatsService statsService;
private final AnswerExportService exportService;
/** 两段式列表(keyset 游标对外是 uuid,排序恒为提交时间序) */
@PostMapping("/list")
public ReadModels.ListView list(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody ReadModels.ListRequest body) {
requireFormUuid(body.getFormUuid());
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
return readService.list(user, domain, body);
}
/** 详情(uuid 寻址):行 + 钉住版本的内联展开 schema;不在范围内=FORBIDDEN(与列表同一构造器判) */
@GetMapping("/detail")
public ReadModels.DetailView detail(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("answerId") String answerId) {
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
return readService.detail(user, domain, answerId);
}
/** 筛选候选列(V3 + P28:searchable 且有检索算子的字段,附件类不进候选) */
@GetMapping("/filterFields")
public List<ReadModels.FilterFieldView> filterFields(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestParam("formUuid") String formUuid) {
requireFormUuid(formUuid);
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
return readService.filterFields(domain, formUuid);
}
/** 统计(总数/按状态/按字段分组,前端不聚合;受范围约束 + 仅升版失效的短 TTL 缓存) */
@PostMapping("/stats")
public ReadModels.StatsView stats(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody ReadModels.QueryRequest body) {
requireFormUuid(body.getFormUuid());
Long domain = adminGuard.requireAdminAny(user, schId,
FormAdminGuard.MODULE_STATS, FormAdminGuard.MODULE_DATA_MANAGE);
return statsService.stats(user, domain, body);
}
/**
* 导出(xlsx 流式同步响应)。<b>不套 JSON 信封</b>:这是文件流,{@code ApiResponseAdvice} 对
* 已提交的双进制响应不介入(返回类型 void、直接写 OutputStream),失败时仍由全局异常处理回到信封。
*
* <p>响应头里文件名只用 code(URL-safe 子集,P16 定稿)拼,避免中文文件名在各浏览器上的编码分支;
* 行数=该身份范围下的总数(V37),受范围约束由 {@code AnswerExportService} 与读路径同源保证。</p>
*/
@PostMapping("/export")
public void export(BoaUser user,
@RequestParam(required = false) Long schId,
@RequestBody ReadModels.QueryRequest body,
HttpServletResponse response) throws IOException {
requireFormUuid(body.getFormUuid());
Long domain = adminGuard.requireAdmin(user, schId, FormAdminGuard.MODULE_DATA_MANAGE);
String fileName = "form-answers.xlsx";
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("UTF-8");
response.setHeader("Content-Disposition",
"attachment; filename=" + fileName + "; filename*=UTF-8''" + encode(fileName));
try (OutputStream out = response.getOutputStream()) {
long written = exportService.export(user, domain, body, out);
log.info("[导出] schId={} formUuid={} 行数={}", domain, body.getFormUuid(), written);
}
}
private static void requireFormUuid(String formUuid) {
if (formUuid == null || formUuid.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "formUuid 不能为空(数据面按表单 uuid 寻址,契约 §1)");
}
}
private static String encode(String value) {
try {
return URLEncoder.encode(value, "UTF-8");
} catch (java.io.UnsupportedEncodingException e) {
return value;
}
}
}
@@ -0,0 +1,48 @@
package com.yunhai.formcenter.controller.oapi;
import com.yunhai.formcenter.model.OapiModels;
import com.yunhai.formcenter.service.OapiFormService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 一期对外读接口(契约 §9,appKey 签名强制:application.yml 的
* {@code yh.app-auth.server.enforce-patterns=[/oapi/*]} 已覆盖本前缀,无签名三件套直接 401)。
*
* <pre>
* GET /oapi/form/schema?schId=&code=[&appCode=] → data=内联 options + valueSchema 的机读 schema(契约 §4)
* GET /oapi/form/answer?schId=&answerId= → data=钉住版本的 schema 引用 + 值文档(契约 §9)
* </pre>
*
* <p>信封 {@code code=0} 由平台统一响应链负责;消费范式={@code YschOpenCall.raw()} 直调
* (Tier0 逃生舱,不建 SDK form 域门面——≥2 消费方实际调用时才触发晋升评估,方案 §7.5)。</p>
*
* <p>{@code appCode} 是 {@code /schema} 的<b>可选</b>参数(契约 §9 只写 schId+code):
* 不传=form-center(中心自己的表单),传=取该快应用归属下的同 code 表单。
* 加它是为了让快应用表单也能被对外读到,且不改变缺省行为——登记执行手册 §5 问题单。</p>
*/
@RestController
@RequestMapping("/oapi/form")
@RequiredArgsConstructor
public class OapiFormController {
private final OapiFormService formService;
/** 机读 schema(内联 options + valueSchema) */
@GetMapping("/schema")
public OapiModels.SchemaView schema(@RequestParam("schId") Long schId,
@RequestParam("code") String code,
@RequestParam(value = "appCode", required = false) String appCode) {
return formService.schema(schId, appCode, code);
}
/** 单条答卷(钉住版本 schema 引用 + 值文档) */
@GetMapping("/answer")
public OapiModels.AnswerView answer(@RequestParam("schId") Long schId,
@RequestParam("answerId") String answerId) {
return formService.answer(schId, answerId);
}
}
@@ -14,9 +14,10 @@ import javax.persistence.UniqueConstraint;
*
* <p>三条钉死的事实:</p>
* <ul>
* <li><b>版本钉住</b>:{@code (sch_id, app_code, code, version)} 是提交时解析到的版本冗余下来
* (方案 §5.2,svc-hall {@code ServiceApply} 先例);升版窗口旧版仍可填(P20),
* 在途提交天然按旧版落地,编辑/详情/导出也都按这一行列语义走。</li>
* <li><b>版本钉住</b>:{@code form_uuid} + {@code version} 是提交时解析到的版本坐标
* (P25;{@code (sch_id, app_code, code, version)} 四列继续冗余留存,供按 code 的入口视角与
* 索引表同源定位用,方案 §5.2,svc-hall {@code ServiceApply} 先例);升版窗口旧版仍可填(P20),
* 在途提交天然按旧版落地,编辑/详情/统计/导出也都按这一坐标取 schema。</li>
* <li><b>身份三键</b>(方案 §6.3):{@code submitter_id}=谁提交、{@code subject_id}=关于谁(可空)、
* {@code sch_id}=会话校,<b>全部会话推导不可外部传入</b>(契约 §5、V17)。</li>
* <li><b>幂等键是库级唯一</b>:防双击/重放只有 DB 唯一索引靠得住(方案 §5.1),
@@ -37,7 +38,12 @@ import javax.persistence.UniqueConstraint;
indexes = {
@Index(name = "idx_answer_form", columnList = "sch_id, code, status, id"),
@Index(name = "idx_answer_submitter", columnList = "sch_id, submitter_id"),
@Index(name = "idx_answer_subject", columnList = "subject_id")
@Index(name = "idx_answer_subject", columnList = "subject_id"),
// 铁律③(方案 §5.5):单表单列表/导出/计数的 keyset 游标走 (域, 应用, code, 未删, id)
// ——当页只取 id 时该索引即覆盖索引(index-only,不回表、不碰 values_json)。
// 契约 §10 草案的 idx_answer_form 以 status 结尾,只对"按状态筛/分组"是覆盖的,
// 不含 app_code 且无法在跨状态时给出 id 序 → 新增本索引,登记问题单。
@Index(name = "idx_answer_form_time", columnList = "sch_id, app_code, code, is_deleted, id")
})
public class Answer extends BaseEntity {
@@ -47,6 +53,18 @@ public class Answer extends BaseEntity {
@Column(name = "uuid", nullable = false, length = 32, updatable = false)
private String uuid;
/**
* 提交时解析<b>命中</b>的那张表单的 uuid(P25 裁决:钉版溯源的精确坐标)。
*
* <p>为什么不能只靠 {@code (sch_id, app_code, code, version)}:那组坐标不含"表单归属校",
* 平台级表单(sch_id=0)被本校后建的同 {@code (app_code, code)} 表单遮蔽后就出现歧义窗口——
* 遮蔽规则会把解析指向本校表单,版本号若恰好对得上,历史答卷会被<b>静默按错 schema</b>
* 校验/重建索引(比查不到更糟)。按 form_uuid → form(uk_form_uuid) → 版本行取数没有这个窗口:
* uuid 跨版本不变(契约 §1),遮蔽与升版都不影响它指向的表单。</p>
*/
@Column(name = "form_uuid", nullable = false, length = 32, updatable = false)
private String formUuid;
@Column(name = "sch_id", nullable = false, updatable = false)
private Long schId;
@@ -59,6 +59,18 @@ public final class FormEnums {
}
return false;
}
/** 对外下发用枚举名(契约 §4 的 {@code "audience":"STUDENT"}),不外露数字 */
public static String nameOf(Integer code) {
if (code != null) {
for (Audience a : values()) {
if (a.code == code) {
return a.name();
}
}
}
return null;
}
}
/** 限填次数:0 不限 / 1 限一次(去重键见 {@link LimitScope},与幂等键是两个机制,方案 §5.4) */
@@ -87,4 +99,38 @@ public final class FormEnums {
public enum LimitScope {
SUBJECT, SUBMITTER
}
/**
* 数据范围(契约 §8 / 方案 §6.4):<b>闭集四枚举,一期契约即含全部四值</b>——
* 平台级的东西定了就不变,后续加枚举=加实现器({@code AnswerScopeImplementor})。
*
* <p>{@link #TEACHING_CLASS} 是 1.5 开放项(依赖 UserClass 任教关系/班级 SDK):
* <b>枚举里有、一期不可选</b>(V28),其实现器只是占位。</p>
*/
public enum DataScope {
ALL("全部"),
TEACHING_CLASS("任教班级"),
SELF_SUBMITTED("仅本人提交"),
ABOUT_ME("关于我");
private final String label;
DataScope(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
/** 枚举名解析(对外只暴露名字,数字序号不外露——加值时不破坏兼容) */
public static DataScope of(String name) {
for (DataScope scope : values()) {
if (scope.name().equalsIgnoreCase(name == null ? null : name.trim())) {
return scope;
}
}
return null;
}
}
}
@@ -0,0 +1,72 @@
package com.yunhai.formcenter.model;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Getter;
import lombok.Setter;
/**
* 一期对外读接口(oapi)的响应模型(契约 §9 两端点、§4 下发形状)。
*
* <p>信封 {@code {code,msg,data}} 与 {@code code=0} 由平台 starter 的统一响应链负责(同 flow-center
* {@code /oapi/flow/**} 先例),本层只交 data 载荷;因此这里<b>不出现</b>任何"未找到"专用码——
* 未找到按契约 §6 定稿映射走 {@code PARAM_ERROR(2)} + msg。</p>
*
* <p>不外露的三样:库内 {@code id}(契约 §1/V5)、字典本身({@code options} 一律内联展开,契约 §2.1)、
* 附件绝对 URL(只有值里的 {@code {id,fileName,key}},V24)。</p>
*/
public final class OapiModels {
private OapiModels() {
}
/** {@code GET /oapi/form/schema} 的 data:机读 schema(内联 options + 每字段 valueSchema) */
@Getter
@Setter
public static class SchemaView {
private String formUuid;
private Integer version;
private String code;
private String appCode;
/** STUDENT / TEACHER(枚举名下发,不外露数字,契约 §4 示例) */
private String audience;
/** {@code [{key,label,fields:[{… , options:[{label,value}], searchable, valueSchema:{…}}]}]} */
private JsonNode groups;
}
/** {@code GET /oapi/form/answer} 的 data:钉住版本的 schema 引用 + 值文档(契约 §9) */
@Getter
@Setter
public static class AnswerView {
private String answerId;
private String formUuid;
private String appCode;
private String code;
/** 该答卷钉住的版本(不随升版改变) */
private Integer version;
private String status;
private Integer submitterId;
private Integer subjectId;
private String answerNo;
private String submitTime;
/** 值文档原样(三态空值/已删字段的降级由消费方按 schema 判,契约 §3) */
private JsonNode values;
/** 钉住版本的 schema 引用(消费方要"当时那张表"就按这组坐标取,见 {@link #groups}) */
private SchemaRef schemaRef;
/**
* 钉住版本的内联展开 schema(同 §4 形状但不含 valueSchema)。
* 为什么带上:§9 的 schema 端点下发的是<b>当前发布版本</b>,答卷钉的可能是历史版本——
* 只给引用不给内容,消费方拿不到"当时那张表",快照语义就落空(契约 §9"快照请消费方值拷贝")。
*/
private JsonNode groups;
}
/** schema 坐标(不含库内 id) */
@Getter
@Setter
public static class SchemaRef {
private String formUuid;
private String appCode;
private String code;
private Integer version;
}
}
@@ -0,0 +1,194 @@
package com.yunhai.formcenter.model;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.common.FormFilterOperators;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
/**
* 数据面读路径(列表/详情/统计/导出)出入参模型(契约 §8/§9,方案 §5.5)。
*
* <p>三条对外纪律:</p>
* <ul>
* <li><b>游标也是 uuid</b>:keyset 的游标值对外是 {@code cursor}=上一页末行答卷的 uuid,
* 服务端按 {@code uk_answer_uuid} 换成库内 id 再比——自增 id 不出现在任何响应外层(契约 §1/V5)。
* 代价是每页多一次索引点查,比把 id 泄露进 URL 便宜得多;</li>
* <li><b>排序列不外露</b>:读请求没有"按某列排序"的参数,一期恒为提交时间序(方案 §5.5 铁律②),
* 只有方向;</li>
* <li><b>值文档原样回传</b>:三态空值(无值/无 key/已删字段)的降级显示由前端做(契约 §3),
* 后端不改写、不补占位。</li>
* </ul>
*/
public final class ReadModels {
private ReadModels() {
}
/** 列表/统计/导出共用的查询条件(filters 的算子由服务端按字段类型定,见 {@link FilterItem}) */
@Getter
@Setter
public static class QueryRequest {
/** 目标表单 uuid(数据面身份,契约 §1) */
private String formUuid;
/** 状态 code 精确过滤,可空 */
private String status;
/** searchable 字段筛选(候选集见 {@link FilterFieldView}) */
private List<FilterItem> filters;
/** 仅统计接口消费:按哪个 searchable 字段分组,可空=只出总数与按状态 */
private String groupByField;
}
/**
* 单个筛选条件。<b>算子不是调用方给的</b>:契约 §2 只给三类算子,按字段 type 收敛——
* 等值(select/radio/checkbox/multiselect)、区间(date/number,用 {@code from}/{@code to})、
* 前缀模糊(text/textarea/phone,用 {@code value})。给错形态按 PARAM_ERROR 拒。
*/
@Getter
@Setter
public static class FilterItem {
private String key;
/** 等值/前缀值 */
private String value;
/** 区间下界(含):number 数值串、date 的 yyyy-MM-dd */
private String from;
/** 区间上界(含) */
private String to;
}
/** 列表请求(keyset 游标 + 方向) */
@Getter
@Setter
public static class ListRequest extends QueryRequest {
/** 上一页末行的 answerId(uuid);首屏不传 */
private String cursor;
/** 方向:desc(默认,最新在前)/asc;排序列恒为提交时间 */
private String order;
/** 页大小,默认 20、上限 100(方案 §5.5:当页 10~20 时 JSON 解析成本可忽略) */
private Integer size;
}
/** 列表结果:rows + 下一页游标;没有 offset/total 页码概念(铁律②禁裸 LIMIT offset) */
@Getter
@Setter
public static class ListView {
private List<RowView> rows = new ArrayList<>();
/** 传给下一页请求的 cursor;当页为空即 null(到底) */
private String nextCursor;
private Integer size;
/** 生效范围(枚举名,便于前端与验收断言对照;不承诺 UI 文案) */
private Set<DataScope> scopes;
}
/** 列表行(值文档原样,列由前端按 schema 展) */
@Getter
@Setter
public static class RowView {
private String answerId;
private String formUuid;
private Integer version;
private String status;
/** 状态 label;已废弃 code 由前端标"(已移除)",这里给 null 即表示定义里没有该 code */
private String statusLabel;
private Integer submitterId;
private Integer subjectId;
private String answerNo;
/** 提交时间 yyyy-MM-dd HH:mm:ss(= create_time,一期唯一排序键) */
private String submitTime;
private JsonNode values;
}
/** 详情:行 + 钉住版本的 schema(内联展开后的 groups),消费方/前端按它渲染历史数据(契约 §9 同形) */
@Getter
@Setter
public static class DetailView extends RowView {
private String appCode;
private String code;
private String formName;
/** 钉住版本(该答卷自己的 schema,不随升版改变) */
private JsonNode groups;
}
/** 统计结果(V11:总数/按状态/按字段分组,前端不做聚合) */
@Getter
@Setter
public static class StatsView {
private long total;
private List<CountItem> byStatus = new ArrayList<>();
/** 按字段分组结果;未请求分组时为空列表 */
private List<FieldGroups> byField = new ArrayList<>();
/** true=本结果来自统计缓存(短 TTL,容忍 N 分钟延迟,方案 §5.5 铁律④) */
private Boolean cached;
}
/** 一个分组桶:value=分组键(状态 code / 索引值),label=展示名(可空),count=条数 */
@Getter
@Setter
public static class CountItem {
private String value;
private String label;
private long count;
public CountItem() {
}
public CountItem(String value, String label, long count) {
this.value = value;
this.label = label;
this.count = count;
}
}
/** 按字段分组的结果集(一个字段一组桶) */
@Getter
@Setter
public static class FieldGroups {
private String key;
private String label;
private List<CountItem> items = new ArrayList<>();
}
/** 筛选候选列(V3/P28:只有 searchable 且<em>有算子</em>的字段才出现在这里) */
@Getter
@Setter
public static class FilterFieldView {
private String key;
private String label;
private String type;
/** EQ(等值)/ RANGE(区间)/ PREFIX(前缀模糊)——由 type 推导,前端按它出控件 */
private String operator;
}
/**
* 读路径内部查询规格(服务端推导,<b>不从请求体绑定</b>):租户域 + 表单坐标 + 范围 + 筛选 + 游标。
* {@link AnswerScopeQueryBuilder} 的入参,四条读出路径与管理动作行级把关共用同一个形状。
*
* <p>{@code filters} 装的是已按字段类型收敛过的 {@link FormFilterOperators.Spec}——
* 请求里的裸条件(字符串三元组)在服务层解析成 Spec,构造器因此不认识 schema、也不给注入面。</p>
*/
@Getter
@Setter
public static class Query {
private Long schId;
private String appCode;
private String code;
private Set<DataScope> scopes;
private Integer userId;
private String status;
private List<FormFilterOperators.Spec> filters;
/** 游标:库内 id(对外是 uuid,服务端换);null=首屏 */
private Long cursorId;
private boolean ascending;
private int size = 20;
/** 行级 uuid 限定(详情/管理动作的"这条在不在范围内"判定用),可空 */
private String answerUuid;
public List<FormFilterOperators.Spec> filtersOrEmpty() {
return filters == null ? new ArrayList<>() : filters;
}
}
}
@@ -17,6 +17,10 @@ public interface AnswerIndexRepository extends JpaRepository<AnswerIndex, Long>
List<AnswerIndex> findByAnswerIdOrderByIdAsc(Long answerId);
/** 该答卷已有索引行的字段(升版补建去重用,方案 §5.2 索引行生命周期) */
@Query("select x.fieldKey from AnswerIndex x where x.answerId = :answerId")
List<String> findFieldKeysByAnswerId(@Param("answerId") Long answerId);
/** 重建/软删清理:bulk delete(方案 §7.7 全量覆盖式保存用 @Modifying) */
@Modifying
@Query("delete from AnswerIndex x where x.answerId = :answerId")
@@ -1,17 +1,22 @@
package com.yunhai.formcenter.repository;
import com.yunhai.formcenter.entity.Answer;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
/**
* 答卷仓库。数据面一律按 {@code uuid + sch_id} 定位(契约 §1:id 绝不外露、uuid 不可枚举)。
* 检索/统计/导出的读路径在 T4(四读出路径共用同一 scope 构造器,方案 §6.4 红线),
* 本轮只放提交与管理操作必需的查询——<b>不提供任何物理删除方法</b>(P22/V30)。
* T4 的检索/统计/导出不在这里写查询——那四条读出路径必须共用同一个 scope 构造器
* (方案 §6.4 红线),实现落在 {@code AnswerScopeQueryBuilder},本仓库只提供它取不到形状的
* 两段式第二段({@link #findLiveByIds})与提交/管理操作的定位方法。
* <b>answer 数据不提供任何物理删除方法</b>(P22/V30;{@code FillQuotaRepository.releaseQuota}
* 的删除是"释放名额",方案 §5.4 点名行为,不属答卷数据删除)。
*/
public interface AnswerRepository extends JpaRepository<Answer, Long> {
@@ -20,6 +25,16 @@ public interface AnswerRepository extends JpaRepository<Answer, Long> {
Optional<Answer> findByUuidAndSchId(String uuid, Long schId);
/**
* 两段式列表的第二段(方案 §5.5 铁律①):<b>按当页 id 批量取整文档</b>。
* 第一段({@code AnswerScopeQueryBuilder#pageIds})只查 id,不带 {@code values_json};
* 这里才取 MEDIUMTEXT——一页 10~20 行,JSON 解析成本可忽略。
* 仍带 {@code sch_id} 与 {@code is_deleted=0}:拿到 id 列表后不再放宽条件,
* 免得"范围判定"与"取数"两处口径分叉。
*/
@Query("select a from Answer a where a.schId = :schId and a.isDeleted = 0 and a.id in (:ids)")
List<Answer> findLiveByIds(@Param("schId") Long schId, @Param("ids") Collection<Long> ids);
/**
* 报名号当日序号:统计该 (校, app_code, code) 下以 {@code {code}-{YYYYMMDD}-} 开头的行数(<b>含软删行</b>——
* 软删不回收号,否则同日重填会出现两个相同报名号)。前缀由服务端拼接,参数绑定进 JPQL。
@@ -31,4 +46,15 @@ public interface AnswerRepository extends JpaRepository<Answer, Long> {
/** 测试与审计取证:某表单下的全部答卷(按 id 升序) */
List<Answer> findBySchIdAndAppCodeAndCodeOrderByIdAsc(Long schId, String appCode, String code);
/**
* 升版补建索引行的分批扫描({@code AnswerIndexService#backfillAfterPublish}):
* 按 {@code form_uuid}(P25 的精确坐标)取未删答卷、id 游标推进、批大小由 Pageable 下推。
* 这是<b>唯一</b>允许整页取 {@code values_json} 的非读接口路径——补建要读的就是值文档本身,
* 且触发点是低频的发布动作,不是列表/统计/导出(那三条的铁律①约束在 {@code AnswerScopeQueryBuilder})。
*/
@Query("select a from Answer a where a.formUuid = :formUuid and a.isDeleted = 0 and a.id > :cursor "
+ "order by a.id asc")
List<Answer> findLiveByFormUuidAfterId(@Param("formUuid") String formUuid, @Param("cursor") Long cursor,
Pageable pageable);
}
@@ -21,6 +21,14 @@ public interface FormRepository extends JpaRepository<Form, Long> {
Optional<Form> findByUuidAndSchId(String uuid, Long schId);
/**
* 按 uuid 精确取头表(P25:数据面 {@code answer.form_uuid} 的溯源入口,uk_form_uuid 唯一)。
* <b>不带 sch_id 条件、也不过滤 is_deleted</b>——答卷的钉版坐标是全局 uuid,
* 归属校由 {@code answer.sch_id} 自己保证;软删表单的历史答卷仍可溯源(P32/方案 §5.4)。
* 租户域复核在调用方({@code FormDefService#readDomainForm})。
*/
Optional<Form> findByUuid(String uuid);
/**
* 管理端列表:本校未删表单(软删行不进列表,方案 §5.4)。
* app_code/status 过滤在应用层做——一所学校的表单量级是个位数到几百,
@@ -35,14 +43,4 @@ public interface FormRepository extends JpaRepository<Form, Long> {
List<Form> findResolveCandidates(@Param("code") String code,
@Param("appCode") String appCode,
@Param("schIds") Collection<Long> schIds);
/**
* 数据面取域(T3 答卷溯源):同一条遮蔽规则,但<b>不排除软删表单</b>——
* 表单只有软删、没有物理删除(方案 §5.4),其历史答卷仍要可按钉住版本校验与溯源,
* 否则"删掉表单"会连带把存量数据变成无法管理的孤儿。
*/
@Query("select f from Form f where f.code = :code and f.appCode = :appCode and f.schId in (:schIds)")
List<Form> findDataDomainCandidates(@Param("code") String code,
@Param("appCode") String appCode,
@Param("schIds") Collection<Long> schIds);
}
@@ -0,0 +1,35 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import org.springframework.stereotype.Component;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
/**
* 范围实现器:<b>关于我</b>({@code subject_id}=会话用户)。
*
* <p>与"仅本人提交"是两个视角(方案 §6.3 拆分 submitter/subject 的动因):学生看到的是
* <em>关于自己</em>的数据,<b>包含老师代填的那一条</b>——老师代填时 {@code submitter_id} 是老师、
* {@code subject_id} 是学生,按本范围才可见(V18)。</p>
*/
@Component
public class AboutMeScopeImplementor implements AnswerScopeImplementor {
@Override
public DataScope scope() {
return DataScope.ABOUT_ME;
}
@Override
public boolean selectable() {
return true;
}
@Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) {
return cb.equal(root.get("subjectId"), userId);
}
}
@@ -0,0 +1,29 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import org.springframework.stereotype.Component;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
/** 范围实现器:<b>全部</b>——不加行级条件(契约 §8 管理员/校领导默认,或 fc:scope:all 升级)。 */
@Component
public class AllScopeImplementor implements AnswerScopeImplementor {
@Override
public DataScope scope() {
return DataScope.ALL;
}
@Override
public boolean selectable() {
return true;
}
@Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) {
return cb.conjunction();
}
}
@@ -1,13 +1,16 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.common.FormRuntimeRules;
import com.yunhai.formcenter.common.FormStatusDefs;
import com.yunhai.formcenter.common.FormValueValidator;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.AnswerOpLog;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.entity.FormFile;
import com.yunhai.formcenter.entity.StatusLog;
import com.yunhai.formcenter.model.AnswerModels;
import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.repository.AnswerOpLogRepository;
import com.yunhai.formcenter.repository.AnswerRepository;
import com.yunhai.formcenter.repository.FillQuotaRepository;
@@ -15,6 +18,7 @@ import com.yunhai.formcenter.repository.FormFileRepository;
import com.yunhai.formcenter.repository.StatusLogRepository;
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;
@@ -24,24 +28,26 @@ import java.util.ArrayList;
import java.util.List;
/**
* 答卷管理操作(契约 §5/§10,方案 §5.4 生命周期表"谁碰、怎么碰"那一列的落地)。
* 答卷管理操作(契约 §5/§8/§10,方案 §5.4 生命周期表"谁碰、怎么碰"那一列的落地)。
*
* <ul>
* <li><b>编辑</b>:仅管理员,原地修正值文档;<b>按该答卷提交时钉住的 version 校验与渲染</b>
* (V8 同族——新 schema 的字段不会凭空出现在旧数据上,旧 schema 已删的字段也不会被新校验误拒);
* 写 {@code answer_op_log},索引同步重建。不改状态、不改身份、不看截止时间。</li>
* <li><b>软删</b>:唯一删除形态(<b>无物理删除接口</b>,V30/P22);同步删 {@code answer_index} 行
* (方案 §5.2)+ <b>释放 fill_quota 名额</b>(管理员删错单后学生可重填,方案 §5.4 点名场景);
* 留痕表不动——痕迹是审计资产。</li>
* <li><b>编辑</b>:仅管理员,原地修正值文档;<b>按该答卷钉住的版本校验与渲染</b>
* (P25:坐标={@code answer.form_uuid + version},V8 同族——新 schema 的字段不会凭空出现在旧数据上,
* 旧 schema 已删的字段也不会被新校验误拒);写 {@code answer_op_log},索引同步重建。
* 不改状态、不改身份、不看截止时间(P32)。</li>
* <li><b>软删</b>:唯一删除形态(<b>无物理删除接口</b>,V30);同步删 {@code answer_index} 行(方案 §5.2)
* + <b>释放 fill_quota 名额</b>(方案 §5.4 点名场景);留痕表不动——痕迹是审计资产。</li>
* <li><b>改状态</b>:只写 {@code status_log},<b>系统内不存在任何转移规则校验</b>(V15);
* 填表人在接口层即被拒(V14)。目标状态必须是该表单状态定义里的<b>未废弃</b> code。</li>
* <li><b>批量</b>:逐条走单条校验与留痕(方案 §5.4)——每条一个独立事务,
* 一条失败不回滚其它条,结果逐条返回。这是"批量与单条完全一致"的实现形态。</li>
* <li><b>批量</b>:逐条走单条校验与留痕(方案 §5.4)——每条一个独立事务,一条失败不回滚其它条。
* <b>批量同样受数据范围约束</b>(V39 的范围半边):范围检查在每条的执行体里,
* 所以"选中 200 条但只有 3 条在你范围内"的结果是 3 成功 197 被拒,逐条报原因。</li>
* </ul>
*
* <p>权限:调用方(控制器)已用 {@code FormAdminGuard} 拿到管理权与租户域,本服务只按
* {@code (schId, answerUuid)} 定位——<b>跨校答卷根本查不到</b>,越权面在定位这一步就关死。
* 数据范围(契约 §8 矩阵/scope 构造器)属 T4,本轮不做行级过滤。</p>
* <p><b>两层权限都过</b>(P30 的收紧点,T4 落地):控制器先按授权模块行把关({@code FormAdminGuard},
* 决定"能不能用数据管理接口"),每条答卷再过一次<b>行级范围</b>——判据是四条读出路径共用的
* {@link AnswerScopeQueryBuilder}(不是另写一套"管理员例外"逻辑)。因此<b>列表里看不见的答卷,
* 编辑/软删/改状态也做不了</b>:教师即便被授权了数据管理模块行,也只能动自己范围内的行。</p>
*/
@Slf4j
@Service
@@ -59,39 +65,42 @@ public class AnswerAdminService {
private final FormDefService defService;
private final FormDictService dictService;
private final AnswerIndexService indexService;
private final SubjectDirectory subjectDirectory;
private final AnswerReadService readService;
private final AnswerScopeQueryBuilder queryBuilder;
private final TransactionTemplate transactionTemplate;
// ==================== 单条 ====================
/** 管理员编辑:按钉住版本校验值文档,原地修正 + 重建索引 + 留痕(V29) */
public void edit(Long schId, Integer operatorId, AnswerModels.EditRequest req) {
transactionTemplate.executeWithoutResult(tx -> doEdit(schId, operatorId, req));
/** 管理员编辑:行级范围 + 按钉住版本校验值文档 + 代填身份复核,原地修正、重建索引、留痕(V29/P30/P33/P34) */
public void edit(BoaUser operator, Long schId, AnswerModels.EditRequest req) {
transactionTemplate.executeWithoutResult(tx -> doEdit(operator, schId, req));
}
/** 软删:唯一删除形态;同步删索引行、释放限次名额、留痕(V30/V32 后半) */
public void softDelete(Long schId, Integer operatorId, String answerUuid) {
transactionTemplate.executeWithoutResult(tx -> doSoftDelete(schId, operatorId, answerUuid, false));
/** 软删:唯一删除形态;同步删索引行、释放限次名额、留痕(V30/V32 后半),受行级范围约束(P30) */
public void softDelete(BoaUser operator, Long schId, String answerUuid) {
transactionTemplate.executeWithoutResult(tx -> doSoftDelete(operator, schId, answerUuid, false));
}
/** 改状态:非填表人(接口层由守卫把关,V14);写 status_log,无转移校验(V15) */
public void changeStatus(Long schId, Integer operatorId, AnswerModels.ChangeStatusRequest req) {
transactionTemplate.executeWithoutResult(tx -> doChangeStatus(schId, operatorId, req, false));
/** 改状态:非填表人(接口层由守卫把关,V14);写 status_log,无转移校验(V15),受行级范围约束 */
public void changeStatus(BoaUser operator, Long schId, AnswerModels.ChangeStatusRequest req) {
transactionTemplate.executeWithoutResult(tx -> doChangeStatus(operator, schId, req, false));
}
// ==================== 批量(逐条独立事务) ====================
public List<AnswerModels.BatchItemView> batchSoftDelete(Long schId, Integer operatorId,
public List<AnswerModels.BatchItemView> batchSoftDelete(BoaUser operator, Long schId,
AnswerModels.BatchRequest req) {
List<String> ids = requireBatchIds(req.getAnswerIds());
List<AnswerModels.BatchItemView> results = new ArrayList<>();
for (String answerUuid : ids) {
results.add(runOne(answerUuid, () -> transactionTemplate.executeWithoutResult(
tx -> doSoftDelete(schId, operatorId, answerUuid, true))));
tx -> doSoftDelete(operator, schId, answerUuid, true))));
}
return results;
}
public List<AnswerModels.BatchItemView> batchChangeStatus(Long schId, Integer operatorId,
public List<AnswerModels.BatchItemView> batchChangeStatus(BoaUser operator, Long schId,
AnswerModels.BatchRequest req) {
List<String> ids = requireBatchIds(req.getAnswerIds());
List<AnswerModels.BatchItemView> results = new ArrayList<>();
@@ -100,19 +109,22 @@ public class AnswerAdminService {
one.setAnswerId(answerUuid);
one.setStatus(req.getStatus());
results.add(runOne(answerUuid, () -> transactionTemplate.executeWithoutResult(
tx -> doChangeStatus(schId, operatorId, one, true))));
tx -> doChangeStatus(operator, schId, one, true))));
}
return results;
}
// ==================== 实现(每条一个独立事务的执行体) ====================
private void doEdit(Long schId, Integer operatorId, AnswerModels.EditRequest req) {
Answer answer = requireLiveAnswer(schId, req.getAnswerId());
FormDefService.Published pinned = defService.pinnedForData(
answer.getSchId(), answer.getAppCode(), answer.getCode(), answer.getVersion());
private void doEdit(BoaUser operator, Long schId, AnswerModels.EditRequest req) {
Answer answer = requireManageableAnswer(operator, schId, req.getAnswerId());
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
String schemaJson = pinned.versionRow().getSchemaJson();
// P33:编辑一条"代填来的"答卷时,被代填人必须仍然真实存在且属本校——
// 提交时验过一次不代表现在还在(用户可能被删/转校)。身份列本接口不改,但脏引用不能靠编辑继续留档。
assertSubjectStillValid(answer);
if (req.getValues() != null && !req.getValues().isNull() && !req.getValues().isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "values 必须是 JSON 对象 {key:value}");
}
@@ -125,36 +137,34 @@ public class AnswerAdminService {
answerRepository.save(answer);
// 索引同步重建(方案 §5.4):仍按钉住版本的 searchable 声明写,不随新版本 schema 改变
indexService.rebuild(answer, schemaJson);
opLog(answer.getId(), AnswerOpLog.OP_EDIT, operatorId,
opLog(answer.getId(), AnswerOpLog.OP_EDIT, operator.getId(),
req.getSummary() == null ? "管理员编辑(钉住版本 v" + answer.getVersion() + ")"
: "编辑:钉住版本 v" + answer.getVersion() + ";" + req.getSummary());
log.info("[编辑] answer={} 按 v{} 校验通过并重建索引", answer.getUuid(), answer.getVersion());
}
private void doSoftDelete(Long schId, Integer operatorId, String answerUuid, boolean batched) {
Answer answer = requireLiveAnswer(schId, answerUuid);
private void doSoftDelete(BoaUser operator, Long schId, String answerUuid, boolean batched) {
Answer answer = requireManageableAnswer(operator, schId, answerUuid);
answer.setIsDeleted(1);
answerRepository.save(answer);
indexService.clear(answer.getId());
// 释放名额:limit_key 与提交时同一条推导(coalesce(subject, submitter))。
// 不按"当前是否仍限次"判断——配置改回不限次后残留名额仍会挡住重填。
Long formId = defService.pinnedForData(answer.getSchId(), answer.getAppCode(), answer.getCode(),
answer.getVersion()).form().getId();
Long formId = defService.pinnedForData(answer.getFormUuid(), answer.getVersion()).form().getId();
quotaRepository.releaseQuota(formId,
FormRuntimeRules.quotaKey(answer.getSubjectId(), answer.getSubmitterId()));
opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_SOFT_DELETE : AnswerOpLog.OP_SOFT_DELETE,
operatorId, batched ? "批量软删(单条同校验、单条留痕)" : "软删(无物理删除接口)");
operator.getId(), batched ? "批量软删(单条同校验、单条留痕)" : "软删(无物理删除接口)");
}
private void doChangeStatus(Long schId, Integer operatorId, AnswerModels.ChangeStatusRequest req,
private void doChangeStatus(BoaUser operator, Long schId, AnswerModels.ChangeStatusRequest req,
boolean batched) {
Answer answer = requireLiveAnswer(schId, req.getAnswerId());
Answer answer = requireManageableAnswer(operator, schId, req.getAnswerId());
String status = req.getStatus() == null ? null : req.getStatus().trim();
if (status == null || status.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "status 不能为空");
}
FormDefService.Published pinned = defService.pinnedForData(
answer.getSchId(), answer.getAppCode(), answer.getCode(), answer.getVersion());
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
Form form = pinned.form();
// 目标状态必须是该表单定义里的未废弃 code;已废弃 code 只用于历史回显(V13 由读路径降级显示)
boolean live = FormStatusDefs.liveDefs(form.getStatusDefs()).stream()
@@ -169,16 +179,47 @@ public class AnswerAdminService {
StatusLog entry = new StatusLog();
entry.setAnswerId(answer.getId());
entry.setStatusCode(status);
entry.setOperatorId(operatorId);
entry.setOperatorId(operator.getId());
statusLogRepository.save(entry);
// 留痕与单条完全一致(方案 §5.4):一条答卷一行;批量通道只在 op 与 summary 上标出来自批量
opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_CHANGE_STATUS : AnswerOpLog.OP_CHANGE_STATUS,
operatorId, (batched ? "批量改状态:" : "改状态:") + oldStatus + " → " + status
operator.getId(), (batched ? "批量改状态:" : "改状态:") + oldStatus + " → " + status
+ "(无转移规则校验)");
}
// ==================== 辅助 ====================
/**
* 定位 + 行级范围把关(P30 的落点)。
*
* <p>顺序是刻意的:先按 {@code (域, uuid)} 定位(跨校答卷查不到,T3 已有的第一道),
* 再用<b>四条读出路径共用的范围构造器</b>判"这条在不在你的数据范围内"。
* 判定用 SQL({@link AnswerScopeQueryBuilder#visible})而不是在 Java 里比字段——
* 与列表口径必然一致,因此"列表能看见的"与"能改的"是同一个集合,不存在两套判据漂移。</p>
*/
private Answer requireManageableAnswer(BoaUser operator, Long schId, String answerUuid) {
Answer answer = requireLiveAnswer(schId, answerUuid);
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
ReadModels.Query scope = readService.scopeQuery(operator, schId, pinned.form(),
new ReadModels.QueryRequest());
if (!queryBuilder.visible(scope, answer.getUuid())) {
throw new BizException(ErrorCode.FORBIDDEN,
"该答卷不在你的数据范围内(契约 §8 矩阵),不能执行管理动作:" + answerUuid);
}
return answer;
}
private void assertSubjectStillValid(Answer answer) {
Integer subjectId = answer.getSubjectId();
if (subjectId == null || subjectId.equals(answer.getSubmitterId())) {
return;
}
if (!subjectDirectory.existsInSchool(subjectId, answer.getSchId())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"该答卷的被代填人已不存在或已不属本校,不能编辑留档:subjectId=" + subjectId);
}
}
private Answer requireLiveAnswer(Long schId, String answerUuid) {
if (answerUuid == null || answerUuid.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "answerId 不能为空");
@@ -191,11 +232,25 @@ public class AnswerAdminService {
return answer;
}
/**
* 附件归属与一致性(P24 同校 + P34 key 一致),与提交侧 {@code AnswerService} 同一判据:
* 值里的 {@code {id,fileName,key}} 必须是同校上传接口返回的原样三件。
*/
private void assertAttachmentsOwned(Long schId, String valuesJson, String schemaJson) {
for (Long id : FormValueValidator.attachmentFileIds(valuesJson, schemaJson)) {
if (!fileRepository.findByIdAndSchId(id, schId).isPresent()) {
for (JsonNode item : FormValueValidator.attachments(valuesJson, schemaJson)) {
long id = item.get("id").longValue();
FormFile file = fileRepository.findByIdAndSchId(id, schId).orElse(null);
if (file == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "附件不存在或不属于本校:id=" + id);
}
String key = item.path("key").asText(null);
if (key == null || !key.equals(file.getStoreKey())) {
throw new BizException(ErrorCode.PARAM_ERROR, "附件 key 与上传记录不一致:id=" + id);
}
String fileName = item.path("fileName").asText(null);
if (fileName == null || !fileName.equals(file.getFileName())) {
throw new BizException(ErrorCode.PARAM_ERROR, "附件文件名与上传记录不一致:id=" + id);
}
}
}
@@ -227,5 +282,4 @@ public class AnswerAdminService {
return AnswerModels.BatchItemView.fail(answerUuid, e.getMessage());
}
}
}
@@ -0,0 +1,267 @@
package com.yunhai.formcenter.service;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.repository.AnswerRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import com.yunhai.platform.utils.model.BoaUser;
import org.springframework.stereotype.Service;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 答卷导出(方案 §5.5 铁律④ / V37):<b>范围恒为单表单 + 可选筛选</b>,流式分批逐批写 easyexcel,
* 同步响应(不设行数上限、不做异步导出/下载中心——真实规模 1 千~2 万行级,没必要)。
*
* <p>三条纪律:</p>
* <ul>
* <li><b>受数据范围约束</b>:查询规格与谓词都来自四读出路径共用的 {@link AnswerScopeQueryBuilder}
* (与 {@link #stats} 同源,见类内实现),所以"导出 ⊆ 该身份列表可见集"是结构性成立的,
* 不是靠导出侧再判一遍;</li>
* <li><b>行数=该范围下的总数</b>(不是列表当页):分批迭代靠 keyset 游标推进(每批
* {@value #BATCH_SIZE} 行),全程不载入全量、也不 {@code select values} 做扫描;</li>
* <li><b>内容按各行钉住版本渲染</b>:选项列显 label(按该版本 options 定义序,契约 §3)、
* 附件列显 fileName(值里本来就只有 {@code {id,fileName,key}},不拼 URL——V24)。</li>
* </ul>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AnswerExportService {
/** 每批行数(流式写的批大小;与列表页大小不同轴,导出要吞吐所以放大) */
static final int BATCH_SIZE = 500;
private static final String SEPARATOR = "、";
private final AnswerReadService readService;
private final AnswerScopeQueryBuilder queryBuilder;
private final FormDefService defService;
private final FormDictService dictService;
private final AnswerRepository answerRepository;
/**
* 导出到输出流(调用方=控制器,负责响应头与文件名;本方法写完关闭 ExcelWriter)。
*
* @return 实际写出的数据行数(控制器日志与 V37 断言用)
*/
public long export(BoaUser user, Long domainSchId, ReadModels.QueryRequest req, OutputStream out) {
Form form = defService.readDomainForm(domainSchId, req.getFormUuid());
ReadModels.Query query = readService.scopeQuery(user, domainSchId, form, req);
query.setSize(BATCH_SIZE);
query.setAscending(false);
query.setCursorId(null);
// 列集合=当前发布版本的字段序(方案 §5.2"列表列=最新 schema ∪ 历史 searchable"里,
// 导出按最新 schema 出列,历史版本独有字段的值仍在该行的 values 文档里,按行钉版渲染 label)
Map<String, JsonNode> currentFields = fieldsByVersion(form, form.getCurrentVersion());
List<String> columnKeys = new ArrayList<>(currentFields.keySet());
ExcelWriter writer = EasyExcel.write(out).head(head(columnKeys, currentFields)).build();
WriteSheet sheet = EasyExcel.writerSheet("答卷").build();
long written = 0L;
int pageSize = AnswerScopeQueryBuilder.pageSize(query);
Map<Integer, Map<String, JsonNode>> versionFields = new HashMap<>();
try {
while (true) {
List<Long> ids = queryBuilder.pageIds(query);
if (ids.isEmpty()) {
break;
}
Map<Long, Answer> loaded = new HashMap<>();
for (Answer answer : answerRepository.findLiveByIds(domainSchId, ids)) {
loaded.put(answer.getId(), answer);
}
List<List<Object>> batch = new ArrayList<>(ids.size());
Long lastId = null;
for (Long id : ids) {
Answer answer = loaded.get(id);
if (answer == null) {
continue;
}
batch.add(rowOf(answer, columnKeys, fieldsForVersion(form, answer, versionFields)));
lastId = id;
}
if (!batch.isEmpty()) {
writer.write(batch, sheet);
written += batch.size();
}
if (lastId == null || ids.size() < pageSize) {
// 末批(不足一页)写完即止;否则游标前移继续下一批(keyset,无 offset)
break;
}
query.setCursorId(lastId);
}
if (written == 0) {
// 空结果也要出一份带表头的合法 xlsx:不写这一次的话 easyexcel 连 sheet 都不建,
// 消费方拿到的是"Can not find any sheet"的坏文件(受限身份范围为空是正常情形)
writer.write(new ArrayList<>(), sheet);
}
} finally {
writer.finish();
}
log.info("[导出] form={}/{} 范围={} 写出 {} 行", form.getAppCode(), form.getCode(),
query.getScopes(), written);
return written;
}
// ==================== 列与单元格 ====================
/** 表头:固定六列(身份/时间)+ 当前发布版本字段序(数组序即展示序,契约 §2) */
private List<List<String>> head(List<String> columnKeys, Map<String, JsonNode> currentFields) {
List<List<String>> head = new ArrayList<>();
head.add(single("答卷ID"));
head.add(single("报名号"));
head.add(single("状态"));
head.add(single("提交人ID"));
head.add(single("被代填人ID"));
head.add(single("提交时间"));
for (String key : columnKeys) {
JsonNode field = currentFields.get(key);
String label = field == null ? key : field.path("label").asText(key);
head.add(single(label + "(" + key + ")"));
}
return head;
}
private List<Object> rowOf(Answer answer, List<String> columnKeys, Map<String, JsonNode> pinnedFields) {
List<Object> row = new ArrayList<>();
row.add(answer.getUuid());
row.add(answer.getAnswerNo());
row.add(answer.getStatus());
row.add(answer.getSubmitterId());
row.add(answer.getSubjectId());
row.add(AnswerReadService.format(answer.getCreateTime()));
JsonNode values = FormSchemaValidator.parse(answer.getValuesJson(), "答卷值文档损坏");
for (String key : columnKeys) {
JsonNode field = pinnedFields.get(key);
row.add(renderCell(values.get(key), field));
}
return row;
}
/**
* 单元格渲染:选项类回查 label(按该钉住版本 options 定义序,多选按定义序而非勾选序,契约 §3);
* 附件类取 fileName(值里没有 URL,V24);其它原样字符串化。
* 已删字段(该版本 schema 里没有)不产出列,值文档仍可在详情接口取到。
*/
private String renderCell(JsonNode value, JsonNode field) {
if (value == null || value.isNull() || field == null) {
return "";
}
String type = field.path("type").asText();
if ("upload".equals(type) || "image".equals(type)) {
List<String> names = new ArrayList<>();
for (JsonNode item : value) {
String fileName = item.path("fileName").asText(null);
if (fileName != null) {
names.add(fileName);
}
}
return join(names);
}
if ("checkbox".equals(type) || "multiselect".equals(type)) {
List<String> labels = optionLabelsInDefinitionOrder(field, value);
return join(labels.isEmpty() ? textList(value) : labels);
}
if ("select".equals(type) || "radio".equals(type)) {
String label = optionLabel(field, value.asText());
return label == null ? value.asText() : label;
}
return value.asText();
}
/** 多选展示顺序按 options 定义序(契约 §3:不按勾选序) */
private List<String> optionLabelsInDefinitionOrder(JsonNode field, JsonNode value) {
List<String> labels = new ArrayList<>();
List<String> picked = new ArrayList<>();
for (JsonNode item : value) {
picked.add(item.asText());
}
JsonNode options = field.get("options");
if (options == null || !options.isArray()) {
return labels;
}
for (JsonNode option : options) {
if (picked.contains(option.path("value").asText())) {
labels.add(option.path("label").asText(option.path("value").asText()));
}
}
return labels;
}
private String optionLabel(JsonNode field, String value) {
JsonNode options = field.get("options");
if (options == null || !options.isArray()) {
return null;
}
for (JsonNode option : options) {
if (value.equals(option.path("value").asText())) {
return option.path("label").asText(value);
}
}
return null;
}
private static List<String> textList(JsonNode value) {
List<String> texts = new ArrayList<>();
for (JsonNode item : value) {
texts.add(item.asText());
}
return texts;
}
private static String join(List<String> parts) {
return String.join(SEPARATOR, parts);
}
private static List<String> single(String title) {
List<String> column = new ArrayList<>(1);
column.add(title);
return column;
}
// ==================== 版本 schema 缓存(一次导出内按版本号复用) ====================
/** 该答卷钉住版本的字段表(options 已内联展开,才能把 value 回查成 label) */
private Map<String, JsonNode> fieldsForVersion(Form form, Answer answer,
Map<Integer, Map<String, JsonNode>> memo) {
Map<String, JsonNode> cached = memo.get(answer.getVersion());
if (cached != null) {
return cached;
}
Map<String, JsonNode> fields = fieldsByVersion(form, answer.getVersion());
memo.put(answer.getVersion(), fields);
return fields;
}
/**
* 指定版本的字段表;版本行缺失(历史脏数据)时返回空表——该行仍会被导出(身份列完整),
* 值列留空而不是让整次导出失败。
*/
private Map<String, JsonNode> fieldsByVersion(Form form, Integer version) {
if (version == null) {
return new LinkedHashMap<>();
}
try {
String schemaJson = defService.pinnedForData(form.getUuid(), version).versionRow().getSchemaJson();
return FormSchemaValidator.fieldsByKeys(dictService.expandSchema(form.getSchId(), schemaJson));
} catch (RuntimeException e) {
log.warn("[导出] 取版本 schema 失败 form={} v{}:{}", form.getUuid(), version, e.getMessage());
return new LinkedHashMap<>();
}
}
}
@@ -5,24 +5,30 @@ import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.AnswerIndex;
import com.yunhai.formcenter.repository.AnswerIndexRepository;
import com.yunhai.formcenter.repository.AnswerRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* {@code answer_index} 写入器(方案 §5.2 / 契约 §2 searchable)。
*
* <p>唯一一条判断口径:<b>只有该答卷钉住版本里显式声明 {@code searchable} 的字段才写行</b>,
* 且列按 type 归位——等值(select/radio/checkbox/multiselect→v_str)、
* 区间(date→v_date、number→v_num)、前缀模糊(text/textarea/phone→v_str,超 191 截断)。
* {@code upload/image} 没有定义检索算子(契约 §2),即便被勾了 searchable 也不写行(只 warn)。</p>
* <p>唯一一条判断口径:<b>只有显式声明 {@code searchable} 的字段才写行</b>,且列按 type 归位——
* 等值(select/radio/checkbox/multiselect→v_str)、区间(date→v_date、number→v_num)、
* 前缀模糊(text/textarea/phone→v_str,超 191 截断)。{@code upload/image} 没有定义检索算子
* (契约 §2),即便被勾了 searchable 也不写行(只 warn,P28)。</p>
*
* <p>三个调用点:提交写入、编辑重建({@link #rebuild})、软删同步删行({@link #clear})。
* <p>四个调用点:提交写入、编辑重建({@link #rebuild})、软删同步删行({@link #clear})、
* 发布升版后补建({@link #backfillAfterPublish})。
* <b>不存在</b>"按新版本 schema 清理旧字段行"的入口——那会打掉 V8 的"删字段后历史仍可筛"。</p>
*/
@Slf4j
@@ -30,13 +36,94 @@ import java.util.Map;
@RequiredArgsConstructor
public class AnswerIndexService {
/** 升版补建的批大小(发布是管理员低频动作,一批最多读这么多份值文档进内存) */
static final int BACKFILL_BATCH_SIZE = 500;
private final AnswerIndexRepository indexRepository;
private final AnswerRepository answerRepository;
/** 提交:按钉住版本写行 */
public void write(Answer answer, String schemaJson) {
indexRepository.saveAll(rowsFor(answer, schemaJson, null));
}
/** 编辑:全量覆盖式重建(先 bulk 删旧行,再按同一份钉住 schema 重写,方案 §5.4"索引同步重建") */
public void rebuild(Answer answer, String schemaJson) {
clear(answer.getId());
write(answer, schemaJson);
}
/** 软删答卷同步删行(方案 §5.2;idx_ai_answer 就是为这条删路径建的) */
public void clear(Long answerId) {
int removed = indexRepository.deleteByAnswerId(answerId);
log.debug("[索引] answer_id={} 清理索引行 {} 条", answerId, removed);
}
/**
* <b>升版发布后给历史答卷补索引行</b>——V9("新增 searchable 字段全程无 DDL,旧数据按新字段筛选正确")
* 的实现形态。
*
* <p>为什么必须补:提交时只按<em>当时钉住版本</em>的 searchable 声明写行,v2 才新声明的字段在 v1
* 答卷上没有行,"旧数据按新字段筛"就落不了地。补建是<b>只增不删</b>的,与 V8"新版本删字段不清历史行"
* 同向,两条索引生命周期规则不冲突(方案 §5.2)。</p>
*
* <p>代价记账(登记执行手册 §5 问题单):发布时按 id 游标分批扫本表单未删答卷,
* 每批 {@value #BACKFILL_BATCH_SIZE} 份值文档,已有该 field_key 的行不重复插(幂等、可重入)。
* 发布是低频管理动作,2 万份量级为秒级;一期不做异步补建半成品。</p>
*
* @param formUuid 本次发布的表单(头表 uuid,P25 后答卷按它溯源)
* @param schemaJson 新版本 schema 原文
* @return 补写的行数
*/
public int backfillAfterPublish(String formUuid, String schemaJson) {
List<String> keys = FormSchemaValidator.searchableKeys(schemaJson);
if (keys.isEmpty()) {
return 0;
}
int inserted = 0;
Long cursor = 0L;
while (true) {
List<Answer> batch = answerRepository.findLiveByFormUuidAfterId(
formUuid, cursor, PageRequest.of(0, BACKFILL_BATCH_SIZE));
if (batch.isEmpty()) {
break;
}
for (Answer answer : batch) {
Set<String> held = new HashSet<>(indexRepository.findFieldKeysByAnswerId(answer.getId()));
List<AnswerIndex> rows = rowsFor(answer, schemaJson, held);
if (!rows.isEmpty()) {
indexRepository.saveAll(rows);
inserted += rows.size();
}
}
cursor = batch.get(batch.size() - 1).getId();
if (batch.size() < BACKFILL_BATCH_SIZE) {
break;
}
}
if (inserted > 0) {
log.info("[索引补建] form_uuid={} 升版后补写 answer_index 行 {} 条(V9)", formUuid, inserted);
}
return inserted;
}
public List<AnswerIndex> rowsOf(Long answerId) {
return indexRepository.findByAnswerIdOrderByIdAsc(answerId);
}
/**
* 按 schema 的 searchable 声明产出该答卷的索引行。
*
* @param skipFieldKeys 已有行的字段集合(补建去重用;null=全量产出,提交/重建用)
*/
private List<AnswerIndex> rowsFor(Answer answer, String schemaJson, Set<String> skipFieldKeys) {
List<AnswerIndex> rows = new ArrayList<>();
JsonNode values = FormSchemaValidator.parse(answer.getValuesJson(), "答卷值文档损坏");
Map<String, JsonNode> fields = FormSchemaValidator.fieldsByKeys(schemaJson);
for (String fieldKey : FormSchemaValidator.searchableKeys(schemaJson)) {
if (skipFieldKeys != null && skipFieldKeys.contains(fieldKey)) {
continue;
}
JsonNode field = fields.get(fieldKey);
JsonNode value = values.get(fieldKey);
if (field == null || value == null || value.isNull()) {
@@ -49,41 +136,26 @@ public class AnswerIndexService {
}
for (JsonNode item : value) {
if (item.isTextual()) {
indexRepository.save(row(answer, fieldKey, item.asText(), null, null));
rows.add(row(answer, fieldKey, item.asText(), null, null));
}
}
} else if ("number".equals(type)) {
if (value.isNumber()) {
indexRepository.save(row(answer, fieldKey, null, new BigDecimal(value.asText()), null));
rows.add(row(answer, fieldKey, null, new BigDecimal(value.asText()), null));
}
} else if ("date".equals(type)) {
if (value.isTextual()) {
indexRepository.save(row(answer, fieldKey, null, null, LocalDate.parse(value.asText())));
rows.add(row(answer, fieldKey, null, null, LocalDate.parse(value.asText())));
}
} else if ("upload".equals(type) || "image".equals(type)) {
log.warn("[索引] 字段 {} 是 {} 类型,契约未定义检索算子,不写 answer_index 行", fieldKey, type);
} else {
if (value.isTextual()) {
indexRepository.save(row(answer, fieldKey, value.asText(), null, null));
rows.add(row(answer, fieldKey, value.asText(), null, null));
}
}
}
}
/** 编辑:全量覆盖式重建(先 bulk 删旧行,再按同一份钉住 schema 重写,方案 §5.4"索引同步重建") */
public void rebuild(Answer answer, String schemaJson) {
clear(answer.getId());
write(answer, schemaJson);
}
/** 软删答卷同步删行(方案 §5.2;idx_ai_answer 就是为这条路径建的) */
public void clear(Long answerId) {
int removed = indexRepository.deleteByAnswerId(answerId);
log.debug("[索引] answer_id={} 清理索引行 {} 条", answerId, removed);
}
public List<AnswerIndex> rowsOf(Long answerId) {
return indexRepository.findByAnswerIdOrderByIdAsc(answerId);
return rows;
}
private AnswerIndex row(Answer answer, String fieldKey, String vStr, BigDecimal vNum, LocalDate vDate) {
@@ -0,0 +1,258 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.common.FormFilterOperators;
import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.common.FormStatusDefs;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.formcenter.repository.AnswerRepository;
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.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 数据面读服务(列表 / 详情 / 筛选候选,契约 §8/§9,方案 §5.5)。
*
* <p><b>与 {@link AnswerScopeQueryBuilder} 的分工</b>:本类负责"把请求翻译成查询规格"
* (解析表单、按会话身份解析范围、把裸筛选条件按字段类型收敛成算子),构造器负责"把规格翻译成 SQL 谓词"。
* 统计({@link AnswerStatsService})与导出({@link AnswerExportService})都调本类的
* {@link #scopeQuery} 拿同一份规格——这就是"四条读出路径共用同一个 scope 构造器"的调用形态,
* 新增第五个读接口时也只能从这里取规格。</p>
*
* <p>三处纪律:列表走<b>两段式</b>(先取当页 id、再按 id 批量取值文档,铁律①);
* 翻页走 <b>keyset</b> 且游标对外是 uuid(铁律② + 契约 §1 id 不外露);
* 软删答卷在四条路径一律不可见(V30,谓词在构造器里)。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AnswerReadService {
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final FormDefService defService;
private final FormDictService dictService;
private final DataScopeService scopeService;
private final AnswerScopeQueryBuilder queryBuilder;
private final AnswerRepository answerRepository;
// ==================== 规格构造(四路径共用) ====================
/**
* 会话身份 + 表单 + 请求 → 读路径查询规格。
*
* <p>范围在这里定:{@link DataScopeService} 按契约 §8 矩阵 + customId 解析,解析不出来
* (未登录等)即拒,不存在"默认全部"的分支。筛选条件按<b>当前发布版本</b>的
* {@code filterableFields} 收敛(V3 未声明 searchable 不进候选、P28 附件无算子),
* 传一个不在候选里的 key 直接拒——不能默默忽略,那会把"筛选没生效"伪装成"没有匹配数据"。</p>
*/
public ReadModels.Query scopeQuery(BoaUser user, Long domainSchId, Form form, ReadModels.QueryRequest req) {
ReadModels.Query query = new ReadModels.Query();
query.setSchId(domainSchId);
query.setAppCode(form.getAppCode());
query.setCode(form.getCode());
query.setScopes(scopeService.resolve(user, form));
query.setUserId(user == null ? null : user.getId());
query.setStatus(req == null ? null : trimToNull(req.getStatus()));
query.setFilters(resolveFilters(form, req == null ? null : req.getFilters()));
return query;
}
/** 对外游标 uuid → 库内 id(游标本身也要过范围,见构造器) */
public void applyCursor(ReadModels.Query query, String cursorUuid, String order, Integer size) {
query.setAscending("asc".equalsIgnoreCase(trimToNull(order)));
if (size != null && size > 0) {
query.setSize(Math.min(size, AnswerScopeQueryBuilder.MAX_PAGE_SIZE));
}
query.setCursorId(queryBuilder.resolveCursorId(query, cursorUuid));
}
// ==================== 列表 ====================
/**
* 两段式列表(铁律①):第一段窄索引取当页 id,第二段按 id 批量取值文档。
* 返回顺序=游标顺序(desc 最新在前 / asc 最旧在前),排序列恒为提交时间(铁律②)。
*/
public ReadModels.ListView list(BoaUser user, Long domainSchId, ReadModels.ListRequest req) {
Form form = defService.readDomainForm(domainSchId, req.getFormUuid());
ReadModels.Query query = scopeQuery(user, domainSchId, form, req);
applyCursor(query, req.getCursor(), req.getOrder(), req.getSize());
List<Long> ids = queryBuilder.pageIds(query);
ReadModels.ListView view = new ReadModels.ListView();
view.setScopes(query.getScopes());
view.setSize(AnswerScopeQueryBuilder.pageSize(query));
if (ids.isEmpty()) {
return view;
}
Map<Long, Answer> loaded = new HashMap<>();
for (Answer answer : answerRepository.findLiveByIds(domainSchId, ids)) {
loaded.put(answer.getId(), answer);
}
Answer last = null;
for (Long id : ids) {
Answer answer = loaded.get(id);
if (answer == null) {
continue;
}
view.getRows().add(toRow(answer, form));
last = answer;
}
view.setNextCursor(last == null ? null : last.getUuid());
return view;
}
// ==================== 详情 ====================
/**
* 详情(uuid 寻址):返回该答卷<b>钉住版本</b>的 schema 引用(内联展开后的 groups)+ 值文档。
* 行级可见性用同一个构造器判(不是"查出来再在 Java 里比")——判定与列表口径必然一致,
* 也就必然出现"列表能看到的详情一定能打开、列表看不到的详情打开不了"。
*/
public ReadModels.DetailView detail(BoaUser user, Long domainSchId, String answerUuid) {
Answer answer = requireLiveAnswer(domainSchId, answerUuid);
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
Form form = pinned.form();
ReadModels.Query query = scopeQuery(user, domainSchId, form, new ReadModels.QueryRequest());
if (!queryBuilder.visible(query, answer.getUuid())) {
throw new BizException(ErrorCode.FORBIDDEN, "该答卷不在你的数据范围内(契约 §8 矩阵):" + answerUuid);
}
ReadModels.DetailView view = new ReadModels.DetailView();
copyInto(answer, form, view);
view.setAppCode(form.getAppCode());
view.setCode(form.getCode());
view.setFormName(form.getName());
view.setGroups(groupsNode(dictService.expandSchema(form.getSchId(), pinned.versionRow().getSchemaJson())));
return view;
}
// ==================== 筛选候选 ====================
/**
* 筛选候选列(V3 + P28):当前发布版本里 searchable 且<b>有算子</b>的字段,
* 附按 type 收敛出的算子(前端据此出控件:等值下拉 / 区间双输入 / 前缀文本框)。
* 未发布或无当前版本 → 空列表(草稿字段不参与筛选)。
*/
public List<ReadModels.FilterFieldView> filterFields(Long domainSchId, String formUuid) {
Form form = defService.readDomainForm(domainSchId, formUuid);
List<ReadModels.FilterFieldView> result = new ArrayList<>();
if (form.getCurrentVersion() == null) {
return result;
}
String schemaJson = defService.pinnedForData(form.getUuid(), form.getCurrentVersion())
.versionRow().getSchemaJson();
Map<String, JsonNode> filterable = FormSchemaValidator.filterableFields(schemaJson);
Map<String, JsonNode> fields = FormSchemaValidator.fieldsByKeys(schemaJson);
for (String key : filterable.keySet()) {
JsonNode field = fields.get(key);
ReadModels.FilterFieldView item = new ReadModels.FilterFieldView();
item.setKey(key);
item.setLabel(field.path("label").asText(key));
item.setType(field.path("type").asText());
item.setOperator(FormFilterOperators.operatorOf(field.path("type").asText()));
result.add(item);
}
return result;
}
// ==================== 行视图(导出与统计复用) ====================
public ReadModels.RowView toRow(Answer answer, Form form) {
ReadModels.RowView row = new ReadModels.RowView();
copyInto(answer, form, row);
return row;
}
private void copyInto(Answer answer, Form form, ReadModels.RowView row) {
row.setAnswerId(answer.getUuid());
row.setFormUuid(answer.getFormUuid());
row.setVersion(answer.getVersion());
row.setStatus(answer.getStatus());
row.setStatusLabel(FormStatusDefs.labelOf(form.getStatusDefs(), answer.getStatus()));
row.setSubmitterId(answer.getSubmitterId());
row.setSubjectId(answer.getSubjectId());
row.setAnswerNo(answer.getAnswerNo());
row.setSubmitTime(format(answer.getCreateTime()));
row.setValues(FormSchemaValidator.parse(answer.getValuesJson(), "答卷值文档损坏"));
}
/** 值文档里的附件/选项原样返回;label 回查(导出用)在 {@link AnswerExportService} */
public static String format(LocalDateTime time) {
return time == null ? null : TS.format(time);
}
/** 钉住版本的 groups(展开后的数组节点),前端按它渲染历史答卷(契约 §3 三态空值由前端判) */
public static JsonNode groupsNode(String expandedSchemaJson) {
return FormSchemaValidator.parse(expandedSchemaJson, "表单结构损坏(请联系管理员)").path("groups");
}
// ==================== 内部 ====================
/** 请求里的筛选条件 → 算子规格(按当前发布版本收敛);不在候选集里的 key 直接拒 */
private List<FormFilterOperators.Spec> resolveFilters(Form form, List<ReadModels.FilterItem> items) {
List<FormFilterOperators.Spec> specs = new ArrayList<>();
if (items == null || items.isEmpty()) {
return specs;
}
if (form.getCurrentVersion() == null) {
throw new BizException(ErrorCode.CUSTOM, "该表单尚未发布,没有可筛选字段:" + form.getCode());
}
String schemaJson = defService.pinnedForData(form.getUuid(), form.getCurrentVersion())
.versionRow().getSchemaJson();
Map<String, JsonNode> filterable = FormSchemaValidator.filterableFields(schemaJson);
for (ReadModels.FilterItem item : items) {
if (item == null || item.getKey() == null || item.getKey().trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "筛选条件缺少字段 key");
}
String key = item.getKey().trim();
JsonNode field = filterable.get(key);
if (field == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"字段不可作为筛选条件(未声明 searchable,或该类型无检索算子):" + key);
}
specs.add(FormFilterOperators.resolve(field, item));
}
return specs;
}
private Answer requireLiveAnswer(Long domainSchId, String answerUuid) {
if (answerUuid == null || answerUuid.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "answerId 不能为空");
}
Answer answer = answerRepository.findByUuidAndSchId(answerUuid.trim(), domainSchId).orElse(null);
if (answer == null || Integer.valueOf(1).equals(answer.getIsDeleted())) {
throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在或已删除:" + answerUuid);
}
return answer;
}
private static String trimToNull(String raw) {
if (raw == null) {
return null;
}
String value = raw.trim();
return value.isEmpty() ? null : value;
}
/** 分组桶的展示名映射(按状态分组用状态定义;选项类字段用 label 回查) */
public Map<String, String> statusLabels(Form form) {
Map<String, String> labels = new LinkedHashMap<>();
for (FormStatusDefs.StatusDef def : FormStatusDefs.effective(form.getStatusDefs())) {
labels.put(def.getCode(), def.getLabel());
}
return labels;
}
}
@@ -0,0 +1,38 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import com.yunhai.formcenter.entity.Answer;
/**
* 数据范围<b>实现器</b>(方案 §6.4"枚举 + 实现器(策略接口)"):一个枚举值一个实现器,
* 后续加枚举=加实现器;实现器内部要去调 SDK(如 UserClass 的任教关系)也只在自己这一格里长,
* 接口隔离保证读路径不跟着改。
*
* <p><b>唯一出口约束</b>:实现器只产出"这条答卷在不在该范围内"的 SQL 谓词,
* 由 {@link AnswerScopeQueryBuilder} 在四条读出路径(列表/详情/统计/导出)与管理动作行级把关上
* 复用同一次构造(方案 §6.4 红线,V27/V37/V42)。实现器里不允许出现"顺手把 values 取出来判"这类分支。</p>
*/
public interface AnswerScopeImplementor {
/** 本实现器负责的范围值 */
DataScope scope();
/**
* 一期是否可选。契约 §8 的四值闭集里 {@code 任教班级} 属 1.5 开放项(依赖 UserClass 任教关系/班级 SDK)
* ——枚举里有、<b>一期不可选</b>(V28),故其实现器返回 false 且不参与解析结果。
*/
boolean selectable();
/**
* 产出该范围对单条答卷的行级谓词。
*
* @param userId 会话用户 id({@code submitter_id}/{@code subject_id} 的比对基准)
* @return 恒真({@code cb.conjunction()})=该范围不额外收窄行
*/
Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId);
}
@@ -0,0 +1,294 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.common.FormFilterOperators;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.AnswerIndex;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import com.yunhai.formcenter.model.ReadModels;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.criteria.AbstractQuery;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Expression;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import javax.persistence.criteria.Subquery;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* <b>数据范围查询构造器</b>(方案 §6.4 红线 / 契约 §8 红线的唯一实现处):
* 列表、详情、统计、导出四条读出路径,加上管理动作(编辑/软删/改状态/批量)的行级把关,
* <em>全部</em>调本类同一份谓词构造。绕开这里另写一条查询=越权后门(教师列表只见本人、
* 统计一调返回全校分布,契约 §8 的枚举就白定了),故本类不接受"跳过范围"的开关参数。
*
* <p>一次查询的条件由这几段 AND 起来:租户域 {@code sch_id} → 表单坐标 {@code (app_code, code)}
* → {@code is_deleted=0} → 范围(实现器 OR)→ 状态 → searchable 筛选(answer_index 子查询)
* → keyset 游标。</p>
*
* <p>四条铁律在本类的落点(方案 §5.5):</p>
* <ol>
* <li>铁律①:{@link #pageIds} 只 {@code select a.id},<b>不碰 values_json</b>;
* 当页整文档由调用方按 id 批量取({@code AnswerRepository#findLiveByIds});</li>
* <li>铁律②:游标条件恒落在 {@code id}(提交时间序,与索引同序),没有"按任意列排序"的入口,
* 也没有 offset;</li>
* <li>铁律③:筛选落 {@code answer_index} 的 {@code (sch_id, code, field_key, v_*, answer_id)}
* 覆盖索引子查询;列表/计数落 {@code idx_answer_form_time};按状态分组落 {@code idx_answer_form};</li>
* <li>铁律④:导出的分批迭代就是 {@link #pageIds} 的游标逐批前移,不存在一次取全量。</li>
* </ol>
*/
@Slf4j
@Repository
@RequiredArgsConstructor
public class AnswerScopeQueryBuilder {
/** 单页上限(超出按上限截,防"一次拉十万行"把两段式变成全量载入) */
public static final int MAX_PAGE_SIZE = 100;
public static final int DEFAULT_PAGE_SIZE = 20;
@PersistenceContext
private EntityManager em;
private final DataScopeService scopeService;
/** 第一段:本页答卷的库内 id(窄索引定位,不取 values_json) */
public List<Long> pageIds(ReadModels.Query q) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Long> cq = cb.createQuery(Long.class);
Root<Answer> root = cq.from(Answer.class);
cq.select(root.<Long>get("id"));
cq.where(cb.and(predicates(cq, cb, root, q).toArray(new Predicate[0])));
cq.orderBy(q.isAscending() ? cb.asc(root.get("id")) : cb.desc(root.get("id")));
return em.createQuery(cq).setMaxResults(pageSize(q)).getResultList();
}
/** 计数(列表总数、导出行数、管理动作的"这条可不可见"都走它) */
public long count(ReadModels.Query q) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Long> cq = cb.createQuery(Long.class);
Root<Answer> root = cq.from(Answer.class);
cq.select(cb.count(root));
cq.where(cb.and(predicates(cq, cb, root, q).toArray(new Predicate[0])));
Long result = em.createQuery(cq).getSingleResult();
return result == null ? 0L : result;
}
/** 该范围内的可见性(详情与管理动作的行级把关:把 uuid 加进同一个构造器再数一次) */
public boolean visible(ReadModels.Query q, String answerUuid) {
ReadModels.Query probe = copyOf(q);
probe.setAnswerUuid(answerUuid);
return count(probe) > 0;
}
/** 按状态分组(走 {@code idx_answer_form(sch_id, code, status, id)} 的覆盖分组,不回表) */
public Map<String, Long> groupByStatus(ReadModels.Query q) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Object[]> cq = cb.createQuery(Object[].class);
Root<Answer> root = cq.from(Answer.class);
Expression<String> status = root.get("status");
cq.multiselect(status, cb.count(root));
cq.where(cb.and(predicates(cq, cb, root, q).toArray(new Predicate[0])));
cq.groupBy(status);
Map<String, Long> result = new LinkedHashMap<>();
for (Object[] row : em.createQuery(cq).getResultList()) {
result.put((String) row[0], ((Number) row[1]).longValue());
}
return result;
}
/**
* 按 searchable 字段分组(V11/V2 的落点):分组事实全在 {@code answer_index},
* "哪些答卷可见"用<b>同一个范围构造器</b>作子查询套上去——这就是"统计不是越权后门"的实现依据。
*
* @param fieldKey 分组字段
* @param column 该字段落地的索引列(由字段类型收敛而来)
* @return 值 → 答卷数(多值字段按 {@code count(distinct answer_id)},一答卷不因多值重复计数)
*/
public Map<Object, Long> groupByField(ReadModels.Query q, String fieldKey, FormFilterOperators.Column column) {
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Object[]> cq = cb.createQuery(Object[].class);
Root<AnswerIndex> ai = cq.from(AnswerIndex.class);
Expression<? extends Comparable> value = comparablePath(ai, column);
Subquery<Long> visibleAnswers = cq.subquery(Long.class);
Root<Answer> answerRoot = visibleAnswers.from(Answer.class);
visibleAnswers.select(answerRoot.<Long>get("id"));
visibleAnswers.where(cb.and(predicates(visibleAnswers, cb, answerRoot, q).toArray(new Predicate[0])));
List<Predicate> indexPredicates = new ArrayList<>();
indexPredicates.add(cb.equal(ai.get("schId"), q.getSchId()));
indexPredicates.add(cb.equal(ai.get("code"), q.getCode()));
indexPredicates.add(cb.equal(ai.<String>get("fieldKey"), fieldKey));
indexPredicates.add(ai.<Long>get("answerId").in(visibleAnswers));
cq.multiselect(value, cb.countDistinct(ai.get("answerId")));
cq.where(cb.and(indexPredicates.toArray(new Predicate[0])));
cq.groupBy(value);
Map<Object, Long> result = new LinkedHashMap<>();
for (Object[] row : em.createQuery(cq).getResultList()) {
if (row[0] == null) {
continue;
}
result.put(row[0], ((Number) row[1]).longValue());
}
return result;
}
/**
* 对外游标(uuid)→ 库内 id。游标本身也要过范围:拿一条不可见答卷的 uuid 当起点就等于
* 用游标探测他人数据,所以先在同一个构造器里数一次,不可见直接拒。
*/
public Long resolveCursorId(ReadModels.Query q, String cursorUuid) {
if (cursorUuid == null || cursorUuid.trim().isEmpty()) {
return null;
}
ReadModels.Query probe = copyOf(q);
probe.setAnswerUuid(cursorUuid.trim());
if (count(probe) <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "游标不可用(不在可见范围内或已删除):" + cursorUuid);
}
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Long> cq = cb.createQuery(Long.class);
Root<Answer> root = cq.from(Answer.class);
cq.select(root.<Long>get("id"));
cq.where(cb.and(predicates(cq, cb, root, probe).toArray(new Predicate[0])));
List<Long> ids = em.createQuery(cq).setMaxResults(1).getResultList();
return ids.isEmpty() ? null : ids.get(0);
}
/** 请求页大小:缺省 20,上限 {@value #MAX_PAGE_SIZE}(方案 §5.5:当页 10~20,解析成本可忽略) */
public static int pageSize(ReadModels.Query q) {
return q.getSize() <= 0 ? DEFAULT_PAGE_SIZE : Math.min(q.getSize(), MAX_PAGE_SIZE);
}
/** 浅拷贝一份规格(改游标/uuid 时用,避免污染调用方对象) */
public static ReadModels.Query copyOf(ReadModels.Query q) {
ReadModels.Query copy = new ReadModels.Query();
copy.setSchId(q.getSchId());
copy.setAppCode(q.getAppCode());
copy.setCode(q.getCode());
copy.setScopes(q.getScopes());
copy.setUserId(q.getUserId());
copy.setStatus(q.getStatus());
copy.setFilters(q.getFilters());
copy.setCursorId(q.getCursorId());
copy.setAscending(q.isAscending());
copy.setSize(q.getSize());
copy.setAnswerUuid(q.getAnswerUuid());
return copy;
}
// ==================== 谓词构造(唯一出口) ====================
private List<Predicate> predicates(AbstractQuery<?> owner, CriteriaBuilder cb, Root<Answer> root,
ReadModels.Query q) {
if (q.getSchId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "读查询缺少租户域");
}
List<Predicate> ps = new ArrayList<>();
ps.add(cb.equal(root.get("schId"), q.getSchId()));
ps.add(cb.equal(root.get("appCode"), q.getAppCode()));
ps.add(cb.equal(root.get("code"), q.getCode()));
// 软删答卷在四条读出路径一律不可见(V30 的读侧半边)
ps.add(cb.equal(root.get("isDeleted"), 0));
ps.add(scopePredicate(cb, root, q));
if (q.getStatus() != null && !q.getStatus().trim().isEmpty()) {
ps.add(cb.equal(root.get("status"), q.getStatus().trim()));
}
if (q.getAnswerUuid() != null && !q.getAnswerUuid().trim().isEmpty()) {
ps.add(cb.equal(root.get("uuid"), q.getAnswerUuid().trim()));
}
if (q.getCursorId() != null) {
ps.add(q.isAscending() ? cb.greaterThan(root.get("id"), q.getCursorId())
: cb.lessThan(root.get("id"), q.getCursorId()));
}
for (FormFilterOperators.Spec filter : q.filtersOrEmpty()) {
ps.add(filterPredicate(owner, cb, root, q, filter));
}
return ps;
}
/** 范围段:多来源的并集(OR);命中"全部"即不加行级条件 */
private Predicate scopePredicate(CriteriaBuilder cb, Root<Answer> root, ReadModels.Query q) {
Set<DataScope> scopes = q.getScopes();
if (scopes == null || scopes.isEmpty()) {
// 空范围不放行——"没解析出范围"与"范围是全部"是两回事,缺省必须是仅本人提交
throw new BizException(ErrorCode.FORBIDDEN, "未解析出数据范围,拒绝按无范围读取");
}
if (scopes.contains(DataScope.ALL)) {
return cb.conjunction();
}
if (q.getUserId() == null) {
throw new BizException(ErrorCode.FORBIDDEN, "数据范围需要会话用户身份");
}
List<Predicate> ors = new ArrayList<>();
for (DataScope scope : scopes) {
ors.add(scopeService.implementorOf(scope).rowPredicate(cb, root, q.getUserId()));
}
return cb.or(ors.toArray(new Predicate[0]));
}
/** 筛选段:每个条件一个 answer_index 子查询(AND 叠加,多条件在 SQL 层完成——V10) */
private Predicate filterPredicate(AbstractQuery<?> owner, CriteriaBuilder cb, Root<Answer> root,
ReadModels.Query q, FormFilterOperators.Spec filter) {
Subquery<Long> sub = owner.subquery(Long.class);
Root<AnswerIndex> ai = sub.from(AnswerIndex.class);
List<Predicate> ps = new ArrayList<>();
ps.add(cb.equal(ai.get("schId"), q.getSchId()));
ps.add(cb.equal(ai.get("code"), q.getCode()));
ps.add(cb.equal(ai.<String>get("fieldKey"), filter.getKey()));
if (filter.getColumn() == FormFilterOperators.Column.STR) {
Expression<String> column = ai.get("vStr");
ps.add(filter.getKind() == FormFilterOperators.Kind.PREFIX
? cb.like(column, likePrefix(filter.getStrValue()), '\\')
: cb.equal(column, filter.getStrValue()));
} else if (filter.getColumn() == FormFilterOperators.Column.NUM) {
Expression<BigDecimal> column = ai.get("vNum");
ps.add(cb.between(column, filter.getNumFrom(), filter.getNumTo()));
} else {
Expression<LocalDate> column = ai.get("vDate");
ps.add(cb.between(column, filter.getDateFrom(), filter.getDateTo()));
}
sub.select(ai.<Long>get("answerId"));
sub.where(cb.and(ps.toArray(new Predicate[0])));
return root.<Long>get("id").in(sub);
}
@SuppressWarnings("unchecked")
private Expression<? extends Comparable> comparablePath(Root<AnswerIndex> ai, FormFilterOperators.Column column) {
if (column == FormFilterOperators.Column.NUM) {
return (Expression<? extends Comparable>) ai.<BigDecimal>get("vNum");
}
if (column == FormFilterOperators.Column.DATE) {
return (Expression<? extends Comparable>) ai.<LocalDate>get("vDate");
}
return (Expression<? extends Comparable>) ai.<String>get("vStr");
}
/**
* 前缀模糊的 LIKE 模式:先转义 {@code \ % _} 再加尾随 {@code %}。
* 不转义时用户输入 {@code "%"} 会把前缀筛选变成全索引扫描(不是越权,但让铁律③的索引选择性失效)。
*/
public static String likePrefix(String raw) {
StringBuilder sb = new StringBuilder(raw.length() + 4);
for (int i = 0; i < raw.length(); i++) {
char c = raw.charAt(i);
if (c == '\\' || c == '%' || c == '_') {
sb.append('\\');
}
sb.append(c);
}
return sb.append('%').toString();
}
}
@@ -25,15 +25,15 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
/**
* 提交链路(契约 §5 / 方案 §5.4)。一次提交在这条流水线上依次过关:
*
* <pre>
* 钉版解析 → 截止 → 幂等命中 → subject 授权 → 值校验(按钉住版本) → 附件归属
* → 限次占位(fill_quota 撞键即拒) → 落 answer → 写 answer_index(只写 searchable)
* 钉版解析 → 截止 → 幂等命中 → subject 授权+存在性同校(P33) → 值校验(按钉住版本)
* → 附件归属与 key 一致(P34) → 限次占位(fill_quota 撞键即拒) → 落 answer(写 form_uuid)
* → 写 answer_index(只写 searchable)
* </pre>
*
* <p><b>为什么顺序是这样</b>:</p>
@@ -62,6 +62,7 @@ public class AnswerService {
private final FormDefService defService;
private final FormDictService dictService;
private final FormAdminGuard adminGuard;
private final SubjectDirectory subjectDirectory;
private final AnswerRepository answerRepository;
private final FillQuotaRepository quotaRepository;
private final FormFileRepository fileRepository;
@@ -113,8 +114,8 @@ public class AnswerService {
return toView(existing, true);
}
// 4) subject 授权(方案 §6.3:普通用户只能=自己,有管理权者可代填他人)
Integer subjectId = requireSubject(user, req.getSubjectId(), submitterId);
// 4) subject 授权(方案 §6.3:普通用户只能=自己,有管理权者可代填他人)+ 存在性/同校(P33)
Integer subjectId = requireSubject(user, req.getSubjectId(), submitterId, schId);
// 5) 值校验(按钉住版本;字典取值域含软删条目)+ 附件归属
String valuesJson = valuesToJson(req.getValues());
@@ -127,6 +128,8 @@ public class AnswerService {
// 7) 落答卷
Answer answer = new Answer();
answer.setUuid(newUuid());
// P25:钉版溯源的精确坐标=resolvePublished 命中的那张表单(本校遮蔽平台时的胜出行)
answer.setFormUuid(form.getUuid());
answer.setSchId(schId);
answer.setAppCode(form.getAppCode());
answer.setCode(form.getCode());
@@ -152,7 +155,7 @@ public class AnswerService {
// ==================== 各环节 ====================
private Integer requireSubject(BoaUser user, Integer subjectId, Integer submitterId) {
private Integer requireSubject(BoaUser user, Integer subjectId, Integer submitterId, Long schId) {
if (subjectId == null || subjectId.equals(submitterId)) {
return subjectId;
}
@@ -160,6 +163,12 @@ public class AnswerService {
throw new BizException(ErrorCode.FORBIDDEN,
"无权为他人填报:subjectId 只能等于本人(当前 " + submitterId + ",请求 " + subjectId + ")");
}
// P33:代填=把数据挂到别人名下,"关于我的"视图(V18)与按 subject 限次都建立在"id 真实且同校"上。
// 本人自填不查(会话里的人本来就存在),只验代填那一个 id。
if (!subjectDirectory.existsInSchool(subjectId, schId)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"被代填人不存在或不属于本校:subjectId=" + subjectId + ",schId=" + schId);
}
return subjectId;
}
@@ -195,14 +204,31 @@ public class AnswerService {
return prefix + String.format("%04d", seq);
}
/**
* 附件归属与一致性(P24 同校归属 + <b>P34 key 一致性</b>)。
*
* <p>值里的 {@code {id,fileName,key}} 必须是<b>同校上传接口返回的那三件原样</b>:
* id 定位到本校的 form_file 行,key 必须等于该行的 store_key——否则"id 是中心侧可定位的附件行"
* 这个语义就只是半个(可以挂着别人的 key 渲染)。合法前端永远原样转存返回值,此校验不误伤正常流程。</p>
*/
private void assertAttachmentsOwned(Long schId, String valuesJson, String schemaJson) {
List<Long> ids = FormValueValidator.attachmentFileIds(valuesJson, schemaJson);
for (Long id : ids) {
for (JsonNode item : FormValueValidator.attachments(valuesJson, schemaJson)) {
long id = item.get("id").longValue();
FormFile file = fileRepository.findByIdAndSchId(id, schId).orElse(null);
if (file == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"附件不存在或不属于本校(请先走中心上传接口,值里只存返回的 {id,fileName,key}):id=" + id);
}
String key = item.path("key").asText(null);
if (key == null || !key.equals(file.getStoreKey())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"附件 key 与上传记录不一致(值里的 key 必须是上传接口返回的原值):id=" + id);
}
String fileName = item.path("fileName").asText(null);
if (fileName == null || !fileName.equals(file.getFileName())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"附件文件名与上传记录不一致:id=" + id);
}
}
}
@@ -0,0 +1,242 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.common.FormFilterOperators;
import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.model.ReadModels;
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.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 统计服务(V11 / 方案 §5.5 铁律③④):总数、按状态、按 searchable 字段分组,
* <b>聚合全部在接口侧(SQL)完成,前端不做聚合</b>。
*
* <p><b>范围口径与列表同源</b>:统计用的查询规格来自 {@link AnswerReadService#scopeQuery}、
* 谓词来自 {@link AnswerScopeQueryBuilder}——按字段分组时"哪些答卷可见"是同一个构造器做的子查询。
* 这条同源性就是 V27"受限身份的统计 ⊆ 其列表可见集"的实现依据;单独写一条 group by 就是越权后门。</p>
*
* <p><b>缓存(铁律④的失效策略)</b>:键={@code form_uuid + 当前发布 version + 范围签名 + 过滤条件},
* <b>只在升版时换键失效</b>,加短 TTL;<b>不按数据变更失效</b>——提交是高频事件,每次提交清缓存
* 等于没有缓存(方案 §5.5 v1.2 修正)。代价=统计容忍 TTL 内的延迟,验收断言据此写
* (新答卷落库后 TTL 内统计总数不变是<em>预期行为</em>,不是 bug)。</p>
*
* <p>缓存范围也收紧到有边界:只缓存<b>无筛选条件</b>的全量统计(方案原话"大表单全量分组统计加缓存"),
* 带筛选的组合键不缓存;条目数超上限整体清空,避免无界增长。键里含范围签名——
* 否则"管理员算出的全校分布"会被教师命中,那就是缓存版本的越权后门。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AnswerStatsService {
/** 统计结果短 TTL(方案 §5.5"统计容忍 N 分钟延迟",一期取 60s:够吸收一次页面重查,又不至于陈旧) */
static final long CACHE_TTL_MILLIS = 60_000L;
/** 缓存条目上限(超出整体清空——一期不引 Caffeine,见 T4 报告问题单) */
static final int MAX_CACHE_ENTRIES = 256;
private final AnswerReadService readService;
private final AnswerScopeQueryBuilder queryBuilder;
private final FormDefService defService;
private final FormDictService dictService;
private final Map<String, Cached> cache = new ConcurrentHashMap<>();
/**
* 统计。
*
* @param user 会话用户(范围由它解析)
* @param domainSchId 租户域(管理端守卫返回的域=答卷的 sch_id)
*/
public ReadModels.StatsView stats(BoaUser user, Long domainSchId, ReadModels.QueryRequest req) {
Form form = defService.readDomainForm(domainSchId, req.getFormUuid());
ReadModels.Query query = readService.scopeQuery(user, domainSchId, form, req);
boolean cacheable = (req.getFilters() == null || req.getFilters().isEmpty())
&& (req.getStatus() == null || req.getStatus().trim().isEmpty());
String key = cacheKey(domainSchId, form, user, query, req);
if (cacheable) {
Cached hit = cache.get(key);
if (hit != null && !hit.expired()) {
return hit.copyAsCached();
}
}
ReadModels.StatsView view = new ReadModels.StatsView();
view.setTotal(queryBuilder.count(query));
view.setByStatus(statusBuckets(form, queryBuilder.groupByStatus(query)));
String fieldKey = trimToNull(req.getGroupByField());
if (fieldKey != null) {
view.getByField().add(fieldGroups(form, query, fieldKey));
}
view.setCached(cacheable ? Boolean.FALSE : null);
if (cacheable) {
put(key, view);
}
return view;
}
// ==================== 分组 ====================
/** 按状态分组:桶=状态 code,label 取自状态定义(含已废弃,V13 由前端标"已废弃";定义里没有=null) */
private List<ReadModels.CountItem> statusBuckets(Form form, Map<String, Long> counts) {
Map<String, String> labels = readService.statusLabels(form);
List<ReadModels.CountItem> items = new ArrayList<>();
for (Map.Entry<String, Long> entry : counts.entrySet()) {
items.add(new ReadModels.CountItem(entry.getKey(), labels.get(entry.getKey()), entry.getValue()));
}
sortDescByCount(items);
return items;
}
/**
* 按 searchable 字段分组(走 answer_index,V11/V38)。
* 选项类字段的桶 label 回查 options 定义(改 label 不改 value,历史统计值不变——V2 的依据);
* 数值/日期/文本类的桶就是原始值(一期不做分桶区间,那是发明语义)。
*/
private ReadModels.FieldGroups fieldGroups(Form form, ReadModels.Query query, String fieldKey) {
String schemaJson = currentSchema(form);
Map<String, JsonNode> filterable = FormSchemaValidator.filterableFields(schemaJson);
JsonNode field = filterable.get(fieldKey);
if (field == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"分组字段不可用(未声明 searchable 或该类型无检索算子):" + fieldKey);
}
FormFilterOperators.Column column = FormFilterOperators.columnOf(field.path("type").asText());
if (column == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "该字段一期不支持分组统计:" + field.path("type").asText());
}
Map<String, String> optionLabels = optionLabels(field);
Map<Object, Long> buckets = queryBuilder.groupByField(query, fieldKey, column);
List<ReadModels.CountItem> items = new ArrayList<>();
for (Map.Entry<Object, Long> entry : buckets.entrySet()) {
String text = render(entry.getKey(), column);
items.add(new ReadModels.CountItem(text, optionLabels.get(text), entry.getValue()));
}
sortDescByCount(items);
ReadModels.FieldGroups groups = new ReadModels.FieldGroups();
groups.setKey(fieldKey);
groups.setLabel(field.path("label").asText(fieldKey));
groups.setItems(items);
return groups;
}
/** 选项值 → label(含字典引用展开后的 options;软删条目在展开结果里已被剔除,历史桶因此 label=null) */
private Map<String, String> optionLabels(JsonNode field) {
Map<String, String> labels = new LinkedHashMap<>();
JsonNode options = field.get("options");
if (options == null || !options.isArray()) {
return labels;
}
for (JsonNode option : options) {
String value = option.path("value").asText(null);
if (value != null) {
labels.put(value, option.path("label").asText(value));
}
}
return labels;
}
// ==================== 缓存 ====================
private String cacheKey(Long domainSchId, Form form, BoaUser user, ReadModels.Query query,
ReadModels.QueryRequest req) {
StringBuilder sb = new StringBuilder();
sb.append(domainSchId).append('|')
.append(form.getUuid()).append('@').append(form.getCurrentVersion()).append('|')
.append(query.getScopes()).append('|')
.append(user == null ? "-" : user.getId()).append('|')
.append(trimToNull(req.getGroupByField()));
return sb.toString();
}
private void put(String key, ReadModels.StatsView view) {
if (cache.size() >= MAX_CACHE_ENTRIES) {
cache.clear();
}
cache.put(key, new Cached(view, System.currentTimeMillis() + CACHE_TTL_MILLIS));
}
/** 统计缓存条目:值 + 到期时刻(仅升版换键失效 + 短 TTL,不按数据变更失效——类注释) */
private static final class Cached {
private final ReadModels.StatsView view;
private final long expireAt;
private Cached(ReadModels.StatsView view, long expireAt) {
this.view = view;
this.expireAt = expireAt;
}
private boolean expired() {
return System.currentTimeMillis() > expireAt;
}
/** 命中时包一层新对象返回:不把 cached 标记写回缓存里的共享实例(并发请求会互相改) */
private ReadModels.StatsView copyAsCached() {
ReadModels.StatsView copy = new ReadModels.StatsView();
copy.setTotal(view.getTotal());
copy.setByStatus(new ArrayList<>(view.getByStatus()));
copy.setByField(new ArrayList<>(view.getByField()));
copy.setCached(Boolean.TRUE);
return copy;
}
}
// ==================== 工具 ====================
private String currentSchema(Form form) {
if (form.getCurrentVersion() == null) {
return "{\"groups\":[]}";
}
String raw = defService.pinnedForData(form.getUuid(), form.getCurrentVersion())
.versionRow().getSchemaJson();
// 选项 label 回查要的是展开后的 options(字典条目改 label 历史 value 不变,V2/V38)
return dictService.expandSchema(form.getSchId(), raw);
}
private static String render(Object value, FormFilterOperators.Column column) {
if (value == null) {
return null;
}
if (column == FormFilterOperators.Column.NUM) {
BigDecimal number = (BigDecimal) value;
return number.stripTrailingZeros().toPlainString();
}
if (column == FormFilterOperators.Column.DATE) {
return ((LocalDate) value).toString();
}
return String.valueOf(value);
}
private static void sortDescByCount(List<ReadModels.CountItem> items) {
Collections.sort(items, (a, b) -> {
int byCount = Long.compare(b.getCount(), a.getCount());
if (byCount != 0) {
return byCount;
}
String left = a.getValue() == null ? "" : a.getValue();
String right = b.getValue() == null ? "" : b.getValue();
return left.compareTo(right);
});
}
private static String trimToNull(String raw) {
if (raw == null) {
return null;
}
String value = raw.trim();
return value.isEmpty() ? null : value;
}
}
@@ -0,0 +1,166 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.common.FormModuleKeys;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
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.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* 数据范围解析器(契约 §8 / 方案 §6.4):把"会话用户 + 这张表单"解析成一组可见范围,
* 交给 {@link AnswerScopeQueryBuilder} 落成 SQL 谓词。
*
* <p><b>解析规则(优先级)</b>:{@code 范围 = 自定义授权(fc:scope:all) ∪ 身份默认矩阵},
* 命中"全部"即收敛为全部;<b>全不命中回落仅本人提交</b>(宁紧勿松)。</p>
*
* <p>"多来源取最宽"在这里的实现是<b>并集</b>:一个用户可能同时有多个身份({@code roleIds} 里既有
* 教师又有学生),逐格取默认值后并起来,读路径按 OR 判行——这比"只取一个最宽枚举值"更贴语义:
* 学生视角(关于我)与教师视角(仅本人提交)不是包含关系,取单值会丢掉其中一格。</p>
*
* <p><b>身份默认矩阵</b>(契约 §8,行=roleId、列=表单面向人群):</p>
* <pre>
* 面向学生(1) 面向教师(2)
* 7/2 全部 全部
* 1 任教班级 → 一期实现器缺席=仅本人提交 仅本人提交
* 3 关于我 仅本人提交(该格契约写"不授填报模块行,进不来",
* 数据面按最保守的兜底值处理)
* 4/6/5 仅本人提交 仅本人提交
* </pre>
*
* <p>与 {@link FormAdminGuard} 是<b>两条正交的口径</b>:守卫答"能不能进这个管理面"(授权模块行),
* 本类答"进来后能看哪些行"。两者共用 {@link ModuleTreeSource} 这一棵树(P21 裁决要求),
* 但一个是 path、一个是 customId,不要混用。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataScopeService {
/** 数据范围升级锚点的 customId(契约 §8:path 空、customId 配这个值的模块行,授权给谁谁升级"全部") */
public static final String SCOPE_ALL_CUSTOM_ID = "fc:scope:all";
/** 超管用户类型(同 FormAdminGuard:不是 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 ModuleTreeSource moduleTreeSource;
private final List<AnswerScopeImplementor> implementors;
/**
* 该会话用户在这张表单上的可见范围集合(至少一个,见类注释的回落规则)。
*
* @param form 目标表单(其 {@code audience} 决定用矩阵哪一列);null 按面向学生处理
*/
public Set<DataScope> resolve(BoaUser user, Form form) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
if (user.getType() != null && user.getType() == SUPER_ADMIN_TYPE) {
return EnumSet.of(DataScope.ALL);
}
Set<DataScope> scopes = new LinkedHashSet<>();
// 1) customId 授权(契约 §8 优先级:customId 模块授权 > 身份默认)
if (hasScopeAllAnchor(user)) {
scopes.add(DataScope.ALL);
}
// 2) 身份默认矩阵,roleId 逐个取默认后并起来(多来源取最宽)
int audience = form == null || form.getAudience() == null
? FormEnums.Audience.STUDENT.getCode() : form.getAudience();
for (Integer roleId : identityRoleIds(user)) {
scopes.add(defaultScope(roleId, audience));
}
if (scopes.isEmpty()) {
scopes.add(DataScope.SELF_SUBMITTED);
}
if (scopes.contains(DataScope.ALL)) {
// 命中"全部"即收敛:其余范围是它的真子集,并集里留着只会让读路径多写几个 OR
return EnumSet.of(DataScope.ALL);
}
return EnumSet.copyOf(scopes);
}
/**
* 一期可选的范围值(V28 的断言载体):契约 §8 闭集四值都在枚举里,
* 但 {@code 任教班级} 的实现器 {@code selectable()=false},因此不出现在这里。
*/
public Set<DataScope> selectableScopes() {
Set<DataScope> result = new LinkedHashSet<>();
for (AnswerScopeImplementor implementor : implementors) {
if (implementor.selectable()) {
result.add(implementor.scope());
}
}
return result;
}
/** 范围 → 实现器(读路径构造器按它取谓词;未注册即配置错误,直接抛出不静默放行) */
public AnswerScopeImplementor implementorOf(DataScope scope) {
for (AnswerScopeImplementor implementor : implementors) {
if (implementor.scope() == scope) {
return implementor;
}
}
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "数据范围缺少实现器:" + scope);
}
/** 是否命中 fc:scope:all 权限锚点(path 空的模块行,只由后端扫描授权集合消费,不进导航) */
private boolean hasScopeAllAnchor(BoaUser user) {
return FormModuleKeys.customIds(moduleTreeSource.authorizedModules(user)).contains(SCOPE_ALL_CUSTOM_ID);
}
/**
* 参与矩阵解析的身份:{@code roleIds}(用户全部正常态角色)并上会话 {@code currentRole.roleId}
* ——会话身份理论上必在 roleIds 内,并一次是防 UserBase 两侧不一致时漏掉当前身份那一格。
*/
private 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 DataScope defaultScope(Integer roleId, int audience) {
boolean studentFacing = FormEnums.Audience.STUDENT.getCode() == audience;
if (roleId == null) {
return DataScope.SELF_SUBMITTED;
}
if (roleId == ROLE_ADMIN || roleId == ROLE_SCHOOL_LEADER) {
return DataScope.ALL;
}
if (roleId == ROLE_TEACHER) {
if (!studentFacing) {
return DataScope.SELF_SUBMITTED;
}
// 面向学生:矩阵值=任教班级,一期实现器缺席 → 生效值=仅本人提交(方案 §6.4 表格明示,防被当 bug 报)
log.debug("[数据范围] 教师×面向学生:任教班级一期未开放,按矩阵注记生效为仅本人提交");
return DataScope.SELF_SUBMITTED;
}
if (roleId == ROLE_STUDENT && studentFacing) {
return DataScope.ABOUT_ME;
}
// 学生×面向教师、家长(4)/工作号(6)/局领导(5)、以及未知角色:兜底
return DataScope.SELF_SUBMITTED;
}
}
@@ -1,44 +1,59 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.common.FormModuleKeys;
import com.yunhai.formcenter.entity.Form;
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.Arrays;
import java.util.List;
import java.util.Set;
/**
* 管理面守卫(方案 §6.5:中心管理端同样走三层权限,"中心管理员"=被授权这些模块行的人)。
* 管理面守卫(方案 §6.1 第②层 / §6.5 / 执行手册 §2.2)。
*
* <p><b>过渡态</b>:方案 §6.1 第②层的权威载体是"授权模块集"(表单设计/数据管理/字典管理模块行),
* 那要等 T4 消费 GetUserMod 授权树后才能实现;在那之前按平台标准角色闸门把关,
* 口径=执行手册 §5 <b>P21 裁决</b>(T2 验收时对 flow-center 模板做的唯一一处收紧):</p>
* <p><b>T4 起口径=授权模块集</b>(P21 裁决的到期替换:T2/T3 的 roleId∈{2,7} 硬编码整体移除、不保留回落)——
* "中心管理员"就是<b>被授权对应模块行的人</b>,与平台其它子应用同构(左导航与后端把关用的是同一棵树,
* 因此不存在"菜单看得见、接口调不动"的两套口径):</p>
* <ul>
* <li>超管({@code type==1},UserBase {@code UserTypeEnum.SUPER_ADMIN}):可跨校,
* 且是<b>唯一</b>能操作平台级行({@code sch_id=0})的身份;</li>
* <li>校级:{@code roleId ∈ {2 校领导, 7 管理员}} 限本校——
* <b>局领导(5) 不给管理权</b>(契约 §8 矩阵里 5=回落"仅本人提交",属查看角色非维护角色)。</li>
* <li>定义管理={@code /admin/forms},数据管理={@code /admin/data},统计={@code /admin/stats},
* 字典管理={@code /admin/dicts}(手册 §2.2 表;path 逐字对上 front 路由)。</li>
* <li>超管({@code type==1},UserBase {@code UserTypeEnum.SUPER_ADMIN})仍是例外:不受单校开通约束、
* 且是<b>唯一</b>能操作平台级行({@code sch_id=0})的身份(方案 §6.5)——这条不是 roleId 判定,
* 是 UserBase 的用户类型,故 P21 移除角色硬编码时保留它。</li>
* </ul>
*
* <p>{@link #canManage} 是同一个判定的"不抛异常"形态,供契约 §5 的 subject 授权分支使用
* (普通用户 {@code subject_id} 只能=自己,有管理权者可代填他人,V17)。</p>
* <p><b>与管理权正交的另一条线</b>:数据面能看<em>哪些行</em>由契约 §8 的角色矩阵 + customId 决定
* ({@link DataScopeService}),本守卫只回答"能不能进这个管理面"。教师被授权了数据管理模块行,
* 看到的仍是其范围内(一期=仅本人提交)的数据,不是全校——两层各管各的,勿混。</p>
*/
@Service
@RequiredArgsConstructor
public class FormAdminGuard {
/** 超管用户类型(UserBase UserTypeEnum.SUPER_ADMIN) */
private static final int SUPER_ADMIN_TYPE = 1;
/** P21 收紧:校级只认 2(校领导)/7(管理员),局领导 5 已移出 */
private static final List<Integer> ADMIN_ROLE_IDS = Arrays.asList(2, 7);
/** 定义管理模块行 path(手册 §2.2:表单设计) */
public static final String MODULE_FORM_DESIGN = "/admin/forms";
/** 数据管理模块行 path */
public static final String MODULE_DATA_MANAGE = "/admin/data";
/** 统计模块行 path(可并入数据管理,手册 §2.2 二选一——这里按独立模块行判,授权了才给) */
public static final String MODULE_STATS = "/admin/stats";
/** 字典管理模块行 path */
public static final String MODULE_DICT = "/admin/dicts";
private final ModuleTreeSource moduleTreeSource;
/**
* 校验管理权并返回本次操作的租户域(定义面与数据面共用)。
* 校验"被授权了 {@code modulePath} 这个模块行"并返回本次操作的租户域。
*
* @param user 会话用户(CAS 自取,不接受任何外部传入身份——契约 §7 身份行)
* @param targetSchId 目标校;超管可显式指定(含 0=平台级),校级传入不等本校即拒
* @param modulePath 需要的模块行 path(本类的四个 {@code MODULE_*} 常量之一)
*/
public Long requireAdmin(BoaUser user, Long targetSchId) {
public Long requireAdmin(BoaUser user, Long targetSchId, String modulePath) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
@@ -53,8 +68,13 @@ public class FormAdminGuard {
throw new BizException(ErrorCode.PARAM_ERROR, "超管请显式指定 schId(当前身份无学校归属)");
}
Long current = currentSchId(user);
if (!hasSchoolAdminRole(user) || current == null) {
throw new BizException(ErrorCode.FORBIDDEN, "无表单管理权限");
if (!hasModule(user, modulePath)) {
throw new BizException(ErrorCode.FORBIDDEN, "无该模块权限:" + modulePath);
}
if (current == null) {
// 授权树是按 (userId, schId 上下文) 取的,无学校归属时返回的树不可信;
// 更关键的是这里必须返回一个"本校"域,给不出就不能放行
throw new BizException(ErrorCode.FORBIDDEN, "当前身份无学校归属,不能确定管理域");
}
if (targetSchId != null && targetSchId == Form.PLATFORM_SCH_ID) {
// 平台级行=跨校资产:校级管理员把目标域传成 0,先按"平台级不归学校管"讲清楚,
@@ -68,24 +88,68 @@ public class FormAdminGuard {
}
/**
* 是否具备管理权(超管或校级管理员)。<b>不含租户域核对</b>——
* 是否具备管理权(超管,或被授权定义管理/数据管理任一模块行)。<b>不含租户域核对</b>——
* 需要限定"只能管本校"的调用方请用 {@link #requireAdmin}。
*
* <p>现用途=契约 §5 的代填分支({@code subject_id} 能否指向他人):普通用户只能=自己,
* 有管理权者可代填(方案 §6.3/V17)。口径与 P21 过渡期的 roleId∈{2,7} 等宽(定义或数据管理),
* 判定依据换成授权模块集。</p>
*/
public boolean canManage(BoaUser user) {
if (user == null || user.getId() == null) {
return false;
}
return isSuperAdmin(user) || (hasSchoolAdminRole(user) && currentSchId(user) != null);
if (isSuperAdmin(user)) {
return true;
}
if (currentSchId(user) == null) {
return false;
}
Set<String> paths = authorizedPaths(user);
return paths.contains(MODULE_DATA_MANAGE) || paths.contains(MODULE_FORM_DESIGN);
}
/**
* 同一条校验的"任一模块行命中即放行"形态。
*
* <p>现用途=统计:手册 §2.2 明示统计行"可并入数据管理,二选一",所以两种配法都算有统计权;
* 只有一个模块行要求的接口请用 {@link #requireAdmin}。</p>
*/
public Long requireAdminAny(BoaUser user, Long targetSchId, String... modulePaths) {
if (user == null || user.getId() == null) {
throw new BizException(ErrorCode.UNAUTHORIZED, "未登录");
}
if (modulePaths == null || modulePaths.length == 0) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "模块行清单为空(调用方写错了)");
}
if (isSuperAdmin(user)) {
return requireAdmin(user, targetSchId, modulePaths[0]);
}
Set<String> held = authorizedPaths(user);
for (String modulePath : modulePaths) {
if (held.contains(modulePath)) {
// 命中即按该模块行走完整租户域校验(复用同一段拒绝逻辑,不在这里另写一份域判定)
return requireAdmin(user, targetSchId, modulePath);
}
}
throw new BizException(ErrorCode.FORBIDDEN,
"无该模块权限(需任一):" + String.join(" 或 ", modulePaths));
}
/** 会话用户被授权的模块行 path 集合(数据范围解析与调试取证用) */
public Set<String> authorizedPaths(BoaUser user) {
return FormModuleKeys.paths(moduleTreeSource.authorizedModules(user));
}
/** 是否被授权某个模块行(path 精确匹配;超管直接放行) */
private boolean hasModule(BoaUser user, String modulePath) {
return isSuperAdmin(user) || authorizedPaths(user).contains(modulePath);
}
private boolean isSuperAdmin(BoaUser user) {
return user.getType() != null && user.getType() == SUPER_ADMIN_TYPE;
}
private boolean hasSchoolAdminRole(BoaUser user) {
return user.getRoleIds() != null && user.getRoleIds().stream().anyMatch(ADMIN_ROLE_IDS::contains);
}
private Long currentSchId(BoaUser user) {
if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) {
return null;
@@ -56,8 +56,9 @@ public class FormDefService {
private static final Pattern CODE_PATTERN = Pattern.compile("^[A-Za-z0-9_\\-]{1,64}$");
/**
* 开报名号时的 code 长度上限:{@code -yyyyMMdd-} 占 9 位,序号留到 5 位(单日上万)仍不越界
* {@code answer_no varchar(64)}(契约 §10)→ 64-9-6=49,留 1 位余量。
* 开报名号时的 code 长度上限:{@code -yyyyMMdd-} 占 10 位(两个连字符 + 8 位日期),
* 当日序号预留 5 位(单日超万)→ {@code 64-10-5=49},保证 {@code answer_no varchar(64)}
* (契约 §10)装得下。开关处拦住,好过提交当天报库错。
*/
static final int MAX_CODE_LEN_FOR_ANSWER_NO = 49;
@@ -69,6 +70,7 @@ public class FormDefService {
private final FormRepository formRepository;
private final FormVersionRepository versionRepository;
private final FormDictService dictService;
private final AnswerIndexService indexService;
// ==================== 读 ====================
@@ -180,44 +182,53 @@ public class FormDefService {
}
/**
* 数据面解析<b>答卷钉住的版本行</b>(T3 编辑/软删/改状态用):按同一条校域遮蔽规则取头表,
* 再取该版本行;<b>不校验生命周期</b>——方案 §5.4"停用只断填报入口、数据读改接口不变",
* 数据面解析<b>答卷钉住的版本行</b>(编辑/软删/改状态 + T4 全部读路径):
* 按 {@code answer.form_uuid} → {@code form}(uk_form_uuid) → 该头表下 {@code version} 的行,
* <b>精确取数、不经遮蔽规则、不校验生命周期</b>——方案 §5.4"停用只断填报入口、数据读改接口不变",
* 升版期间的旧版答卷同样必须可管理。
*
* <p>一处歧义窗口按"退回平台级行"处理:答卷的钉版坐标是 {@code (会话校, app_code, code, version)}
* (契约 §1 校域不变式:{@code answer.sch_id} 恒=会话校),若它当初投的是<b>平台级表单</b>、
* 而本校后来建了同 {@code (app_code, code)} 的表单,遮蔽规则会把解析指向本校表单且版本号可能对不上——
* 此时退回平台级同 code 表单的版本行,历史答卷仍按原 schema 校验。
* 根因与替代方案见 T3 报告问题单。</p>
* <p><b>为什么按 form_uuid 而不是 (校, app_code, code, version)</b>(P25 裁决,替代 T3 的回退启发式):
* 那组坐标不含"表单归属校",平台级表单被本校后建的同 {@code (app_code,code)} 表单遮蔽后会出现歧义窗口,
* 版本号恰好对上时历史答卷被<b>静默按错 schema</b> 校验/重建索引——比查不到更难发现。
* uuid 跨版本不变(契约 §1),按它取数没有这个窗口;代价是历史行必须有该列,
* 本轮开发库答卷零行、无回填(系统未上线=零迁移成本窗口,H3 同款论证)。</p>
*
* <p>本方法<b>不过滤 {@code form.is_deleted}</b>:表单软删是唯一切除形态且无物理删除,
* 存量答卷的定义溯源不能因为表单被软删而断掉(P32/方案 §5.4)。</p>
*/
public Published pinnedForData(Long schId, String appCode, String code, Integer version) {
String cleanApp = normalizeAppCode(appCode);
// 数据面取域含软删表单:答卷的定义溯源不能因为表单被软删而断掉(无物理删除,方案 §5.4)
Form form = pickByShadowing(
formRepository.findDataDomainCandidates(code, cleanApp, resolveDomain(schId)), schId);
public Published pinnedForData(String formUuid, Integer version) {
if (formUuid == null || formUuid.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "答卷缺少 form_uuid,无法溯源钉住版本");
}
Form form = formRepository.findByUuid(formUuid.trim()).orElse(null);
if (form == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + cleanApp + "/" + code);
throw new BizException(ErrorCode.PARAM_ERROR, "答卷归属的表单已不存在:" + formUuid);
}
FormVersion row = versionRepository.findByFormIdAndVersion(form.getId(), version).orElse(null);
if (row == null && form.getSchId() != null && form.getSchId() != Form.PLATFORM_SCH_ID) {
Form platform = formRepository.findBySchIdAndAppCodeAndCode(Form.PLATFORM_SCH_ID, cleanApp, code)
.orElse(null);
if (platform != null) {
FormVersion platformRow = versionRepository.findByFormIdAndVersion(platform.getId(), version)
.orElse(null);
if (platformRow != null) {
form = platform;
row = platformRow;
}
}
}
if (row == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"答卷钉住的版本行不存在:" + cleanApp + "/" + code + " v" + version);
"答卷钉住的版本行不存在:" + form.getAppCode() + "/" + form.getCode() + " v" + version);
}
return new Published(form, row);
}
/**
* 数据面读路径的表单定位(T4 列表/统计/导出/筛选候选):按 {@code form_uuid} 取头表,
* 域=会话校 ∪ 平台级(契约 §1)——答卷按 {@code sch_id} 租户隔离,表单本身可能是本校的或平台级的。
* 他校表单与不存在的表单同一口径拒绝(uuid 不可枚举,差别信息就是探测面,同 {@link #requireForm})。
*/
public Form readDomainForm(Long sessionSchId, String formUuid) {
if (formUuid == null || formUuid.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "formUuid 不能为空");
}
Form form = formRepository.findByUuid(formUuid.trim()).orElse(null);
if (form == null || form.getSchId() == null
|| !(form.getSchId().equals(sessionSchId) || form.getSchId() == Form.PLATFORM_SCH_ID)) {
throw new BizException(ErrorCode.PARAM_ERROR, "表单不存在:" + formUuid);
}
return form;
}
// ==================== 写:草稿/发布/生命周期 ====================
/** 建草稿:uuid 当场生成、跨版本不变(契约 §1) */
@@ -314,6 +325,9 @@ public class FormDefService {
form.setCurrentVersion(draft.getVersion());
form.setStatus(FormEnums.FormStatus.PUBLISHED.getCode());
formRepository.save(form);
// V9:新版本新声明的 searchable 字段,要在历史答卷上补出索引行,否则"旧数据按新字段筛"落不了地。
// 只增不删(与 V8 的"新版本删字段不清历史行"同向),幂等可重入。见 AnswerIndexService 注释。
indexService.backfillAfterPublish(form.getUuid(), draft.getSchemaJson());
return toView(form, null);
}
@@ -380,8 +394,8 @@ public class FormDefService {
throw new BizException(ErrorCode.PARAM_ERROR, "answerNoEnabled 只能是 0 或 1");
}
if (req.getAnswerNoEnabled() == 1) {
// 报名号形态 {code}-{yyyyMMdd}-{4 位序号} 必须装得进 answer_no varchar(64)(契约 §10):
// 9 个分隔/日期字符 + 4 位序号占掉 13,code 上限因此是 51。开关处拦住,好过提交当天报库错。
// 报名号形态 {code}-{yyyyMMdd}-{当日序号} 必须装得进 answer_no varchar(64)(契约 §10):
// -yyyyMMdd- 占 10 位、序号预留 5 位,code 上限因此是 49(MAX_CODE_LEN_FOR_ANSWER_NO)。
if (form.getCode().length() > MAX_CODE_LEN_FOR_ANSWER_NO) {
throw new BizException(ErrorCode.PARAM_ERROR,
"开启报名号要求 code ≤" + MAX_CODE_LEN_FOR_ANSWER_NO + " 位(当前 "
@@ -0,0 +1,29 @@
package com.yunhai.formcenter.service;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaUser;
import java.util.List;
/**
* 会话用户在本应用下的<b>已授权模块树</b>来源(方案 §6.1 第②层,权限模型的唯一权威载体)。
*
* <p>T4 起两类判断都从这棵树出,<b>共用同一个接口</b>(执行手册 §5 P21/P30 裁决落地口径):</p>
* <ul>
* <li><b>管理权</b>:定义管理/数据管理/统计/字典管理=被授权对应模块行(path 命中,手册 §2.2 表);</li>
* <li><b>数据范围</b>:{@code fc:scope:all} 授权锚点=这棵树里 path 空、customId 为该值的纯权限位
* (契约 §8 扩展协议)。</li>
* </ul>
*
* <p>做成策略接口的原因与 {@link SubjectDirectory} 同一条:事实源在 UserBase。
* 生产实现走平台标准签名调用({@code /oapi/user/modules},签名由 starter 装配的出站链负责);
* 本地 IT 不启动 UserBase/CAS,注入桩即可断言权限矩阵本身,真实链路 T9 联调验。</p>
*
* <p><b>失败口径=fail-closed</b>:不可达或异常按"无任何授权模块"处理(管理权被拒、范围回落到仅本人提交),
* 与契约 §8"全不命中=仅本人提交(宁紧勿松)"同向。</p>
*/
public interface ModuleTreeSource {
/** 该用户在表单中心应用下的已授权模块树(含 path 空的权限锚点);无授权或不可达=空列表 */
List<BoaModule> authorizedModules(BoaUser user);
}
@@ -0,0 +1,117 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.common.FormSchemaValidator;
import com.yunhai.formcenter.common.FormValueSchemaBuilder;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.Form;
import com.yunhai.formcenter.enums.FormEnums;
import com.yunhai.formcenter.model.OapiModels;
import com.yunhai.formcenter.repository.AnswerRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 一期对外读接口服务(契约 §9 两端点)。
*
* <p><b>身份与租户</b>:{@code schId} 是<b>调用方声明的租户域</b>(应用签名已核实调用方身份,
* 与 flow-center {@code /oapi/flow/**} 同一口径:"应用签名不替代谁能看"——消费方对自己侧的
* 用户可见性负责,本层只保证不跨校取数:答卷按 {@code (uuid, sch_id)} 定位,跨校一律查不到,
* 表单按 resolve 的"会话校 ∪ 平台级、本校遮蔽"规则解析。</p>
*
* <p><b>与 CAS 读路径的分工</b>:契约 §8 的行级数据范围矩阵挂在<b>会话身份</b>上,oapi 面没有会话用户
* (签名方是应用),因此不走 {@link AnswerScopeQueryBuilder}——它给的是"这张表的结构"与"这一条答卷",
* 批量读(列表/统计/导出)在对外面上根本不存在,越权面按端点形状就被关死(一期对外只读单条与 schema,
* 不提供全量拉取)。这一条写清楚,避免后来人"顺手"加一个 /oapi/form/list。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class OapiFormService {
/** 契约 §9 只给 schId+code 两个参数,归属应用缺省=form-center(手册 §1);快应用表单显式传 appCode */
private static final String DEFAULT_APP_CODE = Form.CENTER_APP_CODE;
private final FormDefService defService;
private final FormDictService dictService;
private final AnswerRepository answerRepository;
/**
* 机读 schema(契约 §4 形状):字典已内联展开,每个字段附 valueSchema。
*
* @param schId 租户域(调用方声明)
* @param appCode 归属应用,可空=form-center
* @param code 表单 code
*/
public OapiModels.SchemaView schema(Long schId, String appCode, String code) {
requireSchId(schId);
FormDefService.Published published = defService.resolvePublished(schId,
appCode == null || appCode.trim().isEmpty() ? DEFAULT_APP_CODE : appCode.trim(), code);
Form form = published.form();
JsonNode root = FormValueSchemaBuilder.withValueSchemas(expandedRoot(form, published.versionRow().getSchemaJson()));
OapiModels.SchemaView view = new OapiModels.SchemaView();
view.setFormUuid(form.getUuid());
view.setVersion(published.versionRow().getVersion());
view.setCode(form.getCode());
view.setAppCode(form.getAppCode());
view.setAudience(FormEnums.Audience.nameOf(form.getAudience()));
view.setGroups(root.path("groups"));
return view;
}
/**
* 单条答卷(契约 §9):钉住版本的内联 schema + 值文档。
* 软删答卷对外不可见(与四条 CAS 读路径同一口径,V30)。
*/
public OapiModels.AnswerView answer(Long schId, String answerId) {
requireSchId(schId);
if (answerId == null || answerId.trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "answerId 不能为空");
}
Answer answer = answerRepository.findByUuidAndSchId(answerId.trim(), schId).orElse(null);
if (answer == null || Integer.valueOf(1).equals(answer.getIsDeleted())) {
// 与 CAS 面同口径:不存在与跨校同一回答,不给探测面(契约 §1 uuid 不可枚举)
throw new BizException(ErrorCode.PARAM_ERROR, "答卷不存在:" + answerId);
}
FormDefService.Published pinned = defService.pinnedForData(answer.getFormUuid(), answer.getVersion());
Form form = pinned.form();
JsonNode root = expandedRoot(form, pinned.versionRow().getSchemaJson());
OapiModels.AnswerView view = new OapiModels.AnswerView();
view.setAnswerId(answer.getUuid());
view.setFormUuid(answer.getFormUuid());
view.setAppCode(answer.getAppCode());
view.setCode(answer.getCode());
view.setVersion(answer.getVersion());
view.setStatus(answer.getStatus());
view.setSubmitterId(answer.getSubmitterId());
view.setSubjectId(answer.getSubjectId());
view.setAnswerNo(answer.getAnswerNo());
view.setSubmitTime(AnswerReadService.format(answer.getCreateTime()));
view.setValues(FormSchemaValidator.parse(answer.getValuesJson(), "答卷值文档损坏"));
OapiModels.SchemaRef ref = new OapiModels.SchemaRef();
ref.setFormUuid(form.getUuid());
ref.setAppCode(form.getAppCode());
ref.setCode(form.getCode());
ref.setVersion(answer.getVersion());
view.setSchemaRef(ref);
view.setGroups(root.path("groups"));
return view;
}
/** 字典内联展开 → JSON 树(对外自包含,契约 §2.1) */
private JsonNode expandedRoot(Form form, String schemaJson) {
return FormSchemaValidator.parse(dictService.expandSchema(form.getSchId(), schemaJson),
"表单结构损坏(请联系管理员)");
}
private void requireSchId(Long schId) {
if (schId == null || schId < 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "schId 必填(调用方声明的租户域,≥0)");
}
}
}
@@ -0,0 +1,40 @@
package com.yunhai.formcenter.service;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
/**
* {@link ModuleTreeSource} 的生产实现:平台标准签名调用 UserBase {@code /oapi/user/modules}
* (经 SDK {@code YschBaseOpenApi.getUserModule(userId, schId)},与前端壳 GetUserMod 同一条链路、
* 同一个数据源——管理权与导航菜单因此不会有两套口径,方案 §6.1 运行时链路)。
*
* <p>{@code schId} 传会话学校=把授权限定在该学校上下文(UserBase 侧只取该校子树内的真实归属组参与判定);
* 无学校归属的身份(区域组/根直属)传 null,按 UserBase 语义取全部归属组。</p>
*/
@Slf4j
@Service
public class PlatformModuleTreeSource implements ModuleTreeSource {
@Override
public List<BoaModule> authorizedModules(BoaUser user) {
if (user == null || user.getId() == null) {
return Collections.emptyList();
}
Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
try {
List<BoaModule> modules = YschBaseOpenApi.getUserModule(user.getId(), schId);
return modules == null ? Collections.<BoaModule>emptyList() : modules;
} catch (Exception e) {
// fail-closed:取不到树就当没有授权——管理权拒、数据范围回落到仅本人提交(接口注释)
log.warn("[模块树] UserBase /oapi/user/modules 不可达,按无授权处理 userId={} schId={}:{}",
user.getId(), schId, e.getMessage());
return Collections.emptyList();
}
}
}
@@ -0,0 +1,74 @@
package com.yunhai.formcenter.service;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* {@link SubjectDirectory} 的生产实现:平台标准签名调用 UserBase {@code GET /oapi/user/byId?id=}
* (经 starter 装配的 {@link OpenApiUserClient},签名四件套由出站拦截器负责,本工程不私写签名、
* 不直连 UserBase 库)。每次提交只验一个 id,byId 单查够用,不需要批量端点(P33 裁决)。
*
* <p>失败口径见接口注释:不可达与查不到都返回 false(fail-closed),差异只落在日志上便于排查
* ——flow-center {@code AssigneeResolver#requireUserName} 同款分档。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PlatformSubjectDirectory implements SubjectDirectory {
private final OpenApiUserClient userClient;
@Override
public boolean existsInSchool(Integer subjectId, Long schId) {
if (subjectId == null || schId == null) {
return false;
}
BoaUser subject;
try {
subject = userClient.getUserById(String.valueOf(subjectId));
} catch (Exception e) {
log.warn("[代填校验] UserBase 不可达,按查不到处理 subjectId={}:{}", subjectId, e.getMessage());
return false;
}
if (subject == null) {
log.warn("[代填校验] UserBase 查无此人 subjectId={}", subjectId);
return false;
}
if (!belongsToSchool(subject, schId)) {
log.warn("[代填校验] subjectId={} 的学校归属不含 schId={}", subjectId, schId);
return false;
}
return true;
}
/**
* 学校归属判定:{@code schools}(用户全部可选身份的学校集合,UserBase 本期起填充)为主,
* {@code school}(当前激活学校)与 {@code currentRole.schId} 为兜底——三者任一含目标校即算同校。
* 纯函数,脱库可单测。
*/
static boolean belongsToSchool(BoaUser subject, Long schId) {
if (subject == null || schId == null) {
return false;
}
List<BoaGroup> schools = subject.getSchools();
if (schools != null) {
for (BoaGroup group : schools) {
if (group != null && group.getId() != null && group.getId().longValue() == schId) {
return true;
}
}
}
BoaGroup school = subject.getSchool();
if (school != null && school.getId() != null && school.getId().longValue() == schId) {
return true;
}
return subject.getCurrentRole() != null && subject.getCurrentRole().getSchId() != null
&& subject.getCurrentRole().getSchId().longValue() == schId;
}
}
@@ -0,0 +1,35 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import org.springframework.stereotype.Component;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
/**
* 范围实现器:<b>仅本人提交</b>({@code submitter_id}=会话用户)。
*
* <p>这是契约 §8 的兜底值:"全不命中=仅本人提交(宁紧勿松)",也是教师×面向学生表单在 1.5
* 之前的实际生效值(任教班级实现器缺席,方案 §6.4 表格里写明了要防"教师怎么只看到自己"被当 bug 报)。
* 走 {@code idx_answer_submitter(sch_id, submitter_id)}。</p>
*/
@Component
public class SelfSubmittedScopeImplementor implements AnswerScopeImplementor {
@Override
public DataScope scope() {
return DataScope.SELF_SUBMITTED;
}
@Override
public boolean selectable() {
return true;
}
@Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) {
return cb.equal(root.get("submitterId"), userId);
}
}
@@ -0,0 +1,29 @@
package com.yunhai.formcenter.service;
/**
* 被代填人({@code answer.subject_id})的存在性与学校归属解析(执行手册 §5 <b>P33 裁决</b>)。
*
* <p><b>为什么要这层抽象</b>:这个判断的事实源在 UserBase,不在本库。把它做成策略接口而不是
* 在业务服务里直连 UserBase 库或自己拼签名,是为了——</p>
* <ul>
* <li>生产实现走平台标准签名调用({@code GET /oapi/user/byId?id=},签名四件套由 starter 装配的
* 出站拦截器负责,本工程不私写签名逻辑);</li>
* <li>本地/IT 环境不启动 UserBase 与 CAS,测试注入桩即可断言"查不到 / 跨校 → 拒"这条规则本身,
* 真实链路按裁决在 T9 联调验;</li>
* <li>V18 的"关于我的"视图依赖 subject id 指向真实存在的人,这道校验是它的前提。</li>
* </ul>
*
* <p><b>口径</b>:解析不出或不可达一律按"查不到"处理(fail-closed)。代填是低频动作
* (管理员录一条数据),宁可让管理员重试一次,不可把不存在/他校的人挂进本校数据。</p>
*/
public interface SubjectDirectory {
/**
* 该 id 是否是<b>本校</b>真实存在的用户。
*
* @param subjectId 被代填人 id
* @param schId 会话校(= {@code answer.sch_id},校域不变式)
* @return 存在且其学校归属包含 {@code schId} 才 true;查不到、跨校、UserBase 不可达都返回 false
*/
boolean existsInSchool(Integer subjectId, Long schId);
}
@@ -0,0 +1,42 @@
package com.yunhai.formcenter.service;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.enums.FormEnums.DataScope;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.stereotype.Component;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
/**
* 范围实现器:<b>任教班级</b>——1.5 占位(契约 §8 / 方案 §6.4)。
*
* <p>存在意义就是把 V28 的两半钉在代码里:枚举值<em>在</em>(一期契约即含四值,定了就不变),
* 但 {@link #selectable()} 为 false,{@code DataScopeService} 因此永远不会把它放进解析结果,
* 教师×面向学生表单在一期按矩阵注明的"实际生效=仅本人提交"落地。</p>
*
* <p>1.5 开放时的改动面就在本类:接 UserClass(原 BschClass)的任教关系/班级 SDK,把
* {@code rowPredicate} 换成"submitter 的任教班级 ∩ 答卷班级列"或按 subject 的班级归属判定,
* {@code selectable()} 翻成 true——读路径与管理动作不用改(它们只认 {@link AnswerScopeQueryBuilder})。</p>
*/
@Component
public class TeachingClassScopeImplementor implements AnswerScopeImplementor {
@Override
public DataScope scope() {
return DataScope.TEACHING_CLASS;
}
@Override
public boolean selectable() {
return false;
}
@Override
public Predicate rowPredicate(CriteriaBuilder cb, Root<Answer> root, Integer userId) {
// 防御性:一期解析不会产生该范围(selectable=false),走到这里说明有人绕过了矩阵解析
throw new BizException(ErrorCode.CUSTOM, "数据范围 任教班级 一期未开放(1.5 依赖 UserClass 任教关系)");
}
}