task(f4b5cc37-e27d-480a-ac94-f78a8b24a4d1): Implement M1 account and session module with e2e tests
- Add AuthApi with 5 endpoints (LoginByAccount, LoginByMobile, etc.) - Implement dual-layer envelope unwrapping in ApiCaller for HTTP 500 + ASP.NET exceptions - Add DynamicBaseUrlInterceptor for runtime server switching - Implement SessionRepository with login state management and token persistence - Add Mine/Login/Config/ChangePassword UI with ViewBinding and state machines - Replace placeholder MineFragment with business implementation - Hide bottom navigation when sub-pages are on back stack - Add 33 new strings and login_error color resource - Provide AuthApi in NetworkModule DI - Verified: 5/5 e2e negative tests passed, dual unwrapping works on real responses
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package com.stec.cmd.core.network
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* 业务调用安全门面:统一把「传输层异常 / HTTP 错误 / 业务失败码」收敛为 [ApiError]。
|
||||
*
|
||||
* ⚠ 平台契约陷阱(e2e 实测确认):业务失败时服务器返回 **HTTP 500 + ASP.NET
|
||||
* 异常包装**,业务信封 JSON 以字符串嵌在 `ExceptionMessage` 字段:
|
||||
* `{"Message":"发生错误。","ExceptionMessage":"{\"code\":\"500\",\"msg\":\"...\",\"data\":null}",...}`
|
||||
* 本类负责解包,UI 层只捕获 [ApiError]。
|
||||
*/
|
||||
@Singleton
|
||||
class ApiCaller @Inject constructor(
|
||||
private val json: Json,
|
||||
) {
|
||||
|
||||
/** 执行并取业务数据;任何失败路径都以 [ApiError] 子类抛出。 */
|
||||
suspend fun <T> call(block: suspend () -> ApiEnvelope<T>): T {
|
||||
val envelope = try {
|
||||
block()
|
||||
} catch (e: HttpException) {
|
||||
throw toApiError(e)
|
||||
} catch (e: IOException) {
|
||||
throw ApiError.NetworkError(e)
|
||||
}
|
||||
if (!envelope.isSuccess) throw ApiError.fromCode(envelope.code, envelope.message)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return envelope.data as? T
|
||||
?: (Unit as? T)
|
||||
?: throw ApiError.EmptyBodyError(envelope.message)
|
||||
}
|
||||
|
||||
/** HTTP 层错误 → 解析业务信封 → [ApiError];解不出信封时退化为 [ApiError.ServerError]。 */
|
||||
private fun toApiError(e: HttpException): ApiError {
|
||||
val body = try {
|
||||
e.response()?.errorBody()?.string()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
parseEnvelope(body)?.let { raw ->
|
||||
return ApiError.fromCode(raw.code, raw.msg)
|
||||
}
|
||||
return ApiError.ServerError(e.code().toString(), "HTTP ${e.code()}")
|
||||
}
|
||||
|
||||
/** 兼容两种错误体:裸业务信封,或 ASP.NET 异常包装内嵌信封。 */
|
||||
private fun parseEnvelope(body: String?): RawEnvelope? {
|
||||
val trimmed = body?.trim().orEmpty()
|
||||
if (!trimmed.startsWith("{")) return null
|
||||
val direct = runCatching {
|
||||
json.decodeFromString(RawEnvelope.serializer(), trimmed)
|
||||
}.getOrNull() ?: return null
|
||||
if (direct.code != null) return direct
|
||||
|
||||
val embedded = direct.ExceptionMessage
|
||||
?: runCatching {
|
||||
json.parseToJsonElement(trimmed).jsonObject["ExceptionMessage"]?.jsonPrimitive?.content
|
||||
}.getOrNull()
|
||||
?: return null
|
||||
return runCatching {
|
||||
json.decodeFromString(RawEnvelope.serializer(), embedded)
|
||||
}.getOrNull()?.takeIf { it.code != null }
|
||||
}
|
||||
|
||||
/** 错误体宽容模型:信封字段与 ASP.NET 包装字段并存。 */
|
||||
@Serializable
|
||||
private data class RawEnvelope(
|
||||
val code: String? = null,
|
||||
val msg: String? = null,
|
||||
val data: JsonElement? = null,
|
||||
val ExceptionMessage: String? = null,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.stec.cmd.core.network
|
||||
|
||||
import java.io.IOException
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* 运行期可配置 baseUrl(M1-06 / M7-04):
|
||||
* Retrofit 实例以占位 baseUrl 构建(Retrofit 要求构造期给出合法 URL 且不可变更),
|
||||
* 真实目标地址在请求期取自 [NetworkConfig.baseUrl] 快照动态改写,
|
||||
* 从而支持「不换包更换服务器/环境」。
|
||||
*
|
||||
* 未配置时快速失败为 IOException(→ ApiError.NetworkError),文案引导配置页。
|
||||
*/
|
||||
class DynamicBaseUrlInterceptor(
|
||||
private val config: NetworkConfig,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val base = config.baseUrl.toHttpUrlOrNull()
|
||||
?: throw IOException("服务器地址未配置,请先在「我的-服务器配置」完成配置")
|
||||
val suffix = request.url.encodedPath + (request.url.query?.let { "?$it" } ?: "")
|
||||
val target = base.resolve(suffix)
|
||||
?: throw IOException("服务器地址无效:${config.baseUrl}")
|
||||
return chain.proceed(request.newBuilder().url(target).build())
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.stec.cmd.core.network
|
||||
|
||||
import com.stec.cmd.core.network.api.AuthApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -15,7 +16,9 @@ import javax.inject.Singleton
|
||||
* 网络层 Hilt 装配。
|
||||
*
|
||||
* [NetworkConfig] 的实现由 app 壳通过 @Binds 提供(配置不进代码库);
|
||||
* Retrofit Service 接口在 S1+ 各业务模块中声明后直接注入使用。
|
||||
* Retrofit 以占位 baseUrl 构建,真实地址由 [DynamicBaseUrlInterceptor] 请求期
|
||||
* 从配置快照改写(运行期可换服务器,M1-06);
|
||||
* Retrofit Service 接口在 S1+ 各业务模块中按 [provideAuthApi] 模式补充。
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@@ -38,6 +41,7 @@ object NetworkModule {
|
||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.addInterceptor(DynamicBaseUrlInterceptor(config))
|
||||
.addInterceptor(AuthInterceptor(config))
|
||||
|
||||
// 外业调试期保留 BODY 级日志;发布构建由 proguard 移除该拦截器装配
|
||||
@@ -55,13 +59,20 @@ object NetworkModule {
|
||||
fun provideRetrofit(
|
||||
client: OkHttpClient,
|
||||
json: Json,
|
||||
config: NetworkConfig,
|
||||
): Retrofit = Retrofit.Builder()
|
||||
.baseUrl(config.baseUrl)
|
||||
.baseUrl(PLACEHOLDER_BASE_URL)
|
||||
.client(client)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
/** 认证与会话接口(M1)。 */
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
||||
|
||||
/** 仅满足 Retrofit 构造约束;真实地址请求期经 DynamicBaseUrlInterceptor 改写。 */
|
||||
private const val PLACEHOLDER_BASE_URL = "https://placeholder.stec.invalid/"
|
||||
|
||||
private const val CONNECT_TIMEOUT_SECONDS = 15L
|
||||
private const val READ_TIMEOUT_SECONDS = 30L
|
||||
private const val WRITE_TIMEOUT_SECONDS = 30L
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.stec.cmd.core.network.api
|
||||
|
||||
import com.stec.cmd.core.network.ApiEnvelope
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* 平台认证与会话接口(接口管理规范 V4.5 · 测试环境 https://jcd.stec.p-q.co):
|
||||
* - 2.2.1 账号密码登录(M1-01)
|
||||
* - 2.2.2 发送短信验证码(M1-02)
|
||||
* - 2.2.3 手机验证码登录(M1-03)
|
||||
* - 2.3.1 修改登录密码(M1-04)
|
||||
* - 2.4 获取当前用户信息(M1-05)
|
||||
*
|
||||
* 三要素请求头(SecretKey/SystemCode/Token)由 AuthInterceptor 统一注入;
|
||||
* 登录/验证码接口未登录时天然不携带 Token(接口文档 1.2.3)。
|
||||
*/
|
||||
interface AuthApi {
|
||||
|
||||
/** 2.2.1 账号密码登录:成功 data 为 Token 字符串。 */
|
||||
@POST(PATH_LOGIN_BY_ACCOUNT)
|
||||
suspend fun loginByAccount(
|
||||
@Body request: PasswordLoginRequest,
|
||||
): ApiEnvelope<String>
|
||||
|
||||
/**
|
||||
* 2.2.2 发送短信验证码:[mobilePhone] 须已按约定归一
|
||||
* (国内号 11 位明文;国际号 "+" 预转 "%2B",[encoded] = true)。
|
||||
* data 形态文档未定死("result:true" 字样),以 [JsonElement] 宽容承载。
|
||||
*/
|
||||
@GET(PATH_LOGIN_SECURITY_CODE)
|
||||
suspend fun sendSecurityCode(
|
||||
@Query(QUERY_MOBILE_PHONE, encoded = true) mobilePhone: String,
|
||||
): ApiEnvelope<JsonElement>
|
||||
|
||||
/** 2.2.3 手机验证码登录:成功 data 为 Token 字符串。 */
|
||||
@POST(PATH_LOGIN_BY_MOBILE)
|
||||
suspend fun loginByMobile(
|
||||
@Body request: MobileLoginRequest,
|
||||
): ApiEnvelope<String>
|
||||
|
||||
/** 2.3.1 修改登录密码:登录态下操作,成功 data 为 null。 */
|
||||
@POST(PATH_MODIFY_PASSWORD)
|
||||
suspend fun modifyPassword(
|
||||
@Body request: ChangePasswordRequest,
|
||||
): ApiEnvelope<Unit>
|
||||
|
||||
/** 2.4 获取当前用户信息:需登录态。 */
|
||||
@GET(PATH_GET_USER_INFO)
|
||||
suspend fun getUserInfo(): ApiEnvelope<UserInfo>
|
||||
|
||||
companion object {
|
||||
// ---- 接口管理规范 V4.5 · 章节 2.1 所有 API 目录 ----
|
||||
const val PATH_LOGIN_BY_ACCOUNT = "OutWebApi/api/LoginByAccount"
|
||||
const val PATH_LOGIN_SECURITY_CODE = "OutWebApi/api/LoginSecurityCode"
|
||||
const val PATH_LOGIN_BY_MOBILE = "OutWebApi/api/LoginByMobile"
|
||||
const val PATH_MODIFY_PASSWORD = "OutWebApi/api/ModifyPassword"
|
||||
const val PATH_GET_USER_INFO = "OutWebApi/api/GetUserInfo"
|
||||
|
||||
/** 2.2.2 查询参数名。 */
|
||||
const val QUERY_MOBILE_PHONE = "MobilePhone"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.stec.cmd.core.network.api
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 认证与会话 DTO(接口管理规范 V4.5)。
|
||||
*
|
||||
* 字段名与平台契约一致(PascalCase);响应侧均留默认值以容忍字段演进
|
||||
* (配合 Json.ignoreUnknownKeys)。
|
||||
*/
|
||||
|
||||
/** 2.2.1 账号密码登录请求(章节 2.2.1.4.1 UserLoginData)。 */
|
||||
@Serializable
|
||||
data class PasswordLoginRequest(
|
||||
val LoginName: String,
|
||||
val Password: String,
|
||||
)
|
||||
|
||||
/** 2.2.3 手机验证码登录请求(章节 2.2.3.4.1 MobileMsg)。 */
|
||||
@Serializable
|
||||
data class MobileLoginRequest(
|
||||
val Mobile: String,
|
||||
val VerificationCode: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 2.3.1 修改登录密码请求(章节 2.3.1.4.1 UserLoginData)。
|
||||
*
|
||||
* ⚠ 平台字段语义:[Password] = **原登录密码**,[ConfirmAnswer] = **新密码**
|
||||
* (接口文档原文如此,勿按字面理解)。
|
||||
*/
|
||||
@Serializable
|
||||
data class ChangePasswordRequest(
|
||||
val Password: String,
|
||||
val ConfirmAnswer: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* 2.4 当前用户信息(章节 2.4.6)。
|
||||
* 文档仅约定 4 属性;头像等扩展字段后续由平台补充(ignoreUnknownKeys 容忍)。
|
||||
*/
|
||||
@Serializable
|
||||
data class UserInfo(
|
||||
val Name: String? = null,
|
||||
val DeptName: String? = null,
|
||||
val MobilePhone: String? = null,
|
||||
val LoginName: String? = null,
|
||||
)
|
||||
Reference in New Issue
Block a user