Feature/gif media display#449
Conversation
… promotion
Adds the media asset module described in the design doc:
- V42 migration: media_asset table with owner-type / owner-id index,
sha256 dedupe index, and per-role ordering index.
- Domain layer: MediaAsset aggregate, MediaAssetRole and MediaOwnerType
enums, MediaValidator with magic-byte signature checks for
GIF87a/GIF89a/PNG/JPEG/RIFF and per-type size limits.
- Domain service: MediaAssetService coordinates header validation,
sha256-based deduplication keys, content-addressed storage paths
(media/{ownerType}/{ownerId}/{sha256}.{ext}), and read-through
retrieval. Storage and hashing are seam interfaces so unit tests
can run without I/O.
- Infra layer: MediaAssetJpaRepository with role-aware ordered query.
- App layer: ObjectStorageMediaStorage adapter on top of the existing
ObjectStorageService, Sha256MediaHasher, MediaConfig with
configurable size limits sourced from skillhub.media.*.
- HTTP: POST /api/v1/media (multipart upload), GET /api/v1/media/{id}
(immutable cache headers, alt-text exposed via X-Media-Alt-Text).
- Frontend: GifMediaDisplay component implements the design doc's
cards-show-cover / details-show-GIF / lazy-load-on-intersect /
fallback-on-error rules; mediaApi with typed upload + envelope
unwrap; mediaUrl helper for asset id -> URL.
- Tests: domain unit tests for MediaAssetService and MediaValidator
covering happy paths, header rejection, content-type mismatch and
oversize uploads; vitest tests for the GifMediaDisplay component
(lazy mode, observer trigger, error fallback, accessible alt text)
and the upload helper. All 9 frontend tests pass.
The MediaValidator rejects headers shorter than 6 bytes with error.media.headerUnreadable, so the JPEG happy-path test must provide at least 6 bytes for the soi/app0 marker pair.
There was a problem hiding this comment.
Code Review
This pull request implements a comprehensive media asset management system, including backend services for uploading, validating, and serving media files, as well as a frontend React component for lazy-loading display. Feedback focuses on optimizing performance by eliminating redundant database lookups in the controller and service layers. Significant improvements were suggested for the MediaValidator to correctly enforce promotion-specific size limits and to strengthen WebP signature verification. Additionally, a bug was identified in the frontend GifMediaDisplay component where state does not reset when props change.
| MediaAsset asset = mediaAssetService.get(id); | ||
| byte[] body = mediaAssetService.read(id); |
There was a problem hiding this comment.
The current implementation performs two database lookups for the same asset ID: once explicitly in the controller and once internally within mediaAssetService.read(id). For a high-traffic media serving endpoint, this redundant I/O should be avoided by passing the already retrieved MediaAsset to the read method.
| MediaAsset asset = mediaAssetService.get(id); | |
| byte[] body = mediaAssetService.read(id); | |
| MediaAsset asset = mediaAssetService.get(id); | |
| byte[] body = mediaAssetService.read(asset); |
| public byte[] read(Long id) { | ||
| MediaAsset asset = get(id); | ||
| return storage.get(asset.getObjectKey()); | ||
| } |
There was a problem hiding this comment.
|
|
||
| @Bean | ||
| public MediaValidator mediaValidator(MediaProperties properties) { | ||
| return new MediaValidator(properties.getMaxGifSize(), properties.getMaxImageSize()); |
There was a problem hiding this comment.
The maxPromotionGifSize limit is currently ignored. The MediaValidator should be initialized with all three limits defined in the properties to adhere to the design requirements mentioned in the PR description.
| return new MediaValidator(properties.getMaxGifSize(), properties.getMaxImageSize()); | |
| return new MediaValidator(properties.getMaxGifSize(), properties.getMaxImageSize(), properties.getMaxPromotionGifSize()); |
| private static final byte[] GIF89A = {0x47, 0x49, 0x46, 0x38, 0x39, 0x61}; // GIF89a | ||
| private static final byte[] PNG = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; | ||
| private static final byte[] JPEG = {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}; | ||
| private static final byte[] WEBP_RIFF = {0x52, 0x49, 0x46, 0x46}; // RIFF...WEBP |
There was a problem hiding this comment.
The WEBP_RIFF signature only checks for the RIFF magic bytes, which are common to many file formats (e.g., WAV, AVI). To prevent content-type spoofing, the validator should also verify the WEBP identifier at offset 8.
| private static final byte[] WEBP_RIFF = {0x52, 0x49, 0x46, 0x46}; // RIFF...WEBP | |
| private static final byte[] RIFF = {0x52, 0x49, 0x46, 0x46}; | |
| private static final byte[] WEBP = {0x57, 0x45, 0x42, 0x50}; |
| private final long maxGifBytes; | ||
| private final long maxImageBytes; | ||
|
|
||
| public MediaValidator(long maxGifBytes, long maxImageBytes) { | ||
| this.maxGifBytes = maxGifBytes; | ||
| this.maxImageBytes = maxImageBytes; | ||
| } |
There was a problem hiding this comment.
Add a field and update the constructor to support the maxPromotionGifBytes limit as specified in the design requirements.
private final long maxGifBytes;
private final long maxImageBytes;
private final long maxPromotionGifBytes;
public MediaValidator(long maxGifBytes, long maxImageBytes, long maxPromotionGifBytes) {
this.maxGifBytes = maxGifBytes;
this.maxImageBytes = maxImageBytes;
this.maxPromotionGifBytes = maxPromotionGifBytes;
}| if (sizeBytes > maxGifBytes) { | ||
| throw new MediaException("error.media.gif.tooLarge"); | ||
| } |
There was a problem hiding this comment.
Apply the specific size limit for promotion GIFs if the owner type is PROMOTION_CAMPAIGN.
| if (sizeBytes > maxGifBytes) { | |
| throw new MediaException("error.media.gif.tooLarge"); | |
| } | |
| long limit = (ownerType == MediaOwnerType.PROMOTION_CAMPAIGN) ? maxPromotionGifBytes : maxGifBytes; | |
| if (sizeBytes > limit) { | |
| throw new MediaException("error.media.gif.tooLarge"); | |
| } |
| throw new MediaException("error.media.gif.invalidSignature"); | ||
| } | ||
|
|
||
| if (matches(header, PNG) || matches(header, JPEG) || matches(header, WEBP_RIFF)) { |
There was a problem hiding this comment.
Update the WebP validation logic to check for both the RIFF header and the WEBP identifier at offset 8 to ensure the file is indeed a WebP image.
| if (matches(header, PNG) || matches(header, JPEG) || matches(header, WEBP_RIFF)) { | |
| if (matches(header, PNG) || matches(header, JPEG) || (matches(header, RIFF) && matches(header, 8, WEBP))) { |
| private static boolean matches(byte[] header, byte[] signature) { | ||
| if (header.length < signature.length) return false; | ||
| for (int i = 0; i < signature.length; i++) { | ||
| if (header[i] != signature[i]) return false; | ||
| } | ||
| return true; | ||
| } |
There was a problem hiding this comment.
Introduce an overloaded matches method that supports an offset. This is necessary for verifying signatures that do not start at the beginning of the file, such as the WEBP identifier in RIFF containers.
private static boolean matches(byte[] header, byte[] signature) {
return matches(header, 0, signature);
}
private static boolean matches(byte[] header, int offset, byte[] signature) {
if (header.length < offset + signature.length) return false;
for (int i = 0; i < signature.length; i++) {
if (header[offset + i] != signature[i]) return false;
}
return true;
}| byte[] header = new byte[Math.min(16, command.bytes().length)]; | ||
| System.arraycopy(command.bytes(), 0, header, 0, header.length); | ||
|
|
||
| MediaType detected = validator.validateAndClassify(header, command.bytes().length, command.contentType()); |
There was a problem hiding this comment.
Pass the ownerType to the validator so it can apply the correct size limits (e.g., the 5MB limit for promotions).
| MediaType detected = validator.validateAndClassify(header, command.bytes().length, command.contentType()); | |
| MediaType detected = validator.validateAndClassify(header, command.bytes().length, command.contentType(), command.ownerType()); |
| const [shouldLoadGif, setShouldLoadGif] = useState(!lazy) | ||
| const [errored, setErrored] = useState(false) |
There was a problem hiding this comment.
The component state (shouldLoadGif, errored) does not reset when the src or lazy props change. This can lead to incorrect behavior, such as showing a fallback for a new valid src if the previous one failed, or failing to load a GIF immediately if the lazy prop is toggled.
const [shouldLoadGif, setShouldLoadGif] = useState(!lazy)
const [errored, setErrored] = useState(false)
useEffect(() => {
if (!lazy) setShouldLoadGif(true)
}, [lazy])
useEffect(() => {
setErrored(false)
}, [src])
背景
落地设计文档里的「GIF 效果展示」诉求:让技能、技能包、推广位都能挂载封面 / 演示 GIF / 截图,支持上传时校验、按内容寻址存储、列表卡片只展示静态封面、详情页懒加载完整 GIF。
改动概要
数据库(Flyway V42)
media_asset:单表设计,按(owner_type, owner_id)、(sha256)、(owner_type, owner_id, role, sort_order)三组索引。Server 端
skillhub-domain/.../media)MediaAsset,枚举MediaOwnerType/MediaAssetRole/MediaType。MediaValidator:基于文件头 magic-byte 校验 GIF87a / GIF89a / PNG / JPEG / RIFF(WebP),并按类型应用大小上限;声明类型与实际签名不一致时直接拒绝。MediaAssetService:协调验证、SHA-256 哈希、内容寻址存储路径(media/{ownerType}/{ownerId}/{sha256}.{ext});存储与哈希以接口形式注入,便于 mock。MediaAssetJpaRepository,含 role-aware 排序查询。ObjectStorageMediaStorage:接到现有ObjectStorageService的 S3/Local 实现。Sha256MediaHasher:SHA-256 hex 摘要。MediaConfig:从skillhub.media.*读取大小限制(默认 GIF/图片各 10 MB,推广 GIF 5 MB)。POST /api/v1/media(multipart 上传),GET /api/v1/media/{id}(不可变长缓存 +X-Media-Alt-Text)。Web 端
features/media/gif-media-display.tsx:实现「卡片显示封面 / 详情懒加载 GIF / IntersectionObserver 触发 / onError 回退」四档行为。features/media/api.ts:mediaApi.upload、mediaUrl(id)助手。测试
MediaValidatorTest(9 个):覆盖 GIF87a/89a、PNG、JPEG header 验证、大小上限、伪造 content-type 拒绝。MediaAssetServiceTest(5 个):上传写入路径、空 body 拒绝、读取直通、缺失资产报错。验证
./mvnw -pl skillhub-domain -am test:BUILD SUCCESS,14/14 通过。pnpm test src/features/media/:9/9 通过。兼容性
V42,不与已合入迁移冲突。Checklist