Compare commits
4 Commits
8089c06a73
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cd8005577 | |||
| d98a400f47 | |||
| 15c2e66519 | |||
| a5634c5f85 |
@@ -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
|
||||||
|
|||||||
+95
-11
@@ -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-19(D3-8):auth 域 6 个 M1 操作补进契约一致性保障,机制与
|
* T3-19(D3-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/me(v1.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");
|
||||||
|
|||||||
+315
-27
@@ -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_FOUND:asset 不存在、非本人所有或已删(防枚举合并) |
|
| 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有、已删,或**用途与引用场景不符**(帖图当头像、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:引用了本人所有但非 ready(uploading/failed)状态的 asset |
|
| 42203 | 422 | MEDIA_NOT_READY:引用了本人所有、用途相符但非 ready(uploading/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_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 |
|
| 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 |
|
||||||
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
|
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
|
||||||
@@ -68,7 +76,10 @@ info:
|
|||||||
- **权限模型(ADR-015:owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档:
|
- **权限模型(ADR-015:owner/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-user,ADR-016 预签名直传)
|
description: 媒体上传两步流程(patbond-user,ADR-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 仅 image(ADR-018 视频后置;video/document 为向后新增枚举预留)
|
description: M3 仅 image(ADR-018 视频后置;video/document 为向后新增枚举预留)
|
||||||
purpose:
|
purpose:
|
||||||
type: string
|
type: string
|
||||||
enum: [post_image]
|
enum: [post_image, user_avatar, pet_avatar]
|
||||||
description: |
|
description: |
|
||||||
用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩
|
用途白名单(服务端配置项 `patbond.media.allowed-purposes`,决定 objectKey 前缀
|
||||||
user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展)
|
`<purpose>/yyyy/MM/{assetId}`)。M3.5 起为三值:`post_image`(帖子配图)、
|
||||||
|
`user_avatar`(用户头像)、`pet_avatar`(宠物头像)——相对 M3 的纯枚举追加。
|
||||||
|
**用途即引用侧的类型检查**:引用时校验 `purpose` 相符,故帖图不能当头像、
|
||||||
|
两种头像也互不通用(不符者 404/40405)。白名单外的取值 400/40000。
|
||||||
mimeType:
|
mimeType:
|
||||||
type: string
|
type: string
|
||||||
enum: [image/jpeg, image/png, image/webp]
|
enum: [image/jpeg, image/png, image/webp]
|
||||||
@@ -3857,3 +4112,36 @@ components:
|
|||||||
example: success
|
example: success
|
||||||
data:
|
data:
|
||||||
$ref: '#/components/schemas/FollowStats'
|
$ref: '#/components/schemas/FollowStats'
|
||||||
|
|
||||||
|
CommunityStats:
|
||||||
|
type: object
|
||||||
|
description: |
|
||||||
|
调用者本人的社区数字(M3.5)。两数同一集合:本人的、`status='published'` 的、
|
||||||
|
未软删的帖(草稿 / 软删 / hidden / archived 均不计)。读侧实时聚合,无冗余计数列。
|
||||||
|
required: [receivedLikeCount, publishedPostCount]
|
||||||
|
properties:
|
||||||
|
receivedLikeCount:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: |
|
||||||
|
获赞总数 = 该集合的 `like_count` 之和(写侧同事务维护的帖级冗余列,精确值)。
|
||||||
|
**自己赞自己计入**,与帖子详情的 `likeCount` 同一口径。空数据为 0,非 null。
|
||||||
|
example: 128
|
||||||
|
publishedPostCount:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: 作品数 = 该集合的帖子数。空数据为 0,非 null。
|
||||||
|
example: 12
|
||||||
|
|
||||||
|
CommunityStatsEnvelope:
|
||||||
|
type: object
|
||||||
|
required: [code, message, data]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: integer
|
||||||
|
enum: [0]
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
example: success
|
||||||
|
data:
|
||||||
|
$ref: '#/components/schemas/CommunityStats'
|
||||||
+14
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
@@ -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) {
|
||||||
|
}
|
||||||
+26
@@ -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;
|
||||||
|
|||||||
+14
@@ -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,
|
||||||
|
|||||||
+53
-14
@@ -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-20(M3 第二波收尾):community 域 17 个操作补进契约一致性保障,机制与
|
* T3-20(M3 第二波收尾):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)
|
||||||
|
|||||||
+3
-3
@@ -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");
|
||||||
|
|||||||
+237
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+315
-27
@@ -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_FOUND:asset 不存在、非本人所有或已删(防枚举合并) |
|
| 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有、已删,或**用途与引用场景不符**(帖图当头像、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:引用了本人所有但非 ready(uploading/failed)状态的 asset |
|
| 42203 | 422 | MEDIA_NOT_READY:引用了本人所有、用途相符但非 ready(uploading/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_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 |
|
| 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 |
|
||||||
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
|
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
|
||||||
@@ -68,7 +76,10 @@ info:
|
|||||||
- **权限模型(ADR-015:owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档:
|
- **权限模型(ADR-015:owner/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-user,ADR-016 预签名直传)
|
description: 媒体上传两步流程(patbond-user,ADR-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 仅 image(ADR-018 视频后置;video/document 为向后新增枚举预留)
|
description: M3 仅 image(ADR-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'
|
||||||
@@ -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 形参名取 value:check-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}
|
||||||
|
|||||||
+59
-8
@@ -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/user 域 7 个操作(register/login/refresh/logout/me 读写/trackEvents)
|
||||||
* 不在本单范围(M1 交付无契约测试,补齐另立工单)。
|
* 的契约测试在 patbond-auth(T3-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");
|
||||||
|
|||||||
+335
@@ -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_deleted:status='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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+315
-27
@@ -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_FOUND:asset 不存在、非本人所有或已删(防枚举合并) |
|
| 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有、已删,或**用途与引用场景不符**(帖图当头像、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:引用了本人所有但非 ready(uploading/failed)状态的 asset |
|
| 42203 | 422 | MEDIA_NOT_READY:引用了本人所有、用途相符但非 ready(uploading/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_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 |
|
| 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 |
|
||||||
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
|
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
|
||||||
@@ -68,7 +76,10 @@ info:
|
|||||||
- **权限模型(ADR-015:owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档:
|
- **权限模型(ADR-015:owner/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-user,ADR-016 预签名直传)
|
description: 媒体上传两步流程(patbond-user,ADR-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 仅 image(ADR-018 视频后置;video/document 为向后新增枚举预留)
|
description: M3 仅 image(ADR-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:
|
||||||
|
|||||||
+7
-7
@@ -40,13 +40,13 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
|||||||
/**
|
/**
|
||||||
* T3-20(M3 第二波收尾):media 域 2 个操作(两步上传,属 user 模块)补进契约
|
* T3-20(M3 第二波收尾):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/user 域 7 操作在 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");
|
||||||
|
|||||||
+217
@@ -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 服务钉同一 tag(ADR-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
|
||||||
|
|||||||
+430
@@ -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_deleted:status='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=null:nickname 未出现在 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -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())
|
||||||
|
|||||||
+315
-27
@@ -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_FOUND:asset 不存在、非本人所有或已删(防枚举合并) |
|
| 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有、已删,或**用途与引用场景不符**(帖图当头像、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:引用了本人所有但非 ready(uploading/failed)状态的 asset |
|
| 42203 | 422 | MEDIA_NOT_READY:引用了本人所有、用途相符但非 ready(uploading/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_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 |
|
| 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 |
|
||||||
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
|
| 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
|
||||||
@@ -68,7 +76,10 @@ info:
|
|||||||
- **权限模型(ADR-015:owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档:
|
- **权限模型(ADR-015:owner/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-user,ADR-016 预签名直传)
|
description: 媒体上传两步流程(patbond-user,ADR-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 仅 image(ADR-018 视频后置;video/document 为向后新增枚举预留)
|
description: M3 仅 image(ADR-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'
|
||||||
Reference in New Issue
Block a user