From 166fb1b507c9692792b71effdbe99abe5ed3890c Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Sun, 3 May 2026 15:53:09 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.9.63.dev.260503=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=201.=20=E4=B8=BB=E5=8A=A8=E8=B0=83=E5=BA=A6?= =?UTF-8?q?=20`unfinished=5Ffeedback`=20=E5=80=99=E9=80=89=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=94=B6=E5=8F=A3=E2=80=94=E2=80=94=E4=BB=85=E5=9C=A8?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=E7=9B=AE=E6=A0=87=E5=8F=AF=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E5=88=B0=20`task=5Fitem`=20=E6=97=B6=E7=94=9F=E6=88=90=20`crea?= =?UTF-8?q?te=5Fmakeup`=EF=BC=8C=E8=AF=BE=E7=A8=8B=E5=9D=97=E4=B8=8E=20`ta?= =?UTF-8?q?rget=5Fid=3D0`=20=E7=BB=A7=E7=BB=AD=E5=9B=9E=E9=80=80=20`ask=5F?= =?UTF-8?q?user`=EF=BC=8C=E9=81=BF=E5=85=8D=E7=94=9F=E6=88=90=E4=BC=9A?= =?UTF-8?q?=E8=A2=AB=20apply=20=E5=B1=82=E6=8B=A6=E6=88=AA=E7=9A=84?= =?UTF-8?q?=E6=97=A0=E6=95=88=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .../active_scheduler/candidate/candidate.go | 3 + .../微服务四步迁移与第二阶段并行开发计划.md | 1311 +++++++++++------ 3 files changed, 845 insertions(+), 470 deletions(-) diff --git a/.gitignore b/.gitignore index f197395..950fb61 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ backend/config.yaml *.log /tmp/ /frontend/dist/ +/scripts/ # 5. IDE 与系统文件 .idea/ diff --git a/backend/active_scheduler/candidate/candidate.go b/backend/active_scheduler/candidate/candidate.go index 759f4f5..75c2d8c 100644 --- a/backend/active_scheduler/candidate/candidate.go +++ b/backend/active_scheduler/candidate/candidate.go @@ -149,6 +149,9 @@ func (g *Generator) addTaskPoolCandidate(ctx *schedulercontext.ActiveScheduleCon } func (g *Generator) createMakeupCandidate(ctx *schedulercontext.ActiveScheduleContext) (Candidate, bool) { + if ctx == nil || ctx.FeedbackFacts.TargetTaskItemID <= 0 { + return Candidate{}, false + } span, ok := firstContiguousFreeSpan(ctx.ScheduleFacts.FreeSlots, 1) if !ok { return Candidate{}, false diff --git a/docs/backend/微服务四步迁移与第二阶段并行开发计划.md b/docs/backend/微服务四步迁移与第二阶段并行开发计划.md index 6f1723a..16050c9 100644 --- a/docs/backend/微服务四步迁移与第二阶段并行开发计划.md +++ b/docs/backend/微服务四步迁移与第二阶段并行开发计划.md @@ -1,557 +1,928 @@ -# 微服务四步迁移与第二阶段并行开发计划 +# 微服务迁移与 Gin Gateway + gozero 演进计划 -## 1. 文档目的 +## 1. 文档定位 -本文档回答两个问题: +这份文档是当前后端迁移的主总纲,目标是把现状从“单体 Gin + 统一 outbox”平滑演进到“Gin Gateway + gozero 服务群 + 服务级 outbox + Kafka 共享运输层”。 -1. 当前 Gin 单体如何平滑演进到“同仓库、多 Go module、分别启动”的微服务形态。 -2. 第二阶段 `to5.1` 主动调度闭环应在什么时间点介入,才能与底座迁移并行推进,避免互相干扰。 +本计划遵守两个硬原则: -本轮迁移的核心原则是:**先拆运行边界,再拆服务边界,最后再拆数据所有权**。 +1. **业务语义不变**。 + 预览、确认、通知、任务、日程的用户可见行为尽量不变,先改边界、再改归属、最后改运行形态。 -也就是说,第一轮不追求一步到位变成完整微服务,而是先把现在所有能力都挤在 `cmd.Start()` 里的问题解决掉,让 API、Worker、通知、主动调度后续可以各走各的启动链路。 +2. **并行迁移,不一步到位**。 + 允许新旧实现并存,先迁移、再切流、再验证、最后删除旧实现。 --- -## 2. 最终目标形态 +## 2. 现状判断 -项目保持一个 GitHub 仓库,但后端逐步演进为多个可独立构建、独立启动、独立部署的 Go module。 +当前代码已经具备迁移基础: -推荐最终目录形态如下: +1. `api / worker / all` 三种启动边界已经存在。 +2. 主动调度已经跑通 `trigger -> dry-run -> preview -> notification` 闭环。 +3. `notification_records` 已经有独立状态机、幂等、重试能力。 +4. 事件契约已经拆成 `active_schedule.triggered`、`notification.feishu.requested`、`schedule.apply.*` 等独立 payload。 -```text -SmartFlow-Agent/ - frontend/ - package.json - src/ +但当前 outbox 仍然偏单体: - backend/ - apps/ - api/ # Gin BFF / API Gateway - go.mod - cmd/ - api/ - main.go - internal/ +1. relay 还带着“全局扫一遍”的思维。 +2. consumer 还默认“一个 worker 吃全部事件”。 +3. 未知事件的处理策略还不适合长期多服务共用。 - services/ - notification/ # go-zero 通知服务,飞书优先落地 - go.mod - cmd/ - internal/ - - active-scheduler/ # 主动调度服务/worker - go.mod - cmd/ - internal/ - - schedule/ # 正式日程应用服务,后期拆 - go.mod - cmd/ - internal/ - - task/ # 四象限任务服务,后期拆 - go.mod - cmd/ - internal/ - - workers/ - outbox-worker/ # outbox relay + Kafka consumer - go.mod - cmd/ - internal/ - - agent-worker/ # 后续 agent 重计算/主动优化 worker - go.mod - cmd/ - internal/ - - shared/ - proto/ # RPC 契约 - events/ # Kafka 事件契约、JSON 示例、版本说明 - packages/ # 极少量稳定公共库 - - deploy/ - docs/ -``` - -但这只是最终形态,不是第一轮要直接改成这样。 - -迁移期建议先保持当前 `backend/go.mod`,只在现有模块内拆出多启动入口。等第一轮稳定后,再把 `notification` 作为第一个独立 Go module 放到 `backend/services/notification`。 +所以现在最合适的路线不是直接把所有服务拆完,而是先把 outbox 升级成服务级基础设施,再按服务边界逐个切出去。 --- -## 3. 四步走总览 +## 3. 终态形态 -### 第一步:拆运行边界,但保持业务行为不变 +### 3.1 网关层 -目标:把现在的 Gin 单体拆成同 Go module 内的多启动入口。 +Gin Gateway 只做边缘层职责: -建议目录: +1. 鉴权。 +2. 路由聚合。 +3. 请求编排。 +4. SSE / 流式返回。 +5. 前端所需的轻量组合逻辑。 -```text -backend/ - go.mod - cmd/ - api/ - main.go - worker/ - main.go - all/ - main.go - internal/app/ # 或 cmd/app,承载启动装配函数 -``` +网关不再承担这些职责: -启动角色: +1. 具体业务状态机。 +2. 直接写核心业务表。 +3. 直接消费所有后台事件。 +4. 直接维护服务内部重试与投递状态。 -```text -api - 启动 Gin、鉴权、SSE、查询接口、用户确认接口、事件发布能力。 - 迁移第一阶段仍保留 API 所需的同步 service/dao,不是最终纯网关。 +### 3.2 服务层 -worker - 只启动 outbox relay、Kafka consumer、事件 handler、memory worker。 +gozero 服务负责领域能力: -all - 保持当前单体行为,用于本地开发和迁移期兜底。 -``` +1. `user/auth` 负责用户注册、登录、刷新、登出、JWT 签发、黑名单与 token 额度治理。 +2. `course` 负责课程导入、图片解析、课表校验与课程落表。 +3. `task-class` 负责任务类别、条目维护与批量入表。 +4. `notification` 负责通知投递。 +5. `active-scheduler` 负责主动调度建议生成和预览。 +6. `schedule` 负责正式日程所有权。 +7. `task` 负责任务池和任务状态所有权。 +8. `agent` 负责聊天路由、计划/执行编排、工具接入、SSE 输出与会话生命周期。 +9. `memory` 负责记忆抽取、检索、管理、用户记忆设置和异步 worker。 +10. `llm-service` 负责统一模型调用、provider 路由、流式输出、重试、限流与审计。 +11. `rag-service` 负责统一向量化、召回、重排、向量库读写与语料适配,依赖 `llm-service`。 -这一阶段必须保证: +> 说明:`agent` 和 `memory` 都可以单独成服务,不应再被写成“公共能力”;其中 `agent` 更像对外对话编排服务,`memory` 更像其支撑服务/worker 服务。 +> +> 说明:`llm-service` 先抽成全仓统一模型出口,`rag-service` 再抽成检索基础设施服务;`rag-service` 只能依赖 `llm-service`,不反向依赖具体业务服务。 -1. `all` 模式行为与当前 `cmd.Start()` 一致。 -2. `api` 进程保留现有同步业务依赖,但不注册业务消费者,不启动 `memoryModule.StartWorker()`。 -3. `worker` 进程不注册 Gin 路由,不占用 HTTP 端口。 -4. 所有现有 API 路径、响应格式、缓存策略、数据库写入语义不变。 -5. 只改启动装配,不改主动调度业务逻辑。 +### 3.3 事件层 -这一阶段完成后,第二阶段新功能就可以优先挂到 worker 或事件链路,而不是继续塞进 Gin 主进程。 +1. 每个服务拥有自己的 outbox。 +2. 每个服务有自己的 relay worker。 +3. Kafka 作为共享运输层。 +4. 同一服务的多个实例进入同一个 consumer group,做横向负载均衡。 +5. topic 优先按服务/事件域划分,过渡期如果共享 topic,必须有显式 `event_type -> service` 路由。 -### 第二步:建立事件契约、主动调度闭环 MVP 与飞书触达 +### 3.4 共享层边界 -目标:让“四象限任务池 + 课表时间轴”进入同一个调度闭环,并通过飞书主动触达用户。 - -关键修正: - -1. 主动调度不是等用户打开聊天后才触发,而是由后台 worker 定时/事件驱动触发。 -2. 飞书不是后置锦上添花,而是本周期主动出击闭环的第一版触达渠道。 -3. 第一版飞书只负责通知用户“系统发现问题并生成了建议”,不承载复杂调度判断,也不直接改正式日程。 - -主动调度闭环 MVP: - -```text -后台监控/事件触发 - -> 读取四象限任务 - -> 读取课表/日程空闲时间 - -> 生成局部调整建议 - -> 附带 Reasoning - -> 写入对比预览 - -> 发布 notification.feishu.requested - -> 飞书提醒用户回到系统确认 - -> 用户按变更项确认 - -> 确认后应用 -``` - -建议新增事件契约: - -```text -active_schedule.triggered -schedule.preview.generated -schedule.apply.requested -schedule.apply.succeeded -schedule.apply.failed -notification.feishu.requested -``` - -这一阶段主动调度可以仍在 `backend` 模块内实现,但要按未来服务边界写: - -1. API 只负责测试触发、查询预览、用户确认和正式应用入口。 -2. Worker 负责后台监控、消费触发事件、生成建议、写预览、发布飞书通知事件。 -3. 正式应用仍先复用当前强一致落库链路,不拆成远程服务。 -4. 飞书第一版可以先在 `backend` worker 内实现 webhook/provider,后续再迁出到独立 notification 服务。 - -### 第三步:拆出第一个独立 Go module:notification - -目标:把第二步里先落在 `backend` worker 内的飞书通知,演进为第一个真正服务化模块。 - -推荐目录: - -```text -backend/ - services/ - notification/ - go.mod - cmd/ - notification/ - main.go - internal/ - consumer/ - domain/ - repo/ - provider/ - feishu/ -``` - -推荐技术选型: - -1. 使用 go-zero 作为服务工程框架。 -2. 通过 Kafka 消费 `notification.feishu.requested`。 -3. 若需要同步管理接口,再补 go-zero API 或 RPC。 - -通知服务必须有自己的投递模型,不能只依赖 outbox 的 `consumed` 状态。 - -建议新增通知记录表或等价存储: - -```text -notification_records - id - user_id - channel - biz_type - biz_id - idempotency_key - status - retry_count - last_error - requested_at - sent_at -``` - -飞书发送链路: - -```text -notification.feishu.requested - -> 写入/读取 notification_records - -> 幂等判断 - -> 调用飞书 provider - -> 记录 sent / failed / retry -``` - -这一阶段完成后,飞书挂了不会影响 Gin API,也不会影响主动调度生成预览。 - -> 说明:第二步允许先做简版飞书 webhook,是为了尽快跑通“后台主动发现 -> 飞书触达 -> 用户回系统确认”的产品闭环。第三步再把通知投递模型、失败补偿、幂等记录独立出来,避免第一轮就被完整 notification 平台拖慢。 - -### 第四步:逐步拆主动调度、日程、任务服务 - -目标:在业务边界稳定后,再拆核心服务。 - -推荐顺序: - -```text -active-scheduler - -> schedule - -> task -``` - -拆 `active-scheduler` 的前提: - -1. 主动调度输入输出 DTO 稳定。 -2. Reasoning 结构稳定。 -3. 预览生成和确认协议稳定。 -4. 调度触发事件稳定。 - -拆 `schedule` 的前提: - -1. 正式日程应用命令契约稳定。 -2. 已明确 `schedule_events`、`schedules`、`task_items.embedded_time` 的一致性边界。 -3. 有 apply id / idempotency key。 -4. 有冲突失败回执和回滚策略。 - -拆 `task` 的前提: - -1. 四象限任务池语义稳定。 -2. DDL、完成状态、优先级平移规则稳定。 -3. 任务表所有权明确。 - -在这些前提未满足之前,不建议把 `schedule` 和 `task` 强行拆成独立服务,否则容易变成分布式单体。 +1. `shared` 只放跨进程、跨服务都要认识的契约,不放某个服务自己的业务逻辑。 +2. 当前仓库里的 `backend/shared/events` 就是典型用法:事件类型、payload、版本字段。 +3. 如果未来确实出现多个服务共同依赖的 DTO、枚举或错误码,可以放到 `shared/contracts` 或 `shared/types`,但前提是它们不依赖数据库,也不依赖某个服务的状态机。 +4. 不要把 `dao`、`model`、`service`、`handler` 这类服务私有代码塞进 `shared`;它们应该跟随各自服务归属。 +5. `infra` 也不应该是一个大公共篮子:像 `kafka`、`outbox` 这类跨服务底座可以放到 `shared/infra`;`llm-service`、`rag-service` 这类模型与检索能力要单独成基础设施服务,不要塞进 `shared`;`prompt`、`tooling` 这类强业务依赖的适配器则应跟着具体服务走。 +6. 换句话说,`shared` 是“跨进程契约层 + 少量跨服务底座”,不是“公共业务层”。 --- -## 4. 第二阶段功能如何并行介入 +## 4. 迁移阶段 -第二阶段 `to5.1` 的业务目标是让四象限任务和课表联通,不再是两个孤岛。 +### 4.1 阶段总览 -建议按以下时间点介入。 +| 阶段 | 目标 | 建议 commit 点 | 建议测试 | +| --- | --- | --- | --- | +| 0 | 语义冻结和基线确认 | 当前运行边界、事件契约和路由清单固定后,先做一个基线 commit | `go test ./...`,`api / worker / all` 启动 smoke | +| 1 | Outbox v2 基建 | 服务级 outbox 路由和 relay 逻辑打通后 commit 一次 | 全量单测 + outbox 发布/消费 smoke | +| 1.5 | 先抽 llm-service | 统一模型调用、provider 路由、流式输出和审计后 commit | course / active-scheduler / memory 模型调用 smoke | +| 1.6 | 再抽 rag-service | 向量化、召回、重排、检索能力跑通后 commit | memory retrieve / rerank smoke | +| 2 | 先拆 user/auth | user 路由、JWT 签发和 token 额度治理独立后 commit | 注册/登录/刷新/登出 smoke + token quota 回归 | +| 3 | 再拆 notification | notification 服务能独立消费和重试后 commit | notification E2E smoke + worker-only smoke | +| 4 | 再拆 active-scheduler | 预览生成和确认链路通过 gozero 服务跑通后 commit | dry-run / preview / confirm smoke | +| 5 | 再拆 schedule / task / course / task-class | 每个领域完成一次切流就 commit 一次 | schedule/task/course/task-class 回归 + 全链路 smoke | +| 6 | 再拆 agent / memory | agent 编排服务、memory 支撑服务和后台 worker 独立后 commit | agent chat / SSE / memory extract / memory retrieve smoke | +| 7 | Gin Gateway 收口 | 网关不再直接碰核心业务表后 commit | Gateway 路由、鉴权、组合逻辑 smoke | -### 介入点 A:第一步完成后,开始“后台主动调度 + 飞书触达”MVP - -当 `api / worker / all` 三种启动角色跑通后,就可以开始主动调度 MVP。 - -这一版不要只做“用户打开聊天后触发”,而是要把后台触发作为主链路,把 API 测试接口作为调试入口。 - -此时你可以开发: - -1. 主动调度后台触发器。 -2. 主动调度 dry-run / trigger 测试接口。 -3. `mock_now` 时间注入。 -4. 任务未完成 / 用户反馈很累 / DDL 临近 的触发 payload。 -5. 写入对比预览。 -6. 发布 `notification.feishu.requested`。 -7. 简版飞书 webhook/provider,通知用户回系统确认。 - -不建议此时开发: - -1. 飞书内直接确认/改日程。 -2. 飞书作为完整 agent 聊天入口。 -3. DDL 全自动插空的复杂版本。 -4. 大范围全局重排。 -5. 独立 schedule/task 微服务。 - -原因:这一阶段的目标是验证“后台主动发现 -> 生成建议预览 -> 飞书触达 -> 用户回系统确认”的闭环,而不是一次性做完所有渠道与触发场景。 - -### 介入点 B:飞书触达可用后,预埋飞书聊天链路 - -当飞书 webhook 通知可用后,可以提前预埋下周期“飞书接入 agent 聊天链路”的边界。 - -下周期目标类似“飞书内直接和 agent 对话”,但本周期只埋以下能力: - -1. `channel` 字段:区分 `web`、`feishu`、未来移动端等入口。 -2. `external_user_id` / `open_id` 映射:把飞书用户身份映射到系统用户。 -3. `external_conversation_id` 映射:把飞书会话映射到系统 `conversation_id`。 -4. 入站消息 DTO:把飞书消息统一转换成 `AgentInboundMessage`。 -5. 出站消息 DTO:把 agent 回复统一转换成 `AgentOutboundMessage`。 -6. 幂等键:飞书 message id 需要映射为入站消息幂等键,避免重复回调导致重复对话。 - -本周期不做: - -1. 飞书内完整多轮 Agent Chat。 -2. 飞书内复杂确认卡片。 -3. 飞书内直接应用日程。 -4. 移动端替代方案。 - -推荐预埋事件: - -```text -agent.channel.message.received -agent.channel.reply.requested -``` - -飞书主动通知仍然走: - -```text -notification.feishu.requested -``` - -也就是说,**通知通道** 和 **聊天通道** 从事件名和 DTO 上就要分开,避免下周期把飞书消息接入时污染 notification 逻辑。 - -### 介入点 C:主动调度 MVP 稳定后,扩充更多主动触发源 - -此时可以扩展: - -1. DDL 临近插入课表空余时间。 -2. 任务未完成监控。 -3. 用户反馈很累后的局部微调。 -4. 提前完成后的碎片任务建议。 -5. 休息/发呆选项。 - -所有触发源都应该进入同一个主动调度入口: - -```text -active_schedule.triggered -``` - -不要每个触发源各写一套调度逻辑。 - -### 介入点 D:飞书通知模型稳定后,拆 notification 独立 module - -当飞书通知从“能发 webhook”升级为“有幂等、有记录、有失败补偿”后,再把它从 `backend` 中迁到: - -```text -backend/services/notification -``` - -迁移时要保持: - -1. 主动调度 worker 只发布 `notification.feishu.requested`,不直接依赖飞书 SDK 或 webhook 细节。 -2. notification 服务负责通知记录、幂等、重试、provider 调用。 -3. 飞书聊天链路如果下周期启动,应单独走 `agent.channel.*` 事件,不混入 notification 投递模型。 - -### 介入点 E:主动调度协议稳定后,再拆 active-scheduler 独立 module - -当主动调度闭环稳定后,再把它从 `backend` 中迁到: - -```text -backend/services/active-scheduler -``` - -迁移时要保持: - -1. API 仍只发布事件和查询预览。 -2. active-scheduler 只负责建议生成和预览。 -3. 正式日程应用仍通过稳定命令或事件进入 schedule 域。 +> 建议习惯:每完成一个“可回退的切流点”就留一个 commit。 +> 如果一个阶段会跨好几天,尽量拆成“骨架 commit”和“切流 commit”两次保存进度。 --- -## 4.1 为下周期飞书 Agent 聊天链路预埋 +### 4.2 阶段 0:语义冻结和基线确认 -如果后续暂缓移动端开发,把飞书作为轻量移动入口,那么本周期接飞书时要提前避免两个误区: +目标: -1. 不要把飞书通知写成只会发送固定文案的死逻辑。 -2. 不要把飞书聊天直接塞进 notification handler。 +1. 先不改业务语义。 +2. 先把当前单体里哪些接口会迁走、哪些事件会保留,列清楚。 +3. 先把当前 `backend` 当成临时网关壳,而不是未来最终形态。 -建议从本周期就区分三层: +这一步要做的事: -```text -notification - 负责主动通知,例如“我发现你的今晚安排可能过载,已生成一版建议”。 +1. 固定对外接口语义。 +2. 固定事件类型和 DTO 契约。 +3. 固定当前 `api / worker / all` 的启动行为。 +4. 固定当前 outbox、llm-service、rag-service、user/auth、course、task-class、notification、active-scheduler 的责任边界。 -channel adapter - 负责不同渠道的入站/出站消息适配,例如 web、feishu、未来移动端。 +建议提交点: -agent conversation - 负责真正的 agent 多轮对话、上下文、工具调用、确认卡片。 -``` +1. 文档定稿。 +2. 路由和契约清单定稿。 -飞书作为聊天入口时,推荐链路是: +建议测试: -```text -飞书回调 - -> feishu channel adapter - -> 校验签名/解密/去重 - -> 映射系统 user_id 与 conversation_id - -> 发布 agent.channel.message.received - -> agent worker / agent service 处理 - -> 发布 agent.channel.reply.requested - -> feishu channel adapter 发送回复 -``` - -本周期最小预埋: - -1. 配置层预留 `feishu.enabled`、`feishu.webhook`、`feishu.appID`、`feishu.appSecret` 等字段位置。 -2. DTO 层预留 `Channel`、`ExternalUserID`、`ExternalConversationID`、`MessageID`、`IdempotencyKey`。 -3. 事件层预留 `agent.channel.message.received` 和 `agent.channel.reply.requested`。 -4. 数据层先不强行建全量飞书会话表;若需要,只建轻量映射表或先通过 Redis/配置映射过渡。 - -这样下周期即使做“飞书内直接聊 agent”,也不会推翻本周期的飞书通知实现。 +1. `go test ./...` +2. `api` 模式启动 smoke。 +3. `worker` 模式启动 smoke。 +4. `all` 模式启动 smoke。 --- -## 5. 并行开发分工建议 +### 4.3 阶段 1:Outbox v2 基建 -### Codex 优先负责 +目标: -1. 拆 `cmd.Start()`,抽启动装配层。 -2. 新增 `api / worker / all` 启动入口。 -3. 把 outbox relay、Kafka consumer、memory worker 从 API 入口移走。 -4. 为 notification 服务预留事件契约和目录位置。 -5. 补迁移文档和启动说明。 +1. 把 outbox 从“单体内部事件泵”升级成“服务级事件总线能力”。 +2. 让 outbox 先具备服务归属,再谈服务拆分。 +3. 为后面的 gozero 服务切分打地基。 -### 业务开发优先负责 +这一步要做的事: -1. 定义主动调度 MVP 的产品语义。 -2. 明确 Reasoning 展示格式。 -3. 明确用户确认粒度。 -4. 设计后台触发、dry-run、trigger 测试场景。 -5. 梳理哪些任务可被退回任务池,哪些必须保护。 -6. 明确飞书通知文案与“回系统确认”的跳转语义。 +1. 引入 outbox 归属概念,服务和 outbox 一一对应。 +2. relay worker 变成服务自己的后台进程。 +3. consumer 按服务订阅路由,不再一个 worker 什么都吃。 +4. 过渡期允许旧实现与新实现并行,但切流点必须清晰。 +5. 如果短期共享 topic,必须有显式事件路由,不能靠“未知事件直接 dead”来硬顶。 -### 双方交汇点 +建议提交点: -交汇点只放在事件契约和 DTO 上。 +1. outbox 归属和路由抽象完成后,先保存一个 commit。 +2. 第一条服务级 relay 跑通后,再保存一个 commit。 -建议先稳定以下结构: +建议测试: -```text -ActiveScheduleTrigger -ActiveScheduleSuggestion -ActiveScheduleChangeItem -ActiveScheduleReasoning -SchedulePreviewVersion -NotificationRequested -AgentInboundMessage -AgentOutboundMessage -``` - -只要这些结构稳定,底层是否已经拆成独立 module,不影响你继续开发业务。 +1. `go test ./...` +2. outbox 发布 / 投递 / 消费 smoke。 +3. 未知事件不会误伤其他服务的路由验证。 --- -## 6. 每一步的验收标准 +### 4.4 阶段 1.5:先抽 llm-service -### 第一步验收:启动边界 +目标: -1. `all` 模式与当前单体行为一致。 -2. `api` 模式能启动 Gin,并能访问现有接口。 -3. `worker` 模式能启动 outbox 和 memory worker,不启动 HTTP。 -4. API 进程发布的 outbox 消息能被 worker 消费。 -5. 停掉 worker 时,API 仍可启动,只是异步能力延迟执行。 +1. 把全仓统一模型出口先从各业务服务里抽出来。 +2. 让 `course`、`active-scheduler`、`memory`、`agent` 对模型调用的依赖先收口到统一服务。 +3. 先把模型 provider 路由、流式输出、限流、审计这些共性收束起来,避免每个服务各写一份。 -### 第二步验收:主动调度 MVP +这一步要做的事: -1. worker 能后台触发主动调度。 -2. 测试接口能触发同一条主动调度链路。 -3. 能传入 `mock_now`。 -4. 能读取四象限任务和课表空余时间。 -5. 能生成调整建议。 -6. 每个建议都有 Reasoning。 -7. 结果写入对比预览,不直接落库。 -8. 能发布并发送飞书通知,提醒用户回系统确认。 +1. 把当前分散在业务服务里的模型调用入口改成统一调用 `llm-service`。 +2. 把 provider 路由、重试、流式转发、审计日志收进 `llm-service`。 +3. 先保留旧调用路径的兼容适配,但新逻辑必须优先走 `llm-service`。 +4. 让 `course`、`active-scheduler`、`memory`、`agent` 的模型使用方式先统一,后面再看是否继续做更细的协议抽象。 +5. `llm-service` 只负责模型出口,不负责业务 prompt 状态机、工具编排或领域决策。 -### 第三步验收:飞书通知服务 +建议提交点: -1. `notification.feishu.requested` 能被独立服务消费。 -2. 有通知幂等键。 -3. 有通知发送记录。 -4. 飞书失败可重试。 -5. 飞书服务停止不影响 API 和主动调度预览生成。 +1. `llm-service` 可以独立启动时先 commit。 +2. 第一批业务服务完成切换并验证稳定后再 commit。 -### 第四步验收:核心服务化 +建议测试: -1. active-scheduler 可独立启动。 -2. schedule/task 的数据所有权逐步明确。 -3. API 不再直接承担核心调度重计算。 -4. 服务间通信只通过 RPC / Kafka / 明确契约完成。 +1. `llm-service` 单独启动 smoke。 +2. `course` 调模型 smoke。 +3. `active-scheduler` 调模型 smoke。 +4. `memory` / `agent` 调模型 smoke。 +5. 流式输出、重试和审计回归 smoke。 --- -## 7. 风险与回退策略 +### 4.5 阶段 1.6:再抽 rag-service -### 风险 1:启动拆分后本地开发变复杂 +目标: -回退策略: +1. 把统一检索基础设施从 `memory` / `agent` 里抽出来。 +2. 让向量化、召回、重排、向量库读写先进入独立服务。 +3. 明确 `rag-service` 只能依赖 `llm-service` 做 embedding / rerank,不反向依赖业务服务。 -1. 保留 `all` 模式。 -2. 本地默认仍可一键启动。 -3. 只有联调 worker / 飞书时才分进程启动。 +这一步要做的事: -### 风险 2:API 不启动消费者后 outbox 堆积 +1. 把当前分散在 `memory`、`agent` 里的检索逻辑改成统一调用 `rag-service`。 +2. 把 chunk、embed、rerank、retrieve、store 这些能力收进 `rag-service`。 +3. `rag-service` 通过 `llm-service` 获取 embedding 或 rerank 能力,不直接接业务模型出口。 +4. 先保留旧检索链路的兼容适配,但新链路必须优先走 `rag-service`。 +5. 让 `memory` 退回成记忆管理和编排支撑服务,不再自己持有完整检索基础设施。 -回退策略: +建议提交点: -1. 本地使用 `all` 模式。 -2. 联调环境明确启动 worker。 -3. 增加 outbox 堆积观测或临时查询 SQL。 +1. `rag-service` 可以独立启动时先 commit。 +2. `memory` / `agent` 切到 `rag-service` 后稳定,再 commit。 -### 风险 3:多个 worker 重复投递 +建议测试: -回退策略: - -1. 第一阶段只启动一个 worker。 -2. 后续再补 outbox claim / processing 状态。 -3. 外部通知必须有业务幂等键。 - -### 风险 4:过早拆 schedule/task 导致分布式单体 - -回退策略: - -1. 第一阶段不拆正式日程落库。 -2. 第二阶段只拆主动建议生成。 -3. schedule/task 等数据所有权稳定后再拆。 +1. `rag-service` 单独启动 smoke。 +2. `memory retrieve` smoke。 +3. `memory rerank` smoke。 +4. `agent` 检索调用 smoke。 +5. `rag-service -> llm-service` 依赖链路 smoke。 --- -## 8. 推荐近期执行顺序 +### 4.6 阶段 2:先拆 user/auth -近期建议按以下顺序推进: +目标: -1. Codex 先完成启动边界拆分:`api / worker / all`。 -2. 业务侧同步定义主动调度 MVP 的 DTO 与 Reasoning 格式。 -3. 在 `backend` worker 内实现后台主动触发器,并保留 dry-run / trigger 测试接口。 -4. 主动调度结果先写现有对比预览。 -5. 增加 `notification.feishu.requested` 事件,并实现简版飞书 webhook/provider。 -6. 预留 `agent.channel.*` 事件和 channel DTO,为下周期飞书聊天链路做准备。 -7. 飞书通知模型稳定后,新建 `backend/services/notification`,使用 go-zero 落地独立通知服务。 -8. 主动调度稳定后,再考虑拆 `active-scheduler`。 +1. 把用户入口、登录态签发和 token 额度治理从 Gin 单体里拆出来。 +2. 让 gateway 不再直读 `users` 表,先把“用户域”变成独立服务边界。 +3. 为后面所有需要鉴权和额度检查的服务提供稳定入口。 + +这一步要做的事: + +1. 把 `/user/register`、`/user/login`、`/user/refresh-token`、`/user/logout` 迁出当前单体。 +2. 把 JWT 签发、刷新、黑名单和 token 额度判断收进 `user/auth` 服务。 +3. gateway 只保留 access token 校验、路由转发和轻量编排,不再直接碰用户表。 +4. `agent/chat` 相关的 token quota 门禁同步改成调用 `user/auth` 能力,避免网关继续直连 `users` 表。 +5. 过渡期允许 gateway 保留原 `/user` 路由壳,但业务实现必须已经落到新服务。 + +建议提交点: + +1. `user/auth` 服务可以独立启动时,先 commit。 +2. 登录、刷新、登出与 token quota 完整切流后,再 commit。 + +建议测试: + +1. `user/auth` 服务单独启动 smoke。 +2. 注册 / 登录 / 刷新 / 登出 smoke。 +3. token quota 门禁回归 smoke。 +4. 旧网关壳停掉 user 直连后,`agent/chat` 仍能正常鉴权与限额校验。 + +--- + +### 4.7 阶段 3:再拆 notification + +目标: + +1. 把通知投递做成第一条真正独立出来的 gozero 服务。 +2. 把通知投递和主动调度闭环解耦。 +3. 验证“服务级 outbox + 服务专属 worker + 独立重试”这套新模式。 + +这一步要做的事: + +1. 把 notification 的投递记录、幂等、重试、provider 调用收进 notification 服务,并先按你熟悉的 service 内单体壳收束,避免继续长出新的顶层小包。 +2. 主动调度 worker 只负责发布 `notification.feishu.requested`。 +3. API 只负责通道配置、测试和轻量查询,不直接发真实通知。 +4. notification 服务只消费自己的通知事件。 +5. 这一步优先用 gozero 落地。 + +建议提交点: + +1. notification 服务可以独立启动时,先 commit。 +2. notification 的独立消费和重试都稳定后,再 commit。 + +建议测试: + +1. notification 服务单独启动 smoke。 +2. 通知事件端到端 smoke。 +3. 重试扫描 smoke。 +4. 停掉 notification 服务后,主动调度预览仍然可用的回归测试。 + +--- + +### 4.8 阶段 4:再拆 active-scheduler + +目标: + +1. 把主动调度的“生成建议”能力独立成服务。 +2. 让 gateway 退成边缘编排层,不再承载核心建议生成。 +3. 把主动调度的输入输出契约稳定下来。 + +这一步要做的事: + +1. 把 `trigger -> dry-run -> preview` 链路迁出当前单体。 +2. 保留清晰的 DTO 和事件契约。 +3. `active-scheduler` 用 gozero 落地。 +4. 正式确认应用先保持稳定同步语义,等契约更稳后再考虑更深的异步化。 + +建议提交点: + +1. dry-run 能独立跑通时,先 commit。 +2. preview / confirm 的 end-to-end 跑通后,再 commit。 + +建议测试: + +1. dry-run smoke。 +2. preview 生成 smoke。 +3. confirm / apply smoke。 +4. `mock_now` 和幂等回归测试。 + +--- + +### 4.9 阶段 5:再拆 schedule / task / course / task-class + +目标: + +1. 把正式日程和任务池的所有权拆出去。 +2. 把课程导入和任务类别编排一起纳入计划编排域。 +3. 让核心数据服务真正独立。 +4. 让 gateway 不再直接读写这些核心表。 + +这一步要做的事: + +1. `schedule` 先独立,再看 `task`、`course`、`task-class`。 +2. 每个领域只维护自己的写模型。 +3. 通过事件或明确 RPC 契约通信。 +4. 继续保持并行迁移,旧实现和新实现可以短期并存。 + +建议提交点: + +1. schedule 切流完成后 commit。 +2. course / task-class 切流完成后 commit。 +3. task 切流完成后 commit。 + +建议测试: + +1. schedule 回归测试。 +2. course 回归测试。 +3. task-class 回归测试。 +4. task 回归测试。 +5. 全链路 smoke。 + +--- + +### 4.10 阶段 6:再拆 agent / memory + +目标: + +1. 把聊天路由、计划/执行编排、工具接入从现有单体里独立出去。 +2. 把 memory 的异步抽取、检索、管理拆成独立支撑服务/worker。 +3. 让 agent 通过清晰的服务契约调用 user/auth、course、task-class、notification、active-scheduler、schedule、task,不再依赖大装配入口。 + +这一步要做的事: + +1. 把 `newAgent` 里的路由、prompt、graph、tool registry、会话状态和 SSE 输出包装收进 `agent` 服务。 +2. 把 `memory` 的 repo、worker、orchestrator 和审计写入收进 `memory` 服务。 +3. gateway 只保留鉴权、路由转发和流式透传,不再直接承担 agent 会话编排。 +4. 过渡期允许 agent 先通过同进程适配器访问现有能力,但切流点必须清楚。 + +建议提交点: + +1. agent 服务可以独立启动时先 commit。 +2. memory 的独立抽取和检索链路稳定后再 commit。 + +建议测试: + +1. agent chat smoke。 +2. memory extract smoke。 +3. memory retrieve / manage smoke。 +4. agent 停用旧网关直连后的回归 smoke。 + +--- + +### 4.11 阶段 7:Gin Gateway 收口 + +目标: + +1. 让当前 Gin 服务真正退化成 Gateway。 +2. 只保留用户入口转发、鉴权、组合和流式交互。 +3. 清掉剩余的核心业务直连路径。 + +这一步要做的事: + +1. 移除 gateway 里的核心领域写路径。 +2. 把业务能力全部迁到 gozero 服务。 +3. 只保留前端入口的薄编排。 +4. 用户入口也只做转发和响应适配,不再直接读写 `users` 表。 +5. 把 gateway 当成 BFF,而不是域服务承载体。 + +建议提交点: + +1. gateway 不再直接写核心业务表时 commit。 +2. gateway 路由只剩边缘职责时再 commit。 + +建议测试: + +1. 网关路由 smoke。 +2. 鉴权 smoke。 +3. 前端关键路径 smoke。 + +--- + +## 5. 推荐执行顺序 + +近期建议按这个顺序推进: + +1. 先冻结现有语义和契约。 +2. 再做 Outbox v2。 +3. 先切 llm-service,把统一模型出口从各业务服务里抽出去。 +4. 再切 rag-service,把检索基础设施从 memory / agent 里抽出去。 +5. 先切 user/auth,把登录态和额度门禁从 gateway 拿出去。 +6. 再切 notification。 +7. 再切 active-scheduler。 +8. 然后切 schedule / task / course / task-class。 +9. 再切 agent / memory,把聊天编排和记忆链路独立出去。 +10. 最后把 Gin 收口成纯 Gateway。 一句话总结: -> 先让项目从“所有能力绑在一个 Gin 单体进程”变成“API 与 Worker 分开跑”;再让第二阶段主动调度闭环挂到 Worker 与事件契约上;最后把飞书通知作为第一个独立 Go module 服务拆出去。 +> 先把 outbox 从单体内部兜底机制变成服务级基础设施;再把 llm-service 抽成全仓统一模型出口,把 rag-service 抽成统一检索基础设施;然后把 user/auth 从 gateway 里抽出去,清掉用户表直连和额度门禁耦合;接着把 notification 切成第一条事件驱动服务线;然后让 active-scheduler、schedule、task、course、task-class 按稳定边界逐步独立;再把 agent / memory 独立出来,完成聊天编排和记忆链路的服务化;最后把 Gin 收口成真正的 Gateway。 + +--- + +## 6. 最终文件结构对齐 + +### 6.1 参考结论 + +对照你上一个 `Kitex + Hertz` 项目,这次换成 `Gin Gateway + gozero` 后,**结构理念基本不变,服务承载方式会变**。 + +不变的部分: + +1. 仍然是“入口层 + 多服务层 + 共享契约层”的三段式。 +2. 仍然是“一个服务一个目录”,目录内部继续按职责拆分。 +3. 仍然保留 `dao / model / handler(or api) / utils / init` 这类服务内部基础分层。 +4. 仍然保留按领域拆目录,而不是把所有业务代码堆到一个大包里。 + +需要变化的部分: + +1. `Kitex` 的 `kitex_gen` / `.thrift` / `build.sh` / `script` 这套 RPC 生成结构,会被 gozero 的 `api` / `rpc` / `internal` / `etc` 结构替代。 +2. 原来 `client` + `kitex server` 的双模块思路,会变成 `gateway` + `services/*` 的多服务布局。 +3. 入口层会从“RPC 客户端网关”转成“HTTP Gateway + gozero 服务调用”。 +4. 服务内部的 `start.go` 会收口成 gozero 风格的 `main + config + logic + svc`,或者保留少量自定义启动壳,但不再依赖 Kitex 那套启动模板。 + +### 6.2 推荐目标树 + +```text +SmartFlow-Agent/ +├── frontend/ +├── backend/ +│ ├── gateway/ +│ │ ├── cmd/ +│ │ │ └── gateway/ +│ │ │ └── main.go +│ │ ├── api/ +│ │ ├── middleware/ +│ │ ├── routers/ +│ │ └── auth/ +│ ├── services/ +│ │ ├── user-auth/ +│ │ │ ├── start.go +│ │ │ ├── handler.go +│ │ │ ├── sv/ +│ │ │ ├── dao/ +│ │ │ ├── model/ +│ │ │ └── internal/ +│ │ │ ├── token/ +│ │ │ ├── quota/ +│ │ │ └── session/ +│ │ ├── course/ +│ │ │ ├── start.go +│ │ │ ├── handler.go +│ │ │ ├── sv/ +│ │ │ ├── dao/ +│ │ │ ├── model/ +│ │ │ └── internal/ +│ │ │ ├── parse/ +│ │ │ ├── import/ +│ │ │ ├── conflict/ +│ │ │ └── adapter/ +│ │ ├── task-class/ +│ │ │ ├── start.go +│ │ │ ├── handler.go +│ │ │ ├── sv/ +│ │ │ ├── dao/ +│ │ │ ├── model/ +│ │ │ └── internal/ +│ │ │ ├── convert/ +│ │ │ ├── batch/ +│ │ │ └── item/ +│ │ ├── notification/ +│ │ │ ├── start.go +│ │ │ ├── handler.go +│ │ │ ├── sv/ +│ │ │ ├── dao/ +│ │ │ ├── model/ +│ │ │ └── internal/ +│ │ │ ├── provider/ +│ │ │ ├── runner/ +│ │ │ ├── dedupe/ +│ │ │ ├── channel/ +│ │ │ └── retry/ +│ │ ├── active-scheduler/ +│ │ │ ├── start.go +│ │ │ ├── handler.go +│ │ │ ├── sv/ +│ │ │ ├── dao/ +│ │ │ ├── model/ +│ │ │ └── internal/ +│ │ │ ├── graph/ +│ │ │ ├── selection/ +│ │ │ ├── feedbacklocate/ +│ │ │ ├── preview/ +│ │ │ ├── apply/ +│ │ │ ├── job/ +│ │ │ └── trigger/ +│ │ ├── schedule/ +│ │ │ ├── start.go +│ │ │ ├── handler.go +│ │ │ ├── sv/ +│ │ │ ├── dao/ +│ │ │ ├── model/ +│ │ │ └── internal/ +│ │ │ ├── command/ +│ │ │ ├── event/ +│ │ │ └── conflict/ +│ │ ├── task/ +│ │ │ ├── start.go +│ │ │ ├── handler.go +│ │ │ ├── sv/ +│ │ │ ├── dao/ +│ │ │ ├── model/ +│ │ │ └── internal/ +│ │ │ ├── policy/ +│ │ │ ├── event/ +│ │ │ └── urgency/ +│ │ ├── agent/ +│ │ │ ├── start.go +│ │ │ ├── handler.go +│ │ │ ├── sv/ +│ │ │ ├── dao/ +│ │ │ ├── model/ +│ │ │ └── internal/ +│ │ │ ├── prompt/ +│ │ │ ├── graph/ +│ │ │ ├── stream/ +│ │ │ ├── tool/ +│ │ │ ├── session/ +│ │ │ └── router/ +│ │ ├── memory/ +│ │ │ ├── start.go +│ │ │ ├── handler.go +│ │ │ ├── sv/ +│ │ │ ├── dao/ +│ │ │ ├── model/ +│ │ │ └── internal/ +│ │ │ ├── repo/ +│ │ │ ├── orchestrator/ +│ │ │ ├── worker/ +│ │ │ ├── observe/ +│ │ │ ├── cleanup/ +│ │ │ └── vectorsync/ +│ │ ├── llm/ +│ │ │ ├── start.go +│ │ │ ├── handler.go +│ │ │ ├── sv/ +│ │ │ ├── model/ +│ │ │ └── internal/ +│ │ │ ├── provider/ +│ │ │ ├── router/ +│ │ │ ├── stream/ +│ │ │ ├── quota/ +│ │ │ └── audit/ +│ │ └── rag/ +│ │ ├── start.go +│ │ ├── handler.go +│ │ ├── sv/ +│ │ ├── model/ +│ │ └── internal/ +│ │ ├── chunk/ +│ │ ├── embed/ +│ │ ├── rerank/ +│ │ ├── retrieve/ +│ │ ├── store/ +│ │ ├── corpus/ +│ │ └── observe/ +│ ├── shared/ +│ │ ├── events/ +│ │ └── infra/ +│ │ ├── kafka/ +│ │ └── outbox/ +│ └── main.go +└── docs/ +``` + +> 说明 1:`sv/` 是本文档里唯一推荐的“服务主业务编排层”目录名;文中若出现 `service/`,默认只表示当前仓库里的旧命名或迁移遗留,不作为终态目录名。 +> +> 说明 2:`dao/` 只负责数据库访问,`handler.go` 只负责 HTTP 入口适配,`sv/` 只负责业务用例编排,`internal/` 只放服务私有子模块,禁止被别的服务直接 import。 +> +> 说明 3:`start.go` 只负责装配依赖和启动进程,不承载业务规则。 +> +> 当前目录到目标目录的映射: +> +> 1. `backend/service/*.go` 这批现有业务逻辑,后面要分别迁到各自服务根目录下的 `sv/`。 +> 2. `backend/service/agentsvc/*` 和 `backend/newAgent/*`,后面要收束到 `backend/services/agent/sv/` + `internal/{prompt,graph,stream,tool,session,router}`。 +> 3. `backend/notification/*`,后面要收束到 `backend/services/notification/`,其中 `runner/provider/dedupe/channel_service` 归入 `internal/notification/`。 +> 4. `backend/active_scheduler/*`,后面要收束到 `backend/services/active-scheduler/`,其中 `graph/selection/feedbacklocate/apply/job` 归入 `internal/`。 +> 5. `backend/memory/*`,后面要收束到 `backend/services/memory/`;当前 `memory/service/*` 只是迁移过渡态,终态还是按 `sv/` 或 `internal/` 拆开。 +> +> 说明 4:`shared` 先保留 `events` 和少量跨服务底座型 `infra`。以后如果真的出现跨服务 DTO / 枚举 / 常量,再新增 `contracts` 一类目录,但不要把 `dao`、`model`、`sv`、`handler` 这类服务私有层塞进去。 + +> 说明 5:`notification` 和 `active-scheduler` 的服务内部建议继续收束成你熟悉的“服务内单体壳”风格,不要让一级目录一直长成一排小框架;复杂算法和编排细节可以继续拆文件,但尽量下沉到 `sv/` 或 `internal/` 下面。 +> +> 说明 6:`llm-service` 和 `rag-service` 是独立基础设施服务,不放进 `shared`;`rag-service` 依赖 `llm-service` 做 embedding / rerank,不反向依赖业务服务。 +> +> 说明 7:目录树里如果暂时写成 `backend/services/llm/` 和 `backend/services/rag/`,那只是目录名写法;后文所有职责判断都以 `llm-service` / `rag-service` 这两个逻辑服务名为准。 + +### 6.3 哪些可以不用变 + +1. 课程、任务、日程、通知这些领域模型的名字和业务语义可以保留。 +2. `middleware` 的鉴权、限流、幂等这类横切能力可以继续保留,最多是接入方式变化。 +3. `conv`、`infra`、`inits` 这类能力可以复用思路,但位置要按服务边界重新归属,不默认做成全局公共层。 +4. `shared` 里的事件契约和少量跨服务底座可以继续保留,并作为跨进程通信的稳定锚点。 + +### 6.4 哪些需要变 + +1. `backend/cmd/start.go` 这种“大装配入口”后面要逐步拆成 gateway 启动和各服务启动。 +2. `api` 这一层会收缩成纯 Gateway 职责,不再承载核心领域逻辑。 +3. 当前仓库里的 `backend/service` 目录和相关遗留入口,要按 `user/auth`、`course`、`task-class`、`notification`、`active-scheduler`、`schedule`、`task`、`agent`、`memory` 拆出去;其中 `notification` 和 `active-scheduler` 最终都要收束成更像 seckill 的服务内单体壳,不要长期维持一串顶层小包。 +4. 当前单体里的共享启动方式 `api / worker / all`,后面会拆成“gateway 进程 + 服务进程 + worker 进程”的组合。 +5. 任何依赖 `users` 表直读、核心表直写的网关路径,都要迁到对应服务里。 +6. 不再把服务私有的 `dao` / `model` / `sv` / `handler` 误放进 `shared`,避免它变成新的单体公共层。 +7. 当前 `backend/infra/llm` 和 `backend/infra/rag` 只是迁移过渡态,后面分别收束到 `backend/services/llm` 和 `backend/services/rag`。 + +### 6.5 换对话时要先记住的锚点 + +1. `shared` 不是公共业务层,只是跨服务契约层。 +2. 第一批 gozero 域服务是 `user/auth`、`course`、`task-class`、`notification`、`active-scheduler`、`schedule`、`task`,后面再切 `agent` / `memory`。 +3. `agent` 不是公共能力,它应当单独成服务;`memory` 也是独立支撑服务,不应长期挂在 gateway 里。 +4. `notification` 和 `active-scheduler` 都应该回到更像 seckill 的服务内单体结构,避免成为“半个框架”。 +5. `llm-service` 是全仓统一模型出口;`rag-service` 是统一检索基础设施;`rag-service` 依赖 `llm-service`,不反向依赖业务服务。 +6. outbox 先升级成服务级基础设施,再按域边界逐个切出去。 +7. 后续任何服务目录调整,都要先对照下面的“典型用例”;如果这次改动说不清它属于哪个用例,就先不要动结构,只补文件或补注释。 + +### 6.6 `notification` / `active-scheduler` 的服务内结构 + +1. `notification` 建议直接收束成更标准的服务内单体壳:外层统一成 `dao/`、`model/`、`sv/`、`handler.go`、`start.go`,当前的 `runner.go`、`provider.go`、`dedupe.go`、`channel_service.go` 这类细节先保留在服务内部,但后面要逐步并入 `sv/` 或 `internal/notification/`,不要长期挂成一串平级文件。 +2. `active-scheduler` 建议收束成同类服务壳,外层只保留 `dao/`、`model/`、`sv/`、`handler.go`、`start.go`,把 `graph`、`selection`、`feedbacklocate`、`apply`、`job` 这些复杂流程统一下沉到 `internal/`。 +3. 这样做的目标,是让后续每个服务的阅读方式都更接近你熟悉的 seckill 风格,而不是把一个服务拆成十几个平级目录。 + +### 6.7 每个服务的典型用例 + +> 这段是给后续代理看的硬护栏:先对齐用例,再动目录;如果不是围绕对应用例扩展,不要先拆目录再补理由。 + +| 服务 | 典型用例 | 结构收束建议 | 不允许的改法 | +| --- | --- | --- | --- | +| `user/auth` | 注册、登录、刷新、登出、JWT 签发、黑名单、token 额度门禁 | `handler.go` / `sv/` / `dao/` / `model/` / `internal/auth/`;认证状态机和额度判断留在服务内 | 不要把 gateway 鉴权逻辑和别的领域规则混进来 | +| `course` | 课程导入、图片解析、课表校验、课程落表,图片解析走 `llm-service` | `handler.go` / `sv/` / `dao/` / `model/` / `internal/{parse,import,conflict,adapter}/` | 不要把课程解析代码写成网关临时脚本 | +| `task-class` | 任务类创建/更新、items 批量 upsert、嵌入时间同步 | `handler.go` / `sv/` / `dao/` / `model/` / `internal/{convert,batch,item}/` | 不要把批处理拼装沉到 handler 里,也不要让 agent 直接改库 | +| `notification` | 消费 `notification.feishu.requested`、写通知记录、幂等、重试、provider 投递 | `start.go` / `handler.go` / `sv/` / `dao/` / `model/` / `internal/{provider,runner,dedupe,channel,retry}/` | 不要把通知投递逻辑散回 worker 或 gateway | +| `active-scheduler` | `trigger -> dry-run -> preview -> confirm`、建议生成、反馈定位;候选选择走 `llm-service` | `start.go` / `handler.go` / `sv/` / `dao/` / `model/` / `internal/{graph,selection,preview,feedbacklocate,apply,job,trigger}/` | 不要把 graph/selection 继续长成对外平级框架 | +| `schedule` | 正式日程所有权、查询、删除、应用命令 | `start.go` / `handler.go` / `sv/` / `dao/` / `model/` / `internal/{command,event,conflict}/` | 不要让 gateway 直接写 schedule 表 | +| `task` | 任务新增、完成/撤销、任务池查询、紧急性平移 | `start.go` / `handler.go` / `sv/` / `dao/` / `model/` / `internal/{policy,event,urgency}/` | 不要把 task 的状态机塞进 agent 或 schedule | +| `llm-service` | 统一模型调用、provider 路由、流式输出、重试、限流、审计 | `start.go` / `handler.go` / `sv/` / `model/` / `internal/{provider,router,stream,quota,audit}/` | 不要把业务 prompt、状态机、工具编排塞进模型出口 | +| `rag-service` | 向量化、召回、重排、向量库读写、语料适配、检索 API | `start.go` / `handler.go` / `sv/` / `model/` / `internal/{chunk,embed,rerank,retrieve,store,corpus,observe}/` | 不要让业务服务直连向量库并绕开统一检索层 | +| `agent` | 多轮对话、SSE、工具调用、计划/执行编排、主动调度会话复跑;模型推理走 `llm-service`,记忆检索走 `memory` / `rag-service` | 当前过渡形态是 `backend/service/agentsvc` + `backend/newAgent/*`;终态应收束为 `start.go` / `handler.go` / `sv/` / `dao/` / `model/` / `internal/{prompt,graph,stream,tool,session,router}/` | 不要把 memory、notification、schedule 的业务实现塞进 agent,只通过契约调用 | +| `memory` | 记忆抽取、检索、管理、worker 执行、观测埋点;抽取走 `llm-service`,检索走 `rag-service` | `start.go` / `handler.go` / `sv/` / `dao/` / `model/` / `internal/{repo,orchestrator,worker,observe,cleanup,vectorsync}/`;当前 `module.go` 只是过渡总门面 | 不要把 memory 变成 gateway 的辅助函数 | + +`shared` 和 `shared/infra` 不在这张表里,因为它们不是业务服务,只承载跨服务契约和少量公共底座,不按某一个域服务的用例来改。 + +### 6.8 最终服务关系图 + +```mermaid +graph TD + FE["frontend"] --> GW["gateway"] + + subgraph D["业务域服务"] + UA["user/auth"] + C["course"] + TC["task-class"] + N["notification"] + AS["active-scheduler"] + S["schedule"] + T["task"] + A["agent"] + M["memory"] + end + + subgraph I["基础设施服务"] + L["llm-service"] + R["rag-service"] + end + + subgraph B["共享底座"] + OB["各服务 outbox"] + K[(Kafka)] + end + + GW --> UA + GW --> C + GW --> TC + GW --> N + GW --> AS + GW --> S + GW --> T + GW --> A + GW --> M + + A --> UA + A --> C + A --> TC + A --> N + A --> AS + A --> S + A --> T + A --> M + + AS --> C + AS --> TC + AS --> S + AS --> T + AS --> N + + C --> L + AS --> L + A --> L + M --> L + M --> R + R --> L + + UA --> OB + C --> OB + TC --> OB + N --> OB + AS --> OB + S --> OB + T --> OB + A --> OB + M --> OB + OB --> K +``` + +说明: + +1. `rag-service` 只依赖 `llm-service`,不反向依赖业务服务。 +2. `agent` 仍然是编排层,实际会通过契约调用 `user/auth`、`course`、`task-class`、`notification`、`active-scheduler`、`schedule`、`task` 和 `memory`。 +3. Gateway 不直接碰 `llm-service` / `rag-service`,只把请求转给对应业务服务。 +4. 图里的 outbox 是“每个服务自己的 outbox 表 + 专属 relay worker”的抽象,不代表所有服务共用一张表。 + +### 6.9 切对话交接卡 + +这段是给下一位代理直接接手用的,不需要再反复问大方向。 + +1. 先读顺序:`3.2` 服务层、`4.1` 阶段总览、`4.3` Outbox v2、`4.4` llm-service、`4.5` rag-service、`4.6` user/auth、`4.7` notification、`4.8` active-scheduler、`4.10` agent / memory、`6.5` 切对话锚点、`6.7` 典型用例、`6.8` 最终关系图、`6.10` 启动方式、`6.11` 测试自动化、`6.12` 多代理执行闭环。 +2. 已冻结的终态是 `Gin Gateway + gozero 服务群 + 服务级 outbox + Kafka 共享运输层`。 +3. 已冻结的基础设施服务是 `llm-service` 和 `rag-service`,其中 `rag-service` 只依赖 `llm-service`。 +4. 已冻结的业务服务优先级是 `user/auth -> notification -> active-scheduler -> schedule/task/course/task-class -> agent/memory`,其中 `notification` 和 `active-scheduler` 要回到更像 seckill 的服务内单体壳。 +5. `shared` 只保留跨进程契约和少量跨服务底座,不承载业务逻辑、DAO、模型或状态机。 +6. 如果后续要改目录,必须先回答“这个文件属于哪一个典型用例”,回答不清楚就先别动结构。 +7. 当前文档已经可以作为切对话基线;后续代理默认按本文件推进,只在出现新的契约风险、边界变化或业务语义变化时再重新讨论架构。 + +### 6.10 启动方式与进程模型 + +1. 终态里每个 gozero 服务都应当是独立进程:一个服务一个 `main.go`,一份配置,一组日志,一套端口和资源连接。 +2. 目录上可以继续采用 `backend/cmd//main.go` 作为可执行入口,`backend/services//` 负责 `start.go`、`handler.go`、`sv/`、`dao/`、`model/`、`internal/`。 +3. 本地开发为了方便,可以保留 `backend/cmd/all`、`make dev` 或类似聚合启动器,但它只负责拉起多个独立进程,不在同一个 Go 进程里把所有服务 `startXXX()` 混着跑。 +4. `go startxxx()` 这种“一个进程里同时起多个服务”的方式只适合作为过渡调试壳,不作为最终部署形态。 +5. 如果某些服务需要联动启动,应通过脚本、Makefile、docker compose 或开发编排器去启动多个二进制,而不是把进程边界打穿。 +6. 带 worker 的服务可以继续保留多入口角色,例如 `api` / `worker` / `all`,但它们仍然是同一服务的不同可执行角色,不是把多个服务硬塞进一个进程。 + +### 6.11 测试自动化与 smoke 权限边界 + +后续 agent 做阶段 smoke 时,默认可以使用下面三类本地测试能力,不需要每次重新讨论测试方式: + +1. **注册测试账号**:允许通过本地或测试环境接口创建临时 smoke 账号,用于登录、鉴权、token quota、agent chat、主动调度等链路验证。 +2. **curl 调接口**:允许用 `curl` 调本地 `gateway` 或本地服务接口,验证 HTTP 状态码、响应结构、业务状态流转和错误码。 +3. **docker exec 查 MySQL**:允许通过 `docker exec` 进入本地 MySQL 容器执行只读查询,核对用户、任务、日程、outbox、notification_records、memory_jobs 等表里的状态。 + +默认测试账号规则: + +1. 账号必须使用明显的测试前缀,例如 `smoke_agent_@example.test`。 +2. 不使用真实手机号、真实邮箱、真实姓名或用户个人信息。 +3. 密码只用于本地 smoke,不能写入文档、提交记录或日志摘要。 +4. 如果接口要求昵称、用户名等字段,统一使用 `smoke_agent_` 这类可识别测试值。 + +默认允许的 smoke 操作: + +1. 启动本地服务或本地 worker。 +2. 注册、登录、刷新 token、登出。 +3. 调用迁移阶段相关接口,例如课程导入、任务类维护、主动调度 dry-run / preview / confirm、通知投递、agent chat、memory retrieve。 +4. 用 `docker exec` + `SELECT` / `SHOW` 查询本地 MySQL 状态。 +5. 查看 outbox 是否写入、是否投递、是否被对应服务消费。 +6. 查看 notification、schedule、task、memory 等关键表状态是否符合预期。 + +默认不允许的 smoke 操作: + +1. 不访问生产环境接口。 +2. 不使用真实用户账号或真实第三方 webhook 做自动化验证。 +3. 不直接执行 `DROP`、`TRUNCATE`、`DELETE`、`UPDATE`、`ALTER` 这类破坏性或批量数据库操作。 +4. 不直接清空 outbox、任务、日程、用户、通知、记忆等业务表。 +5. 不把 token、密码、API key、webhook 地址等敏感值写进文档、提交信息或最终报告。 + +如果 smoke 需要清理测试数据,优先使用专门测试库、测试前缀、接口级清理能力或一次性测试环境;直接改库清理必须单独确认。 + +每次 smoke 最终汇报至少包含: + +1. 启动了哪些进程或服务。 +2. 注册/登录使用的是哪类测试账号前缀,不输出密码。 +3. curl 调用了哪些接口、返回了哪些关键状态码。 +4. docker exec 查询了哪些表、验证了哪些关键字段。 +5. 哪些检查通过,哪些失败,失败时保留可复现命令或最小上下文。 + +如果本轮还执行了 `go test`,测试结束后必须清理项目根目录下的 `.gocache`,保持仓库整洁。 + +### 6.12 多代理并行执行闭环 + +后续迁移任务如果范围较大,推荐按“主代理规划与收口,子代理并行推进”的方式执行。 + +子代理统一配置: + +1. 子代理默认统一使用 `gpt-5.4`,reasoning effort 统一使用 `xhigh`。 +2. 除非用户明确指定其他模型,否则迁移实现、代码评审和真实 smoke 子代理都按这个配置开。 +3. 主代理给子代理的任务必须写清楚目标、文件范围、禁止改动范围、验收标准和最终输出格式。 + +整体闭环: + +1. **主代理先定边界**:读取本计划和当前代码,明确本轮只处理一个阶段、一个能力域或一类公共件。 +2. **主代理拆分任务**:把任务拆成互不冲突的子任务,并给每个子代理指定明确文件范围、责任边界和验收标准。 +3. **子代理并行推进**:多个子代理可以并行实现不同服务、不同适配层或不同测试补强,但禁止同时修改同一批核心文件。 +4. **主代理耐心等待**:主代理必须等并行推进的子代理返回完整结果后,再进入统一收口;等待期间可以做不冲突的上下文整理,但不能重复实现、覆盖或抢跑子代理负责的任务。 +5. **主代理统一收口**:主代理负责合并实现、处理接口命名、依赖注入、配置、启动入口和跨服务契约一致性。 +6. **子代理并行 code review + 真实测试**:实现收口后,再开子代理分别做代码评审和真实 smoke;code review 聚焦 bug、边界、回归风险,真实测试按 `6.11` 执行。 +7. **主代理再次等待**:主代理必须等 review 子代理和测试子代理都给出结论后,再判断是否修复、回退或扩大测试面。 +8. **主代理修主要问题**:主代理根据评审和 smoke 结果修复阻塞问题、高风险问题和明确回归。 +9. **主代理最终复核**:复跑关键测试,汇总本轮迁了什么、旧实现保留在哪里、切流点在哪里、下一轮建议继续处理什么。 + +子代理拆分原则: + +1. 每个子代理必须有清晰所有权,例如 `services/notification`、`shared/events`、`gateway route adapter`、`outbox relay`。 +2. 子代理之间的写入范围尽量不重叠;如果必须碰同一文件,由主代理自己处理。 +3. 子代理不能擅自回滚、删除或覆盖其他代理的改动。 +4. 子代理不能自行扩大阶段范围,例如本轮拆 notification 时顺手重构 active-scheduler。 +5. 子代理输出必须包含改了哪些文件、为什么改、还有哪些风险没有验证。 + +主代理收口职责: + +1. 统一包名、目录名、接口名和配置名。 +2. 统一错误处理、日志、注释语言和启动方式。 +3. 确认 `shared` 没有被塞入服务私有业务逻辑。 +4. 确认 `sv` / `dao` / `model` / `internal` 的职责没有串层。 +5. 确认 outbox、Kafka consumer group、worker 归属没有打乱服务边界。 + +并行 code review 细则: + +1. review 子代理只做评审,不直接改代码,除非主代理明确分派修复任务。 +2. review 先报 bug、回归风险、缺测试和边界污染,不写空泛风格建议。 +3. review 必须引用具体文件和位置,说明为什么这是迁移风险。 +4. review 结果交给主代理统一判断,不由多个子代理分别修同一处。 + +并行真实测试细则: + +1. 测试子代理按 `6.11` 的权限边界执行,只使用本地或测试环境。 +2. 可以注册临时 smoke 账号、curl 接口、docker exec 只读查询 MySQL。 +3. 测试子代理不能做破坏性数据库操作,不能访问生产环境,不能使用真实用户信息。 +4. 测试结果必须包含命令、关键状态码、关键表查询点和通过/失败结论。 +5. 如果测试发现阻塞问题,主代理先修复主路径,再决定是否扩大测试面。 + +提交与保存进度: + +1. 不主动 `git commit`,除非用户明确要求。 +2. 如果用户要求提交,主代理必须先确认本轮改动范围、测试结果和未解决风险。 +3. 每个阶段推荐在“骨架可启动”“切流可回退”“真实 smoke 通过”这几个点分别保存进度。 + +--- + +## 7. 风险与回退 + +### 风险 1:消息路由不清 + +表现: + +1. 服务误吃别人的事件。 +2. 未知事件被错误 dead-letter。 +3. 多服务相互污染状态。 + +回退: + +1. 先把 topic 再切细。 +2. 先用服务级 consumer group 隔离。 +3. 先保留 `all` 模式兜底。 + +### 风险 2:relay 并行过早 + +表现: + +1. 重复投递。 +2. 状态回写打架。 +3. 重试窗口失真。 + +回退: + +1. 先保持每服务单 relay。 +2. 真要横向扩展时,再补 claim / lease。 + +### 风险 3:切服务过快 + +表现: + +1. 业务边界还没稳,服务已经拆散。 +2. 请求链路变长,但收益没起来。 +3. 调试成本先于收益暴涨。 + +回退: + +1. 保留当前单体实现。 +2. 只做接口和契约前置拆分。 +3. 不提前拆 schedule/task。