diff --git a/muse-cloud/muse-module-member/muse-module-member-api/src/main/java/cn/iocoder/muse/module/member/enums/ErrorCodeConstants.java b/muse-cloud/muse-module-member/muse-module-member-api/src/main/java/cn/iocoder/muse/module/member/enums/ErrorCodeConstants.java
index 72a1b8db..ab6c25c9 100644
--- a/muse-cloud/muse-module-member/muse-module-member-api/src/main/java/cn/iocoder/muse/module/member/enums/ErrorCodeConstants.java
+++ b/muse-cloud/muse-module-member/muse-module-member-api/src/main/java/cn/iocoder/muse/module/member/enums/ErrorCodeConstants.java
@@ -55,4 +55,19 @@ public interface ErrorCodeConstants {
ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_004_012_000, "用户分组不存在");
ErrorCode GROUP_HAS_USER = new ErrorCode(1_004_012_001, "用户分组下存在用户,无法删除");
+ // ========== Account 真实 API 1-004-020-000 ==========
+ ErrorCode ACCOUNT_API_VERSION_UNSUPPORTED = new ErrorCode(1_004_020_000, "Account API 版本不支持");
+ ErrorCode ACCOUNT_USER_NOT_EXISTS = new ErrorCode(1_004_020_001, "账户用户不存在");
+ ErrorCode ACCOUNT_RESOURCE_FORBIDDEN = new ErrorCode(1_004_020_002, "无权访问该账户资源");
+ ErrorCode ACCOUNT_COMMAND_ID_REQUIRED = new ErrorCode(1_004_020_003, "commandId 不能为空");
+ ErrorCode ACCOUNT_COMMAND_ID_CONFLICT = new ErrorCode(1_004_020_004, "commandId 已用于不同请求");
+ ErrorCode ACCOUNT_VERSION_CONFLICT = new ErrorCode(1_004_020_005, "Account 版本冲突");
+ ErrorCode ACCOUNT_NEW_API_BINDING_NOT_EXISTS = new ErrorCode(1_004_020_006, "New-API 绑定不存在");
+ ErrorCode ACCOUNT_NEW_API_UNAVAILABLE = new ErrorCode(1_004_020_007, "New-API 暂不可用");
+ ErrorCode ACCOUNT_INTEGRATION_CALL_NOT_EXISTS = new ErrorCode(1_004_020_008, "集成调用不存在");
+ ErrorCode ACCOUNT_QUOTA_EXHAUSTED = new ErrorCode(1_004_020_009, "账户额度不足");
+ ErrorCode ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID = new ErrorCode(1_004_020_010, "下载凭证无效或已过期");
+ ErrorCode ACCOUNT_EXPORT_SOURCE_BLOCKED = new ErrorCode(1_004_020_011, "导出来源已被阻断");
+ ErrorCode ACCOUNT_REQUEST_HASH_REQUIRED = new ErrorCode(1_004_020_012, "requestHash 不能为空");
+
}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountAuditService.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountAuditService.java
new file mode 100644
index 00000000..3a224f31
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountAuditService.java
@@ -0,0 +1,41 @@
+package cn.iocoder.muse.module.member.application.account;
+
+import cn.iocoder.muse.module.member.dal.dataobject.account.AccountAuditDO;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * Account 领域审计服务。
+ */
+public interface AccountAuditService {
+
+ /**
+ * 记录 Account 审计。
+ *
+ *
审计只存脱敏快照和必要定位字段,不能把 New-API token、Prompt/Response 原文写入长期表。
+ */
+ AccountAuditDO record(AuditCreateReq req);
+
+ /**
+ * Account 审计创建请求。
+ */
+ @Data
+ @Builder
+ class AuditCreateReq {
+ private String operationId;
+ private Long actorUserId;
+ private Long accountUserId;
+ private String side;
+ private String targetType;
+ private Long targetId;
+ private String commandId;
+ private String requestHash;
+ private String correlationId;
+ private String beforeSnapshot;
+ private String afterSnapshot;
+ private String status;
+ private String errorCode;
+ private String errorMessage;
+ }
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountAuditServiceImpl.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountAuditServiceImpl.java
new file mode 100644
index 00000000..e51569c3
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountAuditServiceImpl.java
@@ -0,0 +1,108 @@
+package cn.iocoder.muse.module.member.application.account;
+
+import cn.iocoder.muse.framework.common.util.json.JsonUtils;
+import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.muse.module.member.dal.dataobject.account.AccountAuditDO;
+import cn.iocoder.muse.module.member.dal.mysql.account.AccountAuditMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * Account 领域审计服务实现。
+ */
+@Service
+public class AccountAuditServiceImpl implements AccountAuditService {
+
+ private static final String REDACTED = "[REDACTED]";
+ private static final Set SENSITIVE_FIELDS = Set.of(
+ "newapitoken", "token", "accesstoken", "refreshtoken", "apikey", "secret", "authorization",
+ "prompt", "response", "externalorderraw", "ipaddress");
+
+ @Resource
+ private AccountAuditMapper auditMapper;
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public AccountAuditDO record(AuditCreateReq req) {
+ // 审计快照先脱敏再入库,避免把外部 token、Prompt/Response 全文等高敏内容长期保存。
+ AccountAuditDO audit = AccountAuditDO.builder()
+ .operationId(req.getOperationId())
+ .actorUserId(req.getActorUserId())
+ .accountUserId(req.getAccountUserId())
+ .side(req.getSide())
+ .targetType(req.getTargetType())
+ .targetId(req.getTargetId())
+ .commandId(req.getCommandId())
+ .requestHash(req.getRequestHash())
+ .correlationId(req.getCorrelationId())
+ .beforeSnapshot(sanitizeSnapshot(req.getBeforeSnapshot()))
+ .afterSnapshot(sanitizeSnapshot(req.getAfterSnapshot()))
+ .status(req.getStatus())
+ .errorCode(req.getErrorCode())
+ .errorMessage(req.getErrorMessage())
+ .build();
+ audit.setTenantId(TenantContextHolder.getRequiredTenantId());
+ audit.setCreateTime(LocalDateTime.now());
+ auditMapper.insert(audit);
+ return audit;
+ }
+
+ private String sanitizeSnapshot(String snapshot) {
+ if (snapshot == null || snapshot.isBlank()) {
+ return snapshot;
+ }
+ try {
+ JsonNode tree = JsonUtils.parseTree(snapshot);
+ return JsonUtils.toJsonString(redactNode(tree));
+ } catch (RuntimeException ex) {
+ return containsSensitiveText(snapshot) ? "{\"redacted\":true}" : snapshot;
+ }
+ }
+
+ private JsonNode redactNode(JsonNode node) {
+ if (node == null || node.isNull()) {
+ return JsonUtils.getObjectMapper().nullNode();
+ }
+ if (node.isObject()) {
+ ObjectNode redactedObject = JsonUtils.getObjectMapper().createObjectNode();
+ node.fields().forEachRemaining(entry -> {
+ if (isSensitiveField(entry.getKey())) {
+ redactedObject.put(entry.getKey(), REDACTED);
+ return;
+ }
+ redactedObject.set(entry.getKey(), redactNode(entry.getValue()));
+ });
+ return redactedObject;
+ }
+ if (node.isArray()) {
+ ArrayNode redactedArray = JsonUtils.getObjectMapper().createArrayNode();
+ for (JsonNode child : node) {
+ redactedArray.add(redactNode(child));
+ }
+ return redactedArray;
+ }
+ return node;
+ }
+
+ private boolean isSensitiveField(String fieldName) {
+ return SENSITIVE_FIELDS.contains(normalizeFieldName(fieldName));
+ }
+
+ private boolean containsSensitiveText(String snapshot) {
+ String normalized = snapshot.toLowerCase(Locale.ROOT);
+ return SENSITIVE_FIELDS.stream().anyMatch(normalized::contains);
+ }
+
+ private String normalizeFieldName(String fieldName) {
+ return fieldName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", "");
+ }
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountCommandService.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountCommandService.java
new file mode 100644
index 00000000..1d4a14d2
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountCommandService.java
@@ -0,0 +1,54 @@
+package cn.iocoder.muse.module.member.application.account;
+
+import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCommandDO;
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * Account 写命令幂等服务。
+ */
+public interface AccountCommandService {
+
+ /**
+ * 根据请求内容生成稳定 hash。
+ *
+ * @param payload 请求摘要或请求体
+ * @return SHA-256 hash
+ */
+ String buildRequestHash(Object payload);
+
+ /**
+ * 查询可复用命令。
+ *
+ * @return 不存在时返回 null;存在且 hash 与 envelope 一致时返回首次执行记录
+ */
+ AccountCommandDO getReplayCommand(CommandEnvelope envelope);
+
+ /**
+ * 在业务写入前预占 commandId。
+ *
+ * @return 首次请求返回 null;重复成功请求返回首次成功记录
+ */
+ AccountCommandDO reserveCommand(CommandEnvelope envelope);
+
+ /**
+ * 记录首次成功命令;如果已存在同 hash 命令,则直接返回旧记录。
+ */
+ AccountCommandDO recordSucceeded(CommandEnvelope envelope, String resultSnapshot);
+
+ /**
+ * Account 写命令 envelope。
+ */
+ @Data
+ @Builder
+ class CommandEnvelope {
+ private String commandId;
+ private String operationId;
+ private Long actorUserId;
+ private Long ownerUserId;
+ private String targetType;
+ private Long targetId;
+ private String requestHash;
+ }
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountCommandServiceImpl.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountCommandServiceImpl.java
new file mode 100644
index 00000000..ffa544d4
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountCommandServiceImpl.java
@@ -0,0 +1,197 @@
+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.tenant.core.context.TenantContextHolder;
+import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCommandDO;
+import cn.iocoder.muse.module.member.dal.mysql.account.AccountCommandMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+import java.util.Objects;
+import java.util.TreeSet;
+
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_COMMAND_ID_CONFLICT;
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_COMMAND_ID_REQUIRED;
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_REQUEST_HASH_REQUIRED;
+
+/**
+ * Account 写命令幂等服务实现。
+ */
+@Service
+public class AccountCommandServiceImpl implements AccountCommandService {
+
+ @Resource
+ private AccountCommandMapper commandMapper;
+
+ @Override
+ public String buildRequestHash(Object payload) {
+ String stablePayload = payload instanceof String text ? text : stableJson(payload);
+ return sha256(stablePayload == null ? "{}" : stablePayload);
+ }
+
+ @Override
+ public AccountCommandDO getReplayCommand(CommandEnvelope envelope) {
+ requireEnvelope(envelope);
+ AccountCommandDO command = commandMapper.selectByCommandId(envelope.getCommandId());
+ if (command == null) {
+ return null;
+ }
+ requireSameHash(command, envelope.getRequestHash());
+ requireSameEnvelope(command, envelope);
+ return command;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public AccountCommandDO reserveCommand(CommandEnvelope envelope) {
+ requireEnvelope(envelope);
+ AccountCommandDO command = AccountCommandDO.builder()
+ .commandId(envelope.getCommandId())
+ .operationId(envelope.getOperationId())
+ .actorUserId(envelope.getActorUserId())
+ .ownerUserId(envelope.getOwnerUserId())
+ .targetType(envelope.getTargetType())
+ .targetId(envelope.getTargetId())
+ .requestHash(envelope.getRequestHash())
+ .build();
+ // 先预占 commandId,再执行业务写入;重复请求看到未完成预占行时必须冲突,不能误回放。
+ command.setTenantId(TenantContextHolder.getRequiredTenantId());
+ int inserted = commandMapper.insertIgnore(command);
+ if (inserted == 1) {
+ return null;
+ }
+ AccountCommandDO replayCommand = getReplayCommand(envelope);
+ if (replayCommand == null || replayCommand.getResultSnapshot() == null
+ || replayCommand.getResultSnapshot().isBlank()) {
+ throw new ServiceException(ACCOUNT_COMMAND_ID_CONFLICT);
+ }
+ return replayCommand;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public AccountCommandDO recordSucceeded(CommandEnvelope envelope, String resultSnapshot) {
+ requireEnvelope(envelope);
+ AccountCommandDO replayCommand = commandMapper.selectByCommandId(envelope.getCommandId());
+ if (replayCommand != null) {
+ requireSameHash(replayCommand, envelope.getRequestHash());
+ requireSameEnvelope(replayCommand, envelope);
+ if (replayCommand.getResultSnapshot() != null && !replayCommand.getResultSnapshot().isBlank()) {
+ return replayCommand;
+ }
+ replayCommand.setResultSnapshot(resultSnapshot);
+ replayCommand.setTenantId(TenantContextHolder.getRequiredTenantId());
+ commandMapper.updateResult(replayCommand);
+ return replayCommand;
+ }
+
+ AccountCommandDO command = buildSucceededCommand(envelope, resultSnapshot);
+ int inserted = commandMapper.insertIgnore(command);
+ if (inserted == 0) {
+ // 并发首次成功可能同时通过前置查询;ON CONFLICT 后读取已成功命令,保持幂等返回。
+ return getReplayCommand(envelope);
+ }
+ return command;
+ }
+
+ private AccountCommandDO buildSucceededCommand(CommandEnvelope envelope, String resultSnapshot) {
+ AccountCommandDO command = AccountCommandDO.builder()
+ .commandId(envelope.getCommandId())
+ .operationId(envelope.getOperationId())
+ .actorUserId(envelope.getActorUserId())
+ .ownerUserId(envelope.getOwnerUserId())
+ .targetType(envelope.getTargetType())
+ .targetId(envelope.getTargetId())
+ .requestHash(envelope.getRequestHash())
+ .resultSnapshot(resultSnapshot)
+ .build();
+ // commandId 唯一性按租户隔离;写入当前租户,避免账户命令跨租户互相污染回放。
+ command.setTenantId(TenantContextHolder.getRequiredTenantId());
+ return command;
+ }
+
+ private void requireEnvelope(CommandEnvelope envelope) {
+ if (envelope == null || envelope.getCommandId() == null || envelope.getCommandId().isBlank()) {
+ throw new ServiceException(ACCOUNT_COMMAND_ID_REQUIRED);
+ }
+ if (envelope.getRequestHash() == null || envelope.getRequestHash().isBlank()) {
+ throw new ServiceException(ACCOUNT_REQUEST_HASH_REQUIRED);
+ }
+ if (envelope.getOperationId() == null || envelope.getOperationId().isBlank()
+ || envelope.getActorUserId() == null
+ || envelope.getOwnerUserId() == null
+ || envelope.getTargetType() == null || envelope.getTargetType().isBlank()
+ || envelope.getTargetId() == null) {
+ throw new ServiceException(ACCOUNT_COMMAND_ID_CONFLICT.getCode(),
+ "Account command envelope 缺少 operationId、actorUserId、ownerUserId、targetType 或 targetId");
+ }
+ }
+
+ private void requireSameHash(AccountCommandDO command, String requestHash) {
+ if (!Objects.equals(command.getRequestHash(), requestHash)) {
+ throw new ServiceException(ACCOUNT_COMMAND_ID_CONFLICT);
+ }
+ }
+
+ private void requireSameEnvelope(AccountCommandDO command, CommandEnvelope envelope) {
+ // requestHash 只代表请求内容;commandId 还必须绑定 operation/actor/owner/target,避免跨账户对象误回放。
+ if (!Objects.equals(command.getOperationId(), envelope.getOperationId())
+ || !Objects.equals(command.getActorUserId(), envelope.getActorUserId())
+ || !Objects.equals(command.getOwnerUserId(), envelope.getOwnerUserId())
+ || !Objects.equals(command.getTargetType(), envelope.getTargetType())
+ || !Objects.equals(command.getTargetId(), envelope.getTargetId())) {
+ throw new ServiceException(ACCOUNT_COMMAND_ID_CONFLICT);
+ }
+ }
+
+ private String stableJson(Object payload) {
+ if (payload == null) {
+ return "{}";
+ }
+ JsonNode tree = JsonUtils.getObjectMapper().valueToTree(payload);
+ return JsonUtils.toJsonString(sortJsonNode(tree));
+ }
+
+ private JsonNode sortJsonNode(JsonNode node) {
+ if (node == null || node.isNull()) {
+ return JsonUtils.getObjectMapper().nullNode();
+ }
+ if (node.isObject()) {
+ ObjectNode sortedObject = JsonUtils.getObjectMapper().createObjectNode();
+ TreeSet fieldNames = new TreeSet<>();
+ node.fieldNames().forEachRemaining(fieldNames::add);
+ for (String fieldName : fieldNames) {
+ sortedObject.set(fieldName, sortJsonNode(node.get(fieldName)));
+ }
+ return sortedObject;
+ }
+ if (node.isArray()) {
+ ArrayNode sortedArray = JsonUtils.getObjectMapper().createArrayNode();
+ for (JsonNode child : node) {
+ sortedArray.add(sortJsonNode(child));
+ }
+ return sortedArray;
+ }
+ return node;
+ }
+
+ private String sha256(String payload) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] bytes = digest.digest(payload.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(bytes);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", e);
+ }
+ }
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/dataobject/account/AccountAuditDO.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/dataobject/account/AccountAuditDO.java
new file mode 100644
index 00000000..e9882e7b
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/dataobject/account/AccountAuditDO.java
@@ -0,0 +1,50 @@
+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.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+
+/**
+ * Account 领域审计 DO。
+ *
+ * 审计只保留脱敏快照和必要定位字段,不能落 New-API token、Prompt/Response 原文等敏感内容。
+ */
+@TableName(value = "muse_account_audit", autoResultMap = true)
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AccountAuditDO extends TenantBaseDO {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private String operationId;
+ private Long actorUserId;
+ private Long accountUserId;
+ private String side;
+ private String targetType;
+ private Long targetId;
+ private String commandId;
+ private String requestHash;
+ private String correlationId;
+ @TableField(typeHandler = JsonbStringTypeHandler.class)
+ private String beforeSnapshot;
+ @TableField(typeHandler = JsonbStringTypeHandler.class)
+ private String afterSnapshot;
+ private String status;
+ private String errorCode;
+ private String errorMessage;
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/dataobject/account/AccountCommandDO.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/dataobject/account/AccountCommandDO.java
new file mode 100644
index 00000000..71308900
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/dataobject/account/AccountCommandDO.java
@@ -0,0 +1,43 @@
+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.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+
+/**
+ * Account 写命令幂等日志 DO。
+ */
+@TableName(value = "muse_account_command", autoResultMap = true)
+@Data
+@EqualsAndHashCode(callSuper = true)
+@ToString(callSuper = true)
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AccountCommandDO extends TenantBaseDO {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private String commandId;
+ private String operationId;
+ private Long actorUserId;
+ private Long ownerUserId;
+ private String targetType;
+ private Long targetId;
+ /** 请求摘要 hash,用于识别同一个 commandId 是否被复用到不同请求体。 */
+ private String requestHash;
+ /** 首次成功结果快照,重复请求必须返回该快照,不能重新执行账户写入。 */
+ @TableField(typeHandler = JsonbStringTypeHandler.class)
+ private String resultSnapshot;
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountAuditMapper.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountAuditMapper.java
new file mode 100644
index 00000000..0986f0d3
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountAuditMapper.java
@@ -0,0 +1,12 @@
+package cn.iocoder.muse.module.member.dal.mysql.account;
+
+import cn.iocoder.muse.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.muse.module.member.dal.dataobject.account.AccountAuditDO;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * Account 领域审计 Mapper。
+ */
+@Mapper
+public interface AccountAuditMapper extends BaseMapperX {
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountCommandMapper.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountCommandMapper.java
new file mode 100644
index 00000000..30777046
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountCommandMapper.java
@@ -0,0 +1,45 @@
+package cn.iocoder.muse.module.member.dal.mysql.account;
+
+import cn.iocoder.muse.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCommandDO;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Update;
+
+/**
+ * Account 写命令幂等日志 Mapper。
+ */
+@Mapper
+public interface AccountCommandMapper extends BaseMapperX {
+
+ default AccountCommandDO selectByCommandId(String commandId) {
+ return selectOne(AccountCommandDO::getCommandId, commandId);
+ }
+
+ /**
+ * 首次写入命令日志;并发重复 commandId 时不抛唯一键异常,交给服务层读取已有结果做回放。
+ */
+ @Insert("""
+ INSERT INTO muse_account_command(command_id, operation_id, actor_user_id, owner_user_id,
+ target_type, target_id, request_hash, result_snapshot, tenant_id)
+ VALUES (#{commandId}, #{operationId}, #{actorUserId}, #{ownerUserId}, #{targetType}, #{targetId},
+ #{requestHash},
+ #{resultSnapshot,typeHandler=cn.iocoder.muse.module.member.dal.type.JsonbStringTypeHandler},
+ #{tenantId})
+ ON CONFLICT (tenant_id, command_id) DO NOTHING
+ """)
+ int insertIgnore(AccountCommandDO command);
+
+ /**
+ * 补全已预占命令的首次成功结果。
+ */
+ @Update("""
+ UPDATE muse_account_command
+ SET result_snapshot = #{resultSnapshot,typeHandler=cn.iocoder.muse.module.member.dal.type.JsonbStringTypeHandler},
+ update_time = CURRENT_TIMESTAMP
+ WHERE tenant_id = #{tenantId}
+ AND command_id = #{commandId}
+ """)
+ int updateResult(AccountCommandDO command);
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/type/JsonbStringTypeHandler.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/type/JsonbStringTypeHandler.java
new file mode 100644
index 00000000..3a5b2396
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/type/JsonbStringTypeHandler.java
@@ -0,0 +1,47 @@
+package cn.iocoder.muse.module.member.dal.type;
+
+import org.apache.ibatis.type.BaseTypeHandler;
+import org.apache.ibatis.type.JdbcType;
+
+import java.sql.CallableStatement;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Types;
+
+/**
+ * PostgreSQL JSONB 原始字符串 TypeHandler。
+ *
+ * Account 审计快照和命令结果需要保持 JSON 对象语义,不能被二次序列化成字符串字面量。
+ */
+public class JsonbStringTypeHandler extends BaseTypeHandler {
+
+ @Override
+ public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType)
+ throws SQLException {
+ ps.setObject(i, parameter, Types.OTHER);
+ }
+
+ @Override
+ public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
+ return readJson(rs.getObject(columnName));
+ }
+
+ @Override
+ public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
+ return readJson(rs.getObject(columnIndex));
+ }
+
+ @Override
+ public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
+ return readJson(cs.getObject(columnIndex));
+ }
+
+ private String readJson(Object value) {
+ if (value == null) {
+ return null;
+ }
+ return value.toString();
+ }
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountApiVersionGuard.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountApiVersionGuard.java
new file mode 100644
index 00000000..4d614092
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountApiVersionGuard.java
@@ -0,0 +1,23 @@
+package cn.iocoder.muse.module.member.domain.account;
+
+import cn.iocoder.muse.framework.common.exception.ServiceException;
+
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_API_VERSION_UNSUPPORTED;
+
+/**
+ * Account API 版本守卫。
+ */
+public final class AccountApiVersionGuard {
+
+ private static final String SUPPORTED_VERSION = "1";
+
+ private AccountApiVersionGuard() {
+ }
+
+ public static void requireVersion(String version) {
+ if (!SUPPORTED_VERSION.equals(version)) {
+ throw new ServiceException(ACCOUNT_API_VERSION_UNSUPPORTED);
+ }
+ }
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountDownloadGuard.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountDownloadGuard.java
new file mode 100644
index 00000000..8707e15d
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountDownloadGuard.java
@@ -0,0 +1,31 @@
+package cn.iocoder.muse.module.member.domain.account;
+
+import cn.iocoder.muse.framework.common.exception.ServiceException;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID;
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_EXPORT_SOURCE_BLOCKED;
+
+/**
+ * Account 导出下载凭证守卫。
+ */
+public final class AccountDownloadGuard {
+
+ private AccountDownloadGuard() {
+ }
+
+ public static void requireUsableCredential(Long loginUserId, Long ownerUserId, LocalDateTime expiresAt,
+ LocalDateTime consumedAt, LocalDateTime revokedAt,
+ boolean sourceBlocked, LocalDateTime now) {
+ AccountOwnerGuard.requireOwner(loginUserId, ownerUserId);
+ if (sourceBlocked) {
+ throw new ServiceException(ACCOUNT_EXPORT_SOURCE_BLOCKED);
+ }
+ LocalDateTime checkTime = now == null ? LocalDateTime.now() : now;
+ if (expiresAt == null || !expiresAt.isAfter(checkTime) || consumedAt != null || revokedAt != null) {
+ throw new ServiceException(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID);
+ }
+ }
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountOwnerGuard.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountOwnerGuard.java
new file mode 100644
index 00000000..fa4e3ca6
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountOwnerGuard.java
@@ -0,0 +1,23 @@
+package cn.iocoder.muse.module.member.domain.account;
+
+import cn.iocoder.muse.framework.common.exception.ServiceException;
+
+import java.util.Objects;
+
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_RESOURCE_FORBIDDEN;
+
+/**
+ * Account 当前用户 owner 守卫。
+ */
+public final class AccountOwnerGuard {
+
+ private AccountOwnerGuard() {
+ }
+
+ public static void requireOwner(Long loginUserId, Long ownerUserId) {
+ if (loginUserId == null || ownerUserId == null || !Objects.equals(loginUserId, ownerUserId)) {
+ throw new ServiceException(ACCOUNT_RESOURCE_FORBIDDEN);
+ }
+ }
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountQuotaGuard.java b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountQuotaGuard.java
new file mode 100644
index 00000000..ba013cb6
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountQuotaGuard.java
@@ -0,0 +1,21 @@
+package cn.iocoder.muse.module.member.domain.account;
+
+import cn.iocoder.muse.framework.common.exception.ServiceException;
+
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_QUOTA_EXHAUSTED;
+
+/**
+ * Account 额度守卫。
+ */
+public final class AccountQuotaGuard {
+
+ private AccountQuotaGuard() {
+ }
+
+ public static void requireAvailable(Long remainingAmount, Long requiredAmount) {
+ if (remainingAmount == null || requiredAmount == null || remainingAmount < requiredAmount) {
+ throw new ServiceException(ACCOUNT_QUOTA_EXHAUSTED);
+ }
+ }
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountAuditServiceTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountAuditServiceTest.java
new file mode 100644
index 00000000..1cb14ea2
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountAuditServiceTest.java
@@ -0,0 +1,94 @@
+package cn.iocoder.muse.module.member.application.account;
+
+import cn.iocoder.muse.framework.common.util.json.JsonUtils;
+import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
+import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.muse.module.member.dal.dataobject.account.AccountAuditDO;
+import cn.iocoder.muse.module.member.dal.mysql.account.AccountAuditMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.verify;
+
+/**
+ * AccountAuditService 单元测试。
+ */
+class AccountAuditServiceTest extends BaseMockitoUnitTest {
+
+ @InjectMocks
+ private AccountAuditServiceImpl auditService;
+ @Mock
+ private AccountAuditMapper auditMapper;
+
+ @AfterEach
+ void clearTenantContext() {
+ TenantContextHolder.clear();
+ }
+
+ @Test
+ void should_insertAuditWithRequiredSpecFields() {
+ TenantContextHolder.setTenantId(100L);
+
+ AccountAuditDO audit = auditService.record(AccountAuditService.AuditCreateReq.builder()
+ .operationId("updateProfile")
+ .actorUserId(1001L)
+ .accountUserId(1001L)
+ .side("app")
+ .targetType("profile")
+ .targetId(1001L)
+ .commandId("cmd-1")
+ .requestHash("hash-1")
+ .correlationId("corr-1")
+ .beforeSnapshot("{\"profileVersion\":1}")
+ .afterSnapshot("{\"profileVersion\":2}")
+ .status("succeeded")
+ .build());
+
+ assertEquals("updateProfile", audit.getOperationId());
+ assertEquals(1001L, audit.getAccountUserId());
+ verify(auditMapper).insert(argThat((AccountAuditDO inserted) ->
+ Long.valueOf(100L).equals(inserted.getTenantId())
+ && "cmd-1".equals(inserted.getCommandId())
+ && "hash-1".equals(inserted.getRequestHash())
+ && "corr-1".equals(inserted.getCorrelationId())
+ && "{\"profileVersion\":1}".equals(inserted.getBeforeSnapshot())
+ && "{\"profileVersion\":2}".equals(inserted.getAfterSnapshot())
+ && inserted.getCreateTime() != null));
+ }
+
+ @Test
+ void should_sanitizeSensitiveFields_when_recordingAuditSnapshots() {
+ TenantContextHolder.setTenantId(100L);
+
+ AccountAuditDO audit = auditService.record(AccountAuditService.AuditCreateReq.builder()
+ .operationId("adminCreateNewApiBinding")
+ .actorUserId(9001L)
+ .accountUserId(1001L)
+ .side("admin")
+ .targetType("newApiBinding")
+ .targetId(88L)
+ .commandId("cmd-token")
+ .requestHash("hash-token")
+ .beforeSnapshot("{\"newApiToken\":\"secret-token\",\"new_api_token\":\"secret-token-2\",\"prompt\":\"full prompt\",\"ipAddress\":\"192.168.1.22\",\"ip_address\":\"10.0.0.1\"}")
+ .afterSnapshot("{\"response\":\"full response\",\"externalOrderRaw\":\"order-body\",\"status\":\"active\"}")
+ .status("succeeded")
+ .build());
+
+ String serialized = JsonUtils.toJsonString(audit);
+ assertFalse(serialized.contains("secret-token"));
+ assertFalse(serialized.contains("secret-token-2"));
+ assertFalse(serialized.contains("full prompt"));
+ assertFalse(serialized.contains("full response"));
+ assertFalse(serialized.contains("order-body"));
+ assertFalse(serialized.contains("192.168.1.22"));
+ assertFalse(serialized.contains("10.0.0.1"));
+ assertEquals("[REDACTED]", JsonUtils.parseTree(audit.getBeforeSnapshot()).path("newApiToken").asText());
+ assertEquals("[REDACTED]", JsonUtils.parseTree(audit.getBeforeSnapshot()).path("new_api_token").asText());
+ assertEquals("active", JsonUtils.parseTree(audit.getAfterSnapshot()).path("status").asText());
+ }
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountCommandServiceTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountCommandServiceTest.java
new file mode 100644
index 00000000..e5cf7132
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountCommandServiceTest.java
@@ -0,0 +1,196 @@
+package cn.iocoder.muse.module.member.application.account;
+
+import cn.iocoder.muse.framework.common.exception.ServiceException;
+import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
+import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCommandDO;
+import cn.iocoder.muse.module.member.dal.mysql.account.AccountCommandMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_COMMAND_ID_CONFLICT;
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_COMMAND_ID_REQUIRED;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.*;
+
+/**
+ * AccountCommandService 单元测试。
+ */
+class AccountCommandServiceTest extends BaseMockitoUnitTest {
+
+ @InjectMocks
+ private AccountCommandServiceImpl commandService;
+ @Mock
+ private AccountCommandMapper commandMapper;
+
+ @AfterEach
+ void clearTenantContext() {
+ TenantContextHolder.clear();
+ }
+
+ @Test
+ void should_throwRequired_when_commandIdBlank() {
+ AccountCommandService.CommandEnvelope envelope = envelope(" ", "updateProfile",
+ 1001L, 1001L, "profile", 1001L, "hash-1");
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> commandService.getReplayCommand(envelope));
+
+ assertEquals(ACCOUNT_COMMAND_ID_REQUIRED.getCode(), exception.getCode());
+ }
+
+ @Test
+ void should_returnSavedResult_when_sameCommandIdAndSameRequestHash() {
+ AccountCommandDO savedCommand = command("cmd-1", "updateProfile", 1001L, 1001L,
+ "profile", 1001L, "hash-1", "{\"profileVersion\":2}");
+ when(commandMapper.selectByCommandId("cmd-1")).thenReturn(savedCommand);
+
+ AccountCommandDO replayCommand = commandService.getReplayCommand(envelope("cmd-1", "updateProfile",
+ 1001L, 1001L, "profile", 1001L, "hash-1"));
+
+ assertSame(savedCommand, replayCommand);
+ assertEquals("{\"profileVersion\":2}", replayCommand.getResultSnapshot());
+ verify(commandMapper, never()).insert(any(AccountCommandDO.class));
+ }
+
+ @Test
+ void should_throwConflict_when_sameCommandIdButDifferentRequestHash() {
+ when(commandMapper.selectByCommandId("cmd-1")).thenReturn(command("cmd-1", "updateProfile",
+ 1001L, 1001L, "profile", 1001L, "hash-1", "{\"profileVersion\":2}"));
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> commandService.getReplayCommand(envelope("cmd-1", "updateProfile",
+ 1001L, 1001L, "profile", 1001L, "hash-2")));
+
+ assertEquals(ACCOUNT_COMMAND_ID_CONFLICT.getCode(), exception.getCode());
+ }
+
+ @Test
+ void should_throwConflict_when_sameHashButDifferentEnvelope() {
+ when(commandMapper.selectByCommandId("cmd-1")).thenReturn(command("cmd-1", "updateProfile",
+ 1001L, 1001L, "profile", 1001L, "hash-1", "{\"profileVersion\":2}"));
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> commandService.getReplayCommand(envelope("cmd-1", "appCreateQuotaRequest",
+ 1001L, 1001L, "profile", 1001L, "hash-1")));
+
+ assertEquals(ACCOUNT_COMMAND_ID_CONFLICT.getCode(), exception.getCode());
+ }
+
+ @Test
+ void should_reserveCommandBeforeBusinessWrite_when_firstRequest() {
+ TenantContextHolder.setTenantId(100L);
+ when(commandMapper.insertIgnore(any(AccountCommandDO.class))).thenReturn(1);
+
+ AccountCommandDO replayCommand = commandService.reserveCommand(envelope("cmd-1", "updateProfile",
+ 1001L, 1001L, "profile", 1001L, "hash-1"));
+
+ assertNull(replayCommand);
+ verify(commandMapper).insertIgnore(argThat((AccountCommandDO inserted) -> "cmd-1".equals(inserted.getCommandId())
+ && "updateProfile".equals(inserted.getOperationId())
+ && Long.valueOf(1001L).equals(inserted.getActorUserId())
+ && Long.valueOf(1001L).equals(inserted.getOwnerUserId())
+ && "profile".equals(inserted.getTargetType())
+ && Long.valueOf(1001L).equals(inserted.getTargetId())
+ && Long.valueOf(100L).equals(inserted.getTenantId())
+ && inserted.getResultSnapshot() == null));
+ }
+
+ @Test
+ void should_throwConflict_when_reserveSeesUnfinishedCommand() {
+ TenantContextHolder.setTenantId(100L);
+ when(commandMapper.insertIgnore(any(AccountCommandDO.class))).thenReturn(0);
+ when(commandMapper.selectByCommandId("cmd-1")).thenReturn(command("cmd-1", "updateProfile",
+ 1001L, 1001L, "profile", 1001L, "hash-1", null));
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> commandService.reserveCommand(envelope("cmd-1", "updateProfile",
+ 1001L, 1001L, "profile", 1001L, "hash-1")));
+
+ assertEquals(ACCOUNT_COMMAND_ID_CONFLICT.getCode(), exception.getCode());
+ }
+
+ @Test
+ void should_returnReplay_when_reserveSeesSucceededCommand() {
+ TenantContextHolder.setTenantId(100L);
+ AccountCommandDO savedCommand = command("cmd-1", "updateProfile", 1001L, 1001L,
+ "profile", 1001L, "hash-1", "{\"profileVersion\":2}");
+ when(commandMapper.insertIgnore(any(AccountCommandDO.class))).thenReturn(0);
+ when(commandMapper.selectByCommandId("cmd-1")).thenReturn(savedCommand);
+
+ AccountCommandDO replayCommand = commandService.reserveCommand(envelope("cmd-1", "updateProfile",
+ 1001L, 1001L, "profile", 1001L, "hash-1"));
+
+ assertSame(savedCommand, replayCommand);
+ }
+
+ @Test
+ void should_completeReservedCommand_when_recordSucceededAfterBusinessWrite() {
+ TenantContextHolder.setTenantId(100L);
+ AccountCommandDO reservedCommand = command("cmd-1", "updateProfile", 1001L, 1001L,
+ "profile", 1001L, "hash-1", null);
+ when(commandMapper.selectByCommandId("cmd-1")).thenReturn(reservedCommand);
+ when(commandMapper.updateResult(any(AccountCommandDO.class))).thenReturn(1);
+
+ AccountCommandDO command = commandService.recordSucceeded(envelope("cmd-1", "updateProfile",
+ 1001L, 1001L, "profile", 1001L, "hash-1"), "{\"profileVersion\":2}");
+
+ assertEquals("{\"profileVersion\":2}", command.getResultSnapshot());
+ verify(commandMapper).updateResult(argThat((AccountCommandDO updated) -> "cmd-1".equals(updated.getCommandId())
+ && Long.valueOf(100L).equals(updated.getTenantId())
+ && "{\"profileVersion\":2}".equals(updated.getResultSnapshot())));
+ }
+
+ @Test
+ void should_buildStableHash_when_payloadKeyOrderDiffers() {
+ Map first = new LinkedHashMap<>();
+ first.put("nickname", "Muse");
+ first.put("expectedVersion", 3);
+ Map second = new LinkedHashMap<>();
+ second.put("expectedVersion", 3);
+ second.put("nickname", "Muse");
+
+ String firstHash = commandService.buildRequestHash(first);
+ String secondHash = commandService.buildRequestHash(second);
+
+ assertEquals(64, firstHash.length());
+ assertEquals(firstHash, secondHash);
+ }
+
+ private static AccountCommandService.CommandEnvelope envelope(String commandId, String operationId,
+ Long actorUserId, Long ownerUserId,
+ String targetType, Long targetId,
+ String requestHash) {
+ return AccountCommandService.CommandEnvelope.builder()
+ .commandId(commandId)
+ .operationId(operationId)
+ .actorUserId(actorUserId)
+ .ownerUserId(ownerUserId)
+ .targetType(targetType)
+ .targetId(targetId)
+ .requestHash(requestHash)
+ .build();
+ }
+
+ private static AccountCommandDO command(String commandId, String operationId, Long actorUserId, Long ownerUserId,
+ String targetType, Long targetId, String requestHash, String resultSnapshot) {
+ return AccountCommandDO.builder()
+ .commandId(commandId)
+ .operationId(operationId)
+ .actorUserId(actorUserId)
+ .ownerUserId(ownerUserId)
+ .targetType(targetType)
+ .targetId(targetId)
+ .requestHash(requestHash)
+ .resultSnapshot(resultSnapshot)
+ .build();
+ }
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountApiVersionGuardTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountApiVersionGuardTest.java
new file mode 100644
index 00000000..c97f3c34
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountApiVersionGuardTest.java
@@ -0,0 +1,36 @@
+package cn.iocoder.muse.module.member.domain.account;
+
+import cn.iocoder.muse.framework.common.exception.ServiceException;
+import org.junit.jupiter.api.Test;
+
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_API_VERSION_UNSUPPORTED;
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * AccountApiVersionGuard 单元测试。
+ */
+class AccountApiVersionGuardTest {
+
+ @Test
+ void should_accept_when_versionIsOne() {
+ assertDoesNotThrow(() -> AccountApiVersionGuard.requireVersion("1"));
+ }
+
+ @Test
+ void should_throwUnsupported_when_versionMissing() {
+ // Account 真实 API 不能给缺版本头的旧客户端隐式降级。
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> AccountApiVersionGuard.requireVersion(null));
+
+ assertEquals(ACCOUNT_API_VERSION_UNSUPPORTED.getCode(), exception.getCode());
+ }
+
+ @Test
+ void should_throwUnsupported_when_versionUnknown() {
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> AccountApiVersionGuard.requireVersion("2"));
+
+ assertEquals(ACCOUNT_API_VERSION_UNSUPPORTED.getCode(), exception.getCode());
+ }
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountDownloadGuardTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountDownloadGuardTest.java
new file mode 100644
index 00000000..cb388bd8
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountDownloadGuardTest.java
@@ -0,0 +1,81 @@
+package cn.iocoder.muse.module.member.domain.account;
+
+import cn.iocoder.muse.framework.common.exception.ServiceException;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID;
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_EXPORT_SOURCE_BLOCKED;
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_RESOURCE_FORBIDDEN;
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * AccountDownloadGuard 单元测试。
+ */
+class AccountDownloadGuardTest {
+
+ @Test
+ void should_allow_when_downloadCredentialIsUsable() {
+ LocalDateTime now = LocalDateTime.of(2026, 5, 28, 10, 0);
+
+ assertDoesNotThrow(() -> AccountDownloadGuard.requireUsableCredential(1001L, 1001L,
+ now.plusMinutes(5), null, null, false, now));
+ }
+
+ @Test
+ void should_throwInvalid_when_credentialExpired() {
+ LocalDateTime now = LocalDateTime.of(2026, 5, 28, 10, 0);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> AccountDownloadGuard.requireUsableCredential(1001L, 1001L,
+ now.minusSeconds(1), null, null, false, now));
+
+ assertEquals(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID.getCode(), exception.getCode());
+ }
+
+ @Test
+ void should_throwInvalid_when_credentialAlreadyConsumed() {
+ LocalDateTime now = LocalDateTime.of(2026, 5, 28, 10, 0);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> AccountDownloadGuard.requireUsableCredential(1001L, 1001L,
+ now.plusMinutes(5), now.minusMinutes(1), null, false, now));
+
+ assertEquals(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID.getCode(), exception.getCode());
+ }
+
+ @Test
+ void should_throwInvalid_when_credentialRevoked() {
+ LocalDateTime now = LocalDateTime.of(2026, 5, 28, 10, 0);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> AccountDownloadGuard.requireUsableCredential(1001L, 1001L,
+ now.plusMinutes(5), null, now.minusMinutes(1), false, now));
+
+ assertEquals(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID.getCode(), exception.getCode());
+ }
+
+ @Test
+ void should_throwForbidden_when_loginUserDoesNotOwnCredential() {
+ LocalDateTime now = LocalDateTime.of(2026, 5, 28, 10, 0);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> AccountDownloadGuard.requireUsableCredential(1001L, 2002L,
+ now.plusMinutes(5), null, null, false, now));
+
+ assertEquals(ACCOUNT_RESOURCE_FORBIDDEN.getCode(), exception.getCode());
+ }
+
+ @Test
+ void should_throwBlocked_when_exportSourceBlocked() {
+ LocalDateTime now = LocalDateTime.of(2026, 5, 28, 10, 0);
+
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> AccountDownloadGuard.requireUsableCredential(1001L, 1001L,
+ now.plusMinutes(5), null, null, true, now));
+
+ assertEquals(ACCOUNT_EXPORT_SOURCE_BLOCKED.getCode(), exception.getCode());
+ }
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountOwnerGuardTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountOwnerGuardTest.java
new file mode 100644
index 00000000..06ed7f40
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountOwnerGuardTest.java
@@ -0,0 +1,35 @@
+package cn.iocoder.muse.module.member.domain.account;
+
+import cn.iocoder.muse.framework.common.exception.ServiceException;
+import org.junit.jupiter.api.Test;
+
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_RESOURCE_FORBIDDEN;
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * AccountOwnerGuard 单元测试。
+ */
+class AccountOwnerGuardTest {
+
+ @Test
+ void should_allow_when_loginUserOwnsResource() {
+ assertDoesNotThrow(() -> AccountOwnerGuard.requireOwner(1001L, 1001L));
+ }
+
+ @Test
+ void should_throwForbidden_when_ownerMissing() {
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> AccountOwnerGuard.requireOwner(1001L, null));
+
+ assertEquals(ACCOUNT_RESOURCE_FORBIDDEN.getCode(), exception.getCode());
+ }
+
+ @Test
+ void should_throwForbidden_when_loginUserDiffersFromOwner() {
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> AccountOwnerGuard.requireOwner(1001L, 2002L));
+
+ assertEquals(ACCOUNT_RESOURCE_FORBIDDEN.getCode(), exception.getCode());
+ }
+
+}
diff --git a/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountQuotaGuardTest.java b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountQuotaGuardTest.java
new file mode 100644
index 00000000..5783ebca
--- /dev/null
+++ b/muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountQuotaGuardTest.java
@@ -0,0 +1,36 @@
+package cn.iocoder.muse.module.member.domain.account;
+
+import cn.iocoder.muse.framework.common.exception.ServiceException;
+import org.junit.jupiter.api.Test;
+
+import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_QUOTA_EXHAUSTED;
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * AccountQuotaGuard 单元测试。
+ */
+class AccountQuotaGuardTest {
+
+ @Test
+ void should_allow_when_remainingAmountCanCoverRequiredAmount() {
+ assertDoesNotThrow(() -> AccountQuotaGuard.requireAvailable(10L, 7L));
+ assertDoesNotThrow(() -> AccountQuotaGuard.requireAvailable(10L, 10L));
+ }
+
+ @Test
+ void should_throwExhausted_when_remainingAmountIsLessThanRequiredAmount() {
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> AccountQuotaGuard.requireAvailable(3L, 4L));
+
+ assertEquals(ACCOUNT_QUOTA_EXHAUSTED.getCode(), exception.getCode());
+ }
+
+ @Test
+ void should_throwExhausted_when_remainingAmountMissing() {
+ ServiceException exception = assertThrows(ServiceException.class,
+ () -> AccountQuotaGuard.requireAvailable(null, 1L));
+
+ assertEquals(ACCOUNT_QUOTA_EXHAUSTED.getCode(), exception.getCode());
+ }
+
+}
diff --git a/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountMigrationSqlTest.java b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountMigrationSqlTest.java
new file mode 100644
index 00000000..bc40e65f
--- /dev/null
+++ b/muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountMigrationSqlTest.java
@@ -0,0 +1,108 @@
+package cn.iocoder.muse.server.framework.api;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Locale;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * P1R-3 Account 迁移 SQL 门禁测试。
+ */
+class P1rAccountMigrationSqlTest {
+
+ private static final Set REQUIRED_TABLES = Set.of(
+ "muse_account_command",
+ "muse_account_audit",
+ "muse_account_profile",
+ "muse_account_balance_snapshot",
+ "muse_account_quota_request",
+ "muse_account_integration_call",
+ "muse_account_call_attribution_job",
+ "muse_account_call_attribution_item",
+ "muse_account_export_task",
+ "muse_account_download_credential",
+ "muse_account_security_event_ack",
+ "muse_account_record_projection"
+ );
+
+ @Test
+ void should_include_accountRealApiInfrastructureTables() throws IOException {
+ String normalizedSql = normalize(readV11Sql());
+
+ for (String tableName : REQUIRED_TABLES) {
+ assertTrue(normalizedSql.contains("create table " + tableName + " "),
+ "V11 必须创建 Account 表: " + tableName);
+ }
+ }
+
+ @Test
+ void should_include_commandProfileProjectionAndCredentialConstraints() throws IOException {
+ String normalizedSql = normalize(readV11Sql());
+
+ assertTrue(normalizedSql.contains("constraint uk_muse_account_command unique (tenant_id, command_id)"),
+ "command 必须用 (tenant_id, command_id) 唯一键");
+ assertTrue(normalizedSql.contains("profile_version"),
+ "profile 必须包含 profile_version 支撑 expectedVersion");
+ assertTrue(normalizedSql.contains("constraint uk_muse_account_profile_user unique (tenant_id, account_user_id)"),
+ "profile 必须以 account_user_id 唯一");
+ assertTrue(normalizedSql.contains("record_type") && normalizedSql.contains("purchase")
+ && normalizedSql.contains("license") && normalizedSql.contains("publish"),
+ "record projection 必须用 record_type 区分 purchase/license/publish");
+ assertTrue(normalizedSql.contains("source_table") && normalizedSql.contains("source_id")
+ && normalizedSql.contains("source_revision"),
+ "record projection 必须保存上游来源表、来源 id 和来源 revision");
+ assertTrue(normalizedSql.contains("credential_hash"),
+ "download credential 必须只存 credential_hash");
+ }
+
+ @Test
+ void should_include_ownerAndCorrelationIndexesAndAuditFields() throws IOException {
+ String normalizedSql = normalize(readV11Sql());
+
+ assertTrue(normalizedSql.contains("idx_muse_account_audit_user")
+ && normalizedSql.contains("account_user_id"),
+ "audit 必须具备 account_user_id 索引");
+ assertTrue(normalizedSql.contains("idx_muse_account_audit_operation")
+ && normalizedSql.contains("operation_id"),
+ "audit 必须具备 operation_id 索引");
+ assertTrue(normalizedSql.contains("idx_muse_account_audit_correlation")
+ && normalizedSql.contains("correlation_id"),
+ "audit 必须具备 correlation_id 索引");
+ assertTrue(normalizedSql.contains("idx_muse_account_integration_call_correlation")
+ && normalizedSql.contains("correlation_id"),
+ "integration call 必须具备 correlation_id 查询索引");
+ for (String field : Set.of("operation_id", "actor_user_id", "account_user_id", "side", "target_type",
+ "target_id", "command_id", "request_hash", "correlation_id", "before_snapshot",
+ "after_snapshot", "status", "error_code", "error_message", "create_time")) {
+ assertTrue(normalizedSql.contains(field), "audit 必须覆盖字段: " + field);
+ }
+ }
+
+ private static String readV11Sql() throws IOException {
+ Path migrationPath = findRepositoryRoot().resolve("muse-cloud/sql/muse/V11__extend_account_real_api_schema.sql");
+ assertTrue(Files.exists(migrationPath), "V11 Account 迁移 SQL 必须存在");
+ return Files.readString(migrationPath);
+ }
+
+ private static Path findRepositoryRoot() {
+ Path current = Path.of("").toAbsolutePath();
+ for (Path candidate = current; candidate != null; candidate = candidate.getParent()) {
+ if (Files.exists(candidate.resolve("muse-cloud/sql/muse/V2__init_account_schema.sql"))) {
+ return candidate;
+ }
+ }
+ return current;
+ }
+
+ private static String normalize(String sql) {
+ return sql.toLowerCase(Locale.ROOT)
+ .replaceAll("--.*", " ")
+ .replaceAll("\\s+", " ");
+ }
+
+}
diff --git a/muse-cloud/sql/muse/V11__extend_account_real_api_schema.sql b/muse-cloud/sql/muse/V11__extend_account_real_api_schema.sql
new file mode 100644
index 00000000..a7f8377f
--- /dev/null
+++ b/muse-cloud/sql/muse/V11__extend_account_real_api_schema.sql
@@ -0,0 +1,370 @@
+-- P1R-3 Account Real API 扩展 Schema。
+-- 说明:本迁移只追加 Account owner 自有事实;New-API、Market、AI、Knowledge、FileService 的主流程不在此处伪造。
+
+CREATE TABLE muse_account_command (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ command_id VARCHAR(128) NOT NULL,
+ operation_id VARCHAR(128) NOT NULL,
+ actor_user_id BIGINT NOT NULL,
+ owner_user_id BIGINT NOT NULL,
+ target_type VARCHAR(64) NOT NULL,
+ target_id BIGINT NOT NULL,
+ request_hash CHAR(64) NOT NULL,
+ result_snapshot JSONB,
+ creator VARCHAR(64) NOT NULL DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) NOT NULL DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ tenant_id BIGINT NOT NULL DEFAULT 0,
+ CONSTRAINT uk_muse_account_command UNIQUE (tenant_id, command_id)
+);
+
+CREATE INDEX idx_muse_account_command_target
+ ON muse_account_command(tenant_id, target_type, target_id);
+CREATE TRIGGER trg_muse_account_command_updated_at
+ BEFORE UPDATE ON muse_account_command
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TABLE muse_account_audit (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ operation_id VARCHAR(128) NOT NULL,
+ actor_user_id BIGINT NOT NULL,
+ account_user_id BIGINT NOT NULL,
+ side VARCHAR(32) NOT NULL,
+ target_type VARCHAR(64) NOT NULL,
+ target_id BIGINT,
+ command_id VARCHAR(128),
+ request_hash CHAR(64),
+ correlation_id VARCHAR(200),
+ before_snapshot JSONB,
+ after_snapshot JSONB,
+ status VARCHAR(32) NOT NULL,
+ error_code VARCHAR(64),
+ error_message TEXT,
+ creator VARCHAR(64) NOT NULL DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) NOT NULL DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ tenant_id BIGINT NOT NULL DEFAULT 0
+);
+
+CREATE INDEX idx_muse_account_audit_user
+ ON muse_account_audit(tenant_id, account_user_id, create_time);
+CREATE INDEX idx_muse_account_audit_operation
+ ON muse_account_audit(tenant_id, operation_id, create_time);
+CREATE INDEX idx_muse_account_audit_correlation
+ ON muse_account_audit(tenant_id, correlation_id)
+ WHERE correlation_id IS NOT NULL;
+CREATE TRIGGER trg_muse_account_audit_updated_at
+ BEFORE UPDATE ON muse_account_audit
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TABLE muse_account_profile (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ account_user_id BIGINT NOT NULL,
+ display_name VARCHAR(100),
+ public_pen_name VARCHAR(100),
+ avatar_url VARCHAR(500),
+ bio TEXT,
+ profile_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
+ profile_version INT NOT NULL DEFAULT 1,
+ creator VARCHAR(64) NOT NULL DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) NOT NULL DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ tenant_id BIGINT NOT NULL DEFAULT 0,
+ CONSTRAINT uk_muse_account_profile_user UNIQUE (tenant_id, account_user_id)
+);
+
+CREATE INDEX idx_muse_account_profile_user
+ ON muse_account_profile(tenant_id, account_user_id);
+CREATE TRIGGER trg_muse_account_profile_updated_at
+ BEFORE UPDATE ON muse_account_profile
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TABLE muse_account_balance_snapshot (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ account_user_id BIGINT NOT NULL,
+ source_type VARCHAR(64) NOT NULL,
+ source_id VARCHAR(128),
+ balance_type VARCHAR(64) NOT NULL,
+ total_amount BIGINT NOT NULL DEFAULT 0,
+ used_amount BIGINT NOT NULL DEFAULT 0,
+ remaining_amount BIGINT NOT NULL DEFAULT 0,
+ snapshot_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ correlation_id VARCHAR(200),
+ snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
+ creator VARCHAR(64) NOT NULL DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) NOT NULL DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ tenant_id BIGINT NOT NULL DEFAULT 0
+);
+
+CREATE INDEX idx_muse_account_balance_snapshot_user
+ ON muse_account_balance_snapshot(tenant_id, account_user_id, snapshot_at);
+CREATE INDEX idx_muse_account_balance_snapshot_correlation
+ ON muse_account_balance_snapshot(tenant_id, correlation_id)
+ WHERE correlation_id IS NOT NULL;
+CREATE TRIGGER trg_muse_account_balance_snapshot_updated_at
+ BEFORE UPDATE ON muse_account_balance_snapshot
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TABLE muse_account_quota_request (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ request_id VARCHAR(128) NOT NULL,
+ account_user_id BIGINT NOT NULL,
+ requested_by BIGINT NOT NULL,
+ side VARCHAR(32) NOT NULL,
+ quota_type VARCHAR(64) NOT NULL,
+ requested_amount BIGINT NOT NULL,
+ status VARCHAR(32) NOT NULL,
+ command_id VARCHAR(128) NOT NULL,
+ request_hash CHAR(64) NOT NULL,
+ correlation_id VARCHAR(200) NOT NULL,
+ integration_call_id BIGINT,
+ failure_reason TEXT,
+ result_snapshot JSONB,
+ creator VARCHAR(64) NOT NULL DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) NOT NULL DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ tenant_id BIGINT NOT NULL DEFAULT 0,
+ CONSTRAINT uk_muse_account_quota_request UNIQUE (tenant_id, request_id),
+ CONSTRAINT uk_muse_account_quota_request_command UNIQUE (tenant_id, command_id)
+);
+
+CREATE INDEX idx_muse_account_quota_request_user
+ ON muse_account_quota_request(tenant_id, account_user_id, create_time);
+CREATE INDEX idx_muse_account_quota_request_correlation
+ ON muse_account_quota_request(tenant_id, correlation_id);
+CREATE TRIGGER trg_muse_account_quota_request_updated_at
+ BEFORE UPDATE ON muse_account_quota_request
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TABLE muse_account_integration_call (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ correlation_id VARCHAR(200) NOT NULL,
+ account_user_id BIGINT NOT NULL,
+ provider VARCHAR(64) NOT NULL,
+ operation_id VARCHAR(128) NOT NULL,
+ binding_id BIGINT,
+ job_id VARCHAR(128),
+ command_id VARCHAR(128),
+ request_hash CHAR(64),
+ status VARCHAR(32) NOT NULL,
+ retry_count INT NOT NULL DEFAULT 0,
+ next_retry_at TIMESTAMP,
+ external_call_id VARCHAR(200),
+ request_summary JSONB,
+ response_summary JSONB,
+ error_code VARCHAR(64),
+ error_message TEXT,
+ started_at TIMESTAMP,
+ finished_at TIMESTAMP,
+ creator VARCHAR(64) NOT NULL DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) NOT NULL DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ tenant_id BIGINT NOT NULL DEFAULT 0,
+ CONSTRAINT uk_muse_account_integration_call_correlation UNIQUE (tenant_id, correlation_id)
+);
+
+CREATE INDEX idx_muse_account_integration_call_user
+ ON muse_account_integration_call(tenant_id, account_user_id, create_time);
+CREATE INDEX idx_muse_account_integration_call_correlation
+ ON muse_account_integration_call(tenant_id, correlation_id);
+CREATE INDEX idx_muse_account_integration_call_external
+ ON muse_account_integration_call(tenant_id, external_call_id)
+ WHERE external_call_id IS NOT NULL;
+CREATE TRIGGER trg_muse_account_integration_call_updated_at
+ BEFORE UPDATE ON muse_account_integration_call
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TABLE muse_account_call_attribution_job (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ job_id VARCHAR(128) NOT NULL,
+ account_user_id BIGINT,
+ actor_user_id BIGINT NOT NULL,
+ command_id VARCHAR(128) NOT NULL,
+ request_hash CHAR(64) NOT NULL,
+ correlation_id VARCHAR(200) NOT NULL,
+ status VARCHAR(32) NOT NULL,
+ source_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
+ result_snapshot JSONB,
+ error_code VARCHAR(64),
+ error_message TEXT,
+ creator VARCHAR(64) NOT NULL DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) NOT NULL DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ tenant_id BIGINT NOT NULL DEFAULT 0,
+ CONSTRAINT uk_muse_account_call_attribution_job UNIQUE (tenant_id, job_id),
+ CONSTRAINT uk_muse_account_call_attribution_job_command UNIQUE (tenant_id, command_id)
+);
+
+CREATE INDEX idx_muse_account_call_attribution_job_user
+ ON muse_account_call_attribution_job(tenant_id, account_user_id, create_time);
+CREATE INDEX idx_muse_account_call_attribution_job_correlation
+ ON muse_account_call_attribution_job(tenant_id, correlation_id);
+CREATE TRIGGER trg_muse_account_call_attribution_job_updated_at
+ BEFORE UPDATE ON muse_account_call_attribution_job
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TABLE muse_account_call_attribution_item (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ job_id VARCHAR(128) NOT NULL,
+ integration_call_id BIGINT,
+ account_user_id BIGINT NOT NULL,
+ correlation_id VARCHAR(200) NOT NULL,
+ call_id VARCHAR(200),
+ attribution_type VARCHAR(64) NOT NULL,
+ attributed_target_type VARCHAR(64),
+ attributed_target_id BIGINT,
+ source_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
+ attribution_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
+ status VARCHAR(32) NOT NULL,
+ creator VARCHAR(64) NOT NULL DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) NOT NULL DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ tenant_id BIGINT NOT NULL DEFAULT 0
+);
+
+CREATE INDEX idx_muse_account_call_attribution_item_user
+ ON muse_account_call_attribution_item(tenant_id, account_user_id, create_time);
+CREATE INDEX idx_muse_account_call_attribution_item_correlation
+ ON muse_account_call_attribution_item(tenant_id, correlation_id);
+CREATE INDEX idx_muse_account_call_attribution_item_job
+ ON muse_account_call_attribution_item(tenant_id, job_id);
+CREATE TRIGGER trg_muse_account_call_attribution_item_updated_at
+ BEFORE UPDATE ON muse_account_call_attribution_item
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TABLE muse_account_export_task (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ task_id VARCHAR(128) NOT NULL,
+ account_user_id BIGINT NOT NULL,
+ command_id VARCHAR(128) NOT NULL,
+ request_hash CHAR(64) NOT NULL,
+ export_type VARCHAR(64) NOT NULL,
+ status VARCHAR(32) NOT NULL,
+ source_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
+ file_ref VARCHAR(300),
+ source_blocked BOOLEAN NOT NULL DEFAULT FALSE,
+ error_code VARCHAR(64),
+ error_message TEXT,
+ completed_at TIMESTAMP,
+ creator VARCHAR(64) NOT NULL DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) NOT NULL DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ tenant_id BIGINT NOT NULL DEFAULT 0,
+ CONSTRAINT uk_muse_account_export_task UNIQUE (tenant_id, task_id),
+ CONSTRAINT uk_muse_account_export_task_command UNIQUE (tenant_id, command_id)
+);
+
+CREATE INDEX idx_muse_account_export_task_user
+ ON muse_account_export_task(tenant_id, account_user_id, create_time);
+CREATE TRIGGER trg_muse_account_export_task_updated_at
+ BEFORE UPDATE ON muse_account_export_task
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TABLE muse_account_download_credential (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ credential_id VARCHAR(128) NOT NULL,
+ credential_hash CHAR(64) NOT NULL,
+ account_user_id BIGINT NOT NULL,
+ task_id VARCHAR(128) NOT NULL,
+ expires_at TIMESTAMP NOT NULL,
+ consumed_at TIMESTAMP,
+ revoked_at TIMESTAMP,
+ source_blocked BOOLEAN NOT NULL DEFAULT FALSE,
+ creator VARCHAR(64) NOT NULL DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) NOT NULL DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ tenant_id BIGINT NOT NULL DEFAULT 0,
+ CONSTRAINT uk_muse_account_download_credential UNIQUE (tenant_id, credential_id),
+ CONSTRAINT uk_muse_account_download_credential_hash UNIQUE (tenant_id, credential_hash)
+);
+
+CREATE INDEX idx_muse_account_download_credential_user
+ ON muse_account_download_credential(tenant_id, account_user_id, expires_at);
+CREATE INDEX idx_muse_account_download_credential_task
+ ON muse_account_download_credential(tenant_id, task_id);
+CREATE TRIGGER trg_muse_account_download_credential_updated_at
+ BEFORE UPDATE ON muse_account_download_credential
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TABLE muse_account_security_event_ack (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ event_id BIGINT NOT NULL,
+ account_user_id BIGINT NOT NULL,
+ actor_user_id BIGINT NOT NULL,
+ action VARCHAR(64) NOT NULL,
+ note TEXT,
+ command_id VARCHAR(128) NOT NULL,
+ request_hash CHAR(64) NOT NULL,
+ risk_summary JSONB,
+ creator VARCHAR(64) NOT NULL DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) NOT NULL DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ tenant_id BIGINT NOT NULL DEFAULT 0,
+ CONSTRAINT uk_muse_account_security_event_ack_command UNIQUE (tenant_id, command_id)
+);
+
+CREATE INDEX idx_muse_account_security_event_ack_user
+ ON muse_account_security_event_ack(tenant_id, account_user_id, create_time);
+CREATE INDEX idx_muse_account_security_event_ack_event
+ ON muse_account_security_event_ack(tenant_id, event_id);
+CREATE TRIGGER trg_muse_account_security_event_ack_updated_at
+ BEFORE UPDATE ON muse_account_security_event_ack
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+
+CREATE TABLE muse_account_record_projection (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ account_user_id BIGINT NOT NULL,
+ record_type VARCHAR(32) NOT NULL,
+ record_id VARCHAR(128) NOT NULL,
+ source_table VARCHAR(128) NOT NULL,
+ source_id VARCHAR(128) NOT NULL,
+ source_revision VARCHAR(64) NOT NULL,
+ source_status VARCHAR(64),
+ title VARCHAR(200),
+ amount BIGINT,
+ occurred_at TIMESTAMP,
+ correlation_id VARCHAR(200),
+ projection_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
+ creator VARCHAR(64) NOT NULL DEFAULT '',
+ create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updater VARCHAR(64) NOT NULL DEFAULT '',
+ update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
+ tenant_id BIGINT NOT NULL DEFAULT 0,
+ CONSTRAINT uk_muse_account_record_projection UNIQUE (tenant_id, record_type, record_id),
+ CONSTRAINT ck_muse_account_record_projection_type CHECK (record_type IN ('purchase', 'license', 'publish'))
+);
+
+CREATE INDEX idx_muse_account_record_projection_user
+ ON muse_account_record_projection(tenant_id, account_user_id, record_type, occurred_at);
+CREATE INDEX idx_muse_account_record_projection_source
+ ON muse_account_record_projection(tenant_id, source_table, source_id, source_revision);
+CREATE INDEX idx_muse_account_record_projection_correlation
+ ON muse_account_record_projection(tenant_id, correlation_id)
+ WHERE correlation_id IS NOT NULL;
+CREATE TRIGGER trg_muse_account_record_projection_updated_at
+ BEFORE UPDATE ON muse_account_record_projection
+ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();