import Bowser from "bowser";
import type { SessionMetadataPayload } from "../models/AuthModels";

const LOCATION_TIMEOUT_MS = 2500;
const LOCATION_MAX_AGE_MS = 5 * 60 * 1000;
const LOCATION_CACHE_KEY = "dpz.auth.location.cache";
const LOCATION_CACHE_TTL_MS = 10 * 60 * 1000;

interface CachedLocation {
    latitude?: number;
    longitude?: number;
    locationAccuracy?: number;
    locationSource?: string;
    cachedAt: number;
}

let warmUpInFlight: Promise<void> | null = null;

export class SessionMetadataService {
    async collect(): Promise<SessionMetadataPayload> {
        const parser = Bowser.getParser(navigator.userAgent);
        const browser = parser.getBrowser();
        const os = parser.getOS();
        const platform = parser.getPlatform();
        const deviceType = platform.type || "desktop";

        return {
            deviceName: this.toDeviceName(browser.name, os.name, deviceType),
            deviceType,
            browserName: browser.name,
            browserVersion: browser.version,
            osName: os.name,
            osVersion: os.version,
            platform: platform.vendor || platform.model || platform.type,
            screenWidth: window.screen?.width,
            screenHeight: window.screen?.height,
            language: navigator.language,
            ...this.getCachedLocation(),
        };
    }

    warmUp(): void {
        if (typeof navigator === "undefined" || !("geolocation" in navigator)) {
            return;
        }

        if (warmUpInFlight !== null) {
            return;
        }

        warmUpInFlight = this.warmUpInternal().finally(() => {
            warmUpInFlight = null;
        });
    }

    private async warmUpInternal(): Promise<void> {
        try {
            if (this.isLocationCacheFresh()) {
                return;
            }

            const permission = await this.queryGeolocationPermission();
            if (permission === "denied") {
                return;
            }

            const location = await this.getBrowserLocation(true);
            this.saveLocationCache(location);
        } catch {
            // 预热失败静默忽略,登录时 collect() 会退化为仅静态信息
        }
    }

    private async queryGeolocationPermission(): Promise<PermissionState | null> {
        if (typeof navigator.permissions === "undefined") {
            return null;
        }

        try {
            const result = await navigator.permissions.query({ name: "geolocation" });
            return result.state;
        } catch {
            return null;
        }
    }

    private getCachedLocation(): Partial<SessionMetadataPayload> {
        const cached = this.readLocationCache();
        if (cached === null) {
            return {};
        }

        return {
            latitude: cached.latitude,
            longitude: cached.longitude,
            locationAccuracy: cached.locationAccuracy,
            locationSource: cached.locationSource,
        };
    }

    private isLocationCacheFresh(): boolean {
        const cached = this.readLocationCache();
        return (
            cached !== null &&
            typeof cached.cachedAt === "number" &&
            Date.now() - cached.cachedAt <= LOCATION_CACHE_TTL_MS
        );
    }

    private readLocationCache(): CachedLocation | null {
        try {
            const raw = window.localStorage.getItem(LOCATION_CACHE_KEY);
            if (raw === null) {
                return null;
            }

            const cached = JSON.parse(raw) as CachedLocation;
            if (
                typeof cached !== "object" ||
                cached === null ||
                typeof cached.cachedAt !== "number"
            ) {
                return null;
            }

            return cached;
        } catch {
            return null;
        }
    }

    private saveLocationCache(location: Partial<SessionMetadataPayload>): void {
        if (location.latitude === undefined || location.longitude === undefined) {
            return;
        }

        const cached: CachedLocation = {
            latitude: location.latitude,
            longitude: location.longitude,
            locationAccuracy: location.locationAccuracy,
            locationSource: location.locationSource,
            cachedAt: Date.now(),
        };

        try {
            window.localStorage.setItem(LOCATION_CACHE_KEY, JSON.stringify(cached));
        } catch {
            // 存储不可用时静默忽略
        }
    }

    private getBrowserLocation(waitForUser: boolean): Promise<Partial<SessionMetadataPayload>> {
        if (!("geolocation" in navigator)) {
            return Promise.resolve({});
        }

        const timeoutMs = waitForUser ? 0 : LOCATION_TIMEOUT_MS;

        return new Promise((resolve) => {
            let settled = false;
            const finish = (value: Partial<SessionMetadataPayload>) => {
                if (settled) {
                    return;
                }

                settled = true;
                resolve(value);
            };

            let timer = 0;
            if (timeoutMs > 0) {
                timer = window.setTimeout(() => finish({}), timeoutMs);
            }

            navigator.geolocation.getCurrentPosition(
                (position) => {
                    if (timer > 0) {
                        window.clearTimeout(timer);
                    }
                    finish({
                        latitude: position.coords.latitude,
                        longitude: position.coords.longitude,
                        locationAccuracy: position.coords.accuracy,
                        locationSource: "browser",
                    });
                },
                () => {
                    if (timer > 0) {
                        window.clearTimeout(timer);
                    }
                    finish({});
                },
                {
                    enableHighAccuracy: false,
                    maximumAge: LOCATION_MAX_AGE_MS,
                    timeout: timeoutMs > 0 ? timeoutMs : undefined,
                },
            );
        });
    }

