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

0
hzhub-ai/hzhub-admin/Dockerfile Normal file → Executable file
View File

19
hzhub-ai/hzhub-admin/pom.xml Normal file → Executable file
View File

@@ -23,6 +23,11 @@
<artifactId>mysql-connector-j</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- &lt;!&ndash; mp支持的数据库均支持 只需要增加对应的jdbc依赖即可 &ndash;&gt;-->
<!-- &lt;!&ndash; Oracle &ndash;&gt;-->
<!-- <dependency>-->
@@ -67,26 +72,16 @@
<dependency>
<groupId>org.hzhub</groupId>
<artifactId>hzhub-system</artifactId>
<artifactId>hzhub-common-oss</artifactId>
</dependency>
<!-- 代码生成-->
<dependency>
<groupId>org.hzhub</groupId>
<artifactId>hzhub-generator</artifactId>
</dependency>
<!-- hzhub-system / hzhub-workflow / hzhub-generator 已迁移至独立服务 -->
<dependency>
<groupId>org.hzhub</groupId>
<artifactId>hzhub-chat</artifactId>
</dependency>
<!-- 工作流模块 -->
<dependency>
<groupId>org.hzhub</groupId>
<artifactId>hzhub-workflow</artifactId>
</dependency>
<!-- AI流程编排模块 -->
<dependency>
<groupId>org.hzhub</groupId>

View File

View File

