3 Commits
Author SHA1 Message Date
mazengfei 57345f8750 feat(environment): 新增环境切换功能及相关优化
- 新增业务环境选择弹窗 EnvironmentSelectDialog,支持本地/测试/生产环境切换
- 实现连点三次屏幕触发环境切换弹窗功能,便于快速切换接口环境
- 切换环境时清空本地人脸库并重置增量同步状态,保证环境切换的完整生效
- 业务全局 BaseUrl 支持从持久化读取,首次无值时默认测试环境
- Retrofit Client 接口 BaseUrl 优化,自动规范尾部斜杠,保证 BaseUrl 合法
- 设备初始化界面增强,增加等待框触摸穿透,优化接口请求失败时的提示信息
- 提升人脸缓存拉取接口分页大小,默认请求 500 条数据
- 修正登录界面布局细节,隐藏密码切换按钮,调整控件顺序及样式细节
2026-09-02 09:59:48 +08:00
mazengfeiandClaude Fable 5 d33f941d12 docs: 补充切环境后 ArcSoft 参数自动适配说明
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 17:29:53 +08:00
mazengfeiandClaude Fable 5 41cfec0583 docs: 新增业务 BaseUrl 切换设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 17:15:04 +08:00
8 changed files with 316 additions and 18 deletions
@@ -21,6 +21,7 @@ object GlobalData {
* 竖排数量
*/
var arrayVertical: Int = 11
/**
* 排列方式 0 垂直 1 水平
*/
@@ -38,9 +39,9 @@ object GlobalData {
/**
* 具体业务 BaseUrl
*/
const val LOCAL_BASE_URL: String = "http://192.168.10.101:24801"
const val TEST_BASE_URL: String = "https://dev.yixiong-tech.com:8081"
const val PROD_BASE_URL: String = "https://platform-api.uat.shuziweidao.com"
const val LOCAL_BASE_URL: String = "http://192.168.10.101:24801"//开发环境
const val TEST_BASE_URL: String = "https://dev.yixiong-tech.com:8081"//测试环境
const val PROD_BASE_URL: String = "https://platform-api.uat.shuziweidao.com"//生产环境
/**
* 设备id
@@ -70,3 +71,22 @@ object GlobalKey {
const val PARAM_EQUIPMENT_INFO = "equipmentUserInfo"
const val KEY_BASE_URL = "baseUrlKey"
}
/**
* 业务环境配置项
* @param name 环境名称(用于弹窗展示)
* @param url 环境 BaseUrl(不带尾斜杠)
*/
data class Environment(
val name: String,
val url: String
)
/**
* 预设环境列表(本地 / 测试 / 生产)
*/
val ENVIRONMENTS: List<Environment> = listOf(
Environment("本地", GlobalData.LOCAL_BASE_URL),
Environment("测试", GlobalData.TEST_BASE_URL),
Environment("生产", GlobalData.PROD_BASE_URL)
)
@@ -4,6 +4,7 @@ import android.util.Log
import com.sw.plate.App
import com.sw.plate.utils.AppUtil
import com.sw.platecabinet.utils.CrashHandler
import com.sw.platecabinet.utils.SpTool
import timber.log.Timber
class MyApp : App() {
@@ -33,6 +34,10 @@ class MyApp : App() {
GlobalData.appVersion = AppUtil.getAppVersionCode(this).toString()
GlobalData.globalEquipmentCode = "202501171634"
// 从持久化读取业务 BaseUrl,首次无值兜底测试环境
GlobalData.appBaseUrl = SpTool.getBaseUrl().ifBlank { GlobalData.TEST_BASE_URL }
Timber.d("initGlobalData appBaseUrl=${GlobalData.appBaseUrl}")
// GlobalData.appBaseUrl = "http://192.168.1.201:14801"
}
}
@@ -1,17 +1,34 @@
package com.sw.platecabinet.activity
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.WindowManager
import androidx.lifecycle.lifecycleScope
import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.facedb.FaceDatabase
import com.sw.platecabinet.Environment
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.dialog.EnvironmentSelectDialog
import com.sw.platecabinet.member.databinding.ActivityDeviceInitBinding
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.utils.SpTool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
/**
* 设备初始化界面
*/
class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
// 连点切换环境的计数与复位
private var tapCount = 0
private val tapHandler = Handler(Looper.getMainLooper())
private val resetTapRunnable = Runnable { tapCount = 0 }
override fun inflateViewBinding(): ActivityDeviceInitBinding {
return ActivityDeviceInitBinding.inflate(layoutInflater)
}
@@ -21,7 +38,8 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
}
override fun initialize() {
showWaitingDialog("加载中……")
// 等待框设为不拦截触摸,确保连点手势可达
showWaitingDialog("加载中……")?.window?.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
// 首次启动先全量拉取人脸缓存,后续走增量同步
if (SpTool.getFirstGetFace()) {
netViewModelV2.getUserFaceCache { status, msg ->
@@ -33,6 +51,50 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
}
}
/**
* 连续点击 3 次(500ms 内)触发环境切换弹窗
*/
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (ev.action == MotionEvent.ACTION_DOWN) {
tapCount++
tapHandler.removeCallbacks(resetTapRunnable)
tapHandler.postDelayed(resetTapRunnable, 500)
if (tapCount >= 3) {
tapCount = 0
tapHandler.removeCallbacks(resetTapRunnable)
showEnvironmentDialog()
return true
}
}
return super.dispatchTouchEvent(ev)
}
/**
* 弹出环境选择弹窗
*/
private fun showEnvironmentDialog() {
EnvironmentSelectDialog(this) { env ->
switchEnvironment(env)
}.show()
}
/**
* 切换环境:持久化 + 清空本地人脸库 + 重置增量同步状态
*/
private fun switchEnvironment(env: Environment) {
SpTool.setBaseUrl(env.url)
lifecycleScope.launch(Dispatchers.IO) {
val faceDao = FaceDatabase.getInstance(this@DeviceInitActivity).faceDao()
faceDao.deleteAll()
faceDao.resetId()
SpTool.setLastFaceTimestamp(0L)
SpTool.setFirstGetFace(true)
withContext(Dispatchers.Main) {
ToastUtils.showToast("已切换到${env.name},重启后生效")
}
}
}
/**
* 获取设备配置(V2),并初始化虹软 SDK 激活参数
*/
@@ -41,8 +103,8 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
runOnUiThread {
if (deviceConfig == null) {
hideWaitingDialog()
ToastUtils.showToast("获取设备配置数据失败")
goLoginActivity()
ToastUtils.showToast("获取设备配置失败,请连点屏幕3次切换环境")
// goLoginActivity()
return@runOnUiThread
}
GlobalData.appId = deviceConfig.arcsoftAppId ?: ""
@@ -56,8 +118,9 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
}, onFailure = { errMsg ->
runOnUiThread {
hideWaitingDialog()
ToastUtils.showToast(errMsg ?: "接口异常")
goLoginActivity()
Timber.e("getDeviceConfig onFailure: $errMsg")
ToastUtils.showToast("服务连接失败,请连点屏幕3次切换环境")
// goLoginActivity()
}
})
}
@@ -0,0 +1,81 @@
package com.sw.platecabinet.dialog
import android.app.Dialog
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.Window
import android.widget.LinearLayout
import android.widget.RadioButton
import androidx.fragment.app.FragmentActivity
import com.sw.platecabinet.ENVIRONMENTS
import com.sw.platecabinet.Environment
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.ext.dp
import com.sw.platecabinet.member.databinding.DialogEnvironmentSelectBinding
/**
* 业务环境选择弹窗
*/
class EnvironmentSelectDialog(
private val activity: FragmentActivity,
private val onConfirm: (Environment) -> Unit
) : Dialog(activity) {
private lateinit var binding: DialogEnvironmentSelectBinding
private var selected: Environment? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE)
binding = DialogEnvironmentSelectBinding.inflate(layoutInflater)
setContentView(binding.root)
window?.run {
attributes = attributes.apply { width = 480.dp }
setBackgroundDrawable(ColorDrawable())
setCancelable(true)
}
renderEnvironments()
addListener()
}
/**
* 渲染环境选项,高亮当前环境
*/
private fun renderEnvironments() {
val current = GlobalData.appBaseUrl
ENVIRONMENTS.forEach { env ->
val rb = RadioButton(activity).apply {
id = View.generateViewId()
text = env.name
gravity = Gravity.CENTER_VERTICAL
textSize = 18f
setTextColor(Color.WHITE)
buttonTintList = ColorStateList.valueOf(Color.WHITE)
isChecked = env.url == current
setOnClickListener { selected = env }
}
binding.rgEnvironments.addView(
rb,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { topMargin = 8.dp }
)
}
selected = ENVIRONMENTS.firstOrNull { it.url == current }
}
/**
* 确认按钮:回调所选环境并关闭弹窗
*/
private fun addListener() {
binding.tvConfirm.setOnClickListener {
selected?.let(onConfirm)
dismiss()
}
}
}
@@ -29,7 +29,7 @@ object ApiClient {
private val retrofit by lazy {
Retrofit.Builder()
.baseUrl(GlobalData.appBaseUrl)
.baseUrl(resolveBaseUrl())
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
@@ -42,4 +42,13 @@ object ApiClient {
val apiServiceV2: ApiServiceV2 by lazy {
retrofit.create(ApiServiceV2::class.java)
}
/**
* 规范化 Retrofit 要求的 baseUrl:非空且以 / 结尾。
* 所有接口走 @Url 全路径,此值不参与实际拼接,只需合法。
*/
private fun resolveBaseUrl(): String {
val url = GlobalData.appBaseUrl.ifBlank { "http://localhost/" }
return if (url.endsWith("/")) url else "$url/"
}
}
@@ -22,7 +22,7 @@ class RemoteRepositoryV2 constructor(
*/
suspend fun getUserFaceCache(
pageNum: Int,
pageSize: Int = 100,
pageSize: Int = 500,
): ApiResponse<List<UserFaceModelV2>> {
return safeApiCall {
apiService.getUserFaceCache(
@@ -59,9 +59,9 @@
android:layout_gravity="center_horizontal"
android:layout_marginTop="129dp"
android:text="请正视屏幕"
android:textStyle="bold"
android:textColor="#FFCC99"
android:textSize="36sp" />
android:textSize="36sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/ll_to_pwd"
@@ -72,7 +72,8 @@
android:background="@drawable/btn_outline"
android:gravity="center"
android:paddingHorizontal="45dp"
android:paddingVertical="18dp">
android:paddingVertical="18dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
@@ -86,12 +87,12 @@
android:id="@+id/tv_to_init"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="30"
android:padding="12dp"
android:gravity="center"
android:layout_marginTop="185dp"
android:gravity="center"
android:padding="12dp"
android:textColor="#FFCC99"
android:textSize="36sp" />
android:textSize="36sp"
tools:text="30" />
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,119 @@
# 业务 BaseUrl 切换 — 设计文档
- 日期:2026-09-01
- 状态:已确认
- 关联模块:网络层 / 设备初始化 / 全局配置
## 1. 背景与目标
SmartTakePlate(发盘机)当前业务服务地址由 `GlobalData.appBaseUrl` 决定,但该字段:
- 默认值为空串,全局无任何活跃代码赋值(仅剩注释)。
- 已具备一套"半成品"基础设施(`SpTool.getBaseUrl/setBaseUrl``GlobalKey.KEY_BASE_URL`
`LOCAL/TEST/PROD_BASE_URL` 常量),但从未接线。
目标:让现场运维人员能在**设备端**切换业务环境(本地 / 测试 / 生产),切换结果持久化,
重启后生效。
## 2. 需求
- **入口**:设备端隐藏入口,置于**登录前的初始化界面**(`DeviceInitActivity`)。
原因:若入口放在业务主界面,一旦配置的服务地址访问不到,业务界面因依赖网络请求成功才能
进入,将无法切回正确地址,形成死锁。
- **生效方式**:持久化到 SharedPreferences,重启 app 后生效(最简单可靠)。
- **选项来源**:仅预设环境列表(本地 / 测试 / 生产),不支持手输。
## 3. 现状分析
| 已存在 | 状态 |
|---|---|
| `GlobalData.appBaseUrl``var String = ""` | 默认空串,无活跃赋值 |
| `LOCAL_BASE_URL` / `TEST_BASE_URL` / `PROD_BASE_URL` | 已定义,不带尾斜杠 |
| `GlobalKey.KEY_BASE_URL = "baseUrlKey"` | 已定义 |
| `SpTool.getBaseUrl() / setBaseUrl()` | 已实现,无调用方 |
| `ApiClient.retrofit``by lazy``baseUrl(GlobalData.appBaseUrl)` | 用空串构建,切环境不重建 |
| `ApiService` / `ApiServiceV2` 全部方法 | 用 `@Url url = "${GlobalData.appBaseUrl}/..."` 默认参数,调用时求值 |
关键结论:
1. 所有请求地址由 `GlobalData.appBaseUrl` 在**每次调用时**经默认参数拼接决定,故切环境
只需改这一个变量(配合重启重建 `Retrofit`)。
2. `GlobalData.appBaseUrl=""` 会让 `Retrofit.Builder().baseUrl("")` 抛异常 —— 现有隐患。
## 4. 设计
### 4.1 组件划分与改动清单
| 文件 | 改动 |
|---|---|
| `GlobalData.kt` | 补充环境列表模型(名称 + URL),复用已有 `LOCAL/TEST/PROD_BASE_URL` |
| `MyApp.kt` | `initGlobalData()` 接线:读 `SpTool.getBaseUrl()`,兜底 `TEST_BASE_URL` |
| `DeviceInitActivity.kt` | 加隐藏连点手势 + 弹环境选择弹窗 |
| `ApiClient.kt` | 修复 Retrofit baseUrl 校验(补尾斜杠 / 占位),消除空串崩溃 |
| 新增 `EnvironmentSelectDialog`(或内联 Dialog) | 环境单选弹窗,高亮当前项 |
| `BaseActivity.kt`(复用) | 复用已有 `clearAllFace()` 清人脸库 |
### 4.2 关键设计决策
**决策 A — URL 规范化(消除现有隐患)**
- 约定 `GlobalData.appBaseUrl` **不带尾斜杠**,供 `@Url` 拼接得到形如
`https://dev.yixiong-tech.com:8081/terminal/...` 的完整地址。
- `Retrofit` 的 baseUrl 单独规范化:空则占位 `http://localhost/`,非空且无尾斜杠则补 `/`
因所有请求走 `@Url` 全路径,Retrofit baseUrl 不参与实际拼接,只需合法即可。
**决策 B — 切换时清理环境残留**
选中环境后执行:
1. `SpTool.setBaseUrl(url)` 持久化;
2. 清空本地人脸库(`FaceDatabase` / `clearAllFace()`);
3. 重置人脸时间戳 `lastFaceTimestamp = 0`
4. 重置 `firstGetFace = true`
原因:不同环境的人脸数据与时间戳不同,不清理会导致重启后增量同步对不上。
另:ArcSoft 激活参数(`appId` / `sdkKey` / `activeKey`)无需单独处理——`DeviceInitActivity`
每次启动都会调用 `getDeviceConfig()` 从新环境重新获取并覆盖,切环境后自动适配。
**决策 C — 隐藏手势**
`DeviceInitActivity` 连续点击屏幕(如 5 次)触发,不新增可见 UI 元素。该界面登录前
可达、不依赖网络,满足"配错地址也能切回"的要求。
## 5. 数据流
```mermaid
sequenceDiagram
participant User as 运维人员
participant App as MyApp/DeviceInit
participant Sp as SpTool(SharedPrefs)
participant Net as Retrofit/网络
App->>Sp: 启动时读 KEY_BASE_URL
alt 有值
Sp-->>App: 返回已存 URL
else 无值(首次)
Sp-->>App: 空 → 用 TEST_BASE_URL
end
App->>App: GlobalData.appBaseUrl = 结果
User->>App: 初始化界面连点5次
App->>App: 弹环境选择Dialog(高亮当前)
User->>App: 选择「生产」并确认
App->>Sp: setBaseUrl(PROD)
App->>App: 清人脸库 + 重置时间戳 + firstGetFace=true
App->>User: Toast「重启后生效」
User->>App: 重启 app
App->>Net: appBaseUrl 已指向生产 → 全量拉新环境人脸
```
## 6. 错误处理
- **baseUrl 空 / 非法**:兜底 `TEST_BASE_URL`,并保证 Retrofit baseUrl 合法(占位/补斜杠)。
- **清人脸库失败**:非致命,仅记日志,不阻塞切换(重启后首次拉取仍会兜底全量)。
- **写 SharedPreferences 失败**:几乎不抛;Toast 提示用户重试。
## 7. 验证方式
- 构建:`./gradlew assembleDebug` 通过。
- 手动:初始化界面连点 5 次 → 弹窗 → 切「本地」→ 重启 → 抓包/日志确认请求打到新地址;
再切回「测试」验证可恢复。