updateMe(
+ @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
+ @RequestBody UpdateMeRequest request) {
+ return ApiResponse.success(meProfileService.update(userId, request));
}
}
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/dto/MeResponse.java b/patbond-user/src/main/java/com/patbond/patbond/user/dto/MeResponse.java
index 0532226..8aa642d 100644
--- a/patbond-user/src/main/java/com/patbond/patbond/user/dto/MeResponse.java
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/dto/MeResponse.java
@@ -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).
+ *
+ * {@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.
+ *
+ * {@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.
*/
-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) {
}
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/dto/UpdateMeRequest.java b/patbond-user/src/main/java/com/patbond/patbond/user/dto/UpdateMeRequest.java
new file mode 100644
index 0000000..2ebb07f
--- /dev/null
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/dto/UpdateMeRequest.java
@@ -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:
+ *
+ *
+ * - key absent → leave the column untouched;
+ * - key present with null → clear the column (remove the nickname /
+ * remove the avatar);
+ * - key present with a value → set it.
+ *
+ *
+ * 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.
+ *
+ * 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.
+ */
+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;
+ }
+}
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaProperties.java b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaProperties.java
index fbaa7ad..e92b732 100644
--- a/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaProperties.java
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaProperties.java
@@ -54,8 +54,16 @@ public class MediaProperties {
/** Mime whitelist for kind=image (M3: jpeg/png/webp). */
private List allowedMimeTypes = List.of("image/jpeg", "image/png", "image/webp");
- /** Purpose whitelist; decides the objectKey prefix. M3: post_image. */
- private List 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 allowedPurposes = List.of("post_image", "user_avatar", "pet_avatar");
public String getEndpoint() {
return endpoint;
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/repository/UserRepository.java b/patbond-user/src/main/java/com/patbond/patbond/user/repository/UserRepository.java
index ec9130e..7ce1fed 100644
--- a/patbond-user/src/main/java/com/patbond/patbond/user/repository/UserRepository.java
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/repository/UserRepository.java
@@ -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 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 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 findAuthByUsername(String username) {
return jdbcClient.sql("""
SELECT u.id, u.username::text AS username, u.nickname, c.password_hash, c.locked_until
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/service/MeProfileService.java b/patbond-user/src/main/java/com/patbond/patbond/user/service/MeProfileService.java
new file mode 100644
index 0000000..14b2d9f
--- /dev/null
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/service/MeProfileService.java
@@ -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).
+ *
+ * Semantics frozen with this ticket:
+ *
+ * - No nickname fallback on /me — 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.
+ * - PATCH is三态 per field — absent = unchanged, explicit null =
+ * clear, value = set (see {@link UpdateMeRequest}). An empty patch is
+ * 400/40000.
+ * - nickname validation mirrors ck_users_nickname — 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.
+ * - avatarAssetId validation mirrors the T3-03 referencing protocol
+ * — 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.
+ *
+ *
+ * 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}).
+ */
+@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());
+ }
+}
diff --git a/patbond-user/src/main/resources/application.yml.sample b/patbond-user/src/main/resources/application.yml.sample
index ff0a6b2..cf8628b 100644
--- a/patbond-user/src/main/resources/application.yml.sample
+++ b/patbond-user/src/main/resources/application.yml.sample
@@ -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:
diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeAvatarSigningIntegrationTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeAvatarSigningIntegrationTest.java
new file mode 100644
index 0000000..9d1af1a
--- /dev/null
+++ b/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeAvatarSigningIntegrationTest.java
@@ -0,0 +1,217 @@
+package com.patbond.patbond.user.controller;
+
+import com.jayway.jsonpath.JsonPath;
+import com.patbond.patbond.user.TestcontainersConfiguration;
+import com.patbond.patbond.user.support.TestJwtKeys;
+import com.patbond.patbond.user.support.UuidV7;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.MediaType;
+import org.springframework.jdbc.core.simple.JdbcClient;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.testcontainers.containers.MinIOContainer;
+import org.testcontainers.utility.DockerImageName;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * T3.5-04 头像全链路(真实 MinIO Testcontainer,镜像 tag 与 compose 一致):
+ * 以 {@code purpose=user_avatar} 创建上传 → 凭据直传 → complete 置 ready →
+ * PATCH /me 挂头像 → GET /me 的 {@code avatarUrl} 是可真实下载的预签名 GET →
+ * 清空后回到 null。同时实证新加入白名单的 user_avatar 用途端到端可用,以及
+ * 「真实未就绪 asset 被拒 42203」(非 SQL 造数据的那一版)。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@Import(TestcontainersConfiguration.class)
+class MeAvatarSigningIntegrationTest {
+
+ /** 与 docker-compose.yml 的 minio 服务钉同一 tag(ADR-016 三环境零分叉)。 */
+ private static final MinIOContainer MINIO = new MinIOContainer(
+ DockerImageName.parse("minio/minio:RELEASE.2025-04-22T22-12-26Z"))
+ // 值仅为测试占位(dummy),非真实凭证
+ .withUserName("minio-dummy-access")
+ .withPassword("minio-dummy-secret");
+
+ private static final HttpClient HTTP = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .build();
+
+ private static final byte[] FAKE_JPEG = fakeJpeg();
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private JdbcClient jdbcClient;
+
+ @DynamicPropertySource
+ static void wireMedia(DynamicPropertyRegistry registry) {
+ MINIO.start();
+ registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
+ registry.add("patbond.media.endpoint", MINIO::getS3URL);
+ registry.add("patbond.media.access-key", MINIO::getUserName);
+ registry.add("patbond.media.secret-key", MINIO::getPassword);
+ }
+
+ private static byte[] fakeJpeg() {
+ byte[] bytes = new byte[1024];
+ for (int i = 0; i < bytes.length; i++) {
+ bytes[i] = (byte) (i * 17);
+ }
+ bytes[0] = (byte) 0xFF;
+ bytes[1] = (byte) 0xD8; // JPEG SOI
+ return bytes;
+ }
+
+ private UUID newUser(String username) {
+ UUID id = UuidV7.generate();
+ jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
+ .param("id", id)
+ .param("username", username)
+ .update();
+ return id;
+ }
+
+ private static String bearer(UUID userId) {
+ return "Bearer " + TestJwtKeys.accessToken(
+ TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
+ }
+
+ /** 以 user_avatar 用途申请上传凭据(本单新加入白名单)。 */
+ private String createAvatarUpload(UUID user) throws Exception {
+ return mockMvc.perform(post("/api/v1/media/uploads")
+ .header("Authorization", bearer(user))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"kind":"image","purpose":"user_avatar",
+ "mimeType":"image/jpeg","byteSize":%d}
+ """.formatted(FAKE_JPEG.length)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.data.assetId").isNotEmpty())
+ .andReturn().getResponse().getContentAsString();
+ }
+
+ private void directPut(String createdBody) throws Exception {
+ String uploadUrl = JsonPath.read(createdBody, "$.data.uploadUrl");
+ Map 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 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));
+ }
+}
diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeEndpointTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeEndpointTest.java
index 0213bde..c6df0b5 100644
--- a/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeEndpointTest.java
+++ b/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeEndpointTest.java
@@ -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
diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeProfileIntegrationTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeProfileIntegrationTest.java
new file mode 100644
index 0000000..6b9d258
--- /dev/null
+++ b/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeProfileIntegrationTest.java
@@ -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 回退)。
+ *
+ * 本类不配置对象存储,因此断言 {@code avatarUrl} 恒为 null——这正是
+ * 「存储未配置时资料读取整体降级而不失败」的实证;真实签名 URL 的全链路
+ * (创建上传 → 直传 → complete → 挂头像 → URL 可访问)在
+ * MeAvatarSigningIntegrationTest 用真实 MinIO 覆盖。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@Import(TestcontainersConfiguration.class)
+class MeProfileIntegrationTest {
+
+ private static final String INTERNAL_TOKEN = "test-internal-token";
+
+ /** 32 个 CJK 码点:恰好压在 ck_users_nickname 的上界上。 */
+ private static final String NICKNAME_32_CJK = "豆".repeat(32);
+
+ /**
+ * 32 个 emoji 码点(UTF-16 长度 64):证明长度校验按码点而非 Java
+ * String.length() 计——PostgreSQL char_length 数的是码点,若按 UTF-16
+ * 长度校验,这个数据库能存的昵称会被应用层误拒。
+ */
+ private static final String NICKNAME_32_EMOJI = "🐶".repeat(32);
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private JdbcClient jdbcClient;
+
+ @DynamicPropertySource
+ static void jwtPublicKey(DynamicPropertyRegistry registry) {
+ registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
+ }
+
+ // ---- helpers -------------------------------------------------------
+
+ private UUID newUser(String username) {
+ UUID id = UuidV7.generate();
+ jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
+ .param("id", id)
+ .param("username", username)
+ .update();
+ return id;
+ }
+
+ private static String bearer(UUID userId) {
+ return "Bearer " + TestJwtKeys.accessToken(
+ TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
+ }
+
+ /** 一枚 media.assets 行,用途/状态/归属可控(模拟 T3-03 的产物)。 */
+ private UUID insertAsset(UUID ownerUserId, String purpose, String status) {
+ UUID id = UuidV7.generate();
+ jdbcClient.sql("""
+ INSERT INTO media.assets
+ (id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
+ mime_type, byte_size, status, ready_at, deleted_at)
+ VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media',
+ :objectKey, 'image/jpeg', 2048, :status, :readyAt, :deletedAt)
+ """)
+ .param("id", id)
+ .param("owner", ownerUserId)
+ .param("purpose", purpose)
+ .param("objectKey", purpose + "/2026/09/" + id)
+ .param("status", status)
+ .param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
+ // ck_media_deleted:status='deleted' 必带 deleted_at
+ .param("deletedAt", "deleted".equals(status) ? OffsetDateTime.now() : null)
+ .update();
+ return id;
+ }
+
+ private String patchMe(UUID userId, String body, int expectedStatus) throws Exception {
+ return mockMvc.perform(patch("/api/v1/me")
+ .header("Authorization", bearer(userId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().is(expectedStatus))
+ .andReturn().getResponse().getContentAsString();
+ }
+
+ private void patchMeExpectingCode(UUID userId, String body, int httpStatus, int bizCode)
+ throws Exception {
+ mockMvc.perform(patch("/api/v1/me")
+ .header("Authorization", bearer(userId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().is(httpStatus))
+ .andExpect(jsonPath("$.code").value(bizCode));
+ }
+
+ private String getMe(UUID userId) throws Exception {
+ return mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(userId)))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+ }
+
+ /** 他人视角的展示名(/internal 批量接口,回退在 SQL 层)。 */
+ private String publicNickname(UUID userId) throws Exception {
+ String body = mockMvc.perform(get("/internal/users/profiles")
+ .header(InternalAuthFilter.HEADER, INTERNAL_TOKEN)
+ .queryParam("ids", userId.toString()))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+ List