diff --git a/pom.xml b/pom.xml index d853160..7cb7e8c 100644 --- a/pom.xml +++ b/pom.xml @@ -64,6 +64,14 @@ org.springframework.boot spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-amqp + org.jasig.cas.client diff --git a/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java b/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java index 069e86f..51d7f18 100644 --- a/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java +++ b/src/main/java/com/yunhai/formcenter/controller/oapi/OapiFormController.java @@ -17,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController; * GET /oapi/form/schema?schId=&uuid= → T11 新形态:该表单当前已发布版 * GET /oapi/form/schema?schId=&uuid=&version= → T11 新形态:精确版本行(快照核对/重放) * GET /oapi/form/answer?schId=&answerId= → data=钉住版本的 schema 引用 + 值文档(契约 §9) + * GET /oapi/form/answers?schId=&form_uuid=&游标 → v2 增量拉取(契约 v2 §6.1,T17,见方法注释) * * *

uuid 两形态的用途=给 flow-center 在流程定义发布期拉契约快照嵌进 def 版本行 @@ -57,4 +58,25 @@ public class OapiFormController { @RequestParam("answerId") String answerId) { return formService.answer(schId, answerId); } + + /** + * v2 增量拉取(契约 §6.1/C9,T17)——事实源,MQ 事件只是降延迟。 + * + *

+     * GET /oapi/form/answers?schId=&formUuid=&after=answer_uuid[&limit=]        冻结表单:append-only + 墓碑
+     * GET /oapi/form/answers?schId=&formUuid=&updated_since=ISO8601[|answer_uuid]  非冻结:updated 窗口
+     * 
+ * + *

两种游标按表单的冻结状态二分、不接受混用(拒绝文案点明这张表要哪种),{@code limit} 默认 500 + * 上限 2000;响应 {@code {rows,nextCursor}},行字段是闭集且不含库内 id(V53)。 + * {@code schId} 与既有两个 oapi 端点同义=调用方声明的租户域,行集按它过滤(应用签名不替代谁能看)。

+ */ + @GetMapping("/answers") + public OapiModels.AnswersView answers(@RequestParam("schId") Long schId, + @RequestParam("form_uuid") String formUuid, + @RequestParam(value = "after", required = false) String after, + @RequestParam(value = "updated_since", required = false) String updatedSince, + @RequestParam(value = "limit", required = false) Integer limit) { + return formService.answers(schId, formUuid, after, updatedSince, limit); + } } diff --git a/src/main/java/com/yunhai/formcenter/controller/oapi/package-info.java b/src/main/java/com/yunhai/formcenter/controller/oapi/package-info.java index 26163a6..f85ce04 100644 --- a/src/main/java/com/yunhai/formcenter/controller/oapi/package-info.java +++ b/src/main/java/com/yunhai/formcenter/controller/oapi/package-info.java @@ -1,6 +1,8 @@ /** * 对外签名面(/oapi/**,appKey 三件套验签,见 application.yml 的 yh.app-auth.server.enforce-patterns)。 * T4 落地契约 §9 的两个读端点:{@code GET /oapi/form/schema}(内联 options + valueSchema)与 - * {@code GET /oapi/form/answer}。一期无 oapi 代提交(方案 §2.3 边界,答卷身份一律会话推导)。 + * {@code GET /oapi/form/answer}。T17 加第三个:{@code GET /oapi/form/answers}(契约 v2 §6.1 增量拉取, + * 它是消费方的事实源,MQ 事件只降延迟、不含值文档)。一期无 oapi 代提交 + * (方案 §2.3 边界,答卷身份一律会话推导)。 */ package com.yunhai.formcenter.controller.oapi; diff --git a/src/main/java/com/yunhai/formcenter/model/OapiModels.java b/src/main/java/com/yunhai/formcenter/model/OapiModels.java index ed11e55..3a8216b 100644 --- a/src/main/java/com/yunhai/formcenter/model/OapiModels.java +++ b/src/main/java/com/yunhai/formcenter/model/OapiModels.java @@ -75,4 +75,92 @@ public final class OapiModels { private String code; private Integer version; } + + // ==================== v2 拉取端点(§6.1,T17) ==================== + + /** + * {@code GET /oapi/form/answers} 的 data:{@code {rows, nextCursor}}(C9)。 + * + *

外层两个键按契约原文是 camelCase,而 {@link AnswerRow} 内部是 snake_case—— + * 这不是笔误而是原文如此(§6.1 的行样例 + C9 的响应样例),实现照抄,不在中间加"顺手统一"的适配层: + * 线格式一改,已接的消费方就得跟着改(同 C5/G3 那次 breaking 的教训)。

