Spring Boot学习周记:重构项目管理与实现文件上传

在上周,通过编写demo独立项目的方式,我实现了项目管理的增删改查,数据库交互等功能,理解了 Spring Boot 的核心概念,包括 依赖注入、自动配置、RESTful API 设计 等,并通过创建和管理项目的数据库交互,学习了如何使用 Spring Data JPA,如何处理 HTTP 请求、响应数据等。

本周,我将学习文件上传,异常处理,服务层,上下文策略的 Java 实现和阈值计算,OCR 集成等知识,并且通过具体的demo独立验证。

1.项目管理中增删改查的整体实现

应用分层:Controller -> Service -> Repository -> Database
引入ProjectService:

package com.example.demo.service;

import com.example.demo.model.Project;
import com.example.demo.repository.ProjectRepository; 
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 

import java.util.List;
import java.util.Optional;

@Service 
public class ProjectService {

    private final ProjectRepository projectRepository;

    // --- 依赖注入 ---
    @Autowired
    public ProjectService(ProjectRepository projectRepository) {
        this.projectRepository = projectRepository;
    }

    // --- 业务方法 ---
    
    // 查询所有项目
    @Transactional(readOnly = true) // 只读
    public List<Project> getAllProjects() {
        return projectRepository.findAll();
    }

    // 根据 ID 查询项目
    @Transactional(readOnly = true)
    public Optional<Project> getProjectById(Long id) {
        return projectRepository.findById(id);
    }

    // 创建新项目
    @Transactional // 读写事务 (默认)
    public Project createProject(Project project) {
        // 可以在这里添加创建前的业务逻辑校验,例如检查名称是否重复等
        project.setId(null); // 确保创建时 ID 由数据库生成
        return projectRepository.save(project);
    }

    // 更新项目
    @Transactional
    public Optional<Project> updateProject(Long id, Project projectDetails) {
        Optional<Project> optionalExistingProject = projectRepository.findById(id);

        if (optionalExistingProject.isPresent()) {
            Project existingProject = optionalExistingProject.get();
            // 更新需要修改的字段
            existingProject.setName(projectDetails.getName());
            existingProject.setSummary(projectDetails.getSummary());
            // ... 更新其他字段 ...

            // 保存更新后的项目
            Project updatedProject = projectRepository.save(existingProject);
            return Optional.of(updatedProject);
        } else {
            // 项目不存在
            return Optional.empty();
        }
    }

    // 删除项目
    @Transactional
    public boolean deleteProject(Long id) {
        if (projectRepository.existsById(id)) {
            projectRepository.deleteById(id);
            return true; // 表示删除成功
        } else {
            return false; // 表示项目不存在,无法删除
        }
    }

    // 根据名称搜索项目
    @Transactional(readOnly = true)
    public List<Project> searchProjectsByName(String name) {
        if (name == null || name.trim().isEmpty()) {
             // 如果搜索词为空,可以返回所有项目或空列表,这里返回所有
             return projectRepository.findAll();
        }
        return projectRepository.findByNameContainingIgnoreCase(name);
    }

    // 置顶项目(A6)、取消置顶(A6)、配置项目设置(A7)等逻辑将来会在这里实现
}

修改ProjectController:

package com.example.demo.controller;

import com.example.demo.model.Project;
import com.example.demo.service.ProjectService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;


@RestController
@RequestMapping("/api/projects")
public class ProjectController {

    // 将 Repository 依赖改为 Service 依赖
    private final ProjectService projectService;

    // 通过构造函数注入 Service
    @Autowired
    public ProjectController(ProjectService projectService) {
        this.projectService = projectService;
    }

    // 1. 获取所有项目
    @GetMapping
    public ResponseEntity<List<Project>> getAllProjects() {
        // 调用 Service 层方法
        List<Project> projects = projectService.getAllProjects();
        return ResponseEntity.ok(projects);
    }

    // 2. 根据 ID 获取单个项目
    @GetMapping("/{id}")
    public ResponseEntity<Project> getProjectById(@PathVariable Long id) {
        return projectService.getProjectById(id)
                .map(project -> ResponseEntity.ok(project)) // 如果 Optional 有值 (找到项目)
                .orElse(ResponseEntity.notFound().build()); // 如果 Optional 为空 (未找到项目)
    }

