getAsset(@PathVariable Long id) {
+ MediaAsset asset = mediaAssetService.get(id);
+ byte[] body = mediaAssetService.read(id);
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.parseMediaType(asset.getContentType()));
+ headers.setContentLength(body.length);
+ // Long-cache GIFs/images keyed by id since assets are immutable once stored.
+ headers.add(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable");
+ if (asset.getAltText() != null) {
+ headers.add("X-Media-Alt-Text", asset.getAltText());
+ }
+ return new ResponseEntity<>(body, headers, org.springframework.http.HttpStatus.OK);
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/media/MediaAssetResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/media/MediaAssetResponse.java
new file mode 100644
index 000000000..015510e84
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/media/MediaAssetResponse.java
@@ -0,0 +1,31 @@
+package com.iflytek.skillhub.dto.media;
+
+import com.iflytek.skillhub.domain.media.MediaAsset;
+import com.iflytek.skillhub.domain.media.MediaAssetRole;
+import com.iflytek.skillhub.domain.media.MediaOwnerType;
+import com.iflytek.skillhub.domain.media.MediaType;
+
+import java.time.Instant;
+
+public record MediaAssetResponse(
+ Long id,
+ MediaOwnerType ownerType,
+ Long ownerId,
+ MediaType mediaType,
+ MediaAssetRole role,
+ String url,
+ String contentType,
+ long sizeBytes,
+ String altText,
+ Instant createdAt
+) {
+ public static MediaAssetResponse from(MediaAsset asset) {
+ return new MediaAssetResponse(
+ asset.getId(), asset.getOwnerType(), asset.getOwnerId(),
+ asset.getMediaType(), asset.getRole(),
+ "/api/v1/media/" + asset.getId(),
+ asset.getContentType(), asset.getSizeBytes(),
+ asset.getAltText(), asset.getCreatedAt()
+ );
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/media/ObjectStorageMediaStorage.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/media/ObjectStorageMediaStorage.java
new file mode 100644
index 000000000..f364b3272
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/media/ObjectStorageMediaStorage.java
@@ -0,0 +1,38 @@
+package com.iflytek.skillhub.service.media;
+
+import com.iflytek.skillhub.domain.media.MediaAssetService;
+import com.iflytek.skillhub.storage.ObjectStorageService;
+import org.springframework.stereotype.Component;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+/**
+ * Adapts {@link ObjectStorageService} to the domain port {@link MediaAssetService.MediaStorage}.
+ * Buffers the entire body in memory because media uploads are bounded by validator limits
+ * (10MB default), well below per-request budget.
+ */
+@Component
+public class ObjectStorageMediaStorage implements MediaAssetService.MediaStorage {
+
+ private final ObjectStorageService objectStorage;
+
+ public ObjectStorageMediaStorage(ObjectStorageService objectStorage) {
+ this.objectStorage = objectStorage;
+ }
+
+ @Override
+ public void put(String key, byte[] bytes, String contentType) {
+ objectStorage.putObject(key, new ByteArrayInputStream(bytes), bytes.length, contentType);
+ }
+
+ @Override
+ public byte[] get(String key) {
+ try (InputStream stream = objectStorage.getObject(key)) {
+ return stream.readAllBytes();
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to read media object " + key, e);
+ }
+ }
+}
diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/media/Sha256MediaHasher.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/media/Sha256MediaHasher.java
new file mode 100644
index 000000000..3166a60e3
--- /dev/null
+++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/media/Sha256MediaHasher.java
@@ -0,0 +1,33 @@
+package com.iflytek.skillhub.service.media;
+
+import com.iflytek.skillhub.domain.media.MediaAssetService;
+import org.springframework.stereotype.Component;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/**
+ * Production {@link MediaAssetService.MediaHasher} that computes lowercase
+ * SHA-256 hex digests of the upload body. Used both as the storage path key
+ * and for cross-owner deduplication.
+ */
+@Component
+public class Sha256MediaHasher implements MediaAssetService.MediaHasher {
+
+ private static final char[] HEX = "0123456789abcdef".toCharArray();
+
+ @Override
+ public String sha256(byte[] bytes) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes);
+ char[] out = new char[digest.length * 2];
+ for (int i = 0; i < digest.length; i++) {
+ out[i * 2] = HEX[(digest[i] >> 4) & 0xF];
+ out[i * 2 + 1] = HEX[digest[i] & 0xF];
+ }
+ return new String(out);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 unavailable", e);
+ }
+ }
+}
diff --git a/server/skillhub-app/src/main/resources/db/migration/V42__media_asset_table.sql b/server/skillhub-app/src/main/resources/db/migration/V42__media_asset_table.sql
new file mode 100644
index 000000000..4fd8114ba
--- /dev/null
+++ b/server/skillhub-app/src/main/resources/db/migration/V42__media_asset_table.sql
@@ -0,0 +1,23 @@
+-- Media asset table backing GIF / image attachments for skills, bundles, and promotions.
+-- Card lists default to a static cover; detail screens load the full asset.
+
+CREATE TABLE media_asset (
+ id BIGSERIAL PRIMARY KEY,
+ owner_type VARCHAR(64) NOT NULL,
+ owner_id BIGINT NOT NULL,
+ media_type VARCHAR(32) NOT NULL,
+ role VARCHAR(32) NOT NULL,
+ file_path VARCHAR(512),
+ object_key VARCHAR(512) NOT NULL,
+ content_type VARCHAR(128) NOT NULL,
+ size_bytes BIGINT NOT NULL,
+ sha256 VARCHAR(64) NOT NULL,
+ alt_text VARCHAR(256),
+ sort_order INT NOT NULL DEFAULT 0,
+ created_by VARCHAR(128) NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_media_asset_owner ON media_asset (owner_type, owner_id);
+CREATE INDEX idx_media_asset_sha256 ON media_asset (sha256);
+CREATE INDEX idx_media_asset_role ON media_asset (owner_type, owner_id, role, sort_order);
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaAsset.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaAsset.java
new file mode 100644
index 000000000..26d740fb0
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaAsset.java
@@ -0,0 +1,98 @@
+package com.iflytek.skillhub.domain.media;
+
+import jakarta.persistence.*;
+import java.time.Instant;
+
+/**
+ * Persisted media attachment — typically a GIF demo, a static cover, or a screenshot.
+ *
+ * The asset is stored verbatim in object storage at {@link #getObjectKey()}; the
+ * {@code /api/v1/media/{id}} endpoint streams it back with the recorded
+ * {@link #getContentType()} and length so caches can range-fetch.
+ */
+@Entity
+@Table(name = "media_asset")
+public class MediaAsset {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "owner_type", nullable = false, length = 64)
+ private MediaOwnerType ownerType;
+
+ @Column(name = "owner_id", nullable = false)
+ private Long ownerId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "media_type", nullable = false, length = 32)
+ private MediaType mediaType;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 32)
+ private MediaAssetRole role;
+
+ @Column(name = "file_path", length = 512)
+ private String filePath;
+
+ @Column(name = "object_key", nullable = false, length = 512)
+ private String objectKey;
+
+ @Column(name = "content_type", nullable = false, length = 128)
+ private String contentType;
+
+ @Column(name = "size_bytes", nullable = false)
+ private long sizeBytes;
+
+ @Column(nullable = false, length = 64)
+ private String sha256;
+
+ @Column(name = "alt_text", length = 256)
+ private String altText;
+
+ @Column(name = "sort_order", nullable = false)
+ private int sortOrder;
+
+ @Column(name = "created_by", nullable = false, length = 128)
+ private String createdBy;
+
+ @Column(name = "created_at", nullable = false)
+ private Instant createdAt = Instant.now();
+
+ protected MediaAsset() {}
+
+ public MediaAsset(MediaOwnerType ownerType, Long ownerId,
+ MediaType mediaType, MediaAssetRole role,
+ String objectKey, String contentType,
+ long sizeBytes, String sha256, String createdBy) {
+ this.ownerType = ownerType;
+ this.ownerId = ownerId;
+ this.mediaType = mediaType;
+ this.role = role;
+ this.objectKey = objectKey;
+ this.contentType = contentType;
+ this.sizeBytes = sizeBytes;
+ this.sha256 = sha256;
+ this.createdBy = createdBy;
+ }
+
+ public Long getId() { return id; }
+ public MediaOwnerType getOwnerType() { return ownerType; }
+ public Long getOwnerId() { return ownerId; }
+ public MediaType getMediaType() { return mediaType; }
+ public MediaAssetRole getRole() { return role; }
+ public String getFilePath() { return filePath; }
+ public String getObjectKey() { return objectKey; }
+ public String getContentType() { return contentType; }
+ public long getSizeBytes() { return sizeBytes; }
+ public String getSha256() { return sha256; }
+ public String getAltText() { return altText; }
+ public int getSortOrder() { return sortOrder; }
+ public String getCreatedBy() { return createdBy; }
+ public Instant getCreatedAt() { return createdAt; }
+
+ public void setFilePath(String filePath) { this.filePath = filePath; }
+ public void setAltText(String altText) { this.altText = altText; }
+ public void setSortOrder(int sortOrder) { this.sortOrder = sortOrder; }
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaAssetRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaAssetRepository.java
new file mode 100644
index 000000000..5cd9d1a6d
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaAssetRepository.java
@@ -0,0 +1,16 @@
+package com.iflytek.skillhub.domain.media;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Persistence contract for media assets.
+ */
+public interface MediaAssetRepository {
+ MediaAsset save(MediaAsset asset);
+ Optional findById(Long id);
+ List findByOwner(MediaOwnerType ownerType, Long ownerId);
+ Optional findFirstByOwnerAndRole(MediaOwnerType ownerType, Long ownerId, MediaAssetRole role);
+ List findBySha256(String sha256);
+ void delete(MediaAsset asset);
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaAssetRole.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaAssetRole.java
new file mode 100644
index 000000000..ce6ab197d
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaAssetRole.java
@@ -0,0 +1,10 @@
+package com.iflytek.skillhub.domain.media;
+
+/**
+ * Functional role of a media asset within an owner aggregate.
+ */
+public enum MediaAssetRole {
+ COVER,
+ DEMO,
+ SCREENSHOT
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaAssetService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaAssetService.java
new file mode 100644
index 000000000..97e70a81d
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaAssetService.java
@@ -0,0 +1,95 @@
+package com.iflytek.skillhub.domain.media;
+
+import java.util.List;
+
+/**
+ * Coordinates upload, lookup and visibility for media assets.
+ *
+ * Storage IO is delegated to {@link MediaStorage} so the domain layer can
+ * stay free of S3 / MinIO specifics. {@link MediaValidator} is used to enforce
+ * file-header and size policy before persistence.
+ */
+public class MediaAssetService {
+
+ private final MediaAssetRepository repository;
+ private final MediaValidator validator;
+ private final MediaStorage storage;
+ private final MediaHasher hasher;
+
+ public MediaAssetService(MediaAssetRepository repository,
+ MediaValidator validator,
+ MediaStorage storage,
+ MediaHasher hasher) {
+ this.repository = repository;
+ this.validator = validator;
+ this.storage = storage;
+ this.hasher = hasher;
+ }
+
+ public MediaAsset upload(UploadCommand command) {
+ if (command.bytes() == null || command.bytes().length == 0) {
+ throw new MediaException("error.media.empty");
+ }
+ 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());
+ String sha256 = hasher.sha256(command.bytes());
+ String objectKey = "media/" + command.ownerType().name().toLowerCase() + "/" + command.ownerId() + "/" + sha256
+ + extensionFor(command.contentType(), detected);
+ storage.put(objectKey, command.bytes(), command.contentType());
+
+ MediaAsset asset = new MediaAsset(command.ownerType(), command.ownerId(),
+ detected, command.role(), objectKey, command.contentType(),
+ command.bytes().length, sha256, command.uploader());
+ asset.setAltText(command.altText());
+ asset.setFilePath(command.filename());
+ return repository.save(asset);
+ }
+
+ public MediaAsset get(Long id) {
+ return repository.findById(id).orElseThrow(() -> new MediaException("error.media.notFound"));
+ }
+
+ public List listByOwner(MediaOwnerType ownerType, Long ownerId) {
+ return repository.findByOwner(ownerType, ownerId);
+ }
+
+ public byte[] read(Long id) {
+ MediaAsset asset = get(id);
+ return storage.get(asset.getObjectKey());
+ }
+
+ private String extensionFor(String contentType, MediaType detected) {
+ return switch (detected) {
+ case GIF -> ".gif";
+ case IMAGE -> switch (contentType.toLowerCase()) {
+ case "image/png" -> ".png";
+ case "image/webp" -> ".webp";
+ case "image/jpeg", "image/jpg" -> ".jpg";
+ default -> ".bin";
+ };
+ };
+ }
+
+ public record UploadCommand(MediaOwnerType ownerType,
+ Long ownerId,
+ MediaAssetRole role,
+ byte[] bytes,
+ String contentType,
+ String filename,
+ String altText,
+ String uploader) {}
+
+ /** Storage adapter — implementations live in {@code skillhub-storage}. */
+ public interface MediaStorage {
+ void put(String key, byte[] bytes, String contentType);
+
+ byte[] get(String key);
+ }
+
+ /** Hashing seam so unit tests can stub deterministically. */
+ public interface MediaHasher {
+ String sha256(byte[] bytes);
+ }
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaException.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaException.java
new file mode 100644
index 000000000..ce43e1783
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaException.java
@@ -0,0 +1,11 @@
+package com.iflytek.skillhub.domain.media;
+
+/**
+ * Domain-level error for media uploads (file-header rejection, size limit, etc.).
+ * Message is the i18n key (e.g. {@code error.media.gif.invalidSignature}).
+ */
+public class MediaException extends RuntimeException {
+ public MediaException(String messageCode) {
+ super(messageCode);
+ }
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaOwnerType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaOwnerType.java
new file mode 100644
index 000000000..cbb14cf11
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaOwnerType.java
@@ -0,0 +1,11 @@
+package com.iflytek.skillhub.domain.media;
+
+/**
+ * Owner of a media asset. Bound owners use the singular form so the same media
+ * row can target a skill version, a bundle version, or a promotion campaign.
+ */
+public enum MediaOwnerType {
+ SKILL_VERSION,
+ SKILL_BUNDLE_VERSION,
+ PROMOTION_CAMPAIGN
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaType.java
new file mode 100644
index 000000000..460257a2d
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaType.java
@@ -0,0 +1,9 @@
+package com.iflytek.skillhub.domain.media;
+
+/**
+ * High-level media classification.
+ */
+public enum MediaType {
+ IMAGE,
+ GIF
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaValidator.java
new file mode 100644
index 000000000..966ae2d02
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/MediaValidator.java
@@ -0,0 +1,75 @@
+package com.iflytek.skillhub.domain.media;
+
+import java.util.Set;
+
+/**
+ * Validates media uploads against magic-byte signatures and size limits.
+ *
+ * The design doc requires GIF magic-byte validation (rejects files whose extension
+ * lies about content), and a size cap to keep cards/list views responsive.
+ */
+public class MediaValidator {
+
+ private static final byte[] GIF87A = {0x47, 0x49, 0x46, 0x38, 0x37, 0x61}; // GIF87a
+ 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
+
+ private static final Set ALLOWED_GIF_TYPES =
+ Set.of("image/gif");
+ private static final Set ALLOWED_IMAGE_TYPES =
+ Set.of("image/png", "image/jpeg", "image/jpg", "image/webp");
+
+ private final long maxGifBytes;
+ private final long maxImageBytes;
+
+ public MediaValidator(long maxGifBytes, long maxImageBytes) {
+ this.maxGifBytes = maxGifBytes;
+ this.maxImageBytes = maxImageBytes;
+ }
+
+ public MediaType validateAndClassify(byte[] header, long sizeBytes, String declaredContentType) {
+ if (header == null || header.length < 6) {
+ throw new MediaException("error.media.headerUnreadable");
+ }
+ if (declaredContentType == null) {
+ throw new MediaException("error.media.unsupportedType");
+ }
+
+ if (matches(header, GIF87A) || matches(header, GIF89A)) {
+ if (!ALLOWED_GIF_TYPES.contains(declaredContentType.toLowerCase())) {
+ throw new MediaException("error.media.gif.contentTypeMismatch");
+ }
+ if (sizeBytes > maxGifBytes) {
+ throw new MediaException("error.media.gif.tooLarge");
+ }
+ return MediaType.GIF;
+ }
+
+ if (declaredContentType.toLowerCase().equals("image/gif")) {
+ // Declared GIF but header doesn't match — reject before storage.
+ throw new MediaException("error.media.gif.invalidSignature");
+ }
+
+ if (matches(header, PNG) || matches(header, JPEG) || matches(header, WEBP_RIFF)) {
+ if (!ALLOWED_IMAGE_TYPES.contains(declaredContentType.toLowerCase())) {
+ throw new MediaException("error.media.image.contentTypeMismatch");
+ }
+ if (sizeBytes > maxImageBytes) {
+ throw new MediaException("error.media.image.tooLarge");
+ }
+ return MediaType.IMAGE;
+ }
+
+ throw new MediaException("error.media.unsupportedType");
+ }
+
+ 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;
+ }
+}
diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/package-info.java
new file mode 100644
index 000000000..7d74bd487
--- /dev/null
+++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/media/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * Media asset domain — covers / demos / screenshots attached to skills, skill bundles, or promotion campaigns.
+ */
+package com.iflytek.skillhub.domain.media;
diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/media/MediaAssetServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/media/MediaAssetServiceTest.java
new file mode 100644
index 000000000..c6e3326b8
--- /dev/null
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/media/MediaAssetServiceTest.java
@@ -0,0 +1,113 @@
+package com.iflytek.skillhub.domain.media;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link MediaAssetService}: validation, hashing, storage layout, and
+ * read-through error handling.
+ */
+@ExtendWith(MockitoExtension.class)
+class MediaAssetServiceTest {
+
+ private MediaAssetRepository repository;
+ private MediaValidator validator;
+ private MediaAssetService.MediaStorage storage;
+ private MediaAssetService.MediaHasher hasher;
+ private MediaAssetService service;
+
+ @BeforeEach
+ void setUp() {
+ repository = mock(MediaAssetRepository.class);
+ validator = new MediaValidator(10_000, 5_000);
+ storage = mock(MediaAssetService.MediaStorage.class);
+ hasher = mock(MediaAssetService.MediaHasher.class);
+ service = new MediaAssetService(repository, validator, storage, hasher);
+ }
+
+ @Test
+ void upload_storesGifWithSignaturePathAndContentTypeAndPersistsAsset() {
+ byte[] gifBody = new byte[] {0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x02};
+ given(hasher.sha256(gifBody)).willReturn("abcd1234");
+ given(repository.save(any(MediaAsset.class))).willAnswer(invocation -> invocation.getArgument(0));
+
+ MediaAssetService.UploadCommand command = new MediaAssetService.UploadCommand(
+ MediaOwnerType.SKILL_VERSION, 7L, MediaAssetRole.DEMO,
+ gifBody, "image/gif", "demo.gif", "演示效果", "alice");
+
+ MediaAsset stored = service.upload(command);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(MediaAsset.class);
+ verify(repository).save(captor.capture());
+ MediaAsset captured = captor.getValue();
+ assertThat(captured.getMediaType()).isEqualTo(MediaType.GIF);
+ assertThat(captured.getRole()).isEqualTo(MediaAssetRole.DEMO);
+ assertThat(captured.getObjectKey()).isEqualTo("media/skill_version/7/abcd1234.gif");
+ assertThat(captured.getContentType()).isEqualTo("image/gif");
+ assertThat(captured.getSizeBytes()).isEqualTo(8);
+ assertThat(captured.getAltText()).isEqualTo("演示效果");
+ assertThat(captured.getFilePath()).isEqualTo("demo.gif");
+ verify(storage).put("media/skill_version/7/abcd1234.gif", gifBody, "image/gif");
+ assertThat(stored).isSameAs(captured);
+ }
+
+ @Test
+ void upload_rejectsEmptyBody() {
+ MediaAssetService.UploadCommand command = new MediaAssetService.UploadCommand(
+ MediaOwnerType.SKILL_VERSION, 7L, MediaAssetRole.DEMO,
+ new byte[0], "image/gif", "demo.gif", null, "alice");
+
+ assertThatThrownBy(() -> service.upload(command))
+ .isInstanceOf(MediaException.class)
+ .hasMessage("error.media.empty");
+
+ verify(storage, never()).put(anyString(), any(), anyString());
+ verify(repository, never()).save(any());
+ }
+
+ @Test
+ void upload_rejectsHeaderMismatch() {
+ byte[] body = new byte[] {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
+ MediaAssetService.UploadCommand command = new MediaAssetService.UploadCommand(
+ MediaOwnerType.SKILL_VERSION, 7L, MediaAssetRole.DEMO,
+ body, "image/gif", "demo.gif", null, "alice");
+
+ assertThatThrownBy(() -> service.upload(command))
+ .isInstanceOf(MediaException.class)
+ .hasMessage("error.media.gif.invalidSignature");
+ verify(storage, never()).put(anyString(), any(), anyString());
+ }
+
+ @Test
+ void read_throwsWhenAssetMissing() {
+ given(repository.findById(99L)).willReturn(java.util.Optional.empty());
+ assertThatThrownBy(() -> service.read(99L))
+ .isInstanceOf(MediaException.class)
+ .hasMessage("error.media.notFound");
+ }
+
+ @Test
+ void read_returnsBytesFromStorageForExistingAsset() {
+ MediaAsset asset = new MediaAsset(MediaOwnerType.SKILL_VERSION, 7L, MediaType.GIF,
+ MediaAssetRole.DEMO, "media/skill_version/7/abcd1234.gif", "image/gif",
+ 4, "abcd1234", "alice");
+ given(repository.findById(99L)).willReturn(java.util.Optional.of(asset));
+ given(storage.get("media/skill_version/7/abcd1234.gif")).willReturn(new byte[] {1, 2, 3, 4});
+
+ byte[] data = service.read(99L);
+
+ assertThat(data).containsExactly(1, 2, 3, 4);
+ }
+}
diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/media/MediaValidatorTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/media/MediaValidatorTest.java
new file mode 100644
index 000000000..dcd2279ba
--- /dev/null
+++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/media/MediaValidatorTest.java
@@ -0,0 +1,89 @@
+package com.iflytek.skillhub.domain.media;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Verifies that {@link MediaValidator} accepts only files whose magic bytes
+ * match the declared content type, and rejects oversized uploads.
+ */
+class MediaValidatorTest {
+
+ private final MediaValidator validator = new MediaValidator(10_000, 5_000);
+
+ @Test
+ void acceptsGif87aHeader() {
+ byte[] header = bytes("GIF87a-rest");
+ MediaType type = validator.validateAndClassify(header, 200, "image/gif");
+ assertThat(type).isEqualTo(MediaType.GIF);
+ }
+
+ @Test
+ void acceptsGif89aHeader() {
+ byte[] header = bytes("GIF89a-rest");
+ MediaType type = validator.validateAndClassify(header, 200, "image/gif");
+ assertThat(type).isEqualTo(MediaType.GIF);
+ }
+
+ @Test
+ void rejectsGifWithMismatchingDeclaredType() {
+ byte[] header = bytes("GIF89a-x");
+ assertThatThrownBy(() -> validator.validateAndClassify(header, 200, "image/png"))
+ .isInstanceOf(MediaException.class)
+ .hasMessage("error.media.gif.contentTypeMismatch");
+ }
+
+ @Test
+ void rejectsTooLargeGif() {
+ byte[] header = bytes("GIF89a-x");
+ assertThatThrownBy(() -> validator.validateAndClassify(header, 99_000, "image/gif"))
+ .isInstanceOf(MediaException.class)
+ .hasMessage("error.media.gif.tooLarge");
+ }
+
+ @Test
+ void rejectsImageDeclaredAsGifWithoutGifSignature() {
+ byte[] header = new byte[] {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A};
+ assertThatThrownBy(() -> validator.validateAndClassify(header, 200, "image/gif"))
+ .isInstanceOf(MediaException.class)
+ .hasMessage("error.media.gif.invalidSignature");
+ }
+
+ @Test
+ void acceptsPngWithMatchingDeclaredType() {
+ byte[] header = new byte[] {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
+ MediaType type = validator.validateAndClassify(header, 200, "image/png");
+ assertThat(type).isEqualTo(MediaType.IMAGE);
+ }
+
+ @Test
+ void acceptsJpegWithMatchingDeclaredType() {
+ byte[] header = new byte[] {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0, 0x00, 0x10};
+ MediaType type = validator.validateAndClassify(header, 200, "image/jpeg");
+ assertThat(type).isEqualTo(MediaType.IMAGE);
+ }
+
+ @Test
+ void rejectsTooLargeImage() {
+ byte[] header = new byte[] {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
+ assertThatThrownBy(() -> validator.validateAndClassify(header, 100_000, "image/png"))
+ .isInstanceOf(MediaException.class)
+ .hasMessage("error.media.image.tooLarge");
+ }
+
+ @Test
+ void rejectsUnknownSignature() {
+ byte[] header = bytes("HELLO!");
+ assertThatThrownBy(() -> validator.validateAndClassify(header, 200, "image/png"))
+ .isInstanceOf(MediaException.class)
+ .hasMessage("error.media.unsupportedType");
+ }
+
+ private static byte[] bytes(String literal) {
+ byte[] result = new byte[Math.max(literal.length(), 8)];
+ for (int i = 0; i < literal.length(); i++) result[i] = (byte) literal.charAt(i);
+ return result;
+ }
+}
diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/MediaAssetJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/MediaAssetJpaRepository.java
new file mode 100644
index 000000000..08fc33cd5
--- /dev/null
+++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/MediaAssetJpaRepository.java
@@ -0,0 +1,45 @@
+package com.iflytek.skillhub.infra.jpa;
+
+import com.iflytek.skillhub.domain.media.MediaAsset;
+import com.iflytek.skillhub.domain.media.MediaAssetRepository;
+import com.iflytek.skillhub.domain.media.MediaAssetRole;
+import com.iflytek.skillhub.domain.media.MediaOwnerType;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface MediaAssetJpaRepository extends JpaRepository, MediaAssetRepository {
+
+ @Override
+ @Query("""
+ SELECT m FROM MediaAsset m
+ WHERE m.ownerType = :ownerType AND m.ownerId = :ownerId
+ ORDER BY m.sortOrder ASC, m.id ASC
+ """)
+ List findByOwner(@Param("ownerType") MediaOwnerType ownerType,
+ @Param("ownerId") Long ownerId);
+
+ @Override
+ @Query("""
+ SELECT m FROM MediaAsset m
+ WHERE m.ownerType = :ownerType AND m.ownerId = :ownerId AND m.role = :role
+ ORDER BY m.sortOrder ASC, m.id ASC
+ """)
+ List findByOwnerAndRoleOrdered(@Param("ownerType") MediaOwnerType ownerType,
+ @Param("ownerId") Long ownerId,
+ @Param("role") MediaAssetRole role);
+
+ @Override
+ default Optional findFirstByOwnerAndRole(MediaOwnerType ownerType, Long ownerId, MediaAssetRole role) {
+ List hits = findByOwnerAndRoleOrdered(ownerType, ownerId, role);
+ return hits.isEmpty() ? Optional.empty() : Optional.of(hits.get(0));
+ }
+
+ @Override
+ List findBySha256(String sha256);
+}
diff --git a/web/src/features/media/api.test.ts b/web/src/features/media/api.test.ts
new file mode 100644
index 000000000..19ea0fb50
--- /dev/null
+++ b/web/src/features/media/api.test.ts
@@ -0,0 +1,101 @@
+/** @vitest-environment jsdom */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mediaApi, mediaUrl } from './api'
+
+describe('mediaUrl', () => {
+ it('returns null for empty ids', () => {
+ expect(mediaUrl(null)).toBeNull()
+ expect(mediaUrl(undefined)).toBeNull()
+ expect(mediaUrl(0)).toBeNull()
+ })
+
+ it('builds the canonical media path', () => {
+ expect(mediaUrl(7)).toBe('/api/v1/media/7')
+ })
+})
+
+describe('mediaApi.upload', () => {
+ const originalFetch = globalThis.fetch
+
+ beforeEach(() => {
+ Object.defineProperty(document, 'cookie', { value: '', writable: true, configurable: true })
+ })
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch
+ vi.restoreAllMocks()
+ })
+
+ it('posts a multipart body with all required fields and unwraps envelope', async () => {
+ const mockFetch = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ code: 0,
+ msg: 'ok',
+ data: {
+ id: 502,
+ ownerType: 'SKILL_VERSION',
+ ownerId: 7,
+ mediaType: 'GIF',
+ role: 'DEMO',
+ url: '/api/v1/media/502',
+ contentType: 'image/gif',
+ sizeBytes: 8,
+ altText: '演示效果',
+ createdAt: 'now',
+ },
+ timestamp: '',
+ requestId: '',
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ ),
+ )
+ globalThis.fetch = mockFetch as unknown as typeof fetch
+
+ const file = new File([new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])], 'demo.gif', {
+ type: 'image/gif',
+ })
+
+ const asset = await mediaApi.upload({
+ file,
+ ownerType: 'SKILL_VERSION',
+ ownerId: 7,
+ role: 'DEMO',
+ altText: '演示效果',
+ })
+
+ expect(asset.url).toBe('/api/v1/media/502')
+ const [url, init] = mockFetch.mock.calls[0]
+ expect(String(url)).toBe('/api/v1/media')
+ expect(init.method).toBe('POST')
+ expect(init.body).toBeInstanceOf(FormData)
+ const form = init.body as FormData
+ expect(form.get('ownerType')).toBe('SKILL_VERSION')
+ expect(form.get('ownerId')).toBe('7')
+ expect(form.get('role')).toBe('DEMO')
+ expect(form.get('altText')).toBe('演示效果')
+ expect(form.get('file')).toBeInstanceOf(File)
+ })
+
+ it('throws on non-zero envelope codes', async () => {
+ const mockFetch = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ code: 4002,
+ msg: 'error.media.gif.invalidSignature',
+ data: null,
+ timestamp: '',
+ requestId: '',
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
+ ),
+ )
+ globalThis.fetch = mockFetch as unknown as typeof fetch
+
+ const file = new File([new Uint8Array([0x00])], 'demo.gif', { type: 'image/gif' })
+
+ await expect(
+ mediaApi.upload({ file, ownerType: 'SKILL_VERSION', ownerId: 7, role: 'DEMO' }),
+ ).rejects.toThrow('error.media.gif.invalidSignature')
+ })
+})
diff --git a/web/src/features/media/api.ts b/web/src/features/media/api.ts
new file mode 100644
index 000000000..c60b4b803
--- /dev/null
+++ b/web/src/features/media/api.ts
@@ -0,0 +1,73 @@
+/**
+ * Minimal media API client used by detail pages and the upload UI.
+ */
+type ApiEnvelope = {
+ code: number
+ msg: string
+ data: T
+ timestamp: string
+ requestId: string
+}
+
+function getCsrfToken(): string | null {
+ if (typeof document === 'undefined') return null
+ const match = document.cookie.match(/(?:^|; )XSRF-TOKEN=([^;]+)/)
+ return match ? decodeURIComponent(match[1]) : null
+}
+
+export type MediaOwnerType = 'SKILL_VERSION' | 'SKILL_BUNDLE_VERSION' | 'PROMOTION_CAMPAIGN'
+export type MediaAssetRole = 'COVER' | 'DEMO' | 'SCREENSHOT'
+export type MediaType = 'IMAGE' | 'GIF'
+
+export type MediaAsset = {
+ id: number
+ ownerType: MediaOwnerType
+ ownerId: number
+ mediaType: MediaType
+ role: MediaAssetRole
+ url: string
+ contentType: string
+ sizeBytes: number
+ altText?: string | null
+ createdAt: string
+}
+
+/**
+ * URL helper for an asset id. Used by cards / details / promotion slots so they
+ * don't hand-write the path (and so the path can be swapped later if we move to
+ * pre-signed URLs).
+ */
+export function mediaUrl(id: number | null | undefined): string | null {
+ if (!id || id <= 0) return null
+ return `/api/v1/media/${id}`
+}
+
+export const mediaApi = {
+ upload: async (params: {
+ file: File
+ ownerType: MediaOwnerType
+ ownerId: number
+ role: MediaAssetRole
+ altText?: string
+ }): Promise => {
+ const form = new FormData()
+ form.append('file', params.file)
+ form.append('ownerType', params.ownerType)
+ form.append('ownerId', String(params.ownerId))
+ form.append('role', params.role)
+ if (params.altText) form.append('altText', params.altText)
+ const headers = new Headers()
+ const csrf = getCsrfToken()
+ if (csrf) headers.set('X-XSRF-TOKEN', csrf)
+ const res = await fetch('/api/v1/media', {
+ method: 'POST',
+ body: form,
+ headers,
+ credentials: 'include',
+ })
+ if (!res.ok) throw new Error(`Request failed: ${res.status}`)
+ const envelope = (await res.json()) as ApiEnvelope
+ if (envelope.code !== 0) throw new Error(envelope.msg ?? `Request failed (code ${envelope.code})`)
+ return envelope.data
+ },
+}
diff --git a/web/src/features/media/gif-media-display.test.tsx b/web/src/features/media/gif-media-display.test.tsx
new file mode 100644
index 000000000..ea035cb37
--- /dev/null
+++ b/web/src/features/media/gif-media-display.test.tsx
@@ -0,0 +1,72 @@
+/** @vitest-environment jsdom */
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { GifMediaDisplay } from './gif-media-display'
+
+/**
+ * Tests for {@link GifMediaDisplay}: lazy-loading via IntersectionObserver, fallback
+ * to cover on error, and accessible alt text. Lazy mode hides the GIF until
+ * intersection; eager mode shows it immediately.
+ */
+describe('GifMediaDisplay', () => {
+ let observerCallback: IntersectionObserverCallback = () => {}
+
+ beforeEach(() => {
+ class MockIntersectionObserver implements IntersectionObserver {
+ readonly root: Element | Document | null = null
+ readonly rootMargin: string = ''
+ readonly thresholds: ReadonlyArray = []
+ constructor(callback: IntersectionObserverCallback) {
+ observerCallback = callback
+ }
+ disconnect(): void {}
+ observe(_target: Element): void {}
+ takeRecords(): IntersectionObserverEntry[] {
+ return []
+ }
+ unobserve(_target: Element): void {}
+ }
+ vi.stubGlobal('IntersectionObserver', MockIntersectionObserver as unknown as typeof IntersectionObserver)
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+ })
+
+ it('renders cover by default in lazy mode', () => {
+ render()
+ expect(screen.getByAltText('演示').getAttribute('src')).toBe('/cover.png')
+ expect(screen.queryByTestId('gif-media-img')).toBeNull()
+ })
+
+ it('switches to GIF when intersection observer fires', () => {
+ render()
+ act(() => {
+ observerCallback(
+ [{ isIntersecting: true } as unknown as IntersectionObserverEntry],
+ {} as IntersectionObserver,
+ )
+ })
+ const img = screen.getByTestId('gif-media-img') as HTMLImageElement
+ expect(img.src).toContain('/api/v1/media/2')
+ })
+
+ it('falls back to cover when GIF errors', () => {
+ render()
+ const img = screen.getByTestId('gif-media-img') as HTMLImageElement
+ fireEvent.error(img)
+ expect(screen.getByAltText('演示').getAttribute('src')).toBe('/cover.png')
+ })
+
+ it('renders a placeholder with role=img when no cover provided', () => {
+ render()
+ expect(screen.getByRole('img', { name: '演示' })).not.toBeNull()
+ })
+
+ it('shows GIF immediately when lazy is false', () => {
+ render()
+ expect(screen.getByTestId('gif-media-img')).not.toBeNull()
+ })
+})
diff --git a/web/src/features/media/gif-media-display.tsx b/web/src/features/media/gif-media-display.tsx
new file mode 100644
index 000000000..aa52520a4
--- /dev/null
+++ b/web/src/features/media/gif-media-display.tsx
@@ -0,0 +1,82 @@
+import { useEffect, useRef, useState } from 'react'
+
+export type MediaDisplayProps = {
+ /** Resource URL — typically `/api/v1/media/{id}` returned by the backend. */
+ src: string
+ /** Static fallback shown until the GIF is in the viewport, or when the GIF fails to load. */
+ coverSrc?: string | null
+ /** Accessibility text. Required for screen readers; passes through to {@code img.alt}. */
+ alt: string
+ /**
+ * When {@code true} (default), the GIF only starts loading once the element scrolls
+ * into view. Skill cards in lists set this to keep above-the-fold paint quick.
+ */
+ lazy?: boolean
+ className?: string
+}
+
+/**
+ * Renders a structured GIF / image media asset with three behaviours from the design doc:
+ *
+ *
+ * - Cards default to the static cover; full GIF loads only on the detail page.
+ * - Detail pages can request {@code lazy=false} to show the GIF immediately.
+ * - Failed loads gracefully fall back to the cover (or an inline placeholder).
+ *
+ */
+export function GifMediaDisplay({ src, coverSrc, alt, lazy = true, className }: MediaDisplayProps) {
+ const containerRef = useRef(null)
+ const [shouldLoadGif, setShouldLoadGif] = useState(!lazy)
+ const [errored, setErrored] = useState(false)
+
+ useEffect(() => {
+ if (shouldLoadGif) return
+ if (!containerRef.current) return
+
+ if (typeof IntersectionObserver === 'undefined') {
+ setShouldLoadGif(true)
+ return
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ setShouldLoadGif(true)
+ observer.disconnect()
+ break
+ }
+ }
+ },
+ { rootMargin: '200px' },
+ )
+ observer.observe(containerRef.current)
+ return () => observer.disconnect()
+ }, [shouldLoadGif])
+
+ const showCover = !shouldLoadGif || errored
+
+ return (
+
+ {showCover ? (
+ coverSrc ? (
+

+ ) : (
+
+ {alt}
+
+ )
+ ) : (
+

setErrored(true)}
+ data-testid="gif-media-img"
+ />
+ )}
+
+ )
+}
+
+export default GifMediaDisplay