From a5634c5f8581943fdbf4c67ae7993d64fa24de01 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 11 Sep 2026 10:02:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E8=B5=84=E6=96=99?= =?UTF-8?q?=E8=AF=BB=E5=86=99=E2=80=94=E2=80=94GET/PATCH=20/api/v1/me=20?= =?UTF-8?q?=E8=A1=A5=E6=98=B5=E7=A7=B0=E4=B8=8E=E5=A4=B4=E5=83=8F=20URL?= =?UTF-8?q?=EF=BC=88T3.5-04=EF=BC=8CADR-022=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../patbond/user/controller/MeController.java | 40 +- .../patbond/patbond/user/dto/MeResponse.java | 23 +- .../patbond/user/dto/UpdateMeRequest.java | 67 +++ .../patbond/user/media/MediaProperties.java | 12 +- .../user/repository/UserRepository.java | 74 +++ .../user/service/MeProfileService.java | 152 +++++++ .../src/main/resources/application.yml.sample | 4 +- .../MeAvatarSigningIntegrationTest.java | 217 +++++++++ .../user/controller/MeEndpointTest.java | 15 +- .../controller/MeProfileIntegrationTest.java | 430 ++++++++++++++++++ .../media/MediaUploadIntegrationTest.java | 4 +- 11 files changed, 1014 insertions(+), 24 deletions(-) create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/dto/UpdateMeRequest.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/service/MeProfileService.java create mode 100644 patbond-user/src/test/java/com/patbond/patbond/user/controller/MeAvatarSigningIntegrationTest.java create mode 100644 patbond-user/src/test/java/com/patbond/patbond/user/controller/MeProfileIntegrationTest.java diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/controller/MeController.java b/patbond-user/src/main/java/com/patbond/patbond/user/controller/MeController.java index f6f0600..309beb8 100644 --- a/patbond-user/src/main/java/com/patbond/patbond/user/controller/MeController.java +++ b/patbond-user/src/main/java/com/patbond/patbond/user/controller/MeController.java @@ -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. + * + *

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.

