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:
lili 2026-06-18 10:54:56 -07:00
parent e378f3c662
commit 09ced58930
20 changed files with 1434 additions and 0 deletions

View File

@ -188,6 +188,73 @@ paths:
application/json:
schema: { $ref: '#/components/schemas/CommonResultStudioTaskChain' }
# ===========================================================================
# 素材库 R-MATU6 | 六类素材 浏览/选用/上传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-MATcomposes 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-MATowner-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-MATselect-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' }
# ---- 素材库选用入草稿 请求 VOR-MATU6----
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 ==========
# ---- 素材库浏览项 响应 VOR-MATU6比输入态 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 }

View File

@ -0,0 +1,40 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 模块studiogame-module-studioU6 R-MAT 素材库)| ownerstudio 路
-- 文件V24.0.0__create_game_material.sqlFlyway只新增已合入禁止修改回滚写新补偿迁移 V24.0.1 DROP TABLE
-- 内容:创作者私有素材库表 game_material六类素材 浏览/选用/上传)
-- 职责U6 R-MAT创作者维度可复用素材实体。上传时字节由 huijing-module-infra FileApi 存储,本表只登记 ref/url/元数据。
-- 边界纪律:
-- - 与 game_studio_asset会话内单步整包槽位是两个概念本表=创作者素材库(六类,归属隔离),不混用。
-- - 字节存储权威归 infraFileApi本表只持 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.refMVP=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 '租户 IDYudao 多租户兼容MVP 单租户=0',
PRIMARY KEY (`id`),
KEY `idx_owner_cat` (`creator_user_id`, `category`, `id`) COMMENT '创作者素材浏览(归属隔离 + 按类目过滤 + 按 id 倒序)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作素材库表R-MAT创作者私有六类素材归属隔离浏览/选用/上传)';

View File

@ -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, "草稿会话不存在或无权操作");
}

View File

@ -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 造梦AIU6 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/pngimage/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);
}
}

View File

@ -53,6 +53,13 @@
<version>${revision}</version>
</dependency>
<!-- 依赖 infra 的 -apiR-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>

View File

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

View File

@ -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 VOR-MAT 浏览入口
*
* 端点 GET /app-api/studio/asset/browse继承 PageParampageNo/pageSize+ 可选 category 过滤
* 归属隔离不在本 VO 承载 Service 用当前登录用户强制注入 mapper前端不是边界不接受前端传 creatorUserId
*
* @author 造梦AIU6 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;
}

View File

@ -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;
/**
* 素材浏览响应 VOR-MAT 浏览项比输入态 StudioAssetContextItem 多展示用元数据
*
* 浏览返回素材库列表项 id选用入参+ 六类引用四元组category/ref/url/provider+ 名称/大小/MIME/上传时间
* 上传端点返回的是冻结的 {@link StudioAssetContextItem}直接喂 assetContext浏览返回本 VO id 供选用
*
* @author 造梦AIU6 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;
}

View File

@ -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 VOR-MAT select-into-draft便利生产者
*
* 端点 POST /app-api/studio/asset/select把已选素材 materialIds写入指定草稿会话的 assetContext
* = session.attachments JSON取代旧 attachmentsC3 create/modify/extend 已直接接受 assetContext 输入
* 本端点是从素材库选好 落到草稿的便利产出保证 assetContext.ref 有产出者
* 归属sessionId materialIds 均按当前登录用户校验归属前端不是边界
*
* @author 造梦AIU6 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;
}

View File

@ -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 VOmultipart/form-dataR-MAT 上传入口
*
* 端点 POST /app-api/studio/asset/uploadmultipart 携带 file + category+ 可空 directory
* 类目六类校验大小/MIME 红线校验在 Service 可信边界前端不是边界
*
* @author 造梦AIU6 R-MAT 素材库
*/
@Schema(description = "产品端 - 素材上传 Request VOmultipart")
@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;
}

View File

@ -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 造梦AIU6 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;
}
}

View File

@ -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_materialR-MAT 创作者私有素材库实体
*
* 继承 {@link TenantBaseDO}自动携带审计/租户列
* 与会话内资产槽位{@link StudioAssetDO} game_studio_asset单步整包槽位是两个概念
* - 本类 = 创作者维度的可复用素材库六类归属隔离浏览/选用/上传
* - StudioAssetDO = 单次会话生成产物的槽位登记二者不混用
* 边界上传时素材字节由 huijing-module-infra FileApi 存储不在 studio 重造存储本类只登记 ref/url/元数据
*
* @author 造梦AIU6 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.refMVP=infra 存储返回的访问路径 url 同源P1 mmx-cli assetId
*/
private String ref;
/**
* 素材访问 URL只读镜像可空MVP=infra FileApi.createFile 返回路径
*/
private String url;
/**
* 素材来源 providermmx-cli/上传等缺省 mmx-cli provider 不动消费侧§5.5
*/
private String provider;
/**
* 文件大小字节上传时记录供配额/红线校验审计
*/
private Long sizeBytes;
/**
* 文件 MIME 类型 image/pngaudio/mpeg
*/
private String mimeType;
}

