add
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'api_domain.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'endpoints.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../storage/local_store.dart';
|
||||
import '../storage/token_store.dart';
|
||||
|
||||
/// 统一网络客户端:
|
||||
/// - Base URL 来自域名发现(ApiDomain),不硬编码任何 API 域名
|
||||
/// - 公共请求头:X-Device-ID / X-App-Version / X-Platform / X-Request-ID
|
||||
/// - 登录态:Authorization: Bearer `access_token`(TokenStore 内存缓存)
|
||||
/// - 统一响应解包:code=0 → data;非 0 → ApiException;HTTP 401 → 静默刷新重试一次或登出
|
||||
/// - 缓存域名连续失败达阈值 → 重新域名发现并切换 Base URL
|
||||
class ApiClient {
|
||||
ApiClient._();
|
||||
|
||||
static late Dio _dio;
|
||||
static Dio get I => _dio;
|
||||
|
||||
/// 401 且无法刷新时的回调(由 AuthProvider 注册:清登录态并跳登录页)
|
||||
static void Function()? onSessionExpired;
|
||||
|
||||
/// 初始化(splash 阶段调用一次)
|
||||
static Future<void> init() async {
|
||||
final base = await ApiDomain.resolve();
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: base,
|
||||
connectTimeout: AppConfig.connectTimeout,
|
||||
receiveTimeout: AppConfig.receiveTimeout,
|
||||
// 业务错误统一 HTTP 200 + code,服务端 4xx/5xx 也走统一处理
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
));
|
||||
_dio.interceptors.addAll([
|
||||
_CommonHeadersInterceptor(),
|
||||
_UnwrapInterceptor(),
|
||||
_ErrorInterceptor(),
|
||||
]);
|
||||
}
|
||||
|
||||
/// ---------- 便捷请求方法:直接返回解包后的 data ----------
|
||||
/// 拦截器已完成 code!=0 → 异常、401 → 刷新重试 的统一处理
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
static Future<T> put<T>(String path, {Object? data}) async {
|
||||
final resp = await _dio.put<dynamic>(path, data: data);
|
||||
return resp.data as T;
|
||||
}
|
||||
|
||||
static Future<T> delete<T>(String path, {Object? data}) async {
|
||||
final resp = await _dio.delete<dynamic>(path, data: data);
|
||||
return resp.data as T;
|
||||
}
|
||||
}
|
||||
|
||||
/// 公共请求头注入
|
||||
class _CommonHeadersInterceptor extends Interceptor {
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
options.headers['X-Device-ID'] = LocalStore.I.deviceId;
|
||||
options.headers['X-App-Version'] = AppConfig.appVersion;
|
||||
options.headers['X-Platform'] = Platform.isIOS
|
||||
? AppConfig.platformNameIOS
|
||||
: AppConfig.platformNameAndroid;
|
||||
options.headers['X-Request-ID'] = const Uuid().v4();
|
||||
final token = TokenStore.accessToken;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一响应解包:{ code, message, data } → code=0 返回 data,非 0 抛 ApiException
|
||||
class _UnwrapInterceptor extends Interceptor {
|
||||
@override
|
||||
void onResponse(Response response, ResponseInterceptorHandler handler) {
|
||||
ApiDomain.markSuccess();
|
||||
final body = response.data;
|
||||
if (body is Map && body.containsKey('code')) {
|
||||
final code = body['code'] as int;
|
||||
if (code == 0) {
|
||||
response.data = body['data'];
|
||||
return handler.next(response);
|
||||
}
|
||||
return handler.reject(DioException(
|
||||
requestOptions: response.requestOptions,
|
||||
response: response,
|
||||
error: ApiException(
|
||||
code,
|
||||
(body['message'] as String?) ?? '请求失败',
|
||||
data: body['data'] is Map<String, dynamic>
|
||||
? body['data'] as Map<String, dynamic>
|
||||
: null,
|
||||
),
|
||||
));
|
||||
}
|
||||
handler.next(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误统一转 ApiException;401 尝试静默刷新并重试一次,失败则回调登出
|
||||
class _ErrorInterceptor extends Interceptor {
|
||||
@override
|
||||
Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
// 网络层失败(域名不可达等):计数并按需重新发现域名
|
||||
if (err.type == DioExceptionType.connectionError ||
|
||||
err.type == DioExceptionType.connectionTimeout) {
|
||||
await ApiDomain.rediscoverIfBroken();
|
||||
ApiClient._dio.options.baseUrl = ApiDomain.current;
|
||||
}
|
||||
|
||||
final apiError = err.error;
|
||||
final status = err.response?.statusCode;
|
||||
|
||||
// 401:静默刷新重试一次
|
||||
if (status == 401 &&
|
||||
err.requestOptions.extra['retried'] != true &&
|
||||
TokenStore.refreshToken != null) {
|
||||
final newToken = await _refreshToken();
|
||||
if (newToken != null) {
|
||||
try {
|
||||
final opts = err.requestOptions;
|
||||
opts.extra['retried'] = true;
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final resp = await ApiClient._dio.fetch<dynamic>(opts);
|
||||
return handler.resolve(resp);
|
||||
} catch (_) {
|
||||
// 重试仍失败 → 走下方登出
|
||||
}
|
||||
}
|
||||
await TokenStore.clear();
|
||||
ApiClient.onSessionExpired?.call();
|
||||
}
|
||||
|
||||
final message = apiError is ApiException ? apiError.message : '网络异常,请稍后重试';
|
||||
final code = apiError is ApiException ? apiError.code : -1;
|
||||
return handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: ApiException(code, message),
|
||||
));
|
||||
}
|
||||
|
||||
/// 刷新令牌(旋转:旧 refresh 立即失效);用独立 Dio 避免拦截器递归
|
||||
Future<String?> _refreshToken() async {
|
||||
final refresh = TokenStore.refreshToken;
|
||||
if (refresh == null) return null;
|
||||
try {
|
||||
final bare = Dio(BaseOptions(
|
||||
baseUrl: ApiDomain.current,
|
||||
validateStatus: (s) => s != null && s < 500,
|
||||
));
|
||||
final 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 (_) {
|
||||
// 刷新失败视为会话过期
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_exception.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../storage/local_store.dart';
|
||||
|
||||
/// API 域名发现(《01-开发文档》5.5):
|
||||
/// 1. 优先使用 --dart-define=API_BASE 调试覆盖
|
||||
/// 2. 本地缓存域名
|
||||
/// 3. DoH 解析 txt.256kb.top 的 TXT 记录,内容即真实 API 域名
|
||||
/// 4. 兜底:md5(yyyyMM) + "_jsnbhh.vip"
|
||||
/// 结果统一拼 https:// 作为 Base URL 并写入缓存。
|
||||
class ApiDomain {
|
||||
ApiDomain._();
|
||||
|
||||
static final Dio _doh = Dio(BaseOptions(
|
||||
connectTimeout: AppConfig.connectTimeout,
|
||||
receiveTimeout: AppConfig.connectTimeout,
|
||||
));
|
||||
|
||||
static int _failCount = 0;
|
||||
|
||||
/// 当前生效的 Base URL(含 https:// 前缀,无尾斜杠)
|
||||
static String? _current;
|
||||
static String get current => _current ?? '';
|
||||
|
||||
/// 启动时解析域名:按优先级逐级回退
|
||||
static Future<String> resolve() async {
|
||||
// 0. 调试覆盖
|
||||
if (AppConfig.devApiBase.isNotEmpty) {
|
||||
_current = _normalize(AppConfig.devApiBase);
|
||||
return _current!;
|
||||
}
|
||||
// 1. 本地缓存
|
||||
final cached = LocalStore.I.cachedBaseUrl;
|
||||
if (cached != null && cached.isNotEmpty) {
|
||||
_current = cached;
|
||||
return cached;
|
||||
}
|
||||
// 2/3. TXT → 兜底域名
|
||||
_current = await _discover();
|
||||
return _current!;
|
||||
}
|
||||
|
||||
/// 缓存域名连续失败达到阈值时重新发现(由 ApiClient 调用)
|
||||
static Future<String> rediscoverIfBroken() async {
|
||||
_failCount++;
|
||||
if (_failCount < AppConfig.domainFailureThreshold) return current;
|
||||
_failCount = 0;
|
||||
final next = await _discover();
|
||||
if (next != current) {
|
||||
_current = next;
|
||||
await LocalStore.I.saveBaseUrl(next);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/// 请求成功时清零失败计数
|
||||
static void markSuccess() => _failCount = 0;
|
||||
|
||||
/// 完整发现流程:TXT → 兜底
|
||||
static Future<String> _discover() async {
|
||||
final host = await _resolveTxt();
|
||||
if (host != null && host.isNotEmpty) {
|
||||
final base = _normalize(host);
|
||||
await LocalStore.I.saveBaseUrl(base);
|
||||
return base;
|
||||
}
|
||||
final base = _normalize(_fallbackDomain());
|
||||
await LocalStore.I.saveBaseUrl(base);
|
||||
return base;
|
||||
}
|
||||
|
||||
/// DoH 解析 TXT:优先阿里 223.5.5.5,失败切腾讯 119.29.29.29
|
||||
static Future<String?> _resolveTxt() async {
|
||||
for (final server in AppConfig.dohServers) {
|
||||
try {
|
||||
final resp = await _doh.get<dynamic>(
|
||||
server,
|
||||
queryParameters: {'name': AppConfig.dnsTxtHost, 'type': 'TXT'},
|
||||
);
|
||||
final body = resp.data;
|
||||
final answers = body is Map ? body['Answer'] as List? : null;
|
||||
if (answers == null || answers.isEmpty) continue;
|
||||
// TXT 记录值形如 "\"api.xxx.com\"",去掉引号
|
||||
final raw = (answers.first['data'] as String? ?? '').replaceAll('"', '').trim();
|
||||
if (raw.isNotEmpty) return raw;
|
||||
} catch (_) {
|
||||
continue; // 单个 DoH 失败不影响整体回退
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 兜底域名:md5(yyyyMM)(32 位小写)+ 后缀
|
||||
static String _fallbackDomain() {
|
||||
final now = DateTime.now();
|
||||
final ym = '${now.year}${now.month.toString().padLeft(2, '0')}';
|
||||
final digest = md5.convert(utf8.encode(ym)).toString();
|
||||
return '$digest${AppConfig.fallbackDomainSuffix}';
|
||||
}
|
||||
|
||||
/// 归一化为 Base URL:补 https://,去尾斜杠
|
||||
static String _normalize(String hostOrUrl) {
|
||||
var s = hostOrUrl.trim();
|
||||
if (!s.startsWith('http://') && !s.startsWith('https://')) {
|
||||
s = 'https://$s';
|
||||
}
|
||||
return s.endsWith('/') ? s.substring(0, s.length - 1) : s;
|
||||
}
|
||||
|
||||
/// 统一业务错误转 ApiException(域名探测等场景复用)
|
||||
static ApiException wrap(Object error) {
|
||||
if (error is ApiException) return error;
|
||||
return ApiException(-1, '网络异常,请稍后重试');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// 业务异常:对应统一响应结构 { code, message, data }
|
||||
/// code=0 为成功;非 0 抛出该异常由 UI 层捕获提示(错误码表见《03-接口文档》1.3)
|
||||
class ApiException implements Exception {
|
||||
ApiException(this.code, this.message, {this.data});
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
/// 错误响应携带的 data(如 2001 积分不足时含当前 points、2002 含 need_login)
|
||||
final Map<String, dynamic>? data;
|
||||
|
||||
/// 未登录或 Token 无效/过期
|
||||
bool get isUnauthorized => code == 1002;
|
||||
|
||||
/// 游客今日免费次数已用完(需登录引导)
|
||||
bool get isGuestLimit => code == 2002;
|
||||
|
||||
/// 积分不足(需引导充值/开通会员)
|
||||
bool get isPointsInsufficient => code == 2001;
|
||||
|
||||
@override
|
||||
String toString() => 'ApiException($code, $message)';
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/// 接口路径常量(《03-接口文档》第 2~10 章)
|
||||
/// 统一响应已由 ApiClient 解包,各 Service 只关心 data。
|
||||
class Endpoints {
|
||||
Endpoints._();
|
||||
|
||||
// ---------- 认证 ----------
|
||||
static const smsSend = '/auth/sms/send';
|
||||
static const emailSend = '/auth/email/send';
|
||||
static const register = '/auth/register';
|
||||
static const login = '/auth/login';
|
||||
static const tokenRefresh = '/auth/token/refresh';
|
||||
static const logout = '/auth/logout';
|
||||
static const passwordReset = '/auth/password/reset';
|
||||
|
||||
// ---------- 用户 ----------
|
||||
static const me = '/users/me';
|
||||
static const meAvatar = '/users/me/avatar';
|
||||
static const meWallet = '/users/me/wallet';
|
||||
static const mePointsRecords = '/users/me/points/records';
|
||||
static const meDeactivate = '/users/me/deactivate';
|
||||
static String userDetail(int userId) => '/users/$userId';
|
||||
static String userVideos(int userId) => '/users/$userId/videos';
|
||||
static const meVideos = '/users/me/videos';
|
||||
static const meFavorites = '/users/me/favorites';
|
||||
static const meHistory = '/users/me/history';
|
||||
|
||||
// ---------- 视频 ----------
|
||||
static const feed = '/videos/feed';
|
||||
static const categories = '/videos/categories';
|
||||
static const uploadToken = '/videos/upload/token';
|
||||
static const uploadFile = '/videos/upload/file';
|
||||
static const publish = '/videos';
|
||||
static const search = '/videos/search';
|
||||
static const hotSearch = '/search/hot';
|
||||
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 videoLike(int videoId) => '/videos/$videoId/like';
|
||||
static String videoFavorite(int videoId) => '/videos/$videoId/favorite';
|
||||
static String videoComments(int videoId) => '/videos/$videoId/comments';
|
||||
static String videoEdit(int videoId) => '/videos/$videoId';
|
||||
static String videoDelete(int videoId) => '/videos/$videoId';
|
||||
static String tagVideos(String tagName) => '/tags/$tagName/videos';
|
||||
|
||||
// ---------- 互动 ----------
|
||||
static String commentDelete(int commentId) => '/comments/$commentId';
|
||||
|
||||
// ---------- 会员 ----------
|
||||
static const membershipPlans = '/membership/plans';
|
||||
static const membershipMe = '/membership/me';
|
||||
static const membershipRedeem = '/membership/redeem';
|
||||
|
||||
// ---------- 支付 ----------
|
||||
static const payPackages = '/payments/packages';
|
||||
static const recharge = '/payments/recharge';
|
||||
static String orderDetail(String orderNo) => '/payments/orders/$orderNo';
|
||||
static String orderCancel(String orderNo) => '/payments/orders/$orderNo/cancel';
|
||||
|
||||
// ---------- 激励 / 推广归因 ----------
|
||||
static const referralMe = '/referral/me';
|
||||
static const referralBind = '/referral/bind';
|
||||
static const inviteMatch = '/invite/match';
|
||||
static const assetsMe = '/assets/me';
|
||||
static const assetsRecords = '/assets/records';
|
||||
static const assetsWithdraw = '/assets/withdraw';
|
||||
static const withdrawRecords = '/assets/withdraw/records';
|
||||
|
||||
// ---------- 通用 ----------
|
||||
static const appVersion = '/app/version';
|
||||
static const notifications = '/notifications';
|
||||
static const notificationsUnread = '/notifications/unread_count';
|
||||
static const notificationsRead = '/notifications/read';
|
||||
static const report = '/reports';
|
||||
}
|
||||
Reference in New Issue
Block a user