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)。
+ *
+ * 三条裁决决定了这个类的形状(契约把它们标成"不可协商"):
+ *
+ * - MQ 是通知通道,不是数据通道——信封十字段闭集(V63),不含值文档。值文档只有
+ * 签名验过的拉取端点那一份;把值塞进总线等于把读面从"鉴权端点"扩大到"能连上 broker 的任何人";
+ * - 拉取是事实源,MQ 只降延迟——所以这里不写 outbox、不配死信,发布失败只 warn
+ * (丢的那条由消费方的 5 分钟增量拉取补回,V62 的"同步单向"也因此不会被消息顺序反向污染);
+ * - 可选依赖——{@code RABBITMQ_HOST} 未注入 → 整体 no-op + 启动一条日志,服务照跑(V64;
+ * 照 data-redis 那条先例:引依赖但不引运行期强耦合,配套 yml 关 rabbit 健康探针)。
+ *
+ *
+ * 发布时机=事务提交后({@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 是非阻塞运维项),
+ * 所以这里断言的全是"发布器自己的四条纪律",不是"消息真的到了队列":
+ *
+ * - 信封字段闭集(V63):十字段,多一个即 FAIL——值文档绝不进总线是裁决 1,
+ * 这条必须机械判,靠 code review 拦不住"顺手加个 values";
+ * - 未配置即 no-op(裁决 3):{@code RABBITMQ_HOST} 空 → 一个模板方法都不碰;
+ * - 事务提交后才发(§6.2):活动事务里注册 afterCommit,回滚的那次不留事件;
+ * - 发布失败只 warn 不抛(裁决 2):broker 抖动不能把提交链路带倒。
+ *
+ */
+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;
+ }
+}