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:
+77
@@ -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<UUID>) 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
|
||||
|
||||
+190
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user