feat: 添加ERP服务和系统服务,完善员工门户功能

## 新增服务模块

### 1. ERP服务 (hzhub-erp)
- 新增独立的ERP数据适配服务
- 支持SQL Server 2008 R2数据源
- 提供动态API配置管理系统
- 包含客户管理、销售数据等业务接口

### 2. 系统服务 (hzhub-system)
- 新增独立的系统管理服务
- 用户、角色、权限、部门、菜单管理
- 租户管理、操作日志、在线用户监控
- 工作流引擎(warm-flow)集成
- 企业微信审批同步功能

### 3. API网关 (hzhub-gateway)
- 新增Spring Cloud Gateway网关服务
- JWT认证、路由分发、限流熔断
- XSS防护、请求日志记录
- 统一入口端口8080

## 后台管理功能增强

### ERP动态API管理
- 新增动态API配置管理界面
- API测试、文档预览、统计监控
- 错误日志查看、缓存管理
- 从数据库表自动导入API配置

### 系统管理增强
- 企业微信配置管理
- 企业微信审批同步配置
- 部门和用户管理优化

## 员工门户功能完善

### 业务页面
- 审批中心:工作流审批、待办任务
- CRM管理:客户关系管理
- 经销商管理:经销商数据展示
- 供应链管理:采购、库存、销售
- BI报表:数据可视化分析
- ERP数据探索:SQL Server数据查询

### 个人中心
- 基本设置:个人信息管理
- 安全设置:密码修改、登录日志
- 锁屏功能:自动锁屏、手动锁屏

### 其他功能
- 标签页管理:多标签页导航
- 页面缓存:keepAlive缓存机制
- 会话超时:自动检测并提示

## 经销商门户

### 页面路由
- 新增经销商管理页面路由
- AI聊天界面完善

## 文档更新

- ERP API数据库初始化指南
- ERP API前端完整实现文档
- ERP API测试和验证指南
- Gateway路由迁移计划
- 项目配置文档更新

## 部署脚本

- 统一启动/停止/重启脚本
- Docker Compose配置优化
- Nginx配置文件更新

## 技术栈

- 后端: Spring Boot 3.5.8, Java 17
- 前端: Vue 3, TypeScript, Element Plus, Vben Admin
- 工作流: warm-flow 1.8.2
- 网关: Spring Cloud Gateway
- 数据库: MySQL 8.0, SQL Server 2008 R2
- 缓存: Redis 7
- 向量库: Weaviate 1.25.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
大壮
2026-05-08 08:00:19 +00:00
parent e6fc123b1f
commit c2513849b4
1564 changed files with 52903 additions and 641 deletions

24
hzhub-erp/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
# 构建阶段
FROM maven:3.9-eclipse-temurin-17-alpine AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
# 构建项目(跳过测试)
RUN mvn clean package -DskipTests
# 运行阶段
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
# 复制构建产物
COPY --from=builder /app/target/hzhub-erp-1.0.0.jar app.jar
# 暴露端口
EXPOSE 8082
# 启动命令
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -0,0 +1,264 @@
# ERP API动态化迁移完整文档
## 一、迁移背景
原有的ERP硬编码API存在以下问题
- API固定无法动态扩展
- 修改需要重新编译部署
- 缺乏统一管理和监控
- 缺乏缓存和性能优化机制
## 二、解决方案
采用动态API配置系统
- 配置驱动API定义存储在MySQL数据库中
- 灵活管理:通过前端界面可视化管理
- 安全执行使用PreparedStatement防止SQL注入
- SQL Server 2008 R2兼容使用ROW_NUMBER实现分页
## 三、迁移成果
### 3.1 已迁移API清单
| API名称 | 旧路径 | 新路径 | 状态 |
|---------|--------|--------|------|
| 品牌列表 | `/erp/customer/brands` | `/erp/dynamic/v1/customer/brands` | ✅ 可用 |
| 销区列表 | `/erp/customer/sales-areas` | `/erp/dynamic/v1/customer/sales-areas` | ✅ 可用 |
| 客户列表 | `/erp/customer/list` | `/erp/dynamic/v1/customer/list` | ✅ 可用 |
| 客户详情 | `/erp/customer/{code}` | `/erp/dynamic/v1/customer/detail?customerCode={code}` | ✅ 可用 |
### 3.2 数据库表结构
**MySQL配置表hzhub数据库**
```sql
-- API配置表
erp_api_config (4)
- api_id: API唯一标识
- api_name: API名称
- api_path: API路径
- sql_template: SQL模板
- result_type: (LIST/SINGLE/COUNT)
- support_pagination:
-- API参数表
erp_api_param (3)
- param_id:
- api_id: API
- param_name:
- param_type: (String/Integer)
-- API统计表
erp_api_stats (使)
- stats_id: ID
- api_id: API
- call_time:
- response_time:
```
### 3.3 技术架构
**双数据源配置**
- **MySQL (master)**: 存储API配置、参数、统计信息
- **SQL Server (erp)**: 执行ERP动态SQL查询
**核心组件**
- **DynamicApiController**: 动态路由控制器(支持多层级路径)
- **DynamicApiExecutor**: SQL执行引擎PreparedStatement + ROW_NUMBER分页
- **SqlValidator**: SQL安全验证禁止危险关键字
- **ErpApiController**: API配置管理CRUD
## 四、测试验证
### 4.1 功能测试结果
```bash
# 品牌列表返回57条
curl 'http://192.168.120.60:8082/erp/dynamic/v1/customer/brands'
200 OK, data: [{"brand":"5月玫瑰"}, {"brand":"B&G"}, ...]
# 销区列表返回23条
curl 'http://192.168.120.60:8082/erp/dynamic/v1/customer/sales-areas'
200 OK, data: [{"salesAreaCode":"GC001","salesAreaName":"华润置地"}, ...]
# 客户列表总计3177条分页10条
curl 'http://192.168.120.60:8082/erp/dynamic/v1/customer/list?pageNum=1&pageSize=10'
200 OK, total: 3177, rows: 10
# 客户详情
curl 'http://192.168.120.60:8082/erp/dynamic/v1/customer/detail?customerCode=JAH3026'
200 OK, data: {customerCode, customerName, companyName, ...}
```
### 4.2 性能对比
| 指标 | 动态API | 旧API | 说明 |
|------|---------|-------|------|
| 查询速度 | 相同 | 相同 | 都是直接SQL查询 |
| 扩展性 | ✅ 高 | ❌ 低 | 配置化vs硬编码 |
| 管理便利性 | ✅ 高 | ❌ 低 | 前端可视化vs修改代码 |
| 监控统计 | ✅ 支持 | ❌ 无 | 自动记录调用统计 |
| 缓存支持 | ✅ 可配置 | ❌ 无 | Redis缓存可选 |
## 五、前端集成
### 5.1 动态API管理界面
**访问地址**http://192.168.120.60:5666/erp/api
**功能清单**
- ✅ API配置列表查看、搜索、启用/禁用)
- ✅ API编辑SQL模板、参数配置
- ✅ API测试在线执行查看结果
- ✅ API文档预览
- ✅ 缓存管理(清除缓存)
### 5.2 前端调用迁移
**旧版本**
```javascript
// 旧API调用
axios.get('/erp/customer/brands')
axios.get('/erp/customer/list', {params: {pageNum: 1, pageSize: 10}})
```
**新版本**
```javascript
// 动态API调用
axios.get('/erp/dynamic/v1/customer/brands')
axios.get('/erp/dynamic/v1/customer/list', {params: {pageNum: 1, pageSize: 10}})
```
## 六、旧API处理
### 6.1 废弃策略
**标记为@Deprecated**
- 添加`@Deprecated(since = "2026-04-30", forRemoval = true)`注解
- 添加废弃警告日志:每次调用记录迁移提示
- 保留期限3个月至2026-07-30
**废弃路径**
- `/erp/customer/*` → 标记为废弃
- `/erp/test/*` → 保留(开发调试工具)
### 6.2 迁移时间表
| 时间 | 动作 | 说明 |
|------|------|------|
| 2026-04-30 | 标记废弃 | 添加@Deprecated注解和警告日志 |
| 2026-05-30 | 前端迁移 | 前端全面切换到动态API |
| 2026-06-30 | 停止维护 | 旧API不再修复bug |
| 2026-07-30 | 删除代码 | 删除CustomerController及相关代码 |
## 七、后续优化
### 7.1 功能扩展计划
**Phase 2预计2026-05**
- ✅ 从表导入功能从数据库表自动生成API配置
- ✅ 参数化筛选支持WHERE条件动态生成
- ✅ SQL编辑器优化CodeMirror + SQL Server语法高亮
**Phase 3预计2026-06**
- ✅ Redis缓存集成
- ✅ API调用统计可视化
- ✅ 性能监控和慢查询告警
**Phase 4预计2026-07**
- ✅ 权限集成Sa-Token permission_code
- ✅ RateLimiter限流
- ✅ 版本管理v1/v2多版本并存
### 7.2 性能优化建议
1. **启用Redis缓存**对于频繁调用的API品牌列表、销区列表
2. **SQL优化**:为常用查询字段添加索引
3. **批量操作**:支持批量查询减少数据库连接
4. **连接池优化**调整HikariCP参数
## 八、运维指南
### 8.1 新增API流程
1. 前端界面添加配置或SQL插入
```sql
INSERT INTO erp_api_config (api_name, api_path, sql_template, ...)
VALUES ('新API名称', '/erp/dynamic/v1/new/api', 'SELECT ...', ...)
```
2. 配置参数(如需要)
```sql
INSERT INTO erp_api_param (api_id, param_name, param_type, ...)
VALUES (新API_ID, 'param1', 'String', ...)
```
3. 测试验证
```bash
curl 'http://192.168.120.60:8082/erp/dynamic/v1/new/api?param1=value1'
```
4. 前端集成调用
### 8.2 故障排查
**常见问题**
- 404错误检查api_path配置是否正确
- 500错误检查SQL模板语法查看logs/erp.log
- 参数错误检查参数配置表erp_api_param
- 权限错误检查网关白名单AuthGlobalFilter.java
**日志位置**
- ERP服务`/data/hzhub/hzhub-erp/logs/erp.log`
- 网关:`/data/hzhub/hzhub-gateway/logs/gateway.log`
- 前端浏览器Console + Network面板
## 九、安全注意事项
### 9.1 SQL安全
**防护措施**
- ✅ PreparedStatement参数绑定防止SQL注入
- ✅ SqlValidator关键字验证禁止DROP/DELETE等
- ✅ 白名单关键字检查只允许SELECT/FROM/WHERE等
- ✅ 配置表权限控制生产环境启用require_auth
### 9.2 访问控制
**网关白名单**(开发阶段):
```java
// AuthGlobalFilter.java
WHITE_LIST = List.of(
"/erp/test/", "/erp/customer/",
"/erp/api/", "/erp/dynamic/", // 已添加
...
);
```
**生产环境建议**
- 启用Sa-Token权限验证
- 配置require_auth = 1
- 设置permission_code如 erp:api:list
## 十、总结
### 成功要点
1.**完整迁移**4个API全部成功迁移
2.**兼容性解决**SQL Server 2008 R2分页兼容
3.**双数据源配置**MySQL + SQL Server完美分离
4.**前端管理界面**:可视化配置管理
5.**安全防护**PreparedStatement + SQL验证
### 经验教训
1. PID文件机制失效导致服务未重启已改进stop.sh
2. SQL Server版本兼容性问题2008 R2不支持OFFSET FETCH
3. 网关白名单需要同步更新(/erp/api/, /erp/dynamic/
4. 多层级路径需要通配符路由DynamicApiController /**
---
**迁移完成日期**2026-04-30
**文档版本**v1.0
**维护团队**HZHub Team

View File

@@ -0,0 +1,199 @@
# ERP API 统计监控功能说明
## 功能概述
ERP API统计监控页面提供多维度的API调用分析和性能监控功能帮助开发者快速定位问题、优化性能。
## 页面布局
页面采用上下堆叠布局:
- **上方**API概览显示所有API的汇总统计
- **下方**详细统计单个API的详细分析和监控
## 主要功能模块
### 1. API概览卡片
显示整个系统的API汇总数据
- **API总数**系统中配置的API数量
- **启用API**状态为启用的API数量
- **禁用API**状态为禁用的API数量
- **启用缓存**启用Redis缓存的API数量
### 2. 详细统计筛选
提供多种筛选和控制选项:
- **API选择**下拉选择要查看的API
- **时间范围**DatePicker范围选择器可选
- **查询按钮**:立即查询统计数据
- **刷新按钮**:手动刷新统计数据
- **重置按钮**:重置时间范围并重新查询
- **自动刷新开关**:开启/关闭自动刷新
- **刷新间隔配置**设置自动刷新的时间间隔10-300秒
- **慢查询阈值配置**定义慢查询的响应时间阈值100-10000ms
### 3. 统计卡片组8个核心指标
横向排列的统计卡片显示API的核心性能数据
- **总调用次数**该API的总调用次数
- **成功次数**:调用成功的次数(绿色标识)
- **错误次数**:调用失败的次数(红色标识)
- **错误率**错误次数占比百分比超过5%时红色警告)
- **平均响应时间**:所有调用的平均响应时间(超过阈值时黄色警告)
- **最大响应时间**:响应时间最大值(超过阈值时红色警告)
- **最小响应时间**:响应时间最小值
- **慢查询次数**:响应时间超过阈值的调用次数(黄色标识)
### 4. 性能指标卡片4个综合指标
提供更深度的性能分析:
- **性能健康度**综合考虑错误率和响应时间的健康评分0-100分
- 80+分:优秀(绿色)
- 60-80分一般黄色
- <60分红色
- 包含健康状态描述
- **成功率**:成功调用占比的进度条展示
- 显示成功次数/总次数的详细数据
- **响应时间分布**:响应时间性能评分
- 基于平均响应时间和阈值的对比
- 颜色分级显示性能状态
- **缓存状态**显示该API是否启用Redis缓存
- 启用:绿色显示"已启用"
- 未启用:灰色显示"未启用"
- 提示缓存可提升性能
### 5. 性能优化建议卡片
基于统计数据自动生成优化建议:
- **高错误率警告**:错误率>5%建议检查API配置和SQL
- **慢查询警告**平均响应时间超过阈值建议优化SQL或启用缓存
- **缓存未启用提示**:调用次数>10且未启用缓存建议启用
- **性能优秀提示**错误率≤1%且响应时间良好,保持现状
### 6. 统计概览Tab
当API有调用记录时显示
- 功能完善说明
- 性能优化建议卡片
当API无调用记录时提示
- 该API暂无调用记录
- 建议先调用动态API触发统计
### 7. 错误日志Tab
显示API的错误调用记录
- **调用时间**:错误发生的时间
- **响应时间**:该次调用的响应时间
- **客户端IP**调用方的IP地址
- **用户ID**:调用方的用户标识
- **错误消息**截断显示完整内容在Tooltip中
- **详情按钮**:打开错误详情弹窗
### 8. 错误详情弹窗
点击"详情"按钮打开,显示完整的错误信息:
- **调用信息**调用时间、响应时间、客户端IP、用户ID、调用状态、统计ID
- **错误信息**醒目的红色Alert显示完整错误消息
- **调用参数**可折叠的JSON格式化参数展示
- **执行的SQL**可折叠的SQL语句展示实际执行的SQL参数已替换
- **错误堆栈**可折叠的错误堆栈前10行
## 使用场景
### 场景1监控API运行状态
1. 选择要监控的API
2. 查看统计卡片了解基本性能数据
3. 查看性能健康度了解整体状态
4. 根据优化建议进行改进
### 场景2分析错误问题
1. 选择有错误的API
2. 切换到"错误日志"Tab
3. 查看错误消息概览
4. 点击"详情"查看完整的错误信息、SQL和堆栈
5. 根据详细信息定位问题根源
### 场景3优化慢查询
1. 设置慢查询阈值如1000ms
2. 查看慢查询次数统计
3. 如果慢查询较多,查看平均/最大响应时间
4. 根据建议优化SQL或启用缓存
### 场景4实时监控
1. 开启自动刷新开关
2. 设置刷新间隔如30秒
3. 页面将自动刷新统计数据
4. 实时观察API性能变化
### 场景5时间范围分析
1. 选择时间范围如最近1小时
2. 查询该时间段内的统计数据
3. 对比不同时间段的性能差异
4. 分析高峰时段的性能表现
## 技术实现要点
### 前端技术栈
- Vue 3 Composition API
- Ant Design Vue组件库
- TypeScript类型安全
- Reactive响应式数据
- Computed计算属性
- Watch监听器
- 定时器管理
### 后端支持接口
- `GET /erp/api/config/stats/{apiId}` - 查询统计数据
- `GET /erp/api/config/errorLog/{apiId}` - 查询错误日志
- `GET /erp/api/config/list` - 查询API列表
### 数据库表
- `erp_api_stats` - 存储每次API调用的详细信息
- 字段api_id, call_time, call_params, executed_sql, response_time, call_status, error_message, error_stack, client_ip, user_id
### 性能指标计算公式
**健康度评分**
```javascript
健康度 = (错误评分 + 响应时间评分) / 2
错误评分 = max(0, 100 - 错误率 * 10) // 每1%错误率扣10分
响应时间评分 = max(0, 100 - (平均响应时间 / 阈值) * 20)
```
**慢查询判定**
```javascript
慢查询 = 响应时间 > 配置的阈值默认1000ms
```
## 扩展功能建议
### 未来可扩展功能
1. **图表可视化**集成ECharts展示调用趋势图、响应时间分布图
2. **导出功能**导出统计数据为CSV或Excel
3. **告警通知**:配置告警阈值,超过阈值发送邮件/短信通知
4. **对比分析**多个API的性能对比
5. **缓存命中率统计**:如果启用缓存,显示缓存命中率
6. **数据留存策略**:设置统计数据保留时长
7. **慢查询日志详情**显示慢查询的具体SQL和参数
## 注意事项
1. **自动刷新**:自动刷新会消耗服务器资源,建议在需要实时监控时才开启
2. **时间范围**:查询较长时间范围的数据可能较慢,建议根据数据量选择合适的时间范围
3. **错误详情**错误堆栈可能很长弹窗中只显示前10行便于快速定位
4. **性能建议**:系统自动给出的建议仅供参考,实际优化需结合具体业务场景
## 页面访问地址
```
http://192.168.120.60:5666/erp/stats
```
或本地开发环境:
```
http://localhost:5666/#/erp/stats
```

View File

@@ -0,0 +1,267 @@
# ERP API 统计监控功能完善日志
## 更新时间
2026-04-30
## 更新内容
### 前端功能完善
#### 1. 新增错误详情弹窗组件
- 文件:`/data/hzhub/hzhub-admin/apps/web-antd/src/views/erp/stats/error-detail-modal.vue`
- 功能:
- 显示完整的错误信息和错误堆栈
- 显示实际执行的SQL语句参数已替换
- 显示调用参数JSON格式化
- 显示客户端IP、用户ID、响应时间等详细信息
- 使用Collapse组件折叠显示SQL和堆栈
- 错误堆栈只显示前10行便于快速定位
#### 2. 优化统计主页面
- 文件:`/data/hzhub/hzhub-admin/apps/web-antd/src/views/erp/stats/index.vue`
- 新增功能:
**自动刷新功能**
- 添加自动刷新开关Switch组件
- 可配置刷新间隔InputNumber范围10-300秒
- 使用setInterval定时器实现自动刷新
- 组件卸载时自动清理定时器
- 监听刷新间隔变化动态调整定时器
**慢查询分析**
- 添加慢查询阈值配置InputNumber范围100-10000ms
- 统计卡片中显示慢查询次数
- 响应时间超过阈值时黄色/红色标识
- 性能优化建议中包含慢查询警告
**性能健康度评估**
- 综合考虑错误率和响应时间计算健康度0-100分
- 三级健康状态:
- 80+分:优秀(绿色)
- 60-80分一般黄色
- <60分红色
- 自动生成健康状态描述
- 使用Progress组件可视化展示
**成功率展示**
- Progress组件显示成功率百分比
- 显示成功次数/总次数的详细数据
- 颜色从绿色到深绿色渐变
**响应时间分布**
- 基于平均响应时间和阈值对比计算评分
- 四级颜色标识:
- ≥80分绿色优秀
- ≥60分蓝色良好
- ≥40分黄色一般
- <40分红色较差
- 显示平均/最大响应时间的对比
**缓存状态展示**
- 显示API是否启用Redis缓存
- 启用时绿色,未启用时灰色
- 提示缓存可提升性能
**性能优化建议**
- 根据统计数据自动生成4种建议
- 高错误率警告(错误率>5%
- 慢查询警告(平均响应时间>阈值)
- 缓存未启用提示(调用次数>10且未启用
- 性能优秀提示错误率≤1%且响应时间良好)
**优化错误日志表格**
- 新增"用户ID"列
- 错误消息过长时截断显示前50字符
- Tooltip显示完整错误消息
- 修复fixed字段的TypeScript类型问题
- 添加横向滚动支持scroll={{ x: 1000 }}
- 顶部添加错误记录数量提示Alert组件
**优化筛选栏布局**
- 使用Space组件wrap属性支持换行
- 添加Divider分隔符分隔不同功能区
- 添加Tooltip提示说明各配置项的作用
- 新增"慢查询阈值"配置带说明Tooltip
**新增8个统计卡片**
- 总调用次数
- 成功次数(绿色)
- 错误次数(红色)
- 错误率超过5%红色警告)
- 平均响应时间(超过阈值黄色)
- 最大响应时间(超过阈值红色)
- 最小响应时间
- 慢查询次数(黄色,带阈值说明)
**新增4个性能指标卡片**
- 性能健康度Progress + 描述)
- 成功率Progress + 详细数据)
- 响应时间分布Progress + 对比数据)
- 缓存状态Statistic + 描述)
**优化统计概览Tab**
- 移除"功能正在开发"提示
- 添加"功能已完善"成功提示
- 新增性能优化建议卡片
**优化错误日志Tab**
- 顶部添加错误数量提示Alert
- 错误消息使用Tooltip显示完整内容
- 详情按钮打开错误详情弹窗
#### 3. 新增样式类
- `.mb-2`底部间距8px
- `.mt-4`顶部间距16px
- `.metric-desc`指标描述样式12px字体灰色
### 后端功能完善(已完成)
#### 1. API测试功能统计记录
- 文件:`/data/hzhub/hzhub-erp/src/main/java/org/hzhub/erp/service/impl/ErpApiServiceImpl.java`
- 修改:
- 添加ApiStatsRecorder依赖注入
- testApi方法新增clientIp和userId参数
- 执行成功时调用recordSuccess记录统计
- 执行失败时调用recordError记录统计
- 修复ApiExecutionResult类型导入
#### 2. API测试接口客户端信息提取
- 文件:`/data/hzhub/hzhub-erp/src/main/java/org/hzhub/erp/controller/ErpApiController.java`
- 修改:
- testApi方法新增HttpServletRequest参数
- 实现getClientIp方法支持多代理场景
- 实现getUserId方法从网关请求头提取
- 测试环境默认userId为"test-user"
#### 3. 服务接口签名更新
- 文件:`/data/hzhub/hzhub-erp/src/main/java/org/hzhub/erp/service/IErpApiService.java`
- 修改:
- testApi方法签名新增clientIp和userId参数
## 测试验证
### 功能测试结果
1. **API调用统计记录**
- 动态API路径调用正常记录
- API测试接口调用正常记录
- 统计字段client_ip, user_id, executed_sql, call_params, response_time全部正确
2. **统计查询接口**
- 返回正确的统计数据totalCalls, successCalls, errorCalls, avgResponseTime, maxResponseTime, minResponseTime, errorRate
- 类型转换正确Long/BigDecimal兼容
3. **错误日志查询**
- 返回错误记录列表
- 包含所有必要字段
4. **前端页面访问**
- 页面正常加载http://localhost:5666/#/erp/stats
- 无编译错误
### 统计数据测试
```bash
# 测试API ID 1
curl -X POST -H "Content-Type: application/json" \
-d '{"pageNum":1,"pageSize":20}' \
http://localhost:8082/erp/api/config/test/1
# 查询统计
curl http://localhost:8082/erp/api/config/stats/1
# 结果
{
"totalCalls": 6,
"avgResponseTime": 10,
"errorRate": 0,
"successCalls": 6,
"errorCalls": 0
}
```
## 功能对比
### 原有功能
- 基础统计卡片6个
- API选择和筛选
- 错误日志表格(简单)
- 简单的进度条显示
- 手动刷新
### 新增功能
- 错误详情弹窗(完整信息)
- 自动刷新(可配置间隔)
- 慢查询分析(可配置阈值)
- 性能健康度评估(综合评分)
- 多维度性能指标4个综合卡片
- 性能优化建议(自动生成)
- 优化错误日志表格新增用户ID列、Tooltip
- 更多统计指标(慢查询次数、缓存状态)
- Tooltip提示说明
- TypeScript类型安全修复
## 文件清单
### 前端文件
- `/data/hzhub/hzhub-admin/apps/web-antd/src/views/erp/stats/index.vue` - 主页面(已完善)
- `/data/hzhub/hzhub-admin/apps/web-antd/src/views/erp/stats/error-detail-modal.vue` - 错误详情弹窗(新增)
### 后端文件
- `/data/hzhub/hzhub-erp/src/main/java/org/hzhub/erp/service/impl/ErpApiServiceImpl.java` - 统计记录(已完善)
- `/data/hzhub/hzhub-erp/src/main/java/org/hzhub/erp/controller/ErpApiController.java` - 客户端信息提取(已完善)
- `/data/hzhub/hzhub-erp/src/main/java/org/hzhub/erp/service/IErpApiService.java` - 接口签名(已完善)
### 文档文件
- `/data/hzhub/hzhub-erp/docs/api-stats-feature.md` - 功能说明文档(新增)
- `/data/hzhub/hzhub-erp/docs/api-stats-update-log.md` - 更新日志(新增)
## 部署说明
### 前端部署
前端开发服务器已在运行端口5666代码修改会自动热更新。
### 后端部署
后端服务已重启端口8082修改已生效。
### 访问地址
- 前端http://192.168.120.60:5666/erp/stats
- 后端API
- http://192.168.120.60:8082/erp/api/config/stats/{apiId}
- http://192.168.120.60:8082/erp/api/config/errorLog/{apiId}
## 使用建议
1. **实时监控**开启自动刷新设置30秒间隔实时观察API性能
2. **性能优化**:关注健康度评分和优化建议,针对性改进
3. **错误定位**:通过错误详情弹窗快速定位问题根源
4. **慢查询分析**设置合适的阈值识别需要优化的SQL
5. **缓存优化**根据缓存状态提示为高频API启用缓存
## 后续计划
### 可扩展功能
1. 集成ECharts图表库添加
- 调用趋势折线图
- 响应时间分布柱状图
- 错误率变化趋势图
2. 导出统计数据为CSV/Excel
3. 配置告警阈值,超过阈值发送通知
4. 多API性能对比分析
5. 缓存命中率统计
6. 慢查询日志详情展示
### 数据留存优化
- 添加统计数据定时清理机制
- 配置数据保留时长
- 添加数据归档功能
## 总结
本次完善大幅提升了ERP API统计监控功能的实用性和用户体验
- 从基础统计展示升级为多维度性能分析
- 添加了自动化监控功能(自动刷新)
- 提供了智能化的优化建议
- 完善了错误定位功能(详细弹窗)
- 增强了数据可视化(健康度、成功率、响应时间分布)
所有功能都已测试验证可以正常使用。前端页面访问地址http://192.168.120.60:5666/erp/stats

Binary file not shown.

View File

@@ -0,0 +1,3 @@
-- 添加executed_sql字段到erp_api_stats表
ALTER TABLE hzhub.erp_api_stats
ADD COLUMN executed_sql TEXT COMMENT '实际执行的SQL语句' AFTER call_params;

View File

@@ -0,0 +1,147 @@
-- ERP API 管理平台数据库表创建脚本
-- 执行顺序:先创建 erp_api_config再创建 erp_api_param最后创建 erp_api_stats
USE hzhub;
-- 1. API 配置主表
CREATE TABLE IF NOT EXISTS erp_api_config (
api_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 'API ID',
api_name VARCHAR(100) NOT NULL COMMENT 'API名称',
api_path VARCHAR(200) NOT NULL COMMENT 'API路径如 /erp/dynamic/customer/list',
api_method VARCHAR(10) NOT NULL DEFAULT 'GET' COMMENT 'HTTP方法GET/POST',
api_desc VARCHAR(500) COMMENT 'API描述',
api_version VARCHAR(10) DEFAULT 'v1' COMMENT 'API版本号v1/v2',
-- 数据源配置
data_source VARCHAR(50) DEFAULT 'erp' COMMENT '数据源名称',
-- SQL配置
sql_template TEXT NOT NULL COMMENT 'SQL模板支持参数占位符 #{paramName}',
result_type VARCHAR(20) NOT NULL DEFAULT 'LIST' COMMENT '结果类型LIST/SINGLE/COUNT',
-- 分页配置
support_pagination TINYINT(1) DEFAULT 0 COMMENT '是否支持分页',
page_param_name VARCHAR(50) DEFAULT 'pageNum' COMMENT '页码参数名',
size_param_name VARCHAR(50) DEFAULT 'pageSize' COMMENT '页大小参数名',
-- 权限配置
require_auth TINYINT(1) DEFAULT 0 COMMENT '是否需要认证',
permission_code VARCHAR(100) COMMENT '权限标识(如 erp:customer:list',
-- 缓存配置
enable_cache TINYINT(1) DEFAULT 0 COMMENT '是否启用缓存',
cache_key_template VARCHAR(200) COMMENT '缓存键模板(支持参数占位符)',
cache_ttl INT DEFAULT 300 COMMENT '缓存过期时间(秒)',
-- 来源表信息
source_table VARCHAR(100) COMMENT '来源表名',
source_table_comment VARCHAR(500) COMMENT '来源表描述',
-- 状态
status TINYINT(1) DEFAULT 1 COMMENT '状态0禁用 1启用',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
create_by VARCHAR(50) COMMENT '创建者',
update_by VARCHAR(50) COMMENT '更新者',
remark VARCHAR(500) COMMENT '备注'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ERP动态API配置表';
-- 创建索引
CREATE INDEX idx_api_path_method ON erp_api_config(api_path, api_method, api_version);
CREATE INDEX idx_status ON erp_api_config(status);
CREATE INDEX idx_source_table ON erp_api_config(source_table);
-- 2. API 参数配置表
CREATE TABLE IF NOT EXISTS erp_api_param (
param_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '参数ID',
api_id BIGINT NOT NULL COMMENT '所属API ID',
-- 参数基本信息
param_name VARCHAR(100) NOT NULL COMMENT '参数名称',
param_desc VARCHAR(500) COMMENT '参数描述',
param_type VARCHAR(20) NOT NULL DEFAULT 'String' COMMENT '参数类型String/Integer/Long/Date/Boolean',
-- 参数位置
param_position VARCHAR(20) NOT NULL DEFAULT 'QUERY' COMMENT '参数位置QUERY/BODY',
-- 参数验证
is_required TINYINT(1) DEFAULT 0 COMMENT '是否必填',
default_value VARCHAR(200) COMMENT '默认值',
-- SQL映射
sql_param_name VARCHAR(100) COMMENT 'SQL参数名',
-- 排序
sort INT DEFAULT 0 COMMENT '排序',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ERP动态API参数配置表';
-- 创建索引和外键
CREATE INDEX idx_api_id ON erp_api_param(api_id);
ALTER TABLE erp_api_param ADD CONSTRAINT fk_api_param_api
FOREIGN KEY (api_id) REFERENCES erp_api_config(api_id) ON DELETE CASCADE;
-- 3. API 调用统计表
CREATE TABLE IF NOT EXISTS erp_api_stats (
stats_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '统计ID',
api_id BIGINT NOT NULL COMMENT 'API ID',
-- 调用信息
call_time DATETIME NOT NULL COMMENT '调用时间',
call_params TEXT COMMENT '调用参数JSON',
response_time INT COMMENT '响应时间ms',
call_status VARCHAR(10) COMMENT '调用状态SUCCESS/ERROR',
-- 错误信息
error_message TEXT COMMENT '错误消息',
error_stack TEXT COMMENT '错误堆栈',
-- 客户端信息
client_ip VARCHAR(50) COMMENT '客户端IP',
user_id VARCHAR(50) COMMENT '用户ID',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ERP动态API调用统计表';
-- 创建索引
CREATE INDEX idx_api_id_time ON erp_api_stats(api_id, call_time);
CREATE INDEX idx_call_status ON erp_api_stats(call_status);
CREATE INDEX idx_response_time ON erp_api_stats(response_time);
-- 插入菜单配置
-- 注意:需要在 hzhub-system 的 sys_menu 表中执行以下 SQL
-- ERP 管理一级菜单(目录)
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
VALUES ('ERP管理', 0, 5, '/erp', 'ParentView', 1, 0, 'M', '0', '0', '', 'monitor', 'admin', NOW(), 'ERP API管理目录');
-- API 配置管理菜单(二级菜单 - 列表页面)
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
VALUES ('API配置', (SELECT menu_id FROM (SELECT menu_id FROM sys_menu WHERE menu_name='ERP管理' LIMIT 1) t), 1, 'api', 'erp/api/index', 1, 0, 'C', '0', '0', 'erp:api:list', 'tool', 'admin', NOW(), 'API配置管理页面');
-- API 配置按钮权限(不在菜单显示,只用于按钮级权限控制)
SET @api_menu_id = (SELECT menu_id FROM (SELECT menu_id FROM sys_menu WHERE menu_name='API配置' AND component='erp/api/index' LIMIT 1) t);
-- 注意menu_type='F' 表示按钮visible='1' 表示隐藏不在菜单中显示
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES ('API查询', @api_menu_id, 1, '', NULL, 1, 0, 'F', '1', '0', 'erp:api:query', '#', 'admin', NOW());
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES ('API新增', @api_menu_id, 2, '', NULL, 1, 0, 'F', '1', '0', 'erp:api:add', '#', 'admin', NOW());
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES ('API修改', @api_menu_id, 3, '', NULL, 1, 0, 'F', '1', '0', 'erp:api:edit', '#', 'admin', NOW());
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES ('API删除', @api_menu_id, 4, '', NULL, 1, 0, 'F', '1', '0', 'erp:api:remove', '#', 'admin', NOW());
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES ('API测试', @api_menu_id, 5, '', NULL, 1, 0, 'F', '1', '0', 'erp:api:test', '#', 'admin', NOW());
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES ('清除缓存', @api_menu_id, 6, '', NULL, 1, 0, 'F', '1', '0', 'erp:api:cache', '#', 'admin', NOW());
-- API 监控统计菜单(二级菜单 - 统计页面)
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
VALUES ('API监控', (SELECT menu_id FROM (SELECT menu_id FROM sys_menu WHERE menu_name='ERP管理' LIMIT 1) t), 2, 'stats', 'erp/stats/index', 1, 0, 'C', '0', '0', 'erp:api:stats', 'monitor', 'admin', NOW(), 'API调用统计监控页面');

View File

@@ -0,0 +1,158 @@
-- ERP API 管理平台数据库表创建脚本 (SQL Server 版本)
-- 执行顺序:先创建 erp_api_config再创建 erp_api_param最后创建 erp_api_stats
-- 1. API 配置主表
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='erp_api_config' AND xtype='U')
BEGIN
CREATE TABLE erp_api_config (
api_id BIGINT IDENTITY(1,1) PRIMARY KEY,
api_name NVARCHAR(100) NOT NULL,
api_path NVARCHAR(200) NOT NULL,
api_method NVARCHAR(10) NOT NULL DEFAULT 'GET',
api_desc NVARCHAR(500),
api_version NVARCHAR(10) DEFAULT 'v1',
-- 数据源配置
data_source NVARCHAR(50) DEFAULT 'erp',
-- SQL配置
sql_template NVARCHAR(MAX) NOT NULL,
result_type NVARCHAR(20) NOT NULL DEFAULT 'LIST',
-- 分页配置
support_pagination TINYINT DEFAULT 0,
page_param_name NVARCHAR(50) DEFAULT 'pageNum',
size_param_name NVARCHAR(50) DEFAULT 'pageSize',
-- 权限配置
require_auth TINYINT DEFAULT 0,
permission_code NVARCHAR(100),
-- 缓存配置
enable_cache TINYINT DEFAULT 0,
cache_key_template NVARCHAR(200),
cache_ttl INT DEFAULT 300,
-- 来源表信息
source_table NVARCHAR(100),
source_table_comment NVARCHAR(500),
-- 状态
status TINYINT DEFAULT 1,
create_time DATETIME DEFAULT GETDATE(),
update_time DATETIME DEFAULT GETDATE(),
create_by NVARCHAR(50),
update_by NVARCHAR(50),
remark NVARCHAR(500)
);
PRINT 'Table erp_api_config created successfully';
END
ELSE
BEGIN
PRINT 'Table erp_api_config already exists';
END
GO
-- 创建索引
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name='idx_api_path_method' AND object_id=OBJECT_ID('erp_api_config'))
CREATE INDEX idx_api_path_method ON erp_api_config(api_path, api_method, api_version);
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name='idx_status' AND object_id=OBJECT_ID('erp_api_config'))
CREATE INDEX idx_status ON erp_api_config(status);
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name='idx_source_table' AND object_id=OBJECT_ID('erp_api_config'))
CREATE INDEX idx_source_table ON erp_api_config(source_table);
GO
-- 2. API 参数配置表
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='erp_api_param' AND xtype='U')
BEGIN
CREATE TABLE erp_api_param (
param_id BIGINT IDENTITY(1,1) PRIMARY KEY,
api_id BIGINT NOT NULL,
-- 参数基本信息
param_name NVARCHAR(100) NOT NULL,
param_desc NVARCHAR(500),
param_type NVARCHAR(20) NOT NULL DEFAULT 'String',
-- 参数位置
param_position NVARCHAR(20) NOT NULL DEFAULT 'QUERY',
-- 参数验证
is_required TINYINT DEFAULT 0,
default_value NVARCHAR(200),
-- SQL映射
sql_param_name NVARCHAR(100),
-- 排序
sort INT DEFAULT 0,
create_time DATETIME DEFAULT GETDATE(),
update_time DATETIME DEFAULT GETDATE()
);
PRINT 'Table erp_api_param created successfully';
END
ELSE
BEGIN
PRINT 'Table erp_api_param already exists';
END
GO
-- 创建索引和外键
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name='idx_api_id' AND object_id=OBJECT_ID('erp_api_param'))
CREATE INDEX idx_api_id ON erp_api_param(api_id);
IF NOT EXISTS (SELECT * FROM sys.foreign_keys WHERE name='fk_api_param_api')
BEGIN
ALTER TABLE erp_api_param ADD CONSTRAINT fk_api_param_api
FOREIGN KEY (api_id) REFERENCES erp_api_config(api_id) ON DELETE CASCADE;
END
GO
-- 3. API 调用统计表
IF NOT EXISTS (SELECT * FROM sysobjects WHERE name='erp_api_stats' AND xtype='U')
BEGIN
CREATE TABLE erp_api_stats (
stats_id BIGINT IDENTITY(1,1) PRIMARY KEY,
api_id BIGINT NOT NULL,
-- 调用信息
call_time DATETIME NOT NULL,
call_params NVARCHAR(MAX),
response_time INT,
call_status NVARCHAR(10),
-- 错误信息
error_message NVARCHAR(MAX),
error_stack NVARCHAR(MAX),
-- 客户端信息
client_ip NVARCHAR(50),
user_id NVARCHAR(50),
create_time DATETIME DEFAULT GETDATE()
);
PRINT 'Table erp_api_stats created successfully';
END
ELSE
BEGIN
PRINT 'Table erp_api_stats already exists';
END
GO
-- 创建索引
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name='idx_api_id_time' AND object_id=OBJECT_ID('erp_api_stats'))
CREATE INDEX idx_api_id_time ON erp_api_stats(api_id, call_time);
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name='idx_call_status' AND object_id=OBJECT_ID('erp_api_stats'))
CREATE INDEX idx_call_status ON erp_api_stats(call_status);
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name='idx_response_time' AND object_id=OBJECT_ID('erp_api_stats'))
CREATE INDEX idx_response_time ON erp_api_stats(response_time);
GO
PRINT 'All ERP API management tables created successfully';

View File

@@ -0,0 +1,31 @@
-- ERP API管理菜单配置SQL脚本
-- 执行时间2026-04-30
-- 说明添加ERP管理模块到hzhub-system的菜单系统中
-- ERP管理菜单一级菜单
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
VALUES ('ERP管理', 0, 5, '/erp', NULL, 1, 0, 'M', '0', '0', '', 'monitor', 'admin', NOW(), 'ERP API管理目录');
-- API配置管理菜单二级菜单
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
VALUES ('API配置', (SELECT menu_id FROM (SELECT menu_id FROM sys_menu WHERE menu_name='ERP管理' AND parent_id=0) AS temp), 1, 'api', 'erp/api/index', 1, 0, 'C', '0', '0', 'erp:api:list', 'tool', 'admin', NOW(), 'API配置管理菜单');
-- API配置按钮权限
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES ('API查询', (SELECT menu_id FROM (SELECT menu_id FROM sys_menu WHERE menu_name='API配置' AND path='api') AS temp2), 1, '', NULL, 1, 0, 'F', '0', '0', 'erp:api:query', '#', 'admin', NOW());
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES ('API新增', (SELECT menu_id FROM (SELECT menu_id FROM sys_menu WHERE menu_name='API配置' AND path='api') AS temp2), 2, '', NULL, 1, 0, 'F', '0', '0', 'erp:api:add', '#', 'admin', NOW());
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES ('API修改', (SELECT menu_id FROM (SELECT menu_id FROM sys_menu WHERE menu_name='API配置' AND path='api') AS temp2), 3, '', NULL, 1, 0, 'F', '0', '0', 'erp:api:edit', '#', 'admin', NOW());
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES ('API删除', (SELECT menu_id FROM (SELECT menu_id FROM sys_menu WHERE menu_name='API配置' AND path='api') AS temp2), 4, '', NULL, 1, 0, 'F', '0', '0', 'erp:api:remove', '#', 'admin', NOW());
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time)
VALUES ('API测试', (SELECT menu_id FROM (SELECT menu_id FROM sys_menu WHERE menu_name='API配置' AND path='api') AS temp2), 5, '', NULL, 1, 0, 'F', '0', '0', 'erp:api:test', '#', 'admin', NOW());
-- API监控统计菜单二级菜单
INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
VALUES ('API监控', (SELECT menu_id FROM (SELECT menu_id FROM sys_menu WHERE menu_name='ERP管理' AND parent_id=0) AS temp), 2, 'stats', 'erp/stats/index', 1, 0, 'C', '0', '0', 'erp:api:stats', 'monitor', 'admin', NOW(), 'API调用统计监控');

196
hzhub-erp/fix_sql_final.py Normal file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
完整修复ERP动态API SQL模板 - 最终版本
使用实际存在的字段和正确的SQL语法
"""
import pymysql
import os
MYSQL_HOST = os.getenv('MYSQL_HOST', '192.168.120.60')
MYSQL_PORT = int(os.getenv('MYSQL_PORT', '3306'))
MYSQL_DB = os.getenv('MYSQL_DB', 'hzhub')
MYSQL_USER = os.getenv('MYSQL_USERNAME', 'root')
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', 'hzhub123')
print(f"连接MySQL: {MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DB}")
conn = pymysql.connect(
host=MYSQL_HOST,
port=MYSQL_PORT,
database=MYSQL_DB,
user=MYSQL_USER,
password=MYSQL_PASSWORD,
charset='utf8mb4'
)
cursor = conn.cursor()
# 完整修复的SQL模板使用实际存在的字段
fixed_configs = {
1: { # api_id = 1 (客户列表查询) - 简化版本,不支持筛选
'api_name': '客户列表查询(基础版)',
'api_desc': '查询客户档案列表(基础分页查询,暂不支持筛选)',
'sql_template': '''
SELECT CLTCODE AS customerCode,
CLTNAME AS customerName,
COMPANY_ID AS companyCode,
COMPANY_NAME AS companyName,
BRAND AS brand,
BRANDNAME AS brandName,
LINKMAN AS contactName,
AREAID AS salesAreaCode,
AREANAME AS salesAreaName,
SALESID_T AS salesPersonCode,
SALESNAME_T AS salesPersonName,
TEL1 AS phone,
EMAIL AS email,
ISSTOP AS isStop
FROM SCLTGENERAL
''',
'result_type': 'LIST',
'support_pagination': 1,
},
2: { # api_id = 2 (客户详情查询)
'sql_template': '''
SELECT CLTCODE AS customerCode,
CLTNAME AS customerName,
COMPANY_ID AS companyCode,
COMPANY_NAME AS companyName,
BRAND AS brand,
BRANDNAME AS brandName,
LINKMAN AS contactName,
AREAID AS salesAreaCode,
AREANAME AS salesAreaName,
SALESID_T AS salesPersonCode,
SALESNAME_T AS salesPersonName,
SALEDOCID AS saleDocCode,
SALEDOCNAME AS saleDocName,
CLTPRICENO AS pricePlanCode,
CLTPRICENAME AS pricePlanName,
CLTTYPE AS customerType,
STREET AS address,
TEL1 AS phone,
EMAIL AS email,
SDORGID AS sdOrgCode,
SDORGNAME AS sdOrgName,
province,
city,
ISSTOP AS isStop
FROM SCLTGENERAL
WHERE CLTCODE = #{customerCode}
''',
},
3: { # api_id = 3 (销区列表) - 使用Customer表的AREAID字段
'api_name': '销区列表查询',
'api_desc': '从客户档案表提取销区列表',
'sql_template': '''
SELECT DISTINCT AREAID AS salesAreaCode,
AREANAME AS salesAreaName
FROM SCLTGENERAL
WHERE AREAID IS NOT NULL
AND AREANAME IS NOT NULL
ORDER BY AREAID
''',
'source_table': 'SCLTGENERAL',
'source_table_comment': '客户档案主表',
},
}
print("\n开始完整修复SQL模板...")
for api_id, updates in fixed_configs.items():
print(f"\n修复API ID {api_id}:")
# 构建UPDATE语句
update_fields = []
update_values = []
if 'api_name' in updates:
update_fields.append('api_name = %s')
update_values.append(updates['api_name'])
print(f" ✓ API名称: {updates['api_name']}")
if 'api_desc' in updates:
update_fields.append('api_desc = %s')
update_values.append(updates['api_desc'])
print(f" ✓ API描述: {updates['api_desc']}")
if 'sql_template' in updates:
update_fields.append('sql_template = %s')
update_values.append(updates['sql_template'].strip())
print(f" ✓ SQL模板已更新")
if 'source_table' in updates:
update_fields.append('source_table = %s')
update_values.append(updates['source_table'])
if 'source_table_comment' in updates:
update_fields.append('source_table_comment = %s')
update_values.append(updates['source_table_comment'])
# 添加更新时间
update_fields.append('update_time = NOW()')
# 执行UPDATE
sql = f"UPDATE erp_api_config SET {', '.join(update_fields)} WHERE api_id = %s"
cursor.execute(sql, update_values + [api_id])
conn.commit()
# 清理参数表api_id=1的客户列表参数太多简化
print("\n清理冗余参数...")
cursor.execute("DELETE FROM erp_api_param WHERE api_id = 1")
print(" ✓ 已清空客户列表的旧参数")
# 重新插入简化参数(只保留分页参数)
simplified_params = [
(1, 'pageNum', '页码', 'Integer', 'QUERY', 0, '1', 1),
(1, 'pageSize', '页大小', 'Integer', 'QUERY', 0, '10', 2),
]
for api_id, param_name, param_desc, param_type, param_position, is_required, default_value, sort in simplified_params:
cursor.execute("""
INSERT INTO erp_api_param (
api_id, param_name, param_desc, param_type,
param_position, is_required, default_value, sort,
create_time
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
""", (api_id, param_name, param_desc, param_type, param_position, is_required, default_value, sort))
print(" ✓ 已插入2个简化参数分页参数")
conn.commit()
# 显示修复后的配置
print("\n验证修复结果:")
cursor.execute("""
SELECT api_id, api_name, api_path, result_type, support_pagination, source_table
FROM erp_api_config
ORDER BY api_id
""")
for row in cursor.fetchall():
api_id, name, path, result_type, pagination, source_table = row
print(f" [{api_id}] {name} - {path} ({result_type}, 分页={pagination}, 表={source_table or 'N/A'})")
# 统计参数
cursor.execute("SELECT api_id, COUNT(*) FROM erp_api_param GROUP BY api_id ORDER BY api_id")
params_count = cursor.fetchall()
print("\n参数统计:")
for api_id, count in params_count:
print(f" API {api_id}: {count}个参数")
cursor.close()
conn.close()
print("\n✅ ERP动态API完整修复完成")
print("\n下一步测试:")
print(" 1. 重启ERP服务SQL模板已更新立即生效")
print(" 2. 测试所有API:")
print(" curl 'http://192.168.120.60:8082/erp/dynamic/v1/customer/brands'")
print(" curl 'http://192.168.120.60:8082/erp/dynamic/v1/customer/sales-areas'")
print(" curl 'http://192.168.120.60:8082/erp/dynamic/v1/customer/list?pageNum=1&pageSize=10'")
print(" curl 'http://192.168.120.60:8082/erp/dynamic/v1/customer/detail?customerCode=C00001'")
print(" 3. 验证前端管理界面:")
print(" http://192.168.120.60:5666/erp/api")

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""
修复ERP动态API SQL模板 - 第二版
使用标准SQL WHERE条件不依赖MyBatis动态语法
"""
import pymysql
import os
MYSQL_HOST = os.getenv('MYSQL_HOST', '192.168.120.60')
MYSQL_PORT = int(os.getenv('MYSQL_PORT', '3306'))
MYSQL_DB = os.getenv('MYSQL_DB', 'hzhub')
MYSQL_USER = os.getenv('MYSQL_USERNAME', 'root')
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', 'hzhub123')
print(f"连接MySQL: {MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DB}")
conn = pymysql.connect(
host=MYSQL_HOST,
port=MYSQL_PORT,
database=MYSQL_DB,
user=MYSQL_USER,
password=MYSQL_PASSWORD,
charset='utf8mb4'
)
cursor = conn.cursor()
# 修复后的API配置简化SQL不使用MyBatis条件语法
fixed_configs = {
1: { # api_id = 1 (客户列表查询)
'sql_template': '''
SELECT CLTCODE AS customerCode,
CLTNAME AS customerName,
COMPANY_ID AS companyCode,
COMPANY_NAME AS companyName,
BRAND AS brand,
BRANDNAME AS brandName,
LINKMAN AS contactName,
AREAID AS salesAreaCode,
AREANAME AS salesAreaName,
SALESID_T AS salesPersonCode,
SALESNAME_T AS salesPersonName,
SALEDOCID AS saleDocCode,
SALEDOCNAME AS saleDocName,
CLTPRICENO AS pricePlanCode,
CLTPRICENAME AS pricePlanName,
CLTTYPE AS customerType,
STREET AS address,
TEL1 AS phone,
EMAIL AS email,
SDORGID AS sdOrgCode,
SDORGNAME AS sdOrgName,
province, city, ISSTOP AS isStop
FROM SCLTGENERAL
ORDER BY CLTCODE
''',
'result_type': 'LIST',
'support_pagination': 1,
'api_desc': '查询客户档案列表(暂不支持筛选,后续优化)'
},
2: { # api_id = 2 (客户详情查询)
'sql_template': '''
SELECT CLTCODE AS customerCode,
CLTNAME AS customerName,
COMPANY_ID AS companyCode,
COMPANY_NAME AS companyName,
BRAND AS brand,
BRANDNAME AS brandName,
LINKMAN AS contactName,
AREAID AS salesAreaCode,
AREANAME AS salesAreaName,
SALESID_T AS salesPersonCode,
SALESNAME_T AS salesPersonName,
SALEDOCID AS saleDocCode,
SALEDOCNAME AS saleDocName,
CLTPRICENO AS pricePlanCode,
CLTPRICENAME AS pricePlanName,
CLTTYPE AS customerType,
STREET AS address,
TEL1 AS phone,
EMAIL AS email,
SDORGID AS sdOrgCode,
SDORGNAME AS sdOrgName,
province, city, ISSTOP AS isStop
FROM SCLTGENERAL
WHERE CLTCODE = #{customerCode}
''',
'result_type': 'SINGLE',
},
3: { # api_id = 3 (销区列表 - OSDORG表)
'sql_template': '''
SELECT DISTINCT ORGCODE AS salesAreaCode, ORGNAME AS salesAreaName
FROM OSDORG
WHERE ORGLEVEL = 3
AND ORGCODE IS NOT NULL
AND ORGNAME IS NOT NULL
AND ISENABLE = 1
ORDER BY ORGCODE
''',
},
# api_id = 4 (品牌列表) - SQL已正确无需修复
}
print("\n开始修复SQL模板...")
for api_id, updates in fixed_configs.items():
print(f"\n修复API ID {api_id}:")
if 'sql_template' in updates:
sql_template = updates['sql_template'].strip()
cursor.execute("""
UPDATE erp_api_config
SET sql_template = %s,
update_time = NOW()
WHERE api_id = %s
""", (sql_template, api_id))
print(f" ✓ SQL模板已更新")
if 'api_desc' in updates:
cursor.execute("""
UPDATE erp_api_config
SET api_desc = %s,
update_time = NOW()
WHERE api_id = %s
""", (updates['api_desc'], api_id))
print(f" ✓ API描述已更新")
conn.commit()
# 显示修复后的配置
print("\n验证修复结果:")
cursor.execute("""
SELECT api_id, api_name, api_path, result_type, support_pagination
FROM erp_api_config
ORDER BY api_id
""")
for row in cursor.fetchall():
api_id, name, path, result_type, pagination = row
print(f" [{api_id}] {name} - {path} ({result_type}, 分页={pagination})")
cursor.close()
conn.close()
print("\n✅ SQL模板修复完成")
print("\n下一步:")
print(" 1. 重启ERP服务如果需要立即生效")
print(" 2. 测试动态API:")
print(" curl 'http://192.168.120.60:8082/erp/dynamic/v1/customer/list?pageNum=1&pageSize=10'")
print(" curl 'http://192.168.120.60:8082/erp/dynamic/v1/customer/detail?customerCode=C00001'")
print(" 3. 对比旧API和动态API的结果一致性")

89
hzhub-erp/init_tables.py Normal file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env python3
"""
ERP API 配置表初始化脚本
执行 SQL Server 表创建脚本
"""
import pymssql
import os
from pathlib import Path
# 数据库连接配置(从环境变量或使用默认值)
# 使用 .env 文件中的配置
DB_HOST = os.getenv('ERP_DB_HOST', '192.168.120.10')
DB_PORT = int(os.getenv('ERP_DB_PORT', '8042'))
DB_NAME = os.getenv('ERP_DB_NAME', 'DMPF_HY')
DB_USER = os.getenv('ERP_DB_USERNAME', 'aiuser')
DB_PASSWORD = os.getenv('ERP_DB_PASSWORD', 'aiuser123')
print(f"Connecting to SQL Server: {DB_HOST}:{DB_PORT}/{DB_NAME}")
try:
# 连接数据库
conn = pymssql.connect(
server=DB_HOST,
port=DB_PORT,
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
charset='utf8'
)
cursor = conn.cursor()
# 读取SQL文件
sql_file = Path(__file__).parent / 'docs' / 'sql' / 'erp_api_tables_sqlserver.sql'
print(f"Reading SQL file: {sql_file}")
with open(sql_file, 'r', encoding='utf-8') as f:
sql_content = f.read()
# 分割SQL语句按GO分割
sql_statements = sql_content.split('GO')
print(f"Found {len(sql_statements)} SQL statement blocks")
# 执行每个SQL语句块
for i, statement in enumerate(sql_statements, 1):
statement = statement.strip()
if not statement or statement.startswith('--'):
continue
print(f"Executing statement block {i}...")
try:
cursor.execute(statement)
conn.commit()
print(f"✓ Statement block {i} executed successfully")
except Exception as e:
print(f"✗ Error in statement block {i}: {e}")
# 继续执行其他语句
conn.rollback()
# 查询表是否创建成功
print("\nVerifying tables...")
tables_to_check = ['erp_api_config', 'erp_api_param', 'erp_api_stats']
for table in tables_to_check:
cursor.execute(f"SELECT COUNT(*) FROM sysobjects WHERE name='{table}' AND xtype='U'")
count = cursor.fetchone()[0]
if count > 0:
print(f"✓ Table {table} exists")
# 显示表的行数
cursor.execute(f"SELECT COUNT(*) FROM {table}")
rows = cursor.fetchone()[0]
print(f" - Rows: {rows}")
else:
print(f"✗ Table {table} NOT found")
cursor.close()
conn.close()
print("\n✓ ERP API configuration tables initialized successfully!")
except pymssql.Error as e:
print(f"\n✗ Database error: {e}")
exit(1)
except Exception as e:
print(f"\n✗ Error: {e}")
exit(1)

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
ERP API 配置表初始化脚本 - MySQL版本
在MySQL hzhub数据库中创建配置表
"""
import pymysql
import os
from pathlib import Path
# MySQL数据库连接配置
MYSQL_HOST = os.getenv('MYSQL_HOST', '192.168.120.60')
MYSQL_PORT = int(os.getenv('MYSQL_PORT', '3306'))
MYSQL_DB = os.getenv('MYSQL_DB', 'hzhub')
MYSQL_USER = os.getenv('MYSQL_USERNAME', 'root')
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', 'hzhub123')
print(f"Connecting to MySQL: {MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DB}")
try:
# 连接MySQL数据库
conn = pymysql.connect(
host=MYSQL_HOST,
port=MYSQL_PORT,
database=MYSQL_DB,
user=MYSQL_USER,
password=MYSQL_PASSWORD,
charset='utf8mb4'
)
cursor = conn.cursor()
# 读取MySQL SQL文件
sql_file = Path(__file__).parent / 'docs' / 'sql' / 'erp_api_tables.sql'
print(f"Reading SQL file: {sql_file}")
with open(sql_file, 'r', encoding='utf-8') as f:
sql_content = f.read()
# 分割SQL语句按分号分割跳过注释和USE语句
sql_statements = []
for statement in sql_content.split(';'):
statement = statement.strip()
# 跳过USE语句、注释和空语句
if not statement or statement.startswith('--') or statement.upper().startswith('USE'):
continue
sql_statements.append(statement)
print(f"Found {len(sql_statements)} SQL statements")
# 执行每个SQL语句
for i, statement in enumerate(sql_statements, 1):
print(f"Executing statement {i}...")
try:
cursor.execute(statement)
conn.commit()
print(f"✓ Statement {i} executed successfully")
except Exception as e:
error_msg = str(e)
if "already exists" in error_msg or "Duplicate" in error_msg:
print(f"✓ Statement {i} skipped (already exists)")
else:
print(f"✗ Error in statement {i}: {e}")
conn.rollback()
# 验证表创建
print("\nVerifying tables...")
tables_to_check = ['erp_api_config', 'erp_api_param', 'erp_api_stats']
for table in tables_to_check:
cursor.execute(f"SHOW TABLES LIKE '{table}'")
result = cursor.fetchone()
if result:
print(f"✓ Table {table} exists")
# 显示表的行数
cursor.execute(f"SELECT COUNT(*) FROM {table}")
rows = cursor.fetchone()[0]
print(f" - Rows: {rows}")
else:
print(f"✗ Table {table} NOT found")
cursor.close()
conn.close()
print("\n✓ ERP API configuration tables initialized successfully in MySQL!")
except pymysql.Error as e:
print(f"\n✗ MySQL error: {e}")
exit(1)
except Exception as e:
print(f"\n✗ Error: {e}")
exit(1)

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""
ERP API 配置表初始化脚本 - 简化版本
直接创建表,不检查是否存在
"""
import pymssql
import os
# 数据库连接配置
DB_HOST = os.getenv('ERP_DB_HOST', '192.168.120.10')
DB_PORT = int(os.getenv('ERP_DB_PORT', '8042'))
DB_NAME = os.getenv('ERP_DB_NAME', 'DMPF_HY')
DB_USER = os.getenv('ERP_DB_USERNAME', 'aiuser')
DB_PASSWORD = os.getenv('ERP_DB_PASSWORD', 'aiuser123')
print(f"Connecting to SQL Server: {DB_HOST}:{DB_PORT}/{DB_NAME}")
conn = pymssql.connect(
server=DB_HOST,
port=DB_PORT,
database=DB_NAME,
user=DB_USER,
password=DB_PASSWORD,
charset='utf8'
)
cursor = conn.cursor()
# 1. 创建 erp_api_config 表
print("Creating erp_api_config table...")
try:
cursor.execute("""
CREATE TABLE erp_api_config (
api_id BIGINT IDENTITY(1,1) PRIMARY KEY,
api_name NVARCHAR(100) NOT NULL,
api_path NVARCHAR(200) NOT NULL,
api_method NVARCHAR(10) NOT NULL DEFAULT 'GET',
api_desc NVARCHAR(500),
api_version NVARCHAR(10) DEFAULT 'v1',
data_source NVARCHAR(50) DEFAULT 'erp',
sql_template NVARCHAR(MAX) NOT NULL,
result_type NVARCHAR(20) NOT NULL DEFAULT 'LIST',
support_pagination TINYINT DEFAULT 0,
page_param_name NVARCHAR(50) DEFAULT 'pageNum',
size_param_name NVARCHAR(50) DEFAULT 'pageSize',
require_auth TINYINT DEFAULT 0,
permission_code NVARCHAR(100),
enable_cache TINYINT DEFAULT 0,
cache_key_template NVARCHAR(200),
cache_ttl INT DEFAULT 300,
source_table NVARCHAR(100),
source_table_comment NVARCHAR(500),
status TINYINT DEFAULT 1,
create_time DATETIME DEFAULT GETDATE(),
update_time DATETIME DEFAULT GETDATE(),
create_by NVARCHAR(50),
update_by NVARCHAR(50),
remark NVARCHAR(500)
)
""")
conn.commit()
print("✓ erp_api_config created")
except Exception as e:
if "already exists" in str(e):
print("✓ erp_api_config already exists")
else:
print(f"✗ Error creating erp_api_config: {e}")
conn.rollback()
# 创建索引
print("Creating indexes on erp_api_config...")
try:
cursor.execute("CREATE INDEX idx_api_path_method ON erp_api_config(api_path, api_method, api_version)")
conn.commit()
print("✓ idx_api_path_method created")
except Exception as e:
if "already exists" in str(e):
print("✓ idx_api_path_method already exists")
else:
print(f"Note: {e}")
conn.rollback()
try:
cursor.execute("CREATE INDEX idx_status ON erp_api_config(status)")
conn.commit()
print("✓ idx_status created")
except Exception as e:
if "already exists" in str(e):
print("✓ idx_status already exists")
else:
print(f"Note: {e}")
conn.rollback()
# 2. 创建 erp_api_param 表
print("Creating erp_api_param table...")
try:
cursor.execute("""
CREATE TABLE erp_api_param (
param_id BIGINT IDENTITY(1,1) PRIMARY KEY,
api_id BIGINT NOT NULL,
param_name NVARCHAR(100) NOT NULL,
param_desc NVARCHAR(500),
param_type NVARCHAR(20) NOT NULL DEFAULT 'String',
param_position NVARCHAR(20) NOT NULL DEFAULT 'QUERY',
is_required TINYINT DEFAULT 0,
default_value NVARCHAR(200),
sql_param_name NVARCHAR(100),
sort INT DEFAULT 0,
create_time DATETIME DEFAULT GETDATE(),
update_time DATETIME DEFAULT GETDATE()
)
""")
conn.commit()
print("✓ erp_api_param created")
except Exception as e:
if "already exists" in str(e):
print("✓ erp_api_param already exists")
else:
print(f"✗ Error creating erp_api_param: {e}")
conn.rollback()
# 创建索引和外键
print("Creating indexes on erp_api_param...")
try:
cursor.execute("CREATE INDEX idx_api_id ON erp_api_param(api_id)")
conn.commit()
print("✓ idx_api_id created")
except Exception as e:
if "already exists" in str(e):
print("✓ idx_api_id already exists")
else:
print(f"Note: {e}")
conn.rollback()
# 3. 创建 erp_api_stats 表
print("Creating erp_api_stats table...")
try:
cursor.execute("""
CREATE TABLE erp_api_stats (
stats_id BIGINT IDENTITY(1,1) PRIMARY KEY,
api_id BIGINT NOT NULL,
call_time DATETIME NOT NULL,
call_params NVARCHAR(MAX),
response_time INT,
call_status NVARCHAR(10),
error_message NVARCHAR(MAX),
error_stack NVARCHAR(MAX),
client_ip NVARCHAR(50),
user_id NVARCHAR(50),
create_time DATETIME DEFAULT GETDATE()
)
""")
conn.commit()
print("✓ erp_api_stats created")
except Exception as e:
if "already exists" in str(e):
print("✓ erp_api_stats already exists")
else:
print(f"✗ Error creating erp_api_stats: {e}")
conn.rollback()
# 创建索引
print("Creating indexes on erp_api_stats...")
try:
cursor.execute("CREATE INDEX idx_api_id_time ON erp_api_stats(api_id, call_time)")
conn.commit()
print("✓ idx_api_id_time created")
except Exception as e:
if "already exists" in str(e):
print("✓ idx_api_id_time already exists")
else:
print(f"Note: {e}")
conn.rollback()
# 验证表创建
print("\nVerifying tables...")
cursor.execute("SELECT name FROM sysobjects WHERE xtype='U' AND name LIKE 'erp_api_%' ORDER BY name")
tables = cursor.fetchall()
for table in tables:
print(f"✓ Table {table[0]} created successfully")
cursor.close()
conn.close()
print("\n✓ All ERP API tables initialized!")

13
hzhub-erp/logs.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
# hzhub-erp 日志查看脚本
cd "$(dirname "$0")"
LOG_FILE="logs/erp.log"
if [ -f "$LOG_FILE" ]; then
tail -f "$LOG_FILE"
else
echo "Log file not found: $LOG_FILE"
echo "Start the service first to generate logs."
fi

View File

@@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""
ERP API 迁移脚本
将硬编码的CustomerController API转换为动态配置
"""
import pymysql
import os
from datetime import datetime
# MySQL连接配置
MYSQL_HOST = os.getenv('MYSQL_HOST', '192.168.120.60')
MYSQL_PORT = int(os.getenv('MYSQL_PORT', '3306'))
MYSQL_DB = os.getenv('MYSQL_DB', 'hzhub')
MYSQL_USER = os.getenv('MYSQL_USERNAME', 'root')
MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', 'hzhub123')
print(f"连接MySQL: {MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DB}")
conn = pymysql.connect(
host=MYSQL_HOST,
port=MYSQL_PORT,
database=MYSQL_DB,
user=MYSQL_USER,
password=MYSQL_PASSWORD,
charset='utf8mb4'
)
cursor = conn.cursor()
# API配置数据
api_configs = [
{
'api_name': '客户列表查询',
'api_path': '/erp/dynamic/v1/customer/list',
'api_method': 'GET',
'api_desc': '分页查询客户档案列表,支持多条件筛选',
'api_version': 'v1',
'data_source': 'erp',
'sql_template': '''
SELECT TOP #{pageSize} * FROM (
SELECT ROW_NUMBER() OVER (ORDER BY CLTCODE) AS rn,
CLTCODE AS customerCode,
CLTNAME AS customerName,
COMPANY_ID AS companyCode,
COMPANY_NAME AS companyName,
BRAND AS brand,
BRANDNAME AS brandName,
LINKMAN AS contactName,
AREAID AS salesAreaCode,
AREANAME AS salesAreaName,
SALESID_T AS salesPersonCode,
SALESNAME_T AS salesPersonName,
SALEDOCID AS saleDocCode,
SALEDOCNAME AS saleDocName,
CLTPRICENO AS pricePlanCode,
CLTPRICENAME AS pricePlanName,
CLTTYPE AS customerType,
STREET AS address,
TEL1 AS phone,
EMAIL AS email,
SDORGID AS sdOrgCode,
SDORGNAME AS sdOrgName,
province, city, ISSTOP AS isStop
FROM SCLTGENERAL
WHERE 1=1
AND #{keyword} IS NOT NULL THEN (CLTCODE LIKE '%#{keyword}%'
OR CLTNAME LIKE '%#{keyword}%'
OR LINKMAN LIKE '%#{keyword}%'
OR AREANAME LIKE '%#{keyword}%'
OR SALESNAME_T LIKE '%#{keyword}%')
AND #{companyCode} IS NOT NULL THEN COMPANY_ID = #{companyCode}
AND #{salesAreaCode} IS NOT NULL THEN AREAID = #{salesAreaCode}
AND #{brand} IS NOT NULL THEN BRAND = #{brand}
) t WHERE rn > (#{pageNum} - 1) * #{pageSize} ORDER BY rn
''',
'result_type': 'LIST',
'support_pagination': 1,
'page_param_name': 'pageNum',
'size_param_name': 'pageSize',
'require_auth': 0,
'permission_code': None,
'enable_cache': 0,
'source_table': 'SCLTGENERAL',
'source_table_comment': '客户档案主表',
'status': 1,
'params': [
{'param_name': 'pageNum', 'param_desc': '页码', 'param_type': 'Integer', 'param_position': 'QUERY', 'is_required': 0, 'default_value': '1', 'sort': 1},
{'param_name': 'pageSize', 'param_desc': '页大小', 'param_type': 'Integer', 'param_position': 'QUERY', 'is_required': 0, 'default_value': '10', 'sort': 2},
{'param_name': 'keyword', 'param_desc': '关键词(客户编码/名称/联系人/销区/销售员)', 'param_type': 'String', 'param_position': 'QUERY', 'is_required': 0, 'default_value': None, 'sort': 3},
{'param_name': 'companyCode', 'param_desc': '公司编码', 'param_type': 'String', 'param_position': 'QUERY', 'is_required': 0, 'default_value': None, 'sort': 4},
{'param_name': 'salesAreaCode', 'param_desc': '销区编码', 'param_type': 'String', 'param_position': 'QUERY', 'is_required': 0, 'default_value': None, 'sort': 5},
{'param_name': 'brand', 'param_desc': '品牌编码', 'param_type': 'String', 'param_position': 'QUERY', 'is_required': 0, 'default_value': None, 'sort': 6},
]
},
{
'api_name': '客户详情查询',
'api_path': '/erp/dynamic/v1/customer/detail',
'api_method': 'GET',
'api_desc': '根据客户编码查询客户详细信息',
'api_version': 'v1',
'data_source': 'erp',
'sql_template': '''
SELECT
CLTCODE AS customerCode,
CLTNAME AS customerName,
COMPANY_ID AS companyCode,
COMPANY_NAME AS companyName,
BRAND AS brand,
BRANDNAME AS brandName,
LINKMAN AS contactName,
AREAID AS salesAreaCode,
AREANAME AS salesAreaName,
SALESID_T AS salesPersonCode,
SALESNAME_T AS salesPersonName,
SALEDOCID AS saleDocCode,
SALEDOCNAME AS saleDocName,
CLTPRICENO AS pricePlanCode,
CLTPRICENAME AS pricePlanName,
CLTTYPE AS customerType,
STREET AS address,
TEL1 AS phone,
EMAIL AS email,
SDORGID AS sdOrgCode,
SDORGNAME AS sdOrgName,
province, city, ISSTOP AS isStop
FROM SCLTGENERAL
WHERE CLTCODE = #{customerCode}
''',
'result_type': 'SINGLE',
'support_pagination': 0,
'require_auth': 0,
'enable_cache': 1,
'cache_key_template': 'customer:#{customerCode}',
'cache_ttl': 600,
'source_table': 'SCLTGENERAL',
'source_table_comment': '客户档案主表',
'status': 1,
'params': [
{'param_name': 'customerCode', 'param_desc': '客户编码', 'param_type': 'String', 'param_position': 'QUERY', 'is_required': 1, 'default_value': None, 'sort': 1},
]
},
{
'api_name': '销区列表查询',
'api_path': '/erp/dynamic/v1/customer/sales-areas',
'api_method': 'GET',
'api_desc': '获取所有销区列表从OSDORG销售组织表',
'api_version': 'v1',
'data_source': 'erp',
'sql_template': '''
SELECT DISTINCT ORGCODE AS salesAreaCode, ORGNAME AS salesAreaName
FROM OSDORG
WHERE ORGLEVEL = 3
AND ORGCODE IS NOT NULL
AND ORGNAME IS NOT NULL
AND ISENABLE = 1
ORDER BY ORGCODE
''',
'result_type': 'LIST',
'support_pagination': 0,
'require_auth': 0,
'enable_cache': 1,
'cache_key_template': 'sales-areas',
'cache_ttl': 3600,
'source_table': 'OSDORG',
'source_table_comment': '销售组织表',
'status': 1,
'params': []
},
{
'api_name': '品牌列表查询',
'api_path': '/erp/dynamic/v1/customer/brands',
'api_method': 'GET',
'api_desc': '获取所有品牌列表',
'api_version': 'v1',
'data_source': 'erp',
'sql_template': '''
SELECT DISTINCT BRAND AS brand, BRANDNAME AS brandName
FROM SCLTGENERAL
WHERE BRAND IS NOT NULL AND BRANDNAME IS NOT NULL
ORDER BY BRAND
''',
'result_type': 'LIST',
'support_pagination': 0,
'require_auth': 0,
'enable_cache': 1,
'cache_key_template': 'brands',
'cache_ttl': 3600,
'source_table': 'SCLTGENERAL',
'source_table_comment': '客户档案主表',
'status': 1,
'params': []
},
]
# 插入API配置
print("\n开始迁移ERP API配置...")
for api in api_configs:
print(f"\n处理API: {api['api_name']}")
# 插入主表
sql_insert_config = """
INSERT INTO erp_api_config (
api_name, api_path, api_method, api_desc, api_version,
data_source, sql_template, result_type,
support_pagination, page_param_name, size_param_name,
require_auth, permission_code,
enable_cache, cache_key_template, cache_ttl,
source_table, source_table_comment,
status, create_time, create_by
) VALUES (
%s, %s, %s, %s, %s,
%s, %s, %s,
%s, %s, %s,
%s, %s,
%s, %s, %s,
%s, %s,
%s, %s, %s
)
"""
cursor.execute(sql_insert_config, (
api['api_name'], api['api_path'], api['api_method'], api['api_desc'], api['api_version'],
api['data_source'], api['sql_template'].strip(), api['result_type'],
api['support_pagination'], api.get('page_param_name', 'pageNum'), api.get('size_param_name', 'pageSize'),
api['require_auth'], api.get('permission_code'),
api['enable_cache'], api.get('cache_key_template'), api.get('cache_ttl', 300),
api['source_table'], api['source_table_comment'],
api['status'], datetime.now(), 'admin'
))
api_id = cursor.lastrowid
print(f" ✓ API配置已插入 (api_id: {api_id})")
# 插入参数表
for param in api['params']:
sql_insert_param = """
INSERT INTO erp_api_param (
api_id, param_name, param_desc, param_type,
param_position, is_required, default_value, sort,
create_time
) VALUES (
%s, %s, %s, %s,
%s, %s, %s, %s,
%s
)
"""
cursor.execute(sql_insert_param, (
api_id, param['param_name'], param['param_desc'], param['param_type'],
param['param_position'], param['is_required'], param.get('default_value'), param['sort'],
datetime.now()
))
if api['params']:
print(f" ✓ 已插入 {len(api['params'])} 个参数配置")
conn.commit()
# 验证插入结果
print("\n验证迁移结果:")
cursor.execute("SELECT COUNT(*) FROM erp_api_config")
total_configs = cursor.fetchone()[0]
print(f" ✓ erp_api_config: {total_configs} 条记录")
cursor.execute("SELECT COUNT(*) FROM erp_api_param")
total_params = cursor.fetchone()[0]
print(f" ✓ erp_api_param: {total_params} 条记录")
# 显示插入的API列表
print("\n已迁移的API列表:")
cursor.execute("""
SELECT api_id, api_name, api_path, api_method, result_type, support_pagination
FROM erp_api_config
ORDER BY api_id
""")
for row in cursor.fetchall():
api_id, name, path, method, result_type, pagination = row
pagination_str = "分页" if pagination else "不分页"
print(f" [{api_id}] {name} - {method} {path} ({result_type}, {pagination_str})")
cursor.close()
conn.close()
print("\n✅ ERP API迁移完成")
print("\n下一步:")
print(" 1. 测试动态API:")
print(" curl 'http://192.168.120.60:8080/erp/dynamic/v1/customer/list?pageNum=1&pageSize=10'")
print(" 2. 前端访问动态API管理界面:")
print(" http://192.168.120.60:5666/erp/api")
print(" 3. 可选废弃旧的CustomerController保留作为备用或对比测试")

View File

@@ -0,0 +1,3 @@
# Override disabled algorithms for SQL Server 2008 R2 compatibility
# This is less restrictive than the default for legacy server support
jdk.tls.disabledAlgorithms=SSLv3, DTLSv1.0, RC4, DES, MD5withRSA, DH keySize < 1024, 3DES_EDE_CBC, anon, NULL

View File

@@ -4,7 +4,7 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.foshanhuiya</groupId>
<groupId>org.hzhub</groupId>
<artifactId>hzhub-erp</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
@@ -25,7 +25,7 @@
<mybatis-plus.version>3.5.14</mybatis-plus.version>
<hutool.version>5.8.40</hutool.version>
<sa-token.version>1.44.0</sa-token.version>
<mssql-jdbc.version>12.8.1.jre11</mssql-jdbc.version>
<mssql-jdbc.version>11.2.3.jre17</mssql-jdbc.version>
</properties>
<dependencies>
@@ -54,13 +54,27 @@
<scope>runtime</scope>
</dependency>
<!-- MyBatis Plus -->
<!-- MyBatis Plus (Spring Boot 3 specific starter) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MyBatis Plus JSqlParser (for PaginationInnerInterceptor) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- Dynamic Datasource (多数据源支持) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot3-starter</artifactId>
<version>4.3.1</version>
</dependency>
<!-- Hutool -->
<dependency>
<groupId>cn.hutool</groupId>
@@ -75,6 +89,38 @@
<version>${sa-token.version}</version>
</dependency>
<!-- Spring Boot AOP (required for Sa-Token annotations) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Spring Boot JDBC (for connection validation) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- Spring Boot Actuator (health checks) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Sa-Token JWT support -->
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-jwt</artifactId>
<version>${sa-token.version}</version>
</dependency>
<!-- .env file support -->
<dependency>
<groupId>me.paulschwarz</groupId>
<artifactId>spring-dotenv</artifactId>
<version>4.0.0</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>

9
hzhub-erp/restart.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
# hzhub-erp 重启脚本
cd "$(dirname "$0")"
echo "Restarting hzhub-erp..."
./stop.sh
sleep 2
./start.sh

1
hzhub-erp/run Normal file
View File

@@ -0,0 +1 @@
mvn spring-boot:run -Dspring-boot.run.profiles=dev

View File

@@ -1,17 +0,0 @@
package com.foshanhuiya.erp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* HZHub ERP服务启动类
*
* @author HZHub Team
*/
@SpringBootApplication
public class HzhubErpApplication {
public static void main(String[] args) {
SpringApplication.run(HzhubErpApplication.class, args);
}
}

View File

@@ -0,0 +1,21 @@
package org.hzhub.erp;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* HZHub ERP服务启动类
*
* @author HZHub Team
*/
@SpringBootApplication(excludeName = {
"cn.dev33.satoken.spring.SaTokenContextRegister" // 完全排除 Sa-Token 自动配置(开发阶段)
}) // 开发阶段完全禁用 Sa-Token生产环境需要移除此排除并启用认证
@MapperScan("org.hzhub.erp.mapper")
public class HzhubErpApplication {
public static void main(String[] args) {
SpringApplication.run(HzhubErpApplication.class, args);
}
}

View File

@@ -0,0 +1,40 @@
package org.hzhub.erp.common.core;
import org.hzhub.erp.common.domain.R;
import org.hzhub.erp.common.page.TableDataInfo;
import java.util.List;
/**
* 控制器基类
*/
public class BaseController {
/**
* 响应返回结果
*/
protected <T> R<T> toAjax(int rows) {
return rows > 0 ? R.ok() : R.fail();
}
/**
* 响应返回结果
*/
protected <T> R<T> toAjax(boolean result) {
return result ? R.ok() : R.fail();
}
/**
* 页面跳转
*/
protected String redirect(String url) {
return String.format("redirect:%s", url);
}
/**
* 获取分页数据
*/
protected <T> TableDataInfo<T> getDataTable(List<T> list) {
return new TableDataInfo<>(list, list.size());
}
}

View File

@@ -0,0 +1,49 @@
package org.hzhub.erp.common.core;
/**
* HTTP状态码常量
*/
public final class HttpStatus {
private HttpStatus() {}
/**
* 操作成功
*/
public static final int SUCCESS = 200;
/**
* 对象创建成功
*/
public static final int CREATED = 201;
/**
* 请求已经被接受
*/
public static final int ACCEPTED = 202;
/**
* 操作已经执行成功,但是没有返回数据
*/
public static final int NO_CONTENT = 204;
/**
* 资源未找到
*/
public static final int NOT_FOUND = 404;
/**
* 请求方法未允许
*/
public static final int METHOD_NOT_ALLOWED = 405;
/**
* 业务异常
*/
public static final int WARN = 601;
/**
* 操作失败
*/
public static final int ERROR = 500;
}

View File

@@ -0,0 +1,61 @@
package org.hzhub.erp.common.domain;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* Entity基类
*/
@Data
public class BaseEntity implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 搜索值
*/
@JsonIgnore
@TableField(exist = false)
private String searchValue;
/**
* 创建者
*/
@TableField(fill = FieldFill.INSERT)
private String createBy;
/**
* 创建时间
*/
@TableField(fill = FieldFill.INSERT)
private Date createTime;
/**
* 更新者
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/**
* 更新时间
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
/**
* 请求参数
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@TableField(exist = false)
private Map<String, Object> params = new HashMap<>();
}

View File

@@ -0,0 +1,85 @@
package org.hzhub.erp.common.domain;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
/**
* 响应信息主体
*/
@Data
@NoArgsConstructor
public class R<T> implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
public static final int SUCCESS = 200;
public static final int FAIL = 500;
private int code;
private String msg;
private T data;
public static <T> R<T> ok() {
return restResult(null, SUCCESS, "操作成功");
}
public static <T> R<T> ok(T data) {
return restResult(data, SUCCESS, "操作成功");
}
public static <T> R<T> ok(String msg) {
return restResult(null, SUCCESS, msg);
}
public static <T> R<T> ok(String msg, T data) {
return restResult(data, SUCCESS, msg);
}
public static <T> R<T> fail() {
return restResult(null, FAIL, "操作失败");
}
public static <T> R<T> fail(String msg) {
return restResult(null, FAIL, msg);
}
public static <T> R<T> fail(T data) {
return restResult(data, FAIL, "操作失败");
}
public static <T> R<T> fail(String msg, T data) {
return restResult(data, FAIL, msg);
}
public static <T> R<T> fail(int code, String msg) {
return restResult(null, code, msg);
}
public static <T> R<T> warn(String msg) {
return restResult(null, 601, msg);
}
public static <T> R<T> warn(String msg, T data) {
return restResult(data, 601, msg);
}
private static <T> R<T> restResult(T data, int code, String msg) {
R<T> r = new R<>();
r.setCode(code);
r.setData(data);
r.setMsg(msg);
return r;
}
public static <T> Boolean isError(R<T> ret) {
return !isSuccess(ret);
}
public static <T> Boolean isSuccess(R<T> ret) {
return R.SUCCESS == ret.getCode();
}
}

