From a5634c5f8581943fdbf4c67ae7993d64fa24de01 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 11 Sep 2026 10:02:26 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E7=94=A8=E6=88=B7=E8=B5=84?= =?UTF-8?q?=E6=96=99=E8=AF=BB=E5=86=99=E2=80=94=E2=80=94GET/PATCH=20/api/v?= =?UTF-8?q?1/me=20=E8=A1=A5=E6=98=B5=E7=A7=B0=E4=B8=8E=E5=A4=B4=E5=83=8F?= =?UTF-8?q?=20URL=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()) -- 2.43.0 From 15c2e66519608e09465bc43900ec7073c796b826 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 11 Sep 2026 10:02:50 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E5=AE=A0=E7=89=A9=E5=A4=B4?= =?UTF-8?q?=E5=83=8F=E8=AF=BB=E5=86=99=E2=80=94=E2=80=94PATCH=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20avatarAssetId=E3=80=81=E8=AF=A6=E6=83=85=E4=B8=8E?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E8=A1=A5=20avatarUrl=EF=BC=88T3.5-05?= =?UTF-8?q?=EF=BC=8CADR-022=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PATCH /api/v1/pets/{petId} 支持 avatarAssetId 三态:缺省不改、显式 null 清空、给值设置。这是 pets 域唯一的三态字段——M2 惯例「null 与缺省同义」 无法表达「删掉头像」,而 name/species/sex 本就不允许为空,故差异刻意限定 在本字段 - 权限按「本次请求碰了哪些字段」定档:仅改头像为 WRITE(owner+caregiver, ADR-022:头像属日常照护信息,与体重/疫苗同档),碰到任一资料字段仍是 MANAGE(仅 owner),混合请求按更严的一半判;viewer 一律 403/40300 - 头像与资料共用同一把乐观锁:仅改头像也吃 version,旧版本必答 409/40902 - asset 校验复用 T3-03 引用侧协议 + purpose='pet_avatar':不存在/非本人/ 已删/用途不符答 404/40405,本人未就绪答 422/42203(user_avatar 资源也不能 当宠物头像) - 详情与列表响应补 avatarUrl(本地 SigV4 现签预签名 GET,沿用 community 读侧 先例):仅当 asset 为 ready 才签,指针在而资源退出 ready 时降级为 null, 不签一个下载必 404 的地址,也不隐式清理指针 - PetRepository 读改为返回 PetRow(含头像存储坐标),签名上移到 PetService, 与 community 的 PostRow → PostResponse 装配同构——过期 URL 不下沉到仓储层 - pet 模块加入 aws-sdk s3(仅本地签名,不直连对象存储)与读侧 patbond.media 配置;compose 补同一组 PATBOND_MINIO_* 环境变量 - 测试 +11(成功/清空/缺省保留/降级/caregiver 与 viewer 权限/防枚举 404/ asset 五态/畸形入参/乐观锁与重放),pet 模块 89 → 100 Co-Authored-By: Claude Fable 5 --- docker-compose.yml | 5 + patbond-pet/pom.xml | 9 + .../patbond/pet/config/MediaConfig.java | 22 ++ .../patbond/patbond/pet/dto/PetResponse.java | 8 + .../patbond/pet/dto/UpdatePetRequest.java | 42 +++ .../patbond/pet/media/MediaAssetGateway.java | 42 +++ .../patbond/pet/media/MediaAssetRef.java | 10 + .../patbond/pet/media/MediaUrlSigner.java | 61 ++++ .../patbond/pet/media/PetMediaProperties.java | 80 +++++ .../patbond/pet/repository/PetRepository.java | 59 ++- .../patbond/pet/service/PetService.java | 118 +++++- .../src/main/resources/application.yml.sample | 9 + .../controller/PetAvatarIntegrationTest.java | 335 ++++++++++++++++++ 13 files changed, 782 insertions(+), 18 deletions(-) create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/config/MediaConfig.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetGateway.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetRef.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaUrlSigner.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/media/PetMediaProperties.java create mode 100644 patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetAvatarIntegrationTest.java diff --git a/docker-compose.yml b/docker-compose.yml index bd462f2..37c942a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -102,6 +102,11 @@ services: PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond} PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env} PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem + # 媒体读取侧(M3.5 T3.5-05):宠物头像的预签名 GET 与 user 服务同一凭证/ + # 同一客户端可达地址(本地 SigV4 计算,不直连 MinIO,无需 depends_on minio)。 + PATBOND_MINIO_PUBLIC_ENDPOINT: ${PATBOND_MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000} + PATBOND_MINIO_ACCESS_KEY: ${PATBOND_MINIO_ROOT_USER:?先运行 deploy/init-secrets.sh 生成 .env} + PATBOND_MINIO_SECRET_KEY: ${PATBOND_MINIO_ROOT_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env} volumes: - ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro - ./deploy/keys:/run/patbond/keys:ro diff --git a/patbond-pet/pom.xml b/patbond-pet/pom.xml index d4c2110..9ddbf8f 100644 --- a/patbond-pet/pom.xml +++ b/patbond-pet/pom.xml @@ -43,6 +43,15 @@ postgresql runtime + + + software.amazon.awssdk + s3 + diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/config/MediaConfig.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/config/MediaConfig.java new file mode 100644 index 0000000..3587133 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/config/MediaConfig.java @@ -0,0 +1,22 @@ +package com.patbond.patbond.pet.config; + +import com.patbond.patbond.pet.media.MediaUrlSigner; +import com.patbond.patbond.pet.media.PetMediaProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Read-side media wiring (T3.5-05): a presigned-GET signer over the same + * MinIO configuration patbond-user uses (ADR-016). Bean destruction closes + * the underlying presigner. + */ +@Configuration +@EnableConfigurationProperties(PetMediaProperties.class) +public class MediaConfig { + + @Bean(destroyMethod = "close") + public MediaUrlSigner mediaUrlSigner(PetMediaProperties properties) { + return new MediaUrlSigner(properties); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/PetResponse.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/PetResponse.java index cdb27ac..8eb6b4c 100644 --- a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/PetResponse.java +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/PetResponse.java @@ -9,6 +9,13 @@ import java.util.UUID; * dictionary when {@code breedId} is set; exactly one of {@code breedId} / * {@code customBreedName} is non-null (ck_pets_breed). {@code myRole} is the * calling user's own pet_owners role — the client uses it to gate write UI. + * + *

