docs: 更新协议文档并清理过时计划文件

This commit is contained in:
zl-q
2026-03-30 09:07:07 +08:00
parent 5999d0edd1
commit 0f3175e303
13 changed files with 1359 additions and 842 deletions
@@ -1,178 +0,0 @@
# Apps 通用数据采集与报错日志系统设计(一阶段)
## 1. 背景与目标
当前前端需要在应用分发后具备可观测性能力,以支持线上报错定位和关键行为分析。
一阶段目标聚焦于“最小可用且可扩展”的通用采集体系,覆盖:
- 报错日志收集与排查支持。
- 用户打开应用时间与会话持续时长。
- 页面停留时长。
- 对话输入耗时与发送次数。
本设计优先保证数据质量、隐私安全、稳定性与后续重构兼容性,不绑定当前目录结构和具体实现文件。
## 2. 设计范围
### 2.1 一阶段纳入
- 全局异常采集(框架异常、异步未捕获异常、业务显式上报异常)。
- 会话生命周期采集(开始、结束、时长)。
- 页面生命周期采集(进入、离开、停留时长)。
- 对话输入行为采集(输入开始、输入提交)。
### 2.2 一阶段不纳入
- 复杂埋点体系(曝光、点击流全量追踪、实验分流)。
- 全量性能指标体系(FPS、内存、卡顿详细分层)。
- 非关键业务域的大规模事件扩展。
## 3. 方案选型结论
采用“自研通用采集 SDK + 自有后端接收”的主路径。
原因:
- 关键数据字段需与业务强关联,需高可定制能力。
- 需要强约束隐私边界与脱敏策略。
- 一阶段目标明确且有限,自研成本可控。
- 后续可平滑扩展第三方崩溃平台作为补充,不影响主链路。
## 4. 总体架构
系统分为四层:
1. 采集层:负责统一接入错误、会话、页面、输入等事件源。
2. 处理层:负责事件标准化、上下文补全、脱敏、去重与采样。
3. 存储层:负责本地队列缓存、离线持久化、容量控制。
4. 上报层:负责批量传输、失败重试、退避与状态感知。
核心原则:
- 所有事件必须走统一入口。
- 业务代码不直接请求上报接口。
- 上报失败不能影响用户主流程。
## 5. 事件模型设计
### 5.1 事件分类
- error:异常与失败事件。
- lifecycle:应用/页面生命周期事件。
- behavior:关键操作行为事件。
### 5.2 一阶段标准事件
- app_session_started
- app_session_ended
- page_view_started
- page_view_ended
- chat_input_started
- chat_input_submitted
说明:
- 输入“次数”由 chat_input_submitted 聚合统计,不新增独立事件。
- 应用“持续事件”采用开始+结束计算时长,不使用固定频率心跳。
### 5.3 通用字段规范
每条事件统一包含:
- event_id:事件唯一标识。
- event_name:事件名。
- event_type:事件分类。
- event_time:客户端事件时间。
- session_id:会话标识。
- user_id_hash:用户标识哈希(可空)。
- app_version:应用版本。
- platform:平台类型。
- route:当前页面标识(可空)。
- payload:事件特有字段。
错误事件附加字段:
- error_type、error_message、stacktrace(按策略裁剪)。
- severitywarning/error/fatal)。
- fingerprint(聚合键)。
## 6. 关键指标口径
### 6.1 应用打开时间与会话时长
- 会话开始:应用进入可交互前台时记录。
- 会话结束:应用切到后台时记录。
- 时长计算:session_end_time - session_start_time。
- 异常中断补偿:若会话未正常结束,在下次启动时补偿结束记录,并标记为补偿事件。
### 6.2 页面停留时长
- 页面进入时记录 page_view_started。
- 页面离开时记录 page_view_ended 与 duration_ms。
- 页面切换由统一路由观察机制触发,确保口径一致。
### 6.3 对话输入时间与次数
- 用户首次进入输入状态时记录 chat_input_started。
- 用户提交输入时记录 chat_input_submitted。
- 输入时长:submit_time - input_start_time。
- 输入次数:按提交事件数量聚合。
- 不采集输入正文,仅采集长度与时长。
## 7. 隐私与安全设计
必须遵循最小采集原则与默认脱敏策略:
- 禁止采集或上报:token、密码、手机号、邮箱、聊天正文、敏感证件信息。
- 标识类字段统一哈希化或匿名化。
- 错误消息与堆栈按规则裁剪,避免包含敏感上下文。
- 采用字段白名单策略,非白名单字段默认不上报。
安全底线:
- 采集系统本身不可引入认证绕过与敏感信息泄露风险。
- 上报通道需具备鉴权与重放防护能力。
## 8. 稳定性与性能策略
- 事件写入本地队列后异步上报,主线程不阻塞。
- 批量上传,控制单次包体和频率。
- 失败使用指数退避重试,达到上限后丢弃并记录内部统计。
- 本地队列设置容量上限,采用环形覆盖或优先级淘汰策略。
- 在弱网/离线场景允许延迟上报,恢复后补发。
## 9. 质量保障与验证
一阶段验收重点:
- 正确性:六类标准事件均可稳定产出且字段完整。
- 一致性:同类事件口径一致,可跨版本对比。
- 安全性:敏感字段泄露检测通过。
- 稳定性:采集开启后不影响主要业务链路。
建议验证维度:
- 会话开始/结束与时长计算一致性。
- 页面进出成对率。
- 输入开始到提交链路闭环率。
- 错误事件聚合有效性(fingerprint 去重后可读)。
## 10. 演进路线
二阶段建议按需扩展:
- 增加更多关键业务事件(保留最小集合原则)。
- 引入崩溃平台作为补充通道(仅 fatal/crash)。
- 建立统一查询与告警规则(高频错误、关键路径失败率)。
- 增强版本对比分析能力,支持发布质量回归判断。
## 11. 决策摘要
一阶段采用通用采集体系,围绕“报错日志 + 三类关键行为”快速落地:
- 会话:打开与持续时长。
- 页面:停留时长。
- 对话:输入时长与提交次数。
在不依赖具体重构代码结构的前提下,该设计可作为后续大重构期间的稳定观测基线。
@@ -1,126 +0,0 @@
# App Theme Dark Mode and Hardcoded Color Migration Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Enable automatic light/dark mode following system settings and gradually migrate UI color usage from direct `AppColors` references to theme-driven semantic colors.
**Architecture:** Introduce a dual-theme foundation in `AppTheme` (`light` and `dark`) and route app root to `ThemeMode.system`. Then migrate color access in batches: first shared widgets, then high-traffic feature pages, then long-tail modules. Keep `AppColors` as design-token source only inside theme/token layers during migration.
**Tech Stack:** Flutter Material 3 (`ThemeData`, `ColorScheme`, `ThemeMode`), existing design tokens (`AppColors`, `AppSpacing`, `AppRadius`), flutter analyze/test verification.
---
### Task 1: Theme Foundation and System Dark Mode Wiring
**Files:**
- Modify: `apps/lib/core/theme/app_theme.dart`
- Modify: `apps/lib/app/app.dart`
**Step 1: Build dual-theme entry points**
- Add `AppTheme.dark` and a shared private builder `AppTheme._buildTheme(Brightness)`.
- Ensure light and dark themes both use Material 3 and `ColorScheme.fromSeed`.
**Step 2: Remove theme-level hardcoded semantic colors**
- Replace direct `AppColors` usage in app bar/button/input decoration theme with color-scheme-driven values.
- Keep radii and spacing tokens unchanged.
**Step 3: Wire root app to system mode**
- In `MaterialApp.router`, set `theme`, `darkTheme`, and `themeMode: ThemeMode.system`.
**Step 4: Verify**
Run: `cd apps && flutter analyze`
Expected: no new analyzer errors.
---
### Task 2: Shared Widget Batch (P0) - Replace Direct AppColors with Theme Semantics
**Files:**
- Modify: `apps/lib/shared/widgets/error_retry_surface.dart`
- Modify: `apps/lib/shared/widgets/confirm_sheet.dart`
- Modify: `apps/lib/shared/widgets/destructive_action_sheet.dart`
- Modify: `apps/lib/shared/widgets/toast/toast_type_config.dart`
**Step 1: Migrate simple error surface**
- Use `Theme.of(context).colorScheme.error` for retry message color.
**Step 2: Migrate confirm/destructive sheets**
- Replace sheet background/border/text/primary-action color bindings with `ColorScheme` semantic roles (`surface`, `outlineVariant`, `onSurface`, `onSurfaceVariant`, `primary`, `onPrimary`, `error`, `onError`).
**Step 3: Migrate toast style mapping**
- Replace static feedback colors with semantic color-scheme mappings per toast type.
- Preserve existing icon and l10n label behavior.
**Step 4: Verify**
Run: `cd apps && flutter analyze`
Expected: no new analyzer errors.
---
### Task 3: Batch Migration Governance and Rollout Rules
**Files:**
- Modify: `docs/bugs/AppTheme硬编码颜色且缺失DarkMode.md`
**Step 1: Document batch strategy**
- Add phased rollout strategy: Foundation -> Shared widgets (P0) -> Core pages (P1) -> Long tail (P2) -> guardrails.
**Step 2: Define acceptance and rollback criteria**
- Include per-batch verification (`flutter analyze`, targeted widget tests/manual dark mode checks).
- Require small-scope commits per batch for safe rollback.
---
### Task 4: P1/P2 Execution Backlog (Follow-up)
**Files:**
- Modify: `apps/lib/features/home/**`
- Modify: `apps/lib/features/settings/**`
- Modify: `apps/lib/features/auth/**`
- Modify: remaining `apps/lib/features/**`
**Step 1: Prioritize by user path**
- P1 order: home -> settings -> auth.
- P2: remaining feature pages and low-frequency screens.
**Step 2: Migrate per file with semantic mapping**
- Replace direct `AppColors` usage in widget code with `Theme.of(context).colorScheme` or scoped theme helpers.
- Keep visual behavior equivalent first; optimize polish after semantic migration is stable.
**Step 3: Verify per batch**
Run:
- `cd apps && flutter analyze`
- `cd apps && flutter test` (targeted where tests exist)
Expected: no regressions in targeted flows and theme switching.
---
## Risks and Mitigations
- Risk: dark mode contrast regression in legacy widgets.
- Mitigation: migrate shared widgets first, then high-traffic pages with screenshot/manual checks.
- Risk: mixed semantic + static colors during transition.
- Mitigation: enforce migration order and avoid adding new direct `AppColors` in UI layers.
- Risk: broad-scope change fatigue.
- Mitigation: ship in small batches with isolated verification and rollback points.
## Done Criteria
- `MaterialApp` follows `ThemeMode.system` with `theme` and `darkTheme` configured.
- Theme layer no longer relies on static semantic colors for app-wide component themes.
- P0 shared widget batch migrated to theme semantics.
- Migration plan and execution progress documented in bug tracker doc.
@@ -1,144 +0,0 @@
# Data Repositories Cache Strategy Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Establish a shared cache abstraction and shared data-repository entrypoints so cross-feature data access no longer depends on direct feature-to-feature data imports.
**Architecture:** Keep cache infrastructure centralized in `apps/lib/data/cache/`, and expose cross-feature data through `apps/lib/data/repositories/` facades registered in DI as singletons. Feature screens consume repositories, while cache policy/key/invalidation remain in repository layer.
**Tech Stack:** Flutter, Dart, GetIt, flutter_test
---
### Task 1: Finalize Shared Cache Foundation
**Files:**
- Modify: `apps/lib/data/cache/cached_repository.dart`
- Modify: `apps/lib/features/settings/data/services/user_profile_cache_repository.dart`
- Test: `apps/test/features/settings/data/services/user_profile_cache_repository_test.dart`
**Step 1: Write the failing test**
Add an invalidate-vs-inflight regression case in `user_profile_cache_repository_test.dart` asserting stale in-flight refresh cannot restore `cachedUser` after invalidate.
**Step 2: Run test to verify it fails**
Run: `flutter test test/features/settings/data/services/user_profile_cache_repository_test.dart`
Expected: FAIL before generation-guard fix.
**Step 3: Write minimal implementation**
Add generation/version guard in `UserProfileCacheRepository` so `invalidate()` increments generation and stale async loader results are ignored for in-memory snapshot updates.
**Step 4: Run test to verify it passes**
Run: `flutter test test/features/settings/data/services/user_profile_cache_repository_test.dart`
Expected: PASS.
**Step 5: Commit**
```bash
git add apps/lib/data/cache/cached_repository.dart apps/lib/features/settings/data/services/user_profile_cache_repository.dart apps/test/features/settings/data/services/user_profile_cache_repository_test.dart
git commit -m "refactor: harden user profile cache invalidation race handling"
```
### Task 2: Introduce Shared Data Repositories Module
**Files:**
- Create: `apps/lib/data/repositories/inbox_repository.dart`
- Create: `apps/lib/data/repositories/calendar_event_repository.dart`
- Create: `apps/lib/data/repositories/user_repository.dart`
- Modify: `apps/lib/app/di/injection.dart`
**Step 1: Write the failing test**
Add a DI registration smoke test (or lightweight compile-level usage test) that resolves the new repositories from GetIt.
**Step 2: Run test to verify it fails**
Run: `flutter test <new-test-file>`
Expected: FAIL because repositories are not yet defined/registered.
**Step 3: Write minimal implementation**
Implement repository facades that wrap existing APIs (`InboxApi`, `CalendarApi`, `UsersApi`) and register them as singletons in DI.
**Step 4: Run test to verify it passes**
Run: `flutter test <new-test-file>`
Expected: PASS.
**Step 5: Commit**
```bash
git add apps/lib/data/repositories apps/lib/app/di/injection.dart
git commit -m "refactor: add shared data repositories module"
```
### Task 3: Migrate Cross-Feature Screens to Shared Repositories
**Files:**
- Modify: `apps/lib/features/messages/presentation/screens/message_invite_detail_screen.dart`
- Modify: `apps/lib/features/messages/presentation/screens/message_invite_list_screen.dart`
- Modify: `apps/lib/features/todo/presentation/screens/todo_edit_screen.dart`
**Step 1: Write the failing test**
Add targeted tests for message detail/list data loading and todo edit schedule loading using repository dependencies.
**Step 2: Run test to verify it fails**
Run: `flutter test <affected-test-files>`
Expected: FAIL before dependency switch.
**Step 3: Write minimal implementation**
Replace direct API injections with shared repository injections from `apps/lib/data/repositories/*`.
**Step 4: Run test to verify it passes**
Run: `flutter test <affected-test-files>`
Expected: PASS.
**Step 5: Commit**
```bash
git add apps/lib/features/messages/presentation/screens/message_invite_detail_screen.dart apps/lib/features/messages/presentation/screens/message_invite_list_screen.dart apps/lib/features/todo/presentation/screens/todo_edit_screen.dart
git commit -m "refactor: switch cross-feature screens to shared repositories"
```
### Task 4: Verification and Cleanup
**Files:**
- Modify: `apps/AGENTS.md`
- Modify: `docs/bugs/2026-03-27-repository缓存抽象.md`
- Modify: `docs/bugs/服务层与Repository层职责混乱.md`
**Step 1: Run full targeted verification**
Run: `flutter analyze lib/data/cache lib/data/repositories lib/app/di/injection.dart lib/features/messages/presentation/screens/message_invite_detail_screen.dart lib/features/messages/presentation/screens/message_invite_list_screen.dart lib/features/todo/presentation/screens/todo_edit_screen.dart`
Expected: No issues.
**Step 2: Run regression tests**
Run: `flutter test test/data/cache/cached_repository_test.dart test/features/settings/data/services/user_profile_cache_repository_test.dart test/app/router/app_router_redirect_test.dart`
Expected: PASS.
**Step 3: Update docs status**
Mark relevant bug docs as in-progress/resolved and document new layering rules.
**Step 4: Commit**
```bash
git add apps/AGENTS.md docs/bugs/2026-03-27-repository缓存抽象.md docs/bugs/服务层与Repository层职责混乱.md
git commit -m "docs: document shared repository and cache strategy"
```
@@ -1,317 +0,0 @@
# L10n Cleanup + Stable Error Code + Frontend Text Migration Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Remove redundant l10n wrappers, introduce backend stable/mappable error codes for HTTP contracts, and continue frontend hardcoded-text localization migration to zh/en with default zh.
**Architecture:** Keep Flutter UI localization in `lib/l10n` as single source of truth, minimize cross-layer localization coupling, and use backend RFC7807 + `code`/`params` as machine-readable contract. Frontend maps `code -> l10n key` for user-facing messages while preserving fallback behavior.
**Tech Stack:** Flutter gen-l10n, FastAPI (RFC7807), Pydantic models, Dio client error mapping, existing AGENTS/rules constraints.
---
### Task 1: Freeze and baseline current behavior
**Files:**
- Modify: none (read-only task)
- Verify: `apps/lib/**`, `backend/src/**`, `docs/protocols/**`
**Step 1: Snapshot app localization status**
Run: `python scripts/count_cn_literals.py` (or equivalent one-off command)
Expected: baseline count and top files with Chinese literals.
**Step 2: Snapshot backend detail-string usage**
Run: `python scripts/count_http_detail_usage.py` (or equivalent one-off command)
Expected: per-file count of `HTTPException(detail=...)` hotspots.
**Step 3: Capture baseline checks**
Run: `cd apps && flutter analyze`
Expected: no new errors, only known existing warnings/infos.
Run: `cd backend && uv run pytest -q` (or targeted fast suite if full too slow)
Expected: baseline pass/fail recorded for regression comparison.
---
### Task 2: Refactor l10n structure to remove redundant wrapper responsibilities
**Files:**
- Modify: `apps/lib/app/app.dart`
- Modify: UI files currently importing `apps/lib/core/l10n/l10n.dart`
- Delete/Modify: `apps/lib/core/l10n/l10n.dart` (depending on final outcome)
- Verify: generated files under `apps/lib/l10n/`
**Step 1: Define target rule**
Rule:
- UI layer uses `context.l10n`.
- Non-UI layer does not depend on ad-hoc global locale state.
- If non-UI needs localization, pass already-localized strings in from caller or inject mapper service.
**Step 2: Write failing/static guard checks**
Add temporary grep checks:
- Fail when new code adds `L10n.current` in feature/presentation.
- Fail when `core/l10n/l10n.dart` is reintroduced for convenience access.
**Step 3: Replace call sites incrementally**
For each file:
1. Replace `L10n.current.xxx` with `context.l10n.xxx` where `BuildContext` exists.
2. For cubit/service/form validators, inject message providers or pass messages from UI.
3. Keep behavior unchanged.
**Step 4: Remove locale global mutation path**
In `app.dart`:
- Remove `L10n.setLocale(...)` style side effects.
- Keep Flutter-native delegates + `supportedLocales` + default locale logic.
**Step 5: Delete redundant wrapper (if no remaining valid use case)**
Delete `apps/lib/core/l10n/l10n.dart` only after all references are removed and non-UI strategy is in place.
**Step 6: Verify**
Run: `cd apps && flutter gen-l10n && flutter analyze`
Expected: no errors.
---
### Task 3: Define backend stable error code contract (RFC7807 extension)
**Files:**
- Modify: `backend/src/core/http/response.py`
- Modify: `backend/src/app.py`
- Create: `backend/src/core/http/errors.py`
- Modify: `docs/protocols/agent/api-endpoints.md`
- (optional) Create: `docs/protocols/common/error-contract.md`
**Step 1: Extend problem details schema**
Add fields:
- `code: str | None`
- `params: dict[str, str | int | float | bool] | None`
Preserve RFC7807 required fields and media type.
**Step 2: Introduce unified domain error type**
In `core/http/errors.py`, create exception class carrying:
- http status
- stable error code (UPPER_SNAKE_CASE)
- optional params
- optional internal detail
**Step 3: Wire global exception handlers**
In `app.py`:
- Convert domain exceptions to problem+json with `code` and `params`.
- Keep fallback for unknown exceptions.
**Step 4: Define code naming convention**
Examples:
- `AUTH_INVALID_TOKEN`
- `AUTH_TOKEN_EXPIRED`
- `SCHEDULE_ITEM_NOT_FOUND`
- `TODO_TITLE_REQUIRED`
- `FRIENDSHIP_ALREADY_EXISTS`
---
### Task 4: Migrate backend hotspots from free-text detail to stable codes
**Files:**
- Modify: `backend/src/v1/friendships/service.py`
- Modify: `backend/src/v1/schedule_items/service.py`
- Modify: `backend/src/v1/todo/service.py`
- Modify: `backend/src/v1/agent/service.py`
- Modify: `backend/src/v1/users/service.py`
- Modify: `backend/src/v1/memories/service.py`
- Modify: `backend/src/v1/auth/gateway.py`
- Modify: `backend/src/v1/agent/router.py`
- Modify: other files with `HTTPException(detail=...)`
**Step 1: Prioritize by impact**
Order:
1. Auth
2. Agent
3. Todo/Schedule/Friendships
4. Users/Memories
**Step 2: Replace throw sites**
For each detail-based throw:
1. Map to stable `code`.
2. Keep detail only as optional server diagnostic text.
3. Add params when useful (e.g., max size, field, limit).
**Step 3: Preserve backwards compatibility window**
During transition:
- Keep `detail` present.
- Add `code`/`params` immediately.
- Frontend prefers `code`, falls back to existing behavior.
---
### Task 5: Frontend network error mapping to l10n via backend code
**Files:**
- Modify: `apps/lib/core/network/api_exception.dart`
- Create: `apps/lib/core/network/error_code_mapper.dart`
- Modify: call sites currently displaying raw backend detail
- Modify: `apps/lib/l10n/app_zh.arb`, `apps/lib/l10n/app_en.arb`
**Step 1: Parse `code` and `params` from response payload**
In `ApiException.fromDioError`:
- Read RFC7807 + extension fields.
- Keep `statusCode` fallback behavior.
**Step 2: Map `code -> localized message`**
Implement central mapper:
- Input: code/status/params
- Output: localized user-facing string key resolution
**Step 3: Fallback strategy**
Priority:
1. known code mapping
2. status-based generic mapping
3. safe generic fallback (`request failed` localized)
**Step 4: Replace UI direct usage of raw server detail**
Audit and update places where `e.toString()` or backend detail is shown directly.
---
### Task 6: Continue frontend hardcoded text migration (remaining files)
**Files:**
- Modify: `apps/lib/features/settings/presentation/screens/*.dart` (remaining high-count files)
- Modify: `apps/lib/features/calendar/presentation/screens/*.dart`
- Modify: `apps/lib/features/calendar/presentation/widgets/*.dart`
- Modify: `apps/lib/l10n/app_zh.arb`, `apps/lib/l10n/app_en.arb`
**Step 1: Batch by screen group**
Batch A: settings deep pages
Batch B: calendar pages
Batch C: shared/home leftovers
**Step 2: Migrate with key hygiene**
Rules:
- key names are feature-prefixed and stable
- dynamic texts use placeholders, not string concatenation
- avoid duplicate semantic keys
**Step 3: After each batch, run verification**
Run:
- `cd apps && flutter gen-l10n`
- `cd apps && flutter analyze`
Track remaining hardcoded-literal count after each batch.
---
### Task 7: Protocol docs and test updates
**Files:**
- Modify: `docs/protocols/agent/api-endpoints.md`
- Modify/Create: `docs/protocols/common/error-contract.md`
- Modify: backend integration/unit tests asserting only `detail`
- Modify: frontend tests around error display/mapping
**Step 1: Document new error response shape**
Example:
```json
{
"type": "about:blank",
"title": "Unprocessable Entity",
"status": 422,
"detail": "Validation failed",
"code": "TODO_TITLE_REQUIRED",
"params": {"field": "title"},
"instance": "/api/v1/todo"
}
```
**Step 2: Update tests to assert codes first**
Replace brittle text assertions with:
- `status`
- `code`
- optional `params`
---
### Task 8: Final verification gate
**Files:**
- Verify only
**Step 1: Apps verification**
Run:
- `cd apps && flutter gen-l10n`
- `cd apps && flutter analyze`
**Step 2: Backend verification**
Run:
- `cd backend && uv run ruff check .`
- `cd backend && uv run basedpyright`
- `cd backend && uv run pytest -q`
**Step 3: Cross-contract smoke**
Run targeted API checks ensuring error payload includes `code` for representative modules.
---
### Task 9: Rollout and compatibility
**Files:**
- Modify: release notes/changelog if used
**Step 1: Progressive rollout strategy**
- Phase 1: backend emits both `detail` + `code`
- Phase 2: frontend consumes `code` with fallback
- Phase 3: clean up legacy detail-dependent branches
**Step 2: Monitoring**
- Log unknown/unmapped error codes on frontend
- Add backend metrics for top emitted error codes
---
## Risks and mitigations
- Risk: non-UI code loses localization access after wrapper removal
- Mitigation: inject messages from UI/service boundary; avoid static locale globals.
- Risk: backend code migration is broad (many detail throws)
- Mitigation: staged module-by-module migration + compatibility window.
- Risk: front/back mismatch in error code enum
- Mitigation: shared protocol doc + CI checks for known code list.
## Done criteria
- `apps/lib/core/l10n/l10n.dart` removed or reduced to zero-overlap minimal utility with explicit justification.
- Backend RFC7807 responses include stable `code` (and optional `params`) on migrated endpoints.
- Frontend maps known codes to zh/en l10n; raw detail is no longer primary user-facing string.
- Hardcoded visible Chinese text count in `apps/lib` reduced to agreed threshold or zero for targeted modules.
- Docs and tests updated accordingly.
@@ -0,0 +1,556 @@
# 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,214 @@
# 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`
- [ ] 手动测试分享流程
@@ -0,0 +1,473 @@
# 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 显示,不参与权限判断
+9 -1
View File
@@ -52,7 +52,7 @@ Base URL: `/api/v1/agent`
## 2) GET `/runs/{thread_id}/events`
订阅指定 thread 的实时事件流。
订阅指定 thread 的实时事件流(按 `runId` 隔离当前 run
### Path
@@ -64,6 +64,7 @@ Base URL: `/api/v1/agent`
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
| `runId` | string | - | 目标 run ID(必填)。SSE 仅输出该 run 事件 |
| `idle_limit` | integer | `300` | 最大空闲轮询次数(1-3600) |
### Headers
@@ -81,6 +82,11 @@ Base URL: `/api/v1/agent`
- usage 审计与成本回退策略见 `docs/protocols/agent/sse-events.md`5) Usage 审计协议)
- 空闲时会发送 keep-alive 注释行 `: keep-alive`
run 过滤语义:
- 服务端会读取 thread 的事件流游标,但仅向客户端发送 `event.runId == query.runId` 的事件。
- SSE 连接终止条件为“目标 run 收到 `RUN_FINISHED``RUN_ERROR`”,其他 run 的 terminal 事件不会终止当前连接。
当前阶段执行说明:
- `chat` 模式采用两阶段:`router` -> `worker`
@@ -91,6 +97,7 @@ Base URL: `/api/v1/agent`
- `401` 未认证
- `403` 非会话所有者
- `422` `runId` 非法
- `422` `Last-Event-ID` 非法
- `429` 超过 SSE 连接数限制
@@ -279,6 +286,7 @@ Agent 路由的错误同样遵循统一 HTTP 错误契约,详见:
- `AGENT_RUN_INPUT_INVALID`
- `AGENT_RUN_MESSAGES_INVALID`
- `AGENT_INVALID_RUN_ID`
- `AGENT_INVALID_LAST_EVENT_ID`
- `AGENT_SSE_CONNECTION_LIMIT`
- `AGENT_ATTACHMENT_EMPTY`
+13 -1
View File
@@ -2,6 +2,8 @@
本文档描述 `GET /api/v1/agent/runs/{thread_id}/events` 的事件协议。
> 当前协议要求 SSE 订阅显式携带 `runId`,事件输出按 run 隔离。
---
## 1) 事件管道
@@ -13,7 +15,7 @@
3. 事件同时:
- 持久化到数据库(用于 history)
- 发布到 Redis Stream(用于 SSE
4. `/runs/{thread_id}/events` 从 Redis Stream 读取并输出 SSE
4. `/runs/{thread_id}/events` 从 Redis Stream 读取并输出 SSE(仅输出目标 `runId` 事件)
---
@@ -30,6 +32,7 @@ data: <json>
- `id` 可用于断点续流(`Last-Event-ID`
- `event` 与 JSON 内 `type` 一致(例如 `RUN_STARTED`
- 仅当 payload 中 `runId` 与 query `runId` 一致时才会对外发送
- 空闲时可能出现 keep-alive 注释帧:
```text
@@ -41,6 +44,15 @@ data: <json>
## 3) 事件类型(当前实现)
### 3.0 过滤与终止规则
- 请求参数 `runId` 为本次订阅目标 run。
- 服务端只转发 `event.runId == runId` 的事件。
- SSE 连接仅在目标 run 收到 terminal 事件后结束:
- `RUN_FINISHED`
- `RUN_ERROR`
- 其他 run 的事件(包括 terminal)不会结束当前连接。
### 3.1 Run 生命周期
#### `RUN_STARTED`
@@ -0,0 +1,83 @@
# 客户端缓存键作用域规范(Cache Key Scoping
## 目标
防止同一设备多账号切换时出现缓存串读(A 账号数据在 B 账号展示)。
本规范适用于 `apps/lib/data/cache/**` 以及所有通过 `CachedRepository` 读写的业务缓存。
---
## 作用域模型
### 1) 作用域级别
- `anonymous`:未登录态(或未绑定用户上下文)
- `user:<user_id>`:已登录用户态
### 2) 键格式
客户端持久缓存最终键必须按以下格式生成:
`cache:<scope>:<feature-key>`
其中 `<scope>` 可以附带会话代际后缀(推荐):
- `user:<user_id>:v<epoch>`
- `anonymous:v<epoch>`
该后缀用于切号并发场景,确保旧会话异步回写落在旧命名空间。
示例:
- `cache:user:8ef4...:chat:history:first:default`
- `cache:user:8ef4...:v12:chat:history:first:default`
- `cache:user:8ef4...:v12:calendar:day:2026-03-29`
- `cache:anonymous:v13:inbox:list:all`
---
## 责任边界
### 基础层(必须)
- `CachedRepository` 负责统一附加 `<scope>` 前缀。
- Feature Repository 只声明业务键(`<feature-key>`),不得手工拼接 userId 前缀。
### 应用层(必须)
- 在认证状态变化时更新当前缓存作用域:
- 登录成功 -> `user:<user_id>`
- 登出/失效 -> `anonymous`
---
## 并发与切号安全
- 切号后,旧账号异步请求结果不得回写到新账号 UI 状态。
- 推荐使用会话代际(epoch/token)保护异步回写。
- 缓存分区与 UI 状态隔离必须同时存在:
- 仅有分区,无代际保护:仍可能出现瞬时回流显示。
- 仅有代际保护,无分区:仍可能读取到旧持久缓存。
---
## 兼容与迁移策略
### 向后兼容
- 旧无作用域键允许保留在本地存储中,不参与新读取路径。
- 新版本只读取带 `cache:<scope>:` 前缀的键。
### 迁移方式
- 采用增量迁移(additive),不执行强制删除旧键。
- 如需清理旧键,必须通过统一维护任务处理,不在功能逻辑中零散实现。
---
## 验收标准
1. 同设备 A/B 账号来回切换,不出现跨账号历史/列表串读。
2. 登录后首次读取命中当前用户作用域键;登出后读取命中匿名作用域键。
3. Feature 仓库不再自行实现 userId 拼 key 逻辑。
@@ -51,6 +51,7 @@ When creating/modifying/deprecating any code, this table must be updated in the
|---|---|---:|---|
| `AGENT_RUN_INPUT_INVALID` | agent | 422 | Run input payload invalid |
| `AGENT_RUN_MESSAGES_INVALID` | agent | 422 | Run messages contract invalid |
| `AGENT_INVALID_RUN_ID` | agent | 422 | SSE runId query invalid |
| `AGENT_INVALID_LAST_EVENT_ID` | agent | 422 | SSE Last-Event-ID invalid |
| `AGENT_SSE_CONNECTION_LIMIT` | agent | 429 | SSE connections exceed per-user limit |
| `AGENT_ATTACHMENT_EMPTY` | agent | 422 | Attachment payload empty |
@@ -114,6 +115,7 @@ When creating/modifying/deprecating any code, this table must be updated in the
| `SCHEDULE_ITEM_PAGE_INVALID` | schedule_items | 400 | Pagination `page` must be greater than or equal to 1 |
| `SCHEDULE_ITEM_PAGE_SIZE_INVALID` | schedule_items | 400 | Pagination `page_size` out of allowed range |
| `SCHEDULE_ITEM_SHARE_FORBIDDEN` | schedule_items | 403 | Current user cannot share this schedule item |
| `SCHEDULE_ITEM_FORBIDDEN` | schedule_items | 403 | Current user does not have permission to edit this schedule item |
| `SCHEDULE_ITEM_SHARE_PERMISSION_EXCEEDED` | schedule_items | 403 | Requested share permission exceeds inviter permission |
| `SCHEDULE_ITEM_SUBSCRIPTION_ALREADY_ACTIVE` | schedule_items | 400 | Recipient already has active subscription |
| `SCHEDULE_ITEM_INVITE_ALREADY_SUBSCRIBED` | schedule_items | 400 | Recipient already accepted calendar invite |
@@ -172,6 +174,7 @@ Exit code policy:
- `AGENT_RUN_INPUT_INVALID`
- `AGENT_RUN_MESSAGES_INVALID`
- `AGENT_INVALID_RUN_ID`
- `AGENT_INVALID_LAST_EVENT_ID`
- `AGENT_SSE_CONNECTION_LIMIT`
- `AGENT_ATTACHMENT_EMPTY`
+8 -2
View File
@@ -162,6 +162,12 @@ interface UiBadgeNode extends UiBaseNode {
}
```
`label` contract:
- Backend SHOULD return stable i18n tokens for status badges: `ui.status.info|success|warning|error|pending`
- Frontend is responsible for localizing these tokens by current locale
- Backward compatibility: frontend SHOULD still tolerate legacy uppercase labels (`INFO/SUCCESS/...`) during migration
- Unknown token fallback: frontend SHOULD keep original label (no semantic remap to other statuses)
#### 4. Button Node
```typescript
@@ -455,7 +461,7 @@ interface UiIconSpec {
},
{
"type": "badge",
"label": "ERROR",
"label": "ui.status.error",
"status": "error"
}
]
@@ -729,7 +735,7 @@ UiSchemaRenderer (for frontend rendering)
"gap": 8,
"children": [
{"type": "text", "content": "日程已创建", "role": "title"},
{"type": "badge", "label": "SUCCESS", "status": "success"}
{"type": "badge", "label": "ui.status.success", "status": "success"}
],
"justify": "space-between",
"align": "center"
@@ -1,73 +0,0 @@
# Frontend Cache + SWR + Boot Prewarm Implementation Plan (Updated)
> This document replaces the previous version and removes outdated path references.
## Current Architecture Baseline
- Shared infrastructure is under `apps/lib/data/` only:
- cache: `apps/lib/data/cache/`
- network: `apps/lib/data/network/`
- storage: `apps/lib/data/storage/`
- Feature business repositories/models live under each feature:
- calendar: `apps/lib/features/calendar/data/repositories/`, `apps/lib/features/calendar/data/models/`
- messages: `apps/lib/features/messages/data/repositories/`, `apps/lib/features/messages/data/models/`
- contacts: `apps/lib/features/contacts/data/repositories/`, `apps/lib/features/contacts/data/models/`
- chat history cache repo: `apps/lib/features/chat/data/repositories/chat_history_repository.dart`
## Scope
1. Keep cache infra generic and reusable.
2. Keep TTL policy defined per feature repository (not centralized in shared cache).
3. Keep boot prewarm bounded by timeout with cache-first UX.
## Tasks
### Task A: Cache Infra Stability
**Files:**
- `apps/lib/data/cache/cache_store.dart`
- `apps/lib/data/cache/cache_policy.dart`
- `apps/lib/data/cache/cached_repository.dart`
**Checks:**
- `flutter test apps/test/data/cache/cached_repository_test.dart`
- `flutter test apps/test/data/cache/hybrid_cache_store_test.dart`
- `flutter test apps/test/data/cache/shared_prefs_cache_store_test.dart`
### Task B: Feature Repositories Use Local TTL Policies
**Files:**
- `apps/lib/features/todo/data/repositories/todo_repository.dart`
- `apps/lib/features/messages/data/repositories/inbox_repository.dart`
- `apps/lib/features/contacts/data/repositories/friend_repository.dart`
- `apps/lib/features/settings/data/repositories/user_profile_cache_repository.dart`
- `apps/lib/features/chat/data/repositories/chat_history_repository.dart`
- `apps/lib/features/calendar/data/repositories/calendar_repository.dart`
**Checks:**
- `flutter test apps/test/data/repositories/shared_repositories_test.dart`
- `flutter test apps/test/features/chat/data/repositories/chat_history_repository_test.dart`
- `flutter test apps/test/features/settings/data/repositories/user_profile_cache_repository_test.dart`
### Task C: Auth Boot Prewarm Gate
**Files:**
- `apps/lib/app/services/app_prewarm_orchestrator.dart`
- `apps/lib/app/router/app_router.dart`
- `apps/lib/features/auth/presentation/screens/auth_boot_screen.dart`
- `apps/lib/app/di/injection.dart`
**Checks:**
- `flutter test apps/test/app/services/app_prewarm_orchestrator_test.dart`
- `flutter test apps/test/app/router/app_router_redirect_test.dart`
### Task D: Regression Safety
**Checks:**
- `flutter analyze`
- Run all tests from Tasks A/B/C together before finalizing.
## Notes
- Any old path references from previous plan versions are obsolete.
- Notification/reminder data-interaction services are intentionally removed for separate rewrite.