feat(device): 后台自动恢复设备身份 (#201)

This commit is contained in:
QiuSW
2026-09-03 11:16:03 +08:00
parent 13164adde0
commit 2c263e687e
4 changed files with 19 additions and 107 deletions
@@ -1,7 +1,6 @@
package cn.ilapage.goauto.agent.ui
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.ColorStateList
import android.os.Build
import android.os.Bundle
@@ -18,10 +17,8 @@ import android.widget.TextView
import androidx.fragment.app.Fragment
import cn.ilapage.goauto.agent.BuildConfig
import cn.ilapage.goauto.agent.R
import cn.ilapage.goauto.agent.automation.AgentCapabilities
import cn.ilapage.goauto.agent.identity.SecureDeviceStore
import cn.ilapage.goauto.agent.network.AgentApiClient
import cn.ilapage.goauto.agent.network.DeviceInfo
import cn.ilapage.goauto.agent.network.AgentAppRelease
import cn.ilapage.goauto.agent.network.CollectionHistoryItem
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
@@ -58,7 +55,6 @@ class AgentSettingsFragment : Fragment() {
private lateinit var nameInput: TextInputEditText
private lateinit var testButton: MaterialButton
private lateinit var saveButton: MaterialButton
private lateinit var recoveryButton: MaterialButton
private lateinit var connectionFeedback: TextView
private lateinit var disabledReason: TextView
private lateinit var diagnostics: TextView
@@ -162,13 +158,6 @@ class AgentSettingsFragment : Fragment() {
addView(saveButton, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1.5f))
}
addView(actions, fullWidth(12))
recoveryButton = MaterialButton(context).apply {
text = "使用管理员恢复码重新注册"
minHeight = context.dp(48)
visibility = View.GONE
setOnClickListener { openRecoveryDialog() }
}
addView(recoveryButton, fullWidth(8))
connectionFeedback = context.label("尚未测试连接", 14f, context.getColor(R.color.agent_text_muted))
connectionFeedback.setPadding(0, context.dp(10), 0, 0)
addView(connectionFeedback)
@@ -521,74 +510,6 @@ class AgentSettingsFragment : Fragment() {
refreshDiagnostics()
}
private fun openRecoveryDialog() {
val state = stateStore.read()
if (state.code != "AUTH_ERROR") return
val input = TextInputEditText(requireContext()).apply {
hint = "管理员恢复码"
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
setSingleLine(true)
}
val layout = TextInputLayout(requireContext()).apply {
hint = "管理员恢复码"
boxBackgroundMode = TextInputLayout.BOX_BACKGROUND_OUTLINE
addView(input)
}
MaterialAlertDialogBuilder(requireContext())
.setTitle("重新注册原设备")
.setMessage("请在后台发起“重置设备身份”,输入仅显示一次且尚未过期的恢复码。恢复后仍使用原设备和原任务分配。")
.setView(layout)
.setNegativeButton("取消", null)
.setPositiveButton("重新注册") { _, _ -> recoverIdentity(input.text?.toString().orEmpty().trim()) }
.show()
}
private fun recoverIdentity(recoveryCode: String) {
if (recoveryCode.isBlank()) { connectionFeedback.text = "恢复失败:请输入管理员恢复码"; return }
val serverUrl = validateInputs(requireName = true) ?: return
val credentials = runCatching { identityStore.credentials() }.getOrNull()
if (credentials == null) { connectionFeedback.text = "恢复失败:本机没有原设备凭据"; return }
val deviceName = nameInput.text?.toString()?.trim().orEmpty()
val deviceInfo = recoveryDeviceInfo(deviceName)
recoveryButton.isEnabled = false
connectionFeedback.text = "正在安全重新注册原设备…"
executor.execute {
val result = runCatching {
val registration = AgentApiClient(serverUrl).recoverRegistration(deviceInfo, credentials.token, recoveryCode)
check(registration.deviceId == credentials.deviceId) { "服务端设备身份与本地不一致" }
val token = registration.deviceToken ?: error("恢复注册未返回新 Token")
identityStore.saveCredentials(registration.deviceId, token)
settingsStore.saveConnectionSettings(serverUrl, deviceName)
AgentForegroundService.start(requireContext(), reconnect = true)
}
activity?.runOnUiThread {
if (!isAdded || view == null) return@runOnUiThread
recoveryButton.isEnabled = true
connectionFeedback.text = result.fold(
onSuccess = { "原设备身份已恢复,正在重新连接服务端" },
onFailure = { "恢复失败:${friendlyError(it)}" },
)
connectionFeedback.setTextColor(requireContext().getColor(if (result.isSuccess) R.color.agent_primary_light else R.color.agent_error))
refreshDiagnostics()
}
}
}
private fun recoveryDeviceInfo(name: String): DeviceInfo = DeviceInfo(
installId = identityStore.installId(), name = name.ifBlank { "${Build.MANUFACTURER}-${Build.MODEL}" }.take(100),
manufacturer = Build.MANUFACTURER.take(100), model = Build.MODEL.take(100),
androidVersion = Build.VERSION.RELEASE.take(32), agentVersion = BuildConfig.VERSION_NAME.take(32),
pddVersion = installedPddVersion().take(32), capabilities = AgentCapabilities.supported,
)
@Suppress("DEPRECATION")
private fun installedPddVersion(): String = runCatching {
val info = if (Build.VERSION.SDK_INT >= 33) {
requireContext().packageManager.getPackageInfo("com.xunmeng.pinduoduo", PackageManager.PackageInfoFlags.of(0))
} else requireContext().packageManager.getPackageInfo("com.xunmeng.pinduoduo", 0)
info.versionName ?: "unknown"
}.getOrDefault("not_installed")
private fun syncHistory() {
historyDaysLayout.error = null
val days = HistoryRangePolicy.parse(historyDaysInput.text?.toString().orEmpty())
@@ -744,7 +665,6 @@ class AgentSettingsFragment : Fragment() {
nameInput.isEnabled = editable
testButton.isEnabled = editable
saveButton.isEnabled = editable
recoveryButton.visibility = if (state.code == "AUTH_ERROR" && editable) View.VISIBLE else View.GONE
testButton.text = if (testing) "正在测试…" else "测试连接"
disabledReason.text = if (busy) "任务执行中,暂时不能修改服务器或设备名称。" else ""
+9 -12
View File
@@ -72,9 +72,8 @@ type RegisterResponse struct {
}
type ResetIdentityResponse struct {
DeviceID uint64 `json:"deviceId"`
RecoveryCode string `json:"recoveryCode"`
ExpiresAt time.Time `json:"expiresAt"`
DeviceID uint64 `json:"deviceId"`
ExpiresAt time.Time `json:"expiresAt"`
}
type Service struct {
@@ -182,11 +181,13 @@ func (service *Service) Register(ctx context.Context, request RegisterRequest, p
}
usingRecovery := !tokenMatches(presentedToken, existing.TokenDigest)
if usingRecovery {
if recoveryCode == "" || !digestMatches(recoveryCode, existing.RecoveryCodeDigest) || existing.RecoveryUsedAt != nil {
return RegisterResponse{}, &ServiceError{Code: CodeInstallIDConflict, Message: "installId 已注册,需要该设备的有效 Token 或管理员恢复码", Retryable: false}
validCode := recoveryCode != "" && digestMatches(recoveryCode, existing.RecoveryCodeDigest)
autoRecovery := recoveryCode == "" && existing.RecoveryCodeDigest == nil && existing.RecoveryUsedAt == nil
if !validCode && !autoRecovery {
return RegisterResponse{}, &ServiceError{Code: CodeInstallIDConflict, Message: "installId 已注册,需要该设备的有效 Token", Retryable: false}
}
if existing.RecoveryExpiresAt == nil || !service.Now().Before(*existing.RecoveryExpiresAt) {
return RegisterResponse{}, &ServiceError{Code: CodeRecoveryExpired, Message: "管理员恢复码已过期,请重新发起重置", Retryable: false}
return RegisterResponse{}, &ServiceError{Code: CodeRecoveryExpired, Message: "设备身份重置窗口已过期,请在后台重新操作", Retryable: false}
}
}
updates := map[string]any{
@@ -265,13 +266,9 @@ func (service *Service) ResetIdentity(ctx context.Context, deviceID uint64) (Res
if service.DB == nil {
return ResetIdentityResponse{}, internalError(errors.New("database is nil"))
}
code, err := service.GenerateToken()
if err != nil {
return ResetIdentityResponse{}, internalError(err)
}
now := service.Now()
expiresAt := now.Add(deviceRecoveryLifetime)
response := ResetIdentityResponse{DeviceID: deviceID, RecoveryCode: code, ExpiresAt: expiresAt}
response := ResetIdentityResponse{DeviceID: deviceID, ExpiresAt: expiresAt}
err = service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var device models.AgentDevice
if err := tx.First(&device, deviceID).Error; errors.Is(err, gorm.ErrRecordNotFound) {
@@ -288,7 +285,7 @@ func (service *Service) ResetIdentity(ctx context.Context, deviceID uint64) (Res
}
return tx.Model(&models.AgentDevice{}).Where("id = ?", deviceID).Updates(map[string]any{
"token_digest": tokenDigest(invalidatedToken), "token_issued_at": now,
"recovery_code_digest": tokenDigest(code), "recovery_expires_at": expiresAt,
"recovery_code_digest": nil, "recovery_expires_at": expiresAt,
"recovery_used_at": nil, "status": models.DeviceStatusOffline,
}).Error
})
+7 -7
View File
@@ -188,7 +188,7 @@ func TestDisableAndRevokePreventFurtherAuthentication(t *testing.T) {
func TestResetIdentityRecoversSameDeviceAndRotatesToken(t *testing.T) {
db := openTestDatabase(t)
service := newTestService(t, db)
tokens := []string{"original-token", "recovery-code", "invalidated-token", "replacement-token"}
tokens := []string{"original-token", "invalidated-token", "replacement-token"}
service.GenerateToken = func() (string, error) {
if len(tokens) == 0 {
t.Fatal("unexpected token generation")
@@ -206,14 +206,14 @@ func TestResetIdentityRecoversSameDeviceAndRotatesToken(t *testing.T) {
if err != nil {
t.Fatalf("reset identity: %v", err)
}
if recovery.RecoveryCode != "recovery-code" || !recovery.ExpiresAt.After(service.Now()) {
if !recovery.ExpiresAt.After(service.Now()) {
t.Fatalf("unexpected recovery response: %+v", recovery)
}
request.RequestID = uuid.NewString()
if _, err := service.Register(context.Background(), request, "original-token"); serviceErrorCode(t, err) != CodeInstallIDConflict {
t.Fatal("old token remained valid after identity reset")
}
recovered, err := service.Register(context.Background(), request, "original-token", recovery.RecoveryCode)
recovered, err := service.Register(context.Background(), request, "original-token")
if err != nil {
t.Fatalf("recover registration: %v", err)
}
@@ -228,15 +228,15 @@ func TestResetIdentityRecoversSameDeviceAndRotatesToken(t *testing.T) {
t.Fatalf("recovery did not retain device safely: %+v", stored)
}
request.RequestID = uuid.NewString()
if _, err := service.Register(context.Background(), request, "original-token", recovery.RecoveryCode); serviceErrorCode(t, err) != CodeInstallIDConflict {
t.Fatal("recovery code was reusable")
if _, err := service.Register(context.Background(), request, "original-token"); serviceErrorCode(t, err) != CodeInstallIDConflict {
t.Fatal("automatic recovery window was reusable")
}
}
func TestResetIdentityRejectsExpiredRecoveryCode(t *testing.T) {
db := openTestDatabase(t)
service := newTestService(t, db)
tokens := []string{"original-token", "recovery-code", "invalidated-token"}
tokens := []string{"original-token", "invalidated-token"}
service.GenerateToken = func() (string, error) { value := tokens[0]; tokens = tokens[1:]; return value, nil }
request := validRegisterRequest()
registered, err := service.Register(context.Background(), request, "")
@@ -249,7 +249,7 @@ func TestResetIdentityRejectsExpiredRecoveryCode(t *testing.T) {
}
service.Now = func() time.Time { return recovery.ExpiresAt.Add(time.Second) }
request.RequestID = uuid.NewString()
if _, err := service.Register(context.Background(), request, "", recovery.RecoveryCode); serviceErrorCode(t, err) != CodeRecoveryExpired {
if _, err := service.Register(context.Background(), request, ""); serviceErrorCode(t, err) != CodeRecoveryExpired {
t.Fatalf("expected expired recovery code, got %v", err)
}
}
+3 -8
View File
@@ -200,17 +200,12 @@ export default {
},
async confirmIdentityReset(row) {
await ElMessageBox.confirm(
`将立即使“${row.name}”当前 Token 失效,并生成 10 分钟内仅能使用一次的恢复码。恢复后仍使用设备 #${row.id},已分配的待领取任务保持不变。恢复码只显示一次,请在手机 Agent 中手动输入。`,
`将立即使“${row.name}”当前 Token 失效,并开启 10 分钟自动重新注册窗口。手机 Agent 保持运行时会自动恢复原设备 #${row.id},已分配的待领取任务保持不变。`,
'确认重置设备身份',
{ type: 'warning', confirmButtonText: '生成恢复码', cancelButtonText: '取消', distinguishCancelAndClose: true }
{ type: 'warning', confirmButtonText: '确认重置', cancelButtonText: '取消', distinguishCancelAndClose: true }
)
const response = await resetDeviceIdentity(row.id)
const { recoveryCode, expiresAt } = response.data
await ElMessageBox.alert(
`恢复码:${recoveryCode}\n\n有效至:${parseTime(expiresAt)}\n\n请在手机 Agent 的“设置”中点击“使用管理员恢复码重新注册”,输入该恢复码。关闭此窗口后无法再次查看;需要时请重新发起重置。`,
'一次性设备恢复码',
{ type: 'warning', confirmButtonText: '我已安全记录' }
)
ElMessage.success(`已开启自动重新注册窗口,有效至 ${new Date(response.data.expiresAt).toLocaleString()}`)
await this.getList()
}
}