feat: 新增官网AI智能咨询功能及配套基础设施

本次提交实现了基于Spring AI的官网RAG智能问答系统,包含以下核心变更:
1. 引入Spring AI依赖并配置全局BOM版本管理
2. 新增AI问答相关领域实体、服务接口与实现
3. 重构实体类简化代码,新增MyBatis JSON字段处理器
4. 配置AI模型与知识库检索参数
5. 新增匿名访问的AI问答控制器接口
6. 优化部分现有实体类的代码结构
main
zch 1 month ago
parent e92331435f
commit 4c53d51655

@ -35,6 +35,7 @@
<jaxb-api.version>2.3.1</jaxb-api.version>
<jakarta.version>6.0.0</jakarta.version>
<springdoc.version>2.8.14</springdoc.version>
<spring-ai.version>1.1.7</spring-ai.version>
</properties>
<!-- 依赖声明 -->
@ -50,6 +51,15 @@
<scope>import</scope>
</dependency>
<!-- Spring AI 稳定版 BOM集中锁定 AI 相关组件版本,避免各子模块自行漂移。 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- 阿里数据库连接池 -->
<dependency>
<groupId>com.alibaba</groupId>

@ -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

@ -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 JSONJackson JsonNode
*
* JSONcommon
*
*/
public class JsonNodeTypeHandler extends BaseTypeHandler<JsonNode>
{
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);
}
}
}

@ -22,6 +22,12 @@
<artifactId>ruoyi-common</artifactId>
</dependency>
<!-- AI 只属于门户咨询场景,依赖放在 portal 内,避免 common/framework 被业务能力反向污染。 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>
</dependency>
</dependencies>
</project>

@ -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
*
* <p></p>
*/
@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, ollamaValue endpoint
*
*/
private Map<String, ModelConfig> 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<String, ModelConfig> getModels()
{
return models;
}
public void setModels(Map<String, ModelConfig> 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;
}
}
}

@ -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
*
* <p>访 RAG </p>
*/
@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));
}
}

@ -0,0 +1,45 @@
package com.ruoyi.portal.ai.domain;
import lombok.Data;
/**
* AI
*
* <p></p>
*/
@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;
}
}

@ -0,0 +1,80 @@
package com.ruoyi.portal.ai.domain;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* AI
*
* <p></p>
*/
@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<AiSource> 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<AiSource> getSources()
{
return sources;
}
public void setSources(List<AiSource> sources)
{
this.sources = sources;
}
}

@ -0,0 +1,56 @@
package com.ruoyi.portal.ai.domain;
import lombok.Data;
/**
* AI
*
* <p></p>
*/
@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;
}
}

@ -0,0 +1,71 @@
package com.ruoyi.portal.ai.domain;
import lombok.Data;
/**
*
*
* <p></p>
*/
@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;
}
}

@ -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);
}

@ -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<TextChunk> search(String question, int topK);
}

@ -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
*
* <p> RAG ()
* -> -> / Prompts -> -> </p>
*/
@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<String, OpenAiChatModel> chatModelCache = new ConcurrentHashMap<>();
/**
* RAG
*/
@Override
public AiChatResponse chat(AiChatRequest request)
{
String question = request.getQuestion().trim();
// 动态决策出本次调用的目标模型,若前端请求的模型未配置或不可用,将自动退化至系统默认大模型
String modelKey = resolveModelKey(request.getModel());
// 1. 召回阶段:从本地知识库检索出 Top-K 个相关的纯文本分片
List<TextChunk> 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<TextChunk> 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<TextChunk> 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<String, Object> 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<TextChunk> 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<TextChunk> 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<TextChunk> 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<AiSource> toSources(List<TextChunk> chunks)
{
List<AiSource> 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) + "...";
}
}

@ -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
*
* <p> ElasticSearch
* </p>
*/
@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<TextChunk> cachedChunks = new ArrayList<>();
/**
*
*
* @param question
* @param topK
* @return Top-K
*/
@Override
public List<TextChunk> search(String question, int topK)
{
// 1. 分词转换:将用户输入的问题切分为关键词列表(含滑动窗口字词)
List<String> terms = buildTerms(question);
if (terms.isEmpty())
{
return new ArrayList<>();
}
List<TextChunk> 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<TextChunk> loadChunks()
{
long now = System.currentTimeMillis();
// 第一层检查:未过期直接无锁返回,提升读取吞吐
if (now < cacheExpireAt && !cachedChunks.isEmpty())
{
return cachedChunks;
}
synchronized (this)
{
// 第二层检查:多线程竞争锁排队进入后,再次确认过期时间,防止重复执行解析逻辑
if (now < cacheExpireAt && !cachedChunks.isEmpty())
{
return cachedChunks;
}
List<TextChunk> 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<TextChunk> chunks)
{
List<HwWeb> 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<TextChunk> chunks)
{
List<HwWeb1> 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<TextChunk> 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<String> values = new ArrayList<>();
collectText(node, values);
return normalizeText(String.join(" ", values));
}
/**
* JsonNode
*/
private void collectText(JsonNode node, List<String> 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<TextChunk> 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<String> buildTerms(String question)
{
Set<String> 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<String> 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(" ");
}
/**
* &nbsp;
*/
private String normalizeText(String text)
{
if (text == null)
{
return "";
}
return text.replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replaceAll("\\s+", " ") // 合并多余的连续空白字符为单空格,节省存储与 Token 消耗
.trim();
}
}

@ -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();
}
}

