Flutter-OH OAuth 鸿蒙平台适配详细技术文档

Flutter-OH OAuth 鸿蒙平台适配详细技术文档

目录

  • [1. OAuth 2.0 基础知识](#1. OAuth 2.0 基础知识)
  • [2. 鸿蒙平台适配特点](#2. 鸿蒙平台适配特点)
  • [3. 项目架构与实现流程](#3. 项目架构与实现流程)
  • [4. 核心代码解析](#4. 核心代码解析)
  • [5. 常见问题与解决方案](#5. 常见问题与解决方案)
  • [6. 最佳实践与安全建议](#6. 最佳实践与安全建议)
  • [7. 性能优化](#7. 性能优化)
  • [8. 测试与调试](#8. 测试与调试)

1. OAuth 2.0 基础知识

1.1 什么是 OAuth 2.0

OAuth 2.0 是一个行业标准的授权协议,它允许用户授权第三方应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方应用。

1.2 OAuth 2.0 核心概念

1.2.1 角色定义
  • 资源所有者(Resource Owner):用户,拥有受保护资源的实体
  • 客户端(Client):第三方应用,需要访问资源所有者的受保护资源
  • 授权服务器(Authorization Server):颁发访问令牌的服务器(本项目为 GitCode)
  • 资源服务器(Resource Server):托管受保护资源的服务器
1.2.2 授权类型

OAuth 2.0 定义了四种授权类型:

  1. 授权码模式(Authorization Code) - 本项目采用
  2. 隐式授权模式(Implicit)
  3. 密码模式(Resource Owner Password Credentials)
  4. 客户端凭证模式(Client Credentials)

1.3 授权码模式流程

本项目采用的授权码模式是最安全、最常用的授权方式,适用于有后端服务器的应用。流程如下:

复制代码
+----------+
| Resource |
|  Owner   |
|          |
+----------+
     ^
     |
    (B)
+----|-----+          Client Identifier      +---------------+
|         -+----(A)-- & Redirection URI ---->|               |
|  User-   |                                 | Authorization |
|  Agent  -+----(B)-- User authenticates --->|     Server    |
|          |                                 |               |
|         -+----(C)-- Authorization Code ---<|               |
+-|----|---+                                 +---------------+
  |    |                                         ^      v
 (A)  (C)                                        |      |
  |    |                                         |      |
  ^    v                                         |      |
+---------+                                      |      |
|         |>---(D)-- Authorization Code ---------'      |
|  Client |          & Redirection URI                  |
|         |                                             |
|         |<---(E)----- Access Token -------------------'
+---------+       (w/ Optional Refresh Token)

步骤说明:

  • (A) 客户端引导用户到授权服务器
  • (B) 用户同意授权
  • © 授权服务器重定向用户回客户端,带上授权码
  • (D) 客户端使用授权码向授权服务器申请访问令牌
  • (E) 授权服务器验证授权码,发放访问令牌和刷新令牌

2. 鸿蒙平台适配特点

2.1 鸿蒙生态概述

HarmonyOS(鸿蒙系统)是华为推出的面向万物互联时代的智能终端操作系统。Flutter 在鸿蒙平台的适配使得开发者能够使用统一的代码库为多个平台开发应用。

2.2 Flutter 鸿蒙适配关键点

2.2.1 插件适配

本项目使用的关键插件都需要鸿蒙版本:

插件 作用 鸿蒙适配仓库
webview_flutter 显示 OAuth 授权页面 openharmony-tpc/flutter_packages.git
shared_preferences 存储 Token openharmony-tpc/flutter_packages.git
http 网络请求 官方支持跨平台
2.2.2 依赖配置

pubspec.yaml 中使用 Git 依赖指定鸿蒙版本:

yaml 复制代码
dependencies:
  webview_flutter:
    git:
      url: https://gitcode.com/openharmony-tpc/flutter_packages.git
      path: packages/webview_flutter/webview_flutter
      ref: br_webview_flutter-v4.13.0_ohos
  
  shared_preferences:
    git:
      url: "https://gitcode.com/openharmony-tpc/flutter_packages.git"
      path: "packages/shared_preferences/shared_preferences"
      ref: "br_shared_preferences-v2.5.3_ohos"

2.3 鸿蒙平台特有配置

2.3.1 网络权限配置

在鸿蒙平台需要在 module.json5 中声明网络权限:

json5 复制代码
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      }
    ]
  }
}

对于 OAuth 回调,鸿蒙平台需要配置 URI Scheme。在 module.json5abilities 中添加:

json5 复制代码
{
  "abilities": [
    {
      "name": "EntryAbility",
      "skills": [
        {
          "entities": [
            "entity.system.home"
          ],
          "actions": [
            "action.system.home",
            "ohos.want.action.viewData"
          ],
          "uris": [
            {
              "scheme": "myapp",  // 你的自定义 scheme
              "host": "oauth",
              "path": "callback"
            }
          ]
        }
      ]
    }
  ]
}

注意:

  • scheme 应该与 oauth_service.dart 中的 redirectUri 保持一致
  • 例如 redirectUri = "myapp://oauth/callback" 对应 scheme: "myapp"

2.4 WebView 在鸿蒙平台的特性

鸿蒙平台的 webview_flutter 基于 HarmonyOS Web 组件实现,具有以下特点:

  1. 性能优化:利用鸿蒙系统的分布式能力,提供流畅的网页浏览体验
  2. 安全性:支持沙箱机制,隔离网页内容
  3. 兼容性:支持主流 Web 标准,兼容大多数网页
  4. 导航控制:提供完整的页面导航控制和拦截能力

3. 项目架构与实现流程

3.1 项目结构

复制代码
testcamera/
├── lib/
│   ├── main.dart                    # 应用入口
│   ├── pages/
│   │   ├── oauth_page.dart          # OAuth 授权页面
│   │   └── user_info_page.dart      # 用户信息展示页面
│   └── services/
│       ├── oauth_service.dart       # OAuth 服务封装
│       └── storage_service.dart     # 本地存储服务
├── pubspec.yaml                     # 依赖配置
└── ohos/                            # 鸿蒙平台配置
    └── entry/src/main/
        └── module.json5             # 模块配置文件

3.2 完整授权流程

用户 应用(AuthCheckPage) StorageService WebView(OAuthPage) GitCode授权服务器 OAuthService UserInfoPage 启动应用 检查本地 Token 返回有效 Token 跳转用户信息页 Token 无效 跳转 OAuth 授权页 加载授权 URL 显示授权页面 同意授权 重定向带 code 提取 code 用 code 换 Token 返回 access_token & refresh_token 保存 Token 跳转用户信息页 alt [Token 存在且未过期] [Token 不存在或已过期] 获取用户信息 请求用户信息(带 access_token) 返回用户数据 显示用户信息 用户 应用(AuthCheckPage) StorageService WebView(OAuthPage) GitCode授权服务器 OAuthService UserInfoPage

3.3 Token 生命周期管理

应用首次启动 用户发起授权 授权成功 时间流逝 自动刷新 刷新成功 刷新失败/Refresh Token过期 用户登出 无Token 授权中 Token有效 Token即将过期 刷新中

Token 过期策略:

  1. Access Token 有效期:通常 2 小时(7200 秒)
  2. Refresh Token 有效期:通常 30 天
  3. 检查时机
    • 应用启动时
    • 发起 API 请求前
    • 后台切换回前台时

4. 核心代码解析

4.1 应用入口与 Token 检查

4.1.1 main.dart 核心逻辑
dart 复制代码
// main.dart - AuthCheckPage
class _AuthCheckPageState extends State<AuthCheckPage> {
  @override
  void initState() {
    super.initState();
    _checkAuth();
  }

  Future<void> _checkAuth() async {
    setState(() {
      _isLoading = true;
    });

    try {
      // 设置 5 秒超时,防止无限等待
      final checkFuture = Future.wait([
        StorageService.getAccessToken(),
        StorageService.isTokenExpired(),
      ]).timeout(
        const Duration(seconds: 5),
        onTimeout: () => [null, true],
      );

      final results = await checkFuture;
      final token = results[0] as String?;
      final isExpired = results[1] as bool;

      if (!mounted) return;

      setState(() {
        _isLoading = false;
      });

      // Token 有效且未过期,直接进入用户信息页
      if (token != null && !isExpired) {
        Navigator.of(context).pushReplacement(
          MaterialPageRoute(builder: (context) => const UserInfoPage()),
        );
      } else {
        // 需要重新授权
        Navigator.of(context).pushReplacement(
          MaterialPageRoute(builder: (context) => const OAuthPage()),
        );
      }
    } catch (e) {
      // 错误处理:默认跳转到授权页
      if (mounted) {
        setState(() {
          _isLoading = false;
        });
        Navigator.of(context).pushReplacement(
          MaterialPageRoute(builder: (context) => const OAuthPage()),
        );
      }
    }
  }
}

关键点:

  1. 超时机制 :使用 .timeout() 防止存储读取卡死
  2. mounted 检查:异步操作完成后检查 Widget 是否还在树中
  3. 错误降级:出现异常时默认跳转到授权页,确保用户可以重新登录

4.2 OAuth 服务封装

4.2.1 oauth_service.dart 完整实现
dart 复制代码
// oauth_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;

class OAuthService {
  // ========== OAuth 配置 ==========
  // 注意:这些配置需要在 GitCode 应用管理后台获取
  static const String clientId = '你的客户端ID';
  static const String clientSecret = '你的客户端密钥';
  static const String redirectUri = 'myapp://oauth/callback';
  
  static const String baseUrl = 'https://gitcode.com';
  static const String authorizePath = '/oauth/authorize';
  static const String tokenPath = '/oauth/token';
  static const String apiPath = '/api/v5/user';

  // ========== 数据模型 ==========
  
  /// OAuth Token 模型
  class OAuthToken {
    final String accessToken;
    final String? refreshToken;
    final int expiresIn;
    final String? tokenType;
    final String createdAt;

    OAuthToken({
      required this.accessToken,
      this.refreshToken,
      required this.expiresIn,
      this.tokenType,
      required this.createdAt,
    });

    factory OAuthToken.fromJson(Map<String, dynamic> json) {
      return OAuthToken(
        accessToken: json['access_token'],
        refreshToken: json['refresh_token'],
        expiresIn: json['expires_in'],
        tokenType: json['token_type'],
        createdAt: DateTime.now().toIso8601String(),
      );
    }
  }

  /// 用户信息模型
  class UserInfo {
    final String login;
    final String name;
    final String? avatarUrl;
    final String? email;
    final String? bio;

    UserInfo({
      required this.login,
      required this.name,
      this.avatarUrl,
      this.email,
      this.bio,
    });

    factory UserInfo.fromJson(Map<String, dynamic> json) {
      return UserInfo(
        login: json['login'] ?? '',
        name: json['name'] ?? '',
        avatarUrl: json['avatar_url'],
        email: json['email'],
        bio: json['bio'],
      );
    }
  }

  // ========== OAuth 流程方法 ==========

  /// 获取授权 URL
  /// 
  /// 参数:
  /// - scope: 请求的权限范围,多个用空格分隔
  /// - state: 防止 CSRF 攻击的随机字符串
  static String getAuthorizationUrl({
    String scope = 'user_info',
    String? state,
  }) {
    final params = {
      'client_id': clientId,
      'redirect_uri': redirectUri,
      'response_type': 'code',
      'scope': scope,
      if (state != null) 'state': state,
    };

    final uri = Uri.parse('$baseUrl$authorizePath')
        .replace(queryParameters: params);
    return uri.toString();
  }

  /// 使用授权码获取访问令牌
  /// 
  /// 参数:
  /// - code: 从授权服务器回调中获取的授权码
  /// 
  /// 返回:OAuthToken 对象
  /// 
  /// 异常:
  /// - HttpException: 网络请求失败
  /// - FormatException: JSON 解析失败
  static Future<OAuthToken> getAccessToken(String code) async {
    final url = Uri.parse('$baseUrl$tokenPath');
    
    try {
      final response = await http.post(
        url,
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          'Accept': 'application/json',
        },
        body: {
          'grant_type': 'authorization_code',
          'code': code,
          'client_id': clientId,
          'client_secret': clientSecret,
        },
      ).timeout(const Duration(seconds: 10));

      if (response.statusCode == 200) {
        final json = jsonDecode(response.body);
        return OAuthToken.fromJson(json);
      } else {
        throw Exception('获取访问令牌失败: ${response.statusCode} - ${response.body}');
      }
    } catch (e) {
      throw Exception('网络请求失败: $e');
    }
  }

  /// 刷新访问令牌
  /// 
  /// 参数:
  /// - refreshToken: 刷新令牌
  /// 
  /// 返回:新的 OAuthToken 对象
  static Future<OAuthToken> refreshAccessToken(String refreshToken) async {
    final url = Uri.parse('$baseUrl$tokenPath');
    
    try {
      final response = await http.post(
        url,
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          'Accept': 'application/json',
        },
        body: {
          'grant_type': 'refresh_token',
          'refresh_token': refreshToken,
          'client_id': clientId,
          'client_secret': clientSecret,
        },
      ).timeout(const Duration(seconds: 10));

      if (response.statusCode == 200) {
        final json = jsonDecode(response.body);
        return OAuthToken.fromJson(json);
      } else {
        throw Exception('刷新令牌失败: ${response.statusCode} - ${response.body}');
      }
    } catch (e) {
      throw Exception('刷新令牌网络请求失败: $e');
    }
  }

  /// 获取用户信息
  /// 
  /// 参数:
  /// - accessToken: 访问令牌
  /// 
  /// 返回:UserInfo 对象
  static Future<UserInfo> getUserInfo(String accessToken) async {
    final url = Uri.parse('$baseUrl$apiPath');
    
    try {
      final response = await http.get(
        url,
        headers: {
          'Authorization': 'Bearer $accessToken',
          'Accept': 'application/json',
        },
      ).timeout(const Duration(seconds: 10));

      if (response.statusCode == 200) {
        final json = jsonDecode(response.body);
        return UserInfo.fromJson(json);
      } else if (response.statusCode == 401) {
        throw Exception('访问令牌已过期或无效');
      } else {
        throw Exception('获取用户信息失败: ${response.statusCode}');
      }
    } catch (e) {
      throw Exception('获取用户信息网络请求失败: $e');
    }
  }
}

设计亮点:

  1. 完整的错误处理:每个网络请求都有 try-catch 和超时控制
  2. 清晰的数据模型 :使用 OAuthTokenUserInfo 类封装数据
  3. 文档注释:每个公共方法都有详细的文档说明
  4. Token 时间记录:在获取 Token 时记录创建时间,用于过期判断

4.3 本地存储服务

4.3.1 storage_service.dart 实现
dart 复制代码
// storage_service.dart
import 'package:shared_preferences/shared_preferences.dart';

class StorageService {
  // 存储键名
  static const String _accessTokenKey = 'access_token';
  static const String _refreshTokenKey = 'refresh_token';
  static const String _expiresInKey = 'expires_in';
  static const String _tokenCreatedAtKey = 'token_created_at';

  // ========== Access Token 操作 ==========

  /// 保存访问令牌
  static Future<void> saveAccessToken(String token) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_accessTokenKey, token);
  }

  /// 获取访问令牌
  static Future<String?> getAccessToken() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getString(_accessTokenKey);
  }

  // ========== Refresh Token 操作 ==========

  /// 保存刷新令牌
  static Future<void> saveRefreshToken(String token) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_refreshTokenKey, token);
  }

  /// 获取刷新令牌
  static Future<String?> getRefreshToken() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getString(_refreshTokenKey);
  }

  // ========== Token 过期信息 ==========

  /// 保存令牌过期时间(秒)
  static Future<void> saveExpiresIn(int expiresIn) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt(_expiresInKey, expiresIn);
  }

  /// 获取令牌过期时间(秒)
  static Future<int?> getExpiresIn() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getInt(_expiresInKey);
  }

  /// 保存令牌创建时间
  static Future<void> saveTokenCreatedAt(String createdAt) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_tokenCreatedAtKey, createdAt);
  }

  /// 获取令牌创建时间
  static Future<String?> getTokenCreatedAt() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getString(_tokenCreatedAtKey);
  }

  // ========== Token 有效性判断 ==========

  /// 判断令牌是否过期
  /// 
  /// 算法:当前时间 > (创建时间 + 有效期)
  /// 
  /// 返回:true - 已过期,false - 未过期
  static Future<bool> isTokenExpired() async {
    final expiresIn = await getExpiresIn();
    final createdAt = await getTokenCreatedAt();

    // 如果缺少任何信息,视为已过期
    if (expiresIn == null || createdAt == null) {
      return true;
    }

    try {
      final createdTime = DateTime.parse(createdAt);
      final expireTime = createdTime.add(Duration(seconds: expiresIn));
      final now = DateTime.now();
      
      return now.isAfter(expireTime);
    } catch (e) {
      // 日期解析失败,视为已过期
      return true;
    }
  }

  /// 判断令牌是否即将过期(5 分钟内)
  /// 
  /// 用于提前刷新令牌
  static Future<bool> isTokenExpiringSoon() async {
    final expiresIn = await getExpiresIn();
    final createdAt = await getTokenCreatedAt();

    if (expiresIn == null || createdAt == null) {
      return true;
    }

    try {
      final createdTime = DateTime.parse(createdAt);
      final expireTime = createdTime.add(Duration(seconds: expiresIn));
      final now = DateTime.now();
      final fiveMinutesLater = now.add(const Duration(minutes: 5));
      
      return fiveMinutesLater.isAfter(expireTime);
    } catch (e) {
      return true;
    }
  }

  // ========== 清除操作 ==========

  /// 清除所有 Token 信息(登出时使用)
  static Future<void> clearAllTokens() async {
    final prefs = await SharedPreferences.getInstance();
    await Future.wait([
      prefs.remove(_accessTokenKey),
      prefs.remove(_refreshTokenKey),
      prefs.remove(_expiresInKey),
      prefs.remove(_tokenCreatedAtKey),
    ]);
  }
}

