feat(p1r): 接入 Account 事件传播真实链路

This commit is contained in:
zizi 2026-06-08 02:49:02 +08:00
parent 0f498026bb
commit 0e10caf324
14 changed files with 1447 additions and 0 deletions

View File

@ -40,6 +40,11 @@
<artifactId>muse-module-infra-api</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>muse-module-events-api</artifactId>
<version>${revision}</version>
</dependency>
<!-- 业务组件 -->
<dependency>

View File

@ -0,0 +1,33 @@
package cn.iocoder.muse.module.member.application.account;
import java.util.Map;
import java.util.Set;
/**
* Account Events payload 安全白名单
*/
public final class AccountEventPayloads {
private static final Set<String> PAYLOAD_KEYS = Set.of("type", "message", "resourceRef", "timestamp");
private static final Set<String> RESOURCE_REF_KEYS = Set.of("resourceType", "resourceId");
private AccountEventPayloads() {
}
public static boolean isValidQuotaAlertPayload(Map<String, Object> payload) {
if (payload == null || !PAYLOAD_KEYS.equals(payload.keySet())) {
return false;
}
if (!"quota_alert".equals(payload.get("type")) || !"额度已调整".equals(payload.get("message"))
|| !(payload.get("timestamp") instanceof String)) {
return false;
}
if (!(payload.get("resourceRef") instanceof Map<?, ?> resourceRef)
|| !RESOURCE_REF_KEYS.equals(resourceRef.keySet())) {
return false;
}
return "account_quota".equals(resourceRef.get("resourceType"))
&& resourceRef.get("resourceId") instanceof Number;
}
}

View File

@ -0,0 +1,18 @@
package cn.iocoder.muse.module.member.application.account;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountEventPublishOutboxDO;
import cn.iocoder.muse.module.member.dal.dataobject.account.MemberEntitlementAuditLogDO;
/**
* Account 本域 Events publish outbox 创建服务
*/
public interface AccountEventPublishOutboxService {
/**
* quota adjustment entitlement audit log 创建一条 Account Events publish outbox
*/
AccountEventPublishOutboxDO createForQuotaAdjustment(MemberEntitlementAuditLogDO log,
String sourceCommandId,
String requestHash);
}

View File

@ -0,0 +1,195 @@
package cn.iocoder.muse.module.member.application.account;
import cn.iocoder.muse.framework.common.exception.ServiceException;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountEventPublishOutboxDO;
import cn.iocoder.muse.module.member.dal.dataobject.account.MemberEntitlementAuditLogDO;
import cn.iocoder.muse.module.member.dal.mysql.account.AccountEventPublishOutboxMapper;
import cn.iocoder.muse.module.member.framework.config.MuseAccountEventsProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Account quota adjustment 发布 outbox 服务实现
*/
@Service
@Slf4j
public class AccountEventPublishOutboxServiceImpl implements AccountEventPublishOutboxService {
static final String STATUS_QUEUED = "queued";
private static final String CHANGE_TYPE_QUOTA_ADJUSTMENT = "quota_adjustment";
private static final String SOURCE_REVISION_NONE = "__none__";
private static final String EVENT_TYPE_NOTIFICATION = "notification";
private static final String NOTIFICATION_QUOTA_ALERT = "quota_alert";
private static final String RESOURCE_REF_TYPE_ACCOUNT_QUOTA = "account_quota";
static final String ERROR_PAYLOAD_INVALID_CODE = "ACCOUNT_EVENTS_PAYLOAD_INVALID";
static final int ERROR_PAYLOAD_INVALID_NUMERIC_CODE = 1_042_000_050;
private final AccountEventPublishOutboxMapper outboxMapper;
private final MuseAccountEventsProperties properties;
public AccountEventPublishOutboxServiceImpl(AccountEventPublishOutboxMapper outboxMapper,
MuseAccountEventsProperties properties) {
this.outboxMapper = outboxMapper;
this.properties = properties;
}
@Override
public AccountEventPublishOutboxDO createForQuotaAdjustment(MemberEntitlementAuditLogDO auditLog,
String sourceCommandId,
String requestHash) {
if (auditLog == null || !CHANGE_TYPE_QUOTA_ADJUSTMENT.equals(auditLog.getChangeType())) {
return null;
}
if (auditLog.getAccountUserId() == null || auditLog.getAccountUserId() <= 0) {
log.warn("Account Events outbox 跳过非法 owner 配额审计日志tenantId={}, auditLogId={}, accountUserId={}",
auditLog.getTenantId(), auditLog.getId(), auditLog.getAccountUserId());
return null;
}
if (auditLog.getId() == null || auditLog.getId() <= 0) {
log.warn("Account Events outbox 跳过非法 source facttenantId={}, accountUserId={}, auditLogId={}",
auditLog.getTenantId(), auditLog.getAccountUserId(), auditLog.getId());
return null;
}
AccountEventPublishOutboxDO outbox = buildOutbox(auditLog, sourceCommandId, requestHash);
validatePayloadOrThrow(outbox);
int inserted = outboxMapper.insertIgnore(outbox);
if (inserted > 0) {
return outboxMapper.selectByTenantIdAndOutboxId(auditLog.getTenantId(), outbox.getOutboxId());
}
AccountEventPublishOutboxDO existing = outboxMapper.selectByTenantIdAndAuditLogIdAndNotificationType(
auditLog.getTenantId(), auditLog.getId(), NOTIFICATION_QUOTA_ALERT);
return existing == null ? outboxMapper.selectByTenantIdAndCommandId(auditLog.getTenantId(), outbox.getCommandId())
: existing;
}
private AccountEventPublishOutboxDO buildOutbox(MemberEntitlementAuditLogDO log,
String sourceCommandId,
String requestHash) {
AccountEventPublishOutboxDO outbox = new AccountEventPublishOutboxDO();
outbox.setTenantId(log.getTenantId());
outbox.setOutboxId(buildOutboxId(log.getTenantId(), log.getId(), log.getAccountUserId()));
outbox.setCommandId(buildPublishCommandId(log.getTenantId(), log.getId(), log.getAccountUserId()));
outbox.setSourceCommandId(resolveSourceCommandId(sourceCommandId, log.getIdempotencyKey()));
outbox.setAuditLogId(log.getId());
outbox.setAccountUserId(log.getAccountUserId());
outbox.setOperatorUserId(log.getOperatorUserId());
outbox.setResourceType(resourceType(log));
outbox.setDeltaAmount(deltaAmount(log));
outbox.setSourceOwner(log.getSourceOwner());
outbox.setAuditEventId(log.getAuditEventId());
outbox.setRequestHash(requestHash);
outbox.setSourceRevision(SOURCE_REVISION_NONE);
outbox.setEventType(EVENT_TYPE_NOTIFICATION);
outbox.setNotificationType(NOTIFICATION_QUOTA_ALERT);
outbox.setResourceRefType(RESOURCE_REF_TYPE_ACCOUNT_QUOTA);
outbox.setResourceRefId(log.getId());
LocalDateTime eventTime = eventTimestamp(log);
outbox.setPayloadSummary(JsonUtils.toJsonString(notificationPayload(log, eventTime)));
outbox.setPublishStatus(STATUS_QUEUED);
outbox.setAttemptCount(0);
outbox.setMaxAttempt(configuredMaxAttempt());
outbox.setNextRetryAt(LocalDateTime.now());
outbox.setCreateTime(eventTime);
return outbox;
}
private Map<String, Object> notificationPayload(MemberEntitlementAuditLogDO log, LocalDateTime eventTime) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("type", NOTIFICATION_QUOTA_ALERT);
payload.put("message", "额度已调整");
payload.put("resourceRef", resourceRef(log.getId()));
payload.put("timestamp", eventTime.toString());
return payload;
}
private LocalDateTime eventTimestamp(MemberEntitlementAuditLogDO log) {
return log.getCreateTime() == null ? LocalDateTime.now() : log.getCreateTime();
}
private Map<String, Object> resourceRef(Long auditLogId) {
Map<String, Object> resourceRef = new LinkedHashMap<>();
resourceRef.put("resourceType", RESOURCE_REF_TYPE_ACCOUNT_QUOTA);
resourceRef.put("resourceId", auditLogId);
return resourceRef;
}
private void validatePayloadOrThrow(AccountEventPublishOutboxDO outbox) {
Map<String, Object> payload = JsonUtils.parseObject(outbox.getPayloadSummary(), Map.class);
if (!AccountEventPayloads.isValidQuotaAlertPayload(payload)
|| !StringUtils.hasText(outbox.getResourceType())
|| outbox.getDeltaAmount() == null) {
log.warn("Account Events outbox payload 安全校验失败tenantId={}, accountUserId={}, auditLogId={}, errorCode={}",
outbox.getTenantId(), outbox.getAccountUserId(), outbox.getAuditLogId(),
ERROR_PAYLOAD_INVALID_CODE);
throw new ServiceException(ERROR_PAYLOAD_INVALID_NUMERIC_CODE, ERROR_PAYLOAD_INVALID_CODE);
}
}
private String resolveSourceCommandId(String sourceCommandId, String idempotencyKey) {
if (StringUtils.hasText(sourceCommandId)) {
return sourceCommandId;
}
if (!StringUtils.hasText(idempotencyKey)) {
return "";
}
int separator = idempotencyKey.lastIndexOf(':');
return separator <= 0 ? idempotencyKey : idempotencyKey.substring(0, separator);
}
private String resourceType(MemberEntitlementAuditLogDO log) {
return JsonUtils.parseTree(log.getDeltaValueSnapshot()).path("resourceType").asText(null);
}
private Long deltaAmount(MemberEntitlementAuditLogDO log) {
if (JsonUtils.parseTree(log.getDeltaValueSnapshot()).path("delta").isNumber()) {
return JsonUtils.parseTree(log.getDeltaValueSnapshot()).path("delta").asLong();
}
return null;
}
private int configuredMaxAttempt() {
if (properties == null || properties.getPublishWorker() == null
|| properties.getPublishWorker().getMaxAttempt() <= 0) {
return 5;
}
return properties.getPublishWorker().getMaxAttempt();
}
private String buildPublishCommandId(Long tenantId, Long auditLogId, Long accountUserId) {
String raw = idempotencyKey(tenantId, auditLogId, accountUserId);
return "acct_evt:" + sha256Hex(raw).substring(0, 32);
}
private String buildOutboxId(Long tenantId, Long auditLogId, Long accountUserId) {
String raw = idempotencyKey(tenantId, auditLogId, accountUserId);
return "acct_out:" + sha256Hex(raw).substring(0, 32);
}
private String idempotencyKey(Long tenantId, Long auditLogId, Long accountUserId) {
return tenantId + "|" + auditLogId + "|" + accountUserId + "|" + NOTIFICATION_QUOTA_ALERT;
}
private String sha256Hex(String raw) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(raw.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(digest.length * 2);
for (byte value : digest) {
hex.append(String.format("%02x", value));
}
return hex.toString();
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 algorithm is required", exception);
}
}
}

