Skip to content

Commit 4710127

Browse files
committed
feat: 实现果实模块后端核心功能
- 新增 Fruit 领域模型,支持容器化设计(固定系统字段 + 自由 payload) - 新增 FruitGrowthData 独立实体,用于存储发布后的平台数据指标 - 新增平台指标字典静态常量,通过 API 下发给前端 - 新增 FruitRepository 和 FruitGrowthRepository 接口及 Redis 实现 - 新增 FruitService 业务层,封装状态机流转与 fruitCount 原子维护 - 新增 8 个 HTTP RESTful 接口(查询、选品、发布、堆肥、修剪、指标读写、字典) - 新增 5 个 MCP Tools(批量存果实、获取上下文、更新状态、快捷选品、堆肥) - 注册新路由和错误类型到 server.ts
1 parent 687f6f3 commit 4710127

9 files changed

Lines changed: 690 additions & 196 deletions

File tree

doc/内容森林果实模块设计.md

Lines changed: 274 additions & 196 deletions
Large diffs are not rendered by default.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-03-19
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
## Context
2+
3+
内容森林后端已完成种子(Seed)和生成器(Generator)两个核心模块,均采用 **Domain → Repository → Service → API / MCP** 四层架构,Redis 作为主存储,`X-User-Id` 请求头实现用户隔离。果实(Fruit)是整条内容生产链路的产出物,是连接生成器执行结果与用户选品决策的核心实体,目前完全缺失。
4+
5+
现有约束:
6+
- 技术栈:TypeScript + Hono + Redis(ioredis)+ MCP SDK
7+
- 用户隔离:所有 Redis Key 必须包含 `userId` 维度
8+
- MCP 工具统一注册在 `src/mcp/server.ts`
9+
- HTTP 路由统一挂载在 `src/api/server.ts`
10+
11+
## Goals / Non-Goals
12+
13+
**Goals:**
14+
- 实现果实的完整生命周期(创建、状态流转、修剪、查询)
15+
- 实现果实成长数据(Metrics)的独立存储与读写
16+
- 暴露 5 个 MCP Tools 供 Agent 驱动生成流程
17+
- 暴露 8 个 HTTP 接口供 Web UI 操作
18+
- 写入果实时原子维护种子的 `fruitCount` 计数
19+
- 严格遵循现有分层架构,新模块可零摩擦并入
20+
21+
**Non-Goals:**
22+
- 营养库(Nutrients)实装:本期只在 `context.nutrientsUsed` 预留字段
23+
- Markdown 文件备份:本期只写 Redis,文件同步为二期
24+
- 多父节点 DAG(嫁接重组):本期使用单父 `parentFruitId?: string`
25+
- 支付 / 权限体系:MVP 单用户本地环境
26+
- 生成日志(GenerationLog)写入:由 Agent 单独调用现有 `write_generation_log` MCP Tool
27+
28+
## Decisions
29+
30+
### Decision 1: 单父节点(parentFruitId)而非多父 DAG
31+
32+
**选择**`parentFruitId?: string`(单父,简单树)
33+
34+
**理由**:MVP 阶段进化树的使用场景是线性迭代(A → A1 → A2),多父 DAG(嫁接重组)是高级功能。单父实现简单,Redis ZSet 排序、前端 Vue Flow 渲染均无复杂度,且可无破坏性升级为 `parentFruitIds: string[]`
35+
36+
**备选**:直接使用 `parentFruitIds: string[]`。被否决原因:前端 DAG 布局算法复杂,MVP 阶段无对应用例。
37+
38+
---
39+
40+
### Decision 2: FruitGrowthData 独立 Redis Hash
41+
42+
**选择**:成长数据存独立 Key `cf:u:{userId}:fruit:{fruitId}:growth`,不嵌入 Fruit Hash。
43+
44+
**理由**:监控数据更新频率远高于果实主体(发布后可能每天更新),分离存储避免大 Hash 频繁全量读写。符合单一职责原则,未来监控器模块可独立操作 growth Key 无需感知 Fruit 主体。
45+
46+
**备选**:将 metrics 作为 JSON 字符串嵌入 Fruit Hash 的一个字段。被否决原因:破坏领域边界,监控器需要解析 Fruit Hash 才能写入数据。
47+
48+
---
49+
50+
### Decision 3: 平台指标字典为静态 TS 常量
51+
52+
**选择**`src/domain/platform-metrics-dict.ts` 导出静态常量,不写 Redis。
53+
54+
**理由**:字典是配置数据,不随业务状态变化,无需持久化。静态常量零运行时开销,类型安全,修改只需改代码而非操作数据库。
55+
56+
**备选**:存 Redis Hash,支持运行时热更新。被否决原因:MVP 阶段字典变更频率极低,引入运行时可变配置增加复杂度无收益。
57+
58+
---
59+
60+
### Decision 4: payload 为纯 Markdown 字符串
61+
62+
**选择**`payload: string`,存储 Agent 生成的完整 Markdown 内容,系统不解析、不校验其结构。
63+
64+
**理由**:生成器面向不同平台输出不同格式的内容(小红书、推特、视频脚本等),容器化 `Record<string, any>` 需要系统预知所有字段结构,与「平台解耦」设计理念冲突。纯 Markdown 方案让生成器完全自由,前端统一使用 Markdown 渲染组件,修剪操作简化为直接编辑字符串,无需理解字段结构。`preview` 字段由 Agent 单独填写,作为卡片渲染的稳定数据源,与 payload 解耦。
65+
66+
**备选**:容器化 `Record<string, any>`,系统定义各平台字段 schema。被否决原因:每新增平台需修改系统 schema,违反开放封闭原则;Agent 编写生成器时需了解系统字段规范,门槛高;前端渲染需针对每种 payload 结构写适配逻辑。
67+
68+
---
69+
70+
### Decision 4: save_fruits 支持批量写入
71+
72+
**选择**`save_fruits` MCP Tool 接受 `fruits: FruitInput[]` 数组,单次调用持久化多个果实。
73+
74+
**理由**:Agent 生成一次通常产出 3-5 个变体,逐条调用会产生多次 MCP 往返延迟。批量写入后 `fruitCount` 只需更新一次,减少 Redis 写操作。
75+
76+
**备选**:单果实写入,Agent 循环调用。被否决原因:效率低,且 fruitCount 需要多次原子递增,容易出现竞态。
77+
78+
---
79+
80+
### Decision 5: fruitCount 在 FruitService 内部原子维护
81+
82+
**选择**`FruitService.saveFruits()` 在写入果实后,直接调用 `SeedRepository.update()` 递增 `fruitCount`,调用方无需感知此逻辑。
83+
84+
**理由**:fruitCount 是种子的冗余计数字段,维护责任应由触发方(FruitService)承担,避免调用方(MCP Handler)遗漏更新。封装在 Service 层符合「业务逻辑不外漏」原则。
85+
86+
**备选**:由 MCP Handler 在调用 `save_fruits` 后手动调用 seed 更新。被否决原因:跨 Service 的副作用由 Handler 管理违反分层原则,且容易遗漏。
87+
88+
---
89+
90+
### Decision 6: 状态机显式定义 VALID_TRANSITIONS(对齐 Seed 模块模式)
91+
92+
**选择**:在 `src/domain/fruit.ts` 定义 `VALID_FRUIT_TRANSITIONS` 常量 Map,`FruitService` 调用 `isValidFruitTransition()` 校验,抛出 `InvalidFruitTransitionError`,在 `server.ts` 映射为 HTTP 400。
93+
94+
**理由**:与 Seed 模块完全一致的模式,降低认知负担。状态机规则集中在 domain 层,Service 和 API 层均复用同一校验逻辑。
95+
96+
## Risks / Trade-offs
97+
98+
- **[风险] fruitCount 与实际果实数不一致** → 缓解:`saveFruits` 使用 Redis `HINCRBY` 原子操作;如发现不一致可通过 ZSet `ZCARD` 修复计数。
99+
- **[风险] Fruit Hash 字段过多导致序列化开销** → 缓解:`payload` 存为 JSON 字符串字段,列表接口可选择性返回 `preview`,不强制返回完整 `payload`
100+
- **[Trade-off] MVP 不做 Markdown 备份** → 接受风险:Redis 单点故障会丢失数据。缓解方案:二期补充 file-storage 同步层,当前使用本地 Redis 风险可控。
101+
- **[风险] MCP Tool 的 userId 来自 getCurrentUser()** → 与现有 Seed/Generator 模块一致,无额外风险。
102+
103+
## Migration Plan
104+
105+
1. 新增 `src/domain/fruit.ts``src/domain/platform-metrics-dict.ts`
106+
2. 新增 repository 接口与 Redis 实现
107+
3. 新增 `src/services/fruit-service.ts`
108+
4. 新增 `src/api/fruits.ts` 路由文件
109+
5. 新增 `src/mcp/fruit-tools.ts` MCP 工具文件
110+
6. 修改 `src/api/server.ts`:挂载 fruitRoutes,注册 `FruitNotFoundError``InvalidFruitTransitionError` 错误映射
111+
7. 修改 `src/mcp/server.ts`:注册 fruit MCP tools
112+
113+
**回滚**:所有新增文件独立,server.ts 的挂载行为可注释回滚,不影响现有 Seed / Generator 功能。
114+
115+
## Open Questions
116+
117+
- 营养库(Nutrients)模块何时启动提案?`context.nutrientsUsed` 字段需要对应的营养库实体才能产生业务价值。
118+
- 未来是否需要「果实列表」的分页支持?当前 `GET /api/seeds/:seedId/fruits` 返回全量,种子果实数量极大时需要分页。
119+
- Markdown 备份的触发时机:写时同步(同一事务)还是异步队列?影响 `file-storage.ts` 的扩展方式。
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
## Why
2+
3+
果实(Fruit)是内容森林的核心产出物,现有后端已实现种子(Seed)和生成器(Generator)模块,但缺少果实的持久化、状态管理和成长数据能力,导致 Agent 生成的内容无处落库,整条「种子 → 生成 → 选品 → 发布」链路无法跑通。
4+
5+
## What Changes
6+
7+
- 新增 `Fruit` 领域模型,支持容器化设计(固定系统字段 + 自由 payload)
8+
- 新增 `FruitGrowthData` 独立实体,承载发布后的平台数据指标(读时模式)
9+
- 新增平台指标字典静态常量,通过 API 下发给前端
10+
- 新增 `FruitRepository` + `FruitGrowthRepository` 接口及 Redis 实现
11+
- 新增 `FruitService` 业务层,封装状态机流转与 `fruitCount` 原子维护
12+
- 新增 8 个 HTTP RESTful 接口(查询、选品、发布、堆肥、修剪、指标读写、字典)
13+
- 新增 5 个 MCP Tools(批量存果实、获取上下文、更新状态、快捷选品、堆肥)
14+
- 注册新路由和错误类型到 `server.ts`
15+
16+
## Capabilities
17+
18+
### New Capabilities
19+
20+
- `fruit-lifecycle`: 果实的创建、状态机流转(generated → picked → published / rejected)与持久化存储
21+
- `fruit-growth-data`: 果实成长数据(Metrics)的独立存储、读取与更新,支持读时模式的自描述指标
22+
- `fruit-mcp-tools`: Agent 通过 MCP 协议批量写入果实、获取上下文、更新状态、执行选品与堆肥
23+
- `platform-metrics-dict`: 平台指标字典静态常量及 HTTP 下发接口
24+
25+
### Modified Capabilities
26+
27+
- `seed`: 种子实体已有 `fruitCount` 字段,果实模块写入时需原子更新此计数(行为变更,非破坏性)
28+
29+
## Impact
30+
31+
- **新增文件**`src/domain/fruit.ts``src/domain/platform-metrics-dict.ts``src/repositories/fruit-repository.ts``src/repositories/fruit-growth-repository.ts``src/repositories/redis-fruit-repository.ts``src/repositories/redis-fruit-growth-repository.ts``src/services/fruit-service.ts``src/api/fruits.ts``src/mcp/fruit-tools.ts`
32+
- **修改文件**`src/api/server.ts`(挂载新路由和错误类型)、`src/mcp/server.ts`(注册 MCP fruit tools)
33+
- **Redis Key 新增**`cf:u:{userId}:fruit:{fruitId}`(Hash)、`cf:u:{userId}:seed:{seedId}:fruits`(ZSet)、`cf:u:{userId}:fruit:{fruitId}:growth`(Hash)
34+
- **依赖**:无新增 npm 依赖,复用现有 Redis client、Hono、Zod、nanoid
35+
- **不含**:营养库(Nutrients)实装、Markdown 文件备份、多父节点 DAG(均为二期)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Growth data independent storage
4+
The system SHALL store `FruitGrowthData` as a separate Redis Hash under key `cf:u:{userId}:fruit:{fruitId}:growth`, decoupled from the Fruit entity, so that the monitoring domain can operate independently.
5+
6+
#### Scenario: Get growth data when none exists
7+
- **WHEN** client sends `GET /api/fruits/:fruitId/metrics` and no growth data has been recorded
8+
- **THEN** system returns `{ code: 0, data: { fruitId, platform, collectedAt: null, metrics: [] } }`
9+
10+
#### Scenario: Get growth data when data exists
11+
- **WHEN** client sends `GET /api/fruits/:fruitId/metrics` and growth data exists
12+
- **THEN** system returns the stored `FruitGrowthData` including fruitId, platform, collectedAt, and metrics array
13+
14+
### Requirement: Growth data write and overwrite
15+
The system SHALL allow clients to submit a complete `MetricsField[]` array that fully replaces the stored metrics for a fruit. Each `MetricsField` SHALL contain key, value, label, and description.
16+
17+
#### Scenario: Update growth data
18+
- **WHEN** client sends `PUT /api/fruits/:fruitId/metrics` with a valid metrics array
19+
- **THEN** system overwrites the stored metrics and updates `collectedAt` to the current timestamp
20+
- **AND** returns `{ code: 0, data: { success: true } }`
21+
22+
#### Scenario: Invalid metrics payload rejected
23+
- **WHEN** client sends `PUT /api/fruits/:fruitId/metrics` with a metrics entry missing required fields (key, value, label, description)
24+
- **THEN** system returns HTTP 400 with a validation error message
25+
26+
### Requirement: Schema-on-read metrics model
27+
The system SHALL NOT validate or restrict the `key` values within `MetricsField`. Any string key SHALL be accepted, enabling platform-specific and custom metrics without schema changes.
28+
29+
#### Scenario: Custom metric key accepted
30+
- **WHEN** client submits a MetricsField with key `retention_rate_7d` (not in any predefined list)
31+
- **THEN** system stores and returns it without error
32+
33+
#### Scenario: Standard platform metric stored
34+
- **WHEN** client submits a MetricsField with key `views`, value `12000`, label `浏览量`, description `内容曝光次数`
35+
- **THEN** system stores the full self-describing field and returns it verbatim on GET
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Fruit entity storage
4+
The system SHALL persist Fruit entities to Redis using the key pattern `cf:u:{userId}:fruit:{fruitId}` (Hash) and maintain a sorted index `cf:u:{userId}:seed:{seedId}:fruits` (ZSet, score=createdAt) for efficient per-seed queries.
5+
6+
#### Scenario: Save a new fruit
7+
- **WHEN** Agent calls `save_fruits` MCP tool with a valid seedId and fruit payload
8+
- **THEN** system stores each fruit as a Redis Hash under the user-scoped key
9+
- **AND** system adds the fruitId to the seed's ZSet index with createdAt as score
10+
- **AND** system atomically increments the seed's `fruitCount` field
11+
12+
#### Scenario: Query fruits by seed
13+
- **WHEN** client sends `GET /api/seeds/:seedId/fruits` with a valid X-User-Id header
14+
- **THEN** system returns all fruits belonging to that seed, ordered by createdAt descending
15+
- **AND** response contains id, seedId, parentFruitId, generation, status, preview, payload, context, mutation fields
16+
17+
### Requirement: Fruit state machine
18+
The system SHALL enforce the following state transitions and reject any invalid transition with HTTP 400 / MCP error:
19+
20+
```
21+
generated → picked (user picks fruit)
22+
generated → rejected (user composts fruit)
23+
picked → published (user publishes, terminal state)
24+
picked → generated (user revokes pick)
25+
rejected → generated (user revokes compost)
26+
published → [no transitions allowed]
27+
```
28+
29+
#### Scenario: Valid state transition via HTTP
30+
- **WHEN** client sends `PATCH /api/fruits/:fruitId/pickup` for a fruit with status `generated`
31+
- **THEN** system updates the fruit status to `picked` and returns `{ code: 0, data: { id, status: "picked" } }`
32+
33+
#### Scenario: Invalid state transition rejected
34+
- **WHEN** client sends `PATCH /api/fruits/:fruitId/pickup` for a fruit with status `published`
35+
- **THEN** system returns HTTP 400 with a descriptive error message
36+
37+
#### Scenario: Publish fruit (terminal state)
38+
- **WHEN** client sends `PATCH /api/fruits/:fruitId/publish` for a fruit with status `picked`
39+
- **THEN** system updates status to `published` and returns the updated fruit
40+
- **AND** no further status transitions are permitted for this fruit
41+
42+
#### Scenario: Compost fruit
43+
- **WHEN** client sends `PATCH /api/fruits/:fruitId/compost` for a fruit with status `generated`
44+
- **THEN** system updates status to `rejected` and returns `{ code: 0, data: { id, status: "rejected" } }`
45+
46+
#### Scenario: Revoke compost
47+
- **WHEN** client sends a status update to `generated` for a fruit with status `rejected`
48+
- **THEN** system accepts the transition and updates the status
49+
50+
### Requirement: In-place fruit pruning
51+
The system SHALL allow users to directly edit the `payload` (Markdown string) and optionally `preview` fields of any fruit without creating a new iteration branch. The `payload` field is a plain Markdown string; the system SHALL store and return it verbatim without parsing or validating its structure.
52+
53+
#### Scenario: Prune fruit Markdown content
54+
- **WHEN** client sends `PUT /api/fruits/:fruitId/payload` with an updated `payload` (Markdown string) and/or `preview` body
55+
- **THEN** system overwrites the stored payload string and/or preview fields and updates `updatedAt`
56+
- **AND** fruit status, generation, parentFruitId, and context remain unchanged
57+
58+
#### Scenario: Payload stored and returned verbatim
59+
- **WHEN** Agent saves a fruit with a Markdown payload containing platform-specific formatting
60+
- **THEN** system stores the string as-is and returns it unchanged on subsequent reads
61+
- **AND** system does NOT attempt to parse, validate, or transform the Markdown content
62+
63+
### Requirement: User data isolation
64+
The system SHALL enforce that all fruit operations are scoped to the authenticated user via the `X-User-Id` header, and SHALL reject requests that attempt to access fruits belonging to another user.
65+
66+
#### Scenario: User can only access own fruits
67+
- **WHEN** client sends any fruit API request with X-User-Id header
68+
- **THEN** system only reads/writes Redis keys scoped to that userId
69+
- **AND** if the fruitId does not exist under that userId, system returns HTTP 404
70+
71+
#### Scenario: Missing user header rejected
72+
- **WHEN** client sends a fruit API request without X-User-Id header
73+
- **THEN** system returns HTTP 401 with message "X-User-Id header is required"
74+
75+
### Requirement: Fruit ID format
76+
The system SHALL generate fruit IDs in the format `fruit_{YYYYMMDD}_{nanoid(8)}` to ensure global uniqueness and time-sortability.
77+
78+
#### Scenario: New fruit ID generation
79+
- **WHEN** a new fruit is created via `save_fruits` MCP tool
80+
- **THEN** each fruit receives a unique ID matching the pattern `fruit_YYYYMMDD_XXXXXXXX`

0 commit comments

Comments
 (0)