萌萌技术分析笔记
仓库地址 : https://github.com/pluchon/mengmeng_forum_demo
文章目录
- 萌萌技术分析笔记
-
- 说明
- 部分界面演示
- 项目概览
- 整体架构
- 功能一览
- 核心流程
-
- 1) 行为验证码 + 一次性票据(防刷) 行为验证码 + 一次性票据(防刷))
- 2) 积分抽奖防超卖(扣积分 + 扣库存都要稳) 积分抽奖防超卖(扣积分 + 扣库存都要稳))
- 3) 发帖审核工作流(异步 + 幂等) 发帖审核工作流(异步 + 幂等))
- 4) 私信跨实例推送(Redis 广播) 私信跨实例推送(Redis 广播))
- 5) 热帖榜(ZSet) 热帖榜(ZSet))
- 6) 智能搜索(先快搜,后增强) 智能搜索(先快搜,后增强))
- 7) 图片压缩 + AI 审核 + OSS 上传(同一条流水线) 图片压缩 + AI 审核 + OSS 上传(同一条流水线))
- 本地开发(可选)
- [生产部署(Docker Compose)](#生产部署(Docker Compose))
- 配置说明(环境变量)
- 常见问题
-
- 1) 刚启动访问 502 / WebSocket 失败 刚启动访问 502 / WebSocket 失败)
- 2) 白屏 / `xxx is not a function` / `Ye is not a function` 白屏 /
xxx is not a function/Ye is not a function) - 3) Navicat 连不上 MySQL Navicat 连不上 MySQL)
- 4) 管理端登录提示"需要管理员权限" 管理端登录提示“需要管理员权限”)
- 仓库结构
-
- 3) Navicat 连不上 MySQL Navicat 连不上 MySQL)
- 4) 管理端登录提示"需要管理员权限" 管理端登录提示“需要管理员权限”)
- 仓库结构
说明
部分界面演示