设计亮点:

  1. 统一的键名管理:使用常量管理所有存储键,避免硬编码
  2. 完整的 Token 信息:不仅存储 Token 本身,还存储过期时间和创建时间
  3. 智能过期判断 :提供 isTokenExpired()isTokenExpiringSoon() 两种判断
  4. 异常安全:日期解析失败时默认视为已过期,保证安全性

4.4 OAuth 授权页面

4.4.1 oauth_page.dart 核心实现
dart 复制代码
// oauth_page.dart
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../services/oauth_service.dart';
import '../services/storage_service.dart';
import 'user_info_page.dart';

class OAuthPage extends StatefulWidget {
  const OAuthPage({Key? key}) : super(key: key);

  @override
  State<OAuthPage> createState() => _OAuthPageState();
}

class _OAuthPageState extends State<OAuthPage> {
  late final WebViewController _controller;
  bool _isLoading = true;
  String? _errorMessage;

  @override
  void initState() {
    super.initState();
    _initWebView();
  }

  /// 初始化 WebView
  void _initWebView() {
    // 生成防 CSRF 的 state 参数
    final state = DateTime.now().millisecondsSinceEpoch.toString();
    final authUrl = OAuthService.getAuthorizationUrl(
      scope: 'user_info',
      state: state,
    );

    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..setBackgroundColor(const Color(0x00000000))
      ..setNavigationDelegate(
        NavigationDelegate(
          onPageStarted: (String url) {
            print('页面开始加载: $url');
          },
          onPageFinished: (String url) {
            setState(() {
              _isLoading = false;
            });
            print('页面加载完成: $url');
          },
          onWebResourceError: (WebResourceError error) {
            setState(() {
              _isLoading = false;
              _errorMessage = '加载错误: ${error.description}';
            });
            print('WebView 错误: ${error.description}');
          },
          onNavigationRequest: (NavigationRequest request) {
            final url = request.url;
            print('导航请求: $url');

            // 检查是否是回调 URL
            if (url.startsWith(OAuthService.redirectUri)) {
              _handleCallback(url);
              // 阻止 WebView 继续导航
              return NavigationDecision.prevent;
            }

            // 允许其他 URL 正常导航
            return NavigationDecision.navigate;
          },
        ),
      )
      ..loadRequest(Uri.parse(authUrl));
  }

  /// 处理 OAuth 回调
  void _handleCallback(String url) async {
    try {
      final uri = Uri.parse(url);
      
      // 提取授权码
      final code = uri.queryParameters['code'];
      
      // 检查是否有错误
      final error = uri.queryParameters['error'];
      if (error != null) {
        final errorDescription = uri.queryParameters['error_description'] ?? '未知错误';
        _showError('授权失败: $errorDescription');
        return;
      }

      if (code == null || code.isEmpty) {
        _showError('未获取到授权码');
        return;
      }

      print('获取到授权码: $code');

      // 显示加载状态
      setState(() {
        _isLoading = true;
        _errorMessage = null;
      });

      // 用授权码换取访问令牌
      final token = await OAuthService.getAccessToken(code);
      print('获取到访问令牌');

      // 保存令牌信息
      await Future.wait([
        StorageService.saveAccessToken(token.accessToken),
        if (token.refreshToken != null)
          StorageService.saveRefreshToken(token.refreshToken!),
        StorageService.saveExpiresIn(token.expiresIn),
        StorageService.saveTokenCreatedAt(token.createdAt),
      ]);

      print('令牌保存成功');

      // 跳转到用户信息页
      if (!mounted) return;
      Navigator.of(context).pushReplacement(
        MaterialPageRoute(builder: (context) => const UserInfoPage()),
      );
    } catch (e) {
      print('处理回调失败: $e');
      _showError('授权失败: $e');
    }
  }

