You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
hw-web/doc/IMPLEMENTATION_PLAN_PORTAL_...

42 KiB

官网编辑输入升级、关键词搜索与访问监控实现方案

1. 文档目标与范围

本方案面向当前仓库架构(portal_website_edit + portal_website + ruoyi-portal + hw_web.sql),输出三项能力的完整落地设计:

  1. 将编辑后台模板组件文本输入快速升级为富文本或 Markdown 输入。
  2. 基于当前重点表(hw_web_menuhw_webhw_web1hw_web_menu1hw_web_document)实现前后端关键词搜索。
  3. 实现官网匿名访问监控PV/UV/访问路径/来源/停留等)。

说明:本文件是“实现方案与代码位点设计”,不直接修改业务代码。


2. 需求清单

2.1 功能需求

  1. 编辑器输入升级
  • 支持 纯文本富文本(HTML)Markdown 三种输入模式。
  • 兼容历史 webJsonString 数据,不影响已发布页面渲染。
  • 支持逐组件/逐字段平滑迁移,不要求一次性改完全部 editEl 组件。
  1. 关键词搜索
  • 用户输入关键词后可搜索菜单、页面内容、产品详情、资料下载。
  • 返回结果需标识内容类型、命中位置、跳转路由参数。
  • 支持分页、排序(相关度优先)、高亮摘要。
  1. 官网访问监控(非登录)
  • 采集匿名访问行为:页面访问、停留时长、来源、设备、下载、搜索。
  • 输出基础看板PV、UV、入口页、跳出率、热门页面、热门关键词。
  • 支持反爬/刷量基础治理。

2.2 非功能需求

  1. 安全
  • 富文本输出必须经过 XSS 清洗。
  • 搜索接口必须防注入、限流、防超长关键词。
  • 监控数据脱敏存储IP 哈希、最小化隐私采集)。
  1. 性能
  • 搜索接口 P95 < 300ms初期中小数据量
  • 埋点上报不阻塞页面渲染,采用异步/批量上报。
  1. 可维护性
  • 输入升级采用“统一组件 + 统一协议”模式,避免 21 个模板重复改造。
  • 搜索结果统一 DTO前端只做渲染不做多形态兼容。
  • 监控事件采用统一事件字典,便于扩展。

3. 对现有架构与代码的理解(基于当前仓库结构)

3.1 前端编辑端 portal_website_edit

  1. 关键入口
  • portal_website_edit/src/views/editPage/index.vue
  • portal_website_edit/src/views/productCenter/edit/index.vue
  1. 模板组件
  • portal_website_edit/src/components/editEl/carousel.vueeditEl1.vue ... editEl16.vueclassicCase.vueproductCenter.vue
  • 当前大量使用 contenteditable + blur + 直接写 this.$props.data
  1. 数据模型
  • 保存时统一 JSON.stringify(components) 写入后端 webJsonString
  • 后端持久层主要是 hw_webhw_web1
  1. 请求与鉴权
  • portal_website_edit/src/utils/request.js 统一请求封装
  • Cookie 中 Admin-Token 注入 Bearer

