feat(storage): 集成腾讯云COS实现简历PDF文件存储
- 配置腾讯云COS相关参数和密钥设置 - 新增CosConfig配置类和CosService对象存储服务 - 实现文件上传、删除、存在性检查等功能 - 修改简历上传接口使用COS存储替代本地存储 - 限制文件格式仅支持PDF并更新前端验证逻辑 - 添加删除COS文件的后端接口和前端调用 - 移除前端MyResumes页面中的旧PDF管理组件 - 更新上传和删除操作的安全验证和权限检查
This commit is contained in:
1
BOSSBackEnd/.gitignore
vendored
1
BOSSBackEnd/.gitignore
vendored
@@ -31,3 +31,4 @@ build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
/src/main/resources/application-dev.yml
|
||||
|
||||
@@ -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>
|
||||
|
||||
58
BOSSBackEnd/src/main/java/com/zds/boss/config/CosConfig.java
Normal file
58
BOSSBackEnd/src/main/java/com/zds/boss/config/CosConfig.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
159
BOSSBackEnd/src/main/java/com/zds/boss/service/CosService.java
Normal file
159
BOSSBackEnd/src/main/java/com/zds/boss/service/CosService.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user