From 09ced589306b5e6dfb7fc5e81542b18291053497 Mon Sep 17 00:00:00 2001 From: lili Date: Thu, 18 Jun 2026 10:54:56 -0700 Subject: [PATCH] =?UTF-8?q?feat(studio):=20U6=20R-MAT=20=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E5=AE=9E=E4=BD=93/=E6=B5=8F=E8=A7=88/=E9=80=89=E7=94=A8/?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0(compose=20infra=20FileApi)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- contracts/api-schemas/studio.yaml | 116 ++++++ .../V24.0.0__create_game_material.sql | 40 ++ .../studio/enums/ErrorCodeConstants.java | 16 + .../studio/enums/MaterialCategoryEnum.java | 89 +++++ .../game-module-studio-server/pom.xml | 7 + .../studio/AppStudioMaterialController.java | 73 ++++ .../studio/vo/StudioMaterialPageReqVO.java | 24 ++ .../app/studio/vo/StudioMaterialRespVO.java | 47 +++ .../studio/vo/StudioMaterialSelectReqVO.java | 32 ++ .../studio/vo/StudioMaterialUploadReqVO.java | 33 ++ .../convert/studio/StudioMaterialConvert.java | 55 +++ .../dataobject/studio/StudioMaterialDO.java | 63 +++ .../mysql/studio/StudioMaterialMapper.java | 51 +++ .../framework/provider/MaterialProvider.java | 32 ++ .../provider/MaterialProviderFactory.java | 45 +++ .../provider/MmxCliMaterialProvider.java | 30 ++ .../service/studio/StudioMaterialService.java | 56 +++ .../studio/StudioMaterialServiceImpl.java | 221 +++++++++++ .../studio/StudioMaterialServiceImplTest.java | 364 ++++++++++++++++++ .../V24.0.0__create_game_material.sql | 40 ++ 20 files changed, 1434 insertions(+) create mode 100644 contracts/db-schemas/V24.0.0__create_game_material.sql create mode 100644 game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/MaterialCategoryEnum.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/AppStudioMaterialController.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialPageReqVO.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialRespVO.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialSelectReqVO.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialUploadReqVO.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/convert/studio/StudioMaterialConvert.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/dataobject/studio/StudioMaterialDO.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/mysql/studio/StudioMaterialMapper.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/framework/provider/MaterialProvider.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/framework/provider/MaterialProviderFactory.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/framework/provider/MmxCliMaterialProvider.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioMaterialService.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioMaterialServiceImpl.java create mode 100644 game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioMaterialServiceImplTest.java create mode 100644 game-cloud/huijing-server/src/main/resources/db/migration/V24.0.0__create_game_material.sql diff --git a/contracts/api-schemas/studio.yaml b/contracts/api-schemas/studio.yaml index 66b7e635..0344870c 100644 --- a/contracts/api-schemas/studio.yaml +++ b/contracts/api-schemas/studio.yaml @@ -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 } diff --git a/contracts/db-schemas/V24.0.0__create_game_material.sql b/contracts/db-schemas/V24.0.0__create_game_material.sql new file mode 100644 index 00000000..cd79ff8b --- /dev/null +++ b/contracts/db-schemas/V24.0.0__create_game_material.sql @@ -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;创作者私有六类素材,归属隔离浏览/选用/上传)'; diff --git a/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/ErrorCodeConstants.java b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/ErrorCodeConstants.java index 80785f2a..ce66fca5 100644 --- a/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/ErrorCodeConstants.java +++ b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/ErrorCodeConstants.java @@ -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, "草稿会话不存在或无权操作"); + } diff --git a/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/MaterialCategoryEnum.java b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/MaterialCategoryEnum.java new file mode 100644 index 00000000..3dbedee1 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-api/src/main/java/com/wanxiang/huijing/game/module/studio/enums/MaterialCategoryEnum.java @@ -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 allowedMimePrefixes; + + MaterialCategoryEnum(String category, Set 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); + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/pom.xml b/game-cloud/game-module-studio/game-module-studio-server/pom.xml index 302edc50..b75386f4 100644 --- a/game-cloud/game-module-studio/game-module-studio-server/pom.xml +++ b/game-cloud/game-module-studio/game-module-studio-server/pom.xml @@ -53,6 +53,13 @@ ${revision} + + + com.wanxiang + huijing-module-infra-api + ${revision} + + com.wanxiang diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/AppStudioMaterialController.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/AppStudioMaterialController.java new file mode 100644 index 00000000..d5b828a8 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/AppStudioMaterialController.java @@ -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 upload(@Valid StudioMaterialUploadReqVO reqVO) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + return success(studioMaterialService.upload(reqVO, userId)); + } + + @GetMapping("/browse") + @Operation(summary = "浏览素材", description = "owner-isolated 分页(只见本人素材),可选按 category 过滤六类") + public CommonResult> browse(@Valid StudioMaterialPageReqVO reqVO) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + PageResult 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> select(@Valid @RequestBody StudioMaterialSelectReqVO reqVO) { + Long userId = SecurityFrameworkUtils.getLoginUserId(); + return success(studioMaterialService.selectIntoDraft(reqVO, userId)); + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialPageReqVO.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialPageReqVO.java new file mode 100644 index 00000000..97bef6fd --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialPageReqVO.java @@ -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; + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialRespVO.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialRespVO.java new file mode 100644 index 00000000..7df10bb6 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialRespVO.java @@ -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; + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialSelectReqVO.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialSelectReqVO.java new file mode 100644 index 00000000..68c697e7 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialSelectReqVO.java @@ -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 materialIds; + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialUploadReqVO.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialUploadReqVO.java new file mode 100644 index 00000000..acd98bb7 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/controller/app/studio/vo/StudioMaterialUploadReqVO.java @@ -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; + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/convert/studio/StudioMaterialConvert.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/convert/studio/StudioMaterialConvert.java new file mode 100644 index 00000000..a0dc3b8f --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/convert/studio/StudioMaterialConvert.java @@ -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; + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/dataobject/studio/StudioMaterialDO.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/dataobject/studio/StudioMaterialDO.java new file mode 100644 index 00000000..e75f0472 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/dataobject/studio/StudioMaterialDO.java @@ -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; + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/mysql/studio/StudioMaterialMapper.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/mysql/studio/StudioMaterialMapper.java new file mode 100644 index 00000000..f1e2d56e --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/mysql/studio/StudioMaterialMapper.java @@ -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 { + + /** + * 素材浏览分页(owner-isolated,命中 idx_owner_cat) + * + *

