Przeglądaj źródła

feat:系统元信息API传递参数与响应使用具体的对象类型替代原先的字符串;联调OSS图片存储API;

yangyi 2 dni temu
rodzic
commit
52ba7eccf7

+ 2 - 6
sql/postgersql.sql

@@ -95,12 +95,8 @@ CREATE UNIQUE INDEX idx_meta_key ON dev.meta (key);
 CREATE INDEX idx_meta_value_gin ON dev.meta USING GIN (value);
 
 INSERT INTO dev.meta(key, value) VALUES
-                                     ('site','{
-                                       "title": "咕咕嘎嘎论坛",
-                                       "logo": "",
-                                       "announcement": "欢迎访问咕咕嘎嘎论坛!",
-                                       "statement": "本站所有内容仅代表发布者个人观点,平台对内容的真实性、完整性、及时性不做任何保证,请用户理性参考,谨慎打赏。"
-                                     }');
+                                     ('website_config','{"title": "咕咕嘎嘎论坛", "logo": "", "announcement": "欢迎访问咕咕嘎嘎论坛!", "statement": "本站所有内容仅代表发布者个人观点,平台对内容的真实性、完整性、及时性不做任何保证,请用户理性参考,谨慎打赏。"}'),
+                                     ('oss_config','{"endpoint": "","region":"","bucket":"","accessKey":"","secretKey":"","publicDomain":""}');
 
 -- =============================================
 -- 帖子表

+ 24 - 7
src/main/java/space/anyi/serve/controller/AttachmentController.java

@@ -4,36 +4,53 @@ import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.core.Authentication;
+import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 import space.anyi.serve.entity.Response;
+import space.anyi.serve.entity.attachment.ImageValidationException;
 import space.anyi.serve.entity.attachment.UploadVo;
 import space.anyi.serve.entity.auth.JwtUserDetails;
 import space.anyi.serve.service.AttachmentService;
