Refactor project pages and update related docs

This commit is contained in:
wkc
2026-05-28 16:37:51 +08:00
parent 000e8698a5
commit 7ce721ef93
40 changed files with 730 additions and 785 deletions

4
.gitignore vendored
View File

@@ -97,3 +97,7 @@ tongweb_62318.properties
.superpowers/
tmp/
.codegraph/
.claude/

669
CLAUDE.md
View File

@@ -1,669 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 快速参考
**启动项目:**
- 后端: `mvn spring-boot:run` 或运行 `ry.bat`
- 前端: `cd ruoyi-ui && npm run dev`
**访问地址:**
- 前端: http://localhost:80
- 后端: http://localhost:8080
- Swagger: http://localhost:8080/swagger-ui/index.html
- Druid 监控: http://localhost:8080/druid/ (ruoyi/123456)
**测试账号:**
- 用户名: `admin`
- 密码: `admin123`
**获取 Token:**
```bash
POST http://localhost:8080/login/test?username=admin&password=admin123
```
---
## 项目概述
**纪检初核系统** - 基于 **若依管理系统 v3.9.1** 构建的企业级前后端分离管理系统,用于员工异常行为风险识别。
### 技术栈版本
| 后端技术 | 版本 | 前端技术 | 版本 |
|-----------------------------|--------|------------|---------|
| Spring Boot | 3.5.8 | Vue.js | 2.6.12 |
| Java | 21 | Element UI | 2.15.14 |
| MyBatis Spring Boot Starter | 3.0.5 | Vuex | 3.6.0 |
| MySQL Connector | 8.2.0 | Vue Router | 3.4.9 |
| SpringDoc OpenAPI | 2.8.14 | Axios | 0.28.1 |
| EasyExcel | 3.3.4 | ECharts | 5.4.0 |
| Quartz | 2.5.2 | Sass | 1.32.13 |
---
## 常用命令
### 后端 (Maven)
```bash
# 编译项目
mvn clean compile
# 运行应用 (开发环境)
mvn spring-boot:run
# 打包部署
mvn clean package
# Windows 启动
ry.bat
# Linux/Mac 启动
./ry.sh start
```
### 前端 (npm)
```bash
cd ruoyi-ui
# 安装依赖 (推荐使用国内镜像)
npm install --registry=https://registry.npmmirror.com
# 开发服务器 (端口 80)
npm run dev
# 生产构建
npm run build:prod
# 预览生产构建
npm run preview
```
### 数据库初始化
```bash
# 初始化若依框架基础表
mysql -u root -p < sql/ry_20250522.sql
# 初始化定时任务表
mysql -u root -p < sql/quartz.sql
# 导入业务表(根据需要执行)
mysql -u root -p ccdi < sql/dpc_employee.sql
mysql -u root -p ccdi < sql/dpc_intermediary_blacklist.sql
# ... 其他业务表脚本
```
**注意:**
- 业务表脚本文件名以 `ccdi_``dpc_` 开头
- 部分脚本包含菜单数据,需要按顺序执行
- 数据库需要先创建(数据库名: `ccdi`
---
## 模块架构
```
ccdi/
├── ruoyi-admin/ # 主应用入口 (Spring Boot 启动类)
├── ruoyi-framework/ # 核心框架 (Security, Config, Filters)
├── ruoyi-system/ # 系统管理 (Users, Roles, Menus, Depts)
├── ruoyi-common/ # 通用工具 (annotations, utils, constants)
├── ruoyi-quartz/ # 定时任务
├── ruoyi-generator/ # 代码生成器
├── ccdi-info-collection/ # 【核心业务模块】信息采集
├── ccdi-project/ # 【核心业务模块】项目管理
├── ccdi-lsfx/ # 【核心业务模块】流水分析对接
├── lsfx-mock-server/ # 流水分析模拟服务器 (Python)
├── ruoyi-ui/ # 前端 Vue 应用
├── sql/ # 数据库脚本
├── bin/ # 启动脚本
└── doc/ # 项目文档
```
### 模块依赖关系
```
ruoyi-admin (启动模块)
├── ruoyi-framework (核心安全配置)
├── ruoyi-system (系统核心业务)
├── ruoyi-common (共享工具)
├── ruoyi-quartz (定时任务)
├── ruoyi-generator (代码生成)
├── ccdi-info-collection (信息采集模块)
│ └── 依赖 ruoyi-common
├── ccdi-project (项目管理模块)
│ └── 依赖 ruoyi-common
└── ccdi-lsfx (流水分析对接模块)
└── 依赖 ruoyi-common
```
**添加新业务模块:**
1. 在根目录 `pom.xml``<modules>` 中添加新模块
2. 在新模块的 `pom.xml` 中添加对 `ruoyi-common` 的依赖
3.`ruoyi-admin/pom.xml` 中添加对新模块的依赖
4. 在新模块中按照分层规范创建 controller/service/mapper/domain 包
### ccdi-info-collection 业务模块 (核心)
自定义业务模块,包含以下核心功能:
| 功能 | Controller | 实体类 |
|----------|---------------------------------------|-----------------------------|
| 员工基础信息 | CcdiBaseStaffController | CcdiBaseStaff |
| 中介黑名单 | CcdiIntermediaryController | CcdiBizIntermediary |
| 员工家庭关系 | CcdiStaffFmyRelationController | CcdiStaffFmyRelation |
| 员工企业关系 | CcdiStaffEnterpriseRelationController | CcdiStaffEnterpriseRelation |
| 信贷客户家庭关系 | CcdiCustFmyRelationController | CcdiCustFmyRelation |
| 信贷客户企业关系 | CcdiCustEnterpriseRelationController | CcdiCustEnterpriseRelation |
| 员工调动记录 | CcdiStaffTransferController | CcdiStaffTransfer |
| 员工招聘记录 | CcdiStaffRecruitmentController | CcdiStaffRecruitment |
| 采购交易 | CcdiPurchaseTransactionController | CcdiPurchaseTransaction |
**分层结构:**
- Controller: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/`
- Service: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/`
- Mapper: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/`
- Domain: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/`
- dto/: 数据传输对象
- vo/: 视图对象
- excel/: Excel导入导出实体
- XML映射: `ccdi-info-collection/src/main/resources/mapper/info/collection/`
### ccdi-project 业务模块 (核心)
项目管理模块,用于管理纪检初核项目的全生命周期:
**核心功能:**
- 项目创建、更新、删除、查询
- 项目状态管理 (进行中、已完成、已归档)
- 项目统计(按状态统计数量)
- 模型参数配置管理
**主要 Controller:**
- CcdiProjectController: 项目管理
- CcdiModelParamController: 模型参数配置
**分层结构:**
- Controller: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/`
- Service: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/`
- Mapper: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/mapper/`
- Domain: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/domain/`
- XML映射: `ccdi-project/src/main/resources/mapper/ccdi/project/`
### ccdi-lsfx 业务模块 (核心)
流水分析平台对接模块,用于与外部流水分析系统交互:
**核心功能:**
- 获取访问令牌 (Token)
- 上传流水文件并解析
- 拉取行内流水数据
- 查询解析状态和结果
- 获取银行流水明细
**主要组件:**
- LsfxAnalysisClient: 流水分析平台客户端
- LsfxTestController: 测试接口
**配置项 (application-dev.yml):**
```yaml
lsfx:
api:
base-url: http://localhost:8000 # 流水分析平台地址
app-id: your-app-id
app-secret: your-app-secret
client-id: your-client-id
endpoints:
get-token: /api/auth/token
upload-file: /api/files/upload
fetch-inner-flow: /api/flow/inner
```
**分层结构:**
- Client: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/`
- Controller: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/controller/`
- Domain: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/domain/`
- request/: 请求对象
- response/: 响应对象
- Config: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/config/`
### lsfx-mock-server (开发测试工具)
Python 实现的流水分析平台模拟服务器,用于本地开发和测试:
**用途:**
- 模拟流水分析平台的 API 接口
- 提供测试数据和模拟响应
- 支持错误场景模拟
**启动方式:**
```bash
cd lsfx-mock-server
python app.py # 默认监听 http://localhost:8000
```
---
## 后端开发规范
### 通用规范
- **新模块命名**: 项目英文名首字母集合 + 主要功能 (如 `ruoyi-info-collection`)
- **代码分离**: 新功能代码与若依框架自带代码分离Controller 放在新模块中
- **审计字段**: 实体类不继承 BaseEntity单独添加审计字段通过注释实现自动插入
### Java 代码风格
```java
// 使用 @Data 注解
@Data
public class CcdiBaseStaff {
// 审计字段通过注释实现自动插入
/** 创建者 */
private String createBy;
/** 创建时间 */
private Date createTime;
/** 更新者 */
private String updateBy;
/** 更新时间 */
private Date updateTime;
}
// 服务层使用 @Resource 注入
@Resource
private ICcdiBaseStaffService baseStaffService;
```
### 分层规范
- **Controller**: 所有接口添加 Swagger 注释,分页使用 MyBatis Plus Page
- **Service**: 简单 CRUD 用 MyBatis Plus 方法,复杂操作在 XML 写 SQL
- **DTO/VO**: 接口传参使用独立 DTO返回使用独立 VO不与 entity 混用
- **Mapper**: 简单操作继承 BaseMapper复杂操作在 XML 中定义
### 禁止事项
- **禁止使用全限定类名**: 必须使用 `import` 语句导入类,不要在代码中使用 `java.util.List` 这样的全限定名
- **禁止使用 `extends ServiceImpl<>`**: Service 接口和实现类分离定义
- **禁止 Entity 混用**: DTO、VO、Excel 类必须独立,不与 Entity 混用
- **禁止缺少 `@Resource`**: Service 注入必须使用 `@Resource` 注解
### API 响应格式
```java
// 成功
AjaxResult.success("操作成功", data);
// 错误
AjaxResult.error("操作失败");
// 分页
Page<CcdiBaseStaff> page = new Page<>(pageNum, pageSize);
IPage<CcdiBaseStaff> result = baseStaffMapper.selectPage(page, queryWrapper);
return AjaxResult.success(result);
```
---
## 前端开发规范
### 目录结构
```
ruoyi-ui/src/
├── api/ # API 请求定义 (与后端 Controller 对应)
├── views/ # 页面组件 (按功能模块组织)
│ ├── ccdiBaseStaff/
│ ├── ccdiIntermediary/
│ └── ...
├── components/ # 可复用组件 (复杂组件需拆分)
├── router/ # 路由配置
└── store/ # Vuex 状态管理
```
### API 调用示例
```javascript
import request from '@/utils/request'
export function listStaff(query) {
return request({
url: '/ccdi/baseStaff/list',
method: 'get',
params: query
})
}
```
### 菜单联动
添加页面和组件后,需要同步修改数据库中的菜单表 (`sys_menu`)。
---
## 特殊功能
### 异步导入
支持大数据量异步 Excel 导入,通过 taskId 查询导入状态:
```java
@PostMapping("/import")
public AjaxResult asyncImport(@RequestParam("file") MultipartFile file) {
String taskId = asyncImportService.startImport(file);
return AjaxResult.success("导入任务已启动", taskId);
}
@GetMapping("/import/status/{taskId}")
public AjaxResult getImportStatus(@PathVariable String taskId) {
return AjaxResult.success(asyncImportService.getStatus(taskId));
}
```
**导入流程:**
1. 前端上传 Excel 文件
2. 后端异步处理,返回 taskId
3. 前端轮询 `/import/status/{taskId}` 获取导入进度
4. 导入完成后,可获取成功/失败数据统计
**导入结果处理:**
- 只返回导入失败的数据(含失败原因)
- 成功数据不返回,减少响应体积
- 支持批量插入,提高性能
### EasyExcel 字典下拉框
导入模板支持字典下拉框配置,提升数据录入准确性。使用 `DictDropdownWriteHandler` 实现。
### 权限控制
基于 Spring Security + JWT 的角色菜单权限系统:
- 权限格式: `system:user:edit`, `ccdi:staff:list`
- 数据权限: 支持全部、自定义、部门等范围
---
## 测试与验证
### 测试账号
- **用户名**: `admin`
- **密码**: `admin123`
### 登录获取 Token
```bash
# 登录接口
POST /login/test?username=admin&password=admin123
```
### API 文档
- **Swagger UI**: `/swagger-ui/index.html`
- **API Docs**: `/v3/api-docs`
### 测试规范
- 不在命令行启动后端进行测试
- 生成可执行的测试脚本进行验证
- 测试完成后保存接口输出并生成测试用例报告
### 开发调试技巧
**使用 Swagger 测试接口:**
1. 访问 `/swagger-ui/index.html`
2. 点击接口展开详情
3. 点击 "Try it out" 进行测试
4. 填写参数后点击 "Execute" 执行
**查看 SQL 执行日志:**
-`application.yml` 中设置日志级别: `com.ruoyi: debug`
- 使用 Druid 监控台查看慢 SQL
**前端代理配置:**
前端开发服务器通过代理转发请求到后端:
- 前端地址: `http://localhost:80`
- 后端地址: `http://localhost:8080`
- 代理配置文件: `ruoyi-ui/vue.config.js`
---
## 配置说明
| 配置项 | 值 |
|---------|-------------------|
| 后端端口 | 8080 |
| 前端开发端口 | 80 |
| 默认管理员 | admin/admin123 |
| JWT 有效期 | 30 分钟 |
| 文件上传限制 | 单文件 10MB, 总计 20MB |
### 配置文件位置
| 配置 | 路径 |
|----------|------------------------------------------------------|
| 主配置 | `ruoyi-admin/src/main/resources/application.yml` |
| 开发环境 | `ruoyi-admin/src/main/resources/application-dev.yml` |
| 数据库连接 | `application-dev.yml` |
| Redis 配置 | `application-dev.yml` |
### 数据源配置
项目使用 Druid 连接池,支持主从分离(默认关闭从库):
- **数据库连接**: `jdbc:mysql://host:3306/ccdi`
- **初始连接数**: 5
- **最小连接数**: 10
- **最大连接数**: 20
- **慢 SQL 记录**: 超过 1000ms 的 SQL 会被记录
### Redis 配置
- **默认端口**: 6379
- **数据库索引**: 0
- **连接超时**: 10s
### 流水分析平台配置
项目集成了外部流水分析平台,配置项位于 `application-dev.yml`:
```yaml
lsfx:
api:
base-url: http://localhost:8000 # 流水分析平台基础地址
app-id: ccdi-app # 应用ID
app-secret: ccdi-secret-2024 # 应用密钥
client-id: ccdi-client # 客户端ID
endpoints:
get-token: /api/auth/token # 获取令牌接口
upload-file: /api/files/upload # 文件上传接口
fetch-inner-flow: /api/flow/inner # 拉取行内流水接口
```
**开发环境使用 Mock 服务器:**
- 本地开发时,将 `base-url` 设置为 `http://localhost:8000`
- 启动 `lsfx-mock-server` 提供模拟接口
- 生产环境替换为真实的流水分析平台地址
### MCP 配置
项目使用 MCP (Model Context Protocol) 连接数据库,配置文件: `.mcp.json`
```json
{
"mcpServers": {
"mysql": {
"command": "npx",
"args": ["-y", "@fhuang/mcp-mysql-server"],
"env": {
"MYSQL_HOST": "116.62.17.81",
"MYSQL_PORT": "3306",
"MYSQL_USER": "root",
"MYSQL_PASSWORD": "Kfcx@1234",
"MYSQL_DATABASE": "ccdi"
}
}
}
}
```
**使用场景:**
- 通过 MCP 工具直接查询和操作数据库
- 在开发过程中快速验证数据
- 生成测试数据和调试 SQL
### Druid 监控台
访问地址: `http://localhost:8080/druid/`
- 用户名: `ruoyi`
- 密码: `123456`
用于监控 SQL 执行情况、连接池状态等。
---
## 重要文件路径
| 用途 | 路径 |
|---------------|--------------------------------------------------------------------------------|
| 应用入口 | `ruoyi-admin/src/main/java/com/ruoyi/RuoYiApplication.java` |
| 安全配置 | `ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java` |
| 信息采集 Controller | `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/` |
| 信息采集 Mapper XML | `ccdi-info-collection/src/main/resources/mapper/info/collection/` |
| 项目管理 Controller | `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/` |
| 项目管理 Mapper XML | `ccdi-project/src/main/resources/mapper/ccdi/project/` |
| 流水分析 Client | `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java` |
| Vue 路由 | `ruoyi-ui/src/router/index.js` |
| Vuex Store | `ruoyi-ui/src/store/` |
| 前端 API | `ruoyi-ui/src/api/` |
---
## 数据库规范
- **新建表名**: 需要加上项目英文名首字母集合前缀 `ccdi_` (如 `ccdi_base_staff`)
---
## 文档管理
- **文档语言**: 使用简体中文编写 .md 文档
- **文档目录**: 所有生成的文档放在 `doc/` 目录下,按类型分类
- **需求分析**: 在 `doc/` 目录下新建文件夹,以需求内容命名
### doc 目录结构
```
doc/
├── api-docs/ # API 文档
├── database/ # 数据库相关
├── design/ # 设计文档
├── implementation/ # 实施文档
├── requirements/ # 需求文档
└── test-scripts/ # 测试脚本
```
---
## OpenSpec 工作流
项目使用 OpenSpec 进行规范驱动开发,参考 `openspec/AGENTS.md`
### 何时创建 Proposal
**需要创建:**
- 新功能或能力
- 破坏性变更 (API, 数据库结构)
- 架构变更
- 改变行为的性能优化
**无需创建:**
- Bug 修复 (恢复预期行为)
- 拼写错误、格式、注释
- 非破坏性依赖更新
- 配置变更
---
## 沟通规范
- 永远使用简体中文进行思考和对话
---
## 常见问题排查
### 数据库连接失败
**检查项:**
1. 确认 MySQL 服务已启动
2. 检查 `application-dev.yml` 中的数据库连接配置
3. 确认数据库用户名和密码正确
4. 检查数据库是否已创建(数据库名: `ccdi`
### Redis 连接失败
**检查项:**
1. 确认 Redis 服务已启动
2. 检查 `application-dev.yml` 中的 Redis 配置
3. 如果 Redis 不需要密码,将 `password` 配置注释掉
### 前端无法访问后端接口
**检查项:**
1. 确认后端已启动(端口 8080
2. 检查前端代理配置(`ruoyi-ui/vue.config.js`
3. 确认后端接口路径正确(查看 Controller 的 `@RequestMapping`
### 导入功能无响应
**检查项:**
1. 检查文件大小是否超过限制(默认 10MB
2. 查看后端日志是否有异常
3. 确认 Excel 模板格式正确
4. 检查必填字段是否为空
### 流水分析平台连接失败
**检查项:**
1. 确认 `lsfx-mock-server` 已启动(开发环境)
2. 检查 `application-dev.yml` 中的 `lsfx.api.base-url` 配置
3. 验证 app-id、app-secret、client-id 是否正确
4. 检查网络连接和防火墙设置
5. 查看后端日志中的 HTTP 请求错误信息
---
## MyBatis Plus 分页使用
```java
// Controller 层
@GetMapping("/list")
public TableDataInfo list(QueryDTO queryDTO) {
PageDomain pageDomain = TableSupport.buildPageRequest();
Page<VO> page = new Page<>(pageDomain.getPageNum(), pageDomain.getPageSize());
Page<VO> result = service.selectPage(page, queryDTO);
return getDataTable(result.getRecords(), result.getTotal());
}
// Service 层
Page<VO> selectPage(Page<VO> page, QueryDTO queryDTO);
// Mapper 层 (使用 XML)
<select id="selectPage" resultType="VO">
SELECT * FROM table_name
<where>
<if test="queryDTO.name != null">
AND name LIKE CONCAT('%', #{queryDTO.name}, '%')
</if>
</where>
</select>
```

View File

@@ -1,92 +0,0 @@
#!/bin/sh
set -eu
ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
DATE_STAMP=$(date "+%Y%m%d")
RELEASE_ZIP="$ROOT_DIR/ccdi_${DATE_STAMP}.zip"
STAGE_DIR="$ROOT_DIR/.deploy/ccdi-release-package"
WORK_DIR="$STAGE_DIR/files"
BACKEND_JAR_SOURCE="$ROOT_DIR/ruoyi-admin/target/ruoyi-admin.jar"
FRONTEND_DIR="$ROOT_DIR/ruoyi-ui"
FRONTEND_DIST_DIR="$FRONTEND_DIR/dist"
FRONTEND_DIST_ZIP="$WORK_DIR/dist.zip"
log_info() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1"
}
log_error() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >&2
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
log_error "缺少命令: $1"
exit 1
fi
}
reset_stage_dir() {
rm -rf "$STAGE_DIR"
mkdir -p "$WORK_DIR"
}
build_backend() {
log_info "开始构建后端生产 jar"
(
cd "$ROOT_DIR"
mvn -pl ruoyi-admin -am clean package -DskipTests
)
if [ ! -f "$BACKEND_JAR_SOURCE" ]; then
log_error "未生成后端 jar: $BACKEND_JAR_SOURCE"
exit 1
fi
}
build_frontend() {
log_info "开始构建前端生产 dist"
FRONTEND_DIR="$FRONTEND_DIR" zsh -lic 'cd "$FRONTEND_DIR" && nvm use >/dev/null && npm run build:prod'
if [ ! -f "$FRONTEND_DIST_DIR/index.html" ]; then
log_error "前端生产构建失败,未找到: $FRONTEND_DIST_DIR/index.html"
exit 1
fi
(
cd "$FRONTEND_DIR"
zip -qr "$FRONTEND_DIST_ZIP" dist
)
if [ ! -f "$FRONTEND_DIST_ZIP" ]; then
log_error "未生成前端压缩包: $FRONTEND_DIST_ZIP"
exit 1
fi
}
package_release() {
cp "$BACKEND_JAR_SOURCE" "$WORK_DIR/ruoyi-admin.jar"
rm -f "$RELEASE_ZIP"
(
cd "$WORK_DIR"
zip -qr "$RELEASE_ZIP" ruoyi-admin.jar dist.zip
)
log_info "上线压缩包已生成: $RELEASE_ZIP"
log_info "压缩包根层内容: ruoyi-admin.jar, dist.zip"
}
main() {
require_command mvn
require_command zsh
require_command zip
reset_stage_dir
build_backend
build_frontend
package_release
}
main "$@"

View File

@@ -84,6 +84,7 @@
<sql id="AccountInfoWhereClause">
WHERE 1 = 1
AND ai.owner_type &lt;&gt; 'CREDIT_CUSTOMER'
<if test="query.staffName != null and query.staffName != ''">
AND (
(ai.owner_type = 'EMPLOYEE' AND bs.name LIKE CONCAT('%', #{query.staffName}, '%'))

View File

@@ -35,6 +35,7 @@ class CcdiAccountInfoMapperTest {
assertTrue(sql.contains("ai.is_self_account as isactualcontrol"), sql);
assertTrue(sql.contains("ai.monthly_avg_trans_count as avgmonthtxncount"), sql);
assertTrue(sql.contains("ai.trans_risk_level as txnrisklevel"), sql);
assertTrue(sql.contains("ai.owner_type <> 'credit_customer'"), sql);
}
private MappedStatement loadMappedStatement(String statementId) throws Exception {

View File

@@ -9,6 +9,7 @@ public final class CcdiProjectStatusConstants {
public static final String COMPLETED = "1";
public static final String ARCHIVED = "2";
public static final String TAGGING = "3";
public static final String TAG_FAILED = "4";
private CcdiProjectStatusConstants() {
}

View File

@@ -32,7 +32,7 @@ public class CcdiProject implements Serializable {
/** 配置方式default-全局默认custom-自定义 */
private String configType;
/** 项目状态0-进行中1-已完成2-已归档3-打标中 */
/** 项目状态0-进行中1-已完成2-已归档3-打标中4-打标失败 */
private String status;
/** 是否归档0-未归档1-已归档 */

View File

@@ -23,4 +23,7 @@ public class CcdiProjectStatusCountsVO {
/** 打标中项目数(状态3) */
private Long status3;
/** 打标失败项目数(状态4) */
private Long status4;
}

View File

@@ -50,6 +50,12 @@ public class CcdiProjectVO {
/** 更新时间 */
private Date updateTime;
/** 最近一次打标失败原因 */
private String latestTagTaskErrorMessage;
/** 最近一次打标失败结束时间 */
private Date latestTagTaskEndTime;
/** 创建者(用户名) */
private String createBy;

View File

@@ -32,4 +32,12 @@ public interface CcdiBankTagTaskMapper extends BaseMapper<CcdiBankTagTask> {
* @return 任务实体
*/
CcdiBankTagTask selectRunningTaskByProjectId(@Param("projectId") Long projectId);
/**
* 查询项目最近一次失败任务
*
* @param projectId 项目ID
* @return 任务实体
*/
CcdiBankTagTask selectLatestFailedTaskByProjectId(@Param("projectId") Long projectId);
}

View File

@@ -153,7 +153,7 @@ public class CcdiBankTagServiceImpl implements ICcdiBankTagService {
task.setUpdateBy(operator);
task.setUpdateTime(new Date());
updateFailedTaskSafely(task, ex);
projectService.updateProjectStatus(projectId, CcdiProjectStatusConstants.PROCESSING, operator);
projectService.updateProjectStatus(projectId, CcdiProjectStatusConstants.TAG_FAILED, operator);
log.error("【流水标签】任务执行失败: taskId={}, projectId={}, modelCode={}, triggerType={}, error={}",
task.getId(), projectId, modelCode, triggerType, ex.getMessage(), ex);
throw ex;

View File

@@ -7,10 +7,12 @@ import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSaveDTO;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask;
import com.ruoyi.ccdi.project.domain.event.CcdiProjectHistoryImportSubmittedEvent;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectHistoryListItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectStatusCountsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.service.ICcdiProjectService;
import com.ruoyi.common.exception.ServiceException;
@@ -43,6 +45,9 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
@Resource
private CcdiProjectMapper projectMapper;
@Resource
private CcdiBankTagTaskMapper bankTagTaskMapper;
@Resource
private LsfxAnalysisClient lsfxAnalysisClient;
@@ -77,6 +82,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
// 5. 返回VO
CcdiProjectVO vo = new CcdiProjectVO();
BeanUtils.copyProperties(project, vo);
fillLatestTagFailure(project, vo);
return vo;
}
@@ -116,6 +122,7 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
}
CcdiProjectVO vo = new CcdiProjectVO();
BeanUtils.copyProperties(project, vo);
fillLatestTagFailure(project, vo);
return vo;
}
@@ -183,6 +190,12 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
);
vo.setStatus3(status3Count);
Long status4Count = projectMapper.selectCount(
new LambdaQueryWrapper<CcdiProject>()
.eq(CcdiProject::getStatus, CcdiProjectStatusConstants.TAG_FAILED)
);
vo.setStatus4(status4Count);
return vo;
}
@@ -263,10 +276,23 @@ public class CcdiProjectServiceImpl implements ICcdiProjectService {
case CcdiProjectStatusConstants.COMPLETED -> "已完成";
case CcdiProjectStatusConstants.ARCHIVED -> "已归档";
case CcdiProjectStatusConstants.TAGGING -> "打标中";
case CcdiProjectStatusConstants.TAG_FAILED -> "打标失败";
default -> "未知";
};
}
private void fillLatestTagFailure(CcdiProject project, CcdiProjectVO vo) {
if (!CcdiProjectStatusConstants.TAG_FAILED.equals(project.getStatus())) {
return;
}
CcdiBankTagTask latestFailedTask = bankTagTaskMapper.selectLatestFailedTaskByProjectId(project.getProjectId());
if (latestFailedTask == null) {
return;
}
vo.setLatestTagTaskErrorMessage(latestFailedTask.getErrorMessage());
vo.setLatestTagTaskEndTime(latestFailedTask.getEndTime());
}
private String resolveOperator(String operator) {
return StringUtils.hasText(operator) ? operator : "system";
}

View File

@@ -65,4 +65,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
limit 1
</select>
<select id="selectLatestFailedTaskByProjectId" resultMap="CcdiBankTagTaskResultMap">
select id, project_id, trigger_type, model_code, status, need_rerun, success_rule_count,
failed_rule_count, hit_count, error_message, start_time, end_time,
create_by, create_time, update_by, update_time
from ccdi_bank_tag_task
where project_id = #{projectId}
and status = 'FAILED'
order by id desc
limit 1
</select>
</mapper>

View File

@@ -317,7 +317,7 @@ class CcdiBankTagServiceImplTest {
}
@Test
void shouldRollbackProjectStatusToProcessingWhenRebuildFails() {
void shouldMarkProjectTagFailedWhenRebuildFails() {
ReflectionTestUtils.setField(service, "tagRuleExecutor", (Executor) Runnable::run);
CcdiBankTagRule rule = buildRule("LARGE_TRANSACTION", "大额交易",
@@ -329,7 +329,7 @@ class CcdiBankTagServiceImplTest {
assertThrows(RuntimeException.class,
() -> service.rebuildProject(40L, null, "tester", TriggerType.MANUAL));
verify(projectService).updateProjectStatus(40L, "0", "tester");
verify(projectService).updateProjectStatus(40L, "4", "tester");
}
@Test

View File

@@ -112,7 +112,7 @@ class CcdiBankTagServiceRiskCountRefreshTest {
verify(taskMapper).updateTask(argThat(task -> "FAILED".equals(task.getStatus())
&& "refresh failed".equals(task.getErrorMessage())));
verify(projectService).updateProjectStatus(40L, "0", "tester");
verify(projectService).updateProjectStatus(40L, "4", "tester");
}
private CcdiBankTagRule buildRule() {

View File

@@ -7,10 +7,12 @@ import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectImportHistoryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectSaveDTO;
import com.ruoyi.ccdi.project.domain.entity.CcdiBankTagTask;
import com.ruoyi.ccdi.project.domain.event.CcdiProjectHistoryImportSubmittedEvent;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectHistoryListItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectStatusCountsVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectVO;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagTaskMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.lsfx.client.LsfxAnalysisClient;
@@ -25,11 +27,14 @@ import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.Date;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -47,6 +52,9 @@ class CcdiProjectServiceImplTest {
@Mock
private CcdiProjectMapper projectMapper;
@Mock
private CcdiBankTagTaskMapper bankTagTaskMapper;
@Mock
private LsfxAnalysisClient lsfxAnalysisClient;
@@ -55,13 +63,55 @@ class CcdiProjectServiceImplTest {
@Test
void shouldCountTaggingProjectsSeparately() {
when(projectMapper.selectCount(any())).thenReturn(10L, 3L, 4L, 2L, 1L);
when(projectMapper.selectCount(any())).thenReturn(10L, 3L, 4L, 2L, 1L, 0L);
CcdiProjectStatusCountsVO counts = service.getStatusCounts();
assertEquals(1L, counts.getStatus3());
}
@Test
void shouldCountTagFailedProjectsSeparately() {
when(projectMapper.selectCount(any())).thenReturn(10L, 3L, 4L, 2L, 1L, 5L);
CcdiProjectStatusCountsVO counts = service.getStatusCounts();
assertEquals(5L, counts.getStatus4());
}
@Test
void shouldReturnLatestFailedTagTaskOnFailedProjectDetail() {
Date endTime = new Date();
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setStatus("4");
when(projectMapper.selectById(40L)).thenReturn(project);
CcdiBankTagTask failedTask = new CcdiBankTagTask();
failedTask.setErrorMessage("threshold missing");
failedTask.setEndTime(endTime);
when(bankTagTaskMapper.selectLatestFailedTaskByProjectId(40L)).thenReturn(failedTask);
CcdiProjectVO result = service.getProjectById(40L);
assertEquals("threshold missing", result.getLatestTagTaskErrorMessage());
assertEquals(endTime, result.getLatestTagTaskEndTime());
}
@Test
void shouldNotReturnLatestFailedTagTaskWhenProjectIsNotFailed() {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setStatus("0");
when(projectMapper.selectById(40L)).thenReturn(project);
CcdiProjectVO result = service.getProjectById(40L);
assertNotNull(result);
assertNull(result.getLatestTagTaskErrorMessage());
verify(bankTagTaskMapper, never()).selectLatestFailedTaskByProjectId(any());
}
@Test
void shouldRejectUpdatingArchivedProjectToTagging() {
CcdiProject archived = new CcdiProject();
@@ -84,6 +134,16 @@ class CcdiProjectServiceImplTest {
() -> service.ensureProjectWritable(40L, "当前项目正在进行银行流水打标,暂不允许修改参数"));
}
@Test
void shouldAllowWritingWhenProjectTagFailed() {
CcdiProject tagFailed = new CcdiProject();
tagFailed.setProjectId(40L);
tagFailed.setStatus("4");
when(projectMapper.selectById(40L)).thenReturn(tagFailed);
assertDoesNotThrow(() -> service.ensureProjectWritable(40L, "当前项目正在进行银行流水打标,暂不允许修改参数"));
}
@Test
void shouldArchiveCompletedProject() {
CcdiProject project = new CcdiProject();
@@ -110,6 +170,16 @@ class CcdiProjectServiceImplTest {
assertThrows(ServiceException.class, () -> service.archiveProject(41L, "tester"));
}
@Test
void shouldRejectArchivingProjectWhenStatusIsTagFailed() {
CcdiProject project = new CcdiProject();
project.setProjectId(41L);
project.setStatus("4");
when(projectMapper.selectById(41L)).thenReturn(project);
assertThrows(ServiceException.class, () -> service.archiveProject(41L, "tester"));
}
@Test
void shouldRejectWritingWhenProjectIsArchived() {
CcdiProject archived = new CcdiProject();

View File

@@ -14,12 +14,25 @@ class CcdiProjectStatusSqlTest {
void shouldContainTaggingStatusInInitAndMigrationSql() throws IOException {
Path repoRoot = Path.of("..");
String initSql = Files.readString(repoRoot.resolve("sql/ccdi_project.sql"));
String prodInitSql = Files.readString(repoRoot.resolve("sql/ccdi_prod_init.sql"));
String migrationSql = Files.readString(repoRoot.resolve("sql/migration/2026-03-18-add-project-tagging-status.sql"));
String tagFailedMigrationSql =
Files.readString(repoRoot.resolve("sql/migration/2026-05-27-add-project-tag-failed-status.sql"));
assertTrue(initSql.contains("打标中"));
assertTrue(initSql.contains("'3'"));
assertTrue(migrationSql.contains("ccdi_project_status"));
assertTrue(migrationSql.contains("打标中"));
assertTrue(migrationSql.contains("'3'"));
assertTrue(initSql.contains("打标失败"));
assertTrue(initSql.contains("'4'"));
assertTrue(prodInitSql.contains("打标失败"));
assertTrue(prodInitSql.contains("'4','ccdi_project_status'"));
assertTrue(tagFailedMigrationSql.contains("ccdi_project_status"));
assertTrue(tagFailedMigrationSql.contains("打标失败"));
assertTrue(tagFailedMigrationSql.contains("'4'"));
assertTrue(tagFailedMigrationSql.contains("latest_task.status = 'FAILED'"));
assertTrue(tagFailedMigrationSql.contains("project.status IN ('0', '3')"));
}
}

View File

@@ -0,0 +1,29 @@
# 账号库列表排除信贷客户后端实施计划
## 1. 目标
账号库管理列表不展示 `ccdi_account_info.owner_type = 'CREDIT_CUSTOMER'` 的信贷客户账号,避免信贷客户账号批量导入后进入页面列表并影响查询性能。
## 2. 实施范围
- 后端账号库列表查询 SQL
- 账号库导出查询复用同一筛选条件
- 本次不调整前端筛选项、接口参数、返回结构、新增编辑导入校验
## 3. 实施步骤
1.`CcdiAccountInfoMapper.xml``AccountInfoWhereClause` 增加固定条件:
`AND ai.owner_type <> 'CREDIT_CUSTOMER'`
2. 保持现有 `ownerType` 动态筛选逻辑不变,使 `ownerType=CREDIT_CUSTOMER` 查询自然返回空结果。
3. 不新增前端“信贷客户”筛选项,不扩展账号库维护端归属类型。
## 4. 验证要点
- 无筛选条件时列表不返回 `CREDIT_CUSTOMER` 数据。
- `ownerType=EMPLOYEE``RELATION``INTERMEDIARY``EXTERNAL` 时仍按原逻辑查询。
- `ownerType=CREDIT_CUSTOMER` 时返回空结果。
- 账号库导出与列表使用同一排除口径。
## 5. 前提
信贷客户账号导入 `ccdi_account_info` 时,`owner_type` 必须固定写入 `CREDIT_CUSTOMER`

View File

@@ -0,0 +1,24 @@
# 项目打标失败状态后端实施计划
## 保存路径确认
- 后端计划:`docs/plans/backend/2026-05-27-project-tag-failed-status-backend-implementation.md`
- 实施记录:`docs/reports/implementation/2026-05-27-project-tag-failed-status-implementation.md`
## 目标
新增正式项目状态 `4-打标失败`,打标任务失败后项目状态停留在失败态;项目详情接口在失败态下返回最近失败任务错误信息,列表接口不返回完整错误。
## 实施步骤
1. 扩展项目状态常量、实体注释、状态文案和状态统计 VO新增 `TAG_FAILED = "4"``status4`
2. 修改打标失败流转:`CcdiBankTagServiceImpl.rebuildProject` 捕获异常后保留任务失败信息,并将项目状态更新为 `4`
3. 新增 `CcdiBankTagTaskMapper.selectLatestFailedTaskByProjectId`,按 `id desc limit 1` 查询项目最近失败任务。
4. 扩展 `CcdiProjectVO`,只新增 `latestTagTaskErrorMessage``latestTagTaskEndTime``getProjectById` 仅在状态为 `4` 时组装失败任务信息。
5. 补充 SQL 初始化与迁移脚本,新增 `ccdi_project_status` 字典值 `4-打标失败`,并回填未归档且最新打标任务失败的 `0/3` 项目。
6. 补充后端单测覆盖失败状态流转、详情失败信息、`status4` 统计、`4` 状态可写和 SQL/Mapper 契约。
## 验证
- 执行本次相关后端测试类。
- 执行 `mvn -pl ccdi-project -am test` 观察全量状态并记录非本次问题。

View File

@@ -0,0 +1,28 @@
# 项目打标失败状态前端实施计划
## 保存路径确认
- 前端计划:`docs/plans/frontend/2026-05-27-project-tag-failed-status-frontend-implementation.md`
- 实施记录:`docs/reports/implementation/2026-05-27-project-tag-failed-status-implementation.md`
## 目标
前端支持 `4-打标失败` 状态展示;项目列表只展示失败状态和进入项目入口;项目详情页展示失败提示,并通过详情接口字段查看完整错误。
## 实施步骤
1. 在项目列表、项目详情、历史导入状态映射中增加 `4-打标失败`,使用失败红色样式。
2.`SearchBar` 和项目首页状态统计中增加 `4` 筛选与 `status4` 计数。
3. 在项目详情页头部下方增加打标失败提示,仅当 `projectInfo.projectStatus === "4"` 且存在 `latestTagTaskErrorMessage` 时展示。
4. 详情失败提示提供完整错误弹窗,内容只使用详情接口返回的 `latestTagTaskErrorMessage``latestTagTaskEndTime`
5. 项目状态轮询在状态脱离 `3-打标中` 后停止,因此遇到 `4` 自动停止并展示失败信息。
6. `UploadData.vue``4``0-进行中` 处理:允许上传、拉取、征信导入,禁用查看报告入口。
7. `ParamConfig.vue` 维持只锁定 `3-打标中``2-已归档`,因此 `4` 状态允许保存参数并触发重新打标。
8. 补充静态单测覆盖状态映射、详情失败提示、列表不展示完整错误、筛选计数和失败态操作口径。
## 验证
- 前端命令执行前先通过 `nvm use` 切换到项目 Node 版本。
- 执行相关静态单测。
- 执行 `npm run build:prod`
- 在真实业务页面路由中验证列表和详情页显示效果,不打开 prototype 页面。

View File

@@ -0,0 +1,48 @@
# 2026-05-22 生产安全组网络访问清单
## 保存路径确认
- 目标目录:`docs/reports/implementation/`
- 文档用途:根据 `ruoyi-admin/src/main/resources/application-pro.yml` 生成生产运行所需网络 IP 与端口清单,供服务器安全组配置使用。
- 路径检查结果:符合仓库实施记录归档规范。
## 配置来源
- 后端监听端口:`server.port=62318`
- 生产文件公开访问基址:`credit-parse.api.file-public-base-url=http://64.116.19.153`
- 生产 MySQL`64.116.19.156:3306`
- 生产 Redis`64.116.19.155:6379`
- 流水分析平台:`http://64.202.32.176/c4c3`
- 征信解析平台:`http://64.202.32.40:8083`
说明:本文只记录网络地址和端口,不记录生产账号、密码、密钥等敏感配置。
## 生产运行安全组放行清单
### 入站规则
| 目标服务器 | 来源 | 协议/端口 | 用途 | 配置依据 |
| --- | --- | --- | --- | --- |
| `64.116.19.153/32` | 业务访问源 IP 段或前置代理/SLB | TCP `62318` | 访问后端服务 | `server.port=62318` |
| `64.116.19.153/32` | `64.202.32.40/32` | TCP `80` | 征信解析平台读取已上传 HTML 文件 | `file-public-base-url=http://64.116.19.153`HTTP 默认端口为 `80` |
### 出站规则
| 源服务器 | 目标 | 协议/端口 | 用途 | 配置依据 |
| --- | --- | --- | --- | --- |
| `64.116.19.153/32` | `64.116.19.156/32` | TCP `3306` | 后端连接生产 MySQL | `spring.datasource.druid.master.url` |
| `64.116.19.153/32` | `64.116.19.155/32` | TCP `6379` | 后端连接生产 Redis | `spring.data.redis.host` / `port` |
| `64.116.19.153/32` | `64.202.32.176/32` | TCP `80` | 后端调用流水分析平台接口 | `lsfx.api.base-url=http://64.202.32.176/c4c3` |
| `64.116.19.153/32` | `64.202.32.40/32` | TCP `8083` | 后端调用征信解析发起与结果查询接口 | `credit-parse.api.url` / `result-url` |
## 配置校验点
1. 生产服务若按 `pro` 配置运行,启动参数需要使用 `--spring.profiles.active=pro`
2. `file-public-base-url` 当前未带端口,因此征信解析平台回读文件时访问的是 `64.116.19.153:80`;如果服务器只开放 `62318`,外部平台无法按当前 `pro` 配置读取 `/profile/credit-html/**` 文件。
3. `/profile/**` 在后端资源映射和安全配置中允许匿名 GET 访问,因此安全组应确保征信解析平台到文件公开入口的网络链路可达。
## 本次验证
- 已检查 `ruoyi-admin/src/main/resources/application-pro.yml` 中生产端口、MySQL、Redis、流水分析平台、征信解析平台和文件公开访问基址。
- 已检查 `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/CreditHtmlStorageService.java`,确认远程文件地址由 `file-public-base-url` 拼接 `/profile/credit-html/**` 生成。
- 已检查 `ruoyi-framework/src/main/java/com/ruoyi/framework/config/ResourcesConfig.java``SecurityConfig.java`,确认 `/profile/**` 对应本地上传目录并允许匿名 GET 访问。

View File

@@ -0,0 +1,26 @@
# 账号库列表排除信贷客户实施记录
## 1. 本次实施内容
-`CcdiAccountInfoMapper.xml` 的公共查询条件 `AccountInfoWhereClause` 中增加 `AND ai.owner_type <> 'CREDIT_CUSTOMER'`
- 账号库列表分页与导出查询共用该条件,因此两处均不再返回信贷客户账号。
-`CcdiAccountInfoMapperTest` 中补充 SQL 渲染断言,覆盖信贷客户排除条件。
- 前端页面、筛选项、接口参数和账号库新增编辑导入校验未调整。
## 2. 影响范围
- 影响接口:`/ccdi/accountInfo/list`
- 影响查询:`selectAccountInfoPage``selectAccountInfoListForExport`
- 不影响账号详情、新增、编辑、删除、导入模板和导入处理。
## 3. 验证记录
- 已确认 Mapper XML 包含固定排除条件。
- 已确认 `ownerType=CREDIT_CUSTOMER` 会与固定排除条件组合为空结果。
- 已执行 `git diff --check -- ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiAccountInfoMapper.xml docs/plans/backend/2026-05-27-account-info-exclude-credit-customer-backend-implementation.md docs/reports/implementation/2026-05-27-account-info-exclude-credit-customer-implementation.md`,无空白问题。
- 已执行 `mvn -pl ccdi-info-collection -am -DskipTests compile`,结果 `BUILD SUCCESS`
- 已执行 `mvn -pl ccdi-info-collection -am test`,结果 `BUILD SUCCESS`,共运行 171 个测试,失败 0、错误 0。
## 4. 前提说明
信贷客户账号需要以 `owner_type = 'CREDIT_CUSTOMER'` 写入 `ccdi_account_info`,否则本次列表排除条件无法识别。

View File

@@ -0,0 +1,39 @@
# 生产打包技能命名优化实施记录
## 基本信息
- 实施日期2026-05-27
- 实施对象:`/Users/wkc/.codex/skills/fullstack-prod-package`
- 实施内容:优化生产打包技能,使最终发布压缩包文件名包含项目英文代码
## 修改内容
1. 更新打包脚本 `scripts/package_fullstack_prod.py`
- 新增 `--project-code` 参数,用作最终 zip 文件名前缀
- 未传入 `--project-code` 时,默认使用后端项目目录名作为项目英文代码
- 对项目英文代码进行规范化处理,仅保留英文、数字、点、下划线和连字符
- 最终压缩包命名从 `YYYYMMDD-HHMMSS.zip` 调整为 `projectcode-YYYYMMDD-HHMMSS.zip`
- 打包完成输出增加 `PROJECT_CODE`
2. 更新技能说明 `SKILL.md`
- 调整技能描述,保持触发条件清晰
- 标准命令增加 `--project-code projectcode`
- 说明默认推断规则和验证要求
## 影响范围
- 后续使用 `fullstack-prod-package` 生成生产包时,最终 zip 文件名会包含项目英文代码。
- 生产包内部内容不变,仍仅包含:
- `dist.zip`
- 后端运行 Jar
## 验证结果
- `python3 -m py_compile` 通过
- `--help` 输出已包含 `--project-code`
- 使用现有 `ruoyi-ui/dist``ruoyi-admin.jar` 在临时目录执行轻量打包验证成功
- 验证生成文件名:`ccdi-20260527-152829.zip`
- 验证 zip 内容仍仅包含:
- `dist.zip`
- `ruoyi-admin.jar`
- 临时验证目录已删除

View File

@@ -0,0 +1,50 @@
# 全栈生产包生成实施记录
## 基本信息
- 实施日期2026-05-27
- 实施内容:生成前端 `dist.zip` 与后端可运行 Jar 的生产发布压缩包
- 最终产物:`/Users/wkc/Downloads/20260527-150234.zip`
## 执行内容
1. 核对项目构建配置:
- 前端目录:`ruoyi-ui`
- 前端 Node 版本:通过 `.nvmrc` 使用 `v14.21.3`
- 前端生产构建命令:`npm run build:prod`
- 后端构建命令:`mvn clean package -DskipTests`
- 后端运行 Jar`ruoyi-admin/target/ruoyi-admin.jar`
2. 执行后端生产构建:
- 在仓库根目录执行 `mvn clean package -DskipTests`
- Maven Reactor 全模块构建成功
- 生成后端运行包 `ruoyi-admin.jar`
3. 执行前端生产构建与发布包生成:
- 通过 `nvm use` 切换至 Node `v14.21.3`
- 执行 `npm run build:prod`
- 生成 `ruoyi-ui/dist`
-`dist` 压缩为 `dist.zip`
-`dist.zip``ruoyi-admin.jar` 合并为最终发布包
## 影响范围
- 更新构建产物目录:
- `ruoyi-ui/dist`
- 各后端模块 `target`
- 新增本地发布产物:
- `/Users/wkc/Downloads/20260527-150234.zip`
## 验证结果
- Node 版本已确认:`v14.21.3`
- Java 版本已确认:`21.0.9`
- Maven 版本已确认:`3.9.14`
- 后端构建结果:成功
- 前端构建结果:成功,存在前端资源体积 warning不影响构建完成
- `ruoyi-ui/dist` 文件数377
- 后端 Jar`ruoyi-admin.jar`,约 100 MB
- 最终压缩包:`20260527-150234.zip`,约 94 MB
- 最终压缩包内容已通过 `unzip -l` 验证,仅包含:
- `dist.zip`
- `ruoyi-admin.jar`

View File

@@ -0,0 +1,26 @@
# 项目打标失败状态实施记录
## 修改内容
- 后端新增项目状态 `4-打标失败`,扩展状态统计 `status4` 和状态文案。
- 打标失败后项目状态由原先回退 `0-进行中` 改为写入 `4-打标失败`,任务表仍记录 `FAILED/error_message`
- 项目详情接口在失败态下返回最近失败任务的 `latestTagTaskErrorMessage``latestTagTaskEndTime`;项目列表不返回完整错误。
- SQL 初始化和迁移脚本新增 `ccdi_project_status` 字典值 `4-打标失败`,并提供生产数据回填 SQL。
- 前端列表、筛选、计数和详情页支持 `4-打标失败`;详情页提供失败提示和完整错误弹窗。
- `UploadData.vue``ParamConfig.vue``4` 按进行中口径处理,允许重新上传、拉取、修改参数和重新分析,不开放报告查看/归档入口。
## 影响范围
- 后端:项目状态模型、打标任务失败流转、项目详情接口、状态统计接口、打标任务 Mapper、SQL 初始化和迁移。
- 前端:项目首页列表与筛选、项目详情头部提示、上传数据页报告入口权限、状态映射。
- 数据:迁移脚本会将未归档、当前状态为 `0/3`、最新打标任务为 `FAILED` 的项目更新为 `4`
## 验证结果
- 通过:`mvn -pl ccdi-project -am -Dtest=CcdiBankTagServiceImplTest,CcdiBankTagServiceRiskCountRefreshTest,CcdiProjectServiceImplTest,CcdiProjectStatusSqlTest,CcdiBankTagTaskMapperXmlTest -Dsurefire.failIfNoSpecifiedTests=false test`
- 全量后端:`mvn -pl ccdi-project -am test` 未通过,剩余失败为既有问题:
- 多个测试使用 static mock但当前 `SubclassByteBuddyMockMaker` 不支持 static mocks。
- `CcdiProjectOverviewControllerContractTest.shouldExposeOverviewReportExportEndpointContract` 期望摘要为“一键导出结果总览报告”,实际为“导出结果总览报告”。
- 通过:`cd ruoyi-ui && nvm use && node tests/unit/project-tag-failed-status.test.js && node tests/unit/project-detail-tagging-polling.test.js && node tests/unit/project-archive-readonly-guard.test.js && node tests/unit/upload-data-disabled-cards.test.js`
- 通过:`cd ruoyi-ui && nvm use && npm run build:prod`,仅有既有资源体积 warning。
- 真实页面验证:`browser-use` 工具不可用;已使用 Playwright 打开真实业务页面路由,并通过本地 mock 后端构造失败项目数据,验证列表只显示“打标失败”且不泄露完整错误,详情页可查看完整错误,失败状态下上传/拉取入口可见。

View File

@@ -0,0 +1,32 @@
# 拉取本行信息近一年日期范围实施记录
## 修改内容
- 将“拉取本行信息”弹窗的时间跨度最早可选日期由固定 `2025-01-01` 调整为动态近一年窗口。
- 日期可选范围按当前日期滚动计算:
- 最晚可选日期:昨天。
- 最早可选日期:最晚可选日期往前一年。
- 提交前校验同步使用同一套最早、最晚日期边界。
- 校验提示调整为“时间跨度仅支持近一年内日期,且最晚只能选择到昨天”。
- 补充前端单测断言,防止日期范围再次退回固定日期。
## 影响范围
- 前端页面:
- `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- 前端测试:
- `ruoyi-ui/tests/unit/upload-data-pull-bank-info-date-limit.test.js`
- 不涉及后端接口、不改数据库。
## 验证情况
- 已执行 `source ~/.nvm/nvm.sh && nvm use && node tests/unit/upload-data-pull-bank-info-date-limit.test.js`,通过。
- 已执行 `source ~/.nvm/nvm.sh && nvm use && npm run build:prod`,通过;仅存在项目既有资源体积告警。
- 已在真实项目详情页验证“拉取本行信息”弹窗:
- 验证页面:`http://localhost:8090/ccdiProject/detail/90336`
- 当前系统日期:`2026-05-27`
- 默认开始日期:`2025-05-26`
- 默认结束日期:`2026-05-26`
- 日期面板中 `2025-05-25` 禁用,`2025-05-26` 可选。
- 日期面板中 `2026-05-26` 可选,`2026-05-27` 及之后日期禁用。
- 浏览器验证截图已保存到 `output/browser-use/2026-05-27-pull-bank-info-date-range.png`,该文件为本地验证产物,不提交 Git。

View File

@@ -0,0 +1,32 @@
# 银行流水打标 UNION 排序规则冲突修复记录
## 问题现象
- 生产执行银行流水打标规则 `ABNORMAL_CUSTOMER_TRANSACTION` 时失败。
- 异常为 `Illegal mix of collations for operation 'UNION'`
- 报错位置为 `CcdiBankTagAnalysisMapper.xml``selectAbnormalCustomerTransactionStatements` 查询。
## 根因
- 该规则会将 `ccdi_base_staff``ccdi_staff_fmy_relation` 组装为同一个人员主体派生表,并继续与银行流水、账户库、中介库、企业库做多分支 `UNION ALL`
- 生产表字段排序规则确认无异常后,问题收敛到应用数据库连接会话。
- 生产 JDBC URL 只设置了 `characterEncoding=UTF-8`,未固定 `connectionCollation`。现场查询显示生产 MySQL 8.0.36 的 `@@character_set_connection``utf8mb3``@@collation_connection``@@collation_server` 均为 `utf8mb3_general_ci`SQL 字符串字面量与 `CAST(... AS CHAR)` 也解析为 `utf8mb3_general_ci`,与项目字段统一使用的 `utf8mb4_general_ci` 不一致,容易在 `UNION` 合并字符串列时触发排序规则冲突。
## 修改内容
- 生产数据源 JDBC URL 增加 `connectionCollation=utf8mb4_general_ci`,固定应用连接会话排序规则。
## 影响范围
- 数据库结构:无变更。
- 后端配置:仅影响生产 profile 的 MySQL 连接会话排序规则。
- 后端代码:未修改 Java 和 MyBatis SQL 业务逻辑。
- 前端:无影响。
## 验证情况
- 已确认异常 SQL 对应规则为 `ABNORMAL_CUSTOMER_TRANSACTION`
- 已确认生产 JDBC URL 未固定 `connectionCollation`
- 现场查询 `@@character_set_connection` 返回 `utf8mb3``@@collation_connection``@@collation_server` 返回 `utf8mb3_general_ci`
- 现场查询 `COLLATION('本人')``COLLATION(CAST(1 AS CHAR))` 返回 `utf8mb3_general_ci`
- 未连接生产数据库执行验证;生产发布并重启后需确认应用连接中的 `@@collation_connection``utf8mb4_general_ci`,再重新触发失败项目的银行流水打标任务验证。

View File

@@ -0,0 +1,40 @@
# 2026-05-28 全栈生产包实施记录
## 保存路径确认
- 文档目录:`docs/reports/implementation/`
- 本文档:`docs/reports/implementation/2026-05-28-fullstack-prod-package-095235.md`
## 实施内容
-`fullstack-prod-package` 技能执行全栈生产打包。
- 前端目录:`ruoyi-ui`
- 后端目录:`ruoyi-admin`,构建命令在仓库根目录执行。
- 项目英文编码:`ccdi`
- 最终生产包:`/Users/wkc/Downloads/ccdi-20260528-095235.zip`
## 构建命令
```bash
python3 /Users/wkc/.codex/skills/fullstack-prod-package/scripts/package_fullstack_prod.py \
--frontend-dir /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui \
--backend-dir /Users/wkc/Desktop/ccdi/ccdi/ruoyi-admin \
--backend-build-command 'cd /Users/wkc/Desktop/ccdi/ccdi && mvn clean package -DskipTests' \
--project-code ccdi
```
## 验证结果
- 前端 `npm run build:prod` 执行成功,使用 `.nvmrc` 指定的 Node `14.21.3`
- 前端 `ruoyi-ui/dist` 已生成,目录大小约 `8.7M`
- 后端 `mvn clean package -DskipTests` 执行成功,生成 `ruoyi-admin/target/ruoyi-admin.jar`,大小约 `100M`
- 最终压缩包已生成,大小约 `94M`
- `unzip -l /Users/wkc/Downloads/ccdi-20260528-095235.zip` 验证通过,根目录仅包含:
- `dist.zip`
- `ruoyi-admin.jar`
## 注意事项
- 前端构建存在 Vue CLI 默认资源体积告警,未导致构建失败。
- Maven 构建跳过测试,符合生产打包命令参数 `-DskipTests`
- 本次打包基于执行时当前工作区内容,执行前工作区已存在未提交变更。

View File

@@ -34,7 +34,7 @@ spring:
druid:
# 主库数据源
master:
url: jdbc:mysql://116.62.17.81:3307/ccdi?useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
url: jdbc:mysql://116.62.17.81:3307/ccdi?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: Kfcx@1234
# 从库数据源

View File

@@ -35,7 +35,7 @@ spring:
druid:
# 主库数据源
master:
url: jdbc:mysql://64.116.19.156:3306/ccdi?useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
url: jdbc:mysql://64.116.19.156:3306/ccdi?useUnicode=true&characterEncoding=UTF-8&connectionCollation=utf8mb4_general_ci&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: lx_ai
password: lx-ai@9520
# 从库数据源

View File

@@ -176,7 +176,8 @@ export default {
'0': 'primary',
'1': 'success',
'2': 'info',
'3': 'warning'
'3': 'warning',
'4': 'danger'
}
return statusMap[status] || 'info'
},
@@ -185,7 +186,8 @@ export default {
'0': '进行中',
'1': '已完成',
'2': '已归档',
'3': '打标中'
'3': '打标中',
'4': '打标失败'
}
return statusMap[status] || '未知'
},

View File

@@ -98,7 +98,7 @@
>
<template slot-scope="scope">
<el-button
v-if="scope.row.status === '0' || scope.row.status === '3'"
v-if="['0', '3', '4'].includes(scope.row.status)"
size="mini"
type="text"
icon="el-icon-right"
@@ -199,6 +199,7 @@ export default {
1: "#52c41a",
2: "#8c8c8c",
3: "#fa8c16",
4: "#f56c6c",
};
return colorMap[status] || "#8c8c8c";
},

View File

@@ -40,7 +40,8 @@ export default {
'0': 0,
'1': 0,
'2': 0,
'3': 0
'3': 0,
'4': 0
})
}
},
@@ -53,7 +54,8 @@ export default {
{ label: '进行中', value: '0', count: 0 },
{ label: '已完成', value: '1', count: 0 },
{ label: '已归档', value: '2', count: 0 },
{ label: '打标中', value: '3', count: 0 }
{ label: '打标中', value: '3', count: 0 },
{ label: '打标失败', value: '4', count: 0 }
]
}
},

View File

@@ -359,7 +359,7 @@ export default {
return String(this.projectInfo.projectStatus) === "2";
},
isReportDisabled() {
return ["0", "3"].includes(String(this.projectInfo.projectStatus));
return ["0", "3", "4"].includes(String(this.projectInfo.projectStatus));
},
},
created() {
@@ -505,7 +505,9 @@ export default {
return today;
},
getPullBankInfoMinSelectableDate() {
return new Date(2025, 0, 1);
const minSelectableDate = this.getPullBankInfoMaxSelectableDate();
minSelectableDate.setFullYear(minSelectableDate.getFullYear() - 1);
return minSelectableDate;
},
getPullBankInfoMaxSelectableDate() {
const yesterday = this.getPullBankInfoTodayStart();
@@ -546,10 +548,10 @@ export default {
},
isPullBankInfoDateDisabled(time) {
const minSelectableDate = this.getPullBankInfoMinSelectableDate();
const todayStart = this.getPullBankInfoTodayStart();
const maxSelectableDate = this.getPullBankInfoMaxSelectableDate();
return (
time.getTime() < minSelectableDate.getTime() ||
time.getTime() >= todayStart.getTime()
time.getTime() > maxSelectableDate.getTime()
);
},
hasInvalidPullBankInfoDateRange(dateRange) {
@@ -590,7 +592,7 @@ export default {
}
if (this.hasInvalidPullBankInfoDateRange([startDate, endDate])) {
this.$message.warning("时间跨度仅支持 2025-01-01 至昨天");
this.$message.warning("时间跨度仅支持近一年内日期,且最晚只能选择到昨天");
return;
}
@@ -662,6 +664,7 @@ export default {
1: "success",
2: "archived",
3: "tagging",
4: "failed",
};
return statusMap[status] || "processing";
},
@@ -673,6 +676,7 @@ export default {
1: "已完成",
2: "已归档",
3: "打标中",
4: "打标失败",
};
return statusMap[status] || "未知";
},
@@ -1050,6 +1054,11 @@ export default {
color: #909399;
font-weight: 500;
}
.status-failed {
color: #f56c6c;
font-weight: 500;
}
}
.update-time {

View File

@@ -57,6 +57,24 @@
</div>
</div>
<div
v-if="isProjectTagFailed && projectInfo.latestTagTaskErrorMessage"
class="tag-failure-alert"
>
<div class="tag-failure-alert__content">
<i class="el-icon-warning-outline"></i>
<div>
<div class="tag-failure-alert__title">项目打标失败</div>
<div class="tag-failure-alert__message">
{{ tagFailureSummary }}
</div>
</div>
</div>
<el-button type="text" size="mini" @click="openTagFailureDialog">
查看完整错误
</el-button>
</div>
<!-- 动态组件渲染区域 -->
<component
:is="currentComponent"
@@ -81,6 +99,20 @@
:visible.sync="evidenceDrawerVisible"
:project-id="projectId"
/>
<el-dialog
title="打标失败详情"
:visible.sync="tagFailureDialogVisible"
width="720px"
append-to-body
>
<div
v-if="projectInfo.latestTagTaskEndTime"
class="tag-failure-dialog__time"
>
失败时间{{ formatUpdateTime(projectInfo.latestTagTaskEndTime) }}
</div>
<pre class="tag-failure-dialog__message">{{ projectInfo.latestTagTaskErrorMessage }}</pre>
</el-dialog>
</div>
</template>
@@ -126,10 +158,13 @@ export default {
warningCount: 0,
warningThreshold: 60,
projectStatus: "0",
latestTagTaskErrorMessage: "",
latestTagTaskEndTime: "",
},
evidenceConfirmVisible: false,
evidenceDrawerVisible: false,
evidencePayload: {},
tagFailureDialogVisible: false,
projectStatusPollingTimer: null,
projectStatusPollingInterval: 1000,
projectStatusPollingLoading: false,
@@ -139,6 +174,13 @@ export default {
isProjectArchived() {
return String(this.projectInfo.projectStatus) === "2";
},
isProjectTagFailed() {
return String(this.projectInfo.projectStatus) === "4";
},
tagFailureSummary() {
const message = this.projectInfo.latestTagTaskErrorMessage || "";
return message.length > 120 ? `${message.slice(0, 120)}...` : message;
},
},
watch: {
"$route.params.projectId"(newId) {
@@ -346,6 +388,7 @@ export default {
1: "success", // 已完成
2: "info", // 已归档
3: "warning", // 打标中
4: "danger", // 打标失败
};
return statusMap[status] || "info";
},
@@ -356,9 +399,13 @@ export default {
1: "已完成",
2: "已归档",
3: "打标中",
4: "打标失败",
};
return statusMap[status] || "未知";
},
openTagFailureDialog() {
this.tagFailureDialogVisible = true;
},
/** 获取配置类型标签文字 */
getConfigTypeLabel(configType) {
const configTypeMap = {
@@ -606,6 +653,67 @@ export default {
}
}
.tag-failure-alert {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
padding: 12px 16px;
color: #9f3a38;
background: #fff2f0;
border: 1px solid #ffd6d1;
border-radius: 8px;
}
.tag-failure-alert__content {
display: flex;
align-items: flex-start;
min-width: 0;
gap: 10px;
i {
margin-top: 2px;
font-size: 18px;
color: #f56c6c;
}
}
.tag-failure-alert__title {
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
.tag-failure-alert__message {
margin-top: 2px;
font-size: 13px;
line-height: 20px;
word-break: break-word;
}
.tag-failure-dialog__time {
margin-bottom: 12px;
color: #606266;
font-size: 13px;
}
.tag-failure-dialog__message {
max-height: 420px;
margin: 0;
padding: 12px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
color: #303133;
background: #f8f9fb;
border: 1px solid #ebeef5;
border-radius: 6px;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 12px;
line-height: 1.6;
}
.info-card {
margin-bottom: 16px;

View File

@@ -103,7 +103,8 @@ export default {
'0': 0,
'1': 0,
'2': 0,
'3': 0
'3': 0,
'4': 0
},
// 新增/编辑弹窗
addDialogVisible: false,
@@ -142,7 +143,8 @@ export default {
'0': counts.status0 || 0,
'1': counts.status1 || 0,
'2': counts.status2 || 0,
'3': counts.status3 || 0
'3': counts.status3 || 0,
'4': counts.status4 || 0
}
this.loading = false

View File

@@ -23,4 +23,14 @@ assert(
"组件脚本中应提供“最晚可选日期”为昨天的统一 helper"
);
assert(
/getPullBankInfoMinSelectableDate\(\)[\s\S]*?getPullBankInfoMaxSelectableDate\(\)[\s\S]*?setFullYear\([^)]*getFullYear\(\) - 1\)/.test(source),
"拉取本行信息最早可选日期应跟随最晚可选日期滚动到近一年内"
);
assert(
!/return new Date\(2025, 0, 1\)/.test(source),
"拉取本行信息日期范围不应再固定从 2025-01-01 开始"
);
console.log("upload-data-pull-bank-info-date-limit test passed");

File diff suppressed because one or more lines are too long

View File

@@ -11,7 +11,7 @@ CREATE TABLE `ccdi_project` (
`project_name` VARCHAR(200) NOT NULL COMMENT '项目名称',
`description` VARCHAR(500) DEFAULT NULL COMMENT '项目描述',
`config_type` VARCHAR(20) NOT NULL DEFAULT 'default' COMMENT '配置方式default-全局默认custom-自定义',
`status` CHAR(1) NOT NULL DEFAULT '0' COMMENT '项目状态0-进行中1-已完成2-已归档3-打标中',
`status` CHAR(1) NOT NULL DEFAULT '0' COMMENT '项目状态0-进行中1-已完成2-已归档3-打标中4-打标失败',
`is_archived` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否归档0-未归档1-已归档',
`target_count` INT NOT NULL DEFAULT 0 COMMENT '目标人数',
`high_risk_count` INT NOT NULL DEFAULT 0 COMMENT '高风险人数',
@@ -42,7 +42,8 @@ VALUES
(1, '进行中', '0', 'ccdi_project_status', '', 'primary', 'Y', '0', 'admin', NOW()),
(2, '已完成', '1', 'ccdi_project_status', '', 'success', 'N', '0', 'admin', NOW()),
(3, '已归档', '2', 'ccdi_project_status', '', 'info', 'N', '0', 'admin', NOW()),
(4, '打标中', '3', 'ccdi_project_status', '', 'warning', 'N', '0', 'admin', NOW());
(4, '打标中', '3', 'ccdi_project_status', '', 'warning', 'N', '0', 'admin', NOW()),
(5, '打标失败', '4', 'ccdi_project_status', '', 'danger', 'N', '0', 'admin', NOW());
-- ----------------------------
-- 4. 插入配置方式字典

View File

@@ -0,0 +1,53 @@
ALTER TABLE ccdi_project
MODIFY COLUMN status CHAR(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '0'
COMMENT '项目状态0-进行中1-已完成2-已归档3-打标中4-打标失败';
INSERT INTO sys_dict_data (
dict_sort,
dict_label,
dict_value,
dict_type,
css_class,
list_class,
is_default,
status,
create_by,
create_time
)
SELECT
5,
'打标失败',
'4',
'ccdi_project_status',
'',
'danger',
'N',
'0',
'admin',
NOW()
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_project_status'
AND dict_value = '4'
);
UPDATE ccdi_project project
JOIN (
SELECT latest.project_id, latest.status
FROM ccdi_bank_tag_task latest
JOIN (
SELECT project_id, MAX(id) AS latest_id
FROM ccdi_bank_tag_task
GROUP BY project_id
) latest_id
ON latest.id = latest_id.latest_id
) latest_task
ON latest_task.project_id = project.project_id
SET project.status = '4',
project.update_by = 'system',
project.update_time = NOW()
WHERE latest_task.status = 'FAILED'
AND project.status IN ('0', '3')
AND (project.is_archived IS NULL OR project.is_archived = 0)
AND project.del_flag = '0';