Files
ccdi/docs/plans/backend/2026-03-16-project-upload-file-delete-backend-implementation.md

411 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Project Upload File Delete Backend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 为项目上传文件列表新增后端删除能力,支持删除已解析成功的文件、清理本地流水,并把上传记录状态更新为 `deleted`
**Architecture:**`CcdiFileUploadController` 新增按记录 ID 删除接口,由 Controller 获取当前登录用户 ID 并传给 `ICcdiFileUploadService`。Service 负责查询记录、校验状态、调用 `LsfxAnalysisClient.deleteFiles`、删除 `ccdi_bank_statement` 中对应 `logId` 的流水,并更新 `ccdi_file_upload_record.file_status``deleted`;统计 VO 同步扩展 `deleted` 状态。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, JUnit 5, Mockito, Maven
---
### Task 1: 补齐删除接口控制器契约
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java`
**Step 1: Write the failing test**
`CcdiFileUploadControllerTest` 中新增测试,验证删除接口会读取当前登录用户 ID 并调用 Service
```java
@Test
void deleteFile_shouldUseCurrentLoginUserId() {
try (MockedStatic<SecurityUtils> mocked = mockStatic(SecurityUtils.class)) {
mocked.when(SecurityUtils::getUserId).thenReturn(9527L);
when(fileUploadService.deleteFileUploadRecord(123L, 9527L))
.thenReturn("删除成功");
AjaxResult result = controller.deleteFile(123L);
assertEquals(200, result.get("code"));
assertEquals("删除成功", result.get("msg"));
verify(fileUploadService).deleteFileUploadRecord(123L, 9527L);
}
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadControllerTest#deleteFile_shouldUseCurrentLoginUserId
```
Expected:
- `FAIL`
- 原因是 `CcdiFileUploadController` 中还没有 `deleteFile` 方法或 `ICcdiFileUploadService` 中还没有 `deleteFileUploadRecord` 方法
**Step 3: Write minimal implementation**
在接口和控制器中补最小实现:
```java
String deleteFileUploadRecord(Long id, Long operatorUserId);
```
```java
@DeleteMapping("/{id}")
@Operation(summary = "删除上传文件", description = "按上传记录ID删除文件并清理流水")
public AjaxResult deleteFile(@PathVariable Long id) {
Long userId = SecurityUtils.getUserId();
String message = fileUploadService.deleteFileUploadRecord(id, userId);
return AjaxResult.success(message);
}
```
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadControllerTest#deleteFile_shouldUseCurrentLoginUserId
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/ICcdiFileUploadService.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java
git commit -m "test: 补充上传文件删除接口控制器契约"
```
### Task 2: 实现删除成功主链路
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
**Step 1: Write the failing test**
`CcdiFileUploadServiceImplTest` 中新增成功链路测试:
```java
@Test
void deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted() {
CcdiFileUploadRecord record = buildRecord();
record.setProjectId(PROJECT_ID);
record.setLsfxProjectId(LSFX_PROJECT_ID);
record.setLogId(LOG_ID);
record.setFileStatus("parsed_success");
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
when(lsfxClient.deleteFiles(any())).thenReturn(buildDeleteFilesResponse());
String result = service.deleteFileUploadRecord(RECORD_ID, 9527L);
assertEquals("删除成功", result);
verify(lsfxClient).deleteFiles(argThat(request ->
request.getGroupId().equals(LSFX_PROJECT_ID)
&& request.getUserId().equals(9527)
&& request.getLogIds().length == 1
&& request.getLogIds()[0].equals(LOG_ID)
));
verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
verify(recordMapper).updateById(argThat(item ->
RECORD_ID.equals(item.getId()) && "deleted".equals(item.getFileStatus())
));
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted
```
Expected:
- `FAIL`
- 原因是 `CcdiFileUploadServiceImpl` 中还没有 `deleteFileUploadRecord` 实现
**Step 3: Write minimal implementation**
`CcdiFileUploadServiceImpl` 中实现删除主链路:
```java
@Override
@Transactional
public String deleteFileUploadRecord(Long id, Long operatorUserId) {
CcdiFileUploadRecord record = recordMapper.selectById(id);
validateDeleteRecord(record);
DeleteFilesRequest request = new DeleteFilesRequest();
request.setGroupId(record.getLsfxProjectId());
request.setLogIds(new Integer[]{record.getLogId()});
request.setUserId(toUploadUserId(operatorUserId));
DeleteFilesResponse response = lsfxClient.deleteFiles(request);
if (response == null || Boolean.FALSE.equals(response.getSuccessResponse())) {
throw new RuntimeException("流水分析平台删除文件失败");
}
bankStatementMapper.deleteByProjectIdAndBatchId(record.getProjectId(), record.getLogId());
CcdiFileUploadRecord update = new CcdiFileUploadRecord();
update.setId(record.getId());
update.setFileStatus("deleted");
recordMapper.updateById(update);
return "删除成功";
}
```
同时补一个私有校验方法,仅先满足成功路径所需字段。
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java
git commit -m "feat: 打通上传文件删除成功主链路"
```
### Task 3: 补齐删除前置校验与失败保护
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
**Step 1: Write the failing test**
为关键边界新增失败测试,至少覆盖“状态不允许删除”和“平台删除失败时不应更新本地状态”:
```java
@Test
void deleteFileUploadRecord_shouldRejectNonParsedSuccessStatus() {
CcdiFileUploadRecord record = buildRecord();
record.setFileStatus("parsed_failed");
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
RuntimeException exception = assertThrows(RuntimeException.class,
() -> service.deleteFileUploadRecord(RECORD_ID, 9527L));
assertTrue(exception.getMessage().contains("仅支持删除解析成功文件"));
}
@Test
void deleteFileUploadRecord_shouldStopWhenLsfxDeleteFails() {
CcdiFileUploadRecord record = buildRecord();
record.setFileStatus("parsed_success");
record.setLogId(LOG_ID);
record.setLsfxProjectId(LSFX_PROJECT_ID);
when(recordMapper.selectById(RECORD_ID)).thenReturn(record);
when(lsfxClient.deleteFiles(any())).thenThrow(new RuntimeException("lsfx delete failed"));
assertThrows(RuntimeException.class, () -> service.deleteFileUploadRecord(RECORD_ID, 9527L));
verify(bankStatementMapper, org.mockito.Mockito.never()).deleteByProjectIdAndBatchId(any(), any());
verify(recordMapper, org.mockito.Mockito.never()).updateById(argThat(item ->
"deleted".equals(item.getFileStatus())
));
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldRejectNonParsedSuccessStatus,CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldStopWhenLsfxDeleteFails
```
Expected:
- `FAIL`
- 原因是现有实现还没有完整的前置校验和失败保护
**Step 3: Write minimal implementation**
补全 Service 校验和异常处理:
```java
private void validateDeleteRecord(CcdiFileUploadRecord record) {
if (record == null) {
throw new RuntimeException("上传记录不存在");
}
if (!"parsed_success".equals(record.getFileStatus())) {
if ("deleted".equals(record.getFileStatus())) {
throw new RuntimeException("文件已删除,请勿重复操作");
}
throw new RuntimeException("仅支持删除解析成功文件");
}
if (record.getLsfxProjectId() == null) {
throw new RuntimeException("缺少流水分析项目ID");
}
if (record.getLogId() == null) {
throw new RuntimeException("缺少文件logId");
}
}
```
不要吞掉平台删除异常,让事务直接回滚。
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldRejectNonParsedSuccessStatus,CcdiFileUploadServiceImplTest#deleteFileUploadRecord_shouldStopWhenLsfxDeleteFails
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java
git commit -m "test: 补齐上传文件删除校验与失败保护"
```
### Task 4: 扩展统计口径支持 `deleted`
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFileUploadStatisticsVO.java`
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- Test: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
**Step 1: Write the failing test**
新增统计测试,验证 `deleted` 状态会被正确映射到 VO
```java
@Test
void countByStatus_shouldIncludeDeletedCount() {
when(recordMapper.countByStatus(PROJECT_ID)).thenReturn(List.of(
Map.of("status", "uploading", "count", 1),
Map.of("status", "deleted", "count", 2)
));
CcdiFileUploadStatisticsVO result = service.countByStatus(PROJECT_ID);
assertEquals(1L, result.getUploading());
assertEquals(2L, result.getDeleted());
assertEquals(3L, result.getTotal());
}
```
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#countByStatus_shouldIncludeDeletedCount
```
Expected:
- `FAIL`
- 原因是 `CcdiFileUploadStatisticsVO` 还没有 `deleted` 字段,或 `countByStatus` 还没有映射该状态
**Step 3: Write minimal implementation**
在 VO 和 Service 中补 `deleted` 字段及映射:
```java
private Long deleted;
```
```java
vo.setDeleted(0L);
case "deleted" -> vo.setDeleted(count);
```
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest#countByStatus_shouldIncludeDeletedCount
```
Expected:
- `PASS`
**Step 5: Commit**
```bash
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/vo/CcdiFileUploadStatisticsVO.java ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java
git commit -m "feat: 扩展上传文件统计支持已删除状态"
```
### Task 5: 跑后端回归验证
**Files:**
- Verify only: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadController.java`
- Verify only: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- Verify only: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/controller/CcdiFileUploadControllerTest.java`
- Verify only: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
**Step 1: Run focused tests**
Run:
```bash
mvn test -pl ccdi-project -am -Dtest=CcdiFileUploadControllerTest,CcdiFileUploadServiceImplTest
```
Expected:
- 全部 `PASS`
**Step 2: Run module compile**
Run:
```bash
mvn clean compile -pl ccdi-project -am
```
Expected:
- `BUILD SUCCESS`
**Step 3: Record manual API smoke checklist**
手工检查以下接口行为:
- `DELETE /ccdi/file-upload/{id}` 删除 `parsed_success` 记录返回成功
- 删除 `parsed_failed` 记录返回错误
- 重复删除 `deleted` 记录返回错误
**Step 4: Commit**
```bash
git add .
git commit -m "test: 完成上传文件删除后端回归验证"
```