chore: 清理opencode技能文件、旧设计文档并更新配置文档

This commit is contained in:
qzl
2026-03-03 17:29:01 +08:00
parent 30a4a1af5d
commit a4f684466c
48 changed files with 134 additions and 72641 deletions
@@ -1,651 +0,0 @@
# Plan: social-app 数据库数据模型重设计(支持社交/事项/自动化)
**Date:** 2026-02-26
**Author:** AI Assistant
**Status:** Draft
## 枚举存储约定
**统一使用枚举名称(字符串)存储,不使用整数值。**
- 数据库层:`VARCHAR(20)` + `CHECK` 约束
- 代码层:Python `Enum` 类继承 `str`
- 优势:调试可读、易扩展(新增枚举值无需迁移旧数据)、ORM 友好
```python
class AgentType(str, Enum):
INTENT_RECOGNITION = "INTENT_RECOGNITION"
TASK_EXECUTION = "TASK_EXECUTION"
RESULT_REPORTING = "RESULT_REPORTING"
```
```sql
-- Migration
ALTER TABLE user_agents ADD CONSTRAINT chk_agent_type
CHECK (agent_type IN ('INTENT_RECOGNITION', 'TASK_EXECUTION', 'RESULT_REPORTING'));
```
## Overview
本方案面向 `social-app` 的下一阶段功能升级,重设计 PostgreSQL 数据模型,统一支持用户专属 agent、好友/群组协作、待处理消息、设置、可订阅且可授权编辑的日程事项、待办联动与自动化定时任务。目标是在 FastAPI + Flutter 协作场景下提供长期稳定的数据基础,降低后续 API 演进和跨端同步复杂度。
## Requirements
### Functional
- [x] 每个用户有专属 agent,且模型可扩展到未来多 agent 能力
- [x] 用户支持好友关系、群组创建与成员管理
- [x] 用户支持 inbox/pending 待处理消息
- [x] 用户支持个性化设置(偏好/隐私/通知)
- [x] 用户支持“绑定日程的事项”,可多人订阅,且仅特定人可修改
- [x] 用户支持待办事项(可由日程事项提取,也可手动创建)
- [x] 用户支持自动化定时任务(循环触发)
### Non-Functional
- [x] 性能:核心读路径(inbox 列表、待办列表、事项列表)P95 < 150ms(单用户典型数据量)
- [x] 安全:权限以后端业务授权为准;数据库层保留 RLS 防御边界
- [x] 一致性:关键写路径(好友状态、权限变更、任务触发)使用事务保障
- [x] 可演进:当前阶段采用重建库快速迭代;后续稳定后切换为增量迁移与灰度
## Technical Approach
采用“认证域(`auth.users`+ 业务域(`public.*`)”分层建模。保持 `auth.users` 作为身份主键来源,业务表统一引用 `user_id UUID -> auth.users.id`。领域边界拆分为:Identity/Profile、Social Graph、Collaboration(事项/订阅/权限)、Inbox、Todo、Automation。通过“规范化主模型 + 局部物化/冗余快照”平衡一致性与查询性能。
### Key Decisions
| Decision | Rationale |
|----------|-----------|
| 用户与 agent 采用 1:1 主约束 + 可扩展结构 | 当前满足"每用户专属 agent",未来允许多 agent 形态演进 |
| 记忆系统采用单表 + memory_type 区分 | user 类型可选 agent_idwork 类型必须绑定 agent_id |
| 好友关系用单表双向规范化表示 | 避免 A-B / B-A 重复,降低去重成本 |
| 事项权限采用 ACL 表而非仅 owner | 满足“仅特定人可修改”的协作场景 |
| 待办采用主表 + 关联表 | `todos` + `todo_sources` 保证来源关系可校验 |
| 自动化采用 Jobs 单表 + Sessions 关联 | `sessions` 通过 `session_type + job_id` 区分普通对话与自动化运行 |
| inbox 采用单表接收者视角 | 发送者 + 消息类型 + 关联业务,一表搞定待处理消息 |
## A. 设计原则与边界
### 1) 核心实体与聚合边界
- 用户聚合:`profiles`(含 settings JSONB, `user_agents`, `memories`
- 社交聚合:`friendships`, `groups`, `group_members`
- 协作事项聚合:`schedule_items`, `schedule_subscriptions`(当前仅用户主体)
- 消息聚合:`inbox_messages`
- 待办聚合:`todos`
- 自动化聚合:`automation_jobs`
### 2) 一致性分级
- 强一致(同事务):好友关系状态迁移、群组成员角色变更、事项权限写入、定时任务抢占执行
- 最终一致:inbox 衍生、待办同步、提醒派发(允许异步补偿)
### 3) 多租户假设
- 默认假设:单租户产品(同一业务库服务所有用户),以 `user_id` 做数据隔离
- 扩展预留:各核心表可预留 `tenant_id UUID NULL`(需业务确认是否近期引入组织空间)
## B. 领域模型与关系图(文字化)
### 实体与关系
- `auth.users (1) - (1) profiles`settings 作为 JSONB 内嵌)
- `auth.users (1) - (1) user_agents`
- `auth.users (1) - (N) memories`
- `user_agents (1) - (N) memories`work 类型)
- `auth.users (N) - (N) auth.users` 通过 `friendships`
- `auth.users (1) - (N) groups`(创建者)
- `groups (1) - (N) group_members``auth.users (1) - (N) group_members`
- `auth.users (1) - (N) schedule_items`(创建者)
- `schedule_items (1) - (N) schedule_subscriptions``auth.users (1) - (N) schedule_subscriptions`
- `auth.users (1) - (N) inbox_messages`
- `auth.users (1) - (N) todos`
- `auth.users (1) - (N) automation_jobs`
- `automation_jobs (1) - (N) sessions`(通过 `sessions.job_id` 关联)
### 关键约束
- 唯一性:
- `user_agents.user_id` 唯一
- `friendships(user_low_id, user_high_id)` 唯一
- `group_members(group_id, user_id)` 唯一
- `schedule_subscriptions(item_id, subscriber_id)` 唯一
- CHECK
- `friendships`: `user_low_id < user_high_id``user_low_id <> user_high_id`
- `schedule_subscriptions`: `permission BETWEEN 0 AND 7`
- `memories`: `work` 类型必须有 `agent_id``user` 类型必须无 `agent_id`
- `sessions`: `session_type/job_id` 组合一致
- 外键:统一显式 `ON DELETE` 策略(见下)
- 可空性:权限关键字段、状态字段默认 `NOT NULL`
- 删除策略:
- 用户删除:大部分 `CASCADE`(用户私有数据);跨用户协作数据优先软删
- 事项删除:对子表 `CASCADE`;待办保留历史,改 `status = 'archived'`
### 外键删除策略明细(必做)
- `sessions.job_id -> automation_jobs.id`: `ON DELETE RESTRICT`
- `todo_sources.todo_id -> todos.id`: `ON DELETE CASCADE`
- `todo_sources.schedule_item_id -> schedule_items.id`: `ON DELETE CASCADE`
- `inbox_messages.friendship_id -> friendships.id`: `ON DELETE CASCADE`
- `inbox_messages.schedule_item_id -> schedule_items.id`: `ON DELETE CASCADE`
- `inbox_messages.group_id -> groups.id`: `ON DELETE CASCADE`
## C. 数据库表设计(PostgreSQL
以下为推荐主表(方案 1,规范化优先)。字段示例采用 `UUID + timestamptz + enum/text-check`
### 1) 用户与 agent
#### `profiles`(已有,建议补齐)
- PK: `id UUID` (`auth.users.id`)
- 关键字段: `username`, `avatar_url`, `bio`
- **新增 JSONB 字段**:
- `settings JSONB`(用户自定义设置,含 `version`, `preferences`, `privacy`, `notification` 四大块)
- 时间字段: `created_at`, `updated_at`, `deleted_at`
- 索引:
- `INDEX(username)`(允许重名,仅用于列表查询)
- `GIN(settings)`(支持 JSONB 表达式查询)
- 表达式索引:`(settings->'notification'->>'enabled')`(按需,对高频查询字段单独建)
- 审计: `created_by`, `updated_by`(可等于 id
- 删除策略: 用户删除时 `CASCADE`
#### `user_agents`
- PK: `id UUID`
- 关键字段:
- `user_id UNIQUE`(每用户专属 agent
- `llm_id UUID NOT NULL`(关联绑定的 LLM 模型)
- `agent_type VARCHAR(20) NOT NULL`(枚举限制:`INTENT_RECOGNITION` | `TASK_EXECUTION` | `RESULT_REPORTING`
- `config JSONB`agent 配置参数)
- 时间字段: `created_at`, `updated_at`, `deleted_at`
- 状态字段: `status``active|paused|migrating`
- 索引:
- `UNIQUE(user_id) WHERE deleted_at IS NULL`
- `INDEX(status)`
- `INDEX(agent_type)`
- `GIN(config)`(按需)
- 审计: `created_by`, `updated_by`
#### `memories`
- PK: `id UUID`
- 关键字段:
- `owner_id`(用户,NOT NULL
- `agent_id`work 类型时必需)
- `memory_type`(枚举:`user | work`
- `title`
- `content`JSONB,存储具体记忆结构)
- `source``manual | agent | imported`
- 时间字段: `created_at`, `updated_at`
- 状态字段: `status``active | disabled`
- 索引:
- `INDEX(owner_id, memory_type, status)`
- `INDEX(agent_id, memory_type, status)`
- `GIN(content)`(支持 JSONB 内容查询)
- 约束: `CHECK ((memory_type = 'work' AND agent_id IS NOT NULL) OR (memory_type = 'user' AND agent_id IS NULL))`
**memory_type 说明**
| 类型 | agent_id | 说明 |
|------|----------|------|
| `user` | 可空 | 用户记忆:偏好、背景信息、实体等 |
| `work` | 必需 | 工作记忆:长期运行后对工作流程的经验整理,避免重复错误 |
**content JSONB 示例**
```json
// 用户记忆
{"type": "preference", "data": {"style": "concise", "language": "zh-CN"}}
// 工作记忆
{"type": "workflow_summary", "data": {"task": "代码审查", "learnings": ["优先检查安全漏洞", "关注性能热点"], "improvements": []}}
```
### 2) 社交关系
#### `friendships`
- PK: `id UUID`
- 关键字段:
- `user_low_id`(两者中较小的 UUID
- `user_high_id`(两者中较大的 UUID
- `initiator_id`(发起请求方的 user_id,用于追溯谁主动)
- `status`, `requested_at`, `accepted_at`, `blocked_by`
- 时间字段: `created_at`, `updated_at`
- 状态字段: `status``pending|accepted|blocked|declined|canceled`
- 约束:
- `CHECK(user_low_id < user_high_id)`(强制小值放 low,大值放 high,确保 A→B 和 B→A 是同一行)
- `CHECK(initiator_id IN (user_low_id, user_high_id))`
- `UNIQUE(user_low_id, user_high_id)`
- 索引:
- `INDEX(user_low_id, status)`
- `INDEX(user_high_id, status)`
- 部分索引 `INDEX(status) WHERE status='pending'`
- 审计: `created_by`, `updated_by`
**查询示例**
- 查询用户 A 的所有好友:`SELECT * FROM friendships WHERE user_low_id = A OR user_high_id = A`
#### `groups`
- PK: `id UUID`
- 关键字段: `name`, `description`, `owner_id`
- 时间字段: `created_at`, `updated_at`, `deleted_at`
- 状态字段: `status``active|archived`
- 索引: `INDEX(owner_id, status)`
- 审计: `created_by`, `updated_by`
#### `group_members`
- PK: `id UUID`
- 关键字段:
- `group_id`, `user_id`
- `role`(枚举:`owner` | `admin` | `member`
- `join_source``invited|joined`
- `invited_by`, `joined_at`
- 时间字段: `created_at`, `updated_at`, `removed_at`
- 状态字段: `status``active|muted|removed`
- 约束: `UNIQUE(group_id, user_id)`
- 索引:
- `INDEX(group_id, role, status)`
- `INDEX(user_id, status)`
- 审计: `created_by`, `updated_by`
**role 说明**
| role | 含义 |
|------|------|
| `owner` | 群主/创建者 |
| `admin` | 管理员 |
| `member` | 普通成员 |
- 角色可升降:服务层变更 role 字段即可
### 3) 用户设置(已合并至 profiles 表)
用户设置采用 JSONB 内嵌方式,渐进式扩展无需改表结构:
```json
{
"version": 1,
"preferences": {
"interface_language": "zh-CN",
"ai_language": "zh-CN",
"timezone": "Asia/Shanghai"
},
"privacy": {},
"notification": {}
}
```
- 索引策略:对高频查询字段使用表达式索引
- 更新方式:服务层使用 JSONB merge 或字段级 UPDATE,避免读-改-写并发问题(建议用 `jsonb_set` 原子操作)
### 4) 事项与订阅/权限
#### `schedule_items`
- PK: `id UUID`
- 关键字段:
- `owner_id`
- `title`
- `description`
- `start_at`
- `end_at`
- `timezone`(用于将日程时间转换为用户本地时间显示)
- `metadata`JSONB,扩展字段)
- `recurrence_rule`(可选,支持循环日程)
- `source_type``manual | imported | agent_generated`
- 时间字段: `created_at`, `updated_at`
- 状态字段: `status``active | completed | canceled | archived`
- 索引:
- `INDEX(owner_id, start_at)`
- `INDEX(status, start_at)`
- 审计: `created_by`
**metadata JSONB 示例**
```json
{
"color": "#FF6B6B",
"location": "会议室A",
"notes": "记得提前准备投影仪",
"attachments": [
{
"name": "会议纪要.pdf",
"url": "https://...",
"visible_to": [],
"type": "document"
},
{
"name": "投影仪提醒",
"visible_to": ["uuid1"],
"type": "reminder",
"content": "记得带投影仪"
},
{
"name": "技术方案.docx",
"url": "https://...",
"visible_to": ["uuid2"],
"type": "document",
"note": "需要他确认预算"
}
],
"version": 1
}
```
| type | 说明 | 特殊字段 |
|------|------|----------|
| document | 文档/文件 | url, note |
| reminder | 提醒 | content |
#### `schedule_subscriptions`
- PK: `id UUID`
- 关键字段:
- `item_id`
- `subscriber_id`
- `permission`(INTEGER,用位运算存储权限组合,`NOT NULL DEFAULT 1`
- `notify_level``all | mentions | none``NOT NULL DEFAULT 'all'`
- 时间字段: `created_at`
- 状态字段: `status``active | paused | unsubscribed``NOT NULL DEFAULT 'active'`
- 约束: `UNIQUE(item_id, subscriber_id)`
- 约束补充: `CHECK(permission BETWEEN 0 AND 7)``view=1, invite=2, edit=4``0` 表示无权限)
- 索引: `INDEX(subscriber_id, status)`, `INDEX(item_id, status)`
- 审计: `created_by`
**权柄说明(位运算)**
| 权柄 | 值 | 二进制 | 说明 |
|------|-----|--------|------|
| view | 1 | 001 | 查看事项详情 |
| invite | 2 | 010 | 邀请其他人订阅此事项 |
| edit | 4 | 100 | 修改事项内容、管理订阅 |
- 权限检查:`permission & 2 = 2` 检查是否有 invite 权限
- 权限添加:`permission | 2` 添加 invite 权限
- 事项 owner 默认拥有全部权柄:`7`111
- owner 权柄由服务层恒等判定为 `7`,不依赖 owner 是否在 `schedule_subscriptions` 中存在记录
**当前版本边界**
- `schedule_subscriptions` 仅支持用户订阅(`subscriber_id -> auth.users.id`
- 事项协作暂不引入群主体授权
### 5) 待处理消息(Inbox
#### `inbox_messages`
- PK: `id UUID`
- 关键字段:
- `recipient_id`(接收者)
- `sender_id`(发送者,系统消息可为 NULL
- `message_type`(枚举:`friend_request | calendar | system | group`
- `friendship_id`(可空,`friend_request` 时必填)
- `schedule_item_id`(可空,`calendar` 时必填)
- `group_id`(可空,`group` 时必填)
- `content`TEXT,消息内容,系统消息用)
- 时间字段: `created_at`
- 状态字段:
- `is_read`BOOLEAN,是否已读)
- `status``pending | accepted | rejected | dismissed`
- 索引:
- `INDEX(recipient_id, status, created_at DESC)`
- 部分索引 `INDEX(recipient_id, created_at DESC) WHERE status='pending'`
- 审计: `created_by`
**message_type 与业务字段对应关系**
| message_type | 对应业务字段 |
|--------------|-----------------|
| friend_request | friendship_id -> friendships.id |
| calendar | schedule_item_id -> schedule_items.id |
| system | 三个业务字段均为 NULL(内容直接在 content |
| group | group_id -> groups.id |
**说明**:一张表搞定,接收者视角,通过 `message_type + 对应业务字段` 直接定位要处理的业务,避免单列多态外键带来的引用不一致问题。
**一致性约束(必做)**
- 使用 `CHECK` 保证不同 `message_type` 下仅允许对应业务字段非空(`system` 时业务字段全空)
- 使用 `CHECK` 保证 `message_type='system'``sender_id IS NULL`,否则 `sender_id IS NOT NULL`
- `friendship_id``schedule_item_id``group_id` 分别建立 FK,并显式声明 `ON DELETE` 策略
### 6) 待办
#### `todos`
- PK: `id UUID`
- 关键字段:
- `owner_id`
- `title`
- `description`
- `due_at`
- `priority`(INTEGER,用于四象限:1=重要且紧急, 2=重要不紧急, 3=紧急不重要, 4=不重要不紧急)
- 时间字段: `created_at`, `completed_at`
- 状态字段: `status``pending | done | canceled`
- 索引:
- `INDEX(owner_id, status, due_at)`
- `INDEX(owner_id, created_at DESC)`
- 部分索引 `INDEX(owner_id, due_at) WHERE status='pending'`
- 审计: `created_by`
#### `todo_sources`
- PK: `id UUID`
- 关键字段:
- `todo_id`FK -> todos.id
- `schedule_item_id`FK -> schedule_items.id
- 时间字段: `created_at`
- 约束: `UNIQUE(todo_id, schedule_item_id)`
- 索引: `INDEX(todo_id)`, `INDEX(schedule_item_id)`
**说明**
- 手动创建待办:不写 `todo_sources`
- 从事项提取待办:写入 `todo_sources`,替代 JSONB 数组,保证来源关系可校验
### 7) 自动化定时任务
#### `automation_jobs`
- PK: `id UUID`
- 关键字段:
- `owner_id`
- `title`(任务标题)
- `prompt`AI 执行的 prompt
- `schedule_type`(枚举:`daily | weekly`
- `run_at`(首次运行时间)
- `next_run_at`(下次运行时间,调度器扫描主字段)
- `timezone`(时区,如 `Asia/Shanghai`
- `last_run_at`(最近运行时间,可空)
- 时间字段: `created_at`, `updated_at`
- 状态字段: `status``active | disabled`
- 索引: `INDEX(owner_id, status)`, `INDEX(status, next_run_at)`
- 约束补充: `UNIQUE(id, owner_id)`(用于 `sessions(job_id, user_id)` 归属一致性外键)
- 审计: `created_by`
**说明**:定时任务执行时,在 sessions 表创建记录存储 AI 对话内容。
### 8) 会话表扩展(已有 sessions
#### `sessions`(更新)
- 新增字段:
- `session_type``chat | automation`
- `job_id`(可空,FK -> automation_jobs.id
- 一致性约束:
- `CHECK((session_type = 'chat' AND job_id IS NULL) OR (session_type = 'automation' AND job_id IS NOT NULL))`
- 通过复合 FK 约束归属一致性:`FOREIGN KEY(job_id, user_id) -> automation_jobs(id, owner_id)`
- 索引:
- `INDEX(user_id, session_type, last_activity_at DESC)`
- `INDEX(job_id)`
## D. 权限与协作模型
### 1) 事项权限落表
- 权限直接存储在 `schedule_subscriptions.permission` 整数中(位运算)
- owner 不写入 `schedule_subscriptions`owner 权限仅由 `schedule_items.owner_id` 推导
- 权限决策顺序:
1. `schedule_items.owner_id` → 服务层恒等全部权柄 `["view", "invite", "edit"]`7
2. `schedule_subscriptions` 中该用户的 `permission` 位图
3. 非 owner 且非 subscriber 默认无权限(0
### 2) 当前版本边界
- 事项权限仅处理用户主体(owner + subscriber
- 群组与事项权限继承关系不在本期范围
## E. 消息与待办联动
### 1) inbox 关联业务对象
- `inbox_messages.message_type` 枚举:
- `friend_request`(好友请求)→ `friendship_id` 指向 friendships
- `calendar`(日程邀请)→ `schedule_item_id` 指向 schedule_items
- `system`(系统消息)→ 业务字段均为 NULL
- `group`(群组邀请)→ `group_id` 指向 groups
- 通过 `message_type + 对应业务字段` 直接定位业务对象,并用 `CHECK` 约束保证字段一致性
### 2) 待办来源提取
- 从事项提取待办时,写入 `todo_sources(todo_id, schedule_item_id)`
- 手动创建的待办不写 `todo_sources`
- 支持多来源:同一待办可关联多个日程事项(多行 `todo_sources`
- 待办完成时无需反向更新来源事项状态(简化设计)
## F. 定时任务模型
### 1) 调度规则
- `schedule_type` 枚举:`daily`(每日) | `weekly`(每周)
- `run_at` 用于首次执行时间,`next_run_at` 用于后续调度
- 调度器扫描 `status='active' AND next_run_at <= now()` 的任务,执行后回写下一次 `next_run_at`
- `timezone` 参与下一次执行时间计算,避免时区偏差
### 2) 执行记录
- 每次执行在 sessions 表创建记录,通过 `sessions.job_id` 关联 job
- `sessions` 通过 `session_type` 区分 `chat``automation`
- 执行失败时记录在 `automation_jobs`(如 `last_error`,可后续细化)
## G. 数据库迁移思路
### 策略:重建数据库 + Alembic ORM 迁移
由于是全新设计的数据模型,且当前处于开发初期(可清除旧数据),采用**重建数据库**策略:
**执行门禁(强制)**
- 仅允许在本地开发环境执行
- 禁止在生产/共享环境执行 `rm backend/alembic/versions/*.py`
- 执行前必须备份数据库或创建 git tag
1. **删除所有旧 migration 脚本**(保留 `env.py`
2. **创建 ORM 模型文件**
3. **生成 Alembic migration**
4. **重建数据库并执行迁移**
### 执行步骤
1. 删除旧 migration 文件
```bash
rm backend/alembic/versions/*.py
```
2. 重建空数据库(确保以空库基线生成 initial migration
```bash
docker compose --env-file .env -f infra/docker/docker-compose.yml down -v
docker compose --env-file .env -f infra/docker/docker-compose.yml up -d db
```
3. 创建 ORM 模型文件(参考 `models/` 目录结构)
- 新增:`user_agents.py`, `memories.py`, `friendships.py`, `groups.py`, `group_members.py`, `schedule_items.py`, `schedule_subscriptions.py`, `inbox_messages.py`, `todos.py`, `todo_sources.py`, `automation_jobs.py`
- 更新:`profile.py` - 添加 `settings` 字段
- 更新:`agent_chat_session.py` - 添加 `session_type`、`job_id` 字段
- 重写:`create_profile_for_new_user` 触发器,确保 `profiles.settings` 有默认值
4. 更新 `models/__init__.py` 导出所有模型
5. 更新 `alembic/env.py` 添加模型导入
6. 生成 initial migration(以空库为对比基线)
```bash
cd backend && uv run alembic revision --autogenerate -m "initial schema"
```
7. 为所有新建 `public` 业务表补齐 RLS`SELECT/INSERT/UPDATE/DELETE` policy
- 每张表都执行 `ENABLE ROW LEVEL SECURITY`
- 每张表都显式创建 `SELECT/INSERT/UPDATE/DELETE` policy
- downgrade 必须可逆,不得弱化既定安全边界
- `anon/authenticated` 默认全部 deny
RLS 最小策略矩阵(本期统一模板):
- `anon``SELECT/INSERT/UPDATE/DELETE` 全部 deny
- `authenticated``SELECT/INSERT/UPDATE/DELETE` 全部 deny
- `service_role`:由后端服务连接使用,不依赖 RLS 放行
8. 执行迁移
```bash
cd backend && uv run alembic upgrade head
```
9. 验证表结构
## H. 交付物
### ORM 模型文件清单
| 文件 | 说明 |
|------|------|
| `models/user_agents.py` | 用户专属 agent |
| `models/memories.py` | 用户/工作记忆 |
| `models/friendships.py` | 好友关系 |
| `models/groups.py` | 群组 |
| `models/group_members.py` | 群组成员 |
| `models/schedule_items.py` | 日程事项 |
| `models/schedule_subscriptions.py` | 日程订阅与权限 |
| `models/inbox_messages.py` | 待处理消息 |
| `models/todos.py` | 待办 |
| `models/todo_sources.py` | 待办与事项来源关联 |
| `models/automation_jobs.py` | 定时任务 |
| `models/profile.py` | 更新:添加 `settings` 字段 |
| `models/agent_chat_session.py` | 更新:添加 `session_type`、`job_id` 字段 |
### 执行步骤
1. 删除旧 migration 文件
```bash
rm backend/alembic/versions/*.py
```
2. 重建空数据库(确保以空库基线生成 initial migration
```bash
docker compose --env-file .env -f infra/docker/docker-compose.yml down -v
docker compose --env-file .env -f infra/docker/docker-compose.yml up -d db
```
3. 创建/更新 ORM 模型文件
4. 更新 `models/__init__.py` 导出所有模型
5. 更新 `alembic/env.py` 添加模型导入
6. 生成 initial migration(以空库为对比基线)
```bash
cd backend && uv run alembic revision --autogenerate -m "initial schema"
```
7. 为所有新建 `public` 业务表补齐 RLS`SELECT/INSERT/UPDATE/DELETE` policy
- 每张表都执行 `ENABLE ROW LEVEL SECURITY`
- 每张表都显式创建 `SELECT/INSERT/UPDATE/DELETE` policy
- downgrade 必须可逆,不得弱化既定安全边界
8. 执行迁移
```bash
cd backend && uv run alembic upgrade head
```
9. 更新测试文件适配新表结构
## I. 数据库表名规范与审计
### 1) 命名规范(统一执行)
- 使用 `snake_case`
- 业务表统一使用复数名词(如 `profiles`, `friendships`, `automation_jobs`
- 关联表使用 `<主实体复数>_<从实体复数>` 或约定俗成复数短语(如 `group_members`, `todo_sources`
- 禁止过于泛化的表名(如 `messages`, `sessions`),必须带业务前缀
- 存量历史表允许短期例外,但必须在审计表中登记并给出迁移计划
- 缩写保持一致:LLM 统一使用 `llm` 前缀,不混用 `model`/`llm` 两套命名
### 2) 表名审计结果
| 当前表名 | 审计结论 | 建议表名 | 说明 |
|----------|----------|----------|------|
| `profiles` | 通过 | - | 符合复数名词规范 |
| `user_agents` | 通过 | - | 语义清晰 |
| `memories` | 通过 | - | 语义清晰 |
| `friendships` | 通过 | - | 关系表命名清晰 |
| `groups` | 通过 | - | 符合规范 |
| `group_members` | 通过 | - | 关联表命名清晰 |
| `schedule_items` | 通过 | - | 语义清晰 |
| `schedule_subscriptions` | 通过 | - | 语义清晰 |
| `inbox_messages` | 通过 | - | 带业务前缀,避免歧义 |
| `todos` | 通过 | - | 简洁且清晰 |
| `todo_sources` | 通过 | - | 关联关系明确 |
| `automation_jobs` | 通过 | - | 语义清晰 |
| `llms` | 通过 | - | 与 LLM 语义一致 |
| `llm_factory` | 建议调整 | `llm_factories` | 当前为单数,建议改复数以统一规范 |
| `sessions` | 建议调整 | `agent_chat_sessions` | 过于泛化,建议加业务前缀 |
| `messages` | 建议调整 | `agent_chat_messages` | 过于泛化,建议加业务前缀 |
### 3) 落地建议
- 本期命名边界:不重命名 `llm_factory/sessions/messages`,仅在新表严格执行命名规范
- 本期最小可行:先保持现有表名可运行,新增表全部遵循规范
- 下期统一治理:通过一次性迁移将 `llm_factory/sessions/messages` 重命名到规范名
- 若本期直接重命名,需同步 ORM 模型、外键、索引、RLS policy 名称与运行文档
-161
View File
@@ -1,161 +0,0 @@
# 邀请码机制设计
**Date**: 2026-02-27
**Status**: Approved
**Author**: User + AI
## 背景
为用户注册增加邀请码机制,支持:
- 每个用户注册后自动获得专属邀请码
- 注册时可填写他人邀请码
- 记录邀请关系和使用统计
- 支持运营邀请码(批量、限额、过期、禁用)
- 预留奖励策略配置
## 数据模型
### invite_codes 表
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | UUID | PK | 主键 |
| code | VARCHAR(8) | UNIQUE, NOT NULL | 邀请码 |
| owner_id | UUID | FK → profiles.id, nullable | 所属用户,NULL 为运营码 |
| max_uses | INT | nullable | 最大使用次数,NULL 无限制 |
| used_count | INT | DEFAULT 0 | 已用次数 |
| expires_at | TIMESTAMPTZ | nullable | 过期时间,NULL 永不过期 |
| status | VARCHAR(20) | NOT NULL | active / disabled |
| reward_config | JSONB | DEFAULT '{}' | 奖励策略配置 |
| created_at | TIMESTAMPTZ | NOT NULL | 创建时间 |
| updated_at | TIMESTAMPTZ | NOT NULL | 更新时间 |
| deleted_at | TIMESTAMPTZ | nullable | 软删除 |
**索引:**
- `ix_invite_codes_code` ON (code) UNIQUE
- `ix_invite_codes_owner_id` ON (owner_id)
- `ix_invite_codes_status_expires` ON (status, expires_at)
**CHECK 约束:**
- `status IN ('active', 'disabled')`
- `used_count >= 0`
- `max_uses IS NULL OR max_uses > 0`
### profiles 表变更
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| referred_by | UUID | FK → profiles.id, nullable | 被谁邀请 |
**索引:**
- `ix_profiles_referred_by` ON (referred_by)
## API 变更
### POST /auth/verifications
**Request:**
```json
{
"username": "string (3-30 chars)",
"email": "string (email)",
"password": "string (min 6 chars)",
"redirect_to": "string?",
"invite_code": "string (8 chars)?" // 新增,可选
}
```
**Response:** 202 Accepted(不变)
## 注册流程
```
┌─────────────────────────────────────────────────────────────────┐
│ 1. POST /auth/verifications │
│ - 存储 username + invite_code 到 Supabase metadata │
│ - 发送 OTP 邮件 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 2. POST /auth/verifications/verify │
│ - 验证 OTP │
│ - 创建 auth.users 记录 │
│ - 触发 on_auth_user_created trigger │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 3. Trigger: on_auth_user_created │
│ a. INSERT INTO profiles (id, username, ...) │
│ b. 生成 8 位随机邀请码 │
│ c. INSERT INTO invite_codes (code, owner_id, ...) │
│ d. 从 metadata 取 invite_code,执行邀请校验逻辑 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 4. 邀请码校验逻辑 │
│ IF invite_code 存在 AND │
│ status = 'active' AND │
│ (expires_at IS NULL OR expires_at > now()) AND │
│ (max_uses IS NULL OR used_count < max_uses) │
│ THEN │
│ UPDATE profiles SET referred_by = invite_codes.owner_id │
│ UPDATE invite_codes SET used_count = used_count + 1 │
│ END IF │
└─────────────────────────────────────────────────────────────────┘
```
## 邀请码生成规则
- 8 位随机字符串
- 字符集:`ABCDEFGHJKLMNPQRSTUVWXYZ23456789`(排除易混淆字符 0/O/1/I/L
- 唯一性:数据库 UNIQUE 约束 + 生成时冲突重试(最多 10 次)
## 使用记录查询
通过 profiles 表查询:
```sql
-- 查询某个邀请码的使用记录
SELECT p.id, p.username, p.created_at
FROM profiles p
JOIN invite_codes ic ON ic.owner_id = :owner_id
WHERE p.referred_by = ic.owner_id
ORDER BY p.created_at DESC;
-- 查询某个用户邀请了多少人
SELECT COUNT(*) FROM profiles WHERE referred_by = :user_id;
```
## 边界情况
| 场景 | 处理方式 |
|------|----------|
| 邀请码不存在 | 跳过邀请,注册正常成功 |
| 邀请码已禁用 | 跳过邀请 |
| 邀请码已过期 | 跳过邀请 |
| 邀请码已达上限 | 跳过邀请 |
| 用户自邀(用自己的码) | 不可能,用户注册时还没有邀请码 |
| 重复使用同一邀请码 | 允许(until max_uses |
## 后续扩展
1. **奖励系统**:通过 `reward_config` JSONB 字段配置不同奖励策略
2. **运营批量码**`owner_id = NULL` 的邀请码,支持市场推广
3. **邀请排行榜**:基于 `used_count` 或 profiles 关联查询
4. **邀请码回收**:软删除 `deleted_at`,保留历史记录
## 迁移计划
1. 新增迁移文件创建 `invite_codes`
2. 新增迁移文件给 `profiles` 表添加 `referred_by` 字段
3. 修改 `on_auth_user_created` trigger 增加邀请码逻辑
4. 修改 `VerificationCreateRequest` schema 添加 `invite_code` 字段
5. 修改 `create_verification` gateway 传递 `invite_code` 到 metadata
## 测试用例
1. 注册时不填邀请码 → 正常注册,生成专属邀请码
2. 注册时填写有效邀请码 → 关联邀请关系,used_count +1
3. 注册时填写无效邀请码 → 正常注册,无邀请关系
4. 邀请码达上限后使用 → 正常注册,无邀请关系
5. 运营邀请码使用 → 正常注册,无 referred_byowner_id = NULL
@@ -1,309 +0,0 @@
# Invite Code Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 在现有 OTP 注册链路中引入邀请码能力,支持用户自动生成专属邀请码、注册时可选填邀请码并记录邀请关系与使用次数。
**Architecture:** 采用数据库中心实现:通过 Alembic 新增 `invite_codes` 表、扩展 `profiles` 字段,并在 `auth.users` 的现有 trigger 函数中完成邀请码校验与记账,保证注册与邀请关系写入尽量原子。应用层只负责透传 `invite_code` 到 Supabase `raw_user_meta_data`
**Tech Stack:** FastAPI, SQLAlchemy, Alembic, Supabase Auth, PostgreSQL PL/pgSQL, Pytest
---
### Task 1: 更新注册请求 SchemaTDD
**Files:**
- Modify: `backend/src/v1/auth/schemas.py`
- Modify: `backend/tests/integration/test_auth_routes.py`
**Step 1: Write the failing test**
`test_signup_start_returns_pending_response` 基础上新增断言路径:请求体带 `invite_code` 时返回仍为 202,且未触发 422。
**Step 2: Run test to verify it fails**
Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k signup_start_returns_pending_response -v`
Expected: FAIL`invite_code` 为额外字段或校验不通过)
**Step 3: Write minimal implementation**
`VerificationCreateRequest` 增加可选字段:
```python
invite_code: str | None = Field(default=None, min_length=8, max_length=8)
```
**Step 4: Run test to verify it passes**
Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k signup_start_returns_pending_response -v`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/src/v1/auth/schemas.py backend/tests/integration/test_auth_routes.py
git commit -m "feat: accept invite code in signup request"
```
### Task 2: 透传 invite_code 到 Supabase metadataTDD
**Files:**
- Modify: `backend/src/v1/auth/gateway.py`
- Modify: `backend/tests/unit/v1/auth/test_auth_service.py`
**Step 1: Write the failing test**
`test_supabase_signup_passes_username_in_metadata` 增加 `invite_code` 并断言:
```python
assert captured_payload["data"] == {
"username": "demo",
"invite_code": "A1B2C3D4",
}
```
**Step 2: Run test to verify it fails**
Run: `cd backend && uv run pytest tests/unit/v1/auth/test_auth_service.py -k metadata -v`
Expected: FAILmetadata 未包含 `invite_code`
**Step 3: Write minimal implementation**
`create_verification` 中构建 metadata
```python
metadata = {"username": request.username}
if request.invite_code:
metadata["invite_code"] = request.invite_code
payload = {
"email": request.email,
"password": request.password,
"data": metadata,
}
```
**Step 4: Run test to verify it passes**
Run: `cd backend && uv run pytest tests/unit/v1/auth/test_auth_service.py -k metadata -v`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/src/v1/auth/gateway.py backend/tests/unit/v1/auth/test_auth_service.py
git commit -m "feat: pass invite code through signup metadata"
```
### Task 3: 新增 invite_codes 表与 profiles.referred_by(迁移先行)
**Files:**
- Create: `backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py`
- Modify: `backend/src/models/profile.py`
- Create: `backend/src/models/invite_code.py`
- Modify: `backend/src/models/__init__.py`
**Step 1: Write the failing test**
`backend/tests/unit/database/test_profile_models.py` 新增 `referred_by` 读写测试;新增 `backend/tests/unit/database/test_invite_code_models.py` 验证 `InviteCode` 基本创建与约束字段。
**Step 2: Run test to verify it fails**
Run: `cd backend && uv run pytest tests/unit/database/test_profile_models.py tests/unit/database/test_invite_code_models.py -v`
Expected: FAIL(字段/模型不存在)
**Step 3: Write minimal implementation**
- Alembic 创建 `invite_codes`
- `code` 唯一索引
- `owner_id` 外键到 `profiles.id`(可空)
- `status``used_count``max_uses` check 约束
- `max_uses` 默认 `NULL`(无限制)
- `expires_at` 默认 `NULL`(无限制)
- `reward_config` JSONB 默认 `{}`
- 启用 RLS(按项目默认 deny-all
- **注意**:本期不开放 invite_codes 表直接读取,用户邀请码通过 profile 聚合接口返回(后续实现)
- Alembic 给 `profiles` 增加 `referred_by` + 索引 + 外键
- ORM 同步 `Profile.referred_by``InviteCode` 模型
**Step 4: Run test to verify it passes**
Run: `cd backend && uv run pytest tests/unit/database/test_profile_models.py tests/unit/database/test_invite_code_models.py -v`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py backend/src/models/profile.py backend/src/models/invite_code.py backend/src/models/__init__.py backend/tests/unit/database/test_profile_models.py backend/tests/unit/database/test_invite_code_models.py
git commit -m "feat: add invite code schema and profile referral fields"
```
### Task 4: 扩展注册 trigger 生成邀请码并消费邀请(TDD)
**Files:**
- Modify: `backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py`
- Modify: `backend/tests/integration/test_auth_routes.py`
**Step 1: Write the failing test**
新增集成测试(建议通过测试替身/fixture 验证行为):
- 注册不带邀请码时,profile 创建后存在 owner 邀请码
- 注册带有效邀请码时,`referred_by` 生效且 `used_count + 1`
**Step 2: Run test to verify it fails**
Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k invite -v`
Expected: FAIL(触发器逻辑尚未实现)
**Step 3: Write minimal implementation**
在迁移 SQL 中:
- 新增 helper function:生成 8 位随机码(排除易混淆字符 0/O/1/I/L,冲突重试)
- 重建 `public.create_profile_for_new_user()`
1. 插入 `profiles`
2. 创建该用户专属 `invite_codes``owner_id = NEW.id`
3. 读取 `NEW.raw_user_meta_data ->> 'invite_code'`
4. 校验邀请码状态/过期/次数
5. 若有效:更新 `profiles.referred_by`,并 `used_count = used_count + 1`
**Step 4: Run test to verify it passes**
Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k invite -v`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py backend/tests/integration/test_auth_routes.py
git commit -m "feat: extend signup trigger for invite code generation and usage"
```
### Task 5: 覆盖邀请码边界场景(TDD)
**Files:**
- Modify: `backend/tests/integration/test_auth_routes.py`
- Optional Modify: `backend/tests/e2e/test_auth_flow.py`
**Step 1: Write the failing test**
新增场景测试:
- 邀请码不存在
- 邀请码 disabled
- 邀请码 expires_at 已过期
- 邀请码达到 `max_uses`
断言:注册仍成功(202/200 链路正常),仅邀请关系不建立。
**Step 2: Run test to verify it fails**
Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k "invite and (expired or disabled or max_uses or invalid)" -v`
Expected: FAIL
**Step 3: Write minimal implementation**
修正 trigger 判断顺序和条件,确保“邀请无效不影响注册”原则。
**Step 4: Run test to verify it passes**
Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k invite -v`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/tests/integration/test_auth_routes.py backend/alembic/versions/20260227_0006_invite_codes_and_profile_referral.py
git commit -m "test: cover invite code edge cases in signup flow"
```
### Task 6: 文档同步与运行手册更新
**Files:**
- Modify: `docs/runtime/runtime-route.md`
- Modify: `docs/runtime/runtime-runbook.md`
**Step 1: Write the failing test**
无自动化测试;改为文档一致性检查清单(手工):
- 注册接口 request 字段包含 `invite_code`
- 说明邀请码消费时机与“无效码不阻断注册”
**Step 2: Run check to verify missing docs**
Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k signup_start -v`
Expected: PASS(作为行为基线),文档尚未同步
**Step 3: Write minimal implementation**
- 更新 `POST /auth/verifications` 请求字段
- 新增邀请码行为说明
- 在 runbook 变更日志添加本次改动记录
**Step 4: Run check after docs update**
Run: `cd backend && uv run pytest tests/integration/test_auth_routes.py -k signup_start -v`
Expected: PASS(行为与文档一致)
**Step 5: Commit**
```bash
git add docs/runtime/runtime-route.md docs/runtime/runtime-runbook.md
git commit -m "docs: document invite code behavior in signup flow"
```
### Task 7: 全量验证与风险审查(L2)
**Files:**
- Verify only
**Step 1: Run lint/type checks**
Run:
- `cd backend && uv run ruff check src tests`
- `cd backend && uv run basedpyright src`
Expected: 全部通过
**Step 2: Run test suites**
Run:
- `cd backend && uv run pytest tests/unit -v`
- `cd backend && uv run pytest tests/integration -v`
- `cd backend && uv run pytest tests/e2e/test_auth_flow.py -v`
Expected: 通过
**Step 3: Run mandatory review gates for L2**
- `refactor-cleaner` agent:确认无死代码/重复代码
- `code-reviewer` agent:检查 DB trigger、安全边界、可维护性
Expected: CRITICAL/HIGH 为 0
**Step 4: Security-specific sanity checks**
检查项:
- 未硬编码密钥
- SQL 逻辑无注入风险(trigger 中仅参数/列操作)
- 邀请码校验失败不泄露内部细节
**Step 5: Commit verification evidence (if needed in docs/PR notes)**
```bash
git add <updated verification notes if any>
git commit -m "chore: record invite code verification results"
```
---
## 交付验收标准
1. 新用户注册后必有 1 条专属邀请码。
2. 注册时传入有效邀请码会建立 `profiles.referred_by` 并增加 `used_count`
3. 无效邀请码不会阻断注册成功。
4. 支持运营码(`owner_id IS NULL`)与后续奖励扩展(`reward_config`)。
5. 文档已同步,测试与检查通过。
## 备注
- 本需求触发 L2(数据库迁移 + trigger + 多文件大改),必须走双审查 gate。
- 不在本期实现运营后台批量发码 API;仅完成数据层与注册链路支撑。
@@ -1,191 +0,0 @@
# Design: Schedule Items API
**Date:** 2026-02-27
**Status:** Approved
## Overview
实现日历事项(Schedule Items)的后端 CRUD API,支持用户创建、查询、更新、删除日历事项。
## Scope
- 仅后端 API,不涉及前端
- 全量 CRUD
- 查询按时间范围筛选
- 暂不支持重复日程(recurrence_rule 留空)
## API Endpoints
### 1. 创建日历事项
```
POST /api/v1/schedule-items
```
**Request:**
```json
{
"title": "string (1-255 chars, required)",
"description": "string? (max 2000 chars)",
"start_at": "string (ISO 8601 datetime, required)",
"end_at": "string? (ISO 8601 datetime, must be after start_at)",
"timezone": "string? (default: UTC)",
"metadata": {
"color": "#FF6B6B",
"location": "会议室A",
"notes": "记得带身份证",
"attachments": [],
"version": 1
}
}
```
**Response:** 201 Created
```json
{
"id": "uuid",
"title": "string",
"description": "string?",
"start_at": "string",
"end_at": "string?",
"timezone": "string",
"metadata": {...},
"status": "active",
"source_type": "manual",
"created_at": "string",
"updated_at": "string"
}
```
### 2. 查询日历事项列表
```
GET /api/v1/schedule-items?start_at=2026-02-01&end_at=2026-02-28
```
**Query Parameters:**
- `start_at`: ISO 8601 date/datetime(查询范围起始)
- `end_at`: ISO 8601 date/datetime(查询范围结束)
**Response:** 200 OK
```json
[
{
"id": "uuid",
"title": "string",
"start_at": "string",
"end_at": "string?",
"timezone": "string",
"status": "active"
}
]
```
### 3. 获取单个事项
```
GET /api/v1/schedule-items/{id}
```
**Response:** 200 OK(完整字段,同创建响应)
### 4. 更新事项
```
PATCH /api/v1/schedule-items/{id}
```
**Request:** 支持 `title`/`description`/`start_at`/`end_at`/`timezone`/`metadata`/`status` 部分更新
**Response:** 200 OK
### 5. 删除事项
```
DELETE /api/v1/schedule-items/{id}
```
**Response:** 204 No Content(软删除)
## Data Models
### Metadata 结构(Pydantic
```python
from enum import Enum
from pydantic import BaseModel
from uuid import UUID
class AttachmentType(str, Enum):
DOCUMENT = "document"
REMINDER = "reminder"
class ScheduleItemMetadataAttachment(BaseModel):
name: str
type: AttachmentType
visible_to: list[UUID] = []
# document 类型
url: str | None = None
note: str | None = None
# reminder 类型
content: str | None = None
class ScheduleItemMetadata(BaseModel):
color: str | None = None
location: str | None = None
notes: str | None = None
attachments: list[ScheduleItemMetadataAttachment] = []
version: int = 1
```
### 数据库模型(已有)
参见 `backend/src/models/schedule_items.py`
- `id`: UUID
- `owner_id`: UUID
- `title`: String(255)
- `description`: Text
- `start_at`: DateTime(timezone=True)
- `end_at`: DateTime(timezone=True)
- `timezone`: String(50)
- `extra_metadata`: JSONB (mapped as "metadata")
- `recurrence_rule`: String(255)
- `source_type`: Enum (MANUAL/IMPORTED/AGENT_GENERATED)
- `status`: Enum (ACTIVE/COMPLETED/CANCELED/ARCHIVED)
- `created_by`: UUID
## Architecture
遵循项目 `schemas / repository / service / router` 分层模式:
```
backend/src/v1/schedule_items/
├── __init__.py
├── schemas.py # Pydantic 请求/响应模型
├── repository.py # CRUD 操作(无 auth,无 commit
├── service.py # 业务逻辑 + 授权 + 事务边界
├── router.py # FastAPI 路由定义
└── dependencies.py # DI(如有)
```
## Security
- 所有端点需要认证(JWT
- `owner_id` 从 JWT `sub` 提取,不从请求体读取
- 用户只能操作自己的日历事项(`owner_id` 过滤)
- RLS 已在数据库层启用(防御边界)
## Error Handling
使用 RFC 7807 `application/problem+json` 格式:
- 400: 请求参数无效
- 401: 未认证
- 404: 事项不存在或无权限访问
- 422: 验证失败
## Out of Scope
- 重复日程(recurrence_rule
- 日程订阅与协作(schedule_subscriptions
- 待办事项联动(todos/todo_sources
- 前端实现
File diff suppressed because it is too large Load Diff
-567
View File
@@ -1,567 +0,0 @@
# AG-UI 聊天功能设计文档
## 1. 概述
本文档描述如何使用 AG-UI 协议实现 AI 聊天功能,包括:
- 消息的发送与接收(通过 AG-UI 事件流)
- AI 工具调用(Tool Call)机制
- 日历卡片作为 Tool Result 渲染
- 前端工具注册与执行
- 本地持久化
## 2. 架构设计
### 2.1 整体流程
```
用户输入消息
AgUiService.sendMessage()
[Mock Mode] 规则引擎决策 → 事件流模拟
[Real Mode] POST /api/chat → SSE 监听
┌─────────────────────────────────────────────────────────────┐
│ AG-UI Event Stream (按序处理) │
├─────────────────────────────────────────────────────────────┤
│ TEXT_MESSAGE_START → TEXT_MESSAGE_CONTENT* → TEXT_MESSAGE_END │
│ TOOL_CALL_START → TOOL_CALL_ARGS* → TOOL_CALL_END │
│ TOOL_CALL_RESULT │
│ RUN_STARTED → ... → RUN_FINISHED │
└─────────────────────────────────────────────────────────────┘
ChatListItem 渲染
```
### 2.2 核心组件
| 组件 | 职责 |
|------|------|
| `AgUiEvent` | AG-UI 事件数据模型 |
| `AgUiService` | 事件流处理:发送消息、解析事件 |
| `ToolRegistry` | 前端工具注册表:定义工具 + handler |
| `AiDecisionEngine` | Mock 模式:规则引擎决定是否调用工具 |
| `UiSchemaParser` | 解析 tool result 中的 UI Schema |
| `UiSchemaRenderer` | 根据 schema 渲染对应组件 |
| `ChatHistoryRepository` | 本地持久化:IndexedDB/localStorage |
### 2.3 状态管理
```
ChatState {
messages: ChatListItem[] // 渲染列表
pendingToolCalls: Map<call_id, ToolCallState>
isLoading: bool
runId: string | null
}
```
## 3. 数据模型
### 3.1 AG-UI 事件模型
```dart
// 基类
abstract class AgUiEvent {
final String type;
final String? timestamp;
}
// 生命周期事件
class RunStartedEvent extends AgUiEvent {
final String threadId;
final String runId;
final String? parentRunId;
}
class RunFinishedEvent extends AgUiEvent {
final String threadId;
final String runId;
final dynamic result;
}
// 文本消息事件
class TextMessageStartEvent extends AgUiEvent {
final String messageId;
final String role; // "user" | "assistant" | "system"
}
class TextMessageContentEvent extends AgUiEvent {
final String messageId;
final String delta;
}
class TextMessageEndEvent extends AgUiEvent {
final String messageId;
}
// 工具调用事件
class ToolCallStartEvent extends AgUiEvent {
final String toolCallId;
final String toolCallName;
final String? parentMessageId;
}
class ToolCallArgsEvent extends AgUiEvent {
final String toolCallId;
final String delta; // JSON fragment
}
class ToolCallEndEvent extends AgUiEvent {
final String toolCallId;
}
class ToolCallResultEvent extends AgUiEvent {
final String messageId;
final String toolCallId;
final ToolResult result; // 给 AI 的原始结果
final UiCard? ui; // 给 UI 的渲染数据
}
class ToolCallErrorEvent extends AgUiEvent {
final String toolCallId;
final String error;
final String? code;
}
```
### 3.2 Tool Result Schemav1
```json
{
"type": "tool_result",
"version": "v1",
"call_id": "call_abc123",
"tool_name": "create_calendar_event",
"result": {
"eventId": "evt_xxx",
"ok": true,
"message": "日程已创建"
},
"ui": {
"type": "card",
"cardType": "calendar_card.v1",
"data": {
"id": "evt_xxx",
"title": "产品评审会议",
"description": "讨论Q2路线图",
"startAt": "2026-03-01T10:00:00+08:00",
"endAt": "2026-03-01T11:00:00+08:00",
"timezone": "Asia/Shanghai",
"location": "会议室A",
"color": "#4F46E5",
"sourceType": "agentGenerated"
},
"actions": [
{"type": "open", "label": "打开", "target": "calendar/evt_xxx"},
{"type": "edit", "label": "编辑", "action": "edit_event"},
{"type": "delete", "label": "删除", "action": "delete_event"}
]
}
}
```
### 3.3 工具定义(前端 Tool Registry
```dart
// 工具定义
class ToolDefinition {
final String name;
final String description;
final Map<String, dynamic> parameters;
final ToolHandler handler;
}
// create_calendar_event 工具
{
"name": "create_calendar_event",
"description": "创建一个日历事件或待办事项",
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "事件标题",
"minLength": 1,
"maxLength": 100
},
"description": {
"type": "string",
"description": "事件描述"
},
"startAt": {
"type": "string",
"format": "date-time",
"description": "开始时间 (ISO8601)"
},
"endAt": {
"type": "string",
"format": "date-time",
"description": "结束时间 (ISO8601)"
},
"timezone": {
"type": "string",
"default": "Asia/Shanghai"
},
"location": {
"type": "string"
},
"notes": {
"type": "string"
}
},
"required": ["title", "startAt"]
}
}
```
### 3.4 ChatListItem 模型
```dart
enum ChatItemType {
message, // 纯文本消息
toolCall, // 工具调用中
toolResult, // 工具结果卡片
schedule // 日历事件(兼容旧数据)
}
abstract class ChatListItem {
String get id;
DateTime get timestamp;
ChatItemType get type;
MessageSender get sender;
}
class TextMessageItem extends ChatListItem {
final String id;
final String content;
final DateTime timestamp;
final MessageSender sender;
final bool isStreaming; // 是否正在流式输出
}
class ToolCallItem extends ChatListItem {
final String id;
final String callId;
final String toolName;
final Map<String, dynamic> args; // 解析后的参数
final ToolCallStatus status; // pending | executing | completed | error
final ToolResult? result;
final UiCard? uiCard;
}
class CalendarCardItem extends ChatListItem {
final String id;
final String callId; // 关联的 tool call
final CalendarCardData data;
final List<CardAction> actions;
}
```
## 4. 核心流程
### 4.1 发送消息
```dart
Future<void> sendMessage(String content) async {
// 1. 添加用户消息到列表
final userMessage = TextMessageItem(
id: generateId(),
content: content,
timestamp: DateTime.now(),
sender: MessageSender.user,
);
_chatItems.add(userMessage);
// 2. 发起请求
if (Env.isMockApi) {
await _mockEventStream(content);
} else {
await _realEventStream(content);
}
}
```
### 4.2 Mock 事件流(规则引擎)
```dart
class AiDecisionEngine {
// 意图关键词映射
static final Map<Intent, List<Pattern>> _intentPatterns = {
Intent.createEvent: [
RegExp(r'提醒|开会|预约|日程|安排'),
RegExp(r'明天|今天|后天|下周'),
RegExp(r'\d{1,2}点|\d{1,2}:\d{2}'),
],
Intent.searchEvent: [
RegExp(r'查看|有什么|今天.*日程|明天.*安排'),
],
};
Intent? matchIntent(String text) {
for (final entry in _intentPatterns.entries) {
for (final pattern in entry.value) {
if (pattern.hasMatch(text)) {
return entry.key;
}
}
}
return null;
}
// 支持强制触发:#tool:create_calendar_event {"title": "test"}
bool tryForceTrigger(String text) {...}
}
```
### 4.3 事件解析与处理
```dart
Future<void> _processEvent(AgUiEvent event) async {
switch (event.type) {
case 'TEXT_MESSAGE_START':
_handleTextMessageStart(event);
break;
case 'TEXT_MESSAGE_CONTENT':
_handleTextMessageContent(event);
break;
case 'TEXT_MESSAGE_END':
_handleTextMessageEnd(event);
break;
case 'TOOL_CALL_START':
_handleToolCallStart(event);
break;
case 'TOOL_CALL_ARGS':
_handleToolCallArgs(event);
break;
case 'TOOL_CALL_END':
await _handleToolCallEnd(event);
break;
case 'TOOL_CALL_RESULT':
_handleToolCallResult(event);
break;
case 'TOOL_CALL_ERROR':
_handleToolCallError(event);
break;
}
}
void _handleToolCallStart(ToolCallStartEvent event) {
// 创建 pending 状态的 tool call item
final item = ToolCallItem(
id: event.toolCallId,
callId: event.toolCallId,
toolName: event.toolCallName,
args: {},
status: ToolCallStatus.pending,
);
_chatItems.add(item);
}
Future<void> _handleToolCallEnd(ToolCallEndEvent event) async {
// 1. 找到对应的 pending tool call
final toolCall = _findPendingToolCall(event.toolCallId);
if (toolCall == null) return;
// 2. 校验参数 JSON Schema
final validation = validateToolArgs(toolCall.toolName, toolCall.args);
if (!validation.ok) {
_emitToolCallError(event.toolCallId, validation.error);
return;
}
// 3. 执行工具 handler
toolCall.status = ToolCallStatus.executing;
final result = await ToolRegistry.execute(
toolCall.toolName,
toolCall.args,
);
// 4. 构建 tool result(包含 result + ui
final toolResult = ToolResult(
eventId: result['eventId'],
ok: result['ok'] ?? true,
message: result['message'],
);
final uiCard = _buildUiCard(toolCall.toolName, result);
// 5. 发送 TOOL_CALL_RESULT 事件
_emitToolCallResult(event.toolCallId, toolResult, uiCard);
}
```
### 4.4 UI Schema 渲染
```dart
class UiSchemaRenderer {
static final Map<String, Widget Function(UiCard)> _renderers = {
'calendar_card.v1': (card) => CalendarCardWidget(
data: CalendarCardData.fromJson(card.data),
actions: card.actions,
),
};
static Widget render(UiCard card) {
final renderer = _renderers[card.cardType];
if (renderer != null) {
return renderer(card);
}
// Unknown card type fallback
return _renderUnknownCard(card);
}
static Widget _renderUnknownCard(UiCard card) {
return GenericCardWidget(
rawJson: jsonEncode(card.toJson()),
cardType: card.cardType,
);
}
}
```
### 4.5 日历卡片组件
```dart
class CalendarCardWidget extends StatelessWidget {
final CalendarCardData data;
final List<CardAction> actions;
@override
Widget build(BuildContext context) {
final color = ColorExt.parse(data.color ?? '#4F46E5');
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [...],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 颜色条
Container(
height: 4,
color: color,
),
// 内容
Padding(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(data.title, style: ...),
if (data.description != null) ...,
_buildTimeRow(),
if (data.location != null) ...,
],
),
),
// Actions
if (actions.isNotEmpty) _buildActions(actions),
],
),
);
}
}
```
## 5. 持久化设计
### 5.1 存储结构
```dart
// localStorage / IndexedDB
{
"chat_sessions": {
"current_thread_id": {
"messages": [...], // ChatListItem JSON
"lastRunId": "run_xxx",
"updatedAt": "2026-02-28T12:00:00Z"
}
},
"calendar_events": {
"evt_xxx": {...} // 独立存储的日历事件
}
}
```
### 5.2 恢复逻辑
```dart
Future<void> restoreSession() async {
final session = await ChatHistoryRepository.load('current_thread_id');
if (session != null) {
_chatItems.clear();
_chatItems.addAll(session.messages);
_runId = session.lastRunId;
}
}
```
## 6. 错误处理
### 6.1 Tool Call 错误
```dart
void _emitToolCallError(String callId, String error) {
// 1. 更新 item 状态
final item = _findToolCallItem(callId);
item?.status = ToolCallStatus.error;
item?.errorMessage = error;
// 2. 渲染错误卡片
final errorCard = UiCard(
cardType: 'error_card.v1',
data: {'message': error},
);
// 3. 触发 UI 更新
notifyListeners();
}
```
### 6.2 事件流重连
```dart
// 断线重连时从 snapshot 恢复
Future<void> reconnect() async {
final snapshot = await _fetchMessagesSnapshot();
_chatItems.clear();
_chatItems.addAll(snapshot.messages);
// 重新订阅事件流
_subscribeToEvents();
}
```
## 7. 实施计划
### Phase 1: 基础框架
- [ ] 定义 AG-UI 事件模型
- [ ] 实现 AgUiService 基础结构
- [ ] 实现 ToolRegistry
### Phase 2: Mock 实现
- [ ] 实现 AiDecisionEngine 规则引擎
- [ ] 实现 Mock 事件流
- [ ] 集成现有 HomeScreen
### Phase 3: UI 渲染
- [ ] 实现 UiSchemaParser
- [ ] 实现 CalendarCardWidget
- [ ] 实现 ToolPending / ToolError 状态卡片
### Phase 4: 持久化
- [ ] 实现 ChatHistoryRepository
- [ ] 实现会话恢复
### Phase 5: 真实后端对接
- [ ] 实现 SSE 客户端
- [ ] 实现事件流解析器
## 8. 版本历史
| 版本 | 日期 | 变更 |
|------|------|------|
| v1.0 | 2026-02-28 | 初始版本 |
File diff suppressed because it is too large Load Diff
@@ -1,714 +0,0 @@
# Calendar Sharing Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 实现日历事件分享功能 - 用户可以分享日历事件给其他人(通过邮箱),被邀请人会收到待办消息,可以同意或忽略邀请。
**Architecture:** 使用现有的 schemas/repository/service/router 分层架构。新增 inbox_messages 模块处理邀请消息。复用 auth gateway 的 get_user_by_email 通过邮箱查找用户。
**Tech Stack:** FastAPI, SQLAlchemy (async), Pydantic, Supabase Auth
---
## Permission Bits (from design doc)
| Permission | Value | Binary |
|------------|-------|--------|
| view | 1 | 001 |
| invite | 2 | 010 |
| edit | 4 | 100 |
- Owner has all permissions: 7 (111)
- Check permission: `permission & 2 == 2` (has invite)
- Add permission: `permission | 2`
---
## Task 1: Add inbox_messages module (schemas, repository, service, router)
**Files:**
- Create: `backend/src/v1/inbox_messages/__init__.py`
- Create: `backend/src/v1/inbox_messages/schemas.py`
- Create: `backend/src/v1/inbox_messages/repository.py`
- Create: `backend/src/v1/inbox_messages/service.py`
- Create: `backend/src/v1/inbox_messages/router.py`
- Modify: `backend/src/v1/router.py` - include inbox_messages router
**Step 1: Write the failing test**
```python
# backend/tests/unit/v1/inbox_messages/test_schemas.py
import pytest
from uuid import uuid4
from v1.inbox_messages.schemas import (
InboxMessageResponse,
InboxMessageListRequest,
InboxMessageAcceptRequest,
)
def test_inbox_message_response_schema():
msg_id = uuid4()
response = InboxMessageResponse(
id=msg_id,
recipient_id=uuid4(),
sender_id=uuid4(),
message_type="calendar",
schedule_item_id=uuid4(),
content="Join my calendar",
is_read=False,
status="pending",
)
assert response.message_type == "calendar"
assert response.status == "pending"
def test_inbox_message_accept_request_schema():
request = InboxMessageAcceptRequest(
permission_view=True,
permission_edit=False,
permission_invite=False,
)
assert request.permission_view is True
assert request.permission_edit is False
```
**Step 2: Run test to verify it fails**
Run: `cd backend && uv run pytest tests/unit/v1/inbox_messages/test_schemas.py -v`
Expected: FAIL with "ModuleNotFoundError: No module named 'v1.inbox_messages'"
**Step 3: Write minimal implementation**
Create `backend/src/v1/inbox_messages/__init__.py`:
```python
```
Create `backend/src/v1/inbox_messages/schemas.py`:
```python
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
class InboxMessageType(str, Enum):
FRIEND_REQUEST = "friend_request"
CALENDAR = "calendar"
SYSTEM = "system"
GROUP = "group"
class InboxMessageStatus(str, Enum):
PENDING = "pending"
ACCEPTED = "accepted"
REJECTED = "rejected"
DISMISSED = "dismissed"
class InboxMessageResponse(BaseModel):
id: UUID
recipient_id: UUID
sender_id: Optional[UUID] = None
message_type: InboxMessageType
schedule_item_id: Optional[UUID] = None
content: Optional[str] = None
is_read: bool = False
status: InboxMessageStatus = InboxMessageStatus.PENDING
created_at: datetime
class InboxMessageListRequest(BaseModel):
status: Optional[InboxMessageStatus] = None
class InboxMessageAcceptRequest(BaseModel):
permission_view: bool = True
permission_edit: bool = False
permission_invite: bool = False
```
Create `backend/src/v1/inbox_messages/repository.py`:
```python
from __future__ import annotations
from typing import Optional
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.inbox_messages import InboxMessage, InboxMessageStatus
class InboxMessageRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def create(self, data: dict) -> InboxMessage:
msg = InboxMessage(**data)
self._session.add(msg)
await self._session.flush()
return msg
async def get_by_id(self, message_id: UUID, recipient_id: UUID) -> Optional[InboxMessage]:
result = await self._session.execute(
select(InboxMessage).where(
InboxMessage.id == message_id,
InboxMessage.recipient_id == recipient_id,
)
)
return result.scalar_one_or_none()
async def list_by_recipient(
self, recipient_id: UUID, status: Optional[InboxMessageStatus] = None
) -> list[InboxMessage]:
query = select(InboxMessage).where(InboxMessage.recipient_id == recipient_id)
if status:
query = query.where(InboxMessage.status == status)
query = query.order_by(InboxMessage.created_at.desc())
result = await self._session.execute(query)
return list(result.scalars().all())
async def update_status(
self, message_id: UUID, recipient_id: UUID, status: InboxMessageStatus
) -> Optional[InboxMessage]:
msg = await self.get_by_id(message_id, recipient_id)
if msg:
msg.status = status
await self._session.flush()
return msg
```
Create `backend/src/v1/inbox_messages/service.py`:
```python
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser
from core.db.base_service import BaseService
from core.logging import get_logger
from models.inbox_messages import InboxMessageStatus
from v1.inbox_messages.repository import InboxMessageRepository
from v1.inbox_messages.schemas import (
InboxMessageAcceptRequest,
InboxMessageListRequest,
InboxMessageResponse,
)
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger("v1.inbox_messages.service")
class InboxMessageService(BaseService):
_repository: InboxMessageRepository
_session: AsyncSession
def __init__(
self,
repository: InboxMessageRepository,
session: AsyncSession,
current_user: Optional[CurrentUser] = None,
) -> None:
super().__init__(current_user=current_user)
self._repository = repository
self._session = session
async def list_messages(
self, request: InboxMessageListRequest
) -> list[InboxMessageResponse]:
user_id = self.require_user_id()
try:
messages = await self._repository.list_by_recipient(
user_id, request.status
)
except SQLAlchemyError:
logger.exception("Failed to list inbox messages")
raise HTTPException(status_code=503, detail="Inbox unavailable")
return [
InboxMessageResponse(
id=m.id,
recipient_id=m.recipient_id,
sender_id=m.sender_id,
message_type=m.message_type,
schedule_item_id=m.schedule_item_id,
content=m.content,
is_read=m.is_read,
status=m.status,
created_at=m.created_at,
)
for m in messages
]
async def accept_invitation(
self, message_id: UUID, request: InboxMessageAcceptRequest
) -> None:
user_id = self.require_user_id()
try:
message = await self._repository.get_by_id(message_id, user_id)
except SQLAlchemyError:
logger.exception("Failed to get inbox message", message_id=str(message_id))
raise HTTPException(status_code=503, detail="Inbox unavailable")
if message is None:
raise HTTPException(status_code=404, detail="Message not found")
if message.message_type != InboxMessageStatus.PENDING:
raise HTTPException(status_code=400, detail="Message already processed")
message.status = InboxMessageStatus.ACCEPTED
await self._session.flush()
await self._session.commit()
async def dismiss_invitation(self, message_id: UUID) -> None:
user_id = self.require_user_id()
try:
message = await self._repository.get_by_id(message_id, user_id)
except SQLAlchemyError:
logger.exception("Failed to get inbox message", message_id=str(message_id))
raise HTTPException(status_code=503, detail="Inbox unavailable")
if message is None:
raise HTTPException(status_code=404, detail="Message not found")
message.status = InboxMessageStatus.DISMISSED
await self._session.flush()
await self._session.commit()
```
Create `backend/src/v1/inbox_messages/dependencies.py`:
```python
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.dependencies import get_current_user
from core.db.session import get_db
from models.auth.models import CurrentUser
from v1.inbox_messages.repository import InboxMessageRepository
from v1.inbox_messages.service import InboxMessageService
def get_inbox_message_repository(
session: Annotated[AsyncSession, Depends(get_db)]
) -> InboxMessageRepository:
return InboxMessageRepository(session)
def get_inbox_message_service(
repository: Annotated[InboxMessageRepository, Depends(get_inbox_message_repository)],
current_user: Annotated[CurrentUser | None, Depends(get_current_user)],
) -> InboxMessageService:
return InboxMessageService(
repository=repository,
session=repository._session,
current_user=current_user,
)
```
Create `backend/src/v1/inbox_messages/router.py`:
```python
from __future__ import annotations
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from v1.inbox_messages.dependencies import get_inbox_message_service
from v1.inbox_messages.schemas import (
InboxMessageAcceptRequest,
InboxMessageListRequest,
InboxMessageResponse,
InboxMessageStatus,
)
from v1.inbox_messages.service import InboxMessageService
router = APIRouter(prefix="/inbox", tags=["inbox"])
@router.get("/messages", response_model=list[InboxMessageResponse])
async def list_inbox_messages(
service: Annotated[InboxMessageService, Depends(get_inbox_message_service)],
status: InboxMessageStatus | None = Query(None, description="Filter by status"),
) -> list[InboxMessageResponse]:
request = InboxMessageListRequest(status=status)
return await service.list_messages(request)
@router.post("/messages/{message_id}/accept", status_code=204)
async def accept_invitation(
message_id: UUID,
request: InboxMessageAcceptRequest,
service: Annotated[InboxMessageService, Depends(get_inbox_message_service)],
) -> None:
await service.accept_invitation(message_id, request)
@router.post("/messages/{message_id}/dismiss", status_code=204)
async def dismiss_invitation(
message_id: UUID,
service: Annotated[InboxMessageService, Depends(get_inbox_message_service)],
) -> None:
await service.dismiss_invitation(message_id)
```
Modify `backend/src/v1/router.py`:
```python
from fastapi import APIRouter
from core.http.models import HealthResponse
from v1.agent_chat.router import router as agent_chat_router
from v1.auth.router import router as auth_router
from v1.infra.router import router as infra_router
from v1.inbox_messages.router import router as inbox_messages_router
from v1.schedule_items.router import router as schedule_items_router
from v1.users.router import router as users_router
router = APIRouter(prefix="/api/v1")
router.include_router(auth_router)
router.include_router(infra_router)
router.include_router(users_router)
router.include_router(agent_chat_router)
router.include_router(schedule_items_router)
router.include_router(inbox_messages_router)
@router.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
return HealthResponse(status="ok")
```
**Step 4: Run test to verify it passes**
Run: `cd backend && uv run pytest tests/unit/v1/inbox_messages/test_schemas.py -v`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/src/v1/inbox_messages/ backend/src/v1/router.py
git commit -m "feat: add inbox messages module for calendar invitations"
```
---
## Task 2: Add share calendar API to schedule_items
**Files:**
- Modify: `backend/src/v1/schedule_items/schemas.py` - add share schemas
- Modify: `backend/src/v1/schedule_items/repository.py` - add subscription create
- Modify: `backend/src/v1/schedule_items/service.py` - add share method
- Modify: `backend/src/v1/schedule_items/router.py` - add share endpoint
- Modify: `backend/src/v1/schedule_items/dependencies.py` - add dependencies
**Step 1: Write the failing test**
```python
# backend/tests/unit/v1/schedule_items/test_share.py
import pytest
from uuid import uuid4
from v1.schedule_items.schemas import ScheduleItemShareRequest
def test_share_request_schema():
request = ScheduleItemShareRequest(
email="friend@example.com",
permission_view=True,
permission_edit=True,
permission_invite=False,
)
assert request.email == "friend@example.com"
assert request.permission_view is True
def test_permission_bits_calculation():
request = ScheduleItemShareRequest(
email="friend@example.com",
permission_view=True,
permission_edit=True,
permission_invite=False,
)
# view=1, edit=4, invite=0 -> 1|4 = 5
assert request._permission_value() == 5
```
**Step 2: Run test to verify it fails**
Run: `cd backend && uv run pytest tests/unit/v1/schedule_items/test_share.py -v`
Expected: FAIL with "cannot import 'ScheduleItemShareRequest'"
**Step 3: Write minimal implementation**
Add to `backend/src/v1/schedule_items/schemas.py`:
```python
class ScheduleItemShareRequest(BaseModel):
email: str = Field(..., description="Email of user to share with")
permission_view: bool = Field(True, description="Grant view permission")
permission_edit: bool = Field(False, description="Grant edit permission")
permission_invite: bool = Field(False, description="Grant invite permission")
def _permission_value(self) -> int:
value = 0
if self.permission_view:
value |= 1 # 001
if self.permission_edit:
value |= 4 # 100
if self.permission_invite:
value |= 2 # 010
return value
class ScheduleItemShareResponse(BaseModel):
message: str
```
Add to `backend/src/v1/schedule_items/repository.py`:
```python
from models.schedule_subscriptions import ScheduleSubscription
class ScheduleItemRepository:
# ... existing code ...
async def create_subscription(self, data: dict) -> ScheduleSubscription:
sub = ScheduleSubscription(**data)
self._session.add(sub)
await self._session.flush()
return sub
```
Add to `backend/src/v1/schedule_items/service.py`:
```python
from uuid import UUID
from core.auth.models import CurrentUser
from v1.auth.gateway import SupabaseAuthGateway
from models.schedule_subscriptions import ScheduleSubscription
class ScheduleItemService:
# ... existing code ...
async def share(
self, item_id: UUID, request: ScheduleItemShareRequest
) -> ScheduleItemShareResponse:
user_id = self.require_user_id()
# Check item exists and user is owner
try:
item = await self._repository.get_by_item_id(item_id, user_id)
except SQLAlchemyError:
logger.exception("Failed to get schedule item", item_id=str(item_id))
raise HTTPException(status_code=503, detail="Schedule item store unavailable")
if item is None:
raise HTTPException(status_code=404, detail="Schedule item not found")
if item.owner_id != user_id:
raise HTTPException(status_code=403, detail="Only owner can share")
# Lookup user by email
auth_gateway = SupabaseAuthGateway()
try:
target_user = await auth_gateway.get_user_by_email(request.email)
except HTTPException as exc:
if exc.status_code == 404:
raise HTTPException(status_code=404, detail="User not found")
raise
target_user_id = UUID(target_user.id)
# Create inbox message
from models.inbox_messages import InboxMessage, InboxMessageType
inbox_data = {
"recipient_id": target_user_id,
"sender_id": user_id,
"message_type": InboxMessageType.CALENDAR,
"schedule_item_id": item_id,
"content": f"{item.title} shared with you",
"created_by": user_id,
}
try:
inbox_msg = InboxMessage(**inbox_data)
self._session.add(inbox_msg)
await self._session.flush()
except SQLAlchemyError:
logger.exception("Failed to create inbox message")
raise HTTPException(status_code=503, detail="Failed to send invitation")
await self._session.commit()
return ScheduleItemShareResponse(
message=f"Invitation sent to {request.email}"
)
```
Add to `backend/src/v1/schedule_items/router.py`:
```python
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
ScheduleItemListItem,
ScheduleItemListRequest,
ScheduleItemResponse,
ScheduleItemShareRequest,
ScheduleItemShareResponse,
ScheduleItemUpdateRequest,
)
@router.post("/{item_id}/share", response_model=ScheduleItemShareResponse)
async def share_schedule_item(
item_id: UUID,
request: ScheduleItemShareRequest,
service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)],
) -> ScheduleItemShareResponse:
return await service.share(item_id, request)
```
**Step 4: Run test to verify it passes**
Run: `cd backend && uv run pytest tests/unit/v1/schedule_items/test_share.py -v`
Expected: PASS
**Step 5: Commit**
```bash
git add backend/src/v1/schedule_items/
git commit -m "feat: add share calendar API"
```
---
## Task 3: Add accept invitation - create subscription
**Files:**
- Modify: `backend/src/v1/inbox_messages/service.py` - add subscription creation on accept
**Step 1: Write the failing test**
```python
# backend/tests/unit/v1/inbox_messages/test_accept_invitation.py
import pytest
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
from v1.inbox_messages.service import InboxMessageService
@pytest.mark.asyncio
async def test_accept_creates_subscription():
# Setup mocks
mock_repo = MagicMock()
mock_session = MagicMock()
mock_message = MagicMock()
mock_message.id = uuid4()
mock_message.message_type = "calendar"
mock_message.status = "pending"
mock_message.schedule_item_id = uuid4()
mock_repo.get_by_id = AsyncMock(return_value=mock_message)
mock_repo._session = mock_session
service = InboxMessageService(
repository=mock_repo,
session=mock_session,
current_user=MagicMock(user_id=uuid4()),
)
# This should be implemented
await service.accept_invitation(mock_message.id, ...)
```
**Step 2: Run test to verify it fails**
Expected: FAIL (test will fail because accept doesn't create subscription yet)
**Step 3: Write implementation**
Modify `backend/src/v1/inbox_messages/service.py` to import ScheduleSubscriptionRepository and create subscription on accept.
**Step 4: Run test to verify it passes**
Run tests and verify pass.
**Step 5: Commit**
---
## Task 4: Fix permission enum reference bug
**Files:**
- Modify: `backend/src/v1/inbox_messages/service.py` - fix InboxMessageStatus reference
**Bug:** In Task 1, we used `InboxMessageStatus.PENDING` but should check against the actual enum type. Fix the bug.
**Step 1: Write test to verify bug**
```python
def test_accept_checks_message_type_not_status():
# Current code incorrectly checks message_type == PENDING
# Should check status == PENDING
```
**Step 2: Fix the implementation**
---
## Task 5: Write unit tests
**Files:**
- Create: `backend/tests/unit/v1/inbox_messages/test_service.py`
- Create: `backend/tests/unit/v1/schedule_items/test_share.py`
---
## Task 6: Write integration tests
**Files:**
- Create: `backend/tests/integration/test_inbox_messages_routes.py`
- Create: `backend/tests/integration/test_schedule_share_routes.py`
---
## Task 7: Update API documentation
**Files:**
- Modify: `docs/runtime/runtime-route.md` - add share/inbox endpoints
---
## Task 8: Run all tests and fix issues
Run full test suite and fix any issues.
---
## Task 9: Run lint and typecheck
Run:
```bash
cd backend && uv run ruff check src/v1/schedule_items/ src/v1/inbox_messages/
cd backend && uv run basedpyright src/v1/schedule_items/ src/v1/inbox_messages/
```
---
## Task 10: Final commit and create PR
```bash
git add .
git commit -m "feat: add calendar sharing with invitations"
git push -u origin feature-calendar-sharing
gh pr create --title "feat: add calendar sharing" --body "..."
```
-136
View File
@@ -1,136 +0,0 @@
# 好友申请与待办消息功能设计
**Date:** 2026-02-28
**Status:** Approved
## 1. 数据模型
### Friendship 表 (已存在)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID | 主键 |
| user_low_id | UUID | 用户A ID (固定排序小值) |
| user_high_id | UUID | 用户B ID (固定排序大值) |
| initiator_id | UUID? | 发起请求者 |
| status | VARCHAR(20) | pending/accepted/blocked/declined/canceled |
| requested_at | TIMESTAMP? | 请求时间 |
| accepted_at | TIMESTAMP? | 接受时间 |
| blocked_by | UUID? | 被谁屏蔽 |
| created_by/updated_by | UUID? | 审计字段 |
### InboxMessage 表 (复用)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID | 主键 |
| recipient_id | UUID | 接收方 |
| sender_id | UUID? | 发送方 |
| message_type | VARCHAR(20) | FRIEND_REQUEST / CALENDAR / SYSTEM / GROUP |
| friendship_id | UUID? | 关联 Friendship |
| content | TEXT? | 附加消息 |
| is_read | BOOLEAN | 已读状态 |
| status | VARCHAR(20) | pending/accepted/rejected/dismissed |
## 2. API 设计
| 方法 | 路径 | 功能 |
|------|------|------|
| POST | /friends/requests | 发送好友请求 |
| GET | /friends/requests/outgoing | 获取我发出的请求 |
| GET | /friends/requests/inbox | 获取收到的好友请求 |
| POST | /friends/requests/{id}/accept | 接受好友请求 |
| POST | /friends/requests/{id}/decline | 拒绝好友请求 |
| DELETE | /friends/requests/{id} | 取消我的请求 |
| GET | /friends | 获取好友列表 |
| DELETE | /friends/{id} | 删除好友 |
## 3. 业务逻辑流程
### 3.1 发送好友请求
```
1. 验证 target_user_id != current_user_id
2. 检查是否已存在 Friendship 记录
- 已 accepted: 返回 409
- 已 pending: 返回 409
- 已 blocked: 返回 403
3. 创建 Friendship (status=pending, initiator_id=current_user)
4. 创建 InboxMessage (message_type=FRIEND_REQUEST, recipient=target_user)
5. 提交事务
```
### 3.2 接受好友请求
```
1. 查询 Friendship 和 InboxMessage
2. 验证 current_user == recipient
3. 更新 Friendship (status=accepted, accepted_at=now)
4. 更新 InboxMessage (status=accepted)
5. 提交事务
```
### 3.3 拒绝好友请求
```
1. 查询 Friendship 和 InboxMessage
2. 验证 current_user == recipient
3. 更新 Friendship (status=declined)
4. 更新 InboxMessage (status=rejected)
5. 提交事务
```
### 3.4 获取好友列表
```
查询 Friendship WHERE (user_low_id=current OR user_high_id=current) AND status=accepted
```
## 4. 响应 Schema
### FriendRequestResponse
```python
{
"id": "uuid",
"sender": {"id": "uuid", "username": "string", "avatar_url": "string?"},
"recipient": {"id": "uuid", "username": "string", "avatar_url": "string?"},
"content": "string?",
"status": "pending",
"created_at": "datetime"
}
```
### FriendResponse
```python
{
"id": "uuid",
"friend": {"id": "uuid", "username": "string", "avatar_url": "string?"},
"status": "accepted",
"created_at": "datetime",
"accepted_at": "datetime?"
}
```
## 5. 边界处理
| 场景 | 状态码 | 响应 |
|------|--------|------|
| 对自己发送请求 | 400 | Cannot send friend request to yourself |
| 已是好友 | 409 | Already friends |
| 已有待处理请求 | 409 | Friend request already exists |
| 被对方屏蔽 | 403 | Blocked by user |
| 请求不存在 | 404 | Friend request not found |
| 无权限操作 | 403 | Not authorized |
## 6. 测试用例
### 单元测试
- FriendshipService 业务逻辑
- 状态转换验证
- 边界条件处理
### 集成测试
- POST /friends/requests - 成功/失败场景
- GET /friends/requests/inbox - 返回正确列表
- POST /friends/requests/{id}/accept - 状态更新
- DELETE /friends/{id} - 删除好友
@@ -1,870 +0,0 @@
# 好友申请功能实现计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 实现好友申请、待办消息、添加/删除好友等系列功能的后端API
**Architecture:** 使用 repository/service/router 模式,复用已有的 Friendship 和 InboxMessage 模型,通过 inbox_messages 表存储好友请求通知
**Tech Stack:** FastAPI, SQLAlchemy, Pydantic
---
### Task 1: 创建 friendships 模块目录结构和基础文件
**Files:**
- Create: `backend/src/v1/friendships/__init__.py`
- Create: `backend/src/v1/friendships/schemas.py`
- Create: `backend/src/v1/friendships/repository.py`
- Create: `backend/src/v1/friendships/service.py`
- Create: `backend/src/v1/friendships/dependencies.py`
- Create: `backend/src/v1/friendships/router.py`
**Step 1: 创建目录和基础 schema**
```python
# backend/src/v1/friendships/__init__.py
```
**Step 2: 创建 Pydantic schemas**
```python
# backend/src/v1/friendships/schemas.py
from __future__ import annotations
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
class UserBasicInfo(BaseModel):
id: str
username: str
avatar_url: Optional[str] = None
class FriendRequestCreate(BaseModel):
target_user_id: UUID
content: Optional[str] = Field(None, max_length=200)
class FriendRequestResponse(BaseModel):
id: UUID
sender: UserBasicInfo
recipient: UserBasicInfo
content: Optional[str]
status: str
created_at: datetime
class FriendResponse(BaseModel):
id: UUID
friend: UserBasicInfo
status: str
created_at: datetime
accepted_at: Optional[datetime]
class FriendRequestAction(BaseModel):
# For accept/decline - no body needed but kept for extensibility
pass
```
**Step 3: Commit**
```bash
git add backend/src/v1/friendships/
git commit -m "feat(friendships): create module structure and schemas"
```
---
### Task 2: 实现 FriendshipRepository
**Files:**
- Modify: `backend/src/v1/friendships/repository.py`
**Step 1: 写入失败的测试**
```python
# backend/tests/unit/v1/friendships/test_friendship_repository.py
import pytest
from uuid import uuid4
from v1.friendships.repository import FriendshipRepository
@pytest.fixture
def mock_session():
# Create mock async session
pass
@pytest.mark.asyncio
async def test_create_friendship_request(mock_session):
repository = FriendshipRepository(mock_session)
# Test creating friendship request
pass
@pytest.mark.asyncio
async def test_get_pending_request_between_users(mock_session):
repository = FriendshipRepository(mock_session)
# Test checking existing requests
pass
```
**Step 2: 运行测试确认失败**
**Step 3: 实现 repository**
```python
# backend/src/v1/friendships/repository.py
from __future__ import annotations
from typing import Optional
from uuid import UUID
from sqlalchemy import select, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from models.friendships import Friendship, FriendshipStatus
from models.inbox_messages import InboxMessage, InboxMessageType, InboxMessageStatus
class FriendshipRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def create_request(
self,
user_low_id: UUID,
user_high_id: UUID,
initiator_id: UUID,
recipient_id: UUID,
content: Optional[str] = None,
) -> tuple[Friendship, InboxMessage]:
friendship = Friendship(
user_low_id=user_low_id,
user_high_id=user_high_id,
initiator_id=initiator_id,
status=FriendshipStatus.PENDING,
)
self._session.add(friendship)
await self._session.flush()
inbox_message = InboxMessage(
recipient_id=recipient_id,
sender_id=initiator_id,
message_type=InboxMessageType.FRIEND_REQUEST,
friendship_id=friendship.id,
content=content,
status=InboxMessageStatus.PENDING,
)
self._session.add(inbox_message)
return friendship, inbox_message
async def get_friendship_between_users(
self, user_a_id: UUID, user_b_id: UUID
) -> Optional[Friendship]:
low_id = min(user_a_id, user_b_id)
high_id = max(user_a_id, user_b_id)
stmt = select(Friendship).where(
and_(
Friendship.user_low_id == low_id,
Friendship.user_high_id == high_id,
)
)
result = await self._session.execute(stmt)
return result.scalar_one_or_none()
async def get_pending_inbox_for_recipient(
self, friendship_id: UUID, recipient_id: UUID
) -> Optional[InboxMessage]:
stmt = select(InboxMessage).where(
and_(
InboxMessage.friendship_id == friendship_id,
InboxMessage.recipient_id == recipient_id,
InboxMessage.message_type == InboxMessageType.FRIEND_REQUEST,
)
)
result = await self._session.execute(stmt)
return result.scalar_one_or_none()
async def get_friendship_by_id(self, friendship_id: UUID) -> Optional[Friendship]:
stmt = select(Friendship).where(Friendship.id == friendship_id)
result = await self._session.execute(stmt)
return result.scalar_one_or_none()
async def get_inbox_messages_for_user(
self, user_id: UUID, status: Optional[InboxMessageStatus] = None
) -> list[InboxMessage]:
stmt = select(InboxMessage).where(
and_(
InboxMessage.recipient_id == user_id,
InboxMessage.message_type == InboxMessageType.FRIEND_REQUEST,
)
)
if status:
stmt = stmt.where(InboxMessage.status == status)
stmt = stmt.order_by(InboxMessage.created_at.desc())
result = await self._session.execute(stmt)
return list(result.scalars().all())
async def get_outgoing_requests(
self, user_id: UUID, status: Optional[FriendshipStatus] = None
) -> list[Friendship]:
stmt = select(Friendship).where(Friendship.initiator_id == user_id)
if status:
stmt = stmt.where(Friendship.status == status)
else:
stmt = stmt.where(Friendship.status == FriendshipStatus.PENDING)
stmt = stmt.order_by(Friendship.created_at.desc())
result = await self._session.execute(stmt)
return list(result.scalars().all())
async def get_friends_list(self, user_id: UUID) -> list[Friendship]:
stmt = select(Friendship).where(
or_(
Friendship.user_low_id == user_id,
Friendship.user_high_id == user_id,
),
Friendship.status == FriendshipStatus.ACCEPTED,
).order_by(Friendship.updated_at.desc())
result = await self._session.execute(stmt)
return list(result.scalars().all())
```
**Step 4: 运行测试确认通过**
**Step 5: Commit**
```bash
git add backend/src/v1/friendships/repository.py backend/tests/unit/v1/friendships/
git commit -m "feat(friendships): implement repository"
```
---
### Task 3: 实现 FriendshipService
**Files:**
- Modify: `backend/src/v1/friendships/service.py`
**Step 1: 写入失败的测试**
```python
# backend/tests/unit/v1/friendships/test_friendship_service.py
import pytest
from uuid import uuid4
from v1.friendships.service import FriendshipService
@pytest.fixture
def mock_repository():
pass
@pytest.mark.asyncio
async def test_send_friend_request_success(mock_repository):
service = FriendshipService(mock_repository, current_user)
# Test successful friend request
pass
@pytest.mark.asyncio
async def test_send_friend_request_to_self_fails():
# Test that sending to self returns 400
pass
@pytest.mark.asyncio
async def test_send_friend_request_when_already_friends():
# Test that sending to existing friend returns 409
pass
```
**Step 2: 运行测试确认失败**
**Step 3: 实现 service**
```python
# backend/src/v1/friendships/service.py
from __future__ import annotations
from datetime import datetime
from typing import Optional
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.models import CurrentUser
from core.db.base_service import BaseService
from core.logging import get_logger
from models.friendships import Friendship, FriendshipStatus
from models.inbox_messages import InboxMessageStatus
from models.profile import Profile
from v1.friendships.repository import FriendshipRepository
from v1.friendships.schemas import (
FriendRequestCreate,
FriendRequestResponse,
FriendResponse,
UserBasicInfo,
)
logger = get_logger("v1.friendships.service")
class FriendshipService(BaseService):
def __init__(
self,
repository: FriendshipRepository,
session: AsyncSession,
current_user: CurrentUser,
) -> None:
super().__init__(current_user=current_user)
self._repository = repository
self._session = session
async def send_request(
self, payload: FriendRequestCreate
) -> FriendRequestResponse:
current_user_id = self.require_user_id()
target_user_id = payload.target_user_id
if current_user_id == target_user_id:
raise HTTPException(
status_code=400,
detail="Cannot send friend request to yourself"
)
# Check existing relationship
existing = await self._repository.get_friendship_between_users(
current_user_id, target_user_id
)
if existing:
if existing.status == FriendshipStatus.ACCEPTED:
raise HTTPException(status_code=409, detail="Already friends")
if existing.status == FriendshipStatus.PENDING:
raise HTTPException(status_code=409, detail="Friend request already exists")
if existing.status == FriendshipStatus.BLOCKED:
raise HTTPException(status_code=403, detail="Blocked by user")
user_low_id = min(current_user_id, target_user_id)
user_high_id = max(current_user_id, target_user_id)
friendship, inbox = await self._repository.create_request(
user_low_id=user_low_id,
user_high_id=user_high_id,
initiator_id=current_user_id,
recipient_id=target_user_id,
content=payload.content,
)
await self._session.commit()
sender_info = await self._get_profile_info(current_user_id)
recipient_info = await self._get_profile_info(target_user_id)
return FriendRequestResponse(
id=friendship.id,
sender=sender_info,
recipient=recipient_info,
content=payload.content,
status=friendship.status.value,
created_at=friendship.created_at,
)
async def accept_request(self, friendship_id: UUID) -> FriendRequestResponse:
current_user_id = self.require_user_id()
friendship = await self._repository.get_friendship_by_id(friendship_id)
if not friendship:
raise HTTPException(status_code=404, detail="Friend request not found")
# Determine recipient - must be the current user
recipient_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
if recipient_id != current_user_id:
raise HTTPException(status_code=403, detail="Not authorized")
inbox = await self._repository.get_pending_inbox_for_recipient(
friendship_id, current_user_id
)
friendship.status = FriendshipStatus.ACCEPTED
friendship.accepted_at = datetime.utcnow()
if inbox:
inbox.status = InboxMessageStatus.ACCEPTED
await self._session.commit()
initiator_info = await self._get_profile_info(friendship.initiator_id)
recipient_info = await self._get_profile_info(current_user_id)
return FriendRequestResponse(
id=friendship.id,
sender=initiator_info,
recipient=recipient_info,
content=inbox.content if inbox else None,
status=friendship.status.value,
created_at=friendship.created_at,
)
async def decline_request(self, friendship_id: UUID) -> FriendRequestResponse:
current_user_id = self.require_user_id()
friendship = await self._repository.get_friendship_by_id(friendship_id)
if not friendship:
raise HTTPException(status_code=404, detail="Friend request not found")
recipient_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
if recipient_id != current_user_id:
raise HTTPException(status_code=403, detail="Not authorized")
inbox = await self._repository.get_pending_inbox_for_recipient(
friendship_id, current_user_id
)
friendship.status = FriendshipStatus.DECLINED
if inbox:
inbox.status = InboxMessageStatus.REJECTED
await self._session.commit()
initiator_info = await self._get_profile_info(friendship.initiator_id)
recipient_info = await self._get_profile_info(current_user_id)
return FriendRequestResponse(
id=friendship.id,
sender=initiator_info,
recipient=recipient_info,
content=inbox.content if inbox else None,
status=friendship.status.value,
created_at=friendship.created_at,
)
async def cancel_request(self, friendship_id: UUID) -> None:
current_user_id = self.require_user_id()
friendship = await self._repository.get_friendship_by_id(friendship_id)
if not friendship:
raise HTTPException(status_code=404, detail="Friend request not found")
if friendship.initiator_id != current_user_id:
raise HTTPException(status_code=403, detail="Not authorized")
if friendship.status != FriendshipStatus.PENDING:
raise HTTPException(status_code=400, detail="Can only cancel pending requests")
inbox = await self._repository.get_pending_inbox_for_recipient(
friendship_id, friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
)
friendship.status = FriendshipStatus.CANCELED
if inbox:
inbox.status = InboxMessageStatus.DISMISSED
await self._session.commit()
async def get_inbox(self) -> list[FriendRequestResponse]:
current_user_id = self.require_user_id()
inbox_messages = await self._repository.get_pending_inbox_for_user(
current_user_id, InboxMessageStatus.PENDING
)
results = []
for msg in inbox_messages:
friendship = await self._repository.get_friendship_by_id(msg.friendship_id)
if not friendship:
continue
sender_info = await self._get_profile_info(msg.sender_id)
recipient_info = await self._get_profile_info(current_user_id)
results.append(FriendRequestResponse(
id=friendship.id,
sender=sender_info,
recipient=recipient_info,
content=msg.content,
status=msg.status.value,
created_at=msg.created_at,
))
return results
async def get_outgoing_requests(self) -> list[FriendRequestResponse]:
current_user_id = self.require_user_id()
friendships = await self._repository.get_outgoing_requests(current_user_id)
results = []
for friendship in friendships:
sender_info = await self._get_profile_info(current_user_id)
recipient_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
recipient_info = await self._get_profile_info(recipient_id)
inbox = await self._repository.get_pending_inbox_for_recipient(
friendship.id, recipient_id
)
results.append(FriendRequestResponse(
id=friendship.id,
sender=sender_info,
recipient=recipient_info,
content=inbox.content if inbox else None,
status=friendship.status.value,
created_at=friendship.created_at,
))
return results
async def get_friends_list(self) -> list[FriendResponse]:
current_user_id = self.require_user_id()
friendships = await self._repository.get_friends_list(current_user_id)
results = []
for friendship in friendships:
friend_id = friendship.user_low_id if friendship.user_high_id == current_user_id else friendship.user_high_id
friend_info = await self._get_profile_info(friend_id)
results.append(FriendResponse(
id=friendship.id,
friend=friend_info,
status=friendship.status.value,
created_at=friendship.created_at,
accepted_at=friendship.accepted_at,
))
return results
async def remove_friend(self, friendship_id: UUID) -> None:
current_user_id = self.require_user_id()
friendship = await self._repository.get_friendship_by_id(friendship_id)
if not friendship:
raise HTTPException(status_code=404, detail="Friendship not found")
if friendship.status != FriendshipStatus.ACCEPTED:
raise HTTPException(status_code=400, detail="Can only remove accepted friends")
# Verify user is part of this friendship
if friendship.user_low_id != current_user_id and friendship.user_high_id != current_user_id:
raise HTTPException(status_code=403, detail="Not authorized")
# Soft delete - mark as canceled
friendship.status = FriendshipStatus.CANCELED
await self._session.commit()
async def _get_profile_info(self, user_id: UUID) -> UserBasicInfo:
from sqlalchemy import select
from models.profile import Profile
stmt = select(Profile).where(Profile.id == user_id)
result = await self._session.execute(stmt)
profile = result.scalar_one_or_none()
if not profile:
return UserBasicInfo(id=str(user_id), username="Unknown")
return UserBasicInfo(
id=str(profile.id),
username=profile.username,
avatar_url=profile.avatar_url,
)
```
**Step 4: 运行测试确认通过**
**Step 5: Commit**
```bash
git add backend/src/v1/friendships/service.py
git commit -m "feat(friendships): implement service layer"
```
---
### Task 4: 实现 Dependencies 和 Router
**Files:**
- Modify: `backend/src/v1/friendships/dependencies.py`
- Modify: `backend/src/v1/friendships/router.py`
**Step 1: 实现 dependencies**
```python
# backend/src/v1/friendships/dependencies.py
from __future__ import annotations
from typing import Annotated
from uuid import UUID
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.models import CurrentUser
from core.db import get_db
from v1.friendships.repository import FriendshipRepository
from v1.friendships.service import FriendshipService
from v1.users.dependencies import get_current_user
async def get_friendship_repository(
session: Annotated[AsyncSession, Depends(get_db)]
) -> FriendshipRepository:
return FriendshipRepository(session)
async def get_friendship_service(
repository: Annotated[FriendshipRepository, Depends(get_friendship_repository)],
session: Annotated[AsyncSession, Depends(get_db)],
user: Annotated[CurrentUser, Depends(get_current_user)],
) -> FriendshipService:
return FriendshipService(
repository=repository,
session=session,
current_user=user,
)
```
**Step 2: 实现 router**
```python
# backend/src/v1/friendships/router.py
from __future__ import annotations
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Path, HTTPException
from v1.friendships.dependencies import get_friendship_service
from v1.friendships.schemas import (
FriendRequestCreate,
FriendRequestResponse,
FriendResponse,
)
from v1.friendships.service import FriendshipService
router = APIRouter(prefix="/friends", tags=["friends"])
@router.post("/requests", response_model=FriendRequestResponse, status_code=201)
async def send_friend_request(
payload: FriendRequestCreate,
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> FriendRequestResponse:
return await service.send_request(payload)
@router.get("/requests/inbox", response_model=list[FriendRequestResponse])
async def get_inbox(
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> list[FriendRequestResponse]:
return await service.get_inbox()
@router.get("/requests/outgoing", response_model=list[FriendRequestResponse])
async def get_outgoing_requests(
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> list[FriendRequestResponse]:
return await service.get_outgoing_requests()
@router.post("/requests/{friendship_id}/accept", response_model=FriendRequestResponse)
async def accept_friend_request(
friendship_id: Annotated[UUID, Path()],
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> FriendRequestResponse:
return await service.accept_request(friendship_id)
@router.post("/requests/{friendship_id}/decline", response_model=FriendRequestResponse)
async def decline_friend_request(
friendship_id: Annotated[UUID, Path()],
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> FriendRequestResponse:
return await service.decline_request(friendship_id)
@router.delete("/requests/{friendship_id}", status_code=204)
async def cancel_friend_request(
friendship_id: Annotated[UUID, Path()],
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> None:
await service.cancel_request(friendship_id)
@router.get("", response_model=list[FriendResponse])
async def get_friends_list(
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> list[FriendResponse]:
return await service.get_friends_list()
@router.delete("/{friendship_id}", status_code=204)
async def remove_friend(
friendship_id: Annotated[UUID, Path()],
service: Annotated[FriendshipService, Depends(get_friendship_service)],
) -> None:
await service.remove_friend(friendship_id)
```
**Step 3: 注册 router 到主路由**
```python
# backend/src/v1/router.py
from fastapi import APIRouter
from v1.auth.router import router as auth_router
from v1.users.router import router as users_router
from v1.profile.router import router as profile_router
from v1.friendships.router import router as friendships_router
router = APIRouter()
router.include_router(auth_router)
router.include_router(users_router)
router.include_router(profile_router)
router.include_router(friendships_router)
```
**Step 4: Commit**
```bash
git add backend/src/v1/friendships/dependencies.py backend/src/v1/friendships/router.py backend/src/v1/router.py
git commit -m "feat(friendships): implement router and dependencies"
```
---
### Task 5: 集成测试
**Files:**
- Create: `backend/tests/integration/test_friendship_routes.py`
**Step 1: 写入测试**
```python
# backend/tests/integration/test_friendship_routes.py
import pytest
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.pool import StaticPool
from main import app # FastAPI app
from core.db.base import Base
from core.db import get_db
@pytest.fixture
async def async_client():
# Setup test database
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def override_get_db():
async with AsyncSession(engine) as session:
yield session
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_send_friend_request_requires_auth(async_client):
response = await async_client.post(
"/api/v1/friends/requests",
json={"target_user_id": "..."}
)
assert response.status_code == 401
# More tests...
```
**Step 2: 运行测试**
**Step 3: Commit**
---
### Task 6: 运行 Lint 和 Typecheck
**Step 1: 运行 ruff**
```bash
cd backend && uv run ruff check src/v1/friendships/
```
**Step 2: 运行 typecheck**
```bash
cd backend && uv run basedpyright src/v1/friendships/
```
**Step 3: Commit (if any fixes needed)**
---
### Task 7: 更新文档
**Files:**
- Modify: `docs/runtime/runtime-route.md`
**Step 1: 添加 API 文档**
```markdown
## Friends
### Send Friend Request
- **POST** `/api/v1/friends/requests`
- **Auth:** Required
- **Body:** `{ "target_user_id": "uuid", "content": "string?" }`
- **Response:** `FriendRequestResponse`
### Get Inbox
- **GET** `/api/v1/friends/requests/inbox`
- **Auth:** Required
- **Response:** `FriendRequestResponse[]`
### Accept Request
- **POST** `/api/v1/friends/requests/{id}/accept`
- **Auth:** Required
- **Response:** `FriendRequestResponse`
### Decline Request
- **POST** `/api/v1/friends/requests/{id}/decline`
- **Auth:** Required
- **Response:** `FriendRequestResponse`
### Get Friends List
- **GET** `/api/v1/friends`
- **Auth:** Required
- **Response:** `FriendResponse[]`
### Remove Friend
- **DELETE** `/api/v1/friends/{id}`
- **Auth:** Required
```
**Step 2: Commit**
```bash
git add docs/runtime/runtime-route.md
git commit -m "docs: add friendship API documentation"
```