编程导航Go话题讨论

Go

440 参与
分享

快来分享你的内容吧~

点击登录,快来和大家讨论吧~
表情
图片
话题
打卡
综合
交流
文章
问答

我做了一个轻量级定时任务平台 ChronoFlow

最近我做了一个轻量级的定时任务平台,名字叫 **ChronoFlow**。 它的定位很简单:**给内网单团队使用的轻量级任务调度系统**。 如果你只是想管理几十个定时任务,不想上太重的平台,又希望有 Web 页面、执行日志、手动运行、Cron 配置、任务终止、Docker 部署这些能力,那么 ChronoFlow 可能会比较适合。 项目地址: ```text https://github.com/Honghuaijie/chronoFlow ``` ![ChronoFlow 登录页](https://pic.code-nav.cn/post_picture/1904155932949557250/4gIRfWZgZV693Iex.webp) ## 为什么做这个项目 在很多中小型团队或者个人项目里,经常会有一些定时任务需求,比如: - 每隔几分钟同步一次数据 - 每天凌晨跑统计脚本 - 定时清理临时数据 - 定时调用 Python 脚本生成报表 - 手动触发某个后台任务 - 查看任务执行日志和失败原因 这些需求一开始可能直接写在 Linux crontab 里。 但任务一多,问题就来了: - 不方便查看有哪些任务 - 不方便手动运行 - 不方便看执行日志 - 任务失败了不直观 - 多台机器执行脚本不好管理 - 想终止正在运行的任务比较麻烦 - 非运维同学不方便操作 所以我做了 ChronoFlow,希望它足够轻量,但又能覆盖日常任务调度的大部分场景。 ## ChronoFlow 是什么 ChronoFlow 主要由三个部分组成: ```text UI -> Admin -> Exec ^ | | v +-- callback ``` - **chronoFlow-ui**:调度中心前端页面 - **chronoFlow-admin**:调度器后端,负责任务、执行器、日志、调度逻辑 - **chronoFlow-exec**:执行器后端,负责真正执行 Shell 脚本 其中只有 Admin 连接 MySQL,Exec 不连接数据库。 Exec 执行完任务后,会通过 callback 回调 Admin,把执行结果、退出码、日志信息写回来。 ## 目前支持的功能 ChronoFlow 第一版主要支持这些功能。 ### 1. 执行器管理 可以在页面中新增、编辑、删除执行器。 执行器会有在线/离线状态,Admin 会定期检查执行器健康状态。 如果 Admin 和 Exec 都在 Docker Compose 里部署,执行器地址可以填写: ```text http://chronoflow-exec:10004 ``` ![image.png](https://pic.code-nav.cn/post_picture/1904155932949557250/JDpG60KVdCzm5HqQ.webp) ### 2. 任务管理 支持创建任务、编辑任务、删除任务、启动调度、停止调度、手动运行。 任务可以绑定到某一个执行器上。 同一个任务默认不允许并发运行,避免同一个脚本重复执行导致数据问题。 ![image.png](https://pic.code-nav.cn/post_picture/1904155932949557250/poAGT2YfP108camS.webp) 新增任务时,可以选择执行器、配置 Cron 表达式、设置超时时间和任务说明。 ![image.png](https://pic.code-nav.cn/post_picture/1904155932949557250/e0TzHUXwneLkJOyZ.webp) ### 3. Cron 可视化配置 页面提供了 Cron 配置弹窗,支持常见的分钟、小时、日、周、月配置,也支持手动输入 Cron 表达式。 同时会展示接下来几次运行时间,方便确认表达式是否符合预期。 ![image.png](https://pic.code-nav.cn/post_picture/1904155932949557250/Aeu6zZnvZ6DBLYzm.webp) ### 4. Glue Shell 每个任务可以维护一段 Glue Shell 脚本。 例如: ```bash #!/bin/bash set -e echo "hello chronoflow" echo "run time: $(date '+%Y-%m-%d %H:%M:%S')" python3 --version echo "done" ``` 如果你的脚本比较复杂,也可以把 Python 文件挂载到执行器容器里,然后在 Glue Shell 里调用: ```bash python3 /scripts/report.py ``` ![image.png](https://pic.code-nav.cn/post_picture/1904155932949557250/3dkN5s73czSKnc55.webp) ### 5. 异步执行和回调 Admin 下发任务后不会一直阻塞等待结果。 Exec 会异步执行脚本,执行完成后回调 Admin。 如果 Admin 临时重启或不可用,Exec 会把待回调结果临时落盘,后续继续重试。 ### 6. 任务终止 对于长时间运行的任务,可以在页面上点击终止。 Exec 会尽量终止整个进程组,而不是只 kill 主进程。 这对 Shell 脚本里再启动 Python、子进程的场景比较重要。 ### 7. 执行日志 MySQL 只保存日志元数据,完整日志正文保存到文件里。 这样可以避免把大量 stdout/stderr 直接塞进 MySQL,后续日志增长也更好处理。 日志里可以看到: - 执行状态 - 开始时间 - 结束时间 - 执行耗时 - exit code - 错误信息 - Glue 快照 - 文件日志内容 ![image.png](https://pic.code-nav.cn/post_picture/1904155932949557250/iLxnJ3TjQPiGg2Zz.webp) ### 8. 运行报表 目前也做了一个简单的运行报表页面,可以看到: - 任务数量 - 调度次数 - 执行器数量 - 最近执行成功/失败比例 - 近 7 天执行趋势 ![image.png](https://pic.code-nav.cn/post_picture/1904155932949557250/qhJLotjMdahw1sJ4.webp) ## 部署方式 ChronoFlow 支持两种 Docker 部署方式。 ### 源码构建部署 适合开发者自己修改代码后构建: ```bash git clone https://github.com/Honghuaijie/chronoFlow.git chronoflow cd chronoflow/deploy cp .env.example .env docker compose -f docker-compose.mysql.yml up -d docker compose up -d --build ``` ### 作者镜像部署 如果服务器空间比较小,不想拉完整源码,可以只复制 `deploy` 目录,然后使用已经发布的镜像: ```env CHRONOFLOW_ADMIN_IMAGE=ghcr.io/honghuaijie/chronoflow-admin:v0.1.2 CHRONOFLOW_EXEC_IMAGE=ghcr.io/honghuaijie/chronoflow-exec:v0.1.2 CHRONOFLOW_UI_IMAGE=ghcr.io/honghuaijie/chronoflow-ui:latest ``` 启动: ```bash docker compose -f docker-compose.image.yml up -d ``` 默认访问地址: ```text http://127.0.0.1:5173 ``` 默认账号: ```text admin / admin123 ``` 生产环境需要修改默认密码、JWT Secret、Callback Token、执行器 Token 和数据库密码。 ## 技术栈 后端主要使用 Go。 整体结构分为: - API 层 - Service 层 - Biz 层 - Data 层 - Worker / Scheduler - Executor Client - Log Store 前端是一个 Web 调度中心,主要面向 PC 页面,不追求移动端复杂适配。 部署使用 Docker Compose,MySQL 单独拆成一个 compose 文件,避免频繁构建业务镜像时影响数据库容器。 ## 适合什么场景 ChronoFlow 当前更适合: - 内网环境 - 单团队使用 - 几十个以内任务 - 单调度器 - Shell / Python 脚本调度 - 希望轻量部署 - 希望有 Web 页面和日志查看 它暂时不定位为大规模分布式任务调度平台,也不追求复杂的多租户、权限体系和海量任务调度能力。 ## 第一版的一些取舍 第一版我做了一些偏轻量的选择: - 单调度器 - MySQL 保存元数据 - 日志正文保存文件 - Exec 不连接数据库 - 全局 callback token - 同一个任务不允许并发运行 - Linux 优先,任务终止基于进程组 - Docker 部署优先 这些选择不是为了“做少”,而是希望系统更容易部署、理解和维护。 ## 当前状态 目前 ChronoFlow 第一版已经完成了核心功能,并且已经做过本地和线上 Docker 部署验证。 线上验证过的链路包括: ```text UI -> Admin -> Exec -> Glue Shell -> Callback -> 执行日志 ``` 也就是说,从页面创建任务、手动运行、执行器执行脚本、回调结果、查看日志,这条主链路已经跑通。 ## 后续计划 后续可能会继续优化这些方向: - 添加任务失败提醒 - 更完善的部署文档 - 更好的日志降噪 - 更细的报表统计 - 更友好的 Cron 配置体验 - 更多执行器运行环境示例 - GitHub Release 和镜像版本管理 - 更完善的错误提示 ## 总结 ChronoFlow 是我做的一个轻量级定时任务平台,目标不是替代大型调度系统,而是解决一些更日常、更直接的内网任务调度问题。 如果你也有类似需求,比如想把 crontab、Shell 脚本、Python 脚本统一放到一个 Web 平台里管理,可以试试这个项目。 项目地址: ```text https://github.com/Honghuaijie/chronoFlow ``` 欢迎体验、反馈和交流。

1ms变200ms,拆解Dify代码节点的“逆天”操作

# 前言 最近因为一些技术调研,我集中用了几天 Dify 工作流。 整体体验不错,尤其是日志和流程图这两块:节点之间的数据怎么流、在哪一步出了问题,基本一眼就能看出来。这种可视化做得很友好。 但在实际接入时,我碰到了一个很疑惑的问题。 一段本地执行时间只有 **1-2ms**的代码逻辑。结果放进 Dify 代码节点之后,单次耗时直接到了**200ms+。** ![本地跑测试用例耗时](https://pic.code-nav.cn/post_picture/1954800492371845121/wezBwFDkq2UTCKew.webp) ![节点图](https://pic.code-nav.cn/post_picture/1954800492371845121/2G2BjvswAHfIazNk.webp) ![dify节点耗时](https://pic.code-nav.cn/post_picture/1954800492371845121/iFJAA6jxWIpmmbIy.png) 这乍一看我还以为写出`O(n³)`的代码了。 如果只看业务逻辑本身,几毫秒的代码无论如何也跑不到 200ms 量级。 遇到这种量级明显不对的耗时,最直接的办法还是翻源码。所以我顺手 clone 了一把 `dify` 。 我带了这两个问题写这篇文章: 1. Dify 的代码节点到底是怎么执行的? 2. 这 200ms+ 的耗时,到底花在了哪里? 如果先把这次源码阅读后的结论说在前面,大概是: > **至少从当前这套实现看,Dify 代码节点慢,通常不是慢在你的业务代码,而是慢在“为安全执行这段代码所付出的整套沙箱成本”。** # 先看调用链:代码节点并不是在主服务里直接执行 一开始的直觉是:`会不会是多了一次服务间调用原因?` 我先去翻了 Dify 源码,结果很快就能看到,代码执行并不是在主服务内部直接完成的,而是通过 HTTP 请求打到一个专门的代码执行服务。 ![相关代码](https://pic.code-nav.cn/post_picture/1954800492371845121/1jU830wK3LK2owiG.webp) 继续往下看,会发现这个能力拆到了另一个仓库里,也就是 `dify-sandbox`。这个仓库的主要语言是 Go。 ![dify-sandbox仓库](https://pic.code-nav.cn/post_picture/1954800492371845121/Vw6WrUpS6LkqQUP4.webp) 这个设计本身我觉得挺合理。 虽然我没设计过沙箱,但只要产品允许用户执行自定义代码,优先考虑隔离性和安全性,本来就是很自然的工程选择。 但新的问题也来了: **代码节点的额外耗时,主要是因为跨服务通信吗?** `那肯定不是。` 因为在同机或内网环境里,一次普通 HTTP 往返通常很难解释掉 200ms 这个量级。它可能贡献几毫秒,甚至十几毫秒,但如果总耗时已经上到 200ms+,那大头多半在别的地方。 # 绕过 Dify,直接请求 sandbox 为了把问题拆开,我没有继续只盯着工作流节点,而是决定直接测 `dify-sandbox` 自己的耗时。 不过这里有个现实问题:`dify-sandbox` 的实现完全依赖 Linux 特性,我本地模拟不太方便。 ![dify-sandbox运行要求](https://pic.code-nav.cn/post_picture/1954800492371845121/ZXFC3GPqlWCSmbRR.webp) 不过没事,我们直接采用了官方 Docker 镜像来模拟环境: ```bash docker run --rm \ -p 8194:8194 \ --cpus="2" \ --memory="4g" \ --memory-swap="4g" \ langgenius/dify-sandbox ``` ![dify-sandbox docker镜像](https://pic.code-nav.cn/post_picture/1954800492371845121/1hW2GdEMTyj1PvP5.webp) 然后我用相同代码直接请求 sandbox 服务,循环跑了 1000 次。结果很直接:**单次平均往返延时已经到了 100ms 以上。** ![本地请求sandbox服务1000次平均耗时](https://pic.code-nav.cn/post_picture/1954800492371845121/NuC9eWg5wCkWMq2h.webp) 这里先强调一下边界: 1. 这个结果是我在本地 Docker 环境下测出来的,不代表所有部署环境的绝对值都一样。 2. 但它至少说明了一件事:**即使绕开 Dify 工作流编排层,sandbox 本身也已经有明显固定成本。** 换句话说,代码节点的慢,确实有很大一部分来自沙箱执行链路本身,而不是业务逻辑。 # 从源码看,sandbox 到底做了什么 下面开始看 `dify-sandbox` 的 NodeJS runner。 ![nodejs runner 代码](https://pic.code-nav.cn/post_picture/1954800492371845121/q7mDnfKw1Y8ZlC1H.webp) 如果把这段执行流程压缩一下,大概可以整理成下面几步: 1. sandbox 鉴权、路由、JSON 解析 2. 创建临时目录并复制运行所需文件 3. 生成 bootstrap 脚本 4. 拉起 Node 子进程 5. Node 启动并加载基础模块 6. 通过 `koffi` 加载 `nodejs.so` 7. 开启 seccomp / 切换 uid gid / 进入隔离环境 8. 从 fd3 读取用户代码 9. `eval(code)` 10. 捕获 stdout / stderr,等待进程退出并回传结果 这里最重要的观察是: > **用户代码执行并不是“主角”,而是整条执行链的最后一步。** 这也是我翻到这里时,第一个比较明确的感受:前面那些准备动作,才是固定开销的主要来源。 ## 前三步基本都不重 ### 1. 读取全局配置 一开始先读 sandbox 的全局配置,比如: 1. `nodejs` 可执行文件路径 2. 允许哪些系统调用 3. 是否允许联网 4. 超时时间 这部分更像普通配置读取,性能影响通常可以忽略。 ![读全局配置代码](https://pic.code-nav.cn/post_picture/1954800492371845121/8eyDRXKs8isoU26f.webp) 类型定义也比较直观。 ![config类型定义](https://pic.code-nav.cn/post_picture/1954800492371845121/hZ8NtPimBqdtdGZF.webp) ### 2. 申请一个专属沙箱 UID 接着会从一个 UID 池里取出低权限用户 ID,后续执行代码时,会切到这个受限身份下面运行。 ![linux uid生成](https://pic.code-nav.cn/post_picture/1954800492371845121/Hb4PrnoQugEBey2M.webp) 这一步本身也不重。除非是在并发特别高的时候,可能会受到资源竞争影响? ### 3. 准备输出采集器 然后会初始化输出捕获逻辑,用来接收 `stdout`、`stderr`,同时挂上超时控制。 ![捕获output](https://pic.code-nav.cn/post_picture/1954800492371845121/VSAq1Alvq3lih30M.webp) 这部分也更像“装配流程”,通常不是主要瓶颈。 所以如果只看前面三步,很难解释为什么一次执行会到 100ms 甚至 200ms。 真正开始变重,是从下一步开始。 ## 第一个明显的固定成本:创建临时目录,复制运行时文件 这一段是我自己最先盯上的地方。 ![创建临时目录](https://pic.code-nav.cn/post_picture/1954800492371845121/tqUj0bvYSNPVeo7u.webp) `WithTempDir` 主要做两件事: 1. 创建一个形如 `/tmp/sandbox-<uuid>` 的临时目录 2. 把 `REQUIRED_FS` 里的文件和目录用 `cp -r` 复制进去 `REQUIRED_FS` 包括这些内容: 1. `node_temp` 2. `nodejs.so` 3. `ca-certificates.crt` 4. `nsswitch.conf` 5. `resolv.conf` 6. `stub-resolv.conf` 7. `hosts` 这里最重的显然不是那几个系统配置文件,而是 `node_temp`。 这个目录里带着一整套 Node 运行时依赖。我本地看了下仓库里的内容,目录体积大概在 **35MB** 左右。 ![koffi仓库](https://pic.code-nav.cn/post_picture/1954800492371845121/4rqMliuBp1N8jn6w.webp) 这意味着什么? 意味着每次执行代码之前,sandbox 都不是“复用一个已经准备好的 Node 环境”,而是先准备一份新的、可隔离、可销毁的运行环境副本。 对长任务来说,这点固定成本可能不明显;但对一个本身只跑 1-2ms 的小脚本来说,这种文件复制成本就会非常刺眼。 按照经验判断,它显然已经具备成为主要耗时来源的条件: 1. 有磁盘 I/O 2. 有目录复制 3. 复制体积不小 4. 每次执行都要重复发生 ## 第二个明显的固定成本:每次都要拉起一个新的 Node 进程 复制完运行环境之后,sandbox 还会写一个启动脚本,然后用 `exec.Command` 拉起一个新的 Node 子进程。 ![nodejs进程](https://pic.code-nav.cn/post_picture/1954800492371845121/8vl7tMiRiN3hITUN.webp) 这一步我也觉得很关键,因为它说明 Dify 这里走的不是“长驻 Worker 复用”的思路,而是**每次执行都新起一次进程**。 这里执行的还不是用户代码本体,而是一层 bootstrap 脚本。 ![执行测试脚本](https://pic.code-nav.cn/post_picture/1954800492371845121/VeEFjfWQjYp0FK5x.webp) 这一层会先做几件事: 1. 加载基础模块 2. 加载 `koffi` 3. 再通过 `koffi` 加载 `nodejs.so` 4. 然后才进入真正的沙箱逻辑 特征也很明显: 1. 需要启动 Node 运行时 2. 需要模块解析与加载 3. 需要加载动态库 4. 每次执行都要重新来一遍 从工程常识看,**“拉起新进程 + 运行时初始化”本身就是典型固定成本**。至于多少毫秒取决于设备。 ## 真正执行用户代码之前,还要再过一道安全门 再往后,才终于轮到执行用户代码。 ![执行代码](https://pic.code-nav.cn/post_picture/1954800492371845121/yIYJEp4M9x1T1K5H.webp) 但这里也不是一上来就 `eval(code)`。 在 `prescript.js` 里,实际顺序大概是: 1. 通过 `koffi` 加载本地动态库 `nodejs.so` 2. 调用其中的 `DifySeccomp(...)` 3. 完成 uid / gid、seccomp、网络权限等隔离设置 4. 从 fd3 读取用户代码 5. 最后执行 `eval(code)` 也就是说,真正的业务代码其实是最后才进场。 这也是为什么很多人在看代码节点耗时时会产生错觉: 明明自己的逻辑只有几行,为什么却慢得像跑了一个小服务? 因为从 sandbox 的视角看,它确实不是“帮你跑几行 JS”,而是在**完整地执行一次受限代码任务**。 ## 所以这 200ms+ 到底花在哪了? 如果把前面的实测和源码放在一起看,我认为可以得到一个比较稳妥的判断: **Dify 代码节点的主要耗时,并不在业务代码本身,而在每次执行前后的固定启动成本。** 这些固定成本主要包括: 1. HTTP 请求进入 sandbox 服务 2. 创建临时目录 3. 复制运行时文件 4. 生成 bootstrap 脚本 5. 启动 Node 子进程 6. 加载模块和动态库 7. 设置 seccomp / uid / gid / 网络隔离 8. 采集输出并等待进程结束 其中最值得怀疑的大头,至少从实现方式上看,是两类: 1. **文件系统准备成本**:临时目录、`cp -r`、运行时副本 2. **进程冷启动成本**:Node 进程启动、模块加载、动态库加载 反过来说,这也解释了一个现象: > **为什么短代码最容易觉得慢。** 因为你的业务逻辑如果本来就只值 1-2ms,那么上面这些固定成本会被无限放大;但如果你执行的是一个本来就要跑几百毫秒甚至几秒的任务,这些固定成本在总时长里的占比反而会下降。 ## 这或许是标准沙箱写法?优先安全大于性能 看到上面,很容易顺手吐槽一句:这也太重了。 但我更愿意把它理解成一个典型的工程权衡,而不是“为了写得更重”。 如果你要支持“用户上传任意代码并在线执行”,那你优先追求的一般不会是极限低延迟,而是: 1. 隔离性 2. 可控性 3. 可回收性 4. 出问题时不影响主服务 从这个目标看,Dify 把代码执行拆到单独 sandbox,再给每次执行都准备独立环境,其实是很符合安全思路的。 只是它带来的代价也很清楚: **安全边界越完整,固定成本往往越高。** # 最后 把结论简单过一下。 如果你也遇到过类似的困惑: > 为什么我本地只跑 1-2ms 的代码,到了线上平台跑代码时却变成了 200ms+? 那以这次翻源码后的理解,一个比较接近事实的答案是: **慢的通常不是你的代码,而是沙箱。** 更准确一点说,是`为了安全地运行这段代码,系统必须额外完成一整套隔离和启动流程。`而对毫秒级小任务来说,这些固定成本远大于业务代码本身,于是你看到的最终效果就会像“慢了几百倍”。 所以如果你的代码节点只是做一些非常轻的小判断、小转换、小规则处理,看到这个量级的耗时。 <span style="color: red;font-size: 16px">很遗憾,这或许是正常的。</span>

易扣AI (Go + CloudWeGo) 企业级AI智能体项目教程 第4章:后端项目应用模块搭建

# 第4章:后端项目应用模块搭建 > 本章将讲解如何开发应用模块的基础部分和如何将第3章实现的 AI 代码生成核心功能集成到完整的后端应用模块中。 ## 知识点清单 ### 一、方案设计 #### 业务需求描述 在前面的章节,我们已经封装好了代码生成智能体。在接下来的章节,我们将进一步构建一个 AI 代码生成平台,用户可以通过自然语言描述需求提示词,AI 智能体 自动生成对应的代码文件。为了满足需求,我们需要实现以下核心功能: **核心业务功能:** | 功能模块 | 功能描述 | 技术实现 | | ------------------ | -------------------------- | ------------------- | | **应用管理** | 创建、编辑、删除、查询应用 | CRUD 操作 | | **代码生成** | 根据用户描述生成代码 | AI 模型 + Eino 框架 | | **代码部署** | 将生成的代码部署到服务器 | 静态文件服务 | | **应用展示** | 展示用户创建的应用列表 | 分页查询、排序 | #### 数据库表设计 执行以下sql语句,并且执行gorm实体结构体的生成脚本,直接生成应用表的dao文件 ##### 应用表(app) 应用表存储用户创建的应用信息,包括应用名称、封面、初始 Prompt、代码生成类型、部署信息等。 **表结构:** ```sql create table app ( id bigint auto_increment comment 'id' primary key, appName varchar(256) null comment '应用名称', cover varchar(512) null comment '应用封面', initPrompt text null comment '应用初始化的 prompt', codeGenType varchar(64) null comment '代码生成类型(枚举)', deployKey varchar(64) null comment '部署标识', deployedTime datetime null comment '部署时间', priority int default 0 not null comment '优先级', userId bigint not null comment '创建用户id', editTime datetime default CURRENT_TIMESTAMP 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 '是否删除', UNIQUE KEY uk_deployKey (deployKey), INDEX idx_appName (appName), INDEX idx_userId (userId) ) comment '应用' collate = utf8mb4_unicode_ci; ``` **字段说明:** | 字段名 | 类型 | 说明 | 约束 | 业务含义 | | ------------ | ------------ | ------------------- | ------------------- | ------------------------------------------------------- | | id | bigint | 应用 ID,自增主键 | PRIMARY KEY | 唯一标识一个应用 | | appName | varchar(256) | 应用名称 | NULL | 用户定义的应用名称,如"个人博客" | | cover | varchar(512) | 应用封面图片 URL | NULL | 应用展示的封面图片 | | initPrompt | text | 应用初始化的 Prompt | NULL | AI 生成代码的系统提示词,定义应用的基本功能和样式 | | codeGenType | varchar(64) | 代码生成类型 | NULL | 枚举值:html(单文件)、multi_file(多文件) | | deployKey | varchar(64) | 部署标识 | UNIQUE | 唯一的部署标识,用于生成访问链接,如 "my-blog-20240101" | | deployedTime | datetime | 部署时间 | NULL | 应用最后一次部署的时间 | | priority | int | 优先级 | NOT NULL, DEFAULT 0 | 应用展示的优先级,数值越大越靠前 | | userId | bigint | 创建用户 ID | NOT NULL | 关联用户表,标识应用的创建者 | | editTime | datetime | 最后编辑时间 | NOT NULL | 用户最后一次编辑应用的时间 | | createTime | datetime | 创建时间 | NOT NULL | 应用创建的时间 | | updateTime | datetime | 更新时间 | NOT NULL | 数据库记录更新的时间 | | isDelete | tinyint | 是否删除 | NOT NULL, DEFAULT 0 | 软删除标记,0:未删除, 1:已删除 | **索引说明:** | 索引名 | 索引类型 | 字段 | 说明 | | ------------ | -------- | --------- | ------------------------------------------ | | PRIMARY | 主键索引 | id | 主键 | | uk_deployKey | 唯一索引 | deployKey | 保证部署标识唯一性,用于生成唯一的访问链接 | | idx_appName | 普通索引 | appName | 提升按应用名称搜索的性能 | | idx_userId | 普通索引 | userId | 提升按用户 ID 查询应用的性能 | ### 二、实现应用模块基础接口 应用模块是本项目的核心模块之一,提供应用的创建、查询、更新、删除等功能。本节将按照每个接口的完整流程,从 API 层、Handler 层到 Service 层,详细讲解每个接口的实现。 #### 路由配置 **文件位置:** `internal/router/router.go` **应用模块路由分组** ```go appRoute := h.Group("/app") { // 公开接口(无需登录) appRoute.POST("/good/list/page/vo", appHandler.ListGoodApp) appRoute.GET("/get/vo", middleware.AuthMiddleware(enum.UserRole, db), appHandler.GetAppVo) // 用户接口(需要登录) 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) } ``` #### 接口实现详解 ##### 新增应用接口 **接口路径:** `POST /app/add` **功能说明:** 用户创建新应用,填写初始 Prompt,系统自动生成应用名称和 ID。 ###### API 层 **文件位置:** `internal/api/app.go` **请求结构体:** ```go type YiKouAppAddRequest struct { InitPrompt string `json:"initPrompt"` } ``` **请求字段说明:** | 字段名 | 类型 | JSON 标签 | 说明 | 必填 | | ---------- | ------ | ---------- | ------------------------------------ | ---- | | InitPrompt | string | initPrompt | 应用初始化 Prompt,AI 根据此生成代码 | 是 | **响应结构体:** ```go type YiKouAppAddResponse response.BaseResponse[string] ``` **响应数据说明:** - 返回新创建的应用 ID(string 类型) ###### Handler 层 **文件位置:** `internal/handler/app_handler.go` **控制器结构体:** ```go type AppHandler struct { appService service.IAppService // 应用服务接口 userService service.IUserService // 用户服务接口 } func NewAppHandler( appService service.IAppService, userService service.IUserService, ) *AppHandler { return &AppHandler{ appService: appService, userService: userService, } } ``` **接口实现:** ```go // AddApp 新增应用 // @Summary 新增应用 // @Description 新增应用 // @Tags 应用模块 // @Accept json // @Produce json // @Param req body api.YiKouAppAddRequest true "新增应用请求" // @Success 200 {object} api.YiKouAppAddResponse "应用ID" // @Router /app/add [post] func (a *AppHandler) AddApp(ctx context.Context, c *app.RequestContext) { // 1. 绑定和验证请求参数 req := &api.YiKouAppAddRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 2. 获取当前登录用户 userVo, err := a.userService.GetLoginUserVo(ctx, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 3. 调用服务层创建应用 appId, err := a.appService.AddApp(ctx, req, userVo.ID) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 4. 返回成功响应 c.JSON(consts.StatusOK, response.NewSuccessResponse[string](strconv.Itoa(int(appId)))) } ``` ###### Service 层 **文件位置:** `internal/logic/app_logic.go` **服务结构体:** ```go type AppService struct { aiCodeGenFacade *core.YiKouAiCodegenFacade // AI 代码生成门面 userService service.IUserService // 用户服务接口 db *gorm.DB // 数据库连接 } func NewAppService( aiCodeGenFacade *core.YiKouAiCodegenFacade, userService service.IUserService, db *gorm.DB, ) *AppService { return &AppService{ aiCodeGenFacade: aiCodeGenFacade, userService: userService, db: db, } } ``` **业务逻辑实现:** ```go func (s *AppService) AddApp(ctx context.Context, req *api.YiKouAppAddRequest, userId int64) (int64, error) { // 1. 参数校验 if req.InitPrompt == "" { return 0, errorutil.ParamsError.WithMessage("初始化prompt不能为空") } // 2. 生成应用名称(截取前12个字符) appName := req.InitPrompt count := 0 for i := range appName { if count >= 12 { appName = appName[:i] break } count++ } // 3. 生成应用 ID(雪花算法) appId, err := snowflake.GenerateSnowFlakeId() if err != nil { return 0, err } // 4. 构建应用实体 newApp := &model.App{ ID: appId, AppName: appName, InitPrompt: req.InitPrompt, UserID: userId, CodeGenType: string(enum.HtmlCodeGen), Priority: 0, } // 5. 保存到数据库 err = query.Use(s.db).App. Select(query.App.ID, query.App.AppName, query.App.InitPrompt, query.App.UserID, query.App.Priority, query.App.CodeGenType). Create(newApp) if err != nil { return 0, err } logger.Infof("应用创建成功,ID: %d, 类型: %s", appId, enum.HtmlCodeGen) return newApp.ID, nil } ``` ##### 更新应用接口 **接口路径:** `POST /app/update` **功能说明:** 用户更新自己的应用信息,只能更新应用名称。 ###### API 层 **请求结构体:** ```go type YiKouAppUpdateRequest struct { request.DeleteRequest AppName string `json:"appName"` } ``` **请求字段说明:** | 字段名 | 类型 | JSON 标签 | 说明 | 必填 | | ------- | ------ | --------- | ------------------------------- | ---- | | Id | int | id | 应用 ID(继承自 DeleteRequest) | 是 | | AppName | string | appName | 应用名称 | 否 | **响应结构体:** ```go type YiKouAppUpdateResponse response.BaseResponse[bool] ``` **响应数据说明:** - 返回是否更新成功(bool 类型) ###### Handler 层 **接口实现:** ```go func (a *AppHandler) UpdateApp(ctx context.Context, c *app.RequestContext) { req := &api.YiKouAppUpdateRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } userVo, err := a.userService.GetLoginUserVo(ctx, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } success, err := a.appService.UpdateApp(ctx, req, userVo.ID) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[bool](success)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) UpdateApp(ctx context.Context, req *api.YiKouAppUpdateRequest, userId int64) (bool, error) { // 1. 参数校验 if req.Id == 0 { return false, errorutil.ParamsError.WithMessage("应用ID不能为空") } // 2. 查询应用 app, err := query.Use(s.db).App.Where(query.App.ID.Eq(int64(req.Id))).First() if err != nil { return false, err } // 3. 权限校验 if app.UserID != userId { return false, errorutil.ParamsError.WithMessage("无权修改该应用") } // 4. 构建更新字段 updateMap := make(map[string]interface{}) if req.AppName != "" { updateMap["appName"] = req.AppName } // 5. 执行更新 _, err = query.Use(s.db).App.Where(query.App.ID.Eq(int64(req.Id))).Updates(updateMap) if err != nil { return false, err } return true, nil } ``` ##### 删除应用接口 **接口路径:** `POST /app/delete` **功能说明:** 用户删除自己的应用,使用逻辑删除(软删除)。 ###### API 层 **请求结构体:** ```go type DeleteRequest struct { Id int `json:"id"` } ``` **请求字段说明:** | 字段名 | 类型 | JSON 标签 | 说明 | 必填 | | ------ | ---- | --------- | ------- | ---- | | Id | int | id | 应用 ID | 是 | **响应结构体:** ```go type YiKouAppDeleteResponse response.BaseResponse[bool] ``` **响应数据说明:** - 返回是否删除成功(bool 类型) ###### Handler 层 **接口实现:** ```go func (a *AppHandler) DeleteApp(ctx context.Context, c *app.RequestContext) { req := &request.DeleteRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } userVo, err := a.userService.GetLoginUserVo(ctx, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } success, err := a.appService.DeleteApp(ctx, int64(req.Id), userVo.ID) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[bool](success)) } ``` ###### Service 层 **业务逻辑实现:** ```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 } return true, nil } ``` ##### 获取应用详情接口 **接口路径:** `GET /app/get/vo` **功能说明:** 根据 ID 获取应用详情,返回应用 VO(包含用户信息)。 ###### API 层 **请求参数:** - `id`(query 参数):应用 ID **响应结构体:** ```go type YiKouAppGetVoResponse response.BaseResponse[vo.AppVo] ``` **AppVo 结构体:** ```go type AppVo struct { ID int64 `json:"id"` AppName string `json:"appName"` Cover string `json:"cover"` InitPrompt string `json:"initPrompt"` CodeGenType string `json:"codeGenType"` DeployKey string `json:"deployKey"` DeployedTime time.Time `json:"deployedTime"` Priority int32 `json:"priority"` UserID int64 `json:"userId"` User UserVo `json:"user"` CreateTime time.Time `json:"createTime"` UpdateTime time.Time `json:"updateTime"` } ``` ###### Handler 层 **接口实现:** ```go func (a *AppHandler) GetAppVo(ctx context.Context, c *app.RequestContext) { // 1. 获取查询参数 id := c.Query("id") if id == "" { c.JSON(consts.StatusOK, response.NewErrorResponse[any](errorutil.ParamsError)) return } idInt64, _ := strconv.ParseInt(id, 10, 64) // 2. 获取当前登录用户 userVo, err := a.userService.GetLoginUserVo(ctx, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 3. 调用服务层获取应用详情 appVo, err := a.appService.GetAppVo(ctx, idInt64, userVo.ID) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } // 4. 返回应用详情 c.JSON(consts.StatusOK, response.NewSuccessResponse[vo.AppVo](appVo)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) GetAppVo(ctx context.Context, id int64, userId int64) (vo.AppVo, error) { // 1. 获取应用实体 app, err := s.GetApp(ctx, id, userId) if err != nil { return vo.AppVo{}, err } // 2. 获取用户信息 userVo, err := s.userService.GetUserVo(ctx, app.UserID) if err != nil { return vo.AppVo{}, err } // 3. 构建应用 VO appVo := vo.AppVo{ ID: app.ID, AppName: app.AppName, Cover: app.Cover, InitPrompt: app.InitPrompt, CodeGenType: app.CodeGenType, DeployKey: app.DeployKey, DeployedTime: app.DeployedTime, Priority: app.Priority, UserID: app.UserID, User: userVo, CreateTime: app.CreateTime, UpdateTime: app.UpdateTime, } return appVo, nil } ``` **GetApp 方法:** ```go func (s *AppService) GetApp(ctx context.Context, id int64, userId int64) (*model.App, error) { // 1. 查询应用 app, err := query.Use(s.db).App.Where(query.App.ID.Eq(id)).First() if err != nil { return nil, err } // 2. 权限校验 if app.UserID != userId { return nil, errorutil.ParamsError.WithMessage("无权查看该应用") } return app, nil } ``` ##### 我的应用列表接口 **接口路径:** `POST /app/my/list/page/vo` **功能说明:** 分页获取当前用户的应用列表,支持按应用名称模糊查询和排序。 ###### API 层 **请求结构体:** ```go type YiKouAppMyListRequest struct { request.PageRequest AppName string `json:"appName"` } ``` **PageRequest 基础结构体:** ```go type PageRequest struct { PageNum int `json:"pageNum"` PageSize int `json:"pageSize"` SortField string `json:"sortField"` SortOrder string `json:"sortOrder"` } ``` **请求字段说明:** | 字段名 | 类型 | JSON 标签 | 说明 | 必填 | | --------- | ------ | --------- | -------------------------- | ---- | | PageNum | int | pageNum | 页码,默认 1 | 否 | | PageSize | int | pageSize | 每页大小,默认 20,最大 20 | 否 | | SortField | string | sortField | 排序字段 | 否 | | SortOrder | string | sortOrder | 排序方式(asc/desc) | 否 | | AppName | string | appName | 应用名称(模糊查询) | 否 | **响应结构体:** ```go type YiKouAppMyListResponse response.BaseResponse[response.PageResponse[vo.AppVo]] ``` **PageResponse 结构体:** ```go type PageResponse[T any] struct { Records []T `json:"records"` PageNum int `json:"pageNum"` PageSize int `json:"pageSize"` TotalPage int `json:"totalPage"` TotalRow int `json:"totalRow"` OptimizeCountQuery bool `json:"optimizeCountQuery"` } ``` ###### Handler 层 **接口实现:** ```go func (a *AppHandler) ListMyApp(ctx context.Context, c *app.RequestContext) { req := &api.YiKouAppMyListRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } userVo, err := a.userService.GetLoginUserVo(ctx, c) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } pageResponse, err := a.appService.ListMyApp(ctx, req, userVo.ID) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[*response.PageResponse[vo.AppVo]](pageResponse)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) ListMyApp(ctx context.Context, req *api.YiKouAppMyListRequest, userId int64) (*response.PageResponse[vo.AppVo], error) { // 1. 参数校验和默认值设置 if req.PageNum <= 0 { req.PageNum = 1 } if req.PageSize <= 0 { req.PageSize = 20 } if req.PageSize > 20 { req.PageSize = 20 } // 2. 构建查询条件 queryBuilder := query.Use(s.db).App.Where(query.App.IsDelete.Eq(0), query.App.UserID.Eq(userId)) if req.AppName != "" { queryBuilder = queryBuilder.Where(query.App.AppName.Like("%" + req.AppName + "%")) } // 3. 查询总数 totalCount, err := queryBuilder.Count() if err != nil { return nil, err } // 4. 计算分页信息 totalPage := int((totalCount + int64(req.PageSize) - 1) / int64(req.PageSize)) offset := (req.PageNum - 1) * req.PageSize // 5. 设置排序 if req.SortField != "" { if orderExpr, ok := query.App.GetFieldByName(req.SortField); ok { if req.SortOrder == "desc" { queryBuilder = queryBuilder.Order(orderExpr.Desc()) } else { queryBuilder = queryBuilder.Order(orderExpr) } } else { queryBuilder = queryBuilder.Order(query.App.CreateTime.Desc()) } } else { queryBuilder = queryBuilder.Order(query.App.CreateTime.Desc()) } // 6. 执行分页查询 appList, err := queryBuilder.Offset(offset).Limit(req.PageSize).Find() if err != nil { return nil, err } // 7. 转换为AppVo列表 appVoList, err := s.GetAppVoList(ctx, appList) if err != nil { return nil, err } // 8. 构建分页响应 pageResponse := &response.PageResponse[vo.AppVo]{ Records: appVoList, PageNum: req.PageNum, PageSize: req.PageSize, TotalPage: totalPage, TotalRow: int(totalCount), OptimizeCountQuery: false, } return pageResponse, nil } ``` ##### 精选应用列表接口 **接口路径:** `POST /app/good/list/page/vo` **功能说明:** 分页获取精选应用列表(priority > 0),无需登录,支持多条件查询。 ###### API 层 **请求结构体:** ```go type YiKouAppFeaturedListRequest struct { request.PageRequest AppName string `json:"appName"` CodeGenType string `json:"codeGenType"` InitPrompt string `json:"initPrompt"` Priority int32 `json:"priority"` } ``` **请求字段说明:** | 字段名 | 类型 | JSON 标签 | 说明 | 必填 | | ----------- | ------ | ----------- | -------------------------- | ---- | | PageNum | int | pageNum | 页码,默认 1 | 否 | | PageSize | int | pageSize | 每页大小,默认 20,最大 20 | 否 | | SortField | string | sortField | 排序字段 | 否 | | SortOrder | string | sortOrder | 排序方式(asc/desc) | 否 | | AppName | string | appName | 应用名称(模糊查询) | 否 | | CodeGenType | string | codeGenType | 代码生成类型 | 否 | | InitPrompt | string | initPrompt | 初始化 Prompt(模糊查询) | 否 | | Priority | int32 | priority | 优先级 | 否 | **响应结构体:** ```go type YiKouAppFeaturedListResponse response.BaseResponse[response.PageResponse[vo.AppVo]] ``` ###### Handler 层 **接口实现:** ```go func (a *AppHandler) ListGoodApp(ctx context.Context, c *app.RequestContext) { req := &api.YiKouAppFeaturedListRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } pageResponse, err := a.appService.ListGoodApp(ctx, req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[*response.PageResponse[vo.AppVo]](pageResponse)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) ListGoodApp(ctx context.Context, req *api.YiKouAppFeaturedListRequest) (*response.PageResponse[vo.AppVo], error) { // 1. 参数校验和默认值设置 if req.PageNum <= 0 { req.PageNum = 1 } if req.PageSize <= 0 { req.PageSize = 20 } if req.PageSize > 20 { req.PageSize = 20 } // 2. 构建查询条件(精选应用:priority > 0) queryBuilder := query.Use(s.db).App.Where(query.App.IsDelete.Eq(0), query.App.Priority.Gt(0)) // 3. 添加查询条件 if req.AppName != "" { queryBuilder = queryBuilder.Where(query.App.AppName.Like("%" + req.AppName + "%")) } if req.CodeGenType != "" { queryBuilder = queryBuilder.Where(query.App.CodeGenType.Eq(req.CodeGenType)) } if req.InitPrompt != "" { queryBuilder = queryBuilder.Where(query.App.InitPrompt.Like("%" + req.InitPrompt + "%")) } if req.Priority != 0 { queryBuilder = queryBuilder.Where(query.App.Priority.Eq(req.Priority)) } // 4. 查询总数 totalCount, err := queryBuilder.Count() if err != nil { return nil, err } // 5. 计算分页信息 totalPage := int((totalCount + int64(req.PageSize) - 1) / int64(req.PageSize)) offset := (req.PageNum - 1) * req.PageSize // 6. 设置排序(默认按优先级降序、创建时间降序) if req.SortField != "" { if orderExpr, ok := query.App.GetFieldByName(req.SortField); ok { if req.SortOrder == "desc" { queryBuilder = queryBuilder.Order(orderExpr.Desc()) } else { queryBuilder = queryBuilder.Order(orderExpr) } } else { queryBuilder = queryBuilder.Order(query.App.Priority.Desc(), query.App.CreateTime.Desc()) } } else { queryBuilder = queryBuilder.Order(query.App.Priority.Desc(), query.App.CreateTime.Desc()) } // 7. 执行分页查询 appList, err := queryBuilder.Offset(offset).Limit(req.PageSize).Find() if err != nil { return nil, err } // 8. 转换为AppVo列表 appVoList, err := s.GetAppVoList(ctx, appList) if err != nil { return nil, err } // 9. 构建分页响应 pageResponse := &response.PageResponse[vo.AppVo]{ Records: appVoList, PageNum: req.PageNum, PageSize: req.PageSize, TotalPage: totalPage, TotalRow: int(totalCount), } return pageResponse, nil } ``` ##### 管理员更新应用接口 **接口路径:** `POST /app/admin/update` **功能说明:** 管理员更新应用信息,可更新应用名称、封面和优先级,无需权限校验。 ###### API 层 **请求结构体:** ```go type YiKouAppAdminUpdateRequest struct { Id string `json:"id"` AppName string `json:"appName"` Cover string `json:"cover"` Priority int32 `json:"priority"` } ``` **响应结构体:** ```go type YiKouAppAdminUpdateResponse response.BaseResponse[bool] ``` ###### Handler 层 **接口实现:** ```go func (a *AppHandler) AdminUpdateApp(ctx context.Context, c *app.RequestContext) { req := &api.YiKouAppAdminUpdateRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } success, err := a.appService.AdminUpdateApp(ctx, req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[bool](success)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) AdminUpdateApp(ctx context.Context, req *api.YiKouAppAdminUpdateRequest) (bool, error) { // 1. 参数校验 if req.Id == "" { return false, errorutil.ParamsError.WithMessage("应用ID不能为空") } appId, err := strconv.Atoi(req.Id) if err != nil { return false, err } // 2. 查询应用 _, err = query.Use(s.db).App.Where(query.App.ID.Eq(int64(appId))).First() if err != nil { return false, err } // 3. 构建更新字段 updateMap := make(map[string]interface{}) if req.AppName != "" { updateMap["appName"] = req.AppName } if req.Cover != "" { updateMap["cover"] = req.Cover } updateMap["priority"] = req.Priority // 4. 执行更新 _, err = query.Use(s.db).App.Where(query.App.ID.Eq(int64(appId))).Updates(updateMap) if err != nil { return false, err } return true, nil } ``` ##### 管理员删除应用接口 **接口路径:** `POST /app/admin/delete` **功能说明:** 管理员删除应用,使用逻辑删除,无需权限校验。 ###### API 层 **请求结构体:** ```go type DeleteRequest struct { Id int `json:"id"` } ``` **响应结构体:** ```go type YiKouAppAdminDeleteResponse response.BaseResponse[bool] ``` ###### Handler 层 **接口实现:** ```go func (a *AppHandler) AdminDeleteApp(ctx context.Context, c *app.RequestContext) { req := &request.DeleteRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } success, err := a.appService.AdminDeleteApp(ctx, int64(req.Id)) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[bool](success)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) AdminDeleteApp(ctx context.Context, id int64) (bool, error) { // 逻辑删除应用 _, err := query.Use(s.db).App.Where(query.App.ID.Eq(id)).Update(query.App.IsDelete, 1) if err != nil { return false, err } return true, nil } ``` ##### 管理员获取应用详情接口 **接口路径:** `GET /app/admin/get/vo` **功能说明:** 管理员根据 ID 获取应用详情,无需权限校验。 ###### API 层 **请求参数:** - `id`(query 参数):应用 ID **响应结构体:** ```go type YiKouAppAdminGetResponse response.BaseResponse[vo.AppVo] ``` ###### Handler 层 **接口实现:** ```go func (a *AppHandler) AdminGetAppVo(ctx context.Context, c *app.RequestContext) { id := c.Query("id") if id == "" { c.JSON(consts.StatusOK, response.NewErrorResponse[any](errorutil.ParamsError)) return } idInt64, _ := strconv.ParseInt(id, 10, 64) appVo, err := a.appService.AdminGetAppVo(ctx, idInt64) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[vo.AppVo](appVo)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) AdminGetAppVo(ctx context.Context, id int64) (vo.AppVo, error) { // 1. 查询应用 app, err := query.Use(s.db).App.Where(query.App.ID.Eq(id)).First() if err != nil { return vo.AppVo{}, err } // 2. 获取用户信息 userVo, err := s.userService.GetUserVo(ctx, app.UserID) if err != nil { return vo.AppVo{}, err } // 3. 构建应用 VO appVo := vo.AppVo{ ID: app.ID, AppName: app.AppName, Cover: app.Cover, InitPrompt: app.InitPrompt, CodeGenType: app.CodeGenType, DeployKey: app.DeployKey, DeployedTime: app.DeployedTime, Priority: app.Priority, UserID: app.UserID, User: userVo, CreateTime: app.CreateTime, UpdateTime: app.UpdateTime, } return appVo, nil } ``` ##### 管理员应用列表接口 **接口路径:** `POST /app/admin/list/page/vo` **功能说明:** 管理员分页获取所有应用列表,支持多条件查询,无需权限校验。 ###### API 层 **请求结构体:** ```go type YiKouAppAdminListRequest struct { request.PageRequest ID string `json:"id"` AppName string `json:"appName"` Cover string `json:"cover"` InitPrompt string `json:"initPrompt"` CodeGenType string `json:"codeGenType"` DeployKey string `json:"deployKey"` DeployedTime string `json:"deployedTime"` Priority int32 `json:"priority"` UserID int64 `json:"userId"` } ``` **响应结构体:** ```go type YiKouAppAdminListResponse response.BaseResponse[response.PageResponse[model.App]] ``` **注意:** 管理员列表返回的是 `model.App` 实体,而不是 `vo.AppVo`。 ###### Handler 层 **接口实现:** ```go func (a *AppHandler) AdminListApp(ctx context.Context, c *app.RequestContext) { req := &api.YiKouAppAdminListRequest{} err := c.BindAndValidate(req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } pageResponse, err := a.appService.AdminListApp(ctx, req) if err != nil { c.JSON(consts.StatusOK, response.NewErrorResponse[any](err)) return } c.JSON(consts.StatusOK, response.NewSuccessResponse[*response.PageResponse[*model.App]](pageResponse)) } ``` ###### Service 层 **业务逻辑实现:** ```go func (s *AppService) AdminListApp(ctx context.Context, req *api.YiKouAppAdminListRequest) (*response.PageResponse[*model.App], error) { // 1. 参数校验和默认值设置 if req.PageNum <= 0 { req.PageNum = 1 } if req.PageSize <= 0 { req.PageSize = 20 } if req.PageSize > 20 { req.PageSize = 20 } // 2. 构建查询条件 queryBuilder := query.Use(s.db).App.Where(query.App.IsDelete.Eq(0)) // 3. 添加查询条件 if req.ID != "" { id, _ := strconv.ParseInt(req.ID, 10, 64) queryBuilder = queryBuilder.Where(query.App.ID.Eq(id)) } if req.AppName != "" { queryBuilder = queryBuilder.Where(query.App.AppName.Like("%" + req.AppName + "%")) } if req.Cover != "" { queryBuilder = queryBuilder.Where(query.App.Cover.Like("%" + req.Cover + "%")) } if req.InitPrompt != "" { queryBuilder = queryBuilder.Where(query.App.InitPrompt.Like("%" + req.InitPrompt + "%")) } if req.CodeGenType != "" { queryBuilder = queryBuilder.Where(query.App.CodeGenType.Eq(req.CodeGenType)) } if req.DeployKey != "" { queryBuilder = queryBuilder.Where(query.App.DeployKey.Like("%" + req.DeployKey + "%")) } if req.Priority != 0 { queryBuilder = queryBuilder.Where(query.App.Priority.Eq(req.Priority)) } if req.UserID != 0 { queryBuilder = queryBuilder.Where(query.App.UserID.Eq(req.UserID)) } // 4. 查询总数 totalCount, err := queryBuilder.Count() if err != nil { return nil, err } // 5. 计算分页信息 totalPage := int((totalCount + int64(req.PageSize) - 1) / int64(req.PageSize)) offset := (req.PageNum - 1) * req.PageSize // 6. 设置排序 if req.SortField != "" { if orderExpr, ok := query.App.GetFieldByName(req.SortField); ok { if req.SortOrder == "desc" { queryBuilder = queryBuilder.Order(orderExpr.Desc()) } else { queryBuilder = queryBuilder.Order(orderExpr) } } else { queryBuilder = queryBuilder.Order(query.App.CreateTime.Desc()) } } else { queryBuilder = queryBuilder.Order(query.App.CreateTime.Desc()) } // 7. 执行分页查询 appList, err := queryBuilder.Offset(offset).Limit(req.PageSize).Find() if err != nil { return nil, err } // 8. 构建分页响应 pageResponse := &response.PageResponse[*model.App]{ Records: appList, PageNum: req.PageNum, PageSize: req.PageSize, TotalPage: totalPage, TotalRow: int(totalCount), } return pageResponse, nil } ``` ### 三、实现应用生成接口 应用生成接口是本项目的核心功能,实现了用户与应用的AI对话,实时生成代码并保存。本节我将详细讲解应用生成接口的实现,包括流式响应、代码解析、代码保存等关键流程。 #### 接口实现详解 ##### Handler 层实现 **文件位置:** `internal/handler/app_handler.go` **接口实现:** ```go // ChatToGenCode 应用聊天生成代码(流式) // @Summary 应用聊天生成代码(流式) // @Description 应用聊天生成代码(流式) // @Tags 应用模块 // @Accept json // @Produce json // @Param appId query string true "应用ID" // @Param message query string true "消息" // @Router /app/chat/gen/code [get] func (a *AppHandler) ChatToGenCode(ctx context.Context, c *app.RequestContext) { // 1. 设置 SSE 响应头 c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") // 2. 获取请求参数 appIdStr := c.Query("appId") w := sse.NewWriter(c) lastEventID := sse.GetLastEventID(&c.Request) if appIdStr == "" { c.JSON(consts.StatusOK, response.NewErrorResponse[any](errorutil.ParamsError.WithMessage("应用ID不能为空"))) return } message := c.Query("message") if message == "" { _ = w.WriteEvent(lastEventID, "error", []byte("消息不能为空")) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } // 3. 获取当前登录用户 userVo, err := a.userService.GetLoginUserVo(ctx, c) if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } // 4. 转换应用ID appId, err := strconv.ParseInt(appIdStr, 10, 64) if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } // 5. 获取流数据 streamResp, err := a.appService.ChatToGenCode(ctx, appId, message, &userVo) if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } defer streamResp.Close() // 6. 流式返回数据 var aiResponseBuilder strings.Builder for { select { case <-ctx.Done(): logger.Info("连接中断") _ = w.WriteEvent(lastEventID, "done", []byte{1}) return default: } chunk, err := streamResp.Recv() if err == io.EOF || errors.Is(err, context.Canceled) { break } if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } aiResponseBuilder.WriteString(chunk.Content) // 7. 发送SSE事件 wrapper := &map[string]string{ "d": chunk.Content, } data, err := json.Marshal(wrapper) if err != nil { logger.Errorf("序列化数据失败: %v\n", err) continue } err = w.WriteEvent(lastEventID, "message", data) if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } } // 8. 发送完成事件 _ = w.WriteEvent(lastEventID, "done", []byte{1}) } ``` **以下是我将会对某些步骤进行详解,因为我自己在当初在开发这个代码生成接口时踩了不少坑,所以我现在通过我踩过的坑给你们讲解一些重点步骤** ###### 设置 SSE 响应头 **代码:** ```go c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") ``` **详细说明:** | 响应头 | 值 | 说明 | | ----------------- | ----------------- | --------------------------------------------- | | Content-Type | text/event-stream | SSE协议要求的MIME类型,告诉浏览器这是流式事件 | | Cache-Control | no-cache | 禁止缓存,确保实时接收数据 | | Connection | keep-alive | 保持长连接,不断开TCP连接 | | X-Accel-Buffering | no | 禁用Nginx缓冲,确保数据实时传输 | ###### 获取流数据 **代码:** ```go streamResp, err := a.appService.ChatToGenCode(ctx, appId, message, &userVo) if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } defer streamResp.Close() ``` **流式响应说明:** - `streamResp`是 `*schema.StreamReader[*schema.Message]`类型 - 使用 `Recv()`方法接收流数据 - 使用 `Close()`方法关闭流 - 必须使用defer确保流关闭,避免资源泄漏 ###### 循环读取流式数据 **代码:** ```go var aiResponseBuilder strings.Builder for { select { case <-ctx.Done(): logger.Info("连接中断") _ = w.WriteEvent(lastEventID, "done", []byte{1}) return default: } chunk, err := streamResp.Recv() if err == io.EOF || errors.Is(err, context.Canceled) { break } if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } aiResponseBuilder.WriteString(chunk.Content) // ... 发送SSE事件 } ``` **详细说明:** | 操作 | 说明 | 技术点 | | ---------------- | -------------- | ------------------ | | strings.Builder | 构建完整响应 | 用于收集所有流数据 | | for循环 | 持续接收流数据 | 直到EOF或错误 | | select | 监听上下文取消 | 处理连接中断 | | Recv() | 接收流数据块 | 返回Message结构体 | | io.EOF | 流结束标志 | 正常结束 | | context.Canceled | 上下文取消 | 用户取消或超时 | ###### 发送SSE事件 **代码:** ```go wrapper := &map[string]string{ "d": chunk.Content, } data, err := json.Marshal(wrapper) if err != nil { logger.Errorf("序列化数据失败: %v\n", err) continue } err = w.WriteEvent(lastEventID, "message", data) if err != nil { _ = w.WriteEvent(lastEventID, "error", []byte(fmt.Sprintf("%v", err))) _ = w.WriteEvent(lastEventID, "done", []byte{1}) return } ``` 原生的data数据在传输数据的时候会丢失空格,影响了原本的内容格式。这里我们可以包装成json格式发送给前端,由前端再去解析json格式,这样就保证了格式的一致性了 **SSE事件格式:** ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/stLOlLhTVvv10Y7L.webp) ###### 发送完成事件 **代码:** ```go _ = w.WriteEvent(lastEventID, "done", []byte{1}) ``` **完成事件说明:** - 流正常结束后发送 - 前端收到此事件后关闭SSE连接 - 数据为 `[]byte{1}`,表示成功完成 这里我也是踩过一个非常致命的坑,我在前端调试的过程中,发现每次后端结束流的时候都没有发送完成事件。后来我发现,原来是因为hertz的sse库发送事件必须得夹带数据,不然就不会发送,当场整个人都红温了 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/HPeioZ6xzs7x5DsT.webp) ##### Service 层实现 **文件位置:** `internal/logic/app_logic.go` **业务逻辑实现:** ```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("应用代码生成类型不支持") } // 5. 调用代码生成服务 return s.aiCodeGenFacade.GenCodeStreamAndSave(ctx, message, enum.CodeGenTypeEnum(app.CodeGenType), appId) } ``` ##### 修改 AI 代码生成门面结构体 **文件位置:** `internal/core/ai_codegen_facade.go` **流式生成并保存代码方法增加appId参数:** ```go func (y *YiKouAiCodegenFacade) GenCodeStreamAndSave(ctx context.Context, userMessage string, typeStr enum.CodeGenTypeEnum, appId int64) (*schema.StreamReader[*schema.Message], error) { switch typeStr { case enum.HtmlCodeGen: streamResp, err := y.codegenService.GenerateHtmlCodeStream(ctx, userMessage) if err != nil { return nil, err } return y.processCodeStream(streamResp, typeStr, appId) case enum.MultiFileGen: streamResp, err := y.codegenService.GenerateMultiFileCodeStream(ctx, userMessage) if err != nil { return nil, err } return y.processCodeStream(streamResp, typeStr, appId) default: return nil, fmt.Errorf("不支持的代码生成类型: %s", typeStr) } } ``` **处理代码流方法也一样:** ```go func (y *YiKouAiCodegenFacade) processCodeStream(respStream *schema.StreamReader[*schema.Message], typeStr enum.CodeGenTypeEnum, appId int64) (*schema.StreamReader[*schema.Message], error) { // 1. 复制流,一个用于处理,一个返回给上游 streams := respStream.Copy(2) processingStream := streams[0] returnStream := streams[1] // 2. 在 goroutine 中处理流数据,不阻塞返回 go func() { var builder strings.Builder defer processingStream.Close() // 3. 接收完整的流数据 for { chunk, err := processingStream.Recv() if err == io.EOF { break } if err != nil { return } builder.WriteString(chunk.Content) } // 4. 解析代码 parsedResp, err := y.codeParserExecutor.ExecuteParser(builder.String(), typeStr) if err != nil { return } // 5. 保存代码(传入appId) dirPath, err := y.codeFileSaverExecutor.ExecuteSaver(parsedResp, typeStr, appId) if err != nil { return } logger.Info("代码已保存到目录: %s", dirPath) }() return returnStream, nil } ``` ##### 代码保存器实现 **文件位置:** `internal/core/saver/codefile_saver.go` **代码保存执行器的执行方法增加appId参数:** ```go func (e *CodeFileSaverExecutor) ExecuteSaver(content interface{}, saveType enum.CodeGenTypeEnum, appId int64) (string, error) { switch saveType { case enum.HtmlCodeGen: return e.htmlCodeFileSaver.saveCode(content.(*aimodel.HtmlCodeResponse), appId) case enum.MultiFileGen: return e.multiFileCodeFileSaver.saveCode(content.(*aimodel.MultiFileCodeResponse), appId) default: return "", fmt.Errorf("不支持的代码文件类型: %s", saveType) } } ``` **代码保存模板的两个方法也一样:** ```go type CodeFileSaverTemplate[T any] struct { CodeFileSaver[T] } func (d *CodeFileSaverTemplate[T]) saveCode(response T, appId int64) (string, error) { err := d.validateInput(response) if err != nil { return "", err } dirPath, err := d.buildUniqueDir(appId) if err != nil { return "", err } return dirPath, d.saveFiles(response, dirPath) } // buildUniqueDir 构建唯一的目录名 // 目录名格式: {代码生成类型}_{唯一ID} func (d *CodeFileSaverTemplate[T]) buildUniqueDir(appId int64) (string, error) { if appId == 0 { return "", fmt.Errorf("应用id不能为空") } //构建唯一目录名 fileSaveDir, err := myfile.GetCodeOutputRoot() uniqueDirName := fmt.Sprintf("%s_%s", d.getCodeType(), strconv.FormatUint(uint64(appId), 20)) dirPath := filepath.Join(fileSaveDir, uniqueDirName) // 创建目录 err = os.MkdirAll(dirPath, os.ModePerm) if err != nil { return "", err } return dirPath, nil } ``` ##### 修改测试方法增加appId **文件位置:** `internal/core/ai_codegen_facade_test.go` **流式生成测试:** ```go func TestYiKouAiCodegenFacade_GenCodeStreamAndSave(t *testing.T) { config.SetEnvFlag("local") // 解析命令行参数 initConfig := config.InitConfig() chatModel := llm.NewChatModel(initConfig) codeGenAgent := agent.NewCodeGenAgent(chatModel, enum.MultiFileGen) parserExecutor := parser.NewCodeParserExecutor() fileSaverExecutor := saver.NewCodeFileSaverExecutor() aiCodegenFacade := NewYiKouAiCodegenFacade(codeGenAgent, parserExecutor, fileSaverExecutor) // 调用流式生成方法(传入appId) resp, err := aiCodegenFacade.GenCodeStreamAndSave(context.Background(), "帮我生成一个日常记录网站", enum.MultiFileGen, 1) if err != nil { panic(err) } var builder strings.Builder for { message, err := resp.Recv() if err != nil { break } builder.WriteString(message.Content) } assert.NotNil(t, builder.String()) } ``` ##### 修改路由配置 **文件位置:** `internal/router/router.go` **增加接口声明:** ```go appRoute := h.Group("/app") { // ... 其他路由 // 需要登录的接口 appRoute.GET("/chat/gen/code", middleware.AuthMiddleware(enum.UserRole, db), appHandler.ChatToGenCode) // ... 其他路由 } ``` ##### 修改依赖注入配置 **文件位置:** `wire/wire.go` **修改服务依赖注入(记得把包引入修改成自己的包):** ```go //go:build wireinject package wire import ( "fmt" "github.com/cloudwego/hertz/pkg/app/server" "github.com/google/wire" "github.com/hertz-contrib/swagger" "gorm.io/gorm" "strconv" "yikou-ai-go-teach/config" "yikou-ai-go-teach/docs" "yikou-ai-go-teach/internal/ai" "yikou-ai-go-teach/internal/ai/agent" "yikou-ai-go-teach/internal/ai/llm" "yikou-ai-go-teach/internal/core" "yikou-ai-go-teach/internal/core/parser" "yikou-ai-go-teach/internal/core/saver" "yikou-ai-go-teach/internal/dal" "yikou-ai-go-teach/internal/handler" "yikou-ai-go-teach/internal/logic" "yikou-ai-go-teach/internal/router" "yikou-ai-go-teach/internal/service" ) // 配置依赖 var configSet = wire.NewSet( config.InitConfig, ) var llmSet = wire.NewSet(llm.NewChatModel) // 数据库依赖 var dbSet = wire.NewSet( dal.InitDB, ) // Service依赖 var serviceSet = wire.NewSet( core.NewYiKouAiCodegenFacade, logic.NewAppService, wire.Bind(new(service.IAppService), new(*logic.AppService)), logic.NewUserService, wire.Bind(new(service.IUserService), new(*logic.UserService)), agent.NewTestCodeGenAgent, wire.Bind(new(ai.IYiKouAiCodegenService), new(*agent.CodeGenAgent)), ) // Handler依赖 var handlerSet = wire.NewSet( handler.NewUserHandler, handler.NewAppHandler, ) // initServer 初始化 Web 服务器 func initServer(cfg *config.Config, userHandler *handler.UserHandler, appHandler *handler.AppHandler, db *gorm.DB) *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) return h } // InitializeApp 初始化所有依赖(依赖图) func InitializeApp() (*server.Hertz, error) { panic(wire.Build( initServer, configSet, dbSet, serviceSet, handlerSet, llmSet, parser.NewCodeParserExecutor, saver.NewCodeFileSaverExecutor, )) } ``` 在项目的根目录下执行wire生成命令,生成注入文件 ```bash cd ./wire wire ``` 在ide的命令台执行swagger的api文档生成命令 ```bash swag init ``` ## 四、测试sse接口 由于sse接口不能像之前一样在swagger文档上测试,所以我们直接在前端页面上测试,尽管现在没有开发完所有的后端接口,但是我们仍然能看到接口的效果。这里你们直接到我的GitHub教学仓库[https://github.com/FeiWuSama/yikou-ai-go-teach](https://github.com/FeiWuSama/yikou-ai-go-teach)克隆或者复制仓库下载前端源码就行了。获得前端源码直接进入前端源码的根目录,打开该目录的cmd窗口,输入以下前端运行命令即可:npm run dev ```bash npm run dev ``` ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/QsrRv2AHbIIvo7lx.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/oRbmxVrfWHpTuv9J.webp) 然后再启动后端服务,但是一定要记住后端的服务启动端口要和前端的反向代理的配置端口一致。这里我就不教大家怎样修改前端配置了,直接修改后端的配置文件保持和前端的配置文件一样就行了 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/LsckZ0dV428iGcEc.webp) 我们启动完前端和后端,直接打开浏览器在正上方访问 [http://localhost:5173/](http://localhost:5173/) 就可以测试了 我们先在右上方登录之前注册过的账号,这里记得先提前按f12打开前端开发控制台实时观察流式输出过程,然后在对话框输入提示词: ```markdown 请帮我生成一个简单的任务记录工具网站 ``` 然后点击发送按钮 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/IDfIdIdNZ9JtIyJr.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/s9URiPCqZlNGVPWy.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/inXLJYy1aPE3mFUc.webp) 可以看到,前端的效果是正常的,到这里我们的应用模块的已经基本完成了。在下一章,我们将会进一步拓展代码生成智能体,赋予其记忆能力,并且开发出对话记忆模块,请大家尽情期待。要是对该教程感兴趣的,可以star一下仓库 [https://github.com/FeiWuSama/yikou-ai-go](https://github.com/FeiWuSama/yikou-ai-go) 给予博主更多支持哦,谢谢各位看到这里的读者!

易扣AI (Go + CloudWeGo) 企业级AI智能体项目教程 第3章:用Eino实现AI应用生成逻辑设计

> 本章将深入讲解如何使用 AI 技术实现代码应用生成功能。我们将从需求分析开始,设计完整的解决方案,介绍字节跳动开源的 Eino 框架,实现 AI 代码生成功能,集成 Hertz 的 SSE 流式输出,并探讨优化设计模式。 ## 知识点清单 ### 一、需求分析 #### **AI 代码生成的应用场景** 本项目的核心目标是实现一个智能应用代码生成系统,支持用户通过自然语言描述需求,AI 自动生成相应的代码应用。而本章先实现基本的需求场景,封装ai为智能体,然后使ai能生成原生网页代码,并保存到本地 #### 两种代码生成逻辑设计 本项目支持两种核心的代码生成模式,满足不同场景的需求。 ##### 原生 HTML 代码生成 只生成一个html文件,将所有代码(html,css,js)全部封装到一个文件中,满足简单网页应用的生成 ##### 原生多文件代码生成 按照标准的前端项目架构,分别生成html文件、css文件和js文件 ### 二、方案设计 #### 整体架构流程设计 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/JhHvkDecQMZ4ZTg3.png) #### AI 模型选型 AI 模型的选择是项目的核心决策之一,需要综合考虑性能、成本、稳定性、合规性等多个维度。考虑到学习该项目的成本,我优先推荐各位使用阿里云的百炼平台接入ai服务,相比于本地部署和使用外国的大模型,阿里云百炼平台的大模型更具有性价比。当然,我选择阿里云的百炼平台是受限于学习成本,要是在实际的公司业务需求中,项目经理肯定会更综合的考虑使用什么模型,但我们只需要了解如何接入模型,如何将模型运用到自己的项目就行了 ##### 阿里云百炼平台详解 **平台概述:** **阿里云百炼**是基于通义大模型的一站式大模型应用开发平台,提供从模型训练、部署到应用开发的全链路服务。 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/ARFa5y7r76ItMYRO.webp) 在百炼平台的上方我们切换为全部模型 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/KWJNJYUmQR1gTMWn.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/rtbIaoenSfJAgqOX.webp) 只要随便点击一个大模型,我们就能查看同系列的所有大模型的具体使用信息,例如:该大模型支持不支持function calling和结构化输出、该模型的token使用价格之类的信息 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/H3Jb9vxY4pAFsftf.webp) **核心优势:** | 优势 | 说明 | | ------------------ | ---------------------------------------- | | **性价比高** | 相比国际模型,价格更具优势 | | **易于集成** | 提供完善的SDK和API接口 | | **模型丰富** | 支持多种通义模型(通义千问、通义万相等) | 这里综合考虑,我最后选用了deepseek-v3.2作为该项目使用的大模型,大家也可以选用自己喜欢的大模型进行使用,下面我们将要修改原先占位的ai配置属性 **百炼平台配置:** ```yaml # AI服务配置 ai: chat-model: base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 api-key: <你的api-key> model-name: deepseek-v3.2 memory-store: redis memory-ttl: 3600 ``` 修改 `config/config.go`的AIConfig结构体 ```go type AIConfig struct { ChatModel ChatModelConfig `yaml:"chat-model" mapstructure:"chat-model"` } type ChatModelConfig struct { BaseURL string `yaml:"base-url" mapstructure:"base-url"` APIKey string `yaml:"api-key" mapstructure:"api-key"` ModelName string `yaml:"model-name" mapstructure:"model-name"` MemoryTTL int `yaml:"memory-ttl" mapstructure:"memory-ttl"` } ``` ##### 设计提示词 提示词是影响AI生成文本效果的决定性因素,设计好一个智能体的第一步是设计一段高质量的提示词,关于如何编写提示词的诀窍我就不在这里展开讲了,详细可以参考下阿里云的标准:[https://help.aliyun.com/zh/model-studio/use-cases/prompt-engineering-guide](https://help.aliyun.com/zh/model-studio/use-cases/prompt-engineering-guide) 大家也可以直接叫ai按照规范生成一份提示词,然后在使用生成的提示词测试一下看看效果,我在下面就直接给出我自己的提示词了 ###### 1. HTML单文件代码生成提示词 **文件位置:** `prompt/codegen-html-system-prompt.txt` **提示词内容:** ```markdown 你是一位资深的 Web 前端开发专家,精通 HTML、CSS 和原生 JavaScript。你擅长构建响应式、美观且代码整洁的单页面网站。 你的任务是根据用户提供的网站描述,生成一个完整、独立的单页面网站。你需要一步步思考,并最终将所有代码整合到一个 HTML 文件中。 约束: 1. 技术栈: 只能使用 HTML、CSS 和原生 JavaScript。 2. 禁止外部依赖: 绝对不允许使用任何外部 CSS 框架、JS 库或字体库。所有功能必须用原生代码实现。 3. 独立文件: 必须将所有的 CSS 代码都内联在 `<head>` 标签的 `<style>` 标签内,并将所有的 JavaScript 代码都放在 `</body>` 标签之前的 `<script>` 标签内。最终只输出一个 `.html` 文件,不包含任何外部文件引用。 4. 响应式设计: 网站必须是响应式的,能够在桌面和移动设备上良好显示。请优先使用 Flexbox 或 Grid 进行布局。 5. 内容填充: 如果用户描述中缺少具体文本或图片,请使用有意义的占位符。例如,文本可以使用 Lorem Ipsum,图片可以使用 https://picsum.photos 的服务 (例如 `<img src="https://picsum.photos/800/600" alt="Placeholder Image">`)。 6. 代码质量: 代码必须结构清晰、有适当的注释,易于阅读和维护。 7. 交互性: 如果用户描述了交互功能 (如 Tab 切换、图片轮播、表单提交提示等),请使用原生 JavaScript 来实现。 8. 安全性: 不要包含任何服务器端代码或逻辑。所有功能都是纯客户端的。 9. 输出格式: 你的最终输出必须包含 HTML 代码块,可以在代码块之外添加解释、标题或总结性文字。格式如下: ```html ... HTML 代码 ... ... 对代码生成的解释性文字 ... 特别注意:在生成代码后,用户可能会提出修改要求并给出要修改的元素信息。 1. 你必须严格按照要求修改,不要额外修改用户要求之外的元素和内容 2. 确保始终最多输出 1 个 HTML 代码块,里面包含了完整的页面代码(而不是要修改的部分代码)。 3. 一定不能输出超过 1 个代码块,否则会导致保存错误! ``` ###### 2. 多文件代码生成提示词 **文件位置:** `prompt/codegen-multi-file-system-prompt.txt` **提示词内容:** ```markdown 你是一位资深的Web 前端开发专家,你精‌通编写结构化的 HTML、清晰的 CSS 和高效的原生JavaScript,遵循代؜码分离和模块化的最佳实践。 你的任务是根据用户提供的网站描述,创建构成一个完整单页网站所需的三个核心文件:HTML, CSS, 和 JavaScript。你需要在最终输出时,将这三部分代码分别放入三个独立的 Markdown 代码块中,并明确标注文件名。 约束: 1. 技术栈: 只能使用 HTML、CSS 和原生 JavaScript。 2. 文件分离: - index.html: 只包含网页的结构和内容。它必须在 `<head>` 中通过 `<link>` 标签引用 `style.css`,并且在 `</body>` 结束标签之前通过 `<script>` 标签引用 `script.js`。 - style.css: 包含网站所有的样式规则。 - script.js: 包含网站所有的交互逻辑。 3. 禁止外部依赖: 绝对不允许使用任何外部 CSS 框架、JS 库或字体库。所有功能必须用原生代码实现。 4. 响应式设计: 网站必须是响应式的,能够在桌面和移动设备上良好显示。请在 CSS 中使用 Flexbox 或 Grid 进行布局。 5. 内容填充: 如果用户描述中缺少具体文本或图片,请使用有意义的占位符。例如,文本可以使用 Lorem Ipsum,图片可以使用 https://picsum.photos 的服务 (例如 `<img src="https://picsum.photos/800/600" alt="Placeholder Image">`)。 6. 代码质量: 代码必须结构清晰、有适当的注释,易于阅读和维护。 7. 输出格式: 每个代码块前要注明文件名。可以在代码块之外添加解释、标题或总结性文字。格式如下: ```html ... HTML 代码 ... ```css ... CSS 代码 ... ```javascript ... JavaScript 代码 ... ... 对代码生成的解释性文字 ... 特别注意:在生成代码后,用户可能会提出修改要求并给出要修改的元素信息。 1. 你必须严格按照要求修改,不要额外修改用户要求之外的元素和内容 2. 确保始终最多输出 1 个 HTML 代码块 + 1 个 CSS 代码块 + 1 个 JavaScript 代码块,里面包含了完整的页面代码(而不是要修改的部分代码)。 3. 每种语言的代码块一定不能输出超过 1 个,否则会导致保存错误! ``` ### 三、Eino 框架介绍 #### Eino 框架概述 **Eino['aino]** (近似音: i know,希望框架能达到 "i know" 的愿景) 旨在提供基于 Go 语言的终极大模型应用开发框架。它从开源社区中的诸多优秀 LLM 应用开发框架,如 LangChain 和 LlamaIndex 等获取灵感,同时借鉴前沿研究成果与实际应用,提供了一个强调简洁性、可扩展性、可靠性与有效性,且更符合 Go 语言编程惯例的 LLM 应用开发框架。 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/p2bormIgXEznGhMZ.webp) **在这里,我也吐槽一下自己,我之前一直将'a的发音误以为是i的发言,在查完官网才知道,绝了** **官网地址:** [https://www.cloudwego.io/zh/docs/eino/](https://www.cloudwego.io/zh/docs/eino/) **GitHub 仓库:** [https://github.com/cloudwego/eino](https://github.com/cloudwego/eino) ##### Eino 提供的价值 Eino 为开发者提供以下核心价值: | 价值 | 说明 | | ------------------------------- | ------------------------------------------------------------------------------------ | | **组件抽象与实现** | 精心整理的一系列组件(component)抽象与实现,可轻松复用与组合,用于构建 LLM 应用 | | **智能体开发套件(ADK)** | 提供构建 AI 智能体的高级抽象,支持多智能体编排、人机协作中断机制以及预置的智能体模式 | | **强大的编排框架** | 为用户承担繁重的类型检查、流式处理、并发管理、切面注入、选项赋值等工作 | | **简洁的 API** | 一套精心设计、注重简洁明了的 API | | **最佳实践集合** | 以集成流程(flow)和示例(example)形式不断扩充的最佳实践集合 | | **实用工具(DevOps)** | 一套实用工具,涵盖从可视化开发与调试到在线追踪与评估的整个开发生命周期 | ##### Eino vs LangChain-Go LangChain-Go 是 LangChain 的 Go 语言实现,而 Eino 是字节跳动基于 Go 语言开发的 LLM 应用框架。两者都是优秀的 LLM 应用开发框架,但在设计理念、技术实现和适用场景上有所不同。 **1. 项目背景对比** | 对比维度 | Eino | LangChain-Go | | ------------------ | ------------------------------------------------------ | --------------------------- | | **开发团队** | 字节跳动 CloudWeGo 团队 | LangChain 社区 | | **开源时间** | 2024年 | 2023年 | | **设计理念** | 强调简洁性、可扩展性、可靠性与有效性,符合 Go 语言惯例 | Python LangChain 的 Go 移植 | | **成熟度** | 在字节跳动内部经过半年以上的实践验证 | 社区驱动,持续迭代 | | **生态支持** | CloudWeGo 生态(Hertz、Kitex 等) | LangChain 生态 | **2. 技术特性对比** | 技术特性 | Eino | LangChain-Go | | -------------------- | -------------------------------------- | ---------------------------- | | **类型安全** | ✅ 强类型,编译时类型检查 | ⚠️ 部分弱类型,运行时检查 | | **流式处理** | ✅ 原生支持,自动处理流式响应 | ✅ 支持,但需要手动处理 | | **并发管理** | ✅ 自动管理,线程安全 | ⚠️ 需要开发者手动管理 | | **编排能力** | ✅ Chain、Graph、Workflow 三种编排方式 | ✅ Chain、Graph 编排 | | **组件抽象** | ✅ 清晰的组件接口定义 | ✅ 丰富的组件实现 | | **错误处理** | ✅ 完善的错误处理和恢复机制 | ⚠️ 基础的错误处理 | | **性能优化** | ✅ 针对 Go 语言优化,高性能 | ⚠️ 性能一般 | | **代码可读性** | ✅ 符合 Go 语言惯例,易读 | ⚠️ Python 风格,可读性一般 | **3. 总结** | 总结维度 | Eino | LangChain-Go | | ------------------ | ---------------------------------- | ------------------------------- | | **核心优势** | 性能优异、符合 Go 惯例、企业级支持 | Python 风格、有原型基础 | | **主要劣势** | 相对较新,生态还在完善 | 性能一般,不够 Go 化 | | **推荐指数** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | | **适合人群** | Go 开发者、企业项目、性能要求高 | Python 转型、快速原型、社区支持 | 截至我现在在做教程的时候,eino的仓库已经接近12k的star量了,而比eino还要早开源的langchaingo才9k左右的star量,甚至现在eino短短一年内就已经维护到0.8版本了准备今年1.0的正式发布(我记得我刚开始构建先项目的时候是0.7),而langchaingo开源了好几年才发布了14个版本,这开发社区活跃度也是没谁了awa,我相信大家肯定看出了两者目前的差距了。而且还要一个最重要的因素,就是eino官方文档同时支持英文和中文,大大降低了大部分新手程序员的入手难度! ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/7qKbMP30obFbmPFQ.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/dVE7hCTvPPNR2bTP.webp) #### Eino 核心概念 Eino 的核心概念围绕"组件"和"编排"展开,通过清晰的抽象和强大的编排能力,帮助开发者快速构建复杂的 LLM 应用。我们这一章先粗略地介绍下eino的核心组件**ADK Agent**,该组件是我们入手框架的第一步。 ##### ADK Agent(智能体开发套件) **定义:** ADK(Agent Development Kit)是 Eino 提供的智能体开发套件,用于构建 AI 智能体的高级抽象,支持多智能体编排、人机协作中断机制以及预置的智能体模式。 **核心价值:** | 价值 | 说明 | | ---------------------- | ------------------------------------------- | | **高级抽象** | 提供智能体级别的高级API,简化开发流程 | | **工具集成** | 自动处理工具调用、结果解析、错误处理 | | **多智能体编排** | 支持多个智能体协作完成复杂任务 | | **人机协作** | 支持中断机制,实现人在环路的交互模式 | | **预置模式** | 提供ReAct、Plan-and-Execute等常见智能体模式 | **ADK Agent 类型:** | Agent 类型 | 说明 | 适用场景 | | ----------------------------- | -------------------- | ---------------------- | | **ChatModelAgent** | 基于对话模型的智能体 | 简单对话、问答系统 | | **ReActAgent** | 推理-行动智能体 | 需要工具调用的复杂任务 | | **PlanAndExecuteAgent** | 规划-执行智能体 | 多步骤复杂任务 | | **MultiAgent** | 多智能体协作系统 | 需要多个专家协作的任务 | **1. ChatModelAgent(对话模型智能体)** 最简单的智能体,直接基于对话模型进行交互。 ```go package main import ( "context" "fmt" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/model/openai" ) func main() { ctx := context.Background() // 创建 ChatModel model, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{ Model: "gpt-4", }) // 创建 ChatModelAgent agent := adk.NewChatModelAgent(model) // 执行对话 result, _ := agent.Invoke(ctx, []*schema.Message{ schema.UserMessage("What is the capital of France?"), }) fmt.Println(result.Content) } ``` **2. ReActAgent(推理-行动智能体)** ReAct(Reasoning and Acting)是一种经典的智能体模式,通过"思考-行动-观察"的循环来完成任务。 **ReActAgent 示例:** ```go package main import ( "context" "fmt" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/model/openai" "github.com/cloudwego/eino/components/tool" ) func main() { ctx := context.Background() // 创建 ChatModel model, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{ Model: "gpt-4", }) // 定义工具 weatherTool := &tool.Tool{ Name: "get_weather", Description: "Get weather information for a city", Execute: func(ctx context.Context, city string) (string, error) { return fmt.Sprintf("Weather in %s: Sunny, 25°C", city), nil }, } searchTool := &tool.Tool{ Name: "search", Description: "Search for information on the internet", Execute: func(ctx context.Context, query string) (string, error) { return fmt.Sprintf("Search results for: %s", query), nil }, } // 创建 ReActAgent agent := adk.NewReActAgent(model, []tool.Tool{weatherTool, searchTool}) // 执行任务 result, _ := agent.Invoke(ctx, "What's the weather in Beijing?") fmt.Println(result.Content) } ``` **3. PlanAndExecuteAgent(规划-执行智能体)** **PlanAndExecuteAgent 示例:** ```go package main import ( "context" "fmt" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/model/openai" ) func main() { ctx := context.Background() // 创建 ChatModel model, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{ Model: "gpt-4", }) // 创建 PlanAndExecuteAgent agent := adk.NewPlanAndExecuteAgent(model) // 执行复杂任务 result, _ := agent.Invoke(ctx, "Research the history of AI and write a summary") fmt.Println(result.Content) } ``` **4. MultiAgent(多智能体协作)** **MultiAgent 示例:** ```go package main import ( "context" "fmt" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/model/openai" ) func main() { ctx := context.Background() // 创建 ChatModel model, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{ Model: "gpt-4", }) // 创建多个专家智能体 researchAgent := adk.NewChatModelAgent(model, adk.WithSystemPrompt("You are a research expert.")) writerAgent := adk.NewChatModelAgent(model, adk.WithSystemPrompt("You are a writing expert.")) reviewerAgent := adk.NewChatModelAgent(model, adk.WithSystemPrompt("You are a review expert.")) // 创建 MultiAgent multiAgent := adk.NewMultiAgent( adk.WithAgents(map[string]adk.Agent{ "researcher": researchAgent, "writer": writerAgent, "reviewer": reviewerAgent, }), adk.WithRouter(func(ctx context.Context, task string) string { // 根据任务内容路由到合适的智能体 if strings.Contains(task, "research") { return "researcher" } if strings.Contains(task, "write") { return "writer" } return "reviewer" }), ) // 执行任务 result, _ := multiAgent.Invoke(ctx, "Research AI history and write a summary") fmt.Println(result.Content) } ``` **5. 人机协作(Human-in-the-Loop)** 支持在智能体执行过程中插入人工干预。 ```go package main import ( "context" "fmt" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/model/openai" ) func main() { ctx := context.Background() // 创建 ChatModel model, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{ Model: "gpt-4", }) // 创建带人机协作的 Agent agent := adk.NewReActAgent(model, tools, adk.WithHumanInTheLoop(true), adk.WithInterruptPoint(func(ctx context.Context, state *adk.AgentState) bool { // 定义中断点:在执行重要操作前暂停 return state.CurrentStep == "critical_operation" }), ) // 执行任务 stream, _ := agent.Stream(ctx, "Perform critical operation") for { event, err := stream.Recv() if err == io.EOF { break } // 处理中断事件 if event.Type == adk.EventTypeInterrupt { // 等待人工输入 humanInput := getHumanInput() // 恢复执行 stream.Resume(ctx, humanInput) } fmt.Println(event.Content) } } ``` 大家也可以直接去查看官方文档更深入的学习,毕竟官方文档往往是一个人了解这个框架的入口。哪怕后面框架有较大的改动或者增加了什么新特性,大家也可以去官方文档那里直接了解详情。在官网的核心模块也有ADK Agent有更多的特性介绍,我推荐大家可以直接在官网入手。接下来,我们将要正式进入代码教程,开发项目的第一个智能体 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/dnBNjvxTeThpr2Vd.webp) ### 四、实现 AI 代码应用生成 #### 接入大模型 我们直接到百炼平台创建一个API KEY用于接入大模型 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/hFl8CW5RXQNpB2hG.webp) 然后我们需要下载eino的第三方库,在ide的终端输入以下命令 ```bash go get github.com/cloudwego/eino@v0.8.2 go get github.com/cloudwego/eino-ext/components/model/openai@v0.1.8 ``` #### 智能体封装 ##### **定义大模型配置** 在 `internal`包下新建 `/ai/llm/chat_model.go` ```go package llm import ( "context" "github.com/cloudwego/eino-ext/components/model/openai" "yikou-ai-go-teach/config" ) type ChatModelWrapper struct { *openai.ChatModel ModelName string } func NewChatModel(cfg *config.Config) *ChatModelWrapper { ctx := context.Background() modelName := cfg.AI.ChatModel.ModelName chatModel, err := openai.NewChatModel(ctx, &openai.ChatModelConfig{ BaseURL: cfg.AI.ChatModel.BaseURL, Model: modelName, APIKey: cfg.AI.ChatModel.APIKey, }) if err != nil { panic(err) } return &ChatModelWrapper{ ChatModel: chatModel, ModelName: modelName, } } func (w *ChatModelWrapper) GetChatModel() *openai.ChatModel { return w.ChatModel } func (w *ChatModelWrapper) GetModelName() string { return w.ModelName } ``` 这里也许有小伙伴会疑惑封装一个ChatModelWrapper结构体增加一个ModelName属性,这个 ModelName是用于后面的增加可观测性章节的,由于原本eino提供的ChatModel结构体没有ModelName的方法,我只好自己在原本的基础上再包装了 ##### 声明AI服务接口 在 `internal`包下新建 `/ai/ai_service.go`,定义ai服务接口是为了提供智能体具体的功能声明,该接口也遵守了go语言的接口声明实现规范,普遍使用于正常业务的代码设计中 ```go type IYiKouAiCodegenService interface { GenerateHtmlCode(ctx context.Context, userMessage string) (*schema.Message, error) GenerateMultiFileCode(ctx context.Context, userMessage string) (*schema.Message, error) } ``` ##### 封装智能体实现功能 智能体的封装实现是整个AI代码生成系统的核心部分,通过合理的封装设计,实现了代码的复用和扩展性。下面详细介绍各个文件的功能和实现细节。 ###### 代码生成类型枚举 (`pkg/enum/code_gentype.go`) **文件作用:** 定义代码生成的类型枚举,用于区分不同的代码生成模式。 **完整代码:** ```go package enum type CodeGenTypeEnum string const ( HtmlCodeGen CodeGenTypeEnum = "html" MultiFileGen CodeGenTypeEnum = "multi_file" VueCodeGen CodeGenTypeEnum = "vue_project" ) var CodeGenTypeTextMap = map[CodeGenTypeEnum]string{ HtmlCodeGen: "原生 HTML 模式", MultiFileGen: "原生多文件模式", VueCodeGen: "Vue工厂模式", } ``` ###### 修改文件路径工具类 (`pkg/myfile/path.go`) 增加获取代码保存路径的方法 **完整代码:** ```go func GetCodeOutputRoot() (string, error) { projectRoot, err := GetProjectRoot() if err != nil { return "", fmt.Errorf("获取项目根目录失败: %w", err) } return filepath.Join(projectRoot, "tmp/code_output"), nil } ``` ###### 提示词管理 (`internal/ai/myprompt/my_prompt.go`) **文件作用:** 加载和管理系统提示词,为不同的代码生成模式提供对应的提示词模板。 **完整代码:** ```go package myprompt import ( "os" "path/filepath" "sync" "yikou-ai-go-teach/pkg/myfile" "github.com/cloudwego/eino/components/prompt" "github.com/cloudwego/eino/schema" ) var ( htmlPrompt string multiFilePrompt string promptOnce sync.Once ) func loadPromptFile(fileName string) (string, error) { projectRoot, err := myfile.GetProjectRoot() if err != nil { return "", err } filePath := filepath.Join(projectRoot, "prompt", fileName) data, err := os.ReadFile(filePath) if err != nil { return "", err } return string(data), nil } func LoadPrompts() error { var err error promptOnce.Do(func() { htmlPrompt, err = loadPromptFile("codegen-html-system-prompt.txt") if err != nil { panic(err) } multiFilePrompt, err = loadPromptFile("codegen-multi-file-system-prompt.txt") if err != nil { panic(err) } if err != nil { panic(err) } }) return err } func GetHtmlPrompt() string { return htmlPrompt } func GetMultiFilePrompt() string { return multiFilePrompt } func NewMultiFileChatTemplate() (prompt.ChatTemplate, error) { return newChatTemplate(GetMultiFilePrompt()), nil } func NewHtmlChatTemplate() (prompt.ChatTemplate, error) { return newChatTemplate(GetHtmlPrompt()), nil } func newChatTemplate(systemPrompt string) prompt.ChatTemplate { ctp := prompt.FromMessages(schema.GoTemplate, []schema.MessagesTemplate{ schema.SystemMessage(systemPrompt), schema.MessagesPlaceholder("history", false), schema.UserMessage("{{.content}}"), }...) return ctp } ``` ###### 基础智能体封装 (`internal/ai/agent/base_agent.go`) **文件作用:** 提供智能体的基础封装,包含通用的智能体创建和执行方法,作为其他智能体的基类。 **完整代码:** ```go package agent import ( "context" "errors" "fmt" "github.com/bytedance/gopkg/util/logger" "github.com/cloudwego/eino-ext/components/model/openai" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/prompt" "github.com/cloudwego/eino/components/tool" "github.com/cloudwego/eino/compose" "github.com/cloudwego/eino/schema" ) type ChatModelWrapperAdaptor interface { GetChatModel() *openai.ChatModel GetModelName() string } type BaseAgent struct { model *openai.ChatModel modelName string } func NewBaseAgent(chatModel ChatModelWrapperAdaptor) *BaseAgent { return &BaseAgent{ model: chatModel.GetChatModel(), modelName: chatModel.GetModelName(), } } func (a *BaseAgent) GetModel() *openai.ChatModel { return a.model } func (a *BaseAgent) NewAdkAgent(name, description, instruction string, tools []tool.BaseTool) *adk.ChatModelAgent { ctx := context.Background() config := &adk.ChatModelAgentConfig{ Name: name, Description: description, Instruction: instruction, Model: a.model, MaxIterations: 50, ModelRetryConfig: &adk.ModelRetryConfig{ MaxRetries: 3, IsRetryAble: func(ctx context.Context, err error) bool { if errors.Is(err, context.Canceled) { return false } return true }, }, } agent, err := adk.NewChatModelAgent(ctx, config) if err != nil { logger.Errorf("创建Agent失败: %v", err) return nil } return agent } func (a *BaseAgent) Generate(ctx context.Context, userMessage string, chatTemplate prompt.ChatTemplate, adkAgent *adk.ChatModelAgent) (*schema.Message, error) { format, err := chatTemplate.Format(ctx, map[string]any{ "content": userMessage, }) if err != nil { return nil, err } runner := adk.NewRunner(ctx, adk.RunnerConfig{ Agent: adkAgent, EnableStreaming: false, }) iter := runner.Run(ctx, format) var resultMsg *schema.Message for { event, ok := iter.Next() if !ok { break } if event.Err != nil { return nil, event.Err } if event.Output != nil && event.Output.MessageOutput != nil { msg, err := event.Output.MessageOutput.GetMessage() if err != nil { return nil, err } resultMsg = msg } } return resultMsg, nil } ``` ###### 代码生成智能体 (`internal/ai/agent/codegen_agent.go`) **文件作用:** 继承基础智能体,实现具体的代码生成功能,支持HTML和多文件两种生成模式,并使用结构化输出确保返回格式的稳定性。 **完整代码:** ```go package agent import ( "context" "encoding/json" "yikou-ai-go-teach/internal/ai/aimodel" "yikou-ai-go-teach/internal/ai/myprompt" "yikou-ai-go-teach/pkg/enum" "github.com/bytedance/gopkg/util/logger" "github.com/cloudwego/eino/adk" ) func NewCodeGenAgent(chatModel ChatModelWrapperAdaptor, codeGenType enum.CodeGenTypeEnum) *CodeGenAgent { baseAgent := NewBaseAgent(chatModel) return &CodeGenAgent{ BaseAgent: baseAgent, agentType: codeGenType, } } type CodeGenAgent struct { *BaseAgent agentType enum.CodeGenTypeEnum } func (a *CodeGenAgent) getAdkAgent() *adk.ChatModelAgent { switch a.agentType { case enum.HtmlCodeGen: return a.newHtmlFileCodeGenAgent() case enum.MultiFileGen: return a.newMultiFileCodeGenAgent() default: return nil } } func (a *CodeGenAgent) GenerateHtmlCode(ctx context.Context, userMessage string) (*aimodel.HtmlCodeResponse, error) { chatTemplate, err := myprompt.NewHtmlChatTemplate() if err != nil { return nil, err } adkAgent := a.getAdkAgent() message, err := a.Generate(ctx, userMessage+ `You must answer strictly in the following JSON format: { "htmlCode": "your html code here", "description": "description of the code" } IMPORTANT: You must answer ONLY with a valid JSON object, no markdown, no code blocks, no backticks. `, chatTemplate, adkAgent) if err != nil { return nil, err } var result aimodel.HtmlCodeResponse err = json.Unmarshal([]byte(message.Content), &result) if err != nil { return nil, err } return &result, nil } func (a *CodeGenAgent) GenerateMultiFileCode(ctx context.Context, userMessage string) (*aimodel.MultiFileCodeResponse, error) { chatTemplate, err := myprompt.NewMultiFileChatTemplate() if err != nil { return nil, err } adkAgent := a.getAdkAgent() message, err := a.Generate(ctx, userMessage+ `You must answer strictly in the following JSON format: { "htmlCode": "your html code here", "description": "description of the code", "cssCode": "your css code here", "jsCode": "your javascript code here" } IMPORTANT: You must answer ONLY with a valid JSON object, no markdown, no code blocks, no backticks. `, chatTemplate, adkAgent) if err != nil { return nil, err } var result aimodel.MultiFileCodeResponse err = json.Unmarshal([]byte(message.Content), &result) if err != nil { return nil, err } return &result, nil } func (a *CodeGenAgent) newMultiFileCodeGenAgent() *adk.ChatModelAgent { if err := myprompt.LoadPrompts(); err != nil { logger.Errorf("加载prompts失败: %v", err) return nil } return a.NewAdkAgent( "AI 代码生成助手", "具有强大的代码生成能力", myprompt.GetMultiFilePrompt(), ) } func (a *CodeGenAgent) newHtmlFileCodeGenAgent() *adk.ChatModelAgent { if err := myprompt.LoadPrompts(); err != nil { logger.Errorf("加载prompts失败: %v", err) return nil } return a.NewAdkAgent( "AI 代码生成助手", "具有强大的代码生成能力", myprompt.GetHtmlPrompt(), ) } ``` ###### 单元测试实现 (`internal/ai/agent/codegen_agent_test.go`) **文件作用:** 为代码生成智能体提供单元测试,验证HTML和多文件代码生成功能的正确性。 先修改 `config/config.go`,支持测试方法传递读取配置参数 ```go var envFlag string func SetEnvFlag(flag string) { envFlag = flag } // InitConfig 初始化配置 // env 参数用于指定配置文件后缀,如 "local" 会读取 config-local.yaml func InitConfig() *Config { if envFlag == "" { // 解析命令行参数 env := flag.String("env", "", "运行环境,如 local, dev, test, prod") flag.Parse() envFlag = *env } // 获取项目根路径 rootPath, err := GetProjectRootPath() if err != nil { panic(fmt.Errorf("获取项目根路径失败: %w", err)) } // 拼接配置文件目录路径 configPath := filepath.Join(rootPath, "config") // 确定配置文件名称 configName := "config" if envFlag != "" { configName = fmt.Sprintf("config-%s", envFlag) } // 设置配置文件名和路径 viper.SetConfigName(configName) // 配置文件名称 viper.SetConfigType("yml") // 配置文件类型 viper.AddConfigPath(configPath) // 配置文件路径 // 读取环境变量 viper.AutomaticEnv() // 读取配置文件 if err := viper.ReadInConfig(); err != nil { panic(fmt.Errorf("读取配置文件失败: %w", err)) } logger.Infof("配置文件路径: %s\n", viper.ConfigFileUsed()) // 解析配置到结构体 cfg := &Config{} if err := viper.Unmarshal(cfg); err != nil { panic(fmt.Errorf("解析配置失败: %w", err)) } return cfg } ``` **下面是 `codegen_agent_test.go`的完整代码:** ```go package agent import ( "context" "github.com/cloudwego/hertz/pkg/common/test/assert" "testing" "yikou-ai-go-teach/config" "yikou-ai-go-teach/internal/ai/llm" "yikou-ai-go-teach/pkg/enum" ) func TestCodeGenAgent_GenerateHtmlCode(t *testing.T) { config.SetEnvFlag("local") // 解析命令行参数 initConfig := config.InitConfig() chatModel := llm.NewChatModel(initConfig) codeGenAgent := NewCodeGenAgent(chatModel, enum.HtmlCodeGen) code, err := codeGenAgent.GenerateHtmlCode(context.Background(), "做个mysql学习知识图") if err != nil { return } assert.NotNil(t, code) } func TestCodeGenAgent_GenerateMultiFileCode(t *testing.T) { initConfig := config.InitConfig() chatModel := llm.NewChatModel(initConfig) codeGenAgent := NewCodeGenAgent(chatModel, enum.MultiFileGen) code, err := codeGenAgent.GenerateMultiFileCode(context.Background(), "做个留言版") if err != nil { return } assert.NotNil(t, code) } ``` **Debug测试结果示例:** ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/bSFlWrcZYrvWLQW4.webp) ###### **结构化输出设计:** 结构化输出是确保AI返回数据格式稳定性的关键技术,通过JSON Schema约束AI的输出格式,在eino中实现结构化输出的途径就是在提示词拼接json输出格式的限制。 由于百炼的deepseek模型不支持结构化输出,大家可以修改yml配置文件更换千问进行测试,这一节其实不影响后面的步骤,只是给大家讲解一下这个功能特点,现在很多智能体都用到结构化输出这个功能。 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/bv67ps9UNRtXF9vc.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/znfcANNinSsBtaKi.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/t6SPbWH2dzXy3qQ4.webp) **结构化输出模型 (`internal/ai/aimodel/code_result.go`)** **文件作用:** 定义代码生成结果的强类型结构体,用于JSON解析和类型安全的数据传递。 **完整代码:** ```go package aimodel type HtmlCodeResponse struct { HtmlCode string `json:"htmlCode"` Description string `json:"description"` } type MultiFileCodeResponse struct { HtmlCodeResponse JsCode string `json:"jsCode"` CssCode string `json:"cssCode"` } ``` **修改AIService和代码生成智能体** 修改AIService两个方法的返回值为结构化输出模型 ```go type IYiKouAiCodegenService interface { GenerateHtmlCode(ctx context.Context, userMessage string) (*aimodel.HtmlCodeResponse, error) GenerateMultiFileCode(ctx context.Context, userMessage string) (*aimodel.MultiFileCodeResponse, error) } ``` 修改代码生成智能体增加提示词拼接json输出格式的限制,以及增加解析结构化输出结结果的逻辑 ```go func (a *CodeGenAgent) GenerateHtmlCode(ctx context.Context, userMessage string) (*aimodel.HtmlCodeResponse, error) { chatTemplate, err := myprompt.NewHtmlChatTemplate() if err != nil { return nil, err } adkAgent := a.getAdkAgent() message, err := a.Generate(ctx, userMessage+ `You must answer strictly in the following JSON format: { "htmlCode": "your html code here", "description": "description of the code" } IMPORTANT: You must answer ONLY with a valid JSON object, no markdown, no code blocks, no backticks. `, chatTemplate, adkAgent) if err != nil { return nil, err } var result aimodel.HtmlCodeResponse err = json.Unmarshal([]byte(message.Content), &result) if err != nil { return nil, err } return &result, nil } func (a *CodeGenAgent) GenerateMultiFileCode(ctx context.Context, userMessage string) (*aimodel.MultiFileCodeResponse, error) { chatTemplate, err := myprompt.NewMultiFileChatTemplate() if err != nil { return nil, err } adkAgent := a.getAdkAgent() message, err := a.Generate(ctx, userMessage+ `You must answer strictly in the following JSON format: { "htmlCode": "your html code here", "description": "description of the code", "cssCode": "your css code here", "jsCode": "your javascript code here" } IMPORTANT: You must answer ONLY with a valid JSON object, no markdown, no code blocks, no backticks. `, chatTemplate, adkAgent) if err != nil { return nil, err } var result aimodel.MultiFileCodeResponse err = json.Unmarshal([]byte(message.Content), &result) if err != nil { return nil, err } return &result, nil } ``` **重新debug单元测试** ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/myriq17tRk4wbkCD.webp) 可以看到成功返回结构化输出模型了 ##### 保存代码文件实现 代码生成后需要将生成的代码保存到文件系统中,这里使用了门面模式(Facade Pattern)来统一管理代码生成和保存的流程。 ###### **什么是门面模式(Facade Pattern)?** 门面模式是一种结构型设计模式,它为复杂的子系统提供一个统一的、简化的接口。门面模式通过定义一个高层接口,使得子系统更容易使用。 **门面模式的优势:** **降低复杂度**: - 隐藏子系统的复杂性 - 客户端无需了解内部实现细节 - 减少学习成本和使用难度 **解耦客户端**: - 客户端只依赖门面接口 - 子系统变化不影响客户端 - 提高系统的灵活性和可维护性 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/cbmq53FBLsQZWvKB.png) ###### 门面模式实现 (`internal/core/ai_codegen_facade.go`) **文件作用:** 使用门面模式统一管理代码生成和保存的流程,对外提供简单的接口,隐藏内部复杂性。 **完整代码:** ```go package core import ( "context" "fmt" "yikou-ai-go-teach/internal/ai" "yikou-ai-go-teach/internal/core/saver" "yikou-ai-go-teach/pkg/enum" "github.com/bytedance/gopkg/util/logger" ) type YiKouAiCodegenFacade struct { codegenService ai.IYiKouAiCodegenService } func NewYiKouAiCodegenFacade(codegenService ai.IYiKouAiCodegenService) *YiKouAiCodegenFacade { return &YiKouAiCodegenFacade{ codegenService: codegenService, } } func (y *YiKouAiCodegenFacade) GenHtmlCodeAndSave(ctx context.Context, userMessage string) error { resp, err := y.codegenService.GenerateHtmlCode(ctx, userMessage) if err != nil { return err } dirPath, err := saver.SaveHtmlCode(*resp) if err != nil { return err } logger.Info("HTML代码已保存到目录: %s", dirPath) return nil } func (y *YiKouAiCodegenFacade) GenMultiFileCodeAndSave(ctx context.Context, userMessage string) error { resp, err := y.codegenService.GenerateMultiFileCode(ctx, userMessage) if err != nil { return err } dirPath, err := saver.SaveMultiFileCode(*resp) if err != nil { return err } logger.Info("多文件代码已保存到目录: %s", dirPath) return nil } func (y *YiKouAiCodegenFacade) GenCodeAndSave(ctx context.Context, userMessage string, typeStr enum.CodeGenTypeEnum) error { switch typeStr { case enum.HtmlCodeGen: return y.GenHtmlCodeAndSave(ctx, userMessage) case enum.MultiFileGen: return y.GenMultiFileCodeAndSave(ctx, userMessage) default: return fmt.Errorf("不支持的代码生成类型: %s", typeStr) } } ``` ###### 文件保存器 (`internal/core/saver/codefile_saver.go`) **文件作用:** 负责将生成的代码内容保存到文件系统,使用雪花算法生成唯一目录名,避免文件冲突。 **完整代码:** ```go package saver import ( "fmt" "github.com/sony/sonyflake" "os" "path/filepath" "strconv" "yikou-ai-go-teach/internal/ai/aimodel" "yikou-ai-go-teach/pkg/enum" "yikou-ai-go-teach/pkg/myfile" ) // buildUniqueDir 构建唯一的目录名 // 目录名格式: {代码生成类型}_{唯一ID} func buildUniqueDir(typeStr enum.CodeGenTypeEnum) (string, error) { // 生成雪花id var sf = sonyflake.NewSonyflake(sonyflake.Settings{ MachineID: func() (uint16, error) { return 1, nil }, }) id, err := sf.NextID() if err != nil { return "", err } // 构建唯一目录名 uniqueDirName := fmt.Sprintf("%s_%s", typeStr, strconv.FormatUint(id, 20)) fileSaveDir, err := myfile.GetCodeOutputRoot() dirPath := filepath.Join(fileSaveDir, uniqueDirName) // 创建目录 err = os.MkdirAll(dirPath, os.ModePerm) if err != nil { return "", err } return dirPath, nil } // writeToFile 将内容写入文件并保存 func writeToFile(dirPath string, fileName string, content string) error { filePath := filepath.Join(dirPath, fileName) err := os.WriteFile(filePath, []byte(content), os.ModePerm) if err != nil { return err } return nil } // SaveHtmlCode 保存 HTML 代码文件 func SaveHtmlCode(response aimodel.HtmlCodeResponse) (string, error) { dirPath, err := buildUniqueDir(enum.HtmlCodeGen) if err != nil { return "", err } fileName := "index.html" return dirPath, writeToFile(dirPath, fileName, response.HtmlCode) } // SaveMultiFileCode 保存多文件代码文件 func SaveMultiFileCode(response aimodel.MultiFileCodeResponse) (string, error) { dirPath, err := buildUniqueDir(enum.MultiFileGen) if err != nil { return "", err } // 保存 HTML 文件 err = writeToFile(dirPath, "index.html", response.HtmlCode) if err != nil { return "", err } // 保存 JS 文件 err = writeToFile(dirPath, "script.js", response.JsCode) if err != nil { return "", err } // 保存 CSS 文件 err = writeToFile(dirPath, "style.css", response.CssCode) if err != nil { return "", err } return dirPath, nil } ``` ###### 门面模式测试 (`internal/core/ai_codegen_facade_test.go`) **文件作用:** 测试门面模式的完整功能,验证代码生成和保存的端到端流程。 **完整代码:** ```go package core import ( "context" "testing" "yikou-ai-go-teach/config" "yikou-ai-go-teach/internal/ai/agent" "yikou-ai-go-teach/internal/ai/llm" "yikou-ai-go-teach/pkg/enum" ) func TestYiKouAiCodegenFacade_GenCodeAndSave(t *testing.T) { config.SetEnvFlag("local") // 解析命令行参数 initConfig := config.InitConfig() chatModel := llm.NewChatModel(initConfig) codeGenAgent := agent.NewCodeGenAgent(chatModel, enum.MultiFileGen) aiCodegenFacade := NewYiKouAiCodegenFacade(codeGenAgent) err := aiCodegenFacade.GenCodeAndSave(context.Background(), "帮我生成一个日常记录网站", enum.MultiFileGen) if err != nil { panic(err) } } ``` 运行测试方法,我们可以在项目根路径下找到tmp文件夹 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/ooHNsL64v3RNKfIT.png) 点击index.html文件,然后在ide的右上方可以看到在浏览器打开文件,点击打开图标后,我们就能看到生成的网站效果了 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/y0zb2PYpD99XcU2w.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/Tc5T83kWoEL7oeCq.webp) ### 五、使用 Hertz SSE 流式输出扩展库 #### 什么是SSE(Server-Sent Events)? SSE(Server-Sent Events)是一种服务器向客户端推送数据的技术,基于HTTP协议,使用单向连接从服务器向客户端发送**实时更新**。SSE是HTML5规范的一部分,专门用于服务器推送场景。因为普通的HTTP协议,我们需要长时间等待代码生成接口的返回,为了提高用户的体验感,所以我们引用SSE协议的实时更新特性使接口像打印机一样返回数据给前端。而之前的结构化输出不能通过sse流式输出获得,所以我们这里需要用到eino的streamReader类型,后面我会讲解到 #### Hertz SSE 内置库使用 **示例:** ```go func HandleSSE(ctx context.Context, c *app.RequestContext) { // 获取上次事件 ID lastEventID := sse.GetLastEventID(&c.Request) // 创建 SSE Writer w := sse.NewWriter(c) // 写入事件 for i := 0; i < 5; i++ { w.WriteEvent("id-x", "message", []byte("hello world")) time.Sleep(10 * time.Millisecond) } w.Close() } ``` #### 代码内容解析器实现 AI 生成的代码通常包含在 Markdown 代码块中,需要解析器将其提取出来。代码解析器负责从 AI 返回的文本中提取 HTML、CSS 和 JavaScript 代码。 **文件位置:** `internal/core/parser/code_paser.go` **完整代码:** ```go package parser import ( "regexp" "strings" "yikou-ai-go-teach/internal/ai/aimodel" ) var ( htmlCodeRegex = regexp.MustCompile("(?i)```html\\s*\\n([\\s\\S]*?)```") cssCodeRegex = regexp.MustCompile("(?i)```css\\s*\\n([\\s\\S]*?)```") jsCodeRegex = regexp.MustCompile("(?i)```(?:js|javascript)\\s*\\n([\\s\\S]*?)```") ) func ParseHtmlCode(codeContent string) *aimodel.HtmlCodeResponse { result := &aimodel.HtmlCodeResponse{} htmlCode := extractHtmlCode(codeContent) if htmlCode != "" { result.HtmlCode = strings.TrimSpace(htmlCode) } else { result.HtmlCode = strings.TrimSpace(codeContent) } return result } func ParseMultiFileCode(codeContent string) *aimodel.MultiFileCodeResponse { result := &aimodel.MultiFileCodeResponse{} htmlCode := extractCodeByPattern(codeContent, htmlCodeRegex) cssCode := extractCodeByPattern(codeContent, cssCodeRegex) jsCode := extractCodeByPattern(codeContent, jsCodeRegex) if htmlCode != "" { result.HtmlCode = strings.TrimSpace(htmlCode) } if cssCode != "" { result.CssCode = strings.TrimSpace(cssCode) } if jsCode != "" { result.JsCode = strings.TrimSpace(jsCode) } return result } func extractHtmlCode(content string) string { matches := htmlCodeRegex.FindStringSubmatch(content) if len(matches) > 1 { return matches[1] } return "" } func extractCodeByPattern(content string, pattern *regexp.Regexp) string { matches := pattern.FindStringSubmatch(content) if len(matches) > 1 { return matches[1] } return "" } ``` **debug运行测试,得到测试结果:** ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/l2PDZRJFXWskaWBz.webp) 可以看到测试成功,剩下的描述字段因为对项目业务没啥作用,所有没对此进行解析 #### 流式输出方法实现 流式输出是提升用户体验的关键技术,通过 SSE 协议实现服务器向客户端的实时数据推送。本节详细介绍流式输出方法的实现。 ##### 修改 AI 服务接口定义 在ai服务接口新增两个流式输出的方法 **文件位置:** `internal/ai/ai_codegen_service.go` **完整代码:** ```go package ai import ( "context" "github.com/cloudwego/eino/schema" "yikou-ai-go-teach/internal/ai/aimodel" ) type IYiKouAiCodegenService interface { GenerateHtmlCode(ctx context.Context, userMessage string) (*aimodel.HtmlCodeResponse, error) GenerateMultiFileCode(ctx context.Context, userMessage string) (*aimodel.MultiFileCodeResponse, error) GenerateHtmlCodeStream(ctx context.Context, userMessage string) (*schema.StreamReader[*schema.Message], error) GenerateMultiFileCodeStream(ctx context.Context, userMessage string) (*schema.StreamReader[*schema.Message], error) } ``` ##### 基础智能体增加流式输出方法 **文件位置:** `internal/ai/agent/base_agent.go` **完整代码:** ```go package agent import ( "context" "errors" "github.com/bytedance/gopkg/util/logger" "github.com/cloudwego/eino-ext/components/model/openai" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/components/prompt" "github.com/cloudwego/eino/schema" "io" ) type ChatModelWrapperAdaptor interface { GetChatModel() *openai.ChatModel GetModelName() string } type BaseAgent struct { model *openai.ChatModel modelName string } func NewBaseAgent(chatModel ChatModelWrapperAdaptor) *BaseAgent { return &BaseAgent{ model: chatModel.GetChatModel(), modelName: chatModel.GetModelName(), } } func (a *BaseAgent) GetModel() *openai.ChatModel { return a.model } func (a *BaseAgent) NewAdkAgent(name, description, instruction string) *adk.ChatModelAgent { ctx := context.Background() config := &adk.ChatModelAgentConfig{ Name: name, Description: description, Instruction: instruction, Model: a.model, MaxIterations: 50, ModelRetryConfig: &adk.ModelRetryConfig{ MaxRetries: 3, IsRetryAble: func(ctx context.Context, err error) bool { if errors.Is(err, context.Canceled) { return false } return true }, }, } agent, err := adk.NewChatModelAgent(ctx, config) if err != nil { logger.Errorf("创建Agent失败: %v", err) return nil } return agent } func (a *BaseAgent) Generate(ctx context.Context, userMessage string, chatTemplate prompt.ChatTemplate, adkAgent *adk.ChatModelAgent) (*schema.Message, error) { format, err := chatTemplate.Format(ctx, map[string]any{ "content": userMessage, }) if err != nil { return nil, err } runner := adk.NewRunner(ctx, adk.RunnerConfig{ Agent: adkAgent, EnableStreaming: false, }) iter := runner.Run(ctx, format) var resultMsg *schema.Message for { event, ok := iter.Next() if !ok { break } if event.Err != nil { return nil, event.Err } if event.Output != nil && event.Output.MessageOutput != nil { msg, err := event.Output.MessageOutput.GetMessage() if err != nil { return nil, err } resultMsg = msg } } return resultMsg, nil } func (a *BaseAgent) GenerateStream(ctx context.Context, userMessage string, chatTemplate prompt.ChatTemplate, adkAgent *adk.ChatModelAgent) (*schema.StreamReader[*schema.Message], error) { format, err := chatTemplate.Format(ctx, map[string]any{ "content": userMessage, }) if err != nil { return nil, err } runner := adk.NewRunner(ctx, adk.RunnerConfig{ Agent: adkAgent, EnableStreaming: true, }) iter := runner.Run(ctx, format) reader, writer := schema.Pipe[*schema.Message](2) 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) } } } } } }() return reader, nil } ``` **关键:eino ADK Agent 的 Runner需要配置流式输出选项** ```go runner := adk.NewRunner(ctx, adk.RunnerConfig{ Agent: adkAgent, EnableStreaming: true, // 关键:启用流式输出 }) ``` **说明:** **1. 创建 Pipe** ```go reader, writer := schema.Pipe[*schema.Message](2) ``` - `Pipe`:创建一个管道,用于在 goroutine 之间传递数据 - `reader`:客户端通过它读取流式数据 - `writer`:在 goroutine 中写入流式数据 - 参数 `2`:管道缓冲区大小 **2. 处理流式事件** ```go 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) } } } } ``` - `MessageStream`:消息流对象 - `stream.Recv()`:接收流中的下一个消息 - `io.EOF`:流结束标志 - `writer.Send(msg, nil)`:将消息发送到管道 ##### 智能体流式输出实现 **文件位置:** `internal/ai/agent/codegen_agent.go` **流式输出方法代码:** ```go func (a *CodeGenAgent) GenerateHtmlCodeStream(ctx context.Context, userMessage string) (*schema.StreamReader[*schema.Message], error) { chatTemplate, err := myprompt.NewHtmlChatTemplate() if err != nil { return nil, err } adkAgent := a.getAdkAgent() return a.GenerateStream(ctx, userMessage, chatTemplate, adkAgent) } func (a *CodeGenAgent) GenerateMultiFileCodeStream(ctx context.Context, userMessage string) (*schema.StreamReader[*schema.Message], error) { chatTemplate, err := myprompt.NewMultiFileChatTemplate() if err != nil { return nil, err } adkAgent := a.getAdkAgent() return a.GenerateStream(ctx, userMessage, chatTemplate, adkAgent) } ``` ##### 流式输出测试 **文件位置:** `internal/core/ai_codegen_facade_test.go` **完整测试代码:** ```go package core import ( "context" "github.com/cloudwego/hertz/pkg/common/test/assert" "strings" "testing" "yikou-ai-go-teach/config" "yikou-ai-go-teach/internal/ai/agent" "yikou-ai-go-teach/internal/ai/llm" "yikou-ai-go-teach/pkg/enum" ) func TestYiKouAiCodegenFacade_GenCodeAndSave(t *testing.T) { config.SetEnvFlag("local") // 解析命令行参数 initConfig := config.InitConfig() chatModel := llm.NewChatModel(initConfig) codeGenAgent := agent.NewCodeGenAgent(chatModel, enum.MultiFileGen) aiCodegenFacade := NewYiKouAiCodegenFacade(codeGenAgent) err := aiCodegenFacade.GenCodeAndSave(context.Background(), "帮我生成一个日常记录网站", enum.MultiFileGen) if err != nil { panic(err) } } func TestYiKouAiCodegenFacade_GenCodeStreamAndSave(t *testing.T) { config.SetEnvFlag("local") // 解析命令行参数 initConfig := config.InitConfig() chatModel := llm.NewChatModel(initConfig) codeGenAgent := agent.NewCodeGenAgent(chatModel, enum.MultiFileGen) aiCodegenFacade := NewYiKouAiCodegenFacade(codeGenAgent) resp, err := aiCodegenFacade.GenCodeStreamAndSave(context.Background(), "帮我生成一个日常记录网站", enum.MultiFileGen) if err != nil { panic(err) } var builder strings.Builder for { message, err := resp.Recv() if err != nil { break } builder.WriteString(message.Content) } assert.NotNil(t, builder.String()) } ``` ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/57kABOfA83ecPiK1.webp) 可以看到,能够正常拼接流式输出 ### 六、优化设计模式 #### 设计模式应用 **1. 策略模式(Strategy Pattern)** 策略模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换。在解析器设计中,我们定义了统一的解析策略接口。 **策略模式例子:** ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/OMwo8KciXYkOrGTv.webp) **2. 执行器模式(Executor Pattern)** 执行器模式提供了一个统一的执行接口,根据不同的类型选择不同的策略执行。在解析器设计中,`CodeParserExecutor` 作为执行器,根据代码生成类型选择对应的解析器。 **执行器模式例子:** ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/WAxKvslbUSUhaS5O.png) **3. 模板方法模式(Template Method Pattern)** 模板方法模式是一种行为型设计模式,它在父类中定义了一个算法的骨架,将某些步骤延迟到子类中实现。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法的某些特定步骤。 外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 #### 优化解析器设计 为了提高代码的可扩展性和可维护性,我们使用**策略模式**和**执行器模式**对解析器进行优化设计。 **文件位置:** `internal/core/parser/code_paser.go` **完整代码:** ```go package parser import ( "fmt" "regexp" "strings" "yikou-ai-go-teach/internal/ai/aimodel" "yikou-ai-go-teach/pkg/enum" ) // Parser 定义解析策略接口(策略模式) type Parser[T any] interface { Parse(content string) (T, error) } // HtmlCodeParser HTML代码解析器(具体策略A) type HtmlCodeParser struct{} // NewHtmlCodeParser 创建HTML解析器(工厂方法) func NewHtmlCodeParser() *HtmlCodeParser { return &HtmlCodeParser{} } // Parse 实现解析策略 func (p *HtmlCodeParser) Parse(content string) (*aimodel.HtmlCodeResponse, error) { result := &aimodel.HtmlCodeResponse{} matches := htmlCodeRegex.FindStringSubmatch(content) if len(matches) >= 2 { result.HtmlCode = strings.TrimSpace(matches[1]) } return result, nil } // MultiFileCodeParser 多文件代码解析器(具体策略B) type MultiFileCodeParser struct{} // NewMultiFileCodeParser 创建多文件解析器(工厂方法) func NewMultiFileCodeParser() *MultiFileCodeParser { return &MultiFileCodeParser{} } // Parse 实现解析策略 func (p *MultiFileCodeParser) Parse(content string) (*aimodel.MultiFileCodeResponse, error) { result := &aimodel.MultiFileCodeResponse{} htmlMatches := htmlCodeRegex.FindStringSubmatch(content) if len(htmlMatches) >= 2 { result.HtmlCode = strings.TrimSpace(htmlMatches[1]) } cssMatches := cssCodeRegex.FindStringSubmatch(content) if len(cssMatches) >= 2 { result.CssCode = strings.TrimSpace(cssMatches[1]) } jsMatches := jsCodeRegex.FindStringSubmatch(content) if len(jsMatches) >= 3 { result.JsCode = strings.TrimSpace(jsMatches[2]) } return result, nil } // CodeParserExecutor 解析器执行器(执行器模式) type CodeParserExecutor struct { htmlCodeParser *HtmlCodeParser multiFileCodeParser *MultiFileCodeParser } // NewCodeParserExecutor 创建解析器执行器(工厂方法) func NewCodeParserExecutor() *CodeParserExecutor { return &CodeParserExecutor{ htmlCodeParser: NewHtmlCodeParser(), multiFileCodeParser: NewMultiFileCodeParser(), } } // ExecuteParser 执行解析(根据类型选择策略) func (e *CodeParserExecutor) ExecuteParser(content string, parserType enum.CodeGenTypeEnum) (interface{}, error) { switch parserType { case enum.HtmlCodeGen: return e.htmlCodeParser.Parse(content) case enum.MultiFileGen: return e.multiFileCodeParser.Parse(content) default: return nil, fmt.Errorf("不支持的解析类型: %s", parserType) } } // 正则表达式定义 var ( htmlCodeRegex = regexp.MustCompile("(?i)```html\\s*\\n([\\s\\S]*?)```") cssCodeRegex = regexp.MustCompile("(?i)```css\\s*\\n([\\s\\S]*?)```") jsCodeRegex = regexp.MustCompile("(?i)```(?:js|javascript)\\s*\\n([\\s\\S]*?)```") ) // 以下是保留的函数式方法,用于向后兼容 func ParseHtmlCode(codeContent string) *aimodel.HtmlCodeResponse { result := &aimodel.HtmlCodeResponse{} htmlCode := extractHtmlCode(codeContent) if htmlCode != "" { result.HtmlCode = strings.TrimSpace(htmlCode) } else { result.HtmlCode = strings.TrimSpace(codeContent) } return result } func ParseMultiFileCode(codeContent string) *aimodel.MultiFileCodeResponse { result := &aimodel.MultiFileCodeResponse{} htmlCode := extractCodeByPattern(codeContent, htmlCodeRegex) cssCode := extractCodeByPattern(codeContent, cssCodeRegex) jsCode := extractCodeByPattern(codeContent, jsCodeRegex) if htmlCode != "" { result.HtmlCode = strings.TrimSpace(htmlCode) } if cssCode != "" { result.CssCode = strings.TrimSpace(cssCode) } if jsCode != "" { result.JsCode = strings.TrimSpace(jsCode) } return result } func extractHtmlCode(content string) string { matches := htmlCodeRegex.FindStringSubmatch(content) if len(matches) > 1 { return matches[1] } return "" } func extractCodeByPattern(content string, pattern *regexp.Regexp) string { matches := pattern.FindStringSubmatch(content) if len(matches) > 1 { return matches[1] } return "" } ``` #### 优化保存器设计 为了提高代码的可扩展性和可维护性,我们使用**模板方法模式**和**执行器模式**对保存器进行优化设计。 **文件位置:** `internal/core/saver/codefile_saver.go` **完整代码:** ```go package saver import ( "fmt" "github.com/sony/sonyflake" "os" "path/filepath" "strconv" "yikou-ai-go-teach/internal/ai/aimodel" "yikou-ai-go-teach/pkg/enum" "yikou-ai-go-teach/pkg/myfile" ) // buildUniqueDir 构建唯一的目录名 // 目录名格式: {代码生成类型}_{唯一ID} func (d *CodeFileSaverTemplate[T]) buildUniqueDir(appId int64) (string, error) { if appId == 0 { return "", fmt.Errorf("应用id不能为空") } //构建唯一目录名 fileSaveDir, err := myfile.GetCodeOutputRoot() uniqueDirName := fmt.Sprintf("%s_%s", d.getCodeType(), strconv.FormatUint(uint64(appId), 20)) dirPath := filepath.Join(fileSaveDir, uniqueDirName) // 创建目录 err = os.MkdirAll(dirPath, os.ModePerm) if err != nil { return "", err } return dirPath, nil } // writeToFile 将内容写入文件并保存 func writeToFile(dirPath string, fileName string, content string) error { filePath := filepath.Join(dirPath, fileName) err := os.WriteFile(filePath, []byte(content), os.ModePerm) if err != nil { return err } return nil } // SaveHtmlCode 保存 HTML 代码文件 func SaveHtmlCode(response aimodel.HtmlCodeResponse) (string, error) { dirPath, err := buildUniqueDir(enum.HtmlCodeGen) if err != nil { return "", err } fileName := "index.html" return dirPath, writeToFile(dirPath, fileName, response.HtmlCode) } // SaveMultiFileCode 保存多文件代码文件 func SaveMultiFileCode(response aimodel.MultiFileCodeResponse) (string, error) { dirPath, err := buildUniqueDir(enum.MultiFileGen) if err != nil { return "", err } // 保存 HTML 文件 err = writeToFile(dirPath, "index.html", response.HtmlCode) if err != nil { return "", err } // 保存 JS 文件 err = writeToFile(dirPath, "script.js", response.JsCode) if err != nil { return "", err } // 保存 CSS 文件 err = writeToFile(dirPath, "style.css", response.CssCode) if err != nil { return "", err } return dirPath, nil } type CodeFileSaver[T any] interface { getCodeType() enum.CodeGenTypeEnum saveFiles(response T, baseDir string) error validateInput(response T) error } type CodeFileSaverTemplate[T any] struct { CodeFileSaver[T] } func (d *CodeFileSaverTemplate[T]) saveCode(response T) (string, error) { err := d.validateInput(response) if err != nil { return "", err } dirPath, err := d.buildUniqueDir() if err != nil { return "", err } return dirPath, d.saveFiles(response, dirPath) } // buildUniqueDir 构建唯一的目录名 // 目录名格式: {代码生成类型}_{唯一ID} func (d *CodeFileSaverTemplate[T]) buildUniqueDir() (string, error) { // 生成雪花id var sf = sonyflake.NewSonyflake(sonyflake.Settings{ MachineID: func() (uint16, error) { return 1, nil }, }) id, err := sf.NextID() if err != nil { return "", err } //构建唯一目录名 dirPath := fmt.Sprintf("%s_%s", d.getCodeType(), strconv.FormatUint(id, 20)) // 创建目录 err = os.MkdirAll(dirPath, os.ModePerm) if err != nil { return "", err } return dirPath, nil } // writeToFile 将内容写入文件并保存 func (d *CodeFileSaverTemplate[T]) writeToFile(dirPath string, fileName string, content string) error { filePath := filepath.Join(dirPath, fileName) err := os.WriteFile(filePath, []byte(content), os.ModePerm) if err != nil { return err } return nil } type HtmlCodeFileSaverTemplate struct { CodeFileSaverTemplate[*aimodel.HtmlCodeResponse] } func NewHtmlCodeFileSaverTemplate() *HtmlCodeFileSaverTemplate { t := &HtmlCodeFileSaverTemplate{} t.CodeFileSaverTemplate.CodeFileSaver = t return t } func (h *HtmlCodeFileSaverTemplate) getCodeType() enum.CodeGenTypeEnum { return enum.HtmlCodeGen } func (h *HtmlCodeFileSaverTemplate) saveFiles(response *aimodel.HtmlCodeResponse, baseDir string) error { fileName := "index.html" return h.writeToFile(baseDir, fileName, response.HtmlCode) } func (h *HtmlCodeFileSaverTemplate) validateInput(response *aimodel.HtmlCodeResponse) error { if response == nil { return fmt.Errorf("代码结果为空") } if response.HtmlCode == "" { return fmt.Errorf("HTML 代码为空") } return nil } type MultiFileCodeFileSaverTemplate struct { CodeFileSaverTemplate[*aimodel.MultiFileCodeResponse] } func NewMultiFileCodeFileSaverTemplate() *MultiFileCodeFileSaverTemplate { t := &MultiFileCodeFileSaverTemplate{} t.CodeFileSaverTemplate.CodeFileSaver = t return t } func (m *MultiFileCodeFileSaverTemplate) getCodeType() enum.CodeGenTypeEnum { return enum.MultiFileGen } func (m *MultiFileCodeFileSaverTemplate) saveFiles(response *aimodel.MultiFileCodeResponse, baseDir string) error { // 保存 HTML 文件 err := m.writeToFile(baseDir, "index.html", response.HtmlCode) if err != nil { return err } // 保存 JS 文件 err = m.writeToFile(baseDir, "script.js", response.JsCode) if err != nil { return err } // 保存 CSS 文件 err = m.writeToFile(baseDir, "style.css", response.CssCode) if err != nil { return err } return nil } func (m *MultiFileCodeFileSaverTemplate) validateInput(response *aimodel.MultiFileCodeResponse) error { if response == nil { return fmt.Errorf("代码结果为空") } if response.HtmlCode == "" { return fmt.Errorf("HTML 代码为空") } if response.JsCode == "" { return fmt.Errorf("JS 代码为空") } if response.CssCode == "" { return fmt.Errorf("CSS 代码为空") } return nil } type CodeFileSaverExecutor struct { htmlCodeFileSaver *HtmlCodeFileSaverTemplate multiFileCodeFileSaver *MultiFileCodeFileSaverTemplate } func NewCodeFileSaverExecutor() *CodeFileSaverExecutor { return &CodeFileSaverExecutor{ htmlCodeFileSaver: NewHtmlCodeFileSaverTemplate(), multiFileCodeFileSaver: NewMultiFileCodeFileSaverTemplate(), } } func (e *CodeFileSaverExecutor) ExecuteSaver(content interface{}, saveType enum.CodeGenTypeEnum) (string, error) { switch saveType { case enum.HtmlCodeGen: return e.htmlCodeFileSaver.saveCode(content.(*aimodel.HtmlCodeResponse)) case enum.MultiFileGen: return e.multiFileCodeFileSaver.saveCode(content.(*aimodel.MultiFileCodeResponse)) default: return "", fmt.Errorf("不支持的代码文件类型: %s", saveType) } } ``` #### 优化门面结构体流式方法 **文件位置:** `internal/core/ai_codegen_facade.go` 增加门面结构体的属性;增加流式处理方法,该方法主要负责调用解析器执行器和保存器执行器 ```go // YiKouAiCodegenFacade AI代码生成门面(门面模式) type YiKouAiCodegenFacade struct { codegenService ai.IYiKouAiCodegenService // AI代码生成服务 codeParserExecutor *parser.CodeParserExecutor // 代码解析器执行器 codeFileSaverExecutor *saver.CodeFileSaverExecutor // 代码文件保存器执行器 } // NewYiKouAiCodegenFacade 创建AI代码生成门面 func NewYiKouAiCodegenFacade(codegenService ai.IYiKouAiCodegenService, codeParserExecutor *parser.CodeParserExecutor, codeFileSaverExecutor *saver.CodeFileSaverExecutor) *YiKouAiCodegenFacade { return &YiKouAiCodegenFacade{ codegenService: codegenService, codeParserExecutor: codeParserExecutor, codeFileSaverExecutor: codeFileSaverExecutor, } } // processCodeStream 处理代码流式数据并保存 func (y *YiKouAiCodegenFacade) processCodeStream(respStream *schema.StreamReader[*schema.Message], typeStr enum.CodeGenTypeEnum) (*schema.StreamReader[*schema.Message], error) { // 先复制流,一个用于处理,一个返回给上游 streams := respStream.Copy(2) processingStream := streams[0] returnStream := streams[1] // 在 goroutine 中处理流数据,不阻塞返回 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) if err != nil { return } logger.Info("代码已保存到目录: %s", dirPath) }() return returnStream, nil } // GenCodeStreamAndSave 根据类型生成代码流式输出并保存 func (y *YiKouAiCodegenFacade) GenCodeStreamAndSave(ctx context.Context, userMessage string, typeStr enum.CodeGenTypeEnum) (*schema.StreamReader[*schema.Message], error) { switch typeStr { case enum.HtmlCodeGen: streamResp, err := y.codegenService.GenerateHtmlCodeStream(ctx, userMessage) if err != nil { return nil, err } return y.processCodeStream(streamResp, typeStr) case enum.MultiFileGen: streamResp, err := y.codegenService.GenerateMultiFileCodeStream(ctx, userMessage) if err != nil { return nil, err } return y.processCodeStream(streamResp, typeStr) default: return nil, fmt.Errorf("不支持的代码生成类型: %s", typeStr) } } ``` #### 修改门面结构体的测试方法 **文件位置:** `internal/core/ai_codegen_facade_test.go` **完整测试代码:** ```go package core import ( "context" "github.com/cloudwego/hertz/pkg/common/test/assert" "strings" "testing" "yikou-ai-go-teach/config" "yikou-ai-go-teach/internal/ai/agent" "yikou-ai-go-teach/internal/ai/llm" "yikou-ai-go-teach/internal/core/parser" "yikou-ai-go-teach/internal/core/saver" "yikou-ai-go-teach/pkg/enum" ) // TestYiKouAiCodegenFacade_GenCodeAndSave 测试非流式代码生成和保存 func TestYiKouAiCodegenFacade_GenCodeAndSave(t *testing.T) { config.SetEnvFlag("local") // 初始化配置 initConfig := config.InitConfig() // 创建聊天模型 chatModel := llm.NewChatModel(initConfig) // 创建代码生成智能体 codeGenAgent := agent.NewCodeGenAgent(chatModel, enum.MultiFileGen) // 创建解析器执行器 parserExecutor := parser.NewCodeParserExecutor() // 创建保存器执行器 fileSaverExecutor := saver.NewCodeFileSaverExecutor() // 创建门面对象 aiCodegenFacade := NewYiKouAiCodegenFacade(codeGenAgent, parserExecutor, fileSaverExecutor) // 执行代码生成和保存 err := aiCodegenFacade.GenCodeAndSave(context.Background(), "帮我生成一个日常记录网站", enum.MultiFileGen) if err != nil { panic(err) } } // TestYiKouAiCodegenFacade_GenCodeStreamAndSave 测试流式代码生成和保存 func TestYiKouAiCodegenFacade_GenCodeStreamAndSave(t *testing.T) { config.SetEnvFlag("local") // 初始化配置 initConfig := config.InitConfig() // 创建聊天模型 chatModel := llm.NewChatModel(initConfig) // 创建代码生成智能体 codeGenAgent := agent.NewCodeGenAgent(chatModel, enum.MultiFileGen) // 创建解析器执行器 parserExecutor := parser.NewCodeParserExecutor() // 创建保存器执行器 fileSaverExecutor := saver.NewCodeFileSaverExecutor() // 创建门面对象 aiCodegenFacade := NewYiKouAiCodegenFacade(codeGenAgent, parserExecutor, fileSaverExecutor) // 执行流式代码生成和保存 resp, err := aiCodegenFacade.GenCodeStreamAndSave(context.Background(), "帮我生成一个日常记录网站", enum.MultiFileGen) if err != nil { panic(err) } // 读取流式数据 var builder strings.Builder for { message, err := resp.Recv() if err != nil { break } builder.WriteString(message.Content) } // 验证结果 assert.NotNil(t, builder.String()) } ``` 测试方法这里我就具体调试查看效果了,大家可以自行测试。通过本章的代码优化,大部分的业务逻辑都显著地提高了代码可读性和可维护性,当我们需要对项目新增业务逻辑时,我们的修改工作只需增加新的处理方法,而不需要修改主要的业务方法。**我们现在已经封装好了智能体,以及对现有的代码进行大幅度的优化,在下一章,我们将实现后端的应用生成模块,我们的代码生成智能体将会进一步拓展成应用生成平台,请大家敬请期待!**

告别丑陋的 Swagger UI,Coco 给你的 Go API 换上优雅新衣

Hello,大家好,这里是小nuo😎。 小nuo在实习的时候发现,Java 中有 Knief4j 渲染 Swagger 方便 Javaer 调试和提供接口给到前端或者测试等人。但是我在使用 Go 的时候发现没有一款让我满意的,所以自己开发了一个。   ## 先看效果 ✨ ![coco-light.png](https://pic.code-nav.cn/post_picture/1925030981941538817/gobD0C9XUBXvT3Es.webp) ![coco-dark.png](https://pic.code-nav.cn/post_picture/1925030981941538817/aT0tkJxujQJjpIrc.webp) > 现代化、优雅、流畅 - 这才是你 Go 应用程序的 API 文档应有的样子   ## 你是否也遇到过这些问题?   通过 swaggo 或者 huma 写完 Go API 后: - 😫 Swagger UI 界面丑陋,用户体验差 - 🤯 如果要自己弄界面,又需要额外部署前端服务,麻烦 - 😤 如果用 Postman 或者 Apifox 文档和代码分离,维护困难 **是时候换一个方案了!**   ## 认识 Coco 🥥 <p align="center"> <img src="https://raw.githubusercontent.com/leehainuo/coco/main/docs/images/coco.png" alt="Coco 文档界面 - 亮色主题" width="175" > </p>  **Coco** 是一个专为 Go 开发者打造的 OpenAPI 文档渲染器,让 API 文档变得优雅且易用。   ### 核心亮点 🌟 - **⚡ 快速上手** - 三行代码完成集成 - **🔌 全框架可用** - 支持 Gin、Echo、Fiber、Chi、net/http 等所有框架 - **🎨 颜值即正义** - Vue 3 + TailwindCSS 精心打磨的界面 - **🧪 内置测试** - 无需 Postman,文档里直接测试 API - **🌓 主题切换** - 深色浅色主题,随心选择 - **🚀 零依赖集成** - 纯 Go 实现,前端完全内嵌到二进制 - **🌍 多语言** - 内置中英文,可扩展 - **📝 请求历史** - 自动保存测试记录   ### 对比一下 | 特性 | Swagger UI | ReDoc | **Coco** | |------|-----------|-------|----------| | 界面美观度 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | | Go 集成难度 | 中 | 中 | **超简单** | | 依赖项 | 需要前端资源 | 需要前端资源 | **零依赖** | | API 测试 | ✅ | ❌ | **✅** | | 主题切换 | ❌ | ✅ | **✅** | | 请求历史 | ❌ | ❌ | **✅** | | 部署方式 | 需要额外部署 | 需要额外部署 | **单二进制** | ## 快速上手 ⚡ ### 安装 ```bash go get github.com/leehainuo/coco ```` ### 基础使用 **只需三行代码!** ```go import "github.com/leehainuo/coco"   // 挂载文档路由 mux.Handle("/docs/", coco.New("./openapi.json")) ``` 启动服务,访问 `http://localhost:8000/docs/` 就能看到漂亮的文档了! ### 与 Gin 集成 ```go package main   import ( "github.com/gin-gonic/gin" "github.com/leehainuo/coco" )   func main() { r := gin.Default() // 你的 API 路由 r.GET("/api/users", getUsers) r.POST("/api/users", createUser) // 挂载 Coco 文档 r.Any("/docs/*any", gin.WrapH(coco.New("./docs/swagger.json", coco.Title("我的 API 文档"), coco.Lang("zh"), coco.Theme("auto"), ))) r.Run(":8000") } ``` ### 配置选项 ```go coco.New("./openapi.json", coco.Title("自定义标题"), // 文档标题 coco.Theme("dark"), // 主题:light/dark/auto coco.Lang("zh"), // 语言:en/zh coco.EnableDebug(true), // 启用调试面板 coco.EnableExport(true), // 启用导出功能 coco.EnableHistory(true), // 启用请求历史 ) ``` ### 从远程 URL 加载 ```go coco.New("", coco.SpecURL("https://api.example.com/openapi.json")) ``` ### 与 Swag 配合使用 ```bash # 1. 使用 swag 生成文档 swag init   # 2. 使用 Coco 渲染 coco.New("./docs/swagger.json") ``` ## 支持的框架 ✅ **net/http** - Go 标准库 ✅ **Gin** - 最流行的 Web 框架 ✅ **Echo** - 高性能框架 ✅ **Fiber** - Express 风格的框架 ✅ **Chi** - 轻量级路由器 ✅ **以及任何兼容 `http.Handler` 的框架** 完整示例见:[GitHub - examples](https://github.com/leehainuo/coco/tree/main/example/framework) ## 实际效果 ### 📱 响应式设计 完美支持移动端、平板、桌面端 ### 🧪 API 测试面板 直接在文档中测试接口,支持: - 请求参数填写 - 请求头自定义 - 实时响应预览 - JSON 格式化显示 ### 📝 请求历史 自动保存所有测试记录,方便回溯和复用 ### 🌓 智能主题 - **亮色模式** - 清爽舒适 - **暗色模式** - 保护视力 - **自动模式** - 跟随系统 ### 🌍 国际化 内置中英文支持,用户可随时切换 ## 项目信息 - **GitHub**: <https://github.com/leehainuo/coco> - **文档**: <https://github.com/leehainuo/coco#readme> - **示例**: <https://github.com/leehainuo/coco/tree/main/example> - **License**: MIT ## 快速链接 - [完整文档](https://github.com/leehainuo/coco/tree/main/docs/zh) - [快速开始](https://github.com/leehainuo/coco#%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B) - [框架集成示例](https://github.com/leehainuo/coco/tree/main/example/framework) - [问题反馈](https://github.com/leehainuo/coco/issues) ## 加入 Coco! 🎉 Coco 是一个开源项目,小nuo欢迎任何形式的贡献! 小nuo还是一个学生,经验还是不足,Coco 肯定存在很多的不足。Coco 很需要各位佬佬和童鞋们的帮助!!!才能变的更好 💕 ### 你可以: - 🌟 **给个 Star** - 这是对小nuo和各位贡献者最大的鼓励 - 🐛 **报告 Bug** - 帮助我们发现问题 - 📝 **改进文档** - 让文档更清晰易懂 - 🌍 **添加翻译** - 支持更多语言 - 💻 **贡献代码** - 实现新功能或修复问题 - 📢 **分享推荐** - 让更多人知道 Coco ### 贡献指南 查看 [CONTRIBUTING.md](https://github.com/leehainuo/coco/blob/main/CONTRIBUTING.md) 了解如何参与贡献。 ### 社区 - **GitHub Issues**: 提问题、提需求 - **GitHub Discussions**: 技术讨论、分享经验 - **Star & Watch**: 及时获取更新 ## 结语 如果你厌倦了 Swagger UI 的老旧界面,如果你想要更优雅的 API 文档体验,那就试试 **Coco** 吧! **三行代码,优雅文档,就是这么简单!**  🥥 觉得有用?请给小nuo一个 Star!⭐ 发现问题?欢迎提 Issue!成为贡献者!🐛 **项目地址**: <https://github.com/leehainuo/coco>

易扣AI (Go + CloudWeGo) 企业级AI智能体项目教程 第1章:项目环境配置与依赖整合

## 项目简介 大家好,我是程序员绯雾 本博客专栏是 [易扣AI-Go重构版](https://github.com/FeiWuSama/yikou-ai-go) 的配套教学项目,提供手把手的代码案例,带你从零构建企业级Go项目。 ## 适合人群 - **Go基础学习者**:刚学完Go基础,不知道怎么进阶GoWeb?恭喜你,发现宝藏了!这里有手把手的教学案例,带你从零构建企业级项目 - **Go开发者**:想提升代码水平?想学习AI编程?恭喜你,来对地方了!这里有实战项目等你来战 - **企业级项目构建者**:需要完整的实战项目参考?恭喜你,找对仓库了! 本教学项目采用循序渐进的方式,带你从零开始构建企业级Go项目,接下来就让我们正式进入项目的实战环节 # 第1章:项目环境准备和依赖整合 > 本章目标:掌握企业级Go项目的初始化流程,学会设计清晰的目录结构 ## 本章概述 在这一章,我们将从零开始创建一个企业级项目。在本章的开始,要是有小伙伴担忧自己只会后端,不会前端怎么办,别担心,关于前端的代码可以直接克隆仓库复制前端代码到自己的项目中,但是博主自己的前端代码有大部分都是通过vibe coding 生成的,大多都没有什么技术含量,该教程主要教导大家如何使用vibe coding 生成质量好的代码,所以在教程中我并没有太过关注前端的教学。 ### 一、环境准备 #### 1. Go 语言环境 **版本要求:** - Go 版本:≥ 1.24.9(推荐使用最新稳定版) - 环境变量:已正确配置 GOROOT、GOPATH、PATH > 注意:具体的 Go 安装步骤这里不再赘述,默认大家已经安装好开发环境。如果还没安装,请参考 Go 官方文档或相关教程。 #### 2. MySQL 数据库 **版本要求:** - MySQL 版本:8.x(推荐) - 避免使用过低版本,防止部分SQL 语句执行错误 > 注意:使用 MySQL 8.x 可以避免很多兼容性问题,确保项目构建过程中 SQL 语句能够正确执行。 ### 二、开始新建项目 #### 步骤 1:创建项目文件夹 **推荐方式:从新建文件夹开始** 1. 选择合适的磁盘路径作为项目工作区 2. 新建文件夹,命名为项目名称(例如我自己教学使用则命名为:`yikou-ai-go-teach-example`) 3. 这个文件夹将作为项目的根目录 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/9VZZiMdumjjm9fGi.png) #### 步骤 2:使用 GoLand 创建项目 1. **打开 GoLand IDE** 2. **选择新建项目** - 点击 `File` → `New Project` - 或在欢迎界面点击 `New Project` 3. **配置项目路径** - Location:选择步骤 1 创建的文件夹绝对路径 - 选择合适的 GOROOT 路径 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/h4HEMp5W3RM0Quot.webp) 4. **配置环境变量(关键步骤)** * 这一步非常关键!如果没有正确配置,可能会导致: - Go 下载第三方库速度非常慢 - 依赖下载失败 **配置方法:** - 在环境变量配置区域,添加 `GOPROXY` 环境变量 - 值选择:`https://goproxy.cn`(国内推荐) - 或手动输入:`https://goproxy.io` ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/KVOsEql66Zcxd1IJ.webp) 5. **完成创建** 点击确定再点击创建后,你将得到一个初见雏形的 Go 项目! #### 步骤 3:验证项目创建成功 创建完成后,会发现根目录下会有一个go.mod文件,这表示你的项目已经创建成功了 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/kbcMKhGUPBz75UGL.webp) ### 三、下载项目依赖 项目创建成功后,我们需要下载一些核心依赖库,这些依赖将在后续开发中使用。让我们逐一安装: #### 1. Hertz - Go Web 框架 **简介:** Hertz 是一个高性能的 Go HTTP 框架,由字节跳动开源,适合构建微服务和 Web 应用。 **安装命令:** ```bash go get github.com/cloudwego/hertz ``` 到这里,也许会有小伙伴问:为什么不用gin作为项目的web框架呢,gin在企业的使用规模不是比hertz大很多吗?博主对此声明一下,使用Golang开发web项目其实并不是很关注你使用什么框架,现在上市企业有使用gin的,有使用iris的,有使用go-frame的,我不可能同时照顾到所有人的学习要求,只能选择与Eino同生态的Hertz,而且Hertz也只是在gin的基础上加了几层封装而已,你弄懂了Hertz就相当于弄懂了gin,无论选择哪个开发框架,只要你弄懂了分层架构了,即便只学会一个框架也能随机应变兼容其他框架。**所以总结一句话:框架并不重要,重要的是搭建逻辑**。 #### 2. GORM - ORM 库 **简介:** GORM 是 Go 语言最流行的 ORM 库,提供了强大的数据库操作能力。 **安装命令:** ```bash # 安装 GORM 核心库 go get gorm.io/gorm@v1.31.1 # 安装 GORM代码生成器库 go get gorm.io/gen # 安装 MySQL 驱动 go get gorm.io/driver/mysql ``` #### 3. Swaggo - Swagger 文档生成工具 **简介:** Swag 可以自动根据代码注释生成 Swagger API 文档,方便接口测试和文档管理。 **安装命令:** ```bash # 安装 swag 命令行工具 go install github.com/swaggo/swag/cmd/swag@latest # 安装 swagger 内嵌文件 go get github.com/swaggo/files # 安装hertz的swagger拓展库 go get github.com/hertz-contrib/swagger ``` #### 4. Eino - AI 框架 **简介:** Eino 是一个强大的 AI 应用开发框架,帮助你快速集成 AI 能力到项目中。 **安装命令:** ```bash go get github.com/cloudwego/eino ``` #### 5. Viper-配置动态读取 **介绍:‌**Viper 是 Go 语言中最流行的配置管理库‌,由 spf13 开发并维护,广泛用于 Go 应用程序中处理各类配置需求。它支持多种配置源、格式和高级功能,适用于从简单 CLI 工具到复杂微服务架构的场景。 ```bash go get github.com/spf13/viper ``` #### 6. 验证依赖安装 安装完成后,可以查看go.mod文件是否有以上依赖的声明: ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/CCrj7QbTMSr33EgD.webp) ### 四、定义配置结构体 在企业级项目中,配置管理是非常重要的一环。我们需要定义清晰的配置结构体来管理各种配置信息,如数据库连接、服务器端口、API密钥等。 #### 1. 为什么需要配置结构体? **使用配置结构体的优势:** - 集中管理所有配置项 - 支持多环境配置切换 - 类型安全,编译时检查 - 便于扩展和维护 #### 2. 定义配置结构体 先在根目录下创建config目录,然后在该目录下创建 `config.go` 文件,定义配置结构体: ```go package config import ( "fmt" "github.com/bytedance/gopkg/util/logger" "os" "path/filepath" "runtime" "github.com/spf13/viper" ) // Config 全局配置结构体 type Config struct { Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` AI AIConfig `mapstructure:"ai"` } // ServerConfig 服务器配置 type ServerConfig struct { Port int `mapstructure:"port"` // 服务端口 ContextPath string `mapstructure:"context_path"` // api路径前缀 } // DatabaseConfig 数据库配置 type DatabaseConfig struct { Host string `mapstructure:"host"` // 数据库地址 Port int `mapstructure:"port"` // 数据库端口 Username string `mapstructure:"username"` // 用户名 Password string `mapstructure:"password"` // 密码 Database string `mapstructure:"database"` // 数据库名 } // AIConfig AI服务配置 type AIConfig struct { APIKey string `mapstructure:"api_key"` // API密钥 Model string `mapstructure:"model"` // 模型名称 BaseURL string `mapstructure:"base_url"` // API基础URL } // GlobalConfig 全局配置变量 var GlobalConfig *Config // GetProjectRootPath 获取项目根路径 // 通过 runtime.Caller 获取当前文件的路径,然后向上查找 go.mod 文件所在目录 func GetProjectRootPath() (string, error) { // 获取当前文件的路径 _, filename, _, ok := runtime.Caller(0) if !ok { return "", fmt.Errorf("获取当前文件路径失败") } // 从当前文件路径向上查找,直到找到 go.mod 文件 dir := filepath.Dir(filename) for { // 检查当前目录是否存在 go.mod 文件 goModPath := filepath.Join(dir, "go.mod") if _, err := os.Stat(goModPath); err == nil { return dir, nil } // 向上一级目录 parentDir := filepath.Dir(dir) if parentDir == dir { // 已经到达根目录,仍未找到 go.mod return "", fmt.Errorf("未找到项目根路径(找不到 go.mod 文件)") } dir = parentDir } } // InitConfig 初始化配置 // env 参数用于指定配置文件后缀,如 "local" 会读取 config-local.yaml func InitConfig(env string) { // 获取项目根路径 rootPath, err := GetProjectRootPath() if err != nil { panic(fmt.Errorf("获取项目根路径失败: %w", err)) } // 拼接配置文件目录路径 configPath := filepath.Join(rootPath, "config") // 确定配置文件名称 configName := "config" if env != "" { configName = fmt.Sprintf("config-%s", env) } // 设置配置文件名和路径 viper.SetConfigName(configName) // 配置文件名称 viper.SetConfigType("yml") // 配置文件类型 viper.AddConfigPath(configPath) // 配置文件路径 // 读取环境变量 viper.AutomaticEnv() // 读取配置文件 if err := viper.ReadInConfig(); err != nil { panic(fmt.Errorf("读取配置文件失败: %w", err)) } logger.Infof("配置文件路径: %s\n", viper.ConfigFileUsed()) // 解析配置到结构体 GlobalConfig = &Config{} if err := viper.Unmarshal(GlobalConfig); err != nil { panic(fmt.Errorf("解析配置失败: %w", err)) } } // GetDSN 获取数据库连接字符串 func (c *DatabaseConfig) GetDSN() string { return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", c.Username, c.Password, c.Host, c.Port, c.Database, ) } ``` #### 4. 创建配置文件 **新建默认配置文件 `config/config.yml`:** ```yaml # 服务器配置 server: port: 8123 context_path: /api # 数据库配置 database: host: localhost port: 3306 username: root password: your_password database: yikou_ai charset: utf8mb4 # AI服务配置 ai: api_key: your_api_key model: gpt-3.5-turbo base_url: https://api.openai.com/v1 ``` 还可以根据不同的环境创建不同的配置文件,然后运行前在ide的运行配置修改运行命令指定运行读取的配置yml文件。 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/04y4TZHMm4Ti4hF2.webp) ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/b42ANUcPWE1vR89P.webp) **由于配置文件里面携带了我的隐私信息(ai大模型的apiKey、cos对象存储的apiKey等等),这些隐私信息我都不会push到Github仓库上开源,所以之后的教程里我都是以local环境运行程序** **配置文件命名规则:** - `config.yml` - 默认配置文件 - `config-local.yml` - 本地开发环境(使用 `-env local`) - `config-prod.yml` - 生产环境(使用 `-env prod`) #### 5. 在主程序中使用配置 创建 `main.go` 主程序文件,并且记得修改包名为main,否则ide无法识别出程序运行入口: ```go func main() { // 解析命令行参数 env := flag.String("env", "", "运行环境,如 local, dev, test, prod") flag.Parse() // 初始化配置 // 如果不指定 -env 参数,默认读取 config.yaml // 如果指定 -env local,则读取 config-local.yaml // 配置文件路径会自动从项目根目录下的 config 目录读取 config.InitConfig(*env) // 使用配置 cfg := config.GlobalConfig fmt.Printf("服务器启动在端口: %d\n", cfg.Server.Port) fmt.Printf("数据库地址: %s\n", cfg.Database.Host) fmt.Printf("AI模型: %s\n", cfg.AI.Model) // 获取数据库连接字符串 dsn := cfg.Database.GetDSN() fmt.Printf("DSN: %s\n", dsn) } ``` 运行后的输出参考 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/YIrVsPr0yr9oouYn.webp) ### 五、编写通用代码 在企业级项目中,统一的错误处理和响应格式是非常重要的。我们需要创建一个通用包(pkg),用于定义错误码、业务错误。 #### 1. 为什么需要统一错误处理? **传统方式的问题:** - 错误码散落在代码各处,难以维护 - 错误信息不统一,前端难以处理 - 缺少错误分类,难以定位问题 - 响应格式不一致,增加前后端沟通成本 **统一错误处理的优势:** - 错误码集中管理,便于维护 - 统一的错误响应格式,前端易于处理 - 清晰的错误分类,便于问题定位 - 标准化的API响应,提高开发效率 #### 2. 创建 pkg 目录结构 在根目录下新建目录pkg,该目录为公共包,用于存放所有公共文件代码 #### 3. 定义业务错误结构体和错误码(business_error.go) 在pkg包下新建文件 `business_error.go` ```go package errorutil import ( "errors" "fmt" ) // BusinessError 自定义错误结构体 type BusinessError struct { Code int Message string } func (e BusinessError) Error() string { return fmt.Sprintf("errorCode: %d, message: %s", e.Code, e.Message) } func NewBusinessError(code ErrorNo, message string) BusinessError { return BusinessError{ Code: int(code), Message: message, } } func NewBusinessErrorByNo(code ErrorNo) BusinessError { message, _ := ErrMessageMap[code] return BusinessError{ Code: int(code), Message: message, } } func (e BusinessError) WithMessage(message string) BusinessError { e.Message = message return e } // ConvertError 将错误转换自定义系统错误 func ConvertError(err error) BusinessError { newErr := BusinessError{} if errors.As(err, &newErr) { return newErr } newErr = SystemError newErr.Message = err.Error() return newErr } type ErrorNo int // 错误码枚举 const ( SuccessErrCode ErrorNo = 0 ParamErrCode ErrorNo = 40000 NotLoginErrCode ErrorNo = 40100 NotAuthErrCode ErrorNo = 40101 ForbiddenErrorCode ErrorNo = 40400 TooManyRequestErrCode ErrorNo = 40300 SystemErrorCode ErrorNo = 50000 OperationErrorCode ErrorNo = 51001 ) var ErrMessageMap = map[ErrorNo]string{ SuccessErrCode: "ok", ParamErrCode: "请求参数错误", NotLoginErrCode: "未登录", NotAuthErrCode: "无权限", ForbiddenErrorCode: "禁止访问", TooManyRequestErrCode: "请求过于频繁", SystemErrorCode: "系统内部异常", OperationErrorCode: "操作失败", } // 错误码全局变量 var ( Success = NewBusinessErrorByNo(SuccessErrCode) ParamsError = NewBusinessErrorByNo(ParamErrCode) NotLoginError = NewBusinessErrorByNo(NotLoginErrCode) NotAuthError = NewBusinessErrorByNo(NotAuthErrCode) ForbiddenError = NewBusinessErrorByNo(ForbiddenErrorCode) TooManyRequestError = NewBusinessErrorByNo(TooManyRequestErrCode) SystemError = NewBusinessErrorByNo(SystemErrorCode) OperationError = NewBusinessErrorByNo(OperationErrorCode) ) ``` #### 4. 定义统一返回结构体(base_response.go)和常用请求api(base_request.go) 在pkg包下新建包response,在包里新建文件 `base_response.go`,定义统一返回结构体: ```go package response import ( "errors" "yikou-ai-go-teach/pkg/errorutil" ) type BaseResponse[T any] struct { Code int `json:"code"` Message string `json:"message"` Data T `json:"data"` } func NewSuccessResponse[T any](data T) *BaseResponse[T] { return &BaseResponse[T]{ Code: int(errorutil.SuccessErrCode), Message: errorutil.Success.Message, Data: data, } } func NewErrorResponse[T any](err error) *BaseResponse[T] { newError := errorutil.BusinessError{} if errors.As(err, &newError) { return &BaseResponse[T]{ Code: newError.Code, Message: newError.Message, } } else { newError = errorutil.ConvertError(err) return &BaseResponse[T]{ Code: newError.Code, Message: newError.Message, } } } func NewResponse[T any](code int, message string, data T) *BaseResponse[T] { return &BaseResponse[T]{ Code: code, Message: message, Data: data, } } type PageResponse[T any] struct { Records []T `json:"records"` PageNum int `json:"pageNum"` PageSize int `json:"pageSize"` TotalPage int `json:"totalPage"` TotalRow int `json:"totalRow"` OptimizeCountQuery bool `json:"optimizeCountQuery"` } ``` 在pkg包新建request包,再在该包下创建 `base_request.go`文件 ```go package request type DeleteRequest struct { Id int `json:"id"` } type PageRequest struct { PageNum int `json:"pageNum"` PageSize int `json:"pageSize"` SortField string `json:"sortField"` SortOrder string `json:"sortOrder"` } ``` ### 六、初始化 Web 服务器 在完成了配置管理、错误处理和统一响应之后,我们需要初始化一个 Web 服务器来提供 HTTP 服务。本节将使用 Hertz 框架来搭建一个高性能的 Web 服务器。 #### 1. 理解 internal 包的作用 在开始创建 Web 服务器之前,我们需要先创建 `internal`包。如果你只是一位刚学完Go没多久的萌新,想必会问: **什么是 internal 包?** `internal` 是 Go 语言的一个特殊目录名,Go 编译器会对 `internal` 目录下的代码进行特殊的访问控制: - **私有性**:`internal` 包中的代码只能被其父目录及其子目录中的代码导入 - **封装性**:防止外部项目依赖你的内部实现细节 - **模块化**:强制良好的代码组织结构 **为什么要使用 internal 包?** **问题场景:** 假设你的项目结构如下: ``` my-project/ ├── user/ │ └── service.go # 包含敏感的用户逻辑 └── go.mod ``` 其他项目可以直接导入你的 `user` 包: ```go import "github.com/yourname/my-project/user" ``` 这会导致: - 内部实现细节暴露 - 难以重构(因为外部可能依赖) - 代码耦合度高 **解决方案:使用 internal 包** ``` my-project/ ├── internal/ │ └── user/ │ └── service.go # 私有的用户逻辑 └── go.mod ``` 现在,只有 `my-project` 及其子目录可以导入 `internal/user`,其他项目无法导入。 **最佳实践:** 1. **核心业务逻辑放 internal** - Handler、Service、Repository 等业务代码 - 数据模型、业务规则等 2. **可复用工具放 pkg** - 错误处理、响应格式等通用工具 - 工具函数、帮助类等 3. **入口程序放 cmd** - main.go 等程序入口文件(当然main文件也可以放在项目根路径下) - 不同应用的启动脚本 4. **配置文件放 config** - 配置结构体定义 - 配置文件(yaml、json 等) 在我的开源项目里,我将internal包替换成了biz包,其实这是字节跳动的规范,两种包的命名含义都是一样的,没什么区别 #### 2. 创建测试接口 创建 `internal/handler/ping.go` - 健康检查: ```go package handler import ( "context" "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/protocol/consts" "yikou-ai-go-teach/pkg/response" ) type PingResponse response.BaseResponse[string] func Ping(ctx context.Context, c *app.RequestContext) { c.JSON(consts.StatusOK, response.NewSuccessResponse[string]("pong")) } ``` #### 3. 创建路由配置 创建 `internal/router/router.go` 文件: ```go package router import ( "context" "fmt" "github.com/bytedance/gopkg/util/logger" "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/app/middlewares/server/recovery" "github.com/cloudwego/hertz/pkg/app/server" "github.com/cloudwego/hertz/pkg/protocol/consts" "github.com/hertz-contrib/cors" "time" "yikou-ai-go-teach/internal/handler" "yikou-ai-go-teach/pkg/errorutil" "yikou-ai-go-teach/pkg/response" ) // RegisterRoutes 注册路由 func RegisterRoutes(h *server.Hertz) { // 注册全局中间件 // 处理跨域问题 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) } // CustomRecoveryHandler 全局异常处理器 func CustomRecoveryHandler(ctx context.Context, c *app.RequestContext, err interface{}, stack []byte) { logger.Errorf("panic recovered: %v\n%s", err, stack) c.JSON(consts.StatusOK, response.NewErrorResponse[any](errorutil.SystemError.WithMessage(fmt.Sprintf("%v", err)))) c.Abort() } ``` #### 4. 更新 main.go 修改 `main.go` 文件,可以启动 Web 服务器: ```go package main import ( "flag" "github.com/cloudwego/hertz/pkg/app/server" "strconv" "yikou-ai-go-teach/config" "yikou-ai-go-teach/internal/router" ) // initServer 初始化 Web 服务器 func initServer() *server.Hertz { cfg := config.GlobalConfig // 创建 Hertz 服务器 h := server.Default( server.WithHostPorts(":" + strconv.Itoa(cfg.Server.Port)), server.WithBasePath(cfg.Server.ContextPath), ) // 注册路由 router.RegisterRoutes(h) return h } func main() { // 解析命令行参数 env := flag.String("env", "", "运行环境,如 local, dev, test, prod") flag.Parse() // 初始化配置 // 如果不指定 -env 参数,默认读取 config.yaml // 如果指定 -env local,则读取 config-local.yaml // 配置文件路径会自动从项目根目录下的 config 目录读取 config.InitConfig(*env) // 初始化 Web 服务器 h := initServer() // 启动服务器 h.Spin() } ``` ### 七、测试验证 - Hertz 接入 Swagger 在完成了 Web 服务器的初始化之后,我们需要为 API 添加文档,方便前端开发和接口测试。Swagger 是最流行的 API 文档工具,本节将介绍如何在 Hertz 中接入 Swagger。 #### 1. 什么是 Swagger? **Swagger 的作用:** - **自动生成文档**:根据代码注释自动生成 API 文档 - **可视化界面**:提供 Swagger UI 进行接口测试 - **标准化**:遵循 OpenAPI 规范,支持多种工具 #### 2. 添加 Swagger 注释 **在测试接口添加接口注释:** ```go // Ping // @Summary 测试接口 // @Description 根据名字返回问候语 // @Accept json // @Produce json // @Success 200 {object} PingResponse // @Router /api/ping [get] func Ping(ctx context.Context, c *app.RequestContext) { c.JSON(consts.StatusOK, response.NewSuccessResponse[string]("pong")) } ``` #### 3. 修改main文件和增加swaggo的路由注册 修改 `main.go`文件 ```go / initServer 初始化 Web 服务器 func initServer() *server.Hertz { cfg := config.GlobalConfig // 动态设置 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) return h } ``` 修改 `router.go`文件 ```go // RegisterRoutes 注册路由 func RegisterRoutes(h *server.Hertz, url func(config *swagger.Config)) { // 注册全局中间件 // 处理跨域问题 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)) } ``` #### 4. 生成swagger文档 **生成文档命令:** ```bash # 在项目根目录执行 swag init ``` **生成的文件结构:** ``` docs/ ├── docs.go # Go 代码 ├── swagger.json # JSON 格式的 API 文档 └── swagger.yaml # YAML 格式的 API 文档 ``` #### 5. 访问 Swagger 文档 **启动服务器,访问 Swagger UI:** 打开浏览器访问: ```bash http://localhost:8123/api/swagger/index.html ``` ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/E21eSf7e0So7hh8s.webp) 若启动main方法失败,可以执行以下指令重新整理项目依赖 ```bash go mod tidy ``` swagger文档能正常访问后,就可以直接进行接口测试了 ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1969049470100254722/KHt58GlTLhGXiiy8.webp) **以下是swaggo常用注释说明:** | 注释 | 说明 | 示例 | | ------------ | ------------------ | --------------------------------------- | | @Summary | 接口简介 | @Summary 获取用户信息 | | @Description | 接口详细描述 | @Description 根据用户ID获取用户详细信息 | | @Tags | 接口分组 | @Tags 用户管理 | | @Accept | 接受的Content-Type | @Accept json | | @Produce | 返回的Content-Type | @Produce json | | @Param | 参数说明 | @Param id path int true "用户ID" | | @Success | 成功响应 | @Success 200 {object} Response | | @Failure | 失败响应 | @Failure 400 {object} Response | | @Router | 路由路径 | @Router /user/{id} [get] | **@Param 参数格式:** ``` @Param [参数名] [参数类型] [数据类型] [是否必须] [描述] [其他选项] ``` **参数类型:** - `query`:URL 查询参数 `?id=1` - `path`:URL 路径参数 `/user/{id}` - `body`:请求体参数 - `header`:请求头参数 - `formData`:表单参数 **示例:** ```go // @Param id path int true "用户ID" // @Param name query string false "用户名" default(test) // @Param request body CreateUserRequest true "创建用户请求" // @Param token header string true "认证令牌" ``` 第一章的内容到这里就结束了。如果您对这个项目感兴趣,欢迎访问项目的GitHub仓库(https://github.com/FeiWuSama/yikou-ai-go )并点个star支持。您的支持将帮助博主加快教程更新进度,感谢大家的关注!

花了漫长的三个月时间,终于将鱼皮的AI零代码应用生成平台用Go语言全部重构完了,将单体架构和微服务架构都弄完了,当初重构项目的时候也只是想学习一下Go的Eino编排框架,不知不觉间已经把整个项目都重构完了,接下来有空的话,我还会慢慢出个专栏来教学如何从零搭建这个项目,以及聊一聊在重构项目的过程我又遇到了哪些恶心的bug。这段时间还是挺心累的,用其他语言重构项目确实不是一件简单的事啊,也算锻炼自己的耐心了,所以要是鱼油们有感兴趣的话就给仓库 https://github.com/FeiWuSama/yikou-ai-go 点个star吧,希望我这个重构项目能给一些想学Go语言的小伙伴们一些帮助

开源!用CloudWeGo全家桶重构的AI零代码生成应用项目

#### 震惊,历时 3 个月,我基于 CloudWeGo 全家桶 将鱼皮的 AI 零代码应用生成平台从头到尾重构了一遍。这也是目前 Go 语言生态中较为少见的 AI 方向实战项目,希望能为刚入门 Go 的开发者提供一份完整的学习参考。 > 项目仓库:https://github.com/FeiWuSama/yikou-ai-go > ![image.png](https://pic.code-nav.cn/post_picture/1969049470100254722/M5pWimMtQI7Pcmg6.webp) ### 项目背景 鱼皮的 AI 零代码应用生成平台原本是一个帮助用户通过自然语言描述生成 Web 应用平台。为了更好地学习 Go 语言并实践微服务架构,我决定使用 CloudWeGo 技术栈对其进行全面重构。重构后的项目不仅保持了原有功能,还在性能、可维护性和扩展性上有了显著提升。 ![img2.png](https://pic.code-nav.cn/post_picture/1969049470100254722/wlFN9IPb954ohYkV.webp) ### 技术选型 Web框架: Hertz, 字节跳动开源,高性能,支持 HTTP/2,与 Kitex 无缝集成 ORM: GORM, Go 最流行的 ORM,功能丰富,社区活跃 缓存数据库框架: go-redis, 高性能 Redis 客户端,支持集群、哨兵模式 AI 工作流: Eino, 字节开源的大模型编排框架,支持链式调用、流式输出、工具集成 前端: Vue 3 + TS + Ant Design Vue, 现代化、类型安全、组件丰富 配置管理: Viper, 支持多种配置源,热加载 依赖注入: go-Wire, 编译时 DI,无反射开销 ### 项目覆盖的核心知识点 1. Go 协程与 Channel – 并发模型实战,例如 AI 生成任务中的流式处理 2. Hertz / Gin – 高性能 Web 框架的使用,包括路由、中间件、参数绑定 3. GORM / go-redis – 数据库与缓存框架的集成,支持连接池、事务、软删除 4. Eino – 目前 Go 生态中最强大的 AI 工作流框架,支持 ChatModel、Retriever、Tool 等组件 5. Kitex – 微服务 RPC 通信,服务注册与发现(Nacos) 6. Wire – 依赖注入自动生成,解决循环依赖 7. Monorepo 管理 – 使用 Go Workspace 管理多模块 8. Docker 容器化部署 – 编写 Dockerfile 和 docker-compose 目前整个项目的单体服务架构以及完善好了,剩下的微服务架构估计还要半个月的时间,如果该仓库的star数较多,我会考虑出一份详细的教程 如果你觉得这个项目有意思,或者想学习 Go 微服务 + AI 应用开发,请为我点一个 Star ⭐️,这对我非常重要,也会激励我更快出教程!

后端服务线上部署

本文主要记录一次 基于 Docker + API 网关(Kong) 的后端服务线上部署实践,个人觉得还有很多细节需要完善。 整体目标是: ```bash 后端服务容器化部署 通过 API 网关统一入口 对外仅暴露 Nginx 的 HTTP 端口 ``` ## 整体请求链路说明 一次接口请求的完整流程如下: ```bash 客户端(Apifox / 浏览器) ↓ Nginx(宿主机,仅暴露 80 端口) ↓ Kong API Gateway(Docker 容器) ↓ 后端服务容器(user-service / order-service) ``` 具体到请求示例: ```bash 访问:http://域名或者IP地址/api/user/health ↓ Nginx 将请求转发至: http://127.0.0.1:8000/api/user/health (8000 端口由 Kong 使用) ↓ Kong 根据路由规则转发至: http://user-service:30122/health ``` 其中: 8000 端口 仅在宿主机本地监听 30122 / 30222 等端口 仅存在于 Docker 内部网络 ## 部署前准备 在开始部署前,需要准备: 1、已完成开发的后端服务(我这里是user-service和order-service) ,有一个/health接口 返回{"status":"ok"} 表示服务正常启动 2、一台服务器:已安装 Docker 与 Docker Compose、Nginx 常用的docker compose命令 ```bash docker compose up -d --build #强制重新构建所有镜像并启动 docker compose up -d # 若本地已有镜像,仅启动容器 docker compose up -d --build order-service # 仅构建并启动指定服务 ``` 服务器目录结构: ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1904155932949557250/m12SLmlI4W4M12Yr.webp) ## docker-compose.yml 配置说明 ```bash version: "3.9" networks: backend: driver: bridge services: kong: image: kong:3.6 container_name: kong restart: unless-stopped #自动重启 environment: KONG_DATABASE: "off" KONG_DECLARATIVE_CONFIG: /kong/kong.yml KONG_PROXY_ACCESS_LOG: /dev/stdout KONG_ADMIN_ACCESS_LOG: /dev/stdout KONG_PROXY_ERROR_LOG: /dev/stderr KONG_ADMIN_ERROR_LOG: /dev/stderr KONG_LOG_LEVEL: info volumes: - ./kong/kong.yml:/kong/kong.yml ports: - "127.0.0.1:8000:8000" # 仅宿主机可访问 - "127.0.0.1:8001:8001" # Kong Admin(本地管理) networks: - backend user-service: build: context: ./user-service dockerfile: Dockerfile image: user-service:latest container_name: user-service restart: unless-stopped expose: - "30122" # 仅 Docker 网络内可见 - "31122" networks: - backend order-service: build: context: ./order-service dockerfile: Dockerfile image: order-service:latest container_name: order-service restart: unless-stopped expose: - "30222" - "31222" networks: - backend ``` 关键点说明: expose 不会占用宿主机端口 后端服务只能被 Kong 或同一 Docker 网络内的容器访问 外部流量必须经过 Nginx → Kong ## 后端服务镜像构建(多阶段构建) 我的后端服务是使用kratos写的,如果是springboot项目,可以让ai去给你生成一个 Dockerfile文件 Dockerfile内容如下 ```bash FROM golang:1.22 AS builder COPY . /src WORKDIR /src RUN GOPROXY=https://goproxy.cn make build FROM debian:stable-slim RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ netbase \ tzdata \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /src/bin /app COPY ./configs /data/conf VOLUME /logs WORKDIR /app EXPOSE 30122 EXPOSE 31122 #运行服务 CMD ["./user-service", "-conf", "/data/conf", "-env", ""] ``` ## Kong 配置(声明式) 修改 kong.yml 后,需要重启 Kong 容器: docker compose restart kong kong.yml 内容如下: ```bash _format_version: "3.0" #定义apikey的值,所有服务只要使用下面某个apikey的值,都可以鉴权成功 consumers: - username: user-service-consumer keyauth_credentials: - key: test1 - username: order-service-consumer keyauth_credentials: - key: test2 services: - name: user-service # 因为 Kong 和 user-service 在同一个 Docker 网络里 url: http://user-service:30122 routes: - name: user-route paths: - /api/user strip_path: true #把 /api/user 从路径中去掉,再转发给后端,因为我后端写的api地址是/health,前面没有/api/user plugins: - name: key-auth # 使用Kong 官方的 API Key 鉴权插件 config: key_names: - apikey hide_credentials: true # apikey不转发给后端接口,只给kong鉴权用 - name: order-service url: http://order-service:30222 routes: - name: order-route paths: - /api/order strip_path: true plugins: - name: key-auth config: key_names: - apikey hide_credentials: true ``` ## Nginx 配置(宿主机) ```bash server { listen 80; # 监听80端口 server_name <填写域名或者ip地址>; #匹配并处理所有以 /api/ 开头的请求路径 location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 3s; proxy_send_timeout 10s; proxy_read_timeout 10s; } } ``` 避坑点:location 与 proxy_pass 末尾的 / 在使用 Nginx 反向代理时,location 与 proxy_pass 末尾是否带 /,会直接影响请求路径的转发结果,这是一个非常容易被忽略但影响极大的问题。 ## 测试 携带正确的apikey ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1904155932949557250/FN2OvUkqRNCwDyjg.webp) 携带错误的apikey ![在这里插入图片描述](https://pic.code-nav.cn/post_picture/1904155932949557250/89VRTKd2pm0MvMfI.webp) ## 整体方案总结 后端服务全部运行在 Docker 容器中,服务端口仅在 Docker 内部网络暴露 外部访问统一经由 Nginx + Kong,并且访问接口时,需要在headler中携带apikey

今天看到一张图,有点蚌埠住了,和大家分享一下,正巧,我是之前是写java的,目前正在转go语言 “我不要做va学弟了,我要当go学长”

下载 APP