@ -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<HwPortalConfigType> children = new ArrayList<HwPortalConfigType>();
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<HwProductCaseInfo> getHwProductCaseInfoList() {
return hwProductCaseInfoList;
}
public void setHwProductCaseInfoList(List<HwProductCaseInfo> hwProductCaseInfoList) {
this.hwProductCaseInfoList = hwProductCaseInfoList;
}
public List<HwPortalConfigType> getChildren() {
return children;
}
public void setChildren(List<HwPortalConfigType> 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();
}
}

@ -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;
/**
* haiweijson 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();
}
}

@ -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;
/**
* haiweijson 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();
}
}

@ -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;
/** 主键 */

@ -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();
}
}

@ -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();
}
}

@ -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();
}
}

@ -1,132 +1,79 @@
package com.ruoyi.portal.domain;
import lombok.Data;
import java.util.Date;
/**
* 访
* 访
*
* <p>访线/
*
* </p>
*
* @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;
}
}

@ -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;
}
}

@ -1,27 +1,28 @@
package com.ruoyi.portal.domain;
import lombok.Data;
import java.io.Serial;
/**
*
*
* <p>ID访
* </p>
*/
@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;
}
}

@ -6,7 +6,7 @@
<resultMap type="HwWeb" id="HwWebResult">
<result property="webId" column="web_id" />
<result property="webJson" column="web_json" />
<result property="webJson" column="web_json" typeHandler="com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler" />
<result property="webJsonString" column="web_json_string" />
<result property="webCode" column="web_code" />
<result property="isDelete" column="is_delete" />
@ -25,7 +25,7 @@
<where>
and is_delete = '0'
<if test="webId != null "> and web_id = #{webId}</if>
<if test="webJson != null and webJson != ''"> and web_json = #{webJson}</if>
<if test="webJson != null"> and web_json = #{webJson,typeHandler=com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler}</if>
<if test="webJsonString != null and webJsonString != ''"> and web_json_string = #{webJsonString}</if>
<if test="webCode != null "> and web_code = #{webCode}</if>
<if test="webJsonEnglish != null"> and web_json_english = #{webJsonEnglish}</if>
@ -47,7 +47,7 @@
is_delete,
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="webJson != null">#{webJson},</if>
<if test="webJson != null">#{webJson,typeHandler=com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler},</if>
<if test="webJsonString != null">#{webJsonString},</if>
<if test="webCode != null">#{webCode},</if>
<if test="webJsonEnglish != null">#{webJsonEnglish},</if>
@ -61,7 +61,7 @@
<update id="updateHwWeb" parameterType="HwWeb">
update hw_web
<trim prefix="SET" suffixOverrides=",">
<if test="webJson != null">web_json = #{webJson},</if>
<if test="webJson != null">web_json = #{webJson,typeHandler=com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler},</if>
<if test="webJsonString != null">web_json_string = #{webJsonString},</if>
<!-- <if test="webCode != null">web_code = #{webCode},</if>-->
<if test="webJsonEnglish != null">web_json_english = #{webJsonEnglish},</if>
@ -79,4 +79,4 @@
#{webId}
</foreach>
</update>
</mapper>
</mapper>

@ -6,7 +6,7 @@
<resultMap type="HwWeb1" id="HwWebResult1">
<result property="webId" column="web_id" />
<result property="webJson" column="web_json" />
<result property="webJson" column="web_json" typeHandler="com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler" />
<result property="webJsonString" column="web_json_string" />
<result property="webCode" column="web_code" />
<result property="deviceId" column="device_id" />
@ -27,7 +27,7 @@
<where>
and is_delete = '0'
<if test="webId != null "> and web_id = #{webId}</if>
<if test="webJson != null and webJson != ''"> and web_json = #{webJson}</if>
<if test="webJson != null"> and web_json = #{webJson,typeHandler=com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler}</if>
<if test="webJsonString != null and webJsonString != ''"> and web_json_string = #{webJsonString}</if>
<if test="webCode != null "> and web_code = #{webCode}</if>
<if test="deviceId != null "> and device_id = #{deviceId}</if>
@ -51,7 +51,7 @@
<insert id="insertHwWeb" parameterType="HwWeb1" useGeneratedKeys="true" keyProperty="webId">
insert into hw_web1
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="webJson != null">web_json,</if>
<if test="webJson != null">#{webJson,typeHandler=com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler},</if>
<if test="webJsonString != null">web_json_string,</if>
<if test="webCode != null">web_code,</if>
<if test="deviceId != null">device_id,</if>
@ -60,7 +60,7 @@
is_delete,
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="webJson != null">#{webJson},</if>
<if test="webJson != null">#{webJson,typeHandler=com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler},</if>
<if test="webJsonString != null">#{webJsonString},</if>
<if test="webCode != null">#{webCode},</if>
<if test="deviceId != null">#{deviceId},</if>
@ -76,7 +76,7 @@
<update id="updateHwWeb" parameterType="HwWeb1">
update hw_web1
<trim prefix="SET" suffixOverrides=",">
<if test="webJson != null">web_json = #{webJson},</if>
<if test="webJson != null">#{webJson,typeHandler=com.ruoyi.common.mybatis.handler.JsonNodeTypeHandler},</if>
<if test="webJsonString != null">web_json_string = #{webJsonString},</if>
<!-- <if test="webCode != null">web_code = #{webCode},</if>-->
<!-- <if test="deviceId != null">device_id = #{deviceId},</if>-->

Loading…
Cancel
Save