From e1ee68155023c06c78c5269f079fa45a73ac9fa7 Mon Sep 17 00:00:00 2001 From: wkc <978997012@qq.com> Date: Mon, 16 Mar 2026 16:25:01 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dlsfx=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=8E=A5=E5=8F=A3logIds=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/大额交易.csv | 214 +++++++++ ...load-file-delete-backend-implementation.md | 410 ++++++++++++++++++ ...oad-file-delete-frontend-implementation.md | 362 ++++++++++++++++ lsfx-mock-server/routers/api.py | 35 +- lsfx-mock-server/tests/test_api.py | 17 + 5 files changed, 1034 insertions(+), 4 deletions(-) create mode 100644 assets/大额交易.csv create mode 100644 docs/plans/2026-03-16-project-upload-file-delete-backend-implementation.md create mode 100644 docs/plans/2026-03-16-project-upload-file-delete-frontend-implementation.md diff --git a/assets/大额交易.csv b/assets/大额交易.csv new file mode 100644 index 00000000..4f926365 --- /dev/null +++ b/assets/大额交易.csv @@ -0,0 +1,214 @@ +序号,模型名称,核心异常点(展示在前端页面),业务口径,相关指标,指标英文名,风险筛查对象,技术口径,代码,限制阈值指标,可疑结果返回,风险等级 +1.1,大额交易,房车消费支出交易,备注或对交易对手是房产公司、二手房、车辆销售公司、物业公司等。,购买车房支出金额,prop_exp_amt,员工本人及亲属,关联员工及其亲属所有账户(ccdi_account_info 关联 ccdi_fmy_relation_person),在 ccdi_bank_statement 中筛选 amount_dr>0 且对手方/摘要含房产/车产关键词,"sql
---员工及其亲属购买车房支出流水id +select t2.bank_statement_id +from +ccdi_base_staff t1 +inner join +ccdi_bank_statement t2 +on t1.id_card = t2.cret_no +where project_id = PROJECT_ID +and (user_memo rlike '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷' +or customer_account_name rlike '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局') +and amount_dr > 0 +union all +select t2.bank_statement_id +from +ccdi_staff_fmy_relation t1 +inner join +ccdi_bank_statement t2 +on t1.relation_cert_no = t2.cret_no +where t1.status = 1 +and t2.project_id = PROJECT_ID +and (user_memo rlike '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷' +or customer_account_name rlike '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局') +and amount_dr > 0 +;",/,流水明细,一般 +1.2,,税务支出交易,有税务支出记录,税务支出金额,tax_exp_amt,员工本人及亲属,员工及其亲属账户中,筛选 amount_dr>0 且摘要含税务关键词,"sql
----员工及其亲属税务支出流水id +select t2.bank_statement_id +from +ccdi_base_staff t1 +inner join +ccdi_bank_statement t2 +on t1.id_card = t2.cret_no +where project_id = PROJECT_ID +and (user_memo rlike '税务|缴税|税款' +or customer_account_name rlike '税务|税务局|国库|国家金库|财政') +and amount_dr > 0 +union all +select t2.bank_statement_id +from +ccdi_staff_fmy_relation t1 +inner join +ccdi_bank_statement t2 +on t1.relation_cert_no = t2.cret_no +where t1.status = 1 +and t2.project_id = PROJECT_ID +and (user_memo rlike '税务|缴税|税款' +or customer_account_name rlike '税务|税务局|国库|国家金库|财政') +and amount_dr > 0 +;",/,流水明细,一般 +1.3,,大额单笔收入,同一交易对手(除本人、家庭成员外、本单位代发工资)单笔超过设置限额超过设置限额的资金流入;,大额流入金额(单笔),SINGLE_TRANSACTION_AMOUNT,员工本人,员工账户中,筛选 amount_cr>0,对手方名称不在该员工的家庭关系内,排除工资代发,按员工和对手方汇总金额,判断单笔是否超限,"sql
--员工与同一交易对手(非亲属)的最大一笔收入交易流水id +select t1.bank_statement_id +from +( +select t1.id_card +,t2.bank_statement_id +,t2.customer_account_name +from ccdi_base_staff t1 +inner join +ccdi_bank_statement t2 +on t1.id_card = t2.cret_no +where project_id = PROJECT_ID +and t2.le_account_name <> t2.customer_account_name +and not (customer_account_name = '浙江兰溪农村商业银行股份有限公司' +and (user_memo like '%代发%' +or user_memo like '%工资%' +or user_memo like '%奖金%' +or user_memo like '%薪酬%' +or user_memo like '%薪金%' +or user_memo like '%补贴%' +or user_memo like '%薪%' +or user_memo like '%年终奖%' +or user_memo like '%年金%' +or user_memo like '%加班费%' +or user_memo like '%劳务费%' +or user_memo like '%劳务外包%' +or user_memo like '%提成%' +or user_memo like '%劳务派遣%' +or user_memo like '%绩效%' +or user_memo like '%酬劳%' +or user_memo like '%PAYROLL%' +or user_memo like '%SALA%' +or user_memo like '%CPF%' +or user_memo like '%directors%fees%' +or user_memo like '%批量代付%' +or cash_type like '%代发%' +or cash_type like '%工资%' +or cash_type like '%劳务费%' )) +and amount_cr > 0 +) t1 +left join ccdi_staff_fmy_relation t2 +on t1.id_card = t2.person_id +and t1.customer_account_name = t2.relation_name +where t2.person_id is null;",大额流入金额,流水明细,一般 +新增,,累计收入超限,同一交易对手(除本人、家庭成员外、本单位代发工资)累计交易金额超过设置限额的资金流入;,累计流入金额(所有累计),CUMULATIVE_TRANSACTION_AMOUNT,员工本人,员工账户中,筛选 amount_cr>0,对手方名称不在该员工的家庭关系内,排除工资代发,按员工和对手方汇总金额,判断累计是否超限,"sql
--员工与同一交易对手(非亲属)的累计收入交易金额 +select +t1.id_card +,t1.customer_account_name +,t1.sum_amount_cr +from +( +select t1.id_card +,customer_account_name +,sum(amount_cr) as sum_amount_cr +from ccdi_base_staff t1 +inner join +ccdi_bank_statement t2 +on t1.id_card = t2.cret_no +where project_id = PROJECT_ID +and t2.le_account_name <> t2.customer_account_name +and not (customer_account_name = '浙江兰溪农村商业银行股份有限公司' +and (user_memo like '%代发%' +or user_memo like '%工资%' +or user_memo like '%奖金%' +or user_memo like '%薪酬%' +or user_memo like '%薪金%' +or user_memo like '%补贴%' +or user_memo like '%薪%' +or user_memo like '%年终奖%' +or user_memo like '%年金%' +or user_memo like '%加班费%' +or user_memo like '%劳务费%' +or user_memo like '%劳务外包%' +or user_memo like '%提成%' +or user_memo like '%劳务派遣%' +or user_memo like '%绩效%' +or user_memo like '%酬劳%' +or user_memo like '%PAYROLL%' +or user_memo like '%SALA%' +or user_memo like '%CPF%' +or user_memo like '%directors%fees%' +or user_memo like '%批量代付%' +or cash_type like '%代发%' +or cash_type like '%工资%' +or cash_type like '%劳务费%' )) +group by id_card,customer_account_name +having sum(amount_cr)>0 +) t1 +left join ccdi_staff_fmy_relation t2 +on t1.id_card = t2.person_id +and t1.customer_account_name = t2.relation_name +where t2.person_id is null; +",累计流入金额,个人、累积金额,一般 +1.4,,年流水交易额超限,年流水交易额超过设置限额,年交易金额,annual_turnover,员工本人,员工账户中,排除本人及亲属名称,统计一年内 amount_cr+amount_dr 总额,"sql
--员工年交易金额 +select t1.id_card +,sum(trans_amount) as annual_trans_amount +from +( +select t1.id_card +,amount_dr + amount_cr as trans_amount +from +ccdi_base_staff t1 +inner join +ccdi_bank_statement t2 +on t1.id_card = t2.cret_no +where project_id = PROJECT_ID +and left(TRX_DATE,10) >= add_months(current_date(),-12) --近一年 +and t2.le_account_name <> t2.customer_account_name --排除同名交易 +) t1 +left join ccdi_staff_fmy_relation t2 +on t1.id_card = t2.person_id +and t1.customer_account_name = t2.relation_name +where t2.person_id is NULL +group by t1.id_card;",年交易金额,个人、累积金额,一般 +1.5,,大额存现交易,大额存现,单笔超过设置限额;,大额存现金额(单笔),LARGE_CASH_DEPOSIT,员工本人,员工及其亲属账户中,筛选 现金存入,且单笔 amount_cr 超阈值,按员工汇总,"sql
---员工大额存现单流水id +select t2.bank_statement_id +from +ccdi_base_staff t1 +inner join +ccdi_bank_statement t2 +on t1.id_card = t2.cret_no +where project_id = PROJECT_ID +and amount_cr> $$$$ ---大额存现阈值参数 +and ( +(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or +((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') +) +; +",大额存现金额,流水明细,一般 +,,短时间多次存现,短时间多次存现,次数超过设置限额,单日存现总次数,FREQUENT_CASH_DEPOSIT,员工本人,员工及其亲属账户中,按日统计现金存入次数超阈值,"sql
--员工及其亲属 大额现金存入次数 +select id_card,count(1) +from +( +select t1.id_card +,amount_cr +from +ccdi_base_staff t1 +inner join +ccdi_bank_statement t2 +on t1.id_card = t2.cret_no +where project_id = PROJECT_ID +and amount_cr> $$$$$$ +and ( +(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or +((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') +) +union all +select t1.person_id +,amount_cr +from +ccdi_staff_fmy_relation t1 +inner join +ccdi_bank_statement t2 +on t1.relation_cert_no = t2.cret_no +where t1.status = 1 +and t2.project_id = PROJECT_ID +and amount_cr> $$$$$$$ +and ( +(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or +((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') +) +)group by id_card +; +",单日存现总次数,个人、日期、次数,一般 +1.6,,大额转账交易,大额转账单笔超过设置限额,大额转账金额(单笔),large_tfr_cnt,员工本人,员工及其亲属账户中,筛选单笔 amount_dr 超金额阈值的数据,,大额转账金额,流水明细,一般 diff --git a/docs/plans/2026-03-16-project-upload-file-delete-backend-implementation.md b/docs/plans/2026-03-16-project-upload-file-delete-backend-implementation.md new file mode 100644 index 00000000..720edf97 --- /dev/null +++ b/docs/plans/2026-03-16-project-upload-file-delete-backend-implementation.md @@ -0,0 +1,410 @@ +# 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 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: 完成上传文件删除后端回归验证" +``` diff --git a/docs/plans/2026-03-16-project-upload-file-delete-frontend-implementation.md b/docs/plans/2026-03-16-project-upload-file-delete-frontend-implementation.md new file mode 100644 index 00000000..d0ed8bc6 --- /dev/null +++ b/docs/plans/2026-03-16-project-upload-file-delete-frontend-implementation.md @@ -0,0 +1,362 @@ +# Project Upload File Delete Frontend Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 在项目详情-上传数据-上传文件列表中新增操作列,实现“查看错误原因”和“删除”交互,并让删除后的记录显示为“已删除”。 + +**Architecture:** 先把状态到“操作/标签”的判断抽取到一个轻量 helper,通过 Node 可执行脚本做最小自动化校验;然后在 `ccdiProjectUpload.js` 新增删除接口,在 `UploadData.vue` 中接入操作列、删除确认框、删除后刷新列表与统计。保留现有轮询机制,不新增前端全局状态管理。 + +**Tech Stack:** Vue 2, Element UI, Axios request wrapper, Node script verification, npm build + +--- + +### Task 1: 抽取状态与操作规则并先写失败测试 + +**Files:** +- Create: `ruoyi-ui/src/views/ccdiProject/components/detail/uploadFileActionRules.mjs` +- Create: `tests/frontend/upload-file-action-rules.test.mjs` + +**Step 1: Write the failing test** + +新建一个轻量 Node 规则测试脚本,先定义期望行为: + +```javascript +import assert from "node:assert/strict"; +import { + getUploadFileAction, + getUploadFileStatusText, + getUploadFileStatusType +} from "../../ruoyi-ui/src/views/ccdiProject/components/detail/uploadFileActionRules.mjs"; + +assert.deepEqual(getUploadFileAction("parsed_failed"), { key: "viewError", text: "查看错误原因" }); +assert.deepEqual(getUploadFileAction("parsed_success"), { key: "delete", text: "删除" }); +assert.equal(getUploadFileAction("deleted"), null); +assert.equal(getUploadFileStatusText("deleted"), "已删除"); +assert.equal(getUploadFileStatusType("deleted"), "info"); +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +node tests/frontend/upload-file-action-rules.test.mjs +``` + +Expected: + +- `FAIL` +- 原因是 helper 文件还不存在 + +**Step 3: Write minimal implementation** + +创建 helper 文件: + +```javascript +export function getUploadFileAction(status) { + const actionMap = { + parsed_failed: { key: "viewError", text: "查看错误原因" }, + parsed_success: { key: "delete", text: "删除" } + }; + return actionMap[status] || null; +} + +export function getUploadFileStatusText(status) { + const map = { + uploading: "上传中", + parsing: "解析中", + parsed_success: "解析成功", + parsed_failed: "解析失败", + deleted: "已删除" + }; + return map[status] || status; +} + +export function getUploadFileStatusType(status) { + const map = { + uploading: "primary", + parsing: "warning", + parsed_success: "success", + parsed_failed: "danger", + deleted: "info" + }; + return map[status] || "info"; +} +``` + +**Step 4: Run test to verify it passes** + +Run: + +```bash +node tests/frontend/upload-file-action-rules.test.mjs +``` + +Expected: + +- `PASS` + +**Step 5: Commit** + +```bash +git add ruoyi-ui/src/views/ccdiProject/components/detail/uploadFileActionRules.mjs tests/frontend/upload-file-action-rules.test.mjs +git commit -m "test: 补充上传文件操作规则测试" +``` + +### Task 2: 新增删除 API 契约 + +**Files:** +- Modify: `ruoyi-ui/src/api/ccdiProjectUpload.js` + +**Step 1: Write the failing usage** + +先在计划中约定组件调用的新 API 形式,后续组件实现前必须存在: + +```javascript +export function deleteFileUploadRecord(id) { + return request({ + url: `/ccdi/file-upload/${id}`, + method: "delete" + }); +} +``` + +**Step 2: Run a quick import smoke check** + +Run: + +```bash +node -e "const fs=require('fs'); const text=fs.readFileSync('ruoyi-ui/src/api/ccdiProjectUpload.js','utf8'); if(!text.includes('deleteFileUploadRecord')) process.exit(1);" +``` + +Expected: + +- `FAIL` + +**Step 3: Write minimal implementation** + +把旧的按 `projectId + uploadType` 删除接口保留不动,新加按记录 ID 删除的方法: + +```javascript +export function deleteFileUploadRecord(id) { + return request({ + url: `/ccdi/file-upload/${id}`, + method: "delete" + }); +} +``` + +**Step 4: Run smoke check to verify it passes** + +Run: + +```bash +node -e "const fs=require('fs'); const text=fs.readFileSync('ruoyi-ui/src/api/ccdiProjectUpload.js','utf8'); if(!text.includes('deleteFileUploadRecord')) process.exit(1);" +``` + +Expected: + +- 退出码 `0` + +**Step 5: Commit** + +```bash +git add ruoyi-ui/src/api/ccdiProjectUpload.js +git commit -m "feat: 新增上传文件按记录删除接口" +``` + +### Task 3: 在列表中渲染操作列和错误原因弹窗 + +**Files:** +- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue` +- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/uploadFileActionRules.mjs` +- Test: `tests/frontend/upload-file-action-rules.test.mjs` + +**Step 1: Write the failing test** + +先扩充规则测试,确保无操作状态不会返回按钮: + +```javascript +assert.equal(getUploadFileAction("uploading"), null); +assert.equal(getUploadFileAction("parsing"), null); +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +node tests/frontend/upload-file-action-rules.test.mjs +``` + +Expected: + +- 如果 helper 暂未覆盖全部状态,则 `FAIL` + +**Step 3: Write minimal implementation** + +在 `UploadData.vue` 中: + +- 引入 helper +- 给表格新增“操作”列 +- 根据 `getUploadFileAction(scope.row.fileStatus)` 渲染按钮 +- 增加 `handleViewError(row)`,直接读取 `row.errorMessage || "未知错误"` 并调用: + +```javascript +this.$alert(row.errorMessage || "未知错误", "错误信息", { + confirmButtonText: "确定", + type: "error" +}); +``` + +表格模板示例: + +```vue + + + +``` + +**Step 4: Run test to verify it passes** + +Run: + +```bash +node tests/frontend/upload-file-action-rules.test.mjs +``` + +Expected: + +- `PASS` + +**Step 5: Commit** + +```bash +git add ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue ruoyi-ui/src/views/ccdiProject/components/detail/uploadFileActionRules.mjs tests/frontend/upload-file-action-rules.test.mjs +git commit -m "feat: 新增上传文件列表操作列与错误原因查看" +``` + +### Task 4: 接入删除确认与刷新逻辑 + +**Files:** +- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue` +- Modify: `ruoyi-ui/src/api/ccdiProjectUpload.js` + +**Step 1: Write the failing manual checklist** + +先记录需要被满足的行为: + +- `parsed_success` 行点击“删除”必须弹出二次确认 +- 确认后调用 `deleteFileUploadRecord(row.id)` +- 成功后提示“删除成功” +- 成功后刷新 `loadStatistics()` 与 `loadFileList()` +- 若仍存在 `uploading/parsing` 记录,则继续轮询;否则不重复启动轮询 + +**Step 2: Implement the minimal component code** + +在 `UploadData.vue` 新增删除逻辑: + +```javascript +async handleDeleteFile(row) { + await this.$confirm( + "删除后将同步删除流水分析平台中的文件,并清除本系统中该文件对应的所有银行流水数据,是否继续?", + "提示", + { + confirmButtonText: "确定", + cancelButtonText: "取消", + type: "warning" + } + ); + + await deleteFileUploadRecord(row.id); + this.$message.success("删除成功"); + await Promise.all([this.loadStatistics(), this.loadFileList()]); + + if (this.statistics.uploading > 0 || this.statistics.parsing > 0) { + this.startPolling(); + } +} +``` + +并在统一入口 `handleRowAction(row)` 中根据 action key 分流到: + +- `viewError` +- `delete` + +**Step 3: Run build to verify no syntax errors** + +Run: + +```bash +cd ruoyi-ui +npm run build:prod +``` + +Expected: + +- `BUILD SUCCESS` 或等价的前端打包成功输出 + +**Step 4: Commit** + +```bash +git add ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue ruoyi-ui/src/api/ccdiProjectUpload.js +git commit -m "feat: 接入上传文件删除确认与刷新逻辑" +``` + +### Task 5: 完成前端手工回归验证 + +**Files:** +- Verify only: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue` +- Verify only: `ruoyi-ui/src/api/ccdiProjectUpload.js` +- Verify only: `tests/frontend/upload-file-action-rules.test.mjs` + +**Step 1: Run rules test** + +Run: + +```bash +node tests/frontend/upload-file-action-rules.test.mjs +``` + +Expected: + +- `PASS` + +**Step 2: Run production build** + +Run: + +```bash +cd ruoyi-ui +npm run build:prod +``` + +Expected: + +- 打包成功 + +**Step 3: Perform manual UI verification** + +手工验证清单: + +- `parsed_failed` 行显示“查看错误原因” +- 点击后能弹出错误信息 +- `parsed_success` 行显示“删除” +- 点击删除会出现确认框 +- 删除成功后状态显示为“已删除” +- `deleted` 行不再显示任何操作按钮 + +**Step 4: Commit** + +```bash +git add . +git commit -m "test: 完成上传文件删除前端回归验证" +``` diff --git a/lsfx-mock-server/routers/api.py b/lsfx-mock-server/routers/api.py index f22acab4..3943f6c0 100644 --- a/lsfx-mock-server/routers/api.py +++ b/lsfx-mock-server/routers/api.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, Query +import json + +from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Query from services.token_service import TokenService from services.file_service import FileService from services.statement_service import StatementService @@ -14,6 +16,32 @@ file_service = FileService() statement_service = StatementService() +def _parse_log_ids(log_ids: str) -> List[int]: + """兼容逗号分隔和 JSON 数组两种 logIds 传参格式。""" + raw_value = log_ids.strip() + if not raw_value: + raise HTTPException(status_code=422, detail="logIds 不能为空") + + try: + if raw_value.startswith("["): + parsed = json.loads(raw_value) + if not isinstance(parsed, list): + raise ValueError + values = parsed + else: + values = [item.strip() for item in raw_value.split(",") if item.strip()] + + if not values: + raise ValueError + + return [int(item) for item in values] + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise HTTPException( + status_code=422, + detail="logIds 必须是逗号分隔的数字字符串或 JSON 数组" + ) from exc + + # ==================== 接口1:获取Token ==================== @router.post("/account/common/getToken") async def get_token( @@ -144,15 +172,14 @@ async def get_upload_status( @router.post("/watson/api/project/batchDeleteUploadFile") async def delete_files( groupId: int = Form(..., description="项目id"), - logIds: str = Form(..., description="文件id数组,逗号分隔,如: 10001,10002"), + logIds: str = Form(..., description="文件id数组,支持 10001,10002 或 [10001,10002]"), userId: int = Form(..., description="用户柜员号"), ): """批量删除上传的文件 根据logIds列表删除对应的文件记录 """ - # 将逗号分隔的字符串转换为整数列表 - log_id_list = [int(id.strip()) for id in logIds.split(",")] + log_id_list = _parse_log_ids(logIds) return file_service.delete_files(groupId, log_id_list, userId) diff --git a/lsfx-mock-server/tests/test_api.py b/lsfx-mock-server/tests/test_api.py index c4c0833b..09bf73fc 100644 --- a/lsfx-mock-server/tests/test_api.py +++ b/lsfx-mock-server/tests/test_api.py @@ -174,3 +174,20 @@ def test_field_completeness(client): for field in required_fields: assert field in log, f"缺少字段: {field}" + + +def test_delete_files_accepts_array_style_log_ids(client): + """测试删除文件接口兼容数组风格的 logIds 入参""" + response = client.post( + "/watson/api/project/batchDeleteUploadFile", + data={ + "groupId": 1000, + "logIds": "[50689]", + "userId": 902001, + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == "200 OK" + assert data["message"] == "delete.files.success"