diff --git a/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt b/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt index ae100ed..7247a1f 100644 --- a/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt +++ b/app/src/main/kotlin/com/stec/cmd/FeatureMount.kt @@ -1,5 +1,7 @@ package com.stec.cmd +import com.stec.cmd.feature.mine.MineFragment + /** * 底部导航 5 Tab 与业务模块(功能明细表编号)的挂载点注册中心。 * diff --git a/app/src/main/kotlin/com/stec/cmd/MainActivity.kt b/app/src/main/kotlin/com/stec/cmd/MainActivity.kt index 41e2f96..128352b 100644 --- a/app/src/main/kotlin/com/stec/cmd/MainActivity.kt +++ b/app/src/main/kotlin/com/stec/cmd/MainActivity.kt @@ -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 } diff --git a/app/src/main/kotlin/com/stec/cmd/MineFragment.kt b/app/src/main/kotlin/com/stec/cmd/MineFragment.kt deleted file mode 100644 index 39e7945..0000000 --- a/app/src/main/kotlin/com/stec/cmd/MineFragment.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.stec.cmd - -import dagger.hilt.android.AndroidEntryPoint - -/** 我的占位(M1 登录与配置,S1 挂载登录/配置页)。 */ -@AndroidEntryPoint -class MineFragment : PlaceholderFragment() diff --git a/app/src/main/kotlin/com/stec/cmd/feature/mine/ApiErrorMessages.kt b/app/src/main/kotlin/com/stec/cmd/feature/mine/ApiErrorMessages.kt new file mode 100644 index 0000000..9c04d38 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/mine/ApiErrorMessages.kt @@ -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() } ?: "操作失败,请稍后重试" +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/mine/ChangePasswordFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/mine/ChangePasswordFragment.kt new file mode 100644 index 0000000..6a24bc0 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/mine/ChangePasswordFragment.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/mine/ChangePasswordViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/mine/ChangePasswordViewModel.kt new file mode 100644 index 0000000..40914ee --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/mine/ChangePasswordViewModel.kt @@ -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.Idle) + val uiState: StateFlow> = _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 + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/mine/ConfigFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/mine/ConfigFragment.kt new file mode 100644 index 0000000..57ee8c0 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/mine/ConfigFragment.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/mine/ConfigViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/mine/ConfigViewModel.kt new file mode 100644 index 0000000..f953f45 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/mine/ConfigViewModel.kt @@ -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.Idle) + val saveState: StateFlow> = _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}") + } + } + } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/mine/LoginFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/mine/LoginFragment.kt new file mode 100644 index 0000000..7231156 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/mine/LoginFragment.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/mine/LoginViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/mine/LoginViewModel.kt new file mode 100644 index 0000000..3927749 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/mine/LoginViewModel.kt @@ -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.asStateFlow() + + private val _uiState = MutableStateFlow>(UiState.Idle) + val uiState: StateFlow> = _uiState.asStateFlow() + + /** 获取验证码倒计时(秒),0 = 可发送。 */ + private val _countdown = MutableStateFlow(0) + val countdown: StateFlow = _countdown.asStateFlow() + + /** 非阻断提示(如「验证码已发送」)。 */ + private val _info = MutableStateFlow(null) + val info: StateFlow = _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 + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/mine/MineFragment.kt b/app/src/main/kotlin/com/stec/cmd/feature/mine/MineFragment.kt new file mode 100644 index 0000000..e644888 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/mine/MineFragment.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/feature/mine/MineViewModel.kt b/app/src/main/kotlin/com/stec/cmd/feature/mine/MineViewModel.kt new file mode 100644 index 0000000..1798e67 --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/feature/mine/MineViewModel.kt @@ -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() } + } +} diff --git a/app/src/main/kotlin/com/stec/cmd/session/Phones.kt b/app/src/main/kotlin/com/stec/cmd/session/Phones.kt new file mode 100644 index 0000000..587889e --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/session/Phones.kt @@ -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() +} diff --git a/app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt b/app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt new file mode 100644 index 0000000..c00c78f --- /dev/null +++ b/app/src/main/kotlin/com/stec/cmd/session/SessionRepository.kt @@ -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.SignedOut) + + /** 登录态流:Mine 等页面订阅渲染。 */ + val state: StateFlow = _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() }) + } +} diff --git a/app/src/main/res/layout/fragment_change_password.xml b/app/src/main/res/layout/fragment_change_password.xml new file mode 100644 index 0000000..116b695 --- /dev/null +++ b/app/src/main/res/layout/fragment_change_password.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_config.xml b/app/src/main/res/layout/fragment_config.xml new file mode 100644 index 0000000..d339175 --- /dev/null +++ b/app/src/main/res/layout/fragment_config.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_login.xml b/app/src/main/res/layout/fragment_login.xml new file mode 100644 index 0000000..f00bdc2 --- /dev/null +++ b/app/src/main/res/layout/fragment_login.xml @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_mine.xml b/app/src/main/res/layout/fragment_mine.xml new file mode 100644 index 0000000..2fab3ce --- /dev/null +++ b/app/src/main/res/layout/fragment_mine.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 41eebb3..aeb9a40 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -6,4 +6,5 @@ #F7F9FC #FFFFFF #1E6FD9 + #B3261E diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 876da65..992b994 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,4 +9,54 @@ 我的 占位页 —— 业务功能按 S1–S4 计划经 FeatureMount 挂载 + + + 返回 + 保存 + + + 我的 + 未登录 + 未知用户 + 部门:%1$s + 账号:%1$s + 手机号:%1$s + 登录 + 退出登录 + 修改登录密码 + 服务器与密钥配置 + 刷新用户信息 + 服务器与密钥已配置 + 尚未配置服务器地址与密钥,无法登录 + 去配置 + 确定退出当前账号? + + + 登录 + 上海城建勘测院 · 监测数据不落地平台 + 账号密码 + 短信验证码 + 登录账号 + 登录密码 + 手机号(国内 11 位) + 短信验证码 + 获取验证码 + %1$ds 后重发 + 登 录 + + + 服务器与密钥配置 + 服务器地址(如 https://jcd.stec.p-q.co) + SecretKey(接口调用秘钥) + SystemCode(供应商编码) + 配置已保存 + 配置仅保存在本机(加密存储),不会随代码库分发;密钥变更后在此更新即可,无需换包。 + + + 修改登录密码 + 原密码 + 新密码(至少 6 位) + 确认新密码 + 提交修改 + 密码已修改,请使用新密码重新登录 diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/ApiCaller.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/ApiCaller.kt new file mode 100644 index 0000000..83c33f6 --- /dev/null +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/ApiCaller.kt @@ -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 call(block: suspend () -> ApiEnvelope): 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, + ) +} diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/DynamicBaseUrlInterceptor.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/DynamicBaseUrlInterceptor.kt new file mode 100644 index 0000000..813dd8d --- /dev/null +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/DynamicBaseUrlInterceptor.kt @@ -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()) + } +} diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt index 3d3a1c8..0f1775a 100644 --- a/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/NetworkModule.kt @@ -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 diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/api/AuthApi.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/AuthApi.kt new file mode 100644 index 0000000..a21678f --- /dev/null +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/AuthApi.kt @@ -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 + + /** + * 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 + + /** 2.2.3 手机验证码登录:成功 data 为 Token 字符串。 */ + @POST(PATH_LOGIN_BY_MOBILE) + suspend fun loginByMobile( + @Body request: MobileLoginRequest, + ): ApiEnvelope + + /** 2.3.1 修改登录密码:登录态下操作,成功 data 为 null。 */ + @POST(PATH_MODIFY_PASSWORD) + suspend fun modifyPassword( + @Body request: ChangePasswordRequest, + ): ApiEnvelope + + /** 2.4 获取当前用户信息:需登录态。 */ + @GET(PATH_GET_USER_INFO) + suspend fun getUserInfo(): ApiEnvelope + + 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" + } +} diff --git a/core/network/src/main/kotlin/com/stec/cmd/core/network/api/AuthModels.kt b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/AuthModels.kt new file mode 100644 index 0000000..0d2d312 --- /dev/null +++ b/core/network/src/main/kotlin/com/stec/cmd/core/network/api/AuthModels.kt @@ -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, +)