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

@@ -1,5 +1,7 @@
package com.stec.cmd package com.stec.cmd
import com.stec.cmd.feature.mine.MineFragment
/** /**
* 底部导航 5 Tab 与业务模块(功能明细表编号)的挂载点注册中心。 * 底部导航 5 Tab 与业务模块(功能明细表编号)的挂载点注册中心。
* *

View File

@@ -2,6 +2,7 @@ package com.stec.cmd
import android.os.Bundle import android.os.Bundle
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.commit import androidx.fragment.app.commit
@@ -11,6 +12,9 @@ import dagger.hilt.android.AndroidEntryPoint
/** /**
* 单 Activity 导航壳BottomNavigationView 5 Tab 切换 Fragment。 * 单 Activity 导航壳BottomNavigationView 5 Tab 切换 Fragment。
* show/hide 复用实例以保留各 Tab 视图状态S1 可迁移 Navigation 组件。 * show/hide 复用实例以保留各 Tab 视图状态S1 可迁移 Navigation 组件。
*
* 子页面(登录/配置/改密等)经 addToBackStack 压栈并隐藏底栏,
* 返回栈清空后底栏恢复S1·M1
*/ */
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -23,6 +27,10 @@ class MainActivity : AppCompatActivity() {
setContentView(binding.root) setContentView(binding.root)
binding.bottomNav.setOnItemSelectedListener(::selectTab) binding.bottomNav.setOnItemSelectedListener(::selectTab)
supportFragmentManager.addOnBackStackChangedListener {
val hasSubPage = supportFragmentManager.backStackEntryCount > 0
binding.bottomNav.visibility = if (hasSubPage) View.GONE else View.VISIBLE
}
if (savedInstanceState == null) { if (savedInstanceState == null) {
binding.bottomNav.selectedItemId = FeatureMount.Tab.HOME.itemId binding.bottomNav.selectedItemId = FeatureMount.Tab.HOME.itemId
} }

View File

@@ -1,7 +0,0 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 我的占位M1 登录与配置S1 挂载登录/配置页)。 */
@AndroidEntryPoint
class MineFragment : PlaceholderFragment()

View File

@@ -0,0 +1,13 @@
package com.stec.cmd.feature.mine
import com.stec.cmd.core.network.ApiError
/** [ApiError] → 用户可读文案429/403 语义对齐接口文档 1.1,见待确认问题 #3。 */
fun ApiError.userMessage(): String = when (this) {
is ApiError.TooManyRequests -> message ?: "操作过于频繁,请稍后再试"
is ApiError.Locked -> message ?: "连续失败次数过多,请稍后再试"
is ApiError.NetworkError ->
cause?.message?.takeIf { it.startsWith("服务器地址") } ?: "网络连接异常,请检查网络后重试"
is ApiError.EmptyBodyError -> message ?: "服务返回数据异常"
is ApiError.ServerError -> message?.takeIf { it.isNotBlank() } ?: "操作失败,请稍后重试"
}

View File

@@ -0,0 +1,69 @@
package com.stec.cmd.feature.mine
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.stec.cmd.R
import com.stec.cmd.core.common.UiState
import com.stec.cmd.databinding.FragmentChangePasswordBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
/** 修改登录密码页M1-042.3.1):成功后本地登出,需用新密码重登。 */
@AndroidEntryPoint
class ChangePasswordFragment : Fragment() {
private var _binding: FragmentChangePasswordBinding? = null
private val binding get() = checkNotNull(_binding)
private val viewModel: ChangePasswordViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentChangePasswordBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() }
binding.btnSubmit.setOnClickListener {
viewModel.submit(
oldPassword = binding.etOldPassword.text?.toString().orEmpty(),
newPassword = binding.etNewPassword.text?.toString().orEmpty(),
confirmPassword = binding.etConfirmPassword.text?.toString().orEmpty(),
)
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
binding.progress.isVisible = state is UiState.Loading
binding.btnSubmit.isEnabled = state !is UiState.Loading
when (state) {
is UiState.Error -> binding.tvMessage.text = state.message
is UiState.Success -> {
binding.tvMessage.text = getString(R.string.change_password_success)
binding.btnBack.callOnClick()
}
else -> Unit
}
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,46 @@
package com.stec.cmd.feature.mine
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.stec.cmd.core.common.UiState
import com.stec.cmd.core.network.ApiError
import com.stec.cmd.session.SessionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/** 修改登录密码M1-042.3.1):登录态下操作,成功后登出用新密码重登。 */
@HiltViewModel
class ChangePasswordViewModel @Inject constructor(
private val sessionRepository: SessionRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow<UiState<Unit>>(UiState.Idle)
val uiState: StateFlow<UiState<Unit>> = _uiState.asStateFlow()
fun submit(oldPassword: String, newPassword: String, confirmPassword: String) {
val validationError = when {
oldPassword.isBlank() || newPassword.isBlank() -> "请输入原密码与新密码"
newPassword.length < MIN_LENGTH -> "新密码至少 ${MIN_LENGTH}"
newPassword != confirmPassword -> "两次输入的新密码不一致"
else -> null
}
if (validationError != null) {
_uiState.value = UiState.Error(validationError)
return
}
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
// 平台字段Password=原密码ConfirmAnswer=新密码(接口文档 2.3.1.4.1
sessionRepository.changePassword(oldPassword, newPassword)
_uiState.value = UiState.Success(Unit)
} catch (e: ApiError) {
_uiState.value = UiState.Error(e.userMessage())
}
}
}
private companion object {
const val MIN_LENGTH = 6
}
}

