test(contract): v1.4.0 快照四模块同步 + 用户资料与头像矩阵入场(T3.5-07)
CI / backend-test (push) Failing after 2s

doc 仓正典 main@5f02909 冻结 v1.4.0 后的 api 侧收尾:字节级同步快照、守卫
升版、新增/变更操作入契约一致性矩阵。**零生产代码改动**(只改契约快照与测试)。

快照同步(字节级,md5 与正典逐一比对一致 a7081fb84f1207eef579ab94025f5801):
- 四模块 openapi-v1.3.0.yaml → openapi-v1.4.0.yaml,删旧文件(守卫只认一份,
  保留旧快照是死重;历史版本由 git 与 doc 仓承载,沿 T3-19 先例)
- 四份守卫期望升版:1.3.0/31/43/72 → 1.4.0/32/45/75

矩阵扩展(173 → 181 格,豁免仍为 1 格):
- patbond-auth +5 格:PATCH /api/v1/me 全响应矩阵(200 设值 / 200 显式 null
  清空 / 400 空 patch / 401 / 404 双码 40400+40405 / 422 42203),并让 GET
  /api/v1/me 在 nickname 非空分支再走一遍严格校验。注意 /api/v1/me 的守卫与
  矩阵都在 auth 模块(实现在 user,契约测试跨服务发请求),扩契约易漏
- patbond-pet +1 格:PATCH /api/v1/pets/{petId} 新增 422/42203;404 单元格
  补 40405 第二种业务码(幽灵 asset 与用途不符 asset 合并同答),并补一格
  挂 ready 头像的 200
- patbond-community +2 格:GET /api/v1/me/community-stats 200(空数据零值与
  有数据 1 赞/2 作品两分支)+ 401(由全操作循环覆盖)
- patbond-user media 域 2 操作 8 格不变(v1.4.0 未触碰其冻结面)

T3.5-04/05/06 遗留的 11 格契约守卫红(auth 2 + pet 9,根因为新增字段未冻结)
全部转绿。测试 379 → 381(+2 个新增矩阵方法),根反应堆 clean test 全绿,
check-secrets.sh --all exit 0。

