feat(studio): U6 R-MAT 素材实体/浏览/选用/上传(compose infra FileApi)
R-MAT 创作者私有素材库(六类 sprite/character/effect/scene/ui/music,冻结同枚举):
- StudioMaterialDO + game_material 表(V24.0.0,归属 creator_user_id,idx_owner_cat)
- 3 端点 /app-api/studio/asset/{upload,browse,select}(studio 领域端点,需登录用户)
- upload:六类校验 + 大小≤10MB + 每类目 MIME 白名单 → composes huijing-module-infra
FileApi.createFile 存字节 → provider 包装 ref → 落库 → 返回冻结 StudioAssetContextItem
- browse:owner-isolated 分页(强制 eq creator_user_id),可选 category 过滤
- select-into-draft:把本人素材 ref 写入草稿会话 assetContext(JsonUtils 序列化,按 ref 去重合并)
- MaterialProvider SPI + 工厂 + mmx-cli mock(graceful degrade,仿 AdProviderFactory)
- 错误码段 1-112-005-***(七个);studio.yaml 加 3 端点 + schemas(additive)
验证:mvn -pl game-module-studio-server,-api -am test 全绿
(StudioMaterialServiceImplTest 17/17,studio 模块 61/61)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e378f3c662
commit
09ced58930
@ -188,6 +188,73 @@ paths:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultStudioTaskChain' }
|
||||
|
||||
# ===========================================================================
|
||||
# 素材库 R-MAT(U6 | 六类素材 浏览/选用/上传,additive)
|
||||
# 归属:studio 拥有领域端点,上传 composes huijing-module-infra FileApi 存字节(不重造上传,不把领域逻辑放 infra)。
|
||||
# 六类冻结:sprite/character/effect/scene/ui/music(与 StudioAssetContextItem + source-project.schema.json 同引同枚举)。
|
||||
# 鉴权:均需用户 Token(不可 @PermitAll);归属隔离在 Service 强制(创作者只见/只用自己的素材,前端不是边界)。
|
||||
# 上传红线:单文件 ≤10MB + 每类目 MIME 白名单(image/* 给图类、audio/* 给 music);非法类目/超限/MIME 不匹配前置拒(1-112-005-***)。
|
||||
# ===========================================================================
|
||||
/app-api/studio/asset/upload:
|
||||
post:
|
||||
tags: [app-studio]
|
||||
summary: 上传素材(R-MAT;composes infra FileApi 存字节)
|
||||
description: >-
|
||||
编排:六类校验 → 大小(≤10MB)/MIME(类目白名单)红线 → 读 MultipartFile 字节 → 委托 huijing-module-infra FileApi.createFile
|
||||
存储拿访问路径 → provider 包装为 ref → 落 game_material(归属本人) → 返回输入态六类引用项(直接喂 assetContext.ref)。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file, category]
|
||||
properties:
|
||||
file: { type: string, format: binary, description: '素材文件附件' }
|
||||
category: { type: string, enum: [sprite, character, effect, scene, ui, music], description: '六类素材类目' }
|
||||
directory: { type: string, description: '存储目录(可空,透传 infra FileApi)', example: 'material/sprite' }
|
||||
responses:
|
||||
'200':
|
||||
description: 成功(素材引用项,喂 assetContext)
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultStudioAssetContextItem' }
|
||||
|
||||
/app-api/studio/asset/browse:
|
||||
get:
|
||||
tags: [app-studio]
|
||||
summary: 浏览素材(R-MAT;owner-isolated 分页)
|
||||
description: 创作者只见自己的素材(服务端按当前登录用户强制过滤);可选按 category 过滤六类。按 id 倒序(新上传在前)。
|
||||
parameters:
|
||||
- { name: pageNo, in: query, required: true, schema: { type: integer, default: 1 }, description: '页码,从 1 开始' }
|
||||
- { name: pageSize, in: query, required: true, schema: { type: integer, default: 10 }, description: '每页条数,最大 200' }
|
||||
- { name: category, in: query, required: false, schema: { type: string, enum: [sprite, character, effect, scene, ui, music] }, description: '按类目过滤(可空=全部六类)' }
|
||||
responses:
|
||||
'200':
|
||||
description: 成功(本人素材分页)
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultStudioMaterialPage' }
|
||||
|
||||
/app-api/studio/asset/select:
|
||||
post:
|
||||
tags: [app-studio]
|
||||
summary: 选用素材入草稿(R-MAT;select-into-draft)
|
||||
description: >-
|
||||
把选中素材(按 materialIds,本人素材库)写入目标草稿会话的 assetContext(取代旧 attachments)。归属校验本人会话 + 本人素材;
|
||||
与既有 assetContext 按 ref 去重合并。C3 create/modify/extend 已直接接受 assetContext 输入,本端点是「选好→落草稿」的便利产出。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/StudioMaterialSelectReqVO' }
|
||||
responses:
|
||||
'200':
|
||||
description: 成功(写入后草稿的完整 assetContext)
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/CommonResultStudioAssetContextItemList' }
|
||||
|
||||
components:
|
||||
schemas:
|
||||
# ========== 请求 VO ==========
|
||||
@ -274,7 +341,30 @@ components:
|
||||
url: { type: string, format: uri, description: '只读镜像 URL(可空)' }
|
||||
provider: { type: string, description: '素材来源 provider(可空,默认 mmx-cli;切 provider 不动消费侧,§5.5)', example: 'mmx-cli' }
|
||||
|
||||
# ---- 素材库选用入草稿 请求 VO(R-MAT,U6)----
|
||||
StudioMaterialSelectReqVO:
|
||||
type: object
|
||||
required: [sessionId, materialIds]
|
||||
description: 选用素材入草稿(把本人素材库选中项写入草稿会话 assetContext,取代旧 attachments)
|
||||
properties:
|
||||
sessionId: { type: integer, format: int64, description: '目标草稿会话 ID(写其 assetContext)', example: 512 }
|
||||
materialIds: { type: array, items: { type: integer, format: int64 }, description: '选用的素材 ID 列表(本人素材库)', example: [88231, 88232] }
|
||||
|
||||
# ========== 响应 VO ==========
|
||||
# ---- 素材库浏览项 响应 VO(R-MAT,U6;比输入态 StudioAssetContextItem 多 id + 展示元数据)----
|
||||
StudioMaterialRespVO:
|
||||
type: object
|
||||
description: 素材浏览项(含 id 供选用 + 六类引用四元组 + 名称/大小/MIME/上传时间)
|
||||
properties:
|
||||
id: { type: integer, format: int64, description: '素材 ID(选用入参 materialIds)', example: 88231 }
|
||||
category: { type: string, enum: [sprite, character, effect, scene, ui, music], description: '六类素材类目' }
|
||||
ref: { type: string, description: '平台素材引用 ref(喂 assetContext.ref)', example: 'asset_88231' }
|
||||
url: { type: string, format: uri, description: '素材访问 URL(只读镜像,可空)' }
|
||||
provider: { type: string, description: '素材来源 provider(缺省 mmx-cli)', example: 'mmx-cli' }
|
||||
name: { type: string, description: '素材名称(展示用)', example: '陨石.png' }
|
||||
sizeBytes: { type: integer, format: int64, description: '文件大小(字节)', example: 20480 }
|
||||
mimeType: { type: string, description: '文件 MIME 类型', example: 'image/png' }
|
||||
createTime: { type: string, format: date-time, description: '上传时间' }
|
||||
StudioTemplateRespVO:
|
||||
type: object
|
||||
description: 玩法模板(透传 aigc 模板注册表,对齐 aigc.yaml TemplateRespVO)
|
||||
@ -327,3 +417,29 @@ components:
|
||||
code: { type: integer, example: 0 }
|
||||
data: { $ref: '#/components/schemas/StudioTaskChainRespVO' }
|
||||
msg: { type: string }
|
||||
# ---- 素材库 R-MAT 信封(U6)----
|
||||
CommonResultStudioAssetContextItem:
|
||||
type: object
|
||||
description: 上传素材返回(单条六类引用项,喂 assetContext)
|
||||
properties:
|
||||
code: { type: integer, example: 0 }
|
||||
data: { $ref: '#/components/schemas/StudioAssetContextItem' }
|
||||
msg: { type: string }
|
||||
CommonResultStudioAssetContextItemList:
|
||||
type: object
|
||||
description: 选用入草稿返回(写入后草稿的完整 assetContext 列表)
|
||||
properties:
|
||||
code: { type: integer, example: 0 }
|
||||
data: { type: array, items: { $ref: '#/components/schemas/StudioAssetContextItem' } }
|
||||
msg: { type: string }
|
||||
CommonResultStudioMaterialPage:
|
||||
type: object
|
||||
description: 素材浏览分页返回(owner-isolated)
|
||||
properties:
|
||||
code: { type: integer, example: 0 }
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
list: { type: array, items: { $ref: '#/components/schemas/StudioMaterialRespVO' } }
|
||||
total: { type: integer, format: int64, example: 42 }
|
||||
msg: { type: string }
|
||||
|
||||
40
contracts/db-schemas/V24.0.0__create_game_material.sql
Normal file
40
contracts/db-schemas/V24.0.0__create_game_material.sql
Normal file
@ -0,0 +1,40 @@
|
||||
-- =============================================================================
|
||||
-- 契约 #2 DB 迁移 | 模块:studio(game-module-studio,U6 R-MAT 素材库)| owner:studio 路
|
||||
-- 文件:V24.0.0__create_game_material.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移 V24.0.1 DROP TABLE)
|
||||
-- 内容:创作者私有素材库表 game_material(六类素材 浏览/选用/上传)
|
||||
-- 职责(U6 R-MAT):创作者维度可复用素材实体。上传时字节由 huijing-module-infra FileApi 存储,本表只登记 ref/url/元数据。
|
||||
-- 边界纪律:
|
||||
-- - 与 game_studio_asset(会话内单步整包槽位)是两个概念:本表=创作者素材库(六类,归属隔离),不混用。
|
||||
-- - 字节存储权威归 infra(FileApi);本表只持 ref/url 引用,不在 studio 重造文件存储。
|
||||
-- - 六类 category 冻结(sprite/character/effect/scene/ui/music),与 studio.yaml StudioAssetContextItem +
|
||||
-- source-project.schema.json assetSpec.category 四处同引同一枚举(MaterialCategoryEnum)。
|
||||
-- 约定:InnoDB + utf8mb4;显式列;含 Yudao 审计列 creator/create_time/updater/update_time/deleted + tenant_id;
|
||||
-- 归属隔离用 creator_user_id(业务字段,区别于审计列 creator);中文列注释;关键查询建索引、禁裸 select*。
|
||||
-- 上传红线:单文件 ≤10MB + 每类目 MIME 白名单(在 Service 可信边界校验,DB 仅登记 size_bytes/mime_type 审计)。
|
||||
-- 错误码段:studio = 1-112-***-***(素材库 005 段)
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表:game_material —— 创作素材库表(R-MAT;创作者私有六类素材,归属隔离浏览/选用/上传)
|
||||
-- 用途:上传素材(infra 存字节)后登记一行;浏览按 creator_user_id 隔离分页;选用把 ref 写入草稿 assetContext。
|
||||
-- 引用边界:ref/url 引用 infra 存储的访问路径(MVP ref=url 同源,自描述可寻址);P1 ref 可换 mmx-cli assetId。
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_material` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '素材 ID',
|
||||
`creator_user_id` BIGINT NOT NULL COMMENT '创作者用户 ID(业务字段;归属隔离:创作者只见/只用自己的素材。区别于审计列 creator)',
|
||||
`category` VARCHAR(16) NOT NULL COMMENT '素材类目(六类:sprite图元/character角色/effect特效/scene场景/ui界面/music音乐;MaterialCategoryEnum)',
|
||||
`name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '素材名称(上传文件原名兜底,浏览展示用)',
|
||||
`ref` VARCHAR(512) NOT NULL COMMENT '平台素材引用 ref(喂 assetContext.ref);MVP=infra 访问路径,P1 可换 mmx-cli assetId',
|
||||
`url` VARCHAR(512) NULL DEFAULT NULL COMMENT '素材访问 URL(只读镜像,可空);MVP=infra FileApi 返回路径',
|
||||
`provider` VARCHAR(32) NOT NULL DEFAULT 'mmx-cli' COMMENT '素材来源 provider(缺省 mmx-cli;切 provider 不动消费侧,§5.5)',
|
||||
`size_bytes` BIGINT NULL DEFAULT NULL COMMENT '文件大小(字节),上传记录供配额/红线审计',
|
||||
`mime_type` VARCHAR(128) NULL DEFAULT NULL COMMENT '文件 MIME 类型(如 image/png、audio/mpeg)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_owner_cat` (`creator_user_id`, `category`, `id`) COMMENT '创作者素材浏览(归属隔离 + 按类目过滤 + 按 id 倒序)'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作素材库表(R-MAT;创作者私有六类素材,归属隔离浏览/选用/上传)';
|
||||
@ -46,4 +46,20 @@ public interface ErrorCodeConstants {
|
||||
* 注:status 流转方法(markBuilt/markOrphan)对「源行不存在/非法流转」返 false 不抛(best-effort 由调用方决断),故不另设错误码。 */
|
||||
ErrorCode STUDIO_SOURCE_PROJECT_INVALID = new ErrorCode(1_112_004_000, "源项目落库入参非法(gameId 缺或 sourceJson 空)");
|
||||
|
||||
// ========== 素材库 R-MAT 1-112-005-***(U6 素材实体/浏览/选用/上传)==========
|
||||
/** 上传/浏览:素材类目非法(仅 sprite/character/effect/scene/ui/music 六类,前置拒)。 */
|
||||
ErrorCode STUDIO_MATERIAL_CATEGORY_INVALID = new ErrorCode(1_112_005_000, "素材类目非法(仅 sprite/character/effect/scene/ui/music)");
|
||||
/** 上传:素材文件为空(MultipartFile 缺或零字节,前置拒)。 */
|
||||
ErrorCode STUDIO_MATERIAL_FILE_EMPTY = new ErrorCode(1_112_005_001, "上传文件不能为空");
|
||||
/** 上传:素材文件超过单文件大小上限(平台红线 ≤10MB,前置拒)。 */
|
||||
ErrorCode STUDIO_MATERIAL_FILE_TOO_LARGE = new ErrorCode(1_112_005_002, "上传文件超过大小上限(单文件 ≤10MB)");
|
||||
/** 上传:素材 MIME 类型不在该类目白名单内(前置拒,挡非法/伪装文件)。 */
|
||||
ErrorCode STUDIO_MATERIAL_MIME_NOT_ALLOWED = new ErrorCode(1_112_005_003, "文件类型与素材类目不匹配(MIME 不在白名单)");
|
||||
/** 上传:底层文件存储失败(composes infra FileApi 异常:超时/返回空路径)。 */
|
||||
ErrorCode STUDIO_MATERIAL_STORE_FAIL = new ErrorCode(1_112_005_004, "素材文件存储失败(文件服务异常)");
|
||||
/** 选用:素材不存在或非本人所有(按 id 查不到 / creator_user_id 不匹配,归属可信边界拒)。 */
|
||||
ErrorCode STUDIO_MATERIAL_NOT_EXISTS = new ErrorCode(1_112_005_005, "素材不存在或无权使用");
|
||||
/** 选用:草稿会话不存在或非本人所有(select-into-draft 写 assetContext 前置归属校验)。 */
|
||||
ErrorCode STUDIO_MATERIAL_DRAFT_NOT_OWNER = new ErrorCode(1_112_005_006, "草稿会话不存在或无权操作");
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
package com.wanxiang.huijing.game.module.studio.enums;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 六类素材类目枚举(R-MAT,冻结口径,四处同引同一枚举)
|
||||
*
|
||||
* 与契约对齐(不得增减/改名):
|
||||
* - contracts/api-schemas/studio.yaml StudioAssetContextItem.category / StudioMaterialUploadReqVO.category;
|
||||
* - contracts/agent-loop/source-project.schema.json assetSpec.category;
|
||||
* - controller/app/studio/vo/StudioAssetContextItem.category(输入态引用项)。
|
||||
* 每类自带 MIME 前缀白名单(上传安全红线:挡非法/伪装文件)。校验在 Service 可信边界(前端不是边界)。
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
public enum MaterialCategoryEnum {
|
||||
|
||||
/** 图元(精灵图/帧图,位图) */
|
||||
SPRITE("sprite", Set.of("image/")),
|
||||
/** 角色(位图) */
|
||||
CHARACTER("character", Set.of("image/")),
|
||||
/** 特效(位图/序列帧) */
|
||||
EFFECT("effect", Set.of("image/")),
|
||||
/** 场景(背景图,位图) */
|
||||
SCENE("scene", Set.of("image/")),
|
||||
/** 界面(UI 元素,位图) */
|
||||
UI("ui", Set.of("image/")),
|
||||
/** 音乐/音效(音频) */
|
||||
MUSIC("music", Set.of("audio/"));
|
||||
|
||||
/** 类目标识(落库 + 契约枚举值,禁改) */
|
||||
private final String category;
|
||||
/**
|
||||
* 允许的 MIME 前缀白名单(如 image/ 匹配 image/png、image/jpeg)。
|
||||
* 用前缀匹配而非全等:覆盖同族多子类型,且不依赖前端精确上报子类型。
|
||||
*/
|
||||
private final Set<String> allowedMimePrefixes;
|
||||
|
||||
MaterialCategoryEnum(String category, Set<String> allowedMimePrefixes) {
|
||||
this.category = category;
|
||||
this.allowedMimePrefixes = allowedMimePrefixes;
|
||||
}
|
||||
|
||||
public String getCategory() {
|
||||
return category;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类目标识解析枚举;非六类返回 null(调用方据此抛 STUDIO_MATERIAL_CATEGORY_INVALID)
|
||||
*
|
||||
* @param category 类目标识
|
||||
* @return 枚举;非法/空返回 null
|
||||
*/
|
||||
public static MaterialCategoryEnum of(String category) {
|
||||
if (category == null) {
|
||||
return null;
|
||||
}
|
||||
return Arrays.stream(values())
|
||||
.filter(e -> e.category.equals(category))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验类目是否合法(六类之一)
|
||||
*
|
||||
* @param category 类目标识
|
||||
* @return true=合法
|
||||
*/
|
||||
public static boolean isValid(String category) {
|
||||
return of(category) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 MIME 是否命中本类目白名单(前缀匹配,挡类型伪装)
|
||||
*
|
||||
* @param mimeType 文件 MIME(来自 MultipartFile.getContentType,可空)
|
||||
* @return true=允许;mime 为空或不命中任一前缀=拒
|
||||
*/
|
||||
public boolean isMimeAllowed(String mimeType) {
|
||||
if (mimeType == null || mimeType.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String lower = mimeType.toLowerCase();
|
||||
return allowedMimePrefixes.stream().anyMatch(lower::startsWith);
|
||||
}
|
||||
|
||||
}
|
||||
@ -53,6 +53,13 @@
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 依赖 infra 的 -api:R-MAT 素材上传 composes FileApi 存字节(不重造上传;跨模块只依赖对方 -api,禁依赖 -server) -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
<artifactId>huijing-module-infra-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 业务组件:数据权限 + 多租户(DO 继承 TenantBaseDO) -->
|
||||
<dependency>
|
||||
<groupId>com.wanxiang</groupId>
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
package com.wanxiang.huijing.game.module.studio.controller.app.studio;
|
||||
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioAssetContextItem;
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialRespVO;
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialSelectReqVO;
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialUploadReqVO;
|
||||
import com.wanxiang.huijing.game.module.studio.convert.studio.StudioMaterialConvert;
|
||||
import com.wanxiang.huijing.game.module.studio.dal.dataobject.studio.StudioMaterialDO;
|
||||
import com.wanxiang.huijing.game.module.studio.service.studio.StudioMaterialService;
|
||||
import com.wanxiang.huijing.framework.common.pojo.CommonResult;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import com.wanxiang.huijing.framework.common.util.collection.CollectionUtils;
|
||||
import com.wanxiang.huijing.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
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 java.util.List;
|
||||
|
||||
import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 产品端(game-studio)- 创作素材库控制器(R-MAT 六类素材 浏览/选用/上传)
|
||||
*
|
||||
* 端前缀 /app-api 由框架按包名 controller.app.* 自动添加。鉴权:所有端点需用户 Token(不可 @PermitAll)——
|
||||
* 归属隔离在 Service 强制(创作者只见/只用自己的素材),前端不是边界。
|
||||
* 上传 composes huijing-module-infra FileApi 存字节(不重造上传)。
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
@Tag(name = "用户 App - 创作素材库")
|
||||
@RestController
|
||||
@RequestMapping("/studio/asset")
|
||||
@Validated
|
||||
public class AppStudioMaterialController {
|
||||
|
||||
@Resource
|
||||
private StudioMaterialService studioMaterialService;
|
||||
|
||||
@PostMapping("/upload")
|
||||
@Operation(summary = "上传素材", description = "六类校验 + 大小/MIME 红线 → composes infra FileApi 存字节 → 登记素材,返回六类引用项(喂 assetContext)")
|
||||
@Parameter(name = "file", description = "素材文件附件", required = true,
|
||||
schema = @Schema(type = "string", format = "binary"))
|
||||
public CommonResult<StudioAssetContextItem> upload(@Valid StudioMaterialUploadReqVO reqVO) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
return success(studioMaterialService.upload(reqVO, userId));
|
||||
}
|
||||
|
||||
@GetMapping("/browse")
|
||||
@Operation(summary = "浏览素材", description = "owner-isolated 分页(只见本人素材),可选按 category 过滤六类")
|
||||
public CommonResult<PageResult<StudioMaterialRespVO>> browse(@Valid StudioMaterialPageReqVO reqVO) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
PageResult<StudioMaterialDO> page = studioMaterialService.browse(reqVO, userId);
|
||||
// DO 分页 → VO 分页(显式字段搬运,含 id 供选用)
|
||||
return success(new PageResult<>(
|
||||
CollectionUtils.convertList(page.getList(), StudioMaterialConvert::toRespVO),
|
||||
page.getTotal()));
|
||||
}
|
||||
|
||||
@PostMapping("/select")
|
||||
@Operation(summary = "选用素材入草稿", description = "把选中素材写入草稿会话 assetContext(取代旧 attachments);归属校验本人会话 + 本人素材")
|
||||
public CommonResult<List<StudioAssetContextItem>> select(@Valid @RequestBody StudioMaterialSelectReqVO reqVO) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
return success(studioMaterialService.selectIntoDraft(reqVO, userId));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.wanxiang.huijing.game.module.studio.controller.app.studio.vo;
|
||||
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageParam;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 素材浏览分页 Request VO(R-MAT 浏览入口)
|
||||
*
|
||||
* 端点 GET /app-api/studio/asset/browse:继承 PageParam(pageNo/pageSize)+ 可选 category 过滤。
|
||||
* 归属隔离不在本 VO 承载——由 Service 用当前登录用户强制注入 mapper(前端不是边界,不接受前端传 creatorUserId)。
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
@Schema(description = "产品端 - 素材浏览分页 Request VO")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class StudioMaterialPageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "按类目过滤(可空,不传=全部六类);sprite/character/effect/scene/ui/music", example = "sprite")
|
||||
private String category;
|
||||
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package com.wanxiang.huijing.game.module.studio.controller.app.studio.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 素材浏览响应 VO(R-MAT 浏览项;比输入态 StudioAssetContextItem 多展示用元数据)
|
||||
*
|
||||
* 浏览返回素材库列表项,含 id(选用入参)+ 六类引用四元组(category/ref/url/provider)+ 名称/大小/MIME/上传时间。
|
||||
* 注:上传端点返回的是冻结的 {@link StudioAssetContextItem}(直接喂 assetContext);浏览返回本 VO(含 id 供选用)。
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
@Schema(description = "产品端 - 素材浏览项")
|
||||
@Data
|
||||
public class StudioMaterialRespVO {
|
||||
|
||||
@Schema(description = "素材 ID(选用入参 materialIds)", example = "88231")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "六类:sprite/character/effect/scene/ui/music", example = "sprite")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "平台素材引用 ref(喂 assetContext.ref)", example = "asset_88231")
|
||||
private String ref;
|
||||
|
||||
@Schema(description = "素材访问 URL(只读镜像,可空)")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "素材来源 provider(缺省 mmx-cli)", example = "mmx-cli")
|
||||
private String provider;
|
||||
|
||||
@Schema(description = "素材名称(展示用)", example = "陨石.png")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "文件大小(字节)", example = "20480")
|
||||
private Long sizeBytes;
|
||||
|
||||
@Schema(description = "文件 MIME 类型", example = "image/png")
|
||||
private String mimeType;
|
||||
|
||||
@Schema(description = "上传时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package com.wanxiang.huijing.game.module.studio.controller.app.studio.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 素材选用入草稿 Request VO(R-MAT select-into-draft;便利生产者)
|
||||
*
|
||||
* 端点 POST /app-api/studio/asset/select:把已选素材(按 materialIds)写入指定草稿会话的 assetContext
|
||||
* (= session.attachments JSON,取代旧 attachments)。C3 create/modify/extend 已直接接受 assetContext 输入,
|
||||
* 本端点是「从素材库选好 → 落到草稿」的便利产出(保证 assetContext.ref 有产出者)。
|
||||
* 归属:sessionId 与 materialIds 均按当前登录用户校验归属(前端不是边界)。
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
@Schema(description = "产品端 - 素材选用入草稿 Request VO")
|
||||
@Data
|
||||
public class StudioMaterialSelectReqVO {
|
||||
|
||||
@Schema(description = "目标草稿会话 ID(写其 assetContext)", requiredMode = Schema.RequiredMode.REQUIRED, example = "512")
|
||||
@NotNull(message = "草稿会话 ID 不能为空")
|
||||
private Long sessionId;
|
||||
|
||||
@Schema(description = "选用的素材 ID 列表(本人素材库)", requiredMode = Schema.RequiredMode.REQUIRED, example = "[88231, 88232]")
|
||||
@NotEmpty(message = "选用素材列表不能为空")
|
||||
private List<Long> materialIds;
|
||||
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.wanxiang.huijing.game.module.studio.controller.app.studio.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* 素材上传 Request VO(multipart/form-data;R-MAT 上传入口)
|
||||
*
|
||||
* 端点 POST /app-api/studio/asset/upload:multipart 携带 file + category(+ 可空 directory)。
|
||||
* 类目六类校验、大小/MIME 红线校验在 Service 可信边界(前端不是边界)。
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
@Schema(description = "产品端 - 素材上传 Request VO(multipart)")
|
||||
@Data
|
||||
public class StudioMaterialUploadReqVO {
|
||||
|
||||
@Schema(description = "素材文件附件", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotNull(message = "上传文件不能为空")
|
||||
private MultipartFile file;
|
||||
|
||||
@Schema(description = "六类:sprite图元/character角色/effect特效/scene场景/ui界面/music音乐",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED, example = "sprite")
|
||||
@NotBlank(message = "素材类目不能为空")
|
||||
private String category;
|
||||
|
||||
@Schema(description = "存储目录(可空,透传 infra FileApi)", example = "material/sprite")
|
||||
private String directory;
|
||||
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package com.wanxiang.huijing.game.module.studio.convert.studio;
|
||||
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioAssetContextItem;
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialRespVO;
|
||||
import com.wanxiang.huijing.game.module.studio.dal.dataobject.studio.StudioMaterialDO;
|
||||
|
||||
/**
|
||||
* 素材 DO ↔ VO 转换器(纯静态方法,仿 StudioConvert 范式;只搬字段不写业务)
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
public class StudioMaterialConvert {
|
||||
|
||||
/**
|
||||
* 素材 DO → 浏览项 VO(含 id + 元数据)
|
||||
*
|
||||
* @param material 素材 DO
|
||||
* @return 浏览项 VO;入参 null 返回 null
|
||||
*/
|
||||
public static StudioMaterialRespVO toRespVO(StudioMaterialDO material) {
|
||||
if (material == null) {
|
||||
return null;
|
||||
}
|
||||
StudioMaterialRespVO vo = new StudioMaterialRespVO();
|
||||
vo.setId(material.getId());
|
||||
vo.setCategory(material.getCategory());
|
||||
vo.setRef(material.getRef());
|
||||
vo.setUrl(material.getUrl());
|
||||
vo.setProvider(material.getProvider());
|
||||
vo.setName(material.getName());
|
||||
vo.setSizeBytes(material.getSizeBytes());
|
||||
vo.setMimeType(material.getMimeType());
|
||||
vo.setCreateTime(material.getCreateTime());
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材 DO → 六类素材引用项(输入态,直接喂 assetContext.ref;上传端点返回 + select 写入草稿用)
|
||||
*
|
||||
* @param material 素材 DO
|
||||
* @return 引用项;入参 null 返回 null
|
||||
*/
|
||||
public static StudioAssetContextItem toAssetContextItem(StudioMaterialDO material) {
|
||||
if (material == null) {
|
||||
return null;
|
||||
}
|
||||
StudioAssetContextItem item = new StudioAssetContextItem();
|
||||
item.setCategory(material.getCategory());
|
||||
item.setRef(material.getRef());
|
||||
item.setUrl(material.getUrl());
|
||||
item.setProvider(material.getProvider());
|
||||
return item;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
package com.wanxiang.huijing.game.module.studio.dal.dataobject.studio;
|
||||
|
||||
import com.wanxiang.huijing.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 创作素材 DO(对应表 game_material;R-MAT 创作者私有素材库实体)
|
||||
*
|
||||
* 继承 {@link TenantBaseDO}:自动携带审计/租户列。
|
||||
* 与会话内「资产槽位」{@link StudioAssetDO}(表 game_studio_asset,单步整包槽位)是两个概念:
|
||||
* - 本类 = 创作者维度的可复用素材库(六类,归属隔离,浏览/选用/上传);
|
||||
* - StudioAssetDO = 单次会话生成产物的槽位登记。二者不混用。
|
||||
* 边界:上传时素材字节由 huijing-module-infra FileApi 存储(不在 studio 重造存储),本类只登记 ref/url/元数据。
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
@TableName("game_material")
|
||||
@KeySequence("game_material_seq") // Oracle/PostgreSQL 等主键自增用;MySQL 可忽略
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class StudioMaterialDO extends TenantBaseDO {
|
||||
|
||||
/**
|
||||
* 素材 ID
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 创作者用户 ID(业务字段;归属隔离:创作者只见/只用自己的素材。区别于审计列 creator)
|
||||
*/
|
||||
private Long creatorUserId;
|
||||
/**
|
||||
* 素材类目(六类之一:sprite/character/effect/scene/ui/music,{@link com.wanxiang.huijing.game.module.studio.enums.MaterialCategoryEnum})
|
||||
*/
|
||||
private String category;
|
||||
/**
|
||||
* 素材名称(上传文件原名兜底,浏览展示用)
|
||||
*/
|
||||
private String name;
|
||||
/**
|
||||
* 平台素材引用 ref(主键引用,喂 assetContext.ref);MVP=infra 存储返回的访问路径(与 url 同源),P1 真 mmx-cli assetId
|
||||
*/
|
||||
private String ref;
|
||||
/**
|
||||
* 素材访问 URL(只读镜像,可空);MVP=infra FileApi.createFile 返回路径
|
||||
*/
|
||||
private String url;
|
||||
/**
|
||||
* 素材来源 provider(mmx-cli/上传等,缺省 mmx-cli;切 provider 不动消费侧,§5.5)
|
||||
*/
|
||||
private String provider;
|
||||
/**
|
||||
* 文件大小(字节),上传时记录,供配额/红线校验审计
|
||||
*/
|
||||
private Long sizeBytes;
|
||||
/**
|
||||
* 文件 MIME 类型(如 image/png、audio/mpeg)
|
||||
*/
|
||||
private String mimeType;
|
||||
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package com.wanxiang.huijing.game.module.studio.dal.mysql.studio;
|
||||
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.studio.dal.dataobject.studio.StudioMaterialDO;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import com.wanxiang.huijing.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import com.wanxiang.huijing.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 创作素材 Mapper(R-MAT)
|
||||
*
|
||||
* 显式列查询(不裸 select*)。归属隔离硬约束:浏览分页强制 eq creator_user_id(创作者只见自己的素材,
|
||||
* 服务端可信边界,前端不是边界);可选按 category 过滤。命中 idx_owner_cat(creator_user_id, category)。
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
@Mapper
|
||||
public interface StudioMaterialMapper extends BaseMapperX<StudioMaterialDO> {
|
||||
|
||||
/**
|
||||
* 素材浏览分页(owner-isolated,命中 idx_owner_cat)
|
||||
*
|
||||
* <p>归属隔离:creatorUserId 由 Service 用当前登录用户强制注入,eq 硬过滤(非 eqIfPresent),
|
||||
* 保证不会因调用方漏传而返回他人素材;category 可空(不传=该创作者全部类目)。按 id 倒序(新上传在前)。
|
||||
*
|
||||
* @param creatorUserId 当前登录创作者 ID(归属边界,必填)
|
||||
* @param reqVO 分页 + 可选 category 过滤
|
||||
* @return 分页结果(仅本人素材)
|
||||
*/
|
||||
default PageResult<StudioMaterialDO> selectPageByOwner(Long creatorUserId, StudioMaterialPageReqVO reqVO) {
|
||||
return selectPage(reqVO, new LambdaQueryWrapperX<StudioMaterialDO>()
|
||||
.eq(StudioMaterialDO::getCreatorUserId, creatorUserId) // 归属硬隔离:强制 eq,不可缺省
|
||||
.eqIfPresent(StudioMaterialDO::getCategory, reqVO.getCategory())
|
||||
.orderByDesc(StudioMaterialDO::getId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 id + 归属取素材(选用前归属校验:创作者只能用自己的素材);命中主键 + eq creator_user_id
|
||||
*
|
||||
* @param id 素材 ID
|
||||
* @param creatorUserId 当前登录创作者 ID
|
||||
* @return 素材 DO;不存在或非本人=null
|
||||
*/
|
||||
default StudioMaterialDO selectByIdAndOwner(Long id, Long creatorUserId) {
|
||||
return selectOne(new LambdaQueryWrapperX<StudioMaterialDO>()
|
||||
.eq(StudioMaterialDO::getId, id)
|
||||
.eq(StudioMaterialDO::getCreatorUserId, creatorUserId));
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package com.wanxiang.huijing.game.module.studio.framework.provider;
|
||||
|
||||
/**
|
||||
* 素材 Provider SPI(R-MAT):统一形态屏蔽 mmx-cli / 上传 等素材来源差异,不绑死单一来源
|
||||
*
|
||||
* 仿 ad 模块 AdProvider 的 SPI 思路(走扩展点不改 framework)。素材来源 graceful degrade(缺 provider 降级 mock),
|
||||
* 非 fail-fast(与 payout 不同)。MVP 仅 {@link MmxCliMaterialProvider}(provider=mmx-cli):
|
||||
* 把 infra 已存储的文件路径包装为素材 ref;真实 mmx-cli 生成式素材是 later additive swap,本接口为统一入口。
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
public interface MaterialProvider {
|
||||
|
||||
/**
|
||||
* 本实现对应的来源标识(mmx-cli 等),与 game_material.provider 一致
|
||||
*
|
||||
* @return 来源标识
|
||||
*/
|
||||
String getProvider();
|
||||
|
||||
/**
|
||||
* 将「已由 infra 存储的文件访问路径」包装为平台素材引用 ref
|
||||
*
|
||||
* <p>MVP 口径:ref 直接取 infra 返回的访问路径(与 url 同源,自描述可寻址)。真实 mmx-cli later 由其
|
||||
* 素材服务返回 assetId 作 ref(届时覆写本方法),消费侧(assetContext.ref→sourceProject.assets[])不变。
|
||||
*
|
||||
* @param storedFilePath infra FileApi.createFile 返回的文件访问路径(非空)
|
||||
* @return 平台素材 ref
|
||||
*/
|
||||
String wrapRef(String storedFilePath);
|
||||
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package com.wanxiang.huijing.game.module.studio.framework.provider;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* MaterialProvider 工厂:按 provider 标识路由到对应 SPI 实现(仿 ad 模块 AdProviderFactory)
|
||||
*
|
||||
* Spring 注入全部 {@link MaterialProvider} 实现按 getProvider() 建索引。素材来源 graceful degrade:
|
||||
* 未注册的 provider 一律降级到 mmx-cli(保证上传链路恒可跑),非 fail-fast。真实来源注入后自动命中,无需改本类与 Service。
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
@Component
|
||||
public class MaterialProviderFactory {
|
||||
|
||||
/** provider 标识 → 实现,应用启动时一次性构建 */
|
||||
private final Map<String, MaterialProvider> providerMap;
|
||||
|
||||
/**
|
||||
* 构造时收集所有 MaterialProvider 实现建索引
|
||||
*
|
||||
* @param providers Spring 容器中全部 MaterialProvider 实现
|
||||
*/
|
||||
public MaterialProviderFactory(List<MaterialProvider> providers) {
|
||||
this.providerMap = providers.stream()
|
||||
.collect(Collectors.toMap(MaterialProvider::getProvider, Function.identity()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 provider 取实现;未注册降级到 mmx-cli(graceful degrade,保证非 null)
|
||||
*
|
||||
* @param provider 来源标识(可空)
|
||||
* @return 对应 MaterialProvider(兜底 mmx-cli)
|
||||
*/
|
||||
public MaterialProvider get(String provider) {
|
||||
MaterialProvider p = providerMap.get(provider);
|
||||
return p != null ? p : providerMap.get(MmxCliMaterialProvider.PROVIDER);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.wanxiang.huijing.game.module.studio.framework.provider;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* mmx-cli 素材来源实现(MVP 默认 provider)
|
||||
*
|
||||
* MVP 不做真实生成式素材:上传素材的字节由 infra FileApi 存储后,本实现把返回的访问路径包装为 ref
|
||||
* (ref=访问路径,与 url 同源)。真实 mmx-cli 生成式素材(按 prompt 出图/出音)是 later additive 接入,本类即扩展位。
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
@Component
|
||||
public class MmxCliMaterialProvider implements MaterialProvider {
|
||||
|
||||
/** 来源标识:mmx-cli(与 assetContext.provider 缺省值一致,§5.5) */
|
||||
public static final String PROVIDER = "mmx-cli";
|
||||
|
||||
@Override
|
||||
public String getProvider() {
|
||||
return PROVIDER;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String wrapRef(String storedFilePath) {
|
||||
// MVP:ref 直接复用 infra 返回的访问路径(自描述可寻址);真实 mmx-cli later 改返 assetId
|
||||
return storedFilePath;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
package com.wanxiang.huijing.game.module.studio.service.studio;
|
||||
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioAssetContextItem;
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialSelectReqVO;
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialUploadReqVO;
|
||||
import com.wanxiang.huijing.game.module.studio.dal.dataobject.studio.StudioMaterialDO;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 创作素材库 Service 接口(R-MAT;六类素材 浏览/选用/上传)
|
||||
*
|
||||
* 职责:素材实体(六类)+ 浏览分页(归属隔离)+ 选用入草稿(写 assetContext)+ 上传(composes infra FileApi 存字节)。
|
||||
* 边界:字节存储委托 huijing-module-infra FileApi(不在 studio 重造存储);生成权威/源项目消费 assetContext 属引擎线(不在本单元)。
|
||||
* 本单元只保证 assetContext.ref 有产出者(上传产 ref / 选用把 ref 落到草稿)。
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
public interface StudioMaterialService {
|
||||
|
||||
/**
|
||||
* 上传素材(composes infra FileApi 存字节 → 登记素材实体)
|
||||
*
|
||||
* 编排:六类校验 → 大小/MIME 红线校验 → 读 MultipartFile 字节 → FileApi.createFile 存储拿访问路径
|
||||
* → provider 包装为 ref → 落 game_material(归属本人) → 返回输入态引用项(直接喂 assetContext)。
|
||||
*
|
||||
* @param reqVO 上传入参(file/category/directory)
|
||||
* @param userId 当前登录创作者 ID(归属)
|
||||
* @return 六类素材引用项(category/ref/url/provider)
|
||||
*/
|
||||
StudioAssetContextItem upload(StudioMaterialUploadReqVO reqVO, Long userId);
|
||||
|
||||
/**
|
||||
* 浏览素材(owner-isolated 分页,只见本人素材,可选按 category 过滤)
|
||||
*
|
||||
* @param reqVO 分页 + 可选 category
|
||||
* @param userId 当前登录创作者 ID(归属边界,强制注入查询)
|
||||
* @return 素材分页(仅本人)
|
||||
*/
|
||||
PageResult<StudioMaterialDO> browse(StudioMaterialPageReqVO reqVO, Long userId);
|
||||
|
||||
/**
|
||||
* 选用素材入草稿(select-into-draft):把选中的素材(按 materialIds,本人)写入草稿会话 assetContext(取代旧 attachments)
|
||||
*
|
||||
* 编排:校验 session 归属(本人草稿)→ 按 materialIds 取本人素材(归属校验)→ 合并进会话既有 assetContext(JSON 去重)
|
||||
* → JsonUtils 序列化回写 session.attachments → 返回合并后的完整 assetContext。
|
||||
*
|
||||
* @param reqVO 选用入参(sessionId/materialIds)
|
||||
* @param userId 当前登录创作者 ID(归属)
|
||||
* @return 写入后草稿的完整 assetContext 引用项列表
|
||||
*/
|
||||
List<StudioAssetContextItem> selectIntoDraft(StudioMaterialSelectReqVO reqVO, Long userId);
|
||||
|
||||
}
|
||||
@ -0,0 +1,221 @@
|
||||
package com.wanxiang.huijing.game.module.studio.service.studio;
|
||||
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioAssetContextItem;
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialSelectReqVO;
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialUploadReqVO;
|
||||
import com.wanxiang.huijing.game.module.studio.convert.studio.StudioMaterialConvert;
|
||||
import com.wanxiang.huijing.game.module.studio.dal.dataobject.studio.StudioMaterialDO;
|
||||
import com.wanxiang.huijing.game.module.studio.dal.dataobject.studio.StudioSessionDO;
|
||||
import com.wanxiang.huijing.game.module.studio.dal.mysql.studio.StudioMaterialMapper;
|
||||
import com.wanxiang.huijing.game.module.studio.dal.mysql.studio.StudioSessionMapper;
|
||||
import com.wanxiang.huijing.game.module.studio.enums.MaterialCategoryEnum;
|
||||
import com.wanxiang.huijing.game.module.studio.framework.provider.MaterialProvider;
|
||||
import com.wanxiang.huijing.game.module.studio.framework.provider.MaterialProviderFactory;
|
||||
import com.wanxiang.huijing.game.module.studio.framework.provider.MmxCliMaterialProvider;
|
||||
import com.wanxiang.huijing.module.infra.api.file.FileApi;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import com.wanxiang.huijing.framework.common.util.json.JsonUtils;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import static com.wanxiang.huijing.game.module.studio.enums.ErrorCodeConstants.*;
|
||||
import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
|
||||
/**
|
||||
* 创作素材库 Service 实现(R-MAT;六类素材 浏览/选用/上传)
|
||||
*
|
||||
* 关键边界与纪律:
|
||||
* - 字节存储 composes infra FileApi(不在 studio 重造上传):注入 {@link FileApi},upload 读 MultipartFile 字节
|
||||
* → FileApi.createFile 拿访问路径 → provider 包装为 ref → 登记素材实体。
|
||||
* - 上传安全红线(前置校验,前端不是边界):六类校验 + 单文件 ≤10MB + 每类目 MIME 白名单(挡类型伪装)。
|
||||
* - 归属隔离:浏览/选用按当前登录用户强制过滤(创作者只见/只用自己的素材)。
|
||||
* - 外部文件 IO(FileApi)与异常路径带中文日志(链路可追溯)。
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
@Service
|
||||
public class StudioMaterialServiceImpl implements StudioMaterialService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(StudioMaterialServiceImpl.class);
|
||||
|
||||
/** 单文件大小上限:10MB(平台红线 bundle ≤10MB;单素材不得超此上限)。 */
|
||||
private static final long MAX_FILE_SIZE_BYTES = 10L * 1024 * 1024;
|
||||
|
||||
@Resource
|
||||
private StudioMaterialMapper studioMaterialMapper;
|
||||
|
||||
@Resource
|
||||
private StudioSessionMapper studioSessionMapper;
|
||||
|
||||
/**
|
||||
* 素材来源工厂(graceful degrade:未注册 provider 降级 mmx-cli)。MVP 仅 mmx-cli 把 infra 路径包装为 ref。
|
||||
*/
|
||||
@Resource
|
||||
private MaterialProviderFactory materialProviderFactory;
|
||||
|
||||
/**
|
||||
* 共享文件基础设施(COMPOSE,不重造):createFile(byte[], name, directory, type) 存字节返访问路径。
|
||||
* MVP 单体由 infra 的 FileApiImpl(@Primary) 就地解析。
|
||||
*/
|
||||
@Resource
|
||||
private FileApi fileApi;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public StudioAssetContextItem upload(StudioMaterialUploadReqVO reqVO, Long userId) {
|
||||
// 1) 六类校验(前置拒,前端不是边界)
|
||||
MaterialCategoryEnum categoryEnum = MaterialCategoryEnum.of(reqVO.getCategory());
|
||||
if (categoryEnum == null) {
|
||||
log.warn("[upload] 素材类目非法 category={} userId={}", reqVO.getCategory(), userId);
|
||||
throw exception(STUDIO_MATERIAL_CATEGORY_INVALID);
|
||||
}
|
||||
// 2) 文件非空校验
|
||||
MultipartFile file = reqVO.getFile();
|
||||
if (file == null || file.isEmpty()) {
|
||||
log.warn("[upload] 上传文件为空 category={} userId={}", reqVO.getCategory(), userId);
|
||||
throw exception(STUDIO_MATERIAL_FILE_EMPTY);
|
||||
}
|
||||
// 3) 大小红线(单文件 ≤10MB)
|
||||
long size = file.getSize();
|
||||
if (size > MAX_FILE_SIZE_BYTES) {
|
||||
log.warn("[upload] 上传文件超限 size={}B max={}B category={} userId={}",
|
||||
size, MAX_FILE_SIZE_BYTES, reqVO.getCategory(), userId);
|
||||
throw exception(STUDIO_MATERIAL_FILE_TOO_LARGE);
|
||||
}
|
||||
// 4) MIME 白名单(按类目,挡类型伪装;如 music 只收 audio/*,图类只收 image/*)
|
||||
String mimeType = file.getContentType();
|
||||
if (!categoryEnum.isMimeAllowed(mimeType)) {
|
||||
log.warn("[upload] MIME 不在类目白名单 category={} mime={} userId={}",
|
||||
reqVO.getCategory(), mimeType, userId);
|
||||
throw exception(STUDIO_MATERIAL_MIME_NOT_ALLOWED);
|
||||
}
|
||||
// 5) composes infra FileApi 存字节拿访问路径(外部文件 IO,带中文日志 + 异常包装)
|
||||
String name = file.getOriginalFilename();
|
||||
String storedPath;
|
||||
try {
|
||||
byte[] content = file.getBytes(); // 读 MultipartFile 字节(标准 Spring API,IOException 由 catch 兜底)
|
||||
storedPath = fileApi.createFile(content, name, reqVO.getDirectory(), mimeType);
|
||||
} catch (Exception e) {
|
||||
log.error("[upload] 调 infra FileApi 存储失败 name={} category={} userId={}", name, reqVO.getCategory(), userId, e);
|
||||
throw exception(STUDIO_MATERIAL_STORE_FAIL);
|
||||
}
|
||||
if (!StringUtils.hasText(storedPath)) {
|
||||
log.error("[upload] infra FileApi 返回空访问路径 name={} category={} userId={}", name, reqVO.getCategory(), userId);
|
||||
throw exception(STUDIO_MATERIAL_STORE_FAIL);
|
||||
}
|
||||
// 6) provider 把 infra 路径包装为平台素材 ref(MVP mmx-cli:ref=访问路径)
|
||||
MaterialProvider provider = materialProviderFactory.get(MmxCliMaterialProvider.PROVIDER);
|
||||
String ref = provider.wrapRef(storedPath);
|
||||
// 7) 登记素材实体(归属本人)
|
||||
StudioMaterialDO material = new StudioMaterialDO();
|
||||
material.setCreatorUserId(userId);
|
||||
material.setCategory(categoryEnum.getCategory());
|
||||
material.setName(StringUtils.hasText(name) ? name : categoryEnum.getCategory());
|
||||
material.setRef(ref);
|
||||
material.setUrl(storedPath);
|
||||
material.setProvider(provider.getProvider());
|
||||
material.setSizeBytes(size);
|
||||
material.setMimeType(mimeType);
|
||||
studioMaterialMapper.insert(material);
|
||||
log.info("[upload] 素材已登记 materialId={} category={} provider={} size={}B userId={}",
|
||||
material.getId(), categoryEnum.getCategory(), provider.getProvider(), size, userId);
|
||||
// 8) 返回输入态引用项(直接喂 assetContext.ref)
|
||||
return StudioMaterialConvert.toAssetContextItem(material);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<StudioMaterialDO> browse(StudioMaterialPageReqVO reqVO, Long userId) {
|
||||
// 浏览前可选 category 合法性校验(传了就必须合法;不传=全部六类)
|
||||
if (StringUtils.hasText(reqVO.getCategory()) && !MaterialCategoryEnum.isValid(reqVO.getCategory())) {
|
||||
log.warn("[browse] 过滤类目非法 category={} userId={}", reqVO.getCategory(), userId);
|
||||
throw exception(STUDIO_MATERIAL_CATEGORY_INVALID);
|
||||
}
|
||||
// 归属隔离:creatorUserId=当前登录用户强制注入(mapper 内 eq 硬过滤,不会返回他人素材)
|
||||
return studioMaterialMapper.selectPageByOwner(userId, reqVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public List<StudioAssetContextItem> selectIntoDraft(StudioMaterialSelectReqVO reqVO, Long userId) {
|
||||
// 1) 校验目标草稿会话归属(本人草稿,前端不是边界)
|
||||
StudioSessionDO session = studioSessionMapper.selectById(reqVO.getSessionId());
|
||||
if (session == null || !Objects.equals(session.getCreatorUserId(), userId)) {
|
||||
log.warn("[select] 草稿会话不存在或非本人 sessionId={} userId={}", reqVO.getSessionId(), userId);
|
||||
throw exception(STUDIO_MATERIAL_DRAFT_NOT_OWNER);
|
||||
}
|
||||
// 2) 按 materialIds 逐个取本人素材(归属校验:任一非本人/不存在即拒,不静默跳过)
|
||||
List<StudioAssetContextItem> selected = new ArrayList<>();
|
||||
for (Long materialId : reqVO.getMaterialIds()) {
|
||||
StudioMaterialDO material = studioMaterialMapper.selectByIdAndOwner(materialId, userId);
|
||||
if (material == null) {
|
||||
log.warn("[select] 素材不存在或非本人 materialId={} userId={}", materialId, userId);
|
||||
throw exception(STUDIO_MATERIAL_NOT_EXISTS);
|
||||
}
|
||||
selected.add(StudioMaterialConvert.toAssetContextItem(material));
|
||||
}
|
||||
// 3) 合并进会话既有 assetContext(解析旧 attachments JSON → 追加 → 按 ref 去重,保持顺序)
|
||||
List<StudioAssetContextItem> merged = mergeAssetContext(session.getAttachments(), selected);
|
||||
// 4) JsonUtils 序列化回写 session.attachments(绝不用 Map.toString),仅更新该列
|
||||
StudioSessionDO update = new StudioSessionDO();
|
||||
update.setId(session.getId());
|
||||
update.setAttachments(JsonUtils.toJsonString(merged));
|
||||
studioSessionMapper.updateById(update);
|
||||
log.info("[select] 素材已写入草稿 assetContext sessionId={} addCount={} totalCount={} userId={}",
|
||||
session.getId(), selected.size(), merged.size(), userId);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并草稿既有 assetContext 与新选素材(按 ref 去重,保持插入顺序:既有在前、新增在后)
|
||||
*
|
||||
* <p>既有 attachments 可能为旧自由文本/空/合法 assetContext JSON:用 JsonUtils.parseArray 安全解析,
|
||||
* 解析失败(非数组/旧格式)则视为空既有上下文(best-effort,不阻断选用),仅以新选素材为准。
|
||||
*
|
||||
* @param existingJson 会话既有 attachments JSON(可空/旧格式)
|
||||
* @param selected 本次新选素材引用项
|
||||
* @return 去重合并后的引用项列表
|
||||
*/
|
||||
private List<StudioAssetContextItem> mergeAssetContext(String existingJson, List<StudioAssetContextItem> selected) {
|
||||
List<StudioAssetContextItem> result = new ArrayList<>();
|
||||
Set<String> seenRefs = new LinkedHashSet<>();
|
||||
// 解析既有 assetContext(best-effort:旧文本/非数组 → 空既有)
|
||||
if (StringUtils.hasText(existingJson)) {
|
||||
try {
|
||||
List<StudioAssetContextItem> existing = JsonUtils.parseArray(existingJson, StudioAssetContextItem.class);
|
||||
if (!CollectionUtils.isEmpty(existing)) {
|
||||
for (StudioAssetContextItem item : existing) {
|
||||
if (item != null && StringUtils.hasText(item.getRef()) && seenRefs.add(item.getRef())) {
|
||||
result.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 既有为旧自由文本/非 assetContext 格式:忽略既有,仅以新选为准(不阻断主链)。
|
||||
// 此为预期降级路径(旧 attachments 兼容),只记摘要不打全栈,避免日志噪音。
|
||||
log.warn("[select] 既有 attachments 非 assetContext 数组,忽略既有仅以新选为准 len={} cause={}",
|
||||
existingJson.length(), e.getMessage());
|
||||
}
|
||||
}
|
||||
// 追加新选(去重)
|
||||
for (StudioAssetContextItem item : selected) {
|
||||
if (item != null && StringUtils.hasText(item.getRef()) && seenRefs.add(item.getRef())) {
|
||||
result.add(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,364 @@
|
||||
package com.wanxiang.huijing.game.module.studio.service.studio;
|
||||
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioAssetContextItem;
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialPageReqVO;
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialSelectReqVO;
|
||||
import com.wanxiang.huijing.game.module.studio.controller.app.studio.vo.StudioMaterialUploadReqVO;
|
||||
import com.wanxiang.huijing.game.module.studio.dal.dataobject.studio.StudioMaterialDO;
|
||||
import com.wanxiang.huijing.game.module.studio.dal.dataobject.studio.StudioSessionDO;
|
||||
import com.wanxiang.huijing.game.module.studio.dal.mysql.studio.StudioMaterialMapper;
|
||||
import com.wanxiang.huijing.game.module.studio.dal.mysql.studio.StudioSessionMapper;
|
||||
import com.wanxiang.huijing.game.module.studio.framework.provider.MaterialProviderFactory;
|
||||
import com.wanxiang.huijing.game.module.studio.framework.provider.MmxCliMaterialProvider;
|
||||
import com.wanxiang.huijing.module.infra.api.file.FileApi;
|
||||
import com.wanxiang.huijing.framework.common.exception.ServiceException;
|
||||
import com.wanxiang.huijing.framework.common.pojo.PageResult;
|
||||
import com.wanxiang.huijing.framework.common.util.json.JsonUtils;
|
||||
import com.wanxiang.huijing.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static com.wanxiang.huijing.game.module.studio.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* {@link StudioMaterialServiceImpl} 单元测试(纯 Mockito,不依赖 DB;R-MAT 素材库 U6)
|
||||
*
|
||||
* <p>遵循 studio 模块既有测试范式({@link BaseMockitoUnitTest}:mock 自模块 Mapper + 跨模块 -api,mock FileApi 不打真实存储)。
|
||||
* 覆盖 KTD4 测试矩阵:
|
||||
* <ul>
|
||||
* <li>upload happy:composes FileApi 存字节 → 落库 → 返回 valid StudioAssetContextItem;</li>
|
||||
* <li>upload 六类校验:非法类目前置拒(不调 FileApi);</li>
|
||||
* <li>upload size 红线:超 10MB 前置拒;</li>
|
||||
* <li>upload MIME 红线:music 收到 image/* → 拒(类目白名单);image 类收到 audio/* → 拒;</li>
|
||||
* <li>upload 空文件:前置拒;FileApi 返回空路径 → STORE_FAIL;</li>
|
||||
* <li>browse:归属隔离(mapper 强制传当前登录 userId)+ 非法 category 过滤拒;</li>
|
||||
* <li>select→draft:归属校验 + assetContext JSON 落库(round-trip)+ 去重合并 + 非本人素材拒 + 非本人会话拒。</li>
|
||||
* </ul>
|
||||
* mock BaseMapper.insert 用 doAnswer 回填 ID(Mockito 下 insert 不真写库);updateById 用 any(*.class) 消歧。
|
||||
*
|
||||
* @author 造梦AI(U6 R-MAT 素材库)
|
||||
*/
|
||||
class StudioMaterialServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@InjectMocks
|
||||
private StudioMaterialServiceImpl studioMaterialService;
|
||||
|
||||
@Mock
|
||||
private StudioMaterialMapper studioMaterialMapper;
|
||||
@Mock
|
||||
private StudioSessionMapper studioSessionMapper;
|
||||
@Mock
|
||||
private MaterialProviderFactory materialProviderFactory; // 工厂注入;upload 用 get(mmx-cli) 取真 mock provider
|
||||
@Mock
|
||||
private FileApi fileApi; // 共享文件基础设施,mock 不打真实存储(KTD4 要求)
|
||||
|
||||
private static final Long USER_ID = 99L;
|
||||
|
||||
/** 真 mmx-cli provider(wrapRef=透传路径):工厂 get(mmx-cli) 返回它,避免 mock provider 的 stub 噪音。 */
|
||||
private final MmxCliMaterialProvider mmxProvider = new MmxCliMaterialProvider();
|
||||
|
||||
/** 桩工厂返回真 mmx-cli provider(upload happy 路径用)。 */
|
||||
private void stubProviderFactory() {
|
||||
when(materialProviderFactory.get(MmxCliMaterialProvider.PROVIDER)).thenReturn(mmxProvider);
|
||||
}
|
||||
|
||||
// ============================== upload happy:composes FileApi + 落库 + 返回 valid item ==============================
|
||||
|
||||
@Test
|
||||
void testUpload_happy_composesFileApi_persists_returnsValidItem() {
|
||||
stubProviderFactory();
|
||||
// composes infra FileApi:存字节返回访问路径
|
||||
when(fileApi.createFile(any(byte[].class), eq("陨石.png"), eq("material/sprite"), eq("image/png")))
|
||||
.thenReturn("/infra/file/abc/陨石.png");
|
||||
// insert 回填素材 ID
|
||||
doAnswer(inv -> { ((StudioMaterialDO) inv.getArgument(0)).setId(88231L); return 1; })
|
||||
.when(studioMaterialMapper).insert(any(StudioMaterialDO.class));
|
||||
|
||||
StudioMaterialUploadReqVO reqVO = uploadReq("sprite", "陨石.png", "image/png", "material/sprite", 2048);
|
||||
StudioAssetContextItem item = studioMaterialService.upload(reqVO, USER_ID);
|
||||
|
||||
// 返回 valid StudioAssetContextItem(category/ref/url/provider 四元组)
|
||||
assertEquals("sprite", item.getCategory());
|
||||
assertEquals("/infra/file/abc/陨石.png", item.getRef()); // mmx-cli wrapRef=透传 infra 路径
|
||||
assertEquals("/infra/file/abc/陨石.png", item.getUrl());
|
||||
assertEquals(MmxCliMaterialProvider.PROVIDER, item.getProvider());
|
||||
// 校验 composes FileApi(读字节 → createFile)+ 落库归属本人
|
||||
verify(fileApi).createFile(any(byte[].class), eq("陨石.png"), eq("material/sprite"), eq("image/png"));
|
||||
ArgumentCaptor<StudioMaterialDO> captor = ArgumentCaptor.forClass(StudioMaterialDO.class);
|
||||
verify(studioMaterialMapper).insert(captor.capture());
|
||||
StudioMaterialDO persisted = captor.getValue();
|
||||
assertEquals(USER_ID, persisted.getCreatorUserId()); // 归属本人
|
||||
assertEquals("sprite", persisted.getCategory());
|
||||
assertEquals("/infra/file/abc/陨石.png", persisted.getRef());
|
||||
assertEquals(2048L, persisted.getSizeBytes());
|
||||
assertEquals("image/png", persisted.getMimeType());
|
||||
}
|
||||
|
||||
// ============================== upload 六类校验 ==============================
|
||||
|
||||
@Test
|
||||
void testUpload_invalidCategory_rejected_noFileApi() {
|
||||
StudioMaterialUploadReqVO reqVO = uploadReq("weapon", "x.png", "image/png", null, 100); // 非六类
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> studioMaterialService.upload(reqVO, USER_ID));
|
||||
assertEquals(STUDIO_MATERIAL_CATEGORY_INVALID.getCode(), ex.getCode());
|
||||
verifyNoInteractions(fileApi); // 前置拒,不触达存储
|
||||
verify(studioMaterialMapper, never()).insert(any(StudioMaterialDO.class));
|
||||
}
|
||||
|
||||
// ============================== upload size 红线(≤10MB)==============================
|
||||
|
||||
@Test
|
||||
void testUpload_oversize_rejected() {
|
||||
long oversize = 10L * 1024 * 1024 + 1; // 10MB + 1B
|
||||
StudioMaterialUploadReqVO reqVO = uploadReq("sprite", "big.png", "image/png", null, (int) Math.min(oversize, 16));
|
||||
// size 由 MockMultipartFile 字节长度决定,这里手工造一个超限文件
|
||||
reqVO.setFile(new MockMultipartFile("file", "big.png", "image/png", new byte[(int) 0]) {
|
||||
@Override
|
||||
public long getSize() {
|
||||
return oversize; // 覆写 size 模拟超限(避免真分配 10MB 字节)
|
||||
}
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> studioMaterialService.upload(reqVO, USER_ID));
|
||||
assertEquals(STUDIO_MATERIAL_FILE_TOO_LARGE.getCode(), ex.getCode());
|
||||
verifyNoInteractions(fileApi);
|
||||
}
|
||||
|
||||
// ============================== upload MIME 红线(类目白名单)==============================
|
||||
|
||||
@Test
|
||||
void testUpload_musicCategory_rejectsImageMime() {
|
||||
// music 类只收 audio/*,收到 image/png → 拒(挡类型伪装)
|
||||
StudioMaterialUploadReqVO reqVO = uploadReq("music", "fake.mp3", "image/png", null, 100);
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> studioMaterialService.upload(reqVO, USER_ID));
|
||||
assertEquals(STUDIO_MATERIAL_MIME_NOT_ALLOWED.getCode(), ex.getCode());
|
||||
verifyNoInteractions(fileApi);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpload_spriteCategory_rejectsAudioMime() {
|
||||
// image 类(sprite)只收 image/*,收到 audio/mpeg → 拒
|
||||
StudioMaterialUploadReqVO reqVO = uploadReq("sprite", "fake.png", "audio/mpeg", null, 100);
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> studioMaterialService.upload(reqVO, USER_ID));
|
||||
assertEquals(STUDIO_MATERIAL_MIME_NOT_ALLOWED.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpload_musicCategory_acceptsAudioMime() {
|
||||
// music 收 audio/mpeg → 放行(正路)
|
||||
stubProviderFactory();
|
||||
when(fileApi.createFile(any(byte[].class), any(), any(), eq("audio/mpeg"))).thenReturn("/infra/file/bgm.mp3");
|
||||
doAnswer(inv -> { ((StudioMaterialDO) inv.getArgument(0)).setId(7L); return 1; })
|
||||
.when(studioMaterialMapper).insert(any(StudioMaterialDO.class));
|
||||
|
||||
StudioAssetContextItem item = studioMaterialService.upload(
|
||||
uploadReq("music", "bgm.mp3", "audio/mpeg", null, 100), USER_ID);
|
||||
assertEquals("music", item.getCategory());
|
||||
assertEquals("/infra/file/bgm.mp3", item.getRef());
|
||||
}
|
||||
|
||||
// ============================== upload 空文件 / 存储失败 ==============================
|
||||
|
||||
@Test
|
||||
void testUpload_emptyFile_rejected() {
|
||||
StudioMaterialUploadReqVO reqVO = new StudioMaterialUploadReqVO();
|
||||
reqVO.setCategory("sprite");
|
||||
reqVO.setFile(new MockMultipartFile("file", "empty.png", "image/png", new byte[0])); // 零字节=空
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> studioMaterialService.upload(reqVO, USER_ID));
|
||||
assertEquals(STUDIO_MATERIAL_FILE_EMPTY.getCode(), ex.getCode());
|
||||
verifyNoInteractions(fileApi);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpload_fileApiReturnsBlank_storeFail() {
|
||||
// 注:本路在 createFile 返回空白后即抛 STORE_FAIL,尚未取 provider 工厂(故不 stub 工厂,STRICT_STUBS 下避免冗余)
|
||||
when(fileApi.createFile(any(byte[].class), any(), any(), any())).thenReturn(" "); // 空白路径=存储失败
|
||||
StudioMaterialUploadReqVO reqVO = uploadReq("sprite", "x.png", "image/png", null, 100);
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> studioMaterialService.upload(reqVO, USER_ID));
|
||||
assertEquals(STUDIO_MATERIAL_STORE_FAIL.getCode(), ex.getCode());
|
||||
verify(studioMaterialMapper, never()).insert(any(StudioMaterialDO.class));
|
||||
}
|
||||
|
||||
// ============================== browse:归属隔离 + 非法 category ==============================
|
||||
|
||||
@Test
|
||||
void testBrowse_ownerIsolated_passesLoginUserIdToMapper() {
|
||||
StudioMaterialPageReqVO reqVO = new StudioMaterialPageReqVO();
|
||||
reqVO.setCategory("sprite");
|
||||
StudioMaterialDO mine = material(88231L, USER_ID, "sprite");
|
||||
when(studioMaterialMapper.selectPageByOwner(eq(USER_ID), eq(reqVO)))
|
||||
.thenReturn(new PageResult<>(List.of(mine), 1L));
|
||||
|
||||
PageResult<StudioMaterialDO> page = studioMaterialService.browse(reqVO, USER_ID);
|
||||
|
||||
assertEquals(1, page.getList().size());
|
||||
assertEquals(USER_ID, page.getList().get(0).getCreatorUserId());
|
||||
// 归属隔离坐实:mapper 收到的 owner = 当前登录 userId(不接受前端传 creatorUserId)
|
||||
verify(studioMaterialMapper).selectPageByOwner(eq(USER_ID), eq(reqVO));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBrowse_emptyData_returnsEmptyPage() {
|
||||
StudioMaterialPageReqVO reqVO = new StudioMaterialPageReqVO();
|
||||
when(studioMaterialMapper.selectPageByOwner(eq(USER_ID), any())).thenReturn(PageResult.empty());
|
||||
|
||||
PageResult<StudioMaterialDO> page = studioMaterialService.browse(reqVO, USER_ID);
|
||||
assertTrue(page.getList().isEmpty());
|
||||
assertEquals(0L, page.getTotal());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testBrowse_invalidCategoryFilter_rejected() {
|
||||
StudioMaterialPageReqVO reqVO = new StudioMaterialPageReqVO();
|
||||
reqVO.setCategory("weapon"); // 非六类
|
||||
ServiceException ex = assertThrows(ServiceException.class, () -> studioMaterialService.browse(reqVO, USER_ID));
|
||||
assertEquals(STUDIO_MATERIAL_CATEGORY_INVALID.getCode(), ex.getCode());
|
||||
verify(studioMaterialMapper, never()).selectPageByOwner(any(), any());
|
||||
}
|
||||
|
||||
// ============================== select→draft:归属 + assetContext JSON round-trip + 去重 ==============================
|
||||
|
||||
@Test
|
||||
void testSelectIntoDraft_writesAssetContextJson_roundTrip() {
|
||||
// 会话本人草稿(无既有 assetContext)
|
||||
StudioSessionDO session = session(512L, USER_ID, null);
|
||||
when(studioSessionMapper.selectById(512L)).thenReturn(session);
|
||||
// 两个本人素材
|
||||
when(studioMaterialMapper.selectByIdAndOwner(88231L, USER_ID)).thenReturn(material(88231L, USER_ID, "sprite"));
|
||||
when(studioMaterialMapper.selectByIdAndOwner(88232L, USER_ID)).thenReturn(material(88232L, USER_ID, "music"));
|
||||
|
||||
List<StudioAssetContextItem> result =
|
||||
studioMaterialService.selectIntoDraft(selectReq(512L, List.of(88231L, 88232L)), USER_ID);
|
||||
|
||||
// 返回合并后两条
|
||||
assertEquals(2, result.size());
|
||||
// assetContext JSON 落库 round-trip:updateById 的 attachments 反序列化回来 = 两条 ref
|
||||
ArgumentCaptor<StudioSessionDO> captor = ArgumentCaptor.forClass(StudioSessionDO.class);
|
||||
verify(studioSessionMapper).updateById(captor.capture());
|
||||
String json = captor.getValue().getAttachments();
|
||||
assertNotNull(json);
|
||||
List<StudioAssetContextItem> parsed = JsonUtils.parseArray(json, StudioAssetContextItem.class);
|
||||
assertEquals(2, parsed.size());
|
||||
assertEquals("ref-88231", parsed.get(0).getRef());
|
||||
assertEquals("ref-88232", parsed.get(1).getRef());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSelectIntoDraft_mergesWithExisting_dedupByRef() {
|
||||
// 会话已有一条 assetContext(ref-88231);再选 88231(重复) + 88232(新) → 去重后 2 条(既有在前)
|
||||
StudioSessionDO session = session(512L, USER_ID,
|
||||
JsonUtils.toJsonString(List.of(itemOf("sprite", "ref-88231"))));
|
||||
when(studioSessionMapper.selectById(512L)).thenReturn(session);
|
||||
when(studioMaterialMapper.selectByIdAndOwner(88231L, USER_ID)).thenReturn(material(88231L, USER_ID, "sprite"));
|
||||
when(studioMaterialMapper.selectByIdAndOwner(88232L, USER_ID)).thenReturn(material(88232L, USER_ID, "music"));
|
||||
|
||||
List<StudioAssetContextItem> result =
|
||||
studioMaterialService.selectIntoDraft(selectReq(512L, List.of(88231L, 88232L)), USER_ID);
|
||||
|
||||
assertEquals(2, result.size()); // 88231 去重,不重复
|
||||
assertEquals("ref-88231", result.get(0).getRef());
|
||||
assertEquals("ref-88232", result.get(1).getRef());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSelectIntoDraft_existingLegacyText_ignoredNotBlocking() {
|
||||
// 既有 attachments 是旧自由文本(非 assetContext 数组)→ best-effort 忽略既有,仅以新选为准(不抛)
|
||||
StudioSessionDO session = session(512L, USER_ID, "[\"old-free-text.png\"]"); // 旧格式(字符串数组非对象数组)
|
||||
when(studioSessionMapper.selectById(512L)).thenReturn(session);
|
||||
when(studioMaterialMapper.selectByIdAndOwner(88231L, USER_ID)).thenReturn(material(88231L, USER_ID, "sprite"));
|
||||
|
||||
List<StudioAssetContextItem> result =
|
||||
studioMaterialService.selectIntoDraft(selectReq(512L, List.of(88231L)), USER_ID);
|
||||
assertEquals(1, result.size());
|
||||
assertEquals("ref-88231", result.get(0).getRef());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSelectIntoDraft_materialNotOwned_rejected() {
|
||||
StudioSessionDO session = session(512L, USER_ID, null);
|
||||
when(studioSessionMapper.selectById(512L)).thenReturn(session);
|
||||
// 素材非本人(mapper 归属查返 null)→ 拒
|
||||
when(studioMaterialMapper.selectByIdAndOwner(88231L, USER_ID)).thenReturn(null);
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> studioMaterialService.selectIntoDraft(selectReq(512L, List.of(88231L)), USER_ID));
|
||||
assertEquals(STUDIO_MATERIAL_NOT_EXISTS.getCode(), ex.getCode());
|
||||
verify(studioSessionMapper, never()).updateById(any(StudioSessionDO.class)); // 不写草稿
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSelectIntoDraft_sessionNotOwner_rejected() {
|
||||
// 会话属他人 → 归属拒,不取素材、不写草稿
|
||||
when(studioSessionMapper.selectById(512L)).thenReturn(session(512L, 1L, null));
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> studioMaterialService.selectIntoDraft(selectReq(512L, List.of(88231L)), USER_ID));
|
||||
assertEquals(STUDIO_MATERIAL_DRAFT_NOT_OWNER.getCode(), ex.getCode());
|
||||
verify(studioMaterialMapper, never()).selectByIdAndOwner(any(), any());
|
||||
verify(studioSessionMapper, never()).updateById(any(StudioSessionDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSelectIntoDraft_sessionNotExists_rejected() {
|
||||
when(studioSessionMapper.selectById(512L)).thenReturn(null);
|
||||
ServiceException ex = assertThrows(ServiceException.class,
|
||||
() -> studioMaterialService.selectIntoDraft(selectReq(512L, List.of(88231L)), USER_ID));
|
||||
assertEquals(STUDIO_MATERIAL_DRAFT_NOT_OWNER.getCode(), ex.getCode());
|
||||
}
|
||||
|
||||
// ============================== 测试夹具 ==============================
|
||||
|
||||
private static StudioMaterialUploadReqVO uploadReq(String category, String filename, String mime,
|
||||
String directory, int byteLen) {
|
||||
StudioMaterialUploadReqVO reqVO = new StudioMaterialUploadReqVO();
|
||||
reqVO.setCategory(category);
|
||||
reqVO.setDirectory(directory);
|
||||
reqVO.setFile(new MockMultipartFile("file", filename, mime, new byte[byteLen]));
|
||||
return reqVO;
|
||||
}
|
||||
|
||||
private static StudioMaterialDO material(Long id, Long creatorUserId, String category) {
|
||||
StudioMaterialDO m = new StudioMaterialDO();
|
||||
m.setId(id);
|
||||
m.setCreatorUserId(creatorUserId);
|
||||
m.setCategory(category);
|
||||
m.setName("m-" + id);
|
||||
m.setRef("ref-" + id);
|
||||
m.setUrl("url-" + id);
|
||||
m.setProvider(MmxCliMaterialProvider.PROVIDER);
|
||||
return m;
|
||||
}
|
||||
|
||||
private static StudioSessionDO session(Long id, Long creatorUserId, String attachments) {
|
||||
StudioSessionDO s = new StudioSessionDO();
|
||||
s.setId(id);
|
||||
s.setCreatorUserId(creatorUserId);
|
||||
s.setAttachments(attachments);
|
||||
return s;
|
||||
}
|
||||
|
||||
private static StudioMaterialSelectReqVO selectReq(Long sessionId, List<Long> materialIds) {
|
||||
StudioMaterialSelectReqVO reqVO = new StudioMaterialSelectReqVO();
|
||||
reqVO.setSessionId(sessionId);
|
||||
reqVO.setMaterialIds(materialIds);
|
||||
return reqVO;
|
||||
}
|
||||
|
||||
private static StudioAssetContextItem itemOf(String category, String ref) {
|
||||
StudioAssetContextItem item = new StudioAssetContextItem();
|
||||
item.setCategory(category);
|
||||
item.setRef(ref);
|
||||
return item;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
-- =============================================================================
|
||||
-- 契约 #2 DB 迁移 | 模块:studio(game-module-studio,U6 R-MAT 素材库)| owner:studio 路
|
||||
-- 文件:V24.0.0__create_game_material.sql(Flyway,只新增;已合入禁止修改,回滚写新补偿迁移 V24.0.1 DROP TABLE)
|
||||
-- 内容:创作者私有素材库表 game_material(六类素材 浏览/选用/上传)
|
||||
-- 职责(U6 R-MAT):创作者维度可复用素材实体。上传时字节由 huijing-module-infra FileApi 存储,本表只登记 ref/url/元数据。
|
||||
-- 边界纪律:
|
||||
-- - 与 game_studio_asset(会话内单步整包槽位)是两个概念:本表=创作者素材库(六类,归属隔离),不混用。
|
||||
-- - 字节存储权威归 infra(FileApi);本表只持 ref/url 引用,不在 studio 重造文件存储。
|
||||
-- - 六类 category 冻结(sprite/character/effect/scene/ui/music),与 studio.yaml StudioAssetContextItem +
|
||||
-- source-project.schema.json assetSpec.category 四处同引同一枚举(MaterialCategoryEnum)。
|
||||
-- 约定:InnoDB + utf8mb4;显式列;含 Yudao 审计列 creator/create_time/updater/update_time/deleted + tenant_id;
|
||||
-- 归属隔离用 creator_user_id(业务字段,区别于审计列 creator);中文列注释;关键查询建索引、禁裸 select*。
|
||||
-- 上传红线:单文件 ≤10MB + 每类目 MIME 白名单(在 Service 可信边界校验,DB 仅登记 size_bytes/mime_type 审计)。
|
||||
-- 错误码段:studio = 1-112-***-***(素材库 005 段)
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 表:game_material —— 创作素材库表(R-MAT;创作者私有六类素材,归属隔离浏览/选用/上传)
|
||||
-- 用途:上传素材(infra 存字节)后登记一行;浏览按 creator_user_id 隔离分页;选用把 ref 写入草稿 assetContext。
|
||||
-- 引用边界:ref/url 引用 infra 存储的访问路径(MVP ref=url 同源,自描述可寻址);P1 ref 可换 mmx-cli assetId。
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE `game_material` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '素材 ID',
|
||||
`creator_user_id` BIGINT NOT NULL COMMENT '创作者用户 ID(业务字段;归属隔离:创作者只见/只用自己的素材。区别于审计列 creator)',
|
||||
`category` VARCHAR(16) NOT NULL COMMENT '素材类目(六类:sprite图元/character角色/effect特效/scene场景/ui界面/music音乐;MaterialCategoryEnum)',
|
||||
`name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '素材名称(上传文件原名兜底,浏览展示用)',
|
||||
`ref` VARCHAR(512) NOT NULL COMMENT '平台素材引用 ref(喂 assetContext.ref);MVP=infra 访问路径,P1 可换 mmx-cli assetId',
|
||||
`url` VARCHAR(512) NULL DEFAULT NULL COMMENT '素材访问 URL(只读镜像,可空);MVP=infra FileApi 返回路径',
|
||||
`provider` VARCHAR(32) NOT NULL DEFAULT 'mmx-cli' COMMENT '素材来源 provider(缺省 mmx-cli;切 provider 不动消费侧,§5.5)',
|
||||
`size_bytes` BIGINT NULL DEFAULT NULL COMMENT '文件大小(字节),上传记录供配额/红线审计',
|
||||
`mime_type` VARCHAR(128) NULL DEFAULT NULL COMMENT '文件 MIME 类型(如 image/png、audio/mpeg)',
|
||||
`creator` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建者(Yudao 审计列)',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除:0未删 1已删',
|
||||
`tenant_id` BIGINT NOT NULL DEFAULT 0 COMMENT '租户 ID(Yudao 多租户兼容,MVP 单租户=0)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_owner_cat` (`creator_user_id`, `category`, `id`) COMMENT '创作者素材浏览(归属隔离 + 按类目过滤 + 按 id 倒序)'
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作素材库表(R-MAT;创作者私有六类素材,归属隔离浏览/选用/上传)';
|
||||
Loading…
x
Reference in New Issue
Block a user