归属隔离:creatorUserId 由 Service 用当前登录用户强制注入,eq 硬过滤(非 eqIfPresent), + * 保证不会因调用方漏传而返回他人素材;category 可空(不传=该创作者全部类目)。按 id 倒序(新上传在前)。 + * + * @param creatorUserId 当前登录创作者 ID(归属边界,必填) + * @param reqVO 分页 + 可选 category 过滤 + * @return 分页结果(仅本人素材) + */ + default PageResult selectPageByOwner(Long creatorUserId, StudioMaterialPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .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() + .eq(StudioMaterialDO::getId, id) + .eq(StudioMaterialDO::getCreatorUserId, creatorUserId)); + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/framework/provider/MaterialProvider.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/framework/provider/MaterialProvider.java new file mode 100644 index 00000000..b79922e6 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/framework/provider/MaterialProvider.java @@ -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 + * + *

MVP 口径:ref 直接取 infra 返回的访问路径(与 url 同源,自描述可寻址)。真实 mmx-cli later 由其 + * 素材服务返回 assetId 作 ref(届时覆写本方法),消费侧(assetContext.ref→sourceProject.assets[])不变。 + * + * @param storedFilePath infra FileApi.createFile 返回的文件访问路径(非空) + * @return 平台素材 ref + */ + String wrapRef(String storedFilePath); + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/framework/provider/MaterialProviderFactory.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/framework/provider/MaterialProviderFactory.java new file mode 100644 index 00000000..d4a6d83b --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/framework/provider/MaterialProviderFactory.java @@ -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 providerMap; + + /** + * 构造时收集所有 MaterialProvider 实现建索引 + * + * @param providers Spring 容器中全部 MaterialProvider 实现 + */ + public MaterialProviderFactory(List 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); + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/framework/provider/MmxCliMaterialProvider.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/framework/provider/MmxCliMaterialProvider.java new file mode 100644 index 00000000..5ed8fd62 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/framework/provider/MmxCliMaterialProvider.java @@ -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; + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioMaterialService.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioMaterialService.java new file mode 100644 index 00000000..6b322788 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioMaterialService.java @@ -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 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 selectIntoDraft(StudioMaterialSelectReqVO reqVO, Long userId); + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioMaterialServiceImpl.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioMaterialServiceImpl.java new file mode 100644 index 00000000..179b53b7 --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioMaterialServiceImpl.java @@ -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 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 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 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 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 去重,保持插入顺序:既有在前、新增在后) + * + *

