Compare commits

..

285 Commits

Author SHA1 Message Date
f6b5ef06d9 feat(codegen):uniapp 表单生成器对齐 @wot-ui/ui 2.x + 修测试快照撞名
form 模板全量对齐 2.x:
- :rules/formRules → :schema/createFormSchema(2.x wd-form 只认 schema)
- 字段统一包 wd-form-item;下拉字典 → yd-form-picker 独立;radio shape→type
- 日期时间 → wd-form-item 触发 + wd-datetime-picker 弹层 + xxxVisible;
  LocalDateTime 用 formatDateTime、Date/LocalDate 用 formatDate(import 按需)
- 图片/文件上传 → yd-upload-img/file(原误生成文本框);小数补 precision
- 包名 wot-design-uni(未装)→ @wot-ui/ui(form + detail)

search-form:日期范围 → yd-search-date-range;radio shape→type
字典 import 按实际用量门控(纯 select 不越界)
writeResult:同名快照撞名补父目录 + 序号兜底,assert 路径不变
2026-06-21 09:41:20 -07:00
adbec22e95 feat(mall):自提门店 admin 接口回填 areaName
DeliveryPickUpStoreRespVO 加 areaName 字段;DeliveryPickUpStoreConvert 加 convert01(@Mapping areaId→areaName,复用 convertAreaIdToAreaName/AreaUtils.format);/get 改用 convert01 返回 areaName。convertPage/convertList 间接复用 convert01,故 /page、/list 也会带出 areaName
2026-06-21 07:48:23 -07:00
f587d4f756 feat(member):收货地址 admin 接口回填 areaName
AddressRespVO 加 areaName 字段;AddressConvert 补 convert03
(@Mapping areaId→areaName,复用 convertAreaIdToAreaName/AreaUtils.format),
convertList2 自动带出。/member/address/list 返回可读地区名,替代前端展示原始 areaId 编码
2026-06-21 06:24:22 -07:00
93eabb4efd feat(crm):新增 list-by-contact 接口,适配 admin uniapp 不分页下拉 2026-06-20 21:56:22 -07:00
ca367c376f feat(crm):新增 contact/business 的 list-by-customer 接口(含 @CrmPermission 数据权限) 2026-06-20 11:01:37 -07:00
296eb22b68 feat(bpm): 增加一些注释 2026-06-20 02:58:21 -07:00
ca815b4c30 !1563 fix: 并行分支后的,审核人自选问题修复。<a href="https://gitee.com/link?target=https%3A%2F%2Ft.zsxq.com%2Fdaxv1">https://t.zsxq.com/daxv1</a>
Merge pull request !1563 from 芋道源码/master-jdk17-bpm-bug-fix
2026-06-20 09:55:49 +00:00
c779a47661 fix(crm): 修复跟进记录详情越权读取
跟进记录详情接口按 id 查询后,增加关联 CRM 业务对象的 READ 权限校验,
避免用户通过自增 id 越权读取其他用户的跟进记录。

Fixes: https://github.com/YunaiV/ruoyi-vue-pro/issues/1159
2026-06-20 02:25:27 -07:00
5d1fd70dc3 fix(erp): 修复销售订单误用销售出库权限
将 ErpSaleOrderController 的权限标识从 erp:sale-out:* 修正为
erp:sale-order:*,避免拥有销售出库权限的用户越权访问销售订单接口。

Fixes: https://github.com/YunaiV/ruoyi-vue-pro/issues/1161
2026-06-20 01:58:25 -07:00
57f575bf2e fix: 并行分支后的,审核人自选问题修复。https://t.zsxq.com/daxv1 2026-06-20 13:28:11 +08:00
bb538ba4f8 Merge remote-tracking branch 'origin/master-jdk17' into master-jdk17-bpm-bug-fix 2026-06-20 10:22:46 +08:00
d9b320f29f fix: 条件分支、并行分支下,审核人自选问题修复,https://wx.zsxq.com/group/88858522214142/topic/2852142228215121 2026-06-20 10:20:25 +08:00
cd47e9dda6 feat(im): im 相关的菜单导入 2026-06-19 18:42:01 -07:00
863a420287 fix(im):已退的群,允许 read,避免退群无法读 2026-06-17 13:36:46 +08:00
cd0156e0e2 fix: 修正多租户 Job 自动配置条件 2026-06-14 14:48:22 +08:00
acda3c95de fix(wms): 优化库存批量锁定顺序
- 批量 FOR UPDATE 查询前复制并排序库存 id,保持查询输入顺序稳定
- 补充多条已存在库存变更单测,验证库存余额更新和流水顺序
2026-06-14 14:32:54 +08:00
3d1b66d40b !1556 feat: 审批,拒绝任务时允许添加附件
Merge pull request !1556 from Jason/master-jdk17-bpm-bug-fix
2026-06-13 17:49:43 +00:00
61d312d2e9 feat: 支持瀚高数据库 PostgreSQL 兼容模式
- 新增 highgo SQL 脚本与转换器类型
- 增加 HighGo Docker Compose 示例和工具文档
- 后端运行包引入 PostgreSQL JDBC 驱动
- 补充本地瀚高连接配置示例
2026-06-14 00:42:44 +08:00
7dd3aed542 Merge remote-tracking branch 'origin/master-jdk17' into master-jdk17 2026-06-14 00:25:34 +08:00
c9392af494 fix(tenant): 按需注册多租户 MQ 配置
将 Redis、RabbitMQ、RocketMQ 的多租户 Bean 拆到独立条件配置,
避免未引入 yudao-spring-boot-starter-mq 的模块启动时因解析
YudaoTenantAutoConfiguration 方法签名触发 NoClassDefFoundError。

Closes https://gitee.com/zhijiantianya/yudao-cloud/issues/IJTOF4
2026-06-14 00:25:23 +08:00
98a977dd1a fix(mes): 修复 SN 码查看条码缺少 bizType
- 新增 SN 码条码业务类型与配置
- 支持按 SN 明细查询对应条码
- 抽离 SN 码明细弹窗组件
- 同步 vben antd/ele 的条码入口

Refs: https://t.zsxq.com/1YCqD
2026-06-13 18:51:47 +08:00
9cd964a9d8 feat: flowable 升级 8.0.0。 并修复并行分支回退到并行节点前的问题 2026-06-12 22:39:59 +08:00
d37bc069a3 feat: 审批,拒绝任务时允许添加附件 2026-06-11 18:15:25 +08:00
c7649efa45 feat: 审批,拒绝任务时允许添加附件 2026-06-11 18:11:28 +08:00
d97b85bb4c fix(iot): 开启 TDengine WebSocket 自动重连配置示例
为本地 TDengine 示例 URL 增加 enableAutoReconnect=true
2026-06-08 18:50:48 +08:00
eb767f77e3 fix(mall): 校验售后申请类型必填
- 后端售后申请接口启用请求体参数校验
- 前端申请售后提交前校验售后类型
- 修正售后类型 radio 选中态绑定字段
2026-06-07 23:14:59 +08:00
25c2fbd4bc build(dependencies): BOM 统一管理 fastjson2 版本为 2.0.61
在 yudao-dependencies 中新增 fastjson2.version 属性及 dependencyManagement 条目,
统一锁定 fastjson2 版本,避免由 rocketmq 等依赖间接引入导致版本不可控(原 2.0.43)。
2026-06-07 22:33:26 +08:00
633bd70dfe fix(mes): 自动编码重复时支持自动重试,修复流水号落后导致的生成失败
当 Redis 流水号落后于历史数据时,生成的编码可能与已有记录重复,原逻辑会直接抛出
AUTO_CODE_GENERATE_FAILED 异常。现改为循环重试(最多 10 次),重复时自动跳过并
生成下一个可用编码,仅在重试耗尽后才抛异常。

- 抽取 generateCode 方法,分离编码拼接/补齐与重复校验、记录保存逻辑
- 补充重复重试场景的单元测试
2026-06-07 20:14:12 +08:00
08f7c33cd4 fix(bpm): 优化回退逻辑健壮性与连续审批可读性
1. returnTask 在 moveExecutionsToSingleActivityId 前校验 runExecutionIds 非空,
   避免传入空集合导致 Flowable 内部报错
2. approveTask 清理退回标记变量改为先判断存在再删除,避免每次完成任务产生无谓的
   DB delete,并补充 info 日志便于排查
3. processTaskAssigned 中 sameAssigneeQuery 重命名为 approvedTaskQuery,
   贴合其「已审批通过历史任务查询」的实际语义
2026-06-07 20:08:19 +08:00
0075d19f88 feat:优化 https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1554 代码的小排版 2026-06-07 19:35:56 +08:00
28aeb8a666 !1554 fix: 修复回退报错问题, moveExecutionsToSingleActivityId 替换为 moveActivityIdsToS…
Merge pull request !1554 from 芋道源码/master-jdk17-bpm-bug-fix
2026-06-07 11:31:20 +00:00
93dadc14f6 feat: 增加 IoT Kafka 消息总线实现
- 新增 IotKafkaMessageBus,支持 IoT gateway 与 yudao-server 通过 Kafka 通信
- 增加 type=kafka 自动配置,并补充消息总线类型说明
- 为 iot-gateway 补充 spring-kafka 依赖和 Kafka 连接配置
- 将 iot-gateway 与 yudao-server 的 IoT message-bus 默认切换为 kafka
2026-06-07 19:14:07 +08:00
6d3d097383 feat: 增加 IoT Kafka 消息总线实现
- 新增 IotKafkaMessageBus,支持 IoT gateway 与 yudao-server 通过 Kafka 通信
- 增加 type=kafka 自动配置,并补充消息总线类型说明
- 为 iot-gateway 补充 spring-kafka 依赖和 Kafka 连接配置
- 将 iot-gateway 与 yudao-server 的 IoT message-bus 默认切换为 kafka
2026-06-07 19:13:56 +08:00
93dd97afb7 fix(infra): 修复文件 URL 特殊字符编码问题
- 新增 URL Path 编解码工具,保留 + 字符语义
- 文件访问基于原始 URI 解码 path,避免中文、空格、% 等字符下载异常
- 文件客户端生成 URL 时按路径段编码,S3 兼容完整 URL 和裸 path
- 优化 Content-Disposition 文件名,支持 filename 和 filename*
- 下载文件名优先使用文件记录的原始名称
- 补充 HTTP、Local、S3、文件服务相关单测
2026-06-07 14:19:13 +08:00
472993e25a fix(infra): 修复文件 URL 特殊字符编码问题
- 新增 URL Path 编解码工具,保留 + 字符语义
- 文件访问基于原始 URI 解码 path,避免中文、空格、% 等字符下载异常
- 文件客户端生成 URL 时按路径段编码,S3 兼容完整 URL 和裸 path
- 优化 Content-Disposition 文件名,支持 filename 和 filename*
- 下载文件名优先使用文件记录的原始名称
- 补充 HTTP、Local、S3、文件服务相关单测
2026-06-07 12:21:03 +08:00
858a351c94 !1544 fix: 修复IoT使用redis消息队列时iot_device_message的STREAM的消息无限积压问题
Merge pull request !1544 from 熊猫大侠/master-jdk17-iotalert
2026-06-07 03:25:01 +00:00
81a77aade1 !1543 fix: 修复未返回最后触发时间导致前端一直显示未触发
Merge pull request !1543 from 熊猫大侠/master-jdk17-iotscene
2026-06-07 03:20:29 +00:00
c1101dc4b9 feat(iot): 优化 Modbus TCP Client 连续点位批量读取
- 泛化 Modbus 轮询任务为 taskKey,保持默认单点轮询兼容
- 支持 TCP Client 按功能码、轮询间隔和连续地址聚合读取段
- 新增区间读取方法,批量读取后按点位切片上报
- 按 Modbus 单次读取上限拆分读取段
- 补充连续、重叠、拆段和线圈切片单测

https://github.com/YunaiV/ruoyi-vue-pro/issues/1147
2026-06-07 11:17:11 +08:00
7b6adb410e Merge remote-tracking branch 'origin/master-jdk17' into master-jdk17
# Conflicts:
#	yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceTest.java
2026-06-07 10:13:29 +08:00
45422d84ab fix: 修复微信转账确认收款 packageInfo 丢失
- 转账中状态下允许补写缺失的 channelPackageInfo
- 处理同步任务与发起转账接口并发更新转账状态的场景
- 增加仅空值时更新 channelPackageInfo 的 Mapper 方法
- 补充转账状态推进单测覆盖 Issue #1144
2026-06-07 10:13:19 +08:00
9b9a10695a fix: 修复微信转账确认收款 packageInfo 丢失
- 转账中状态下允许补写缺失的 channelPackageInfo
- 处理同步任务与发起转账接口并发更新转账状态的场景
- 增加仅空值时更新 channelPackageInfo 的 Mapper 方法
- 补充转账状态推进单测覆盖 Issue #1144
2026-06-07 10:13:13 +08:00
11f1a3ee9c chore: 合并 unibest 2025-12 构建配置修复
部分合并:
- 61cceb5 build: 更新 @uni-helper 相关依赖版本
  - 升级 @uni-helper/vite-plugin-uni-manifest 0.2.11 -> 0.2.12
  - 升级 @uni-helper/vite-plugin-uni-pages 0.3.19 -> 0.3.22

- 4fa7a43 chore: 同步 lock 锁定 61cceb5 的插件版本
  - pnpm-lock.yaml 仅同步 uni-manifest 0.2.12 和 uni-pages 0.3.22
  - 未引入无关依赖漂移

- 36abf49 refactor(vite): 重命名插件导入并调整插件顺序
  - Components -> UniComponents
  - Optimization -> UniOptimization
  - 保留当前项目的 WotResolver 和芋道分包配置

- a75502a refactor(vite配置): 调整 UniPages 和 UniComponents 插件顺序并更新排除规则
  - 将 UniComponents 前置到 UniPages 前
  - UniPages 和 UniKuRoot 均补充 **/sections/**/**.* 排除规则
  - 保留 pages-core、pages-system、pages-infra、pages-bpm 分包

- bfe5c04 fix(scripts): 补充 app/mp 开发命令的基础文件初始化入口
  - 新增 predev:app 和 predev:mp
  - 保留当前项目已有 init-json

- a487ba7 fix(脚本): 修复基础文件创建逻辑,避免空对象文件导致错误
  - src/manifest.json 为空对象时重新生成
  - src/pages.json 为空对象时重新生成

完全合并:
- 无

忽略:
- bffa3bd、8926504 tabbar 重构:当前项目已有芋道定制 tabbar 和登录态保护,暂不合并
- cc8db0d、286f23e、ff5ef61、8a20eec:上游版本号更新,不覆盖当前项目版本
- 1021b55、4ebed7a、b9a3bf2、bd21100、b2a3496、a068049、a09365f:文档/注释更新,不属于本次运行时修复
- 2939eb8:上游合并提交
- 24a6712:VSCode snippets,不合并
- 0f68e06:package script 顺序整理,当前不需要

验证:
- git diff --check 通过
- pnpm build:h5 通过
- pnpm build:mp-weixin 通过
- 浏览器和微信小程序手测通过
2026-06-07 10:12:24 +08:00
11dd407e18 refactor: 移动端代码生成器列表页迁移为 z-paging
- 将 uniapp 列表模板切换为 z-paging 分页
- 支持返回列表自动刷新、顶部下拉刷新和底部自动加载更多
- 表单提交、详情删除后统一发送 reload 事件
- 详情页监听 reload 事件刷新数据,并增加删除中刷新守卫
- 新增 uniapp 代码生成器快照单测
2026-06-07 09:05:44 +08:00
a426cc2f4b fix: 修复 findInSet SQL 注入风险
- 调整 MyBatisUtils.findInSet 使用 MyBatis-Plus 参数绑定
- 增加 columnName 白名单校验,避免列名被注入
- 补充 H2 兼容实现,恢复相关单测覆盖
- 替换各模块动态 FIND_IN_SET 字符串拼接写法
- 补充单参数、多参数绑定场景单测
2026-06-07 02:40:46 +08:00
e72c02497f feat(infra): 优化 uni-app 生成模板变量注释
- 为列表、表单、详情、搜索模板补充简洁尾注释
- 将变量注释调整为当前模块上下文内的短描述
- 优化时间范围选择器确认方法注释
2026-06-06 23:21:52 +08:00
574f90d956 fix(infra): 兼容代码生成快照的 Windows 换行
- 代码生成尾逗号清理兼容 CRLF 换行
- 快照断言统一 CRLF/CR 为 LF,并忽略文件末尾换行
- 避免使用 trimEnd,防止误删模板中有意义的尾随空格
2026-06-06 02:18:19 +08:00
f07ec76806 !1551 fix(infra): 修复 Windows 下 Codegen 单测和代码生成统一 LF 并优化尾逗号正则
Merge pull request !1551 from haohaoMT/fix/codegen-format-windows
2026-06-05 17:41:50 +00:00
4ca0aadd4a fix(infra): 代码生成统一 LF 并优化尾逗号正则 2026-06-04 11:22:42 +08:00
f6886a780d fix(infra): 修复 Windows 下代码生成单测换行与尾逗号差异 2026-06-04 09:58:22 +08:00
6a3b384fc3 merge: sync master-jdk17, keep stripTrailing and trailing comma fix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 09:24:57 +08:00
eddd21f4e8 fix(infra): 修复 Windows 下 Codegen 单测 2026-06-04 09:21:49 +08:00
1d463f4f1a fix: 修复回退报错问题, moveExecutionsToSingleActivityId 替换为 moveActivityIdsToSingleActivityId 2026-06-04 08:12:28 +08:00
78d798bbe9 fix(infra): 修复 windows 代码生成单测换行符差异 2026-06-04 01:18:37 +08:00
83ce168a68 fix(infra): 代码生成统一 LF 与尾逗号处理,修复 Windows 单测 2026-06-03 14:54:01 +08:00
91f356fc74 fix: 自动去重,连续审批自动通过问题修复。https://gitee.com/zhijiantianya/yudao-cloud/issues/IE8CQN 2026-06-03 14:39:35 +08:00
5e8df3089d fix: 修复IoT使用redis消息队列时iot_device_message的STREAM的消息无限积压问题 2026-06-02 14:37:26 +08:00
45399687b4 fix: 修复退回到多实例任务时候,会自动跳过问题。 https://github.com/YunaiV/ruoyi-vue-pro/issues/1128 2026-06-02 13:54:30 +08:00
ef5a3ae7f8 fix: 修复未返回最后触发时间导致前端一直显示未触发 2026-06-02 11:20:20 +08:00
dea5e07ed6 feat(im):增加 im 的功能说明 2026-06-01 08:13:15 +08:00
115055c403 feat(im):增加 im 的功能说明
- 移除 IM 对 websocket starter 的直接依赖
- 改为通过 infra WebSocketSenderApi 发送推送消息
- 同步调整已有 WebSocket 推送单测
2026-06-01 08:08:17 +08:00
c13ca7b4d8 feat(im): 统一 WebSocket 推送依赖
- 移除 IM 对 websocket starter 的直接依赖
- 改为通过 infra WebSocketSenderApi 发送推送消息
- 同步调整已有 WebSocket 推送单测
2026-06-01 00:36:52 +08:00
92c0ea303c feat(im):增加 livekit poc 启动脚本 2026-06-01 00:00:35 +08:00
3235b4e707 fix: 优化对 JDK8 的兼容性 2026-05-31 23:45:15 +08:00
40c9449f39 fix: 优化对 JDK8 的兼容性 2026-05-31 23:36:22 +08:00
5998e7f931 chore: remove macOS metadata file 2026-05-31 23:08:24 +08:00
11b4eef41d fix: add .DS_Store to .gitignore 2026-05-31 23:07:11 +08:00
3d0142abe1 feat(im):合并 im 最新版本到 master 分支 2026-05-31 22:33:58 +08:00
ec9862cd29 Merge branch 'feature/im-dev' of https://gitee.com/zhijiantianya/ruoyi-vue-pro into master-jdk17
# Conflicts:
#	pom.xml
#	yudao-dependencies/pom.xml
#	yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/JsonUtils.java
#	yudao-server/pom.xml
2026-05-31 22:25:45 +08:00
bfca0820ca (〃'▽'〃)_v2026_04_发布:新增 WMS 仓储管理系统,完成 Vben5 IoT/MES/WMS 双端适配 2026-05-31 21:27:07 +08:00
ca702b81af fix: 优化对 JDK8 的兼容性 2026-05-31 20:00:40 +08:00
f1660e18e4 fix: 优化对 JDK8 的兼容性 2026-05-31 19:51:03 +08:00
688de72367 fix(bpm): 修复流程摘要解析展示组件时报错
- 表单字段 JSON 改为按 JsonNode 逐个节点递归解析
- 跳过分割线、标签、文字等展示组件的字符串 children
- 默认摘要按表单字段配置顺序取前三个真实字段
- 补充流程摘要解析单元测试,覆盖 DB form_fields 常见格式

对应 https://t.zsxq.com/x1UrW
2026-05-31 19:14:59 +08:00
af9b6253b1 fix(sql): 修复 convertor 索引解析兼容问题
- 兼容 simple-ddl-parser 不同版本的索引列返回结构
- 修复唯一索引列中误混入 ASC/DESC 导致生成 SQL 不合法的问题
- 兼容 Oracle、Kingbase 的 level 等保留字列名转义
- 同步处理 CREATE、COMMENT、INSERT 显式列清单的列名转义
2026-05-31 17:49:23 +08:00
abee9ff48d fix: 修复拼团开团与库存边界校验
- 允许拼团购买数量等于 SKU 库存,仅购买数量大于库存时提示库存不足
- 补充 CombinationRecordServiceImpl 的 DB 单测,覆盖 headId=0 开团、参团、父团不存在、库存边界、重复参团、总限购和过期取消订单等场景
- 补充 promotion_combination_record 单测表结构和清理 SQL

帖子:https://t.zsxq.com/Brapi
2026-05-31 17:11:56 +08:00
eaaeb86a0c feat(mes): add MES production task issue API and improve combination record validation 2026-05-31 16:48:00 +08:00
89c137a915 fix(mall): 修复独立分销固定佣金为空时报错
独立分销商品的 SKU 固定佣金为空时,预计佣金计算和下单分销记录创建统一按 0 处理,避免最小/最大佣金比较或佣金乘数量时出现空指针异常。

补充分销预计佣金和订单转换单元测试,覆盖固定佣金为空与 SKU 列表为空场景。
2026-05-31 16:41:23 +08:00
0943307785 fix(mall): 修复全局比例分佣商品预估佣金为 0,对应 https://t.zsxq.com/Lsu8i 2026-05-31 13:13:53 +08:00
2f5984afd9 fix(pay): 修复微信支付公钥模式配置,兼容 weixin-java-pay 新版本 2026-05-31 09:46:20 +08:00
42d4112bd9 fix(iot):单测报错问题 2026-05-31 09:26:59 +08:00
e03e3c1a89 fix(iot): 修复设备属性上报类型转换
- 抽取物模型属性值转换到 ThingModelService
- 支持 ENUM、DATE、BOOL、STRUCT、ARRAY、TEXT 等 TDengine 类型转换
- 跳过 null、非字符串 key 和无效值,并补充单测
2026-05-31 00:30:21 +08:00
23c642ed72 fix(iot): 清理已删除 Modbus 设备的网关轮询
- 过滤 RPC Modbus 配置列表中的已删除设备
- Modbus TCP Server 刷新配置时清理失效设备资源
- 停止轮询任务并清理 Pending 请求、配置缓存和连接
2026-05-31 00:15:21 +08:00
4ae3f6b2c9 fix(infra): 加强文件上传路径安全校验
- 为 App 文件上传入口启用参数校验,保留 PermitAll
- 统一校验文件名、目录和相对路径,拒绝目录穿越路径
- LocalFileClient 限制最终文件路径必须位于 basePath 内
- 补充文件路径安全相关单元测试
2026-05-30 23:35:30 +08:00
e44011754c feat(member): 会员增加 email 字段 2026-05-30 22:55:27 +08:00
fe4a774c1e !253 iot,MessageBus增加iotRabbitMQMessageBus:https://gitee.com/zhijiantianya/yudao-cloud/pulls/253 2026-05-30 22:19:59 +08:00
5f2abdabbe feat: 优化 IoT 告警模板选择
- 后端 mail/sms/notify 模板 simple-list 仅返回启用模板精简字段
- 前端补充 mail/sms/notify 模板 simple-list API 封装
- vue3 与 vben antd/ele 在各自 system 模块封装模板选择组件
- IoT 告警配置按接收类型动态选择短信、邮件、站内信模板
- 补充前端 IotAlertReceiveTypeEnum,替换表单内裸常量
2026-05-30 22:06:02 +08:00
8ba906c6eb !1540 fix: 优化IoT告警配置支持动态选择邮件、短信、站内信的模板,不再依赖templateCode。
Merge pull request !1540 from 熊猫大侠/master-jdk17-iotalert
2026-05-30 12:41:16 +00:00
b776dc2a06 refactor(mes): update comments and improve code readability in condition matchers 2026-05-30 20:38:05 +08:00
215d0ce8f0 !1541 fix: 修复触发器和条件匹配器中缺少对产品和设备一致性的验证
Merge pull request !1541 from 熊猫大侠/master-jdk17-iotscene
2026-05-30 12:35:49 +00:00
c6813d43af feat(mes-qc): 迁移 antd 来料检验及检测结果、缺陷记录组件(代码优化) 2026-05-29 16:27:15 +08:00
07f26a7e02 fix: 修复触发器和条件匹配器中缺少对产品和设备一致性的验证 2026-05-26 11:16:06 +08:00
c39865e90d fix(mes): 修正安灯迁移的配置角色显示和记录只读字段 2026-05-26 00:08:46 +08:00
bd29116e45 fix: 修复 IM 申请与 RTC 边界问题
- 复用好友申请、群申请和群邀请唯一键冲突后的旧记录,并补充测试
- 收敛 RTC 旁观者加入、忙线校验、追加邀请超员和群通话通知逻辑
- 为 RTC 参与者补充房间用户唯一约束与 MySQL 迁移
- 统一群本体管理请求的 id 字段,并同步前端调用
- 修复前端来电活跃态守卫和 LiveKit 重连前断开旧房间
- 清理群成员通知基类命名和相关注释
2026-05-25 20:54:12 +08:00
a06fb9e995 fix(im):批量修复群管理、RTC 和消息链路问题
- 修复群管理行锁、管理员角色更新、群主转让、置顶消息并发问题
- 修复好友申请 maxId 游标、重复申请排序、通知类型校验和消息内容结构校验
- 修复消息统计口径、RTC token 鉴权、离会通知、前端拉取取消和媒体重试
- 优化表情批量删除、WebSocket 推送注释、群 READ 字段和相关单测
- 更新 bug_todo、bug_done 和 bug_rejected,剩余 9 个待修
2026-05-25 09:04:25 +08:00
9b44ed74e6 fix(im): 批量修复 P1/P2 问题
- 修复管理端消息内容搜索和私聊双向查询
- 加强 RTC 通话并发状态保护,去除重复接口错误提示
- 支持成员永久禁言
- 脱敏群消息 WebSocket 定向收件人字段
- 更新 IM bug 台账,剩余 P1/P2 共 35 个
2026-05-25 00:29:00 +08:00
36c30a431a fix: 加强 IM 上传 URL 与 RTC 来电载荷校验 2026-05-24 23:41:47 +08:00
49241c3123 fix(im): 强化好友关系、消息历史和前端交互
- 校验群资料字段长度,并在同意好友申请时复验双方用户
- 仅向双向有效好友推送资料更新通知
- WebSocket 推送收件人去重,并忽略空用户编号
- 群聊和私聊历史保留撤回消息记录
- 校验群通话排除发起人后仍需存在被邀请人
- 统一 IM 前端接口参数传递方式
- 抽取全局 URL 安全打开工具,并复用到消息预览
- 防止好友申请同意和拒绝按钮重复操作
- 补充好友、消息、RTC、WebSocket 相关测试
2026-05-24 21:24:15 +08:00
f5ef0a8997 fix(im): 批量修复 P0 安全边界和通话流程问题
- 拒绝匿名 WebSocket 握手,收紧 RTC 接听和入会忙线校验
- 支持封禁群解散,管理端解散改为独立权限码
- 增加个人表情数量配置、唯一约束和并发重复兜底
- 修复 RTC 异常断开上报、视频远端音频和好友选择大列表渲染
- 让个人表情添加失败透出后端业务错误
- 流转 P0 bug 文档,并按产品取舍记录 apiSecret 默认值不强制拦截
2026-05-24 20:21:00 +08:00
bda892277d fix(im):加固管理端入参和统计边界
- 校验敏感词、表情包、表情项状态枚举和批量删除数量上限
- 限制私聊、群聊增量拉取 size 最小值
- 收紧表情包项 Mapper 的 in 查询并在 Service 层处理空好友集合
- 兼容统计聚合数值类型并明确当前有效群总数口径
- 固定 LiveKit token 签名编码和 IM web 包说明
- 修正群禁言权限错误码顺序并补充封禁、踢人、禁言回归测试
- 流转 IM bug 文档并记录本轮不修项语义
2026-05-24 19:02:19 +08:00
bb9a2b5382 fix(im):加固好友、群成员与群消息边界流程
- 清理好友重加时的 deleteTime 和历史备注残留
- 清理群成员重入时的 quitTime、muteEndTime 和邀请来源残留
- 允许封禁群成员主动退群,仍拦截已解散群
- 校验群已读游标的消息归属和可见性
- 收窄群消息置顶通知为专用展示对象并同步前端展示
- 回填群成员单查接口的昵称和头像
- 补充相关回归测试并流转 IM bug 状态文档
2026-05-24 18:22:08 +08:00
513c130151 fix(im):加固群聊与好友部分流程
- 拦截对方已拉黑时的静默恢复好友关系
- 入群申请通过前复核群状态和申请人成员状态
- 群消息撤回保留定向可见范围
- 过滤群历史消息和已读用户查询中的定向消息
- 敏感词缓存刷新补充租户上下文
- 补充 IM 测试表约束和好友申请回归用例
2026-05-24 17:13:17 +08:00
a4b485562f feat(mybatis): 支持 likeRightIfPresent 条件拼接 2026-05-24 11:12:04 +08:00
dd6be0e595 feat(mybatis): 支持 likeRightIfPresent 条件拼接 2026-05-23 23:02:15 +08:00
69121bec6e fix(mall): 修复优惠券模板限领数量校验误报库存不足 2026-05-23 18:23:49 +08:00
bac7cf17d8 build: 升级可解析的三方依赖版本 2026-05-23 16:49:28 +08:00
0d6a75a2a6 fix(framework): 锁定 Bouncy Castle 版本避免 Fat Jar 启动失败 2026-05-23 16:14:34 +08:00
da96ceab7a fix(framework): 修复分页排序 SQL 注入风险 2026-05-23 12:27:02 +08:00
cab59d4dd8 fix(framework): 修复分页排序 SQL 注入风险 2026-05-23 12:26:57 +08:00
f0e4639920 feat(im): 修一批群消息撤回与群管理治理问题
- 群主 / 管理员可撤回他人违规消息;治理他人消息时不受撤回时间窗限制,撤回自己的仍受限
- banGroup 校验群未解散(已解散抛 GROUP_DISSOLVED)+ banned 幂等,避免重复封禁广播
- 踢人跳过已退群的失效目标,只踢有效成员,不再因混入历史管理员整批失败
- 退群成员离线 pull 去掉撤回消息过滤,与在群成员的 selectListByMinId 对齐
- 补 removeGroupMember 单测 status 字段 + 新增「跳过失效目标」混合用例
2026-05-22 18:09:47 +08:00
3f1d86efff fix: 优化IoT告警配置支持动态选择邮件、短信、站内信的模板,不再依赖templateCode。 2026-05-22 14:52:02 +08:00
f938362b04 feat(im): 增加频道消息的已读状态 2026-05-20 01:00:47 +08:00
b18281fc5c feat(im): 修复频道消息的引用展示不对; 2026-05-19 23:57:46 +08:00
79be419067 feat(im): 继续优化频道的各种代码(v4)优化卡片样式 2026-05-19 23:52:11 +08:00
ee9362a2f2 feat(im): 继续优化频道的各种代码(v3) 2026-05-19 22:06:38 +08:00
6bfaa848f2 feat(im): 继续优化频道的各种代码(v2) 2026-05-19 17:48:36 +08:00
865a58a646 feat(im): 继续优化频道的各种代码, 2026-05-19 17:18:49 +08:00
d60156015b feat(im): 增加频道的检查 2026-05-19 14:18:09 +08:00
032d955800 feat(im): 新增频道消息的前端实现 2026-05-19 13:26:33 +08:00
611880a3c4 feat(im): 新增频道消息的后端实现 2026-05-19 11:47:19 +08:00
6b1a0cfce2 feat(im): 更多单元测试 2026-05-18 15:17:08 +08:00
36c4410512 feat(im): 管理后台新增通话记录只读查询(列表 / 详情 / 参与者);im_rtc_participant 增加 call_id 关联 im_rtc_call.id
 feat(im): 管理后台新增通话记录页面(列表 + 详情抽屉 + 参与者表),消息预览补 RTC_CALL_START / END 文案
2026-05-18 12:37:52 +08:00
2fd201bf59 feat(im): 振铃超时 Job 单人粒度标 NO_ANSWER + 独立 NO_ANSWER 信令推送
 feat(im): 处理 RTC_CALL(NO_ANSWER) 信令;私聊气泡显示「未接听」
2026-05-18 09:45:33 +08:00
9e1a6b15e4 feat(im): 振铃超时 Job 单人粒度标 NO_ANSWER + 独立 NO_ANSWER 信令推送
 feat(im): 处理 RTC_CALL(NO_ANSWER) 信令;私聊气泡显示「未接听」
2026-05-18 08:03:53 +08:00
1eda319ea0 feat(im): 通话事件接入会话列表预览(私聊补 START 入消息流);文案统一「语音通话」 2026-05-17 22:26:07 +08:00
dc081cfdd2 fix(im): 简化 LiveKit webhook 的 @PostMapping 注解(移除冗余 consumes)因为 LiveKit webhook 是 webhook/json 非标 2026-05-17 20:27:33 +08:00
9ce816e247 feat(im): 优化群邀请的 incoming、inviting 的交互 2026-05-17 10:36:01 +08:00
70271c1cbf feat(im): 对齐微信的通话,模拟手机拨打电话。每次都一定有记录 2026-05-13 22:16:21 +08:00
c69e20d681 feat(im): RTC 接口拆 create / invite + 字段对齐 + 群关房语义升级
- refactor: inviteCall 拆成 createCall(创建新通话)/ inviteCall(追加邀请);VO `ImRtcCallInviteReqVO`(统一版)→ git mv 为 `ImRtcCallCreateReqVO`;恢复 + git mv `ImRtcCallInviteMoreReqVO` → `ImRtcCallInviteReqVO`(room + inviteeIds);Controller 端点 `/invite` → `/create` + 新 `/invite`;Service 内部拆 createGroupCall / createPrivateCall / createCall0
- refactor: VO 字段 `scene` → `conversationType`(CreateReqVO / RespVO 同步),与 ImConversationTypeEnum 对齐;`peerUserId` 融合进 `inviteeIds`,私聊必传 1 个对端从集合派生
- feat: 群通话关房语义升级;shouldCloseGroupRoom 改 `(JOINED==0) || (JOINED==1 && INVITING==0)`,1 人独守 + 无后续即关;endSessionIfTerminal 按 call.status 自动推 reason(CREATED→CANCEL 没真接通过 / RUNNING→HANGUP);rejectCall 群分支末尾触发关房判定,全员拒接自动收敛
- chore: 错误码新增 RTC_GROUP_INVITEE_REQUIRED;validateInviteScene → validateCreateCall;Controller `getLoginUserId` 抽局部变量;resolveDisplayName 抽私有 helper 复用 displayName 派生
- fix: pushCallInviteNotification 加 NPE 防御(getUserMap 缺键时降级为 userId 字符串);pushCallEndNotification 私聊 receiverId 去掉错误的 senderId 兜底
- chore: handleLiveKitEvent 改回传统 switch 兼容 JDK8;其它若干 Javadoc 补齐 / 注释精简 / typo 修复
2026-05-13 13:24:10 +08:00
690a549963 ```
 feat(im): RTC 通话逻辑收口(displayName / inviteeIds 校验 / 配置化阈值)

- fix: pushCallInviteNotification 给被邀请人签 token 时 displayName 改用 invitee 自己的昵称(原来错用 inviter)
- feat: 群通话发起 / 追加邀请均强制 inviteeIds 非空,对齐微信交互;超量改抛 RTC_GROUP_INVITEE_OVER_LIMIT,不再静默截断
- refactor: ImGroupMemberService 抽 validateMembersInGroup(groupId, ids) 批量校验,inviteMoreCall / resolveInvitees 复用
- refactor: 僵尸通话清理阈值从 ImRtcCallCleanupJob 静态常量挪到 ImProperties.rtc.cleanupZombieThresholdMinutes
- perf: createCall / inviteMoreCall 推 INVITE 前一次 getUserMap 批量拉用户,省 N 次 RPC
- refactor: inviteMoreCall 参与者构造改 CollectionUtils.convertList;trim 多余注释
- docs: 补齐 RTC service / controller / job / LiveKitClient 私有方法的 @param / @return
- chore: 错误码新增 RTC_GROUP_INVITEE_REQUIRED / RTC_GROUP_INVITEE_OVER_LIMIT
```
2026-05-13 01:38:01 +08:00
27685b99c8 ```
 feat(im): RTC 通话逻辑收口(displayName / inviteeIds 校验 / 配置化阈值)

- fix: pushCallInviteNotification 给被邀请人签 token 时 displayName 改用 invitee 自己的昵称(原来错用 inviter)
- feat: 群通话发起 / 追加邀请均强制 inviteeIds 非空,对齐微信交互;超量改抛 RTC_GROUP_INVITEE_OVER_LIMIT,不再静默截断
- refactor: ImGroupMemberService 抽 validateMembersInGroup(groupId, ids) 批量校验,inviteMoreCall / resolveInvitees 复用
- refactor: 僵尸通话清理阈值从 ImRtcCallCleanupJob 静态常量挪到 ImProperties.rtc.cleanupZombieThresholdMinutes
- perf: createCall / inviteMoreCall 推 INVITE 前一次 getUserMap 批量拉用户,省 N 次 RPC
- refactor: inviteMoreCall 参与者构造改 CollectionUtils.convertList;trim 多余注释
- docs: 补齐 RTC service / controller / job / LiveKitClient 私有方法的 @param / @return
- chore: 错误码新增 RTC_GROUP_INVITEE_REQUIRED / RTC_GROUP_INVITEE_OVER_LIMIT
```
2026-05-13 01:37:48 +08:00
bb5ac45be8 feat(im): 优化 JDK8 的兼容性 2026-05-13 00:59:06 +08:00
8996b94795 feat(im): 优化 ImRtcCallServiceImpl 实现类的各种注释,以及编码风格。 2026-05-12 23:48:50 +08:00
07f3bab72a feat(im): 将后端的 roomName 和 callId 融合,简化字段和逻辑(一致性更好、概念更简洁) 2026-05-12 20:29:09 +08:00
d0aac3824d feat(im): rtc 增加代码评审,进一步优化代码风格; 2026-05-12 19:16:34 +08:00
b683031528 feat(im): rtc 交流通信的部分 2026-05-12 13:52:11 +08:00
8b497fa655 feat(im): 完善 rtc 的后端代码逻辑,各种代码风格的优化 2026-05-12 13:17:04 +08:00
530d3b3a5f feat(im): rtc 增加数据库存储(从单体 =》集群) 2026-05-12 09:47:20 +08:00
fe77884cc3 feat(friend): 重构好友状态管理逻辑
优化好友状态的获取和验证逻辑,将 ImFriendStateEnum 的使用改为整型状态值,简化了好友关系的校验过程。新增 validateFriend 方法,统一处理好友和黑名单的校验,提升代码可读性和维护性。
2026-05-11 14:25:25 +08:00
bcac553c69 feat(im): 基于 livekit 构建 im 通话(语音聊天、视频聊天、共享桌面)v0.1:推进中 2026-05-09 15:23:07 +08:00
348b97047d feat(im): 拆出私聊 / 群聊已读两个全局开关,关闭后禁用接口与所有 UI 入口(含群回执)
ImProperties.message 新增 privateReadEnabled / groupReadEnabled,前端 config.ts 同步镜像。关闭后:
- 后端:read 系列接口(read / getMaxReadMessageId / getGroupReadUserIds)抛业务异常;sendGroupMessage 强制 NO_RECEIPT 忽略 receipt=true;pull 群消息跳过 Redis 已读游标读取与 readCount 补齐
- 前端:气泡已读标签 / 群回执 popover / 「发送回执消息」下拉入口 / admin 列表「状态」「回执」列与详情对应字段按开关隐藏;自动上报 / 冷启动同步对方已读位置 / WS READ & RECEIPT handler 全部按开关短路兜底,避免打到禁用接口
- 单测:补 @Spy ImProperties 修复原本就在的 NPE,加 disabled 分支断言
2026-05-09 01:07:18 +08:00
3d8b8792fd ♻️ refactor(im): 业务策略数值从 ImCommonConstants 上移到 ImProperties(按 group / message 子模块分组),常量类仅保留 AT_USER_ID_ALL 协议契约值
♻️ refactor(im): 抽出 utils/config.ts 集中数值常量,按业务域统一前缀(GROUP_ / MESSAGE_ / FRIEND_ / CONVERSATION_ / FORWARD_),constants.ts 只留协议枚举与契约值
2026-05-08 17:42:13 +08:00
1db025649a feat(im): 初始化群名片 v0.2:第二次评审(需求各种进群的小问题) 2026-05-07 17:25:03 +08:00
012dc01182 feat(im): 初始化群名片 v0.1:第一次评审 2026-05-07 13:07:56 +08:00
f360040a3d feat(im): 初始化群申请 v0.5:第六把 review(性能 / 健壮性 / 简洁度收口)
后端
- createInviteRequestList N+1 → 3 SQL:批量 select IN + update IN + insertBatch;20 人邀请从 40 RTT 降到 3 RTT
- service 不再出现 mybatis:复用记录的 update(null, wrapper) 下沉到 Im{Group,Friend}RequestMapper.update*Reset helper
- inviteGroupMember 入参去重切 hutool:CollUtil.subtractToList(CollUtil.distinct(...), activeMemberUserIds)
- 删除 dead 字段 inviterUserId(GroupRequestApprovedNotification / GroupRequestRejectedNotification):前端不再消费

前端
- 1505 / 1506 通知改静默:同意走群事件 1509 / 1510 渲染系统提示,拒绝不再打扰
- 修竞态:addByRequestId 校验 handleResult === UNHANDLED,避免 1503 在途时被 1505 / 1506 抢先后又把已处理记录塞回未处理列表
- 修复 dialog 复用记录刷新:watch key 含 inviterUserId / applyContent,同 id 不同内容也触发 refetch;actingId 期间跳过避免本端动作多余 RTT
- 修复 willGoApproval 误报:group.ownerUserId 兜底群主;members 未到位时保守按非审批处理
- unhandledCountMap memoized getter:O(N) 扫一次缓存到 Map,ConversationItem 直读 Map 消除 O(N×M) 重复 filter
2026-05-07 08:13:27 +08:00
ded120902a feat(im): 初始化群申请 v0.4:第五把 review(多轮 finding 修复 + 通知静默化)
- 邀请路径写 addSource=INVITE;群主 / 管理员邀请绕过审批;inviteGroupMember 入参去重
- getGroupRequest 越权校验加成员有效状态判断;新增 list-by-group 接口
- 申请列表按 update_time 倒序,update(null, wrapper) 路径手动刷 updateTime
- addByRequestId 不再 skip 同 id,复用记录刷新并置顶
- GroupRequestListDialog 单群模式订阅 store 增量同步;GroupMemberAddDialog 审批分支文案区分
- ConversationItem 增加 [X 条进群申请] 红字前缀;MessagePanel 顶部胶囊横幅
- 1505 / 1506 通知改静默:同意走群事件渲染系统提示,拒绝不再打扰;清掉 dead inviterUserId 字段
2026-05-07 00:51:48 +08:00
3b0abfb26e feat(im): 初始化群申请 v0.3:第四把 review(优化界面,进一步对齐微信界面)【之前提交错了】 2026-05-06 23:57:54 +08:00
241064e7d0 feat(im): 初始化群申请 v0.3:第四把 review(优化界面,进一步对齐微信界面) 2026-05-06 23:57:03 +08:00
f5bec3eec6 feat(im): 初始化表情包 v0.3:第四把 review(增加表情管理的界面) 2026-05-06 23:00:09 +08:00
ae71b7134c feat(im): 初始化群申请 v0.2:第三把 review 2026-05-06 20:51:46 +08:00
bed0a463f5 feat(im): 初始化表情包 v0.1:第二把 review 2026-05-06 20:50:56 +08:00
8a4ad959d6 feat(im): 初始化表情包 v0.0:第一把 review 2026-05-06 19:42:19 +08:00
b320e3c19a feat(im): 初始化群申请 v0.1:第二把 review 2026-05-06 18:52:31 +08:00
9ebf720901 feat(im): 初始化群申请 v0.0:第一把 review 2026-05-06 14:53:48 +08:00
7d5ee9f1c9 feat(im): 增加名片消息类型 2026-05-06 08:00:36 +08:00
f640b67159 🐛 fix(im):codex 评审修复 IM 单测契约
- ImPrivateMessageServiceImplTest 用 getFriendState 替换已删除的 isFriend mock;返回 ImFriendStateEnum.FRIEND / NONE
- ImFriendServiceImplTest 删 stale 测试(isFriend / addFriend / deleteFriend 二参版本均已不存在);addFriend0 测试参数补齐 null displayName / addSource 凑齐 4 参签名
- 修复后 mvn -pl yudao-module-im test-compile 通过

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 22:32:03 +08:00
f2136c43d3 feat(im): 清理一些 TODO 的修复 2026-05-05 22:04:45 +08:00
28cf87ac8a feat(im): 重构普通消息类型,和 openim 的消息编号对齐 2026-05-05 21:56:05 +08:00
0d7cb763ff feat(im): 增加好友申请的管理界面 2026-05-05 19:36:30 +08:00
43b5de5192 feat(im): 群封禁(GROUP_BANNED)全链路实现
1. ImMessageTypeEnum 新增 GROUP_BANNED(1533) 枚举
2. 新增 GroupBannedNotification 通知 DTO
3. ImGroupMessageSendDTO 新增 ofGroupBanned 工厂方法
4. banGroup / unbanGroup 添加 operatorUserId 参数,
   封禁/解封后广播 GROUP_BANNED 通知到全群成员
5. ImGroupManagerController 传递 getLoginUserId()
2026-05-05 18:41:11 +08:00
96ae9bbcb0 feat(IM):群禁言功能(阶段二后端)
1. im_group 新增 muted_all 字段,支持全群禁言 / 取消
2. im_group_member 新增 mute_end_time 字段,支持单成员定时禁言
3. 新增 3 个 API:mute-all、mute-member、cancel-mute-member
4. sendGroupMessage 新增 validateMuteStatus 拦截:全群禁言(群主/管理员豁免)+ 成员禁言
5. 三档分层权限校验:群主可禁言管理员和普通成员,管理员仅可禁言普通成员
6. 新增 4 个 WebSocket 通知类(1512-1515)及对应 DTO 工厂方法
7. Mapper 新增 updateMuteEndTimeNull 方法,解决 MyBatis-Plus 默认跳过 null 字段的问题
8. 新增 5 个错误码(GROUP_MUTED_CANNOT_SEND 等)
2026-05-05 14:44:23 +08:00
14090be672 feat(im):将"免打扰"字段从 muted 全量重命名为 silent(DO/VO/Service/Mapper/测试/SQL + 前端 types/store/组件/管理后台),为后续 mute 禁言功能腾出词族 2026-05-05 13:51:53 +08:00
6e1a8dc343 feat(im):删好友「同时清空聊天记录」选项(DTO 加 persistent 单边 TIP + clear 跨端透传 + clear=true 跳 TIP 避免复活会话;silent re-add 补单边 TIP) 2026-05-05 00:57:49 +08:00
9d11741aa5 feat(im): 优化好友重新添加逻辑,增强用户体验
实现好友关系的单边重新启用,推送隐私友好的通知,确保对方不感知好友状态的变化。此变更提升了用户在好友管理中的体验,保持了社交互动的自然性。
2026-05-05 00:39:44 +08:00
9720f20865 feat(im):增加好友删除时,增加是否删除本地聊天的选项 2026-05-05 00:33:06 +08:00
ee01a16f8b 🐛 fix(im):好友模块 codex 评审修复——1209 改 @TransactionalEventListener(AFTER_COMMIT) 避免幽灵通知、Consumer 过滤下推 SQL、申请表索引带 id 后缀覆盖游标排序、limit 加 @Max(200) 上限 2026-05-04 23:06:04 +08:00
eb2d3c39e2 ♻️ refactor(im):用户申请列表,增加流式查询,避免一次性加载过多,或者历史无法被加载到。 2026-05-04 22:46:21 +08:00
b5f5c408ee feat(im):好友申请通知 payload 补 fromNickname/fromAvatar,前端按 requestId 直推不再回拉 2026-05-04 21:13:29 +08:00
b0221c21cd ♻️ refactor(im):好友模块清理——删 ImFriendServiceImpl 无效的 getSelf 自代理 + Controller 回调参数命名展开 + Consumer 补日志 + silent re-add 注释补充 2026-05-04 19:52:37 +08:00
b51ca45531 feat(im):实现 1209 FRIEND_INFO_UPDATED 推送链路 + 修 deleteFriend 单边 TIP bug + FRIEND_APPLICATION 重命名 FRIEND_REQUEST_RECEIVED 2026-05-04 19:14:36 +08:00
b0ca5b7550 feat(im):实现 1209 FRIEND_INFO_UPDATED 推送(system 发 AdminUserProfileUpdateMessage,IM Consumer 监听后批量推好友多端) 2026-05-04 18:23:58 +08:00
2804e5ed10 fix(im):好友模块 code review 多项修复(缓存事务时序、agree/refuse 乐观锁、补建表 SQL 与字典 seed);framework 开 setTransactionAware 让 @CacheEvict 自动事务感知 2026-05-04 17:31:13 +08:00
ba1a85e38a feat(im): 优化好友申请逻辑,增加自我添加校验与异常处理
更新好友申请功能,使用 computed 包裹当前用户 ID,避免在 keep-alive 实例中持有旧 ID。增加自我添加好友的校验逻辑,防止用户添加自己为好友。同时,增强自动通过好友申请的异常处理,确保在事务提交后能正确处理失败情况。
2026-05-04 16:41:55 +08:00
f0be7ba137 feat(im): 优化好友请求处理逻辑,增加乐观锁支持 2026-05-04 16:29:11 +08:00
e266a0fc4a ♻️ refactor(cache): 开启事务感知以优化缓存管理 2026-05-04 16:10:31 +08:00
f03143c4ee refactor(im): 移除 TIP_TIME 消息类型,时间分隔条改为渲染时按 prevMessage.sendTime 计算
顺带修复 Bug-Y(删除最后一条消息后孤立时间分隔条)
2026-05-04 16:05:23 +08:00
cee0688c30 feat(im): 增加好友申请的逻辑(v1.3:修复各种边界情况,包括静默添加好友) 2026-05-04 11:08:03 +08:00
63d00c2bf2 feat(im): 增加好友申请的逻辑(v1.1:增加各种 code review 注释) 2026-05-04 09:47:25 +08:00
479af56a08 feat(im): 增加好友申请的逻辑(v1) 2026-05-04 09:18:36 +08:00
bfd374eedf ♻️ refactor(im): 清理代码中的 TODO 注释并优化逻辑 2026-05-03 13:27:31 +08:00
4d2f470499 feat(im): 增加群消息的置顶 2026-05-03 12:53:25 +08:00
6e823f848d feat(im): 增加群消息的置顶 2026-05-03 12:15:40 +08:00
ed9f23ae8c feat(im): 重构群通知相关,对齐 openim 的消息编号(继续优化代码) 2026-05-03 09:22:54 +08:00
719b139ca2 feat(im): 重构群通知相关,对齐 openim 的消息编号 2026-05-03 02:00:43 +08:00
1c29398da5 feat(im): 增加群角色(管理员) 2026-05-02 14:31:43 +08:00
37dfd6c07b feat(im): ImMessageTypeEnum 显式化 persistent + normal,sendXxxMessage 收敛 insert 与推送 2026-05-02 09:05:54 +08:00
41762f0ae5 feat(im): 优化【消息引用】的功能,来自第二波 code review,解决安全性问题 2026-05-01 18:20:04 +08:00
53fc9ae4fc feat(im): 优化【消息引用】的功能,来自第一波 code review 2026-05-01 18:09:02 +08:00
ae36d1ad68 feat(im): 增加【消息引用】的功能 2026-05-01 18:03:05 +08:00
5f88c9ca35 feat(im): 增加消息查询条件以排除已删除记录 2026-05-01 11:02:44 +08:00
45889459b7 feat(im): 实现 im 的首页统计 2026-05-01 09:25:39 +08:00
af6e283f49 feat(im): 增加群管理的完善 2026-05-01 08:19:13 +08:00
782314f17f feat(im): 增加群聊消息的管理 2026-05-01 07:08:05 +08:00
a4901a848d feat(im): 增加 pinyin 功能 2026-04-30 15:22:35 +08:00
fc90635abb feat(im): 新增 friend、group 等管理后台接口 2026-04-30 14:07:39 +08:00
37f0a8a01d feat(im): 增加 friend、group、message、sensitiveword 的管理接口 2026-04-30 08:46:28 +08:00
d0fbab8f73 feat(im): 完善 friend、group 相关的本地存储(疯狂优化) 2026-04-29 22:03:55 +08:00
96f2d1a12f feat(friend): 添加好友展示备注功能 2026-04-28 23:28:27 +08:00
b0b707ddc0 feat(im): 新增 GET /max-read-message-id,私聊多端 / 离线补齐对方已读位置
私聊已读语义在 message.status 字段翻转,离线期间错过的 RECEIPT 推送没法
重放(与群聊不同——群聊已读在 message.readCount / receiptStatus 字段自带)。
新增此接口:客户端进入私聊会话或断线重连后,拿到对方已读到我发的最大消息
id,按 id <= maxReadId 翻转本地自发消息状态。
实现:
- mapper 用 order by id desc + LIMIT 1 命中 (sender_id, receiver_id)
  索引,无 max() 聚合开销;
- 对方一条都没读过时返回 null(不做 0 兜底,避免与真实 id 混淆);
- 补齐 hit / miss 两个单测。
2026-04-26 10:30:45 +08:00
711a692c3a 🐛 fix(im): 私聊已读改用前端上报 messageId,修正 RECEIPT 语义
原 readPrivateMessages 走 select-then-update 两步:先按 status=UNREAD
查未读,再按 id in 翻转,maxReadId 取已 update 行的 max(id)。该实现
有两个问题:
1. select 与 update 之间到达的消息会被一并标 READ,但 maxReadId 取的
   是 select 时刻的最大值,对方端拿到的「已读到哪条」语义偏弱,前端按
   maxReadId 卡边界时也会与实际状态错位。
2. 接口与群聊 readGroupMessages(userId, groupId, messageId) 不对称。

改造:
- 接口签名增加 messageId(已读位置,含),与群聊接口对齐;
- mapper 替换为单条 update SQL:id <= messageId AND status=UNREAD 直接
  翻转,消除两步竞态窗口;
- READ / RECEIPT 推送的已读位置直接采用前端上报的 messageId;
- 落地 ImPrivateMessageServiceImpl 原 TODO;
- 同步更新 testReadMessages_success / testReadMessages_noUnread。
2026-04-26 09:41:44 +08:00
ceb609785b 🐛 fix(im): 群聊离线拉取移除 status=RECALL 过滤,与私聊行为对齐 2026-04-26 00:11:18 +08:00
8d1089bac5 🐛 fix(im): 撤回信号错用 TIP_TEXT,应为 RECALL 2026-04-25 11:42:35 +08:00
3ca5db95e0 ♻️ refactor(im): 重命名 IM 模块中的多个类和文件以简化结构 2026-04-23 20:38:27 +08:00
8b908afe90 ♻️ refactor(error): 优化错误码常量和消息提示 2026-04-23 20:08:30 +08:00
6a10ed7d88 feat(im): 更新群组和群成员相关接口,优化成员查询逻辑
- 修改获取当前用户群组列表的接口名称,提升语义清晰度
- 新增获取有效群成员 userId 列表的接口,支持缓存
- 优化群成员查询逻辑,确保历史消息可见性
- 更新相关文档,补充接口说明和使用示例
2026-04-23 18:43:53 +08:00
16d5c0b0cc feat(im): 完善单元测试 2026-04-23 13:46:14 +08:00
843cc90b87 feat(im): 好友/群成员添加增加唯一索引并发兼容
- ImFriendServiceImpl#addFriend0 捕获 DuplicateKeyException
  并发插入冲突时忽略(另一方插入必然为 ENABLE 状态)
- ImGroupMemberServiceImpl#addGroupMember 改为先查后写:
  已存在 DISABLE 复活为 ENABLE,新记录 insert 失败降级 select 返回
- ImGroupMemberServiceImpl#addGroupMembers 批量 insert 触发
  DuplicateKey 时降级为逐个 addGroupMember 走幂等兜底
- 依赖 im_friend.uk_user_friend 与 im_group_member.uk_group_user 唯一索引
2026-04-23 09:12:10 +08:00
106cf1c9f7 feat(im): 好友/群成员添加增加唯一索引并发兼容
- ImFriendServiceImpl#addFriend0 捕获 DuplicateKeyException
  并发插入冲突时忽略(另一方插入必然为 ENABLE 状态)
- ImGroupMemberServiceImpl#addGroupMember 改为先查后写:
  已存在 DISABLE 复活为 ENABLE,新记录 insert 失败降级 select 返回
- ImGroupMemberServiceImpl#addGroupMembers 批量 insert 触发
  DuplicateKey 时降级为逐个 addGroupMember 走幂等兜底
