# 21 M3 第三波:community feature 数据层(T3-12) **执行日期**:2026-09-09 **工单**:T3-12 community feature 数据层——第三波前置,T3-13~17 全依赖本单 **契约依据**:openapi.yaml v1.3.0(冻结稿,community/media 域 13 路径 / 19 操作) **提交**:patbond-flutter dev `19bd8c1`(基线 `66f983d`) --- ## 0. 概要 照 M2 pets 数据层模式(Controller / Repository / ApiClient 分层、类型化异常、 分端口直连)新建 `lib/features/community/`,交付五个生产文件 + 四个测试文件: | 文件 | 职责 | |------|------| | `lib/features/community/community_models.dart` | 全部 DTO,手写 JSON 逐字段照契约;未知枚举抛 FormatException 暴露漂移 | | `lib/features/community/community_exceptions.dart` | v1.3.0 新增 9 码 + 40902 共码的类型化异常与映射 | | `lib/features/community/community_repository.dart` | 抽象接口 + ApiCommunityRepository,19 操作全覆盖 | | `lib/features/community/toggle_sync.dart` | 点赞/收藏共用的乐观更新状态机(数据层部分) | | `lib/features/community/community_controller.dart` | Feed 多页缓存 + 四态骨架、详情副本、reset | | `lib/core/models/cursor_page.dart` | CursorPage 自 pet_models 上移 core(pets 侧 export 兼容,零调用方改动) | 配套改动:`lib/core/network/api_client.dart` 新增 `patbondCommunityApiBaseUrl` (`--dart-define=PATBOND_COMMUNITY_API_BASE_URL`,默认 `http://127.0.0.1:8084`); `lib/core/network/api_exception.dart` ApiCodes 增 9 码;`lib/app/app.dart` 装配 CommunityController(共享 TokenRefresher,登出与 pets 同步 reset)。 主壳 UI 未接线(T3-14 挂 Feed segment 时注入)。 **质量门禁**:`flutter test` 347/347 全绿(基线 286,+61)、`flutter analyze` 0 问题、`dart format --set-exit-if-changed` 无 diff。 --- ## 1. 19 操作覆盖对照表 | # | operationId | 方法/路径 | 仓库方法 | 备注 | |---|-------------|-----------|----------|------| | 1 | createMediaUpload | POST /api/v1/media/uploads | `createMediaUpload` | 两步上传第一步,返回预签名 PUT 凭据(TTL 10 min,不持久化) | | 2 | completeMediaUpload | POST /api/v1/media/uploads/{assetId}/complete | `completeMediaUpload` | 服务端幂等(已 ready 重复 confirm 200 同 asset) | | 3 | createPost | POST /api/v1/posts | `createPost` | **Idempotency-Key 必带** | | 4 | getPost | GET /api/v1/posts/{postId} | `getPost` | 防枚举 40403 | | 5 | updatePost | PATCH /api/v1/posts/{postId} | `updatePost` | version 乐观锁;`publish: true` 即 `status: published`;media 三态(缺席/[]/整组替换) | | 6 | deletePost | DELETE /api/v1/posts/{postId} | `deletePost` | 软删,重复删同 404/40403 | | 7 | listMyPosts | GET /api/v1/me/posts | `listMyPosts` | keyset 游标 + status 过滤(draft\|published) | | 8 | getFeed | GET /api/v1/feed | `getFeed` | (published_at,id) 游标 | | 9 | listComments | GET /api/v1/posts/{postId}/comments | `listComments` | 游标分页,单层平铺 | | 10 | createComment | POST /api/v1/posts/{postId}/comments | `createComment` | **Idempotency-Key 必带**;replyToUserId 可选 @ | | 11 | deleteComment | DELETE /api/v1/comments/{commentId} | `deleteComment` | 顶层短路径先例 | | 12 | likePost | PUT /api/v1/posts/{postId}/like | `likePost` | 语义幂等,返回权威 {liked,likeCount} | | 13 | unlikePost | DELETE /api/v1/posts/{postId}/like | `unlikePost` | 取消不存在的点赞 200 no-op | | 14 | bookmarkPost | PUT /api/v1/posts/{postId}/bookmark | `bookmarkPost` | 与点赞同构 | | 15 | unbookmarkPost | DELETE /api/v1/posts/{postId}/bookmark | `unbookmarkPost` | 同上 | | 16 | listMyBookmarks | GET /api/v1/me/bookmarks | `listMyBookmarks` | 项形态 = FeedCard | | 17 | followUser | PUT /api/v1/users/{userId}/follow | `followUser` | 自关注 422/42204 | | 18 | unfollowUser | DELETE /api/v1/users/{userId}/follow | `unfollowUser` | 自取关 200 幂等 no-op | | 19 | getFollowStats | GET /api/v1/users/{userId}/follow-stats | `getFollowStats` | 实时 COUNT,查自己 followedByMe 恒 false | 定型语义落点(20 号收口 §1 逐条): - **预签名 URL 不持久化**:MediaUploadCredentials / MediaAsset.url / PostMediaItem.url / AuthorSummary.avatarUrl 的 doc 注释均标注「每次响应现签, 不得持久化、过期即重取」,DTO 不做任何本地缓存。直传 PUT 本体属 T3-13, 本单只到协议层(凭据 DTO 含 `requiredHeaders` 原样映射)。 - **Idempotency-Key 必带 + 刷新重放同键**:键在仓库层每次调用生成一次 (UUID v4,≤128 字符),ApiClient 401/40101 单飞刷新后的重放走同一 headers ——同键命中服务端首次结果,不重复建帖/评论(测试断言两次请求同键)。 - **PUT/DELETE 权威终态**:四个互动方法与关注两方法直接返回服务端 LikeState/BookmarkState/FollowState,ToggleSync 以此对账。 - **防枚举 40403**:PostNotFoundException 注明「hidden/archived 对作者亦不露、 互动面 = 帖子公开面含本人草稿」。 - **AuthorSummary nullable 降级**:`isDegraded`(nickname 与 avatarUrl 同为 null)一个占位判定口,客户端不做昵称回退拼装。 ## 2. 错误码映射(v1.3.0 新增 9 码 + 共码) | 码 | 类型化异常 | 语义 | |----|-----------|------| | 40301 | PostAccessDeniedException | 对可见帖/评论无操作权限 | | 40403 | PostNotFoundException | 帖子防枚举合并 | | 40404 | CommentNotFoundException | 评论防枚举合并 | | 40405 | MediaAssetNotFoundException | asset 防枚举合并 | | 40406 | CommunityUserNotFoundException | 目标用户不存在/已注销 | | 40902 | PostVersionConflictException | 乐观锁共码,community 域独立类型 | | 40905 | IdempotencyMismatchException | 同键异 payload | | 42203 | MediaNotReadyException | 引用非 ready asset | | 42204 | SelfFollowException | 自关注(仅 PUT) | | 42205 | MediaUploadStateException | confirm 状态不允许 | 未覆盖码(40000、40401 宠物码等)原样透传通用 ApiBusinessException, 既有按基类捕获的处理不受影响(与 pets 域映射器同构)。 ## 3. ToggleSync 状态机(数据层部分) 03 号评估 §3 草案的定稿实现,点赞/收藏共用一套(字段读写 read/write、 端点 send、代次 generation 全参数化,like/bookmark 各持一实例): ``` 点击 toggle(id) ├─ read(id) == null(已被刷新剔除)→ 作废 ├─ 立即 write 翻转内存副本(计数 ±1,同帧反馈) ├─ 无在途链 → 记快照(链起点)+ 记代次 → send(target) └─ 有在途链 → 只并入 pendingTarget,不发新请求(单飞) 响应到达 ├─ 链已被 reset / 代次不符(期间刷新)→ 丢弃,不覆盖不回滚 ├─ 成功且 pendingTarget ≠ 确认态 → 以最终意图补发一次(连点至多两在途) ├─ 成功且意图一致 → write 服务端权威 {active,count}(吸收他人并发偏差),清链 └─ 失败 → 校验「id 仍可读且当前态 == 本轮乐观目标」后恢复快照, onError 轻提示,不自动重试,清链 ``` 一句话:**乐观翻转 + 快照回滚 + 单飞合并最终意图 + 代次守卫,以服务端 权威终态收敛**。UI 侧 SnackBar/图标反馈属 T3-15/16(controller 已暴露 `toggleError` 一次性消费口)。 CommunityController 骨架:首屏四态(initial/loading/ready/error)+ 尾部 LoadMorePhase(idle/loading/error)+ 多页内存缓存;刷新 = 代次 +1 + 整体 替换(失败保留旧列表走 refreshError);loadMore 携带上一页 nextCursor, 旧代次尾页响应丢弃(避免刷新后重复/错位);详情 `getPost` 入 `_postCache` 并回写卡片互动字段(Feed 卡片与详情页同源);`reset()` 登出清态 (app.dart 与 pets 同一监听点)。 ## 4. 测试数变化 | 项 | 基线 | 本单后 | |----|------|--------| | flutter test | 286 | **347(+61)** | | flutter analyze | 0 | 0 | | dart format | 无 diff | 无 diff | 新增分布:模型映射与请求序列化 15、仓库 19 操作线路 + 幂等键 + 错误映射 24、 controller 竞态序列(四态/游标拼接/单飞补发/代次守卫/reset)22。 竞态序列全部用 FakeCommunityRepository + Completer 控时序(test/helpers 先例)。 验证命令(仓库根目录执行): ```bash cd flutter analyze flutter test dart format --set-exit-if-changed --output=none . ``` ## 5. 契约核对与遗留 - 本单实现与 openapi.yaml v1.3.0 逐字段核对,**未发现契约不一致**, 未改动契约与 patbond-api。 - CreatePostRequest.category 只开放 general/help(ai_creation 提交 400/40000),DTO 读侧三值、写侧由调用方约束;Post/FeedCard 读侧可解析 ai_creation。 - 遗留给后续工单:T3-13 预签名 PUT 直传客户端(裸 Dio,两段异构错误)、 T3-14 Feed segment UI 接线(主壳注入 CommunityController)、 T3-15/16 互动 UI 反馈(SnackBar 消费 toggleError)、T3-17 发布页。 --- **Frontend Developer(Flutter)** **日期**:2026-09-09