fix(push): 退出登录时系统配置模式的推送设备同步向 DooPush 解绑

users/logout 先按 token 哈希删除本地推送设备,App 随后的 remove 找不到记录而漏发解绑;
统一由 DeviceRegistry::forgetHash 清理并解绑,解绑请求超时缩短为 3 秒,不拖慢退出。
This commit is contained in:
kuaifan 2026-10-19 16:22:32 +00:00
parent 531129f48d
commit a74778f77e
4 changed files with 39 additions and 16 deletions

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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;