From ce4b0a5bdb0a9c0933c9396db9a5376ee39d383e Mon Sep 17 00:00:00 2001 From: zizi Date: Thu, 28 May 2026 21:31:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(p1r):=20=E5=A2=9E=E5=8A=A0=20Account=20?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=20API=20=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/enums/ErrorCodeConstants.java | 15 + .../account/AccountAuditService.java | 41 ++ .../account/AccountAuditServiceImpl.java | 108 +++++ .../account/AccountCommandService.java | 54 +++ .../account/AccountCommandServiceImpl.java | 197 ++++++++++ .../dataobject/account/AccountAuditDO.java | 50 +++ .../dataobject/account/AccountCommandDO.java | 43 ++ .../dal/mysql/account/AccountAuditMapper.java | 12 + .../mysql/account/AccountCommandMapper.java | 45 +++ .../dal/type/JsonbStringTypeHandler.java | 47 +++ .../account/AccountApiVersionGuard.java | 23 ++ .../domain/account/AccountDownloadGuard.java | 31 ++ .../domain/account/AccountOwnerGuard.java | 23 ++ .../domain/account/AccountQuotaGuard.java | 21 + .../account/AccountAuditServiceTest.java | 94 +++++ .../account/AccountCommandServiceTest.java | 196 ++++++++++ .../account/AccountApiVersionGuardTest.java | 36 ++ .../account/AccountDownloadGuardTest.java | 81 ++++ .../domain/account/AccountOwnerGuardTest.java | 35 ++ .../domain/account/AccountQuotaGuardTest.java | 36 ++ .../api/P1rAccountMigrationSqlTest.java | 108 +++++ .../V11__extend_account_real_api_schema.sql | 370 ++++++++++++++++++ 22 files changed, 1666 insertions(+) create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountAuditService.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountAuditServiceImpl.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountCommandService.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/application/account/AccountCommandServiceImpl.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/dataobject/account/AccountAuditDO.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/dataobject/account/AccountCommandDO.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountAuditMapper.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/mysql/account/AccountCommandMapper.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/dal/type/JsonbStringTypeHandler.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountApiVersionGuard.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountDownloadGuard.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountOwnerGuard.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/main/java/cn/iocoder/muse/module/member/domain/account/AccountQuotaGuard.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountAuditServiceTest.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/application/account/AccountCommandServiceTest.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountApiVersionGuardTest.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountDownloadGuardTest.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountOwnerGuardTest.java create mode 100644 muse-cloud/muse-module-member/muse-module-member-server/src/test/java/cn/iocoder/muse/module/member/domain/account/AccountQuotaGuardTest.java create mode 100644 muse-cloud/muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rAccountMigrationSqlTest.java create mode 100644 muse-cloud/sql/muse/V11__extend_account_real_api_schema.sql 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();