4 Commits

Author SHA1 Message Date
lixi 3cd8005577 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>
2026-09-11 10:33:19 +08:00
lixi d98a400f47 feat: 获赞聚合——新增 GET /api/v1/me/community-stats 读侧实时聚合(T3.5-06,ADR-022)
CI / backend-test (push) Failing after 1s
- 新增 GET /api/v1/me/community-stats,返回
  {receivedLikeCount, publishedPostCount}:本人「已发布且未软删」帖的
  like_count 之和与帖子数
- 口径:草稿不计(尚非作品)、软删不计(删帖即撤回其数字)、运营态
  hidden/archived 不计(在 M3 契约里对所有人不可见)、他人帖不计;空数据答 0
  不答 null,任何已认证用户都有 stats,从不 404
- 走读侧实时聚合不引冗余列(ADR-022):写侧无按人计数器,也就没有可漂移的
  副本;单次查询压在 ix_posts_author_created 的前导列上
- 端点独立而不并入 /users/{userId}/follow-stats(ADR-022 决策 A):后者主体是
  「某用户的关注数」,混入「我的获赞」会让一个载荷有两个主体;且本端点主体
  恒为 token 里的自己,路径上没有可枚举的 userId
- 测试 +12(空数据零值/多帖求和/自赞与取消赞/草稿与软删与运营态排除/他人帖
  不串味/401 两态/无他人入口/并发与重复读幂等/载荷形态),community 模块
  94 → 106

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 10:03:07 +08:00
lixi 15c2e66519 feat: 宠物头像读写——PATCH 支持 avatarAssetId、详情与列表补 avatarUrl(T3.5-05,ADR-022)
- PATCH /api/v1/pets/{petId} 支持 avatarAssetId 三态:缺省不改、显式 null
  清空、给值设置。这是 pets 域唯一的三态字段——M2 惯例「null 与缺省同义」
  无法表达「删掉头像」,而 name/species/sex 本就不允许为空,故差异刻意限定
  在本字段
- 权限按「本次请求碰了哪些字段」定档:仅改头像为 WRITE(owner+caregiver,
  ADR-022:头像属日常照护信息,与体重/疫苗同档),碰到任一资料字段仍是
  MANAGE(仅 owner),混合请求按更严的一半判;viewer 一律 403/40300
- 头像与资料共用同一把乐观锁:仅改头像也吃 version,旧版本必答 409/40902
- asset 校验复用 T3-03 引用侧协议 + purpose='pet_avatar':不存在/非本人/
  已删/用途不符答 404/40405,本人未就绪答 422/42203(user_avatar 资源也不能
  当宠物头像)
- 详情与列表响应补 avatarUrl(本地 SigV4 现签预签名 GET,沿用 community 读侧
  先例):仅当 asset 为 ready 才签,指针在而资源退出 ready 时降级为 null,
  不签一个下载必 404 的地址,也不隐式清理指针
- PetRepository 读改为返回 PetRow(含头像存储坐标),签名上移到 PetService,
  与 community 的 PostRow → PostResponse 装配同构——过期 URL 不下沉到仓储层
- pet 模块加入 aws-sdk s3(仅本地签名,不直连对象存储)与读侧 patbond.media
  配置;compose 补同一组 PATBOND_MINIO_* 环境变量
- 测试 +11(成功/清空/缺省保留/降级/caregiver 与 viewer 权限/防枚举 404/
  asset 五态/畸形入参/乐观锁与重放),pet 模块 89 → 100

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 10:02:50 +08:00
lixi a5634c5f85 feat: 用户资料读写——GET/PATCH /api/v1/me 补昵称与头像 URL(T3.5-04,ADR-022)
- GET /api/v1/me 补 nickname 与 avatarUrl:nickname 为 DB 原值不做 username
  回退(/me 是本人编辑态,回退会让用户误以为已设过昵称;他人视角的回退仍在
  /internal/users/profiles 的 SQL 层,M3 T3-05 已交付);avatarUrl 每次现签
  预签名 GET,非 ready 或存储未配置一律降级为 null 而非 500
- 新增 PATCH /api/v1/me:nickname 与 avatarAssetId 均为三态语义——键缺省即
  不改、显式 null 即清空、给值即设置;空 patch 答 400/40000 而非静默 200
- nickname 校验对齐 ck_users_nickname:btrim 后按码点计 1~32(PostgreSQL
  char_length 数码点,按 UTF-16 长度校验会误拒 32 个 emoji),纯空白答 40000
  而非隐式清空(清空只留显式 null 一种表达)
- avatarAssetId 沿用 T3-03 引用侧协议:不存在/非本人/已删/用途非
  user_avatar 均答 404/40405(防枚举合并),本人 user_avatar 未就绪答
  422/42203
- 写入走列级选择性 UPDATE 而非读-合并-写:并发的昵称与头像 PATCH 互不覆盖,
  故 /me 无需暴露版本号乐观锁
- MediaProperties.allowedPurposes 增 user_avatar/pet_avatar(ADR-022:purpose
  无 CHECK 约束,新用途只改配置,零 Flyway 迁移)