  /// 显示错误信息
  void _showError(String message) {
    if (!mounted) return;
    setState(() {
      _isLoading = false;
      _errorMessage = message;
    });
    
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: Colors.red,
        duration: const Duration(seconds: 5),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('授权登录'),
        centerTitle: true,
      ),
      body: Stack(
        children: [
          // WebView
          WebViewWidget(controller: _controller),
          
          // 加载指示器
          if (_isLoading)
            const Center(
              child: CircularProgressIndicator(),
            ),
          
          // 错误信息
          if (_errorMessage != null)
            Center(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    const Icon(
                      Icons.error_outline,
                      color: Colors.red,
                      size: 48,
                    ),
                    const SizedBox(height: 16),
                    Text(
                      _errorMessage!,
                      style: const TextStyle(color: Colors.red),
                      textAlign: TextAlign.center,
                    ),
                    const SizedBox(height: 16),
                    ElevatedButton(
                      onPressed: () {
                        setState(() {
                          _errorMessage = null;
                          _isLoading = true;
                        });
                        _initWebView();
                      },
                      child: const Text('重试'),
                    ),
                  ],
                ),
              ),
            ),
        ],
      ),
    );
  }
}

设计亮点:

  1. CSRF 防护 :使用时间戳作为 state 参数(生产环境建议使用更强的随机数)
  2. URL 拦截 :通过 onNavigationRequest 精确拦截回调 URL
  3. 完善的错误处理:区分授权错误、网络错误等不同场景
  4. 用户体验:提供加载指示器和错误重试功能
  5. 日志输出:关键步骤都有日志,方便调试

4.5 用户信息页面与 Token 刷新

4.5.1 user_info_page.dart 实现
dart 复制代码
// user_info_page.dart
import 'package:flutter/material.dart';
import '../services/oauth_service.dart';
import '../services/storage_service.dart';
import 'oauth_page.dart';

class UserInfoPage extends StatefulWidget {
  const UserInfoPage({Key? key}) : super(key: key);

  @override
  State<UserInfoPage> createState() => _UserInfoPageState();
}

class _UserInfoPageState extends State<UserInfoPage> {
  OAuthService.UserInfo? _userInfo;
  bool _isLoading = true;
  String? _errorMessage;

  @override
  void initState() {
    super.initState();
    _loadUserInfo();
  }

  /// 加载用户信息
  Future<void> _loadUserInfo() async {
    setState(() {
      _isLoading = true;
      _errorMessage = null;
    });

    try {
      // 1. 检查 Token 是否即将过期,如果是则先刷新
      final isExpiringSoon = await StorageService.isTokenExpiringSoon();
      if (isExpiringSoon) {
        print('Token 即将过期,尝试刷新');
        await _refreshToken();
      }

      // 2. 获取访问令牌
      final accessToken = await StorageService.getAccessToken();
      if (accessToken == null) {
        throw Exception('访问令牌不存在');
      }

      // 3. 请求用户信息
      final userInfo = await OAuthService.getUserInfo(accessToken);

      if (!mounted) return;
      setState(() {
        _userInfo = userInfo;
        _isLoading = false;
      });
    } catch (e) {
      print('加载用户信息失败: $e');
      
      // 如果是 401 错误,尝试刷新令牌
      if (e.toString().contains('401') || e.toString().contains('过期')) {
        try {
          await _refreshToken();
          // 刷新成功后重新加载用户信息
          await _loadUserInfo();
          return;
        } catch (refreshError) {
          print('刷新令牌失败: $refreshError');
          _handleAuthError();
          return;
        }
      }

      if (!mounted) return;
      setState(() {
        _isLoading = false;
        _errorMessage = e.toString();
      });
    }
  }

  /// 刷新访问令牌
  Future<void> _refreshToken() async {
    final refreshToken = await StorageService.getRefreshToken();
    if (refreshToken == null) {
      throw Exception('刷新令牌不存在');
    }

    // 调用刷新接口
    final newToken = await OAuthService.refreshAccessToken(refreshToken);

    // 保存新的令牌
    await Future.wait([
      StorageService.saveAccessToken(newToken.accessToken),
      if (newToken.refreshToken != null)
        StorageService.saveRefreshToken(newToken.refreshToken!),
      StorageService.saveExpiresIn(newToken.expiresIn),
      StorageService.saveTokenCreatedAt(newToken.createdAt),
    ]);

    print('令牌刷新成功');
  }

