Compare commits
No commits in common. "master" and "v2025.10(jdk8/11)" have entirely different histories.
master
...
v2025.10(j
|
Before Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 163 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 54 KiB |
74
README.md
|
|
@ -31,8 +31,8 @@
|
|||
| 【完整版】[yudao-cloud](https://gitee.com/zhijiantianya/yudao-cloud) | [`master`](https://gitee.com/zhijiantianya/yudao-cloud/tree/master/) 分支 | [`master-jdk17`](https://gitee.com/zhijiantianya/yudao-cloud/tree/master-jdk17/) 分支 |
|
||||
| 【精简版】[yudao-cloud-mini](https://gitee.com/yudaocode/yudao-cloud-mini) | [`master`](https://gitee.com/yudaocode/yudao-cloud-mini/tree/master/) 分支 | [`master-jdk17`](https://gitee.com/yudaocode/yudao-cloud-mini/tree/master-jdk17/) 分支 |
|
||||
|
||||
* 【完整版】:包括系统功能、基础设施、会员中心、数据报表、工作流程、商城系统、微信公众号、CRM、ERP、WMS、MES、IM 即时通讯、AI 大模型、IoT 物联网等功能
|
||||
* 【精简版】:只包括系统功能、基础设施功能,不包括会员中心、数据报表、工作流程、商城系统、微信公众号、CRM、ERP、WMS、MES、IM 即时通讯、AI 大模型、IoT 物联网等功能
|
||||
* 【完整版】:包括系统功能、基础设施、会员中心、数据报表、工作流程、商城系统、微信公众号、CRM、ERP 等功能
|
||||
* 【精简版】:只包括系统功能、基础设施功能,不包括会员中心、数据报表、工作流程、商城系统、微信公众号、CRM、ERP 等功能
|
||||
|
||||
可参考 [《迁移文档》](https://cloud.iocoder.cn/migrate-module/) ,只需要 5-10 分钟,即可将【完整版】按需迁移到【精简版】
|
||||
|
||||
|
|
@ -105,7 +105,7 @@
|
|||
|
||||
团队包含专业的项目经理、架构师、前端工程师、后端工程师、测试工程师、运维工程师,可以提供全流程的外包服务。
|
||||
|
||||
项目可以是商城、SCRM 系统、OA 系统、物流系统、ERP 系统、CMS 系统、HIS 系统、支付系统、IM 即时通讯、微信公众号、微信小程序等等。
|
||||
项目可以是商城、SCRM 系统、OA 系统、物流系统、ERP 系统、CMS 系统、HIS 系统、支付系统、IM 聊天、微信公众号、微信小程序等等。
|
||||
|
||||
## 🐼 内置功能
|
||||
|
||||
|
|
@ -115,7 +115,7 @@
|
|||
|
||||
* 通用模块(必选):系统功能、基础设施
|
||||
* 通用模块(可选):工作流程、支付系统、数据报表、会员中心
|
||||
* 业务系统(按需):Mall 电子商城、OA 办公自动化、ERP 企业资源计划系统、WMS 仓库管理系统、CRM 客户关系管理、CMS 内容管理系统、MES 执行制造系统、AI 大模型平台、IoT 物联网系统、IM 即时通讯系统、Mobile 手机移动端、Report 数据大屏
|
||||
* 业务系统(按需):ERP 系统、CRM 系统、商城系统、微信公众号、AI 大模型
|
||||
|
||||
> 友情提示:本项目基于 RuoYi-Vue 修改,**重构优化**后端的代码,**美化**前端的界面。
|
||||
>
|
||||
|
|
@ -235,19 +235,18 @@
|
|||
|
||||
### 微信公众号
|
||||
|
||||
| | 功能 | 描述 |
|
||||
|----|--------|-------------------------------|
|
||||
| 🚀 | 账号管理 | 配置接入的微信公众号,可支持多个公众号 |
|
||||
| 🚀 | 数据统计 | 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 |
|
||||
| 🚀 | 粉丝管理 | 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 |
|
||||
| 🚀 | 消息管理 | 查看粉丝发送的消息列表,可主动回复粉丝消息 |
|
||||
| 🚀 | 模版消息 | 配置和发送模版消息,用于向粉丝推送通知类消息 |
|
||||
| 🚀 | 自动回复 | 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 |
|
||||
| 🚀 | 标签管理 | 对公众号的标签进行创建、查询、修改、删除等操作 |
|
||||
| 🚀 | 菜单管理 | 自定义公众号的菜单,也可以从公众号同步菜单 |
|
||||
| 🚀 | 素材管理 | 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 |
|
||||
| 🚀 | 图文草稿箱 | 新增常用的图文素材到草稿箱,可发布到公众号 |
|
||||
| 🚀 | 图文发表记录 | 查看已发布成功的图文素材,支持删除操作 |
|
||||
| | 功能 | 描述 |
|
||||
|-----|--------|-------------------------------|
|
||||
| 🚀 | 账号管理 | 配置接入的微信公众号,可支持多个公众号 |
|
||||
| 🚀 | 数据统计 | 统计公众号的用户增减、累计用户、消息概况、接口分析等数据 |
|
||||
| 🚀 | 粉丝管理 | 查看已关注、取关的粉丝列表,可对粉丝进行同步、打标签等操作 |
|
||||
| 🚀 | 消息管理 | 查看粉丝发送的消息列表,可主动回复粉丝消息 |
|
||||
| 🚀 | 自动回复 | 自动回复粉丝发送的消息,支持关注回复、消息回复、关键字回复 |
|
||||
| 🚀 | 标签管理 | 对公众号的标签进行创建、查询、修改、删除等操作 |
|
||||
| 🚀 | 菜单管理 | 自定义公众号的菜单,也可以从公众号同步菜单 |
|
||||
| 🚀 | 素材管理 | 管理公众号的图片、语音、视频等素材,支持在线播放语音、视频 |
|
||||
| 🚀 | 图文草稿箱 | 新增常用的图文素材到草稿箱,可发布到公众号 |
|
||||
| 🚀 | 图文发表记录 | 查看已发布成功的图文素材,支持删除操作 |
|
||||
|
||||
### 商城系统
|
||||
|
||||
|
|
@ -273,28 +272,12 @@
|
|||
|
||||

|
||||
|
||||
### WMS 系统
|
||||
|
||||
演示地址:<https://cloud.iocoder.cn/wms-preview/>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### CRM 系统
|
||||
|
||||
演示地址:<https://cloud.iocoder.cn/crm-preview/>
|
||||
|
||||

|
||||
|
||||
### MES 系统
|
||||
|
||||
演示地址:<https://cloud.iocoder.cn/mes-preview/>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### AI 大模型
|
||||
|
||||
演示地址:<https://cloud.iocoder.cn/ai-preview/>
|
||||
|
|
@ -303,27 +286,6 @@
|
|||
|
||||

|
||||
|
||||
### IoT 物联网
|
||||
|
||||
演示地址:<https://cloud.iocoder.cn/iot/build>
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### IM 即时通讯
|
||||
|
||||
演示地址(Cloud):<https://cloud.iocoder.cn/im-preview/>
|
||||
|
||||
演示地址(Vue3 + Element Plus):<http://dashboard-vue3.yudao.iocoder.cn>
|
||||
|
||||
|
||||

|
||||
|
||||
| 聊天界面 | 聊天管理 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
## 🐨 技术栈
|
||||
|
||||
### 微服务
|
||||
|
|
@ -341,11 +303,7 @@
|
|||
| `yudao-module-mall` | 商城系统的 Module 模块 |
|
||||
| `yudao-module-erp` | ERP 系统的 Module 模块 |
|
||||
| `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 模块 |
|
||||
| `yudao-module-report` | 大屏报表 Module 模块 |
|
||||
|
||||
|
|
|
|||
9
pom.xml
|
|
@ -24,12 +24,9 @@
|
|||
<module>yudao-module-mall</module>
|
||||
<module>yudao-module-erp</module>
|
||||
<module>yudao-module-crm</module>
|
||||
<module>yudao-module-iot</module>
|
||||
<module>yudao-module-mes</module>
|
||||
<module>yudao-module-wms</module>
|
||||
<module>yudao-module-im</module>
|
||||
<!-- 友情提示:基于 Spring AI 实现 LLM 大模型的接入,需要使用 JDK17 版本,详细可见 https://doc.iocoder.cn/ai/build/ -->
|
||||
<!-- <module>yudao-module-ai</module>-->
|
||||
<module>yudao-module-iot</module>
|
||||
</modules>
|
||||
|
||||
<name>${project.artifactId}</name>
|
||||
|
|
@ -37,7 +34,7 @@
|
|||
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
|
||||
|
||||
<properties>
|
||||
<revision>2026.05-jdk8-SNAPSHOT</revision>
|
||||
<revision>2025.10-jdk8-SNAPSHOT</revision>
|
||||
<!-- Maven 相关 -->
|
||||
<java.version>1.8</java.version>
|
||||
<maven.compiler.source>${java.version}</maven.compiler.source>
|
||||
|
|
@ -46,7 +43,7 @@
|
|||
<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>
|
||||
<lombok.version>1.18.38</lombok.version>
|
||||
<spring.boot.version>2.7.18</spring.boot.version>
|
||||
<mapstruct.version>1.6.3</mapstruct.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ def load_and_clean(sql_file: str) -> str:
|
|||
REPLACE_PAIR_LIST = (
|
||||
(")\nVALUES ", ") VALUES "),
|
||||
(" CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ", " "),
|
||||
(" CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci ", " "),
|
||||
(" KEY `", " INDEX `"),
|
||||
("UNIQUE INDEX", "UNIQUE KEY"),
|
||||
("b'0'", "'0'"),
|
||||
|
|
@ -62,33 +61,17 @@ def load_and_clean(sql_file: str) -> str:
|
|||
content = open(sql_file, encoding="utf-8").read()
|
||||
for replace_pair in REPLACE_PAIR_LIST:
|
||||
content = content.replace(*replace_pair)
|
||||
# 移除所有 CHARACTER SET / COLLATE 变体 (utf8mb3、utf8 等)
|
||||
content = re.sub(r" CHARACTER SET \w+ COLLATE \w+", "", content)
|
||||
content = re.sub(r" CHARACTER SET \w+", "", content)
|
||||
content = re.sub(r" COLLATE \w+", "", content)
|
||||
# 移除索引字段的前缀长度定义,例如: `name`(32) -> `name`
|
||||
# 移除索引定义上的 USING BTREE COMMENT 部分
|
||||
# 相关 issue:https://t.zsxq.com/96IFc 、https://t.zsxq.com/rC3A3
|
||||
content = re.sub(r'`([^`]+)`\(\d+\)', r'`\1`', content)
|
||||
content = re.sub(r'\s+USING\s+BTREE\s+COMMENT\s+\'[^\']+\'', '', content)
|
||||
content = re.sub(r"ENGINE.*COMMENT", "COMMENT", content)
|
||||
content = re.sub(r"ENGINE.*;", ";", content)
|
||||
return content
|
||||
|
||||
|
||||
class Convertor(ABC):
|
||||
# 不同数据库的关键字不完全一致;子类按需声明需要转义的列名。
|
||||
reserved_column_names = set()
|
||||
|
||||
def __init__(self, src: str, db_type) -> None:
|
||||
self.src = src
|
||||
self.db_type = db_type
|
||||
self.content = load_and_clean(self.src)
|
||||
# original_content 保留原始 COMMENT 信息,用于注释提取
|
||||
self.original_content = open(src, encoding="utf-8").read()
|
||||
# 剥离列级 COMMENT 以避免 COMMENT 值内的分号截断 CREATE TABLE 正则
|
||||
content_no_comment = re.sub(r" COMMENT '(?:[^'\\]|\\.)*'", "", self.content)
|
||||
self.table_script_list = re.findall(r"CREATE TABLE [^;]*;", content_no_comment)
|
||||
self.table_script_list = re.findall(r"CREATE TABLE [^;]*;", self.content)
|
||||
|
||||
@abstractmethod
|
||||
def translate_type(self, type: str, size: Optional[Union[int, Tuple[int]]]) -> str:
|
||||
|
|
@ -182,31 +165,6 @@ 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}`"
|
||||
|
|
@ -218,8 +176,7 @@ class Convertor(ABC):
|
|||
head = head.strip().replace("`", "").lower()
|
||||
tail = tail.strip().replace(r"\"", '"')
|
||||
# tail = tail.replace("b'0'", "'0'").replace("b'1'", "'1'")
|
||||
col_part = f" {head}" if head else ""
|
||||
yield f"INSERT INTO {table_name.lower()}{col_part} VALUES {tail}"
|
||||
yield f"INSERT INTO {table_name.lower()} {head} VALUES {tail}"
|
||||
|
||||
@staticmethod
|
||||
def index(ddl: Dict) -> Generator:
|
||||
|
|
@ -232,55 +189,18 @@ class Convertor(ABC):
|
|||
Generator[str]: create index 语句
|
||||
"""
|
||||
|
||||
for no, index in enumerate(ddl.get("index", []), 1):
|
||||
columns = ", ".join(Convertor.index_columns(index.get("columns", [])))
|
||||
if not columns:
|
||||
continue
|
||||
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"])
|
||||
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"]:
|
||||
|
|
@ -288,9 +208,7 @@ class Convertor(ABC):
|
|||
for uk in uk_list:
|
||||
table_name = ddl["table_name"]
|
||||
uk_name = uk["constraint_name"]
|
||||
uk_columns = Convertor.index_columns(uk["columns"])
|
||||
if not uk_columns:
|
||||
continue
|
||||
uk_columns = uk["columns"]
|
||||
yield table_name, uk_name, uk_columns
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -303,8 +221,7 @@ class Convertor(ABC):
|
|||
yield field, comment_string
|
||||
|
||||
def table_comment(self, table_sql: str) -> str:
|
||||
# 兼容 COMMENT='xxx' / COMMENT = 'xxx' 等等号两侧空格变体;允许 COMMENT 内含转义单引号
|
||||
match = re.search(r"COMMENT\s*=\s*'((?:[^'\\]|\\.)*)'", table_sql)
|
||||
match = re.search(r"COMMENT \='([^']+)';", table_sql)
|
||||
return match.group(1) if match else None
|
||||
|
||||
def print(self):
|
||||
|
|
@ -328,9 +245,7 @@ class Convertor(ABC):
|
|||
|
||||
error_scripts = []
|
||||
for table_sql in self.table_script_list:
|
||||
# 剥离 COMMENT 子句避免 DDLParser 无法处理中文等特殊字符
|
||||
table_sql_for_parse = re.sub(r"\s+COMMENT\s+'[^']*'", "", table_sql)
|
||||
ddl = DDLParser(table_sql_for_parse.replace("`", "")).run()
|
||||
ddl = DDLParser(table_sql.replace("`", "")).run()
|
||||
|
||||
# 如果parse失败, 需要跟进
|
||||
if len(ddl) == 0:
|
||||
|
|
@ -345,23 +260,17 @@ class Convertor(ABC):
|
|||
continue
|
||||
|
||||
# 解析注释
|
||||
# 从原始 SQL 提取注释(支持中文等 DDLParser 不能处理的内容)
|
||||
# 按表名定位完整建表段(以「换行 + )」起始的 ENGINE 行作为终止),避免被列 COMMENT 内的分号截断
|
||||
orig_match = re.search(
|
||||
rf"CREATE TABLE\s+`{re.escape(table_name)}`[\s\S]*?\n\)[^;]*;",
|
||||
self.original_content,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
orig_table_sql = orig_match.group() if orig_match else table_sql
|
||||
comments_dict = dict(Convertor.filed_comments(orig_table_sql))
|
||||
for column in table_ddl["columns"]:
|
||||
column["comment"] = comments_dict.get(column["name"], "")
|
||||
table_ddl["comment"] = self.table_comment(orig_table_sql) or ""
|
||||
column["comment"] = bytes(column["comment"], "utf-8").decode(
|
||||
"unicode_escape"
|
||||
)[1:-1]
|
||||
table_ddl["comment"] = bytes(table_ddl["comment"], "utf-8").decode(
|
||||
"unicode_escape"
|
||||
)[1:-1]
|
||||
|
||||
# 为每个表生成个6个基本部分
|
||||
create = self.gen_create(table_ddl)
|
||||
has_id = any(col["name"].lower() == "id" for col in table_ddl["columns"])
|
||||
pk = self.gen_pk(table_name) if has_id else ""
|
||||
pk = self.gen_pk(table_name)
|
||||
uk = self.gen_uk(table_ddl)
|
||||
index = self.gen_index(table_ddl)
|
||||
comment = self.gen_comment(table_ddl)
|
||||
|
|
@ -405,31 +314,25 @@ class PostgreSQLConvertor(Convertor):
|
|||
|
||||
if type == "varchar":
|
||||
return f"varchar({size})"
|
||||
if type in ("int", "int unsigned", "int unsigned zerofill"):
|
||||
if type in ("int", "int unsigned"):
|
||||
return "int4"
|
||||
if type in ("bigint", "bigint unsigned"):
|
||||
return "int8"
|
||||
if type in ("tinyint", "smallint", "tinyint unsigned"):
|
||||
return "int2"
|
||||
if type in ("datetime", "timestamp null"):
|
||||
if type == "datetime":
|
||||
return "timestamp"
|
||||
if type == "date":
|
||||
return "date"
|
||||
if type == "json":
|
||||
return "jsonb"
|
||||
if type == "double":
|
||||
return "double precision"
|
||||
if type == "timestamp":
|
||||
return f"timestamp({size})" if size else "timestamp"
|
||||
return f"timestamp({size})"
|
||||
if type == "bit":
|
||||
return "bool"
|
||||
if type in ("tinyint", "smallint"):
|
||||
return "int2"
|
||||
if type in ("text", "longtext"):
|
||||
return "text"
|
||||
if type in ("blob", "mediumblob", "longblob"):
|
||||
if type in ("blob", "mediumblob"):
|
||||
return "bytea"
|
||||
if type == "decimal":
|
||||
return (
|
||||
f"numeric({','.join(str(s) for s in size)})" if size and len(size) else "numeric"
|
||||
f"numeric({','.join(str(s) for s in size)})" if len(size) else "numeric"
|
||||
)
|
||||
|
||||
def gen_create(self, ddl: Dict) -> str:
|
||||
|
|
@ -442,13 +345,9 @@ class PostgreSQLConvertor(Convertor):
|
|||
|
||||
type = col["type"].lower()
|
||||
full_type = self.translate_type(type, col["size"])
|
||||
if full_type is None:
|
||||
raise NotImplementedError(
|
||||
f"未支持的类型: '{col['type']}' (列: {name}, 表: {ddl['table_name']})"
|
||||
)
|
||||
nullable = "NULL" if col["nullable"] else "NOT NULL"
|
||||
default = f"DEFAULT {col['default']}" if col["default"] is not None else ""
|
||||
return f"{self.escape_column_name(name)} {full_type} {nullable} {default}"
|
||||
return f"{name} {full_type} {nullable} {default}"
|
||||
|
||||
table_name = ddl["table_name"].lower()
|
||||
columns = [f"{_generate_column(col).strip()}" for col in ddl["columns"]]
|
||||
|
|
@ -473,7 +372,7 @@ CREATE TABLE {table_name} (
|
|||
for column in table_ddl["columns"]:
|
||||
table_comment = column["comment"]
|
||||
script += (
|
||||
f"COMMENT ON COLUMN {table_ddl['table_name']}.{self.escape_column_name(column['name'])} IS '{table_comment}';"
|
||||
f"COMMENT ON COLUMN {table_ddl['table_name']}.{column['name']} IS '{table_comment}';"
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
|
@ -502,9 +401,6 @@ 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 脚本
|
||||
script = ""
|
||||
last_id = 0
|
||||
|
|
@ -550,8 +446,6 @@ INSERT INTO dual VALUES (1);
|
|||
|
||||
|
||||
class OracleConvertor(Convertor):
|
||||
reserved_column_names = {"level", "size"}
|
||||
|
||||
def __init__(self, src):
|
||||
super().__init__(src, "Oracle")
|
||||
|
||||
|
|
@ -596,8 +490,10 @@ 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"{self.escape_column_name(name)} {full_type} {default} {nullable}"
|
||||
return f"{field_name} {full_type} {default} {nullable}"
|
||||
|
||||
table_name = ddl["table_name"].lower()
|
||||
columns = [f"{generate_column(col).strip()}" for col in ddl["columns"]]
|
||||
|
|
@ -622,7 +518,7 @@ CREATE TABLE {table_name} (
|
|||
for column in table_ddl["columns"]:
|
||||
table_comment = column["comment"]
|
||||
script += (
|
||||
f"COMMENT ON COLUMN {table_ddl['table_name']}.{self.escape_column_name(column['name'])} IS '{table_comment}';"
|
||||
f"COMMENT ON COLUMN {table_ddl['table_name']}.{column['name']} IS '{table_comment}';"
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
|
@ -654,7 +550,6 @@ 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}')",
|
||||
|
|
@ -976,8 +871,6 @@ 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"
|
||||
|
|
@ -996,7 +889,7 @@ class KingbaseConvertor(PostgreSQLConvertor):
|
|||
if full_type == "text":
|
||||
nullable = "NULL"
|
||||
default = f"DEFAULT {col['default']}" if col["default"] is not None else ""
|
||||
return f"{self.escape_column_name(name)} {full_type} {nullable} {default}"
|
||||
return f"{name} {full_type} {nullable} {default}"
|
||||
|
||||
table_name = ddl["table_name"].lower()
|
||||
columns = [f"{_generate_column(col).strip()}" for col in ddl["columns"]]
|
||||
|
|
@ -1016,8 +909,6 @@ CREATE TABLE {table_name} (
|
|||
|
||||
|
||||
class OpengaussConvertor(KingbaseConvertor):
|
||||
reserved_column_names = set()
|
||||
|
||||
def __init__(self, src):
|
||||
super().__init__(src)
|
||||
self.db_type = "OpenGauss"
|
||||
|
|
|
|||
|
|
@ -14,32 +14,32 @@
|
|||
<url>https://github.com/YunaiV/ruoyi-vue-pro</url>
|
||||
|
||||
<properties>
|
||||
<revision>2026.05-jdk8-SNAPSHOT</revision>
|
||||
<revision>2025.10-jdk8-SNAPSHOT</revision>
|
||||
<flatten-maven-plugin.version>1.6.0</flatten-maven-plugin.version>
|
||||
<!-- 统一依赖管理 -->
|
||||
<spring.framework.version>5.3.39</spring.framework.version>
|
||||
<spring.security.version>5.8.16</spring.security.version>
|
||||
<spring.boot.version>2.7.18</spring.boot.version>
|
||||
<spring.cloud.version>2021.0.9</spring.cloud.version> <!-- Spring Boot 2.X 最多使用 2021.0.9 版本 -->
|
||||
<spring.cloud.alibaba.version>2021.0.6.2</spring.cloud.alibaba.version> <!-- Spring Boot 2.X 最多使用 2021.0.6.2 版本 -->
|
||||
<spring.cloud.version>2021.0.9</spring.cloud.version>
|
||||
<spring.cloud.alibaba.version>2021.0.6.2</spring.cloud.alibaba.version>
|
||||
<!-- Web 相关 -->
|
||||
<servlet.versoin>2.5</servlet.versoin>
|
||||
<springdoc.version>1.8.0</springdoc.version>
|
||||
<knife4j.version>4.5.0</knife4j.version>
|
||||
<!-- DB 相关 -->
|
||||
<druid.version>1.2.28</druid.version>
|
||||
<druid.version>1.2.27</druid.version>
|
||||
<mybatis.version>3.5.19</mybatis.version>
|
||||
<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>
|
||||
<mybatis-plus.version>3.5.14</mybatis-plus.version>
|
||||
<mybatis-plus-join.version>1.5.4</mybatis-plus-join.version>
|
||||
<dynamic-datasource.version>4.3.1</dynamic-datasource.version>
|
||||
<easy-trans.version>3.0.6</easy-trans.version>
|
||||
<redisson.version>4.4.0</redisson.version>
|
||||
<redisson.version>3.51.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>
|
||||
<taos.version>3.8.3</taos.version>
|
||||
<kingbase.jdbc.version>8.6.0</kingbase.jdbc.version>
|
||||
<opengauss.jdbc.version>5.1.0</opengauss.jdbc.version>
|
||||
<taos.version>3.7.3</taos.version>
|
||||
<!-- 消息队列 -->
|
||||
<rocketmq-spring.version>2.3.5</rocketmq-spring.version>
|
||||
<rocketmq-spring.version>2.3.4</rocketmq-spring.version>
|
||||
<!-- RPC 相关 -->
|
||||
<!-- Config 配置中心相关 -->
|
||||
<!-- Job 定时任务相关 -->
|
||||
|
|
@ -52,47 +52,39 @@
|
|||
<opentracing.version>0.33.0</opentracing.version>
|
||||
<!-- Test 测试相关 -->
|
||||
<podam.version>7.2.11.RELEASE</podam.version> <!-- Spring Boot 2.X 最多使用 7.2.11 版本 -->
|
||||
<jedis-mock.version>1.1.12</jedis-mock.version>
|
||||
<jedis-mock.version>1.1.11</jedis-mock.version>
|
||||
<mockito-inline.version>4.11.0</mockito-inline.version>
|
||||
<!-- Bpm 工作流相关 -->
|
||||
<flowable.version>6.8.1</flowable.version>
|
||||
<flowable.version>6.8.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>
|
||||
<jsoup.version>1.21.2</jsoup.version>
|
||||
<lombok.version>1.18.38</lombok.version>
|
||||
<mapstruct.version>1.6.3</mapstruct.version>
|
||||
<hutool-5.version>5.8.44</hutool-5.version>
|
||||
<hutool-5.version>5.8.40</hutool-5.version>
|
||||
<fastexcel.version>1.3.0</fastexcel.version>
|
||||
<velocity.version>2.4</velocity.version> <!-- JDK8 不能从 2.4 升级到 2.4.1,会报包不存在!!!! -->
|
||||
<fastjson.version>1.2.83</fastjson.version>
|
||||
<guava.version>33.6.0-jre</guava.version>
|
||||
<guava.version>33.4.8-jre</guava.version>
|
||||
<transmittable-thread-local.version>2.14.5</transmittable-thread-local.version>
|
||||
<commons-net.version>3.13.0</commons-net.version>
|
||||
<commons-lang3.version>3.20.0</commons-lang3.version>
|
||||
<jsch.version>2.28.2</jsch.version>
|
||||
<commons-net.version>3.11.1</commons-net.version>
|
||||
<commons-lang3.version>3.18.0</commons-lang3.version>
|
||||
<jsch.version>2.27.3</jsch.version>
|
||||
<tika-core.version>2.9.3</tika-core.version> <!-- JDK8 不能从 2.9.3 升级到 3.X,会报 JDK8 不支持 -->
|
||||
<ip2region.version>2.7.0</ip2region.version>
|
||||
<bizlog-sdk.version>3.0.6</bizlog-sdk.version>
|
||||
<reflections.version>0.10.2</reflections.version>
|
||||
<netty.version>4.2.14.Final</netty.version>
|
||||
<netty.version>4.1.116.Final</netty.version>
|
||||
<mqtt.version>1.2.5</mqtt.version>
|
||||
<vertx.version>4.5.26</vertx.version>
|
||||
<okhttp.version>4.12.0</okhttp.version>
|
||||
<californium.version>3.14.0</californium.version>
|
||||
<j2mod.version>3.3.0</j2mod.version>
|
||||
<httpclient5.version>5.5.2</httpclient5.version> <!-- WxJava 4.8.x 需要 HttpClient5 5.4+,Spring Boot 2.7 默认 5.1.4 不兼容 -->
|
||||
<httpcore5.version>5.3.6</httpcore5.version> <!-- 配套 httpclient5 5.5.2,Spring Boot 2.7 默认 5.1.5 不兼容 -->
|
||||
<pf4j-spring.version>0.9.0</pf4j-spring.version>
|
||||
<vertx.version>4.5.13</vertx.version>
|
||||
<!-- 三方云服务相关 -->
|
||||
<awssdk.version>2.44.0</awssdk.version>
|
||||
<awssdk.version>2.30.14</awssdk.version>
|
||||
<justauth.version>1.16.7</justauth.version>
|
||||
<justauth-starter.version>1.4.0</justauth-starter.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>
|
||||
<bouncycastle.version>1.80</bouncycastle.version>
|
||||
<alipay-sdk-java.version>4.40.806.ALL</alipay-sdk-java.version>
|
||||
<jimureport.version>2.1.1</jimureport.version>
|
||||
<jimubi.version>2.1.0</jimubi.version>
|
||||
<weixin-java.version>4.7.7-20250808.182223</weixin-java.version>
|
||||
<!-- 专属于 JDK8 安全漏洞升级 -->
|
||||
<logback.version>1.2.13</logback.version> <!-- 无法使用 1.3.X 版本,启动会报错 -->
|
||||
</properties>
|
||||
|
|
@ -313,7 +305,7 @@
|
|||
<exclusion>
|
||||
<groupId>org.redisson</groupId>
|
||||
<!-- 使用 redisson-spring-data-27 替代,解决 Tuple NoClassDefFoundError 报错 -->
|
||||
<artifactId>redisson-spring-data-40</artifactId> <!-- Redisson 4.x 默认依赖 spring-data-40,排除后使用 spring-data-27 适配 Spring Boot 2.7 -->
|
||||
<artifactId>redisson-spring-data-35</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
|
@ -376,6 +368,7 @@
|
|||
<artifactId>yudao-spring-boot-starter-mq</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.apache.rocketmq</groupId>
|
||||
<artifactId>rocketmq-spring-boot-starter</artifactId>
|
||||
|
|
@ -627,24 +620,81 @@
|
|||
<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>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.reflections</groupId>
|
||||
<artifactId>reflections</artifactId>
|
||||
<version>${reflections.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 三方云服务相关 -->
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>s3</artifactId>
|
||||
<version>${awssdk.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.github.binarywang</groupId>
|
||||
<artifactId>weixin-java-pay</artifactId>
|
||||
<version>${weixin-java.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.binarywang</groupId>
|
||||
<artifactId>wx-java-mp-spring-boot-starter</artifactId>
|
||||
<version>${weixin-java.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.binarywang</groupId>
|
||||
<artifactId>wx-java-miniapp-spring-boot-starter</artifactId>
|
||||
<version>${weixin-java.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>me.zhyd.oauth</groupId>
|
||||
<artifactId>JustAuth</artifactId> <!-- 社交登陆(例如说,个人微信、企业微信等等) -->
|
||||
<version>${justauth.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xkcoding.justauth</groupId>
|
||||
<artifactId>justauth-spring-boot-starter</artifactId>
|
||||
<version>${justauth-starter.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 积木报表-->
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.jimureport</groupId>
|
||||
<artifactId>jimureport-spring-boot-starter</artifactId>
|
||||
<version>${jimureport.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.jimureport</groupId>
|
||||
<artifactId>jimubi-spring-boot-starter</artifactId>
|
||||
<version>${jimubi.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.github.jsqlparser</groupId>
|
||||
<artifactId>jsqlparser</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-core</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- PF4J -->
|
||||
<dependency>
|
||||
<groupId>org.pf4j</groupId>
|
||||
<artifactId>pf4j-spring</artifactId>
|
||||
<version>${pf4j-spring.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-log4j12</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- Vert.x -->
|
||||
<dependency>
|
||||
<groupId>io.vertx</groupId>
|
||||
|
|
@ -669,141 +719,16 @@
|
|||
<version>${mqtt.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- OkHttp -->
|
||||
<!-- 专属于 JDK8 安全漏洞升级 -->
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>okhttp</artifactId>
|
||||
<version>${okhttp.version}</version>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-core</artifactId>
|
||||
<version>${logback.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.squareup.okhttp3</groupId>
|
||||
<artifactId>mockwebserver</artifactId>
|
||||
<version>${okhttp.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- CoAP - Eclipse Californium -->
|
||||
<dependency>
|
||||
<groupId>org.eclipse.californium</groupId>
|
||||
<artifactId>californium-core</artifactId>
|
||||
<version>${californium.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Modbus 相关 -->
|
||||
<dependency>
|
||||
<groupId>com.ghgande</groupId>
|
||||
<artifactId>j2mod</artifactId>
|
||||
<version>${j2mod.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- WxJava 4.8.x 需要 HttpClient5 5.4+,覆盖 Spring Boot 2.7 默认的 5.1.4 -->
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents.client5</groupId>
|
||||
<artifactId>httpclient5</artifactId>
|
||||
<version>${httpclient5.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents.core5</groupId>
|
||||
<artifactId>httpcore5</artifactId>
|
||||
<version>${httpcore5.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents.core5</groupId>
|
||||
<artifactId>httpcore5-h2</artifactId>
|
||||
<version>${httpcore5.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 三方云服务相关 -->
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>s3</artifactId>
|
||||
<version>${awssdk.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>me.zhyd.oauth</groupId>
|
||||
<artifactId>JustAuth</artifactId> <!-- 社交登陆(例如说,个人微信、企业微信等等) -->
|
||||
<version>${justauth.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.xkcoding.justauth</groupId>
|
||||
<artifactId>justauth-spring-boot-starter</artifactId>
|
||||
<version>${justauth-starter.version}</version>
|
||||
<exclusions>
|
||||
<!-- 移除,避免和项目里的 hutool-all 冲突 -->
|
||||
<exclusion>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-core</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.alipay.sdk</groupId>
|
||||
<artifactId>alipay-sdk-java</artifactId>
|
||||
<version>${alipay-sdk-java.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk15on</artifactId>
|
||||
</exclusion>
|
||||
</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>
|
||||
<version>${weixin-java.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.binarywang</groupId>
|
||||
<artifactId>wx-java-mp-spring-boot-starter</artifactId>
|
||||
<version>${weixin-java.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.binarywang</groupId>
|
||||
<artifactId>wx-java-miniapp-spring-boot-starter</artifactId>
|
||||
<version>${weixin-java.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 积木报表-->
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.jimureport</groupId>
|
||||
<artifactId>jimureport-spring-boot-starter</artifactId>
|
||||
<version>${jimureport.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.jimureport</groupId>
|
||||
<artifactId>jimubi-spring-boot-starter</artifactId>
|
||||
<version>${jimubi.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>com.github.jsqlparser</groupId>
|
||||
<artifactId>jsqlparser</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-core</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<version>${logback.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@
|
|||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>javax.servlet</groupId>
|
||||
<artifactId>javax.servlet-api</artifactId>
|
||||
<groupId>jakarta.servlet</groupId>
|
||||
<artifactId>jakarta.servlet-api</artifactId>
|
||||
<scope>provided</scope> <!-- 设置为 provided,只有工具类需要使用到 -->
|
||||
</dependency>
|
||||
|
||||
|
|
@ -125,8 +125,8 @@
|
|||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>javax.validation</groupId>
|
||||
<artifactId>validation-api</artifactId>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
<scope>provided</scope> <!-- 设置为 provided,主要是 PageParam 使用到 -->
|
||||
</dependency>
|
||||
|
||||
|
|
|
|||
|
|
@ -27,10 +27,10 @@ public class PageParam implements Serializable {
|
|||
@Min(value = 1, message = "页码最小值为 1")
|
||||
private Integer pageNo = PAGE_NO;
|
||||
|
||||
@Schema(description = "每页条数,最大值为 200", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
|
||||
@Schema(description = "每页条数,最大值为 100", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
|
||||
@NotNull(message = "每页条数不能为空")
|
||||
@Min(value = 1, message = "每页条数最小值为 1")
|
||||
@Max(value = 200, message = "每页条数最大值为 200")
|
||||
@Max(value = 100, message = "每页条数最大值为 100")
|
||||
private Integer pageSize = PAGE_SIZE;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,22 +124,6 @@ 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<>();
|
||||
|
|
@ -365,37 +349,4 @@ public class CollectionUtils {
|
|||
return (LinkedHashSet<T>) toCollection(LinkedHashSet.class, elementType, value);
|
||||
}
|
||||
|
||||
public static boolean dfs(Long node, Map<Long, Set<Long>> graph) {
|
||||
return dfs(node, graph, new HashSet<>(), new HashSet<>());
|
||||
}
|
||||
|
||||
private static boolean dfs(Long node, Map<Long, Set<Long>> graph, Set<Long> visited, Set<Long> inStack) {
|
||||
if (inStack.contains(node)) {
|
||||
return true;
|
||||
}
|
||||
if (visited.contains(node)) {
|
||||
return false;
|
||||
}
|
||||
visited.add(node);
|
||||
inStack.add(node);
|
||||
Set<Long> neighbors = graph.getOrDefault(node, Collections.emptySet());
|
||||
for (Long neighbor : neighbors) {
|
||||
if (dfs(neighbor, graph, visited, inStack)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
inStack.remove(node);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把单元素 head 与集合 tail 合并成新 List(head 在前,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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ import cn.iocoder.yudao.framework.common.core.KeyValue;
|
|||
import com.google.common.collect.Maps;
|
||||
import com.google.common.collect.Multimap;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
|
@ -66,47 +65,4 @@ public class MapUtils {
|
|||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Map 中获取 BigDecimal 值
|
||||
*
|
||||
* @param map Map 数据源
|
||||
* @param key 键名
|
||||
* @return BigDecimal 值,解析失败或值为 null 时返回 null
|
||||
*/
|
||||
public static BigDecimal getBigDecimal(Map<String, ?> map, String key) {
|
||||
return getBigDecimal(map, key, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Map 中获取 BigDecimal 值
|
||||
*
|
||||
* @param map Map 数据源
|
||||
* @param key 键名
|
||||
* @param defaultValue 默认值
|
||||
* @return BigDecimal 值,解析失败或值为 null 时返回默认值
|
||||
*/
|
||||
public static BigDecimal getBigDecimal(Map<String, ?> map, String key, BigDecimal defaultValue) {
|
||||
if (map == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
Object value = map.get(key);
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
if (value instanceof BigDecimal) {
|
||||
return (BigDecimal) value;
|
||||
}
|
||||
if (value instanceof Number) {
|
||||
return BigDecimal.valueOf(((Number) value).doubleValue());
|
||||
}
|
||||
if (value instanceof String) {
|
||||
try {
|
||||
return new BigDecimal((String) value);
|
||||
} catch (NumberFormatException e) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -236,23 +236,6 @@ 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) {
|
||||
|
|
@ -319,21 +302,6 @@ public class LocalDateTimeUtils {
|
|||
return timeRanges;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取从开始日期起的日期列表
|
||||
*
|
||||
* @param startDate 开始日期
|
||||
* @param days 天数
|
||||
* @return 日期列表,包含开始日期
|
||||
*/
|
||||
public static List<LocalDate> getDateList(LocalDate startDate, int days) {
|
||||
List<LocalDate> dateList = new ArrayList<>(days);
|
||||
for (int i = 0; i < days; i++) {
|
||||
dateList.add(startDate.plusDays(i));
|
||||
}
|
||||
return dateList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间范围
|
||||
*
|
||||
|
|
@ -367,27 +335,6 @@ public class LocalDateTimeUtils {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定日期所在季度的第一天
|
||||
*
|
||||
* @param date 日期
|
||||
* @return 所在季度的第一天
|
||||
*/
|
||||
public static LocalDate getQuarterStart(LocalDate date) {
|
||||
Month firstMonthOfQuarter = date.getMonth().firstMonthOfQuarter();
|
||||
return LocalDate.of(date.getYear(), firstMonthOfQuarter, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定日期所在周的第一天(周一)
|
||||
*
|
||||
* @param date 日期
|
||||
* @return 所在周的周一
|
||||
*/
|
||||
public static LocalDate getWeekStart(LocalDate date) {
|
||||
return date.with(DayOfWeek.MONDAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将给定的 {@link LocalDateTime} 转换为自 Unix 纪元时间(1970-01-01T00:00:00Z)以来的秒数。
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package cn.iocoder.yudao.framework.common.util.http;
|
||||
|
||||
import cn.hutool.core.codec.Base64;
|
||||
import cn.hutool.core.map.TableMap;
|
||||
import cn.hutool.core.net.url.UrlBuilder;
|
||||
import cn.hutool.core.util.ReflectUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
|
|
@ -9,11 +11,9 @@ import lombok.SneakyThrows;
|
|||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.util.UriComponents;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
import org.springframework.web.util.UriUtils;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.net.URI;
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
|
@ -37,88 +37,14 @@ public class HttpUtils {
|
|||
return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 URL 参数(query parameter)
|
||||
* 注意:此方法会将 + 解码为空格,适用于 query parameter,不适用于 URL path
|
||||
*
|
||||
* @see #decodeUrlPath(String)
|
||||
* @param value 参数
|
||||
* @return 解码后的参数
|
||||
*/
|
||||
@SneakyThrows
|
||||
public static String decodeUtf8(String value) {
|
||||
return URLDecoder.decode(value, StandardCharsets.UTF_8.name());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解码 URL 路径
|
||||
* 与 {@link #decodeUtf8(String)} 不同,此方法不会将 + 解码为空格,保持 + 为字面字符
|
||||
* 适用于 URL path 部分的解码
|
||||
*
|
||||
* @param path URL 路径
|
||||
* @return 解码后的路径
|
||||
*/
|
||||
@SneakyThrows
|
||||
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.name());
|
||||
}
|
||||
|
||||
/**
|
||||
* 编码 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);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public static String replaceUrlQuery(String url, String key, String value) {
|
||||
UrlBuilder builder = UrlBuilder.of(url, Charset.defaultCharset());
|
||||
// 先移除;再添加
|
||||
builder.getQuery().remove(key);
|
||||
// 先移除
|
||||
TableMap<CharSequence, CharSequence> query = (TableMap<CharSequence, CharSequence>)
|
||||
ReflectUtil.getFieldValue(builder.getQuery(), "query");
|
||||
query.remove(key);
|
||||
// 后添加
|
||||
builder.addQuery(key, value);
|
||||
return builder.build();
|
||||
}
|
||||
|
|
@ -255,14 +181,4 @@ public class HttpUtils {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket URL 切换成 HTTP URL:ws:// → http://;wss:// → https://;其它格式原样保留
|
||||
*
|
||||
* @param url 原始 URL
|
||||
* @return 切换协议后的 URL
|
||||
*/
|
||||
public static String wsUrlToHttp(String url) {
|
||||
return StrUtil.startWithIgnoreCase(url, "ws") ? "http" + url.substring(2) : url;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import java.lang.reflect.Type;
|
|||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* JSON 工具类
|
||||
|
|
@ -174,41 +173,6 @@ public class JsonUtils {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 JSON 字符串成指定类型的对象,如果解析失败,则返回 null
|
||||
*
|
||||
* @param text 字符串
|
||||
* @param clazz 类型
|
||||
* @return 指定类型的对象
|
||||
*/
|
||||
public static <T> T parseObjectQuietly(String text, Class<T> clazz) {
|
||||
if (StrUtil.isEmpty(text)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(text, clazz);
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static <T> List<T> parseArray(String text, Class<T> clazz) {
|
||||
if (StrUtil.isEmpty(text)) {
|
||||
return new ArrayList<>();
|
||||
|
|
@ -253,14 +217,6 @@ public class JsonUtils {
|
|||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -273,53 +229,4 @@ public class JsonUtils {
|
|||
return JSONUtil.isTypeJSONObject(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Object 转换为目标类型
|
||||
* <p>
|
||||
* 避免先转 jsonString 再 parseObject 的性能损耗
|
||||
*
|
||||
* @param obj 源对象(可以是 Map、POJO 等)
|
||||
* @param clazz 目标类型
|
||||
* @return 转换后的对象
|
||||
*/
|
||||
public static <T> T convertObject(Object obj, Class<T> clazz) {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
if (clazz.isInstance(obj)) {
|
||||
return clazz.cast(obj);
|
||||
}
|
||||
return objectMapper.convertValue(obj, clazz);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Object 转换为目标类型(支持泛型)
|
||||
*
|
||||
* @param obj 源对象
|
||||
* @param typeReference 目标类型引用
|
||||
* @return 转换后的对象
|
||||
*/
|
||||
public static <T> T convertObject(Object obj, TypeReference<T> typeReference) {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
return objectMapper.convertValue(obj, typeReference);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Object 转换为 List 类型
|
||||
* <p>
|
||||
* 避免先转 jsonString 再 parseArray 的性能损耗
|
||||
*
|
||||
* @param obj 源对象(可以是 List、数组等)
|
||||
* @param clazz 目标元素类型
|
||||
* @return 转换后的 List
|
||||
*/
|
||||
public static <T> List<T> convertList(Object obj, Class<T> clazz) {
|
||||
if (obj == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
return objectMapper.convertValue(obj, objectMapper.getTypeFactory().constructCollectionType(List.class, clazz));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +1,26 @@
|
|||
package cn.iocoder.yudao.framework.common.util.json.databind;
|
||||
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
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 java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 基于时间戳的 LocalDateTime 序列化器
|
||||
*
|
||||
* @author 老五
|
||||
*/
|
||||
@Slf4j
|
||||
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<>();
|
||||
|
||||
@Override
|
||||
public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
|
||||
// 情况一:有 JsonFormat 自定义注解,则使用它。https://github.com/YunaiV/ruoyi-vue-pro/pull/1019
|
||||
String fieldName = gen.getOutputContext().getCurrentName();
|
||||
if (fieldName != null) {
|
||||
Object currentValue = gen.getOutputContext().getCurrentValue();
|
||||
if (currentValue != null) {
|
||||
Class<?> clazz = currentValue.getClass();
|
||||
Map<String, Field> fieldMap = FIELD_CACHE.computeIfAbsent(clazz, this::buildFieldMap);
|
||||
Field field = fieldMap.get(fieldName);
|
||||
// 进一步修复:https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1480
|
||||
if (field != null && field.isAnnotationPresent(JsonFormat.class)) {
|
||||
JsonFormat jsonFormat = field.getAnnotation(JsonFormat.class);
|
||||
try {
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(jsonFormat.pattern());
|
||||
gen.writeString(formatter.format(value));
|
||||
return;
|
||||
} catch (Exception ex) {
|
||||
log.warn("[serialize][({}#{}) 使用 JsonFormat pattern 失败,尝试使用默认的 Long 时间戳]",
|
||||
clazz.getName(), fieldName, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 情况二:默认将 LocalDateTime 对象,转换为 Long 时间戳
|
||||
// 将 LocalDateTime 对象,转换为 Long 时间戳
|
||||
gen.writeNumber(value.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建字段映射(缓存)
|
||||
*
|
||||
* @param clazz 类
|
||||
* @return 字段映射
|
||||
*/
|
||||
private Map<String, Field> buildFieldMap(Class<?> clazz) {
|
||||
Map<String, Field> fieldMap = new HashMap<>();
|
||||
for (Field field : ReflectUtil.getFields(clazz)) {
|
||||
String fieldName = field.getName();
|
||||
JsonProperty jsonProperty = field.getAnnotation(JsonProperty.class);
|
||||
if (jsonProperty != null) {
|
||||
String value = jsonProperty.value();
|
||||
if (StrUtil.isNotEmpty(value) && ObjUtil.notEqual("\u0000", value)) {
|
||||
fieldName = value;
|
||||
}
|
||||
}
|
||||
fieldMap.put(fieldName, field);
|
||||
}
|
||||
return fieldMap;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,11 +60,6 @@ public class ObjectUtils {
|
|||
return Arrays.asList(array).contains(obj);
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
public static <T> boolean notEqualsAny(T obj, T... array) {
|
||||
return !Arrays.asList(array).contains(obj);
|
||||
}
|
||||
|
||||
public static boolean isNotAllEmpty(Object... objs) {
|
||||
return !ObjectUtil.isAllEmpty(objs);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ 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;
|
||||
|
|
@ -79,25 +78,6 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接方法的参数
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
package cn.iocoder.yudao.framework.common.util.http;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* {@link HttpUtils} 的单元测试
|
||||
*/
|
||||
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() {
|
||||
// 准备参数
|
||||
String url = "https://www.iocoder.cn/path?a=1&b=2";
|
||||
// 调用
|
||||
String result = HttpUtils.replaceUrlQuery(url, "a", "3");
|
||||
// 断言:被替换的 key 会移到末尾,原顺序的其它参数保留
|
||||
assertEquals("https://www.iocoder.cn/path?b=2&a=3", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReplaceUrlQuery_add() {
|
||||
// 准备参数
|
||||
String url = "https://www.iocoder.cn/path?a=1";
|
||||
// 调用
|
||||
String result = HttpUtils.replaceUrlQuery(url, "b", "2");
|
||||
// 断言
|
||||
assertEquals("https://www.iocoder.cn/path?a=1&b=2", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReplaceUrlQuery_noQuery() {
|
||||
// 准备参数:原 URL 没有 query
|
||||
String url = "https://www.iocoder.cn/path";
|
||||
// 调用
|
||||
String result = HttpUtils.replaceUrlQuery(url, "a", "1");
|
||||
// 断言
|
||||
assertEquals("https://www.iocoder.cn/path?a=1", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReplaceUrlQuery_emptyValue() {
|
||||
// 准备参数:value 为空字符串
|
||||
String url = "https://www.iocoder.cn/path?a=1";
|
||||
// 调用
|
||||
String result = HttpUtils.replaceUrlQuery(url, "a", "");
|
||||
// 断言:保留 key,value 为空
|
||||
assertEquals("https://www.iocoder.cn/path?a=", result);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ 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 com.fhs.trans.service.impl.SimpleTransService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.Collections;
|
||||
|
|
@ -32,53 +31,32 @@ public class DataPermissionRuleFactoryImpl implements DataPermissionRuleFactory
|
|||
|
||||
@Override // mappedStatementId 参数,暂时没有用。以后,可以基于 mappedStatementId + DataPermission 进行缓存
|
||||
public List<DataPermissionRule> getDataPermissionRule(String mappedStatementId) {
|
||||
// 1.1 无数据权限
|
||||
// 1. 无数据权限
|
||||
if (CollUtil.isEmpty(rules)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
// 1.2 未配置,则默认开启
|
||||
// 2. 未配置,则默认开启
|
||||
DataPermission dataPermission = DataPermissionContextHolder.get();
|
||||
if (dataPermission == null) {
|
||||
return rules;
|
||||
}
|
||||
// 1.3 已配置,但禁用
|
||||
// 3. 已配置,但禁用
|
||||
if (!dataPermission.enable()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
// 1.4 特殊:数据翻译时,强制忽略数据权限 https://github.com/YunaiV/ruoyi-vue-pro/issues/1007
|
||||
if (isTranslateCall()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// 2.1 情况一:已配置,只选择部分规则
|
||||
// 4. 已配置,只选择部分规则
|
||||
if (ArrayUtil.isNotEmpty(dataPermission.includeRules())) {
|
||||
return rules.stream().filter(rule -> ArrayUtil.contains(dataPermission.includeRules(), rule.getClass()))
|
||||
.collect(Collectors.toList()); // 一般规则不会太多,所以不采用 HashSet 查询
|
||||
}
|
||||
// 2.2 已配置,只排除部分规则
|
||||
// 5. 已配置,只排除部分规则
|
||||
if (ArrayUtil.isNotEmpty(dataPermission.excludeRules())) {
|
||||
return rules.stream().filter(rule -> !ArrayUtil.contains(dataPermission.excludeRules(), rule.getClass()))
|
||||
.collect(Collectors.toList()); // 一般规则不会太多,所以不采用 HashSet 查询
|
||||
}
|
||||
// 2.3 已配置,全部规则
|
||||
// 6. 已配置,全部规则
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为数据翻译 {@link com.fhs.core.trans.anno.Trans} 的调用
|
||||
*
|
||||
* 目前暂时只有这个办法,已经和 easy-trans 做过沟通
|
||||
*
|
||||
* @return 是否
|
||||
*/
|
||||
private boolean isTranslateCall() {
|
||||
StackTraceElement[] stack = Thread.currentThread().getStackTrace();
|
||||
for (StackTraceElement e : stack) {
|
||||
if (SimpleTransService.class.getName().equals(e.getClassName())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ public class DeptDataPermissionRule implements DataPermissionRule {
|
|||
private static final String DEPT_COLUMN_NAME = "dept_id";
|
||||
private static final String USER_COLUMN_NAME = "user_id";
|
||||
|
||||
static final Expression EXPRESSION_NULL = new NullValue();
|
||||
|
||||
private final PermissionCommonApi permissionApi;
|
||||
|
||||
/**
|
||||
|
|
@ -118,7 +120,7 @@ public class DeptDataPermissionRule implements DataPermissionRule {
|
|||
|
||||
// 情况二,即不能查看部门,又不能查看自己,则说明 100% 无权限
|
||||
if (CollUtil.isEmpty(deptDataPermission.getDeptIds())
|
||||
&& Boolean.FALSE.equals(deptDataPermission.getSelf())) {
|
||||
&& Boolean.FALSE.equals(deptDataPermission.getSelf())) {
|
||||
return new EqualsTo(null, null); // WHERE null = null,可以保证返回的数据为空
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +133,7 @@ public class DeptDataPermissionRule implements DataPermissionRule {
|
|||
JsonUtils.toJsonString(loginUser), tableName, tableAlias, JsonUtils.toJsonString(deptDataPermission));
|
||||
// throw new NullPointerException(String.format("LoginUser(%d) Table(%s/%s) 构建的条件为空",
|
||||
// loginUser.getId(), tableName, tableAlias.getName()));
|
||||
return new EqualsTo(null, null); // WHERE null = null,可以保证返回的数据为空
|
||||
return EXPRESSION_NULL;
|
||||
}
|
||||
if (deptExpression == null) {
|
||||
return userExpression;
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ package cn.iocoder.yudao.framework.datapermission.core.rule.dept;
|
|||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.ReflectUtil;
|
||||
import cn.iocoder.yudao.framework.common.biz.system.permission.PermissionCommonApi;
|
||||
import cn.iocoder.yudao.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO;
|
||||
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
|
||||
import cn.iocoder.yudao.framework.common.util.collection.SetUtils;
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.yudao.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO;
|
||||
import net.sf.jsqlparser.expression.Alias;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
|
@ -20,6 +20,7 @@ import org.mockito.MockedStatic;
|
|||
import java.util.Map;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
import static cn.iocoder.yudao.framework.datapermission.core.rule.dept.DeptDataPermissionRule.EXPRESSION_NULL;
|
||||
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
|
||||
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
|
@ -150,7 +151,7 @@ class DeptDataPermissionRuleTest extends BaseMockitoUnitTest {
|
|||
// 调用
|
||||
Expression expression = rule.getExpression(tableName, tableAlias);
|
||||
// 断言
|
||||
assertEquals("null = null", expression.toString());
|
||||
assertSame(EXPRESSION_NULL, expression);
|
||||
assertSame(deptDataPermission, loginUser.getContext(DeptDataPermissionRule.CONTEXT_KEY, DeptDataPermissionRespDTO.class));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
|
|||
import cn.iocoder.yudao.framework.ip.core.Area;
|
||||
import cn.iocoder.yudao.framework.ip.core.enums.AreaTypeEnum;
|
||||
import lombok.NonNull;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
|
@ -26,46 +25,44 @@ import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.
|
|||
* @author 芋道源码
|
||||
*/
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class AreaUtils {
|
||||
|
||||
/**
|
||||
* 初始化 SEARCHER
|
||||
*/
|
||||
@SuppressWarnings("InstantiationOfUtilityClass")
|
||||
private final static AreaUtils INSTANCE = new AreaUtils();
|
||||
|
||||
/**
|
||||
* Area 内存缓存,提升访问速度
|
||||
*/
|
||||
private static Map<Integer, Area> areas;
|
||||
|
||||
static {
|
||||
init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化
|
||||
*/
|
||||
private static void init() {
|
||||
try {
|
||||
long now = System.currentTimeMillis();
|
||||
areas = new HashMap<>();
|
||||
areas.put(Area.ID_GLOBAL, new Area(Area.ID_GLOBAL, "全球", 0, null, new ArrayList<>()));
|
||||
// 从 csv 中加载数据
|
||||
List<CsvRow> rows = CsvUtil.getReader().read(ResourceUtil.getUtf8Reader("area.csv")).getRows();
|
||||
rows.remove(0); // 删除 header
|
||||
for (CsvRow row : rows) {
|
||||
Area area = new Area(Integer.valueOf(row.get(0)), row.get(1), Integer.valueOf(row.get(2)), null, new ArrayList<>());
|
||||
areas.put(area.getId(), area);
|
||||
}
|
||||
|
||||
// 构建父子关系:因为 Area 中没有 parentId 字段,所以需要重复读取
|
||||
for (CsvRow row : rows) {
|
||||
Area area = areas.get(Integer.valueOf(row.get(0))); // 自己
|
||||
Area parent = areas.get(Integer.valueOf(row.get(3))); // 父
|
||||
Assert.isTrue(area != parent, "{}:父子节点相同", area.getName());
|
||||
area.setParent(parent);
|
||||
parent.getChildren().add(area);
|
||||
}
|
||||
log.info("启动加载 AreaUtils 成功,耗时 ({}) 毫秒", System.currentTimeMillis() - now);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("AreaUtils 初始化失败", e);
|
||||
private AreaUtils() {
|
||||
long now = System.currentTimeMillis();
|
||||
areas = new HashMap<>();
|
||||
areas.put(Area.ID_GLOBAL, new Area(Area.ID_GLOBAL, "全球", 0,
|
||||
null, new ArrayList<>()));
|
||||
// 从 csv 中加载数据
|
||||
List<CsvRow> rows = CsvUtil.getReader().read(ResourceUtil.getUtf8Reader("area.csv")).getRows();
|
||||
rows.remove(0); // 删除 header
|
||||
for (CsvRow row : rows) {
|
||||
// 创建 Area 对象
|
||||
Area area = new Area(Integer.valueOf(row.get(0)), row.get(1), Integer.valueOf(row.get(2)),
|
||||
null, new ArrayList<>());
|
||||
// 添加到 areas 中
|
||||
areas.put(area.getId(), area);
|
||||
}
|
||||
|
||||
// 构建父子关系:因为 Area 中没有 parentId 字段,所以需要重复读取
|
||||
for (CsvRow row : rows) {
|
||||
Area area = areas.get(Integer.valueOf(row.get(0))); // 自己
|
||||
Area parent = areas.get(Integer.valueOf(row.get(3))); // 父
|
||||
Assert.isTrue(area != parent, "{}:父子节点相同", area.getName());
|
||||
area.setParent(parent);
|
||||
parent.getChildren().add(area);
|
||||
}
|
||||
log.info("启动加载 AreaUtils 成功,耗时 ({}) 毫秒", System.currentTimeMillis() - now);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ package cn.iocoder.yudao.framework.ip.core.utils;
|
|||
import cn.hutool.core.io.resource.ResourceUtil;
|
||||
import cn.iocoder.yudao.framework.ip.core.Area;
|
||||
import lombok.SneakyThrows;
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.lionsoul.ip2region.xdb.Searcher;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* IP 工具类
|
||||
*
|
||||
|
|
@ -15,29 +16,30 @@ import org.lionsoul.ip2region.xdb.Searcher;
|
|||
* @author wanglhup
|
||||
*/
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class IPUtils {
|
||||
|
||||
/**
|
||||
* 初始化 SEARCHER
|
||||
*/
|
||||
@SuppressWarnings("InstantiationOfUtilityClass")
|
||||
private final static IPUtils INSTANCE = new IPUtils();
|
||||
|
||||
/**
|
||||
* IP 查询器,启动加载到内存中
|
||||
*/
|
||||
private static Searcher SEARCHER;
|
||||
|
||||
static {
|
||||
init();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化
|
||||
* 私有化构造
|
||||
*/
|
||||
private static void init() {
|
||||
private IPUtils() {
|
||||
try {
|
||||
long now = System.currentTimeMillis();
|
||||
byte[] bytes = ResourceUtil.readBytes("ip2region.xdb");
|
||||
SEARCHER = Searcher.newWithBuffer(bytes);
|
||||
log.info("启动加载 IPUtils 成功,耗时 ({}) 毫秒", System.currentTimeMillis() - now);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("IPUtils 初始化失败", e);
|
||||
} catch (IOException e) {
|
||||
log.error("启动加载 IPUtils 失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,12 +5,7 @@ import cn.iocoder.yudao.framework.ip.core.Area;
|
|||
import cn.iocoder.yudao.framework.ip.core.enums.AreaTypeEnum;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* {@link AreaUtils} 的单元测试
|
||||
|
|
@ -36,46 +31,6 @@ public class AreaUtilsTest {
|
|||
assertEquals(AreaUtils.format(110105), "北京市 北京市 朝阳区");
|
||||
assertEquals(AreaUtils.format(1), "中国");
|
||||
assertEquals(AreaUtils.format(2), "蒙古");
|
||||
// 中国台湾省:省/市/区三级
|
||||
assertEquals(AreaUtils.format(710101), "台湾省 台北市 中正区");
|
||||
// 自定义分隔符
|
||||
assertEquals(AreaUtils.format(110105, "/"), "北京市/北京市/朝阳区");
|
||||
// 不存在的编号
|
||||
assertNull(AreaUtils.format(-1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testParseArea() {
|
||||
// 调用:通过路径解析得到地区
|
||||
Area area = AreaUtils.parseArea("北京市/北京市/朝阳区");
|
||||
// 断言
|
||||
assertNotNull(area);
|
||||
assertEquals(area.getId(), 110105);
|
||||
// 路径不存在时返回 null
|
||||
assertNull(AreaUtils.parseArea("不存在/路径"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetParentIdByType() {
|
||||
// 调用:朝阳区向上找省
|
||||
Integer provinceId = AreaUtils.getParentIdByType(110105, AreaTypeEnum.PROVINCE);
|
||||
// 断言
|
||||
assertEquals(provinceId, 110000);
|
||||
// 自身就是目标类型
|
||||
assertEquals(AreaUtils.getParentIdByType(110000, AreaTypeEnum.PROVINCE), 110000);
|
||||
// 不存在的编号返回 null
|
||||
assertNull(AreaUtils.getParentIdByType(-1, AreaTypeEnum.PROVINCE));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetByType() {
|
||||
// 调用:获取所有省份
|
||||
List<Area> provinces = AreaUtils.getByType(AreaTypeEnum.PROVINCE, area -> area);
|
||||
// 断言:包含北京、台湾、香港、澳门
|
||||
assertTrue(provinces.stream().anyMatch(area -> "北京市".equals(area.getName())));
|
||||
assertTrue(provinces.stream().anyMatch(area -> "台湾省".equals(area.getName())));
|
||||
assertTrue(provinces.stream().anyMatch(area -> "香港特别行政区".equals(area.getName())));
|
||||
assertTrue(provinces.stream().anyMatch(area -> "澳门特别行政区".equals(area.getName())));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package cn.iocoder.yudao.framework.tenant.config;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.biz.system.tenant.TenantCommonApi;
|
||||
import cn.iocoder.yudao.framework.common.enums.WebFilterOrderEnum;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
|
||||
|
|
@ -22,7 +23,6 @@ import cn.iocoder.yudao.framework.web.config.WebProperties;
|
|||
import cn.iocoder.yudao.framework.web.core.handler.GlobalExceptionHandler;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||
import javax.annotation.Resource;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
|
|
@ -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;
|
||||
|
|
@ -44,7 +45,11 @@ 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 javax.annotation.Resource;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
|
||||
|
||||
|
|
@ -58,6 +63,13 @@ public class YudaoTenantAutoConfiguration {
|
|||
|
||||
@Bean
|
||||
public TenantFrameworkService tenantFrameworkService(TenantCommonApi tenantApi) {
|
||||
// 参见 https://gitee.com/zhijiantianya/yudao-cloud/issues/IC6YZF
|
||||
try {
|
||||
TenantCommonApi tenantApiImpl = SpringUtil.getBean("tenantApiImpl", TenantCommonApi.class);
|
||||
if (tenantApiImpl != null) {
|
||||
tenantApi = tenantApiImpl;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return new TenantFrameworkServiceImpl(tenantApi);
|
||||
}
|
||||
|
||||
|
|
@ -155,9 +167,20 @@ public class YudaoTenantAutoConfiguration {
|
|||
|
||||
// ========== MQ ==========
|
||||
|
||||
@Bean
|
||||
public TenantRedisMessageInterceptor tenantRedisMessageInterceptor() {
|
||||
return new TenantRedisMessageInterceptor();
|
||||
/**
|
||||
* 多租户 Redis 消息队列的配置类
|
||||
*
|
||||
* 为什么要单独一个配置类呢?如果直接把 TenantRedisMessageInterceptor Bean 的初始化放外面,会报 RedisMessageInterceptor 类不存在的错误
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnClass(name = "cn.iocoder.yudao.framework.mq.redis.core.RedisMQTemplate")
|
||||
public static class TenantRedisMQAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
public TenantRedisMessageInterceptor tenantRedisMessageInterceptor() {
|
||||
return new TenantRedisMessageInterceptor();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
|
@ -175,6 +198,7 @@ public class YudaoTenantAutoConfiguration {
|
|||
// ========== Job ==========
|
||||
|
||||
@Bean
|
||||
@ConditionalOnClass(name = "com.xxl.job.core.handler.annotation.XxlJob")
|
||||
public TenantJobAspect tenantJobAspect(TenantFrameworkService tenantFrameworkService) {
|
||||
return new TenantJobAspect(tenantFrameworkService);
|
||||
}
|
||||
|
|
@ -192,12 +216,7 @@ public class YudaoTenantAutoConfiguration {
|
|||
RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory,
|
||||
BatchStrategies.scan(yudaoCacheProperties.getRedisScanBatchSize()));
|
||||
// 创建 TenantRedisCacheManager 对象
|
||||
TenantRedisCacheManager cacheManager = new TenantRedisCacheManager(cacheWriter, redisCacheConfiguration,
|
||||
tenantProperties.getIgnoreCaches());
|
||||
// 开启事务感知:@Transactional 方法内的 @CacheEvict / @CachePut 自动延迟到 afterCommit,
|
||||
// 避免事务未提交就清缓存被并发读穿写脏值;无事务时立即生效,行为不变
|
||||
cacheManager.setTransactionAware(true);
|
||||
return cacheManager;
|
||||
return new TenantRedisCacheManager(cacheWriter, redisCacheConfiguration, tenantProperties.getIgnoreCaches());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package cn.iocoder.yudao.framework.tenant.core.redis;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.redis.core.TimeoutRedisCacheManager;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
|
@ -22,8 +21,6 @@ import java.util.Set;
|
|||
@Slf4j
|
||||
public class TenantRedisCacheManager extends TimeoutRedisCacheManager {
|
||||
|
||||
private static final String SPLIT = "#";
|
||||
|
||||
private final Set<String> ignoreCaches;
|
||||
|
||||
public TenantRedisCacheManager(RedisCacheWriter cacheWriter,
|
||||
|
|
@ -35,11 +32,10 @@ public class TenantRedisCacheManager extends TimeoutRedisCacheManager {
|
|||
|
||||
@Override
|
||||
public Cache getCache(String name) {
|
||||
String[] names = StrUtil.splitToArray(name, SPLIT);
|
||||
// 如果开启多租户,则 name 拼接租户后缀
|
||||
if (!TenantContextHolder.isIgnore()
|
||||
&& TenantContextHolder.getTenantId() != null
|
||||
&& !CollUtil.contains(ignoreCaches, names[0])) {
|
||||
&& !CollUtil.contains(ignoreCaches, name)) {
|
||||
name = name + ":" + TenantContextHolder.getTenantId();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@
|
|||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>javax.servlet</groupId>
|
||||
<artifactId>javax.servlet-api</artifactId>
|
||||
<groupId>jakarta.servlet</groupId>
|
||||
<artifactId>jakarta.servlet-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- RPC 相关 -->
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@
|
|||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>javax.servlet</groupId>
|
||||
<artifactId>javax.servlet-api</artifactId>
|
||||
<groupId>jakarta.servlet</groupId>
|
||||
<artifactId>jakarta.servlet-api</artifactId>
|
||||
<scope>provided</scope> <!-- 设置为 provided,只有 ExcelUtils 使用 -->
|
||||
</dependency>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package cn.iocoder.yudao.framework.excel.core.util;
|
|||
|
||||
import cn.idev.excel.FastExcelFactory;
|
||||
import cn.idev.excel.converters.longconverter.LongStringConverter;
|
||||
import cn.idev.excel.write.style.column.LongestMatchColumnWidthStyleStrategy;
|
||||
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
|
||||
import cn.iocoder.yudao.framework.excel.core.handler.ColumnWidthMatchStyleStrategy;
|
||||
import cn.iocoder.yudao.framework.excel.core.handler.SelectSheetWriteHandler;
|
||||
|
|
@ -9,7 +10,6 @@ import org.springframework.web.multipart.MultipartFile;
|
|||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
|
|
@ -45,12 +45,9 @@ public class ExcelUtils {
|
|||
}
|
||||
|
||||
public static <T> List<T> read(MultipartFile file, Class<T> head) throws IOException {
|
||||
// 参考 https://t.zsxq.com/zM77F 帖子,增加 try 处理,兼容 windows 场景
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
return FastExcelFactory.read(inputStream, head, null)
|
||||
.autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理
|
||||
.doReadAllSync();
|
||||
}
|
||||
return FastExcelFactory.read(file.getInputStream(), head, null)
|
||||
.autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理
|
||||
.doReadAllSync();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@
|
|||
|
||||
<!-- 工具类相关 -->
|
||||
<dependency>
|
||||
<groupId>javax.validation</groupId>
|
||||
<artifactId>validation-api</artifactId>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@
|
|||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>javax.servlet</groupId>
|
||||
<artifactId>javax.servlet-api</artifactId>
|
||||
<groupId>jakarta.servlet</groupId>
|
||||
<artifactId>jakarta.servlet-api</artifactId>
|
||||
<scope>provided</scope> <!-- 设置为 provided,只有 TraceFilter 使用 -->
|
||||
</dependency>
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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;
|
||||
|
|
@ -69,8 +70,7 @@ public class YudaoRedisMQConsumerAutoConfiguration {
|
|||
public RedisPendingMessageResendJob redisPendingMessageResendJob(List<AbstractRedisStreamMessageListener<?>> listeners,
|
||||
RedisMQTemplate redisTemplate,
|
||||
RedissonClient redissonClient) {
|
||||
return new RedisPendingMessageResendJob(listeners, redisTemplate, redissonClient,
|
||||
RedisPendingMessageResendJob.DEFAULT_RESEND_LOCK_KEY);
|
||||
return new RedisPendingMessageResendJob(listeners, redisTemplate, redissonClient);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -81,8 +81,7 @@ public class YudaoRedisMQConsumerAutoConfiguration {
|
|||
public RedisStreamMessageCleanupJob redisStreamMessageCleanupJob(List<AbstractRedisStreamMessageListener<?>> listeners,
|
||||
RedisMQTemplate redisTemplate,
|
||||
RedissonClient redissonClient) {
|
||||
return new RedisStreamMessageCleanupJob(listeners, redisTemplate, redissonClient,
|
||||
RedisStreamMessageCleanupJob.DEFAULT_CLEANUP_LOCK_KEY);
|
||||
return new RedisStreamMessageCleanupJob(listeners, redisTemplate, redissonClient);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -23,9 +23,7 @@ import java.util.Objects;
|
|||
@AllArgsConstructor
|
||||
public class RedisPendingMessageResendJob {
|
||||
|
||||
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";
|
||||
private static final String LOCK_KEY = "redis:stream:pending-message-resend:lock";
|
||||
|
||||
/**
|
||||
* 消息超时时间,默认 5 分钟
|
||||
|
|
@ -38,26 +36,22 @@ 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(resendLockKey);
|
||||
RLock lock = redissonClient.getLock(LOCK_KEY);
|
||||
// 尝试加锁
|
||||
if (lock.tryLock()) {
|
||||
try {
|
||||
execute();
|
||||
} catch (Exception ex) {
|
||||
log.error("[messageResend][执行异常][lockKey={}]", resendLockKey, ex);
|
||||
log.error("[messageResend][执行异常]", ex);
|
||||
} finally {
|
||||
if (lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
lock.unlock();
|
||||
}
|
||||
} else {
|
||||
log.debug("[messageResend][未获取到锁,跳过本轮][lockKey={}]", resendLockKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,16 +23,7 @@ import java.util.List;
|
|||
@AllArgsConstructor
|
||||
public class RedisStreamMessageCleanupJob {
|
||||
|
||||
/**
|
||||
* 业务 MQ(Spring 容器内 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";
|
||||
private static final String LOCK_KEY = "redis:stream:message-cleanup:lock";
|
||||
|
||||
/**
|
||||
* 保留的消息数量,默认保留最近 10000 条消息
|
||||
|
|
@ -42,29 +33,22 @@ 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(cleanupLockKey);
|
||||
RLock lock = redissonClient.getLock(LOCK_KEY);
|
||||
// 尝试加锁
|
||||
if (lock.tryLock()) {
|
||||
try {
|
||||
execute();
|
||||
} catch (Exception ex) {
|
||||
log.error("[cleanup][执行异常][lockKey={}]", cleanupLockKey, ex);
|
||||
log.error("[cleanup][执行异常]", ex);
|
||||
} finally {
|
||||
if (lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
}
|
||||
lock.unlock();
|
||||
}
|
||||
} else {
|
||||
log.debug("[cleanup][未获取到锁,跳过本轮][lockKey={}]", cleanupLockKey);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -75,8 +59,8 @@ public class RedisStreamMessageCleanupJob {
|
|||
StreamOperations<String, Object, Object> ops = redisTemplate.getRedisTemplate().opsForStream();
|
||||
listeners.forEach(listener -> {
|
||||
try {
|
||||
// 使用 XTRIM MAXLEN 精确裁剪(approximate=false),避免 ~ 模式下长期明显高于上限
|
||||
Long trimCount = ops.trim(listener.getStreamKey(), MAX_COUNT, false);
|
||||
// 使用 XTRIM 命令清理消息,只保留最近的 MAX_LEN 条消息
|
||||
Long trimCount = ops.trim(listener.getStreamKey(), MAX_COUNT, true);
|
||||
if (trimCount != null && trimCount > 0) {
|
||||
log.info("[execute][Stream({}) 清理消息数量({})]", listener.getStreamKey(), trimCount);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,13 +94,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.env.EnvironmentPostProcessor;
|
||||
import org.springframework.core.env.ConfigurableEnvironment;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
|
|
@ -59,19 +56,11 @@ public class IdTypeEnvironmentPostProcessor implements EnvironmentPostProcessor
|
|||
}
|
||||
|
||||
public IdType getIdType(ConfigurableEnvironment environment) {
|
||||
String value = environment.getProperty(ID_TYPE_KEY);
|
||||
try {
|
||||
return StrUtil.isNotBlank(value) ? IdType.valueOf(value) : IdType.NONE;
|
||||
} catch (IllegalArgumentException ex) {
|
||||
log.error("[getIdType][无法解析 id-type 配置值({})]", value, ex);
|
||||
return IdType.NONE;
|
||||
}
|
||||
return environment.getProperty(ID_TYPE_KEY, IdType.class);
|
||||
}
|
||||
|
||||
public void setIdType(ConfigurableEnvironment environment, IdType idType) {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put(ID_TYPE_KEY, idType);
|
||||
environment.getPropertySources().addFirst(new MapPropertySource("mybatisPlusIdType", map));
|
||||
environment.getSystemProperties().put(ID_TYPE_KEY, idType);
|
||||
log.info("[setIdType][修改 MyBatis Plus 的 idType 为({})]", idType);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,49 +20,51 @@ public enum DbTypeEnum {
|
|||
|
||||
/**
|
||||
* H2
|
||||
*
|
||||
* 注意:H2 不支持 find_in_set 函数
|
||||
*/
|
||||
H2(DbType.H2, "H2", "POSITION(',' || CAST(#{value} AS VARCHAR) || ',' IN ',' || #{column} || ',') > 0"),
|
||||
H2(DbType.H2, "H2", ""),
|
||||
|
||||
/**
|
||||
* 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", "INSTR(',' || #{column} || ',', ',' || #{value} || ',') > 0"),
|
||||
ORACLE(DbType.ORACLE, "Oracle", "FIND_IN_SET('#{value}', #{column}) <> 0"),
|
||||
|
||||
/**
|
||||
* PostgreSQL
|
||||
*
|
||||
* 华为 openGauss 使用 ProductName 与 PostgreSQL 相同
|
||||
*/
|
||||
POSTGRE_SQL(DbType.POSTGRE_SQL, "PostgreSQL", "POSITION(',' || CAST(#{value} AS VARCHAR) || ',' IN ',' || #{column} || ',') > 0"),
|
||||
POSTGRE_SQL(DbType.POSTGRE_SQL,"PostgreSQL", "POSITION('#{value}' IN #{column}) <> 0"),
|
||||
|
||||
/**
|
||||
* SQL Server
|
||||
*/
|
||||
SQL_SERVER(DbType.SQL_SERVER, "Microsoft SQL Server", "CHARINDEX(',' + CAST(#{value} AS varchar(255)) + ',', ',' + #{column} + ',') > 0"),
|
||||
SQL_SERVER(DbType.SQL_SERVER, "Microsoft SQL Server", "CHARINDEX(',' + #{value} + ',', ',' + #{column} + ',') <> 0"),
|
||||
/**
|
||||
* SQL Server 2005
|
||||
*/
|
||||
SQL_SERVER2005(DbType.SQL_SERVER2005, "Microsoft SQL Server 2005", "CHARINDEX(',' + CAST(#{value} AS varchar(255)) + ',', ',' + #{column} + ',') > 0"),
|
||||
SQL_SERVER2005(DbType.SQL_SERVER2005, "Microsoft SQL Server 2005", "CHARINDEX(',' + #{value} + ',', ',' + #{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(',' || CAST(#{value} AS VARCHAR) || ',' IN ',' || #{column} || ',') > 0"),
|
||||
KINGBASE_ES(DbType.KINGBASE_ES, "KingbaseES", "POSITION('#{value}' 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")
|
||||
|
||||
;
|
||||
|
||||
|
|
@ -93,9 +95,7 @@ public enum DbTypeEnum {
|
|||
}
|
||||
|
||||
public static String getFindInSetTemplate(DbType dbType) {
|
||||
return Optional.ofNullable(MAP_BY_MP.get(dbType))
|
||||
.map(DbTypeEnum::getFindInSetTemplate)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
return Optional.of(MAP_BY_MP.get(dbType).getFindInSetTemplate())
|
||||
.orElseThrow(() -> new IllegalArgumentException("FIND_IN_SET not supported"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,29 +68,6 @@ public interface BaseMapperX<T> extends MPJBaseMapper<T> {
|
|||
return new PageResult<>(mpPage.getRecords(), mpPage.getTotal());
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行分页查询并返回结果。
|
||||
*
|
||||
* @param pageParam 分页参数,包含页码、每页条数和排序字段信息。如果 pageSize 为 {@link PageParam#PAGE_SIZE_NONE},则不分页,直接查询所有数据。
|
||||
* @param clazz 结果集的类类型
|
||||
* @param lambdaWrapper MyBatis Plus Join 查询条件包装器
|
||||
* @param <D> 结果集的泛型类型
|
||||
* @return 返回分页查询的结果,包括总记录数和当前页的数据列表
|
||||
*/
|
||||
default <D> PageResult<D> selectJoinPage(SortablePageParam pageParam, Class<D> clazz, MPJLambdaWrapper<T> lambdaWrapper) {
|
||||
// 特殊:不分页,直接查询全部
|
||||
if (PageParam.PAGE_SIZE_NONE.equals(pageParam.getPageSize())) {
|
||||
List<D> list = selectJoinList(clazz, lambdaWrapper);
|
||||
return new PageResult<>(list, (long) list.size());
|
||||
}
|
||||
|
||||
// MyBatis Plus Join 查询
|
||||
IPage<D> mpPage = MyBatisUtils.buildPage(pageParam, pageParam.getSortingFields());
|
||||
mpPage = selectJoinPage(mpPage, clazz, lambdaWrapper);
|
||||
// 转换返回
|
||||
return new PageResult<>(mpPage.getRecords(), mpPage.getTotal());
|
||||
}
|
||||
|
||||
default <DTO> PageResult<DTO> selectJoinPage(PageParam pageParam, Class<DTO> resultTypeClass, MPJBaseJoin<T> joinQueryWrapper) {
|
||||
IPage<DTO> mpPage = MyBatisUtils.buildPage(pageParam);
|
||||
selectJoinPage(mpPage, resultTypeClass, joinQueryWrapper);
|
||||
|
|
@ -119,31 +96,6 @@ public interface BaseMapperX<T> extends MPJBaseMapper<T> {
|
|||
return selectOne(new LambdaQueryWrapper<T>().eq(field1, value1).eq(field2, value2).eq(field3, value3));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得满足条件的一条记录,并使用 FOR UPDATE 锁定。
|
||||
*
|
||||
* 注意:需要在事务中调用,否则锁会立即释放。
|
||||
*
|
||||
* @param queryWrapper 查询条件
|
||||
* @return 实体
|
||||
*/
|
||||
default T selectOneForUpdate(LambdaQueryWrapper<T> queryWrapper) {
|
||||
return selectOne(queryWrapper.last("FOR UPDATE"));
|
||||
}
|
||||
|
||||
default T selectOneForUpdate(SFunction<T, ?> field, Object value) {
|
||||
return selectOneForUpdate(new LambdaQueryWrapper<T>().eq(field, value));
|
||||
}
|
||||
|
||||
default T selectOneForUpdate(SFunction<T, ?> field1, Object value1, SFunction<T, ?> field2, Object value2) {
|
||||
return selectOneForUpdate(new LambdaQueryWrapper<T>().eq(field1, value1).eq(field2, value2));
|
||||
}
|
||||
|
||||
default T selectOneForUpdate(SFunction<T, ?> field1, Object value1, SFunction<T, ?> field2, Object value2,
|
||||
SFunction<T, ?> field3, Object value3) {
|
||||
return selectOneForUpdate(new LambdaQueryWrapper<T>().eq(field1, value1).eq(field2, value2).eq(field3, value3));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取满足条件的第 1 条记录
|
||||
*
|
||||
|
|
@ -170,17 +122,6 @@ 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<>());
|
||||
|
|
|
|||
|
|
@ -24,12 +24,6 @@ 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)) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import java.util.function.Consumer;
|
|||
* <p>
|
||||
* 1. 拼接条件的方法,增加 xxxIfPresent 方法,用于判断值不存在的时候,不要拼接到条件中。
|
||||
* 2. SFunction<S, ?> column + <S> 泛型:支持任意类字段(主表、子表、三表),推荐写法, 让编译器自动推断 S 类型
|
||||
*
|
||||
* @param <T> 数据类型
|
||||
*/
|
||||
public class MPJLambdaWrapperX<T> extends MPJLambdaWrapper<T> {
|
||||
|
|
@ -27,13 +26,6 @@ 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);
|
||||
|
|
@ -109,6 +101,7 @@ public class MPJLambdaWrapperX<T> extends MPJLambdaWrapper<T> {
|
|||
return this;
|
||||
}
|
||||
|
||||
|
||||
// ========== 重写父类方法,方便链式调用 ==========
|
||||
|
||||
@Override
|
||||
|
|
@ -129,12 +122,6 @@ public class MPJLambdaWrapperX<T> extends MPJLambdaWrapper<T> {
|
|||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <X> MPJLambdaWrapperX<T> orderByAsc(SFunction<X, ?> column) {
|
||||
super.orderByAsc(true, column);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public MPJLambdaWrapperX<T> last(String lastSql) {
|
||||
super.last(lastSql);
|
||||
|
|
|
|||
|
|
@ -25,13 +25,6 @@ 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);
|
||||
|
|
@ -102,13 +95,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;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import net.sf.jsqlparser.schema.Table;
|
|||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* MyBatis 工具类
|
||||
|
|
@ -32,12 +31,6 @@ 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);
|
||||
}
|
||||
|
|
@ -45,15 +38,11 @@ public class MyBatisUtils {
|
|||
public static <T> Page<T> buildPage(PageParam pageParam, Collection<SortingField> sortingFields) {
|
||||
// 页码 + 数量
|
||||
Page<T> page = new Page<>(pageParam.getPageNo(), pageParam.getPageSize());
|
||||
page.setOptimizeJoinOfCountSql(false); // 关联 issue:https://gitee.com/zhijiantianya/yudao-cloud/issues/ID2QLL
|
||||
// 排序字段
|
||||
if (CollUtil.isNotEmpty(sortingFields)) {
|
||||
for (SortingField sortingField : sortingFields) {
|
||||
String columnName = buildSafeOrderColumn(sortingField.getField());
|
||||
if (columnName == null) {
|
||||
continue;
|
||||
}
|
||||
page.addOrder(new OrderItem().setAsc(isAscOrder(sortingField.getOrder())).setColumn(columnName));
|
||||
page.addOrder(new OrderItem().setAsc(SortingField.ORDER_ASC.equals(sortingField.getOrder()))
|
||||
.setColumn(StrUtil.toUnderlineCase(sortingField.getField())));
|
||||
}
|
||||
}
|
||||
return page;
|
||||
|
|
@ -67,29 +56,23 @@ public class MyBatisUtils {
|
|||
if (wrapper instanceof QueryWrapper) {
|
||||
QueryWrapper<T> query = (QueryWrapper<T>) wrapper;
|
||||
for (SortingField sortingField : sortingFields) {
|
||||
String columnName = buildSafeOrderColumn(sortingField.getField());
|
||||
if (columnName == null) {
|
||||
continue;
|
||||
}
|
||||
query.orderBy(true, isAscOrder(sortingField.getOrder()), columnName);
|
||||
query.orderBy(true,
|
||||
SortingField.ORDER_ASC.equals(sortingField.getOrder()),
|
||||
StrUtil.toUnderlineCase(sortingField.getField()));
|
||||
}
|
||||
} else if (wrapper instanceof LambdaQueryWrapper) {
|
||||
// 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(columnName).append(" ").append(getOrderDirection(sortingField.getOrder()));
|
||||
}
|
||||
if (StrUtil.isNotEmpty(orderBy)) {
|
||||
lambdaQuery.last("ORDER BY " + orderBy);
|
||||
orderBy.append(StrUtil.toUnderlineCase(sortingField.getField()))
|
||||
.append(" ")
|
||||
.append(SortingField.ORDER_ASC.equals(sortingField.getOrder()) ? "ASC" : "DESC");
|
||||
}
|
||||
lambdaQuery.last("ORDER BY " + orderBy);
|
||||
// 另外个思路:https://blog.csdn.net/m0_59084856/article/details/138450913
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported wrapper type: " + wrapper.getClass().getName());
|
||||
|
|
@ -97,22 +80,6 @@ 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 不支持添加拦截器,所以只能全量设置
|
||||
|
|
@ -161,43 +128,15 @@ public class MyBatisUtils {
|
|||
/**
|
||||
* 跨数据库的 find_in_set 实现
|
||||
*
|
||||
* @param columnName 字段名称
|
||||
* @param column 字段名称
|
||||
* @param value 查询值(不带单引号)
|
||||
* @return sql
|
||||
*/
|
||||
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) {
|
||||
public static String findInSet(String column, Object value) {
|
||||
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(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();
|
||||
.replace("#{column}", column)
|
||||
.replace("#{value}", StrUtil.toString(value));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,173 +0,0 @@
|
|||
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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -75,12 +75,8 @@ public class YudaoCacheAutoConfiguration {
|
|||
RedisConnectionFactory connectionFactory = Objects.requireNonNull(redisTemplate.getConnectionFactory());
|
||||
RedisCacheWriter cacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(connectionFactory,
|
||||
BatchStrategies.scan(yudaoCacheProperties.getRedisScanBatchSize()));
|
||||
// 创建 TimeoutRedisCacheManager 对象
|
||||
TimeoutRedisCacheManager cacheManager = new TimeoutRedisCacheManager(cacheWriter, redisCacheConfiguration);
|
||||
// 开启事务感知:@Transactional 方法内的 @CacheEvict / @CachePut 自动延迟到 afterCommit,
|
||||
// 避免事务未提交就清缓存被并发读穿写脏值;无事务时立即生效,行为不变
|
||||
cacheManager.setTransactionAware(true);
|
||||
return cacheManager;
|
||||
// 创建 TenantRedisCacheManager 对象
|
||||
return new TimeoutRedisCacheManager(cacheWriter, redisCacheConfiguration);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,22 +36,11 @@
|
|||
<groupId>io.github.openfeign</groupId>
|
||||
<artifactId>feign-okhttp</artifactId>
|
||||
</dependency>
|
||||
<!--
|
||||
TODO 芋艿:WxJava 4.8.x 的 AbstractWxMpConfigStorageConfiguration 仍引用了 HttpClient 4.x 的
|
||||
org.apache.http.ssl.TrustStrategy 类。升级 Spring Cloud Alibaba 到 2025.0.0.0 后,Nacos 不再
|
||||
传递 HttpClient 4.x(httpcore),导致 ClassNotFoundException。
|
||||
临时解决:显式引入 httpclient 4.x。待 WxJava 修复后移除。
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>org.apache.httpcomponents</groupId>
|
||||
<artifactId>httpclient</artifactId>
|
||||
<version>4.5.14</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 工具相关 -->
|
||||
<dependency>
|
||||
<groupId>javax.validation</groupId>
|
||||
<artifactId>validation-api</artifactId>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import uk.co.jemos.podam.api.PodamFactory;
|
|||
import uk.co.jemos.podam.api.PodamFactoryImpl;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
|
|
@ -53,10 +52,6 @@ public class RandomUtils {
|
|||
}
|
||||
return RandomUtil.randomInt();
|
||||
});
|
||||
// BigDecimal:限制精度在 DECIMAL(10,2) 范围内,避免 H2 等数据库溢出
|
||||
PODAM_FACTORY.getStrategy().addOrReplaceTypeManufacturer(BigDecimal.class,
|
||||
(dataProviderStrategy, attributeMetadata, map) ->
|
||||
BigDecimal.valueOf(RandomUtil.randomInt(0, 10000000), 2));
|
||||
// LocalDateTime
|
||||
PODAM_FACTORY.getStrategy().addOrReplaceTypeManufacturer(LocalDateTime.class,
|
||||
(dataProviderStrategy, attributeMetadata, map) -> randomLocalDateTime());
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ public class ApiAccessLogFilter extends ApiRequestFilter {
|
|||
LocalDateTime beginTime = LocalDateTime.now();
|
||||
// 提前获得参数,避免 XssFilter 过滤处理
|
||||
Map<String, String> queryString = ServletUtils.getParamMap(request);
|
||||
String requestBody = ServletUtils.getBody(request);
|
||||
String requestBody = ServletUtils.isJsonRequest(request) ? ServletUtils.getBody(request) : null;
|
||||
|
||||
try {
|
||||
// 继续过滤器
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ public class ApiAccessLogInterceptor implements HandlerInterceptor {
|
|||
// 打印 request 日志
|
||||
if (!SpringUtils.isProd()) {
|
||||
Map<String, String> queryString = ServletUtils.getParamMap(request);
|
||||
String requestBody = ServletUtils.getBody(request);
|
||||
String requestBody = ServletUtils.isJsonRequest(request) ? ServletUtils.getBody(request) : null;
|
||||
if (CollUtil.isEmpty(queryString) && StrUtil.isEmpty(requestBody)) {
|
||||
log.info("[preHandle][开始请求 URL({}) 无参数]", request.getRequestURI());
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -37,14 +37,8 @@ public class BannerApplicationRunner implements ApplicationRunner {
|
|||
System.out.println("[商城系统 yudao-module-mall 教程][参考 https://cloud.iocoder.cn/mall/build/ 开启]");
|
||||
// ERP 系统
|
||||
System.out.println("[ERP 系统 yudao-module-erp - 教程][参考 https://cloud.iocoder.cn/erp/build/ 开启]");
|
||||
// WMS 仓库管理系统
|
||||
System.out.println("[WMS 仓库管理系统 yudao-module-wms - 教程][参考 https://cloud.iocoder.cn/wms/build/ 开启]");
|
||||
// CRM 系统
|
||||
System.out.println("[CRM 系统 yudao-module-crm - 教程][参考 https://cloud.iocoder.cn/crm/build/ 开启]");
|
||||
// MES 系统
|
||||
System.out.println("[MES 系统 yudao-module-mes - 教程][参考 https://cloud.iocoder.cn/mes/build/ 开启]");
|
||||
// IM 即时通讯
|
||||
System.out.println("[IM 即时通讯 yudao-module-im - 教程][参考 https://cloud.iocoder.cn/im/build/ 开启]");
|
||||
// 微信公众号
|
||||
System.out.println("[微信公众号 yudao-module-mp 教程][参考 https://cloud.iocoder.cn/mp/build/ 开启]");
|
||||
// 支付平台
|
||||
|
|
|
|||
|
|
@ -42,16 +42,15 @@ public class ApiEncryptResponseWrapper extends HttpServletResponseWrapper {
|
|||
this.flushBuffer();
|
||||
byte[] body = byteArrayOutputStream.toByteArray();
|
||||
|
||||
// 2. 添加加密 header 标识
|
||||
// 2. 加密 body
|
||||
String encryptedBody = symmetricEncryptor != null ? symmetricEncryptor.encryptBase64(body)
|
||||
: asymmetricEncryptor.encryptBase64(body, KeyType.PublicKey);
|
||||
response.getWriter().write(encryptedBody);
|
||||
|
||||
// 3. 添加加密 header 标识
|
||||
this.addHeader(properties.getHeader(), "true");
|
||||
// 特殊:特殊:https://juejin.cn/post/6867327674675625992
|
||||
this.addHeader("Access-Control-Expose-Headers", properties.getHeader());
|
||||
|
||||
// 3.1 加密 body
|
||||
String encryptedBody = symmetricEncryptor != null ? symmetricEncryptor.encryptBase64(body)
|
||||
: asymmetricEncryptor.encryptBase64(body, KeyType.PublicKey);
|
||||
// 3.2 输出加密后的 body:(设置 header 要放在 response 的 write 之前)
|
||||
response.getWriter().write(encryptedBody);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ import org.springframework.boot.web.client.RestTemplateBuilder;
|
|||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
|
@ -34,9 +32,7 @@ import javax.servlet.Filter;
|
|||
import java.util.Map;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
@AutoConfiguration(beforeName = {
|
||||
"com.fhs.trans.config.TransServiceConfig" // cloud 独有:避免一键改包后,RestTemplate 初始化的冲突。可见 https://t.zsxq.com/T4yj7 帖子
|
||||
})
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(WebProperties.class)
|
||||
public class YudaoWebAutoConfiguration {
|
||||
|
||||
|
|
@ -85,7 +81,6 @@ public class YudaoWebAutoConfiguration {
|
|||
}
|
||||
|
||||
@Bean
|
||||
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
|
||||
public GlobalExceptionHandler globalExceptionHandler(ApiErrorLogCommonApi apiErrorLogApi) {
|
||||
return new GlobalExceptionHandler(applicationName, apiErrorLogApi);
|
||||
}
|
||||
|
|
@ -108,7 +103,6 @@ public class YudaoWebAutoConfiguration {
|
|||
* 创建 CorsFilter Bean,解决跨域问题
|
||||
*/
|
||||
@Bean
|
||||
@Order(value = WebFilterOrderEnum.CORS_FILTER) // 特殊:修复因执行顺序影响到跨域配置不生效问题
|
||||
public FilterRegistrationBean<CorsFilter> corsFilterBean() {
|
||||
// 创建 CorsConfiguration 对象
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
|
|
@ -152,20 +146,9 @@ public class YudaoWebAutoConfiguration {
|
|||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@Primary
|
||||
@LoadBalanced
|
||||
public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) {
|
||||
return restTemplateBuilder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 RestTemplate 实例(支持负载均衡)
|
||||
*
|
||||
* @param restTemplateBuilder {@link RestTemplateAutoConfiguration#restTemplateBuilder}
|
||||
*/
|
||||
@Bean
|
||||
@LoadBalanced
|
||||
public RestTemplate loadBalancedRestTemplate(RestTemplateBuilder restTemplateBuilder) {
|
||||
return restTemplateBuilder.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -410,43 +410,25 @@ public class GlobalExceptionHandler {
|
|||
return CommonResult.error(NOT_IMPLEMENTED.getCode(),
|
||||
"[ERP 系统 yudao-module-erp - 表结构未导入][参考 https://cloud.iocoder.cn/erp/build/ 开启]");
|
||||
}
|
||||
// 6. WMS 仓库管理系统
|
||||
if (message.contains("wms_")) {
|
||||
log.error("[WMS 仓库管理系统 yudao-module-wms - 表结构未导入][参考 https://cloud.iocoder.cn/wms/build/ 开启]");
|
||||
return CommonResult.error(NOT_IMPLEMENTED.getCode(),
|
||||
"[WMS 仓库管理系统 yudao-module-wms - 表结构未导入][参考 https://cloud.iocoder.cn/wms/build/ 开启]");
|
||||
}
|
||||
// 7. CRM 系统
|
||||
// 6. CRM 系统
|
||||
if (message.contains("crm_")) {
|
||||
log.error("[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]");
|
||||
return CommonResult.error(NOT_IMPLEMENTED.getCode(),
|
||||
"[CRM 系统 yudao-module-crm - 表结构未导入][参考 https://cloud.iocoder.cn/crm/build/ 开启]");
|
||||
}
|
||||
// 8. MES 系统
|
||||
if (message.contains("mes_")) {
|
||||
log.error("[MES 系统 yudao-module-mes - 表结构未导入][参考 https://cloud.iocoder.cn/mes/build/ 开启]");
|
||||
return CommonResult.error(NOT_IMPLEMENTED.getCode(),
|
||||
"[MES 系统 yudao-module-mes - 表结构未导入][参考 https://cloud.iocoder.cn/mes/build/ 开启]");
|
||||
}
|
||||
// 9. IM 即时通讯
|
||||
if (message.contains("im_")) {
|
||||
log.error("[IM 即时通讯 yudao-module-im - 表结构未导入][参考 https://cloud.iocoder.cn/im/build/ 开启]");
|
||||
return CommonResult.error(NOT_IMPLEMENTED.getCode(),
|
||||
"[IM 即时通讯 yudao-module-im - 表结构未导入][参考 https://cloud.iocoder.cn/im/build/ 开启]");
|
||||
}
|
||||
// 10. 支付平台
|
||||
// 7. 支付平台
|
||||
if (message.contains("pay_")) {
|
||||
log.error("[支付模块 yudao-module-pay - 表结构未导入][参考 https://cloud.iocoder.cn/pay/build/ 开启]");
|
||||
return CommonResult.error(NOT_IMPLEMENTED.getCode(),
|
||||
"[支付模块 yudao-module-pay - 表结构未导入][参考 https://cloud.iocoder.cn/pay/build/ 开启]");
|
||||
}
|
||||
// 11. AI 大模型
|
||||
// 8. AI 大模型
|
||||
if (message.contains("ai_")) {
|
||||
log.error("[AI 大模型 yudao-module-ai - 表结构未导入][参考 https://cloud.iocoder.cn/ai/build/ 开启]");
|
||||
return CommonResult.error(NOT_IMPLEMENTED.getCode(),
|
||||
"[AI 大模型 yudao-module-ai - 表结构未导入][参考 https://cloud.iocoder.cn/ai/build/ 开启]");
|
||||
}
|
||||
// 12. IoT 物联网
|
||||
// 9. IoT 物联网
|
||||
if (message.contains("iot_")) {
|
||||
log.error("[IoT 物联网 yudao-module-iot - 表结构未导入][参考 https://doc.iocoder.cn/iot/build/ 开启]");
|
||||
return CommonResult.error(NOT_IMPLEMENTED.getCode(),
|
||||
|
|
|
|||
|
|
@ -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(), ex);
|
||||
log.error("[handleTextMessage][session({}) message({}) 处理异常]", session.getId(), message.getPayload());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -27,10 +27,9 @@ 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) {
|
||||
return false;
|
||||
if (loginUser != null) {
|
||||
WebSocketFrameworkUtils.setLoginUser(loginUser, attributes);
|
||||
}
|
||||
WebSocketFrameworkUtils.setLoginUser(loginUser, attributes);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@
|
|||
<artifactId>javax.servlet-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>javax.servlet</groupId>
|
||||
<artifactId>javax.servlet-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.github.xiaoymin</groupId> <!-- 接口文档 -->
|
||||
<artifactId>knife4j-gateway-spring-boot-starter</artifactId>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
package cn.iocoder.yudao.gateway.filter.cors;
|
||||
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 解决 Spring Cloud Gateway 2.x 跨域时,出现重复 Origin 的 BUG
|
||||
*
|
||||
* 参考文档:<a href="https://blog.csdn.net/zimou5581/article/details/90043178" />
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Component
|
||||
public class CorsResponseHeaderFilter implements GlobalFilter, Ordered {
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
// 指定此过滤器位于 NettyWriteResponseFilter 之后
|
||||
// 即待处理完响应体后接着处理响应头
|
||||
return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||
return chain.filter(exchange).then(Mono.defer(() -> {
|
||||
// https://gitee.com/zhijiantianya/yudao-cloud/pulls/177/
|
||||
List<String> keysToModify = exchange.getResponse().getHeaders().entrySet().stream()
|
||||
.filter(kv -> (kv.getValue() != null && kv.getValue().size() > 1))
|
||||
.filter(kv -> (kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
|| kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)))
|
||||
.map(Map.Entry::getKey)
|
||||
.collect(Collectors.toList());
|
||||
keysToModify.forEach(key->{
|
||||
List<String> values = exchange.getResponse().getHeaders().get(key);
|
||||
if (values != null && !values.isEmpty()) {
|
||||
exchange.getResponse().getHeaders().put(key, Collections.singletonList(values.get(0)));
|
||||
}
|
||||
});
|
||||
return chain.filter(exchange);
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -39,14 +39,8 @@ public class BannerApplicationRunner implements ApplicationRunner {
|
|||
System.out.println("[商城系统 yudao-module-mall 教程][参考 https://cloud.iocoder.cn/mall/build/ 开启]");
|
||||
// ERP 系统
|
||||
System.out.println("[ERP 系统 yudao-module-erp - 教程][参考 https://cloud.iocoder.cn/erp/build/ 开启]");
|
||||
// WMS 仓库管理系统
|
||||
System.out.println("[WMS 仓库管理系统 yudao-module-wms - 教程][参考 https://cloud.iocoder.cn/wms/build/ 开启]");
|
||||
// CRM 系统
|
||||
System.out.println("[CRM 系统 yudao-module-crm - 教程][参考 https://cloud.iocoder.cn/crm/build/ 开启]");
|
||||
// MES 系统
|
||||
System.out.println("[MES 系统 yudao-module-mes - 教程][参考 https://cloud.iocoder.cn/mes/build/ 开启]");
|
||||
// IM 即时通讯
|
||||
System.out.println("[IM 即时通讯 yudao-module-im - 教程][参考 https://cloud.iocoder.cn/im/build/ 开启]");
|
||||
// 微信公众号
|
||||
System.out.println("[微信公众号 yudao-module-mp 教程][参考 https://cloud.iocoder.cn/mp/build/ 开启]");
|
||||
// 支付平台
|
||||
|
|
|
|||
|
|
@ -12,24 +12,3 @@ spring:
|
|||
config: # 【注册中心】配置项
|
||||
namespace: dev # 命名空间。这里使用 dev 开发环境
|
||||
group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
|
||||
|
||||
--- #################### 监控相关配置 ####################
|
||||
|
||||
# Actuator 监控端点的配置项
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
base-path: /actuator # Actuator 提供的 API 接口的根目录。默认为 /actuator
|
||||
exposure:
|
||||
include: '*' # 需要开放的端点。默认值只打开 health 和 info 两个端点。通过设置 * ,可以开放所有端点。
|
||||
|
||||
# Spring Boot Admin 配置项
|
||||
spring:
|
||||
boot:
|
||||
admin:
|
||||
# Spring Boot Admin Client 客户端的相关配置
|
||||
client:
|
||||
instance:
|
||||
service-host-type: IP # 注册实例时,优先使用 IP [IP, HOST_NAME, CANONICAL_HOST_NAME]
|
||||
username: admin
|
||||
password: admin
|
||||
|
|
@ -4,38 +4,16 @@ spring:
|
|||
cloud:
|
||||
nacos:
|
||||
server-addr: 127.0.0.1:8848 # Nacos 服务器地址
|
||||
username: nacos
|
||||
password: nacos-admin
|
||||
username: # Nacos 账号
|
||||
password: # Nacos 密码
|
||||
discovery: # 【配置中心】配置项
|
||||
namespace: dev # 命名空间。这里使用 dev 开发环境
|
||||
group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
|
||||
config: # 【注册中心】配置项
|
||||
namespace: dev # 命名空间。这里使用 dev 开发环境
|
||||
group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUPn
|
||||
|
||||
--- #################### 监控相关配置 ####################
|
||||
|
||||
# Actuator 监控端点的配置项
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
base-path: /actuator # Actuator 提供的 API 接口的根目录。默认为 /actuator
|
||||
exposure:
|
||||
include: '*' # 需要开放的端点。默认值只打开 health 和 info 两个端点。通过设置 * ,可以开放所有端点。
|
||||
|
||||
# Spring Boot Admin 配置项
|
||||
spring:
|
||||
boot:
|
||||
admin:
|
||||
# Spring Boot Admin Client 客户端的相关配置
|
||||
client:
|
||||
instance:
|
||||
service-host-type: IP # 注册实例时,优先使用 IP [IP, HOST_NAME, CANONICAL_HOST_NAME]
|
||||
username: admin
|
||||
password: admin
|
||||
group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
|
||||
|
||||
# 日志文件配置
|
||||
logging:
|
||||
level:
|
||||
org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR # TODO 芋艿:先禁用,Spring Boot 3.X 存在部分错误的 WARN 提示
|
||||
|
||||
|
|
|
|||
|
|
@ -192,31 +192,8 @@ spring:
|
|||
- Path=/admin-api/iot/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/iot/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
## mes-server 服务
|
||||
- id: mes-admin-api # 路由的编号
|
||||
uri: grayLb://mes-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/mes/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/mes/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
## wms-server 服务
|
||||
- id: wms-admin-api # 路由的编号
|
||||
uri: grayLb://wms-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/wms/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/wms/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
## im-server 服务
|
||||
- id: im-admin-api # 路由的编号
|
||||
uri: grayLb://im-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/im/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/im/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
x-forwarded:
|
||||
prefix-enabled: false # 避免 Swagger 重复带上额外的 /admin-api/system 前缀
|
||||
default-filters: # 全局过滤器,对应 GatewayFilterDefinition 数组
|
||||
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
|
||||
|
||||
server:
|
||||
port: 48080
|
||||
|
|
@ -272,15 +249,6 @@ knife4j:
|
|||
- name: iot-server
|
||||
service-name: iot-server
|
||||
url: /admin-api/iot/v3/api-docs
|
||||
- name: mes-server
|
||||
service-name: mes-server
|
||||
url: /admin-api/mes/v3/api-docs
|
||||
- name: wms-server
|
||||
service-name: wms-server
|
||||
url: /admin-api/wms/v3/api-docs
|
||||
- name: im-server
|
||||
service-name: im-server
|
||||
url: /admin-api/im/v3/api-docs
|
||||
|
||||
--- #################### 芋道相关配置 ####################
|
||||
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
package cn.iocoder.yudao.module.ai.enums;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* AI 知识库文档切片策略枚举
|
||||
*
|
||||
* @author runzhen
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@Getter
|
||||
public enum AiDocumentSplitStrategyEnum {
|
||||
|
||||
/**
|
||||
* 自动识别文档类型并选择最佳切片策略
|
||||
*/
|
||||
AUTO("auto", "自动识别"),
|
||||
|
||||
/**
|
||||
* 基于 Token 数量机械切分(默认策略)
|
||||
*/
|
||||
TOKEN("token", "Token 切分"),
|
||||
|
||||
/**
|
||||
* 按段落切分(以双换行符为分隔)
|
||||
*/
|
||||
PARAGRAPH("paragraph", "段落切分"),
|
||||
|
||||
/**
|
||||
* Markdown QA 格式专用切片器
|
||||
* 识别二级标题作为问题,保持问答对完整性
|
||||
* 长答案智能切分但保留问题作为上下文
|
||||
*/
|
||||
MARKDOWN_QA("markdown_qa", "Markdown QA 切分"),
|
||||
|
||||
/**
|
||||
* 语义化切分,保留句子完整性
|
||||
* 在段落和句子边界处切分,避免截断
|
||||
*/
|
||||
SEMANTIC("semantic", "语义切分");
|
||||
|
||||
/**
|
||||
* 策略代码
|
||||
*/
|
||||
private final String code;
|
||||
|
||||
/**
|
||||
* 策略名称
|
||||
*/
|
||||
private final String name;
|
||||
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ public enum AiPlatformEnum implements ArrayValuable<String> {
|
|||
HUN_YUAN("HunYuan", "混元"), // 腾讯
|
||||
SILICON_FLOW("SiliconFlow", "硅基流动"), // 硅基流动
|
||||
MINI_MAX("MiniMax", "MiniMax"), // 稀宇科技
|
||||
MOONSHOT("Moonshot", "月之暗面"), // KIMI
|
||||
MOONSHOT("Moonshot", "月之暗灭"), // KIMI
|
||||
BAI_CHUAN("BaiChuan", "百川智能"), // 百川智能
|
||||
|
||||
// ========== 国外平台 ==========
|
||||
|
|
@ -40,7 +40,6 @@ public enum AiPlatformEnum implements ArrayValuable<String> {
|
|||
STABLE_DIFFUSION("StableDiffusion", "StableDiffusion"), // Stability AI
|
||||
MIDJOURNEY("Midjourney", "Midjourney"), // Midjourney
|
||||
SUNO("Suno", "Suno"), // Suno AI
|
||||
GROK("Grok","Grok"), // Grok
|
||||
|
||||
;
|
||||
|
||||
|
|
|
|||
|
|
@ -19,10 +19,9 @@
|
|||
国外:OpenAI、Ollama、Midjourney、StableDiffusion、Suno
|
||||
</description>
|
||||
<properties>
|
||||
<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.2</alibaba-ai.version>
|
||||
<tinyflow.version>1.2.6</tinyflow.version>
|
||||
<spring-ai.version>1.0.1</spring-ai.version>
|
||||
<alibaba-ai.version>1.0.0.3</alibaba-ai.version>
|
||||
<tinyflow.version>1.0.2</tinyflow.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
|
@ -173,7 +172,7 @@
|
|||
<version>1.0.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<!-- 月之暗面 -->
|
||||
<!-- 月之暗灭 -->
|
||||
<groupId>org.springaicommunity</groupId>
|
||||
<artifactId>moonshot-spring-boot-starter</artifactId>
|
||||
<version>1.0.0</version>
|
||||
|
|
@ -240,12 +239,6 @@
|
|||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
|
||||
<version>${spring-ai.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
<artifactId>swagger-annotations-jakarta</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<!-- 客户端 -->
|
||||
|
|
@ -269,11 +262,6 @@
|
|||
<groupId>com.agentsflex</groupId>
|
||||
<artifactId>agents-flex-store-elasticsearch</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<!-- 解决 https://t.zsxq.com/pCBZC 问题 -->
|
||||
<groupId>com.agentsflex</groupId>
|
||||
<artifactId>agents-flex-search-engine-es</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<!-- TODO @芋艿:暂时移除 groovy,和 iot 冲突 -->
|
||||
<groupId>org.codehaus.groovy</groupId>
|
||||
|
|
|
|||
|
|
@ -83,15 +83,6 @@ public class AiKnowledgeSegmentController {
|
|||
return success(true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/delete")
|
||||
@Operation(summary = "删除段落")
|
||||
@Parameter(name = "id", description = "段落编号", required = true, example = "1024")
|
||||
@PreAuthorize("@ss.hasPermission('ai:knowledge:delete')")
|
||||
public CommonResult<Boolean> deleteKnowledgeSegment(@RequestParam("id") Long id) {
|
||||
segmentService.deleteKnowledgeSegment(id);
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/split")
|
||||
@Operation(summary = "切片内容")
|
||||
@Parameters({
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import lombok.Data;
|
|||
public class AiKnowledgeSegmentPageReqVO extends PageParam {
|
||||
|
||||
@Schema(description = "文档编号", example = "1")
|
||||
private Long documentId;
|
||||
private Integer documentId;
|
||||
|
||||
@Schema(description = "分段内容关键字", example = "Java 开发")
|
||||
private String content;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import java.util.List;
|
|||
* @since 2024/4/14 17:35
|
||||
*/
|
||||
@TableName(value = "ai_chat_message", autoResultMap = true)
|
||||
@KeySequence("ai_chat_message_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
|
||||
@KeySequence("ai_chat_conversation_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import lombok.*;
|
|||
* @author 芋道源码
|
||||
*/
|
||||
@TableName("ai_api_key")
|
||||
@KeySequence("ai_api_key_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
|
||||
@KeySequence("ai_chat_conversation_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ public interface AiKnowledgeSegmentMapper extends BaseMapperX<AiKnowledgeSegment
|
|||
MPJLambdaWrapper<AiKnowledgeSegmentDO> wrapper = new MPJLambdaWrapperX<AiKnowledgeSegmentDO>()
|
||||
.selectAs(AiKnowledgeSegmentDO::getDocumentId, AiKnowledgeSegmentProcessRespVO::getDocumentId)
|
||||
.selectCount(AiKnowledgeSegmentDO::getId, "count")
|
||||
.select("COUNT(CASE WHEN vector_id IS NOT NULL AND vector_id <> '" + AiKnowledgeSegmentDO.VECTOR_ID_EMPTY + "' THEN 1 ELSE NULL END) AS embeddingCount")
|
||||
.select("COUNT(CASE WHEN vector_id > '" + AiKnowledgeSegmentDO.VECTOR_ID_EMPTY
|
||||
+ "' THEN 1 ELSE NULL END) AS embeddingCount")
|
||||
.in(AiKnowledgeSegmentDO::getDocumentId, documentIds)
|
||||
.groupBy(AiKnowledgeSegmentDO::getDocumentId);
|
||||
return selectJoinList(AiKnowledgeSegmentProcessRespVO.class, wrapper);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import cn.iocoder.yudao.module.ai.framework.ai.core.model.AiModelFactoryImpl;
|
|||
import cn.iocoder.yudao.module.ai.framework.ai.core.model.baichuan.BaiChuanChatModel;
|
||||
import cn.iocoder.yudao.module.ai.framework.ai.core.model.doubao.DouBaoChatModel;
|
||||
import cn.iocoder.yudao.module.ai.framework.ai.core.model.gemini.GeminiChatModel;
|
||||
import cn.iocoder.yudao.module.ai.framework.ai.core.model.grok.GrokChatModel;
|
||||
import cn.iocoder.yudao.module.ai.framework.ai.core.model.hunyuan.HunYuanChatModel;
|
||||
import cn.iocoder.yudao.module.ai.framework.ai.core.model.midjourney.api.MidjourneyApi;
|
||||
import cn.iocoder.yudao.module.ai.framework.ai.core.model.siliconflow.SiliconFlowApiConstants;
|
||||
|
|
@ -17,9 +16,7 @@ import cn.iocoder.yudao.module.ai.framework.ai.core.model.xinghuo.XingHuoChatMod
|
|||
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;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.deepseek.DeepSeekChatModel;
|
||||
import org.springframework.ai.deepseek.DeepSeekChatOptions;
|
||||
import org.springframework.ai.deepseek.api.DeepSeekApi;
|
||||
|
|
@ -37,14 +34,12 @@ import org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusServiceClie
|
|||
import org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusVectorStoreProperties;
|
||||
import org.springframework.ai.vectorstore.qdrant.autoconfigure.QdrantVectorStoreProperties;
|
||||
import org.springframework.ai.vectorstore.redis.autoconfigure.RedisVectorStoreProperties;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 芋道 AI 自动配置
|
||||
|
|
@ -65,13 +60,6 @@ public class AiAutoConfiguration {
|
|||
return new AiModelFactoryImpl();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public ObservationRegistry observationRegistry() {
|
||||
// 特殊:兜底有 ObservationRegistry Bean,避免相关的 ChatModel 创建报错。相关 issue:https://t.zsxq.com/CuPu4
|
||||
return ObservationRegistry.NOOP;
|
||||
}
|
||||
|
||||
// ========== 各种 AI Client 创建 ==========
|
||||
|
||||
@Bean
|
||||
|
|
@ -264,28 +252,6 @@ public class AiAutoConfiguration {
|
|||
return new SunoApi(yudaoAiProperties.getSuno().getBaseUrl());
|
||||
}
|
||||
|
||||
public ChatModel buildGrokChatClient(YudaoAiProperties.Grok properties) {
|
||||
if (StrUtil.isEmpty(properties.getModel())) {
|
||||
properties.setModel(GrokChatModel.MODEL_DEFAULT);
|
||||
}
|
||||
OpenAiChatModel openAiChatModel = OpenAiChatModel.builder()
|
||||
.openAiApi(OpenAiApi.builder()
|
||||
.baseUrl(Optional.ofNullable(properties.getBaseUrl())
|
||||
.orElse(GrokChatModel.BASE_URL))
|
||||
.completionsPath(GrokChatModel.COMPLETE_PATH)
|
||||
.apiKey(properties.getApiKey())
|
||||
.build())
|
||||
.defaultOptions(OpenAiChatOptions.builder()
|
||||
.model(properties.getModel())
|
||||
.temperature(properties.getTemperature())
|
||||
.maxTokens(properties.getMaxTokens())
|
||||
.topP(properties.getTopP())
|
||||
.build())
|
||||
.toolCallingManager(getToolCallingManager())
|
||||
.build();
|
||||
return new DouBaoChatModel(openAiChatModel);
|
||||
}
|
||||
|
||||
// ========== RAG 相关 ==========
|
||||
|
||||
@Bean
|
||||
|
|
|
|||
|
|
@ -160,20 +160,6 @@ public class YudaoAiProperties {
|
|||
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Grok {
|
||||
|
||||
private String enable;
|
||||
private String apiKey;
|
||||
private String baseUrl;
|
||||
|
||||
private String model;
|
||||
private Double temperature;
|
||||
private Integer maxTokens;
|
||||
private Double topP;
|
||||
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class WebSearch {
|
||||
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ import org.springframework.ai.model.zhipuai.autoconfigure.ZhiPuAiImageAutoConfig
|
|||
import org.springframework.ai.ollama.OllamaChatModel;
|
||||
import org.springframework.ai.ollama.OllamaEmbeddingModel;
|
||||
import org.springframework.ai.ollama.api.OllamaApi;
|
||||
import org.springframework.ai.ollama.api.OllamaEmbeddingOptions;
|
||||
import org.springframework.ai.ollama.api.OllamaOptions;
|
||||
import org.springframework.ai.openai.OpenAiChatModel;
|
||||
import org.springframework.ai.openai.OpenAiEmbeddingModel;
|
||||
import org.springframework.ai.openai.OpenAiEmbeddingOptions;
|
||||
|
|
@ -178,8 +178,6 @@ public class AiModelFactoryImpl implements AiModelFactory {
|
|||
return buildGeminiChatModel(apiKey);
|
||||
case OLLAMA:
|
||||
return buildOllamaChatModel(url);
|
||||
case GROK:
|
||||
return buildGrokChatModel(apiKey,url);
|
||||
default:
|
||||
throw new IllegalArgumentException(StrUtil.format("未知平台({})", platform));
|
||||
}
|
||||
|
|
@ -438,12 +436,10 @@ public class AiModelFactoryImpl implements AiModelFactory {
|
|||
* 可参考 {@link ZhiPuAiChatAutoConfiguration} 的 zhiPuAiChatModel 方法
|
||||
*/
|
||||
private ZhiPuAiChatModel buildZhiPuChatModel(String apiKey, String url) {
|
||||
ZhiPuAiApi.Builder zhiPuAiApiBuilder = ZhiPuAiApi.builder().apiKey(apiKey);
|
||||
if (StrUtil.isNotEmpty(url)) {
|
||||
zhiPuAiApiBuilder.baseUrl(url);
|
||||
}
|
||||
ZhiPuAiApi zhiPuAiApi = StrUtil.isEmpty(url) ? new ZhiPuAiApi(apiKey)
|
||||
: new ZhiPuAiApi(url, apiKey);
|
||||
ZhiPuAiChatOptions options = ZhiPuAiChatOptions.builder().model(ZhiPuAiApi.DEFAULT_CHAT_MODEL).temperature(0.7).build();
|
||||
return new ZhiPuAiChatModel(zhiPuAiApiBuilder.build(), options, getToolCallingManager(), DEFAULT_RETRY_TEMPLATE,
|
||||
return new ZhiPuAiChatModel(zhiPuAiApi, options, getToolCallingManager(), DEFAULT_RETRY_TEMPLATE,
|
||||
getObservationRegistry().getIfAvailable());
|
||||
}
|
||||
|
||||
|
|
@ -590,13 +586,6 @@ public class AiModelFactoryImpl implements AiModelFactory {
|
|||
return new StabilityAiImageModel(stabilityAiApi);
|
||||
}
|
||||
|
||||
private ChatModel buildGrokChatModel(String apiKey,String url) {
|
||||
YudaoAiProperties.Grok properties = new YudaoAiProperties.Grok()
|
||||
.setBaseUrl(url)
|
||||
.setApiKey(apiKey);
|
||||
return new AiAutoConfiguration().buildGrokChatClient(properties);
|
||||
}
|
||||
|
||||
// ========== 各种创建 EmbeddingModel 的方法 ==========
|
||||
|
||||
/**
|
||||
|
|
@ -612,12 +601,10 @@ public class AiModelFactoryImpl implements AiModelFactory {
|
|||
* 可参考 {@link ZhiPuAiEmbeddingAutoConfiguration} 的 zhiPuAiEmbeddingModel 方法
|
||||
*/
|
||||
private ZhiPuAiEmbeddingModel buildZhiPuEmbeddingModel(String apiKey, String url, String model) {
|
||||
ZhiPuAiApi.Builder zhiPuAiApiBuilder = ZhiPuAiApi.builder().apiKey(apiKey);
|
||||
if (StrUtil.isNotEmpty(url)) {
|
||||
zhiPuAiApiBuilder.baseUrl(url);
|
||||
}
|
||||
ZhiPuAiApi zhiPuAiApi = StrUtil.isEmpty(url) ? new ZhiPuAiApi(apiKey)
|
||||
: new ZhiPuAiApi(url, apiKey);
|
||||
ZhiPuAiEmbeddingOptions zhiPuAiEmbeddingOptions = ZhiPuAiEmbeddingOptions.builder().model(model).build();
|
||||
return new ZhiPuAiEmbeddingModel(zhiPuAiApiBuilder.build(), MetadataMode.EMBED, zhiPuAiEmbeddingOptions);
|
||||
return new ZhiPuAiEmbeddingModel(zhiPuAiApi, MetadataMode.EMBED, zhiPuAiEmbeddingOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -645,7 +632,7 @@ public class AiModelFactoryImpl implements AiModelFactory {
|
|||
|
||||
private OllamaEmbeddingModel buildOllamaEmbeddingModel(String url, String model) {
|
||||
OllamaApi ollamaApi = OllamaApi.builder().baseUrl(url).build();
|
||||
OllamaEmbeddingOptions ollamaOptions = OllamaEmbeddingOptions.builder().model(model).build();
|
||||
OllamaOptions ollamaOptions = OllamaOptions.builder().model(model).build();
|
||||
return OllamaEmbeddingModel.builder()
|
||||
.ollamaApi(ollamaApi)
|
||||
.defaultOptions(ollamaOptions)
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
package cn.iocoder.yudao.module.ai.framework.ai.core.model.grok;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.ChatOptions;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
/**
|
||||
* Grok {@link ChatModel} 实现类
|
||||
*
|
||||
*
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class GrokChatModel implements ChatModel {
|
||||
|
||||
public static final String BASE_URL = "https://api.x.ai";
|
||||
public static final String COMPLETE_PATH = "/v1/chat/completions";
|
||||
public static final String MODEL_DEFAULT = "grok-4-fast-reasoning";
|
||||
|
||||
/**
|
||||
* 兼容 OpenAI 接口,进行复用
|
||||
*/
|
||||
private final ChatModel openAiChatModel;
|
||||
|
||||
@Override
|
||||
public ChatResponse call(Prompt prompt) {
|
||||
return openAiChatModel.call(prompt);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<ChatResponse> stream(Prompt prompt) {
|
||||
return openAiChatModel.stream(prompt);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatOptions getDefaultOptions() {
|
||||
return openAiChatModel.getDefaultOptions();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -3,8 +3,7 @@ package cn.iocoder.yudao.module.ai.framework.security.config;
|
|||
import cn.iocoder.yudao.framework.security.config.AuthorizeRequestsCustomizer;
|
||||
import cn.iocoder.yudao.module.infra.enums.ApiConstants;
|
||||
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.ai.mcp.server.autoconfigure.McpServerProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
|
|
@ -19,9 +18,7 @@ import java.util.Optional;
|
|||
public class SecurityConfiguration {
|
||||
|
||||
@Resource
|
||||
private Optional<McpServerSseProperties> mcpServerSseProperties;
|
||||
@Resource
|
||||
private Optional<McpServerStreamableHttpProperties> mcpServerStreamableHttpProperties;
|
||||
private Optional<McpServerProperties> serverProperties;
|
||||
|
||||
@Bean("aiAuthorizeRequestsCustomizer")
|
||||
public AuthorizeRequestsCustomizer authorizeRequestsCustomizer() {
|
||||
|
|
@ -45,12 +42,10 @@ public class SecurityConfiguration {
|
|||
registry.requestMatchers(ApiConstants.PREFIX + "/**").permitAll();
|
||||
|
||||
// MCP Server
|
||||
mcpServerSseProperties.ifPresent(properties -> {
|
||||
serverProperties.ifPresent(properties -> {
|
||||
registry.requestMatchers(properties.getSseEndpoint()).permitAll();
|
||||
registry.requestMatchers(properties.getSseMessageEndpoint()).permitAll();
|
||||
});
|
||||
mcpServerStreamableHttpProperties.ifPresent(properties ->
|
||||
registry.requestMatchers(properties.getMcpEndpoint()).permitAll());
|
||||
}
|
||||
|
||||
};
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import cn.iocoder.yudao.module.ai.util.AiUtils;
|
|||
import cn.iocoder.yudao.module.ai.util.FileTypeUtils;
|
||||
import com.google.common.collect.Maps;
|
||||
import io.modelcontextprotocol.client.McpSyncClient;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
import org.springframework.ai.chat.messages.MessageType;
|
||||
|
|
@ -48,7 +49,7 @@ 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.mcp.client.autoconfigure.properties.McpClientCommonProperties;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.resolution.ToolCallbackResolver;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
|
@ -58,8 +59,6 @@ import reactor.core.publisher.Flux;
|
|||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
|
|
@ -232,24 +231,20 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
|
|||
// 4.3 流式返回
|
||||
StringBuffer contentBuffer = new StringBuffer();
|
||||
StringBuffer reasoningContentBuffer = new StringBuffer();
|
||||
|
||||
// 防止执行多次知识库和联网搜索
|
||||
AtomicBoolean firstExecuteFlag = new AtomicBoolean(true);
|
||||
AtomicReference<List<AiChatMessageRespVO.KnowledgeSegment>> cacheSegments = new AtomicReference<>();
|
||||
AtomicReference<List<AiWebSearchResponse.WebPage>> cacheWebSearchPages = new AtomicReference<>();
|
||||
return streamResponse.map(chunk -> {
|
||||
// 仅首次:返回知识库、联网搜索
|
||||
List<AiChatMessageRespVO.KnowledgeSegment> segments = null;
|
||||
List<AiWebSearchResponse.WebPage> webSearchPages = null;
|
||||
if (StrUtil.isEmpty(contentBuffer)) {
|
||||
if (firstExecuteFlag.compareAndSet(true, false)) { // CAS 操作,确保仅执行一次
|
||||
Map<Long, AiKnowledgeDocumentDO> documentMap = TenantUtils.executeIgnore(() -> knowledgeDocumentService.getKnowledgeDocumentMap(
|
||||
convertSet(knowledgeSegments, AiKnowledgeSegmentSearchRespBO::getDocumentId)));
|
||||
cacheSegments.set(BeanUtils.toBean(knowledgeSegments, AiChatMessageRespVO.KnowledgeSegment.class, segment -> {
|
||||
AiKnowledgeDocumentDO document = documentMap.get(segment.getDocumentId());
|
||||
segment.setDocumentName(document != null ? document.getName() : null);
|
||||
}));
|
||||
if (webSearchResponse != null) {
|
||||
cacheWebSearchPages.set(webSearchResponse.getLists());
|
||||
}
|
||||
Map<Long, AiKnowledgeDocumentDO> documentMap = TenantUtils.executeIgnore(() ->
|
||||
knowledgeDocumentService.getKnowledgeDocumentMap(
|
||||
convertSet(knowledgeSegments, AiKnowledgeSegmentSearchRespBO::getDocumentId)));
|
||||
segments = BeanUtils.toBean(knowledgeSegments, AiChatMessageRespVO.KnowledgeSegment.class, segment -> {
|
||||
AiKnowledgeDocumentDO document = documentMap.get(segment.getDocumentId());
|
||||
segment.setDocumentName(document != null ? document.getName() : null);
|
||||
});
|
||||
if (webSearchResponse != null) {
|
||||
webSearchPages = webSearchResponse.getLists();
|
||||
}
|
||||
}
|
||||
// 响应结果
|
||||
|
|
@ -266,7 +261,7 @@ public class AiChatMessageServiceImpl implements AiChatMessageService {
|
|||
.setReceive(BeanUtils.toBean(assistantMessage, AiChatMessageSendRespVO.Message.class)
|
||||
.setContent(StrUtil.nullToDefault(newContent, "")) // 避免 null 的 情况
|
||||
.setReasoningContent(StrUtil.nullToDefault(newReasoningContent, "")) // 避免 null 的 情况
|
||||
.setSegments(cacheSegments.get()).setWebSearchPages(cacheWebSearchPages.get()))); // 知识库 + 联网搜索
|
||||
.setSegments(segments).setWebSearchPages(webSearchPages))); // 知识库 + 联网搜索
|
||||
}).doOnComplete(() -> {
|
||||
// 忽略租户,因为 Flux 异步无法透传租户
|
||||
TenantUtils.executeIgnore(() -> chatMessageMapper.updateById(
|
||||
|
|
|
|||
|
|
@ -109,7 +109,6 @@ public class AiImageServiceImpl implements AiImageService {
|
|||
}
|
||||
|
||||
@Async
|
||||
@SuppressWarnings("ConstantValue")
|
||||
public void executeDrawImage(AiImageDO image, AiImageDrawReqVO reqVO, AiModelDO model) {
|
||||
try {
|
||||
// 1.1 构建请求
|
||||
|
|
@ -165,8 +164,8 @@ public class AiImageServiceImpl implements AiImageService {
|
|||
.build();
|
||||
} else if (ObjUtil.equal(model.getPlatform(), AiPlatformEnum.TONG_YI.getPlatform())) {
|
||||
return DashScopeImageOptions.builder()
|
||||
.model(model.getModel()).n(1)
|
||||
.height(draw.getHeight()).width(draw.getWidth())
|
||||
.withModel(model.getModel()).withN(1)
|
||||
.withHeight(draw.getHeight()).withWidth(draw.getWidth())
|
||||
.build();
|
||||
} else if (ObjUtil.equal(model.getPlatform(), AiPlatformEnum.YI_YAN.getPlatform())) {
|
||||
return QianFanImageOptions.builder()
|
||||
|
|
|
|||
|
|
@ -98,13 +98,6 @@ public interface AiKnowledgeSegmentService {
|
|||
*/
|
||||
void updateKnowledgeSegmentStatus(AiKnowledgeSegmentUpdateStatusReqVO reqVO);
|
||||
|
||||
/**
|
||||
* 删除知识库段落
|
||||
*
|
||||
* @param id 段落编号
|
||||
*/
|
||||
void deleteKnowledgeSegment(Long id);
|
||||
|
||||
/**
|
||||
* 重新索引知识库下的所有文档段落
|
||||
*
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import cn.hutool.core.collection.CollUtil;
|
|||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
|
|
@ -16,11 +15,8 @@ import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeDO;
|
|||
import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeDocumentDO;
|
||||
import cn.iocoder.yudao.module.ai.dal.dataobject.knowledge.AiKnowledgeSegmentDO;
|
||||
import cn.iocoder.yudao.module.ai.dal.mysql.knowledge.AiKnowledgeSegmentMapper;
|
||||
import cn.iocoder.yudao.module.ai.enums.AiDocumentSplitStrategyEnum;
|
||||
import cn.iocoder.yudao.module.ai.service.knowledge.bo.AiKnowledgeSegmentSearchReqBO;
|
||||
import cn.iocoder.yudao.module.ai.service.knowledge.bo.AiKnowledgeSegmentSearchRespBO;
|
||||
import cn.iocoder.yudao.module.ai.service.knowledge.splitter.MarkdownQaSplitter;
|
||||
import cn.iocoder.yudao.module.ai.service.knowledge.splitter.SemanticTextSplitter;
|
||||
import cn.iocoder.yudao.module.ai.service.model.AiModelService;
|
||||
import com.alibaba.cloud.ai.dashscope.rerank.DashScopeRerankOptions;
|
||||
import com.alibaba.cloud.ai.model.RerankModel;
|
||||
|
|
@ -43,7 +39,8 @@ import java.util.*;
|
|||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
|
||||
import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.*;
|
||||
import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.KNOWLEDGE_SEGMENT_CONTENT_TOO_LONG;
|
||||
import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.KNOWLEDGE_SEGMENT_NOT_EXISTS;
|
||||
import static org.springframework.ai.vectorstore.SearchRequest.SIMILARITY_THRESHOLD_ACCEPT_ALL;
|
||||
|
||||
/**
|
||||
|
|
@ -98,9 +95,8 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
|
|||
AiKnowledgeDO knowledgeDO = knowledgeService.validateKnowledgeExists(documentDO.getKnowledgeId());
|
||||
VectorStore vectorStore = getVectorStoreById(knowledgeDO);
|
||||
|
||||
// 2. 文档切片(使用自动检测策略)
|
||||
List<Document> documentSegments = splitContentByStrategy(content, documentDO.getSegmentMaxTokens(),
|
||||
AiDocumentSplitStrategyEnum.AUTO, documentDO.getUrl());
|
||||
// 2. 文档切片
|
||||
List<Document> documentSegments = splitContentByToken(content, documentDO.getSegmentMaxTokens());
|
||||
|
||||
// 3.1 存储切片
|
||||
List<AiKnowledgeSegmentDO> segmentDOs = convertList(documentSegments, segment -> {
|
||||
|
|
@ -141,19 +137,6 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteKnowledgeSegment(Long id) {
|
||||
// 1. 校验段落存在
|
||||
AiKnowledgeSegmentDO segment = validateKnowledgeSegmentExists(id);
|
||||
|
||||
// 2. 删除向量
|
||||
VectorStore vectorStore = getVectorStoreById(segment.getKnowledgeId());
|
||||
deleteVectorStore(vectorStore, segment);
|
||||
|
||||
// 3. 删除段落记录
|
||||
segmentMapper.deleteById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteKnowledgeSegmentByDocumentId(Long documentId) {
|
||||
// 1. 查询需要删除的段落
|
||||
|
|
@ -244,9 +227,6 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
|
|||
|
||||
// 2. 检索
|
||||
List<Document> documents = searchDocument(knowledge, reqBO);
|
||||
if (CollUtil.isEmpty(documents)) {
|
||||
return ListUtil.empty();
|
||||
}
|
||||
|
||||
// 3.1 段落召回
|
||||
List<AiKnowledgeSegmentDO> segments = segmentMapper
|
||||
|
|
@ -312,10 +292,8 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
|
|||
// 1. 读取 URL 内容
|
||||
String content = knowledgeDocumentService.readUrl(url);
|
||||
|
||||
// 2.1 自动检测文档类型并选择策略
|
||||
AiDocumentSplitStrategyEnum strategy = detectDocumentStrategy(content, url);
|
||||
// 2.2 文档切片
|
||||
List<Document> documentSegments = splitContentByStrategy(content, segmentMaxTokens, strategy, url);
|
||||
// 2. 文档切片
|
||||
List<Document> documentSegments = splitContentByToken(content, segmentMaxTokens);
|
||||
|
||||
// 3. 转换为段落对象
|
||||
return convertList(documentSegments, segment -> {
|
||||
|
|
@ -352,103 +330,11 @@ public class AiKnowledgeSegmentServiceImpl implements AiKnowledgeSegmentService
|
|||
return getVectorStoreById(knowledge);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据策略切分内容
|
||||
*
|
||||
* @param content 文档内容
|
||||
* @param segmentMaxTokens 分段的最大 Token 数
|
||||
* @param strategy 切片策略
|
||||
* @param url 文档 URL(用于自动检测文件类型)
|
||||
* @return 切片后的文档列表
|
||||
*/
|
||||
@SuppressWarnings("EnhancedSwitchMigration")
|
||||
private List<Document> splitContentByStrategy(String content, Integer segmentMaxTokens,
|
||||
AiDocumentSplitStrategyEnum strategy, String url) {
|
||||
// 自动检测策略
|
||||
if (strategy == AiDocumentSplitStrategyEnum.AUTO) {
|
||||
strategy = detectDocumentStrategy(content, url);
|
||||
log.info("[splitContentByStrategy][自动检测到文档策略: {}]", strategy.getName());
|
||||
}
|
||||
// 根据策略切分
|
||||
TextSplitter textSplitter;
|
||||
switch (strategy) {
|
||||
case MARKDOWN_QA:
|
||||
textSplitter = new MarkdownQaSplitter(segmentMaxTokens);
|
||||
break;
|
||||
case SEMANTIC:
|
||||
textSplitter = new SemanticTextSplitter(segmentMaxTokens);
|
||||
break;
|
||||
case PARAGRAPH:
|
||||
textSplitter = new SemanticTextSplitter(segmentMaxTokens, 0); // 段落切分,无重叠
|
||||
break;
|
||||
case TOKEN:
|
||||
default:
|
||||
textSplitter = buildTokenTextSplitter(segmentMaxTokens);
|
||||
break;
|
||||
}
|
||||
// 执行切分
|
||||
private static List<Document> splitContentByToken(String content, Integer segmentMaxTokens) {
|
||||
TextSplitter textSplitter = buildTokenTextSplitter(segmentMaxTokens);
|
||||
return textSplitter.apply(Collections.singletonList(new Document(content)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动检测文档类型并选择切片策略
|
||||
*
|
||||
* @param content 文档内容
|
||||
* @param url 文档 URL
|
||||
* @return 推荐的切片策略
|
||||
*/
|
||||
private AiDocumentSplitStrategyEnum detectDocumentStrategy(String content, String url) {
|
||||
if (StrUtil.isEmpty(content)) {
|
||||
return AiDocumentSplitStrategyEnum.TOKEN;
|
||||
}
|
||||
// 1. 检测 Markdown QA 格式
|
||||
if (isMarkdownQaFormat(content, url)) {
|
||||
return AiDocumentSplitStrategyEnum.MARKDOWN_QA;
|
||||
}
|
||||
// 2. 检测普通 Markdown 文档
|
||||
if (isMarkdownDocument(url)) {
|
||||
return AiDocumentSplitStrategyEnum.SEMANTIC;
|
||||
}
|
||||
// 3. 默认使用语义切分(比 Token 切分更智能)
|
||||
return AiDocumentSplitStrategyEnum.SEMANTIC;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为 Markdown QA 格式
|
||||
* 特征:包含多个二级标题(## )且标题后紧跟答案内容
|
||||
*/
|
||||
private boolean isMarkdownQaFormat(String content, String url) {
|
||||
// 文件扩展名判断
|
||||
if (StrUtil.isNotEmpty(url) && !url.toLowerCase().endsWith(".md")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 统计二级标题数量
|
||||
long h2Count = content.lines()
|
||||
.filter(line -> line.trim().startsWith("## "))
|
||||
.count();
|
||||
|
||||
// 要求一:至少包含 2 个二级标题才认为是 QA 格式
|
||||
if (h2Count < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 要求二:检查标题占比(QA 文档标题行数相对较多),如果二级标题占比超过 10%,认为是 QA 格式
|
||||
long totalLines = content.lines().count();
|
||||
double h2Ratio = (double) h2Count / totalLines;
|
||||
return h2Ratio > 0.1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为 Markdown 文档
|
||||
*/
|
||||
private boolean isMarkdownDocument(String url) {
|
||||
return StrUtil.endWithAnyIgnoreCase(url, ".md", ".markdown");
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建基于 Token 的文本切片器(原有逻辑保留)
|
||||
*/
|
||||
private static TextSplitter buildTokenTextSplitter(Integer segmentMaxTokens) {
|
||||
return TokenTextSplitter.builder()
|
||||
.withChunkSize(segmentMaxTokens)
|
||||
|
|
|
|||
|
|
@ -1,342 +0,0 @@
|
|||
package cn.iocoder.yudao.module.ai.service.knowledge.splitter;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.transformer.splitter.TextSplitter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Markdown QA 格式专用切片器
|
||||
*
|
||||
* <p>功能特点:
|
||||
* <ul>
|
||||
* <li>识别二级标题(## )作为问题标记</li>
|
||||
* <li>短 QA 对保持完整(不超过 Token 限制)</li>
|
||||
* <li>长答案智能切分,每个片段保留完整问题作为上下文</li>
|
||||
* <li>支持自定义 Token 估算器</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author runzhen
|
||||
*/
|
||||
@Slf4j
|
||||
@SuppressWarnings("SizeReplaceableByIsEmpty")
|
||||
public class MarkdownQaSplitter extends TextSplitter {
|
||||
|
||||
/**
|
||||
* 二级标题正则:匹配 "## " 开头的行
|
||||
*/
|
||||
private static final Pattern H2_PATTERN = Pattern.compile("^##\\s+(.+)$", Pattern.MULTILINE);
|
||||
|
||||
/**
|
||||
* 段落分隔符:双换行
|
||||
*/
|
||||
private static final String PARAGRAPH_SEPARATOR = "\n\n";
|
||||
|
||||
/**
|
||||
* 句子分隔符
|
||||
*/
|
||||
private static final Pattern SENTENCE_PATTERN = Pattern.compile("[。!?.!?]\\s*");
|
||||
|
||||
/**
|
||||
* 分段的最大 Token 数
|
||||
*/
|
||||
private final int chunkSize;
|
||||
|
||||
/**
|
||||
* Token 估算器(简单实现:中文按字符数,英文按单词数的 1.3 倍)
|
||||
*/
|
||||
private final TokenEstimator tokenEstimator;
|
||||
|
||||
public MarkdownQaSplitter(int chunkSize) {
|
||||
this.chunkSize = chunkSize;
|
||||
this.tokenEstimator = new SimpleTokenEstimator();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<String> splitText(String text) {
|
||||
if (StrUtil.isEmpty(text)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// 解析 QA 对
|
||||
List<QaPair> qaPairs = parseQaPairs(text);
|
||||
if (CollUtil.isEmpty(qaPairs)) {
|
||||
// 如果没有识别到 QA 格式,按段落切分
|
||||
return fallbackSplit(text);
|
||||
}
|
||||
|
||||
// 处理每个 QA 对
|
||||
List<String> result = new ArrayList<>();
|
||||
for (QaPair qaPair : qaPairs) {
|
||||
result.addAll(splitQaPair(qaPair));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 Markdown QA 对
|
||||
*
|
||||
* @param content 文本内容
|
||||
* @return QA 对列表
|
||||
*/
|
||||
private List<QaPair> parseQaPairs(String content) {
|
||||
// 找到所有二级标题位置
|
||||
List<QaPair> qaPairs = new ArrayList<>();
|
||||
List<Integer> headingPositions = new ArrayList<>();
|
||||
List<String> questions = new ArrayList<>();
|
||||
Matcher matcher = H2_PATTERN.matcher(content);
|
||||
while (matcher.find()) {
|
||||
headingPositions.add(matcher.start());
|
||||
questions.add(matcher.group(1).trim());
|
||||
}
|
||||
if (CollUtil.isEmpty(headingPositions)) {
|
||||
return qaPairs;
|
||||
}
|
||||
|
||||
// 提取每个 QA 对
|
||||
for (int i = 0; i < headingPositions.size(); i++) {
|
||||
int start = headingPositions.get(i);
|
||||
int end = (i + 1 < headingPositions.size())
|
||||
? headingPositions.get(i + 1)
|
||||
: content.length();
|
||||
String qaText = content.substring(start, end).trim();
|
||||
String question = questions.get(i);
|
||||
// 提取答案部分(去掉问题标题)
|
||||
String answer = qaText.substring(qaText.indexOf('\n') + 1).trim();
|
||||
qaPairs.add(new QaPair(question, answer, qaText));
|
||||
}
|
||||
return qaPairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切分单个 QA 对
|
||||
*
|
||||
* @param qaPair QA 对
|
||||
* @return 切分后的文本片段列表
|
||||
*/
|
||||
private List<String> splitQaPair(QaPair qaPair) {
|
||||
// 如果整个 QA 对不超过限制,保持完整
|
||||
List<String> chunks = new ArrayList<>();
|
||||
String fullQa = qaPair.fullText;
|
||||
int qaTokens = tokenEstimator.estimate(fullQa);
|
||||
if (qaTokens <= chunkSize) {
|
||||
chunks.add(fullQa);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// 长答案需要切分
|
||||
log.debug("QA 对超过 Token 限制 ({} > {}),开始智能切分: {}", qaTokens, chunkSize, qaPair.question);
|
||||
List<String> answerChunks = splitLongAnswer(qaPair.answer, qaPair.question);
|
||||
for (String answerChunk : answerChunks) {
|
||||
// 每个片段都包含完整问题
|
||||
String chunkText = "## " + qaPair.question + "\n" + answerChunk;
|
||||
chunks.add(chunkText);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切分长答案
|
||||
*
|
||||
* @param answer 答案文本
|
||||
* @param question 问题文本
|
||||
* @return 切分后的答案片段列表
|
||||
*/
|
||||
private List<String> splitLongAnswer(String answer, String question) {
|
||||
List<String> chunks = new ArrayList<>();
|
||||
// 预留问题的 Token 空间
|
||||
String questionHeader = "## " + question + "\n";
|
||||
int questionTokens = tokenEstimator.estimate(questionHeader);
|
||||
int availableTokens = chunkSize - questionTokens - 10; // 预留 10 个 Token 的缓冲
|
||||
|
||||
// 先按段落切分
|
||||
String[] paragraphs = answer.split(PARAGRAPH_SEPARATOR);
|
||||
StringBuilder currentChunk = new StringBuilder();
|
||||
int currentTokens = 0;
|
||||
for (String paragraph : paragraphs) {
|
||||
if (StrUtil.isEmpty(paragraph)) {
|
||||
continue;
|
||||
}
|
||||
int paragraphTokens = tokenEstimator.estimate(paragraph);
|
||||
// 如果单个段落就超过限制,需要按句子切分
|
||||
if (paragraphTokens > availableTokens) {
|
||||
// 先保存当前块
|
||||
if (currentChunk.length() > 0) {
|
||||
chunks.add(currentChunk.toString().trim());
|
||||
currentChunk = new StringBuilder();
|
||||
currentTokens = 0;
|
||||
}
|
||||
// 按句子切分长段落
|
||||
chunks.addAll(splitLongParagraph(paragraph, availableTokens));
|
||||
continue;
|
||||
}
|
||||
// 如果加上这个段落会超过限制
|
||||
if (currentTokens + paragraphTokens > availableTokens && currentChunk.length() > 0) {
|
||||
chunks.add(currentChunk.toString().trim());
|
||||
currentChunk = new StringBuilder();
|
||||
currentTokens = 0;
|
||||
}
|
||||
if (currentChunk.length() > 0) {
|
||||
currentChunk.append("\n\n");
|
||||
}
|
||||
// 添加段落
|
||||
currentChunk.append(paragraph);
|
||||
currentTokens += paragraphTokens;
|
||||
}
|
||||
|
||||
// 添加最后一块
|
||||
if (currentChunk.length() > 0) {
|
||||
chunks.add(currentChunk.toString().trim());
|
||||
}
|
||||
return CollUtil.isEmpty(chunks) ? Collections.singletonList(answer) : chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切分长段落(按句子)
|
||||
*
|
||||
* @param paragraph 段落文本
|
||||
* @param availableTokens 可用的 Token 数
|
||||
* @return 切分后的文本片段列表
|
||||
*/
|
||||
private List<String> splitLongParagraph(String paragraph, int availableTokens) {
|
||||
// 按句子切分
|
||||
List<String> chunks = new ArrayList<>();
|
||||
String[] sentences = SENTENCE_PATTERN.split(paragraph);
|
||||
|
||||
// 按句子累积切分
|
||||
StringBuilder currentChunk = new StringBuilder();
|
||||
int currentTokens = 0;
|
||||
for (String sentence : sentences) {
|
||||
if (StrUtil.isEmpty(sentence)) {
|
||||
continue;
|
||||
}
|
||||
int sentenceTokens = tokenEstimator.estimate(sentence);
|
||||
// 如果单个句子就超过限制,强制切分
|
||||
if (sentenceTokens > availableTokens) {
|
||||
if (currentChunk.length() > 0) {
|
||||
chunks.add(currentChunk.toString().trim());
|
||||
currentChunk = new StringBuilder();
|
||||
currentTokens = 0;
|
||||
}
|
||||
chunks.add(sentence.trim());
|
||||
continue;
|
||||
}
|
||||
// 如果加上这个句子会超过限制
|
||||
if (currentTokens + sentenceTokens > availableTokens && currentChunk.length() > 0) {
|
||||
chunks.add(currentChunk.toString().trim());
|
||||
currentChunk = new StringBuilder();
|
||||
currentTokens = 0;
|
||||
}
|
||||
// 添加句子
|
||||
currentChunk.append(sentence);
|
||||
currentTokens += sentenceTokens;
|
||||
}
|
||||
|
||||
// 添加最后一块
|
||||
if (currentChunk.length() > 0) {
|
||||
chunks.add(currentChunk.toString().trim());
|
||||
}
|
||||
return chunks.isEmpty() ? Collections.singletonList(paragraph) : chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 降级切分策略(当未识别到 QA 格式时)
|
||||
*
|
||||
* @param content 文本内容
|
||||
* @return 切分后的文本片段列表
|
||||
*/
|
||||
private List<String> fallbackSplit(String content) {
|
||||
// 按段落切分
|
||||
List<String> chunks = new ArrayList<>();
|
||||
String[] paragraphs = content.split(PARAGRAPH_SEPARATOR);
|
||||
|
||||
// 按段落累积切分
|
||||
StringBuilder currentChunk = new StringBuilder();
|
||||
int currentTokens = 0;
|
||||
for (String paragraph : paragraphs) {
|
||||
if (StrUtil.isEmpty(paragraph)) {
|
||||
continue;
|
||||
}
|
||||
int paragraphTokens = tokenEstimator.estimate(paragraph);
|
||||
// 如果加上这个段落会超过限制
|
||||
if (currentTokens + paragraphTokens > chunkSize && currentChunk.length() > 0) {
|
||||
chunks.add(currentChunk.toString().trim());
|
||||
currentChunk = new StringBuilder();
|
||||
currentTokens = 0;
|
||||
}
|
||||
// 添加段落
|
||||
if (currentChunk.length() > 0) {
|
||||
currentChunk.append("\n\n");
|
||||
}
|
||||
currentChunk.append(paragraph);
|
||||
currentTokens += paragraphTokens;
|
||||
}
|
||||
|
||||
// 添加最后一块
|
||||
if (currentChunk.length() > 0) {
|
||||
chunks.add(currentChunk.toString().trim());
|
||||
}
|
||||
return chunks.isEmpty() ? Collections.singletonList(content) : chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* QA 对数据结构
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
private static class QaPair {
|
||||
|
||||
String question;
|
||||
String answer;
|
||||
String fullText;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 估算器接口
|
||||
*/
|
||||
public interface TokenEstimator {
|
||||
|
||||
int estimate(String text);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的 Token 估算器实现
|
||||
* 中文:1 字符 ≈ 1 Token
|
||||
* 英文:1 单词 ≈ 1.3 Token
|
||||
*/
|
||||
private static class SimpleTokenEstimator implements TokenEstimator {
|
||||
|
||||
@Override
|
||||
public int estimate(String text) {
|
||||
if (StrUtil.isEmpty(text)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int chineseChars = 0;
|
||||
int englishWords = 0;
|
||||
// 简单统计中英文
|
||||
for (char c : text.toCharArray()) {
|
||||
if (c >= 0x4E00 && c <= 0x9FA5) {
|
||||
chineseChars++;
|
||||
}
|
||||
}
|
||||
// 英文单词估算
|
||||
String[] words = text.split("\\s+");
|
||||
for (String word : words) {
|
||||
if (word.matches(".*[a-zA-Z].*")) {
|
||||
englishWords++;
|
||||
}
|
||||
}
|
||||
return chineseChars + (int) (englishWords * 1.3);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,301 +0,0 @@
|
|||
package cn.iocoder.yudao.module.ai.service.knowledge.splitter;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.transformer.splitter.TextSplitter;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 语义化文本切片器
|
||||
*
|
||||
* <p>功能特点:
|
||||
* <ul>
|
||||
* <li>优先在段落边界(双换行)处切分</li>
|
||||
* <li>其次在句子边界(句号、问号、感叹号)处切分</li>
|
||||
* <li>避免在句子中间截断,保持语义完整性</li>
|
||||
* <li>支持中英文标点符号识别</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author runzhen
|
||||
*/
|
||||
@Slf4j
|
||||
public class SemanticTextSplitter extends TextSplitter {
|
||||
|
||||
/**
|
||||
* 分段的最大 Token 数
|
||||
*/
|
||||
private final int chunkSize;
|
||||
|
||||
/**
|
||||
* 段落重叠大小(用于保持上下文连贯性)
|
||||
*/
|
||||
private final int chunkOverlap;
|
||||
|
||||
/**
|
||||
* 段落分隔符(按优先级排序)
|
||||
*/
|
||||
private static final List<String> PARAGRAPH_SEPARATORS = Arrays.asList(
|
||||
"\n\n\n", // 三个换行
|
||||
"\n\n", // 双换行
|
||||
"\n" // 单换行
|
||||
);
|
||||
|
||||
/**
|
||||
* 句子结束标记(中英文标点)
|
||||
*/
|
||||
private static final Pattern SENTENCE_END_PATTERN = Pattern.compile(
|
||||
"[。!?.!?]+[\\s\"'))】\\]]*"
|
||||
);
|
||||
|
||||
/**
|
||||
* Token 估算器
|
||||
*/
|
||||
private final MarkdownQaSplitter.TokenEstimator tokenEstimator;
|
||||
|
||||
public SemanticTextSplitter(int chunkSize, int chunkOverlap) {
|
||||
this.chunkSize = chunkSize;
|
||||
this.chunkOverlap = Math.min(chunkOverlap, chunkSize / 2); // 重叠不超过一半
|
||||
this.tokenEstimator = new SimpleTokenEstimator();
|
||||
}
|
||||
|
||||
public SemanticTextSplitter(int chunkSize) {
|
||||
this(chunkSize, 50); // 默认重叠 50 个 Token
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<String> splitText(String text) {
|
||||
if (StrUtil.isEmpty(text)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return splitTextRecursive(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切分文本(递归策略)
|
||||
*
|
||||
* @param text 待切分文本
|
||||
* @return 切分后的文本块列表
|
||||
*/
|
||||
private List<String> splitTextRecursive(String text) {
|
||||
List<String> chunks = new ArrayList<>();
|
||||
|
||||
// 如果文本不超过限制,直接返回
|
||||
int textTokens = tokenEstimator.estimate(text);
|
||||
if (textTokens <= chunkSize) {
|
||||
chunks.add(text.trim());
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// 尝试按不同分隔符切分
|
||||
List<String> splits = null;
|
||||
String usedSeparator = null;
|
||||
for (String separator : PARAGRAPH_SEPARATORS) {
|
||||
if (text.contains(separator)) {
|
||||
splits = Arrays.asList(text.split(Pattern.quote(separator)));
|
||||
usedSeparator = separator;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到段落分隔符,按句子切分
|
||||
if (splits == null || splits.size() == 1) {
|
||||
splits = splitBySentences(text);
|
||||
usedSeparator = ""; // 句子切分不需要分隔符
|
||||
}
|
||||
|
||||
// 合并小片段
|
||||
chunks = mergeSplits(splits, usedSeparator);
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按句子切分
|
||||
*
|
||||
* @param text 待切分文本
|
||||
* @return 句子列表
|
||||
*/
|
||||
private List<String> splitBySentences(String text) {
|
||||
// 使用正则表达式匹配句子结束位置
|
||||
List<String> sentences = new ArrayList<>();
|
||||
int lastEnd = 0;
|
||||
Matcher matcher = SENTENCE_END_PATTERN.matcher(text);
|
||||
while (matcher.find()) {
|
||||
String sentence = text.substring(lastEnd, matcher.end()).trim();
|
||||
if (StrUtil.isNotEmpty(sentence)) {
|
||||
sentences.add(sentence);
|
||||
}
|
||||
lastEnd = matcher.end();
|
||||
}
|
||||
|
||||
// 添加剩余部分
|
||||
if (lastEnd < text.length()) {
|
||||
String remaining = text.substring(lastEnd).trim();
|
||||
if (StrUtil.isNotEmpty(remaining)) {
|
||||
sentences.add(remaining);
|
||||
}
|
||||
}
|
||||
return sentences.isEmpty() ? Collections.singletonList(text) : sentences;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并切分后的小片段
|
||||
*
|
||||
* @param splits 切分后的片段列表
|
||||
* @param separator 片段间的分隔符
|
||||
* @return 合并后的文本块列表
|
||||
*/
|
||||
private List<String> mergeSplits(List<String> splits, String separator) {
|
||||
List<String> chunks = new ArrayList<>();
|
||||
List<String> currentChunks = new ArrayList<>();
|
||||
int currentLength = 0;
|
||||
|
||||
for (String split : splits) {
|
||||
if (StrUtil.isEmpty(split)) {
|
||||
continue;
|
||||
}
|
||||
int splitTokens = tokenEstimator.estimate(split);
|
||||
// 如果单个片段就超过限制,进一步递归切分
|
||||
if (splitTokens > chunkSize) {
|
||||
// 先保存当前累积的块
|
||||
if (!currentChunks.isEmpty()) {
|
||||
String chunkText = String.join(separator, currentChunks);
|
||||
chunks.add(chunkText.trim());
|
||||
currentChunks.clear();
|
||||
currentLength = 0;
|
||||
}
|
||||
// 递归切分大片段
|
||||
if (!separator.isEmpty()) {
|
||||
// 如果是段落分隔符,尝试按句子切分
|
||||
chunks.addAll(splitTextRecursive(split));
|
||||
} else {
|
||||
// 如果已经是句子级别,强制按字符切分
|
||||
chunks.addAll(forceSplitLongText(split));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 计算加上分隔符的 Token 数
|
||||
int separatorTokens = StrUtil.isEmpty(separator) ? 0 : tokenEstimator.estimate(separator);
|
||||
// 如果加上这个片段会超过限制
|
||||
if (!currentChunks.isEmpty() && currentLength + splitTokens + separatorTokens > chunkSize) {
|
||||
// 保存当前块
|
||||
String chunkText = String.join(separator, currentChunks);
|
||||
chunks.add(chunkText.trim());
|
||||
|
||||
// 处理重叠:保留最后几个片段
|
||||
currentChunks = getOverlappingChunks(currentChunks, separator);
|
||||
currentLength = estimateTokens(currentChunks, separator);
|
||||
}
|
||||
// 添加当前片段
|
||||
currentChunks.add(split);
|
||||
currentLength += splitTokens + separatorTokens;
|
||||
}
|
||||
|
||||
// 添加最后一块
|
||||
if (!currentChunks.isEmpty()) {
|
||||
String chunkText = String.join(separator, currentChunks);
|
||||
chunks.add(chunkText.trim());
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取重叠的片段(用于保持上下文)
|
||||
*
|
||||
* @param chunks 当前片段列表
|
||||
* @param separator 片段间的分隔符
|
||||
* @return 重叠的片段列表
|
||||
*/
|
||||
private List<String> getOverlappingChunks(List<String> chunks, String separator) {
|
||||
if (chunkOverlap == 0 || chunks.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
// 从后往前取片段,直到达到重叠大小
|
||||
List<String> overlapping = new ArrayList<>();
|
||||
int tokens = 0;
|
||||
for (int i = chunks.size() - 1; i >= 0; i--) {
|
||||
String chunk = chunks.get(i);
|
||||
int chunkTokens = tokenEstimator.estimate(chunk);
|
||||
if (tokens + chunkTokens > chunkOverlap) {
|
||||
break;
|
||||
}
|
||||
// 添加到重叠列表前端
|
||||
overlapping.add(0, chunk);
|
||||
tokens += chunkTokens + (StrUtil.isEmpty(separator) ? 0 : tokenEstimator.estimate(separator));
|
||||
}
|
||||
return overlapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* 估算片段列表的总 Token 数
|
||||
*
|
||||
* @param chunks 片段列表
|
||||
* @param separator 片段间的分隔符
|
||||
* @return 总 Token 数
|
||||
*/
|
||||
private int estimateTokens(List<String> chunks, String separator) {
|
||||
int total = 0;
|
||||
for (int i = 0; i < chunks.size(); i++) {
|
||||
total += tokenEstimator.estimate(chunks.get(i));
|
||||
if (i < chunks.size() - 1 && StrUtil.isNotEmpty(separator)) {
|
||||
total += tokenEstimator.estimate(separator);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制切分长文本(当语义切分失败时)
|
||||
*
|
||||
* @param text 待切分文本
|
||||
* @return 切分后的文本块列表
|
||||
*/
|
||||
private List<String> forceSplitLongText(String text) {
|
||||
List<String> chunks = new ArrayList<>();
|
||||
int charsPerChunk = (int) (chunkSize * 0.8); // 保守估计
|
||||
for (int i = 0; i < text.length(); i += charsPerChunk) {
|
||||
int end = Math.min(i + charsPerChunk, text.length());
|
||||
String chunk = text.substring(i, end);
|
||||
chunks.add(chunk.trim());
|
||||
}
|
||||
log.warn("文本过长,已强制按字符切分,可能影响语义完整性");
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的 Token 估算器实现
|
||||
*/
|
||||
private static class SimpleTokenEstimator implements MarkdownQaSplitter.TokenEstimator {
|
||||
|
||||
@Override
|
||||
public int estimate(String text) {
|
||||
if (StrUtil.isEmpty(text)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int chineseChars = 0;
|
||||
int englishWords = 0;
|
||||
// 简单统计中英文
|
||||
for (char c : text.toCharArray()) {
|
||||
if (c >= 0x4E00 && c <= 0x9FA5) {
|
||||
chineseChars++;
|
||||
}
|
||||
}
|
||||
// 英文单词估算
|
||||
String[] words = text.split("\\s+");
|
||||
for (String word : words) {
|
||||
if (word.matches(".*[a-zA-Z].*")) {
|
||||
englishWords++;
|
||||
}
|
||||
}
|
||||
return chineseChars + (int) (englishWords * 1.3);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -84,7 +84,7 @@ public class UserProfileQueryToolFunction
|
|||
request.setId(loginUser.getId());
|
||||
}
|
||||
return TenantUtils.execute(tenantId, () -> {
|
||||
AdminUserRespDTO user = adminUserApi.getUser(request.getId());
|
||||
AdminUserRespDTO user = adminUserApi.getUser(request.getId()).getCheckedData();
|
||||
return BeanUtils.toBean(user, Response.class);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
package cn.iocoder.yudao.module.ai.tool.method;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
package cn.iocoder.yudao.module.ai.util;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.collection.SetUtils;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.module.ai.enums.model.AiPlatformEnum;
|
||||
|
|
@ -18,7 +16,7 @@ import org.springframework.ai.chat.prompt.ChatOptions;
|
|||
import org.springframework.ai.deepseek.DeepSeekAssistantMessage;
|
||||
import org.springframework.ai.deepseek.DeepSeekChatOptions;
|
||||
import org.springframework.ai.minimax.MiniMaxChatOptions;
|
||||
import org.springframework.ai.ollama.api.OllamaChatOptions;
|
||||
import org.springframework.ai.ollama.api.OllamaOptions;
|
||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.zhipuai.ZhiPuAiChatOptions;
|
||||
|
|
@ -35,28 +33,6 @@ public class AiUtils {
|
|||
public static final String TOOL_CONTEXT_LOGIN_USER = "LOGIN_USER";
|
||||
public static final String TOOL_CONTEXT_TENANT_ID = "TENANT_ID";
|
||||
|
||||
/**
|
||||
* 通义千问支持多模态的模型
|
||||
*
|
||||
* @see <a href="https://bailian.console.aliyun.com/cn-beijing/?tab=model#/model-market/all?providers=qwen&capabilities=VU">模型广场</a>
|
||||
* @see <a href="https://help.aliyun.com/zh/model-studio/error-code#error-url">必须开启 withMultiModel 参数</a>
|
||||
*/
|
||||
public static final Set<String> TONG_YI_MULTI_MODELS = SetUtils.asSet(
|
||||
// qwen3.5 / 3.6 系列(统一多模态主干)
|
||||
"qwen3.6-plus", "qwen3.6-flash",
|
||||
"qwen3.5-plus", "qwen3.5-flash",
|
||||
// qwen-vl 视觉理解
|
||||
"qwen3-vl-plus", "qwen3-vl-flash",
|
||||
"qwen-vl-max", "qwen-vl-plus",
|
||||
"qwen2.5-vl-72b-instruct", "qwen2.5-vl-32b-instruct",
|
||||
"qwen2.5-vl-7b-instruct", "qwen2.5-vl-3b-instruct",
|
||||
// qvq 视觉推理
|
||||
"qvq-max", "qvq-plus",
|
||||
// qwen-omni 全模态
|
||||
"qwen3.5-omni-plus", "qwen3.5-omni-flash",
|
||||
"qwen3-omni-flash", "qwen-omni-turbo"
|
||||
);
|
||||
|
||||
public static ChatOptions buildChatOptions(AiPlatformEnum platform, String model, Double temperature, Integer maxTokens) {
|
||||
return buildChatOptions(platform, model, temperature, maxTokens, null, null);
|
||||
}
|
||||
|
|
@ -68,10 +44,9 @@ public class AiUtils {
|
|||
// noinspection EnhancedSwitchMigration
|
||||
switch (platform) {
|
||||
case TONG_YI:
|
||||
return DashScopeChatOptions.builder().model(model).temperature(temperature).maxToken(maxTokens)
|
||||
.enableThinking(true) // TODO 芋艿:默认都开启 thinking 模式,后续可以让用户配置
|
||||
.multiModel(TONG_YI_MULTI_MODELS.contains(model)) // 是否多模态模型
|
||||
.toolCallbacks(toolCallbacks).toolContext(toolContext).build();
|
||||
return DashScopeChatOptions.builder().withModel(model).withTemperature(temperature).withMaxToken(maxTokens)
|
||||
.withEnableThinking(true) // TODO 芋艿:默认都开启 thinking 模式,后续可以让用户配置
|
||||
.withToolCallbacks(toolCallbacks).withToolContext(toolContext).build();
|
||||
case YI_YAN:
|
||||
return QianFanChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens).build();
|
||||
case DEEP_SEEK:
|
||||
|
|
@ -93,7 +68,6 @@ public class AiUtils {
|
|||
case OPENAI:
|
||||
case GEMINI: // 复用 OpenAI 客户端
|
||||
case BAI_CHUAN: // 复用 OpenAI 客户端
|
||||
case GROK: // 复用 OpenAI 客户端
|
||||
return OpenAiChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens)
|
||||
.toolCallbacks(toolCallbacks).toolContext(toolContext).build();
|
||||
case AZURE_OPENAI:
|
||||
|
|
@ -103,7 +77,7 @@ public class AiUtils {
|
|||
return AnthropicChatOptions.builder().model(model).temperature(temperature).maxTokens(maxTokens)
|
||||
.toolCallbacks(toolCallbacks).toolContext(toolContext).build();
|
||||
case OLLAMA:
|
||||
return OllamaChatOptions.builder().model(model).temperature(temperature).numPredict(maxTokens)
|
||||
return OllamaOptions.builder().model(model).temperature(temperature).numPredict(maxTokens)
|
||||
.toolCallbacks(toolCallbacks).toolContext(toolContext).build();
|
||||
default:
|
||||
throw new IllegalArgumentException(StrUtil.format("未知平台({})", platform));
|
||||
|
|
@ -150,13 +124,10 @@ public class AiUtils {
|
|||
|| response.getResult().getOutput() == null) {
|
||||
return null;
|
||||
}
|
||||
AssistantMessage output = response.getResult().getOutput();
|
||||
// DeepSeek 通过专属 AssistantMessage 暴露 reasoningContent
|
||||
if (output instanceof DeepSeekAssistantMessage) {
|
||||
return ((DeepSeekAssistantMessage) output).getReasoningContent();
|
||||
if (response.getResult().getOutput() instanceof DeepSeekAssistantMessage) {
|
||||
return ((DeepSeekAssistantMessage) (response.getResult().getOutput())).getReasoningContent();
|
||||
}
|
||||
// 通义千问等通过 metadata 透传 reasoningContent
|
||||
return MapUtil.getStr(output.getMetadata(), "reasoningContent");
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ xxl:
|
|||
# Lock4j 配置项
|
||||
lock4j:
|
||||
acquire-timeout: 3000 # 获取分布式锁超时时间,默认为 3000 毫秒
|
||||
expire: 30000 # 分布式锁的超时时间,默认为 30000 毫秒
|
||||
expire: 30000 # 分布式锁的超时时间,默认为 30 毫秒
|
||||
|
||||
--- #################### 监控相关配置 ####################
|
||||
|
||||
|
|
@ -123,8 +123,6 @@ spring:
|
|||
client:
|
||||
instance:
|
||||
service-host-type: IP # 注册实例时,优先使用 IP [IP, HOST_NAME, CANONICAL_HOST_NAME]
|
||||
username: admin
|
||||
password: admin
|
||||
# Spring Boot Admin Server 服务端的相关配置
|
||||
context-path: /admin # 配置 Spring
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ xxl:
|
|||
# Lock4j 配置项
|
||||
lock4j:
|
||||
acquire-timeout: 3000 # 获取分布式锁超时时间,默认为 3000 毫秒
|
||||
expire: 30000 # 分布式锁的超时时间,默认为 30000 毫秒
|
||||
expire: 30000 # 分布式锁的超时时间,默认为 30 毫秒
|
||||
|
||||
--- #################### 监控相关配置 ####################
|
||||
|
||||
|
|
@ -118,8 +118,6 @@ spring:
|
|||
client:
|
||||
instance:
|
||||
service-host-type: IP # 注册实例时,优先使用 IP [IP, HOST_NAME, CANONICAL_HOST_NAME]
|
||||
username: admin
|
||||
password: admin
|
||||
|
||||
# 日志文件配置
|
||||
logging:
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ spring:
|
|||
api-key: sk-47aa124781be4bfb95244cc62f6xxxx
|
||||
minimax: # Minimax:https://www.minimaxi.com/
|
||||
api-key: xxxx
|
||||
moonshot: # 月之暗面(KIMI)
|
||||
moonshot: # 月之暗灭(KIMI)
|
||||
api-key: sk-abc
|
||||
deepseek: # DeepSeek
|
||||
api-key: sk-e94db327cc7d457d99a8de8810fc6b12
|
||||
|
|
@ -168,8 +168,6 @@ spring:
|
|||
filesystem:
|
||||
url: http://127.0.0.1:8089
|
||||
sse-endpoint: /sse
|
||||
annotation-scanner:
|
||||
enabled: false # TODO @芋艿:有 bug https://github.com/spring-projects/spring-ai/issues/4917 需要官方修复
|
||||
|
||||
yudao:
|
||||
ai:
|
||||
|
|
|
|||