chore: 清理opencode技能文件、旧设计文档并更新配置文档
This commit is contained in:
@@ -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_id,work 类型必须绑定 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 名称与运行文档
|
||||
@@ -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_by(owner_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: 更新注册请求 Schema(TDD)
|
||||
|
||||
**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 metadata(TDD)
|
||||
|
||||
**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: FAIL(metadata 未包含 `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
@@ -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 Schema(v1)
|
||||
|
||||
```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 "..."
|
||||
```
|
||||
@@ -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"
|
||||
```
|
||||
Reference in New Issue
Block a user