-
Notifications
You must be signed in to change notification settings - Fork 429
Feature/gif media display #449
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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. | ||||||||||
| * | ||||||||||
| * <ul> | ||||||||||
| * <li>{@code POST /api/v1/media} — upload a single asset (login required).</li> | ||||||||||
| * <li>{@code GET /api/v1/media/{id}} — stream the asset body. Public read for now, | ||||||||||
| * owner-content visibility tightening is left to the caller's auth filter.</li> | ||||||||||
| * </ul> | ||||||||||
| */ | ||||||||||
| @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<MediaAssetResponse> 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<byte[]> getAsset(@PathVariable Long id) { | ||||||||||
| MediaAsset asset = mediaAssetService.get(id); | ||||||||||
| byte[] body = mediaAssetService.read(id); | ||||||||||
|
Comment on lines
+63
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current implementation performs two database lookups for the same asset ID: once explicitly in the controller and once internally within
Suggested change
|
||||||||||
| 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); | ||||||||||
| } | ||||||||||
| } | ||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| * | ||
| * <p>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; } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<MediaAsset> findById(Long id); | ||
| List<MediaAsset> findByOwner(MediaOwnerType ownerType, Long ownerId); | ||
| Optional<MediaAsset> findFirstByOwnerAndRole(MediaOwnerType ownerType, Long ownerId, MediaAssetRole role); | ||
| List<MediaAsset> findBySha256(String sha256); | ||
| void delete(MediaAsset asset); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
maxPromotionGifSizelimit is currently ignored. TheMediaValidatorshould be initialized with all three limits defined in the properties to adhere to the design requirements mentioned in the PR description.