42 KiB
官网编辑输入升级、关键词搜索与访问监控实现方案
1. 文档目标与范围
本方案面向当前仓库架构(portal_website_edit + portal_website + ruoyi-portal + hw_web.sql),输出三项能力的完整落地设计:
- 将编辑后台模板组件文本输入快速升级为富文本或 Markdown 输入。
- 基于当前重点表(
hw_web_menu、hw_web、hw_web1、hw_web_menu1、hw_web_document)实现前后端关键词搜索。 - 实现官网匿名访问监控(PV/UV/访问路径/来源/停留等)。
说明:本文件是“实现方案与代码位点设计”,不直接修改业务代码。
2. 需求清单
2.1 功能需求
- 编辑器输入升级
- 支持
纯文本、富文本(HTML)、Markdown三种输入模式。 - 兼容历史
webJsonString数据,不影响已发布页面渲染。 - 支持逐组件/逐字段平滑迁移,不要求一次性改完全部
editEl组件。
- 关键词搜索
- 用户输入关键词后可搜索菜单、页面内容、产品详情、资料下载。
- 返回结果需标识内容类型、命中位置、跳转路由参数。
- 支持分页、排序(相关度优先)、高亮摘要。
- 官网访问监控(非登录)
- 采集匿名访问行为:页面访问、停留时长、来源、设备、下载、搜索。
- 输出基础看板:PV、UV、入口页、跳出率、热门页面、热门关键词。
- 支持反爬/刷量基础治理。
2.2 非功能需求
- 安全
- 富文本输出必须经过 XSS 清洗。
- 搜索接口必须防注入、限流、防超长关键词。
- 监控数据脱敏存储(IP 哈希、最小化隐私采集)。
- 性能
- 搜索接口 P95 < 300ms(初期中小数据量)。
- 埋点上报不阻塞页面渲染,采用异步/批量上报。
- 可维护性
- 输入升级采用“统一组件 + 统一协议”模式,避免 21 个模板重复改造。
- 搜索结果统一 DTO,前端只做渲染不做多形态兼容。
- 监控事件采用统一事件字典,便于扩展。
3. 对现有架构与代码的理解(基于当前仓库结构)
3.1 前端编辑端 portal_website_edit
- 关键入口
portal_website_edit/src/views/editPage/index.vueportal_website_edit/src/views/productCenter/edit/index.vue
- 模板组件
portal_website_edit/src/components/editEl/下carousel.vue、editEl1.vue...editEl16.vue、classicCase.vue、productCenter.vue- 当前大量使用
contenteditable + blur + 直接写 this.$props.data
- 数据模型
- 保存时统一
JSON.stringify(components)写入后端webJsonString - 后端持久层主要是
hw_web、hw_web1
- 请求与鉴权
portal_website_edit/src/utils/request.js统一请求封装- Cookie 中
Admin-Token注入 Bearer
3.2 展示端 portal_website
- 渲染模式
- 基于菜单树 + JSON 区块渲染
type = 1..16映射components/el/*
- 数据来源
/portal/hwWebMenu/*/portal/hwWeb/*、/portal/hwWeb1/*/portal/hwWebDocument/*
3.3 后端与数据库
- 后端
- Spring Boot + MyBatis,多模块 RuoYi 结构
ruoyi-portal负责门户内容接口
- 重点表
hw_web_menu:菜单树hw_web:通用页面 JSONhw_web1:产品详情 JSONhw_web_menu1:另一套菜单树(当前编辑端未重点使用)hw_web_document:下载文档与密钥
- 当前特征
- 逻辑删除字段
is_delete='0'才是有效数据 hw_web/hw_web1服务层版本化写入(旧记录逻辑删除 + 插入新记录)- 无唯一索引保障业务键唯一(存在并发竞态风险)
4. 总体落地策略
采用“三阶段并行可拆分实施”:
- 阶段 A(低风险快上线)
- 输入升级先实现统一组件和数据协议,逐组件接入。
- 搜索先做 SQL
LIKE + UNION ALL + 评分版本。 - 监控先做基础埋点与日报表。
- 阶段 B(质量增强)
- 输入渲染加完整 XSS 策略、回填协议迁移工具。
- 搜索引入索引表(可选)与权重调优。
- 监控引入实时指标与反刷策略。
- 阶段 C(规模优化)
- 搜索可迁移到 ES/OpenSearch(数据量变大时)。
- 监控引入漏斗分析、页面性能指标(Web Vitals)。
5. 方案一:模板文本输入升级为富文本/Markdown
5.1 设计目标
- 保持现有
webJsonString可读可写。 - 新增字段协议,不破坏旧字段语义。
- 同一字段可识别
plain/html/md,展示端统一渲染。
5.2 数据协议(推荐)
建议把“原本字符串字段”升级为内容对象:
{
"format": "plain|html|md",
"raw": "原始输入",
"html": "用于展示的安全HTML"
}
兼容规则:
- 旧数据是字符串时:默认
format=plain。 - 新数据写入对象时:展示端优先用对象逻辑渲染。
5.3 前端编辑端代码位点与实现细节
5.3.1 新增公共组件
- 建议新增:
portal_website_edit/src/components/common/RichContentInput.vue
核心逻辑:
props.value支持字符串或对象。- 内部
mode可切换plain/rich/md。 - rich 模式用 Quill;md 模式用 Markdown 编辑器(如 Toast UI)。
- 每次输入输出统一对象
{ format, raw, html }。
示例(核心片段,Vue2 思路):
<template>
<div class="rich-content-input">
<el-radio-group v-model="mode" size="mini">
<el-radio-button label="plain">纯文本</el-radio-button>
<el-radio-button label="rich">富文本</el-radio-button>
<el-radio-button label="md">Markdown</el-radio-button>
</el-radio-group>
<el-input
v-if="mode==='plain'"
type="textarea"
:rows="4"
v-model="plainText"
@input="emitValue"
/>
<quill-editor
v-else-if="mode==='rich'"
v-model="richHtml"
@change="emitValue"
/>
<markdown-editor
v-else
v-model="mdText"
@change="emitValue"
/>
</div>
</template>
<script>
export default {
props: { value: { type: [String, Object], default: '' } },
data() { return { mode: 'plain', plainText: '', richHtml: '', mdText: '' } },
created() { this.initFromValue(this.value) },
methods: {
initFromValue(v) {
if (typeof v === 'string') { this.mode = 'plain'; this.plainText = v; return; }
this.mode = v.format || 'plain';
this.plainText = this.mode === 'plain' ? (v.raw || '') : '';
this.richHtml = this.mode === 'html' || this.mode === 'rich' ? (v.html || '') : '';
this.mdText = this.mode === 'md' ? (v.raw || '') : '';
},
emitValue() {
const payload = this.mode === 'plain'
? { format: 'plain', raw: this.plainText, html: '' }
: this.mode === 'rich'
? { format: 'html', raw: this.richHtml, html: this.richHtml }
: { format: 'md', raw: this.mdText, html: this.$md.render(this.mdText) };
this.$emit('input', payload);
}
}
}
</script>
5.3.2 改造模板组件接入点
- 目标目录:
portal_website_edit/src/components/editEl/*.vue - 改造规则:
- 把
contenteditable文本块替换为RichContentInput。 this.$props.data.xxx = ...改为 v-model 数据绑定。- 仅替换文本字段,图片/列表结构保持原样。
优先改造组件:
editEl2.vue、editEl3.vue、editEl8.vue、editEl11.vue(文本密集)classicCase.vue、productCenter.vue(首页影响面大)
5.3.3 保存与读取适配
- 位置:
portal_website_edit/src/views/editPage/index.vue
逻辑:
- 保存前遍历
components,将文本字段标准化为协议对象(或保持旧字符串)。 - 读取后做 normalize,确保组件拿到统一结构。
- 增加
schemaVersion(建议挂在页面 JSON 顶层)。
5.4 展示端渲染适配
- 建议新增:
portal_website/src/utils/renderContent.js
import DOMPurify from 'dompurify'
export function normalizeContent(v) {
if (typeof v === 'string') return { format: 'plain', raw: v, html: '' }
return v || { format: 'plain', raw: '', html: '' }
}
export function renderHtml(v, mdRender) {
const c = normalizeContent(v)
if (c.format === 'plain') return DOMPurify.sanitize((c.raw || '').replace(/\n/g, '<br/>'))
if (c.format === 'md') return DOMPurify.sanitize(mdRender(c.raw || ''))
return DOMPurify.sanitize(c.html || '')
}
- 在
portal_website/src/components/el/*.vue中统一通过renderHtml()输出。
5.5 安全策略
- 前端展示前净化 HTML(DOMPurify)。
- 后端入库前二次净化(可在 Service 层做策略兜底)。
- 限制允许标签白名单(
p/br/strong/em/ul/li/a/img/table...)。
5.6 使用场景
- 市场运营编辑产品详情时输入图文混排说明。
- 技术文档编辑参数章节时使用 Markdown 表格。
- 首页案例描述保留历史纯文本,不影响旧页面。
6. 方案二:基于现有表结构的关键词搜索
6.1 设计目标
- 一次搜索覆盖菜单、页面、产品详情、文档。
- 结果可直接跳转到对应页面。
- 保持对当前
hw_web/hw_web1版本化数据模型兼容。
6.2 接口设计
6.2.1 门户搜索接口(展示端)
- 建议新增:
GET /portal/search - 参数:
keyword(必填,1~50)pageNum(默认 1)pageSize(默认 20,上限 50)
返回结构:
{
"code": 200,
"data": {
"total": 123,
"rows": [
{
"sourceType": "menu|web|web1|document",
"title": "命中标题",
"snippet": "命中摘要",
"score": 120,
"route": "/productCenter/detail",
"routeQuery": {"webCode":"7","typeId":"1","deviceId":"1001"}
}
]
}
}
6.2.2 编辑端搜索接口(可选)
- 建议新增:
GET /portal/search/edit - 结果附带
editRoute,支持一键跳到/editor或/productCenter/edit。
6.3 后端代码位点建议(ruoyi-portal)
建议新增如下文件(按 RuoYi 常见分层):
- Controller
ruoyi-portal/src/main/java/com/ruoyi/portal/controller/HwSearchController.java
- Service
ruoyi-portal/src/main/java/com/ruoyi/portal/service/IHwSearchService.javaruoyi-portal/src/main/java/com/ruoyi/portal/service/impl/HwSearchServiceImpl.java
- Domain/DTO
ruoyi-portal/src/main/java/com/ruoyi/portal/domain/dto/SearchResultDTO.java
- Mapper
ruoyi-portal/src/main/java/com/ruoyi/portal/mapper/HwSearchMapper.javaruoyi-portal/src/main/resources/mapper/portal/HwSearchMapper.xml
6.4 SQL 实现策略(V1)
使用 UNION ALL + LIKE + 业务打分:
SELECT 'menu' AS source_type,
m.web_menu_id AS biz_id,
m.web_menu_name AS title,
100 AS score,
NULL AS web_code,
NULL AS type_id,
NULL AS device_id
FROM hw_web_menu m
WHERE m.is_delete='0'
AND m.web_menu_name LIKE CONCAT('%', #{keyword}, '%')
UNION ALL
SELECT 'web' AS source_type,
w.web_id AS biz_id,
CONCAT('页面#', w.web_code) AS title,
60 AS score,
w.web_code,
NULL AS type_id,
NULL AS device_id
FROM hw_web w
WHERE w.is_delete='0'
AND w.web_json_string LIKE CONCAT('%', #{keyword}, '%')
UNION ALL
SELECT 'web1' AS source_type,
w1.web_id AS biz_id,
CONCAT('详情#', w1.web_code, '-', w1.typeId, '-', w1.device_id) AS title,
70 AS score,
w1.web_code,
w1.typeId AS type_id,
w1.device_id
FROM hw_web1 w1
WHERE w1.is_delete='0'
AND w1.web_json_string LIKE CONCAT('%', #{keyword}, '%')
UNION ALL
SELECT 'document' AS source_type,
d.document_id AS biz_id,
d.file_name AS title,
50 AS score,
d.web_code,
d.type AS type_id,
NULL AS device_id
FROM hw_web_document d
WHERE d.is_delete='0'
AND (
d.file_name LIKE CONCAT('%', #{keyword}, '%')
OR d.document_address LIKE CONCAT('%', #{keyword}, '%')
)
ORDER BY score DESC;
6.5 搜索结果路由映射逻辑
menu命中
- 跳转
portal_website的菜单路由(如/test?id={menuId})。
web命中
- 若
web_code=-1-> 首页块位(可转首页并定位)。 - 若
web_code=7->/productCenter。 - 其他 ->
/test?id={webCode}(按现有约定)。
web1命中
/productCenter/detail?webCode=...&typeId=...&deviceId=...
document命中
- 跳资料页或详情页
el14区块,并传documentId。
6.6 前端代码位点建议
展示端:
portal_website/src/api/search.js:新增搜索 API 调用。portal_website/src/views/search/index.vue:新增搜索结果页。- 顶部导航组件(现有 header 组件)增加搜索框与回车触发。
编辑端(可选):
portal_website_edit/src/api/search.js/editor顶部增加“全站内容搜索”入口,便于运营定位。
6.7 性能与演进
- V1(当前)
- 直接扫表 +
LIKE - 适合中小数据量和快速上线
- V2(数据增长后)
- 增加
hw_search_index索引表(预拆 JSON 文本) - 定时任务增量更新索引
- V3(大规模)
- 接入 Elasticsearch/OpenSearch
6.8 使用场景
- 用户搜索“工业交换机”,同时命中产品详情、案例页、下载文档。
- 用户搜索“防爆”,优先展示行业方案菜单入口。
- 运营搜索关键词后直达编辑页修正文案。
7. 方案三:官网匿名访问监控(PV/UV/停留/来源)
7.1 监控目标
- 实时看总访问趋势与来源质量。
- 识别热门页面和高价值行为(下载、搜索、咨询)。
- 支持营销活动效果归因(UTM)。
7.2 事件模型
推荐事件类型:
page_viewpage_leavesearch_submitdownload_clickcontact_submit
公共字段:
visitorId(匿名长期 ID)sessionId(30 分钟无活动重置)eventTypepathreferrerutmSource/utmMedium/utmCampaignua/device/browser/osipHasheventTime
7.3 前端采集实现位点(portal_website)
- 新增:
portal_website/src/utils/analytics.js
- 注册:
portal_website/src/main.js中初始化 SDKportal_website/src/router/index.js增加afterEach做page_view- 页面卸载时发送
page_leave(sendBeacon优先)
示例:
// analytics.js
export function collect(eventType, payload = {}) {
const body = {
eventType,
...payload,
visitorId: getOrCreateVisitorId(),
sessionId: getOrCreateSessionId(),
eventTime: Date.now(),
path: location.pathname + location.search,
referrer: document.referrer || ''
}
if (navigator.sendBeacon) {
navigator.sendBeacon('/prod-api/portal/analytics/collect', JSON.stringify(body))
} else {
fetch('/prod-api/portal/analytics/collect', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
keepalive: true
})
}
}
7.4 后端实现位点建议(ruoyi-portal)
- Controller
ruoyi-portal/src/main/java/com/ruoyi/portal/controller/HwAnalyticsController.java- 采集接口
@Anonymous:POST /portal/analytics/collect
- Service/Mapper
IHwAnalyticsService+HwAnalyticsServiceImplHwAnalyticsMapper+HwAnalyticsMapper.xml
- 定时汇总(Quartz)
- 每 5 分钟汇总实时指标到日统计表
- 每日 00:10 出日报聚合
7.5 数据库表设计(建议新增)
- 明细表:
hw_web_visit_event
idbigint pkevent_typevarchar(32)visitor_idvarchar(64)session_idvarchar(64)pathvarchar(500)referrervarchar(500)utm_source/utm_medium/utm_campaignvarchar(128)ip_hashvarchar(128)uavarchar(500)device/browser/osvarchar(64)stay_msbigint nullcreated_atdatetime- 索引:
idx_created_at、idx_event_type、idx_visitor_session、idx_path
- 日统计表:
hw_web_visit_daily
stat_datedate pkpvbigintuvbigintip_uvbigintavg_stay_msbigintbounce_ratedecimal(5,2)search_countbigintdownload_countbigintcreated_at/updated_atdatetime
7.6 风险控制
- 防刷
- IP + UA + path 短时间阈值限流(可复用
@RateLimiter思路) - 机器人 UA 黑名单
- 隐私
- 不存明文 IP,只存 hash
- 避免采集姓名、手机号等 PII
- 性能
- 上报接口异步入库(可先入 Redis Stream/队列再落库)
7.7 使用场景
- 运营查看“今日 PV/UV、来源渠道、热门页面”。
- 产品查看“搜索词和下载行为”评估内容价值。
- 市场评估广告 UTM 转化效果。
8. 迭代计划(建议)
- 第 1 周
- 完成输入协议、公共编辑组件、2~4 个核心模板接入。
- 完成搜索接口 V1(后端 + 展示端结果页)。
- 完成基础埋点
page_view/page_leave/search_submit。
- 第 2 周
- 扩展模板接入到主要文本组件。
- 搜索结果高亮、排序优化、路由跳转完善。
- 埋点加入下载/咨询转化事件与日报接口。
- 第 3 周
- 安全加固(XSS 白名单、限流、SQL 压测)。
- 引入搜索索引表和监控聚合任务(按数据规模决定)。
9. 验收标准
- 输入升级
- 旧页面内容不丢失,显示一致。
- 新增富文本/Markdown可编辑、可保存、可展示。
- XSS 测试样例无法执行脚本。
- 搜索
- 关键词可命中四类内容(菜单/页面/详情/文档)。
- 返回可直接跳转对应页面。
- 支持分页、相关度排序、摘要高亮。
- 监控
- 能输出日维度 PV/UV/停留/跳出率。
- 可区分来源渠道并查看热门页面。
- 采集不显著影响首屏性能。
10. 附:实现时的关键注意点
- 保持
webJsonString契约稳定,避免一次性大范围改字段名。 hw_web/hw_web1版本化写入下,搜索只查is_delete='0'。hw_web_menu1字段命名历史问题(value/valuel)如启用需先统一映射。- 下载文档密钥逻辑不应被搜索结果泄露,搜索仅返回可公开字段。
- 所有新接口遵循现有返回约定(
AjaxResult/TableDataInfo)。
11. 搜索功能备选方案:基于 ES(优先 Easy-Es)
本章节作为“方案二(MySQL LIKE 搜索)”的升级备选,目标是在数据规模上升后获得更好的全文检索效果、相关度排序和高亮能力。
11.1 技术选型结论(优先级)
- 优先:Easy-Es(Java 侧开发效率更高)
- 适用前提:团队接受引入 Easy-Es 依赖;检索需求以常规全文检索、过滤、高亮为主。
- 优点:实体注解 + Wrapper 风格,开发成本低,和现有 MyBatis 项目协作成本较小。
- 风险:框架抽象层增加,遇到复杂 DSL 场景仍需下沉原生能力。
- 备选:原生 Elasticsearch Java Client
- 适用前提:需要复杂查询/聚合/性能调优,或希望减少第三方封装依赖。
- 优点:能力最全、控制力最高。
- 风险:开发样板代码更多。
建议:先按 Easy-Es 落地,保留“复杂查询接口走原生 Client”的扩展点。
11.2 索引模型设计(结合当前 5 张重点表)
统一索引:hw_portal_content_v1,查询别名:hw_portal_content
文档来源:
hw_web_menu->sourceType=menuhw_web->sourceType=webhw_web1->sourceType=web1hw_web_document->sourceType=document
文档 ID 设计(避免版本化写入导致 ID 抖动):
- 菜单:
menu:{web_menu_id} - 页面:
web:{web_code} - 详情:
web1:{web_code}:{typeId}:{device_id} - 文档:
doc:{document_id}
说明:hw_web/hw_web1 当前是“逻辑删旧 + 插入新”,用业务键作为 ES _id,可保证覆盖更新稳定。
11.3 ES Mapping 建议
PUT hw_portal_content_v1
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"default_cn": {
"type": "custom",
"tokenizer": "ik_max_word"
},
"search_cn": {
"type": "custom",
"tokenizer": "ik_smart"
}
}
}
},
"mappings": {
"dynamic": "false",
"properties": {
"docId": { "type": "keyword" },
"sourceType": { "type": "keyword" },
"title": { "type": "text", "analyzer": "default_cn", "search_analyzer": "search_cn", "fields": { "kw": { "type": "keyword", "ignore_above": 256 } } },
"content": { "type": "text", "analyzer": "default_cn", "search_analyzer": "search_cn" },
"keywords": { "type": "keyword" },
"webCode": { "type": "keyword" },
"typeId": { "type": "keyword" },
"deviceId": { "type": "keyword" },
"menuId": { "type": "keyword" },
"documentId": { "type": "keyword" },
"route": { "type": "keyword" },
"routeQuery": { "type": "flattened" },
"isDelete": { "type": "keyword" },
"updatedAt": { "type": "date" }
}
}
}
注意:
- 若 ES 环境未安装 IK 分词器,可临时改为
standard,但中文效果会下降。 routeQuery用flattened可减少动态字段爆炸风险。
11.4 入索引字段映射规则(按表)
hw_web_menu
title = web_menu_namecontent = web_menu_name + ancestors(可拼接)menuId = web_menu_idroute = /testrouteQuery = { "id": web_menu_id }
hw_web
title = 页面标识(如 web_code)或从 JSON 提取首个标题字段content = web_json_string(建议清洗 HTML 标签后再入索引)webCode = web_coderoute根据web_code映射(-1 -> /index,7 -> /productCenter, 其他 ->/test)
hw_web1
title = 设备详情标识或 JSON 首标题content = web_json_string(清洗后)webCode/typeId/deviceId对应业务键route = /productCenter/detailrouteQuery = { webCode, typeId, deviceId }
hw_web_document
title = file_name(若表中无该列则退化为 document_id)content = document_address(仅用于检索,不返回敏感内容)documentId = document_idroute指向资料页或对应详情页
11.5 同步流程设计(全量 + 增量 + 补偿)
11.5.1 全量初始化
- 启动任务或管理接口触发:
- 扫描四类数据表(
is_delete='0') - 按 500~1000 条批量写入 ES(Bulk)
- 完成后切换 alias 到新索引(可选蓝绿)
- 建议代码位置
ruoyi-portal/.../service/search/HwSearchRebuildService.javaruoyi-portal/.../controller/HwSearchAdminController.java(内部管理接口)
11.5.2 增量同步(主路径)
触发点(在现有写接口后):
- 菜单变更:
/portal/hwWebMenu/* - 页面变更:
/portal/hwWeb/updateHwWeb - 详情变更:
/portal/hwWeb1/updateHwWeb1 - 文档变更:
/portal/hwWebDocument/*
实现建议:
- 在 Service 成功提交后发送“同步事件”(推荐事务后回调)。
- 同步消费者将业务对象转换为索引文档并
upsert。 - 删除操作写成
delete by id或isDelete=1后过滤。
11.5.3 补偿机制(强烈建议)
新增 outbox 表:hw_search_sync_outbox
- 字段:
id,biz_type,biz_key,op_type,payload,status,retry_count,next_retry_time,created_at,updated_at
流程:
- 业务写库与 outbox 同事务提交。
- 定时任务扫描
status=pending/failed进行重试。 - 超过阈值告警,人工或脚本修复。
该机制可避免“数据库成功、ES 失败”造成长期不一致。
11.6 查询 DSL 设计
11.6.1 通用搜索 DSL(关键词 + 类型过滤 + 高亮)
POST hw_portal_content/_search
{
"from": 0,
"size": 20,
"_source": ["docId","sourceType","title","route","routeQuery","webCode","typeId","deviceId","menuId","documentId","updatedAt"],
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "工业交换机",
"fields": ["title^5", "content^2"],
"type": "best_fields"
}
}
],
"filter": [
{ "term": { "isDelete": "0" } }
]
}
},
"highlight": {
"pre_tags": ["<em class='hit'>"],
"post_tags": ["</em>"],
"fields": {
"title": {},
"content": { "fragment_size": 120, "number_of_fragments": 1 }
}
},
"sort": [
{ "_score": "desc" },
{ "updatedAt": "desc" }
]
}
11.6.2 指定类型筛选
在 filter 增加:
{ "terms": { "sourceType": ["web1", "document"] } }
11.6.3 精确业务键跳转查询
用于编辑后台快速定位:
{
"query": {
"bool": {
"filter": [
{ "term": { "sourceType": "web1" } },
{ "term": { "webCode": "7" } },
{ "term": { "typeId": "2" } },
{ "term": { "deviceId": "10001" } }
]
}
}
}
11.7 Easy-Es 落地骨架(建议)
建议包结构(ruoyi-portal 内):
.../search/es/entity/PortalSearchDoc.java.../search/es/mapper/PortalSearchMapper.java.../search/service/PortalSearchEsService.java.../search/service/impl/PortalSearchEsServiceImpl.java.../search/convert/PortalSearchDocConverter.java
实体示意(简化):
@IndexName("hw_portal_content")
public class PortalSearchDoc {
@IndexId
private String docId;
private String sourceType;
private String title;
private String content;
private String webCode;
private String typeId;
private String deviceId;
private String menuId;
private String documentId;
private String route;
private Map<String, Object> routeQuery;
private String isDelete;
private Date updatedAt;
}
查询示意(伪代码):
LambdaEsQueryWrapper<PortalSearchDoc> qw = new LambdaEsQueryWrapper<>();
qw.match(PortalSearchDoc::getTitle, keyword).or().match(PortalSearchDoc::getContent, keyword);
qw.eq(PortalSearchDoc::getIsDelete, "0");
qw.orderByDesc(BaseEsEntity::getScore);
// 执行分页查询并组装高亮字段
注:高亮、复杂 multi_match、function_score 等场景若 Easy-Es 封装不够直观,可在同服务中直接使用原生 ES Client 补充。
11.8 与当前 MySQL 方案的切换策略
- 接口层保持不变:继续使用
/portal/search。 - 配置开关:
search.engine=mysql|es(默认 mysql)search.es.enabled=true/false
- 灰度:
- 小流量用户先走 ES,比对命中率与响应时间。
- 指标达标后全量切换。
- 回滚:
- 保留 MySQL 搜索实现,ES 故障时秒级切回。
11.9 适用场景与收益
- 中文关键词检索命中明显提升(分词 + 相关度)。
- 多来源内容统一搜索体验(菜单/页面/详情/文档)。
- 结果高亮与权重排序更符合官网用户行为。
11.10 风险与前置条件
- 前置条件
- 可用 ES 集群(开发/测试/生产)
- 分词器插件(推荐 IK)
- 运维监控(磁盘、分片、慢查询)
- 风险
- 数据一致性:需 outbox + 重试保障
- 运维复杂度:备份、索引管理、版本兼容
- 成本:新增基础设施资源
结论: 在你当前架构中,ES 是可行且长期更优的搜索升级路径;落地顺序建议"Easy-Es 优先 + 原生 Client 补位 + MySQL 方案保底"。
12. 深度补充分析(2026-02-28)
以下内容是对方案 1/2/3 + 11 的深度审视,标注了原方案未覆盖或不够充分的要点。
12.1 问题一补充:富文本/Markdown 输入升级的实际落地细节
12.1.1 字段分类:并非所有文本字段都需要富文本
通过审查全部 21 个 editEl 组件的 contenteditable 使用(grep 到约 50+ 处),字段可分为三类:
-
短标题字段(不需要富文本)
title、subTitle、itemTitle、leftTitle、bannerTitle、bannerValue- 特点:一般 ≤50 字,纯文本即可,富文本反而增加复杂度
- 涉及组件:几乎所有
editEl1~editEl16、carousel
-
中长内容字段(适合富文本/Markdown)
contentInfo(editEl2)、info(editEl6)、value(editEl4/el9/el11/carousel)、itemInfo(editEl3)、leftInfo(editEl8)- 特点:可能 50~500+ 字,含段落、换行、强调等需求
- 优先改造范围
-
结构化字段(不需要改造)
editEl12(imgList/features)、editEl13(params 表格)、editEl14(fileList 文件)、editEl15(banner 双图文,字段短)、editEl16(params 多列表格)- 这些组件的文本是表格单元格或列表项,不适合富文本
结论: 原方案 5.3.2 "把 contenteditable 文本块替换为 RichContentInput" 应改为 "仅对中长内容字段替换",短标题字段保留纯文本(可用 el-input 替代 contenteditable,但不引入富文本)。
12.1.2 Quill 2.0 + Vue 2 兼容性问题(关键风险)
当前 package.json 已有 quill@2.0.3,但:
vue-quill-editor(社区最常用的 Vue 2 Quill 封装)仅支持 Quill 1.x,不兼容 Quill 2.0。@vueup/vue-quill仅支持 Vue 3。- Quill 2.0 的 API 与 1.x 有 breaking changes(模块注册、Delta 格式、toolbar 配置等)。
可行方案(按优先级):
| 方案 | 说明 | 风险 |
|---|---|---|
| A. 降级到 Quill 1.3.7 + vue-quill-editor | 社区成熟、文档多、Vue 2 直接可用 | Quill 1.x 不再维护,功能较老 |
| B. 自封装 Quill 2.0 组件 | 手动 new Quill(el, options) 包装为 Vue 2 组件 |
需自行处理生命周期、v-model 绑定、销毁清理 |
| C. 使用 TinyMCE / CKEditor 5(Vue 2 插件) | 功能更全、官方有 Vue 2 集成 | 体积更大、需额外学习 |
| D. 使用 wangEditor 5(国产,Vue 2 支持) | 中文生态好、文档全、体积适中 | 社区相对 Quill/CKEditor 小 |
建议:若团队无特别偏好,方案 D(wangEditor 5) 对当前项目最友好——原生中文文档、Vue 2 官方支持(@wangeditor/editor-for-vue@1.x)、轻量、默认中文 toolbar。若已有 Quill 使用经验则走 方案 B。
12.1.3 Markdown 编辑器选型(Vue 2 场景)
原方案提到 "Toast UI",补充对比:
| 编辑器 | Vue 2 支持 | 特点 |
|---|---|---|
@toast-ui/vue-editor |
✅ 官方支持 | 功能全,但包体较大(~300KB gzip) |
v-md-editor |
✅ | 国产、轻量、支持预览 |
mavon-editor |
✅ | 国产、使用广泛、功能成熟 |
建议:若 Markdown 需求为"技术文档/参数描述"场景,用 v-md-editor 即可。
12.1.4 contenteditable 替换为 el-input 的快捷路径
在正式引入富文本之前,有一个更快速的改进路径:
- 将所有
<span contenteditable="true" @blur="edit(...)">替换为<el-input v-model="data.xxx" />(短字段用type="text",长字段用type="textarea")。 - 好处:消除
contenteditable的原地改 props 问题,获得输入校验能力,不引入额外依赖。 - 这可以作为阶段 0 快速实施,后续再对长内容字段升级为富文本。
12.2 问题二补充:匿名访问监控的额外考量
12.2.1 自建 vs 第三方方案对比
原方案仅考虑了自建方案。补充第三方对比:
| 方案 | 部署方式 | 数据隐私 | 开发成本 | 功能完整度 |
|---|---|---|---|---|
| 自建(方案三) | 集成到现有后端 | 完全可控 | 高(需开发采集+聚合+看板) | 按需定制 |
| Umami(推荐评估) | 自托管 Docker | 数据自有 | 极低(部署即用) | PV/UV/来源/设备/事件/UTM |
| Matomo | 自托管 | 数据自有 | 低 | 非常全面(含漏斗、热力图) |
| 百度统计/Google Analytics | SaaS | 数据在第三方 | 极低(嵌入 JS 即可) | 全面 |
建议:
- 若只需 PV/UV/来源/热门页面等基础看板,Umami 自托管 1~2 小时即可部署完成,无需后端开发,前端仅需嵌入一行
<script>标签。 - 若需要自定义事件(download_click/search_submit/contact_submit)且希望与业务数据库关联,则自建方案仍有价值,可与 Umami 并行使用。
- 先用 Umami 快速获得基础看板,自建方案聚焦于"业务转化事件 + 与搜索/内容编辑数据关联"。
12.2.2 CORS 配置
portal_website(展示端)与后端可能不同源。采集接口 POST /portal/analytics/collect 需要:
- 后端在
WebMvcConfigurer或 Spring Security 中对/portal/analytics/**放行 CORS。 - 该接口标记
@Anonymous(无需登录)。 sendBeacon发送的Content-Type是text/plain(非application/json),需后端兼容解析或前端用Blob包装。
12.2.3 visitorId 生成与持久化
建议方案:
- 首次访问生成 UUID,存入
localStorage(非 Cookie,避免被清理策略影响)。 - 若
localStorage被清除则重新生成(UV 会略有偏差,可接受)。 - 不要用 IP 或 fingerprint 作为唯一标识(隐私合规风险)。
12.2.4 数据保留与归档策略
hw_web_visit_event 明细表会快速增长。建议:
- 明细表保留 90 天,超期按月归档到
hw_web_visit_event_archive_YYYYMM。 - 日统计表
hw_web_visit_daily永久保留。 - 归档可通过 Quartz 定时任务实现(每月 1 日归档上月数据)。
12.3 问题三补充:搜索功能的深度分析
12.3.1 当前数据量评估(基于 hw_web.sql 实际数据)
| 表 | 总行数 | 活跃行(is_delete='0') | 单行 web_json_string 大小 |
|---|---|---|---|
hw_web_menu |
28 | ~20+ | N/A(无 JSON) |
hw_web |
38 | ~15-18 | 1KB ~ 15KB(含大量 URL 和 JSON 结构标记) |
hw_web1 |
31 | ~15-20 | 1KB ~ 10KB |
hw_web_document |
11 | ~10 | N/A |
hw_web_menu1 |
2 | ~2 | N/A |
结论:当前活跃数据总量 < 100 条,MySQL 方案完全够用。 即使考虑到未来增长 10 倍(~1000 条),MySQL 仍可应对。ES 的引入应作为"数据量破万 + 搜索体验要求高"时的升级路径。
12.3.2 原方案 V1 SQL 的关键缺陷:JSON 误匹配
原方案 6.4 的 SQL 直接 LIKE 搜索 web_json_string,会产生严重误匹配:
-- 用户搜索"png":会命中所有包含图片 URL 的记录
-- 用户搜索"type":会命中所有 JSON 结构的 type 字段
-- 用户搜索"icon":会命中所有含 icon 字段的 JSON
必须在搜索前从 JSON 中提取纯文本。两种方案:
方案 A:MySQL 8.0 JSON 函数实时提取(零成本,推荐 V1)
MySQL 8.0 支持 JSON_EXTRACT + JSON_UNQUOTE,可以在 SQL 中提取 JSON 中的文本字段。但 web_json_string 存储的是 longtext(非 JSON 列类型),且结构嵌套复杂(数组套对象套数组),用 SQL JSON 函数提取成本高。
更实际的做法:在 Service 层做 JSON 文本提取。
// 伪代码:从 webJsonString 中提取所有可见文本
public String extractSearchableText(String webJsonString) {
// 1. 解析 JSON
// 2. 递归遍历,提取 title/subTitle/value/contentInfo/name 等文本字段
// 3. 过滤掉 icon/url/type 等非文本字段
// 4. 拼接为纯文本返回
}
方案 B:预构建搜索索引表(推荐 V1.5)
新增表 hw_search_index:
CREATE TABLE hw_search_index (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
source_type VARCHAR(16) NOT NULL COMMENT 'menu/web/web1/document',
biz_id VARCHAR(64) NOT NULL COMMENT '业务主键或业务键',
title VARCHAR(512) NULL COMMENT '标题(从JSON提取)',
content TEXT NULL COMMENT '纯文本内容(从JSON提取,去除URL/结构标记)',
web_code VARCHAR(32) NULL,
type_id VARCHAR(32) NULL,
device_id VARCHAR(32) NULL,
menu_id VARCHAR(32) NULL,
document_id VARCHAR(64) NULL,
route VARCHAR(128) NULL COMMENT '展示端跳转路由',
route_query JSON NULL COMMENT '路由参数',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
is_delete CHAR(1) NOT NULL DEFAULT '0',
FULLTEXT INDEX ft_title_content (title, content) WITH PARSER ngram,
UNIQUE INDEX uk_source_biz (source_type, biz_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='官网搜索索引表';
关键点:
- MySQL FULLTEXT + ngram 分词器,原生支持中文分词,无需引入 ES。
ngramtoken_size 默认 2,对中文效果比 LIKE 好很多(可匹配"物联"、"轮胎"等双字词)。- 在
hw_web/hw_web1保存成功后(Service 层事务后回调),触发索引更新。 - 搜索 SQL:
SELECT ... FROM hw_search_index WHERE MATCH(title, content) AGAINST(#{keyword} IN BOOLEAN MODE) AND is_delete='0'。
12.3.3 应纳入搜索范围的额外表(原方案遗漏)
除 5 张重点表外,Mapper 目录显示 ruoyi-portal 还操作以下表,这些表也包含可搜索内容:
| 表 | 可搜索字段 | 说明 |
|---|---|---|
hw_portal_config_type |
config_type_name, config_type_desc, home_config_type_name |
产品分类名称和描述 |
hw_product_info |
product_info_ctitle, product_info_etitle |
产品信息标题 |
hw_product_info_detail |
product_info_detail_title, product_info_detail_desc |
产品明细描述 |
hw_product_case_info |
case_info_title, case_info_desc |
案例标题和描述 |
hw_about_us_info |
about_us_info_title, about_us_info_desc |
关于我们 |
搜索命中这些表时,路由映射规则:
hw_portal_config_type->/productCenter(按config_type_classification和层级确定 tab)hw_product_info/detail->/productCenter/detail(按config_type_id跳转)hw_product_case_info-> 案例详情页hw_about_us_info->/contactUs
12.3.4 Elasticsearch 与 Easy-Es 的技术可行性深度评估
什么是 Elasticsearch?
Elasticsearch(ES)是基于 Lucene 的分布式全文搜索引擎,提供:
- 倒排索引:对文档中的每个词建立索引,搜索时 O(1) 定位,远快于 SQL LIKE 的全表扫描。
- 分词:中文需要 IK 分词器插件,将"工业物联网解决方案"拆为"工业/物联网/解决/方案"等词项。
- 相关度评分:BM25 算法自动按匹配程度排序,比固定 score 更准确。
- 高亮:返回结果自动标记命中词位置,展示端直接渲染。
- 近实时搜索:数据写入后 1 秒内可被检索到。
- 代价:需要独立部署 ES 集群(最少 1 节点),JVM 内存建议 ≥1GB,运维磁盘/分片/索引管理。
什么是 Easy-Es?
Easy-Es 是国产开源框架(cn.easy-es),目标是"让 ES 操作像 MyBatis-Plus 一样简单":
- 注解驱动:
@IndexName、@IndexId、@IndexField标注实体类,自动创建/更新索引 Mapping。 - Wrapper 查询:
LambdaEsQueryWrapper链式构建查询条件,不需要手写 JSON DSL。 - 自动高亮:配置
highLight=true即可。 - 与 Spring Boot 集成:
easy-es-boot-starter自动配置连接。
版本兼容性分析(关键)
| 依赖 | 当前版本 | Easy-Es 要求 |
|---|---|---|
| Java | 17 | ✅ 支持 |
| Spring Boot | 3.5.8 | ⚠️ Easy-Es 2.0.0 官方适配 Spring Boot 3.0~3.2,3.5.x 需验证 |
| Elasticsearch | 未引入 | Easy-Es 2.x 支持 ES 7.14~8.x |
风险点:
- Easy-Es 2.0.0 内部依赖
elasticsearch-rest-high-level-client(ES 官方已在 8.x 标记 deprecated),与 Spring Boot 3.5.8 的自动配置可能冲突。需要 排除 Spring Data Elasticsearch 自动配置 或锁定版本。 - 若 ES 服务端版本为 8.x,需确认 Easy-Es 2.0.0 的高级客户端兼容模式是否正常。
Maven 依赖参考(若引入 Easy-Es)
<!-- 根 pom.xml properties -->
<easy-es.version>2.0.0</easy-es.version>
<elasticsearch.version>7.14.0</elasticsearch.version>
<!-- ruoyi-portal/pom.xml -->
<dependency>
<groupId>cn.easy-es</groupId>
<artifactId>easy-es-boot-starter</artifactId>
<version>${easy-es.version}</version>
</dependency>
application.yml 配置:
easy-es:
enable: true
address: localhost:9200
schema: http
# username: elastic # 如有认证
# password: xxx
最终建议落地路径
V1(当前实施)
└─ MySQL hw_search_index + FULLTEXT ngram
├─ 无额外基础设施
├─ 中文分词效果可用(双字粒度)
└─ 数据量 <10000 完全够用
V2(数据量 >10000 或需要更好搜索体验时)
└─ 引入 ES + Easy-Es
├─ 部署 ES 7.14+ 单节点 + IK 分词器
├─ hw_search_index 同步写入 ES
├─ 搜索接口通过配置开关切换 MySQL/ES
└─ 保留 MySQL 搜索作为降级方案
V3(规模化)
└─ ES 集群 + 原生 Client 补位复杂查询
12.3.5 搜索结果摘要生成策略
原方案返回 snippet 字段但未说明如何生成。补充:
- MySQL 方案:在 Service 层做关键词上下文截取(关键词前后各取 50~80 字符)。
- ES 方案:直接用
highlight返回的 fragment。 - 通用规则:
- 优先返回
title高亮。 - 若
title未命中,返回content中命中片段。 - 摘要长度限制 120 字符。
- 高亮标签
<em class="search-hit">...</em>,展示端 CSS 控制样式。
- 优先返回
12.4 三个问题的总结评估
| 问题 | 原方案覆盖度 | 主要补充点 |
|---|---|---|
| 1. 富文本输入 | ~70% | 字段分类(短标题不改)、Quill 2.0 兼容性、wangEditor/v-md-editor 推荐、el-input 快捷路径 |
| 2. 访问监控 | ~75% | Umami 自托管作为快速方案、CORS/sendBeacon 兼容、visitorId 策略、数据保留策略 |
| 3. 搜索功能 | ~65% | JSON 误匹配问题、hw_search_index + FULLTEXT ngram 作为 V1.5、额外可搜索表、Easy-Es 版本兼容性风险、摘要生成 |
修订后的推荐实施优先级
- 最快见效:搜索 V1(hw_search_index + FULLTEXT ngram) + Umami 自托管 → 1~2 天可上线基础能力。
- 第二优先:editEl 组件将
contenteditable改为el-input(阶段 0)→ 消除最大输入风险。 - 第三优先:对
contentInfo/value/info等长文本字段引入 wangEditor → 富文本能力。 - 按需引入:ES + Easy-Es(数据量增长后)、自建埋点(Umami 不满足时)。