diff --git a/docs/architecture/backend-modules.md b/docs/architecture/backend-modules.md index 9867038..eea4e31 100644 --- a/docs/architecture/backend-modules.md +++ b/docs/architecture/backend-modules.md @@ -2,7 +2,7 @@ > 本页是后端结构的**唯一权威速览**:模块怎么划分、各自负责什么、端口与依赖关系。 > 结构性变更(新增/拆分模块)须经 ADR 决策并同步更新本页。 -> 最后更新:2026-09-07(M2 第二波,T2-03 进行中)。 +> 最后更新:2026-09-08(M3 第一波,T3-02 骨架落地)。 ## 一图速览 @@ -11,10 +11,11 @@ patbond-api(Maven 多模块,Spring Boot 3.5 + JDK 17) ├── patbond-common 公共库(无端口,被其余模块依赖) ├── patbond-auth 认证服务 :8081 ├── patbond-user 用户服务 + 埋点 :8082 ← Flyway 迁移链唯一持有者 -└── patbond-pet 宠物健康档案服务 :8083 ← M2 新增(ADR-009) +├── patbond-pet 宠物健康档案服务 :8083 ← M2 新增(ADR-009) +└── patbond-community 社区服务 :8084 ← M3 新增(ADR-017) ``` -部署形态:docker compose 四容器(postgres:18 + auth + user + pet),应用容器无状态(ADR-007)。 +部署形态:docker compose 五容器(postgres:18 + auth + user + pet + community),应用容器无状态(ADR-007)。 ## 模块职责 @@ -22,7 +23,7 @@ patbond-api(Maven 多模块,Spring Boot 3.5 + JDK 17) - 统一错误信封与业务错误码体系(`code`/`message`/`data` 结构,稳定错误码契约) - 共享异常类型与基础组件 -- **不含业务逻辑、不起服务**;其余三个模块都依赖它 +- **不含业务逻辑、不起服务**;其余四个服务模块都依赖它 ### patbond-auth(认证域,:8081) @@ -47,6 +48,14 @@ patbond-api(Maven 多模块,Spring Boot 3.5 + JDK 17) - 照片/附件本迭代不做(ADR-010,media 域待对象存储选型) - **当前状态**:模块骨架 + T2-03(CRUD + 权限框架)施工中;数据表已由 V3 建好 +### patbond-community(社区域,:8084,M3 新增) + +- 社区 Feed / 帖子 / 单层评论 / 点赞收藏 / 关注(ADR-018 的 M3 MVP 范围;话题表已建但功能首版剪出) +- 只读写 `community` schema(数据表由 V5 建);作者公开资料按 D3-9 方案 B 经 patbond-user 的 /internal 批量接口取数(后续波次落地) +- `/api/v1/**` 自骨架起即接 RS256 资源侧校验(与 user/pet 同一公钥约定);`/health` 探活在 /api/v1 之外 +- media 上传流程不在本模块(ADR-017:实现在 patbond-user,社区侧只做 asset 只读校验) +- **当前状态**:第一波骨架(T3-02)已落地;业务端点随 M3 后续波次按契约实现 + ## 关键纪律 1. **契约先行**:所有对外端点以 `patbond-doc/docs/api/openapi.yaml` 为唯一事实源,新接口先冻结契约再实现(M2 起)。 diff --git a/docs/development/iterations/iteration-3/09-community-foundation-report.md b/docs/development/iterations/iteration-3/09-community-foundation-report.md new file mode 100644 index 0000000..9d1ec0e --- /dev/null +++ b/docs/development/iterations/iteration-3/09-community-foundation-report.md @@ -0,0 +1,120 @@ +# M3 第一波社区地基施工报告(数据与骨架线:V5 迁移 + patbond-community 骨架) + +> 作者:Senior Developer(后端) +> 日期:2026-09-08 +> 工单:T3-01(Flyway V5 community schema 迁移)、T3-02(patbond-community 模块骨架与鉴权接入) +> 代码基线:patbond-api `64c9b72`(191 测试全绿)→ 交付 `3c671fc`(206 测试全绿) +> 结论先行:**V5 建 community 全部 8 表,2 条跨 schema 外键(generation_job_id→creation、region_id→platform.regions)按拍板剥离;patbond-community(:8084)挂入构建链、自骨架起 /api/v1/** 即接 RS256 校验并纳入 compose 第五容器;干净 postgres:18 上 V1→V5 全量迁移一次成功,全套 206 测试全绿。** + +--- + +## 1. 提交清单 + +按工单各一逻辑提交,全部已推送 `origin/dev`: + +| 提交 | 内容 | +| --- | --- | +| `a97814a` | feat: Flyway V5 community schema 基线 + 迁移验证集成测试(T3-01) | +| `3c671fc` | feat: 新建 patbond-community 模块骨架(ADR-017,T3-02) | + +## 2. Flyway V5:表清单与裁剪对照(T3-01) + +### 2.1 V5 结构基线(`patbond-user/src/main/resources/db/migration/V5__community_baseline.sql`) + +从目标模型 `patbond-doc/docs/database/patbond_postgresql.sql`(718~875 行)提取,共建 **8 张表**: + +| # | 表 | 处置 | 与目标模型的差异 | +| --- | --- | --- | --- | +| 1 | `community.posts` | 建 | **剥离 2 条跨 schema FK**(见 2.2);列全保留,其余约束/索引无差异 | +| 2 | `community.post_media` | 建 | 无差异(`asset_id → media.assets` RESTRICT 保留,media 表 V1 已建;封面部分唯一索引 `uq_post_media_cover` 照建) | +| 3 | `community.comments` | 建 | 无差异(刻意单层平铺,`reply_to_user_id` 支持 @ 回复;幂等列 `client_request_id + request_hash` 照建) | +| 4 | `community.post_likes` | 建 | 无差异(PK (post_id, user_id) 天然幂等) | +| 5 | `community.post_bookmarks` | 建 | 无差异(同上) | +| 6 | `community.user_follows` | 建 | 无差异(含禁自关注 CHECK) | +| 7 | `community.topics` | 建 | 无差异(`name citext UNIQUE`)。**表建功能剪**:ADR-018 话题剪出 M3 MVP,但结构按目标模型建;**无种子数据进生产链**(测试断言 topics 为空) | +| 8 | `community.post_topics` | 建 | 无差异 | + +其余保留项:全部 CHECK 约束(`ck_posts_publish_state`、`ck_posts_idempotency`、`ck_comments_deleted` 等)、Feed 部分索引 `ix_posts_feed (published_at DESC, id DESC) WHERE status='published' AND visibility='public'`、`uq_posts_author_idempotency`、2 个 `updated_at` 触发器(posts/comments,复用 V1 的 `platform.set_updated_at()`)。到 `identity.users`、`pet_health.pets`、`media.assets` 的跨 schema FK 全部保留(三个 schema V1/V3 已存在,共库阶段先例)。 + +### 2.2 强制裁剪:2 条跨 schema 外键(逐条对照) + +按拍板(工单 T3-01,照 V3 剪 4 条 marketplace FK 的先例格式),对应字段保留为**裸可空 uuid 列**,索引照建,迁移文件头注释逐条标明补回时点: + +| # | 原定义 | V5 处置 | 补回时点 | +| --- | --- | --- | --- | +| 1 | `posts.generation_job_id → creation.generation_jobs(id) ON DELETE SET NULL` | 剥离;裸列保留,`ix_posts_generation_job` 索引保留 | **M4** 建 creation schema 的迁移补回 | +| 2 | `posts.region_id → platform.regions(id) ON DELETE SET NULL` | 剥离;裸列保留,`ix_posts_region`、`ix_posts_region_feed` 索引保留 | **M5** 地区体系迁移补回(ADR-018 将 region 剪出 M3) | + +说明:`platform.regions` 表 V1 已存在(02 号评估 1.4 节原判「保留」),但 PM 拆解按 ADR-018 范围裁剪把 region 体系整体划入 M5,本工单按拍板剪 FK——列与索引保留,M5 补回约束零成本。 + +### 2.3 扩展启用 + +- **`pg_trgm`**:02 号评估发现 V1 只建了 pgcrypto 与 citext,而 `ix_posts_content_trgm`(gin, `gin_trgm_ops`)需要 pg_trgm——V5 文件头 `CREATE EXTENSION IF NOT EXISTS pg_trgm` 补齐;postgres:18 官方镜像含 contrib,Testcontainers 与 compose 均实测无障碍。trgm 索引按 02 号建议照建(M3 无搜索需求,但成本极低、剪了偏离目标模型)。 +- **`citext`**:V1 已建;V5 以 `IF NOT EXISTS` 幂等重申(迁移日志出现一条「already exists, skipping」提示,无害)。 + +### 2.4 主键 DEFAULT 的取舍 + +02 号评估表格建议 V5「去掉主键 `DEFAULT gen_random_uuid()`」,但核对既有链:V1(users)与 V3(pets)**均保留了该 DEFAULT**,应用侧显式写入 UUIDv7、DB DEFAULT 仅作兜底。V5 与 V1/V3 同规**保留 DEFAULT**(工单要求「照 V3 先例写法」优先于评估建议;两者对运行时行为无差异,应用永远显式供 id)。 + +## 3. patbond-community 模块骨架(T3-02,ADR-017) + +```text +patbond-community/ +├── Dockerfile # 同 user/auth/pet 模式(temurin-17-jre,uid 10001,无状态,EXPOSE 8084) +├── pom.xml # 挂入父 pom;依赖对齐 pet(common/web/validation/jdbc/jjwt + 测试侧 user jar + Flyway + Testcontainers) +└── src/ + ├── main/java/com/patbond/patbond/community/ + │ ├── CommunityApplication.java # Spring Boot 入口 + │ ├── config/CommunitySecurityProperties.java # patbond.jwt.public-key + │ ├── config/SecurityConfig.java # BearerAuthFilter 注册到 /api/v1/*(order 20) + │ ├── config/JacksonConfig.java # 整数字段拒绝小数(与其余服务同规) + │ ├── security/{BearerAuthFilter,JwtVerifier,RsaPublicKeyLoader}.java # RS256 资源侧校验(user/pet 同款第三份复制) + │ ├── web/GlobalExceptionHandler.java # {code,message,data} 信封契约 + │ └── controller/HealthController.java # GET /health 探活(含 SELECT 1 连通检查,在 /api/v1 之外) + ├── main/resources/application.yml.sample # .sample 模式,默认端口 8084,DB/公钥经环境变量注入 + └── test/java/com/patbond/patbond/community/ + ├── TestcontainersConfiguration.java # postgres:18 @ServiceConnection;测试 classpath 挂 user jar + Flyway 跑全链 V1..V5 + ├── CommunityApplicationTests.java # 上下文冒烟 + ├── controller/HealthControllerTest.java # /health 200 + db=up + ├── security/BearerAuthIntegrationTest.java # 无 token/畸形/错签/过期 → 401+40101;有效 token 过滤器放行(未实现路由 404+40400) + └── support/TestJwtKeys.java # 运行时生成 RSA 对,无密钥材料入库 +``` + +关键取舍: + +- **鉴权自骨架起接入**(与 pet 骨架期不同):T3-02 验收要求无 token/过期 token 返回 401 + 40100 系,故 `BearerAuthFilter`/`JwtVerifier`/`RsaPublicKeyLoader` 随骨架落地(user/pet 同款第三份复制)。02 号评估 P9 建议的「纯 Java 件下沉 common」未进 ADR-016~021 拍板,本单不做,留待后续决策——届时三处复制件切换为共享件、测试全绿即证等价。 +- **Flyway 归属不拆**:community 生产 classpath 无 Flyway;单迁移链(V1..V5)由 patbond-user 启动统一执行。模块只经 JdbcClient 读写 `community` schema。 +- **compose 第五容器**:照 pet 服务块模式(build + .sample 挂载 + 环境变量注入 + 公钥只读挂载);`depends_on` postgres 健康 + user 先起(保证 V5 已执行、community schema 就绪)。 +- **作者资料取数**:按 ADR-017/D3-9 方案 B(user 增 /internal 批量公开资料接口),本单只搭骨架不实现。 + +## 4. compose 五容器验证 + +`./mvnw -DskipTests package` + `docker compose up -d --build` 实测(既有 pgdata volume,数据库处于 V4): + +- **五容器全部 Up**:postgres(healthy)+ auth + user + pet + community。 +- **增量迁移零影响**:user 启动日志 `Migrating schema "public" to version "5 - community baseline"` → `Successfully applied 1 migration ... now at version v5`——在带 M2 数据的既有库上 V5 增量应用成功(纯增量 schema,对 V1~V4 数据零影响的实证)。 +- **community 探活**:`GET :8084/health` → `{"code":0,...,"data":{"status":"ok","db":"up"}}`(pet :8083 同绿)。 +- **鉴权实证**:`GET :8084/api/v1/posts` 无 token → HTTP 401 + `{"code":40101,"message":"token 无效或过期"}`,与验收标准一致。 +- 验证后 `docker compose down`(保留 pgdata volume),恢复环境原状。 + +## 5. 测试数变化:191 → 206(+15,0 回归) + +| 模块 | 基线 | 交付 | 新增内容 | +| --- | --- | --- | --- | +| patbond-common | 3 | 3 | — | +| patbond-user | 68 | 76 | `CommunityMigrationIntegrationTest` 8 例(schema 存在、8 表齐、pg_trgm 扩展与 trgm 索引、2 条裁剪 FK 确不存在且裸列在、保留 FK 抽查、结构抽查、触发器 2 个、topics 无种子) | +| patbond-auth | 31 | 31 | — | +| patbond-pet | 89 | 89 | — | +| patbond-community | — | 7 | 上下文冒烟 1 + /health 探活 1 + 鉴权集成 5(无 token/畸形/错签/过期 → 401+40101、有效 token 放行)+ 迁移链随上下文启动隐式验证 | +| **合计** | **191** | **206** | `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test` 一次通过,BUILD SUCCESS | + +V1→V2→V3→V4→V5 全量迁移经 Testcontainers 在全新 postgres:18 容器上自动验证通过(user 与 community 两模块的每个 @SpringBootTest 上下文启动即执行全链迁移)。 + +## 6. 遗留与下一波衔接 + +- **2 条裁剪 FK 补回**:`generation_job_id` 随 M4 creation schema 迁移、`region_id` 随 M5 地区体系迁移(V5 文件头注释已标明)。 +- **P9 共享设施下沉**:JWT 校验件已是第三份复制,待拍板后统一下沉 common。 +- **作者公开资料 /internal 批量接口**(D3-9 方案 B):随 Feed/评论纵切(T3-05 等)在 patbond-user 侧落地。 +- **media 上传流程**(T3-03):归 patbond-user,本模块只做 asset 只读校验,随帖子纵切接入。 +- **CI**:多模块 reactor 自动含 patbond-community,`.gitea/workflows/ci.yml` 零改动。 +- **backend-modules.md**:已同步加 patbond-community 行与五容器口径(doc 仓工作区修改,随波末收口提交)。 diff --git a/docs/development/iterations/iteration-3/10-analytics-queue-hardening.md b/docs/development/iterations/iteration-3/10-analytics-queue-hardening.md new file mode 100644 index 0000000..17f826a --- /dev/null +++ b/docs/development/iterations/iteration-3/10-analytics-queue-hardening.md @@ -0,0 +1,68 @@ +# 埋点队列三项完善实施报告(M3 第一波 T3-19 前端半边) + +> 作者:Frontend Developer(Flutter) +> 日期:2026-09-08 +> 依据:`iteration-2/15-analytics-persistent-queue.md` §4 遗留清单、`iteration-3/06-experiment-tracking-plan.md` §2.3(三项处置与优先级)、`iteration-1/13-tracking-implementation-spec.md` §3.3/§3.4 +> 仓库:patbond-flutter dev 分支,提交 `4d40c38`(基线 `720865b`) + +--- + +## 1. 背景 + +15 号报告 §4 留下队列三个未做项:30 秒定时冲刷、失败退避、anonymousId 持久化。iteration-3 06 号 §2.3 把三项全部排入 M3(定时冲刷 P1:社区长前台会话最多积压 19 条不上传;anonymousId P2:A/B 前置 #4「登录前分流」硬依赖),ADR-020 拍板升为第一波必做。本波三项一次落地,既有语义(flushNow、4xx 毒丸丢弃、at-least-once 删段、按段拼批 ≤50、eventId UUIDv7)零回退。 + +## 2. 设计要点 + +### 2.1 30 秒定时冲刷(13 号 §3.4 第 4 触发点,四触发点补齐) + +- `AnalyticsService` 新增 `startPeriodicFlush()` / `stopPeriodicFlush()`:前台期间 `Timer.periodic`(周期 `flushInterval`,默认 30 秒,构造参数化便于测试)触发 `_flush()`;`startPeriodicFlush` 幂等(`??=`),不叠加定时器。 +- 生命周期挂接(`app.dart` + `SessionTracker`): + - `SessionTracker` 新增 `onEnterForeground` 回调,复用既有「只在离开/回到 resumed 的第一次变更触发」的级联去重逻辑——回前台级联 `hidden → inactive → resumed` 只回调一次,冷启动首个 resumed(此前未离开过前台)不触发。 + - App `initState` 启动定时器;退后台回调改为「停定时器 + `flushNow()`」(既有退后台冲刷保留);回前台恢复定时器;App `dispose` 停定时器(widget 测试无悬挂 Timer)。 +- 与既有触发共存:满 20 条、退后台 `flushNow`、冷启动 `restore` 三个触发点原样保留;队列为空时定时器 tick 是廉价空转(`takeBatch` 为空即返回,无网络请求、无持久化写)。 + +### 2.2 失败指数退避(06 号 §2.3:客户端退避先行,不依赖后端限流) + +- 上传失败(网络错误/5xx)后进入退避:首次 30s,×2 递增(30s→60s→120s→240s),封顶 5 分钟;退避窗口内**定时冲刷 tick 直接跳过**,到点后下一 tick 重试。 +- 任一批上传拿到服务端应答(202 受理或 4xx 拒绝——连通性已恢复)即重置退避,恢复 30 秒节奏。 +- **退避只挡定时冲刷**:`flushNow`(退后台)、满 20 条、冷启动 `restore` 等显式触发不受限——退后台是最后的上传窗口,不能被退避挡掉。 +- **429 处理**:从「4xx 毒丸丢弃」改为按网络错误同路径(保段 + 退避重试)。后端限流从未实现(iteration-2/09 出入项核实),`Retry-After` 精细分支待其落地后一并做,代码内已留注释说明。 +- 时钟经构造注入(`now` 参数,照 SessionTracker 先例),退避判定测试免真实等待。 + +### 2.3 anonymousId 持久化(13 号 §3.3 key,跨启动稳定) + +- 现状是每次冷启动 `Uuid().v4()` 随机生成,登录前事件无法跨启动归并。本波在 `restore()` 中增加采用/落盘:shared_preferences key `pb.analytics.anonymousId` 已有值则采用;无值则把本次构造生成的 v4 落盘——首次生成后跨冷启动稳定。 +- 读取/写入失败(持久化不可用)降级为进程内临时 id,只打日志绝不抛出(埋点旁路原则),埋点照常入队。 +- 构造显式注入 `anonymousId` 的测试通道不参与持久化采用/落盘,既有测试语义(`anon-123` 断言)不受影响。 +- 已知边界:`restore()` 完成前 track 的事件仍带构造时的临时 id(首启时两者同值无影响;后续启动 app 装配层在挂接 tracker 前即调用 restore,实际窗口趋近于零),记录备查。 + +### 2.4 可测性改造 + +`_upload` 提升为 `@protected @visibleForTesting` 的 `uploadBatch`:定时/退避测试以假上传子类替换网络层,在 `fakeAsync` 内驱动 `Timer.periodic`(真实 HttpServer 在假异步区无法完成 IO)。既有 HttpServer 集成测试不受影响,继续走真实 HTTP 路径。 + +## 3. 测试变化 + +- 基线 272 → **286 全绿**(+14);`flutter analyze` 0 问题、`dart format` 无 diff。 +- 新增 `test/analytics/analytics_flush_scheduler_test.dart`(8 个,fakeAsync + 时钟注入 + 假上传子类):29 秒不触发 / 30 秒冲刷不满额队列、空队列不发起上传、stop 停 start 恢复(退后台/回前台)、start 幂等不叠加、30s→60s→120s 退避序列且窗口内 tick 跳过、退避封顶 5 分钟(240s×2 → 300s)、退避期间 flushNow 不受限、成功重置退避恢复 30 秒节奏。 +- `analytics_persistent_queue_test.dart` +4:429 保段不丢弃不计丢弃数;anonymousId 首次 restore 落盘、冷启动新实例沿用存储值且事件携带、构造注入通道不被覆盖。 +- `analytics_service_test.dart` +1:持久化不可用时 restore 降级临时 id 不崩溃。 +- `session_tracker_test.dart` +1:前后台回调级联下成对各触发一次,重复 resumed 不触发。 +- 既有语义回归零改动:4xx 毒丸、at-least-once、40+20 分批、flushNow 等原测试全部原样通过。 +- 依赖:dev_dependencies 显式声明 `fake_async ^1.3.3`(flutter_test 既有传递依赖,无新增第三方)。 + +## 4. 与 15 号 §4 遗留清单对照 + +| 15 号 §4 未做项 | 本波状态 | 说明 | +| --- | --- | --- | +| 30 秒定时冲刷 | **已做** | 前台 Timer.periodic,退后台停/回前台恢复;四触发点补齐 | +| 指数退避 | **已做** | 30s ×2 封顶 5min,只挡定时冲刷,成功即重置;15 号原案「5s ×2」按 T3-19 拍板参数调整为 30s 起步 | +| 429 按 Retry-After | **部分**(范围内的全部) | 429 已从毒丸丢弃改为保段退避;Retry-After 精细分支依赖后端限流(09 号出入项,未实现),随其落地一并做 | +| `pb.analytics.anonymousId` 持久化 | **已做** | 首次生成落盘、跨启动稳定、失败降级临时 id | +| `lastActiveAt` 持久化 | 不做(维持决策) | 03 号评估 §3.1 已裁定会话纯内存方案,非遗留项 | +| 401 去 Authorization 重试一次 | 未做 | 不在 T3-19 三项范围,继续遗留 | + +## 5. 交付物 + +- 代码:patbond-flutter `dev` 提交 `4d40c38`(已推送),改动 9 文件 +445/−14。 +- 新增:`test/analytics/analytics_flush_scheduler_test.dart` +- 修改:`lib/analytics/analytics_service.dart`(定时器、退避、anonymousId 持久化、uploadBatch 可测性)、`lib/analytics/session_tracker.dart`(onEnterForeground)、`lib/app/app.dart`(定时器生命周期装配)、`pubspec.yaml`/`pubspec.lock`(fake_async 显式声明)、3 个既有测试文件 diff --git a/docs/development/iterations/iteration-3/11-community-contract-draft.md b/docs/development/iterations/iteration-3/11-community-contract-draft.md new file mode 100644 index 0000000..a2ec510 --- /dev/null +++ b/docs/development/iterations/iteration-3/11-community-contract-draft.md @@ -0,0 +1,134 @@ +# M3 community/media 域契约草案说明(T3-10 起草态) + +> 作者:API 契约工程师 +> 日期:2026-09-08 +> 状态:**草案(DRAFT)——非冻结稿**。冻结须待 T3-03(媒体凭据)/T3-04(权限与错误语义)/T3-05(Feed 卡片)定型回填,按 M2 迭代式冻结流程升版 v1.3.0 合入 `docs/api/openapi.yaml` 并同步 api 侧字节级快照。本文与草案文件均不触碰正典 openapi.yaml。 +> 草案文件:`openapi-community-draft.yaml`(同目录,独立可解析,13 路径 / 19 操作) +> 依据:iteration-3/01(T3-03~09 端点定义与 T3-10 规范)、iteration-3/02(表结构、错误码段、media 状态机)、ADR-016~021、`docs/api/openapi.yaml` v1.2.0 通用约定 + +## 0. 字段正典基准声明 + +**T3-01 的 Flyway V5 迁移尚未推送 dev**(起草时 patbond-api 迁移链仅 V1~V4),本草案以 `docs/database/patbond_postgresql.sql` 的 community schema(718~875 行)与 media.assets(272~331 行)为字段正典。V5 落地后若与 bootstrap 有差异(预期仅两处:剪 `generation_job_id` 外键为裸列、主键默认值改应用侧 UUIDv7,均不影响契约面),以 V5 为准复核本草案。 + +## 1. 端点清单(13 路径 / 19 操作) + +| # | 端点 | 操作 | 对应工单 | 说明 | +| --- | --- | --- | --- | --- | +| 1 | `POST /api/v1/media/uploads` | 1 | T3-03 | 登记 asset + 签发预签名 PUT 凭据(201) | +| 2 | `POST /api/v1/media/uploads/{assetId}/complete` | 1 | T3-03 | HEAD 校验后 uploading→ready(200,幂等重复确认返回同 asset) | +| 3 | `POST /api/v1/posts` | 1 | T3-04 | 创建草稿或直接发布;Idempotency-Key 必带 | +| 4 | `/api/v1/posts/{postId}` | GET/PATCH/DELETE | T3-04 | 详情 / 编辑与发布(version 乐观锁)/ 软删 | +| 5 | `GET /api/v1/me/posts` | 1 | T3-04 | 我的帖子(含草稿),`(created_at,id)` 游标,status 过滤 | +| 6 | `GET /api/v1/feed` | 1 | T3-05 | 公共 Feed,`(published_at,id)` 游标,谓词=ix_posts_feed | +| 7 | `/api/v1/posts/{postId}/comments` | GET/POST | T3-07 | 评论列表(游标)/ 创建(幂等 + replyToUserId) | +| 8 | `DELETE /api/v1/comments/{commentId}` | 1 | T3-07 | 顶层短路径(pets 域先例),仅评论作者 | +| 9 | `/api/v1/posts/{postId}/like` | PUT/DELETE | T3-06 | 语义幂等,响应回 `{liked, likeCount}` 权威态 | +| 10 | `/api/v1/posts/{postId}/bookmark` | PUT/DELETE | T3-06 | 同构,`{bookmarked, bookmarkCount}` | +| 11 | `GET /api/v1/me/bookmarks` | 1 | T3-06 | 收藏列表,`(bookmarks.created_at, post_id)` 游标,项复用 FeedCard | +| 12 | `/api/v1/users/{userId}/follow` | PUT/DELETE | T3-08 | 语义幂等;自关注 422/42204 | +| 13 | `GET /api/v1/users/{userId}/follow-stats` | 1 | T3-08 | 计数 + followedByMe(ADR-018「最小接口 + 数量」口径) | + +裁剪不出现(与 ADR-018 对齐):话题全部端点(T3-09 条件单未启)、关注/粉丝**列表**(最小接口仅留 follow/unfollow + 计数,列表需时纯增量补)、作者主页 `GET /users/{userId}/posts`(M3 工单未列)、`region`/`generationJob`/`visibility=followers|private` 字段整体不出现(ADR-010「裁剪字段整体不出现,后续按新增可选字段补入」先例)。 + +## 2. 设计决策记录 + +1. **幂等按域(ADR-019)**:二元互动(like/bookmark/follow)PUT/DELETE 语义幂等,重复调用返回 200 同一权威终态(非 409)——复合主键即幂等键,无键管理;创建型(发帖/评论)`Idempotency-Key` **必带**(与 pets 域「可选、≤255、不比对请求体」刻意不同:本域 ≤128 对齐表列宽,且比对 request_hash,不符 40905)。差异已在草案头参数描述中显式声明,防止 SDK/客户端按 pets 惯例误用。 +2. **写响应携带权威终态**:like/bookmark 回 `{liked|bookmarked, count}`,follow 回 `{following, followerCount}`——iteration-3/02 §7.5 的乐观更新对账契约,客户端回滚=用响应覆盖本地值。 +3. **防枚举 404 沿 pets 先例并分域给码**:帖子(40403,合并不存在/软删/hidden/他人 draft)、评论(40404)、asset(40405,合并非本人所有)、用户(40406)。403/40301 只发给「可见但无权」的调用者。 +4. **发布即状态迁移**:不设独立 `/publish` 端点,`PATCH {status: published}` 是唯一开放迁移(draft→published),与 pets 域「状态流转走 PATCH」惯例一致,少一个端点少一处幂等语义。 +5. **PATCH media 整组替换**(草案态):部分更新语义下图片增删排序的逐项 diff 契约复杂且易错,草案取「media 字段出现即全量替换」,随 T3-04 实现定型。 +6. **AuthorSummary 服务端回退**:nickname 为空时由服务端回退 username,required 非空——客户端不做拼装(R10「缺失字段不留本地拼凑」);取数为 community 跨 schema 只读 identity(ADR-017),契约面不感知取数方式。 +7. **列表信封零新形态**:全部列表复用 v1.2.0 cursor 分页正典 `{items, nextCursor, hasMore}`,limit 1~100 缺省 20,游标不透明;每列表排序键与支撑索引在 description 中逐一写死(iteration-3/02 §7.3 对照)。 +8. **complete 幂等语义**:重复 complete 已 ready 的 asset 返回 200 同 asset(客户端弱网重试友好);failed/deleted 态 422/42205——比「非 uploading 一律拒」多保留一条安全重试路径。 + +## 3. 与 bootstrap SQL 的字段对照 + +### 3.1 media.assets → MediaAsset / CreateMediaUploadRequest + +| DB 列 | 契约字段 | 说明 | +| --- | --- | --- | +| id | id / assetId | UUID 字符串 | +| kind | kind | 契约 M3 仅 `image`(DB CHECK 含 video/document,读侧枚举预留) | +| purpose | purpose | 白名单草案仅 `post_image`(TODO-FREEZE #1) | +| mime_type | mimeType | 白名单草案 jpeg/png/webp(TODO-FREEZE #1) | +| byte_size | byteSize | 创建时声明,complete 实测比对;上限草案 10 MiB(TODO-FREEZE #1) | +| sha256 (bytea) | sha256 | 契约为 64 位小写 hex 字符串,可选 | +| width_px / height_px | widthPx / heightPx | complete 后回填,可空 | +| status | status | 契约仅露 uploading/ready/failed;deleted 恒 404 | +| ready_at / created_at | readyAt / createdAt | ISO 8601 | +| bucket / object_key / storage_type / duration_ms / external_url / owner_user_id | **不出现** | 存储内部细节不进契约;owner 由 token 隐含;duration 视频后置 | + +### 3.2 community.posts → Post / CreatePostRequest / UpdatePostRequest + +| DB 列 | 契约字段 | 说明 | +| --- | --- | --- | +| id / author_user_id | id / author(AuthorSummary) | 作者展开为公开摘要,不露裸 authorUserId(含在 author.userId) | +| pet_id | petId | 可空 | +| category | category | 写侧 enum [general, help](ai_creation M4 预留只读) | +| title / content | title / content | 长度约束与 ck_posts_title/content 同宽(1~120 / 1~10000) | +| status | status | 契约露 draft/published;hidden/archived 不开放(D3-7),草案对作者也不露 | +| visibility | visibility | M3 恒 `public`(ADR-018;DB 三值保留) | +| like/comment/bookmark_count | 同名 camelCase | int64 | +| idempotency_key / request_hash | Idempotency-Key 头 | 不进 body;≤128 对齐列宽 | +| published_at / created_at / updated_at / version | 同名 camelCase | version 进 PATCH 请求体(必带) | +| deleted_at | **不出现** | 软删即 404 | +| generation_job_id / region_id / location_text_snapshot | **不出现** | M4/M5 裁剪(V5 剪外键,ADR-018) | +| (关联)post_likes/post_bookmarks 行 | likedByMe / bookmarkedByMe | 批量查询组装,required | + +### 3.3 community.post_media → PostMediaItem / PostMediaAttachRequest + +| DB 列 | 契约字段 | 说明 | +| --- | --- | --- | +| asset_id / position / is_cover / caption | assetId / position / isCover / caption | position 0~8(≤9 图,D3-4);isCover 至多一(uq_post_media_cover),全 false 服务端取 position 0 | +| — | url / widthPx / heightPx | 响应侧由 asset 展开,免客户端二次请求 | + +### 3.4 community.comments → Comment / CreateCommentRequest + +| DB 列 | 契约字段 | 说明 | +| --- | --- | --- | +| id / post_id | id / postId | — | +| author_user_id / reply_to_user_id | author / replyToUser(均 AuthorSummary) | 请求侧 replyToUserId 裸 UUID | +| content | content | 1~2000 同宽 | +| client_request_id / request_hash | Idempotency-Key 头 | 落 client_request_id 列 | +| status / deleted_at / updated_at | **不出现** | deleted/hidden 过滤在列表外;契约无评论编辑,不露 updatedAt | + +### 3.5 post_likes / post_bookmarks / user_follows + +关系行不作为资源暴露,仅以 `likedByMe`/`bookmarkedByMe`/`following`/`followedByMe` 布尔态与计数出现;复合主键 = PUT/DELETE 幂等的实现本体(`ON CONFLICT DO NOTHING` + 同事务计数增减)。`ck_user_follows_self` → 422/42204。 + +## 4. TODO-FREEZE 清单(PM 三处 + 补充一处) + +草案 YAML 内共 10 处 `# TODO-FREEZE` 标注,归并为 4 个待定型点: + +| # | 待定型点 | 等待 | 草案内位置 | 草案预设 | +| --- | --- | --- | --- | --- | +| 1 | **媒体凭据形态**(PM 列①):uploadUrl 签名形态、requiredHeaders 键集、TTL、读取侧 URL(公共读稳定 URL vs 签名读);连带 purpose/mime 白名单与大小上限数值 | T3-03 | `createMediaUpload`、`MediaUploadCredentials`、`MediaAsset.url`、`CreateMediaUploadRequest` 三字段 | 预签名 PUT + TTL 10 分钟 + 10 MiB + jpeg/png/webp + purpose 仅 post_image | +| 2 | **Feed 卡片字段**(PM 列②):contentPreview 截断规则、coverImage 选取规则、是否需 mediaCount 外的图列表 | T3-05 | `getFeed`、`FeedCard`;收藏列表「已删帖静默剔除 vs 占位」联动 | 200 字符截断 + isCover→position 0 + 仅封面一图 + mediaCount | +| 3 | **作者公开资料形态**(PM 列③,D3-9 方案 B 预设字段) | T3-05 | `AuthorSummary` | userId + nickname(服务端回退 username)+ avatarUrl 可空;bio/username 露出与注销墓碑待定 | +| 4 | **权限矩阵与错误语义边界**(补充,冻结条件之一) | T3-04 | `getPost`、`UpdatePostRequest.media` 整组替换语义、作者视角 hidden 露出 | 403/404 边界按 §2-3 草案;media 整组替换 | + +收敛期限沿 PM 要求:第二波中期。冻结时逐项回填、删除标注、升版 v1.3.0、同步 api 侧字节级快照。 + +## 5. 错误码段草案(新增 9 码,延续既有分段不重编号) + +| 业务码 | HTTP | 稳定名 | 场景 | +| --- | --- | --- | --- | +| 40301 | 403 | POST_ACCESS_DENIED | 可见但无权操作(改删他人帖/评论) | +| 40403 | 404 | POST_NOT_FOUND | 不存在/软删/hidden/不可见,防枚举合并 | +| 40404 | 404 | COMMENT_NOT_FOUND | 评论不存在/已删/所属帖不可见 | +| 40405 | 404 | MEDIA_NOT_FOUND | asset 不存在或非本人所有 | +| 40406 | 404 | USER_NOT_FOUND | 关注目标用户不存在/已注销(**草案新增**,02 号报告未列) | +| 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH | 同键不同 payload(request_hash 不符) | +| 42203 | 422 | MEDIA_NOT_READY | 引用非 ready 的 asset | +| 42204 | 422 | FOLLOW_RULE_VIOLATION | 自关注(**草案新增**) | +| 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID | complete 时 asset 非 uploading(幂等 ready 除外)(**草案新增**) | + +复用既有码:40000(参数校验,含 mime/大小白名单拒绝、游标非法、limit 越界、Idempotency-Key 缺失/超长、非法状态迁移)、40101(token)、40401(petId 引用不可见宠物,沿 pets 域语义)、40902(version 冲突)、50000/50300。与 iteration-3/02 §6 六码草案的差异:新增 40406/42204/42205 三码(关注与 complete 状态机在 02 号报告端点表中有行为但无码位),冻结评审时定夺。 + +## 6. 冻结前必办事项(交接给冻结时点) + +1. T3-01 V5 推送后与 bootstrap 复核一遍字段对照(§0)。 +2. 四个 TODO-FREEZE 点逐项回填(§4),删除全部标注。 +3. 错误码段三枚草案新增码(40406/42204/42205)评审定夺。 +4. 合入正典 openapi.yaml:升版 1.3.0、错误码表并入 info 头、servers 增 :8084、tags 并入;`mkdocs build --strict` + api 侧字节级快照同步。 +5. Idempotency-Key「必带 + 比对 hash + ≤128」与 pets 域差异在正典 info 头「通用约定」中显式成文。 diff --git a/docs/development/iterations/iteration-3/12-secret-scan-rollout.md b/docs/development/iterations/iteration-3/12-secret-scan-rollout.md new file mode 100644 index 0000000..de90dbb --- /dev/null +++ b/docs/development/iterations/iteration-3/12-secret-scan-rollout.md @@ -0,0 +1,80 @@ +# 12 M3 第一波:凭证防泄漏检查落地(ADR-021) + +- 执行人:Git Workflow Master +- 日期:2026-09-08 +- 依据:ADR-021(CI 兜底 grep 第一波必做、先于 MinIO 凭证进开发机)、iteration-3/08 §4 两层纯 shell 方案 +- 交付边界:本报告只记录,不入 mkdocs 导航;T3-03 自本波 CI 全绿起解除对象存储凭证引入限制。 + +--- + +## 1. 落地形态 + +两层检查、同一规则表,单一来源为各仓入库的 `scripts/check-secrets.sh`(纯 shell + git + grep,零外部依赖、零外部 action,符合三仓 CI「手动克隆本实例」模式约束)。三仓副本内容逐字节同构(`cp -p` 分发),调整规则时三仓同步提交。 + +| 层 | 载体 | 触发 | 扫描范围 | +| --- | --- | --- | --- | +| 第一层(推荐) | `scripts/hooks/pre-commit` → 同一脚本 `--staged` | 本地 `git commit`(`git config core.hooksPath scripts/hooks` 启用,每人每仓一次) | 暂存区内容 + 暂存文件名 | +| 第二层(强制兜底) | 三仓 `ci.yml` checkout 后首个 step,同一脚本 `--all` | 每次 push / PR | 全部已跟踪文件(本次 push 变更文件的超集;`--force-with-lease`、`--no-verify` 均无法绕过) | + +CI 采用 `--all` 而非「仅 diff 变更文件」的原因:三仓 CI 均为 depth-1 浅克隆,无可靠的 push 前基点可 diff;全量扫描是变更文件的严格超集且实测最慢仓仅 6.7 秒,顺带覆盖历史存量。ci.yml 只加 step,既有逻辑零改动。 + +## 2. 规则集清单(9 条) + +内容规则 8 条(规则表内 ID): + +| ID | 检测 | 形态 | +| --- | --- | --- | +| AK-AWS | AWS/MinIO S3 兼容 AK | `AKIA` + 16 位大写字母数字 | +| AK-QCLOUD | 腾讯云 SecretId | `AKID` + 16 位以上字母数字 | +| AK-ALIYUN | 阿里云 AK | `LTAI` + 12 位以上字母数字 | +| MINIO-DEFAULT | MinIO 默认凭证 | minio·admin 及连写变体(忽略大小写) | +| PRIVATE-KEY | 私钥块 | **独占一行**的 `-----BEGIN …PRIVATE KEY-----` PEM 头 | +| KEY-ASSIGN | access/secret key 实值赋值 | `accessKey/secret_key/…` 后接 `:`/`=` 与 8 位以上实值 | +| JWT-SECRET | JWT/签名密钥材料 | `jwt-secret/signing-key/token-secret/hmac-key` 赋值实值 | +| DB-PASSWORD | 数据库口令非注入形态 | 仅限配置类文件(yml/yaml/properties/toml/conf/ini 及其 .sample/.example),`password/passwd/pwd` 赋 6 位以上非 `${}` 实值 | + +文件名黑名单 1 条(NAME-DENY):`.env`/`.env.*`、`credentials*`、密钥导出 CSV(`rootkey.csv`、`*accessKeys*.csv` 形态)本体禁入版本库;`.sample`/`.example` 后缀豁免。 + +允许清单(行级放行):`${…}`/`{{…}}` 注入形态、`changeme`/`change-me`、`your-xxx`、`<占位>`、`placeholder`/`example`/`sample`/`dummy`/`fake`/`redacted`、`***`。二进制文件经 `grep -I` 自然跳过;脚本与 hook 自身(含规则文本)路径豁免。 + +### 2.1 关键校准(避免误伤的两处设计) + +1. **PRIVATE-KEY 采用「PEM 头独占一行」判据**:patbond-api 有两处合法的 PEM 头字面量——`TestJwtKeys.java`(测试密钥**运行时生成**,无入库密钥材料)与 `RsaPrivateKeyLoader.java`(解析代码的 `.replace(...)`)。两处 PEM 头都嵌在代码字符串中而非独占一行,该判据下自然通过,无需路径白名单;真实 .pem 文件或粘进 yaml 的密钥块(头行独立)仍必中。 +2. **DB-PASSWORD 限定配置类文件**:api 测试代码与 Readme 的 curl 示例大量使用 `"password":"secret123"` 假值,Java/Markdown 不在该规则文件范围内;配置类文件中现有口令全部为 `${PATBOND_DB_PASSWORD:…}` 注入形态(docker-compose.yml、application.yml.sample 逐行核实),实值直写才会命中。 + +## 3. 误报实测:三仓现有全部已跟踪文件零误报 + +| 仓库 | 已跟踪文件数 | `--all` 扫描结果 | 耗时 | +| --- | --- | --- | --- | +| patbond-api | 194(+本次 3) | 零命中,exit 0 | 5.6s | +| patbond-flutter | 234(+本次 3) | 零命中,exit 0 | 6.7s | +| patbond-doc | 74(+本次 4) | 零命中,exit 0 | 2.1s | + +另以 `--staged` 模式对本次新增文件(脚本、hook、ci.yml、git-workflow.md)复扫,同样零命中——即规则集对自身与规范文档不误伤。 + +## 4. 拦截自测(临时仓构造假凭证,验证后已删除,未入库) + +在 scratchpad 一次性 git 仓中构造全假样本(编造值,无任何真实凭证),结果: + +- **应拦 9 类全部命中**:AKIA 假 AK、AKID、LTAI、minio·admin(连写形态)、独立 PEM 头、accessKey/secretKey 实值赋值、yml 中 password 实值、`.env` 文件本体(NAME-DENY)——`--staged`、`--all`、文件参数三种模式一致,exit 1。 +- **hook 真实阻断**:`git config core.hooksPath scripts/hooks` 后 `git commit` 被 pre-commit 拒绝(exit 1),输出命中清单与处置指引(真凭证先轮换后清历史)。 +- **应放行全部通过**:`${PATBOND_DB_PASSWORD:patbond}` 注入、`changeme`/`your-access-key` 占位、`.env.sample`——零误拦,exit 0。 +- 顺带发现的既有防线:本机全局 gitignore 已含 `.env`,`git add -A` 根本加不进暂存区,NAME-DENY 是其后的第二道。 + +## 5. 三仓提交与 CI 状态 + +| 仓库 | 分支 | 提交 | 内容 | CI | +| --- | --- | --- | --- | --- | +| patbond-api | dev | `8330885` | 脚本 + hook + ci.yml 加 Secret scan step | 见下 | +| patbond-flutter | dev | `66f983d` | 同上(同构副本) | 见下 | +| patbond-doc | main | `8e1fe2f` | 脚本 + hook + ci.yml step + git-workflow.md「凭证防泄漏检查」节 | 见下 | + +CI 状态(Gitea commit status API 逐仓核实,2026-09-08):三仓全部 **success**——api `CI / backend-test (push)`(16:35:08 完成)、flutter `CI / flutter-gates (push)`(16:37:29)、doc `CI / docs-build (push)`(16:38:14)。新增 Secret scan step 未破坏任何既有流水线。 + +patbond-doc 本地 `mkdocs build --strict` 通过后才提交;他人未提交内容(backend-modules.md 改动、09/10/11 号报告)未混入本次提交。启用说明见 patbond-doc `docs/development/git-workflow.md`「凭证防泄漏检查(ADR-021)」节,命令示例已按参数化路径规范书写(`cd <你的工作区>/<仓名>`)。 + +## 6. 遗留与提醒 + +- **T3-03 解锁条件已满足后**引入 MinIO 凭证时:AK/SK 只进被 gitignore 的 `.env`(compose `${}` 注入),`.sample` 用占位值——直写实值会被本规则集拦下。 +- 两位开发者各自需在三仓执行一次 `git config core.hooksPath scripts/hooks`(CI 兜底不依赖此步,但本地拦截更早更省事)。 +- 规则表若增补(如 M4 引入新云厂商),三仓 `scripts/check-secrets.sh` 必须同步修改、同波提交。 diff --git a/docs/development/iterations/iteration-3/13-media-minio-report.md b/docs/development/iterations/iteration-3/13-media-minio-report.md new file mode 100644 index 0000000..c0f0cce --- /dev/null +++ b/docs/development/iterations/iteration-3/13-media-minio-report.md @@ -0,0 +1,136 @@ +# M3 第一波 media 域最小闭环施工报告(T3-03 MinIO 接入 + T3-19 auth 契约测试补齐) + +> 作者:Senior Developer(后端) +> 日期:2026-09-08 +> 工单:T3-03(media 域最小闭环:对象存储接入与上传流程,M3 关键路径起点)、T3-19 后端半边(auth 域契约一致性测试补齐) +> 代码基线:patbond-api `8330885`(206 测试全绿)→ 交付 `263cd88`(226 测试全绿) +> 结论先行:**媒体凭据形态定型为「预签名 PUT 直传 + 预签名 GET 读取(桶保持私有)」;两步上传全链路(创建→直传→确认→ready→GET 可访问)在 MinIO Testcontainer 与 compose 六容器上实测通过;与契约草案偏差 7 项逐条记录(T3-10 冻结输入);auth 域 6 操作 19 个响应单元格全矩阵入契约测试;全套 226 测试全绿。** + +--- + +## 1. 提交清单 + +按工单各一逻辑提交,全部已推送 `origin/dev`: + +| 提交 | 内容 | +| --- | --- | +| `10a43f8` | T3-03:存储适配层 + 两步上传流程 + MinIO 编排与全链路集成测试 | +| `263cd88` | T3-19:auth 域 6 操作契约一致性测试全响应矩阵 | + +## 2. 存储适配层设计(ADR-016 落地) + +### 2.1 分层与供应商隔离 + +``` +MediaController ─ MediaService ─┬─ MediaAssetRepository(media.assets,JdbcClient) + └─ ObjectStorage(接口,媒体域唯一存储缝) + └─ S3ObjectStorage(AWS SDK v2,指向自托管 MinIO) +``` + +- **`ObjectStorage` 接口**(`patbond-user/src/main/java/com/patbond/patbond/user/media/ObjectStorage.java`)只暴露四个供应商无关操作:`ensureBucket()` / `presignPut(objectKey, contentType, ttl)` / `stat(objectKey)` / `presignGet(objectKey, ttl)`。桶名、端点、凭证、SDK 类型全部收敛在实现内——迁云(COS 等 S3 兼容服务)只换 `MediaProperties` 配置与凭证,调用侧零改动(ADR-016 迁移触发条件见该 ADR)。 +- **`S3ObjectStorage`**:AWS SDK v2(`software.amazon.awssdk:s3`,版本 `2.54.13` 经根 pom `awssdk bom` 管理)。强制 path-style(MinIO 无桶级泛域名)。**双端点设计**:SDK 客户端走内网端点(compose 内 `http://minio:9000`),预签名 URL 按 `public-endpoint`(客户端可达地址)签发——SigV4 把 Host 签进签名,两者必须分开。 +- **未配置时的行为**:`patbond.media.endpoint` 为空时注入 `UnconfiguredObjectStorage` 桩,服务照常启动、仅 `/api/v1/media/**` 返回 500——与 JWT 公钥未配置的既有先例一致,保证 auth E2E 等不涉媒体的上下文零外部依赖。 +- **三环境零分叉**:桶初始化是应用启动时的 `ensureBucket()`(幂等,headBucket→createBucket),本地、compose、Testcontainers 走同一条代码路径;MinIO 镜像三处钉同一 tag `minio/minio:RELEASE.2025-04-22T22-12-26Z`(compose 与集成测试)。 + +### 2.2 两步上传状态机(实现语义,冻结输入) + +``` +POST /api/v1/media/uploads POST /api/v1/media/uploads/{assetId}/complete + │ │ + ▼ ▼ + 白名单校验(purpose/mime/byteSize) findByIdAndOwner(不存在/非本人/deleted → 404/40405 防枚举合并) + │ ├─ ready → 200 幂等返回(现签 GET URL) + insert uploading 行 ├─ failed → 422/42205(终态,须重新创建上传) + (objectKey 服务端生成: └─ uploading → HEAD 对象: + {purpose}/{yyyy/MM}/{assetId}, ├─ 对象不存在 → 422/42205,**保持 uploading 可重试** + 不含任何用户输入) ├─ 大小/类型与登记不符 → 置 failed,422/42205 + │ └─ 通过 → uploading→ready(guarded UPDATE, + ▼ 并发确认幂等收敛),200 + GET URL + 201 + 预签名 PUT 凭据 +``` + +- ready 迁移用 `UPDATE ... WHERE status='uploading'` 守卫,并发 complete 竞争时输家重读终态、幂等返回,不会双写 `ready_at`。 +- 库层 CHECK(`ck_media_location`/`ck_media_ready`/`ck_media_status`)与 `uq_media_object` 是应用校验的兜底,集成测试对三者逐一实证(见 §7)。 + +### 2.3 配置面(全部环境变量注入,ADR-021) + +`patbond.media.*`(`application.yml(.sample)`,占位符形态):`endpoint` / `public-endpoint` / `access-key` / `secret-key` / `bucket`(默认 patbond-media)/ `upload-ttl`(默认 10m)/ `download-ttl`(默认 1h)/ `max-byte-size`(默认 10485760)/ `allowed-mime-types`(默认 jpeg/png/webp)/ `allowed-purposes`(默认 post_image)。上限与白名单按工单要求全部是配置项,不是代码常量。 + +## 3. 媒体凭据形态定型表(T3-10 契约冻结输入) + +`POST /api/v1/media/uploads` → 201,`data` 形态: + +| 字段 | 定型 | 说明 | +| --- | --- | --- | +| `assetId` | UUID 字符串(应用侧 UUIDv7) | 已登记 asset,status=uploading | +| `uploadUrl` | 预签名 PUT 完整 URL | 签名以 query 参数携带(`X-Amz-Algorithm/-Credential/-Signature/...`);指向 `public-endpoint`,客户端直传不经应用服务器 | +| `method` | 恒 `"PUT"` | | +| `requiredHeaders` | `{"Content-Type": <声明的 mimeType>}` | **键集定型为仅此一键**;Content-Type 被签进签名,客户端必须原样携带,改动即 403 | +| `expiresAt` | ISO-8601 date-time | 凭据过期时刻 = 签发时刻 + `upload-ttl`(默认 10 分钟);过期后重新创建上传(原 asset 仍可在补传后确认,见 §4-2) | + +确认/读取侧(`MediaAsset.url`):**预签名 GET URL,TTL 默认 1 小时,仅 `status='ready'` 非空**;桶保持私有,无签名直访 403(有测试)。消费方(T3-05 Feed、头像)由服务端在每次响应时现签,客户端不持久化 URL、过期即重取。 + +## 4. 与契约草案(openapi-community-draft.yaml)偏差清单 + +| # | 草案 | 实现定型 | 理由 | +| --- | --- | --- | --- | +| 1 | 读取侧留白(TODO-FREEZE:公共读稳定 URL vs 签名读;avatarUrl 示例为公共读形态,D3-1 拍板意见曾倾向公共读桶) | **私有桶 + 预签名 GET**(TTL 1h 配置项) | 任务拍板「桶保持私有」;公共读桶对越权枚举无防御,且迁云后改回私有是破坏性变更,反向(私有→放开)是兼容变更 | +| 2 | complete「校验失败置 failed」一刀切 | **对象不存在 → 42205 但保持 uploading(可重试)**;对象存在但大小/类型与登记不符 → 置 failed(终态) | 客户端直传完成前误触 complete 不应把凭据作废;「传了不符的东西」才是不可恢复失败 | +| 3 | 「有 sha256 则一并核」 | **sha256 照收照存(bytea),M3 不核验** | S3 HEAD 拿不到 sha256;逐字节回读核验与单机带宽约束(ADR-016 背景)冲突。迁云或 M4 需要时经 S3 checksum 特性补,不改契约形态 | +| 4 | complete 未声明 400 | 实现对非 UUID `assetId` 返回 400/40000 | 冻结时给 complete 补 400/ValidationError 声明 | +| 5 | TODO-FREEZE:purpose 白名单是否随 P6 扩 | **M3 定 `post_image` 一项**;P6 扩 `user_avatar`/`pet_avatar` 时为纯配置追加 + 契约枚举扩展(向后兼容) | 白名单是配置项,扩展零代码 | +| 6 | TODO-FREEZE:mime 白名单与 HEIC | **定 `image/jpeg` `image/png` `image/webp`,不收 HEIC** | 客户端压缩管线统一转码 jpeg(T3-13 侧约定,见 01 号工单 T3-13 描述);服务端收 HEIC 需转码能力,M3 无 | +| 7 | TODO-FREEZE:byteSize 上限草案 10 MiB | **定 10485760(10 MiB),配置项** | 与客户端压缩目标(长边约束后 jpeg 远小于 10 MiB)留足余量 | + +另注:`MediaUploadCredentials` 草案的 `requiredHeaders` 标注「键集草案态」,本次定型为仅 `Content-Type` 一键(§3);`expiresAt` TTL 草案 10 分钟维持。complete 幂等语义(重复确认 200 返回既有 ready asset)与草案一致,已实证。 + +## 5. compose 变更与六容器实测 + +### 5.1 变更点(docker-compose.yml) + +- 新增 `minio` 服务:镜像钉 `minio/minio:RELEASE.2025-04-22T22-12-26Z`(与集成测试同 tag);对象数据落 `minio-data` volume(ADR-007 应用容器无状态不破坏);healthcheck 走 `/minio/health/live`;**发布 9000 端口**——预签名直传/读取 URL 都直接指向 MinIO,客户端必须可达。 +- `user` 服务注入 5 个 `PATBOND_MINIO_*` 环境变量,`depends_on` minio 健康;`PATBOND_MINIO_PUBLIC_ENDPOINT` 默认本机回环,真机联调/生产改为客户端可达地址(.env 或环境覆盖)。 +- `deploy/init-secrets.sh` 幂等追加 `PATBOND_MINIO_ROOT_USER`(随机后缀)与 `PATBOND_MINIO_ROOT_PASSWORD`(32 hex 随机)到被 gitignore 的 `.env`——凭证零入库,`scripts/check-secrets.sh --all` 全仓通过。 +- 桶初始化在 user 服务启动路径(`ensureBucket`),**无需 mc 初始化容器**,六容器封顶。 + +### 5.2 六容器实测(postgres + minio + user + auth + pet + community) + +```bash +cd <你的工作区>/patbond-api +./deploy/init-secrets.sh +JAVA_HOME=<你的 JDK17 路径> ./mvnw -DskipTests package +docker compose up -d --build +docker compose ps # 六容器 Up,postgres/minio/user (healthy) +# 媒体链路冒烟:注册 → 创建上传 → 直传 → 确认 → GET URL 取回 +docker compose down +``` + +实测结果(2026-09-08,本机):**六容器全部 Up(postgres/minio healthy)**;媒体链路冒烟全通——注册取 token → `POST /api/v1/media/uploads` 201(uploadUrl 指向 public-endpoint,`X-Amz-SignedHeaders=content-type;host` 证实 Content-Type 已签进签名)→ 按凭据直传 PUT 200 → complete 200 `status=ready` → 预签名 GET 200 且取回字节与上传逐字节一致;随后 `docker compose down` 干净退出。 + +## 6. uploading 超时清理——方案(本迭代只记录不实现) + +- **扫描**:定时任务照 `SessionCleanupJob` 既有模式(`@Scheduled` + 配置化节奏),`SELECT id, bucket, object_key FROM media.assets WHERE status='uploading' AND created_at < now() - :timeout`,命中 V1 预留的部分索引 `ix_media_uploading_created`(该索引在位有测试锚定)。 +- **处置**:先删对象(`ObjectStorage` 补 `delete(objectKey)`,容忍对象本就不存在),再把行置 `failed`(保留审计轨迹与防枚举一致性;不物理删行)。两步顺序保证不产生「行没了对象还在」的孤儿。 +- **参数建议**:超时阈值 24h、扫描间隔 6h、单批上限 500 行,全部配置项。 +- **排期**:随 T3-09(或第二波收口)实现;实现前 uploading 僵尸行只占元数据行与零字节~少量对象空间,无正确性风险(业务侧只认 ready)。 + +## 7. 测试变化 + +| 项 | 基线 | 交付 | +| --- | --- | --- | +| 全套 `./mvnw clean test` | 206 | **226**(+20:media 12 + auth 契约 8) | + +新增: + +- `patbond-user` `media/MediaUploadIntegrationTest`(**12 个**,MinIO Testcontainer + postgres:18 真库):全链路(创建→真实 HTTP 直传→确认→ready→预签名 GET 取回字节一致→无签名直访 403);凭据形态(201 形态、TTL 窗口);六类失败路径——非法 mime、超限 byteSize、kind/purpose 白名单外、未上传就确认(保持可重试并实证补传后恢复)、大小不符置 failed 终态、他人/不存在 asset 防枚举 40405;401 矩阵;数据库约束与应用层一致性(`ck_media_ready`/`ck_media_location`/`uq_media_object` 逐一触发库层拒绝);清理索引在位。 +- `patbond-auth` `AuthContractConformanceTest`(**8 个**,T3-19):机制与 patbond-pet `ContractConformanceTest` 同构(模块内复制 `OpenApiContract`/`ContractValidator` + v1.2.0 字节级快照,同一份每模块复制纪律);运行方式沿 `AuthE2eIntegrationTest` 编排——同 JVM 真实拉起 user 服务,register/login/refresh/logout 打 auth、me/trackEvents 打 user,跨服务真实纵切。**全响应矩阵门禁:6 操作 19 个 (操作, 状态码) 单元格零豁免**,含 register 409 双业务码(40900/40901)、login 423 锁定、refresh 40102 重放、me 404 幽灵用户、events 匿名 202 与带无效 token 401。auth 路径本就在 v1.2.0 快照内,无契约升版。 +- **契约测试首轮即抓到一处真实漂移**:`/api/v1/events` 202 响应中 accepted/duplicate 条目序列化出 `"reason": null`,而契约声明 reason 仅 status=rejected 时出现(且未标 nullable)。已修实现侧(`EventResult.reason` 加 `@JsonInclude(NON_NULL)`),既有埋点测试零回归——这正是 T3-19 要补的防护网生效的实证。 + +错误码扩充(`patbond-common` `ErrorCode`):`MEDIA_NOT_FOUND(40405, 404)`、`MEDIA_UPLOAD_STATE_INVALID(42205, 422)`——与草案错误码段取值一致;`42203 MEDIA_NOT_READY` 属 T3-04 引用侧,未预占。 + +## 8. 遗留与交接 + +- **T3-13(Flutter 端)联调输入**:§3 凭据形态定型表 + §4 偏差清单即两步上传协议的权威描述;客户端直传须原样携带 `requiredHeaders`,压缩管线出 jpeg(偏差 #6)。 +- **T3-10 冻结回填**:§4 七项偏差均需回填草案(TODO-FREEZE 三处媒体位 + complete 400 声明);冻结时同步 api 侧快照升版(两处复制:patbond-pet 与 patbond-auth 的 `src/test/resources/contract/`,快照守卫测试会拦忘记同步)。 +- **清理任务**(§6)随后续波次实现;`ObjectStorage.delete` 届时补。 +- 生产化注意:`PATBOND_MINIO_PUBLIC_ENDPOINT` 必须配置为客户端可达地址;带宽瓶颈显现即触发 ADR-016 迁云条件。 diff --git a/docs/development/iterations/iteration-3/14-wave1-closure.md b/docs/development/iterations/iteration-3/14-wave1-closure.md new file mode 100644 index 0000000..975fb23 --- /dev/null +++ b/docs/development/iterations/iteration-3/14-wave1-closure.md @@ -0,0 +1,30 @@ +# 14 M3 第一波收口:地基、媒体闭环与防泄漏 + +**执行日期**:2026-09-08 +**交付**:V5 迁移 + community 骨架、MinIO 媒体最小闭环、埋点队列加固、契约草案、凭证防泄漏三仓、auth 契约测试补齐 + +--- + +## 0. 概要 + +| 工单 | 交付 | 提交 | 测试 | +|------|------|------|------| +| T3-01 V5 迁移 | community 8 表 + pg_trgm,剪 2 条跨 schema FK(M4/M5 补回) | api dev@a97814a | 191→199 | +| T3-02 community 骨架 | :8084 五容器、骨架期即接 RS256 校验 | api dev@3c671fc | →206 | +| T3-03 media 闭环 | MinIO 适配层 + 两步上传 + 私有桶签名读(ADR-016/017) | api dev@10a43f8 | →218 | +| T3-19 auth 契约测试 | 6 操作 19 单元格全矩阵,抓修 1 真实漂移(reason NON_NULL) | api dev@263cd88 | →226 | +| T3-19 队列三项 | 30s 定时冲刷 + 指数退避 + anonymousId 持久化 | flutter dev@4d40c38 | 272→286 | +| T3-10 起草态 | community/media 契约草案 13 路径/19 操作 + 4 待定型点 | 草案在 iteration-3/ | — | +| ADR-021 防泄漏 | 9 规则两层检查三仓落地,零误报 + 拦截自测全命中 | api@8330885 flutter@66f983d doc@8e1fe2f | CI 全绿 | + +**波末状态**:patbond-api 226 测试 / patbond-flutter 286 测试全绿;compose 六容器(postgres+minio+auth+user+pet+community)实测健康;三仓 CI 绿。 + +## 1. 契约冻结输入已定型(T3-03 部分) + +媒体凭据形态:创建上传返回 `{assetId, uploadUrl(预签名 PUT), method, requiredHeaders, expiresAt(10min)}`;读取一律私有桶预签名 GET(1h TTL);purpose=post_image、mime 白名单 jpeg/png/webp、单文件 10 MiB。与草案偏差 7 项见 13 号报告 §4。剩余待定型:Feed 卡片字段与公开资料形态(T3-05)、权限/错误语义(T3-04)。 + +## 2. 遗留与下波 + +- uploading 超时清理:方案已记录(13 号报告),定时任务另排。 +- 401 去 Authorization 重试、429 Retry-After 精细分支:待后端限流(10 号报告记录)。 +- **第二波**:T3-04 帖子生命周期 → T3-05 Feed → T3-06/07 评论互动 → 契约冻结闸门;D3-9 方案 B 的 /internal 批量公开资料接口随 T3-05 落地。 diff --git a/docs/development/iterations/iteration-3/openapi-community-draft.yaml b/docs/development/iterations/iteration-3/openapi-community-draft.yaml new file mode 100644 index 0000000..791ffc4 --- /dev/null +++ b/docs/development/iterations/iteration-3/openapi-community-draft.yaml @@ -0,0 +1,1379 @@ +openapi: 3.0.3 +info: + title: Patbond API — Community / Media 域契约草案(T3-10 起草态,非冻结稿) + version: 1.3.0-draft.1 + description: | + M3 社区迭代 community + media 域契约**草案**(T3-10 第一波起草态)。 + 本文件独立可解析,仅供评审与实现对照;**不是** `docs/api/openapi.yaml` 的一部分, + 冻结合入须待 T3-03/04/05 定型回填后按 M2 迭代式冻结流程升版 v1.3.0。 + + ## 沿用正典约定(openapi.yaml v1.2.0 零偏差) + - 统一前缀 `/api/v1`;JSON 一律 camelCase;ID 为 UUID 字符串。 + - 时间字段 ISO 8601 带时区偏移(timestamptz)。 + - 统一信封 `{"code": 0, "message": "success", "data": …}`;错误同时带正确 HTTP + 状态码与稳定业务码,业务码永不复用或改号。 + - cursor 分页正典:`data: {items, nextCursor, hasMore}`;`limit` 1~100 缺省 20; + 游标不透明,客户端不得解析;禁 OFFSET。 + - 创建操作返回 201(pets 域先例);全部端点强制 Bearer 鉴权,无匿名端点。 + + ## 本域新增约定(草案) + - **幂等按域(ADR-019)**:二元互动(点赞/收藏/关注)用 PUT/DELETE 语义幂等, + 重复调用返回同一权威终态(200,非 409),无键管理;创建型写入(发帖/评论/ + 媒体登记)`Idempotency-Key` 头**必带**,落表内幂等列并比对 request_hash + (请求体规范化 SHA-256),同键不同 payload 返回 409/40905。 + 与 pets 域「可选键、不比对请求体」并存,pets 不回改。 + - **媒体两步上传(ADR-016)**:创建 upload(登记 asset + 签发 MinIO 预签名 PUT + 凭据)→ 客户端直传 → complete 确认(服务端 HEAD 校验后 uploading→ready)。 + 业务引用只接受本人所有且 `status='ready'` 的 asset,否则 422/42203。 + - **防枚举 404**:帖子不存在 / 已软删 / hidden / 他人 draft 或 private 响应完全一致 + (40403);评论、asset、用户同理(40404/40405/40406)。 + - **写响应携带权威终态**:like/bookmark/follow 响应回 `{…State, …Count}`, + 客户端乐观更新以响应对账回滚。 + + ## community/media 域错误码段(草案,待 T3-04 定型冻结) + | 业务码 | HTTP | 场景 | + | --- | --- | --- | + | 40301 | 403 | POST_ACCESS_DENIED:帖子/评论可见但无权操作(如改删他人帖) | + | 40403 | 404 | POST_NOT_FOUND:帖子不存在、已删、hidden 或对调用者不可见(防枚举合并) | + | 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见 | + | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在或非本人所有(防枚举合并) | + | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注/计数端点) | + | 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(request_hash 不符) | + | 42203 | 422 | MEDIA_NOT_READY:引用了非 ready 状态或超挂接窗口的 asset | + | 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(库层 ck_user_follows_self 兜底) | + | 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading 态(幂等重复 ready 除外) | + 复用既有码:40000 参数校验、40101 token、40902 VERSION_CONFLICT、50000/50300。 + +servers: + - url: http://127.0.0.1:8082 + description: patbond-user(media 上传流程,ADR-017) + - url: http://127.0.0.1:8084 + description: patbond-community(社区域全部端点,ADR-017) + +security: + - bearerAuth: [] + +tags: + - name: media + description: 媒体上传两步流程(patbond-user,ADR-016 MinIO 预签名直传) + - name: posts + description: 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子(patbond-community) + - name: feed + description: 公共 Feed 游标分页(patbond-community) + - name: comments + description: 单层平铺评论 + @ 回复(patbond-community,ADR-018) + - name: interactions + description: 点赞/收藏 PUT+DELETE 幂等与收藏列表(patbond-community,ADR-019) + - name: follows + description: 关注最小数据接口:follow/unfollow + 计数(patbond-community,ADR-018) + +paths: + /api/v1/media/uploads: + post: + tags: [media] + summary: 创建上传(登记 asset 并签发预签名直传凭据) + description: | + 两步上传第一步:校验 mime 白名单与大小上限 → 写 `media.assets` 行 + (status=uploading,bucket/objectKey 服务端生成、不含用户输入)→ + 返回预签名 PUT 凭据(短 TTL)。客户端凭凭据直传 MinIO,不经应用服务器。 + M3 仅 `kind=image`(ADR-018 视频后置)。 + # TODO-FREEZE: 等待 T3-03 —— purpose 白名单、mime 白名单、单文件大小上限数值定型 + operationId: createMediaUpload + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateMediaUploadRequest' + responses: + '201': + description: asset 已登记(uploading),返回直传凭据 + content: + application/json: + schema: + $ref: '#/components/schemas/MediaUploadEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + + /api/v1/media/uploads/{assetId}/complete: + post: + tags: [media] + summary: 确认上传完成(uploading → ready) + description: | + 两步上传第二步:服务端对对象 HEAD 校验存在性与 byteSize(有 sha256 则一并核) + → status=ready、写 readyAt,返回可引用的 asset。校验失败置 failed。 + 对已 ready 的 asset 重复 complete 幂等返回同一 asset(200 语义下仍走 201?—— + 草案取 200 返回既有 ready asset);对 failed/deleted 态返回 422/42205。 + operationId: completeMediaUpload + parameters: + - $ref: '#/components/parameters/AssetIdParam' + responses: + '200': + description: 确认成功(或幂等重复确认),asset 为 ready + content: + application/json: + schema: + $ref: '#/components/schemas/MediaAssetEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/MediaNotFound' + '422': + description: asset 非 uploading 态,或对象校验失败已置 failed(code 42205) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + stateInvalid: + value: { code: 42205, message: 上传状态不允许确认, data: null } + + /api/v1/posts: + post: + tags: [posts] + summary: 创建帖子(草稿或直接发布) + description: | + `Idempotency-Key` **必带**(开发计划 6.1 强制名单含帖子):落 + `uq_posts_author_idempotency`,同键重试返回首次创建的帖子(同样 201); + 同键不同 payload 返回 409/40905(request_hash 比对,ADR-019)。 + `status` 可 draft(缺省)或 published(直接发布,服务端写 publishedAt)。 + media 挂接只接受本人所有且 ready 的 asset(否则 422/42203),每帖 ≤9 图 + (D3-4),position 0 起连续,isCover 至多一个(缺省取 position 0)。 + 纯文字帖合法(media 可空数组或缺席)。 + operationId: createPost + parameters: + - $ref: '#/components/parameters/IdempotencyKeyRequiredHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePostRequest' + responses: + '201': + description: 创建成功(或同键幂等重试返回首次结果) + content: + application/json: + schema: + $ref: '#/components/schemas/PostEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '409': + $ref: '#/components/responses/IdempotencyPayloadMismatch' + '422': + $ref: '#/components/responses/MediaNotReady' + + /api/v1/posts/{postId}: + get: + tags: [posts] + summary: 帖子详情 + description: | + published 对可见者开放;draft/hidden 仅作者可见,他人 404/40403 防枚举。 + 响应含 likedByMe/bookmarkedByMe 与作者公开摘要。 + # TODO-FREEZE: 等待 T3-04 —— 权限矩阵与 403/404 边界语义随实现定型 + operationId: getPost + parameters: + - $ref: '#/components/parameters/PostIdParam' + responses: + '200': + description: 帖子详情 + content: + application/json: + schema: + $ref: '#/components/schemas/PostEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PostNotFound' + patch: + tags: [posts] + summary: 编辑帖子 / 发布草稿(部分更新 + version 乐观锁) + description: | + 仅作者(非作者对可见帖 403/40301,不可见帖 404/40403)。PATCH 部分更新惯例: + 缺席字段不变,不支持清空回 null(M2 先例)。`version` 必带(缺失 400/40000, + 过期 409/40902)。发布 = `status: published` 的状态迁移(draft→published, + 服务端写 publishedAt,校验 ck_posts_publish_state);published→draft 不支持; + hidden/archived 不开放任何端点(D3-7 运营位保留)。 + operationId: updatePost + parameters: + - $ref: '#/components/parameters/PostIdParam' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePostRequest' + responses: + '200': + description: 更新成功,返回新 version 的完整帖子 + content: + application/json: + schema: + $ref: '#/components/schemas/PostEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PostAccessDenied' + '404': + $ref: '#/components/responses/PostNotFound' + '409': + $ref: '#/components/responses/VersionConflict' + '422': + $ref: '#/components/responses/MediaNotReady' + delete: + tags: [posts] + summary: 删除帖子(软删,仅作者) + description: | + 软删(deleted_at),删除后详情/Feed/列表一律不可见(M3 验收标准四)。 + 重复删除返回 404/40403(已删与不存在合并防枚举)。 + operationId: deletePost + parameters: + - $ref: '#/components/parameters/PostIdParam' + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + $ref: '#/components/schemas/VoidEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PostAccessDenied' + '404': + $ref: '#/components/responses/PostNotFound' + + /api/v1/me/posts: + get: + tags: [posts] + summary: 我的帖子列表(含草稿) + description: | + 作者视角:含 draft 与 published(软删不含)。排序 `(created_at DESC, id DESC)` + 走 `ix_posts_author_created` 游标。`status` 过滤可选(draft|published)。 + operationId: listMyPosts + parameters: + - $ref: '#/components/parameters/PageLimitParam' + - $ref: '#/components/parameters/PageCursorParam' + - name: status + in: query + required: false + schema: + type: string + enum: [draft, published] + description: 按状态过滤;缺省返回全部(不含已删) + responses: + '200': + description: cursor 分页帖子列表(完整 Post 形态) + content: + application/json: + schema: + $ref: '#/components/schemas/PostListEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + + /api/v1/feed: + get: + tags: [feed] + summary: 公共 Feed(游标分页) + description: | + 谓词恒为 `status='published' AND visibility='public' AND deleted_at IS NULL`, + 与 `ix_posts_feed` 部分索引一致;复合游标 `(published_at DESC, id DESC)`, + keyset 翻页不丢不重,禁 OFFSET。删除/hidden 帖子下一次请求即不可见。 + likedByMe/bookmarkedByMe 批量查询(避免 N+1)。 + # TODO-FREEZE: 等待 T3-05 —— Feed 卡片字段定型:content 摘要截断规则、 + # 封面选取规则(isCover 优先→position 0 兜底)、计数口径逐项写死 + operationId: getFeed + parameters: + - $ref: '#/components/parameters/PageLimitParam' + - $ref: '#/components/parameters/PageCursorParam' + responses: + '200': + description: cursor 分页 Feed 卡片列表 + content: + application/json: + schema: + $ref: '#/components/schemas/FeedListEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + + /api/v1/posts/{postId}/comments: + get: + tags: [comments] + summary: 评论列表(单层平铺,游标分页) + description: | + 排序 `(created_at DESC, id DESC)` 走 `ix_comments_post_created`; + 仅 status=visible;帖子不可见则 404/40403。 + operationId: listComments + parameters: + - $ref: '#/components/parameters/PostIdParam' + - $ref: '#/components/parameters/PageLimitParam' + - $ref: '#/components/parameters/PageCursorParam' + responses: + '200': + description: cursor 分页评论列表 + content: + application/json: + schema: + $ref: '#/components/schemas/CommentListEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PostNotFound' + post: + tags: [comments] + summary: 创建评论(幂等 + 可选 @ 回复) + description: | + `Idempotency-Key` **必带**,落 `client_request_id + request_hash` + (uq author×client_request_id),同键重试返回首条评论(同样 201), + payload 不符 409/40905。`replyToUserId` 可选 @ 回复(单层,无楼中楼, + ADR-018)。帖子不可见则 404/40403。comment_count 同事务 +1。 + operationId: createComment + parameters: + - $ref: '#/components/parameters/PostIdParam' + - $ref: '#/components/parameters/IdempotencyKeyRequiredHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCommentRequest' + responses: + '201': + description: 创建成功(或同键幂等重试返回首次结果) + content: + application/json: + schema: + $ref: '#/components/schemas/CommentEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PostNotFound' + '409': + $ref: '#/components/responses/IdempotencyPayloadMismatch' + + /api/v1/comments/{commentId}: + delete: + tags: [comments] + summary: 删除评论(仅评论作者;顶层短路径) + description: | + 顶层短路径先例(pets 域子资源 PATCH 同理):commentId 全局唯一。 + 仅评论作者可删(status→deleted,comment_count 同事务 -1);帖主删他人评论 + 首版不做(D3-7)。不存在/已删/所属帖不可见合并 404/40404。 + operationId: deleteComment + parameters: + - name: commentId + in: path + required: true + schema: + type: string + format: uuid + description: 评论 ID + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + $ref: '#/components/schemas/VoidEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + $ref: '#/components/responses/PostAccessDenied' + '404': + $ref: '#/components/responses/CommentNotFound' + + /api/v1/posts/{postId}/like: + put: + tags: [interactions] + summary: 点赞(PUT 语义幂等) + description: | + 主键 (post_id, user_id) 即幂等键:`ON CONFLICT DO NOTHING`,仅实际插入才 + like_count 同事务 +1。重复 PUT 返回 200 同一权威态(非 409),并发 N 次 + 计数恰为 1(M3 验收标准二)。帖子不可见 404/40403。 + operationId: likePost + parameters: + - $ref: '#/components/parameters/PostIdParam' + responses: + '200': + description: 权威终态(liked 恒 true) + content: + application/json: + schema: + $ref: '#/components/schemas/LikeStateEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PostNotFound' + delete: + tags: [interactions] + summary: 取消点赞(DELETE 语义幂等) + description: 取消不存在的点赞不报错不减计数,返回 200 权威态(liked 恒 false)。 + operationId: unlikePost + parameters: + - $ref: '#/components/parameters/PostIdParam' + responses: + '200': + description: 权威终态(liked 恒 false) + content: + application/json: + schema: + $ref: '#/components/schemas/LikeStateEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PostNotFound' + + /api/v1/posts/{postId}/bookmark: + put: + tags: [interactions] + summary: 收藏(PUT 语义幂等,与点赞同构) + operationId: bookmarkPost + parameters: + - $ref: '#/components/parameters/PostIdParam' + responses: + '200': + description: 权威终态(bookmarked 恒 true) + content: + application/json: + schema: + $ref: '#/components/schemas/BookmarkStateEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PostNotFound' + delete: + tags: [interactions] + summary: 取消收藏(DELETE 语义幂等) + operationId: unbookmarkPost + parameters: + - $ref: '#/components/parameters/PostIdParam' + responses: + '200': + description: 权威终态(bookmarked 恒 false) + content: + application/json: + schema: + $ref: '#/components/schemas/BookmarkStateEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/PostNotFound' + + /api/v1/me/bookmarks: + get: + tags: [interactions] + summary: 我的收藏列表(游标分页) + description: | + 排序 `(bookmarks.created_at DESC, post_id DESC)` 走 + `ix_post_bookmarks_user_created`。被收藏帖子已删/hidden 时该项不返回 + (草案取「静默剔除」,是否保留占位随 T3-05 联动定型)。项形态复用 Feed 卡片。 + operationId: listMyBookmarks + parameters: + - $ref: '#/components/parameters/PageLimitParam' + - $ref: '#/components/parameters/PageCursorParam' + responses: + '200': + description: cursor 分页收藏卡片列表 + content: + application/json: + schema: + $ref: '#/components/schemas/FeedListEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + + /api/v1/users/{userId}/follow: + put: + tags: [follows] + summary: 关注(PUT 语义幂等) + description: | + 主键 (follower, followee) 幂等,重复 PUT 返回 200 权威态;自关注 422/42204 + (库层 ck_user_follows_self 兜底);目标用户不存在/已注销 404/40406。 + 关注 Feed 与 followers 可见性不在 M3(ADR-018 最小数据接口)。 + operationId: followUser + parameters: + - $ref: '#/components/parameters/UserIdParam' + responses: + '200': + description: 权威终态(following 恒 true) + content: + application/json: + schema: + $ref: '#/components/schemas/FollowStateEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/UserNotFound' + '422': + description: 自关注(code 42204) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + selfFollow: + value: { code: 42204, message: 不能关注自己, data: null } + delete: + tags: [follows] + summary: 取消关注(DELETE 语义幂等) + operationId: unfollowUser + parameters: + - $ref: '#/components/parameters/UserIdParam' + responses: + '200': + description: 权威终态(following 恒 false) + content: + application/json: + schema: + $ref: '#/components/schemas/FollowStateEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/UserNotFound' + + /api/v1/users/{userId}/follow-stats: + get: + tags: [follows] + summary: 关注计数(关注数/粉丝数/我是否已关注) + description: | + ADR-018 最小接口的「数量」端点:followerCount/followingCount 实时 COUNT + (user_follows 双向索引支撑),followedByMe 为调用者视角。 + 查自己时 followedByMe 恒 false。关注/粉丝**列表**端点不在 M3 草案 + (最小接口裁剪,需时按纯增量补入)。 + operationId: getFollowStats + parameters: + - $ref: '#/components/parameters/UserIdParam' + responses: + '200': + description: 计数与关注状态 + content: + application/json: + schema: + $ref: '#/components/schemas/FollowStatsEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + $ref: '#/components/responses/UserNotFound' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: 'Authorization: Bearer (RS256 JWT)' + + parameters: + PostIdParam: + name: postId + in: path + required: true + schema: + type: string + format: uuid + description: 帖子 ID + AssetIdParam: + name: assetId + in: path + required: true + schema: + type: string + format: uuid + description: 媒体 asset ID + UserIdParam: + name: userId + in: path + required: true + schema: + type: string + format: uuid + description: 目标用户 ID + PageLimitParam: + name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + description: 每页条数(1~100,缺省 20);越界 400/40000 + PageCursorParam: + name: cursor + in: query + required: false + schema: + type: string + description: 上一页返回的 nextCursor(不透明字符串,客户端不得解析),首页不传;无效 400/40000 + IdempotencyKeyRequiredHeader: + name: Idempotency-Key + in: header + required: true + schema: + type: string + maxLength: 128 + description: | + **必带**幂等键(1~128 字符,缺失/超长 400/40000;与 pets 域可选 255 字符 + 不同——本域按 ADR-019 落表内幂等列,列宽 128)。键按作者隔离;同键重试 + 返回首次创建的资源(同样 201);**比对 request_hash**:同键不同 payload + 返回 409/40905。客户端每次逻辑提交换新键(建议 UUID),重试间保持不变。 + + responses: + ValidationError: + description: 参数校验失败(code 40000) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + validation: + value: { code: 40000, message: 参数校验失败, data: null } + AccessTokenInvalid: + description: access token 缺失、无效或过期(code 40101) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + tokenInvalid: + value: { code: 40101, message: token 无效或过期, data: null } + PostNotFound: + description: | + 帖子不存在、已软删、hidden,或对调用者不可见(他人 draft/private)—— + 防枚举语义:全部情况响应完全一致(code 40403)。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + postNotFound: + value: { code: 40403, message: 帖子不存在, data: null } + CommentNotFound: + description: 评论不存在、已删或所属帖子不可见(code 40404,防枚举合并) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + commentNotFound: + value: { code: 40404, message: 评论不存在, data: null } + MediaNotFound: + description: asset 不存在或非本人所有(code 40405,防枚举合并) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + mediaNotFound: + value: { code: 40405, message: 媒体不存在, data: null } + UserNotFound: + description: 目标用户不存在或已注销(code 40406) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + userNotFound: + value: { code: 40406, message: 用户不存在, data: null } + PostAccessDenied: + description: | + 对可见帖子/评论无权操作(code 40301):如编辑/删除他人已发布帖子。 + 仅发给对资源「可见」的调用者,不泄露新信息。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + accessDenied: + value: { code: 40301, message: 无权限执行该操作, data: null } + IdempotencyPayloadMismatch: + description: 同 Idempotency-Key 不同 payload,request_hash 不符(code 40905) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + mismatch: + value: { code: 40905, message: 幂等键已用于不同请求, data: null } + MediaNotReady: + description: 引用了非 ready 状态、非本人或不存在的 asset(存在性合并 40405,状态问题 code 42203) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + notReady: + value: { code: 42203, message: 媒体尚未就绪, data: null } + VersionConflict: + description: 乐观锁版本冲突(code 40902):提交的 version 已过期,刷新后重试 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + versionConflict: + value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } + + schemas: + # ---------- 通用 ---------- + VoidEnvelope: + type: object + required: [code, message] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + nullable: true + example: null + + ErrorEnvelope: + type: object + required: [code, message] + properties: + code: + type: integer + description: 稳定业务错误码(见顶部错误码段草案) + example: 40403 + message: + type: string + example: 帖子不存在 + data: + nullable: true + example: null + + AuthorSummary: + type: object + description: | + 作者公开摘要(D3-9 方案 B 预设字段:昵称 + 头像,community 跨 schema + 只读 identity 取数,ADR-017)。nickname 为空时回退 username 由**服务端** + 完成(客户端不做回退拼装);avatarUrl 为 ready 头像 asset 的访问 URL, + 无头像为 null(客户端出占位)。 + # TODO-FREEZE: 等待 T3-05 —— 公开资料形态定型(是否补 bio/是否露 username、 + # 注销用户的墓碑形态) + required: [userId, nickname] + properties: + userId: + type: string + format: uuid + nickname: + type: string + maxLength: 32 + description: 昵称(空昵称已由服务端回退为 username) + example: 毛毛的铲屎官 + avatarUrl: + type: string + nullable: true + description: 头像访问 URL;无头像为 null + example: http://127.0.0.1:9000/patbond-media/user_avatar/2026/09/018f...jpg + + # ---------- media ---------- + CreateMediaUploadRequest: + type: object + required: [kind, purpose, mimeType, byteSize] + properties: + kind: + type: string + enum: [image] + description: M3 仅 image(ADR-018 视频后置;video/document 为向后新增枚举预留) + purpose: + type: string + enum: [post_image] + description: | + 用途白名单,决定 objectKey 前缀。 + # TODO-FREEZE: 等待 T3-03 —— 是否随 P6 扩 user_avatar/pet_avatar + mimeType: + type: string + enum: [image/jpeg, image/png, image/webp] + description: | + 白名单外 400/40000。 + # TODO-FREEZE: 等待 T3-03 —— 白名单与 HEIC 支持定型 + byteSize: + type: integer + format: int64 + minimum: 1 + maximum: 10485760 + description: | + 声明的文件字节数,complete 时与对象实测比对。 + # TODO-FREEZE: 等待 T3-03 —— 单文件上限数值(草案 10 MiB)定型 + sha256: + type: string + pattern: '^[0-9a-f]{64}$' + description: 可选,小写 hex;提供则 complete 时一并核对 + + MediaUploadCredentials: + type: object + description: | + 预签名直传凭据(MinIO PUT,短 TTL,ADR-016)。 + # TODO-FREEZE: 等待 T3-03 —— 凭据形态整体定型:uploadUrl 是否含 query 签名、 + # requiredHeaders 具体键集、TTL 数值、读取侧 URL 形态(公共读桶稳定 URL vs 签名读) + required: [assetId, uploadUrl, method, expiresAt] + properties: + assetId: + type: string + format: uuid + description: 已登记的 asset ID(status=uploading) + uploadUrl: + type: string + description: 预签名 PUT 目标(客户端直传,不经应用服务器) + method: + type: string + enum: [PUT] + requiredHeaders: + type: object + additionalProperties: + type: string + description: 直传请求必须原样携带的头(如 Content-Type),键集草案态 + expiresAt: + type: string + format: date-time + description: 凭据过期时刻(草案 TTL 10 分钟);过期后须重新创建上传 + + MediaAsset: + type: object + required: [id, kind, purpose, mimeType, status, createdAt] + properties: + id: + type: string + format: uuid + kind: + type: string + enum: [image] + purpose: + type: string + example: post_image + mimeType: + type: string + example: image/jpeg + byteSize: + type: integer + format: int64 + widthPx: + type: integer + nullable: true + heightPx: + type: integer + nullable: true + status: + type: string + enum: [uploading, ready, failed] + description: deleted 态对外恒 404/40405,不出现在响应 + url: + type: string + nullable: true + description: | + 访问 URL,仅 ready 态非空。 + # TODO-FREEZE: 等待 T3-03 —— 公共读稳定 URL vs 签名读(随凭据形态一并定型) + readyAt: + type: string + format: date-time + nullable: true + createdAt: + type: string + format: date-time + + MediaUploadEnvelope: + type: object + required: [code, message, data] + properties: + code: { type: integer, enum: [0] } + message: { type: string, example: success } + data: + $ref: '#/components/schemas/MediaUploadCredentials' + + MediaAssetEnvelope: + type: object + required: [code, message, data] + properties: + code: { type: integer, enum: [0] } + message: { type: string, example: success } + data: + $ref: '#/components/schemas/MediaAsset' + + # ---------- posts ---------- + PostMediaItem: + type: object + description: 帖子挂接的一张图(响应形态) + required: [assetId, position, isCover, url] + properties: + assetId: + type: string + format: uuid + position: + type: integer + minimum: 0 + maximum: 8 + isCover: + type: boolean + url: + type: string + description: 图片访问 URL(形态随 T3-03 凭据侧一并定型) + widthPx: + type: integer + nullable: true + heightPx: + type: integer + nullable: true + caption: + type: string + nullable: true + maxLength: 300 + + PostMediaAttachRequest: + type: object + description: 帖子挂接的一张图(请求形态);asset 须本人所有且 ready,否则 422/42203 + required: [assetId] + properties: + assetId: + type: string + format: uuid + position: + type: integer + minimum: 0 + maximum: 8 + description: 0 起连续;缺省按数组序 + isCover: + type: boolean + default: false + description: 至多一个 true(uq_post_media_cover);全 false 则服务端取 position 0 + caption: + type: string + maxLength: 300 + + CreatePostRequest: + type: object + required: [content] + properties: + title: + type: string + minLength: 1 + maxLength: 120 + description: 可选标题(ck_posts_title) + content: + type: string + minLength: 1 + maxLength: 10000 + description: 正文,必填(ck_posts_content;纯文字帖合法,D3-4) + category: + type: string + enum: [general, help] + default: general + description: ai_creation 为 M4 预留值,M3 不开放写入 + status: + type: string + enum: [draft, published] + default: draft + description: published = 创建即发布(服务端写 publishedAt) + petId: + type: string + format: uuid + description: 可选关联宠物;须为调用者可见宠物,否则 404/40401 语义沿 pets 域 + media: + type: array + maxItems: 9 + description: ≤9 图(D3-4);空数组或缺席 = 纯文字帖 + items: + $ref: '#/components/schemas/PostMediaAttachRequest' + + UpdatePostRequest: + type: object + required: [version] + description: | + 部分更新:缺席字段不变;不支持清空回 null(M2 惯例)。media 若出现则**整组替换** + (position 全量重排,草案态,随 T3-04 定型)。 + properties: + version: + type: integer + minimum: 0 + description: 乐观锁,必带;过期 409/40902 + title: + type: string + minLength: 1 + maxLength: 120 + content: + type: string + minLength: 1 + maxLength: 10000 + category: + type: string + enum: [general, help] + petId: + type: string + format: uuid + status: + type: string + enum: [published] + description: 唯一开放的状态迁移 draft→published(发布动作);其余迁移 400/40000 + media: + type: array + maxItems: 9 + items: + $ref: '#/components/schemas/PostMediaAttachRequest' + + Post: + type: object + description: | + 帖子完整形态(详情 / 我的帖子列表 / 写响应共用)。region/generationJob/topics + 等裁剪字段整体不出现(ADR-018 + ADR-010 先例),后续按新增可选字段纯增量补入。 + required: + - id + - author + - category + - content + - status + - visibility + - media + - likeCount + - commentCount + - bookmarkCount + - likedByMe + - bookmarkedByMe + - createdAt + - updatedAt + - version + properties: + id: + type: string + format: uuid + author: + $ref: '#/components/schemas/AuthorSummary' + petId: + type: string + format: uuid + nullable: true + category: + type: string + enum: [general, help, ai_creation] + description: ai_creation 仅读侧预留(M3 无法写入) + title: + type: string + nullable: true + maxLength: 120 + content: + type: string + maxLength: 10000 + status: + type: string + enum: [draft, published] + description: hidden/archived 对非作者恒 404;对作者是否露出运营态待 T3-04(草案不露) + visibility: + type: string + enum: [public] + description: M3 恒 public(ADR-018:followers/private 语义后置,字段保留) + media: + type: array + items: + $ref: '#/components/schemas/PostMediaItem' + likeCount: + type: integer + format: int64 + commentCount: + type: integer + format: int64 + bookmarkCount: + type: integer + format: int64 + likedByMe: + type: boolean + bookmarkedByMe: + type: boolean + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + publishedAt: + type: string + format: date-time + nullable: true + description: 仅 published 非空 + version: + type: integer + + FeedCard: + type: object + description: | + Feed / 收藏列表卡片形态(较 Post 裁剪)。 + # TODO-FREEZE: 等待 T3-05 —— 卡片字段整体定型:contentPreview 截断长度与规则、 + # coverImage 选取规则(isCover→position 0)、是否带 mediaCount 之外的图列表 + required: + - id + - author + - category + - contentPreview + - mediaCount + - likeCount + - commentCount + - bookmarkCount + - likedByMe + - bookmarkedByMe + - publishedAt + properties: + id: + type: string + format: uuid + author: + $ref: '#/components/schemas/AuthorSummary' + category: + type: string + enum: [general, help, ai_creation] + title: + type: string + nullable: true + contentPreview: + type: string + description: 正文摘要(服务端截断,规则待 T3-05 定型;草案 200 字符 + 完整边界截断) + coverImage: + nullable: true + allOf: + - $ref: '#/components/schemas/PostMediaItem' + description: 封面图;纯文字帖为 null + mediaCount: + type: integer + description: 帖子图片总数(卡片角标「1/9」类展示) + likeCount: + type: integer + format: int64 + commentCount: + type: integer + format: int64 + bookmarkCount: + type: integer + format: int64 + likedByMe: + type: boolean + bookmarkedByMe: + type: boolean + publishedAt: + type: string + format: date-time + + PostEnvelope: + type: object + required: [code, message, data] + properties: + code: { type: integer, enum: [0] } + message: { type: string, example: success } + data: + $ref: '#/components/schemas/Post' + + PostListEnvelope: + type: object + required: [code, message, data] + properties: + code: { type: integer, enum: [0] } + message: { type: string, example: success } + data: + type: object + description: cursor 分页正典信封;排序 created_at DESC, id DESC + required: [items, hasMore] + properties: + items: + type: array + items: + $ref: '#/components/schemas/Post' + nextCursor: + type: string + nullable: true + description: 下一页游标(不透明 base64url),hasMore=false 时恒为 null + hasMore: + type: boolean + + FeedListEnvelope: + type: object + required: [code, message, data] + properties: + code: { type: integer, enum: [0] } + message: { type: string, example: success } + data: + type: object + description: | + cursor 分页正典信封;公共 Feed 排序 published_at DESC, id DESC; + 收藏列表排序 bookmarks.created_at DESC, post_id DESC + required: [items, hasMore] + properties: + items: + type: array + items: + $ref: '#/components/schemas/FeedCard' + nextCursor: + type: string + nullable: true + description: 下一页游标(不透明 base64url),hasMore=false 时恒为 null + hasMore: + type: boolean + + # ---------- comments ---------- + CreateCommentRequest: + type: object + required: [content] + properties: + content: + type: string + minLength: 1 + maxLength: 2000 + description: ck_comments_content 同宽 + replyToUserId: + type: string + format: uuid + description: 可选 @ 回复目标(单层平铺,无 parentCommentId,ADR-018) + + Comment: + type: object + required: [id, postId, author, content, createdAt] + properties: + id: + type: string + format: uuid + postId: + type: string + format: uuid + author: + $ref: '#/components/schemas/AuthorSummary' + replyToUser: + nullable: true + allOf: + - $ref: '#/components/schemas/AuthorSummary' + description: '@ 回复目标的公开摘要;非回复为 null' + content: + type: string + maxLength: 2000 + createdAt: + type: string + format: date-time + + CommentEnvelope: + type: object + required: [code, message, data] + properties: + code: { type: integer, enum: [0] } + message: { type: string, example: success } + data: + $ref: '#/components/schemas/Comment' + + CommentListEnvelope: + type: object + required: [code, message, data] + properties: + code: { type: integer, enum: [0] } + message: { type: string, example: success } + data: + type: object + description: cursor 分页正典信封;排序 created_at DESC, id DESC + required: [items, hasMore] + properties: + items: + type: array + items: + $ref: '#/components/schemas/Comment' + nextCursor: + type: string + nullable: true + description: 下一页游标(不透明 base64url),hasMore=false 时恒为 null + hasMore: + type: boolean + + # ---------- interactions / follows ---------- + LikeState: + type: object + description: 点赞权威终态(乐观更新以此对账回滚) + required: [liked, likeCount] + properties: + liked: + type: boolean + likeCount: + type: integer + format: int64 + + BookmarkState: + type: object + description: 收藏权威终态(与点赞同构) + required: [bookmarked, bookmarkCount] + properties: + bookmarked: + type: boolean + bookmarkCount: + type: integer + format: int64 + + FollowState: + type: object + description: 关注权威终态;followerCount 为目标用户的粉丝数 + required: [following, followerCount] + properties: + following: + type: boolean + followerCount: + type: integer + format: int64 + + FollowStats: + type: object + required: [followerCount, followingCount, followedByMe] + properties: + followerCount: + type: integer + format: int64 + description: 目标用户的粉丝数 + followingCount: + type: integer + format: int64 + description: 目标用户关注的人数 + followedByMe: + type: boolean + description: 调用者是否已关注目标用户;查自己恒 false + + LikeStateEnvelope: + type: object + required: [code, message, data] + properties: + code: { type: integer, enum: [0] } + message: { type: string, example: success } + data: + $ref: '#/components/schemas/LikeState' + + BookmarkStateEnvelope: + type: object + required: [code, message, data] + properties: + code: { type: integer, enum: [0] } + message: { type: string, example: success } + data: + $ref: '#/components/schemas/BookmarkState' + + FollowStateEnvelope: + type: object + required: [code, message, data] + properties: + code: { type: integer, enum: [0] } + message: { type: string, example: success } + data: + $ref: '#/components/schemas/FollowState' + + FollowStatsEnvelope: + type: object + required: [code, message, data] + properties: + code: { type: integer, enum: [0] } + message: { type: string, example: success } + data: + $ref: '#/components/schemas/FollowStats' diff --git a/mkdocs.yml b/mkdocs.yml index 87d892e..dc58699 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,6 +72,12 @@ nav: - 06 埋点规划: development/iterations/iteration-3/06-experiment-tracking-plan.md - 07 证据基线审计: development/iterations/iteration-3/07-evidence-baseline-audit.md - 08 Git 工作流规划: development/iterations/iteration-3/08-git-workflow-plan.md + - 09 社区地基报告: development/iterations/iteration-3/09-community-foundation-report.md + - 10 埋点队列加固: development/iterations/iteration-3/10-analytics-queue-hardening.md + - 11 社区契约起草: development/iterations/iteration-3/11-community-contract-draft.md + - 12 防泄漏检查落地: development/iterations/iteration-3/12-secret-scan-rollout.md + - 13 media MinIO 闭环: development/iterations/iteration-3/13-media-minio-report.md + - 14 第一波收口: development/iterations/iteration-3/14-wave1-closure.md - API: - 契约说明: api/index.md - 架构: