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:
lili 2026-06-22 22:51:59 -07:00
parent 1a11bbde7a
commit d113f4ee15
9 changed files with 845 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 凭据不可用");
}

View File

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

View File

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