+ */ + @Getter + @Setter + public static class AnswersView { + private java.util.List rows; + /** null=已到当前窗口末尾;同游标重放返回同窗口或其后子集(C9 重放安全) */ + private String nextCursor; + } + + /** + * 拉取行的字段闭集(§6.1,V53:库内 {@code id} 绝不出现在响应里)。 + * + *

逐字段标 {@code @JsonProperty} 而不是配命名策略:这张表一旦有人"顺手"加字段, + * {@code OapiAnswersIT} 的键集断言就会红(闭集是契约级的,不是实现细节)。 + * 软删行照常出现在这里({@code is_deleted=1} 即墓碑,C11)。

+ */ + @Getter + @Setter + public static class AnswerRow { + @com.fasterxml.jackson.annotation.JsonProperty("answer_uuid") + private String answerUuid; + + @com.fasterxml.jackson.annotation.JsonProperty("sch_id") + private Long schId; + + @com.fasterxml.jackson.annotation.JsonProperty("app_code") + private String appCode; + + @com.fasterxml.jackson.annotation.JsonProperty("form_uuid") + private String formUuid; + + @com.fasterxml.jackson.annotation.JsonProperty("form_code") + private String formCode; + + @com.fasterxml.jackson.annotation.JsonProperty("version") + private Integer version; + + @com.fasterxml.jackson.annotation.JsonProperty("answer_no") + private String answerNo; + + /** 提交人;0=匿名行(C8) */ + @com.fasterxml.jackson.annotation.JsonProperty("submitter_id") + private Integer submitterId; + + /** 被代填人;自填=本人、匿名=NULL */ + @com.fasterxml.jackson.annotation.JsonProperty("subject_id") + private Integer subjectId; + + @com.fasterxml.jackson.annotation.JsonProperty("status") + private String status; + + @com.fasterxml.jackson.annotation.JsonProperty("values") + private JsonNode values; + + /** 匿名留痕(C12:登录答卷两项恒 null,值文档里也不出现) */ + @com.fasterxml.jackson.annotation.JsonProperty("meta") + private AnswerRowMeta meta; + + @com.fasterxml.jackson.annotation.JsonProperty("create_time") + private String createTime; + + @com.fasterxml.jackson.annotation.JsonProperty("update_time") + private String updateTime; + + /** 1=墓碑(C11) */ + @com.fasterxml.jackson.annotation.JsonProperty("is_deleted") + private Integer isDeleted; + } + + /** 行内 {@code meta} 的两件(闭集,消费方按 {@code submitter_id==0} 认匿名行) */ + @Getter + @Setter + public static class AnswerRowMeta { + @com.fasterxml.jackson.annotation.JsonProperty("ip") + private String ip; + + @com.fasterxml.jackson.annotation.JsonProperty("ua") + private String ua; + } } diff --git a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java index 6fda5dd..f516b24 100644 --- a/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java +++ b/src/main/java/com/yunhai/formcenter/repository/AnswerRepository.java @@ -68,6 +68,31 @@ public interface AnswerRepository extends JpaRepository { /** 我的提交段(T7)/ "我提交的"视图(T8):按 submitter 过滤本人答卷,按 id 降序(最新在前) */ List findBySchIdAndSubmitterIdOrderByIdDesc(Long schId, Integer submitterId); + /** + * oapi v2 拉取·冻结表单的 append-only 窗口(契约 v2 §6.1/C9/C11,T17): + * 按 id 升序推进、不过滤 is_deleted(墓碑照常下发)。 + * + *

带 {@code sch_id} 是 oapi 面既有那条"本层只保证不跨校取数"的不变式(应用签名不替代谁能看); + * 与 {@link #findLiveByFormUuidAfterId} 的区别正是软删行——那条是补建索引用的活数据,这条是对外事实流。

+ */ + @Query("select a from Answer a where a.formUuid = :formUuid and a.schId = :schId and a.id > :cursor " + + "order by a.id asc") + List findPullAfterId(@Param("formUuid") String formUuid, @Param("schId") Long schId, + @Param("cursor") Long cursor, Pageable pageable); + + /** + * oapi v2 拉取·非冻结表单的 updated 窗口(§6.1/C9/C11):复合推进键 + * {@code (update_time, answer_uuid)}——同刻多行也能定序且不漏不重(重放安全, + * 边界行至多重复投递一次,由消费方幂等 upsert 吸收)。同样含墓碑: + * 软删走实体 save({@code @PreUpdate} 必刷 update_time),所以删除动作就在这个窗口里(V70 钉住)。 + */ + @Query("select a from Answer a where a.formUuid = :formUuid and a.schId = :schId " + + "and (a.updateTime > :time or (a.updateTime = :time and a.uuid > :uuid)) " + + "order by a.updateTime asc, a.uuid asc") + List findPullUpdatedSince(@Param("formUuid") String formUuid, @Param("schId") Long schId, + @Param("time") java.time.LocalDateTime time, @Param("uuid") String uuid, + Pageable pageable); + /** * "关于我的"视图(T8,V18):按 subject 过滤——别人代我填的答卷也在这里 * (方案 §6.3:谁提交与关于谁是两个角色,学生看到的是"关于我"的那几条,不限于自己填的)。 diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java b/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java index 02d69d0..d93bf25 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerAdminService.java @@ -71,6 +71,7 @@ public class AnswerAdminService { private final AnswerReadService readService; private final AnswerScopeQueryBuilder queryBuilder; private final TransactionTemplate transactionTemplate; + private final FcEventPublisher events; // ==================== 单条 ==================== @@ -145,6 +146,8 @@ public class AnswerAdminService { answerRepository.save(answer); // 索引同步重建(方案 §5.4):仍按钉住版本的 searchable 声明写,不随新版本 schema 改变 indexService.rebuild(answer, schemaJson); + // 事件:非冻结编辑(冻结在上面就拒了,走不到这里)= updated(契约 v2 §6.2 发布点之一) + events.answerUpdated(answer); opLog(answer.getId(), AnswerOpLog.OP_EDIT, operator.getId(), req.getSummary() == null ? "管理员编辑(钉住版本 v" + answer.getVersion() + ")" : "编辑:钉住版本 v" + answer.getVersion() + ";" + req.getSummary()); @@ -163,6 +166,8 @@ public class AnswerAdminService { FormRuntimeRules.quotaKey(answer.getSubjectId(), answer.getSubmitterId())); opLog(answer.getId(), batched ? AnswerOpLog.OP_BATCH_SOFT_DELETE : AnswerOpLog.OP_SOFT_DELETE, operator.getId(), batched ? "批量软删(单条同校验、单条留痕)" : "软删(无物理删除接口)"); + // 事件:tombstoned(管理面单条与批量共用本方法,所以两个入口一处发布,§6.2) + events.answerTombstoned(answer); } private void doChangeStatus(BoaUser operator, Long schId, AnswerModels.ChangeStatusRequest req, diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java index 0e34217..d25ad83 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerReadService.java @@ -66,6 +66,7 @@ public class AnswerReadService { private final FillQuotaRepository quotaRepository; private final FormFileRepository fileRepository; private final AnswerOpLogRepository opLogRepository; + private final FcEventPublisher events; // ==================== 规格构造(四路径共用) ==================== @@ -322,6 +323,8 @@ public class AnswerReadService { defService.pinnedForData(answer.getFormUuid(), answer.getVersion()).form().getId(), FormRuntimeRules.quotaKey(answer.getSubjectId(), answer.getSubmitterId())); opLog(answer.getId(), AnswerOpLog.OP_SOFT_DELETE, user.getId(), "本人删除自己的提交(软删)"); + // 事件:tombstoned(提交人侧软删与管理面各自实体 save,共用发布器这一个出口,§6.2) + events.answerTombstoned(answer); log.info("[我的提交] 软删答卷 answerId={} submitterId={}", answerId, user.getId()); } @@ -367,6 +370,8 @@ public class AnswerReadService { opLog(answer.getId(), AnswerOpLog.OP_EDIT, user.getId(), summary == null || summary.trim().isEmpty() ? "本人编辑自己的提交(钉住版本 v" + answer.getVersion() + ")" : "本人编辑:钉住版本 v" + answer.getVersion() + ";" + summary.trim()); + // 事件:updated(与管理面编辑同一点位;能走到这里说明该表未开冻结,V61 已在上面拦过) + events.answerUpdated(answer); log.info("[我的提交] 编辑答卷 answerId={} submitterId={} v={}", answerId, user.getId(), answer.getVersion()); return toRow(answer, form); diff --git a/src/main/java/com/yunhai/formcenter/service/AnswerService.java b/src/main/java/com/yunhai/formcenter/service/AnswerService.java index a54857b..2298815 100644 --- a/src/main/java/com/yunhai/formcenter/service/AnswerService.java +++ b/src/main/java/com/yunhai/formcenter/service/AnswerService.java @@ -92,6 +92,7 @@ public class AnswerService { private final FormFileRepository fileRepository; private final AnswerIndexService indexService; private final TransactionTemplate transactionTemplate; + private final FcEventPublisher events; /** * 提交答卷。 @@ -266,6 +267,10 @@ public class AnswerService { // 9) 索引:只写 searchable 字段(V3/V9) indexService.write(answer, schemaJson); + // 10) 事件:created 只在"真提交"这条路上发(幂等命中在第 4 关就 return 了,不发事件)。 + // 发布器内部是 afterCommit + 失败仅 warn + 未配 broker 即 no-op,主流程零风险(§6.2 裁决 2/3) + events.answerCreated(answer); + log.info("[提交]{} answer={} form={}/{} v{} submitter={} subject={} quotaKey={}", identity.anonymous() ? "[匿名]" : "", answer.getUuid(), answer.getAppCode(), answer.getCode(), answer.getVersion(), submitterId, storedSubjectId, quotaKey); diff --git a/src/main/java/com/yunhai/formcenter/service/FcEventPublisher.java b/src/main/java/com/yunhai/formcenter/service/FcEventPublisher.java new file mode 100644 index 0000000..ff311db --- /dev/null +++ b/src/main/java/com/yunhai/formcenter/service/FcEventPublisher.java @@ -0,0 +1,213 @@ +package com.yunhai.formcenter.service; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.yunhai.formcenter.entity.Answer; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import javax.annotation.PostConstruct; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 答卷事件推送(契约 v2 §6.2,T17,验收 V62/V63/V64)。 + * + *

三条裁决决定了这个类的形状(契约把它们标成"不可协商"):

+ *
    + *
  1. MQ 是通知通道,不是数据通道——信封十字段闭集(V63),不含值文档。值文档只有 + * 签名验过的拉取端点那一份;把值塞进总线等于把读面从"鉴权端点"扩大到"能连上 broker 的任何人";
  2. + *
  3. 拉取是事实源,MQ 只降延迟——所以这里不写 outbox、不配死信,发布失败只 warn + * (丢的那条由消费方的 5 分钟增量拉取补回,V62 的"同步单向"也因此不会被消息顺序反向污染);
  4. + *
  5. 可选依赖——{@code RABBITMQ_HOST} 未注入 → 整体 no-op + 启动一条日志,服务照跑(V64; + * 照 data-redis 那条先例:引依赖但不引运行期强耦合,配套 yml 关 rabbit 健康探针)。
  6. + *
+ * + *

发布时机=事务提交后({@code afterCommit}):回滚掉的事务不能留下"已发布但不存在"的事件—— + * 消费方按 {@code answer_uuid} 去拉会拉不到,而它没有理由重试。同步器未激活时(无事务上下文, + * 例如单测直接调)就地发布,不为测试特殊开事务。

+ * + *

exchange 惰性声明({@code durable topic fc.events.v1}):第一次真要发的时候 declare 一次, + * 之后不再重复。未启 broker 的环境永远走不到这一行,因此也不需要 {@code RabbitAdmin} 的自动声明链路。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class FcEventPublisher { + + /** 拓扑:topic exchange,名与 durable 都由契约定死(§6.2) */ + public static final String EXCHANGE = "fc.events.v1"; + + /** 平台时区(与 hibernate.jdbc.time_zone / jackson.time-zone 同一条),信封时间戳按它给偏移量 */ + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter OCCURRED_AT = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + /** + * RabbitTemplate 走 {@link ObjectProvider}:本应用"可以完全不配 MQ", + * 直接注入会在依赖缺失时把上下文拖垮(而可选依赖的整个前提就是"哪天 broker 就绪哪天开")。 + */ + private final ObjectProvider templates; + + /** 是否启用的唯一判据=宿主是否注入了 RABBITMQ_HOST(yml 把它映射到 spring.rabbitmq.host) */ + @Value("${spring.rabbitmq.host:}") + private String host; + + private final AtomicBoolean exchangeDeclared = new AtomicBoolean(false); + + @PostConstruct + public void logState() { + if (enabled()) { + log.info("[MQ] 事件发布启用(exchange={},host={})", EXCHANGE, host); + } else { + log.info("[MQ] 未配置 RABBITMQ_HOST,事件发布禁用——拉取端点即事实源,服务照常(契约 v2 §6.2 裁决 3)"); + } + } + + public boolean enabled() { + return host != null && !host.trim().isEmpty(); + } + + // ==================== 三个发布点 ==================== + + /** 提交管线写库段之后(手工/代录/导入同管线,三者自动都在这,§3.4/V60) */ + public void answerCreated(Answer answer) { + publish(answer, "created"); + } + + /** 编辑写库段同点位(非冻结表单才可能走到这里——冻结双通道同关,V61) */ + public void answerUpdated(Answer answer) { + publish(answer, "updated"); + } + + /** + * 软删(墓碑)。 + * + *

写库点有两处实体 save(管理面 {@code doSoftDelete}(单条与批量共用)与提交人侧 + * {@code softDeleteBySubmitter}),契约要求"软删公共出口收敛一处"——本方法就是那一处: + * 类型、routing key、信封组装只在这里定义一次,两个写库点各调它一次。

+ */ + public void answerTombstoned(Answer answer) { + publish(answer, "tombstoned"); + } + + // ==================== 内部 ==================== + + private void publish(Answer answer, String action) { + if (!enabled() || answer == null) { + return; + } + // 事件内容在提交点前就定死(快照字段),afterCommit 只负责"发",不再回读实体—— + // 那时事务已结束,实体可能已被后续操作改写,也可能压根不在同一持久化上下文里 + Envelope envelope = envelopeOf(answer, action); + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + send(envelope, action); + } + }); + } else { + send(envelope, action); + } + } + + private void send(Envelope envelope, String action) { + try { + RabbitTemplate template = templates.getIfAvailable(); + if (template == null) { + log.warn("[MQ] 已配置 RABBITMQ_HOST 但容器里没有 RabbitTemplate,跳过发布(不影响主流程)"); + return; + } + declareExchangeOnce(template); + template.convertAndSend(EXCHANGE, routingKey(envelope.getAppCode(), action), envelope); + } catch (Exception e) { + // 裁决 2:broker 故障绝不阻断提交。消费方以游标拉取为事实源,这条事件丢了由下一轮增量补回 + log.warn("[MQ] 事件发布失败(忽略,拉取兜底):type={} answer={} cause={}", + envelope.getType(), envelope.getAnswerUuid(), e.toString()); + } + } + + private void declareExchangeOnce(RabbitTemplate template) { + if (!exchangeDeclared.compareAndSet(false, true)) { + return; + } + try { + template.execute(channel -> { + channel.exchangeDeclare(EXCHANGE, "topic", true); + return null; + }); + } catch (Exception e) { + // 声明失败要允许下次再试(broker 抖动一次就永久静默是不可接受的) + exchangeDeclared.set(false); + throw e; + } + } + + /** routing key:{@code fc.answer.{app_code}.{created|updated|tombstoned}}(§6.2 队列绑定约定) */ + public static String routingKey(String appCode, String action) { + return "fc.answer." + appCode + "." + action; + } + private Envelope envelopeOf(Answer answer, String action) { + Envelope envelope = new Envelope(); + envelope.setEventId(UUID.randomUUID().toString()); + envelope.setType("fc.answer." + action); + envelope.setOccurredAt(OffsetDateTime.now(ZONE).format(OCCURRED_AT)); + envelope.setAppCode(answer.getAppCode()); + envelope.setSchId(answer.getSchId()); + envelope.setFormUuid(answer.getFormUuid()); + envelope.setFormCode(answer.getCode()); + envelope.setAnswerUuid(answer.getUuid()); + envelope.setVersion(answer.getVersion()); + envelope.setAnswerNo(answer.getAnswerNo()); + return envelope; + } + + /** + * 消息信封(§6.2 字段闭集,V63:多一个字段就该被当成违约,所以这里逐字段标 + * {@link JsonProperty} 而不是靠命名策略——snake_case 是契约原文,不是本类偏好)。 + */ + @Getter + @Setter + public static class Envelope { + + @JsonProperty("event_id") + private String eventId; + + @JsonProperty("type") + private String type; + + @JsonProperty("occurred_at") + private String occurredAt; + + @JsonProperty("app_code") + private String appCode; + + @JsonProperty("sch_id") + private Long schId; + + @JsonProperty("form_uuid") + private String formUuid; + + @JsonProperty("form_code") + private String formCode; + + @JsonProperty("answer_uuid") + private String answerUuid; + + @JsonProperty("version") + private Integer version; + + @JsonProperty("answer_no") + private String answerNo; + } +} diff --git a/src/main/java/com/yunhai/formcenter/service/OapiFormService.java b/src/main/java/com/yunhai/formcenter/service/OapiFormService.java index 009733e..60f02db 100644 --- a/src/main/java/com/yunhai/formcenter/service/OapiFormService.java +++ b/src/main/java/com/yunhai/formcenter/service/OapiFormService.java @@ -12,8 +12,15 @@ import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + /** * 一期对外读接口服务(契约 §9 两端点)。 * @@ -160,4 +167,183 @@ public class OapiFormService { throw new BizException(ErrorCode.PARAM_ERROR, "schId 必填(调用方声明的租户域,≥0)"); } } + + // ==================== v2 拉取端点(契约 §6.1/C9/C11,T17) ==================== + + /** C9:默认 500、上限 2000(超限直接拒而不是静默收窄——调用方要对自己的页大小有确定预期) */ + private static final int DEFAULT_LIMIT = 500; + private static final int MAX_LIMIT = 2000; + + /** + * 游标与行内时间的线格式:{@code yyyy-MM-dd'T'HH:mm:ss}(ISO8601 本地时间、**整秒**,P126)。 + * + *

刻意不用 {@code ISO_LOCAL_DATE_TIME}(它会在有亚秒时把纳秒一起打出来): + * 列侧是 {@code DATETIME}(秒精度,t3/t10 建表实态),带纳秒的游标与库内值永远比不出等值, + * 于是"续拉"会既可能重复投递也可能漏行。更关键的是 Hibernate 的一级缓存会让同一事务里 + * 刚写完再读的那一行拿到内存对象(带纳秒)而不是列里取整后的值——纳秒游标在测试与真实 + * 的同事务重读场景下都不成立。

+ * + *

降到整秒的取舍方向是宁可重复投递也不漏:窗口因此可能把"游标那一秒"的行再给一次, + * 这正是 C9 允许的 at-least-once(消费方按 {@code answer_uuid} 幂等 upsert 吸收); + * 反过来若按秒截断把窗口收窄,同一秒内的变更就会被永久跳过(那才是不可恢复的)。

+ */ + private static final DateTimeFormatter CURSOR_TS = + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + + /** 首拉的时间下界(必然早于任何一行),配 uuid 空串=复合键的全域最小 */ + private static final LocalDateTime PULL_FROM_BEGINNING = LocalDateTime.of(1970, 1, 1, 0, 0, 0); + + /** + * 增量拉取答卷({@code GET /oapi/form/answers}):事实源,MQ 只是降延迟的旁路通知。 + * + *

口径三件:

+ *
    + *
  • 按冻结状态二分增量口径(§6.1)——冻结表单 id 升序 append-only(墓碑照常下发, + * 游标是 {@code answer_uuid},服务端按 uk 换算成库内 id,id 绝不出响应,V53); + * 非冻结表单走 {@code updated_since} + 复合键 {@code (update_time, answer_uuid)}。 + * 两种游标不接受混用:按字面判给另一个口径会静默给出一个"看起来正常"的空窗口, + * 那比报错更难发现(这里给可诊断的拒绝,指出这张表要哪种游标);
  • + *
  • 域校验照做、软删表单照读:{@code form_uuid} 过 {@code readDomainForm}(本校 ∪ 平台级), + * 他校与不存在同一句"表单不存在"(V54);生命周期与 is_deleted 不作读条件(§5.4"停用只断填报入口"、 + * P32"历史数据溯源不能断");行集另带 {@code sch_id} 这条 oapi 面既有不变式—— + * 应用签名不替代谁能看,本层只保证不跨校取数;
  • + *
  • 重放安全(C9):同游标重复请求返回同一窗口;{@code nextCursor} 只在"这一页被填满"时给出, + * 末尾可能多要一次空页,这比"少要一次"(漏数据)可接受。
  • + *
+ */ + public OapiModels.AnswersView answers(Long schId, String formUuid, String after, String updatedSince, + Integer limit) { + requireSchId(schId); + int size = requireLimit(limit); + Form form = defService.readDomainForm(schId, formUuid); + Pageable page = PageRequest.of(0, size); + boolean frozen = Integer.valueOf(1).equals(form.getFrozen()); + + if (frozen) { + if (notBlank(updatedSince)) { + throw new BizException(ErrorCode.PARAM_ERROR, + "表单已开启提交后禁编辑(append-only),增量游标是 after=answer_uuid,不接受 updated_since:" + + form.getAppCode() + "/" + form.getCode()); + } + Long cursor = 0L; + if (notBlank(after)) { + cursor = cursorIdOf(schId, form, after.trim()); + } + List rows = answerRepository.findPullAfterId(form.getUuid(), schId, cursor, page); + OapiModels.AnswersView view = toAnswers(rows); + view.setNextCursor(rows.size() == size ? rows.get(rows.size() - 1).getUuid() : null); + return view; + } + + if (notBlank(after)) { + throw new BizException(ErrorCode.PARAM_ERROR, + "表单未开启禁编辑(可编辑即有 updated 增量),增量游标是 updated_since=ISO8601,不接受 after:" + + form.getAppCode() + "/" + form.getCode()); + } + Cursor cursor = cursorOf(updatedSince); + List rows = answerRepository.findPullUpdatedSince(form.getUuid(), schId, cursor.time, + cursor.answerUuid, page); + OapiModels.AnswersView view = toAnswers(rows); + Answer last = rows.isEmpty() ? null : rows.get(rows.size() - 1); + view.setNextCursor(rows.size() == size && last != null + ? CURSOR_TS.format(last.getUpdateTime()) + "|" + last.getUuid() : null); + return view; + } + + /** 冻结表单的游标换算(对外只有 uuid,id 是库内坐标);游标本身必须属于这张表,否则窗口起点无定义 */ + private Long cursorIdOf(Long schId, Form form, String answerUuid) { + Answer marker = answerRepository.findByUuidAndSchId(answerUuid, schId).orElse(null); + if (marker == null || !form.getUuid().equals(marker.getFormUuid())) { + throw new BizException(ErrorCode.PARAM_ERROR, + "游标答卷不存在或不属于该表单:after=" + answerUuid); + } + return marker.getId(); + } + + private static OapiModels.AnswersView toAnswers(List rows) { + List items = new ArrayList<>(); + for (Answer row : rows) { + items.add(toPullRow(row)); + } + OapiModels.AnswersView view = new OapiModels.AnswersView(); + view.setRows(items); + return view; + } + + /** 行字段闭集(§6.1):无库内 id;{@code meta} 只有匿名行有值(C12),登录行两项都是 null */ + private static OapiModels.AnswerRow toPullRow(Answer answer) { + OapiModels.AnswerRowMeta meta = new OapiModels.AnswerRowMeta(); + meta.setIp(answer.getAnonIp()); + meta.setUa(answer.getAnonUa()); + + OapiModels.AnswerRow row = new OapiModels.AnswerRow(); + row.setAnswerUuid(answer.getUuid()); + row.setSchId(answer.getSchId()); + row.setAppCode(answer.getAppCode()); + row.setFormUuid(answer.getFormUuid()); + row.setFormCode(answer.getCode()); + row.setVersion(answer.getVersion()); + row.setAnswerNo(answer.getAnswerNo()); + row.setSubmitterId(answer.getSubmitterId()); + row.setSubjectId(answer.getSubjectId()); + row.setStatus(answer.getStatus()); + row.setValues(FormSchemaValidator.parse(answer.getValuesJson(), "答卷值文档损坏")); + row.setMeta(meta); + row.setCreateTime(answer.getCreateTime() == null ? null : CURSOR_TS.format(answer.getCreateTime())); + row.setUpdateTime(answer.getUpdateTime() == null ? null : CURSOR_TS.format(answer.getUpdateTime())); + row.setIsDeleted(answer.getIsDeleted()); + return row; + } + + private static int requireLimit(Integer limit) { + if (limit == null) { + return DEFAULT_LIMIT; + } + if (limit < 1 || limit > MAX_LIMIT) { + throw new BizException(ErrorCode.PARAM_ERROR, "limit 需在 1~" + MAX_LIMIT + " 之间(默认 " + DEFAULT_LIMIT + ")"); + } + return limit; + } + + /** + * updated 形态的游标解析(C9)。 + * + *

首拉=裸 {@code }(或干脆不给=从最早一行起算);续拉必须能带回复合键的第二段 + * {@code |}——否则同一时刻的并列行要么被跳过、要么被无限重放 + * (落地差额 P126:C9 只写了"复合推进键",没写游标的线格式)。 + * 输入兼容空格分隔符(本仓其它时间串的形状),输出恒为 ISO 的 {@code T} 形态。

+ */ + private static Cursor cursorOf(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return new Cursor(PULL_FROM_BEGINNING, ""); + } + String clean = raw.trim(); + int bar = clean.indexOf('|'); + String timePart = bar < 0 ? clean : clean.substring(0, bar).trim(); + String uuidPart = bar < 0 ? "" : clean.substring(bar + 1).trim(); + LocalDateTime time; + try { + time = LocalDateTime.parse(timePart.replace(' ', 'T')); + } catch (RuntimeException e) { + throw new BizException(ErrorCode.PARAM_ERROR, + "updated_since 需为 ISO8601 本地时间 yyyy-MM-ddTHH:mm:ss(可带 |answer_uuid 续拉):" + raw); + } + // 消费方即使自己带了纳秒过来也先抹平(列精度只到秒,见 CURSOR_TS 注释那条) + return new Cursor(time.withNano(0), uuidPart); + } + + private static boolean notBlank(String raw) { + return raw != null && !raw.trim().isEmpty(); + } + + /** updated 游标的两段({@code answer_uuid} 空串=该时刻从头开始) */ + private static final class Cursor { + private final LocalDateTime time; + private final String answerUuid; + + private Cursor(LocalDateTime time, String answerUuid) { + this.time = time; + this.answerUuid = answerUuid; + } + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 84b54da..9b386fe 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -39,6 +39,20 @@ spring: multipart: max-file-size: 50MB max-request-size: 50MB + # ===== MQ 事件推送(契约 v2 §6.2,T17):全部键都从 RABBITMQ_* env 现读 ===== + # 本地不启 broker:host 留空 → FcEventPublisher 判定"未配置"→ 发布整体 no-op(启动一条日志说明), + # 服务照跑、提交链路照绿(V64)。**不在本文件写任何生产 host/账号**:projman 按环境注入 + # RABBITMQ_HOST/PORT/USER/PASS/VHOST,哪天容器就绪哪天开,不阻塞任何批次(裁决 3「可选依赖」)。 + rabbitmq: + host: ${RABBITMQ_HOST:} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USER:} + password: ${RABBITMQ_PASS:} + virtual-host: ${RABBITMQ_VHOST:} + # 关掉 Boot 的 RabbitAdmin(它在启动时就拨号声明拓扑:未配 broker 的环境会反复连接失败, + # 实测把上下文启动拖到分钟级——"可选依赖"就名存实亡)。拓扑声明由 FcEventPublisher + # 自己惰性做一次(exchangeDeclare durable topic,第一次真要发的时候),不依赖 admin。 + dynamic: false # ===== 文件上传存储路径 ===== # 教训注释自 notice/service application.yml 原样抄入(2026-10 事故): @@ -123,3 +137,7 @@ management: # 引 data-redis 仅服务于 trust-registry 密钥链;关健康指标防 Redis 停摆拉 DOWN /actuator/health # (组合链 trusted-apps 先行兜底,Redis 不可用时验签仍确定性工作) enabled: false + rabbit: + # 同上一条先例(契约 v2 §6.2 裁决 3/V64):amqp 依赖是可选通道,RABBITMQ_HOST 未注入时 + # 本地/未接 MQ 的环境不该被健康探针拖成 DOWN——发布器自身就是"未配置即 no-op"。 + enabled: false diff --git a/src/test/java/com/yunhai/formcenter/service/FcEventPublisherTest.java b/src/test/java/com/yunhai/formcenter/service/FcEventPublisherTest.java new file mode 100644 index 0000000..baec9d6 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/FcEventPublisherTest.java @@ -0,0 +1,151 @@ +package com.yunhai.formcenter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.formcenter.entity.Answer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatchers; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 事件发布器(契约 v2 §6.2,T17,验收 V62/V63)。 + * + *

用 Mockito 顶掉 {@code RabbitTemplate}——本机不起 broker(P-D 是非阻塞运维项), + * 所以这里断言的全是"发布器自己的四条纪律",不是"消息真的到了队列":

+ *
    + *
  1. 信封字段闭集(V63):十字段,多一个即 FAIL——值文档绝不进总线是裁决 1, + * 这条必须机械判,靠 code review 拦不住"顺手加个 values";
  2. + *
  3. 未配置即 no-op(裁决 3):{@code RABBITMQ_HOST} 空 → 一个模板方法都不碰;
  4. + *
  5. 事务提交后才发(§6.2):活动事务里注册 afterCommit,回滚的那次不留事件;
  6. + *
  7. 发布失败只 warn 不抛(裁决 2):broker 抖动不能把提交链路带倒。
  8. + *
+ */ +class FcEventPublisherTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + @DisplayName("V63:信封是 §6.2 的十字段闭集(不含值文档、不含任何身份注入位)") + void envelopeIsTheClosedTenFieldSet() { + FcEventPublisher.Envelope envelope = envelopeOf(answer()); + List keys = new ArrayList<>(); + MAPPER.valueToTree(envelope).fieldNames().forEachRemaining(keys::add); + + assertThat(keys).containsExactlyInAnyOrder("event_id", "type", "occurred_at", "app_code", "sch_id", + "form_uuid", "form_code", "answer_uuid", "version", "answer_no"); + // 值文档不进消息(裁决 1:MQ 是通知通道,不是数据通道) + assertThat(keys).noneMatch(k -> k.equals("values") || k.equals("values_json")); + JsonNode json = MAPPER.valueToTree(envelope); + assertThat(json.get("type").asText()).isEqualTo("fc.answer.created"); + assertThat(json.get("occurred_at").asText()).contains("+08:00"); + } + + @Test + @DisplayName("routing key = fc.answer.{app_code}.{action}(消费方按本应用段绑定,§6.2 队列约定)") + void routingKeyCarriesTheAppSegment() { + assertThat(FcEventPublisher.routingKey("affair-lib", "created")).isEqualTo("fc.answer.affair-lib.created"); + assertThat(FcEventPublisher.routingKey("honor", "tombstoned")).isEqualTo("fc.answer.honor.tombstoned"); + assertThat(FcEventPublisher.EXCHANGE).isEqualTo("fc.events.v1"); + } + + @Test + @DisplayName("裁决 3:RABBITMQ_HOST 未注入=整体禁用,三个发布点一个都不碰模板(服务照跑)") + void disabledWithoutHostTouchesNothing() { + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + FcEventPublisher publisher = new FcEventPublisher(provider); + ReflectionTestUtils.setField(publisher, "host", ""); + + assertThat(publisher.enabled()).isFalse(); + publisher.answerCreated(answer()); + publisher.answerUpdated(answer()); + publisher.answerTombstoned(answer()); + verifyNoInteractions(provider); + } + + @Test + @DisplayName("§6.2 发布时机:活动事务里注册 afterCommit,提交前一条都不发(回滚的那次不能留下事件)") + void publishWaitsForCommit() { + RabbitTemplate template = mock(RabbitTemplate.class); + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(template); + FcEventPublisher publisher = new FcEventPublisher(provider); + ReflectionTestUtils.setField(publisher, "host", "localhost"); + + TransactionSynchronizationManager.initSynchronization(); + try { + publisher.answerCreated(answer()); + verify(template, never()).convertAndSend(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), + ArgumentMatchers.any(Object.class)); + + for (TransactionSynchronization sync + : TransactionSynchronizationManager.getSynchronizations()) { + sync.afterCommit(); + } + verify(template).convertAndSend(ArgumentMatchers.eq(FcEventPublisher.EXCHANGE), + ArgumentMatchers.eq("fc.answer.demo.created"), + ArgumentMatchers.any(FcEventPublisher.Envelope.class)); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + @DisplayName("裁决 2:broker 故障只 warn 不抛——提交链路不能被事件发布带倒") + void sendFailureIsSwallowed() { + RabbitTemplate template = mock(RabbitTemplate.class); + doThrow(new IllegalStateException("broker down")).when(template) + .convertAndSend(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(), + ArgumentMatchers.any(Object.class)); + @SuppressWarnings("unchecked") + ObjectProvider provider = mock(ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(template); + FcEventPublisher publisher = new FcEventPublisher(provider); + ReflectionTestUtils.setField(publisher, "host", "localhost"); + + // 无事务上下文时就地发布(同步器未激活的那条分支) + assertThatCode(() -> publisher.answerTombstoned(answer())).doesNotThrowAnyException(); + } + + // ==================== 脚手架 ==================== + + private static Answer answer() { + Answer answer = new Answer(); + answer.setUuid("a1b2c3d4e5f60718293a4b5c6d7e8f90"); + answer.setFormUuid("f0e1d2c3b4a5968778695a4b3c2d1e0f"); + answer.setSchId(3L); + answer.setAppCode("demo"); + answer.setCode("honor_apply"); + answer.setVersion(4); + answer.setAnswerNo("honor_apply-20261021-0007"); + return answer; + } + + /** + * 走真实组装链路取信封({@code envelopeOf} 是私有方法,反射拿—— + * 把它改成 public 就为了给测试多一个对外面,不值得)。 + */ + private static FcEventPublisher.Envelope envelopeOf(Answer answer) { + FcEventPublisher publisher = new FcEventPublisher(mock(ObjectProvider.class)); + ReflectionTestUtils.setField(publisher, "host", "localhost"); + return (FcEventPublisher.Envelope) ReflectionTestUtils.invokeMethod(publisher, "envelopeOf", answer, "created"); + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/MqDisabledContextIT.java b/src/test/java/com/yunhai/formcenter/service/MqDisabledContextIT.java new file mode 100644 index 0000000..283081e --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/MqDisabledContextIT.java @@ -0,0 +1,54 @@ +package com.yunhai.formcenter.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * V64:MQ 是可选依赖——本机不起 broker(P-D 是运维侧非阻塞项), + * 所以这条 IT 断言的是"引了 amqp 依赖也没有把应用拖下水": + * + *
    + *
  • 上下文能起来({@code RabbitAutoConfiguration} 的 ConnectionFactory 是惰性连接,不在启动时拨号);
  • + *
  • 发布器判据 {@code enabled()=false}(唯一判据=宿主有没有注入 {@code RABBITMQ_HOST});
  • + *
  • {@code /actuator/health} 仍是 UP——这是配套 yml 里 + * {@code management.health.rabbit.enabled=false} 的存在理由:不关掉这个探针, + * 没接 MQ 的每个环境都会因为拉取不到 broker 而整体 DOWN, + * 而"可选依赖"的语义就变成"必须部署"(照 data-redis 那条先例的同一步处理,注释就在 yml 里)。
  • + *
+ * + *

提交链路在无 broker 下全绿由 {@code OapiAnswersIT}/{@code AnswerSubmitIT} 等整批 IT 共同承担 + * (它们跑的就是"发布器存在但禁用"的上下文),这里只补健康面那半边。

+ */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(AnswerTestSupport.TestGatewayConfiguration.class) +@DisplayName("可选依赖 - 无 broker 启动:发布器禁用且健康探针不受拖累(V64)") +class MqDisabledContextIT { + + @Autowired + private FcEventPublisher publisher; + + @Autowired + private TestRestTemplate rest; + + @Test + @DisplayName("未注入 RABBITMQ_HOST → enabled()=false,发布是 no-op(不抛、不阻塞)") + void publisherIsDisabledWithoutBrokerConfiguration() { + assertThat(publisher.enabled()).isFalse(); + } + + @Test + @DisplayName("GET /actuator/health 仍是 UP(rabbit 健康指标已按 yml 关闭)") + void healthStaysUpWithoutBroker() { + ResponseEntity response = rest.getForEntity("/actuator/health", java.util.Map.class); + assertThat(response.getStatusCodeValue()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(String.valueOf(response.getBody().get("status"))).isEqualTo("UP"); + } +} diff --git a/src/test/java/com/yunhai/formcenter/service/OapiAnswersIT.java b/src/test/java/com/yunhai/formcenter/service/OapiAnswersIT.java new file mode 100644 index 0000000..74f93b5 --- /dev/null +++ b/src/test/java/com/yunhai/formcenter/service/OapiAnswersIT.java @@ -0,0 +1,295 @@ +package com.yunhai.formcenter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.formcenter.model.AnonModels; +import com.yunhai.formcenter.model.AnswerModels; +import com.yunhai.formcenter.model.FormModels; +import com.yunhai.formcenter.model.OapiModels; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +/** + * v2 增量拉取(契约 §6.1/C9/C11/C12/C13,T17,验收 V53/V70)。 + * + *

这个端点是消费方的事实源(MQ 只降延迟),所以断言的重心全在"不漏不重不越权":

+ *
    + *
  • 行字段闭集且没有库内 id(V53)——序列化成 JSON 后按键集判,多一个字段即 FAIL;
  • + *
  • 非冻结走 {@code updated_since} + 复合键、冻结走 {@code after},两种游标不许混用;
  • + *
  • 墓碑必须能经 updated 窗口到达(V70/A6 语义)——这条是给"将来有人把软删改成 bulk UPDATE" + * 准备的闸:bulk 不触发 {@code @PreUpdate},{@code update_time} 不前移,消费方就永远看不到删除;
  • + *
  • 他校 uuid 与不存在的 uuid 同一条"表单不存在"(V54),行集恒带 {@code sch_id};
  • + *
  • 匿名行给 {@code meta.ip/ua}、登录行给 null(C12),且 {@code submitter_id=0} 即匿名(C8)。
  • + *
+ */ +@SpringBootTest +@Import(AnswerTestSupport.TestGatewayConfiguration.class) +@Transactional +@DisplayName("对外拉取 - /oapi/form/answers(C9/C11/C12 + V53/V70)") +class OapiAnswersIT extends AnswerTestSupport { + + /** §6.1 行样例的键集(闭集,V53:这里没有 id) */ + private static final List ROW_FIELDS = java.util.Arrays.asList("answer_uuid", "sch_id", "app_code", + "form_uuid", "form_code", "version", "answer_no", "submitter_id", "subject_id", "status", + "values", "meta", "create_time", "update_time", "is_deleted"); + + private static final String SCHEMA = "{\"groups\":[{\"key\":\"base\",\"label\":\"基本信息\",\"fields\":[" + + "{\"key\":\"name\",\"label\":\"姓名\",\"type\":\"text\",\"required\":true}]}]}"; + + private static final String VALUES = "{\"name\":\"张三\"}"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Autowired + private OapiFormService oapiService; + + @Autowired + private javax.persistence.EntityManager entityManager; + + // ==================== 闭集与两档口径 ==================== + + @Test + @DisplayName("V53:行字段是 §6.1 的闭集且不含库内 id;时间给 ISO8601,未填满一页时 nextCursor=null") + void rowFieldsAreTheClosedSetWithoutDatabaseId() { + String code = "v17_pull_shape"; + String uuid = publishForm(code, SCHEMA); + answerService.submit(user(20267001, SCH, 3), submit(code, VALUES)); + answerService.submit(user(20267002, SCH, 3), submit(code, VALUES)); + + OapiModels.AnswersView page = oapiService.answers(SCH, uuid, null, null, null); + assertThat(page.getRows()).hasSize(2); + assertThat(page.getNextCursor()).isNull(); + assertThat(keysOf(page.getRows().get(0))).containsExactlyInAnyOrderElementsOf(ROW_FIELDS); + // 登录行不给留痕(C12):meta 这个键在,里面两项是 null + JsonNode row = MAPPER.valueToTree(page.getRows().get(0)); + assertThat(row.get("meta").get("ip").isNull()).isTrue(); + assertThat(row.get("meta").get("ua").isNull()).isTrue(); + assertThat(row.get("submitter_id").asInt()).isNotZero(); + assertThat(row.get("is_deleted").asInt()).isZero(); + // 时间是可回解析的 ISO 本地串(游标用的就是同一条线格式) + assertThat(LocalDateTime.parse(row.get("create_time").asText())).isNotNull(); + assertThat(row.get("values").get("name").asText()).isEqualTo("张三"); + } + + @Test + @DisplayName("C9 口径二分:冻结只认 after,非冻结只认 updated_since(混用被点名拒绝,不静默给空窗口)") + void cursorKindsAreNotInterchangeable() { + String open = publishForm("v17_pull_open", SCHEMA); + String frozenCode = "v17_pull_frozen"; + String frozen = publishForm(frozenCode, SCHEMA); + freeze(frozen); + + assertThatThrownBy(() -> oapiService.answers(SCH, open, "any-uuid", null, null)) + .isInstanceOf(BizException.class).hasMessageContaining("不接受 after"); + assertThatThrownBy(() -> oapiService.answers(SCH, frozen, null, "2026-10-20T10:00:00", null)) + .isInstanceOf(BizException.class).hasMessageContaining("不接受 updated_since"); + } + + @Test + @DisplayName("C9:limit 默认 500、上限 2000(越界拒,不静默收窄)") + void limitIsBoundedNotClamped() { + String uuid = publishForm("v17_pull_limit", SCHEMA); + assertThatThrownBy(() -> oapiService.answers(SCH, uuid, null, null, 0)) + .isInstanceOf(BizException.class).hasMessageContaining("1~2000"); + assertThatThrownBy(() -> oapiService.answers(SCH, uuid, null, null, 2001)) + .isInstanceOf(BizException.class).hasMessageContaining("1~2000"); + assertThat(oapiService.answers(SCH, uuid, null, null, null).getRows()).isEmpty(); + } + + // ==================== 冻结:after 游标(append-only + 墓碑可见) ==================== + + @Test + @DisplayName("C9/C11:冻结表单按 id 升序翻页,游标是 answer_uuid(库内 id 不外露),软删行照常下发") + void frozenFormPagesByUuidCursorAndShowsTombstones() { + String code = "v17_pull_frozen_page"; + String uuid = publishForm(code, SCHEMA); + freeze(uuid); + String first = answerService.submit(user(20267011, SCH, 3), submit(code, VALUES)).getAnswerId(); + String second = answerService.submit(user(20267012, SCH, 3), submit(code, VALUES)).getAnswerId(); + String third = answerService.submit(user(20267013, SCH, 3), submit(code, VALUES)).getAnswerId(); + + OapiModels.AnswersView page1 = oapiService.answers(SCH, uuid, null, null, 2); + assertThat(page1.getRows()).hasSize(2); + assertThat(page1.getRows().get(0).getAnswerUuid()).isEqualTo(first); + assertThat(page1.getNextCursor()).isEqualTo(second); + + OapiModels.AnswersView page2 = oapiService.answers(SCH, uuid, page1.getNextCursor(), null, 2); + assertThat(page2.getRows()).hasSize(1); + assertThat(page2.getRows().get(0).getAnswerUuid()).isEqualTo(third); + assertThat(page2.getNextCursor()).isNull(); + + // 同游标重放=同窗口(C9 重放安全,消费方可以随便重试) + assertThat(uuids(oapiService.answers(SCH, uuid, page1.getNextCursor(), null, 2).getRows())) + .isEqualTo(uuids(page2.getRows())); + + // 墓碑:软删之后 append-only 窗口里这行还在,只是 is_deleted=1(C11) + adminService.softDelete(manager(), SCH, third); + List afterDelete = oapiService.answers(SCH, uuid, second, null, 10).getRows(); + assertThat(afterDelete).hasSize(1); + assertThat(afterDelete.get(0).getIsDeleted()).isEqualTo(1); + } + + // ==================== 非冻结:updated 窗口(V70 的落点) ==================== + + @Test + @DisplayName("V70/C11:软删走实体 save → update_time 前移 → 墓碑经 updated 窗口到达(防将来改成 bulk UPDATE)") + void softDeleteMovesUpdateTimeSoTheTombstoneIsPullable() throws InterruptedException { + String code = "v17_pull_tombstone"; + String uuid = publishForm(code, SCHEMA); + String kept = answerService.submit(user(20267021, SCH, 3), submit(code, VALUES)).getAnswerId(); + String doomed = answerService.submit(user(20267022, SCH, 3), submit(code, VALUES)).getAnswerId(); + flushAndClear(); + + OapiModels.AnswersView beforeDelete = oapiService.answers(SCH, uuid, null, null, 50); + assertThat(beforeDelete.getRows()).hasSize(2); + String cursor = cursorOf(beforeDelete.getRows().get(1)); + // update_time 是秒粒度(t2/t10 建表就是 DATETIME 不带小数秒),同秒内的删除不会让时间前移—— + // 跨一秒再删,V70 这条"墓碑经 updated 窗口到达"才判得动(消费方侧的同一事实见报告 P127) + Thread.sleep(1100L); + + adminService.softDelete(manager(), SCH, doomed); + flushAndClear(); + + List window = oapiService.answers(SCH, uuid, null, cursor, 50).getRows(); + assertThat(window).extracting(OapiModels.AnswerRow::getAnswerUuid).containsExactly(doomed); + assertThat(window.get(0).getIsDeleted()).isEqualTo(1); + // 同游标重放=同窗口(C9 重放安全,消费方可以放心重试) + assertThat(uuids(oapiService.answers(SCH, uuid, null, cursor, 50).getRows())).isEqualTo(uuids(window)); + // 越过墓碑之后再拉,墓碑不会被重复投递(窗口严格大于游标)。 + // 这里刻意不断言"必须为空":一行被重复投递并不违反 C9(at-least-once 由消费方幂等 upsert 吸收), + // 真正不能接受的是"删除看不见"和"同一行反复回来"——秒粒度下后者才是会出现的现象(P127) + assertThat(uuids(oapiService.answers(SCH, uuid, null, cursorOf(window.get(0)), 50).getRows())) + .doesNotContain(doomed); + assertThat(kept).isNotEmpty(); + } + + @Test + @DisplayName("C9:updated_since 的续拉带 |answer_uuid(同一时刻的并列行不跳不重),格式非法给可诊断拒绝") + void compositeCursorCarriesTheUuidTail() { + String code = "v17_pull_composite"; + String uuid = publishForm(code, SCHEMA); + answerService.submit(user(20267031, SCH, 3), submit(code, VALUES)); + answerService.submit(user(20267032, SCH, 3), submit(code, VALUES)); + // 关键一步:把本事务里刚写完的实体从一级缓存里清掉,让后面的读取真正回到列上取整后的值。 + // 不清的话,同一事务内读到的还是内存里那个带纳秒的对象,游标就比列里的值"细", + // 秒精度列(DATETIME)与它的比较会出现重复投递——那是测试形态才有的假象(生产上拉取是独立事务) + flushAndClear(); + + // 前置事实:这张表确实有两行(下面"翻页不重不漏"的断言全靠它) + assertThat(uuids(oapiService.answers(SCH, uuid, null, null, 50).getRows())).hasSize(2); + + String first = oapiService.answers(SCH, uuid, null, null, 1).getNextCursor(); + assertThat(first).contains("|"); + List secondPage = uuids(oapiService.answers(SCH, uuid, null, first, 1).getRows()); + List replay = uuids(oapiService.answers(SCH, uuid, null, first, 1).getRows()); + assertThat(secondPage).hasSize(1); + assertThat(replay).isEqualTo(secondPage); + + // 空格分隔的本地时间也收(本仓其它时间串的既有形状,解析时归一为 T):给一个远早于本轮造数的时刻=全量 + assertThat(oapiService.answers(SCH, uuid, null, "2000-01-01 10:00:00", 10).getRows()).hasSize(2); + assertThatThrownBy(() -> oapiService.answers(SCH, uuid, null, "2026/10/20 10:00:00", 10)) + .isInstanceOf(BizException.class).hasMessageContaining("ISO8601"); + } + + // ==================== 域与留痕 ==================== + + @Test + @DisplayName("V54:他校 uuid 与不存在的 uuid 同一条\"表单不存在\";游标不属这张表时点名拒绝") + void foreignAndUnknownFormsAnswerIdentically() { + String uuid = publishForm("v17_pull_domain", SCHEMA); + + BizException foreign = catchThrowableOfType( + () -> oapiService.answers(OTHER_SCH, uuid, null, null, null), BizException.class); + BizException missing = catchThrowableOfType( + () -> oapiService.answers(SCH, "00000000000000000000000000000000", null, null, null), BizException.class); + // 同一条文面:都是「表单不存在:」+ 调用方自己给的那个 uuid。回显输入不构成判定,也不给存在性 oracle + // ——把 uuid 拼进 msg 是既有 readDomainForm 的口径(三个 oapi 端点共用),这里钉住"没有第二种说法" + assertThat(foreign.getMessage()).startsWith("表单不存在:").contains(uuid); + assertThat(missing.getMessage()).startsWith("表单不存在:"); + assertThat(foreign.getMessage()).doesNotContain("无权", "已删除", "停用", "未发布"); + + String answerUuid = answerService.submit(user(20267041, SCH, 3), submit("v17_pull_domain", VALUES)).getAnswerId(); + // after 游标只在冻结表单那条口径上被解析(非冻结传 after 先被口径二分拒),所以这里造一张冻结表 + String otherForm = publishForm("v17_pull_other_form", SCHEMA); + freeze(otherForm); + assertThatThrownBy(() -> oapiService.answers(SCH, otherForm, answerUuid, null, 10)) + .isInstanceOf(BizException.class).hasMessageContaining("游标答卷不存在或不属于该表单"); + } + + @Test + @DisplayName("C8/C12:匿名行 submitter_id=0、subject_id=null,meta.ip/ua 有值(登录行为 null)") + void anonymousRowCarriesTraceInMetaOnly() { + String code = "v17_pull_anon"; + String uuid = publishForm(code, SCHEMA); + FormModels.FormConfigRequest cfg = new FormModels.FormConfigRequest(); + cfg.setUuid(uuid); + cfg.setAudience(java.util.Collections.singletonList("ANONYMOUS")); + defService.updateConfig(SCH, cfg); + + AnonModels.SubmitRequest req = new AnonModels.SubmitRequest(); + req.setFormCode(code); + req.setIdempotencyKey(newIdemKey()); + req.setValues(MAPPER.valueToTree(java.util.Collections.singletonMap("name", "匿名者"))); + answerService.submitAnon(req, new AnonModels.Context("203.0.113.77", "JUnit UA", 0L)); + + List rows = oapiService.answers(SCH, uuid, null, null, 10).getRows(); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).getSubmitterId()).isZero(); + assertThat(rows.get(0).getSubjectId()).isNull(); + assertThat(rows.get(0).getMeta().getIp()).isEqualTo("203.0.113.77"); + assertThat(rows.get(0).getMeta().getUa()).isEqualTo("JUnit UA"); + } + + // ==================== 脚手架 ==================== + + /** + * 把本测试事务里写过的实体从一级缓存清掉,后续读取回到列上的真实值。 + * + *

不这样做的话,同事务读到的是刚 set 过、带纳秒的内存对象,而列侧 {@code DATETIME} 只存到秒 + * (MySQL 还会把 {@code .5+} 进位)——游标精度与列精度不一致,增量窗口的比较结果就不是生产形态的结果。

+ */ + private void flushAndClear() { + entityManager.flush(); + entityManager.clear(); + } + + private void freeze(String formUuid) { + FormModels.FormConfigRequest cfg = new FormModels.FormConfigRequest(); + cfg.setUuid(formUuid); + cfg.setFrozen(1); + defService.updateConfig(SCH, cfg); + } + + /** 行 JSON 的键集(闭集断言用;不用命名策略推断,键名就是契约原文) */ + private static List keysOf(OapiModels.AnswerRow row) { + List keys = new ArrayList<>(); + MAPPER.valueToTree(row).fieldNames().forEachRemaining(keys::add); + return keys; + } + + /** 续拉游标:{@code update_time|answer_uuid}(C9 复合推进键的线形态,P126) */ + private static String cursorOf(OapiModels.AnswerRow row) { + return row.getUpdateTime() + "|" + row.getAnswerUuid(); + } + + private static List uuids(List rows) { + List uuids = new ArrayList<>(); + for (OapiModels.AnswerRow row : rows) { + uuids.add(row.getAnswerUuid()); + } + return uuids; + } +}