@@ -1,238 +0,0 @@
package org.hzhub.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import me.zhyd.oauth.model.AuthResponse;
import me.zhyd.oauth.model.AuthUser;
import me.zhyd.oauth.request.AuthRequest;
import me.zhyd.oauth.utils.AuthStateUtils;
import org.hzhub.common.core.constant.SystemConstants;
import org.hzhub.common.core.domain.R;
import org.hzhub.common.core.domain.model.LoginBody;
import org.hzhub.common.core.domain.model.RegisterBody;
import org.hzhub.common.core.domain.model.SocialLoginBody;
import org.hzhub.common.core.utils.*;
import org.hzhub.common.encrypt.annotation.ApiEncrypt;
import org.hzhub.common.json.utils.JsonUtils;
import org.hzhub.common.ratelimiter.annotation.RateLimiter;
import org.hzhub.common.ratelimiter.enums.LimitType;
import org.hzhub.common.satoken.utils.LoginHelper;
import org.hzhub.common.social.config.properties.SocialLoginConfigProperties;
import org.hzhub.common.social.config.properties.SocialProperties;
import org.hzhub.common.social.utils.SocialUtils;
import org.hzhub.common.sse.dto.SseMessageDto;
import org.hzhub.common.sse.utils.SseMessageUtils;
import org.hzhub.common.tenant.helper.TenantHelper;
import org.hzhub.system.domain.bo.SysTenantBo;
import org.hzhub.system.domain.vo.*;
import org.hzhub.system.service.*;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* 认证
*
* @author Lion Li
*/
@Slf4j
@SaIgnore
@RequiredArgsConstructor
@RestController
@RequestMapping("/auth")
public class AuthController {
private final SocialProperties socialProperties;
private final SysLoginService loginService;
private final SysRegisterService registerService;
private final ISysConfigService configService;
private final ISysTenantService tenantService;
private final ISysSocialService socialUserService;
private final ISysClientService clientService;
private final ScheduledExecutorService scheduledExecutorService;
/**
* 登录方法
*
* @param body 登录信息
* @return 结果
*/
@ApiEncrypt
@PostMapping("/login")
public R<LoginVo> login(@RequestBody String body) {
LoginBody loginBody = JsonUtils.parseObject(body, LoginBody.class);
ValidatorUtils.validate(loginBody);
// 授权类型和客户端id
String clientId = loginBody.getClientId();
String grantType = loginBody.getGrantType();
log.info("登录请求 - clientId: {}, grantType: {}", clientId, grantType);
SysClientVo client = clientService.queryByClientId(clientId);
log.info("查询客户端结果 - client: {}, grantType: {}", client, client != null ? client.getGrantType() : "null");
// 查询不到 client 或 client 内不包含 grantType
if (ObjectUtil.isNull(client)) {
log.info("客户端id: {} 不存在!", clientId);
return R.fail(MessageUtils.message("auth.grant.type.error"));
}
if (!StringUtils.contains(client.getGrantType(), grantType)) {
log.info("客户端id: {} 认证类型:{} 不匹配! 数据库grantType: {}", clientId, grantType, client.getGrantType());
return R.fail(MessageUtils.message("auth.grant.type.error"));
} else if (!SystemConstants.NORMAL.equals(client.getStatus())) {
return R.fail(MessageUtils.message("auth.grant.type.blocked"));
}
// 校验租户
loginService.checkTenant(loginBody.getTenantId());
// 登录
LoginVo loginVo = IAuthStrategy.login(body, client, grantType);
Long userId = LoginHelper.getUserId();
scheduledExecutorService.schedule(() -> {
SseMessageDto dto = new SseMessageDto();
dto.setMessage("欢迎登录hzhub-ai后台管理系统");
dto.setUserIds(List.of(userId));
SseMessageUtils.publishMessage(dto);
}, 5, TimeUnit.SECONDS);
return R.ok(loginVo);
}
/**
* 获取跳转URL
*
* @param source 登录来源
* @return 结果
*/
@GetMapping("/binding/{source}")
public R<String> authBinding(@PathVariable("source") String source,
@RequestParam String tenantId, @RequestParam String domain) {
SocialLoginConfigProperties obj = socialProperties.getType().get(source);
if (ObjectUtil.isNull(obj)) {
return R.fail(source + "平台账号暂不支持");
}
AuthRequest authRequest = SocialUtils.getAuthRequest(source, socialProperties);
Map<String, String> map = new HashMap<>();
map.put("tenantId", tenantId);
map.put("domain", domain);
map.put("state", AuthStateUtils.createState());
String authorizeUrl = authRequest.authorize(Base64.encode(JsonUtils.toJsonString(map), StandardCharsets.UTF_8));
return R.ok("操作成功", authorizeUrl);
}
/**
* 前端回调绑定授权(需要token)
*
* @param loginBody 请求体
* @return 结果
*/
@PostMapping("/social/callback")
public R<Void> socialCallback(@RequestBody SocialLoginBody loginBody) {
// 校验token
StpUtil.checkLogin();
// 获取第三方登录信息
AuthResponse<AuthUser> response = SocialUtils.loginAuth(
loginBody.getSource(), loginBody.getSocialCode(),
loginBody.getSocialState(), socialProperties);
AuthUser authUserData = response.getData();
// 判断授权响应是否成功
if (!response.ok()) {
return R.fail(response.getMsg());
}
loginService.socialRegister(authUserData);
return R.ok();
}
/**
* 取消授权(需要token)
*
* @param socialId socialId
*/
@DeleteMapping(value = "/unlock/{socialId}")
public R<Void> unlockSocial(@PathVariable Long socialId) {
// 校验token
StpUtil.checkLogin();
Boolean rows = socialUserService.deleteWithValidById(socialId);
return rows ? R.ok() : R.fail("取消授权失败");
}
/**
* 退出登录
*/
@PostMapping("/logout")
public R<Void> logout() {
loginService.logout();
return R.ok("退出成功");
}
/**
* 用户注册
*/
@ApiEncrypt
@PostMapping("/register")
public R<Void> register(@Validated @RequestBody RegisterBody user) {
if (!configService.selectRegisterEnabled(user.getTenantId())) {
return R.fail("当前系统没有开启注册功能!");
}
registerService.register(user);
return R.ok();
}
/**
* 登录页面租户下拉框
*
* @return 租户列表
*/
@RateLimiter(time = 60, count = 20, limitType = LimitType.IP)
@GetMapping("/tenant/list")
public R<LoginTenantVo> tenantList(HttpServletRequest request) throws Exception {
// 返回对象
LoginTenantVo result = new LoginTenantVo();
boolean enable = TenantHelper.isEnable();
result.setTenantEnabled(enable);
// 如果未开启租户这直接返回
if (!enable) {
return R.ok(result);
}
List<SysTenantVo> tenantList = tenantService.queryList(new SysTenantBo());
List<TenantListVo> voList = MapstructUtils.convert(tenantList, TenantListVo.class);
try {
// 如果只超管返回所有租户
if (LoginHelper.isSuperAdmin()) {
result.setVoList(voList);
return R.ok(result);
}
} catch (NotLoginException ignored) {
}
// 获取域名
String host;
String referer = request.getHeader("referer");
if (StringUtils.isNotBlank(referer)) {
// 这里从referer中取值是为了本地使用hosts添加虚拟域名方便本地环境调试
host = referer.split("//")[1].split("/")[0];
} else {
host = new URL(request.getRequestURL().toString()).getHost();
}
// 根据域名进行筛选
List<TenantListVo> list = StreamUtils.filter(voList, vo ->
StringUtils.equalsIgnoreCase(vo.getDomain(), host));
result.setVoList(CollUtil.isNotEmpty(list) ? list : voList);
return R.ok(result);
}
}

