dootask/app/Models/UserDevice.php
kuaifan 84f225f3f3 feat(mobile): 兼容新 Expo 壳(dootask_expo UA)
配合 dootask-app 仓库的 Expo 迁移(见 docs/migration-eeui-to-expo.md 的 Phase 5),
让服务端和前端同时识别旧 EEUI 壳与新 Expo 壳的 User-Agent,并让 eeui.js 的同步返回
方法在 Expo 壳下优先读取 injectedJS 启动时写入的 __EXPO_INIT_DATA__ / __EXPO_VARIATES__
缓存,避免原本同步 API 变成 Promise 后破坏调用方。

后端:
- Base::isEEUIApp():同时匹配 kuaifan_eeui / dootask_expo
- UserDevice:android_(kuaifan_eeui|dootask_expo) 正则捕获标识段,版本号按实际段名取
- IndexController PDF 预览:浏览器分类兼容 android_dootask_expo / ios_dootask_expo
- SystemController::prefetch:$isApp 同时接受两种 UA
- resources/views/download.blade.php:/eeui|dootask_expo/i

前端:
- app.js:
  - isEEUIApp 正则新增 dootask_expo
  - $preload 等待条件改为 requireModuleJs 可用 OR window.__EXPO_BRIDGE_READY__,
    避免 Expo 壳下等 15 秒超时
- eeui.js:以下几个同步 getter 在 Expo 壳下先读 window.__EXPO_* 再回落到原生:
  - eeuiAppVersion / eeuiAppLocalVersion → __EXPO_INIT_DATA__.version
  - eeuiAppGetPageInfo → __EXPO_INIT_DATA__.pageInfo
  - eeuiAppGetThemeName → __EXPO_INIT_DATA__.themeName
  - eeuiAppKeyboardStatus → __EXPO_INIT_DATA__.keyboardVisible
  - eeuiAppGetVariate → __EXPO_VARIATES__[key]
  - eeuiAppGetCachesString → __EXPO_CACHES__[key](RN 侧后续要同步 broadcast)

旧 EEUI 壳不受影响:只读缓存不存在时自动回落到原有 $A.eeuiModule() 调用,
行为与改动前一致。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 09:44:53 +00:00