View File

@ -0,0 +1,248 @@
package cn.iocoder.muse.module.member.application.account;
import cn.iocoder.muse.framework.common.pojo.CommonResult;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.framework.tenant.core.util.TenantUtils;
import cn.iocoder.muse.module.events.api.publish.EventsPublishApi;
import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishReqDTO;
import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishRespDTO;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountEventPublishOutboxDO;
import cn.iocoder.muse.module.member.dal.mysql.account.AccountEventPublishOutboxMapper;
import cn.iocoder.muse.module.member.framework.config.MuseAccountEventsProperties;
import com.fasterxml.jackson.core.type.TypeReference;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Map;
/**
* Account quota adjustment outbox 发布到统一 Events worker
*/
@Slf4j
@Component
public class AccountEventPublishWorker {
private static final String PUBLISH_STATUS_ACCEPTED = "accepted";
private static final String PUBLISH_STATUS_REJECTED = "rejected";
private static final String PUBLISH_STATUS_BLOCKED = "blocked";
private static final String SOURCE_OWNER_ACCOUNT = "account";
private static final String SOURCE_TYPE_ACCOUNT_QUOTA_ADJUSTMENT = "account_quota_adjustment";
private static final String EVENT_TYPE_NOTIFICATION = "notification";
private static final String SOURCE_REVISION_NONE = "__none__";
private static final String ERROR_RETRY_EXHAUSTED = "ACCOUNT_EVENTS_PUBLISH_RETRY_EXHAUSTED";
private static final String ERROR_API_FAILED = "ACCOUNT_EVENTS_PUBLISH_API_FAILED";
private static final String ERROR_TEMPORARY_UNAVAILABLE = "ACCOUNT_EVENTS_PUBLISH_TEMPORARY_UNAVAILABLE";
private static final String ERROR_UNKNOWN_STATUS = "ACCOUNT_EVENTS_PUBLISH_UNKNOWN_STATUS";
private static final String ERROR_PAYLOAD_INVALID = "ACCOUNT_EVENTS_PAYLOAD_INVALID";
private static final String ERROR_OUTBOX_INCOMPLETE = "ACCOUNT_EVENTS_OUTBOX_INCOMPLETE";
static final long RETRY_BACKOFF_SECONDS = 60L;
private final AccountEventPublishOutboxMapper outboxMapper;
private final EventsPublishApi eventsPublishApi;
private final MuseAccountEventsProperties properties;
public AccountEventPublishWorker(AccountEventPublishOutboxMapper outboxMapper,
EventsPublishApi eventsPublishApi,
MuseAccountEventsProperties properties) {
this.outboxMapper = outboxMapper;
this.eventsPublishApi = eventsPublishApi;
this.properties = properties;
}
@Scheduled(initialDelayString = "${muse.account.events.publish-worker.initial-delay-ms:1000}",
fixedDelayString = "${muse.account.events.publish-worker.fixed-delay-ms:1000}")
public void dispatchScheduled() {
dispatchOnce();
}
public int dispatchOnce() {
if (!isEnabled()) {
return 0;
}
AccountEventPublishOutboxDO outbox = TenantUtils.executeIgnore(
() -> outboxMapper.claimNextPublishOutbox(configuredClaimTimeoutSeconds()));
if (outbox == null) {
return 0;
}
if (outbox.getTenantId() == null || outbox.getId() == null) {
markDeadLetter(outbox, ERROR_OUTBOX_INCOMPLETE, "Account Events publish outbox incomplete");
return 0;
}
return TenantUtils.execute(outbox.getTenantId(), () -> publishClaimedOutbox(outbox));
}
private int publishClaimedOutbox(AccountEventPublishOutboxDO outbox) {
if (isRetryExhausted(outbox)) {
markDeadLetter(outbox, ERROR_RETRY_EXHAUSTED, "Account Events publish retry exhausted");
return 0;
}
BuildPublishRequestResult buildResult = buildReqDTO(outbox);
if (!buildResult.success()) {
markDeadLetter(outbox, buildResult.errorCode(), buildResult.errorMessage());
return 0;
}
CommonResult<EventsPublishRespDTO> result;
try {
result = eventsPublishApi.publish(buildResult.reqDTO());
} catch (RuntimeException exception) {
log.warn("Account Events publish 临时失败tenantId={}, accountUserId={}, outboxId={}, auditLogId={}, attempt={}, errorType={}",
outbox.getTenantId(), outbox.getAccountUserId(), outbox.getId(), outbox.getAuditLogId(),
outbox.getAttemptCount(), exception.getClass().getSimpleName());
if (isLastAttempt(outbox)) {
markDeadLetter(outbox, ERROR_RETRY_EXHAUSTED, "Account Events publish retry exhausted");
return 0;
}
markRetryable(outbox, ERROR_TEMPORARY_UNAVAILABLE, "Account Events publish temporary unavailable");
return 0;
}
if (result == null || result.isError()) {
if (isLastAttempt(outbox)) {
markDeadLetter(outbox, ERROR_RETRY_EXHAUSTED, "Account Events publish retry exhausted");
return 0;
}
markRetryable(outbox, ERROR_API_FAILED, "Account Events publish failed");
return 0;
}
return handlePublishResponse(outbox, result.getData());
}
private int handlePublishResponse(AccountEventPublishOutboxDO outbox, EventsPublishRespDTO respDTO) {
if (respDTO == null || !StringUtils.hasText(respDTO.getPublishStatus())) {
markDeadLetter(outbox, ERROR_UNKNOWN_STATUS, "Account Events publish unknown status");
return 0;
}
String publishStatus = respDTO.getPublishStatus();
if (PUBLISH_STATUS_ACCEPTED.equals(publishStatus)) {
markPublished(outbox, respDTO);
return 1;
}
if (PUBLISH_STATUS_REJECTED.equals(publishStatus) || PUBLISH_STATUS_BLOCKED.equals(publishStatus)) {
markDeadLetter(outbox, safeErrorCode(respDTO.getPublishErrorCode()), "Account Events publish rejected");
return 1;
}
markDeadLetter(outbox, ERROR_UNKNOWN_STATUS, "Account Events publish unknown status");
return 0;
}
private BuildPublishRequestResult buildReqDTO(AccountEventPublishOutboxDO outbox) {
Map<String, Object> payload = parsePayload(outbox);
if (!AccountEventPayloads.isValidQuotaAlertPayload(payload) || outbox.getCreateTime() == null
|| outbox.getAccountUserId() == null || outbox.getAccountUserId() <= 0
|| outbox.getAuditLogId() == null || outbox.getAuditLogId() <= 0
|| !StringUtils.hasText(outbox.getCommandId())) {
return BuildPublishRequestResult.failed(ERROR_PAYLOAD_INVALID, "Account Events publish payload invalid");
}
EventsPublishReqDTO reqDTO = new EventsPublishReqDTO();
reqDTO.setCommandId(outbox.getCommandId());
reqDTO.setTenantId(outbox.getTenantId());
reqDTO.setOwnerUserId(outbox.getAccountUserId());
reqDTO.setSourceOwner(SOURCE_OWNER_ACCOUNT);
reqDTO.setSourceType(SOURCE_TYPE_ACCOUNT_QUOTA_ADJUSTMENT);
reqDTO.setSourceId(String.valueOf(outbox.getAuditLogId()));
reqDTO.setSourceRevision(StringUtils.hasText(outbox.getSourceRevision())
? outbox.getSourceRevision() : SOURCE_REVISION_NONE);
reqDTO.setEventType(EVENT_TYPE_NOTIFICATION);
reqDTO.setResourceType(outbox.getResourceRefType());
reqDTO.setResourceId(String.valueOf(outbox.getResourceRefId()));
reqDTO.setPayloadSummary(payload);
reqDTO.setEmittedAt(outbox.getCreateTime());
return BuildPublishRequestResult.success(reqDTO);
}
private Map<String, Object> parsePayload(AccountEventPublishOutboxDO outbox) {
try {
Map<String, Object> parsed = JsonUtils.getObjectMapper()
.readValue(outbox.getPayloadSummary(), new TypeReference<Map<String, Object>>() {
});
return parsed == null ? Map.of() : parsed;
} catch (Exception exception) {
log.warn("Account Events publish payload 解析失败tenantId={}, accountUserId={}, outboxId={}, auditLogId={}, errorType={}",
outbox.getTenantId(), outbox.getAccountUserId(), outbox.getId(), outbox.getAuditLogId(),
exception.getClass().getSimpleName());
return Map.of();
}
}
private boolean isEnabled() {
return properties != null
&& properties.getPublishWorker() != null
&& properties.getPublishWorker().isEnabled();
}
private long configuredClaimTimeoutSeconds() {
if (properties == null || properties.getPublishWorker() == null
|| properties.getPublishWorker().getClaimTimeoutSeconds() <= 0) {
return 60L;
}
return properties.getPublishWorker().getClaimTimeoutSeconds();
}
private boolean isRetryExhausted(AccountEventPublishOutboxDO outbox) {
return outbox.getAttemptCount() != null && outbox.getMaxAttempt() != null
&& outbox.getAttemptCount() > outbox.getMaxAttempt();
}
private boolean isLastAttempt(AccountEventPublishOutboxDO outbox) {
return outbox.getAttemptCount() != null && outbox.getMaxAttempt() != null
&& outbox.getAttemptCount() >= outbox.getMaxAttempt();
}
private void markPublished(AccountEventPublishOutboxDO outbox, EventsPublishRespDTO respDTO) {
int updated = outboxMapper.markPublished(outbox.getId(), outbox.getTenantId(), outbox.getAttemptCount(),
respDTO.getEventId(), respDTO.getSequenceNo());
logSkippedTerminalUpdate(outbox, updated, "published", null);
}
private void markRetryable(AccountEventPublishOutboxDO outbox, String errorCode, String errorMessage) {
int updated = outboxMapper.markRetryable(outbox.getId(), outbox.getTenantId(), outbox.getAttemptCount(),
LocalDateTime.now().plusSeconds(RETRY_BACKOFF_SECONDS), errorCode, errorMessage);
logSkippedTerminalUpdate(outbox, updated, "retryable", errorCode);
}
private void markDeadLetter(AccountEventPublishOutboxDO outbox, String errorCode, String errorMessage) {
if (outbox.getId() == null || outbox.getTenantId() == null) {
log.error("Account Events publish outbox 无法标记 dead_letter缺少 id 或 tenantIderrorCode={}", errorCode);
return;
}
int updated = outboxMapper.markDeadLetter(outbox.getId(), outbox.getTenantId(), outbox.getAttemptCount(),
errorCode, errorMessage);
logSkippedTerminalUpdate(outbox, updated, "dead_letter", errorCode);
}
private String safeErrorCode(String publishErrorCode) {
return StringUtils.hasText(publishErrorCode) ? publishErrorCode : ERROR_UNKNOWN_STATUS;
}
private void logSkippedTerminalUpdate(AccountEventPublishOutboxDO outbox, int updated, String targetStatus,
String errorCode) {
if (updated > 0) {
return;
}
// 原因0 行通常表示租约过期后被其他 worker 重新领取必须留下可检索证据避免重复发布排障断链
log.warn("Account Events publish 终态回写被跳过tenantId={}, accountUserId={}, outboxId={}, " +
"auditLogId={}, attempt={}, targetStatus={}, errorCode={}",
outbox.getTenantId(), outbox.getAccountUserId(), outbox.getId(), outbox.getAuditLogId(),
outbox.getAttemptCount(), targetStatus, errorCode);
}
private record BuildPublishRequestResult(boolean success, EventsPublishReqDTO reqDTO,
String errorCode, String errorMessage) {
static BuildPublishRequestResult success(EventsPublishReqDTO reqDTO) {
return new BuildPublishRequestResult(true, reqDTO, null, null);
}
static BuildPublishRequestResult failed(String errorCode, String errorMessage) {
return new BuildPublishRequestResult(false, null, errorCode, errorMessage);
}
}
}