- 依赖 im_friend.uk_user_friend 与 im_group_member.uk_group_user 唯一索引
2026-04-23 09:10:05 +08:00
f5b0750899 ♻️ refactor(im): 将 ImUserController 迁移到 system UserController
- 删除 ImUserController、ImUserRespVO(get-self 复用 UserProfileController#getUserProfile)
- UserController 新增 /get-simple、/list-by-nickname 免鉴权接口(用于 IM 点头像弹名片、加好友搜索),整理到类末尾并用分隔注释标识
- UserSimpleRespVO 补充 avatar、sex 字段
2026-04-23 01:35:41 +08:00
1896721575 feat(im): 群/好友更新消息改为事件通知,客户端自行拉取
- ImPrivateMessageDTO#ofFriendUpdate 移除 content 字段,删除 FriendUpdateMessage
- 新增 ImMessageTypeEnum.GROUP_MEMBER_UPDATE(203) 和 ofGroupMemberUpdate 工厂方法
- ImGroupMemberServiceImpl#updateGroupMember 推送成员变更事件(多端同步,仅推自己)
2026-04-23 01:17:48 +08:00
44981bbee8 ♻️ refactor(im): 拆分 updateGroup 职责,仅保留群整体信息更新
- ImGroupServiceImpl#updateGroup 移除 displayUserName/displayGroupName 处理逻辑,交由 updateGroupMember 承接
- 去掉 hasGroupInfoChange 判断,群主校验前置
- ImGroupUpdateReqVO 清理 displayUserName/displayGroupName 字段
2026-04-23 01:12:27 +08:00
fa474c6f03 feat(group): 添加群聊成员限制及邀请好友功能
新增群聊成员人数限制,最大成员数为 500。实现邀请好友功能,确保被邀请人是当前用户的好友,若不是则返回相应错误提示。更新相关提示信息以提升用户体验。
2026-04-23 00:59:17 +08:00
84304718dc feat(group): 支持批量邀请和移除群成员 2026-04-23 00:42:36 +08:00
b814a87361 feat(group): 添加群成员管理功能
实现群成员的邀请、退出和移除功能,支持群主对成员的管理操作。新增相关提示信息和错误码,优化群成员更新逻辑,确保群主无法退出群聊。

BREAKING CHANGE: 群解散相关逻辑已调整为群删除,更新了相关消息推送类型
2026-04-22 23:57:45 +08:00
ea29dea47c feat(group): 添加群解散功能及提示消息
实现群解散功能,允许群主解散群聊并通知所有成员。新增群解散提示消息格式,并在解散时推送给群成员。增加了校验用户是否为群主的方法,以确保操作的合法性。
2026-04-22 22:24:50 +08:00
886f4fe1f2 ♻️ refactor(group): 移除冗余方法并添加群成员移除功能
重构 ImGroupMemberMapper 和 ImGroupMemberService,移除不再使用的方法,简化代码结构。同时新增群成员移除功能,以支持群解散场景。
2026-04-22 22:11:45 +08:00
cce3dd6110 feat(group): 添加群消息已读位置管理功能
新增删除用户在群中的已读位置和清理群所有用户已读位置的功能,支持成员退群和群解散场景。此功能提升了群消息管理的灵活性和用户体验。
2026-04-22 21:48:10 +08:00
443d91d7e3 feat(group): 添加群成员邀请和更新功能
新增群成员邀请接口,允许用户将其他用户加入群组。同时,更新群成员信息的接口也进行了调整,以支持群内昵称和群名备注的修改。这些改动提升了群组管理的灵活性和用户体验。
2026-04-22 21:16:07 +08:00
e4a6cc932c feat(websocket): 支持事务感知的异步消息推送
实现了在事务中推送 WebSocket 消息的功能,确保在事务提交后再发送消息,避免客户端在查询数据库时看到未提交的变更。新增了 executeAfterTransaction 方法来处理事务同步,优化了私聊和群聊消息的发送逻辑。
2026-04-22 18:47:15 +08:00
736a1a2f16 feat(group): 添加用户侧群组和成员接口
新增获取当前登录用户的群列表和指定群的成员列表接口,支持聚合用户昵称和头像信息。优化群组成员查询逻辑,确保返回有效的群成员数据。

- 实现用户侧接口 `/im/group/list-my` 和 `/im/group-member/list`
- 聚合 AdminUser 的昵称和头像信息
- 过滤已封禁和解散的群组
2026-04-22 12:54:23 +08:00
2ab72872be feat(im): 添加 WebSocket 服务和消息推送功能
新增 IM WebSocket 服务接口及实现,支持异步推送私聊和群聊消息。
同时,重构相关消息发送逻辑,提升代码可读性和维护性。

- 新增 ImWebSocketService 接口
- 实现 ImWebSocketServiceImpl 类
- 更新 ImPrivateMessageService 和 ImGroupMessageService 以使用 WebSocket 推送
2026-04-22 12:30:08 +08:00
e2eab4fe78 ♻️ refactor(friend): 重构好友关系管理逻辑,简化双向绑定方法 2026-04-22 08:52:18 +08:00
15a1bca721 ♻️ refactor(friend): 重构好友关系相关逻辑,优化查询与缓存 2026-04-22 08:39:53 +08:00
affc409022 feat(friend): 新增 IM 好友管理功能,包括添加、删除和更新好友信息
实现了 IM 好友的增删改查功能,支持免打扰状态的更新。新增了相关的请求和响应对象,优化了好友列表的获取逻辑,并确保了好友关系的有效性校验。

- 新增 ImFriendRespVO 和 ImFriendUpdateReqVO 类
- 更新 ImFriendController,添加更新好友信息接口
- 实现 ImFriendService 中的好友管理逻辑
2026-04-21 22:20:52 +08:00
758dfcbb80 📝 docs(controller): 移除控制器类中的作者注释 2026-04-21 20:33:23 +08:00
ef19ad396e feat(message): 优化群聊的 pullGroupMessageList 方法,解决空分页的风险(pro 增强) 2026-04-21 20:11:01 +08:00
2f084bd5e6 feat(message): 优化群聊的 pullGroupMessageList 方法,解决空分页的风险(pro 增强) 2026-04-21 20:10:52 +08:00
99121a25e0 feat(message): 优化群聊的 pullGroupMessageList 方法,解决空分页的风险(pro 增强) 2026-04-21 20:02:47 +08:00
0ce9eddedb feat(message): 优化群聊的 pullGroupMessageList 方法,解决空分页的风险 2026-04-21 17:23:16 +08:00
bf8eb273ec feat(message): 优化群聊的 enrichPullResultForViewer 已读状态、人数 2026-04-21 14:04:21 +08:00
5e824ccb4e feat(message): 优化群聊的 enrichPullResultForViewer 已读状态、人数 2026-04-21 13:20:05 +08:00
3361c75726 feat(message): 继续优化群聊的实现 2026-04-21 11:24:49 +08:00
7057f4715b feat(message): 群聊消息的实现 2026-04-21 09:52:06 +08:00
26413e09f3 feat(message): 评审群聊消息的实现 2026-04-21 00:48:14 +08:00
37cf16c142 📝 docs(model): 添加好友和群组状态字段说明 2026-04-20 23:27:47 +08:00
ab35a13fc4 📝 docs(diff): 更新私聊已读位置文档,明确不再通过 HTTP 读取 maxReadId 2026-04-20 21:24:46 +08:00
4ab9569508 feat(im):优化私聊的推送为 ImPrivateMessageDTO 2026-04-20 20:33:36 +08:00
ca0aadac49 feat(im): 添加私聊历史消息列表请求对象及相关接口调整 2026-04-20 19:08:52 +08:00
e89724e5cd feat(im): 修复单测报错的问题 2026-04-20 14:32:56 +08:00
f495f9c805 feat(im): 添加私聊私聊历史消息查询功能 2026-04-20 14:04:30 +08:00
2ce3a99318 feat(im): 优化消息撤回的逻辑 2026-04-20 10:03:35 +08:00
04ea1c9db5 feat(im): 优化私聊消息已读的处理逻辑 2026-04-20 08:57:18 +08:00
dc7be58506 feat(im): 优化 pullPrivateMessageList 接口的实现 2026-04-19 23:23:58 +08:00
d6d90b2bd5 feat(im): 优化 private message 单挑的发送接口 2026-04-19 22:31:26 +08:00
bee3337925 ♻️ refactor(im): 重命名群聊和私聊消息相关方法以提高可读性
重构了 ImGroupMessageController 和 ImPrivateMessageController 中的方法名称,
将 sendMessage、pullMessages、readMessages 和 recallMessage 等方法重命名为
sendGroupMessage、pullGroupMessages、readGroupMessages 和 recallGroupMessage。
同时,更新了相关服务和测试类中的调用,确保一致性和可读性。
2026-04-18 23:31:31 +08:00
ac05257ec0 ♻️ refactor(im): 修改消息相关字段类型为 Long,优化数据处理 2026-04-18 21:50:50 +08:00
6da5846b74 📝 refactor(ImGroupMessageRespVO): change user ID fields to List<Long> for better type safety 2026-04-12 19:29:50 +08:00
0e3373c553 📝 refactor: 移除不必要的字段,更新接口方法以支持状态参数 2026-04-12 19:21:49 +08:00
5ded56853e 📝 feat(im): 完成代码审查,更新逻辑删除和敏感词处理,优化群聊消息和好友关系管理 2026-04-12 19:01:21 +08:00
16e1936c48 📝 refactor(group): 重命名群组成员相关类和包,统一状态枚举为 CommonStatusEnum 2026-04-12 16:27:26 +08:00
ba5b7dbd0a 📝 refactor(messages): 重构消息类,简化字段命名和结构,增强可读性 2026-04-12 15:27:21 +08:00
f6a29e530a 📝 refactor(im): 移除后端的 conversation 2026-04-12 11:54:00 +08:00
1269e626ca 📝 refactor(controller): 移除不必要的导入和注解,优化会话和群成员响应对象 2026-04-12 11:15:54 +08:00
0ac1f0cc28 📝 refactor(controller): 移除不必要的导入和注解,优化会话和群成员响应对象 2026-04-12 11:15:38 +08:00
2d277765e7 📝 feat(im): 添加 IM 相关服务接口和实现,更新消息处理逻辑,增强敏感词校验功能 2026-04-12 10:39:47 +08:00
984312302a Merge branch 'master-jdk17' of https://gitee.com/zhijiantianya/ruoyi-vue-pro into feature/im-dev
# Conflicts:
#	pom.xml
#	script/idea/http-client.env.json
#	yudao-server/pom.xml
2026-04-11 00:05:34 +08:00
da92b5a582 接口融合 2025-01-04 20:12:17 +08:00
b49339d08b conversationNo更新 2025-01-04 20:08:24 +08:00
1f58fd2be4 【代码评审】IM:消息相关接口 2024-12-15 19:17:04 +08:00
d67362043e 创建会话 2024-12-12 22:21:35 +08:00
29a3ad42b6 TODO处理 2024-10-28 16:29:38 +08:00
40be6ed727 【代码评审】IM:会话、消息相关的接口 2024-10-28 09:30:04 +08:00
788c24dff4 会话和消息处理 2024-10-26 19:42:34 +08:00
b540f8d46d 字段更新 2024-10-19 16:09:27 +08:00
dd272cb54a 【功能修复】IM:解决报错问题 2024-10-14 19:31:16 +08:00
267477c973 Merge branch 'master-jdk17' of https://gitee.com/zhijiantianya/ruoyi-vue-pro into feature/im-dev
# Conflicts:
#	pom.xml
#	yudao-server/pom.xml
#	yudao-server/src/main/resources/application-local.yaml
#	yudao-server/src/main/resources/application.yaml
2024-10-14 12:29:27 +08:00
2c5287a9b2 【修复】IM:基于 offset 读取不到消息时,报 NPE 问题 2024-06-12 19:46:06 +08:00
f1731e4446 【代码评审】IM:review IM 前缀的命名 2024-06-03 12:56:59 +08:00
dc28c7e8a2 【代码评审】IM:review IM 前缀的命名 2024-06-03 12:56:54 +08:00
bada82f8cc 修改:代码优化 2024-06-02 17:01:28 +08:00
651619d5ef 【代码评审】IM:review 消息的实现 2024-04-28 19:46:13 +08:00
fa23ce144d 修改:代码优化 2024-03-31 22:47:18 +08:00
2b891cb432 IM:code review 消息发送的实现 2024-03-30 22:01:44 +08:00
84cea03752 修改: 发送消息使用WebSocketSenderApi 2024-03-28 22:33:01 +08:00
51e724fc90 修改:置顶会话和更新最后已读时间 2024-03-28 21:39:24 +08:00
4015b2f213 新增:消息发送和代码优化 2024-03-27 23:28:00 +08:00
0e79d8ec53 im:code review 消息发送的逻辑 2024-03-21 13:54:49 +08:00
9c1764e36e im:code review 代码风格方面 2024-03-21 12:51:03 +08:00
04123e5987 新增:获取会话列表,获取消息列表 2024-03-18 20:59:29 +08:00
354fe6fcab 新增:im sql建表语句 2024-03-16 15:48:59 +08:00
9cee1b3ceb 新增:群聊发送 2024-03-16 15:43:42 +08:00
f694825435 修改:私聊发送代码优化 2024-03-16 12:17:35 +08:00
ff88d53b3b 新增:私聊消息发送成功后保存会话列表 2024-03-16 11:57:52 +08:00
77f3131ef3 修改:私聊消息判断发送状态 2024-03-13 23:57:34 +08:00
2d052ea752 修改:私聊消息存库 2024-03-13 22:48:18 +08:00
3696b666f4 修改:im 模块简单实现 2024-03-13 21:43:05 +08:00
bb0b7056cf 新建:im 模块 2024-03-12 17:01:37 +08:00
707 changed files with 45831 additions and 1924 deletions

BIN
.DS_Store vendored

Binary file not shown.

1
.gitignore vendored
View File

@ -51,4 +51,5 @@ rebel.xml
application-my.yaml
/yudao-ui-app/unpackage/
.DS_Store
**/.DS_Store

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -28,8 +28,8 @@
| 【完整版】[ruoyi-vue-pro](https://gitee.com/zhijiantianya/ruoyi-vue-pro) | [`master`](https://gitee.com/zhijiantianya/ruoyi-vue-pro/tree/master/) 分支 | [`master-jdk17`](https://gitee.com/zhijiantianya/ruoyi-vue-pro/tree/master-jdk17/) 分支 |
| 【精简版】[yudao-boot-mini](https://gitee.com/yudaocode/yudao-boot-mini) | [`master`](https://gitee.com/yudaocode/yudao-boot-mini/tree/master/) 分支 | [`master-jdk17`](https://gitee.com/yudaocode/yudao-boot-mini/tree/master-jdk17/) 分支 |
* 【完整版】包括系统功能、基础设施、会员中心、数据报表、工作流程、商城系统、微信公众号、CRM、ERP、WMS、MES、AI 大模型、IoT 物联网 等功能
* 【精简版】只包括系统功能、基础设施功能不包括会员中心、数据报表、工作流程、商城系统、微信公众号、CRM、ERP、WMS、MES、AI 大模型、IoT 物联网 等功能
* 【完整版】包括系统功能、基础设施、会员中心、数据报表、工作流程、商城系统、微信公众号、CRM、ERP、WMS、MES、IM 即时通讯、AI 大模型、IoT 物联网等功能
* 【精简版】只包括系统功能、基础设施功能不包括会员中心、数据报表、工作流程、商城系统、微信公众号、CRM、ERP、WMS、MES、IM 即时通讯、AI 大模型、IoT 物联网等功能
可参考 [《迁移文档》](https://doc.iocoder.cn/migrate-module/) ,只需要 5-10 分钟,即可将【完整版】按需迁移到【精简版】
@ -102,7 +102,7 @@
团队包含专业的项目经理、架构师、前端工程师、后端工程师、测试工程师、运维工程师,可以提供全流程的外包服务。
项目可以是商城、SCRM 系统、OA 系统、物流系统、ERP 系统、CMS 系统、HIS 系统、支付系统、IM 聊天、微信公众号、微信小程序等等。
项目可以是商城、SCRM 系统、OA 系统、物流系统、ERP 系统、CMS 系统、HIS 系统、支付系统、IM 即时通讯、微信公众号、微信小程序等等。
## 🐼 内置功能
@ -308,6 +308,19 @@
![预览图](/.image/common/iot-preview.png)
### IM 即时通讯
演示地址Boot<https://doc.iocoder.cn/im-preview/>
演示地址Vue3 + Element Plus<http://dashboard-vue3.yudao.iocoder.cn>
![功能图](/.image/common/im-feature.png)
| 聊天界面 | 聊天管理 |
| --- | --- |
| ![聊天界面](/.image/common/im-preview-home.png) | ![聊天管理](/.image/common/im-preview-manager.png) |
## 🐨 技术栈
### 模块
@ -327,6 +340,7 @@
| `yudao-module-crm` | CRM 系统的 Module 模块 |
| `yudao-module-mes` | MES 系统的 Module 模块 |
| `yudao-module-wms` | WMS 系统的 Module 模块 |
| `yudao-module-im` | IM 即时通讯的 Module 模块 |
| `yudao-module-ai` | AI 大模型的 Module 模块 |
| `yudao-module-iot` | IoT 物联网的 Module 模块 |
| `yudao-module-mp` | 微信公众号的 Module 模块 |

17
pom.xml
View File

@ -26,6 +26,7 @@
<!-- <module>yudao-module-iot</module>-->
<!-- <module>yudao-module-mes</module>-->
<!-- <module>yudao-module-wms</module>-->
<!-- <module>yudao-module-im</module>-->
<!-- 请参考 https://doc.iocoder.cn/ai/build/ 文档,完成 AI 模块的启动!!! -->
<!-- <module>yudao-module-ai</module>-->
</modules>
@ -35,17 +36,17 @@
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
<properties>
<revision>2026.04-SNAPSHOT</revision>
<revision>2026.05-SNAPSHOT</revision>
<!-- Maven 相关 -->
<java.version>21</java.version>
<java.version>17</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
<maven-surefire-plugin.version>3.5.3</maven-surefire-plugin.version>
<maven-compiler-plugin.version>3.14.0</maven-compiler-plugin.version>
<flatten-maven-plugin.version>1.7.2</flatten-maven-plugin.version>
<!-- maven-surefire-plugin 暂时无法通过 bom 的依赖读取(兼容老版本 IDEA 2024 及以前版本) -->
<lombok.version>1.18.46</lombok.version>
<spring.boot.version>4.0.6</spring.boot.version>
<lombok.version>1.18.42</lombok.version>
<spring.boot.version>3.5.9</spring.boot.version>
<mapstruct.version>1.6.3</mapstruct.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
@ -149,14 +150,6 @@
<!-- 使用 huawei / aliyun 的 Maven 源,提升下载速度 -->
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>huaweicloud</id>
<name>huawei</name>

View File

@ -0,0 +1,32 @@
# LiveKit Server PoC
最小可用的 LiveKit Server 自部署验证环境,用于零期 PoC。
## 启动
```bash
cd tools/livekit-poc
docker compose up -d
bash verify.sh
```
## 端口
- 7880HTTP / WebSocket 信令;
- 7881WebRTC TCP fallback
- 7882/UDPWebRTC 媒体;
- macOS / Windows当前 `docker-compose.yml` 走端口映射模式webhook URL 用 `host.docker.internal:48080` 让容器访问到宿主机 yudao 后端;
- macOS 上 host network`network_mode: host`)需要 Docker Desktop 4.34+ 并在 Settings → Resources → Network 勾选「Enable host networking」老版本静默失败容器跑得起来但端口完全不通
- Linux可以把 `docker-compose.yml` 改成 `network_mode: host` + 删 `ports:` 段,并把 `livekit.yaml` 的 webhook URL 改为 `http://127.0.0.1:48080/admin-api/im/livekit/webhook`
## 凭据 (仅 PoC勿用于生产)
- `LIVEKIT_KEYS=devkey: secret-poc-key-min-32-chars-required-here`
- API Key`devkey`
- API Secret`secret-poc-key-min-32-chars-required-here`
生产环境必须改用强随机 secret并通过 `--config /etc/livekit.yaml` 加载。
## 浏览器联调
`verify.sh` 跑完会输出一个 `meet.livekit.io` 链接,用两个浏览器(或两台机器)打开同一链接即可看到对方画面。

View File

@ -0,0 +1,20 @@
services:
livekit:
image: docker.m.daocloud.io/livekit/livekit-server:latest
container_name: yudao-livekit-dev
restart: unless-stopped
# 端口映射模式
# macOS / Windows 必走这种方式Docker Desktop 4.34 以下没有 host network
# Linux 可以改 network_mode: host 省去映射,并把 livekit.yaml 的 webhook url 换成 127.0.0.1
ports:
- "7880:7880" # HTTP / WebSocket 信令
- "7881:7881" # WebRTC TCP fallback
- "7882:7882/udp" # WebRTC UDP (dev 模式 UDP mux 单端口)
volumes:
# 挂载 config 文件webhook 配置在 livekit.yaml 里
- ./livekit.yaml:/etc/livekit.yaml:ro
command:
- --config
- /etc/livekit.yaml
- --bind
- 0.0.0.0

View File

@ -0,0 +1,30 @@
# LiveKit Server 本地开发配置PoC 用,勿用于生产)
# 替代 docker --dev 模式;为支持 webhook 必须用 config 文件而非 env
keys:
devkey: secret-poc-key-min-32-chars-required-here
# 端口
port: 7880
rtc:
tcp_port: 7881
udp_port: 7882
use_external_ip: false
# Webhook成员离开 / 房间结束等事件回调到 yudao 后端做业务态兜底清理
# host.docker.internal 让容器访问宿主机 macOS / Windows 上的 yudao 后端
# Linux 上 docker compose 可改 network_mode: host这里同步改成 127.0.0.1
# api_key 用于签发 JWTyudao 后端用相同 secret 验证签名
webhook:
api_key: devkey
urls:
- http://host.docker.internal:48080/admin-api/im/livekit/webhook
# 房间无人时多久销毁;秒;之前 PoC 默认 300给低些方便排查
room:
empty_timeout: 300
departure_timeout: 20
# 开发模式:放宽 secret 长度限制 + 内置 TURN 服务
development: true
log_level: info

105
script/livekit-poc/verify.sh Executable file
View File

@ -0,0 +1,105 @@
#!/usr/bin/env bash
# LiveKit Server PoC 验证脚本;
# 用法: bash verify.sh
set -e
API_KEY="${LIVEKIT_API_KEY:-devkey}"
API_SECRET="${LIVEKIT_API_SECRET:-secret-poc-key-min-32-chars-required-here}"
HOST="${LIVEKIT_HOST:-localhost:7880}"
ROOM="${LIVEKIT_ROOM:-poc-room}"
ok() { printf "[OK] %s\n" "$1"; }
fail() { printf "[FAIL] %s\n" "$1"; exit 1; }
echo "==> 1/5 等待 HTTP 端点就绪 (http://${HOST}/)"
for i in $(seq 1 20); do
code=$(curl -s -o /dev/null -w "%{http_code}" "http://${HOST}/" || echo "000")
[ "$code" = "200" ] && { ok "HTTP 200"; break; }
[ $i -eq 20 ] && fail "20 秒内未就绪 (last code=${code})"
sleep 1
done
echo "==> 2/5 签发管理 + 客户端权限 Token"
TOKEN=$(API_KEY="$API_KEY" API_SECRET="$API_SECRET" ROOM="$ROOM" python3 - <<'PY'
import json, time, hmac, hashlib, base64, os
def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
header = b64u(json.dumps({"alg":"HS256","typ":"JWT"}, separators=(',',':')).encode())
payload = b64u(json.dumps({
"iss": os.environ["API_KEY"],
"sub": "poc-tester",
"name": "PoC Tester",
"video": {
"roomJoin": True, "room": os.environ["ROOM"],
"canPublish": True, "canSubscribe": True, "canPublishData": True,
"roomCreate": True, "roomList": True, "roomAdmin": True
},
"exp": int(time.time()) + 3600,
"nbf": int(time.time())
}, separators=(',',':')).encode())
sig = b64u(hmac.new(os.environ["API_SECRET"].encode(),
f"{header}.{payload}".encode(),
hashlib.sha256).digest())
print(f"{header}.{payload}.{sig}")
PY
)
[ -n "$TOKEN" ] || fail "Token 生成失败"
ok "Token 已生成 (${#TOKEN} chars)"
echo "==> 3/5 创建房间 ${ROOM} (CreateRoom RPC)"
create_resp=$(curl -s -X POST "http://${HOST}/twirp/livekit.RoomService/CreateRoom" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"name\":\"${ROOM}\",\"empty_timeout\":300,\"max_participants\":10}")
echo " 响应: $create_resp"
echo "$create_resp" | jq -e '.sid' >/dev/null 2>&1 \
&& ok "房间已创建" \
|| fail "CreateRoom 失败"
echo "==> 4/5 列出房间 (ListRooms RPC)"
list_resp=$(curl -s -X POST "http://${HOST}/twirp/livekit.RoomService/ListRooms" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{}')
room_count=$(echo "$list_resp" | jq '.rooms | length' 2>/dev/null || echo "0")
ok "当前房间数: ${room_count}"
echo "$list_resp" | jq '.'
echo "==> 5/5 删除房间 (DeleteRoom RPC) —— 清理"
del_resp=$(curl -s -X POST "http://${HOST}/twirp/livekit.RoomService/DeleteRoom" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"room\":\"${ROOM}\"}")
ok "删除响应: $del_resp"
# 重新签一个仅 client 权限的 token用于浏览器进会
CLIENT_TOKEN=$(API_KEY="$API_KEY" API_SECRET="$API_SECRET" ROOM="$ROOM" python3 - <<'PY'
import json, time, hmac, hashlib, base64, os
def b64u(b): return base64.urlsafe_b64encode(b).rstrip(b'=').decode()
header = b64u(json.dumps({"alg":"HS256","typ":"JWT"}, separators=(',',':')).encode())
payload = b64u(json.dumps({
"iss": os.environ["API_KEY"],
"sub": "browser-tester",
"name": "Browser",
"video": {
"roomJoin": True, "room": os.environ["ROOM"],
"canPublish": True, "canSubscribe": True, "canPublishData": True
},
"exp": int(time.time()) + 7200
}, separators=(',',':')).encode())
sig = b64u(hmac.new(os.environ["API_SECRET"].encode(),
f"{header}.{payload}".encode(),
hashlib.sha256).digest())
print(f"{header}.{payload}.{sig}")
PY
)
echo ""
echo "============================================================"
echo " LiveKit Server 验证通过"
echo "============================================================"
echo " 浏览器测试 (开两个窗口能互通)"
echo " https://meet.livekit.io/?liveKitUrl=ws%3A%2F%2F${HOST}&token=${CLIENT_TOKEN}"
echo ""
echo " 停止服务:"
echo " docker compose -f tools/livekit-poc/docker-compose.yml down"
echo "============================================================"

View File

@ -1552,9 +1552,9 @@ INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_t
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3443, 1, '草稿', '0', 'mes_wm_product_produce_status', 0, 'info', '', '草稿状态', '1', '2026-04-05 15:53:46', '1', '2026-04-05 15:53:46', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3444, 2, '已完成', '4', 'mes_wm_product_produce_status', 0, 'success', '', '已完成状态', '1', '2026-04-05 15:53:46', '1', '2026-04-05 15:53:46', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3445, 3, '已取消', '5', 'mes_wm_product_produce_status', 0, 'danger', '', '已取消状态', '1', '2026-04-05 15:53:46', '1', '2026-04-05 15:53:46', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3446, 0, '草稿', '0', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3447, 1, '已完成', '4', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3448, 2, '已取消', '5', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3446, 0, '草稿', '0', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3447, 1, '已完成', '4', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3448, 2, '已取消', '5', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
COMMIT;
SET IDENTITY_INSERT system_dict_data OFF;
-- @formatter:on
@ -5617,4 +5617,3 @@ INSERT INTO yudao_demo03_student (id, name, sex, birthday, description, creator,
COMMIT;
SET IDENTITY_INSERT yudao_demo03_student OFF;
-- @formatter:on

208
sql/highgo/quartz.sql Normal file
View File

@ -0,0 +1,208 @@
-- https://github.com/quartz-scheduler/quartz/blob/main/quartz/src/main/resources/org/quartz/impl/jdbcjobstore/tables_postgres.sql
-- Thanks to Patrick Lightbody for submitting this...
--
-- In your Quartz properties file, you'll need to set
-- org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.PostgreSQLDelegate
DROP TABLE IF EXISTS QRTZ_FIRED_TRIGGERS;
DROP TABLE IF EXISTS QRTZ_PAUSED_TRIGGER_GRPS;
DROP TABLE IF EXISTS QRTZ_SCHEDULER_STATE;
DROP TABLE IF EXISTS QRTZ_LOCKS;
DROP TABLE IF EXISTS QRTZ_SIMPLE_TRIGGERS;
DROP TABLE IF EXISTS QRTZ_CRON_TRIGGERS;
DROP TABLE IF EXISTS QRTZ_SIMPROP_TRIGGERS;
DROP TABLE IF EXISTS QRTZ_BLOB_TRIGGERS;
DROP TABLE IF EXISTS QRTZ_TRIGGERS;
DROP TABLE IF EXISTS QRTZ_JOB_DETAILS;
DROP TABLE IF EXISTS QRTZ_CALENDARS;
CREATE TABLE QRTZ_JOB_DETAILS
(
SCHED_NAME VARCHAR(120) NOT NULL,
JOB_NAME VARCHAR(200) NOT NULL,
JOB_GROUP VARCHAR(200) NOT NULL,
DESCRIPTION VARCHAR(250) NULL,
JOB_CLASS_NAME VARCHAR(250) NOT NULL,
IS_DURABLE BOOL NOT NULL,
IS_NONCONCURRENT BOOL NOT NULL,
IS_UPDATE_DATA BOOL NOT NULL,
REQUESTS_RECOVERY BOOL NOT NULL,
JOB_DATA BYTEA NULL,
PRIMARY KEY (SCHED_NAME, JOB_NAME, JOB_GROUP)
);
CREATE TABLE QRTZ_TRIGGERS
(
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
JOB_NAME VARCHAR(200) NOT NULL,
JOB_GROUP VARCHAR(200) NOT NULL,
DESCRIPTION VARCHAR(250) NULL,
NEXT_FIRE_TIME BIGINT NULL,
PREV_FIRE_TIME BIGINT NULL,
PRIORITY INTEGER NULL,
TRIGGER_STATE VARCHAR(16) NOT NULL,
TRIGGER_TYPE VARCHAR(8) NOT NULL,
START_TIME BIGINT NOT NULL,
END_TIME BIGINT NULL,
CALENDAR_NAME VARCHAR(200) NULL,
MISFIRE_INSTR SMALLINT NULL,
JOB_DATA BYTEA NULL,
PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP),
FOREIGN KEY (SCHED_NAME, JOB_NAME, JOB_GROUP)
REFERENCES QRTZ_JOB_DETAILS (SCHED_NAME, JOB_NAME, JOB_GROUP)
);
CREATE TABLE QRTZ_SIMPLE_TRIGGERS
(
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
REPEAT_COUNT BIGINT NOT NULL,
REPEAT_INTERVAL BIGINT NOT NULL,
TIMES_TRIGGERED BIGINT NOT NULL,
PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP),
FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
);
CREATE TABLE QRTZ_CRON_TRIGGERS
(
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
CRON_EXPRESSION VARCHAR(120) NOT NULL,
TIME_ZONE_ID VARCHAR(80),
PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP),
FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
);
CREATE TABLE QRTZ_SIMPROP_TRIGGERS
(
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
STR_PROP_1 VARCHAR(512) NULL,
STR_PROP_2 VARCHAR(512) NULL,
STR_PROP_3 VARCHAR(512) NULL,
INT_PROP_1 INT NULL,
INT_PROP_2 INT NULL,
LONG_PROP_1 BIGINT NULL,
LONG_PROP_2 BIGINT NULL,
DEC_PROP_1 NUMERIC(13, 4) NULL,
DEC_PROP_2 NUMERIC(13, 4) NULL,
BOOL_PROP_1 BOOL NULL,
BOOL_PROP_2 BOOL NULL,
PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP),
FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
);
CREATE TABLE QRTZ_BLOB_TRIGGERS
(
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
BLOB_DATA BYTEA NULL,
PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP),
FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP)
);
CREATE TABLE QRTZ_CALENDARS
(
SCHED_NAME VARCHAR(120) NOT NULL,
CALENDAR_NAME VARCHAR(200) NOT NULL,
CALENDAR BYTEA NOT NULL,
PRIMARY KEY (SCHED_NAME, CALENDAR_NAME)
);
CREATE TABLE QRTZ_PAUSED_TRIGGER_GRPS
(
SCHED_NAME VARCHAR(120) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
PRIMARY KEY (SCHED_NAME, TRIGGER_GROUP)
);
CREATE TABLE QRTZ_FIRED_TRIGGERS
(
SCHED_NAME VARCHAR(120) NOT NULL,
ENTRY_ID VARCHAR(95) NOT NULL,
TRIGGER_NAME VARCHAR(200) NOT NULL,
TRIGGER_GROUP VARCHAR(200) NOT NULL,
INSTANCE_NAME VARCHAR(200) NOT NULL,
FIRED_TIME BIGINT NOT NULL,
SCHED_TIME BIGINT NOT NULL,
PRIORITY INTEGER NOT NULL,
STATE VARCHAR(16) NOT NULL,
JOB_NAME VARCHAR(200) NULL,
JOB_GROUP VARCHAR(200) NULL,
IS_NONCONCURRENT BOOL NULL,
REQUESTS_RECOVERY BOOL NULL,
PRIMARY KEY (SCHED_NAME, ENTRY_ID)
);
CREATE TABLE QRTZ_SCHEDULER_STATE
(
SCHED_NAME VARCHAR(120) NOT NULL,
INSTANCE_NAME VARCHAR(200) NOT NULL,
LAST_CHECKIN_TIME BIGINT NOT NULL,
CHECKIN_INTERVAL BIGINT NOT NULL,
PRIMARY KEY (SCHED_NAME, INSTANCE_NAME)
);
CREATE TABLE QRTZ_LOCKS
(
SCHED_NAME VARCHAR(120) NOT NULL,
LOCK_NAME VARCHAR(40) NOT NULL,
PRIMARY KEY (SCHED_NAME, LOCK_NAME)
);
CREATE INDEX IDX_QRTZ_J_REQ_RECOVERY
ON QRTZ_JOB_DETAILS (SCHED_NAME, REQUESTS_RECOVERY);
CREATE INDEX IDX_QRTZ_J_GRP
ON QRTZ_JOB_DETAILS (SCHED_NAME, JOB_GROUP);
CREATE INDEX IDX_QRTZ_T_J
ON QRTZ_TRIGGERS (SCHED_NAME, JOB_NAME, JOB_GROUP);
CREATE INDEX IDX_QRTZ_T_JG
ON QRTZ_TRIGGERS (SCHED_NAME, JOB_GROUP);
CREATE INDEX IDX_QRTZ_T_C
ON QRTZ_TRIGGERS (SCHED_NAME, CALENDAR_NAME);
CREATE INDEX IDX_QRTZ_T_G
ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_GROUP);
CREATE INDEX IDX_QRTZ_T_STATE
ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_STATE);
CREATE INDEX IDX_QRTZ_T_N_STATE
ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP, TRIGGER_STATE);
CREATE INDEX IDX_QRTZ_T_N_G_STATE
ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_GROUP, TRIGGER_STATE);
CREATE INDEX IDX_QRTZ_T_NEXT_FIRE_TIME
ON QRTZ_TRIGGERS (SCHED_NAME, NEXT_FIRE_TIME);
CREATE INDEX IDX_QRTZ_T_NFT_ST
ON QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_STATE, NEXT_FIRE_TIME);
CREATE INDEX IDX_QRTZ_T_NFT_MISFIRE
ON QRTZ_TRIGGERS (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME);
CREATE INDEX IDX_QRTZ_T_NFT_ST_MISFIRE
ON QRTZ_TRIGGERS (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_STATE);
CREATE INDEX IDX_QRTZ_T_NFT_ST_MISFIRE_GRP
ON QRTZ_TRIGGERS (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_GROUP, TRIGGER_STATE);
CREATE INDEX IDX_QRTZ_FT_TRIG_INST_NAME
ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, INSTANCE_NAME);
CREATE INDEX IDX_QRTZ_FT_INST_JOB_REQ_RCVRY
ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, INSTANCE_NAME, REQUESTS_RECOVERY);
CREATE INDEX IDX_QRTZ_FT_J_G
ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, JOB_NAME, JOB_GROUP);
CREATE INDEX IDX_QRTZ_FT_JG
ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, JOB_GROUP);
CREATE INDEX IDX_QRTZ_FT_T_G
ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP);
CREATE INDEX IDX_QRTZ_FT_TG
ON QRTZ_FIRED_TRIGGERS (SCHED_NAME, TRIGGER_GROUP);
COMMIT;

