feat(p1r): 实现 Account 调用归因边界
This commit is contained in:
parent
22f498dd3c
commit
b6d9f36b45
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user