View File

@ -89,6 +89,8 @@ public class AccountQuotaServiceImpl implements AccountQuotaService {
private AccountCommandService commandService;
@Resource
private AccountAuditService auditService;
@Resource
private AccountEventPublishOutboxService accountEventPublishOutboxService;
@Override
@Transactional(rollbackFor = Exception.class)
@ -166,6 +168,9 @@ public class AccountQuotaServiceImpl implements AccountQuotaService {
.auditEventId(accountAudit == null ? null : accountAudit.getId())
.build();
auditLogMapper.insert(log);
// 原因entitlement audit log Account quota 调整的终态事实outbox 必须在同一事务内创建
// 这样 quota / audit / publish 补偿状态不会出现跨事务断链
accountEventPublishOutboxService.createForQuotaAdjustment(log, reqVO.getCommandId(), requestHash);
lastLog = log;
}

View File

@ -0,0 +1,56 @@
package cn.iocoder.muse.module.member.dal.dataobject.account;
import cn.iocoder.muse.framework.tenant.core.db.TenantBaseDO;
import cn.iocoder.muse.module.member.dal.type.JsonbStringTypeHandler;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* Account quota adjustment 发布到统一 Events 的本域 outbox
*/
@TableName(value = "muse_account_event_publish_outbox", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class AccountEventPublishOutboxDO extends TenantBaseDO {
@TableId(type = IdType.AUTO)
private Long id;
private String outboxId;
private String commandId;
private String sourceCommandId;
private Long auditLogId;
private Long accountUserId;
private Long operatorUserId;
private String resourceType;
private Long deltaAmount;
private String sourceOwner;
private Long auditEventId;
private String requestHash;
private String sourceRevision;
private String eventType;
private String notificationType;
private String resourceRefType;
private Long resourceRefId;
@TableField(typeHandler = JsonbStringTypeHandler.class)
private String payloadSummary;
private String publishStatus;
private Integer attemptCount;
private Integer maxAttempt;
private LocalDateTime nextRetryAt;
private LocalDateTime claimedAt;
private LocalDateTime claimExpiresAt;
private String lastErrorCode;
private String lastErrorMessage;
private String publishedEventId;
private Long publishedSequenceNo;
}

View File

@ -0,0 +1,163 @@
package cn.iocoder.muse.module.member.dal.mysql.account;
import cn.iocoder.muse.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.muse.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.muse.framework.tenant.core.aop.TenantIgnore;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountEventPublishOutboxDO;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.time.LocalDateTime;
/**
* Account Events publish outbox Mapper
*/
@Mapper
public interface AccountEventPublishOutboxMapper extends BaseMapperX<AccountEventPublishOutboxDO> {
default AccountEventPublishOutboxDO selectByTenantIdAndOutboxId(Long tenantId, String outboxId) {
return selectOne(new LambdaQueryWrapperX<AccountEventPublishOutboxDO>()
.eq(AccountEventPublishOutboxDO::getTenantId, tenantId)
.eq(AccountEventPublishOutboxDO::getOutboxId, outboxId));
}
default AccountEventPublishOutboxDO selectByTenantIdAndCommandId(Long tenantId, String commandId) {
return selectOne(new LambdaQueryWrapperX<AccountEventPublishOutboxDO>()
.eq(AccountEventPublishOutboxDO::getTenantId, tenantId)
.eq(AccountEventPublishOutboxDO::getCommandId, commandId));
}
default AccountEventPublishOutboxDO selectByTenantIdAndAuditLogIdAndNotificationType(Long tenantId,
Long auditLogId,
String notificationType) {
return selectOne(new LambdaQueryWrapperX<AccountEventPublishOutboxDO>()
.eq(AccountEventPublishOutboxDO::getTenantId, tenantId)
.eq(AccountEventPublishOutboxDO::getAuditLogId, auditLogId)
.eq(AccountEventPublishOutboxDO::getNotificationType, notificationType));
}
/**
* 首次创建 Account Events publish outbox重复 command audit log notification 时返回 0
*/
@Insert("""
INSERT INTO muse_account_event_publish_outbox(tenant_id, outbox_id, command_id, source_command_id,
audit_log_id, account_user_id, operator_user_id,
resource_type, delta_amount, source_owner, audit_event_id,
request_hash, source_revision, event_type, notification_type,
resource_ref_type, resource_ref_id, payload_summary,
publish_status, attempt_count, max_attempt, next_retry_at,
claimed_at, claim_expires_at, last_error_code,
last_error_message, published_event_id, published_sequence_no,
create_time)
VALUES (#{tenantId}, #{outboxId}, #{commandId}, #{sourceCommandId}, #{auditLogId}, #{accountUserId},
#{operatorUserId}, #{resourceType}, #{deltaAmount}, #{sourceOwner}, #{auditEventId},
#{requestHash}, #{sourceRevision}, #{eventType}, #{notificationType}, #{resourceRefType},
#{resourceRefId},
#{payloadSummary,typeHandler=cn.iocoder.muse.module.member.dal.type.JsonbStringTypeHandler},
#{publishStatus}, #{attemptCount}, #{maxAttempt}, #{nextRetryAt}, #{claimedAt},
#{claimExpiresAt}, #{lastErrorCode}, #{lastErrorMessage}, #{publishedEventId},
#{publishedSequenceNo}, #{createTime})
ON CONFLICT DO NOTHING
""")
int insertIgnore(AccountEventPublishOutboxDO outbox);
/**
* 原子领取下一条待发布 Account Events outbox
*/
@TenantIgnore
@Options(flushCache = Options.FlushCachePolicy.TRUE, useCache = false)
@Select("""
UPDATE muse_account_event_publish_outbox
SET publish_status = 'running',
claimed_at = CURRENT_TIMESTAMP,
claim_expires_at = CURRENT_TIMESTAMP + (#{leaseSeconds} * INTERVAL '1 second'),
attempt_count = attempt_count + 1,
update_time = CURRENT_TIMESTAMP
WHERE id = (
SELECT id
FROM muse_account_event_publish_outbox
WHERE deleted = FALSE
AND (
publish_status = 'queued'
OR (publish_status = 'retryable'
AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP))
OR (publish_status = 'running'
AND claim_expires_at IS NOT NULL
AND claim_expires_at <= CURRENT_TIMESTAMP)
)
ORDER BY create_time ASC, id ASC
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *
""")
AccountEventPublishOutboxDO claimNextPublishOutbox(long leaseSeconds);
@Update("""
UPDATE muse_account_event_publish_outbox
SET publish_status = 'published',
claimed_at = NULL,
claim_expires_at = NULL,
next_retry_at = NULL,
last_error_code = NULL,
last_error_message = NULL,
published_event_id = #{publishedEventId},
published_sequence_no = #{publishedSequenceNo},
update_time = CURRENT_TIMESTAMP
WHERE id = #{id}
AND tenant_id = #{tenantId}
AND deleted = FALSE
AND publish_status = 'running'
AND attempt_count = #{claimedAttemptCount}
""")
int markPublished(@Param("id") Long id, @Param("tenantId") Long tenantId,
@Param("claimedAttemptCount") Integer claimedAttemptCount,
@Param("publishedEventId") String publishedEventId,
@Param("publishedSequenceNo") Long publishedSequenceNo);
@Update("""
UPDATE muse_account_event_publish_outbox
SET publish_status = 'retryable',
claimed_at = NULL,
claim_expires_at = NULL,
next_retry_at = #{nextRetryAt},
last_error_code = #{lastErrorCode},
last_error_message = #{lastErrorMessage},
update_time = CURRENT_TIMESTAMP
WHERE id = #{id}
AND tenant_id = #{tenantId}
AND deleted = FALSE
AND publish_status = 'running'
AND attempt_count = #{claimedAttemptCount}
""")
int markRetryable(@Param("id") Long id, @Param("tenantId") Long tenantId,
@Param("claimedAttemptCount") Integer claimedAttemptCount,
@Param("nextRetryAt") LocalDateTime nextRetryAt,
@Param("lastErrorCode") String lastErrorCode,
@Param("lastErrorMessage") String lastErrorMessage);
@Update("""
UPDATE muse_account_event_publish_outbox
SET publish_status = 'dead_letter',
claimed_at = NULL,
claim_expires_at = NULL,
next_retry_at = NULL,
last_error_code = #{lastErrorCode},
last_error_message = #{lastErrorMessage},
update_time = CURRENT_TIMESTAMP
WHERE id = #{id}
AND tenant_id = #{tenantId}
AND deleted = FALSE
AND publish_status = 'running'
AND attempt_count = #{claimedAttemptCount}
""")
int markDeadLetter(@Param("id") Long id, @Param("tenantId") Long tenantId,
@Param("claimedAttemptCount") Integer claimedAttemptCount,
@Param("lastErrorCode") String lastErrorCode,
@Param("lastErrorMessage") String lastErrorMessage);
}

View File

@ -0,0 +1,12 @@
package cn.iocoder.muse.module.member.framework.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* Account Events 发布配置装配
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(MuseAccountEventsProperties.class)
public class MuseAccountEventsConfiguration {
}

View File

@ -0,0 +1,38 @@
package cn.iocoder.muse.module.member.framework.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Account 到统一 Events 的本域发布配置
*/
@ConfigurationProperties(prefix = "muse.account.events")
@Data
public class MuseAccountEventsProperties {
/**
* Account quota adjustment 到统一 Events 的本域 worker 配置
*/
private PublishWorker publishWorker = new PublishWorker();
@Data
public static class PublishWorker {
/**
* 是否启用 Account Events publish worker默认关闭避免未验收链路自动 claim
*/
private boolean enabled;
/**
* 单条 outbox 最大发布尝试次数默认 5 便于运行时回滚和调优
*/
private int maxAttempt = 5;
/**
* 单次 claim 租约秒数默认 60 防止 worker 异常退出后长期占用任务
*/
private long claimTimeoutSeconds = 60L;
}
}

View File

@ -0,0 +1,232 @@
package cn.iocoder.muse.module.member.application.account;
import cn.iocoder.muse.framework.common.exception.ServiceException;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.module.events.api.publish.EventsPublishApi;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountEventPublishOutboxDO;
import cn.iocoder.muse.module.member.dal.dataobject.account.MemberEntitlementAuditLogDO;
import cn.iocoder.muse.module.member.dal.mysql.account.AccountEventPublishOutboxMapper;
import cn.iocoder.muse.module.member.framework.config.MuseAccountEventsProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import java.lang.reflect.Field;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Account quota adjustment Events publish outbox 创建服务测试
*/
class AccountEventPublishOutboxServiceTest extends BaseMockitoUnitTest {
private static final Pattern SHORT_COMMAND_ID = Pattern.compile("acct_evt:[0-9a-f]{32}");
private AccountEventPublishOutboxServiceImpl outboxService;
@Mock
private AccountEventPublishOutboxMapper outboxMapper;
@BeforeEach
void setUp() {
outboxService = outboxServiceWithMaxAttempt(5);
}
@Test
void should_createQueuedOutboxWithExactAllowlistAndNumericResourceRef() {
MemberEntitlementAuditLogDO log = entitlementLog(8001L, 1001L, "quota_adjustment");
when(outboxMapper.insertIgnore(any(AccountEventPublishOutboxDO.class))).thenReturn(1);
when(outboxMapper.selectByTenantIdAndOutboxId(any(), any())).thenAnswer(invocation -> {
AccountEventPublishOutboxDO inserted = captureInsertedOutbox();
inserted.setId(501L);
return inserted;
});
AccountEventPublishOutboxDO result =
outboxService.createForQuotaAdjustment(log, "cmd-quota:with:colon", "request-hash-secret");
AccountEventPublishOutboxDO inserted = captureInsertedOutbox();
assertEquals(501L, result.getId());
assertEquals(100L, inserted.getTenantId());
assertEquals("acct_out:be07290313564a1ff50a804c0da78348", inserted.getOutboxId());
assertEquals("acct_evt:be07290313564a1ff50a804c0da78348", inserted.getCommandId());
assertEquals("cmd-quota:with:colon", inserted.getSourceCommandId());
assertEquals(8001L, inserted.getAuditLogId());
assertEquals(1001L, inserted.getAccountUserId());
assertEquals(9001L, inserted.getOperatorUserId());
assertEquals("ai_calls", inserted.getResourceType());
assertEquals(25L, inserted.getDeltaAmount());
assertEquals("manual", inserted.getSourceOwner());
assertEquals(7001L, inserted.getAuditEventId());
assertEquals("request-hash-secret", inserted.getRequestHash());
assertEquals("__none__", inserted.getSourceRevision());
assertEquals("notification", inserted.getEventType());
assertEquals("quota_alert", inserted.getNotificationType());
assertEquals("account_quota", inserted.getResourceRefType());
assertEquals(8001L, inserted.getResourceRefId());
assertEquals("queued", inserted.getPublishStatus());
assertEquals(0, inserted.getAttemptCount());
assertEquals(5, inserted.getMaxAttempt());
assertTrue(SHORT_COMMAND_ID.matcher(inserted.getCommandId()).matches());
Map<String, Object> payload = JsonUtils.parseObject(inserted.getPayloadSummary(), Map.class);
assertEquals(Set.of("type", "message", "resourceRef", "timestamp"), payload.keySet());
assertEquals("quota_alert", payload.get("type"));
assertEquals("额度已调整", payload.get("message"));
assertEquals("2026-06-07T12:00", payload.get("timestamp"));
Map<?, ?> resourceRef = assertInstanceOf(Map.class, payload.get("resourceRef"));
assertEquals(Set.of("resourceType", "resourceId"), resourceRef.keySet());
assertEquals("account_quota", resourceRef.get("resourceType"));
assertInstanceOf(Number.class, resourceRef.get("resourceId"));
assertEquals(8001L, ((Number) resourceRef.get("resourceId")).longValue());
assertNoForbiddenPayloadFields(inserted.getPayloadSummary());
}
@Test
void should_useLastColonFallbackOnlyWhenSourceCommandIdMissing() {
MemberEntitlementAuditLogDO log = entitlementLog(8001L, 1001L, "quota_adjustment");
log.setIdempotencyKey("cmd-quota:with:colon:ai_calls");
when(outboxMapper.insertIgnore(any(AccountEventPublishOutboxDO.class))).thenReturn(1);
when(outboxMapper.selectByTenantIdAndOutboxId(any(), any())).thenAnswer(invocation -> captureInsertedOutbox());
outboxService.createForQuotaAdjustment(log, null, "request-hash-secret");
assertEquals("cmd-quota:with:colon", captureInsertedOutbox().getSourceCommandId(),
"历史回查 fallback 必须使用 lastIndexOf(':'),不能用第一个冒号截断 commandId");
}
@Test
void should_writeConfiguredMaxAttemptIntoOutbox() {
outboxService = outboxServiceWithMaxAttempt(9);
MemberEntitlementAuditLogDO log = entitlementLog(8001L, 1001L, "quota_adjustment");
when(outboxMapper.insertIgnore(any(AccountEventPublishOutboxDO.class))).thenReturn(1);
when(outboxMapper.selectByTenantIdAndOutboxId(any(), any())).thenAnswer(invocation -> captureInsertedOutbox());
outboxService.createForQuotaAdjustment(log, "cmd-quota-1", "request-hash-secret");
assertEquals(9, captureInsertedOutbox().getMaxAttempt(),
"outbox maxAttempt 必须来自 muse.account.events.publish-worker.max-attempt");
}
@Test
void should_fallbackTimestampToOutboxCreateTimeWhenAuditLogCreateTimeMissing() {
MemberEntitlementAuditLogDO log = entitlementLog(8001L, 1001L, "quota_adjustment");
log.setCreateTime(null);
when(outboxMapper.insertIgnore(any(AccountEventPublishOutboxDO.class))).thenReturn(1);
when(outboxMapper.selectByTenantIdAndOutboxId(any(), any())).thenAnswer(invocation -> captureInsertedOutbox());
outboxService.createForQuotaAdjustment(log, "cmd-quota-1", "request-hash-secret");
AccountEventPublishOutboxDO inserted = captureInsertedOutbox();
Map<String, Object> payload = JsonUtils.parseObject(inserted.getPayloadSummary(), Map.class);
assertEquals(inserted.getCreateTime().toString(), payload.get("timestamp"),
"audit log createTime 缺失时payload timestamp 必须回退到 outbox createTime");
}
@Test
void should_reuseExistingOutboxOnDuplicateCreate() {
MemberEntitlementAuditLogDO log = entitlementLog(8001L, 1001L, "quota_adjustment");
AccountEventPublishOutboxDO existing = new AccountEventPublishOutboxDO();
existing.setId(601L);
existing.setAuditLogId(8001L);
existing.setNotificationType("quota_alert");
when(outboxMapper.insertIgnore(any(AccountEventPublishOutboxDO.class))).thenReturn(0);
when(outboxMapper.selectByTenantIdAndAuditLogIdAndNotificationType(100L, 8001L,
"quota_alert")).thenReturn(existing);
AccountEventPublishOutboxDO result =
outboxService.createForQuotaAdjustment(log, "cmd-quota-1", "request-hash-secret");
assertEquals(601L, result.getId());
}
@Test
void should_notCreateOutboxForUnsupportedChangeTypeOrInvalidOwnerOrMissingAuditId() {
assertNull(outboxService.createForQuotaAdjustment(entitlementLog(8001L, 1001L, "subscription_sync"),
"cmd-quota-1", "hash"));
assertNull(outboxService.createForQuotaAdjustment(entitlementLog(8001L, 0L, "quota_adjustment"),
"cmd-quota-1", "hash"));
assertNull(outboxService.createForQuotaAdjustment(entitlementLog(null, 1001L, "quota_adjustment"),
"cmd-quota-1", "hash"));
verify(outboxMapper, never()).insertIgnore(any(AccountEventPublishOutboxDO.class));
}
@Test
void should_failClosedBeforeInsertWhenPayloadInvalid() {
MemberEntitlementAuditLogDO log = entitlementLog(8001L, 1001L, "quota_adjustment");
log.setDeltaValueSnapshot("{}");
ServiceException exception = assertThrows(ServiceException.class,
() -> outboxService.createForQuotaAdjustment(log, "cmd-quota-1", "request-hash-secret"));
assertEquals(1_042_000_050, exception.getCode());
assertEquals("ACCOUNT_EVENTS_PAYLOAD_INVALID", exception.getMessage());
verify(outboxMapper, never()).insertIgnore(any(AccountEventPublishOutboxDO.class));
}
@Test
void should_notDependOnEventsPublishApiInOutboxCreationService() {
for (Field field : AccountEventPublishOutboxServiceImpl.class.getDeclaredFields()) {
assertFalse(EventsPublishApi.class.equals(field.getType()),
"outbox 创建服务只允许写 Account 本域 outbox不允许持有或调用 EventsPublishApi");
}
}
private AccountEventPublishOutboxDO captureInsertedOutbox() {
ArgumentCaptor<AccountEventPublishOutboxDO> captor =
ArgumentCaptor.forClass(AccountEventPublishOutboxDO.class);
verify(outboxMapper).insertIgnore(captor.capture());
return captor.getValue();
}
private AccountEventPublishOutboxServiceImpl outboxServiceWithMaxAttempt(int maxAttempt) {
MuseAccountEventsProperties properties = new MuseAccountEventsProperties();
properties.getPublishWorker().setMaxAttempt(maxAttempt);
return new AccountEventPublishOutboxServiceImpl(outboxMapper, properties);
}
private static void assertNoForbiddenPayloadFields(String payloadJson) {
for (String forbidden : Set.of("requestHash", "reason", "operatorUserId", "beforeValueSnapshot",
"deltaValueSnapshot", "afterValueSnapshot", "correlationId", "external", "errorStack",
"credential")) {
assertFalse(payloadJson.contains(forbidden), "payload 不得包含敏感字段:" + forbidden);
}
}
private static MemberEntitlementAuditLogDO entitlementLog(Long id, Long accountUserId, String changeType) {
MemberEntitlementAuditLogDO log = MemberEntitlementAuditLogDO.builder()
.id(id)
.accountUserId(accountUserId)
.changeType(changeType)
.sourceOwner("manual")
.sourceId(7001L)
.idempotencyKey("cmd-quota-1:ai_calls")
.beforeValueSnapshot("{\"resourceType\":\"ai_calls\",\"limit\":100,\"used\":40,\"remaining\":60}")
.deltaValueSnapshot("{\"resourceType\":\"ai_calls\",\"delta\":25}")
.afterValueSnapshot("{\"resourceType\":\"ai_calls\",\"limit\":125,\"used\":40,\"remaining\":85}")
.reasonCode("approval-1")
.reasonMessage("内部原因不得外发")
.operatorUserId(9001L)
.auditEventId(7001L)
.build();
log.setTenantId(100L);
log.setCreateTime(LocalDateTime.of(2026, 6, 7, 12, 0));
return log;
}
}

View File

@ -0,0 +1,365 @@
package cn.iocoder.muse.module.member.application.account;
import cn.iocoder.muse.framework.common.pojo.CommonResult;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.muse.module.events.api.publish.EventsPublishApi;
import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishReqDTO;
import cn.iocoder.muse.module.events.api.publish.dto.EventsPublishRespDTO;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountEventPublishOutboxDO;
import cn.iocoder.muse.module.member.dal.mysql.account.AccountEventPublishOutboxMapper;
import cn.iocoder.muse.module.member.framework.config.MuseAccountEventsProperties;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Account Events publish worker 测试
*/
class AccountEventPublishWorkerTest {
private final AccountEventPublishOutboxMapper outboxMapper =
mock(AccountEventPublishOutboxMapper.class);
private final EventsPublishApi eventsPublishApi = mock(EventsPublishApi.class);
@AfterEach
void clearTenantContext() {
TenantContextHolder.clear();
}
@Test
void should_notClaimOutboxWhenWorkerDisabled() {
AccountEventPublishWorker worker = worker(false);
int dispatched = worker.dispatchOnce();
assertEquals(0, dispatched);
verify(outboxMapper, never()).claimNextPublishOutbox(anyLong());
verify(eventsPublishApi, never()).publish(any());
}
@Test
void should_publishAcceptedQuotaAlertForAccountOwnerAndMarkPublished() {
AccountEventPublishOutboxDO outbox = runningOutbox();
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
EventsPublishRespDTO respDTO = accepted("evt_account_1", 401L);
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(respDTO));
when(outboxMapper.markPublished(501L, 100L, 1, "evt_account_1", 401L)).thenReturn(1);
int dispatched = worker(true).dispatchOnce();
assertEquals(1, dispatched);
EventsPublishReqDTO reqDTO = capturePublishReq();
assertEquals("acct_evt:0123456789abcdef0123456789abcdef", reqDTO.getCommandId());
assertEquals(100L, reqDTO.getTenantId());
assertEquals(1001L, reqDTO.getOwnerUserId());
assertEquals("account", reqDTO.getSourceOwner());
assertEquals("account_quota_adjustment", reqDTO.getSourceType());
assertEquals("8001", reqDTO.getSourceId());
assertEquals("__none__", reqDTO.getSourceRevision());
assertEquals("notification", reqDTO.getEventType());
assertEquals("account_quota", reqDTO.getResourceType());
assertEquals("8001", reqDTO.getResourceId());
assertEquals(LocalDateTime.of(2026, 6, 7, 12, 0), reqDTO.getEmittedAt());
assertPayload(reqDTO.getPayloadSummary());
verify(outboxMapper).markPublished(501L, 100L, 1, "evt_account_1", 401L);
assertNull(TenantContextHolder.getTenantId(), "worker 完成后必须清理租户上下文");
}
@Test
void should_claimWithConfiguredTimeoutSeconds() {
AccountEventPublishOutboxDO outbox = runningOutbox();
when(outboxMapper.claimNextPublishOutbox(180L)).thenReturn(outbox);
EventsPublishRespDTO respDTO = accepted("evt_account_claim_timeout", 404L);
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(respDTO));
when(outboxMapper.markPublished(501L, 100L, 1, "evt_account_claim_timeout", 404L)).thenReturn(1);
MuseAccountEventsProperties properties = new MuseAccountEventsProperties();
properties.getPublishWorker().setEnabled(true);
properties.getPublishWorker().setClaimTimeoutSeconds(180L);
int dispatched = new AccountEventPublishWorker(outboxMapper, eventsPublishApi, properties).dispatchOnce();
assertEquals(1, dispatched);
verify(outboxMapper).claimNextPublishOutbox(180L);
}
@Test
void should_markDuplicateAcceptedNotificationAsPublished() {
AccountEventPublishOutboxDO outbox = runningOutbox();
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
EventsPublishRespDTO respDTO = accepted("evt_account_dup", 402L);
respDTO.setDuplicate(true);
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(respDTO));
when(outboxMapper.markPublished(501L, 100L, 1, "evt_account_dup", 402L)).thenReturn(1);
int dispatched = worker(true).dispatchOnce();
assertEquals(1, dispatched);
verify(outboxMapper).markPublished(501L, 100L, 1, "evt_account_dup", 402L);
}
@Test
void should_markRejectedAndBlockedPublishAsDeadLetter() {
assertDeadLetterForTerminalStatus("rejected");
assertDeadLetterForTerminalStatus("blocked");
}
@Test
void should_markCommonResultErrorAsRetryableBeforeLastAttemptAndDeadLetterOnLastAttempt() {
AccountEventPublishOutboxDO retryable = runningOutbox();
retryable.setAttemptCount(2);
retryable.setMaxAttempt(5);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(retryable);
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.error(500, "temporary secret stack"));
when(outboxMapper.markRetryable(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
any(LocalDateTime.class), anyString(), anyString())).thenReturn(1);
assertEquals(0, worker(true).dispatchOnce());
verify(outboxMapper).markRetryable(org.mockito.Mockito.eq(501L), org.mockito.Mockito.eq(100L),
org.mockito.Mockito.eq(2), any(LocalDateTime.class),
org.mockito.Mockito.eq("ACCOUNT_EVENTS_PUBLISH_API_FAILED"),
org.mockito.Mockito.eq("Account Events publish failed"));
AccountEventPublishOutboxMapper lastAttemptMapper = mock(AccountEventPublishOutboxMapper.class);
EventsPublishApi lastAttemptApi = mock(EventsPublishApi.class);
AccountEventPublishOutboxDO lastAttempt = runningOutbox();
lastAttempt.setAttemptCount(5);
lastAttempt.setMaxAttempt(5);
when(lastAttemptMapper.claimNextPublishOutbox(60L)).thenReturn(lastAttempt);
when(lastAttemptApi.publish(any())).thenReturn(CommonResult.error(500, "temporary secret stack"));
when(lastAttemptMapper.markDeadLetter(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
anyString(), anyString())).thenReturn(1);
assertEquals(0, worker(true, lastAttemptMapper, lastAttemptApi).dispatchOnce());
verify(lastAttemptMapper).markDeadLetter(501L, 100L, 5,
"ACCOUNT_EVENTS_PUBLISH_RETRY_EXHAUSTED", "Account Events publish retry exhausted");
}
@Test
void should_markExceptionAsRetryableBeforeLastAttemptAndDeadLetterOnLastAttempt() {
AccountEventPublishOutboxDO retryable = runningOutbox();
retryable.setAttemptCount(2);
retryable.setMaxAttempt(5);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(retryable);
when(eventsPublishApi.publish(any())).thenThrow(new IllegalStateException("secret stack"));
when(outboxMapper.markRetryable(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
any(LocalDateTime.class), anyString(), anyString())).thenReturn(1);
assertEquals(0, worker(true).dispatchOnce());
verify(outboxMapper).markRetryable(org.mockito.Mockito.eq(501L), org.mockito.Mockito.eq(100L),
org.mockito.Mockito.eq(2), any(LocalDateTime.class),
org.mockito.Mockito.eq("ACCOUNT_EVENTS_PUBLISH_TEMPORARY_UNAVAILABLE"),
org.mockito.Mockito.eq("Account Events publish temporary unavailable"));
AccountEventPublishOutboxMapper lastAttemptMapper = mock(AccountEventPublishOutboxMapper.class);
EventsPublishApi lastAttemptApi = mock(EventsPublishApi.class);
AccountEventPublishOutboxDO lastAttempt = runningOutbox();
lastAttempt.setAttemptCount(5);
lastAttempt.setMaxAttempt(5);
when(lastAttemptMapper.claimNextPublishOutbox(60L)).thenReturn(lastAttempt);
when(lastAttemptApi.publish(any())).thenThrow(new IllegalStateException("secret stack"));
when(lastAttemptMapper.markDeadLetter(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
anyString(), anyString())).thenReturn(1);
assertEquals(0, worker(true, lastAttemptMapper, lastAttemptApi).dispatchOnce());
verify(lastAttemptMapper).markDeadLetter(501L, 100L, 5,
"ACCOUNT_EVENTS_PUBLISH_RETRY_EXHAUSTED", "Account Events publish retry exhausted");
}
@Test
void should_failClosedForInvalidPayloadAndNotPublish() {
AccountEventPublishOutboxDO outbox = runningOutbox();
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of(
"type", "quota_alert",
"message", "额度已调整",
"resourceRef", Map.of("resourceType", "account_quota", "resourceId", "8001"),
"timestamp", "2026-06-07T10:00:00")));
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
when(outboxMapper.markDeadLetter(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
anyString(), anyString())).thenReturn(1);
int dispatched = worker(true).dispatchOnce();
assertEquals(0, dispatched);
verify(eventsPublishApi, never()).publish(any());
verify(outboxMapper).markDeadLetter(501L, 100L, 1,
"ACCOUNT_EVENTS_PAYLOAD_INVALID", "Account Events publish payload invalid");
}
@Test
void should_failClosedForInvalidOwnerAndNotPublish() {
AccountEventPublishOutboxDO outbox = runningOutbox();
outbox.setAccountUserId(0L);
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
when(outboxMapper.markDeadLetter(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
anyString(), anyString())).thenReturn(1);
int dispatched = worker(true).dispatchOnce();
assertEquals(0, dispatched);
verify(eventsPublishApi, never()).publish(any());
verify(outboxMapper).markDeadLetter(501L, 100L, 1,
"ACCOUNT_EVENTS_PAYLOAD_INVALID", "Account Events publish payload invalid");
}
@Test
void should_guardTerminalStatusUpdatesWithClaimAttemptOwnership() throws NoSuchMethodException {
assertClaimOwnershipGuard("markPublished", Long.class, Long.class, Integer.class, String.class, Long.class);
assertClaimOwnershipGuard("markRetryable", Long.class, Long.class, Integer.class, LocalDateTime.class,
String.class, String.class);
assertClaimOwnershipGuard("markDeadLetter", Long.class, Long.class, Integer.class, String.class,
String.class);
}
@Test
void should_logWhenAcceptedTerminalUpdateIsSkippedByStaleClaim() {
AccountEventPublishOutboxDO outbox = runningOutbox();
when(outboxMapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
EventsPublishRespDTO respDTO = accepted("evt_stale_claim", 403L);
when(eventsPublishApi.publish(any())).thenReturn(CommonResult.success(respDTO));
when(outboxMapper.markPublished(501L, 100L, 1, "evt_stale_claim", 403L)).thenReturn(0);
ListAppender<ILoggingEvent> appender = attachWorkerLogAppender();
try {
int dispatched = worker(true).dispatchOnce();
assertEquals(1, dispatched);
assertTrue(appender.list.stream().anyMatch(event -> event.getLevel().equals(Level.WARN)
&& event.getFormattedMessage().contains("Account Events publish 终态回写被跳过")
&& event.getFormattedMessage().contains("outboxId=501")
&& event.getFormattedMessage().contains("attempt=1")
&& event.getFormattedMessage().contains("targetStatus=published")),
"accepted 发布后若终态回写 0 行,必须记录 stale claim 诊断日志");
} finally {
detachWorkerLogAppender(appender);
}
}
private void assertDeadLetterForTerminalStatus(String status) {
AccountEventPublishOutboxMapper mapper = mock(AccountEventPublishOutboxMapper.class);
EventsPublishApi api = mock(EventsPublishApi.class);
AccountEventPublishOutboxDO outbox = runningOutbox();
when(mapper.claimNextPublishOutbox(60L)).thenReturn(outbox);
EventsPublishRespDTO respDTO = new EventsPublishRespDTO();
respDTO.setPublishStatus(status);
respDTO.setPublishErrorCode("payload_schema_mismatch");
when(api.publish(any())).thenReturn(CommonResult.success(respDTO));
when(mapper.markDeadLetter(anyLong(), anyLong(), org.mockito.Mockito.anyInt(),
anyString(), anyString())).thenReturn(1);
int dispatched = worker(true, mapper, api).dispatchOnce();
assertEquals(1, dispatched);
verify(mapper).markDeadLetter(501L, 100L, 1, "payload_schema_mismatch",
"Account Events publish rejected");
}
private static void assertClaimOwnershipGuard(String methodName, Class<?>... parameterTypes)
throws NoSuchMethodException {
Method method = AccountEventPublishOutboxMapper.class.getMethod(methodName, parameterTypes);
Update update = method.getAnnotation(Update.class);
String sql = String.join(" ", update.value()).toLowerCase().replaceAll("\\s+", " ");
assertTrue(sql.contains("publish_status = 'running'"),
methodName + " 必须只允许当前 running claim 回写终态");
assertTrue(sql.contains("attempt_count = #{claimedattemptcount}"),
methodName + " 必须用本次 claim attempt_count 防止过期 worker 覆盖新终态");
}
private static ListAppender<ILoggingEvent> attachWorkerLogAppender() {
Logger logger = (Logger) LoggerFactory.getLogger(AccountEventPublishWorker.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
return appender;
}
private static void detachWorkerLogAppender(ListAppender<ILoggingEvent> appender) {
Logger logger = (Logger) LoggerFactory.getLogger(AccountEventPublishWorker.class);
logger.detachAppender(appender);
appender.stop();
}
private AccountEventPublishWorker worker(boolean enabled) {
return worker(enabled, outboxMapper, eventsPublishApi);
}
private AccountEventPublishWorker worker(boolean enabled, AccountEventPublishOutboxMapper mapper,
EventsPublishApi api) {
MuseAccountEventsProperties properties = new MuseAccountEventsProperties();
properties.getPublishWorker().setEnabled(enabled);
return new AccountEventPublishWorker(mapper, api, properties);
}
private EventsPublishReqDTO capturePublishReq() {
ArgumentCaptor<EventsPublishReqDTO> captor = ArgumentCaptor.forClass(EventsPublishReqDTO.class);
verify(eventsPublishApi).publish(captor.capture());
return captor.getValue();
}
private static EventsPublishRespDTO accepted(String eventId, Long sequenceNo) {
EventsPublishRespDTO respDTO = new EventsPublishRespDTO();
respDTO.setPublishStatus("accepted");
respDTO.setEventId(eventId);
respDTO.setSequenceNo(sequenceNo);
return respDTO;
}
private static void assertPayload(Map<String, Object> payload) {
assertEquals(Set.of("type", "message", "resourceRef", "timestamp"), payload.keySet());
assertEquals("quota_alert", payload.get("type"));
assertEquals("额度已调整", payload.get("message"));
Map<?, ?> resourceRef = assertInstanceOf(Map.class, payload.get("resourceRef"));
assertEquals(Set.of("resourceType", "resourceId"), resourceRef.keySet());
assertEquals("account_quota", resourceRef.get("resourceType"));
assertInstanceOf(Number.class, resourceRef.get("resourceId"));
assertEquals(8001L, ((Number) resourceRef.get("resourceId")).longValue());
}
private static AccountEventPublishOutboxDO runningOutbox() {
AccountEventPublishOutboxDO outbox = new AccountEventPublishOutboxDO();
outbox.setId(501L);
outbox.setTenantId(100L);
outbox.setOutboxId("acct_out:0123456789abcdef0123456789abcdef");
outbox.setCommandId("acct_evt:0123456789abcdef0123456789abcdef");
outbox.setAuditLogId(8001L);
outbox.setAccountUserId(1001L);
outbox.setSourceRevision("__none__");
outbox.setEventType("notification");
outbox.setNotificationType("quota_alert");
outbox.setResourceRefType("account_quota");
outbox.setResourceRefId(8001L);
outbox.setPayloadSummary(JsonUtils.toJsonString(Map.of(
"type", "quota_alert",
"message", "额度已调整",
"resourceRef", Map.of("resourceType", "account_quota", "resourceId", 8001L),
"timestamp", "2026-06-07T12:00:00")));
outbox.setAttemptCount(1);
outbox.setMaxAttempt(5);
outbox.setCreateTime(LocalDateTime.of(2026, 6, 7, 12, 0));
return outbox;
}
}

View File

@ -66,6 +66,8 @@ class AccountQuotaServiceTest extends BaseMockitoUnitTest {
private AccountCommandService commandService;
@Mock
private AccountAuditService auditService;
@Mock
private AccountEventPublishOutboxService accountEventPublishOutboxService;
@Test
void should_createQuotaAdjustment_withCommandAuditAndSnapshots() {
@ -114,6 +116,16 @@ class AccountQuotaServiceTest extends BaseMockitoUnitTest {
assertEquals(25, JsonUtils.parseTree(log.getDeltaValueSnapshot()).path("delta").asInt());
assertEquals(125, JsonUtils.parseTree(log.getAfterValueSnapshot()).path("limit").asInt());
assertEquals("corr-quota-1", JsonUtils.parseTree(log.getAfterValueSnapshot()).path("correlationId").asText());
verify(accountEventPublishOutboxService, times(2)).createForQuotaAdjustment(
any(MemberEntitlementAuditLogDO.class), eq("cmd-quota-1"), eq("hash-quota"));
verify(accountEventPublishOutboxService).createForQuotaAdjustment(argThat(outboxLog ->
Long.valueOf(8001L).equals(outboxLog.getId())
&& "cmd-quota-1:ai_calls".equals(outboxLog.getIdempotencyKey())),
eq("cmd-quota-1"), eq("hash-quota"));
verify(accountEventPublishOutboxService).createForQuotaAdjustment(argThat(outboxLog ->
Long.valueOf(8002L).equals(outboxLog.getId())
&& "cmd-quota-1:exports".equals(outboxLog.getIdempotencyKey())),
eq("cmd-quota-1"), eq("hash-quota"));
verify(auditService).record(argThat(req -> "adminCreateQuotaAdjustment".equals(req.getOperationId())
&& Long.valueOf(9001L).equals(req.getActorUserId())
&& Long.valueOf(1001L).equals(req.getAccountUserId())
@ -205,6 +217,7 @@ class AccountQuotaServiceTest extends BaseMockitoUnitTest {
assertEquals("idempotent_hit", result.getStatus());
verify(quotaMapper, never()).updateById(any(MemberQuotaDO.class));
verify(auditLogMapper, never()).insert(any(MemberEntitlementAuditLogDO.class));
verify(accountEventPublishOutboxService, never()).createForQuotaAdjustment(any(), anyString(), anyString());
verify(auditService, never()).record(any());
verify(commandService, never()).recordSucceeded(any(), anyString());
}

View File

@ -0,0 +1,64 @@
-- P1R-7e Account quota adjustment event publish outbox Schema。
-- 迁移边界Account owner 只保存面向 accountUserId 的 quota_alert 摘要;不保存 reason 原文、配额快照、operator 明细或外部凭据。
CREATE TABLE muse_account_event_publish_outbox (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
outbox_id VARCHAR(128) NOT NULL,
command_id VARCHAR(128) NOT NULL,
source_command_id VARCHAR(128) NOT NULL,
audit_log_id BIGINT NOT NULL,
account_user_id BIGINT NOT NULL,
operator_user_id BIGINT,
resource_type VARCHAR(64) NOT NULL,
delta_amount BIGINT NOT NULL,
source_owner VARCHAR(64) NOT NULL,
audit_event_id BIGINT,
request_hash VARCHAR(128),
source_revision VARCHAR(80) NOT NULL,
event_type VARCHAR(32) NOT NULL,
notification_type VARCHAR(64) NOT NULL,
resource_ref_type VARCHAR(64) NOT NULL,
resource_ref_id BIGINT NOT NULL,
payload_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
publish_status VARCHAR(32) NOT NULL,
attempt_count INT NOT NULL DEFAULT 0,
max_attempt INT NOT NULL DEFAULT 5,
next_retry_at TIMESTAMP,
claimed_at TIMESTAMP,
claim_expires_at TIMESTAMP,
last_error_code VARCHAR(64),
last_error_message TEXT,
published_event_id VARCHAR(128),
published_sequence_no BIGINT,
creator VARCHAR(64) NOT NULL DEFAULT '',
updater VARCHAR(64) NOT NULL DEFAULT '',
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT uk_muse_account_event_publish_outbox_id UNIQUE (tenant_id, outbox_id),
CONSTRAINT uk_muse_account_event_publish_outbox_command UNIQUE (tenant_id, command_id),
CONSTRAINT uk_muse_account_event_publish_outbox_audit_notification UNIQUE (tenant_id, audit_log_id, notification_type),
CONSTRAINT chk_muse_account_event_publish_outbox_event_type CHECK (event_type = 'notification'),
CONSTRAINT chk_muse_account_event_publish_outbox_notification_type CHECK (notification_type = 'quota_alert'),
CONSTRAINT chk_muse_account_event_publish_outbox_status CHECK (publish_status IN ('queued', 'running', 'retryable', 'published', 'dead_letter')),
CONSTRAINT chk_muse_account_event_publish_outbox_account_owner CHECK (account_user_id > 0),
CONSTRAINT chk_muse_account_event_publish_outbox_audit_log CHECK (audit_log_id > 0),
CONSTRAINT chk_muse_account_event_publish_outbox_resource_ref CHECK (resource_ref_id > 0),
CONSTRAINT chk_muse_account_event_publish_outbox_attempt_count CHECK (attempt_count >= 0),
CONSTRAINT chk_muse_account_event_publish_outbox_max_attempt CHECK (max_attempt > 0)
);
CREATE INDEX idx_muse_account_event_publish_outbox_claim
ON muse_account_event_publish_outbox(tenant_id, publish_status, next_retry_at, id)
WHERE deleted = FALSE;
CREATE INDEX idx_muse_account_event_publish_outbox_owner_status
ON muse_account_event_publish_outbox(tenant_id, account_user_id, publish_status, create_time);
CREATE INDEX idx_muse_account_event_publish_outbox_audit_log
ON muse_account_event_publish_outbox(tenant_id, audit_log_id);
CREATE TRIGGER trg_muse_account_event_publish_outbox_update_time
BEFORE UPDATE ON muse_account_event_publish_outbox
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();