flutter架 构项目基础
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user