View File

@ -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;
/**
* 创作素材 MapperR-MAT
*
* 显式列查询不裸 select*归属隔离硬约束浏览分页强制 eq creator_user_id创作者只见自己的素材
* 服务端可信边界前端不是边界可选按 category 过滤命中 idx_owner_cat(creator_user_id, category)
*
* @author 造梦AIU6 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));
}
}

View File

@ -0,0 +1,32 @@
package com.wanxiang.huijing.game.module.studio.framework.provider;
/**
* 素材 Provider SPIR-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 造梦AIU6 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.refsourceProject.assets[]不变
*
* @param storedFilePath infra FileApi.createFile 返回的文件访问路径非空
* @return 平台素材 ref
*/
String wrapRef(String storedFilePath);
}

View File

@ -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 造梦AIU6 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-cligraceful 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);
}
}

View File

@ -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 造梦AIU6 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) {
// MVPref 直接复用 infra 返回的访问路径自描述可寻址真实 mmx-cli later 改返 assetId
return storedFilePath;
}
}

View File

@ -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 造梦AIU6 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 取本人素材归属校验 合并进会话既有 assetContextJSON 去重
* JsonUtils 序列化回写 session.attachments 返回合并后的完整 assetContext
*
* @param reqVO 选用入参sessionId/materialIds
* @param userId 当前登录创作者 ID归属
* @return 写入后草稿的完整 assetContext 引用项列表
*/
List<StudioAssetContextItem> selectIntoDraft(StudioMaterialSelectReqVO reqVO, Long userId);
}

View File

@ -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 白名单挡类型伪装
* - 归属隔离浏览/选用按当前登录用户强制过滤创作者只见/只用自己的素材
* - 外部文件 IOFileApi与异常路径带中文日志链路可追溯
*
* @author 造梦AIU6 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-cliMVP 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 APIIOException 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 路径包装为平台素材 refMVP mmx-cliref=访问路径
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<>();
// 解析既有 assetContextbest-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;
}
}

View File

@ -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不依赖 DBR-MAT 素材库 U6
*
* <p>遵循 studio 模块既有测试范式{@link BaseMockitoUnitTest}mock 自模块 Mapper + 跨模块 -apimock FileApi 不打真实存储
* 覆盖 KTD4 测试矩阵
* <ul>
* <li>upload happycomposes 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>selectdraft归属校验 + assetContext JSON 落库round-trip+ 去重合并 + 非本人素材拒 + 非本人会话拒</li>
* </ul>
* mock BaseMapper.insert doAnswer 回填 IDMockito insert 不真写库updateById any(*.class) 消歧
*
* @author 造梦AIU6 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 providerwrapRef=透传路径):工厂 get(mmx-cli) 返回它,避免 mock provider 的 stub 噪音。 */
private final MmxCliMaterialProvider mmxProvider = new MmxCliMaterialProvider();
/** 桩工厂返回真 mmx-cli providerupload happy 路径用)。 */
private void stubProviderFactory() {
when(materialProviderFactory.get(MmxCliMaterialProvider.PROVIDER)).thenReturn(mmxProvider);
}
// ============================== upload happycomposes 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 StudioAssetContextItemcategory/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());
}
// ============================== selectdraft归属 + 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-tripupdateById 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() {
// 会话已有一条 assetContextref-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;
}
}

View File

@ -0,0 +1,40 @@
-- =============================================================================
-- 契约 #2 DB 迁移 | 模块studiogame-module-studioU6 R-MAT 素材库)| ownerstudio 路
-- 文件V24.0.0__create_game_material.sqlFlyway只新增已合入禁止修改回滚写新补偿迁移 V24.0.1 DROP TABLE
-- 内容:创作者私有素材库表 game_material六类素材 浏览/选用/上传)
-- 职责U6 R-MAT创作者维度可复用素材实体。上传时字节由 huijing-module-infra FileApi 存储,本表只登记 ref/url/元数据。
-- 边界纪律:
-- - 与 game_studio_asset会话内单步整包槽位是两个概念本表=创作者素材库(六类,归属隔离),不混用。
-- - 字节存储权威归 infraFileApi本表只持 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.refMVP=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 '租户 IDYudao 多租户兼容MVP 单租户=0',
PRIMARY KEY (`id`),
KEY `idx_owner_cat` (`creator_user_id`, `category`, `id`) COMMENT '创作者素材浏览(归属隔离 + 按类目过滤 + 按 id 倒序)'
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '创作素材库表R-MAT创作者私有六类素材归属隔离浏览/选用/上传)';