fix(studio): U6 codex 整改——P0 FileApi 移出事务 + P1 magic-byte类型校验/选用悲观锁/草稿态守卫

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-18 11:17:16 -07:00
parent 09ced58930
commit a6f8f58d23
5 changed files with 247 additions and 48 deletions

View File

@ -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, "仅草稿态会话可选用素材");
}

View File

@ -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<String> allowedMimePrefixes;
/** 本类目应属的媒体大类(magic-byte 嗅探结果须与之一致,才放行)。 */
private final MediaFamily family;
MaterialCategoryEnum(String category, Set<String> allowedMimePrefixes) {
MaterialCategoryEnum(String category, Set<String> 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)
*

View File

@ -30,4 +30,18 @@ public interface StudioSessionMapper extends BaseMapperX<StudioSessionDO> {
.orderByDesc(StudioSessionDO::getId));
}
/**
* 按主键悲观锁查会话(SELECT ... FOR UPDATE);供 selectIntoDraft 的读-改-写串行化用。
*
* <p>必须在事务内调用(FOR UPDATE 行锁随事务释放)。锁住该会话行后再 读既有 assetContext→合并→回写,
* 避免并发选用各读旧值后互相覆盖(丢素材)。竞争极低(创作者改自己草稿),加锁开销可忽略。
*
* @param id 会话主键
* @return 会话;查不到返回 null
*/
default StudioSessionDO selectByIdForUpdate(Long id) {
return selectOne(new LambdaQueryWrapperX<StudioSessionDO>()
.eq(StudioSessionDO::getId, id).last("FOR UPDATE"));
}
}

View File

@ -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;
/**
* 上传素材。
*
* <p>刻意不加 {@code @Transactional}:方法只有单条 insert(无需事务原子性),且核心动作 {@code fileApi.createFile}
* 是外部对象存储 IO(红线 §4.1:外部 IO 不得置于 DB 事务内——长占连接/锁,且事务回滚也无法撤销对象存储已写入的文件)。
* <p>补偿口径: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 伪造)。
*
* <p>已支持的文件签名(MVP 约定口径,覆盖六类素材常见格式;SVG/AAC 等后续可扩;
* 深度内容安全扫描是合规门 P2 职责,不在此处):
* <ul>
* <li>图像:PNG(89 50 4E 47)、JPEG(FF D8 FF)、GIF(47 49 46 38)、WEBP(RIFF…WEBP)、BMP(42 4D);</li>
* <li>音频: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)。</li>
* </ul>
*
* @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<StudioMaterialDO> browse(StudioMaterialPageReqVO reqVO, Long userId) {
// 浏览前可选 category 合法性校验(传了就必须合法;不传=全部六类)
@ -150,12 +257,18 @@ public class StudioMaterialServiceImpl implements StudioMaterialService {
@Override
@Transactional(rollbackFor = Exception.class)
public List<StudioAssetContextItem> 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<StudioAssetContextItem> selected = new ArrayList<>();
for (Long materialId : reqVO.getMaterialIds()) {

View File

@ -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.*;
* <li>upload happy:composes FileApi 存字节 → 落库 → 返回 valid StudioAssetContextItem;</li>
* <li>upload 六类校验:非法类目前置拒(不调 FileApi);</li>
* <li>upload size 红线:超 10MB 前置拒;</li>
* <li>upload MIME 红线:music 收到 image/* → 拒(类目白名单);image 类收到 audio/* → 拒;</li>
* <li>upload 类型红线(magic-byte 权威门):PNG 字节但 music 类目 → 拒;伪造 Content-Type=image/png 但非图字节 → 拒(证头不可绕);Content-Type 错族廉价初筛先拒;music+真 MP3 头放行;</li>
* <li>upload 空文件:前置拒;FileApi 返回空路径 → STORE_FAIL;</li>
* <li>browse:归属隔离(mapper 强制传当前登录 userId)+ 非法 category 过滤拒;</li>
* <li>select→draft:归属校验 + assetContext JSON 落库(round-trip)+ 去重合并 + 非本人素材拒 + 非本人会话拒。</li>
* <li>select→draft:悲观锁取会话(selectByIdForUpdate)+ 归属校验 + 草稿态守卫(非 DRAFT 拒)+ assetContext JSON 落库(round-trip)+ 去重合并 + 非本人素材拒 + 非本人会话拒。</li>
* </ul>
* 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<StudioAssetContextItem> 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;
}