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>
This commit is contained in:
@@ -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:
|
||||
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package com.patbond.patbond.user.controller;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||
import com.patbond.patbond.user.support.TestJwtKeys;
|
||||
import com.patbond.patbond.user.support.UuidV7;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.testcontainers.containers.MinIOContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* T3.5-04 头像全链路(真实 MinIO Testcontainer,镜像 tag 与 compose 一致):
|
||||
* 以 {@code purpose=user_avatar} 创建上传 → 凭据直传 → complete 置 ready →
|
||||
* PATCH /me 挂头像 → GET /me 的 {@code avatarUrl} 是可真实下载的预签名 GET →
|
||||
* 清空后回到 null。同时实证新加入白名单的 user_avatar 用途端到端可用,以及
|
||||
* 「真实未就绪 asset 被拒 42203」(非 SQL 造数据的那一版)。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class MeAvatarSigningIntegrationTest {
|
||||
|
||||
/** 与 docker-compose.yml 的 minio 服务钉同一 tag(ADR-016 三环境零分叉)。 */
|
||||
private static final MinIOContainer MINIO = new MinIOContainer(
|
||||
DockerImageName.parse("minio/minio:RELEASE.2025-04-22T22-12-26Z"))
|
||||
// 值仅为测试占位(dummy),非真实凭证
|
||||
.withUserName("minio-dummy-access")
|
||||
.withPassword("minio-dummy-secret");
|
||||
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
private static final byte[] FAKE_JPEG = fakeJpeg();
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcClient jdbcClient;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void wireMedia(DynamicPropertyRegistry registry) {
|
||||
MINIO.start();
|
||||
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
|
||||
registry.add("patbond.media.endpoint", MINIO::getS3URL);
|
||||
registry.add("patbond.media.access-key", MINIO::getUserName);
|
||||
registry.add("patbond.media.secret-key", MINIO::getPassword);
|
||||
}
|
||||
|
||||
private static byte[] fakeJpeg() {
|
||||
byte[] bytes = new byte[1024];
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = (byte) (i * 17);
|
||||
}
|
||||
bytes[0] = (byte) 0xFF;
|
||||
bytes[1] = (byte) 0xD8; // JPEG SOI
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private UUID newUser(String username) {
|
||||
UUID id = UuidV7.generate();
|
||||
jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
|
||||
.param("id", id)
|
||||
.param("username", username)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
private static String bearer(UUID userId) {
|
||||
return "Bearer " + TestJwtKeys.accessToken(
|
||||
TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
|
||||
}
|
||||
|
||||
/** 以 user_avatar 用途申请上传凭据(本单新加入白名单)。 */
|
||||
private String createAvatarUpload(UUID user) throws Exception {
|
||||
return mockMvc.perform(post("/api/v1/media/uploads")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"user_avatar",
|
||||
"mimeType":"image/jpeg","byteSize":%d}
|
||||
""".formatted(FAKE_JPEG.length)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.data.assetId").isNotEmpty())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
}
|
||||
|
||||
private void directPut(String createdBody) throws Exception {
|
||||
String uploadUrl = JsonPath.read(createdBody, "$.data.uploadUrl");
|
||||
Map<String, String> headers = JsonPath.read(createdBody, "$.data.requiredHeaders");
|
||||
HttpRequest.Builder put = HttpRequest.newBuilder(URI.create(uploadUrl))
|
||||
.PUT(HttpRequest.BodyPublishers.ofByteArray(FAKE_JPEG));
|
||||
headers.forEach(put::header);
|
||||
assertThat(HTTP.send(put.build(), HttpResponse.BodyHandlers.discarding()).statusCode())
|
||||
.isEqualTo(200);
|
||||
}
|
||||
|
||||
/** 走完 T3-03 两步上传,返回 ready 的 assetId。 */
|
||||
private String uploadReadyAvatar(UUID user) throws Exception {
|
||||
String created = createAvatarUpload(user);
|
||||
String assetId = JsonPath.read(created, "$.data.assetId");
|
||||
directPut(created);
|
||||
mockMvc.perform(post("/api/v1/media/uploads/{assetId}/complete", assetId)
|
||||
.header("Authorization", bearer(user)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.status").value("ready"));
|
||||
return assetId;
|
||||
}
|
||||
|
||||
@Test
|
||||
void avatarUrlIsAFreshPresignedGetThatActuallyDownloads() throws Exception {
|
||||
UUID user = newUser("avatar_signed");
|
||||
String assetId = uploadReadyAvatar(user);
|
||||
|
||||
String patched = mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"avatarAssetId\":\"%s\"}".formatted(assetId)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
|
||||
String avatarUrl = JsonPath.read(patched, "$.data.avatarUrl");
|
||||
assertThat(avatarUrl)
|
||||
.startsWith(MINIO.getS3URL() + "/patbond-media/user_avatar/")
|
||||
.contains("X-Amz-Signature=");
|
||||
|
||||
// 真实下载:签名有效,私有桶靠签名而非公开读
|
||||
HttpResponse<byte[]> download = HTTP.send(
|
||||
HttpRequest.newBuilder(URI.create(avatarUrl)).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray());
|
||||
assertThat(download.statusCode()).isEqualTo(200);
|
||||
assertThat(download.body()).isEqualTo(FAKE_JPEG);
|
||||
|
||||
// GET /me 每次重新签发(URL 会过期,客户端不得持久化)
|
||||
String fetched = mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
assertThat((String) JsonPath.read(fetched, "$.data.avatarUrl"))
|
||||
.startsWith(MINIO.getS3URL() + "/patbond-media/user_avatar/");
|
||||
|
||||
// 清空后回到 null
|
||||
String cleared = mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"avatarAssetId\":null}"))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
assertThat((Object) JsonPath.read(cleared, "$.data.avatarUrl")).isNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实的「凭据已发但还没直传」状态:asset 存在、属本人、用途正确,但仍是
|
||||
* uploading —— 引用侧必须 422/42203,不能挂上一个下载会 404 的头像。
|
||||
*/
|
||||
@Test
|
||||
void refusesAnAvatarAssetWhoseUploadNeverCompleted() throws Exception {
|
||||
UUID user = newUser("avatar_pending");
|
||||
String assetId = JsonPath.read(createAvatarUpload(user), "$.data.assetId");
|
||||
|
||||
mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"avatarAssetId\":\"%s\"}".formatted(assetId)))
|
||||
.andExpect(status().isUnprocessableEntity())
|
||||
.andExpect(jsonPath("$.code").value(42203));
|
||||
}
|
||||
|
||||
/** purpose 白名单:M3.5 之后 pet_avatar 同样可申请(宠物侧引用方在 pet 服务)。 */
|
||||
@Test
|
||||
void petAvatarPurposeIsAlsoWhitelisted() throws Exception {
|
||||
UUID user = newUser("avatar_purposes");
|
||||
mockMvc.perform(post("/api/v1/media/uploads")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"pet_avatar",
|
||||
"mimeType":"image/jpeg","byteSize":1024}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
// 白名单外的用途仍是 400/40000
|
||||
mockMvc.perform(post("/api/v1/media/uploads")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"kind":"image","purpose":"avatar",
|
||||
"mimeType":"image/jpeg","byteSize":1024}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import org.springframework.test.web.servlet.MockMvc;
|
||||
import java.time.Duration;
|
||||
import java.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
|
||||
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
package com.patbond.patbond.user.controller;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||
import com.patbond.patbond.user.security.InternalAuthFilter;
|
||||
import com.patbond.patbond.user.support.TestJwtKeys;
|
||||
import com.patbond.patbond.user.support.UuidV7;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CyclicBarrier;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.nullValue;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* T3.5-04 用户资料读写:GET/PATCH /api/v1/me 的六类路径(成功 / 参数错 /
|
||||
* 不存在 / 无权限 / 并发 / 重放)与三项专项(昵称边界值与清空、头像 asset
|
||||
* 非法三态、/me 不回退 而 /internal 回退)。
|
||||
*
|
||||
* <p>本类不配置对象存储,因此断言 {@code avatarUrl} 恒为 null——这正是
|
||||
* 「存储未配置时资料读取整体降级而不失败」的实证;真实签名 URL 的全链路
|
||||
* (创建上传 → 直传 → complete → 挂头像 → URL 可访问)在
|
||||
* MeAvatarSigningIntegrationTest 用真实 MinIO 覆盖。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class MeProfileIntegrationTest {
|
||||
|
||||
private static final String INTERNAL_TOKEN = "test-internal-token";
|
||||
|
||||
/** 32 个 CJK 码点:恰好压在 ck_users_nickname 的上界上。 */
|
||||
private static final String NICKNAME_32_CJK = "豆".repeat(32);
|
||||
|
||||
/**
|
||||
* 32 个 emoji 码点(UTF-16 长度 64):证明长度校验按码点而非 Java
|
||||
* String.length() 计——PostgreSQL char_length 数的是码点,若按 UTF-16
|
||||
* 长度校验,这个数据库能存的昵称会被应用层误拒。
|
||||
*/
|
||||
private static final String NICKNAME_32_EMOJI = "🐶".repeat(32);
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcClient jdbcClient;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void jwtPublicKey(DynamicPropertyRegistry registry) {
|
||||
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
|
||||
}
|
||||
|
||||
// ---- helpers -------------------------------------------------------
|
||||
|
||||
private UUID newUser(String username) {
|
||||
UUID id = UuidV7.generate();
|
||||
jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
|
||||
.param("id", id)
|
||||
.param("username", username)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
private static String bearer(UUID userId) {
|
||||
return "Bearer " + TestJwtKeys.accessToken(
|
||||
TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
|
||||
}
|
||||
|
||||
/** 一枚 media.assets 行,用途/状态/归属可控(模拟 T3-03 的产物)。 */
|
||||
private UUID insertAsset(UUID ownerUserId, String purpose, String status) {
|
||||
UUID id = UuidV7.generate();
|
||||
jdbcClient.sql("""
|
||||
INSERT INTO media.assets
|
||||
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
|
||||
mime_type, byte_size, status, ready_at, deleted_at)
|
||||
VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media',
|
||||
:objectKey, 'image/jpeg', 2048, :status, :readyAt, :deletedAt)
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("owner", ownerUserId)
|
||||
.param("purpose", purpose)
|
||||
.param("objectKey", purpose + "/2026/09/" + id)
|
||||
.param("status", status)
|
||||
.param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
|
||||
// ck_media_deleted:status='deleted' 必带 deleted_at
|
||||
.param("deletedAt", "deleted".equals(status) ? OffsetDateTime.now() : null)
|
||||
.update();
|
||||
return id;
|
||||
}
|
||||
|
||||
private String patchMe(UUID userId, String body, int expectedStatus) throws Exception {
|
||||
return mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", bearer(userId))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().is(expectedStatus))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
}
|
||||
|
||||
private void patchMeExpectingCode(UUID userId, String body, int httpStatus, int bizCode)
|
||||
throws Exception {
|
||||
mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", bearer(userId))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().is(httpStatus))
|
||||
.andExpect(jsonPath("$.code").value(bizCode));
|
||||
}
|
||||
|
||||
private String getMe(UUID userId) throws Exception {
|
||||
return mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(userId)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
}
|
||||
|
||||
/** 他人视角的展示名(/internal 批量接口,回退在 SQL 层)。 */
|
||||
private String publicNickname(UUID userId) throws Exception {
|
||||
String body = mockMvc.perform(get("/internal/users/profiles")
|
||||
.header(InternalAuthFilter.HEADER, INTERNAL_TOKEN)
|
||||
.queryParam("ids", userId.toString()))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
List<Map<String, Object>> rows = JsonPath.read(body, "$.data");
|
||||
return (String) rows.get(0).get("nickname");
|
||||
}
|
||||
|
||||
private UUID dbAvatarAssetId(UUID userId) {
|
||||
return jdbcClient.sql("SELECT avatar_asset_id FROM identity.users WHERE id = :id")
|
||||
.param("id", userId)
|
||||
.query(UUID.class)
|
||||
.optional()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
// ---- 成功路径 -------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void setsNicknameAndBothSidesAgree() throws Exception {
|
||||
UUID user = newUser("me_nick_set");
|
||||
|
||||
String patched = patchMe(user, "{\"nickname\":\"豆豆家长\"}", 200);
|
||||
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("豆豆家长");
|
||||
|
||||
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("豆豆家长");
|
||||
// 双端一致:他人看到的展示名也立即是昵称(Feed 作者名同链路)
|
||||
assertThat(publicNickname(user)).isEqualTo("豆豆家长");
|
||||
}
|
||||
|
||||
@Test
|
||||
void trimsNicknameLikeBtrim() throws Exception {
|
||||
UUID user = newUser("me_nick_trim");
|
||||
String patched = patchMe(user, "{\"nickname\":\" 豆豆 \"}", 200);
|
||||
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("豆豆");
|
||||
}
|
||||
|
||||
/**
|
||||
* /me 是本人编辑态,故 nickname 为 DB 原值(清空后为 null);他人视角的
|
||||
* /internal 才回退 username。这两者的差异是本单的核心语义定型。
|
||||
*/
|
||||
@Test
|
||||
void clearingNicknameIsNullOnMeButFallsBackForOtherPeople() throws Exception {
|
||||
UUID user = newUser("me_nick_clear");
|
||||
patchMe(user, "{\"nickname\":\"临时昵称\"}", 200);
|
||||
|
||||
String cleared = patchMe(user, "{\"nickname\":null}", 200);
|
||||
assertThat((Object) JsonPath.read(cleared, "$.data.nickname")).isNull();
|
||||
assertThat((Object) JsonPath.read(getMe(user), "$.data.nickname")).isNull();
|
||||
assertThat(publicNickname(user)).isEqualTo("me_nick_clear");
|
||||
}
|
||||
|
||||
@Test
|
||||
void absentFieldIsLeftUnchanged() throws Exception {
|
||||
UUID user = newUser("me_absent");
|
||||
patchMe(user, "{\"nickname\":\"保持不动\"}", 200);
|
||||
|
||||
// 只提交 avatarAssetId=null:nickname 未出现在 body 里,必须不受影响
|
||||
String patched = patchMe(user, "{\"avatarAssetId\":null}", 200);
|
||||
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("保持不动");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setsAndClearsAvatar() throws Exception {
|
||||
UUID user = newUser("me_avatar_ok");
|
||||
UUID asset = insertAsset(user, "user_avatar", "ready");
|
||||
|
||||
String patched = patchMe(user, "{\"avatarAssetId\":\"%s\"}".formatted(asset), 200);
|
||||
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
|
||||
// 对象存储未配置 → 整体降级为 null URL,而不是 500
|
||||
assertThat((Object) JsonPath.read(patched, "$.data.avatarUrl")).isNull();
|
||||
|
||||
patchMe(user, "{\"avatarAssetId\":null}", 200);
|
||||
assertThat(dbAvatarAssetId(user)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsNicknameAndAvatarInOneRequest() throws Exception {
|
||||
UUID user = newUser("me_both");
|
||||
UUID asset = insertAsset(user, "user_avatar", "ready");
|
||||
|
||||
String patched = patchMe(user,
|
||||
"{\"nickname\":\"一次改两样\",\"avatarAssetId\":\"%s\"}".formatted(asset), 200);
|
||||
assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("一次改两样");
|
||||
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
|
||||
}
|
||||
|
||||
// ---- 参数错路径(昵称边界值 + 畸形入参 + 空 patch) -------------------
|
||||
|
||||
@Test
|
||||
void acceptsNicknameAtBothBoundaries() throws Exception {
|
||||
UUID user = newUser("me_nick_bounds");
|
||||
|
||||
assertThat((String) JsonPath.read(patchMe(user, "{\"nickname\":\"豆\"}", 200),
|
||||
"$.data.nickname")).isEqualTo("豆");
|
||||
assertThat((String) JsonPath.read(
|
||||
patchMe(user, "{\"nickname\":\"%s\"}".formatted(NICKNAME_32_CJK), 200),
|
||||
"$.data.nickname")).isEqualTo(NICKNAME_32_CJK);
|
||||
assertThat((String) JsonPath.read(
|
||||
patchMe(user, "{\"nickname\":\"%s\"}".formatted(NICKNAME_32_EMOJI), 200),
|
||||
"$.data.nickname")).isEqualTo(NICKNAME_32_EMOJI);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNicknameOverThirtyTwoCodePoints() throws Exception {
|
||||
UUID user = newUser("me_nick_long");
|
||||
patchMeExpectingCode(user, "{\"nickname\":\"%s\"}".formatted("豆".repeat(33)), 400, 40000);
|
||||
patchMeExpectingCode(user, "{\"nickname\":\"%s\"}".formatted("🐶".repeat(33)), 400, 40000);
|
||||
// 拒绝后 DB 未被写入
|
||||
assertThat((Object) JsonPath.read(getMe(user), "$.data.nickname")).isNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* 空白昵称是参数错,不是「隐式清空」——清空只有显式 null 一种表达,
|
||||
* 否则「用户不小心提交了空格」与「用户想删昵称」无法区分。
|
||||
*/
|
||||
@Test
|
||||
void rejectsWhitespaceOnlyNicknameInsteadOfClearing() throws Exception {
|
||||
UUID user = newUser("me_nick_blank");
|
||||
patchMe(user, "{\"nickname\":\"原昵称\"}", 200);
|
||||
|
||||
patchMeExpectingCode(user, "{\"nickname\":\" \"}", 400, 40000);
|
||||
patchMeExpectingCode(user, "{\"nickname\":\"\"}", 400, 40000);
|
||||
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("原昵称");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPatchThatTouchesNothing() throws Exception {
|
||||
UUID user = newUser("me_empty_patch");
|
||||
patchMeExpectingCode(user, "{}", 400, 40000);
|
||||
// 只带未声明字段同样等于「什么都没改」
|
||||
patchMeExpectingCode(user, "{\"unknownField\":\"x\"}", 400, 40000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedBody() throws Exception {
|
||||
UUID user = newUser("me_malformed");
|
||||
patchMeExpectingCode(user, "{\"avatarAssetId\":\"not-a-uuid\"}", 400, 40000);
|
||||
patchMeExpectingCode(user, "{\"nickname\":", 400, 40000);
|
||||
patchMeExpectingCode(user, "{\"avatarAssetId\":42}", 400, 40000);
|
||||
}
|
||||
|
||||
// ---- 头像 asset 非法三态(不存在/非本人/错用途/未就绪) ---------------
|
||||
|
||||
@Test
|
||||
void rejectsUnknownOrForeignAvatarAssetWithMergedNotFound() throws Exception {
|
||||
UUID user = newUser("me_asset_foreign");
|
||||
UUID stranger = newUser("me_asset_stranger");
|
||||
UUID strangerAsset = insertAsset(stranger, "user_avatar", "ready");
|
||||
|
||||
// 幽灵 id 与他人 asset 同答 40405(防枚举合并)
|
||||
patchMeExpectingCode(user,
|
||||
"{\"avatarAssetId\":\"%s\"}".formatted(UUID.randomUUID()), 404, 40405);
|
||||
patchMeExpectingCode(user,
|
||||
"{\"avatarAssetId\":\"%s\"}".formatted(strangerAsset), 404, 40405);
|
||||
assertThat(dbAvatarAssetId(user)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAssetWithWrongPurpose() throws Exception {
|
||||
UUID user = newUser("me_asset_purpose");
|
||||
UUID postImage = insertAsset(user, "post_image", "ready");
|
||||
|
||||
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(postImage), 404, 40405);
|
||||
assertThat(dbAvatarAssetId(user)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAvatarAssetThatIsNotReady() throws Exception {
|
||||
UUID user = newUser("me_asset_state");
|
||||
UUID uploading = insertAsset(user, "user_avatar", "uploading");
|
||||
UUID failed = insertAsset(user, "user_avatar", "failed");
|
||||
UUID deleted = insertAsset(user, "user_avatar", "deleted");
|
||||
|
||||
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(uploading), 422, 42203);
|
||||
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(failed), 422, 42203);
|
||||
// deleted 归入防枚举合并的 40405:已删资源对引用方就是不存在
|
||||
patchMeExpectingCode(user, "{\"avatarAssetId\":\"%s\"}".formatted(deleted), 404, 40405);
|
||||
assertThat(dbAvatarAssetId(user)).isNull();
|
||||
}
|
||||
|
||||
// ---- 无权限路径 -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
void patchWithoutOrWithBadTokenIs40101() throws Exception {
|
||||
mockMvc.perform(patch("/api/v1/me")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"nickname\":\"无票乘车\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", "Bearer not.a.jwt")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"nickname\":\"伪票\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
// ---- 不存在路径 -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
void softDeletedUserGetsUserNotFoundOnBothVerbs() throws Exception {
|
||||
UUID user = newUser("me_gone");
|
||||
jdbcClient.sql("""
|
||||
UPDATE identity.users
|
||||
SET status = 'deleted', deleted_at = now()
|
||||
WHERE id = :id
|
||||
""")
|
||||
.param("id", user)
|
||||
.update();
|
||||
|
||||
mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40400));
|
||||
patchMeExpectingCode(user, "{\"nickname\":\"亡者昵称\"}", 404, 40400);
|
||||
}
|
||||
|
||||
// ---- 并发路径 -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 两个并发 PATCH 各改一个字段:都必须留下。这是「列级选择性 UPDATE 而非
|
||||
* 读-合并-写」的实证——若走整行合并写,后到的那个会把对方刚写的字段
|
||||
* 悄悄还原(丢失更新)。/me 无版本号乐观锁,靠的正是这个性质。
|
||||
*/
|
||||
@Test
|
||||
void concurrentDisjointPatchesBothSurvive() throws Exception {
|
||||
UUID user = newUser("me_concurrent");
|
||||
UUID asset = insertAsset(user, "user_avatar", "ready");
|
||||
CyclicBarrier startTogether = new CyclicBarrier(2);
|
||||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Callable<Integer> nicknameWriter = () -> {
|
||||
startTogether.await();
|
||||
return mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"nickname\":\"并发昵称\"}"))
|
||||
.andReturn().getResponse().getStatus();
|
||||
};
|
||||
Callable<Integer> avatarWriter = () -> {
|
||||
startTogether.await();
|
||||
return mockMvc.perform(patch("/api/v1/me")
|
||||
.header("Authorization", bearer(user))
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"avatarAssetId\":\"%s\"}".formatted(asset)))
|
||||
.andReturn().getResponse().getStatus();
|
||||
};
|
||||
Future<Integer> first = pool.submit(nicknameWriter);
|
||||
Future<Integer> second = pool.submit(avatarWriter);
|
||||
assertThat(first.get()).isEqualTo(200);
|
||||
assertThat(second.get()).isEqualTo(200);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
|
||||
assertThat((String) JsonPath.read(getMe(user), "$.data.nickname")).isEqualTo("并发昵称");
|
||||
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
|
||||
}
|
||||
|
||||
// ---- 重放路径 -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PATCH /me 是幂等的(无版本号、无幂等键):同一请求重放两次,第二次同样
|
||||
* 200 且状态与首次完全一致——重复点「保存」不会产生二次副作用。
|
||||
*/
|
||||
@Test
|
||||
void repeatingTheSamePatchIsStable() throws Exception {
|
||||
UUID user = newUser("me_replay");
|
||||
UUID asset = insertAsset(user, "user_avatar", "ready");
|
||||
String body = "{\"nickname\":\"重放昵称\",\"avatarAssetId\":\"%s\"}".formatted(asset);
|
||||
|
||||
String firstNickname = JsonPath.read(patchMe(user, body, 200), "$.data.nickname");
|
||||
String secondNickname = JsonPath.read(patchMe(user, body, 200), "$.data.nickname");
|
||||
|
||||
assertThat(secondNickname).isEqualTo(firstNickname).isEqualTo("重放昵称");
|
||||
assertThat(dbAvatarAssetId(user)).isEqualTo(asset);
|
||||
}
|
||||
|
||||
// ---- GET 形态回归 ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
void meCarriesNickAndAvatarUrlFieldsAlways() throws Exception {
|
||||
UUID user = newUser("me_shape");
|
||||
mockMvc.perform(get("/api/v1/me").header("Authorization", bearer(user)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.nickname").value(nullValue()))
|
||||
.andExpect(jsonPath("$.data.avatarUrl").value(nullValue()))
|
||||
.andExpect(jsonPath("$.data.username").value("me_shape"));
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -241,6 +241,8 @@ class MediaUploadIntegrationTest {
|
||||
@Test
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user