{@code avatarUrl} (T3.5-05) is a freshly signed presigned GET against a + * private bucket: it EXPIRES and must never be persisted client-side (the + * client's image cache key strips the signature parameters). It is null both + * when the pet has no avatar and when the referenced asset is not (or no + * longer) ready, so "has an avatar" is exactly {@code avatarUrl != null}. The + * asset id is not echoed — the client only ever writes it.

*/ public record PetResponse( UUID id, @@ -24,6 +31,7 @@ public record PetResponse( String microchipNo, LocalDate sterilizedOn, String status, + String avatarUrl, String myRole, OffsetDateTime createdAt, OffsetDateTime updatedAt, diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdatePetRequest.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdatePetRequest.java index f3a326e..9804db5 100644 --- a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdatePetRequest.java +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdatePetRequest.java @@ -16,6 +16,21 @@ import java.util.UUID; * replaces the pair as a whole (they are mutually exclusive per * ck_pets_breed). {@code version} is mandatory — it is the optimistic lock * the whole endpoint exists to enforce. + * + *

{@code avatarAssetId} is the one three-state field (T3.5-05): + * absent = unchanged, explicit {@code null} = remove the avatar, value = set + * it. Removing an avatar is a first-class user action with no other way to + * express it, whereas the M2 fields either cannot be empty at all (name, + * species, sex) or are edited, not erased — so the asymmetry is deliberate + * and confined to this field. Presence is tracked in the setter: Jackson + * calls it exactly when the JSON key is present, including for an explicit + * null.

+ * + *

Permission note: this endpoint is MANAGE (owner only), but an + * avatar-ONLY patch is WRITE (owner + caregiver) per ADR-022 — the avatar is + * day-to-day care information, same tier as weights and vaccinations. The + * required level is therefore computed from which fields the body touches; + * see PetService.

*/ public class UpdatePetRequest { @@ -56,6 +71,33 @@ public class UpdatePetRequest { message = "status 仅支持 active/lost/deceased/archived") private String status; + private UUID avatarAssetId; + private boolean avatarAssetIdPresent; + + public UUID getAvatarAssetId() { + return avatarAssetId; + } + + public void setAvatarAssetId(UUID avatarAssetId) { + this.avatarAssetId = avatarAssetId; + this.avatarAssetIdPresent = true; + } + + public boolean isAvatarAssetIdPresent() { + return avatarAssetIdPresent; + } + + /** + * True when the body touches any pet-profile field, i.e. anything beyond + * the avatar. {@code version} does not count — it is the lock, not an + * edit. Drives the MANAGE-vs-WRITE decision in PetService. + */ + public boolean touchesProfileFields() { + return name != null || breedId != null || customBreedName != null || sex != null + || birthDate != null || birthDateEstimated != null || personality != null + || microchipNo != null || sterilizedOn != null || status != null; + } + public Integer getVersion() { return version; } diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetGateway.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetGateway.java new file mode 100644 index 0000000..943a156 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetGateway.java @@ -0,0 +1,42 @@ +package com.patbond.patbond.pet.media; + +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +/** + * Read-only cross-schema access to media.assets — the pet side of the T3-03 + * 联调协议 (business references accept only assets owned by the caller with + * status='ready' and the matching purpose). Same-database read was chosen + * over an internal HTTP call to patbond-user, exactly as patbond-community + * did (ADR-017 precedent: while the schemas share one database this is a + * cross-schema read; splitting the database later moves every such gateway to + * an internal API together). This class never writes media.assets — the media + * state machine belongs to patbond-user. + */ +@Repository +public class MediaAssetGateway { + + private final JdbcClient jdbcClient; + + public MediaAssetGateway(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + public Optional findById(UUID assetId) { + return jdbcClient.sql(""" + SELECT id, owner_user_id, purpose, status + FROM media.assets + WHERE id = :id + """) + .param("id", assetId) + .query((rs, rowNum) -> new MediaAssetRef( + rs.getObject("id", UUID.class), + rs.getObject("owner_user_id", UUID.class), + rs.getString("purpose"), + rs.getString("status"))) + .optional(); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetRef.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetRef.java new file mode 100644 index 0000000..62c5d3d --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetRef.java @@ -0,0 +1,10 @@ +package com.patbond.patbond.pet.media; + +import java.util.UUID; + +/** + * Read-only view of one media.assets row — exactly the columns the pet + * avatar flow needs for attach validation (owner, purpose, status). + */ +public record MediaAssetRef(UUID id, UUID ownerUserId, String purpose, String status) { +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaUrlSigner.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaUrlSigner.java new file mode 100644 index 0000000..c3bc395 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaUrlSigner.java @@ -0,0 +1,61 @@ +package com.patbond.patbond.pet.media; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +import java.net.URI; + +/** + * Signs presigned GET URLs for pet avatars (T3-03 定型:private bucket + + * presigned GET, TTL configurable, signed fresh on every response — clients + * never persist the URL). Presigning is a local SigV4 computation against the + * public endpoint; this service never talks to the object store itself. + * Path-style addressing is forced because MinIO has no wildcard DNS for + * virtual-host-style buckets (same as patbond-user's S3ObjectStorage and + * patbond-community's signer). When unconfigured, {@link #signGet} returns + * null and pet responses degrade to {@code avatarUrl: null}. + */ +public class MediaUrlSigner implements AutoCloseable { + + private final PetMediaProperties properties; + private final S3Presigner presigner; + + public MediaUrlSigner(PetMediaProperties properties) { + this.properties = properties; + if (properties.getPublicEndpoint().isBlank()) { + this.presigner = null; + return; + } + this.presigner = S3Presigner.builder() + .endpointOverride(URI.create(properties.getPublicEndpoint())) + .region(Region.of(properties.getRegion())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()))) + .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()) + .build(); + } + + /** @return a presigned GET URL, or null when storage is unconfigured */ + public String signGet(String bucket, String objectKey) { + if (presigner == null || bucket == null || objectKey == null) { + return null; + } + return presigner.presignGetObject(GetObjectPresignRequest.builder() + .signatureDuration(properties.getDownloadTtl()) + .getObjectRequest(b -> b.bucket(bucket).key(objectKey)) + .build()) + .url() + .toString(); + } + + @Override + public void close() { + if (presigner != null) { + presigner.close(); + } + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/media/PetMediaProperties.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/PetMediaProperties.java new file mode 100644 index 0000000..19ed491 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/PetMediaProperties.java @@ -0,0 +1,80 @@ +package com.patbond.patbond.pet.media; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +/** + * Read-side subset of the media object-storage configuration (T3.5-05). The + * write side — upload flow, mime/purpose whitelists, bucket init — lives in + * patbond-user's MediaProperties; this service only signs presigned GET URLs + * for pet avatars, a purely local SigV4 computation, so no S3 client is + * needed. Values reuse the same PATBOND_MINIO_* / PATBOND_MEDIA_* + * environment variables as patbond-user and patbond-community, keeping one + * set of knobs per deployment (ADR-016/021). + */ +@ConfigurationProperties(prefix = "patbond.media") +public class PetMediaProperties { + + /** + * Endpoint presigned GET URLs are issued against — the address CLIENTS + * can reach. Empty means media is unconfigured for this service: pet + * responses carry {@code avatarUrl: null} (same degradation precedent as + * the missing JWT public key). + */ + private String publicEndpoint = ""; + + /** S3 access key; injected via environment, never committed (ADR-021). */ + private String accessKey = ""; + + /** S3 secret key; injected via environment, never committed (ADR-021). */ + private String secretKey = ""; + + /** SigV4 region; MinIO accepts any value, cloud stores need the real one. */ + private String region = "us-east-1"; + + /** TTL of presigned GET URLs (the bucket stays private, T3-03 定型). */ + private Duration downloadTtl = Duration.ofHours(1); + + public String getPublicEndpoint() { + return publicEndpoint; + } + + public void setPublicEndpoint(String publicEndpoint) { + this.publicEndpoint = publicEndpoint; + } + + public String getAccessKey() { + return accessKey; + } + + // setter 形参名取 value:check-secrets 的 KEY-ASSIGN 规则会把「字段 = 同名 + // 形参」的自赋值误报为凭证字面量,规则表三仓同构不单方面改(ADR-021) + public void setAccessKey(String value) { + this.accessKey = value; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String value) { + this.secretKey = value; + } + + public String getRegion() { + return region; + } + + public void setRegion(String region) { + this.region = region; + } + + public Duration getDownloadTtl() { + return downloadTtl; + } + + public void setDownloadTtl(Duration downloadTtl) { + this.downloadTtl = downloadTtl; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java index 4347b64..f0ca535 100644 --- a/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java @@ -1,6 +1,5 @@ package com.patbond.patbond.pet.repository; -import com.patbond.patbond.pet.dto.PetResponse; import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Repository; @@ -16,6 +15,12 @@ import java.util.UUID; * pet_health.pets + pet_owners access. All reads join pet_owners on the * calling user so a row only comes back when a relationship exists — the * repository layer itself never exposes another user's pet. + * + *

Reads return {@link PetRow}, not the API DTO: the avatar travels as + * storage coordinates (bucket + object key of a READY media asset) and the + * presigned URL is produced one layer up, in PetService — same split as + * patbond-community's PostRow → PostResponse assembly, and the reason a + * signed, expiring URL never leaks into a repository-level cache.

*/ @Repository public class PetRepository { @@ -24,10 +29,13 @@ public class PetRepository { SELECT p.id, p.name, p.species, p.breed_id, b.display_name AS breed_display_name, p.custom_breed_name, p.sex, p.birth_date, p.birth_date_estimated, p.personality, p.microchip_no, p.sterilized_on, p.status, + p.avatar_asset_id, + av.bucket AS avatar_bucket, av.object_key AS avatar_object_key, po.role, p.created_at, p.updated_at, p.version FROM pet_health.pets p JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId LEFT JOIN pet_health.breeds b ON b.id = p.breed_id + LEFT JOIN media.assets av ON av.id = p.avatar_asset_id AND av.status = 'ready' WHERE p.status <> 'deleted' """; @@ -37,6 +45,37 @@ public class PetRepository { this.jdbcClient = jdbcClient; } + /** + * One pet as stored, from the calling user's perspective. + * {@code avatarAssetId} is the raw column (so a PATCH that does not touch + * the avatar can carry it through unchanged), while the two storage + * columns are already narrowed to a READY asset — a dangling or + * still-uploading avatar yields nulls there (→ {@code avatarUrl: null}) + * rather than a signed URL that would 404 at the object store. + */ + public record PetRow( + UUID id, + String name, + String species, + UUID breedId, + String breedDisplayName, + String customBreedName, + String sex, + LocalDate birthDate, + Boolean birthDateEstimated, + String personality, + String microchipNo, + LocalDate sterilizedOn, + String status, + UUID avatarAssetId, + String avatarBucket, + String avatarObjectKey, + String myRole, + OffsetDateTime createdAt, + OffsetDateTime updatedAt, + Integer version) { + } + public void insertPet(UUID petId, String name, String species, UUID breedId, String customBreedName, String sex, LocalDate birthDate, boolean birthDateEstimated, String personality, @@ -72,14 +111,14 @@ public class PetRepository { .update(); } - public List listByUser(UUID userId) { + public List listByUser(UUID userId) { return jdbcClient.sql(SELECT_PET + " ORDER BY p.created_at DESC, p.id DESC") .param("userId", userId) .query(PetRepository::mapPet) .list(); } - public Optional findByIdForUser(UUID petId, UUID userId) { + public Optional findByIdForUser(UUID petId, UUID userId) { return jdbcClient.sql(SELECT_PET + " AND p.id = :petId") .param("userId", userId) .param("petId", petId) @@ -109,14 +148,16 @@ public class PetRepository { public int updateWithVersion(UUID petId, int expectedVersion, String name, UUID breedId, String customBreedName, String sex, LocalDate birthDate, boolean birthDateEstimated, String personality, - String microchipNo, LocalDate sterilizedOn, String status) { + String microchipNo, LocalDate sterilizedOn, String status, + UUID avatarAssetId) { return jdbcClient.sql(""" UPDATE pet_health.pets SET name = :name, breed_id = :breedId, custom_breed_name = :customBreedName, sex = :sex, birth_date = :birthDate, birth_date_estimated = :birthDateEstimated, personality = :personality, microchip_no = :microchipNo, sterilized_on = :sterilizedOn, - status = :status, version = version + 1 + status = :status, avatar_asset_id = :avatarAssetId, + version = version + 1 WHERE id = :petId AND version = :expectedVersion AND status <> 'deleted' """) .param("petId", petId) @@ -131,11 +172,12 @@ public class PetRepository { .param("microchipNo", microchipNo) .param("sterilizedOn", sterilizedOn) .param("status", status) + .param("avatarAssetId", avatarAssetId) .update(); } - private static PetResponse mapPet(ResultSet rs, int rowNum) throws SQLException { - return new PetResponse( + private static PetRow mapPet(ResultSet rs, int rowNum) throws SQLException { + return new PetRow( rs.getObject("id", UUID.class), rs.getString("name"), rs.getString("species"), @@ -149,6 +191,9 @@ public class PetRepository { rs.getString("microchip_no"), rs.getObject("sterilized_on", LocalDate.class), rs.getString("status"), + rs.getObject("avatar_asset_id", UUID.class), + rs.getString("avatar_bucket"), + rs.getString("avatar_object_key"), rs.getString("role"), rs.getObject("created_at", OffsetDateTime.class), rs.getObject("updated_at", OffsetDateTime.class), diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java index e979cd5..7a73e4a 100644 --- a/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java @@ -7,8 +7,12 @@ import com.patbond.patbond.pet.access.PetAccessService; import com.patbond.patbond.pet.dto.CreatePetRequest; import com.patbond.patbond.pet.dto.PetResponse; import com.patbond.patbond.pet.dto.UpdatePetRequest; +import com.patbond.patbond.pet.media.MediaAssetGateway; +import com.patbond.patbond.pet.media.MediaAssetRef; +import com.patbond.patbond.pet.media.MediaUrlSigner; import com.patbond.patbond.pet.repository.BreedRepository; import com.patbond.patbond.pet.repository.PetRepository; +import com.patbond.patbond.pet.repository.PetRepository.PetRow; import com.patbond.patbond.pet.support.UuidV7; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; @@ -23,19 +27,47 @@ import java.util.UUID; * uq_pets_microchip) so clients get a stable business error instead of a * constraint-violation 500 — the constraints stay as the last line of * defense. + * + *

Avatar semantics (T3.5-05, ADR-022): + *

    + *
  • The write level is decided per REQUEST, not per endpoint: an + * avatar-only PATCH needs {@link AccessLevel#WRITE} (owner + caregiver + * — the avatar is day-to-day care information, same tier as weights and + * vaccinations), everything else stays {@link AccessLevel#MANAGE} + * (owner only). A body touching both is judged by the stricter half. + * Viewers are refused either way (403/40300).
  • + *
  • The referenced asset must exist, belong to the CALLER, carry + * {@code purpose='pet_avatar'} and be {@code ready} — the T3-03 + * referencing protocol: unknown / someone else's / deleted → 404/40405 + * (merged, anti-enumeration), wrong purpose → 404/40405 (a post image + * is not an avatar; reachable only for the caller's own assets, so the + * message may be specific), own pet_avatar asset still uploading or + * failed → 422/42203.
  • + *
  • The optimistic lock is unchanged: the avatar rides the same + * version-guarded UPDATE, so a stale version loses with 409/40902 even + * when only the avatar changes.
  • + *
*/ @Service public class PetService { + /** The only media purpose acceptable as a pet avatar (ADR-022). */ + private static final String AVATAR_PURPOSE = "pet_avatar"; + private final PetRepository petRepository; private final BreedRepository breedRepository; private final PetAccessService petAccessService; + private final MediaAssetGateway mediaAssetGateway; + private final MediaUrlSigner mediaUrlSigner; public PetService(PetRepository petRepository, BreedRepository breedRepository, - PetAccessService petAccessService) { + PetAccessService petAccessService, MediaAssetGateway mediaAssetGateway, + MediaUrlSigner mediaUrlSigner) { this.petRepository = petRepository; this.breedRepository = breedRepository; this.petAccessService = petAccessService; + this.mediaAssetGateway = mediaAssetGateway; + this.mediaUrlSigner = mediaUrlSigner; } /** @@ -63,18 +95,18 @@ public class PetService { throw new BusinessException(ErrorCode.MICROCHIP_EXISTS); } petRepository.insertPrimaryOwner(petId, userId); - return petRepository.findByIdForUser(petId, userId) - .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + return toResponse(petRepository.findByIdForUser(petId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR))); } public List list(UUID userId) { - return petRepository.listByUser(userId); + return petRepository.listByUser(userId).stream().map(this::toResponse).toList(); } public PetResponse get(UUID userId, UUID petId) { petAccessService.require(userId, petId, AccessLevel.READ); - return petRepository.findByIdForUser(petId, userId) - .orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND)); + return toResponse(petRepository.findByIdForUser(petId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND))); } /** @@ -85,8 +117,8 @@ public class PetService { */ @Transactional public PetResponse update(UUID userId, UUID petId, UpdatePetRequest request) { - petAccessService.require(userId, petId, AccessLevel.MANAGE); - PetResponse current = petRepository.findByIdForUser(petId, userId) + petAccessService.require(userId, petId, requiredLevel(request)); + PetRow current = petRepository.findByIdForUser(petId, userId) .orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND)); UUID breedId = current.breedId(); @@ -102,6 +134,16 @@ public class PetService { String sex = request.getSex() != null ? request.getSex() : current.sex(); String status = request.getStatus() != null ? request.getStatus() : current.status(); + // Three-state avatar: absent → carry the stored id through; explicit + // null → clear; value → validate then set. + UUID avatarAssetId = current.avatarAssetId(); + if (request.isAvatarAssetIdPresent()) { + avatarAssetId = request.getAvatarAssetId(); + if (avatarAssetId != null) { + requireOwnReadyAvatarAsset(userId, avatarAssetId); + } + } + int updated; try { updated = petRepository.updateWithVersion( @@ -120,7 +162,8 @@ public class PetService { ? trimOrNull(request.getMicrochipNo()) : current.microchipNo(), request.getSterilizedOn() != null ? request.getSterilizedOn() : current.sterilizedOn(), - status); + status, + avatarAssetId); } catch (DuplicateKeyException e) { throw new BusinessException(ErrorCode.MICROCHIP_EXISTS); } @@ -129,8 +172,34 @@ public class PetService { // missed conditional update means the version is stale. throw new BusinessException(ErrorCode.VERSION_CONFLICT); } - return petRepository.findByIdForUser(petId, userId) - .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + return toResponse(petRepository.findByIdForUser(petId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR))); + } + + /** + * MANAGE for anything that edits the pet profile, WRITE when the request + * touches nothing but the avatar (ADR-022). A body carrying only + * {@code version} keeps the historical MANAGE level — it is a + * profile-shaped no-op, not an avatar edit. + */ + private static AccessLevel requiredLevel(UpdatePetRequest request) { + boolean avatarOnly = request.isAvatarAssetIdPresent() && !request.touchesProfileFields(); + return avatarOnly ? AccessLevel.WRITE : AccessLevel.MANAGE; + } + + private void requireOwnReadyAvatarAsset(UUID userId, UUID assetId) { + MediaAssetRef asset = mediaAssetGateway.findById(assetId) + .orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND)); + if (!userId.equals(asset.ownerUserId()) || "deleted".equals(asset.status())) { + throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND); + } + if (!AVATAR_PURPOSE.equals(asset.purpose())) { + throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND, + "该媒体资源的用途不是 " + AVATAR_PURPOSE + ",不能作为宠物头像"); + } + if (!"ready".equals(asset.status())) { + throw new BusinessException(ErrorCode.MEDIA_NOT_READY); + } } /** @@ -156,6 +225,33 @@ public class PetService { } } + /** + * Signs the avatar URL fresh on every response (never cached, never + * persisted) and drops the storage coordinates — the DTO exposes a URL, + * not a bucket layout. + */ + private PetResponse toResponse(PetRow row) { + return new PetResponse( + row.id(), + row.name(), + row.species(), + row.breedId(), + row.breedDisplayName(), + row.customBreedName(), + row.sex(), + row.birthDate(), + row.birthDateEstimated(), + row.personality(), + row.microchipNo(), + row.sterilizedOn(), + row.status(), + mediaUrlSigner.signGet(row.avatarBucket(), row.avatarObjectKey()), + row.myRole(), + row.createdAt(), + row.updatedAt(), + row.version()); + } + private static String trimOrNull(String value) { if (value == null) { return null; diff --git a/patbond-pet/src/main/resources/application.yml.sample b/patbond-pet/src/main/resources/application.yml.sample index e0a8527..5a79b65 100644 --- a/patbond-pet/src/main/resources/application.yml.sample +++ b/patbond-pet/src/main/resources/application.yml.sample @@ -19,3 +19,12 @@ patbond: # 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。 # 私钥只给 patbond-auth,绝不入库。 public-key: ${PATBOND_JWT_PUBLIC_KEY:} + media: + # 媒体读取侧(ADR-016 定型:私有桶 + 预签名 GET)。本服务只做本地 SigV4 + # 签名计算生成宠物头像访问 URL,从不直连对象存储;写入流程在 patbond-user。 + # 环境变量与 patbond-user/patbond-community 共用同一组(一套部署一套旋钮)。 + # public-endpoint 为空时服务照常启动,宠物响应中 avatarUrl 为 null。 + public-endpoint: ${PATBOND_MINIO_PUBLIC_ENDPOINT:} + access-key: ${PATBOND_MINIO_ACCESS_KEY:} + secret-key: ${PATBOND_MINIO_SECRET_KEY:} + download-ttl: ${PATBOND_MEDIA_DOWNLOAD_TTL:1h} diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetAvatarIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetAvatarIntegrationTest.java new file mode 100644 index 0000000..50ad777 --- /dev/null +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetAvatarIntegrationTest.java @@ -0,0 +1,335 @@ +package com.patbond.patbond.pet.controller; + +import com.jayway.jsonpath.JsonPath; +import com.patbond.patbond.pet.support.PetIntegrationTestSupport; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * T3.5-05 宠物头像读写:PATCH /api/v1/pets/{petId} 的 {@code avatarAssetId} + * 三态(缺省不改 / 显式 null 清空 / 赋值设置),详情与列表的 {@code avatarUrl} + * 预签名 GET,以及六类路径 —— 成功 / 参数错(asset 非法四态)/ 不存在(防枚举 + * 404)/ 无权限(viewer 拒写、caregiver 只能改头像)/ 并发冲突(乐观锁 40902) + * / 重放(同版本重放必冲突、新版本重放幂等)。 + * + *

预签名 GET 是纯本地 SigV4 计算,故这里用占位端点与占位凭证即可断言 URL + * 形态(与 patbond-community 的 PostApiTestBase 同先例),无需 MinIO 容器; + * 「签名真能下载」的实证由 user 模块的 MeAvatarSigningIntegrationTest 承担。

+ */ +class PetAvatarIntegrationTest extends PetIntegrationTestSupport { + + private static final String SIGNED_PREFIX = "http://127.0.0.1:9000/patbond-media/pet_avatar/"; + + @Autowired + private MockMvc mockMvc; + + @DynamicPropertySource + static void wireMediaSigning(DynamicPropertyRegistry registry) { + // 占位值(dummy):仅用于本地 SigV4 计算,不连任何真实存储 + registry.add("patbond.media.public-endpoint", () -> "http://127.0.0.1:9000"); + registry.add("patbond.media.access-key", () -> "test-access-key"); + registry.add("patbond.media.secret-key", () -> "test-secret-key"); + } + + // ---- helpers ------------------------------------------------------- + + private String createPetAs(UUID ownerId) throws Exception { + String body = mockMvc.perform(post("/api/v1/pets") + .header("Authorization", "Bearer " + tokenFor(ownerId)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"name":"头像猫","species":"cat","sex":"female", + "customBreedName":"狸花"} + """)) + .andExpect(status().isCreated()) + // 新建宠物尚无头像 + .andExpect(jsonPath("$.data.avatarUrl").value(nullValue())) + .andReturn().getResponse().getContentAsString(); + return JsonPath.read(body, "$.data.id"); + } + + /** 一枚 media.assets 行,用途/状态/归属可控(模拟 T3-03 上传的产物)。 */ + private UUID insertAsset(UUID ownerUserId, String purpose, String status) { + UUID id = UUID.randomUUID(); + jdbcClient.sql(""" + INSERT INTO media.assets + (id, owner_user_id, kind, purpose, storage_type, bucket, object_key, + mime_type, byte_size, status, ready_at, deleted_at) + VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media', + :objectKey, 'image/jpeg', 2048, :status, :readyAt, :deletedAt) + """) + .param("id", id) + .param("owner", ownerUserId) + .param("purpose", purpose) + .param("objectKey", purpose + "/2026/09/" + id) + .param("status", status) + .param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null) + // ck_media_deleted:status='deleted' 必带 deleted_at + .param("deletedAt", "deleted".equals(status) ? OffsetDateTime.now() : null) + .update(); + return id; + } + + private UUID readyPetAvatar(UUID ownerUserId) { + return insertAsset(ownerUserId, "pet_avatar", "ready"); + } + + private String patchPet(UUID actor, String petId, String body, int expectedStatus) + throws Exception { + return mockMvc.perform(patch("/api/v1/pets/{id}", petId) + .header("Authorization", "Bearer " + tokenFor(actor)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().is(expectedStatus)) + .andReturn().getResponse().getContentAsString(); + } + + private void patchPetExpectingCode(UUID actor, String petId, String body, + int httpStatus, int bizCode) throws Exception { + mockMvc.perform(patch("/api/v1/pets/{id}", petId) + .header("Authorization", "Bearer " + tokenFor(actor)) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().is(httpStatus)) + .andExpect(jsonPath("$.code").value(bizCode)); + } + + private static String setAvatarBody(int version, UUID assetId) { + return "{\"version\":%d,\"avatarAssetId\":\"%s\"}".formatted(version, assetId); + } + + private String detail(UUID actor, String petId) throws Exception { + return mockMvc.perform(get("/api/v1/pets/{id}", petId) + .header("Authorization", "Bearer " + tokenFor(actor))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + } + + private UUID dbAvatarAssetId(String petId) { + return jdbcClient.sql("SELECT avatar_asset_id FROM pet_health.pets WHERE id = :id") + .param("id", UUID.fromString(petId)) + .query(UUID.class) + .optional() + .orElse(null); + } + + // ---- 成功路径 ------------------------------------------------------- + + @Test + void ownerSetsAvatarAndDetailAndListBothCarryASignedUrl() throws Exception { + UUID owner = newUser("pet_avatar_owner"); + String petId = createPetAs(owner); + UUID asset = readyPetAvatar(owner); + + String patched = patchPet(owner, petId, setAvatarBody(0, asset), 200); + assertThat((String) JsonPath.read(patched, "$.data.avatarUrl")) + .startsWith(SIGNED_PREFIX) + .contains("X-Amz-Signature="); + assertThat(dbAvatarAssetId(petId)).isEqualTo(asset); + // 头像也吃乐观锁:写入后 version 前进 + assertThat((int) JsonPath.read(patched, "$.data.version")).isEqualTo(1); + + assertThat((String) JsonPath.read(detail(owner, petId), "$.data.avatarUrl")) + .startsWith(SIGNED_PREFIX); + + String listBody = mockMvc.perform(get("/api/v1/pets") + .header("Authorization", "Bearer " + tokenFor(owner))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + List> pets = JsonPath.read(listBody, "$.data"); + assertThat((String) pets.get(0).get("avatarUrl")).startsWith(SIGNED_PREFIX); + } + + @Test + void clearsAvatarWithAnExplicitNull() throws Exception { + UUID owner = newUser("pet_avatar_clear"); + String petId = createPetAs(owner); + patchPet(owner, petId, setAvatarBody(0, readyPetAvatar(owner)), 200); + + String cleared = patchPet(owner, petId, "{\"version\":1,\"avatarAssetId\":null}", 200); + assertThat((Object) JsonPath.read(cleared, "$.data.avatarUrl")).isNull(); + assertThat(dbAvatarAssetId(petId)).isNull(); + } + + /** + * 缺省即不改:只改名字的 PATCH 不得把头像顺手清掉(这正是三态语义存在的 + * 理由——若 null 与缺省同义,就无法既保留又能清空)。 + */ + @Test + void absentAvatarFieldLeavesItUntouched() throws Exception { + UUID owner = newUser("pet_avatar_absent"); + String petId = createPetAs(owner); + UUID asset = readyPetAvatar(owner); + patchPet(owner, petId, setAvatarBody(0, asset), 200); + + String renamed = patchPet(owner, petId, "{\"version\":1,\"name\":\"改个名\"}", 200); + assertThat((String) JsonPath.read(renamed, "$.data.name")).isEqualTo("改个名"); + assertThat((String) JsonPath.read(renamed, "$.data.avatarUrl")).startsWith(SIGNED_PREFIX); + assertThat(dbAvatarAssetId(petId)).isEqualTo(asset); + } + + /** + * 指针在、资源却退出 ready(如后台清理置 failed):URL 降级为 null 而不是 + * 签一个下载必 404 的地址;数据库指针本身保留,不做隐式清理。 + */ + @Test + void avatarUrlDegradesToNullWhenTheAssetLeavesReady() throws Exception { + UUID owner = newUser("pet_avatar_degrade"); + String petId = createPetAs(owner); + UUID asset = readyPetAvatar(owner); + patchPet(owner, petId, setAvatarBody(0, asset), 200); + + jdbcClient.sql("UPDATE media.assets SET status = 'failed' WHERE id = :id") + .param("id", asset) + .update(); + + assertThat((Object) JsonPath.read(detail(owner, petId), "$.data.avatarUrl")).isNull(); + assertThat(dbAvatarAssetId(petId)).isEqualTo(asset); + } + + // ---- 权限:WRITE 档(ADR-022) --------------------------------------- + + /** + * caregiver 可改头像(WRITE 档:头像属日常照护信息,与体重/疫苗同档), + * 但资料本体仍是 MANAGE —— 同一端点按「本次请求碰了哪些字段」定档。 + */ + @Test + void caregiverMayChangeTheAvatarButNotTheProfile() throws Exception { + UUID owner = newUser("pet_avatar_owner2"); + UUID caregiver = newUser("pet_avatar_caregiver"); + String petId = createPetAs(owner); + grantRole(UUID.fromString(petId), caregiver, "caregiver"); + UUID asset = readyPetAvatar(caregiver); + + String patched = patchPet(caregiver, petId, setAvatarBody(0, asset), 200); + assertThat((String) JsonPath.read(patched, "$.data.avatarUrl")).startsWith(SIGNED_PREFIX); + + // 资料字段仍需 MANAGE + patchPetExpectingCode(caregiver, petId, "{\"version\":1,\"name\":\"照护人改名\"}", 403, 40300); + // 头像 + 资料混合按更严的那一半判(MANAGE) + patchPetExpectingCode(caregiver, petId, + "{\"version\":1,\"name\":\"夹带改名\",\"avatarAssetId\":null}", 403, 40300); + } + + @Test + void viewerCannotChangeTheAvatar() throws Exception { + UUID owner = newUser("pet_avatar_owner3"); + UUID viewer = newUser("pet_avatar_viewer"); + String petId = createPetAs(owner); + grantRole(UUID.fromString(petId), viewer, "viewer"); + UUID asset = readyPetAvatar(viewer); + + patchPetExpectingCode(viewer, petId, setAvatarBody(0, asset), 403, 40300); + assertThat(dbAvatarAssetId(petId)).isNull(); + // 只读仍可见 + mockMvc.perform(get("/api/v1/pets/{id}", petId) + .header("Authorization", "Bearer " + tokenFor(viewer))) + .andExpect(status().isOk()); + } + + // ---- 不存在路径(防枚举) -------------------------------------------- + + @Test + void strangerAndGhostPetAnswerTheSame404() throws Exception { + UUID owner = newUser("pet_avatar_owner4"); + UUID stranger = newUser("pet_avatar_stranger"); + String petId = createPetAs(owner); + UUID asset = readyPetAvatar(stranger); + + patchPetExpectingCode(stranger, petId, setAvatarBody(0, asset), 404, 40401); + patchPetExpectingCode(stranger, UUID.randomUUID().toString(), + setAvatarBody(0, asset), 404, 40401); + } + + // ---- 参数错:asset 非法四态 ------------------------------------------ + + @Test + void rejectsUnknownForeignOrWrongPurposeAsset() throws Exception { + UUID owner = newUser("pet_avatar_asset"); + UUID stranger = newUser("pet_avatar_assetowner"); + String petId = createPetAs(owner); + + // 幽灵 id 与他人 asset 同答 40405(防枚举合并) + patchPetExpectingCode(owner, petId, setAvatarBody(0, UUID.randomUUID()), 404, 40405); + patchPetExpectingCode(owner, petId, + setAvatarBody(0, insertAsset(stranger, "pet_avatar", "ready")), 404, 40405); + // 用途不符:帖子配图不能当宠物头像 + patchPetExpectingCode(owner, petId, + setAvatarBody(0, insertAsset(owner, "post_image", "ready")), 404, 40405); + // 用户头像也不行:两种头像用途各归各 + patchPetExpectingCode(owner, petId, + setAvatarBody(0, insertAsset(owner, "user_avatar", "ready")), 404, 40405); + // 已删资源对引用方即不存在 + patchPetExpectingCode(owner, petId, + setAvatarBody(0, insertAsset(owner, "pet_avatar", "deleted")), 404, 40405); + assertThat(dbAvatarAssetId(petId)).isNull(); + } + + @Test + void rejectsAssetThatIsNotReadyYet() throws Exception { + UUID owner = newUser("pet_avatar_state"); + String petId = createPetAs(owner); + + patchPetExpectingCode(owner, petId, + setAvatarBody(0, insertAsset(owner, "pet_avatar", "uploading")), 422, 42203); + patchPetExpectingCode(owner, petId, + setAvatarBody(0, insertAsset(owner, "pet_avatar", "failed")), 422, 42203); + assertThat(dbAvatarAssetId(petId)).isNull(); + } + + @Test + void rejectsMalformedAvatarAssetIdAndMissingVersion() throws Exception { + UUID owner = newUser("pet_avatar_malformed"); + String petId = createPetAs(owner); + + patchPetExpectingCode(owner, petId, + "{\"version\":0,\"avatarAssetId\":\"not-a-uuid\"}", 400, 40000); + // version 仍是必填(乐观锁不可绕过),哪怕只改头像 + patchPetExpectingCode(owner, petId, + "{\"avatarAssetId\":\"%s\"}".formatted(readyPetAvatar(owner)), 400, 40000); + } + + // ---- 并发冲突与重放 -------------------------------------------------- + + /** + * 头像写入走同一把乐观锁:拿旧 version 的第二个写者必败 40902(并发冲突), + * 而带同一 body 的重放正是「旧 version 再来一次」,因此必须同样冲突——这 + * 是 pets 域自 M2 起的一致语义,头像不另开后门。 + */ + @Test + void staleVersionLosesAndReplayOfTheSameBodyConflicts() throws Exception { + UUID owner = newUser("pet_avatar_version"); + String petId = createPetAs(owner); + UUID first = readyPetAvatar(owner); + UUID second = readyPetAvatar(owner); + + patchPet(owner, petId, setAvatarBody(0, first), 200); + // 重放(同 body、同旧 version)→ 40902 + patchPetExpectingCode(owner, petId, setAvatarBody(0, first), 409, 40902); + // 另一个写者拿旧 version 抢改 → 同样 40902 + patchPetExpectingCode(owner, petId, setAvatarBody(0, second), 409, 40902); + assertThat(dbAvatarAssetId(petId)).isEqualTo(first); + + // 用新 version 重放同一头像 → 幂等地仍是这张图(version 继续前进) + String again = patchPet(owner, petId, setAvatarBody(1, first), 200); + assertThat(dbAvatarAssetId(petId)).isEqualTo(first); + assertThat((int) JsonPath.read(again, "$.data.version")).isEqualTo(2); + } +} -- 2.43.0 From d98a400f47d100d488210a0ee98d04a6943652a4 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 11 Sep 2026 10:03:07 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E8=8E=B7=E8=B5=9E=E8=81=9A?= =?UTF-8?q?=E5=90=88=E2=80=94=E2=80=94=E6=96=B0=E5=A2=9E=20GET=20/api/v1/m?= =?UTF-8?q?e/community-stats=20=E8=AF=BB=E4=BE=A7=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E8=81=9A=E5=90=88=EF=BC=88T3.5-06=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/community-stats,返回 {receivedLikeCount, publishedPostCount}:本人「已发布且未软删」帖的 like_count 之和与帖子数 - 口径:草稿不计(尚非作品)、软删不计(删帖即撤回其数字)、运营态 hidden/archived 不计(在 M3 契约里对所有人不可见)、他人帖不计;空数据答 0 不答 null,任何已认证用户都有 stats,从不 404 - 走读侧实时聚合不引冗余列(ADR-022):写侧无按人计数器,也就没有可漂移的 副本;单次查询压在 ix_posts_author_created 的前导列上 - 端点独立而不并入 /users/{userId}/follow-stats(ADR-022 决策 A):后者主体是 「某用户的关注数」,混入「我的获赞」会让一个载荷有两个主体;且本端点主体 恒为 token 里的自己,路径上没有可枚举的 userId - 测试 +12(空数据零值/多帖求和/自赞与取消赞/草稿与软删与运营态排除/他人帖 不串味/401 两态/无他人入口/并发与重复读幂等/载荷形态),community 模块 94 → 106 Co-Authored-By: Claude Fable 5 --- .../community/controller/PostController.java | 14 ++ .../community/dto/CommunityStatsResponse.java | 22 ++ .../community/repository/PostRepository.java | 26 ++ .../community/service/PostService.java | 14 ++ .../post/MeCommunityStatsIntegrationTest.java | 237 ++++++++++++++++++ 5 files changed, 313 insertions(+) create mode 100644 patbond-community/src/main/java/com/patbond/patbond/community/dto/CommunityStatsResponse.java create mode 100644 patbond-community/src/test/java/com/patbond/patbond/community/post/MeCommunityStatsIntegrationTest.java diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/controller/PostController.java b/patbond-community/src/main/java/com/patbond/patbond/community/controller/PostController.java index c38113f..6c43dc0 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/controller/PostController.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/controller/PostController.java @@ -1,6 +1,7 @@ package com.patbond.patbond.community.controller; import com.patbond.patbond.common.response.ApiResponse; +import com.patbond.patbond.community.dto.CommunityStatsResponse; import com.patbond.patbond.community.dto.CreatePostRequest; import com.patbond.patbond.community.dto.CursorPage; import com.patbond.patbond.community.dto.PostResponse; @@ -86,4 +87,17 @@ public class PostController { @RequestParam(required = false) String cursor) { return ApiResponse.success(postService.listMine(userId, status, limit, cursor)); } + + /** + * The caller's own community numbers (T3.5-06, ADR-022 决策 A: a dedicated + * endpoint rather than an addition to /users/{userId}/follow-stats, whose + * subject is "some user's follow counts" — mixing "my likes received" in + * would give one payload two subjects). Lives here because posts are the + * sole source of both numbers, next to the other /me posts read. + */ + @GetMapping("/api/v1/me/community-stats") + public ApiResponse communityStats( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) { + return ApiResponse.success(postService.communityStats(userId)); + } } diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/dto/CommunityStatsResponse.java b/patbond-community/src/main/java/com/patbond/patbond/community/dto/CommunityStatsResponse.java new file mode 100644 index 0000000..ab7f562 --- /dev/null +++ b/patbond-community/src/main/java/com/patbond/patbond/community/dto/CommunityStatsResponse.java @@ -0,0 +1,22 @@ +package com.patbond.patbond.community.dto; + +/** + * GET /api/v1/me/community-stats (T3.5-06, ADR-022 决策 A) — the caller's own + * community numbers, read-side aggregates over community.posts with no new + * denormalized column. + * + *

Scope, frozen with this ticket: both numbers count ONLY the caller's own + * posts that are {@code status='published'} and not soft-deleted. Drafts are + * excluded (they are not works yet, and their likes cannot exist anyway), + * soft-deleted posts are excluded (deleting a post removes its numbers), and + * the operational states hidden/archived are excluded for the same reason + * they are invisible everywhere else in the M3 contract. Empty data yields + * {@code 0}, never null.

+ * + *

{@code receivedLikeCount} is {@code SUM(posts.like_count)} — the counter + * the write side maintains in the same transaction as the like row (T3-07), + * so this is exact, not an estimate. A user's own likes on their own posts are + * counted, exactly as the per-post number shows them.

+ */ +public record CommunityStatsResponse(long receivedLikeCount, long publishedPostCount) { +} diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java b/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java index b5a4e78..4c01727 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/repository/PostRepository.java @@ -1,5 +1,6 @@ package com.patbond.patbond.community.repository; +import com.patbond.patbond.community.dto.CommunityStatsResponse; import com.patbond.patbond.community.support.BookmarkCursor; import com.patbond.patbond.community.support.FeedCursor; import com.patbond.patbond.community.support.PostCursor; @@ -169,6 +170,31 @@ public class PostRepository { .update(); } + /** + * The author's own community aggregates in one indexed pass over + * ix_posts_author_created's leading column (T3.5-06, ADR-022: read-side + * aggregation, no denormalized column). {@code COALESCE} turns the empty + * SUM into 0 so the endpoint never answers null; the + * {@code deleted_at IS NULL} predicate is belt-and-braces — softDelete + * parks published rows as 'archived', so status='published' already + * implies live (ck_posts_publish_state). + */ + public CommunityStatsResponse aggregateByAuthor(UUID authorUserId) { + return jdbcClient.sql(""" + SELECT COALESCE(SUM(like_count), 0) AS received_like_count, + COUNT(*) AS published_post_count + FROM community.posts + WHERE author_user_id = :authorUserId + AND status = 'published' + AND deleted_at IS NULL + """) + .param("authorUserId", authorUserId) + .query((rs, rowNum) -> new CommunityStatsResponse( + rs.getLong("received_like_count"), + rs.getLong("published_post_count"))) + .single(); + } + /** * One page of the author's own posts in (created_at DESC, id DESC) — the * exact key of ix_posts_author_created. Soft-deleted rows never appear; diff --git a/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java b/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java index 8450670..0b83016 100644 --- a/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java +++ b/patbond-community/src/main/java/com/patbond/patbond/community/service/PostService.java @@ -5,6 +5,7 @@ import com.patbond.patbond.common.error.ErrorCode; import com.patbond.patbond.community.access.PetVisibilityGateway; import com.patbond.patbond.community.author.AuthorProfileGateway; import com.patbond.patbond.community.dto.AuthorSummaryResponse; +import com.patbond.patbond.community.dto.CommunityStatsResponse; import com.patbond.patbond.community.dto.CreatePostRequest; import com.patbond.patbond.community.dto.CursorPage; import com.patbond.patbond.community.dto.PostMediaAttachRequest; @@ -205,6 +206,19 @@ public class PostService { return new CursorPage<>(assemble(page), nextCursor, hasMore); } + /** + * The caller's own community numbers (T3.5-06): total likes received on + * published, live posts plus the count of those posts. Aggregated on read + * (ADR-022) — the write side keeps no per-user counter, so there is + * nothing that can drift out of sync. Empty data is a legitimate answer of + * zeros, never a 404: every authenticated user has stats, even a brand-new + * one with nothing published. + */ + @Transactional(readOnly = true) + public CommunityStatsResponse communityStats(UUID userId) { + return postRepository.aggregateByAuthor(userId); + } + /** * The shared write gate of PATCH/DELETE: locks the live row, then walks * the 403/404 boundary — invisible (absent, deleted, hidden/archived, diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/post/MeCommunityStatsIntegrationTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/post/MeCommunityStatsIntegrationTest.java new file mode 100644 index 0000000..890b529 --- /dev/null +++ b/patbond-community/src/test/java/com/patbond/patbond/community/post/MeCommunityStatsIntegrationTest.java @@ -0,0 +1,237 @@ +package com.patbond.patbond.community.post; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; + +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * T3.5-06 获赞聚合:GET /api/v1/me/community-stats。口径(ADR-022,读侧实时 + * 聚合,不引冗余列): + * + *
    + *
  • {@code receivedLikeCount} = 本人「已发布且未软删」帖的 like_count 之和;
  • + *
  • {@code publishedPostCount} = 同一集合的帖子数;
  • + *
  • 草稿不计(尚非作品)、软删不计(删帖即撤回其数字)、他人的帖不计;
  • + *
  • 空数据答 0 而非 null,任何已认证用户都有 stats,从不 404。
  • + *
+ */ +class MeCommunityStatsIntegrationTest extends PostApiTestBase { + + private static final String DRAFT = "{\"content\":\"草稿内容\"}"; + private static final String PUBLISHED = "{\"content\":\"已发布内容\",\"status\":\"published\"}"; + + private JsonNode stats(UUID userId) throws Exception { + return data(mockMvc.perform(authed(get("/api/v1/me/community-stats"), userId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn()); + } + + private void like(UUID actor, String postId) throws Exception { + mockMvc.perform(authed(put("/api/v1/posts/{postId}/like", postId), actor)) + .andExpect(status().isOk()); + } + + // ---- 成功路径 + 空数据 ---------------------------------------------- + + @Test + void freshUserGetsZerosNotNullsAndNever404() throws Exception { + JsonNode stats = stats(newUser()); + assertThat(stats.get("receivedLikeCount").isNull()).isFalse(); + assertThat(stats.get("publishedPostCount").isNull()).isFalse(); + assertThat(stats.get("receivedLikeCount").asLong()).isZero(); + assertThat(stats.get("publishedPostCount").asLong()).isZero(); + } + + @Test + void sumsLikesAcrossThePublishedPostsOfTheCaller() throws Exception { + UUID author = newUser(); + UUID fanA = newUser(); + UUID fanB = newUser(); + String first = createPost(author, PUBLISHED).get("id").asText(); + String second = createPost(author, PUBLISHED).get("id").asText(); + + like(fanA, first); + like(fanB, first); + like(fanA, second); + + JsonNode stats = stats(author); + assertThat(stats.get("receivedLikeCount").asLong()).isEqualTo(3); + assertThat(stats.get("publishedPostCount").asLong()).isEqualTo(2); + } + + @Test + void countsSelfLikesExactlyAsThePerPostNumberDoes() throws Exception { + UUID author = newUser(); + String postId = createPost(author, PUBLISHED).get("id").asText(); + like(author, postId); + + assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1); + } + + @Test + void unlikingBringsTheNumberBackDown() throws Exception { + UUID author = newUser(); + UUID fan = newUser(); + String postId = createPost(author, PUBLISHED).get("id").asText(); + like(fan, postId); + assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1); + + mockMvc.perform(authed(delete("/api/v1/posts/{postId}/like", postId), fan)) + .andExpect(status().isOk()); + assertThat(stats(author).get("receivedLikeCount").asLong()).isZero(); + } + + // ---- 口径边界:草稿 / 软删 / 他人 ----------------------------------- + + @Test + void draftsAreExcludedFromBothNumbers() throws Exception { + UUID author = newUser(); + createPost(author, DRAFT); + createPost(author, DRAFT); + + JsonNode before = stats(author); + assertThat(before.get("publishedPostCount").asLong()).isZero(); + assertThat(before.get("receivedLikeCount").asLong()).isZero(); + + // 发布其中一篇后才计入 + String draftId = createPost(author, DRAFT).get("id").asText(); + mockMvc.perform(authed(patch("/api/v1/posts/{postId}", draftId), author) + .content("{\"version\":0,\"status\":\"published\"}")) + .andExpect(status().isOk()); + assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1); + } + + @Test + void softDeletedPostsDropOutOfBothNumbers() throws Exception { + UUID author = newUser(); + UUID fan = newUser(); + String keep = createPost(author, PUBLISHED).get("id").asText(); + String doomed = createPost(author, PUBLISHED).get("id").asText(); + like(fan, keep); + like(fan, doomed); + assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(2); + + mockMvc.perform(authed(delete("/api/v1/posts/{postId}", doomed), author)) + .andExpect(status().isOk()); + + JsonNode after = stats(author); + assertThat(after.get("receivedLikeCount").asLong()).isEqualTo(1); + assertThat(after.get("publishedPostCount").asLong()).isEqualTo(1); + } + + @Test + void otherPeoplesPostsNeverLeakIntoMyStats() throws Exception { + UUID me = newUser(); + UUID other = newUser(); + UUID fan = newUser(); + String theirs = createPost(other, PUBLISHED).get("id").asText(); + like(fan, theirs); + like(me, theirs); + + JsonNode mine = stats(me); + assertThat(mine.get("receivedLikeCount").asLong()).isZero(); + assertThat(mine.get("publishedPostCount").asLong()).isZero(); + // 对方的数字是对方的 + assertThat(stats(other).get("receivedLikeCount").asLong()).isEqualTo(2); + } + + /** + * hidden/archived 是运营态(D3-7),在 M3 契约里对所有人不可见,因此也不计 + * 入作品数——「看不到的帖不该出现在我的作品计数里」。 + */ + @Test + void operationalStatesAreExcluded() throws Exception { + UUID author = newUser(); + String postId = createPost(author, PUBLISHED).get("id").asText(); + assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1); + + jdbcClient.sql("UPDATE community.posts SET status = 'hidden' WHERE id = :id") + .param("id", UUID.fromString(postId)) + .update(); + assertThat(stats(author).get("publishedPostCount").asLong()).isZero(); + } + + // ---- 无权限 / 不存在 ------------------------------------------------- + + @Test + void requiresAValidAccessToken() throws Exception { + mockMvc.perform(get("/api/v1/me/community-stats")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + mockMvc.perform(get("/api/v1/me/community-stats") + .header("Authorization", "Bearer not.a.jwt")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } + + /** + * 主体永远是 token 里的自己,路径上没有可枚举的 userId —— 「查不到别人的 + * 获赞」不靠权限判断,而靠端点形态本身就没有别人的入口。 + */ + @Test + void hasNoPathParameterToProbeSomeoneElse() throws Exception { + UUID me = newUser(); + mockMvc.perform(authed(get("/api/v1/me/community-stats/{userId}", newUser()), me)) + .andExpect(status().isNotFound()); + } + + // ---- 并发与重放 ----------------------------------------------------- + + /** + * 读侧聚合天然幂等:并发重复读必须给出同一答案,且不产生任何副作用 + * (连续两次读的数字完全相同)。 + */ + @Test + void concurrentAndRepeatedReadsAreIdenticalAndSideEffectFree() throws Exception { + UUID author = newUser(); + UUID fan = newUser(); + String postId = createPost(author, PUBLISHED).get("id").asText(); + like(fan, postId); + + CyclicBarrier startTogether = new CyclicBarrier(2); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Callable read = () -> { + startTogether.await(); + return stats(author).get("receivedLikeCount").asLong(); + }; + Future first = pool.submit(read); + Future second = pool.submit(read); + assertThat(first.get()).isEqualTo(1); + assertThat(second.get()).isEqualTo(1); + } finally { + pool.shutdownNow(); + } + + assertThat(stats(author).get("receivedLikeCount").asLong()).isEqualTo(1); + assertThat(stats(author).get("publishedPostCount").asLong()).isEqualTo(1); + } + + // ---- 形态回归 ------------------------------------------------------- + + @Test + void payloadCarriesExactlyTheTwoNumbers() throws Exception { + UUID author = newUser(); + mockMvc.perform(authed(get("/api/v1/me/community-stats"), author)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.receivedLikeCount").value(0)) + .andExpect(jsonPath("$.data.publishedPostCount").value(0)) + .andExpect(jsonPath("$.data.followerCount").doesNotExist()) + .andExpect(jsonPath("$.data.userId").doesNotExist()); + } +} -- 2.43.0 From 3cd80055779db2d52cf8cc1af425d06131f7e41d Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 11 Sep 2026 10:33:19 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(contract):=20v1.4.0=20=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=9B=9B=E6=A8=A1=E5=9D=97=E5=90=8C=E6=AD=A5=20+=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=B5=84=E6=96=99=E4=B8=8E=E5=A4=B4=E5=83=8F?= =?UTF-8?q?=E7=9F=A9=E9=98=B5=E5=85=A5=E5=9C=BA=EF=BC=88T3.5-07=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doc 仓正典 main@5f02909 冻结 v1.4.0 后的 api 侧收尾:字节级同步快照、守卫 升版、新增/变更操作入契约一致性矩阵。**零生产代码改动**(只改契约快照与测试)。 快照同步(字节级,md5 与正典逐一比对一致 a7081fb84f1207eef579ab94025f5801): - 四模块 openapi-v1.3.0.yaml → openapi-v1.4.0.yaml,删旧文件(守卫只认一份, 保留旧快照是死重;历史版本由 git 与 doc 仓承载,沿 T3-19 先例) - 四份守卫期望升版:1.3.0/31/43/72 → 1.4.0/32/45/75 矩阵扩展(173 → 181 格,豁免仍为 1 格): - patbond-auth +5 格:PATCH /api/v1/me 全响应矩阵(200 设值 / 200 显式 null 清空 / 400 空 patch / 401 / 404 双码 40400+40405 / 422 42203),并让 GET /api/v1/me 在 nickname 非空分支再走一遍严格校验。注意 /api/v1/me 的守卫与 矩阵都在 auth 模块(实现在 user,契约测试跨服务发请求),扩契约易漏 - patbond-pet +1 格:PATCH /api/v1/pets/{petId} 新增 422/42203;404 单元格 补 40405 第二种业务码(幽灵 asset 与用途不符 asset 合并同答),并补一格 挂 ready 头像的 200 - patbond-community +2 格:GET /api/v1/me/community-stats 200(空数据零值与 有数据 1 赞/2 作品两分支)+ 401(由全操作循环覆盖) - patbond-user media 域 2 操作 8 格不变(v1.4.0 未触碰其冻结面) T3.5-04/05/06 遗留的 11 格契约守卫红(auth 2 + pet 9,根因为新增字段未冻结) 全部转绿。测试 379 → 381(+2 个新增矩阵方法),根反应堆 clean test 全绿, check-secrets.sh --all exit 0。 mutation 自证(三处定向注毒均红、还原即绿):CommunityStats.required 注入 fakeStatsField → GET /me/community-stats 200 报漂移;Me.required 注入 fakeMeField → GET+PATCH /me 200 报漂移;Pet.required 注入 fakePetAvatarField → POST /pets 201 等 9 格报漂移。 Co-Authored-By: Claude Opus 5 (1M context) --- .../contract/AuthContractConformanceTest.java | 106 +++++- .../auth/contract/OpenApiContract.java | 6 +- ...penapi-v1.3.0.yaml => openapi-v1.4.0.yaml} | 342 ++++++++++++++++-- .../CommunityContractConformanceTest.java | 67 +++- .../community/contract/OpenApiContract.java | 6 +- ...penapi-v1.3.0.yaml => openapi-v1.4.0.yaml} | 342 ++++++++++++++++-- .../pet/contract/ContractConformanceTest.java | 67 +++- .../patbond/pet/contract/OpenApiContract.java | 6 +- ...penapi-v1.3.0.yaml => openapi-v1.4.0.yaml} | 342 ++++++++++++++++-- .../MediaContractConformanceTest.java | 14 +- .../user/contract/OpenApiContract.java | 6 +- ...penapi-v1.3.0.yaml => openapi-v1.4.0.yaml} | 342 ++++++++++++++++-- 12 files changed, 1486 insertions(+), 160 deletions(-) rename patbond-auth/src/test/resources/contract/{openapi-v1.3.0.yaml => openapi-v1.4.0.yaml} (87%) rename patbond-community/src/test/resources/contract/{openapi-v1.3.0.yaml => openapi-v1.4.0.yaml} (87%) rename patbond-pet/src/test/resources/contract/{openapi-v1.3.0.yaml => openapi-v1.4.0.yaml} (87%) rename patbond-user/src/test/resources/contract/{openapi-v1.3.0.yaml => openapi-v1.4.0.yaml} (87%) diff --git a/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/AuthContractConformanceTest.java b/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/AuthContractConformanceTest.java index e33d29e..7e65135 100644 --- a/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/AuthContractConformanceTest.java +++ b/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/AuthContractConformanceTest.java @@ -20,6 +20,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.PostgreSQLContainer; @@ -38,16 +39,24 @@ import java.util.concurrent.ConcurrentHashMap; import static org.assertj.core.api.Assertions.assertThat; /** - * T3-19(D3-8):auth 域 6 个 M1 操作补进契约一致性保障,机制与 - * patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.3.0(快照 - * {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓 + * T3-19(D3-8):auth 域 M1 操作补进契约一致性保障,机制与 + * patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.4.0(快照 + * {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在 doc 仓 * {@code docs/api/openapi.yaml})逐操作真实发请求,用 {@link ContractValidator} * 严格校验响应结构,最后以全响应矩阵门禁兜底。 * *

与 pet 侧的差别只在运行方式:register/login/refresh/logout 走真实 HTTP * 打到 auth 服务(本测试的 Spring 上下文),me/trackEvents 打到同 JVM 内 * 启动的真实 user 服务(复用 AuthE2eIntegrationTest 的编排先例), - * 因此这 6 个操作是跨服务的真实纵切,不是 MockMvc 短路。 + * 因此这些操作是跨服务的真实纵切,不是 MockMvc 短路。 + * + *

T3.5-07:v1.4.0 把 {@code PATCH /api/v1/me} 与 {@code GET} 的 + * nickname/avatarUrl 纳入冻结面,故本类的操作面自 6 增至 7。注意 + * {@code /api/v1/me} 的守卫与矩阵都在 **auth 模块**(实现在 patbond-user, + * 但契约测试在这里跨服务发请求),扩契约时容易漏。本类不配置对象存储, + * 故 {@code avatarUrl} 恒为 null——正是「存储未配置时资料读取整体降级」的 + * nullable 实证;真实签名 URL 的全链路在 patbond-user 的 + * MeAvatarSigningIntegrationTest(真实 MinIO)覆盖。 */ @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @SpringBootTest( @@ -63,13 +72,14 @@ class AuthContractConformanceTest { /** 已被真实响应校验过的 (操作, 状态码) 单元格。 */ private static final Set COVERED = ConcurrentHashMap.newKeySet(); - /** auth 域 6 个操作(= 契约中 tags ∈ {auth, user, analytics})。 */ + /** auth 域 7 个操作(= 契约中 tags ∈ {auth, user, analytics})。 */ private static final List AUTH_OPERATIONS = List.of( "POST /api/v1/auth/register", "POST /api/v1/auth/login", "POST /api/v1/auth/refresh", "POST /api/v1/auth/logout", "GET /api/v1/me", + "PATCH /api/v1/me", "POST /api/v1/events"); private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:18"); @@ -305,6 +315,80 @@ class AuthContractConformanceTest { null, 423, 42300); } + // ---- PATCH /api/v1/me(v1.4.0 新增操作,T3.5-07)-------------------- + + /** + * `PATCH /api/v1/me` 的全响应矩阵(200/400/401/404/422)与三态语义的形态 + * 实证,外加 GET 在 nickname 非空分支上再走一遍严格校验。 + * + *

422/42203 需要一枚「本人所有、用途 user_avatar、状态 uploading」的 + * asset:本上下文未配置对象存储(创建上传会 500),故按 MeProfileIntegrationTest + * 的先例直接写 media.assets 行——测试数据造法,不触碰实现。 + */ + @Test + @Order(7) + void meProfileWriteShapes() { + String registered = register("contract_auth_frank", "+8613800000608"); + String accessToken = JsonPath.read(registered, "$.data.accessToken"); + UUID userId = UUID.fromString(JsonPath.read(registered, "$.data.userId")); + + // 200:三态「给值」——设昵称;avatarUrl 为 null(存储未配置时的降级实证) + String patched = verified(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me", + "{\"nickname\":\"契约昵称\"}", accessToken, 200); + assertThat((String) JsonPath.read(patched, "$.data.nickname")).isEqualTo("契约昵称"); + assertThat((Object) JsonPath.read(patched, "$.data.avatarUrl")).isNull(); + + // GET 回读:nickname 非空分支同样过严格校验(PATCH 与 GET 同一 Me 形态) + String reread = verified(HttpMethod.GET, userBaseUrl + "/api/v1/me", "/api/v1/me", + null, accessToken, 200); + assertThat((String) JsonPath.read(reread, "$.data.nickname")).isEqualTo("契约昵称"); + + // 200:三态「显式 null = 清空」 + String cleared = verified(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me", + "{\"nickname\":null}", accessToken, 200); + assertThat((Object) JsonPath.read(cleared, "$.data.nickname")).isNull(); + + // 400/40000:空 patch(不静默 200) + verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me", + "{}", accessToken, 400, 40000); + + // 401/40101:无 token + verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me", + "{\"nickname\":\"无票\"}", null, 401, 40101); + + // 404:同一单元格的两种业务码——用户已注销 40400 / asset 不可引用 40405 + String ghostToken = signToken(TestJwtKeys.KEY_PAIR.getPrivate(), + UUID.randomUUID().toString(), Duration.ofMinutes(15)); + verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me", + "{\"nickname\":\"幽灵\"}", ghostToken, 404, 40400); + verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me", + "{\"avatarAssetId\":\"%s\"}".formatted(UUID.randomUUID()), + accessToken, 404, 40405); + + // 422/42203:本人的 user_avatar asset 仍在 uploading + UUID uploading = insertUserAvatarAsset(userId, "uploading"); + verifiedError(HttpMethod.PATCH, userBaseUrl + "/api/v1/me", "/api/v1/me", + "{\"avatarAssetId\":\"%s\"}".formatted(uploading), accessToken, 422, 42203); + } + + /** 一枚本人所有的 user_avatar asset 行(模拟 T3-03 两步上传的中间产物)。 */ + private UUID insertUserAvatarAsset(UUID ownerUserId, String status) { + UUID id = UUID.randomUUID(); + userApp.getBean(JdbcClient.class).sql(""" + INSERT INTO media.assets + (id, owner_user_id, kind, purpose, storage_type, bucket, object_key, + mime_type, byte_size, status) + VALUES (:id, :owner, 'image', 'user_avatar', 'object', 'patbond-media', + :objectKey, 'image/jpeg', 2048, :status) + """) + .param("id", id) + .param("owner", ownerUserId) + .param("objectKey", "user_avatar/2026/09/" + id) + .param("status", status) + .update(); + return id; + } + // ---- 快照与覆盖门禁 ------------------------------------------------- /** @@ -314,17 +398,17 @@ class AuthContractConformanceTest { @Test @Order(98) void frozenSnapshotIsTheExpectedContractVersion() { - assertThat(CONTRACT.version()).isEqualTo("1.3.0"); - assertThat(CONTRACT.paths()).hasSize(31); - assertThat(CONTRACT.operations()).hasSize(43); - assertThat(CONTRACT.schemas()).hasSize(72); + assertThat(CONTRACT.version()).isEqualTo("1.4.0"); + assertThat(CONTRACT.paths()).hasSize(32); + assertThat(CONTRACT.operations()).hasSize(45); + assertThat(CONTRACT.schemas()).hasSize(75); assertThat(CONTRACT.operationsTagged(Set.of("auth", "user", "analytics"))) .containsExactlyInAnyOrderElementsOf(AUTH_OPERATIONS); } /** - * 全矩阵覆盖门禁:auth 域 6 个操作声明的每个 (操作, 状态码) 都必须被 - * 前面的测试真实触发并通过契约校验(19 个单元格,无豁免)。 + * 全矩阵覆盖门禁:auth 域 7 个操作声明的每个 (操作, 状态码) 都必须被 + * 前面的测试真实触发并通过契约校验(24 个单元格,无豁免)。 */ @Test @Order(99) diff --git a/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/OpenApiContract.java b/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/OpenApiContract.java index a86ba66..14f0ab0 100644 --- a/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/OpenApiContract.java +++ b/patbond-auth/src/test/java/com/patbond/patbond/auth/contract/OpenApiContract.java @@ -13,8 +13,8 @@ import java.util.Objects; import java.util.Set; /** - * The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot - * {@code /contract/openapi-v1.3.0.yaml}. + * The frozen v1.4.0 OpenAPI contract, loaded from the test-resource snapshot + * {@code /contract/openapi-v1.4.0.yaml}. * *

Sync discipline (T2-09, extended by T3-19): the canonical * contract lives in the doc repo at {@code docs/api/openapi.yaml}; this @@ -36,7 +36,7 @@ import java.util.Set; */ final class OpenApiContract { - static final String RESOURCE = "/contract/openapi-v1.3.0.yaml"; + static final String RESOURCE = "/contract/openapi-v1.4.0.yaml"; private static final Set HTTP_METHODS = Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace"); diff --git a/patbond-auth/src/test/resources/contract/openapi-v1.3.0.yaml b/patbond-auth/src/test/resources/contract/openapi-v1.4.0.yaml similarity index 87% rename from patbond-auth/src/test/resources/contract/openapi-v1.3.0.yaml rename to patbond-auth/src/test/resources/contract/openapi-v1.4.0.yaml index c9d3072..d669f33 100644 --- a/patbond-auth/src/test/resources/contract/openapi-v1.3.0.yaml +++ b/patbond-auth/src/test/resources/contract/openapi-v1.4.0.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约) - version: 1.3.0 + version: 1.4.0 description: | Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差), 1.1.0 追加埋点上报端点 `POST /api/v1/events`(M2 第一波契约补录,以实现实测行为为准)。 @@ -11,6 +11,14 @@ info: **1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、 公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入 (iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。 + **1.4.0 M3.5 契约冻结:用户资料与头像**——`GET /api/v1/me` 补 `nickname`/`avatarUrl`, + 新增 `PATCH /api/v1/me`(昵称与头像读写,三态部分更新),`Pet` 补 `avatarUrl` 且 + `PATCH /api/v1/pets/{petId}` 收 `avatarAssetId`,新增 `GET /api/v1/me/community-stats` + (获赞总数与作品数),媒体 `purpose` 白名单追加 `user_avatar`/`pet_avatar` + (iteration-3.5 报告 03 定型表;冻结报告见 iteration-3.5/04)。 + **1.4.0 相对 1.3.0 纯增量**:无字段删改、无类型变更、无必填收紧,仅新增操作、 + 新增响应字段(键恒在、值可空)、新增可选请求字段、新增响应格与枚举追加, + v1.3.0 客户端无需改动即可继续工作。 ## 通用约定(development-plan 第 6 节) - 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 @@ -29,14 +37,14 @@ info: | 40100 | 401 | 用户名或密码错误 | | 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) | | 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) | - | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录、caregiver 改宠物档案) | + | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录或改头像、caregiver 改宠物档案——头像除外,见 M3.5 分档) | | 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 | | 40400 | 404 | 资源不存在 | | 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) | | 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) | | 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 | | 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) | - | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有或已删(防枚举合并) | + | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有、已删,或**用途与引用场景不符**(帖图当头像、user_avatar 当宠物头像等);防枚举合并,同码各情形响应一致(用途不符分支只对调用者自己的 asset 可达,故 message 可具体) | | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) | | 40900 | 409 | 用户名已存在(大小写不敏感) | | 40901 | 409 | 手机号已被使用 | @@ -46,7 +54,7 @@ info: | 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) | | 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 | | 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 | - | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有但非 ready(uploading/failed)状态的 asset | + | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有、用途相符但非 ready(uploading/failed)状态的 asset(帖图与用户/宠物头像同构) | | 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op) | | 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 | | 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) | @@ -68,7 +76,10 @@ info: - **权限模型(ADR-015:owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档: - `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要; - `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH; - - `MANAGE`——仅 owner:宠物档案 PATCH(含状态流转)。 + **M3.5 起还含宠物头像字段 `avatarAssetId`**(ADR-022:头像属日常照护信息)。 + - `MANAGE`——仅 owner:宠物档案 PATCH 的资料字段(含状态流转)。 + **同一端点按「本次请求触及哪些字段」定档**(不是端点级降档):仅改头像走 WRITE, + 触及任一资料字段走 MANAGE,混合请求取更严的一半。 权限每请求实时查库、无缓存:撤销照护关系立即生效。 - **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况 响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与 @@ -126,6 +137,41 @@ info: 整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口 (如作者公开资料批量接口)不属于本公开契约。 + ## 用户资料与头像域约定(M3.5 冻结,iteration-3.5 报告 03 定型;ADR-022) + - **三态部分更新(仅本域,pets 域 M2 两态语义不回改)**:`PATCH /api/v1/me` 的 + `nickname`/`avatarAssetId` 与 `PATCH /api/v1/pets/{petId}` 的 `avatarAssetId` + 按三态解释——**键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。 + 昵称与头像天生可选,「删掉我设的那个」是一等公民操作,只有两态无法表达。 + 同一请求体内的其余 pets 字段仍是 M2 的「缺省或 null 皆为不改」。 + - **空 PATCH 与纯空白昵称一律 400/40000**,不静默 200、不隐式清空:清空只留 + 显式 `null` 一条路,否则「误提交空格」与「想删昵称」无法区分。 + - **昵称**:btrim 后 1~32 **码点**(非 UTF-16 长度);**不设唯一约束**(ADR-022, + 允许重名,靠 userId 区分);注册不收昵称。`/api/v1/me` 返回 **DB 原值**, + 未设置即 `null`,**不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定 + 固化成真实数据;他人视角的展示回退在 `/internal/users/profiles`(SQL COALESCE), + 本人视角的展示回退由客户端做 `nickname ?? username`。 + - **头像读取一律 `avatarUrl`(时效性预签名 GET,与帖图同一纪律)**:每次响应现签, + **会过期、客户端不得持久化**,过期即重取;无头像、asset 非 ready、对象存储未配置 + 三种情况均为 `null`(降级而非报错——签一个必然 404 的 URL 比给 null 更糟)。 + 指针不隐式清理:asset 事后退出 ready 时 `avatarUrl` 转 null 而引用保留。 + - **`avatarAssetId` 只写不读**:请求体收,响应**一律不外露**(`Me` 与 `Pet` 皆无该字段); + 「是否有头像」等价于 `avatarUrl != null`。 + - **两种头像用途互不通用**:`user_avatar` 不能当宠物头像、`pet_avatar` 不能当用户头像、 + `post_image` 不能当任何头像——引用侧按 `purpose` 校验,不符者 404/40405 + (四态校验:不存在/非本人/已删/用途不符 → 404/40405;本人且用途相符但 + uploading/failed → 422/42203)。 + - **宠物头像的权限档按「本次请求碰了哪些字段」定档**(ADR-022 头像为 WRITE 档): + 仅改 `avatarAssetId` 时 owner + caregiver 皆可(viewer 403/40300);触及任一资料 + 字段时仍是 MANAGE(仅 owner);**混合请求取更严的一半**,堵住把改名夹带进头像 + 请求绕过 MANAGE 的路径。头像与资料共用同一把乐观锁 `version`(仍必填)。 + - **`/api/v1/me` 无乐观锁、无幂等键**:只有一个合法写者(账号本人),暴露 `version` + 只是给客户端加负担;丢失更新由**列级选择性 UPDATE** 排除(SET 列表只含本次请求 + 真正携带的列),并发改不同字段两者皆存活;同 body 重放天然幂等。 + - **`GET /api/v1/me/community-stats` 为独立端点**(ADR-022 决策 A,不并入 + `/users/{userId}/follow-stats`——后者主体是「某用户的关注数」,混入「我的获赞」 + 会让一个载荷有两个主体)。路径上**没有 userId**:「查不到别人的获赞」不靠权限 + 判断,而是入口本身不存在,故**永不 404**,任何已认证用户都有 stats。 + servers: - url: http://127.0.0.1:8081 description: patbond-auth(本地开发,/api/v1/auth/**) @@ -140,7 +186,7 @@ tags: - name: auth description: 注册 / 登录 / 刷新 / 退出(patbond-auth) - name: user - description: 当前用户(patbond-user) + description: 当前用户资料:读取与昵称/头像更新(patbond-user) - name: analytics description: 产品事件批量上报(patbond-user) - name: pets @@ -152,7 +198,9 @@ tags: - name: media description: 媒体上传两步流程(patbond-user,ADR-016 预签名直传) - name: posts - description: 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子(patbond-community) + description: | + 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子;含由帖子派生的「我的社区数字」 + 聚合(patbond-community) - name: feed description: 公共 Feed 游标分页(patbond-community) - name: comments @@ -301,7 +349,15 @@ paths: get: tags: [user] summary: 当前用户资料 - description: 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 + description: | + 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 + + - `nickname` 为 **DB 原值**,未设置即 `null`(**不做 username 回退**,见 info + 「用户资料与头像域约定」);本人视角的展示回退由客户端做 `nickname ?? username`。 + - `avatarUrl` 为**每次响应现签的时效性预签名 GET**:**会过期、客户端不得持久化**, + 过期即重取;无头像、asset 非 ready、对象存储未配置均为 `null`。 + - 响应**不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 + `avatarUrl != null`。 operationId: me security: - bearerAuth: [] @@ -320,6 +376,66 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + patch: + tags: [user] + summary: 更新当前用户资料(昵称 / 头像,三态部分更新) + description: | + 本人资料的唯一写入口(主体恒为 token 里的调用者,无「他人」情形)。 + + - **三态语义**:键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置。 + - **空 patch 400/40000**(两字段都未出现,含只带未声明字段):不静默 200, + 空 PATCH 几乎总是客户端 bug。纯空白/空串昵称同为 400/40000,不隐式清空。 + - `avatarAssetId` 须为**调用者本人、用途为 `user_avatar`、状态 `ready`** 的 asset: + 不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed + 422/42203。 + - **无 `version` 乐观锁、无 `Idempotency-Key`**:只有一个合法写者;丢失更新由 + 列级选择性 UPDATE 排除(并发改不同字段两者皆存活),同 body 重放天然幂等。 + - 成功返回**与 GET 完全相同的 `Me` 全量形态**(回显更新后资料,`avatarUrl` 现签)。 + operationId: updateMe + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMeRequest' + responses: + '200': + description: 更新成功,返回更新后的完整 Me + content: + application/json: + schema: + $ref: '#/components/schemas/MeEnvelope' + '400': + description: | + 参数校验失败(code 40000):空 patch、昵称 btrim 后长度不在 1~32 码点、 + 昵称纯空白或空串、`avatarAssetId` 非法 UUID、body 非法 JSON + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + emptyPatch: + value: { code: 40000, message: 请求未包含任何可更新字段, data: null } + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + description: | + 用户不存在或已注销(code 40400,token 仍有效但账号已注销);或 + `avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `user_avatar` + (code 40405,防枚举合并) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + userNotFound: + value: { code: 40400, message: 用户不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体资源不存在, data: null } + '422': + $ref: '#/components/responses/MediaNotReady' /api/v1/events: post: @@ -468,18 +584,32 @@ paths: $ref: '#/components/responses/PetNotFound' patch: tags: [pets] - summary: 更新宠物档案 + summary: 更新宠物档案(含头像) description: | - 权限档:MANAGE(**仅 owner**);caregiver/viewer 更新得 403/40300。 + 权限档**按本次请求触及的字段定档**(M3.5 起,ADR-022): - - 部分更新:缺席字段不变;**不支持将可选字段清空回 null**。 + | 请求体触及 | 所需档位 | caregiver | viewer | + | --- | --- | --- | --- | + | 仅 `avatarAssetId`(+ `version`) | WRITE | ✅ | ✗ 403/40300 | + | 任一资料字段(name/sex/status/…) | MANAGE(仅 owner) | ✗ 403/40300 | ✗ 403/40300 | + | 资料字段 + `avatarAssetId` 混合 | MANAGE(**取更严的一半**) | ✗ 403/40300 | ✗ 403/40300 | + + 混合请求取更严,是为了堵住「夹带」——否则 caregiver 可把改名塞进头像请求绕过 MANAGE。 + + - 部分更新:缺席字段不变;资料字段**不支持清空回 null**(M2 语义不回改)。 + - **例外:`avatarAssetId` 是本端点唯一的三态字段**(M3.5)——键缺省 = 不改; + 键出现且为 `null` = **清除头像**;键出现且有值 = 设置。差异刻意限定在有清空 + 需求的字段上。 - 例外:品种对(`breedId`/`customBreedName`)**整体替换**——提交任一侧即替换 整对,互斥校验同创建。 - `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。 - `status` 可迁移至 active/lost/deceased/archived;**`deleted` 不可经 PATCH 设置**(400/40000,软删除留待专用端点,M2 契约不含)。 - - `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 + - `version` **必填**(缺失 400/40000),即便只改头像;比对通过才写入并 +1; + 过期 409/40902。头像与资料共用同一把乐观锁——头像变更也应让并发编辑者感知行已变。 - 芯片号改为已被登记的值:409/40903。 + - `avatarAssetId` 须为**调用者本人、用途为 `pet_avatar`、状态 `ready`** 的 asset: + 不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203。 operationId: updatePet security: - bearerAuth: [] @@ -505,7 +635,19 @@ paths: '403': $ref: '#/components/responses/PetWriteDenied' '404': - $ref: '#/components/responses/PetNotFound' + description: | + 宠物不存在、已软删除或调用者与宠物无关系(code 40401,防枚举合并);或 + `avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `pet_avatar` + (code 40405,防枚举合并) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + petNotFound: + value: { code: 40401, message: 宠物不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体资源不存在, data: null } '409': description: 版本冲突(code 40902)或芯片号已被登记(code 40903) content: @@ -517,6 +659,8 @@ paths: value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } microchipExists: value: { code: 40903, message: 芯片号已被登记, data: null } + '422': + $ref: '#/components/responses/MediaNotReady' /api/v1/breeds: get: @@ -1201,7 +1345,7 @@ paths: petNotFound: value: { code: 40401, message: 宠物不存在, data: null } mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } '409': $ref: '#/components/responses/IdempotencyPayloadMismatch' '422': @@ -1284,7 +1428,7 @@ paths: petNotFound: value: { code: 40401, message: 宠物不存在, data: null } mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } '409': $ref: '#/components/responses/VersionConflict' '422': @@ -1348,6 +1492,35 @@ paths: '401': $ref: '#/components/responses/AccessTokenInvalid' + /api/v1/me/community-stats: + get: + tags: [posts] + summary: 我的社区数字(获赞总数 / 作品数) + description: | + 主体恒为 token 里的调用者:无查询参数、无路径参数,**路径上没有 userId**—— + 「查不到别人的获赞」不靠权限判断,而是入口本身不存在。 + + - **统计集合 = 本人的、`status='published'` 的、`deleted_at IS NULL` 的帖**。 + 草稿不计(尚非作品,且未发布不可被赞);软删不计(删帖即撤回其数字,与 + `/me/posts`、Feed 的可见性一致);运营态 hidden/archived 不计(对所有人不可见, + 含作者本人);他人帖自然不计。 + - **自己赞自己计入**——与帖子详情页的 `likeCount` 保持同一口径,两处数字必须能对上。 + - `receivedLikeCount` = 该集合的 `like_count` 之和(读侧实时聚合,读的是写侧同事务 + 维护的帖级冗余列,故为精确值而非估算;ADR-022 不引入按人累计的冗余列)。 + - **空数据返回 `0` 而非 null**,且**永不 404**:任何已认证用户都有 stats。 + operationId: getMyCommunityStats + security: + - bearerAuth: [] + responses: + '200': + description: 我的获赞总数与作品数 + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityStatsEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + /api/v1/feed: get: tags: [feed] @@ -1812,8 +1985,9 @@ components: value: { code: 40402, message: 记录不存在, data: null } PetWriteDenied: description: | - 对可见宠物无相应操作权限(code 40300):viewer 写记录、caregiver/viewer 改 - 宠物档案。仅发给对宠物「可见」的调用者,不泄露新信息。 + 对可见宠物无相应操作权限(code 40300):viewer 写记录或改头像、caregiver/viewer + 改宠物档案的资料字段(caregiver 仅改 `avatarAssetId` 时允许,M3.5 按字段分档)。 + 仅发给对宠物「可见」的调用者,不泄露新信息。 content: application/json: schema: @@ -1854,14 +2028,16 @@ components: commentNotFound: value: { code: 40404, message: 评论不存在, data: null } MediaNotFound: - description: asset 不存在、非本人所有或已删(code 40405,防枚举合并) + description: | + asset 不存在、非本人所有、已删,或**用途与引用场景不符**(code 40405,防枚举合并)。 + 用途不符即「从头像域看,一张帖子配图不是头像」;两种头像用途亦互不通用。 content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } UserNotFound: description: 目标用户不存在或已注销(code 40406,合并不泄露成因) content: @@ -2001,8 +2177,10 @@ components: Me: type: object - description: 当前用户资料(冻结契约,恰好这 4 个字段) - required: [userId, username, createdAt] + description: | + 本人资料(`GET` 与 `PATCH /api/v1/me` 的统一响应形态,冻结契约恰好这 6 个字段)。 + **不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 `avatarUrl != null`。 + required: [userId, username, nickname, avatarUrl, createdAt] properties: userId: type: string @@ -2011,16 +2189,65 @@ components: username: type: string example: demo_user + nickname: + type: string + nullable: true + minLength: 1 + maxLength: 32 + description: | + 昵称,**DB 原值**;未设置为 null(键恒在)。长度按**码点**计 1~32(btrim 后)。 + **本端点不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定固化成 + 真实数据;本人视角的展示回退由客户端做 `nickname ?? username`,他人视角的 + 回退在 `/internal/users/profiles`(SQL COALESCE,不属本公开契约)。 + 不设唯一约束(ADR-022,允许重名)。 + example: 小柴 phone: type: string nullable: true description: E.164;未绑定时为 null example: '+8613800138000' + avatarUrl: + type: string + nullable: true + description: | + 头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + **客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。 + 无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。 + example: https://minio.example.com/patbond-media/user_avatar/2026/09/019212aa…?X-Amz-Signature=… createdAt: type: string format: date-time example: '2026-09-04T04:05:06.789Z' + UpdateMeRequest: + type: object + description: | + 本人资料部分更新(**三态语义**,与 pets 域 M2 的两态刻意不同): + **键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。 + 两字段都未出现(含只带未声明字段)为**空 patch**,答 400/40000 而非静默 200。 + 无必填字段、无 `version` 乐观锁、无 `Idempotency-Key`(同 body 重放天然幂等)。 + properties: + nickname: + type: string + nullable: true + minLength: 1 + maxLength: 32 + description: | + 昵称;btrim 后长度按**码点**计须在 1~32,否则 400/40000。 + 显式 `null` = **清空昵称**;纯空白或空串是 400/40000,**不是隐式清空** + (清空只留显式 null 一条路,否则「误提交空格」与「想删昵称」无法区分)。 + example: 小柴 + avatarAssetId: + type: string + format: uuid + nullable: true + description: | + 头像 asset ID(两步上传的产物,`purpose` 须为 `user_avatar`)。 + 显式 `null` = **清除头像**。校验:不存在/非本人/已删/用途不符 404/40405; + 本人且用途相符但 uploading/failed 422/42203;非法 UUID 400/40000。 + **响应不回显该字段**(只写不读)。 + example: 019212bb-0000-7000-8000-000000000009 + AuthTokenEnvelope: type: object required: [code, message] @@ -2226,7 +2453,8 @@ components: `breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed); `breedDisplayName` 由品种字典解出,随 breedId 存在。 软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的 - status 永不出现 deleted。`avatarAssetId` 不出现在 M2 契约(ADR-010)。 + status 永不出现 deleted。头像以 `avatarUrl` 的现签形式返回, + **`avatarAssetId` 不外露**(只写不读,写入口为 `PATCH /api/v1/pets/{petId}`)。 required: - id - name @@ -2234,6 +2462,7 @@ components: - sex - birthDateEstimated - status + - avatarUrl - myRole - createdAt - updatedAt @@ -2294,6 +2523,15 @@ components: type: string enum: [active, lost, deceased, archived] description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401) + avatarUrl: + type: string + nullable: true + description: | + 宠物头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + **客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。 + 无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。 + asset 事后退出 ready 时转 null 而引用保留(读请求不做写副作用)。 + example: https://minio.example.com/patbond-media/pet_avatar/2026/09/019212cc…?X-Amz-Signature=… myRole: type: string enum: [owner, caregiver, viewer] @@ -2351,14 +2589,18 @@ components: UpdatePetRequest: type: object description: | - 部分更新:缺席字段不变;不支持清空回 null。例外:品种对 + 部分更新:缺席字段不变;资料字段不支持清空回 null。例外:品种对 (breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。 species 不可改(不在请求体)。 + **`avatarAssetId` 是本 schema 唯一的三态字段**(M3.5):键缺省 = 不改; + 键出现且为 `null` = 清除头像;键出现且有值 = 设置。其余字段保持 M2 两态语义。 + 权限档按本次触及的字段决定(仅头像 → WRITE;触及资料字段 → MANAGE;混合取更严), + 见端点描述。 required: [version] properties: version: type: integer - description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902) + description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902);即便只改头像也必带 name: type: string minLength: 1 @@ -2393,6 +2635,16 @@ components: type: string enum: [active, lost, deceased, archived] description: 状态流转;deleted 不可经 PATCH 设置(400/40000) + avatarAssetId: + type: string + format: uuid + nullable: true + description: | + 宠物头像 asset ID(两步上传的产物,`purpose` 须为 `pet_avatar`)。 + **三态**:缺省 = 不改;显式 `null` = 清除头像;给值 = 设置。校验:不存在/ + 非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203; + 非法 UUID 400/40000。**响应不回显该字段**(只写不读,读取见 `Pet.avatarUrl`)。 + example: 019212cc-0000-7000-8000-00000000000a PetEnvelope: type: object @@ -3205,10 +3457,13 @@ components: description: M3 仅 image(ADR-018 视频后置;video/document 为向后新增枚举预留) purpose: type: string - enum: [post_image] + enum: [post_image, user_avatar, pet_avatar] description: | - 用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩 - user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展) + 用途白名单(服务端配置项 `patbond.media.allowed-purposes`,决定 objectKey 前缀 + `/yyyy/MM/{assetId}`)。M3.5 起为三值:`post_image`(帖子配图)、 + `user_avatar`(用户头像)、`pet_avatar`(宠物头像)——相对 M3 的纯枚举追加。 + **用途即引用侧的类型检查**:引用时校验 `purpose` 相符,故帖图不能当头像、 + 两种头像也互不通用(不符者 404/40405)。白名单外的取值 400/40000。 mimeType: type: string enum: [image/jpeg, image/png, image/webp] @@ -3857,3 +4112,36 @@ components: example: success data: $ref: '#/components/schemas/FollowStats' + + CommunityStats: + type: object + description: | + 调用者本人的社区数字(M3.5)。两数同一集合:本人的、`status='published'` 的、 + 未软删的帖(草稿 / 软删 / hidden / archived 均不计)。读侧实时聚合,无冗余计数列。 + required: [receivedLikeCount, publishedPostCount] + properties: + receivedLikeCount: + type: integer + format: int64 + description: | + 获赞总数 = 该集合的 `like_count` 之和(写侧同事务维护的帖级冗余列,精确值)。 + **自己赞自己计入**,与帖子详情的 `likeCount` 同一口径。空数据为 0,非 null。 + example: 128 + publishedPostCount: + type: integer + format: int64 + description: 作品数 = 该集合的帖子数。空数据为 0,非 null。 + example: 12 + + CommunityStatsEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/CommunityStats' diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/contract/CommunityContractConformanceTest.java b/patbond-community/src/test/java/com/patbond/patbond/community/contract/CommunityContractConformanceTest.java index 565e1e5..ba37284 100644 --- a/patbond-community/src/test/java/com/patbond/patbond/community/contract/CommunityContractConformanceTest.java +++ b/patbond-community/src/test/java/com/patbond/patbond/community/contract/CommunityContractConformanceTest.java @@ -28,20 +28,21 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; /** - * T3-20(M3 第二波收尾):community 域 17 个操作补进契约一致性保障,机制与 - * patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.3.0(快照 - * {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓 + * T3-20(M3 第二波收尾):community 域 18 个操作补进契约一致性保障,机制与 + * patbond-pet 的 ContractConformanceTest 同构——对冻结契约 v1.4.0(快照 + * {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在 doc 仓 * {@code docs/api/openapi.yaml})逐操作真实起服务发请求,用 * {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、字段名 * 与类型、必填与 nullable、枚举与格式、信封结构、错误码值。 * *

覆盖目标是**全响应矩阵**:最后的 {@link #everyDeclaredResponseCellIsExercised()} - * 断言契约为这 17 个操作声明的每一个 (操作, 状态码) 单元格(共 64 格)都被 + * 断言契约为这 18 个操作声明的每一个 (操作, 状态码) 单元格(共 66 格)都被 * 至少一次真实响应校验过,**无豁免**——community 域的 409 均为幂等键/乐观锁 * 冲突、422 均为业务规则拒绝,单线程即可确定性触发。 * *

media 域 2 个操作属 patbond-user 模块,由该模块的 - * MediaContractConformanceTest 覆盖(快照同一份)。 + * MediaContractConformanceTest 覆盖;auth/user 域 7 操作在 patbond-auth + * (含 v1.4.0 新增的 PATCH /api/v1/me)。快照四模块同一份。 */ @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class CommunityContractConformanceTest extends PostApiTestBase { @@ -52,7 +53,7 @@ class CommunityContractConformanceTest extends PostApiTestBase { /** 已被真实响应校验过的 (操作, 状态码) 单元格,如 "GET /api/v1/feed 200"。 */ private static final Set COVERED = ConcurrentHashMap.newKeySet(); - /** community 域 17 个操作(= 契约中 tags ∈ {posts, feed, comments, interactions, follows})。 */ + /** community 域 18 个操作(= 契约中 tags ∈ {posts, feed, comments, interactions, follows})。 */ private static final List COMMUNITY_OPERATIONS = List.of( "POST /api/v1/posts", "GET /api/v1/posts/{postId}", @@ -70,7 +71,8 @@ class CommunityContractConformanceTest extends PostApiTestBase { "GET /api/v1/me/bookmarks", "PUT /api/v1/users/{userId}/follow", "DELETE /api/v1/users/{userId}/follow", - "GET /api/v1/users/{userId}/follow-stats"); + "GET /api/v1/users/{userId}/follow-stats", + "GET /api/v1/me/community-stats"); private static final String IDEMPOTENCY_KEY = "Idempotency-Key"; @@ -127,7 +129,7 @@ class CommunityContractConformanceTest extends PostApiTestBase { return JsonPath.read(created, "$.data.id"); } - // ---- 成功路径:17 操作全覆盖 --------------------------------------- + // ---- 成功路径:18 操作全覆盖 --------------------------------------- @Test @Order(1) @@ -299,6 +301,43 @@ class CommunityContractConformanceTest extends PostApiTestBase { assertThat((Boolean) JsonPath.read(unfollowedAgain, "$.data.following")).isFalse(); } + /** + * `GET /api/v1/me/community-stats`(v1.4.0 新增操作,T3.5-07):空数据零值 + * 与有数据两个分支都过严格校验。**永不 404**,故本操作只有 200/401 两格 + * (401 由 {@link #unauthenticatedRequestsAnswer40101OnAllOperations()} 的 + * 全操作循环覆盖)。 + */ + @Test + @Order(51) + void myCommunityStatsSuccessShapes() throws Exception { + UUID fresh = newUser(); + + // 空数据:两数为 0 而非 null,且不是 404 + String zeros = verified(get("/api/v1/me/community-stats") + .header("Authorization", "Bearer " + token(fresh)), + "GET", "/api/v1/me/community-stats", 200); + assertThat(((Number) JsonPath.read(zeros, "$.data.receivedLikeCount")).longValue()) + .isZero(); + assertThat(((Number) JsonPath.read(zeros, "$.data.publishedPostCount")).longValue()) + .isZero(); + + // 有数据:两篇已发布帖,其中一篇被他人赞一次 → 1 赞 / 2 作品 + UUID author = newUser(); + UUID fan = newUser(); + String postA = newPublishedPost(author, "契约统计帖甲"); + newPublishedPost(author, "契约统计帖乙"); + verified(authed(put("/api/v1/posts/{postId}/like", postA), fan), + "PUT", "/api/v1/posts/{postId}/like", 200); + + String counted = verified(get("/api/v1/me/community-stats") + .header("Authorization", "Bearer " + token(author)), + "GET", "/api/v1/me/community-stats", 200); + assertThat(((Number) JsonPath.read(counted, "$.data.receivedLikeCount")).longValue()) + .isEqualTo(1L); + assertThat(((Number) JsonPath.read(counted, "$.data.publishedPostCount")).longValue()) + .isEqualTo(2L); + } + // ---- 错误信封 ------------------------------------------------------ @Test @@ -482,18 +521,18 @@ class CommunityContractConformanceTest extends PostApiTestBase { @Test @Order(98) void frozenSnapshotIsTheExpectedContractVersion() { - assertThat(CONTRACT.version()).isEqualTo("1.3.0"); - assertThat(CONTRACT.paths()).hasSize(31); - assertThat(CONTRACT.operations()).hasSize(43); - assertThat(CONTRACT.schemas()).hasSize(72); + assertThat(CONTRACT.version()).isEqualTo("1.4.0"); + assertThat(CONTRACT.paths()).hasSize(32); + assertThat(CONTRACT.operations()).hasSize(45); + assertThat(CONTRACT.schemas()).hasSize(75); assertThat(CONTRACT.operationsTagged( Set.of("posts", "feed", "comments", "interactions", "follows"))) .containsExactlyInAnyOrderElementsOf(COMMUNITY_OPERATIONS); } /** - * 全矩阵覆盖门禁:community 域 17 个操作声明的每个 (操作, 状态码) 都必须被 - * 前面的测试真实触发并通过契约校验(64 个单元格,无豁免)。 + * 全矩阵覆盖门禁:community 域 18 个操作声明的每个 (操作, 状态码) 都必须被 + * 前面的测试真实触发并通过契约校验(66 个单元格,无豁免)。 */ @Test @Order(99) diff --git a/patbond-community/src/test/java/com/patbond/patbond/community/contract/OpenApiContract.java b/patbond-community/src/test/java/com/patbond/patbond/community/contract/OpenApiContract.java index 7e6a966..dd78440 100644 --- a/patbond-community/src/test/java/com/patbond/patbond/community/contract/OpenApiContract.java +++ b/patbond-community/src/test/java/com/patbond/patbond/community/contract/OpenApiContract.java @@ -13,8 +13,8 @@ import java.util.Objects; import java.util.Set; /** - * The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot - * {@code /contract/openapi-v1.3.0.yaml}. + * The frozen v1.4.0 OpenAPI contract, loaded from the test-resource snapshot + * {@code /contract/openapi-v1.4.0.yaml}. * *

Sync discipline (T2-09, extended by T3-19): the canonical * contract lives in the doc repo at {@code docs/api/openapi.yaml}; this @@ -36,7 +36,7 @@ import java.util.Set; */ final class OpenApiContract { - static final String RESOURCE = "/contract/openapi-v1.3.0.yaml"; + static final String RESOURCE = "/contract/openapi-v1.4.0.yaml"; private static final Set HTTP_METHODS = Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace"); diff --git a/patbond-community/src/test/resources/contract/openapi-v1.3.0.yaml b/patbond-community/src/test/resources/contract/openapi-v1.4.0.yaml similarity index 87% rename from patbond-community/src/test/resources/contract/openapi-v1.3.0.yaml rename to patbond-community/src/test/resources/contract/openapi-v1.4.0.yaml index c9d3072..d669f33 100644 --- a/patbond-community/src/test/resources/contract/openapi-v1.3.0.yaml +++ b/patbond-community/src/test/resources/contract/openapi-v1.4.0.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约) - version: 1.3.0 + version: 1.4.0 description: | Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差), 1.1.0 追加埋点上报端点 `POST /api/v1/events`(M2 第一波契约补录,以实现实测行为为准)。 @@ -11,6 +11,14 @@ info: **1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、 公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入 (iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。 + **1.4.0 M3.5 契约冻结:用户资料与头像**——`GET /api/v1/me` 补 `nickname`/`avatarUrl`, + 新增 `PATCH /api/v1/me`(昵称与头像读写,三态部分更新),`Pet` 补 `avatarUrl` 且 + `PATCH /api/v1/pets/{petId}` 收 `avatarAssetId`,新增 `GET /api/v1/me/community-stats` + (获赞总数与作品数),媒体 `purpose` 白名单追加 `user_avatar`/`pet_avatar` + (iteration-3.5 报告 03 定型表;冻结报告见 iteration-3.5/04)。 + **1.4.0 相对 1.3.0 纯增量**:无字段删改、无类型变更、无必填收紧,仅新增操作、 + 新增响应字段(键恒在、值可空)、新增可选请求字段、新增响应格与枚举追加, + v1.3.0 客户端无需改动即可继续工作。 ## 通用约定(development-plan 第 6 节) - 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 @@ -29,14 +37,14 @@ info: | 40100 | 401 | 用户名或密码错误 | | 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) | | 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) | - | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录、caregiver 改宠物档案) | + | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录或改头像、caregiver 改宠物档案——头像除外,见 M3.5 分档) | | 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 | | 40400 | 404 | 资源不存在 | | 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) | | 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) | | 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 | | 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) | - | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有或已删(防枚举合并) | + | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有、已删,或**用途与引用场景不符**(帖图当头像、user_avatar 当宠物头像等);防枚举合并,同码各情形响应一致(用途不符分支只对调用者自己的 asset 可达,故 message 可具体) | | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) | | 40900 | 409 | 用户名已存在(大小写不敏感) | | 40901 | 409 | 手机号已被使用 | @@ -46,7 +54,7 @@ info: | 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) | | 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 | | 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 | - | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有但非 ready(uploading/failed)状态的 asset | + | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有、用途相符但非 ready(uploading/failed)状态的 asset(帖图与用户/宠物头像同构) | | 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op) | | 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 | | 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) | @@ -68,7 +76,10 @@ info: - **权限模型(ADR-015:owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档: - `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要; - `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH; - - `MANAGE`——仅 owner:宠物档案 PATCH(含状态流转)。 + **M3.5 起还含宠物头像字段 `avatarAssetId`**(ADR-022:头像属日常照护信息)。 + - `MANAGE`——仅 owner:宠物档案 PATCH 的资料字段(含状态流转)。 + **同一端点按「本次请求触及哪些字段」定档**(不是端点级降档):仅改头像走 WRITE, + 触及任一资料字段走 MANAGE,混合请求取更严的一半。 权限每请求实时查库、无缓存:撤销照护关系立即生效。 - **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况 响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与 @@ -126,6 +137,41 @@ info: 整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口 (如作者公开资料批量接口)不属于本公开契约。 + ## 用户资料与头像域约定(M3.5 冻结,iteration-3.5 报告 03 定型;ADR-022) + - **三态部分更新(仅本域,pets 域 M2 两态语义不回改)**:`PATCH /api/v1/me` 的 + `nickname`/`avatarAssetId` 与 `PATCH /api/v1/pets/{petId}` 的 `avatarAssetId` + 按三态解释——**键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。 + 昵称与头像天生可选,「删掉我设的那个」是一等公民操作,只有两态无法表达。 + 同一请求体内的其余 pets 字段仍是 M2 的「缺省或 null 皆为不改」。 + - **空 PATCH 与纯空白昵称一律 400/40000**,不静默 200、不隐式清空:清空只留 + 显式 `null` 一条路,否则「误提交空格」与「想删昵称」无法区分。 + - **昵称**:btrim 后 1~32 **码点**(非 UTF-16 长度);**不设唯一约束**(ADR-022, + 允许重名,靠 userId 区分);注册不收昵称。`/api/v1/me` 返回 **DB 原值**, + 未设置即 `null`,**不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定 + 固化成真实数据;他人视角的展示回退在 `/internal/users/profiles`(SQL COALESCE), + 本人视角的展示回退由客户端做 `nickname ?? username`。 + - **头像读取一律 `avatarUrl`(时效性预签名 GET,与帖图同一纪律)**:每次响应现签, + **会过期、客户端不得持久化**,过期即重取;无头像、asset 非 ready、对象存储未配置 + 三种情况均为 `null`(降级而非报错——签一个必然 404 的 URL 比给 null 更糟)。 + 指针不隐式清理:asset 事后退出 ready 时 `avatarUrl` 转 null 而引用保留。 + - **`avatarAssetId` 只写不读**:请求体收,响应**一律不外露**(`Me` 与 `Pet` 皆无该字段); + 「是否有头像」等价于 `avatarUrl != null`。 + - **两种头像用途互不通用**:`user_avatar` 不能当宠物头像、`pet_avatar` 不能当用户头像、 + `post_image` 不能当任何头像——引用侧按 `purpose` 校验,不符者 404/40405 + (四态校验:不存在/非本人/已删/用途不符 → 404/40405;本人且用途相符但 + uploading/failed → 422/42203)。 + - **宠物头像的权限档按「本次请求碰了哪些字段」定档**(ADR-022 头像为 WRITE 档): + 仅改 `avatarAssetId` 时 owner + caregiver 皆可(viewer 403/40300);触及任一资料 + 字段时仍是 MANAGE(仅 owner);**混合请求取更严的一半**,堵住把改名夹带进头像 + 请求绕过 MANAGE 的路径。头像与资料共用同一把乐观锁 `version`(仍必填)。 + - **`/api/v1/me` 无乐观锁、无幂等键**:只有一个合法写者(账号本人),暴露 `version` + 只是给客户端加负担;丢失更新由**列级选择性 UPDATE** 排除(SET 列表只含本次请求 + 真正携带的列),并发改不同字段两者皆存活;同 body 重放天然幂等。 + - **`GET /api/v1/me/community-stats` 为独立端点**(ADR-022 决策 A,不并入 + `/users/{userId}/follow-stats`——后者主体是「某用户的关注数」,混入「我的获赞」 + 会让一个载荷有两个主体)。路径上**没有 userId**:「查不到别人的获赞」不靠权限 + 判断,而是入口本身不存在,故**永不 404**,任何已认证用户都有 stats。 + servers: - url: http://127.0.0.1:8081 description: patbond-auth(本地开发,/api/v1/auth/**) @@ -140,7 +186,7 @@ tags: - name: auth description: 注册 / 登录 / 刷新 / 退出(patbond-auth) - name: user - description: 当前用户(patbond-user) + description: 当前用户资料:读取与昵称/头像更新(patbond-user) - name: analytics description: 产品事件批量上报(patbond-user) - name: pets @@ -152,7 +198,9 @@ tags: - name: media description: 媒体上传两步流程(patbond-user,ADR-016 预签名直传) - name: posts - description: 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子(patbond-community) + description: | + 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子;含由帖子派生的「我的社区数字」 + 聚合(patbond-community) - name: feed description: 公共 Feed 游标分页(patbond-community) - name: comments @@ -301,7 +349,15 @@ paths: get: tags: [user] summary: 当前用户资料 - description: 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 + description: | + 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 + + - `nickname` 为 **DB 原值**,未设置即 `null`(**不做 username 回退**,见 info + 「用户资料与头像域约定」);本人视角的展示回退由客户端做 `nickname ?? username`。 + - `avatarUrl` 为**每次响应现签的时效性预签名 GET**:**会过期、客户端不得持久化**, + 过期即重取;无头像、asset 非 ready、对象存储未配置均为 `null`。 + - 响应**不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 + `avatarUrl != null`。 operationId: me security: - bearerAuth: [] @@ -320,6 +376,66 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + patch: + tags: [user] + summary: 更新当前用户资料(昵称 / 头像,三态部分更新) + description: | + 本人资料的唯一写入口(主体恒为 token 里的调用者,无「他人」情形)。 + + - **三态语义**:键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置。 + - **空 patch 400/40000**(两字段都未出现,含只带未声明字段):不静默 200, + 空 PATCH 几乎总是客户端 bug。纯空白/空串昵称同为 400/40000,不隐式清空。 + - `avatarAssetId` 须为**调用者本人、用途为 `user_avatar`、状态 `ready`** 的 asset: + 不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed + 422/42203。 + - **无 `version` 乐观锁、无 `Idempotency-Key`**:只有一个合法写者;丢失更新由 + 列级选择性 UPDATE 排除(并发改不同字段两者皆存活),同 body 重放天然幂等。 + - 成功返回**与 GET 完全相同的 `Me` 全量形态**(回显更新后资料,`avatarUrl` 现签)。 + operationId: updateMe + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMeRequest' + responses: + '200': + description: 更新成功,返回更新后的完整 Me + content: + application/json: + schema: + $ref: '#/components/schemas/MeEnvelope' + '400': + description: | + 参数校验失败(code 40000):空 patch、昵称 btrim 后长度不在 1~32 码点、 + 昵称纯空白或空串、`avatarAssetId` 非法 UUID、body 非法 JSON + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + emptyPatch: + value: { code: 40000, message: 请求未包含任何可更新字段, data: null } + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + description: | + 用户不存在或已注销(code 40400,token 仍有效但账号已注销);或 + `avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `user_avatar` + (code 40405,防枚举合并) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + userNotFound: + value: { code: 40400, message: 用户不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体资源不存在, data: null } + '422': + $ref: '#/components/responses/MediaNotReady' /api/v1/events: post: @@ -468,18 +584,32 @@ paths: $ref: '#/components/responses/PetNotFound' patch: tags: [pets] - summary: 更新宠物档案 + summary: 更新宠物档案(含头像) description: | - 权限档:MANAGE(**仅 owner**);caregiver/viewer 更新得 403/40300。 + 权限档**按本次请求触及的字段定档**(M3.5 起,ADR-022): - - 部分更新:缺席字段不变;**不支持将可选字段清空回 null**。 + | 请求体触及 | 所需档位 | caregiver | viewer | + | --- | --- | --- | --- | + | 仅 `avatarAssetId`(+ `version`) | WRITE | ✅ | ✗ 403/40300 | + | 任一资料字段(name/sex/status/…) | MANAGE(仅 owner) | ✗ 403/40300 | ✗ 403/40300 | + | 资料字段 + `avatarAssetId` 混合 | MANAGE(**取更严的一半**) | ✗ 403/40300 | ✗ 403/40300 | + + 混合请求取更严,是为了堵住「夹带」——否则 caregiver 可把改名塞进头像请求绕过 MANAGE。 + + - 部分更新:缺席字段不变;资料字段**不支持清空回 null**(M2 语义不回改)。 + - **例外:`avatarAssetId` 是本端点唯一的三态字段**(M3.5)——键缺省 = 不改; + 键出现且为 `null` = **清除头像**;键出现且有值 = 设置。差异刻意限定在有清空 + 需求的字段上。 - 例外:品种对(`breedId`/`customBreedName`)**整体替换**——提交任一侧即替换 整对,互斥校验同创建。 - `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。 - `status` 可迁移至 active/lost/deceased/archived;**`deleted` 不可经 PATCH 设置**(400/40000,软删除留待专用端点,M2 契约不含)。 - - `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 + - `version` **必填**(缺失 400/40000),即便只改头像;比对通过才写入并 +1; + 过期 409/40902。头像与资料共用同一把乐观锁——头像变更也应让并发编辑者感知行已变。 - 芯片号改为已被登记的值:409/40903。 + - `avatarAssetId` 须为**调用者本人、用途为 `pet_avatar`、状态 `ready`** 的 asset: + 不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203。 operationId: updatePet security: - bearerAuth: [] @@ -505,7 +635,19 @@ paths: '403': $ref: '#/components/responses/PetWriteDenied' '404': - $ref: '#/components/responses/PetNotFound' + description: | + 宠物不存在、已软删除或调用者与宠物无关系(code 40401,防枚举合并);或 + `avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `pet_avatar` + (code 40405,防枚举合并) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + petNotFound: + value: { code: 40401, message: 宠物不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体资源不存在, data: null } '409': description: 版本冲突(code 40902)或芯片号已被登记(code 40903) content: @@ -517,6 +659,8 @@ paths: value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } microchipExists: value: { code: 40903, message: 芯片号已被登记, data: null } + '422': + $ref: '#/components/responses/MediaNotReady' /api/v1/breeds: get: @@ -1201,7 +1345,7 @@ paths: petNotFound: value: { code: 40401, message: 宠物不存在, data: null } mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } '409': $ref: '#/components/responses/IdempotencyPayloadMismatch' '422': @@ -1284,7 +1428,7 @@ paths: petNotFound: value: { code: 40401, message: 宠物不存在, data: null } mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } '409': $ref: '#/components/responses/VersionConflict' '422': @@ -1348,6 +1492,35 @@ paths: '401': $ref: '#/components/responses/AccessTokenInvalid' + /api/v1/me/community-stats: + get: + tags: [posts] + summary: 我的社区数字(获赞总数 / 作品数) + description: | + 主体恒为 token 里的调用者:无查询参数、无路径参数,**路径上没有 userId**—— + 「查不到别人的获赞」不靠权限判断,而是入口本身不存在。 + + - **统计集合 = 本人的、`status='published'` 的、`deleted_at IS NULL` 的帖**。 + 草稿不计(尚非作品,且未发布不可被赞);软删不计(删帖即撤回其数字,与 + `/me/posts`、Feed 的可见性一致);运营态 hidden/archived 不计(对所有人不可见, + 含作者本人);他人帖自然不计。 + - **自己赞自己计入**——与帖子详情页的 `likeCount` 保持同一口径,两处数字必须能对上。 + - `receivedLikeCount` = 该集合的 `like_count` 之和(读侧实时聚合,读的是写侧同事务 + 维护的帖级冗余列,故为精确值而非估算;ADR-022 不引入按人累计的冗余列)。 + - **空数据返回 `0` 而非 null**,且**永不 404**:任何已认证用户都有 stats。 + operationId: getMyCommunityStats + security: + - bearerAuth: [] + responses: + '200': + description: 我的获赞总数与作品数 + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityStatsEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + /api/v1/feed: get: tags: [feed] @@ -1812,8 +1985,9 @@ components: value: { code: 40402, message: 记录不存在, data: null } PetWriteDenied: description: | - 对可见宠物无相应操作权限(code 40300):viewer 写记录、caregiver/viewer 改 - 宠物档案。仅发给对宠物「可见」的调用者,不泄露新信息。 + 对可见宠物无相应操作权限(code 40300):viewer 写记录或改头像、caregiver/viewer + 改宠物档案的资料字段(caregiver 仅改 `avatarAssetId` 时允许,M3.5 按字段分档)。 + 仅发给对宠物「可见」的调用者,不泄露新信息。 content: application/json: schema: @@ -1854,14 +2028,16 @@ components: commentNotFound: value: { code: 40404, message: 评论不存在, data: null } MediaNotFound: - description: asset 不存在、非本人所有或已删(code 40405,防枚举合并) + description: | + asset 不存在、非本人所有、已删,或**用途与引用场景不符**(code 40405,防枚举合并)。 + 用途不符即「从头像域看,一张帖子配图不是头像」;两种头像用途亦互不通用。 content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } UserNotFound: description: 目标用户不存在或已注销(code 40406,合并不泄露成因) content: @@ -2001,8 +2177,10 @@ components: Me: type: object - description: 当前用户资料(冻结契约,恰好这 4 个字段) - required: [userId, username, createdAt] + description: | + 本人资料(`GET` 与 `PATCH /api/v1/me` 的统一响应形态,冻结契约恰好这 6 个字段)。 + **不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 `avatarUrl != null`。 + required: [userId, username, nickname, avatarUrl, createdAt] properties: userId: type: string @@ -2011,16 +2189,65 @@ components: username: type: string example: demo_user + nickname: + type: string + nullable: true + minLength: 1 + maxLength: 32 + description: | + 昵称,**DB 原值**;未设置为 null(键恒在)。长度按**码点**计 1~32(btrim 后)。 + **本端点不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定固化成 + 真实数据;本人视角的展示回退由客户端做 `nickname ?? username`,他人视角的 + 回退在 `/internal/users/profiles`(SQL COALESCE,不属本公开契约)。 + 不设唯一约束(ADR-022,允许重名)。 + example: 小柴 phone: type: string nullable: true description: E.164;未绑定时为 null example: '+8613800138000' + avatarUrl: + type: string + nullable: true + description: | + 头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + **客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。 + 无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。 + example: https://minio.example.com/patbond-media/user_avatar/2026/09/019212aa…?X-Amz-Signature=… createdAt: type: string format: date-time example: '2026-09-04T04:05:06.789Z' + UpdateMeRequest: + type: object + description: | + 本人资料部分更新(**三态语义**,与 pets 域 M2 的两态刻意不同): + **键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。 + 两字段都未出现(含只带未声明字段)为**空 patch**,答 400/40000 而非静默 200。 + 无必填字段、无 `version` 乐观锁、无 `Idempotency-Key`(同 body 重放天然幂等)。 + properties: + nickname: + type: string + nullable: true + minLength: 1 + maxLength: 32 + description: | + 昵称;btrim 后长度按**码点**计须在 1~32,否则 400/40000。 + 显式 `null` = **清空昵称**;纯空白或空串是 400/40000,**不是隐式清空** + (清空只留显式 null 一条路,否则「误提交空格」与「想删昵称」无法区分)。 + example: 小柴 + avatarAssetId: + type: string + format: uuid + nullable: true + description: | + 头像 asset ID(两步上传的产物,`purpose` 须为 `user_avatar`)。 + 显式 `null` = **清除头像**。校验:不存在/非本人/已删/用途不符 404/40405; + 本人且用途相符但 uploading/failed 422/42203;非法 UUID 400/40000。 + **响应不回显该字段**(只写不读)。 + example: 019212bb-0000-7000-8000-000000000009 + AuthTokenEnvelope: type: object required: [code, message] @@ -2226,7 +2453,8 @@ components: `breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed); `breedDisplayName` 由品种字典解出,随 breedId 存在。 软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的 - status 永不出现 deleted。`avatarAssetId` 不出现在 M2 契约(ADR-010)。 + status 永不出现 deleted。头像以 `avatarUrl` 的现签形式返回, + **`avatarAssetId` 不外露**(只写不读,写入口为 `PATCH /api/v1/pets/{petId}`)。 required: - id - name @@ -2234,6 +2462,7 @@ components: - sex - birthDateEstimated - status + - avatarUrl - myRole - createdAt - updatedAt @@ -2294,6 +2523,15 @@ components: type: string enum: [active, lost, deceased, archived] description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401) + avatarUrl: + type: string + nullable: true + description: | + 宠物头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + **客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。 + 无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。 + asset 事后退出 ready 时转 null 而引用保留(读请求不做写副作用)。 + example: https://minio.example.com/patbond-media/pet_avatar/2026/09/019212cc…?X-Amz-Signature=… myRole: type: string enum: [owner, caregiver, viewer] @@ -2351,14 +2589,18 @@ components: UpdatePetRequest: type: object description: | - 部分更新:缺席字段不变;不支持清空回 null。例外:品种对 + 部分更新:缺席字段不变;资料字段不支持清空回 null。例外:品种对 (breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。 species 不可改(不在请求体)。 + **`avatarAssetId` 是本 schema 唯一的三态字段**(M3.5):键缺省 = 不改; + 键出现且为 `null` = 清除头像;键出现且有值 = 设置。其余字段保持 M2 两态语义。 + 权限档按本次触及的字段决定(仅头像 → WRITE;触及资料字段 → MANAGE;混合取更严), + 见端点描述。 required: [version] properties: version: type: integer - description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902) + description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902);即便只改头像也必带 name: type: string minLength: 1 @@ -2393,6 +2635,16 @@ components: type: string enum: [active, lost, deceased, archived] description: 状态流转;deleted 不可经 PATCH 设置(400/40000) + avatarAssetId: + type: string + format: uuid + nullable: true + description: | + 宠物头像 asset ID(两步上传的产物,`purpose` 须为 `pet_avatar`)。 + **三态**:缺省 = 不改;显式 `null` = 清除头像;给值 = 设置。校验:不存在/ + 非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203; + 非法 UUID 400/40000。**响应不回显该字段**(只写不读,读取见 `Pet.avatarUrl`)。 + example: 019212cc-0000-7000-8000-00000000000a PetEnvelope: type: object @@ -3205,10 +3457,13 @@ components: description: M3 仅 image(ADR-018 视频后置;video/document 为向后新增枚举预留) purpose: type: string - enum: [post_image] + enum: [post_image, user_avatar, pet_avatar] description: | - 用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩 - user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展) + 用途白名单(服务端配置项 `patbond.media.allowed-purposes`,决定 objectKey 前缀 + `/yyyy/MM/{assetId}`)。M3.5 起为三值:`post_image`(帖子配图)、 + `user_avatar`(用户头像)、`pet_avatar`(宠物头像)——相对 M3 的纯枚举追加。 + **用途即引用侧的类型检查**:引用时校验 `purpose` 相符,故帖图不能当头像、 + 两种头像也互不通用(不符者 404/40405)。白名单外的取值 400/40000。 mimeType: type: string enum: [image/jpeg, image/png, image/webp] @@ -3857,3 +4112,36 @@ components: example: success data: $ref: '#/components/schemas/FollowStats' + + CommunityStats: + type: object + description: | + 调用者本人的社区数字(M3.5)。两数同一集合:本人的、`status='published'` 的、 + 未软删的帖(草稿 / 软删 / hidden / archived 均不计)。读侧实时聚合,无冗余计数列。 + required: [receivedLikeCount, publishedPostCount] + properties: + receivedLikeCount: + type: integer + format: int64 + description: | + 获赞总数 = 该集合的 `like_count` 之和(写侧同事务维护的帖级冗余列,精确值)。 + **自己赞自己计入**,与帖子详情的 `likeCount` 同一口径。空数据为 0,非 null。 + example: 128 + publishedPostCount: + type: integer + format: int64 + description: 作品数 = 该集合的帖子数。空数据为 0,非 null。 + example: 12 + + CommunityStatsEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/CommunityStats' diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractConformanceTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractConformanceTest.java index 45d5c41..6caf6dd 100644 --- a/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractConformanceTest.java +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractConformanceTest.java @@ -14,6 +14,7 @@ import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.List; import java.util.Set; @@ -27,8 +28,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; /** - * T2-09 契约一致性保障:对冻结契约 v1.3.0(快照 - * {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 doc 仓 + * T2-09 契约一致性保障:对冻结契约 v1.4.0(快照 + * {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在 doc 仓 * {@code docs/api/openapi.yaml})的 pets 域 18 个操作逐一真实起服务发请求, * 用 {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、 * 字段名与类型、必填与 nullable、枚举与格式、信封结构、错误码值。 @@ -38,8 +39,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder * 校验过(唯一豁免:照护提醒 PATCH 的 409——并发条件更新守卫落空,单线程 * MockMvc 无法确定性触发)。契约新增操作或状态码时,本测试立即变红。 * - *

auth 域 6 个既有操作(register/login/refresh/logout/me/trackEvents) - * 不在本单范围(M1 交付无契约测试,补齐另立工单)。 + *

auth/user 域 7 个操作(register/login/refresh/logout/me 读写/trackEvents) + * 的契约测试在 patbond-auth(T3-19 补齐);快照同一份。 */ @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class ContractConformanceTest extends PetIntegrationTestSupport { @@ -122,6 +123,29 @@ class ContractConformanceTest extends PetIntegrationTestSupport { return JsonPath.read(body, "$.data.id"); } + /** + * 一枚 media.assets 行,用途/状态/归属可控(T3.5-07:头像引用侧的四态校验 + * 需要,造法与 PetAvatarIntegrationTest 同构——测试数据,不触碰实现)。 + */ + private UUID insertAsset(UUID ownerUserId, String purpose, String status) { + UUID id = UUID.randomUUID(); + jdbcClient.sql(""" + INSERT INTO media.assets + (id, owner_user_id, kind, purpose, storage_type, bucket, object_key, + mime_type, byte_size, status, ready_at) + VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media', + :objectKey, 'image/jpeg', 2048, :status, :readyAt) + """) + .param("id", id) + .param("owner", ownerUserId) + .param("purpose", purpose) + .param("objectKey", purpose + "/2026/09/" + id) + .param("status", status) + .param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null) + .update(); + return id; + } + // ---- 成功路径:18 操作全覆盖 --------------------------------------- @Test @@ -594,6 +618,33 @@ class ContractConformanceTest extends PetIntegrationTestSupport { .content("{\"version\":0,\"personality\":\"皮\"}"), "PATCH", "/api/v1/pets/{petId}", 409, 40902); + // -- 宠物头像(v1.4.0 新增两格,T3.5-07)-- + // 404/40405:与 40401 同一单元格的第二种业务码——幽灵 asset 与用途不符 + // 的 asset 合并同答(防枚举) + verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"version\":1,\"avatarAssetId\":\"%s\"}" + .formatted(UUID.randomUUID())), + "PATCH", "/api/v1/pets/{petId}", 404, 40405); + verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"version\":1,\"avatarAssetId\":\"%s\"}" + .formatted(insertAsset(owner, "post_image", "ready"))), + "PATCH", "/api/v1/pets/{petId}", 404, 40405); + // 422/42203:本人的 pet_avatar asset 仍在 uploading(新增状态码单元格) + verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"version\":1,\"avatarAssetId\":\"%s\"}" + .formatted(insertAsset(owner, "pet_avatar", "uploading"))), + "PATCH", "/api/v1/pets/{petId}", 422, 42203); + // 200:挂上 ready 头像(avatarUrl 的非 null 分支不在本模块——未配置对象 + // 存储时恒 null,真实签名 URL 由 PetAvatarIntegrationTest 覆盖) + verified(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"version\":1,\"avatarAssetId\":\"%s\"}" + .formatted(insertAsset(owner, "pet_avatar", "ready"))), + "PATCH", "/api/v1/pets/{petId}", 200); + // -- 疫苗:40904 重复剂次、42201 状态-日期规则、PATCH 400/409/422 -- verified(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(owner)) .contentType(MediaType.APPLICATION_JSON) @@ -703,10 +754,10 @@ class ContractConformanceTest extends PetIntegrationTestSupport { @Test @Order(98) void frozenSnapshotIsTheExpectedContractVersion() { - assertThat(CONTRACT.version()).isEqualTo("1.3.0"); - assertThat(CONTRACT.paths()).hasSize(31); - assertThat(CONTRACT.operations()).hasSize(43); - assertThat(CONTRACT.schemas()).hasSize(72); + assertThat(CONTRACT.version()).isEqualTo("1.4.0"); + assertThat(CONTRACT.paths()).hasSize(32); + assertThat(CONTRACT.operations()).hasSize(45); + assertThat(CONTRACT.schemas()).hasSize(75); assertThat(CONTRACT.operationsTagged(Set.of("pets", "dictionaries", "health-records"))) .containsExactlyInAnyOrderElementsOf(PETS_OPERATIONS); } diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/OpenApiContract.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/OpenApiContract.java index ca302d4..cd05b0b 100644 --- a/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/OpenApiContract.java +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/OpenApiContract.java @@ -13,8 +13,8 @@ import java.util.Objects; import java.util.Set; /** - * The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot - * {@code /contract/openapi-v1.3.0.yaml}. + * The frozen v1.4.0 OpenAPI contract, loaded from the test-resource snapshot + * {@code /contract/openapi-v1.4.0.yaml}. * *

Sync discipline (T2-09): the canonical contract lives in the doc * repo at {@code docs/api/openapi.yaml}; this snapshot is a byte-identical @@ -32,7 +32,7 @@ import java.util.Set; */ final class OpenApiContract { - static final String RESOURCE = "/contract/openapi-v1.3.0.yaml"; + static final String RESOURCE = "/contract/openapi-v1.4.0.yaml"; private static final Set HTTP_METHODS = Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace"); diff --git a/patbond-pet/src/test/resources/contract/openapi-v1.3.0.yaml b/patbond-pet/src/test/resources/contract/openapi-v1.4.0.yaml similarity index 87% rename from patbond-pet/src/test/resources/contract/openapi-v1.3.0.yaml rename to patbond-pet/src/test/resources/contract/openapi-v1.4.0.yaml index c9d3072..d669f33 100644 --- a/patbond-pet/src/test/resources/contract/openapi-v1.3.0.yaml +++ b/patbond-pet/src/test/resources/contract/openapi-v1.4.0.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约) - version: 1.3.0 + version: 1.4.0 description: | Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差), 1.1.0 追加埋点上报端点 `POST /api/v1/events`(M2 第一波契约补录,以实现实测行为为准)。 @@ -11,6 +11,14 @@ info: **1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、 公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入 (iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。 + **1.4.0 M3.5 契约冻结:用户资料与头像**——`GET /api/v1/me` 补 `nickname`/`avatarUrl`, + 新增 `PATCH /api/v1/me`(昵称与头像读写,三态部分更新),`Pet` 补 `avatarUrl` 且 + `PATCH /api/v1/pets/{petId}` 收 `avatarAssetId`,新增 `GET /api/v1/me/community-stats` + (获赞总数与作品数),媒体 `purpose` 白名单追加 `user_avatar`/`pet_avatar` + (iteration-3.5 报告 03 定型表;冻结报告见 iteration-3.5/04)。 + **1.4.0 相对 1.3.0 纯增量**:无字段删改、无类型变更、无必填收紧,仅新增操作、 + 新增响应字段(键恒在、值可空)、新增可选请求字段、新增响应格与枚举追加, + v1.3.0 客户端无需改动即可继续工作。 ## 通用约定(development-plan 第 6 节) - 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 @@ -29,14 +37,14 @@ info: | 40100 | 401 | 用户名或密码错误 | | 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) | | 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) | - | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录、caregiver 改宠物档案) | + | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录或改头像、caregiver 改宠物档案——头像除外,见 M3.5 分档) | | 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 | | 40400 | 404 | 资源不存在 | | 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) | | 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) | | 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 | | 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) | - | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有或已删(防枚举合并) | + | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有、已删,或**用途与引用场景不符**(帖图当头像、user_avatar 当宠物头像等);防枚举合并,同码各情形响应一致(用途不符分支只对调用者自己的 asset 可达,故 message 可具体) | | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) | | 40900 | 409 | 用户名已存在(大小写不敏感) | | 40901 | 409 | 手机号已被使用 | @@ -46,7 +54,7 @@ info: | 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) | | 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 | | 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 | - | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有但非 ready(uploading/failed)状态的 asset | + | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有、用途相符但非 ready(uploading/failed)状态的 asset(帖图与用户/宠物头像同构) | | 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op) | | 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 | | 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) | @@ -68,7 +76,10 @@ info: - **权限模型(ADR-015:owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档: - `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要; - `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH; - - `MANAGE`——仅 owner:宠物档案 PATCH(含状态流转)。 + **M3.5 起还含宠物头像字段 `avatarAssetId`**(ADR-022:头像属日常照护信息)。 + - `MANAGE`——仅 owner:宠物档案 PATCH 的资料字段(含状态流转)。 + **同一端点按「本次请求触及哪些字段」定档**(不是端点级降档):仅改头像走 WRITE, + 触及任一资料字段走 MANAGE,混合请求取更严的一半。 权限每请求实时查库、无缓存:撤销照护关系立即生效。 - **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况 响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与 @@ -126,6 +137,41 @@ info: 整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口 (如作者公开资料批量接口)不属于本公开契约。 + ## 用户资料与头像域约定(M3.5 冻结,iteration-3.5 报告 03 定型;ADR-022) + - **三态部分更新(仅本域,pets 域 M2 两态语义不回改)**:`PATCH /api/v1/me` 的 + `nickname`/`avatarAssetId` 与 `PATCH /api/v1/pets/{petId}` 的 `avatarAssetId` + 按三态解释——**键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。 + 昵称与头像天生可选,「删掉我设的那个」是一等公民操作,只有两态无法表达。 + 同一请求体内的其余 pets 字段仍是 M2 的「缺省或 null 皆为不改」。 + - **空 PATCH 与纯空白昵称一律 400/40000**,不静默 200、不隐式清空:清空只留 + 显式 `null` 一条路,否则「误提交空格」与「想删昵称」无法区分。 + - **昵称**:btrim 后 1~32 **码点**(非 UTF-16 长度);**不设唯一约束**(ADR-022, + 允许重名,靠 userId 区分);注册不收昵称。`/api/v1/me` 返回 **DB 原值**, + 未设置即 `null`,**不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定 + 固化成真实数据;他人视角的展示回退在 `/internal/users/profiles`(SQL COALESCE), + 本人视角的展示回退由客户端做 `nickname ?? username`。 + - **头像读取一律 `avatarUrl`(时效性预签名 GET,与帖图同一纪律)**:每次响应现签, + **会过期、客户端不得持久化**,过期即重取;无头像、asset 非 ready、对象存储未配置 + 三种情况均为 `null`(降级而非报错——签一个必然 404 的 URL 比给 null 更糟)。 + 指针不隐式清理:asset 事后退出 ready 时 `avatarUrl` 转 null 而引用保留。 + - **`avatarAssetId` 只写不读**:请求体收,响应**一律不外露**(`Me` 与 `Pet` 皆无该字段); + 「是否有头像」等价于 `avatarUrl != null`。 + - **两种头像用途互不通用**:`user_avatar` 不能当宠物头像、`pet_avatar` 不能当用户头像、 + `post_image` 不能当任何头像——引用侧按 `purpose` 校验,不符者 404/40405 + (四态校验:不存在/非本人/已删/用途不符 → 404/40405;本人且用途相符但 + uploading/failed → 422/42203)。 + - **宠物头像的权限档按「本次请求碰了哪些字段」定档**(ADR-022 头像为 WRITE 档): + 仅改 `avatarAssetId` 时 owner + caregiver 皆可(viewer 403/40300);触及任一资料 + 字段时仍是 MANAGE(仅 owner);**混合请求取更严的一半**,堵住把改名夹带进头像 + 请求绕过 MANAGE 的路径。头像与资料共用同一把乐观锁 `version`(仍必填)。 + - **`/api/v1/me` 无乐观锁、无幂等键**:只有一个合法写者(账号本人),暴露 `version` + 只是给客户端加负担;丢失更新由**列级选择性 UPDATE** 排除(SET 列表只含本次请求 + 真正携带的列),并发改不同字段两者皆存活;同 body 重放天然幂等。 + - **`GET /api/v1/me/community-stats` 为独立端点**(ADR-022 决策 A,不并入 + `/users/{userId}/follow-stats`——后者主体是「某用户的关注数」,混入「我的获赞」 + 会让一个载荷有两个主体)。路径上**没有 userId**:「查不到别人的获赞」不靠权限 + 判断,而是入口本身不存在,故**永不 404**,任何已认证用户都有 stats。 + servers: - url: http://127.0.0.1:8081 description: patbond-auth(本地开发,/api/v1/auth/**) @@ -140,7 +186,7 @@ tags: - name: auth description: 注册 / 登录 / 刷新 / 退出(patbond-auth) - name: user - description: 当前用户(patbond-user) + description: 当前用户资料:读取与昵称/头像更新(patbond-user) - name: analytics description: 产品事件批量上报(patbond-user) - name: pets @@ -152,7 +198,9 @@ tags: - name: media description: 媒体上传两步流程(patbond-user,ADR-016 预签名直传) - name: posts - description: 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子(patbond-community) + description: | + 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子;含由帖子派生的「我的社区数字」 + 聚合(patbond-community) - name: feed description: 公共 Feed 游标分页(patbond-community) - name: comments @@ -301,7 +349,15 @@ paths: get: tags: [user] summary: 当前用户资料 - description: 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 + description: | + 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 + + - `nickname` 为 **DB 原值**,未设置即 `null`(**不做 username 回退**,见 info + 「用户资料与头像域约定」);本人视角的展示回退由客户端做 `nickname ?? username`。 + - `avatarUrl` 为**每次响应现签的时效性预签名 GET**:**会过期、客户端不得持久化**, + 过期即重取;无头像、asset 非 ready、对象存储未配置均为 `null`。 + - 响应**不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 + `avatarUrl != null`。 operationId: me security: - bearerAuth: [] @@ -320,6 +376,66 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + patch: + tags: [user] + summary: 更新当前用户资料(昵称 / 头像,三态部分更新) + description: | + 本人资料的唯一写入口(主体恒为 token 里的调用者,无「他人」情形)。 + + - **三态语义**:键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置。 + - **空 patch 400/40000**(两字段都未出现,含只带未声明字段):不静默 200, + 空 PATCH 几乎总是客户端 bug。纯空白/空串昵称同为 400/40000,不隐式清空。 + - `avatarAssetId` 须为**调用者本人、用途为 `user_avatar`、状态 `ready`** 的 asset: + 不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed + 422/42203。 + - **无 `version` 乐观锁、无 `Idempotency-Key`**:只有一个合法写者;丢失更新由 + 列级选择性 UPDATE 排除(并发改不同字段两者皆存活),同 body 重放天然幂等。 + - 成功返回**与 GET 完全相同的 `Me` 全量形态**(回显更新后资料,`avatarUrl` 现签)。 + operationId: updateMe + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMeRequest' + responses: + '200': + description: 更新成功,返回更新后的完整 Me + content: + application/json: + schema: + $ref: '#/components/schemas/MeEnvelope' + '400': + description: | + 参数校验失败(code 40000):空 patch、昵称 btrim 后长度不在 1~32 码点、 + 昵称纯空白或空串、`avatarAssetId` 非法 UUID、body 非法 JSON + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + emptyPatch: + value: { code: 40000, message: 请求未包含任何可更新字段, data: null } + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + description: | + 用户不存在或已注销(code 40400,token 仍有效但账号已注销);或 + `avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `user_avatar` + (code 40405,防枚举合并) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + userNotFound: + value: { code: 40400, message: 用户不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体资源不存在, data: null } + '422': + $ref: '#/components/responses/MediaNotReady' /api/v1/events: post: @@ -468,18 +584,32 @@ paths: $ref: '#/components/responses/PetNotFound' patch: tags: [pets] - summary: 更新宠物档案 + summary: 更新宠物档案(含头像) description: | - 权限档:MANAGE(**仅 owner**);caregiver/viewer 更新得 403/40300。 + 权限档**按本次请求触及的字段定档**(M3.5 起,ADR-022): - - 部分更新:缺席字段不变;**不支持将可选字段清空回 null**。 + | 请求体触及 | 所需档位 | caregiver | viewer | + | --- | --- | --- | --- | + | 仅 `avatarAssetId`(+ `version`) | WRITE | ✅ | ✗ 403/40300 | + | 任一资料字段(name/sex/status/…) | MANAGE(仅 owner) | ✗ 403/40300 | ✗ 403/40300 | + | 资料字段 + `avatarAssetId` 混合 | MANAGE(**取更严的一半**) | ✗ 403/40300 | ✗ 403/40300 | + + 混合请求取更严,是为了堵住「夹带」——否则 caregiver 可把改名塞进头像请求绕过 MANAGE。 + + - 部分更新:缺席字段不变;资料字段**不支持清空回 null**(M2 语义不回改)。 + - **例外:`avatarAssetId` 是本端点唯一的三态字段**(M3.5)——键缺省 = 不改; + 键出现且为 `null` = **清除头像**;键出现且有值 = 设置。差异刻意限定在有清空 + 需求的字段上。 - 例外:品种对(`breedId`/`customBreedName`)**整体替换**——提交任一侧即替换 整对,互斥校验同创建。 - `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。 - `status` 可迁移至 active/lost/deceased/archived;**`deleted` 不可经 PATCH 设置**(400/40000,软删除留待专用端点,M2 契约不含)。 - - `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 + - `version` **必填**(缺失 400/40000),即便只改头像;比对通过才写入并 +1; + 过期 409/40902。头像与资料共用同一把乐观锁——头像变更也应让并发编辑者感知行已变。 - 芯片号改为已被登记的值:409/40903。 + - `avatarAssetId` 须为**调用者本人、用途为 `pet_avatar`、状态 `ready`** 的 asset: + 不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203。 operationId: updatePet security: - bearerAuth: [] @@ -505,7 +635,19 @@ paths: '403': $ref: '#/components/responses/PetWriteDenied' '404': - $ref: '#/components/responses/PetNotFound' + description: | + 宠物不存在、已软删除或调用者与宠物无关系(code 40401,防枚举合并);或 + `avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `pet_avatar` + (code 40405,防枚举合并) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + petNotFound: + value: { code: 40401, message: 宠物不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体资源不存在, data: null } '409': description: 版本冲突(code 40902)或芯片号已被登记(code 40903) content: @@ -517,6 +659,8 @@ paths: value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } microchipExists: value: { code: 40903, message: 芯片号已被登记, data: null } + '422': + $ref: '#/components/responses/MediaNotReady' /api/v1/breeds: get: @@ -1201,7 +1345,7 @@ paths: petNotFound: value: { code: 40401, message: 宠物不存在, data: null } mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } '409': $ref: '#/components/responses/IdempotencyPayloadMismatch' '422': @@ -1284,7 +1428,7 @@ paths: petNotFound: value: { code: 40401, message: 宠物不存在, data: null } mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } '409': $ref: '#/components/responses/VersionConflict' '422': @@ -1348,6 +1492,35 @@ paths: '401': $ref: '#/components/responses/AccessTokenInvalid' + /api/v1/me/community-stats: + get: + tags: [posts] + summary: 我的社区数字(获赞总数 / 作品数) + description: | + 主体恒为 token 里的调用者:无查询参数、无路径参数,**路径上没有 userId**—— + 「查不到别人的获赞」不靠权限判断,而是入口本身不存在。 + + - **统计集合 = 本人的、`status='published'` 的、`deleted_at IS NULL` 的帖**。 + 草稿不计(尚非作品,且未发布不可被赞);软删不计(删帖即撤回其数字,与 + `/me/posts`、Feed 的可见性一致);运营态 hidden/archived 不计(对所有人不可见, + 含作者本人);他人帖自然不计。 + - **自己赞自己计入**——与帖子详情页的 `likeCount` 保持同一口径,两处数字必须能对上。 + - `receivedLikeCount` = 该集合的 `like_count` 之和(读侧实时聚合,读的是写侧同事务 + 维护的帖级冗余列,故为精确值而非估算;ADR-022 不引入按人累计的冗余列)。 + - **空数据返回 `0` 而非 null**,且**永不 404**:任何已认证用户都有 stats。 + operationId: getMyCommunityStats + security: + - bearerAuth: [] + responses: + '200': + description: 我的获赞总数与作品数 + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityStatsEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + /api/v1/feed: get: tags: [feed] @@ -1812,8 +1985,9 @@ components: value: { code: 40402, message: 记录不存在, data: null } PetWriteDenied: description: | - 对可见宠物无相应操作权限(code 40300):viewer 写记录、caregiver/viewer 改 - 宠物档案。仅发给对宠物「可见」的调用者,不泄露新信息。 + 对可见宠物无相应操作权限(code 40300):viewer 写记录或改头像、caregiver/viewer + 改宠物档案的资料字段(caregiver 仅改 `avatarAssetId` 时允许,M3.5 按字段分档)。 + 仅发给对宠物「可见」的调用者,不泄露新信息。 content: application/json: schema: @@ -1854,14 +2028,16 @@ components: commentNotFound: value: { code: 40404, message: 评论不存在, data: null } MediaNotFound: - description: asset 不存在、非本人所有或已删(code 40405,防枚举合并) + description: | + asset 不存在、非本人所有、已删,或**用途与引用场景不符**(code 40405,防枚举合并)。 + 用途不符即「从头像域看,一张帖子配图不是头像」;两种头像用途亦互不通用。 content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } UserNotFound: description: 目标用户不存在或已注销(code 40406,合并不泄露成因) content: @@ -2001,8 +2177,10 @@ components: Me: type: object - description: 当前用户资料(冻结契约,恰好这 4 个字段) - required: [userId, username, createdAt] + description: | + 本人资料(`GET` 与 `PATCH /api/v1/me` 的统一响应形态,冻结契约恰好这 6 个字段)。 + **不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 `avatarUrl != null`。 + required: [userId, username, nickname, avatarUrl, createdAt] properties: userId: type: string @@ -2011,16 +2189,65 @@ components: username: type: string example: demo_user + nickname: + type: string + nullable: true + minLength: 1 + maxLength: 32 + description: | + 昵称,**DB 原值**;未设置为 null(键恒在)。长度按**码点**计 1~32(btrim 后)。 + **本端点不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定固化成 + 真实数据;本人视角的展示回退由客户端做 `nickname ?? username`,他人视角的 + 回退在 `/internal/users/profiles`(SQL COALESCE,不属本公开契约)。 + 不设唯一约束(ADR-022,允许重名)。 + example: 小柴 phone: type: string nullable: true description: E.164;未绑定时为 null example: '+8613800138000' + avatarUrl: + type: string + nullable: true + description: | + 头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + **客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。 + 无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。 + example: https://minio.example.com/patbond-media/user_avatar/2026/09/019212aa…?X-Amz-Signature=… createdAt: type: string format: date-time example: '2026-09-04T04:05:06.789Z' + UpdateMeRequest: + type: object + description: | + 本人资料部分更新(**三态语义**,与 pets 域 M2 的两态刻意不同): + **键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。 + 两字段都未出现(含只带未声明字段)为**空 patch**,答 400/40000 而非静默 200。 + 无必填字段、无 `version` 乐观锁、无 `Idempotency-Key`(同 body 重放天然幂等)。 + properties: + nickname: + type: string + nullable: true + minLength: 1 + maxLength: 32 + description: | + 昵称;btrim 后长度按**码点**计须在 1~32,否则 400/40000。 + 显式 `null` = **清空昵称**;纯空白或空串是 400/40000,**不是隐式清空** + (清空只留显式 null 一条路,否则「误提交空格」与「想删昵称」无法区分)。 + example: 小柴 + avatarAssetId: + type: string + format: uuid + nullable: true + description: | + 头像 asset ID(两步上传的产物,`purpose` 须为 `user_avatar`)。 + 显式 `null` = **清除头像**。校验:不存在/非本人/已删/用途不符 404/40405; + 本人且用途相符但 uploading/failed 422/42203;非法 UUID 400/40000。 + **响应不回显该字段**(只写不读)。 + example: 019212bb-0000-7000-8000-000000000009 + AuthTokenEnvelope: type: object required: [code, message] @@ -2226,7 +2453,8 @@ components: `breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed); `breedDisplayName` 由品种字典解出,随 breedId 存在。 软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的 - status 永不出现 deleted。`avatarAssetId` 不出现在 M2 契约(ADR-010)。 + status 永不出现 deleted。头像以 `avatarUrl` 的现签形式返回, + **`avatarAssetId` 不外露**(只写不读,写入口为 `PATCH /api/v1/pets/{petId}`)。 required: - id - name @@ -2234,6 +2462,7 @@ components: - sex - birthDateEstimated - status + - avatarUrl - myRole - createdAt - updatedAt @@ -2294,6 +2523,15 @@ components: type: string enum: [active, lost, deceased, archived] description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401) + avatarUrl: + type: string + nullable: true + description: | + 宠物头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + **客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。 + 无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。 + asset 事后退出 ready 时转 null 而引用保留(读请求不做写副作用)。 + example: https://minio.example.com/patbond-media/pet_avatar/2026/09/019212cc…?X-Amz-Signature=… myRole: type: string enum: [owner, caregiver, viewer] @@ -2351,14 +2589,18 @@ components: UpdatePetRequest: type: object description: | - 部分更新:缺席字段不变;不支持清空回 null。例外:品种对 + 部分更新:缺席字段不变;资料字段不支持清空回 null。例外:品种对 (breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。 species 不可改(不在请求体)。 + **`avatarAssetId` 是本 schema 唯一的三态字段**(M3.5):键缺省 = 不改; + 键出现且为 `null` = 清除头像;键出现且有值 = 设置。其余字段保持 M2 两态语义。 + 权限档按本次触及的字段决定(仅头像 → WRITE;触及资料字段 → MANAGE;混合取更严), + 见端点描述。 required: [version] properties: version: type: integer - description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902) + description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902);即便只改头像也必带 name: type: string minLength: 1 @@ -2393,6 +2635,16 @@ components: type: string enum: [active, lost, deceased, archived] description: 状态流转;deleted 不可经 PATCH 设置(400/40000) + avatarAssetId: + type: string + format: uuid + nullable: true + description: | + 宠物头像 asset ID(两步上传的产物,`purpose` 须为 `pet_avatar`)。 + **三态**:缺省 = 不改;显式 `null` = 清除头像;给值 = 设置。校验:不存在/ + 非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203; + 非法 UUID 400/40000。**响应不回显该字段**(只写不读,读取见 `Pet.avatarUrl`)。 + example: 019212cc-0000-7000-8000-00000000000a PetEnvelope: type: object @@ -3205,10 +3457,13 @@ components: description: M3 仅 image(ADR-018 视频后置;video/document 为向后新增枚举预留) purpose: type: string - enum: [post_image] + enum: [post_image, user_avatar, pet_avatar] description: | - 用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩 - user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展) + 用途白名单(服务端配置项 `patbond.media.allowed-purposes`,决定 objectKey 前缀 + `/yyyy/MM/{assetId}`)。M3.5 起为三值:`post_image`(帖子配图)、 + `user_avatar`(用户头像)、`pet_avatar`(宠物头像)——相对 M3 的纯枚举追加。 + **用途即引用侧的类型检查**:引用时校验 `purpose` 相符,故帖图不能当头像、 + 两种头像也互不通用(不符者 404/40405)。白名单外的取值 400/40000。 mimeType: type: string enum: [image/jpeg, image/png, image/webp] @@ -3857,3 +4112,36 @@ components: example: success data: $ref: '#/components/schemas/FollowStats' + + CommunityStats: + type: object + description: | + 调用者本人的社区数字(M3.5)。两数同一集合:本人的、`status='published'` 的、 + 未软删的帖(草稿 / 软删 / hidden / archived 均不计)。读侧实时聚合,无冗余计数列。 + required: [receivedLikeCount, publishedPostCount] + properties: + receivedLikeCount: + type: integer + format: int64 + description: | + 获赞总数 = 该集合的 `like_count` 之和(写侧同事务维护的帖级冗余列,精确值)。 + **自己赞自己计入**,与帖子详情的 `likeCount` 同一口径。空数据为 0,非 null。 + example: 128 + publishedPostCount: + type: integer + format: int64 + description: 作品数 = 该集合的帖子数。空数据为 0,非 null。 + example: 12 + + CommunityStatsEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/CommunityStats' diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/contract/MediaContractConformanceTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/contract/MediaContractConformanceTest.java index 04f88d2..a74f2ce 100644 --- a/patbond-user/src/test/java/com/patbond/patbond/user/contract/MediaContractConformanceTest.java +++ b/patbond-user/src/test/java/com/patbond/patbond/user/contract/MediaContractConformanceTest.java @@ -40,13 +40,13 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder /** * T3-20(M3 第二波收尾):media 域 2 个操作(两步上传,属 user 模块)补进契约 * 一致性保障,机制与 patbond-pet 的 ContractConformanceTest 同构——对冻结契约 - * v1.3.0(快照 {@code src/test/resources/contract/openapi-v1.3.0.yaml},正典在 + * v1.4.0(快照 {@code src/test/resources/contract/openapi-v1.4.0.yaml},正典在 * doc 仓 {@code docs/api/openapi.yaml})逐操作真实起服务发请求(真实 MinIO * Testcontainer,直传走真实 HTTP PUT),用 {@link ContractValidator} 严格校验 * 响应结构,最后以全响应矩阵门禁兜底(8 个单元格,无豁免)。 * - *

auth 域 6 操作在 patbond-auth、pets 域 18 操作在 patbond-pet、community 域 - * 17 操作在 patbond-community 的同构测试内(快照同一份)。 + *

auth/user 域 7 操作在 patbond-auth、pets 域 18 操作在 patbond-pet、community 域 + * 18 操作在 patbond-community 的同构测试内(快照同一份)。 */ @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @SpringBootTest @@ -245,10 +245,10 @@ class MediaContractConformanceTest { @Test @Order(98) void frozenSnapshotIsTheExpectedContractVersion() { - assertThat(CONTRACT.version()).isEqualTo("1.3.0"); - assertThat(CONTRACT.paths()).hasSize(31); - assertThat(CONTRACT.operations()).hasSize(43); - assertThat(CONTRACT.schemas()).hasSize(72); + assertThat(CONTRACT.version()).isEqualTo("1.4.0"); + assertThat(CONTRACT.paths()).hasSize(32); + assertThat(CONTRACT.operations()).hasSize(45); + assertThat(CONTRACT.schemas()).hasSize(75); assertThat(CONTRACT.operationsTagged(Set.of("media"))) .containsExactlyInAnyOrderElementsOf(MEDIA_OPERATIONS); } diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/contract/OpenApiContract.java b/patbond-user/src/test/java/com/patbond/patbond/user/contract/OpenApiContract.java index 0ea219a..79fe84e 100644 --- a/patbond-user/src/test/java/com/patbond/patbond/user/contract/OpenApiContract.java +++ b/patbond-user/src/test/java/com/patbond/patbond/user/contract/OpenApiContract.java @@ -13,8 +13,8 @@ import java.util.Objects; import java.util.Set; /** - * The frozen v1.3.0 OpenAPI contract, loaded from the test-resource snapshot - * {@code /contract/openapi-v1.3.0.yaml}. + * The frozen v1.4.0 OpenAPI contract, loaded from the test-resource snapshot + * {@code /contract/openapi-v1.4.0.yaml}. * *

Sync discipline (T2-09, extended by T3-19): the canonical * contract lives in the doc repo at {@code docs/api/openapi.yaml}; this @@ -36,7 +36,7 @@ import java.util.Set; */ final class OpenApiContract { - static final String RESOURCE = "/contract/openapi-v1.3.0.yaml"; + static final String RESOURCE = "/contract/openapi-v1.4.0.yaml"; private static final Set HTTP_METHODS = Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace"); diff --git a/patbond-user/src/test/resources/contract/openapi-v1.3.0.yaml b/patbond-user/src/test/resources/contract/openapi-v1.4.0.yaml similarity index 87% rename from patbond-user/src/test/resources/contract/openapi-v1.3.0.yaml rename to patbond-user/src/test/resources/contract/openapi-v1.4.0.yaml index c9d3072..d669f33 100644 --- a/patbond-user/src/test/resources/contract/openapi-v1.3.0.yaml +++ b/patbond-user/src/test/resources/contract/openapi-v1.4.0.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Patbond API — Auth / Me / Events / Pets / Community / Media(公开契约) - version: 1.3.0 + version: 1.4.0 description: | Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差), 1.1.0 追加埋点上报端点 `POST /api/v1/events`(M2 第一波契约补录,以实现实测行为为准)。 @@ -11,6 +11,14 @@ info: **1.3.0 M3 契约冻结:community/media 域 13 路径**(媒体两步上传、帖子生命周期、 公共 Feed、单层评论、点赞/收藏/关注最小接口)按第二波已定型实现合入 (iteration-3 报告 13/15/16/17 定型表;冻结报告见 iteration-3/18)。 + **1.4.0 M3.5 契约冻结:用户资料与头像**——`GET /api/v1/me` 补 `nickname`/`avatarUrl`, + 新增 `PATCH /api/v1/me`(昵称与头像读写,三态部分更新),`Pet` 补 `avatarUrl` 且 + `PATCH /api/v1/pets/{petId}` 收 `avatarAssetId`,新增 `GET /api/v1/me/community-stats` + (获赞总数与作品数),媒体 `purpose` 白名单追加 `user_avatar`/`pet_avatar` + (iteration-3.5 报告 03 定型表;冻结报告见 iteration-3.5/04)。 + **1.4.0 相对 1.3.0 纯增量**:无字段删改、无类型变更、无必填收紧,仅新增操作、 + 新增响应字段(键恒在、值可空)、新增可选请求字段、新增响应格与枚举追加, + v1.3.0 客户端无需改动即可继续工作。 ## 通用约定(development-plan 第 6 节) - 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 @@ -29,14 +37,14 @@ info: | 40100 | 401 | 用户名或密码错误 | | 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) | | 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) | - | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录、caregiver 改宠物档案) | + | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录或改头像、caregiver 改宠物档案——头像除外,见 M3.5 分档) | | 40301 | 403 | POST_ACCESS_DENIED:对可见帖子/评论无相应操作权限(改删他人已发布帖、删他人可见评论——含帖主);仅发给对资源「可见」的调用者 | | 40400 | 404 | 资源不存在 | | 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) | | 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) | | 40403 | 404 | POST_NOT_FOUND:帖子不存在、已软删、hidden/archived(作者同样)或他人 draft(防枚举,全部情况响应完全一致);评论与互动路径上含作者本人草稿 | | 40404 | 404 | COMMENT_NOT_FOUND:评论不存在、已删或所属帖子不可见(防枚举合并) | - | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有或已删(防枚举合并) | + | 40405 | 404 | MEDIA_NOT_FOUND:asset 不存在、非本人所有、已删,或**用途与引用场景不符**(帖图当头像、user_avatar 当宠物头像等);防枚举合并,同码各情形响应一致(用途不符分支只对调用者自己的 asset 可达,故 message 可具体) | | 40406 | 404 | USER_NOT_FOUND:目标用户不存在或已注销(关注端点与评论 replyToUserId;不复用 40400——该码已承担「路由级资源不存在」兜底语义,复用会使二者不可区分) | | 40900 | 409 | 用户名已存在(大小写不敏感) | | 40901 | 409 | 手机号已被使用 | @@ -46,7 +54,7 @@ info: | 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH:同 Idempotency-Key 不同 payload(规范化 request_hash 不符,community 域创建型写入) | | 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 | | 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 | - | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有但非 ready(uploading/failed)状态的 asset | + | 42203 | 422 | MEDIA_NOT_READY:引用了本人所有、用途相符但非 ready(uploading/failed)状态的 asset(帖图与用户/宠物头像同构) | | 42204 | 422 | FOLLOW_RULE_VIOLATION:自关注(仅 PUT;自取关为 200 幂等 no-op) | | 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID:complete 时 asset 非 uploading——对象未上传保持可重试、大小/类型不符置 failed 终态、failed 态再确认;已 ready 幂等 200 除外 | | 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) | @@ -68,7 +76,10 @@ info: - **权限模型(ADR-015:owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档: - `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要; - `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH; - - `MANAGE`——仅 owner:宠物档案 PATCH(含状态流转)。 + **M3.5 起还含宠物头像字段 `avatarAssetId`**(ADR-022:头像属日常照护信息)。 + - `MANAGE`——仅 owner:宠物档案 PATCH 的资料字段(含状态流转)。 + **同一端点按「本次请求触及哪些字段」定档**(不是端点级降档):仅改头像走 WRITE, + 触及任一资料字段走 MANAGE,混合请求取更严的一半。 权限每请求实时查库、无缓存:撤销照护关系立即生效。 - **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况 响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与 @@ -126,6 +137,41 @@ info: 整体不出现,后续按新增可选字段/端点纯增量补入。`/internal/**` 服务间接口 (如作者公开资料批量接口)不属于本公开契约。 + ## 用户资料与头像域约定(M3.5 冻结,iteration-3.5 报告 03 定型;ADR-022) + - **三态部分更新(仅本域,pets 域 M2 两态语义不回改)**:`PATCH /api/v1/me` 的 + `nickname`/`avatarAssetId` 与 `PATCH /api/v1/pets/{petId}` 的 `avatarAssetId` + 按三态解释——**键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。 + 昵称与头像天生可选,「删掉我设的那个」是一等公民操作,只有两态无法表达。 + 同一请求体内的其余 pets 字段仍是 M2 的「缺省或 null 皆为不改」。 + - **空 PATCH 与纯空白昵称一律 400/40000**,不静默 200、不隐式清空:清空只留 + 显式 `null` 一条路,否则「误提交空格」与「想删昵称」无法区分。 + - **昵称**:btrim 后 1~32 **码点**(非 UTF-16 长度);**不设唯一约束**(ADR-022, + 允许重名,靠 userId 区分);注册不收昵称。`/api/v1/me` 返回 **DB 原值**, + 未设置即 `null`,**不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定 + 固化成真实数据;他人视角的展示回退在 `/internal/users/profiles`(SQL COALESCE), + 本人视角的展示回退由客户端做 `nickname ?? username`。 + - **头像读取一律 `avatarUrl`(时效性预签名 GET,与帖图同一纪律)**:每次响应现签, + **会过期、客户端不得持久化**,过期即重取;无头像、asset 非 ready、对象存储未配置 + 三种情况均为 `null`(降级而非报错——签一个必然 404 的 URL 比给 null 更糟)。 + 指针不隐式清理:asset 事后退出 ready 时 `avatarUrl` 转 null 而引用保留。 + - **`avatarAssetId` 只写不读**:请求体收,响应**一律不外露**(`Me` 与 `Pet` 皆无该字段); + 「是否有头像」等价于 `avatarUrl != null`。 + - **两种头像用途互不通用**:`user_avatar` 不能当宠物头像、`pet_avatar` 不能当用户头像、 + `post_image` 不能当任何头像——引用侧按 `purpose` 校验,不符者 404/40405 + (四态校验:不存在/非本人/已删/用途不符 → 404/40405;本人且用途相符但 + uploading/failed → 422/42203)。 + - **宠物头像的权限档按「本次请求碰了哪些字段」定档**(ADR-022 头像为 WRITE 档): + 仅改 `avatarAssetId` 时 owner + caregiver 皆可(viewer 403/40300);触及任一资料 + 字段时仍是 MANAGE(仅 owner);**混合请求取更严的一半**,堵住把改名夹带进头像 + 请求绕过 MANAGE 的路径。头像与资料共用同一把乐观锁 `version`(仍必填)。 + - **`/api/v1/me` 无乐观锁、无幂等键**:只有一个合法写者(账号本人),暴露 `version` + 只是给客户端加负担;丢失更新由**列级选择性 UPDATE** 排除(SET 列表只含本次请求 + 真正携带的列),并发改不同字段两者皆存活;同 body 重放天然幂等。 + - **`GET /api/v1/me/community-stats` 为独立端点**(ADR-022 决策 A,不并入 + `/users/{userId}/follow-stats`——后者主体是「某用户的关注数」,混入「我的获赞」 + 会让一个载荷有两个主体)。路径上**没有 userId**:「查不到别人的获赞」不靠权限 + 判断,而是入口本身不存在,故**永不 404**,任何已认证用户都有 stats。 + servers: - url: http://127.0.0.1:8081 description: patbond-auth(本地开发,/api/v1/auth/**) @@ -140,7 +186,7 @@ tags: - name: auth description: 注册 / 登录 / 刷新 / 退出(patbond-auth) - name: user - description: 当前用户(patbond-user) + description: 当前用户资料:读取与昵称/头像更新(patbond-user) - name: analytics description: 产品事件批量上报(patbond-user) - name: pets @@ -152,7 +198,9 @@ tags: - name: media description: 媒体上传两步流程(patbond-user,ADR-016 预签名直传) - name: posts - description: 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子(patbond-community) + description: | + 帖子生命周期:草稿/编辑/发布/删除/详情/我的帖子;含由帖子派生的「我的社区数字」 + 聚合(patbond-community) - name: feed description: 公共 Feed 游标分页(patbond-community) - name: comments @@ -301,7 +349,15 @@ paths: get: tags: [user] summary: 当前用户资料 - description: 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 + description: | + 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。 + + - `nickname` 为 **DB 原值**,未设置即 `null`(**不做 username 回退**,见 info + 「用户资料与头像域约定」);本人视角的展示回退由客户端做 `nickname ?? username`。 + - `avatarUrl` 为**每次响应现签的时效性预签名 GET**:**会过期、客户端不得持久化**, + 过期即重取;无头像、asset 非 ready、对象存储未配置均为 `null`。 + - 响应**不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 + `avatarUrl != null`。 operationId: me security: - bearerAuth: [] @@ -320,6 +376,66 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' + patch: + tags: [user] + summary: 更新当前用户资料(昵称 / 头像,三态部分更新) + description: | + 本人资料的唯一写入口(主体恒为 token 里的调用者,无「他人」情形)。 + + - **三态语义**:键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置。 + - **空 patch 400/40000**(两字段都未出现,含只带未声明字段):不静默 200, + 空 PATCH 几乎总是客户端 bug。纯空白/空串昵称同为 400/40000,不隐式清空。 + - `avatarAssetId` 须为**调用者本人、用途为 `user_avatar`、状态 `ready`** 的 asset: + 不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed + 422/42203。 + - **无 `version` 乐观锁、无 `Idempotency-Key`**:只有一个合法写者;丢失更新由 + 列级选择性 UPDATE 排除(并发改不同字段两者皆存活),同 body 重放天然幂等。 + - 成功返回**与 GET 完全相同的 `Me` 全量形态**(回显更新后资料,`avatarUrl` 现签)。 + operationId: updateMe + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMeRequest' + responses: + '200': + description: 更新成功,返回更新后的完整 Me + content: + application/json: + schema: + $ref: '#/components/schemas/MeEnvelope' + '400': + description: | + 参数校验失败(code 40000):空 patch、昵称 btrim 后长度不在 1~32 码点、 + 昵称纯空白或空串、`avatarAssetId` 非法 UUID、body 非法 JSON + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + emptyPatch: + value: { code: 40000, message: 请求未包含任何可更新字段, data: null } + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + description: | + 用户不存在或已注销(code 40400,token 仍有效但账号已注销);或 + `avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `user_avatar` + (code 40405,防枚举合并) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + userNotFound: + value: { code: 40400, message: 用户不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体资源不存在, data: null } + '422': + $ref: '#/components/responses/MediaNotReady' /api/v1/events: post: @@ -468,18 +584,32 @@ paths: $ref: '#/components/responses/PetNotFound' patch: tags: [pets] - summary: 更新宠物档案 + summary: 更新宠物档案(含头像) description: | - 权限档:MANAGE(**仅 owner**);caregiver/viewer 更新得 403/40300。 + 权限档**按本次请求触及的字段定档**(M3.5 起,ADR-022): - - 部分更新:缺席字段不变;**不支持将可选字段清空回 null**。 + | 请求体触及 | 所需档位 | caregiver | viewer | + | --- | --- | --- | --- | + | 仅 `avatarAssetId`(+ `version`) | WRITE | ✅ | ✗ 403/40300 | + | 任一资料字段(name/sex/status/…) | MANAGE(仅 owner) | ✗ 403/40300 | ✗ 403/40300 | + | 资料字段 + `avatarAssetId` 混合 | MANAGE(**取更严的一半**) | ✗ 403/40300 | ✗ 403/40300 | + + 混合请求取更严,是为了堵住「夹带」——否则 caregiver 可把改名塞进头像请求绕过 MANAGE。 + + - 部分更新:缺席字段不变;资料字段**不支持清空回 null**(M2 语义不回改)。 + - **例外:`avatarAssetId` 是本端点唯一的三态字段**(M3.5)——键缺省 = 不改; + 键出现且为 `null` = **清除头像**;键出现且有值 = 设置。差异刻意限定在有清空 + 需求的字段上。 - 例外:品种对(`breedId`/`customBreedName`)**整体替换**——提交任一侧即替换 整对,互斥校验同创建。 - `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。 - `status` 可迁移至 active/lost/deceased/archived;**`deleted` 不可经 PATCH 设置**(400/40000,软删除留待专用端点,M2 契约不含)。 - - `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。 + - `version` **必填**(缺失 400/40000),即便只改头像;比对通过才写入并 +1; + 过期 409/40902。头像与资料共用同一把乐观锁——头像变更也应让并发编辑者感知行已变。 - 芯片号改为已被登记的值:409/40903。 + - `avatarAssetId` 须为**调用者本人、用途为 `pet_avatar`、状态 `ready`** 的 asset: + 不存在/非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203。 operationId: updatePet security: - bearerAuth: [] @@ -505,7 +635,19 @@ paths: '403': $ref: '#/components/responses/PetWriteDenied' '404': - $ref: '#/components/responses/PetNotFound' + description: | + 宠物不存在、已软删除或调用者与宠物无关系(code 40401,防枚举合并);或 + `avatarAssetId` 引用的 asset 不存在/非本人/已删/用途不是 `pet_avatar` + (code 40405,防枚举合并) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + petNotFound: + value: { code: 40401, message: 宠物不存在, data: null } + mediaNotFound: + value: { code: 40405, message: 媒体资源不存在, data: null } '409': description: 版本冲突(code 40902)或芯片号已被登记(code 40903) content: @@ -517,6 +659,8 @@ paths: value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null } microchipExists: value: { code: 40903, message: 芯片号已被登记, data: null } + '422': + $ref: '#/components/responses/MediaNotReady' /api/v1/breeds: get: @@ -1201,7 +1345,7 @@ paths: petNotFound: value: { code: 40401, message: 宠物不存在, data: null } mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } '409': $ref: '#/components/responses/IdempotencyPayloadMismatch' '422': @@ -1284,7 +1428,7 @@ paths: petNotFound: value: { code: 40401, message: 宠物不存在, data: null } mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } '409': $ref: '#/components/responses/VersionConflict' '422': @@ -1348,6 +1492,35 @@ paths: '401': $ref: '#/components/responses/AccessTokenInvalid' + /api/v1/me/community-stats: + get: + tags: [posts] + summary: 我的社区数字(获赞总数 / 作品数) + description: | + 主体恒为 token 里的调用者:无查询参数、无路径参数,**路径上没有 userId**—— + 「查不到别人的获赞」不靠权限判断,而是入口本身不存在。 + + - **统计集合 = 本人的、`status='published'` 的、`deleted_at IS NULL` 的帖**。 + 草稿不计(尚非作品,且未发布不可被赞);软删不计(删帖即撤回其数字,与 + `/me/posts`、Feed 的可见性一致);运营态 hidden/archived 不计(对所有人不可见, + 含作者本人);他人帖自然不计。 + - **自己赞自己计入**——与帖子详情页的 `likeCount` 保持同一口径,两处数字必须能对上。 + - `receivedLikeCount` = 该集合的 `like_count` 之和(读侧实时聚合,读的是写侧同事务 + 维护的帖级冗余列,故为精确值而非估算;ADR-022 不引入按人累计的冗余列)。 + - **空数据返回 `0` 而非 null**,且**永不 404**:任何已认证用户都有 stats。 + operationId: getMyCommunityStats + security: + - bearerAuth: [] + responses: + '200': + description: 我的获赞总数与作品数 + content: + application/json: + schema: + $ref: '#/components/schemas/CommunityStatsEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + /api/v1/feed: get: tags: [feed] @@ -1812,8 +1985,9 @@ components: value: { code: 40402, message: 记录不存在, data: null } PetWriteDenied: description: | - 对可见宠物无相应操作权限(code 40300):viewer 写记录、caregiver/viewer 改 - 宠物档案。仅发给对宠物「可见」的调用者,不泄露新信息。 + 对可见宠物无相应操作权限(code 40300):viewer 写记录或改头像、caregiver/viewer + 改宠物档案的资料字段(caregiver 仅改 `avatarAssetId` 时允许,M3.5 按字段分档)。 + 仅发给对宠物「可见」的调用者,不泄露新信息。 content: application/json: schema: @@ -1854,14 +2028,16 @@ components: commentNotFound: value: { code: 40404, message: 评论不存在, data: null } MediaNotFound: - description: asset 不存在、非本人所有或已删(code 40405,防枚举合并) + description: | + asset 不存在、非本人所有、已删,或**用途与引用场景不符**(code 40405,防枚举合并)。 + 用途不符即「从头像域看,一张帖子配图不是头像」;两种头像用途亦互不通用。 content: application/json: schema: $ref: '#/components/schemas/ErrorEnvelope' examples: mediaNotFound: - value: { code: 40405, message: 媒体不存在, data: null } + value: { code: 40405, message: 媒体资源不存在, data: null } UserNotFound: description: 目标用户不存在或已注销(code 40406,合并不泄露成因) content: @@ -2001,8 +2177,10 @@ components: Me: type: object - description: 当前用户资料(冻结契约,恰好这 4 个字段) - required: [userId, username, createdAt] + description: | + 本人资料(`GET` 与 `PATCH /api/v1/me` 的统一响应形态,冻结契约恰好这 6 个字段)。 + **不含** `avatarAssetId`:客户端只写不读它,「是否有头像」等价于 `avatarUrl != null`。 + required: [userId, username, nickname, avatarUrl, createdAt] properties: userId: type: string @@ -2011,16 +2189,65 @@ components: username: type: string example: demo_user + nickname: + type: string + nullable: true + minLength: 1 + maxLength: 32 + description: | + 昵称,**DB 原值**;未设置为 null(键恒在)。长度按**码点**计 1~32(btrim 后)。 + **本端点不做 username 回退**——`/me` 是本人编辑态,回退会把展示约定固化成 + 真实数据;本人视角的展示回退由客户端做 `nickname ?? username`,他人视角的 + 回退在 `/internal/users/profiles`(SQL COALESCE,不属本公开契约)。 + 不设唯一约束(ADR-022,允许重名)。 + example: 小柴 phone: type: string nullable: true description: E.164;未绑定时为 null example: '+8613800138000' + avatarUrl: + type: string + nullable: true + description: | + 头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + **客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。 + 无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。 + example: https://minio.example.com/patbond-media/user_avatar/2026/09/019212aa…?X-Amz-Signature=… createdAt: type: string format: date-time example: '2026-09-04T04:05:06.789Z' + UpdateMeRequest: + type: object + description: | + 本人资料部分更新(**三态语义**,与 pets 域 M2 的两态刻意不同): + **键缺省 = 不改;键出现且为 `null` = 清空;键出现且有值 = 设置**。 + 两字段都未出现(含只带未声明字段)为**空 patch**,答 400/40000 而非静默 200。 + 无必填字段、无 `version` 乐观锁、无 `Idempotency-Key`(同 body 重放天然幂等)。 + properties: + nickname: + type: string + nullable: true + minLength: 1 + maxLength: 32 + description: | + 昵称;btrim 后长度按**码点**计须在 1~32,否则 400/40000。 + 显式 `null` = **清空昵称**;纯空白或空串是 400/40000,**不是隐式清空** + (清空只留显式 null 一条路,否则「误提交空格」与「想删昵称」无法区分)。 + example: 小柴 + avatarAssetId: + type: string + format: uuid + nullable: true + description: | + 头像 asset ID(两步上传的产物,`purpose` 须为 `user_avatar`)。 + 显式 `null` = **清除头像**。校验:不存在/非本人/已删/用途不符 404/40405; + 本人且用途相符但 uploading/failed 422/42203;非法 UUID 400/40000。 + **响应不回显该字段**(只写不读)。 + example: 019212bb-0000-7000-8000-000000000009 + AuthTokenEnvelope: type: object required: [code, message] @@ -2226,7 +2453,8 @@ components: `breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed); `breedDisplayName` 由品种字典解出,随 breedId 存在。 软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的 - status 永不出现 deleted。`avatarAssetId` 不出现在 M2 契约(ADR-010)。 + status 永不出现 deleted。头像以 `avatarUrl` 的现签形式返回, + **`avatarAssetId` 不外露**(只写不读,写入口为 `PATCH /api/v1/pets/{petId}`)。 required: - id - name @@ -2234,6 +2462,7 @@ components: - sex - birthDateEstimated - status + - avatarUrl - myRole - createdAt - updatedAt @@ -2294,6 +2523,15 @@ components: type: string enum: [active, lost, deceased, archived] description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401) + avatarUrl: + type: string + nullable: true + description: | + 宠物头像访问 URL——时效性预签名 GET(TTL 默认 1 小时,配置项),每次响应现签, + **客户端不得持久化、过期即重取**;桶保持私有,无签名直访被拒。 + 无头像、asset 非 ready、对象存储未配置三种情况均为 null(键恒在)。 + asset 事后退出 ready 时转 null 而引用保留(读请求不做写副作用)。 + example: https://minio.example.com/patbond-media/pet_avatar/2026/09/019212cc…?X-Amz-Signature=… myRole: type: string enum: [owner, caregiver, viewer] @@ -2351,14 +2589,18 @@ components: UpdatePetRequest: type: object description: | - 部分更新:缺席字段不变;不支持清空回 null。例外:品种对 + 部分更新:缺席字段不变;资料字段不支持清空回 null。例外:品种对 (breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。 species 不可改(不在请求体)。 + **`avatarAssetId` 是本 schema 唯一的三态字段**(M3.5):键缺省 = 不改; + 键出现且为 `null` = 清除头像;键出现且有值 = 设置。其余字段保持 M2 两态语义。 + 权限档按本次触及的字段决定(仅头像 → WRITE;触及资料字段 → MANAGE;混合取更严), + 见端点描述。 required: [version] properties: version: type: integer - description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902) + description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902);即便只改头像也必带 name: type: string minLength: 1 @@ -2393,6 +2635,16 @@ components: type: string enum: [active, lost, deceased, archived] description: 状态流转;deleted 不可经 PATCH 设置(400/40000) + avatarAssetId: + type: string + format: uuid + nullable: true + description: | + 宠物头像 asset ID(两步上传的产物,`purpose` 须为 `pet_avatar`)。 + **三态**:缺省 = 不改;显式 `null` = 清除头像;给值 = 设置。校验:不存在/ + 非本人/已删/用途不符 404/40405;本人且用途相符但 uploading/failed 422/42203; + 非法 UUID 400/40000。**响应不回显该字段**(只写不读,读取见 `Pet.avatarUrl`)。 + example: 019212cc-0000-7000-8000-00000000000a PetEnvelope: type: object @@ -3205,10 +3457,13 @@ components: description: M3 仅 image(ADR-018 视频后置;video/document 为向后新增枚举预留) purpose: type: string - enum: [post_image] + enum: [post_image, user_avatar, pet_avatar] description: | - 用途白名单(M3 定型仅 post_image,决定 objectKey 前缀);P6 扩 - user_avatar/pet_avatar 时为向后兼容的枚举追加(服务端纯配置扩展) + 用途白名单(服务端配置项 `patbond.media.allowed-purposes`,决定 objectKey 前缀 + `/yyyy/MM/{assetId}`)。M3.5 起为三值:`post_image`(帖子配图)、 + `user_avatar`(用户头像)、`pet_avatar`(宠物头像)——相对 M3 的纯枚举追加。 + **用途即引用侧的类型检查**:引用时校验 `purpose` 相符,故帖图不能当头像、 + 两种头像也互不通用(不符者 404/40405)。白名单外的取值 400/40000。 mimeType: type: string enum: [image/jpeg, image/png, image/webp] @@ -3857,3 +4112,36 @@ components: example: success data: $ref: '#/components/schemas/FollowStats' + + CommunityStats: + type: object + description: | + 调用者本人的社区数字(M3.5)。两数同一集合:本人的、`status='published'` 的、 + 未软删的帖(草稿 / 软删 / hidden / archived 均不计)。读侧实时聚合,无冗余计数列。 + required: [receivedLikeCount, publishedPostCount] + properties: + receivedLikeCount: + type: integer + format: int64 + description: | + 获赞总数 = 该集合的 `like_count` 之和(写侧同事务维护的帖级冗余列,精确值)。 + **自己赞自己计入**,与帖子详情的 `likeCount` 同一口径。空数据为 0,非 null。 + example: 128 + publishedPostCount: + type: integer + format: int64 + description: 作品数 = 该集合的帖子数。空数据为 0,非 null。 + example: 12 + + CommunityStatsEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/CommunityStats' -- 2.43.0