feat: 重构 Reminder Notification 系统并更新应用包名

This commit is contained in:
qzl
2026-03-30 18:36:57 +08:00
parent 9fb2a6857b
commit 91bf3c3f96
90 changed files with 5133 additions and 3017 deletions
@@ -1,556 +0,0 @@
# Calendar Share Redesign - Implementation 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:** 重写日历分享功能,支持选择多个好友/用户并单独设置权限,同时后端新增批量分享接口。
**Architecture:**
- **后端**: 新增 `POST /api/v1/schedule-items/{id}/share/batch` 批量分享接口,原有单用户接口保留。
- **前端**: 重构 `CalendarEventShareScreen``CalendarShareDialog`,移除硬编码手机号输入,改为用户搜索 + 多选 + 独立权限设置。
**Tech Stack:** Flutter (Riverpod/BLoC), FastAPI (Pydantic), PostgreSQL, 现有 API 契约
---
## 背景与现状
### 现有流程
```
用户 → CalendarEventShareScreen → CalendarShareDialog
├── 手机号输入 + 前缀选择
├── 权限开关 (View/Edit/Invite)
└── POST /api/v1/schedule-items/{id}/share
```
### 问题
1. 仅支持单用户分享
2. 需要手动输入手机号,不支持好友选择
3. 权限是全局的,无法为不同用户设置不同权限
### 目标流程
```
用户 → CalendarEventShareScreen → CalendarShareDialog
├── 搜索框 (好友/用户名/手机号搜索)
├── 用户列表 (可多选)
│ └── 每行: 头像 + 名字 + 权限开关 (选中后显示)
├── 已选用户 Chips
└── POST /api/v1/schedule-items/{id}/share/batch
```
---
## Task 1: 后端 - 新增批量分享接口
**Files:**
- Create: `backend/src/v1/schedule_items/schemas.py` (新增 `ScheduleItemShareBatchRequest`)
- Modify: `backend/src/v1/schedule_items/router.py` (新增 `/share/batch` 端点)
- Modify: `backend/src/v1/schedule_items/service.py` (新增 `share_batch` 方法)
- Verify: `backend/tests/**`
- [ ] **Step 1: 创建批量分享请求 Schema**
```python
# backend/src/v1/schedule_items/schemas.py
class ScheduleItemShareBatchRequest(BaseModel):
shares: List[ShareTarget] = Field(..., description="List of users to share with")
permission_view: bool = Field(True, description="Default view permission")
permission_edit: bool = Field(False, description="Default edit permission")
permission_invite: bool = Field(False, description="Default invite permission")
class ShareTarget(BaseModel):
phone: str = Field(..., pattern=r"^\+861[3-9]\d{9}$")
permission_view: Optional[bool] = None
permission_edit: Optional[bool] = None
permission_invite: Optional[bool] = None
class ScheduleItemShareBatchResponse(BaseModel):
message: str
success_count: int
failure_count: int
failures: List[ShareFailure] = []
class ShareFailure(BaseModel):
phone: str
reason: str
```
- [ ] **Step 2: 实现批量分享服务方法**
```python
# backend/src/v1/schedule_items/service.py
async def share_batch(
db: AsyncSession,
current_user: User,
schedule_item_id: UUID,
shares: List[ShareTarget],
default_permission_view: bool = True,
default_permission_edit: bool = False,
default_permission_invite: bool = False,
) -> Tuple[int, int, List[Dict]]:
"""批量分享日程给多个用户"""
# 1. 验证当前用户是否有邀请权限
# 2. 对每个 share 调用现有的 share_single 逻辑
# 3. 返回 (成功数, 失败数, 失败详情列表)
```
- [ ] **Step 3: 新增批量分享路由**
```python
# backend/src/v1/schedule_items/router.py
@router.post("/{item_id}/share/batch", response_model=ScheduleItemShareBatchResponse)
async def share_batch(
item_id: UUID,
request: ScheduleItemShareBatchRequest,
current_user: User = Depends(get_current_user),
service: ScheduleItemService = Depends(),
):
"""批量分享日程给多个用户"""
success_count, failure_count, failures = await service.share_batch(
db=db,
current_user=current_user,
schedule_item_id=item_id,
shares=request.shares,
default_permission_view=request.permission_view,
default_permission_edit=request.permission_edit,
default_permission_invite=request.permission_invite,
)
return ScheduleItemShareBatchResponse(
message="Batch share completed",
success_count=success_count,
failure_count=failure_count,
failures=[ShareFailure(**f) for f in failures],
)
```
- [ ] **Step 4: 编写测试**
```python
# backend/tests/v1/test_schedule_items.py
@pytest.mark.asyncio
async def test_share_batch_success():
"""测试批量分享成功"""
# Arrange
# Act
# Assert
@pytest.mark.asyncio
async def test_share_batch_partial_failure():
"""测试批量分享部分失败(非好友用户应被拒绝)"""
# Arrange
# Act
# Assert
```
- [ ] **Step 5: 运行测试验证**
```bash
cd backend && uv run pytest tests/v1/test_schedule_items.py -v -k "share_batch"
```
---
## Task 2: 前端 - 新增批量分享 API 方法
**Files:**
- Modify: `apps/lib/features/calendar/data/apis/calendar_api.dart`
- [ ] **Step 1: 新增批量分享 API 方法**
```dart
// apps/lib/features/calendar/data/apis/calendar_api.dart
class CalendarApi {
// 现有单用户分享保留
Future<void> share(...) { ... }
// 新增批量分享
Future<ShareBatchResponse> shareBatch({
required String eventId,
required List<ShareTarget> shares,
bool permissionView = true,
bool permissionEdit = false,
bool permissionInvite = false,
}) async {
final response = await _dio.post(
'/api/v1/schedule-items/$eventId/share/batch',
data: {
'shares': shares.map((s) => s.toJson()).toList(),
'permission_view': permissionView,
'permission_edit': permissionEdit,
'permission_invite': permissionInvite,
},
);
return ShareBatchResponse.fromJson(response.data);
}
}
class ShareTarget {
final String phone;
final bool? permissionView;
final bool? permissionEdit;
final bool? permissionInvite;
Map<String, dynamic> toJson() => {
'phone': phone,
if (permissionView != null) 'permission_view': permissionView,
if (permissionEdit != null) 'permission_edit': permissionEdit,
if (permissionInvite != null) 'permission_invite': permissionInvite,
};
}
class ShareBatchResponse {
final String message;
final int successCount;
final int failureCount;
final List<ShareFailure> failures;
}
```
- [ ] **Step 2: 验证 API 编译**
```bash
cd apps && flutter analyze lib/features/calendar/data/apis/calendar_api.dart
```
---
## Task 3: 前端 - 创建分享页面状态管理
**Files:**
- Create: `apps/lib/features/calendar/presentation/cubits/calendar_share_cubit.dart`
- Create: `apps/lib/features/calendar/presentation/cubits/calendar_share_state.dart`
- [ ] **Step 1: 创建 State**
```dart
// apps/lib/features/calendar/presentation/cubits/calendar_share_state.dart
enum CalendarShareStatus { initial, loading, success, failure }
class SelectedUser {
final String id;
final String username;
final String? avatarUrl;
final String phone;
bool permissionView;
bool permissionEdit;
bool permissionInvite;
}
class CalendarShareState {
final CalendarShareStatus status;
final List<FriendResponse> friends;
final List<UserProfile> searchResults;
final List<SelectedUser> selectedUsers;
final String searchQuery;
final String? errorMessage;
// 权限默认值(应用于新选中的用户)
bool defaultPermissionView;
bool defaultPermissionEdit;
bool defaultPermissionInvite;
}
```
- [ ] **Step 2: 创建 Cubit**
```dart
// apps/lib/features/calendar/presentation/cubits/calendar_share_cubit.dart
class CalendarShareCubit extends Cubit<CalendarShareState> {
final FriendsApi _friendsApi;
final UsersApi _usersApi;
final CalendarApi _calendarApi;
CalendarShareCubit({
required FriendsApi friendsApi,
required UsersApi usersApi,
required CalendarApi calendarApi,
}) : super(CalendarShareState.initial());
// 加载好友列表
Future<void> loadFriends() async { ... }
// 搜索用户
Future<void> searchUsers(String query) async { ... }
// 切换用户选中状态
void toggleUser(String userId) { ... }
// 更新单个用户的权限
void updateUserPermission(String userId, {bool? view, bool? edit, bool? invite}) { ... }
// 移除已选用户
void removeUser(String userId) { ... }
// 设置默认权限(对新选用户生效)
void setDefaultPermissions({bool? view, bool? edit, bool? invite}) { ... }
// 发送批量分享
Future<void> share(String eventId) async { ... }
}
```
- [ ] **Step 3: 验证编译**
```bash
cd apps && flutter analyze lib/features/calendar/presentation/cubits/calendar_share_cubit.dart
```
---
## Task 4: 前端 - 重构分享对话框 UI
**Files:**
- Create: `apps/lib/features/calendar/presentation/widgets/user_select_list.dart`
- Create: `apps/lib/features/calendar/presentation/widgets/user_select_item.dart`
- Modify: `apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart`
- Modify: `apps/lib/features/calendar/presentation/screens/calendar_event_share_screen.dart`
### 4.1 用户选择列表组件
- [ ] **Step 1: 创建 UserSelectItem**
```dart
// apps/lib/features/calendar/presentation/widgets/user_select_item.dart
class UserSelectItem extends StatelessWidget {
final UserBasicInfo user;
final bool isSelected;
final bool showPermissions; // 选中后才显示权限开关
final SelectedUser? selectedUser; // 如果选中,包含权限状态
final ValueChanged<bool> onToggle;
final Function(PermissionType, bool) onPermissionChanged;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Avatar(url: user.avatarUrl, name: user.username),
title: Text(user.username),
subtitle: showPermissions && isSelected
? Row(
children: [
_PermissionChip('V', selectedUser?.permissionView ?? true, (v) => onPermissionChanged(PermissionType.view, v)),
_PermissionChip('E', selectedUser?.permissionEdit ?? false, (v) => onPermissionChanged(PermissionType.edit, v)),
_PermissionChip('I', selectedUser?.permissionInvite ?? false, (v) => onPermissionChanged(PermissionType.invite, v)),
],
)
: null,
trailing: Checkbox(value: isSelected, onChanged: (v) => onToggle(v ?? false)),
);
}
}
```
### 4.2 重构 CalendarShareDialog
- [ ] **Step 2: 重构对话框**
```dart
// apps/lib/features/calendar/presentation/widgets/calendar_share_dialog.dart
class CalendarShareDialog extends StatefulWidget {
// ... existing props
@override
State<CalendarShareDialog> createState() => _CalendarShareDialogState();
}
class _CalendarShareDialogState extends State<CalendarShareDialog> {
late final CalendarShareCubit _cubit;
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
_cubit = context.read<CalendarShareCubit>();
_cubit.loadFriends();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cubit,
child: BlocBuilder<CalendarShareCubit, CalendarShareState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
_buildHeader(context, state),
// 搜索框
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: '搜索好友或输入手机号...',
prefixIcon: Icon(Icons.search),
),
onChanged: (value) => _cubit.searchUsers(value),
),
// 已选用户 Chips
if (state.selectedUsers.isNotEmpty) ...[
Wrap(
spacing: 8,
children: state.selectedUsers.map((user) {
return Chip(
label: Text('${user.username} (${_getPermissionSummary(user)})'),
onDeleted: () => _cubit.removeUser(user.id),
);
}).toList(),
),
],
// 用户列表
Flexible(
child: _buildUserList(state),
),
// 发送按钮
AppButton(
text: '发送邀请 (${state.selectedUsers.length}人)',
onPressed: state.selectedUsers.isEmpty
? null
: () => _handleShare(context, state),
),
],
);
},
),
);
}
Widget _buildUserList(CalendarShareState state) {
// 如果有搜索结果,显示搜索结果;否则显示好友列表
final users = state.searchQuery.isNotEmpty
? state.searchResults
: state.friends.map((f) => f.friend).toList();
return ListView.builder(
shrinkWrap: true,
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
final selectedUser = state.selectedUsers.cast<SelectedUser?>().firstWhere(
(s) => s?.id == user.id,
orElse: () => null,
);
final isSelected = selectedUser != null;
return UserSelectItem(
user: user,
isSelected: isSelected,
showPermissions: isSelected,
selectedUser: selectedUser,
onToggle: (selected) {
if (selected) {
_cubit.addUser(user, state.defaultPermissionView, state.defaultPermissionEdit, state.defaultPermissionInvite);
} else {
_cubit.removeUser(user.id);
}
},
onPermissionChanged: (type, value) {
_cubit.updateUserPermission(user.id, type: type, value: value);
},
);
},
);
}
}
```
- [ ] **Step 3: 移除顶部栏,简化 CalendarEventShareScreen**
```dart
// apps/lib/features/calendar/presentation/screens/calendar_event_share_screen.dart
class CalendarEventShareScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
// 直接显示分享对话框,不需要额外顶部栏
Expanded(
child: CalendarShareDialog(
eventId: eventId,
eventTitle: event.title,
canInvite: event.canInvite,
canEdit: event.canEdit,
),
),
],
),
),
);
}
}
```
- [ ] **Step 4: 验证编译**
```bash
cd apps && flutter analyze lib/features/calendar/presentation/widgets/calendar_share_dialog.dart
```
---
## Task 5: 集成测试
**Files:**
- Create: `apps/test/features/calendar/presentation/widgets/calendar_share_dialog_test.dart`
- [ ] **Step 1: 编写 Widget 测试**
```dart
void main() {
testWidgets('选择用户后显示权限开关', (tester) async {
// Arrange
// Act
// Assert
});
testWidgets('已选用户显示在 Chips 中', (tester) async {
// Arrange
// Act
// Assert
});
}
```
- [ ] **Step 2: 运行测试**
```bash
cd apps && flutter test test/features/calendar/presentation/widgets/calendar_share_dialog_test.dart
```
---
## 依赖关系
```
Task 1 (后端批量接口)
Task 2 (前端批量分享 API)
Task 3 (分享状态管理 Cubit)
Task 4 (分享对话框 UI)
Task 5 (集成测试)
```
---
## 风险与注意事项
1. **后端兼容性**: 批量接口上线前,前端使用原有单用户接口
2. **搜索防抖**: 搜索用户时需要 debounce 避免频繁请求
3. **好友列表分页**: 如果好友数量大,需要分页加载
4. **权限验证**: 前端权限不能超过当前用户的权限(由后端控制)
@@ -0,0 +1,74 @@
# Agent Calendar/Inbox Stability Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 修复 agent 日历分享失败、对话后日历不刷新、邀请信息不完整,并新增设置页一键清理本地缓存后强制重新拉取。
**Architecture:** 后端补齐 `calendar_share` 依赖注入与邀请 payload 字段,确保工具链与 API 路由行为一致。前端在 Chat 工具结果事件上增加日历刷新钩子,并在设置页提供缓存清理入口,复用既有 prewarm 机制触发重新拉取。协议文档同步更新 inbox 邀请结构,避免前后端契约漂移。
**Tech Stack:** FastAPI + SQLAlchemy + AgentScope tools, Flutter + CachedRepository + SharedPreferences cache.
---
### Task 1: 修复 calendar_share 在 Agent 工具链中的依赖缺失
**Files:**
- Modify: `backend/src/core/agentscope/tools/utils/calendar_domain.py`
**Steps:**
1.`create_schedule_service` 注入 `SQLAlchemyUserRepository`
2. 保持路由层与工具层对 `ScheduleItemService` 的依赖一致。
3. 回归验证 `calendar_share` 不再因为 actor lookup 依赖缺失而失败。
### Task 2: 扩充 calendar invite payload(邀请人 + 时间 + 描述)
**Files:**
- Modify: `backend/src/v1/schedule_items/service.py`
- Modify: `docs/protocols/models/inbox-messages.md`
- Test: `backend/tests/unit/v1/schedule_items/test_share.py`
**Steps:**
1.`share` 中构建邀请消息时写入 `actor.phone`
2.`item` 中写入 `description/start_at/end_at/timezone`
3. 更新协议文档 `CalendarInviteContent`
4. 补充/更新单测断言新增字段。
### Task 3: 对话工具成功后触发日历缓存刷新钩子
**Files:**
- Modify: `apps/lib/features/chat/presentation/bloc/chat_bloc.dart`
- Modify: `apps/lib/features/chat/presentation/bloc/chat_bloc_events.dart`
- Modify: `apps/lib/app/di/injection.dart`
- Test: `apps/test/features/chat/presentation/bloc/chat_bloc_test.dart`
**Steps:**
1.`ChatBloc` 增加可注入回调 `onCalendarMutated`
2.`ToolCallResultEvent` 中识别 `calendar_write` 成功/部分成功并触发回调。
3. DI 中将回调绑定为 `CalendarRepository.getDayEvents/getMonthEvents(forceRefresh: true)`
4. 添加回归测试验证回调触发。
### Task 4: 设置页新增“清理缓存”并触发重新拉取
**Files:**
- Modify: `apps/lib/data/cache/cache_store.dart`
- Modify: `apps/lib/features/settings/presentation/screens/settings_screen.dart`
- Modify: `apps/lib/l10n/app_zh.arb`
- Modify: `apps/lib/l10n/app_en.arb`
**Steps:**
1.`HybridCacheStore` 增加按前缀清理能力(`cache:`)。
2. 设置页在“检查更新”下新增“清理缓存”。
3. 点击后清理缓存并触发 prewarm + inbox 快照刷新。
4. 同步中英文文案并生成本地化代码。
### Task 5: 验证
**Commands:**
- `uv run pytest backend/tests/unit/v1/schedule_items/test_share.py backend/tests/unit/core/agentscope/test_calendar_tools.py -k "share or calendar_share"`
- `flutter test test/features/chat/presentation/bloc/chat_bloc_test.dart`
- `flutter analyze`
**Expected:**
- 后端分享链路测试通过,新增邀请字段存在。
- ChatBloc 回归测试通过,`calendar_write` 成功时触发刷新回调。
- Flutter 静态检查通过,无新增错误。
@@ -1,214 +0,0 @@
# Calendar Detail - Show Subscribers Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 在日历详情页显示已订阅此日历的用户列表
**Architecture:**
- 后端:在 `GET /api/v1/schedule-items/{id}` 响应中返回 `subscribers` 列表
- 前端:在详情页渲染订阅者列表组件
**Tech Stack:** Flutter, FastAPI, PostgreSQL
---
## Task 1: 后端 - 返回订阅者列表
**Files:**
- Modify: `backend/src/v1/schedule_items/schemas.py`
- Modify: `backend/src/v1/schedule_items/service.py`
- Verify: `backend/tests/integration/v1/test_schedule_items_routes.py`
- [ ] **Step 1: 新增 SubscriberInfo Schema**
```python
# backend/src/v1/schedule_items/schemas.py
class SubscriberInfo(BaseModel):
"""订阅者信息"""
user_id: UUID
username: str
avatar_url: str | None = None
permission: int # 位标志: 1=view, 2=invite, 4=edit
status: str # active, pending, paused, unsubscribed
subscribed_at: datetime
```
- [ ] **Step 2: 修改 ScheduleItemResponse**
```python
class ScheduleItemResponse(BaseModel):
# ... existing fields ...
subscribers: List[SubscriberInfo] = [] # 新增
```
- [ ] **Step 3: 修改 service.get_by_id 填充 subscribers**
```python
# backend/src/v1/schedule_items/service.py
async def get_by_id(self, item_id: UUID) -> ScheduleItemResponse:
item = await self._repository.get_by_id(item_id)
if not item:
raise ScheduleItemNotFoundError(item_id)
# 获取订阅者列表
subscriptions = await self._repository.get_subscriptions_by_item_id(item_id)
subscribers = []
for sub in subscriptions:
if sub.status == 'active': # 只返回活跃订阅者
user = await self._user_repo.get_by_id(sub.subscriber_id)
if user:
subscribers.append(SubscriberInfo(
user_id=user.id,
username=user.username,
avatar_url=user.avatar_url,
permission=sub.permission,
status=sub.status,
subscribed_at=sub.created_at,
))
return ScheduleItemResponse(
# ... existing fields ...,
subscribers=subscribers,
)
```
- [ ] **Step 4: 编写集成测试**
```python
# backend/tests/integration/v1/test_schedule_items_routes.py
async def test_get_item_returns_subscribers(self):
"""测试获取日程时返回订阅者列表"""
# Arrange: 创建日程,添加订阅者
# Act: GET /api/v1/schedule-items/{id}
# Assert: 响应包含 subscribers 字段
```
---
## Task 2: 前端 - Model 更新
**Files:**
- Modify: `apps/lib/features/calendar/data/models/schedule_item_model.dart`
- [ ] **Step 1: 新增 Subscriber 模型**
```dart
class Subscriber {
final String userId;
final String username;
final String? avatarUrl;
final int permission;
final String status;
final DateTime subscribedAt;
bool get canView => (permission & 1) != 0;
bool get canEdit => (permission & 4) != 0;
bool get canInvite => (permission & 2) != 0;
}
```
- [ ] **Step 2: 在 ScheduleItemModel 中添加 subscribers 字段**
```dart
class ScheduleItemModel {
// ... existing fields ...
final List<Subscriber> subscribers;
}
```
- [ ] **Step 3: 更新 fromJson**
```dart
factory ScheduleItemModel.fromJson(Map<String, dynamic> json) {
return ScheduleItemModel(
// ... existing fields ...,
subscribers: (json['subscribers'] as List<dynamic>?)
?.map((s) => Subscriber.fromJson(s as Map<String, dynamic>))
.toList() ?? [],
);
}
```
---
## Task 3: 前端 - 详情页渲染订阅者
**Files:**
- Modify: `apps/lib/features/calendar/presentation/screens/calendar_event_detail_screen.dart`
- [ ] **Step 1: 在 _buildMetaSurface 或新方法中渲染订阅者**
```dart
Widget _buildSubscribersSurface(ScheduleItemModel event) {
if (event.subscribers.isEmpty) {
return const SizedBox.shrink();
}
return Container(
padding: EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: _colorScheme.surface,
borderRadius: BorderRadius.circular(AppRadius.xl),
border: Border.all(color: _colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.calendarDetailSubscribers,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: AppSpacing.md),
...event.subscribers.map((sub) => _buildSubscriberRow(sub)),
],
),
);
}
Widget _buildSubscriberRow(Subscriber subscriber) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
child: Row(
children: [
Avatar(url: subscriber.avatarUrl, name: subscriber.username),
const SizedBox(width: AppSpacing.sm),
Expanded(child: Text(subscriber.username)),
if (subscriber.canEdit) Icon(Icons.edit, size: 16),
if (subscriber.canInvite) Icon(Icons.person_add, size: 16),
],
),
);
}
```
- [ ] **Step 2: 在 build 方法中添加**
```dart
// 在 _buildExtraSurface 之后添加
if (event.subscribers.isNotEmpty) [
const SizedBox(height: AppSpacing.md),
_buildSubscribersSurface(event),
],
```
- [ ] **Step 3: 添加 l10n 文本**
```arb
// apps/lib/l10n/app_zh.arb
"calendarDetailSubscribers": "已订阅 ({count}人)"
```
---
## Task 4: 验证
- [ ] 运行后端测试: `cd backend && uv run pytest -v -k "subscriber"`
- [ ] 运行前端分析: `cd apps && flutter analyze`
- [ ] 手动测试分享流程
@@ -1,473 +0,0 @@
# 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=8OWNER=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 显示,不参与权限判断