View File

@@ -0,0 +1,62 @@
package org.hzhub.erp.common.page;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
/**
* 表格分页数据对象
*/
@Data
@NoArgsConstructor
public class TableDataInfo<T> implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 总记录数
*/
private long total;
/**
* 列表数据
*/
private List<T> rows;
/**
* 消息状态码
*/
private int code;
/**
* 消息内容
*/
private String msg;
public TableDataInfo(List<T> list, long total) {
this.rows = list;
this.total = total;
this.code = 200;
this.msg = "查询成功";
}
public static <T> TableDataInfo<T> build(List<T> list) {
TableDataInfo<T> rspData = new TableDataInfo<>();
rspData.setCode(200);
rspData.setMsg("查询成功");
rspData.setRows(list);
rspData.setTotal(list.size());
return rspData;
}
public static <T> TableDataInfo<T> build() {
TableDataInfo<T> rspData = new TableDataInfo<>();
rspData.setCode(200);
rspData.setMsg("查询成功");
return rspData;
}
}

View File

@@ -0,0 +1,51 @@
package org.hzhub.erp.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 配置
*/
@Configuration
public class MybatisPlusConfig {
/**
* MyBatis-Plus 拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件 - MySQL用于配置管理SQL Server查询使用DynamicApiExecutor已自定义分页
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
// 设置最大单页限制数量,-1不受限制
paginationInterceptor.setMaxLimit(500L);
interceptor.addInnerInterceptor(paginationInterceptor);
return interceptor;
}
/**
* 自动填充处理器
*/
@Bean
public MetaObjectHandler metaObjectHandler() {
return new MetaObjectHandler() {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
};
}
}