    private toDeviceName(
        browserName: string | undefined,
        osName: string | undefined,
        deviceType: string,
    ) {
        const browser = browserName || "Browser";
        const os = osName || this.toDeviceTypeName(deviceType);
        return `${os} · ${browser}`;
    }

    private toDeviceTypeName(deviceType: string) {
        switch (deviceType) {
            case "mobile":
                return "Mobile";
            case "tablet":
                return "Tablet";
            case "tv":
                return "TV";
            default:
                return "Desktop";
        }
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

代码解释

这是一个 SessionMetadataService(会话元数据服务) 类,用于收集用户设备和浏览器的元数据信息,包括设备信息、浏览器信息、操作系统信息以及地理位置等。主要用于身份验证场景中标识用户会话。

核心功能

1. 常量定义

const LOCATION_TIMEOUT_MS = 2500;           // 地理位置获取超时时间
const LOCATION_MAX_AGE_MS = 5 * 60 * 1000;  // 地理位置最大缓存时间(5分钟)
const LOCATION_CACHE_KEY = "dpz.auth.location.cache"; // localStorage缓存键
const LOCATION_CACHE_TTL_MS = 10 * 60 * 1000; // 位置缓存有效期(10分钟)

2. 主要方法

collect() - 收集会话元数据

  • 使用 Bowser 库解析 User-Agent 获取浏览器、操作系统、平台信息
  • 收集设备类型、屏幕尺寸、语言等静态信息
  • 尝试从缓存获取地理位置信息
  • 返回完整的 SessionMetadataPayload 对象

warmUp() - 预热地理位置获取

  • 目的:提前获取地理位置权限和数据,避免在实际登录时等待
  • 检查是否支持地理位置 API
  • 防止重复预热(通过 warmUpInFlight 标志)
  • 异步执行预热逻辑

warmUpInternal() - 预热内部实现

1. 检查缓存是否新鲜(10分钟内)
2. 查询地理位置权限状态
3. 如果权限被拒绝,直接返回
4. 获取浏览器地理位置并缓存
5. 失败时静默忽略(不影响主流程)

3. 地理位置相关方法

queryGeolocationPermission() - 查询地理位置权限

  • 使用 Permissions API 检查权限状态
  • 返回 granted | denied | prompt | null

getBrowserLocation(waitForUser) - 获取地理位置

  • waitForUser=true: 等待用户授权,无超时限制
  • waitForUser=false: 设置 2.5 秒超时
  • 使用 navigator.geolocation.getCurrentPosition 获取位置
  • 配置选项:
    • enableHighAccuracy: false - 不要求高精度(节省电量)
    • maximumAge: 5分钟 - 接受5分钟内的缓存位置
    • 支持超时机制

缓存机制

  • 读取缓存 (readLocationCache): 从 localStorage 读取并验证缓存结构
  • 保存缓存 (saveLocationCache): 保存位置信息和时间戳
  • 检查新鲜度 (isLocationCacheFresh): 判断缓存是否在10分钟内

4. 辅助方法

toDeviceName() - 生成设备名称

// 示例输出:
"Windows · Chrome"
"macOS · Safari"
"Mobile · Chrome"

toDeviceTypeName() - 设备类型本地化

mobile/tablet/tv/desktop 转换为首字母大写的显示名称

设计亮点

  1. 渐进式增强:地理位置获取失败不影响核心功能
  2. 性能优化
    • 多层缓存机制(内存标志 + localStorage)
    • 预热机制避免登录时阻塞
  3. 用户体验
    • 超时控制避免长时间等待
    • 权限检查避免不必要的弹窗
  4. 健壮性
    • 全面的异常处理(try-catch 静默失败)
    • 降级策略(无位置信息时仅返回静态信息)
  5. 隐私友好
    • 不强制要求地理位置
    • 使用低精度模式节省资源

使用场景

典型用于登录/认证流程,用于设备指纹识别、安全审计、多设备管理等场景。

评论加载中...