From 40bac855439f36f18575129d1ecc6fb9a581dd33 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Wed, 9 Sep 2026 10:26:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20user=20=E5=9F=9F=20/internal=20?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=85=AC=E5=BC=80=E8=B5=84=E6=96=99=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E2=80=94=E2=80=94=E6=98=B5=E7=A7=B0=E5=9B=9E=E9=80=80?= =?UTF-8?q?/=E5=A4=B4=E5=83=8F=E6=8C=87=E9=92=88/=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E7=BC=BA=E5=B8=AD=EF=BC=88D3-9=20=E6=96=B9=E6=A1=88=20B?= =?UTF-8?q?=EF=BC=8CT3-05=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /internal/users/profiles?ids=…:一次最多 50 个,超限/空/非法 UUID 均 400/40000 - 仅暴露 userId/nickname/avatarAssetId;nickname→username 回退在归属侧 SQL 完成 - 不存在与已注销用户静默缺席(墓碑形态 = 消费侧 authorId 保底,不泄露成因) - InternalAuthFilter 既有 /internal/** 共享密钥保护直接覆盖,无新安全面 - 新增 InternalProfileEndpointTest 8 例(user 模块 88→96) Co-Authored-By: Claude Fable 5 --- .../controller/InternalProfileController.java | 77 +++++++ .../user/dto/PublicProfileResponse.java | 16 ++ .../user/repository/UserRepository.java | 31 +++ .../InternalProfileEndpointTest.java | 190 ++++++++++++++++++ 4 files changed, 314 insertions(+) create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/controller/InternalProfileController.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/dto/PublicProfileResponse.java create mode 100644 patbond-user/src/test/java/com/patbond/patbond/user/controller/InternalProfileEndpointTest.java diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/controller/InternalProfileController.java b/patbond-user/src/main/java/com/patbond/patbond/user/controller/InternalProfileController.java new file mode 100644 index 0000000..d0f1b44 --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/controller/InternalProfileController.java @@ -0,0 +1,77 @@ +package com.patbond.patbond.user.controller; + +import com.patbond.patbond.common.error.BusinessException; +import com.patbond.patbond.common.error.ErrorCode; +import com.patbond.patbond.common.response.ApiResponse; +import com.patbond.patbond.user.dto.PublicProfileResponse; +import com.patbond.patbond.user.repository.UserRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * Internal batch public-profile API (D3-9 方案 B, T3-05), consumed by + * patbond-community for feed/comment author summaries. Guarded by + * InternalAuthFilter like every /internal/** route. + * + *

Semantics: {@code ids} is a mandatory comma-separated list of user ids, + * at most {@value #MAX_BATCH} per call (one feed page's worth of authors + * with headroom) — more is a 400, matching the batch-not-loop contract the + * consumer's cache is built around. Ids that do not resolve (unknown, or the + * user is soft-deleted) are silently absent from the reply; the caller + * renders its id-only fallback for them, so absence leaks nothing about + * which of the two cases it was.

+ */ +@RestController +@RequestMapping("/internal/users") +public class InternalProfileController { + + static final int MAX_BATCH = 50; + + private final UserRepository userRepository; + + public InternalProfileController(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @GetMapping("/profiles") + public ApiResponse> profiles( + @RequestParam(required = false) String ids) { + List profiles = userRepository.findPublicProfiles(parse(ids)) + .stream() + .map(row -> new PublicProfileResponse(row.id(), row.nickname(), row.avatarAssetId())) + .toList(); + return ApiResponse.success(profiles); + } + + /** + * Parsed by hand (not bound as List<UUID>) so every malformed shape + * answers a deterministic 400/40000 instead of falling through to the + * generic handler. + */ + private static Set parse(String ids) { + if (ids == null || ids.isBlank()) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "ids 必带且不得为空"); + } + String[] parts = ids.split(",", -1); + if (parts.length > MAX_BATCH) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, + "ids 一次最多 " + MAX_BATCH + " 个"); + } + Set parsed = new LinkedHashSet<>(parts.length); + for (String part : parts) { + try { + parsed.add(UUID.fromString(part.trim())); + } catch (IllegalArgumentException e) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "ids 含非法 UUID"); + } + } + return parsed; + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/dto/PublicProfileResponse.java b/patbond-user/src/main/java/com/patbond/patbond/user/dto/PublicProfileResponse.java new file mode 100644 index 0000000..09eb06e --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/dto/PublicProfileResponse.java @@ -0,0 +1,16 @@ +package com.patbond.patbond.user.dto; + +import java.util.UUID; + +/** + * One public profile in the /internal/users/profiles batch reply (D3-9 方案 B): + * exactly the fields another service may see — display name and the avatar + * asset pointer, nothing else (no phone, no username-vs-nickname distinction, + * no timestamps). The nickname→username fallback is applied HERE, on the + * owning side, so consumers never see or need the raw username. The avatar + * travels as an asset id, not a URL: URL signing is the consumer's read-side + * concern (community signs presigned GETs locally, T3-03 定型), and a signed + * URL would go stale inside the consumer's cache. + */ +public record PublicProfileResponse(UUID userId, String nickname, UUID avatarAssetId) { +} 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 12819f6..ec9130e 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,8 @@ import org.springframework.jdbc.core.simple.JdbcClient; import org.springframework.stereotype.Repository; import java.time.OffsetDateTime; +import java.util.Collection; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -36,6 +38,10 @@ public class UserRepository { OffsetDateTime lockedUntil) { } + /** Public-profile projection; nickname already carries the username fallback. */ + public record PublicProfileRow(UUID id, String nickname, UUID avatarAssetId) { + } + /** 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(""" @@ -86,6 +92,31 @@ public class UserRepository { .optional(); } + /** + * Batch public-profile lookup for /internal/users/profiles (D3-9 方案 B). + * The nickname→username fallback happens in SQL (ck_users_nickname + * guarantees a stored nickname is trimmed and non-empty, so plain + * COALESCE suffices); soft-deleted users are simply absent — their + * tombstone shape is the caller's id-only fallback, indistinguishable + * from a user it failed to resolve. + */ + public List findPublicProfiles(Collection ids) { + if (ids.isEmpty()) { + return List.of(); + } + return jdbcClient.sql(""" + SELECT id, COALESCE(nickname, username::text) AS nickname, avatar_asset_id + FROM identity.users + WHERE id IN (:ids) AND deleted_at IS NULL + """) + .param("ids", List.copyOf(ids)) + .query((rs, rowNum) -> new PublicProfileRow( + rs.getObject("id", UUID.class), + rs.getString("nickname"), + rs.getObject("avatar_asset_id", UUID.class))) + .list(); + } + 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/test/java/com/patbond/patbond/user/controller/InternalProfileEndpointTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/controller/InternalProfileEndpointTest.java new file mode 100644 index 0000000..1594bd6 --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/controller/InternalProfileEndpointTest.java @@ -0,0 +1,190 @@ +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 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.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +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.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * /internal/users/profiles (D3-9 方案 B, T3-05): service auth, batch limits, + * the nickname→username fallback, avatar pointer passthrough, and the + * silent-absence semantics for unknown or soft-deleted users. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class InternalProfileEndpointTest { + + private static final String INTERNAL_TOKEN = "test-internal-token"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcClient jdbcClient; + + private MockHttpServletRequestBuilder profiles(String ids) { + return get("/internal/users/profiles") + .header(InternalAuthFilter.HEADER, INTERNAL_TOKEN) + .queryParam("ids", ids); + } + + private String registerUser(String username) throws Exception { + String body = mockMvc.perform(post("/internal/users") + .header(InternalAuthFilter.HEADER, INTERNAL_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"%s\",\"password\":\"secret123\"}".formatted(username))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return JsonPath.read(body, "$.data.id"); + } + + private String freshUsername() { + return "prof" + Long.toHexString(ThreadLocalRandom.current().nextLong() & 0x7FFFFFFFFFFFFFFFL); + } + + /** Profiles of a successful reply, keyed by userId. */ + private Map> fetch(String ids) throws Exception { + String body = mockMvc.perform(profiles(ids)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(); + List> rows = JsonPath.read(body, "$.data"); + return rows.stream().collect(Collectors.toMap( + row -> (String) row.get("userId"), Function.identity())); + } + + private UUID insertReadyAvatarAsset(UUID ownerId) { + UUID assetId = 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', 'user_avatar', 'object', 'patbond-media', + :objectKey, 'image/jpeg', 123, 'ready', :readyAt) + """) + .param("id", assetId) + .param("owner", ownerId) + .param("objectKey", "user_avatar/2026/09/" + assetId + ".jpg") + .param("readyAt", OffsetDateTime.now()) + .update(); + jdbcClient.sql("UPDATE identity.users SET avatar_asset_id = :assetId WHERE id = :id") + .param("assetId", assetId) + .param("id", ownerId) + .update(); + return assetId; + } + + @Test + void rejectsCallsWithoutTheServiceSecret() throws Exception { + mockMvc.perform(get("/internal/users/profiles").queryParam("ids", UUID.randomUUID().toString())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + mockMvc.perform(get("/internal/users/profiles") + .header(InternalAuthFilter.HEADER, "wrong-token") + .queryParam("ids", UUID.randomUUID().toString())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void returnsNicknameWhenSetAndFallsBackToUsernameWhenNot() throws Exception { + String withNickname = registerUser(freshUsername()); + String withoutNickname = freshUsername(); + String withoutNicknameId = registerUser(withoutNickname); + jdbcClient.sql("UPDATE identity.users SET nickname = '毛毛的铲屎官' WHERE id = :id") + .param("id", UUID.fromString(withNickname)) + .update(); + + Map> profiles = fetch(withNickname + "," + withoutNicknameId); + assertThat(profiles).hasSize(2); + assertThat(profiles.get(withNickname).get("nickname")).isEqualTo("毛毛的铲屎官"); + assertThat(profiles.get(withoutNicknameId).get("nickname")).isEqualTo(withoutNickname); + } + + @Test + void passesTheAvatarAssetPointerThroughAndNullWithoutAvatar() throws Exception { + String withAvatar = registerUser(freshUsername()); + String withoutAvatar = registerUser(freshUsername()); + UUID assetId = insertReadyAvatarAsset(UUID.fromString(withAvatar)); + + Map> profiles = fetch(withAvatar + "," + withoutAvatar); + assertThat(profiles.get(withAvatar).get("avatarAssetId")).isEqualTo(assetId.toString()); + assertThat(profiles.get(withoutAvatar).get("avatarAssetId")).isNull(); + } + + @Test + void unknownAndSoftDeletedIdsAreSilentlyAbsent() throws Exception { + String live = registerUser(freshUsername()); + String deleted = registerUser(freshUsername()); + jdbcClient.sql("UPDATE identity.users SET deleted_at = now(), status = 'deleted' WHERE id = :id") + .param("id", UUID.fromString(deleted)) + .update(); + + Map> profiles = + fetch(live + "," + deleted + "," + UUID.randomUUID()); + assertThat(profiles).containsOnlyKeys(live); + } + + @Test + void missingOrBlankIdsIsA400() throws Exception { + mockMvc.perform(get("/internal/users/profiles") + .header(InternalAuthFilter.HEADER, INTERNAL_TOKEN)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40000)); + mockMvc.perform(profiles(" ")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40000)); + } + + @Test + void malformedUuidIsA400() throws Exception { + mockMvc.perform(profiles(UUID.randomUUID() + ",not-a-uuid")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40000)); + } + + @Test + void moreThanFiftyIdsIsA400AndExactlyFiftyIsNot() throws Exception { + String fifty = IntStream.range(0, 50) + .mapToObj(i -> UUID.randomUUID().toString()) + .collect(Collectors.joining(",")); + mockMvc.perform(profiles(fifty)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(0)); + mockMvc.perform(profiles(fifty + "," + UUID.randomUUID())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40000)); + } + + @Test + void duplicateIdsCollapseToOneRow() throws Exception { + String user = registerUser(freshUsername()); + mockMvc.perform(profiles(user + "," + user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)); + } +}