diff --git a/app/Models/UserDevice.php b/app/Models/UserDevice.php index 21173f241..84c42d009 100644 --- a/app/Models/UserDevice.php +++ b/app/Models/UserDevice.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Module\AppPush\DeviceRegistry; use App\Module\Base; use App\Module\Doo; use App\Module\Lock; @@ -306,7 +307,7 @@ class UserDevice extends AbstractModel } if (isset($hash)) { Cache::forget(self::ck($hash)); - AppPushDevice::whereDeviceHash($hash)->delete(); + DeviceRegistry::forgetHash($hash); } } } diff --git a/app/Module/AppPush/DeviceRegistry.php b/app/Module/AppPush/DeviceRegistry.php index 967bdba9f..9737501a9 100644 --- a/app/Module/AppPush/DeviceRegistry.php +++ b/app/Module/AppPush/DeviceRegistry.php @@ -47,14 +47,20 @@ class DeviceRegistry return; } // 与 UserDevice::check()/forget() 共用 TOKEN MD5 算法,不依赖 token 解码。 - $channels = AppPushDevice::whereIn('channel', [AppPush::CHANNEL_SYSTEM, AppPush::CHANNEL_CUSTOM]) - ->whereDeviceId($deviceId)->whereDeviceHash(md5($token))->pluck('channel')->all(); - if (!$channels) { - return; - } - AppPushDevice::whereIn('channel', $channels)->whereDeviceId($deviceId)->whereDeviceHash(md5($token))->delete(); - if (in_array(AppPush::CHANNEL_SYSTEM, $channels, true)) { - app(DooPushClient::class)->unbindDevice($deviceId); + self::forgetHash(md5($token), $deviceId); + } + + /** + * 按登录 token 哈希清理推送设备(退出登录、踢下线等),系统配置的设备同时解除租户绑定 + */ + public static function forgetHash(string $hash, ?string $deviceId = null): void + { + $query = AppPushDevice::whereIn('channel', [AppPush::CHANNEL_SYSTEM, AppPush::CHANNEL_CUSTOM])->whereDeviceHash($hash) + ->when($deviceId !== null, fn ($q) => $q->whereDeviceId($deviceId)); + $systemIds = (clone $query)->whereChannel(AppPush::CHANNEL_SYSTEM)->pluck('device_id')->all(); + $query->delete(); + foreach ($systemIds as $id) { + app(DooPushClient::class)->unbindDevice($id); } } } diff --git a/app/Module/AppPush/DooPushClient.php b/app/Module/AppPush/DooPushClient.php index db458d1d9..33604d5ac 100644 --- a/app/Module/AppPush/DooPushClient.php +++ b/app/Module/AppPush/DooPushClient.php @@ -154,9 +154,10 @@ class DooPushClient try { $tenant = $this->tenant(false); if ($tenant) { + // 在退出登录请求中同步执行:只请求一次且缩短超时,网络异常时不拖慢退出。 $this->send('DELETE', $this->systemUrl('/tenant/devices/' . rawurlencode($deviceId)), [ 'Authorization' => 'Bearer ' . $tenant['tenant_secret'], - ], [], 0, 1); + ], [], 0, 1, 3); } } catch (\Throwable $e) { info('[DooPush] unbind failed: ' . $e->getMessage()); @@ -235,13 +236,13 @@ class DooPushClient /** * 发送请求,网络错误或 5xx 时最多重试2次;返回 [HTTP 状态码(网络失败为 0), 响应 JSON] */ - private function send(string $method, string $url, array $headers, array $body, int $userid = 0, int $attempts = 3): array + private function send(string $method, string $url, array $headers, array $body, int $userid = 0, int $attempts = 3, int $timeout = 10): array { $status = 0; $result = []; for ($attempt = 1; $attempt <= $attempts; $attempt++) { try { - $response = $this->request($url, $headers, $body, $method); + $response = $this->request($url, $headers, $body, $method, $timeout); $result = Base::json2array($response['data'] ?? ''); $status = Base::isSuccess($response) ? (int)($response['msg'] ?? 0) : 0; if ($status >= 200 && $status < 300 && in_array($result['code'] ?? 0, [200, 201], true)) { @@ -266,7 +267,7 @@ class DooPushClient return [$status >= 200 && $status < 300 ? 0 : $status, $result]; } - protected function request(string $url, array $headers, array $body, string $method = 'POST'): array + protected function request(string $url, array $headers, array $body, string $method = 'POST', int $timeout = 10): array { $post = $method === 'POST' ? json_encode($body, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) : ''; $options = [ @@ -276,6 +277,6 @@ class DooPushClient if ($method !== 'POST') { $options['CURLOPT_CUSTOMREQUEST'] = $method; } - return Ihttp::ihttp_request($url, $post, array_merge($headers, $options), 10); + return Ihttp::ihttp_request($url, $post, array_merge($headers, $options), $timeout); } } diff --git a/tests/Feature/AppPushTest.php b/tests/Feature/AppPushTest.php index cf6b217af..364c64f21 100644 --- a/tests/Feature/AppPushTest.php +++ b/tests/Feature/AppPushTest.php @@ -383,6 +383,21 @@ class AppPushTest extends TestCase $this->assertSame('https://push.test/api/v1/apps/10/tenant/devices/77005', $client->requests[0]['url']); } + public function test_logout_forget_unbinds_system_devices(): void + { + $client = $this->systemMode('dp_ts_live'); + $token = 'logout-system-token'; + $this->device(910103, '77006', ['channel' => AppPush::CHANNEL_SYSTEM, 'device_hash' => md5($token)]); + $this->device(910103, '96102', ['device_hash' => md5($token)]); + UserDevice::forget($token); + $this->assertFalse(AppPushDevice::whereDeviceHash(md5($token))->exists()); + // 仅系统配置的设备需要向 DooPush 解绑,自定义通道的设备只删本地记录。 + $this->assertCount(1, $client->requests); + $this->assertSame('DELETE', $client->requests[0]['method']); + $this->assertSame('https://push.test/api/v1/apps/10/tenant/devices/77006', $client->requests[0]['url']); + $this->assertSame(3, $client->requests[0]['timeout']); + } + public function test_close_mode_does_not_record_devices(): void { $this->settings(['doopush' => 'close']); @@ -400,9 +415,9 @@ class RecordingDooPushClient extends DooPushClient public function __construct(public array $responses = []) {} - protected function request(string $url, array $headers, array $body, string $method = 'POST'): array + protected function request(string $url, array $headers, array $body, string $method = 'POST', int $timeout = 10): array { - $this->requests[] = compact('url', 'headers', 'body', 'method'); + $this->requests[] = compact('url', 'headers', 'body', 'method', 'timeout'); $response = array_shift($this->responses); if ($response instanceof \Throwable) { throw $response;