feat(p1r): 增加 Account 真实 API 基础设施
This commit is contained in:
parent
4d9a2424fe
commit
ce4b0a5bdb
@ -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 不能为空");
|
||||
|
||||
}
|
||||
|
||||
@ -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 token、Prompt/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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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) {
|
||||
// 审计快照先脱敏再入库,避免把外部 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]", "");
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 token、Prompt/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;
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
@ -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> {
|
||||
}
|
||||
@ -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);
|
||||
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
@ -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+", " ");
|
||||
}
|
||||
|
||||
}
|
||||
370
muse-cloud/sql/muse/V11__extend_account_real_api_schema.sql
Normal file
370
muse-cloud/sql/muse/V11__extend_account_real_api_schema.sql
Normal 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();
|
||||
Loading…
x
Reference in New Issue
Block a user