Skip to content

Feature/gif media display#449

Open
paradoxgacsd wants to merge 2 commits into
iflytek:mainfrom
paradoxgacsd:feature/gif-media-display
Open

Feature/gif media display#449
paradoxgacsd wants to merge 2 commits into
iflytek:mainfrom
paradoxgacsd:feature/gif-media-display

Conversation

@paradoxgacsd
Copy link
Copy Markdown

背景

落地设计文档里的「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)。
    • REST: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.tsmediaApi.uploadmediaUrl(id) 助手。

测试

  • MediaValidatorTest(9 个):覆盖 GIF87a/89a、PNG、JPEG header 验证、大小上限、伪造 content-type 拒绝。
  • MediaAssetServiceTest(5 个):上传写入路径、空 body 拒绝、读取直通、缺失资产报错。
  • 前端 vitest(9 个):组件懒加载、observer 触发、错误回退、上传 multipart 字段、envelope 错误传播。

验证

  • JDK 21(Adoptium Temurin 21.0.10)+ ./mvnw -pl skillhub-domain -am test:BUILD SUCCESS,14/14 通过。
  • 前端 pnpm test src/features/media/:9/9 通过。

兼容性

  • 不修改现有公开 API;新增表使用下一个 Flyway 版本号 V42,不与已合入迁移冲突。

Checklist

  • Magic-byte 文件头校验,避免 content-type 欺骗
  • 上传大小硬上限可配置
  • 详情页 GIF 走懒加载,列表卡片不拉 GIF
  • 失败时静默回退到封面

… 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.
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +63 to +64
MediaAsset asset = mediaAssetService.get(id);
byte[] body = mediaAssetService.read(id);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
MediaAsset asset = mediaAssetService.get(id);
byte[] body = mediaAssetService.read(id);
MediaAsset asset = mediaAssetService.get(id);
byte[] body = mediaAssetService.read(asset);

Comment on lines +58 to +61
public byte[] read(Long id) {
MediaAsset asset = get(id);
return storage.get(asset.getObjectKey());
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To avoid redundant database lookups, the read method should accept a MediaAsset entity directly. This allows callers who have already fetched the metadata to retrieve the content without triggering another query.

    public byte[] read(MediaAsset asset) {
        return storage.get(asset.getObjectKey());
    }


@Bean
public MediaValidator mediaValidator(MediaProperties properties) {
return new MediaValidator(properties.getMaxGifSize(), properties.getMaxImageSize());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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.

Suggested change
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};

Comment on lines +24 to +30
private final long maxGifBytes;
private final long maxImageBytes;

public MediaValidator(long maxGifBytes, long maxImageBytes) {
this.maxGifBytes = maxGifBytes;
this.maxImageBytes = maxImageBytes;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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;
    }

Comment on lines +44 to +46
if (sizeBytes > maxGifBytes) {
throw new MediaException("error.media.gif.tooLarge");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Apply the specific size limit for promotion GIFs if the owner type is PROMOTION_CAMPAIGN.

Suggested change
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)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

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.

Suggested change
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))) {

Comment on lines +68 to +74
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Pass the ownerType to the validator so it can apply the correct size limits (e.g., the 5MB limit for promotions).

Suggested change
MediaType detected = validator.validateAndClassify(header, command.bytes().length, command.contentType());
MediaType detected = validator.validateAndClassify(header, command.bytes().length, command.contentType(), command.ownerType());

Comment on lines +29 to +30
const [shouldLoadGif, setShouldLoadGif] = useState(!lazy)
const [errored, setErrored] = useState(false)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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])

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant