feat(storage): 集成腾讯云COS实现简历PDF文件存储

- 配置腾讯云COS相关参数和密钥设置
- 新增CosConfig配置类和CosService对象存储服务
- 实现文件上传、删除、存在性检查等功能
- 修改简历上传接口使用COS存储替代本地存储
- 限制文件格式仅支持PDF并更新前端验证逻辑
- 添加删除COS文件的后端接口和前端调用
- 移除前端MyResumes页面中的旧PDF管理组件
- 更新上传和删除操作的安全验证和权限检查
This commit is contained in:
2026-01-14 22:57:15 +08:00
parent a146c28cf8
commit fcf095728e
11 changed files with 513 additions and 67 deletions

View File

@@ -31,3 +31,4 @@ build/
### VS Code ###
.vscode/
/src/main/resources/application-dev.yml

View File

@@ -123,6 +123,12 @@
<version>6.0.0</version>
<scope>provided</scope>
</dependency>
<!--腾讯云COS对象存储-->
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos_api</artifactId>
<version>5.6.227</version>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,58 @@
package com.zds.boss.config;
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.region.Region;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 腾讯云COS配置类
*/
@Configuration
@ConfigurationProperties(prefix = "cos")
@Data
public class CosConfig {
/**
* 密钥ID
*/
private String secretId;
/**
* 密钥Key
*/
private String secretKey;
/**
* 地域
*/
private String region;
/**
* 存储桶名称
*/
private String bucketName;
/**
* 访问域名前缀
*/
private String urlPrefix;
/**
* 创建COS客户端Bean
*/
@Bean
public COSClient cosClient() {
// 初始化用户身份信息
COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
// 设置bucket的区域
ClientConfig clientConfig = new ClientConfig(new Region(region));
// 生成cos客户端
return new COSClient(cred, clientConfig);
}
}

View File

@@ -14,7 +14,9 @@ import com.zds.boss.model.entity.Resume;
import com.zds.boss.model.entity.User;
import com.zds.boss.model.enums.UserRoleEnum;
import com.zds.boss.model.vo.ResumeVO;
import com.zds.boss.service.CosService;
import com.zds.boss.service.FileService;
import com.zds.boss.service.ResumeAddressService;
import com.zds.boss.service.ResumeService;
import com.zds.boss.service.UserService;
import jakarta.annotation.Resource;
@@ -24,7 +26,6 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.UUID;
/**
@@ -44,6 +45,12 @@ public class ResumeController {
@Resource
private FileService fileService;
@Resource
private CosService cosService;
@Resource
private ResumeAddressService resumeAddressService;
@Value("${file.max-size-mb:10}")
private long maxSizeMb;
@@ -158,14 +165,18 @@ public class ResumeController {
}
/**
* 上传简历附件文件
* 上传简历附件文件到腾讯云COS
*
* @param file 文件
* @param request HTTP请求
* @param file 文件
* @param resumeId 简历ID可选如果传入则同时更新resume的attachment_url
* @param request HTTP请求
* @return 文件访问URL
*/
@PostMapping("/upload")
public BaseResponse<String> uploadFile(@RequestParam("file") MultipartFile file, HttpServletRequest request) {
public BaseResponse<String> uploadFile(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "resumeId", required = false) Long resumeId,
HttpServletRequest request) {
if (file == null || file.isEmpty()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件不能为空");
}
@@ -192,36 +203,104 @@ public class ResumeController {
extension = originalFilename.substring(lastDotIndex).toLowerCase();
}
// 验证文件类型允许PDF和Word文档
// 验证文件类型(允许PDF
String contentType = file.getContentType();
boolean isAllowedContentType = "application/pdf".equals(contentType)
|| "application/msword".equals(contentType)
|| "application/vnd.openxmlformats-officedocument.wordprocessingml.document".equals(contentType);
boolean isAllowedExtension = ".pdf".equals(extension) || ".doc".equals(extension) || ".docx".equals(extension);
boolean isAllowedContentType = "application/pdf".equals(contentType);
boolean isAllowedExtension = ".pdf".equals(extension);
if (!isAllowedContentType && !isAllowedExtension) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "仅支持PDF和Word文档格式");
throw new BusinessException(ErrorCode.PARAMS_ERROR, "仅支持PDF格式");
}
try {
// 生成唯一文件名用户ID_时间戳_UUID.扩展名
String timestamp = String.valueOf(System.currentTimeMillis());
String uniqueFileName = String.format("%d_%s_%s%s",
loginUser.getId(), timestamp, UUID.randomUUID().toString().replace("-", ""), extension);
// 构建相对路径resume/{userId}/{filename}
String relativePath = String.format("resume/%d/%s", loginUser.getId(), uniqueFileName);
// 上传文件
fileService.upload(relativePath, file);
// 构建文件访问URL
String fileUrl = fileService.buildUrl(relativePath);
log.info("用户 {} 上传文件成功: {}, URL: {}", loginUser.getId(), originalFilename, fileUrl);
return ResultUtils.success(fileUrl);
} catch (IOException e) {
log.error("文件上传失败: {}", e.getMessage(), e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "文件上传失败: " + e.getMessage());
// 如果传入了resumeId检查是否有权限操作该简历
if (resumeId != null && resumeId > 0) {
Resume resume = resumeService.getById(resumeId);
if (resume == null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "简历不存在");
}
if (!resume.getUserId().equals(loginUser.getId())) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权操作该简历");
}
}
// 生成随机文件Key用于resume_address表
String fileKey = UUID.randomUUID().toString().replace("-", "");
// 生成唯一文件名用户ID_时间戳_UUID
String timestamp = String.valueOf(System.currentTimeMillis());
String customFileName = String.format("%d_%s_%s",
loginUser.getId(), timestamp, fileKey);
// 构建存储目录resume/{userId}
String directory = String.format("resume/%d", loginUser.getId());
// 上传文件到腾讯云COS
String fileUrl = cosService.uploadFile(file, directory, customFileName);
// 如果传入了resumeId更新resume的attachment_url
if (resumeId != null && resumeId > 0) {
Resume resume = new Resume();
resume.setId(resumeId);
resume.setAttachmentUrl(fileUrl);
resumeService.updateById(resume);
log.info("已更新简历 {} 的附件URL", resumeId);
}
// 保存或更新resume_address记录
resumeAddressService.saveOrUpdateByUserId(loginUser.getId(), resumeId, fileUrl, fileKey);
log.info("已保存简历地址记录userId: {}, fileKey: {}", loginUser.getId(), fileKey);
log.info("用户 {} 上传简历PDF成功: {}, URL: {}", loginUser.getId(), originalFilename, fileUrl);
return ResultUtils.success(fileUrl);
}
/**
* 删除COS中的简历附件文件
*
* @param fileUrl 文件URL
* @param resumeId 简历ID可选如果传入则同时清空resume的attachment_url
* @param request HTTP请求
* @return 是否删除成功
*/
@PostMapping("/delete-file")
public BaseResponse<Boolean> deleteFile(
@RequestParam("fileUrl") String fileUrl,
@RequestParam(value = "resumeId", required = false) Long resumeId,
HttpServletRequest request) {
if (fileUrl == null || fileUrl.isEmpty()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件URL不能为空");
}
User loginUser = userService.getLoginUser(request);
// 验证URL是否属于当前用户安全检查
String userDirectory = String.format("resume/%d/", loginUser.getId());
if (!fileUrl.contains(userDirectory)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权删除该文件");
}
// 删除COS中的文件
boolean result = cosService.deleteFile(fileUrl);
if (result) {
log.info("用户 {} 删除文件成功: {}", loginUser.getId(), fileUrl);
// 如果传入了resumeId清空resume的attachment_url
if (resumeId != null && resumeId > 0) {
Resume resume = resumeService.getById(resumeId);
if (resume != null && resume.getUserId().equals(loginUser.getId())) {
resume.setAttachmentUrl("");
resumeService.updateById(resume);
log.info("已清空简历 {} 的附件URL", resumeId);
}
}
// 删除resume_address记录
resumeAddressService.deleteByUserId(loginUser.getId());
log.info("已删除用户 {} 的简历地址记录", loginUser.getId());
} else {
log.warn("用户 {} 删除文件失败: {}", loginUser.getId(), fileUrl);
}
return ResultUtils.success(result);
}
}

View File