View File

@@ -1,157 +0,0 @@
package org.hzhub.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.hutool.captcha.AbstractCaptcha;
import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.hzhub.common.core.constant.Constants;
import org.hzhub.common.core.constant.GlobalConstants;
import org.hzhub.common.core.domain.R;
import org.hzhub.common.core.exception.ServiceException;
import org.hzhub.common.core.utils.SpringUtils;
import org.hzhub.common.core.utils.StringUtils;
import org.hzhub.common.core.utils.reflect.ReflectUtils;
import org.hzhub.common.mail.config.properties.MailProperties;
import org.hzhub.common.mail.utils.MailUtils;
import org.hzhub.common.ratelimiter.annotation.RateLimiter;
import org.hzhub.common.ratelimiter.enums.LimitType;
import org.hzhub.common.redis.utils.RedisUtils;
import org.hzhub.common.web.config.properties.CaptchaProperties;
import org.hzhub.common.web.enums.CaptchaType;
import org.dromara.sms4j.api.SmsBlend;
import org.dromara.sms4j.api.entity.SmsResponse;
import org.dromara.sms4j.core.factory.SmsFactory;
import org.hzhub.system.domain.vo.CaptchaVo;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.util.LinkedHashMap;
/**
* 验证码操作处理
*
* @author Lion Li
*/
@SaIgnore
@Slf4j
@Validated
@RequiredArgsConstructor
@RestController
public class CaptchaController {
private final CaptchaProperties captchaProperties;
private final MailProperties mailProperties;
/**
* 短信验证码
*
* @param phonenumber 用户手机号
*/
@RateLimiter(key = "#phonenumber", time = 60, count = 1)
@GetMapping("/resource/sms/code")
public R<Void> smsCode(@NotBlank(message = "{user.phonenumber.not.blank}") String phonenumber) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + phonenumber;
String code = RandomUtil.randomNumbers(4);
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
// 验证码模板id 自行处理 (查数据库或写死均可)
String templateId = "";
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
map.put("code", code);
SmsBlend smsBlend = SmsFactory.getSmsBlend("config1");
SmsResponse smsResponse = smsBlend.sendMessage(phonenumber, templateId, map);
if (!smsResponse.isSuccess()) {
log.error("验证码短信发送异常 => {}", smsResponse);
return R.fail(smsResponse.getData().toString());
}
return R.ok();
}
/**
* 邮箱验证码
*
* @param email 邮箱
*/
@GetMapping("/resource/email/code")
public R<Void> emailCode(@NotBlank(message = "{user.email.not.blank}") String email) {
if (!mailProperties.getEnabled()) {
return R.fail("当前系统没有开启邮箱功能!");
}
SpringUtils.getAopProxy(this).emailCodeImpl(email);
return R.ok();
}
/**
* 邮箱验证码
* 独立方法避免验证码关闭之后仍然走限流
*/
@RateLimiter(key = "#email", time = 60, count = 1)
public void emailCodeImpl(String email) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + email;
String code = RandomUtil.randomNumbers(4);
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
try {
MailUtils.sendText(email, "登录验证码", "您本次验证码为:" + code + ",有效性为" + Constants.CAPTCHA_EXPIRATION + "分钟,请尽快填写。");
} catch (Exception e) {
log.error("验证码短信发送异常 => {}", e.getMessage());
throw new ServiceException(e.getMessage());
}
}
/**
* 生成验证码
*/
@GetMapping("/auth/code")
public R<CaptchaVo> getCode() {
boolean captchaEnabled = captchaProperties.getEnable();
if (!captchaEnabled) {
CaptchaVo captchaVo = new CaptchaVo();
captchaVo.setCaptchaEnabled(false);
return R.ok(captchaVo);
}
return R.ok(SpringUtils.getAopProxy(this).getCodeImpl());
}
/**
* 生成验证码
* 独立方法避免验证码关闭之后仍然走限流
*/
@RateLimiter(time = 60, count = 10, limitType = LimitType.IP)
public CaptchaVo getCodeImpl() {
// 保存验证码信息
String uuid = IdUtil.simpleUUID();
String verifyKey = GlobalConstants.CAPTCHA_CODE_KEY + uuid;
// 生成验证码
CaptchaType captchaType = captchaProperties.getType();
CodeGenerator codeGenerator;
if (CaptchaType.MATH == captchaType) {
codeGenerator = ReflectUtils.newInstance(captchaType.getClazz(), captchaProperties.getNumberLength(), false);
} else {
codeGenerator = ReflectUtils.newInstance(captchaType.getClazz(), captchaProperties.getCharLength());
}
AbstractCaptcha captcha = SpringUtils.getBean(captchaProperties.getCategory().getClazz());
captcha.setGenerator(codeGenerator);
captcha.createCode();
// 如果是数学验证码使用SpEL表达式处理验证码结果
String code = captcha.getCode();
if (CaptchaType.MATH == captchaType) {
ExpressionParser parser = new SpelExpressionParser();
Expression exp = parser.parseExpression(StringUtils.remove(code, "="));
code = exp.getValue(String.class);
}
RedisUtils.setCacheObject(verifyKey, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
CaptchaVo captchaVo = new CaptchaVo();
captchaVo.setUuid(uuid);
captchaVo.setImg(captcha.getImageBase64());
return captchaVo;
}
}

