From 8854ff4288066e735977c56102a0163e4879869c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=98=BF=E7=8C=AB?= Date: Thu, 3 Sep 2026 14:44:30 +0800 Subject: [PATCH] task(1ff499d8-9640-4cda-a068-b52bac9f6731): Fix login response contract deviation blocking real device login - Change AuthApi login and getUserInfo return types to ApiEnvelope - Add tokenFromData to handle both string and {"Token":"..."} object responses - Add userInfoFromData to handle both flat UserInfo and {"UserInfo":""} responses - Inject Json into SessionRepository for unified deserialization - Update signIn flow to extract token and user info through unified helpers - Add contract tolerance documentation explaining V4.5 spec vs test environment behavior --- .../com/stec/cmd/session/SessionRepository.kt | 60 +++++++++++++++---- .../com/stec/cmd/core/network/api/AuthApi.kt | 17 ++++-- 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt b/app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt index c00c78f..1b8b9c9 100644 --- a/app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt +++ b/app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt @@ -15,6 +15,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import javax.inject.Inject import javax.inject.Singleton @@ -32,12 +37,18 @@ sealed class SessionState { * 会话门面(M1-01/02/03/04/05/07): * 登录 → Token 持久化(ConfigRepository/DataStore)→ 拉取用户信息进入 SignedIn; * 登出 / 改密成功 → 清 Token 回 SignedOut;冷启动凭存量 Token 自动恢复会话。 + * + * 契约宽容:登录(2.2.1/2.2.3)与用户信息(2.4)接口的 data 形态与文档口径不符 + * (实测为 {"Token":"…"} 对象与 {"UserInfo":"<二次 JSON 编码字符串>"}), + * AuthApi 以 JsonElement 宽容承载,本类经 [tokenFromData] / [userInfoFromData] + * 归一提取,同时兼容文档口径与服务端实际口径。 */ @Singleton class SessionRepository @Inject constructor( private val authApi: AuthApi, private val apiCaller: ApiCaller, private val configRepository: ConfigRepository, + private val json: Json, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -61,7 +72,7 @@ class SessionRepository @Inject constructor( return } _state.value = try { - SessionState.SignedIn(apiCaller.call { authApi.getUserInfo() }) + SessionState.SignedIn(userInfoFromData(apiCaller.call { authApi.getUserInfo() })) } catch (e: ApiError) { if (e !is ApiError.NetworkError) { // 待确认问题 #2 缺省口径:Token 失效按重新登录处理 @@ -73,18 +84,16 @@ class SessionRepository @Inject constructor( /** M1-01 账号密码登录(2.2.1)。 */ suspend fun loginByAccount(loginName: String, password: String) { - val token = apiCaller.call { + signIn(apiCaller.call { authApi.loginByAccount(PasswordLoginRequest(loginName, password)) - } - signIn(token) + }) } /** M1-03 手机验证码登录(2.2.3);[mobile] 须为归一后的 11 位国内号。 */ suspend fun loginByMobile(mobile: String, verificationCode: String) { - val token = apiCaller.call { + signIn(apiCaller.call { authApi.loginByMobile(MobileLoginRequest(mobile, verificationCode)) - } - signIn(token) + }) } /** M1-02 发送验证码(2.2.2);[mobilePhone] 传 [Phones.toQueryValue] 归一结果。 */ @@ -106,9 +115,40 @@ class SessionRepository @Inject constructor( _state.value = SessionState.SignedOut } - private suspend fun signIn(rawToken: String) { - val token = Phones.normalizeToken(rawToken) + private suspend fun signIn(loginData: JsonElement) { + val token = Phones.normalizeToken(tokenFromData(loginData)) configRepository.updateToken(token) - _state.value = SessionState.SignedIn(apiCaller.call { authApi.getUserInfo() }) + _state.value = + SessionState.SignedIn(userInfoFromData(apiCaller.call { authApi.getUserInfo() })) + } + + /** + * 登录 data → Token 归一提取。兼容两种口径: + * 文档 2.2.1/2.2.3 的裸字符串;测试环境实测(任务 ca3394be)的 {"Token":"…"} 对象。 + * 结构无法识别时以 [ApiError.EmptyBodyError] 抛出,UI 层统一按 ApiError 提示。 + */ + private fun tokenFromData(data: JsonElement): String = when (data) { + is JsonPrimitive -> data.content + is JsonObject -> + (data["Token"] as? JsonPrimitive ?: data["token"] as? JsonPrimitive)?.content + ?: throw ApiError.EmptyBodyError("登录响应缺少 Token 字段") + else -> throw ApiError.EmptyBodyError("登录响应数据结构异常") + } + + /** + * 用户信息 data → [UserInfo] 归一提取。兼容两种口径: + * 文档 2.4 的平铺 UserInfo 对象;测试环境实测的 {"UserInfo":"<二次 JSON 编码字符串>"}。 + * 二次编码字符串与平铺对象之外的形态、或字段与模型不符时,以 [ApiError.EmptyBodyError] 抛出。 + */ + private fun userInfoFromData(data: JsonElement): UserInfo { + val payload = (data as? JsonObject)?.get("UserInfo") ?: data + return try { + when (payload) { + is JsonPrimitive -> json.decodeFromString(UserInfo.serializer(), payload.content) + else -> json.decodeFromJsonElement(UserInfo.serializer(), payload) + } + } catch (e: SerializationException) { + throw ApiError.EmptyBodyError("用户信息解析失败:${e.message}") + } } } diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/api/AuthApi.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/AuthApi.kt index a21678f..7074e47 100644 --- a/core/network/src/main/kotlin/com/stec/cmd/core/network/api/AuthApi.kt +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/AuthApi.kt @@ -17,14 +17,19 @@ import retrofit2.http.Query * * 三要素请求头(SecretKey/SystemCode/Token)由 AuthInterceptor 统一注入; * 登录/验证码接口未登录时天然不携带 Token(接口文档 1.2.3)。 + * + * 契约宽容(V4.5 文档 vs 测试环境实测,任务 ca3394be): + * - 2.2.1/2.2.3 登录 data 实测为 {"Token":"…"} 对象(文档称 Token 字符串); + * - 2.4 用户信息 data 实测为 {"UserInfo":"<二次 JSON 编码字符串>"}(文档称平铺对象)。 + * 三者均以 [JsonElement] 宽容承载,由 SessionRepository 归一提取后兼容两种口径。 */ interface AuthApi { - /** 2.2.1 账号密码登录:成功 data 为 Token 字符串。 */ + /** 2.2.1 账号密码登录:data 形态见类注释契约宽容说明。 */ @POST(PATH_LOGIN_BY_ACCOUNT) suspend fun loginByAccount( @Body request: PasswordLoginRequest, - ): ApiEnvelope + ): ApiEnvelope /** * 2.2.2 发送短信验证码:[mobilePhone] 须已按约定归一 @@ -36,11 +41,11 @@ interface AuthApi { @Query(QUERY_MOBILE_PHONE, encoded = true) mobilePhone: String, ): ApiEnvelope - /** 2.2.3 手机验证码登录:成功 data 为 Token 字符串。 */ + /** 2.2.3 手机验证码登录:data 形态同 2.2.1,见类注释契约宽容说明。 */ @POST(PATH_LOGIN_BY_MOBILE) suspend fun loginByMobile( @Body request: MobileLoginRequest, - ): ApiEnvelope + ): ApiEnvelope /** 2.3.1 修改登录密码:登录态下操作,成功 data 为 null。 */ @POST(PATH_MODIFY_PASSWORD) @@ -48,9 +53,9 @@ interface AuthApi { @Body request: ChangePasswordRequest, ): ApiEnvelope - /** 2.4 获取当前用户信息:需登录态。 */ + /** 2.4 获取当前用户信息:需登录态,data 形态见类注释契约宽容说明。 */ @GET(PATH_GET_USER_INFO) - suspend fun getUserInfo(): ApiEnvelope + suspend fun getUserInfo(): ApiEnvelope companion object { // ---- 接口管理规范 V4.5 · 章节 2.1 所有 API 目录 ----