task(1ff499d8-9640-4cda-a068-b52bac9f6731): Fix login response contract deviation blocking real device login

- Change AuthApi login and getUserInfo return types to ApiEnvelope<JsonElement>
- Add tokenFromData to handle both string and {"Token":"..."} object responses
- Add userInfoFromData to handle both flat UserInfo and {"UserInfo":"<escaped JSON>"} 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
This commit is contained in:
阿猫
2026-09-03 14:44:30 +08:00
parent f1243078ad
commit 8854ff4288
2 changed files with 61 additions and 16 deletions

View File

@@ -15,6 +15,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -32,12 +37,18 @@ sealed class SessionState {
* 会话门面M1-01/02/03/04/05/07 * 会话门面M1-01/02/03/04/05/07
* 登录 → Token 持久化ConfigRepository/DataStore→ 拉取用户信息进入 SignedIn * 登录 → Token 持久化ConfigRepository/DataStore→ 拉取用户信息进入 SignedIn
* 登出 / 改密成功 → 清 Token 回 SignedOut冷启动凭存量 Token 自动恢复会话。 * 登出 / 改密成功 → 清 Token 回 SignedOut冷启动凭存量 Token 自动恢复会话。
*
* 契约宽容登录2.2.1/2.2.3与用户信息2.4)接口的 data 形态与文档口径不符
* (实测为 {"Token":"…"} 对象与 {"UserInfo":"<二次 JSON 编码字符串>"}
* AuthApi 以 JsonElement 宽容承载,本类经 [tokenFromData] / [userInfoFromData]
* 归一提取,同时兼容文档口径与服务端实际口径。
*/ */
@Singleton @Singleton
class SessionRepository @Inject constructor( class SessionRepository @Inject constructor(
private val authApi: AuthApi, private val authApi: AuthApi,
private val apiCaller: ApiCaller, private val apiCaller: ApiCaller,
private val configRepository: ConfigRepository, private val configRepository: ConfigRepository,
private val json: Json,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -61,7 +72,7 @@ class SessionRepository @Inject constructor(
return return
} }
_state.value = try { _state.value = try {
SessionState.SignedIn(apiCaller.call { authApi.getUserInfo() }) SessionState.SignedIn(userInfoFromData(apiCaller.call { authApi.getUserInfo() }))
} catch (e: ApiError) { } catch (e: ApiError) {
if (e !is ApiError.NetworkError) { if (e !is ApiError.NetworkError) {
// 待确认问题 #2 缺省口径Token 失效按重新登录处理 // 待确认问题 #2 缺省口径Token 失效按重新登录处理
@@ -73,18 +84,16 @@ class SessionRepository @Inject constructor(
/** M1-01 账号密码登录2.2.1)。 */ /** M1-01 账号密码登录2.2.1)。 */
suspend fun loginByAccount(loginName: String, password: String) { suspend fun loginByAccount(loginName: String, password: String) {
val token = apiCaller.call { signIn(apiCaller.call {
authApi.loginByAccount(PasswordLoginRequest(loginName, password)) authApi.loginByAccount(PasswordLoginRequest(loginName, password))
} })
signIn(token)
} }
/** M1-03 手机验证码登录2.2.3[mobile] 须为归一后的 11 位国内号。 */ /** M1-03 手机验证码登录2.2.3[mobile] 须为归一后的 11 位国内号。 */
suspend fun loginByMobile(mobile: String, verificationCode: String) { suspend fun loginByMobile(mobile: String, verificationCode: String) {
val token = apiCaller.call { signIn(apiCaller.call {
authApi.loginByMobile(MobileLoginRequest(mobile, verificationCode)) authApi.loginByMobile(MobileLoginRequest(mobile, verificationCode))
} })
signIn(token)
} }
/** M1-02 发送验证码2.2.2[mobilePhone] 传 [Phones.toQueryValue] 归一结果。 */ /** M1-02 发送验证码2.2.2[mobilePhone] 传 [Phones.toQueryValue] 归一结果。 */
@@ -106,9 +115,40 @@ class SessionRepository @Inject constructor(
_state.value = SessionState.SignedOut _state.value = SessionState.SignedOut
} }
private suspend fun signIn(rawToken: String) { private suspend fun signIn(loginData: JsonElement) {
val token = Phones.normalizeToken(rawToken) val token = Phones.normalizeToken(tokenFromData(loginData))
configRepository.updateToken(token) 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}")
}
} }
} }

View File

@@ -17,14 +17,19 @@ import retrofit2.http.Query
* *
* 三要素请求头SecretKey/SystemCode/Token由 AuthInterceptor 统一注入; * 三要素请求头SecretKey/SystemCode/Token由 AuthInterceptor 统一注入;
* 登录/验证码接口未登录时天然不携带 Token接口文档 1.2.3)。 * 登录/验证码接口未登录时天然不携带 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 { interface AuthApi {
/** 2.2.1 账号密码登录:成功 data 为 Token 字符串。 */ /** 2.2.1 账号密码登录data 形态见类注释契约宽容说明。 */
@POST(PATH_LOGIN_BY_ACCOUNT) @POST(PATH_LOGIN_BY_ACCOUNT)
suspend fun loginByAccount( suspend fun loginByAccount(
@Body request: PasswordLoginRequest, @Body request: PasswordLoginRequest,
): ApiEnvelope<String> ): ApiEnvelope<JsonElement>
/** /**
* 2.2.2 发送短信验证码:[mobilePhone] 须已按约定归一 * 2.2.2 发送短信验证码:[mobilePhone] 须已按约定归一
@@ -36,11 +41,11 @@ interface AuthApi {
@Query(QUERY_MOBILE_PHONE, encoded = true) mobilePhone: String, @Query(QUERY_MOBILE_PHONE, encoded = true) mobilePhone: String,
): ApiEnvelope<JsonElement> ): ApiEnvelope<JsonElement>
/** 2.2.3 手机验证码登录:成功 data 为 Token 字符串。 */ /** 2.2.3 手机验证码登录data 形态同 2.2.1,见类注释契约宽容说明。 */
@POST(PATH_LOGIN_BY_MOBILE) @POST(PATH_LOGIN_BY_MOBILE)
suspend fun loginByMobile( suspend fun loginByMobile(
@Body request: MobileLoginRequest, @Body request: MobileLoginRequest,
): ApiEnvelope<String> ): ApiEnvelope<JsonElement>
/** 2.3.1 修改登录密码:登录态下操作,成功 data 为 null。 */ /** 2.3.1 修改登录密码:登录态下操作,成功 data 为 null。 */
@POST(PATH_MODIFY_PASSWORD) @POST(PATH_MODIFY_PASSWORD)
@@ -48,9 +53,9 @@ interface AuthApi {
@Body request: ChangePasswordRequest, @Body request: ChangePasswordRequest,
): ApiEnvelope<Unit> ): ApiEnvelope<Unit>
/** 2.4 获取当前用户信息:需登录态。 */ /** 2.4 获取当前用户信息:需登录态data 形态见类注释契约宽容说明。 */
@GET(PATH_GET_USER_INFO) @GET(PATH_GET_USER_INFO)
suspend fun getUserInfo(): ApiEnvelope<UserInfo> suspend fun getUserInfo(): ApiEnvelope<JsonElement>
companion object { companion object {
// ---- 接口管理规范 V4.5 · 章节 2.1 所有 API 目录 ---- // ---- 接口管理规范 V4.5 · 章节 2.1 所有 API 目录 ----