  /// 处理认证错误(跳转到登录页)
  void _handleAuthError() {
    if (!mounted) return;
    
    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (context) => AlertDialog(
        title: const Text('登录已过期'),
        content: const Text('您的登录已过期,请重新登录'),
        actions: [
          TextButton(
            onPressed: () async {
              await StorageService.clearAllTokens();
              if (!mounted) return;
              Navigator.of(context).pushAndRemoveUntil(
                MaterialPageRoute(builder: (context) => const OAuthPage()),
                (route) => false,
              );
            },
            child: const Text('重新登录'),
          ),
        ],
      ),
    );
  }

  /// 登出
  Future<void> _logout() async {
    final confirm = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('确认登出'),
        content: const Text('确定要退出登录吗?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: const Text('取消'),
          ),
          TextButton(
            onPressed: () => Navigator.pop(context, true),
            child: const Text('确定'),
          ),
        ],
      ),
    );

    if (confirm == true) {
      await StorageService.clearAllTokens();
      if (!mounted) return;
      Navigator.of(context).pushAndRemoveUntil(
        MaterialPageRoute(builder: (context) => const OAuthPage()),
        (route) => false,
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('用户信息'),
        centerTitle: true,
        actions: [
          IconButton(
            icon: const Icon(Icons.logout),
            onPressed: _logout,
            tooltip: '退出登录',
          ),
        ],
      ),
      body: _buildBody(),
    );
  }

  Widget _buildBody() {
    if (_isLoading) {
      return const Center(
        child: CircularProgressIndicator(),
      );
    }

    if (_errorMessage != null) {
      return Center(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const Icon(
                Icons.error_outline,
                color: Colors.red,
                size: 48,
              ),
              const SizedBox(height: 16),
              Text(
                _errorMessage!,
                style: const TextStyle(color: Colors.red),
                textAlign: TextAlign.center,
              ),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: _loadUserInfo,
                child: const Text('重试'),
              ),
            ],
          ),
        ),
      );
    }

    if (_userInfo == null) {
      return const Center(
        child: Text('暂无用户信息'),
      );
    }

    return RefreshIndicator(
      onRefresh: _loadUserInfo,
      child: SingleChildScrollView(
        physics: const AlwaysScrollableScrollPhysics(),
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            // 头像
            if (_userInfo!.avatarUrl != null)
              CircleAvatar(
                radius: 50,
                backgroundImage: NetworkImage(_userInfo!.avatarUrl!),
              )
            else
              const CircleAvatar(
                radius: 50,
                child: Icon(Icons.person, size: 50),
              ),
            const SizedBox(height: 16),
            
            // 用户名
            Text(
              _userInfo!.name,
              style: Theme.of(context).textTheme.headlineSmall,
            ),
            const SizedBox(height: 8),
            
            // 登录名
            Text(
              '@${_userInfo!.login}',
              style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                color: Colors.grey,
              ),
            ),
            const SizedBox(height: 24),
            
            // 其他信息
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    if (_userInfo!.email != null) ...[
                      _buildInfoRow(Icons.email, '邮箱', _userInfo!.email!),
                      const Divider(),
                    ],
                    if (_userInfo!.bio != null) ...[
                      _buildInfoRow(Icons.info, '简介', _userInfo!.bio!),
                    ],
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildInfoRow(IconData icon, String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 8.0),
      child: Row(
        children: [
          Icon(icon, size: 20, color: Colors.grey),
          const SizedBox(width: 12),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  label,
                  style: const TextStyle(
                    fontSize: 12,
                    color: Colors.grey,
                  ),
                ),
                const SizedBox(height: 4),
                Text(
                  value,
                  style: const TextStyle(fontSize: 16),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

设计亮点:

  1. 主动 Token 刷新:在 Token 即将过期时(5 分钟内)主动刷新
  2. 401 错误处理:收到 401 错误时自动尝试刷新 Token
  3. 优雅降级:刷新失败后引导用户重新登录
  4. 下拉刷新 :使用 RefreshIndicator 支持下拉刷新用户信息
  5. 完整的 UI 反馈:加载、错误、空状态都有对应的 UI

5. 常见问题与解决方案

5.1 授权相关问题

问题 1:WebView 无法加载授权页面

现象:

  • WebView 显示空白页或加载失败

可能原因:

  1. 网络权限未配置
  2. HTTPS 证书问题
  3. URL 配置错误

解决方案:

json5 复制代码
// module.json5 - 确保配置了网络权限
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      }
    ]
  }
}
dart 复制代码
// 启用 JavaScript(某些授权页面需要)
_controller = WebViewController()
  ..setJavaScriptMode(JavaScriptMode.unrestricted);
问题 2:回调 URL 无法拦截

现象:

  • 授权成功后,WebView 跳转到空白页或错误页

可能原因:

  1. redirectUri 配置不一致
  2. 平台配置未添加 URI Scheme
  3. onNavigationRequest 判断逻辑错误

解决方案:

dart 复制代码
// oauth_page.dart - 确保严格匹配 redirectUri
onNavigationRequest: (NavigationRequest request) {
  final url = request.url;
  
  // 使用 startsWith 而不是精确匹配,因为会带参数
  if (url.startsWith(OAuthService.redirectUri)) {
    _handleCallback(url);
    return NavigationDecision.prevent;
  }
  
  return NavigationDecision.navigate;
}
dart 复制代码
// oauth_service.dart - 确保 redirectUri 配置正确
static const String redirectUri = 'myapp://oauth/callback';
// ⚠️ 必须与 module.json5 中的 scheme 一致
问题 3:获取授权码后无法换取 Token

现象:

  • 能获取到 code,但 getAccessToken 请求失败

可能原因:

  1. clientIdclientSecret 错误
  2. 授权码已过期(有效期通常 10 分钟)
  3. redirectUri 不匹配

解决方案:

dart 复制代码
// 检查错误响应
static Future<OAuthToken> getAccessToken(String code) async {
  // ...
  if (response.statusCode == 200) {
    // 成功
  } else {
    // 详细记录错误信息
    print('状态码: ${response.statusCode}');
    print('响应体: ${response.body}');
    throw Exception('获取访问令牌失败: ${response.statusCode} - ${response.body}');
  }
}

检查清单:

  • clientId 是否正确
  • clientSecret 是否正确
  • redirectUri 是否与 OAuth 应用配置一致
  • 授权码是否在 10 分钟内使用
  • 授权码是否只使用了一次(不可重复使用)

5.2 Token 管理问题

问题 4:Token 过期判断不准确

现象:

  • Token 实际已过期但判断为未过期
  • 或反之

可能原因:

  1. 时区问题
  2. 创建时间未正确保存
  3. expiresIn 值错误

解决方案:

dart 复制代码
// storage_service.dart - 使用 UTC 时间统一时区
static Future<void> saveTokenCreatedAt(String createdAt) async {
  final prefs = await SharedPreferences.getInstance();
  // 确保使用 ISO8601 格式的 UTC 时间
  final utcTime = DateTime.now().toUtc().toIso8601String();
  await prefs.setString(_tokenCreatedAtKey, utcTime);
}

static Future<bool> isTokenExpired() async {
  final expiresIn = await getExpiresIn();
  final createdAt = await getTokenCreatedAt();

  if (expiresIn == null || createdAt == null) {
    return true;
  }

  try {
    final createdTime = DateTime.parse(createdAt);
    final expireTime = createdTime.add(Duration(seconds: expiresIn));
    final now = DateTime.now().toUtc();
    
    // 添加日志方便调试
    print('Token 创建时间: $createdTime');
    print('Token 过期时间: $expireTime');
    print('当前时间: $now');
    print('是否过期: ${now.isAfter(expireTime)}');
    
    return now.isAfter(expireTime);
  } catch (e) {
    print('Token 过期判断异常: $e');
    return true;
  }
}
问题 5:刷新 Token 失败

现象:

  • refreshAccessToken 返回 400 或 401 错误