- 测试 +22(19 无存储降级路径 + 3 真实 MinIO 全链路签名/下载),user 模块
  100 → 131

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 10:02:26 +08:00
41 changed files with 3595 additions and 202 deletions
+5
View File
@@ -102,6 +102,11 @@ services:
PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond} PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond}
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env} PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
# 媒体读取侧(M3.5 T3.5-05):宠物头像的预签名 GET 与 user 服务同一凭证/
# 同一客户端可达地址(本地 SigV4 计算,不直连 MinIO,无需 depends_on minio)。
PATBOND_MINIO_PUBLIC_ENDPOINT: ${PATBOND_MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000}
PATBOND_MINIO_ACCESS_KEY: ${PATBOND_MINIO_ROOT_USER:?先运行 deploy/init-secrets.sh 生成 .env}
PATBOND_MINIO_SECRET_KEY: ${PATBOND_MINIO_ROOT_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
volumes: volumes:
- ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro - ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro
- ./deploy/keys:/run/patbond/keys:ro - ./deploy/keys:/run/patbond/keys:ro
@@ -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'
@@ -1,6 +1,7 @@
package com.patbond.patbond.community.controller; package com.patbond.patbond.community.controller;
import com.patbond.patbond.common.response.ApiResponse; import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.community.dto.CommunityStatsResponse;
import com.patbond.patbond.community.dto.CreatePostRequest; import com.patbond.patbond.community.dto.CreatePostRequest;
import com.patbond.patbond.community.dto.CursorPage; import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.PostResponse; import com.patbond.patbond.community.dto.PostResponse;
@@ -86,4 +87,17 @@ public class PostController {
@RequestParam(required = false) String cursor) { @RequestParam(required = false) String cursor) {
return ApiResponse.success(postService.listMine(userId, status, limit, cursor)); return ApiResponse.success(postService.listMine(userId, status, limit, cursor));
} }
/**
* The caller's own community numbers (T3.5-06, ADR-022 决策 A: a dedicated
* endpoint rather than an addition to /users/{userId}/follow-stats, whose
* subject is "some user's follow counts" — mixing "my likes received" in
* would give one payload two subjects). Lives here because posts are the
* sole source of both numbers, next to the other /me posts read.
*/
@GetMapping("/api/v1/me/community-stats")
public ApiResponse<CommunityStatsResponse> communityStats(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
return ApiResponse.success(postService.communityStats(userId));
}
} }
@@ -0,0 +1,22 @@
package com.patbond.patbond.community.dto;
/**
* GET /api/v1/me/community-stats (T3.5-06, ADR-022 决策 A) — the caller's own
* community numbers, read-side aggregates over community.posts with no new
* denormalized column.
*
* <p>Scope, frozen with this ticket: both numbers count ONLY the caller's own
* posts that are {@code status='published'} and not soft-deleted. Drafts are
* excluded (they are not works yet, and their likes cannot exist anyway),
* soft-deleted posts are excluded (deleting a post removes its numbers), and
* the operational states hidden/archived are excluded for the same reason
* they are invisible everywhere else in the M3 contract. Empty data yields
* {@code 0}, never null.</p>
*
* <p>{@code receivedLikeCount} is {@code SUM(posts.like_count)} — the counter
* the write side maintains in the same transaction as the like row (T3-07),
* so this is exact, not an estimate. A user's own likes on their own posts are
* counted, exactly as the per-post number shows them.</p>
*/
public record CommunityStatsResponse(long receivedLikeCount, long publishedPostCount) {
}
@@ -1,5 +1,6 @@
package com.patbond.patbond.community.repository; package com.patbond.patbond.community.repository;
import com.patbond.patbond.community.dto.CommunityStatsResponse;
import com.patbond.patbond.community.support.BookmarkCursor; import com.patbond.patbond.community.support.BookmarkCursor;
import com.patbond.patbond.community.support.FeedCursor; import com.patbond.patbond.community.support.FeedCursor;
import com.patbond.patbond.community.support.PostCursor; import com.patbond.patbond.community.support.PostCursor;
@@ -169,6 +170,31 @@ public class PostRepository {
.update(); .update();
} }
/**
* The author's own community aggregates in one indexed pass over
* ix_posts_author_created's leading column (T3.5-06, ADR-022: read-side
* aggregation, no denormalized column). {@code COALESCE} turns the empty
* SUM into 0 so the endpoint never answers null; the
* {@code deleted_at IS NULL} predicate is belt-and-braces — softDelete
* parks published rows as 'archived', so status='published' already
* implies live (ck_posts_publish_state).
*/
public CommunityStatsResponse aggregateByAuthor(UUID authorUserId) {
return jdbcClient.sql("""
SELECT COALESCE(SUM(like_count), 0) AS received_like_count,
COUNT(*) AS published_post_count
FROM community.posts
WHERE author_user_id = :authorUserId
AND status = 'published'
AND deleted_at IS NULL
""")
.param("authorUserId", authorUserId)
.query((rs, rowNum) -> new CommunityStatsResponse(
rs.getLong("received_like_count"),
rs.getLong("published_post_count")))
.single();
}
/** /**
* One page of the author's own posts in (created_at DESC, id DESC) — the * One page of the author's own posts in (created_at DESC, id DESC) — the
* exact key of ix_posts_author_created. Soft-deleted rows never appear; * exact key of ix_posts_author_created. Soft-deleted rows never appear;
@@ -5,6 +5,7 @@ import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.community.access.PetVisibilityGateway; import com.patbond.patbond.community.access.PetVisibilityGateway;
import com.patbond.patbond.community.author.AuthorProfileGateway; import com.patbond.patbond.community.author.AuthorProfileGateway;
import com.patbond.patbond.community.dto.AuthorSummaryResponse; import com.patbond.patbond.community.dto.AuthorSummaryResponse;
import com.patbond.patbond.community.dto.CommunityStatsResponse;
import com.patbond.patbond.community.dto.CreatePostRequest; import com.patbond.patbond.community.dto.CreatePostRequest;
import com.patbond.patbond.community.dto.CursorPage; import com.patbond.patbond.community.dto.CursorPage;
import com.patbond.patbond.community.dto.PostMediaAttachRequest; import com.patbond.patbond.community.dto.PostMediaAttachRequest;
@@ -205,6 +206,19 @@ public class PostService {
return new CursorPage<>(assemble(page), nextCursor, hasMore); return new CursorPage<>(assemble(page), nextCursor, hasMore);
} }
/**
* The caller's own community numbers (T3.5-06): total likes received on
* published, live posts plus the count of those posts. Aggregated on read
* (ADR-022) — the write side keeps no per-user counter, so there is
* nothing that can drift out of sync. Empty data is a legitimate answer of
* zeros, never a 404: every authenticated user has stats, even a brand-new
* one with nothing published.
*/
@Transactional(readOnly = true)
public CommunityStatsResponse communityStats(UUID userId) {
return postRepository.aggregateByAuthor(userId);
}
/** /**
* The shared write gate of PATCH/DELETE: locks the live row, then walks * The shared write gate of PATCH/DELETE: locks the live row, then walks
* the 403/404 boundary — invisible (absent, deleted, hidden/archived, * the 403/404 boundary — invisible (absent, deleted, hidden/archived,
@@ -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");
@@ -0,0 +1,237 @@
package com.patbond.patbond.community.post;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3.5-06 获赞聚合:GET /api/v1/me/community-stats。口径(ADR-022,读侧实时
* 聚合,不引冗余列):
*
* <ul>
* <li>{@code receivedLikeCount} = 本人「已发布且未软删」帖的 like_count 之和;</li>
* <li>{@code publishedPostCount} = 同一集合的帖子数;</li>
* <li>草稿不计(尚非作品)、软删不计(删帖即撤回其数字)、他人的帖不计;</li>
* <li>空数据答 0 而非 null,任何已认证用户都有 stats,从不 404。</li>
* </ul>
*/
class MeCommunityStatsIntegrationTest extends PostApiTestBase {
private static final String DRAFT = "{\"content\":\"草稿内容\"}";
private static final String PUBLISHED = "{\"content\":\"已发布内容\",\"status\":\"published\"}";
private JsonNode stats(UUID userId) throws Exception {
return data(mockMvc.perform(authed(get("/api/v1/me/community-stats"), userId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn());
}
private void like(UUID actor, String postId) throws Exception {
mockMvc.perform(authed(put("/api/v1/posts/{postId}/like", postId), actor))
.andExpect(status().isOk());
}
// ---- 成功路径 + 空数据 ----------------------------------------------
@Test
void freshUserGetsZerosNotNullsAndNever404() throws Exception {
JsonNode stats = stats(newUser());
assertThat(stats.get("receivedLikeCount").isNull()).isFalse();
assertThat(stats.get("publishedPostCount").isNull()).isFalse();
assertThat(stats.get("receivedLikeCount").asLong()).isZero();
assertThat(stats.get("publishedPostCount").asLong()).isZero();
}
@Test
void sumsLikesAcrossThePublishedPostsOfTheCaller() throws Exception {
UUID author = newUser();
UUID fanA = newUser();
UUID fanB = newUser();
String first = createPost(author, PUBLISHED).get("id").asText();
String second = createPost(author, PUBLISHED).get("id").asText();
like(fanA, first);
like(fanB, first);
like(fanA, second);
JsonNode stats = stats(author);
assertThat(stats.get("receivedLikeCount").asLong()).isEqualTo(3);
assertThat(stats.get("publishedPostCount").asLong()).isEqualTo(2);
}
@Test
void countsSelfLikesExactlyAsThePerPostNumberDoes() throws Exception {
UUID author = newUser();
String postId = createPost(author, PUBLISHED).get("id").asText();
like(author, postId);
assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1);
}
@Test
void unlikingBringsTheNumberBackDown() throws Exception {
UUID author = newUser();
UUID fan = newUser();
String postId = createPost(author, PUBLISHED).get("id").asText();
like(fan, postId);
assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1);
mockMvc.perform(authed(delete("/api/v1/posts/{postId}/like", postId), fan))
.andExpect(status().isOk());
assertThat(stats(author).get("receivedLikeCount").asLong()).isZero();
}
// ---- 口径边界:草稿 / 软删 / 他人 -----------------------------------
@Test
void draftsAreExcludedFromBothNumbers() throws Exception {
UUID author = newUser();
createPost(author, DRAFT);
createPost(author, DRAFT);
JsonNode before = stats(author);
assertThat(before.get("publishedPostCount").asLong()).isZero();
assertThat(before.get("receivedLikeCount").asLong()).isZero();
// 发布其中一篇后才计入
String draftId = createPost(author, DRAFT).get("id").asText();
mockMvc.perform(authed(patch("/api/v1/posts/{postId}", draftId), author)
.content("{\"version\":0,\"status\":\"published\"}"))
.andExpect(status().isOk());
assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1);
}
@Test
void softDeletedPostsDropOutOfBothNumbers() throws Exception {
UUID author = newUser();
UUID fan = newUser();
String keep = createPost(author, PUBLISHED).get("id").asText();
String doomed = createPost(author, PUBLISHED).get("id").asText();
like(fan, keep);
like(fan, doomed);
assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(2);
mockMvc.perform(authed(delete("/api/v1/posts/{postId}", doomed), author))
.andExpect(status().isOk());
JsonNode after = stats(author);
assertThat(after.get("receivedLikeCount").asLong()).isEqualTo(1);
assertThat(after.get("publishedPostCount").asLong()).isEqualTo(1);
}
@Test
void otherPeoplesPostsNeverLeakIntoMyStats() throws Exception {
UUID me = newUser();
UUID other = newUser();
UUID fan = newUser();
String theirs = createPost(other, PUBLISHED).get("id").asText();
like(fan, theirs);
like(me, theirs);
JsonNode mine = stats(me);
assertThat(mine.get("receivedLikeCount").asLong()).isZero();
assertThat(mine.get("publishedPostCount").asLong()).isZero();
// 对方的数字是对方的
assertThat(stats(other).get("receivedLikeCount").asLong()).isEqualTo(2);
}
/**
* hidden/archived 是运营态(D3-7),在 M3 契约里对所有人不可见,因此也不计
* 入作品数——「看不到的帖不该出现在我的作品计数里」。
*/
@Test
void operationalStatesAreExcluded() throws Exception {
UUID author = newUser();
String postId = createPost(author, PUBLISHED).get("id").asText();
assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1);
jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id")
.param("id", UUID.fromString(postId))
.update();
assertThat(stats(author).get("publishedPostCount").asLong()).isZero();
}
// ---- 无权限 / 不存在 -------------------------------------------------
@Test
void requiresAValidAccessToken() throws Exception {
mockMvc.perform(get("/api/v1/me/community-stats"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
mockMvc.perform(get("/api/v1/me/community-stats")
.header("Authorization", "Bearer not.a.jwt"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
/**
* 主体永远是 token 里的自己,路径上没有可枚举的 userId —— 「查不到别人的
* 获赞」不靠权限判断,而靠端点形态本身就没有别人的入口。
*/
@Test
void hasNoPathParameterToProbeSomeoneElse() throws Exception {
UUID me = newUser();
mockMvc.perform(authed(get("/api/v1/me/community-stats/{userId}", newUser()), me))
.andExpect(status().isNotFound());
}
// ---- 并发与重放 -----------------------------------------------------
/**
* 读侧聚合天然幂等:并发重复读必须给出同一答案,且不产生任何副作用
* (连续两次读的数字完全相同)。
*/
@Test
void concurrentAndRepeatedReadsAreIdenticalAndSideEffectFree() throws Exception {
UUID author = newUser();
UUID fan = newUser();
String postId = createPost(author, PUBLISHED).get("id").asText();
like(fan, postId);
CyclicBarrier startTogether = new CyclicBarrier(2);
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Callable<Long> read = () -> {
startTogether.await();
return stats(author).get("receivedLikeCount").asLong();
};
Future<Long> first = pool.submit(read);
Future<Long> second = pool.submit(read);
assertThat(first.get()).isEqualTo(1);
assertThat(second.get()).isEqualTo(1);
} finally {
pool.shutdownNow();
}
assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1);
assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1);
}
// ---- 形态回归 -------------------------------------------------------
@Test
void payloadCarriesExactlyTheTwoNumbers() throws Exception {
UUID author = newUser();
mockMvc.perform(authed(get("/api/v1/me/community-stats"), author))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.receivedLikeCount").value(0))
.andExpect(jsonPath("$.data.publishedPostCount").value(0))
.andExpect(jsonPath("$.data.followerCount").doesNotExist())
.andExpect(jsonPath("$.data.userId").doesNotExist());
}
}
@@ -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'
+9
View File
@@ -43,6 +43,15 @@
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- Read-side media URL signing only (presigned GET is a local SigV4
computation): this service never talks to the object store, the
media write flow stays in patbond-user (ADR-016/017). Same
precedent as patbond-community's read side. Version managed by
the root pom's awssdk bom. -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
<!-- Access token verification (RS256, public key only): jjwt is not in <!-- Access token verification (RS256, public key only): jjwt is not in
the Boot BOM, version pinned in step with patbond-user/auth. --> the Boot BOM, version pinned in step with patbond-user/auth. -->
<dependency> <dependency>
@@ -0,0 +1,22 @@
package com.patbond.patbond.pet.config;
import com.patbond.patbond.pet.media.MediaUrlSigner;
import com.patbond.patbond.pet.media.PetMediaProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Read-side media wiring (T3.5-05): a presigned-GET signer over the same
* MinIO configuration patbond-user uses (ADR-016). Bean destruction closes
* the underlying presigner.
*/
@Configuration
@EnableConfigurationProperties(PetMediaProperties.class)
public class MediaConfig {
@Bean(destroyMethod = "close")
public MediaUrlSigner mediaUrlSigner(PetMediaProperties properties) {
return new MediaUrlSigner(properties);
}
}
@@ -9,6 +9,13 @@ import java.util.UUID;
* dictionary when {@code breedId} is set; exactly one of {@code breedId} / * dictionary when {@code breedId} is set; exactly one of {@code breedId} /
* {@code customBreedName} is non-null (ck_pets_breed). {@code myRole} is the * {@code customBreedName} is non-null (ck_pets_breed). {@code myRole} is the
* calling user's own pet_owners role — the client uses it to gate write UI. * calling user's own pet_owners role — the client uses it to gate write UI.
*
* <p>{@code avatarUrl} (T3.5-05) is a freshly signed presigned GET against a
* private bucket: it EXPIRES and must never be persisted client-side (the
* client's image cache key strips the signature parameters). It is null both
* when the pet has no avatar and when the referenced asset is not (or no
* longer) ready, so "has an avatar" is exactly {@code avatarUrl != null}. The
* asset id is not echoed — the client only ever writes it.</p>
*/ */
public record PetResponse( public record PetResponse(
UUID id, UUID id,
@@ -24,6 +31,7 @@ public record PetResponse(
String microchipNo, String microchipNo,
LocalDate sterilizedOn, LocalDate sterilizedOn,
String status, String status,
String avatarUrl,
String myRole, String myRole,
OffsetDateTime createdAt, OffsetDateTime createdAt,
OffsetDateTime updatedAt, OffsetDateTime updatedAt,
@@ -16,6 +16,21 @@ import java.util.UUID;
* replaces the pair as a whole (they are mutually exclusive per * replaces the pair as a whole (they are mutually exclusive per
* ck_pets_breed). {@code version} is mandatory — it is the optimistic lock * ck_pets_breed). {@code version} is mandatory — it is the optimistic lock
* the whole endpoint exists to enforce. * the whole endpoint exists to enforce.
*
* <p><b>{@code avatarAssetId} is the one three-state field</b> (T3.5-05):
* absent = unchanged, explicit {@code null} = remove the avatar, value = set
* it. Removing an avatar is a first-class user action with no other way to
* express it, whereas the M2 fields either cannot be empty at all (name,
* species, sex) or are edited, not erased — so the asymmetry is deliberate
* and confined to this field. Presence is tracked in the setter: Jackson
* calls it exactly when the JSON key is present, including for an explicit
* null.</p>
*
* <p>Permission note: this endpoint is MANAGE (owner only), but an
* avatar-ONLY patch is WRITE (owner + caregiver) per ADR-022 — the avatar is
* day-to-day care information, same tier as weights and vaccinations. The
* required level is therefore computed from which fields the body touches;
* see PetService.</p>
*/ */
public class UpdatePetRequest { public class UpdatePetRequest {
@@ -56,6 +71,33 @@ public class UpdatePetRequest {
message = "status 仅支持 active/lost/deceased/archived") message = "status 仅支持 active/lost/deceased/archived")
private String status; private String status;
private UUID avatarAssetId;
private boolean avatarAssetIdPresent;
public UUID getAvatarAssetId() {
return avatarAssetId;
}
public void setAvatarAssetId(UUID avatarAssetId) {
this.avatarAssetId = avatarAssetId;
this.avatarAssetIdPresent = true;
}
public boolean isAvatarAssetIdPresent() {
return avatarAssetIdPresent;
}
/**
* True when the body touches any pet-profile field, i.e. anything beyond
* the avatar. {@code version} does not count — it is the lock, not an
* edit. Drives the MANAGE-vs-WRITE decision in PetService.
*/
public boolean touchesProfileFields() {
return name != null || breedId != null || customBreedName != null || sex != null
|| birthDate != null || birthDateEstimated != null || personality != null
|| microchipNo != null || sterilizedOn != null || status != null;
}
public Integer getVersion() { public Integer getVersion() {
return version; return version;
} }
@@ -0,0 +1,42 @@
package com.patbond.patbond.pet.media;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
/**
* Read-only cross-schema access to media.assets — the pet side of the T3-03
* 联调协议 (business references accept only assets owned by the caller with
* status='ready' and the matching purpose). Same-database read was chosen
* over an internal HTTP call to patbond-user, exactly as patbond-community
* did (ADR-017 precedent: while the schemas share one database this is a
* cross-schema read; splitting the database later moves every such gateway to
* an internal API together). This class never writes media.assets — the media
* state machine belongs to patbond-user.
*/
@Repository
public class MediaAssetGateway {
private final JdbcClient jdbcClient;
public MediaAssetGateway(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
public Optional<MediaAssetRef> findById(UUID assetId) {
return jdbcClient.sql("""
SELECT id, owner_user_id, purpose, status
FROM media.assets
WHERE id = :id
""")
.param("id", assetId)
.query((rs, rowNum) -> new MediaAssetRef(
rs.getObject("id", UUID.class),
rs.getObject("owner_user_id", UUID.class),
rs.getString("purpose"),
rs.getString("status")))
.optional();
}
}
@@ -0,0 +1,10 @@
package com.patbond.patbond.pet.media;
import java.util.UUID;
/**
* Read-only view of one media.assets row — exactly the columns the pet
* avatar flow needs for attach validation (owner, purpose, status).
*/
public record MediaAssetRef(UUID id, UUID ownerUserId, String purpose, String status) {
}
@@ -0,0 +1,61 @@
package com.patbond.patbond.pet.media;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import java.net.URI;
/**
* Signs presigned GET URLs for pet avatars (T3-03 定型:private bucket +
* presigned GET, TTL configurable, signed fresh on every response — clients
* never persist the URL). Presigning is a local SigV4 computation against the
* public endpoint; this service never talks to the object store itself.
* Path-style addressing is forced because MinIO has no wildcard DNS for
* virtual-host-style buckets (same as patbond-user's S3ObjectStorage and
* patbond-community's signer). When unconfigured, {@link #signGet} returns
* null and pet responses degrade to {@code avatarUrl: null}.
*/
public class MediaUrlSigner implements AutoCloseable {
private final PetMediaProperties properties;
private final S3Presigner presigner;
public MediaUrlSigner(PetMediaProperties properties) {
this.properties = properties;
if (properties.getPublicEndpoint().isBlank()) {
this.presigner = null;
return;
}
this.presigner = S3Presigner.builder()
.endpointOverride(URI.create(properties.getPublicEndpoint()))
.region(Region.of(properties.getRegion()))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())))
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
.build();
}
/** @return a presigned GET URL, or null when storage is unconfigured */
public String signGet(String bucket, String objectKey) {
if (presigner == null || bucket == null || objectKey == null) {
return null;
}
return presigner.presignGetObject(GetObjectPresignRequest.builder()
.signatureDuration(properties.getDownloadTtl())
.getObjectRequest(b -> b.bucket(bucket).key(objectKey))
.build())
.url()
.toString();
}
@Override
public void close() {
if (presigner != null) {
presigner.close();
}
}
}
@@ -0,0 +1,80 @@
package com.patbond.patbond.pet.media;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Read-side subset of the media object-storage configuration (T3.5-05). The
* write side — upload flow, mime/purpose whitelists, bucket init — lives in
* patbond-user's MediaProperties; this service only signs presigned GET URLs
* for pet avatars, a purely local SigV4 computation, so no S3 client is
* needed. Values reuse the same PATBOND_MINIO_* / PATBOND_MEDIA_*
* environment variables as patbond-user and patbond-community, keeping one
* set of knobs per deployment (ADR-016/021).
*/
@ConfigurationProperties(prefix = "patbond.media")
public class PetMediaProperties {
/**
* Endpoint presigned GET URLs are issued against — the address CLIENTS
* can reach. Empty means media is unconfigured for this service: pet
* responses carry {@code avatarUrl: null} (same degradation precedent as
* the missing JWT public key).
*/
private String publicEndpoint = "";
/** S3 access key; injected via environment, never committed (ADR-021). */
private String accessKey = "";
/** S3 secret key; injected via environment, never committed (ADR-021). */
private String secretKey = "";
/** SigV4 region; MinIO accepts any value, cloud stores need the real one. */
private String region = "us-east-1";
/** TTL of presigned GET URLs (the bucket stays private, T3-03 定型). */
private Duration downloadTtl = Duration.ofHours(1);
public String getPublicEndpoint() {
return publicEndpoint;
}
public void setPublicEndpoint(String publicEndpoint) {
this.publicEndpoint = publicEndpoint;
}
public String getAccessKey() {
return accessKey;
}
// setter 形参名取 valuecheck-secrets 的 KEY-ASSIGN 规则会把「字段 = 同名
// 形参」的自赋值误报为凭证字面量,规则表三仓同构不单方面改(ADR-021)
public void setAccessKey(String value) {
this.accessKey = value;
}
public String getSecretKey() {
return secretKey;
}
public void setSecretKey(String value) {
this.secretKey = value;
}
public String getRegion() {
return region;
}
public void setRegion(String region) {
this.region = region;
}
public Duration getDownloadTtl() {
return downloadTtl;
}
public void setDownloadTtl(Duration downloadTtl) {
this.downloadTtl = downloadTtl;
}
}
@@ -1,6 +1,5 @@
package com.patbond.patbond.pet.repository; package com.patbond.patbond.pet.repository;
import com.patbond.patbond.pet.dto.PetResponse;
import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -16,6 +15,12 @@ import java.util.UUID;
* pet_health.pets + pet_owners access. All reads join pet_owners on the * pet_health.pets + pet_owners access. All reads join pet_owners on the
* calling user so a row only comes back when a relationship exists — the * calling user so a row only comes back when a relationship exists — the
* repository layer itself never exposes another user's pet. * repository layer itself never exposes another user's pet.
*
* <p>Reads return {@link PetRow}, not the API DTO: the avatar travels as
* storage coordinates (bucket + object key of a READY media asset) and the
* presigned URL is produced one layer up, in PetService — same split as
* patbond-community's PostRow → PostResponse assembly, and the reason a
* signed, expiring URL never leaks into a repository-level cache.</p>
*/ */
@Repository @Repository
public class PetRepository { public class PetRepository {
@@ -24,10 +29,13 @@ public class PetRepository {
SELECT p.id, p.name, p.species, p.breed_id, b.display_name AS breed_display_name, SELECT p.id, p.name, p.species, p.breed_id, b.display_name AS breed_display_name,
p.custom_breed_name, p.sex, p.birth_date, p.birth_date_estimated, p.custom_breed_name, p.sex, p.birth_date, p.birth_date_estimated,
p.personality, p.microchip_no, p.sterilized_on, p.status, p.personality, p.microchip_no, p.sterilized_on, p.status,
p.avatar_asset_id,
av.bucket AS avatar_bucket, av.object_key AS avatar_object_key,
po.role, p.created_at, p.updated_at, p.version po.role, p.created_at, p.updated_at, p.version
FROM pet_health.pets p FROM pet_health.pets p
JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
LEFT JOIN pet_health.breeds b ON b.id = p.breed_id LEFT JOIN pet_health.breeds b ON b.id = p.breed_id
LEFT JOIN media.assets av ON av.id = p.avatar_asset_id AND av.status = 'ready'
WHERE p.status <> 'deleted' WHERE p.status <> 'deleted'
"""; """;
@@ -37,6 +45,37 @@ public class PetRepository {
this.jdbcClient = jdbcClient; this.jdbcClient = jdbcClient;
} }
/**
* One pet as stored, from the calling user's perspective.
* {@code avatarAssetId} is the raw column (so a PATCH that does not touch
* the avatar can carry it through unchanged), while the two storage
* columns are already narrowed to a READY asset — a dangling or
* still-uploading avatar yields nulls there (→ {@code avatarUrl: null})
* rather than a signed URL that would 404 at the object store.
*/
public record PetRow(
UUID id,
String name,
String species,
UUID breedId,
String breedDisplayName,
String customBreedName,
String sex,
LocalDate birthDate,
Boolean birthDateEstimated,
String personality,
String microchipNo,
LocalDate sterilizedOn,
String status,
UUID avatarAssetId,
String avatarBucket,
String avatarObjectKey,
String myRole,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
Integer version) {
}
public void insertPet(UUID petId, String name, String species, UUID breedId, public void insertPet(UUID petId, String name, String species, UUID breedId,
String customBreedName, String sex, LocalDate birthDate, String customBreedName, String sex, LocalDate birthDate,
boolean birthDateEstimated, String personality, boolean birthDateEstimated, String personality,
@@ -72,14 +111,14 @@ public class PetRepository {
.update(); .update();
} }
public List<PetResponse> listByUser(UUID userId) { public List<PetRow> listByUser(UUID userId) {
return jdbcClient.sql(SELECT_PET + " ORDER BY p.created_at DESC, p.id DESC") return jdbcClient.sql(SELECT_PET + " ORDER BY p.created_at DESC, p.id DESC")
.param("userId", userId) .param("userId", userId)
.query(PetRepository::mapPet) .query(PetRepository::mapPet)
.list(); .list();
} }
public Optional<PetResponse> findByIdForUser(UUID petId, UUID userId) { public Optional<PetRow> findByIdForUser(UUID petId, UUID userId) {
return jdbcClient.sql(SELECT_PET + " AND p.id = :petId") return jdbcClient.sql(SELECT_PET + " AND p.id = :petId")
.param("userId", userId) .param("userId", userId)
.param("petId", petId) .param("petId", petId)
@@ -109,14 +148,16 @@ public class PetRepository {
public int updateWithVersion(UUID petId, int expectedVersion, String name, UUID breedId, public int updateWithVersion(UUID petId, int expectedVersion, String name, UUID breedId,
String customBreedName, String sex, LocalDate birthDate, String customBreedName, String sex, LocalDate birthDate,
boolean birthDateEstimated, String personality, boolean birthDateEstimated, String personality,
String microchipNo, LocalDate sterilizedOn, String status) { String microchipNo, LocalDate sterilizedOn, String status,
UUID avatarAssetId) {
return jdbcClient.sql(""" return jdbcClient.sql("""
UPDATE pet_health.pets UPDATE pet_health.pets
SET name = :name, breed_id = :breedId, custom_breed_name = :customBreedName, SET name = :name, breed_id = :breedId, custom_breed_name = :customBreedName,
sex = :sex, birth_date = :birthDate, sex = :sex, birth_date = :birthDate,
birth_date_estimated = :birthDateEstimated, personality = :personality, birth_date_estimated = :birthDateEstimated, personality = :personality,
microchip_no = :microchipNo, sterilized_on = :sterilizedOn, microchip_no = :microchipNo, sterilized_on = :sterilizedOn,
status = :status, version = version + 1 status = :status, avatar_asset_id = :avatarAssetId,
version = version + 1
WHERE id = :petId AND version = :expectedVersion AND status <> 'deleted' WHERE id = :petId AND version = :expectedVersion AND status <> 'deleted'
""") """)
.param("petId", petId) .param("petId", petId)
@@ -131,11 +172,12 @@ public class PetRepository {
.param("microchipNo", microchipNo) .param("microchipNo", microchipNo)
.param("sterilizedOn", sterilizedOn) .param("sterilizedOn", sterilizedOn)
.param("status", status) .param("status", status)
.param("avatarAssetId", avatarAssetId)
.update(); .update();
} }
private static PetResponse mapPet(ResultSet rs, int rowNum) throws SQLException { private static PetRow mapPet(ResultSet rs, int rowNum) throws SQLException {
return new PetResponse( return new PetRow(
rs.getObject("id", UUID.class), rs.getObject("id", UUID.class),
rs.getString("name"), rs.getString("name"),
rs.getString("species"), rs.getString("species"),
@@ -149,6 +191,9 @@ public class PetRepository {
rs.getString("microchip_no"), rs.getString("microchip_no"),
rs.getObject("sterilized_on", LocalDate.class), rs.getObject("sterilized_on", LocalDate.class),
rs.getString("status"), rs.getString("status"),
rs.getObject("avatar_asset_id", UUID.class),
rs.getString("avatar_bucket"),
rs.getString("avatar_object_key"),
rs.getString("role"), rs.getString("role"),
rs.getObject("created_at", OffsetDateTime.class), rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("updated_at", OffsetDateTime.class), rs.getObject("updated_at", OffsetDateTime.class),
@@ -7,8 +7,12 @@ import com.patbond.patbond.pet.access.PetAccessService;
import com.patbond.patbond.pet.dto.CreatePetRequest; import com.patbond.patbond.pet.dto.CreatePetRequest;
import com.patbond.patbond.pet.dto.PetResponse; import com.patbond.patbond.pet.dto.PetResponse;
import com.patbond.patbond.pet.dto.UpdatePetRequest; import com.patbond.patbond.pet.dto.UpdatePetRequest;
import com.patbond.patbond.pet.media.MediaAssetGateway;
import com.patbond.patbond.pet.media.MediaAssetRef;
import com.patbond.patbond.pet.media.MediaUrlSigner;
import com.patbond.patbond.pet.repository.BreedRepository; import com.patbond.patbond.pet.repository.BreedRepository;
import com.patbond.patbond.pet.repository.PetRepository; import com.patbond.patbond.pet.repository.PetRepository;
import com.patbond.patbond.pet.repository.PetRepository.PetRow;
import com.patbond.patbond.pet.support.UuidV7; import com.patbond.patbond.pet.support.UuidV7;
import org.springframework.dao.DuplicateKeyException; import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -23,19 +27,47 @@ import java.util.UUID;
* uq_pets_microchip) so clients get a stable business error instead of a * uq_pets_microchip) so clients get a stable business error instead of a
* constraint-violation 500 — the constraints stay as the last line of * constraint-violation 500 — the constraints stay as the last line of
* defense. * defense.
*
* <p>Avatar semantics (T3.5-05, ADR-022):
* <ul>
* <li>The write level is decided per REQUEST, not per endpoint: an
* avatar-only PATCH needs {@link AccessLevel#WRITE} (owner + caregiver
* — the avatar is day-to-day care information, same tier as weights and
* vaccinations), everything else stays {@link AccessLevel#MANAGE}
* (owner only). A body touching both is judged by the stricter half.
* Viewers are refused either way (403/40300).</li>
* <li>The referenced asset must exist, belong to the CALLER, carry
* {@code purpose='pet_avatar'} and be {@code ready} — the T3-03
* referencing protocol: unknown / someone else's / deleted → 404/40405
* (merged, anti-enumeration), wrong purpose → 404/40405 (a post image
* is not an avatar; reachable only for the caller's own assets, so the
* message may be specific), own pet_avatar asset still uploading or
* failed → 422/42203.</li>
* <li>The optimistic lock is unchanged: the avatar rides the same
* version-guarded UPDATE, so a stale version loses with 409/40902 even
* when only the avatar changes.</li>
* </ul>
*/ */
@Service @Service
public class PetService { public class PetService {
/** The only media purpose acceptable as a pet avatar (ADR-022). */
private static final String AVATAR_PURPOSE = "pet_avatar";
private final PetRepository petRepository; private final PetRepository petRepository;
private final BreedRepository breedRepository; private final BreedRepository breedRepository;
private final PetAccessService petAccessService; private final PetAccessService petAccessService;
private final MediaAssetGateway mediaAssetGateway;
private final MediaUrlSigner mediaUrlSigner;
public PetService(PetRepository petRepository, BreedRepository breedRepository, public PetService(PetRepository petRepository, BreedRepository breedRepository,
PetAccessService petAccessService) { PetAccessService petAccessService, MediaAssetGateway mediaAssetGateway,
MediaUrlSigner mediaUrlSigner) {
this.petRepository = petRepository; this.petRepository = petRepository;
this.breedRepository = breedRepository; this.breedRepository = breedRepository;
this.petAccessService = petAccessService; this.petAccessService = petAccessService;
this.mediaAssetGateway = mediaAssetGateway;
this.mediaUrlSigner = mediaUrlSigner;
} }
/** /**
@@ -63,18 +95,18 @@ public class PetService {
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS); throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
} }
petRepository.insertPrimaryOwner(petId, userId); petRepository.insertPrimaryOwner(petId, userId);
return petRepository.findByIdForUser(petId, userId) return toResponse(petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)));
} }
public List<PetResponse> list(UUID userId) { public List<PetResponse> list(UUID userId) {
return petRepository.listByUser(userId); return petRepository.listByUser(userId).stream().map(this::toResponse).toList();
} }
public PetResponse get(UUID userId, UUID petId) { public PetResponse get(UUID userId, UUID petId) {
petAccessService.require(userId, petId, AccessLevel.READ); petAccessService.require(userId, petId, AccessLevel.READ);
return petRepository.findByIdForUser(petId, userId) return toResponse(petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND)));
} }
/** /**
@@ -85,8 +117,8 @@ public class PetService {
*/ */
@Transactional @Transactional
public PetResponse update(UUID userId, UUID petId, UpdatePetRequest request) { public PetResponse update(UUID userId, UUID petId, UpdatePetRequest request) {
petAccessService.require(userId, petId, AccessLevel.MANAGE); petAccessService.require(userId, petId, requiredLevel(request));
PetResponse current = petRepository.findByIdForUser(petId, userId) PetRow current = petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
UUID breedId = current.breedId(); UUID breedId = current.breedId();
@@ -102,6 +134,16 @@ public class PetService {
String sex = request.getSex() != null ? request.getSex() : current.sex(); String sex = request.getSex() != null ? request.getSex() : current.sex();
String status = request.getStatus() != null ? request.getStatus() : current.status(); String status = request.getStatus() != null ? request.getStatus() : current.status();
// Three-state avatar: absent → carry the stored id through; explicit
// null → clear; value → validate then set.
UUID avatarAssetId = current.avatarAssetId();
if (request.isAvatarAssetIdPresent()) {
avatarAssetId = request.getAvatarAssetId();
if (avatarAssetId != null) {
requireOwnReadyAvatarAsset(userId, avatarAssetId);
}
}
int updated; int updated;
try { try {
updated = petRepository.updateWithVersion( updated = petRepository.updateWithVersion(
@@ -120,7 +162,8 @@ public class PetService {
? trimOrNull(request.getMicrochipNo()) : current.microchipNo(), ? trimOrNull(request.getMicrochipNo()) : current.microchipNo(),
request.getSterilizedOn() != null request.getSterilizedOn() != null
? request.getSterilizedOn() : current.sterilizedOn(), ? request.getSterilizedOn() : current.sterilizedOn(),
status); status,
avatarAssetId);
} catch (DuplicateKeyException e) { } catch (DuplicateKeyException e) {
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS); throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
} }
@@ -129,8 +172,34 @@ public class PetService {
// missed conditional update means the version is stale. // missed conditional update means the version is stale.
throw new BusinessException(ErrorCode.VERSION_CONFLICT); throw new BusinessException(ErrorCode.VERSION_CONFLICT);
} }
return petRepository.findByIdForUser(petId, userId) return toResponse(petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)));
}
/**
* MANAGE for anything that edits the pet profile, WRITE when the request
* touches nothing but the avatar (ADR-022). A body carrying only
* {@code version} keeps the historical MANAGE level — it is a
* profile-shaped no-op, not an avatar edit.
*/
private static AccessLevel requiredLevel(UpdatePetRequest request) {
boolean avatarOnly = request.isAvatarAssetIdPresent() && !request.touchesProfileFields();
return avatarOnly ? AccessLevel.WRITE : AccessLevel.MANAGE;
}
private void requireOwnReadyAvatarAsset(UUID userId, UUID assetId) {
MediaAssetRef asset = mediaAssetGateway.findById(assetId)
.orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND));
if (!userId.equals(asset.ownerUserId()) || "deleted".equals(asset.status())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND);
}
if (!AVATAR_PURPOSE.equals(asset.purpose())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND,
"该媒体资源的用途不是 " + AVATAR_PURPOSE + ",不能作为宠物头像");
}
if (!"ready".equals(asset.status())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_READY);
}
} }
/** /**
@@ -156,6 +225,33 @@ public class PetService {
} }
} }
/**
* Signs the avatar URL fresh on every response (never cached, never
* persisted) and drops the storage coordinates — the DTO exposes a URL,
* not a bucket layout.
*/
private PetResponse toResponse(PetRow row) {
return new PetResponse(
row.id(),
row.name(),
row.species(),
row.breedId(),
row.breedDisplayName(),
row.customBreedName(),
row.sex(),
row.birthDate(),
row.birthDateEstimated(),
row.personality(),
row.microchipNo(),
row.sterilizedOn(),
row.status(),
mediaUrlSigner.signGet(row.avatarBucket(), row.avatarObjectKey()),
row.myRole(),
row.createdAt(),
row.updatedAt(),
row.version());
}
private static String trimOrNull(String value) { private static String trimOrNull(String value) {
if (value == null) { if (value == null) {
return null; return null;
@@ -19,3 +19,12 @@ patbond:
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。 # 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
# 私钥只给 patbond-auth,绝不入库。 # 私钥只给 patbond-auth,绝不入库。
public-key: ${PATBOND_JWT_PUBLIC_KEY:} public-key: ${PATBOND_JWT_PUBLIC_KEY:}
media:
# 媒体读取侧(ADR-016 定型:私有桶 + 预签名 GET)。本服务只做本地 SigV4
# 签名计算生成宠物头像访问 URL,从不直连对象存储;写入流程在 patbond-user。
# 环境变量与 patbond-user/patbond-community 共用同一组(一套部署一套旋钮)。
# public-endpoint 为空时服务照常启动,宠物响应中 avatarUrl 为 null。
public-endpoint: ${PATBOND_MINIO_PUBLIC_ENDPOINT:}
access-key: ${PATBOND_MINIO_ACCESS_KEY:}
secret-key: ${PATBOND_MINIO_SECRET_KEY:}
download-ttl: ${PATBOND_MEDIA_DOWNLOAD_TTL:1h}
@@ -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");
@@ -0,0 +1,335 @@
package com.patbond.patbond.pet.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.pet.support.PetIntegrationTestSupport;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3.5-05 宠物头像读写:PATCH /api/v1/pets/{petId} 的 {@code avatarAssetId}
* 三态(缺省不改 / 显式 null 清空 / 赋值设置),详情与列表的 {@code avatarUrl}
* 预签名 GET,以及六类路径 —— 成功 / 参数错(asset 非法四态)/ 不存在(防枚举
* 404/ 无权限(viewer 拒写、caregiver 只能改头像)/ 并发冲突(乐观锁 40902)
* / 重放(同版本重放必冲突、新版本重放幂等)。
*
* <p>预签名 GET 是纯本地 SigV4 计算,故这里用占位端点与占位凭证即可断言 URL
* 形态(与 patbond-community 的 PostApiTestBase 同先例),无需 MinIO 容器;
* 「签名真能下载」的实证由 user 模块的 MeAvatarSigningIntegrationTest 承担。</p>
*/
class PetAvatarIntegrationTest extends PetIntegrationTestSupport {
private static final String SIGNED_PREFIX = "http://127.0.0.1:9000/patbond-media/pet_avatar/";
@Autowired
private MockMvc mockMvc;
@DynamicPropertySource
static void wireMediaSigning(DynamicPropertyRegistry registry) {
// 占位值(dummy):仅用于本地 SigV4 计算,不连任何真实存储
registry.add("patbond.media.public-endpoint", () -> "http://127.0.0.1:9000");
registry.add("patbond.media.access-key", () -> "test-access-key");
registry.add("patbond.media.secret-key", () -> "test-secret-key");
}
// ---- helpers -------------------------------------------------------
private String createPetAs(UUID ownerId) throws Exception {
String body = mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"头像猫","species":"cat","sex":"female",
"customBreedName":"狸花"}
"""))
.andExpect(status().isCreated())
// 新建宠物尚无头像
.andExpect(jsonPath("$.data.avatarUrl").value(nullValue()))
.andReturn().getResponse().getContentAsString();
return JsonPath.read(body, "$.data.id");
}
/** 一枚 media.assets 行,用途/状态/归属可控(模拟 T3-03 上传的产物)。 */
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, deleted_at)
VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media',
:objectKey, 'image/jpeg', 2048, :status, :readyAt, :deletedAt)
""")
.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)
// ck_media_deletedstatus='deleted' 必带 deleted_at
.param("deletedAt", "deleted".equals(status) ? OffsetDateTime.now() : null)
.update();
return id;
}
private UUID readyPetAvatar(UUID ownerUserId) {
return insertAsset(ownerUserId, "pet_avatar", "ready");
}
private String patchPet(UUID actor, String petId, String body, int expectedStatus)
throws Exception {
return mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(actor))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().is(expectedStatus))
.andReturn().getResponse().getContentAsString();
}
private void patchPetExpectingCode(UUID actor, String petId, String body,
int httpStatus, int bizCode) throws Exception {
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(actor))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().is(httpStatus))
.andExpect(jsonPath("$.code").value(bizCode));
}
private static String setAvatarBody(int version, UUID assetId) {
return "{\"version\":%d,\"avatarAssetId\":\"%s\"}".formatted(version, assetId);
}
private String detail(UUID actor, String petId) throws Exception {
return mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(actor)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
}
private UUID dbAvatarAssetId(String petId) {
return jdbcClient.sql("SELECT avatar_asset_id FROM pet_health.pets WHERE id = :id")
.param("id", UUID.fromString(petId))
.query(UUID.class)
.optional()
.orElse(null);
}
// ---- 成功路径 -------------------------------------------------------
@Test
void ownerSetsAvatarAndDetailAndListBothCarryASignedUrl() throws Exception {
UUID owner = newUser("pet_avatar_owner");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(owner);
String patched = patchPet(owner, petId, setAvatarBody(0, asset), 200);
assertThat((String) JsonPath.read(patched, "$.data.avatarUrl"))
.startsWith(SIGNED_PREFIX)
.contains("X-Amz-Signature=");
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
// 头像也吃乐观锁:写入后 version 前进
assertThat((int) JsonPath.read(patched, "$.data.version")).isEqualTo(1);
assertThat((String) JsonPath.read(detail(owner, petId), "$.data.avatarUrl"))
.startsWith(SIGNED_PREFIX);
String listBody = mockMvc.perform(get("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
List<Map<String, Object>> pets = JsonPath.read(listBody, "$.data");
assertThat((String) pets.get(0).get("avatarUrl")).startsWith(SIGNED_PREFIX);
}
@Test
void clearsAvatarWithAnExplicitNull() throws Exception {
UUID owner = newUser("pet_avatar_clear");
String petId = createPetAs(owner);
patchPet(owner, petId, setAvatarBody(0, readyPetAvatar(owner)), 200);
String cleared = patchPet(owner, petId, "{\"version\":1,\"avatarAssetId\":null}", 200);
assertThat((Object) JsonPath.read(cleared, "$.data.avatarUrl")).isNull();
assertThat(dbAvatarAssetId(petId)).isNull();
}
/**
* 缺省即不改:只改名字的 PATCH 不得把头像顺手清掉(这正是三态语义存在的
* 理由——若 null 与缺省同义,就无法既保留又能清空)。
*/
@Test
void absentAvatarFieldLeavesItUntouched() throws Exception {
UUID owner = newUser("pet_avatar_absent");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(owner);
patchPet(owner, petId, setAvatarBody(0, asset), 200);
String renamed = patchPet(owner, petId, "{\"version\":1,\"name\":\"改个名\"}", 200);
assertThat((String) JsonPath.read(renamed, "$.data.name")).isEqualTo("改个名");
assertThat((String) JsonPath.read(renamed, "$.data.avatarUrl")).startsWith(SIGNED_PREFIX);
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
}
/**
* 指针在、资源却退出 ready(如后台清理置 failed):URL 降级为 null 而不是
* 签一个下载必 404 的地址;数据库指针本身保留,不做隐式清理。
*/
@Test
void avatarUrlDegradesToNullWhenTheAssetLeavesReady() throws Exception {
UUID owner = newUser("pet_avatar_degrade");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(owner);
patchPet(owner, petId, setAvatarBody(0, asset), 200);
jdbcClient.sql("UPDATE media.assets SET status = 'failed' WHERE id = :id")
.param("id", asset)
.update();
assertThat((Object) JsonPath.read(detail(owner, petId), "$.data.avatarUrl")).isNull();
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
}
// ---- 权限:WRITE 档(ADR-022 ---------------------------------------
/**
* caregiver 可改头像(WRITE 档:头像属日常照护信息,与体重/疫苗同档),
* 但资料本体仍是 MANAGE —— 同一端点按「本次请求碰了哪些字段」定档。
*/
@Test
void caregiverMayChangeTheAvatarButNotTheProfile() throws Exception {
UUID owner = newUser("pet_avatar_owner2");
UUID caregiver = newUser("pet_avatar_caregiver");
String petId = createPetAs(owner);
grantRole(UUID.fromString(petId), caregiver, "caregiver");
UUID asset = readyPetAvatar(caregiver);
String patched = patchPet(caregiver, petId, setAvatarBody(0, asset), 200);
assertThat((String) JsonPath.read(patched, "$.data.avatarUrl")).startsWith(SIGNED_PREFIX);
// 资料字段仍需 MANAGE
patchPetExpectingCode(caregiver, petId, "{\"version\":1,\"name\":\"照护人改名\"}", 403, 40300);
// 头像 + 资料混合按更严的那一半判(MANAGE)
patchPetExpectingCode(caregiver, petId,
"{\"version\":1,\"name\":\"夹带改名\",\"avatarAssetId\":null}", 403, 40300);
}
@Test
void viewerCannotChangeTheAvatar() throws Exception {
UUID owner = newUser("pet_avatar_owner3");
UUID viewer = newUser("pet_avatar_viewer");
String petId = createPetAs(owner);
grantRole(UUID.fromString(petId), viewer, "viewer");
UUID asset = readyPetAvatar(viewer);
patchPetExpectingCode(viewer, petId, setAvatarBody(0, asset), 403, 40300);
assertThat(dbAvatarAssetId(petId)).isNull();
// 只读仍可见
mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(viewer)))
.andExpect(status().isOk());
}
// ---- 不存在路径(防枚举) --------------------------------------------
@Test
void strangerAndGhostPetAnswerTheSame404() throws Exception {
UUID owner = newUser("pet_avatar_owner4");
UUID stranger = newUser("pet_avatar_stranger");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(stranger);
patchPetExpectingCode(stranger, petId, setAvatarBody(0, asset), 404, 40401);
patchPetExpectingCode(stranger, UUID.randomUUID().toString(),
setAvatarBody(0, asset), 404, 40401);
}
// ---- 参数错:asset 非法四态 ------------------------------------------
@Test
void rejectsUnknownForeignOrWrongPurposeAsset() throws Exception {
UUID owner = newUser("pet_avatar_asset");
UUID stranger = newUser("pet_avatar_assetowner");
String petId = createPetAs(owner);
// 幽灵 id 与他人 asset 同答 40405(防枚举合并)
patchPetExpectingCode(owner, petId, setAvatarBody(0, UUID.randomUUID()), 404, 40405);
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(stranger, "pet_avatar", "ready")), 404, 40405);
// 用途不符:帖子配图不能当宠物头像
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "post_image", "ready")), 404, 40405);
// 用户头像也不行:两种头像用途各归各
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "user_avatar", "ready")), 404, 40405);
// 已删资源对引用方即不存在
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "pet_avatar", "deleted")), 404, 40405);
assertThat(dbAvatarAssetId(petId)).isNull();
}
@Test
void rejectsAssetThatIsNotReadyYet() throws Exception {
UUID owner = newUser("pet_avatar_state");
String petId = createPetAs(owner);
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "pet_avatar", "uploading")), 422, 42203);
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "pet_avatar", "failed")), 422, 42203);
assertThat(dbAvatarAssetId(petId)).isNull();
}
@Test
void rejectsMalformedAvatarAssetIdAndMissingVersion() throws Exception {
UUID owner = newUser("pet_avatar_malformed");
String petId = createPetAs(owner);
patchPetExpectingCode(owner, petId,
"{\"version\":0,\"avatarAssetId\":\"not-a-uuid\"}", 400, 40000);
// version 仍是必填(乐观锁不可绕过),哪怕只改头像
patchPetExpectingCode(owner, petId,
"{\"avatarAssetId\":\"%s\"}".formatted(readyPetAvatar(owner)), 400, 40000);
}
// ---- 并发冲突与重放 --------------------------------------------------
/**
* 头像写入走同一把乐观锁:拿旧 version 的第二个写者必败 40902(并发冲突),
* 而带同一 body 的重放正是「旧 version 再来一次」,因此必须同样冲突——这
* 是 pets 域自 M2 起的一致语义,头像不另开后门。
*/
@Test
void staleVersionLosesAndReplayOfTheSameBodyConflicts() throws Exception {
UUID owner = newUser("pet_avatar_version");
String petId = createPetAs(owner);
UUID first = readyPetAvatar(owner);
UUID second = readyPetAvatar(owner);
patchPet(owner, petId, setAvatarBody(0, first), 200);
// 重放(同 body、同旧 version)→ 40902
patchPetExpectingCode(owner, petId, setAvatarBody(0, first), 409, 40902);
// 另一个写者拿旧 version 抢改 → 同样 40902
patchPetExpectingCode(owner, petId, setAvatarBody(0, second), 409, 40902);
assertThat(dbAvatarAssetId(petId)).isEqualTo(first);
// 用新 version 重放同一头像 → 幂等地仍是这张图(version 继续前进)
String again = patchPet(owner, petId, setAvatarBody(1, first), 200);
assertThat(dbAvatarAssetId(petId)).isEqualTo(first);
assertThat((int) JsonPath.read(again, "$.data.version")).isEqualTo(2);
}
}
@@ -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'
@@ -1,34 +1,50 @@
package com.patbond.patbond.user.controller; package com.patbond.patbond.user.controller;
import com.patbond.patbond.common.response.ApiResponse; import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.common.user.UserProfile;
import com.patbond.patbond.user.dto.MeResponse; import com.patbond.patbond.user.dto.MeResponse;
import com.patbond.patbond.user.dto.UpdateMeRequest;
import com.patbond.patbond.user.security.BearerAuthFilter; import com.patbond.patbond.user.security.BearerAuthFilter;
import com.patbond.patbond.user.service.UserService; import com.patbond.patbond.user.service.MeProfileService;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.UUID; import java.util.UUID;
/** /**
* Public profile endpoint. Authentication happens in BearerAuthFilter (RS256 * The account owner's own profile (T3.5-04). Authentication happens in
* verification against the auth service's public key); by the time this * BearerAuthFilter (RS256 verification against the auth service's public
* controller runs, the user id attribute is guaranteed to be present. * key); by the time this controller runs, the user id attribute is guaranteed
* to be present, so there is no "other user" case here — the resource is
* always the caller's own.
*
* <p>PATCH is intentionally not {@code @Valid}-annotated: the three-state
* fields of {@link UpdateMeRequest} (absent / null / value) need
* presence-aware checks that bean validation cannot express, so all rules
* live in {@link MeProfileService} and answer 400/40000 with a precise
* message.</p>
*/ */
@RestController @RestController
public class MeController { public class MeController {
private final UserService userService; private final MeProfileService meProfileService;
public MeController(UserService userService) { public MeController(MeProfileService meProfileService) {
this.userService = userService; this.meProfileService = meProfileService;
} }
@GetMapping("/api/v1/me") @GetMapping("/api/v1/me")
public ApiResponse<MeResponse> me(@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) { public ApiResponse<MeResponse> me(
UserProfile profile = userService.getById(userId); @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
return ApiResponse.success(new MeResponse( return ApiResponse.success(meProfileService.get(userId));
profile.getId(), profile.getUsername(), profile.getPhone(), profile.getCreatedAt())); }
@PatchMapping("/api/v1/me")
public ApiResponse<MeResponse> updateMe(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@RequestBody UpdateMeRequest request) {
return ApiResponse.success(meProfileService.update(userId, request));
} }
} }
@@ -4,8 +4,25 @@ import java.time.OffsetDateTime;
import java.util.UUID; import java.util.UUID;
/** /**
* Public /api/v1/me payload — exactly the frozen contract fields * The owner's own profile — payload of both GET and PATCH /api/v1/me
* {userId, username, phone, createdAt}; nothing else leaks out. * (T3.5-04).
*
* <p>{@code nickname} is the RAW stored value and is null when the user never
* set one: unlike {@link PublicProfileResponse}, this endpoint deliberately
* does NOT apply the nickname→username fallback. /me is the editing surface
* of the account owner, so it must report what is actually stored; a fallback
* here would prefill the edit form with a username the user never chose and
* the next save would silently promote it into a real nickname. The display
* fallback belongs where display happens — /internal/users/profiles for other
* people's view (SQL COALESCE, T3-05) and the client's own greeting.</p>
*
* <p>{@code avatarUrl} is a freshly signed presigned GET (private bucket,
* T3-03 定型): it EXPIRES and must never be persisted client-side. It is null
* both when no avatar is set and when the avatar asset is not (or no longer)
* ready, so the client's "has an avatar" test is exactly "avatarUrl != null".
* The asset id itself is deliberately not echoed — the client only ever
* writes it (PATCH) and renders the URL.</p>
*/ */
public record MeResponse(UUID userId, String username, String phone, OffsetDateTime createdAt) { public record MeResponse(UUID userId, String username, String nickname, String phone,
String avatarUrl, OffsetDateTime createdAt) {
} }
@@ -0,0 +1,67 @@
package com.patbond.patbond.user.dto;
import java.util.UUID;
/**
* PATCH /api/v1/me (T3.5-04). Partial update with an EXPLICIT三态 semantics
* per field, which is what the avatar and nickname features need and what the
* pets domain's "absent-or-null means unchanged" convention (M2, see
* UpdatePetRequest) cannot express:
*
* <ul>
* <li><b>key absent</b> → leave the column untouched;</li>
* <li><b>key present with null</b> → clear the column (remove the nickname /
* remove the avatar);</li>
* <li><b>key present with a value</b> → set it.</li>
* </ul>
*
* <p>Why the difference from pets: name/species/sex have no meaningful empty
* state (their CHECK constraints forbid it), so M2 could afford to conflate
* null with absent. A nickname and an avatar are genuinely optional and
* "remove what I set" is a first-class user action — with only two states
* there would be no way to express it at all. Presence is tracked by the
* setters: Jackson calls a setter exactly when the JSON key is present,
* including when its value is null.</p>
*
* <p>A body that touches neither field is rejected with 400/40000 rather than
* answering a silent 200 — an empty PATCH is a client bug, not an intent.</p>
*/
public class UpdateMeRequest {
private String nickname;
private boolean nicknamePresent;
private UUID avatarAssetId;
private boolean avatarAssetIdPresent;
public String getNickname() {
return nickname;
}
public void setNickname(String nickname) {
this.nickname = nickname;
this.nicknamePresent = true;
}
public boolean isNicknamePresent() {
return nicknamePresent;
}
public UUID getAvatarAssetId() {
return avatarAssetId;
}
public void setAvatarAssetId(UUID avatarAssetId) {
this.avatarAssetId = avatarAssetId;
this.avatarAssetIdPresent = true;
}
public boolean isAvatarAssetIdPresent() {
return avatarAssetIdPresent;
}
/** True when the body carries no updatable field at all. */
public boolean isEmptyPatch() {
return !nicknamePresent && !avatarAssetIdPresent;
}
}
@@ -54,8 +54,16 @@ public class MediaProperties {
/** Mime whitelist for kind=image (M3: jpeg/png/webp). */ /** Mime whitelist for kind=image (M3: jpeg/png/webp). */
private List<String> allowedMimeTypes = List.of("image/jpeg", "image/png", "image/webp"); private List<String> allowedMimeTypes = List.of("image/jpeg", "image/png", "image/webp");
/** Purpose whitelist; decides the objectKey prefix. M3: post_image. */ /**
private List<String> allowedPurposes = List.of("post_image"); * Purpose whitelist; decides the objectKey prefix. M3 shipped
* {@code post_image}; M3.5 adds the two avatar purposes (ADR-022 —
* media.assets.purpose has no CHECK constraint, so a new use case is a
* configuration + contract-enum change, never a migration). The purpose
* is also the referencing side's type check: only a {@code user_avatar}
* asset may become a user avatar, only a {@code pet_avatar} asset a pet
* avatar, so a post image can never be silently reused as an avatar.
*/
private List<String> allowedPurposes = List.of("post_image", "user_avatar", "pet_avatar");
public String getEndpoint() { public String getEndpoint() {
return endpoint; return endpoint;
@@ -4,6 +4,7 @@ import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -42,6 +43,16 @@ public class UserRepository {
public record PublicProfileRow(UUID id, String nickname, UUID avatarAssetId) { public record PublicProfileRow(UUID id, String nickname, UUID avatarAssetId) {
} }
/**
* Owner's-own-profile projection for /api/v1/me (T3.5-04). {@code nickname}
* is the RAW column — no username fallback here (see MeResponse for why);
* {@code avatarObjectKey} is already narrowed to a READY asset, so the
* caller only has to sign it.
*/
public record MeRow(UUID id, String username, String nickname, String phone,
String avatarObjectKey, OffsetDateTime createdAt) {
}
/** Inserts the user row; created_at/updated_at come from the DB defaults. */ /** Inserts the user row; created_at/updated_at come from the DB defaults. */
public OffsetDateTime insertUser(UUID id, String username, String nickname, String phone) { public OffsetDateTime insertUser(UUID id, String username, String nickname, String phone) {
return jdbcClient.sql(""" return jdbcClient.sql("""
@@ -117,6 +128,69 @@ public class UserRepository {
.list(); .list();
} }
/**
* The owner's own profile plus the object key of a READY avatar asset.
* The LEFT JOIN carries the readiness condition, so a dangling or
* still-uploading avatar simply yields a null key (→ {@code avatarUrl:
* null}) instead of a broken signed URL.
*/
public Optional<MeRow> findMeById(UUID id) {
return jdbcClient.sql("""
SELECT u.id, u.username::text AS username, u.nickname, u.phone_e164,
u.created_at, a.object_key AS avatar_object_key
FROM identity.users u
LEFT JOIN media.assets a
ON a.id = u.avatar_asset_id AND a.status = 'ready'
WHERE u.id = :id AND u.deleted_at IS NULL
""")
.param("id", id)
.query((rs, rowNum) -> new MeRow(
rs.getObject("id", UUID.class),
rs.getString("username"),
rs.getString("nickname"),
rs.getString("phone_e164"),
rs.getString("avatar_object_key"),
rs.getObject("created_at", OffsetDateTime.class)))
.optional();
}
/**
* Column-selective profile update for PATCH /api/v1/me: only the columns
* the request actually carried appear in the SET list. This is
* deliberately NOT a read-merge-write — two concurrent PATCHes, one
* changing the nickname and one the avatar, both survive, whereas a
* merged full-row write would let the later one silently revert the
* other's field. /me has a single legitimate writer (the account owner),
* so no optimistic-lock version is exposed; last write wins per column.
*
* @return rows updated — 0 means the user is gone (or soft-deleted)
*/
public int updateOwnProfile(UUID id, boolean setNickname, String nickname,
boolean setAvatarAssetId, UUID avatarAssetId) {
List<String> assignments = new ArrayList<>(2);
if (setNickname) {
assignments.add("nickname = :nickname");
}
if (setAvatarAssetId) {
assignments.add("avatar_asset_id = :avatarAssetId");
}
if (assignments.isEmpty()) {
throw new IllegalArgumentException("updateOwnProfile 需至少一个待更新列");
}
JdbcClient.StatementSpec spec = jdbcClient.sql("""
UPDATE identity.users SET %s
WHERE id = :id AND deleted_at IS NULL
""".formatted(String.join(", ", assignments)))
.param("id", id);
if (setNickname) {
spec = spec.param("nickname", nickname);
}
if (setAvatarAssetId) {
spec = spec.param("avatarAssetId", avatarAssetId);
}
return spec.update();
}
public Optional<AuthRow> findAuthByUsername(String username) { public Optional<AuthRow> findAuthByUsername(String username) {
return jdbcClient.sql(""" return jdbcClient.sql("""
SELECT u.id, u.username::text AS username, u.nickname, c.password_hash, c.locked_until SELECT u.id, u.username::text AS username, u.nickname, c.password_hash, c.locked_until
@@ -0,0 +1,152 @@
package com.patbond.patbond.user.service;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.user.dto.MeResponse;
import com.patbond.patbond.user.dto.UpdateMeRequest;
import com.patbond.patbond.user.media.MediaAssetRepository;
import com.patbond.patbond.user.media.MediaProperties;
import com.patbond.patbond.user.media.ObjectStorage;
import com.patbond.patbond.user.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/**
* The account owner's own profile: GET and PATCH /api/v1/me (T3.5-04).
*
* <p>Semantics frozen with this ticket:
* <ul>
* <li><b>No nickname fallback on /me</b> — the raw stored value, null when
* unset (rationale in {@link MeResponse}). The fallback stays in
* /internal/users/profiles' SQL, where other people's display name is
* produced.</li>
* <li><b>PATCH is三态 per field</b> — absent = unchanged, explicit null =
* clear, value = set (see {@link UpdateMeRequest}). An empty patch is
* 400/40000.</li>
* <li><b>nickname validation mirrors ck_users_nickname</b> — trimmed, 1..32
* CODE POINTS (PostgreSQL char_length counts code points, so a Java
* String.length() bound would reject 32 emoji the database accepts);
* whitespace-only is 400/40000, never an implicit clear, so clearing is
* expressible exactly one way.</li>
* <li><b>avatarAssetId validation mirrors the T3-03 referencing protocol</b>
* — unknown / someone else's / deleted asset → 404/40405 (one merged
* anti-enumeration answer); the caller's own asset with the wrong
* purpose → 404/40405 as well (from the avatar domain's point of view a
* post image is not an avatar; no enumeration risk, since the branch is
* only reachable for assets the caller owns, so the message may be
* specific); the caller's own user_avatar asset still uploading or
* failed → 422/42203.</li>
* </ul>
*
* <p>No optimistic lock is exposed: /me has one legitimate writer and the
* update is column-selective, so concurrent nickname/avatar patches cannot
* clobber each other (see {@link UserRepository#updateOwnProfile}).</p>
*/
@Service
public class MeProfileService {
/** The only media purpose acceptable as a user avatar (ADR-022). */
private static final String AVATAR_PURPOSE = "user_avatar";
private static final int NICKNAME_MAX_CODE_POINTS = 32;
private final UserRepository userRepository;
private final MediaAssetRepository mediaAssetRepository;
private final ObjectStorage objectStorage;
private final MediaProperties mediaProperties;
public MeProfileService(UserRepository userRepository,
MediaAssetRepository mediaAssetRepository,
ObjectStorage objectStorage,
MediaProperties mediaProperties) {
this.userRepository = userRepository;
this.mediaAssetRepository = mediaAssetRepository;
this.objectStorage = objectStorage;
this.mediaProperties = mediaProperties;
}
@Transactional(readOnly = true)
public MeResponse get(UUID userId) {
return toResponse(userRepository.findMeById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)));
}
@Transactional
public MeResponse update(UUID userId, UpdateMeRequest request) {
if (request.isEmptyPatch()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"请至少提交一个可更新字段:nickname 或 avatarAssetId");
}
String nickname = null;
if (request.isNicknamePresent() && request.getNickname() != null) {
nickname = requireNickname(request.getNickname());
}
if (request.isAvatarAssetIdPresent() && request.getAvatarAssetId() != null) {
requireOwnReadyAvatarAsset(userId, request.getAvatarAssetId());
}
int updated = userRepository.updateOwnProfile(userId,
request.isNicknamePresent(), nickname,
request.isAvatarAssetIdPresent(), request.getAvatarAssetId());
if (updated == 0) {
// The token is valid but the account is gone (or 注销) — same
// answer as a GET of a soft-deleted user.
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
return get(userId);
}
/**
* Trims like {@code btrim} and enforces the ck_users_nickname width. A
* blank-after-trim value is a validation error rather than a clear: an
* explicit JSON null is the single, unambiguous way to remove a nickname.
*/
private static String requireNickname(String raw) {
String trimmed = raw.trim();
int codePoints = trimmed.codePointCount(0, trimmed.length());
if (codePoints < 1 || codePoints > NICKNAME_MAX_CODE_POINTS) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"nickname 去除首尾空白后长度须在 1~" + NICKNAME_MAX_CODE_POINTS
+ " 字符;如需清空请显式提交 null");
}
return trimmed;
}
private void requireOwnReadyAvatarAsset(UUID userId, UUID assetId) {
MediaAssetRepository.AssetRow asset = mediaAssetRepository
.findByIdAndOwner(assetId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND));
if ("deleted".equals(asset.status())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND);
}
if (!AVATAR_PURPOSE.equals(asset.purpose())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND,
"该媒体资源的用途不是 " + AVATAR_PURPOSE + ",不能作为头像");
}
if (!"ready".equals(asset.status())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_READY);
}
}
private MeResponse toResponse(UserRepository.MeRow row) {
return new MeResponse(row.id(), row.username(), row.nickname(), row.phone(),
signAvatar(row.avatarObjectKey()), row.createdAt());
}
/**
* Signs a short-lived GET for the avatar object. Storage being
* unconfigured degrades to {@code avatarUrl: null} (same precedent as the
* community read side and the missing JWT public key) instead of failing
* the whole profile read — the condition mirrors MediaStorageConfig's.
*/
private String signAvatar(String objectKey) {
String endpoint = mediaProperties.getEndpoint();
if (objectKey == null || endpoint == null || endpoint.isBlank()) {
return null;
}
return objectStorage.presignGet(objectKey, mediaProperties.getDownloadTtl());
}
}
@@ -53,10 +53,10 @@ patbond:
# 预签名 PUT 凭据与 GET URL 的有效期 # 预签名 PUT 凭据与 GET URL 的有效期
upload-ttl: ${PATBOND_MEDIA_UPLOAD_TTL:10m} upload-ttl: ${PATBOND_MEDIA_UPLOAD_TTL:10m}
download-ttl: ${PATBOND_MEDIA_DOWNLOAD_TTL:1h} download-ttl: ${PATBOND_MEDIA_DOWNLOAD_TTL:1h}
# 单文件上限(字节)与 mime/purpose 白名单(M3 首版:图片、帖子配图 # 单文件上限(字节)与 mime/purpose 白名单(M3.5:帖子配图 + 用户/宠物头像
max-byte-size: ${PATBOND_MEDIA_MAX_BYTE_SIZE:10485760} max-byte-size: ${PATBOND_MEDIA_MAX_BYTE_SIZE:10485760}
allowed-mime-types: image/jpeg,image/png,image/webp allowed-mime-types: image/jpeg,image/png,image/webp
allowed-purposes: post_image allowed-purposes: post_image,user_avatar,pet_avatar
# Development seed data (regions reference rows) is opt-in. To load it, # Development seed data (regions reference rows) is opt-in. To load it,
# activate a dev profile that widens the Flyway locations: # activate a dev profile that widens the Flyway locations:
@@ -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");
@@ -0,0 +1,217 @@
package com.patbond.patbond.user.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.user.TestcontainersConfiguration;
import com.patbond.patbond.user.support.TestJwtKeys;
import com.patbond.patbond.user.support.UuidV7;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.MinIOContainer;
import org.testcontainers.utility.DockerImageName;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3.5-04 头像全链路(真实 MinIO Testcontainer,镜像 tag 与 compose 一致):
* 以 {@code purpose=user_avatar} 创建上传 → 凭据直传 → complete 置 ready →
* PATCH /me 挂头像 → GET /me 的 {@code avatarUrl} 是可真实下载的预签名 GET →
* 清空后回到 null。同时实证新加入白名单的 user_avatar 用途端到端可用,以及
* 「真实未就绪 asset 被拒 42203」(非 SQL 造数据的那一版)。
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
class MeAvatarSigningIntegrationTest {
/** 与 docker-compose.yml 的 minio 服务钉同一 tagADR-016 三环境零分叉)。 */
private static final MinIOContainer MINIO = new MinIOContainer(
DockerImageName.parse("minio/minio:RELEASE.2025-04-22T22-12-26Z"))
// 值仅为测试占位(dummy),非真实凭证
.withUserName("minio-dummy-access")
.withPassword("minio-dummy-secret");
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private static final byte[] FAKE_JPEG = fakeJpeg();
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcClient jdbcClient;
@DynamicPropertySource
static void wireMedia(DynamicPropertyRegistry registry) {
MINIO.start();
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
registry.add("patbond.media.endpoint", MINIO::getS3URL);
registry.add("patbond.media.access-key", MINIO::getUserName);
registry.add("patbond.media.secret-key", MINIO::getPassword);
}
private static byte[] fakeJpeg() {
byte[] bytes = new byte[1024];
for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte) (i * 17);
}
bytes[0] = (byte) 0xFF;
bytes[1] = (byte) 0xD8; // JPEG SOI
return bytes;
}
private UUID newUser(String username) {
UUID id = UuidV7.generate();
jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
.param("id", id)
.param("username", username)
.update();
return id;
}
private static String bearer(UUID userId) {
return "Bearer " + TestJwtKeys.accessToken(
TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
}
/** 以 user_avatar 用途申请上传凭据(本单新加入白名单)。 */
private String createAvatarUpload(UUID user) throws Exception {
return mockMvc.perform(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"user_avatar",
"mimeType":"image/jpeg","byteSize":%d}
""".formatted(FAKE_JPEG.length)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.assetId").isNotEmpty())
.andReturn().getResponse().getContentAsString();
}
private void directPut(String createdBody) throws Exception {
String uploadUrl = JsonPath.read(createdBody, "$.data.uploadUrl");
Map<String, String> headers = JsonPath.read(createdBody, "$.data.requiredHeaders");
HttpRequest.Builder put = HttpRequest.newBuilder(URI.create(uploadUrl))
.PUT(HttpRequest.BodyPublishers.ofByteArray(FAKE_JPEG));
headers.forEach(put::header);
assertThat(HTTP.send(put.build(), HttpResponse.BodyHandlers.discarding()).statusCode())
.isEqualTo(200);
}
/** 走完 T3-03 两步上传,返回 ready 的 assetId。 */
private String uploadReadyAvatar(UUID user) throws Exception {
String created = createAvatarUpload(user);
String assetId = JsonPath.read(created, "$.data.assetId");
directPut(created);
mockMvc.perform(post("/api/v1/media/uploads/{assetId}/complete", assetId)
.header("Authorization", bearer(user)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ready"));
return assetId;
}
@Test
void avatarUrlIsAFreshPresignedGetThatActuallyDownloads() throws Exception {
UUID user = newUser("avatar_signed");
String assetId = uploadReadyAvatar(user);
String patched = mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"avatarAssetId\":\"%s\"}".formatted(assetId)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
String avatarUrl = JsonPath.read(patched, "$.data.avatarUrl");
assertThat(avatarUrl)
.startsWith(MINIO.getS3URL() + "/patbond-media/user_avatar/")
.contains("X-Amz-Signature=");
// 真实下载:签名有效,私有桶靠签名而非公开读
HttpResponse<byte[]> download = HTTP.send(
HttpRequest.newBuilder(URI.create(avatarUrl)).GET().build(),
HttpResponse.BodyHandlers.ofByteArray());
assertThat(download.statusCode()).isEqualTo(200);
assertThat(download.body()).isEqualTo(FAKE_JPEG);
// GET /me 每次重新签发(URL 会过期,客户端不得持久化)
String fetched = mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat((String) JsonPath.read(fetched, "$.data.avatarUrl"))
.startsWith(MINIO.getS3URL() + "/patbond-media/user_avatar/");
// 清空后回到 null
String cleared = mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"avatarAssetId\":null}"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat((Object) JsonPath.read(cleared, "$.data.avatarUrl")).isNull();
}
/**
* 真实的「凭据已发但还没直传」状态:asset 存在、属本人、用途正确,但仍是
* uploading —— 引用侧必须 422/42203,不能挂上一个下载会 404 的头像。
*/
@Test
void refusesAnAvatarAssetWhoseUploadNeverCompleted() throws Exception {
UUID user = newUser("avatar_pending");
String assetId = JsonPath.read(createAvatarUpload(user), "$.data.assetId");
mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"avatarAssetId\":\"%s\"}".formatted(assetId)))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42203));
}
/** purpose 白名单:M3.5 之后 pet_avatar 同样可申请(宠物侧引用方在 pet 服务)。 */
@Test
void petAvatarPurposeIsAlsoWhitelisted() throws Exception {
UUID user = newUser("avatar_purposes");
mockMvc.perform(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"pet_avatar",
"mimeType":"image/jpeg","byteSize":1024}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(0));
// 白名单外的用途仍是 400/40000
mockMvc.perform(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"avatar",
"mimeType":"image/jpeg","byteSize":1024}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
}
@@ -17,6 +17,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.time.Duration; import java.time.Duration;
import java.util.UUID; import java.util.UUID;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -25,8 +26,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
/** /**
* GET /api/v1/me behind BearerAuthFilter: RS256 tokens are verified locally * GET /api/v1/me behind BearerAuthFilter: RS256 tokens are verified locally
* against the configured public key (generated per test run — no committed * against the configured public key (generated per test run — no committed
* key material). Response shape is the frozen contract: * key material). Response shape is the M3.5 surface
* {userId, username, phone, createdAt} and nothing else. * {userId, username, nickname, phone, avatarUrl, createdAt} and nothing else;
* the profile-write semantics live in MeProfileIntegrationTest.
*/ */
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@@ -65,9 +67,14 @@ class MeEndpointTest {
.andExpect(jsonPath("$.data.username").value("me_happy")) .andExpect(jsonPath("$.data.username").value("me_happy"))
.andExpect(jsonPath("$.data.phone").value("+8613800000401")) .andExpect(jsonPath("$.data.phone").value("+8613800000401"))
.andExpect(jsonPath("$.data.createdAt").isNotEmpty()) .andExpect(jsonPath("$.data.createdAt").isNotEmpty())
// Frozen contract: no other identity fields leak out. // 注册不收昵称(ADR-022 决策 D3.5-5),且 /me 不做 username 回退:
// 本人编辑态必须如实反映「我还没设过昵称」。
.andExpect(jsonPath("$.data.nickname").value(nullValue()))
.andExpect(jsonPath("$.data.avatarUrl").value(nullValue()))
// No other identity field leaks out.
.andExpect(jsonPath("$.data.id").doesNotExist()) .andExpect(jsonPath("$.data.id").doesNotExist())
.andExpect(jsonPath("$.data.nickname").doesNotExist()); .andExpect(jsonPath("$.data.avatarAssetId").doesNotExist())
.andExpect(jsonPath("$.data.bio").doesNotExist());
} }
@Test @Test
@@ -0,0 +1,430 @@
package com.patbond.patbond.user.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.user.TestcontainersConfiguration;
import com.patbond.patbond.user.security.InternalAuthFilter;
import com.patbond.patbond.user.support.TestJwtKeys;
import com.patbond.patbond.user.support.UuidV7;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3.5-04 用户资料读写:GET/PATCH /api/v1/me 的六类路径(成功 / 参数错 /
* 不存在 / 无权限 / 并发 / 重放)与三项专项(昵称边界值与清空、头像 asset
* 非法三态、/me 不回退 而 /internal 回退)。
*
* <p>本类不配置对象存储,因此断言 {@code avatarUrl} 恒为 null——这正是
* 「存储未配置时资料读取整体降级而不失败」的实证;真实签名 URL 的全链路
* (创建上传 → 直传 → complete → 挂头像 → URL 可访问)在
* MeAvatarSigningIntegrationTest 用真实 MinIO 覆盖。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
class MeProfileIntegrationTest {
private static final String INTERNAL_TOKEN = "test-internal-token";
/** 32 个 CJK 码点:恰好压在 ck_users_nickname 的上界上。 */
private static final String NICKNAME_32_CJK = "".repeat(32);
/**
* 32 个 emoji 码点(UTF-16 长度 64):证明长度校验按码点而非 Java
* String.length() 计——PostgreSQL char_length 数的是码点,若按 UTF-16
* 长度校验,这个数据库能存的昵称会被应用层误拒。
*/
private static final String NICKNAME_32_EMOJI = "🐶".repeat(32);
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcClient jdbcClient;
@DynamicPropertySource
static void jwtPublicKey(DynamicPropertyRegistry registry) {
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
}
// ---- helpers -------------------------------------------------------
private UUID newUser(String username) {
UUID id = UuidV7.generate();
jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
.param("id", id)
.param("username", username)
.update();
return id;
}
private static String bearer(UUID userId) {
return "Bearer " + TestJwtKeys.accessToken(
TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
}
/** 一枚 media.assets 行,用途/状态/归属可控(模拟 T3-03 的产物)。 */
private UUID insertAsset(UUID ownerUserId, String purpose, String status) {
UUID id = UuidV7.generate();
jdbcClient.sql("""
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
mime_type, byte_size, status, ready_at, deleted_at)
VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media',
:objectKey, 'image/jpeg', 2048, :status, :readyAt, :deletedAt)
""")
.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)
// ck_media_deletedstatus='deleted' 必带 deleted_at
.param("deletedAt", "deleted".equals(status) ? OffsetDateTime.now() : null)
.update();
return id;
}
private String patchMe(UUID userId, String body, int expectedStatus) throws Exception {
return mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(userId))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().is(expectedStatus))
.andReturn().getResponse().getContentAsString();
}
private void patchMeExpectingCode(UUID userId, String body, int httpStatus, int bizCode)
throws Exception {
mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(userId))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().is(httpStatus))
.andExpect(jsonPath("$.code").value(bizCode));
}
private String getMe(UUID userId) throws Exception {
return mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(userId)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
}
/** 他人视角的展示名(/internal 批量接口,回退在 SQL 层)。 */
private String publicNickname(UUID userId) throws Exception {
String body = mockMvc.perform(get("/internal/users/profiles")
.header(InternalAuthFilter.HEADER, INTERNAL_TOKEN)
.queryParam("ids", userId.toString()))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
List<Map<String, Object>> rows = JsonPath.read(body, "$.data");
return (String) rows.get(0).get("nickname");
}
private UUID dbAvatarAssetId(UUID userId) {
return jdbcClient.sql("SELECT avatar_asset_id FROM identity.users WHERE id = :id")
.param("id", userId)
.query(UUID.class)
.optional()
.orElse(null);
}
// ---- 成功路径 -------------------------------------------------------
@Test
void setsNicknameAndBothSidesAgree() throws Exception {
UUID user = newUser("me_nick_set");
String patched = patchMe(user, "{\"nickname\":\"豆豆家长\"}", 200);
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("豆豆家长");
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("豆豆家长");
// 双端一致:他人看到的展示名也立即是昵称(Feed 作者名同链路)
assertThat(publicNickname(user)).isEqualTo("豆豆家长");
}
@Test
void trimsNicknameLikeBtrim() throws Exception {
UUID user = newUser("me_nick_trim");
String patched = patchMe(user, "{\"nickname\":\" 豆豆 \"}", 200);
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("豆豆");
}
/**
* /me 是本人编辑态,故 nickname 为 DB 原值(清空后为 null);他人视角的
* /internal 才回退 username。这两者的差异是本单的核心语义定型。
*/
@Test
void clearingNicknameIsNullOnMeButFallsBackForOtherPeople() throws Exception {
UUID user = newUser("me_nick_clear");
patchMe(user, "{\"nickname\":\"临时昵称\"}", 200);
String cleared = patchMe(user, "{\"nickname\":null}", 200);
assertThat((Object) JsonPath.read(cleared, "$.data.nickname")).isNull();
assertThat((Object) JsonPath.read(getMe(user), "$.data.nickname")).isNull();
assertThat(publicNickname(user)).isEqualTo("me_nick_clear");
}
@Test
void absentFieldIsLeftUnchanged() throws Exception {
UUID user = newUser("me_absent");
patchMe(user, "{\"nickname\":\"保持不动\"}", 200);
// 只提交 avatarAssetId=nullnickname 未出现在 body 里,必须不受影响
String patched = patchMe(user, "{\"avatarAssetId\":null}", 200);
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("保持不动");
}
@Test
void setsAndClearsAvatar() throws Exception {
UUID user = newUser("me_avatar_ok");
UUID asset = insertAsset(user, "user_avatar", "ready");
String patched = patchMe(user, "{\"avatarAssetId\":\"%s\"}".formatted(asset), 200);
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
// 对象存储未配置 → 整体降级为 null URL,而不是 500
assertThat((Object) JsonPath.read(patched, "$.data.avatarUrl")).isNull();
patchMe(user, "{\"avatarAssetId\":null}", 200);
assertThat(dbAvatarAssetId(user)).isNull();
}
@Test
void acceptsNicknameAndAvatarInOneRequest() throws Exception {
UUID user = newUser("me_both");
UUID asset = insertAsset(user, "user_avatar", "ready");
String patched = patchMe(user,
"{\"nickname\":\"一次改两样\",\"avatarAssetId\":\"%s\"}".formatted(asset), 200);
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("一次改两样");
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
}
// ---- 参数错路径(昵称边界值 + 畸形入参 + 空 patch -------------------
@Test
void acceptsNicknameAtBothBoundaries() throws Exception {
UUID user = newUser("me_nick_bounds");
assertThat((String) JsonPath.read(patchMe(user, "{\"nickname\":\"\"}", 200),
"$.data.nickname")).isEqualTo("");
assertThat((String) JsonPath.read(
patchMe(user, "{\"nickname\":\"%s\"}".formatted(NICKNAME_32_CJK), 200),
"$.data.nickname")).isEqualTo(NICKNAME_32_CJK);
assertThat((String) JsonPath.read(
patchMe(user, "{\"nickname\":\"%s\"}".formatted(NICKNAME_32_EMOJI), 200),
"$.data.nickname")).isEqualTo(NICKNAME_32_EMOJI);
}
@Test
void rejectsNicknameOverThirtyTwoCodePoints() throws Exception {
UUID user = newUser("me_nick_long");
patchMeExpectingCode(user, "{\"nickname\":\"%s\"}".formatted("".repeat(33)), 400, 40000);
patchMeExpectingCode(user, "{\"nickname\":\"%s\"}".formatted("🐶".repeat(33)), 400, 40000);
// 拒绝后 DB 未被写入
assertThat((Object) JsonPath.read(getMe(user), "$.data.nickname")).isNull();
}
/**
* 空白昵称是参数错,不是「隐式清空」——清空只有显式 null 一种表达,
* 否则「用户不小心提交了空格」与「用户想删昵称」无法区分。
*/
@Test
void rejectsWhitespaceOnlyNicknameInsteadOfClearing() throws Exception {
UUID user = newUser("me_nick_blank");
patchMe(user, "{\"nickname\":\"原昵称\"}", 200);
patchMeExpectingCode(user, "{\"nickname\":\" \"}", 400, 40000);
patchMeExpectingCode(user, "{\"nickname\":\"\"}", 400, 40000);
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("原昵称");
}
@Test
void rejectsPatchThatTouchesNothing() throws Exception {
UUID user = newUser("me_empty_patch");
patchMeExpectingCode(user, "{}", 400, 40000);
// 只带未声明字段同样等于「什么都没改」
patchMeExpectingCode(user, "{\"unknownField\":\"x\"}", 400, 40000);
}
@Test
void rejectsMalformedBody() throws Exception {
UUID user = newUser("me_malformed");
patchMeExpectingCode(user, "{\"avatarAssetId\":\"not-a-uuid\"}", 400, 40000);
patchMeExpectingCode(user, "{\"nickname\":", 400, 40000);
patchMeExpectingCode(user, "{\"avatarAssetId\":42}", 400, 40000);
}
// ---- 头像 asset 非法三态(不存在/非本人/错用途/未就绪) ---------------
@Test
void rejectsUnknownOrForeignAvatarAssetWithMergedNotFound() throws Exception {
UUID user = newUser("me_asset_foreign");
UUID stranger = newUser("me_asset_stranger");
UUID strangerAsset = insertAsset(stranger, "user_avatar", "ready");
// 幽灵 id 与他人 asset 同答 40405(防枚举合并)
patchMeExpectingCode(user,
"{\"avatarAssetId\":\"%s\"}".formatted(UUID.randomUUID()), 404, 40405);
patchMeExpectingCode(user,
"{\"avatarAssetId\":\"%s\"}".formatted(strangerAsset), 404, 40405);
assertThat(dbAvatarAssetId(user)).isNull();
}
@Test
void rejectsAssetWithWrongPurpose() throws Exception {
UUID user = newUser("me_asset_purpose");
UUID postImage = insertAsset(user, "post_image", "ready");
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(postImage), 404, 40405);
assertThat(dbAvatarAssetId(user)).isNull();
}
@Test
void rejectsAvatarAssetThatIsNotReady() throws Exception {
UUID user = newUser("me_asset_state");
UUID uploading = insertAsset(user, "user_avatar", "uploading");
UUID failed = insertAsset(user, "user_avatar", "failed");
UUID deleted = insertAsset(user, "user_avatar", "deleted");
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(uploading), 422, 42203);
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(failed), 422, 42203);
// deleted 归入防枚举合并的 40405:已删资源对引用方就是不存在
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(deleted), 404, 40405);
assertThat(dbAvatarAssetId(user)).isNull();
}
// ---- 无权限路径 -----------------------------------------------------
@Test
void patchWithoutOrWithBadTokenIs40101() throws Exception {
mockMvc.perform(patch("/api/v1/me")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"nickname\":\"无票乘车\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
mockMvc.perform(patch("/api/v1/me")
.header("Authorization", "Bearer not.a.jwt")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"nickname\":\"伪票\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
// ---- 不存在路径 -----------------------------------------------------
@Test
void softDeletedUserGetsUserNotFoundOnBothVerbs() throws Exception {
UUID user = newUser("me_gone");
jdbcClient.sql("""
UPDATE identity.users
SET status = 'deleted', deleted_at = now()
WHERE id = :id
""")
.param("id", user)
.update();
mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40400));
patchMeExpectingCode(user, "{\"nickname\":\"亡者昵称\"}", 404, 40400);
}
// ---- 并发路径 -------------------------------------------------------
/**
* 两个并发 PATCH 各改一个字段:都必须留下。这是「列级选择性 UPDATE 而非
* 读-合并-写」的实证——若走整行合并写,后到的那个会把对方刚写的字段
* 悄悄还原(丢失更新)。/me 无版本号乐观锁,靠的正是这个性质。
*/
@Test
void concurrentDisjointPatchesBothSurvive() throws Exception {
UUID user = newUser("me_concurrent");
UUID asset = insertAsset(user, "user_avatar", "ready");
CyclicBarrier startTogether = new CyclicBarrier(2);
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Callable<Integer> nicknameWriter = () -> {
startTogether.await();
return mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"nickname\":\"并发昵称\"}"))
.andReturn().getResponse().getStatus();
};
Callable<Integer> avatarWriter = () -> {
startTogether.await();
return mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"avatarAssetId\":\"%s\"}".formatted(asset)))
.andReturn().getResponse().getStatus();
};
Future<Integer> first = pool.submit(nicknameWriter);
Future<Integer> second = pool.submit(avatarWriter);
assertThat(first.get()).isEqualTo(200);
assertThat(second.get()).isEqualTo(200);
} finally {
pool.shutdownNow();
}
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("并发昵称");
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
}
// ---- 重放路径 -------------------------------------------------------
/**
* PATCH /me 是幂等的(无版本号、无幂等键):同一请求重放两次,第二次同样
* 200 且状态与首次完全一致——重复点「保存」不会产生二次副作用。
*/
@Test
void repeatingTheSamePatchIsStable() throws Exception {
UUID user = newUser("me_replay");
UUID asset = insertAsset(user, "user_avatar", "ready");
String body = "{\"nickname\":\"重放昵称\",\"avatarAssetId\":\"%s\"}".formatted(asset);
String firstNickname = JsonPath.read(patchMe(user, body, 200), "$.data.nickname");
String secondNickname = JsonPath.read(patchMe(user, body, 200), "$.data.nickname");
assertThat(secondNickname).isEqualTo(firstNickname).isEqualTo("重放昵称");
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
}
// ---- GET 形态回归 ---------------------------------------------------
@Test
void meCarriesNickAndAvatarUrlFieldsAlways() throws Exception {
UUID user = newUser("me_shape");
mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.nickname").value(nullValue()))
.andExpect(jsonPath("$.data.avatarUrl").value(nullValue()))
.andExpect(jsonPath("$.data.username").value("me_shape"));
}
}
@@ -241,6 +241,8 @@ class MediaUploadIntegrationTest {
@Test @Test
void rejectsKindAndPurposeOutsideWhitelist() throws Exception { void rejectsKindAndPurposeOutsideWhitelist() throws Exception {
UUID user = newUser("media_bad_enum"); UUID user = newUser("media_bad_enum");
// M3.5 起 user_avatar/pet_avatar 已进白名单(ADR-022),反例改用
// 一个仍未开放的用途,保持本用例「白名单外必拒」的语义。
mockMvc.perform(post("/api/v1/media/uploads") mockMvc.perform(post("/api/v1/media/uploads")
.header("Authorization", bearer(user)) .header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -254,7 +256,7 @@ class MediaUploadIntegrationTest {
.header("Authorization", bearer(user)) .header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.content(""" .content("""
{"kind":"image","purpose":"pet_avatar", {"kind":"image","purpose":"id_card",
"mimeType":"image/jpeg","byteSize":1024} "mimeType":"image/jpeg","byteSize":1024}
""")) """))
.andExpect(status().isBadRequest()) .andExpect(status().isBadRequest())
@@ -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'