feat: user 域 /internal 批量公开资料接口——昵称回退/头像指针/静默缺席(D3-9 方案 B,T3-05)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 10:26:01 +08:00
parent 101ac0fbbc
commit 40bac85543
4 changed files with 314 additions and 0 deletions
@@ -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.
*
* <p>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.</p>
*/
@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<List<PublicProfileResponse>> profiles(
@RequestParam(required = false) String ids) {
List<PublicProfileResponse> 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&lt;UUID&gt;) so every malformed shape
* answers a deterministic 400/40000 instead of falling through to the
* generic handler.
*/
private static Set<UUID> 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<UUID> 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;
}
}
@@ -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) {
}
@@ -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<PublicProfileRow> findPublicProfiles(Collection<UUID> 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<AuthRow> findAuthByUsername(String username) {
return jdbcClient.sql("""
SELECT u.id, u.username::text AS username, u.nickname, c.password_hash, c.locked_until
@@ -0,0 +1,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<String, Map<String, Object>> fetch(String ids) throws Exception {
String body = mockMvc.perform(profiles(ids))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString();
List<Map<String, Object>> 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<String, Map<String, Object>> 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<String, Map<String, Object>> 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<String, Map<String, Object>> 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));
}
}