feat(handoff): P3 content asset_use 两段式后端(token 红线 + 单表事实)
content asset_use 从零建(SSOT 后端-04 定义表名/索引、建表 SQL 此前未写、feature disabled): - V28 建表 muse_content_work_asset_use_precheck(单表,照 knowledge bind_precheck) - 两段式:asset-use-prechecks(market 来源 verify(content/asset_use)+consume token、明文仅此阶段) → asset-uses(凭 precheckId + work 级乐观锁,单表转 consumed 即使用事实,AI archive 引用 precheckId) - purposes 白名单 reference/ai_context/generate_reference(禁 template 模板化=forbiddenPurpose) - content-server 依赖 market-api(经端口,不碰 market.dal)+ 错误码 1_041_002_xxx 验证:编译 OK、MuseContentAssetUseServiceTest 8/0(正路 verify+consume/伪造拒/缺 token 拒/禁用 purpose 拒/ 越权拒/create 闭环/work revision 冲突/precheck 过期)、BcBoundaryArchTest 0 违例(content→market-api)。 asset_use 只记录使用授权事实、不写正文/参考来源(产品-02F L456),不涉及资产物化。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1a11bbde7a
commit
d113f4ee15
@ -44,6 +44,12 @@
|
||||
<artifactId>muse-module-meta-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<!-- P3 跨空间 handoff 兑现:消费 market-api MarketHandoffTokenApi(asset_use 服务端 verify/consume token,不碰 market.dal) -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
<artifactId>muse-module-market-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
<artifactId>muse-spring-boot-starter-biz-tenant</artifactId>
|
||||
|
||||
@ -0,0 +1,331 @@
|
||||
package cn.iocoder.muse.module.content.application;
|
||||
|
||||
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.content.controller.app.vo.AppContentAssetUseVO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.ContentCommandDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.MuseContentWorkAssetUsePrecheckDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.WorkDO;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.MuseContentWorkAssetUsePrecheckMapper;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.WorkMapper;
|
||||
import cn.iocoder.muse.module.content.domain.ContentApiVersionGuard;
|
||||
import cn.iocoder.muse.module.content.domain.ContentRevisionGuard;
|
||||
import cn.iocoder.muse.module.market.api.handoff.MarketHandoffTokenApi;
|
||||
import cn.iocoder.muse.module.market.api.handoff.dto.HandoffConsumeReqDTO;
|
||||
import cn.iocoder.muse.module.market.api.handoff.dto.HandoffVerifyReqDTO;
|
||||
import cn.iocoder.muse.module.market.api.handoff.dto.HandoffVerifyRespDTO;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
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.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_ASSET_HANDOFF_UNAVAILABLE;
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_ASSET_SOURCE_UNAVAILABLE;
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_ASSET_USE_PRECHECK_EXPIRED;
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_ASSET_USE_PRECHECK_NOT_EXISTS;
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_ASSET_USE_PURPOSE_INVALID;
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_FORBIDDEN;
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_NOT_FOUND;
|
||||
|
||||
/**
|
||||
* Content 作品资产使用(asset_use)应用服务(跨空间 handoff 兑现,P3)。
|
||||
*
|
||||
* <p>单表两段式(评审版 Q1):precheck 创建落 active(market 来源服务端 verify+consume token);
|
||||
* create 确认转 consumed(work 级乐观锁)即"作品可用此市场资产作参考/AI 上下文"凭证。
|
||||
* 红线"不信任客户端 URL 参数":token 仅 precheck 阶段核验核销,create 凭 precheckId。
|
||||
* asset_use 只记录使用授权事实,不写入正文/参考来源/规划项/局域 kb(产品-02F L456)。</p>
|
||||
*/
|
||||
@Service
|
||||
public class MuseContentAssetUseService {
|
||||
|
||||
private static final String SOURCE_TYPE_MARKET = "market_asset";
|
||||
private static final Set<String> SOURCE_STATUSES = Set.of("available", "authorization_expired",
|
||||
"source_delisted", "source_recalled", "source_revoked", "unavailable");
|
||||
// 用途白名单:reference/ai_context/generate_reference;禁 template(市场资产模板化=forbiddenPurpose,专题-03 L182)
|
||||
private static final Set<String> PURPOSES = Set.of("reference", "ai_context", "generate_reference");
|
||||
|
||||
@Resource
|
||||
private MuseContentWorkAssetUsePrecheckMapper precheckMapper;
|
||||
@Resource
|
||||
private WorkMapper workMapper;
|
||||
@Resource
|
||||
private ContentCommandService commandService;
|
||||
@Resource
|
||||
private MarketHandoffTokenApi marketHandoffTokenApi;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AppContentAssetUseVO.PrecheckRespVO createAssetUsePrecheck(Long loginUserId, String apiVersion, Long workId,
|
||||
AppContentAssetUseVO.PrecheckReqVO reqVO) {
|
||||
ContentApiVersionGuard.requireVersion(apiVersion);
|
||||
validatePrecheckReq(reqVO);
|
||||
requireOwnedWork(loginUserId, workId);
|
||||
String requestHash = commandService.buildRequestHash(reqVO);
|
||||
ContentCommandDO replay = commandService.reserveCommand(reqVO.getCommandId(), "createAssetUsePrecheck",
|
||||
loginUserId, "assetUsePrecheck", workId, requestHash);
|
||||
if (replay != null) {
|
||||
return JsonUtils.parseObject(replay.getResultSnapshot(), AppContentAssetUseVO.PrecheckRespVO.class);
|
||||
}
|
||||
|
||||
List<String> requestedPurposes = normalizePurposes(reqVO.getPurposes());
|
||||
boolean available = "available".equals(reqVO.getSourceStatus());
|
||||
List<String> allowedPurposes = available ? requestedPurposes : List.of();
|
||||
List<String> blockedPurposes = available ? List.of() : requestedPurposes;
|
||||
List<String> blockedReasons = available ? List.of() : List.of(reqVO.getSourceStatus());
|
||||
LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(30);
|
||||
String precheckId = "content-asset-use-precheck-" + workId + "-" + safeCommandId(reqVO.getCommandId())
|
||||
+ "-" + UUID.randomUUID();
|
||||
// market_asset 来源:兑现前服务端核验 + 核销 Market handoff token(红线落地;token 明文仅 precheck 阶段持有)。
|
||||
// 沿用本方法 @Transactional,verify/consume 与 precheck 落库原子(失败一并回滚)。
|
||||
if (SOURCE_TYPE_MARKET.equals(reqVO.getSourceType())) {
|
||||
consumeMarketHandoff(reqVO, apiVersion, workId, precheckId);
|
||||
}
|
||||
String sourceSnapshotId = "source-" + reqVO.getSourceType() + "-" + reqVO.getSourceId()
|
||||
+ "-v" + reqVO.getSourceVersion();
|
||||
Map<String, Object> summary = precheckSummary(reqVO, requestedPurposes, allowedPurposes, blockedPurposes,
|
||||
blockedReasons, sourceSnapshotId);
|
||||
|
||||
MuseContentWorkAssetUsePrecheckDO precheck = new MuseContentWorkAssetUsePrecheckDO();
|
||||
precheck.setPrecheckId(precheckId);
|
||||
precheck.setCommandId(reqVO.getCommandId());
|
||||
precheck.setRequestHash(requestHash);
|
||||
precheck.setWorkId(workId);
|
||||
precheck.setSourceAssetId(parseLong(reqVO.getSourceId()));
|
||||
precheck.setSourceAssetType(reqVO.getSourceType());
|
||||
precheck.setOwnerUserId(loginUserId);
|
||||
precheck.setActorUserId(loginUserId);
|
||||
precheck.setSourceSnapshotId(sourceSnapshotId);
|
||||
precheck.setAuthorizationSnapshotId(reqVO.getAuthorizationSnapshotId());
|
||||
// 交接材料只存 hash,不存可直接使用的完整凭据(token 已核销)。
|
||||
precheck.setHandoffHash(hashNullable(reqVO.getHandoffToken()));
|
||||
precheck.setStatus(available ? "active" : "blocked");
|
||||
precheck.setExpiresAt(expiresAt);
|
||||
precheck.setResultSummary(JsonUtils.toJsonString(summary));
|
||||
precheck.setTenantId(TenantContextHolder.getRequiredTenantId());
|
||||
precheckMapper.insert(precheck);
|
||||
|
||||
AppContentAssetUseVO.PrecheckRespVO respVO = new AppContentAssetUseVO.PrecheckRespVO();
|
||||
respVO.setAssetUsePrecheckId(precheckId);
|
||||
respVO.setAllowedPurposes(allowedPurposes);
|
||||
respVO.setBlockedPurposes(blockedPurposes);
|
||||
respVO.setBlockedReasons(blockedReasons);
|
||||
respVO.setSourceStatus(reqVO.getSourceStatus());
|
||||
respVO.setExpiresAt(rfc3339(expiresAt));
|
||||
commandService.recordSucceeded(reqVO.getCommandId(), "createAssetUsePrecheck", loginUserId,
|
||||
"assetUsePrecheck", workId, requestHash, JsonUtils.toJsonString(respVO));
|
||||
return respVO;
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AppContentAssetUseVO.CreateRespVO createAssetUse(Long loginUserId, String apiVersion, Long workId,
|
||||
AppContentAssetUseVO.CreateReqVO reqVO) {
|
||||
ContentApiVersionGuard.requireVersion(apiVersion);
|
||||
WorkDO work = requireOwnedWork(loginUserId, workId);
|
||||
String requestHash = commandService.buildRequestHash(reqVO);
|
||||
ContentCommandDO replay = commandService.reserveCommand(reqVO.getCommandId(), "createAssetUse",
|
||||
loginUserId, "assetUse", workId, requestHash);
|
||||
if (replay != null) {
|
||||
return JsonUtils.parseObject(replay.getResultSnapshot(), AppContentAssetUseVO.CreateRespVO.class);
|
||||
}
|
||||
|
||||
MuseContentWorkAssetUsePrecheckDO precheck = requireUsablePrecheck(loginUserId, workId,
|
||||
reqVO.getAssetUsePrecheckId());
|
||||
// work 级乐观锁:确保使用资产时作品处于预期版本(content 写命令约定);asset_use 不改 work、不递增 revision。
|
||||
ContentRevisionGuard.requireRevision(reqVO.getExpectedWorkRevision(), work.getRevision(), "作品");
|
||||
Map<String, Object> summary = readJson(precheck.getResultSummary());
|
||||
List<String> purposes = stringList(summary.get("allowedPurposes"));
|
||||
|
||||
// 单表两段式:precheck 转 consumed 即"使用事实"落定(AI archive 后续引用 precheck_id)。token 已在 precheck 核销。
|
||||
precheck.setStatus("consumed");
|
||||
precheck.setExpectedWorkRevision(reqVO.getExpectedWorkRevision());
|
||||
precheck.setConsumedAt(LocalDateTime.now());
|
||||
precheckMapper.updateById(precheck);
|
||||
|
||||
AppContentAssetUseVO.CreateRespVO respVO = new AppContentAssetUseVO.CreateRespVO();
|
||||
respVO.setAssetUseId(precheck.getPrecheckId());
|
||||
respVO.setSourceType(stringValue(summary, "sourceType"));
|
||||
respVO.setSourceId(stringValue(summary, "sourceId"));
|
||||
respVO.setSourceVersion(intValue(summary.get("sourceVersion")));
|
||||
respVO.setPurposes(purposes);
|
||||
respVO.setAuthorizationSnapshotId(precheck.getAuthorizationSnapshotId());
|
||||
respVO.setCreatedAt(rfc3339(LocalDateTime.now()));
|
||||
commandService.recordSucceeded(reqVO.getCommandId(), "createAssetUse", loginUserId,
|
||||
"assetUse", workId, requestHash, JsonUtils.toJsonString(respVO));
|
||||
return respVO;
|
||||
}
|
||||
|
||||
/**
|
||||
* market_asset 来源:服务端核验 handoff token(属主/未过期/未消费/未取消 + owner-action 匹配)并核销(消费换 session)。
|
||||
* 红线("不信任客户端 URL 参数")在 content 兑现侧落地——伪造/过期/跨属主 token 一律阻断,不写任何使用事实。
|
||||
*/
|
||||
private void consumeMarketHandoff(AppContentAssetUseVO.PrecheckReqVO reqVO, String apiVersion, Long workId,
|
||||
String precheckId) {
|
||||
HandoffVerifyRespDTO verify = marketHandoffTokenApi.verify(
|
||||
new HandoffVerifyReqDTO(reqVO.getHandoffToken(), "content", "asset_use"));
|
||||
if (!verify.valid()) {
|
||||
throw new ServiceException(CONTENT_ASSET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
marketHandoffTokenApi.consume(new HandoffConsumeReqDTO(reqVO.getHandoffToken(), reqVO.getCommandId(),
|
||||
apiVersion, verify.status(), workId, precheckId));
|
||||
}
|
||||
|
||||
private void validatePrecheckReq(AppContentAssetUseVO.PrecheckReqVO reqVO) {
|
||||
if (!SOURCE_STATUSES.contains(reqVO.getSourceStatus())) {
|
||||
throw new ServiceException(CONTENT_ASSET_SOURCE_UNAVAILABLE);
|
||||
}
|
||||
// market_asset 来源必须携带 handoffToken(否则视作未授权交接,fail-closed)。
|
||||
if (SOURCE_TYPE_MARKET.equals(reqVO.getSourceType())
|
||||
&& (reqVO.getHandoffToken() == null || reqVO.getHandoffToken().isBlank())) {
|
||||
throw new ServiceException(CONTENT_ASSET_HANDOFF_UNAVAILABLE);
|
||||
}
|
||||
normalizePurposes(reqVO.getPurposes());
|
||||
}
|
||||
|
||||
private MuseContentWorkAssetUsePrecheckDO requireUsablePrecheck(Long loginUserId, Long workId, String precheckId) {
|
||||
MuseContentWorkAssetUsePrecheckDO precheck = precheckMapper.selectByPrecheckId(precheckId);
|
||||
if (precheck == null || !Objects.equals(precheck.getWorkId(), workId)
|
||||
|| !Objects.equals(precheck.getOwnerUserId(), loginUserId)) {
|
||||
throw new ServiceException(CONTENT_ASSET_USE_PRECHECK_NOT_EXISTS);
|
||||
}
|
||||
if (precheck.getConsumedAt() != null || "consumed".equals(precheck.getStatus())
|
||||
|| precheck.getExpiresAt() == null || precheck.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||
throw new ServiceException(CONTENT_ASSET_USE_PRECHECK_EXPIRED);
|
||||
}
|
||||
if (!"active".equals(precheck.getStatus())) {
|
||||
throw new ServiceException(CONTENT_ASSET_SOURCE_UNAVAILABLE);
|
||||
}
|
||||
Map<String, Object> summary = readJson(precheck.getResultSummary());
|
||||
if (!"available".equals(stringValue(summary, "sourceStatus"))) {
|
||||
throw new ServiceException(CONTENT_ASSET_SOURCE_UNAVAILABLE);
|
||||
}
|
||||
return precheck;
|
||||
}
|
||||
|
||||
private WorkDO requireOwnedWork(Long userId, Long workId) {
|
||||
WorkDO work = workMapper.selectById(workId);
|
||||
if (work == null) {
|
||||
throw new ServiceException(CONTENT_NOT_FOUND.getCode(), "作品不存在");
|
||||
}
|
||||
if (!Objects.equals(work.getOwnerUserId(), userId)) {
|
||||
throw new ServiceException(CONTENT_FORBIDDEN.getCode(), "无权访问该作品");
|
||||
}
|
||||
return work;
|
||||
}
|
||||
|
||||
private List<String> normalizePurposes(List<String> purposes) {
|
||||
List<String> value = purposes == null || purposes.isEmpty() ? List.of("reference") : purposes;
|
||||
for (String purpose : value) {
|
||||
if (!PURPOSES.contains(purpose)) {
|
||||
throw new ServiceException(CONTENT_ASSET_USE_PURPOSE_INVALID);
|
||||
}
|
||||
}
|
||||
return List.copyOf(value);
|
||||
}
|
||||
|
||||
private Map<String, Object> precheckSummary(AppContentAssetUseVO.PrecheckReqVO reqVO, List<String> requestedPurposes,
|
||||
List<String> allowedPurposes, List<String> blockedPurposes,
|
||||
List<String> blockedReasons, String sourceSnapshotId) {
|
||||
Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("sourceType", reqVO.getSourceType());
|
||||
summary.put("sourceId", reqVO.getSourceId());
|
||||
summary.put("sourceVersion", reqVO.getSourceVersion());
|
||||
summary.put("sourceStatus", reqVO.getSourceStatus());
|
||||
summary.put("sourceSnapshotId", sourceSnapshotId);
|
||||
summary.put("authorizationSummaryId", reqVO.getAuthorizationSummaryId());
|
||||
summary.put("authorizationSnapshotId", reqVO.getAuthorizationSnapshotId());
|
||||
summary.put("purposes", requestedPurposes);
|
||||
summary.put("allowedPurposes", allowedPurposes);
|
||||
summary.put("blockedPurposes", blockedPurposes);
|
||||
summary.put("blockedReasons", blockedReasons);
|
||||
summary.put("handoffTokenPersisted", false);
|
||||
return summary;
|
||||
}
|
||||
|
||||
private Map<String, Object> readJson(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, Object> value = JsonUtils.parseObjectQuietly(json, new TypeReference<>() {
|
||||
});
|
||||
return value == null ? Map.of() : value;
|
||||
}
|
||||
|
||||
private List<String> stringList(Object value) {
|
||||
if (value instanceof List<?> list) {
|
||||
List<String> result = new ArrayList<>();
|
||||
for (Object item : list) {
|
||||
result.add(String.valueOf(item));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private String stringValue(Map<String, Object> map, String key) {
|
||||
Object value = map.get(key);
|
||||
return value == null ? null : String.valueOf(value);
|
||||
}
|
||||
|
||||
private Integer intValue(Object value) {
|
||||
if (value instanceof Number number) {
|
||||
return number.intValue();
|
||||
}
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(String.valueOf(value));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Long parseLong(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(value);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String hashNullable(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return sha256(value);
|
||||
}
|
||||
|
||||
private String sha256(String value) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String safeCommandId(String commandId) {
|
||||
return commandId == null ? "missing" : commandId.replaceAll("[^a-zA-Z0-9_-]", "-");
|
||||
}
|
||||
|
||||
private String rfc3339(LocalDateTime time) {
|
||||
return time == null ? null : time.toString();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package cn.iocoder.muse.module.content.controller.app;
|
||||
|
||||
import cn.iocoder.muse.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.muse.module.content.application.MuseContentAssetUseService;
|
||||
import cn.iocoder.muse.module.content.controller.app.vo.AppContentAssetUseVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import static cn.iocoder.muse.framework.common.pojo.CommonResult.success;
|
||||
import static cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
|
||||
|
||||
/**
|
||||
* 用户端 Content 作品资产使用(asset_use)API(跨空间 handoff 兑现,P3)。
|
||||
*
|
||||
* <p>两段式:asset-use-prechecks(token 服务端核验+核销)→ asset-uses(凭 precheckId 落使用事实)。
|
||||
* Controller 只做合同入口校验;owner guard、verify/consume、CAS、幂等、API 版本校验都在应用服务中完成。</p>
|
||||
*/
|
||||
@Tag(name = "用户 APP - Muse Content 作品资产使用")
|
||||
@RestController
|
||||
@RequestMapping("/muse")
|
||||
@Validated
|
||||
public class AppContentAssetUseController {
|
||||
|
||||
@Resource
|
||||
private MuseContentAssetUseService assetUseService;
|
||||
|
||||
@PostMapping("/works/{workId}/asset-use-prechecks")
|
||||
@Operation(summary = "作品资产使用预检(服务端核验+核销 handoff token)")
|
||||
public CommonResult<AppContentAssetUseVO.PrecheckRespVO> createAssetUsePrecheck(
|
||||
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
|
||||
@PathVariable Long workId,
|
||||
@RequestBody @Valid AppContentAssetUseVO.PrecheckReqVO reqVO) {
|
||||
return success(assetUseService.createAssetUsePrecheck(getLoginUserId(), apiVersion, workId, reqVO));
|
||||
}
|
||||
|
||||
@PostMapping("/works/{workId}/asset-uses")
|
||||
@Operation(summary = "确认作品资产使用(凭 precheckId 落使用事实)")
|
||||
public CommonResult<AppContentAssetUseVO.CreateRespVO> createAssetUse(
|
||||
@RequestHeader(value = "X-API-Version", required = false) String apiVersion,
|
||||
@PathVariable Long workId,
|
||||
@RequestBody @Valid AppContentAssetUseVO.CreateReqVO reqVO) {
|
||||
return success(assetUseService.createAssetUse(getLoginUserId(), apiVersion, workId, reqVO));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package cn.iocoder.muse.module.content.controller.app.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* App Content 作品资产使用(asset_use)OpenAPI DTO(跨空间 handoff 兑现,P3)。
|
||||
*
|
||||
* <p>红线:handoffToken 必填(market_asset 来源)——后端在 precheck 阶段服务端核验 + 核销 token(消费换 session),
|
||||
* create 段只凭 assetUsePrecheckId(token 已核销)。purposes 禁 template(市场资产模板化属 forbiddenPurpose)。</p>
|
||||
*/
|
||||
public final class AppContentAssetUseVO {
|
||||
|
||||
private AppContentAssetUseVO() {
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PrecheckReqVO {
|
||||
@NotBlank(message = "commandId 不能为空")
|
||||
private String commandId;
|
||||
@NotBlank(message = "sourceType 不能为空")
|
||||
@Pattern(regexp = "market_asset", message = "sourceType 不支持")
|
||||
private String sourceType;
|
||||
@NotBlank(message = "sourceId 不能为空")
|
||||
private String sourceId;
|
||||
@NotNull(message = "sourceVersion 不能为空")
|
||||
private Integer sourceVersion;
|
||||
@NotBlank(message = "sourceStatus 不能为空")
|
||||
@Pattern(regexp = "available|authorization_expired|source_delisted|source_recalled|source_revoked|unavailable",
|
||||
message = "sourceStatus 不支持")
|
||||
private String sourceStatus;
|
||||
// Market 一次性凭证(market_asset 来源必填,service 层校验)
|
||||
private String handoffToken;
|
||||
@NotBlank(message = "authorizationSummaryId 不能为空")
|
||||
private String authorizationSummaryId;
|
||||
@NotBlank(message = "authorizationSnapshotId 不能为空")
|
||||
private String authorizationSnapshotId;
|
||||
// 用途白名单:reference(参考)/ai_context(上下文检索)/generate_reference(生成参考);禁 template(模板化=forbiddenPurpose)
|
||||
private List<@Pattern(regexp = "reference|ai_context|generate_reference", message = "purposes 不支持") String> purposes;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PrecheckRespVO {
|
||||
private String assetUsePrecheckId;
|
||||
private List<String> allowedPurposes;
|
||||
private List<String> blockedPurposes;
|
||||
private List<String> blockedReasons;
|
||||
private String sourceStatus;
|
||||
private String expiresAt;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CreateReqVO {
|
||||
@NotBlank(message = "commandId 不能为空")
|
||||
private String commandId;
|
||||
@NotBlank(message = "assetUsePrecheckId 不能为空")
|
||||
private String assetUsePrecheckId;
|
||||
@NotNull(message = "expectedWorkRevision 不能为空")
|
||||
private Integer expectedWorkRevision;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CreateRespVO {
|
||||
// 单表设计:asset_use 事实载体即 precheck,assetUseId = precheckId
|
||||
private String assetUseId;
|
||||
private String sourceType;
|
||||
private String sourceId;
|
||||
private Integer sourceVersion;
|
||||
private List<String> purposes;
|
||||
private String authorizationSnapshotId;
|
||||
private String createdAt;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
package cn.iocoder.muse.module.content.dal.dataobject;
|
||||
|
||||
import cn.iocoder.muse.framework.tenant.core.db.TenantBaseDO;
|
||||
import cn.iocoder.muse.module.content.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.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Content 作品资产使用预检 DO(跨空间 handoff 兑现,P3)。
|
||||
*
|
||||
* <p>单表两段式:precheck 创建落 active、create 确认转 consumed(即"作品可用此市场资产作参考/AI 上下文"凭证);
|
||||
* AI 接受候选时引用 precheck_id(后端-04 muse_ai_candidate_decision_archive.work_asset_use_precheck_id)。
|
||||
* asset_use 只记录使用授权事实,不写入正文/参考来源/规划项/局域 kb(产品-02F L456)。</p>
|
||||
*/
|
||||
@TableName(value = "muse_content_work_asset_use_precheck", autoResultMap = true)
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ToString(callSuper = true)
|
||||
public class MuseContentWorkAssetUsePrecheckDO extends TenantBaseDO {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private String precheckId;
|
||||
private String commandId;
|
||||
private String requestHash;
|
||||
private Long workId;
|
||||
/** 来源市场资产 id(market asset id)。 */
|
||||
private Long sourceAssetId;
|
||||
private String sourceAssetType;
|
||||
private Long ownerUserId;
|
||||
private Long actorUserId;
|
||||
private Integer expectedWorkRevision;
|
||||
private String sourceSnapshotId;
|
||||
private String authorizationSnapshotId;
|
||||
/** Market 交接材料只保存 hash,不保存可直接使用的完整凭据。 */
|
||||
private String handoffHash;
|
||||
private String status;
|
||||
private LocalDateTime expiresAt;
|
||||
private LocalDateTime consumedAt;
|
||||
@TableField(typeHandler = JsonbStringTypeHandler.class)
|
||||
private String resultSummary;
|
||||
private String errorCode;
|
||||
private String errorMessage;
|
||||
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package cn.iocoder.muse.module.content.dal.mysql;
|
||||
|
||||
import cn.iocoder.muse.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.muse.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.MuseContentWorkAssetUsePrecheckDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* Content 作品资产使用预检 Mapper(跨空间 handoff 兑现,P3)。
|
||||
*/
|
||||
@Mapper
|
||||
public interface MuseContentWorkAssetUsePrecheckMapper extends BaseMapperX<MuseContentWorkAssetUsePrecheckDO> {
|
||||
|
||||
default MuseContentWorkAssetUsePrecheckDO selectByPrecheckId(String precheckId) {
|
||||
return selectOne(new LambdaQueryWrapperX<MuseContentWorkAssetUsePrecheckDO>()
|
||||
.eq(MuseContentWorkAssetUsePrecheckDO::getPrecheckId, precheckId));
|
||||
}
|
||||
|
||||
}
|
||||
@ -25,4 +25,11 @@ public interface ErrorCodeConstants {
|
||||
ErrorCode CONTENT_DOWNLOAD_CREDENTIAL_INVALID = new ErrorCode(1_041_001_002, "下载凭证无效或已过期");
|
||||
ErrorCode CONTENT_WORK_SCHEMA_INVALID = new ErrorCode(1_041_001_003, "绑定的 Meta Schema 不存在或非 active");
|
||||
|
||||
// ========== Content 作品资产使用(asset_use)与 Market 交接 1-041-002-000 ==========
|
||||
ErrorCode CONTENT_ASSET_USE_PRECHECK_NOT_EXISTS = new ErrorCode(1_041_002_000, "作品资产使用预检不存在");
|
||||
ErrorCode CONTENT_ASSET_USE_PRECHECK_EXPIRED = new ErrorCode(1_041_002_001, "作品资产使用预检已过期或已消费");
|
||||
ErrorCode CONTENT_ASSET_USE_PURPOSE_INVALID = new ErrorCode(1_041_002_002, "作品资产使用用途不支持");
|
||||
ErrorCode CONTENT_ASSET_SOURCE_UNAVAILABLE = new ErrorCode(1_041_002_003, "作品资产来源暂不可用");
|
||||
ErrorCode CONTENT_ASSET_HANDOFF_UNAVAILABLE = new ErrorCode(1_041_002_004, "作品资产 Market handoff 凭据不可用");
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,257 @@
|
||||
package cn.iocoder.muse.module.content.application;
|
||||
|
||||
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.content.controller.app.vo.AppContentAssetUseVO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.MuseContentWorkAssetUsePrecheckDO;
|
||||
import cn.iocoder.muse.module.content.dal.dataobject.WorkDO;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.MuseContentWorkAssetUsePrecheckMapper;
|
||||
import cn.iocoder.muse.module.content.dal.mysql.WorkMapper;
|
||||
import cn.iocoder.muse.module.market.api.handoff.MarketHandoffTokenApi;
|
||||
import cn.iocoder.muse.module.market.api.handoff.dto.HandoffVerifyReqDTO;
|
||||
import cn.iocoder.muse.module.market.api.handoff.dto.HandoffVerifyRespDTO;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
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.module.content.enums.ErrorCodeConstants.CONTENT_ASSET_HANDOFF_UNAVAILABLE;
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_ASSET_USE_PRECHECK_EXPIRED;
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_ASSET_USE_PURPOSE_INVALID;
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_FORBIDDEN;
|
||||
import static cn.iocoder.muse.module.content.enums.ErrorCodeConstants.CONTENT_REVISION_CONFLICT;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
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.when;
|
||||
|
||||
/**
|
||||
* Content 作品资产使用(asset_use)应用服务测试(跨空间 handoff 兑现,P3)。
|
||||
*
|
||||
* <p>覆盖两段式 + token 红线:precheck market 来源经 verify+consume(伪造/缺 token 拒、不放宽);
|
||||
* create 凭 precheckId + work 级乐观锁转 consumed。</p>
|
||||
*/
|
||||
class MuseContentAssetUseServiceTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private MuseContentAssetUseService assetUseService;
|
||||
@Mock
|
||||
private MuseContentWorkAssetUsePrecheckMapper precheckMapper;
|
||||
@Mock
|
||||
private WorkMapper workMapper;
|
||||
@Mock
|
||||
private ContentCommandService commandService;
|
||||
@Mock
|
||||
private MarketHandoffTokenApi marketHandoffTokenApi;
|
||||
|
||||
@AfterEach
|
||||
void clearTenantContext() {
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
// ============ precheck 段:token 红线 ============
|
||||
|
||||
@Test
|
||||
void should_acceptAssetUsePrecheckWhenHandoffTokenVerified() {
|
||||
// 正路:market_asset 来源 + token 经 Market verify 通过 → consume token + 落 active precheck(明文不入库)
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AppContentAssetUseVO.PrecheckReqVO req = marketPrecheckReq("cmd-1", "handoff_ok", "available", List.of("reference"));
|
||||
when(workMapper.selectById(9001L)).thenReturn(work(9001L, 10001L, 3));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash");
|
||||
when(commandService.reserveCommand(any(), any(), any(), any(), any(), any())).thenReturn(null);
|
||||
when(marketHandoffTokenApi.verify(any())).thenReturn(validVerify());
|
||||
|
||||
AppContentAssetUseVO.PrecheckRespVO resp = assetUseService.createAssetUsePrecheck(10001L, "1", 9001L, req);
|
||||
|
||||
assertTrue(resp.getAssetUsePrecheckId().startsWith("content-asset-use-precheck-9001-"));
|
||||
assertEquals(List.of("reference"), resp.getAllowedPurposes());
|
||||
assertEquals("available", resp.getSourceStatus());
|
||||
// verify 走 content/asset_use owner-action;consume 核销;落库 active + handoff_hash 非空(明文不入库)
|
||||
verify(marketHandoffTokenApi).verify(argThat((HandoffVerifyReqDTO v) ->
|
||||
"handoff_ok".equals(v.handoffToken()) && "content".equals(v.expectedTargetOwner())
|
||||
&& "asset_use".equals(v.expectedTargetAction())));
|
||||
verify(marketHandoffTokenApi).consume(any());
|
||||
verify(precheckMapper).insert(argThat((MuseContentWorkAssetUsePrecheckDO p) ->
|
||||
"active".equals(p.getStatus()) && p.getHandoffHash() != null
|
||||
&& !p.getHandoffHash().contains("handoff_ok")
|
||||
&& p.getResultSummary().contains("\"sourceType\":\"market_asset\"")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectAssetUsePrecheckWhenHandoffTokenInvalid() {
|
||||
// 负路:token 被 Market verify 拒 → CONTENT_ASSET_HANDOFF_UNAVAILABLE,不 consume、不落库
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AppContentAssetUseVO.PrecheckReqVO req = marketPrecheckReq("cmd-2", "handoff_forged", "available", List.of("reference"));
|
||||
when(workMapper.selectById(9001L)).thenReturn(work(9001L, 10001L, 3));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash");
|
||||
when(commandService.reserveCommand(any(), any(), any(), any(), any(), any())).thenReturn(null);
|
||||
when(marketHandoffTokenApi.verify(any())).thenReturn(invalidVerify());
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> assetUseService.createAssetUsePrecheck(10001L, "1", 9001L, req));
|
||||
|
||||
assertEquals(CONTENT_ASSET_HANDOFF_UNAVAILABLE.getCode(), ex.getCode());
|
||||
verify(marketHandoffTokenApi, never()).consume(any());
|
||||
verify(precheckMapper, never()).insert(any(MuseContentWorkAssetUsePrecheckDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectAssetUsePrecheckWhenHandoffTokenMissing() {
|
||||
// 负路:market 来源但缺 handoffToken → 拒,不调 verify、不落库
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AppContentAssetUseVO.PrecheckReqVO req = marketPrecheckReq("cmd-3", null, "available", List.of("reference"));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> assetUseService.createAssetUsePrecheck(10001L, "1", 9001L, req));
|
||||
|
||||
assertEquals(CONTENT_ASSET_HANDOFF_UNAVAILABLE.getCode(), ex.getCode());
|
||||
verify(marketHandoffTokenApi, never()).verify(any());
|
||||
verify(precheckMapper, never()).insert(any(MuseContentWorkAssetUsePrecheckDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectAssetUsePrecheckWhenPurposeInvalid() {
|
||||
// 禁 template(市场资产模板化=forbiddenPurpose):非白名单用途 → CONTENT_ASSET_USE_PURPOSE_INVALID,不落库
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AppContentAssetUseVO.PrecheckReqVO req = marketPrecheckReq("cmd-4", "handoff_ok", "available", List.of("template"));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> assetUseService.createAssetUsePrecheck(10001L, "1", 9001L, req));
|
||||
|
||||
assertEquals(CONTENT_ASSET_USE_PURPOSE_INVALID.getCode(), ex.getCode());
|
||||
verify(precheckMapper, never()).insert(any(MuseContentWorkAssetUsePrecheckDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectAssetUsePrecheckWhenWorkOwnerForbidden() {
|
||||
// 越权:作品非当前用户所有 → CONTENT_FORBIDDEN,不核验 token、不落库
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AppContentAssetUseVO.PrecheckReqVO req = marketPrecheckReq("cmd-5", "handoff_ok", "available", List.of("reference"));
|
||||
when(workMapper.selectById(9001L)).thenReturn(work(9001L, 2002L, 3));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> assetUseService.createAssetUsePrecheck(10001L, "1", 9001L, req));
|
||||
|
||||
assertEquals(CONTENT_FORBIDDEN.getCode(), ex.getCode());
|
||||
verify(marketHandoffTokenApi, never()).verify(any());
|
||||
verify(precheckMapper, never()).insert(any(MuseContentWorkAssetUsePrecheckDO.class));
|
||||
}
|
||||
|
||||
// ============ create 段:凭 precheckId + work 级乐观锁 ============
|
||||
|
||||
@Test
|
||||
void should_createAssetUseWhenPrecheckUsable() {
|
||||
// 正路:precheck 可用 + work revision 匹配 → 标 consumed(单表事实落定),assetUseId=precheckId
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AppContentAssetUseVO.CreateReqVO req = createReq("cmd-c1", "precheck-1", 3);
|
||||
when(workMapper.selectById(9001L)).thenReturn(work(9001L, 10001L, 3));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash");
|
||||
when(commandService.reserveCommand(any(), any(), any(), any(), any(), any())).thenReturn(null);
|
||||
when(precheckMapper.selectByPrecheckId("precheck-1")).thenReturn(usablePrecheck("precheck-1", 9001L, 10001L));
|
||||
|
||||
AppContentAssetUseVO.CreateRespVO resp = assetUseService.createAssetUse(10001L, "1", 9001L, req);
|
||||
|
||||
assertEquals("precheck-1", resp.getAssetUseId());
|
||||
assertEquals(List.of("reference"), resp.getPurposes());
|
||||
verify(precheckMapper).updateById(argThat((MuseContentWorkAssetUsePrecheckDO p) ->
|
||||
"consumed".equals(p.getStatus()) && p.getConsumedAt() != null
|
||||
&& Integer.valueOf(3).equals(p.getExpectedWorkRevision())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectAssetUseWhenWorkRevisionConflict() {
|
||||
// work 级乐观锁:expectedWorkRevision != work.revision → CONTENT_REVISION_CONFLICT,不消费 precheck
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AppContentAssetUseVO.CreateReqVO req = createReq("cmd-c2", "precheck-1", 99);
|
||||
when(workMapper.selectById(9001L)).thenReturn(work(9001L, 10001L, 3));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash");
|
||||
when(commandService.reserveCommand(any(), any(), any(), any(), any(), any())).thenReturn(null);
|
||||
when(precheckMapper.selectByPrecheckId("precheck-1")).thenReturn(usablePrecheck("precheck-1", 9001L, 10001L));
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> assetUseService.createAssetUse(10001L, "1", 9001L, req));
|
||||
|
||||
assertEquals(CONTENT_REVISION_CONFLICT.getCode(), ex.getCode());
|
||||
verify(precheckMapper, never()).updateById(any(MuseContentWorkAssetUsePrecheckDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectAssetUseWhenPrecheckExpired() {
|
||||
// precheck 已消费/过期 → CONTENT_ASSET_USE_PRECHECK_EXPIRED,不重复落使用事实
|
||||
TenantContextHolder.setTenantId(100L);
|
||||
AppContentAssetUseVO.CreateReqVO req = createReq("cmd-c3", "precheck-1", 3);
|
||||
when(workMapper.selectById(9001L)).thenReturn(work(9001L, 10001L, 3));
|
||||
when(commandService.buildRequestHash(any())).thenReturn("hash");
|
||||
when(commandService.reserveCommand(any(), any(), any(), any(), any(), any())).thenReturn(null);
|
||||
MuseContentWorkAssetUsePrecheckDO consumed = usablePrecheck("precheck-1", 9001L, 10001L);
|
||||
consumed.setStatus("consumed");
|
||||
consumed.setConsumedAt(LocalDateTime.now().minusMinutes(1));
|
||||
when(precheckMapper.selectByPrecheckId("precheck-1")).thenReturn(consumed);
|
||||
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> assetUseService.createAssetUse(10001L, "1", 9001L, req));
|
||||
|
||||
assertEquals(CONTENT_ASSET_USE_PRECHECK_EXPIRED.getCode(), ex.getCode());
|
||||
verify(precheckMapper, never()).updateById(any(MuseContentWorkAssetUsePrecheckDO.class));
|
||||
}
|
||||
|
||||
// ============ helper ============
|
||||
|
||||
private static AppContentAssetUseVO.PrecheckReqVO marketPrecheckReq(String commandId, String handoffToken,
|
||||
String sourceStatus, List<String> purposes) {
|
||||
AppContentAssetUseVO.PrecheckReqVO req = new AppContentAssetUseVO.PrecheckReqVO();
|
||||
req.setCommandId(commandId);
|
||||
req.setSourceType("market_asset");
|
||||
req.setSourceId("2");
|
||||
req.setSourceVersion(1);
|
||||
req.setSourceStatus(sourceStatus);
|
||||
req.setHandoffToken(handoffToken);
|
||||
req.setAuthorizationSummaryId("45");
|
||||
req.setAuthorizationSnapshotId("snap-1");
|
||||
req.setPurposes(purposes);
|
||||
return req;
|
||||
}
|
||||
|
||||
private static AppContentAssetUseVO.CreateReqVO createReq(String commandId, String precheckId, Integer expectedWorkRevision) {
|
||||
AppContentAssetUseVO.CreateReqVO req = new AppContentAssetUseVO.CreateReqVO();
|
||||
req.setCommandId(commandId);
|
||||
req.setAssetUsePrecheckId(precheckId);
|
||||
req.setExpectedWorkRevision(expectedWorkRevision);
|
||||
return req;
|
||||
}
|
||||
|
||||
private static WorkDO work(Long id, Long ownerUserId, Integer revision) {
|
||||
return WorkDO.builder().id(id).ownerUserId(ownerUserId).revision(revision).build();
|
||||
}
|
||||
|
||||
private static MuseContentWorkAssetUsePrecheckDO usablePrecheck(String precheckId, Long workId, Long ownerUserId) {
|
||||
MuseContentWorkAssetUsePrecheckDO p = new MuseContentWorkAssetUsePrecheckDO();
|
||||
p.setPrecheckId(precheckId);
|
||||
p.setWorkId(workId);
|
||||
p.setOwnerUserId(ownerUserId);
|
||||
p.setStatus("active");
|
||||
p.setExpiresAt(LocalDateTime.now().plusMinutes(5));
|
||||
p.setAuthorizationSnapshotId("snap-1");
|
||||
p.setResultSummary("{\"sourceType\":\"market_asset\",\"sourceId\":\"2\",\"sourceVersion\":1,"
|
||||
+ "\"sourceStatus\":\"available\",\"allowedPurposes\":[\"reference\"]}");
|
||||
return p;
|
||||
}
|
||||
|
||||
private static HandoffVerifyRespDTO validVerify() {
|
||||
return new HandoffVerifyRespDTO(true, "pending", "content", "asset_use", "2", 9001L, 45L,
|
||||
LocalDateTime.now().plusMinutes(10), null);
|
||||
}
|
||||
|
||||
private static HandoffVerifyRespDTO invalidVerify() {
|
||||
return new HandoffVerifyRespDTO(false, null, null, null, null, null, null, null, "token_not_found");
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
-- V28:content asset_use 跨空间 handoff 兑现(P3)——作品使用市场资产作参考/AI 上下文的预检凭证(单表两段式)。
|
||||
-- SSOT 后端-04 L135 定义 muse_content_work_asset_use_precheck(此前 feature disabled、建表 SQL 未写、L1081 仅索引约定);
|
||||
-- 评审版 Q1=单表:precheck 表即事实载体(active→consumed = 作品可用此市场资产作参考/AI 上下文凭证),
|
||||
-- AI 接受候选时引用 precheck_id(后端-04 L849 muse_ai_candidate_decision_archive.work_asset_use_precheck_id)。
|
||||
-- 形态照 muse_knowledge_bind_precheck(V14):kb_id→source_asset_id + source_asset_type;全列向后兼容(全新表)。
|
||||
CREATE TABLE muse_content_work_asset_use_precheck (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
precheck_id VARCHAR(128) NOT NULL,
|
||||
command_id VARCHAR(128) NOT NULL,
|
||||
request_hash CHAR(64) NOT NULL,
|
||||
work_id BIGINT NOT NULL,
|
||||
source_asset_id BIGINT NOT NULL,
|
||||
source_asset_type VARCHAR(32) NOT NULL DEFAULT 'market_asset',
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
actor_user_id BIGINT NOT NULL,
|
||||
expected_work_revision INT,
|
||||
source_snapshot_id VARCHAR(128) NOT NULL,
|
||||
authorization_snapshot_id VARCHAR(128) NOT NULL,
|
||||
handoff_hash CHAR(64),
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
consumed_at TIMESTAMP,
|
||||
result_summary JSONB NOT NULL DEFAULT '{}'::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_content_asset_use_precheck_id UNIQUE (tenant_id, precheck_id),
|
||||
CONSTRAINT uk_muse_content_asset_use_precheck_command UNIQUE (tenant_id, command_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_muse_content_asset_use_precheck_owner_status
|
||||
ON muse_content_work_asset_use_precheck(tenant_id, owner_user_id, status, create_time);
|
||||
CREATE INDEX idx_muse_content_asset_use_precheck_work_asset
|
||||
ON muse_content_work_asset_use_precheck(tenant_id, work_id, source_asset_id, status);
|
||||
CREATE INDEX idx_muse_content_asset_use_precheck_active
|
||||
ON muse_content_work_asset_use_precheck(tenant_id, precheck_id, expires_at)
|
||||
WHERE consumed_at IS NULL AND deleted = FALSE;
|
||||
CREATE TRIGGER trg_muse_content_asset_use_precheck_updated_at
|
||||
BEFORE UPDATE ON muse_content_work_asset_use_precheck
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
Loading…
x
Reference in New Issue
Block a user