This commit is contained in:
root
2026-10-16 13:51:00 +08:00
parent 6bf949949c
commit 8fc899d0a9
3830 changed files with 11228 additions and 188377 deletions
+137 -29
View File
@@ -43,28 +43,53 @@ class ApiClient {
}
/// ---------- 便捷请求方法:直接返回解包后的 data ----------
/// 拦截器已完成 code!=0 → 异常、401 → 刷新重试 的统一处理
/// 拦截器已完成 code!=0 → 异常、401 → 刷新重试 的统一处理;
/// 这里兜底把 DioException 转成 ApiException(否则 UI 只能收到未知类型异常,
/// 2002 游客超限 / 2001 积分不足等业务弹窗全部失效)
static Future<T> get<T>(String path, {Map<String, dynamic>? queryParameters}) async {
final resp = await _dio.get<dynamic>(path, queryParameters: queryParameters);
return resp.data as T;
try {
final resp = await _dio.get<dynamic>(path, queryParameters: queryParameters);
return resp.data as T;
} on DioException catch (e) {
throw _unwrap(e);
}
}
static Future<T> post<T>(String path,
{Object? data, ProgressCallback? onSendProgress}) async {
final resp = await _dio.post<dynamic>(path,
data: data, onSendProgress: onSendProgress);
return resp.data as T;
try {
final resp = await _dio.post<dynamic>(path,
data: data, onSendProgress: onSendProgress);
return resp.data as T;
} on DioException catch (e) {
throw _unwrap(e);
}
}
static Future<T> put<T>(String path, {Object? data}) async {
final resp = await _dio.put<dynamic>(path, data: data);
return resp.data as T;
try {
final resp = await _dio.put<dynamic>(path, data: data);
return resp.data as T;
} on DioException catch (e) {
throw _unwrap(e);
}
}
static Future<T> delete<T>(String path, {Object? data}) async {
final resp = await _dio.delete<dynamic>(path, data: data);
return resp.data as T;
try {
final resp = await _dio.delete<dynamic>(path, data: data);
return resp.data as T;
} on DioException catch (e) {
throw _unwrap(e);
}
}
/// DioException → ApiException:保留拦截器已包装的业务错误(含 data)
static ApiException _unwrap(DioException e) {
final err = e.error;
if (err is ApiException) return err;
return ApiException(-1, '网络异常,请稍后重试');
}
}
@@ -86,6 +111,37 @@ class _CommonHeadersInterceptor extends Interceptor {
}
}
/// 递归把 data 中所有 `*_url` / `*_Url` 结尾、且以 `/` 开头(非 `//`)的相对路径
/// 拼上 [ApiDomain.current]。只补一次,幂等。
Object? _resolveRelativeUrls(Object? node, String baseUrl) {
if (node is Map<String, dynamic>) {
for (final entry in node.entries) {
final v = entry.value;
if (v is String &&
v.isNotEmpty &&
v.startsWith('/') &&
!v.startsWith('//') &&
(entry.key.endsWith('_url') ||
entry.key.endsWith('_Url') ||
entry.key.endsWith('_URL') ||
entry.key == 'url' ||
entry.key == 'src')) {
node[entry.key] = baseUrl + v;
} else {
node[entry.key] = _resolveRelativeUrls(v, baseUrl);
}
}
return node;
}
if (node is List) {
for (var i = 0; i < node.length; i++) {
node[i] = _resolveRelativeUrls(node[i], baseUrl);
}
return node;
}
return node;
}
/// 统一响应解包:{ code, message, data } → code=0 返回 data,非 0 抛 ApiException
class _UnwrapInterceptor extends Interceptor {
@override
@@ -95,7 +151,7 @@ class _UnwrapInterceptor extends Interceptor {
if (body is Map && body.containsKey('code')) {
final code = body['code'] as int;
if (code == 0) {
response.data = body['data'];
response.data = _resolveRelativeUrls(body['data'], ApiDomain.current);
return handler.next(response);
}
return handler.reject(DioException(
@@ -110,12 +166,27 @@ class _UnwrapInterceptor extends Interceptor {
),
));
}
// 非统一包裹响应(如 FastAPI 直接返回的 {"detail": ...} 4xx):
// 不能当作成功把 detail 透传给业务层,统一转 ApiException
final status = response.statusCode ?? 200;
if (status >= 400) {
final detail = body is Map ? (body['detail']?.toString() ?? '') : '';
return handler.reject(DioException(
requestOptions: response.requestOptions,
response: response,
error: ApiException(-1, detail.isNotEmpty ? detail : '请求失败(HTTP $status)'),
));
}
handler.next(response);
}
}
/// 错误统一转 ApiException;401 尝试静默刷新并重试一次,失败则回调登出
/// 错误统一转 ApiException;401 尝试静默刷新并重试一次,确认会话失效才登出
class _ErrorInterceptor extends Interceptor {
/// 刷新单飞锁:并发 401 只触发一次刷新,避免 refresh token 轮换互踩
/// (多请求同时携带旧 refresh 请求,后到者必被服务端拒绝 → 误登出)
static Future<String?>? _refreshing;
@override
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
// 网络层失败(域名不可达等):计数并按需重新发现域名
@@ -132,7 +203,16 @@ class _ErrorInterceptor extends Interceptor {
if (status == 401 &&
err.requestOptions.extra['retried'] != true &&
TokenStore.refreshToken != null) {
final newToken = await _refreshToken();
String? newToken;
try {
newToken = await _refreshToken();
} on ApiException catch (refreshErr) {
// 刷新请求本身网络失败:不清登录态,透传错误
return handler.next(DioException(
requestOptions: err.requestOptions,
error: refreshErr,
));
}
if (newToken != null) {
try {
final opts = err.requestOptions;
@@ -140,8 +220,27 @@ class _ErrorInterceptor extends Interceptor {
opts.headers['Authorization'] = 'Bearer $newToken';
final resp = await ApiClient._dio.fetch<dynamic>(opts);
return handler.resolve(resp);
} on DioException catch (retryErr) {
final retryApi = retryErr.error;
final retryStatus = retryErr.response?.statusCode;
// 重试后仍是鉴权失败(401 / 1002)→ 确认会话失效,登出;
// 其余失败(网络抖动、业务错误等)不清登录态,直接透传错误
final stillAuthFailed = retryStatus == 401 ||
(retryApi is ApiException && retryApi.isUnauthorized);
if (!stillAuthFailed) {
return handler.next(DioException(
requestOptions: err.requestOptions,
error: retryApi is ApiException
? retryApi
: ApiException(-1, '网络异常,请稍后重试'),
));
}
} catch (_) {
// 重试仍失败 → 走下方登出
// 非 Dio 失败:视为网络异常,不清登录态
return handler.next(DioException(
requestOptions: err.requestOptions,
error: ApiException(-1, '网络异常,请稍后重试'),
));
}
}
await TokenStore.clear();
@@ -150,37 +249,46 @@ class _ErrorInterceptor extends Interceptor {
final message = apiError is ApiException ? apiError.message : '网络异常,请稍后重试';
final code = apiError is ApiException ? apiError.code : -1;
final data = apiError is ApiException ? apiError.data : null;
return handler.next(DioException(
requestOptions: err.requestOptions,
error: ApiException(code, message),
error: ApiException(code, message, data: data),
));
}
/// 刷新令牌(旋转:旧 refresh 立即失效);用独立 Dio 避免拦截器递归
Future<String?> _refreshToken() async {
/// 刷新令牌(旋转:旧 refresh 立即失效);用独立 Dio 避免拦截器递归。
/// - 服务端明确拒绝(业务失败)→ 返回 null,调用方登出
/// - 网络层失败 → 抛异常,调用方透传错误、不清登录态
Future<String?> _refreshToken() {
// 单飞:进行中的刷新直接共享其结果
return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
}
Future<String?> _doRefresh() async {
final refresh = TokenStore.refreshToken;
if (refresh == null) return null;
Response<Map<String, dynamic>> resp;
try {
final bare = Dio(BaseOptions(
baseUrl: ApiDomain.current,
validateStatus: (s) => s != null && s < 500,
));
final resp = await bare.post<Map<String, dynamic>>(
resp = await bare.post<Map<String, dynamic>>(
Endpoints.tokenRefresh,
data: {'refresh_token': refresh},
);
final body = resp.data;
if (body != null && body['code'] == 0) {
final data = body['data'] as Map<String, dynamic>;
await TokenStore.save(
access: data['access_token'] as String,
refresh: data['refresh_token'] as String,
);
return TokenStore.accessToken;
}
} catch (_) {
// 刷新失败视为会话过期
throw ApiException(-1, '网络异常,请稍后重试');
}
return null;
final body = resp.data;
if (body != null && body['code'] == 0) {
final data = body['data'] as Map<String, dynamic>;
await TokenStore.save(
access: data['access_token'] as String,
refresh: data['refresh_token'] as String,
);
return TokenStore.accessToken;
}
return null; // 服务端明确拒绝刷新 → 会话确实过期
}
}
+2
View File
@@ -81,6 +81,8 @@ class ApiDomain {
final resp = await _doh.get<dynamic>(
server,
queryParameters: {'name': AppConfig.dnsTxtHost, 'type': 'TXT'},
// 腾讯 DoH (119.29.29.29/dns-query) 必须显式声明 JSON 才返回 JSON
options: Options(headers: {'Accept': 'application/dns-json'}),
);
final body = resp.data;
final answers = body is Map ? body['Answer'] as List? : null;
+2
View File
@@ -35,6 +35,8 @@ class Endpoints {
static String videoDetail(int videoId) => '/videos/$videoId';
static String videoWatch(int videoId) => '/videos/$videoId/watch';
static String videoWatchComplete(int videoId) => '/videos/$videoId/watch/complete';
static String videoSeries(int videoId) => '/videos/$videoId/series';
static String seriesDetail(int seriesId) => '/videos/series/$seriesId';
static String videoLike(int videoId) => '/videos/$videoId/like';
static String videoFavorite(int videoId) => '/videos/$videoId/favorite';
static String videoComments(int videoId) => '/videos/$videoId/comments';