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