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

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>