feat(p1r): 实现 Account 调用归因边界

This commit is contained in:
zizi 2026-05-29 13:02:15 +08:00
parent 22f498dd3c
commit b6d9f36b45
9 changed files with 922 additions and 1 deletions

View File

@ -0,0 +1,16 @@
package cn.iocoder.muse.module.member.application.account;
import cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobCreateReqVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobDetailRespVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobResultRespVO;
/**
* Account 调用归因应用服务
*/
public interface AccountAttributionService {
CallAttributionJobResultRespVO adminCreateCallAttributionJob(Long operatorUserId,
CallAttributionJobCreateReqVO reqVO);
CallAttributionJobDetailRespVO adminGetCallAttributionJob(String jobId);
}

View File

@ -0,0 +1,450 @@
package cn.iocoder.muse.module.member.application.account;
import cn.iocoder.muse.framework.common.exception.ServiceException;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.member.application.account.facade.AccountAttributionSourceFacade;
import cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobCreateReqVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobDetailRespVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobResultRespVO;
import cn.iocoder.muse.module.member.convert.account.AccountConvert;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCallAttributionItemDO;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCallAttributionJobDO;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCommandDO;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountIntegrationCallDO;
import cn.iocoder.muse.module.member.dal.mysql.account.AccountCallAttributionItemMapper;
import cn.iocoder.muse.module.member.dal.mysql.account.AccountCallAttributionJobMapper;
import cn.iocoder.muse.module.member.dal.mysql.account.AccountIntegrationCallMapper;
import jakarta.annotation.Resource;
import lombok.Builder;
import lombok.Data;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
import static cn.iocoder.muse.framework.common.exception.util.ServiceExceptionUtil.invalidParamException;
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_INTEGRATION_CALL_NOT_EXISTS;
/**
* Account 调用归因应用服务实现
*/
@Service
public class AccountAttributionServiceImpl implements AccountAttributionService {
private static final String OPERATION_ADMIN_CREATE_ATTRIBUTION_JOB = "adminCreateCallAttributionJob";
private static final String TARGET_TYPE_CALL_ATTRIBUTION = "callAttribution";
private static final String TARGET_KEY_PREFIX = "callAttribution:";
private static final String VERIFICATION_MODE_STRICT = "strict";
private static final String RESULT_QUEUED = "queued";
private static final String RESULT_IDEMPOTENT_HIT = "idempotent_hit";
private static final String STATUS_QUEUED = "queued";
private static final String STATUS_PROCESSING = "processing";
private static final String STATUS_COMPLETED = "completed";
private static final String STATUS_FAILED = "failed";
private static final String STATUS_PARTIALLY_COMPLETED = "partially_completed";
private static final String STATUS_PENDING = "pending";
private static final String STATUS_UNAVAILABLE = "unavailable";
private static final String ATTRIBUTION_UNKNOWN = "unknown";
private static final String ERROR_ATTRIBUTION_SOURCE_UNAVAILABLE = "ACCOUNT_ATTRIBUTION_SOURCE_UNAVAILABLE";
private static final String ATTRIBUTION_SOURCE_UNAVAILABLE_MESSAGE = "外部归因来源暂不可用";
private static final String COMPENSATION_SUGGESTION = "等待 AI/Content/Market owner 接入后重试该 job";
@Resource
private AccountIntegrationCallMapper integrationCallMapper;
@Resource
private AccountCallAttributionJobMapper jobMapper;
@Resource
private AccountCallAttributionItemMapper itemMapper;
@Resource
private AccountCommandService commandService;
@Resource
private AccountAttributionSourceFacade attributionSourceFacade;
@Override
@Transactional(rollbackFor = Exception.class)
public CallAttributionJobResultRespVO adminCreateCallAttributionJob(Long operatorUserId,
CallAttributionJobCreateReqVO reqVO) {
NormalizedCreateRequest request = normalizeCreateRequest(reqVO);
List<AccountIntegrationCallDO> calls = requireEligibleCalls(request);
Long accountUserId = requireSingleAccountUser(calls);
String requestHash = commandService.buildRequestHash(request);
AccountCommandService.CommandEnvelope envelope = AccountCommandService.CommandEnvelope.builder()
.commandId(request.getCommandId())
.operationId(OPERATION_ADMIN_CREATE_ATTRIBUTION_JOB)
.actorUserId(operatorUserId)
.ownerUserId(accountUserId)
.targetType(TARGET_TYPE_CALL_ATTRIBUTION)
.targetKey(TARGET_KEY_PREFIX + request.getCorrelationId())
.requestHash(requestHash)
.build();
AccountCommandDO replayCommand = commandService.reserveCommand(envelope);
if (replayCommand != null) {
CallAttributionJobResultRespVO replay = JsonUtils.parseObject(replayCommand.getResultSnapshot(),
CallAttributionJobResultRespVO.class);
replay.setStatus(RESULT_IDEMPOTENT_HIT);
return replay;
}
String jobId = newJobId();
writeAttributionJob(jobId, operatorUserId, accountUserId, requestHash, request, calls);
CallAttributionJobResultRespVO result = new CallAttributionJobResultRespVO();
result.setJobId(jobId);
result.setStatus(RESULT_QUEUED);
commandService.recordSucceeded(envelope, JsonUtils.toJsonString(result));
return result;
}
@Override
public CallAttributionJobDetailRespVO adminGetCallAttributionJob(String jobId) {
if (jobId == null || jobId.isBlank()) {
throw invalidParamException("jobId 不能为空");
}
AccountCallAttributionJobDO job = jobMapper.selectByJobId(jobId);
if (job == null) {
throw new ServiceException(ACCOUNT_INTEGRATION_CALL_NOT_EXISTS.getCode(), "调用归因 job 不存在");
}
List<AccountCallAttributionItemDO> items = itemMapper.selectListByJobId(jobId);
return toDetail(job, items == null ? Collections.emptyList() : items);
}
private void writeAttributionJob(String jobId, Long operatorUserId, Long accountUserId, String requestHash,
NormalizedCreateRequest request, List<AccountIntegrationCallDO> calls) {
String sourceSnapshot = JsonUtils.toJsonString(SourceSnapshot.builder()
.correlationId(request.getCorrelationId())
.callIds(calls.stream().map(this::callId).toList())
.verificationMode(request.getVerificationMode())
.reason(request.getReason())
.build());
try {
AccountAttributionSourceFacade.AttributionResult sourceResult = attributionSourceFacade.resolve(
AccountAttributionSourceFacade.AttributionCommand.builder()
.accountUserId(accountUserId)
.correlationId(request.getCorrelationId())
.callIds(calls.stream().map(this::callId).toList())
.verificationMode(request.getVerificationMode())
.reason(request.getReason())
.build());
List<AccountCallAttributionItemDO> items = buildResolvedItems(jobId, accountUserId, request, calls,
sourceSnapshot, sourceResult);
String status = resolveJobStatus(items);
insertJob(jobId, accountUserId, operatorUserId, request, requestHash, sourceSnapshot, status,
status.equals(STATUS_FAILED) ? ERROR_ATTRIBUTION_SOURCE_UNAVAILABLE : null,
status.equals(STATUS_FAILED) ? ATTRIBUTION_SOURCE_UNAVAILABLE_MESSAGE : null,
items);
insertItems(items);
} catch (ServiceException ex) {
List<AccountCallAttributionItemDO> failedItems = calls.stream()
.map(call -> failedItem(jobId, accountUserId, request, call, sourceSnapshot))
.toList();
insertJob(jobId, accountUserId, operatorUserId, request, requestHash, sourceSnapshot, STATUS_FAILED,
ERROR_ATTRIBUTION_SOURCE_UNAVAILABLE, ATTRIBUTION_SOURCE_UNAVAILABLE_MESSAGE, failedItems);
insertItems(failedItems);
}
}
private void insertJob(String jobId, Long accountUserId, Long operatorUserId, NormalizedCreateRequest request,
String requestHash, String sourceSnapshot, String status, String errorCode,
String errorMessage, List<AccountCallAttributionItemDO> items) {
AccountCallAttributionJobDO job = AccountCallAttributionJobDO.builder()
.jobId(jobId)
.accountUserId(accountUserId)
.actorUserId(operatorUserId)
.commandId(request.getCommandId())
.requestHash(requestHash)
.correlationId(request.getCorrelationId())
.status(status)
.sourceSnapshot(sourceSnapshot)
.resultSnapshot(JsonUtils.toJsonString(resultSnapshot(status, items)))
.errorCode(errorCode)
.errorMessage(errorMessage)
.build();
jobMapper.insert(job);
}
private void insertItems(List<AccountCallAttributionItemDO> items) {
for (AccountCallAttributionItemDO item : items) {
itemMapper.insert(item);
}
}
private List<AccountCallAttributionItemDO> buildResolvedItems(String jobId, Long accountUserId,
NormalizedCreateRequest request,
List<AccountIntegrationCallDO> calls,
String sourceSnapshot,
AccountAttributionSourceFacade.AttributionResult result) {
Map<String, AccountAttributionSourceFacade.ResolvedAttribution> resolvedByCallId =
result == null || result.getItems() == null ? Collections.emptyMap()
: result.getItems().stream()
.filter(item -> item.getCallId() != null && !item.getCallId().isBlank())
.collect(Collectors.toMap(AccountAttributionSourceFacade.ResolvedAttribution::getCallId,
Function.identity(), (first, second) -> first, LinkedHashMap::new));
List<AccountCallAttributionItemDO> items = new ArrayList<>();
for (AccountIntegrationCallDO call : calls) {
AccountAttributionSourceFacade.ResolvedAttribution resolved = resolvedByCallId.get(callId(call));
if (resolved == null) {
items.add(failedItem(jobId, accountUserId, request, call, sourceSnapshot));
continue;
}
items.add(AccountCallAttributionItemDO.builder()
.jobId(jobId)
.integrationCallId(call.getId())
.accountUserId(accountUserId)
.correlationId(request.getCorrelationId())
.callId(callId(call))
.attributionType(emptyToUnknown(resolved.getAttributionType()))
.attributedTargetType(resolved.getAttributedTargetType())
.attributedTargetId(resolved.getAttributedTargetId())
.sourceSnapshot(nullToJsonObject(resolved.getSourceSnapshot(), sourceSnapshot))
.attributionSnapshot(nullToJsonObject(resolved.getAttributionSnapshot(), "{}"))
.status(STATUS_COMPLETED)
.build());
}
return items;
}
private AccountCallAttributionItemDO failedItem(String jobId, Long accountUserId, NormalizedCreateRequest request,
AccountIntegrationCallDO call, String sourceSnapshot) {
return AccountCallAttributionItemDO.builder()
.jobId(jobId)
.integrationCallId(call.getId())
.accountUserId(accountUserId)
.correlationId(request.getCorrelationId())
.callId(callId(call))
.attributionType(ATTRIBUTION_UNKNOWN)
.sourceSnapshot(sourceSnapshot)
.attributionSnapshot(JsonUtils.toJsonString(FailureSnapshot.builder()
.errorCode(ERROR_ATTRIBUTION_SOURCE_UNAVAILABLE)
.message(ATTRIBUTION_SOURCE_UNAVAILABLE_MESSAGE)
.build()))
.status(STATUS_FAILED)
.build();
}
private NormalizedCreateRequest normalizeCreateRequest(CallAttributionJobCreateReqVO reqVO) {
if (reqVO == null) {
throw invalidParamException("调用归因请求不能为空");
}
if (reqVO.getCommandId() == null || reqVO.getCommandId().isBlank()) {
throw invalidParamException("commandId 不能为空");
}
if (reqVO.getCorrelationId() == null || reqVO.getCorrelationId().isBlank()) {
throw invalidParamException("correlationId 不能为空");
}
if (reqVO.getExpectedCallRevision() != null) {
// 当前 DDL 没有 integration call revision 传入版本时必须失败关闭不能伪造乐观锁校验
throw invalidParamException("expectedCallRevision 暂不支持:当前调用审计记录未持久化 revision");
}
String verificationMode = reqVO.getVerificationMode();
if (verificationMode == null || verificationMode.isBlank()) {
verificationMode = VERIFICATION_MODE_STRICT;
}
if (!VERIFICATION_MODE_STRICT.equals(verificationMode)) {
throw invalidParamException("verificationMode 仅支持 strict");
}
return NormalizedCreateRequest.builder()
.commandId(reqVO.getCommandId().trim())
.correlationId(reqVO.getCorrelationId().trim())
.callIds(normalizeCallIds(reqVO.getCallIds()))
.verificationMode(verificationMode)
.reason(reqVO.getReason())
.build();
}
private List<String> normalizeCallIds(List<String> callIds) {
if (callIds == null || callIds.isEmpty()) {
return Collections.emptyList();
}
LinkedHashSet<String> normalized = new LinkedHashSet<>();
for (String callId : callIds) {
if (callId == null || callId.isBlank()) {
throw invalidParamException("callIds 不能包含空值");
}
normalized.add(callId.trim());
}
return List.copyOf(normalized);
}
private List<AccountIntegrationCallDO> requireEligibleCalls(NormalizedCreateRequest request) {
List<AccountIntegrationCallDO> calls = integrationCallMapper.selectListByCorrelationId(request.getCorrelationId());
if (calls == null || calls.isEmpty()) {
throw new ServiceException(ACCOUNT_INTEGRATION_CALL_NOT_EXISTS);
}
if (request.getCallIds().isEmpty()) {
return calls;
}
LinkedHashSet<String> existingCallIds = calls.stream()
.map(this::callId)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<String> missingCallIds = request.getCallIds().stream()
.filter(callId -> !existingCallIds.contains(callId))
.toList();
if (!missingCallIds.isEmpty()) {
throw invalidParamException("callIds 包含不属于该 correlationId 的调用记录: {}", missingCallIds);
}
List<AccountIntegrationCallDO> filtered = calls.stream()
.filter(call -> request.getCallIds().contains(callId(call)))
.toList();
if (filtered.isEmpty()) {
throw invalidParamException("callIds 未命中该 correlationId 下的调用记录");
}
return filtered;
}
private Long requireSingleAccountUser(List<AccountIntegrationCallDO> calls) {
List<Long> accountUserIds = calls.stream()
.map(AccountIntegrationCallDO::getAccountUserId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (accountUserIds.size() != 1) {
throw invalidParamException("correlationId 下调用记录缺少唯一 accountUserId");
}
return accountUserIds.get(0);
}
private CallAttributionJobDetailRespVO toDetail(AccountCallAttributionJobDO job,
List<AccountCallAttributionItemDO> items) {
String status = toOpenApiDetailStatus(job.getStatus());
CallAttributionJobDetailRespVO detail = new CallAttributionJobDetailRespVO();
detail.setJobId(job.getJobId());
detail.setStatus(status);
detail.setUserId(AccountConvert.INSTANCE.stringId(job.getAccountUserId()));
detail.setAttributedCallCount(countStatus(items, STATUS_COMPLETED));
detail.setFailedCallCount(countStatus(items, STATUS_FAILED));
detail.setPendingCallCount(Math.toIntExact(items.size()
- detail.getAttributedCallCount() - detail.getFailedCallCount()));
detail.setFailedReason(resolveFailedReason(job, detail.getFailedCallCount()));
detail.setCompensationSuggestion(detail.getFailedReason() == null ? null : COMPENSATION_SUGGESTION);
detail.setCreatedAt(job.getCreateTime());
detail.setCompletedAt(isTerminalStatus(status) ? completedAt(job) : null);
return detail;
}
private String resolveJobStatus(List<AccountCallAttributionItemDO> items) {
int successCount = countStatus(items, STATUS_COMPLETED);
int failedCount = countStatus(items, STATUS_FAILED);
if (successCount > 0 && failedCount == 0) {
return STATUS_COMPLETED;
}
if (successCount > 0) {
return STATUS_PARTIALLY_COMPLETED;
}
return STATUS_FAILED;
}
private ResultSnapshot resultSnapshot(String status, List<AccountCallAttributionItemDO> items) {
return ResultSnapshot.builder()
.status(status)
.attributedCallCount(countStatus(items, STATUS_COMPLETED))
.failedCallCount(countStatus(items, STATUS_FAILED))
.pendingCallCount(Math.toIntExact(items.size()
- countStatus(items, STATUS_COMPLETED) - countStatus(items, STATUS_FAILED)))
.failedReason(STATUS_FAILED.equals(status) || STATUS_PARTIALLY_COMPLETED.equals(status)
? ATTRIBUTION_SOURCE_UNAVAILABLE_MESSAGE : null)
.compensationSuggestion(STATUS_FAILED.equals(status) || STATUS_PARTIALLY_COMPLETED.equals(status)
? COMPENSATION_SUGGESTION : null)
.build();
}
private String toOpenApiDetailStatus(String status) {
if (STATUS_QUEUED.equals(status) || STATUS_PROCESSING.equals(status) || STATUS_COMPLETED.equals(status)
|| STATUS_FAILED.equals(status) || STATUS_PARTIALLY_COMPLETED.equals(status)) {
return status;
}
if (STATUS_PENDING.equals(status)) {
return STATUS_QUEUED;
}
if (STATUS_UNAVAILABLE.equals(status)) {
return STATUS_FAILED;
}
return STATUS_FAILED;
}
private boolean isTerminalStatus(String status) {
return STATUS_COMPLETED.equals(status) || STATUS_FAILED.equals(status)
|| STATUS_PARTIALLY_COMPLETED.equals(status);
}
private LocalDateTime completedAt(AccountCallAttributionJobDO job) {
return job.getUpdateTime() == null ? job.getCreateTime() : job.getUpdateTime();
}
private String resolveFailedReason(AccountCallAttributionJobDO job, Integer failedCallCount) {
if (failedCallCount == null || failedCallCount == 0) {
return null;
}
if (job.getErrorMessage() != null && !job.getErrorMessage().isBlank()) {
return job.getErrorMessage();
}
return ATTRIBUTION_SOURCE_UNAVAILABLE_MESSAGE;
}
private int countStatus(List<AccountCallAttributionItemDO> items, String status) {
return Math.toIntExact(items.stream().filter(item -> status.equals(item.getStatus())).count());
}
private String callId(AccountIntegrationCallDO call) {
return call.getExternalCallId() == null || call.getExternalCallId().isBlank()
? String.valueOf(call.getId()) : call.getExternalCallId();
}
private String emptyToUnknown(String value) {
return value == null || value.isBlank() ? ATTRIBUTION_UNKNOWN : value;
}
private String nullToJsonObject(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value;
}
private String newJobId() {
return "call-attr-" + UUID.randomUUID();
}
@Data
@Builder
private static class NormalizedCreateRequest {
private String commandId;
private String correlationId;
private List<String> callIds;
private String verificationMode;
private String reason;
}
@Data
@Builder
private static class SourceSnapshot {
private String correlationId;
private List<String> callIds;
private String verificationMode;
private String reason;
}
@Data
@Builder
private static class FailureSnapshot {
private String errorCode;
private String message;
}
@Data
@Builder
private static class ResultSnapshot {
private String status;
private Integer attributedCallCount;
private Integer pendingCallCount;
private Integer failedCallCount;
private String failedReason;
private String compensationSuggestion;
}
}

View File

@ -0,0 +1,44 @@
package cn.iocoder.muse.module.member.application.account.facade;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
* Account 调用归因来源边界
*
* <p>本接口是 Account AI task / work / agent / asset / license owner 的唯一读取边界P1R-3 默认实现
* 不读取这些外部域后续阶段只能通过替换该 facade 接入真实 owner 关系不能在 Account 服务内直接查主流程表</p>
*/
public interface AccountAttributionSourceFacade {
AttributionResult resolve(AttributionCommand command);
@Data
@Builder
class AttributionCommand {
private Long accountUserId;
private String correlationId;
private List<String> callIds;
private String verificationMode;
private String reason;
}
@Data
@Builder
class AttributionResult {
private List<ResolvedAttribution> items;
}
@Data
@Builder
class ResolvedAttribution {
private String callId;
private String attributionType;
private String attributedTargetType;
private Long attributedTargetId;
private String sourceSnapshot;
private String attributionSnapshot;
}
}

View File

@ -0,0 +1,21 @@
package cn.iocoder.muse.module.member.application.account.facade;
import cn.iocoder.muse.framework.common.exception.ServiceException;
import org.springframework.stereotype.Component;
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_NEW_API_UNAVAILABLE;
/**
* 默认调用归因来源边界
*
* <p>当前阶段没有 AI / Content / Market owner 的可信关系读取能力默认实现必须失败关闭不能伪造
* user/work/agent/asset/license 归因成功</p>
*/
@Component
public class UnavailableAccountAttributionSourceFacade implements AccountAttributionSourceFacade {
@Override
public AttributionResult resolve(AttributionCommand command) {
throw new ServiceException(ACCOUNT_NEW_API_UNAVAILABLE);
}
}

View File

@ -1,11 +1,15 @@
package cn.iocoder.muse.module.member.controller.admin.account;
import cn.iocoder.muse.framework.common.pojo.CommonResult;
import cn.iocoder.muse.module.member.application.account.AccountAttributionService;
import cn.iocoder.muse.module.member.application.account.AccountUsageService;
import cn.iocoder.muse.module.member.controller.account.vo.AccountPageParam;
import cn.iocoder.muse.module.member.controller.account.vo.AccountPageResult;
import cn.iocoder.muse.module.member.controller.admin.account.vo.AdminIntegrationCallDetailRespVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.AdminUsageRecordRespVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobCreateReqVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobDetailRespVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobResultRespVO;
import cn.iocoder.muse.module.member.domain.account.AccountApiVersionGuard;
import cn.iocoder.muse.module.member.domain.account.AccountUserIdResolver;
import io.swagger.v3.oas.annotations.Operation;
@ -16,12 +20,15 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import static cn.iocoder.muse.framework.common.pojo.CommonResult.success;
import static cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
/**
* 管理后台 - Muse Account 用量 API
@ -34,6 +41,8 @@ public class AdminAccountUsageController {
@Resource
private AccountUsageService usageService;
@Resource
private AccountAttributionService attributionService;
@GetMapping("/account/usage-records")
@Operation(summary = "查询账户用量摘要")
@ -58,4 +67,24 @@ public class AdminAccountUsageController {
AccountApiVersionGuard.requireVersion(apiVersion);
return success(usageService.adminGetIntegrationCallByCorrelation(correlationId));
}
@PostMapping("/account/call-attribution-jobs")
@Operation(summary = "创建调用归属 job")
@PreAuthorize("@ss.hasPermission('muse:account:attribution:manage')")
public CommonResult<CallAttributionJobResultRespVO> createCallAttributionJob(
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
@RequestBody @Valid CallAttributionJobCreateReqVO reqVO) {
AccountApiVersionGuard.requireVersion(apiVersion);
return success(attributionService.adminCreateCallAttributionJob(getLoginUserId(), reqVO));
}
@GetMapping("/account/call-attribution-jobs/{jobId}")
@Operation(summary = "查询调用归属状态")
@PreAuthorize("@ss.hasPermission('muse:account:query')")
public CommonResult<CallAttributionJobDetailRespVO> getCallAttributionJob(
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
@PathVariable String jobId) {
AccountApiVersionGuard.requireVersion(apiVersion);
return success(attributionService.adminGetCallAttributionJob(jobId));
}
}

View File

@ -1,6 +1,10 @@
package cn.iocoder.muse.module.member.controller.admin.account.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
@ -10,16 +14,22 @@ import java.util.List;
public class CallAttributionJobCreateReqVO {
@Schema(description = "幂等键")
@NotBlank(message = "commandId 不能为空")
private String commandId;
@Schema(description = "New-API 调用关联 ID")
@NotBlank(message = "correlationId 不能为空")
private String correlationId;
@Schema(description = "New-API callId 白名单")
@Size(max = 200, message = "callIds 最多 200 个")
private List<String> callIds;
@Schema(description = "调用审计记录 revision 乐观锁")
@Min(value = 0, message = "expectedCallRevision 不能小于 0")
private Integer expectedCallRevision;
@Schema(description = "归因校验模式")
@Pattern(regexp = "strict", message = "verificationMode 仅支持 strict")
private String verificationMode;
@Schema(description = "归属原因")
@Size(max = 500, message = "reason 最多 500 个字符")
private String reason;
}

View File

@ -17,6 +17,12 @@ public interface AccountIntegrationCallMapper extends BaseMapperX<AccountIntegra
return selectOne(AccountIntegrationCallDO::getCorrelationId, correlationId);
}
default List<AccountIntegrationCallDO> selectListByCorrelationId(String correlationId) {
return selectList(new LambdaQueryWrapperX<AccountIntegrationCallDO>()
.eq(AccountIntegrationCallDO::getCorrelationId, correlationId)
.orderByAsc(AccountIntegrationCallDO::getId));
}
default List<AccountIntegrationCallDO> selectListByAccountUserId(Long accountUserId) {
return selectList(new LambdaQueryWrapperX<AccountIntegrationCallDO>()
.eq(AccountIntegrationCallDO::getAccountUserId, accountUserId)

View File

@ -0,0 +1,256 @@
package cn.iocoder.muse.module.member.application.account;
import cn.iocoder.muse.framework.common.exception.ServiceException;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.module.member.application.account.facade.AccountAttributionSourceFacade;
import cn.iocoder.muse.module.member.application.account.facade.UnavailableAccountAttributionSourceFacade;
import cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobCreateReqVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobDetailRespVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobResultRespVO;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCallAttributionItemDO;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCallAttributionJobDO;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountCommandDO;
import cn.iocoder.muse.module.member.dal.dataobject.account.AccountIntegrationCallDO;
import cn.iocoder.muse.module.member.dal.mysql.account.AccountCallAttributionItemMapper;
import cn.iocoder.muse.module.member.dal.mysql.account.AccountCallAttributionJobMapper;
import cn.iocoder.muse.module.member.dal.mysql.account.AccountIntegrationCallMapper;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.time.LocalDateTime;
import java.util.List;
import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_COMMAND_ID_CONFLICT;
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_INTEGRATION_CALL_NOT_EXISTS;
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_NEW_API_UNAVAILABLE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* Account 调用归因应用服务测试
*/
class AccountAttributionServiceTest extends BaseMockitoUnitTest {
@InjectMocks
private AccountAttributionServiceImpl attributionService;
@Mock
private AccountIntegrationCallMapper integrationCallMapper;
@Mock
private AccountCallAttributionJobMapper jobMapper;
@Mock
private AccountCallAttributionItemMapper itemMapper;
@Mock
private AccountCommandService commandService;
@Mock
private AccountAttributionSourceFacade attributionSourceFacade;
@Test
void should_throwNotFoundAndNotReserveCommand_when_correlationMissing() {
when(integrationCallMapper.selectListByCorrelationId("corr-missing")).thenReturn(List.of());
ServiceException exception = assertThrows(ServiceException.class,
() -> attributionService.adminCreateCallAttributionJob(9001L,
createReq("cmd-attr-1", "corr-missing", List.of("call-1"))));
assertEquals(ACCOUNT_INTEGRATION_CALL_NOT_EXISTS.getCode(), exception.getCode());
verifyNoInteractions(commandService, jobMapper, itemMapper, attributionSourceFacade);
}
@Test
void should_useCallIdsWhitelist_when_creatingJob() {
when(integrationCallMapper.selectListByCorrelationId("corr-attr-1")).thenReturn(List.of(
integrationCall(101L, "corr-attr-1", "call-keep"),
integrationCall(102L, "corr-attr-1", "call-skip")));
when(commandService.buildRequestHash(any())).thenReturn("hash-attr");
when(commandService.reserveCommand(any())).thenReturn(null);
when(attributionSourceFacade.resolve(any()))
.thenThrow(new ServiceException(ACCOUNT_NEW_API_UNAVAILABLE));
CallAttributionJobResultRespVO result = attributionService.adminCreateCallAttributionJob(9001L,
createReq("cmd-attr-1", "corr-attr-1", List.of("call-keep")));
assertEquals("queued", result.getStatus());
assertFalse(List.of("pending", "unavailable", "failed").contains(result.getStatus()));
verify(commandService).reserveCommand(argThat(envelope ->
"adminCreateCallAttributionJob".equals(envelope.getOperationId())
&& Long.valueOf(9001L).equals(envelope.getActorUserId())
&& Long.valueOf(1001L).equals(envelope.getOwnerUserId())
&& "callAttribution".equals(envelope.getTargetType())
&& "callAttribution:corr-attr-1".equals(envelope.getTargetKey())));
verify(attributionSourceFacade).resolve(argThat(command ->
"corr-attr-1".equals(command.getCorrelationId())
&& List.of("call-keep").equals(command.getCallIds())));
verify(itemMapper).insert(argThat((AccountCallAttributionItemDO item) ->
"call-keep".equals(item.getCallId())
&& Long.valueOf(101L).equals(item.getIntegrationCallId())
&& "failed".equals(item.getStatus())));
verify(itemMapper, never()).insert(argThat((AccountCallAttributionItemDO item) ->
"call-skip".equals(item.getCallId())));
verify(commandService).recordSucceeded(any(), argThat(snapshot ->
"queued".equals(JsonUtils.parseTree(snapshot).path("status").asText())));
}
@Test
void should_rejectCallIdsOutsideCorrelation_when_whitelistContainsUnknownCall() {
when(integrationCallMapper.selectListByCorrelationId("corr-attr-1")).thenReturn(List.of(
integrationCall(101L, "corr-attr-1", "call-keep")));
ServiceException exception = assertThrows(ServiceException.class,
() -> attributionService.adminCreateCallAttributionJob(9001L,
createReq("cmd-attr-1", "corr-attr-1", List.of("call-keep", "call-missing"))));
assertEquals(BAD_REQUEST.getCode(), exception.getCode());
verifyNoInteractions(commandService, jobMapper, itemMapper, attributionSourceFacade);
}
@Test
void should_returnIdempotentHit_when_commandReplayed() {
when(integrationCallMapper.selectListByCorrelationId("corr-attr-1")).thenReturn(List.of(
integrationCall(101L, "corr-attr-1", "call-keep")));
when(commandService.buildRequestHash(any())).thenReturn("hash-attr");
when(commandService.reserveCommand(any())).thenReturn(AccountCommandDO.builder()
.resultSnapshot("""
{"jobId":"attr-job-1","status":"queued"}
""")
.build());
CallAttributionJobResultRespVO result = attributionService.adminCreateCallAttributionJob(9001L,
createReq("cmd-attr-1", "corr-attr-1", List.of("call-keep")));
assertEquals("attr-job-1", result.getJobId());
assertEquals("idempotent_hit", result.getStatus());
verifyNoInteractions(jobMapper, itemMapper, attributionSourceFacade);
}
@Test
void should_propagateConflict_when_commandIdUsedByDifferentRequest() {
when(integrationCallMapper.selectListByCorrelationId("corr-attr-1")).thenReturn(List.of(
integrationCall(101L, "corr-attr-1", "call-keep")));
when(commandService.buildRequestHash(any())).thenReturn("hash-attr");
when(commandService.reserveCommand(any()))
.thenThrow(new ServiceException(ACCOUNT_COMMAND_ID_CONFLICT));
ServiceException exception = assertThrows(ServiceException.class,
() -> attributionService.adminCreateCallAttributionJob(9001L,
createReq("cmd-attr-1", "corr-attr-1", List.of("call-keep"))));
assertEquals(ACCOUNT_COMMAND_ID_CONFLICT.getCode(), exception.getCode());
verifyNoInteractions(jobMapper, itemMapper, attributionSourceFacade);
}
@Test
void should_rejectExpectedCallRevision_when_callRevisionIsNotPersisted() {
CallAttributionJobCreateReqVO reqVO = createReq("cmd-attr-1", "corr-attr-1", List.of("call-keep"));
reqVO.setExpectedCallRevision(2);
ServiceException exception = assertThrows(ServiceException.class,
() -> attributionService.adminCreateCallAttributionJob(9001L, reqVO));
assertEquals(BAD_REQUEST.getCode(), exception.getCode());
verifyNoInteractions(integrationCallMapper, commandService, jobMapper, itemMapper, attributionSourceFacade);
}
@Test
void should_queryDetailWithOpenApiStatusAndCounts() {
AccountCallAttributionJobDO job = job("attr-job-1", "partially_completed");
when(jobMapper.selectByJobId("attr-job-1")).thenReturn(job);
when(itemMapper.selectListByJobId("attr-job-1")).thenReturn(List.of(
item("attr-job-1", "call-ok", "completed"),
item("attr-job-1", "call-failed", "failed"),
item("attr-job-1", "call-wait", "queued")));
CallAttributionJobDetailRespVO detail = attributionService.adminGetCallAttributionJob("attr-job-1");
assertEquals("attr-job-1", detail.getJobId());
assertEquals("partially_completed", detail.getStatus());
assertEquals("1001", detail.getUserId());
assertEquals(1, detail.getAttributedCallCount());
assertEquals(1, detail.getFailedCallCount());
assertEquals(1, detail.getPendingCallCount());
assertEquals("外部归因来源暂不可用", detail.getFailedReason());
assertEquals("等待 AI/Content/Market owner 接入后重试该 job", detail.getCompensationSuggestion());
assertEquals(LocalDateTime.of(2026, 5, 28, 12, 0), detail.getCreatedAt());
assertEquals(LocalDateTime.of(2026, 5, 28, 12, 5), detail.getCompletedAt());
}
@Test
void should_notExposeInternalPendingOrUnavailableStatus_when_queryDetail() {
AccountCallAttributionJobDO job = job("attr-job-1", "unavailable");
when(jobMapper.selectByJobId("attr-job-1")).thenReturn(job);
when(itemMapper.selectListByJobId("attr-job-1")).thenReturn(List.of());
CallAttributionJobDetailRespVO detail = attributionService.adminGetCallAttributionJob("attr-job-1");
assertEquals("failed", detail.getStatus());
assertFalse(List.of("pending", "unavailable").contains(detail.getStatus()));
}
@Test
void should_unavailableFacadeFailClosed_withoutFakeAttributionSuccess() {
UnavailableAccountAttributionSourceFacade facade = new UnavailableAccountAttributionSourceFacade();
ServiceException exception = assertThrows(ServiceException.class,
() -> facade.resolve(AccountAttributionSourceFacade.AttributionCommand.builder()
.accountUserId(1001L)
.correlationId("corr-attr-1")
.callIds(List.of("call-1"))
.verificationMode("strict")
.build()));
assertEquals(ACCOUNT_NEW_API_UNAVAILABLE.getCode(), exception.getCode());
}
private static CallAttributionJobCreateReqVO createReq(String commandId, String correlationId, List<String> callIds) {
CallAttributionJobCreateReqVO reqVO = new CallAttributionJobCreateReqVO();
reqVO.setCommandId(commandId);
reqVO.setCorrelationId(correlationId);
reqVO.setCallIds(callIds);
reqVO.setVerificationMode("strict");
reqVO.setReason("operator requested");
return reqVO;
}
private static AccountIntegrationCallDO integrationCall(Long id, String correlationId, String callId) {
return AccountIntegrationCallDO.builder()
.id(id)
.correlationId(correlationId)
.accountUserId(1001L)
.operationId("adminCreateQuotaRequest")
.status("pending_attribution")
.externalCallId(callId)
.build();
}
private static AccountCallAttributionJobDO job(String jobId, String status) {
AccountCallAttributionJobDO job = AccountCallAttributionJobDO.builder()
.jobId(jobId)
.accountUserId(1001L)
.correlationId("corr-attr-1")
.status(status)
.errorMessage("外部归因来源暂不可用")
.build();
job.setCreateTime(LocalDateTime.of(2026, 5, 28, 12, 0));
job.setUpdateTime(LocalDateTime.of(2026, 5, 28, 12, 5));
return job;
}
private static AccountCallAttributionItemDO item(String jobId, String callId, String status) {
return AccountCallAttributionItemDO.builder()
.jobId(jobId)
.accountUserId(1001L)
.correlationId("corr-attr-1")
.callId(callId)
.status(status)
.build();
}
}

View File

@ -3,10 +3,13 @@ package cn.iocoder.muse.module.member.controller.admin.account;
import cn.iocoder.muse.framework.common.exception.ServiceException;
import cn.iocoder.muse.framework.common.pojo.CommonResult;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.module.member.application.account.AccountAttributionService;
import cn.iocoder.muse.module.member.application.account.AccountUsageService;
import cn.iocoder.muse.module.member.controller.account.vo.AccountPageResult;
import cn.iocoder.muse.module.member.controller.admin.account.vo.AdminIntegrationCallDetailRespVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.AdminUsageRecordRespVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobDetailRespVO;
import cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobResultRespVO;
import cn.iocoder.muse.module.member.domain.account.AccountUserIdResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -33,6 +36,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -45,6 +49,8 @@ class AdminAccountUsageControllerTest extends BaseMockitoUnitTest {
private AdminAccountUsageController controller;
@Mock
private AccountUsageService usageService;
@Mock
private AccountAttributionService attributionService;
private MockMvc mockMvc;
@ -78,13 +84,35 @@ class AdminAccountUsageControllerTest extends BaseMockitoUnitTest {
assertEquals("@ss.hasPermission('muse:account:query')", preAuthorize.value());
}
@Test
void should_requireRbacPermission_forCallAttributionCreate() throws Exception {
Method method = AdminAccountUsageController.class.getMethod("createCallAttributionJob",
String.class, cn.iocoder.muse.module.member.controller.admin.account.vo.CallAttributionJobCreateReqVO.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermission('muse:account:attribution:manage')", preAuthorize.value());
}
@Test
void should_requireRbacPermission_forCallAttributionQuery() throws Exception {
Method method = AdminAccountUsageController.class.getMethod("getCallAttributionJob",
String.class, String.class);
PreAuthorize preAuthorize = method.getAnnotation(PreAuthorize.class);
assertNotNull(preAuthorize);
assertEquals("@ss.hasPermission('muse:account:query')", preAuthorize.value());
}
@Test
void should_rejectMissingApiVersion_when_listUsageRecords() throws Exception {
mockMvc.perform(get("/muse/account/usage-records"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode()))
.andExpect(jsonPath("$").value(hasKey("data")));
verifyNoInteractions(usageService);
verifyNoInteractions(usageService, attributionService);
}
@Test
@ -210,6 +238,67 @@ class AdminAccountUsageControllerTest extends BaseMockitoUnitTest {
verify(usageService).adminGetIntegrationCallByCorrelation("corr-quota-1");
}
@Test
void should_createCallAttributionJobWithTypedBody() throws Exception {
CallAttributionJobResultRespVO result = new CallAttributionJobResultRespVO();
result.setJobId("attr-job-1");
result.setStatus("queued");
when(attributionService.adminCreateCallAttributionJob(any(), any())).thenReturn(result);
mockMvc.perform(post("/muse/account/call-attribution-jobs")
.header("X-API-Version", "1")
.contentType("application/json")
.content("""
{"commandId":"cmd-attr-1","correlationId":"corr-attr-1","callIds":["call-1"],"verificationMode":"strict","reason":"operator requested"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.jobId").value("attr-job-1"))
.andExpect(jsonPath("$.data.status").value("queued"));
verify(attributionService).adminCreateCallAttributionJob(any(), argThat(req ->
"cmd-attr-1".equals(req.getCommandId())
&& "corr-attr-1".equals(req.getCorrelationId())
&& List.of("call-1").equals(req.getCallIds())
&& "strict".equals(req.getVerificationMode())
&& "operator requested".equals(req.getReason())));
}
@Test
void should_rejectMissingCommandId_when_createCallAttributionJob() throws Exception {
mockMvc.perform(post("/muse/account/call-attribution-jobs")
.header("X-API-Version", "1")
.contentType("application/json")
.content("""
{"correlationId":"corr-attr-1","verificationMode":"strict"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(400))
.andExpect(jsonPath("$").value(hasKey("data")));
verifyNoInteractions(attributionService);
}
@Test
void should_getCallAttributionJobDetail() throws Exception {
CallAttributionJobDetailRespVO detail = new CallAttributionJobDetailRespVO();
detail.setJobId("attr-job-1");
detail.setStatus("failed");
detail.setUserId("1001");
detail.setFailedCallCount(1);
detail.setFailedReason("外部归因来源暂不可用");
when(attributionService.adminGetCallAttributionJob("attr-job-1")).thenReturn(detail);
mockMvc.perform(get("/muse/account/call-attribution-jobs/attr-job-1")
.header("X-API-Version", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.jobId").value("attr-job-1"))
.andExpect(jsonPath("$.data.status").value("failed"))
.andExpect(jsonPath("$.data.failedCallCount").value(1));
verify(attributionService).adminGetCallAttributionJob("attr-job-1");
}
@RestControllerAdvice
static class TestExceptionAdvice {