313 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Models;
use App\Module\Base;
use App\Module\Doo;
use App\Module\Lock;
use Cache;
use Carbon\Carbon;
use DeviceDetector\DeviceDetector;
use Illuminate\Database\Eloquent\SoftDeletes;
use Request;
/**
* App\Models\UserDevice
*
* @property int $id
* @property int|null $userid 会员ID
* @property string|null $hash TOKEN MD5
* @property string|null $detail 详细信息
* @property string|null $expired_at 过期时间
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property-read int $is_current
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelAppend()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel cancelHidden()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel change($array)
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel getKeyValue()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice query()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel remove()
* @method static \Illuminate\Database\Eloquent\Builder|AbstractModel saveOrIgnore()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereDetail($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereExpiredAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereHash($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice whereUserid($value)
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice withTrashed()
* @method static \Illuminate\Database\Eloquent\Builder|UserDevice withoutTrashed()
* @mixin \Eloquent
*/
class UserDevice extends AbstractModel
{
use SoftDeletes;
protected $table = 'user_devices';
public static int $deviceLimit = 200; // 每个用户设备限制数量
protected $appends = [
'is_current',
];
public function getDetailAttribute($value)
{
if (is_array($value)) {
return $value;
}
return Base::json2array($value);
}
public function getIsCurrentAttribute(): int
{
return $this->hash === md5(Doo::userToken()) ? 1 : 0;
}
/** ****************************************************************************** */
/** ****************************************************************************** */
/** ****************************************************************************** */
/**
* 缓存key
* @param string $hash
* @return string
*/
public static function ck(string $hash): string
{
return "user_devices:{$hash}";
}
/**
* 解析 UA 获取设备信息
* @param string $ua
* @return array
*/
private static function getDeviceInfo(string $ua): array
{
$result = [
'ip' => Base::getIp(),
'type' => '电脑',
'os' => 'Unknown',
'browser' => 'Unknown',
'version' => '',
'app_name' => '', // 客户端名称
'app_type' => '', // 客户端类型
'app_version' => '', // 客户端版本
];
if (empty($ua)) {
return $result;
}
// 使用 Device-Detector 解析 UA
$dd = new DeviceDetector($ua);
// 解析 UA 字符串
$dd->parse();
// 获取客户端信息(浏览器)
$clientInfo = $dd->getClient();
if (!empty($clientInfo)) {
$result['browser'] = $clientInfo['name'] ?? 'Unknown';
$result['version'] = $clientInfo['version'] ?? '';
}
// 获取操作系统信息
$osInfo = $dd->getOs();
if (!empty($osInfo)) {
$result['os'] = trim(($osInfo['name'] ?? '') . ' ' . ($osInfo['version'] ?? ''));
if (empty($result['os'])) {
$result['os'] = 'Unknown';
}
}
if (preg_match("/android_(kuaifan_eeui|dootask_expo)/i", $ua, $m)) {
// Android 客户端(兼容旧 EEUI 与新 Expo 壳)
$result['app_type'] = 'Android';
if ($dd->getBrandName() && $dd->getModel()) {
// 厂商+型号
$result['app_name'] = $dd->getBrandName() . ' ' . $dd->getModel();
} elseif ($dd->getBrandName()) {
// 仅厂商
$result['app_name'] = $dd->getBrandName();
} elseif ($dd->isTablet()) {
// 平板
$result['app_name'] = 'Tablet';
} elseif ($dd->isPhablet()) {
// 平板
$result['app_name'] = 'Phablet';
}
$result['app_version'] = self::getAfterVersion($ua, $m[1] . '/');
} elseif (preg_match("/ios_(kuaifan_eeui|dootask_expo)/i", $ua, $m)) {
// iOS 客户端(兼容旧 EEUI 与新 Expo 壳)
$result['app_type'] = 'iOS';
if (preg_match("/(macintosh|ipad)/i", $ua)) {
// iPad
$result['app_name'] = 'iPad';
} elseif (preg_match("/iphone/i", $ua)) {
// iPhone
$result['app_name'] = 'iPhone';
}
$result['app_version'] = self::getAfterVersion($ua, $m[1] . '/');
} elseif (preg_match("/dootask/i", $ua)) {
// DooTask 客户端
$result['app_type'] = $osInfo['name'];
$result['app_version'] = self::getAfterVersion($ua, 'dootask/');
} else {
// 其他客户端
$result['app_type'] = 'Web';
$result['app_version'] = Base::getClientVersion();
}
return $result;
}
/**
* 从 ua 的 find 之后的内容获取版本号
* @param string $ua
* @param string $find
* @return string
*/
private static function getAfterVersion(string $ua, string $find): string
{
$findPattern = preg_quote($find, '/');
if (preg_match("/{$findPattern}(.*?)(?:\s|$)/i", $ua, $matches)) {
$appInfo = $matches[1];
// 从内容中提取版本号寻找符合x.x.x格式的部分
if (preg_match("/(\d+\.\d+(?:\.\d+)*)/", $appInfo, $versionMatches)) {
return $versionMatches[1];
}
}
return '';
}
/** ****************************************************************************** */
/** ****************************************************************************** */
/** ****************************************************************************** */
/**
* 检查用户是否存在
* @return string|null
*/
public static function check(): ?string
{
$token = Doo::userToken();
$userid = Doo::userId();
$hash = md5($token);
if (Cache::has(self::ck($hash))) {
return $hash;
}
$row = self::whereHash($hash)->first();
if ($row) {
// 判断是否过期
if ($row->expired_at && Carbon::parse($row->expired_at)->isPast()) {
self::forget($row);
return null;
}
// 更新缓存
self::record();
return $hash;
}
// 没有记录
return null;
}
/**
* 记录设备(添加、更新)
* @param string|null $token
* @return self|null
*/
public static function record(string $token = null): ?self
{
if (empty($token)) {
$token = Doo::userToken();
$userid = Doo::userId();
$expiredAt = Doo::userExpiredAt();
} else {
$info = Doo::tokenDecode($token);
$userid = $info['userid'] ?? 0;
$expiredAt = $info['expired_at'];
}
$hash = md5($token);
//
return Lock::withLock("userDeviceRecord:{$hash}", function () use ($expiredAt, $userid, $hash, $token) {
return AbstractModel::transaction(function () use ($expiredAt, $userid, $hash, $token) {
$row = self::whereHash($hash)->first();
if (empty($row)) {
// 生成一个新的设备记录
$row = self::createInstance([
'userid' => $userid,
'hash' => $hash,
]);
if (!$row->save()) {
return null;
}
// 删除多余的设备记录
$currentDeviceCount = self::whereUserid($userid)->count();
if ($currentDeviceCount > self::$deviceLimit) {
$rows = self::whereUserid($userid)->orderBy('id')->take($currentDeviceCount - self::$deviceLimit)->get();
foreach ($rows as $row) {
UserDevice::forget($row);
}
}
}
$row->expired_at = $expiredAt;
if (Request::hasHeader('version')) {
$deviceInfo = array_merge(Base::json2array($row->detail), self::getDeviceInfo($_SERVER['HTTP_USER_AGENT'] ?? ''));
$row->detail = Base::array2json($deviceInfo);
}
$row->save();
Cache::put(self::ck($hash), $row->userid, now()->addHour());
return $row;
});
});
}
/**
* 忘记设备(删除)
* @param UserDevice|string|int|null $token
* - UserDevice 表示指定的设备对象
* - string 表示指定的 token
* - int 表示指定的数据ID
* - null 表示当前登录的设备
* @return void
*/
public static function forget(UserDevice|string|int $token = null): void
{
if ($token instanceof UserDevice) {
$hash = $token->hash;
$token->delete();
} elseif (Base::isNumber($token)) {
$row = self::find(intval($token));
if ($row) {
$hash = $row->hash;
$row->delete();
}
} else {
if ($token === null) {
$token = Doo::userToken();
}
if ($token) {
$hash = md5($token);
self::whereHash($hash)->delete();
}
}
if (isset($hash)) {
Cache::forget(self::ck($hash));
UmengAlias::whereDeviceHash($hash)->delete();
}
}
}