3 Commits

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 10:02:26 +08:00
29 changed files with 2109 additions and 42 deletions
+5
View File
@@ -102,6 +102,11 @@ services:
PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond}
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
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:
- ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro
- ./deploy/keys:/run/patbond/keys:ro
@@ -1,6 +1,7 @@
package com.patbond.patbond.community.controller;
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.CursorPage;
import com.patbond.patbond.community.dto.PostResponse;
@@ -86,4 +87,17 @@ public class PostController {
@RequestParam(required = false) String cursor) {
return ApiResponse.success(postService.listMine(userId, status, limit, cursor));
}
/**
* The caller's own community numbers (T3.5-06, ADR-022 决策 A: a dedicated
* endpoint rather than an addition to /users/{userId}/follow-stats, whose
* subject is "some user's follow counts" — mixing "my likes received" in
* would give one payload two subjects). Lives here because posts are the
* sole source of both numbers, next to the other /me posts read.
*/
@GetMapping("/api/v1/me/community-stats")
public ApiResponse<CommunityStatsResponse> communityStats(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
return ApiResponse.success(postService.communityStats(userId));
}
}
@@ -0,0 +1,22 @@
package com.patbond.patbond.community.dto;
/**
* GET /api/v1/me/community-stats (T3.5-06, ADR-022 决策 A) — the caller's own
* community numbers, read-side aggregates over community.posts with no new
* denormalized column.
*
* <p>Scope, frozen with this ticket: both numbers count ONLY the caller's own
* posts that are {@code status='published'} and not soft-deleted. Drafts are
* excluded (they are not works yet, and their likes cannot exist anyway),
* soft-deleted posts are excluded (deleting a post removes its numbers), and
* the operational states hidden/archived are excluded for the same reason
* they are invisible everywhere else in the M3 contract. Empty data yields
* {@code 0}, never null.</p>
*
* <p>{@code receivedLikeCount} is {@code SUM(posts.like_count)} — the counter
* the write side maintains in the same transaction as the like row (T3-07),
* so this is exact, not an estimate. A user's own likes on their own posts are
* counted, exactly as the per-post number shows them.</p>
*/
public record CommunityStatsResponse(long receivedLikeCount, long publishedPostCount) {
}
@@ -1,5 +1,6 @@
package com.patbond.patbond.community.repository;
import com.patbond.patbond.community.dto.CommunityStatsResponse;
import com.patbond.patbond.community.support.BookmarkCursor;
import com.patbond.patbond.community.support.FeedCursor;
import com.patbond.patbond.community.support.PostCursor;
@@ -169,6 +170,31 @@ public class PostRepository {
.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
* exact key of ix_posts_author_created. Soft-deleted rows never appear;
@@ -5,6 +5,7 @@ import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.community.access.PetVisibilityGateway;
import com.patbond.patbond.community.author.AuthorProfileGateway;
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.CursorPage;
import com.patbond.patbond.community.dto.PostMediaAttachRequest;
@@ -205,6 +206,19 @@ public class PostService {
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 403/404 boundary — invisible (absent, deleted, hidden/archived,
@@ -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());
}
}
+9
View File
@@ -43,6 +43,15 @@
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</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
the Boot BOM, version pinned in step with patbond-user/auth. -->
<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} /
* {@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.
*
* <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(
UUID id,
@@ -24,6 +31,7 @@ public record PetResponse(
String microchipNo,
LocalDate sterilizedOn,
String status,
String avatarUrl,
String myRole,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
@@ -16,6 +16,21 @@ import java.util.UUID;
* replaces the pair as a whole (they are mutually exclusive per
* ck_pets_breed). {@code version} is mandatory — it is the optimistic lock
* 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 {
@@ -56,6 +71,33 @@ public class UpdatePetRequest {
message = "status 仅支持 active/lost/deceased/archived")
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() {
return version;
}
@@ -0,0 +1,42 @@
package com.patbond.patbond.pet.media;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
/**
* Read-only cross-schema access to media.assets — the pet side of the T3-03
* 联调协议 (business references accept only assets owned by the caller with
* status='ready' and the matching purpose). Same-database read was chosen
* over an internal HTTP call to patbond-user, exactly as patbond-community
* did (ADR-017 precedent: while the schemas share one database this is a
* cross-schema read; splitting the database later moves every such gateway to
* an internal API together). This class never writes media.assets — the media
* state machine belongs to patbond-user.
*/
@Repository
public class MediaAssetGateway {
private final JdbcClient jdbcClient;
public MediaAssetGateway(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
public Optional<MediaAssetRef> findById(UUID assetId) {
return jdbcClient.sql("""
SELECT id, owner_user_id, purpose, status
FROM media.assets
WHERE id = :id
""")
.param("id", assetId)
.query((rs, rowNum) -> new MediaAssetRef(
rs.getObject("id", UUID.class),
rs.getObject("owner_user_id", UUID.class),
rs.getString("purpose"),
rs.getString("status")))
.optional();
}
}
@@ -0,0 +1,10 @@
package com.patbond.patbond.pet.media;
import java.util.UUID;
/**
* Read-only view of one media.assets row — exactly the columns the pet
* avatar flow needs for attach validation (owner, purpose, status).
*/
public record MediaAssetRef(UUID id, UUID ownerUserId, String purpose, String status) {
}
@@ -0,0 +1,61 @@
package com.patbond.patbond.pet.media;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import java.net.URI;
/**
* Signs presigned GET URLs for pet avatars (T3-03 定型:private bucket +
* presigned GET, TTL configurable, signed fresh on every response — clients
* never persist the URL). Presigning is a local SigV4 computation against the
* public endpoint; this service never talks to the object store itself.
* Path-style addressing is forced because MinIO has no wildcard DNS for
* virtual-host-style buckets (same as patbond-user's S3ObjectStorage and
* patbond-community's signer). When unconfigured, {@link #signGet} returns
* null and pet responses degrade to {@code avatarUrl: null}.
*/
public class MediaUrlSigner implements AutoCloseable {
private final PetMediaProperties properties;
private final S3Presigner presigner;
public MediaUrlSigner(PetMediaProperties properties) {
this.properties = properties;
if (properties.getPublicEndpoint().isBlank()) {
this.presigner = null;
return;
}
this.presigner = S3Presigner.builder()
.endpointOverride(URI.create(properties.getPublicEndpoint()))
.region(Region.of(properties.getRegion()))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())))
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
.build();
}
/** @return a presigned GET URL, or null when storage is unconfigured */
public String signGet(String bucket, String objectKey) {
if (presigner == null || bucket == null || objectKey == null) {
return null;
}
return presigner.presignGetObject(GetObjectPresignRequest.builder()
.signatureDuration(properties.getDownloadTtl())
.getObjectRequest(b -> b.bucket(bucket).key(objectKey))
.build())
.url()
.toString();
}
@Override
public void close() {
if (presigner != null) {
presigner.close();
}
}
}
@@ -0,0 +1,80 @@
package com.patbond.patbond.pet.media;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Read-side subset of the media object-storage configuration (T3.5-05). The
* write side — upload flow, mime/purpose whitelists, bucket init — lives in
* patbond-user's MediaProperties; this service only signs presigned GET URLs
* for pet avatars, a purely local SigV4 computation, so no S3 client is
* needed. Values reuse the same PATBOND_MINIO_* / PATBOND_MEDIA_*
* environment variables as patbond-user and patbond-community, keeping one
* set of knobs per deployment (ADR-016/021).
*/
@ConfigurationProperties(prefix = "patbond.media")
public class PetMediaProperties {
/**
* Endpoint presigned GET URLs are issued against — the address CLIENTS
* can reach. Empty means media is unconfigured for this service: pet
* responses carry {@code avatarUrl: null} (same degradation precedent as
* the missing JWT public key).
*/
private String publicEndpoint = "";
/** S3 access key; injected via environment, never committed (ADR-021). */
private String accessKey = "";
/** S3 secret key; injected via environment, never committed (ADR-021). */
private String secretKey = "";
/** SigV4 region; MinIO accepts any value, cloud stores need the real one. */
private String region = "us-east-1";
/** TTL of presigned GET URLs (the bucket stays private, T3-03 定型). */
private Duration downloadTtl = Duration.ofHours(1);
public String getPublicEndpoint() {
return publicEndpoint;
}
public void setPublicEndpoint(String publicEndpoint) {
this.publicEndpoint = publicEndpoint;
}
public String getAccessKey() {
return accessKey;
}
// setter 形参名取 valuecheck-secrets 的 KEY-ASSIGN 规则会把「字段 = 同名
// 形参」的自赋值误报为凭证字面量,规则表三仓同构不单方面改(ADR-021)
public void setAccessKey(String value) {
this.accessKey = value;
}
public String getSecretKey() {
return secretKey;
}
public void setSecretKey(String value) {
this.secretKey = value;
}
public String getRegion() {
return region;
}
public void setRegion(String region) {
this.region = region;
}
public Duration getDownloadTtl() {
return downloadTtl;
}
public void setDownloadTtl(Duration downloadTtl) {
this.downloadTtl = downloadTtl;
}
}
@@ -1,6 +1,5 @@
package com.patbond.patbond.pet.repository;
import com.patbond.patbond.pet.dto.PetResponse;
import org.springframework.jdbc.core.simple.JdbcClient;
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
* calling user so a row only comes back when a relationship exists — the
* 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
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,
p.custom_breed_name, p.sex, p.birth_date, p.birth_date_estimated,
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
FROM pet_health.pets p
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 media.assets av ON av.id = p.avatar_asset_id AND av.status = 'ready'
WHERE p.status <> 'deleted'
""";
@@ -37,6 +45,37 @@ public class PetRepository {
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,
String customBreedName, String sex, LocalDate birthDate,
boolean birthDateEstimated, String personality,
@@ -72,14 +111,14 @@ public class PetRepository {
.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")
.param("userId", userId)
.query(PetRepository::mapPet)
.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")
.param("userId", userId)
.param("petId", petId)
@@ -109,14 +148,16 @@ public class PetRepository {
public int updateWithVersion(UUID petId, int expectedVersion, String name, UUID breedId,
String customBreedName, String sex, LocalDate birthDate,
boolean birthDateEstimated, String personality,
String microchipNo, LocalDate sterilizedOn, String status) {
String microchipNo, LocalDate sterilizedOn, String status,
UUID avatarAssetId) {
return jdbcClient.sql("""
UPDATE pet_health.pets
SET name = :name, breed_id = :breedId, custom_breed_name = :customBreedName,
sex = :sex, birth_date = :birthDate,
birth_date_estimated = :birthDateEstimated, personality = :personality,
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'
""")
.param("petId", petId)
@@ -131,11 +172,12 @@ public class PetRepository {
.param("microchipNo", microchipNo)
.param("sterilizedOn", sterilizedOn)
.param("status", status)
.param("avatarAssetId", avatarAssetId)
.update();
}
private static PetResponse mapPet(ResultSet rs, int rowNum) throws SQLException {
return new PetResponse(
private static PetRow mapPet(ResultSet rs, int rowNum) throws SQLException {
return new PetRow(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("species"),
@@ -149,6 +191,9 @@ public class PetRepository {
rs.getString("microchip_no"),
rs.getObject("sterilized_on", LocalDate.class),
rs.getString("status"),
rs.getObject("avatar_asset_id", UUID.class),
rs.getString("avatar_bucket"),
rs.getString("avatar_object_key"),
rs.getString("role"),
rs.getObject("created_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.PetResponse;
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.PetRepository;
import com.patbond.patbond.pet.repository.PetRepository.PetRow;
import com.patbond.patbond.pet.support.UuidV7;
import org.springframework.dao.DuplicateKeyException;
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
* constraint-violation 500 — the constraints stay as the last line of
* 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
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 BreedRepository breedRepository;
private final PetAccessService petAccessService;
private final MediaAssetGateway mediaAssetGateway;
private final MediaUrlSigner mediaUrlSigner;
public PetService(PetRepository petRepository, BreedRepository breedRepository,
PetAccessService petAccessService) {
PetAccessService petAccessService, MediaAssetGateway mediaAssetGateway,
MediaUrlSigner mediaUrlSigner) {
this.petRepository = petRepository;
this.breedRepository = breedRepository;
this.petAccessService = petAccessService;
this.mediaAssetGateway = mediaAssetGateway;
this.mediaUrlSigner = mediaUrlSigner;
}
/**
@@ -63,18 +95,18 @@ public class PetService {
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
}
petRepository.insertPrimaryOwner(petId, userId);
return petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
return toResponse(petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)));
}
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) {
petAccessService.require(userId, petId, AccessLevel.READ);
return petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
return toResponse(petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND)));
}
/**
@@ -85,8 +117,8 @@ public class PetService {
*/
@Transactional
public PetResponse update(UUID userId, UUID petId, UpdatePetRequest request) {
petAccessService.require(userId, petId, AccessLevel.MANAGE);
PetResponse current = petRepository.findByIdForUser(petId, userId)
petAccessService.require(userId, petId, requiredLevel(request));
PetRow current = petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
UUID breedId = current.breedId();
@@ -102,6 +134,16 @@ public class PetService {
String sex = request.getSex() != null ? request.getSex() : current.sex();
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;
try {
updated = petRepository.updateWithVersion(
@@ -120,7 +162,8 @@ public class PetService {
? trimOrNull(request.getMicrochipNo()) : current.microchipNo(),
request.getSterilizedOn() != null
? request.getSterilizedOn() : current.sterilizedOn(),
status);
status,
avatarAssetId);
} catch (DuplicateKeyException e) {
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
}
@@ -129,8 +172,34 @@ public class PetService {
// missed conditional update means the version is stale.
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
}
return petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
return toResponse(petRepository.findByIdForUser(petId, userId)
.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) {
if (value == null) {
return null;
@@ -19,3 +19,12 @@ patbond:
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
# 私钥只给 patbond-auth,绝不入库。
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}
@@ -0,0 +1,335 @@
package com.patbond.patbond.pet.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.pet.support.PetIntegrationTestSupport;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3.5-05 宠物头像读写:PATCH /api/v1/pets/{petId} 的 {@code avatarAssetId}
* 三态(缺省不改 / 显式 null 清空 / 赋值设置),详情与列表的 {@code avatarUrl}
* 预签名 GET,以及六类路径 —— 成功 / 参数错(asset 非法四态)/ 不存在(防枚举
* 404/ 无权限(viewer 拒写、caregiver 只能改头像)/ 并发冲突(乐观锁 40902)
* / 重放(同版本重放必冲突、新版本重放幂等)。
*
* <p>预签名 GET 是纯本地 SigV4 计算,故这里用占位端点与占位凭证即可断言 URL
* 形态(与 patbond-community 的 PostApiTestBase 同先例),无需 MinIO 容器;
* 「签名真能下载」的实证由 user 模块的 MeAvatarSigningIntegrationTest 承担。</p>
*/
class PetAvatarIntegrationTest extends PetIntegrationTestSupport {
private static final String SIGNED_PREFIX = "http://127.0.0.1:9000/patbond-media/pet_avatar/";
@Autowired
private MockMvc mockMvc;
@DynamicPropertySource
static void wireMediaSigning(DynamicPropertyRegistry registry) {
// 占位值(dummy):仅用于本地 SigV4 计算,不连任何真实存储
registry.add("patbond.media.public-endpoint", () -> "http://127.0.0.1:9000");
registry.add("patbond.media.access-key", () -> "test-access-key");
registry.add("patbond.media.secret-key", () -> "test-secret-key");
}
// ---- helpers -------------------------------------------------------
private String createPetAs(UUID ownerId) throws Exception {
String body = mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"头像猫","species":"cat","sex":"female",
"customBreedName":"狸花"}
"""))
.andExpect(status().isCreated())
// 新建宠物尚无头像
.andExpect(jsonPath("$.data.avatarUrl").value(nullValue()))
.andReturn().getResponse().getContentAsString();
return JsonPath.read(body, "$.data.id");
}
/** 一枚 media.assets 行,用途/状态/归属可控(模拟 T3-03 上传的产物)。 */
private UUID insertAsset(UUID ownerUserId, String purpose, String status) {
UUID id = UUID.randomUUID();
jdbcClient.sql("""
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
mime_type, byte_size, status, ready_at, deleted_at)
VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media',
:objectKey, 'image/jpeg', 2048, :status, :readyAt, :deletedAt)
""")
.param("id", id)
.param("owner", ownerUserId)
.param("purpose", purpose)
.param("objectKey", purpose + "/2026/09/" + id)
.param("status", status)
.param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
// ck_media_deletedstatus='deleted' 必带 deleted_at
.param("deletedAt", "deleted".equals(status) ? OffsetDateTime.now() : null)
.update();
return id;
}
private UUID readyPetAvatar(UUID ownerUserId) {
return insertAsset(ownerUserId, "pet_avatar", "ready");
}
private String patchPet(UUID actor, String petId, String body, int expectedStatus)
throws Exception {
return mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(actor))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().is(expectedStatus))
.andReturn().getResponse().getContentAsString();
}
private void patchPetExpectingCode(UUID actor, String petId, String body,
int httpStatus, int bizCode) throws Exception {
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(actor))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().is(httpStatus))
.andExpect(jsonPath("$.code").value(bizCode));
}
private static String setAvatarBody(int version, UUID assetId) {
return "{\"version\":%d,\"avatarAssetId\":\"%s\"}".formatted(version, assetId);
}
private String detail(UUID actor, String petId) throws Exception {
return mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(actor)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
}
private UUID dbAvatarAssetId(String petId) {
return jdbcClient.sql("SELECT avatar_asset_id FROM pet_health.pets WHERE id = :id")
.param("id", UUID.fromString(petId))
.query(UUID.class)
.optional()
.orElse(null);
}
// ---- 成功路径 -------------------------------------------------------
@Test
void ownerSetsAvatarAndDetailAndListBothCarryASignedUrl() throws Exception {
UUID owner = newUser("pet_avatar_owner");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(owner);
String patched = patchPet(owner, petId, setAvatarBody(0, asset), 200);
assertThat((String) JsonPath.read(patched, "$.data.avatarUrl"))
.startsWith(SIGNED_PREFIX)
.contains("X-Amz-Signature=");
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
// 头像也吃乐观锁:写入后 version 前进
assertThat((int) JsonPath.read(patched, "$.data.version")).isEqualTo(1);
assertThat((String) JsonPath.read(detail(owner, petId), "$.data.avatarUrl"))
.startsWith(SIGNED_PREFIX);
String listBody = mockMvc.perform(get("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
List<Map<String, Object>> pets = JsonPath.read(listBody, "$.data");
assertThat((String) pets.get(0).get("avatarUrl")).startsWith(SIGNED_PREFIX);
}
@Test
void clearsAvatarWithAnExplicitNull() throws Exception {
UUID owner = newUser("pet_avatar_clear");
String petId = createPetAs(owner);
patchPet(owner, petId, setAvatarBody(0, readyPetAvatar(owner)), 200);
String cleared = patchPet(owner, petId, "{\"version\":1,\"avatarAssetId\":null}", 200);
assertThat((Object) JsonPath.read(cleared, "$.data.avatarUrl")).isNull();
assertThat(dbAvatarAssetId(petId)).isNull();
}
/**
* 缺省即不改:只改名字的 PATCH 不得把头像顺手清掉(这正是三态语义存在的
* 理由——若 null 与缺省同义,就无法既保留又能清空)。
*/
@Test
void absentAvatarFieldLeavesItUntouched() throws Exception {
UUID owner = newUser("pet_avatar_absent");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(owner);
patchPet(owner, petId, setAvatarBody(0, asset), 200);
String renamed = patchPet(owner, petId, "{\"version\":1,\"name\":\"改个名\"}", 200);
assertThat((String) JsonPath.read(renamed, "$.data.name")).isEqualTo("改个名");
assertThat((String) JsonPath.read(renamed, "$.data.avatarUrl")).startsWith(SIGNED_PREFIX);
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
}
/**
* 指针在、资源却退出 ready(如后台清理置 failed):URL 降级为 null 而不是
* 签一个下载必 404 的地址;数据库指针本身保留,不做隐式清理。
*/
@Test
void avatarUrlDegradesToNullWhenTheAssetLeavesReady() throws Exception {
UUID owner = newUser("pet_avatar_degrade");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(owner);
patchPet(owner, petId, setAvatarBody(0, asset), 200);
jdbcClient.sql("UPDATE media.assets SET status = 'failed' WHERE id = :id")
.param("id", asset)
.update();
assertThat((Object) JsonPath.read(detail(owner, petId), "$.data.avatarUrl")).isNull();
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
}
// ---- 权限:WRITE 档(ADR-022 ---------------------------------------
/**
* caregiver 可改头像(WRITE 档:头像属日常照护信息,与体重/疫苗同档),
* 但资料本体仍是 MANAGE —— 同一端点按「本次请求碰了哪些字段」定档。
*/
@Test
void caregiverMayChangeTheAvatarButNotTheProfile() throws Exception {
UUID owner = newUser("pet_avatar_owner2");
UUID caregiver = newUser("pet_avatar_caregiver");
String petId = createPetAs(owner);
grantRole(UUID.fromString(petId), caregiver, "caregiver");
UUID asset = readyPetAvatar(caregiver);
String patched = patchPet(caregiver, petId, setAvatarBody(0, asset), 200);
assertThat((String) JsonPath.read(patched, "$.data.avatarUrl")).startsWith(SIGNED_PREFIX);
// 资料字段仍需 MANAGE
patchPetExpectingCode(caregiver, petId, "{\"version\":1,\"name\":\"照护人改名\"}", 403, 40300);
// 头像 + 资料混合按更严的那一半判(MANAGE)
patchPetExpectingCode(caregiver, petId,
"{\"version\":1,\"name\":\"夹带改名\",\"avatarAssetId\":null}", 403, 40300);
}
@Test
void viewerCannotChangeTheAvatar() throws Exception {
UUID owner = newUser("pet_avatar_owner3");
UUID viewer = newUser("pet_avatar_viewer");
String petId = createPetAs(owner);
grantRole(UUID.fromString(petId), viewer, "viewer");
UUID asset = readyPetAvatar(viewer);
patchPetExpectingCode(viewer, petId, setAvatarBody(0, asset), 403, 40300);
assertThat(dbAvatarAssetId(petId)).isNull();
// 只读仍可见
mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(viewer)))
.andExpect(status().isOk());
}
// ---- 不存在路径(防枚举) --------------------------------------------
@Test
void strangerAndGhostPetAnswerTheSame404() throws Exception {
UUID owner = newUser("pet_avatar_owner4");
UUID stranger = newUser("pet_avatar_stranger");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(stranger);
patchPetExpectingCode(stranger, petId, setAvatarBody(0, asset), 404, 40401);
patchPetExpectingCode(stranger, UUID.randomUUID().toString(),
setAvatarBody(0, asset), 404, 40401);
}
// ---- 参数错:asset 非法四态 ------------------------------------------
@Test
void rejectsUnknownForeignOrWrongPurposeAsset() throws Exception {
UUID owner = newUser("pet_avatar_asset");
UUID stranger = newUser("pet_avatar_assetowner");
String petId = createPetAs(owner);
// 幽灵 id 与他人 asset 同答 40405(防枚举合并)
patchPetExpectingCode(owner, petId, setAvatarBody(0, UUID.randomUUID()), 404, 40405);
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(stranger, "pet_avatar", "ready")), 404, 40405);
// 用途不符:帖子配图不能当宠物头像
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "post_image", "ready")), 404, 40405);
// 用户头像也不行:两种头像用途各归各
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "user_avatar", "ready")), 404, 40405);
// 已删资源对引用方即不存在
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "pet_avatar", "deleted")), 404, 40405);
assertThat(dbAvatarAssetId(petId)).isNull();
}
@Test
void rejectsAssetThatIsNotReadyYet() throws Exception {
UUID owner = newUser("pet_avatar_state");
String petId = createPetAs(owner);
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "pet_avatar", "uploading")), 422, 42203);
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "pet_avatar", "failed")), 422, 42203);
assertThat(dbAvatarAssetId(petId)).isNull();
}
@Test
void rejectsMalformedAvatarAssetIdAndMissingVersion() throws Exception {
UUID owner = newUser("pet_avatar_malformed");
String petId = createPetAs(owner);
patchPetExpectingCode(owner, petId,
"{\"version\":0,\"avatarAssetId\":\"not-a-uuid\"}", 400, 40000);
// version 仍是必填(乐观锁不可绕过),哪怕只改头像
patchPetExpectingCode(owner, petId,
"{\"avatarAssetId\":\"%s\"}".formatted(readyPetAvatar(owner)), 400, 40000);
}
// ---- 并发冲突与重放 --------------------------------------------------
/**
* 头像写入走同一把乐观锁:拿旧 version 的第二个写者必败 40902(并发冲突),
* 而带同一 body 的重放正是「旧 version 再来一次」,因此必须同样冲突——这
* 是 pets 域自 M2 起的一致语义,头像不另开后门。
*/
@Test
void staleVersionLosesAndReplayOfTheSameBodyConflicts() throws Exception {
UUID owner = newUser("pet_avatar_version");
String petId = createPetAs(owner);
UUID first = readyPetAvatar(owner);
UUID second = readyPetAvatar(owner);
patchPet(owner, petId, setAvatarBody(0, first), 200);
// 重放(同 body、同旧 version)→ 40902
patchPetExpectingCode(owner, petId, setAvatarBody(0, first), 409, 40902);
// 另一个写者拿旧 version 抢改 → 同样 40902
patchPetExpectingCode(owner, petId, setAvatarBody(0, second), 409, 40902);
assertThat(dbAvatarAssetId(petId)).isEqualTo(first);
// 用新 version 重放同一头像 → 幂等地仍是这张图(version 继续前进)
String again = patchPet(owner, petId, setAvatarBody(1, first), 200);
assertThat(dbAvatarAssetId(petId)).isEqualTo(first);
assertThat((int) JsonPath.read(again, "$.data.version")).isEqualTo(2);
}
}
@@ -1,34 +1,50 @@
package com.patbond.patbond.user.controller;
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.UpdateMeRequest;
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.PatchMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* Public profile endpoint. Authentication happens in BearerAuthFilter (RS256
* verification against the auth service's public key); by the time this
* controller runs, the user id attribute is guaranteed to be present.
* The account owner's own profile (T3.5-04). Authentication happens in
* BearerAuthFilter (RS256 verification against the auth service's public
* 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
public class MeController {
private final UserService userService;
private final MeProfileService meProfileService;
public MeController(UserService userService) {
this.userService = userService;
public MeController(MeProfileService meProfileService) {
this.meProfileService = meProfileService;
}
@GetMapping("/api/v1/me")
public ApiResponse<MeResponse> me(@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
UserProfile profile = userService.getById(userId);
return ApiResponse.success(new MeResponse(
profile.getId(), profile.getUsername(), profile.getPhone(), profile.getCreatedAt()));
public ApiResponse<MeResponse> me(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
return ApiResponse.success(meProfileService.get(userId));
}
@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;
/**
* Public /api/v1/me payload — exactly the frozen contract fields
* {userId, username, phone, createdAt}; nothing else leaks out.
* The owner's own profile — payload of both GET and PATCH /api/v1/me
* (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). */
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() {
return endpoint;
@@ -4,6 +4,7 @@ import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
@@ -42,6 +43,16 @@ public class UserRepository {
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. */
public OffsetDateTime insertUser(UUID id, String username, String nickname, String phone) {
return jdbcClient.sql("""
@@ -117,6 +128,69 @@ public class UserRepository {
.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) {
return jdbcClient.sql("""
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 的有效期
upload-ttl: ${PATBOND_MEDIA_UPLOAD_TTL:10m}
download-ttl: ${PATBOND_MEDIA_DOWNLOAD_TTL:1h}
# 单文件上限(字节)与 mime/purpose 白名单(M3 首版:图片、帖子配图
# 单文件上限(字节)与 mime/purpose 白名单(M3.5:帖子配图 + 用户/宠物头像
max-byte-size: ${PATBOND_MEDIA_MAX_BYTE_SIZE:10485760}
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,
# activate a dev profile that widens the Flyway locations:
@@ -0,0 +1,217 @@
package com.patbond.patbond.user.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.user.TestcontainersConfiguration;
import com.patbond.patbond.user.support.TestJwtKeys;
import com.patbond.patbond.user.support.UuidV7;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.MinIOContainer;
import org.testcontainers.utility.DockerImageName;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3.5-04 头像全链路真实 MinIO Testcontainer镜像 tag compose 一致
* {@code purpose=user_avatar} 创建上传 凭据直传 complete ready
* PATCH /me 挂头像 GET /me {@code avatarUrl} 是可真实下载的预签名 GET
* 清空后回到 null同时实证新加入白名单的 user_avatar 用途端到端可用以及
* 真实未就绪 asset 被拒 42203 SQL 造数据的那一版
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
class MeAvatarSigningIntegrationTest {
/** 与 docker-compose.yml 的 minio 服务钉同一 tagADR-016 三环境零分叉)。 */
private static final MinIOContainer MINIO = new MinIOContainer(
DockerImageName.parse("minio/minio:RELEASE.2025-04-22T22-12-26Z"))
// 值仅为测试占位dummy非真实凭证
.withUserName("minio-dummy-access")
.withPassword("minio-dummy-secret");
private static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private static final byte[] FAKE_JPEG = fakeJpeg();
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcClient jdbcClient;
@DynamicPropertySource
static void wireMedia(DynamicPropertyRegistry registry) {
MINIO.start();
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
registry.add("patbond.media.endpoint", MINIO::getS3URL);
registry.add("patbond.media.access-key", MINIO::getUserName);
registry.add("patbond.media.secret-key", MINIO::getPassword);
}
private static byte[] fakeJpeg() {
byte[] bytes = new byte[1024];
for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte) (i * 17);
}
bytes[0] = (byte) 0xFF;
bytes[1] = (byte) 0xD8; // JPEG SOI
return bytes;
}
private UUID newUser(String username) {
UUID id = UuidV7.generate();
jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
.param("id", id)
.param("username", username)
.update();
return id;
}
private static String bearer(UUID userId) {
return "Bearer " + TestJwtKeys.accessToken(
TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
}
/** 以 user_avatar 用途申请上传凭据(本单新加入白名单)。 */
private String createAvatarUpload(UUID user) throws Exception {
return mockMvc.perform(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"user_avatar",
"mimeType":"image/jpeg","byteSize":%d}
""".formatted(FAKE_JPEG.length)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.assetId").isNotEmpty())
.andReturn().getResponse().getContentAsString();
}
private void directPut(String createdBody) throws Exception {
String uploadUrl = JsonPath.read(createdBody, "$.data.uploadUrl");
Map<String, String> headers = JsonPath.read(createdBody, "$.data.requiredHeaders");
HttpRequest.Builder put = HttpRequest.newBuilder(URI.create(uploadUrl))
.PUT(HttpRequest.BodyPublishers.ofByteArray(FAKE_JPEG));
headers.forEach(put::header);
assertThat(HTTP.send(put.build(), HttpResponse.BodyHandlers.discarding()).statusCode())
.isEqualTo(200);
}
/** 走完 T3-03 两步上传,返回 ready 的 assetId。 */
private String uploadReadyAvatar(UUID user) throws Exception {
String created = createAvatarUpload(user);
String assetId = JsonPath.read(created, "$.data.assetId");
directPut(created);
mockMvc.perform(post("/api/v1/media/uploads/{assetId}/complete", assetId)
.header("Authorization", bearer(user)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("ready"));
return assetId;
}
@Test
void avatarUrlIsAFreshPresignedGetThatActuallyDownloads() throws Exception {
UUID user = newUser("avatar_signed");
String assetId = uploadReadyAvatar(user);
String patched = mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"avatarAssetId\":\"%s\"}".formatted(assetId)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
String avatarUrl = JsonPath.read(patched, "$.data.avatarUrl");
assertThat(avatarUrl)
.startsWith(MINIO.getS3URL() + "/patbond-media/user_avatar/")
.contains("X-Amz-Signature=");
// 真实下载签名有效私有桶靠签名而非公开读
HttpResponse<byte[]> download = HTTP.send(
HttpRequest.newBuilder(URI.create(avatarUrl)).GET().build(),
HttpResponse.BodyHandlers.ofByteArray());
assertThat(download.statusCode()).isEqualTo(200);
assertThat(download.body()).isEqualTo(FAKE_JPEG);
// GET /me 每次重新签发URL 会过期客户端不得持久化
String fetched = mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat((String) JsonPath.read(fetched, "$.data.avatarUrl"))
.startsWith(MINIO.getS3URL() + "/patbond-media/user_avatar/");
// 清空后回到 null
String cleared = mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"avatarAssetId\":null}"))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
assertThat((Object) JsonPath.read(cleared, "$.data.avatarUrl")).isNull();
}
/**
* 真实的凭据已发但还没直传状态asset 存在属本人用途正确但仍是
* uploading 引用侧必须 422/42203不能挂上一个下载会 404 的头像
*/
@Test
void refusesAnAvatarAssetWhoseUploadNeverCompleted() throws Exception {
UUID user = newUser("avatar_pending");
String assetId = JsonPath.read(createAvatarUpload(user), "$.data.assetId");
mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"avatarAssetId\":\"%s\"}".formatted(assetId)))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42203));
}
/** purpose 白名单:M3.5 之后 pet_avatar 同样可申请(宠物侧引用方在 pet 服务)。 */
@Test
void petAvatarPurposeIsAlsoWhitelisted() throws Exception {
UUID user = newUser("avatar_purposes");
mockMvc.perform(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"pet_avatar",
"mimeType":"image/jpeg","byteSize":1024}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(0));
// 白名单外的用途仍是 400/40000
mockMvc.perform(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"avatar",
"mimeType":"image/jpeg","byteSize":1024}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
}
@@ -17,6 +17,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.time.Duration;
import java.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.post;
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
* against the configured public key (generated per test run no committed
* key material). Response shape is the frozen contract:
* {userId, username, phone, createdAt} and nothing else.
* key material). Response shape is the M3.5 surface
* {userId, username, nickname, phone, avatarUrl, createdAt} and nothing else;
* the profile-write semantics live in MeProfileIntegrationTest.
*/
@SpringBootTest
@AutoConfigureMockMvc
@@ -65,9 +67,14 @@ class MeEndpointTest {
.andExpect(jsonPath("$.data.username").value("me_happy"))
.andExpect(jsonPath("$.data.phone").value("+8613800000401"))
.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.nickname").doesNotExist());
.andExpect(jsonPath("$.data.avatarAssetId").doesNotExist())
.andExpect(jsonPath("$.data.bio").doesNotExist());
}
@Test
@@ -0,0 +1,430 @@
package com.patbond.patbond.user.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.user.TestcontainersConfiguration;
import com.patbond.patbond.user.security.InternalAuthFilter;
import com.patbond.patbond.user.support.TestJwtKeys;
import com.patbond.patbond.user.support.UuidV7;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T3.5-04 用户资料读写GET/PATCH /api/v1/me 的六类路径成功 / 参数错 /
* 不存在 / 无权限 / 并发 / 重放与三项专项昵称边界值与清空头像 asset
* 非法三态/me 不回退 /internal 回退
*
* <p>本类不配置对象存储因此断言 {@code avatarUrl} 恒为 null这正是
* 存储未配置时资料读取整体降级而不失败的实证真实签名 URL 的全链路
* 创建上传 直传 complete 挂头像 URL 可访问
* MeAvatarSigningIntegrationTest 用真实 MinIO 覆盖</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@Import(TestcontainersConfiguration.class)
class MeProfileIntegrationTest {
private static final String INTERNAL_TOKEN = "test-internal-token";
/** 32 个 CJK 码点:恰好压在 ck_users_nickname 的上界上。 */
private static final String NICKNAME_32_CJK = "".repeat(32);
/**
* 32 emoji 码点UTF-16 长度 64证明长度校验按码点而非 Java
* String.length() PostgreSQL char_length 数的是码点若按 UTF-16
* 长度校验这个数据库能存的昵称会被应用层误拒
*/
private static final String NICKNAME_32_EMOJI = "🐶".repeat(32);
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcClient jdbcClient;
@DynamicPropertySource
static void jwtPublicKey(DynamicPropertyRegistry registry) {
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
}
// ---- helpers -------------------------------------------------------
private UUID newUser(String username) {
UUID id = UuidV7.generate();
jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
.param("id", id)
.param("username", username)
.update();
return id;
}
private static String bearer(UUID userId) {
return "Bearer " + TestJwtKeys.accessToken(
TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
}
/** 一枚 media.assets 行,用途/状态/归属可控(模拟 T3-03 的产物)。 */
private UUID insertAsset(UUID ownerUserId, String purpose, String status) {
UUID id = UuidV7.generate();
jdbcClient.sql("""
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
mime_type, byte_size, status, ready_at, deleted_at)
VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media',
:objectKey, 'image/jpeg', 2048, :status, :readyAt, :deletedAt)
""")
.param("id", id)
.param("owner", ownerUserId)
.param("purpose", purpose)
.param("objectKey", purpose + "/2026/09/" + id)
.param("status", status)
.param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
// ck_media_deletedstatus='deleted' 必带 deleted_at
.param("deletedAt", "deleted".equals(status) ? OffsetDateTime.now() : null)
.update();
return id;
}
private String patchMe(UUID userId, String body, int expectedStatus) throws Exception {
return mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(userId))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().is(expectedStatus))
.andReturn().getResponse().getContentAsString();
}
private void patchMeExpectingCode(UUID userId, String body, int httpStatus, int bizCode)
throws Exception {
mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(userId))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().is(httpStatus))
.andExpect(jsonPath("$.code").value(bizCode));
}
private String getMe(UUID userId) throws Exception {
return mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(userId)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
}
/** 他人视角的展示名(/internal 批量接口,回退在 SQL 层)。 */
private String publicNickname(UUID userId) throws Exception {
String body = mockMvc.perform(get("/internal/users/profiles")
.header(InternalAuthFilter.HEADER, INTERNAL_TOKEN)
.queryParam("ids", userId.toString()))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
List<Map<String, Object>> rows = JsonPath.read(body, "$.data");
return (String) rows.get(0).get("nickname");
}
private UUID dbAvatarAssetId(UUID userId) {
return jdbcClient.sql("SELECT avatar_asset_id FROM identity.users WHERE id = :id")
.param("id", userId)
.query(UUID.class)
.optional()
.orElse(null);
}
// ---- 成功路径 -------------------------------------------------------
@Test
void setsNicknameAndBothSidesAgree() throws Exception {
UUID user = newUser("me_nick_set");
String patched = patchMe(user, "{\"nickname\":\"豆豆家长\"}", 200);
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("豆豆家长");
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("豆豆家长");
// 双端一致他人看到的展示名也立即是昵称Feed 作者名同链路
assertThat(publicNickname(user)).isEqualTo("豆豆家长");
}
@Test
void trimsNicknameLikeBtrim() throws Exception {
UUID user = newUser("me_nick_trim");
String patched = patchMe(user, "{\"nickname\":\" 豆豆 \"}", 200);
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("豆豆");
}
/**
* /me 是本人编辑态 nickname DB 原值清空后为 null他人视角的
* /internal 才回退 username这两者的差异是本单的核心语义定型
*/
@Test
void clearingNicknameIsNullOnMeButFallsBackForOtherPeople() throws Exception {
UUID user = newUser("me_nick_clear");
patchMe(user, "{\"nickname\":\"临时昵称\"}", 200);
String cleared = patchMe(user, "{\"nickname\":null}", 200);
assertThat((Object) JsonPath.read(cleared, "$.data.nickname")).isNull();
assertThat((Object) JsonPath.read(getMe(user), "$.data.nickname")).isNull();
assertThat(publicNickname(user)).isEqualTo("me_nick_clear");
}
@Test
void absentFieldIsLeftUnchanged() throws Exception {
UUID user = newUser("me_absent");
patchMe(user, "{\"nickname\":\"保持不动\"}", 200);
// 只提交 avatarAssetId=nullnickname 未出现在 body 必须不受影响
String patched = patchMe(user, "{\"avatarAssetId\":null}", 200);
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("保持不动");
}
@Test
void setsAndClearsAvatar() throws Exception {
UUID user = newUser("me_avatar_ok");
UUID asset = insertAsset(user, "user_avatar", "ready");
String patched = patchMe(user, "{\"avatarAssetId\":\"%s\"}".formatted(asset), 200);
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
// 对象存储未配置 整体降级为 null URL而不是 500
assertThat((Object) JsonPath.read(patched, "$.data.avatarUrl")).isNull();
patchMe(user, "{\"avatarAssetId\":null}", 200);
assertThat(dbAvatarAssetId(user)).isNull();
}
@Test
void acceptsNicknameAndAvatarInOneRequest() throws Exception {
UUID user = newUser("me_both");
UUID asset = insertAsset(user, "user_avatar", "ready");
String patched = patchMe(user,
"{\"nickname\":\"一次改两样\",\"avatarAssetId\":\"%s\"}".formatted(asset), 200);
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("一次改两样");
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
}
// ---- 参数错路径昵称边界值 + 畸形入参 + patch -------------------
@Test
void acceptsNicknameAtBothBoundaries() throws Exception {
UUID user = newUser("me_nick_bounds");
assertThat((String) JsonPath.read(patchMe(user, "{\"nickname\":\"\"}", 200),
"$.data.nickname")).isEqualTo("");
assertThat((String) JsonPath.read(
patchMe(user, "{\"nickname\":\"%s\"}".formatted(NICKNAME_32_CJK), 200),
"$.data.nickname")).isEqualTo(NICKNAME_32_CJK);
assertThat((String) JsonPath.read(
patchMe(user, "{\"nickname\":\"%s\"}".formatted(NICKNAME_32_EMOJI), 200),
"$.data.nickname")).isEqualTo(NICKNAME_32_EMOJI);
}
@Test
void rejectsNicknameOverThirtyTwoCodePoints() throws Exception {
UUID user = newUser("me_nick_long");
patchMeExpectingCode(user, "{\"nickname\":\"%s\"}".formatted("".repeat(33)), 400, 40000);
patchMeExpectingCode(user, "{\"nickname\":\"%s\"}".formatted("🐶".repeat(33)), 400, 40000);
// 拒绝后 DB 未被写入
assertThat((Object) JsonPath.read(getMe(user), "$.data.nickname")).isNull();
}
/**
* 空白昵称是参数错不是隐式清空清空只有显式 null 一种表达
* 否则用户不小心提交了空格用户想删昵称无法区分
*/
@Test
void rejectsWhitespaceOnlyNicknameInsteadOfClearing() throws Exception {
UUID user = newUser("me_nick_blank");
patchMe(user, "{\"nickname\":\"原昵称\"}", 200);
patchMeExpectingCode(user, "{\"nickname\":\" \"}", 400, 40000);
patchMeExpectingCode(user, "{\"nickname\":\"\"}", 400, 40000);
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("原昵称");
}
@Test
void rejectsPatchThatTouchesNothing() throws Exception {
UUID user = newUser("me_empty_patch");
patchMeExpectingCode(user, "{}", 400, 40000);
// 只带未声明字段同样等于什么都没改
patchMeExpectingCode(user, "{\"unknownField\":\"x\"}", 400, 40000);
}
@Test
void rejectsMalformedBody() throws Exception {
UUID user = newUser("me_malformed");
patchMeExpectingCode(user, "{\"avatarAssetId\":\"not-a-uuid\"}", 400, 40000);
patchMeExpectingCode(user, "{\"nickname\":", 400, 40000);
patchMeExpectingCode(user, "{\"avatarAssetId\":42}", 400, 40000);
}
// ---- 头像 asset 非法三态不存在/非本人/错用途/未就绪 ---------------
@Test
void rejectsUnknownOrForeignAvatarAssetWithMergedNotFound() throws Exception {
UUID user = newUser("me_asset_foreign");
UUID stranger = newUser("me_asset_stranger");
UUID strangerAsset = insertAsset(stranger, "user_avatar", "ready");
// 幽灵 id 与他人 asset 同答 40405防枚举合并
patchMeExpectingCode(user,
"{\"avatarAssetId\":\"%s\"}".formatted(UUID.randomUUID()), 404, 40405);
patchMeExpectingCode(user,
"{\"avatarAssetId\":\"%s\"}".formatted(strangerAsset), 404, 40405);
assertThat(dbAvatarAssetId(user)).isNull();
}
@Test
void rejectsAssetWithWrongPurpose() throws Exception {
UUID user = newUser("me_asset_purpose");
UUID postImage = insertAsset(user, "post_image", "ready");
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(postImage), 404, 40405);
assertThat(dbAvatarAssetId(user)).isNull();
}
@Test
void rejectsAvatarAssetThatIsNotReady() throws Exception {
UUID user = newUser("me_asset_state");
UUID uploading = insertAsset(user, "user_avatar", "uploading");
UUID failed = insertAsset(user, "user_avatar", "failed");
UUID deleted = insertAsset(user, "user_avatar", "deleted");
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(uploading), 422, 42203);
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(failed), 422, 42203);
// deleted 归入防枚举合并的 40405已删资源对引用方就是不存在
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(deleted), 404, 40405);
assertThat(dbAvatarAssetId(user)).isNull();
}
// ---- 无权限路径 -----------------------------------------------------
@Test
void patchWithoutOrWithBadTokenIs40101() throws Exception {
mockMvc.perform(patch("/api/v1/me")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"nickname\":\"无票乘车\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
mockMvc.perform(patch("/api/v1/me")
.header("Authorization", "Bearer not.a.jwt")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"nickname\":\"伪票\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
// ---- 不存在路径 -----------------------------------------------------
@Test
void softDeletedUserGetsUserNotFoundOnBothVerbs() throws Exception {
UUID user = newUser("me_gone");
jdbcClient.sql("""
UPDATE identity.users
SET status = 'deleted', deleted_at = now()
WHERE id = :id
""")
.param("id", user)
.update();
mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40400));
patchMeExpectingCode(user, "{\"nickname\":\"亡者昵称\"}", 404, 40400);
}
// ---- 并发路径 -------------------------------------------------------
/**
* 两个并发 PATCH 各改一个字段都必须留下这是列级选择性 UPDATE 而非
* -合并-的实证若走整行合并写后到的那个会把对方刚写的字段
* 悄悄还原丢失更新/me 无版本号乐观锁靠的正是这个性质
*/
@Test
void concurrentDisjointPatchesBothSurvive() throws Exception {
UUID user = newUser("me_concurrent");
UUID asset = insertAsset(user, "user_avatar", "ready");
CyclicBarrier startTogether = new CyclicBarrier(2);
ExecutorService pool = Executors.newFixedThreadPool(2);
try {
Callable<Integer> nicknameWriter = () -> {
startTogether.await();
return mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"nickname\":\"并发昵称\"}"))
.andReturn().getResponse().getStatus();
};
Callable<Integer> avatarWriter = () -> {
startTogether.await();
return mockMvc.perform(patch("/api/v1/me")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"avatarAssetId\":\"%s\"}".formatted(asset)))
.andReturn().getResponse().getStatus();
};
Future<Integer> first = pool.submit(nicknameWriter);
Future<Integer> second = pool.submit(avatarWriter);
assertThat(first.get()).isEqualTo(200);
assertThat(second.get()).isEqualTo(200);
} finally {
pool.shutdownNow();
}
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("并发昵称");
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
}
// ---- 重放路径 -------------------------------------------------------
/**
* PATCH /me 是幂等的无版本号无幂等键同一请求重放两次第二次同样
* 200 且状态与首次完全一致重复点保存不会产生二次副作用
*/
@Test
void repeatingTheSamePatchIsStable() throws Exception {
UUID user = newUser("me_replay");
UUID asset = insertAsset(user, "user_avatar", "ready");
String body = "{\"nickname\":\"重放昵称\",\"avatarAssetId\":\"%s\"}".formatted(asset);
String firstNickname = JsonPath.read(patchMe(user, body, 200), "$.data.nickname");
String secondNickname = JsonPath.read(patchMe(user, body, 200), "$.data.nickname");
assertThat(secondNickname).isEqualTo(firstNickname).isEqualTo("重放昵称");
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
}
// ---- GET 形态回归 ---------------------------------------------------
@Test
void meCarriesNickAndAvatarUrlFieldsAlways() throws Exception {
UUID user = newUser("me_shape");
mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.nickname").value(nullValue()))
.andExpect(jsonPath("$.data.avatarUrl").value(nullValue()))
.andExpect(jsonPath("$.data.username").value("me_shape"));
}
}
@@ -241,6 +241,8 @@ class MediaUploadIntegrationTest {
@Test
void rejectsKindAndPurposeOutsideWhitelist() throws Exception {
UUID user = newUser("media_bad_enum");
// M3.5 user_avatar/pet_avatar 已进白名单ADR-022反例改用
// 一个仍未开放的用途保持本用例白名单外必拒的语义
mockMvc.perform(post("/api/v1/media/uploads")
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
@@ -254,7 +256,7 @@ class MediaUploadIntegrationTest {
.header("Authorization", bearer(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"kind":"image","purpose":"pet_avatar",
{"kind":"image","purpose":"id_card",
"mimeType":"image/jpeg","byteSize":1024}
"""))
.andExpect(status().isBadRequest())