474 lines
13 KiB
Markdown
474 lines
13 KiB
Markdown
|
|
# Calendar Permission Refactoring Plan
|
|||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|||
|
|
|
|||
|
|
**Goal:** 统一权限判断逻辑,将 `is_owner` 和 `permission` 的双重判断合并为纯 `permission` 判断,并新增 Owner 徽章 UI。
|
|||
|
|
|
|||
|
|
**Architecture:**
|
|||
|
|
- 后端:扩展权限位掩码,添加 DELETE=8,OWNER=15。简化 Repository 和 Service 层的重复方法。
|
|||
|
|
- 前端:Model 层 `canEdit`/`canDelete` 改为纯 permission 判断,`isOwner` 仅保留用于 UI 徽章显示。
|
|||
|
|
- Protocol:更新位掩码说明文档。
|
|||
|
|
|
|||
|
|
**Tech Stack:** Python (FastAPI/SQLAlchemy), Flutter, Supabase
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 变更范围总结
|
|||
|
|
|
|||
|
|
### 后端 (5 个文件)
|
|||
|
|
|
|||
|
|
| 文件 | 改动 |
|
|||
|
|
|------|------|
|
|||
|
|
| `backend/src/schemas/enums.py` | `DELETE=8`, `OWNER=15` |
|
|||
|
|
| `backend/src/v1/schedule_items/service.py` | 移除双重判断,统一 permission 逻辑 |
|
|||
|
|
| `backend/src/v1/schedule_items/repository.py` | 合并 `get_by_item_id` + `update_by_item_id` → 统一方法 |
|
|||
|
|
| `backend/tests/integration/test_schedule_items_routes.py` | `permission=7` → `permission=15` |
|
|||
|
|
| `docs/protocols/calendar/schedule-items.md` | 更新位掩码说明 |
|
|||
|
|
|
|||
|
|
### 前端 (4 个文件)
|
|||
|
|
|
|||
|
|
| 文件 | 改动 |
|
|||
|
|
|------|------|
|
|||
|
|
| `apps/lib/features/calendar/data/models/schedule_item_model.dart` | `canEdit/canDelete` 改为纯 permission |
|
|||
|
|
| `apps/lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart` | 添加 Owner 徽章 |
|
|||
|
|
| `apps/lib/l10n/app_zh.arb` | 新增 owner badge 文案 |
|
|||
|
|
| `apps/lib/l10n/app_en.arb` | 新增 owner badge 文案 |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 1: 后端 - 更新权限枚举
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `backend/src/schemas/enums.py:96-100`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 更新 SubscriptionPermission 枚举**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
class SubscriptionPermission(int, Enum):
|
|||
|
|
VIEW = 1
|
|||
|
|
INVITE = 2
|
|||
|
|
EDIT = 4
|
|||
|
|
DELETE = 8
|
|||
|
|
OWNER = 15 # VIEW | INVITE | EDIT | DELETE
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 运行语法检查**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /Users/zl-q/Code/social-app/backend && uv run python -m py_compile src/schemas/enums.py
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 无错误输出
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 2: 后端 - 简化 Repository 层
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `backend/src/v1/schedule_items/repository.py`
|
|||
|
|
|
|||
|
|
**原始方法 (需合并/删除):**
|
|||
|
|
- `get_by_item_id(item_id, owner_id)` - 仅用于 owner 判断,可删除
|
|||
|
|
- `update_by_item_id(item_id, owner_id, data)` - 仅用于 owner 更新,可删除
|
|||
|
|
- `update_item_by_id(item_id, data)` - 保留,订阅者更新用
|
|||
|
|
|
|||
|
|
**新逻辑:**
|
|||
|
|
- `get_item(item_id)` - 获取 item,不带 owner 过滤
|
|||
|
|
- `update_item(item_id, data)` - 统一更新,不带 owner 过滤(权限判断移至 Service 层)
|
|||
|
|
- `delete_item(item_id)` - 统一删除,不带 owner 过滤(权限判断移至 Service 层)
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 添加统一方法**
|
|||
|
|
|
|||
|
|
在 `SQLAlchemyScheduleItemRepository` 类中添加:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
async def get_item(self, item_id: UUID) -> ScheduleItem | None:
|
|||
|
|
"""Get item by id without owner filter. Permission check done at service layer."""
|
|||
|
|
try:
|
|||
|
|
stmt = (
|
|||
|
|
select(ScheduleItem)
|
|||
|
|
.where(ScheduleItem.id == item_id)
|
|||
|
|
.where(ScheduleItem.deleted_at.is_(None))
|
|||
|
|
)
|
|||
|
|
result = await self._session.execute(stmt)
|
|||
|
|
return result.scalar_one_or_none()
|
|||
|
|
except SQLAlchemyError:
|
|||
|
|
logger.exception("Schedule item lookup failed", item_id=str(item_id))
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
async def update_item(self, item_id: UUID, data: dict) -> ScheduleItem | None:
|
|||
|
|
"""Update item by id without owner filter. Permission check done at service layer."""
|
|||
|
|
if not data:
|
|||
|
|
return await self.get_item(item_id)
|
|||
|
|
try:
|
|||
|
|
existing = await self.get_item(item_id)
|
|||
|
|
if existing is None:
|
|||
|
|
return None
|
|||
|
|
stmt = (
|
|||
|
|
update(ScheduleItem)
|
|||
|
|
.where(ScheduleItem.id == item_id)
|
|||
|
|
.where(ScheduleItem.deleted_at.is_(None))
|
|||
|
|
.values(**data)
|
|||
|
|
.returning(ScheduleItem)
|
|||
|
|
)
|
|||
|
|
result = await self._session.execute(stmt)
|
|||
|
|
await self._session.flush()
|
|||
|
|
return result.scalar_one_or_none()
|
|||
|
|
except SQLAlchemyError:
|
|||
|
|
logger.exception("Schedule item update failed", item_id=str(item_id))
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
async def delete_item(self, item_id: UUID) -> ScheduleItem | None:
|
|||
|
|
"""Soft delete item by id without owner filter. Permission check done at service layer."""
|
|||
|
|
try:
|
|||
|
|
stmt = (
|
|||
|
|
update(ScheduleItem)
|
|||
|
|
.where(ScheduleItem.id == item_id)
|
|||
|
|
.where(ScheduleItem.deleted_at.is_(None))
|
|||
|
|
.values(deleted_at=datetime.now(timezone.utc))
|
|||
|
|
.returning(ScheduleItem)
|
|||
|
|
)
|
|||
|
|
result = await self._session.execute(stmt)
|
|||
|
|
await self._session.flush()
|
|||
|
|
return result.scalar_one_or_none()
|
|||
|
|
except SQLAlchemyError:
|
|||
|
|
logger.exception("Schedule item delete failed", item_id=str(item_id))
|
|||
|
|
raise
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 更新 Repository Protocol**
|
|||
|
|
|
|||
|
|
修改 `ScheduleItemRepository` Protocol 定义:
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
class ScheduleItemRepository(Protocol):
|
|||
|
|
async def get_by_id(self, entity_id: UUID) -> ScheduleItem | None: ...
|
|||
|
|
async def get_item(self, item_id: UUID) -> ScheduleItem | None: ... # 新增
|
|||
|
|
async def get_subscription(self, item_id: UUID, subscriber_id: UUID) -> ScheduleSubscription | None: ...
|
|||
|
|
async def create(self, data: dict) -> ScheduleItem: ...
|
|||
|
|
async def update_item(self, item_id: UUID, data: dict) -> ScheduleItem | None: ... # 合并
|
|||
|
|
async def delete_item(self, item_id: UUID) -> ScheduleItem | None: ... # 合并
|
|||
|
|
# ... 其他方法保持不变
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 运行语法检查**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /Users/zl-q/Code/social-app/backend && uv run python -m py_compile src/v1/schedule_items/repository.py
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 无错误输出
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 3: 后端 - 简化 Service 层
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `backend/src/v1/schedule_items/service.py`
|
|||
|
|
|
|||
|
|
### 3.1 get_by_id 方法简化
|
|||
|
|
|
|||
|
|
**原始逻辑 (lines 175-180):**
|
|||
|
|
```python
|
|||
|
|
is_owner = item.owner_id == user_id
|
|||
|
|
permission = 1
|
|||
|
|
if not is_owner:
|
|||
|
|
subscription = await self._repository.get_subscription(item_id, user_id)
|
|||
|
|
if subscription:
|
|||
|
|
permission = subscription.permission
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**新逻辑:**
|
|||
|
|
```python
|
|||
|
|
subscription = await self._repository.get_subscription(item_id, user_id)
|
|||
|
|
permission = subscription.permission if subscription else 1
|
|||
|
|
is_owner = item.owner_id == user_id
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.2 update 方法简化
|
|||
|
|
|
|||
|
|
**原始逻辑 (lines 231-264):**
|
|||
|
|
```python
|
|||
|
|
existing = await self._repository.get_by_item_id(item_id, user_id)
|
|||
|
|
is_owner = existing is not None
|
|||
|
|
|
|||
|
|
if not is_owner:
|
|||
|
|
subscription = await self._repository.get_subscription(item_id, user_id)
|
|||
|
|
if subscription is None or subscription.status != SubscriptionStatus.ACTIVE:
|
|||
|
|
raise 404
|
|||
|
|
if not (subscription.permission & SubscriptionPermission.EDIT):
|
|||
|
|
raise 403
|
|||
|
|
existing = await self._repository.get_by_id(item_id)
|
|||
|
|
...
|
|||
|
|
# owner 用 update_by_item_id,订阅者用 update_item_by_id
|
|||
|
|
if is_owner:
|
|||
|
|
item = await self._repository.update_by_item_id(item_id, user_id, update_data)
|
|||
|
|
else:
|
|||
|
|
item = await self._repository.update_item_by_id(item_id, update_data)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**新逻辑:**
|
|||
|
|
```python
|
|||
|
|
subscription = await self._repository.get_subscription(item_id, user_id)
|
|||
|
|
if subscription is None or subscription.status != SubscriptionStatus.ACTIVE:
|
|||
|
|
raise 404
|
|||
|
|
if not (subscription.permission & SubscriptionPermission.EDIT):
|
|||
|
|
raise 403
|
|||
|
|
# 统一用 update_item
|
|||
|
|
item = await self._repository.update_item(item_id, update_data)
|
|||
|
|
is_owner = item.owner_id == user_id if item else False
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 3.3 delete 方法简化
|
|||
|
|
|
|||
|
|
**原始逻辑 (lines 338-366):**
|
|||
|
|
```python
|
|||
|
|
existing = await self._repository.get_by_item_id(item_id, user_id)
|
|||
|
|
if existing is None:
|
|||
|
|
raise 404
|
|||
|
|
...
|
|||
|
|
await self._repository.delete_by_item_id(item_id, user_id)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**新逻辑:**
|
|||
|
|
```python
|
|||
|
|
subscription = await self._repository.get_subscription(item_id, user_id)
|
|||
|
|
if subscription is None or not (subscription.permission & SubscriptionPermission.DELETE):
|
|||
|
|
raise 403
|
|||
|
|
item = await self._repository.delete_item(item_id)
|
|||
|
|
if item is None:
|
|||
|
|
raise 404
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 修改 get_by_id (lines 175-180)**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# 替换为:
|
|||
|
|
subscription = await self._repository.get_subscription(item_id, user_id)
|
|||
|
|
permission = subscription.permission if subscription else 1
|
|||
|
|
is_owner = item.owner_id == user_id
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 修改 update 方法 (lines 226-336)**
|
|||
|
|
|
|||
|
|
简化权限检查逻辑,统一使用 `update_item` 方法
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 修改 delete 方法 (lines 338-366)**
|
|||
|
|
|
|||
|
|
简化权限检查逻辑,统一使用 `delete_item` 方法
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 更新 _to_response 响应方法 (line 645)**
|
|||
|
|
|
|||
|
|
```python
|
|||
|
|
# 原来
|
|||
|
|
permission=permission if not is_owner else 7,
|
|||
|
|
# 改为 (如果 subscription 有值就用其 permission,否则默认 1)
|
|||
|
|
permission=subscription.permission if subscription else 1,
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: 运行语法检查**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /Users/zl-q/Code/social-app/backend && uv run python -m py_compile src/v1/schedule_items/service.py
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 无错误输出
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 4: 后端 - 更新测试
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `backend/tests/integration/test_schedule_items_routes.py`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 更新所有 permission=7 为 permission=15**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
# 使用 replaceAll 功能
|
|||
|
|
# 将 permission=7 替换为 permission=15
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 运行测试验证**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /Users/zl-q/Code/social-app/backend && uv run pytest tests/integration/test_schedule_items_routes.py -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: 所有测试通过
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 5: 前端 - 简化 Model 权限判断
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `apps/lib/features/calendar/data/models/schedule_item_model.dart`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 添加 DELETE 权限常量**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
static const int permissionDelete = 8;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 修改 canEdit/canDelete**
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
// 原来
|
|||
|
|
bool get canEdit => isOwner || (permission & permissionEdit) != 0;
|
|||
|
|
bool get canDelete => isOwner;
|
|||
|
|
|
|||
|
|
// 改为
|
|||
|
|
bool get canEdit => (permission & permissionEdit) != 0;
|
|||
|
|
bool get canDelete => (permission & permissionDelete) != 0;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 运行 flutter analyze**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /Users/zl-q/Code/social-app/apps && flutter analyze lib/features/calendar/data/models/schedule_item_model.dart
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: No issues found
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 6: 前端 - 添加 Owner 徽章
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `apps/lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 在标题栏添加 Owner 徽章**
|
|||
|
|
|
|||
|
|
找到标题显示的位置(约 line 250-280),在标题后添加:
|
|||
|
|
|
|||
|
|
```dart
|
|||
|
|
if (event.isOwner)
|
|||
|
|
Container(
|
|||
|
|
margin: const EdgeInsets.only(left: 8),
|
|||
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
color: Theme.of(context).colorScheme.primaryContainer,
|
|||
|
|
borderRadius: BorderRadius.circular(4),
|
|||
|
|
),
|
|||
|
|
child: Text(
|
|||
|
|
context.l10n.calendarOwnerBadge,
|
|||
|
|
style: TextStyle(
|
|||
|
|
fontSize: 12,
|
|||
|
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 运行 flutter analyze**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /Users/zl-q/Code/social-app/apps && flutter analyze lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Expected: No issues found
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 7: 前端 - 添加 L10n 文案
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `apps/lib/l10n/app_zh.arb`
|
|||
|
|
- Modify: `apps/lib/l10n/app_en.arb`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 添加中文文案**
|
|||
|
|
|
|||
|
|
在 `app_zh.arb` 末尾添加:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
"calendarOwnerBadge": "我的日历",
|
|||
|
|
"@calendarOwnerBadge": {
|
|||
|
|
"description": "Owner badge shown when user owns the calendar"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 添加英文文案**
|
|||
|
|
|
|||
|
|
在 `app_en.arb` 末尾添加:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
"calendarOwnerBadge": "My Calendar",
|
|||
|
|
"@calendarOwnerBadge": {
|
|||
|
|
"description": "Owner badge shown when user owns the calendar"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 生成 l10n**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /Users/zl-q/Code/social-app/apps && flutter gen-l10n
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 8: 更新 Protocol 文档
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `docs/protocols/calendar/schedule-items.md`
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 更新 SubscriberInfo 的 permission 说明**
|
|||
|
|
|
|||
|
|
找到 SubscriberInfo 部分,添加 DELETE 权限说明:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
permission: "int (位掩码: 1=view, 2=invite, 4=edit, 8=delete)"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 更新 PATCH `/{item_id}` 的 Authorization 说明**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
- **Owner**: 可更新所有字段 (permission=15)
|
|||
|
|
- **Subscriber (DELETE permission)**: 可删除日程(权限位掩码包含 `8`)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Task 9: 最终验证
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 后端语法检查**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /Users/zl-q/Code/social-app/backend && uv run python -m py_compile src/schemas/enums.py src/v1/schedule_items/repository.py src/v1/schedule_items/service.py
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 后端测试**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /Users/zl-q/Code/social-app/backend && uv run pytest tests/integration/test_schedule_items_routes.py -v
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 前端分析**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /Users/zl-q/Code/social-app/apps && flutter analyze lib/features/calendar
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Git 状态检查**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /Users/zl-q/Code/social-app && git status
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 回滚计划
|
|||
|
|
|
|||
|
|
如果出现问题:
|
|||
|
|
|
|||
|
|
1. **后端回滚**: `git checkout HEAD~1 -- backend/`
|
|||
|
|
2. **前端回滚**: `git checkout HEAD~1 -- apps/`
|
|||
|
|
3. **Protocol 回滚**: `git checkout HEAD~1 -- docs/`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 注意事项
|
|||
|
|
|
|||
|
|
1. Repository 层的方法合并后,Service 层必须确保权限判断正确
|
|||
|
|
2. Owner 创建日程时,subscription.permission 应该设置为 15 (OWNER)
|
|||
|
|
3. 测试中的 permission=7 需要全部更新为 15
|
|||
|
|
4. 前端 isOwner 字段仍保留,但仅用于 UI 显示,不参与权限判断
|