View File

@@ -0,0 +1,79 @@
package com.stec.cmd.feature.mine
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.stec.cmd.R
import com.stec.cmd.core.common.UiState
import com.stec.cmd.databinding.FragmentConfigBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
/** 服务器与密钥配置页M1-06仅存本机 DataStore不入代码库M7-04。 */
@AndroidEntryPoint
class ConfigFragment : Fragment() {
private var _binding: FragmentConfigBinding? = null
private val binding get() = checkNotNull(_binding)
private val viewModel: ConfigViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentConfigBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() }
binding.btnSave.setOnClickListener {
viewModel.save(
baseUrl = binding.etBaseUrl.text?.toString().orEmpty(),
secretKey = binding.etSecretKey.text?.toString().orEmpty(),
systemCode = binding.etSystemCode.text?.toString().orEmpty(),
)
}
// 进入页面回填当前配置
lifecycleScope.launch {
viewModel.snapshot.collect { snapshot ->
if (binding.etBaseUrl.text.isNullOrBlank() && snapshot.baseUrl.isNotBlank()) {
binding.etBaseUrl.setText(snapshot.baseUrl)
binding.etSecretKey.setText(snapshot.secretKey)
binding.etSystemCode.setText(snapshot.systemCode)
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.saveState.collect { state ->
binding.progress.isVisible = state is UiState.Loading
binding.btnSave.isEnabled = state !is UiState.Loading
when (state) {
is UiState.Error -> binding.tvMessage.text = state.message
is UiState.Success -> {
binding.tvMessage.text = getString(R.string.config_saved)
parentFragmentManager.popBackStack()
}
else -> Unit
}
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,41 @@
package com.stec.cmd.feature.mine
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.stec.cmd.config.ConfigRepository
import com.stec.cmd.core.common.UiState
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* 服务器与密钥配置M1-06M7-04 落地入口):
* baseUrl/SecretKey/SystemCode 仅存本机 DataStore不入代码库。
*/
@HiltViewModel
class ConfigViewModel @Inject constructor(
private val configRepository: ConfigRepository,
) : ViewModel() {
/** 当前配置快照(进入页面回填表单)。 */
val snapshot = configRepository.snapshotStateFlow
private val _saveState = MutableStateFlow<UiState<Unit>>(UiState.Idle)
val saveState: StateFlow<UiState<Unit>> = _saveState.asStateFlow()
fun save(baseUrl: String, secretKey: String, systemCode: String) {
when {
baseUrl.isBlank() || secretKey.isBlank() || systemCode.isBlank() ->
_saveState.value = UiState.Error("服务器地址、SecretKey、SystemCode 均为必填")
!baseUrl.trim().startsWith("http://") && !baseUrl.trim().startsWith("https://") ->
_saveState.value = UiState.Error("服务器地址需以 http:// 或 https:// 开头")
else -> viewModelScope.launch {
try {
configRepository.updateConnection(baseUrl, secretKey, systemCode)
_saveState.value = UiState.Success(Unit)
} catch (e: Exception) {
_saveState.value = UiState.Error("保存失败:${e.message}")
}
}
}
}
}

View File

@@ -0,0 +1,111 @@
package com.stec.cmd.feature.mine
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.stec.cmd.R
import com.stec.cmd.core.common.UiState
import com.stec.cmd.databinding.FragmentLoginBinding
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
/** 登录页M1-01 账号密码 / M1-02 获取验证码 / M1-03 验证码登录)。 */
@AndroidEntryPoint
class LoginFragment : Fragment() {
private var _binding: FragmentLoginBinding? = null
private val binding get() = checkNotNull(_binding)
private val viewModel: LoginViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentLoginBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() }
binding.btnSendCode.setOnClickListener { viewModel.sendCode() }
binding.btnLogin.setOnClickListener { viewModel.login() }
binding.rbAccount.setOnClickListener { viewModel.updateMode(LoginMode.ACCOUNT) }
binding.rbSms.setOnClickListener { viewModel.updateMode(LoginMode.SMS) }
binding.etLoginName.doAfterTextChanged(viewModel::updateLoginName)
binding.etPassword.doAfterTextChanged(viewModel::updatePassword)
binding.etPhone.doAfterTextChanged(viewModel::updatePhone)
binding.etSmsCode.doAfterTextChanged(viewModel::updateSmsCode)
observeStates()
}
private fun observeStates() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch {
viewModel.form.collect { form ->
val accountMode = form.mode == LoginMode.ACCOUNT
if (binding.rbAccount.isChecked != accountMode) {
binding.radioGroup.check(
if (accountMode) binding.rbAccount.id else binding.rbSms.id,
)
}
binding.groupAccount.isVisible = accountMode
binding.groupSms.isVisible = !accountMode
}
}
launch {
viewModel.countdown.collect { seconds ->
binding.btnSendCode.isEnabled = seconds <= 0
binding.btnSendCode.text =
if (seconds > 0) getString(R.string.login_countdown_fmt, seconds)
else getString(R.string.login_send_code)
}
}
launch {
viewModel.uiState.collect { state ->
binding.progress.isVisible = state is UiState.Loading
binding.btnLogin.isEnabled = state !is UiState.Loading
when (state) {
is UiState.Error -> {
binding.tvMessage.text = state.message
binding.tvMessage.setTextColor(
resources.getColor(R.color.login_error, requireContext().theme),
)
}
is UiState.Success -> {
// SessionRepository 已持久化 Token返回「我的」
parentFragmentManager.popBackStack()
}
else -> Unit
}
}
}
launch {
viewModel.info.collect { info ->
binding.tvMessage.text = info.orEmpty()
binding.tvMessage.setTextColor(
resources.getColor(R.color.brand_primary, requireContext().theme),
)
}
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@@ -0,0 +1,140 @@
package com.stec.cmd.feature.mine
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import com.stec.cmd.config.ConfigRepository
import com.stec.cmd.core.common.UiState
import com.stec.cmd.core.network.ApiError
import com.stec.cmd.session.Phones
import com.stec.cmd.session.SessionRepository
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/** 登录方式M1-01 账号密码 / M1-03 短信验证码。 */
enum class LoginMode { ACCOUNT, SMS }
/**
* 登录页状态M1-01/02/03
* 表单 + 提交状态([UiState]+ 验证码 60s 倒计时。
*/
@HiltViewModel
class LoginViewModel @Inject constructor(
private val sessionRepository: SessionRepository,
private val configRepository: ConfigRepository,
) : ViewModel() {
data class Form(
val mode: LoginMode = LoginMode.ACCOUNT,
val loginName: String = "",
val password: String = "",
val phone: String = "",
val smsCode: String = "",
)
private val _form = MutableStateFlow(Form())
val form: StateFlow<Form> = _form.asStateFlow()
private val _uiState = MutableStateFlow<UiState<Unit>>(UiState.Idle)
val uiState: StateFlow<UiState<Unit>> = _uiState.asStateFlow()
/** 获取验证码倒计时0 = 可发送。 */
private val _countdown = MutableStateFlow(0)
val countdown: StateFlow<Int> = _countdown.asStateFlow()
/** 非阻断提示(如「验证码已发送」)。 */
private val _info = MutableStateFlow<String?>(null)
val info: StateFlow<String?> = _info.asStateFlow()
fun updateMode(mode: LoginMode) {
_form.value = _form.value.copy(mode = mode)
_uiState.value = UiState.Idle
_info.value = null
}
fun updateLoginName(value: String) { _form.value = _form.value.copy(loginName = value) }
fun updatePassword(value: String) { _form.value = _form.value.copy(password = value) }
fun updatePhone(value: String) { _form.value = _form.value.copy(phone = value) }
fun updateSmsCode(value: String) { _form.value = _form.value.copy(smsCode = value) }
/** M1-02 发送验证码:国内号全匹配校验 + 60s 防重发。 */
fun sendCode() {
val phone = _form.value.phone
if (!Phones.isDomesticMobile(phone)) {
_uiState.value = UiState.Error(ERROR_BAD_PHONE)
return
}
if (_countdown.value > 0) return
if (!configReady()) return
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
sessionRepository.sendSecurityCode(Phones.toQueryValue(Phones.normalize(phone)))
_uiState.value = UiState.Idle
_info.value = "验证码已发送,请查收短信"
startCountdown()
} catch (e: ApiError) {
_uiState.value = UiState.Error(e.userMessage())
}
}
}
/** 提交登录。成功后 [SessionRepository] 已持久化 TokenUI 收到 [UiState.Success]。 */
fun login() {
val f = _form.value
val validationError = when (f.mode) {
LoginMode.ACCOUNT ->
if (f.loginName.isBlank() || f.password.isBlank()) "请输入账号与密码" else null
LoginMode.SMS ->
when {
!Phones.isDomesticMobile(f.phone) -> ERROR_BAD_PHONE
f.smsCode.isBlank() -> "请输入验证码"
else -> null
}
}
if (validationError != null) {
_uiState.value = UiState.Error(validationError)
return
}
if (!configReady()) return
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
when (f.mode) {
LoginMode.ACCOUNT ->
sessionRepository.loginByAccount(f.loginName.trim(), f.password)
LoginMode.SMS ->
sessionRepository.loginByMobile(Phones.normalize(f.phone), f.smsCode.trim())
}
_uiState.value = UiState.Success(Unit)
} catch (e: ApiError) {
_uiState.value = UiState.Error(e.userMessage())
}
}
}
private fun configReady(): Boolean {
if (configRepository.snapshotStateFlow.value.isReady) return true
_uiState.value = UiState.Error("请先在「我的-服务器配置」填写服务器地址与密钥")
return false
}
private fun startCountdown() {
viewModelScope.launch {
for (seconds in COUNTDOWN_SECONDS downTo 1) {
_countdown.value = seconds
delay(1_000)
}
_countdown.value = 0
}
}
private companion object {
const val ERROR_BAD_PHONE = "请输入正确的国内手机号"
const val COUNTDOWN_SECONDS = 60
}
}

View File

@@ -0,0 +1,113 @@
package com.stec.cmd.feature.mine
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.stec.cmd.R
import com.stec.cmd.databinding.FragmentMineBinding
import com.stec.cmd.session.SessionState
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
/**
* 「我的」业务页M1-05 用户信息 / M1-06 配置入口 / M1-04 改密入口 / M1-07 退出登录)。
* 未配置三要素 → 引导配置页;未登录 → 引导登录页。
*/
@AndroidEntryPoint
class MineFragment : Fragment() {
private var _binding: FragmentMineBinding? = null
private val binding get() = checkNotNull(_binding)
private val viewModel: MineViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
_binding = FragmentMineBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() }
binding.btnLoginOrLogout.setOnClickListener { onPrimaryAction() }
binding.btnChangePassword.setOnClickListener { openOnTop(ChangePasswordFragment(), TAG_CHANGE_PASSWORD) }
binding.btnOpenConfig.setOnClickListener { openOnTop(ConfigFragment(), TAG_CONFIG) }
binding.btnGoConfig.setOnClickListener { openOnTop(ConfigFragment(), TAG_CONFIG) }
binding.btnRefresh.setOnClickListener { viewModel.refresh() }
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
launch { viewModel.sessionState.collect(::renderSession) }
launch { viewModel.configSnapshot.collect(::renderConfig) }
}
}
}
private fun renderSession(state: SessionState) {
when (state) {
is SessionState.SignedIn -> {
binding.cardUser.isVisible = true
binding.tvName.text = state.user.Name ?: getString(R.string.mine_unknown_user)
binding.tvDept.text = getString(R.string.mine_dept_fmt, state.user.DeptName ?: "-")
binding.tvAccount.text = getString(R.string.mine_account_fmt, state.user.LoginName ?: "-")
binding.tvPhone.text = getString(R.string.mine_phone_fmt, state.user.MobilePhone ?: "-")
binding.btnLoginOrLogout.setText(R.string.mine_logout)
binding.btnChangePassword.isEnabled = true
}
is SessionState.SignedOut -> {
binding.cardUser.isVisible = true
binding.tvName.setText(R.string.mine_not_logged_in)
binding.tvDept.text = getString(R.string.mine_dept_fmt, "-")
binding.tvAccount.text = getString(R.string.mine_account_fmt, "-")
binding.tvPhone.text = getString(R.string.mine_phone_fmt, "-")
binding.btnLoginOrLogout.setText(R.string.mine_login)
binding.btnChangePassword.isEnabled = false
}
}
}
private fun renderConfig(snapshot: com.stec.cmd.config.ConfigRepository.Snapshot) {
binding.tvConfigStatus.setText(
if (snapshot.isReady) R.string.mine_config_ready else R.string.mine_config_missing,
)
binding.btnGoConfig.isVisible = !snapshot.isReady
}
private fun onPrimaryAction() {
when (viewModel.sessionState.value) {
is SessionState.SignedIn -> viewModel.logout()
is SessionState.SignedOut -> openOnTop(LoginFragment(), TAG_LOGIN)
}
}
private fun openOnTop(fragment: Fragment, tag: String) {
parentFragmentManager.commit {
setReorderingAllowed(true)
add(R.id.fragment_container, fragment, tag)
hide(this@MineFragment)
addToBackStack(null)
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private companion object {
const val TAG_LOGIN = "login"
const val TAG_CONFIG = "config"
const val TAG_CHANGE_PASSWORD = "change_password"
}
}

View File

@@ -0,0 +1,34 @@
package com.stec.cmd.feature.mine
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.stec.cmd.config.ConfigRepository
import com.stec.cmd.session.SessionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* 「我的」页M1-05/06/07登录态 + 连接配置状态双流渲染。
*/
@HiltViewModel
class MineViewModel @Inject constructor(
private val sessionRepository: SessionRepository,
configRepository: ConfigRepository,
) : ViewModel() {
/** 会话状态流。 */
val sessionState = sessionRepository.state
/** 连接配置快照流(未配置时引导配置页)。 */
val configSnapshot = configRepository.snapshotStateFlow
/** 手动刷新:按存量 Token 重新拉取用户信息。 */
fun refresh() {
viewModelScope.launch { sessionRepository.restore() }
}
/** M1-07 退出登录。 */
fun logout() {
viewModelScope.launch { sessionRepository.logout() }
}
}

View File

@@ -0,0 +1,36 @@
package com.stec.cmd.session
/**
* 手机号与 Token 归一工具(接口管理规范 2.2.2 / 2.2.3 约定)。
*
* - 登录校验仅支持国内手机号「全匹配」;
* - 2.2.2 发送验证码支持国际号,"+" 需转 "%2B" 否则发送不成功。
*/
object Phones {
/** 国内手机号全匹配11 位1 开头,第二位 3-9。 */
private val DOMESTIC = Regex("^1[3-9]\\d{9}$")
/** 归一输入:去空白与分隔符,剥离 +86 / 86 前缀。 */
fun normalize(input: String): String {
val cleaned = input.trim().filterNot { it == ' ' || it == '-' }
return when {
cleaned.startsWith("+86") -> cleaned.removePrefix("+86")
cleaned.startsWith("86") && cleaned.length == 13 -> cleaned.removePrefix("86")
else -> cleaned
}
}
/** 是否国内手机号(归一后全匹配)。 */
fun isDomesticMobile(input: String): Boolean = DOMESTIC.matches(normalize(input))
/** 2.2.2 查询值:国内号明文;国际号 "+" 预转 "%2B"(配合 @Query(encoded = true))。 */
fun toQueryValue(input: String): String {
val trimmed = input.trim()
return if (trimmed.startsWith("+")) trimmed.replace("+", "%2B") else trimmed
}
/** 登录 data 归一:剥除可能的 "Token" / "Token:" 前缀与首尾空白。 */
fun normalizeToken(raw: String): String =
raw.trim().removePrefix("Token").trim().removePrefix("Token:").trim()
}

View File

@@ -0,0 +1,114 @@
package com.stec.cmd.session
import com.stec.cmd.config.ConfigRepository
import com.stec.cmd.core.network.ApiCaller
import com.stec.cmd.core.network.ApiError
import com.stec.cmd.core.network.api.AuthApi
import com.stec.cmd.core.network.api.ChangePasswordRequest
import com.stec.cmd.core.network.api.MobileLoginRequest
import com.stec.cmd.core.network.api.PasswordLoginRequest
import com.stec.cmd.core.network.api.UserInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/** 会话状态UI 只读。 */
sealed class SessionState {
/** 未登录(含 Token 失效被清)。 */
data object SignedOut : SessionState()
/** 已登录携带当前用户信息2.4)。 */
data class SignedIn(val user: UserInfo) : SessionState()
}
/**
* 会话门面M1-01/02/03/04/05/07
* 登录 → Token 持久化ConfigRepository/DataStore→ 拉取用户信息进入 SignedIn
* 登出 / 改密成功 → 清 Token 回 SignedOut冷启动凭存量 Token 自动恢复会话。
*/
@Singleton
class SessionRepository @Inject constructor(
private val authApi: AuthApi,
private val apiCaller: ApiCaller,
private val configRepository: ConfigRepository,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _state = MutableStateFlow<SessionState>(SessionState.SignedOut)
/** 登录态流Mine 等页面订阅渲染。 */
val state: StateFlow<SessionState> = _state.asStateFlow()
init {
scope.launch { restore() }
}
/**
* 冷启动 / 手动刷新:本地有 Token 则拉用户信息复原会话。
* 仅业务性失败Token 失效等)清 Token传输层失败保留 Token 待网络恢复后重试。
*/
suspend fun restore() {
if (configRepository.currentToken().isBlank()) {
_state.value = SessionState.SignedOut
return
}
_state.value = try {
SessionState.SignedIn(apiCaller.call { authApi.getUserInfo() })
} catch (e: ApiError) {
if (e !is ApiError.NetworkError) {
// 待确认问题 #2 缺省口径Token 失效按重新登录处理
configRepository.updateToken("")
}
SessionState.SignedOut
}
}
/** M1-01 账号密码登录2.2.1)。 */
suspend fun loginByAccount(loginName: String, password: String) {
val token = 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 {
authApi.loginByMobile(MobileLoginRequest(mobile, verificationCode))
}
signIn(token)
}
/** M1-02 发送验证码2.2.2[mobilePhone] 传 [Phones.toQueryValue] 归一结果。 */
suspend fun sendSecurityCode(mobilePhone: String) {
apiCaller.call { authApi.sendSecurityCode(mobilePhone) }
}
/** M1-04 修改密码2.3.1):平台成功后本地登出,用新密码重登。 */
suspend fun changePassword(oldPassword: String, newPassword: String) {
apiCaller.call {
authApi.modifyPassword(ChangePasswordRequest(oldPassword, newPassword))
}
logout()
}
/** M1-07 退出登录:平台无注销端点,本地清 Token 即完成。 */
suspend fun logout() {
configRepository.updateToken("")
_state.value = SessionState.SignedOut
}
private suspend fun signIn(rawToken: String) {
val token = Phones.normalizeToken(rawToken)
configRepository.updateToken(token)
_state.value = SessionState.SignedIn(apiCaller.call { authApi.getUserInfo() })
}
}

View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 修改登录密码页M1-042.3.1):成功后本地登出,需用新密码重登 -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_back"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_back" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/change_password_title"
android:textColor="?attr/colorOnSurface"
android:textSize="24sp"
android:textStyle="bold" />
<EditText
android:id="@+id/et_old_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:hint="@string/change_password_old_hint"
android:imeOptions="actionNext"
android:inputType="textPassword"
android:maxLines="1" />
<EditText
android:id="@+id/et_new_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/change_password_new_hint"
android:imeOptions="actionNext"
android:inputType="textPassword"
android:maxLines="1" />
<EditText
android:id="@+id/et_confirm_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/change_password_confirm_hint"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:maxLines="1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_submit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/change_password_submit" />
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp"
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:id="@+id/tv_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="?attr/colorError"
android:textSize="14sp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 服务器与密钥配置页M1-06仅存本机 DataStore不入代码库M7-04 -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_back"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_back" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/config_title"
android:textColor="?attr/colorOnSurface"
android:textSize="24sp"
android:textStyle="bold" />
<EditText
android:id="@+id/et_base_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:hint="@string/config_base_url_hint"
android:imeOptions="actionNext"
android:inputType="textUri"
android:maxLines="1" />
<EditText
android:id="@+id/et_secret_key"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/config_secret_key_hint"
android:imeOptions="actionNext"
android:inputType="text"
android:maxLines="1" />
<EditText
android:id="@+id/et_system_code"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/config_system_code_hint"
android:imeOptions="actionDone"
android:inputType="text"
android:maxLines="1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_save"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/action_save" />
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp"
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:id="@+id/tv_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="?attr/colorError"
android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:alpha="0.6"
android:text="@string/config_note"
android:textSize="12sp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,160 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 登录页M1-01 账号密码 / M1-02 获取验证码 / M1-03 验证码登录) -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_back"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_back" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/login_title"
android:textColor="?attr/colorOnSurface"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.7"
android:text="@string/login_subtitle"
android:textSize="14sp" />
<RadioGroup
android:id="@+id/radio_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="horizontal">
<RadioButton
android:id="@+id/rb_account"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:checked="true"
android:text="@string/login_mode_account" />
<RadioButton
android:id="@+id/rb_sms"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/login_mode_sms" />
</RadioGroup>
<!-- 账号密码模式 -->
<LinearLayout
android:id="@+id/group_account"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="vertical">
<EditText
android:id="@+id/et_login_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_name_hint"
android:imeOptions="actionNext"
android:inputType="text"
android:maxLines="1" />
<EditText
android:id="@+id/et_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="@string/login_password_hint"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:maxLines="1" />
</LinearLayout>
<!-- 短信验证码模式 -->
<LinearLayout
android:id="@+id/group_sms"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="vertical"
android:visibility="gone">
<EditText
android:id="@+id/et_phone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/login_phone_hint"
android:imeOptions="actionNext"
android:inputType="phone"
android:maxLength="11"
android:maxLines="1" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<EditText
android:id="@+id/et_sms_code"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/login_sms_code_hint"
android:inputType="number"
android:maxLength="6"
android:maxLines="1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_send_code"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/login_send_code" />
</LinearLayout>
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_login"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="@string/login_submit" />
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp"
android:indeterminate="true"
android:visibility="gone" />
<TextView
android:id="@+id/tv_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textColor="?attr/colorError"
android:textSize="14sp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 「我的」业务页M1-05 用户信息 / M1-06 配置入口 / M1-04 改密入口 / M1-07 退出登录) -->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/brand_background"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_back"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/action_back" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/mine_title"
android:textColor="?attr/colorOnSurface"
android:textSize="24sp"
android:textStyle="bold" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_user"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardBackgroundColor="@color/brand_surface"
app:cardCornerRadius="12dp"
app:cardElevation="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tv_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/mine_not_logged_in"
android:textColor="?attr/colorOnSurface"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_dept"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:alpha="0.8"
android:textSize="14sp" />
<TextView
android:id="@+id/tv_account"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.8"
android:textSize="14sp" />
<TextView
android:id="@+id/tv_phone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:alpha="0.8"
android:textSize="14sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/tv_config_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:alpha="0.7"
android:textSize="13sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_go_config"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/mine_go_config"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_login_or_logout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:text="@string/mine_login" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_change_password"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/mine_change_password" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_open_config"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/mine_open_config" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_refresh"
style="?attr/materialButtonTextButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/mine_refresh" />
</LinearLayout>
</ScrollView>