View File

@@ -0,0 +1,21 @@
package org.hzhub.erp.config;
import cn.dev33.satoken.jwt.StpLogicJwtForSimple;
import cn.dev33.satoken.stp.StpLogic;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Sa-Token 配置类
*/
@Configuration
public class SaTokenConfig {
/**
* 使用 Simple JWT 模式,与 hzhub-ai 保持一致
*/
@Bean
public StpLogic getStpLogicJwt() {
return new StpLogicJwtForSimple();
}
}

View File

@@ -0,0 +1,30 @@
package org.hzhub.erp.config;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotPermissionException;
import org.hzhub.erp.common.domain.R;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* Sa-Token 异常处理器
*/
@RestControllerAdvice
public class SaTokenExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(SaTokenExceptionHandler.class);
@ExceptionHandler(NotLoginException.class)
public R<Void> handleNotLoginException(NotLoginException e) {
log.error("未登录: {}", e.getMessage());
return R.fail(401, "未登录或登录已过期");
}
@ExceptionHandler(NotPermissionException.class)
public R<Void> handleNotPermissionException(NotPermissionException e) {
log.error("无权限: {}", e.getMessage());
return R.fail(403, "没有访问权限,请联系管理员授权");
}
}

View File

@@ -0,0 +1,52 @@
package org.hzhub.erp.config;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.stp.StpUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.Arrays;
import java.util.List;
/**
* Sa-Token 路由拦截器配置
*/
@Configuration
public class SecurityConfig implements WebMvcConfigurer {
/**
* 不需要拦截的路径
*/
private static final List<String> EXCLUDE_PATHS = Arrays.asList(
"/erp/test/**",
"/erp/customer/**",
"/erp/api/**", // API配置管理开发阶段暂时跳过认证
"/erp/dynamic/**", // 动态API执行开发阶段暂时跳过认证
"/actuator/**",
"/error"
);
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 开发阶段:完全跳过拦截器配置,不添加任何拦截器
// 生产环境:需要启用以下拦截器配置
/*
registry.addInterceptor(new SaInterceptor(handle -> {
// 如果请求来自 Gateway已验证 JWT直接放行
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
if ("true".equals(request.getHeader("X-Gateway-Verified"))) {
return;
}
StpUtil.checkLogin();
}))
.addPathPatterns("/**")
.excludePathPatterns(EXCLUDE_PATHS);
*/
}
}

