diff --git a/pom.xml b/pom.xml index 0979b18..f80028f 100644 --- a/pom.xml +++ b/pom.xml @@ -35,6 +35,7 @@ 2.3.1 6.0.0 2.8.14 + 1.1.7 @@ -50,6 +51,15 @@ import + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + com.alibaba diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index e46b9ec..ed4b3fd 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -159,3 +159,44 @@ search: engine: es es: enabled: true + +# 官网 AI 智能咨询配置 +ai: + # 默认模型路由键,当客户端请求未指定具体模型时,由此配置决定分流至哪个大模型实例,降低前端切换成本 + default-model: deepseek + # 大模型 HTTP 调用的超时阈值,30秒是为网络抖动、流式/推理模型的首 Token 生成延迟预留的合理边界,避免网关层提前断开连接 + request-timeout-seconds: 30 + knowledge-base: + # 检索召回的 TopK 阈值。官网 JSON 承载的信息相对紧凑,过大(如 >10)容易召回低相关内容稀释 Prompt,过小(如 <3)可能导致关键上下文缺失 + top-k: 5 + # 分片的最大字符限制,若单片过长会挤占大模型 Context Window 从而产生额外 Token 计费,过短则会割裂产品介绍的完整上下文语义 + max-chunk-size: 2000 + # 本地知识库的缓存失效时间(5分钟)。由于官网页面配置在后台属于低频更新,缓存可大幅降低每次问答对复杂 JSON 进行反射提取与分词比对的计算负担 + cache-seconds: 300 + models: + deepseek: + # 通过环境变量 DEEPSEEK_API_KEY 动态注入凭证,避免明文硬编码导致凭证泄露进代码仓库 + api-key: ${DEEPSEEK_API_KEY:} + # 官方标准兼容 OpenAI 规范的 API 基础端点,无需追加 /chat/completions 后缀,Spring AI 框架在运行时会自动追加特定路径 + base-url: https://api.deepseek.com + # 默认选择 deepseek-v4-flash 作为线上常驻的轻量快响应模型(deepseek-chat 存在废弃计划),以降低交互式对话的总体延迟与 Token 消耗 + model: deepseek-v4-flash + # 温度值控制模型创造力。0.7 能够在保持海威物联产品回答严谨性(低幻觉)的前提下,提供适当的语言润色,避免行文过于机械 + temperature: 0.7 + # 限制单次响应的最大 Token 数,防止大模型异常输出(如陷入无限死循环或生成冗长废话),同时控制单次调用的计费上限 + max-tokens: 2048 + # 采样累积概率。0.9 配合 temperature 可过滤掉长尾低频词,保证输出句式的连贯与符合常规语法 + top-p: 0.9 + # 针对常规客服问答,关闭深度思考能力以换取秒级极速响应;对复杂推理,可结合 reasoning-effort 控制深度 + thinking-enabled: false + reasoning-effort: + ollama: + # 本地 Ollama 实例默认不设置安全鉴权,但部分网关或客户端组件要求 Authorization 头不能为空,此处使用占位符以兼容 OpenAI 风格请求头 + api-key: ${OLLAMA_API_KEY:ollama} + # 本地 Ollama 默认监听的端口地址,末尾的 v1 表示启用兼容 OpenAI Chat 格式的 API 适配层 + base-url: http://localhost:11434/v1 + # 选用千问 2.5 7B 模型作为本地局域网或内网沙箱测试的替代方案,提供免受公网网络抖动干扰的稳定测试环境 + model: qwen2.5:7b + temperature: 0.7 + max-tokens: 2048 + top-p: 0.9 diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/mybatis/handler/JsonNodeTypeHandler.java b/ruoyi-common/src/main/java/com/ruoyi/common/mybatis/handler/JsonNodeTypeHandler.java new file mode 100644 index 0000000..cf3e4b6 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/mybatis/handler/JsonNodeTypeHandler.java @@ -0,0 +1,63 @@ +package com.ruoyi.common.mybatis.handler; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * MySQL JSON字段与Jackson JsonNode互转处理器。 + * + * 业务上JSON字段常用于承载可配置页面、扩展属性等变化较快的数据结构,统一放在common中复用, + * 避免各业务模块重复编写字符串转换逻辑。 + */ +public class JsonNodeTypeHandler extends BaseTypeHandler +{ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @Override + public void setNonNullParameter(PreparedStatement ps, int i, JsonNode parameter, JdbcType jdbcType) throws SQLException + { + ps.setString(i, parameter.toString()); + } + + @Override + public JsonNode getNullableResult(ResultSet rs, String columnName) throws SQLException + { + return parseJsonNode(rs.getString(columnName)); + } + + @Override + public JsonNode getNullableResult(ResultSet rs, int columnIndex) throws SQLException + { + return parseJsonNode(rs.getString(columnIndex)); + } + + @Override + public JsonNode getNullableResult(CallableStatement cs, int columnIndex) throws SQLException + { + return parseJsonNode(cs.getString(columnIndex)); + } + + private JsonNode parseJsonNode(String value) throws SQLException + { + if (value == null || value.isEmpty()) + { + return null; + } + try + { + return OBJECT_MAPPER.readTree(value); + } + catch (Exception e) + { + // 数据库JSON字段如存在脏数据,直接抛出让全局异常处理接管,避免静默返回错误内容。 + throw new SQLException("parse mysql json column failed", e); + } + } +} diff --git a/ruoyi-portal/pom.xml b/ruoyi-portal/pom.xml index d1319b5..8c900e0 100644 --- a/ruoyi-portal/pom.xml +++ b/ruoyi-portal/pom.xml @@ -22,6 +22,12 @@ ruoyi-common + + + org.springframework.ai + spring-ai-openai + + diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/config/AiProperties.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/config/AiProperties.java new file mode 100644 index 0000000..2ae3171 --- /dev/null +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/config/AiProperties.java @@ -0,0 +1,269 @@ +package com.ruoyi.portal.ai.config; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 官网 AI 配置属性映射类。 + * + *

配置只描述模型路由和检索边界,密钥通过环境变量注入,避免把生产凭据落入代码仓库。

+ */ +@Component +@ConfigurationProperties(prefix = "ai") +public class AiProperties +{ + /** + * 默认模型标识。 + * 当客户端请求未指定 model 参数时,后端自动降级或路由到该默认模型,避免前端硬编码带来的灵活性丧失。 + */ + private String defaultModel = "deepseek"; + + /** + * 大模型 API 请求超时限制(秒)。 + * 设置合理的超时时间(如 30s),防止由于下游大模型服务响应延迟、卡顿或长文本输出异常,导致后端 Web 容器线程被长期霸占从而引发连接池耗尽。 + */ + private int requestTimeoutSeconds = 30; + + /** + * 知识库文档检索配置。 + * 控制 RAG (Retrieval-Augmented Generation) 检索阶段的分片大小与缓存等核心参数。 + */ + private KnowledgeBase knowledgeBase = new KnowledgeBase(); + + /** + * 注册的可用 AI 模型映射字典。 + * Key 为模型别名(如 deepseek, ollama),Value 为对应的 endpoint 及调用超参配置。 + * 业务意图:支持多模型动态路由及灾备切换。 + */ + private Map models = new HashMap<>(); + + public String getDefaultModel() + { + return defaultModel; + } + + public void setDefaultModel(String defaultModel) + { + this.defaultModel = defaultModel; + } + + public int getRequestTimeoutSeconds() + { + return requestTimeoutSeconds; + } + + public void setRequestTimeoutSeconds(int requestTimeoutSeconds) + { + this.requestTimeoutSeconds = requestTimeoutSeconds; + } + + public KnowledgeBase getKnowledgeBase() + { + return knowledgeBase; + } + + public void setKnowledgeBase(KnowledgeBase knowledgeBase) + { + this.knowledgeBase = knowledgeBase; + } + + public Map getModels() + { + return models; + } + + public void setModels(Map models) + { + this.models = models; + } + + /** + * 官网本地知识库检索配置超参。 + */ + public static class KnowledgeBase + { + /** + * 召回 Top-K 分片数量上限。 + * 官网 JSON 承载的信息相对精简,设定为 5 以内可获取足够的上下文,又防止混入低相关数据干扰或稀释 Prompt。 + */ + private int topK = 5; + + /** + * 单个文本块的最大长度限制(字符数)。 + * 2000 字符可在保留产品介绍段落完整语义的基础上,规避单块过大导致模型窗口溢出或推理 Token 成本暴增。 + */ + private int maxChunkSize = 2000; + + /** + * 知识库解析结果在 JVM 内存的缓存周期(秒)。 + * 默认 300 秒(5 分钟)。官网内容更新极不频繁,缓存机制可规避并发访问时重复反射读取并解析大 JSON 的计算损耗。 + */ + private int cacheSeconds = 300; + + public int getTopK() + { + return topK; + } + + public void setTopK(int topK) + { + this.topK = topK; + } + + public int getMaxChunkSize() + { + return maxChunkSize; + } + + public void setMaxChunkSize(int maxChunkSize) + { + this.maxChunkSize = maxChunkSize; + } + + public int getCacheSeconds() + { + return cacheSeconds; + } + + public void setCacheSeconds(int cacheSeconds) + { + this.cacheSeconds = cacheSeconds; + } + } + + /** + * 单个大模型实例的接入与调用配置参数。 + */ + public static class ModelConfig + { + /** + * 大模型服务 API 密钥(对应 Authorization Bearer 令牌)。 + * 必须在外部环境中注入,严禁明文落库或存在于代码中。 + */ + private String apiKey; + + /** + * API 的基础访问端点(Base URL)。 + * 需指向兼容 OpenAI 格式的 HTTP 地址(例如本地 Ollama 或外部 DeepSeek)。 + */ + private String baseUrl; + + /** + * 映射的远端大模型名称标识(例如 deepseek-v4-flash, qwen2.5:7b 等)。 + */ + private String model; + + /** + * 控制生成文本随机性的温度参数(Temperature)。 + * 推荐 0.7。较低的温度有利于官网客服生成更确定、低幻觉的产品描述,但过低(如 <0.2)则会使回答显得十分死板。 + */ + private Double temperature = 0.7D; + + /** + * 大模型单次生成响应的最大 Token 限制。 + * 设为 2048 以阻断大模型因异常陷入“复读机”或超长废话的无限输出状态,进而防止计费失控。 + */ + private Integer maxTokens = 2048; + + /** + * 采样过滤核心概率(Top P)。 + * 默认 0.9。控制高频词的选择池范围,配合 temperature 能够让模型回答更流畅、表达更多样。 + */ + private Double topP = 0.9D; + + /** + * 推理难度/步骤配置参数(针对支持深度思考/Reasoning 的模型,如 o1, deepseek-r1 等)。 + */ + private String reasoningEffort; + + /** + * 是否显式启用大模型的思维链/思考过程输出。 + * 部分 OpenAI 兼容接口(如 DeepSeek)需要特定参数启用思考输出; + * 针对官网访客客服,关闭它可以缩短首字呈现时间并削减多余的 Token 支出。 + */ + private boolean thinkingEnabled; + + public String getApiKey() + { + return apiKey; + } + + public void setApiKey(String apiKey) + { + this.apiKey = apiKey; + } + + public String getBaseUrl() + { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) + { + this.baseUrl = baseUrl; + } + + public String getModel() + { + return model; + } + + public void setModel(String model) + { + this.model = model; + } + + public Double getTemperature() + { + return temperature; + } + + public void setTemperature(Double temperature) + { + this.temperature = temperature; + } + + public Integer getMaxTokens() + { + return maxTokens; + } + + public void setMaxTokens(Integer maxTokens) + { + this.maxTokens = maxTokens; + } + + public Double getTopP() + { + return topP; + } + + public void setTopP(Double topP) + { + this.topP = topP; + } + + public String getReasoningEffort() + { + return reasoningEffort; + } + + public void setReasoningEffort(String reasoningEffort) + { + this.reasoningEffort = reasoningEffort; + } + + public boolean isThinkingEnabled() + { + return thinkingEnabled; + } + + public void setThinkingEnabled(boolean thinkingEnabled) + { + this.thinkingEnabled = thinkingEnabled; + } + } +} diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/controller/AiChatController.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/controller/AiChatController.java new file mode 100644 index 0000000..3ea5ca7 --- /dev/null +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/controller/AiChatController.java @@ -0,0 +1,61 @@ +package com.ruoyi.portal.ai.controller; + +import com.ruoyi.common.annotation.Anonymous; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.portal.ai.domain.AiChatRequest; +import com.ruoyi.portal.ai.service.IAiChatService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 官网 AI 智能咨询入口。 + * + *