可能原因:

  1. Refresh Token 已过期
  2. Refresh Token 格式错误
  3. OAuth 服务器不支持刷新令牌

解决方案:

dart 复制代码
// user_info_page.dart - 完善的刷新逻辑
Future<void> _refreshToken() async {
  try {
    final refreshToken = await StorageService.getRefreshToken();
    if (refreshToken == null || refreshToken.isEmpty) {
      throw Exception('刷新令牌不存在或为空');
    }

    final newToken = await OAuthService.refreshAccessToken(refreshToken);

    // 保存新令牌
    await Future.wait([
      StorageService.saveAccessToken(newToken.accessToken),
      // 注意:有些 OAuth 服务器刷新时不会返回新的 Refresh Token
      if (newToken.refreshToken != null && newToken.refreshToken!.isNotEmpty)
        StorageService.saveRefreshToken(newToken.refreshToken!),
      StorageService.saveExpiresIn(newToken.expiresIn),
      StorageService.saveTokenCreatedAt(newToken.createdAt),
    ]);

    print('令牌刷新成功');
  } catch (e) {
    print('刷新令牌失败: $e');
    // 如果是 400/401 错误,清除所有令牌,引导用户重新登录
    if (e.toString().contains('400') || e.toString().contains('401')) {
      await StorageService.clearAllTokens();
    }
    rethrow;
  }
}

5.3 鸿蒙平台特有问题

问题 6:shared_preferences 无法保存数据

现象:

  • 数据保存后立即读取为空
  • 应用重启后数据丢失

可能原因:

  1. 鸿蒙版本插件未正确安装
  2. 存储权限问题

解决方案:

bash 复制代码
# 1. 清除构建缓存
flutter clean

# 2. 重新获取依赖
flutter pub get

# 3. 验证插件版本
flutter pub deps
yaml 复制代码
# pubspec.yaml - 确保使用正确的鸿蒙版本
shared_preferences:
  git:
    url: "https://gitcode.com/openharmony-tpc/flutter_packages.git"
    path: "packages/shared_preferences/shared_preferences"
    ref: "br_shared_preferences-v2.5.3_ohos"
问题 7:WebView 在鸿蒙设备上显示异常

现象:

  • WebView 尺寸不正确
  • 页面无法交互

解决方案:

dart 复制代码
// oauth_page.dart - 确保 WebView 正确布局
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('授权登录'),
    ),
    body: SafeArea(  // 添加 SafeArea
      child: SizedBox(
        width: double.infinity,
        height: double.infinity,
        child: WebViewWidget(controller: _controller),
      ),
    ),
  );
}

5.4 调试技巧

技巧 1:启用详细日志
dart 复制代码
// main.dart - 在应用启动时配置日志级别
void main() {
  // 开发环境启用详细日志
  if (kDebugMode) {
    Logger.root.level = Level.ALL;
    Logger.root.onRecord.listen((record) {
      print('${record.level.name}: ${record.time}: ${record.message}');
    });
  }
  
  runApp(const MyApp());
}
技巧 2:Charles/Fiddler 抓包

对于 HTTPS 请求,可以通过代理工具抓包分析:

dart 复制代码
// 开发环境启用 HTTP 代理(仅用于调试)
import 'dart:io';

void enableProxy() {
  if (kDebugMode) {
    HttpOverrides.global = MyHttpOverrides();
  }
}

class MyHttpOverrides extends HttpOverrides {
  @override
  HttpClient createHttpClient(SecurityContext? context) {
    return super.createHttpClient(context)
      ..findProxy = (uri) {
        return 'PROXY 192.168.1.100:8888'; // Charles 代理地址
      }
      ..badCertificateCallback = (X509Certificate cert, String host, int port) => true;
  }
}

⚠️ 警告: 上述代码仅用于开发调试,生产环境必须移除!


6. 最佳实践与安全建议

6.1 OAuth 配置安全

6.1.1 敏感信息管理

不要clientSecret 硬编码在代码中:

dart 复制代码
// ❌ 错误做法
class OAuthService {
  static const String clientSecret = '3ca4fc2140014916b4430f854bcf8ecf';
}

推荐做法:

方案 1:使用环境变量(适用于有后端的架构)

dart 复制代码
// ✅ 推荐:从环境变量读取
class OAuthConfig {
  static String get clientId => const String.fromEnvironment('OAUTH_CLIENT_ID');
  static String get clientSecret => const String.fromEnvironment('OAUTH_CLIENT_SECRET');
}

// 运行时传入
// flutter run --dart-define=OAUTH_CLIENT_ID=xxx --dart-define=OAUTH_CLIENT_SECRET=xxx

方案 2:使用后端代理(最安全)

dart 复制代码
// ✅ 最佳实践:通过自己的后端交换 Token
static Future<OAuthToken> getAccessToken(String code) async {
  // 调用自己的后端接口,后端再调用 GitCode
  final response = await http.post(
    Uri.parse('https://your-backend.com/api/oauth/token'),
    body: {'code': code},
  );
  // ...
}
6.1.2 CSRF 防护
dart 复制代码
// oauth_service.dart - 生成强随机 state
import 'dart:math';
import 'dart:convert';

static String generateState() {
  final random = Random.secure();
  final values = List<int>.generate(32, (i) => random.nextInt(256));
  return base64Url.encode(values);
}

// oauth_page.dart - 验证 state
class _OAuthPageState extends State<OAuthPage> {
  late final String _state;

  @override
  void initState() {
    super.initState();
    _state = OAuthService.generateState();
    // 保存 state 用于后续验证
    _initWebView();
  }

  void _initWebView() {
    final authUrl = OAuthService.getAuthorizationUrl(
      scope: 'user_info',
      state: _state,
    );
    // ...
  }

  void _handleCallback(String url) async {
    final uri = Uri.parse(url);
    final returnedState = uri.queryParameters['state'];
    
    // 验证 state
    if (returnedState != _state) {
      _showError('CSRF 检测失败,请重试');
      return;
    }
    
    // 继续处理授权码
    // ...
  }
}

6.2 Token 安全存储

6.2.1 使用安全存储(推荐)

对于生产环境,建议使用 flutter_secure_storage 替代 shared_preferences

yaml 复制代码
# pubspec.yaml
dependencies:
  flutter_secure_storage: ^9.0.0
dart 复制代码
// secure_storage_service.dart
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

class SecureStorageService {
  static const _storage = FlutterSecureStorage(
    aOptions: AndroidOptions(
      encryptedSharedPreferences: true,
    ),
  );

  static Future<void> saveAccessToken(String token) async {
    await _storage.write(key: 'access_token', value: token);
  }

  static Future<String?> getAccessToken() async {
    return await _storage.read(key: 'access_token');
  }
}
6.2.2 Token 过期前主动刷新
dart 复制代码
// background_token_refresher.dart
import 'dart:async';

