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:
@@ -1,5 +1,7 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import com.stec.cmd.feature.mine.MineFragment
|
||||
|
||||
/**
|
||||
* 底部导航 5 Tab 与业务模块(功能明细表编号)的挂载点注册中心。
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.stec.cmd
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
@@ -11,6 +12,9 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
/**
|
||||
* 单 Activity 导航壳:BottomNavigationView 5 Tab 切换 Fragment。
|
||||
* show/hide 复用实例以保留各 Tab 视图状态;S1 可迁移 Navigation 组件。
|
||||
*
|
||||
* 子页面(登录/配置/改密等)经 addToBackStack 压栈并隐藏底栏,
|
||||
* 返回栈清空后底栏恢复(S1·M1)。
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
@@ -23,6 +27,10 @@ class MainActivity : AppCompatActivity() {
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.bottomNav.setOnItemSelectedListener(::selectTab)
|
||||
supportFragmentManager.addOnBackStackChangedListener {
|
||||
val hasSubPage = supportFragmentManager.backStackEntryCount > 0
|
||||
binding.bottomNav.visibility = if (hasSubPage) View.GONE else View.VISIBLE
|
||||
}
|
||||
if (savedInstanceState == null) {
|
||||
binding.bottomNav.selectedItemId = FeatureMount.Tab.HOME.itemId
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/** 我的占位(M1 登录与配置,S1 挂载登录/配置页)。 */
|
||||
@AndroidEntryPoint
|
||||
class MineFragment : PlaceholderFragment()
|
||||
@@ -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() } ?: "操作失败,请稍后重试"
|
||||
}
|
||||
@@ -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-04,2.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
|
||||
}
|
||||
}
|
||||
@@ -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-04,2.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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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-06,M7-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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
111
app/src/main/kotlin/com/stec/cmd/feature/mine/LoginFragment.kt
Normal file
111
app/src/main/kotlin/com/stec/cmd/feature/mine/LoginFragment.kt
Normal 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
|
||||
}
|
||||
}
|
||||
140
app/src/main/kotlin/com/stec/cmd/feature/mine/LoginViewModel.kt
Normal file
140
app/src/main/kotlin/com/stec/cmd/feature/mine/LoginViewModel.kt
Normal 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] 已持久化 Token,UI 收到 [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
|
||||
}
|
||||
}
|
||||
113
app/src/main/kotlin/com/stec/cmd/feature/mine/MineFragment.kt
Normal file
113
app/src/main/kotlin/com/stec/cmd/feature/mine/MineFragment.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
36
app/src/main/kotlin/com/stec/cmd/session/Phones.kt
Normal file
36
app/src/main/kotlin/com/stec/cmd/session/Phones.kt
Normal 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()
|
||||
}
|
||||
114
app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt
Normal file
114
app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt
Normal 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() })
|
||||
}
|
||||
}
|
||||
85
app/src/main/res/layout/fragment_change_password.xml
Normal file
85
app/src/main/res/layout/fragment_change_password.xml
Normal file
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 修改登录密码页(M1-04,2.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>
|
||||
93
app/src/main/res/layout/fragment_config.xml
Normal file
93
app/src/main/res/layout/fragment_config.xml
Normal 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>
|
||||
160
app/src/main/res/layout/fragment_login.xml
Normal file
160
app/src/main/res/layout/fragment_login.xml
Normal 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>
|
||||
130
app/src/main/res/layout/fragment_mine.xml
Normal file
130
app/src/main/res/layout/fragment_mine.xml
Normal 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>
|
||||
@@ -6,4 +6,5 @@
|
||||
<color name="brand_background">#F7F9FC</color>
|
||||
<color name="brand_surface">#FFFFFF</color>
|
||||
<color name="launcher_background">#1E6FD9</color>
|
||||
<color name="login_error">#B3261E</color>
|
||||
</resources>
|
||||
|
||||
@@ -9,4 +9,54 @@
|
||||
<string name="tab_mine">我的</string>
|
||||
|
||||
<string name="placeholder_hint">占位页 —— 业务功能按 S1–S4 计划经 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>
|
||||
|
||||
@@ -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