    // 3. 创建新项目
    @PostMapping
    public ResponseEntity<Project> createProject(@RequestBody Project newProject) {
        Project createdProject = projectService.createProject(newProject);
        return ResponseEntity.status(HttpStatus.CREATED).body(createdProject);
    }

    // 4. 更新项目
    @PutMapping("/{id}")
    public ResponseEntity<Project> updateProject(@PathVariable Long id, @RequestBody Project updatedProject) {
        return projectService.updateProject(id, updatedProject)
                .map(project -> ResponseEntity.ok(project)) // 更新成功
                .orElse(ResponseEntity.notFound().build()); // 项目不存在,无法更新
    }

    // 5. 删除项目
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProject(@PathVariable Long id) {
        // 调用 Service 层方法
        boolean deleted = projectService.deleteProject(id);
        if (deleted) {
            return ResponseEntity.noContent().build(); // 204 No Content,表示成功处理请求但无内容返回
        } else {
            return ResponseEntity.notFound().build(); // 项目不存在,无法删除
        }
    }

    // 6. 根据名称模糊搜索
    @GetMapping("/search")
    public ResponseEntity<List<Project>> searchProjects(@RequestParam(required = false) String name) {
        // 调用 Service 层方法
        List<Project> projects = projectService.searchProjectsByName(name);
        return ResponseEntity.ok(projects);
    }
}

具体测试

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
至此,项目管理功能中的A2-A5(增删改查)已经全部实现

2.任务分析

关于项目管理功能中的A1和A6(置顶功能相关),我初步的想法是:

  1. Project 实体类中添加一个 boolean 类型的字段,比如 isPinned (默认 false)
  2. ProjectService 中添加 pinProject(Long id)unpinProject(Long id) 方法,内部逻辑是获取项目、设置 isPinned 状态、然后 save() 更新
  3. ProjectController 中添加对应的 API 端点,例如 PUT /api/projects/{id}/pinPUT /api/projects/{id}/unpin (或者一个 PUT /api/projects/{id} 端点,通过请求体参数控制置顶状态)
  4. 修改 getAllProjects() 或添加新的 Service 方法(如 getPinnedProjects()),以便前端可以获取置顶项目列表

关于项目管理功能中的A7(配置项目设置),我的初步想法是:

  1. 创建新的 ProjectSettings 实体,并建立关联 (@OneToOne)
  2. 更新 Repository
  3. ProjectService 中添加获取和更新项目设置的方法 (getProjectSettings(Long projectId), updateProjectSettings(Long projectId, SettingsDto settings))
  4. ProjectController 中添加对应的 API 端点 (GET /api/projects/{id}/settings, PUT /api/projects/{id}/settings)

这一部分也需要和我的其他队友沟通后进行实现,所以后续会在项目中实现,目前我打算先学习和实现上下文管理中的功能

分析上下文管理中的具体功能,其中B1-B3可以理解为基础数据,通过我目前的学习,可以应对,准备后续在项目中实现,B4-B6主要聚焦于文件处理,所以我打算先学习文件处理等相关知识

3.文件处理

1. 接收文件

