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:
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>
|
||||
): ApiEnvelope<JsonElement>
|
||||
|
||||
/**
|
||||
* 2.2.2 发送短信验证码:[mobilePhone] 须已按约定归一
|
||||
@@ -36,11 +41,11 @@ interface AuthApi {
|
||||
@Query(QUERY_MOBILE_PHONE, encoded = true) mobilePhone: String,
|
||||
): ApiEnvelope<JsonElement>
|
||||
|
||||
/** 2.2.3 手机验证码登录:成功 data 为 Token 字符串。 */
|
||||
/** 2.2.3 手机验证码登录:data 形态同 2.2.1,见类注释契约宽容说明。 */
|
||||
@POST(PATH_LOGIN_BY_MOBILE)
|
||||
suspend fun loginByMobile(
|
||||
@Body request: MobileLoginRequest,
|
||||
): ApiEnvelope<String>
|
||||
): ApiEnvelope<JsonElement>
|
||||
|
||||
/** 2.3.1 修改登录密码:登录态下操作,成功 data 为 null。 */
|
||||
@POST(PATH_MODIFY_PASSWORD)
|
||||
@@ -48,9 +53,9 @@ interface AuthApi {
|
||||
@Body request: ChangePasswordRequest,
|
||||
): ApiEnvelope<Unit>
|
||||
|
||||
/** 2.4 获取当前用户信息:需登录态。 */
|
||||
/** 2.4 获取当前用户信息:需登录态,data 形态见类注释契约宽容说明。 */
|
||||
@GET(PATH_GET_USER_INFO)
|
||||
suspend fun getUserInfo(): ApiEnvelope<UserInfo>
|
||||
suspend fun getUserInfo(): ApiEnvelope<JsonElement>
|
||||
|
||||
companion object {
|
||||
// ---- 接口管理规范 V4.5 · 章节 2.1 所有 API 目录 ----
|
||||
|
||||
Reference in New Issue
Block a user