@@ -0,0 +1,159 @@
package com.zds.boss.service;
import com.qcloud.cos.COSClient;
import com.qcloud.cos.model.*;
import com.zds.boss.config.CosConfig;
import com.zds.boss.exception.BusinessException;
import com.zds.boss.exception.ErrorCode;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.util.UUID;
/**
* 腾讯云COS对象存储服务
*/
@Service
@Slf4j
public class CosService {
@Resource
private COSClient cosClient;
@Resource
private CosConfig cosConfig;
/**
* 上传文件到COS
*
* @param file 要上传的文件
* @param directory 存储目录resume/123
* @param customName 自定义文件名不含扩展名为null时使用UUID
* @return 文件的访问URL
*/
public String uploadFile(MultipartFile file, String directory, String customName) {
// 获取原始文件名和扩展名
String originalFilename = file.getOriginalFilename();
String extension = "";
if (originalFilename != null && originalFilename.contains(".")) {
extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase();
}
// 生成文件名
String fileName;
if (customName != null && !customName.isEmpty()) {
fileName = customName + extension;
} else {
fileName = UUID.randomUUID().toString().replace("-", "") + extension;
}
// 构建完整的对象Key路径
String key = directory + "/" + fileName;
// 移除开头的斜杠(如果有)
if (key.startsWith("/")) {
key = key.substring(1);
}
try (InputStream inputStream = file.getInputStream()) {
// 设置对象元数据
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(file.getSize());
metadata.setContentType(file.getContentType());
// 创建上传请求
PutObjectRequest putObjectRequest = new PutObjectRequest(
cosConfig.getBucketName(),
key,
inputStream,
metadata
);
// 执行上传
PutObjectResult result = cosClient.putObject(putObjectRequest);
log.info("文件上传成功Key: {}, ETag: {}", key, result.getETag());
// 返回文件访问URL
return cosConfig.getUrlPrefix() + "/" + key;
} catch (IOException e) {
log.error("文件上传失败: {}", e.getMessage(), e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "文件上传失败: " + e.getMessage());
}
}
/**
* 上传文件到COS使用默认UUID文件名
*
* @param file 要上传的文件
* @param directory 存储目录
* @return 文件的访问URL
*/
public String uploadFile(MultipartFile file, String directory) {
return uploadFile(file, directory, null);
}
/**
* 删除COS中的文件
*
* @param fileUrl 文件的完整URL
* @return 是否删除成功
*/
public boolean deleteFile(String fileUrl) {
if (fileUrl == null || fileUrl.isEmpty()) {
return false;
}
try {
// 从URL中提取对象Key
String key = extractKeyFromUrl(fileUrl);
if (key == null) {
log.warn("无法从URL中提取Key: {}", fileUrl);
return false;
}
// 删除对象
cosClient.deleteObject(cosConfig.getBucketName(), key);
log.info("文件删除成功Key: {}", key);
return true;
} catch (Exception e) {
log.error("文件删除失败: {}", e.getMessage(), e);
return false;
}
}
/**
* 从URL中提取对象Key
*
* @param fileUrl 文件URL
* @return 对象Key
*/
private String extractKeyFromUrl(String fileUrl) {
String urlPrefix = cosConfig.getUrlPrefix();
if (fileUrl.startsWith(urlPrefix)) {
String key = fileUrl.substring(urlPrefix.length());
if (key.startsWith("/")) {
key = key.substring(1);
}
return key;
}
return null;
}
/**
* 检查文件是否存在
*
* @param key 对象Key
* @return 是否存在
*/
public boolean doesObjectExist(String key) {
try {
return cosClient.doesObjectExist(cosConfig.getBucketName(), key);
} catch (Exception e) {
log.error("检查文件是否存在失败: {}", e.getMessage(), e);
return false;
}
}
}

View File

@@ -0,0 +1,37 @@
package com.zds.boss.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.zds.boss.model.entity.ResumeAddress;
/**
* 简历地址服务
*/
public interface ResumeAddressService extends IService<ResumeAddress> {
/**
* 根据用户ID获取简历地址
*
* @param userId 用户ID
* @return 简历地址
*/
ResumeAddress getByUserId(Long userId);
/**
* 保存或更新简历地址
*
* @param userId 用户ID
* @param resumeId 简历ID可为空
* @param address 文件URL
* @param fileKey 文件Key
* @return 简历地址
*/
ResumeAddress saveOrUpdateByUserId(Long userId, Long resumeId, String address, String fileKey);
/**
* 根据用户ID删除简历地址
*
* @param userId 用户ID
* @return 是否成功
*/
boolean deleteByUserId(Long userId);
}

View File

@@ -0,0 +1,56 @@
package com.zds.boss.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.zds.boss.mapper.ResumeAddressMapper;
import com.zds.boss.model.entity.ResumeAddress;
import com.zds.boss.service.ResumeAddressService;
import org.springframework.stereotype.Service;
import java.util.Date;
/**
* 简历地址服务实现
*/
@Service
public class ResumeAddressServiceImpl extends ServiceImpl<ResumeAddressMapper, ResumeAddress>
implements ResumeAddressService {
@Override
public ResumeAddress getByUserId(Long userId) {
return this.getOne(new LambdaQueryWrapper<ResumeAddress>()
.eq(ResumeAddress::getUserId, userId));
}
@Override
public ResumeAddress saveOrUpdateByUserId(Long userId, Long resumeId, String address, String fileKey) {
ResumeAddress existing = getByUserId(userId);
if (existing != null) {
// 更新现有记录
existing.setResumeId(resumeId);
existing.setAddress(address);
existing.setFileKey(fileKey);
existing.setUpdatedAt(new Date());
this.updateById(existing);
return existing;
} else {
// 创建新记录
ResumeAddress resumeAddress = new ResumeAddress();
resumeAddress.setUserId(userId);
resumeAddress.setResumeId(resumeId);
resumeAddress.setAddress(address);
resumeAddress.setFileKey(fileKey);
resumeAddress.setCreatedAt(new Date());
resumeAddress.setUpdatedAt(new Date());
this.save(resumeAddress);
return resumeAddress;
}
}
@Override
public boolean deleteByUserId(Long userId) {
return this.remove(new LambdaQueryWrapper<ResumeAddress>()
.eq(ResumeAddress::getUserId, userId));
}
}

View File

@@ -71,3 +71,16 @@ file:
path: src/main/resources/static
# 最大文件大小MB
max-size-mb: 10
# 腾讯云COS配置
cos:
# 密钥ID
secret-id:
# 密钥Key生产环境请使用环境变量或配置中心
secret-key:
# 地域
region: ap-shanghai
# 存储桶名称
bucket-name: bosss-1336488383
# 访问域名前缀
url-prefix: https://bosss-1336488383.cos.ap-shanghai.myqcloud.com