diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt index 6eb248e..812c9db 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/AgentSettingsFragment.kt @@ -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 "" diff --git a/server/app/goauto/device/service.go b/server/app/goauto/device/service.go index ed22416..b9daed2 100644 --- a/server/app/goauto/device/service.go +++ b/server/app/goauto/device/service.go @@ -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 }) diff --git a/server/app/goauto/device/service_test.go b/server/app/goauto/device/service_test.go index 11cd3d1..0bf4c3d 100644 --- a/server/app/goauto/device/service_test.go +++ b/server/app/goauto/device/service_test.go @@ -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) } } diff --git a/web/src/views/goauto/devices/index.vue b/web/src/views/goauto/devices/index.vue index 6442af1..154fcd5 100644 --- a/web/src/views/goauto/devices/index.vue +++ b/web/src/views/goauto/devices/index.vue @@ -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() } }