6198
sql/highgo/ruoyi-vue-pro.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1653,9 +1653,9 @@ INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_t
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3443, 1, '草稿', '0', 'mes_wm_product_produce_status', 0, 'info', '', '草稿状态', '1', '2026-04-05 15:53:46', '1', '2026-04-05 15:53:46', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3444, 2, '已完成', '4', 'mes_wm_product_produce_status', 0, 'success', '', '已完成状态', '1', '2026-04-05 15:53:46', '1', '2026-04-05 15:53:46', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3445, 3, '已取消', '5', 'mes_wm_product_produce_status', 0, 'danger', '', '已取消状态', '1', '2026-04-05 15:53:46', '1', '2026-04-05 15:53:46', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3446, 0, '草稿', '0', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3447, 1, '已完成', '4', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3448, 2, '已取消', '5', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3446, 0, '草稿', '0', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3447, 1, '已完成', '4', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3448, 2, '已取消', '5', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
COMMIT;
-- @formatter:on
@ -5943,4 +5943,3 @@ COMMIT;
DROP SEQUENCE IF EXISTS yudao_demo03_student_seq;
CREATE SEQUENCE yudao_demo03_student_seq
START 10;

File diff suppressed because it is too large Load Diff

View File

@ -1653,9 +1653,9 @@ INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_t
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3443, 1, '草稿', '0', 'mes_wm_product_produce_status', 0, 'info', '', '草稿状态', '1', '2026-04-05 15:53:46', '1', '2026-04-05 15:53:46', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3444, 2, '已完成', '4', 'mes_wm_product_produce_status', 0, 'success', '', '已完成状态', '1', '2026-04-05 15:53:46', '1', '2026-04-05 15:53:46', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3445, 3, '已取消', '5', 'mes_wm_product_produce_status', 0, 'danger', '', '已取消状态', '1', '2026-04-05 15:53:46', '1', '2026-04-05 15:53:46', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3446, 0, '草稿', '0', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3447, 1, '已完成', '4', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3448, 2, '已取消', '5', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3446, 0, '草稿', '0', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3447, 1, '已完成', '4', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3448, 2, '已取消', '5', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
COMMIT;
-- @formatter:on
@ -5943,4 +5943,3 @@ COMMIT;
DROP SEQUENCE IF EXISTS yudao_demo03_student_seq;
CREATE SEQUENCE yudao_demo03_student_seq
START 10;

View File

@ -1605,9 +1605,9 @@ INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_t
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3443, 1, '草稿', '0', 'mes_wm_product_produce_status', 0, 'info', '', '草稿状态', '1', to_date('2026-04-05 15:53:46', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2026-04-05 15:53:46', 'SYYYY-MM-DD HH24:MI:SS'), '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3444, 2, '已完成', '4', 'mes_wm_product_produce_status', 0, 'success', '', '已完成状态', '1', to_date('2026-04-05 15:53:46', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2026-04-05 15:53:46', 'SYYYY-MM-DD HH24:MI:SS'), '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3445, 3, '已取消', '5', 'mes_wm_product_produce_status', 0, 'danger', '', '已取消状态', '1', to_date('2026-04-05 15:53:46', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2026-04-05 15:53:46', 'SYYYY-MM-DD HH24:MI:SS'), '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3446, 0, '草稿', '0', 'mes_pro_task_status', 0, '', '', NULL, '1', to_date('2026-04-16 09:47:00', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2026-04-16 09:47:00', 'SYYYY-MM-DD HH24:MI:SS'), '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3447, 1, '已完成', '4', 'mes_pro_task_status', 0, '', '', NULL, '1', to_date('2026-04-16 09:47:00', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2026-04-16 09:47:00', 'SYYYY-MM-DD HH24:MI:SS'), '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3448, 2, '已取消', '5', 'mes_pro_task_status', 0, '', '', NULL, '1', to_date('2026-04-16 09:47:00', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2026-04-16 09:47:00', 'SYYYY-MM-DD HH24:MI:SS'), '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3446, 0, '草稿', '0', 'mes_pro_task_status', 0, '', '', NULL, '1', to_date('2026-04-16 09:47:00', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2026-04-16 09:47:00', 'SYYYY-MM-DD HH24:MI:SS'), '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3447, 1, '已完成', '4', 'mes_pro_task_status', 0, '', '', NULL, '1', to_date('2026-04-16 09:47:00', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2026-04-16 09:47:00', 'SYYYY-MM-DD HH24:MI:SS'), '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3448, 2, '已取消', '5', 'mes_pro_task_status', 0, '', '', NULL, '1', to_date('2026-04-16 09:47:00', 'SYYYY-MM-DD HH24:MI:SS'), '1', to_date('2026-04-16 09:47:00', 'SYYYY-MM-DD HH24:MI:SS'), '0');
COMMIT;
-- @formatter:on
@ -5801,4 +5801,3 @@ COMMIT;
CREATE SEQUENCE yudao_demo03_student_seq
START WITH 10;

View File

@ -1653,9 +1653,9 @@ INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_t
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3443, 1, '草稿', '0', 'mes_wm_product_produce_status', 0, 'info', '', '草稿状态', '1', '2026-04-05 15:53:46', '1', '2026-04-05 15:53:46', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3444, 2, '已完成', '4', 'mes_wm_product_produce_status', 0, 'success', '', '已完成状态', '1', '2026-04-05 15:53:46', '1', '2026-04-05 15:53:46', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3445, 3, '已取消', '5', 'mes_wm_product_produce_status', 0, 'danger', '', '已取消状态', '1', '2026-04-05 15:53:46', '1', '2026-04-05 15:53:46', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3446, 0, '草稿', '0', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3447, 1, '已完成', '4', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3448, 2, '已取消', '5', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3446, 0, '草稿', '0', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3447, 1, '已完成', '4', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3448, 2, '已取消', '5', 'mes_pro_task_status', 0, '', '', NULL, '1', '2026-04-16 09:47:00', '1', '2026-04-16 09:47:00', '0');
COMMIT;
-- @formatter:on
@ -5943,4 +5943,3 @@ COMMIT;
DROP SEQUENCE IF EXISTS yudao_demo03_student_seq;
CREATE SEQUENCE yudao_demo03_student_seq
START 10;

View File

@ -3931,11 +3931,11 @@ INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_t
GO
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3445, 3, N'已取消', N'5', N'mes_wm_product_produce_status', 0, N'danger', N'', N'已取消状态', N'1', N'2026-04-05 15:53:46', N'1', N'2026-04-05 15:53:46', N'0')
GO
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3446, 0, N'草稿', N'0', N'mes_pro_task_status', 0, N'', N'', NULL, N'1', N'2026-04-16 09:47:00', N'1', N'2026-04-16 09:47:00', N'0')
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3446, 0, N'草稿', N'0', N'mes_pro_task_status', 0, N'', N'', NULL, N'1', N'2026-04-16 09:47:00', N'1', N'2026-04-16 09:47:00', N'0')
GO
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3447, 1, N'已完成', N'4', N'mes_pro_task_status', 0, N'', N'', NULL, N'1', N'2026-04-16 09:47:00', N'1', N'2026-04-16 09:47:00', N'0')
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3447, 1, N'已完成', N'4', N'mes_pro_task_status', 0, N'', N'', NULL, N'1', N'2026-04-16 09:47:00', N'1', N'2026-04-16 09:47:00', N'0')
GO
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3448, 2, N'已取消', N'5', N'mes_pro_task_status', 0, N'', N'', NULL, N'1', N'2026-04-16 09:47:00', N'1', N'2026-04-16 09:47:00', N'0')
INSERT INTO system_dict_data (id, sort, label, value, dict_type, status, color_type, css_class, remark, creator, create_time, updater, update_time, deleted) VALUES (3448, 2, N'已取消', N'5', N'mes_pro_task_status', 0, N'', N'', NULL, N'1', N'2026-04-16 09:47:00', N'1', N'2026-04-16 09:47:00', N'0')
GO
SET IDENTITY_INSERT system_dict_data OFF
GO
@ -13915,4 +13915,3 @@ GO
COMMIT
GO
-- @formatter:on

View File

@ -90,6 +90,25 @@ docker compose up -d opengauss
docker compose exec opengauss bash -c '/usr/local/opengauss/bin/gsql -U $GS_USERNAME -W $GS_PASSWORD -d postgres -f /tmp/schema.sql'
```
### 1.8 HighGo 瀚高数据库
① 下载瀚高官方 Docker 镜像,并加载镜像文件。加载后,将镜像打成本地标签:
```Bash
docker load -i <highgo-image>.tar
docker tag <image>:<tag> highgo:local
```
② 在项目 `sql/tools` 目录下运行:
```Bash
docker compose up -d highgo
```
> 注意:不同瀚高镜像的数据目录可能不同,如果容器无法启动,请按镜像实际 `PGDATA` 修改 `docker-compose.yaml` 中的 `highgo` 数据卷挂载目录。
③ 启动完成后,需要手动导入 Quartz 和项目 SQL。瀚高兼容 PostgreSQL具体客户端命令以当前镜像为准可使用 `psql` 或瀚高镜像内置的兼容客户端执行 `/tmp/quartz.sql``/tmp/schema.sql`
## 1.X 容器的销毁重建
开发测试过程中,有时候需要创建全新干净的数据库。由于测试数据 Docker 容器采用数据卷 Volume 挂载数据库实例的数据目录,因此销毁数据需要停止容器后,删除数据卷,然后再重新创建容器。
@ -103,7 +122,7 @@ docker volume rm ruoyi-vue-pro_postgres
## 2. MySQL 转换其它数据库
项目提供了 `sql/tools/convertor.py` 脚本,支持将 MySQL 转换为 Oracle、PostgreSQL、SQL Server、达梦、人大金仓、OpenGauss 等数据库的脚本。
项目提供了 `sql/tools/convertor.py` 脚本,支持将 MySQL 转换为 Oracle、PostgreSQL、SQL Server、达梦、人大金仓、OpenGauss、瀚高等数据库的脚本。
### 2.1 实现原理
@ -118,11 +137,12 @@ pip install simple-ddl-parser
# pip3 install simple-ddl-parser
```
② 在 `sql/tools/` 目录下,执行如下命令打印生成 postgres 的脚本内容,其他可选参数有:`oracle``sqlserver``dm8``kingbase``opengauss`
② 在 `sql/tools/` 目录下,执行如下命令打印生成 postgres 的脚本内容,其他可选参数有:`oracle``sqlserver``dm8``kingbase``opengauss``highgo`
```Bash
python3 convertor.py postgres
# python3 convertor.py postgres > tmp.sql
# python3 convertor.py highgo ../mysql/ruoyi-vue-pro.sql > ../highgo/ruoyi-vue-pro.sql
```
程序将 SQL 脚本打印到终端,可以重定向到临时文件 `tmp.sql`

View File