提供面向官网访客的公开问答接口,承载 RAG 知识检索与大模型应答响应的顶层分流。

+ */ +@RestController +@RequestMapping("/portal/ai") +// @Anonymous 允许匿名(免 Token)访问。官网智能咨询是公开引流页面,必须免登录访问,但需在网关层或安全策略中限制单 IP 访问频次 +@Anonymous +public class AiChatController extends BaseController +{ + /** + * 单次提问最大字符长度限制。 + * 业务意图: + * 1. 过滤超长无意义提问,规避大模型上下文被无关内容撑爆进而产生高额 Token 计费。 + * 2. 在 Controller 层拦截可能的 Prompt 注入攻击(例如通过长文本携带的指令覆盖)。 + */ + private static final int MAX_QUESTION_LENGTH = 500; + + @Autowired + private IAiChatService aiChatService; + + /** + * 官网 AI 咨询核心对话接口。 + * + * @param request 提问请求(含问题文本及期望模型别名) + * @return 统一 Ajax 响应体,包裹 AiChatResponse 结果 + */ + @PostMapping("/chat") + public AjaxResult chat(@RequestBody AiChatRequest request) + { + // 快速失败原则(Fail Fast):参数缺失时直接阻断并返回错误描述,防止脏数据透传到下游逻辑引起空指针异常 + if (request == null || !StringUtils.hasText(request.getQuestion())) + { + return AjaxResult.error("问题不能为空"); + } + + // 校验输入边界:对两端空格进行裁剪后检测长度,从源头上杜绝发送超大文本拖垮知识分词或大模型推理的风险 + if (request.getQuestion().trim().length() > MAX_QUESTION_LENGTH) + { + return AjaxResult.error("问题长度不能超过 " + MAX_QUESTION_LENGTH + " 个字符"); + } + + // 路由到 AI 业务处理服务。此处的 AjaxResult.success 是若依脚手架的标准契约,确保前端响应格式的统一性 + return AjaxResult.success(aiChatService.chat(request)); + } +} diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/domain/AiChatRequest.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/domain/AiChatRequest.java new file mode 100644 index 0000000..bb77dee --- /dev/null +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/domain/AiChatRequest.java @@ -0,0 +1,45 @@ +package com.ruoyi.portal.ai.domain; + +import lombok.Data; + +/** + * 官网 AI 问答请求。 + * + *

承载前端用户发起的智能咨询提问及期望选用的模型参数。

+ */ +@Data +public class AiChatRequest +{ + /** + * 用户输入的咨询提问文本。 + * 是知识库模糊词距检索和大模型 Prompt 生成的原始输入源。 + */ + private String question; + + /** + * 客户端指定的期望模型别名(例如 "deepseek"、"ollama")。 + * 允许为空,为空时后端将自动降级使用 application.yml 中配置的 default-model, + * 这一设计实现了前后端的解耦,未来新增或下线模型时前端无需紧急发版。 + */ + private String model; + + public String getQuestion() + { + return question; + } + + public void setQuestion(String question) + { + this.question = question; + } + + public String getModel() + { + return model; + } + + public void setModel(String model) + { + this.model = model; + } +} diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/domain/AiChatResponse.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/domain/AiChatResponse.java new file mode 100644 index 0000000..eece530 --- /dev/null +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/domain/AiChatResponse.java @@ -0,0 +1,80 @@ +package com.ruoyi.portal.ai.domain; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 官网 AI 问答响应。 + * + *

封装大模型生成的结果、本次问答匹配到的知识源、以及系统健康度的兜底标记。