View File

@@ -0,0 +1,82 @@
package org.hzhub.erp.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import lombok.RequiredArgsConstructor;
import org.hzhub.erp.common.core.BaseController;
import org.hzhub.erp.common.domain.R;
import org.hzhub.erp.common.page.TableDataInfo;
import org.hzhub.erp.domain.vo.CustomerVO;
import org.hzhub.erp.service.ICustomerService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 客户档案 API已废弃
*
* @deprecated 已迁移到动态API系统请使用
* - /erp/dynamic/v1/customer/list 替代 /erp/customer/list
* - /erp/dynamic/v1/customer/detail 替代 /erp/customer/{customerCode}
* - /erp/dynamic/v1/customer/sales-areas 替代 /erp/customer/sales-areas
* - /erp/dynamic/v1/customer/brands 替代 /erp/customer/brands
*
* @author HZHub Team
* @since 2026-04-21
* @deprecated since 2026-04-30, will be removed in 2026-07-30
*/
@Deprecated(since = "2026-04-30", forRemoval = true)
@SaIgnore
@RestController
@RequestMapping("/erp/customer")
@RequiredArgsConstructor
public class CustomerController extends BaseController {
private static final Logger log = LoggerFactory.getLogger(CustomerController.class);
private final ICustomerService customerService;
/**
* 分页查询客户列表
* @deprecated 请使用动态API: /erp/dynamic/v1/customer/list
*/
@GetMapping("/list")
public TableDataInfo<CustomerVO> list(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String companyCode,
@RequestParam(required = false) String salesAreaCode,
@RequestParam(required = false) String brand) {
log.warn("⚠️ 已废弃API被调用: /erp/customer/list请迁移到动态API: /erp/dynamic/v1/customer/list");
return customerService.queryCustomerList(pageNum, pageSize, keyword, companyCode, salesAreaCode, brand);
}
/**
* 获取客户详情
*/
@GetMapping("/{customerCode}")
public R<CustomerVO> detail(@PathVariable String customerCode) {
CustomerVO vo = customerService.getCustomerDetail(customerCode);
if (vo == null) {
return R.fail("客户不存在");
}
return R.ok(vo);
}
/**
* 获取所有销区列表
*/
@GetMapping("/sales-areas")
public R<List<CustomerVO>> salesAreas() {
return R.ok(customerService.getSalesAreas());
}
/**
* 获取所有品牌列表
*/
@GetMapping("/brands")
public R<List<CustomerVO>> brands() {
return R.ok(customerService.getBrands());
}
}

View File

@@ -0,0 +1,257 @@
package org.hzhub.erp.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.hzhub.erp.common.domain.R;
import org.hzhub.erp.domain.entity.ErpApiConfig;
import org.hzhub.erp.domain.vo.ApiExecutionResult;
import org.hzhub.erp.service.IErpApiService;
import org.hzhub.erp.service.impl.ApiStatsRecorder;
import org.hzhub.erp.service.impl.DynamicApiExecutor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 动态API执行Controller
*
* @author HZHub Team
*/
@RestController
@RequestMapping("/erp/dynamic")
@RequiredArgsConstructor
@SaIgnore // 当前版本暂时忽略认证后续通过配置表的require_auth字段控制
public class DynamicApiController {
private static final Logger log = LoggerFactory.getLogger(DynamicApiController.class);
private final IErpApiService erpApiService;
private final DynamicApiExecutor dynamicApiExecutor;
private final ApiStatsRecorder apiStatsRecorder;
/**
* 动态路由处理GET方法- 支持多层级路径
*
* @param version API版本v1/v2
* @param request HTTP请求对象
* @param allParams 所有请求参数
* @return 执行结果
*/
@GetMapping("/{version}/**")
public R<Object> handleDynamicGet(@PathVariable String version,
HttpServletRequest request,
@RequestParam Map<String, Object> allParams) {
String fullPath = extractFullPath(request, version);
return executeDynamicApi(fullPath, "GET", allParams, request);
}
/**
* 动态路由处理POST方法- 支持多层级路径
*
* @param version API版本v1/v2
* @param request HTTP请求对象
* @param bodyParams 请求体参数
* @return 执行结果
*/
@PostMapping("/{version}/**")
public R<Object> handleDynamicPost(@PathVariable String version,
HttpServletRequest request,
@RequestBody Map<String, Object> bodyParams) {
String fullPath = extractFullPath(request, version);
return executeDynamicApi(fullPath, "POST", bodyParams, request);
}
/**
* 从请求中提取完整的API路径
*
* @param request HTTP请求对象
* @param version API版本
* @return 完整路径(如:/erp/dynamic/v1/customer/brands
*/
private String extractFullPath(HttpServletRequest request, String version) {
String servletPath = request.getServletPath();
log.debug("Servlet path: {}, version: {}", servletPath, version);
return servletPath; // 直接返回完整路径Spring已正确解析
}
/**
* 执行动态API
*
* @param fullPath 完整API路径
* @param method HTTP方法
* @param params 参数Map
* @param request HTTP请求对象
* @return 执行结果
*/
private R<Object> executeDynamicApi(String fullPath, String method, Map<String, Object> params, HttpServletRequest request) {
// 记录开始时间
long startTime = System.currentTimeMillis();
ErpApiConfig config = null;
try {
// 从完整路径中提取版本号(路径格式:/erp/dynamic/v1/customer/brands
String version = fullPath.split("/")[3]; // 第4段是版本号
String apiPath = fullPath.substring("/erp/dynamic/".length() + version.length() + 1);
log.info("执行动态API: fullPath={}, method={}, version={}, apiPath={}, params={}",
fullPath, method, version, apiPath, params);
// 1. 查询API配置使用完整路径匹配
config = erpApiService.selectApiConfigByPath(fullPath, method, version);
if (config == null) {
log.warn("API配置不存在: path={}, method={}, version={}", fullPath, method, version);
return R.fail("API不存在");
}
if (config.getStatus() == 0) {
log.warn("API已禁用: apiId={}", config.getApiId());
return R.fail("API已禁用");
}
// 2. 权限检查如果配置了require_auth
// TODO: 集成Sa-Token权限验证Phase 4
if (config.getRequireAuth() == 1) {
log.info("API需要权限验证: permissionCode={}", config.getPermissionCode());
// StpUtil.checkPermission(config.getPermissionCode());
}
// 3. 参数验证与转换
// TODO: 根据参数配置验证参数Phase 4
// 4. 执行SQL
ApiExecutionResult executionResult = dynamicApiExecutor.execute(config, params);
Object result = executionResult.getData();
String executedSql = executionResult.getExecutedSql();
// 计算响应时间
long responseTime = System.currentTimeMillis() - startTime;
log.info("动态API执行成功: apiId={}, resultType={}, time={}ms",
config.getApiId(), result.getClass().getSimpleName(), responseTime);
log.debug("实际执行SQL: {}", executedSql);
// 5. 记录成功统计(异步记录,不影响响应)
try {
String callParamsJson = params.toString();
String clientIp = getClientIp(request);
String userId = getUserId(request);
apiStatsRecorder.recordSuccess(config.getApiId(), callParamsJson, executedSql,
responseTime, clientIp, userId);
} catch (Exception statsError) {
log.warn("统计记录失败不影响API响应: {}", statsError.getMessage());
}
// 6. 返回结果
if (result instanceof R) {
return (R<Object>) result;
} else {
return R.ok(result);
}
} catch (SecurityException e) {
log.error("安全验证失败: {}", e.getMessage());
// 记录错误统计
if (config != null) {
long responseTime = System.currentTimeMillis() - startTime;
// 错误情况下使用SQL模板作为executedSql
String executedSql = config.getSqlTemplate();
apiStatsRecorder.recordError(config.getApiId(), params.toString(), executedSql,
responseTime, e.getMessage(),
getStackTrace(e), getClientIp(request), getUserId(request));
}
return R.fail("安全验证失败: " + e.getMessage());
} catch (Exception e) {
log.error("动态API执行失败: {}", e.getMessage(), e);
// 记录错误统计
if (config != null) {
long responseTime = System.currentTimeMillis() - startTime;
// 错误情况下使用SQL模板作为executedSql
String executedSql = config.getSqlTemplate();
apiStatsRecorder.recordError(config.getApiId(), params.toString(), executedSql,
responseTime, e.getMessage(),
getStackTrace(e), getClientIp(request), getUserId(request));
}
return R.fail("执行失败: " + e.getMessage());
}
}
/**
* 获取客户端IP考虑代理和网关
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP"); // Nginx常用的真实IP头
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr(); // 最后使用直接连接的IP
}
// 如果通过了多个代理第一个IP才是真实IP
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip != null ? ip : "unknown";
}
/**
* 获取用户ID从网关注入的请求头中获取
*/
private String getUserId(HttpServletRequest request) {
// 网关会在验证JWT后注入用户ID
String userId = request.getHeader("X-User-Id");
if (userId == null || userId.isEmpty()) {
// 如果没有网关头尝试从Sa-Token获取
try {
// StpUtil.getLoginIdAsString(); // 如果已集成Sa-Token
userId = "anonymous";
} catch (Exception e) {
userId = "anonymous";
}
}
return userId;
}
/**
* 获取异常堆栈前500字符
*/
private String getStackTrace(Exception e) {
StringBuilder sb = new StringBuilder();
for (StackTraceElement element : e.getStackTrace()) {
sb.append(element.toString()).append("\n");
if (sb.length() > 500) {
break;
}
}
return sb.toString();
}
}

View File

@@ -0,0 +1,228 @@
package org.hzhub.erp.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.hzhub.erp.common.core.BaseController;
import org.hzhub.erp.common.domain.R;
import org.hzhub.erp.common.page.TableDataInfo;
import org.hzhub.erp.domain.entity.ErpApiConfig;
import org.hzhub.erp.domain.entity.ErpApiParam;
import org.hzhub.erp.domain.vo.ApiTestResultVO;
import org.hzhub.erp.domain.vo.ErpApiConfigVO;
import org.hzhub.erp.service.IErpApiService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* ERP动态API配置Controller
*
* @author HZHub Team
*/
@SaIgnore // 开发阶段暂时跳过认证,生产环境需要移除此注解并启用权限验证
@RestController
@RequestMapping("/erp/api/config")
@RequiredArgsConstructor
public class ErpApiController extends BaseController {
private final IErpApiService erpApiService;
/**
* 分页查询API配置列表
*/
@GetMapping("/list")
public TableDataInfo<ErpApiConfigVO> list(ErpApiConfigVO query,
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize) {
return erpApiService.queryApiConfigList(query, pageNum, pageSize);
}
/**
* 获取API配置详情含参数列表
*/
@GetMapping("/{apiId}")
public R<Map<String, Object>> getInfo(@PathVariable Long apiId) {
ErpApiConfig config = erpApiService.selectApiConfigById(apiId);
List<ErpApiParam> params = erpApiService.selectApiParamsByApiId(apiId);
Map<String, Object> result = new HashMap<>();
result.put("info", config);
result.put("params", params);
return R.ok(result);
}
/**
* 新增API配置
*/
@PostMapping
public R<Void> add(@RequestBody @Validated ErpApiConfig config) {
int rows = erpApiService.insertApiConfig(config);
return rows > 0 ? R.ok() : R.fail("新增失败");
}
/**
* 修改API配置
*/
@PutMapping
public R<Void> edit(@RequestBody @Validated ErpApiConfig config) {
int rows = erpApiService.updateApiConfig(config);
return rows > 0 ? R.ok() : R.fail("修改失败");
}
/**
* 删除API配置
*/
@DeleteMapping("/{apiIds}")
public R<Void> remove(@PathVariable Long[] apiIds) {
int rows = erpApiService.deleteApiConfigByIds(apiIds);
return rows > 0 ? R.ok() : R.fail("删除失败");
}
/**
* 启用/禁用API
*/
@PutMapping("/changeStatus")
public R<Void> changeStatus(@RequestBody ErpApiConfig config) {
int rows = erpApiService.updateApiStatus(config);
return rows > 0 ? R.ok() : R.fail("状态更新失败");
}
/**
* 从数据库表导入生成初始配置
*/
@PostMapping("/importFromTable")
public R<Void> importFromTable(@RequestBody Map<String, Object> request) {
String[] tableNames = ((List<String>) request.get("tableNames")).toArray(new String[0]);
String dataSource = (String) request.getOrDefault("dataSource", "erp");
erpApiService.importFromTable(tableNames, dataSource);
return R.ok();
}
/**
* 同步表结构(更新字段配置)
*/
@GetMapping("/syncTable/{apiId}")
public R<Void> syncTable(@PathVariable Long apiId) {
erpApiService.syncTableStructure(apiId);
return R.ok();
}
/**
* API测试
*/
@PostMapping("/test/{apiId}")
public R<ApiTestResultVO> testApi(@PathVariable Long apiId,
@RequestBody Map<String, Object> testParams,
HttpServletRequest request) {
// 提取客户端IP和用户ID
String clientIp = getClientIp(request);
String userId = getUserId(request);
ApiTestResultVO result = erpApiService.testApi(apiId, testParams, clientIp, userId);
return R.ok(result);
}
/**
* 获取客户端IP考虑代理和网关
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP"); // Nginx常用的真实IP头
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr(); // 最后使用直接连接的IP
}
// 如果通过了多个代理第一个IP才是真实IP
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip != null ? ip : "unknown";
}
/**
* 获取用户ID从网关注入的请求头中获取
*/
private String getUserId(HttpServletRequest request) {
// 网关会在验证JWT后注入用户ID
String userId = request.getHeader("X-User-Id");
if (userId == null || userId.isEmpty()) {
// 如果没有网关头尝试从Sa-Token获取
try {
// StpUtil.getLoginIdAsString(); // 如果已集成Sa-Token
userId = "test-user"; // 测试环境使用固定标识
} catch (Exception e) {
userId = "test-user";
}
}
return userId;
}
/**
* API文档预览
*/
@GetMapping("/preview/{apiId}")
public R<Map<String, String>> preview(@PathVariable Long apiId) {
Map<String, String> docMap = erpApiService.generateApiDoc(apiId);
return R.ok(docMap);
}
/**
* 查询调用统计
*/
@GetMapping("/stats/{apiId}")
public R<Map<String, Object>> getStats(@PathVariable Long apiId,
@RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime) {
Map<String, Object> stats = erpApiService.getApiStats(apiId, startTime, endTime);
return R.ok(stats);
}
/**
* 查询错误日志
*/
@GetMapping("/errorLog/{apiId}")
public R<List<Map<String, Object>>> getErrorLog(@PathVariable Long apiId,
@RequestParam(defaultValue = "10") Integer limit) {
List<Map<String, Object>> logs = erpApiService.getApiErrorLog(apiId, limit);
return R.ok(logs);
}
/**
* 清除缓存
*/
@DeleteMapping("/cache/{apiId}")
public R<Void> clearCache(@PathVariable Long apiId) {
erpApiService.clearApiCache(apiId);
return R.ok();
}
}

View File

