flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
import 'package:new_learn/core/config/app_config.dart';
/// ============================================================
/// 【ApiConfig】接口配置 —— 全项目唯一接口地址出处
///
/// 地址来源:品牌 × 环境 组合解析(对标 uni-app appList × envList):
/// prod 环境 → 当前品牌专属地址(如 aixue → ylax-api.cqbzyx.com)
/// 其他环境 → 环境通用地址(dev/test)
/// 具体组合规则见 core/config/app_config.dart。
///
/// 日常开发连测试环境,不用改代码,运行时带参数即可:
/// flutter run --dart-define=ENV=test
/// ============================================================
class ApiConfig {
ApiConfig._();
/// 接口地址(品牌 × 环境 组合后的最终值)
static String get baseUrl => AppConfig.baseUrl;
}
+313
View File
@@ -0,0 +1,313 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/network/api_config.dart';
import 'package:new_learn/core/storage/user_storage.dart';
import 'package:talker_dio_logger/talker_dio_logger.dart';
/// ============================================================
/// 【DioClient】Dio 网络请求封装(生产级单例)
///
/// 一、为什么必须封装?
/// 裸用 Dio 时,每个页面都要重复写 baseUrl / 超时 / Token / 错误处理,
/// 项目一大会失控。封装后全项目共享【一个 Dio 实例】+【一套拦截器】。
///
/// 二、核心设计(4 层):
/// 1. 单例 :DioClient.instance 全局唯一,复用底层连接池;
/// 2. BaseOptions:统一 baseUrl / 超时 / 公共请求头;
/// 3. 拦截器三件套(InterceptorsWrapper):
/// - onRequest :打印请求日志 + 自动加 Authorization Token;
/// - onResponse :打印响应日志;
/// - onError :401 清 Token 跳登录 + 把 DioException 翻译成 ApiException;
/// 4. 泛型方法 :get / post,内部统一把错误转成 ApiException 再抛出,
/// UI 层只 catch 一个类型,永不接触 Dio 细节。
///
/// 三、Token 生命周期:
/// 登录成功 → user_storage.dart 存安全存储 + 这里写内存 accessToken;
/// 冷启动 → main() 里调 restoreToken(),把磁盘 token 回填内存,
/// 后续请求自动带 Authorization。
///
/// 四、高频坑(Dio 5.x 专属,别再用老教程):
/// 1. timeout 是 Duration 类型,不是旧版的 int 毫秒数;
/// 2. DioError 已改名 DioException(5.0 起),import 错了编译不过;
/// 3. 错误拦截器必须以 handler.reject() / handler.resolve() 结束,
/// 否则请求会永久挂起不返回;
/// 4. handler.reject 的参数类型是 DioException(不是任意对象),
/// 把翻译好的 ApiException 挂在 error 字段再抛给调用方;
/// 5. Android 真机请求网络必须加 INTERNET 权限(本工程 Manifest 已加)。
/// ============================================================
class DioClient {
// ---------- 1. 单例:整个 App 只有一个 Dio ----------
DioClient._()
: _dio = Dio(
BaseOptions(
// 正式环境接口:统一从 ApiConfig 取,换环境只改 api_config.dart
baseUrl: ApiConfig.baseUrl,
connectTimeout: const Duration(seconds: 10), // 连接超时
sendTimeout: const Duration(seconds: 10), // 发送超时
receiveTimeout: const Duration(seconds: 10), // 接收超时
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
),
) {
_dio.interceptors.add(
TalkerDioLogger(
talker: talker,
settings: const TalkerDioLoggerSettings(
printResponseTime: true,
hiddenHeaders: <String>{
'authorization',
},
),
),
);
_dio.interceptors.add(_buildInterceptors());
}
static final DioClient instance = DioClient._();
final Dio _dio;
/// 内存中的 Token(拦截器用它拼 Authorization)。
/// 登录成功后写这里;冷启动由 restoreToken() 从安全存储回填。
String? accessToken;
/// 冷启动回填 Token:从安全存储读出来放进内存。
/// 必须在 runApp 之前 await(见 main.dart)。
Future<void> restoreToken() async {
accessToken = await UserStorage.instance.readToken();
if (accessToken != null) {
debugPrint('▶ 【Token】已从安全存储恢复登录态');
}
}
// ---------- 2. 拦截器 ----------
InterceptorsWrapper _buildInterceptors() {
return InterceptorsWrapper(
// ① 请求发出前:加 Token + 打印日志
onRequest: (options, handler) {
// OSS 直传会带自己的 authorization 头(OSS4-HMAC-SHA256 签名),
// 任何形式存在 authorization 都不再加 Bearer,否则会覆盖 OSS 签名
final hasAuth = options.headers['Authorization'] != null ||
options.headers['authorization'] != null;
if (accessToken != null && !hasAuth) {
options.headers['Authorization'] = 'Bearer $accessToken';
}
debugPrint('▶ 【请求】${options.method} ${options.uri}');
handler.next(options); // 放行
},
// ② 收到响应后:打印日志
onResponse: (response, handler) {
debugPrint(
'◀ 【响应】${response.statusCode} '
'${response.requestOptions.method} ${response.requestOptions.uri}',
);
handler.next(response); // 放行
},
// ③ 出错时:统一翻译成 ApiException
onError: (err, handler) {
final apiException = _mapError(err);
debugPrint(
'✖ 【请求失败】${err.requestOptions.method} '
'${err.requestOptions.uri}\n → ${apiException.message}',
);
// 401 未登录/登录过期:清内存 Token 并提示(真实项目在这里跳登录页)
if (err.response?.statusCode == 401) {
accessToken = null;
debugPrint('⚠ 【401】登录已过期,请重新登录');
}
// 注意:必须以 reject 收尾,且 reject 的参数类型是 DioException。
// 把翻译好的 ApiException 挂在 error 字段,调用方 catch 后再拆出来。
handler.reject(
DioException(
requestOptions: err.requestOptions,
response: err.response,
type: err.type,
error: apiException,
stackTrace: err.stackTrace,
),
);
},
);
}
/// 把 DioException 映射成带中文提示的 ApiException
/// (错误拦截器 reject 的 ApiException 会被 Dio 包进 error 字段再抛回来)
ApiException _mapError(DioException e) {
final inner = e.error;
if (inner is ApiException) return inner; // 已在拦截器翻译过,直接返回
final message = switch (e.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout =>
'网络连接超时,请稍后重试',
DioExceptionType.badResponse =>
'服务器开小差了(${e.response?.statusCode})',
DioExceptionType.cancel => '请求已取消',
DioExceptionType.connectionError => '网络异常,请检查网络连接',
_ => '未知错误(${e.type.name})',
};
return ApiException(e, message);
}
// ---------- 3. 泛型请求方法(业务层只调这几个) ----------
/// GET 请求:path 为相对路径,如 '/posts/1';返回服务器 JSON 解码后的数据
///
/// 生产级参数:
/// - [cancelToken] :可取消请求(页面销毁 / 用户点取消时 cancel());
/// - [onReceiveProgress]:下载进度回调(大文件/图片下载显示进度条);
/// - [retries] :失败自动重试次数(指数退避)。只对网络类错误
/// 生效,业务错误 4xx/5xx 和主动取消都不重试(见 _canRetry);
/// - [headers] :本次请求额外/覆盖的头。典型场景:下载媒体文件
/// 时覆盖全局 Content-Type 为 text/plain——application/json 是非简单
/// 头,Web 跨域会触发 OPTIONS 预检,服务器不支持就 405 被拦(CORS)。
Future<dynamic> get(
String path, {
Map<String, dynamic>? query,
CancelToken? cancelToken,
ProgressCallback? onReceiveProgress,
int retries = 0,
Map<String, dynamic>? headers,
}) async {
var attempt = 0;
while (true) {
try {
final resp = await _dio.get<dynamic>(
path,
queryParameters: query,
cancelToken: cancelToken,
onReceiveProgress: onReceiveProgress,
options: headers == null ? null : Options(headers: headers),
);
return resp.data;
} on DioException catch (e) {
// 主动取消 / 业务错误 / 已重试满 → 直接抛(翻译成 ApiException)
if (!_canRetry(e) || attempt >= retries) {
throw _mapError(e);
}
attempt++;
// 指数退避:300ms → 600ms → 1.2s...(弱网时给服务器喘息时间)
await Future<void>.delayed(_backoff(attempt));
// 退避期间用户点了取消 → 下一次请求会立即抛 cancel,先主动感知
if (cancelToken?.isCancelled ?? false) {
throw DioException(
requestOptions: e.requestOptions,
type: DioExceptionType.cancel,
error: ApiException(e, '请求已取消'),
);
}
debugPrint('↻ 【重试】$path 第 $attempt 次(${e.type.name})');
}
}
}
/// POST 请求:data 为要提交的对象(Map / 模型.toJson() / FormData 上传)
///
/// 生产级参数:
/// - [cancelToken] :可取消请求;
/// - [onSendProgress] :上传进度回调,如 onSendProgress: (sent, total);
/// - [onReceiveProgress]:下载进度回调;
/// - [retries] :POST 默认 0 = 不重试!重试会重复提交数据,
/// 有副作用风险;只有接口本身幂等时才显式传;
/// - [headers] :本次请求额外/覆盖的头(同 get 的 CORS 说明)。
Future<dynamic> post(
String path, {
dynamic data,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
ProgressCallback? onReceiveProgress,
int retries = 0,
Map<String, dynamic>? headers,
}) async {
var attempt = 0;
while (true) {
try {
final resp = await _dio.post<dynamic>(
path,
data: data,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
onReceiveProgress: onReceiveProgress,
options: headers == null ? null : Options(headers: headers),
);
return resp.data;
} on DioException catch (e) {
if (!_canRetry(e) || attempt >= retries) {
throw _mapError(e);
}
attempt++;
await Future<void>.delayed(_backoff(attempt));
if (cancelToken?.isCancelled ?? false) {
throw DioException(
requestOptions: e.requestOptions,
type: DioExceptionType.cancel,
error: ApiException(e, '请求已取消'),
);
}
debugPrint('↻ 【重试】$path 第 $attempt 次(${e.type.name})');
}
}
}
/// 是否值得自动重试:只重试"网络层问题"(连不上/超时/闪断)。
/// 主动取消(cancel)和业务错误(badResponse 4xx/5xx)都不重试,
/// 前者是用户意图,后者重试也大概率同样报错。
bool _canRetry(DioException e) {
return switch (e.type) {
DioExceptionType.connectionError ||
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout =>
true,
_ => false,
};
}
/// 指数退避延迟:300ms * 2^(attempt-1)
Duration _backoff(int attempt) =>
Duration(milliseconds: 300 * (1 << (attempt - 1)));
// ---------- 4. OSS 直传(PUT) ----------
/// PUT 请求:阿里云 OSS 直传专用。
///
/// 与 get/post 的区别:
/// 1. [url] 是【绝对地址】(https://{bucket}.{region}.aliyuncs.com/...),
/// Dio 检测到绝对 URL 会自动忽略 baseUrl;
/// 2. [data] 传文件字节(Uint8List);
/// 3. [headers] 必须传 OSS 签名头(Content-Type / authorization / x-oss-*),
/// 拦截器识别 authorization 后不会再拼 Bearer;
/// 4. 不做自动重试:大文件重传成本高,失败交给业务层决定。
Future<dynamic> put(
String url, {
dynamic data,
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
Map<String, dynamic>? headers,
}) async {
final resp = await _dio.put<dynamic>(
url,
data: data,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
options: headers == null ? null : Options(headers: headers),
);
return resp.data;
}
}
/// 统一业务异常:UI 层只 catch 这一个类型,永不接触 DioException
class ApiException implements Exception {
ApiException(this.dioException, this.message);
final DioException dioException;
final String message;
@override
String toString() => message;
}
+109
View File
@@ -0,0 +1,109 @@
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【OssApi】阿里云 OSS 上传相关接口(对照 UniApp api/userNote/index.ts)
///
/// 一、上传新流程(不传业务服务器,客户端直传 OSS):
/// 1. GetXxxOSSUploadUrl(FileName) → 后端分配 OSS 相对路径(objectName)
/// 2. GetAliOSSSetting() → 后端返回 STS 临时凭证
/// 3. 客户端用 V4 签名(oss_signer.dart)计算 authorization
/// 4. PUT 直传 https://{bucket}.{region}.aliyuncs.com{objectName}
/// → 全程走 DioClient 统一拦截器(自动带 Token、错误翻译)。
///
/// 二、接口清单(与 UniApp 一一对应):
/// GET /api/v1/Public/GetAliOSSSetting → STS 凭证
/// GET /api/v1/Public/GetNoteOSSUploadUrl → 笔记图片路径
/// GET /api/v1/Public/GetAvatarOSSUploadUrl → 头像路径
/// GET /api/v1/Public/GetAIGradingOSSUploadUrl → AI 判题路径
///
/// 三、坑:
/// 1. GetXxxUploadUrl 的 query 参数名是 FileName(首字母大写),
/// 对齐 UniApp 的 { FileName },别写成 fileName;
/// 2. 后端返回统一 {code, message, data} 包装,fromJson 里必须
/// 先解 data(与 user_model.dart 一致),否则 STS/路径全解析为空,
/// 直传 URL 会变成 https://..aliyuncs.com/(bucket/region 都丢了)。
/// ============================================================
/// 上传场景(对应 UniApp ossUpload.js 的 mode 参数)
enum UploadScene { note, avatar, aiGrading }
/// STS 临时凭证(对应 GetAliOSSSetting 返回)
class OssStsConfig {
const OssStsConfig({
required this.regionId,
required this.bucket,
required this.accessKeyId,
required this.accessKeySecret,
required this.securityToken,
});
/// OSS 区域,如 oss-cn-beijing(签名时要去掉 'oss-' 前缀)
final String regionId;
final String bucket;
final String accessKeyId;
final String accessKeySecret;
/// 临时安全令牌(x-oss-security-token)
final String securityToken;
factory OssStsConfig.fromJson(Map<String, dynamic> json) {
// 后端统一 {code, message, data} 包装:data 存在取 data,否则用顶层
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
return OssStsConfig(
regionId: payload['regionId'] as String? ?? '',
bucket: payload['bucket'] as String? ?? '',
accessKeyId: payload['accessKeyID'] as String? ?? '',
accessKeySecret: payload['accessKeySecret'] as String? ?? '',
securityToken: payload['aliyunOSSToken'] as String? ?? '',
);
}
}
/// OSS 上传目标(对应 GetXxxOSSUploadUrl 返回的 resourceInfo)
class OssUploadTarget {
const OssUploadTarget({required this.url, this.ossPathUrl});
/// OSS 相对路径(objectName,直传 URL 拼在域名后面)
final String url;
/// CDN 域名前缀(存在时用它拼最终访问 URL;null 用直传地址)
final String? ossPathUrl;
factory OssUploadTarget.fromJson(Map<String, dynamic> json) {
// 与 OssStsConfig 相同:统一解 data 包装,兼容两种返回
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
return OssUploadTarget(
url: payload['url'] as String? ?? '',
ossPathUrl: payload['ossPathUrl'] as String?,
);
}
}
class OssApi {
OssApi._();
/// 获取 STS 临时凭证 → GET /api/v1/Public/GetAliOSSSetting
static Future<OssStsConfig> getSts() async {
final data = await DioClient.instance.get(
'/api/v1/Public/GetAliOSSSetting',
);
return OssStsConfig.fromJson(data as Map<String, dynamic>);
}
/// 按场景申请 OSS 上传路径 → GET /api/v1/Public/GetXxxOSSUploadUrl
static Future<OssUploadTarget> getUploadUrl(
UploadScene scene,
String fileName,
) async {
final path = switch (scene) {
UploadScene.note => '/api/v1/Public/GetNoteOSSUploadUrl',
UploadScene.avatar => '/api/v1/Public/GetAvatarOSSUploadUrl',
UploadScene.aiGrading => '/api/v1/Public/GetAIGradingOSSUploadUrl',
};
final data = await DioClient.instance.get(
path,
query: {'FileName': fileName},
);
return OssUploadTarget.fromJson(data as Map<String, dynamic>);
}
}
+121
View File
@@ -0,0 +1,121 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:new_learn/core/network/oss_api.dart';
/// ============================================================
/// 【OssV4Signer】阿里云 OSS V4 签名(对照 UniApp utils/ossUpload.js)
///
/// 一、为什么客户端要手写签名:
/// OSS 直传必须带 Authorization 头(OSS4-HMAC-SHA256 ...),
/// 它是用【临时密钥 + 请求内容】算出来的,不能由后端代签(每次请求独立)。
/// 流程:GetAliOSSSetting 拿 STS → 本地算签名 → PUT 时带上。
///
/// 二、对照 JS 的核心坑(ossUpload.js 已踩过):
/// 1. 签名 region 要去掉 'oss-' 前缀(oss-cn-beijing → cn-beijing);
/// 2. CanonicalURI 必须包含 bucketName(/bucketName/objectName);
/// 3. SignedHeaders 留空(不在 Authorization 里声明 AdditionalHeaders),
/// 但 canonicalHeaders 仍要按字典序拼 content-type + 全部 x-oss-*;
/// 4. payloadHash 用 UNSIGNED-PAYLOAD(不校验 Body 哈希,上传性能好);
/// 5. amzDate 是 UTC 的 YYYYMMDDTHHMMSSZ,不要用本地时间。
/// ============================================================
/// 一次已签名的 PUT 请求(直传 URL + 全部请求头)
class OssSignedRequest {
const OssSignedRequest({required this.url, required this.headers});
/// 直传地址 https://{bucket}.{region}.aliyuncs.com{objectName}
final String url;
/// OSS 要求的全部请求头(Content-Type / authorization / x-oss-*)
final Map<String, dynamic> headers;
}
class OssV4Signer {
OssV4Signer._();
/// 计算 V4 签名,返回可直接 PUT 的 URL + headers
static OssSignedRequest build({
required OssStsConfig sts,
required OssUploadTarget target,
String contentType = 'image/jpeg',
}) {
final objectName = '/${target.url}';
// 核心坑 1:签名 region 去掉 'oss-' 前缀
final signRegion = sts.regionId.replaceFirst('oss-', '');
final host = '${sts.bucket}.${sts.regionId}.aliyuncs.com';
final uploadUrl = 'https://$host$objectName';
final amzDate = _amzDate();
final dateStamp = amzDate.substring(0, 8);
const method = 'PUT';
const service = 'oss';
const payloadHash = 'UNSIGNED-PAYLOAD';
// 核心坑 2:CanonicalURI 包含 bucketName
final canonicalUri = _jsEncodeUri('/${sts.bucket}$objectName');
const canonicalQuery = '';
// 核心坑 3:按字典序(content-type < x-oss-*),每行末尾带 \n
final canonicalHeaders = 'content-type:$contentType\n'
'x-oss-content-sha256:$payloadHash\n'
'x-oss-date:$amzDate\n'
'x-oss-security-token:${sts.securityToken}\n';
const signedHeaders = '';
final canonicalRequest = '$method\n$canonicalUri\n$canonicalQuery\n'
'$canonicalHeaders\n$signedHeaders\n$payloadHash';
final hashedRequest =
sha256.convert(utf8.encode(canonicalRequest)).toString();
final scope = '$dateStamp/$signRegion/$service/aliyun_v4_request';
final stringToSign = 'OSS4-HMAC-SHA256\n$amzDate\n$scope\n$hashedRequest';
final signingKey =
_getSignatureKey(sts.accessKeySecret, dateStamp, signRegion, service);
final signature =
Hmac(sha256, signingKey).convert(utf8.encode(stringToSign)).toString();
return OssSignedRequest(
url: uploadUrl,
headers: {
'Content-Type': contentType,
// 注意:OSS 头名用小写 authorization,DioClient 拦截器据此跳过加 Bearer
'authorization':
'OSS4-HMAC-SHA256 Credential=${sts.accessKeyId}/$scope,'
'Signature=$signature',
'x-oss-date': amzDate,
'x-oss-content-sha256': payloadHash,
'x-oss-security-token': sts.securityToken,
},
);
}
/// 4 层 HMAC 派生签名密钥(对照 JS getSignatureKey)
static Uint8List _getSignatureKey(
String secret,
String dateStamp,
String region,
String service,
) {
Uint8List hmac(List<int> key, String msg) =>
Uint8List.fromList(Hmac(sha256, key).convert(utf8.encode(msg)).bytes);
final kDate = hmac(utf8.encode('aliyun_v4$secret'), dateStamp);
final kRegion = hmac(kDate, region);
final kService = hmac(kRegion, service);
return hmac(kService, 'aliyun_v4_request');
}
/// YYYYMMDDTHHMMSSZ(UTC,对照 JS getAmzDate)
static String _amzDate() {
final now = DateTime.now().toUtc();
String two(int n) => n.toString().padLeft(2, '0');
return '${now.year}${two(now.month)}${two(now.day)}'
'T${two(now.hour)}${two(now.minute)}${two(now.second)}Z';
}
/// JS encodeURI 等价:保留 URI 保留字符(:/?#[]@!$&'()*+,;=),编码其余。
/// Dart 的 Uri.encodeFull 行为一致;OSS 对象名一般是安全字符,差异可忽略。
static String _jsEncodeUri(String s) => Uri.encodeFull(s);
}
+76
View File
@@ -0,0 +1,76 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/network/oss_api.dart';
import 'package:new_learn/core/network/oss_signer.dart';
/// ============================================================
/// 【OssUploader】OSS 直传执行器(完整链路,对照 UniApp ossUpload.js)
///
/// 一、链路(4 步,全部走 DioClient 统一拦截器):
/// ① OssApi.getUploadUrl → 后端分配 OSS 相对路径
/// ② OssApi.getSts → 后端下发 STS 临时凭证
/// ③ OssV4Signer.build → 本地计算 OSS4-HMAC-SHA256 签名
/// ④ DioClient.put(绝对URL) → 字节直传 OSS,返回最终访问 URL
///
/// 二、使用方式:
/// OssUploader.uploadImage(
/// bytes: `Uint8List`, // 图片字节(原生可来自 AssetEntity.originBytes)
/// fileName: 'xxx.jpg', // 文件名(含扩展名,后端分配路径用)
/// scene: UploadScene.avatar, // 场景:avatar/note/aiGrading
/// onSendProgress: (s, t) {}, // 上传进度回调(生产可用)
/// );
///
/// 三、坑:
/// 1. 本类只收字节不读文件 —— 读文件平台差异交给调用方
/// (web 用 bytes;原生用 AssetEntity.originBytes,避免 import dart:io);
/// 2. 上传失败抛 ApiException(拦截器统一翻译),调用方 catch 展示即可。
/// ============================================================
class OssUploader {
OssUploader._();
/// 上传图片到 OSS,成功返回最终访问 URL
///
/// [bytes] :图片原始字节(Uint8List)
/// [fileName] :文件名(含扩展名,如 'photo_123.jpg')
/// [scene] :上传场景(决定用哪个申请接口)
/// [contentType]:字节对应的 MIME(必须和真实编码一致)
/// [onSendProgress]:上传进度回调 (已发送, 总大小),可显示进度条
static Future<String> uploadImage({
required Uint8List bytes,
required String fileName,
required UploadScene scene,
String contentType = 'image/jpeg',
CancelToken? cancelToken,
ProgressCallback? onSendProgress,
}) async {
// ① 后端分配 OSS 相对路径(objectName)
final target = await OssApi.getUploadUrl(scene, fileName);
// ② 获取 STS 临时凭证
final sts = await OssApi.getSts();
// ③ 计算 V4 签名(URL + authorization 等全部请求头)
final req = OssV4Signer.build(
sts: sts,
target: target,
contentType: contentType,
);
// ④ PUT 直传(绝对 URL;拦截器识别 authorization 后不再拼 Bearer)
await DioClient.instance.put(
req.url,
data: bytes,
headers: req.headers,
cancelToken: cancelToken,
onSendProgress: onSendProgress,
);
// ⑤ 最终访问 URL:有 CDN 前缀用 CDN(体验好),否则用直传地址
final prefix = target.ossPathUrl;
return (prefix != null && prefix.isNotEmpty)
? '$prefix/${target.url}'
: req.url;
}
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:new_learn/core/models/post_model.dart';
import 'dio_client.dart';
/// ============================================================
/// 【PostApi】业务接口层 —— 学习 Demo 用(帖子 CRUD)
///
/// 职责与 UserApi 相同:页面只调这里,永远不直接碰 Dio。
/// 每个业务模块一个文件:PostApi / UserApi / ...,按需新增。
///
/// 高频坑:
/// 1. fromJson 转换失败会抛 TypeError,若服务端结构不稳定,
/// 应在模型里做防御性解析(见 post_model.dart);
/// 2. 不要在这里 catch ApiException —— 交给页面统一处理展示。
/// ============================================================
class PostApi {
/// GET:查询单个帖子 → /posts/1
static Future<PostModel> fetchPost(int id) async {
final data = await DioClient.instance.get('/posts/$id');
return PostModel.fromJson(data as Map<String, dynamic>);
}
/// GET:查询帖子列表 → /posts?userId=1
static Future<List<PostModel>> fetchPosts({int? userId}) async {
final data = await DioClient.instance.get(
'/posts',
query: {'userId': ?userId},
);
return (data as List)
.map((e) => PostModel.fromJson(e as Map<String, dynamic>))
.toList();
}
/// POST:新建帖子 → POST /posts(jsonplaceholder 会模拟创建成功)
static Future<PostModel> createPost({
required String title,
required String body,
}) async {
final data = await DioClient.instance.post('/posts', data: {
'title': title,
'body': body,
'userId': 1,
});
return PostModel.fromJson(data as Map<String, dynamic>);
}
}
+80
View File
@@ -0,0 +1,80 @@
import 'package:dio/dio.dart';
import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/services/picker_service.dart';
/// ============================================================
/// 【UploadApi】文件上传接口(占位,待接口文档)
///
/// 一、当前状态:
/// 接口文档待用户提供,本文件已按生产级封装思路预留完整方法签名:
/// - 上传单张图片(头像 / 笔记共用)
/// - 支持 Web(bytes)与原生(filePath)双端
/// - 统一走 DioClient 拦截器(自动带 Token / 错误翻译)
///
/// 二、等接口文档后只需补两处:
/// 1. 上面 _path 常量改成真实接口地址(如 /api/v1/File/UploadImage);
/// 2. _upload 方法体里把 FormData 字段名 / 响应解析补全。
///
/// 三、高频坑(multipart 上传的 3 个经典错误):
/// 1. Web 端没有 File 文件系统,必须用 MultipartFile.fromBytes,
/// 原生端用 fromFile(从 picker_service 的 PickedImage 区分);
/// 2. FormData 的 key 必须和后端字段名完全一致(如 'file' / 'files'),
/// 大小写/下划线错一个后端就收不到;
/// 3. Dio 上传要把 contentType 交给 FormData 自带,
/// 不要再手动拼 multipart/form-data,否则会少 boundary。
/// ============================================================
class UploadApi {
UploadApi._();
static final UploadApi instance = UploadApi._();
/// 上传接口地址(TODO: 用户提供接口文档后替换)
static const String _path = '/api/v1/File/UploadImage';
/// 上传单张图片,成功返回图片 URL(网络地址,可直接塞进 avatar 字段)
///
/// [image] 由 PickerService 统一产出:
/// - 原生:image.path 有值 → MultipartFile.fromFile
/// - Web :image.bytes 有值 → MultipartFile.fromBytes
Future<String?> uploadImage(PickedImage image) async {
final data = await _upload([image]);
// TODO: 根据真实接口返回结构解析图片 URL(可能返回字符串或对象)
return data?.toString();
}
/// 批量上传多张图片(笔记多图),成功返回 URL 列表
Future<List<String>> uploadImages(List<PickedImage> images) async {
await _upload(images);
// TODO: 根据真实接口返回结构解析 URL 列表
return const [];
}
/// 通用 multipart 上传实现
Future<dynamic> _upload(List<PickedImage> images) async {
final formData = FormData();
// 双端文件归一:原生走文件路径,Web 走字节
for (int i = 0; i < images.length; i++) {
final image = images[i];
final fileName = image.name ?? 'image_$i.jpg';
final MultipartFile file;
if (image.bytes != null) {
// Web:没有文件系统,直接传字节(filename 必须带扩展名)
file = MultipartFile.fromBytes(
image.bytes!,
filename: fileName,
);
} else {
// 原生:传本地文件路径
file = await MultipartFile.fromFile(
image.path ?? '',
filename: fileName,
);
}
formData.files.add(MapEntry('files', file));
}
return DioClient.instance.post(_path, data: formData);
}
}