+ */ +@Data +public class AiChatResponse +{ + /** + * AI 最终给出的回答文本(Markdown 或纯文本)。 + * 当处于降级模式(retrievalOnly=true)时,此处包装的是直接基于知识库分片提取的精简摘要及友好提示。 + */ + private String answer; + + /** + * 实际处理本次请求的 AI 模型别名(如 "deepseek")。 + * 便于前端在 UI 上展示对应的 AI 图标、提供个性化样式,同时也用作后端接口调用分析和统计的维度。 + */ + private String model; + + /** + * 仅知识库检索标记。 + * 为 true 时,表示大模型 API 出现欠费、网络抖动或配置缺失,系统已自动实施优雅降级(Graceful Degradation), + * 绕过模型调用,直接将本地匹配的知识库摘要拼装返回给访客,保障功能始终可用(Fail-safe 机制)。 + */ + private boolean retrievalOnly; + + /** + * 关联的知识源引用列表。 + * 展示回答此问题参考了官网哪些页面或产品文档,用于提供信息可信凭证,并支持前端做引用来源锚点跳转。 + */ + private List sources = new ArrayList<>(); + + public String getAnswer() + { + return answer; + } + + public void setAnswer(String answer) + { + this.answer = answer; + } + + public String getModel() + { + return model; + } + + public void setModel(String model) + { + this.model = model; + } + + public boolean isRetrievalOnly() + { + return retrievalOnly; + } + + public void setRetrievalOnly(boolean retrievalOnly) + { + this.retrievalOnly = retrievalOnly; + } + + public List getSources() + { + return sources; + } + + public void setSources(List sources) + { + this.sources = sources; + } +} diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/domain/AiSource.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/domain/AiSource.java new file mode 100644 index 0000000..a8271a3 --- /dev/null +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/domain/AiSource.java @@ -0,0 +1,56 @@ +package com.ruoyi.portal.ai.domain; + +import lombok.Data; + +/** + * AI 回答引用的官网知识来源。 + * + *

表示被引用的原文信息片,支持向前端输出以作为大模型生成内容的信任佐证。

+ */ +@Data +public class AiSource +{ + /** + * 知识来源的定位描述。 + * 例如:"hw_web:webCode=7"(表明数据来源于主配置表且 code 为 7 的产品中心)或 "hw_web1:deviceId=10"(产品详情), + * 业务意图在于方便前端直接跳转或在管理后台定位到出问题的知识原文进行订正。 + */ + private String source; + + /** + * 引用的具体文本片段。 + * 为保护数据大小不超标及进行数据脱敏,该内容通常是对应 textChunk 被截取后的摘要, + * 用于向官网访客公开呈现,解答用户的参考依据。 + */ + private String content; + + public AiSource() + { + } + + public AiSource(String source, String content) + { + this.source = source; + this.content = content; + } + + public String getSource() + { + return source; + } + + public void setSource(String source) + { + this.source = source; + } + + public String getContent() + { + return content; + } + + public void setContent(String content) + { + this.content = content; + } +} diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/domain/TextChunk.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/domain/TextChunk.java new file mode 100644 index 0000000..6ee13fe --- /dev/null +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/domain/TextChunk.java @@ -0,0 +1,71 @@ +package com.ruoyi.portal.ai.domain; + +import lombok.Data; + +/** + * 官网知识库文本分片。 + * + *

表示被拆分后的最小知识载体,是内存模糊匹配检索与大模型上下文注入的基本单位。

+ */ +@Data +public class TextChunk +{ + /** + * 该文本分片的具体数据库或文件定位源。 + * 用于标识本段数据是从哪张表(如 hw_web, hw_web1)的哪个字段解析剥离出来的。 + */ + private String source; + + /** + * 该分片提取清理后的纯文本内容。 + * 已滤除 HTML 标签与静态资源链接,保证输入大模型的全部是高价值的语义文本。 + */ + private String content; + + /** + * 分片在此次检索提问中的匹配得分。 + * 由本地内存搜索引擎根据关键词匹配频次、长度等加权计算。用于 Top-K 的截断过滤和降序排列, + * 保证最相关的知识片段总是优先注入给大模型。 + */ + private int score; + + public TextChunk() + { + } + + public TextChunk(String source, String content) + { + this.source = source; + this.content = content; + } + + public String getSource() + { + return source; + } + + public void setSource(String source) + { + this.source = source; + } + + public String getContent() + { + return content; + } + + public void setContent(String content) + { + this.content = content; + } + + public int getScore() + { + return score; + } + + public void setScore(int score) + { + this.score = score; + } +} diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/service/IAiChatService.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/service/IAiChatService.java new file mode 100644 index 0000000..7665e9c --- /dev/null +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/service/IAiChatService.java @@ -0,0 +1,12 @@ +package com.ruoyi.portal.ai.service; + +import com.ruoyi.portal.ai.domain.AiChatRequest; +import com.ruoyi.portal.ai.domain.AiChatResponse; + +/** + * 官网 AI 问答服务。 + */ +public interface IAiChatService +{ + AiChatResponse chat(AiChatRequest request); +} diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/service/IKnowledgeBaseService.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/service/IKnowledgeBaseService.java new file mode 100644 index 0000000..5c26481 --- /dev/null +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/service/IKnowledgeBaseService.java @@ -0,0 +1,13 @@ +package com.ruoyi.portal.ai.service; + +import java.util.List; + +import com.ruoyi.portal.ai.domain.TextChunk; + +/** + * 官网知识库检索服务。 + */ +public interface IKnowledgeBaseService +{ + List search(String question, int topK); +} diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/service/impl/AiChatServiceImpl.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/service/impl/AiChatServiceImpl.java new file mode 100644 index 0000000..10ae49c --- /dev/null +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/service/impl/AiChatServiceImpl.java @@ -0,0 +1,317 @@ +package com.ruoyi.portal.ai.service.impl; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.ruoyi.portal.ai.config.AiProperties; +import com.ruoyi.portal.ai.domain.AiChatRequest; +import com.ruoyi.portal.ai.domain.AiChatResponse; +import com.ruoyi.portal.ai.domain.AiSource; +import com.ruoyi.portal.ai.domain.TextChunk; +import com.ruoyi.portal.ai.service.IAiChatService; +import com.ruoyi.portal.ai.service.IKnowledgeBaseService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +/** + * 基于 Spring AI 1.1.7 的 OpenAI-compatible 官网 AI 问答业务实现。 + * + *

提供了核心的 RAG (检索增强生成) 流程: + * 接收用户提问 -> 检索本地知识分片 -> 组合系统/用户 Prompts -> 调用大模型 -> 故障优雅降级。

+ */ +@Service +public class AiChatServiceImpl implements IAiChatService +{ + private static final Logger log = LoggerFactory.getLogger(AiChatServiceImpl.class); + + /** + * 官网客服专属 System Prompt 约束。 + * 业务意图: + * 1. 明确 AI 客服的身份边界(海威物联官网助手),防止大模型被套话后做出“假冒其他公司”或发表无关言论的行为。 + * 2. 实施强约束:严格依据知识库回答,对于库内不存在的信息必须坦白告知,严禁大模型由于“迎合用户”而胡乱编造联系方式、价格及口头服务承诺, + * 这能有效规避由此引发的法律合同纠纷风险(避坑考量)。 + */ + private static final String SYSTEM_PROMPT = "你是青岛海威物联官网的 AI 咨询助手。" + + "回答必须优先依据提供的官网知识库上下文;上下文没有的信息要明确说明暂未检索到," + + "不要编造联系方式、价格、交付周期或承诺。回答使用简体中文,结构清晰,适合官网访客阅读。"; + + @Autowired + private AiProperties aiProperties; + + @Autowired + private IKnowledgeBaseService knowledgeBaseService; + + /** + * 本地大模型客户端实例的高速缓存字典。 + * 业务意图: + * OpenAiChatModel 的内部实例化涉及 HTTP 线程池、序列化转换器以及底层底层框架的初始化,这属于重量级操作。 + * 采用缓存能够避免每次收到访客提问都重新反射建连,保护系统 JVM 内存免于频繁的 GC 压力,提升问答接口的 QPS。 + */ + private final Map chatModelCache = new ConcurrentHashMap<>(); + + /** + * RAG 对话处理主流程。 + */ + @Override + public AiChatResponse chat(AiChatRequest request) + { + String question = request.getQuestion().trim(); + // 动态决策出本次调用的目标模型,若前端请求的模型未配置或不可用,将自动退化至系统默认大模型 + String modelKey = resolveModelKey(request.getModel()); + + // 1. 召回阶段:从本地知识库检索出 Top-K 个相关的纯文本分片 + List chunks = knowledgeBaseService.search(question, aiProperties.getKnowledgeBase().getTopK()); + + AiProperties.ModelConfig modelConfig = aiProperties.getModels().get(modelKey); + + // 2. 故障自愈/配置检查:若检测到目标模型的 API Key 或 Base URL 未就绪,说明该模型暂时无法提供推理服务, + // 此时系统不直接返回 500 或报错阻断,而是采取“优雅降级”策略,把本地匹配到的知识库大意返回,保证极佳的用户体验。 + if (!isModelReady(modelConfig)) + { + return retrievalOnly(modelKey, chunks, "AI 模型暂未配置 API Key,已先返回官网知识库检索结果。"); + } + + try + { + // 3. 生成阶段:结合本地知识库上下文与用户问题,请求远端大模型进行加工整理 + String answer = callModel(question, chunks, modelConfig); + + AiChatResponse response = new AiChatResponse(); + response.setAnswer(answer); + response.setModel(modelKey); + response.setRetrievalOnly(false); // 标记本次交互大模型正常参与了内容组织 + response.setSources(toSources(chunks)); // 附带引用数据源,便于前端在 UI 上做可信背书 + return response; + } + catch (Exception e) + { + // 4. 调用兜底防御:当大模型接口遇到突发欠费、并发限流(Rate Limit)或外部网络抖动时, + // 记录异常日志以便于排查,并降级返回本地检索出的匹配信息,保证官网问答服务的“坚挺度”(Fail-safe 避坑考量)。 + log.warn("call ai model failed, fallback to retrieval result, model={}", modelKey, e); + return retrievalOnly(modelKey, chunks, "AI 模型暂时不可用,已先返回官网知识库检索结果。"); + } + } + + /** + * 解析路由大模型别名,提供容错机制。 + */ + private String resolveModelKey(String requestModel) + { + String modelKey = StringUtils.hasText(requestModel) ? requestModel.trim() : aiProperties.getDefaultModel(); + if (aiProperties.getModels().containsKey(modelKey)) + { + return modelKey; + } + // 当传入未注册的 key 时,强制回退至 default 配置,防止后续逻辑发生 NullPointerException 崩溃。 + return aiProperties.getDefaultModel(); + } + + /** + * 判断模型调用要素是否齐全,杜绝启动或调用时的空指针漏洞。 + */ + private boolean isModelReady(AiProperties.ModelConfig modelConfig) + { + return modelConfig != null + && StringUtils.hasText(modelConfig.getApiKey()) + && StringUtils.hasText(modelConfig.getBaseUrl()) + && StringUtils.hasText(modelConfig.getModel()); + } + + /** + * 封装具体的 Spring AI 客户端接口调用行为。 + */ + private String callModel(String question, List chunks, AiProperties.ModelConfig modelConfig) + { + // 从缓存中获取已经初始化完毕的大模型客户端实例,调用生成接口 + ChatResponse response = getChatModel(modelConfig).call(buildPrompt(question, chunks, modelConfig)); + String text = response.getResult().getOutput().getText(); + if (!StringUtils.hasText(text)) + { + // 规避大模型因为生成策略原因返回空响应导致前端渲染空白 + throw new IllegalStateException("AI response content is empty"); + } + return text.trim(); + } + + /** + * 构建符合大模型最优效果的结构化 Prompts。 + */ + private Prompt buildPrompt(String question, List chunks, AiProperties.ModelConfig modelConfig) + { + // 创建针对 OpenAI 规范的超参参数绑定 + OpenAiChatOptions.Builder optionsBuilder = OpenAiChatOptions.builder() + .model(modelConfig.getModel()) + .temperature(modelConfig.getTemperature()) + .maxTokens(modelConfig.getMaxTokens()) + .topP(modelConfig.getTopP()); + + // 针对某些支持推理力度选项的模型进行适配参数挂载 + if (StringUtils.hasText(modelConfig.getReasoningEffort())) + { + optionsBuilder.reasoningEffort(modelConfig.getReasoningEffort()); + } + + // 支持非标准 API 的 thinking 深度思考机制,这里通过 extraBody 动态注入自定义属性, + // 巧妙绕过了 Spring AI 官方老版本在 OpenAiChatOptions 中没有显式 thinking 字段的限制(避坑考量)。 + if (modelConfig.isThinkingEnabled()) + { + Map extraBody = new HashMap<>(); + extraBody.put("thinking", Map.of("type", "enabled")); + optionsBuilder.extraBody(extraBody); + } + + // 架构决策点:采用 SystemMessage (系统提示) 与 UserMessage (用户提示) 物理分离的模式。 + // 避坑考量:严禁将知识库原文直接与系统 Prompt 混编在 SystemMessage 中,也避免将其塞在 UserMessage 的开始。 + // 物理隔离可以最大限度防止用户精心设计的输入字符串通过“指令覆盖”劫持大模型(Prompt Injection),确保安全红线。 + return new Prompt(List.of( + new SystemMessage(SYSTEM_PROMPT), + new UserMessage(buildUserPrompt(question, chunks)) + ), optionsBuilder.build()); + } + + /** + * 组装注入大模型的用户端提示语上下文。 + */ + private String buildUserPrompt(String question, List chunks) + { + StringBuilder prompt = new StringBuilder(); + prompt.append("用户问题:").append(question).append("\n\n"); + prompt.append("官网知识库上下文:\n"); + if (chunks.isEmpty()) + { + // 显式告诉模型没有检索到上下文,强力防范大模型在没有任何数据支撑时编造公司虚假情况 + prompt.append("(未检索到直接相关内容)\n"); + } + else + { + // 采用带序号的列表输入,让大模型能更好地识别多篇参考段落的边界,有利于模型产出“引用序号” + for (int i = 0; i < chunks.size(); i++) + { + TextChunk chunk = chunks.get(i); + prompt.append("[").append(i + 1).append("] ") + .append(chunk.getSource()).append("\n") + .append(chunk.getContent()).append("\n\n"); + } + } + prompt.append("请基于以上上下文回答。若上下文不足,请说明“官网知识库暂未检索到相关信息”。"); + return prompt.toString(); + } + + /** + * 获取或懒加载构建大模型调用客户端实例。 + */ + private OpenAiChatModel getChatModel(AiProperties.ModelConfig modelConfig) + { + // 缓存 Key 采用 BaseUrl 和 ApiKey 组合,确保当后台运维人员更改模型秘钥或服务器地址时,能够自动清空缓存并重新创建实例 + String cacheKey = modelConfig.getBaseUrl() + "|" + modelConfig.getApiKey(); + return chatModelCache.computeIfAbsent(cacheKey, key -> { + // 避坑考量:Spring AI 在拼装 completions 接口时,会在底层将 baseUrl 和 completionsPath 直接使用简单字符串拼接。 + // 如果 baseUrl 的末尾带有斜杠(如 http://api.deepseek.com/),与 "/chat/completions" 拼接后就会变成 + // http://api.deepseek.com//chat/completions (双斜杠)。这会导致大部分 API 网关或服务端直接报 404 Not Found。 + // 故此处引入 trimEndSlash 方法进行前置清洗,彻底规避双斜杠请求异常问题。 + OpenAiApi openAiApi = OpenAiApi.builder() + .baseUrl(trimEndSlash(modelConfig.getBaseUrl())) + .apiKey(modelConfig.getApiKey()) + .completionsPath("/chat/completions") + .embeddingsPath("/embeddings") + .build(); + return OpenAiChatModel.builder() + .openAiApi(openAiApi) + .defaultOptions(OpenAiChatOptions.builder() + .model(modelConfig.getModel()) + .temperature(modelConfig.getTemperature()) + .maxTokens(modelConfig.getMaxTokens()) + .topP(modelConfig.getTopP()) + .build()) + .build(); + }); + } + + /** + * 递归剔除 URL 末尾斜杠,防范 HTTP 请求拼接时产生 404 漏洞。 + */ + private String trimEndSlash(String baseUrl) + { + String url = baseUrl.trim(); + while (url.endsWith("/")) + { + url = url.substring(0, url.length() - 1); + } + return url; + } + + /** + * 优雅降级响应构建。 + */ + private AiChatResponse retrievalOnly(String modelKey, List chunks, String reason) + { + AiChatResponse response = new AiChatResponse(); + response.setModel(modelKey); + response.setRetrievalOnly(true); // 强标记当前属于“纯检索兜底无AI介入”模式 + response.setSources(toSources(chunks)); + response.setAnswer(buildRetrievalAnswer(chunks, reason)); + return response; + } + + /** + * 降级场景下,将检索到的片段聚合为对访客友好的可读摘要信息。 + */ + private String buildRetrievalAnswer(List chunks, String reason) + { + StringBuilder answer = new StringBuilder(reason); + if (chunks.isEmpty()) + { + // 当本地知识库也空无一物,且 AI 无法调用时,返回包含明确提示的引导句,指导用户换词检索 + answer.append("\n\n官网知识库暂未检索到与该问题直接相关的信息,建议补充产品名称、方案场景或业务关键词后再试。"); + return answer.toString(); + } + answer.append("\n\n已检索到以下相关内容:"); + for (int i = 0; i < chunks.size(); i++) + { + TextChunk chunk = chunks.get(i); + // 避坑考量:降级提取纯文本返回时,必须限制单分片呈现长度(如 260 字符),防止某些非常庞大的大 JSON 段落一次性挤爆前端卡片 + answer.append("\n").append(i + 1).append(". ") + .append(limit(chunk.getContent(), 260)); + } + return answer.toString(); + } + + /** + * 将 TextChunk 数据形态转为前端所需的引用来源模型。 + */ + private List toSources(List chunks) + { + List sources = new ArrayList<>(); + for (TextChunk chunk : chunks) + { + // 限制来源展示的文本长度,保护网络传输带宽,也避免敏感的后台配置块数据被访客越权获取 + sources.add(new AiSource(chunk.getSource(), limit(chunk.getContent(), 300))); + } + return sources; + } + + /** + * 统一字符串切断截取工具,防范越界。 + */ + private String limit(String text, int maxLength) + { + if (text == null || text.length() <= maxLength) + { + return text; + } + return text.substring(0, maxLength) + "..."; + } +} diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/service/impl/KnowledgeBaseServiceImpl.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/service/impl/KnowledgeBaseServiceImpl.java new file mode 100644 index 0000000..77c9c9f --- /dev/null +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/ai/service/impl/KnowledgeBaseServiceImpl.java @@ -0,0 +1,434 @@ +package com.ruoyi.portal.ai.service.impl; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.lang.reflect.Field; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.portal.ai.config.AiProperties; +import com.ruoyi.portal.ai.domain.TextChunk; +import com.ruoyi.portal.ai.service.IKnowledgeBaseService; +import com.ruoyi.portal.domain.HwWeb; +import com.ruoyi.portal.domain.HwWeb1; +import com.ruoyi.portal.service.IHwWebService; +import com.ruoyi.portal.service.IHwWebService1; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +/** + * 基于现有官网 JSON 的轻量本地知识库检索服务实现。 + * + *

该类实现了一个嵌入式的基于词频权重的搜索引擎,避免引入 ElasticSearch 等重量级外部搜索中间件。 + * 业务意图在于以极低的资源开销,提供官网已发布内容的高召回率本地检索。

+ */ +@Service +public class KnowledgeBaseServiceImpl implements IKnowledgeBaseService +{ + /** + * 过滤富文本及官网编辑内容中可能夹杂的 HTML 标签。 + * 避坑考量:在提取知识库纯文本时,若保留 HTML 标签会导致大模型将其误识别为系统指令,且会占用多余的 Token 预算。 + */ + private static final Pattern HTML_TAG_PATTERN = Pattern.compile("<[^>]+>"); + + /** + * 分词提取正则,匹配长度大于等于2的中文、英文字母和数字。 + * 业务意图:作为本地模糊搜索的切分边界,过滤掉诸如标点符号、单字停用词(如“的”、“了”)等无检索价值的噪音。 + */ + private static final Pattern TOKEN_PATTERN = Pattern.compile("[\\p{IsHan}A-Za-z0-9]{2,}"); + + @Autowired + private IHwWebService hwWebService; + + @Autowired + private IHwWebService1 hwWebService1; + + @Autowired + private AiProperties aiProperties; + + @Autowired + private ObjectMapper objectMapper; + + /** + * 缓存失效时间戳(毫秒数),使用 volatile 保证多线程可见性。 + */ + private volatile long cacheExpireAt; + + /** + * 本地内存知识库分片缓存,使用 volatile 保证多线程间的可见性,防止 JVM 指令重排。 + */ + private volatile List cachedChunks = new ArrayList<>(); + + /** + * 本地知识库的词频打分搜索方法。 + * + * @param question 用户提问文本 + * @param topK 最大召回的分片数 + * @return 降序排列的 Top-K 文本分片 + */ + @Override + public List search(String question, int topK) + { + // 1. 分词转换:将用户输入的问题切分为关键词列表(含滑动窗口字词) + List terms = buildTerms(question); + if (terms.isEmpty()) + { + return new ArrayList<>(); + } + + List matched = new ArrayList<>(); + // 2. 词频匹配打分:遍历已经拉取并在内存缓存的所有知识切片进行打分评估 + for (TextChunk chunk : loadChunks()) + { + int score = score(chunk.getContent(), terms); + if (score > 0) + { + TextChunk copy = new TextChunk(chunk.getSource(), chunk.getContent()); + copy.setScore(score); + matched.add(copy); + } + } + + // 3. 排序及 TopK 截断:按分值降序排列,只返回相关度最高的部分,利用 subList 进行越界防御 + matched.sort(Comparator.comparingInt(TextChunk::getScore).reversed()); + return matched.subList(0, Math.min(Math.max(topK, 1), matched.size())); + } + + /** + * 懒加载拉取知识库切片,应用双重检查锁(DCL, Double-Checked Locking)。 + * 避坑考量: + * 官网的内容配置存放在大表 JSON 中,反射提取和 JSON 树深度递归解析非常消耗 CPU。 + * 为防范高并发访客提问时直接击穿数据库并导致服务器 CPU 瞬间飙到 100%,此处采用 DCL 将结果驻留内存。 + * 配合 volatile 关键字,防止多线程环境下由于指令重排引发的局部初始化半对象逸出安全漏洞。 + */ + private List loadChunks() + { + long now = System.currentTimeMillis(); + // 第一层检查:未过期直接无锁返回,提升读取吞吐 + if (now < cacheExpireAt && !cachedChunks.isEmpty()) + { + return cachedChunks; + } + synchronized (this) + { + // 第二层检查:多线程竞争锁排队进入后,再次确认过期时间,防止重复执行解析逻辑 + if (now < cacheExpireAt && !cachedChunks.isEmpty()) + { + return cachedChunks; + } + List chunks = new ArrayList<>(); + // 解析并追加通用配置页面表(hw_web)的文本内容 + appendHwWebChunks(chunks); + // 解析并追加产品详情关联表(hw_web1)的设备文本内容 + appendHwWeb1Chunks(chunks); + cachedChunks = chunks; + // 官网内容由后台低频发布,短 TTL(如 5分钟)可减少每次问答重复解析大 JSON 的成本。 + cacheExpireAt = now + Math.max(aiProperties.getKnowledgeBase().getCacheSeconds(), 30) * 1000L; + return cachedChunks; + } + } + + /** + * 提取 hw_web 表的知识切片。 + */ + private void appendHwWebChunks(List chunks) + { + List list = hwWebService.selectHwWebList(new HwWeb()); + for (HwWeb item : list) + { + // 拼接可溯源的唯一位置标识,如 "hw_web:webCode=7",方便 AI 回复时进行精准定位与前端高亮跳转 + String source = "hw_web:webCode=" + fieldValue(item, "webCode"); + appendJsonChunks(chunks, source, stringFieldValue(item, "webJsonString")); + appendJsonChunks(chunks, source + ":english", stringFieldValue(item, "webJsonEnglish")); + } + } + + /** + * 提取 hw_web1 关联表的知识切片。 + */ + private void appendHwWeb1Chunks(List chunks) + { + List list = hwWebService1.selectHwWebList(new HwWeb1()); + for (HwWeb1 item : list) + { + // 针对设备对比等强结构数据,定位信息需要包含三元业务键(webCode, typeId, deviceId) + String source = "hw_web1:webCode=" + fieldValue(item, "webCode") + + ",typeId=" + fieldValue(item, "typeId") + + ",deviceId=" + fieldValue(item, "deviceId"); + appendJsonChunks(chunks, source, stringFieldValue(item, "webJsonString")); + appendJsonChunks(chunks, source + ":english", stringFieldValue(item, "webJsonEnglish")); + } + } + + private String stringFieldValue(Object target, String fieldName) + { + Object value = fieldValue(target, fieldName); + return value == null ? null : String.valueOf(value); + } + + /** + * 利用反射动态获取 POJO 对象的属性值。 + * 业务意图: + * HwWeb 和 HwWeb1 是两个独立的 POJO 类,并且在旧代码中没有提取出包含 webCode 等属性的公共父接口。 + * 使用反射获取这三个核心定位键,能够避免在逻辑中为不同类编写大量重复的 if-else 类型转换代码,提高了组件的通用性。 + */ + private Object fieldValue(Object target, String fieldName) + { + if (target == null) + { + // 空防御,避免后续 getClass 抛出 NullPointerException + return null; + } + try + { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(target); + } + catch (ReflectiveOperationException e) + { + // 反射报错时返回空,不让反射异常向外扩散拖垮正常的主流程逻辑(Let it Crash vs. 容错折衷) + return null; + } + } + + /** + * 将包含富文本或结构化的 JSON 字符串解析为内存文本分片。 + */ + private void appendJsonChunks(List chunks, String source, String json) + { + if (!StringUtils.hasText(json)) + { + return; + } + try + { + JsonNode root = objectMapper.readTree(json); + // 针对编辑端输出的数组型 JSON 区块配置,展开每一行独立作为段落提取,以保持语意聚合度 + if (root.isArray()) + { + int index = 0; + for (JsonNode element : root) + { + appendText(chunks, source + "#" + index, extractPlainText(element)); + index++; + } + } + else + { + appendText(chunks, source, extractPlainText(root)); + } + } + catch (Exception e) + { + // 避坑考量:官网的历史脏数据可能并不全是严格的标准 JSON 字符串。 + // 如果遇到解析异常,绝不能让其向上抛出破坏了整个问答系统的可用性,而是采取“优雅退化”: + // 直接将这段不合规的 JSON 当作纯文本,强行剥离 HTML 标签后做兜底提取,以保障知识库的最大抗造性。 + appendText(chunks, source, stripHtml(json)); + } + } + + /** + * 将 Jackson 的 Node 树级结构扁平化提纯为用空格相连的纯文本串。 + */ + private String extractPlainText(JsonNode node) + { + List values = new ArrayList<>(); + collectText(node, values); + return normalizeText(String.join(" ", values)); + } + + /** + * 递归遍历 JsonNode 节点,收集其中所有文本型的值。 + */ + private void collectText(JsonNode node, List values) + { + if (node == null || node.isNull()) + { + return; + } + if (node.isTextual()) + { + String text = stripHtml(node.asText()); + // 过滤无意义的空串及可能的静态资源链接 + if (StringUtils.hasText(text) && !looksLikeAsset(text)) + { + values.add(text); + } + return; + } + if (node.isArray()) + { + for (JsonNode child : node) + { + collectText(child, values); + } + return; + } + if (node.isObject()) + { + // 深度遍历 JSON 对象的每个 KV 字段值,抽取其文本内容进行扁平化拼接 + node.fields().forEachRemaining(entry -> collectText(entry.getValue(), values)); + } + } + + /** + * 静态资源与链接过滤检测。 + * 避坑考量:在提取知识内容时,必须将图片链接、CDN地址及资源后缀过滤掉。 + * 否则大模型可能会误将这些杂乱无章的静态 URL 当作回答的一部分返还给访客,造成极差的视觉体验,同时也会浪费上下文 Token 计费。 + */ + private boolean looksLikeAsset(String text) + { + String value = text.toLowerCase(Locale.ROOT); + return value.startsWith("http://") + || value.startsWith("https://") + || value.endsWith(".png") + || value.endsWith(".jpg") + || value.endsWith(".jpeg") + || value.endsWith(".gif") + || value.endsWith(".webp"); + } + + /** + * 对提取出的长文本,依据最大分片大小规则进行物理分块。 + */ + private void appendText(List chunks, String source, String text) + { + String normalized = normalizeText(text); + if (!StringUtils.hasText(normalized)) + { + return; + } + int maxSize = Math.max(aiProperties.getKnowledgeBase().getMaxChunkSize(), 500); + // 如果文本长度小于单片阈值,直接单片存入,避免不必要的截取 + if (normalized.length() <= maxSize) + { + chunks.add(new TextChunk(source, normalized)); + return; + } + + // 当内容过长时,以 maxSize 为步长进行切块,并用 ".0"、".1" 后缀命名,防止单块过大撑破大模型的 Input Window + int start = 0; + int index = 0; + while (start < normalized.length()) + { + int end = Math.min(start + maxSize, normalized.length()); + chunks.add(new TextChunk(source + "." + index, normalized.substring(start, end))); + start = end; + index++; + } + } + + /** + * 自研轻量级中文滑动分词器(Sliding Window Segmenter)。 + * 避坑考量: + * 为保证系统低延迟、免除大 JAR 依赖包引入造成的 Jar 包冲突与内存膨胀风险,本服务不引入诸如 IKAnalyzer 或 HanLP 等重型分词中间件。 + * 针对中文没有英文天然的空格分词符特征,此处先利用正则 TOKEN_PATTERN 将连贯文字切出, + * 并针对长度大于4的中文词段,强制使用 2~4 长度字符的滑动窗口生成子词倒排索引(比如“工业物联网”会被拆出“工业”、“物业”、“联网”、“物联”等子词)。 + * 这一折衷设计,以极少的代码行数,奇迹般地提升了官网短词(产品名称、型号)在内存匹配时的召回率。 + */ + private List buildTerms(String question) + { + Set terms = new LinkedHashSet<>(); // 保证分词去重且维护插入顺序 + String normalized = normalizeText(question).toLowerCase(Locale.ROOT); + Matcher matcher = TOKEN_PATTERN.matcher(normalized); + while (matcher.find()) + { + String term = matcher.group(); + terms.add(term); + + // 对连续中文长词,利用 2 至 4 个字的滑动窗口进行细粒度词块切分,大幅提高搜索词与知识库的命中率 + if (containsChinese(term) && term.length() > 4) + { + for (int size = 2; size <= 4; size++) + { + for (int i = 0; i + size <= term.length(); i++) + { + terms.add(term.substring(i, i + size)); + } + } + } + } + return new ArrayList<>(terms); + } + + /** + * 判断是否包含汉字。 + */ + private boolean containsChinese(String text) + { + for (int i = 0; i < text.length(); i++) + { + if (Character.UnicodeScript.of(text.charAt(i)) == Character.UnicodeScript.HAN) + { + return true; + } + } + return false; + } + + /** + * 对当前知识切片内容进行相关性评分。 + * 得分机制:遍历所有搜索项,利用其出现频次乘以该词的字符长度,优先偏向长词命中(长词承载更精准的语义), + * 这样能让命中高精度长关键词的段落被排在最前面。 + */ + private int score(String content, List terms) + { + String text = content.toLowerCase(Locale.ROOT); + int score = 0; + for (String term : terms) + { + int count = countOccurrences(text, term); + // 权重为:频次 * max(词长, 2)。加权长词,弱化单字/超短字命中的噪音 + score += count * Math.max(term.length(), 2); + } + return score; + } + + /** + * 检索指定关键词在字符串中出现的频次。 + */ + private int countOccurrences(String text, String term) + { + int count = 0; + int index = text.indexOf(term); + while (index >= 0) + { + count++; + index = text.indexOf(term, index + term.length()); // 推进指针,防止死循环 + } + return count; + } + + /** + * 快速剥离 HTML 标签。 + */ + private String stripHtml(String text) + { + return HTML_TAG_PATTERN.matcher(text).replaceAll(" "); + } + + /** + * 标准化整理过滤文本,将不规范的富文本空格( )和多余空白压缩,维护文本的可读性与一致性。 + */ + private String normalizeText(String text) + { + if (text == null) + { + return ""; + } + return text.replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replaceAll("\\s+", " ") // 合并多余的连续空白字符为单空格,节省存储与 Token 消耗 + .trim(); + } +} diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwPortalConfig.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwPortalConfig.java index 22267aa..ba4250b 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwPortalConfig.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwPortalConfig.java @@ -2,17 +2,24 @@ package com.ruoyi.portal.domain; import com.ruoyi.common.annotation.Excel; import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; +import java.io.Serial; + /** * 门户网站配置对象 hw_portal_config * * @author xins * @date 2024-12-01 */ +@EqualsAndHashCode(callSuper = true) +@Data public class HwPortalConfig extends BaseEntity { + @Serial private static final long serialVersionUID = 1L; /** 主键标识 */ @@ -59,168 +66,5 @@ public class HwPortalConfig extends BaseEntity private Long parentId; private String ancestors; - public void setPortalConfigId(Long portalConfigId) - { - this.portalConfigId = portalConfigId; - } - - public Long getPortalConfigId() - { - return portalConfigId; - } - public void setPortalConfigType(String portalConfigType) - { - this.portalConfigType = portalConfigType; - } - - public String getPortalConfigType() - { - return portalConfigType; - } - - public Long getPortalConfigTypeId() { - return portalConfigTypeId; - } - - public void setPortalConfigTypeId(Long portalConfigTypeId) { - this.portalConfigTypeId = portalConfigTypeId; - } - - public void setPortalConfigTitle(String portalConfigTitle) - { - this.portalConfigTitle = portalConfigTitle; - } - - public String getPortalConfigTitle() - { - return portalConfigTitle; - } - public void setPortalConfigOrder(Long portalConfigOrder) - { - this.portalConfigOrder = portalConfigOrder; - } - - public Long getPortalConfigOrder() - { - return portalConfigOrder; - } - public void setPortalConfigDesc(String portalConfigDesc) - { - this.portalConfigDesc = portalConfigDesc; - } - - public String getPortalConfigDesc() - { - return portalConfigDesc; - } - public void setButtonName(String buttonName) - { - this.buttonName = buttonName; - } - - public String getButtonName() - { - return buttonName; - } - public void setRouterAddress(String routerAddress) - { - this.routerAddress = routerAddress; - } - - public String getRouterAddress() - { - return routerAddress; - } - public void setPortalConfigPic(String portalConfigPic) - { - this.portalConfigPic = portalConfigPic; - } - - public String getPortalConfigPic() - { - return portalConfigPic; - } - - public String getConfigTypeName() { - return configTypeName; - } - - public void setConfigTypeName(String configTypeName) { - this.configTypeName = configTypeName; - } - - - public String getHomeConfigTypePic() { - return homeConfigTypePic; - } - - public void setHomeConfigTypePic(String homeConfigTypePic) { - this.homeConfigTypePic = homeConfigTypePic; - } - - public String getHomeConfigTypeIcon() { - return homeConfigTypeIcon; - } - - public void setHomeConfigTypeIcon(String homeConfigTypeIcon) { - this.homeConfigTypeIcon = homeConfigTypeIcon; - } - - public String getHomeConfigTypeName() { - return homeConfigTypeName; - } - - public void setHomeConfigTypeName(String homeConfigTypeName) { - this.homeConfigTypeName = homeConfigTypeName; - } - - public String getHomeConfigTypeClassfication() { - return homeConfigTypeClassfication; - } - - public void setHomeConfigTypeClassfication(String homeConfigTypeClassfication) { - this.homeConfigTypeClassfication = homeConfigTypeClassfication; - } - - public Long getParentId() { - return parentId; - } - - public void setParentId(Long parentId) { - this.parentId = parentId; - } - - public String getAncestors() { - return ancestors; - } - - public void setAncestors(String ancestors) { - this.ancestors = ancestors; - } - - @Override - public String toString() { - return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) - .append("portalConfigId", getPortalConfigId()) - .append("portalConfigType", getPortalConfigType()) - .append("portalConfigTitle", getPortalConfigTitle()) - .append("portalConfigOrder", getPortalConfigOrder()) - .append("portalConfigDesc", getPortalConfigDesc()) - .append("buttonName", getButtonName()) - .append("routerAddress", getRouterAddress()) - .append("portalConfigPic", getPortalConfigPic()) - .append("createTime", getCreateTime()) - .append("createBy", getCreateBy()) - .append("updateTime", getUpdateTime()) - .append("updateBy", getUpdateBy()) - .append("configTypeName", getConfigTypeName()) - .append("homeConfigTypePic", getHomeConfigTypePic()) - .append("homeConfigTypeIcon", getHomeConfigTypeIcon()) - .append("homeConfigTypeName", getHomeConfigTypeName()) - .append("homeConfigTypeClassfication", getHomeConfigTypeClassfication()) - .append("parentId", getParentId()) - .append("ancestors", getAncestors()) - .toString(); - } } diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwPortalConfigType.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwPortalConfigType.java index fb2736a..b2a692e 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwPortalConfigType.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwPortalConfigType.java @@ -3,9 +3,12 @@ package com.ruoyi.portal.domain; import com.ruoyi.common.annotation.Excel; import com.ruoyi.common.core.domain.BaseEntity; import com.ruoyi.common.core.domain.TreeEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; +import java.io.Serial; import java.util.ArrayList; import java.util.List; @@ -15,8 +18,11 @@ import java.util.List; * @author xins * @date 2024-12-11 */ +@EqualsAndHashCode(callSuper = true) +@Data public class HwPortalConfigType extends BaseEntity { + @Serial private static final long serialVersionUID = 1L; /** 主键标识 */ @@ -55,119 +61,5 @@ public class HwPortalConfigType extends BaseEntity /** 子类型 */ private List children = new ArrayList(); - public void setConfigTypeId(Long configTypeId) - { - this.configTypeId = configTypeId; - } - - public Long getConfigTypeId() - { - return configTypeId; - } - public void setConfigTypeClassfication(String configTypeClassfication) - { - this.configTypeClassfication = configTypeClassfication; - } - - public String getConfigTypeClassfication() - { - return configTypeClassfication; - } - public void setConfigTypeName(String configTypeName) - { - this.configTypeName = configTypeName; - } - - public String getConfigTypeName() - { - return configTypeName; - } - public void setHomeConfigTypeName(String homeConfigTypeName) - { - this.homeConfigTypeName = homeConfigTypeName; - } - - public String getHomeConfigTypeName() - { - return homeConfigTypeName; - } - public void setConfigTypeDesc(String configTypeDesc) - { - this.configTypeDesc = configTypeDesc; - } - - public String getConfigTypeDesc() - { - return configTypeDesc; - } - public void setConfigTypeIcon(String configTypeIcon) - { - this.configTypeIcon = configTypeIcon; - } - - public String getConfigTypeIcon() - { - return configTypeIcon; - } - public void setHomeConfigTypePic(String homeConfigTypePic) - { - this.homeConfigTypePic = homeConfigTypePic; - } - - public String getHomeConfigTypePic() - { - return homeConfigTypePic; - } - - public Long getParentId() { - return parentId; - } - - public void setParentId(Long parentId) { - this.parentId = parentId; - } - - public String getAncestors() { - return ancestors; - } - - public void setAncestors(String ancestors) { - this.ancestors = ancestors; - } - - public List getHwProductCaseInfoList() { - return hwProductCaseInfoList; - } - - public void setHwProductCaseInfoList(List hwProductCaseInfoList) { - this.hwProductCaseInfoList = hwProductCaseInfoList; - } - - public List getChildren() { - return children; - } - - public void setChildren(List children) { - this.children = children; - } - - @Override - public String toString() { - return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) - .append("configTypeId", getConfigTypeId()) - .append("configTypeClassfication", getConfigTypeClassfication()) - .append("configTypeName", getConfigTypeName()) - .append("homeConfigTypeName", getHomeConfigTypeName()) - .append("configTypeDesc", getConfigTypeDesc()) - .append("configTypeIcon", getConfigTypeIcon()) - .append("homeConfigTypePic", getHomeConfigTypePic()) - .append("parentId", getParentId()) - .append("ancestors", getAncestors()) - .append("createTime", getCreateTime()) - .append("createBy", getCreateBy()) - .append("updateTime", getUpdateTime()) - .append("updateBy", getUpdateBy()) - .toString(); - } } diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWeb.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWeb.java index a2bf47b..fe82220 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWeb.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWeb.java @@ -1,18 +1,26 @@ package com.ruoyi.portal.domain; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import lombok.EqualsAndHashCode; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; import com.ruoyi.common.annotation.Excel; import com.ruoyi.common.core.domain.BaseEntity; +import java.io.Serial; + /** * haiwei官网json对象 hw_web * * @author ruoyi * @date 2025-08-18 */ +@EqualsAndHashCode(callSuper = true) +@Data public class HwWeb extends BaseEntity { + @Serial private static final long serialVersionUID = 1L; /** 主键 */ @@ -20,7 +28,7 @@ public class HwWeb extends BaseEntity /** json */ @Excel(name = "json") - private String webJson; + private JsonNode webJson; /** json字符串 */ @Excel(name = "json字符串") @@ -37,69 +45,5 @@ public class HwWeb extends BaseEntity @Excel(name = "字符串") private String webJsonEnglish; - public void setWebId(Long webId) - { - this.webId = webId; - } - - public Long getWebId() - { - return webId; - } - public void setWebJson(String webJson) - { - this.webJson = webJson; - } - - public String getWebJson() - { - return webJson; - } - public void setWebJsonString(String webJsonString) - { - this.webJsonString = webJsonString; - } - - public String getWebJsonString() - { - return webJsonString; - } - public void setWebCode(Long webCode) - { - this.webCode = webCode; - } - - public Long getWebCode() - { - return webCode; - } - - public String getIsDelete() { - return isDelete; - } - - public void setIsDelete(String isDelete) { - this.isDelete = isDelete; - } - - public String getWebJsonEnglish() { - return webJsonEnglish; - } - - public void setWebJsonEnglish(String webJsonEnglish) { - this.webJsonEnglish = webJsonEnglish; - } - - @Override - public String toString() { - return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) - .append("webId", getWebId()) - .append("webJson", getWebJson()) - .append("webJsonString", getWebJsonString()) - .append("webCode", getWebCode()) - .append("isDelete", getIsDelete()) - .append("webJsonEnglish", getWebJsonEnglish()) - .toString(); - } } diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWeb1.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWeb1.java index 77a0404..45a5cd7 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWeb1.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWeb1.java @@ -1,18 +1,24 @@ package com.ruoyi.portal.domain; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; import com.ruoyi.common.annotation.Excel; import com.ruoyi.common.core.domain.BaseEntity; +import java.io.Serial; + /** * haiwei官网json对象 hw_web1 * * @author ruoyi * @date 2025-08-18 */ +@Data public class HwWeb1 extends BaseEntity { + @Serial private static final long serialVersionUID = 1L; /** 主键 */ @@ -20,7 +26,7 @@ public class HwWeb1 extends BaseEntity /** json */ @Excel(name = "json") - private String webJson; + private JsonNode webJson; /** json字符串 */ @Excel(name = "json字符串") @@ -40,88 +46,5 @@ public class HwWeb1 extends BaseEntity /** json字符串 */ @Excel(name = "字符串") private String webJsonEnglish; - - public void setWebId(Long webId) - { - this.webId = webId; - } - - public Long getWebId() - { - return webId; - } - public void setWebJson(String webJson) - { - this.webJson = webJson; - } - - public String getWebJson() - { - return webJson; - } - public void setWebJsonString(String webJsonString) - { - this.webJsonString = webJsonString; - } - - public String getWebJsonString() - { - return webJsonString; - } - public void setWebCode(Long webCode) - { - this.webCode = webCode; - } - - public Long getWebCode() - { - return webCode; - } - - public Long getDeviceId() { - return deviceId; - } - - public void setDeviceId(Long deviceId) { - this.deviceId = deviceId; - } - - public Long getTypeId() { - return typeId; - } - - public void setTypeId(Long typeId) { - this.typeId = typeId; - } - - public String getIsDelete() { - return isDelete; - } - - public void setIsDelete(String isDelete) { - this.isDelete = isDelete; - } - - public String getWebJsonEnglish() { - return webJsonEnglish; - } - - public void setWebJsonEnglish(String webJsonEnglish) { - this.webJsonEnglish = webJsonEnglish; - } - - @Override - public String toString() { - return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) - .append("webId", getWebId()) - .append("webJson", getWebJson()) - .append("webJsonString", getWebJsonString()) - .append("webCode", getWebCode()) - .append("deviceId", getDeviceId()) - .append("typeId", getTypeId()) - .append("isDelete", getIsDelete()) - .append("webJsonEnglish", getWebJsonEnglish()) - .toString(); - } } diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebDocument.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebDocument.java index 3786441..0cd6d10 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebDocument.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebDocument.java @@ -6,6 +6,8 @@ import com.ruoyi.common.annotation.Excel; import com.ruoyi.common.core.domain.BaseEntity; import com.fasterxml.jackson.annotation.JsonProperty; +import java.io.Serial; + /** * Hw资料文件对象 hw_web_document * @@ -14,6 +16,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; */ public class HwWebDocument extends BaseEntity { + @Serial private static final long serialVersionUID = 1L; /** 主键 */ diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebMenu.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebMenu.java index 152ec27..b757ddb 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebMenu.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebMenu.java @@ -1,18 +1,25 @@ package com.ruoyi.portal.domain; +import lombok.Data; +import lombok.EqualsAndHashCode; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; import com.ruoyi.common.annotation.Excel; import com.ruoyi.common.core.domain.TreeEntity; +import java.io.Serial; + /** * haiwei官网菜单对象 hw_web_menu * * @author zch * @date 2025-08-18 */ +@EqualsAndHashCode(callSuper = true) +@Data public class HwWebMenu extends TreeEntity { + @Serial private static final long serialVersionUID = 1L; /** 菜单主键id */ @@ -51,109 +58,5 @@ public class HwWebMenu extends TreeEntity private String webMenuNameEnglish; - public void setWebMenuId(Long webMenuId) - { - this.webMenuId = webMenuId; - } - - public Long getWebMenuId() - { - return webMenuId; - } - public void setParent(Long parent) - { - this.parent = parent; - } - - public Long getParent() - { - return parent; - } - public void setStatus(String status) - { - this.status = status; - } - - public String getStatus() - { - return status; - } - public void setWebMenuName(String webMenuName) - { - this.webMenuName = webMenuName; - } - - public String getWebMenuName() - { - return webMenuName; - } - public void setTenantId(Long tenantId) - { - this.tenantId = tenantId; - } - - public Long getTenantId() - { - return tenantId; - } - public void setWebMenuPic(String webMenuPic) - { - this.webMenuPic = webMenuPic; - } - - public String getWebMenuPic() - { - return webMenuPic; - } - public void setWebMenuType(Long webMenuType) - { - this.webMenuType = webMenuType; - } - - public Long getWebMenuType() - { - return webMenuType; - } - - public Integer getOrder() { - return order; - } - - public void setOrder(Integer order) { - this.order = order; - } - - public String getIsDelete() { - return isDelete; - } - - public void setIsDelete(String isDelete) { - this.isDelete = isDelete; - } - - public String getWebMenuNameEnglish() { - return webMenuNameEnglish; - } - - public void setWebMenuNameEnglish(String webMenuNameEnglish) { - this.webMenuNameEnglish = webMenuNameEnglish; - } - - @Override - public String toString() { - return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) - .append("webMenuId", getWebMenuId()) - .append("parent", getParent()) - .append("ancestors", getAncestors()) - .append("status", getStatus()) - .append("webMenuName", getWebMenuName()) - .append("tenantId", getTenantId()) - .append("webMenuPic", getWebMenuPic()) - .append("webMenuType", getWebMenuType()) - .append("order", getOrder()) - .append("isDelete", getIsDelete()) - .append("webMenuNameEnglish", getWebMenuNameEnglish()) - .toString(); - } } diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebMenu1.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebMenu1.java index c58c17a..6e85d13 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebMenu1.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebMenu1.java @@ -1,10 +1,13 @@ package com.ruoyi.portal.domain; +import lombok.Data; +import lombok.EqualsAndHashCode; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; import com.ruoyi.common.annotation.Excel; import com.ruoyi.common.core.domain.TreeEntity; +import java.io.Serial; import java.util.List; /** @@ -13,8 +16,11 @@ import java.util.List; * @author zch * @date 2025-08-18 */ +@EqualsAndHashCode(callSuper = true) +@Data public class HwWebMenu1 extends TreeEntity { + @Serial private static final long serialVersionUID = 1L; /** 菜单主键id */ @@ -51,108 +57,5 @@ public class HwWebMenu1 extends TreeEntity private String webMenuNameEnglish; - public void setWebMenuId(Long webMenuId) - { - this.webMenuId = webMenuId; - } - - public Long getWebMenuId() - { - return webMenuId; - } - public void setParent(Long parent) - { - this.parent = parent; - } - - public Long getParent() - { - return parent; - } - public void setStatus(String status) - { - this.status = status; - } - - public String getStatus() - { - return status; - } - public void setWebMenuName(String webMenuName) - { - this.webMenuName = webMenuName; - } - - public String getWebMenuName() - { - return webMenuName; - } - public void setTenantId(Long tenantId) - { - this.tenantId = tenantId; - } - - public Long getTenantId() - { - return tenantId; - } - public void setWebMenuPic(String webMenuPic) - { - this.webMenuPic = webMenuPic; - } - - public String getWebMenuPic() - { - return webMenuPic; - } - public void setWebMenuType(Long webMenuType) - { - this.webMenuType = webMenuType; - } - - public Long getWebMenuType() - { - return webMenuType; - } - - public String getValuel() { - return valuel; - } - public void setValuel(String valuel) { - this.valuel = valuel; - } - - public String getIsDelete() { - return isDelete; - } - - public void setIsDelete(String isDelete) { - this.isDelete = isDelete; - } - - public String getWebMenuNameEnglish() { - return webMenuNameEnglish; - } - - public void setWebMenuNameEnglish(String webMenuNameEnglish) { - this.webMenuNameEnglish = webMenuNameEnglish; - } - - @Override - public String toString() { - return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) - .append("webMenuId", getWebMenuId()) - .append("parent", getParent()) - .append("ancestors", getAncestors()) - .append("status", getStatus()) - .append("webMenuName", getWebMenuName()) - .append("tenantId", getTenantId()) - .append("webMenuPic", getWebMenuPic()) - .append("webMenuType", getWebMenuType()) - .append("valuel", getValuel()) - .append("isDelete", getIsDelete()) - .append("webMenuNameEnglish", getWebMenuNameEnglish()) - .toString(); - } } diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebNews.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebNews.java index 4960639..d5d2ef6 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebNews.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebNews.java @@ -3,17 +3,24 @@ package com.ruoyi.portal.domain; import com.ruoyi.common.annotation.Excel; import com.ruoyi.common.core.domain.BaseEntity; import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import lombok.EqualsAndHashCode; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; +import java.io.Serial; + /** * 官网新闻对象 hw_web_news * * @author ruoyi * @date 2026-05-13 */ +@EqualsAndHashCode(callSuper = true) +@Data public class HwWebNews extends BaseEntity { + @Serial private static final long serialVersionUID = 1L; /** 主键 */ @@ -34,67 +41,4 @@ public class HwWebNews extends BaseEntity /** 逻辑删除标志:'0'未删除,'1'已删除 */ private String isDelete; - public void setNewsId(Long newsId) - { - this.newsId = newsId; - } - - public Long getNewsId() - { - return newsId; - } - - public void setNewsCode(String newsCode) - { - this.newsCode = newsCode; - } - - public String getNewsCode() - { - return newsCode; - } - - public void setNewsJson(JsonNode newsJson) - { - this.newsJson = newsJson; - } - - public JsonNode getNewsJson() - { - return newsJson; - } - - public void setJsonString(String jsonString) - { - this.jsonString = jsonString; - } - - public String getJsonString() - { - return jsonString; - } - - public String getIsDelete() - { - return isDelete; - } - - public void setIsDelete(String isDelete) - { - this.isDelete = isDelete; - } - - @Override - public String toString() - { - return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE) - .append("newsId", getNewsId()) - .append("newsCode", getNewsCode()) - .append("newsJson", getNewsJson()) - .append("jsonString", getJsonString()) - .append("isDelete", getIsDelete()) - .append("createTime", getCreateTime()) - .append("updateTime", getUpdateTime()) - .toString(); - } } diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebVisitDaily.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebVisitDaily.java index a296f74..e7cddf8 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebVisitDaily.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebVisitDaily.java @@ -1,132 +1,79 @@ package com.ruoyi.portal.domain; +import lombok.Data; + import java.util.Date; /** - * 官网访问日汇总 + * 官网访问日汇总实体。 + * + *

该表承载了官网每日访问数据的离线/定时跑批统计结果。 + * 业务意图在于为运营人员提供流量大盘走势、用户黏性指标、资源消耗热度等维度的报表展示, + * 避免实时查询明细日志表导致的数据库读压力。

* * @author ruoyi */ +@Data public class HwWebVisitDaily { + /** + * 统计的自然日期。 + * 通常作为本表逻辑主键,聚合当前日期的整点及全天数据。 + */ private Date statDate; + /** + * 页面浏览量(Page View)。 + * 记录页面被加载或刷新的累计次数,用以监测网站总体的页面暴露热度。 + */ private Long pv; + /** + * 独立访客数(Unique Visitor)。 + * 依据客户端浏览器 Cookie/Token 计算的独立访客总数,是评估实际潜在客户规模的关键指标。 + */ private Long uv; + /** + * 独立 IP 数。 + * 基于请求源 IP 去重统计的访客数。在企业网/内网代理(多台机共享同一公网IP)或用户清除 Cookie 时, + * 配合 UV 指标进行双向校准,以辨别访客真实身份。 + */ private Long ipUv; + /** + * 平均停留时长(毫秒)。 + * 记录访客在官网的单次会话持续时长均值。用以衡量网站内容的吸引力、产品册的易读性及用户黏性。 + */ private Long avgStayMs; + /** + * 跳出率(Bounce Rate)。 + * 仅浏览了当前落地页就直接关闭网站的单页会话占比。 + * 用于评估广告引流的精准度及落地页排版设计(如首页、产品聚合页)是否符合访客预期。 + */ private Double bounceRate; + /** + * 站内搜索累计次数。 + * 反映主动寻找特定设备型号或解决方案的强意向用户占比,搜索词频是产品研发和销售重点的“风向标”。 + */ private Long searchCount; + /** + * 资料/文档累计下载次数。 + * 记录软件手册、白皮书、技术图纸的下载频次,是官网漏斗转化链路中最接近“商机线索”的指标。 + */ private Long downloadCount; + /** + * 记录创建时间(跑批生成时间)。 + */ private Date createdAt; + /** + * 记录更新时间(若存在数据二次覆盖)。 + */ private Date updatedAt; - public Date getStatDate() - { - return statDate; - } - - public void setStatDate(Date statDate) - { - this.statDate = statDate; - } - - public Long getPv() - { - return pv; - } - - public void setPv(Long pv) - { - this.pv = pv; - } - - public Long getUv() - { - return uv; - } - - public void setUv(Long uv) - { - this.uv = uv; - } - - public Long getIpUv() - { - return ipUv; - } - - public void setIpUv(Long ipUv) - { - this.ipUv = ipUv; - } - - public Long getAvgStayMs() - { - return avgStayMs; - } - - public void setAvgStayMs(Long avgStayMs) - { - this.avgStayMs = avgStayMs; - } - - public Double getBounceRate() - { - return bounceRate; - } - - public void setBounceRate(Double bounceRate) - { - this.bounceRate = bounceRate; - } - - public Long getSearchCount() - { - return searchCount; - } - - public void setSearchCount(Long searchCount) - { - this.searchCount = searchCount; - } - - public Long getDownloadCount() - { - return downloadCount; - } - - public void setDownloadCount(Long downloadCount) - { - this.downloadCount = downloadCount; - } - - public Date getCreatedAt() - { - return createdAt; - } - - public void setCreatedAt(Date createdAt) - { - this.createdAt = createdAt; - } - - public Date getUpdatedAt() - { - return updatedAt; - } - - public void setUpdatedAt(Date updatedAt) - { - this.updatedAt = updatedAt; - } } - diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebVisitEvent.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebVisitEvent.java index c2a8357..f0280c6 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebVisitEvent.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/HwWebVisitEvent.java @@ -1,7 +1,10 @@ package com.ruoyi.portal.domain; import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; +import java.io.Serial; import java.util.Date; /** @@ -9,8 +12,11 @@ import java.util.Date; * * @author ruoyi */ +@EqualsAndHashCode(callSuper = true) +@Data public class HwWebVisitEvent extends BaseEntity { + @Serial private static final long serialVersionUID = 1L; private Long id; @@ -48,185 +54,5 @@ public class HwWebVisitEvent extends BaseEntity private Date eventTime; private Date createdAt; - - public Long getId() - { - return id; - } - - public void setId(Long id) - { - this.id = id; - } - - public String getEventType() - { - return eventType; - } - - public void setEventType(String eventType) - { - this.eventType = eventType; - } - - public String getVisitorId() - { - return visitorId; - } - - public void setVisitorId(String visitorId) - { - this.visitorId = visitorId; - } - - public String getSessionId() - { - return sessionId; - } - - public void setSessionId(String sessionId) - { - this.sessionId = sessionId; - } - - public String getPath() - { - return path; - } - - public void setPath(String path) - { - this.path = path; - } - - public String getReferrer() - { - return referrer; - } - - public void setReferrer(String referrer) - { - this.referrer = referrer; - } - - public String getUtmSource() - { - return utmSource; - } - - public void setUtmSource(String utmSource) - { - this.utmSource = utmSource; - } - - public String getUtmMedium() - { - return utmMedium; - } - - public void setUtmMedium(String utmMedium) - { - this.utmMedium = utmMedium; - } - - public String getUtmCampaign() - { - return utmCampaign; - } - - public void setUtmCampaign(String utmCampaign) - { - this.utmCampaign = utmCampaign; - } - - public String getKeyword() - { - return keyword; - } - - public void setKeyword(String keyword) - { - this.keyword = keyword; - } - - public String getIpHash() - { - return ipHash; - } - - public void setIpHash(String ipHash) - { - this.ipHash = ipHash; - } - - public String getUa() - { - return ua; - } - - public void setUa(String ua) - { - this.ua = ua; - } - - public String getDevice() - { - return device; - } - - public void setDevice(String device) - { - this.device = device; - } - - public String getBrowser() - { - return browser; - } - - public void setBrowser(String browser) - { - this.browser = browser; - } - - public String getOs() - { - return os; - } - - public void setOs(String os) - { - this.os = os; - } - - public Long getStayMs() - { - return stayMs; - } - - public void setStayMs(Long stayMs) - { - this.stayMs = stayMs; - } - - public Date getEventTime() - { - return eventTime; - } - - public void setEventTime(Date eventTime) - { - this.eventTime = eventTime; - } - - public Date getCreatedAt() - { - return createdAt; - } - - public void setCreatedAt(Date createdAt) - { - this.createdAt = createdAt; - } } diff --git a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/SecureDocumentRequest.java b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/SecureDocumentRequest.java index e64ce10..7544231 100644 --- a/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/SecureDocumentRequest.java +++ b/ruoyi-portal/src/main/java/com/ruoyi/portal/domain/SecureDocumentRequest.java @@ -1,27 +1,28 @@ package com.ruoyi.portal.domain; +import lombok.Data; + +import java.io.Serial; + +/** + * 官网安全文档请求体。 + * + *

用于在用户尝试下载受保护文档时,传递文档ID及客户端输入的访问密钥。 + * 业务意图在于实现“密钥托管”与“防越权遍历”,只有密钥校验通过后,后端才会下发具有时效性的真实存储地址。

+ */ +@Data public class SecureDocumentRequest { + /** + * 文档的唯一物理主键(对应 hw_web_document.document_id)。 + * 采用无规律的 UUID 作为主键,防止恶意攻击者通过自增 ID 进行规律性的遍历下载。 + */ private String documentId; + + /** + * 访问受控文档所必须匹配的校验密钥。 + * 当该文档在后台配置了密钥时,前端不展示原始下载链接,必须由用户输入密钥并经此字段提交, + * 从而规避静态资源链接被搜索引擎爬虫抓取或直接公开传播的安全风险。 + */ private String providedKey; - - public String getDocumentId() - { - return documentId; - } - - public void setDocumentId(String documentId) - { - this.documentId = documentId; - } - - public String getProvidedKey() - { - return providedKey; - } - - public void setProvidedKey(String providedKey) - { - this.providedKey = providedKey; - } } diff --git a/ruoyi-portal/src/main/resources/mapper/portal/HwWebMapper.xml b/ruoyi-portal/src/main/resources/mapper/portal/HwWebMapper.xml index c0cba77..e708e2e 100644 --- a/ruoyi-portal/src/main/resources/mapper/portal/HwWebMapper.xml +++ b/ruoyi-portal/src/main/resources/mapper/portal/HwWebMapper.xml @@ -6,7 +6,7 @@ - + @@ -25,7 +25,7 @@ and is_delete = '0' and web_id = #{webId} - and web_json = #{webJson} + and web_json = #{webJson,typeHandler=com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler} and web_json_string = #{webJsonString} and web_code = #{webCode} and web_json_english = #{webJsonEnglish} @@ -47,7 +47,7 @@ is_delete, - #{webJson}, + #{webJson,typeHandler=com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler}, #{webJsonString}, #{webCode}, #{webJsonEnglish}, @@ -61,7 +61,7 @@ update hw_web - web_json = #{webJson}, + web_json = #{webJson,typeHandler=com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler}, web_json_string = #{webJsonString}, web_json_english = #{webJsonEnglish}, @@ -79,4 +79,4 @@ #{webId} - \ No newline at end of file + diff --git a/ruoyi-portal/src/main/resources/mapper/portal/HwWebMapper1.xml b/ruoyi-portal/src/main/resources/mapper/portal/HwWebMapper1.xml index 0c23c9d..f38ce18 100644 --- a/ruoyi-portal/src/main/resources/mapper/portal/HwWebMapper1.xml +++ b/ruoyi-portal/src/main/resources/mapper/portal/HwWebMapper1.xml @@ -6,7 +6,7 @@ - + @@ -27,7 +27,7 @@ and is_delete = '0' and web_id = #{webId} - and web_json = #{webJson} + and web_json = #{webJson,typeHandler=com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler} and web_json_string = #{webJsonString} and web_code = #{webCode} and device_id = #{deviceId} @@ -51,7 +51,7 @@ insert into hw_web1 - web_json, + #{webJson,typeHandler=com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler}, web_json_string, web_code, device_id, @@ -60,7 +60,7 @@ is_delete, - #{webJson}, + #{webJson,typeHandler=com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler}, #{webJsonString}, #{webCode}, #{deviceId}, @@ -76,7 +76,7 @@ update hw_web1 - web_json = #{webJson}, + #{webJson,typeHandler=com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler}, web_json_string = #{webJsonString},