chore: initialize Guangya Flutter project

This commit is contained in:
ngfchl
2026-07-18 10:23:34 +08:00
commit 6bcc3fdf18
143 changed files with 13939 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import '../config/app_config.dart';
import 'interceptors/auth_interceptor.dart';
import 'interceptors/response_interceptor.dart';
class DioClient {
static late Dio dio;
static late Dio accountDio;
static AuthInterceptor? _authInterceptor;
/// 初始化 DIO 客户端
static void init({OnLogout? onLogout}) {
_authInterceptor = AuthInterceptor(onLogout: onLogout);
dio = _createDio(
baseUrl: AppConfig.apiBase,
interceptors: [
_authInterceptor!,
ResponseInterceptor(),
],
);
accountDio = _createDio(
baseUrl: AppConfig.accountBase,
interceptors: [
ResponseInterceptor(),
],
);
}
/// 更新 API baseUrl 并同步到 DioClient
static void updateBaseUrl(String apiBase, {String? accountBase}) {
dio.options.baseUrl = apiBase;
if (accountBase != null) {
accountDio.options.baseUrl = accountBase;
}
}
/// 重新设置 onLogout 回调(例如切换 provider 时)
static void setOnLogout(OnLogout? onLogout) {
if (_authInterceptor != null) {
_authInterceptor = AuthInterceptor(onLogout: onLogout);
dio.interceptors.removeWhere((i) => i is AuthInterceptor);
dio.interceptors.insert(0, _authInterceptor!);
}
}
static Dio _createDio({
required String baseUrl,
required List<Interceptor> interceptors,
}) {
final dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
headers: {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'Origin': 'https://www.guangyapan.com',
'Referer': 'https://www.guangyapan.com/',
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
},
),
);
// Windows 下优化连接池
if (Platform.isWindows) {
(dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
final client = HttpClient();
client.maxConnectionsPerHost = 10;
client.idleTimeout = const Duration(seconds: 60);
return client;
};
}
dio.interceptors.addAll(interceptors);
return dio;
}
}
+188
View File
@@ -0,0 +1,188 @@
import 'dart:developer';
import 'package:dio/dio.dart';
import '../storage/storage_manager.dart';
import 'dio_client.dart';
import 'http_error.dart';
import 'interceptors/response_interceptor.dart';
/// 统一 HTTP 封装 — 光鸭云盘
class Http {
/// 通用底层请求方法
static Future<T> request<T>(
String path, {
String method = 'POST',
Map<String, dynamic>? queryParameters,
dynamic data,
Map<String, dynamic>? headers,
CancelToken? cancelToken,
Options? options,
bool useAccountDio = false,
bool allowAnyCode = false,
}) async {
final client = useAccountDio ? DioClient.accountDio : DioClient.dio;
final mergedOptions = (options ?? Options()).copyWith(
method: method,
headers: headers,
);
final stopwatch = Stopwatch()..start();
final endpoint = useAccountDio ? 'account' : 'api';
log('[HTTP:$endpoint] -> $method $path');
try {
final res = await client.request(
path,
data: data,
queryParameters: queryParameters,
cancelToken: cancelToken,
options: mergedOptions,
);
stopwatch.stop();
log(
'[HTTP:$endpoint] <- $method $path status=${res.statusCode} '
'elapsed=${stopwatch.elapsedMilliseconds}ms',
);
return res.data as T;
} on DioException catch (e) {
stopwatch.stop();
final status = e.response?.statusCode;
log(
'[HTTP:$endpoint] !! $method $path status=${status ?? '-'} '
'type=${e.type.name} elapsed=${stopwatch.elapsedMilliseconds}ms',
);
// 将业务错误信息包装为 ApiException
final errorMsg = e.error is String ? (e.error as String) : '';
if (errorMsg.isNotEmpty) {
throw ApiException(status: status, message: errorMsg);
}
rethrow;
} catch (e) {
stopwatch.stop();
log(
'[HTTP:$endpoint] !! $method $path elapsed=${stopwatch.elapsedMilliseconds}ms',
);
rethrow;
}
}
/// 光鸭 API 请求(带 tokencode==200 为成功)
static Future<Map<String, dynamic>> apiRequest(
String path, {
String method = 'POST',
dynamic body,
Map<String, dynamic>? headers,
CancelToken? cancelToken,
List<int> allowedCodes = const [],
}) async {
// 过期自动刷新
_checkTokenExpiry();
final mergedHeaders = <String, dynamic>{
'traceparent': _generateTraceparent(),
...?headers,
};
final data = await request<Map<String, dynamic>>(
path,
method: method,
data: body,
headers: mergedHeaders,
cancelToken: cancelToken,
options: Options(extra: {ResponseInterceptor.skipCodeCheckKey: true}),
);
final code = _businessCode(data['code']);
if (code == null ||
code == 0 ||
code == 200 ||
allowedCodes.contains(code)) {
return data;
}
throw ApiException(
status: code,
message: extractHttpMessage(data) ?? '请求失败 ($code)',
);
}
/// 光鸭账号请求(account API 响应格式无 code 字段,跳过业务 code 检查)
static Future<Map<String, dynamic>> accountRequest(
String path, {
String method = 'POST',
Map<String, dynamic>? body,
Map<String, dynamic>? extraHeaders,
bool authenticated = false,
}) async {
final headers = <String, dynamic>{
'did': _getDeviceID(),
'dt': '4',
...?extraHeaders,
};
if (authenticated) {
final token = StorageManager.get<String>(StorageKeys.accessToken);
if (token != null && token.isNotEmpty) {
headers['authorization'] = 'Bearer $token';
}
}
final data = await request<Map<String, dynamic>>(
path,
method: method,
data: body,
headers: headers,
useAccountDio: true,
options: Options(extra: {ResponseInterceptor.skipCodeCheckKey: true}),
);
return data;
}
/// 公开请求(无 token,用于分享预览等)
static Future<Map<String, dynamic>> publicRequest(
String path, {
Map<String, dynamic>? body,
}) async {
return request<Map<String, dynamic>>(
path,
method: 'POST',
data: body,
headers: {'traceparent': _generateTraceparent()},
);
}
// ── Token 过期检测 ─────────────────────────────────────────────
static void _checkTokenExpiry() {
final expiresAt = StorageManager.get<int>(StorageKeys.tokenExpiresAt);
if (expiresAt == null) return;
if (DateTime.now().millisecondsSinceEpoch > expiresAt) {
log('[HTTP] token expired, will refresh on next 401');
}
}
static String _getDeviceID() {
return StorageManager.get<String>(StorageKeys.deviceID) ??
'flutter-${DateTime.now().millisecondsSinceEpoch}';
}
// ── Helpers ─────────────────────────────────────────────────────
static String _generateTraceparent() {
final now = DateTime.now().microsecondsSinceEpoch;
return '00-${now.toRadixString(16).padLeft(32, '0')}-${now.toRadixString(16).padLeft(16, '0')}-01';
}
static int? _businessCode(dynamic value) {
if (value == null) return null;
if (value is int) return value;
return int.tryParse(value.toString());
}
}
+95
View File
@@ -0,0 +1,95 @@
import 'package:dio/dio.dart';
const String noToastHeader = 'NoToast';
const String suppressErrorToastExtra = 'suppressErrorToast';
bool suppressErrorToast(RequestOptions options) {
return options.extra[suppressErrorToastExtra] == true;
}
void applyNoToastHeader(RequestOptions options) {
String? matchedKey;
Object? matchedValue;
for (final entry in options.headers.entries) {
if (entry.key.toLowerCase() == noToastHeader.toLowerCase()) {
matchedKey = entry.key;
matchedValue = entry.value;
break;
}
}
if (matchedKey == null) return;
options.headers.remove(matchedKey);
if (_isNoToastValue(matchedValue)) {
options.extra[suppressErrorToastExtra] = true;
}
}
bool _isNoToastValue(Object? value) {
if (value == null) return true;
final text = value.toString().trim().toLowerCase();
return text.isEmpty || text == '1' || text == 'true' || text == 'yes';
}
/// Extracts a human-readable message from various response shapes
String? extractHttpMessage(dynamic value) {
if (value == null) return null;
if (value is String) return value.trim().isEmpty ? null : value.trim();
if (value is Map) {
for (final key in const ['message', 'msg', 'info', 'detail', 'error']) {
final message = extractHttpMessage(value[key]);
if (message != null) return message;
}
return extractHttpMessage(value['data']);
}
if (value is Iterable) {
final messages = value
.map(extractHttpMessage)
.whereType<String>()
.where((message) => message.trim().isNotEmpty)
.toList();
return messages.isEmpty ? null : messages.join('\n');
}
return null;
}
/// Build a user-friendly toast message from request context + detail
String requestToastMessage(RequestOptions options, String detail) {
final endpoint = _describePath(options.path);
final trimmed = detail.trim();
if (trimmed.isEmpty) return endpoint;
return '$endpoint$trimmed';
}
String _describePath(String path) {
if (path.contains('/auth')) return '认证接口';
if (path.contains('/token')) return '令牌接口';
if (path.contains('/file')) return '文件接口';
if (path.contains('/share')) return '分享接口';
if (path.contains('/res')) return '资源接口';
if (path.contains('/task') || path.contains('/cloud')) return '云下载接口';
return '接口请求';
}
/// Custom exception for API errors
class ApiException implements Exception {
final int? status;
final String message;
ApiException({this.status, required this.message});
@override
String toString() => message;
}
class AuthTokens {
final String accessToken;
final String? refreshToken;
final double? expiresIn;
AuthTokens({
required this.accessToken,
this.refreshToken,
this.expiresIn,
});
}
@@ -0,0 +1,263 @@
import 'dart:async';
import 'dart:developer';
import 'package:dio/dio.dart';
import '../../config/app_config.dart';
import '../../storage/storage_manager.dart';
import '../http_error.dart';
import '../dio_client.dart';
/// 光鸭 OAuth token 刷新回调
typedef OnLogout = Future<void> Function();
class AuthInterceptor extends Interceptor {
final OnLogout? onLogout;
bool _isRefreshing = false;
bool _logoutScheduled = false;
final List<Completer<void>> _waitQueue = [];
AuthInterceptor({this.onLogout});
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
applyNoToastHeader(options);
// 免认证路径:登录、刷新等
if (_isAuthExemptPath(options.path)) {
return handler.next(options);
}
final token = StorageManager.get<String>(StorageKeys.accessToken);
if (token != null && token.isNotEmpty) {
_logoutScheduled = false;
options.headers['authorization'] = 'Bearer $token';
return handler.next(options);
}
// 无 token → 静默取消
return handler.reject(_silentCancel(options));
}
bool _isAuthExemptPath(String path) {
return path.contains('/auth/signin') ||
path.contains('/auth/verification') ||
path.contains('/auth/device/code') ||
path.contains('/auth/token');
}
bool _isSilentCancel(DioException err) {
return err.type == DioExceptionType.cancel &&
err.error?.toString() == 'silent_auth_cancel';
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (_isSilentCancel(err)) {
return handler.next(err);
}
final status = err.response?.statusCode;
final alreadyLoggedOut = status == 401 && _isAlreadyLoggedOut;
if (!alreadyLoggedOut) {
log('[Auth] error path=${err.requestOptions.path} status=$status');
}
// 免认证路径的错误直接放行
if (_isAuthExemptPath(err.requestOptions.path)) {
return handler.next(err);
}
// 网络错误
if (err.type == DioExceptionType.connectionError ||
err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.sendTimeout ||
err.type == DioExceptionType.receiveTimeout) {
return handler.next(err);
}
// 非 401 → 直接传递
if (status != 401) {
return handler.next(err);
}
// 以下全部是 401 处理
if (alreadyLoggedOut) {
return handler.reject(_silentCancel(err.requestOptions));
}
// 刷新接口本身 401 → refresh token 也失效了
if (err.requestOptions.path.contains('/auth/token') &&
err.requestOptions.method == 'POST') {
await _logout();
return handler.reject(_silentCancel(err.requestOptions));
}
// 获取 refreshToken
final refreshToken = StorageManager.get<String>(StorageKeys.refreshToken);
// 没有 refreshToken → 静默登出
if (refreshToken == null || refreshToken.isEmpty) {
await _logout();
return handler.reject(_silentCancel(err.requestOptions));
}
// 已经在刷新中 → 排队等待
if (_isRefreshing) {
final completer = Completer<void>();
_waitQueue.add(completer);
await completer.future;
final newToken = StorageManager.get<String>(StorageKeys.accessToken);
if (newToken == null || newToken.isEmpty) {
return handler.reject(_silentCancel(err.requestOptions));
}
final request = err.requestOptions;
request.headers['authorization'] = 'Bearer $newToken';
try {
final response = await DioClient.dio.fetch(request);
return handler.resolve(response);
} catch (e) {
return handler.reject(_silentCancel(err.requestOptions));
}
}
// 开始刷新
_isRefreshing = true;
log('[Auth] refreshing access token');
try {
final dio = Dio(
BaseOptions(
baseUrl: AppConfig.accountBase,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
),
);
final res = await dio.post(
'/v1/auth/token',
data: {
'client_id': AppConfig.clientID,
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
},
options: Options(
headers: {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json',
'Origin': 'https://www.guangyapan.com',
'Referer': 'https://www.guangyapan.com/',
'did': StorageManager.get<String>(StorageKeys.deviceID) ?? '',
'dt': '4',
'x-action': '401',
},
),
);
log('[Auth] access token refreshed');
final data = res.data;
final newAccess = _findStringDeep(data, ['access_token', 'accessToken']);
final newRefresh = _findStringDeep(data, [
'refresh_token',
'refreshToken',
]);
if (newAccess == null) {
throw Exception('刷新令牌返回数据无效');
}
// 保存新 token
await StorageManager.set(StorageKeys.accessToken, newAccess);
if (newRefresh != null) {
await StorageManager.set(StorageKeys.refreshToken, newRefresh);
}
// 保存过期时间
final expiresIn = data is Map ? data['expires_in'] : null;
if (expiresIn != null) {
final expiresAt =
DateTime.now().millisecondsSinceEpoch +
(expiresIn is int
? expiresIn
: int.tryParse(expiresIn.toString()) ?? 3600) *
1000;
await StorageManager.set(StorageKeys.tokenExpiresAt, expiresAt);
}
// 唤醒排队的请求
for (var c in _waitQueue) {
c.complete();
}
_waitQueue.clear();
// 用新 token 重试当前请求
final request = err.requestOptions;
request.headers['authorization'] = 'Bearer $newAccess';
final response = await DioClient.dio.fetch(request);
return handler.resolve(response);
} catch (e) {
log('[Auth] token refresh failed: $e');
for (var c in _waitQueue) {
c.complete();
}
_waitQueue.clear();
await _logout();
return handler.reject(_silentCancel(err.requestOptions));
} finally {
_isRefreshing = false;
}
}
DioException _silentCancel(RequestOptions options) {
return DioException(
requestOptions: options,
type: DioExceptionType.cancel,
error: 'silent_auth_cancel',
);
}
Future<void> _logout() async {
if (_logoutScheduled) return;
_logoutScheduled = true;
log('[Auth] logout scheduled');
await StorageManager.delete(StorageKeys.accessToken);
await StorageManager.delete(StorageKeys.refreshToken);
await StorageManager.delete(StorageKeys.tokenExpiresAt);
final logout = onLogout;
if (logout != null) {
await logout();
}
_logoutScheduled = false;
}
bool get _isAlreadyLoggedOut {
return StorageManager.get<String>(StorageKeys.accessToken) == null &&
StorageManager.get<String>(StorageKeys.refreshToken) == null;
}
static String? _findStringDeep(dynamic value, List<String> keys) {
if (value is Map) {
for (final key in keys) {
final v = value[key];
if (v != null && v.toString().isNotEmpty) return v.toString();
}
for (final entry in value.entries) {
if (entry.value is Map) {
final found = _findStringDeep(entry.value, keys);
if (found != null) return found;
}
}
}
return null;
}
}
@@ -0,0 +1,113 @@
import 'dart:convert';
import 'dart:developer';
import 'package:dio/dio.dart';
import '../http_error.dart';
class ResponseInterceptor extends Interceptor {
/// Extra key: true = account API (skip business code check)
static const String skipCodeCheckKey = 'skipBusinessCodeCheck';
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
final data = response.data;
// 流式响应直接放行
if (data is ResponseBody) {
return handler.next(response);
}
// 尝试解析 JSON
if (data is String) {
try {
response.data = jsonDecode(data);
} catch (_) {
// non-JSON response, pass through
}
}
final body = response.data;
if (body is! Map) {
return handler.next(response);
}
// Account API 跳过业务 code 检查(响应格式是 {data: {...}},没有 code 字段)
final skipCodeCheck =
response.requestOptions.extra[skipCodeCheckKey] == true ||
response.requestOptions.headers['x-skip-code-check'] == true;
if (skipCodeCheck) {
return handler.next(response);
}
// 业务 API:检查 code 字段
final code = _businessCode(body['code']);
final message = extractHttpMessage(body);
// code == 0/200 视为成功。不同网关返回的业务成功码不完全一致。
if (code == null || code == 0 || code == 200) {
if (message != null && message.isNotEmpty) {
response.extra['success_message'] = message;
}
return handler.next(response);
}
// 其他 code 视为业务错误
final errorMsg = message ?? _defaultErrorMessage(code);
return handler.reject(
DioException(
requestOptions: response.requestOptions,
response: response,
type: DioExceptionType.badResponse,
error: errorMsg,
),
);
}
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
if (err.type == DioExceptionType.cancel) {
return handler.next(err);
}
// 网络错误
if (err.type == DioExceptionType.connectionError ||
err.type == DioExceptionType.connectionTimeout ||
err.type == DioExceptionType.sendTimeout ||
err.type == DioExceptionType.receiveTimeout) {
if (!suppressErrorToast(err.requestOptions)) {
log(
'[HTTP] ${requestToastMessage(err.requestOptions, '请求超时或连接失败,请检查网络')}',
);
}
return handler.next(err);
}
// 其他错误
if (!suppressErrorToast(err.requestOptions)) {
final msg = err.response?.data is Map
? extractHttpMessage(err.response?.data) ?? '请求失败'
: '请求失败';
log('[HTTP] ${requestToastMessage(err.requestOptions, msg)}');
}
return handler.next(err);
}
static String? _defaultErrorMessage(dynamic code) {
if (code == 400) return '请求参数错误';
if (code == 401) return '未授权,请重新登录';
if (code == 403) return '没有权限执行此操作';
if (code == 404) return '接口不存在';
if (code == 500) return '服务器内部错误';
return '请求失败 ($code)';
}
static int? _businessCode(dynamic value) {
if (value == null) return null;
if (value is int) return value;
return int.tryParse(value.toString());
}
}