feat(p1r): 增加 Account 真实 API 基础设施

This commit is contained in:
zizi 2026-05-28 21:31:53 +08:00
parent 4d9a2424fe
commit ce4b0a5bdb
22 changed files with 1666 additions and 0 deletions

View File

@ -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 不能为空");
}

View File

@ -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 审计
*
* <p>审计只存脱敏快照和必要定位字段不能把 New-API tokenPrompt/Response 原文写入长期表</p>
*/
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;
}
}

View File

@ -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<String> 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) {
// 审计快照先脱敏再入库避免把外部 tokenPrompt/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]", "");
}
}

View File

@ -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;
}
}

View File

@ -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<String> 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);
}
}
}

View File

@ -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
*
* <p>审计只保留脱敏快照和必要定位字段不能落 New-API tokenPrompt/Response 原文等敏感内容</p>
*/
@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;
}

View File

@ -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;
}

View File

@ -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<AccountAuditDO> {
}

View File

@ -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<AccountCommandDO> {
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);
}

View File

@ -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
*
* <p>Account 审计快照和命令结果需要保持 JSON 对象语义不能被二次序列化成字符串字面量</p>
*/
public class JsonbStringTypeHandler extends BaseTypeHandler<String> {
@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();
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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());
}
}

View File

@ -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<String, Object> first = new LinkedHashMap<>();
first.put("nickname", "Muse");
first.put("expectedVersion", 3);
Map<String, Object> 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();
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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());
}
}

View File

@ -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<String> 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+", " ");
}
}

View File

@ -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();