mutation 自证(三处定向注毒均红、还原即绿):CommunityStats.required 注入
fakeStatsField → GET /me/community-stats 200 报漂移;Me.required 注入
fakeMeField → GET+PATCH /me 200 报漂移;Pet.required 注入 fakePetAvatarField
→ POST /pets 201 等 9 格报漂移。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-11 10:33:19 +08:00
parent d98a400f47
commit 3cd8005577
12 changed files with 1486 additions and 160 deletions
@@ -20,6 +20,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.containers.PostgreSQLContainer;
@@ -38,16 +39,24 @@ import java.util.concurrent.ConcurrentHashMap;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
/** /**
* T3-19D3-8):auth 域 6 个 M1 操作补进契约一致性保障,机制与 * T3-19D3-8):auth 域 M1 操作补进契约一致性保障,机制与
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.3.0(快照 * patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.4.0(快照
* {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓 * {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在 doc 仓
* {@code docs/api/openapi.yaml})逐操作真实发请求,用 {@link ContractValidator} * {@code docs/api/openapi.yaml})逐操作真实发请求,用 {@link ContractValidator}
* 严格校验响应结构,最后以全响应矩阵门禁兜底。 * 严格校验响应结构,最后以全响应矩阵门禁兜底。
* *
* <p>与 pet 侧的差别只在运行方式:register/login/refresh/logout 走真实 HTTP * <p>与 pet 侧的差别只在运行方式:register/login/refresh/logout 走真实 HTTP
* 打到 auth 服务(本测试的 Spring 上下文),me/trackEvents 打到同 JVM 内 * 打到 auth 服务(本测试的 Spring 上下文),me/trackEvents 打到同 JVM 内
* 启动的真实 user 服务(复用 AuthE2eIntegrationTest 的编排先例), * 启动的真实 user 服务(复用 AuthE2eIntegrationTest 的编排先例),
* 因此这 6 个操作是跨服务的真实纵切,不是 MockMvc 短路。 * 因此这操作是跨服务的真实纵切,不是 MockMvc 短路。
*
* <p><b>T3.5-07</b>v1.4.0 把 {@code PATCH /api/v1/me} 与 {@code GET} 的
* nickname/avatarUrl 纳入冻结面,故本类的操作面自 6 增至 7。注意
* {@code /api/v1/me} 的守卫与矩阵都在 **auth 模块**(实现在 patbond-user
* 但契约测试在这里跨服务发请求),扩契约时容易漏。本类不配置对象存储,
* 故 {@code avatarUrl} 恒为 null——正是「存储未配置时资料读取整体降级」的
* nullable 实证;真实签名 URL 的全链路在 patbond-user 的
* MeAvatarSigningIntegrationTest(真实 MinIO)覆盖。
*/ */
@TestMethodOrder(MethodOrderer.OrderAnnotation.class) @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@SpringBootTest( @SpringBootTest(
@@ -63,13 +72,14 @@ class AuthContractConformanceTest {
/** 已被真实响应校验过的 (操作, 状态码) 单元格。 */ /** 已被真实响应校验过的 (操作, 状态码) 单元格。 */
private static final Set<String> COVERED = ConcurrentHashMap.newKeySet(); private static final Set<String> COVERED = ConcurrentHashMap.newKeySet();
/** auth 域 6 个操作(= 契约中 tags ∈ {auth, user, analytics})。 */ /** auth 域 7 个操作(= 契约中 tags ∈ {auth, user, analytics})。 */
private static final List<String> AUTH_OPERATIONS = List.of( private static final List<String> AUTH_OPERATIONS = List.of(
"POST /api/v1/auth/register", "POST /api/v1/auth/register",
"POST /api/v1/auth/login", "POST /api/v1/auth/login",
"POST /api/v1/auth/refresh", "POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout", "POST /api/v1/auth/logout",
"GET /api/v1/me", "GET /api/v1/me",
"PATCH /api/v1/me",
"POST /api/v1/events"); "POST /api/v1/events");
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:18"); private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:18");
@@ -305,6 +315,80 @@ class AuthContractConformanceTest {
null, 423, 42300); null, 423, 42300);
} }
// ---- PATCH /api/v1/mev1.4.0 新增操作,T3.5-07--------------------
/**
* `PATCH /api/v1/me` 的全响应矩阵(200/400/401/404/422)与三态语义的形态
* 实证,外加 GET 在 nickname 非空分支上再走一遍严格校验。
*
* <p>422/42203 需要一枚「本人所有、用途 user_avatar、状态 uploading」的
* asset:本上下文未配置对象存储(创建上传会 500),故按 MeProfileIntegrationTest
* 的先例直接写 media.assets 行——测试数据造法,不触碰实现。
*/
@Test
@Order(7)
void meProfileWriteShapes() {
String registered = register("contract_auth_frank", "+8613800000608");
String accessToken = JsonPath.read(registered, "$.data.accessToken");
UUID userId = UUID.fromString(JsonPath.read(registered, "$.data.userId"));
// 200:三态「给值」——设昵称;avatarUrl 为 null(存储未配置时的降级实证)
String patched = verified(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
"{\"nickname\":\"契约昵称\"}", accessToken, 200);
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("契约昵称");
assertThat((Object) JsonPath.read(patched, "$.data.avatarUrl")).isNull();
// GET 回读:nickname 非空分支同样过严格校验(PATCH 与 GET 同一 Me 形态)
String reread = verified(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me",
null, accessToken, 200);
assertThat((String) JsonPath.read(reread, "$.data.nickname")).isEqualTo("契约昵称");
// 200:三态「显式 null = 清空」
String cleared = verified(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
"{\"nickname\":null}", accessToken, 200);
assertThat((Object) JsonPath.read(cleared, "$.data.nickname")).isNull();
// 400/40000:空 patch(不静默 200
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
"{}", accessToken, 400, 40000);
// 401/40101:无 token
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
"{\"nickname\":\"无票\"}", null, 401, 40101);
// 404:同一单元格的两种业务码——用户已注销 40400 / asset 不可引用 40405
String ghostToken = signToken(TestJwtKeys.KEY_PAIR.getPrivate(),
UUID.randomUUID().toString(), Duration.ofMinutes(15));
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
"{\"nickname\":\"幽灵\"}", ghostToken, 404, 40400);
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
"{\"avatarAssetId\":\"%s\"}".formatted(UUID.randomUUID()),
accessToken, 404, 40405);
// 422/42203:本人的 user_avatar asset 仍在 uploading
UUID uploading = insertUserAvatarAsset(userId, "uploading");
verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me",
"{\"avatarAssetId\":\"%s\"}".formatted(uploading), accessToken, 422, 42203);
}
/** 一枚本人所有的 user_avatar asset 行(模拟 T3-03 两步上传的中间产物)。 */
private UUID insertUserAvatarAsset(UUID ownerUserId, String status) {
UUID id = UUID.randomUUID();
userApp.getBean(JdbcClient.class).sql("""
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
mime_type, byte_size, status)
VALUES (:id, :owner, 'image', 'user_avatar', 'object', 'patbond-media',
:objectKey, 'image/jpeg', 2048, :status)
""")
.param("id", id)
.param("owner", ownerUserId)
.param("objectKey", "user_avatar/2026/09/" + id)
.param("status", status)
.update();
return id;
}
// ---- 快照与覆盖门禁 ------------------------------------------------- // ---- 快照与覆盖门禁 -------------------------------------------------
/** /**
@@ -314,17 +398,17 @@ class AuthContractConformanceTest {
@Test @Test
@Order(98) @Order(98)
void frozenSnapshotIsTheExpectedContractVersion() { void frozenSnapshotIsTheExpectedContractVersion() {
assertThat(CONTRACT.version()).isEqualTo("1.3.0"); assertThat(CONTRACT.version()).isEqualTo("1.4.0");
assertThat(CONTRACT.paths()).hasSize(31); assertThat(CONTRACT.paths()).hasSize(32);
assertThat(CONTRACT.operations()).hasSize(43); assertThat(CONTRACT.operations()).hasSize(45);
assertThat(CONTRACT.schemas()).hasSize(72); assertThat(CONTRACT.schemas()).hasSize(75);
assertThat(CONTRACT.operationsTagged(Set.of("auth", "user", "analytics"))) assertThat(CONTRACT.operationsTagged(Set.of("auth", "user", "analytics")))
.containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS); .containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS);
} }
/** /**
* 全矩阵覆盖门禁:auth 域 6 个操作声明的每个 (操作, 状态码) 都必须被 * 全矩阵覆盖门禁:auth 域 7 个操作声明的每个 (操作, 状态码) 都必须被
* 前面的测试真实触发并通过契约校验(19 个单元格,无豁免)。 * 前面的测试真实触发并通过契约校验(24 个单元格,无豁免)。
*/ */
@Test @Test
@Order(99) @Order(99)
@@ -13,8 +13,8 @@ import java.util.Objects;
import java.util.Set; import java.util.Set;
/** /**
* The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot * The frozen v1.4.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.3.0.yaml}. * {@code /contract/openapi-v1.4.0.yaml}.
* *
* <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical * <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical
* contract lives in the doc repo at {@code docs/api/openapi.yaml}; this * contract lives in the doc repo at {@code docs/api/openapi.yaml}; this
@@ -36,7 +36,7 @@ import java.util.Set;
*/ */
final class OpenApiContract { final class OpenApiContract {
static final String RESOURCE = "/contract/openapi-v1.3.0.yaml"; static final String RESOURCE = "/contract/openapi-v1.4.0.yaml";
private static final Set<String> HTTP_METHODS = private static final Set<String> HTTP_METHODS =
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace"); Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
@@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约) title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约)
version: 1.3.0 version: 1.4.0
description: | description: |
Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差), Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差),
1.1.0 追加埋点上报端点 `POST /api/v1/events`M2 第一波契约补录,以实现实测行为为准)。 1.1.0 追加埋点上报端点 `POST /api/v1/events`M2 第一波契约补录,以实现实测行为为准)。
@@ -11,6 +11,14 @@ info:
**1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、 **1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、
公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入 公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入
iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。 iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。
**1.4.0 M3.5 契约冻结:用户资料与头像**——`GET /api/v1/me` 补 `nickname`/`avatarUrl`
新增 `PATCH /api/v1/me`(昵称与头像读写,三态部分更新),`Pet` 补 `avatarUrl` 且
`PATCH /api/v1/pets/{petId}` 收 `avatarAssetId`,新增 `GET /api/v1/me/community-stats`
(获赞总数与作品数),媒体 `purpose` 白名单追加 `user_avatar`/`pet_avatar`
iteration-3.5 报告 03 定型表;冻结报告见 iteration-3.5/04)。
**1.4.0 相对 1.3.0 纯增量**:无字段删改、无类型变更、无必填收紧,仅新增操作、
新增响应字段(键恒在、值可空)、新增可选请求字段、新增响应格与枚举追加,
v1.3.0 客户端无需改动即可继续工作。
## 通用约定(development-plan 第 6 节) ## 通用约定(development-plan 第 6 节)
- 公开接口统一前缀 `/api/v1`JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 - 公开接口统一前缀 `/api/v1`JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。
@@ -29,14 +37,14 @@ info:
| 40100 | 401 | 用户名或密码错误 | | 40100 | 401 | 用户名或密码错误 |
| 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) | | 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) |
| 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) | | 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) |
| 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录、caregiver 改宠物档案) | | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录或改头像、caregiver 改宠物档案——头像除外,见 M3.5 分档 |
| 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 | | 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 |
| 40400 | 404 | 资源不存在 | | 40400 | 404 | 资源不存在 |
| 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) | | 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) |
| 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) | | 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) |
| 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 | | 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 |
| 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) | | 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) |
| 40405 | 404 | MEDIA_NOT_FOUNDasset 不存在、非本人所有已删(防枚举合并 | | 40405 | 404 | MEDIA_NOT_FOUNDasset 不存在、非本人所有已删,或**用途与引用场景不符**(帖图当头像、user_avatar 当宠物头像等);防枚举合并,同码各情形响应一致(用途不符分支只对调用者自己的 asset 可达,故 message 可具体 |
| 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) | | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) |
| 40900 | 409 | 用户名已存在(大小写不敏感) | | 40900 | 409 | 用户名已存在(大小写不敏感) |
| 40901 | 409 | 手机号已被使用 | | 40901 | 409 | 手机号已被使用 |
@@ -46,7 +54,7 @@ info:
| 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) | | 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) |
| 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 | | 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 |
| 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 | | 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 |
| 42203 | 422 | MEDIA_NOT_READY:引用了本人所有但非 readyuploading/failed)状态的 asset | | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有、用途相符但非 readyuploading/failed)状态的 asset(帖图与用户/宠物头像同构) |
| 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op | | 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op |
| 42205 | 422 | MEDIA_UPLOAD_STATE_INVALIDcomplete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 | | 42205 | 422 | MEDIA_UPLOAD_STATE_INVALIDcomplete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 |
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) | | 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
@@ -68,7 +76,10 @@ info:
- **权限模型(ADR-015owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档: - **权限模型(ADR-015owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档:
- `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要; - `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要;
- `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH - `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH
- `MANAGE`——仅 owner:宠物档案 PATCH(含状态流转)。 **M3.5 起还含宠物头像字段 `avatarAssetId`**ADR-022:头像属日常照护信息)。
- `MANAGE`——仅 owner:宠物档案 PATCH 的资料字段(含状态流转)。
**同一端点按「本次请求触及哪些字段」定档**(不是端点级降档):仅改头像走 WRITE
触及任一资料字段走 MANAGE,混合请求取更严的一半。
权限每请求实时查库、无缓存:撤销照护关系立即生效。 权限每请求实时查库、无缓存:撤销照护关系立即生效。
- **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况 - **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况
响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与 响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与
@@ -126,6 +137,41 @@ info:
整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口 整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口
(如作者公开资料批量接口)不属于本公开契约。 (如作者公开资料批量接口)不属于本公开契约。
## 用户资料与头像域约定(M3.5 冻结,iteration-3.5 报告 03 定型;ADR-022
- **三态部分更新(仅本域,pets 域 M2 两态语义不回改)**`PATCH /api/v1/me` 的
`nickname`/`avatarAssetId` 与 `PATCH /api/v1/pets/{petId}` 的 `avatarAssetId`
按三态解释——**键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。
昵称与头像天生可选,「删掉我设的那个」是一等公民操作,只有两态无法表达。
同一请求体内的其余 pets 字段仍是 M2 的「缺省或 null 皆为不改」。
- **空 PATCH 与纯空白昵称一律 400/40000**,不静默 200、不隐式清空:清空只留
显式 `null` 一条路,否则「误提交空格」与「想删昵称」无法区分。
- **昵称**btrim 后 1~32 **码点**(非 UTF-16 长度);**不设唯一约束**ADR-022
允许重名,靠 userId 区分);注册不收昵称。`/api/v1/me` 返回 **DB 原值**
未设置即 `null`**不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定
固化成真实数据;他人视角的展示回退在 `/internal/users/profiles`SQL COALESCE),
本人视角的展示回退由客户端做 `nickname ?? username`。
- **头像读取一律 `avatarUrl`(时效性预签名 GET,与帖图同一纪律)**:每次响应现签,
**会过期、客户端不得持久化**,过期即重取;无头像、asset 非 ready、对象存储未配置
三种情况均为 `null`(降级而非报错——签一个必然 404 的 URL 比给 null 更糟)。
指针不隐式清理:asset 事后退出 ready 时 `avatarUrl` 转 null 而引用保留。
- **`avatarAssetId` 只写不读**:请求体收,响应**一律不外露**(`Me` 与 `Pet` 皆无该字段);
「是否有头像」等价于 `avatarUrl != null`。
- **两种头像用途互不通用**`user_avatar` 不能当宠物头像、`pet_avatar` 不能当用户头像、
`post_image` 不能当任何头像——引用侧按 `purpose` 校验,不符者 404/40405
(四态校验:不存在/非本人/已删/用途不符 → 404/40405;本人且用途相符但
uploading/failed → 422/42203)。
- **宠物头像的权限档按「本次请求碰了哪些字段」定档**(ADR-022 头像为 WRITE 档):
仅改 `avatarAssetId` 时 owner + caregiver 皆可(viewer 403/40300);触及任一资料
字段时仍是 MANAGE(仅 owner);**混合请求取更严的一半**,堵住把改名夹带进头像
请求绕过 MANAGE 的路径。头像与资料共用同一把乐观锁 `version`(仍必填)。
- **`/api/v1/me` 无乐观锁、无幂等键**:只有一个合法写者(账号本人),暴露 `version`
只是给客户端加负担;丢失更新由**列级选择性 UPDATE** 排除(SET 列表只含本次请求
真正携带的列),并发改不同字段两者皆存活;同 body 重放天然幂等。
- **`GET /api/v1/me/community-stats` 为独立端点**ADR-022 决策 A,不并入
`/users/{userId}/follow-stats`——后者主体是「某用户的关注数」,混入「我的获赞」
会让一个载荷有两个主体)。路径上**没有 userId**:「查不到别人的获赞」不靠权限
判断,而是入口本身不存在,故**永不 404**,任何已认证用户都有 stats。
servers: servers:
- url: http://127.0.0.1:8081 - url: http://127.0.0.1:8081
description: patbond-auth(本地开发,/api/v1/auth/** description: patbond-auth(本地开发,/api/v1/auth/**
@@ -140,7 +186,7 @@ tags:
- name: auth - name: auth
description: 注册 / 登录 / 刷新 / 退出(patbond-auth description: 注册 / 登录 / 刷新 / 退出(patbond-auth
- name: user - name: user
description: 当前用户(patbond-user description: 当前用户资料:读取与昵称/头像更新patbond-user
- name: analytics - name: analytics
description: 产品事件批量上报(patbond-user description: 产品事件批量上报(patbond-user
- name: pets - name: pets
@@ -152,7 +198,9 @@ tags:
- name: media - name: media
description: 媒体上传两步流程(patbond-userADR-016 预签名直传) description: 媒体上传两步流程(patbond-userADR-016 预签名直传)
- name: posts - name: posts
description: 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子(patbond-community description: |
帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子;含由帖子派生的「我的社区数字」
聚合(patbond-community
- name: feed - name: feed
description: 公共 Feed 游标分页(patbond-community description: 公共 Feed 游标分页(patbond-community
- name: comments - name: comments
@@ -301,7 +349,15 @@ paths:
get: get:
tags: [user] tags: [user]
summary: 当前用户资料 summary: 当前用户资料
description: 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 description: |
由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。
- `nickname` 为 **DB 原值**,未设置即 `null`**不做 username 回退**,见 info
「用户资料与头像域约定」);本人视角的展示回退由客户端做 `nickname ?? username`。
- `avatarUrl` 为**每次响应现签的时效性预签名 GET**:**会过期、客户端不得持久化**,
过期即重取;无头像、asset 非 ready、对象存储未配置均为 `null`。
- 响应**不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于
`avatarUrl != null`。
operationId: me operationId: me
security: security:
- bearerAuth: [] - bearerAuth: []
@@ -320,6 +376,66 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorEnvelope' $ref: '#/components/schemas/ErrorEnvelope'
patch:
tags: [user]
summary: 更新当前用户资料(昵称 / 头像,三态部分更新)
description: |
本人资料的唯一写入口(主体恒为 token 里的调用者,无「他人」情形)。
- **三态语义**:键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置。
- **空 patch 400/40000**(两字段都未出现,含只带未声明字段):不静默 200,
空 PATCH 几乎总是客户端 bug。纯空白/空串昵称同为 400/40000,不隐式清空。
- `avatarAssetId` 须为**调用者本人、用途为 `user_avatar`、状态 `ready`** 的 asset
不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed
422/42203。
- **无 `version` 乐观锁、无 `Idempotency-Key`**:只有一个合法写者;丢失更新由
列级选择性 UPDATE 排除(并发改不同字段两者皆存活),同 body 重放天然幂等。
- 成功返回**与 GET 完全相同的 `Me` 全量形态**(回显更新后资料,`avatarUrl` 现签)。
operationId: updateMe
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateMeRequest'
responses:
'200':
description: 更新成功,返回更新后的完整 Me
content:
application/json:
schema:
$ref: '#/components/schemas/MeEnvelope'
'400':
description: |
参数校验失败(code 40000):空 patch、昵称 btrim 后长度不在 1~32 码点、
昵称纯空白或空串、`avatarAssetId` 非法 UUID、body 非法 JSON
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
emptyPatch:
value: { code: 40000, message: 请求未包含任何可更新字段, data: null }
'401':
$ref: '#/components/responses/AccessTokenInvalid'
'404':
description: |
用户不存在或已注销(code 40400,token 仍有效但账号已注销);或
`avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `user_avatar`
code 40405,防枚举合并)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
userNotFound:
value: { code: 40400, message: 用户不存在, data: null }
mediaNotFound:
value: { code: 40405, message: 媒体资源不存在, data: null }
'422':
$ref: '#/components/responses/MediaNotReady'
/api/v1/events: /api/v1/events:
post: post:
@@ -468,18 +584,32 @@ paths:
$ref: '#/components/responses/PetNotFound' $ref: '#/components/responses/PetNotFound'
patch: patch:
tags: [pets] tags: [pets]
summary: 更新宠物档案 summary: 更新宠物档案(含头像)
description: | description: |
权限档MANAGE**仅 owner**);caregiver/viewer 更新得 403/40300。 权限档**按本次请求触及的字段定档**(M3.5 起,ADR-022):
- 部分更新:缺席字段不变;**不支持将可选字段清空回 null**。 | 请求体触及 | 所需档位 | caregiver | viewer |
| --- | --- | --- | --- |
| 仅 `avatarAssetId`+ `version` | WRITE | ✅ | ✗ 403/40300 |
| 任一资料字段(name/sex/status/…) | MANAGE(仅 owner | ✗ 403/40300 | ✗ 403/40300 |
| 资料字段 + `avatarAssetId` 混合 | MANAGE**取更严的一半** | ✗ 403/40300 | ✗ 403/40300 |
混合请求取更严,是为了堵住「夹带」——否则 caregiver 可把改名塞进头像请求绕过 MANAGE。
- 部分更新:缺席字段不变;资料字段**不支持清空回 null**(M2 语义不回改)。
- **例外:`avatarAssetId` 是本端点唯一的三态字段**(M3.5)——键缺省 = 不改;
键出现且为 `null` = **清除头像**;键出现且有值 = 设置。差异刻意限定在有清空
需求的字段上。
- 例外:品种对(`breedId`/`customBreedName`**整体替换**——提交任一侧即替换 - 例外:品种对(`breedId`/`customBreedName`**整体替换**——提交任一侧即替换
整对,互斥校验同创建。 整对,互斥校验同创建。
- `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。 - `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。
- `status` 可迁移至 active/lost/deceased/archived**`deleted` 不可经 PATCH - `status` 可迁移至 active/lost/deceased/archived**`deleted` 不可经 PATCH
设置**(400/40000,软删除留待专用端点,M2 契约不含)。 设置**(400/40000,软删除留待专用端点,M2 契约不含)。
- `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 - `version` **必填**(缺失 400/40000),即便只改头像;比对通过才写入并 +1
过期 409/40902。头像与资料共用同一把乐观锁——头像变更也应让并发编辑者感知行已变。
- 芯片号改为已被登记的值:409/40903。 - 芯片号改为已被登记的值:409/40903。
- `avatarAssetId` 须为**调用者本人、用途为 `pet_avatar`、状态 `ready`** 的 asset
不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203。
operationId: updatePet operationId: updatePet
security: security:
- bearerAuth: [] - bearerAuth: []
@@ -505,7 +635,19 @@ paths:
'403': '403':
$ref: '#/components/responses/PetWriteDenied' $ref: '#/components/responses/PetWriteDenied'
'404': '404':
$ref: '#/components/responses/PetNotFound' description: |
宠物不存在、已软删除或调用者与宠物无关系(code 40401,防枚举合并);或
`avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `pet_avatar`
code 40405,防枚举合并)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
petNotFound:
value: { code: 40401, message: 宠物不存在, data: null }
mediaNotFound:
value: { code: 40405, message: 媒体资源不存在, data: null }
'409': '409':
description: 版本冲突(code 40902)或芯片号已被登记(code 40903) description: 版本冲突(code 40902)或芯片号已被登记(code 40903)
content: content:
@@ -517,6 +659,8 @@ paths:
value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null }
microchipExists: microchipExists:
value: { code: 40903, message: 芯片号已被登记, data: null } value: { code: 40903, message: 芯片号已被登记, data: null }
'422':
$ref: '#/components/responses/MediaNotReady'
/api/v1/breeds: /api/v1/breeds:
get: get:
@@ -1201,7 +1345,7 @@ paths:
petNotFound: petNotFound:
value: { code: 40401, message: 宠物不存在, data: null } value: { code: 40401, message: 宠物不存在, data: null }
mediaNotFound: mediaNotFound:
value: { code: 40405, message: 媒体不存在, data: null } value: { code: 40405, message: 媒体资源不存在, data: null }
'409': '409':
$ref: '#/components/responses/IdempotencyPayloadMismatch' $ref: '#/components/responses/IdempotencyPayloadMismatch'
'422': '422':
@@ -1284,7 +1428,7 @@ paths:
petNotFound: petNotFound:
value: { code: 40401, message: 宠物不存在, data: null } value: { code: 40401, message: 宠物不存在, data: null }
mediaNotFound: mediaNotFound:
value: { code: 40405, message: 媒体不存在, data: null } value: { code: 40405, message: 媒体资源不存在, data: null }
'409': '409':
$ref: '#/components/responses/VersionConflict' $ref: '#/components/responses/VersionConflict'
'422': '422':
@@ -1348,6 +1492,35 @@ paths:
'401': '401':
$ref: '#/components/responses/AccessTokenInvalid' $ref: '#/components/responses/AccessTokenInvalid'
/api/v1/me/community-stats:
get:
tags: [posts]
summary: 我的社区数字(获赞总数 / 作品数)
description: |
主体恒为 token 里的调用者:无查询参数、无路径参数,**路径上没有 userId**——
「查不到别人的获赞」不靠权限判断,而是入口本身不存在。
- **统计集合 = 本人的、`status='published'` 的、`deleted_at IS NULL` 的帖**。
草稿不计(尚非作品,且未发布不可被赞);软删不计(删帖即撤回其数字,与
`/me/posts`、Feed 的可见性一致);运营态 hidden/archived 不计(对所有人不可见,
含作者本人);他人帖自然不计。
- **自己赞自己计入**——与帖子详情页的 `likeCount` 保持同一口径,两处数字必须能对上。
- `receivedLikeCount` = 该集合的 `like_count` 之和(读侧实时聚合,读的是写侧同事务
维护的帖级冗余列,故为精确值而非估算;ADR-022 不引入按人累计的冗余列)。
- **空数据返回 `0` 而非 null**,且**永不 404**:任何已认证用户都有 stats。
operationId: getMyCommunityStats
security:
- bearerAuth: []
responses:
'200':
description: 我的获赞总数与作品数
content:
application/json:
schema:
$ref: '#/components/schemas/CommunityStatsEnvelope'
'401':
$ref: '#/components/responses/AccessTokenInvalid'
/api/v1/feed: /api/v1/feed:
get: get:
tags: [feed] tags: [feed]
@@ -1812,8 +1985,9 @@ components:
value: { code: 40402, message: 记录不存在, data: null } value: { code: 40402, message: 记录不存在, data: null }
PetWriteDenied: PetWriteDenied:
description: | description: |
对可见宠物无相应操作权限(code 40300):viewer 写记录、caregiver/viewer 对可见宠物无相应操作权限(code 40300):viewer 写记录或改头像、caregiver/viewer
宠物档案。仅发给对宠物「可见」的调用者,不泄露新信息 宠物档案的资料字段(caregiver 仅改 `avatarAssetId` 时允许,M3.5 按字段分档)
仅发给对宠物「可见」的调用者,不泄露新信息。
content: content:
application/json: application/json:
schema: schema:
@@ -1854,14 +2028,16 @@ components:
commentNotFound: commentNotFound:
value: { code: 40404, message: 评论不存在, data: null } value: { code: 40404, message: 评论不存在, data: null }
MediaNotFound: MediaNotFound:
description: asset 不存在、非本人所有或已删(code 40405,防枚举合并) description: |
asset 不存在、非本人所有、已删,或**用途与引用场景不符**(code 40405,防枚举合并)。
用途不符即「从头像域看,一张帖子配图不是头像」;两种头像用途亦互不通用。
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorEnvelope' $ref: '#/components/schemas/ErrorEnvelope'
examples: examples:
mediaNotFound: mediaNotFound:
value: { code: 40405, message: 媒体不存在, data: null } value: { code: 40405, message: 媒体资源不存在, data: null }
UserNotFound: UserNotFound:
description: 目标用户不存在或已注销(code 40406,合并不泄露成因) description: 目标用户不存在或已注销(code 40406,合并不泄露成因)
content: content:
@@ -2001,8 +2177,10 @@ components:
Me: Me:
type: object type: object
description: 当前用户资料(冻结契约,恰好这 4 个字段) description: |
required: [userId, username, createdAt] 本人资料(`GET` 与 `PATCH /api/v1/me` 的统一响应形态,冻结契约恰好这 6 个字段)。
**不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 `avatarUrl != null`。
required: [userId, username, nickname, avatarUrl, createdAt]
properties: properties:
userId: userId:
type: string type: string
@@ -2011,16 +2189,65 @@ components:
username: username:
type: string type: string
example: demo_user example: demo_user
nickname:
type: string
nullable: true
minLength: 1
maxLength: 32
description: |
昵称,**DB 原值**;未设置为 null(键恒在)。长度按**码点**计 1~32(btrim 后)。
**本端点不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定固化成
真实数据;本人视角的展示回退由客户端做 `nickname ?? username`,他人视角的
回退在 `/internal/users/profiles`SQL COALESCE,不属本公开契约)。
不设唯一约束(ADR-022,允许重名)。
example: 小柴
phone: phone:
type: string type: string
nullable: true nullable: true
description: E.164;未绑定时为 null description: E.164;未绑定时为 null
example: '+8613800138000' example: '+8613800138000'
avatarUrl:
type: string
nullable: true
description: |
头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签,
**客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。
无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。
example: https://minio.example.com/patbond-media/user_avatar/2026/09/019212aa…?X-Amz-Signature=…
createdAt: createdAt:
type: string type: string
format: date-time format: date-time
example: '2026-09-04T04:05:06.789Z' example: '2026-09-04T04:05:06.789Z'
UpdateMeRequest:
type: object
description: |
本人资料部分更新(**三态语义**,与 pets 域 M2 的两态刻意不同):
**键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。
两字段都未出现(含只带未声明字段)为**空 patch**,答 400/40000 而非静默 200。
无必填字段、无 `version` 乐观锁、无 `Idempotency-Key`(同 body 重放天然幂等)。
properties:
nickname:
type: string
nullable: true
minLength: 1
maxLength: 32
description: |
昵称;btrim 后长度按**码点**计须在 1~32,否则 400/40000。
显式 `null` = **清空昵称**;纯空白或空串是 400/40000,**不是隐式清空**
(清空只留显式 null 一条路,否则「误提交空格」与「想删昵称」无法区分)。
example: 小柴
avatarAssetId:
type: string
format: uuid
nullable: true
description: |
头像 asset ID(两步上传的产物,`purpose` 须为 `user_avatar`)。
显式 `null` = **清除头像**。校验:不存在/非本人/已删/用途不符 404/40405
本人且用途相符但 uploading/failed 422/42203;非法 UUID 400/40000。
**响应不回显该字段**(只写不读)。
example: 019212bb-0000-7000-8000-000000000009
AuthTokenEnvelope: AuthTokenEnvelope:
type: object type: object
required: [code, message] required: [code, message]
@@ -2226,7 +2453,8 @@ components:
`breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed); `breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed);
`breedDisplayName` 由品种字典解出,随 breedId 存在。 `breedDisplayName` 由品种字典解出,随 breedId 存在。
软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的 软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的
status 永不出现 deleted。`avatarAssetId` 不出现在 M2 契约(ADR-010)。 status 永不出现 deleted。头像以 `avatarUrl` 的现签形式返回,
**`avatarAssetId` 不外露**(只写不读,写入口为 `PATCH /api/v1/pets/{petId}`)。
required: required:
- id - id
- name - name
@@ -2234,6 +2462,7 @@ components:
- sex - sex
- birthDateEstimated - birthDateEstimated
- status - status
- avatarUrl
- myRole - myRole
- createdAt - createdAt
- updatedAt - updatedAt
@@ -2294,6 +2523,15 @@ components:
type: string type: string
enum: [active, lost, deceased, archived] enum: [active, lost, deceased, archived]
description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401) description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401)
avatarUrl:
type: string
nullable: true
description: |
宠物头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签,
**客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。
无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。
asset 事后退出 ready 时转 null 而引用保留(读请求不做写副作用)。
example: https://minio.example.com/patbond-media/pet_avatar/2026/09/019212cc…?X-Amz-Signature=…
myRole: myRole:
type: string type: string
enum: [owner, caregiver, viewer] enum: [owner, caregiver, viewer]
@@ -2351,14 +2589,18 @@ components:
UpdatePetRequest: UpdatePetRequest:
type: object type: object
description: | description: |
部分更新:缺席字段不变;不支持清空回 null。例外:品种对 部分更新:缺席字段不变;资料字段不支持清空回 null。例外:品种对
breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。 breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。
species 不可改(不在请求体)。 species 不可改(不在请求体)。
**`avatarAssetId` 是本 schema 唯一的三态字段**(M3.5):键缺省 = 不改;
键出现且为 `null` = 清除头像;键出现且有值 = 设置。其余字段保持 M2 两态语义。
权限档按本次触及的字段决定(仅头像 → WRITE;触及资料字段 → MANAGE;混合取更严),
见端点描述。
required: [version] required: [version]
properties: properties:
version: version:
type: integer type: integer
description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902 description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902;即便只改头像也必带
name: name:
type: string type: string
minLength: 1 minLength: 1
@@ -2393,6 +2635,16 @@ components:
type: string type: string
enum: [active, lost, deceased, archived] enum: [active, lost, deceased, archived]
description: 状态流转;deleted 不可经 PATCH 设置(400/40000 description: 状态流转;deleted 不可经 PATCH 设置(400/40000
avatarAssetId:
type: string
format: uuid
nullable: true
description: |
宠物头像 asset ID(两步上传的产物,`purpose` 须为 `pet_avatar`)。
**三态**:缺省 = 不改;显式 `null` = 清除头像;给值 = 设置。校验:不存在/
非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203
非法 UUID 400/40000。**响应不回显该字段**(只写不读,读取见 `Pet.avatarUrl`)。
example: 019212cc-0000-7000-8000-00000000000a
PetEnvelope: PetEnvelope:
type: object type: object
@@ -3205,10 +3457,13 @@ components:
description: M3 仅 imageADR-018 视频后置;video/document 为向后新增枚举预留) description: M3 仅 imageADR-018 视频后置;video/document 为向后新增枚举预留)
purpose: purpose:
type: string type: string
enum: [post_image] enum: [post_image, user_avatar, pet_avatar]
description: | description: |
用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩 用途白名单(服务端配置项 `patbond.media.allowed-purposes`,决定 objectKey 前缀
user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展) `<purpose>/yyyy/MM/{assetId}`)。M3.5 起为三值:`post_image`(帖子配图)、
`user_avatar`(用户头像)、`pet_avatar`(宠物头像)——相对 M3 的纯枚举追加。
**用途即引用侧的类型检查**:引用时校验 `purpose` 相符,故帖图不能当头像、
两种头像也互不通用(不符者 404/40405)。白名单外的取值 400/40000。
mimeType: mimeType:
type: string type: string
enum: [image/jpeg, image/png, image/webp] enum: [image/jpeg, image/png, image/webp]
@@ -3857,3 +4112,36 @@ components:
example: success example: success
data: data:
$ref: '#/components/schemas/FollowStats' $ref: '#/components/schemas/FollowStats'
CommunityStats:
type: object
description: |
调用者本人的社区数字(M3.5)。两数同一集合:本人的、`status='published'` 的、
未软删的帖(草稿 / 软删 / hidden / archived 均不计)。读侧实时聚合,无冗余计数列。
required: [receivedLikeCount, publishedPostCount]
properties:
receivedLikeCount:
type: integer
format: int64
description: |
获赞总数 = 该集合的 `like_count` 之和(写侧同事务维护的帖级冗余列,精确值)。
**自己赞自己计入**,与帖子详情的 `likeCount` 同一口径。空数据为 0,非 null。
example: 128
publishedPostCount:
type: integer
format: int64
description: 作品数 = 该集合的帖子数。空数据为 0,非 null。
example: 12
CommunityStatsEnvelope:
type: object
required: [code, message, data]
properties:
code:
type: integer
enum: [0]
message:
type: string
example: success
data:
$ref: '#/components/schemas/CommunityStats'
@@ -28,20 +28,21 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
/** /**
* T3-20M3 第二波收尾):community 域 17 个操作补进契约一致性保障,机制与 * T3-20M3 第二波收尾):community 域 18 个操作补进契约一致性保障,机制与
* patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.3.0(快照 * patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.4.0(快照
* {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓 * {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在 doc 仓
* {@code docs/api/openapi.yaml})逐操作真实起服务发请求,用 * {@code docs/api/openapi.yaml})逐操作真实起服务发请求,用
* {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、字段名 * {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、字段名
* 与类型、必填与 nullable、枚举与格式、信封结构、错误码值。 * 与类型、必填与 nullable、枚举与格式、信封结构、错误码值。
* *
* <p>覆盖目标是**全响应矩阵**:最后的 {@link #everyDeclaredResponseCellIsExercised()} * <p>覆盖目标是**全响应矩阵**:最后的 {@link #everyDeclaredResponseCellIsExercised()}
* 断言契约为这 17 个操作声明的每一个 (操作, 状态码) 单元格(共 64 格)都被 * 断言契约为这 18 个操作声明的每一个 (操作, 状态码) 单元格(共 66 格)都被
* 至少一次真实响应校验过,**无豁免**——community 域的 409 均为幂等键/乐观锁 * 至少一次真实响应校验过,**无豁免**——community 域的 409 均为幂等键/乐观锁
* 冲突、422 均为业务规则拒绝,单线程即可确定性触发。 * 冲突、422 均为业务规则拒绝,单线程即可确定性触发。
* *
* <p>media 域 2 个操作属 patbond-user 模块,由该模块的 * <p>media 域 2 个操作属 patbond-user 模块,由该模块的
* MediaContractConformanceTest 覆盖(快照同一份)。 * MediaContractConformanceTest 覆盖auth/user 域 7 操作在 patbond-auth
* (含 v1.4.0 新增的 PATCH /api/v1/me)。快照四模块同一份。
*/ */
@TestMethodOrder(MethodOrderer.OrderAnnotation.class) @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class CommunityContractConformanceTest extends PostApiTestBase { class CommunityContractConformanceTest extends PostApiTestBase {
@@ -52,7 +53,7 @@ class CommunityContractConformanceTest extends PostApiTestBase {
/** 已被真实响应校验过的 (操作, 状态码) 单元格,如 "GET /api/v1/feed 200"。 */ /** 已被真实响应校验过的 (操作, 状态码) 单元格,如 "GET /api/v1/feed 200"。 */
private static final Set<String> COVERED = ConcurrentHashMap.newKeySet(); private static final Set<String> COVERED = ConcurrentHashMap.newKeySet();
/** community 域 17 个操作(= 契约中 tags ∈ {posts, feed, comments, interactions, follows})。 */ /** community 域 18 个操作(= 契约中 tags ∈ {posts, feed, comments, interactions, follows})。 */
private static final List<String> COMMUNITY_OPERATIONS = List.of( private static final List<String> COMMUNITY_OPERATIONS = List.of(
"POST /api/v1/posts", "POST /api/v1/posts",
"GET /api/v1/posts/{postId}", "GET /api/v1/posts/{postId}",
@@ -70,7 +71,8 @@ class CommunityContractConformanceTest extends PostApiTestBase {
"GET /api/v1/me/bookmarks", "GET /api/v1/me/bookmarks",
"PUT /api/v1/users/{userId}/follow", "PUT /api/v1/users/{userId}/follow",
"DELETE /api/v1/users/{userId}/follow", "DELETE /api/v1/users/{userId}/follow",
"GET /api/v1/users/{userId}/follow-stats"); "GET /api/v1/users/{userId}/follow-stats",
"GET /api/v1/me/community-stats");
private static final String IDEMPOTENCY_KEY = "Idempotency-Key"; private static final String IDEMPOTENCY_KEY = "Idempotency-Key";
@@ -127,7 +129,7 @@ class CommunityContractConformanceTest extends PostApiTestBase {
return JsonPath.read(created, "$.data.id"); return JsonPath.read(created, "$.data.id");
} }
// ---- 成功路径:17 操作全覆盖 --------------------------------------- // ---- 成功路径:18 操作全覆盖 ---------------------------------------
@Test @Test
@Order(1) @Order(1)
@@ -299,6 +301,43 @@ class CommunityContractConformanceTest extends PostApiTestBase {
assertThat((Boolean) JsonPath.read(unfollowedAgain, "$.data.following")).isFalse(); assertThat((Boolean) JsonPath.read(unfollowedAgain, "$.data.following")).isFalse();
} }
/**
* `GET /api/v1/me/community-stats`v1.4.0 新增操作,T3.5-07):空数据零值
* 与有数据两个分支都过严格校验。**永不 404**,故本操作只有 200/401 两格
* 401 由 {@link #unauthenticatedRequestsAnswer40101OnAllOperations()} 的
* 全操作循环覆盖)。
*/
@Test
@Order(51)
void myCommunityStatsSuccessShapes() throws Exception {
UUID fresh = newUser();
// 空数据:两数为 0 而非 null,且不是 404
String zeros = verified(get("/api/v1/me/community-stats")
.header("Authorization", "Bearer " + token(fresh)),
"GET", "/api/v1/me/community-stats", 200);
assertThat(((Number) JsonPath.read(zeros, "$.data.receivedLikeCount")).longValue())
.isZero();
assertThat(((Number) JsonPath.read(zeros, "$.data.publishedPostCount")).longValue())
.isZero();
// 有数据:两篇已发布帖,其中一篇被他人赞一次 → 1 赞 / 2 作品
UUID author = newUser();
UUID fan = newUser();
String postA = newPublishedPost(author, "契约统计帖甲");
newPublishedPost(author, "契约统计帖乙");
verified(authed(put("/api/v1/posts/{postId}/like", postA), fan),
"PUT", "/api/v1/posts/{postId}/like", 200);
String counted = verified(get("/api/v1/me/community-stats")
.header("Authorization", "Bearer " + token(author)),
"GET", "/api/v1/me/community-stats", 200);
assertThat(((Number) JsonPath.read(counted, "$.data.receivedLikeCount")).longValue())
.isEqualTo(1L);
assertThat(((Number) JsonPath.read(counted, "$.data.publishedPostCount")).longValue())
.isEqualTo(2L);
}
// ---- 错误信封 ------------------------------------------------------ // ---- 错误信封 ------------------------------------------------------
@Test @Test
@@ -482,18 +521,18 @@ class CommunityContractConformanceTest extends PostApiTestBase {
@Test @Test
@Order(98) @Order(98)
void frozenSnapshotIsTheExpectedContractVersion() { void frozenSnapshotIsTheExpectedContractVersion() {
assertThat(CONTRACT.version()).isEqualTo("1.3.0"); assertThat(CONTRACT.version()).isEqualTo("1.4.0");
assertThat(CONTRACT.paths()).hasSize(31); assertThat(CONTRACT.paths()).hasSize(32);
assertThat(CONTRACT.operations()).hasSize(43); assertThat(CONTRACT.operations()).hasSize(45);
assertThat(CONTRACT.schemas()).hasSize(72); assertThat(CONTRACT.schemas()).hasSize(75);
assertThat(CONTRACT.operationsTagged( assertThat(CONTRACT.operationsTagged(
Set.of("posts", "feed", "comments", "interactions", "follows"))) Set.of("posts", "feed", "comments", "interactions", "follows")))
.containsExactlyInAnyOrderElementsOf(COMMUNITY_OPERATIONS); .containsExactlyInAnyOrderElementsOf(COMMUNITY_OPERATIONS);
} }
/** /**
* 全矩阵覆盖门禁:community 域 17 个操作声明的每个 (操作, 状态码) 都必须被 * 全矩阵覆盖门禁:community 域 18 个操作声明的每个 (操作, 状态码) 都必须被
* 前面的测试真实触发并通过契约校验(64 个单元格,无豁免)。 * 前面的测试真实触发并通过契约校验(66 个单元格,无豁免)。
*/ */
@Test @Test
@Order(99) @Order(99)
@@ -13,8 +13,8 @@ import java.util.Objects;
import java.util.Set; import java.util.Set;
/** /**
* The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot * The frozen v1.4.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.3.0.yaml}. * {@code /contract/openapi-v1.4.0.yaml}.
* *
* <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical * <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical
* contract lives in the doc repo at {@code docs/api/openapi.yaml}; this * contract lives in the doc repo at {@code docs/api/openapi.yaml}; this
@@ -36,7 +36,7 @@ import java.util.Set;
*/ */
final class OpenApiContract { final class OpenApiContract {
static final String RESOURCE = "/contract/openapi-v1.3.0.yaml"; static final String RESOURCE = "/contract/openapi-v1.4.0.yaml";
private static final Set<String> HTTP_METHODS = private static final Set<String> HTTP_METHODS =
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace"); Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
@@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约) title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约)
version: 1.3.0 version: 1.4.0
description: | description: |
Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差), Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差),
1.1.0 追加埋点上报端点 `POST /api/v1/events`M2 第一波契约补录,以实现实测行为为准)。 1.1.0 追加埋点上报端点 `POST /api/v1/events`M2 第一波契约补录,以实现实测行为为准)。
@@ -11,6 +11,14 @@ info:
**1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、 **1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、
公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入 公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入
iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。 iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。
**1.4.0 M3.5 契约冻结:用户资料与头像**——`GET /api/v1/me` 补 `nickname`/`avatarUrl`
新增 `PATCH /api/v1/me`(昵称与头像读写,三态部分更新),`Pet` 补 `avatarUrl` 且
`PATCH /api/v1/pets/{petId}` 收 `avatarAssetId`,新增 `GET /api/v1/me/community-stats`
(获赞总数与作品数),媒体 `purpose` 白名单追加 `user_avatar`/`pet_avatar`
iteration-3.5 报告 03 定型表;冻结报告见 iteration-3.5/04)。
**1.4.0 相对 1.3.0 纯增量**:无字段删改、无类型变更、无必填收紧,仅新增操作、
新增响应字段(键恒在、值可空)、新增可选请求字段、新增响应格与枚举追加,
v1.3.0 客户端无需改动即可继续工作。
## 通用约定(development-plan 第 6 节) ## 通用约定(development-plan 第 6 节)
- 公开接口统一前缀 `/api/v1`JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 - 公开接口统一前缀 `/api/v1`JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。
@@ -29,14 +37,14 @@ info:
| 40100 | 401 | 用户名或密码错误 | | 40100 | 401 | 用户名或密码错误 |
| 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) | | 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) |
| 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) | | 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) |
| 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录、caregiver 改宠物档案) | | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录或改头像、caregiver 改宠物档案——头像除外,见 M3.5 分档 |
| 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 | | 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 |
| 40400 | 404 | 资源不存在 | | 40400 | 404 | 资源不存在 |
| 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) | | 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) |
| 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) | | 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) |
| 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 | | 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 |
| 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) | | 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) |
| 40405 | 404 | MEDIA_NOT_FOUNDasset 不存在、非本人所有已删(防枚举合并 | | 40405 | 404 | MEDIA_NOT_FOUNDasset 不存在、非本人所有已删,或**用途与引用场景不符**(帖图当头像、user_avatar 当宠物头像等);防枚举合并,同码各情形响应一致(用途不符分支只对调用者自己的 asset 可达,故 message 可具体 |
| 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) | | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) |
| 40900 | 409 | 用户名已存在(大小写不敏感) | | 40900 | 409 | 用户名已存在(大小写不敏感) |
| 40901 | 409 | 手机号已被使用 | | 40901 | 409 | 手机号已被使用 |
@@ -46,7 +54,7 @@ info:
| 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) | | 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) |
| 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 | | 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 |
| 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 | | 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 |
| 42203 | 422 | MEDIA_NOT_READY:引用了本人所有但非 readyuploading/failed)状态的 asset | | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有、用途相符但非 readyuploading/failed)状态的 asset(帖图与用户/宠物头像同构) |
| 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op | | 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op |
| 42205 | 422 | MEDIA_UPLOAD_STATE_INVALIDcomplete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 | | 42205 | 422 | MEDIA_UPLOAD_STATE_INVALIDcomplete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 |
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) | | 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
@@ -68,7 +76,10 @@ info:
- **权限模型(ADR-015owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档: - **权限模型(ADR-015owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档:
- `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要; - `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要;
- `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH - `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH
- `MANAGE`——仅 owner:宠物档案 PATCH(含状态流转)。 **M3.5 起还含宠物头像字段 `avatarAssetId`**ADR-022:头像属日常照护信息)。
- `MANAGE`——仅 owner:宠物档案 PATCH 的资料字段(含状态流转)。
**同一端点按「本次请求触及哪些字段」定档**(不是端点级降档):仅改头像走 WRITE
触及任一资料字段走 MANAGE,混合请求取更严的一半。
权限每请求实时查库、无缓存:撤销照护关系立即生效。 权限每请求实时查库、无缓存:撤销照护关系立即生效。
- **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况 - **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况
响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与 响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与
@@ -126,6 +137,41 @@ info:
整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口 整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口
(如作者公开资料批量接口)不属于本公开契约。 (如作者公开资料批量接口)不属于本公开契约。
## 用户资料与头像域约定(M3.5 冻结,iteration-3.5 报告 03 定型;ADR-022
- **三态部分更新(仅本域,pets 域 M2 两态语义不回改)**`PATCH /api/v1/me` 的
`nickname`/`avatarAssetId` 与 `PATCH /api/v1/pets/{petId}` 的 `avatarAssetId`
按三态解释——**键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。
昵称与头像天生可选,「删掉我设的那个」是一等公民操作,只有两态无法表达。
同一请求体内的其余 pets 字段仍是 M2 的「缺省或 null 皆为不改」。
- **空 PATCH 与纯空白昵称一律 400/40000**,不静默 200、不隐式清空:清空只留
显式 `null` 一条路,否则「误提交空格」与「想删昵称」无法区分。
- **昵称**btrim 后 1~32 **码点**(非 UTF-16 长度);**不设唯一约束**ADR-022
允许重名,靠 userId 区分);注册不收昵称。`/api/v1/me` 返回 **DB 原值**
未设置即 `null`**不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定
固化成真实数据;他人视角的展示回退在 `/internal/users/profiles`SQL COALESCE),
本人视角的展示回退由客户端做 `nickname ?? username`。
- **头像读取一律 `avatarUrl`(时效性预签名 GET,与帖图同一纪律)**:每次响应现签,
**会过期、客户端不得持久化**,过期即重取;无头像、asset 非 ready、对象存储未配置
三种情况均为 `null`(降级而非报错——签一个必然 404 的 URL 比给 null 更糟)。
指针不隐式清理:asset 事后退出 ready 时 `avatarUrl` 转 null 而引用保留。
- **`avatarAssetId` 只写不读**:请求体收,响应**一律不外露**(`Me` 与 `Pet` 皆无该字段);
「是否有头像」等价于 `avatarUrl != null`。
- **两种头像用途互不通用**`user_avatar` 不能当宠物头像、`pet_avatar` 不能当用户头像、
`post_image` 不能当任何头像——引用侧按 `purpose` 校验,不符者 404/40405
(四态校验:不存在/非本人/已删/用途不符 → 404/40405;本人且用途相符但
uploading/failed → 422/42203)。
- **宠物头像的权限档按「本次请求碰了哪些字段」定档**(ADR-022 头像为 WRITE 档):
仅改 `avatarAssetId` 时 owner + caregiver 皆可(viewer 403/40300);触及任一资料
字段时仍是 MANAGE(仅 owner);**混合请求取更严的一半**,堵住把改名夹带进头像
请求绕过 MANAGE 的路径。头像与资料共用同一把乐观锁 `version`(仍必填)。
- **`/api/v1/me` 无乐观锁、无幂等键**:只有一个合法写者(账号本人),暴露 `version`
只是给客户端加负担;丢失更新由**列级选择性 UPDATE** 排除(SET 列表只含本次请求
真正携带的列),并发改不同字段两者皆存活;同 body 重放天然幂等。
- **`GET /api/v1/me/community-stats` 为独立端点**ADR-022 决策 A,不并入
`/users/{userId}/follow-stats`——后者主体是「某用户的关注数」,混入「我的获赞」
会让一个载荷有两个主体)。路径上**没有 userId**:「查不到别人的获赞」不靠权限
判断,而是入口本身不存在,故**永不 404**,任何已认证用户都有 stats。
servers: servers:
- url: http://127.0.0.1:8081 - url: http://127.0.0.1:8081
description: patbond-auth(本地开发,/api/v1/auth/** description: patbond-auth(本地开发,/api/v1/auth/**
@@ -140,7 +186,7 @@ tags:
- name: auth - name: auth
description: 注册 / 登录 / 刷新 / 退出(patbond-auth description: 注册 / 登录 / 刷新 / 退出(patbond-auth
- name: user - name: user
description: 当前用户(patbond-user description: 当前用户资料:读取与昵称/头像更新patbond-user
- name: analytics - name: analytics
description: 产品事件批量上报(patbond-user description: 产品事件批量上报(patbond-user
- name: pets - name: pets
@@ -152,7 +198,9 @@ tags:
- name: media - name: media
description: 媒体上传两步流程(patbond-userADR-016 预签名直传) description: 媒体上传两步流程(patbond-userADR-016 预签名直传)
- name: posts - name: posts
description: 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子(patbond-community description: |
帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子;含由帖子派生的「我的社区数字」
聚合(patbond-community
- name: feed - name: feed
description: 公共 Feed 游标分页(patbond-community description: 公共 Feed 游标分页(patbond-community
- name: comments - name: comments
@@ -301,7 +349,15 @@ paths:
get: get:
tags: [user] tags: [user]
summary: 当前用户资料 summary: 当前用户资料
description: 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 description: |
由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。
- `nickname` 为 **DB 原值**,未设置即 `null`**不做 username 回退**,见 info
「用户资料与头像域约定」);本人视角的展示回退由客户端做 `nickname ?? username`。
- `avatarUrl` 为**每次响应现签的时效性预签名 GET**:**会过期、客户端不得持久化**,
过期即重取;无头像、asset 非 ready、对象存储未配置均为 `null`。
- 响应**不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于
`avatarUrl != null`。
operationId: me operationId: me
security: security:
- bearerAuth: [] - bearerAuth: []
@@ -320,6 +376,66 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorEnvelope' $ref: '#/components/schemas/ErrorEnvelope'
patch:
tags: [user]
summary: 更新当前用户资料(昵称 / 头像,三态部分更新)
description: |
本人资料的唯一写入口(主体恒为 token 里的调用者,无「他人」情形)。
- **三态语义**:键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置。
- **空 patch 400/40000**(两字段都未出现,含只带未声明字段):不静默 200,
空 PATCH 几乎总是客户端 bug。纯空白/空串昵称同为 400/40000,不隐式清空。
- `avatarAssetId` 须为**调用者本人、用途为 `user_avatar`、状态 `ready`** 的 asset
不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed
422/42203。
- **无 `version` 乐观锁、无 `Idempotency-Key`**:只有一个合法写者;丢失更新由
列级选择性 UPDATE 排除(并发改不同字段两者皆存活),同 body 重放天然幂等。
- 成功返回**与 GET 完全相同的 `Me` 全量形态**(回显更新后资料,`avatarUrl` 现签)。
operationId: updateMe
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateMeRequest'
responses:
'200':
description: 更新成功,返回更新后的完整 Me
content:
application/json:
schema:
$ref: '#/components/schemas/MeEnvelope'
'400':
description: |
参数校验失败(code 40000):空 patch、昵称 btrim 后长度不在 1~32 码点、
昵称纯空白或空串、`avatarAssetId` 非法 UUID、body 非法 JSON
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
emptyPatch:
value: { code: 40000, message: 请求未包含任何可更新字段, data: null }
'401':
$ref: '#/components/responses/AccessTokenInvalid'
'404':
description: |
用户不存在或已注销(code 40400,token 仍有效但账号已注销);或
`avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `user_avatar`
code 40405,防枚举合并)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
userNotFound:
value: { code: 40400, message: 用户不存在, data: null }
mediaNotFound:
value: { code: 40405, message: 媒体资源不存在, data: null }
'422':
$ref: '#/components/responses/MediaNotReady'
/api/v1/events: /api/v1/events:
post: post:
@@ -468,18 +584,32 @@ paths:
$ref: '#/components/responses/PetNotFound' $ref: '#/components/responses/PetNotFound'
patch: patch:
tags: [pets] tags: [pets]
summary: 更新宠物档案 summary: 更新宠物档案(含头像)
description: | description: |
权限档MANAGE**仅 owner**);caregiver/viewer 更新得 403/40300。 权限档**按本次请求触及的字段定档**(M3.5 起,ADR-022):
- 部分更新:缺席字段不变;**不支持将可选字段清空回 null**。 | 请求体触及 | 所需档位 | caregiver | viewer |
| --- | --- | --- | --- |
| 仅 `avatarAssetId`+ `version` | WRITE | ✅ | ✗ 403/40300 |
| 任一资料字段(name/sex/status/…) | MANAGE(仅 owner | ✗ 403/40300 | ✗ 403/40300 |
| 资料字段 + `avatarAssetId` 混合 | MANAGE**取更严的一半** | ✗ 403/40300 | ✗ 403/40300 |
混合请求取更严,是为了堵住「夹带」——否则 caregiver 可把改名塞进头像请求绕过 MANAGE。
- 部分更新:缺席字段不变;资料字段**不支持清空回 null**(M2 语义不回改)。
- **例外:`avatarAssetId` 是本端点唯一的三态字段**(M3.5)——键缺省 = 不改;
键出现且为 `null` = **清除头像**;键出现且有值 = 设置。差异刻意限定在有清空
需求的字段上。
- 例外:品种对(`breedId`/`customBreedName`**整体替换**——提交任一侧即替换 - 例外:品种对(`breedId`/`customBreedName`**整体替换**——提交任一侧即替换
整对,互斥校验同创建。 整对,互斥校验同创建。
- `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。 - `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。
- `status` 可迁移至 active/lost/deceased/archived**`deleted` 不可经 PATCH - `status` 可迁移至 active/lost/deceased/archived**`deleted` 不可经 PATCH
设置**(400/40000,软删除留待专用端点,M2 契约不含)。 设置**(400/40000,软删除留待专用端点,M2 契约不含)。
- `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 - `version` **必填**(缺失 400/40000),即便只改头像;比对通过才写入并 +1
过期 409/40902。头像与资料共用同一把乐观锁——头像变更也应让并发编辑者感知行已变。
- 芯片号改为已被登记的值:409/40903。 - 芯片号改为已被登记的值:409/40903。
- `avatarAssetId` 须为**调用者本人、用途为 `pet_avatar`、状态 `ready`** 的 asset
不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203。
operationId: updatePet operationId: updatePet
security: security:
- bearerAuth: [] - bearerAuth: []
@@ -505,7 +635,19 @@ paths:
'403': '403':
$ref: '#/components/responses/PetWriteDenied' $ref: '#/components/responses/PetWriteDenied'
'404': '404':
$ref: '#/components/responses/PetNotFound' description: |
宠物不存在、已软删除或调用者与宠物无关系(code 40401,防枚举合并);或
`avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `pet_avatar`
code 40405,防枚举合并)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
petNotFound:
value: { code: 40401, message: 宠物不存在, data: null }
mediaNotFound:
value: { code: 40405, message: 媒体资源不存在, data: null }
'409': '409':
description: 版本冲突(code 40902)或芯片号已被登记(code 40903) description: 版本冲突(code 40902)或芯片号已被登记(code 40903)
content: content:
@@ -517,6 +659,8 @@ paths:
value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null }
microchipExists: microchipExists:
value: { code: 40903, message: 芯片号已被登记, data: null } value: { code: 40903, message: 芯片号已被登记, data: null }
'422':
$ref: '#/components/responses/MediaNotReady'
/api/v1/breeds: /api/v1/breeds:
get: get:
@@ -1201,7 +1345,7 @@ paths:
petNotFound: petNotFound:
value: { code: 40401, message: 宠物不存在, data: null } value: { code: 40401, message: 宠物不存在, data: null }
mediaNotFound: mediaNotFound:
value: { code: 40405, message: 媒体不存在, data: null } value: { code: 40405, message: 媒体资源不存在, data: null }
'409': '409':
$ref: '#/components/responses/IdempotencyPayloadMismatch' $ref: '#/components/responses/IdempotencyPayloadMismatch'
'422': '422':
@@ -1284,7 +1428,7 @@ paths:
petNotFound: petNotFound:
value: { code: 40401, message: 宠物不存在, data: null } value: { code: 40401, message: 宠物不存在, data: null }
mediaNotFound: mediaNotFound:
value: { code: 40405, message: 媒体不存在, data: null } value: { code: 40405, message: 媒体资源不存在, data: null }
'409': '409':
$ref: '#/components/responses/VersionConflict' $ref: '#/components/responses/VersionConflict'
'422': '422':
@@ -1348,6 +1492,35 @@ paths:
'401': '401':
$ref: '#/components/responses/AccessTokenInvalid' $ref: '#/components/responses/AccessTokenInvalid'
/api/v1/me/community-stats:
get:
tags: [posts]
summary: 我的社区数字(获赞总数 / 作品数)
description: |
主体恒为 token 里的调用者:无查询参数、无路径参数,**路径上没有 userId**——
「查不到别人的获赞」不靠权限判断,而是入口本身不存在。
- **统计集合 = 本人的、`status='published'` 的、`deleted_at IS NULL` 的帖**。
草稿不计(尚非作品,且未发布不可被赞);软删不计(删帖即撤回其数字,与
`/me/posts`、Feed 的可见性一致);运营态 hidden/archived 不计(对所有人不可见,
含作者本人);他人帖自然不计。
- **自己赞自己计入**——与帖子详情页的 `likeCount` 保持同一口径,两处数字必须能对上。
- `receivedLikeCount` = 该集合的 `like_count` 之和(读侧实时聚合,读的是写侧同事务
维护的帖级冗余列,故为精确值而非估算;ADR-022 不引入按人累计的冗余列)。
- **空数据返回 `0` 而非 null**,且**永不 404**:任何已认证用户都有 stats。
operationId: getMyCommunityStats
security:
- bearerAuth: []
responses:
'200':
description: 我的获赞总数与作品数
content:
application/json:
schema:
$ref: '#/components/schemas/CommunityStatsEnvelope'
'401':
$ref: '#/components/responses/AccessTokenInvalid'
/api/v1/feed: /api/v1/feed:
get: get:
tags: [feed] tags: [feed]
@@ -1812,8 +1985,9 @@ components:
value: { code: 40402, message: 记录不存在, data: null } value: { code: 40402, message: 记录不存在, data: null }
PetWriteDenied: PetWriteDenied:
description: | description: |
对可见宠物无相应操作权限(code 40300):viewer 写记录、caregiver/viewer 对可见宠物无相应操作权限(code 40300):viewer 写记录或改头像、caregiver/viewer
宠物档案。仅发给对宠物「可见」的调用者,不泄露新信息 宠物档案的资料字段(caregiver 仅改 `avatarAssetId` 时允许,M3.5 按字段分档)
仅发给对宠物「可见」的调用者,不泄露新信息。
content: content:
application/json: application/json:
schema: schema:
@@ -1854,14 +2028,16 @@ components:
commentNotFound: commentNotFound:
value: { code: 40404, message: 评论不存在, data: null } value: { code: 40404, message: 评论不存在, data: null }
MediaNotFound: MediaNotFound:
description: asset 不存在、非本人所有或已删(code 40405,防枚举合并) description: |
asset 不存在、非本人所有、已删,或**用途与引用场景不符**(code 40405,防枚举合并)。
用途不符即「从头像域看,一张帖子配图不是头像」;两种头像用途亦互不通用。
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorEnvelope' $ref: '#/components/schemas/ErrorEnvelope'
examples: examples:
mediaNotFound: mediaNotFound:
value: { code: 40405, message: 媒体不存在, data: null } value: { code: 40405, message: 媒体资源不存在, data: null }
UserNotFound: UserNotFound:
description: 目标用户不存在或已注销(code 40406,合并不泄露成因) description: 目标用户不存在或已注销(code 40406,合并不泄露成因)
content: content:
@@ -2001,8 +2177,10 @@ components:
Me: Me:
type: object type: object
description: 当前用户资料(冻结契约,恰好这 4 个字段) description: |
required: [userId, username, createdAt] 本人资料(`GET` 与 `PATCH /api/v1/me` 的统一响应形态,冻结契约恰好这 6 个字段)。
**不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 `avatarUrl != null`。
required: [userId, username, nickname, avatarUrl, createdAt]
properties: properties:
userId: userId:
type: string type: string
@@ -2011,16 +2189,65 @@ components:
username: username:
type: string type: string
example: demo_user example: demo_user
nickname:
type: string
nullable: true
minLength: 1
maxLength: 32
description: |
昵称,**DB 原值**;未设置为 null(键恒在)。长度按**码点**计 1~32(btrim 后)。
**本端点不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定固化成
真实数据;本人视角的展示回退由客户端做 `nickname ?? username`,他人视角的
回退在 `/internal/users/profiles`SQL COALESCE,不属本公开契约)。
不设唯一约束(ADR-022,允许重名)。
example: 小柴
phone: phone:
type: string type: string
nullable: true nullable: true
description: E.164;未绑定时为 null description: E.164;未绑定时为 null
example: '+8613800138000' example: '+8613800138000'
avatarUrl:
type: string
nullable: true
description: |
头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签,
**客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。
无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。
example: https://minio.example.com/patbond-media/user_avatar/2026/09/019212aa…?X-Amz-Signature=…
createdAt: createdAt:
type: string type: string
format: date-time format: date-time
example: '2026-09-04T04:05:06.789Z' example: '2026-09-04T04:05:06.789Z'
UpdateMeRequest:
type: object
description: |
本人资料部分更新(**三态语义**,与 pets 域 M2 的两态刻意不同):
**键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。
两字段都未出现(含只带未声明字段)为**空 patch**,答 400/40000 而非静默 200。
无必填字段、无 `version` 乐观锁、无 `Idempotency-Key`(同 body 重放天然幂等)。
properties:
nickname:
type: string
nullable: true
minLength: 1
maxLength: 32
description: |
昵称;btrim 后长度按**码点**计须在 1~32,否则 400/40000。
显式 `null` = **清空昵称**;纯空白或空串是 400/40000,**不是隐式清空**
(清空只留显式 null 一条路,否则「误提交空格」与「想删昵称」无法区分)。
example: 小柴
avatarAssetId:
type: string
format: uuid
nullable: true
description: |
头像 asset ID(两步上传的产物,`purpose` 须为 `user_avatar`)。
显式 `null` = **清除头像**。校验:不存在/非本人/已删/用途不符 404/40405
本人且用途相符但 uploading/failed 422/42203;非法 UUID 400/40000。
**响应不回显该字段**(只写不读)。
example: 019212bb-0000-7000-8000-000000000009
AuthTokenEnvelope: AuthTokenEnvelope:
type: object type: object
required: [code, message] required: [code, message]
@@ -2226,7 +2453,8 @@ components:
`breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed); `breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed);
`breedDisplayName` 由品种字典解出,随 breedId 存在。 `breedDisplayName` 由品种字典解出,随 breedId 存在。
软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的 软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的
status 永不出现 deleted。`avatarAssetId` 不出现在 M2 契约(ADR-010)。 status 永不出现 deleted。头像以 `avatarUrl` 的现签形式返回,
**`avatarAssetId` 不外露**(只写不读,写入口为 `PATCH /api/v1/pets/{petId}`)。
required: required:
- id - id
- name - name
@@ -2234,6 +2462,7 @@ components:
- sex - sex
- birthDateEstimated - birthDateEstimated
- status - status
- avatarUrl
- myRole - myRole
- createdAt - createdAt
- updatedAt - updatedAt
@@ -2294,6 +2523,15 @@ components:
type: string type: string
enum: [active, lost, deceased, archived] enum: [active, lost, deceased, archived]
description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401) description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401)
avatarUrl:
type: string
nullable: true
description: |
宠物头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签,
**客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。
无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。
asset 事后退出 ready 时转 null 而引用保留(读请求不做写副作用)。
example: https://minio.example.com/patbond-media/pet_avatar/2026/09/019212cc…?X-Amz-Signature=…
myRole: myRole:
type: string type: string
enum: [owner, caregiver, viewer] enum: [owner, caregiver, viewer]
@@ -2351,14 +2589,18 @@ components:
UpdatePetRequest: UpdatePetRequest:
type: object type: object
description: | description: |
部分更新:缺席字段不变;不支持清空回 null。例外:品种对 部分更新:缺席字段不变;资料字段不支持清空回 null。例外:品种对
breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。 breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。
species 不可改(不在请求体)。 species 不可改(不在请求体)。
**`avatarAssetId` 是本 schema 唯一的三态字段**(M3.5):键缺省 = 不改;
键出现且为 `null` = 清除头像;键出现且有值 = 设置。其余字段保持 M2 两态语义。
权限档按本次触及的字段决定(仅头像 → WRITE;触及资料字段 → MANAGE;混合取更严),
见端点描述。
required: [version] required: [version]
properties: properties:
version: version:
type: integer type: integer
description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902 description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902;即便只改头像也必带
name: name:
type: string type: string
minLength: 1 minLength: 1
@@ -2393,6 +2635,16 @@ components:
type: string type: string
enum: [active, lost, deceased, archived] enum: [active, lost, deceased, archived]
description: 状态流转;deleted 不可经 PATCH 设置(400/40000 description: 状态流转;deleted 不可经 PATCH 设置(400/40000
avatarAssetId:
type: string
format: uuid
nullable: true
description: |
宠物头像 asset ID(两步上传的产物,`purpose` 须为 `pet_avatar`)。
**三态**:缺省 = 不改;显式 `null` = 清除头像;给值 = 设置。校验:不存在/
非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203
非法 UUID 400/40000。**响应不回显该字段**(只写不读,读取见 `Pet.avatarUrl`)。
example: 019212cc-0000-7000-8000-00000000000a
PetEnvelope: PetEnvelope:
type: object type: object
@@ -3205,10 +3457,13 @@ components:
description: M3 仅 imageADR-018 视频后置;video/document 为向后新增枚举预留) description: M3 仅 imageADR-018 视频后置;video/document 为向后新增枚举预留)
purpose: purpose:
type: string type: string
enum: [post_image] enum: [post_image, user_avatar, pet_avatar]
description: | description: |
用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩 用途白名单(服务端配置项 `patbond.media.allowed-purposes`,决定 objectKey 前缀
user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展) `<purpose>/yyyy/MM/{assetId}`)。M3.5 起为三值:`post_image`(帖子配图)、
`user_avatar`(用户头像)、`pet_avatar`(宠物头像)——相对 M3 的纯枚举追加。
**用途即引用侧的类型检查**:引用时校验 `purpose` 相符,故帖图不能当头像、
两种头像也互不通用(不符者 404/40405)。白名单外的取值 400/40000。
mimeType: mimeType:
type: string type: string
enum: [image/jpeg, image/png, image/webp] enum: [image/jpeg, image/png, image/webp]
@@ -3857,3 +4112,36 @@ components:
example: success example: success
data: data:
$ref: '#/components/schemas/FollowStats' $ref: '#/components/schemas/FollowStats'
CommunityStats:
type: object
description: |
调用者本人的社区数字(M3.5)。两数同一集合:本人的、`status='published'` 的、
未软删的帖(草稿 / 软删 / hidden / archived 均不计)。读侧实时聚合,无冗余计数列。
required: [receivedLikeCount, publishedPostCount]
properties:
receivedLikeCount:
type: integer
format: int64
description: |
获赞总数 = 该集合的 `like_count` 之和(写侧同事务维护的帖级冗余列,精确值)。
**自己赞自己计入**,与帖子详情的 `likeCount` 同一口径。空数据为 0,非 null。
example: 128
publishedPostCount:
type: integer
format: int64
description: 作品数 = 该集合的帖子数。空数据为 0,非 null。
example: 12
CommunityStatsEnvelope:
type: object
required: [code, message, data]
properties:
code:
type: integer
enum: [0]
message:
type: string
example: success
data:
$ref: '#/components/schemas/CommunityStats'
@@ -14,6 +14,7 @@ import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@@ -27,8 +28,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request;
/** /**
* T2-09 契约一致性保障:对冻结契约 v1.3.0(快照 * T2-09 契约一致性保障:对冻结契约 v1.4.0(快照
* {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓 * {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在 doc 仓
* {@code docs/api/openapi.yaml})的 pets 域 18 个操作逐一真实起服务发请求, * {@code docs/api/openapi.yaml})的 pets 域 18 个操作逐一真实起服务发请求,
* 用 {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、 * 用 {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、
* 字段名与类型、必填与 nullable、枚举与格式、信封结构、错误码值。 * 字段名与类型、必填与 nullable、枚举与格式、信封结构、错误码值。
@@ -38,8 +39,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
* 校验过(唯一豁免:照护提醒 PATCH 的 409——并发条件更新守卫落空,单线程 * 校验过(唯一豁免:照护提醒 PATCH 的 409——并发条件更新守卫落空,单线程
* MockMvc 无法确定性触发)。契约新增操作或状态码时,本测试立即变红。 * MockMvc 无法确定性触发)。契约新增操作或状态码时,本测试立即变红。
* *
* <p>auth 域 6既有操作(register/login/refresh/logout/me/trackEvents * <p>auth/user7 个操作(register/login/refresh/logout/me 读写/trackEvents
* 不在本单范围(M1 交付无契约测试,补齐另立工单) * 的契约测试在 patbond-authT3-19 补齐);快照同一份
*/ */
@TestMethodOrder(MethodOrderer.OrderAnnotation.class) @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ContractConformanceTest extends PetIntegrationTestSupport { class ContractConformanceTest extends PetIntegrationTestSupport {
@@ -122,6 +123,29 @@ class ContractConformanceTest extends PetIntegrationTestSupport {
return JsonPath.read(body, "$.data.id"); return JsonPath.read(body, "$.data.id");
} }
/**
* 一枚 media.assets 行,用途/状态/归属可控(T3.5-07:头像引用侧的四态校验
* 需要,造法与 PetAvatarIntegrationTest 同构——测试数据,不触碰实现)。
*/
private UUID insertAsset(UUID ownerUserId, String purpose, String status) {
UUID id = UUID.randomUUID();
jdbcClient.sql("""
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
mime_type, byte_size, status, ready_at)
VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media',
:objectKey, 'image/jpeg', 2048, :status, :readyAt)
""")
.param("id", id)
.param("owner", ownerUserId)
.param("purpose", purpose)
.param("objectKey", purpose + "/2026/09/" + id)
.param("status", status)
.param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
.update();
return id;
}
// ---- 成功路径:18 操作全覆盖 --------------------------------------- // ---- 成功路径:18 操作全覆盖 ---------------------------------------
@Test @Test
@@ -594,6 +618,33 @@ class ContractConformanceTest extends PetIntegrationTestSupport {
.content("{\"version\":0,\"personality\":\"\"}"), .content("{\"version\":0,\"personality\":\"\"}"),
"PATCH", "/api/v1/pets/{petId}", 409, 40902); "PATCH", "/api/v1/pets/{petId}", 409, 40902);
// -- 宠物头像(v1.4.0 新增两格,T3.5-07--
// 404/40405:与 40401 同一单元格的第二种业务码——幽灵 asset 与用途不符
// 的 asset 合并同答(防枚举)
verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":1,\"avatarAssetId\":\"%s\"}"
.formatted(UUID.randomUUID())),
"PATCH", "/api/v1/pets/{petId}", 404, 40405);
verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":1,\"avatarAssetId\":\"%s\"}"
.formatted(insertAsset(owner, "post_image", "ready"))),
"PATCH", "/api/v1/pets/{petId}", 404, 40405);
// 422/42203:本人的 pet_avatar asset 仍在 uploading(新增状态码单元格)
verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":1,\"avatarAssetId\":\"%s\"}"
.formatted(insertAsset(owner, "pet_avatar", "uploading"))),
"PATCH", "/api/v1/pets/{petId}", 422, 42203);
// 200:挂上 ready 头像(avatarUrl 的非 null 分支不在本模块——未配置对象
// 存储时恒 null,真实签名 URL 由 PetAvatarIntegrationTest 覆盖)
verified(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":1,\"avatarAssetId\":\"%s\"}"
.formatted(insertAsset(owner, "pet_avatar", "ready"))),
"PATCH", "/api/v1/pets/{petId}", 200);
// -- 疫苗:40904 重复剂次、42201 状态-日期规则、PATCH 400/409/422 -- // -- 疫苗:40904 重复剂次、42201 状态-日期规则、PATCH 400/409/422 --
verified(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(owner)) verified(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(owner))
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -703,10 +754,10 @@ class ContractConformanceTest extends PetIntegrationTestSupport {
@Test @Test
@Order(98) @Order(98)
void frozenSnapshotIsTheExpectedContractVersion() { void frozenSnapshotIsTheExpectedContractVersion() {
assertThat(CONTRACT.version()).isEqualTo("1.3.0"); assertThat(CONTRACT.version()).isEqualTo("1.4.0");
assertThat(CONTRACT.paths()).hasSize(31); assertThat(CONTRACT.paths()).hasSize(32);
assertThat(CONTRACT.operations()).hasSize(43); assertThat(CONTRACT.operations()).hasSize(45);
assertThat(CONTRACT.schemas()).hasSize(72); assertThat(CONTRACT.schemas()).hasSize(75);
assertThat(CONTRACT.operationsTagged(Set.of("pets", "dictionaries", "health-records"))) assertThat(CONTRACT.operationsTagged(Set.of("pets", "dictionaries", "health-records")))
.containsExactlyInAnyOrderElementsOf(PETS_OPERATIONS); .containsExactlyInAnyOrderElementsOf(PETS_OPERATIONS);
} }
@@ -13,8 +13,8 @@ import java.util.Objects;
import java.util.Set; import java.util.Set;
/** /**
* The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot * The frozen v1.4.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.3.0.yaml}. * {@code /contract/openapi-v1.4.0.yaml}.
* *
* <p><b>Sync discipline (T2-09)</b>: the canonical contract lives in the doc * <p><b>Sync discipline (T2-09)</b>: the canonical contract lives in the doc
* repo at {@code docs/api/openapi.yaml}; this snapshot is a byte-identical * repo at {@code docs/api/openapi.yaml}; this snapshot is a byte-identical
@@ -32,7 +32,7 @@ import java.util.Set;
*/ */
final class OpenApiContract { final class OpenApiContract {
static final String RESOURCE = "/contract/openapi-v1.3.0.yaml"; static final String RESOURCE = "/contract/openapi-v1.4.0.yaml";
private static final Set<String> HTTP_METHODS = private static final Set<String> HTTP_METHODS =
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace"); Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
@@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约) title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约)
version: 1.3.0 version: 1.4.0
description: | description: |
Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差), Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差),
1.1.0 追加埋点上报端点 `POST /api/v1/events`M2 第一波契约补录,以实现实测行为为准)。 1.1.0 追加埋点上报端点 `POST /api/v1/events`M2 第一波契约补录,以实现实测行为为准)。
@@ -11,6 +11,14 @@ info:
**1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、 **1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、
公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入 公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入
iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。 iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。
**1.4.0 M3.5 契约冻结:用户资料与头像**——`GET /api/v1/me` 补 `nickname`/`avatarUrl`
新增 `PATCH /api/v1/me`(昵称与头像读写,三态部分更新),`Pet` 补 `avatarUrl` 且
`PATCH /api/v1/pets/{petId}` 收 `avatarAssetId`,新增 `GET /api/v1/me/community-stats`
(获赞总数与作品数),媒体 `purpose` 白名单追加 `user_avatar`/`pet_avatar`
iteration-3.5 报告 03 定型表;冻结报告见 iteration-3.5/04)。
**1.4.0 相对 1.3.0 纯增量**:无字段删改、无类型变更、无必填收紧,仅新增操作、
新增响应字段(键恒在、值可空)、新增可选请求字段、新增响应格与枚举追加,
v1.3.0 客户端无需改动即可继续工作。
## 通用约定(development-plan 第 6 节) ## 通用约定(development-plan 第 6 节)
- 公开接口统一前缀 `/api/v1`JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 - 公开接口统一前缀 `/api/v1`JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。
@@ -29,14 +37,14 @@ info:
| 40100 | 401 | 用户名或密码错误 | | 40100 | 401 | 用户名或密码错误 |
| 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) | | 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) |
| 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) | | 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) |
| 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录、caregiver 改宠物档案) | | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录或改头像、caregiver 改宠物档案——头像除外,见 M3.5 分档 |
| 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 | | 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 |
| 40400 | 404 | 资源不存在 | | 40400 | 404 | 资源不存在 |
| 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) | | 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) |
| 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) | | 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) |
| 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 | | 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 |
| 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) | | 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) |
| 40405 | 404 | MEDIA_NOT_FOUNDasset 不存在、非本人所有已删(防枚举合并 | | 40405 | 404 | MEDIA_NOT_FOUNDasset 不存在、非本人所有已删,或**用途与引用场景不符**(帖图当头像、user_avatar 当宠物头像等);防枚举合并,同码各情形响应一致(用途不符分支只对调用者自己的 asset 可达,故 message 可具体 |
| 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) | | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) |
| 40900 | 409 | 用户名已存在(大小写不敏感) | | 40900 | 409 | 用户名已存在(大小写不敏感) |
| 40901 | 409 | 手机号已被使用 | | 40901 | 409 | 手机号已被使用 |
@@ -46,7 +54,7 @@ info:
| 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) | | 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) |
| 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 | | 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 |
| 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 | | 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 |
| 42203 | 422 | MEDIA_NOT_READY:引用了本人所有但非 readyuploading/failed)状态的 asset | | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有、用途相符但非 readyuploading/failed)状态的 asset(帖图与用户/宠物头像同构) |
| 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op | | 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op |
| 42205 | 422 | MEDIA_UPLOAD_STATE_INVALIDcomplete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 | | 42205 | 422 | MEDIA_UPLOAD_STATE_INVALIDcomplete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 |
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) | | 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
@@ -68,7 +76,10 @@ info:
- **权限模型(ADR-015owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档: - **权限模型(ADR-015owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档:
- `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要; - `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要;
- `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH - `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH
- `MANAGE`——仅 owner:宠物档案 PATCH(含状态流转)。 **M3.5 起还含宠物头像字段 `avatarAssetId`**ADR-022:头像属日常照护信息)。
- `MANAGE`——仅 owner:宠物档案 PATCH 的资料字段(含状态流转)。
**同一端点按「本次请求触及哪些字段」定档**(不是端点级降档):仅改头像走 WRITE
触及任一资料字段走 MANAGE,混合请求取更严的一半。
权限每请求实时查库、无缓存:撤销照护关系立即生效。 权限每请求实时查库、无缓存:撤销照护关系立即生效。
- **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况 - **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况
响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与 响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与
@@ -126,6 +137,41 @@ info:
整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口 整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口
(如作者公开资料批量接口)不属于本公开契约。 (如作者公开资料批量接口)不属于本公开契约。
## 用户资料与头像域约定(M3.5 冻结,iteration-3.5 报告 03 定型;ADR-022
- **三态部分更新(仅本域,pets 域 M2 两态语义不回改)**`PATCH /api/v1/me` 的
`nickname`/`avatarAssetId` 与 `PATCH /api/v1/pets/{petId}` 的 `avatarAssetId`
按三态解释——**键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。
昵称与头像天生可选,「删掉我设的那个」是一等公民操作,只有两态无法表达。
同一请求体内的其余 pets 字段仍是 M2 的「缺省或 null 皆为不改」。
- **空 PATCH 与纯空白昵称一律 400/40000**,不静默 200、不隐式清空:清空只留
显式 `null` 一条路,否则「误提交空格」与「想删昵称」无法区分。
- **昵称**btrim 后 1~32 **码点**(非 UTF-16 长度);**不设唯一约束**ADR-022
允许重名,靠 userId 区分);注册不收昵称。`/api/v1/me` 返回 **DB 原值**
未设置即 `null`**不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定
固化成真实数据;他人视角的展示回退在 `/internal/users/profiles`SQL COALESCE),
本人视角的展示回退由客户端做 `nickname ?? username`。
- **头像读取一律 `avatarUrl`(时效性预签名 GET,与帖图同一纪律)**:每次响应现签,
**会过期、客户端不得持久化**,过期即重取;无头像、asset 非 ready、对象存储未配置
三种情况均为 `null`(降级而非报错——签一个必然 404 的 URL 比给 null 更糟)。
指针不隐式清理:asset 事后退出 ready 时 `avatarUrl` 转 null 而引用保留。
- **`avatarAssetId` 只写不读**:请求体收,响应**一律不外露**(`Me` 与 `Pet` 皆无该字段);
「是否有头像」等价于 `avatarUrl != null`。
- **两种头像用途互不通用**`user_avatar` 不能当宠物头像、`pet_avatar` 不能当用户头像、
`post_image` 不能当任何头像——引用侧按 `purpose` 校验,不符者 404/40405
(四态校验:不存在/非本人/已删/用途不符 → 404/40405;本人且用途相符但
uploading/failed → 422/42203)。
- **宠物头像的权限档按「本次请求碰了哪些字段」定档**(ADR-022 头像为 WRITE 档):
仅改 `avatarAssetId` 时 owner + caregiver 皆可(viewer 403/40300);触及任一资料
字段时仍是 MANAGE(仅 owner);**混合请求取更严的一半**,堵住把改名夹带进头像
请求绕过 MANAGE 的路径。头像与资料共用同一把乐观锁 `version`(仍必填)。
- **`/api/v1/me` 无乐观锁、无幂等键**:只有一个合法写者(账号本人),暴露 `version`
只是给客户端加负担;丢失更新由**列级选择性 UPDATE** 排除(SET 列表只含本次请求
真正携带的列),并发改不同字段两者皆存活;同 body 重放天然幂等。
- **`GET /api/v1/me/community-stats` 为独立端点**ADR-022 决策 A,不并入
`/users/{userId}/follow-stats`——后者主体是「某用户的关注数」,混入「我的获赞」
会让一个载荷有两个主体)。路径上**没有 userId**:「查不到别人的获赞」不靠权限
判断,而是入口本身不存在,故**永不 404**,任何已认证用户都有 stats。
servers: servers:
- url: http://127.0.0.1:8081 - url: http://127.0.0.1:8081
description: patbond-auth(本地开发,/api/v1/auth/** description: patbond-auth(本地开发,/api/v1/auth/**
@@ -140,7 +186,7 @@ tags:
- name: auth - name: auth
description: 注册 / 登录 / 刷新 / 退出(patbond-auth description: 注册 / 登录 / 刷新 / 退出(patbond-auth
- name: user - name: user
description: 当前用户(patbond-user description: 当前用户资料:读取与昵称/头像更新patbond-user
- name: analytics - name: analytics
description: 产品事件批量上报(patbond-user description: 产品事件批量上报(patbond-user
- name: pets - name: pets
@@ -152,7 +198,9 @@ tags:
- name: media - name: media
description: 媒体上传两步流程(patbond-userADR-016 预签名直传) description: 媒体上传两步流程(patbond-userADR-016 预签名直传)
- name: posts - name: posts
description: 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子(patbond-community description: |
帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子;含由帖子派生的「我的社区数字」
聚合(patbond-community
- name: feed - name: feed
description: 公共 Feed 游标分页(patbond-community description: 公共 Feed 游标分页(patbond-community
- name: comments - name: comments
@@ -301,7 +349,15 @@ paths:
get: get:
tags: [user] tags: [user]
summary: 当前用户资料 summary: 当前用户资料
description: 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 description: |
由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。
- `nickname` 为 **DB 原值**,未设置即 `null`**不做 username 回退**,见 info
「用户资料与头像域约定」);本人视角的展示回退由客户端做 `nickname ?? username`。
- `avatarUrl` 为**每次响应现签的时效性预签名 GET**:**会过期、客户端不得持久化**,
过期即重取;无头像、asset 非 ready、对象存储未配置均为 `null`。
- 响应**不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于
`avatarUrl != null`。
operationId: me operationId: me
security: security:
- bearerAuth: [] - bearerAuth: []
@@ -320,6 +376,66 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorEnvelope' $ref: '#/components/schemas/ErrorEnvelope'
patch:
tags: [user]
summary: 更新当前用户资料(昵称 / 头像,三态部分更新)
description: |
本人资料的唯一写入口(主体恒为 token 里的调用者,无「他人」情形)。
- **三态语义**:键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置。
- **空 patch 400/40000**(两字段都未出现,含只带未声明字段):不静默 200,
空 PATCH 几乎总是客户端 bug。纯空白/空串昵称同为 400/40000,不隐式清空。
- `avatarAssetId` 须为**调用者本人、用途为 `user_avatar`、状态 `ready`** 的 asset
不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed
422/42203。
- **无 `version` 乐观锁、无 `Idempotency-Key`**:只有一个合法写者;丢失更新由
列级选择性 UPDATE 排除(并发改不同字段两者皆存活),同 body 重放天然幂等。
- 成功返回**与 GET 完全相同的 `Me` 全量形态**(回显更新后资料,`avatarUrl` 现签)。
operationId: updateMe
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateMeRequest'
responses:
'200':
description: 更新成功,返回更新后的完整 Me
content:
application/json:
schema:
$ref: '#/components/schemas/MeEnvelope'
'400':
description: |
参数校验失败(code 40000):空 patch、昵称 btrim 后长度不在 1~32 码点、
昵称纯空白或空串、`avatarAssetId` 非法 UUID、body 非法 JSON
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
emptyPatch:
value: { code: 40000, message: 请求未包含任何可更新字段, data: null }
'401':
$ref: '#/components/responses/AccessTokenInvalid'
'404':
description: |
用户不存在或已注销(code 40400,token 仍有效但账号已注销);或
`avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `user_avatar`
code 40405,防枚举合并)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
userNotFound:
value: { code: 40400, message: 用户不存在, data: null }
mediaNotFound:
value: { code: 40405, message: 媒体资源不存在, data: null }
'422':
$ref: '#/components/responses/MediaNotReady'
/api/v1/events: /api/v1/events:
post: post:
@@ -468,18 +584,32 @@ paths:
$ref: '#/components/responses/PetNotFound' $ref: '#/components/responses/PetNotFound'
patch: patch:
tags: [pets] tags: [pets]
summary: 更新宠物档案 summary: 更新宠物档案(含头像)
description: | description: |
权限档MANAGE**仅 owner**);caregiver/viewer 更新得 403/40300。 权限档**按本次请求触及的字段定档**(M3.5 起,ADR-022):
- 部分更新:缺席字段不变;**不支持将可选字段清空回 null**。 | 请求体触及 | 所需档位 | caregiver | viewer |
| --- | --- | --- | --- |
| 仅 `avatarAssetId`+ `version` | WRITE | ✅ | ✗ 403/40300 |
| 任一资料字段(name/sex/status/…) | MANAGE(仅 owner | ✗ 403/40300 | ✗ 403/40300 |
| 资料字段 + `avatarAssetId` 混合 | MANAGE**取更严的一半** | ✗ 403/40300 | ✗ 403/40300 |
混合请求取更严,是为了堵住「夹带」——否则 caregiver 可把改名塞进头像请求绕过 MANAGE。
- 部分更新:缺席字段不变;资料字段**不支持清空回 null**(M2 语义不回改)。
- **例外:`avatarAssetId` 是本端点唯一的三态字段**(M3.5)——键缺省 = 不改;
键出现且为 `null` = **清除头像**;键出现且有值 = 设置。差异刻意限定在有清空
需求的字段上。
- 例外:品种对(`breedId`/`customBreedName`**整体替换**——提交任一侧即替换 - 例外:品种对(`breedId`/`customBreedName`**整体替换**——提交任一侧即替换
整对,互斥校验同创建。 整对,互斥校验同创建。
- `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。 - `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。
- `status` 可迁移至 active/lost/deceased/archived**`deleted` 不可经 PATCH - `status` 可迁移至 active/lost/deceased/archived**`deleted` 不可经 PATCH
设置**(400/40000,软删除留待专用端点,M2 契约不含)。 设置**(400/40000,软删除留待专用端点,M2 契约不含)。
- `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 - `version` **必填**(缺失 400/40000),即便只改头像;比对通过才写入并 +1
过期 409/40902。头像与资料共用同一把乐观锁——头像变更也应让并发编辑者感知行已变。
- 芯片号改为已被登记的值:409/40903。 - 芯片号改为已被登记的值:409/40903。
- `avatarAssetId` 须为**调用者本人、用途为 `pet_avatar`、状态 `ready`** 的 asset
不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203。
operationId: updatePet operationId: updatePet
security: security:
- bearerAuth: [] - bearerAuth: []
@@ -505,7 +635,19 @@ paths:
'403': '403':
$ref: '#/components/responses/PetWriteDenied' $ref: '#/components/responses/PetWriteDenied'
'404': '404':
$ref: '#/components/responses/PetNotFound' description: |
宠物不存在、已软删除或调用者与宠物无关系(code 40401,防枚举合并);或
`avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `pet_avatar`
code 40405,防枚举合并)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
petNotFound:
value: { code: 40401, message: 宠物不存在, data: null }
mediaNotFound:
value: { code: 40405, message: 媒体资源不存在, data: null }
'409': '409':
description: 版本冲突(code 40902)或芯片号已被登记(code 40903) description: 版本冲突(code 40902)或芯片号已被登记(code 40903)
content: content:
@@ -517,6 +659,8 @@ paths:
value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null }
microchipExists: microchipExists:
value: { code: 40903, message: 芯片号已被登记, data: null } value: { code: 40903, message: 芯片号已被登记, data: null }
'422':
$ref: '#/components/responses/MediaNotReady'
/api/v1/breeds: /api/v1/breeds:
get: get:
@@ -1201,7 +1345,7 @@ paths:
petNotFound: petNotFound:
value: { code: 40401, message: 宠物不存在, data: null } value: { code: 40401, message: 宠物不存在, data: null }
mediaNotFound: mediaNotFound:
value: { code: 40405, message: 媒体不存在, data: null } value: { code: 40405, message: 媒体资源不存在, data: null }
'409': '409':
$ref: '#/components/responses/IdempotencyPayloadMismatch' $ref: '#/components/responses/IdempotencyPayloadMismatch'
'422': '422':
@@ -1284,7 +1428,7 @@ paths:
petNotFound: petNotFound:
value: { code: 40401, message: 宠物不存在, data: null } value: { code: 40401, message: 宠物不存在, data: null }
mediaNotFound: mediaNotFound:
value: { code: 40405, message: 媒体不存在, data: null } value: { code: 40405, message: 媒体资源不存在, data: null }
'409': '409':
$ref: '#/components/responses/VersionConflict' $ref: '#/components/responses/VersionConflict'
'422': '422':
@@ -1348,6 +1492,35 @@ paths:
'401': '401':
$ref: '#/components/responses/AccessTokenInvalid' $ref: '#/components/responses/AccessTokenInvalid'
/api/v1/me/community-stats:
get:
tags: [posts]
summary: 我的社区数字(获赞总数 / 作品数)
description: |
主体恒为 token 里的调用者:无查询参数、无路径参数,**路径上没有 userId**——
「查不到别人的获赞」不靠权限判断,而是入口本身不存在。
- **统计集合 = 本人的、`status='published'` 的、`deleted_at IS NULL` 的帖**。
草稿不计(尚非作品,且未发布不可被赞);软删不计(删帖即撤回其数字,与
`/me/posts`、Feed 的可见性一致);运营态 hidden/archived 不计(对所有人不可见,
含作者本人);他人帖自然不计。
- **自己赞自己计入**——与帖子详情页的 `likeCount` 保持同一口径,两处数字必须能对上。
- `receivedLikeCount` = 该集合的 `like_count` 之和(读侧实时聚合,读的是写侧同事务
维护的帖级冗余列,故为精确值而非估算;ADR-022 不引入按人累计的冗余列)。
- **空数据返回 `0` 而非 null**,且**永不 404**:任何已认证用户都有 stats。
operationId: getMyCommunityStats
security:
- bearerAuth: []
responses:
'200':
description: 我的获赞总数与作品数
content:
application/json:
schema:
$ref: '#/components/schemas/CommunityStatsEnvelope'
'401':
$ref: '#/components/responses/AccessTokenInvalid'
/api/v1/feed: /api/v1/feed:
get: get:
tags: [feed] tags: [feed]
@@ -1812,8 +1985,9 @@ components:
value: { code: 40402, message: 记录不存在, data: null } value: { code: 40402, message: 记录不存在, data: null }
PetWriteDenied: PetWriteDenied:
description: | description: |
对可见宠物无相应操作权限(code 40300):viewer 写记录、caregiver/viewer 对可见宠物无相应操作权限(code 40300):viewer 写记录或改头像、caregiver/viewer
宠物档案。仅发给对宠物「可见」的调用者,不泄露新信息 宠物档案的资料字段(caregiver 仅改 `avatarAssetId` 时允许,M3.5 按字段分档)
仅发给对宠物「可见」的调用者,不泄露新信息。
content: content:
application/json: application/json:
schema: schema:
@@ -1854,14 +2028,16 @@ components:
commentNotFound: commentNotFound:
value: { code: 40404, message: 评论不存在, data: null } value: { code: 40404, message: 评论不存在, data: null }
MediaNotFound: MediaNotFound:
description: asset 不存在、非本人所有或已删(code 40405,防枚举合并) description: |
asset 不存在、非本人所有、已删,或**用途与引用场景不符**(code 40405,防枚举合并)。
用途不符即「从头像域看,一张帖子配图不是头像」;两种头像用途亦互不通用。
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorEnvelope' $ref: '#/components/schemas/ErrorEnvelope'
examples: examples:
mediaNotFound: mediaNotFound:
value: { code: 40405, message: 媒体不存在, data: null } value: { code: 40405, message: 媒体资源不存在, data: null }
UserNotFound: UserNotFound:
description: 目标用户不存在或已注销(code 40406,合并不泄露成因) description: 目标用户不存在或已注销(code 40406,合并不泄露成因)
content: content:
@@ -2001,8 +2177,10 @@ components:
Me: Me:
type: object type: object
description: 当前用户资料(冻结契约,恰好这 4 个字段) description: |
required: [userId, username, createdAt] 本人资料(`GET` 与 `PATCH /api/v1/me` 的统一响应形态,冻结契约恰好这 6 个字段)。
**不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 `avatarUrl != null`。
required: [userId, username, nickname, avatarUrl, createdAt]
properties: properties:
userId: userId:
type: string type: string
@@ -2011,16 +2189,65 @@ components:
username: username:
type: string type: string
example: demo_user example: demo_user
nickname:
type: string
nullable: true
minLength: 1
maxLength: 32
description: |
昵称,**DB 原值**;未设置为 null(键恒在)。长度按**码点**计 1~32(btrim 后)。
**本端点不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定固化成
真实数据;本人视角的展示回退由客户端做 `nickname ?? username`,他人视角的
回退在 `/internal/users/profiles`SQL COALESCE,不属本公开契约)。
不设唯一约束(ADR-022,允许重名)。
example: 小柴
phone: phone:
type: string type: string
nullable: true nullable: true
description: E.164;未绑定时为 null description: E.164;未绑定时为 null
example: '+8613800138000' example: '+8613800138000'
avatarUrl:
type: string
nullable: true
description: |
头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签,
**客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。
无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。
example: https://minio.example.com/patbond-media/user_avatar/2026/09/019212aa…?X-Amz-Signature=…
createdAt: createdAt:
type: string type: string
format: date-time format: date-time
example: '2026-09-04T04:05:06.789Z' example: '2026-09-04T04:05:06.789Z'
UpdateMeRequest:
type: object
description: |
本人资料部分更新(**三态语义**,与 pets 域 M2 的两态刻意不同):
**键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。
两字段都未出现(含只带未声明字段)为**空 patch**,答 400/40000 而非静默 200。
无必填字段、无 `version` 乐观锁、无 `Idempotency-Key`(同 body 重放天然幂等)。
properties:
nickname:
type: string
nullable: true
minLength: 1
maxLength: 32
description: |
昵称;btrim 后长度按**码点**计须在 1~32,否则 400/40000。
显式 `null` = **清空昵称**;纯空白或空串是 400/40000,**不是隐式清空**
(清空只留显式 null 一条路,否则「误提交空格」与「想删昵称」无法区分)。
example: 小柴
avatarAssetId:
type: string
format: uuid
nullable: true
description: |
头像 asset ID(两步上传的产物,`purpose` 须为 `user_avatar`)。
显式 `null` = **清除头像**。校验:不存在/非本人/已删/用途不符 404/40405
本人且用途相符但 uploading/failed 422/42203;非法 UUID 400/40000。
**响应不回显该字段**(只写不读)。
example: 019212bb-0000-7000-8000-000000000009
AuthTokenEnvelope: AuthTokenEnvelope:
type: object type: object
required: [code, message] required: [code, message]
@@ -2226,7 +2453,8 @@ components:
`breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed); `breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed);
`breedDisplayName` 由品种字典解出,随 breedId 存在。 `breedDisplayName` 由品种字典解出,随 breedId 存在。
软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的 软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的
status 永不出现 deleted。`avatarAssetId` 不出现在 M2 契约(ADR-010)。 status 永不出现 deleted。头像以 `avatarUrl` 的现签形式返回,
**`avatarAssetId` 不外露**(只写不读,写入口为 `PATCH /api/v1/pets/{petId}`)。
required: required:
- id - id
- name - name
@@ -2234,6 +2462,7 @@ components:
- sex - sex
- birthDateEstimated - birthDateEstimated
- status - status
- avatarUrl
- myRole - myRole
- createdAt - createdAt
- updatedAt - updatedAt
@@ -2294,6 +2523,15 @@ components:
type: string type: string
enum: [active, lost, deceased, archived] enum: [active, lost, deceased, archived]
description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401) description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401)
avatarUrl:
type: string
nullable: true
description: |
宠物头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签,
**客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。
无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。
asset 事后退出 ready 时转 null 而引用保留(读请求不做写副作用)。
example: https://minio.example.com/patbond-media/pet_avatar/2026/09/019212cc…?X-Amz-Signature=…
myRole: myRole:
type: string type: string
enum: [owner, caregiver, viewer] enum: [owner, caregiver, viewer]
@@ -2351,14 +2589,18 @@ components:
UpdatePetRequest: UpdatePetRequest:
type: object type: object
description: | description: |
部分更新:缺席字段不变;不支持清空回 null。例外:品种对 部分更新:缺席字段不变;资料字段不支持清空回 null。例外:品种对
breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。 breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。
species 不可改(不在请求体)。 species 不可改(不在请求体)。
**`avatarAssetId` 是本 schema 唯一的三态字段**(M3.5):键缺省 = 不改;
键出现且为 `null` = 清除头像;键出现且有值 = 设置。其余字段保持 M2 两态语义。
权限档按本次触及的字段决定(仅头像 → WRITE;触及资料字段 → MANAGE;混合取更严),
见端点描述。
required: [version] required: [version]
properties: properties:
version: version:
type: integer type: integer
description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902 description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902;即便只改头像也必带
name: name:
type: string type: string
minLength: 1 minLength: 1
@@ -2393,6 +2635,16 @@ components:
type: string type: string
enum: [active, lost, deceased, archived] enum: [active, lost, deceased, archived]
description: 状态流转;deleted 不可经 PATCH 设置(400/40000 description: 状态流转;deleted 不可经 PATCH 设置(400/40000
avatarAssetId:
type: string
format: uuid
nullable: true
description: |
宠物头像 asset ID(两步上传的产物,`purpose` 须为 `pet_avatar`)。
**三态**:缺省 = 不改;显式 `null` = 清除头像;给值 = 设置。校验:不存在/
非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203
非法 UUID 400/40000。**响应不回显该字段**(只写不读,读取见 `Pet.avatarUrl`)。
example: 019212cc-0000-7000-8000-00000000000a
PetEnvelope: PetEnvelope:
type: object type: object
@@ -3205,10 +3457,13 @@ components:
description: M3 仅 imageADR-018 视频后置;video/document 为向后新增枚举预留) description: M3 仅 imageADR-018 视频后置;video/document 为向后新增枚举预留)
purpose: purpose:
type: string type: string
enum: [post_image] enum: [post_image, user_avatar, pet_avatar]
description: | description: |
用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩 用途白名单(服务端配置项 `patbond.media.allowed-purposes`,决定 objectKey 前缀
user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展) `<purpose>/yyyy/MM/{assetId}`)。M3.5 起为三值:`post_image`(帖子配图)、
`user_avatar`(用户头像)、`pet_avatar`(宠物头像)——相对 M3 的纯枚举追加。
**用途即引用侧的类型检查**:引用时校验 `purpose` 相符,故帖图不能当头像、
两种头像也互不通用(不符者 404/40405)。白名单外的取值 400/40000。
mimeType: mimeType:
type: string type: string
enum: [image/jpeg, image/png, image/webp] enum: [image/jpeg, image/png, image/webp]
@@ -3857,3 +4112,36 @@ components:
example: success example: success
data: data:
$ref: '#/components/schemas/FollowStats' $ref: '#/components/schemas/FollowStats'
CommunityStats:
type: object
description: |
调用者本人的社区数字(M3.5)。两数同一集合:本人的、`status='published'` 的、
未软删的帖(草稿 / 软删 / hidden / archived 均不计)。读侧实时聚合,无冗余计数列。
required: [receivedLikeCount, publishedPostCount]
properties:
receivedLikeCount:
type: integer
format: int64
description: |
获赞总数 = 该集合的 `like_count` 之和(写侧同事务维护的帖级冗余列,精确值)。
**自己赞自己计入**,与帖子详情的 `likeCount` 同一口径。空数据为 0,非 null。
example: 128
publishedPostCount:
type: integer
format: int64
description: 作品数 = 该集合的帖子数。空数据为 0,非 null。
example: 12
CommunityStatsEnvelope:
type: object
required: [code, message, data]
properties:
code:
type: integer
enum: [0]
message:
type: string
example: success
data:
$ref: '#/components/schemas/CommunityStats'
@@ -40,13 +40,13 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
/** /**
* T3-20M3 第二波收尾):media 域 2 个操作(两步上传,属 user 模块)补进契约 * T3-20M3 第二波收尾):media 域 2 个操作(两步上传,属 user 模块)补进契约
* 一致性保障,机制与 patbond-pet 的 ContractConformanceTest 同构——对冻结契约 * 一致性保障,机制与 patbond-pet 的 ContractConformanceTest 同构——对冻结契约
* v1.3.0(快照 {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 * v1.4.0(快照 {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在
* doc 仓 {@code docs/api/openapi.yaml})逐操作真实起服务发请求(真实 MinIO * doc 仓 {@code docs/api/openapi.yaml})逐操作真实起服务发请求(真实 MinIO
* Testcontainer,直传走真实 HTTP PUT),用 {@link ContractValidator} 严格校验 * Testcontainer,直传走真实 HTTP PUT),用 {@link ContractValidator} 严格校验
* 响应结构,最后以全响应矩阵门禁兜底(8 个单元格,无豁免)。 * 响应结构,最后以全响应矩阵门禁兜底(8 个单元格,无豁免)。
* *
* <p>auth 域 6 操作在 patbond-auth、pets 域 18 操作在 patbond-pet、community 域 * <p>auth/user7 操作在 patbond-auth、pets 域 18 操作在 patbond-pet、community 域
* 17 操作在 patbond-community 的同构测试内(快照同一份)。 * 18 操作在 patbond-community 的同构测试内(快照同一份)。
*/ */
@TestMethodOrder(MethodOrderer.OrderAnnotation.class) @TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@SpringBootTest @SpringBootTest
@@ -245,10 +245,10 @@ class MediaContractConformanceTest {
@Test @Test
@Order(98) @Order(98)
void frozenSnapshotIsTheExpectedContractVersion() { void frozenSnapshotIsTheExpectedContractVersion() {
assertThat(CONTRACT.version()).isEqualTo("1.3.0"); assertThat(CONTRACT.version()).isEqualTo("1.4.0");
assertThat(CONTRACT.paths()).hasSize(31); assertThat(CONTRACT.paths()).hasSize(32);
assertThat(CONTRACT.operations()).hasSize(43); assertThat(CONTRACT.operations()).hasSize(45);
assertThat(CONTRACT.schemas()).hasSize(72); assertThat(CONTRACT.schemas()).hasSize(75);
assertThat(CONTRACT.operationsTagged(Set.of("media"))) assertThat(CONTRACT.operationsTagged(Set.of("media")))
.containsExactlyInAnyOrderElementsOf(MEDIA_OPERATIONS); .containsExactlyInAnyOrderElementsOf(MEDIA_OPERATIONS);
} }
@@ -13,8 +13,8 @@ import java.util.Objects;
import java.util.Set; import java.util.Set;
/** /**
* The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot * The frozen v1.4.0 OpenAPI contract, loaded from the test-resource snapshot
* {@code /contract/openapi-v1.3.0.yaml}. * {@code /contract/openapi-v1.4.0.yaml}.
* *
* <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical * <p><b>Sync discipline (T2-09, extended by T3-19)</b>: the canonical
* contract lives in the doc repo at {@code docs/api/openapi.yaml}; this * contract lives in the doc repo at {@code docs/api/openapi.yaml}; this
@@ -36,7 +36,7 @@ import java.util.Set;
*/ */
final class OpenApiContract { final class OpenApiContract {
static final String RESOURCE = "/contract/openapi-v1.3.0.yaml"; static final String RESOURCE = "/contract/openapi-v1.4.0.yaml";
private static final Set<String> HTTP_METHODS = private static final Set<String> HTTP_METHODS =
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace"); Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
@@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约) title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约)
version: 1.3.0 version: 1.4.0
description: | description: |
Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差), Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差),
1.1.0 追加埋点上报端点 `POST /api/v1/events`M2 第一波契约补录,以实现实测行为为准)。 1.1.0 追加埋点上报端点 `POST /api/v1/events`M2 第一波契约补录,以实现实测行为为准)。
@@ -11,6 +11,14 @@ info:
**1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、 **1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、
公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入 公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入
iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。 iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。
**1.4.0 M3.5 契约冻结:用户资料与头像**——`GET /api/v1/me` 补 `nickname`/`avatarUrl`
新增 `PATCH /api/v1/me`(昵称与头像读写,三态部分更新),`Pet` 补 `avatarUrl` 且
`PATCH /api/v1/pets/{petId}` 收 `avatarAssetId`,新增 `GET /api/v1/me/community-stats`
(获赞总数与作品数),媒体 `purpose` 白名单追加 `user_avatar`/`pet_avatar`
iteration-3.5 报告 03 定型表;冻结报告见 iteration-3.5/04)。
**1.4.0 相对 1.3.0 纯增量**:无字段删改、无类型变更、无必填收紧,仅新增操作、
新增响应字段(键恒在、值可空)、新增可选请求字段、新增响应格与枚举追加,
v1.3.0 客户端无需改动即可继续工作。
## 通用约定(development-plan 第 6 节) ## 通用约定(development-plan 第 6 节)
- 公开接口统一前缀 `/api/v1`JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 - 公开接口统一前缀 `/api/v1`JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。
@@ -29,14 +37,14 @@ info:
| 40100 | 401 | 用户名或密码错误 | | 40100 | 401 | 用户名或密码错误 |
| 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) | | 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) |
| 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) | | 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) |
| 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录、caregiver 改宠物档案) | | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录或改头像、caregiver 改宠物档案——头像除外,见 M3.5 分档 |
| 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 | | 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 |
| 40400 | 404 | 资源不存在 | | 40400 | 404 | 资源不存在 |
| 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) | | 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) |
| 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) | | 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) |
| 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 | | 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 |
| 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) | | 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) |
| 40405 | 404 | MEDIA_NOT_FOUNDasset 不存在、非本人所有已删(防枚举合并 | | 40405 | 404 | MEDIA_NOT_FOUNDasset 不存在、非本人所有已删,或**用途与引用场景不符**(帖图当头像、user_avatar 当宠物头像等);防枚举合并,同码各情形响应一致(用途不符分支只对调用者自己的 asset 可达,故 message 可具体 |
| 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) | | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) |
| 40900 | 409 | 用户名已存在(大小写不敏感) | | 40900 | 409 | 用户名已存在(大小写不敏感) |
| 40901 | 409 | 手机号已被使用 | | 40901 | 409 | 手机号已被使用 |
@@ -46,7 +54,7 @@ info:
| 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) | | 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) |
| 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 | | 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 |
| 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 | | 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 |
| 42203 | 422 | MEDIA_NOT_READY:引用了本人所有但非 readyuploading/failed)状态的 asset | | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有、用途相符但非 readyuploading/failed)状态的 asset(帖图与用户/宠物头像同构) |
| 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op | | 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op |
| 42205 | 422 | MEDIA_UPLOAD_STATE_INVALIDcomplete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 | | 42205 | 422 | MEDIA_UPLOAD_STATE_INVALIDcomplete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 |
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) | | 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
@@ -68,7 +76,10 @@ info:
- **权限模型(ADR-015owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档: - **权限模型(ADR-015owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档:
- `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要; - `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要;
- `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH - `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH
- `MANAGE`——仅 owner:宠物档案 PATCH(含状态流转)。 **M3.5 起还含宠物头像字段 `avatarAssetId`**ADR-022:头像属日常照护信息)。
- `MANAGE`——仅 owner:宠物档案 PATCH 的资料字段(含状态流转)。
**同一端点按「本次请求触及哪些字段」定档**(不是端点级降档):仅改头像走 WRITE
触及任一资料字段走 MANAGE,混合请求取更严的一半。
权限每请求实时查库、无缓存:撤销照护关系立即生效。 权限每请求实时查库、无缓存:撤销照护关系立即生效。
- **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况 - **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况
响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与 响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与
@@ -126,6 +137,41 @@ info:
整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口 整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口
(如作者公开资料批量接口)不属于本公开契约。 (如作者公开资料批量接口)不属于本公开契约。
## 用户资料与头像域约定(M3.5 冻结,iteration-3.5 报告 03 定型;ADR-022
- **三态部分更新(仅本域,pets 域 M2 两态语义不回改)**`PATCH /api/v1/me` 的
`nickname`/`avatarAssetId` 与 `PATCH /api/v1/pets/{petId}` 的 `avatarAssetId`
按三态解释——**键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。
昵称与头像天生可选,「删掉我设的那个」是一等公民操作,只有两态无法表达。
同一请求体内的其余 pets 字段仍是 M2 的「缺省或 null 皆为不改」。
- **空 PATCH 与纯空白昵称一律 400/40000**,不静默 200、不隐式清空:清空只留
显式 `null` 一条路,否则「误提交空格」与「想删昵称」无法区分。
- **昵称**btrim 后 1~32 **码点**(非 UTF-16 长度);**不设唯一约束**ADR-022
允许重名,靠 userId 区分);注册不收昵称。`/api/v1/me` 返回 **DB 原值**
未设置即 `null`**不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定
固化成真实数据;他人视角的展示回退在 `/internal/users/profiles`SQL COALESCE),
本人视角的展示回退由客户端做 `nickname ?? username`。
- **头像读取一律 `avatarUrl`(时效性预签名 GET,与帖图同一纪律)**:每次响应现签,
**会过期、客户端不得持久化**,过期即重取;无头像、asset 非 ready、对象存储未配置
三种情况均为 `null`(降级而非报错——签一个必然 404 的 URL 比给 null 更糟)。
指针不隐式清理:asset 事后退出 ready 时 `avatarUrl` 转 null 而引用保留。
- **`avatarAssetId` 只写不读**:请求体收,响应**一律不外露**(`Me` 与 `Pet` 皆无该字段);
「是否有头像」等价于 `avatarUrl != null`。
- **两种头像用途互不通用**`user_avatar` 不能当宠物头像、`pet_avatar` 不能当用户头像、
`post_image` 不能当任何头像——引用侧按 `purpose` 校验,不符者 404/40405
(四态校验:不存在/非本人/已删/用途不符 → 404/40405;本人且用途相符但
uploading/failed → 422/42203)。
- **宠物头像的权限档按「本次请求碰了哪些字段」定档**(ADR-022 头像为 WRITE 档):
仅改 `avatarAssetId` 时 owner + caregiver 皆可(viewer 403/40300);触及任一资料
字段时仍是 MANAGE(仅 owner);**混合请求取更严的一半**,堵住把改名夹带进头像
请求绕过 MANAGE 的路径。头像与资料共用同一把乐观锁 `version`(仍必填)。
- **`/api/v1/me` 无乐观锁、无幂等键**:只有一个合法写者(账号本人),暴露 `version`
只是给客户端加负担;丢失更新由**列级选择性 UPDATE** 排除(SET 列表只含本次请求
真正携带的列),并发改不同字段两者皆存活;同 body 重放天然幂等。
- **`GET /api/v1/me/community-stats` 为独立端点**ADR-022 决策 A,不并入
`/users/{userId}/follow-stats`——后者主体是「某用户的关注数」,混入「我的获赞」
会让一个载荷有两个主体)。路径上**没有 userId**:「查不到别人的获赞」不靠权限
判断,而是入口本身不存在,故**永不 404**,任何已认证用户都有 stats。
servers: servers:
- url: http://127.0.0.1:8081 - url: http://127.0.0.1:8081
description: patbond-auth(本地开发,/api/v1/auth/** description: patbond-auth(本地开发,/api/v1/auth/**
@@ -140,7 +186,7 @@ tags:
- name: auth - name: auth
description: 注册 / 登录 / 刷新 / 退出(patbond-auth description: 注册 / 登录 / 刷新 / 退出(patbond-auth
- name: user - name: user
description: 当前用户(patbond-user description: 当前用户资料:读取与昵称/头像更新patbond-user
- name: analytics - name: analytics
description: 产品事件批量上报(patbond-user description: 产品事件批量上报(patbond-user
- name: pets - name: pets
@@ -152,7 +198,9 @@ tags:
- name: media - name: media
description: 媒体上传两步流程(patbond-userADR-016 预签名直传) description: 媒体上传两步流程(patbond-userADR-016 预签名直传)
- name: posts - name: posts
description: 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子(patbond-community description: |
帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子;含由帖子派生的「我的社区数字」
聚合(patbond-community
- name: feed - name: feed
description: 公共 Feed 游标分页(patbond-community description: 公共 Feed 游标分页(patbond-community
- name: comments - name: comments
@@ -301,7 +349,15 @@ paths:
get: get:
tags: [user] tags: [user]
summary: 当前用户资料 summary: 当前用户资料
description: 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 description: |
由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。
- `nickname` 为 **DB 原值**,未设置即 `null`**不做 username 回退**,见 info
「用户资料与头像域约定」);本人视角的展示回退由客户端做 `nickname ?? username`。
- `avatarUrl` 为**每次响应现签的时效性预签名 GET**:**会过期、客户端不得持久化**,
过期即重取;无头像、asset 非 ready、对象存储未配置均为 `null`。
- 响应**不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于
`avatarUrl != null`。
operationId: me operationId: me
security: security:
- bearerAuth: [] - bearerAuth: []
@@ -320,6 +376,66 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorEnvelope' $ref: '#/components/schemas/ErrorEnvelope'
patch:
tags: [user]
summary: 更新当前用户资料(昵称 / 头像,三态部分更新)
description: |
本人资料的唯一写入口(主体恒为 token 里的调用者,无「他人」情形)。
- **三态语义**:键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置。
- **空 patch 400/40000**(两字段都未出现,含只带未声明字段):不静默 200,
空 PATCH 几乎总是客户端 bug。纯空白/空串昵称同为 400/40000,不隐式清空。
- `avatarAssetId` 须为**调用者本人、用途为 `user_avatar`、状态 `ready`** 的 asset
不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed
422/42203。
- **无 `version` 乐观锁、无 `Idempotency-Key`**:只有一个合法写者;丢失更新由
列级选择性 UPDATE 排除(并发改不同字段两者皆存活),同 body 重放天然幂等。
- 成功返回**与 GET 完全相同的 `Me` 全量形态**(回显更新后资料,`avatarUrl` 现签)。
operationId: updateMe
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateMeRequest'
responses:
'200':
description: 更新成功,返回更新后的完整 Me
content:
application/json:
schema:
$ref: '#/components/schemas/MeEnvelope'
'400':
description: |
参数校验失败(code 40000):空 patch、昵称 btrim 后长度不在 1~32 码点、
昵称纯空白或空串、`avatarAssetId` 非法 UUID、body 非法 JSON
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
emptyPatch:
value: { code: 40000, message: 请求未包含任何可更新字段, data: null }
'401':
$ref: '#/components/responses/AccessTokenInvalid'
'404':
description: |
用户不存在或已注销(code 40400,token 仍有效但账号已注销);或
`avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `user_avatar`
code 40405,防枚举合并)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
userNotFound:
value: { code: 40400, message: 用户不存在, data: null }
mediaNotFound:
value: { code: 40405, message: 媒体资源不存在, data: null }
'422':
$ref: '#/components/responses/MediaNotReady'
/api/v1/events: /api/v1/events:
post: post:
@@ -468,18 +584,32 @@ paths:
$ref: '#/components/responses/PetNotFound' $ref: '#/components/responses/PetNotFound'
patch: patch:
tags: [pets] tags: [pets]
summary: 更新宠物档案 summary: 更新宠物档案(含头像)
description: | description: |
权限档MANAGE**仅 owner**);caregiver/viewer 更新得 403/40300。 权限档**按本次请求触及的字段定档**(M3.5 起,ADR-022):
- 部分更新:缺席字段不变;**不支持将可选字段清空回 null**。 | 请求体触及 | 所需档位 | caregiver | viewer |
| --- | --- | --- | --- |
| 仅 `avatarAssetId`+ `version` | WRITE | ✅ | ✗ 403/40300 |
| 任一资料字段(name/sex/status/…) | MANAGE(仅 owner | ✗ 403/40300 | ✗ 403/40300 |
| 资料字段 + `avatarAssetId` 混合 | MANAGE**取更严的一半** | ✗ 403/40300 | ✗ 403/40300 |
混合请求取更严,是为了堵住「夹带」——否则 caregiver 可把改名塞进头像请求绕过 MANAGE。
- 部分更新:缺席字段不变;资料字段**不支持清空回 null**(M2 语义不回改)。
- **例外:`avatarAssetId` 是本端点唯一的三态字段**(M3.5)——键缺省 = 不改;
键出现且为 `null` = **清除头像**;键出现且有值 = 设置。差异刻意限定在有清空
需求的字段上。
- 例外:品种对(`breedId`/`customBreedName`**整体替换**——提交任一侧即替换 - 例外:品种对(`breedId`/`customBreedName`**整体替换**——提交任一侧即替换
整对,互斥校验同创建。 整对,互斥校验同创建。
- `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。 - `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。
- `status` 可迁移至 active/lost/deceased/archived**`deleted` 不可经 PATCH - `status` 可迁移至 active/lost/deceased/archived**`deleted` 不可经 PATCH
设置**(400/40000,软删除留待专用端点,M2 契约不含)。 设置**(400/40000,软删除留待专用端点,M2 契约不含)。
- `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 - `version` **必填**(缺失 400/40000),即便只改头像;比对通过才写入并 +1
过期 409/40902。头像与资料共用同一把乐观锁——头像变更也应让并发编辑者感知行已变。
- 芯片号改为已被登记的值:409/40903。 - 芯片号改为已被登记的值:409/40903。
- `avatarAssetId` 须为**调用者本人、用途为 `pet_avatar`、状态 `ready`** 的 asset
不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203。
operationId: updatePet operationId: updatePet
security: security:
- bearerAuth: [] - bearerAuth: []
@@ -505,7 +635,19 @@ paths:
'403': '403':
$ref: '#/components/responses/PetWriteDenied' $ref: '#/components/responses/PetWriteDenied'
'404': '404':
$ref: '#/components/responses/PetNotFound' description: |
宠物不存在、已软删除或调用者与宠物无关系(code 40401,防枚举合并);或
`avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `pet_avatar`
code 40405,防枚举合并)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
examples:
petNotFound:
value: { code: 40401, message: 宠物不存在, data: null }
mediaNotFound:
value: { code: 40405, message: 媒体资源不存在, data: null }
'409': '409':
description: 版本冲突(code 40902)或芯片号已被登记(code 40903) description: 版本冲突(code 40902)或芯片号已被登记(code 40903)
content: content:
@@ -517,6 +659,8 @@ paths:
value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null }
microchipExists: microchipExists:
value: { code: 40903, message: 芯片号已被登记, data: null } value: { code: 40903, message: 芯片号已被登记, data: null }
'422':
$ref: '#/components/responses/MediaNotReady'
/api/v1/breeds: /api/v1/breeds:
get: get:
@@ -1201,7 +1345,7 @@ paths:
petNotFound: petNotFound:
value: { code: 40401, message: 宠物不存在, data: null } value: { code: 40401, message: 宠物不存在, data: null }
mediaNotFound: mediaNotFound:
value: { code: 40405, message: 媒体不存在, data: null } value: { code: 40405, message: 媒体资源不存在, data: null }
'409': '409':
$ref: '#/components/responses/IdempotencyPayloadMismatch' $ref: '#/components/responses/IdempotencyPayloadMismatch'
'422': '422':
@@ -1284,7 +1428,7 @@ paths:
petNotFound: petNotFound:
value: { code: 40401, message: 宠物不存在, data: null } value: { code: 40401, message: 宠物不存在, data: null }
mediaNotFound: mediaNotFound:
value: { code: 40405, message: 媒体不存在, data: null } value: { code: 40405, message: 媒体资源不存在, data: null }
'409': '409':
$ref: '#/components/responses/VersionConflict' $ref: '#/components/responses/VersionConflict'
'422': '422':
@@ -1348,6 +1492,35 @@ paths:
'401': '401':
$ref: '#/components/responses/AccessTokenInvalid' $ref: '#/components/responses/AccessTokenInvalid'
/api/v1/me/community-stats:
get:
tags: [posts]
summary: 我的社区数字(获赞总数 / 作品数)
description: |
主体恒为 token 里的调用者:无查询参数、无路径参数,**路径上没有 userId**——
「查不到别人的获赞」不靠权限判断,而是入口本身不存在。
- **统计集合 = 本人的、`status='published'` 的、`deleted_at IS NULL` 的帖**。
草稿不计(尚非作品,且未发布不可被赞);软删不计(删帖即撤回其数字,与
`/me/posts`、Feed 的可见性一致);运营态 hidden/archived 不计(对所有人不可见,
含作者本人);他人帖自然不计。
- **自己赞自己计入**——与帖子详情页的 `likeCount` 保持同一口径,两处数字必须能对上。
- `receivedLikeCount` = 该集合的 `like_count` 之和(读侧实时聚合,读的是写侧同事务
维护的帖级冗余列,故为精确值而非估算;ADR-022 不引入按人累计的冗余列)。
- **空数据返回 `0` 而非 null**,且**永不 404**:任何已认证用户都有 stats。
operationId: getMyCommunityStats
security:
- bearerAuth: []
responses:
'200':
description: 我的获赞总数与作品数
content:
application/json:
schema:
$ref: '#/components/schemas/CommunityStatsEnvelope'
'401':
$ref: '#/components/responses/AccessTokenInvalid'
/api/v1/feed: /api/v1/feed:
get: get:
tags: [feed] tags: [feed]
@@ -1812,8 +1985,9 @@ components:
value: { code: 40402, message: 记录不存在, data: null } value: { code: 40402, message: 记录不存在, data: null }
PetWriteDenied: PetWriteDenied:
description: | description: |
对可见宠物无相应操作权限(code 40300):viewer 写记录、caregiver/viewer 对可见宠物无相应操作权限(code 40300):viewer 写记录或改头像、caregiver/viewer
宠物档案。仅发给对宠物「可见」的调用者,不泄露新信息 宠物档案的资料字段(caregiver 仅改 `avatarAssetId` 时允许,M3.5 按字段分档)
仅发给对宠物「可见」的调用者,不泄露新信息。
content: content:
application/json: application/json:
schema: schema:
@@ -1854,14 +2028,16 @@ components:
commentNotFound: commentNotFound:
value: { code: 40404, message: 评论不存在, data: null } value: { code: 40404, message: 评论不存在, data: null }
MediaNotFound: MediaNotFound:
description: asset 不存在、非本人所有或已删(code 40405,防枚举合并) description: |
asset 不存在、非本人所有、已删,或**用途与引用场景不符**(code 40405,防枚举合并)。
用途不符即「从头像域看,一张帖子配图不是头像」;两种头像用途亦互不通用。
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorEnvelope' $ref: '#/components/schemas/ErrorEnvelope'
examples: examples:
mediaNotFound: mediaNotFound:
value: { code: 40405, message: 媒体不存在, data: null } value: { code: 40405, message: 媒体资源不存在, data: null }
UserNotFound: UserNotFound:
description: 目标用户不存在或已注销(code 40406,合并不泄露成因) description: 目标用户不存在或已注销(code 40406,合并不泄露成因)
content: content:
@@ -2001,8 +2177,10 @@ components:
Me: Me:
type: object type: object
description: 当前用户资料(冻结契约,恰好这 4 个字段) description: |
required: [userId, username, createdAt] 本人资料(`GET` 与 `PATCH /api/v1/me` 的统一响应形态,冻结契约恰好这 6 个字段)。
**不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 `avatarUrl != null`。
required: [userId, username, nickname, avatarUrl, createdAt]
properties: properties:
userId: userId:
type: string type: string
@@ -2011,16 +2189,65 @@ components:
username: username:
type: string type: string
example: demo_user example: demo_user
nickname:
type: string
nullable: true
minLength: 1
maxLength: 32
description: |
昵称,**DB 原值**;未设置为 null(键恒在)。长度按**码点**计 1~32(btrim 后)。
**本端点不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定固化成
真实数据;本人视角的展示回退由客户端做 `nickname ?? username`,他人视角的
回退在 `/internal/users/profiles`SQL COALESCE,不属本公开契约)。
不设唯一约束(ADR-022,允许重名)。
example: 小柴
phone: phone:
type: string type: string
nullable: true nullable: true
description: E.164;未绑定时为 null description: E.164;未绑定时为 null
example: '+8613800138000' example: '+8613800138000'
avatarUrl:
type: string
nullable: true
description: |
头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签,
**客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。
无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。
example: https://minio.example.com/patbond-media/user_avatar/2026/09/019212aa…?X-Amz-Signature=…
createdAt: createdAt:
type: string type: string
format: date-time format: date-time
example: '2026-09-04T04:05:06.789Z' example: '2026-09-04T04:05:06.789Z'
UpdateMeRequest:
type: object
description: |
本人资料部分更新(**三态语义**,与 pets 域 M2 的两态刻意不同):
**键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。
两字段都未出现(含只带未声明字段)为**空 patch**,答 400/40000 而非静默 200。
无必填字段、无 `version` 乐观锁、无 `Idempotency-Key`(同 body 重放天然幂等)。
properties:
nickname:
type: string
nullable: true
minLength: 1
maxLength: 32
description: |
昵称;btrim 后长度按**码点**计须在 1~32,否则 400/40000。
显式 `null` = **清空昵称**;纯空白或空串是 400/40000,**不是隐式清空**
(清空只留显式 null 一条路,否则「误提交空格」与「想删昵称」无法区分)。
example: 小柴
avatarAssetId:
type: string
format: uuid
nullable: true
description: |
头像 asset ID(两步上传的产物,`purpose` 须为 `user_avatar`)。
显式 `null` = **清除头像**。校验:不存在/非本人/已删/用途不符 404/40405
本人且用途相符但 uploading/failed 422/42203;非法 UUID 400/40000。
**响应不回显该字段**(只写不读)。
example: 019212bb-0000-7000-8000-000000000009
AuthTokenEnvelope: AuthTokenEnvelope:
type: object type: object
required: [code, message] required: [code, message]
@@ -2226,7 +2453,8 @@ components:
`breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed); `breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed);
`breedDisplayName` 由品种字典解出,随 breedId 存在。 `breedDisplayName` 由品种字典解出,随 breedId 存在。
软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的 软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的
status 永不出现 deleted。`avatarAssetId` 不出现在 M2 契约(ADR-010)。 status 永不出现 deleted。头像以 `avatarUrl` 的现签形式返回,
**`avatarAssetId` 不外露**(只写不读,写入口为 `PATCH /api/v1/pets/{petId}`)。
required: required:
- id - id
- name - name
@@ -2234,6 +2462,7 @@ components:
- sex - sex
- birthDateEstimated - birthDateEstimated
- status - status
- avatarUrl
- myRole - myRole
- createdAt - createdAt
- updatedAt - updatedAt
@@ -2294,6 +2523,15 @@ components:
type: string type: string
enum: [active, lost, deceased, archived] enum: [active, lost, deceased, archived]
description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401) description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401)
avatarUrl:
type: string
nullable: true
description: |
宠物头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签,
**客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。
无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。
asset 事后退出 ready 时转 null 而引用保留(读请求不做写副作用)。
example: https://minio.example.com/patbond-media/pet_avatar/2026/09/019212cc…?X-Amz-Signature=…
myRole: myRole:
type: string type: string
enum: [owner, caregiver, viewer] enum: [owner, caregiver, viewer]
@@ -2351,14 +2589,18 @@ components:
UpdatePetRequest: UpdatePetRequest:
type: object type: object
description: | description: |
部分更新:缺席字段不变;不支持清空回 null。例外:品种对 部分更新:缺席字段不变;资料字段不支持清空回 null。例外:品种对
breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。 breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。
species 不可改(不在请求体)。 species 不可改(不在请求体)。
**`avatarAssetId` 是本 schema 唯一的三态字段**(M3.5):键缺省 = 不改;
键出现且为 `null` = 清除头像;键出现且有值 = 设置。其余字段保持 M2 两态语义。
权限档按本次触及的字段决定(仅头像 → WRITE;触及资料字段 → MANAGE;混合取更严),
见端点描述。
required: [version] required: [version]
properties: properties:
version: version:
type: integer type: integer
description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902 description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902;即便只改头像也必带
name: name:
type: string type: string
minLength: 1 minLength: 1
@@ -2393,6 +2635,16 @@ components:
type: string type: string
enum: [active, lost, deceased, archived] enum: [active, lost, deceased, archived]
description: 状态流转;deleted 不可经 PATCH 设置(400/40000 description: 状态流转;deleted 不可经 PATCH 设置(400/40000
avatarAssetId:
type: string
format: uuid
nullable: true
description: |
宠物头像 asset ID(两步上传的产物,`purpose` 须为 `pet_avatar`)。
**三态**:缺省 = 不改;显式 `null` = 清除头像;给值 = 设置。校验:不存在/
非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203
非法 UUID 400/40000。**响应不回显该字段**(只写不读,读取见 `Pet.avatarUrl`)。
example: 019212cc-0000-7000-8000-00000000000a
PetEnvelope: PetEnvelope:
type: object type: object
@@ -3205,10 +3457,13 @@ components:
description: M3 仅 imageADR-018 视频后置;video/document 为向后新增枚举预留) description: M3 仅 imageADR-018 视频后置;video/document 为向后新增枚举预留)
purpose: purpose:
type: string type: string
enum: [post_image] enum: [post_image, user_avatar, pet_avatar]
description: | description: |
用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩 用途白名单(服务端配置项 `patbond.media.allowed-purposes`,决定 objectKey 前缀
user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展) `<purpose>/yyyy/MM/{assetId}`)。M3.5 起为三值:`post_image`(帖子配图)、
`user_avatar`(用户头像)、`pet_avatar`(宠物头像)——相对 M3 的纯枚举追加。
**用途即引用侧的类型检查**:引用时校验 `purpose` 相符,故帖图不能当头像、
两种头像也互不通用(不符者 404/40405)。白名单外的取值 400/40000。
mimeType: mimeType:
type: string type: string
enum: [image/jpeg, image/png, image/webp] enum: [image/jpeg, image/png, image/webp]
@@ -3857,3 +4112,36 @@ components:
example: success example: success
data: data:
$ref: '#/components/schemas/FollowStats' $ref: '#/components/schemas/FollowStats'
CommunityStats:
type: object
description: |
调用者本人的社区数字(M3.5)。两数同一集合:本人的、`status='published'` 的、
未软删的帖(草稿 / 软删 / hidden / archived 均不计)。读侧实时聚合,无冗余计数列。
required: [receivedLikeCount, publishedPostCount]
properties:
receivedLikeCount:
type: integer
format: int64
description: |
获赞总数 = 该集合的 `like_count` 之和(写侧同事务维护的帖级冗余列,精确值)。
**自己赞自己计入**,与帖子详情的 `likeCount` 同一口径。空数据为 0,非 null。
example: 128
publishedPostCount:
type: integer
format: int64
description: 作品数 = 该集合的帖子数。空数据为 0,非 null。
example: 12
CommunityStatsEnvelope:
type: object
required: [code, message, data]
properties:
code:
type: integer
enum: [0]
message:
type: string
example: success
data:
$ref: '#/components/schemas/CommunityStats'