class BackgroundTokenRefresher {
  Timer? _timer;

  void start() {
    // 每 30 分钟检查一次
    _timer = Timer.periodic(const Duration(minutes: 30), (timer) {
      _checkAndRefresh();
    });
  }

  Future<void> _checkAndRefresh() async {
    final isExpiringSoon = await StorageService.isTokenExpiringSoon();
    if (isExpiringSoon) {
      try {
        final refreshToken = await StorageService.getRefreshToken();
        if (refreshToken != null) {
          final newToken = await OAuthService.refreshAccessToken(refreshToken);
          await _saveToken(newToken);
          print('后台刷新 Token 成功');
        }
      } catch (e) {
        print('后台刷新 Token 失败: $e');
      }
    }
  }

  void stop() {
    _timer?.cancel();
  }
}

// 在应用生命周期中使用
class MyApp extends StatefulWidget {
  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
  final _refresher = BackgroundTokenRefresher();

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _refresher.start();
  }

  @override
  void dispose() {
    _refresher.stop();
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      // 应用回到前台时检查 Token
      _refresher._checkAndRefresh();
    }
  }

  // ...
}

6.3 网络安全

6.3.1 证书固定(Certificate Pinning)
dart 复制代码
// certificate_pinning.dart
import 'dart:io';

class SecurityConfig {
  static HttpClient createHttpClient() {
    final client = HttpClient();
    
    client.badCertificateCallback = (X509Certificate cert, String host, int port) {
      // 验证证书指纹
      if (host == 'gitcode.com') {
        // 获取 GitCode 的证书指纹并验证
        final certFingerprint = _getCertFingerprint(cert);
        const expectedFingerprint = 'YOUR_CERT_FINGERPRINT';
        return certFingerprint == expectedFingerprint;
      }
      return false;
    };
    
    return client;
  }

  static String _getCertFingerprint(X509Certificate cert) {
    // 实现证书指纹计算
    return '';
  }
}
6.3.2 请求超时控制
dart 复制代码
// oauth_service.dart - 统一超时配置
class OAuthService {
  static const Duration _timeout = Duration(seconds: 10);
  
  static Future<OAuthToken> getAccessToken(String code) async {
    final response = await http.post(
      url,
      headers: headers,
      body: body,
    ).timeout(
      _timeout,
      onTimeout: () {
        throw TimeoutException('请求超时,请检查网络连接');
      },
    );
    // ...
  }
}

6.4 用户隐私保护

6.4.1 最小权限原则
dart 复制代码
// 只请求必要的权限
static String getAuthorizationUrl({
  String scope = 'user_info', // 仅请求基本用户信息
  String? state,
}) {
  // ...
}

// 如果需要更多权限,明确告知用户
// scope = 'user_info,projects,issues'
6.4.2 数据脱敏日志
dart 复制代码
// logger.dart
class SafeLogger {
  static void log(String message) {
    // 移除敏感信息
    final sanitized = message
        .replaceAll(RegExp(r'access_token=[^&]+'), 'access_token=***')
        .replaceAll(RegExp(r'client_secret=[^&]+'), 'client_secret=***');
    print(sanitized);
  }
}

// 使用
SafeLogger.log('请求 URL: $url');

7. 性能优化

7.1 Token 缓存策略

7.1.1 内存缓存
dart 复制代码
// storage_service.dart - 添加内存缓存
class StorageService {
  // 内存缓存
  static String? _cachedAccessToken;
  static DateTime? _cacheTime;
  static const Duration _cacheExpiry = Duration(minutes: 5);

  static Future<String?> getAccessToken() async {
    // 检查内存缓存
    if (_cachedAccessToken != null && _cacheTime != null) {
      if (DateTime.now().difference(_cacheTime!) < _cacheExpiry) {
        return _cachedAccessToken;
      }
    }

    // 从持久化存储读取
    final prefs = await SharedPreferences.getInstance();
    final token = prefs.getString(_accessTokenKey);
    
    // 更新缓存
    _cachedAccessToken = token;
    _cacheTime = DateTime.now();
    
    return token;
  }

  static Future<void> saveAccessToken(String token) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_accessTokenKey, token);
    
    // 更新缓存
    _cachedAccessToken = token;
    _cacheTime = DateTime.now();
  }

  static void clearCache() {
    _cachedAccessToken = null;
    _cacheTime = null;
  }
}

7.2 网络请求优化

7.2.1 请求合并
dart 复制代码
// api_service.dart - 避免重复请求
class ApiService {
  static Future<UserInfo>? _userInfoRequest;

  static Future<UserInfo> getUserInfo(String token) {
    // 如果已有进行中的请求,直接返回
    if (_userInfoRequest != null) {
      return _userInfoRequest!;
    }

    _userInfoRequest = OAuthService.getUserInfo(token);
    
    // 请求完成后清除
    _userInfoRequest!.whenComplete(() {
      _userInfoRequest = null;
    });

    return _userInfoRequest!;
  }
}
7.2.2 响应缓存
dart 复制代码
// cached_user_info.dart
class CachedUserInfo {
  static UserInfo? _cache;
  static DateTime? _cacheTime;
  static const Duration _cacheDuration = Duration(minutes: 10);

  static Future<UserInfo> getUserInfo(String token) async {
    // 检查缓存
    if (_cache != null && _cacheTime != null) {
      if (DateTime.now().difference(_cacheTime!) < _cacheDuration) {
        return _cache!;
      }
    }

    // 获取新数据
    final userInfo = await OAuthService.getUserInfo(token);
    
    // 更新缓存
    _cache = userInfo;
    _cacheTime = DateTime.now();
    
    return userInfo;
  }

  static void clearCache() {
    _cache = null;
    _cacheTime = null;
  }
}

7.3 WebView 性能优化

dart 复制代码
// oauth_page.dart - WebView 优化配置
void _initWebView() {
  _controller = WebViewController()
    ..setJavaScriptMode(JavaScriptMode.unrestricted)
    ..setBackgroundColor(const Color(0x00000000))
    // 启用页面缓存
    ..enableZoom(false)
    // 优化加载策略
    ..setNavigationDelegate(
      NavigationDelegate(
        onPageStarted: (url) {
          setState(() {
            _isLoading = true;
          });
        },
        onPageFinished: (url) {
          // 注入 JavaScript 优化页面性能
          _controller.runJavaScript('''
            // 隐藏不必要的页面元素
            document.querySelector('header')?.style.display = 'none';
            document.querySelector('footer')?.style.display = 'none';
          ''');
          setState(() {
            _isLoading = false;
          });
        },
        // ...
      ),
    )
    ..loadRequest(Uri.parse(authUrl));
}

8. 测试与调试

8.1 单元测试

