Compare commits
2 Commits
2e2697140c
...
637f125348
| Author | SHA1 | Date | |
|---|---|---|---|
| 637f125348 | |||
| 4ee3ebcbec |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -35,4 +35,8 @@ build/
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
|
||||
### mybatis plus generator
|
||||
/generator/
|
||||
@@ -5,15 +5,15 @@ package cn.meowrain.aioj.backend.framework.core.enums;
|
||||
*/
|
||||
public enum DelStatusEnum {
|
||||
|
||||
STATUS_NORMAL("0"), STATUS_DELETE("1");
|
||||
STATUS_NORMAL(0), STATUS_DELETE(1);
|
||||
|
||||
private final String code;
|
||||
private final Integer code;
|
||||
|
||||
DelStatusEnum(String code) {
|
||||
DelStatusEnum(Integer code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
public Integer code() {
|
||||
return this.code;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.generator.FastAutoGenerator;
|
||||
import com.baomidou.mybatisplus.generator.config.OutputFile;
|
||||
import com.baomidou.mybatisplus.generator.config.TemplateType;
|
||||
import com.baomidou.mybatisplus.generator.config.rules.DateType;
|
||||
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
|
||||
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
|
||||
@@ -20,6 +21,11 @@ import java.util.Collections;
|
||||
* 3. 修改输出路径和包名
|
||||
* 4. 运行 main 方法
|
||||
* </p>
|
||||
* <p>
|
||||
* 生成规则:
|
||||
* - Entity: XxxDO (数据对象)
|
||||
* - Controller: /v1/xxx (RESTful 风格,包含 CRUD)
|
||||
* </p>
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 1.0.0
|
||||
@@ -28,11 +34,11 @@ public class CodeGenerator {
|
||||
|
||||
// ==================== 数据库配置 ====================
|
||||
/** 数据库地址 */
|
||||
private static final String DB_URL = "jdbc:mysql://10.0.0.10:3306/aioj?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
|
||||
private static final String DB_URL = "jdbc:mysql://10.0.0.10:3306/aioj_dev?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
|
||||
/** 数据库用户名 */
|
||||
private static final String DB_USERNAME = "root";
|
||||
/** 数据库密码 */
|
||||
private static final String DB_PASSWORD = "123456";
|
||||
private static final String DB_PASSWORD = "root";
|
||||
|
||||
// ==================== 代码生成配置 ====================
|
||||
/** 作者名 */
|
||||
@@ -40,17 +46,17 @@ public class CodeGenerator {
|
||||
/** 父包名 */
|
||||
private static final String PARENT_PACKAGE = "cn.meowrain.aioj.backend";
|
||||
/** 模块名 (如: userservice, questionservice) */
|
||||
private static final String MODULE_NAME = "userservice";
|
||||
private static final String MODULE_NAME = "fileservice";
|
||||
/** 要生成的表名 (多个表用逗号分隔) */
|
||||
private static final String[] TABLE_NAMES = {"sys_user", "sys_role"};
|
||||
private static final String[] TABLE_NAMES = {"attachment"};
|
||||
/** 表前缀 (生成时会去掉前缀) */
|
||||
private static final String[] TABLE_PREFIX = {"sys_", "t_"};
|
||||
private static final String[] TABLE_PREFIX = {""};
|
||||
|
||||
// ==================== 输出路径配置 ====================
|
||||
/** 代码输出目录 (默认当前项目的 src/main/java) */
|
||||
private static final String OUTPUT_DIR = System.getProperty("user.dir") + "/src/main/java";
|
||||
private static final String OUTPUT_DIR = System.getProperty("user.dir") + "/generator/src/main/java";
|
||||
/** Mapper XML 输出目录 */
|
||||
private static final String MAPPER_XML_DIR = System.getProperty("user.dir") + "/src/main/resources/mapper";
|
||||
private static final String MAPPER_XML_DIR = System.getProperty("user.dir") + "/generator/src/main/resources/mapper";
|
||||
|
||||
public static void main(String[] args) {
|
||||
generateCode();
|
||||
@@ -85,8 +91,9 @@ public class CodeGenerator {
|
||||
.strategyConfig(builder -> builder
|
||||
.addInclude(TABLE_NAMES)
|
||||
.addTablePrefix(TABLE_PREFIX)
|
||||
// Entity 策略
|
||||
// Entity 策略: 生成 XxxDO
|
||||
.entityBuilder()
|
||||
.formatFileName("%sDO") // 实体类后缀改为 DO
|
||||
.enableLombok()
|
||||
.enableTableFieldAnnotation()
|
||||
.naming(NamingStrategy.underline_to_camel)
|
||||
@@ -108,6 +115,15 @@ public class CodeGenerator {
|
||||
.controllerBuilder()
|
||||
.enableRestStyle()
|
||||
)
|
||||
// 自定义模板 (使用 /templates/ 目录下的模板)
|
||||
.templateConfig(builder -> builder
|
||||
.entity("/templates/entityDO.java")
|
||||
.controller("/templates/controller.java")
|
||||
.service("/templates/service.java")
|
||||
.serviceImpl("/templates/serviceImpl.java")
|
||||
.mapper("/templates/mapper.java")
|
||||
.xml("/templates/mapper.xml")
|
||||
)
|
||||
// 模板引擎
|
||||
.templateEngine(new FreemarkerTemplateEngine())
|
||||
.execute();
|
||||
@@ -115,5 +131,9 @@ public class CodeGenerator {
|
||||
System.out.println("========== 代码生成完成 ==========");
|
||||
System.out.println("输出目录: " + OUTPUT_DIR);
|
||||
System.out.println("Mapper XML: " + MAPPER_XML_DIR);
|
||||
System.out.println();
|
||||
System.out.println("生成规则:");
|
||||
System.out.println(" - Entity: XxxDO");
|
||||
System.out.println(" - Controller: /v1/xxx (包含 CRUD 增删改查)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ import java.util.Scanner;
|
||||
* <p>
|
||||
* 运行后会在控制台提示输入相关配置信息
|
||||
* </p>
|
||||
* <p>
|
||||
* 生成规则:
|
||||
* - Entity: XxxDO (数据对象)
|
||||
* - Controller: /v1/xxx (RESTful 风格,包含 CRUD)
|
||||
* </p>
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 1.0.0
|
||||
@@ -118,8 +123,9 @@ public class InteractiveCodeGenerator {
|
||||
builder.addTablePrefix(tablePrefixes);
|
||||
}
|
||||
builder
|
||||
// Entity 策略
|
||||
// Entity 策略: 生成 XxxDO
|
||||
.entityBuilder()
|
||||
.formatFileName("%sDO") // 实体类后缀改为 DO
|
||||
.enableLombok()
|
||||
.enableTableFieldAnnotation()
|
||||
.naming(NamingStrategy.underline_to_camel)
|
||||
@@ -141,6 +147,15 @@ public class InteractiveCodeGenerator {
|
||||
.controllerBuilder()
|
||||
.enableRestStyle();
|
||||
})
|
||||
// 自定义模板 (使用 /templates/ 目录下的模板)
|
||||
.templateConfig(builder -> builder
|
||||
.entity("/templates/entityDO.java")
|
||||
.controller("/templates/controller.java")
|
||||
.service("/templates/service.java")
|
||||
.serviceImpl("/templates/serviceImpl.java")
|
||||
.mapper("/templates/mapper.java")
|
||||
.xml("/templates/mapper.xml")
|
||||
)
|
||||
// 模板引擎
|
||||
.templateEngine(new FreemarkerTemplateEngine())
|
||||
.execute();
|
||||
@@ -149,5 +164,9 @@ public class InteractiveCodeGenerator {
|
||||
System.out.println("========== 代码生成完成 ==========");
|
||||
System.out.println("Java 代码: " + outputDir);
|
||||
System.out.println("Mapper XML: " + mapperXmlDir);
|
||||
System.out.println();
|
||||
System.out.println("生成规则:");
|
||||
System.out.println(" - Entity: XxxDO");
|
||||
System.out.println(" - Controller: /v1/xxx (包含 CRUD 增删改查)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package ${package.Controller};
|
||||
|
||||
import ${package.Entity}.${entity}DO;
|
||||
import ${package.Service}.${table.serviceName};
|
||||
import cn.meowrain.aioj.backend.framework.result.Result;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* ${table.comment!} 控制器
|
||||
*
|
||||
* @author ${author}
|
||||
* @since ${date}
|
||||
*/
|
||||
@Tag(name = "${table.comment!}管理")
|
||||
@RestController
|
||||
@RequestMapping("/v1/<#if controllerMappingHyphenStyle>${controllerMappingHyphen}<#else>${table.entityPath}</#if>")
|
||||
@RequiredArgsConstructor
|
||||
public class ${table.controllerName} {
|
||||
|
||||
private final ${table.serviceName} ${table.entityPath}Service;
|
||||
|
||||
/**
|
||||
* 分页查询${table.comment!}列表
|
||||
*/
|
||||
@Operation(summary = "分页查询${table.comment!}列表")
|
||||
@GetMapping("/page")
|
||||
public Result<Page<${entity}DO>> page(
|
||||
@Parameter(description = "当前页码") @RequestParam(defaultValue = "1") Integer current,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Integer size) {
|
||||
Page<${entity}DO> page = new Page<>(current, size);
|
||||
return Result.success(${table.entityPath}Service.page(page));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有${table.comment!}列表
|
||||
*/
|
||||
@Operation(summary = "查询所有${table.comment!}列表")
|
||||
@GetMapping("/list")
|
||||
public Result<List<${entity}DO>> list() {
|
||||
return Result.success(${table.entityPath}Service.list());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查询${table.comment!}详情
|
||||
*/
|
||||
@Operation(summary = "根据ID查询${table.comment!}详情")
|
||||
@GetMapping("/{id}")
|
||||
public Result<${entity}DO> getById(
|
||||
@Parameter(description = "${table.comment!}ID") @PathVariable Long id) {
|
||||
return Result.success(${table.entityPath}Service.getById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增${table.comment!}
|
||||
*/
|
||||
@Operation(summary = "新增${table.comment!}")
|
||||
@PostMapping
|
||||
public Result<Boolean> save(@RequestBody ${entity}DO entity) {
|
||||
return Result.success(${table.entityPath}Service.save(entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改${table.comment!}
|
||||
*/
|
||||
@Operation(summary = "修改${table.comment!}")
|
||||
@PutMapping
|
||||
public Result<Boolean> update(@RequestBody ${entity}DO entity) {
|
||||
return Result.success(${table.entityPath}Service.updateById(entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除${table.comment!}
|
||||
*/
|
||||
@Operation(summary = "删除${table.comment!}")
|
||||
@DeleteMapping("/{id}")
|
||||
public Result<Boolean> delete(
|
||||
@Parameter(description = "${table.comment!}ID") @PathVariable Long id) {
|
||||
return Result.success(${table.entityPath}Service.removeById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除${table.comment!}
|
||||
*/
|
||||
@Operation(summary = "批量删除${table.comment!}")
|
||||
@DeleteMapping("/batch")
|
||||
public Result<Boolean> deleteBatch(@RequestBody List<Long> ids) {
|
||||
return Result.success(${table.entityPath}Service.removeByIds(ids));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package ${package.Entity};
|
||||
|
||||
<#list table.importPackages as pkg>
|
||||
import ${pkg};
|
||||
</#list>
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* ${table.comment!} 数据访问对象
|
||||
*
|
||||
* @author ${author}
|
||||
* @since ${date}
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@TableName("${table.name}")
|
||||
public class ${entity}DO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
<#-- 遍历字段 -->
|
||||
<#list table.fields as field>
|
||||
<#if field.keyFlag>
|
||||
/**
|
||||
* ${field.comment}
|
||||
*/
|
||||
@TableId(value = "${field.annotationColumnName}", type = IdType.ASSIGN_ID)
|
||||
private ${field.propertyType} ${field.propertyName};
|
||||
|
||||
<#elseif field.propertyName == "delFlag">
|
||||
/**
|
||||
* ${field.comment}
|
||||
*/
|
||||
@TableLogic
|
||||
@TableField("${field.annotationColumnName}")
|
||||
private ${field.propertyType} ${field.propertyName};
|
||||
|
||||
<#elseif field.propertyName == "createTime">
|
||||
/**
|
||||
* ${field.comment}
|
||||
*/
|
||||
@TableField(value = "${field.annotationColumnName}", fill = FieldFill.INSERT)
|
||||
private ${field.propertyType} ${field.propertyName};
|
||||
|
||||
<#elseif field.propertyName == "updateTime">
|
||||
/**
|
||||
* ${field.comment}
|
||||
*/
|
||||
@TableField(value = "${field.annotationColumnName}", fill = FieldFill.INSERT_UPDATE)
|
||||
private ${field.propertyType} ${field.propertyName};
|
||||
|
||||
<#else>
|
||||
/**
|
||||
* ${field.comment}
|
||||
*/
|
||||
@TableField("${field.annotationColumnName}")
|
||||
private ${field.propertyType} ${field.propertyName};
|
||||
|
||||
</#if>
|
||||
</#list>
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ${package.Mapper};
|
||||
|
||||
import ${package.Entity}.${entity}DO;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* ${table.comment!} Mapper 接口
|
||||
*
|
||||
* @author ${author}
|
||||
* @since ${date}
|
||||
*/
|
||||
@Mapper
|
||||
public interface ${table.mapperName} extends BaseMapper<${entity}DO> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="${package.Mapper}.${table.mapperName}">
|
||||
|
||||
<#if enableCache>
|
||||
<!-- 开启二级缓存 -->
|
||||
<cache type="org.mybatis.caches.ehcache.LoggingEhcache"/>
|
||||
|
||||
</#if>
|
||||
<#if baseResultMap>
|
||||
<!-- 通用查询映射结果 -->
|
||||
<resultMap id="BaseResultMap" type="${package.Entity}.${entity}DO">
|
||||
<#list table.fields as field>
|
||||
<#if field.keyFlag>
|
||||
<id column="${field.name}" property="${field.propertyName}" />
|
||||
<#else>
|
||||
<result column="${field.name}" property="${field.propertyName}" />
|
||||
</#if>
|
||||
</#list>
|
||||
</resultMap>
|
||||
|
||||
</#if>
|
||||
<#if baseColumnList>
|
||||
<!-- 通用查询结果列 -->
|
||||
<sql id="Base_Column_List">
|
||||
<#list table.commonFields as field>
|
||||
${field.columnName},
|
||||
</#list>
|
||||
${table.fieldNames}
|
||||
</sql>
|
||||
|
||||
</#if>
|
||||
</mapper>
|
||||
@@ -0,0 +1,14 @@
|
||||
package ${package.Service};
|
||||
|
||||
import ${package.Entity}.${entity}DO;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
|
||||
/**
|
||||
* ${table.comment!} 服务接口
|
||||
*
|
||||
* @author ${author}
|
||||
* @since ${date}
|
||||
*/
|
||||
public interface ${table.serviceName} extends IService<${entity}DO> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package ${package.Service}.impl;
|
||||
|
||||
import ${package.Entity}.${entity}DO;
|
||||
import ${package.Mapper}.${table.mapperName};
|
||||
import ${package.Service}.${table.serviceName};
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.springframework.stereotype.Service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* ${table.comment!} 服务实现类
|
||||
*
|
||||
* @author ${author}
|
||||
* @since ${date}
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ${table.serviceImplName} extends ServiceImpl<${table.mapperName}, ${entity}DO> implements ${table.serviceName} {
|
||||
|
||||
}
|
||||
@@ -81,5 +81,13 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-crypto</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -1,9 +1,13 @@
|
||||
package cn.meowrain.aioj.backend.fileservice;
|
||||
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties
|
||||
@MapperScan("cn.meowrain.aioj.backend.fileservice.dao.mapper")
|
||||
public class FileServiceApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(FileServiceApplication.class);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
|
||||
/**
|
||||
* 异步配置类
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
|
||||
@Value("${file-config.async.core-pool-size:2}")
|
||||
private int corePoolSize;
|
||||
|
||||
@Value("${file-config.async.max-pool-size:5}")
|
||||
private int maxPoolSize;
|
||||
|
||||
@Value("${file-config.async.queue-capacity:100}")
|
||||
private int queueCapacity;
|
||||
|
||||
@Value("${file-config.async.thread-name-prefix:file-async-}")
|
||||
private String threadNamePrefix;
|
||||
|
||||
@Bean("fileExecutor")
|
||||
public Executor fileExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(corePoolSize);
|
||||
executor.setMaxPoolSize(maxPoolSize);
|
||||
executor.setQueueCapacity(queueCapacity);
|
||||
executor.setThreadNamePrefix(threadNamePrefix);
|
||||
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
|
||||
executor.initialize();
|
||||
log.info("文件异步线程池初始化完成: coreSize={}, maxSize={}, queueCapacity={}",
|
||||
corePoolSize, maxPoolSize, queueCapacity);
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 文件存储配置属性
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
@Component
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = FileConfigurationProperties.PREFIX)
|
||||
public class FileConfigurationProperties {
|
||||
static final String PREFIX = "file-config";
|
||||
|
||||
/**
|
||||
* 是否使用云存储
|
||||
*/
|
||||
private boolean useCloud = false;
|
||||
|
||||
/**
|
||||
* 本地存储配置
|
||||
*/
|
||||
private Local local = new Local();
|
||||
|
||||
/**
|
||||
* 腾讯云COS存储配置
|
||||
*/
|
||||
private Cos cos = new Cos();
|
||||
|
||||
@Data
|
||||
public static class Local {
|
||||
/**
|
||||
* 本地存储基础路径
|
||||
*/
|
||||
private String basePath = "uploads";
|
||||
|
||||
/**
|
||||
* 访问域名
|
||||
*/
|
||||
private String domain = "http://localhost:10013/api";
|
||||
|
||||
/**
|
||||
* URL前缀
|
||||
*/
|
||||
private String urlPrefix = "/file";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Cos {
|
||||
/**
|
||||
* 是否启用COS
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* 腾讯云SecretId
|
||||
*/
|
||||
private String secretId;
|
||||
|
||||
/**
|
||||
* 腾讯云SecretKey
|
||||
*/
|
||||
private String secretKey;
|
||||
|
||||
/**
|
||||
* 区域,如ap-guangzhou、ap-beijing等
|
||||
*/
|
||||
private String region;
|
||||
|
||||
/**
|
||||
* 存储桶名称
|
||||
*/
|
||||
private String bucketName;
|
||||
|
||||
/**
|
||||
* 访问域名(可选,如果配置则使用该域名生成访问URL)
|
||||
*/
|
||||
private String domain;
|
||||
|
||||
/**
|
||||
* 文件前缀
|
||||
*/
|
||||
private String prefix = "";
|
||||
|
||||
/**
|
||||
* 预签名URL过期时间(秒)
|
||||
*/
|
||||
private Integer presignedUrlExpireSeconds = 3600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.config;
|
||||
|
||||
import cn.meowrain.aioj.backend.fileservice.storage.FileStorageStrategy;
|
||||
import cn.meowrain.aioj.backend.fileservice.storage.impl.CosFileStorageStrategy;
|
||||
import cn.meowrain.aioj.backend.fileservice.storage.impl.LocalFileStorageStrategy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
|
||||
/**
|
||||
* 文件存储配置类
|
||||
* 根据配置自动选择存储策略
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
public class FileStorageConfig {
|
||||
|
||||
private final LocalFileStorageStrategy localFileStorageStrategy;
|
||||
private final FileConfigurationProperties fileConfigurationProperties;
|
||||
private final ApplicationContext applicationContext;
|
||||
|
||||
/**
|
||||
* 配置文件存储策略
|
||||
* 优先使用腾讯云COS,如果未启用则使用本地存储
|
||||
*/
|
||||
@Bean
|
||||
@Primary
|
||||
public FileStorageStrategy fileStorageStrategy() {
|
||||
if (fileConfigurationProperties.isUseCloud()) {
|
||||
try {
|
||||
CosFileStorageStrategy cosStrategy = applicationContext.getBean(CosFileStorageStrategy.class);
|
||||
log.info("使用腾讯云COS文件存储策略");
|
||||
return cosStrategy;
|
||||
} catch (Exception e) {
|
||||
log.warn("腾讯云COS未配置,回退到本地文件存储策略");
|
||||
return localFileStorageStrategy;
|
||||
}
|
||||
} else {
|
||||
log.info("使用本地文件存储策略");
|
||||
return localFileStorageStrategy;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.config;
|
||||
|
||||
import com.github.xiaoymin.knife4j.spring.annotations.EnableKnife4j;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.info.Contact;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.info.License;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Swagger配置类
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableKnife4j
|
||||
public class SwaggerConfiguration implements ApplicationRunner {
|
||||
|
||||
@Value("${server.port:8080}")
|
||||
private String serverPort;
|
||||
|
||||
@Value("${server.servlet.context-path:}")
|
||||
private String contextPath;
|
||||
|
||||
@Bean
|
||||
public OpenAPI customerOpenAPI() {
|
||||
return new OpenAPI().info(new Info().title("AIOJ-文件服务✨")
|
||||
.description("文件上传、下载、存储管理等功能")
|
||||
.version("v1.0.0")
|
||||
.contact(new Contact().name("meowrain").email("meowrain@126.com"))
|
||||
.license(new License().name("MeowRain").url("https://meowrain.cn")));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) throws Exception {
|
||||
log.info("✨API Document: http://127.0.0.1:{}{}/doc.html", serverPort, contextPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.controller;
|
||||
|
||||
import cn.meowrain.aioj.backend.fileservice.dao.entity.AttachmentDO;
|
||||
import cn.meowrain.aioj.backend.fileservice.service.AttachmentService;
|
||||
import cn.meowrain.aioj.backend.framework.core.web.Result;
|
||||
import cn.meowrain.aioj.backend.framework.core.web.Results;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* 通用附件表 前端控制器
|
||||
* </p>
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/v1/file")
|
||||
@RequiredArgsConstructor
|
||||
public class AttachmentController {
|
||||
|
||||
private final AttachmentService attachmentService;
|
||||
@Operation(summary = "通用文件上传组件")
|
||||
@PostMapping("/upload")
|
||||
public Result<AttachmentDO> uploadFile(@RequestParam("file")MultipartFile file) {
|
||||
return Results.success(attachmentService.upload(file));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询通用附件表列表
|
||||
*/
|
||||
@Operation(summary = "分页查询通用附件表列表")
|
||||
@GetMapping("/page")
|
||||
public Result<Page<AttachmentDO>> page(
|
||||
@Parameter(description = "当前页码") @RequestParam(defaultValue = "1") Integer current,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Integer size) {
|
||||
Page<AttachmentDO> page = new Page<>(current, size);
|
||||
return Results.success(attachmentService.page(page));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询所有通用附件表列表
|
||||
*/
|
||||
@Operation(summary = "查询所有通用附件表列表")
|
||||
@GetMapping("/list")
|
||||
public Result<List<AttachmentDO>> list() {
|
||||
return Results.success(attachmentService.list());
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID查询通用附件表详情
|
||||
*/
|
||||
@Operation(summary = "根据ID查询通用附件表详情")
|
||||
@GetMapping("/{id}")
|
||||
public Result<AttachmentDO> getById(
|
||||
@Parameter(description = "通用附件表ID") @PathVariable Long id) {
|
||||
return Results.success(attachmentService.getById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增通用附件表
|
||||
*/
|
||||
@Operation(summary = "新增通用附件表")
|
||||
@PostMapping
|
||||
public Result<Boolean> save(@RequestBody AttachmentDO entity) {
|
||||
return Results.success(attachmentService.save(entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改通用附件表
|
||||
*/
|
||||
@Operation(summary = "修改通用附件表")
|
||||
@PutMapping
|
||||
public Result<Boolean> update(@RequestBody AttachmentDO entity) {
|
||||
return Results.success(attachmentService.updateById(entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除通用附件表
|
||||
*/
|
||||
@Operation(summary = "删除通用附件表")
|
||||
@DeleteMapping("/{id}")
|
||||
public Result<Boolean> delete(
|
||||
@Parameter(description = "通用附件表ID") @PathVariable Long id) {
|
||||
return Results.success(attachmentService.removeById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除通用附件表
|
||||
*/
|
||||
@Operation(summary = "批量删除通用附件表")
|
||||
@DeleteMapping("/batch")
|
||||
public Result<Boolean> deleteBatch(@RequestBody List<Long> ids) {
|
||||
return Results.success(attachmentService.removeByIds(ids));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* 本地文件访问控制器
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@ConditionalOnProperty(prefix = "file-config", name = "use-cloud", havingValue = "false", matchIfMissing = true)
|
||||
public class FileAccessController {
|
||||
|
||||
@Value("${file-config.local.base-path:uploads}")
|
||||
private String basePath;
|
||||
|
||||
@Value("${file-config.local.domain:http://localhost:10013}")
|
||||
private String domain;
|
||||
|
||||
@Value("${server.servlet.context-path:}")
|
||||
private String contextPath;
|
||||
|
||||
@GetMapping("/file/**")
|
||||
@Operation(summary = "访问本地文件")
|
||||
public void accessFile(HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||
String requestURI = request.getRequestURI();
|
||||
// 去除 context-path 和 /file 前缀,获取相对路径
|
||||
String prefix = contextPath + "/file";
|
||||
String filePath = requestURI.substring(prefix.length());
|
||||
|
||||
// 移除开头的斜杠(如果有)
|
||||
if (filePath.startsWith("/")) {
|
||||
filePath = filePath.substring(1);
|
||||
}
|
||||
|
||||
if (StrUtil.isBlank(filePath)) {
|
||||
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 Paths.get 自动处理路径分隔符
|
||||
Path targetPath = Paths.get(basePath, filePath).normalize();
|
||||
|
||||
// 安全检查:防止路径遍历攻击
|
||||
if (!targetPath.startsWith(Paths.get(basePath).normalize())) {
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Files.exists(targetPath)) {
|
||||
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
|
||||
String contentType = Files.probeContentType(targetPath);
|
||||
if (StrUtil.isBlank(contentType)) {
|
||||
contentType = MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
}
|
||||
|
||||
response.setContentType(contentType);
|
||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"inline; filename=\"" + targetPath.getFileName().toString() + "\"");
|
||||
|
||||
Files.copy(targetPath, response.getOutputStream());
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.dao;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class AttachmentDAO {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.dao.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* 通用附件表
|
||||
* </p>
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@TableName("attachment")
|
||||
public class AttachmentDO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 原始文件名
|
||||
*/
|
||||
@TableField("file_name")
|
||||
private String fileName;
|
||||
|
||||
/**
|
||||
* 文件后缀名
|
||||
*/
|
||||
@TableField("file_extension")
|
||||
private String fileExtension;
|
||||
|
||||
/**
|
||||
* 文件大小(Byte)
|
||||
*/
|
||||
@TableField("file_size")
|
||||
private Long fileSize;
|
||||
|
||||
/**
|
||||
* 文件哈希(MD5/SHA256)用于去重
|
||||
*/
|
||||
@TableField("file_hash")
|
||||
private String fileHash;
|
||||
|
||||
/**
|
||||
* MIME类型
|
||||
*/
|
||||
@TableField("mime_type")
|
||||
private String mimeType;
|
||||
|
||||
/**
|
||||
* 存储方案: LOCAL, OSS, S3, MINIO
|
||||
*/
|
||||
@TableField("storage_type")
|
||||
private String storageType;
|
||||
|
||||
/**
|
||||
* 物理存储路径或对象存储Key
|
||||
*/
|
||||
@TableField("storage_path")
|
||||
private String storagePath;
|
||||
|
||||
/**
|
||||
* 所属业务模块
|
||||
*/
|
||||
@TableField("business_type")
|
||||
private String businessType;
|
||||
|
||||
/**
|
||||
* 所属业务id
|
||||
*/
|
||||
@TableField("business_id")
|
||||
private Long businessId;
|
||||
|
||||
/**
|
||||
* 上传者ID
|
||||
*/
|
||||
@TableField("user_id")
|
||||
private Long userId;
|
||||
|
||||
/**
|
||||
* 图片宽高、EXIF等元数据
|
||||
*/
|
||||
@TableField("image_info")
|
||||
private String imageInfo;
|
||||
|
||||
/**
|
||||
* 逻辑删除(0-正常, 1-已删除)
|
||||
*/
|
||||
@TableField("is_deleted")
|
||||
private Integer isDeleted;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@TableField("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@TableField("updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.dao.mapper;
|
||||
|
||||
import cn.meowrain.aioj.backend.fileservice.dao.entity.AttachmentDO;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* 通用附件表 Mapper 接口
|
||||
* </p>
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
@Mapper
|
||||
public interface AttachmentMapper extends BaseMapper<AttachmentDO> {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.service;
|
||||
|
||||
import cn.meowrain.aioj.backend.fileservice.dao.entity.AttachmentDO;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* 通用附件表 服务类
|
||||
* </p>
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
public interface AttachmentService extends IService<AttachmentDO> {
|
||||
|
||||
|
||||
AttachmentDO upload(MultipartFile file);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.service;
|
||||
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.meowrain.aioj.backend.fileservice.config.FileConfigurationProperties;
|
||||
import cn.meowrain.aioj.backend.fileservice.dao.entity.AttachmentDO;
|
||||
import cn.meowrain.aioj.backend.fileservice.dao.mapper.AttachmentMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 异步哈希计算服务
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class HashCalculationService {
|
||||
|
||||
private final AttachmentMapper attachmentMapper;
|
||||
private final FileConfigurationProperties fileConfigurationProperties;
|
||||
|
||||
/**
|
||||
* 异步计算大文件哈希(只计算,不删除文件)
|
||||
*
|
||||
* @param attachmentId 新上传的附件ID
|
||||
* @param storagePath 存储路径
|
||||
* @param storageType 存储类型
|
||||
*/
|
||||
@Async("fileExecutor")
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void calculateHashAsync(Long attachmentId, String storagePath, String storageType) {
|
||||
FileInputStream fis = null;
|
||||
try {
|
||||
log.info("开始异步计算大文件哈希: attachmentId={}, storagePath={}, storageType={}",
|
||||
attachmentId, storagePath, storageType);
|
||||
|
||||
String fileHash;
|
||||
if ("LOCAL".equals(storageType)) {
|
||||
String fullPath = fileConfigurationProperties.getLocal().getBasePath() + "/" + storagePath;
|
||||
File file = new File(fullPath);
|
||||
fis = new FileInputStream(file);
|
||||
fileHash = DigestUtil.sha256Hex(fis);
|
||||
} else {
|
||||
log.warn("暂不支持云存储的异步哈希计算: storageType={}", storageType);
|
||||
return;
|
||||
}
|
||||
|
||||
// 只更新哈希值,不删除文件
|
||||
AttachmentDO update = new AttachmentDO();
|
||||
update.setId(attachmentId);
|
||||
update.setFileHash(fileHash);
|
||||
attachmentMapper.updateById(update);
|
||||
|
||||
log.info("大文件哈希计算完成: attachmentId={}, fileHash={}", attachmentId, fileHash);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("异步计算大文件哈希失败: attachmentId={}", attachmentId, e);
|
||||
} finally {
|
||||
if (fis != null) {
|
||||
try {
|
||||
fis.close();
|
||||
} catch (IOException e) {
|
||||
log.error("关闭文件流失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.service.impl;
|
||||
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.meowrain.aioj.backend.fileservice.dao.entity.AttachmentDO;
|
||||
import cn.meowrain.aioj.backend.fileservice.dao.mapper.AttachmentMapper;
|
||||
import cn.meowrain.aioj.backend.fileservice.service.AttachmentService;
|
||||
import cn.meowrain.aioj.backend.fileservice.service.HashCalculationService;
|
||||
import cn.meowrain.aioj.backend.fileservice.storage.FileStorageStrategy;
|
||||
import cn.meowrain.aioj.backend.framework.core.enums.DelStatusEnum;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* 通用附件表 服务实现类
|
||||
* </p>
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AttachmentServiceImpl extends ServiceImpl<AttachmentMapper, AttachmentDO> implements AttachmentService {
|
||||
|
||||
private final FileStorageStrategy fileStorageStrategy;
|
||||
private final HashCalculationService hashCalculationService;
|
||||
|
||||
/**
|
||||
* 是否启用文件去重
|
||||
*/
|
||||
@Value("${file-config.deduplication-enabled:true}")
|
||||
private boolean deduplicationEnabled;
|
||||
|
||||
/**
|
||||
* 同步计算哈希的文件大小阈值(字节)
|
||||
* 超过此大小异步计算,默认 10MB
|
||||
*/
|
||||
@Value("${file-config.sync-hash-threshold:10485760}")
|
||||
private long syncHashThreshold;
|
||||
|
||||
@Override
|
||||
public AttachmentDO upload(MultipartFile file) {
|
||||
try {
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String fileExtension = FileUtil.extName(originalFilename);
|
||||
long fileSize = file.getSize();
|
||||
|
||||
// 判断文件大小,选择处理方式
|
||||
boolean isSmallFile = fileSize <= syncHashThreshold;
|
||||
|
||||
if (deduplicationEnabled && isSmallFile) {
|
||||
// ========== 小文件:同步计算哈希,立即去重 ==========
|
||||
String fileHash = DigestUtil.sha256Hex(file.getInputStream());
|
||||
|
||||
AttachmentDO existingFile = getOne(new LambdaQueryWrapper<AttachmentDO>()
|
||||
.eq(AttachmentDO::getFileHash, fileHash)
|
||||
.eq(AttachmentDO::getIsDeleted, DelStatusEnum.STATUS_NORMAL.code())
|
||||
.last("LIMIT 1"));
|
||||
|
||||
if (existingFile != null) {
|
||||
log.info("小文件已存在,返回已有文件: fileName={}, fileHash={}, existingId={}",
|
||||
originalFilename, fileHash, existingFile.getId());
|
||||
return existingFile;
|
||||
}
|
||||
|
||||
// 文件不存在,上传并保存哈希
|
||||
return uploadAndSave(file, originalFilename, fileExtension, fileHash);
|
||||
} else {
|
||||
// ========== 大文件:直接上传,异步计算哈希 ==========
|
||||
AttachmentDO attachmentDO = uploadAndSave(file, originalFilename, fileExtension, null);
|
||||
|
||||
// 异步计算哈希(只计算,不删除文件)
|
||||
hashCalculationService.calculateHashAsync(attachmentDO.getId(), attachmentDO.getStoragePath(), attachmentDO.getStorageType());
|
||||
|
||||
return attachmentDO;
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("文件上传失败: {}", file.getOriginalFilename(), e);
|
||||
throw new RuntimeException("文件上传失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件并保存记录
|
||||
*/
|
||||
private AttachmentDO uploadAndSave(MultipartFile file, String originalFilename,
|
||||
String fileExtension, String fileHash) throws IOException {
|
||||
String fileName = IdUtil.fastSimpleUUID() + StrUtil.DOT + fileExtension;
|
||||
String datePath = LocalDateTime.now().toLocalDate().toString().replace("-", "/");
|
||||
String relativePath = datePath + "/" + fileName;
|
||||
|
||||
String fileUrl = fileStorageStrategy.upload(file, relativePath);
|
||||
|
||||
AttachmentDO attachmentDO = new AttachmentDO();
|
||||
attachmentDO.setId(IdUtil.getSnowflakeNextId());
|
||||
attachmentDO.setFileName(originalFilename);
|
||||
attachmentDO.setFileExtension(fileExtension);
|
||||
attachmentDO.setFileSize(file.getSize());
|
||||
attachmentDO.setFileHash(fileHash);
|
||||
attachmentDO.setMimeType(file.getContentType());
|
||||
attachmentDO.setStorageType(fileStorageStrategy.getStorageType());
|
||||
attachmentDO.setStoragePath(relativePath);
|
||||
attachmentDO.setCreatedAt(LocalDateTime.now());
|
||||
attachmentDO.setUpdatedAt(LocalDateTime.now());
|
||||
attachmentDO.setIsDeleted(DelStatusEnum.STATUS_NORMAL.code());
|
||||
|
||||
save(attachmentDO);
|
||||
log.info("文件上传成功: fileName={}, storageType={}, storagePath={}, fileHash={}, size={}KB",
|
||||
originalFilename, fileStorageStrategy.getStorageType(), relativePath,
|
||||
fileHash, file.getSize() / 1024);
|
||||
|
||||
return attachmentDO;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.storage;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* 文件存储策略接口
|
||||
* 支持多种存储方式:本地存储、腾讯云COS、阿里云OSS等
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
public interface FileStorageStrategy {
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*
|
||||
* @param file 文件
|
||||
* @param filePath 文件存储路径
|
||||
* @return 文件访问URL
|
||||
* @throws IOException 上传失败时抛出
|
||||
*/
|
||||
String upload(MultipartFile file, String filePath) throws IOException;
|
||||
|
||||
/**
|
||||
* 上传文件流
|
||||
*
|
||||
* @param inputStream 文件流
|
||||
* @param filePath 文件存储路径
|
||||
* @param fileSize 文件大小
|
||||
* @return 文件访问URL
|
||||
* @throws IOException 上传失败时抛出
|
||||
*/
|
||||
String upload(InputStream inputStream, String filePath, long fileSize) throws IOException;
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*
|
||||
* @param filePath 文件存储路径
|
||||
* @return 是否删除成功
|
||||
*/
|
||||
boolean delete(String filePath);
|
||||
|
||||
/**
|
||||
* 判断文件是否存在
|
||||
*
|
||||
* @param filePath 文件存储路径
|
||||
* @return 是否存在
|
||||
*/
|
||||
boolean exists(String filePath);
|
||||
|
||||
/**
|
||||
* 获取文件访问URL
|
||||
*
|
||||
* @param filePath 文件存储路径
|
||||
* @return 文件访问URL
|
||||
*/
|
||||
String getFileUrl(String filePath);
|
||||
|
||||
/**
|
||||
* 获取存储类型标识
|
||||
*
|
||||
* @return 存储类型,如 LOCAL、COS、OSS
|
||||
*/
|
||||
String getStorageType();
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.storage.impl;
|
||||
|
||||
import cn.meowrain.aioj.backend.fileservice.storage.FileStorageStrategy;
|
||||
import com.qcloud.cos.COSClient;
|
||||
import com.qcloud.cos.ClientConfig;
|
||||
import com.qcloud.cos.auth.BasicCOSCredentials;
|
||||
import com.qcloud.cos.auth.COSCredentials;
|
||||
import com.qcloud.cos.http.HttpMethodName;
|
||||
import com.qcloud.cos.model.ObjectMetadata;
|
||||
import com.qcloud.cos.model.PutObjectRequest;
|
||||
import com.qcloud.cos.region.Region;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 腾讯云COS文件存储策略
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
@Slf4j
|
||||
@Component("cosFileStorageStrategy")
|
||||
@ConditionalOnProperty(prefix = "file-config.cos", name = "enabled", havingValue = "true")
|
||||
public class CosFileStorageStrategy implements FileStorageStrategy {
|
||||
|
||||
@Value("${file-config.cos.secret-id}")
|
||||
private String secretId;
|
||||
|
||||
@Value("${file-config.cos.secret-key}")
|
||||
private String secretKey;
|
||||
|
||||
@Value("${file-config.cos.region}")
|
||||
private String region;
|
||||
|
||||
@Value("${file-config.cos.bucket-name}")
|
||||
private String bucketName;
|
||||
|
||||
@Value("${file-config.cos.domain:}")
|
||||
private String domain;
|
||||
|
||||
@Value("${file-config.cos.prefix:}")
|
||||
private String prefix;
|
||||
|
||||
@Value("${file-config.cos.presigned-url-expire-seconds:3600}")
|
||||
private Integer presignedUrlExpireSeconds;
|
||||
|
||||
private COSClient cosClient;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
try {
|
||||
COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
|
||||
ClientConfig clientConfig = new ClientConfig(new Region(region));
|
||||
cosClient = new COSClient(cred, clientConfig);
|
||||
log.info("腾讯云COS存储初始化成功, bucket: {}, region: {}", bucketName, region);
|
||||
} catch (Exception e) {
|
||||
log.error("腾讯云COS存储初始化失败", e);
|
||||
throw new RuntimeException("初始化腾讯云COS存储失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
if (cosClient != null) {
|
||||
cosClient.shutdown();
|
||||
log.info("腾讯云COS客户端已关闭");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String upload(org.springframework.web.multipart.MultipartFile file, String filePath) throws IOException {
|
||||
try {
|
||||
String key = buildKey(filePath);
|
||||
ObjectMetadata metadata = new ObjectMetadata();
|
||||
metadata.setContentLength(file.getSize());
|
||||
metadata.setContentType(file.getContentType());
|
||||
|
||||
PutObjectRequest request = new PutObjectRequest(bucketName, key, file.getInputStream(), metadata);
|
||||
cosClient.putObject(request);
|
||||
|
||||
log.info("文件上传到COS成功: {}", key);
|
||||
return getFileUrl(filePath);
|
||||
} catch (Exception e) {
|
||||
log.error("文件上传到COS失败: {}", filePath, e);
|
||||
throw new IOException("文件上传到COS失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String upload(InputStream inputStream, String filePath, long fileSize) throws IOException {
|
||||
try {
|
||||
String key = buildKey(filePath);
|
||||
ObjectMetadata metadata = new ObjectMetadata();
|
||||
metadata.setContentLength(fileSize);
|
||||
|
||||
PutObjectRequest request = new PutObjectRequest(bucketName, key, inputStream, metadata);
|
||||
cosClient.putObject(request);
|
||||
|
||||
log.info("文件流上传到COS成功: {}", key);
|
||||
return getFileUrl(filePath);
|
||||
} catch (Exception e) {
|
||||
log.error("文件流上传到COS失败: {}", filePath, e);
|
||||
throw new IOException("文件流上传到COS失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(String filePath) {
|
||||
try {
|
||||
String key = buildKey(filePath);
|
||||
cosClient.deleteObject(bucketName, key);
|
||||
log.info("文件从COS删除成功: {}", key);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("文件从COS删除失败: {}", filePath, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String filePath) {
|
||||
try {
|
||||
String key = buildKey(filePath);
|
||||
return cosClient.doesObjectExist(bucketName, key);
|
||||
} catch (Exception e) {
|
||||
log.error("检查文件是否存在失败: {}", filePath, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileUrl(String filePath) {
|
||||
String key = buildKey(filePath);
|
||||
|
||||
if (domain != null && !domain.isEmpty()) {
|
||||
return domain + "/" + key;
|
||||
}
|
||||
|
||||
Date expirationDate = new Date(System.currentTimeMillis() + presignedUrlExpireSeconds * 1000L);
|
||||
URL url = cosClient.generatePresignedUrl(bucketName, key, expirationDate, HttpMethodName.GET);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStorageType() {
|
||||
return "COS";
|
||||
}
|
||||
|
||||
private String buildKey(String filePath) {
|
||||
if (prefix != null && !prefix.isEmpty()) {
|
||||
return prefix + "/" + filePath;
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package cn.meowrain.aioj.backend.fileservice.storage.impl;
|
||||
|
||||
import cn.meowrain.aioj.backend.fileservice.storage.FileStorageStrategy;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
|
||||
/**
|
||||
* 本地文件存储策略
|
||||
*
|
||||
* @author meowrain
|
||||
* @since 2026-01-10
|
||||
*/
|
||||
@Slf4j
|
||||
@Component("localFileStorageStrategy")
|
||||
@RequiredArgsConstructor
|
||||
public class LocalFileStorageStrategy implements FileStorageStrategy {
|
||||
|
||||
@Value("${file-config.local.base-path:uploads}")
|
||||
private String basePath;
|
||||
|
||||
@Value("${file-config.local.domain:http://localhost:10013/api}")
|
||||
private String domain;
|
||||
|
||||
@Value("${file-config.local.url-prefix:/file}")
|
||||
private String urlPrefix;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
try {
|
||||
Path baseDir = Paths.get(basePath);
|
||||
if (!Files.exists(baseDir)) {
|
||||
Files.createDirectories(baseDir);
|
||||
log.info("创建本地文件存储目录: {}", basePath);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("创建本地文件存储目录失败: {}", basePath, e);
|
||||
throw new RuntimeException("初始化本地文件存储失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String upload(MultipartFile file, String filePath) throws IOException {
|
||||
Path targetPath = Paths.get(basePath, filePath);
|
||||
Path parentDir = targetPath.getParent();
|
||||
|
||||
if (parentDir != null && !Files.exists(parentDir)) {
|
||||
Files.createDirectories(parentDir);
|
||||
}
|
||||
|
||||
Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
log.info("文件上传成功: {}", targetPath);
|
||||
|
||||
return getFileUrl(filePath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String upload(InputStream inputStream, String filePath, long fileSize) throws IOException {
|
||||
Path targetPath = Paths.get(basePath, filePath);
|
||||
Path parentDir = targetPath.getParent();
|
||||
|
||||
if (parentDir != null && !Files.exists(parentDir)) {
|
||||
Files.createDirectories(parentDir);
|
||||
}
|
||||
|
||||
Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
log.info("文件流上传成功: {}", targetPath);
|
||||
|
||||
return getFileUrl(filePath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean delete(String filePath) {
|
||||
try {
|
||||
Path targetPath = Paths.get(basePath, filePath);
|
||||
if (Files.exists(targetPath)) {
|
||||
Files.delete(targetPath);
|
||||
log.info("文件删除成功: {}", targetPath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (IOException e) {
|
||||
log.error("文件删除失败: {}", filePath, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exists(String filePath) {
|
||||
Path targetPath = Paths.get(basePath, filePath);
|
||||
return Files.exists(targetPath);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFileUrl(String filePath) {
|
||||
// 确保URL中始终使用正斜杠
|
||||
String normalizedPath = filePath.replace("\\", "/");
|
||||
return domain + urlPrefix + "/" + normalizedPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getStorageType() {
|
||||
return "LOCAL";
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,4 @@
|
||||
spring:
|
||||
mail:
|
||||
host: smtp.qq.com
|
||||
port: 465
|
||||
username: 2705356115@qq.com
|
||||
# 这里使用授权码
|
||||
password: yohcndfrlxwcdfed
|
||||
default-encoding: UTF-8
|
||||
protocol: smtp
|
||||
properties:
|
||||
mail:
|
||||
smtp:
|
||||
ssl:
|
||||
enable: true # 在 properties 中明确指定
|
||||
auth: true
|
||||
starttls:
|
||||
enable: true # QQ邮箱也支持STARTTLS,但使用465端口时,ssl.enable=true是必须的
|
||||
data:
|
||||
redis:
|
||||
host: 10.0.0.10
|
||||
|
||||
@@ -3,6 +3,10 @@ spring:
|
||||
name: file-service
|
||||
profiles:
|
||||
active: @env@
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 500MB
|
||||
max-request-size: 500MB
|
||||
server:
|
||||
port: 10013
|
||||
servlet:
|
||||
@@ -20,8 +24,8 @@ springdoc:
|
||||
operations-sorter: alpha
|
||||
group-configs:
|
||||
- group: 'default'
|
||||
paths-to-match: '/api/**'
|
||||
packages-to-scan: cn.meowrain.aioj.backend.fileservice.controller
|
||||
paths-to-match: '/**'
|
||||
packages-to-scan: cn.meowrain.aioj.backend.fileservice
|
||||
knife4j:
|
||||
basic:
|
||||
enable: true
|
||||
@@ -31,7 +35,48 @@ mybatis-plus:
|
||||
configuration:
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
mapper-locations: classpath*:/mapper/**/*.xml
|
||||
aioj:
|
||||
log:
|
||||
enabled: true
|
||||
max-length: 20000
|
||||
|
||||
# 文件存储配置
|
||||
file-config:
|
||||
# 是否使用云存储(true=腾讯云COS,false=本地存储)
|
||||
use-cloud: false
|
||||
# 是否启用文件去重(相同文件只存储一份)
|
||||
deduplication-enabled: true
|
||||
# 同步计算哈希的文件大小阈值(字节),默认 10MB
|
||||
sync-hash-threshold: 10485760
|
||||
# 本地存储配置
|
||||
local:
|
||||
# 本地存储基础路径(相对路径)
|
||||
base-path: uploads
|
||||
# 访问域名(不含context-path)
|
||||
domain: http://localhost:10013
|
||||
# URL前缀(需包含context-path)
|
||||
url-prefix: /api/file
|
||||
# 腾讯云COS存储配置
|
||||
cos:
|
||||
# 是否启用COS
|
||||
enabled: false
|
||||
# 腾讯云SecretId
|
||||
secret-id: your-secret-id
|
||||
# 腾讯云SecretKey
|
||||
secret-key: your-secret-key
|
||||
# 区域,如ap-guangzhou、ap-beijing等
|
||||
region: ap-guangzhou
|
||||
# 存储桶名称
|
||||
bucket-name: your-bucket-name
|
||||
# 访问域名(可选,如果配置则使用该域名生成访问URL)
|
||||
domain: https://your-bucket-name.cos.ap-guangzhou.myqcloud.com
|
||||
# 文件前缀
|
||||
prefix: aioj
|
||||
# 预签名URL过期时间(秒)
|
||||
presigned-url-expire-seconds: 3600
|
||||
# 异步线程池配置(用于大文件哈希计算)
|
||||
async:
|
||||
# 核心线程数
|
||||
core-pool-size: 2
|
||||
# 最大线程数
|
||||
max-pool-size: 5
|
||||
# 队列容量
|
||||
queue-capacity: 100
|
||||
# 线程名前缀
|
||||
thread-name-prefix: file-async-
|
||||
|
||||
Reference in New Issue
Block a user