feat: 会话记录接入客户端 X-Device-Id(auth_sessions.device_id)

- Flutter 端每次请求已携带 X-Device-Id;auth 读取该头(截断 128)经 CreateSessionRequest 透传,user 落 auth_sessions.device_id,为多设备会话列表备数据
- 门禁:./mvnw clean test → BUILD SUCCESS,74 测试 0 失败(新增 registerForwardsDeviceIdHeaderToTheSessionRecord + 会话落库断言)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 14:43:44 +08:00
parent 4dc3dcdfa3
commit 8bdaf53222
7 changed files with 65 additions and 16 deletions
@@ -53,14 +53,19 @@ public class AuthController {
}
private AuthService.ClientInfo clientInfo(HttpServletRequest request) {
String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
if (userAgent != null && userAgent.length() > 512) {
userAgent = userAgent.substring(0, 512);
}
String deviceId = truncate(request.getHeader("X-Device-Id"), 128);
String userAgent = truncate(request.getHeader(HttpHeaders.USER_AGENT), 512);
String forwarded = request.getHeader("X-Forwarded-For");
String ip = forwarded != null && !forwarded.isBlank()
? forwarded.split(",")[0].trim()
: request.getRemoteAddr();
return new AuthService.ClientInfo(userAgent, ip);
return new AuthService.ClientInfo(deviceId, userAgent, ip);
}
private static String truncate(String value, int maxLength) {
if (value == null || value.isBlank()) {
return null;
}
return value.length() > maxLength ? value.substring(0, maxLength) : value;
}
}
@@ -34,7 +34,7 @@ import java.util.UUID;
public class AuthService {
/** Device metadata forwarded to the session record (observability only). */
public record ClientInfo(String userAgent, String ipAddress) {
public record ClientInfo(String deviceId, String userAgent, String ipAddress) {
}
private final UserClient userClient;
@@ -88,7 +88,8 @@ public class AuthService {
private AuthTokenResponse openSession(UUID userId, ClientInfo clientInfo) {
SessionTokens tokens = requireData(sessionClient.create(new CreateSessionRequest(
userId, clientInfo.userAgent(), clientInfo.ipAddress())), "创建会话失败");
userId, clientInfo.deviceId(), clientInfo.userAgent(), clientInfo.ipAddress())),
"创建会话失败");
return assemble(tokens);
}
@@ -8,6 +8,7 @@ import com.patbond.patbond.auth.support.TestJwtKeys;
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.common.session.CreateSessionRequest;
import com.patbond.patbond.common.session.RevokeSessionRequest;
import com.patbond.patbond.common.session.SessionTokens;
import com.patbond.patbond.common.user.UserProfile;
@@ -112,6 +113,25 @@ class AuthControllerTest {
.andExpect(jsonPath("$.data.expiresAt").doesNotExist());
}
@Test
void registerForwardsDeviceIdHeaderToTheSessionRecord() throws Exception {
when(userClient.createUser(any())).thenReturn(ApiResponse.success(
new UserProfile(USER_ID, "alice", null, "+8613800138000", OffsetDateTime.now())));
when(sessionClient.create(any())).thenReturn(ApiResponse.success(sessionTokens()));
mockMvc.perform(post("/api/v1/auth/register")
.header("X-Device-Id", "pixel-8-of-alice")
.contentType(APPLICATION_JSON)
.content(REGISTER_BODY))
.andExpect(status().isOk());
ArgumentCaptor<CreateSessionRequest> captor =
ArgumentCaptor.forClass(CreateSessionRequest.class);
verify(sessionClient).create(captor.capture());
assertThat(captor.getValue().getDeviceId()).isEqualTo("pixel-8-of-alice");
assertThat(captor.getValue().getUserId()).isEqualTo(USER_ID);
}
@Test
void registerPropagatesDuplicateUsernameAsConflict() throws Exception {
when(userClient.createUser(any()))