8.1.1 Token 过期逻辑测试
dart 复制代码
// test/storage_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() {
  group('StorageService Token 过期测试', () {
    setUp(() async {
      SharedPreferences.setMockInitialValues({});
    });

    test('Token 未过期应返回 false', () async {
      // 设置 1 小时有效期的 Token
      await StorageService.saveExpiresIn(3600);
      await StorageService.saveTokenCreatedAt(DateTime.now().toIso8601String());

      final isExpired = await StorageService.isTokenExpired();

      expect(isExpired, false);
    });

    test('Token 已过期应返回 true', () async {
      // 设置 1 秒有效期的 Token
      await StorageService.saveExpiresIn(1);
      final twoSecondsAgo = DateTime.now().subtract(const Duration(seconds: 2));
      await StorageService.saveTokenCreatedAt(twoSecondsAgo.toIso8601String());

      final isExpired = await StorageService.isTokenExpired();

      expect(isExpired, true);
    });

    test('Token 信息不完整应返回 true', () async {
      // 只保存 expiresIn,不保存 createdAt
      await StorageService.saveExpiresIn(3600);

      final isExpired = await StorageService.isTokenExpired();

      expect(isExpired, true);
    });
  });
}
8.1.2 OAuth URL 生成测试
dart 复制代码
// test/oauth_service_test.dart
import 'package:flutter_test/flutter_test.dart';

void main() {
  group('OAuthService URL 生成测试', () {
    test('授权 URL 应包含所有必要参数', () {
      final url = OAuthService.getAuthorizationUrl(
        scope: 'user_info',
        state: 'test_state',
      );

      expect(url, contains('client_id='));
      expect(url, contains('redirect_uri='));
      expect(url, contains('response_type=code'));
      expect(url, contains('scope=user_info'));
      expect(url, contains('state=test_state'));
    });

    test('授权 URL 应正确编码参数', () {
      final url = OAuthService.getAuthorizationUrl();
      final uri = Uri.parse(url);

      expect(uri.queryParameters['client_id'], isNotEmpty);
      expect(uri.queryParameters['redirect_uri'], OAuthService.redirectUri);
    });
  });
}

8.2 Widget 测试

dart 复制代码
// test/oauth_page_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('OAuthPage 应显示 WebView', (WidgetTester tester) async {
    await tester.pumpWidget(
      const MaterialApp(
        home: OAuthPage(),
      ),
    );

    // 等待 Widget 构建完成
    await tester.pumpAndSettle();

    // 验证 AppBar 存在
    expect(find.text('授权登录'), findsOneWidget);

    // 验证 WebView 存在(通过类型查找)
    expect(find.byType(WebViewWidget), findsOneWidget);
  });
}

8.3 集成测试

dart 复制代码
// integration_test/oauth_flow_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('完整 OAuth 流程测试', (WidgetTester tester) async {
    // 1. 启动应用
    await tester.pumpWidget(const MyApp());
    await tester.pumpAndSettle();

    // 2. 应该显示授权页面
    expect(find.text('授权登录'), findsOneWidget);

    // 3. 等待 WebView 加载
    await tester.pumpAndSettle(const Duration(seconds: 5));

    // 注意:实际的登录操作需要手动或使用 UI 自动化工具完成

    // 4. 模拟授权完成后的场景
    // ...
  });
}

8.4 调试工具

8.4.1 DevTools 网络监控
bash 复制代码
# 启动应用并打开 DevTools
flutter run --observatory-port=8888

在浏览器中访问 http://localhost:8888,使用 Network 标签监控所有 HTTP 请求。

8.4.2 自定义调试面板
dart 复制代码
// debug_panel.dart
class DebugPanel extends StatelessWidget {
  const DebugPanel({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return FloatingActionButton(
      onPressed: () => _showDebugInfo(context),
      child: const Icon(Icons.bug_report),
    );
  }

  void _showDebugInfo(BuildContext context) async {
    final token = await StorageService.getAccessToken();
    final isExpired = await StorageService.isTokenExpired();
    final expiresIn = await StorageService.getExpiresIn();
    final createdAt = await StorageService.getTokenCreatedAt();

    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('调试信息'),
        content: SingleChildScrollView(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              Text('Access Token: ${token?.substring(0, 20) ?? "无"}...'),
              Text('Token 是否过期: $isExpired'),
              Text('有效期: $expiresIn 秒'),
              Text('创建时间: $createdAt'),
            ],
          ),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('关闭'),
          ),
        ],
      ),
    );
  }
}

// 在开发环境使用
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: const AuthCheckPage(),
        floatingActionButton: kDebugMode ? const DebugPanel() : null,
      ),
    );
  }
}

9. 附录

9.1 相关资源

官方文档
开源插件

9.2 常用命令

bash 复制代码
# Flutter 项目清理
flutter clean

# 获取依赖
flutter pub get

# 运行应用(调试模式)
flutter run

# 构建发布版本
flutter build apk --release  # Android
flutter build ios --release  # iOS

# 查看依赖树
flutter pub deps

# 分析代码
flutter analyze

# 格式化代码
flutter format lib/

# 运行测试
flutter test

# 运行集成测试
flutter test integration_test/

9.3 术语表

术语 英文 解释
授权码 Authorization Code OAuth 2.0 授权流程中的临时凭证,用于交换访问令牌
访问令牌 Access Token 用于访问受保护资源的凭证,有时效性
刷新令牌 Refresh Token 用于获取新访问令牌的凭证,有效期较长
重定向 URI Redirect URI 授权完成后的回调地址
作用域 Scope 定义应用可以访问的资源范围
客户端 ID Client ID 应用的唯一标识符
客户端密钥 Client Secret 应用的密钥,用于验证应用身份
CSRF Cross-Site Request Forgery 跨站请求伪造攻击
state 参数 State Parameter 用于防止 CSRF 攻击的随机字符串

欢迎大家加入开源鸿蒙跨平台开发者社区

相关推荐
讯方洋哥2 小时前
判断、循环
harmonyos
前端OnTheRun2 小时前
如何禁用项目中的ESLint配置?
javascript·vue.js·eslint
庄雨山2 小时前
深入解析Flutter动画体系:原理、实战与开源鸿蒙OpenHarmony对比
flutter·openharmonyos
kirk_wang2 小时前
Flutter GPUImage 库在鸿蒙平台的 GPU 图像滤镜适配实战
flutter·移动开发·跨平台·arkts·鸿蒙
前端无涯2 小时前
APP 内嵌 H5 复制功能实现:从现代 API 到兼容兜底方案
javascript
未来之窗软件服务2 小时前
幽冥大陆(五十四)V10酒店门锁SDK 鸿蒙(HarmonyOS)——东方仙盟筑基期
华为·harmonyos·仙盟创梦ide·东方仙盟·东方仙盟sdk
LFly_ice2 小时前
Next-1-启动!
开发语言·前端·javascript
cc蒲公英2 小时前
vue nextTick和setTimeout区别
前端·javascript·vue.js
柒儿吖2 小时前
命令行esh在开源鸿蒙PC平台的工程实践
开源·harmonyos·命令行