From a6f8f58d23698e9ff9d71ecaf6c3df7750084764 Mon Sep 17 00:00:00 2001 From: lili Date: Thu, 18 Jun 2026 11:17:16 -0700 Subject: [PATCH] =?UTF-8?q?fix(studio):=20U6=20codex=20=E6=95=B4=E6=94=B9?= =?UTF-8?q?=E2=80=94=E2=80=94P0=20FileApi=20=E7=A7=BB=E5=87=BA=E4=BA=8B?= =?UTF-8?q?=E5=8A=A1=20+=20P1=20magic-byte=E7=B1=BB=E5=9E=8B=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C/=E9=80=89=E7=94=A8=E6=82=B2=E8=A7=82=E9=94=81/?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E6=80=81=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../studio/enums/ErrorCodeConstants.java | 2 + .../studio/enums/MaterialCategoryEnum.java | 32 ++++- .../dal/mysql/studio/StudioSessionMapper.java | 14 ++ .../studio/StudioMaterialServiceImpl.java | 135 ++++++++++++++++-- .../studio/StudioMaterialServiceImplTest.java | 112 +++++++++++---- 5 files changed, 247 insertions(+), 48 deletions(-) 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 ce66fca5..c47e45a3 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 @@ -61,5 +61,7 @@ public interface ErrorCodeConstants { 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, "草稿会话不存在或无权操作"); + /** 选用:会话非草稿态,不可再选用素材(仅 DRAFT 可编辑 assetContext,挡 GENERATING/DONE/FAILED 被并发污染)。 */ + ErrorCode STUDIO_MATERIAL_DRAFT_NOT_EDITABLE = new ErrorCode(1_112_005_007, "仅草稿态会话可选用素材"); } 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 index 3dbedee1..eae0ce69 100644 --- 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 @@ -17,17 +17,28 @@ import java.util.Set; public enum MaterialCategoryEnum { /** 图元(精灵图/帧图,位图) */ - SPRITE("sprite", Set.of("image/")), + SPRITE("sprite", Set.of("image/"), MediaFamily.IMAGE), /** 角色(位图) */ - CHARACTER("character", Set.of("image/")), + CHARACTER("character", Set.of("image/"), MediaFamily.IMAGE), /** 特效(位图/序列帧) */ - EFFECT("effect", Set.of("image/")), + EFFECT("effect", Set.of("image/"), MediaFamily.IMAGE), /** 场景(背景图,位图) */ - SCENE("scene", Set.of("image/")), + SCENE("scene", Set.of("image/"), MediaFamily.IMAGE), /** 界面(UI 元素,位图) */ - UI("ui", Set.of("image/")), + UI("ui", Set.of("image/"), MediaFamily.IMAGE), /** 音乐/音效(音频) */ - MUSIC("music", Set.of("audio/")); + MUSIC("music", Set.of("audio/"), MediaFamily.AUDIO); + + /** + * 媒体大类(magic-byte 权威校验口径):六类素材按物理介质归并为 图像 / 音频。 + * 上传时按文件头字节嗅探出 family,与本类目应属 family 比对——挡住伪造 Content-Type 的类型欺骗。 + */ + public enum MediaFamily { + /** 图像族(PNG/JPEG/GIF/WEBP/BMP …) */ + IMAGE, + /** 音频族(MP3/WAV/OGG/FLAC/M4A …) */ + AUDIO + } /** 类目标识(落库 + 契约枚举值,禁改) */ private final String category; @@ -36,16 +47,23 @@ public enum MaterialCategoryEnum { * 用前缀匹配而非全等:覆盖同族多子类型,且不依赖前端精确上报子类型。 */ private final Set allowedMimePrefixes; + /** 本类目应属的媒体大类(magic-byte 嗅探结果须与之一致,才放行)。 */ + private final MediaFamily family; - MaterialCategoryEnum(String category, Set allowedMimePrefixes) { + MaterialCategoryEnum(String category, Set allowedMimePrefixes, MediaFamily family) { this.category = category; this.allowedMimePrefixes = allowedMimePrefixes; + this.family = family; } public String getCategory() { return category; } + public MediaFamily getFamily() { + return family; + } + /** * 按类目标识解析枚举;非六类返回 null(调用方据此抛 STUDIO_MATERIAL_CATEGORY_INVALID) * diff --git a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/mysql/studio/StudioSessionMapper.java b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/mysql/studio/StudioSessionMapper.java index b0fb477c..d0f376d3 100644 --- a/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/mysql/studio/StudioSessionMapper.java +++ b/game-cloud/game-module-studio/game-module-studio-server/src/main/java/com/wanxiang/huijing/game/module/studio/dal/mysql/studio/StudioSessionMapper.java @@ -30,4 +30,18 @@ public interface StudioSessionMapper extends BaseMapperX { .orderByDesc(StudioSessionDO::getId)); } + /** + * 按主键悲观锁查会话(SELECT ... FOR UPDATE);供 selectIntoDraft 的读-改-写串行化用。 + * + *

必须在事务内调用(FOR UPDATE 行锁随事务释放)。锁住该会话行后再 读既有 assetContext→合并→回写, + * 避免并发选用各读旧值后互相覆盖(丢素材)。竞争极低(创作者改自己草稿),加锁开销可忽略。 + * + * @param id 会话主键 + * @return 会话;查不到返回 null + */ + default StudioSessionDO selectByIdForUpdate(Long id) { + return selectOne(new LambdaQueryWrapperX() + .eq(StudioSessionDO::getId, id).last("FOR UPDATE")); + } + } 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 index 179b53b7..6b516527 100644 --- 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 @@ -10,6 +10,8 @@ import com.wanxiang.huijing.game.module.studio.dal.dataobject.studio.StudioSessi 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.enums.MaterialCategoryEnum.MediaFamily; +import com.wanxiang.huijing.game.module.studio.enums.StudioSessionStatusEnum; 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; @@ -73,8 +75,15 @@ public class StudioMaterialServiceImpl implements StudioMaterialService { @Resource private FileApi fileApi; + /** + * 上传素材。 + * + *

刻意不加 {@code @Transactional}:方法只有单条 insert(无需事务原子性),且核心动作 {@code fileApi.createFile} + * 是外部对象存储 IO(红线 §4.1:外部 IO 不得置于 DB 事务内——长占连接/锁,且事务回滚也无法撤销对象存储已写入的文件)。 + *

补偿口径:FileApi 无 delete 能力,故 insert 失败后存储里会残留孤儿文件,无法程序化清理; + * 此处 best-effort 打 error 日志(含 ref + userId + 存储路径)交由运维人工核查清理,并抛 STORE_FAIL。 + */ @Override - @Transactional(rollbackFor = Exception.class) public StudioAssetContextItem upload(StudioMaterialUploadReqVO reqVO, Long userId) { // 1) 六类校验(前置拒,前端不是边界) MaterialCategoryEnum categoryEnum = MaterialCategoryEnum.of(reqVO.getCategory()); @@ -95,18 +104,32 @@ public class StudioMaterialServiceImpl implements StudioMaterialService { size, MAX_FILE_SIZE_BYTES, reqVO.getCategory(), userId); throw exception(STUDIO_MATERIAL_FILE_TOO_LARGE); } - // 4) MIME 白名单(按类目,挡类型伪装;如 music 只收 audio/*,图类只收 image/*) + // 4) MIME 廉价初筛(Content-Type 可伪造,仅作第一道便宜过滤,不作权威;权威校验见步骤 6 magic-byte) String mimeType = file.getContentType(); if (!categoryEnum.isMimeAllowed(mimeType)) { - log.warn("[upload] MIME 不在类目白名单 category={} mime={} userId={}", + log.warn("[upload] MIME 不在类目白名单(初筛)category={} mime={} userId={}", reqVO.getCategory(), mimeType, userId); throw exception(STUDIO_MATERIAL_MIME_NOT_ALLOWED); } - // 5) composes infra FileApi 存字节拿访问路径(外部文件 IO,带中文日志 + 异常包装) + // 5) 一次性读字节(嗅探与 createFile 复用同一份 content,避免重复读流) String name = file.getOriginalFilename(); + byte[] content; + try { + content = file.getBytes(); // 读 MultipartFile 字节(标准 Spring API,IOException 由 catch 兜底) + } catch (Exception e) { + log.error("[upload] 读取上传文件字节失败 name={} category={} userId={}", name, reqVO.getCategory(), userId, e); + throw exception(STUDIO_MATERIAL_STORE_FAIL); + } + // 6) magic-byte 权威类型校验(挡 Content-Type 伪造):嗅探文件头大类,须与本类目应属 family 一致 + MediaFamily family = sniffFamily(content); + if (family == null || family != categoryEnum.getFamily()) { + log.warn("[upload] magic-byte 类型校验未过 category={} expectFamily={} sniffedFamily={} declaredMime={} userId={}", + reqVO.getCategory(), categoryEnum.getFamily(), family, mimeType, userId); + throw exception(STUDIO_MATERIAL_MIME_NOT_ALLOWED); + } + // 7) composes infra FileApi 存字节拿访问路径(外部文件 IO,带中文日志 + 异常包装;不在事务内) 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); @@ -116,10 +139,10 @@ public class StudioMaterialServiceImpl implements StudioMaterialService { 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=访问路径) + // 8) provider 把 infra 路径包装为平台素材 ref(MVP mmx-cli:ref=访问路径) MaterialProvider provider = materialProviderFactory.get(MmxCliMaterialProvider.PROVIDER); String ref = provider.wrapRef(storedPath); - // 7) 登记素材实体(归属本人) + // 9) 登记素材实体(归属本人)。FileApi 已写文件、无法回滚——insert 失败则 best-effort 记孤儿文件交运维清理。 StudioMaterialDO material = new StudioMaterialDO(); material.setCreatorUserId(userId); material.setCategory(categoryEnum.getCategory()); @@ -129,13 +152,97 @@ public class StudioMaterialServiceImpl implements StudioMaterialService { material.setProvider(provider.getProvider()); material.setSizeBytes(size); material.setMimeType(mimeType); - studioMaterialMapper.insert(material); + try { + studioMaterialMapper.insert(material); + } catch (Exception e) { + // 文件已落存储但登记失败:FileApi 无 delete,残留孤儿文件无法程序化清理,记 error 交运维人工核查。 + log.error("[upload] 素材登记入库失败,存储已写入孤儿文件待人工清理 ref={} url={} category={} userId={}", + ref, storedPath, reqVO.getCategory(), userId, e); + throw exception(STUDIO_MATERIAL_STORE_FAIL); + } log.info("[upload] 素材已登记 materialId={} category={} provider={} size={}B userId={}", material.getId(), categoryEnum.getCategory(), provider.getProvider(), size, userId); - // 8) 返回输入态引用项(直接喂 assetContext.ref) + // 10) 返回输入态引用项(直接喂 assetContext.ref) return StudioMaterialConvert.toAssetContextItem(material); } + /** + * 按文件头 magic bytes 嗅探媒体大类(权威类型门,挡 Content-Type 伪造)。 + * + *

已支持的文件签名(MVP 约定口径,覆盖六类素材常见格式;SVG/AAC 等后续可扩; + * 深度内容安全扫描是合规门 P2 职责,不在此处): + *

    + *
  • 图像:PNG(89 50 4E 47)、JPEG(FF D8 FF)、GIF(47 49 46 38)、WEBP(RIFF…WEBP)、BMP(42 4D);
  • + *
  • 音频:MP3(ID3=49 44 33 或帧同步 FF Fx)、WAV(RIFF…WAVE)、OGG(4F 67 67 53)、FLAC(66 4C 61 43)、 + * M4A/MP4(偏移 4 处 ftyp=66 74 79 70)。
  • + *
+ * + * @param head 文件起始字节(读前 ~16 字节即可判定) + * @return 命中的媒体大类;无法识别返回 null + */ + private MediaFamily sniffFamily(byte[] head) { + if (head == null || head.length < 4) { + return null; + } + int b0 = head[0] & 0xFF, b1 = head[1] & 0xFF, b2 = head[2] & 0xFF, b3 = head[3] & 0xFF; + + // ---- 图像族 ---- + // PNG: 89 50 4E 47 + if (b0 == 0x89 && b1 == 0x50 && b2 == 0x4E && b3 == 0x47) { + return MediaFamily.IMAGE; + } + // JPEG: FF D8 FF + if (b0 == 0xFF && b1 == 0xD8 && b2 == 0xFF) { + return MediaFamily.IMAGE; + } + // GIF: 47 49 46 38 (GIF8) + if (b0 == 0x47 && b1 == 0x49 && b2 == 0x46 && b3 == 0x38) { + return MediaFamily.IMAGE; + } + // BMP: 42 4D (BM) + if (b0 == 0x42 && b1 == 0x4D) { + return MediaFamily.IMAGE; + } + + // ---- 音频族(先判无歧义的固定签名)---- + // ID3(MP3): 49 44 33 + if (b0 == 0x49 && b1 == 0x44 && b2 == 0x33) { + return MediaFamily.AUDIO; + } + // MP3 帧同步: FF Fx(11 位同步位;F0~FF 的高半字节) + if (b0 == 0xFF && (b1 & 0xF0) == 0xF0) { + return MediaFamily.AUDIO; + } + // OGG: 4F 67 67 53 (OggS) + if (b0 == 0x4F && b1 == 0x67 && b2 == 0x67 && b3 == 0x53) { + return MediaFamily.AUDIO; + } + // FLAC: 66 4C 61 43 (fLaC) + if (b0 == 0x66 && b1 == 0x4C && b2 == 0x61 && b3 == 0x43) { + return MediaFamily.AUDIO; + } + + // ---- RIFF 容器:RIFF(52 49 46 46) + 偏移 8 处子类型区分 WEBP(图)/WAVE(音) ---- + if (b0 == 0x52 && b1 == 0x49 && b2 == 0x46 && b3 == 0x46 && head.length >= 12) { + int f8 = head[8] & 0xFF, f9 = head[9] & 0xFF, f10 = head[10] & 0xFF, f11 = head[11] & 0xFF; + // WEBP: 57 45 42 50 + if (f8 == 0x57 && f9 == 0x45 && f10 == 0x42 && f11 == 0x50) { + return MediaFamily.IMAGE; + } + // WAVE: 57 41 56 45 + if (f8 == 0x57 && f9 == 0x41 && f10 == 0x56 && f11 == 0x45) { + return MediaFamily.AUDIO; + } + } + + // ---- ISO BMFF / M4A/MP4:偏移 4 处 ftyp(66 74 79 70) ---- + if (head.length >= 8 && head[4] == 0x66 && head[5] == 0x74 && head[6] == 0x79 && head[7] == 0x70) { + return MediaFamily.AUDIO; + } + + return null; // 未识别 + } + @Override public PageResult browse(StudioMaterialPageReqVO reqVO, Long userId) { // 浏览前可选 category 合法性校验(传了就必须合法;不传=全部六类) @@ -150,12 +257,18 @@ public class StudioMaterialServiceImpl implements StudioMaterialService { @Override @Transactional(rollbackFor = Exception.class) public List selectIntoDraft(StudioMaterialSelectReqVO reqVO, Long userId) { - // 1) 校验目标草稿会话归属(本人草稿,前端不是边界) - StudioSessionDO session = studioSessionMapper.selectById(reqVO.getSessionId()); + // 1) 悲观锁查目标会话并校验归属(FOR UPDATE 锁住会话行,串行化并发选用的「读既有→合并→回写」,避免互相覆盖丢素材) + StudioSessionDO session = studioSessionMapper.selectByIdForUpdate(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); } + // 1.1) 草稿态守卫:仅 DRAFT 可选用素材(挡 GENERATING/DONE/FAILED——这些态再改 assetContext 会污染已提交/已生成的会话) + if (!Objects.equals(session.getStatus(), StudioSessionStatusEnum.DRAFT.getStatus())) { + log.warn("[select] 会话非草稿态不可选用素材 sessionId={} status={} userId={}", + session.getId(), session.getStatus(), userId); + throw exception(STUDIO_MATERIAL_DRAFT_NOT_EDITABLE); + } // 2) 按 materialIds 逐个取本人素材(归属校验:任一非本人/不存在即拒,不静默跳过) List selected = new ArrayList<>(); for (Long materialId : reqVO.getMaterialIds()) { 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 index 6386d84c..7b9f378e 100644 --- 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 @@ -8,6 +8,7 @@ import com.wanxiang.huijing.game.module.studio.dal.dataobject.studio.StudioMater 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.StudioSessionStatusEnum; 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; @@ -38,10 +39,10 @@ import static org.mockito.Mockito.*; *
  • upload happy:composes FileApi 存字节 → 落库 → 返回 valid StudioAssetContextItem;
  • *
  • upload 六类校验:非法类目前置拒(不调 FileApi);
  • *
  • upload size 红线:超 10MB 前置拒;
  • - *
  • upload MIME 红线:music 收到 image/* → 拒(类目白名单);image 类收到 audio/* → 拒;
  • + *
  • upload 类型红线(magic-byte 权威门):PNG 字节但 music 类目 → 拒;伪造 Content-Type=image/png 但非图字节 → 拒(证头不可绕);Content-Type 错族廉价初筛先拒;music+真 MP3 头放行;
  • *
  • upload 空文件:前置拒;FileApi 返回空路径 → STORE_FAIL;
  • *
  • browse:归属隔离(mapper 强制传当前登录 userId)+ 非法 category 过滤拒;
  • - *
  • select→draft:归属校验 + assetContext JSON 落库(round-trip)+ 去重合并 + 非本人素材拒 + 非本人会话拒。
  • + *
  • select→draft:悲观锁取会话(selectByIdForUpdate)+ 归属校验 + 草稿态守卫(非 DRAFT 拒)+ assetContext JSON 落库(round-trip)+ 去重合并 + 非本人素材拒 + 非本人会话拒。
  • * * mock BaseMapper.insert 用 doAnswer 回填 ID(Mockito 下 insert 不真写库);updateById 用 any(*.class) 消歧。 * @@ -83,7 +84,8 @@ class StudioMaterialServiceImplTest extends BaseMockitoUnitTest { 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); + // 喂真 PNG magic bytes(体长 2048)→ magic-byte 嗅探判定为 IMAGE,与 sprite 类目一致,放行 + StudioMaterialUploadReqVO reqVO = uploadReq("sprite", "陨石.png", "image/png", "material/sprite", pngBytes(2048)); StudioAssetContextItem item = studioMaterialService.upload(reqVO, USER_ID); // 返回 valid StudioAssetContextItem(category/ref/url/provider 四元组) @@ -107,7 +109,7 @@ class StudioMaterialServiceImplTest extends BaseMockitoUnitTest { @Test void testUpload_invalidCategory_rejected_noFileApi() { - StudioMaterialUploadReqVO reqVO = uploadReq("weapon", "x.png", "image/png", null, 100); // 非六类 + StudioMaterialUploadReqVO reqVO = uploadReq("weapon", "x.png", "image/png", null, pngBytes(100)); // 非六类 ServiceException ex = assertThrows(ServiceException.class, () -> studioMaterialService.upload(reqVO, USER_ID)); assertEquals(STUDIO_MATERIAL_CATEGORY_INVALID.getCode(), ex.getCode()); verifyNoInteractions(fileApi); // 前置拒,不触达存储 @@ -119,8 +121,8 @@ class StudioMaterialServiceImplTest extends BaseMockitoUnitTest { @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 字节长度决定,这里手工造一个超限文件 + StudioMaterialUploadReqVO reqVO = uploadReq("sprite", "big.png", "image/png", null, pngBytes(16)); + // size 由 MockMultipartFile 字节长度决定,这里手工造一个超限文件(覆写 getSize;超限在读字节/嗅探前即拒) reqVO.setFile(new MockMultipartFile("file", "big.png", "image/png", new byte[(int) 0]) { @Override public long getSize() { @@ -136,35 +138,47 @@ class StudioMaterialServiceImplTest extends BaseMockitoUnitTest { verifyNoInteractions(fileApi); } - // ============================== upload MIME 红线(类目白名单)============================== + // ============================== upload 类型红线(magic-byte 权威门,挡 Content-Type 伪造)============================== @Test - void testUpload_musicCategory_rejectsImageMime() { - // music 类只收 audio/*,收到 image/png → 拒(挡类型伪装) - StudioMaterialUploadReqVO reqVO = uploadReq("music", "fake.mp3", "image/png", null, 100); + void testUpload_pngBytesButMusicCategory_rejectedByMagicByte() { + // 字节是真 PNG(族=IMAGE),但类目声明 music(族应=AUDIO)→ magic-byte 嗅探判定族不符 → 拒 + // 注:Content-Type 给 audio/mpeg 故意先过廉价初筛,证明真正拦下它的是 magic-byte 而非 Content-Type + StudioMaterialUploadReqVO reqVO = uploadReq("music", "fake.mp3", "audio/mpeg", null, pngBytes(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_spoofedImageContentTypeButNonImageBytes_rejected() { + // 前端伪造 Content-Type=image/png(过得了廉价初筛),但字节是真 MP3(族=AUDIO,非 IMAGE) + // → magic-byte 权威门判定族不符 → 拒。坐实:伪造的 Content-Type 无法绕过类型校验。 + StudioMaterialUploadReqVO reqVO = uploadReq("sprite", "evil.png", "image/png", null, mp3Bytes(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_cheapMimeFilter_rejectsWrongContentTypeFirst() { + // image 类(sprite)收到 Content-Type=audio/mpeg → 廉价初筛即拒(不依赖 magic-byte,便宜先挡) + StudioMaterialUploadReqVO reqVO = uploadReq("sprite", "fake.png", "audio/mpeg", null, pngBytes(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 → 放行(正路) + void testUpload_musicCategory_acceptsRealMp3Bytes() { + // music 类 + 真 ID3(MP3) magic bytes(族=AUDIO,一致)→ 放行(正路) 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); + uploadReq("music", "bgm.mp3", "audio/mpeg", null, mp3Bytes(100)), USER_ID); assertEquals("music", item.getCategory()); assertEquals("/infra/file/bgm.mp3", item.getRef()); } @@ -185,7 +199,8 @@ class StudioMaterialServiceImplTest extends BaseMockitoUnitTest { 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); + // 喂真 PNG magic bytes 过类型门,方能触达 createFile(验证返回空白路径 → STORE_FAIL) + StudioMaterialUploadReqVO reqVO = uploadReq("sprite", "x.png", "image/png", null, pngBytes(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)); @@ -234,7 +249,7 @@ class StudioMaterialServiceImplTest extends BaseMockitoUnitTest { void testSelectIntoDraft_writesAssetContextJson_roundTrip() { // 会话本人草稿(无既有 assetContext) StudioSessionDO session = session(512L, USER_ID, null); - when(studioSessionMapper.selectById(512L)).thenReturn(session); + when(studioSessionMapper.selectByIdForUpdate(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")); @@ -260,7 +275,7 @@ class StudioMaterialServiceImplTest extends BaseMockitoUnitTest { // 会话已有一条 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(studioSessionMapper.selectByIdForUpdate(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")); @@ -276,7 +291,7 @@ class StudioMaterialServiceImplTest extends BaseMockitoUnitTest { 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(studioSessionMapper.selectByIdForUpdate(512L)).thenReturn(session); when(studioMaterialMapper.selectByIdAndOwner(88231L, USER_ID)).thenReturn(material(88231L, USER_ID, "sprite")); List result = @@ -288,7 +303,7 @@ class StudioMaterialServiceImplTest extends BaseMockitoUnitTest { @Test void testSelectIntoDraft_materialNotOwned_rejected() { StudioSessionDO session = session(512L, USER_ID, null); - when(studioSessionMapper.selectById(512L)).thenReturn(session); + when(studioSessionMapper.selectByIdForUpdate(512L)).thenReturn(session); // 素材非本人(mapper 归属查返 null)→ 拒 when(studioMaterialMapper.selectByIdAndOwner(88231L, USER_ID)).thenReturn(null); ServiceException ex = assertThrows(ServiceException.class, @@ -300,7 +315,7 @@ class StudioMaterialServiceImplTest extends BaseMockitoUnitTest { @Test void testSelectIntoDraft_sessionNotOwner_rejected() { // 会话属他人 → 归属拒,不取素材、不写草稿 - when(studioSessionMapper.selectById(512L)).thenReturn(session(512L, 1L, null)); + when(studioSessionMapper.selectByIdForUpdate(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()); @@ -310,20 +325,50 @@ class StudioMaterialServiceImplTest extends BaseMockitoUnitTest { @Test void testSelectIntoDraft_sessionNotExists_rejected() { - when(studioSessionMapper.selectById(512L)).thenReturn(null); + when(studioSessionMapper.selectByIdForUpdate(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()); } + @Test + void testSelectIntoDraft_nonDraftSession_rejectedByStatusGuard() { + // 会话本人但已 GENERATING(非草稿态)→ 草稿态守卫拒(仅 DRAFT 可选用素材),不取素材、不写会话 + when(studioSessionMapper.selectByIdForUpdate(512L)) + .thenReturn(session(512L, USER_ID, null, StudioSessionStatusEnum.GENERATING.getStatus())); + ServiceException ex = assertThrows(ServiceException.class, + () -> studioMaterialService.selectIntoDraft(selectReq(512L, List.of(88231L)), USER_ID)); + assertEquals(STUDIO_MATERIAL_DRAFT_NOT_EDITABLE.getCode(), ex.getCode()); + verify(studioMaterialMapper, never()).selectByIdAndOwner(any(), any()); // 守卫前置拒,不取素材 + verify(studioSessionMapper, never()).updateById(any(StudioSessionDO.class)); // 不写会话 + } + // ============================== 测试夹具 ============================== + /** 真 PNG 文件头 magic bytes(89 50 4E 47 …)+ 补足体长,供图类 happy 路径过 magic-byte 嗅探。 */ + private static byte[] pngBytes(int totalLen) { + byte[] b = new byte[Math.max(totalLen, 8)]; + b[0] = (byte) 0x89; b[1] = 'P'; b[2] = 'N'; b[3] = 'G'; + b[4] = (byte) 0x0D; b[5] = (byte) 0x0A; b[6] = (byte) 0x1A; b[7] = (byte) 0x0A; + return b; + } + + /** 真 ID3(MP3) 文件头 magic bytes(49 44 33 …)+ 补足体长,供 music happy 路径过 magic-byte 嗅探。 */ + private static byte[] mp3Bytes(int totalLen) { + byte[] b = new byte[Math.max(totalLen, 4)]; + b[0] = 'I'; b[1] = 'D'; b[2] = '3'; b[3] = 0x03; + return b; + } + + /** + * 造上传请求;content 直接给字节(含 magic bytes),mime 为前端声明的 Content-Type(可与字节不符,用于伪造测试)。 + */ private static StudioMaterialUploadReqVO uploadReq(String category, String filename, String mime, - String directory, int byteLen) { + String directory, byte[] content) { StudioMaterialUploadReqVO reqVO = new StudioMaterialUploadReqVO(); reqVO.setCategory(category); reqVO.setDirectory(directory); - reqVO.setFile(new MockMultipartFile("file", filename, mime, new byte[byteLen])); + reqVO.setFile(new MockMultipartFile("file", filename, mime, content)); return reqVO; } @@ -339,11 +384,18 @@ class StudioMaterialServiceImplTest extends BaseMockitoUnitTest { return m; } + /** 默认草稿态会话(DRAFT):素材选用合法前提是会话仍为草稿态。 */ private static StudioSessionDO session(Long id, Long creatorUserId, String attachments) { + return session(id, creatorUserId, attachments, StudioSessionStatusEnum.DRAFT.getStatus()); + } + + /** 指定状态的会话(草稿态守卫测试用:传 GENERATING/DONE 验证拒绝)。 */ + private static StudioSessionDO session(Long id, Long creatorUserId, String attachments, Integer status) { StudioSessionDO s = new StudioSessionDO(); s.setId(id); s.setCreatorUserId(creatorUserId); s.setAttachments(attachments); + s.setStatus(status); return s; }