项目
快来分享你的内容吧~
- 4 天前·后端大家好,我是汉堡。 上一篇文章《如何从0到1 Vibe Coding 一个项目,并长期维护》里,我分享了自己踩坑之后沉淀出来的一套 Harness 体系——用文档治理、AGENTS.md、范围冻结和分阶段推进来驯服 Vibe Coding 的混乱。查看全文加油鸭:把方法论沉淀成可复用的 Skill 太棒了!文档驱动 + 阶段治理,真正让 Vibe Coding 有了工程骨架,为你点赞!737分享
- 交通行业工程师-缺乏AI落地场景困惑-求推荐可快速验证的AI应用方向资源类型交通行业的ai应用能做什么呢,感觉像这种工程领域并没有什么合适的ai应用。求广大网友提提建议!!...查看全文程序员鱼皮:你这问题太宽泛了,像 “工程领域有没有 AI 应用” 这种,网上一搜能出来一堆,但落到你自己身上未必有用。与其问别人 “能做什么”,不如直接找个 AI,用 grill-me 这个技能让它反过来拷问你。把你的岗位、日常工作流、痛点交代清楚,让它按照决策树一个个追问,往往比别人给的通用建议更有启发。详细介绍可以看这篇:https://mp.weixin.qq.com/s/B1PJl7-p7HhV5oR

- 07-17 10:40·全干的耄耋之神,老登的年纪且登味为0,只
06-04 17:38从零构建在线Excel:一个Java全栈工程师的实战记录 我为什么要自己造这个轮子 说出来你可能不信,起因是公司内部一堆Excel文件满天飞。 财务部的预算表、运营部的数据看板、产品部的需求矩阵——每一次改一个数,就要在微信上重新传一遍文件。文件名从"最终版"进化到"最终最终版"再到"打死也不改了版",像极了程序员给变量起名。 市面上不是没有在线表格产品,腾讯文档、飞书表格都挺好用。但公司内网环境查看全文加油鸭:太硬核了!分块存储+前端导出的思路既巧妙又实用,代码可移植性更是直击开发者痛点,为你这份扎实的全栈实践点赞!442070分享- AI入门者-两周AI开发钻入牛角尖,请求各位老手指导问题描述最近两周多一直在尝试AI开发,但是一直效果不佳,来试试提问能不能得到更多的思路背景信息使用的工具是codex cli,模型是GPT-5.4- 我手上有个耦合性很高的Spring MVC单体架构项目,存在不少的缓存自研、session自研,因为自研产品有些粗糙,为了后续的发展考虑,我想重构成成熟的组件以及模块分组去掉一些耦合。- 于是我开始按自己想法安排:1. 先让AI大致了解项目后,划分模...查看全文程序员鱼皮:很多人刚上手 AI 编程都会踩类似的坑,我觉得你最大的问题是【把太大的任务一次性丢给 AI】。重构一个耦合度高的单体项目,这件事本身就很复杂,AI 没有上下文记忆,不理解你的业务全貌,你让它一口气搞定是不现实的。正确做法是把任务拆到足够小,一次只让 AI 做一个明确的、可验证的小改动,做完你确认没问题再进行下一步。不要让 AI 自己排计划,然后你当甩手掌柜。AI 生成的计划看起来头头是道,但很多时