通过学习,我了解到一些核心概念:

  1. HTTP 请求类型 multipart/form-data:

    • 当 HTML 表单包含 <input type="file"> 标签时,为了能够将文件和其他文本数据一起发送到服务器,浏览器会将整个请求的 Content-Type 设置为 multipart/form-data

    • 这与之前用 @RequestBody 处理的 application/json 不同。multipart/form-data 将请求体分割成多个部分,每个部分可以包含一个表单字段或一个文件。

  2. Spring Boot 的 MultipartFile 接口:

    • Spring MVC内置了对 multipart/form-data 请求的处理能力。

    • 当一个带有 @RequestParam 注解的方法参数类型是 MultipartFile 时,Spring 会自动将请求中对应名称的文件部分绑定到这个参数上。

    • MultipartFile 是一个非常重要的接口,它提供了访问上传文件内容和元数据的方法。常用方法包括:

      • String getOriginalFilename(): 获取用户上传时文件的原始名称(例如 mydocument.pdf

      • long getSize(): 获取文件的大小(以字节为单位)。

      • String getContentType(): 获取文件的 MIME 类型(例如 image/jpeg, app/pdf

      • byte[] getBytes(): 将文件内容读取到一个字节数组中。但对于大文件,会消耗大量内存。

      • InputStream getInputStream(): 获取一个输入流 (java.io.InputStream),可以用来读取文件内容。这是处理文件内容(例如保存到磁盘)的推荐方式

      • boolean isEmpty(): 判断用户是否实际选择了文件进行上传。

      • void transferTo(File dest): 一个便捷方法,可以将上传的文件直接保存到服务器本地文件系统中的目标文件 (java.io.File)。

一个接收文件,并获取文件的基本信息的小demo:

// === 文件上传处理 ===

    /**
     * 处理指定项目的文件上传请求 (B5)
     *
     * @param projectId 要关联的项目 ID
     * @param file      上传的文件 (来自表单中 name="file" 的 input)
     * @return 包含上传结果信息的响应
     */
    @PostMapping("/{projectId}/files") // 定义 API 路径,例如 POST /api/projects/1/files
    public ResponseEntity<String> handleFileUpload(@PathVariable Long projectId,
                                                   @RequestParam("file") MultipartFile file) { 

        // 1. 检查文件是否为空
        if (file.isEmpty()) {
            return ResponseEntity.badRequest().body("请选择一个文件上传。"); // 返回 400 Bad Request
        }

        // 2. 获取文件信息 (暂时只打印到控制台)
        String originalFilename = file.getOriginalFilename();
        long sizeInBytes = file.getSize();
        String contentType = file.getContentType();

        System.out.println("接收到项目 ID [" + projectId + "] 的文件:");
        System.out.println("  原始文件名: " + originalFilename);
        System.out.println("  文件大小: " + sizeInBytes + " bytes");
        System.out.println("  文件类型: " + contentType);

        // --- 这里是下一步要添加文件保存逻辑的地方 ---
        // 现在先只是模拟成功接收
        String successMessage = String.format("文件 '%s' (%.2f KB) 已成功接收,关联到项目 %d。",
                originalFilename, (double) sizeInBytes / 1024, projectId);

        return ResponseEntity.ok(successMessage); // 返回 200 OK 和成功信息
    }

在这里插入图片描述
在这里插入图片描述

2.文件存储

考虑到需要将接收到的 MultipartFile 真正地保存到某个地方,并且要处理一些实际问题,比如文件名冲突,后续提取文件中文字或精炼功能,需要将创建一个专门的 Service 来处理这个任务。

1.配置存储路径

在**application.properties**中配置默认存储路径

file.upload-dir=./uploads
#在项目运行目录下创建一个 uploads 文件夹
2.创建文件存储服务,编写FileStorageService
package com.example.demo.service;

import org.springframework.beans.factory.annotation.Autowired; 
import org.springframework.beans.factory.annotation.Value; 
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import jakarta.annotation.PostConstruct; // 导入 PostConstruct

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.*; 
import java.util.UUID;
import org.springframework.util.StringUtils;

@Service
public class FileStorageService {

    private final Path fileStorageLocation; // 使用 final

    // 使用 @Autowired 通过构造函数注入配置值 
    @Autowired
    public FileStorageService(@Value("${file.upload-dir}") String uploadDir) {
        // 将字符串路径转换为 Path 对象
        this.fileStorageLocation = Paths.get(uploadDir).toAbsolutePath().normalize();

        // 初始化时尝试创建目录
        try {
            Files.createDirectories(this.fileStorageLocation);
        } catch (Exception ex) {
            throw new RuntimeException("无法创建用于存储上传文件的目录!", ex);
        }
    }


    /**
     * 存储上传的文件到服务器
     *
     * @param file 上传的 MultipartFile
     * @return 存储后的唯一文件名 (用于后续数据库记录和查找)
     * @throws RuntimeException 如果存储过程中发生错误
     */
    public String storeFile(MultipartFile file) {
        // 1. 清理和规范化文件名
        // 获取原始文件名
        String originalFilename = StringUtils.cleanPath(file.getOriginalFilename());

        try {
            // 2. 检查文件名是否有效
            if (originalFilename.contains("..")) {
                throw new RuntimeException("错误:文件名包含无效路径序列 " + originalFilename);
            }

            // 3. 生成唯一文件名 (避免冲突)
            // 获取文件扩展名,例如 ".pdf"
            String fileExtension = "";
            int lastDotIndex = originalFilename.lastIndexOf('.');
            if (lastDotIndex > 0) {
                fileExtension = originalFilename.substring(lastDotIndex);
            }
            // 生成 UUID 作为主文件名,保留原始扩展名
            String storedFilename = UUID.randomUUID().toString() + fileExtension;

            // 4. 确定目标存储路径
            // 假设 fileStorageLocation 是 D:/uploads 或 /var/www/uploads
            // targetLocation 将是 D:/uploads/uuid-blabla.pdf
            Path targetLocation = this.fileStorageLocation.resolve(storedFilename);

            // 5. 将文件内容复制到目标路径
            // 使用 try-with-resources 确保 InputStream 被正确关闭
            try (InputStream inputStream = file.getInputStream()) {
                // Files.copy(源输入流, 目标路径, 复制选项...)
                // StandardCopyOption.REPLACE_EXISTING: 如果同名文件已存在,则覆盖它 (虽然我们用了 UUID,理论上不会重复)
                Files.copy(inputStream, targetLocation, StandardCopyOption.REPLACE_EXISTING);
            }

            // 6. 返回存储后的文件名
            return storedFilename;

        } catch (IOException ex) {
            // 捕获 IO 异常,包装成运行时异常抛出,以便上层处理
            throw new RuntimeException("无法存储文件 " + originalFilename + "。请重试!", ex);
        }
    }

}

3.更新 Controller 调用 Service
// === 文件上传处理 ===
    @PostMapping("/{projectId}/files")
    public ResponseEntity<String> handleFileUpload(@PathVariable Long projectId,
                                                 @RequestParam("file") MultipartFile file) {

        if (file.isEmpty()) {
            return ResponseEntity.badRequest().body("请选择一个文件上传。");
        }

        // --- 调用 FileStorageService 来存储文件 ---
        try {
            // TODO: 在这里可以添加业务逻辑检查,例如项目是否存在 projectService.getProjectById(projectId).isPresent()

            // 调用服务存储文件,获取存储后的唯一文件名
            String storedFilename = fileStorageService.storeFile(file);

            // --- 下一步: 将文件信息 (projectId, originalFilename, storedFilename, size, type) 保存到数据库 ---
            // 现在我们先返回成功信息,包含存储后的文件名

            String successMessage = String.format("文件 '%s' 已成功上传并存储为 '%s',关联到项目 %d。",
                    file.getOriginalFilename(), storedFilename, projectId);

            return ResponseEntity.ok(successMessage);

        } catch (RuntimeException e) {
            // 如果 storeFile 抛出异常 (例如 IO 错误或无效文件名)
            // 返回 500 Internal Server Error 或根据异常类型返回更具体的错误码
            e.printStackTrace(); // 打印堆栈信息方便调试
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                                 .body("文件上传失败: " + e.getMessage());
        }
    }

具体测试结果
在这里插入图片描述

3. 将文件信息记录到数据库

(等会放代码和具体分析)
在这里插入图片描述
B5-上传文件的核心逻辑已经实现

总结

本周,我对项目管理的增删改查功能进行了重构,通过引入ProjectService,实现了控制器(Controller)、服务(Service)、仓库(Repository)的清晰分层,这不仅优化了代码结构,也加深了对依赖注入、事务管理和面向服务设计的理解。

其次,我开始着手处理项目中的文件上传需求(B5),深入学习了Spring Boot如何利用MultipartFile接口处理multipart/form-data类型的请求。我成功搭建了文件接收的基础框架,并进一步创建了FileStorageService,实现了将上传文件安全、可靠地存储到服务器指定位置的核心逻辑,包括了路径配置、唯一文件名生成和文件流处理等关键步骤。通过Curl测试,验证了项目管理API和服务层逻辑的正确性,以及文件上传和存储功能的基本实现。

接下来我的计划是完善文件上传功能,继续学习异常处理、上下文策略等知识,为构建更健壮、功能更完善的应用做好准备。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值