+import space.anyi.serve.service.OssService;
+
+import java.util.Set;
 
 @Tag(name = "AttachmentController", description = "附件上传")
 @RestController
 @RequestMapping("attachments")
 public class AttachmentController {
 
+    private static final Set<String> ALLOWED_TYPES = Set.of(
+            "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp"
+    );
+
     private final AttachmentService attachmentService;
+    private final OssService ossService;
 
-    public AttachmentController(AttachmentService attachmentService) {
+    public AttachmentController(AttachmentService attachmentService, OssService ossService) {
         this.attachmentService = attachmentService;
+        this.ossService = ossService;
     }
 
-    @Operation(summary = "上传附件到OSS(需对接OSS配置)")
-    @PreAuthorize("hasAnyRole('ROLE_user', 'ROLE_expert', 'ROLE_admin')")
-    @PostMapping("upload")
+    @Operation(summary = "上传图片到OSS")
+    @PreAuthorize("hasAnyRole('user', 'expert', 'admin')")
+    @PostMapping(value = "upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
     public Response<UploadVo> upload(
-            @RequestParam("file") MultipartFile file,
+            @RequestPart("file") MultipartFile file,
             @RequestParam(defaultValue = "post_image") String type,
             Authentication authentication) {
+        if (file.isEmpty()) {
+            throw new ImageValidationException("文件不能为空");
+        }
+        if (!ALLOWED_TYPES.contains(file.getContentType())) {
+            throw new ImageValidationException("仅支持 JPG/PNG/GIF/WebP/BMP 格式的图片");
+        }
+
         JwtUserDetails details = (JwtUserDetails) authentication.getPrincipal();
         Long userId = details.getUser().getId();
 
-        // TODO: 对接 OSS 上传,当前返回占位 URL
-        String url = "https://oss.example.com/" + System.currentTimeMillis() + "_" + file.getOriginalFilename();
+        String url = ossService.upload(file, type);
         attachmentService.upload(userId, url, type);
         return Response.ok(new UploadVo(url));
     }

+ 15 - 8
src/main/java/space/anyi/serve/controller/MetaController.java

@@ -11,8 +11,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 import space.anyi.serve.entity.Response;
 import space.anyi.serve.entity.meta.Meta;
-import space.anyi.serve.entity.meta.OssConfigDto;
-import space.anyi.serve.entity.meta.WebsiteMetaDto;
+import space.anyi.serve.entity.meta.OssConfigMeta;
+import space.anyi.serve.entity.meta.WebsiteMeta;
 import space.anyi.serve.service.MetaService;
 
 /**
@@ -42,26 +42,33 @@ public class MetaController {
     }
     @PreAuthorize("hasAnyRole('ROLE_admin')")
     @PostMapping("updateWebsiteMeta")
-    public Response<Void> updateWebsiteMeta(@Valid @RequestBody WebsiteMetaDto websiteMetaDto){
+    public Response<Void> updateWebsiteMeta(@Valid @RequestBody WebsiteMeta websiteMeta) throws JsonProcessingException {
         Meta meta = new Meta();
         meta.setKey(Meta.WEBSITE_META_KEY);
-        meta.setValue(websiteMetaDto);
+        meta.setValue(objectMapper.writeValueAsString(websiteMeta));
         metaService.updateMeta(Meta.WEBSITE_META_KEY,meta);
         return Response.ok();
     }
 
+    @PreAuthorize("hasAnyRole('ROLE_admin')")
     @GetMapping("getOssConfig")
-    public Response<Object> getOssConfig() {
+    public Response<OssConfigMeta> getOssConfig() throws JsonProcessingException {
         Meta meta = metaService.getMeta(Meta.OSS_CONFIG_KEY);
-        return Response.ok(meta != null ? meta.getValue() : null);
+        if (meta == null || meta.getValue() == null) {
+            return Response.ok(new OssConfigMeta());
+        }
+        String json = meta.getValue() instanceof String
+            ? (String) meta.getValue()
+            : objectMapper.writeValueAsString(meta.getValue());
+        return Response.ok(objectMapper.readValue(json, OssConfigMeta.class));
     }
 
     @PreAuthorize("hasAnyRole('ROLE_admin')")
     @PostMapping("updateOssConfig")
-    public Response<Void> updateOssConfig(@Valid @RequestBody OssConfigDto ossConfigDto){
+    public Response<Void> updateOssConfig(@Valid @RequestBody OssConfigMeta ossConfigMeta) throws JsonProcessingException {
         Meta meta = new Meta();
         meta.setKey(Meta.OSS_CONFIG_KEY);
-        meta.setValue(ossConfigDto);
+        meta.setValue(objectMapper.writeValueAsString(ossConfigMeta));
         metaService.updateMeta(Meta.OSS_CONFIG_KEY, meta);
         return Response.ok();
     }

+ 7 - 0
src/main/java/space/anyi/serve/entity/attachment/ImageValidationException.java

@@ -0,0 +1,7 @@
+package space.anyi.serve.entity.attachment;
+
+public class ImageValidationException extends RuntimeException {
+    public ImageValidationException(String message) {
+        super(message);
+    }
+}

+ 4 - 4
src/main/java/space/anyi/serve/entity/meta/Meta.java

@@ -14,7 +14,7 @@ import jakarta.validation.constraints.Size;
 @Schema(description = "元数据实体,存储键值对形式的配置")
 public class Meta {
     public static final String WEBSITE_META_KEY = "website_config";
-    public static final String OSS_CONFIG_KEY = "oss";
+    public static final String OSS_CONFIG_KEY = "oss_config";
     /**
      * 自增主键,唯一标识一条元数据记录
      */
@@ -34,7 +34,7 @@ public class Meta {
      * 元数据的值,使用 JSONB 类型存储,支持结构化数据
      */
     @Schema(description = "元数据的值,JSONB 格式")
-    private Object value;
+    private String value;
 
     /**
      * 自增主键,唯一标识一条元数据记录
@@ -67,14 +67,14 @@ public class Meta {
     /**
      * 元数据的值,使用 JSONB 类型存储,支持结构化数据
      */
-    public Object getValue() {
+    public String getValue() {
         return value;
     }
 
     /**
      * 元数据的值,使用 JSONB 类型存储,支持结构化数据
      */
-    public void setValue(Object value) {
+    public void setValue(String value) {
         this.value = value;
     }
 

+ 1 - 1
src/main/java/space/anyi/serve/entity/meta/OssConfigDto.java → src/main/java/space/anyi/serve/entity/meta/OssConfigMeta.java

@@ -3,7 +3,7 @@ package space.anyi.serve.entity.meta;
 import io.swagger.v3.oas.annotations.media.Schema;
 
 @Schema(description = "OSS配置DTO,用于更新OSS存储配置信息")
-public class OssConfigDto {
+public class OssConfigMeta {
     @Schema(description = "Endpoint")
     String endpoint = "";
 

+ 1 - 1
src/main/java/space/anyi/serve/entity/meta/WebsiteMetaDto.java → src/main/java/space/anyi/serve/entity/meta/WebsiteMeta.java

@@ -12,7 +12,7 @@ import jakarta.validation.constraints.Size;
  * @description:
  */
 @Schema(description = "网站元数据DTO,用于更新网站配置信息")
-public class WebsiteMetaDto {
+public class WebsiteMeta {
     @Size(max = 200, message = "网站标题长度不能超过200个字符")
     @Schema(description = "网站标题", maxLength = 200)
     String title = "";

+ 14 - 0
src/main/java/space/anyi/serve/handler/GlobalExceptionHandler.java

@@ -8,8 +8,10 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.multipart.MaxUploadSizeExceededException;
 import org.springframework.web.servlet.View;
 import space.anyi.serve.entity.Response;
+import space.anyi.serve.entity.attachment.ImageValidationException;
 
 import java.util.List;
 import java.util.stream.Collectors;
@@ -62,6 +64,18 @@ public class GlobalExceptionHandler {
         return Response.error("参数不合法",fieldErrors);
     }
 
+    @ExceptionHandler(MaxUploadSizeExceededException.class)
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    public Response<Void> handleMaxUploadSizeExceeded(MaxUploadSizeExceededException e) {
+        return Response.error("文件大小不能超过 1MB");
+    }
+
+    @ExceptionHandler(ImageValidationException.class)
+    @ResponseStatus(HttpStatus.BAD_REQUEST)
+    public Response<Void> handleImageValidation(ImageValidationException e) {
+        return Response.error(e.getMessage());
+    }
+
     @ExceptionHandler(Exception.class)
     public Response handlerException(Exception e){
         return Response.error(e.getMessage());

+ 0 - 1
src/main/java/space/anyi/serve/service/MetaService.java

@@ -2,7 +2,6 @@ package space.anyi.serve.service;
 
 import space.anyi.serve.entity.meta.Meta;
 import com.baomidou.mybatisplus.extension.service.IService;
-import space.anyi.serve.entity.meta.WebsiteMetaDto;
 
 /**
 * @author yangyi

+ 7 - 0
src/main/java/space/anyi/serve/service/OssService.java

@@ -0,0 +1,7 @@
+package space.anyi.serve.service;
+
+import org.springframework.web.multipart.MultipartFile;
+
+public interface OssService {
+    String upload(MultipartFile file, String type);
+}

+ 0 - 2
src/main/java/space/anyi/serve/service/impl/MetaServiceImpl.java

@@ -1,7 +1,6 @@
 package space.anyi.serve.service.impl;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.fasterxml.jackson.core.JsonProcessingException;
@@ -9,7 +8,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import space.anyi.serve.entity.meta.Meta;
-import space.anyi.serve.entity.meta.WebsiteMetaDto;
 import space.anyi.serve.service.MetaService;
 import space.anyi.serve.mapper.MetaMapper;
 import org.springframework.stereotype.Service;

+ 116 - 0
src/main/java/space/anyi/serve/service/impl/OssServiceImpl.java

@@ -0,0 +1,116 @@
+package space.anyi.serve.service.impl;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import space.anyi.serve.entity.attachment.ImageValidationException;
+import space.anyi.serve.entity.meta.Meta;
+import space.anyi.serve.entity.meta.OssConfigMeta;
+import space.anyi.serve.service.MetaService;
+import space.anyi.serve.service.OssService;
+
+import java.net.URI;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
+import java.util.UUID;
+
+@Service
+public class OssServiceImpl implements OssService {
+
+    private static final Logger log = LoggerFactory.getLogger(OssServiceImpl.class);
+    private static final Map<String, String> MIME_TO_EXT = Map.of(
+            "image/jpeg", ".jpg",
+            "image/png", ".png",
+            "image/gif", ".gif",
+            "image/webp", ".webp",
+            "image/bmp", ".bmp"
+    );
+    private static final String OBJECT_KEY_PATTERN = "forum/%s/%s/%s%s";
+
+    private final MetaService metaService;
+    private final ObjectMapper objectMapper;
+
+    public OssServiceImpl(MetaService metaService, ObjectMapper objectMapper) {
+        this.metaService = metaService;
+        this.objectMapper = objectMapper;
+    }
+
+    @Override
+    public String upload(MultipartFile file, String type) {
+        OssConfigMeta config = getOssConfig();
+
+        String ext = MIME_TO_EXT.get(file.getContentType());
+        if (ext == null) {
+            throw new ImageValidationException("不支持的文件类型: " + file.getContentType());
+        }
+
+        String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"));
+        String uuid = UUID.randomUUID().toString().replace("-", "");
+        String objectKey = String.format(OBJECT_KEY_PATTERN, type, dateDir, uuid, ext);
+
+        S3Client s3Client = buildS3Client(config);
+        try (s3Client) {
+            PutObjectRequest putRequest = PutObjectRequest.builder()
+                    .bucket(config.getBucket())
+                    .key(objectKey)
+                    .contentType(file.getContentType())
+                    .build();
+            s3Client.putObject(putRequest, RequestBody.fromBytes(file.getBytes()));
+            log.info("OSS upload success: bucket={}, key={}", config.getBucket(), objectKey);
+        } catch (Exception e) {
+            log.error("OSS upload failed", e);
+            throw new RuntimeException("文件上传失败", e);
+        }
+
+        String publicDomain = config.getPublicDomain();
+        if (publicDomain.endsWith("/")) {
+            return publicDomain + objectKey;
+        }
+        return publicDomain + "/" + objectKey;
+    }
+
+    private OssConfigMeta getOssConfig(){
+        Meta meta = metaService.getMeta(Meta.OSS_CONFIG_KEY);
+        String value = meta.getValue();
+        if (meta == null || value == null) {
+            throw new ImageValidationException("OSS 未配置");
+        }
+        OssConfigMeta config = null;
+        try {
+            config = objectMapper.readValue(value, OssConfigMeta.class);
+        } catch (JsonProcessingException e) {
+            throw new RuntimeException(e);
+        }
+        if (config.getEndpoint() == null || config.getEndpoint().isBlank() ||
+                config.getRegion() == null || config.getRegion().isBlank() ||
+                config.getBucket() == null || config.getBucket().isBlank() ||
+                config.getAccessKey() == null || config.getAccessKey().isBlank() ||
+                config.getSecretKey() == null || config.getSecretKey().isBlank() ||
+                config.getPublicDomain() == null || config.getPublicDomain().isBlank()) {
+            throw new ImageValidationException("OSS 配置不完整");
+        }
+        return config;
+    }
+
+    // package-private for testability
+    S3Client buildS3Client(OssConfigMeta config) {
+        return S3Client.builder()
+                .endpointOverride(URI.create("https://"+config.getEndpoint()))
+                .region(Region.of(config.getRegion()))
+                .credentialsProvider(StaticCredentialsProvider.create(
+                        AwsBasicCredentials.create(config.getAccessKey(), config.getSecretKey())
+                ))
+                .forcePathStyle(true)
+                .build();
+    }
+}

+ 4 - 0
src/main/resources/application.yaml

@@ -2,6 +2,10 @@ server:
   port: 8080
 
 spring:
+  servlet:
+    multipart:
+      max-file-size: 1MB
+      max-request-size: 1MB
   application:
     name: serve
     admin: