diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java index 7b36265f0..f8ecf6ed5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java @@ -1,5 +1,8 @@ package com.iflytek.skillhub.config; +import com.iflytek.skillhub.domain.media.MediaAssetRepository; +import com.iflytek.skillhub.domain.media.MediaAssetService; +import com.iflytek.skillhub.domain.media.MediaValidator; import com.iflytek.skillhub.domain.skill.VisibilityChecker; import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser; import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; @@ -41,4 +44,12 @@ public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMeta public VisibilityChecker visibilityChecker() { return new VisibilityChecker(); } + + @Bean + public MediaAssetService mediaAssetService(MediaAssetRepository repository, + MediaValidator mediaValidator, + MediaAssetService.MediaStorage storage, + MediaAssetService.MediaHasher hasher) { + return new MediaAssetService(repository, mediaValidator, storage, hasher); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/MediaConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/MediaConfig.java new file mode 100644 index 000000000..3bbe45194 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/MediaConfig.java @@ -0,0 +1,40 @@ +package com.iflytek.skillhub.config; + +import com.iflytek.skillhub.domain.media.MediaValidator; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Wires up the {@link MediaValidator} with file-size limits sourced from + * {@code skillhub.media.*}. Defaults are 10MB / 10MB matching the design doc. + */ +@Configuration +public class MediaConfig { + + @Bean + @ConfigurationProperties(prefix = "skillhub.media") + public MediaProperties mediaProperties() { + return new MediaProperties(); + } + + @Bean + public MediaValidator mediaValidator(MediaProperties properties) { + return new MediaValidator(properties.getMaxGifSize(), properties.getMaxImageSize()); + } + + public static class MediaProperties { + private long maxGifSize = 10L * 1024L * 1024L; + private long maxImageSize = 10L * 1024L * 1024L; + private long maxPromotionGifSize = 5L * 1024L * 1024L; + + public long getMaxGifSize() { return maxGifSize; } + public void setMaxGifSize(long maxGifSize) { this.maxGifSize = maxGifSize; } + + public long getMaxImageSize() { return maxImageSize; } + public void setMaxImageSize(long maxImageSize) { this.maxImageSize = maxImageSize; } + + public long getMaxPromotionGifSize() { return maxPromotionGifSize; } + public void setMaxPromotionGifSize(long maxPromotionGifSize) { this.maxPromotionGifSize = maxPromotionGifSize; } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MediaController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MediaController.java new file mode 100644 index 000000000..af4530477 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MediaController.java @@ -0,0 +1,75 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.media.MediaAsset; +import com.iflytek.skillhub.domain.media.MediaAssetRole; +import com.iflytek.skillhub.domain.media.MediaAssetService; +import com.iflytek.skillhub.domain.media.MediaOwnerType; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.media.MediaAssetResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +/** + * Public media endpoints. + * + * + */ +@RestController +@RequestMapping("/api/v1/media") +public class MediaController extends BaseApiController { + + private final MediaAssetService mediaAssetService; + + public MediaController(MediaAssetService mediaAssetService, ApiResponseFactory responseFactory) { + super(responseFactory); + this.mediaAssetService = mediaAssetService; + } + + @PostMapping + public ApiResponse upload(@RequestParam("file") MultipartFile file, + @RequestParam("ownerType") MediaOwnerType ownerType, + @RequestParam("ownerId") Long ownerId, + @RequestParam("role") MediaAssetRole role, + @RequestParam(value = "altText", required = false) String altText, + @RequestAttribute("userId") String userId, + HttpServletRequest httpRequest) throws IOException { + MediaAssetService.UploadCommand command = new MediaAssetService.UploadCommand( + ownerType, ownerId, role, file.getBytes(), file.getContentType(), + file.getOriginalFilename(), altText, userId); + MediaAsset asset = mediaAssetService.upload(command); + return ok("response.success.created", MediaAssetResponse.from(asset)); + } + + @GetMapping("/{id}") + public ResponseEntity 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: + * + *

+ */ +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} + ) : ( +
+ {alt} +
+ ) + ) : ( + {alt} setErrored(true)} + data-testid="gif-media-img" + /> + )} +
+ ) +} + +export default GifMediaDisplay