3.2 展示端 portal_website

  1. 渲染模式
  • 基于菜单树 + JSON 区块渲染
  • type = 1..16 映射 components/el/*
  1. 数据来源
  • /portal/hwWebMenu/*
  • /portal/hwWeb/*/portal/hwWeb1/*
  • /portal/hwWebDocument/*

3.3 后端与数据库

  1. 后端
  • Spring Boot + MyBatis多模块 RuoYi 结构
  • ruoyi-portal 负责门户内容接口
  1. 重点表
  • hw_web_menu:菜单树
  • hw_web:通用页面 JSON
  • hw_web1:产品详情 JSON
  • hw_web_menu1:另一套菜单树(当前编辑端未重点使用)
  • hw_web_document:下载文档与密钥
  1. 当前特征
  • 逻辑删除字段 is_delete='0' 才是有效数据
  • hw_web/hw_web1 服务层版本化写入(旧记录逻辑删除 + 插入新记录)
  • 无唯一索引保障业务键唯一(存在并发竞态风险)

4. 总体落地策略

采用“三阶段并行可拆分实施”:

  1. 阶段 A低风险快上线
  • 输入升级先实现统一组件和数据协议,逐组件接入。
  • 搜索先做 SQL LIKE + UNION ALL + 评分 版本。
  • 监控先做基础埋点与日报表。
  1. 阶段 B质量增强
  • 输入渲染加完整 XSS 策略、回填协议迁移工具。
  • 搜索引入索引表(可选)与权重调优。
  • 监控引入实时指标与反刷策略。
  1. 阶段 C规模优化
  • 搜索可迁移到 ES/OpenSearch数据量变大时
  • 监控引入漏斗分析、页面性能指标Web Vitals

5. 方案一:模板文本输入升级为富文本/Markdown

5.1 设计目标

  1. 保持现有 webJsonString 可读可写。
  2. 新增字段协议,不破坏旧字段语义。
  3. 同一字段可识别 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

核心逻辑:

  1. props.value 支持字符串或对象。
  2. 内部 mode 可切换 plain/rich/md
  3. rich 模式用 Quillmd 模式用 Markdown 编辑器(如 Toast UI
  4. 每次输入输出统一对象 { 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
  • 改造规则:
  1. contenteditable 文本块替换为 RichContentInput
  2. this.$props.data.xxx = ... 改为 v-model 数据绑定。
  3. 仅替换文本字段,图片/列表结构保持原样。

优先改造组件:

  1. editEl2.vueeditEl3.vueeditEl8.vueeditEl11.vue(文本密集)
  2. classicCase.vueproductCenter.vue(首页影响面大)

5.3.3 保存与读取适配

  • 位置:portal_website_edit/src/views/editPage/index.vue

逻辑:

  1. 保存前遍历 components,将文本字段标准化为协议对象(或保持旧字符串)。
  2. 读取后做 normalize确保组件拿到统一结构。
  3. 增加 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 安全策略

  1. 前端展示前净化 HTMLDOMPurify
  2. 后端入库前二次净化(可在 Service 层做策略兜底)。
  3. 限制允许标签白名单(p/br/strong/em/ul/li/a/img/table...)。

5.6 使用场景

  1. 市场运营编辑产品详情时输入图文混排说明。
  2. 技术文档编辑参数章节时使用 Markdown 表格。
  3. 首页案例描述保留历史纯文本,不影响旧页面。

6. 方案二:基于现有表结构的关键词搜索

6.1 设计目标

  1. 一次搜索覆盖菜单、页面、产品详情、文档。
  2. 结果可直接跳转到对应页面。
  3. 保持对当前 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 常见分层):

  1. Controller
  • ruoyi-portal/src/main/java/com/ruoyi/portal/controller/HwSearchController.java
  1. Service
  • ruoyi-portal/src/main/java/com/ruoyi/portal/service/IHwSearchService.java
  • ruoyi-portal/src/main/java/com/ruoyi/portal/service/impl/HwSearchServiceImpl.java
  1. Domain/DTO
  • ruoyi-portal/src/main/java/com/ruoyi/portal/domain/dto/SearchResultDTO.java
  1. Mapper
  • ruoyi-portal/src/main/java/com/ruoyi/portal/mapper/HwSearchMapper.java
  • ruoyi-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 搜索结果路由映射逻辑

  1. menu 命中
  • 跳转 portal_website 的菜单路由(如 /test?id={menuId})。
  1. web 命中
  • web_code=-1 -> 首页块位(可转首页并定位)。
  • web_code=7 -> /productCenter
  • 其他 -> /test?id={webCode}(按现有约定)。
  1. web1 命中
  • /productCenter/detail?webCode=...&typeId=...&deviceId=...
  1. document 命中
  • 跳资料页或详情页 el14 区块,并传 documentId

6.6 前端代码位点建议

展示端:

  1. portal_website/src/api/search.js:新增搜索 API 调用。
  2. portal_website/src/views/search/index.vue:新增搜索结果页。
  3. 顶部导航组件(现有 header 组件)增加搜索框与回车触发。

编辑端(可选):

  1. portal_website_edit/src/api/search.js
  2. /editor 顶部增加“全站内容搜索”入口,便于运营定位。

6.7 性能与演进

  1. V1当前
  • 直接扫表 + LIKE
  • 适合中小数据量和快速上线
  1. V2数据增长后
  • 增加 hw_search_index 索引表(预拆 JSON 文本)
  • 定时任务增量更新索引
  1. V3大规模
  • 接入 Elasticsearch/OpenSearch

6.8 使用场景

  1. 用户搜索“工业交换机”,同时命中产品详情、案例页、下载文档。
  2. 用户搜索“防爆”,优先展示行业方案菜单入口。
  3. 运营搜索关键词后直达编辑页修正文案。

7. 方案三官网匿名访问监控PV/UV/停留/来源)

7.1 监控目标

  1. 实时看总访问趋势与来源质量。
  2. 识别热门页面和高价值行为(下载、搜索、咨询)。
  3. 支持营销活动效果归因UTM

7.2 事件模型

推荐事件类型:

  1. page_view
  2. page_leave
  3. search_submit
  4. download_click
  5. contact_submit

公共字段:

  • visitorId(匿名长期 ID
  • sessionId30 分钟无活动重置)
  • eventType
  • path
  • referrer
  • utmSource/utmMedium/utmCampaign
  • ua/device/browser/os
  • ipHash
  • eventTime

7.3 前端采集实现位点portal_website

  1. 新增:
  • portal_website/src/utils/analytics.js
  1. 注册:
  • portal_website/src/main.js 中初始化 SDK
  • portal_website/src/router/index.js 增加 afterEachpage_view
  • 页面卸载时发送 page_leavesendBeacon 优先)

示例:

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

  1. Controller
  • ruoyi-portal/src/main/java/com/ruoyi/portal/controller/HwAnalyticsController.java
  • 采集接口 @AnonymousPOST /portal/analytics/collect
  1. Service/Mapper
  • IHwAnalyticsService + HwAnalyticsServiceImpl
  • HwAnalyticsMapper + HwAnalyticsMapper.xml
  1. 定时汇总Quartz
  • 每 5 分钟汇总实时指标到日统计表
  • 每日 00:10 出日报聚合

7.5 数据库表设计(建议新增)

  1. 明细表:hw_web_visit_event
  • id bigint pk
  • event_type varchar(32)
  • visitor_id varchar(64)
  • session_id varchar(64)
  • path varchar(500)
  • referrer varchar(500)
  • utm_source/utm_medium/utm_campaign varchar(128)
  • ip_hash varchar(128)
  • ua varchar(500)
  • device/browser/os varchar(64)
  • stay_ms bigint null
  • created_at datetime
  • 索引:idx_created_atidx_event_typeidx_visitor_sessionidx_path
  1. 日统计表:hw_web_visit_daily
  • stat_date date pk
  • pv bigint
  • uv bigint
  • ip_uv bigint
  • avg_stay_ms bigint
  • bounce_rate decimal(5,2)
  • search_count bigint
  • download_count bigint
  • created_at/updated_at datetime

7.6 风险控制

  1. 防刷
  • IP + UA + path 短时间阈值限流(可复用 @RateLimiter 思路)
  • 机器人 UA 黑名单
  1. 隐私
  • 不存明文 IP只存 hash
  • 避免采集姓名、手机号等 PII
  1. 性能
  • 上报接口异步入库(可先入 Redis Stream/队列再落库)

7.7 使用场景

  1. 运营查看“今日 PV/UV、来源渠道、热门页面”。
  2. 产品查看“搜索词和下载行为”评估内容价值。
  3. 市场评估广告 UTM 转化效果。

8. 迭代计划(建议)

  1. 第 1 周
  • 完成输入协议、公共编辑组件、2~4 个核心模板接入。
  • 完成搜索接口 V1后端 + 展示端结果页)。
  • 完成基础埋点 page_view/page_leave/search_submit
  1. 第 2 周
  • 扩展模板接入到主要文本组件。
  • 搜索结果高亮、排序优化、路由跳转完善。
  • 埋点加入下载/咨询转化事件与日报接口。
  1. 第 3 周
  • 安全加固XSS 白名单、限流、SQL 压测)。
  • 引入搜索索引表和监控聚合任务(按数据规模决定)。

9. 验收标准

  1. 输入升级
  • 旧页面内容不丢失,显示一致。
  • 新增富文本/Markdown可编辑、可保存、可展示。
  • XSS 测试样例无法执行脚本。
  1. 搜索
  • 关键词可命中四类内容(菜单/页面/详情/文档)。
  • 返回可直接跳转对应页面。
  • 支持分页、相关度排序、摘要高亮。
  1. 监控
  • 能输出日维度 PV/UV/停留/跳出率。
  • 可区分来源渠道并查看热门页面。
  • 采集不显著影响首屏性能。

10. 附:实现时的关键注意点

  1. 保持 webJsonString 契约稳定,避免一次性大范围改字段名。
  2. hw_web/hw_web1 版本化写入下,搜索只查 is_delete='0'
  3. hw_web_menu1 字段命名历史问题(value/valuel)如启用需先统一映射。
  4. 下载文档密钥逻辑不应被搜索结果泄露,搜索仅返回可公开字段。
  5. 所有新接口遵循现有返回约定(AjaxResult / TableDataInfo)。

11. 搜索功能备选方案:基于 ES优先 Easy-Es

本章节作为“方案二MySQL LIKE 搜索)”的升级备选,目标是在数据规模上升后获得更好的全文检索效果、相关度排序和高亮能力。

11.1 技术选型结论(优先级)

  1. 优先Easy-EsJava 侧开发效率更高)
  • 适用前提:团队接受引入 Easy-Es 依赖;检索需求以常规全文检索、过滤、高亮为主。
  • 优点:实体注解 + Wrapper 风格,开发成本低,和现有 MyBatis 项目协作成本较小。
  • 风险:框架抽象层增加,遇到复杂 DSL 场景仍需下沉原生能力。
  1. 备选:原生 Elasticsearch Java Client
  • 适用前提:需要复杂查询/聚合/性能调优,或希望减少第三方封装依赖。
  • 优点:能力最全、控制力最高。
  • 风险:开发样板代码更多。

建议:先按 Easy-Es 落地,保留“复杂查询接口走原生 Client”的扩展点。

11.2 索引模型设计(结合当前 5 张重点表)

统一索引:hw_portal_content_v1,查询别名:hw_portal_content

文档来源:

  1. hw_web_menu -> sourceType=menu
  2. hw_web -> sourceType=web
  3. hw_web1 -> sourceType=web1
  4. hw_web_document -> sourceType=document

文档 ID 设计(避免版本化写入导致 ID 抖动):

  1. 菜单:menu:{web_menu_id}
  2. 页面:web:{web_code}
  3. 详情:web1:{web_code}:{typeId}:{device_id}
  4. 文档: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" }
    }
  }
}

注意:

  1. 若 ES 环境未安装 IK 分词器,可临时改为 standard,但中文效果会下降。
  2. routeQueryflattened 可减少动态字段爆炸风险。

11.4 入索引字段映射规则(按表)

  1. hw_web_menu
  • title = web_menu_name
  • content = web_menu_name + ancestors(可拼接)
  • menuId = web_menu_id
  • route = /test
  • routeQuery = { "id": web_menu_id }
  1. hw_web
  • title = 页面标识(如 web_code) 或从 JSON 提取首个标题字段
  • content = web_json_string(建议清洗 HTML 标签后再入索引)
  • webCode = web_code
  • route 根据 web_code 映射(-1 -> /index, 7 -> /productCenter, 其他 -> /test
  1. hw_web1
  • title = 设备详情标识或 JSON 首标题
  • content = web_json_string(清洗后)
  • webCode/typeId/deviceId 对应业务键
  • route = /productCenter/detail
  • routeQuery = { webCode, typeId, deviceId }
  1. hw_web_document
  • title = file_name(若表中无该列则退化为 document_id
  • content = document_address(仅用于检索,不返回敏感内容)
  • documentId = document_id
  • route 指向资料页或对应详情页

11.5 同步流程设计(全量 + 增量 + 补偿)

11.5.1 全量初始化

  1. 启动任务或管理接口触发:
  • 扫描四类数据表(is_delete='0'
  • 按 500~1000 条批量写入 ESBulk
  • 完成后切换 alias 到新索引(可选蓝绿)
  1. 建议代码位置
  • ruoyi-portal/.../service/search/HwSearchRebuildService.java
  • ruoyi-portal/.../controller/HwSearchAdminController.java(内部管理接口)

11.5.2 增量同步(主路径)

触发点(在现有写接口后):

  1. 菜单变更:/portal/hwWebMenu/*
  2. 页面变更:/portal/hwWeb/updateHwWeb
  3. 详情变更:/portal/hwWeb1/updateHwWeb1
  4. 文档变更:/portal/hwWebDocument/*

实现建议:

  1. 在 Service 成功提交后发送“同步事件”(推荐事务后回调)。
  2. 同步消费者将业务对象转换为索引文档并 upsert
  3. 删除操作写成 delete by idisDelete=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

流程:

  1. 业务写库与 outbox 同事务提交。
  2. 定时任务扫描 status=pending/failed 进行重试。
  3. 超过阈值告警,人工或脚本修复。

该机制可避免“数据库成功、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 内):

  1. .../search/es/entity/PortalSearchDoc.java
  2. .../search/es/mapper/PortalSearchMapper.java
  3. .../search/service/PortalSearchEsService.java
  4. .../search/service/impl/PortalSearchEsServiceImpl.java
  5. .../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_matchfunction_score 等场景若 Easy-Es 封装不够直观,可在同服务中直接使用原生 ES Client 补充。

11.8 与当前 MySQL 方案的切换策略

  1. 接口层保持不变:继续使用 /portal/search
  2. 配置开关:
  • search.engine=mysql|es(默认 mysql
  • search.es.enabled=true/false
  1. 灰度:
  • 小流量用户先走 ES比对命中率与响应时间。
  • 指标达标后全量切换。
  1. 回滚:
  • 保留 MySQL 搜索实现ES 故障时秒级切回。

11.9 适用场景与收益

  1. 中文关键词检索命中明显提升(分词 + 相关度)。
  2. 多来源内容统一搜索体验(菜单/页面/详情/文档)。
  3. 结果高亮与权重排序更符合官网用户行为。

11.10 风险与前置条件

  1. 前置条件
  • 可用 ES 集群(开发/测试/生产)
  • 分词器插件(推荐 IK
  • 运维监控(磁盘、分片、慢查询)
  1. 风险
  • 数据一致性:需 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+ 处),字段可分为三类:

  1. 短标题字段(不需要富文本)

    • titlesubTitleitemTitleleftTitlebannerTitlebannerValue
    • 特点:一般 ≤50 字,纯文本即可,富文本反而增加复杂度
    • 涉及组件:几乎所有 editEl1~editEl16carousel
  2. 中长内容字段(适合富文本/Markdown

    • contentInfoeditEl2infoeditEl6valueeditEl4/el9/el11/carouselitemInfoeditEl3leftInfoeditEl8
    • 特点:可能 50~500+ 字,含段落、换行、强调等需求
    • 优先改造范围
  3. 结构化字段(不需要改造)

    • editEl12imgList/featureseditEl13params 表格)、editEl14fileList 文件)、editEl15banner 双图文,字段短)、editEl16params 多列表格)
    • 这些组件的文本是表格单元格或列表项,不适合富文本

结论: 原方案 5.3.2 "把 contenteditable 文本块替换为 RichContentInput" 应改为 "仅对中长内容字段替换",短标题字段保留纯文本(可用 el-input 替代 contenteditable,但不引入富文本)。

12.1.2 Quill 2.0 + Vue 2 兼容性问题(关键风险)

当前 package.json 已有 quill@2.0.3,但:

  1. vue-quill-editor(社区最常用的 Vue 2 Quill 封装)仅支持 Quill 1.x不兼容 Quill 2.0
  2. @vueup/vue-quill 仅支持 Vue 3。
  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 5Vue 2 插件) 功能更全、官方有 Vue 2 集成 体积更大、需额外学习
D. 使用 wangEditor 5国产Vue 2 支持) 中文生态好、文档全、体积适中 社区相对 Quill/CKEditor 小

建议:若团队无特别偏好,方案 DwangEditor 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 需要:

  1. 后端在 WebMvcConfigurer 或 Spring Security 中对 /portal/analytics/** 放行 CORS。
  2. 该接口标记 @Anonymous(无需登录)。
  3. sendBeacon 发送的 Content-Typetext/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 中提取纯文本。两种方案:

方案 AMySQL 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。
  • ngram token_size 默认 2对中文效果比 LIKE 好很多(可匹配"物联"、"轮胎"等双字词)。
  • hw_web/hw_web1 保存成功后Service 层事务后回调),触发索引更新。
  • 搜索 SQLSELECT ... 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

ElasticsearchES是基于 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.23.5.x 需验证
Elasticsearch 未引入 Easy-Es 2.x 支持 ES 7.14~8.x

风险点

  1. Easy-Es 2.0.0 内部依赖 elasticsearch-rest-high-level-clientES 官方已在 8.x 标记 deprecated与 Spring Boot 3.5.8 的自动配置可能冲突。需要 排除 Spring Data Elasticsearch 自动配置 或锁定版本。
  2. 若 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 字段但未说明如何生成。补充:

  1. MySQL 方案:在 Service 层做关键词上下文截取(关键词前后各取 50~80 字符)。
  2. ES 方案:直接用 highlight 返回的 fragment。
  3. 通用规则
    • 优先返回 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 版本兼容性风险、摘要生成

修订后的推荐实施优先级

  1. 最快见效:搜索 V1hw_search_index + FULLTEXT ngram + Umami 自托管 → 1~2 天可上线基础能力。
  2. 第二优先editEl 组件将 contenteditable 改为 el-input(阶段 0→ 消除最大输入风险。
  3. 第三优先:对 contentInfo/value/info 等长文本字段引入 wangEditor → 富文本能力。
  4. 按需引入ES + Easy-Es数据量增长后、自建埋点Umami 不满足时)。