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:
阿猫
2026-09-03 13:38:08 +08:00
parent 36479373e0
commit ee8238020a
25 changed files with 1564 additions and 10 deletions

View File

@@ -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,
)
}

View File

@@ -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
/**
* 运行期可配置 baseUrlM1-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())
}
}

View File

@@ -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

View File

@@ -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 形态文档未定死("resulttrue" 字样),以 [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"
}
}

View File

@@ -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,
)