*/ @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 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 me( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) { + return ApiResponse.success(meProfileService.get(userId)); + } + + @PatchMapping("/api/v1/me") + public ApiResponse 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> rows = JsonPath.read(body, "$.data"); + return (String) rows.get(0).get("nickname"); + } + + private UUID dbAvatarAssetId(UUID userId) { + return jdbcClient.sql("SELECT avatar_asset_id FROM identity.users WHERE id = :id") + .param("id", userId) + .query(UUID.class) + .optional() + .orElse(null); + } + + // ---- 成功路径 ------------------------------------------------------- + + @Test + void setsNicknameAndBothSidesAgree() throws Exception { + UUID user = newUser("me_nick_set"); + + String patched = patchMe(user, "{\"nickname\":\"豆豆家长\"}", 200); + assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("豆豆家长"); + + assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("豆豆家长"); + // 双端一致:他人看到的展示名也立即是昵称(Feed 作者名同链路) + assertThat(publicNickname(user)).isEqualTo("豆豆家长"); + } + + @Test + void trimsNicknameLikeBtrim() throws Exception { + UUID user = newUser("me_nick_trim"); + String patched = patchMe(user, "{\"nickname\":\" 豆豆 \"}", 200); + assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("豆豆"); + } + + /** + * /me 是本人编辑态,故 nickname 为 DB 原值(清空后为 null);他人视角的 + * /internal 才回退 username。这两者的差异是本单的核心语义定型。 + */ + @Test + void clearingNicknameIsNullOnMeButFallsBackForOtherPeople() throws Exception { + UUID user = newUser("me_nick_clear"); + patchMe(user, "{\"nickname\":\"临时昵称\"}", 200); + + String cleared = patchMe(user, "{\"nickname\":null}", 200); + assertThat((Object) JsonPath.read(cleared, "$.data.nickname")).isNull(); + assertThat((Object) JsonPath.read(getMe(user), "$.data.nickname")).isNull(); + assertThat(publicNickname(user)).isEqualTo("me_nick_clear"); + } + + @Test + void absentFieldIsLeftUnchanged() throws Exception { + UUID user = newUser("me_absent"); + patchMe(user, "{\"nickname\":\"保持不动\"}", 200); + + // 只提交 avatarAssetId=null:nickname 未出现在 body 里,必须不受影响 + String patched = patchMe(user, "{\"avatarAssetId\":null}", 200); + assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("保持不动"); + } + + @Test + void setsAndClearsAvatar() throws Exception { + UUID user = newUser("me_avatar_ok"); + UUID asset = insertAsset(user, "user_avatar", "ready"); + + String patched = patchMe(user, "{\"avatarAssetId\":\"%s\"}".formatted(asset), 200); + assertThat(dbAvatarAssetId(user)).isEqualTo(asset); + // 对象存储未配置 → 整体降级为 null URL,而不是 500 + assertThat((Object) JsonPath.read(patched, "$.data.avatarUrl")).isNull(); + + patchMe(user, "{\"avatarAssetId\":null}", 200); + assertThat(dbAvatarAssetId(user)).isNull(); + } + + @Test + void acceptsNicknameAndAvatarInOneRequest() throws Exception { + UUID user = newUser("me_both"); + UUID asset = insertAsset(user, "user_avatar", "ready"); + + String patched = patchMe(user, + "{\"nickname\":\"一次改两样\",\"avatarAssetId\":\"%s\"}".formatted(asset), 200); + assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("一次改两样"); + assertThat(dbAvatarAssetId(user)).isEqualTo(asset); + } + + // ---- 参数错路径(昵称边界值 + 畸形入参 + 空 patch) ------------------- + + @Test + void acceptsNicknameAtBothBoundaries() throws Exception { + UUID user = newUser("me_nick_bounds"); + + assertThat((String) JsonPath.read(patchMe(user, "{\"nickname\":\"豆\"}", 200), + "$.data.nickname")).isEqualTo("豆"); + assertThat((String) JsonPath.read( + patchMe(user, "{\"nickname\":\"%s\"}".formatted(NICKNAME_32_CJK), 200), + "$.data.nickname")).isEqualTo(NICKNAME_32_CJK); + assertThat((String) JsonPath.read( + patchMe(user, "{\"nickname\":\"%s\"}".formatted(NICKNAME_32_EMOJI), 200), + "$.data.nickname")).isEqualTo(NICKNAME_32_EMOJI); + } + + @Test + void rejectsNicknameOverThirtyTwoCodePoints() throws Exception { + UUID user = newUser("me_nick_long"); + patchMeExpectingCode(user, "{\"nickname\":\"%s\"}".formatted("豆".repeat(33)), 400, 40000); + patchMeExpectingCode(user, "{\"nickname\":\"%s\"}".formatted("🐶".repeat(33)), 400, 40000); + // 拒绝后 DB 未被写入 + assertThat((Object) JsonPath.read(getMe(user), "$.data.nickname")).isNull(); + } + + /** + * 空白昵称是参数错,不是「隐式清空」——清空只有显式 null 一种表达, + * 否则「用户不小心提交了空格」与「用户想删昵称」无法区分。 + */ + @Test + void rejectsWhitespaceOnlyNicknameInsteadOfClearing() throws Exception { + UUID user = newUser("me_nick_blank"); + patchMe(user, "{\"nickname\":\"原昵称\"}", 200); + + patchMeExpectingCode(user, "{\"nickname\":\" \"}", 400, 40000); + patchMeExpectingCode(user, "{\"nickname\":\"\"}", 400, 40000); + assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("原昵称"); + } + + @Test + void rejectsPatchThatTouchesNothing() throws Exception { + UUID user = newUser("me_empty_patch"); + patchMeExpectingCode(user, "{}", 400, 40000); + // 只带未声明字段同样等于「什么都没改」 + patchMeExpectingCode(user, "{\"unknownField\":\"x\"}", 400, 40000); + } + + @Test + void rejectsMalformedBody() throws Exception { + UUID user = newUser("me_malformed"); + patchMeExpectingCode(user, "{\"avatarAssetId\":\"not-a-uuid\"}", 400, 40000); + patchMeExpectingCode(user, "{\"nickname\":", 400, 40000); + patchMeExpectingCode(user, "{\"avatarAssetId\":42}", 400, 40000); + } + + // ---- 头像 asset 非法三态(不存在/非本人/错用途/未就绪) --------------- + + @Test + void rejectsUnknownOrForeignAvatarAssetWithMergedNotFound() throws Exception { + UUID user = newUser("me_asset_foreign"); + UUID stranger = newUser("me_asset_stranger"); + UUID strangerAsset = insertAsset(stranger, "user_avatar", "ready"); + + // 幽灵 id 与他人 asset 同答 40405(防枚举合并) + patchMeExpectingCode(user, + "{\"avatarAssetId\":\"%s\"}".formatted(UUID.randomUUID()), 404, 40405); + patchMeExpectingCode(user, + "{\"avatarAssetId\":\"%s\"}".formatted(strangerAsset), 404, 40405); + assertThat(dbAvatarAssetId(user)).isNull(); + } + + @Test + void rejectsAssetWithWrongPurpose() throws Exception { + UUID user = newUser("me_asset_purpose"); + UUID postImage = insertAsset(user, "post_image", "ready"); + + patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(postImage), 404, 40405); + assertThat(dbAvatarAssetId(user)).isNull(); + } + + @Test + void rejectsAvatarAssetThatIsNotReady() throws Exception { + UUID user = newUser("me_asset_state"); + UUID uploading = insertAsset(user, "user_avatar", "uploading"); + UUID failed = insertAsset(user, "user_avatar", "failed"); + UUID deleted = insertAsset(user, "user_avatar", "deleted"); + + patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(uploading), 422, 42203); + patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(failed), 422, 42203); + // deleted 归入防枚举合并的 40405:已删资源对引用方就是不存在 + patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(deleted), 404, 40405); + assertThat(dbAvatarAssetId(user)).isNull(); + } + + // ---- 无权限路径 ----------------------------------------------------- + + @Test + void patchWithoutOrWithBadTokenIs40101() throws Exception { + mockMvc.perform(patch("/api/v1/me") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"nickname\":\"无票乘车\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + mockMvc.perform(patch("/api/v1/me") + .header("Authorization", "Bearer not.a.jwt") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"nickname\":\"伪票\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } + + // ---- 不存在路径 ----------------------------------------------------- + + @Test + void softDeletedUserGetsUserNotFoundOnBothVerbs() throws Exception { + UUID user = newUser("me_gone"); + jdbcClient.sql(""" + UPDATE identity.users + SET status = 'deleted', deleted_at = now() + WHERE id = :id + """) + .param("id", user) + .update(); + + mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(40400)); + patchMeExpectingCode(user, "{\"nickname\":\"亡者昵称\"}", 404, 40400); + } + + // ---- 并发路径 ------------------------------------------------------- + + /** + * 两个并发 PATCH 各改一个字段:都必须留下。这是「列级选择性 UPDATE 而非 + * 读-合并-写」的实证——若走整行合并写,后到的那个会把对方刚写的字段 + * 悄悄还原(丢失更新)。/me 无版本号乐观锁,靠的正是这个性质。 + */ + @Test + void concurrentDisjointPatchesBothSurvive() throws Exception { + UUID user = newUser("me_concurrent"); + UUID asset = insertAsset(user, "user_avatar", "ready"); + CyclicBarrier startTogether = new CyclicBarrier(2); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Callable nicknameWriter = () -> { + startTogether.await(); + return mockMvc.perform(patch("/api/v1/me") + .header("Authorization", bearer(user)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"nickname\":\"并发昵称\"}")) + .andReturn().getResponse().getStatus(); + }; + Callable 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 first = pool.submit(nicknameWriter); + Future 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")); + } +} diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/media/MediaUploadIntegrationTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/media/MediaUploadIntegrationTest.java index 2e5b464..d0c9e32 100644 --- a/patbond-user/src/test/java/com/patbond/patbond/user/media/MediaUploadIntegrationTest.java +++ b/patbond-user/src/test/java/com/patbond/patbond/user/media/MediaUploadIntegrationTest.java @@ -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())