@ -10,6 +10,7 @@ uv run --with simple-ddl-parser convertor.py postgres ../mysql/ruoyi-vue-pro.sql
uv run --with simple-ddl-parser convertor.py sqlserver ../mysql/ruoyi-vue-pro.sql > ../sqlserver/ruoyi-vue-pro.sql
uv run --with simple-ddl-parser convertor.py kingbase ../mysql/ruoyi-vue-pro.sql > ../kingbase/ruoyi-vue-pro.sql
uv run --with simple-ddl-parser convertor.py opengauss ../mysql/ruoyi-vue-pro.sql > ../opengauss/ruoyi-vue-pro.sql
uv run --with simple-ddl-parser convertor.py highgo ../mysql/ruoyi-vue-pro.sql > ../highgo/ruoyi-vue-pro.sql
uv run --with simple-ddl-parser convertor.py oracle ../mysql/ruoyi-vue-pro.sql > ../oracle/ruoyi-vue-pro.sql
uv run --with simple-ddl-parser convertor.py dm8 ../mysql/ruoyi-vue-pro.sql > ../dm/ruoyi-vue-pro-dm8.sql
"""
@ -77,6 +78,9 @@ def load_and_clean(sql_file: str) -> str:
class Convertor(ABC):
# 不同数据库的关键字不完全一致;子类按需声明需要转义的列名。
reserved_column_names = set()
def __init__(self, src: str, db_type) -> None:
self.src = src
self.db_type = db_type
@ -179,6 +183,31 @@ class Convertor(ABC):
"""
return ""
def escape_column_name(self, name: str) -> str:
"""转义目标库保留字列名,例如 Oracle / Kingbase 的 level。"""
column_name = name.lower()
if column_name in self.reserved_column_names:
return f'"{column_name}"'
return column_name
def escape_insert_columns(self, insert_script: str) -> str:
"""INSERT 显式列清单需要和 CREATE / COMMENT 使用同一套列名转义。"""
match = re.match(
r"(INSERT INTO\s+\S+\s*\()([^)]+)(\)\s+VALUES\s+[\s\S]*)",
insert_script,
flags=re.IGNORECASE,
)
if not match:
return insert_script
columns = [
self.escape_column_name(column.strip())
for column in match.group(2).split(",")
]
return f"{match.group(1)}{', '.join(columns)}{match.group(3)}"
@staticmethod
def inserts(table_name: str, script_content: str) -> Generator:
PREFIX = f"INSERT INTO `{table_name}`"
@ -204,18 +233,55 @@ class Convertor(ABC):
Generator[str]: create index 语句
"""
def generate_columns(columns):
keys = [
f"{col['name'].lower()}{' ' + col['order'].lower() if col['order'] != 'ASC' else ''}"
for col in columns[0]
]
return ", ".join(keys)
for no, index in enumerate(ddl["index"], 1):
columns = generate_columns(index["columns"])
for no, index in enumerate(ddl.get("index", []), 1):
columns = ", ".join(Convertor.index_columns(index.get("columns", [])))
if not columns:
continue
table_name = ddl["table_name"].lower()
yield f"CREATE INDEX idx_{table_name}_{no:02d} ON {table_name} ({columns})"
@staticmethod
def index_columns(columns) -> list:
"""兼容 simple-ddl-parser 不同版本的索引列结构。"""
keys = []
def append(name, order="ASC"):
if not name:
return
column_name = str(name).strip("`").lower()
column_order = str(order or "ASC").upper()
if column_order == "DESC":
keys.append(f"{column_name} desc")
else:
keys.append(column_name)
def visit(value):
# 普通索引常见结构:[[{'name': 'user_id', 'order': 'ASC'}]]
if isinstance(value, (list, tuple)):
for item in value:
visit(item)
return
if isinstance(value, dict):
name = value.get("name")
if isinstance(name, (dict, list, tuple)):
visit(name)
return
append(name, value.get("order", "ASC"))
return
# 唯一索引在部分版本中会被解析成 ['mobile', 'ASC', 'tenant_id', 'ASC']。
if isinstance(value, str):
token = value.strip("`")
order = token.upper()
if order in ("ASC", "DESC"):
if order == "DESC" and keys and not keys[-1].endswith(" desc"):
keys[-1] = f"{keys[-1]} desc"
return
append(token)
visit(columns)
return keys
@staticmethod
def unique_index(ddl: Dict) -> Generator:
if "constraints" in ddl and "uniques" in ddl["constraints"]:
@ -223,7 +289,9 @@ class Convertor(ABC):
for uk in uk_list:
table_name = ddl["table_name"]
uk_name = uk["constraint_name"]
uk_columns = uk["columns"]
uk_columns = Convertor.index_columns(uk["columns"])
if not uk_columns:
continue
yield table_name, uk_name, uk_columns
@staticmethod
@ -381,7 +449,7 @@ class PostgreSQLConvertor(Convertor):
)
nullable = "NULL" if col["nullable"] else "NOT NULL"
default = f"DEFAULT {col['default']}" if col["default"] is not None else ""
return f"{name} {full_type} {nullable} {default}"
return f"{self.escape_column_name(name)} {full_type} {nullable} {default}"
table_name = ddl["table_name"].lower()
columns = [f"{_generate_column(col).strip()}" for col in ddl["columns"]]
@ -406,7 +474,7 @@ CREATE TABLE {table_name} (
for column in table_ddl["columns"]:
table_comment = column["comment"]
script += (
f"COMMENT ON COLUMN {table_ddl['table_name']}.{column['name']} IS '{table_comment}';"
f"COMMENT ON COLUMN {table_ddl['table_name']}.{self.escape_column_name(column['name'])} IS '{table_comment}';"
+ "\n"
)
@ -435,6 +503,7 @@ CREATE TABLE {table_name} (
"""生成 insert 语句,以及根据最后的 insert id+1 生成 Sequence"""
inserts = list(Convertor.inserts(table_name, self.content))
inserts = [self.escape_insert_columns(s) for s in inserts]
# 转换 MySQL 字符串转义为 PostgreSQL 格式:\\ -> \\' -> ''
inserts = [re.sub(r"\\\\|\\'", lambda m: "\\" if m.group() == "\\\\" else "''", s) for s in inserts]
## 生成 insert 脚本
@ -482,6 +551,8 @@ INSERT INTO dual VALUES (1);
class OracleConvertor(Convertor):
reserved_column_names = {"level", "size"}
def __init__(self, src):
super().__init__(src, "Oracle")
@ -526,10 +597,8 @@ class OracleConvertor(Convertor):
# Oracle的 INSERT '' 不能通过NOT NULL校验因此对文字类型字段覆写为 NULL
nullable = "NULL" if type in ("varchar", "text", "longtext") else nullable
default = f"DEFAULT {col['default']}" if col["default"] is not None else ""
# Oracle 中 size 不能作为字段名
field_name = '"size"' if name == "size" else name
# Oracle DEFAULT 定义在 NULLABLE 之前
return f"{field_name} {full_type} {default} {nullable}"
return f"{self.escape_column_name(name)} {full_type} {default} {nullable}"
table_name = ddl["table_name"].lower()
columns = [f"{generate_column(col).strip()}" for col in ddl["columns"]]
@ -554,7 +623,7 @@ CREATE TABLE {table_name} (
for column in table_ddl["columns"]:
table_comment = column["comment"]
script += (
f"COMMENT ON COLUMN {table_ddl['table_name']}.{column['name']} IS '{table_comment}';"
f"COMMENT ON COLUMN {table_ddl['table_name']}.{self.escape_column_name(column['name'])} IS '{table_comment}';"
+ "\n"
)
@ -586,6 +655,7 @@ CREATE TABLE {table_name} (
"""拷贝 INSERT 语句"""
inserts = []
for insert_script in Convertor.inserts(table_name, self.content):
insert_script = self.escape_insert_columns(insert_script)
# 对日期数据添加 TO_DATE 转换
insert_script = re.sub(
r"('\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}')",
@ -907,6 +977,8 @@ SET IDENTITY_INSERT {table_name.lower()} OFF;
class KingbaseConvertor(PostgreSQLConvertor):
reserved_column_names = {"level"}
def __init__(self, src):
super().__init__(src)
self.db_type = "Kingbase"
@ -925,7 +997,7 @@ class KingbaseConvertor(PostgreSQLConvertor):
if full_type == "text":
nullable = "NULL"
default = f"DEFAULT {col['default']}" if col["default"] is not None else ""
return f"{name} {full_type} {nullable} {default}"
return f"{self.escape_column_name(name)} {full_type} {nullable} {default}"
table_name = ddl["table_name"].lower()
columns = [f"{_generate_column(col).strip()}" for col in ddl["columns"]]
@ -945,23 +1017,32 @@ CREATE TABLE {table_name} (
class OpengaussConvertor(KingbaseConvertor):
reserved_column_names = set()
def __init__(self, src):
super().__init__(src)
self.db_type = "OpenGauss"
class HighGoConvertor(PostgreSQLConvertor):
def __init__(self, src):
super().__init__(src)
self.db_type = "HighGo"
def main():
parser = argparse.ArgumentParser(description="芋道系统数据库转换工具")
parser.add_argument(
"type",
type=str,
help="目标数据库类型",
choices=["postgres", "oracle", "sqlserver", "dm8", "kingbase", "opengauss"],
choices=["postgres", "oracle", "sqlserver", "dm8", "kingbase", "opengauss", "highgo"],
)
parser.add_argument(
"path",
type=str,
help="源数据库脚本路径",
nargs="?",
default="../mysql/ruoyi-vue-pro.sql"
)
args = parser.parse_args()
@ -980,6 +1061,8 @@ def main():
convertor = KingbaseConvertor(sql_file)
elif args.type == "opengauss":
convertor = OpengaussConvertor(sql_file)
elif args.type == "highgo":
convertor = HighGoConvertor(sql_file)
else:
raise NotImplementedError(f"不支持目标数据库类型: {args.type}")

View File

@ -7,6 +7,7 @@ volumes:
dm8: { }
kingbase: { }
opengauss: { }
highgo: { }
services:
mysql:
@ -131,4 +132,16 @@ services:
volumes:
- opengauss:/var/lib/opengauss
- ../opengauss/ruoyi-vue-pro.sql:/tmp/schema.sql:ro
# docker compose exec opengauss bash -c '/usr/local/opengauss/bin/gsql -U $GS_USERNAME -W $GS_PASSWORD -d postgres -f /tmp/schema.sql'
# docker compose exec opengauss bash -c '/usr/local/opengauss/bin/gsql -U $GS_USERNAME -W $GS_PASSWORD -d postgres -f /tmp/schema.sql'
highgo:
# 使用瀚高官方提供的 Docker 镜像,加载后打成本地标签:
# docker tag <image>:<tag> highgo:local
image: highgo:local
restart: unless-stopped
ports:
- "5866:5866"
volumes:
- highgo:/home/highgo/hgdb/data
- ../highgo/quartz.sql:/tmp/quartz.sql:ro
- ../highgo/ruoyi-vue-pro.sql:/tmp/schema.sql:ro

View File

@ -14,12 +14,12 @@
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
<properties>
<revision>2026.04-SNAPSHOT</revision>
<revision>2026.05-SNAPSHOT</revision>
<flatten-maven-plugin.version>1.7.2</flatten-maven-plugin.version>
<!-- 统一依赖管理 -->
<spring.boot.version>4.0.6</spring.boot.version>
<spring.boot.version>3.5.14</spring.boot.version>
<!-- Web 相关 -->
<springdoc.version>3.0.3</springdoc.version>
<springdoc.version>2.8.17</springdoc.version>
<knife4j.version>4.5.0</knife4j.version>
<!-- DB 相关 -->
<druid.version>1.2.28</druid.version>
@ -27,8 +27,8 @@
<mybatis-plus.version>3.5.16</mybatis-plus.version>
<mybatis-plus-join.version>1.5.7</mybatis-plus-join.version>
<dynamic-datasource.version>4.5.0</dynamic-datasource.version>
<easy-trans.version>3.1.5</easy-trans.version>
<redisson.version>4.3.1</redisson.version>
<easy-trans.version>3.0.6</easy-trans.version>
<redisson.version>4.4.0</redisson.version>
<dm8.jdbc.version>8.1.3.140</dm8.jdbc.version>
<kingbase.jdbc.version>9.0.1.jre7</kingbase.jdbc.version>
<opengauss.jdbc.version>7.0.0-RC3-og</opengauss.jdbc.version>
@ -39,17 +39,19 @@
<lock4j.version>2.2.7</lock4j.version>
<!-- 监控相关 -->
<skywalking.version>9.6.0</skywalking.version>
<spring-boot-admin.version>4.0.4</spring-boot-admin.version>
<spring-boot-admin.version>3.5.8</spring-boot-admin.version>
<opentracing.version>0.33.0</opentracing.version>
<!-- Test 测试相关 -->
<podam.version>8.0.2.RELEASE</podam.version>
<jedis-mock.version>1.1.12</jedis-mock.version>
<mockito-inline.version>5.2.0</mockito-inline.version>
<!-- Bpm 工作流相关 -->
<flowable.version>7.2.0</flowable.version>
<flowable.version>8.0.0</flowable.version>
<!-- 工具类相关 -->
<anji-plus-captcha.version>1.4.0</anji-plus-captcha.version>
<jsoup.version>1.22.2</jsoup.version>
<sensitive-word.version>0.29.5</sensitive-word.version>
<pinyin4j.version>2.5.1</pinyin4j.version>
<lombok.version>1.18.46</lombok.version>
<mapstruct.version>1.6.3</mapstruct.version>
<hutool-5.version>5.8.44</hutool-5.version>
@ -57,6 +59,7 @@
<fastexcel.version>1.3.0</fastexcel.version>
<velocity.version>2.4.1</velocity.version>
<fastjson.version>1.2.83</fastjson.version>
<fastjson2.version>2.0.61</fastjson2.version>
<guava.version>33.6.0-jre</guava.version>
<transmittable-thread-local.version>2.14.5</transmittable-thread-local.version>
<commons-net.version>3.13.0</commons-net.version>
@ -65,7 +68,7 @@
<tika-core.version>3.3.0</tika-core.version>
<ip2region.version>2.7.0</ip2region.version>
<bizlog-sdk.version>3.0.6</bizlog-sdk.version>
<netty.version>4.2.12.Final</netty.version>
<netty.version>4.2.14.Final</netty.version>
<mqtt.version>1.2.5</mqtt.version>
<vertx.version>4.5.26</vertx.version>
<okhttp.version>4.12.0</okhttp.version>
@ -75,10 +78,11 @@
<awssdk.version>2.44.0</awssdk.version>
<justauth.version>1.16.7</justauth.version>
<justauth-starter.version>1.4.0</justauth-starter.version>
<jimureport.version>2.3.2</jimureport.version>
<jimureport.version>2.3.4</jimureport.version>
<jimubi.version>2.3.2</jimubi.version>
<weixin-java.version>4.8.2-20260501.180637</weixin-java.version>
<alipay-sdk-java.version>4.40.771.ALL</alipay-sdk-java.version>
<bouncycastle.version>1.80</bouncycastle.version>
<alipay-sdk-java.version>4.40.806.ALL</alipay-sdk-java.version>
</properties>
<dependencyManagement>
@ -135,17 +139,6 @@
<version>${spring.boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jackson</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<!-- Web 相关 -->
<dependency>
<groupId>cn.iocoder.boot</groupId>
@ -191,7 +184,7 @@
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-4-starter</artifactId>
<artifactId>druid-spring-boot-3-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<dependency>
@ -202,7 +195,7 @@
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
@ -217,7 +210,7 @@
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot4-starter</artifactId> <!-- 多数据源 -->
<artifactId>dynamic-datasource-spring-boot3-starter</artifactId> <!-- 多数据源 -->
<version>${dynamic-datasource.version}</version>
</dependency>
<dependency>
@ -227,7 +220,7 @@
</dependency>
<dependency>
<groupId>org.dromara</groupId> <!-- VO 数据翻译 -->
<groupId>com.fhs-opensource</groupId> <!-- VO 数据翻译 -->
<artifactId>easy-trans-spring-boot-starter</artifactId>
<version>${easy-trans.version}</version>
<exclusions>
@ -242,12 +235,12 @@
</exclusions>
</dependency>
<dependency>
<groupId>org.dromara</groupId>
<groupId>com.fhs-opensource</groupId>
<artifactId>easy-trans-mybatis-plus-extend</artifactId>
<version>${easy-trans.version}</version>
</dependency>
<dependency>
<groupId>org.dromara</groupId>
<groupId>com.fhs-opensource</groupId>
<artifactId>easy-trans-anno</artifactId>
<version>${easy-trans.version}</version>
</dependency>
@ -521,6 +514,11 @@
<artifactId>fastjson</artifactId>
<version>${fastjson.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
@ -569,6 +567,18 @@
<version>${jsoup.version}</version>
</dependency>
<dependency>
<groupId>com.github.houbb</groupId>
<artifactId>sensitive-word</artifactId> <!-- 敏感词检测trie 树高效匹配 -->
<version>${sensitive-word.version}</version>
</dependency>
<dependency>
<groupId>com.belerweb</groupId>
<artifactId>pinyin4j</artifactId> <!-- 汉字转拼音:作为 hutool PinyinUtil 的底层引擎 -->
<version>${pinyin4j.version}</version>
</dependency>
<!-- Vert.x -->
<dependency>
<groupId>io.vertx</groupId>
@ -657,6 +667,24 @@
</exclusions>
</dependency>
<!-- 锁定 weixin-java 传递依赖,避免 Maven 版本范围自动升级到 1.80.2 后 Fat Jar 启动失败。
反馈https://t.zsxq.com/pCVBo -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcutil-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-pay</artifactId>

View File

@ -96,15 +96,20 @@
</dependency>
<dependency>
<groupId>tools.jackson.core</groupId>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<scope>provided</scope> <!-- 设置为 provided只有工具类需要使用到 -->
</dependency>
<dependency>
<groupId>tools.jackson.core</groupId>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<scope>provided</scope> <!-- 设置为 provided只有工具类需要使用到 -->
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<scope>provided</scope> <!-- 设置为 provided只有工具类需要使用到 -->
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
@ -129,7 +134,7 @@
</dependency>
<dependency>
<groupId>org.dromara</groupId> <!-- VO 数据翻译 -->
<groupId>com.fhs-opensource</groupId> <!-- VO 数据翻译 -->
<artifactId>easy-trans-anno</artifactId> <!-- 默认引入的原因,方便 xxx-module-api 包使用 -->
</dependency>

View File

@ -124,6 +124,22 @@ public class CollectionUtils {
return from.stream().filter(filter).map(func).filter(Objects::nonNull).collect(Collectors.toSet());
}
public static <T, U> Set<U> convertLinkedSet(Collection<T> from, Function<T, U> func) {
if (CollUtil.isEmpty(from)) {
return new LinkedHashSet<>();
}
return from.stream().map(func).filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
public static <T, U> Set<U> convertLinkedSet(Collection<T> from, Function<T, U> func, Predicate<T> filter) {
if (CollUtil.isEmpty(from)) {
return new LinkedHashSet<>();
}
return from.stream().filter(filter).map(func).filter(Objects::nonNull)
.collect(Collectors.toCollection(LinkedHashSet::new));
}
public static <T, K> Map<K, T> convertMapByFilter(Collection<T> from, Predicate<T> filter, Function<T, K> keyFunc) {
if (CollUtil.isEmpty(from)) {
return new HashMap<>();
@ -372,4 +388,14 @@ public class CollectionUtils {
return false;
}
/**
* 把单元素 head 与集合 tail 合并成新 Listhead 在前tail 顺序保留)
*/
public static <T> List<T> of(T head, Collection<T> tail) {
List<T> list = new ArrayList<>();
list.add(head);
CollUtil.addAll(list, tail);
return list;
}
}

View File

@ -236,6 +236,23 @@ public class LocalDateTimeUtils {
return LocalDateTime.now().with(TemporalAdjusters.firstDayOfYear()).with(LocalTime.MIN);
}
/**
* 获取最近 N 天的 0 点时刻序列(升序,含今天)
* <p>
* 例getLatestDays(3) 返回 [前天 00:00, 昨天 00:00, 今天 00:00]
*
* @param days 天数(含今天)
* @return 升序的 LocalDateTime 列表
*/
public static List<LocalDateTime> getLatestDays(int days) {
LocalDateTime today = getToday();
List<LocalDateTime> dates = new ArrayList<>(days);
for (int i = days - 1; i >= 0; i--) {
dates.add(today.minusDays(i));
}
return dates;
}
public static List<LocalDateTime[]> getDateRangeList(LocalDateTime startTime,
LocalDateTime endTime,
Integer interval) {

View File

@ -9,6 +9,7 @@ import jakarta.servlet.http.HttpServletRequest;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;
import java.net.URI;
import java.net.URLDecoder;
@ -55,11 +56,61 @@ public class HttpUtils {
* @return 解码后的路径
*/
public static String decodeUrlPath(String path) {
if (StrUtil.isEmpty(path)) {
return path;
}
// 先将 + 替换为 %2B避免被 URLDecoder 解码为空格
String encoded = path.replace("+", "%2B");
return URLDecoder.decode(encoded, StandardCharsets.UTF_8);
}
/**
* 编码 URL 路径,按路径段编码,保留 / 分隔符
*
* @param path URL 路径,例如 20250602/xxx.pdf
* @return 编码后的路径
*/
public static String encodeUrlPath(String path) {
if (StrUtil.isEmpty(path)) {
return path;
}
String[] segments = path.split(StrUtil.SLASH, -1);
StringBuilder result = new StringBuilder(path.length());
for (int i = 0; i < segments.length; i++) {
if (i > 0) {
result.append(StrUtil.SLASH);
}
result.append(encodeUrlPathSegment(segments[i]));
}
return result.toString();
}
/**
* 编码 URL 路径段
*
* @param segment URL 路径段
* @return 编码后的路径段
*/
public static String encodeUrlPathSegment(String segment) {
return UriUtils.encodePathSegment(segment, StandardCharsets.UTF_8);
}
public static String removeUrlPathQueryAndFragment(String path) {
if (StrUtil.isEmpty(path)) {
return path;
}
int endIndex = path.length();
int queryIndex = path.indexOf('?');
if (queryIndex >= 0) {
endIndex = queryIndex;
}
int fragmentIndex = path.indexOf('#');
if (fragmentIndex >= 0 && fragmentIndex < endIndex) {
endIndex = fragmentIndex;
}
return path.substring(0, endIndex);
}
public static String replaceUrlQuery(String url, String key, String value) {
UrlBuilder builder = UrlBuilder.of(url, Charset.defaultCharset());
// 先移除;再添加
@ -200,4 +251,14 @@ public class HttpUtils {
}
}
/**
* WebSocket URL 切换成 HTTP URLws:// → http://wss:// → https://;其它格式原样保留
*
* @param url 原始 URL
* @return 切换协议后的 URL
*/
public static String wsUrlToHttp(String url) {
return StrUtil.startWithIgnoreCase(url, "ws") ? "http" + url.substring(2) : url;
}
}

View File

@ -6,22 +6,23 @@ import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.framework.common.util.json.databind.TimestampLocalDateTimeDeserializer;
import cn.iocoder.yudao.framework.common.util.json.databind.TimestampLocalDateTimeSerializer;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import lombok.Getter;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.module.SimpleModule;
import java.io.IOException;
import java.lang.reflect.Type;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* JSON 工具类
@ -32,18 +33,17 @@ import java.util.List;
public class JsonUtils {
@Getter
private static ObjectMapper objectMapper = buildObjectMapper();
private static ObjectMapper objectMapper = new ObjectMapper();
private static ObjectMapper buildObjectMapper() {
SimpleModule simpleModule = new SimpleModule()
static {
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); // 忽略 null 值
// 解决 LocalDateTime 的序列化
SimpleModule simpleModule = new JavaTimeModule()
.addSerializer(LocalDateTime.class, TimestampLocalDateTimeSerializer.INSTANCE)
.addDeserializer(LocalDateTime.class, TimestampLocalDateTimeDeserializer.INSTANCE);
return JsonMapper.builder()
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.changeDefaultPropertyInclusion(value -> JsonInclude.Value.construct(JsonInclude.Include.NON_NULL, JsonInclude.Include.NON_NULL))
.addModule(simpleModule)
.build();
objectMapper.registerModules(simpleModule);
}
/**
@ -78,7 +78,7 @@ public class JsonUtils {
}
try {
return objectMapper.readValue(text, clazz);
} catch (JacksonException e) {
} catch (IOException e) {
log.error("json parse err,json:{}", text, e);
throw new RuntimeException(e);
}
@ -92,7 +92,7 @@ public class JsonUtils {
JsonNode treeNode = objectMapper.readTree(text);
JsonNode pathNode = treeNode.path(path);
return objectMapper.readValue(pathNode.toString(), clazz);
} catch (JacksonException e) {
} catch (IOException e) {
log.error("json parse err,json:{}", text, e);
throw new RuntimeException(e);
}
@ -104,7 +104,7 @@ public class JsonUtils {
}
try {
return objectMapper.readValue(text, objectMapper.getTypeFactory().constructType(type));
} catch (JacksonException e) {
} catch (IOException e) {
log.error("json parse err,json:{}", text, e);
throw new RuntimeException(e);
}
@ -116,7 +116,7 @@ public class JsonUtils {
}
try {
return objectMapper.readValue(text, objectMapper.getTypeFactory().constructType(type));
} catch (JacksonException e) {
} catch (IOException e) {
log.error("json parse err,json:{}", text, e);
throw new RuntimeException(e);
}
@ -144,7 +144,7 @@ public class JsonUtils {
}
try {
return objectMapper.readValue(bytes, clazz);
} catch (JacksonException e) {
} catch (IOException e) {
log.error("json parse err,json:{}", bytes, e);
throw new RuntimeException(e);
}
@ -153,7 +153,7 @@ public class JsonUtils {
public static <T> T parseObject(String text, TypeReference<T> typeReference) {
try {
return objectMapper.readValue(text, typeReference);
} catch (JacksonException e) {
} catch (IOException e) {
log.error("json parse err,json:{}", text, e);
throw new RuntimeException(e);
}
@ -169,7 +169,24 @@ public class JsonUtils {
public static <T> T parseObjectQuietly(String text, TypeReference<T> typeReference) {
try {
return objectMapper.readValue(text, typeReference);
} catch (JacksonException e) {
} catch (IOException e) {
return null;
}
}
/**
* 解析 JSON 字符串成 Map空字符串或解析失败返回 null
*
* @param text JSON 字符串
* @return Map 对象
*/
public static Map<String, Object> parseMap(String text) {
if (StrUtil.isEmpty(text)) {
return null;
}
try {
return objectMapper.readValue(text, new TypeReference<Map<String, Object>>() {});
} catch (IOException e) {
return null;
}
}
@ -187,7 +204,7 @@ public class JsonUtils {
}
try {
return objectMapper.readValue(text, clazz);
} catch (JacksonException e) {
} catch (IOException e) {
return null;
}
}
@ -198,7 +215,7 @@ public class JsonUtils {
}
try {
return objectMapper.readValue(text, objectMapper.getTypeFactory().constructCollectionType(List.class, clazz));
} catch (JacksonException e) {
} catch (IOException e) {
log.error("json parse err,json:{}", text, e);
throw new RuntimeException(e);
}
@ -212,7 +229,7 @@ public class JsonUtils {
JsonNode treeNode = objectMapper.readTree(text);
JsonNode pathNode = treeNode.path(path);
return objectMapper.readValue(pathNode.toString(), objectMapper.getTypeFactory().constructCollectionType(List.class, clazz));
} catch (JacksonException e) {
} catch (IOException e) {
log.error("json parse err,json:{}", text, e);
throw new RuntimeException(e);
}
@ -221,7 +238,7 @@ public class JsonUtils {
public static JsonNode parseTree(String text) {
try {
return objectMapper.readTree(text);
} catch (JacksonException e) {
} catch (IOException e) {
log.error("json parse err,json:{}", text, e);
throw new RuntimeException(e);
}
@ -230,12 +247,20 @@ public class JsonUtils {
public static JsonNode parseTree(byte[] text) {
try {
return objectMapper.readTree(text);
} catch (JacksonException e) {
} catch (IOException e) {
log.error("json parse err,json:{}", text, e);
throw new RuntimeException(e);
}
}
public static String getText(JsonNode node, String fieldName) {
if (node == null) {
return null;
}
JsonNode value = node.get(fieldName);
return value != null && !value.isNull() ? value.asText() : null;
}
public static boolean isJson(String text) {
return JSONUtil.isTypeJSON(text);
}

View File

@ -1,13 +1,10 @@
package cn.iocoder.yudao.framework.common.util.json.databind;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.annotation.JacksonStdImpl;
import tools.jackson.databind.ser.std.StdScalarSerializer;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JacksonStdImpl;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.io.IOException;
/**
* Long 序列化规则
@ -17,41 +14,24 @@ import java.math.BigInteger;
* @author 星语
*/
@JacksonStdImpl
public class NumberSerializer extends StdScalarSerializer<Number> {
public class NumberSerializer extends com.fasterxml.jackson.databind.ser.std.NumberSerializer {
private static final long MAX_SAFE_INTEGER = 9007199254740991L;
private static final long MIN_SAFE_INTEGER = -9007199254740991L;
public static final NumberSerializer INSTANCE = new NumberSerializer(Number.class);
@SuppressWarnings("unchecked")
public NumberSerializer(Class<? extends Number> rawType) {
super((Class<Number>) rawType);
super(rawType);
}
@Override
public void serialize(Number value, JsonGenerator gen, SerializationContext serializers) throws JacksonException {
public void serialize(Number value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
// 超出范围 序列化位字符串
if (value.longValue() > MIN_SAFE_INTEGER && value.longValue() < MAX_SAFE_INTEGER) {
writeNumber(value, gen);
super.serialize(value, gen, serializers);
} else {
gen.writeString(value.toString());
}
}
private static void writeNumber(Number value, JsonGenerator gen) throws JacksonException {
if (value instanceof BigDecimal decimal) {
gen.writeNumber(decimal);
} else if (value instanceof BigInteger integer) {
gen.writeNumber(integer);
} else if (value instanceof Double doubleValue) {
gen.writeNumber(doubleValue);
} else if (value instanceof Float floatValue) {
gen.writeNumber(floatValue);
} else if (value instanceof Long longValue) {
gen.writeNumber(longValue);
} else {
gen.writeNumber(value.intValue());
}
}
}

View File

@ -1,10 +1,10 @@
package cn.iocoder.yudao.framework.common.util.json.databind;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonParser;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.ValueDeserializer;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import java.io.IOException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
@ -14,12 +14,12 @@ import java.time.ZoneId;
*
* @author 老五
*/
public class TimestampLocalDateTimeDeserializer extends ValueDeserializer<LocalDateTime> {
public class TimestampLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
public static final TimestampLocalDateTimeDeserializer INSTANCE = new TimestampLocalDateTimeDeserializer();
@Override
public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException {
public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
// 将 Long 时间戳,转换为 LocalDateTime 对象
return LocalDateTime.ofInstant(Instant.ofEpochMilli(p.getValueAsLong()), ZoneId.systemDefault());
}

View File

@ -5,12 +5,12 @@ import cn.hutool.core.util.ReflectUtil;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import lombok.extern.slf4j.Slf4j;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.ser.std.StdScalarSerializer;
import java.io.IOException;
import java.lang.reflect.Field;
import java.time.LocalDateTime;
import java.time.ZoneId;
@ -25,22 +25,18 @@ import java.util.concurrent.ConcurrentHashMap;
* @author 老五
*/
@Slf4j
public class TimestampLocalDateTimeSerializer extends StdScalarSerializer<LocalDateTime> {
public class TimestampLocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {
public static final TimestampLocalDateTimeSerializer INSTANCE = new TimestampLocalDateTimeSerializer();
private static final Map<Class<?>, Map<String, Field>> FIELD_CACHE = new ConcurrentHashMap<>();
public TimestampLocalDateTimeSerializer() {
super(LocalDateTime.class);
}
@Override
public void serialize(LocalDateTime value, JsonGenerator gen, SerializationContext serializers) throws JacksonException {
public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
// 情况一:有 JsonFormat 自定义注解则使用它。https://github.com/YunaiV/ruoyi-vue-pro/pull/1019
String fieldName = gen.streamWriteContext().currentName();
String fieldName = gen.getOutputContext().getCurrentName();
if (fieldName != null) {
Object currentValue = gen.currentValue();
Object currentValue = gen.getOutputContext().getCurrentValue();
if (currentValue != null) {
Class<?> clazz = currentValue.getClass();
Map<String, Field> fieldMap = FIELD_CACHE.computeIfAbsent(clazz, this::buildFieldMap);

View File

@ -26,9 +26,10 @@ public class ServletUtils {
* @param response 响应
* @param object 对象,会序列化成 JSON 字符串
*/
@SuppressWarnings("deprecation") // 必须使用 APPLICATION_JSON_UTF8_VALUE否则会乱码
public static void writeJSON(HttpServletResponse response, Object object) {
String content = JsonUtils.toJsonString(object);
JakartaServletUtil.write(response, content, MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
JakartaServletUtil.write(response, content, MediaType.APPLICATION_JSON_UTF8_VALUE);
}
/**

View File

@ -3,6 +3,7 @@ package cn.iocoder.yudao.framework.common.util.string;
import cn.hutool.core.text.StrPool;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.pinyin.PinyinUtil;
import org.aspectj.lang.JoinPoint;
import java.util.Arrays;
@ -78,6 +79,25 @@ public class StrUtils {
.collect(Collectors.joining("\n"));
}
/**
* 转小写拼音,字之间以空格分隔,便于调用方按需拼接 / 取首字母 / 拼音搜索
*
* 例:「老张」→ "lao zhang"、「ZhangSan」→ "zhangsan"
* 英文 / 数字 / 符号原样返回,空值返回 null
*
* 注意:底层依赖 hutool-extra 的 {@link PinyinUtil},需要业务模块自行引入拼音引擎依赖
* pinyin4j / TinyPinyin / Bopomofo4j 任选其一),否则运行时会抛 NoClassDefFoundError
*
* @param str 字符串
* @return 拼音串(保留空格分隔)
*/
public static String toPinyin(String str) {
if (StrUtil.isBlank(str)) {
return null;
}
return PinyinUtil.getPinyin(str);
}
/**
* 拼接方法的参数
*

View File

@ -9,6 +9,36 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
*/
public class HttpUtilsTest {
@Test
public void testEncodeUrlPath() {
// 准备参数
String path = "avatar/中文 100%+文件.jpg";
// 调用
String result = HttpUtils.encodeUrlPath(path);
// 断言
assertEquals("avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg", result);
}
@Test
public void testDecodeUrlPath() {
// 准备参数:+ 是路径字符,不应该按 query parameter 语义解码为空格
String path = "avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg";
// 调用
String result = HttpUtils.decodeUrlPath(path);
// 断言
assertEquals("avatar/中文 100%+文件.jpg", result);
}
@Test
public void testRemoveUrlPathQueryAndFragment() {
assertEquals("avatar/test.jpg", HttpUtils.removeUrlPathQueryAndFragment("avatar/test.jpg?token=1#preview"));
assertEquals("avatar/test.jpg", HttpUtils.removeUrlPathQueryAndFragment("avatar/test.jpg#preview?token=1"));
}
@Test
public void testReplaceUrlQuery_replace() {
// 准备参数

View File

@ -4,7 +4,7 @@ import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ArrayUtil;
import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission;
import cn.iocoder.yudao.framework.datapermission.core.aop.DataPermissionContextHolder;
import org.dromara.trans.service.impl.SimpleTransService;
import com.fhs.trans.service.impl.SimpleTransService;
import lombok.RequiredArgsConstructor;
import java.util.Collections;
@ -65,7 +65,7 @@ public class DataPermissionRuleFactoryImpl implements DataPermissionRuleFactory
}
/**
* 判断是否为数据翻译 {@link org.dromara.core.trans.anno.Trans} 的调用
* 判断是否为数据翻译 {@link com.fhs.core.trans.anno.Trans} 的调用
*
* 目前暂时只有这个办法,已经和 easy-trans 做过沟通
*

View File

@ -30,6 +30,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.cache.BatchStrategies;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
@ -42,9 +43,12 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.util.pattern.PathPattern;
import java.util.*;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
@AutoConfiguration
@ConditionalOnProperty(prefix = "yudao.tenant", value = "enable", matchIfMissing = true) // 允许使用 yudao.tenant.enable=false 禁用多租户
@EnableConfigurationProperties(TenantProperties.class)
@ -139,35 +143,50 @@ public class YudaoTenantAutoConfiguration {
continue;
}
// 添加到忽略的 URL 中
ignoreUrls.addAll(entry.getKey().getPatternValues());
if (entry.getKey().getPatternsCondition() != null) {
ignoreUrls.addAll(entry.getKey().getPatternsCondition().getPatterns());
}
if (entry.getKey().getPathPatternsCondition() != null) {
ignoreUrls.addAll(
convertList(entry.getKey().getPathPatternsCondition().getPatterns(), PathPattern::getPatternString));
}
}
return ignoreUrls;
}
// ========== MQ ==========
@Bean
public TenantRedisMessageInterceptor tenantRedisMessageInterceptor() {
return new TenantRedisMessageInterceptor();
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(name = "cn.iocoder.yudao.framework.mq.redis.core.interceptor.RedisMessageInterceptor")
public static class TenantRedisMQConfiguration {
@Bean
public TenantRedisMessageInterceptor tenantRedisMessageInterceptor() {
return new TenantRedisMessageInterceptor();
}
}
@Bean
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(name = "org.springframework.amqp.rabbit.core.RabbitTemplate")
public TenantRabbitMQInitializer tenantRabbitMQInitializer() {
return new TenantRabbitMQInitializer();
public static class TenantRabbitMQConfiguration {
@Bean
public TenantRabbitMQInitializer tenantRabbitMQInitializer() {
return new TenantRabbitMQInitializer();
}
}
@Bean
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(name = "org.apache.rocketmq.spring.core.RocketMQTemplate")
public TenantRocketMQInitializer tenantRocketMQInitializer() {
return new TenantRocketMQInitializer();
}
public static class TenantRocketMQConfiguration {
// ========== Job ==========
@Bean
public TenantRocketMQInitializer tenantRocketMQInitializer() {
return new TenantRocketMQInitializer();
}
@Bean
public TenantJobAspect tenantJobAspect(TenantFrameworkService tenantFrameworkService) {
return new TenantJobAspect(tenantFrameworkService);
}
// ========== Redis ==========
@ -183,7 +202,25 @@ public class YudaoTenantAutoConfiguration {
RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory,
BatchStrategies.scan(yudaoCacheProperties.getRedisScanBatchSize()));
// 创建 TenantRedisCacheManager 对象
return new TenantRedisCacheManager(cacheWriter, redisCacheConfiguration, tenantProperties.getIgnoreCaches());
TenantRedisCacheManager cacheManager = new TenantRedisCacheManager(cacheWriter, redisCacheConfiguration,
tenantProperties.getIgnoreCaches());
// 开启事务感知:@Transactional 方法内的 @CacheEvict / @CachePut 自动延迟到 afterCommit
// 避免事务未提交就清缓存被并发读穿写脏值;无事务时立即生效,行为不变
cacheManager.setTransactionAware(true);
return cacheManager;
}
// ========== Job ==========
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(name = "cn.iocoder.yudao.framework.quartz.core.handler.JobHandler")
public static class TenantJobConfiguration {
@Bean
public TenantJobAspect tenantJobAspect(TenantFrameworkService tenantFrameworkService) {
return new TenantJobAspect(tenantFrameworkService);
}
}
}

View File

@ -2,8 +2,8 @@ package cn.iocoder.yudao.framework.tenant.core.mq.kafka;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.EnvironmentPostProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.env.ConfigurableEnvironment;
/**

View File

@ -27,7 +27,7 @@ import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.MethodParameter;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.core.ResolvableType;
import org.jspecify.annotations.Nullable;
import org.springframework.lang.Nullable;
import org.springframework.messaging.Message;
import org.springframework.messaging.handler.HandlerMethod;
import org.springframework.util.ObjectUtils;

View File

@ -1,2 +1,2 @@
org.springframework.boot.EnvironmentPostProcessor=\
org.springframework.boot.env.EnvironmentPostProcessor=\
cn.iocoder.yudao.framework.tenant.core.mq.kafka.TenantKafkaEnvironmentPostProcessor

View File

@ -24,7 +24,7 @@
<!-- Spring 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Web 相关 -->

View File

@ -2,7 +2,7 @@ package cn.iocoder.yudao.framework.tracer.config;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.micrometer.metrics.autoconfigure.MeterRegistryCustomizer;
import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;

View File

@ -1,7 +1,7 @@
package cn.iocoder.yudao.framework.mq.rabbitmq.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.support.converter.JacksonJsonMessageConverter;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
@ -18,11 +18,11 @@ import org.springframework.context.annotation.Bean;
public class YudaoRabbitMQAutoConfiguration {
/**
* JacksonJsonMessageConverter Bean使用 jackson 序列化消息
* Jackson2JsonMessageConverter Bean使用 jackson 序列化消息
*/
@Bean
public MessageConverter createMessageConverter() {
return new JacksonJsonMessageConverter();
return new Jackson2JsonMessageConverter();
}
}

View File

@ -12,7 +12,6 @@ import cn.iocoder.yudao.framework.mq.redis.core.stream.AbstractRedisStreamMessag
import cn.iocoder.yudao.framework.redis.config.YudaoRedisAutoConfiguration;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.context.annotation.Bean;
@ -70,7 +69,8 @@ public class YudaoRedisMQConsumerAutoConfiguration {
public RedisPendingMessageResendJob redisPendingMessageResendJob(List<AbstractRedisStreamMessageListener<?>> listeners,
RedisMQTemplate redisTemplate,
RedissonClient redissonClient) {
return new RedisPendingMessageResendJob(listeners, redisTemplate, redissonClient);
return new RedisPendingMessageResendJob(listeners, redisTemplate, redissonClient,
RedisPendingMessageResendJob.DEFAULT_RESEND_LOCK_KEY);
}
/**
@ -81,7 +81,8 @@ public class YudaoRedisMQConsumerAutoConfiguration {
public RedisStreamMessageCleanupJob redisStreamMessageCleanupJob(List<AbstractRedisStreamMessageListener<?>> listeners,
RedisMQTemplate redisTemplate,
RedissonClient redissonClient) {
return new RedisStreamMessageCleanupJob(listeners, redisTemplate, redissonClient);
return new RedisStreamMessageCleanupJob(listeners, redisTemplate, redissonClient,
RedisStreamMessageCleanupJob.DEFAULT_CLEANUP_LOCK_KEY);
}
/**

View File

@ -23,7 +23,9 @@ import java.util.Objects;
@AllArgsConstructor
public class RedisPendingMessageResendJob {
private static final String LOCK_KEY = "redis:stream:pending-message-resend:lock";
public static final String DEFAULT_RESEND_LOCK_KEY = "redis:stream:pending-message-resend:lock";
public static final String IOT_RESEND_LOCK_KEY = "redis:stream:pending-message-resend:lock:iot";
/**
* 消息超时时间,默认 5 分钟
@ -36,22 +38,26 @@ public class RedisPendingMessageResendJob {
private final List<AbstractRedisStreamMessageListener<?>> listeners;
private final RedisMQTemplate redisTemplate;
private final RedissonClient redissonClient;
private final String resendLockKey;
/**
* 一分钟执行一次,这里选择每分钟的 35 秒执行,是为了避免整点任务过多的问题
*/
@Scheduled(cron = "35 * * * * ?")
public void messageResend() {
RLock lock = redissonClient.getLock(LOCK_KEY);
// 尝试加锁
RLock lock = redissonClient.getLock(resendLockKey);
if (lock.tryLock()) {
try {
execute();
} catch (Exception ex) {
log.error("[messageResend][执行异常]", ex);
log.error("[messageResend][执行异常][lockKey={}]", resendLockKey, ex);
} finally {
lock.unlock();
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
} else {
log.debug("[messageResend][未获取到锁,跳过本轮][lockKey={}]", resendLockKey);
}
}

View File

@ -23,7 +23,16 @@ import java.util.List;
@AllArgsConstructor
public class RedisStreamMessageCleanupJob {
private static final String LOCK_KEY = "redis:stream:message-cleanup:lock";
/**
* 业务 MQSpring 容器内 AbstractRedisStreamMessageListener清理任务使用的分布式锁
*/
public static final String DEFAULT_CLEANUP_LOCK_KEY = "redis:stream:message-cleanup:lock";
/**
* IoT Redis 总线清理任务使用的分布式锁(须与 {@link #DEFAULT_CLEANUP_LOCK_KEY} 区分,否则会共抢一把锁,
* 同一时刻只有一侧能执行 XTRIM另一侧 Stream 可能无限积压)
*/
public static final String IOT_CLEANUP_LOCK_KEY = "redis:stream:message-cleanup:lock:iot";
/**
* 保留的消息数量,默认保留最近 10000 条消息
@ -33,22 +42,29 @@ public class RedisStreamMessageCleanupJob {
private final List<AbstractRedisStreamMessageListener<?>> listeners;
private final RedisMQTemplate redisTemplate;
private final RedissonClient redissonClient;
/**
* Redisson 锁键(多 Bean 注册清理任务时必须各不相同)
*/
private final String cleanupLockKey;
/**
* 每小时执行一次清理任务
*/
@Scheduled(cron = "0 0 * * * ?")
public void cleanup() {
RLock lock = redissonClient.getLock(LOCK_KEY);
// 尝试加锁
RLock lock = redissonClient.getLock(cleanupLockKey);
if (lock.tryLock()) {
try {
execute();
} catch (Exception ex) {
log.error("[cleanup][执行异常]", ex);
log.error("[cleanup][执行异常][lockKey={}]", cleanupLockKey, ex);
} finally {
lock.unlock();
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
} else {
log.debug("[cleanup][未获取到锁,跳过本轮][lockKey={}]", cleanupLockKey);
}
}
@ -59,8 +75,8 @@ public class RedisStreamMessageCleanupJob {
StreamOperations<String, Object, Object> ops = redisTemplate.getRedisTemplate().opsForStream();
listeners.forEach(listener -> {
try {
// 使用 XTRIM 命令清理消息,只保留最近的 MAX_LEN 条消息
Long trimCount = ops.trim(listener.getStreamKey(), MAX_COUNT, true);
// 使用 XTRIM MAXLEN 精确裁剪approximate=false避免 ~ 模式下长期明显高于上限
Long trimCount = ops.trim(listener.getStreamKey(), MAX_COUNT, false);
if (trimCount != null && trimCount > 0) {
log.info("[execute][Stream({}) 清理消息数量({})]", listener.getStreamKey(), trimCount);
}

View File

@ -71,11 +71,11 @@
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-4-starter</artifactId>
<artifactId>druid-spring-boot-3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
@ -83,7 +83,7 @@
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot4-starter</artifactId> <!-- 多数据源 -->
<artifactId>dynamic-datasource-spring-boot3-starter</artifactId> <!-- 多数据源 -->
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
@ -98,13 +98,20 @@
</dependency>
<dependency>
<groupId>org.dromara</groupId> <!-- VO 数据翻译 -->
<groupId>com.fhs-opensource</groupId> <!-- VO 数据翻译 -->
<artifactId>easy-trans-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.dromara</groupId>
<groupId>com.fhs-opensource</groupId>
<artifactId>easy-trans-mybatis-plus-extend</artifactId>
</dependency>
<!-- Test 测试相关 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@ -1,7 +1,7 @@
package cn.iocoder.yudao.framework.datasource.config;
import cn.iocoder.yudao.framework.datasource.core.filter.DruidAdRemoveFilter;
import com.alibaba.druid.spring.boot4.autoconfigure.properties.DruidStatProperties;
import com.alibaba.druid.spring.boot3.autoconfigure.properties.DruidStatProperties;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

View File

@ -6,8 +6,8 @@ import cn.iocoder.yudao.framework.mybatis.core.util.JdbcUtils;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.annotation.IdType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.EnvironmentPostProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.env.EnvironmentPostProcessor;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;

View File

@ -6,14 +6,16 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.mybatis.core.handler.DefaultDBFieldHandler;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
import com.baomidou.mybatisplus.core.handlers.IJsonTypeHandler;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.core.incrementer.IKeyGenerator;
import com.baomidou.mybatisplus.extension.handlers.Jackson3TypeHandler;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.baomidou.mybatisplus.extension.incrementer.*;
import com.baomidou.mybatisplus.extension.parser.JsqlParserGlobal;
import com.baomidou.mybatisplus.extension.parser.cache.JdkSerialCaffeineJsqlParseCache;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.autoconfigure.AutoConfiguration;
@ -23,7 +25,6 @@ import org.springframework.core.env.ConfigurableEnvironment;
import java.util.List;
import java.util.concurrent.TimeUnit;
import tools.jackson.databind.ObjectMapper;
/**
* MyBaits 配置类
@ -80,15 +81,15 @@ public class YudaoMybatisAutoConfiguration {
throw new IllegalArgumentException(StrUtil.format("DbType{} 找不到合适的 IKeyGenerator 实现类", dbType));
}
@Bean // 特殊:返回结果使用 Object 而不用 Jackson3TypeHandler 的原因,避免因为 Jackson3TypeHandler 被 mybatis 全局使用!
@Bean // 特殊:返回结果使用 Object 而不用 JacksonTypeHandler 的原因,避免因为 JacksonTypeHandler 被 mybatis 全局使用!
public Object jacksonTypeHandler(List<ObjectMapper> objectMappers) {
// 特殊:设置 Jackson3TypeHandler 的 ObjectMapper
// 特殊:设置 JacksonTypeHandler 的 ObjectMapper
ObjectMapper objectMapper = CollUtil.getFirst(objectMappers);
if (objectMapper == null) {
objectMapper = JsonUtils.getObjectMapper();
}
Jackson3TypeHandler.setObjectMapper(objectMapper);
return new Jackson3TypeHandler(Object.class);
JacksonTypeHandler.setObjectMapper(objectMapper);
return new JacksonTypeHandler(Object.class);
}
}

View File

@ -4,7 +4,7 @@ import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import org.dromara.core.trans.vo.TransPojo;
import com.fhs.core.trans.vo.TransPojo;
import lombok.Data;
import org.apache.ibatis.type.JdbcType;

View File

@ -20,51 +20,49 @@ public enum DbTypeEnum {
/**
* H2
*
* 注意H2 不支持 find_in_set 函数
*/
H2(DbType.H2, "H2", ""),
H2(DbType.H2, "H2", "POSITION(',' || CAST(#{value} AS VARCHAR) || ',' IN ',' || #{column} || ',') > 0"),
/**
* MySQL
*/
MY_SQL(DbType.MYSQL, "MySQL", "FIND_IN_SET('#{value}', #{column}) <> 0"),
MY_SQL(DbType.MYSQL, "MySQL", "FIND_IN_SET(#{value}, #{column}) <> 0"),
/**
* Oracle
*/
ORACLE(DbType.ORACLE, "Oracle", "FIND_IN_SET('#{value}', #{column}) <> 0"),
ORACLE(DbType.ORACLE, "Oracle", "INSTR(',' || #{column} || ',', ',' || #{value} || ',') > 0"),
/**
* PostgreSQL
*
* 华为 openGauss 使用 ProductName 与 PostgreSQL 相同
*/
POSTGRE_SQL(DbType.POSTGRE_SQL,"PostgreSQL", "POSITION('#{value}' IN #{column}) <> 0"),
POSTGRE_SQL(DbType.POSTGRE_SQL, "PostgreSQL", "POSITION(',' || CAST(#{value} AS VARCHAR) || ',' IN ',' || #{column} || ',') > 0"),
/**
* SQL Server
*/
SQL_SERVER(DbType.SQL_SERVER, "Microsoft SQL Server", "CHARINDEX(',' + #{value} + ',', ',' + #{column} + ',') <> 0"),
SQL_SERVER(DbType.SQL_SERVER, "Microsoft SQL Server", "CHARINDEX(',' + CAST(#{value} AS varchar(255)) + ',', ',' + #{column} + ',') > 0"),
/**
* SQL Server 2005
*/
SQL_SERVER2005(DbType.SQL_SERVER2005, "Microsoft SQL Server 2005", "CHARINDEX(',' + #{value} + ',', ',' + #{column} + ',') <> 0"),
SQL_SERVER2005(DbType.SQL_SERVER2005, "Microsoft SQL Server 2005", "CHARINDEX(',' + CAST(#{value} AS varchar(255)) + ',', ',' + #{column} + ',') > 0"),
/**
* 达梦
*/
DM(DbType.DM, "DM DBMS", "FIND_IN_SET('#{value}', #{column}) <> 0"),
DM(DbType.DM, "DM DBMS", "FIND_IN_SET(#{value}, #{column}) <> 0"),
/**
* 人大金仓
*/
KINGBASE_ES(DbType.KINGBASE_ES, "KingbaseES", "POSITION('#{value}' IN #{column}) <> 0"),
KINGBASE_ES(DbType.KINGBASE_ES, "KingbaseES", "POSITION(',' || CAST(#{value} AS VARCHAR) || ',' IN ',' || #{column} || ',') > 0"),
/**
* OceanBase
*/
OCEAN_BASE(DbType.OCEAN_BASE, "OceanBase", "FIND_IN_SET('#{value}', #{column}) <> 0")
OCEAN_BASE(DbType.OCEAN_BASE, "OceanBase", "FIND_IN_SET(#{value}, #{column}) <> 0")
;
@ -95,7 +93,9 @@ public enum DbTypeEnum {
}
public static String getFindInSetTemplate(DbType dbType) {
return Optional.of(MAP_BY_MP.get(dbType).getFindInSetTemplate())
return Optional.ofNullable(MAP_BY_MP.get(dbType))
.map(DbTypeEnum::getFindInSetTemplate)
.filter(StrUtil::isNotBlank)
.orElseThrow(() -> new IllegalArgumentException("FIND_IN_SET not supported"));
}
}

View File

@ -170,6 +170,17 @@ public interface BaseMapperX<T> extends MPJBaseMapper<T> {
return CollUtil.getFirst(list);
}
/**
* 获取满足条件的最新一条记录
* <p>
* 目的:解决并发场景下,插入多条记录后,使用 selectOne 会报错的问题
*
* @param queryWrapper 查询条件
* @return 最新一条;不存在返回 null
*/
default T selectLastOne(LambdaQueryWrapper<T> queryWrapper) {
return CollUtil.getLast(selectList(queryWrapper));
}
default Long selectCount() {
return selectCount(new QueryWrapper<>());

View File

@ -25,6 +25,13 @@ public class LambdaQueryWrapperX<T> extends LambdaQueryWrapper<T> {
return this;
}
public LambdaQueryWrapperX<T> likeRightIfPresent(SFunction<T, ?> column, String val) {
if (StringUtils.hasText(val)) {
return (LambdaQueryWrapperX<T>) super.likeRight(column, val);
}
return this;
}
public LambdaQueryWrapperX<T> inIfPresent(SFunction<T, ?> column, Collection<?> values) {
if (ObjectUtil.isAllNotEmpty(values) && !ArrayUtil.isEmpty(values)) {
return (LambdaQueryWrapperX<T>) super.in(column, values);

View File

@ -27,6 +27,13 @@ public class MPJLambdaWrapperX<T> extends MPJLambdaWrapper<T> {
return this;
}
public <S> MPJLambdaWrapperX<T> likeRightIfPresent(SFunction<S, ?> column, String val) {
if (StringUtils.hasText(val)) {
return (MPJLambdaWrapperX<T>) super.likeRight(column, val);
}
return this;
}
public <S> MPJLambdaWrapperX<T> inIfPresent(SFunction<S, ?> column, Collection<?> values) {
if (ObjectUtil.isAllNotEmpty(values) && !ArrayUtil.isEmpty(values)) {
return (MPJLambdaWrapperX<T>) super.in(column, values);
@ -102,7 +109,6 @@ public class MPJLambdaWrapperX<T> extends MPJLambdaWrapper<T> {
return this;
}
// ========== 重写父类方法,方便链式调用 ==========
@Override

View File

@ -25,6 +25,13 @@ public class QueryWrapperX<T> extends QueryWrapper<T> {
return this;
}
public QueryWrapperX<T> likeRightIfPresent(String column, String val) {
if (StringUtils.hasText(val)) {
return (QueryWrapperX<T>) super.likeRight(column, val);
}
return this;
}
public QueryWrapperX<T> inIfPresent(String column, Collection<?> values) {
if (!CollectionUtils.isEmpty(values)) {
return (QueryWrapperX<T>) super.in(column, values);
@ -95,13 +102,13 @@ public class QueryWrapperX<T> extends QueryWrapper<T> {
}
public QueryWrapperX<T> betweenIfPresent(String column, Object[] values) {
if (values!= null && values.length != 0 && values[0] != null && values[1] != null) {
if (values != null && values.length != 0 && values[0] != null && values[1] != null) {
return (QueryWrapperX<T>) super.between(column, values[0], values[1]);
}
if (values!= null && values.length != 0 && values[0] != null) {
if (values != null && values.length != 0 && values[0] != null) {
return (QueryWrapperX<T>) ge(column, values[0]);
}
if (values!= null && values.length != 0 && values[1] != null) {
if (values != null && values.length != 0 && values[1] != null) {
return (QueryWrapperX<T>) le(column, values[1]);
}
return this;

View File

@ -23,6 +23,7 @@ import net.sf.jsqlparser.schema.Table;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.regex.Pattern;
/**
* MyBatis 工具类
@ -31,6 +32,12 @@ public class MyBatisUtils {
private static final String MYSQL_ESCAPE_CHARACTER = "`";
private static final Pattern SAFE_COLUMN_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_]+(\\.[a-zA-Z0-9_]+)*$");
private static final String FIND_IN_SET_VALUE_PLACEHOLDER = "#{value}";
private static final String FIND_IN_SET_COLUMN_PLACEHOLDER = "#{column}";
public static <T> Page<T> buildPage(PageParam pageParam) {
return buildPage(pageParam, null);
}
@ -42,8 +49,11 @@ public class MyBatisUtils {
// 排序字段
if (CollUtil.isNotEmpty(sortingFields)) {
for (SortingField sortingField : sortingFields) {
page.addOrder(new OrderItem().setAsc(SortingField.ORDER_ASC.equals(sortingField.getOrder()))
.setColumn(StrUtil.toUnderlineCase(sortingField.getField())));
String columnName = buildSafeOrderColumn(sortingField.getField());
if (columnName == null) {
continue;
}
page.addOrder(new OrderItem().setAsc(isAscOrder(sortingField.getOrder())).setColumn(columnName));
}
}
return page;
@ -57,23 +67,29 @@ public class MyBatisUtils {
if (wrapper instanceof QueryWrapper<T>) {
QueryWrapper<T> query = (QueryWrapper<T>) wrapper;
for (SortingField sortingField : sortingFields) {
query.orderBy(true,
SortingField.ORDER_ASC.equals(sortingField.getOrder()),
StrUtil.toUnderlineCase(sortingField.getField()));
String columnName = buildSafeOrderColumn(sortingField.getField());
if (columnName == null) {
continue;
}
query.orderBy(true, isAscOrder(sortingField.getOrder()), columnName);
}
} else if (wrapper instanceof LambdaQueryWrapper<T>) {
// LambdaQueryWrapper 不直接支持字符串字段排序,使用 last 方法拼接 ORDER BY
LambdaQueryWrapper<T> lambdaQuery = (LambdaQueryWrapper<T>) wrapper;
StringBuilder orderBy = new StringBuilder();
for (SortingField sortingField : sortingFields) {
String columnName = buildSafeOrderColumn(sortingField.getField());
if (columnName == null) {
continue;
}
if (StrUtil.isNotEmpty(orderBy)) {
orderBy.append(", ");
}
orderBy.append(StrUtil.toUnderlineCase(sortingField.getField()))
.append(" ")
.append(SortingField.ORDER_ASC.equals(sortingField.getOrder()) ? "ASC" : "DESC");
orderBy.append(columnName).append(" ").append(getOrderDirection(sortingField.getOrder()));
}
if (StrUtil.isNotEmpty(orderBy)) {
lambdaQuery.last("ORDER BY " + orderBy);
}
lambdaQuery.last("ORDER BY " + orderBy);
// 另外个思路https://blog.csdn.net/m0_59084856/article/details/138450913
} else {
throw new IllegalArgumentException("Unsupported wrapper type: " + wrapper.getClass().getName());
@ -81,6 +97,22 @@ public class MyBatisUtils {
}
public static boolean isAscOrder(String order) {
return SortingField.ORDER_ASC.equals(order);
}
public static String getOrderDirection(String order) {
return isAscOrder(order) ? "ASC" : "DESC";
}
private static String buildSafeOrderColumn(String field) {
String columnName = StrUtil.toUnderlineCase(field);
if (StrUtil.isEmpty(columnName) || !SAFE_COLUMN_NAME_PATTERN.matcher(columnName).matches()) {
return null;
}
return columnName;
}
/**
* 将拦截器添加到链中
* 由于 MybatisPlusInterceptor 不支持添加拦截器,所以只能全量设置
@ -129,15 +161,43 @@ public class MyBatisUtils {
/**
* 跨数据库的 find_in_set 实现
*
* @param column 字段名称
* @param value 查询值(不带单引号)
* @param columnName 字段名称
* @return sql
*/
public static String findInSet(String column, Object value) {
public static String findInSet(String columnName) {
return findInSet(columnName, 0);
}
/**
* 跨数据库的 find_in_set 实现,适用于同一个 apply 语句中有多个参数的场景
*
* @param columnName 字段名称
* @param paramIndex apply 参数序号
* @return sql
*/
public static String findInSetWithParamIndex(String columnName, int paramIndex) {
return findInSet(columnName, paramIndex);
}
private static String findInSet(String columnName, int paramIndex) {
DbType dbType = JdbcUtils.getDbType();
return findInSet(dbType, columnName, paramIndex);
}
static String findInSet(DbType dbType, String columnName, int paramIndex) {
if (!isSafeColumnName(columnName)) {
throw new IllegalArgumentException("Invalid column name: " + columnName);
}
if (paramIndex < 0) {
throw new IllegalArgumentException("Invalid param index: " + paramIndex);
}
return DbTypeEnum.getFindInSetTemplate(dbType)
.replace("#{column}", column)
.replace("#{value}", StrUtil.toString(value));
.replace(FIND_IN_SET_COLUMN_PLACEHOLDER, columnName)
.replace(FIND_IN_SET_VALUE_PLACEHOLDER, "{" + paramIndex + "}");
}
private static boolean isSafeColumnName(String columnName) {
return StrUtil.isNotEmpty(columnName) && SAFE_COLUMN_NAME_PATTERN.matcher(columnName).matches();
}
/**

View File

@ -1,16 +1,14 @@
package cn.iocoder.yudao.framework.translate.config;
import cn.iocoder.yudao.framework.translate.core.TranslateUtils;
import org.dromara.trans.service.impl.TransService;
import com.fhs.trans.service.impl.TransService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.context.annotation.Bean;
@AutoConfiguration
public class YudaoTranslateAutoConfiguration {
@Bean
@ConditionalOnBean(TransService.class)
@SuppressWarnings({"InstantiationOfUtilityClass", "SpringJavaInjectionPointsAutowiringInspection"})
public TranslateUtils translateUtils(TransService transService) {
TranslateUtils.init(transService);

View File

@ -1,8 +1,8 @@
package cn.iocoder.yudao.framework.translate.core;
import cn.hutool.core.collection.CollUtil;
import org.dromara.core.trans.vo.VO;
import org.dromara.trans.service.impl.TransService;
import com.fhs.core.trans.vo.VO;
import com.fhs.trans.service.impl.TransService;
import java.util.List;

View File

@ -1,2 +1,2 @@
org.springframework.boot.EnvironmentPostProcessor=\
org.springframework.boot.env.EnvironmentPostProcessor=\
cn.iocoder.yudao.framework.mybatis.config.IdTypeEnvironmentPostProcessor

View File

@ -0,0 +1,173 @@
package cn.iocoder.yudao.framework.mybatis.core.util;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.SortingField;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* {@link MyBatisUtils} 的单元测试
*/
public class MyBatisUtilsTest {
@Test
public void testBuildPage_sortingFields() {
// 准备参数
PageParam pageParam = new PageParam();
pageParam.setPageNo(2);
pageParam.setPageSize(20);
List<SortingField> sortingFields = Arrays.asList(
new SortingField("userName", SortingField.ORDER_ASC),
new SortingField("u.id", SortingField.ORDER_DESC),
new SortingField("name desc", SortingField.ORDER_DESC));
// 调用
Page<Object> page = MyBatisUtils.buildPage(pageParam, sortingFields);
// 断言
assertEquals(2, page.getCurrent());
assertEquals(20, page.getSize());
assertEquals(2, page.orders().size());
assertOrderItem(page.orders().get(0), "user_name", true);
assertOrderItem(page.orders().get(1), "u.id", false);
}
@Test
public void testAddOrder_queryWrapper() {
// 准备参数
QueryWrapper<Object> query = new QueryWrapper<>();
List<SortingField> sortingFields = Arrays.asList(
new SortingField("userName", SortingField.ORDER_ASC),
new SortingField("u.id", SortingField.ORDER_DESC),
new SortingField("name;drop", SortingField.ORDER_ASC));
// 调用
MyBatisUtils.addOrder(query, sortingFields);
// 断言
assertEquals(" ORDER BY user_name ASC,u.id DESC", query.getSqlSegment());
}
@Test
public void testAddOrder_lambdaQueryWrapper() {
// 准备参数
LambdaQueryWrapper<Object> query = new LambdaQueryWrapper<>();
List<SortingField> sortingFields = Arrays.asList(
new SortingField("userName", SortingField.ORDER_ASC),
new SortingField("u.id", SortingField.ORDER_DESC),
new SortingField("name`", SortingField.ORDER_ASC));
// 调用
MyBatisUtils.addOrder(query, sortingFields);
// 断言
assertEquals(" ORDER BY user_name ASC, u.id DESC", query.getSqlSegment());
}
@Test
public void testAddOrder_lambdaQueryWrapper_invalidSortingFields() {
// 准备参数
LambdaQueryWrapper<Object> query = new LambdaQueryWrapper<>();
List<SortingField> sortingFields = Arrays.asList(
new SortingField("name desc", SortingField.ORDER_ASC),
new SortingField("name;drop", SortingField.ORDER_DESC));
// 调用
MyBatisUtils.addOrder(query, sortingFields);
// 断言
assertEquals("", query.getSqlSegment());
}
@Test
public void testOrderDirection() {
assertTrue(MyBatisUtils.isAscOrder(SortingField.ORDER_ASC));
assertFalse(MyBatisUtils.isAscOrder(SortingField.ORDER_DESC));
assertEquals("ASC", MyBatisUtils.getOrderDirection(SortingField.ORDER_ASC));
assertEquals("DESC", MyBatisUtils.getOrderDirection(SortingField.ORDER_DESC));
assertEquals("DESC", MyBatisUtils.getOrderDirection(null));
}
@Test
public void testFindInSet() {
assertEquals("FIND_IN_SET({0}, websites) <> 0",
MyBatisUtils.findInSet(DbType.MYSQL, "websites", 0));
assertEquals("POSITION(',' || CAST({0} AS VARCHAR) || ',' IN ',' || websites || ',') > 0",
MyBatisUtils.findInSet(DbType.H2, "websites", 0));
assertEquals("INSTR(',' || t.websites || ',', ',' || {0} || ',') > 0",
MyBatisUtils.findInSet(DbType.ORACLE, "t.websites", 0));
assertEquals("POSITION(',' || CAST({1} AS VARCHAR) || ',' IN ',' || websites || ',') > 0",
MyBatisUtils.findInSet(DbType.POSTGRE_SQL, "websites", 1));
assertEquals("CHARINDEX(',' + CAST({2} AS varchar(255)) + ',', ',' + websites + ',') > 0",
MyBatisUtils.findInSet(DbType.SQL_SERVER, "websites", 2));
}
@Test
public void testFindInSet_invalidColumnName() {
assertThrows(IllegalArgumentException.class,
() -> MyBatisUtils.findInSet(DbType.MYSQL, "websites;drop table system_tenant", 0));
assertThrows(IllegalArgumentException.class,
() -> MyBatisUtils.findInSet(DbType.MYSQL, "FIND_IN_SET(value, websites)", 0));
}
@Test
public void testFindInSet_invalidParamIndex() {
assertThrows(IllegalArgumentException.class,
() -> MyBatisUtils.findInSet(DbType.MYSQL, "websites", -1));
}
@Test
public void testFindInSet_applyBindsValue() {
// 准备参数
QueryWrapper<Object> query = new QueryWrapper<>();
String value = "test' OR 1 = 1";
// 调用
query.apply(MyBatisUtils.findInSet(DbType.MYSQL, "to_mails", 0), value);
// 断言SQL 片段里只有 MyBatis Plus 参数占位,用户输入不会被直接拼接进去
assertEquals("(FIND_IN_SET(#{ew.paramNameValuePairs.MPGENVAL1}, to_mails) <> 0)",
query.getSqlSegment());
assertFalse(query.getSqlSegment().contains(value));
assertEquals(value, query.getParamNameValuePairs().get("MPGENVAL1"));
}
@Test
public void testFindInSet_applyBindsMultipleValues() {
// 准备参数
QueryWrapper<Object> query = new QueryWrapper<>();
String value1 = "1' OR 1 = 1";
String value2 = "2' OR 1 = 1";
// 调用
query.apply(MyBatisUtils.findInSet(DbType.MYSQL, "tag_ids", 0)
+ " OR " + MyBatisUtils.findInSet(DbType.MYSQL, "tag_ids", 1), value1, value2);
// 断言:多个参数都由 MyBatis Plus 生成占位符,不拼接用户输入
assertEquals("(FIND_IN_SET(#{ew.paramNameValuePairs.MPGENVAL1}, tag_ids) <> 0"
+ " OR FIND_IN_SET(#{ew.paramNameValuePairs.MPGENVAL2}, tag_ids) <> 0)",
query.getSqlSegment());
assertFalse(query.getSqlSegment().contains(value1));
assertFalse(query.getSqlSegment().contains(value2));
assertEquals(value1, query.getParamNameValuePairs().get("MPGENVAL1"));
assertEquals(value2, query.getParamNameValuePairs().get("MPGENVAL2"));
}
private void assertOrderItem(OrderItem orderItem, String column, boolean asc) {
assertEquals(column, orderItem.getColumn());
assertEquals(asc, orderItem.isAsc());
}
}

View File

@ -33,8 +33,8 @@
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jackson</artifactId>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
</dependencies>

View File

@ -3,7 +3,7 @@ package cn.iocoder.yudao.framework.redis.config;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.redis.core.TimeoutRedisCacheManager;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.cache.autoconfigure.CacheProperties;
import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
@ -75,8 +75,12 @@ public class YudaoCacheAutoConfiguration {
RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory());
RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory,
BatchStrategies.scan(yudaoCacheProperties.getRedisScanBatchSize()));
// 创建 TenantRedisCacheManager 对象
return new TimeoutRedisCacheManager(cacheWriter, redisCacheConfiguration);
// 创建 TimeoutRedisCacheManager 对象
TimeoutRedisCacheManager cacheManager = new TimeoutRedisCacheManager(cacheWriter, redisCacheConfiguration);
// 开启事务感知:@Transactional 方法内的 @CacheEvict / @CachePut 自动延迟到 afterCommit
// 避免事务未提交就清缓存被并发读穿写脏值;无事务时立即生效,行为不变
cacheManager.setTransactionAware(true);
return cacheManager;
}
}

View File

@ -1,18 +1,19 @@
package cn.iocoder.yudao.framework.redis.config;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import org.redisson.spring.starter.RedissonAutoConfigurationV4;
import cn.hutool.core.util.ReflectUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.redisson.spring.starter.RedissonAutoConfigurationV2;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
/**
* Redis 配置类
*/
@AutoConfiguration(before = RedissonAutoConfigurationV4.class) // 目的:使用自己定义的 RedisTemplate Bean
@AutoConfiguration(before = RedissonAutoConfigurationV2.class) // 目的:使用自己定义的 RedisTemplate Bean
public class YudaoRedisAutoConfiguration {
/**
@ -27,15 +28,18 @@ public class YudaoRedisAutoConfiguration {
// 使用 String 序列化方式,序列化 KEY 。
template.setKeySerializer(RedisSerializer.string());
template.setHashKeySerializer(RedisSerializer.string());
// 使用 JSON 序列化方式,序列化 VALUE
RedisSerializer<?> redisSerializer = buildRedisSerializer();
template.setValueSerializer(redisSerializer);
template.setHashValueSerializer(redisSerializer);
// 使用 JSON 序列化方式(库是 Jackson ,序列化 VALUE
template.setValueSerializer(buildRedisSerializer());
template.setHashValueSerializer(buildRedisSerializer());
return template;
}
public static RedisSerializer<?> buildRedisSerializer() {
return new GenericJacksonJsonRedisSerializer(JsonUtils.getObjectMapper());
RedisSerializer<Object> json = RedisSerializer.json();
// 解决 LocalDateTime 的序列化
ObjectMapper objectMapper = (ObjectMapper) ReflectUtil.getFieldValue(json, "mapper");
objectMapper.registerModules(new JavaTimeModule());
return json;
}
}

View File

@ -27,7 +27,7 @@
<!-- Spring 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Web 相关 -->

View File

@ -29,12 +29,15 @@ import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.util.pattern.PathPattern;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
/**
* 自定义的 Spring Security 配置适配器实现
*
@ -167,7 +170,12 @@ public class YudaoWebSecurityConfigurerAdapter {
continue;
}
Set<String> urls = new HashSet<>();
urls.addAll(entry.getKey().getPatternValues());
if (entry.getKey().getPatternsCondition() != null) {
urls.addAll(entry.getKey().getPatternsCondition().getPatterns());
}
if (entry.getKey().getPathPatternsCondition() != null) {
urls.addAll(convertList(entry.getKey().getPathPatternsCondition().getPatterns(), PathPattern::getPatternString));
}
if (urls.isEmpty()) {
continue;
}

View File

@ -5,7 +5,7 @@ import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils;
import org.jspecify.annotations.Nullable;
import org.springframework.lang.Nullable;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;

View File

@ -1,7 +1,7 @@
package cn.iocoder.yudao.framework.test.config;
import com.github.fppt.jedismock.RedisServer;
import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -16,14 +16,14 @@ import java.io.IOException;
*/
@Configuration(proxyBeanMethods = false)
@Lazy(false) // 禁止延迟加载
@EnableConfigurationProperties(DataRedisProperties.class)
@EnableConfigurationProperties(RedisProperties.class)
public class RedisTestConfiguration {
/**
* 创建模拟的 Redis Server 服务器
*/
@Bean
public RedisServer redisServer(DataRedisProperties properties) throws IOException {
public RedisServer redisServer(RedisProperties properties) throws IOException {
RedisServer redisServer = new RedisServer(properties.getPort());
// 一次执行多个单元测试时,貌似创建多个 spring 容器,导致不进行 stop。这样就导致端口被占用无法启动。。。
try {

View File

@ -3,7 +3,7 @@ package cn.iocoder.yudao.framework.test.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate;
import org.springframework.boot.sql.autoconfigure.init.SqlInitializationProperties;
import org.springframework.boot.autoconfigure.sql.init.SqlInitializationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.jdbc.init.DataSourceScriptDatabaseInitializer;
import org.springframework.boot.sql.init.AbstractScriptDatabaseInitializer;
@ -17,7 +17,7 @@ import javax.sql.DataSource;
/**
* SQL 初始化的测试 Configuration
*
* 为什么不使用 org.springframework.boot.sql.autoconfigure.init.DataSourceInitializationConfiguration 呢?
* 为什么不使用 org.springframework.boot.autoconfigure.sql.init.DataSourceInitializationConfiguration 呢?
* 因为我们在单元测试会使用 spring.main.lazy-initialization 为 true开启延迟加载。此时会导致 DataSourceInitializationConfiguration 初始化
* 不过呢,当前类的实现代码,基本是复制 DataSourceInitializationConfiguration 的哈!
*

View File

@ -6,12 +6,12 @@ import cn.iocoder.yudao.framework.mybatis.config.YudaoMybatisAutoConfiguration;
import cn.iocoder.yudao.framework.redis.config.YudaoRedisAutoConfiguration;
import cn.iocoder.yudao.framework.test.config.RedisTestConfiguration;
import cn.iocoder.yudao.framework.test.config.SqlInitializationTestConfiguration;
import com.alibaba.druid.spring.boot4.autoconfigure.DruidDataSourceAutoConfigure;
import com.alibaba.druid.spring.boot3.autoconfigure.DruidDataSourceAutoConfigure;
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
import org.redisson.spring.starter.RedissonAutoConfigurationV4;
import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration;
import org.redisson.spring.starter.RedissonAutoConfiguration;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
@ -43,8 +43,8 @@ public class BaseDbAndRedisUnitTest {
// Redis 配置类
RedisTestConfiguration.class, // Redis 测试配置类,用于启动 RedisServer
YudaoRedisAutoConfiguration.class, // 自己的 Redis 配置类
DataRedisAutoConfiguration.class, // Spring Redis 自动配置类
RedissonAutoConfigurationV4.class, // Redisson 自动配置类
RedisAutoConfiguration.class, // Spring Redis 自动配置类
RedissonAutoConfiguration.class, // Redisson 自动配置类
// 其它配置类
SpringUtil.class

View File

@ -4,11 +4,11 @@ import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.datasource.config.YudaoDataSourceAutoConfiguration;
import cn.iocoder.yudao.framework.mybatis.config.YudaoMybatisAutoConfiguration;
import cn.iocoder.yudao.framework.test.config.SqlInitializationTestConfiguration;
import com.alibaba.druid.spring.boot4.autoconfigure.DruidDataSourceAutoConfigure;
import com.alibaba.druid.spring.boot3.autoconfigure.DruidDataSourceAutoConfigure;
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
import com.github.yulichang.autoconfigure.MybatisPlusJoinAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration;
import org.springframework.boot.jdbc.autoconfigure.DataSourceTransactionManagerAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;

View File

@ -3,8 +3,8 @@ package cn.iocoder.yudao.framework.test.core.ut;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.redis.config.YudaoRedisAutoConfiguration;
import cn.iocoder.yudao.framework.test.config.RedisTestConfiguration;
import org.redisson.spring.starter.RedissonAutoConfigurationV4;
import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration;
import org.redisson.spring.starter.RedissonAutoConfiguration;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
@ -23,9 +23,9 @@ public class BaseRedisUnitTest {
@Import({
// Redis 配置类
RedisTestConfiguration.class, // Redis 测试配置类,用于启动 RedisServer
DataRedisAutoConfiguration.class, // Spring Redis 自动配置类
RedisAutoConfiguration.class, // Spring Redis 自动配置类
YudaoRedisAutoConfiguration.class, // 自己的 Redis 配置类
RedissonAutoConfigurationV4.class, // Redisson 自动配置类
RedissonAutoConfiguration.class, // Redisson 自动配置类
// 其它配置类
SpringUtil.class

View File

@ -26,14 +26,6 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-restclient</artifactId>
</dependency>
<!-- spring boot 配置所需依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@ -19,6 +19,7 @@ import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
import cn.iocoder.yudao.framework.web.config.WebProperties;
import cn.iocoder.yudao.framework.web.core.filter.ApiRequestFilter;
import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils;
import com.fasterxml.jackson.databind.JsonNode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.FilterChain;
@ -28,7 +29,6 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import tools.jackson.databind.JsonNode;
import java.io.IOException;
import java.time.LocalDateTime;

View File

@ -3,7 +3,7 @@ package cn.iocoder.yudao.framework.desensitize.core.base.annotation;
import cn.iocoder.yudao.framework.desensitize.core.base.handler.DesensitizationHandler;
import cn.iocoder.yudao.framework.desensitize.core.base.serializer.StringDesensitizeSerializer;
import com.fasterxml.jackson.annotation.JacksonAnnotationsInside;
import tools.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;

View File

@ -7,15 +7,16 @@ import cn.hutool.core.util.ReflectUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.desensitize.core.base.annotation.DesensitizeBy;
import cn.iocoder.yudao.framework.desensitize.core.base.handler.DesensitizationHandler;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.ContextualSerializer;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import lombok.Getter;
import lombok.Setter;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.BeanProperty;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.ValueSerializer;
import tools.jackson.databind.ser.std.StdSerializer;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
@ -27,7 +28,7 @@ import java.lang.reflect.Field;
* @author gaibu
*/
@SuppressWarnings("rawtypes")
public class StringDesensitizeSerializer extends StdSerializer<String> {
public class StringDesensitizeSerializer extends StdSerializer<String> implements ContextualSerializer {
@Getter
@Setter
@ -38,7 +39,7 @@ public class StringDesensitizeSerializer extends StdSerializer<String> {
}
@Override
public ValueSerializer<?> createContextual(SerializationContext serializerProvider, BeanProperty beanProperty) {
public JsonSerializer<?> createContextual(SerializerProvider serializerProvider, BeanProperty beanProperty) {
DesensitizeBy annotation = beanProperty.getAnnotation(DesensitizeBy.class);
if (annotation == null) {
return this;
@ -51,7 +52,7 @@ public class StringDesensitizeSerializer extends StdSerializer<String> {
@Override
@SuppressWarnings("unchecked")
public void serialize(String value, JsonGenerator gen, SerializationContext serializerProvider) throws JacksonException {
public void serialize(String value, JsonGenerator gen, SerializerProvider serializerProvider) throws IOException {
if (StrUtil.isBlank(value)) {
gen.writeNull();
return;
@ -82,7 +83,7 @@ public class StringDesensitizeSerializer extends StdSerializer<String> {
* @return 字段
*/
private Field getField(JsonGenerator generator) {
String currentName = generator.streamWriteContext().currentName();
String currentName = generator.getOutputContext().getCurrentName();
Object currentValue = generator.currentValue();
Class<?> currentValueClass = currentValue.getClass();
return ReflectUtil.getField(currentValueClass, currentName);

View File

@ -4,18 +4,18 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.common.util.json.databind.NumberSerializer;
import cn.iocoder.yudao.framework.common.util.json.databind.TimestampLocalDateTimeDeserializer;
import cn.iocoder.yudao.framework.common.util.json.databind.TimestampLocalDateTimeSerializer;
import com.fasterxml.jackson.databind.Module;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.context.annotation.Bean;
import tools.jackson.databind.JacksonModule;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.ext.javatime.deser.LocalDateDeserializer;
import tools.jackson.databind.ext.javatime.deser.LocalTimeDeserializer;
import tools.jackson.databind.ext.javatime.ser.LocalDateSerializer;
import tools.jackson.databind.ext.javatime.ser.LocalTimeSerializer;
import tools.jackson.databind.module.SimpleModule;
import java.time.LocalDate;
import java.time.LocalDateTime;
@ -29,15 +29,26 @@ public class YudaoJacksonAutoConfiguration {
* 从 Builder 源头定制(关键:使用 *ByType避免 handledType 要求)
*/
@Bean
public JsonMapperBuilderCustomizer ldtEpochMillisCustomizer(JacksonModule timestampSupportModuleBean) {
return builder -> builder.addModule(timestampSupportModuleBean);
public Jackson2ObjectMapperBuilderCustomizer ldtEpochMillisCustomizer() {
return builder -> builder
// Long -> Number
.serializerByType(Long.class, NumberSerializer.INSTANCE)
.serializerByType(Long.TYPE, NumberSerializer.INSTANCE)
// LocalDate / LocalTime
.serializerByType(LocalDate.class, LocalDateSerializer.INSTANCE)
.deserializerByType(LocalDate.class, LocalDateDeserializer.INSTANCE)
.serializerByType(LocalTime.class, LocalTimeSerializer.INSTANCE)
.deserializerByType(LocalTime.class, LocalTimeDeserializer.INSTANCE)
// LocalDateTime < - > EpochMillis
.serializerByType(LocalDateTime.class, TimestampLocalDateTimeSerializer.INSTANCE)
.deserializerByType(LocalDateTime.class, TimestampLocalDateTimeDeserializer.INSTANCE);
}
/**
* 以 Bean 形式暴露 ModuleBoot 会自动注册到所有 ObjectMapper
*/
@Bean
public JacksonModule timestampSupportModuleBean() {
public Module timestampSupportModuleBean() {
SimpleModule m = new SimpleModule("TimestampSupportModule");
// Long -> Number避免前端精度丢失
m.addSerializer(Long.class, NumberSerializer.INSTANCE);

View File

@ -14,9 +14,10 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.webmvc.autoconfigure.WebMvcRegistrations;
import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.Order;
@ -143,7 +144,7 @@ public class YudaoWebAutoConfiguration {
/**
* 创建 RestTemplate 实例
*
* @param restTemplateBuilder {@link RestTemplateBuilder#build}
* @param restTemplateBuilder {@link RestTemplateAutoConfiguration#restTemplateBuilder}
*/
@Bean
@ConditionalOnMissingBean

View File

@ -15,6 +15,7 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.common.util.monitor.TracerUtils;
import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import com.google.common.util.concurrent.UncheckedExecutionException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
@ -38,7 +39,6 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import tools.jackson.databind.exc.InvalidFormatException;
import java.time.LocalDateTime;
import java.util.List;

View File

@ -9,7 +9,7 @@ import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
@ -37,17 +37,17 @@ public class YudaoXssAutoConfiguration implements WebMvcConfigurer {
/**
* 注册 Jackson 的序列化器,用于处理 json 类型参数的 xss 过滤
*
* @return JsonMapperBuilderCustomizer
* @return Jackson2ObjectMapperBuilderCustomizer
*/
@Bean
@ConditionalOnMissingBean(name = "xssJacksonCustomizer")
@ConditionalOnProperty(value = "yudao.xss.enable", havingValue = "true")
public JsonMapperBuilderCustomizer xssJacksonCustomizer(XssProperties properties,
PathMatcher pathMatcher,
XssCleaner xssCleaner) {
public Jackson2ObjectMapperBuilderCustomizer xssJacksonCustomizer(XssProperties properties,
PathMatcher pathMatcher,
XssCleaner xssCleaner) {
// 在反序列化时进行 xss 过滤,可以替换使用 XssStringJsonSerializer在序列化时进行处理
return builder -> builder.addModule(new tools.jackson.databind.module.SimpleModule("XssStringModule")
.addDeserializer(String.class, new XssStringJsonDeserializer(properties, pathMatcher, xssCleaner)));
return builder ->
builder.deserializerByType(String.class, new XssStringJsonDeserializer(properties, pathMatcher, xssCleaner));
}
/**

View File

@ -3,15 +3,16 @@ package cn.iocoder.yudao.framework.xss.core.json;
import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
import cn.iocoder.yudao.framework.xss.config.XssProperties;
import cn.iocoder.yudao.framework.xss.core.clean.XssCleaner;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StringDeserializer;
import jakarta.servlet.http.HttpServletRequest;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.PathMatcher;
import tools.jackson.core.JacksonException;
import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonToken;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.deser.jdk.StringDeserializer;
import java.io.IOException;
/**
* XSS 过滤 jackson 反序列化器。
@ -35,19 +36,19 @@ public class XssStringJsonDeserializer extends StringDeserializer {
private final XssCleaner xssCleaner;
@Override
public String deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException {
public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
// 1. 白名单 URL 的处理
HttpServletRequest request = ServletUtils.getRequest();
if (request != null) {
String uri = ServletUtils.getRequest().getRequestURI();
if (properties.getExcludeUrls().stream().anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, uri))) {
return p.getString();
return p.getText();
}
}
// 2. 真正使用 xssCleaner 进行过滤
if (p.hasToken(JsonToken.VALUE_STRING)) {
return xssCleaner.clean(p.getString());
return xssCleaner.clean(p.getText());
}
JsonToken t = p.currentToken();
// [databind#381]

View File

@ -76,7 +76,7 @@ public class JsonWebSocketMessageHandler extends TextWebSocketHandler {
Long tenantId = WebSocketFrameworkUtils.getTenantId(session);
TenantUtils.execute(tenantId, () -> messageListener.onMessage(session, messageObj));
} catch (Throwable ex) {
log.error("[handleTextMessage][session({}) message({}) 处理异常]", session.getId(), message.getPayload());
log.error("[handleTextMessage][session({}) message({}) 处理异常]", session.getId(), message.getPayload(), ex);
}
}

View File

@ -27,9 +27,10 @@ public class LoginUserHandshakeInterceptor implements HandshakeInterceptor {
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser != null) {
WebSocketFrameworkUtils.setLoginUser(loginUser, attributes);
if (loginUser == null) {
return false;
}
WebSocketFrameworkUtils.setLoginUser(loginUser, attributes);
return true;
}

View File

@ -19,10 +19,9 @@
国外OpenAI、Ollama、Midjourney、StableDiffusion、Suno
</description>
<properties>
<spring-ai.version>2.0.0-M7</spring-ai.version>
<spring-ai-legacy-model.version>2.0.0-M4</spring-ai-legacy-model.version>
<spring-ai.version>1.1.5</spring-ai.version>
<!-- https://mvnrepository.com/artifact/com.alibaba.cloud.ai/spring-ai-alibaba -->
<alibaba-ai.version>1.1.2.3</alibaba-ai.version>
<alibaba-ai.version>1.1.2.2</alibaba-ai.version>
<tinyflow.version>1.2.6</tinyflow.version>
</properties>
@ -90,7 +89,7 @@
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-azure-openai</artifactId>
<version>${spring-ai-legacy-model.version}</version>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
@ -116,7 +115,7 @@
<!-- 智谱 GLM -->
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-zhipuai</artifactId>
<version>${spring-ai-legacy-model.version}</version>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
@ -134,13 +133,13 @@
<dependency>
<!-- 文心一言 -->
<groupId>org.springaicommunity</groupId>
<artifactId>qianfan-core</artifactId>
<artifactId>qianfan-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<!-- 月之暗面 -->
<groupId>org.springaicommunity</groupId>
<artifactId>moonshot-core</artifactId>
<artifactId>moonshot-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
@ -212,22 +211,12 @@
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-autoconfigure-mcp-server-common</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<!-- 客户端 -->
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-client</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-autoconfigure-mcp-client-common</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- TinyFlowAI 工作流 -->
<dependency>
@ -271,4 +260,4 @@
</dependency>
</dependencies>
</project>
</project>

View File

@ -12,7 +12,7 @@ import cn.iocoder.yudao.module.ai.controller.admin.chat.vo.conversation.AiChatCo
import cn.iocoder.yudao.module.ai.dal.dataobject.chat.AiChatConversationDO;
import cn.iocoder.yudao.module.ai.service.chat.AiChatConversationService;
import cn.iocoder.yudao.module.ai.service.chat.AiChatMessageService;
import org.dromara.core.trans.anno.TransMethodResult;
import com.fhs.core.trans.anno.TransMethodResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;

View File

@ -2,9 +2,9 @@ package cn.iocoder.yudao.module.ai.controller.admin.chat.vo.conversation;
import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiModelDO;
import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiChatRoleDO;
import org.dromara.core.trans.anno.Trans;
import org.dromara.core.trans.constant.TransType;
import org.dromara.core.trans.vo.VO;
import com.fhs.core.trans.anno.Trans;
import com.fhs.core.trans.constant.TransType;
import com.fhs.core.trans.vo.VO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

View File

@ -10,7 +10,7 @@ import cn.iocoder.yudao.module.ai.controller.admin.model.vo.chatRole.AiChatRoleS
import cn.iocoder.yudao.module.ai.controller.admin.model.vo.chatRole.AiChatRoleSaveReqVO;
import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiChatRoleDO;
import cn.iocoder.yudao.module.ai.service.model.AiChatRoleService;
import org.dromara.core.trans.anno.TransMethodResult;
import com.fhs.core.trans.anno.TransMethodResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;

View File

@ -1,9 +1,9 @@
package cn.iocoder.yudao.module.ai.controller.admin.model.vo.chatRole;
import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiModelDO;
import org.dromara.core.trans.anno.Trans;
import org.dromara.core.trans.constant.TransType;
import org.dromara.core.trans.vo.VO;
import com.fhs.core.trans.anno.Trans;
import com.fhs.core.trans.constant.TransType;
import com.fhs.core.trans.vo.VO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@ -64,4 +64,4 @@ public class AiChatRoleRespVO implements VO {
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;
}
}

View File

@ -11,7 +11,7 @@ import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.Jackson3TypeHandler;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@ -114,7 +114,7 @@ public class AiChatMessageDO extends BaseDO {
/**
* 联网搜索的网页内容数组
*/
@TableField(typeHandler = Jackson3TypeHandler.class)
@TableField(typeHandler = JacksonTypeHandler.class)
private List<AiWebSearchResponse.WebPage> webSearchPages;
/**

View File

@ -10,7 +10,7 @@ import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.Jackson3TypeHandler;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import org.springframework.ai.openai.OpenAiImageOptions;
import org.springframework.ai.stabilityai.api.StabilityAiImageOptions;
@ -107,13 +107,13 @@ public class AiImageDO extends BaseDO {
* 1. {@link OpenAiImageOptions}
* 2. {@link StabilityAiImageOptions}
*/
@TableField(typeHandler = Jackson3TypeHandler.class)
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, Object> options;
/**
* mj buttons 按钮
*/
@TableField(typeHandler = Jackson3TypeHandler.class)
@TableField(typeHandler = JacksonTypeHandler.class)
private List<MidjourneyApi.Button> buttons;
/**

View File

@ -8,7 +8,7 @@ import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.Jackson3TypeHandler;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import java.util.List;
@ -93,7 +93,7 @@ public class AiMusicDO extends BaseDO {
/**
* 音乐风格标签
*/
@TableField(typeHandler = Jackson3TypeHandler.class)
@TableField(typeHandler = JacksonTypeHandler.class)
private List<String> tags;
/**

View File

@ -14,7 +14,6 @@ import cn.iocoder.yudao.module.ai.framework.ai.core.model.siliconflow.SiliconFlo
import cn.iocoder.yudao.module.ai.framework.ai.core.model.siliconflow.SiliconFlowChatModel;
import cn.iocoder.yudao.module.ai.framework.ai.core.model.suno.api.SunoApi;
import cn.iocoder.yudao.module.ai.framework.ai.core.model.xinghuo.XingHuoChatModel;
import com.openai.client.okhttp.OpenAIOkHttpClient;
import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchClient;
import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.bocha.AiBoChaWebSearchClient;
import cn.iocoder.yudao.module.ai.tool.method.PersonService;
@ -29,6 +28,7 @@ import org.springframework.ai.embedding.TokenCountBatchingStrategy;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tokenizer.JTokkitTokenCountEstimator;
import org.springframework.ai.tokenizer.TokenCountEstimator;
@ -86,11 +86,12 @@ public class AiAutoConfiguration {
properties.setModel(GeminiChatModel.MODEL_DEFAULT);
}
OpenAiChatModel openAiChatModel = OpenAiChatModel.builder()
.openAiClient(OpenAIOkHttpClient.builder()
.openAiApi(OpenAiApi.builder()
.baseUrl(GeminiChatModel.BASE_URL)
.completionsPath(GeminiChatModel.COMPLETE_PATH)
.apiKey(properties.getApiKey())
.build())
.options(OpenAiChatOptions.builder()
.defaultOptions(OpenAiChatOptions.builder()
.model(properties.getModel())
.temperature(properties.getTemperature())
.maxTokens(properties.getMaxTokens())
@ -113,11 +114,12 @@ public class AiAutoConfiguration {
properties.setModel(DouBaoChatModel.MODEL_DEFAULT);
}
OpenAiChatModel openAiChatModel = OpenAiChatModel.builder()
.openAiClient(OpenAIOkHttpClient.builder()
.openAiApi(OpenAiApi.builder()
.baseUrl(DouBaoChatModel.BASE_URL)
.completionsPath(DouBaoChatModel.COMPLETE_PATH)
.apiKey(properties.getApiKey())
.build())
.options(OpenAiChatOptions.builder()
.defaultOptions(OpenAiChatOptions.builder()
.model(properties.getModel())
.temperature(properties.getTemperature())
.maxTokens(properties.getMaxTokens())
@ -201,15 +203,16 @@ public class AiAutoConfiguration {
if (StrUtil.isEmpty(properties.getModel())) {
properties.setModel(XingHuoChatModel.MODEL_DEFAULT);
}
OpenAIOkHttpClient.Builder builder = OpenAIOkHttpClient.builder()
OpenAiApi.Builder builder = OpenAiApi.builder()
.baseUrl(XingHuoChatModel.BASE_URL_V1)
.apiKey(properties.getAppKey() + ":" + properties.getSecretKey());
if ("x1".equals(properties.getModel())) {
builder.baseUrl(XingHuoChatModel.BASE_URL_V2);
builder.baseUrl(XingHuoChatModel.BASE_URL_V2)
.completionsPath(XingHuoChatModel.BASE_COMPLETIONS_PATH_V2);
}
OpenAiChatModel openAiChatModel = OpenAiChatModel.builder()
.openAiClient(builder.build())
.options(OpenAiChatOptions.builder()
.openAiApi(builder.build())
.defaultOptions(OpenAiChatOptions.builder()
.model(properties.getModel())
.temperature(properties.getTemperature())
.maxTokens(properties.getMaxTokens())
@ -233,11 +236,11 @@ public class AiAutoConfiguration {
properties.setModel(BaiChuanChatModel.MODEL_DEFAULT);
}
OpenAiChatModel openAiChatModel = OpenAiChatModel.builder()
.openAiClient(OpenAIOkHttpClient.builder()
.openAiApi(OpenAiApi.builder()
.baseUrl(BaiChuanChatModel.BASE_URL)
.apiKey(properties.getApiKey())
.build())
.options(OpenAiChatOptions.builder()
.defaultOptions(OpenAiChatOptions.builder()
.model(properties.getModel())
.temperature(properties.getTemperature())
.maxTokens(properties.getMaxTokens())
@ -266,12 +269,13 @@ public class AiAutoConfiguration {
properties.setModel(GrokChatModel.MODEL_DEFAULT);
}
OpenAiChatModel openAiChatModel = OpenAiChatModel.builder()
.openAiClient(OpenAIOkHttpClient.builder()
.openAiApi(OpenAiApi.builder()
.baseUrl(Optional.ofNullable(properties.getBaseUrl())
.orElse(GrokChatModel.BASE_URL))
.completionsPath(GrokChatModel.COMPLETE_PATH)
.apiKey(properties.getApiKey())
.build())
.options(OpenAiChatOptions.builder()
.defaultOptions(OpenAiChatOptions.builder()
.model(properties.getModel())
.temperature(properties.getTemperature())
.maxTokens(properties.getMaxTokens())
@ -316,4 +320,4 @@ public class AiAutoConfiguration {
return List.of(ToolCallbacks.from(personService));
}
}
}

View File

@ -30,14 +30,11 @@ import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
import com.alibaba.cloud.ai.dashscope.api.DashScopeImageApi;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import com.alibaba.cloud.ai.dashscope.embedding.text.DashScopeEmbeddingModel;
import com.alibaba.cloud.ai.dashscope.embedding.text.DashScopeEmbeddingOptions;
import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingModel;
import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingOptions;
import com.alibaba.cloud.ai.dashscope.image.DashScopeImageModel;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.azure.ai.openai.OpenAIClientBuilder;
import com.azure.core.credential.KeyCredential;
import com.openai.client.OpenAIClient;
import com.openai.client.okhttp.OpenAIOkHttpClient;
import io.micrometer.observation.ObservationRegistry;
import io.milvus.client.MilvusServiceClient;
import io.qdrant.client.QdrantClient;
@ -46,12 +43,15 @@ import lombok.SneakyThrows;
import org.springaicommunity.moonshot.MoonshotChatModel;
import org.springaicommunity.moonshot.MoonshotChatOptions;
import org.springaicommunity.moonshot.api.MoonshotApi;
import org.springaicommunity.moonshot.autoconfigure.MoonshotChatAutoConfiguration;
import org.springaicommunity.qianfan.QianFanChatModel;
import org.springaicommunity.qianfan.QianFanEmbeddingModel;
import org.springaicommunity.qianfan.QianFanEmbeddingOptions;
import org.springaicommunity.qianfan.QianFanImageModel;
import org.springaicommunity.qianfan.api.QianFanApi;
import org.springaicommunity.qianfan.api.QianFanImageApi;
import org.springaicommunity.qianfan.autoconfigure.QianFanChatAutoConfiguration;
import org.springaicommunity.qianfan.autoconfigure.QianFanEmbeddingAutoConfiguration;
import org.springframework.ai.azure.openai.AzureOpenAiChatModel;
import org.springframework.ai.azure.openai.AzureOpenAiEmbeddingModel;
import org.springframework.ai.chat.model.ChatModel;
@ -92,7 +92,11 @@ import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiEmbeddingModel;
import org.springframework.ai.openai.OpenAiEmbeddingOptions;
import org.springframework.ai.openai.OpenAiImageModel;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.ai.openai.api.OpenAiImageApi;
import org.springframework.ai.openai.api.common.OpenAiApiConstants;
import org.springframework.ai.anthropic.AnthropicChatModel;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.ai.stabilityai.StabilityAiImageModel;
import org.springframework.ai.stabilityai.api.StabilityAiApi;
import org.springframework.ai.vectorstore.SimpleVectorStore;
@ -115,7 +119,7 @@ import org.springframework.ai.zhipuai.api.ZhiPuAiApi;
import org.springframework.ai.zhipuai.api.ZhiPuAiImageApi;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.web.client.RestClient;
import redis.clients.jedis.JedisPooled;
@ -127,6 +131,7 @@ import java.util.Timer;
import java.util.TimerTask;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static org.springframework.ai.retry.RetryUtils.DEFAULT_RETRY_TEMPLATE;
/**
* AI Model 模型工厂的实现类
@ -343,13 +348,9 @@ public class AiModelFactoryImpl implements AiModelFactory {
*/
private static DashScopeChatModel buildTongYiChatModel(String key) {
DashScopeApi dashScopeApi = DashScopeApi.builder().apiKey(key).build();
DashScopeChatOptions options = DashScopeChatOptions
.builder()
.model(DashScopeApi.DEFAULT_CHAT_MODEL)
.temperature(0.7)
.build();
return DashScopeChatModel
.builder()
DashScopeChatOptions options = DashScopeChatOptions.builder().withModel(DashScopeApi.DEFAULT_CHAT_MODEL)
.withTemperature(0.7).build();
return DashScopeChatModel.builder()
.dashScopeApi(dashScopeApi)
.defaultOptions(options)
.toolCallingManager(getToolCallingManager())
@ -367,7 +368,7 @@ public class AiModelFactoryImpl implements AiModelFactory {
}
/**
* 可参考 QianFanChatAutoConfiguration 的 qianFanChatModel 方法
* 可参考 {@link QianFanChatAutoConfiguration} 的 qianFanChatModel 方法
*/
private static QianFanChatModel buildYiYanChatModel(String key) {
// TODO spring ai qianfan 有 bug无法使用 https://github.com/spring-ai-community/qianfan/issues/6
@ -380,7 +381,7 @@ public class AiModelFactoryImpl implements AiModelFactory {
}
/**
* 可参考 QianFanEmbeddingAutoConfiguration 的 qianFanImageModel 方法
* 可参考 {@link QianFanEmbeddingAutoConfiguration} 的 qianFanImageModel 方法
*/
private QianFanImageModel buildQianFanImageModel(String key) {
// TODO spring ai qianfan 有 bug无法使用 https://github.com/spring-ai-community/qianfan/issues/6
@ -442,7 +443,7 @@ public class AiModelFactoryImpl implements AiModelFactory {
zhiPuAiApiBuilder.baseUrl(url);
}
ZhiPuAiChatOptions options = ZhiPuAiChatOptions.builder().model(ZhiPuAiApi.DEFAULT_CHAT_MODEL).temperature(0.7).build();
return new ZhiPuAiChatModel(zhiPuAiApiBuilder.build(), options, getToolCallingManager(), new org.springframework.core.retry.RetryTemplate(),
return new ZhiPuAiChatModel(zhiPuAiApiBuilder.build(), options, getToolCallingManager(), DEFAULT_RETRY_TEMPLATE,
getObservationRegistry().getIfAvailable());
}
@ -462,11 +463,11 @@ public class AiModelFactoryImpl implements AiModelFactory {
MiniMaxApi miniMaxApi = StrUtil.isEmpty(url) ? new MiniMaxApi(apiKey)
: new MiniMaxApi(url, apiKey);
MiniMaxChatOptions options = MiniMaxChatOptions.builder().model(MiniMaxApi.DEFAULT_CHAT_MODEL).temperature(0.7).build();
return new MiniMaxChatModel(miniMaxApi, options, getToolCallingManager(), new org.springframework.core.retry.RetryTemplate());
return new MiniMaxChatModel(miniMaxApi, options, getToolCallingManager(), DEFAULT_RETRY_TEMPLATE);
}
/**
* 可参考 MoonshotChatAutoConfiguration 的 moonshotChatModel 方法
* 可参考 {@link MoonshotChatAutoConfiguration} 的 moonshotChatModel 方法
*/
private MoonshotChatModel buildMoonshotChatModel(String apiKey, String url) {
MoonshotApi.Builder moonshotApiBuilder = MoonshotApi.builder()
@ -506,8 +507,10 @@ public class AiModelFactoryImpl implements AiModelFactory {
* 可参考 {@link OpenAiChatAutoConfiguration} 的 openAiChatModel 方法
*/
private static OpenAiChatModel buildOpenAiChatModel(String openAiToken, String url) {
url = StrUtil.blankToDefault(url, OpenAiApiConstants.DEFAULT_BASE_URL);
OpenAiApi openAiApi = OpenAiApi.builder().baseUrl(url).apiKey(openAiToken).build();
return OpenAiChatModel.builder()
.openAiClient(buildOpenAiClient(openAiToken, url))
.openAiApi(openAiApi)
.toolCallingManager(getToolCallingManager())
.build();
}
@ -529,12 +532,13 @@ public class AiModelFactoryImpl implements AiModelFactory {
* 可参考 {@link AnthropicChatAutoConfiguration} 的 anthropicApi 方法
*/
private static AnthropicChatModel buildAnthropicChatModel(String apiKey, String url) {
AnthropicOkHttpClient.Builder builder = AnthropicOkHttpClient.builder().apiKey(apiKey);
AnthropicApi.Builder builder = AnthropicApi.builder().apiKey(apiKey);
if (StrUtil.isNotEmpty(url)) {
builder.baseUrl(url);
}
AnthropicApi anthropicApi = builder.build();
return AnthropicChatModel.builder()
.anthropicClient(builder.build())
.anthropicApi(anthropicApi)
.toolCallingManager(getToolCallingManager())
.build();
}
@ -552,7 +556,9 @@ public class AiModelFactoryImpl implements AiModelFactory {
* 可参考 {@link OpenAiImageAutoConfiguration} 的 openAiImageModel 方法
*/
private OpenAiImageModel buildOpenAiImageModel(String openAiToken, String url) {
return new OpenAiImageModel(buildOpenAiClient(openAiToken, url));
url = StrUtil.blankToDefault(url, OpenAiApiConstants.DEFAULT_BASE_URL);
OpenAiImageApi openAiApi = OpenAiImageApi.builder().baseUrl(url).apiKey(openAiToken).build();
return new OpenAiImageModel(openAiApi);
}
/**
@ -594,16 +600,16 @@ public class AiModelFactoryImpl implements AiModelFactory {
// ========== 各种创建 EmbeddingModel 的方法 ==========
/**
* 可参考 {@link DashScopeEmbeddingAutoConfiguration} 的 DashScopeEmbeddingModel 方法
* 可参考 {@link DashScopeEmbeddingAutoConfiguration} 的 dashscopeEmbeddingModel 方法
*/
private DashScopeEmbeddingModel buildTongYiEmbeddingModel(String apiKey, String model) {
DashScopeApi dashScopeApi = DashScopeApi.builder().apiKey(apiKey).build();
DashScopeEmbeddingOptions dashScopeEmbeddingOptions = DashScopeEmbeddingOptions.builder().model(model).build();
DashScopeEmbeddingOptions dashScopeEmbeddingOptions = DashScopeEmbeddingOptions.builder().withModel(model).build();
return new DashScopeEmbeddingModel(dashScopeApi, MetadataMode.EMBED, dashScopeEmbeddingOptions);
}
/**
* 可参考 {@link ZhiPuAiEmbeddingAutoConfiguration} 的 ZhiPuAiEmbeddingModel 方法
* 可参考 {@link ZhiPuAiEmbeddingAutoConfiguration} 的 zhiPuAiEmbeddingModel 方法
*/
private ZhiPuAiEmbeddingModel buildZhiPuEmbeddingModel(String apiKey, String url, String model) {
ZhiPuAiApi.Builder zhiPuAiApiBuilder = ZhiPuAiApi.builder().apiKey(apiKey);
@ -625,7 +631,7 @@ public class AiModelFactoryImpl implements AiModelFactory {
}
/**
* 可参考 {@link QianFanEmbeddingModel} 的 qianFanEmbeddingModel 方法
* 可参考 {@link QianFanEmbeddingAutoConfiguration} 的 qianFanEmbeddingModel 方法
*/
private QianFanEmbeddingModel buildYiYanEmbeddingModel(String key, String model) {
List<String> keys = StrUtil.split(key, '|');
@ -650,16 +656,10 @@ public class AiModelFactoryImpl implements AiModelFactory {
* 可参考 {@link OpenAiEmbeddingAutoConfiguration} 的 openAiEmbeddingModel 方法
*/
private OpenAiEmbeddingModel buildOpenAiEmbeddingModel(String openAiToken, String url, String model) {
url = StrUtil.blankToDefault(url, OpenAiApiConstants.DEFAULT_BASE_URL);
OpenAiApi openAiApi = OpenAiApi.builder().baseUrl(url).apiKey(openAiToken).build();
OpenAiEmbeddingOptions openAiEmbeddingProperties = OpenAiEmbeddingOptions.builder().model(model).build();
return new OpenAiEmbeddingModel(buildOpenAiClient(openAiToken, url), MetadataMode.EMBED, openAiEmbeddingProperties);
}
private static OpenAIClient buildOpenAiClient(String apiKey, String url) {
OpenAIOkHttpClient.Builder builder = OpenAIOkHttpClient.builder().apiKey(apiKey);
if (StrUtil.isNotEmpty(url)) {
builder.baseUrl(url);
}
return builder.build();
return new OpenAiEmbeddingModel(openAiApi, MetadataMode.EMBED, openAiEmbeddingProperties);
}
/**
@ -738,7 +738,7 @@ public class AiModelFactoryImpl implements AiModelFactory {
private RedisVectorStore buildRedisVectorStore(EmbeddingModel embeddingModel,
Map<String, Class<?>> metadataFields) {
// 创建 JedisPooled 对象
DataRedisProperties redisProperties = SpringUtils.getBean(DataRedisProperties.class);
RedisProperties redisProperties = SpringUtils.getBean(RedisProperties.class);
JedisPooled jedisPooled = new JedisPooled(redisProperties.getHost(), redisProperties.getPort(),
redisProperties.getUsername(), redisProperties.getPassword());
// 创建 RedisVectorStoreProperties 对象

View File

@ -21,14 +21,18 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import org.springframework.ai.model.ApiKey;
import org.springframework.ai.model.NoopApiKey;
import org.springframework.ai.model.SimpleApiKey;
import org.springframework.ai.openai.api.OpenAiImageApi;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClient;
import java.util.Map;
/**
* 硅基流动 Image API
*
@ -54,15 +58,15 @@ public class SiliconFlowImageApi {
public SiliconFlowImageApi(String baseUrl, String apiKey, RestClient.Builder restClientBuilder,
ResponseErrorHandler responseErrorHandler) {
this(baseUrl, apiKey, new HttpHeaders(), restClientBuilder, responseErrorHandler);
this(baseUrl, apiKey, CollectionUtils.toMultiValueMap(Map.of()), restClientBuilder, responseErrorHandler);
}
public SiliconFlowImageApi(String baseUrl, String apiKey, HttpHeaders headers,
public SiliconFlowImageApi(String baseUrl, String apiKey, MultiValueMap<String, String> headers,
RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) {
this(baseUrl, new SimpleApiKey(apiKey), headers, restClientBuilder, responseErrorHandler);
}
public SiliconFlowImageApi(String baseUrl, ApiKey apiKey, HttpHeaders headers,
public SiliconFlowImageApi(String baseUrl, ApiKey apiKey, MultiValueMap<String, String> headers,
RestClient.Builder restClientBuilder, ResponseErrorHandler responseErrorHandler) {
// @formatter:off
@ -79,7 +83,7 @@ public class SiliconFlowImageApi {
// @formatter:on
}
public ResponseEntity<SiliconFlowImageResponse> createImage(SiliconflowImageRequest siliconflowImageRequest) {
public ResponseEntity<OpenAiImageApi.OpenAiImageResponse> createImage(SiliconflowImageRequest siliconflowImageRequest) {
Assert.notNull(siliconflowImageRequest, "Image request cannot be null.");
Assert.hasLength(siliconflowImageRequest.prompt(), "Prompt cannot be empty.");
@ -87,7 +91,7 @@ public class SiliconFlowImageApi {
.uri("v1/images/generations")
.body(siliconflowImageRequest)
.retrieve()
.toEntity(SiliconFlowImageResponse.class);
.toEntity(OpenAiImageApi.OpenAiImageResponse.class);
}
@ -108,15 +112,4 @@ public class SiliconFlowImageApi {
}
}
public record SiliconFlowImageResponse(
@JsonProperty("created") Long created,
@JsonProperty("data") java.util.List<Entry> data) {
public record Entry(
@JsonProperty("url") String url,
@JsonProperty("b64_json") String b64Json,
@JsonProperty("revised_prompt") String revisedPrompt) {
}
}
}

View File

@ -27,10 +27,11 @@ import org.springframework.ai.image.observation.ImageModelObservationConvention;
import org.springframework.ai.image.observation.ImageModelObservationDocumentation;
import org.springframework.ai.model.ModelOptionsUtils;
import org.springframework.ai.openai.OpenAiImageModel;
import org.springframework.ai.openai.api.OpenAiImageApi;
import org.springframework.ai.openai.metadata.OpenAiImageGenerationMetadata;
import org.springframework.ai.retry.RetryUtils;
import org.springframework.http.ResponseEntity;
import org.jspecify.annotations.Nullable;
import org.springframework.lang.Nullable;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.util.Assert;
@ -70,7 +71,7 @@ public class SiliconFlowImageModel implements ImageModel {
public SiliconFlowImageModel(SiliconFlowImageApi siliconFlowImageApi, SiliconFlowImageOptions options, RetryTemplate retryTemplate,
ObservationRegistry observationRegistry) {
Assert.notNull(siliconFlowImageApi, "SiliconFlowImageApi must not be null");
Assert.notNull(siliconFlowImageApi, "OpenAiImageApi must not be null");
Assert.notNull(options, "options must not be null");
Assert.notNull(retryTemplate, "retryTemplate must not be null");
Assert.notNull(observationRegistry, "observationRegistry must not be null");
@ -95,7 +96,7 @@ public class SiliconFlowImageModel implements ImageModel {
.observation(this.observationConvention, DEFAULT_OBSERVATION_CONVENTION, () -> observationContext,
this.observationRegistry)
.observe(() -> {
ResponseEntity<SiliconFlowImageApi.SiliconFlowImageResponse> imageResponseEntity = this.retryTemplate
ResponseEntity<OpenAiImageApi.OpenAiImageResponse> imageResponseEntity = this.retryTemplate
.execute(ctx -> this.siliconFlowImageApi.createImage(imageRequest));
ImageResponse imageResponse = convertResponse(imageResponseEntity, imageRequest);
@ -108,22 +109,17 @@ public class SiliconFlowImageModel implements ImageModel {
private SiliconFlowImageApi.SiliconflowImageRequest createRequest(ImagePrompt imagePrompt,
SiliconFlowImageOptions requestImageOptions) {
String instructions = imagePrompt.getInstructions().getFirst().getText();
String instructions = imagePrompt.getInstructions().get(0).getText();
return new SiliconFlowImageApi.SiliconflowImageRequest(
instructions,
ModelOptionsUtils.mergeOption(requestImageOptions.getModel(), SiliconFlowApiConstants.DEFAULT_IMAGE_MODEL),
requestImageOptions.getN(),
requestImageOptions.getNegativePrompt(),
requestImageOptions.getSeed(),
requestImageOptions.getNumInferenceSteps(),
requestImageOptions.getGuidanceScale(),
requestImageOptions.getImage());
SiliconFlowImageApi.SiliconflowImageRequest imageRequest = new SiliconFlowImageApi.SiliconflowImageRequest(instructions,
SiliconFlowApiConstants.DEFAULT_IMAGE_MODEL);
return ModelOptionsUtils.merge(requestImageOptions, imageRequest, SiliconFlowImageApi.SiliconflowImageRequest.class);
}
private ImageResponse convertResponse(ResponseEntity<SiliconFlowImageApi.SiliconFlowImageResponse> imageResponseEntity,
private ImageResponse convertResponse(ResponseEntity<OpenAiImageApi.OpenAiImageResponse> imageResponseEntity,
SiliconFlowImageApi.SiliconflowImageRequest siliconflowImageRequest) {
SiliconFlowImageApi.SiliconFlowImageResponse imageApiResponse = imageResponseEntity.getBody();
OpenAiImageApi.OpenAiImageResponse imageApiResponse = imageResponseEntity.getBody();
if (imageApiResponse == null) {
logger.warn("No image response returned for request: {}", siliconflowImageRequest);
return new ImageResponse(List.of());
@ -140,17 +136,12 @@ public class SiliconFlowImageModel implements ImageModel {
}
private SiliconFlowImageOptions mergeOptions(@Nullable ImageOptions runtimeOptions, SiliconFlowImageOptions defaultOptions) {
if (runtimeOptions == null) {
var runtimeOptionsForProvider = ModelOptionsUtils.copyToTarget(runtimeOptions, ImageOptions.class,
SiliconFlowImageOptions.class);
if (runtimeOptionsForProvider == null) {
return defaultOptions;
}
SiliconFlowImageOptions runtimeOptionsForProvider = runtimeOptions instanceof SiliconFlowImageOptions siliconFlowImageOptions
? siliconFlowImageOptions
: SiliconFlowImageOptions.builder()
.model(runtimeOptions.getModel())
.batchSize(runtimeOptions.getN())
.width(runtimeOptions.getWidth())
.height(runtimeOptions.getHeight())
.build();
return SiliconFlowImageOptions.builder()
// Handle portable image options

View File

@ -1,25 +1,26 @@
package cn.iocoder.yudao.module.ai.framework.security.config;
import cn.iocoder.yudao.framework.security.config.AuthorizeRequestsCustomizer;
import cn.hutool.core.util.StrUtil;
import org.springframework.beans.factory.annotation.Value;
import jakarta.annotation.Resource;
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerSseProperties;
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerStreamableHttpProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import java.util.Optional;
/**
* AI 模块的 Security 配置
*/
@Configuration(proxyBeanMethods = false, value = "aiSecurityConfiguration")
public class SecurityConfiguration {
@Value("${spring.ai.mcp.server.sse-endpoint:/sse}")
private String mcpSseEndpoint;
@Value("${spring.ai.mcp.server.sse-message-endpoint:/mcp/message}")
private String mcpSseMessageEndpoint;
@Value("${spring.ai.mcp.server.streamable-http-endpoint:/mcp}")
private String mcpStreamableHttpEndpoint;
@Resource
private Optional<McpServerSseProperties> mcpServerSseProperties;
@Resource
private Optional<McpServerStreamableHttpProperties> mcpServerStreamableHttpProperties;
@Bean("aiAuthorizeRequestsCustomizer")
public AuthorizeRequestsCustomizer authorizeRequestsCustomizer() {
@ -27,15 +28,12 @@ public class SecurityConfiguration {
@Override
public void customize(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {
if (StrUtil.isNotBlank(mcpSseEndpoint)) {
registry.requestMatchers(mcpSseEndpoint).permitAll();
}
if (StrUtil.isNotBlank(mcpSseMessageEndpoint)) {
registry.requestMatchers(mcpSseMessageEndpoint).permitAll();
}
if (StrUtil.isNotBlank(mcpStreamableHttpEndpoint)) {
registry.requestMatchers(mcpStreamableHttpEndpoint).permitAll();
}
mcpServerSseProperties.ifPresent(properties -> {
registry.requestMatchers(properties.getSseEndpoint()).permitAll();
registry.requestMatchers(properties.getSseMessageEndpoint()).permitAll();
});
mcpServerStreamableHttpProperties.ifPresent(properties ->
registry.requestMatchers(properties.getMcpEndpoint()).permitAll());
}
};

View File

@ -49,10 +49,10 @@ import org.springframework.ai.chat.model.StreamingChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
import org.springframework.ai.mcp.client.common.autoconfigure.properties.McpClientCommonProperties;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.resolution.ToolCallbackResolver;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import reactor.core.publisher.Flux;
@ -130,8 +130,9 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
@Autowired(required = false) // 由于 yudao.ai.mcp.client.enable 配置项,可以关闭 McpSyncClient 的功能,所以这里只能不强制注入
private List<McpSyncClient> mcpClients;
@Value("${spring.ai.mcp.client.name:mcp}")
private String mcpClientName;
@SuppressWarnings("SpringJavaAutowiredFieldsWarningInspection")
@Autowired(required = false) // 由于 yudao.ai.mcp.client.enable 配置项,可以关闭 McpSyncClient 的功能,所以这里只能不强制注入
private McpClientCommonProperties mcpClientCommonProperties;
@Resource
private ToolCallbackResolver toolCallbackResolver;
@ -409,16 +410,13 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
if (CollUtil.isNotEmpty(mcpClients) && CollUtil.isNotEmpty(chatRole.getMcpClientNames())) {
chatRole.getMcpClientNames().forEach(mcpClientName -> {
// 2.1 标准化名字,参考 McpClientAutoConfiguration 的 connectedClientName 方法
String finalMcpClientName = this.mcpClientName + " - " + mcpClientName;
String finalMcpClientName = mcpClientCommonProperties.getName() + " - " + mcpClientName;
// 2.2 匹配对应的 McpSyncClient
mcpClients.forEach(mcpClient -> {
if (ObjUtil.notEqual(mcpClient.getClientInfo().name(), finalMcpClientName)) {
return;
}
ToolCallback[] mcpToolCallBacks = SyncMcpToolCallbackProvider.builder()
.mcpClients(mcpClient)
.build()
.getToolCallbacks();
ToolCallback[] mcpToolCallBacks = new SyncMcpToolCallbackProvider(mcpClient).getToolCallbacks();
CollUtil.addAll(toolCallbacks, mcpToolCallBacks);
});
});
@ -541,7 +539,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
public void deleteChatMessageByConversationId(Long conversationId, Long userId) {
// 1. 校验消息存在
List<AiChatMessageDO> messages = chatMessageMapper.selectListByConversationId(conversationId);
if (CollUtil.isEmpty(messages) || ObjUtil.notEqual(messages.getFirst().getUserId(), userId)) {
if (CollUtil.isEmpty(messages) || ObjUtil.notEqual(messages.get(0).getUserId(), userId)) {
throw exception(CHAT_MESSAGE_NOT_EXIST);
}
// 2. 执行删除

View File

@ -166,7 +166,7 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
segmentMapper.deleteByIds(convertList(segments, AiKnowledgeSegmentDO::getId));
// 3. 删除向量存储中的段落
VectorStore vectorStore = getVectorStoreById(segments.getFirst().getKnowledgeId());
VectorStore vectorStore = getVectorStoreById(segments.get(0).getKnowledgeId());
vectorStore.delete(convertList(segments, AiKnowledgeSegmentDO::getVectorId));
}
@ -299,7 +299,7 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
// 2. Rerank 重排序
if (rerankModel != null) {
RerankResponse rerankResponse = rerankModel.call(new RerankRequest(reqBO.getContent(), documents,
DashScopeRerankOptions.builder().topN(topK).build()));
DashScopeRerankOptions.builder().withTopN(topK).build()));
documents = convertList(rerankResponse.getResults(),
documentWithScore -> documentWithScore.getScore() >= similarityThreshold
? documentWithScore.getOutput() : null);

View File

@ -1,10 +1,10 @@
package cn.iocoder.yudao.module.ai.framework.ai.core.model.chat;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.ai.anthropic.AnthropicChatModel;
import org.springframework.ai.anthropic.AnthropicChatOptions;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
@ -23,12 +23,12 @@ import java.util.List;
public class AnthropicChatModelTest {
private final AnthropicChatModel chatModel = AnthropicChatModel.builder()
.anthropicClient(AnthropicOkHttpClient.builder()
.anthropicApi(AnthropicApi.builder()
.apiKey("sk-muubv7cXeLw0Etgs743f365cD5Ea44429946Fa7e672d8942")
.baseUrl("https://aihubmix.com")
.build())
.options(AnthropicChatOptions.builder()
.model("claude-sonnet-4-5")
.defaultOptions(AnthropicChatOptions.builder()
.model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5)
.temperature(0.7)
.maxTokens(4096)
.build())
@ -70,7 +70,8 @@ public class AnthropicChatModelTest {
List<Message> messages = new ArrayList<>();
messages.add(new UserMessage("thkinking 下1+1 为什么等于 2 "));
AnthropicChatOptions options = AnthropicChatOptions.builder()
.model("claude-sonnet-4-5")
.model(AnthropicApi.ChatModel.CLAUDE_SONNET_4_5)
.thinking(AnthropicApi.ThinkingType.ENABLED, 3096)
.temperature(1D)
.build();

View File

@ -1,7 +1,6 @@
package cn.iocoder.yudao.module.ai.framework.ai.core.model.chat;
import cn.iocoder.yudao.module.ai.framework.ai.core.model.baichuan.BaiChuanChatModel;
import com.openai.client.okhttp.OpenAIOkHttpClient;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.Message;
@ -11,6 +10,7 @@ import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
@ -24,11 +24,11 @@ import java.util.List;
public class BaiChuanChatModelTests {
private final OpenAiChatModel openAiChatModel = OpenAiChatModel.builder()
.openAiClient(OpenAIOkHttpClient.builder()
.openAiApi(OpenAiApi.builder()
.baseUrl(BaiChuanChatModel.BASE_URL)
.apiKey("sk-61b6766a94c70786ed02673f5e16af3c") // apiKey
.build())
.options(OpenAiChatOptions.builder()
.defaultOptions(OpenAiChatOptions.builder()
.model("Baichuan4-Turbo") // 模型https://platform.baichuan-ai.com/docs/api
.temperature(0.7)
.build())

Some files were not shown because too many files have changed in this diff Show More