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 org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -36,6 +38,10 @@ public class UserRepository {
|
|||||||
OffsetDateTime lockedUntil) {
|
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. */
|
/** Inserts the user row; created_at/updated_at come from the DB defaults. */
|
||||||
public OffsetDateTime insertUser(UUID id, String username, String nickname, String phone) {
|
public OffsetDateTime insertUser(UUID id, String username, String nickname, String phone) {
|
||||||
return jdbcClient.sql("""
|
return jdbcClient.sql("""
|
||||||
@@ -86,6 +92,31 @@ public class UserRepository {
|
|||||||
.optional();
|
.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) {
|
public Optional<AuthRow> findAuthByUsername(String username) {
|
||||||
return jdbcClient.sql("""
|
return jdbcClient.sql("""
|
||||||
SELECT u.id, u.username::text AS username, u.nickname, c.password_hash, c.locked_until
|
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