12 changed files with 901 additions and 28 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/sh
set -eu
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
TESTS=FcAnswerDeliveryTest,FcDiscoveryAccessTest,FormConsumerGrantPushGateTest,FcReconcileTaskTest,OapiFormControllerTest,FcEventPublisherTest,AnonReplayGuardTest,AppCodeReconcileRoutingTest,OapiSchemaPublishedVersionTest
TESTS=FcAnswerDeliveryTest,FcDiscoveryAccessTest,FormConsumerGrantPushGateTest,FcReconcileTaskTest,OapiFormControllerTest,FcEventPublisherTest,AnonReplayGuardTest,AppCodeReconcileRoutingTest,OapiSchemaPublishedVersionTest,AnswerDeliveryBrokerRaceTest,AnswerDeliverySourceIdentityTest,AnswerDeliveryMqContractTest,FcEventConverterContractTest,FcEventJsonPublishTest
# Surefire 的选择器部分匹配也可能成功;每个指定类必须随源码存在。
oldIFS=$IFS
IFS=,
+46
View File
@@ -0,0 +1,46 @@
# MQ 独立修复契约(2026-10-23,本地候选)
主链已经部署并由用户验收。本补丁只处理 MQ,不改变 UserBase 身份模型、HTTP 收据主链、FL、B5 或对账调度。
代码基线为 FC 核心 SHA 1717f4c47e26918e7f582d0c00ddd23b732ab98a;旧 MQ WIP 保留在原 worktree。
## 身份与路由
- `application_info.id` 是业务应用主身份;`appKey/appSecret` 是签名凭据;`appCode` 只用于部署/寻址以及现有注册查询。
- 兼容现有 v2 协议:`sourceAppKey/consumerAppKey` 仍携带凭据键,不改名、不把业务 id 填进去、不迁移已有业务绑定。
- source 默认引用最终 `yh.app-auth.app-key`;显式 source 与签名键不一致时拒绝。真正发布前还按本应用的部署 code 调已有 `getAppByCode`,核验注册行的正整数 id、appCode、appKey。允许 id != appKey。不可达/错配进入有界失败退避,不记 broker 成功。
- 不使用答卷的资源 `snapshot.appCode` 推断发布者身份。该字段是资源所属应用技术标识,原样保留。
- v2 routing key 固定为 `fc.snapshot.v2.<consumer credential key>.<schoolId>`,不能把应用主键或 appCode 代入。拒绝能扩展 topic 段的通配符/额外分隔符与非正学校号。
- SH 的 binding、信封消费身份与最终签名凭据必须同源;不硬编码为 100008 或 100018。
## 投递状态裁决
| 输入 | 条件 | 结果 |
|---|---|---|
| ACK | 当前代且没有已返回事实 | BROKER_ACCEPTED;不写 consumer_applied |
| 重复 ACK | 当前代已接受 | 时间戳/计数不再改写 |
| NACK / mandatory return | 当前代,包括先前已收到 ACK | FAILED 或预算耗尽 DEAD;清 broker_accepted_at,保留原因并退避 |
| ACK 带 CorrelationData.returned | 独立 return 回调未持久化也一样 | 先按 NO_ROUTE 失败处理,不能写成功 |
| 重复负回调/迟到旧代 ACK | 负回调已推进 retry_count 或授权重放操作已变化 | 幂等忽略,不清错误、不重复扣重试预算 |
| DEAD/GRANT_REVOKED | 任意 broker 回调 | 不复活 |
| 序列化/快照损坏/注册不可达 | 未实际发送 | 失败退避;同 eventId 与不可变快照恢复,不伪造消费成功 |
回调以现有 retry_count 与重放操作键摘要标识代际,读取同一投递行时使用写锁;没有新增库列。
这是至少一次投递,不能声称 broker/数据库的真实并发恰好一次已验证。
发送通道必须配置 correlated confirm、publisher-returns 和 mandatory;缺少返回事实观察能力时不启用扫描发送。
`push_allowed=0` 保留 read/subscribe 数据权,既不建新 v2 outbox 行,也不继续发失去 push 权的旧行。
## N-1 元数据事件
`fc.events.v1` 现在显式序列化为 UTF-8 JSON Message,再调用 RabbitTemplate.send;不更换共享模板的转换器,不影响 v2。
信封仍为十字段闭集;不含 values/附件字节/凭据。messageId 与 event_id 一致,提交后使用冻结的坐标发送。
`fc.events.enabled` 默认 false,且仍须 broker host;仅平台注入 host 不会打开已修好的事件通道。
该元数据通知与 v2 push 授权是两个协议;没有引入新的订阅授权模型。
保持原 best-effort 语义:转换/声明/发送失败不反向破坏答卷提交,不增加 v1 outbox 或自动重放;后续事件可以恢复。v2 才有有界可靠重试台账。
## B-03 及验证边界
B-03 原定义是共享 `/` vhost 与同一 admin 账号,不是转换器问题。独立 vhost、分权账号与 topic 前缀约束仍需环境交付。
FC 会主动 declare 自己的 exchange;SH RabbitAdmin 会声明队列/exchange/DLX/binding。因此权限方案须包含这些实际操作,不能只写“发布方 write”就认为配置完毕。
普通资源权限与 topic binding key 权限须分开核验;依据 [RabbitMQ 官方访问控制表](https://www.rabbitmq.com/docs/access-control),不是本轮 broker 实测结论。
本轮仅本地规则、真实 JSON 转换、Spring 条件装配测试。真实路由、NO_ROUTE、ACL、HMAC身份、DB并发与生产验收均 NOT RUN。
不得开启生产 MQ、修改 push_allowed 或 B-08。请使用独立测试 vhost 完成后续环境验证。
@@ -4,6 +4,8 @@ import com.yunhai.formcenter.entity.AnswerDelivery;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import javax.persistence.LockModeType;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -20,13 +22,20 @@ import java.util.Optional;
*/
public interface AnswerDeliveryRepository extends JpaRepository<AnswerDelivery, Long> {
/** 回调/发送失败按同一行串行裁决,避免 return 与 ACK 各读旧状态后互相覆盖。 */
@Override
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<AnswerDelivery> findById(Long id);
/** 回执端点定位(eventId 全局唯一,另三键做逐字复核) */
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<AnswerDelivery> findByEventId(String eventId);
/** 同 revision 同消费方是否已有行(入队幂等;正常由 uk_delivery_revision 兜底) */
Optional<AnswerDelivery> findByAnswerUuidAndAnswerRevisionAndConsumerAppKeyAndConsumerSchoolId(
String answerUuid, Long answerRevision, String consumerAppKey, Long consumerSchoolId);
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<AnswerDelivery> findByAnswerUuidAndConsumerAppKeyOrderByIdAsc(String answerUuid, String consumerAppKey);
/** 到点待投(PENDING 首投与 FAILED 退避重试同一条队列) */
@@ -1,6 +1,7 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.formcenter.entity.Answer;
import com.yunhai.formcenter.entity.AnswerDelivery;
import com.yunhai.formcenter.model.AnswerDeliveryModels;
@@ -22,6 +23,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageDeliveryMode;
import org.springframework.amqp.core.MessageProperties;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.ObjectProvider;
@@ -38,10 +40,13 @@ import org.springframework.transaction.support.TransactionTemplate;
import javax.annotation.PostConstruct;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
@@ -74,6 +79,7 @@ public class AnswerDeliveryService {
private static final DateTimeFormatter OCCURRED_AT = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
private static final DateTimeFormatter TS = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
private static final String HEADER_DELIVERY_ID = "fc-delivery-id";
private static final String HEADER_DELIVERY_ATTEMPT = "fc-delivery-attempt";
/** 退避序列:base×2^n,封顶 cap(broker 故障时不许热循环) */
private static final List<String> SCANNABLE_STATES =
Arrays.asList(AnswerDelivery.STATE_PENDING, AnswerDelivery.STATE_FAILED);
@@ -87,9 +93,18 @@ public class AnswerDeliveryService {
private final ObjectProvider<RabbitTemplate> templates;
/** 发布方身份(服务端配置;不接受消费方申报,也不从资源 appCode 推) */
@Value("${fc.answer.delivery.source-app-key:${spring.application.name:form-center}}")
@Value("${fc.answer.delivery.source-app-key:${yh.app-auth.app-key:}}")
private String sourceAppKey;
@Value("${yh.app-auth.app-key:}")
private String signingAppKey;
@Autowired
private com.yunhai.platform.openapi.base.OpenApiAppClient appRegistryClient;
@Value("${spring.application.name:form-center}")
private String applicationCode;
@Value("${spring.rabbitmq.host:}")
private String host;
@@ -97,6 +112,12 @@ public class AnswerDeliveryService {
@Value("${spring.rabbitmq.publisher-confirm-type:none}")
private String confirmType;
@Value("${spring.rabbitmq.publisher-returns:false}")
private boolean publisherReturns = true;
@Value("${spring.rabbitmq.template.mandatory:false}")
private boolean mandatory = true;
@Value("${fc.answer.delivery.batch-size:50}")
private int batchSize;
@@ -138,11 +159,15 @@ public class AnswerDeliveryService {
@PostConstruct
public void logState() {
if (isBlank(signingAppKey) || isBlank(sourceAppKey) || !signingAppKey.trim().equals(sourceAppKey.trim())) {
throw new IllegalStateException("fc.answer.delivery.source-app-key 必须等于 yh.app-auth.app-key 签名身份");
}
sourceAppKey = sourceAppKey.trim();
if (!hostConfigured()) {
log.info("[v2投递] 未配置 RABBITMQ_HOST:outbox 仍照常落库并留在 PENDING(不丢事件,B.4)");
return;
}
if (!"correlated".equalsIgnoreCase(confirmType)) {
if (!"correlated".equalsIgnoreCase(confirmType) || !publisherReturns || !mandatory) {
log.error("[v2投递] 已配置 broker 但 spring.rabbitmq.publisher-confirm-type={} —— "
+ "拿不到逐条 confirm 就不发送(宁可积压也不按未确认投递),请置 correlated", confirmType);
return;
@@ -159,6 +184,7 @@ public class AnswerDeliveryService {
/** 投递通道是否可用(broker + confirm 双条件;不满足=只入队不发送) */
public boolean sendEnabled() {
return hostConfigured() && "correlated".equalsIgnoreCase(confirmType)
&& publisherReturns && mandatory
&& templates.getIfAvailable() != null;
}
@@ -340,21 +366,24 @@ public class AnswerDeliveryService {
markFailed(row, "容器内无 RabbitTemplate");
return false;
}
final String publishKey = callbackKey(row);
try {
requireSourceApplication(row);
declareExchangeOnce(template);
byte[] body = objectMapper.writeValueAsString(envelopeOf(row)).getBytes(StandardCharsets.UTF_8);
MessageProperties props = new MessageProperties();
props.setMessageId(row.getEventId());
props.setCorrelationId(String.valueOf(row.getId()));
props.setCorrelationId(publishKey);
props.setHeader(HEADER_DELIVERY_ID, String.valueOf(row.getId()));
props.setHeader(HEADER_DELIVERY_ATTEMPT, publishKey);
props.setDeliveryMode(MessageDeliveryMode.PERSISTENT);
props.setContentType(MessageProperties.CONTENT_TYPE_JSON);
props.setContentEncoding(StandardCharsets.UTF_8.name());
template.send(AnswerDeliveryModels.EXCHANGE, routingKey(row), new Message(body, props),
new CorrelationData(String.valueOf(row.getId())));
new CorrelationData(publishKey));
return true;
} catch (Exception e) {
markFailed(row, e.toString());
markFailed(row, publishKey, e.toString());
log.warn("[v2投递] 发送异常,退避重试:eventId={} cause={}", row.getEventId(), e.toString());
return false;
}
@@ -366,9 +395,28 @@ public class AnswerDeliveryService {
}
public static String routingKey(String consumerAppKey, Long consumerSchoolId) {
if (consumerAppKey == null || !consumerAppKey.matches("[A-Za-z0-9_-]+")
|| consumerSchoolId == null || consumerSchoolId <= 0) {
throw new IllegalArgumentException("INVALID_MQ_ROUTE_COORDINATE");
}
return "fc.snapshot.v2." + consumerAppKey + "." + consumerSchoolId;
}
/** Wire *AppKey fields contain credentials' keys, never application_info.id or resource appCode. */
private void requireSourceApplication(AnswerDelivery row) {
if (isBlank(sourceAppKey) || !sourceAppKey.equals(signingAppKey == null ? null : signingAppKey.trim())
|| !sourceAppKey.equals(row.getSourceAppKey())) {
throw new IllegalStateException("SOURCE_CREDENTIAL_MISMATCH");
}
JsonNode application = appRegistryClient == null ? null : appRegistryClient.getAppByCode(applicationCode);
if (application == null || !application.path("id").isIntegralNumber()
|| !application.path("id").canConvertToLong() || application.path("id").asLong() <= 0
|| !sourceAppKey.equals(application.path("appKey").asText())
|| !applicationCode.equals(application.path("appCode").asText())) {
throw new IllegalStateException("SOURCE_APPLICATION_MISMATCH");
}
}
private SnapshotEnvelope envelopeOf(AnswerDelivery row) {
SnapshotEnvelope envelope = new SnapshotEnvelope();
envelope.setProtocolVersion(row.getProtocolVersion());
@@ -441,7 +489,16 @@ public class AnswerDeliveryService {
return; // v1 广播通道不带我们的相关键
}
try {
onBrokerConfirm(Long.valueOf(correlationData.getId()), ack, cause);
String key = correlationData.getId();
// CorrelationData retains the return even if its separate callback could not persist it.
if (correlationData.getReturned() != null) {
ReturnedMessage returned = correlationData.getReturned();
onUnroutable(callbackDeliveryId(key), key, returned.getExchange() + "/"
+ returned.getRoutingKey() + " reply=" + returned.getReplyCode()
+ " " + returned.getReplyText());
return;
}
onBrokerConfirm(callbackDeliveryId(key), key, ack, cause);
} catch (Exception e) {
log.warn("[v2投递] confirm 回写失败 id={}:{}", correlationData.getId(), e.toString());
}
@@ -451,7 +508,10 @@ public class AnswerDeliveryService {
if (id == null) {
return;
}
onUnroutable(id, returned.getExchange() + "/" + returned.getRoutingKey()
Object generation = returned.getMessage().getMessageProperties()
.getHeaders().get(HEADER_DELIVERY_ATTEMPT);
String key = generation == null ? legacyCallbackKey(id) : String.valueOf(generation);
onUnroutable(id, key, returned.getExchange() + "/" + returned.getRoutingKey()
+ " reply=" + returned.getReplyCode() + " " + returned.getReplyText());
});
}
@@ -471,44 +531,94 @@ public class AnswerDeliveryService {
}
}
/** confirm 只落 brokerAccepted;nack 走退避(B.4"confirm 只记 brokerAccepted") */
/** 代际来自现有 retry_count + 授权重放操作键,无新列;失败/新重放后旧回调不能改变新轮。 */
static String callbackKey(AnswerDelivery row) {
String replay = row.getLastReplayOperationId();
String digest = "";
if (replay != null && !replay.isEmpty()) {
try {
digest = Base64.getUrlEncoder().withoutPadding().encodeToString(
MessageDigest.getInstance("SHA-256").digest(replay.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 unavailable", e);
}
}
return row.getId() + ":" + (row.getRetryCount() == null ? 0 : row.getRetryCount()) + ":" + digest;
}
private static String legacyCallbackKey(Long id) {
return id + ":0:"; // 旧无代际回调只可能确认原始首投,不能猜测后续重试/重放。
}
private static Long callbackDeliveryId(String key) {
int separator = key.indexOf(':');
return Long.valueOf(separator < 0 ? key : key.substring(0, separator));
}
/** 兼容首投的旧包内调用;生产新投递始终携带完整代际。 */
void onBrokerConfirm(Long deliveryId, boolean ack, String cause) {
onBrokerConfirm(deliveryId, legacyCallbackKey(deliveryId), ack, cause);
}
/** confirm 不代表可路由;同代 return 的失败事实优先,旧代 ACK/nack 不能抹掉失败或改新轮。 */
void onBrokerConfirm(Long deliveryId, String key, boolean ack, String cause) {
transactionTemplate.executeWithoutResult(tx -> {
AnswerDelivery row = deliveryRepository.findById(deliveryId).orElse(null);
if (row == null) {
if (row == null || !callbackKey(row).equals(normalizeCallbackKey(key, deliveryId))
|| terminalDelivery(row)
|| (ack && AnswerDelivery.STATE_BROKER_ACCEPTED.equals(row.getState()))) {
return;
}
if (AnswerDelivery.STATE_BROKER_ACCEPTED.equals(row.getState())) {
return; // 重复 confirm(broker 重投同一相关键)幂等吸收
}
if (ack) {
row.setState(AnswerDelivery.STATE_BROKER_ACCEPTED);
row.setBrokerAcceptedAt(LocalDateTime.now());
row.setLastError(null);
row.setNextRetryAt(null);
} else {
row.setBrokerAcceptedAt(null);
bumpRetry(row, "broker nack: " + cause);
}
deliveryRepository.save(row);
});
}
/** mandatory 不可路由=目标私有队列没绑上(部署契约缺隔离配置),按失败退避而不是静默丢弃 */
void onUnroutable(Long deliveryId, String detail) {
onUnroutable(deliveryId, legacyCallbackKey(deliveryId), detail);
}
/** 同代 return 即使迟于 ACK 也要保留 NO_ROUTE;重复 return 代际已失效,不能重复增加退避。 */
void onUnroutable(Long deliveryId, String key, String detail) {
transactionTemplate.executeWithoutResult(tx -> {
AnswerDelivery row = deliveryRepository.findById(deliveryId).orElse(null);
if (row == null || AnswerDelivery.STATE_BROKER_ACCEPTED.equals(row.getState())) {
if (row == null || !callbackKey(row).equals(normalizeCallbackKey(key, deliveryId))
|| terminalDelivery(row)) {
return;
}
markFailed(row, "不可路由(mandatory return):" + detail);
row.setBrokerAcceptedAt(null);
bumpRetry(row, "NO_ROUTE: 不可路由(mandatory return):" + detail);
deliveryRepository.save(row);
});
log.warn("[v2投递] 消息不可路由:id={} {}", deliveryId, detail);
}
private static String normalizeCallbackKey(String key, Long id) {
return String.valueOf(id).equals(key) ? legacyCallbackKey(id) : key;
}
private static boolean terminalDelivery(AnswerDelivery row) {
return AnswerDelivery.STATE_DEAD.equals(row.getState())
|| AnswerDelivery.STATE_GRANT_REVOKED.equals(row.getState());
}
private void markFailed(AnswerDelivery row, String error) {
markFailed(row, callbackKey(row), error);
}
private void markFailed(AnswerDelivery row, String key, String error) {
transactionTemplate.executeWithoutResult(tx -> {
AnswerDelivery fresh = deliveryRepository.findById(row.getId()).orElse(null);
if (fresh == null) {
if (fresh == null || !callbackKey(fresh).equals(key) || terminalDelivery(fresh)
|| AnswerDelivery.STATE_BROKER_ACCEPTED.equals(fresh.getState())) {
return;
}
bumpRetry(fresh, error);
@@ -1,12 +1,16 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
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.amqp.core.Message;
import org.springframework.amqp.core.MessageProperties;
import org.springframework.amqp.core.MessageDeliveryMode;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@@ -58,7 +62,13 @@ public class FcEventPublisher {
*/
private final ObjectProvider<RabbitTemplate> templates;
/** 是否启用的唯一判据=宿主是否注入了 RABBITMQ_HOST(yml 把它映射到 spring.rabbitmq.host) */
// Local serialization leaves the shared RabbitTemplate converter and v2 channel unchanged.
private final ObjectMapper eventMapper = new ObjectMapper();
@Value("${fc.events.enabled:false}")
private boolean eventsEnabled;
/** Explicit events switch plus broker host; independent of the v2 snapshot outbox. */
@Value("${spring.rabbitmq.host:}")
private String host;
@@ -69,12 +79,12 @@ public class FcEventPublisher {
if (enabled()) {
log.info("[MQ] 事件发布启用(exchange={},host={})", EXCHANGE, host);
} else {
log.info("[MQ] 未配置 RABBITMQ_HOST,事件发布禁用——拉取端点即事实源,服务照常(契约 v2 §6.2 裁决 3)");
log.info("[MQ] events.enabled=false 或未配置 broker,事件发布禁用;拉取仍是事实源");
}
}
public boolean enabled() {
return host != null && !host.trim().isEmpty();
return eventsEnabled && host != null && !host.trim().isEmpty();
}
// ==================== 三个发布点 ====================
@@ -123,13 +133,19 @@ public class FcEventPublisher {
private void send(Envelope envelope, String action) {
try {
byte[] body = eventMapper.writeValueAsBytes(envelope);
RabbitTemplate template = templates.getIfAvailable();
if (template == null) {
log.warn("[MQ] 已配置 RABBITMQ_HOST 但容器里没有 RabbitTemplate,跳过发布(不影响主流程)");
return;
}
declareExchangeOnce(template);
template.convertAndSend(EXCHANGE, routingKey(envelope.getAppCode(), action), envelope);
MessageProperties properties = new MessageProperties();
properties.setContentType(MessageProperties.CONTENT_TYPE_JSON);
properties.setContentEncoding("UTF-8");
properties.setMessageId(envelope.getEventId());
properties.setDeliveryMode(MessageDeliveryMode.PERSISTENT);
template.send(EXCHANGE, routingKey(envelope.getAppCode(), action), new Message(body, properties));
} catch (Exception e) {
// 裁决 2:broker 故障绝不阻断提交。消费方以游标拉取为事实源,这条事件丢了由下一轮增量补回
log.warn("[MQ] 事件发布失败(忽略,拉取兜底):type={} answer={} cause={}",
+4 -1
View File
@@ -148,9 +148,12 @@ form-center:
# source-app-key 默认取本应用签名身份(yh.app-auth.app-key),信封里的 sourceAppKey 由服务端给,
# 不接受消费方申报。未配 broker 时行留在 PENDING、不丢事件(B.4)。
fc:
events:
# Metadata notification is opt-in too; fixing conversion must not enable a production channel.
enabled: ${FC_EVENTS_ENABLED:false}
answer:
delivery:
source-app-key: ${YH_APP_AUTH_APP_KEY:100021}
source-app-key: ${yh.app-auth.app-key}
scan-interval-ms: ${FC_DELIVERY_SCAN_INTERVAL_MS:10000}
scan-initial-delay-ms: ${FC_DELIVERY_SCAN_INITIAL_DELAY_MS:30000}
batch-size: ${FC_DELIVERY_BATCH_SIZE:50}
@@ -0,0 +1,229 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.formcenter.entity.AnswerDelivery;
import com.yunhai.formcenter.repository.AnswerDeliveryRepository;
import com.yunhai.formcenter.repository.AnswerRepository;
import com.yunhai.formcenter.model.FormAccessModels.SubscriptionGrant;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.mockito.ArgumentCaptor;
import java.util.Collections;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import javax.persistence.LockModeType;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.verify;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
/** B-09 独立规则回归;不冒充真实 RabbitMQ 或真实数据库并发验收。 */
class AnswerDeliveryBrokerRaceTest {
private AnswerDelivery row;
private AnswerDeliveryService service;
@BeforeEach
void setUp() {
row = new AnswerDelivery();
row.setId(7L);
row.setEventId("event");
row.setConsumerAppKey("consumer");
row.setState(AnswerDelivery.STATE_PENDING);
row.setRetryCount(0);
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
when(rows.findById(7L)).thenReturn(Optional.of(row));
TransactionTemplate tx = mock(TransactionTemplate.class);
doAnswer(call -> {
Consumer<TransactionStatus> action = call.getArgument(0);
action.accept(null);
return null;
}).when(tx).executeWithoutResult(any());
@SuppressWarnings("unchecked")
ObjectProvider<RabbitTemplate> templates = mock(ObjectProvider.class);
service = new AnswerDeliveryService(rows, mock(AnswerRepository.class),
mock(AnswerSnapshotService.class), mock(FormConsumerAccessService.class),
new ObjectMapper(), tx, templates);
ReflectionTestUtils.setField(service, "maxRetries", 3);
ReflectionTestUtils.setField(service, "backoffBaseSeconds", 30L);
ReflectionTestUtils.setField(service, "backoffCapSeconds", 3600L);
}
@Test
void noRouteIsNotOverwrittenByFollowingAck() {
String sent = AnswerDeliveryService.callbackKey(row);
service.onUnroutable(7L, sent, "312 NO_ROUTE");
String error = row.getLastError();
LocalDateTime retryAt = row.getNextRetryAt();
service.onBrokerConfirm(7L, sent, true, null);
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_FAILED);
assertThat(row.getLastError()).isEqualTo(error).contains("NO_ROUTE");
assertThat(row.getRetryCount()).isEqualTo(1);
assertThat(row.getNextRetryAt()).isEqualTo(retryAt).isAfter(LocalDateTime.now());
assertThat(row.getBrokerAcceptedAt()).isNull();
}
@Test
void returnAlsoWinsIfAckCallbackAcquiredTheRowFirst() {
String sent = AnswerDeliveryService.callbackKey(row);
service.onBrokerConfirm(7L, sent, true, null);
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_BROKER_ACCEPTED);
service.onUnroutable(7L, sent, "312 NO_ROUTE");
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_FAILED);
assertThat(row.getBrokerAcceptedAt()).isNull();
assertThat(row.getLastError()).contains("NO_ROUTE");
assertThat(row.getRetryCount()).isEqualTo(1);
}
@Test
void duplicateReturnAndNackDoNotDoubleChargeSameFailedAttempt() {
String sent = AnswerDeliveryService.callbackKey(row);
service.onUnroutable(7L, sent, "312 NO_ROUTE");
service.onUnroutable(7L, sent, "duplicate");
service.onBrokerConfirm(7L, sent, false, "late nack");
assertThat(row.getRetryCount()).isEqualTo(1);
assertThat(row.getLastError()).contains("NO_ROUTE").doesNotContain("duplicate", "late nack");
}
@Test
void onlyCurrentRetryCanClearFailureAfterSuccessfulRoute() {
String first = AnswerDeliveryService.callbackKey(row);
service.onUnroutable(7L, first, "312 NO_ROUTE");
String retry = AnswerDeliveryService.callbackKey(row);
assertThat(retry).isNotEqualTo(first);
service.onBrokerConfirm(7L, retry, true, null);
service.onUnroutable(7L, first, "late old return");
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_BROKER_ACCEPTED);
assertThat(row.getLastError()).isNull();
assertThat(row.getNextRetryAt()).isNull();
assertThat(row.getBrokerAcceptedAt()).isNotNull();
assertThat(row.getConsumerAppliedAt()).isNull();
}
@Test
void unroutableAttemptsBackOffThenStopAtExistingBudget() {
service.onUnroutable(7L, AnswerDeliveryService.callbackKey(row), "NO_ROUTE #1");
LocalDateTime firstRetry = row.getNextRetryAt();
service.onUnroutable(7L, AnswerDeliveryService.callbackKey(row), "NO_ROUTE #2");
assertThat(row.getNextRetryAt()).isAfter(firstRetry);
String last = AnswerDeliveryService.callbackKey(row);
service.onUnroutable(7L, last, "NO_ROUTE #3");
service.onBrokerConfirm(7L, last, true, null);
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_DEAD);
assertThat(row.getRetryCount()).isEqualTo(3);
assertThat(row.getNextRetryAt()).isNull();
assertThat(row.getLastError()).contains("NO_ROUTE #3");
}
@Test
void oldReplayCannotConfirmOrFailNewAuthorizedOperation() {
row.setLastReplayOperationId("old:operation");
String old = AnswerDeliveryService.callbackKey(row);
row.setLastReplayOperationId("new:operation");
service.onBrokerConfirm(7L, old, true, null);
service.onUnroutable(7L, old, "old operation NO_ROUTE");
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_PENDING);
assertThat(row.getRetryCount()).isZero();
}
@Test
void multibyteReplayOperationKeepsCorrelationWithinAmqpShortStringLimit() {
StringBuilder operation = new StringBuilder();
for (int i = 0; i < 64; i++) operation.append("重");
row.setLastReplayOperationId(operation.toString());
String key = AnswerDeliveryService.callbackKey(row);
assertThat(key.getBytes(java.nio.charset.StandardCharsets.UTF_8).length).isLessThan(255);
assertThat(key).startsWith("7:0:");
}
@Test
void revokedGrantCannotBeReopenedByBrokerCallbacks() {
String sent = AnswerDeliveryService.callbackKey(row);
row.setState(AnswerDelivery.STATE_GRANT_REVOKED);
row.setLastError("revoked");
service.onBrokerConfirm(7L, sent, true, null);
service.onUnroutable(7L, sent, "NO_ROUTE");
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_GRANT_REVOKED);
assertThat(row.getLastError()).isEqualTo("revoked");
}
@Test
void productionSendAndCallbacksCarryTheCapturedRetryGeneration() {
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
when(rows.findById(7L)).thenReturn(Optional.of(row));
row.setFormUuid("form");
row.setConsumerSchoolId(27L);
row.setGrantVersion(3);
row.setSnapshotJson("{}");
when(rows.findDue(any(), any())).thenReturn(Collections.singletonList(row));
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
SubscriptionGrant grant = new SubscriptionGrant();
grant.setConsumerAppKey("consumer");
grant.setGrantVersion(3);
grant.setPushAllowed(true);
when(access.listEnabledSubscriptions("form", 27L)).thenReturn(Collections.singletonList(grant));
TransactionTemplate tx = mock(TransactionTemplate.class);
doAnswer(call -> {
Consumer<TransactionStatus> action = call.getArgument(0);
action.accept(null);
return null;
}).when(tx).executeWithoutResult(any());
RabbitTemplate template = mock(RabbitTemplate.class);
@SuppressWarnings("unchecked")
ObjectProvider<RabbitTemplate> templates = mock(ObjectProvider.class);
when(templates.getIfAvailable()).thenReturn(template);
AnswerDeliveryService sender = new AnswerDeliveryService(rows, null, null, access,
new ObjectMapper(), tx, templates);
ReflectionTestUtils.setField(sender, "sourceAppKey", "100021");
row.setSourceAppKey("100021");
ReflectionTestUtils.setField(sender, "signingAppKey", "100021");
ReflectionTestUtils.setField(sender, "applicationCode", "form-center");
com.yunhai.platform.openapi.base.OpenApiAppClient registry = mock(com.yunhai.platform.openapi.base.OpenApiAppClient.class);
when(registry.getAppByCode("form-center")).thenReturn(new ObjectMapper().createObjectNode()
.put("id", 700021).put("appKey", 100021).put("appCode", "form-center"));
ReflectionTestUtils.setField(sender, "appRegistryClient", registry);
ReflectionTestUtils.setField(sender, "host", "broker");
ReflectionTestUtils.setField(sender, "confirmType", "correlated");
ReflectionTestUtils.setField(sender, "maxRetries", 3);
ReflectionTestUtils.setField(sender, "backoffBaseSeconds", 30L);
ReflectionTestUtils.setField(sender, "backoffCapSeconds", 3600L);
ReflectionTestUtils.invokeMethod(sender, "registerCallbacks");
assertThat(sender.sendDueBatch()).isEqualTo(1);
ArgumentCaptor<Message> message = ArgumentCaptor.forClass(Message.class);
ArgumentCaptor<CorrelationData> correlation = ArgumentCaptor.forClass(CorrelationData.class);
verify(template).send(anyString(), anyString(), message.capture(), correlation.capture());
assertThat(correlation.getValue().getId()).isEqualTo("7:0:");
assertThat(message.getValue().getMessageProperties().getHeaders().get("fc-delivery-attempt"))
.isEqualTo(correlation.getValue().getId());
ArgumentCaptor<RabbitTemplate.ReturnsCallback> returns = ArgumentCaptor.forClass(RabbitTemplate.ReturnsCallback.class);
ArgumentCaptor<RabbitTemplate.ConfirmCallback> confirms = ArgumentCaptor.forClass(RabbitTemplate.ConfirmCallback.class);
verify(template).setReturnsCallback(returns.capture());
verify(template).setConfirmCallback(confirms.capture());
returns.getValue().returnedMessage(new ReturnedMessage(message.getValue(), 312, "NO_ROUTE", "exchange", "route"));
confirms.getValue().confirm(correlation.getValue(), true, null);
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_FAILED);
assertThat(row.getLastError()).contains("NO_ROUTE");
}
@Test
void callbackReadsUseTheSameDatabaseWriteLock() throws Exception {
Lock lock = AnswerDeliveryRepository.class.getMethod("findById", Long.class).getAnnotation(Lock.class);
assertThat(lock).isNotNull();
assertThat(lock.value()).isEqualTo(LockModeType.PESSIMISTIC_WRITE);
}
}
@@ -0,0 +1,239 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.yunhai.formcenter.entity.AnswerDelivery;
import com.yunhai.formcenter.model.AnswerDeliveryModels;
import com.yunhai.formcenter.model.FormAccessModels.SubscriptionGrant;
import com.yunhai.formcenter.repository.AnswerDeliveryRepository;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.Optional;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Local state/serialization/wiring tests; no broker and no real database concurrency. */
class AnswerDeliveryMqContractTest {
private final ObjectMapper mapper = new ObjectMapper();
private AnswerDelivery row;
private AnswerDeliveryService service;
private RabbitTemplate template;
private OpenApiAppClient registry;
private ObjectNode application;
private RabbitTemplate.ConfirmCallback confirm;
private RabbitTemplate.ReturnsCallback returns;
@BeforeEach
void setUp() {
row = new AnswerDelivery();
row.setId(9L); row.setEventId("stable-event");
row.setProtocolVersion(AnswerDeliveryModels.PROTOCOL_VERSION); row.setEventType("updated");
row.setSourceAppKey("100025"); row.setConsumerAppKey("100018"); row.setConsumerSchoolId(27L);
row.setGrantVersion(4); row.setFormUuid("form-uuid"); row.setFormVersion(1);
row.setAnswerUuid("answer-uuid"); row.setAnswerRevision(8L); row.setContentHash("hash");
row.setOccurredAt(LocalDateTime.of(2026, 10, 23, 10, 0));
row.setSnapshotJson("{\"schId\":27,\"appCode\":\"business-owner\",\"code\":\"leave\","
+ "\"submitterId\":41,\"subjectId\":42,\"status\":\"processed\",\"values\":{\"reason\":\"leave\"},"
+ "\"answerNo\":\"LEAVE-8\",\"createTime\":\"2026-10-23T10:00:00\","
+ "\"updateTime\":\"2026-10-23T10:01:00\",\"intent\":{\"intentId\":\"intent-1\"}}");
AnswerDeliveryRepository rows = mock(AnswerDeliveryRepository.class);
when(rows.findById(9L)).thenReturn(Optional.of(row));
when(rows.findDue(any(), any())).thenReturn(Collections.singletonList(row));
FormConsumerAccessService access = mock(FormConsumerAccessService.class);
SubscriptionGrant grant = new SubscriptionGrant();
grant.setConsumerAppKey("100018"); grant.setGrantVersion(4); grant.setPushAllowed(true);
when(access.listEnabledSubscriptions("form-uuid", 27L)).thenReturn(Collections.singletonList(grant));
TransactionTemplate tx = mock(TransactionTemplate.class);
doAnswer(call -> { Consumer<TransactionStatus> action = call.getArgument(0); action.accept(null); return null; })
.when(tx).executeWithoutResult(any());
template = mock(RabbitTemplate.class);
@SuppressWarnings("unchecked") ObjectProvider<RabbitTemplate> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(template);
service = new AnswerDeliveryService(rows, null, null, access, mapper, tx, provider);
registry = mock(OpenApiAppClient.class);
application = mapper.createObjectNode().put("id", 700025).put("appKey", 100025).put("appCode", "form-center");
when(registry.getAppByCode("form-center")).thenReturn(application);
ReflectionTestUtils.setField(service, "sourceAppKey", "100025");
ReflectionTestUtils.setField(service, "signingAppKey", "100025");
ReflectionTestUtils.setField(service, "applicationCode", "form-center");
ReflectionTestUtils.setField(service, "appRegistryClient", registry);
ReflectionTestUtils.setField(service, "host", "local-test-only");
ReflectionTestUtils.setField(service, "confirmType", "correlated");
ReflectionTestUtils.setField(service, "batchSize", 10);
ReflectionTestUtils.setField(service, "maxRetries", 3);
ReflectionTestUtils.setField(service, "backoffBaseSeconds", 30L);
ReflectionTestUtils.setField(service, "backoffCapSeconds", 3600L);
service.logState();
ArgumentCaptor<RabbitTemplate.ConfirmCallback> confirms = ArgumentCaptor.forClass(RabbitTemplate.ConfirmCallback.class);
ArgumentCaptor<RabbitTemplate.ReturnsCallback> returned = ArgumentCaptor.forClass(RabbitTemplate.ReturnsCallback.class);
verify(template).setConfirmCallback(confirms.capture()); verify(template).setReturnsCallback(returned.capture());
confirm = confirms.getValue(); returns = returned.getValue();
}
@Test void routingUsesConsumerCredentialAndSchoolNotBusinessIdOrAppCode() {
assertThat(AnswerDeliveryService.routingKey("100018", 27L)).isEqualTo("fc.snapshot.v2.100018.27");
assertThat(AnswerDeliveryService.routingKey("100008", 27L)).isEqualTo("fc.snapshot.v2.100008.27")
.isNotEqualTo("fc.snapshot.v2.100018.27");
assertThat(AnswerDeliveryService.routingKey("700025", 27L)).isNotEqualTo(AnswerDeliveryService.routingKey(row));
assertThatThrownBy(() -> AnswerDeliveryService.routingKey("100018.*", 27L)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> AnswerDeliveryService.routingKey("100018", 0L)).isInstanceOf(IllegalArgumentException.class);
}
@Test void successfulWirePreservesAllCoordinatesAndAnswerData() throws Exception {
assertThat(service.sendDueBatch()).isEqualTo(1);
ArgumentCaptor<Message> message = ArgumentCaptor.forClass(Message.class);
verify(template).send(eq(AnswerDeliveryModels.EXCHANGE), eq("fc.snapshot.v2.100018.27"), message.capture(), any(CorrelationData.class));
JsonNode json = mapper.readTree(message.getValue().getBody());
assertThat(json.size()).isEqualTo(15);
assertThat(json.get("sourceAppKey").asText()).isEqualTo("100025");
assertThat(json.get("consumerAppKey").asText()).isEqualTo("100018");
assertThat(json.get("consumerSchoolId").asLong()).isEqualTo(27);
assertThat(json.get("eventId").asText()).isEqualTo("stable-event");
assertThat(json.get("formUuid").asText()).isEqualTo("form-uuid");
assertThat(json.get("formVersion").asInt()).isEqualTo(1);
assertThat(json.get("answerUuid").asText()).isEqualTo("answer-uuid");
assertThat(json.get("answerRevision").asLong()).isEqualTo(8);
assertThat(json.get("contentHash").asText()).isEqualTo("hash");
assertThat(json.get("snapshot")).isEqualTo(mapper.readTree(row.getSnapshotJson()));
assertThat(message.getValue().getMessageProperties().getMessageId()).isEqualTo("stable-event");
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_PENDING); // send is not a confirm
verify(registry).getAppByCode("form-center"); verify(registry, never()).getAppById(anyString());
}
@Test void applicationIdMayDifferFromSigningKey() {
assertThat(application.get("id").asText()).isNotEqualTo(application.get("appKey").asText());
assertThat(service.sendDueBatch()).isEqualTo(1);
}
@Test void sourceKeyIsNotTheApplicationPrimaryId() {
row.setSourceAppKey("700025");
assertThat(service.sendDueBatch()).isZero();
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_FAILED);
assertThat(row.getLastError()).contains("SOURCE_CREDENTIAL_MISMATCH");
verify(template, never()).send(anyString(), anyString(), any(Message.class), any(CorrelationData.class));
}
@Test void signingCredentialBelongingToAnotherApplicationIsRejected() {
application.put("appKey", 100099);
assertThat(service.sendDueBatch()).isZero();
assertThat(row.getLastError()).contains("SOURCE_APPLICATION_MISMATCH");
}
@Test void wrongApplicationCodeOrMissingBusinessIdCannotMasqueradeAsSource() {
application.put("appCode", "svc-hall");
assertThat(service.sendDueBatch()).isZero();
application.put("appCode", "form-center").remove("id");
assertThat(service.sendDueBatch()).isZero();
assertThat(row.getLastError()).contains("SOURCE_APPLICATION_MISMATCH");
}
@Test void registryFailureIsRetryableAndCannotBeConfirmedByTheFailedGeneration() {
String failed = AnswerDeliveryService.callbackKey(row);
when(registry.getAppByCode("form-center")).thenThrow(new IllegalStateException("registry unavailable"));
assertThat(service.sendDueBatch()).isZero();
confirm.confirm(new CorrelationData(failed), true, null);
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_FAILED);
assertThat(row.getNextRetryAt()).isNotNull();
assertThat(row.getLastError()).contains("registry unavailable");
}
@Test void returnedFactOnCorrelationPreventsSuccessEvenWithoutTheSeparateReturnCallback() {
assertThat(service.sendDueBatch()).isEqualTo(1);
ArgumentCaptor<Message> message = ArgumentCaptor.forClass(Message.class);
ArgumentCaptor<CorrelationData> key = ArgumentCaptor.forClass(CorrelationData.class);
verify(template).send(anyString(), anyString(), message.capture(), key.capture());
key.getValue().setReturned(new ReturnedMessage(message.getValue(), 312, "NO_ROUTE", AnswerDeliveryModels.EXCHANGE, "wrong.route"));
confirm.confirm(key.getValue(), true, null);
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_FAILED);
assertThat(row.getLastError()).contains("NO_ROUTE");
assertThat(row.getBrokerAcceptedAt()).isNull();
}
@Test void bindingMismatchReturnThenAckKeepsFailureAndRetry() {
assertThat(service.sendDueBatch()).isEqualTo(1);
ArgumentCaptor<Message> message = ArgumentCaptor.forClass(Message.class);
ArgumentCaptor<CorrelationData> key = ArgumentCaptor.forClass(CorrelationData.class);
verify(template).send(anyString(), anyString(), message.capture(), key.capture());
returns.returnedMessage(new ReturnedMessage(message.getValue(), 312, "NO_ROUTE", AnswerDeliveryModels.EXCHANGE, "fc.snapshot.v2.100018.27"));
confirm.confirm(key.getValue(), true, null);
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_FAILED);
assertThat(row.getLastError()).contains("NO_ROUTE");
assertThat(row.getRetryCount()).isEqualTo(1);
assertThat(row.getNextRetryAt()).isNotNull();
}
@Test void nackBeforeAckAndDuplicateNackCannotBecomeSuccess() {
CorrelationData key = new CorrelationData(AnswerDeliveryService.callbackKey(row));
confirm.confirm(key, false, "broker rejected"); confirm.confirm(key, true, null); confirm.confirm(key, false, "duplicate");
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_FAILED);
assertThat(row.getRetryCount()).isEqualTo(1);
assertThat(row.getLastError()).contains("broker rejected").doesNotContain("duplicate");
}
@Test void sameGenerationNackAfterAckOverridesOnlyTheBrokerState() {
CorrelationData key = new CorrelationData(AnswerDeliveryService.callbackKey(row));
confirm.confirm(key, true, null); confirm.confirm(key, false, "negative wins"); confirm.confirm(key, true, null);
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_FAILED);
assertThat(row.getBrokerAcceptedAt()).isNull();
assertThat(row.getConsumerAppliedAt()).isNull();
assertThat(row.getLastError()).contains("negative wins");
}
@Test void duplicateAckDoesNotRewriteTheAcceptedTimestamp() {
CorrelationData key = new CorrelationData(AnswerDeliveryService.callbackKey(row));
confirm.confirm(key, true, null); LocalDateTime first = row.getBrokerAcceptedAt(); confirm.confirm(key, true, null);
assertThat(row.getBrokerAcceptedAt()).isEqualTo(first); assertThat(row.getRetryCount()).isZero();
}
@Test void conversionFailureRetriesWithSameImmutableEventAndNewGeneration() throws Exception {
ObjectMapper failing = spy(new ObjectMapper());
doThrow(new JsonProcessingException("encode failure") {}).when(failing).writeValueAsString(any());
ReflectionTestUtils.setField(service, "objectMapper", failing);
String original = AnswerDeliveryService.callbackKey(row);
assertThat(service.sendDueBatch()).isZero();
assertThat(row.getLastError()).contains("encode failure");
confirm.confirm(new CorrelationData(original), true, null);
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_FAILED);
ReflectionTestUtils.setField(service, "objectMapper", mapper);
row.setNextRetryAt(LocalDateTime.now().minusSeconds(1));
assertThat(service.sendDueBatch()).isEqualTo(1);
ArgumentCaptor<CorrelationData> retry = ArgumentCaptor.forClass(CorrelationData.class);
verify(template).send(anyString(), anyString(), any(Message.class), retry.capture());
assertThat(retry.getValue().getId()).isNotEqualTo(original);
confirm.confirm(retry.getValue(), true, null);
assertThat(row.getEventId()).isEqualTo("stable-event");
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_BROKER_ACCEPTED);
assertThat(row.getLastError()).isNull(); assertThat(row.getConsumerAppliedAt()).isNull();
}
@Test void corruptSnapshotCannotReachBroker() {
row.setSnapshotJson("{broken"); assertThat(service.sendDueBatch()).isZero();
assertThat(row.getState()).isEqualTo(AnswerDelivery.STATE_FAILED);
verify(template, never()).send(anyString(), anyString(), any(Message.class), any(CorrelationData.class));
}
@Test void returnsAndMandatoryAreRequiredBeforeTheScannerCanSend() {
assertThat(service.sendEnabled()).isTrue();
ReflectionTestUtils.setField(service, "publisherReturns", false); assertThat(service.sendEnabled()).isFalse();
ReflectionTestUtils.setField(service, "publisherReturns", true);
ReflectionTestUtils.setField(service, "mandatory", false); assertThat(service.sendEnabled()).isFalse();
}
}
@@ -0,0 +1,53 @@
package com.yunhai.formcenter.service;
import org.junit.jupiter.api.Test;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.ClassPathResource;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
/** B-05 只证明最终配置解析与错配拒绝;没有核实 160 的实际签名配置。 */
class AnswerDeliverySourceIdentityTest {
@Test
void deliverySourceTracksFinalSigningPropertyInsteadOfSeparateEnvironmentFallback() throws Exception {
StandardEnvironment env = new StandardEnvironment();
new YamlPropertySourceLoader().load("application", new ClassPathResource("application.yml"))
.forEach(env.getPropertySources()::addLast);
env.getPropertySources().addFirst(new MapPropertySource("platform-final-signing-property",
Collections.singletonMap("yh.app-auth.app-key", "100008")));
assertThat(env.getProperty("fc.answer.delivery.source-app-key")).isEqualTo("100008");
}
@Test
void mismatchedExplicitSourceFailsEvenWithoutBroker() {
AnswerDeliveryService service = service("100021", "100008");
assertThatThrownBy(service::logState).isInstanceOf(IllegalStateException.class)
.hasMessageContaining("source-app-key", "yh.app-auth.app-key");
}
@Test
void matchingFinalSigningIdentityDoesNotNeedBroker() {
assertThatCode(service("100008", "100008")::logState).doesNotThrowAnyException();
}
@Test
void missingSigningIdentityFailsClosed() {
assertThatThrownBy(service("100008", "")::logState).isInstanceOf(IllegalStateException.class);
}
private AnswerDeliveryService service(String source, String signing) {
AnswerDeliveryService service = new AnswerDeliveryService(null, null, null, null, null, null, null);
ReflectionTestUtils.setField(service, "sourceAppKey", source);
ReflectionTestUtils.setField(service, "signingAppKey", signing);
ReflectionTestUtils.setField(service, "host", "");
return service;
}
}
@@ -0,0 +1,37 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageProperties;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.SimpleMessageConverter;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** N-1 legacy-converter evidence; production now sends explicit JSON bytes. No broker acceptance claim. */
class FcEventConverterContractTest {
@Test
void defaultRabbitTemplateCannotEncodeTheCurrentNonSerializableEnvelope() {
RabbitTemplate template = new RabbitTemplate();
assertThat(template.getMessageConverter()).isInstanceOf(SimpleMessageConverter.class);
assertThatThrownBy(() -> template.getMessageConverter()
.toMessage(new FcEventPublisher.Envelope(), new MessageProperties()))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Serializable");
}
@Test
void jsonConverterIsASeparatePossibleFixAndKeepsClosedTenFields() throws Exception {
ObjectMapper mapper = new ObjectMapper();
FcEventPublisher.Envelope envelope = new FcEventPublisher.Envelope();
envelope.setType("fc.answer.created");
Message encoded = new Jackson2JsonMessageConverter(mapper).toMessage(envelope, new MessageProperties());
JsonNode json = mapper.readTree(encoded.getBody());
assertThat(json.size()).isEqualTo(10);
assertThat(json.get("type").asText()).isEqualTo("fc.answer.created");
assertThat(encoded.getMessageProperties().getContentType()).isEqualTo(MessageProperties.CONTENT_TYPE_JSON);
}
}
@@ -0,0 +1,127 @@
package com.yunhai.formcenter.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.formcenter.entity.Answer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.core.MessageProperties;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.SimpleMessageConverter;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real JSON encoding and template boundary, no RabbitMQ connection. v1 remains best effort. */
class FcEventJsonPublishTest {
private FcEventPublisher publisher;
private RabbitTemplate template;
private ObjectProvider<RabbitTemplate> provider;
private Answer answer;
private final ObjectMapper mapper = new ObjectMapper();
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
template = spy(new RabbitTemplate());
doReturn(null).when(template).execute(any());
doNothing().when(template).send(anyString(), anyString(), any(Message.class));
provider = mock(ObjectProvider.class); when(provider.getIfAvailable()).thenReturn(template);
publisher = new FcEventPublisher(provider);
ReflectionTestUtils.setField(publisher, "host", "local-test-only");
ReflectionTestUtils.setField(publisher, "eventsEnabled", true);
answer = new Answer(); answer.setUuid("answer-uuid"); answer.setFormUuid("form-uuid");
answer.setSchId(27L); answer.setAppCode("svc-hall"); answer.setCode("leave");
answer.setVersion(1); answer.setAnswerNo("LEAVE-1"); answer.setValuesJson("{\"secret\":\"must-not-broadcast\"}");
}
@Test void hostInjectionDoesNotEnableTheFixedNotificationChannel() {
FcEventPublisher disabled = new FcEventPublisher(provider);
ReflectionTestUtils.setField(disabled, "host", "platform-host");
assertThat(disabled.enabled()).isFalse(); disabled.answerCreated(answer);
verifyNoInteractions(provider);
}
@Test void actualSendWorksWithDefaultConverterAndPreservesTheTenFieldContract() throws Exception {
publisher.answerCreated(answer);
ArgumentCaptor<Message> wire = ArgumentCaptor.forClass(Message.class);
verify(template).send(eq(FcEventPublisher.EXCHANGE), eq("fc.answer.svc-hall.created"), wire.capture());
assertThat(template.getMessageConverter()).isInstanceOf(SimpleMessageConverter.class);
JsonNode json = mapper.readTree(wire.getValue().getBody());
List<String> keys = new ArrayList<>(); json.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");
assertThat(json.get("event_id").asText()).isNotEmpty();
assertThat(json.get("type").asText()).isEqualTo("fc.answer.created");
assertThat(json.get("occurred_at").asText()).contains("+08:00");
assertThat(json.get("app_code").asText()).isEqualTo("svc-hall");
assertThat(json.get("sch_id").asLong()).isEqualTo(27);
assertThat(json.get("form_uuid").asText()).isEqualTo("form-uuid");
assertThat(json.get("form_code").asText()).isEqualTo("leave");
assertThat(json.get("answer_uuid").asText()).isEqualTo("answer-uuid");
assertThat(json.get("version").asInt()).isEqualTo(1);
assertThat(json.get("answer_no").asText()).isEqualTo("LEAVE-1");
assertThat(json.toString()).doesNotContain("must-not-broadcast", "values", "appSecret");
assertThat(wire.getValue().getMessageProperties().getMessageId()).isEqualTo(json.get("event_id").asText());
assertThat(wire.getValue().getMessageProperties().getContentType()).isEqualTo(MessageProperties.CONTENT_TYPE_JSON);
assertThat(wire.getValue().getMessageProperties().getContentEncoding()).isEqualTo("UTF-8");
}
@Test void conversionFailureCannotReachTheTemplateOrBreakTheAnswerWrite() throws Exception {
ObjectMapper broken = spy(new ObjectMapper());
doThrow(new JsonProcessingException("encode failure") {}).when(broken).writeValueAsBytes(any());
ReflectionTestUtils.setField(publisher, "eventMapper", broken);
assertThatCode(() -> publisher.answerCreated(answer)).doesNotThrowAnyException();
verifyNoInteractions(provider); assertThat(answer.getUuid()).isEqualTo("answer-uuid");
assertThat(answer.getVersion()).isEqualTo(1);
}
@Test void aFailedEncodeDoesNotPermanentlyDisableLaterNotifications() throws Exception {
ObjectMapper recovering = spy(new ObjectMapper());
doThrow(new JsonProcessingException("first encode failed") {}).doCallRealMethod()
.when(recovering).writeValueAsBytes(any());
ReflectionTestUtils.setField(publisher, "eventMapper", recovering);
publisher.answerUpdated(answer); publisher.answerUpdated(answer);
verify(template, times(1)).send(eq(FcEventPublisher.EXCHANGE), eq("fc.answer.svc-hall.updated"), any(Message.class));
}
@Test void afterCommitUsesTheFrozenAnswerCoordinates() throws Exception {
TransactionSynchronizationManager.initSynchronization();
try {
publisher.answerCreated(answer);
verify(template, never()).send(anyString(), anyString(), any(Message.class));
answer.setUuid("later-answer"); answer.setVersion(99); answer.setAnswerNo("later-number");
TransactionSynchronizationManager.getSynchronizations().forEach(s -> s.afterCommit());
ArgumentCaptor<Message> wire = ArgumentCaptor.forClass(Message.class);
verify(template).send(anyString(), anyString(), wire.capture());
JsonNode json = mapper.readTree(wire.getValue().getBody());
assertThat(json.get("answer_uuid").asText()).isEqualTo("answer-uuid");
assertThat(json.get("version").asInt()).isEqualTo(1);
assertThat(json.get("answer_no").asText()).isEqualTo("LEAVE-1");
} finally { TransactionSynchronizationManager.clearSynchronization(); }
}
@Test void failedDeclarationIsRetriedByTheNextNotification() {
doThrow(new IllegalStateException("declare failed")).doReturn(null).when(template).execute(any());
assertThatCode(() -> publisher.answerCreated(answer)).doesNotThrowAnyException();
publisher.answerCreated(answer);
verify(template, times(2)).execute(any()); verify(template).send(anyString(), anyString(), any(Message.class));
}
@Test void sendExceptionRemainsBestEffortAndNeverClaimsDelivery() {
doThrow(new IllegalStateException("send failed")).when(template).send(anyString(), anyString(), any(Message.class));
assertThatCode(() -> publisher.answerTombstoned(answer)).doesNotThrowAnyException();
verify(template, times(1)).send(anyString(), eq("fc.answer.svc-hall.tombstoned"), any(Message.class));
assertThat(answer.getUuid()).isEqualTo("answer-uuid");
}
}
@@ -7,6 +7,7 @@ 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.amqp.core.Message;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronization;
@@ -71,6 +72,7 @@ class FcEventPublisherTest {
@SuppressWarnings("unchecked")
ObjectProvider<RabbitTemplate> provider = mock(ObjectProvider.class);
FcEventPublisher publisher = new FcEventPublisher(provider);
ReflectionTestUtils.setField(publisher, "eventsEnabled", true);
ReflectionTestUtils.setField(publisher, "host", "");
assertThat(publisher.enabled()).isFalse();
@@ -88,21 +90,22 @@ class FcEventPublisherTest {
ObjectProvider<RabbitTemplate> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(template);
FcEventPublisher publisher = new FcEventPublisher(provider);
ReflectionTestUtils.setField(publisher, "eventsEnabled", true);
ReflectionTestUtils.setField(publisher, "host", "localhost");
TransactionSynchronizationManager.initSynchronization();
try {
publisher.answerCreated(answer());
verify(template, never()).convertAndSend(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(),
ArgumentMatchers.any(Object.class));
verify(template, never()).send(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(),
ArgumentMatchers.any(Message.class));
for (TransactionSynchronization sync
: TransactionSynchronizationManager.getSynchronizations()) {
sync.afterCommit();
}
verify(template).convertAndSend(ArgumentMatchers.eq(FcEventPublisher.EXCHANGE),
verify(template).send(ArgumentMatchers.eq(FcEventPublisher.EXCHANGE),
ArgumentMatchers.eq("fc.answer.demo.created"),
ArgumentMatchers.any(FcEventPublisher.Envelope.class));
ArgumentMatchers.any(Message.class));
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
@@ -113,12 +116,13 @@ class FcEventPublisherTest {
void sendFailureIsSwallowed() {
RabbitTemplate template = mock(RabbitTemplate.class);
doThrow(new IllegalStateException("broker down")).when(template)
.convertAndSend(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(),
ArgumentMatchers.any(Object.class));
.send(ArgumentMatchers.anyString(), ArgumentMatchers.anyString(),
ArgumentMatchers.any(Message.class));
@SuppressWarnings("unchecked")
ObjectProvider<RabbitTemplate> provider = mock(ObjectProvider.class);
when(provider.getIfAvailable()).thenReturn(template);
FcEventPublisher publisher = new FcEventPublisher(provider);
ReflectionTestUtils.setField(publisher, "eventsEnabled", true);
ReflectionTestUtils.setField(publisher, "host", "localhost");
// 无事务上下文时就地发布(同步器未激活的那条分支)