既有 attachments 可能为旧自由文本/空/合法 assetContext JSON:用 JsonUtils.parseArray 安全解析, + * 解析失败(非数组/旧格式)则视为空既有上下文(best-effort,不阻断选用),仅以新选素材为准。 + * + * @param existingJson 会话既有 attachments JSON(可空/旧格式) + * @param selected 本次新选素材引用项 + * @return 去重合并后的引用项列表 + */ + private List mergeAssetContext(String existingJson, List selected) { + List result = new ArrayList<>(); + Set seenRefs = new LinkedHashSet<>(); + // 解析既有 assetContext(best-effort:旧文本/非数组 → 空既有) + if (StringUtils.hasText(existingJson)) { + try { + List 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; + } + +} diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioMaterialServiceImplTest.java b/game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioMaterialServiceImplTest.java new file mode 100644 index 00000000..6386d84c --- /dev/null +++ b/game-cloud/game-module-studio/game-module-studio-server/src/test/java/com/wanxiang/huijing/game/module/studio/service/studio/StudioMaterialServiceImplTest.java @@ -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) + * + *

遵循 studio 模块既有测试范式({@link BaseMockitoUnitTest}:mock 自模块 Mapper + 跨模块 -api,mock FileApi 不打真实存储)。 + * 覆盖 KTD4 测试矩阵: + *

    + *
  • upload happy:composes FileApi 存字节 → 落库 → 返回 valid StudioAssetContextItem;
  • + *
  • upload 六类校验:非法类目前置拒(不调 FileApi);
  • + *
  • upload size 红线:超 10MB 前置拒;
  • + *
  • upload MIME 红线:music 收到 image/* → 拒(类目白名单);image 类收到 audio/* → 拒;
  • + *
  • upload 空文件:前置拒;FileApi 返回空路径 → STORE_FAIL;
  • + *
  • browse:归属隔离(mapper 强制传当前登录 userId)+ 非法 category 过滤拒;
  • + *
  • select→draft:归属校验 + assetContext JSON 落库(round-trip)+ 去重合并 + 非本人素材拒 + 非本人会话拒。
  • + *
+ * 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 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 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 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 result = + studioMaterialService.selectIntoDraft(selectReq(512L, List.of(88231L, 88232L)), USER_ID); + + // 返回合并后两条 + assertEquals(2, result.size()); + // assetContext JSON 落库 round-trip:updateById 的 attachments 反序列化回来 = 两条 ref + ArgumentCaptor captor = ArgumentCaptor.forClass(StudioSessionDO.class); + verify(studioSessionMapper).updateById(captor.capture()); + String json = captor.getValue().getAttachments(); + assertNotNull(json); + List 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 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 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 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; + } + +} diff --git a/game-cloud/huijing-server/src/main/resources/db/migration/V24.0.0__create_game_material.sql b/game-cloud/huijing-server/src/main/resources/db/migration/V24.0.0__create_game_material.sql new file mode 100644 index 00000000..cd79ff8b --- /dev/null +++ b/game-cloud/huijing-server/src/main/resources/db/migration/V24.0.0__create_game_material.sql @@ -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;创作者私有六类素材,归属隔离浏览/选用/上传)';