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:
parent
09ced58930
commit
a6f8f58d23
@ -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, "仅草稿态会话可选用素材");
|
||||
|
||||
}
|
||||
|
||||
@ -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)
|
||||
*
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user