This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
+186
View File
@@ -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;
}
}
+120
View File
@@ -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, '网络异常,请稍后重试');
}
}
+23
View File
@@ -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)';
}
+74
View File
@@ -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';
}