线上真实地址:
- 用户端:
https://www.nuonuoya.cn - 管理端:
https://admin.nuonuoya.cn
这是一个前后端分离技术社区项目:发帖、评论、私信、抽奖、搜索都具备;帖子和图片在发布前会经过 AI 审核;多实例部署时私信支持跨实例实时推送。
项目概览
这个仓库包含 4 个主要部分:
forum-demo:Java 后端(Spring Boot),提供业务 API、鉴权、消息消费、WebSocket、审核状态流转等ai-server:Python 服务,负责 AI 审核 / AI 写作 / 看板娘聊天 / 智能搜索(语义排序、RAG 相关能力)forum-vue:用户端前端(Vue 3)forum-vue-admin:管理端前端(Vue 3 + Arco)nginx:打包与部署(Nginx 配置、compose、脚本)
整体架构
系统的主链路大致是这样:
- 用户端 / 管理端 → Nginx(静态资源 + 反向代理)
- Nginx → Java 后端(业务 API、WebSocket)
- Java 后端 → MySQL(主业务数据)
- Java 后端 → Redis(缓存、排行榜、发布订阅)
- Java 后端 → RabbitMQ(发帖审核、异步任务解耦)
- Java 后端 → Python AI 服务(审核、写作、搜索、聊天)
- Python AI 服务 → PostgreSQL(LangGraph 会话/状态持久化)
#mermaid-svg-J1sViT2fPAYBW83F{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-J1sViT2fPAYBW83F .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-J1sViT2fPAYBW83F .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-J1sViT2fPAYBW83F .error-icon{fill:#552222;}#mermaid-svg-J1sViT2fPAYBW83F .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-J1sViT2fPAYBW83F .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-J1sViT2fPAYBW83F .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-J1sViT2fPAYBW83F .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-J1sViT2fPAYBW83F .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-J1sViT2fPAYBW83F .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-J1sViT2fPAYBW83F .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-J1sViT2fPAYBW83F .marker{fill:#333333;stroke:#333333;}#mermaid-svg-J1sViT2fPAYBW83F .marker.cross{stroke:#333333;}#mermaid-svg-J1sViT2fPAYBW83F svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-J1sViT2fPAYBW83F p{margin:0;}#mermaid-svg-J1sViT2fPAYBW83F .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-J1sViT2fPAYBW83F .cluster-label text{fill:#333;}#mermaid-svg-J1sViT2fPAYBW83F .cluster-label span{color:#333;}#mermaid-svg-J1sViT2fPAYBW83F .cluster-label span p{background-color:transparent;}#mermaid-svg-J1sViT2fPAYBW83F .label text,#mermaid-svg-J1sViT2fPAYBW83F span{fill:#333;color:#333;}#mermaid-svg-J1sViT2fPAYBW83F .node rect,#mermaid-svg-J1sViT2fPAYBW83F .node circle,#mermaid-svg-J1sViT2fPAYBW83F .node ellipse,#mermaid-svg-J1sViT2fPAYBW83F .node polygon,#mermaid-svg-J1sViT2fPAYBW83F .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-J1sViT2fPAYBW83F .rough-node .label text,#mermaid-svg-J1sViT2fPAYBW83F .node .label text,#mermaid-svg-J1sViT2fPAYBW83F .image-shape .label,#mermaid-svg-J1sViT2fPAYBW83F .icon-shape .label{text-anchor:middle;}#mermaid-svg-J1sViT2fPAYBW83F .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-J1sViT2fPAYBW83F .rough-node .label,#mermaid-svg-J1sViT2fPAYBW83F .node .label,#mermaid-svg-J1sViT2fPAYBW83F .image-shape .label,#mermaid-svg-J1sViT2fPAYBW83F .icon-shape .label{text-align:center;}#mermaid-svg-J1sViT2fPAYBW83F .node.clickable{cursor:pointer;}#mermaid-svg-J1sViT2fPAYBW83F .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-J1sViT2fPAYBW83F .arrowheadPath{fill:#333333;}#mermaid-svg-J1sViT2fPAYBW83F .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-J1sViT2fPAYBW83F .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-J1sViT2fPAYBW83F .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-J1sViT2fPAYBW83F .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-J1sViT2fPAYBW83F .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-J1sViT2fPAYBW83F .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-J1sViT2fPAYBW83F .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-J1sViT2fPAYBW83F .cluster text{fill:#333;}#mermaid-svg-J1sViT2fPAYBW83F .cluster span{color:#333;}#mermaid-svg-J1sViT2fPAYBW83F div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-J1sViT2fPAYBW83F .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-J1sViT2fPAYBW83F rect.text{fill:none;stroke-width:0;}#mermaid-svg-J1sViT2fPAYBW83F .icon-shape,#mermaid-svg-J1sViT2fPAYBW83F .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-J1sViT2fPAYBW83F .icon-shape p,#mermaid-svg-J1sViT2fPAYBW83F .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-J1sViT2fPAYBW83F .icon-shape .label rect,#mermaid-svg-J1sViT2fPAYBW83F .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-J1sViT2fPAYBW83F .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-J1sViT2fPAYBW83F .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-J1sViT2fPAYBW83F :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户端 Web
Nginx
管理端 Web
Java 后端 forum-demo
MySQL
Redis
RabbitMQ
Python AI 服务 ai-server
PostgreSQL
功能一览
用户端
- 发帖 / 评论 / 楼中楼:支持富文本 / Markdown(按项目实现)
- 发帖审核:提交后先进入"审核中",通过才发布,失败会给出原因
- 图片上传:服务端压缩 + AI 图片审核,通过后才上传 OSS
- 私信聊天:WebSocket 实时消息,支持跨实例推送
- 积分体系:签到、商城、抽奖等带来积分增减(以你的业务为准)
- 抽奖活动:扣积分、扣库存、防超卖,支持软/硬保底
- 热帖榜:实时加分的排行榜
- 智能搜索:先走数据库快搜,必要时再走 AI 语义增强
管理端
- 内容管理:帖子、评论、公告、抽奖活动/奖品等(以你的页面为准)
- 系统管理:字典、菜单、部门、角色(RBAC 表结构已预置)
核心流程
1) 行为验证码 + 一次性票据(防刷)
目的很简单:短信/邮件是要花钱的,注册/找回密码也不能让脚本随便刷。
做法分两层:
- 先过行为验证码:通过后签发一张"一次性票据"
- 再做频率限制:按手机号/邮箱做冷却与窗口限额
一次性票据的核心是两点:
- 票据写入 Redis,短过期
- 使用时校验通过后立刻删除,同一张票用一次就失效
Redis Java后端 前端 Redis Java后端 前端 #mermaid-svg-yMI11HnCOdpJ6BtV{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-yMI11HnCOdpJ6BtV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-yMI11HnCOdpJ6BtV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-yMI11HnCOdpJ6BtV .error-icon{fill:#552222;}#mermaid-svg-yMI11HnCOdpJ6BtV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-yMI11HnCOdpJ6BtV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-yMI11HnCOdpJ6BtV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-yMI11HnCOdpJ6BtV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-yMI11HnCOdpJ6BtV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-yMI11HnCOdpJ6BtV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-yMI11HnCOdpJ6BtV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-yMI11HnCOdpJ6BtV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-yMI11HnCOdpJ6BtV .marker.cross{stroke:#333333;}#mermaid-svg-yMI11HnCOdpJ6BtV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-yMI11HnCOdpJ6BtV p{margin:0;}#mermaid-svg-yMI11HnCOdpJ6BtV .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-yMI11HnCOdpJ6BtV text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-yMI11HnCOdpJ6BtV .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-yMI11HnCOdpJ6BtV .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-yMI11HnCOdpJ6BtV .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-yMI11HnCOdpJ6BtV .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-yMI11HnCOdpJ6BtV #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-yMI11HnCOdpJ6BtV .sequenceNumber{fill:white;}#mermaid-svg-yMI11HnCOdpJ6BtV #sequencenumber{fill:#333;}#mermaid-svg-yMI11HnCOdpJ6BtV #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-yMI11HnCOdpJ6BtV .messageText{fill:#333;stroke:none;}#mermaid-svg-yMI11HnCOdpJ6BtV .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-yMI11HnCOdpJ6BtV .labelText,#mermaid-svg-yMI11HnCOdpJ6BtV .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-yMI11HnCOdpJ6BtV .loopText,#mermaid-svg-yMI11HnCOdpJ6BtV .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-yMI11HnCOdpJ6BtV .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-yMI11HnCOdpJ6BtV .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-yMI11HnCOdpJ6BtV .noteText,#mermaid-svg-yMI11HnCOdpJ6BtV .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-yMI11HnCOdpJ6BtV .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-yMI11HnCOdpJ6BtV .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-yMI11HnCOdpJ6BtV .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-yMI11HnCOdpJ6BtV .actorPopupMenu{position:absolute;}#mermaid-svg-yMI11HnCOdpJ6BtV .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-yMI11HnCOdpJ6BtV .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-yMI11HnCOdpJ6BtV .actor-man circle,#mermaid-svg-yMI11HnCOdpJ6BtV line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-yMI11HnCOdpJ6BtV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 票据有效 票据无效/已用 1) 提交滑块验证结果 写入 ticket(UUID, TTL=2min, purpose) 返回 ticket 2) 携带 ticket 调注册/发码 校验 ticket + 删除(用一次就失效) 继续业务(发码/注册) 拒绝
2) 积分抽奖防超卖(扣积分 + 扣库存都要稳)
抽奖里最怕两件事:
- 积分扣成负数
- 限量奖品发超了
这里的处理思路是:关键扣减都交给数据库做"带条件的更新",让它天然原子。
- 扣积分:
points >= cost才能扣,影响行数为 0 就直接失败 - 扣库存:
stock > 0才能扣,失败就换个奖品重新抽(有限次数) - 同一用户并发抽奖:在事务内锁定用户行,避免同时扣两次
硬保底 / 软保底:
- 硬保底:连续 N 次没中头奖,下一次直接走保底池(保证命中)
- 软保底:十连在最后一抽做兜底(保证至少出一个稀有)
#mermaid-svg-Bn1VaG46IfYVC2D0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Bn1VaG46IfYVC2D0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Bn1VaG46IfYVC2D0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Bn1VaG46IfYVC2D0 .error-icon{fill:#552222;}#mermaid-svg-Bn1VaG46IfYVC2D0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Bn1VaG46IfYVC2D0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Bn1VaG46IfYVC2D0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Bn1VaG46IfYVC2D0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Bn1VaG46IfYVC2D0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Bn1VaG46IfYVC2D0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Bn1VaG46IfYVC2D0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Bn1VaG46IfYVC2D0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Bn1VaG46IfYVC2D0 .marker.cross{stroke:#333333;}#mermaid-svg-Bn1VaG46IfYVC2D0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Bn1VaG46IfYVC2D0 p{margin:0;}#mermaid-svg-Bn1VaG46IfYVC2D0 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Bn1VaG46IfYVC2D0 .cluster-label text{fill:#333;}#mermaid-svg-Bn1VaG46IfYVC2D0 .cluster-label span{color:#333;}#mermaid-svg-Bn1VaG46IfYVC2D0 .cluster-label span p{background-color:transparent;}#mermaid-svg-Bn1VaG46IfYVC2D0 .label text,#mermaid-svg-Bn1VaG46IfYVC2D0 span{fill:#333;color:#333;}#mermaid-svg-Bn1VaG46IfYVC2D0 .node rect,#mermaid-svg-Bn1VaG46IfYVC2D0 .node circle,#mermaid-svg-Bn1VaG46IfYVC2D0 .node ellipse,#mermaid-svg-Bn1VaG46IfYVC2D0 .node polygon,#mermaid-svg-Bn1VaG46IfYVC2D0 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Bn1VaG46IfYVC2D0 .rough-node .label text,#mermaid-svg-Bn1VaG46IfYVC2D0 .node .label text,#mermaid-svg-Bn1VaG46IfYVC2D0 .image-shape .label,#mermaid-svg-Bn1VaG46IfYVC2D0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-Bn1VaG46IfYVC2D0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Bn1VaG46IfYVC2D0 .rough-node .label,#mermaid-svg-Bn1VaG46IfYVC2D0 .node .label,#mermaid-svg-Bn1VaG46IfYVC2D0 .image-shape .label,#mermaid-svg-Bn1VaG46IfYVC2D0 .icon-shape .label{text-align:center;}#mermaid-svg-Bn1VaG46IfYVC2D0 .node.clickable{cursor:pointer;}#mermaid-svg-Bn1VaG46IfYVC2D0 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Bn1VaG46IfYVC2D0 .arrowheadPath{fill:#333333;}#mermaid-svg-Bn1VaG46IfYVC2D0 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Bn1VaG46IfYVC2D0 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Bn1VaG46IfYVC2D0 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Bn1VaG46IfYVC2D0 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Bn1VaG46IfYVC2D0 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Bn1VaG46IfYVC2D0 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Bn1VaG46IfYVC2D0 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Bn1VaG46IfYVC2D0 .cluster text{fill:#333;}#mermaid-svg-Bn1VaG46IfYVC2D0 .cluster span{color:#333;}#mermaid-svg-Bn1VaG46IfYVC2D0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Bn1VaG46IfYVC2D0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Bn1VaG46IfYVC2D0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-Bn1VaG46IfYVC2D0 .icon-shape,#mermaid-svg-Bn1VaG46IfYVC2D0 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Bn1VaG46IfYVC2D0 .icon-shape p,#mermaid-svg-Bn1VaG46IfYVC2D0 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Bn1VaG46IfYVC2D0 .icon-shape .label rect,#mermaid-svg-Bn1VaG46IfYVC2D0 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Bn1VaG46IfYVC2D0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Bn1VaG46IfYVC2D0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Bn1VaG46IfYVC2D0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 影响行数=0
扣成功
失败
成功
开始抽奖
事务内锁用户行: SELECT FOR UPDATE
扣积分: UPDATE where points>=cost
余额不足 结束
单抽/十连
按权重抽奖品
扣库存: UPDATE where stock>0
换奖品重试
写中奖记录/发放积分或VIP
提交事务 结束
3) 发帖审核工作流(异步 + 幂等)
AI 审核慢,不能让用户提交发帖接口卡住。
所以发帖审核采用"异步工作流":
- 用户提交后,Java 先把帖子状态改为"审核中",生成任务 ID
- Java 把任务投递到 MQ,接口立刻返回(用户体验不卡)
- Python worker 消费消息,跑审核流程(文字→图片→摘要)
- 审核结果再回到 MQ,Java 消费后更新帖子状态、通知用户
关键点:
- 不怕重复回调:用"状态 + 任务 ID"做条件更新,结果只会成功一次
- 不怕短暂失败:消费者手动 ACK、失败重试;超时可以做补偿扫描
Python审核(ai-server) RabbitMQ Java后端 前端 Python审核(ai-server) RabbitMQ Java后端 前端 #mermaid-svg-SQJXUzHSvsa2Kq6c{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SQJXUzHSvsa2Kq6c .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SQJXUzHSvsa2Kq6c .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SQJXUzHSvsa2Kq6c .error-icon{fill:#552222;}#mermaid-svg-SQJXUzHSvsa2Kq6c .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SQJXUzHSvsa2Kq6c .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SQJXUzHSvsa2Kq6c .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SQJXUzHSvsa2Kq6c .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SQJXUzHSvsa2Kq6c .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SQJXUzHSvsa2Kq6c .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SQJXUzHSvsa2Kq6c .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SQJXUzHSvsa2Kq6c .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SQJXUzHSvsa2Kq6c .marker.cross{stroke:#333333;}#mermaid-svg-SQJXUzHSvsa2Kq6c svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SQJXUzHSvsa2Kq6c p{margin:0;}#mermaid-svg-SQJXUzHSvsa2Kq6c .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SQJXUzHSvsa2Kq6c text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-SQJXUzHSvsa2Kq6c .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-SQJXUzHSvsa2Kq6c .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-SQJXUzHSvsa2Kq6c .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-SQJXUzHSvsa2Kq6c .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-SQJXUzHSvsa2Kq6c #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-SQJXUzHSvsa2Kq6c .sequenceNumber{fill:white;}#mermaid-svg-SQJXUzHSvsa2Kq6c #sequencenumber{fill:#333;}#mermaid-svg-SQJXUzHSvsa2Kq6c #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-SQJXUzHSvsa2Kq6c .messageText{fill:#333;stroke:none;}#mermaid-svg-SQJXUzHSvsa2Kq6c .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SQJXUzHSvsa2Kq6c .labelText,#mermaid-svg-SQJXUzHSvsa2Kq6c .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-SQJXUzHSvsa2Kq6c .loopText,#mermaid-svg-SQJXUzHSvsa2Kq6c .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-SQJXUzHSvsa2Kq6c .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-SQJXUzHSvsa2Kq6c .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-SQJXUzHSvsa2Kq6c .noteText,#mermaid-svg-SQJXUzHSvsa2Kq6c .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-SQJXUzHSvsa2Kq6c .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SQJXUzHSvsa2Kq6c .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SQJXUzHSvsa2Kq6c .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SQJXUzHSvsa2Kq6c .actorPopupMenu{position:absolute;}#mermaid-svg-SQJXUzHSvsa2Kq6c .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-SQJXUzHSvsa2Kq6c .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SQJXUzHSvsa2Kq6c .actor-man circle,#mermaid-svg-SQJXUzHSvsa2Kq6c line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-SQJXUzHSvsa2Kq6c :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 提交帖子 状态=审核中 + 生成taskId 投递审核任务(taskId,内容) 立刻返回(不等待AI) worker消费任务 审核文字/图片/生成摘要 回传审核结果(taskId,结论) Java消费结果 条件更新(状态=审核中 && taskId匹配) 通知/刷新后看到发布或驳回
4) 私信跨实例推送(Redis 广播)
单机推送很简单:用户 A 发给 B,服务端拿到 B 的 WebSocket 连接就推。
但多实例部署时,B 的连接在哪台机器上是不确定的。解决办法是:
- 写库后发布一条"推送事件"到 Redis(广播)
- 所有实例都能收到
- 真正持有 B 连接的那台负责推送,其它实例忽略
这样做的好处是:无论用户连到哪台机器,都能实时收到消息。
#mermaid-svg-YkldtF22rfpeaFlI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-YkldtF22rfpeaFlI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YkldtF22rfpeaFlI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YkldtF22rfpeaFlI .error-icon{fill:#552222;}#mermaid-svg-YkldtF22rfpeaFlI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YkldtF22rfpeaFlI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YkldtF22rfpeaFlI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YkldtF22rfpeaFlI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YkldtF22rfpeaFlI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YkldtF22rfpeaFlI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YkldtF22rfpeaFlI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YkldtF22rfpeaFlI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YkldtF22rfpeaFlI .marker.cross{stroke:#333333;}#mermaid-svg-YkldtF22rfpeaFlI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YkldtF22rfpeaFlI p{margin:0;}#mermaid-svg-YkldtF22rfpeaFlI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-YkldtF22rfpeaFlI .cluster-label text{fill:#333;}#mermaid-svg-YkldtF22rfpeaFlI .cluster-label span{color:#333;}#mermaid-svg-YkldtF22rfpeaFlI .cluster-label span p{background-color:transparent;}#mermaid-svg-YkldtF22rfpeaFlI .label text,#mermaid-svg-YkldtF22rfpeaFlI span{fill:#333;color:#333;}#mermaid-svg-YkldtF22rfpeaFlI .node rect,#mermaid-svg-YkldtF22rfpeaFlI .node circle,#mermaid-svg-YkldtF22rfpeaFlI .node ellipse,#mermaid-svg-YkldtF22rfpeaFlI .node polygon,#mermaid-svg-YkldtF22rfpeaFlI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-YkldtF22rfpeaFlI .rough-node .label text,#mermaid-svg-YkldtF22rfpeaFlI .node .label text,#mermaid-svg-YkldtF22rfpeaFlI .image-shape .label,#mermaid-svg-YkldtF22rfpeaFlI .icon-shape .label{text-anchor:middle;}#mermaid-svg-YkldtF22rfpeaFlI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-YkldtF22rfpeaFlI .rough-node .label,#mermaid-svg-YkldtF22rfpeaFlI .node .label,#mermaid-svg-YkldtF22rfpeaFlI .image-shape .label,#mermaid-svg-YkldtF22rfpeaFlI .icon-shape .label{text-align:center;}#mermaid-svg-YkldtF22rfpeaFlI .node.clickable{cursor:pointer;}#mermaid-svg-YkldtF22rfpeaFlI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-YkldtF22rfpeaFlI .arrowheadPath{fill:#333333;}#mermaid-svg-YkldtF22rfpeaFlI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-YkldtF22rfpeaFlI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-YkldtF22rfpeaFlI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YkldtF22rfpeaFlI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-YkldtF22rfpeaFlI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YkldtF22rfpeaFlI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-YkldtF22rfpeaFlI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-YkldtF22rfpeaFlI .cluster text{fill:#333;}#mermaid-svg-YkldtF22rfpeaFlI .cluster span{color:#333;}#mermaid-svg-YkldtF22rfpeaFlI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-YkldtF22rfpeaFlI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-YkldtF22rfpeaFlI rect.text{fill:none;stroke-width:0;}#mermaid-svg-YkldtF22rfpeaFlI .icon-shape,#mermaid-svg-YkldtF22rfpeaFlI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YkldtF22rfpeaFlI .icon-shape p,#mermaid-svg-YkldtF22rfpeaFlI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-YkldtF22rfpeaFlI .icon-shape .label rect,#mermaid-svg-YkldtF22rfpeaFlI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YkldtF22rfpeaFlI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-YkldtF22rfpeaFlI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-YkldtF22rfpeaFlI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户A
Java实例1
用户B
Java实例2: 持有B的WS连接
MySQL 写消息
Redis 广播: PubSub
WebSocket 推送给B
5) 热帖榜(ZSet)
热帖榜用 Redis 的 ZSet 存:
- member:帖子 ID
- score:热度分
点赞/浏览/回复/收藏等行为发生时,直接 ZINCRBY 加分。
删帖/驳回/下线时从榜单移除(并且可以定时全量重算做兜底)。
#mermaid-svg-OGkmiTwAia6G6tFF{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OGkmiTwAia6G6tFF .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OGkmiTwAia6G6tFF .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OGkmiTwAia6G6tFF .error-icon{fill:#552222;}#mermaid-svg-OGkmiTwAia6G6tFF .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OGkmiTwAia6G6tFF .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OGkmiTwAia6G6tFF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OGkmiTwAia6G6tFF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OGkmiTwAia6G6tFF .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OGkmiTwAia6G6tFF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OGkmiTwAia6G6tFF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OGkmiTwAia6G6tFF .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OGkmiTwAia6G6tFF .marker.cross{stroke:#333333;}#mermaid-svg-OGkmiTwAia6G6tFF svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OGkmiTwAia6G6tFF p{margin:0;}#mermaid-svg-OGkmiTwAia6G6tFF .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OGkmiTwAia6G6tFF .cluster-label text{fill:#333;}#mermaid-svg-OGkmiTwAia6G6tFF .cluster-label span{color:#333;}#mermaid-svg-OGkmiTwAia6G6tFF .cluster-label span p{background-color:transparent;}#mermaid-svg-OGkmiTwAia6G6tFF .label text,#mermaid-svg-OGkmiTwAia6G6tFF span{fill:#333;color:#333;}#mermaid-svg-OGkmiTwAia6G6tFF .node rect,#mermaid-svg-OGkmiTwAia6G6tFF .node circle,#mermaid-svg-OGkmiTwAia6G6tFF .node ellipse,#mermaid-svg-OGkmiTwAia6G6tFF .node polygon,#mermaid-svg-OGkmiTwAia6G6tFF .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OGkmiTwAia6G6tFF .rough-node .label text,#mermaid-svg-OGkmiTwAia6G6tFF .node .label text,#mermaid-svg-OGkmiTwAia6G6tFF .image-shape .label,#mermaid-svg-OGkmiTwAia6G6tFF .icon-shape .label{text-anchor:middle;}#mermaid-svg-OGkmiTwAia6G6tFF .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OGkmiTwAia6G6tFF .rough-node .label,#mermaid-svg-OGkmiTwAia6G6tFF .node .label,#mermaid-svg-OGkmiTwAia6G6tFF .image-shape .label,#mermaid-svg-OGkmiTwAia6G6tFF .icon-shape .label{text-align:center;}#mermaid-svg-OGkmiTwAia6G6tFF .node.clickable{cursor:pointer;}#mermaid-svg-OGkmiTwAia6G6tFF .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OGkmiTwAia6G6tFF .arrowheadPath{fill:#333333;}#mermaid-svg-OGkmiTwAia6G6tFF .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OGkmiTwAia6G6tFF .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OGkmiTwAia6G6tFF .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OGkmiTwAia6G6tFF .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OGkmiTwAia6G6tFF .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OGkmiTwAia6G6tFF .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OGkmiTwAia6G6tFF .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OGkmiTwAia6G6tFF .cluster text{fill:#333;}#mermaid-svg-OGkmiTwAia6G6tFF .cluster span{color:#333;}#mermaid-svg-OGkmiTwAia6G6tFF div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OGkmiTwAia6G6tFF .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OGkmiTwAia6G6tFF rect.text{fill:none;stroke-width:0;}#mermaid-svg-OGkmiTwAia6G6tFF .icon-shape,#mermaid-svg-OGkmiTwAia6G6tFF .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OGkmiTwAia6G6tFF .icon-shape p,#mermaid-svg-OGkmiTwAia6G6tFF .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OGkmiTwAia6G6tFF .icon-shape .label rect,#mermaid-svg-OGkmiTwAia6G6tFF .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OGkmiTwAia6G6tFF .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OGkmiTwAia6G6tFF .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OGkmiTwAia6G6tFF :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户行为: 浏览 点赞 回复 收藏
Redis ZSet: hot_rank
ZINCRBY 加分
删帖/驳回
ZREM 移除
定时任务兜底
从DB重算并写回ZSet
6) 智能搜索(先快搜,后增强)
搜索不是上来就 AI:
- 先走数据库:标题模糊匹配,快、稳定、成本低
- 必要时走 AI:当结果太少/不准确,才用语义增强做排序或召回
一句话解释:普通搜索用"快的",找不到再用"聪明的"。
#mermaid-svg-jK9Utrad9squrKm4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jK9Utrad9squrKm4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jK9Utrad9squrKm4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jK9Utrad9squrKm4 .error-icon{fill:#552222;}#mermaid-svg-jK9Utrad9squrKm4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jK9Utrad9squrKm4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jK9Utrad9squrKm4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jK9Utrad9squrKm4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jK9Utrad9squrKm4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jK9Utrad9squrKm4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jK9Utrad9squrKm4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jK9Utrad9squrKm4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jK9Utrad9squrKm4 .marker.cross{stroke:#333333;}#mermaid-svg-jK9Utrad9squrKm4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jK9Utrad9squrKm4 p{margin:0;}#mermaid-svg-jK9Utrad9squrKm4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-jK9Utrad9squrKm4 .cluster-label text{fill:#333;}#mermaid-svg-jK9Utrad9squrKm4 .cluster-label span{color:#333;}#mermaid-svg-jK9Utrad9squrKm4 .cluster-label span p{background-color:transparent;}#mermaid-svg-jK9Utrad9squrKm4 .label text,#mermaid-svg-jK9Utrad9squrKm4 span{fill:#333;color:#333;}#mermaid-svg-jK9Utrad9squrKm4 .node rect,#mermaid-svg-jK9Utrad9squrKm4 .node circle,#mermaid-svg-jK9Utrad9squrKm4 .node ellipse,#mermaid-svg-jK9Utrad9squrKm4 .node polygon,#mermaid-svg-jK9Utrad9squrKm4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-jK9Utrad9squrKm4 .rough-node .label text,#mermaid-svg-jK9Utrad9squrKm4 .node .label text,#mermaid-svg-jK9Utrad9squrKm4 .image-shape .label,#mermaid-svg-jK9Utrad9squrKm4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-jK9Utrad9squrKm4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-jK9Utrad9squrKm4 .rough-node .label,#mermaid-svg-jK9Utrad9squrKm4 .node .label,#mermaid-svg-jK9Utrad9squrKm4 .image-shape .label,#mermaid-svg-jK9Utrad9squrKm4 .icon-shape .label{text-align:center;}#mermaid-svg-jK9Utrad9squrKm4 .node.clickable{cursor:pointer;}#mermaid-svg-jK9Utrad9squrKm4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-jK9Utrad9squrKm4 .arrowheadPath{fill:#333333;}#mermaid-svg-jK9Utrad9squrKm4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-jK9Utrad9squrKm4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-jK9Utrad9squrKm4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jK9Utrad9squrKm4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-jK9Utrad9squrKm4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jK9Utrad9squrKm4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-jK9Utrad9squrKm4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-jK9Utrad9squrKm4 .cluster text{fill:#333;}#mermaid-svg-jK9Utrad9squrKm4 .cluster span{color:#333;}#mermaid-svg-jK9Utrad9squrKm4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-jK9Utrad9squrKm4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-jK9Utrad9squrKm4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-jK9Utrad9squrKm4 .icon-shape,#mermaid-svg-jK9Utrad9squrKm4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jK9Utrad9squrKm4 .icon-shape p,#mermaid-svg-jK9Utrad9squrKm4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-jK9Utrad9squrKm4 .icon-shape .label rect,#mermaid-svg-jK9Utrad9squrKm4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jK9Utrad9squrKm4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-jK9Utrad9squrKm4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-jK9Utrad9squrKm4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 结果够用
结果太少或无
输入关键词
数据库 LIKE 快搜
直接返回
取候选: 标题 摘要
调用 ai-server: 语义排序
返回更相关的结果
7) 图片压缩 + AI 审核 + OSS 上传(同一条流水线)
图片上传做三件事:
- 压缩:体积太大就逐级压缩,压到合适为止(不写临时文件)
- AI 审核:违规图不允许进入 OSS
- 上传 OSS:统一目录、统一命名策略
这套流水线的目标就是:图片更省、更快、更安全。
#mermaid-svg-HkLMbTHmbdtDpYRk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HkLMbTHmbdtDpYRk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HkLMbTHmbdtDpYRk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HkLMbTHmbdtDpYRk .error-icon{fill:#552222;}#mermaid-svg-HkLMbTHmbdtDpYRk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HkLMbTHmbdtDpYRk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HkLMbTHmbdtDpYRk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HkLMbTHmbdtDpYRk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HkLMbTHmbdtDpYRk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HkLMbTHmbdtDpYRk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HkLMbTHmbdtDpYRk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HkLMbTHmbdtDpYRk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HkLMbTHmbdtDpYRk .marker.cross{stroke:#333333;}#mermaid-svg-HkLMbTHmbdtDpYRk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HkLMbTHmbdtDpYRk p{margin:0;}#mermaid-svg-HkLMbTHmbdtDpYRk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-HkLMbTHmbdtDpYRk .cluster-label text{fill:#333;}#mermaid-svg-HkLMbTHmbdtDpYRk .cluster-label span{color:#333;}#mermaid-svg-HkLMbTHmbdtDpYRk .cluster-label span p{background-color:transparent;}#mermaid-svg-HkLMbTHmbdtDpYRk .label text,#mermaid-svg-HkLMbTHmbdtDpYRk span{fill:#333;color:#333;}#mermaid-svg-HkLMbTHmbdtDpYRk .node rect,#mermaid-svg-HkLMbTHmbdtDpYRk .node circle,#mermaid-svg-HkLMbTHmbdtDpYRk .node ellipse,#mermaid-svg-HkLMbTHmbdtDpYRk .node polygon,#mermaid-svg-HkLMbTHmbdtDpYRk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HkLMbTHmbdtDpYRk .rough-node .label text,#mermaid-svg-HkLMbTHmbdtDpYRk .node .label text,#mermaid-svg-HkLMbTHmbdtDpYRk .image-shape .label,#mermaid-svg-HkLMbTHmbdtDpYRk .icon-shape .label{text-anchor:middle;}#mermaid-svg-HkLMbTHmbdtDpYRk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HkLMbTHmbdtDpYRk .rough-node .label,#mermaid-svg-HkLMbTHmbdtDpYRk .node .label,#mermaid-svg-HkLMbTHmbdtDpYRk .image-shape .label,#mermaid-svg-HkLMbTHmbdtDpYRk .icon-shape .label{text-align:center;}#mermaid-svg-HkLMbTHmbdtDpYRk .node.clickable{cursor:pointer;}#mermaid-svg-HkLMbTHmbdtDpYRk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HkLMbTHmbdtDpYRk .arrowheadPath{fill:#333333;}#mermaid-svg-HkLMbTHmbdtDpYRk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HkLMbTHmbdtDpYRk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HkLMbTHmbdtDpYRk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HkLMbTHmbdtDpYRk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HkLMbTHmbdtDpYRk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HkLMbTHmbdtDpYRk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HkLMbTHmbdtDpYRk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HkLMbTHmbdtDpYRk .cluster text{fill:#333;}#mermaid-svg-HkLMbTHmbdtDpYRk .cluster span{color:#333;}#mermaid-svg-HkLMbTHmbdtDpYRk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HkLMbTHmbdtDpYRk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HkLMbTHmbdtDpYRk rect.text{fill:none;stroke-width:0;}#mermaid-svg-HkLMbTHmbdtDpYRk .icon-shape,#mermaid-svg-HkLMbTHmbdtDpYRk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HkLMbTHmbdtDpYRk .icon-shape p,#mermaid-svg-HkLMbTHmbdtDpYRk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HkLMbTHmbdtDpYRk .icon-shape .label rect,#mermaid-svg-HkLMbTHmbdtDpYRk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HkLMbTHmbdtDpYRk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HkLMbTHmbdtDpYRk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HkLMbTHmbdtDpYRk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 不通过
违规
通过
用户上传图片
校验格式和大小
拒绝
阶梯压缩
AI 图片审核
拒绝
上传 OSS
返回 URL
本地开发(可选)
powershell
# 仅中间件 + Nginx(Java / AI / 前端在宿主机)
cd nginx
copy .env.dev.example .env
docker compose -f docker-compose.dev.yaml up -d
用户端:forum-vue/front → npm install → npm run dev(默认 http://localhost:5173)。
管理端:forum-vue-admin/admin → 同样方式。
后端:forum-demo 默认 http://localhost:10086。
AI:ai-server 见该目录 config.yaml / config.docker.yaml。
nginx/conf.d.local/ 将 API 反代到 host.docker.internal:10086;生产用 conf.d/。
生产部署(Docker Compose)
栈:Vite 构建前端 → Docker 打镜像 → Compose 编排 → Nginx 反代 + 挂载 dist/ 。服务器只上传 nginx/package/ 整目录,不要上传整个 nginx/ 或仓库根目录。
本机打包
powershell
cd nginx
.\scripts\make-package.ps1
产物:nginx\package\(含 dist/、images/*.tar、conf.d/、ssl/ 模板、.env.example、start.sh)。
自检:package\dist\user\index.html 存在;package\images\ 下三个 tar 都在;用户端前端使用 Vite 6 (勿用 Vite 8,否则可能出现 Ye is not a function)。
仅更新前端(本机已有镜像时):
powershell
.\scripts\build-all.ps1 -SkipDocker -SkipBackend
.\scripts\export-images.ps1
上传到服务器
整目录传到 ~/package/。不要 只覆盖几个 assets/*.js(会与 index.html 混版本导致白屏)。
若服务器上已有旧包,建议先备份 .env 和 ssl/,再整包替换;或至少:
bash
rm -rf ~/package/dist/user/assets ~/package/dist/admin/assets
# 再上传本次 package 里的完整 dist/
服务器首次 / 重装 package 目录
bash
cd ~/package
cp .env.example .env && nano .env # JWT、MySQL、OSS、各 AI Key 等
# HTTPS:将证书放入 ssl/(勿提交仓库)
# www.nuonuoya.cn.pem / .key
# admin.nuonuoya.cn.pem / .key
chmod +x start.sh verify-frontend-dist.sh
./verify-frontend-dist.sh .
./start.sh
./start.sh 会 docker load 三个 tar 并 docker compose up -d --force-recreate(不带 -v,不删数据卷)。首次启动约 2~5 分钟,后端未就绪时可能短暂 502,刷新即可。
start.sh 若卡在 chmod: logs/... Operation not permitted:旧日志属 root,脚本会因此中断。先执行:
bash
cd ~/package
sudo rm -rf logs && mkdir -p logs/backend
./start.sh
或手动:chmod -R a+rX dist conf.d ssl 后执行 docker load 与 docker compose ... up -d。
Nginx 挂载:
./dist/user→/usr/share/nginx/user./dist/admin→/usr/share/nginx/admin
数据与 Navicat
| 操作 | 数据 |
|---|---|
./start.sh / up --force-recreate |
保留(Docker 卷在) |
docker compose down(无 -v) |
保留 |
docker compose down -v |
删除 MySQL/Redis 等卷 |
生产中间件端口只绑 127.0.0.1 ,需 SSH 隧道 后用 Navicat:
| 服务 | 端口 |
|---|---|
| MySQL | 33061 |
| Redis | 63790 |
| PostgreSQL | 54320 |
| RabbitMQ 管理台 | 15672 |
须同时加载:docker-compose.yaml + docker-compose.prod.yml。
空库初始化可执行 package/sql/create.sql;增量见 forum-demo/src/main/resources/sql/migrate-*.sql。
部署验证
bash
docker compose -f docker-compose.yaml -f docker-compose.prod.yml ps
curl -s http://127.0.0.1/healthz
curl -I https://www.nuonuoya.cn
容器内 dist 为空(Snap Docker 读不到 ~/home)时:
bash
sudo snap connect docker:home
sudo snap restart docker
cd ~/package
docker compose -f docker-compose.yaml -f docker-compose.prod.yml up -d --force-recreate nginx
或把部署目录迁到 /opt/forum/package。
配置说明(环境变量)
下面这些通常是上线时最需要改的(以 nginx/.env 为准):
- 安全相关
JWT_SECRETPII_CRYPTO_SECRETFORUM_MASCOT_INTERNAL_KEY/FORUM_AI_INTERNAL_KEY
- 数据库 / 缓存 / MQ
MYSQL_*、REDIS_PASSWORDRABBITMQ_*POSTGRES_*
- AI 能力
DASHSCOPE_API_KEY(通义)DEEPSEEK_API_KEYHUANAPI_*(Gemini / Image / Claude 等)TAVILY_API_KEY
- 对象存储 / 短信 / 邮件
ALIYUN_ACCESS_KEY_ID/ALIYUN_ACCESS_KEY_SECRETOSS_*MAIL_USERNAME/MAIL_PASSWORD
常见问题
1) 刚启动访问 502 / WebSocket 失败
后端 Spring Boot 启动需要 20~30 秒,Nginx 可能先起来,刷新即可。新包会在后端 healthy 后再起 Nginx,可减轻首次 502。
2) 白屏 / xxx is not a function / Ye is not a function
index.html 与 assets/*.js 不是同一次构建;或曾用 Vite 8 打包。处理:本机 make-package.ps1 后整包 上传 dist/,服务器 ./verify-frontend-dist.sh . 通过后再 ./start.sh。浏览器强刷;chat-integration.*.js 为浏览器插件可忽略。
3) Navicat 连不上 MySQL
生产端口只绑 127.0.0.1,需 SSH 隧道;须带 docker-compose.prod.yml 启动。
4) 管理端登录提示"需要管理员权限"
管理端要求 user.is_admin = 1,需在库中提升并绑定 role_admin。
仓库结构
text
luntan/
forum-demo/ # Java 后端(Spring Boot)
ai-server/ # Python AI 服务(审核/写作/搜索/聊天)
forum-vue/ # 用户端前端(Vue)
forum-vue-admin/ # 管理端前端(Vue + Arco)
nginx/ # 打包与部署(compose、Nginx 配置、脚本)
*.js` 为浏览器插件可忽略。
3) Navicat 连不上 MySQL
生产端口只绑 127.0.0.1,需 SSH 隧道;须带 docker-compose.prod.yml 启动。
4) 管理端登录提示"需要管理员权限"
管理端要求 user.is_admin = 1,需在库中提升并绑定 role_admin。
仓库结构
text
luntan/
forum-demo/ # Java 后端(Spring Boot)
ai-server/ # Python AI 服务(审核/写作/搜索/聊天)
forum-vue/ # 用户端前端(Vue)
forum-vue-admin/ # 管理端前端(Vue + Arco)
nginx/ # 打包与部署(compose、Nginx 配置、脚本)