@@ -0,0 +1,112 @@
package org.hzhub.erp.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import lombok.RequiredArgsConstructor;
import org.hzhub.erp.common.domain.R;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* ERP 数据库探查工具
*/
@SaIgnore
@RestController
@RequestMapping("/erp/test")
@RequiredArgsConstructor
public class ErpExploreController {
private static final Logger log = LoggerFactory.getLogger(ErpExploreController.class);
private final JdbcTemplate jdbcTemplate;
@Value("${spring.datasource.url}")
private String datasourceUrl;
@Value("${spring.datasource.username}")
private String datasourceUsername;
/**
* 探查数据库所有表,返回统计信息
*/
@GetMapping("/explore")
public R<Map<String, Object>> exploreDatabase() {
Map<String, Object> result = new HashMap<>();
try {
// 获取数据库名
String dbName = jdbcTemplate.queryForObject("SELECT DB_NAME()", String.class);
result.put("database", dbName);
// 获取所有用户表及其基本信息
String sql =
"SELECT " +
" t.name AS tableName, " +
" SCHEMA_NAME(t.schema_id) AS schemaName, " +
" p.rows AS rowCount, " +
" c2.columnCount, " +
" CASE WHEN pk.colCount > 0 THEN 1 ELSE 0 END AS hasPrimaryKey " +
"FROM sys.tables t " +
"CROSS APPLY ( " +
" SELECT COUNT(*) AS columnCount " +
" FROM sys.columns c " +
" WHERE c.object_id = t.object_id " +
") c2 " +
"LEFT JOIN sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1) " +
"OUTER APPLY ( " +
" SELECT COUNT(*) AS colCount " +
" FROM sys.index_columns ic " +
" INNER JOIN sys.indexes i ON ic.object_id = i.object_id AND ic.index_id = i.index_id " +
" WHERE ic.object_id = t.object_id AND i.is_primary_key = 1 " +
") pk " +
"WHERE t.type = 'U' " +
"ORDER BY p.rows DESC";
List<Map<String, Object>> tables = jdbcTemplate.queryForList(sql);
result.put("tables", tables);
result.put("totalTables", tables.size());
return R.ok("数据库探查成功", result);
} catch (Exception e) {
log.error("数据库探查失败", e);
return R.fail("数据库探查失败: " + e.getMessage());
}
}
/**
* 查看指定表的列信息
*/
@GetMapping("/explore/table")
public R<Map<String, Object>> exploreTable(@org.springframework.web.bind.annotation.RequestParam String tableName) {
Map<String, Object> result = new HashMap<>();
try {
String sql =
"SELECT " +
" c.name AS columnName, " +
" TYPE_NAME(c.user_type_id) AS dataType, " +
" c.max_length AS maxLength, " +
" c.is_nullable AS nullable, " +
" c.is_identity AS isIdentity, " +
" ep.value AS description " +
"FROM sys.columns c " +
"INNER JOIN sys.tables t ON c.object_id = t.object_id " +
"LEFT JOIN sys.extended_properties ep ON c.object_id = ep.major_id " +
" AND c.column_id = ep.minor_id AND ep.name = 'MS_Description' " +
"WHERE t.name = ? " +
"ORDER BY c.column_id";
List<Map<String, Object>> columns = jdbcTemplate.queryForList(sql, tableName);
result.put("tableName", tableName);
result.put("columns", columns);
return R.ok(result);
} catch (Exception e) {
return R.fail("查询表结构失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,19 @@
package org.hzhub.erp.controller;
import org.hzhub.erp.common.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* ERP测试Controller用于验证认证问题
*/
@RestController
@RequestMapping("/erp/test2")
public class ErpTest2Controller {
@GetMapping("/hello")
public R<String> hello() {
return R.ok("Hello, ERP Test2");
}
}

View File

@@ -0,0 +1,61 @@
package org.hzhub.erp.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import lombok.RequiredArgsConstructor;
import org.hzhub.erp.common.domain.R;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* ERP 连接测试控制器
*/
@SaIgnore
@RestController
@RequestMapping("/erp/test")
@RequiredArgsConstructor
public class ErpTestController {
private static final Logger log = LoggerFactory.getLogger(ErpTestController.class);
private final JdbcTemplate jdbcTemplate;
/**
* 测试 SQL Server 数据库连接
*/
@GetMapping("/connection")
public R<Map<String, String>> testConnection() {
Map<String, String> result = new HashMap<>();
try {
String version = jdbcTemplate.queryForObject("SELECT @@VERSION", String.class);
String dbName = jdbcTemplate.queryForObject("SELECT DB_NAME()", String.class);
result.put("status", "connected");
result.put("database", dbName != null ? dbName : "unknown");
result.put("version", version != null ? version.substring(0, Math.min(150, version.length())) : "unknown");
return R.ok("SQL Server 连接成功", result);
} catch (Exception e) {
log.error("SQL Server 连接失败", e);
Throwable cause = e;
while (cause.getCause() != null) {
cause = cause.getCause();
}
result.put("status", "failed");
result.put("error", e.getMessage());
result.put("rootCause", cause.getClass().getName() + ": " + cause.getMessage());
return R.fail("SQL Server 连接失败: " + e.getMessage(), result);
}
}
/**
* 健康检查
*/
@GetMapping("/health")
public R<String> health() {
return R.ok("hzhub-erp is running");
}
}

View File

@@ -0,0 +1,106 @@
package org.hzhub.erp.domain.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 客户档案实体SCLTGENERAL 表)
*/
@Data
@TableName("SCLTGENERAL")
public class CustomerGeneral {
@TableId("CLTCODE")
private String cltCode;
@TableField("CLTNAME")
private String cltName;
@TableField("COMPANY_ID")
private String companyId;
@TableField("COMPANY_NAME")
private String companyName;
@TableField("BRAND")
private String brand;
@TableField("BRANDNAME")
private String brandName;
@TableField("LINKMAN")
private String linkMan;
@TableField("AREAID")
private String areaId;
@TableField("AREANAME")
private String areaName;
@TableField("SALESID_T")
private String salesId;
@TableField("SALESNAME_T")
private String salesName;
@TableField("SALEDOCID")
private String saleDocId;
@TableField("SALEDOCNAME")
private String saleDocName;
@TableField("CLTPRICENO")
private String cltPriceNo;
@TableField("CLTPRICENAME")
private String cltPriceName;
@TableField("CLTTYPE")
private String cltType;
@TableField("STREET")
private String street;
@TableField("TEL1")
private String tel1;
@TableField("TEL2")
private String tel2;
@TableField("EMAIL")
private String email;
@TableField("SDORGID")
private String sdOrgId;
@TableField("SDORGNAME")
private String sdOrgName;
@TableField("ISSTOP")
private Integer isStop;
@TableField("CREATE_DATE")
private LocalDateTime createDate;
@TableField("CREATE_NAME")
private String createName;
@TableField("administrative")
private String administrative;
@TableField("administraname")
private String administraname;
@TableField("province")
private String province;
@TableField("city")
private String city;
@TableField("Country")
private String country;
}

View File

@@ -0,0 +1,98 @@
package org.hzhub.erp.domain.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* ERP动态API配置实体
*
* @author HZHub Team
*/
@Data
@TableName("erp_api_config")
public class ErpApiConfig implements Serializable {
private static final long serialVersionUID = 1L;
/** API ID */
@TableId(value = "api_id", type = IdType.AUTO)
private Long apiId;
/** API名称 */
private String apiName;
/** API路径如 /erp/dynamic/customer/list */
private String apiPath;
/** HTTP方法GET/POST */
private String apiMethod;
/** API描述 */
private String apiDesc;
/** API版本号v1/v2 */
private String apiVersion;
/** 数据源名称 */
private String dataSource;
/** SQL模板支持参数占位符 #{paramName} */
private String sqlTemplate;
/** 结果类型LIST/SINGLE/COUNT */
private String resultType;
/** 是否支持分页 */
private Integer supportPagination;
/** 页码参数名 */
private String pageParamName;
/** 页大小参数名 */
private String sizeParamName;
/** 是否需要认证 */
private Integer requireAuth;
/** 权限标识(如 erp:customer:list */
private String permissionCode;
/** 是否启用缓存 */
private Integer enableCache;
/** 缓存键模板(支持参数占位符) */
private String cacheKeyTemplate;
/** 缓存过期时间(秒) */
private Integer cacheTtl;
/** 来源表名 */
private String sourceTable;
/** 来源表描述 */
private String sourceTableComment;
/** 状态0禁用 1启用 */
private Integer status;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
/** 创建者 */
@TableField(fill = FieldFill.INSERT)
private String createBy;
/** 更新者 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/** 备注 */
private String remark;
}

View File

@@ -0,0 +1,57 @@
package org.hzhub.erp.domain.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* ERP动态API参数配置实体
*
* @author HZHub Team
*/
@Data
@TableName("erp_api_param")
public class ErpApiParam implements Serializable {
private static final long serialVersionUID = 1L;
/** 参数ID */
@TableId(value = "param_id", type = IdType.AUTO)
private Long paramId;
/** 所属API ID */
private Long apiId;
/** 参数名称 */
private String paramName;
/** 参数描述 */
private String paramDesc;
/** 参数类型String/Integer/Long/Date/Boolean */
private String paramType;
/** 参数位置QUERY/BODY */
private String paramPosition;
/** 是否必填 */
private Integer isRequired;
/** 默认值 */
private String defaultValue;
/** SQL参数名 */
private String sqlParamName;
/** 排序 */
private Integer sort;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,53 @@
package org.hzhub.erp.domain.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* ERP动态API调用统计实体
*
* @author HZHub Team
*/
@Data
@TableName("erp_api_stats")
public class ErpApiStats implements Serializable {
private static final long serialVersionUID = 1L;
/** 统计ID */
@TableId(value = "stats_id", type = IdType.AUTO)
private Long statsId;
/** API ID */
private Long apiId;
/** 调用时间 */
private LocalDateTime callTime;
/** 调用参数JSON */
private String callParams;
/** 响应时间ms */
private Integer responseTime;
/** 调用状态SUCCESS/ERROR */
private String callStatus;
/** 错误消息 */
private String errorMessage;
/** 错误堆栈 */
private String errorStack;
/** 客户端IP */
private String clientIp;
/** 用户ID */
private String userId;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,39 @@
package org.hzhub.erp.domain.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 销售组织实体OSDORG 表)
*/
@Data
@TableName("OSDORG")
public class SalesOrganization {
/**
* 销售组织编码
*/
@TableId
private String orgCode;
/**
* 销售组织名称
*/
private String orgName;
/**
* 父组织编码
*/
private String parentOrgCode;
/**
* 组织层级
*/
private Integer orgLevel;
/**
* 是否启用1-启用0-停用)
*/
private Integer isEnable;
}

View File

@@ -0,0 +1,33 @@
package org.hzhub.erp.domain.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* API执行结果包装类
* 包含执行结果和实际执行的SQL
*
* @author HZHub Team
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiExecutionResult {
/**
* 执行结果数据
*/
private Object data;
/**
* 实际执行的SQL语句已替换参数
*/
private String executedSql;
/**
* 创建成功结果
*/
public static ApiExecutionResult success(Object data, String executedSql) {
return new ApiExecutionResult(data, executedSql);
}
}

View File

@@ -0,0 +1,43 @@
package org.hzhub.erp.domain.vo;
import lombok.Data;
import java.io.Serializable;
import java.util.Map;
/**
* API测试结果VO
*
* @author HZHub Team
*/
@Data
public class ApiTestResultVO implements Serializable {
private static final long serialVersionUID = 1L;
/** API路径 */
private String apiPath;
/** 测试方法 */
private String testMethod;
/** 请求参数 */
private Map<String, Object> requestParams;
/** 执行成功 */
private Boolean success;
/** 执行结果 */
private Object data;
/** 执行时间ms */
private Long executionTime;
/** 实际执行的SQL */
private String executedSql;
/** 错误消息 */
private String errorMessage;
/** 错误堆栈 */
private String errorStack;
}

View File

@@ -0,0 +1,86 @@
package org.hzhub.erp.domain.vo;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
/**
* 客户档案视图对象SCLTGENERAL 统一查询结果)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CustomerVO {
/** 客户编号 */
private String customerCode;
/** 客户名称 */
private String customerName;
/** 公司编号 */
private String companyCode;
/** 公司名称 */
private String companyName;
/** 品牌 */
private String brand;
/** 品牌名称 */
private String brandName;
/** 联系人 */
private String contactName;
/** 销区编号 */
private String salesAreaCode;
/** 销区名称 */
private String salesAreaName;
/** 业务员编号 */
private String salesPersonCode;
/** 业务员姓名 */
private String salesPersonName;
/** 销售负责人编号 */
private String saleDocCode;
/** 销售负责人姓名 */
private String saleDocName;
/** 价格方案号 */
private String pricePlanCode;
/** 价格方案名称 */
private String pricePlanName;
/** 客户类型 */
private String customerType;
/** 地址 */
private String address;
/** 电话 */
private String phone;
/** 邮箱 */
private String email;
/** 经销组织编号 */
private String sdOrgCode;
/** 经销组织名称 */
private String sdOrgName;
/** 省份 */
private String province;
/** 城市 */
private String city;
/** 是否停用 */
private Integer isStop;
}

View File

@@ -0,0 +1,96 @@
package org.hzhub.erp.domain.vo;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
import java.time.LocalDateTime;
import org.hzhub.erp.domain.entity.ErpApiParam;
/**
* ERP动态API配置VO包含参数列表
*
* @author HZHub Team
*/
@Data
public class ErpApiConfigVO implements Serializable {
private static final long serialVersionUID = 1L;
/** API ID */
private Long apiId;
/** API名称 */
private String apiName;
/** API路径 */
private String apiPath;
/** HTTP方法 */
private String apiMethod;
/** API描述 */
private String apiDesc;
/** API版本号 */
private String apiVersion;
/** 数据源名称 */
private String dataSource;
/** SQL模板 */
private String sqlTemplate;
/** 结果类型 */
private String resultType;
/** 是否支持分页 */
private Integer supportPagination;
/** 页码参数名 */
private String pageParamName;
/** 页大小参数名 */
private String sizeParamName;
/** 是否需要认证 */
private Integer requireAuth;
/** 权限标识 */
private String permissionCode;
/** 是否启用缓存 */
private Integer enableCache;
/** 缓存键模板 */
private String cacheKeyTemplate;
/** 缓存过期时间 */
private Integer cacheTtl;
/** 来源表名 */
private String sourceTable;
/** 来源表描述 */
private String sourceTableComment;
/** 状态 */
private Integer status;
/** 创建时间 */
private LocalDateTime createTime;
/** 更新时间 */
private LocalDateTime updateTime;
/** 创建者 */
private String createBy;
/** 更新者 */
private String updateBy;
/** 备注 */
private String remark;
/** 参数列表 */
private List<ErpApiParam> params;
}

View File

@@ -0,0 +1,146 @@
package org.hzhub.erp.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.*;
import org.hzhub.erp.domain.entity.CustomerGeneral;
import org.hzhub.erp.domain.vo.CustomerVO;
import java.util.List;
/**
* 客户档案 MapperSCLTGENERAL 表)
*/
@Mapper
public interface CustomerMapper extends BaseMapper<CustomerGeneral> {
/**
* 分页查询客户列表
*/
@Select("<script>" +
"SELECT TOP ${pageSize} * FROM (" +
" SELECT ROW_NUMBER() OVER (ORDER BY CLTCODE) AS rn, " +
" CLTCODE AS customerCode, " +
" CLTNAME AS customerName, " +
" COMPANY_ID AS companyCode, " +
" COMPANY_NAME AS companyName, " +
" BRAND AS brand, " +
" BRANDNAME AS brandName, " +
" LINKMAN AS contactName, " +
" AREAID AS salesAreaCode, " +
" AREANAME AS salesAreaName, " +
" SALESID_T AS salesPersonCode, " +
" SALESNAME_T AS salesPersonName, " +
" SALEDOCID AS saleDocCode, " +
" SALEDOCNAME AS saleDocName, " +
" CLTPRICENO AS pricePlanCode, " +
" CLTPRICENAME AS pricePlanName, " +
" CLTTYPE AS customerType, " +
" STREET AS address, " +
" TEL1 AS phone, " +
" EMAIL AS email, " +
" SDORGID AS sdOrgCode, " +
" SDORGNAME AS sdOrgName, " +
" province, city, ISSTOP AS isStop " +
" FROM SCLTGENERAL " +
" WHERE 1=1 " +
" <if test='keyword != null and keyword != \"\"'>" +
" AND (CLTCODE LIKE '%' + #{keyword} + '%' " +
" OR CLTNAME LIKE '%' + #{keyword} + '%' " +
" OR LINKMAN LIKE '%' + #{keyword} + '%' " +
" OR AREANAME LIKE '%' + #{keyword} + '%' " +
" OR SALESNAME_T LIKE '%' + #{keyword} + '%') " +
" </if>" +
" <if test='companyCode != null and companyCode != \"\"'>" +
" AND COMPANY_ID = #{companyCode} " +
" </if>" +
" <if test='salesAreaCode != null and salesAreaCode != \"\"'>" +
" AND AREAID = #{salesAreaCode} " +
" </if>" +
" <if test='brand != null and brand != \"\"'>" +
" AND BRAND = #{brand} " +
" </if>" +
") t WHERE rn &gt; (${pageNum} - 1) * ${pageSize} ORDER BY rn" +
"</script>")
List<CustomerVO> selectCustomerPage(@Param("pageNum") int pageNum,
@Param("pageSize") int pageSize,
@Param("keyword") String keyword,
@Param("companyCode") String companyCode,
@Param("salesAreaCode") String salesAreaCode,
@Param("brand") String brand);
/**
* 查询客户总数
*/
@Select("<script>" +
"SELECT COUNT(*) FROM SCLTGENERAL " +
"WHERE 1=1 " +
"<if test='keyword != null and keyword != \"\"'>" +
" AND (CLTCODE LIKE '%' + #{keyword} + '%' " +
" OR CLTNAME LIKE '%' + #{keyword} + '%' " +
" OR LINKMAN LIKE '%' + #{keyword} + '%' " +
" OR AREANAME LIKE '%' + #{keyword} + '%' " +
" OR SALESNAME_T LIKE '%' + #{keyword} + '%') " +
"</if>" +
"<if test='companyCode != null and companyCode != \"\"'>" +
" AND COMPANY_ID = #{companyCode} " +
"</if>" +
"<if test='salesAreaCode != null and salesAreaCode != \"\"'>" +
" AND AREAID = #{salesAreaCode} " +
"</if>" +
"<if test='brand != null and brand != \"\"'>" +
" AND BRAND = #{brand} " +
"</if>" +
"</script>")
long selectCustomerCount(@Param("keyword") String keyword,
@Param("companyCode") String companyCode,
@Param("salesAreaCode") String salesAreaCode,
@Param("brand") String brand);
/**
* 根据客户编号获取详情
*/
@Select("SELECT " +
" CLTCODE AS customerCode, " +
" CLTNAME AS customerName, " +
" COMPANY_ID AS companyCode, " +
" COMPANY_NAME AS companyName, " +
" BRAND AS brand, " +
" BRANDNAME AS brandName, " +
" LINKMAN AS contactName, " +
" AREAID AS salesAreaCode, " +
" AREANAME AS salesAreaName, " +
" SALESID_T AS salesPersonCode, " +
" SALESNAME_T AS salesPersonName, " +
" SALEDOCID AS saleDocCode, " +
" SALEDOCNAME AS saleDocName, " +
" CLTPRICENO AS pricePlanCode, " +
" CLTPRICENAME AS pricePlanName, " +
" CLTTYPE AS customerType, " +
" STREET AS address, " +
" TEL1 AS phone, " +
" EMAIL AS email, " +
" SDORGID AS sdOrgCode, " +
" SDORGNAME AS sdOrgName, " +
" province, city, ISSTOP AS isStop " +
"FROM SCLTGENERAL " +
"WHERE CLTCODE = #{customerCode}")
CustomerVO selectCustomerDetail(@Param("customerCode") String customerCode);
/**
* 获取所有销区列表
*/
@Select("SELECT DISTINCT AREAID AS salesAreaCode, AREANAME AS salesAreaName " +
"FROM SCLTGENERAL " +
"WHERE AREAID IS NOT NULL AND AREANAME IS NOT NULL " +
"ORDER BY AREAID")
List<CustomerVO> selectSalesAreas();
/**
* 获取所有品牌列表
*/
@Select("SELECT DISTINCT BRAND AS brand, BRANDNAME AS brandName " +
"FROM SCLTGENERAL " +
"WHERE BRAND IS NOT NULL AND BRANDNAME IS NOT NULL " +
"ORDER BY BRAND")
List<CustomerVO> selectBrands();
}

View File

@@ -0,0 +1,27 @@
package org.hzhub.erp.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.hzhub.erp.domain.entity.ErpApiConfig;
/**
* ERP动态API配置Mapper
*
* @author HZHub Team
*/
@Mapper
public interface ErpApiConfigMapper extends BaseMapper<ErpApiConfig> {
/**
* 根据API路径和方法查询配置
*
* @param apiPath API路径
* @param apiMethod HTTP方法
* @param apiVersion API版本
* @return API配置
*/
ErpApiConfig selectByPathAndMethod(@Param("apiPath") String apiPath,
@Param("apiMethod") String apiMethod,
@Param("apiVersion") String apiVersion);
}

View File

@@ -0,0 +1,41 @@
package org.hzhub.erp.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.hzhub.erp.domain.entity.ErpApiParam;
import java.util.List;
/**
* ERP动态API参数配置Mapper
*
* @author HZHub Team
*/
@Mapper
public interface ErpApiParamMapper extends BaseMapper<ErpApiParam> {
/**
* 根据API ID查询参数列表
*
* @param apiId API ID
* @return 参数列表
*/
List<ErpApiParam> selectByApiId(@Param("apiId") Long apiId);
/**
* 批量插入参数
*
* @param params 参数列表
* @return 插入数量
*/
int batchInsert(@Param("params") List<ErpApiParam> params);
/**
* 根据API ID删除参数
*
* @param apiId API ID
* @return 删除数量
*/
int deleteByApiId(@Param("apiId") Long apiId);
}

View File

@@ -0,0 +1,76 @@
package org.hzhub.erp.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.hzhub.erp.domain.entity.ErpApiStats;
import java.time.LocalDateTime;
import java.util.List;
/**
* ERP动态API调用统计Mapper
*
* @author HZHub Team
*/
@Mapper
public interface ErpApiStatsMapper extends BaseMapper<ErpApiStats> {
/**
* 根据API ID和时间范围查询统计
*
* @param apiId API ID
* @param startTime 开始时间
* @param endTime 结束时间
* @return 统计列表
*/
List<ErpApiStats> selectByApiIdAndTime(@Param("apiId") Long apiId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 查询API的错误日志
*
* @param apiId API ID
* @param limit 限制数量
* @return 错误日志列表
*/
List<ErpApiStats> selectErrorLogByApiId(@Param("apiId") Long apiId,
@Param("limit") Integer limit);
/**
* 统计API调用次数
*
* @param apiId API ID
* @param startTime 开始时间
* @param endTime 结束时间
* @return 调用次数
*/
Long countByApiId(@Param("apiId") Long apiId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 统计API平均响应时间
*
* @param apiId API ID
* @param startTime 开始时间
* @param endTime 结束时间
* @return 平均响应时间ms
*/
Integer avgResponseTimeByApiId(@Param("apiId") Long apiId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
/**
* 统计API错误率
*
* @param apiId API ID
* @param startTime 开始时间
* @param endTime 结束时间
* @return 错误次数
*/
Long countErrorByApiId(@Param("apiId") Long apiId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime);
}

View File

@@ -0,0 +1,35 @@
package org.hzhub.erp.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import org.hzhub.erp.domain.entity.SalesOrganization;
import org.hzhub.erp.domain.vo.CustomerVO;
import java.util.List;
/**
* 销售组织 MapperOSDORG 表)
*/
@Mapper
public interface SalesOrganizationMapper extends BaseMapper<SalesOrganization> {
/**
* 获取所有销区列表(从 OSDORG 表)
* 销区通常是销售组织的第2层或第3层
*/
@Select("SELECT DISTINCT ORGCODE AS salesAreaCode, ORGNAME AS salesAreaName " +
"FROM OSDORG " +
"WHERE ORGLEVEL = 3 " + // 假设销区是第3层可根据实际情况调整
" AND ORGCODE IS NOT NULL " +
" AND ORGNAME IS NOT NULL " +
" AND ISENABLE = 1 " +
"ORDER BY ORGCODE")
List<CustomerVO> selectSalesAreasFromOrg();
/**
* 获取所有销售组织层级
*/
@Select("SELECT DISTINCT ORGLEVEL FROM OSDORG ORDER BY ORGLEVEL")
List<Integer> selectOrgLevels();
}

View File

@@ -0,0 +1,33 @@
package org.hzhub.erp.service;
import org.hzhub.erp.common.page.TableDataInfo;
import org.hzhub.erp.domain.vo.CustomerVO;
import java.util.List;
/**
* 客户档案 Service 接口
*/
public interface ICustomerService {
/**
* 分页查询客户列表
*/
TableDataInfo<CustomerVO> queryCustomerList(int pageNum, int pageSize, String keyword,
String companyCode, String salesAreaCode, String brand);
/**
* 获取客户详情
*/
CustomerVO getCustomerDetail(String customerCode);
/**
* 获取所有销区列表
*/
List<CustomerVO> getSalesAreas();
/**
* 获取所有品牌列表
*/
List<CustomerVO> getBrands();
}

View File

@@ -0,0 +1,146 @@
package org.hzhub.erp.service;
import org.hzhub.erp.common.page.TableDataInfo;
import org.hzhub.erp.domain.entity.ErpApiConfig;
import org.hzhub.erp.domain.entity.ErpApiParam;
import org.hzhub.erp.domain.vo.ApiTestResultVO;
import org.hzhub.erp.domain.vo.ErpApiConfigVO;
import java.util.List;
import java.util.Map;
/**
* ERP动态API配置Service接口
*
* @author HZHub Team
*/
public interface IErpApiService {
/**
* 分页查询API配置列表
*
* @param query 查询条件
* @param pageNum 页码
* @param pageSize 页大小
* @return 分页结果
*/
TableDataInfo<ErpApiConfigVO> queryApiConfigList(ErpApiConfigVO query, Integer pageNum, Integer pageSize);
/**
* 根据ID查询API配置
*
* @param apiId API ID
* @return API配置
*/
ErpApiConfig selectApiConfigById(Long apiId);
/**
* 根据API ID查询参数列表
*
* @param apiId API ID
* @return 参数列表
*/
List<ErpApiParam> selectApiParamsByApiId(Long apiId);
/**
* 根据路径、方法、版本查询API配置
*
* @param apiPath API路径
* @param apiMethod HTTP方法
* @param apiVersion API版本
* @return API配置
*/
ErpApiConfig selectApiConfigByPath(String apiPath, String apiMethod, String apiVersion);
/**
* 新增API配置
*
* @param config API配置
* @return 影响行数
*/
int insertApiConfig(ErpApiConfig config);
/**
* 修改API配置
*
* @param config API配置
* @return 影响行数
*/
int updateApiConfig(ErpApiConfig config);
/**
* 批量删除API配置
*
* @param apiIds API ID数组
* @return 影响行数
*/
int deleteApiConfigByIds(Long[] apiIds);
/**
* 更新API状态
*
* @param config API配置包含apiId和status
* @return 影响行数
*/
int updateApiStatus(ErpApiConfig config);
/**
* 从数据库表导入生成API配置
*
* @param tableNames 表名数组
* @param dataSource 数据源名称
*/
void importFromTable(String[] tableNames, String dataSource);
/**
* 同步表结构
*
* @param apiId API ID
*/
void syncTableStructure(Long apiId);
/**
* 测试API
*
* @param apiId API ID
* @param testParams 测试参数
* @param clientIp 客户端IP
* @param userId 用户ID
* @return 测试结果
*/
ApiTestResultVO testApi(Long apiId, Map<String, Object> testParams, String clientIp, String userId);
/**
* 生成API文档
*
* @param apiId API ID
* @return 文档内容Map
*/
Map<String, String> generateApiDoc(Long apiId);
/**
* 查询API调用统计
*
* @param apiId API ID
* @param startTime 开始时间
* @param endTime 结束时间
* @return 统计信息
*/
Map<String, Object> getApiStats(Long apiId, String startTime, String endTime);
/**
* 查询API错误日志
*
* @param apiId API ID
* @param limit 限制数量
* @return 错误日志列表
*/
List<Map<String, Object>> getApiErrorLog(Long apiId, Integer limit);
/**
* 清除API缓存
*
* @param apiId API ID
*/
void clearApiCache(Long apiId);
}

View File

@@ -0,0 +1,94 @@
package org.hzhub.erp.service.impl;
import com.baomidou.dynamic.datasource.annotation.DS;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* API统计记录服务
* 使用MySQL数据源记录API调用统计信息
*
* @author HZHub Team
*/
@Service
@DS("master") // 使用MySQL数据源记录统计信息
@RequiredArgsConstructor
public class ApiStatsRecorder {
private static final Logger log = LoggerFactory.getLogger(ApiStatsRecorder.class);
private final JdbcTemplate jdbcTemplate;
/**
* 记录API调用统计
*
* @param apiId API ID
* @param callParams 调用参数JSON字符串
* @param executedSql 实际执行的SQL语句
* @param responseTime 响应时间(毫秒)
* @param callStatus 调用状态SUCCESS/ERROR
* @param errorMessage 错误消息(可选)
* @param errorStack 错误堆栈(可选)
* @param clientIp 客户端IP
* @param userId 用户ID
*/
public void recordApiCall(Long apiId, String callParams, String executedSql,
Long responseTime, String callStatus,
String errorMessage, String errorStack,
String clientIp, String userId) {
try {
String sql = "INSERT INTO erp_api_stats " +
"(api_id, call_time, call_params, executed_sql, response_time, call_status, " +
"error_message, error_stack, client_ip, user_id, create_time) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
LocalDateTime now = LocalDateTime.now();
String callTime = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
jdbcTemplate.update(sql,
apiId,
callTime,
callParams,
executedSql,
responseTime,
callStatus,
errorMessage,
errorStack,
clientIp,
userId,
callTime
);
log.info("API调用统计记录成功: apiId={}, status={}, time={}ms",
apiId, callStatus, responseTime);
} catch (Exception e) {
// 统计记录失败不影响API执行只记录日志
log.error("记录API调用统计失败: {}", e.getMessage(), e);
}
}
/**
* 记录成功的API调用
*/
public void recordSuccess(Long apiId, String callParams, String executedSql,
Long responseTime, String clientIp, String userId) {
recordApiCall(apiId, callParams, executedSql, responseTime, "SUCCESS",
null, null, clientIp, userId);
}
/**
* 记录失败的API调用
*/
public void recordError(Long apiId, String callParams, String executedSql,
Long responseTime, String errorMessage, String errorStack,
String clientIp, String userId) {
recordApiCall(apiId, callParams, executedSql, responseTime, "ERROR",
errorMessage, errorStack, clientIp, userId);
}
}

View File

@@ -0,0 +1,46 @@
package org.hzhub.erp.service.impl;
import lombok.RequiredArgsConstructor;
import org.hzhub.erp.common.page.TableDataInfo;
import org.hzhub.erp.domain.vo.CustomerVO;
import org.hzhub.erp.mapper.CustomerMapper;
import org.hzhub.erp.mapper.SalesOrganizationMapper;
import org.hzhub.erp.service.ICustomerService;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 客户档案 Service 实现
*/
@Service
@RequiredArgsConstructor
public class CustomerServiceImpl implements ICustomerService {
private final CustomerMapper customerMapper;
private final SalesOrganizationMapper salesOrganizationMapper;
@Override
public TableDataInfo<CustomerVO> queryCustomerList(int pageNum, int pageSize, String keyword,
String companyCode, String salesAreaCode, String brand) {
long total = customerMapper.selectCustomerCount(keyword, companyCode, salesAreaCode, brand);
List<CustomerVO> list = customerMapper.selectCustomerPage(pageNum, pageSize, keyword, companyCode, salesAreaCode, brand);
return new TableDataInfo<>(list, total);
}
@Override
public CustomerVO getCustomerDetail(String customerCode) {
return customerMapper.selectCustomerDetail(customerCode);
}
@Override
public List<CustomerVO> getSalesAreas() {
// 从 OSDORG 表获取销区数据
return salesOrganizationMapper.selectSalesAreasFromOrg();
}
@Override
public List<CustomerVO> getBrands() {
return customerMapper.selectBrands();
}
}

View File

@@ -0,0 +1,326 @@
package org.hzhub.erp.service.impl;
import com.baomidou.dynamic.datasource.annotation.DS;
import lombok.RequiredArgsConstructor;
import org.hzhub.erp.common.domain.R;
import org.hzhub.erp.common.page.TableDataInfo;
import org.hzhub.erp.domain.entity.ErpApiConfig;
import org.hzhub.erp.domain.vo.ApiExecutionResult;
import org.hzhub.erp.util.SqlValidator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 动态API执行引擎
* 使用SQL Server数据源执行动态SQL查询只读
*
* @author HZHub Team
*/
@Service
@DS("erp") // 使用SQL Server数据源erp执行动态SQL
@RequiredArgsConstructor
public class DynamicApiExecutor {
private static final Logger log = LoggerFactory.getLogger(DynamicApiExecutor.class);
private final JdbcTemplate jdbcTemplate;
/**
* 执行动态API
*
* @param config API配置
* @param params 参数Map
* @return 执行结果包含数据和实际执行的SQL
*/
public ApiExecutionResult execute(ErpApiConfig config, Map<String, Object> params) {
String sqlTemplate = config.getSqlTemplate();
// 1. SQL安全验证
if (!SqlValidator.validate(sqlTemplate)) {
throw new SecurityException("SQL验证失败可能存在安全风险");
}
// 2. 处理SQL模板参数占位符转换
ProcessedSql processedSql = processSqlTemplate(sqlTemplate, params);
String processedSqlStr = processedSql.getSql();
List<Object> paramValues = processedSql.getParamValues();
log.info("执行SQL: {}", processedSqlStr);
log.info("参数值: {}", paramValues);
// 3. 根据结果类型执行
try {
Object result;
String finalExecutedSql;
switch (config.getResultType()) {
case "LIST":
if (config.getSupportPagination() == 1) {
result = executePaginatedQuery(config, processedSqlStr, paramValues);
// 分页SQL需要重新构建完整SQL
finalExecutedSql = buildFinalSqlWithParams(processedSqlStr, paramValues);
} else {
result = executeListQuery(processedSqlStr, paramValues);
finalExecutedSql = buildFinalSqlWithParams(processedSqlStr, paramValues);
}
break;
case "SINGLE":
result = executeSingleQuery(processedSqlStr, paramValues);
finalExecutedSql = buildFinalSqlWithParams(processedSqlStr, paramValues);
break;
case "COUNT":
result = executeCountQuery(processedSqlStr, paramValues);
finalExecutedSql = buildFinalSqlWithParams(processedSqlStr, paramValues);
break;
default:
throw new IllegalArgumentException("不支持的结果类型: " + config.getResultType());
}
return ApiExecutionResult.success(result, finalExecutedSql);
} catch (Exception e) {
log.error("SQL执行失败: {}", e.getMessage(), e);
throw new RuntimeException("SQL执行失败: " + e.getMessage(), e);
}
}
/**
* 构建包含参数值的最终SQL用于日志和统计
* 注意这只是展示用实际执行使用PreparedStatement
*/
private String buildFinalSqlWithParams(String sql, List<Object> paramValues) {
StringBuilder finalSql = new StringBuilder(sql);
int paramIndex = 0;
// 替换 ? 为实际参数值(仅用于展示)
for (int i = 0; i < finalSql.length() && paramIndex < paramValues.size(); i++) {
if (finalSql.charAt(i) == '?') {
Object value = paramValues.get(paramIndex++);
String valueStr;
if (value == null) {
valueStr = "NULL";
} else if (value instanceof String) {
valueStr = "'" + value + "'";
} else if (value instanceof Boolean) {
valueStr = ((Boolean) value) ? "1" : "0";
} else {
valueStr = String.valueOf(value);
}
finalSql.replace(i, i + 1, valueStr);
i += valueStr.length() - 1;
}
}
return finalSql.toString();
}
/**
* 处理SQL模板将 #{param} 转换为 ? 占位符,并提取参数值)
*
* @param sqlTemplate SQL模板
* @param params 参数Map
* @return 处理后的SQL和参数值列表
*/
private ProcessedSql processSqlTemplate(String sqlTemplate, Map<String, Object> params) {
// 正则表达式匹配 #{paramName}
Pattern pattern = Pattern.compile("#\\{(\\w+)\\}");
Matcher matcher = pattern.matcher(sqlTemplate);
List<Object> paramValues = new ArrayList<>();
StringBuffer processedSql = new StringBuffer();
while (matcher.find()) {
String paramName = matcher.group(1);
Object paramValue = params.get(paramName);
// 替换 #{param} 为 ?
matcher.appendReplacement(processedSql, "?");
// 记录参数值
paramValues.add(paramValue != null ? paramValue : null);
}
matcher.appendTail(processedSql);
// 处理 WHERE 条件中的 IS NOT NULL THEN ... 逻辑
String finalSql = processWhereConditions(processedSql.toString(), params);
return new ProcessedSql(finalSql, paramValues);
}
/**
* 处理 WHERE 条件中的动态逻辑IS NOT NULL THEN ...
* 例如WHERE #{customerCode} IS NOT NULL THEN customer_code = #{customerCode}
* 如果 customerCode 为 null则移除该条件
*
* @param sql SQL语句
* @param params 参数Map
* @return 处理后的SQL
*/
private String processWhereConditions(String sql, Map<String, Object> params) {
// 处理 #{param} IS NOT NULL THEN ... 的条件
// 这种条件格式用于动态WHERE条件只有当参数不为null时才生效
Pattern pattern = Pattern.compile("AND\\s+#\\{(\\w+)\\}\\s+IS\\s+NOT\\s+NULL\\s+THEN\\s+(.+?)(?=\\s+AND|#|$)");
Matcher matcher = pattern.matcher(sql);
StringBuffer result = new StringBuffer();
while (matcher.find()) {
String paramName = matcher.group(1);
String condition = matcher.group(2);
Object paramValue = params.get(paramName);
if (paramValue != null) {
// 参数不为null保留条件替换 #{param} 为 ?
String processedCondition = condition.replaceAll("#\\{" + paramName + "\\}", "?");
matcher.appendReplacement(result, "AND " + processedCondition);
} else {
// 参数为null移除整个条件
matcher.appendReplacement(result, "");
}
}
matcher.appendTail(result);
return result.toString();
}
/**
* 执行分页查询SQL Server语法OFFSET FETCH
*
* @param config API配置
* @param sql SQL语句
* @param paramValues 参数值列表
* @return 分页结果
*/
/**
* 执行分页查询SQL Server 2008 R2兼容版本使用ROW_NUMBER
*
* @param config API配置
* @param sql SQL语句
* @param paramValues 参数值列表
* @return 分页结果
*/
private TableDataInfo<Map<String, Object>> executePaginatedQuery(ErpApiConfig config, String sql, List<Object> paramValues) {
// 从配置中获取分页参数名
String pageParamName = config.getPageParamName() != null ? config.getPageParamName() : "pageNum";
String sizeParamName = config.getSizeParamName() != null ? config.getSizeParamName() : "pageSize";
// 注意paramValues是从SQL模板中的#{param}提取的,不包含分页参数
// 分页参数需要通过其他方式传递,这里使用默认值
// TODO: 改进参数传递,支持动态分页参数
int pageNum = 1;
int pageSize = 10;
// 查询总数
String countSql = "SELECT COUNT(*) AS total FROM (" + sql + ") AS count_query";
Long total = jdbcTemplate.queryForObject(countSql, paramValues.toArray(), Long.class);
// SQL Server 2008 R2兼容分页使用ROW_NUMBER
// 注意原始SQL不能有ORDER BYROW_NUMBER需要自己指定排序
int offset = (pageNum - 1) * pageSize;
String paginatedSql = "SELECT * FROM (" +
" SELECT ROW_NUMBER() OVER (ORDER BY (SELECT 0)) AS rn, * FROM (" +
sql +
") AS inner_query" +
") AS numbered_query WHERE rn > " + offset + " AND rn <= " + (offset + pageSize);
// 查询数据
List<Map<String, Object>> rows = jdbcTemplate.query(paginatedSql, paramValues.toArray(), new DynamicRowMapper());
// 移除ROW_NUMBER列rn字段
rows.forEach(row -> row.remove("rn"));
return new TableDataInfo<>(rows, total);
}
/**
* 执行列表查询
*
* @param sql SQL语句
* @param paramValues 参数值列表
* @return 列表结果
*/
private R<List<Map<String, Object>>> executeListQuery(String sql, List<Object> paramValues) {
List<Map<String, Object>> rows = jdbcTemplate.query(sql, paramValues.toArray(), new DynamicRowMapper());
return R.ok(rows);
}
/**
* 执行单条查询
*
* @param sql SQL语句
* @param paramValues 参数值列表
* @return 单条结果
*/
private R<Map<String, Object>> executeSingleQuery(String sql, List<Object> paramValues) {
List<Map<String, Object>> rows = jdbcTemplate.query(sql, paramValues.toArray(), new DynamicRowMapper());
if (rows.isEmpty()) {
return R.fail("未找到数据");
}
return R.ok(rows.get(0));
}
/**
* 执行计数查询
*
* @param sql SQL语句
* @param paramValues 参数值列表
* @return 计数结果
*/
private R<Long> executeCountQuery(String sql, List<Object> paramValues) {
Long count = jdbcTemplate.queryForObject(sql, paramValues.toArray(), Long.class);
return R.ok(count);
}
/**
* 动态RowMapper将SQL结果映射为Map
*/
private static class DynamicRowMapper implements RowMapper<Map<String, Object>> {
@Override
public Map<String, Object> mapRow(ResultSet rs, int rowNum) throws SQLException {
ResultSetMetaData metaData = rs.getMetaData();
int columnCount = metaData.getColumnCount();
Map<String, Object> row = new LinkedHashMap<>();
for (int i = 1; i <= columnCount; i++) {
String columnName = metaData.getColumnLabel(i);
Object value = rs.getObject(i);
row.put(columnName, value);
}
return row;
}
}
/**
* 处理后的SQL和参数值
*/
private static class ProcessedSql {
private final String sql;
private final List<Object> paramValues;
public ProcessedSql(String sql, List<Object> paramValues) {
this.sql = sql;
this.paramValues = paramValues;
}
public String getSql() {
return sql;
}
public List<Object> getParamValues() {
return paramValues;
}
}
}

View File

@@ -0,0 +1,376 @@
package org.hzhub.erp.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.dynamic.datasource.annotation.DS;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import org.hzhub.erp.common.page.TableDataInfo;
import org.hzhub.erp.domain.entity.ErpApiConfig;
import org.hzhub.erp.domain.entity.ErpApiParam;
import org.hzhub.erp.domain.vo.ApiExecutionResult;
import org.hzhub.erp.domain.vo.ApiTestResultVO;
import org.hzhub.erp.domain.vo.ErpApiConfigVO;
import org.hzhub.erp.mapper.ErpApiConfigMapper;
import org.hzhub.erp.mapper.ErpApiParamMapper;
import org.hzhub.erp.service.IErpApiService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
/**
* ERP动态API配置Service实现
* 使用MySQL数据源存储配置信息
*
* @author HZHub Team
*/
@Service
@DS("master") // 使用MySQL数据源master
@RequiredArgsConstructor
public class ErpApiServiceImpl implements IErpApiService {
private static final Logger log = LoggerFactory.getLogger(ErpApiServiceImpl.class);
private final ErpApiConfigMapper apiConfigMapper;
private final ErpApiParamMapper apiParamMapper;
private final DynamicApiExecutor dynamicApiExecutor;
private final JdbcTemplate jdbcTemplate;
private final ApiStatsRecorder apiStatsRecorder;
@Override
public TableDataInfo<ErpApiConfigVO> queryApiConfigList(ErpApiConfigVO query, Integer pageNum, Integer pageSize) {
Page<ErpApiConfig> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<ErpApiConfig> wrapper = new LambdaQueryWrapper<>();
if (StrUtil.isNotBlank(query.getApiName())) {
wrapper.like(ErpApiConfig::getApiName, query.getApiName());
}
if (StrUtil.isNotBlank(query.getApiPath())) {
wrapper.like(ErpApiConfig::getApiPath, query.getApiPath());
}
if (query.getStatus() != null) {
wrapper.eq(ErpApiConfig::getStatus, query.getStatus());
}
wrapper.orderByDesc(ErpApiConfig::getCreateTime);
IPage<ErpApiConfig> configPage = apiConfigMapper.selectPage(page, wrapper);
// 转换为VO
List<ErpApiConfigVO> voList = configPage.getRecords().stream()
.map(config -> BeanUtil.copyProperties(config, ErpApiConfigVO.class))
.collect(Collectors.toList());
return new TableDataInfo<>(voList, configPage.getTotal());
}
@Override
public ErpApiConfig selectApiConfigById(Long apiId) {
return apiConfigMapper.selectById(apiId);
}
@Override
public List<ErpApiParam> selectApiParamsByApiId(Long apiId) {
return apiParamMapper.selectByApiId(apiId);
}
@Override
public ErpApiConfig selectApiConfigByPath(String apiPath, String apiMethod, String apiVersion) {
return apiConfigMapper.selectByPathAndMethod(apiPath, apiMethod, apiVersion);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int insertApiConfig(ErpApiConfig config) {
return apiConfigMapper.insert(config);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int updateApiConfig(ErpApiConfig config) {
return apiConfigMapper.updateById(config);
}
@Override
@Transactional(rollbackFor = Exception.class)
public int deleteApiConfigByIds(Long[] apiIds) {
int count = 0;
for (Long apiId : apiIds) {
count += apiConfigMapper.deleteById(apiId);
}
return count;
}
@Override
public int updateApiStatus(ErpApiConfig config) {
ErpApiConfig updateConfig = new ErpApiConfig();
updateConfig.setApiId(config.getApiId());
updateConfig.setStatus(config.getStatus());
return apiConfigMapper.updateById(updateConfig);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void importFromTable(String[] tableNames, String dataSource) {
// TODO: 实现从表导入功能Phase 2
log.info("从表导入功能待实现: tableNames={}, dataSource={}", Arrays.toString(tableNames), dataSource);
}
@Override
public void syncTableStructure(Long apiId) {
// TODO: 实现同步表结构功能Phase 4
log.info("同步表结构功能待实现: apiId={}", apiId);
}
@Override
public ApiTestResultVO testApi(Long apiId, Map<String, Object> testParams, String clientIp, String userId) {
ErpApiConfig config = selectApiConfigById(apiId);
if (config == null) {
throw new RuntimeException("API配置不存在");
}
ApiTestResultVO result = new ApiTestResultVO();
result.setApiPath(config.getApiPath());
result.setTestMethod(config.getApiMethod());
result.setRequestParams(testParams);
long startTime = System.currentTimeMillis();
try {
ApiExecutionResult executionResult = dynamicApiExecutor.execute(config, testParams);
Object data = executionResult.getData();
String executedSql = executionResult.getExecutedSql();
long executionTime = System.currentTimeMillis() - startTime;
result.setSuccess(true);
result.setData(data);
result.setExecutionTime(executionTime);
result.setExecutedSql(executedSql);
log.info("API测试成功: apiId={}, executionTime={}ms", apiId, executionTime);
// 记录成功统计(异步记录,不影响响应)
try {
String callParamsJson = testParams.toString();
apiStatsRecorder.recordSuccess(apiId, callParamsJson, executedSql,
executionTime, clientIp, userId);
} catch (Exception statsError) {
log.warn("统计记录失败不影响API响应: {}", statsError.getMessage());
}
} catch (Exception e) {
result.setSuccess(false);
result.setErrorMessage(e.getMessage());
// 获取错误堆栈
StringBuilder stackTrace = new StringBuilder();
for (StackTraceElement element : e.getStackTrace()) {
stackTrace.append(element.toString()).append("\n");
}
result.setErrorStack(stackTrace.toString());
log.error("API测试失败: apiId={}, error={}", apiId, e.getMessage(), e);
// 记录错误统计
long executionTime = System.currentTimeMillis() - startTime;
String executedSql = config.getSqlTemplate();
try {
String callParamsJson = testParams.toString();
apiStatsRecorder.recordError(apiId, callParamsJson, executedSql,
executionTime, e.getMessage(),
stackTrace.toString(), clientIp, userId);
} catch (Exception statsError) {
log.warn("统计记录失败不影响API响应: {}", statsError.getMessage());
}
}
return result;
}
@Override
public Map<String, String> generateApiDoc(Long apiId) {
// TODO: 实现API文档生成Phase 3
ErpApiConfig config = selectApiConfigById(apiId);
List<ErpApiParam> params = selectApiParamsByApiId(apiId);
Map<String, String> docMap = new HashMap<>();
docMap.put("basic", generateBasicInfo(config));
docMap.put("params", generateParamsInfo(params));
docMap.put("sql", config.getSqlTemplate());
return docMap;
}
@Override
public Map<String, Object> getApiStats(Long apiId, String startTime, String endTime) {
// 查询统计数据
Map<String, Object> stats = new HashMap<>();
try {
// 构建SQL查询
StringBuilder sql = new StringBuilder();
sql.append("SELECT ");
sql.append("COUNT(*) AS totalCalls, ");
sql.append("SUM(CASE WHEN call_status = 'SUCCESS' THEN 1 ELSE 0 END) AS successCalls, ");
sql.append("SUM(CASE WHEN call_status = 'ERROR' THEN 1 ELSE 0 END) AS errorCalls, ");
sql.append("AVG(response_time) AS avgResponseTime, ");
sql.append("MAX(response_time) AS maxResponseTime, ");
sql.append("MIN(response_time) AS minResponseTime ");
sql.append("FROM erp_api_stats ");
sql.append("WHERE api_id = ? ");
List<Object> params = new ArrayList<>();
params.add(apiId);
if (startTime != null && !startTime.isEmpty()) {
sql.append("AND call_time >= ? ");
params.add(startTime);
}
if (endTime != null && !endTime.isEmpty()) {
sql.append("AND call_time <= ? ");
params.add(endTime);
}
log.info("查询统计数据: apiId={}, SQL={}, params={}", apiId, sql.toString(), params);
// 执行查询
Map<String, Object> result = jdbcTemplate.queryForMap(sql.toString(), params.toArray());
log.info("查询结果: {}", result);
// 处理类型转换MySQL返回类型可能不同
// COUNT返回Long或BigDecimal
Object totalCallsObj = result.get("totalCalls");
stats.put("totalCalls", convertToLong(totalCallsObj));
Object successCallsObj = result.get("successCalls");
stats.put("successCalls", convertToLong(successCallsObj));
Object errorCallsObj = result.get("errorCalls");
stats.put("errorCalls", convertToLong(errorCallsObj));
// 响应时间处理可能为null
Object avgTime = result.get("avgResponseTime");
stats.put("avgResponseTime", avgTime != null ? convertToLong(avgTime) : 0);
Object maxTime = result.get("maxResponseTime");
stats.put("maxResponseTime", maxTime != null ? convertToLong(maxTime) : 0);
Object minTime = result.get("minResponseTime");
stats.put("minResponseTime", minTime != null ? convertToLong(minTime) : 0);
// 计算错误率
Long totalCalls = (Long) stats.get("totalCalls");
Long errorCalls = (Long) stats.get("errorCalls");
Double errorRate = totalCalls > 0 ? (errorCalls * 100.0 / totalCalls) : 0.0;
stats.put("errorRate", errorRate);
log.info("统计数据返回: totalCalls={}, successCalls={}, errorCalls={}, avgTime={}ms",
totalCalls, stats.get("successCalls"), errorCalls, stats.get("avgResponseTime"));
} catch (Exception e) {
log.error("查询统计数据失败: apiId={}, error={}", apiId, e.getMessage(), e);
// 返回默认值
stats.put("totalCalls", 0);
stats.put("successCalls", 0);
stats.put("errorCalls", 0);
stats.put("avgResponseTime", 0);
stats.put("maxResponseTime", 0);
stats.put("minResponseTime", 0);
stats.put("errorRate", 0.0);
}
return stats;
}
/**
* 将数据库返回的数值转换为Long
* MySQL可能返回Long、BigDecimal或Integer
*/
private Long convertToLong(Object value) {
if (value == null) {
return 0L;
}
if (value instanceof Long) {
return (Long) value;
} else if (value instanceof java.math.BigDecimal) {
return ((java.math.BigDecimal) value).longValue();
} else if (value instanceof Integer) {
return ((Integer) value).longValue();
} else if (value instanceof Number) {
return ((Number) value).longValue();
} else {
log.warn("无法转换类型: {} -> {}", value.getClass().getName(), value);
return 0L;
}
}
@Override
public List<Map<String, Object>> getApiErrorLog(Long apiId, Integer limit) {
// 查询错误日志
try {
String sql = "SELECT stats_id, api_id, call_time, call_params, response_time, " +
"call_status, error_message, error_stack, client_ip, user_id " +
"FROM erp_api_stats " +
"WHERE api_id = ? AND call_status = 'ERROR' " +
"ORDER BY call_time DESC " +
"LIMIT ?";
List<Map<String, Object>> logs = jdbcTemplate.queryForList(sql, apiId, limit != null ? limit : 10);
return logs;
} catch (Exception e) {
log.error("查询错误日志失败: {}", e.getMessage(), e);
return new ArrayList<>();
}
}
@Override
public void clearApiCache(Long apiId) {
// TODO: 实现缓存清除Phase 4
log.info("缓存清除功能待实现: apiId={}", apiId);
}
/**
* 生成基本信息文档
*/
private String generateBasicInfo(ErpApiConfig config) {
StringBuilder sb = new StringBuilder();
sb.append("API名称: ").append(config.getApiName()).append("\n");
sb.append("API路径: ").append(config.getApiPath()).append("\n");
sb.append("HTTP方法: ").append(config.getApiMethod()).append("\n");
sb.append("描述: ").append(config.getApiDesc()).append("\n");
sb.append("版本: ").append(config.getApiVersion()).append("\n");
sb.append("结果类型: ").append(config.getResultType()).append("\n");
sb.append("支持分页: ").append(config.getSupportPagination() == 1 ? "" : "").append("\n");
return sb.toString();
}
/**
* 生成参数信息文档
*/
private String generateParamsInfo(List<ErpApiParam> params) {
if (params == null || params.isEmpty()) {
return "无参数";
}
StringBuilder sb = new StringBuilder();
sb.append("参数列表:\n");
for (ErpApiParam param : params) {
sb.append("- ").append(param.getParamName())
.append(" (").append(param.getParamType()).append(")")
.append(": ").append(param.getParamDesc())
.append(param.getIsRequired() == 1 ? " [必填]" : "")
.append("\n");
}
return sb.toString();
}
}

View File

@@ -0,0 +1,120 @@
package org.hzhub.erp.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
/**
* 参数类型转换器
*
* @author HZHub Team
*/
public class ParamTypeConverter {
private static final Logger log = LoggerFactory.getLogger(ParamTypeConverter.class);
/**
* 根据参数类型转换参数值
*
* @param paramType 参数类型String/Integer/Long/Date/Boolean
* @param value 参数值
* @return 转换后的值
*/
public static Object convert(String paramType, Object value) {
if (value == null) {
return null;
}
try {
switch (paramType.toUpperCase()) {
case "STRING":
return value.toString();
case "INTEGER":
if (value instanceof Number) {
return ((Number) value).intValue();
}
return Integer.parseInt(value.toString());
case "LONG":
if (value instanceof Number) {
return ((Number) value).longValue();
}
return Long.parseLong(value.toString());
case "DOUBLE":
if (value instanceof Number) {
return ((Number) value).doubleValue();
}
return Double.parseDouble(value.toString());
case "DATE":
return parseDate(value.toString());
case "DATETIME":
return parseDateTime(value.toString());
case "BOOLEAN":
if (value instanceof Boolean) {
return value;
}
return Boolean.parseBoolean(value.toString());
default:
log.warn("未知的参数类型: {}, 返回原值", paramType);
return value;
}
} catch (Exception e) {
log.error("参数类型转换失败: paramType={}, value={}, error={}", paramType, value, e.getMessage());
return value;
}
}
/**
* 解析日期yyyy-MM-dd
*
* @param dateStr 日期字符串
* @return Date对象
*/
private static Date parseDate(String dateStr) throws ParseException {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
return sdf.parse(dateStr);
}
/**
* 解析日期时间yyyy-MM-dd HH:mm:ss
*
* @param dateTimeStr 日期时间字符串
* @return LocalDateTime对象
*/
private static LocalDateTime parseDateTime(String dateTimeStr) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
return LocalDateTime.parse(dateTimeStr, formatter);
}
/**
* 判断是否是有效的参数类型
*
* @param paramType 参数类型
* @return 是否有效
*/
public static boolean isValidType(String paramType) {
if (paramType == null || paramType.trim().isEmpty()) {
return false;
}
String upperType = paramType.toUpperCase();
return upperType.equals("STRING")
|| upperType.equals("INTEGER")
|| upperType.equals("LONG")
|| upperType.equals("DOUBLE")
|| upperType.equals("DATE")
|| upperType.equals("DATETIME")
|| upperType.equals("BOOLEAN");
}
}

View File

@@ -0,0 +1,118 @@
package org.hzhub.erp.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
/**
* SQL安全验证工具
*
* @author HZHub Team
*/
public class SqlValidator {
private static final Logger log = LoggerFactory.getLogger(SqlValidator.class);
/**
* SQL白名单关键字允许的SQL关键字
*/
private static final Set<String> ALLOWED_KEYWORDS = new HashSet<>(Arrays.asList(
"SELECT", "FROM", "WHERE", "AND", "OR", "ORDER", "BY", "GROUP", "HAVING",
"OFFSET", "FETCH", "NEXT", "ROWS", "ONLY", "AS", "COUNT", "SUM", "AVG",
"MIN", "MAX", "DISTINCT", "TOP", "INNER", "LEFT", "RIGHT", "JOIN", "ON",
"IS", "NULL", "NOT", "IN", "LIKE", "BETWEEN", "EXISTS", "CASE", "WHEN",
"THEN", "ELSE", "END", "UNION", "ALL", "WITH", "OVER", "PARTITION"
));
/**
* SQL危险关键字禁止的SQL关键字
*/
private static final Set<String> DANGEROUS_KEYWORDS = new HashSet<>(Arrays.asList(
"DROP", "DELETE", "TRUNCATE", "ALTER", "CREATE", "GRANT", "REVOKE",
"INSERT", "UPDATE", "EXEC", "EXECUTE", "MERGE", "CALL"
));
/**
* 验证SQL安全性
*
* @param sql SQL语句
* @return 是否安全
*/
public static boolean validate(String sql) {
if (sql == null || sql.trim().isEmpty()) {
return false;
}
// 提取SQL关键字
String[] words = sql.toUpperCase().split("\\s+");
// 检查危险关键字
for (String word : words) {
if (DANGEROUS_KEYWORDS.contains(word)) {
log.error("SQL包含危险关键字: {}", word);
return false;
}
}
// 检查是否以SELECT开头只允许查询语句
String upperSql = sql.trim().toUpperCase();
if (!upperSql.startsWith("SELECT")) {
log.error("SQL不是查询语句必须以SELECT开头");
return false;
}
// 检查分号防止多条SQL注入
if (sql.contains(";") && !sql.trim().endsWith(";")) {
log.error("SQL包含多个语句分号可能存在注入风险");
return false;
}
return true;
}
/**
* 检查是否包含参数占位符
*
* @param sql SQL语句
* @return 是否包含参数占位符 #{paramName}
*/
public static boolean containsParams(String sql) {
return sql != null && sql.contains("#{") && sql.contains("}");
}
/**
* 提取参数名称列表
*
* @param sql SQL语句
* @return 参数名称列表
*/
public static Set<String> extractParamNames(String sql) {
Set<String> paramNames = new HashSet<>();
if (sql == null) {
return paramNames;
}
// 提取 #{paramName} 中的参数名
int start = 0;
while (start < sql.length()) {
int begin = sql.indexOf("#{", start);
if (begin == -1) {
break;
}
int end = sql.indexOf("}", begin);
if (end == -1) {
break;
}
String paramName = sql.substring(begin + 2, end).trim();
paramNames.add(paramName);
start = end + 1;
}
return paramNames;
}
}

View File

@@ -0,0 +1,17 @@
# MyBatis SQL 日志输出
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 日志级别
logging:
level:
org.hzhub.erp: debug
com.zaxxer.hikari: debug
# 开发环境数据源(覆盖 application.yml 中的占位符)
spring:
datasource:
url: jdbc:sqlserver://192.168.120.10:8042;databaseName=DMPF_HY;encrypt=false;trustServerCertificate=true;loginTimeout=10
username: aiuser
password: aiuser123

View File

@@ -4,43 +4,48 @@ server:
spring:
application:
name: hzhub-erp
# 多数据源配置
datasource:
# 主数据源 - SQL Server 2008 R2 (ERP直连)
primary:
url: jdbc:sqlserver://192.168.x.x:1433;database=ERP;encrypt=false;trustServerCertificate=true
username: ${ERP_DB_USERNAME:sa}
password: ${ERP_DB_PASSWORD:}
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 30000
# secondary数据源 - MySQL (预留)
secondary:
url: jdbc:mysql://localhost:3306/hzhub_erp?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 5
# MyBatis Plus配置
datasource:
dynamic:
primary: master # 设置默认数据源为masterMySQL
strict: false # 允许非严格匹配
datasource:
# MySQL数据源 - 用于存储API配置、参数、统计信息
master:
url: jdbc:mysql://${MYSQL_HOST:192.168.120.60}:${MYSQL_PORT:3306}/hzhub?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${MYSQL_USERNAME:root}
password: ${MYSQL_PASSWORD:hzhub123}
driver-class-name: com.mysql.cj.jdbc.Driver
# SQL Server数据源 - 用于执行ERP动态SQL查询只读
erp:
url: jdbc:sqlserver://${ERP_DB_HOST:192.168.120.10}:${ERP_DB_PORT:8042};databaseName=${ERP_DB_NAME:DMPF_HY};encrypt=false;trustServerCertificate=true
username: ${ERP_DB_USERNAME:aiuser}
password: ${ERP_DB_PASSWORD:aiuser123}
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
jackson:
date-format: yyyy-MM-dd HH:mm:ss
serialization:
indent_output: false
fail_on_empty_beans: false
deserialization:
fail_on_unknown_properties: false
profiles:
active: dev
# MyBatis-Plus 配置
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
mapper-locations: classpath*:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
# Sa-Token配置
# Sa-Token 配置JWT secret 需与 hzhub-ai 一致以支持跨服务 Token 验证)
sa-token:
token-name: Authorization
timeout: 86400
@@ -49,9 +54,22 @@ sa-token:
is-share: false
token-style: uuid
is-log: false
jwt-secret-key: ${ERP_JWT_SECRET:abcdefghijklmnopqrstuvwxyz}
token-prefix: "Bearer"
is-read-header: true
is-read-cookie: false
# 开发阶段:关闭认证检查
is-read-body: false
check-id-token: false
is-token-header: false
is-token-cookie: false
# 日志配置
logging:
level:
com.foshanhuiya.erp: debug
org.springframework.jdbc: debug
# Actuator 健康检查
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: always

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.hzhub.erp.mapper.ErpApiConfigMapper">
<resultMap id="ErpApiConfigResult" type="org.hzhub.erp.domain.entity.ErpApiConfig">
<id property="apiId" column="api_id"/>
<result property="apiName" column="api_name"/>
<result property="apiPath" column="api_path"/>
<result property="apiMethod" column="api_method"/>
<result property="apiDesc" column="api_desc"/>
<result property="apiVersion" column="api_version"/>
<result property="dataSource" column="data_source"/>
<result property="sqlTemplate" column="sql_template"/>
<result property="resultType" column="result_type"/>
<result property="supportPagination" column="support_pagination"/>
<result property="pageParamName" column="page_param_name"/>
<result property="sizeParamName" column="size_param_name"/>
<result property="requireAuth" column="require_auth"/>
<result property="permissionCode" column="permission_code"/>
<result property="enableCache" column="enable_cache"/>
<result property="cacheKeyTemplate" column="cache_key_template"/>
<result property="cacheTtl" column="cache_ttl"/>
<result property="sourceTable" column="source_table"/>
<result property="sourceTableComment" column="source_table_comment"/>
<result property="status" column="status"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
<result property="createBy" column="create_by"/>
<result property="updateBy" column="update_by"/>
<result property="remark" column="remark"/>
</resultMap>
<sql id="selectErpApiConfigVo">
select api_id, api_name, api_path, api_method, api_desc, api_version, data_source,
sql_template, result_type, support_pagination, page_param_name, size_param_name,
require_auth, permission_code, enable_cache, cache_key_template, cache_ttl,
source_table, source_table_comment, status, create_time, update_time, create_by, update_by, remark
from erp_api_config
</sql>
<select id="selectByPathAndMethod" parameterType="String" resultMap="ErpApiConfigResult">
<include refid="selectErpApiConfigVo"/>
where api_path = #{apiPath} and api_method = #{apiMethod} and api_version = #{apiVersion} and status = 1
</select>
</mapper>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.hzhub.erp.mapper.ErpApiParamMapper">
<resultMap id="ErpApiParamResult" type="org.hzhub.erp.domain.entity.ErpApiParam">
<id property="paramId" column="param_id"/>
<result property="apiId" column="api_id"/>
<result property="paramName" column="param_name"/>
<result property="paramDesc" column="param_desc"/>
<result property="paramType" column="param_type"/>
<result property="paramPosition" column="param_position"/>
<result property="isRequired" column="is_required"/>
<result property="defaultValue" column="default_value"/>
<result property="sqlParamName" column="sql_param_name"/>
<result property="sort" column="sort"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<sql id="selectErpApiParamVo">
select param_id, api_id, param_name, param_desc, param_type, param_position,
is_required, default_value, sql_param_name, sort, create_time, update_time
from erp_api_param
</sql>
<select id="selectByApiId" parameterType="Long" resultMap="ErpApiParamResult">
<include refid="selectErpApiParamVo"/>
where api_id = #{apiId}
order by sort asc
</select>
<insert id="batchInsert" parameterType="java.util.List">
insert into erp_api_param(api_id, param_name, param_desc, param_type, param_position,
is_required, default_value, sql_param_name, sort, create_time)
values
<foreach collection="params" item="param" separator=",">
(#{param.apiId}, #{param.paramName}, #{param.paramDesc}, #{param.paramType}, #{param.paramPosition},
#{param.isRequired}, #{param.defaultValue}, #{param.sqlParamName}, #{param.sort}, now())
</foreach>
</insert>
<delete id="deleteByApiId" parameterType="Long">
delete from erp_api_param where api_id = #{apiId}
</delete>
</mapper>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.hzhub.erp.mapper.ErpApiStatsMapper">
<resultMap id="ErpApiStatsResult" type="org.hzhub.erp.domain.entity.ErpApiStats">
<id property="statsId" column="stats_id"/>
<result property="apiId" column="api_id"/>
<result property="callTime" column="call_time"/>
<result property="callParams" column="call_params"/>
<result property="responseTime" column="response_time"/>
<result property="callStatus" column="call_status"/>
<result property="errorMessage" column="error_message"/>
<result property="errorStack" column="error_stack"/>
<result property="clientIp" column="client_ip"/>
<result property="userId" column="user_id"/>
<result property="createTime" column="create_time"/>
</resultMap>
<sql id="selectErpApiStatsVo">
select stats_id, api_id, call_time, call_params, response_time, call_status,
error_message, error_stack, client_ip, user_id, create_time
from erp_api_stats
</sql>
<select id="selectByApiIdAndTime" resultMap="ErpApiStatsResult">
<include refid="selectErpApiStatsVo"/>
where api_id = #{apiId}
and call_time between #{startTime} and #{endTime}
order by call_time desc
</select>
<select id="selectErrorLogByApiId" resultMap="ErpApiStatsResult">
<include refid="selectErpApiStatsVo"/>
where api_id = #{apiId}
and call_status = 'ERROR'
order by call_time desc
limit #{limit}
</select>
<select id="countByApiId" resultType="Long">
select count(*)
from erp_api_stats
where api_id = #{apiId}
and call_time between #{startTime} and #{endTime}
</select>
<select id="avgResponseTimeByApiId" resultType="Integer">
select avg(response_time)
from erp_api_stats
where api_id = #{apiId}
and call_time between #{startTime} and #{endTime}
and call_status = 'SUCCESS'
</select>
<select id="countErrorByApiId" resultType="Long">
select count(*)
from erp_api_stats
where api_id = #{apiId}
and call_time between #{startTime} and #{endTime}
and call_status = 'ERROR'
</select>
</mapper>

18
hzhub-erp/start.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/bin/bash
# SQL Server 2008 R2 兼容启动脚本
# Java 17 默认禁用了 TLSv1/TLSv1.1SQL Server 2008 R2 需要这些协议
cd "$(dirname "$0")"
mkdir -p logs
echo "Starting hzhub-erp (SQL Server 2008 R2 compatible mode)..."
nohup mvn spring-boot:run \
-Dspring-boot.run.profiles=dev \
-Dspring-boot.run.jvmArguments="-Djava.security.properties=$(pwd)/override.security" \
> logs/erp.log 2>&1 &
echo $! > logs/erp.pid
echo "hzhub-erp started with PID $(cat logs/erp.pid)"
echo "View logs with: tail -f logs/erp.log"

36
hzhub-erp/status.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
# hzhub-erp 状态检查脚本
cd "$(dirname "$0")"
PID_FILE="logs/erp.pid"
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
PORT=$(netstat -tulnp 2>/dev/null | grep ":8082 " | grep "$PID" | awk '{print $4}' | cut -d: -f2)
if [ -z "$PORT" ]; then
PORT=$(ss -tulnp 2>/dev/null | grep ":8082 " | grep "pid=$PID" | awk '{print $5}' | cut -d: -f2)
fi
echo "hzhub-erp is running[1]."
echo " PID: $PID"
if [ -n "$PORT" ]; then
echo " Port: $PORT"
else
echo " Port: 8082 (checking...)"
fi
else
echo "hzhub-erp is not running (stale PID file: $PID)."
rm -f "$PID_FILE"
fi
else
# Fallback: find by process name
PID=$(ps aux | grep '[h]zhub-erp\|[h]zhub.erp' | grep -v grep | awk '{print $2}')
if [ -n "$PID" ]; then
echo "hzhub-erp is running[2]."
echo " PID: $PID"
echo " Port: 8082"
else
echo "hzhub-erp is not running."
fi
fi

40
hzhub-erp/stop.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
# hzhub-erp 停止脚本
cd "$(dirname "$0")"
PID_FILE="logs/erp.pid"
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
echo "Stopping hzhub-erp (PID: $PID)..."
kill "$PID"
# Wait up to 10 seconds for graceful shutdown
for i in $(seq 1 10); do
if ! kill -0 "$PID" 2>/dev/null; then
echo "hzhub-erp stopped."
rm -f "$PID_FILE"
exit 0
fi
sleep 1
done
echo "Process did not stop gracefully, force killing..."
kill -9 "$PID" 2>/dev/null
rm -f "$PID_FILE"
echo "hzhub-erp force stopped."
else
echo "Process $PID is not running, cleaning up PID file."
rm -f "$PID_FILE"
fi
else
# Fallback: find by process name
PID=$(ps aux | grep '[h]zhub-erp\|[h]zhub.erp' | grep -v grep | awk '{print $2}')
if [ -n "$PID" ]; then
echo "Stopping hzhub-erp (PID: $PID)..."
kill "$PID"
echo "hzhub-erp stopped."
else
echo "hzhub-erp is not running."
fi
fi