View File

@@ -6,4 +6,5 @@
<color name="brand_background">#F7F9FC</color> <color name="brand_background">#F7F9FC</color>
<color name="brand_surface">#FFFFFF</color> <color name="brand_surface">#FFFFFF</color>
<color name="launcher_background">#1E6FD9</color> <color name="launcher_background">#1E6FD9</color>
<color name="login_error">#B3261E</color>
</resources> </resources>

View File

@@ -9,4 +9,54 @@
<string name="tab_mine">我的</string> <string name="tab_mine">我的</string>
<string name="placeholder_hint">占位页 —— 业务功能按 S1S4 计划经 FeatureMount 挂载</string> <string name="placeholder_hint">占位页 —— 业务功能按 S1S4 计划经 FeatureMount 挂载</string>
<!-- 通用 -->
<string name="action_back">返回</string>
<string name="action_save">保存</string>
<!-- M1 我的M1-05/06/07 -->
<string name="mine_title">我的</string>
<string name="mine_not_logged_in">未登录</string>
<string name="mine_unknown_user">未知用户</string>
<string name="mine_dept_fmt">部门:%1$s</string>
<string name="mine_account_fmt">账号:%1$s</string>
<string name="mine_phone_fmt">手机号:%1$s</string>
<string name="mine_login">登录</string>
<string name="mine_logout">退出登录</string>
<string name="mine_change_password">修改登录密码</string>
<string name="mine_open_config">服务器与密钥配置</string>
<string name="mine_refresh">刷新用户信息</string>
<string name="mine_config_ready">服务器与密钥已配置</string>
<string name="mine_config_missing">尚未配置服务器地址与密钥,无法登录</string>
<string name="mine_go_config">去配置</string>
<string name="mine_logout_confirm">确定退出当前账号?</string>
<!-- M1 登录M1-01/02/03 -->
<string name="login_title">登录</string>
<string name="login_subtitle">上海城建勘测院 · 监测数据不落地平台</string>
<string name="login_mode_account">账号密码</string>
<string name="login_mode_sms">短信验证码</string>
<string name="login_name_hint">登录账号</string>
<string name="login_password_hint">登录密码</string>
<string name="login_phone_hint">手机号(国内 11 位)</string>
<string name="login_sms_code_hint">短信验证码</string>
<string name="login_send_code">获取验证码</string>
<string name="login_countdown_fmt">%1$ds 后重发</string>
<string name="login_submit">登 录</string>
<!-- M1 服务器与密钥配置M1-06 -->
<string name="config_title">服务器与密钥配置</string>
<string name="config_base_url_hint">服务器地址(如 https://jcd.stec.p-q.co</string>
<string name="config_secret_key_hint">SecretKey接口调用秘钥</string>
<string name="config_system_code_hint">SystemCode供应商编码</string>
<string name="config_saved">配置已保存</string>
<string name="config_note">配置仅保存在本机(加密存储),不会随代码库分发;密钥变更后在此更新即可,无需换包。</string>
<!-- M1 修改密码M1-04 -->
<string name="change_password_title">修改登录密码</string>
<string name="change_password_old_hint">原密码</string>
<string name="change_password_new_hint">新密码(至少 6 位)</string>
<string name="change_password_confirm_hint">确认新密码</string>
<string name="change_password_submit">提交修改</string>
<string name="change_password_success">密码已修改,请使用新密码重新登录</string>
</resources> </resources>

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 package com.stec.cmd.core.network
import com.stec.cmd.core.network.api.AuthApi
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -15,7 +16,9 @@ import javax.inject.Singleton
* 网络层 Hilt 装配。 * 网络层 Hilt 装配。
* *
* [NetworkConfig] 的实现由 app 壳通过 @Binds 提供(配置不进代码库); * [NetworkConfig] 的实现由 app 壳通过 @Binds 提供(配置不进代码库);
* Retrofit Service 接口在 S1+ 各业务模块中声明后直接注入使用。 * Retrofit 以占位 baseUrl 构建,真实地址由 [DynamicBaseUrlInterceptor] 请求期
* 从配置快照改写运行期可换服务器M1-06
* Retrofit Service 接口在 S1+ 各业务模块中按 [provideAuthApi] 模式补充。
*/ */
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@@ -38,6 +41,7 @@ object NetworkModule {
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS) .writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.addInterceptor(DynamicBaseUrlInterceptor(config))
.addInterceptor(AuthInterceptor(config)) .addInterceptor(AuthInterceptor(config))
// 外业调试期保留 BODY 级日志;发布构建由 proguard 移除该拦截器装配 // 外业调试期保留 BODY 级日志;发布构建由 proguard 移除该拦截器装配
@@ -55,13 +59,20 @@ object NetworkModule {
fun provideRetrofit( fun provideRetrofit(
client: OkHttpClient, client: OkHttpClient,
json: Json, json: Json,
config: NetworkConfig,
): Retrofit = Retrofit.Builder() ): Retrofit = Retrofit.Builder()
.baseUrl(config.baseUrl) .baseUrl(PLACEHOLDER_BASE_URL)
.client(client) .client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build() .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 CONNECT_TIMEOUT_SECONDS = 15L
private const val READ_TIMEOUT_SECONDS = 30L private const val READ_TIMEOUT_SECONDS = 30L
private const val WRITE_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,
)