feat: 搭建后端多租户名片服务

- 初始化 Spring Boot 多模块工程与通用基础能力

- 增加租户、组织、用户、名片、文件、统计等业务模块

- 补充数据库迁移脚本与基础测试
This commit is contained in:
2026-03-20 12:43:21 +08:00
parent 1a2a078c0f
commit 9ef50288e9
95 changed files with 6722 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easycard</groupId>
<artifactId>easycard-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>easycard-module-file</artifactId>
<name>easycard-module-file</name>
<dependencies>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-user</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.17</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,36 @@
package com.easycard.module.file.controller;
import com.easycard.common.api.ApiResponse;
import com.easycard.module.file.service.FileAssetService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
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.util.List;
@RestController
@RequestMapping("/api/v1/files")
public class FileAssetController {
private final FileAssetService fileAssetService;
public FileAssetController(FileAssetService fileAssetService) {
this.fileAssetService = fileAssetService;
}
@PostMapping("/upload")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<FileAssetService.FileAssetView> upload(@RequestParam("file") MultipartFile file) {
return ApiResponse.success(fileAssetService.upload(file));
}
@GetMapping
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<List<FileAssetService.FileAssetView>> list() {
return ApiResponse.success(fileAssetService.listAssets());
}
}

View File

@@ -0,0 +1,35 @@
package com.easycard.module.file.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("file_asset")
public class FileAssetDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private Long uploadUserId;
private String storageProvider;
private String bucketName;
private String objectKey;
private String originalName;
private String fileExt;
private String mimeType;
private Long fileSize;
private String fileHash;
private String accessUrl;
private String assetStatus;
private Long createdBy;
private LocalDateTime createdTime;
private Long updatedBy;
private LocalDateTime updatedTime;
@TableLogic
private Integer deleted;
}

View File

@@ -0,0 +1,28 @@
package com.easycard.module.file.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("file_asset_usage")
public class FileAssetUsageDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private Long assetId;
private String bizType;
private Long bizId;
private String fieldName;
private Long createdBy;
private LocalDateTime createdTime;
private Long updatedBy;
private LocalDateTime updatedTime;
@TableLogic
private Integer deleted;
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.file.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.file.dal.entity.FileAssetDO;
public interface FileAssetMapper extends BaseMapper<FileAssetDO> {
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.file.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.file.dal.entity.FileAssetUsageDO;
public interface FileAssetUsageMapper extends BaseMapper<FileAssetUsageDO> {
}

View File

@@ -0,0 +1,134 @@
package com.easycard.module.file.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.easycard.common.auth.LoginUser;
import com.easycard.common.auth.SecurityUtils;
import com.easycard.common.exception.BusinessException;
import com.easycard.common.storage.StorageUrlUtils;
import com.easycard.module.file.dal.entity.FileAssetDO;
import com.easycard.module.file.dal.mapper.FileAssetMapper;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class FileAssetService {
private final FileAssetMapper fileAssetMapper;
@Value("${easycard.storage.endpoint}")
private String endpoint;
@Value("${easycard.storage.public-endpoint:${easycard.storage.endpoint}}")
private String publicEndpoint;
@Value("${easycard.storage.access-key}")
private String accessKey;
@Value("${easycard.storage.secret-key}")
private String secretKey;
@Value("${easycard.storage.bucket}")
private String bucket;
@Transactional
public FileAssetView upload(MultipartFile file) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
if (file == null || file.isEmpty()) {
throw new BusinessException("FILE_EMPTY", "上传文件不能为空");
}
if (file.getSize() > 5 * 1024 * 1024L) {
throw new BusinessException("FILE_TOO_LARGE", "图片大小不能超过 5MB");
}
String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
if (!contentType.startsWith("image/")) {
throw new BusinessException("FILE_TYPE_INVALID", "仅支持图片上传");
}
String extension = resolveExtension(file.getOriginalFilename());
String objectKey = loginUser.tenantId() + "/" + UUID.randomUUID() + extension;
try (InputStream inputStream = file.getInputStream()) {
MinioClient minioClient = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucket)
.object(objectKey)
.stream(inputStream, file.getSize(), -1)
.contentType(file.getContentType())
.build());
} catch (Exception exception) {
throw new BusinessException("FILE_UPLOAD_FAILED", "上传文件失败: " + exception.getMessage());
}
FileAssetDO asset = new FileAssetDO();
asset.setTenantId(loginUser.tenantId());
asset.setUploadUserId(loginUser.userId());
asset.setStorageProvider("MINIO");
asset.setBucketName(bucket);
asset.setObjectKey(objectKey);
asset.setOriginalName(file.getOriginalFilename());
asset.setFileExt(extension);
asset.setMimeType(file.getContentType());
asset.setFileSize(file.getSize());
asset.setAccessUrl(StorageUrlUtils.buildPublicUrl(publicEndpoint, bucket, objectKey, ""));
asset.setAssetStatus("ACTIVE");
asset.setCreatedBy(loginUser.userId());
asset.setUpdatedBy(loginUser.userId());
asset.setDeleted(0);
fileAssetMapper.insert(asset);
return toView(asset);
}
public List<FileAssetView> listAssets() {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
return fileAssetMapper.selectList(Wrappers.<FileAssetDO>lambdaQuery()
.eq(FileAssetDO::getTenantId, loginUser.tenantId())
.eq(FileAssetDO::getDeleted, 0)
.orderByDesc(FileAssetDO::getId))
.stream()
.map(this::toView)
.toList();
}
public FileAssetView getAsset(Long assetId) {
FileAssetDO asset = fileAssetMapper.selectById(assetId);
if (asset == null || Integer.valueOf(1).equals(asset.getDeleted())) {
throw new BusinessException("ASSET_NOT_FOUND", "素材不存在");
}
return toView(asset);
}
private FileAssetView toView(FileAssetDO asset) {
return new FileAssetView(
asset.getId(),
asset.getOriginalName(),
asset.getMimeType(),
asset.getFileSize(),
StorageUrlUtils.buildPublicUrl(publicEndpoint, asset.getBucketName(), asset.getObjectKey(), asset.getAccessUrl()),
asset.getCreatedTime() == null ? LocalDateTime.now() : asset.getCreatedTime()
);
}
private String resolveExtension(String originalName) {
if (originalName == null || !originalName.contains(".")) {
return "";
}
return originalName.substring(originalName.lastIndexOf('.'));
}
public record FileAssetView(Long id, String originalName, String mimeType, Long fileSize, String accessUrl, LocalDateTime createdTime) {
}
}