feat(device): 后台自动恢复设备身份 (#201)
This commit is contained in:
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user