View File

@@ -0,0 +1,117 @@
package org.hzhub.service;
import cn.hutool.core.util.RandomUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.hzhub.common.core.domain.dto.OssDTO;
import org.hzhub.common.core.exception.ServiceException;
import org.hzhub.common.core.service.OssService;
import org.hzhub.common.core.utils.StringUtils;
import org.hzhub.common.core.utils.file.ContentTypeUtil;
import org.hzhub.common.oss.core.OssClient;
import org.hzhub.common.oss.entity.UploadResult;
import org.hzhub.common.oss.factory.OssFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
/**
* 简易 OSS 服务实现(用于 hzhub-ai
* 上传文件至 OSS文件信息存储至 sys_oss 表
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class OssServiceImpl implements OssService {
private final JdbcTemplate jdbcTemplate;
@Override
public OssDTO uploadFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new ServiceException("上传文件不能为空");
}
String originalFileName = file.getOriginalFilename();
String suffix = StringUtils.substring(originalFileName, originalFileName.lastIndexOf("."));
OssClient storage = OssFactory.instance();
UploadResult uploadResult;
try {
uploadResult = storage.uploadSuffix(file.getBytes(), suffix, ContentTypeUtil.getContentType(suffix, file.getContentType()));
} catch (IOException e) {
throw new ServiceException("上传失败: " + e.getMessage());
}
// 保存到 sys_oss 表
Long ossId = saveOssRecord(originalFileName, suffix, storage.getConfigKey(), uploadResult, file.getSize(), file.getContentType());
OssDTO dto = new OssDTO();
dto.setOssId(ossId);
dto.setFileName(uploadResult.getFilename());
dto.setOriginalName(originalFileName);
dto.setFileSuffix(suffix);
dto.setUrl(uploadResult.getUrl());
return dto;
}
@Override
public String selectUrlByIds(String ossIds) {
if (StringUtils.isBlank(ossIds)) return "";
List<String> urls = new ArrayList<>();
for (String idStr : ossIds.split(",")) {
idStr = idStr.trim();
if (StringUtils.isBlank(idStr)) continue;
try {
String url = jdbcTemplate.queryForObject(
"SELECT url FROM sys_oss WHERE oss_id = ?", String.class, Long.parseLong(idStr));
if (url != null) {
urls.add(url);
}
} catch (Exception e) {
log.warn("查询 OSS URL 失败: ossId={}", idStr, e);
}
}
return StringUtils.joinComma(urls);
}
@Override
public List<OssDTO> selectByIds(String ossIds) {
if (StringUtils.isBlank(ossIds)) return List.of();
List<OssDTO> result = new ArrayList<>();
for (String idStr : ossIds.split(",")) {
idStr = idStr.trim();
if (StringUtils.isBlank(idStr)) continue;
try {
List<OssDTO> rows = jdbcTemplate.query(
"SELECT oss_id, file_name, original_name, file_suffix, url FROM sys_oss WHERE oss_id = ?",
(rs, rowNum) -> {
OssDTO dto = new OssDTO();
dto.setOssId(rs.getLong("oss_id"));
dto.setFileName(rs.getString("file_name"));
dto.setOriginalName(rs.getString("original_name"));
dto.setFileSuffix(rs.getString("file_suffix"));
dto.setUrl(rs.getString("url"));
return dto;
},
Long.parseLong(idStr));
if (!rows.isEmpty()) {
result.add(rows.get(0));
}
} catch (Exception e) {
log.warn("查询 OSS 记录失败: ossId={}", idStr, e);
}
}
return result;
}
private Long saveOssRecord(String originalName, String suffix, String configKey, UploadResult result, long size, String contentType) {
Long ossId = RandomUtil.randomLong(Long.MAX_VALUE);
String extJson = String.format("{\"fileSize\":%d,\"contentType\":\"%s\"}", size, contentType);
jdbcTemplate.update(
"INSERT INTO sys_oss (oss_id, url, file_name, original_name, file_suffix, service, ext1) VALUES (?, ?, ?, ?, ?, ?, ?)",
ossId, result.getUrl(), result.getFilename(), originalName, suffix, configKey, extJson);
return ossId;
}
}

View File

View File

12
hzhub-ai/hzhub-admin/src/main/resources/application.yml Normal file → Executable file
View File

@@ -108,8 +108,8 @@ sa-token:
is-concurrent: true
# 在多人登录同一账号时是否共用一个token (为true时所有登录共用一个token, 为false时每次登录新建一个token)
is-share: false
# jwt秘钥
jwt-secret-key: abcdefghijklmnopqrstuvwxyz
# jwt秘钥(必须 >= 32 字节)
jwt-secret-key: ${JWT_SECRET:Om1fovSeKIA1oLIoHdDPMF-trbqbrPQoDS3H4u1xoRY}
# security配置
security:
@@ -210,13 +210,7 @@ springdoc:
packages-to-scan: org.hzhub.demo
- group: 2.通用模块
packages-to-scan: org.hzhub.web
- group: 3.系统模块
packages-to-scan: org.hzhub.system
- group: 4.代码生成模块
packages-to-scan: org.hzhub.generator
- group: 5.工作流模块
packages-to-scan: org.hzhub.workflow
- group: 6.MCP模块
- group: 3.MCP模块
packages-to-scan: org.hzhub.mcp
# 防止XSS攻击

0
hzhub-ai/hzhub-admin/src/main/resources/banner.txt Normal file → Executable file
View File

View File

View File

View File

0
hzhub-ai/hzhub-admin/src/main/resources/ip2region.xdb Normal file → Executable file
View File

View File