用户中心前端模板初始化bugBug 描述用户中心项目,前端模板初始化阶段遇到初始化命令"yarn create umi myapp"错误信息红框内容:The filename, directory name, or volume label syntax is incorrect.error Command failed.自己的思路和解决方案尝试过更换目录、清除缓存、更换yarn版本,都没有成功,问AI说是Windows系统...查看全文PKQiu:报错已经解决,前因后果如下:我的yarn是使用npm进行全局安装,为了节省C盘空间,我在安装node时更换了npm的全局安装路径到D盘,所以yarn也被安装到D盘。但yarn的全局bin目录在C盘下,create-umi.cmd中的内容为:@"%~dp0\D:\NVM\yarnglobal\node_modules.bin\create-umi.cmd" %*前面的%~dp0\在运行时代表了C
开源我的 Vibe Coding 工作流,已有人靠它把项目做完了
## 前言 > 如果这篇文章对你有帮助,欢迎先去给仓库点个 Star:[**project-vibe-spec**](https://github.com/dnwwdwd/project-vibe-spec),这对我是很大的鼓励,也让更多人能找到这个工具。 大家好,我是汉堡。 上一篇文章《如何从0到1 Vibe Coding 一个项目,并长期维护》里,我分享了自己踩坑之后沉淀出来的一套 Harness 体系——用文档治理、AGENTS.md、范围冻结和分阶段推进来驯服 Vibe Coding 的混乱。 文章发出去之后,有鱼友来问我:**多个 AI Agent 接力做项目,怎么让它们互相"知道"彼此做了什么?** 答案就在那篇文章里。**多 Agent 之间通信和协作,唯一的方式只有文档。** 在项目根目录维护好 Agent 的"说明书"——Codex/OpenCode 对应 `AGENTS.md`,Claude Code 对应 `CLAUDE.md`——Agent 启动时自动注入,啥也不用说就知道项目的一切。 有人照着做了,昨天来告诉我:**"牛逼,用了文章里的内容之后,AI 的产出就稳多了,现在已经把项目做完了,感谢大佬。"** 这让我很开心。所以今天这篇文章,我想介绍一个更进一步的东西——我把那套方法论直接做成了一个可以复用的 **Agent Skill**。 --- ## 为什么要做成 Skill? 上篇文章写的是**思路和方法**,但每次新建项目,你还是得自己手写 AGENTS.md、搭 docs/ 目录结构、想文档命名规范…… 重复劳动,而且容易遗漏。 所以我把这套体系沉淀成了一个开箱即用的 GitHub 仓库: > **👉 [https://github.com/dnwwdwd/project-vibe-spec](https://github.com/dnwwdwd/project-vibe-spec)** 如果这个 Skill 对你有帮助,欢迎点个 Star,这对我是很大的鼓励。 --- ## 这个 Skill 解决什么问题? 回顾一下 Vibe Coding 的几个典型困境: - **上下文膨胀**:代码越多,AI 越难理解全貌 - **耦合蔓延**:改一处牵一发而动全身 - **意图退化**:没有文档,几轮对话后你自己都忘了当初为什么这么设计 - **多 Agent 失忆**:换一个 Agent 工具,之前的上下文全部归零 这些问题都可以追溯到同一个原因——缺乏工程化的文档治理。 `project-vibe-spec` 提供了一套完整的项目规范模板,让你在开始写第一行代码之前,就把"地基"打好。 --- ## Skill 里有什么? ### 1. AGENTS.md 模板 这是整个体系的核心。AGENTS.md 干的事情只有一件:**让 AI 知道你的编码哲学和项目规范,不用每次都重复交代。** 对于 Codex/OpenCode,启动时会自动将项目级别和全局的 AGENTS.md 注入当前对话上下文。你啥也不用说,Agent 就知道: - 项目的技术栈和架构 - 代码风格和命名规范 - 禁止的行为(比如不要擅自改架构、不要顺手加功能) - 文档优先级和冲突解决规则 - 完成标准(DoD) ### 2. 文档治理体系 一套完整的文档分类规范: | 文档类型 | 命名格式 | 用途 | | --- | --- | --- | | **REQ** 需求文档 | `REQ-YYYYMMDD-XX-*.md` | 新功能或大范围改造前必写 | | **PROG** 进度日志 | `PROG-YYYYMMDD.md` | 每天一日志,记录完成了什么 | | **BUG** 缺陷记录 | `BUG-YYYYMMDD-XX-*.md` | 发现 bug 立即记录 | | **BIZ** 业务决策 | `BIZ-YYYYMMDD-XX-*.md` | 业务流程或实现策略的确认 | | **DEV** 技术方案 | `DEV-YYYYMMDD-XX-*.md` | 复杂模块拆解、阶段实施方案 | 这套体系的价值: - **上下文外挂**:AI 每次对话前先读相关文档,不会丢失上下文 - **可追溯**:三个月后回来,还能知道当初为什么这么设计 - **可交接**:换一个 AI 模型或工具,读一遍文档就能接手 ### 3. 分阶段推进模板(Phase 0 → Phase N) 大项目一口气让 AI 实现 = 灾难。必须拆阶段,每个阶段有明确的 DoD(Definition of Done): | 阶段 | 内容 | DoD | | --- | --- | --- | | **Phase 0** | 文档体系初始化 | AGENTS.md、README.md、docs/ 结构就绪 | | **Phase 1** | 后端骨架 | 服务可启动、配置可读、数据库可初始化 | | **Phase 2\~3** | 核心链路 | 端到端链路跑通 | | **Phase 4** | 业务 API | 接口字段对齐、错误响应统一 | | **Phase 5** | 前端工程化 | 拆页拆组件、接入真实 API | | **Phase 6\~7** | 收尾上线 | 链路闭环、打包部署 | 每个 Phase 结束必须达到 DoD 才能进入下一阶段。这个纪律不能破。 ### 4. 范围冻结清单 v1 要做什么、不做什么,在一开始就写死。一旦范围冻结,后续开发中 AI 想"顺手"加功能时,你就可以说:**"不在 v1 范围,先记 REQ,下个版本再说。"** --- ## 怎么用? 直接 clone 或 fork 这个仓库,把模板文件复制到你的项目根目录,按照说明填写你的项目信息即可。 ```bash git clone https://github.com/dnwwdwd/project-vibe-spec ``` 然后把 `AGENTS.md`、`docs/` 目录结构复制到你的项目里,根据你的项目实际情况填写内容。 --- ## 真实反馈 这套方法论有人真的用了。 有读者看了上篇文章之后,把这套文档治理的思路用到了自己的项目上。几天后来反馈:**AI 的产出稳定了很多,项目已经做完了。** 我写这篇文章、做这个 Skill,就是想把这套工程化方法变成别人可以直接用的东西,不用每个人再从头踩一遍。 --- ## 最后 Vibe Coding 的问题不在 AI 的能力,在我们给 AI 的上下文质量。 一个没有文档、没有规范、没有阶段划分的项目,再强的模型也推不动。换上完整的 Harness 体系——文档治理、阶段划分、范围冻结——用中等模型也能稳定推进。 `project-vibe-spec` 就是帮你把这个"地基"快速搭起来的工具。 仓库地址:<https://github.com/dnwwdwd/project-vibe-spec> 如果觉得有用,可以点个 Star,或者在评论区聊聊你的使用体验。 --- ## 相关文章 - [如何从0到1 Vibe Coding 一个项目,并长期维护](https://blog.hejiajun.com) --- *我的博客:[https://blog.hejiajun.com](https://blog.hejiajun.com)*
交通行业工程师-缺乏AI落地场景困惑-求推荐可快速验证的AI应用方向
### 资源类型 交通行业的ai应用能做什么呢,感觉像这种工程领域并没有什么合适的ai应用。求广大网友提提建议!!
耄耋专属AI Loop全自动编码器技术笔记
> 写给想了解「多AI Agent 在大LOOP时代怎么真上线」的同学 ## 一句话 **Loop Engineering 时代,别只在聊天里写代码——用多 AI Agent,跑一条自己干活的交付 Loop。** 先给这套 Loop 代码编排器定一条简单主线: **需求 Issue → 多角色 AI 协作 → 脚本验证与独立审阅 → 合并 → 固定脚本部署 → 看板盯进度。** 人定目标和验收;AI 负责中间大部分实现与自检;模型不直接登录生产。 --- ## 为什么不只「开个 Cursor 窗口」 聊天式助手擅长帮你改文件,但上线仍要人拉分支、补测、发版、盯结果。Loop 把这些环节收成固定流水线: | | 聊天式 AI 编程 | Loop | |---|---|---| | 起点 | 粘贴上下文 | Issue / 看板目标 | | 过程 | 人来回追问 | 多角色自动推进 | | 质量 | 靠自觉 | 脚本验证 + 独立审阅 | | 上线 | 手工发版 | 确定性发布脚本 | | 可见性 | 对话记录 | 看板阶段与应用入口 | 一句话:**助手帮你写;Loop 帮你把需求送到可点开的版本。** --- ## 技术骨架:三层分工 ```text 触发层 Forgejo Issue(ai-ready) / 看板「新建项目」 编排层 Worker + loopctl + Hermes 五角色 Profile 落地层 verify.sh → PR 合并 → SSH/Compose 发布 → sslip 域名跳转 ``` - **触发层**:习惯还是 Git Issue;新产品也可在看板填「仓库名 + 目标」一键建仓。 - **编排层**:分析 → 架构 → 编码 → 测试 → 审阅;审阅打回最多返工 3 轮;单阶段约 30 分钟超时。 - **落地层**:过门后由脚本部署,看板只监控和跳转,不托管业务前端。 密钥只在服务器 `secrets/`,不进仓库。 --- ## 多 Agent:故意「拆开」,不让一个模型既当运动员又当裁判 | 角色 | 做什么 | 典型产出 | |---|---|---| | 分析 | 收成可验收目标 | `spec.md` | | 架构 | 拆任务、定边界 | `plan.md` | | 编码 | 改业务代码与测试 | 仓库 diff | | 测试 | 独立补测、跑验证 | 测试报告 | | 审阅 | 只评不改,给通过/打回 | `review.json` | 执行侧用 Hermes 多 Profile,工具集刻意收窄(文件 / 终端 / 必要时代码执行),并设轮次上限,避免一轮对话无限烧 Token。 模型也可分层:编码侧重代码模型,测试用更快模型,审阅用更稳的模型——**质量门与写代码的人不是同一个「脑」**。 ## 配置落在哪 ### 编排层(仓库) `config/loop.json` 只规定: - 五角色各自用哪个 Profile 名 - 工具集(分析 / 架构 / 审阅:`file,terminal`;编码 / 测试再加 `code_execution`) - 单阶段轮次上限、YOLO、返工次数、超时 其中 `hermes.models` 可以按角色覆盖模型名;**留空则用 Profile 默认值**。 ### 执行层(服务器 Hermes) 真正的模型 ID、`base_url`、API Key 写在各 Profile 的 `config.yaml`(例如 `/root/.hermes/profiles/loop-coder/config.yaml`)。 线上现行大致是: | 角色 | 模型 | |---|---| | 分析 / 架构 / 编码 | `ark-code-latest`(火山方舟选GLM5.2) | | 测试 | `deepseek-v4-flash` | | 审阅 | `deepseek-v4-pro` |  ### 小技巧 如果想真正的分饰多角,完全可以多买几个便宜的Agent Plan分别挂不同对应角色能力的**LLM**,但这里由于成本控制的原因,最少两个**LLM**对应不同能力就可以完成基本工作了。 ### 人格层(SOUL) 仓库 `roles/*.md` 同步进各 Profile 的 `SOUL.md`,约束「只做什么、不做什么」: - **模型**负责能力 - **SOUL**负责边界 ### 调用链 ```text Issue Worker → loopctl → 对应 Profile 的 Hermes(--oneshot,瘦工具集)→ 该 Profile 配置的模型 ``` --- ## 有边界的自治(比「全自动」更重要) **会自动做:** 读仓、改码、跑校验、开/合 PR、按脚本部署。 **不会自动做:** 没目标乱开需求、跳过质量门上生产、把密钥写进 Git。 **人能介入:** 看板看卡点、一键重跑;紧急可停 Worker。 发布永远走 `release.sh` / SSH 一类确定性路径,而不是让 Agent 临时拼命令登生产。 --- ## 看板:把黑盒变成进度条 看板回答三件事: 1. 现在跑到哪(分析 / 架构 / 编码 / 测试 / 审阅 / 发布) 2. 有没有被打回、卡在哪 3. 应用在哪打开(当前交付 + 历史项目各自地址) 多项目时:**一仓一应用、独立端口、独立 `*.sslip.io` 域名**。改哪个程序,Issue 就开在哪个仓库。   --- ## 一次真实路径长什么样 1. 看板新建,或在目标仓开 Issue 并打 `ai-ready` 2. Worker 领取 → 工作区拉出 `ai/issue-N` 分支 3. 五角色流水线跑完,产物落在 `.loop/current/` 4. 质量门通过 → PR 合并 5. `deploy_once` 部署 Staging/Production,写好跳转域名 6. 打开 `{项目名}.xxx.sslip.io` 验收 试点里我们用这条链路交付过多智能体对话应用、内容创作平台等——从「一句话目标」到「浏览器能点开」,中间大部分由流水线完成。  --- ## 适合什么,不适合什么 **适合:** 中小功能、脚手架增量、内部试点、需求边界写得清的迭代。 **暂不适合:** 无人值守改核心账务、无评审的大重构、强合规唯一发布通道。 产出质量仍然取决于:需求是否写清、模板是否匹配、模型额度与提示词。 ## 目前Loop编排器自动完成的项目展示    --- ## 结语 Loop 的技术选择可以概括成三句: 1. **角色分离**,降低「自写自审」幻觉; 2. **脚本守门**,模型负责想和改,脚本负责过不过、能不能上; 3. **看板可见**,自动跑也不变成黑盒。 它不是取代工程师,而是把重复的「领任务 → 改代码 → 验证 → 发版」收成一条可观测流水线,让人把时间花在目标与验收上。
AI 热点监控平台项目 VibeCoding 学习 -未完成 后续要持续更新
需求 == 作为一名程序员,为了与时俱进,想要第一时间获取一些热点信息,不依赖人工搜索, 而是利用工具自动发现,指定的热点变化,或者最新热点,并且及时给用户发送通知,让我能走在信息获取的第一线 💡由于是一个比较轻量化的小需求(工具类项目),所以打算采用较为捷敏的开发方式,不用过多的人工介入和工程化 具体功能: * 用户输入要监控的关键词,当这个关键词内容出现,(注意利用 AI识别假冒的内容),并第一时间发送通知 * 每隔一段时间,自动收集用户输入的指定范围,(比如AI 编程)内的热点,并且能够让用户看到 产品的形式: * 响应式兼容 web 页面 * 封装成 Agent Skills 技能,能够交给其他 AI agent 来监控和发现热点 环境准备 ==== * Vscode * claude code /codex * claude sonnet/gpt5.4 大模型 * 扩展 MCP (mcp 就是让 ai 获取外部的数据,操作外部的系统,增强 ai 的能力 ) * Firecrawl MCP 网页内容抓取 * context7 获取最新的文档,防止 AI 使用过时代码 * 需要安装的 skills(skill 就是 给 ai 快速学各种专业技能,各种技能生成包,本质是一份代码脚本和说使用明书) * UI UX Pro Max 专门用来美化前端页面的技能,因为我们要开发 web 页面 * Skills Creator 专门能帮你制作新的技能,也我们要把工具封装成 Agent Skills 技能,所以可以用它来开发规范技能,让各种 AI 工具能识别 * mcp= 食材 + skills= 配方 ==> 结果 * 在 skill.sh ,找到 find skills 这个技能的安装地址进行安装,安装以后可以用这个 skill 安装其他 skills * 找不到直接手动安装 * npx skills add anthropics/skills --skill skill-creator -g -y * 在 终端里 `npx ctx7@latest setup` * 跟着一步步往下走,需要先去官网注册 方案设计 ==== 由于项目不复杂,不写专业提示词,让 AI 帮我们根据需求设计方案就好,提示词中一定要包含“人工确认”这四个字 ```markdown 你是一位专业的程序员,现在请你根据需求,帮我设计方案,人工确认,分步骤完成开发,执行测试,帮我验收 ## 需求 作为一名程序员,为了与时俱进,想要第一时间获取一些热点信息,不依赖人工搜索, 而是利用工具自动发现,指定的热点变化,或者最新热点,并且及时给用户发送通知,让我能走在信息获取的第一线 💡由于是一个比较轻量化的小需求(工具类项目),所以打算采用较为捷敏的开发方式,不用过多的人工介入和工程化 具体功能: - 用户输入要监控的关键词,当这个关键词内容出现,(注意利用 AI识别假冒的内容),并第一时间发送通知 - 每隔一段时间,自动收集用户输入的指定范围,(比如AI 编程)内的热点,并且能够让用户看到 产品的形式: - 响应式兼容 web 页面 - 封装成 Agent Skills 技能,能够交给其他 AI agent 来监控和发现热点 ## 注意事项 1. 前端页面足够独特,不要千篇一律 2. 你需要先完成网页版,确保功能正常使用之后,再开发 Agent skills 3. 项目需要利用 AI 来识别热点,请你理由 Openrouter 来对接 AI 服务,帮我快速把 AI 接入到项目中 ``` 把需求给了 ai 以后,问了我 4 个问题,我如下回答 ```markdown Plan阶段共问了 4 个问题(同一组问题触发了 3 次,可能是页面重试): 1. [通知方式] 你希望使用哪种通知方式?- 浏览器推送通知 / 邮件通知 / 应用内通知 2. [API密钥] 你是否已有 OpenRouter API 密钥? - 已有密钥 / 需要注册 / 使用其他AI服务 3. [部署] 项目部署方式? - 本地运行 / 云服务器部署 / 暂不考虑 4. [数据源] 你最关注哪些数据源? - Hacker News / Reddit / GitHub Trending / RSS订阅 ``` 然后在生成文档中,我修改了一下技术方案,要求要用 vue 做前端,以及要用 python 做后端,因为个人比较熟悉,以及告知了,项目热点查询频率为 30 分钟,最后生成了以下方案 ``` # AI热点监控工具 - 技术方案设计 ## Context 用户希望构建一个轻量级的AI热点监控工具,能够自动跟踪关键词和行业热点,并及时发送通知。这是一个工具类项目,采用敏捷开发方式,需要较少的人工介入。项目分为两个阶段:先开发Web版本验证功能,再封装成Agent Skills供AI agent使用。 ## 技术架构选型 ### 前端技术栈 - **框架**: Vue 3 + Vite(快速启动,轻量级) - **样式**: 原生CSS(手工打造独特视觉风格,符合design_sense中的warm paper风格) - **状态管理**: Vue Composition API(项目简单,无需Pinia) - **UI风格**: - 温暖的纸质背景色调(#F6F1E8) - 琥珀色强调色(#C8853F) - 粗体slab衬线标题 + Inter正文 - 卡片式布局,带柔和阴影 ### 后端技术栈 - **框架**: Python + FastAPI - **数据存储**: SQLite(轻量级,无需额外数据库服务) - **ORM**: SQLAlchemy - **定时任务**: APScheduler - **爬虫**: httpx + beautifulsoup4(或playwright用于复杂页面) - **通知**: - 邮件通知(aiosmtplib)- 首选通知方式 - Web推送通知(pywebpush)- 浏览器实时通知 ### AI服务集成 - **提供商**: OpenRouter(用户已有API密钥) - **用途**: 1. 识别假冒/无关内容(过滤噪音) 2. 提取热点摘要 3. 评估内容相关性 4. 多信息源数据聚合去重 ### 热点数据源 - **网页搜索爬虫**: Google搜索(httpx + beautifulsoup4,控制频率) - **Twitter(X) API**: 通过twitterapi.io接入 - **免费API**: Hacker News, GitHub Trending - **可选**: RSS订阅(技术博客) - **查询频率**: - 关键词监控: 每30分钟执行一次 - 热点收集: 每30分钟执行一次 ## 项目结构 ai_trending_tool/ ├── frontend/ # Vue前端 │ ├── src/ │ │ ├── components/ # 组件 │ │ ├── views/ # 页面视图 │ │ ├── services/ # API调用 │ │ ├── assets/ # 静态资源 │ │ └── styles/ # 样式文件 │ ├── public/ # 公共资源 │ ├── package.json │ └── vite.config.js ├── backend/ # Python FastAPI后端 │ ├── app/ │ │ ├── api/ # API路由 │ │ ├── services/ # 业务逻辑 │ │ │ └── scrapers/ # 数据源爬虫 │ │ ├── jobs/ # 定时任务 │ │ ├── db/ # 数据库模型 │ │ ├── ai/ # AI服务集成 │ │ └── main.py # 应用入口 │ ├── requirements.txt │ └── .env ├── skills/ # Agent Skills(第二阶段) │ └── trending-monitor.json └── README.md ## 核心功能实现 ### 1. 关键词监控 **流程**: 1. 用户在Web界面输入关键词和监控范围 2. 后端定时任务(每30分钟)抓取数据源 3. 使用OpenRouter AI判断内容是否真实相关 4. 匹配成功则存储并触发通知 **AI Prompt示例**: ``` 判断以下内容是否真正与关键词"{keyword}"相关, 排除标题党、假冒、无关内容。 返回JSON: {relevant: boolean, reason: string, confidence: number} ``` ### 2. 热点收集 **流程**: 1. 用户设置监控领域(如"AI编程") 2. 定时任务(每30分钟)从多个数据源抓取 3. AI聚合、去重、排序热点 4. 生成热点摘要存入数据库 5. 前端展示热点列表(按时间/热度排序) **AI Prompt示例**: ``` 从以下内容中提取"{domain}"领域的前10个热点, 排除重复、过时、无关内容。 返回JSON数组: [{title, summary, source, score, tags}] ``` ### 3. 通知系统 - **邮件通知**: aiosmtplib发送即时通知和每日摘要(首选) - **Web推送**: pywebpush + Service Worker(浏览器实时通知) - **应用内通知**: WebSocket实时推送(FastAPI WebSocket) ### 4. 数据持久化 **数据表设计**: - `keywords`: 关键词配置 - `trending_items`: 热点内容 - `notifications`: 通知记录 - `user_settings`: 用户配置 ## 开发步骤 ### 阶段1: Web版本(MVP) 1. **初始化项目结构** - 创建前后端项目 - 配置构建工具 - 设置开发环境 2. **后端核心开发** - 数据库schema设计和初始化 - OpenRouter集成(ai/openrouter.py) - 数据源爬虫(services/scrapers/) - RESTful API(api/routes.py) - 定时任务(jobs/scheduler.py,每30分钟执行) 3. **前端核心开发** - 响应式布局(支持桌面/移动) - 关键词管理页面 - 热点展示页面 - 通知设置页面 - 独特的视觉设计实现 4. **集成与测试** - 前后端联调 - AI识别准确性测试 - 通知功能测试 - 响应式适配测试 ### 阶段2: Agent Skills封装 1. **Skills定义** - 监控关键词技能 - 获取热点技能 - 配置管理技能 2. **Skills实现** - 复用后端API - 定义skill schema - 编写skill prompt - 测试skill调用 ## 技术细节 ### OpenRouter集成 ```python # backend/app/ai/openrouter.py import httpx import os async def analyze_content(prompt: str, content: str) -> str: async with httpx.AsyncClient() as client: response = await client.post( 'https://openrouter.ai/api/v1/chat/completions', json={ 'model': 'anthropic/claude-sonnet-4.6', 'messages': [ {'role': 'system', 'content': prompt}, {'role': 'user', 'content': content} ] }, headers={ 'Authorization': f"Bearer {os.getenv('OPENROUTER_API_KEY')}", 'HTTP-Referer': os.getenv('APP_URL'), 'X-Title': 'AI Trending Monitor' } ) return response.json()['choices'][0]['message']['content'] ### 数据源爬虫示例 **1. Hacker News** ```python # backend/app/services/scrapers/hackernews.py import httpx async def fetch_hn_trending(): async with httpx.AsyncClient() as client: top_stories = await client.get('https://hacker-news.firebaseio.com/v0/topstories.json') ids = top_stories.json()[:30] stories = [] for story_id in ids: story = await client.get(f'https://hacker-news.firebaseio.com/v0/item/{story_id}.json') stories.append(story.json()) return stories ``` **2. Google搜索爬虫** ```python # backend/app/services/scrapers/google.py import httpx from bs4 import BeautifulSoup from urllib.parse import quote async def search_google(keyword: str): async with httpx.AsyncClient() as client: headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } url = f'https://www.google.com/search?q={quote(keyword)}&tbm=nws' response = await client.get(url, headers=headers) soup = BeautifulSoup(response.text, 'html.parser') results = [] for item in soup.select('.SoaBEf'): title = item.select_one('.mCBkyc') link = item.select_one('a') snippet = item.select_one('.GI74Re') if title and link: results.append({ 'title': title.get_text(), 'link': link.get('href'), 'snippet': snippet.get_text() if snippet else '' }) return results **3. Twitter(X) API** ```python # backend/app/services/scrapers/twitter.py import httpx import os async def search_twitter(keyword: str, limit: int = 20): async with httpx.AsyncClient() as client: response = await client.get( 'https://api.twitterapi.io/v1/tweets/search', params={'query': keyword, 'max_results': limit}, headers={'Authorization': f"Bearer {os.getenv('TWITTER_API_KEY')}"} ) return response.json().get('data', []) ### 关键词匹配AI判断 ```python import json async def is_relevant_to_keyword(keyword: str, item: dict) -> bool: prompt = f'判断以下内容是否真正与关键词"{keyword}"相关...' result = await analyze_content(prompt, json.dumps(item)) parsed = json.loads(result) return parsed['relevant'] and parsed['confidence'] > 0.7 ## 验证计划 ### 功能测试 1. **关键词监控测试** - 添加关键词"Claude" - 等待30分钟触发监控任务 - 验证AI能正确识别相关内容 - 验证通知是否送达 2. **热点收集测试** - 配置"AI编程"领域 - 手动触发热点收集 - 验证AI提取的热点质量 - 验证前端展示效果 3. **响应式测试** - 桌面浏览器(1920x1080) - 平板(768x1024) - 手机(375x667) ### 性能测试 - 爬虫任务执行时间 - AI响应时间 - 前端加载速度 ## 环境变量 ```env # OpenRouter(用户已有) OPENROUTER_API_KEY=your_openrouter_key # Twitter API (twitterapi.io) TWITTER_API_KEY=your_twitter_api_key # 邮件通知(必需) SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER=your_email@gmail.com SMTP_PASS=your_app_password NOTIFICATION_EMAIL=your_notification_email@gmail.com # Web Push(可选) VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= VAPID_SUBJECT=mailto:your_email@example.com # 应用配置 APP_URL=http://localhost:5173 BACKEND_PORT=8000 FRONTEND_PORT=5173 # 定时任务配置 MONITOR_INTERVAL_MINUTES=30 ## 关键文件清单 ### 需要创建的核心文件 1. `backend/app/ai/openrouter.py` - OpenRouter集成 2. `backend/app/services/scrapers/google.py` - Google搜索爬虫 3. `backend/app/services/scrapers/twitter.py` - Twitter API集成 4. `backend/app/services/scrapers/hackernews.py` - Hacker News爬虫 5. `backend/app/services/scrapers/__init__.py` - 数据源聚合器 6. `backend/app/services/notifier.py` - 通知服务(邮件+Web Push) 7. `backend/app/jobs/scheduler.py` - 定时监控任务(每30分钟) 8. `backend/app/db/models.py` - 数据库模型 9. `backend/app/db/database.py` - 数据库连接 10. `backend/app/main.py` - FastAPI应用入口 11. `frontend/src/views/Dashboard.vue` - 主仪表盘 12. `frontend/src/views/Keywords.vue` - 关键词管理 13. `frontend/src/components/TrendingCard.vue` - 热点卡片组件 14. `frontend/src/styles/theme.css` - 独特视觉风格(warm paper主题) ## 风险与挑战 1. **AI识别准确性**: 需要调优prompt,可能需要多轮测试 2. **数据源限流**: - Google搜索爬虫易被检测,需要添加延迟和User-Agent伪装 - Twitter API有rate limit,需要实现请求队列 3. **通知可靠性**: - Web Push需要用户授权 - 邮件可能进垃圾箱,需要配置SPF/DKIM 4. **成本控制**: - OpenRouter API调用需要监控用量 - twitterapi.io收费(需确认套餐) 5. **多信息源聚合**: 需要AI有效去重和排序 ## 下一步行动 待用户确认方案后,按以下顺序执行: 1. 初始化项目结构和依赖 2. 实现后端核心功能 3. 实现前端界面 4. 集成测试 5. 部署并收集反馈 6. 开发Agent Skills(第二阶段) ``` 我开了,全自动模式,不要求 ai 每次编辑都询问,但在每次做完一个 task,都要询问我是否继续,我会手动的压缩一下上下文,/compact 去节约 token 用量,中途有些比较需要权限的 bash 任务 AI 也会顺问我,完成以后 我给 AI 提出了, 最后 ai 完成了,我让他写一个启动和对那些东西进行配置的文档,语句如下, “我想知道该如何启动项目,以及我哪些需要配置,写在一个 markdown 文档里” ```markdown # 启动指南 ## 环境要求 - Python 3.10+ - Node.js 18+ - pip / npm --- ## 1. 配置环境变量 编辑 `backend/.env`,填入以下配置: env # ✅ 必填 —— 没有这个 AI 分析和摘要功能无法运行 OPENROUTER_API_KEY=sk-or-... # ⚙️ 必填(如果启用邮件通知) SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER=your_email@gmail.com # Gmail 账号 SMTP_PASS=xxxx xxxx xxxx xxxx # Gmail 应用专用密码(不是登录密码) NOTIFICATION_EMAIL=you@example.com # 接收通知的邮箱 # 🐦 可选 —— Twitter 数据源(当前已停用,保留字段兼容旧配置) TWITTER_API_KEY= # 🔔 可选 —— Web Push 浏览器推送(不填则禁用) VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= VAPID_SUBJECT=mailto:you@example.com # 🔧 通常不需要改 APP_URL=http://127.0.0.1:5173 BACKEND_PORT=8000 MONITOR_INTERVAL_MINUTES=30 建议本地开发时前后端统一使用同一种主机名,推荐全部使用 `127.0.0.1`,避免 `localhost` / `127.0.0.1` 混用导致代理或 CORS 判断不一致。 ### Gmail 应用专用密码获取方式 1. 打开 Google 账号 → 安全性 → 两步验证(需先开启) 2. 搜索"应用专用密码" → 选择"邮件" → 生成 3. 将生成的 16 位密码填入 `SMTP_PASS` --- ## 2. 启动后端 ```bash cd backend # 首次运行:安装依赖(建议在虚拟环境中) pip install -r requirements.txt # 启动服务(默认端口 8000) uvicorn app.main:app --reload --port 8000 ``` 启动成功后会看到: ``` INFO: Uvicorn running on http://0.0.0.0:8000 INFO: Scheduler started 数据库文件 `trending_monitor.db` 会自动创建。 --- ## 3. 启动前端 新开一个终端窗口: ```bash cd frontend # 首次运行:安装依赖 npm install # 启动开发服务器(默认端口 5173) npm run dev 打开浏览器访问:**http://127.0.0.1:5173** --- ## 4. 功能验收 | 页面 | 验证项 | |------|--------| | 热点页 | 空列表正常显示,"触发监控"按钮可点击 | | 关键词页 | 添加 / 暂停 / 删除关键词 | | 设置页 | 保存邮箱配置,发送测试邮件 | 添加关键词后,点击"触发监控"可立即测试(无需等待 30 分钟定时任务)。 --- ## 5. 功能开关说明 | 功能 | 依赖配置 | 未配置时的行为 | |------|---------|--------------| | AI 内容分析 | `OPENROUTER_API_KEY` | 监控任务报错,内容不会被分析 | | 邮件通知 | `SMTP_*` + 设置页开启 | 发送静默失败,不影响其他功能 | | Twitter 数据源 | `TWITTER_API_KEY` | 当前已停用,不参与采集 | | Web Push | `VAPID_*` | 禁用,不影响邮件和应用内通知 | | Hacker News | 无需配置 | 始终可用 | | Bing 新闻 | 无需配置 | 始终可用(注意频率限制) | --- ## 6. 常见问题 **后端启动报 `ModuleNotFoundError`** → 确认已激活虚拟环境,或重新执行 `pip install -r requirements.txt` **前端访问报 `502 Bad Gateway` / API 请求失败** → 确认后端已在 8000 端口运行 **测试邮件发送失败** → 检查 `SMTP_PASS` 是否为应用专用密码(非 Gmail 登录密码);确认 Gmail 已开启两步验证 **`OPENROUTER_API_KEY` 未填时触发监控** → 后端日志会显示 API 错误,热点列表保持空,属于正常降级行为 **Bing 新闻抓不到内容** → 先确认关键词本身有相关新闻;如果页面结构变动,日志里会看到 Bing 解析错误,后端会返回空列表 ``` #展示初步完成效果 ======    # 前端美化 ==== 从这里开始我换了模型 用 codex gpt5.4 写的,因为 claude 太贵了 Ai 设计前端千篇一律,使用前端美化技能,并人工指定UI风格,让界面更新颖 * 前端美化技能UI UX pro max * 人工提示词,指定风格 * Aceternity UI 组件库,做酷炫效果, 因为大模型训练时,看到大量 自然背景,圆角卡片,通用图标,所以形成了一种默认审美,要消除核心思路是约束 1. 指定色彩方案 2. 指定 UI 库 3. 注入情感和场景,告诉 AI 产品调性,如科技感,极客风 4. 使用前端美化技能:让AI 参考专业的 UI/UX 设计规范 写给 AI 的提示词 ```plain 目前的界面风格过于死板(米白配色+传统布局),请在保证功能易用的前提下,帮我优化前端页面。 你需要利用前端美化技能(ui ux pro max)进行设计开发,并且必须采用 Aceternity UI 组件库(通过 Context7 插件来获取最新的用法),来增强网站的科技感,但注意不要过于花里胡哨,影响了页面的正常加载或使用 我自己是一位 AI 编程程序员,最求简介高效,直观酷炫,充满科技感的页面,我急切地想要发现热点,想要第一时间给大家分享有价值的内容,你需要感受我的情绪,让前端页面体现出来 ``` 如果ai卡住了,报错了,可能是重构的代码量较多,可能会出现网络中断的情况,如果一直中断,最好换个模型,或者让 ai 拆分一下任务再继续执行     # 项目地址 https://github.com/ansonwong9695/ai_trending_tool
GitHub 每周精选|2026 W25
GitHub 上每天都会冒出很多新项目。 大多数我都会看过就忘。 有些看起来很酷,但装完就吃灰。 还有一些,会让我真的想留下来继续折腾。 我想把这些项目记录下来。 不追求“最火”。 只记录那些: 让我真正想装下来试试的东西。 --- ### 1. 人味 skill 项目名:renwei-writing GitHub 仓库地址: https://github.com/orange2ai/renwei-writing **这是继 web-access 之后,我基本上天天会使用的 skill,目前还不到 1k 的 star,暂时算是不温不火的状态** 从这个仓库名称也基本可以猜到这个仓库的作用到底是什么了,没错就是输出的时候更有人味 这里我没有单独去做一个用和不用这个 skill 输出的文本的实验了,因为自从用了这个 skill 之后,就基本没有在创作的时候不用这个 skill 了 如果你是一名创作者的话,这个 skill 大抵会让你爱不释手的 **虽然这个 skill 的效果确实很不错,但对于输出的内容并不能做到真正的全部使用,还是需要人的创作指导的,要不人味依然不会很高** ### 2. andrej-karpathy-skills 项目名:andrej-karpathy-skills GitHub 仓库地址: https://github.com/multica-ai/andrej-karpathy-skills Andrej Karpathy 在 26 年的 1 月发了一条推特,来聊了聊过去几周大量使用Claude编程的一些零散想法,**有 770 万次浏览量**,推特链接如下: https://x.com/karpathy/status/2015883857489522876 这里也简单介绍一下 Andrej Karpathy 的背景 Andrej Karpathy 是 AI 研究者与工程实践者,**OpenAI 创始团队成员之一**,后担任 Tesla Autopilot 视觉方向负责人 --- 在这条推特当中,Andrej Karpathy 聊到了现在 AI 智能体在编码时的一些问题,这里我还是觉得直接放原文会好一些,相信这也是大家在用 AI 智能体编码时多多少少会遇到的一个问题  为了解决上面的问题,就有这个仓库,ndrej-karpathy-skills 做的事情,就是把以上问题压缩成 4 条规则(**以下不是完整的 skill 内容**): 1. 编码前先思考:不要默默假设,有歧义就说出来 2. 简洁优先:能 50 行解决,就不要写成 200 行 3. 精准修改:只动和当前任务有关的地方,不顺手重构 4. 目标驱动执行:不要只说“修一下”,而是定义可验证的成功标准 优先是很轻,可以直接将这个 skill 安装到 AI Agent 当中,或者直接写进 CLAUDE.md 或者 AGENTS.md 都是可以的,可以一定程度上解决上面的问题 **缺点也是比较明显的,它不是强约束**,它不会像类型系统、测试、lint 那样硬性拦住错误,能减少犯错的概率,但并不能杜绝错误的发生 比如关于 "编码前先思考" 这点,用 superpower 的效果会更好 --- 至于使用人群的话,如果你打算用 AI 认真写代码,那么值得一试,它做了一件很朴素的事情:**AI coding 的下一步,不只是让模型更会写代码,也要让模型少乱写代码**  ### 3. guizang-social-card-skill 项目名:guizang-social-card-skill GitHub 仓库地址: https://github.com/op7418/guizang-social-card-skill 藏师傅的新作品,之前也有推荐过藏师傅的 PPT skill,感兴趣的话可以点击下面的链接去看一下 https://zhuanlan.zhihu.com/p/2040526006285509057 优点有如下几个: 第一:延续上次 guizang-ppt-skill 的两种风格,审美起点更高 不是让你从零调字体、颜色、间距,而是先给你一套有约束的视觉语言。这个约束反而是好事,因为大多数内容图做丑,不是因为自由不够,而是因为自由太多 第二:适合长文拆图 说成大白话就是为文章配图,将一片文章拆成 5 到 9 张小红书卡片,它能从结构、标题、重点句、截图排布这些地方一起处理,不只是做一张封面 第三:修改成本低 最初的产物是单文件 HTML,再用 Playwright 渲染成 PNG。HTML 和 CSS 都能继续改,出问题也能查,不像很多在线设计工具,最后只剩一个不可控的导出结果 当然我也需要说一说局限性,原配的 skill 主要是生成小红书和公众号内容的,这两个平台的图片比例为 3:4 和 21:9,**对于 4:3 和 16:9 比例的支持稍微差一些**,我第一次在生成 16:9 的配图时出现了配图模糊的情况,后续在原配的基础上进行一些改进,顺利的解决了这个问题,这一点有必要告诉大家  ### 4. agent-skills 项目名:agent-skills GitHub 仓库地址: https://github.com/addyosmani/agent-skills 区别于很多 "角色大全" 式的 skill 合集,产品、运营、设计、销售、法务、数据分析都来一点,看起来很全 agent-skills 的重心收得很窄,基本围绕软件开发这条线展开:**从需求澄清、写规格、拆任务,到编码、测试、调试、代码审查、安全、性能、CI/CD、发布、监控和迁移废弃** 所以它更像是把一个资深工程团队的日常习惯拆开,写成 AI coding agent 可以照着执行的工作流 里面不只是“你是一个前端工程师”这种角色设定,还会告诉agent:什么时候该写 spec,什么时候该停下来补测试,什么时候该做安全检查,什么时候该考虑回滚和可观测性等等  ### 5. whisper 项目名:whisper GitHub 仓库地址: https://github.com/openai/whisper 如果单说功能的话,这个仓库的功能是极其简单的: 将视频 / 音频转换成文字稿 而且这个文字稿也不是完美的。**它不会顺手帮你清理语气词,不会自动帮你删除重复表达,对一些专业名词、人名、品牌名的识别也不总是稳定。**你如果拿它的结果直接发出去,往往还是要自己再校对一遍 对于做字幕,没有剪映方便 对于视频会议的转写也没有腾讯会议、飞书会议方便 对于只是偶尔转一段采访或者播客,现在市面上也有很多现成工具能做,比如 TurboScribe、Riverside、VEED、Otter、Notta,中文场景里还有飞书妙记、通义听悟这类产品,很多都比它更省事 --- 那我为什么还想要来推荐这个仓库呢? 第一点是,**它足够便宜**,准确点说,是几乎没有使用门槛上的持续成本 很多在线转写产品表面上能免费试,但真正想长期用,要么限制分钟数,要么限制文件大小,要么导出字幕和全文稿时开始收费 Whisper 不一样。环境装好、模型下载好之后,它就是一个可以一直放在你电脑上的本地工具。你不用反复算时长,也不用担心哪天平台把免费额度收紧 第二点是,**它是本地可控的** 你的视频、录音、采访、会议材料,不需要上传到第三方网站。这个差别平时不明显,但一旦素材涉及客户、内部沟通、未发布内容、个人隐私,你就会知道“本地处理”这四个字有多值钱。很多产品更方便,但方便的代价就是文件先出去;Whisper 不是。 第三点是,它虽然简单,但它简单得很像一个基座 很多成品工具解决的是“给你一个结果”,Whisper 解决的是“把音频转文字这一步,变成你自己手里的一项能力” 你可以拿它输出 .txt、.srt、.vtt、.json,然后继续接自己的工作流:字幕、归档、摘要、检索、内容拆条、AI 总结,后面怎么接都行 这也是它和剪映、飞书会议、腾讯会议这类工具最大的差异点。那些工具是成品,目标是让普通用户少折腾;Whisper 是底层能力,目标是让你自己决定后面怎么用 它的优点说白了就三个:免费、本地、通用 它的缺点也很明确:不够傻瓜、不够省心、结果不够干净、后处理要靠自己 --- 所以它并不是一个适合所有人的仓库 如果你只是想偶尔给视频加字幕,剪映更合适 如果你主要是开会并且想自动生成纪要,腾讯会议、飞书会议更合适。 如果你不想碰命令行,也不想自己管模型和环境,那各种在线转写网站也更合适 --- 但如果你属于下面这几类人,Whisper 就很值得看一眼: 你经常要处理录音、播客、口播、采访、课程、会议素材; 你在意隐私,不想把文件上传到第三方平台; 你想把“音频转文字”这一步沉淀成一个长期可用的本地能力; 或者你是开发者,后面还想接字幕、摘要、检索、自动化流程 对这些人来说,Whisper 的价值不在于它做得比所有产品都更好,而在于它把最基础、也最关键的一步,稳定地交回到了你自己手里。 如果要给它一句比较准确的定位,我会更愿意这么说: **Whisper 不是最好用的视频转文字产品,但它是很值得拥有的本地转写底座**  --- 最后: 后面肯定还会继续遇到: 让我真正想装下来试试的项目。 这个系列也会继续更新下去。
易扣AI (Go + CloudWeGo) 企业级AI智能体项目教程 第5章:后端项目基于Eino对话记忆的对话历史模块搭建
## 一、方案设计 ### 业务需求描述 在集成Eino对话记忆之前,我们的代码生成智能体存在以下核心问题: ##### 问题1:对话无持久化 **现状:** ```go // 现有智能体的Generate方法 - 无状态设计 func (a *BaseAgent) Generate(ctx context.Context, userMessage string, chatTemplate prompt.ChatTemplate, adkAgent *adk.ChatModelAgent) (*schema.Message, error) { // 直接格式化Prompt,没有任何历史上下文 format, err := chatTemplate.Format(ctx, map[string]any{ "content": userMessage, // 只有当前消息,没有history! }) // ... } ``` **用户体验:** - 每次都要重新描述需求 - AI无法理解"它"、"这个"、"那个"等指代词 - 对话不连贯,体验极差 ##### 问题2:无法追溯对话历史 **现状:** - 智能体运行时内存中保存对话 - 服务重启后所有对话丢失 - 无法查询历史对话记录 - 无法回溯用户的完整需求变更过程 **业务影响:** - 用户无法回顾之前的对话内容 - 开发者无法调试AI生成的问题 - 无法进行数据分析(如用户常用功能统计) - 不符合合规性要求(需要保留操作日志) ##### 问题3:无多对话隔离 **现状:** 单个智能体处理多个应用对话 **安全风险:** - 应用A的用户可能看到应用B的对话 - 不同应用的对话历史混在一起 - 数据泄露风险高 - 无法按应用维度管理数据 ##### 问题4:无自动总结机制 **现状:** - 长对话导致Token消耗指数增长 - 20轮对话后,Prompt可能超过模型上下文窗口 - AI响应质量下降(注意力分散) - API调用成本急剧上升 **成本估算示例:** ``` 假设每条消息平均100 tokens: - 第1轮:200 tokens (用户+AI) - 第10轮:2000 tokens - 第20轮:4000 tokens - 第50轮:10000 tokens (超出许多模型的限制) API成本增长曲线:线性 → 指数级增长 ``` ### 对话历史分页方案选型:传统分页 vs 游标分页 在对话历史模块中,我们同时使用了两种分页方式:管理员接口使用**传统分页**,用户接口使用**游标分页**。为什么要这样设计?下面通过一个真实的案例来说明。 #### 一个真实的场景 假设你的应用"任务管理系统"已经和AI进行了 **50轮对话**,产生了 **100条消息记录**。用户打开对话历史页面,每页显示10条。 #### 传统分页的做法 传统分页的思路很简单:告诉数据库"我要第3页,每页10条"。 ``` 请求:GET /api/chat/history?appId=123&pageNum=3&pageSize=10 ``` 数据库执行的SQL: ```sql SELECT * FROM chat_history WHERE app_id = 123 ORDER BY create_time DESC LIMIT 10 OFFSET 20; ``` 翻译成白话就是:"跳过前20条,取接下来的10条"。 **看起来没问题?让我们看看会发生什么。** 用户正在浏览第3页时,另一条新的AI消息到达了,插入到了最新位置。此时数据变成了101条,最新的一条被推到了第1页的顶部。 用户点击"下一页"想看第4页: ```sql SELECT * FROM chat_history WHERE app_id = 123 ORDER BY create_time DESC LIMIT 10 OFFSET 30; ``` **问题出现了**——用户看到了第3页最后一条消息的重复!因为新插入的消息把所有记录往下挤了一位,OFFSET 30实际上跳过了一条本该看到的消息,而把第3页末尾的那条又展示了一次。 ``` 插入前: 插入后(新消息挤入第1页): 第1页:Msg100, Msg99... 第1页:Msg101, Msg100, Msg99... 第2页:Msg90, Msg89... 第2页:Msg91, Msg90, Msg89... 第3页:Msg80, Msg79... 第3页:Msg81, Msg80, Msg79... ← Msg81是新挤进来的 第4页:Msg70, Msg69... 第4页:Msg71, Msg70, Msg69... ← 用户看到的第4页,Msg71重复了! ``` 这就是传统分页的**数据漂移问题**——在有新数据插入时,页与页之间会出现重复或遗漏。 **另一个问题:性能** 当数据量很大时,OFFSET的代价很高: ```sql -- 查看第1000页,每页10条 SELECT * FROM chat_history ORDER BY create_time DESC LIMIT 10 OFFSET 9990; ``` 数据库并不是直接跳到第9990条,而是先扫描前9990条记录,然后丢弃它们,再返回接下来的10条。也就是说,**翻到越后面的页,查询越慢**。 ``` 第1页: 扫描 0 条 → 耗时 1ms 第10页: 扫描 90 条 → 耗时 3ms 第100页:扫描 990条 → 耗时 15ms 第1000页:扫描9990条 → 耗时 150ms ``` #### 游标分页的做法 游标分页的思路完全不同:不告诉数据库"我要第几页",而是告诉它"我要在某个位置之后的数据"。 ``` 第一次请求:GET /api/chat/history?appId=123&pageSize=10 (没有游标,从头开始取) 返回结果: Msg100 (createTime: 2025-01-10 10:30:00) Msg99 (createTime: 2025-01-10 10:28:00) ... Msg91 (createTime: 2025-01-10 10:10:00) 游标标记:lastCreateTime = 2025-01-10 10:10:00 ``` 用户向下滚动,加载更多: ``` 第二次请求:GET /api/chat/history?appId=123&pageSize=10&lastCreateTime=2025-01-10 10:10:00 (告诉数据库:从这条之后继续取) ``` 数据库执行的SQL: ```sql SELECT * FROM chat_history WHERE app_id = 123 AND create_time < '2025-01-10 10:10:00' ORDER BY create_time DESC LIMIT 10; ``` 翻译成白话就是:"给我比这个时间更早的10条"。 **关键区别来了**——即使此时有新消息插入,也不会影响结果。因为我们不是在说"跳过多少条",而是在说"从这个时间点之前取"。新消息的时间一定比游标更新,不会出现在结果中。 ``` 插入前和插入后的查询结果完全一致: 第二次请求返回: Msg90 (createTime: 2025-01-10 10:08:00) Msg89 (createTime: 2025-01-10 10:06:00) ... Msg81 (createTime: 2025-01-10 09:50:00) 不会出现重复,不会出现遗漏! ``` **性能方面**——游标分页利用了索引,无论翻到多深,查询速度都一样快: ```sql -- 第1次查询和第100次查询的执行计划完全相同 -- 都是利用 create_time 索引定位,然后向后扫描10条 -- 耗时始终在 1-2ms ``` ``` 第1次加载: 利用索引定位 → 耗时 1ms 第10次加载:利用索引定位 → 耗时 1ms 第100次加载:利用索引定位 → 耗时 1ms 第1000次加载:利用索引定位 → 耗时 1ms ``` #### 那为什么管理员接口还用传统分页? 游标分页虽然好,但也有它的局限: **局限1:无法跳页** 游标分页只能"下一页",不能直接跳到第5页。用户必须从第1页开始,一页一页往下翻。 ``` 传统分页:可以直接跳到第50页 → GET /api/admin/chat/history?pageNum=50 游标分页:必须从第1页开始,连续翻50次 → 不现实 ``` **局限2:无法显示总页数** 游标分页不知道总共有多少数据,所以无法显示"共100页"这样的信息。 ``` 传统分页:可以显示 "第3页/共100页" 游标分页:只能显示 "加载更多" 或 "没有更多了" ``` **局限3:排序字段必须唯一且递增** 游标分页依赖一个稳定的、单调递增的字段作为游标。如果排序字段有重复值,可能会漏数据。 ``` 用 createTime 做游标: 如果两条消息的 createTime 完全相同(精度到秒) → 可能会漏掉其中一条 → 解决方案:用 (createTime, id) 联合游标 ``` #### 我们的选择 根据两种分页的特点,我们这样分配: **用户查看对话历史 → 游标分页** 用户的场景是"向下滚动加载更多",不需要跳页,不需要总页数。对话是实时产生的,用游标分页可以避免数据漂移,保证体验流畅。 ``` 前端交互: 打开对话历史 → 加载最新10条 向下滚动 → 基于最后一条的时间加载更多 继续滚动 → 继续加载... 没有更多了 → 显示"已加载全部" ``` **管理员查看所有对话 → 传统分页** 管理员的场景是"后台管理",需要跳转到指定页码,需要看到总记录数,需要按多种条件筛选。数据变动对管理员来说影响不大。 ``` 前端交互: 打开管理后台 → 显示第1页,共50页 点击第5页 → 直接跳转 输入页码25 → 直接跳转 筛选条件变更 → 重新从第1页开始 ``` ### 数据库表设计 **对话历史表(chat_history)** **表结构:** | 字段名 | 类型 | 说明 | 约束 | | ----------- | ----------- | ------------------- | ----------------------------------- | | id | bigint | 主键ID | PRIMARY KEY, AUTO_INCREMENT | | message | text | 消息内容 | NOT NULL | | messageType | varchar(32) | 消息类型(user/ai) | NOT NULL | | appId | bigint | 应用ID | NOT NULL, INDEX | | userId | bigint | 用户ID | NOT NULL, INDEX | | turnNumber | int | 对话轮数 | NOT NULL, DEFAULT 1 | | createTime | datetime | 创建时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP | | updateTime | datetime | 更新时间 | NOT NULL, DEFAULT CURRENT_TIMESTAMP | | isDelete | tinyint | 是否删除 | NOT NULL, DEFAULT 0 | **索引设计:** - 主键索引:id - 联合索引:(appId, userId, turnNumber) - 普通索引:createTime **建表语句:** ```sql -- 对话历史表 create table if not exists chat_history ( id bigint auto_increment comment 'id' primary key, message text not null comment '消息内容', messageType varchar(32) not null comment '消息类型:user/ai', appId bigint not null comment '应用ID', userId bigint not null comment '用户ID', turnNumber int default 1 not null comment '对话轮数', createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间', updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间', isDelete tinyint default 0 not null comment '是否删除', INDEX idx_appId_userId (appId, userId), INDEX idx_turnNumber (turnNumber), INDEX idx_createTime (createTime) ) comment '对话历史' collate = utf8mb4_unicode_ci; ``` ## 二、对话历史接口开发 本节我将详细讲解对话历史模块的Service接口定义和Logic层实现,包括对话历史的增删改查、轮次管理、自动总结等核心功能。 ### Service 接口定义 **文件位置:** `internal/service/chat_history_service.go` **接口设计:** ```go type IChatHistoryService interface { AddChatMessage(ctx context.Context, appId int64, message string, messageType enum.ChatHistoryMessageTypeEnum, userId int64) error DeleteByAppId(ctx context.Context, appId int64) error ListAppChatHistoryByPage(ctx context.Context, appId int64, pageSize int32, lastCreateTime time.Time, loginUser *vo.UserVo) (*response.PageResponse[*model.ChatHistory], error) ListAllChatHistoryByPageForAdmin(ctx context.Context, pageNum int32, pageSize int32, queryRequest *api.YiKouChatHistoryQueryRequest) (*response.PageResponse[*model.ChatHistory], error) } ``` ### Logic 层实现 **文件位置:** `internal/logic/chat_history_logic.go` #### 服务初始化 ```go func NewChatHistoryService(db *gorm.DB) *ChatHistoryService { return &ChatHistoryService{ db: db, } } type ChatHistoryService struct { db *gorm.DB } ``` #### 分页查询应用对话历史(ListAppChatHistoryByPage) **功能说明:** 分页获取指定应用的对话历史记录,支持游标分页和时间过滤。 **完整代码:** ```go func (s *ChatHistoryService) ListAppChatHistoryByPage(ctx context.Context, appId int64, pageSize int32, lastCreateTime time.Time, loginUser *vo.UserVo) (*response.PageResponse[*model.ChatHistory], error) { // 1. 校验基本参数 if appId == 0 || appId < 0 || pageSize <= 0 || pageSize > 50 { return nil, errorutil.ParamsError } if loginUser == nil { return nil, errorutil.NotLoginError } // 2. 校验用户角色是否为管理员或者应用创建者 app, err := query.Use(s.db).App.Where(query.App.ID.Eq(appId)).First() if err != nil { return nil, err } if app.UserID != loginUser.ID && loginUser.UserRole != string(enum.AdminRole) { return nil, errorutil.NotAuthError } // 3. 构建查询条件 chatHistoryQuery := query.Use(s.db).ChatHistory. Where(query.ChatHistory.AppID.Eq(appId)). Where(query.ChatHistory.MessageType.Neq(string(enum.SummaryMessageType))) // 4. 处理时间过滤(游标分页) if !lastCreateTime.IsZero() { chatHistoryQuery = chatHistoryQuery.Where(query.ChatHistory.CreateTime.Lt(lastCreateTime)) } // 5. 查询总记录数 totalRow, err := chatHistoryQuery.Count() if err != nil { return nil, err } // 6. 计算总页数 totalPage := 0 if totalRow > 0 { totalPage = int((totalRow + int64(pageSize) - 1) / int64(pageSize)) } // 7. 分页查询应用的聊天记录 chatHistoryList, err := chatHistoryQuery. Order(query.ChatHistory.CreateTime.Desc()). Limit(int(pageSize)). Find() if err != nil { return nil, err } // 8. 构建并返回分页响应 return &response.PageResponse[*model.ChatHistory]{ Records: chatHistoryList, PageNum: 1, PageSize: int(pageSize), TotalPage: totalPage, TotalRow: int(totalRow), OptimizeCountQuery: true, }, nil } ``` #### 删除应用对话历史(DeleteByAppId) **功能说明:** 删除指定应用的所有对话记录,通常在删除应用时调用。 **完整代码:** ```go func (s *ChatHistoryService) DeleteByAppId(ctx context.Context, appId int64) error { // 1. 校验应用ID if appId == 0 || appId < 0 { return errorutil.ParamsError.WithMessage("应用ID不能为空") } // 2. 删除该应用的所有对话记录 _, err := query.Use(s.db).ChatHistory. Where(query.ChatHistory.AppID.Eq(appId)). Delete() if err != nil { return err } return nil } ``` #### 添加对话消息(AddChatMessage)⭐核心方法 **功能说明:** 添加一条对话消息到数据库,自动计算对话轮次,并在达到阈值时触发对话总结。 **完整代码:** ```go func (s *ChatHistoryService) AddChatMessage(ctx context.Context, appId int64, message string, messageType enum.ChatHistoryMessageTypeEnum, userId int64) error { // 1. 校验参数 if appId <= 0 || messageType == "" || userId <= 0 || message == "" { return errorutil.ParamsError } // 2. 获取上一条消息的轮次 lastMessage, err := query.Use(s.db).ChatHistory. Where(query.ChatHistory.AppID.Eq(appId)). Order(query.ChatHistory.CreateTime.Desc()). First() var turnNumber int32 if err != nil { turnNumber = 0 // 第一条消息,轮次为0 } else { turnNumber = lastMessage.TurnNumber } // 3. 如果当前是用户消息,开启新的一轮 if messageType == enum.UserMessageType { turnNumber += 1 } // 4. 生成雪花算法ID chatMessageId, err := snowflake.GenerateSnowFlakeId() if err != nil { return err } // 5. 创建对话记录 err = query.Use(s.db).ChatHistory.Create(&model.ChatHistory{ ID: chatMessageId, AppID: appId, Message: message, MessageType: string(messageType), UserID: userId, TurnNumber: turnNumber, }) if err != nil { return err } // 6. 当对话轮次达到20轮且为AI消息时,异步生成总结 if turnNumber >= 20 && messageType == enum.AIMessageType { go s.generateSummary(context.Background(), appId, userId) } return nil } ``` **步骤详解:** | 步骤 | 操作 | 说明 | | ---- | ---------- | ---------------------- | | 1 | 参数校验 | 验证所有必填参数 | | 2 | 查询轮次 | 获取上一条消息的轮次数 | | 3 | 计算新轮次 | 用户消息则轮次+1 | | 4 | 生成ID | 使用雪花算法生成唯一ID | | 5 | 保存记录 | 写入数据库 | | 6 | 触发总结 | 达到阈值后异步生成总结 | **对话轮次计算规则:** ``` 示例对话流程: 轮次1: - 用户消息(turnNumber=1) - AI响应(turnNumber=1) 轮次2: - 用户消息(turnNumber=2) - AI响应(turnNumber=2) 轮次3: - 用户消息(turnNumber=3) - AI响应(turnNumber=3) ... ``` #### 生成对话总结(generateSummary)⭐高级功能 **功能说明:** 当对话达到一定轮次时,异步生成对话总结,用于优化长对话的上下文管理。 **完整代码:** ```go // generateSummary 生成对话总结 func (s *ChatHistoryService) generateSummary(ctx context.Context, appId int64, userId int64) { // 1. 获取历史对话记录(按时间正序) historyList, err := query.Use(s.db).ChatHistory. Where(query.ChatHistory.AppID.Eq(appId)). Order(query.ChatHistory.CreateTime.Asc()). Find() if err != nil { logger.Errorf("获取历史对话失败: %v\n", err) return } // 2. 构建对话历史字符串 var chatHistoryBuilder strings.Builder for _, history := range historyList { if history.MessageType == string(enum.UserMessageType) { chatHistoryBuilder.WriteString(fmt.Sprintf("用户: %s\n", history.Message)) } else if history.MessageType == string(enum.AIMessageType) { chatHistoryBuilder.WriteString(fmt.Sprintf("AI: %s\n", history.Message)) } } err = s.AddChatMessage(ctx, appId, chatHistoryBuilder.String(), enum.SummaryMessageType, userId) if err != nil { logger.Errorf("对话总结保存失败: %v\n", err) } } ``` **对话格式化示例:** ``` 输入:数据库中的对话记录列表 输出格式化的对话字符串: 用户: 我需要一个任务管理系统 AI: 好的,我来帮你创建一个任务管理系统。这个系统将包括任务的增删改查功能... 用户: 需要支持优先级设置 AI: 明白,我会添加优先级字段,支持高、中、低三个级别... 用户: 还要有截止日期提醒 AI: 没问题,我会集成日期选择器和提醒功能... ``` 在后续我们会自定义一个对话总结智能体用于总结所有轮次的对话,实现真正意义上的节省token #### 管理员分页查询所有对话历史(ListAllChatHistoryByPageForAdmin) **功能说明:** 管理员专用的全量对话历史查询接口,支持多条件组合查询。 **完整代码:** ```go func (s *ChatHistoryService) ListAllChatHistoryByPageForAdmin(ctx context.Context, pageNum int32, pageSize int32, queryRequest *api.YiKouChatHistoryQueryRequest) (*response.PageResponse[*model.ChatHistory], error) { // 1. 校验基本参数 if pageNum <= 0 || pageSize <= 0 || pageSize > 50 { return nil, errorutil.ParamsError } if queryRequest == nil { return nil, errorutil.ParamsError } // 2. 构建基础查询 chatHistoryQuery := query.Use(s.db).ChatHistory. Where(query.ChatHistory.ID.IsNotNull()) // 3. 动态添加查询条件 if queryRequest.Id > 0 { chatHistoryQuery = chatHistoryQuery.Where(query.ChatHistory.ID.Eq(queryRequest.Id)) } if queryRequest.AppId > 0 { chatHistoryQuery = chatHistoryQuery.Where(query.ChatHistory.AppID.Eq(queryRequest.AppId)) } if queryRequest.UserId > 0 { chatHistoryQuery = chatHistoryQuery.Where(query.ChatHistory.UserID.Eq(queryRequest.UserId)) } if queryRequest.MessageType != "" { chatHistoryQuery = chatHistoryQuery.Where(query.ChatHistory.MessageType.Eq(queryRequest.MessageType)) } if queryRequest.Message != "" { chatHistoryQuery = chatHistoryQuery.Where( query.ChatHistory.Message.Like("%" + queryRequest.Message + "%") ) } if !queryRequest.LastCreateTime.IsZero() { chatHistoryQuery = chatHistoryQuery.Where( query.ChatHistory.CreateTime.Lt(queryRequest.LastCreateTime) ) } // 4. 查询总记录数 totalRow, err := chatHistoryQuery.Count() if err != nil { return nil, err } // 5. 计算总页数 totalPage := 0 if totalRow > 0 { totalPage = int((totalRow + int64(pageSize) - 1) / int64(pageSize)) } // 6. 计算偏移量 offset := int((pageNum - 1) * pageSize) // 7. 执行分页查询 chatHistoryList, err := chatHistoryQuery. Order(query.ChatHistory.CreateTime.Desc()). Limit(int(pageSize)). Offset(offset). Find() if err != nil { return nil, err } // 8. 构建并返回分页响应 return &response.PageResponse[*model.ChatHistory]{ Records: chatHistoryList, PageNum: int(pageNum), PageSize: int(pageSize), TotalPage: totalPage, TotalRow: int(totalRow), OptimizeCountQuery: true, }, nil } ``` ### Handler 层实现 **文件位置:** `internal/handler/chat_history_handler.go` #### Handler 结构体定义 ```go type ChatHistoryHandler struct { chatHistoryService service.IChatHistoryService userService service.IUserService } func NewChatHistoryHandler( chatHistoryService service.IChatHistoryService, userService service.IUserService, ) *ChatHistoryHandler { return &ChatHistoryHandler{ chatHistoryService: chatHistoryService, userService: userService, } } ``` #### 分页查询应用对话历史(ListAppChatHistory) **接口说明:** 用户查看指定应用的对话历史,使用游标分页。 **完整代码:** ```go func (h *ChatHistoryHandler) ListAppChatHistory(ctx context.Context, c *app.RequestContext) { // 1. 获取路径参数appId appIdStr := c.Param("appId") appId, err := strconv.ParseInt(appIdStr, 10, 64) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](errorutil.ParamsError.WithMessage("应用ID格式错误"))) return } // 2. 获取查询参数pageSize,默认值为10 pageSizeStr := c.Query("pageSize") pageSize := int32(10) // 默认值 if pageSizeStr != "" { if ps, err := strconv.Atoi(pageSizeStr); err == nil { pageSize = int32(ps) } } // 3. 获取查询参数lastCreateTime,可选 lastCreateTimeStr := c.Query("lastCreateTime") var lastCreateTime time.Time if lastCreateTimeStr != "" { if t, err := time.Parse(time.RFC3339, lastCreateTimeStr); err == nil { lastCreateTime = t } } // 4. 获取登录用户 loginUser, err := h.userService.GetLoginUserVo(ctx, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 5. 调用服务层方法 result, err := h.chatHistoryService.ListAppChatHistoryByPage(ctx, appId, pageSize, lastCreateTime, &loginUser) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 6. 返回成功响应 c.JSON(consts.StatusOK, response.NewSuccessResponse[*response.PageResponse[*model.ChatHistory]](result)) } ``` #### 管理员分页查询所有对话历史(ListAllChatHistoryByPageForAdmin) **接口说明:** 管理员查看所有应用的对话历史,支持多条件筛选,使用传统分页。 **请求参数(文件位置 `internal/api/chat_history.go`):** ```go type YiKouChatHistoryQueryRequest struct { Id int64 `json:"id"` // 对话ID AppId int64 `json:"appId"` // 应用ID UserId int64 `json:"userId"` // 用户ID MessageType string `json:"messageType"` // 消息类型 Message string `json:"message"` // 消息内容(模糊搜索) LastCreateTime time.Time `json:"lastCreateTime"` // 创建时间过滤 } type YiKouChatHistoryQueryResponse response.BaseResponse[response.PageResponse[*model.ChatHistory]] ``` **完整代码:** ```go func (h *ChatHistoryHandler) ListAllChatHistoryByPageForAdmin(ctx context.Context, c *app.RequestContext) { // 1. 绑定请求参数 req := &api.YiKouChatHistoryQueryRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](errorutil.ParamsError)) return } // 2. 获取分页参数 pageNum := int32(1) // 默认值 pageSize := int32(10) // 默认值 // 3. 调用服务层方法 result, err := h.chatHistoryService.ListAllChatHistoryByPageForAdmin(ctx, pageNum, pageSize, req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 4. 返回成功响应 c.JSON(consts.StatusOK, response.NewSuccessResponse[*response.PageResponse[*model.ChatHistory]](result)) } ``` #### 修改路由文件增加接口声明 文件位置 `internal/router/router.go` ```go // RegisterRoutes 注册路由 func RegisterRoutes(h *server.Hertz, url func(config *swagger.Config), db *gorm.DB, userHandler *handler.UserHandler, appHandler *handler.AppHandler, chatHistoryHandler *handler.ChatHistoryHandler) { // 注册全局中间件 // 处理跨域问题 h.Use(cors.New(cors.Config{ AllowAllOrigins: true, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: false, MaxAge: 12 * time.Hour, })) // 全局异常处理 h.Use(recovery.Recovery(recovery.WithRecoveryHandler(CustomRecoveryHandler))) // 测试接口 h.GET("/ping", handler.Ping) // swaggo文档 h.GET("/swagger/*any", swagger.WrapHandler(swaggerFiles.Handler, url)) userRoute := h.Group("/user") { userRoute.POST("/register", userHandler.UserRegister) userRoute.POST("/login", userHandler.UserLogin) userRoute.GET("/get/vo", userHandler.GetUserVo) // 需要登录的接口 userRoute.GET("/get/login", middleware.AuthMiddleware(enum.UserRole, db), userHandler.GetLoginUser) userRoute.POST("/logout", middleware.AuthMiddleware(enum.UserRole, db), userHandler.Logout) // 需要管理员权限的接口 userRoute.POST("/add", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.AddUser) userRoute.GET("/get", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.GetUser) userRoute.POST("/delete", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.DeleteUser) userRoute.POST("/update", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.UpdateUser) userRoute.POST("/list/page/vo", middleware.AuthMiddleware(enum.AdminRole, db), userHandler.ListUserVoByPage) } appRoute := h.Group("/app") { appRoute.POST("/good/list/page/vo", appHandler.ListGoodApp) appRoute.GET("/get/vo", middleware.AuthMiddleware(enum.UserRole, db), appHandler.GetAppVo) // 需要登录的接口 appRoute.GET("/chat/gen/code", middleware.AuthMiddleware(enum.UserRole, db), appHandler.ChatToGenCode) appRoute.POST("/my/list/page/vo", middleware.AuthMiddleware(enum.UserRole, db), appHandler.ListMyApp) appRoute.POST("/add", middleware.AuthMiddleware(enum.UserRole, db), appHandler.AddApp) appRoute.POST("/update", middleware.AuthMiddleware(enum.UserRole, db), appHandler.UpdateApp) appRoute.POST("/delete", middleware.AuthMiddleware(enum.UserRole, db), appHandler.DeleteApp) // 需要管理员权限的接口 appRoute.POST("/admin/update", middleware.AuthMiddleware(enum.AdminRole, db), appHandler.AdminUpdateApp) appRoute.POST("/admin/delete", middleware.AuthMiddleware(enum.AdminRole, db), appHandler.AdminDeleteApp) appRoute.GET("/admin/get/vo", middleware.AuthMiddleware(enum.AdminRole, db), appHandler.AdminGetAppVo) appRoute.POST("/admin/list/page/vo", middleware.AuthMiddleware(enum.AdminRole, db), appHandler.AdminListApp) } // 聊天历史路由 chatHistoryRoute := h.Group("/chatHistory") { // 需要管理员权限的接口 chatHistoryRoute.POST("/admin/list/page/vo", middleware.AuthMiddleware(enum.AdminRole, db), chatHistoryHandler.ListAllChatHistoryByPageForAdmin) chatHistoryRoute.GET("/app/:appId", middleware.AuthMiddleware(enum.UserRole, db), chatHistoryHandler.ListAppChatHistory) } } ``` ### 应用模块集成对话历史服务 除了专门的对话历史接口外,应用模块也需要调用对话历史服务来保存用户和AI的对话记录。本节将详细描述 `app_handler.go` 和 `app_logic.go` 中如何集成对话历史服务。 #### Handler 层集成 **文件位置:** `internal/handler/app_handler.go` ##### AppHandler 结构体修改 在 `AppHandler` 中注入 `chatHistoryService`: ```go type AppHandler struct { appService service.IAppService userService service.IUserService chatHistoryService service.IChatHistoryService // ← 新增:对话历史服务 } func NewAppHandler( appService service.IAppService, userService service.IUserService, chatHistoryService service.IChatHistoryService, // ← 新增参数 ) *AppHandler { return &AppHandler{ appService: appService, userService: userService, chatHistoryService: chatHistoryService, } } ``` ##### ChatToGenCode 方法中的调用 **功能说明:** 在流式代码生成完成后,异步保存AI的响应消息到对话历史表。 **调用位置:** 流式响应结束后 **完整代码片段:** ```go func (a *AppHandler) ChatToGenCode(ctx context.Context, c *app.RequestContext) { // ... 前面的代码省略 var aiResponseBuilder strings.Builder for { // ... 流式读取代码省略 aiResponseBuilder.WriteString(chunk.Content) // ... 发送SSE事件省略 } // ← 关键调用:保存AI响应到对话历史 err = a.chatHistoryService.AddChatMessage(ctx, appId, aiResponseBuilder.String(), enum.AIMessageType, userVo.ID) if err != nil { logger.Errorf("保存对话历史失败: %v\n", err) } _ = w.WriteEvent(lastEventID, "done", []byte{1}) } ``` #### Service 层集成 **文件位置:** `internal/logic/app_logic.go` ##### AppService 结构体修改 在 `AppService` 中注入 `chatHistoryService`: ```go func NewAppService( aiCodeGenFacade *core.YiKouAiCodegenFacade, userService service.IUserService, chatHistoryService service.IChatHistoryService, // ← 新增:对话历史服务 db *gorm.DB, ) *AppService { return &AppService{ aiCodeGenFacade: aiCodeGenFacade, userService: userService, chatHistoryService: chatHistoryService, db: db, } } type AppService struct { aiCodeGenFacade *core.YiKouAiCodegenFacade userService service.IUserService chatHistoryService service.IChatHistoryService // ← 新增字段 db *gorm.DB } ``` ##### ChatToGenCode 方法中的调用 **功能说明:** 在调用代码生成服务前,先保存用户的对话消息到对话历史表。 **调用位置:** 参数校验通过后,调用代码生成服务前 **完整代码片段:** ```go func (s *AppService) ChatToGenCode(ctx context.Context, appId int64, message string, loginUser *vo.UserVo) (*schema.StreamReader[*schema.Message], error) { // 1. 校验参数 if message == "" { return nil, errorutil.ParamsError.WithMessage("消息不能为空") } if appId == 0 || appId < 0 { return nil, errorutil.ParamsError.WithMessage("应用ID不能为空") } // 2. 校验应用是否存在 app, err := query.Use(s.db).App.Where(query.App.ID.Eq(appId), query.App.IsDelete.Eq(0)).First() if err != nil { return nil, err } // 3. 校验用户是否有权限使用该应用 if app.UserID != loginUser.ID { return nil, errorutil.NotAuthError.WithMessage("无权使用该应用") } // 4. 获取代码生成类型 if enum.CodeGenTypeTextMap[enum.CodeGenTypeEnum(app.CodeGenType)] == "" { return nil, errorutil.ParamsError.WithMessage("应用代码生成类型不支持") } // ← 关键调用:保存用户消息到对话历史 err = s.chatHistoryService.AddChatMessage(ctx, appId, message, enum.UserMessageType, loginUser.ID) if err != nil { logger.Errorf("保存对话历史失败: %v\n", err) } // 6. 调用代码生成服务 return s.aiCodeGenFacade.GenCodeStreamAndSave(ctx, message, enum.CodeGenTypeEnum(app.CodeGenType), appId) } ``` ##### DeleteApp 方法中的调用 **功能说明:** 在删除应用时,级联删除该应用的所有对话历史记录。 **调用位置:** 应用逻辑删除成功后 **完整代码片段:** ```go func (s *AppService) DeleteApp(ctx context.Context, id int64, userId int64) (bool, error) { // 1. 查询应用 app, err := query.Use(s.db).App.Where(query.App.ID.Eq(id)).First() if err != nil { return false, err } // 2. 校验权限 if app.UserID != userId { return false, errorutil.ParamsError.WithMessage("无权删除该应用") } // 3. 逻辑删除应用 _, err = query.Use(s.db).App.Where(query.App.ID.Eq(id)).Update(query.App.IsDelete, 1) if err != nil { return false, err } // ← 关键调用:级联删除对话历史 err = s.chatHistoryService.DeleteByAppId(ctx, id) if err != nil { logger.Errorf("对话历史删除失败: %v\n", err) } return true, nil } ``` ## 三、集成Eino对话记忆 本节将详细讲解Eino框架的对话记忆机制在项目中的完整实现,包括基础设施层、存储层、Agent层和工厂层的架构设计与代码实现。 ### 对话记忆的保存方案设计 在深入实现细节之前,我们需要先理解一个核心设计决策:**为什么Eino对话记忆保存在Redis,而不是MySQL?** #### 一个关键的区别:对话历史 vs 对话记忆 很多人会混淆这两个概念,但它们有本质区别: **对话历史(Chat History)** - 保存在MySQL的 `chat_history` 表中 - 是**完整的、永久的**对话记录 - 用于**审计、统计、回溯** - 数据结构:包含 appId、userId、turnNumber、messageType 等完整字段 - 查询场景:管理员查看、用户翻阅历史、数据分析 **对话记忆(Conversation Memory)** - 保存在Redis的 `memory:{appId}` 键中 - 是**裁剪的、临时的**对话上下文 - 用于**AI实时推理** - 数据结构:只包含 role 和 content 的消息列表 - 查询场景:AI调用时加载上下文 用一个比喻来理解: ``` 对话历史 = 银行的交易流水账本 - 永久保存,不能修改 - 记录每一笔交易的完整信息 - 用于对账、审计、统计 - 存储在数据库 对话记忆 = 你的记账本摘要 - 只记录最近几笔交易 - 定期清理旧记录 - 用于快速查看当前财务状况 - 存储在便签纸上(Redis) ``` #### 为什么Eino对话记忆不用MySQL? 假设我们用MySQL存储Eino对话记忆,会发生什么? **场景:用户发送一条消息,AI生成响应** ``` [步骤1] 用户发送消息 "添加删除功能" ↓ [步骤2] 从MySQL加载对话记忆 执行SQL: SELECT * FROM chat_history WHERE app_id = 123 ORDER BY create_time DESC LIMIT 20; 耗时:5-10ms(需要解析SQL、优化查询、扫描索引、回表) ↓ [步骤3] 将查询结果转换为Eino的Message格式 遍历20条记录,构建 []*schema.Message 耗时:1-2ms ↓ [步骤4] 调用AI模型(带上下文) 耗时:2000-5000ms(网络请求) ↓ [步骤5] AI响应完成,保存新的对话记忆 执行SQL: INSERT INTO chat_history (...) VALUES (...); 耗时:3-5ms ↓ 总耗时:2010-5020ms ``` **如果用Redis存储对话记忆:** ``` [步骤1] 用户发送消息 "添加删除功能" ↓ [步骤2] 从Redis加载对话记忆 执行命令: GET memory:123 耗时:0.5-1ms(直接内存读取,无SQL解析) ↓ [步骤3] JSON反序列化为Message格式 耗时:0.5-1ms ↓ [步骤4] 调用AI模型(带上下文) 耗时:2000-5000ms(网络请求) ↓ [步骤5] AI响应完成,保存新的对话记忆 执行命令: SET memory:123 '...' EX 86400 耗时:0.5-1ms ↓ 总耗时:2002-5003ms ``` **性能对比:** | 操作 | MySQL | Redis | 差异 | | ---------- | ------ | ------- | ------------------ | | 加载记忆 | 5-10ms | 0.5-1ms | **快10倍** | | 保存记忆 | 3-5ms | 0.5-1ms | **快5倍** | | 总耗时影响 | +15ms | +3ms | **节省12ms** | 看起来差异不大?但考虑以下场景: **场景:高并发情况下,每秒100个用户同时对话** ``` MySQL方案: 每秒 100 次读取 + 100 次写入 = 200 次数据库操作 数据库连接池压力:高 慢查询风险:高(特别是OFFSET分页) 主从延迟风险:高(写入后立即读取可能读到旧数据) Redis方案: 每秒 100 次读取 + 100 次写入 = 200 次Redis操作 Redis单线程也能轻松处理(QPS可达10万+) 连接池压力:低 响应速度:稳定在1ms以内 ``` #### 那对话历史为什么还保存在MySQL? 既然Redis这么快,为什么不把对话历史也保存在Redis? **原因1:持久化要求** 对话历史是**审计数据**,必须永久保存。Redis虽然有RDB/AOF持久化,但: - RDB是定期快照,可能丢失最近的数据 - AOF虽然实时,但文件体积大,恢复慢 - Redis重启后数据可能丢失 MySQL的InnoDB引擎提供ACID事务保证,数据绝对不会丢失。 **原因2:复杂查询需求** 对话历史需要支持: - 分页查询(游标分页、传统分页) - 多条件筛选(按appId、userId、messageType) - 聚合统计(按应用统计对话数、按用户统计活跃度) - 关联查询(JOIN应用表、用户表) Redis只能做简单的Key-Value操作,无法支持这些复杂查询。 **原因3:数据分析需求** 运营需要分析: - 用户最常问的问题是什么? - 哪个应用的对话最多? - 用户活跃度趋势如何? 这些需要SQL聚合查询,Redis无法支持。 **原因4:合规性要求** 很多行业要求保留用户操作日志至少6个月,甚至永久保存。Redis的内存成本太高,不适合存储海量历史数据。 #### 存储方案总结 | 维度 | 对话历史(MySQL) | 对话记忆(Redis) | | ---------------- | ---------------------------- | ------------------------- | | **用途** | 审计、统计、回溯 | AI实时推理 | | **数据量** | 全量永久保存 | 最近20条,24小时TTL | | **字段** | 完整字段(appId、userId等) | 简化字段(role、content) | | **查询** | 复杂查询(分页、筛选、聚合) | 简单查询(GET、SET) | | **性能** | 5-10ms | 0.5-1ms | | **一致性** | 强一致(ACID) | 最终一致 | | **持久化** | 永久保存 | 可能丢失(可重建) | | **成本** | 磁盘存储,成本低 | 内存存储,成本高 | ### Redis初始化 我们先在控制台执行以下命令下载go-redis库,然后修改dal包下的 `init.go`文件 ```bash go get github.com/redis/go-redis/v9 v9.7.3 ``` **文件位置:** `internal/dal/init.go` #### 功能说明 负责项目核心基础设施的初始化,包括MySQL数据库连接和Redis客户端连接。而Redis是整个对话记忆系统的基础设施支撑,我们在原来的初始化内容增加一个Redis的provider方法用于注入。 #### 完整代码 ```go package dal import ( "fmt" "github.com/redis/go-redis/v9" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" "yikou-ai-go-teach/config" "yikou-ai-go-teach/internal/dal/query" ) // InitDB 初始化数据库连接 func InitDB(config *config.Config) *gorm.DB { if config == nil { panic(fmt.Errorf("配置加载失败")) } dsn := config.Database.GetDSN() db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Info), }) if err != nil { panic(fmt.Errorf("数据库连接失败: %w", err)) } query.SetDefault(db) return db } // InitRedis 初始化Redis连接 func InitRedis(config *config.Config) *redis.Client { if config == nil { panic(fmt.Errorf("配置加载失败")) } redisClient := redis.NewClient(&redis.Options{ Addr: fmt.Sprintf("%s:%d", config.Redis.Host, config.Redis.Port), Password: config.Redis.Password, DB: config.Redis.DB, }) return redisClient } ``` ### RedisMemoryStore实现 **文件位置:** `internal/store/memory_store.go` #### 功能说明 由于Eino官方不支持将对话记忆抽象为组件,所有我根据Eino官网的方案自定义了一个MemoryStore的接口,我们只需要自定义一种对话记忆存储方式实现然后在基础agent引用就行了。这里我实现了基于Redis的对话记忆存储,作为MemoryStore接口的具体实现。负责对话消息的持久化存储和读取。  #### 接口定义与实现 ```go package store import ( "context" "encoding/json" "fmt" "github.com/cloudwego/eino/schema" "github.com/redis/go-redis/v9" "time" ) // MemoryStore 对话记忆存储接口 type MemoryStore interface { GetMessages(ctx context.Context) ([]*schema.Message, error) AppendMessage(ctx context.Context, message *schema.Message) error } // RedisMemoryStore Redis实现的内存存储 type RedisMemoryStore struct { redisClient *redis.Client memoryId string maxMemoryMessages int ttl time.Duration } ``` #### 构造函数 ```go func NewRedisMemoryStore(redisClient *redis.Client, memoryId string, maxMemoryMessages int, ttl time.Duration) *RedisMemoryStore { return &RedisMemoryStore{ redisClient: redisClient, memoryId: memoryId, maxMemoryMessages: maxMemoryMessages, ttl: ttl, } } ``` #### 核心方法实现 ##### 获取消息列表(GetMessages) ```go func (r RedisMemoryStore) GetMessages(ctx context.Context) ([]*schema.Message, error) { key := fmt.Sprintf("memory:%s", r.memoryId) data, err := r.redisClient.Get(ctx, key).Bytes() if err != nil { return nil, err } return decodeMessagesFromJSON(data) } ``` ##### 追加消息(AppendMessage) ```go func (r RedisMemoryStore) AppendMessage(ctx context.Context, message *schema.Message) error { messages, err := r.GetMessages(ctx) if err != nil { if errors.Is(err, redis.Nil) { messages = []*schema.Message{} } else { return err } } messages = append(messages, message) messagesToJSON, err := encodeMessagesToJSON(messages) if err != nil { return err } key := fmt.Sprintf("memory:%s", r.memoryId) return r.redisClient.Set(ctx, key, messagesToJSON, r.ttl).Err() } ``` ##### 序列化辅助函数 ```go func encodeMessagesToJSON(msgs []*schema.Message) ([]byte, error) { return json.Marshal(msgs) } func decodeMessagesFromJSON(data []byte) ([]*schema.Message, error) { if len(data) == 0 { return nil, nil } var msgs []*schema.Message err := json.Unmarshal(data, &msgs) return msgs, err } ``` ### 修改基础Agent - Eino对话记忆核心实现 **文件位置:** `internal/ai/agent/base_agent.go` #### 修改结构体定义和构造函数 ```go type ChatModelWrapperAdaptor interface { GetChatModel() *openai.ChatModel GetModelName() string } type BaseAgent struct { model *openai.ChatModel modelName string memoryStore store.MemoryStore } func NewBaseAgent(chatModel ChatModelWrapperAdaptor, memoryStore store.MemoryStore) *BaseAgent { return &BaseAgent{ model: chatModel.GetChatModel(), modelName: chatModel.GetModelName(), memoryStore: memoryStore, } } ``` #### 修改流式生成方法(GenerateStream) ```go func (a *BaseAgent) GenerateStream(ctx context.Context, userMessage string, chatTemplate prompt.ChatTemplate, adkAgent *adk.ChatModelAgent) (*schema.StreamReader[*schema.Message], error) { // 1. 从Redis加载对话历史 messages, err := a.memoryStore.GetMessages(ctx) if err != nil { if errors.Is(err, redis.Nil) { messages = []*schema.Message{} } else { return nil, err } } // 2. 格式化Prompt(包含历史上下文) format, err := chatTemplate.Format(ctx, map[string]any{ "content": userMessage, "history": messages, }) if err != nil { return nil, err } err = a.memoryStore.AppendMessage(ctx, schema.UserMessage(userMessage)) if err != nil { return nil, err } // 3. 创建流式Runner runner := adk.NewRunner(ctx, adk.RunnerConfig{ Agent: adkAgent, EnableStreaming: true, }) iter := runner.Run(ctx, format) // 4. 创建管道用于流式传输 reader, writer := schema.Pipe[*schema.Message](2) // 5. 异步处理流数据 go func() { defer writer.Close() var fullContent string for { event, ok := iter.Next() if !ok { break } if event.Err != nil { writer.Send(nil, event.Err) return } if event.Output != nil && event.Output.MessageOutput != nil { stream := event.Output.MessageOutput.MessageStream if stream != nil { for { msg, err := stream.Recv() if err == io.EOF { break } if err != nil { writer.Send(nil, err) return } if msg != nil { fullContent += msg.Content writer.Send(msg, nil) } } } } } // 6. 将完整响应保存到Redis err := a.memoryStore.AppendMessage(ctx, schema.AssistantMessage(fullContent, nil)) if err != nil { logger.Errorf("保存对话记忆失败: %v", err) } }() return reader, nil } ``` ### 修改代码生成Agent **文件位置:** `internal/ai/agent/codegen_agent.go` ```go func NewCodeGenAgent(chatModel ChatModelWrapperAdaptor, codeGenType enum.CodeGenTypeEnum, memoryStore store.MemoryStore) *CodeGenAgent { baseAgent := NewBaseAgent(chatModel, memoryStore) return &CodeGenAgent{ BaseAgent: baseAgent, agentType: codeGenType, } } ``` ### 增加Agent工厂结构体 **文件位置:** `internal/ai/ai/agent/codegen_agent_factory.go` #### 为什么使用工厂模式? 在修改代码实现后,由于原有的智能体业务发生了较大的改动,于是在这里我引用工厂模式进行创建代码生成智能体。当然,在学习业务逻辑前,我们先理解为什么需要引入工厂模式。通过对比**没有工厂模式**和**有工厂模式**的代码,你会发现工厂模式解决了哪些核心问题。 ##### 问题场景:没有工厂模式时 假设我们直接在业务代码中创建 `CodeGenAgent`,会发生什么? **场景:用户请求生成HTML代码** ```go // 在 Handler 或 Service 中直接创建 Agent func (h *AppHandler) ChatToGenCode(c *gin.Context) { // ... 获取参数 redisStore := store.NewRedisMemoryStore( h.redisClient, // 需要传递Redis客户端 strconv.Itoa(int(appId)), // 需要手动转换类型 20, // 魔法数字:最大消息数 24*time.Hour, // 魔法数字:TTL ) agent := agent.NewCodeGenAgent( h.chatModel, // 需要传递ChatModel codeGenType, // 需要传递类型 redisStore, // 需要传递刚创建的Store ) // ... } ``` **这段代码有什么问题?** **问题1:重复代码** 每次需要Agent时,都要重复这段创建逻辑: ``` 创建RedisStore → 配置参数 → 创建Agent → 传递依赖 ``` 如果项目中有10个地方需要创建Agent,这段代码就要复制10次。 **问题2:违反开闭原则** 假设我们要修改RedisMemoryStore的配置: ```go // 从 20条消息 改为 30条消息 redisStore := store.NewRedisMemoryStore( h.redisClient, strconv.Itoa(int(appId)), 30, // ← 修改这里 24*time.Hour, ) ``` 我们需要找到所有创建Agent的地方,逐一修改。如果有10处,就要改10次。 ##### 解决方案:引入工厂模式 工厂模式的核心思想:**把对象的创建逻辑封装到一个专门的类中**。 ```go // 工厂类:专门负责创建 CodeGenAgent type CodeGenAgentFactory struct { chatModel *llm.ChatModelWrapper // 持有依赖 redisClient *redis.Client // 持有依赖 chatHistoryService service.IChatHistoryService // 持有依赖 } // 工厂方法:封装创建逻辑 func (c CodeGenAgentFactory) GetCodeGenAgent(appId int64, codeGenType enum.CodeGenTypeEnum) (*CodeGenAgent, error) { // 所有创建逻辑都在这里 redisStore := store.NewRedisMemoryStore(c.redisClient, strconv.Itoa(int(appId)), 20, 24*time.Hour) return NewCodeGenAgent(c.chatModel, codeGenType, redisStore), nil } ``` **业务代码变得简洁:** ```go // 在 Handler 或 Service 中 func (h *AppHandler) ChatToGenCode(c *gin.Context) { // ... 获取参数 // 一行代码创建Agent,所有细节都被封装 agent, err := h.agentFactory.GetCodeGenAgent(appId, codeGenType) if err != nil { // 错误处理 } // 业务逻辑清晰,不被创建逻辑干扰 stream, err := agent.GenerateHtmlCodeStream(ctx, userMessage) // ... } ``` #### 结构体定义 ```go type CodeGenAgentFactory struct { chatModel *llm.ChatModelWrapper redisClient *redis.Client chatHistoryService service.IChatHistoryService } ``` #### 构造函数 ```go func NewCodeGenAgentFactory(chatModel *llm.ChatModelWrapper, redisClient *redis.Client, chatHistoryService service.IChatHistoryService) *CodeGenAgentFactory { return &CodeGenAgentFactory{ chatModel: chatModel, redisClient: redisClient, chatHistoryService: chatHistoryService, } } ``` #### 创建Agent方法 ```go func (c CodeGenAgentFactory) GetCodeGenAgent(appId int64, codeGenType enum.CodeGenTypeEnum) (*CodeGenAgent, error) { redisStore := store.NewRedisMemoryStore(c.redisClient, strconv.Itoa(int(appId)), 20, 24*time.Hour) return NewCodeGenAgent(c.chatModel, codeGenType, redisStore), nil } ``` ### 修改门面结构体 **文件位置:** `internal/core/ai_codegen_facade.go` ```go package core import ( "context" "fmt" "io" "strings" "github.com/bytedance/gopkg/util/logger" "github.com/cloudwego/eino/schema" "yikou-ai-go-teach/internal/ai/agent" "yikou-ai-go-teach/internal/core/parser" "yikou-ai-go-teach/internal/core/saver" "yikou-ai-go-teach/pkg/enum" ) // YiKouAiCodegenFacade 代码生成门面 type YiKouAiCodegenFacade struct { codeGenFactory *agent.CodeGenAgentFactory // Agent工厂 codeParserExecutor *parser.CodeParserExecutor // 代码解析器 codeFileSaverExecutor *saver.CodeFileSaverExecutor // 代码保存器 } // NewYiKouAiCodegenFacade 构造函数 func NewYiKouAiCodegenFacade( codeGenFactory *agent.CodeGenAgentFactory, codeParserExecutor *parser.CodeParserExecutor, codeFileSaverExecutor *saver.CodeFileSaverExecutor, ) *YiKouAiCodegenFacade { return &YiKouAiCodegenFacade{ codeGenFactory: codeGenFactory, codeParserExecutor: codeParserExecutor, codeFileSaverExecutor: codeFileSaverExecutor, } } // GenCodeStreamAndSave 流式代码生成并保存(核心方法) func (y *YiKouAiCodegenFacade) GenCodeStreamAndSave( ctx context.Context, userMessage string, typeStr enum.CodeGenTypeEnum, appId int64, ) (*schema.StreamReader[*schema.Message], error) { // 创建Agent genAgent, err := y.codeGenFactory.GetCodeGenAgent(appId, typeStr) if err != nil { return nil, err } // 根据类型调用不同的生成方法 switch typeStr { case enum.HtmlCodeGen: streamResp, err := genAgent.GenerateHtmlCodeStream(ctx, userMessage) if err != nil { return nil, err } return y.processCodeStream(streamResp, typeStr, appId) case enum.MultiFileGen: streamResp, err := genAgent.GenerateMultiFileCodeStream(ctx, userMessage) if err != nil { return nil, err } return y.processCodeStream(streamResp, typeStr, appId) default: return nil, fmt.Errorf("不支持的代码生成类型: %s", typeStr) } } // processCodeStream 处理流式响应并异步保存 func (y *YiKouAiCodegenFacade) processCodeStream( respStream *schema.StreamReader[*schema.Message], typeStr enum.CodeGenTypeEnum, appId int64, ) (*schema.StreamReader[*schema.Message], error) { // 复制流 streams := respStream.Copy(2) processingStream := streams[0] returnStream := streams[1] // 异步处理 go func() { var builder strings.Builder defer processingStream.Close() // 读取流 for { chunk, err := processingStream.Recv() if err == io.EOF { break } if err != nil { return } builder.WriteString(chunk.Content) } // 解析和保存 parsedResp, err := y.codeParserExecutor.ExecuteParser(builder.String(), typeStr) if err != nil { return } dirPath, err := y.codeFileSaverExecutor.ExecuteSaver(parsedResp, typeStr, appId) if err != nil { return } logger.Info("代码已保存到目录: %s", dirPath) }() return returnStream, nil } ``` ### 修改依赖注入文件 **文件位置:** `wire/wire.go` ##### 修改点1:新增Redis客户端Provider ```go // 数据库依赖 var dbSet = wire.NewSet( dal.InitDB, // MySQL客户端 dal.InitRedis, // ← 新增:Redis客户端 ) ``` ##### 修改点2:新增ChatHistoryService注册并移除原来的agentService注入 ```go // Service依赖 var serviceSet = wire.NewSet( logic.NewAppService, wire.Bind(new(service.IAppService), new(*logic.AppService)), logic.NewUserService, wire.Bind(new(service.IUserService), new(*logic.UserService)), // ← 新增:ChatHistoryService logic.NewChatHistoryService, wire.Bind(new(service.IChatHistoryService), new(*logic.ChatHistoryService)), ) ``` ##### 修改点3:新增ChatHistoryHandler ```go // Handler依赖 var handlerSet = wire.NewSet( handler.NewUserHandler, handler.NewAppHandler, handler.NewChatHistoryHandler, ) ``` ##### 修改点4:新增CodeGenAgentFactory注册 ```go // 初始化函数 func InitializeApp() (*server.Hertz, error) { panic(wire.Build( initServer, configSet, dbSet, serviceSet, handlerSet, llmSet, parser.NewCodeParserExecutor, saver.NewCodeFileSaverExecutor, agent.NewCodeGenAgentFactory, // ← 新增:Agent工厂 core.NewYiKouAiCodegenFacade, )) } ``` ##### 修改点5:initServer方法 ```go // initServer 初始化 Web 服务器 func initServer(cfg *config.Config, userHandler *handler.UserHandler, appHandler *handler.AppHandler, db *gorm.DB, chatHistoryHandler *handler.ChatHistoryHandler) *server.Hertz { // 动态设置 Swagger 信息 docs.SwaggerInfo.Host = fmt.Sprintf("localhost:%d", cfg.Server.Port) docs.SwaggerInfo.BasePath = cfg.Server.ContextPath // 初始化swagger路径 swaggerPath := fmt.Sprintf("http://localhost:%d%s/swagger/doc.json", cfg.Server.Port, cfg.Server.ContextPath) url := swagger.URL(swaggerPath) // 创建 Hertz 服务器 h := server.Default( server.WithHostPorts(":"+strconv.Itoa(cfg.Server.Port)), server.WithBasePath(cfg.Server.ContextPath), ) // 注册路由 router.RegisterRoutes(h, url, db, userHandler, appHandler, chatHistoryHandler) return h } ``` **最后在wire目录下运行以下生成命令** ```bash wire gen wire/wire.go ``` 如果配置正确,会生成 `wire/wire_gen.go` 文件,无报错。 ### 测试对话记忆功能 直接启动前端服务器,然后随便生成一个应用  然后查看Redis保存记忆对话是否成功  可以得到对话记忆如下 ```json [ { "role": "user", "content": "做一个个人博客网站,包含文章列表和详情页面" }, { "role": "assistant", "content": "这是一个包含文章列表和详情功能的个人博客网站。它使用原生 JavaScript 模拟了单页面应用(SPA)的视图切换体验。\n\n```html\n\u003c!DOCTYPE html\u003e\n\u003chtml lang=\"zh-CN\"\u003e\n\u003chead\u003e\n \u003cmeta charset=\"UTF-8\"\u003e\n \u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"\u003e\n \u003ctitle\u003e我的极简博客\u003c/title\u003e\n \u003cstyle\u003e\n /* --- 全局重置与基础样式 --- */\n :root {\n --primary-color: #2c3e50;\n --accent-color: #3498db;\n --bg-color: #f9f9f9;\n --card-bg: #ffffff;\n --text-color: #333;\n --text-light: #666;\n --border-radius: 8px;\n --shadow: 0 4px 6px rgba(0,0,0,0.05);\n --transition: all 0.3s ease;\n }\n\n * {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n }\n\n body {\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n line-height: 1.6;\n color: var(--text-color);\n background-color: var(--bg-color);\n display: flex;\n flex-direction: column;\n min-height: 100vh;\n }\n\n a {\n text-decoration: none;\n color: inherit;\n }\n\n ul {\n list-style: none;\n }\n\n img {\n max-width: 100%;\n display: block;\n }\n\n /* --- 头部导航 --- */\n header {\n background-color: var(--card-bg);\n box-shadow: var(--shadow);\n position: sticky;\n top: 0;\n z-index: 100;\n }\n\n .nav-container {\n max-width: 1200px;\n margin: 0 auto;\n padding: 1rem 2rem;\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n\n .logo {\n font-size: 1.5rem;\n font-weight: 700;\n color: var(--primary-color);\n }\n\n .nav-links a {\n margin-left: 20px;\n font-weight: 500;\n color: var(--text-light);\n transition: var(--transition);\n }\n\n .nav-links a:hover {\n color: var(--accent-color);\n }\n\n /* --- 主要内容区域 --- */\n main {\n flex: 1;\n max-width: 1200px;\n margin: 2rem auto;\n padding: 0 2rem;\n width: 100%;\n }\n\n /* --- 文章列表视图 --- */\n .view-section {\n display: none; /* 默认隐藏所有视图 */\n animation: fadeIn 0.5s ease;\n }\n\n .view-section.active {\n display: block; /* 激活时显示 */\n }\n\n .page-title {\n margin-bottom: 2rem;\n font-size: 2rem;\n color: var(--primary-color);\n border-bottom: 2px solid var(--accent-color);\n display: inline-block;\n padding-bottom: 0.5rem;\n }\n\n .post-grid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));\n gap: 2rem;\n }\n\n .post-card {\n background: var(--card-bg);\n border-radius: var(--border-radius);\n overflow: hidden;\n box-shadow: var(--shadow);\n transition: var(--transition);\n cursor: pointer;\n display: flex;\n flex-direction: column;\n }\n\n .post-card:hover {\n transform: translateY(-5px);\n box-shadow: 0 10px 15px rgba(0,0,0,0.1);\n }\n\n .post-card-image {\n height: 200px;\n width: 100%;\n object-fit: cover;\n }\n\n .post-card-content {\n padding: 1.5rem;\n flex: 1;\n display: flex;\n flex-direction: column;\n }\n\n .post-meta {\n font-size: 0.85rem;\n color: var(--text-light);\n margin-bottom: 0.5rem;\n }\n\n .post-title {\n font-size: 1.25rem;\n margin-bottom: 0.75rem;\n color: var(--primary-color);\n }\n\n .post-excerpt {\n color: var(--text-light);\n font-size: 0.95rem;\n margin-bottom: 1.5rem;\n flex: 1;\n }\n\n .read-more {\n color: var(--accent-color);\n font-weight: 600;\n font-size: 0.9rem;\n align-self: flex-start;\n }\n\n /* --- 文章详情视图 --- */\n .back-btn {\n display: inline-flex;\n align-items: center;\n margin-bottom: 1.5rem;\n color: var(--text-light);\n cursor: pointer;\n font-weight: 500;\n transition: var(--transition);\n }\n\n .back-btn:hover {\n color: var(--accent-color);\n }\n\n .back-btn::before {\n content: '←';\n margin-right: 5px;\n font-size: 1.2rem;\n }\n\n .article-header {\n margin-bottom: 2rem;\n text-align: center;\n }\n\n .article-title {\n font-size: 2.5rem;\n margin-bottom: 1rem;\n color: var(--primary-color);\n }\n\n .article-cover {\n width: 100%;\n height: 400px;\n object-fit: cover;\n border-radius: var(--border-radius);\n margin-bottom: 2rem;\n box-shadow: var(--shadow);\n }\n\n .article-content {\n background: var(--card-bg);\n padding: 3rem;\n border-radius: var(--border-radius);\n box-shadow: var(--shadow);\n font-size: 1.1rem;\n line-height: 1.8;\n }\n\n .article-content p {\n margin-bottom: 1.5rem;\n }\n\n .article-content h2 {\n margin-top: 2rem;\n margin-bottom: 1rem;\n color: var(--primary-color);\n }\n\n /* --- 底部 --- */\n footer {\n background-color: var(--primary-color);\n color: #fff;\n text-align: center;\n padding: 2rem;\n margin-top: auto;\n }\n\n /* --- 动画 --- */\n @keyframes fadeIn {\n from { opacity: 0; transform: translateY(10px); }\n to { opacity: 1; transform: translateY(0); }\n }\n\n /* --- 响应式调整 --- */\n @media (max-width: 768px) {\n .nav-container {\n flex-direction: column;\n gap: 1rem;\n }\n \n .nav-links a {\n margin: 0 10px;\n }\n\n .article-title {\n font-size: 1.8rem;\n }\n\n .article-cover {\n height: 250px;\n }\n\n .article-content {\n padding: 1.5rem;\n }\n }\n \u003c/style\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n\n \u003c!-- 导航栏 --\u003e\n \u003cheader\u003e\n \u003cdiv class=\"nav-container\"\u003e\n \u003ca href=\"#\" class=\"logo\" onclick=\"app.showHome()\"\u003eMyBlog\u003c/a\u003e\n \u003cnav class=\"nav-links\"\u003e\n \u003ca href=\"#\" onclick=\"app.showHome()\"\u003e首页\u003c/a\u003e\n \u003ca href=\"#\"\u003e关于我\u003c/a\u003e\n \u003ca href=\"#\"\u003e联系\u003c/a\u003e\n \u003c/nav\u003e\n \u003c/div\u003e\n \u003c/header\u003e\n\n \u003c!-- 主内容区 --\u003e\n \u003cmain\u003e\n \u003c!-- 1. 文章列表视图 --\u003e\n \u003csection id=\"list-view\" class=\"view-section active\"\u003e\n \u003ch1 class=\"page-title\"\u003e最新文章\u003c/h1\u003e\n \u003cdiv class=\"post-grid\" id=\"post-container\"\u003e\n \u003c!-- 文章卡片将通过 JS 插入这里 --\u003e\n \u003c/div\u003e\n \u003c/section\u003e\n\n \u003c!-- 2. 文章详情视图 --\u003e\n \u003csection id=\"detail-view\" class=\"view-section\"\u003e\n \u003cdiv class=\"back-btn\" onclick=\"app.showHome()\"\u003e返回列表\u003c/div\u003e\n \n \u003carticle id=\"article-container\"\u003e\n \u003c!-- 文章详情将通过 JS 插入这里 --\u003e\n \u003c/article\u003e\n \u003c/section\u003e\n \u003c/main\u003e\n\n \u003c!-- 页脚 --\u003e\n \u003cfooter\u003e\n \u003cp\u003e\u0026copy; 2023 My Personal Blog. All rights reserved.\u003c/p\u003e\n \u003cp style=\"font-size: 0.8rem; margin-top: 0.5rem; opacity: 0.7;\"\u003eDesigned with Native HTML/CSS/JS\u003c/p\u003e\n \u003c/footer\u003e\n\n \u003cscript\u003e\n // --- 模拟数据 ---\n const postsData = [\n {\n id: 1,\n title: \"探索现代 Web 开发的边界\",\n date: \"2023-10-24\",\n image: \"https://picsum.photos/id/1/800/600\",\n excerpt: \"随着技术的飞速发展,前端开发变得越来越复杂。本文将探讨如何在保持代码简洁的同时构建高性能应用。\",\n content: `\n \u003cp\u003eWeb 开发领域正在经历一场前所未有的变革。从简单的静态页面到复杂的单页应用(SPA),再到如今的服务器端渲染(SSR)和边缘计算,我们手中的工具越来越强大,但同时也带来了更多的挑战。\u003c/p\u003e\n \u003ch2\u003e性能优化的重要性\u003c/h2\u003e\n \u003cp\u003e在移动设备普及的今天,性能不再是锦上添花,而是生存之本。用户对于加载时间的容忍度极低。我们需要关注核心 Web 指标(Core Web Vitals),如 LCP、FID 和 CLS。\u003c/p\u003e\n \u003cp\u003e优化不仅仅是压缩代码,更包括合理的资源加载策略、图片优化以及高效的渲染路径。原生 JavaScript 的性能往往优于庞大的框架,因此在某些场景下,回归原生可能是一个明智的选择。\u003c/p\u003e\n \u003ch2\u003e保持代码的可维护性\u003c/h2\u003e\n \u003cp\u003e无论使用什么技术栈,代码的可读性和可维护性始终是关键。良好的命名规范、组件化设计以及清晰的文档,能让团队协作更加顺畅。\u003c/p\u003e\n \u003cp\u003eLorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\u003c/p\u003e\n `\n },\n {\n id: 2,\n title: \"极简主义设计的艺术\",\n date: \"2023-10-18\",\n image: \"https://picsum.photos/id/20/800/600\",\n excerpt: \"少即是多。在信息过载的时代,极简设计不仅是一种美学选择,更是一种功能性的必需。\",\n content: `\n \u003cp\u003e极简主义(Minimalism)不仅仅意味着“少”,它意味着“恰到好处”。在设计中,每一个元素都应该有其存在的理由。多余的装饰会分散用户的注意力,降低信息的传递效率。\u003c/p\u003e\n \u003ch2\u003e留白的力量\u003c/h2\u003e\n \u003cp\u003e留白(White Space)是极简设计的核心。它不是浪费空间,而是为了突出内容,给用户的眼睛提供休息的区域。合理的留白能提升阅读体验,让页面看起来更加高端和精致。\u003c/p\u003e\n \u003cp\u003eDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\u003c/p\u003e\n \u003ch2\u003e色彩与排版\u003c/h2\u003e\n \u003cp\u003e在极简设计中,色彩通常被限制在少数几种,通常是一个主色调加上中性色。排版则成为了视觉的主角,字体的选择、大小、行高都至关重要。\u003c/p\u003e\n `\n },\n {\n id: 3,\n title: \"JavaScript 异步编程指南\",\n date: \"2023-10-10\",\n image: \"https://picsum.photos/id/48/800/600\",\n excerpt: \"从回调地狱到 Promise,再到 Async/Await,理解 JavaScript 的异步机制是成为高级开发者的必经之路。\",\n content: `\n \u003cp\u003eJavaScript 是单线程语言,这意味着它一次只能执行一个任务。为了处理耗时操作(如网络请求、文件读取),异步编程应运而生。\u003c/p\u003e\n \u003ch2\u003ePromises 的崛起\u003c/h2\u003e\n \u003cp\u003ePromise 对象代表一个异步操作的最终完成(或失败)及其结果值。它解决了回调地狱的问题,让代码逻辑更加清晰。我们可以使用 .then() 和 .catch() 来处理结果和错误。\u003c/p\u003e\n \u003cp\u003eUt enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.\u003c/p\u003e\n \u003ch2\u003eAsync/Await 的优雅\u003c/h2\u003e\n \u003cp\u003eAsync/Await 是基于 Promise 的语法糖,它让异步代码看起来像同步代码一样。这使得错误处理变得更加直观,可以使用 try/catch 块来捕获异常。\u003c/p\u003e\n `\n },\n {\n id: 4,\n title: \"CSS Grid 布局实战\",\n date: \"2023-09-28\",\n image: \"https://picsum.photos/id/60/800/600\",\n excerpt: \"告别浮动和定位,CSS Grid 带来了二维布局的革命。本文将通过实战案例展示 Grid 的强大之处。\",\n content: `\n \u003cp\u003eCSS Grid Layout 是 CSS 中最强大的布局系统。与 Flexbox(一维布局)不同,Grid 允许我们在行和列上同时控制元素的位置。\u003c/p\u003e\n \u003ch2\u003e基本概念\u003c/h2\u003e\n \u003cp\u003e要使用 Grid,首先需要将容器设置为 display: grid。然后,我们可以使用 grid-template-columns 和 grid-template-rows 来定义网格结构。\u003c/p\u003e\n \u003cp\u003eSed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.\u003c/p\u003e\n \u003ch2\u003e响应式网格\u003c/h2\u003e\n \u003cp\u003eGrid 非常适合响应式设计。通过使用 auto-fit 和 minmax() 函数,我们可以轻松创建能够根据屏幕宽度自动调整列数的布局,而无需编写大量的媒体查询。\u003c/p\u003e\n `\n }\n ];\n\n // --- 应用程序逻辑 ---\n const app = {\n // 初始化\n init: function() {\n this.renderPostList();\n },\n\n // 渲染文章列表\n renderPostList: function() {\n const container = document.getElementById('post-container');\n container.innerHTML = ''; // 清空容器\n\n postsData.forEach(post =\u003e {\n const card = document.createElement('div');\n card.className = 'post-card';\n card.onclick = () =\u003e this.showDetail(post.id); // 绑定点击事件\n\n card.innerHTML = `\n \u003cimg src=\"${post.image}\" alt=\"${post.title}\" class=\"post-card-image\" loading=\"lazy\"\u003e\n \u003cdiv class=\"post-card-content\"\u003e\n \u003cdiv class=\"post-meta\"\u003e${post.date}\u003c/div\u003e\n \u003ch3 class=\"post-title\"\u003e${post.title}\u003c/h3\u003e\n \u003cp class=\"post-excerpt\"\u003e${post.excerpt}\u003c/p\u003e\n \u003cspan class=\"read-more\"\u003e阅读全文 →\u003c/span\u003e\n \u003c/div\u003e\n `;\n container.appendChild(card);\n });\n },\n\n // 渲染文章详情\n showDetail: function(id) {\n const post = postsData.find(p =\u003e p.id === id);\n if (!post) return;\n\n const container = document.getElementById('article-container');\n \n // 构建详情 HTML\n container.innerHTML = `\n \u003cheader class=\"article-header\"\u003e\n \u003ch1 class=\"article-title\"\u003e${post.title}\u003c/h1\u003e\n \u003cdiv class=\"post-meta\" style=\"font-size: 1rem;\"\u003e发布于 ${post.date}\u003c/div\u003e\n \u003c/header\u003e\n \u003cimg src=\"${post.image}\" alt=\"${post.title}\" class=\"article-cover\"\u003e\n \u003cdiv class=\"article-content\"\u003e\n ${post.content}\n \u003c/div\u003e\n `;\n\n // 切换视图\n this.switchView('detail');\n // 滚动到顶部\n window.scrollTo(0, 0);\n },\n\n // 返回首页\n showHome: function() {\n this.switchView('list');\n window.scrollTo(0, 0);\n },\n\n // 视图切换逻辑\n switchView: function(viewName) {\n const listView = document.getElementById('list-view');\n const detailView = document.getElementById('detail-view');\n\n if (viewName === 'list') {\n listView.classList.add('active');\n detailView.classList.remove('active');\n } else {\n listView.classList.remove('active');\n detailView.classList.add('active');\n }\n }\n };\n\n // 启动应用\n document.addEventListener('DOMContentLoaded', () =\u003e {\n app.init();\n });\n \u003c/script\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n```\n\n### 代码说明:\n\n1. **HTML 结构**:\n * 页面分为 `header`(导航)、`main`(内容)和 `footer`(页脚)。\n * `main` 标签内包含两个主要的 `section`:`#list-view`(文章列表)和 `#detail-view`(文章详情)。通过 CSS 类 `.active` 来控制哪个视图显示。\n2. **CSS 样式**:\n * 使用了 CSS 变量 (`:root`) 来管理颜色,方便统一修改主题。\n * 使用了 **CSS Grid** (`display: grid`) 来实现文章列表的响应式布局,自动适应屏幕宽度。\n * 使用了 **Flexbox** 来处理导航栏和卡片内部的布局。\n * 添加了简单的 `@keyframes` 动画,使视图切换更加平滑。\n * 包含了针对移动端的媒体查询 (`@media`),确保在手机上也美观。\n3. **JavaScript 逻辑**:\n * **数据模拟**: 创建了一个 `postsData` 数组,包含文章的 ID、标题、日期、图片 URL、摘要和详细内容。\n * **状态管理**: 定义了一个 `app` 对象来封装逻辑。\n * **渲染函数**:\n * `renderPostList()`: 遍历数据数组,动态生成 HTML 卡片并插入页面。\n * `showDetail(id)`: 根据 ID 查找对应文章,生成详情页 HTML,并切换到详情视图。\n * **视图切换**: `switchView()` 函数通过添加/移除 CSS 类来隐藏或显示不同的 section,实现了类似单页应用(SPA)的效果,无需刷新页面。" } ] ``` ## 四、Go-cache缓存优化 在上一节中,我们实现了基本的Agent工厂模式,每次调用 `GetCodeGenAgent` 都会创建一个新的Agent实例。但在实际生产环境中,这会带来严重的性能问题。本节将详细说明如何使用 **go-cache** 库优化Agent实例的管理。 ### 为什么需要缓存优化? #### 问题场景:无缓存时的性能瓶颈 假设我的网站每分钟有100个用户同时对话: ``` 每分钟的请求: 100个用户 × 每人发送1条消息 = 100次调用 GetCodeGenAgent 每次调用: 1. 创建 RedisMemoryStore(分配内存、初始化连接) 2. 创建 CodeGenAgent(分配内存、初始化字段) 3. 配置 Redis Key、TTL等参数 耗时: 创建 RedisMemoryStore:~1ms 创建 CodeGenAgent:~0.5ms 总耗时:~1.5ms 每分钟总耗时: 100次 × 1.5ms = 150ms 看起来不多?但考虑以下问题: ``` **问题1:内存泄漏** ``` 每次创建新的Agent实例: Agent实例大小:~2KB(包含字段、指针等) 每分钟创建: 100个实例 × 2KB = 200KB 每小时: 200KB × 60 = 12MB 每天: 12MB × 24 = 288MB 如果不释放,内存会持续增长! ``` **问题2:GC压力** ``` 频繁创建和销毁对象: → 增加垃圾回收器的负担 → 导致GC停顿时间变长 → 影响整体性能 ``` ### 解决方案:使用go-cache缓存Agent实例 **核心思想:** > 对于同一个应用的同一个代码生成类型,Agent实例是可以复用的。因为Agent本身是无状态的,状态存储在Redis中。 ### go-cache库介绍 **go-cache** 是一个纯Go实现的内存缓存库,特点: | 特性 | 说明 | | ------------------ | ----------------------- | | **自动过期** | 支持TTL,过期自动删除 | | **LRU淘汰** | 支持自定义淘汰策略 | | **线程安全** | 内置锁,并发安全 | | **简单易用** | API简洁,Set/Get/Delete | **安装:** ```bash go get github.com/patrickmn/go-cache ``` **基本用法:** ```go import "github.com/patrickmn/go-cache" // 创建缓存,默认过期时间30分钟,清理间隔10分钟 c := cache.New(30*time.Minute, 10*time.Minute) // 设置缓存 c.Set("key", value, cache.DefaultExpiration) // 获取缓存 if x, found := c.Get("key"); found { value := x.(MyType) } // 删除缓存 c.Delete("key") ``` ### 完整的缓存优化实现 下面是对于缓存每个逻辑步骤的详细讲解,最后我提供了完整的修改内容 #### 全局缓存和实例计数 ```go package agent import ( "github.com/patrickmn/go-cache" "github.com/redis/go-redis/v9" "strconv" "sync" "time" "yikou-ai-go-teach/internal/ai/llm" "yikou-ai-go-teach/internal/service" "yikou-ai-go-teach/internal/store" "yikou-ai-go-teach/pkg/enum" ) // MaxAgentInstances 最大Agent实例数量 const MaxAgentInstances = 1000 var ( // serviceCache 全局缓存,存储Agent实例 // 默认过期时间:30分钟 // 清理间隔:10分钟 serviceCache = cache.New(30*time.Minute, 10*time.Minute) // instanceCount 当前实例数量 instanceCount int // instanceCountMu 实例计数锁(并发安全) instanceCountMu sync.Mutex ) ``` #### 工厂结构体定义 ```go type CodeGenAgentFactory struct { chatModel *llm.ChatModelWrapper redisClient *redis.Client chatHistoryService service.IChatHistoryService } func NewCodeGenAgentFactory( chatModel *llm.ChatModelWrapper, redisClient *redis.Client, chatHistoryService service.IChatHistoryService, ) *CodeGenAgentFactory { // 注册淘汰回调,记录日志 serviceCache.OnEvicted(func(k string, v interface{}) { logger.Debugf("AI服务实例被移除,缓冲键: %v", k) }) return &CodeGenAgentFactory{ chatModel: chatModel, redisClient: redisClient, chatHistoryService: chatHistoryService, } } ``` #### 缓存Key构建 ```go // buildCacheKey 构建缓存Key // 格式:{appId}_{codeGenType} // 示例:100_HtmlCodeGen, 200_MultiFileGen func buildCacheKey(appId int64, codeGenType enum.CodeGenTypeEnum) string { return strconv.Itoa(int(appId)) + "_" + string(codeGenType) } ``` #### LRU淘汰策略 当缓存实例数达到上限时,需要淘汰最老的实例: ```go // evictOldest 淘汰最老的缓存项(LRU策略) func (c CodeGenAgentFactory) evictOldest() { items := serviceCache.Items() oldestKey := "" var oldestExpiration int64 // 遍历所有缓存项,找到过期时间最早的 for k, item := range items { if item.Expiration == 0 { continue // 永不过期的项,跳过 } if oldestKey == "" || item.Expiration < oldestExpiration { oldestExpiration = item.Expiration oldestKey = k } } // 删除最老的项 if oldestKey != "" { serviceCache.Delete(oldestKey) instanceCountMu.Lock() instanceCount-- instanceCountMu.Unlock() } } ``` **LRU(Least Recently Used)策略:** ``` 假设缓存已满(1000个实例): 当前缓存项: Key1: 过期时间 10:00 Key2: 过期时间 10:05 Key3: 过期时间 10:10 ... 淘汰策略: 找到过期时间最早的 → Key1 删除 Key1 释放一个位置 新实例: 创建新的Agent实例 放入缓存 ``` #### 核心方法:GetCodeGenAgent ```go func (c CodeGenAgentFactory) GetCodeGenAgent( appId int64, codeGenType enum.CodeGenTypeEnum, ) (*CodeGenAgent, error) { redisStore := store.NewRedisMemoryStore( c.redisClient, strconv.Itoa(int(appId)), 20, 24*time.Hour, ) // 1. 构建缓存Key key := buildCacheKey(appId, codeGenType) // 2. 尝试从缓存获取 if agent, found := serviceCache.Get(key); found { // 缓存命中,直接返回 return agent.(*CodeGenAgent), nil } // 3. 缓存未命中,检查实例数量 instanceCountMu.Lock() if instanceCount >= MaxAgentInstances { // 达到上限,淘汰最老的实例 c.evictOldest() } instanceCountMu.Unlock() // 4. 创建新的Agent实例 agent := NewCodeGenAgent(c.chatModel, codeGenType, redisStore) // 5. 放入缓存 serviceCache.Set(key, agent, cache.DefaultExpiration) // 6. 更新实例计数 instanceCountMu.Lock() instanceCount++ instanceCountMu.Unlock() return agent, nil } ``` ### 性能对比分析 #### 无缓存 vs 有缓存 **场景:同一应用,连续100次调用** ``` 无缓存: 每次调用: 创建 RedisMemoryStore: 1ms 创建 CodeGenAgent: 0.5ms 总耗时: 1.5ms 100次调用: 总耗时: 100 × 1.5ms = 150ms 创建实例数: 100个 内存占用: 100 × 2KB = 200KB 有缓存: 第1次调用: 缓存未命中 创建实例: 1.5ms 放入缓存: 0.1ms 总耗时: 1.6ms 第2-100次调用: 缓存命中 查缓存: 0.01ms 总耗时: 0.01ms 100次调用: 总耗时: 1.6ms + 99 × 0.01ms = 2.59ms 创建实例数: 1个 内存占用: 1 × 2KB = 2KB 性能提升: 耗时:150ms → 2.59ms(快58倍) 实例数:100 → 1(减少99%) 内存:200KB → 2KB(减少99%) ``` ### 并发安全性分析 #### 多个请求同时调用GetCodeGenAgent ``` 请求1: GetCodeGenAgent(100, HtmlCodeGen) 请求2: GetCodeGenAgent(100, HtmlCodeGen) ← 同时到达 请求3: GetCodeGenAgent(200, HtmlCodeGen) 可能的竞态条件: 请求1和请求2同时发现缓存未命中 → 都创建新的Agent实例 → 都尝试放入缓存 → 重复创建 ``` **解决方案:instanceCountMu互斥锁** ```go instanceCountMu.Lock() if instanceCount >= MaxAgentInstances { c.evictOldest() } instanceCountMu.Unlock() ``` **注意:这里的锁只保护实例计数,不保护缓存操作** go-cache内部已经实现了线程安全: ```go // go-cache源码(简化) func (c *cache) Set(k string, x interface{}, d time.Duration) { c.mu.Lock() // 内部锁 // ... 设置操作 c.mu.Unlock() } func (c *cache) Get(k string) (interface{}, bool) { c.mu.Lock() // 内部锁 // ... 获取操作 c.mu.Unlock() } ``` ### 完整代码 ```go package agent import ( "github.com/bytedance/gopkg/util/logger" "github.com/patrickmn/go-cache" "github.com/redis/go-redis/v9" "strconv" "sync" "time" "yikou-ai-go-teach/internal/ai/llm" "yikou-ai-go-teach/internal/service" "yikou-ai-go-teach/internal/store" "yikou-ai-go-teach/pkg/enum" ) const MaxAgentInstances = 1000 var ( serviceCache = cache.New(30*time.Minute, 10*time.Minute) instanceCount int instanceCountMu sync.Mutex ) type CodeGenAgentFactory struct { chatModel *llm.ChatModelWrapper redisClient *redis.Client chatHistoryService service.IChatHistoryService } func NewCodeGenAgentFactory( chatModel *llm.ChatModelWrapper, redisClient *redis.Client, chatHistoryService service.IChatHistoryService, ) *CodeGenAgentFactory { serviceCache.OnEvicted(func(k string, v interface{}) { logger.Debugf("AI服务实例被移除,缓冲键: %v", k) }) return &CodeGenAgentFactory{ chatModel: chatModel, redisClient: redisClient, chatHistoryService: chatHistoryService, } } func (c CodeGenAgentFactory) evictOldest() { items := serviceCache.Items() oldestKey := "" var oldestExpiration int64 for k, item := range items { if item.Expiration == 0 { continue } if oldestKey == "" || item.Expiration < oldestExpiration { oldestExpiration = item.Expiration oldestKey = k } } if oldestKey != "" { serviceCache.Delete(oldestKey) instanceCountMu.Lock() instanceCount-- instanceCountMu.Unlock() } } func buildCacheKey(appId int64, codeGenType enum.CodeGenTypeEnum) string { return strconv.Itoa(int(appId)) + "_" + string(codeGenType) } func (c CodeGenAgentFactory) GetCodeGenAgent(appId int64, codeGenType enum.CodeGenTypeEnum) (*CodeGenAgent, error) { redisStore := store.NewRedisMemoryStore(c.redisClient, strconv.Itoa(int(appId)), 20, 24*time.Hour) key := buildCacheKey(appId, codeGenType) // 查缓存 if agent, found := serviceCache.Get(key); found { return agent.(*CodeGenAgent), nil } // 检查实例数 instanceCountMu.Lock() if instanceCount >= MaxAgentInstances { c.evictOldest() } instanceCountMu.Unlock() // 创建新实例 agent := NewCodeGenAgent(c.chatModel, codeGenType, redisStore) // 放入缓存 serviceCache.Set(key, agent, cache.DefaultExpiration) // 更新计数 instanceCountMu.Lock() instanceCount++ instanceCountMu.Unlock() return agent, nil } ``` ## 五、加载对话历史到Eino智能体的对话记忆中 在前面的章节中,我们实现了对话历史的MySQL持久化和Eino对话记忆的Redis存储。但存在一个关键问题:**如果Redis数据丢失(重启、过期、故障),对话记忆如何恢复?** 本节将详细说明如何从MySQL加载对话历史到Eino智能体的对话记忆中,实现**容灾恢复**和**冷启动优化**。 ### 为什么需要加载历史到记忆? **问题场景:Redis数据丢失** ``` 场景1:TTL过期 24小时未使用应用 → Redis Key过期被删除 用户重新使用应用 → AI从零开始 → 丢失之前的对话背景 场景2:Redis故障 Redis服务宕机 → 无法读取对话记忆 降级处理 → AI无历史上下文 → 体验下降 ``` **解决方案:从MySQL恢复到Redis** ### Service接口新增方法 **文件位置:** `internal/service/chat_history_service.go` ```go type IChatHistoryService interface { AddChatMessage(ctx context.Context, appId int64, message string, messageType enum.ChatHistoryMessageTypeEnum, userId int64) error DeleteByAppId(ctx context.Context, appId int64) error ListAppChatHistoryByPage(ctx context.Context, appId int64, pageSize int32, lastCreateTime time.Time, loginUser *vo.UserVo) (*response.PageResponse[*model.ChatHistory], error) ListAllChatHistoryByPageForAdmin(ctx context.Context, pageNum int32, pageSize int32, queryRequest *api.YiKouChatHistoryQueryRequest) (*response.PageResponse[*model.ChatHistory], error) LoadChatHistoryToMemory(ctx context.Context, appId int64, memoryStore store.MemoryStore, maxCount int) (int, error) } ``` ### Logic层实现 **文件位置:** `internal/logic/chat_history_logic.go` ```go func (s *ChatHistoryService) LoadChatHistoryToMemory( ctx context.Context, appId int64, memoryStore store.MemoryStore, maxCount int, ) (int, error) { // 1. 查询摘要消息(如果有) historySummary, err := query.Use(s.db).ChatHistory. Where( query.ChatHistory.AppID.Eq(appId), query.ChatHistory.MessageType.Eq(string(enum.SummaryMessageType)), ). Order(query.ChatHistory.CreateTime.Desc()). Limit(maxCount). Find() var historyList []*model.ChatHistory // 2. 根据摘要情况决定加载策略 if err != nil && historySummary == nil { // 2.1 无摘要:加载最近的对话历史 historyList, err = query.Use(s.db).ChatHistory. Where(query.ChatHistory.AppID.Eq(appId)). Order(query.ChatHistory.CreateTime.Desc()). Limit(maxCount). Find() if err != nil { return 0, err } // 排除第一条(因为第一条是当前正在进行的对话) if len(historyList) > 1 { historyList = historyList[1:] } } else { // 2.2 有摘要:只加载摘要消息 historyList = historySummary } // 3. 清空MemoryStore(避免重复) err = memoryStore.ClearMessages(ctx) if err != nil { return 0, err } // 4. 按时间正序添加消息(从旧到新) loadedCount := 0 for i := len(historyList) - 1; i >= 0; i-- { history := historyList[i] // 根据消息类型创建不同的Message if history.MessageType == string(enum.UserMessageType) { err = memoryStore.AppendMessage(ctx, schema.UserMessage(history.Message)) if err != nil { return loadedCount, err } loadedCount++ } else if history.MessageType == string(enum.AIMessageType) { err = memoryStore.AppendMessage(ctx, schema.AssistantMessage(history.Message, nil)) if err != nil { return loadedCount, err } loadedCount++ } else if history.MessageType == string(enum.SummaryMessageType) { err = memoryStore.AppendMessage(ctx, schema.SystemMessage(history.Message)) if err != nil { return loadedCount, err } loadedCount++ } } return loadedCount, nil } ``` ### 修改Factory获取agent方法 **文件位置:** `internal/ai/agent/codegen_agent_factory.go` ```go func (c CodeGenAgentFactory) GetCodeGenAgent( appId int64, codeGenType enum.CodeGenTypeEnum, ) (*CodeGenAgent, error) { // 1. 创建RedisMemoryStore redisStore := store.NewRedisMemoryStore( c.redisClient, strconv.Itoa(int(appId)), 20, 24*time.Hour, ) // 2. 新增:从MySQL加载对话历史到Redis _, err := c.chatHistoryService.LoadChatHistoryToMemory( context.Background(), appId, redisStore, 20, ) if err != nil { return nil, err } if err != nil { return nil, err } // 3. 构建缓存Key key := buildCacheKey(appId, codeGenType) // 4. 尝试从缓存获取 if agent, found := serviceCache.Get(key); found { return agent.(*CodeGenAgent), nil } // 5. 检查实例数量 instanceCountMu.Lock() if instanceCount >= MaxAgentInstances { c.evictOldest() } instanceCountMu.Unlock() // 6. 创建CodeGenAgent agent := NewCodeGenAgent(c.chatModel, codeGenType, redisStore) // 7. 放入缓存 serviceCache.Set(key, agent, cache.DefaultExpiration) // 8. 更新计数 instanceCountMu.Lock() instanceCount++ instanceCountMu.Unlock() return agent, nil } ``` ### 重新测试 若Redis之前还保留着对话记忆,你可以直接删除键值对,然后重新询问智能体你还记得我刚刚要求你干什么吗,测试是否能回答出  可以看出来,加载对话历史的功能成功 ## 六、使用Redis优化用户登录功能 在对话历史模块中,我们使用Redis作为对话记忆的存储介质。为了保持技术栈的一致性,本节将用户登录功能也迁移到Redis,实现统一的会话管理方案。 ### 为什么使用Redis存储用户登录状态? #### 传统Session方案的问题 ```go // 全局map存储session var sessions = make(map[string]*User) func Login(user *User) string { sessionId := generateSessionId() sessions[sessionId] = user // 存储在内存中 return sessionId } ``` **问题:** - **单机限制**:无法支持多实例部署 - **重启丢失**:服务重启后所有用户需要重新登录 - **内存泄漏**:长时间运行可能导致内存溢出 - **无法共享**:多个服务实例无法共享session #### Redis方案的优势 1. **高性能**:Redis基于内存,读写速度极快(微秒级) 2. **自动过期**:TTL机制自动清理过期session,无需手动维护 3. **分布式支持**:多个服务实例共享同一个Redis,实现分布式session 4. **持久化**:Redis支持RDB/AOF持久化,重启不丢失数据 5. **高可用**:支持主从复制、哨兵、集群模式 ### Service 层修改 **文件位置:** `internal/logic/user_logic.go` #### UserService 结构体修改 注入 `redisClient` 依赖: ```go type UserService struct { db *gorm.DB redisClient *redis.Client // ← 新增:Redis客户端 } func NewUserService(db *gorm.DB, redisClient *redis.Client) *UserService { return &UserService{ db: db, redisClient: redisClient, } } ``` #### UserLogin 方法:用户登录 **功能说明:** 用户登录成功后,将用户信息存入Redis,并返回sessionId。 **完整代码:** ```go func (s *UserService) UserLogin(ctx context.Context, req *api.YiKouUserLoginRequest, c *app.RequestContext) (vo.UserVo, error) { // 1. 校验参数 if req.UserAccount == "" || req.UserPassword == "" { return vo.UserVo{}, errorutil.ParamsError } // 2. 校验用户是否存在 user, err := query.Use(s.db).User.Where(query.User.UserAccount.Eq(req.UserAccount)).First() if err != nil { return vo.UserVo{}, err } // 3. 校验密码是否正确 encryptPassword := s.GetEncryptPassword(ctx, req.UserPassword) if user.UserPassword != encryptPassword { return vo.UserVo{}, errorutil.ParamsError.WithMessage("密码错误") } // 4. 生成 sessionId sessionId := fmt.Sprintf("session:%d", time.Now().UnixNano()) // 关键步骤:将用户信息转换为json并存入Redis userJson, err := json.Marshal(user) if err != nil { return vo.UserVo{}, err } err = s.redisClient.Set(ctx, sessionId, string(userJson), 24*time.Hour).Err() if err != nil { return vo.UserVo{}, err } // 6. 保存sessionId到cookie c.SetCookie(constants.UserLoginState, sessionId, 86400, "/", "", protocol.CookieSameSiteLaxMode, false, true) // 7. 构建userVo对象 loginUserVo := vo.UserVo{ ID: user.ID, UserAccount: user.UserAccount, UserName: user.UserName, UserAvatar: user.UserAvatar, UserProfile: user.UserProfile, UserRole: user.UserRole, CreateTime: user.CreateTime, UpdateTime: user.UpdateTime, } return loginUserVo, nil } ``` #### GetLoginUserVo 方法:获取登录用户信息 **功能说明:** 从Redis中获取当前登录用户的详细信息。 **完整代码:** ```go func (s *UserService) GetLoginUserVo(ctx context.Context, c *app.RequestContext) (vo.UserVo, error) { // 1. 获取sessionId(从Cookie中) sessionId := c.Request.Header.Cookie(constants.UserLoginState) if sessionId == nil { return vo.UserVo{}, errorutil.ParamsError } // 2. URL解码sessionId decodedSessionId, err := url.QueryUnescape(string(sessionId)) if err != nil { return vo.UserVo{}, err } // 关键步骤:从Redis获取用户信息 userJson, err := s.redisClient.Get(ctx, decodedSessionId).Result() if err != nil { return vo.UserVo{}, errorutil.ParamsError.WithMessage("登录已过期,请重新登录") } // 4. 反序列化用户信息 var user model.User err = json.Unmarshal([]byte(userJson), &user) if err != nil { return vo.UserVo{}, err } // 5. 校验用户是否存在(双重校验) _, err = query.Use(s.db).User.Where(query.User.ID.Eq(user.ID), query.User.IsDelete.Eq(0)).First() if err != nil { return vo.UserVo{}, err } // 6. 构建 UserVo loginUserVo := vo.UserVo{ ID: user.ID, UserAccount: user.UserAccount, UserName: user.UserName, UserAvatar: user.UserAvatar, UserProfile: user.UserProfile, UserRole: user.UserRole, CreateTime: user.CreateTime, UpdateTime: user.UpdateTime, } return loginUserVo, nil } ``` #### Logout 方法:用户登出 **功能说明:** 从Redis中删除用户session,实现登出功能。 **完整代码:** ```go func (s *UserService) Logout(ctx context.Context, c *app.RequestContext) error { // 1. 获取sessionId sessionId := c.Request.Header.Cookie(constants.UserLoginState) if sessionId == nil { return errorutil.ParamsError.WithMessage("用户未登录") } // 2. URL解码sessionId decodedSessionId, err := url.QueryUnescape(string(sessionId)) if err != nil { return err } // 关键步骤:从Redis删除session _ = s.redisClient.Del(ctx, decodedSessionId).Err() // 4. 清除Cookie c.SetCookie(constants.UserLoginState, "", 0, "/", "", protocol.CookieSameSiteLaxMode, false, true) return nil } ``` ### Middleware修改 **文件位置:** `internal/middleware/auth.go` #### AuthMiddleware 函数修改 注入 `redisClient` 参数,从Redis获取用户信息进行鉴权。 **完整代码:** ```go // AuthMiddleware 鉴权中间件 func AuthMiddleware(roleEnum enum.UserRoleEnum, db *gorm.DB, redisClient *redis.Client) app.HandlerFunc { return func(ctx context.Context, c *app.RequestContext) { // 1. 校验权限 var decodeUser []byte if roleEnum != "" { // 2. 获取sessionId(从Cookie中) sessionId := c.Request.Header.Cookie(constants.UserLoginState) if sessionId == nil { c.JSON(200, errorutil.NotLoginError) c.Abort() return } // 3. URL解码sessionId decodedSessionId, err := url.QueryUnescape(string(sessionId)) if err != nil { c.JSON(200, errorutil.NotAuthError) c.Abort() return } // 关键步骤:从Redis获取用户信息 userJsonStr, err := redisClient.Get(ctx, decodedSessionId).Result() if err != nil { c.JSON(200, errorutil.NotLoginError.WithMessage("登录已过期,请重新登录")) c.Abort() return } decodeUser = []byte(userJsonStr) } // 5. 解析用户信息 var user model.User err := json.Unmarshal(decodeUser, &user) if err != nil { c.JSON(200, errorutil.SystemError.WithMessage(err.Error())) c.Abort() return } // 6. 校验用户权限等级是否符合要求 dbUser, err := query.Use(db).User.Where(query.User.ID.Eq(user.ID), query.User.IsDelete.Eq(0)).First() if err != nil { c.JSON(200, errorutil.NotAuthError) c.Abort() return } // 7. 校验角色权限 if roleEnum == enum.AdminRole && enum.UserRoleEnum(dbUser.UserRole) != roleEnum { c.JSON(200, errorutil.NotAuthError) c.Abort() return } c.Next(ctx) } } ``` ### Router 层修改 **文件位置:** `internal/router/router.go` #### RegisterRoutes 函数修改 在路由注册函数中注入 `redisClient` 参数,并传递给中间件。 **完整代码:** ```go // RegisterRoutes 注册路由 func RegisterRoutes(h *server.Hertz, url func(config *swagger.Config), db *gorm.DB, redisClient *redis.Client, userHandler *handler.UserHandler, appHandler *handler.AppHandler, chatHistoryHandler *handler.ChatHistoryHandler) { // 注册全局中间件 // 处理跨域问题 h.Use(cors.New(cors.Config{ AllowAllOrigins: true, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: false, MaxAge: 12 * time.Hour, })) // 全局异常处理 h.Use(recovery.Recovery(recovery.WithRecoveryHandler(CustomRecoveryHandler))) // 测试接口 h.GET("/ping", handler.Ping) // swaggo文档 h.GET("/swagger/*any", swagger.WrapHandler(swaggerFiles.Handler, url)) userRoute := h.Group("/user") { userRoute.POST("/register", userHandler.UserRegister) userRoute.POST("/login", userHandler.UserLogin) userRoute.GET("/get/vo", userHandler.GetUserVo) // 需要登录的接口(传递redisClient) userRoute.GET("/get/login", middleware.AuthMiddleware(enum.UserRole, db, redisClient), userHandler.GetLoginUser) userRoute.POST("/logout", middleware.AuthMiddleware(enum.UserRole, db, redisClient), userHandler.Logout) // 需要管理员权限的接口(传递redisClient) userRoute.POST("/add", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), userHandler.AddUser) userRoute.GET("/get", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), userHandler.GetUser) userRoute.POST("/delete", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), userHandler.DeleteUser) userRoute.POST("/update", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), userHandler.UpdateUser) userRoute.POST("/list/page/vo", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), userHandler.ListUserVoByPage) } appRoute := h.Group("/app") { appRoute.POST("/good/list/page/vo", appHandler.ListGoodApp) appRoute.GET("/get/vo", middleware.AuthMiddleware(enum.UserRole, db, redisClient), appHandler.GetAppVo) // 需要登录的接口(传递redisClient) appRoute.GET("/chat/gen/code", middleware.AuthMiddleware(enum.UserRole, db, redisClient), appHandler.ChatToGenCode) appRoute.POST("/my/list/page/vo", middleware.AuthMiddleware(enum.UserRole, db, redisClient), appHandler.ListMyApp) appRoute.POST("/add", middleware.AuthMiddleware(enum.UserRole, db, redisClient), appHandler.AddApp) appRoute.POST("/update", middleware.AuthMiddleware(enum.UserRole, db, redisClient), appHandler.UpdateApp) appRoute.POST("/delete", middleware.AuthMiddleware(enum.UserRole, db, redisClient), appHandler.DeleteApp) // 需要管理员权限的接口(传递redisClient) appRoute.POST("/admin/update", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), appHandler.AdminUpdateApp) appRoute.POST("/admin/delete", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), appHandler.AdminDeleteApp) appRoute.GET("/admin/get/vo", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), appHandler.AdminGetAppVo) appRoute.POST("/admin/list/page/vo", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), appHandler.AdminListApp) } // 聊天历史路由 chatHistoryRoute := h.Group("/chatHistory") { // 需要管理员权限的接口(传递redisClient) chatHistoryRoute.POST("/admin/list/page/vo", middleware.AuthMiddleware(enum.AdminRole, db, redisClient), chatHistoryHandler.ListAllChatHistoryByPageForAdmin) chatHistoryRoute.GET("/app/:appId", middleware.AuthMiddleware(enum.UserRole, db, redisClient), chatHistoryHandler.ListAppChatHistory) } } ``` ### Wire 依赖注入修改 **文件位置:** `wire/wire.go` ```go // initServer 初始化 Web 服务器 func initServer(cfg *config.Config, userHandler *handler.UserHandler, appHandler *handler.AppHandler, db *gorm.DB, redisClient *redis.Client, chatHistoryHandler *handler.ChatHistoryHandler) *server.Hertz { // 动态设置 Swagger 信息 docs.SwaggerInfo.Host = fmt.Sprintf("localhost:%d", cfg.Server.Port) docs.SwaggerInfo.BasePath = cfg.Server.ContextPath // 初始化swagger路径 swaggerPath := fmt.Sprintf("http://localhost:%d%s/swagger/doc.json", cfg.Server.Port, cfg.Server.ContextPath) url := swagger.URL(swaggerPath) // 创建 Hertz 服务器 h := server.Default( server.WithHostPorts(":"+strconv.Itoa(cfg.Server.Port)), server.WithBasePath(cfg.Server.ContextPath), ) // 注册路由 router.RegisterRoutes(h, url, db, redisClient, userHandler, appHandler, chatHistoryHandler) return h } ``` **在wire目录下执行生成命令** ```bash cd wire wire ``` 到这里,本章的教学内容就结束,这一章的内容量特别大,希望各位读者能好好消化。下一章我会增加难度,讲解如何实现智能体生成vue工程化项目代码。要是对该教程感兴趣的,可以star一下仓库 [https://github.com/FeiWuSama/yikou-ai-go](https://github.com/FeiWuSama/yikou-ai-go) 给予博主更多支持哦,谢谢各位看到这里的读者!
从零构建在线Excel:一个Java全栈工程师的实战记录
# 从零构建在线Excel:一个Java全栈工程师的实战记录 ## 我为什么要自己造这个轮子 说出来你可能不信,起因是公司内部一堆Excel文件满天飞。 财务部的预算表、运营部的数据看板、产品部的需求矩阵——每一次改一个数,就要在微信上重新传一遍文件。文件名从"最终版"进化到"最终最终版"再到"打死也不改了版",像极了程序员给变量起名。 市面上不是没有在线表格产品,腾讯文档、飞书表格都挺好用。但公司内网环境不允许直连外部服务,数据又不能出内网。私有化部署的那些方案,要么贵得离谱,要么定制成本太高。 于是我决定自己动手。一个全栈工程师的自我修养,不就是"没人做我来做"吗? 项目地址:https://github.com/DevYangJC/DataLoom **希望大家给给star,给我增加更新的动力,谢谢大家**   ## 不止是我用:这个项目给你的价值 要说清楚这件事——我不是在做一个"我的项目",我是在做一个"你也能用的零件"。 大多数技术教程里的项目,你照着写完就跑不起来了。要么跟作者的数据库强绑定,要么前端和后端耦合得像连体婴儿,要么硬编码了一堆作者公司才有的配置。说白了,那些代码离开作者的电脑就是个摆设。 DataLoom 从一开始就被设计成**可移植的**。什么意思?就是当你自己的项目需要一个在线表格功能时,你能把这个项目直接复制粘贴进去,改改配置就能跑,不用从零攻坚。 具体来说,我刻意做了这么几件事: **前端是一个独立的 SPA 应用。** 它不依赖特定的后端框架,跟后端只通过 REST API 通信。你后端是 Spring Boot、Go、Node,甚至 Python Flask,都无所谓——把 `excel-web-demo/src` 目录往你项目里一扔,配个 API 代理就行。前端不认后端是谁,只认 `/api/excel` 这个前缀。 **后端是一个独立的微服务。** 它有自己的端口(9191)、自己的数据库(H2),不跟你现有的业务代码搅在一起。你甚至不需要碰它的 Java 代码——启动一个 JAR 包,在线表格能力就有了。等业务量大了想替换掉 H2,改一行 `application.yml` 的数据库配置,MyBatis-Plus 自动适配,SQL 一行不用改。 **数据模型解耦干净。** 文档、Sheet、数据块三张表之间只用外键关联,不写存储过程、不依赖触发器、不用数据库专有特性。换成任何关系型数据库都只要建三张表,改改连接串就行。 **启动链路就是一个 shell 脚本的事。** 后端 `mvn spring-boot:run`,前端 `npm run dev`。如果打包成 Docker,就是一个 `docker-compose.yml`,两行 `services` 搞定。你的团队成员 clone 下来,两分钟之内就能在本地跑到"上传 → 编辑 → 导出"的完整闭环。 说白了,**DataLoom 不是你照着学的玩具项目,是你抄来就用的功能模块。** 你项目的管理后台缺一个数据看板?把一个 Excel 当模板上传进去,线上改数据、导出报表,直接搞定。你自己的 SaaS 产品需要给客户提供表格编辑能力?把这两个目录放进你的微服务集群,注册个域名,齐活。 后面讲的技术细节——分块存储、脏数据追踪、前端导出——都是在保证"能用"的前提下,做到"好用"。但"能用"这件事,在架构设计阶段就已经写死进去了。 ## 技术选型:少即是多 需求其实很明确:上传Excel → 在线编辑 → 导出Excel。听起来简单,但细节全是坑。 **前端**,Vue 3 + Vite + Element Plus。Vue 3的Composition API是这次选型里我最满意的决定——`<script setup>`语法写起来比Options API清爽太多了,逻辑拆成独立函数,不用再在`data`、`methods`、`computed`之间来回跳。Vite替代Webpack,冷启动和热更新都快了一个量级,改一行代码瞬间看到效果,开发体验上了不止一个台阶。 核心的表格渲染引擎,我锁定了[Luckysheet](https://github.com/dream-num/Luckysheet)。这是国内团队做的一个开源在线电子表格,长得跟Excel几乎一模一样。说实话,看到它的demo那天我差点没睡着——这不就是我要的东西吗?支持单元格编辑、公式计算、合并单元格、条件格式,甚至连图表都有。后来这个项目被字节跳动收购了,演变成了现在的Univer,但早期开源版本依然很好用。 **后端**,Spring Boot 2.1 + MyBatis-Plus,老牌组合,稳得一批。Java 8,不用解释。 关键的选择在**Excel解析和导出**这两块。解析选了Apache POI,老牌选手,虽然API丑得像上个世纪的东西,但胜在稳定,Excel 97到2007+通吃。导出我走了另一条路——前端用ExcelJS,后端用EasyExcel做补充。为什么?因为Luckysheet的数据在前端,前端的ExcelJS可以直接拿到每个单元格的样式信息(字体、颜色、边框),导出的效果几乎100%还原。如果走后端导出,光是把样式信息传到后端就够喝一壶的。 **数据库**,Demo阶段直接用H2嵌入式数据库,零配置启动。等你把代码clone下来,`mvn spring-boot:run`一行命令就能跑起来,MySQL都不用装。 ### 一图看全貌:现在的架构长什么样 ```mermaid flowchart TB subgraph 用户["👤 用户浏览器"] Upload["📤 上传 Excel 文件"] Edit["✏️ 在线编辑单元格"] Export["📥 导出 Excel"] end subgraph Frontend["🎨 Vue 3 + Vite 前端 (8080)"] Luckysheet["Luckysheet 表格引擎"] DirtyTracker["脏数据追踪 reactive{}"] ExcelJS["ExcelJS 导出引擎"] Router["Vue Router 路由"] end subgraph Backend["⚙️ Spring Boot 后端 (9191)"] Controller["ExcelFileController<br/>REST API"] Service["ExcelFileService<br/>业务逻辑"] Parser["POI 解析器<br/>WorkbookFactory"] ChunkStore["分块存储引擎<br/>每1000行一块"] end subgraph Storage["💾 数据层"] H2[("H2 嵌入式数据库<br/>excel_file / excel_sheet / chunk_data")] end Upload -->|"POST /api/excel/upload"| Controller Controller --> Parser Parser -->|"按Sheet逐行解析"| ChunkStore ChunkStore -->|"分批写入"| H2 Edit -->|"cellUpdated 事件"| Luckysheet Luckysheet -->|"markCellDirty()"| DirtyTracker DirtyTracker -->|"用户点保存<br/>POST /api/excel/batchUpdate"| Controller Controller --> Service Service -->|"定位 Chunk → 局部更新"| H2 Export -->|"用户点导出"| ExcelJS ExcelJS -->|"直接读 Luckysheet 数据"| Luckysheet Luckysheet -->|"GET /api/excel/{id}/data<br/>按块懒加载"| Controller Controller --> H2 Router -->|"文档列表"| Controller Controller -->|"GET /api/excel/list"| H2 style 用户 fill:#534AB7,color:#fff style Frontend fill:#4A90D9,color:#fff style Backend fill:#5CC9C1,color:#fff style Storage fill:#2C3E50,color:#fff ``` 这张图基本上就是 DataLoom 的全貌了。三个核心闭环: - **上传流**(紫色→蓝色→绿色→灰):文件从前端飞进后端,POI 拆解成 Luckysheet 格式,按 1000 行一块塞进 H2 - **编辑流**(蓝色闭环):Luckysheet 捕获每次编辑 → 脏数据追踪暂存 → 批量保存到后端 - **导出流**(蓝色闭环):ExcelJS 直接读前端 Luckysheet 数据生成 Blob 下载,不走网络 注意导出那条线——它根本不走后端。这就是为什么样式能做到 100% 还原的原因。后面会展开讲。 ## 分块存储:这个设计救了我的命 很多人做在线表格,第一反应就是把整个Excel的JSON存到数据库的一个字段里。50行的表格这么干没问题。5000行,勉强能撑。5万行呢?10万行呢? 一个10万行、20列的表格转成JSON大概有几十MB。把几十MB的JSON塞进数据库的一个字段里,MySQL直接给你脸色看——超长字段、查询慢、更新要全量替换,每一条都是死路。 我的方案是**分块存储**。每1000行切一块。用图说话: ```mermaid flowchart LR subgraph Excel["📊 原始 Excel 文件<br/>10万行 × 20列"] R0["行 0 ~ 999"] R1["行 1000 ~ 1999"] R2["行 2000 ~ 2999"] RD["..."] R99["行 99000 ~ 99999"] end subgraph DB["💾 excel_chunk 表"] C0[("Chunk #0<br/>chunk_index=0<br/>celldata_json<br/>~500KB")] C1[("Chunk #1<br/>chunk_index=1<br/>celldata_json<br/>~500KB")] C2[("Chunk #2<br/>chunk_index=2<br/>celldata_json<br/>~500KB")] CD["..."] C99[("Chunk #99<br/>chunk_index=99<br/>celldata_json<br/>~500KB")] end subgraph Edit["✏️ 编辑第 8888 行 C 列"] Locate["📍 8888 ÷ 1000 = Chunk #8"] Update["🔧 只更新 Chunk #8<br/>其余 99 块纹丝不动"] end R0 -->|"POI 解析"| C0 R1 -->|"POI 解析"| C1 R2 -->|"POI 解析"| C2 RD -->|"POI 解析"| CD R99 -->|"POI 解析"| C99 C8["Chunk #8"] -.->|"定位目标"| Locate Locate --> Update style Excel fill:#534AB7,color:#fff style DB fill:#2C3E50,color:#fff style Edit fill:#E74C3C,color:#fff ``` 传统做法是把整个 Excel 的 JSON 塞进数据库一个字段里。50 行的表格这么干没问题。5000 行勉强能撑。5 万行、10 万行?几十 MB 的 JSON 塞进去,查询慢、更新要全量替换,每一条都是死路。 分块之后,每个块只存 1000 行的单元格数据,JSON 大小控制在 500KB 左右。用户编辑第 8888 行的 C 列——8888 除以 1000 等于第 8 号块,API 只在这个块里找到那一行那一列,改了就完事。不用动其他 99 个块,IO 消耗约等于零。 这个设计还有一个额外好处:**懒加载**。打开一个 10 万行的表格,前端不用一次性把所有数据都拉下来。先加载文档的元信息(有哪些 Sheet、每个 Sheet 多少行),用户切换到某个 Sheet 时,再按块范围按需请求数据。滚动到哪加载到哪,体验跟本地 Excel 几乎没区别。 如果你也在做类似的东西,我建议别等到数据撑爆了再重构。分块存储这个决策,在架构阶段就定下来,后期改的成本太高了。 ## 上传解析:POI 的正确打开方式 上传 Excel 文件的流程,用一张时序图比文字直观得多: ```mermaid sequenceDiagram actor U as 👤 用户 participant FE as 🎨 Vue 前端 participant Ctrl as 🔧 ExcelFileController participant Svc as 📦 ExcelFileService participant POI as 📑 Apache POI participant DB as 💾 H2 数据库 U->>FE: 选择 Excel 文件 FE->>FE: el-upload 组件封装 FormData FE->>Ctrl: POST /api/excel/upload<br/>multipart/form-data Ctrl->>Ctrl: 生成 UUID 文件名<br/>保存到磁盘临时目录 Ctrl->>Svc: parseAndStore(filePath, uuidName) Svc->>POI: WorkbookFactory.create(inputStream) POI-->>Svc: Workbook 对象 Svc->>DB: INSERT excel_file 记录 loop 遍历每个 Sheet Svc->>DB: INSERT excel_sheet 记录 loop 每 1000 行一个 Chunk Svc->>POI: 逐行逐列读取单元格 POI-->>Svc: 原始 Cell 值 alt 日期类型 Svc->>Svc: DateUtil.isCellDateFormatted() ✅ else 公式类型 Svc->>POI: FormulaEvaluator.evaluate() POI-->>Svc: 计算结果值 else 数字/字符串 Svc->>Svc: 直接转换 end Svc->>Svc: 转成 Luckysheet celldata 格式 Svc->>DB: INSERT excel_chunk (celldata_json) end end Svc-->>Ctrl: 解析完成 Ctrl-->>FE: 200 OK + excelFileId FE->>FE: 跳转到编辑页 FE-->>U: 看到在线表格 🎉 ``` 1. 前端用 `<el-upload>` 组件把文件发到后端 2. 后端保存到磁盘,生成 UUID 文件名避免冲突 3. 用 Apache POI 的 `WorkbookFactory` 打开文件流 4. 遍历每个 Sheet → 遍历每一行 → 遍历每个单元格 5. 把单元格值转成 Luckysheet 能认的格式,按 1000 行分批写入数据库 第 5 步是重点。POI 解析出来的单元格类型五花八门——字符串、数字、日期、公式、布尔值、空白、错误。每种类型都要转换成 Luckysheet 的数据格式: ```json { "r": 0, "c": 1, "v": { "v": 8848, "m": "8848", "ct": { "fa": "General", "t": "n" } } } ``` 数字类型和日期类型是最容易搞混的。Excel里日期其实就是一个数字(从1900-01-01开始的天数),POI需要用`DateUtil.isCellDateFormatted()`来判断。我一开始没注意到这个细节,所有日期都变成了五位数,看着像身份证号,排查了半小时才发现是漏了日期检测。 公式的处理稍微复杂一点。POI读公式单元格时,`getCellType()`返回的是`FORMULA`类型,你需要额外用`FormulaEvaluator`去计算结果值。如果公式引用了其他Sheet的数据,`FormulaEvaluator`也可能算不出来(因为解析时可能还没加载到那个Sheet),这种情况就给个空值,让Luckysheet在前端自己算。 ## 在线编辑:脏数据追踪 Luckysheet 提供了非常完善的事件钩子。单元格内容变化有 `cellUpdated`,工具栏操作(改字体、加粗、合并单元格)有 `updated`。 但问题来了:Luckysheet 的 `cellUpdated` 只告诉你"第 3 行第 5 列变了",不会自动帮你把变化存到后端。你需要自己维护一个"脏数据"集合。 整个编辑→保存的生命周期,看这张图: ```mermaid stateDiagram-v2 [*] --> 加载完成: Luckysheet 初始化 加载完成 --> 等待编辑: 数据渲染完毕 等待编辑 --> 有脏数据: cellUpdated / updated 触发<br/>markCellDirty() 有脏数据 --> 等待编辑: 继续编辑<br/>累积更多脏数据 有脏数据 --> 保存中: 用户点击"保存"<br/>saveChanges() 保存中 --> 等待编辑: 保存成功 ✨<br/>清空 dirtyCells + ElMessage 保存中 --> 有脏数据: 保存失败 ❌<br/>保留脏数据,用户可重试 等待编辑 --> 危险操作: 用户切换 Sheet / 关闭页面 危险操作 --> 确认离开: hasUnsavedChanges = true<br/>弹窗"有未保存的修改" 确认离开 --> [*]: 用户确认放弃 确认离开 --> 保存中: 用户选择"先保存再离开" ``` 得益于 Vue 3 的 Composition API,我把脏数据追踪逻辑拆成了几个独立的函数,每个只做一件事,不用在一个巨大的组件对象里翻来翻去。核心是用 `reactive` 管理脏数据集合,key 是 `sheetId_row_col`: ```javascript // SheetEditor.vue <script setup> import { reactive } from 'vue' const dirtyCells = reactive({}) const hasUnsavedChanges = computed(() => Object.keys(dirtyCells).length > 0) function markCellDirty(r, c, newValue) { const currentSheet = window.luckysheet?.getSheet?.() if (!currentSheet) return const dbSheetId = sheetIdMap[currentSheet.index] dirtyCells[`${dbSheetId}_${r}_${c}`] = { sheetId: dbSheetId, r, c, v: newValue } } async function saveChanges() { const updates = Object.values(dirtyCells) if (updates.length === 0) { ElMessage.info('没有需要保存的修改') return } saving.value = true try { await batchUpdateCells(documentId.value, updates) Object.keys(dirtyCells).forEach(key => delete dirtyCells[key]) ElMessage.success(`保存成功,共 ${updates.length} 个单元格`) } finally { saving.value = false } } ``` 每次编辑触发`markCellDirty`往`dirtyCells`里塞数据,用户点保存时打包批量请求。 这里有一个坑:`updated`事件不会告诉你具体改了哪个单元格。它只是在工具栏操作(比如点击"加粗")后触发,你需要自己去拿当前选中的区域。我是通过`markCurrentSelectionDirty()`方法,先调用`luckysheet.getRange()`获取选中范围,然后把选中区域里所有有值的单元格都标记为脏数据。这个方法有点暴力,但胜在不会漏。 另外,Vue 3的`onBeforeUnmount`里我做了件以前容易忘的事——注销Luckysheet实例和手动绑定的DOM事件。`<script setup>`里没有`beforeDestroy`钩子了,但`onBeforeUnmount`语义更清晰,而且可以写多个,互不干扰。 ## 导出:为什么我把这件事交给了前端 说到 Excel 导出,你可能会想:后端有 EasyExcel,干嘛不用? 简单说:样式的锅。用图对比一下两种方案就清楚了: ```mermaid flowchart LR subgraph Backend["❌ 后端导出方案"] direction TB B1["Luckysheet 数据<br/>含样式信息"] -->|"序列化 + 网络传输<br/>几十 MB"| B2["后端接收"] B2 -->|"Apache POI / EasyExcel<br/>逐个单元格重建样式"| B3["生成 .xlsx 文件"] B3 -->|"再次网络传输"| B4["浏览器下载"] B5["⚠️ 样式信息传输成本高<br/>⚠️ 后端需完整重建样式引擎"] end subgraph Frontend["✅ 前端导出方案 (DataLoom 采用的)"] direction TB F1["Luckysheet 数据<br/>已在浏览器内存中"] -->|"零网络传输<br/>内存直接操作"| F2["ExcelJS 构建 Workbook"] F2 -->|"样式天然兼容<br/>无需转换"| F3["生成 Blob"] F3 -->|"FileSaver 触发下载"| F4["浏览器下载"] F5["✨ 网络开销:0<br/>✨ 样式还原:100%<br/>✨ 导出速度:秒级"] end style Backend fill:#E74C3C,color:#fff style Frontend fill:#27AE60,color:#fff ``` 后端用 EasyExcel 导出,你需要把每一个单元格的字体、字号、颜色、背景色、边框样式、对齐方式、数字格式——全都在后端重建一遍。这些信息都在前端的 Luckysheet 数据里,传到后端要经过序列化、网络传输、反序列化。一个 10 万行的表格,光样式数据的传输就能让人等到下班。 而前端的 ExcelJS 可以直接读取 Luckysheet 的数据结构(它俩的格式高度兼容),在前端内存里构建 Workbook 对象,然后输出为 Blob,通过 FileSaver 触发下载。全程不走网络,样式零丢失。 唯一的代价是:导出大文件时,浏览器可能会短暂卡顿。解决方法也简单——加个 loading 动画,告诉用户"正在生成文件,请稍候"。心理体验比硬等要好得多。 ## 四个 Phase:从"能用"到"好用"的路线图 先上一张全局路线图,看清楚每个阶段在做什么、依赖关系是什么: ```mermaid gantt title DataLoom 演进路线图 dateFormat YYYY-MM axisFormat %Y-%m section Phase 1 基础能力 ✅ 文件上传解析 :done, p1a, 2025-01, 2025-02 Luckysheet 集成 :done, p1b, 2025-02, 2025-03 分块存储 + 懒加载 :done, p1c, 2025-03, 2025-04 脏数据追踪 + 手动保存 :done, p1d, 2025-04, 2025-05 前端 ExcelJS 导出 :done, p1e, 2025-05, 2025-06 section Phase 2 实时协作 ⬜ WebSocket 服务搭建 :p2a, 2025-07, 2025-08 LWW 冲突解决 :p2b, after p2a, 2025-09 在线用户列表展示 :p2c, after p2a, 2025-09 编辑广播同步 :p2d, after p2b, 2025-10 section Phase 3 增强完善 ⬜ 操作日志系统 :p3a, 2025-10, 2025-11 撤销重做 :p3b, after p3a, 2025-11 分享链接生成 :p3c, 2025-10, 2025-11 三档权限控制 :p3d, 2025-11, 2025-12 定时快照 :p3e, after p3a, 2025-12 section Phase 4 性能优化 ⬜ POI 流式读写 SXSSF :p4a, 2026-01, 2026-02 Redis 分片缓存 :p4b, after p4a, 2026-02 并发压测 + 索引优化 :p4c, after p4b, 2026-03 ``` 目前的 V1 已经跑通了**文件上传解析 → 在线编辑 → 手动保存 → 前端导出**这个最基础的闭环。一个人用没问题,但离真正意义上的协作工具还很远。后面分四个阶段来补齐。 ### Phase 1(当前 · ✅ 已完成) 这就是你现在看到的版本——上传、解析、编辑、保存、导出。基础能力拉满,但本质上是单机版的体验搬到浏览器里了。适用于"一个人改一个表"的场景。 ### Phase 2:实时协作 协同编辑是整个项目里最难啃的骨头。核心思路是给 `excel-service-demo` 扩展一个独立的 `socket-service`,新增 `ExcelWebSocketServer`,让前端通过 WebSocket 把每次编辑动作实时广播出去。 冲突解决这块,不急着上 OT 或 CRDT 那种重型方案——先用 **LWW(Last Writer Wins,最后写入者获胜)**。说白了就是"谁后改谁说了算",实现简单,覆盖 90% 的协同场景。同一个单元格两个人同时改了不同的值,服务器按时间戳取最新的那个写进去。 配套要做的:在线用户列表。文档页展示当前有哪些人在编辑这个表,头像 + 名字,谁在线一目了然。这个不只是好看——用户知道有别人在改同一个文档,自然会避免冲突。 ### Phase 3:增强完善 这时候表已经能多人协作了,得把"可靠性"和"可分享性"补上: - **操作日志**:谁在什么时候改了哪个单元格,从什么值改成什么值。一条不多地记录。出了数据事故能追责,改错了能回滚。 - **撤销重做**:现在的浏览器刷新就没了,操作日志是撤销重做的数据源。跨会话、跨设备都能回到历史状态。 - **定时快照**:每隔一段时间自动保存一份全量的表格快照。服务器崩了不至于回到解放前。 - **分享链接**:生成一个链接发给同事,点开就能看/编辑。不只是"上传文件"这一种入口,分享才是表格流转的核心路径。 - **权限控制**:三档角色——OWNER(拥有者,能删能改权限)、EDITOR(编辑者,能改数据不能删文档)、VIEWER(查看者,只能看不能动)。权限不写在代码里,存数据库,前端按角色动态渲染按钮。 ### Phase 4:性能优化 功能全了,该修路了。 - **大文件优化**:现在上传用的 POI `WorkbookFactory` 是全部读到内存再解析的。换用 `SXSSF`(流式写入)和 `XSSFReader`(流式读取),100MB+ 的 Excel 也不会 OOM。 - **Redis 分片存储**:H2 换成 MySQL 之后,大 JSON 块不能继续这么存。用 Redis 做热数据缓存,大 JSON 按 1000 行分片存进 String 类型的 key,查哪个块读哪个块,比扫数据库快一个数量级。 - **并发压测**:用 JMeter 或 wrk 模拟 50、100、200 个并发用户同时编辑,找瓶颈、加索引、调连接池,最终给出一个"建议最大并发数"。 ### 做完四个 Phase 之后 V1 是一个人能用,Phase 4 跑完之后是一个团队能依赖。从"玩具"到"工具",差的就是这四个阶段里的工程化细节。每一步都有坑,但我已经隐约看到它们在哪了——剩下的就是时间问题。 ## 总结 从零构建一个在线Excel,难点不在"能不能做出来",而在于"数据大了怎么办"和"多个用户一起改怎么办"。分块存储解决了前者,协同编辑还在解决后者的路上。 如果你也想做类似的东西,我的建议是:先跑通最小闭环,再逐层加功能。上传→解析→显示→编辑→导出,这五个节点打通了,你手里就是一个能用的产品。后面的分块优化、协同编辑、UI打磨,都是在"能用"的基础上做到"好用"。 源码我放在GitHub上了,感兴趣的朋友可以去看看。里面包括完整的后端服务和前端页面,clone下来改改配置就能跑。 --- 这就是"从零构建在线Excel"的过程。不是PPT架构师讲的那种"我们只需要三步"的爽文路线,而是一个真实的Java全栈工程师,在各种细节和坑之间来回试探的过程。但说实话,当你看到自己写的系统成功打开一个10万行的Excel,并且在浏览器里流畅地编辑时——那种感觉,值了。
AI入门者-两周AI开发钻入牛角尖,请求各位老手指导
### 问题描述 最近两周多一直在尝试AI开发,但是一直效果不佳,来试试提问能不能得到更多的思路 ### 背景信息 #### 使用的工具是codex cli,模型是GPT-5.4 - 我手上有个耦合性很高的Spring MVC单体架构项目,存在不少的缓存自研、session自研,因为自研产品有些粗糙,为了后续的发展考虑,我想重构成成熟的组件以及模块分组去掉一些耦合。 - 于是我开始按自己想法安排: 1. 先让AI大致了解项目后,划分模块(比如cache模块、secret模块和各个业务模块) 2. 让ai细化功能点,排开发计划 3. 使用Superpower的tdd方式让ai开发 - 一开始效果还不错,文档虽然很长但感觉功能点都有,但好景不长,因为看着AI说的头头是道也没什么错误,我没去检查它写的代码,一直是让他继续下一步,然后不知不觉一个下午过去了,我突然发现开发似乎没什么进展,查看修改的代码我才注意到,它说的什么“收口”或者“修复”,大多都只是改动了一点点代码(比如做了个try),但是进行了多轮思考+测试。 - 中途还有不少问题,也是我的操作失误,比如:进行多轮会话后,Superpower技能的强制tdd和先文档后动手效果逐渐变弱,让我分不清到底哪句对话该显式使用技能;因为计划的说法存在约束不够紧的漏洞,导致AI理直气壮地说它做完了。 - -- - 其实后面我有点放弃了,烧了不少token却没啥成果,最后的倔强让我把视点转向了即将要做的需求:做一套新的账户体系和支付模块。 - 吸取上面重构的教训,我打算用新的技术栈实现,然后老系统通过http来请求新系统、老系统单点登录到新系统来实现互通。 - 依旧是老办法,先文档后开发,这次遇到的问题却是:在计划和边界已拟定的情况下,如果发现先前的边界设定可能不够,业务流程细节你以为AI了解了,但实际实现却发现它没有根本没关注这些细节,这时候想修正,已经有点难了。 ### 具体疑问 到如今使用AI开发也有半个月有余了,如今的我似乎进入了死胡同,感觉如果想使用AI开发达到理想效果,我似乎得具体到每个接口文档、业务流程图都要准备得一应俱全,让AI产生?可我又想不到使用怎样的标准、工作流,何况我还没接触到怎么测试,如何验收,所以我想问问各位的经验,各位是怎么开发的呢?又是怎么利用各项工具的?
用户中心前端模板初始化bug
### Bug 描述 用户中心项目,前端模板初始化阶段遇到 初始化命令`yarn create umi myapp` ### 错误信息  红框内容: ```js The filename, directory name, or volume label syntax is incorrect. error Command failed. ``` ### 自己的思路和解决方案 尝试过更换目录、清除缓存、更换yarn版本,都没有成功,问AI说是Windows系统级报错
我的本科毕业设计《智搭低代码应用生成系统》开源啦!!!
# 智搭低代码应用生成系统 ## 项目简介    本项目是我学习完编程导航的零代码应用生成平台系列教程交的作业,同时也是我的软件工程专业的本科毕业设计。 本项目由3个 Github 仓库共同组成,它们分别是: 后端:[ymz6/zhida-backend](https://github.com/ymz6/zhida-backend) 前端:[ymz6/zhida-frontend](https://github.com/ymz6/zhida-frontend) 应用项目模板:[ymz6/zhida-application-template](https://github.com/ymz6/zhida-application-template) 后端是用 Java 21 + SpringBoot 3 开发的,前端则是用 React 。 本项目的亮点: - 增加思考模式支持 - 通过预制项目模板来初始化应用 - 还有一些我自己也忘了,请各位鱼友自行探索吧! - 另外附带我开发这个项目的完整git提交记录,各位鱼友可以看到我起起落落落落落又起的、曲折的开发过程,方便各位从中学到些东西 毕业之后,我可能不打算对再此项目继续维护了,也许会存在一些大大小小的我没发现的Bug,有能力的鱼友可以 Fork 本项目进行二次开发,十分期待看到大家的二开作品! 最后真心希望我的项目对大家有所帮助,Love & Peace! ## 所需运行环境 后端:JDK 21 前端:Node.js >= 24 数据库:MySQL、Redis OSS:推荐用 Docker 起一个 RustFS 容器 操作系统:最好是 Windows,因为我为了开发方便,直接把 Nginx 可执行文件放在了后端项目中,使用其他操作系统的同学请自行下载 Nginx,并且我还用 AI 写了一些方便本地开发用的 Powershell 脚本 截图服务依赖:本项目有部署后自动截取应用封面图片的功能,由于固定使用的 Chrome 浏览器的驱动,因此若想要正常使用本功能,请务必确认在你本地电脑上安装有 Chrome 浏览器! ## 如何运行本项目? > 注意,本项目最终只是为了在本地演示给学校老师检查,因此并未考虑过部署上线的事情,所以跑起来会有些许麻烦。 > > 如果有鱼友有这方面需求,可以 fork 本项目进行改造。 ### 后端 ① 执行后端项目中的数据库初始化脚本(`sql/schemas.sql`) ② 准备自己的 AI Api Key。本项目为了开发方便,并未将大语言模型配置写在`application.yaml`中,而是通过代码进行配置,具体位于`app/src/main/java/org/ymz/app/ai/config/LLMsFactoryConfig.java`中,你需要在环境变量中设置对应大语言模型的 API Key。 ③ 以下配置需要你自行修改,推荐定位到后端项目的根目录中: ```yaml zhida: app-path: tmp-dir: C:/Users/ymz/Data/Codes/zhida/backend/tmp template-dir: C:/Users/ymz/Data/Codes/zhida/backend/project-template/zhida-react-project app-url: deploy-base-url: http://localhost:80 ``` ⑤ 准备 OSS 的访问密钥,本项目开发时使用的是通过本地 Docker 运行的 RustFS 容器。你可以替换为任何你喜欢的兼容 S3 协议的对象存储服务。这里只需要创建一个公共 bucket 即可,目前写的私有 bucket 可以不用动,原本我打算用来存放私有资源的,但是最终并未用上。 ⑥ 如果你要测试部署功能,请提前启动后端项目中集成的 Nginx。为了方便我让 AI 写了个用于方便启动和关闭 Nginx 的 Powershell 脚本,你可以通过它来启动/关闭 Nginx。 另外,你还需要修改 Nginx 的配置文件(`nginx/conf/nginx.conf`),重点是这一段: ```nginx # 项目部署根目录 root C:/Users/ymz/Data/Codes/zhida/backend/tmp/app-deploy; ``` 推荐定位到后端的根目录。 ⑦ 上述内容配置好后,便可以使用本项目内置的 maven 包装器来安装后端所需依赖,然后启动运行了。 ### 前端 本项目使用 pnpm 作为前端的包管理器。 安装完依赖就可以启动了
