Initial commit: YX OSS Flutter SDK
This commit is contained in:
commit
5794eb93f8
|
|
@ -0,0 +1,31 @@
|
|||
Extension Discovery Cache
|
||||
=========================
|
||||
|
||||
This folder is used by `package:extension_discovery` to cache lists of
|
||||
packages that contains extensions for other packages.
|
||||
|
||||
DO NOT USE THIS FOLDER
|
||||
----------------------
|
||||
|
||||
* Do not read (or rely) the contents of this folder.
|
||||
* Do write to this folder.
|
||||
|
||||
If you're interested in the lists of extensions stored in this folder use the
|
||||
API offered by package `extension_discovery` to get this information.
|
||||
|
||||
If this package doesn't work for your use-case, then don't try to read the
|
||||
contents of this folder. It may change, and will not remain stable.
|
||||
|
||||
Use package `extension_discovery`
|
||||
---------------------------------
|
||||
|
||||
If you want to access information from this folder.
|
||||
|
||||
Feel free to delete this folder
|
||||
-------------------------------
|
||||
|
||||
Files in this folder act as a cache, and the cache is discarded if the files
|
||||
are older than the modification time of `.dart_tool/package_config.json`.
|
||||
|
||||
Hence, it should never be necessary to clear this cache manually, if you find a
|
||||
need to do please file a bug.
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":2,"entries":[{"package":"yx_oss","rootUri":"../","packageUri":"lib/"}]}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"version":2,"entries":[{"package":"yx_oss","rootUri":"../","packageUri":"lib/"}]}
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
{
|
||||
"configVersion": 2,
|
||||
"packages": [
|
||||
{
|
||||
"name": "async",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/async-2.13.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "boolean_selector",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/boolean_selector-2.1.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "characters",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/characters-1.4.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "clock",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/clock-1.1.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "collection",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/collection-1.19.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "crypto",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/crypto-3.0.6",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "dio",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/dio-5.9.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.18"
|
||||
},
|
||||
{
|
||||
"name": "dio_web_adapter",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/dio_web_adapter-2.1.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.3"
|
||||
},
|
||||
{
|
||||
"name": "fake_async",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/fake_async-1.3.3",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.3"
|
||||
},
|
||||
{
|
||||
"name": "ffi",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/ffi-2.1.4",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.7"
|
||||
},
|
||||
{
|
||||
"name": "flutter",
|
||||
"rootUri": "file:///Users/max/fvm/versions/3.32.0/packages/flutter",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.7"
|
||||
},
|
||||
{
|
||||
"name": "flutter_lints",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/flutter_lints-4.0.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "flutter_test",
|
||||
"rootUri": "file:///Users/max/fvm/versions/3.32.0/packages/flutter_test",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.7"
|
||||
},
|
||||
{
|
||||
"name": "http_parser",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/http_parser-4.1.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker-10.0.9",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_flutter_testing",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker_flutter_testing-3.0.9",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_testing",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker_testing-3.0.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "lints",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/lints-4.0.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "matcher",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/matcher-0.12.17",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "material_color_utilities",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/material_color_utilities-0.11.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.17"
|
||||
},
|
||||
{
|
||||
"name": "meta",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/meta-1.16.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.12"
|
||||
},
|
||||
{
|
||||
"name": "mime",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/mime-1.0.6",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path-1.9.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "path_provider",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider-2.1.5",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "path_provider_android",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_android-2.2.18",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.7"
|
||||
},
|
||||
{
|
||||
"name": "path_provider_foundation",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_foundation-2.4.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.7"
|
||||
},
|
||||
{
|
||||
"name": "path_provider_linux",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_linux-2.2.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.19"
|
||||
},
|
||||
{
|
||||
"name": "path_provider_platform_interface",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_platform_interface-2.1.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.0"
|
||||
},
|
||||
{
|
||||
"name": "path_provider_windows",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_windows-2.3.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "platform",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/platform-3.1.6",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.2"
|
||||
},
|
||||
{
|
||||
"name": "plugin_platform_interface",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/plugin_platform_interface-2.1.8",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.0"
|
||||
},
|
||||
{
|
||||
"name": "sky_engine",
|
||||
"rootUri": "file:///Users/max/fvm/versions/3.32.0/bin/cache/pkg/sky_engine",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.7"
|
||||
},
|
||||
{
|
||||
"name": "source_span",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/source_span-1.10.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "stack_trace",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/stack_trace-1.12.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "stream_channel",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/stream_channel-2.1.4",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.3"
|
||||
},
|
||||
{
|
||||
"name": "string_scanner",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/string_scanner-1.4.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "term_glyph",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/term_glyph-1.2.2",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.1"
|
||||
},
|
||||
{
|
||||
"name": "test_api",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/test_api-0.7.4",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.5"
|
||||
},
|
||||
{
|
||||
"name": "typed_data",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/typed_data-1.4.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.5"
|
||||
},
|
||||
{
|
||||
"name": "uuid",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/uuid-3.0.7",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.12"
|
||||
},
|
||||
{
|
||||
"name": "vector_math",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/vector_math-2.1.4",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "2.14"
|
||||
},
|
||||
{
|
||||
"name": "vm_service",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/vm_service-15.0.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.3"
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/web-1.1.1",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.4"
|
||||
},
|
||||
{
|
||||
"name": "xdg_directories",
|
||||
"rootUri": "file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/xdg_directories-1.1.0",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.3"
|
||||
},
|
||||
{
|
||||
"name": "yx_oss",
|
||||
"rootUri": "../",
|
||||
"packageUri": "lib/",
|
||||
"languageVersion": "3.0"
|
||||
}
|
||||
],
|
||||
"generator": "pub",
|
||||
"generatorVersion": "3.8.0",
|
||||
"flutterRoot": "file:///Users/max/fvm/versions/3.32.0",
|
||||
"flutterVersion": "3.32.0",
|
||||
"pubCache": "file:///Users/max/.pub-cache"
|
||||
}
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
async
|
||||
3.4
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/async-2.13.0/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/async-2.13.0/lib/
|
||||
boolean_selector
|
||||
3.1
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/boolean_selector-2.1.2/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/boolean_selector-2.1.2/lib/
|
||||
characters
|
||||
3.4
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/characters-1.4.0/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/characters-1.4.0/lib/
|
||||
clock
|
||||
3.4
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/clock-1.1.2/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/clock-1.1.2/lib/
|
||||
collection
|
||||
3.4
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/collection-1.19.1/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/collection-1.19.1/lib/
|
||||
crypto
|
||||
3.4
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/crypto-3.0.6/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/crypto-3.0.6/lib/
|
||||
dio
|
||||
2.18
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/dio-5.9.0/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/dio-5.9.0/lib/
|
||||
dio_web_adapter
|
||||
3.3
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/dio_web_adapter-2.1.1/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/dio_web_adapter-2.1.1/lib/
|
||||
fake_async
|
||||
3.3
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/fake_async-1.3.3/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/fake_async-1.3.3/lib/
|
||||
ffi
|
||||
3.7
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/ffi-2.1.4/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/ffi-2.1.4/lib/
|
||||
flutter_lints
|
||||
3.1
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/flutter_lints-4.0.0/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/flutter_lints-4.0.0/lib/
|
||||
http_parser
|
||||
3.4
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/http_parser-4.1.2/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/http_parser-4.1.2/lib/
|
||||
leak_tracker
|
||||
3.2
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker-10.0.9/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker-10.0.9/lib/
|
||||
leak_tracker_flutter_testing
|
||||
3.2
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker_flutter_testing-3.0.9/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker_flutter_testing-3.0.9/lib/
|
||||
leak_tracker_testing
|
||||
3.2
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker_testing-3.0.1/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/leak_tracker_testing-3.0.1/lib/
|
||||
lints
|
||||
3.1
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/lints-4.0.0/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/lints-4.0.0/lib/
|
||||
matcher
|
||||
3.4
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/matcher-0.12.17/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/matcher-0.12.17/lib/
|
||||
material_color_utilities
|
||||
2.17
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/material_color_utilities-0.11.1/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/material_color_utilities-0.11.1/lib/
|
||||
meta
|
||||
2.12
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/meta-1.16.0/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/meta-1.16.0/lib/
|
||||
mime
|
||||
3.2
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/mime-1.0.6/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/mime-1.0.6/lib/
|
||||
path
|
||||
3.4
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path-1.9.1/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path-1.9.1/lib/
|
||||
path_provider
|
||||
3.4
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider-2.1.5/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider-2.1.5/lib/
|
||||
path_provider_android
|
||||
3.7
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_android-2.2.18/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_android-2.2.18/lib/
|
||||
path_provider_foundation
|
||||
3.7
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_foundation-2.4.2/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_foundation-2.4.2/lib/
|
||||
path_provider_linux
|
||||
2.19
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_linux-2.2.1/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_linux-2.2.1/lib/
|
||||
path_provider_platform_interface
|
||||
3.0
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_platform_interface-2.1.2/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_platform_interface-2.1.2/lib/
|
||||
path_provider_windows
|
||||
3.2
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_windows-2.3.0/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_windows-2.3.0/lib/
|
||||
platform
|
||||
3.2
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/platform-3.1.6/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/platform-3.1.6/lib/
|
||||
plugin_platform_interface
|
||||
3.0
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/plugin_platform_interface-2.1.8/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/plugin_platform_interface-2.1.8/lib/
|
||||
source_span
|
||||
3.1
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/source_span-1.10.1/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/source_span-1.10.1/lib/
|
||||
stack_trace
|
||||
3.4
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/stack_trace-1.12.1/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/stack_trace-1.12.1/lib/
|
||||
stream_channel
|
||||
3.3
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/stream_channel-2.1.4/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/stream_channel-2.1.4/lib/
|
||||
string_scanner
|
||||
3.1
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/string_scanner-1.4.1/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/string_scanner-1.4.1/lib/
|
||||
term_glyph
|
||||
3.1
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/term_glyph-1.2.2/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/term_glyph-1.2.2/lib/
|
||||
test_api
|
||||
3.5
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/test_api-0.7.4/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/test_api-0.7.4/lib/
|
||||
typed_data
|
||||
3.5
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/typed_data-1.4.0/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/typed_data-1.4.0/lib/
|
||||
uuid
|
||||
2.12
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/uuid-3.0.7/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/uuid-3.0.7/lib/
|
||||
vector_math
|
||||
2.14
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/vector_math-2.1.4/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/vector_math-2.1.4/lib/
|
||||
vm_service
|
||||
3.3
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/vm_service-15.0.0/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/vm_service-15.0.0/lib/
|
||||
web
|
||||
3.4
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/web-1.1.1/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/web-1.1.1/lib/
|
||||
xdg_directories
|
||||
3.3
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/xdg_directories-1.1.0/
|
||||
file:///Users/max/.pub-cache/hosted/pub.flutter-io.cn/xdg_directories-1.1.0/lib/
|
||||
yx_oss
|
||||
3.0
|
||||
file:///Users/max/SourceCode/yuanxuan/learning_officer_oa/yx_oss/
|
||||
file:///Users/max/SourceCode/yuanxuan/learning_officer_oa/yx_oss/lib/
|
||||
sky_engine
|
||||
3.7
|
||||
file:///Users/max/fvm/versions/3.32.0/bin/cache/pkg/sky_engine/
|
||||
file:///Users/max/fvm/versions/3.32.0/bin/cache/pkg/sky_engine/lib/
|
||||
flutter
|
||||
3.7
|
||||
file:///Users/max/fvm/versions/3.32.0/packages/flutter/
|
||||
file:///Users/max/fvm/versions/3.32.0/packages/flutter/lib/
|
||||
flutter_test
|
||||
3.7
|
||||
file:///Users/max/fvm/versions/3.32.0/packages/flutter_test/
|
||||
file:///Users/max/fvm/versions/3.32.0/packages/flutter_test/lib/
|
||||
2
|
||||
|
|
@ -0,0 +1,387 @@
|
|||
{
|
||||
"roots": [
|
||||
"yx_oss"
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"name": "yx_oss",
|
||||
"version": "1.0.0",
|
||||
"dependencies": [
|
||||
"crypto",
|
||||
"dio",
|
||||
"flutter",
|
||||
"meta",
|
||||
"mime",
|
||||
"path_provider",
|
||||
"uuid"
|
||||
],
|
||||
"devDependencies": [
|
||||
"flutter_lints",
|
||||
"flutter_test"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "flutter_lints",
|
||||
"version": "4.0.0",
|
||||
"dependencies": [
|
||||
"lints"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "flutter_test",
|
||||
"version": "0.0.0",
|
||||
"dependencies": [
|
||||
"async",
|
||||
"boolean_selector",
|
||||
"characters",
|
||||
"clock",
|
||||
"collection",
|
||||
"fake_async",
|
||||
"flutter",
|
||||
"leak_tracker",
|
||||
"leak_tracker_flutter_testing",
|
||||
"leak_tracker_testing",
|
||||
"matcher",
|
||||
"material_color_utilities",
|
||||
"meta",
|
||||
"path",
|
||||
"source_span",
|
||||
"stack_trace",
|
||||
"stream_channel",
|
||||
"string_scanner",
|
||||
"term_glyph",
|
||||
"test_api",
|
||||
"vector_math",
|
||||
"vm_service"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "path_provider",
|
||||
"version": "2.1.5",
|
||||
"dependencies": [
|
||||
"flutter",
|
||||
"path_provider_android",
|
||||
"path_provider_foundation",
|
||||
"path_provider_linux",
|
||||
"path_provider_platform_interface",
|
||||
"path_provider_windows"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "meta",
|
||||
"version": "1.16.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "uuid",
|
||||
"version": "3.0.7",
|
||||
"dependencies": [
|
||||
"crypto"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mime",
|
||||
"version": "1.0.6",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "crypto",
|
||||
"version": "3.0.6",
|
||||
"dependencies": [
|
||||
"typed_data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dio",
|
||||
"version": "5.9.0",
|
||||
"dependencies": [
|
||||
"async",
|
||||
"collection",
|
||||
"dio_web_adapter",
|
||||
"http_parser",
|
||||
"meta",
|
||||
"mime",
|
||||
"path"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "flutter",
|
||||
"version": "0.0.0",
|
||||
"dependencies": [
|
||||
"characters",
|
||||
"collection",
|
||||
"material_color_utilities",
|
||||
"meta",
|
||||
"sky_engine",
|
||||
"vector_math"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "lints",
|
||||
"version": "4.0.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "vm_service",
|
||||
"version": "15.0.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "term_glyph",
|
||||
"version": "1.2.2",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "string_scanner",
|
||||
"version": "1.4.1",
|
||||
"dependencies": [
|
||||
"source_span"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stream_channel",
|
||||
"version": "2.1.4",
|
||||
"dependencies": [
|
||||
"async"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "source_span",
|
||||
"version": "1.10.1",
|
||||
"dependencies": [
|
||||
"collection",
|
||||
"path",
|
||||
"term_glyph"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "material_color_utilities",
|
||||
"version": "0.11.1",
|
||||
"dependencies": [
|
||||
"collection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_testing",
|
||||
"version": "3.0.1",
|
||||
"dependencies": [
|
||||
"leak_tracker",
|
||||
"matcher",
|
||||
"meta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker",
|
||||
"version": "10.0.9",
|
||||
"dependencies": [
|
||||
"clock",
|
||||
"collection",
|
||||
"meta",
|
||||
"path",
|
||||
"vm_service"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "collection",
|
||||
"version": "1.19.1",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "characters",
|
||||
"version": "1.4.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "boolean_selector",
|
||||
"version": "2.1.2",
|
||||
"dependencies": [
|
||||
"source_span",
|
||||
"string_scanner"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "async",
|
||||
"version": "2.13.0",
|
||||
"dependencies": [
|
||||
"collection",
|
||||
"meta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "leak_tracker_flutter_testing",
|
||||
"version": "3.0.9",
|
||||
"dependencies": [
|
||||
"flutter",
|
||||
"leak_tracker",
|
||||
"leak_tracker_testing",
|
||||
"matcher",
|
||||
"meta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "vector_math",
|
||||
"version": "2.1.4",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "stack_trace",
|
||||
"version": "1.12.1",
|
||||
"dependencies": [
|
||||
"path"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "clock",
|
||||
"version": "1.1.2",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "fake_async",
|
||||
"version": "1.3.3",
|
||||
"dependencies": [
|
||||
"clock",
|
||||
"collection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"version": "1.9.1",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "matcher",
|
||||
"version": "0.12.17",
|
||||
"dependencies": [
|
||||
"async",
|
||||
"meta",
|
||||
"stack_trace",
|
||||
"term_glyph",
|
||||
"test_api"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "test_api",
|
||||
"version": "0.7.4",
|
||||
"dependencies": [
|
||||
"async",
|
||||
"boolean_selector",
|
||||
"collection",
|
||||
"meta",
|
||||
"source_span",
|
||||
"stack_trace",
|
||||
"stream_channel",
|
||||
"string_scanner",
|
||||
"term_glyph"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "path_provider_windows",
|
||||
"version": "2.3.0",
|
||||
"dependencies": [
|
||||
"ffi",
|
||||
"flutter",
|
||||
"path",
|
||||
"path_provider_platform_interface"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "path_provider_platform_interface",
|
||||
"version": "2.1.2",
|
||||
"dependencies": [
|
||||
"flutter",
|
||||
"platform",
|
||||
"plugin_platform_interface"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "path_provider_linux",
|
||||
"version": "2.2.1",
|
||||
"dependencies": [
|
||||
"ffi",
|
||||
"flutter",
|
||||
"path",
|
||||
"path_provider_platform_interface",
|
||||
"xdg_directories"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "path_provider_foundation",
|
||||
"version": "2.4.2",
|
||||
"dependencies": [
|
||||
"flutter",
|
||||
"path_provider_platform_interface"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "path_provider_android",
|
||||
"version": "2.2.18",
|
||||
"dependencies": [
|
||||
"flutter",
|
||||
"path_provider_platform_interface"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "typed_data",
|
||||
"version": "1.4.0",
|
||||
"dependencies": [
|
||||
"collection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "dio_web_adapter",
|
||||
"version": "2.1.1",
|
||||
"dependencies": [
|
||||
"dio",
|
||||
"http_parser",
|
||||
"meta",
|
||||
"web"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "http_parser",
|
||||
"version": "4.1.2",
|
||||
"dependencies": [
|
||||
"collection",
|
||||
"source_span",
|
||||
"string_scanner",
|
||||
"typed_data"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "sky_engine",
|
||||
"version": "0.0.0",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "ffi",
|
||||
"version": "2.1.4",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "plugin_platform_interface",
|
||||
"version": "2.1.8",
|
||||
"dependencies": [
|
||||
"meta"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "platform",
|
||||
"version": "3.1.6",
|
||||
"dependencies": []
|
||||
},
|
||||
{
|
||||
"name": "xdg_directories",
|
||||
"version": "1.1.0",
|
||||
"dependencies": [
|
||||
"meta",
|
||||
"path"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"version": "1.1.1",
|
||||
"dependencies": []
|
||||
}
|
||||
],
|
||||
"configVersion": 1
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
3.32.0
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"/Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_foundation-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"path_provider_android","path":"/Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_android-2.2.18/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"path_provider_foundation","path":"/Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_foundation-2.4.2/","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"path_provider_linux","path":"/Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_linux-2.2.1/","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"path_provider_windows","path":"/Users/max/.pub-cache/hosted/pub.flutter-io.cn/path_provider_windows-2.3.0/","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]}],"date_created":"2025-09-24 16:33:04.709702","version":"3.32.0","swift_package_manager_enabled":{"ios":false,"macos":false}}
|
||||
|
|
@ -0,0 +1,572 @@
|
|||
# YX OSS
|
||||
|
||||
[](https://pub.dev/packages/yx_oss)
|
||||
[](https://pub.dev/documentation/yx_oss/latest/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
一个专注于核心功能的阿里云 OSS (Object Storage Service) Flutter 客户端库,提供纯净的文件上传下载能力。
|
||||
|
||||
## 🎯 设计理念
|
||||
|
||||
**单一职责**:本库专注于 OSS 核心操作 - 文件上传、下载和管理。故意排除了 UI 组件、图片压缩、文件选择等功能,保持简洁性和灵活性。
|
||||
|
||||
**最小依赖**:仅包含 OSS 操作的必需依赖,让您可以自由选择自己的 UI 和工具库。
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
### 认证与配置
|
||||
- 🔐 **多种认证方式**:静态密钥、STS临时凭证、预签名URL
|
||||
- ⚙️ **动态配置**:运行时配置更新和缓存
|
||||
- 🔌 **可插拔提供者**:灵活的认证和配置提供者架构
|
||||
|
||||
### 上传与下载
|
||||
- 📤 **断点续传**:支持大文件的断点续传,本地存储断点信息
|
||||
- 🧩 **分片上传**:大文件自动分片处理,支持并发上传
|
||||
- 📊 **进度监控**:实时上传下载进度跟踪
|
||||
- ⚡ **预签名URL**:支持客户端直接上传,服务端控制权限
|
||||
|
||||
### 可靠性与错误处理
|
||||
- 🔄 **重试机制**:指数退避重试失败操作
|
||||
- 🛡️ **类型安全错误处理**:完整的错误类型和处理
|
||||
- ❌ **取消支持**:可取消正在进行的操作
|
||||
- 📝 **统一日志**:可配置的日志系统,支持自定义处理器
|
||||
|
||||
### 架构特点
|
||||
- 🎯 **零耦合**:不依赖特定项目架构
|
||||
- 📱 **跨平台**:支持 iOS、Android、Web、Desktop
|
||||
- 🏗️ **清洁架构**:依赖注入和关注点分离
|
||||
- 🔧 **项目集成**:提供现成的项目集成适配器
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 安装
|
||||
|
||||
在你的 `pubspec.yaml` 文件中添加依赖:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
yx_oss: ^1.0.0
|
||||
```
|
||||
|
||||
然后运行:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
### 基础用法
|
||||
|
||||
```dart
|
||||
import 'package:yx_oss/yx_oss.dart';
|
||||
|
||||
// 1. 创建认证提供者
|
||||
final authProvider = StaticAuthProvider(
|
||||
accessKeyId: 'your_access_key_id',
|
||||
accessKeySecret: 'your_access_key_secret',
|
||||
);
|
||||
|
||||
// 2. 创建配置提供者
|
||||
final configProvider = StaticConfigProvider(
|
||||
endpoint: 'oss-cn-hangzhou.aliyuncs.com',
|
||||
bucketName: 'your-bucket-name',
|
||||
directory: 'uploads',
|
||||
);
|
||||
|
||||
// 3. 创建客户端配置
|
||||
final config = YxOSSConfig(
|
||||
authProvider: authProvider,
|
||||
configProvider: configProvider,
|
||||
logConfig: LogConfig(
|
||||
enabled: true,
|
||||
level: LogLevel.info,
|
||||
logError: true,
|
||||
),
|
||||
);
|
||||
|
||||
// 4. 创建OSS客户端
|
||||
final client = YxOSSClient(config);
|
||||
|
||||
// 5. 初始化客户端
|
||||
await client.initialize();
|
||||
|
||||
// 6. 上传文件
|
||||
final fileData = Uint8List.fromList('Hello, World!'.codeUnits);
|
||||
final result = await client.uploadBytes(
|
||||
fileData,
|
||||
'my-file.txt',
|
||||
options: UploadOptions(
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: (sent, total) {
|
||||
print('进度: ${(sent / total * 100).toStringAsFixed(1)}%');
|
||||
},
|
||||
onSuccess: (result) {
|
||||
print('上传成功: ${result.url}');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 7. 释放资源
|
||||
client.dispose();
|
||||
```
|
||||
|
||||
## 📖 详细文档
|
||||
|
||||
### 认证方式
|
||||
|
||||
#### 1. 静态认证 (Static Authentication)
|
||||
|
||||
适用于开发测试环境或简单应用场景:
|
||||
|
||||
```dart
|
||||
final authProvider = StaticAuthProvider(
|
||||
accessKeyId: 'your_access_key_id',
|
||||
accessKeySecret: 'your_access_key_secret',
|
||||
);
|
||||
```
|
||||
|
||||
#### 2. STS临时凭证 (STS Authentication)
|
||||
|
||||
适用于生产环境,提供更安全的权限控制:
|
||||
|
||||
```dart
|
||||
// 从服务器获取STS凭证的函数
|
||||
Future<STSCredentials> getSTSCredentials() async {
|
||||
// 调用你的后端API获取STS凭证
|
||||
final response = await http.get('/api/sts-credentials');
|
||||
return STSCredentials.fromJson(response.data);
|
||||
}
|
||||
|
||||
final authProvider = STSAuthProvider(
|
||||
accessKeyId: stsCredentials.accessKeyId,
|
||||
accessKeySecret: stsCredentials.accessKeySecret,
|
||||
securityToken: stsCredentials.securityToken,
|
||||
expiration: stsCredentials.expiration,
|
||||
credentialsGetter: getSTSCredentials, // 自动刷新过期凭证
|
||||
);
|
||||
```
|
||||
|
||||
#### 3. 项目集成认证 (Project Integration)
|
||||
|
||||
适用于现有项目集成,从后端API获取配置:
|
||||
|
||||
```dart
|
||||
// 使用项目集成适配器
|
||||
final manager = ProjectOSSManager(myApiImpl);
|
||||
await manager.initialize();
|
||||
|
||||
// 直接使用,自动处理认证和配置
|
||||
final fileUrl = await manager.uploadFile(
|
||||
filePath,
|
||||
onProgress: (sent, total) => print('进度: $sent/$total'),
|
||||
type: 1, // 文件类型
|
||||
);
|
||||
```
|
||||
|
||||
### 配置管理
|
||||
|
||||
#### 1. 静态配置 (Static Configuration)
|
||||
|
||||
```dart
|
||||
final configProvider = StaticConfigProvider(
|
||||
endpoint: 'oss-cn-hangzhou.aliyuncs.com',
|
||||
bucketName: 'your-bucket-name',
|
||||
directory: 'uploads',
|
||||
domain: 'https://your-custom-domain.com', // 可选
|
||||
);
|
||||
```
|
||||
|
||||
#### 2. 动态配置 (Dynamic Configuration)
|
||||
|
||||
支持从服务器动态获取配置,适用于多环境部署:
|
||||
|
||||
```dart
|
||||
final configProvider = DynamicConfigProvider(
|
||||
configGetter: () async {
|
||||
final response = await http.get('/api/oss-config');
|
||||
return OSSConfig.fromJson(response.data);
|
||||
},
|
||||
cacheTimeout: const Duration(hours: 1), // 配置缓存时间
|
||||
);
|
||||
```
|
||||
|
||||
### 日志配置
|
||||
|
||||
新增统一的日志系统,支持级别控制和自定义处理器:
|
||||
|
||||
```dart
|
||||
final config = YxOSSConfig(
|
||||
authProvider: authProvider,
|
||||
configProvider: configProvider,
|
||||
logConfig: LogConfig(
|
||||
enabled: true,
|
||||
level: LogLevel.warning, // 只记录警告及以上级别
|
||||
logRequest: false, // 是否记录HTTP请求
|
||||
logResponse: false, // 是否记录HTTP响应
|
||||
logError: true, // 是否记录错误
|
||||
customHandler: (message, level) {
|
||||
// 自定义日志处理,可接入你的日志系统
|
||||
myLogger.log(level.name, message);
|
||||
},
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
### 上传选项
|
||||
|
||||
```dart
|
||||
final options = UploadOptions(
|
||||
// 基础选项
|
||||
overwrite: true, // 是否覆盖同名文件
|
||||
contentType: 'image/jpeg', // 文件MIME类型
|
||||
enableResume: true, // 启用断点续传
|
||||
|
||||
// 访问控制
|
||||
acl: ACLMode.publicRead, // 访问权限
|
||||
storageClass: StorageClass.standard, // 存储类型
|
||||
|
||||
// 自定义元数据
|
||||
metadata: {
|
||||
'author': 'YX OSS',
|
||||
'version': '1.0.0',
|
||||
},
|
||||
|
||||
// 分片上传配置
|
||||
enableMultipart: true, // 启用分片上传
|
||||
partSize: 10 * 1024 * 1024, // 分片大小 (10MB)
|
||||
multipartThreshold: 100 * 1024 * 1024, // 分片阈值 (100MB)
|
||||
concurrency: 3, // 并发上传数
|
||||
|
||||
// 回调函数
|
||||
callbacks: UploadCallbacks(
|
||||
onStart: () => print('开始上传'),
|
||||
onProgress: (sent, total) {
|
||||
final progress = (sent / total * 100).toStringAsFixed(1);
|
||||
print('上传进度: $progress%');
|
||||
},
|
||||
onSuccess: (result) => print('上传成功: ${result.url}'),
|
||||
onError: (error) => print('上传失败: ${error.message}'),
|
||||
onComplete: () => print('上传完成'),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
### 断点续传
|
||||
|
||||
支持大文件的断点续传功能:
|
||||
|
||||
```dart
|
||||
// 启用断点续传的上传
|
||||
final result = await client.uploadFile(
|
||||
largeFilePath,
|
||||
options: UploadOptions(
|
||||
enableResume: true,
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: (sent, total) {
|
||||
print('断点续传进度: ${(sent/total*100).toInt()}%');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 获取可恢复的上传任务
|
||||
final resumableTasks = await client.getResumableTasks();
|
||||
for (final task in resumableTasks) {
|
||||
print('可恢复任务: ${task.objectKey}, 进度: ${task.uploadedSize}/${task.totalSize}');
|
||||
}
|
||||
|
||||
// 恢复指定任务
|
||||
final resumeResult = await client.resumeUpload(taskId);
|
||||
|
||||
// 清理过期的断点续传数据
|
||||
await client.cleanupExpiredData(maxAgeDays: 7);
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
|
||||
库提供了完整的错误类型定义:
|
||||
|
||||
```dart
|
||||
try {
|
||||
final result = await client.uploadBytes(data, 'file.txt');
|
||||
print('上传成功: ${result.url}');
|
||||
} catch (e) {
|
||||
if (e is OSSError) {
|
||||
switch (e.type) {
|
||||
case OSSErrorType.network:
|
||||
print('网络错误: ${e.message}');
|
||||
break;
|
||||
case OSSErrorType.authentication:
|
||||
print('认证错误: ${e.message}');
|
||||
break;
|
||||
case OSSErrorType.permission:
|
||||
print('权限错误: ${e.message}');
|
||||
break;
|
||||
case OSSErrorType.server:
|
||||
print('服务器错误: ${e.message}');
|
||||
break;
|
||||
default:
|
||||
print('其他错误: ${e.message}');
|
||||
}
|
||||
|
||||
// 检查是否可重试
|
||||
if (e.isRetryable) {
|
||||
print('此错误可以重试');
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔧 高级配置
|
||||
|
||||
### 完整配置示例
|
||||
|
||||
```dart
|
||||
final config = YxOSSConfig(
|
||||
authProvider: authProvider,
|
||||
configProvider: configProvider,
|
||||
|
||||
// 超时配置
|
||||
timeoutConfig: TimeoutConfig(
|
||||
connectTimeout: 30000, // 30秒连接超时
|
||||
sendTimeout: 600000, // 10分钟发送超时
|
||||
receiveTimeout: 600000, // 10分钟接收超时
|
||||
),
|
||||
|
||||
// 重试配置
|
||||
retryConfig: RetryConfig(
|
||||
maxRetries: 5, // 最多重试5次
|
||||
initialInterval: 500, // 初始间隔500ms
|
||||
backoffMultiplier: 2.0, // 指数退避因子
|
||||
maxInterval: 30000, // 最大间隔30秒
|
||||
enableExponentialBackoff: true, // 启用指数退避
|
||||
),
|
||||
|
||||
// 日志配置
|
||||
logConfig: LogConfig(
|
||||
enabled: true,
|
||||
level: LogLevel.debug,
|
||||
logRequest: true, // 记录请求详情
|
||||
logResponse: true, // 记录响应详情
|
||||
logError: true, // 记录错误详情
|
||||
customHandler: (message, level) {
|
||||
// 自定义日志处理
|
||||
print('[YX_OSS] ${level.name.toUpperCase()}: $message');
|
||||
},
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
## 🏗️ 项目集成
|
||||
|
||||
### 使用项目集成适配器
|
||||
|
||||
库提供了现成的项目集成适配器,可以快速集成到现有项目中:
|
||||
|
||||
```dart
|
||||
// 1. 实现服务器API接口
|
||||
class MyOSSApiImpl implements YxOssServerInterface {
|
||||
@override
|
||||
Future<YxOSSConfigModel> getOssConfig() async {
|
||||
// 从你的后端获取OSS配置
|
||||
final response = await http.get('/api/oss-config');
|
||||
return YxOSSConfigModel.fromJson(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<YxOSSSignModel> getOssSign({String? objectName, int? type}) async {
|
||||
// 从你的后端获取预签名URL
|
||||
final response = await http.post('/api/oss-sign', {
|
||||
'objectName': objectName,
|
||||
'type': type,
|
||||
});
|
||||
return YxOSSSignModel.fromJson(response.data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> deleteOSSFile({required String filePath}) async {
|
||||
// 删除文件
|
||||
final response = await http.delete('/api/oss-file', {
|
||||
'filePath': filePath,
|
||||
});
|
||||
return response.statusCode == 200;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 使用项目集成管理器
|
||||
final ossManager = ProjectOSSManager(MyOSSApiImpl());
|
||||
await ossManager.initialize();
|
||||
|
||||
// 3. 上传文件(自动使用预签名URL)
|
||||
final fileUrl = await ossManager.uploadFile(
|
||||
filePath,
|
||||
onProgress: (sent, total) {
|
||||
print('上传进度: ${(sent/total*100).toInt()}%');
|
||||
},
|
||||
type: 1, // 文件类型:1-普通上传,2-资料收集等
|
||||
);
|
||||
|
||||
// 4. 上传字节数组
|
||||
final fileUrl = await ossManager.uploadBytes(
|
||||
fileBytes,
|
||||
'example.jpg',
|
||||
onProgress: (sent, total) => print('进度: $sent/$total'),
|
||||
type: 1,
|
||||
);
|
||||
|
||||
// 5. 删除文件
|
||||
final success = await ossManager.deleteFile(fileUrl);
|
||||
|
||||
// 6. 断点续传相关
|
||||
final tasks = await ossManager.getResumableTasks();
|
||||
final resumeResult = await ossManager.resumeUpload(taskId);
|
||||
await ossManager.cleanupExpiredData(maxAgeDays: 7);
|
||||
```
|
||||
|
||||
### 集成到应用生命周期
|
||||
|
||||
```dart
|
||||
class MyApp extends StatefulWidget {
|
||||
@override
|
||||
_MyAppState createState() => _MyAppState();
|
||||
}
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
late final ProjectOSSManager ossManager;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ossManager = ProjectOSSManager(MyOSSApiImpl());
|
||||
}
|
||||
|
||||
Future<void> initializeOSS() async {
|
||||
try {
|
||||
await ossManager.initialize();
|
||||
print('OSS初始化成功');
|
||||
} catch (e) {
|
||||
print('OSS初始化失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
ossManager.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ... 其他代码
|
||||
}
|
||||
```
|
||||
|
||||
## 🌍 区域支持
|
||||
|
||||
库内置了常用的阿里云OSS区域配置:
|
||||
|
||||
```dart
|
||||
// 中国大陆区域
|
||||
CommonRegions.hangzhou // 华东1 (杭州)
|
||||
CommonRegions.beijing // 华北2 (北京)
|
||||
CommonRegions.shanghai // 华东2 (上海)
|
||||
CommonRegions.shenzhen // 华南1 (深圳)
|
||||
CommonRegions.chengdu // 西南1 (成都)
|
||||
|
||||
// 海外区域
|
||||
CommonRegions.singapore // 亚太东南1 (新加坡)
|
||||
CommonRegions.sydney // 亚太东南2 (悉尼)
|
||||
CommonRegions.tokyo // 亚太东北1 (东京)
|
||||
CommonRegions.virginia // 美国东部1 (弗吉尼亚)
|
||||
CommonRegions.london // 欧洲西部1 (伦敦)
|
||||
```
|
||||
|
||||
## 🔄 迁移指南
|
||||
|
||||
### 从Legacy OSS迁移
|
||||
|
||||
如果你正在从现有的OSS实现迁移到YX OSS:
|
||||
|
||||
#### 原项目代码
|
||||
```dart
|
||||
// 原项目可能是这样的
|
||||
final ossClient = AliyunOssClientUtils();
|
||||
await ossClient.uploadFile(file, fileName);
|
||||
```
|
||||
|
||||
#### YX OSS代码
|
||||
```dart
|
||||
// 迁移到YX OSS - 使用项目集成适配器
|
||||
final ossManager = ProjectOSSManager(apiImpl);
|
||||
await ossManager.initialize();
|
||||
final fileUrl = await ossManager.uploadFile(filePath);
|
||||
```
|
||||
|
||||
#### 迁移优势
|
||||
|
||||
1. **解耦合**: YX OSS是完全独立的库,不依赖项目特定的代码
|
||||
2. **更好的错误处理**: 类型安全的错误处理机制
|
||||
3. **断点续传**: 完整的断点续传支持
|
||||
4. **灵活配置**: 支持多种认证和配置方式
|
||||
5. **统一日志**: 可配置的日志系统
|
||||
6. **更好的测试**: 所有组件都可以独立测试
|
||||
|
||||
## 📚 API文档
|
||||
|
||||
### 核心类
|
||||
|
||||
- `YxOSSClient` - OSS客户端主类
|
||||
- `YxOSSConfig` - 客户端配置
|
||||
- `ProjectOSSManager` - 项目集成管理器
|
||||
|
||||
### 接口
|
||||
|
||||
- `AuthProvider` - 认证提供者接口
|
||||
- `ConfigProvider` - 配置提供者接口
|
||||
- `ResumeStorage` - 断点续传存储接口
|
||||
- `YxOssServerInterface` - 服务器API接口
|
||||
|
||||
### 模型
|
||||
|
||||
- `UploadOptions` - 上传选项
|
||||
- `UploadResult` - 上传结果
|
||||
- `OSSError` - 错误类型
|
||||
|
||||
### 工具
|
||||
|
||||
- `YxOSSFileUtils` - 文件工具类
|
||||
- `OssLogger` - 日志工具
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
```dart
|
||||
// 运行测试
|
||||
flutter test
|
||||
|
||||
// 运行集成测试
|
||||
flutter test integration_test/
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 开发环境设置
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支
|
||||
3. 提交更改
|
||||
4. 创建 Pull Request
|
||||
|
||||
|
||||
|
||||
## 📈 更新日志
|
||||
|
||||
### v1.0.0
|
||||
- ✅ 核心OSS上传下载功能
|
||||
- ✅ 多种认证方式支持
|
||||
- ✅ 断点续传功能
|
||||
- ✅ 项目集成适配器
|
||||
- ✅ 统一日志系统
|
||||
- ✅ 完整的错误处理
|
||||
- ✅ 类型安全的API设计
|
||||
|
||||
---
|
||||
|
||||
**YX OSS** - 让 Flutter OSS 上传更简单、更可靠! 🚀
|
||||
|
|
@ -0,0 +1,534 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:yx_oss/yx_oss.dart';
|
||||
|
||||
/// YX OSS 使用示例
|
||||
///
|
||||
/// 展示如何使用不同的认证和配置方式
|
||||
void main() async {
|
||||
print('YX OSS 使用示例');
|
||||
|
||||
// 示例1: 静态配置 + 静态认证
|
||||
await example1StaticAuth();
|
||||
|
||||
// 示例2: STS临时凭证认证
|
||||
await example2STSAuth();
|
||||
|
||||
// 示例3: 预签名URL认证
|
||||
await example3PreSignedAuth();
|
||||
|
||||
// 示例4: 动态配置
|
||||
await example4DynamicConfig();
|
||||
|
||||
// 示例5: 分片上传
|
||||
await example5MultipartUpload();
|
||||
}
|
||||
|
||||
/// 示例1: 静态配置 + 静态认证
|
||||
/// 适用场景: 开发测试环境,简单的应用场景
|
||||
Future<void> example1StaticAuth() async {
|
||||
print('\n=== 示例1: 静态配置 + 静态认证 ===');
|
||||
|
||||
// 创建静态认证提供者
|
||||
const authProvider = StaticAuthProvider(
|
||||
accessKeyId: 'your_access_key_id',
|
||||
accessKeySecret: 'your_access_key_secret',
|
||||
);
|
||||
|
||||
// 创建静态配置提供者
|
||||
const configProvider = StaticConfigProvider(
|
||||
endpoint: 'oss-cn-hangzhou.aliyuncs.com',
|
||||
bucketName: 'your-bucket-name',
|
||||
directory: 'uploads', // 可选,默认为'uploads'
|
||||
domain: 'https://your-custom-domain.com', // 可选,自定义域名
|
||||
);
|
||||
|
||||
// 创建OSS客户端配置
|
||||
const config = YxOSSConfig(
|
||||
authProvider: authProvider,
|
||||
configProvider: configProvider,
|
||||
// 可选的超时配置
|
||||
timeoutConfig: TimeoutConfig(
|
||||
connectTimeout: 60000,
|
||||
sendTimeout: 300000,
|
||||
receiveTimeout: 300000,
|
||||
),
|
||||
// 可选的重试配置
|
||||
retryConfig: RetryConfig(
|
||||
maxRetries: 3,
|
||||
initialInterval: 1000,
|
||||
enableExponentialBackoff: true,
|
||||
),
|
||||
// 可选的日志配置
|
||||
logConfig: LogConfig(
|
||||
enabled: true,
|
||||
level: LogLevel.info,
|
||||
logRequest: false,
|
||||
logResponse: false,
|
||||
logError: true,
|
||||
),
|
||||
);
|
||||
|
||||
// 创建OSS客户端
|
||||
final client = YxOSSClient(config);
|
||||
|
||||
try {
|
||||
// 初始化客户端
|
||||
await client.initialize();
|
||||
|
||||
// 准备上传的文件数据
|
||||
final fileData = Uint8List.fromList('Hello, YX OSS!'.codeUnits);
|
||||
|
||||
// 创建上传选项
|
||||
final options = UploadOptions(
|
||||
overwrite: true,
|
||||
acl: ACLMode.publicRead,
|
||||
storageClass: StorageClass.standard,
|
||||
contentType: 'text/plain',
|
||||
metadata: {'author': 'YX OSS Example'},
|
||||
callbacks: UploadCallbacks(
|
||||
onStart: () => print('开始上传...'),
|
||||
onProgress: (sent, total) {
|
||||
final progress = (sent / total * 100).toStringAsFixed(1);
|
||||
print('上传进度: $progress% ($sent/$total)');
|
||||
},
|
||||
onSuccess: (result) => print('上传成功: ${result.url}'),
|
||||
onError: (error) => print('上传失败: $error'),
|
||||
onComplete: () => print('上传完成'),
|
||||
),
|
||||
);
|
||||
|
||||
// 执行上传
|
||||
final result = await client.uploadBytes(
|
||||
fileData,
|
||||
'examples/test-file.txt',
|
||||
options: options,
|
||||
);
|
||||
|
||||
print('上传结果:');
|
||||
print(' URL: ${result.url}');
|
||||
print(' 对象键: ${result.objectKey}');
|
||||
print(' 文件大小: ${result.fileSize} 字节');
|
||||
print(' ETag: ${result.etag}');
|
||||
print(' 上传耗时: ${result.uploadDuration} 毫秒');
|
||||
} catch (e) {
|
||||
print('操作失败: $e');
|
||||
} finally {
|
||||
// 释放资源
|
||||
client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 示例2: STS临时凭证认证
|
||||
/// 适用场景: 生产环境,需要动态权限控制
|
||||
Future<void> example2STSAuth() async {
|
||||
print('\n=== 示例2: STS临时凭证认证 ===');
|
||||
|
||||
// 模拟从服务器获取STS凭证的函数
|
||||
Future<STSCredentials> getSTSCredentials() async {
|
||||
// 实际应用中,这里应该调用您的后端API获取STS凭证
|
||||
// 这里只是示例数据
|
||||
return STSCredentials(
|
||||
accessKeyId: 'STS.your_sts_access_key_id',
|
||||
accessKeySecret: 'your_sts_access_key_secret',
|
||||
securityToken: 'your_security_token',
|
||||
expiration: DateTime.now().add(const Duration(hours: 1)),
|
||||
);
|
||||
}
|
||||
|
||||
// 创建STS认证提供者
|
||||
final stsCredentials = await getSTSCredentials();
|
||||
final authProvider = STSAuthProvider(
|
||||
accessKeyId: stsCredentials.accessKeyId,
|
||||
accessKeySecret: stsCredentials.accessKeySecret,
|
||||
securityToken: stsCredentials.securityToken,
|
||||
expiration: stsCredentials.expiration,
|
||||
credentialsGetter: getSTSCredentials, // 凭证过期时自动刷新
|
||||
);
|
||||
|
||||
const configProvider = StaticConfigProvider(
|
||||
endpoint: 'oss-cn-hangzhou.aliyuncs.com',
|
||||
bucketName: 'your-bucket-name',
|
||||
directory: 'sts-uploads',
|
||||
);
|
||||
|
||||
final config = YxOSSConfig(
|
||||
authProvider: authProvider,
|
||||
configProvider: configProvider,
|
||||
);
|
||||
|
||||
final client = YxOSSClient(config);
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
|
||||
final fileData = Uint8List.fromList('STS upload test'.codeUnits);
|
||||
|
||||
final result = await client.uploadBytes(
|
||||
fileData,
|
||||
'sts-test-file.txt',
|
||||
options: UploadOptions(
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: (sent, total) {
|
||||
print('STS上传进度: ${(sent / total * 100).toStringAsFixed(1)}%');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
print('STS上传成功: ${result.url}');
|
||||
} catch (e) {
|
||||
print('STS上传失败: $e');
|
||||
} finally {
|
||||
client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 示例3: 预签名URL认证
|
||||
/// 适用场景: 客户端直接上传,服务端控制权限
|
||||
Future<void> example3PreSignedAuth() async {
|
||||
print('\n=== 示例3: 预签名URL认证 ===');
|
||||
|
||||
// 模拟从服务器获取预签名URL的函数
|
||||
Future<String> getPreSignedUrl(String fileName) async {
|
||||
// 实际应用中,这里应该调用您的后端API获取预签名URL
|
||||
// 后端会根据文件名和权限策略生成预签名URL
|
||||
return 'https://your-bucket.oss-cn-hangzhou.aliyuncs.com/uploads/$fileName?Expires=1234567890&OSSAccessKeyId=your_key&Signature=signature';
|
||||
}
|
||||
|
||||
// 创建预签名URL认证提供者
|
||||
final authProvider = PreSignedAuthProvider(
|
||||
urlGetter: getPreSignedUrl,
|
||||
);
|
||||
|
||||
// 注意:预签名URL模式下,配置提供者仍然需要,但某些配置可能不会被使用
|
||||
const configProvider = StaticConfigProvider(
|
||||
endpoint: 'oss-cn-hangzhou.aliyuncs.com',
|
||||
bucketName: 'your-bucket-name',
|
||||
directory: '', // 预签名URL通常已包含完整路径
|
||||
);
|
||||
|
||||
final config = YxOSSConfig(
|
||||
authProvider: authProvider,
|
||||
configProvider: configProvider,
|
||||
);
|
||||
|
||||
final client = YxOSSClient(config);
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
|
||||
final fileData = Uint8List.fromList('PreSigned URL upload test'.codeUnits);
|
||||
|
||||
final result = await client.uploadBytes(
|
||||
fileData,
|
||||
'presigned-test-file.txt',
|
||||
options: UploadOptions(
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: (sent, total) {
|
||||
print('预签名上传进度: ${(sent / total * 100).toStringAsFixed(1)}%');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
print('预签名上传成功: ${result.url}');
|
||||
} catch (e) {
|
||||
print('预签名上传失败: $e');
|
||||
} finally {
|
||||
client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 示例4: 动态配置
|
||||
/// 适用场景: 配置信息需要从服务器动态获取
|
||||
Future<void> example4DynamicConfig() async {
|
||||
print('\n=== 示例4: 动态配置 ===');
|
||||
|
||||
// 模拟从服务器获取OSS配置的函数
|
||||
Future<OSSConfig> getOSSConfig() async {
|
||||
// 实际应用中,这里应该调用您的后端API获取OSS配置
|
||||
return const OSSConfig(
|
||||
endpoint: 'oss-cn-hangzhou.aliyuncs.com',
|
||||
bucketName: 'dynamic-bucket-name',
|
||||
directory: 'dynamic-uploads',
|
||||
domain: 'https://cdn.example.com',
|
||||
);
|
||||
}
|
||||
|
||||
const authProvider = StaticAuthProvider(
|
||||
accessKeyId: 'your_access_key_id',
|
||||
accessKeySecret: 'your_access_key_secret',
|
||||
);
|
||||
|
||||
// 创建动态配置提供者
|
||||
final configProvider = DynamicConfigProvider(
|
||||
configGetter: getOSSConfig,
|
||||
cacheTimeout: const Duration(minutes: 30), // 配置缓存30分钟
|
||||
);
|
||||
|
||||
final config = YxOSSConfig(
|
||||
authProvider: authProvider,
|
||||
configProvider: configProvider,
|
||||
);
|
||||
|
||||
final client = YxOSSClient(config);
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
|
||||
final fileData = Uint8List.fromList('Dynamic config upload test'.codeUnits);
|
||||
|
||||
final result = await client.uploadBytes(
|
||||
fileData,
|
||||
'dynamic-config-test.txt',
|
||||
options: UploadOptions(
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: (sent, total) {
|
||||
print('动态配置上传进度: ${(sent / total * 100).toStringAsFixed(1)}%');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
print('动态配置上传成功: ${result.url}');
|
||||
} catch (e) {
|
||||
print('动态配置上传失败: $e');
|
||||
} finally {
|
||||
client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 示例5: 分片上传
|
||||
/// 适用场景: 大文件上传,需要支持断点续传和并发上传
|
||||
Future<void> example5MultipartUpload() async {
|
||||
print('\n=== 示例5: 分片上传 ===');
|
||||
|
||||
const authProvider = StaticAuthProvider(
|
||||
accessKeyId: 'your_access_key_id',
|
||||
accessKeySecret: 'your_access_key_secret',
|
||||
);
|
||||
|
||||
const configProvider = StaticConfigProvider(
|
||||
endpoint: 'oss-cn-hangzhou.aliyuncs.com',
|
||||
bucketName: 'your-bucket-name',
|
||||
directory: 'large-files',
|
||||
);
|
||||
|
||||
const config = YxOSSConfig(
|
||||
authProvider: authProvider,
|
||||
configProvider: configProvider,
|
||||
);
|
||||
|
||||
final client = YxOSSClient(config);
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
|
||||
// 模拟大文件数据(200MB)
|
||||
final largeFileData = Uint8List(200 * 1024 * 1024);
|
||||
for (int i = 0; i < largeFileData.length; i++) {
|
||||
largeFileData[i] = i % 256;
|
||||
}
|
||||
|
||||
print(
|
||||
'准备上传 ${YxOSSFileUtils.formatFileSize(largeFileData.length)} 的大文件...');
|
||||
|
||||
final options = UploadOptions(
|
||||
// 启用分片上传
|
||||
enableMultipart: true,
|
||||
// 分片大小设置为10MB
|
||||
partSize: 10 * 1024 * 1024,
|
||||
// 并发上传3个分片
|
||||
concurrency: 3,
|
||||
// 100MB以上的文件使用分片上传
|
||||
multipartThreshold: 100 * 1024 * 1024,
|
||||
callbacks: UploadCallbacks(
|
||||
onStart: () => print('开始分片上传...'),
|
||||
onProgress: (sent, total) {
|
||||
final progress = (sent / total * 100).toStringAsFixed(1);
|
||||
final speed = YxOSSFileUtils.formatFileSize(sent ~/ 10); // 假设10秒速度
|
||||
print('分片上传进度: $progress% - 速度: $speed');
|
||||
},
|
||||
onSuccess: (result) {
|
||||
if (result is MultipartUploadResult) {
|
||||
print('分片上传成功:');
|
||||
print(' URL: ${result.url}');
|
||||
print(' 分片数量: ${result.partCount}');
|
||||
print(' 上传ID: ${result.uploadId}');
|
||||
print(' 总耗时: ${result.uploadDuration} 毫秒');
|
||||
}
|
||||
},
|
||||
onError: (error) => print('分片上传失败: $error'),
|
||||
onComplete: () => print('分片上传完成'),
|
||||
),
|
||||
);
|
||||
|
||||
final result = await client.uploadBytes(
|
||||
largeFileData,
|
||||
'large-file-${DateTime.now().millisecondsSinceEpoch}.dat',
|
||||
options: options,
|
||||
);
|
||||
|
||||
print('大文件上传结果: ${result.url}');
|
||||
} catch (e) {
|
||||
print('大文件上传失败: $e');
|
||||
} finally {
|
||||
client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 示例6: 错误处理和重试
|
||||
Future<void> example6ErrorHandling() async {
|
||||
print('\n=== 示例6: 错误处理和重试 ===');
|
||||
|
||||
const authProvider = StaticAuthProvider(
|
||||
accessKeyId: 'invalid_key', // 故意使用错误的key来演示错误处理
|
||||
accessKeySecret: 'invalid_secret',
|
||||
);
|
||||
|
||||
const configProvider = StaticConfigProvider(
|
||||
endpoint: 'oss-cn-hangzhou.aliyuncs.com',
|
||||
bucketName: 'your-bucket-name',
|
||||
);
|
||||
|
||||
const config = YxOSSConfig(
|
||||
authProvider: authProvider,
|
||||
configProvider: configProvider,
|
||||
// 配置重试策略
|
||||
retryConfig: RetryConfig(
|
||||
maxRetries: 3,
|
||||
initialInterval: 1000,
|
||||
backoffMultiplier: 2.0,
|
||||
enableExponentialBackoff: true,
|
||||
),
|
||||
);
|
||||
|
||||
final client = YxOSSClient(config);
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
|
||||
final fileData = Uint8List.fromList('Error handling test'.codeUnits);
|
||||
|
||||
await client.uploadBytes(
|
||||
fileData,
|
||||
'error-test.txt',
|
||||
options: UploadOptions(
|
||||
callbacks: UploadCallbacks(
|
||||
onError: (error) {
|
||||
print('捕获到错误:');
|
||||
print(' 类型: ${error.type}');
|
||||
print(' 消息: ${error.message}');
|
||||
print(' 状态码: ${error.statusCode ?? 'N/A'}');
|
||||
print(' 请求ID: ${error.requestId ?? 'N/A'}');
|
||||
print(' 是否可重试: ${error.isRetryable}');
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (e is OSSError) {
|
||||
print('OSS错误详情:');
|
||||
print(' 错误类型: ${e.type}');
|
||||
print(' 错误代码: ${e.code}');
|
||||
print(' 错误消息: ${e.message}');
|
||||
print(' HTTP状态码: ${e.statusCode}');
|
||||
print(' 是否为认证错误: ${e.isAuthenticationError}');
|
||||
print(' 是否为网络错误: ${e.isNetworkError}');
|
||||
} else {
|
||||
print('未知错误: $e');
|
||||
}
|
||||
} finally {
|
||||
client.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 示例7: 集成到项目中的适配器模式
|
||||
class ProjectOSSAdapter {
|
||||
late final YxOSSClient _client;
|
||||
|
||||
/// 初始化适配器
|
||||
Future<void> initialize({
|
||||
required String endpoint,
|
||||
required String bucketName,
|
||||
required String accessKeyId,
|
||||
required String accessKeySecret,
|
||||
String? customDomain,
|
||||
}) async {
|
||||
final authProvider = StaticAuthProvider(
|
||||
accessKeyId: accessKeyId,
|
||||
accessKeySecret: accessKeySecret,
|
||||
);
|
||||
|
||||
final configProvider = StaticConfigProvider(
|
||||
endpoint: endpoint,
|
||||
bucketName: bucketName,
|
||||
directory: 'app-uploads',
|
||||
domain: customDomain,
|
||||
);
|
||||
|
||||
final config = YxOSSConfig(
|
||||
authProvider: authProvider,
|
||||
configProvider: configProvider,
|
||||
logConfig: const LogConfig(
|
||||
enabled: true,
|
||||
level: LogLevel.warning, // 生产环境只记录警告和错误
|
||||
),
|
||||
);
|
||||
|
||||
_client = YxOSSClient(config);
|
||||
await _client.initialize();
|
||||
}
|
||||
|
||||
/// 上传图片文件
|
||||
Future<String> uploadImage(
|
||||
Uint8List imageData,
|
||||
String fileName, {
|
||||
Function(double progress)? onProgress,
|
||||
}) async {
|
||||
final result = await _client.uploadBytes(
|
||||
imageData,
|
||||
'images/${YxOSSFileUtils.generateUniqueFileName(originalFileName: fileName)}',
|
||||
options: UploadOptions(
|
||||
contentType: YxOSSFileUtils.guessMimeType(fileName),
|
||||
acl: ACLMode.publicRead,
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: (sent, total) {
|
||||
onProgress?.call(sent / total);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/// 上传文档文件
|
||||
Future<String> uploadDocument(
|
||||
Uint8List documentData,
|
||||
String fileName, {
|
||||
Function(double progress)? onProgress,
|
||||
}) async {
|
||||
final result = await _client.uploadBytes(
|
||||
documentData,
|
||||
'documents/${YxOSSFileUtils.generateUniqueFileName(originalFileName: fileName)}',
|
||||
options: UploadOptions(
|
||||
contentType: YxOSSFileUtils.guessMimeType(fileName),
|
||||
acl: ACLMode.private, // 文档默认私有
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: (sent, total) {
|
||||
onProgress?.call(sent / total);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/// 释放资源
|
||||
void dispose() {
|
||||
_client.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:yx_oss/yx_oss.dart';
|
||||
|
||||
/// 简单使用示例
|
||||
/// 展示基本的上传功能
|
||||
void main() async {
|
||||
print('YX OSS 简单示例');
|
||||
|
||||
// 创建认证提供者
|
||||
const authProvider = StaticAuthProvider(
|
||||
accessKeyId: 'your_access_key_id',
|
||||
accessKeySecret: 'your_access_key_secret',
|
||||
);
|
||||
|
||||
// 创建配置提供者
|
||||
const configProvider = StaticConfigProvider(
|
||||
endpoint: 'oss-cn-hangzhou.aliyuncs.com',
|
||||
bucketName: 'your-bucket-name',
|
||||
directory: 'uploads',
|
||||
);
|
||||
|
||||
// 创建OSS客户端配置
|
||||
const config = YxOSSConfig(
|
||||
authProvider: authProvider,
|
||||
configProvider: configProvider,
|
||||
);
|
||||
|
||||
// 创建OSS客户端
|
||||
final client = YxOSSClient(config);
|
||||
|
||||
try {
|
||||
// 初始化客户端
|
||||
await client.initialize();
|
||||
|
||||
// 准备上传的文件数据
|
||||
final fileData = Uint8List.fromList('Hello, YX OSS!'.codeUnits);
|
||||
|
||||
// 创建上传选项
|
||||
final options = UploadOptions(
|
||||
overwrite: true,
|
||||
contentType: 'text/plain',
|
||||
callbacks: UploadCallbacks(
|
||||
onStart: () => print('开始上传...'),
|
||||
onProgress: (sent, total) {
|
||||
final progress = (sent / total * 100).toStringAsFixed(1);
|
||||
print('上传进度: $progress% ($sent/$total)');
|
||||
},
|
||||
onSuccess: (result) => print('上传成功'),
|
||||
onError: (error) => print('上传失败'),
|
||||
onComplete: () => print('上传完成'),
|
||||
),
|
||||
);
|
||||
|
||||
// 执行上传
|
||||
final result = await client.uploadBytes(
|
||||
fileData,
|
||||
'simple-test.txt',
|
||||
options: options,
|
||||
);
|
||||
|
||||
print('上传成功: ${result.url}');
|
||||
} catch (e) {
|
||||
print('操作失败: $e');
|
||||
} finally {
|
||||
// 释放资源
|
||||
client.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,861 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../core/yx_oss_client.dart';
|
||||
import '../core/yx_oss_config.dart';
|
||||
import '../interfaces/auth_provider.dart';
|
||||
import '../interfaces/config_provider.dart';
|
||||
import '../interfaces/upload_callbacks.dart';
|
||||
import '../models/upload_options.dart';
|
||||
import '../utils/file_utils.dart';
|
||||
import '../utils/oss_logger.dart';
|
||||
|
||||
// 项目API相关类型定义
|
||||
/// Server API的OSS配置结果
|
||||
class YxOSSConfigModel {
|
||||
final String? accessKeyId;
|
||||
final String? accessKeySecret;
|
||||
final String? endpoint;
|
||||
final String? bucketName;
|
||||
final int size;
|
||||
|
||||
const YxOSSConfigModel({
|
||||
this.accessKeyId,
|
||||
this.accessKeySecret,
|
||||
this.endpoint,
|
||||
this.bucketName,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
factory YxOSSConfigModel.fromJson(Map<String, dynamic> json) {
|
||||
return YxOSSConfigModel(
|
||||
accessKeyId: json['accessKeyId'] as String?,
|
||||
accessKeySecret: json['accessKeySecret'] as String?,
|
||||
endpoint: json['endpoint'] as String?,
|
||||
bucketName: json['bucketName'] as String?,
|
||||
size: json['size'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Server API的OSS签名结果
|
||||
class YxOSSSignModel {
|
||||
final String? filePath;
|
||||
final int fileSize;
|
||||
final String? uploadUrl;
|
||||
|
||||
const YxOSSSignModel({
|
||||
this.filePath,
|
||||
required this.fileSize,
|
||||
this.uploadUrl,
|
||||
});
|
||||
|
||||
factory YxOSSSignModel.fromJson(Map<String, dynamic> json) {
|
||||
return YxOSSSignModel(
|
||||
filePath: json['filePath'] as String?,
|
||||
fileSize: json['fileSize'] as int? ?? 0,
|
||||
uploadUrl: json['uploadUrl'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// YX OSS API接口
|
||||
abstract class YxOssServerInterface {
|
||||
/// 获取OSS配置
|
||||
Future<YxOSSConfigModel> getOssConfig();
|
||||
|
||||
/// 获取OSS预签名
|
||||
Future<YxOSSSignModel> getOssSign({String? objectName, int? type});
|
||||
|
||||
/// 删除OSS文件
|
||||
Future<bool> deleteOSSFile({required String filePath});
|
||||
}
|
||||
|
||||
/// 项目集成适配器
|
||||
///
|
||||
/// 这个适配器展示了如何将YX OSS库集成到现有项目中
|
||||
class ProjectOSSAdapter {
|
||||
late final YxOSSClient _client;
|
||||
bool _isInitialized = false;
|
||||
|
||||
/// 初始化适配器
|
||||
///
|
||||
/// [endpoint] OSS端点
|
||||
/// [bucketName] 存储桶名称
|
||||
/// [accessKeyId] 访问密钥ID
|
||||
/// [accessKeySecret] 访问密钥密码
|
||||
/// [customDomain] 自定义域名(可选)
|
||||
/// [directory] 上传目录(可选,默认为'app-uploads')
|
||||
Future<void> initialize({
|
||||
required String endpoint,
|
||||
required String bucketName,
|
||||
required String accessKeyId,
|
||||
required String accessKeySecret,
|
||||
String? customDomain,
|
||||
String directory = 'app-uploads',
|
||||
}) async {
|
||||
if (_isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
final authProvider = StaticAuthProvider(
|
||||
accessKeyId: accessKeyId,
|
||||
accessKeySecret: accessKeySecret,
|
||||
);
|
||||
|
||||
final configProvider = StaticConfigProvider(
|
||||
endpoint: endpoint,
|
||||
bucketName: bucketName,
|
||||
directory: directory,
|
||||
domain: customDomain,
|
||||
);
|
||||
|
||||
final config = YxOSSConfig(
|
||||
authProvider: authProvider,
|
||||
configProvider: configProvider,
|
||||
logConfig: const LogConfig(
|
||||
enabled: true,
|
||||
level: LogLevel.warning, // 生产环境只记录警告和错误
|
||||
logError: true,
|
||||
),
|
||||
);
|
||||
|
||||
_client = YxOSSClient(config);
|
||||
await _client.initialize();
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
/// 使用STS凭证初始化
|
||||
///
|
||||
/// [endpoint] OSS端点
|
||||
/// [bucketName] 存储桶名称
|
||||
/// [stsCredentials] STS凭证
|
||||
/// [credentialsRefresher] 凭证刷新函数
|
||||
/// [customDomain] 自定义域名(可选)
|
||||
/// [directory] 上传目录(可选)
|
||||
Future<void> initializeWithSTS({
|
||||
required String endpoint,
|
||||
required String bucketName,
|
||||
required STSCredentials stsCredentials,
|
||||
required Future<STSCredentials> Function() credentialsRefresher,
|
||||
String? customDomain,
|
||||
String directory = 'app-uploads',
|
||||
}) async {
|
||||
if (_isInitialized) {
|
||||
throw StateError('Adapter already initialized');
|
||||
}
|
||||
|
||||
final authProvider = STSAuthProvider(
|
||||
accessKeyId: stsCredentials.accessKeyId,
|
||||
accessKeySecret: stsCredentials.accessKeySecret,
|
||||
securityToken: stsCredentials.securityToken,
|
||||
expiration: stsCredentials.expiration,
|
||||
credentialsGetter: credentialsRefresher,
|
||||
);
|
||||
|
||||
final configProvider = StaticConfigProvider(
|
||||
endpoint: endpoint,
|
||||
bucketName: bucketName,
|
||||
directory: directory,
|
||||
domain: customDomain,
|
||||
);
|
||||
|
||||
final config = YxOSSConfig(
|
||||
authProvider: authProvider,
|
||||
configProvider: configProvider,
|
||||
);
|
||||
|
||||
_client = YxOSSClient(config);
|
||||
await _client.initialize();
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
/// 上传图片文件
|
||||
///
|
||||
/// [imageData] 图片数据
|
||||
/// [fileName] 文件名
|
||||
/// [onProgress] 进度回调
|
||||
/// [quality] 图片质量(0.0-1.0,仅用于说明,实际压缩需要额外实现)
|
||||
Future<String> uploadImage(
|
||||
Uint8List imageData,
|
||||
String fileName, {
|
||||
Function(double progress)? onProgress,
|
||||
}) async {
|
||||
_checkInitialized();
|
||||
|
||||
// 生成唯一文件名
|
||||
final uniqueFileName = YxOSSFileUtils.generateUniqueFileName(
|
||||
originalFileName: fileName,
|
||||
prefix: 'img_',
|
||||
);
|
||||
|
||||
final result = await _client.uploadBytes(
|
||||
imageData,
|
||||
'images/$uniqueFileName',
|
||||
options: UploadOptions(
|
||||
contentType: YxOSSFileUtils.guessMimeType(fileName) ?? 'image/jpeg',
|
||||
acl: ACLMode.publicRead,
|
||||
metadata: {
|
||||
'original_name': fileName,
|
||||
'upload_time': DateTime.now().toIso8601String(),
|
||||
'file_type': 'image',
|
||||
},
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: (sent, total) {
|
||||
onProgress?.call(sent / total);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/// 上传文档文件
|
||||
///
|
||||
/// [documentData] 文档数据
|
||||
/// [fileName] 文件名
|
||||
/// [onProgress] 进度回调
|
||||
/// [isPublic] 是否公开访问
|
||||
Future<String> uploadDocument(
|
||||
Uint8List documentData,
|
||||
String fileName, {
|
||||
Function(double progress)? onProgress,
|
||||
bool isPublic = false,
|
||||
}) async {
|
||||
_checkInitialized();
|
||||
|
||||
final uniqueFileName = YxOSSFileUtils.generateUniqueFileName(
|
||||
originalFileName: fileName,
|
||||
prefix: 'doc_',
|
||||
);
|
||||
|
||||
final result = await _client.uploadBytes(
|
||||
documentData,
|
||||
'documents/$uniqueFileName',
|
||||
options: UploadOptions(
|
||||
contentType: YxOSSFileUtils.guessMimeType(fileName) ??
|
||||
'application/octet-stream',
|
||||
acl: isPublic ? ACLMode.publicRead : ACLMode.private,
|
||||
metadata: {
|
||||
'original_name': fileName,
|
||||
'upload_time': DateTime.now().toIso8601String(),
|
||||
'file_type': 'document',
|
||||
},
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: (sent, total) {
|
||||
onProgress?.call(sent / total);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/// 上传用户头像
|
||||
///
|
||||
/// [avatarData] 头像数据
|
||||
/// [userId] 用户ID
|
||||
/// [onProgress] 进度回调
|
||||
Future<String> uploadUserAvatar(
|
||||
Uint8List avatarData,
|
||||
String userId, {
|
||||
Function(double progress)? onProgress,
|
||||
}) async {
|
||||
_checkInitialized();
|
||||
|
||||
final fileName =
|
||||
'avatar_${userId}_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
|
||||
final result = await _client.uploadBytes(
|
||||
avatarData,
|
||||
'avatars/$fileName',
|
||||
options: UploadOptions(
|
||||
contentType: 'image/jpeg',
|
||||
acl: ACLMode.publicRead,
|
||||
metadata: {
|
||||
'user_id': userId,
|
||||
'upload_time': DateTime.now().toIso8601String(),
|
||||
'file_type': 'avatar',
|
||||
},
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: (sent, total) {
|
||||
onProgress?.call(sent / total);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/// 上传大文件(自动使用分片上传)
|
||||
///
|
||||
/// [fileData] 文件数据
|
||||
/// [fileName] 文件名
|
||||
/// [onProgress] 进度回调
|
||||
/// [category] 文件分类(用于目录组织)
|
||||
Future<String> uploadLargeFile(
|
||||
Uint8List fileData,
|
||||
String fileName, {
|
||||
Function(double progress)? onProgress,
|
||||
String category = 'files',
|
||||
}) async {
|
||||
_checkInitialized();
|
||||
|
||||
final uniqueFileName = YxOSSFileUtils.generateUniqueFileName(
|
||||
originalFileName: fileName,
|
||||
prefix: 'large_',
|
||||
);
|
||||
|
||||
final result = await _client.uploadBytes(
|
||||
fileData,
|
||||
'$category/$uniqueFileName',
|
||||
options: UploadOptions(
|
||||
contentType: YxOSSFileUtils.guessMimeType(fileName) ??
|
||||
'application/octet-stream',
|
||||
acl: ACLMode.private,
|
||||
// 大文件配置
|
||||
enableMultipart: true,
|
||||
partSize: 10 * 1024 * 1024, // 10MB分片
|
||||
multipartThreshold: 50 * 1024 * 1024, // 50MB阈值
|
||||
concurrency: 3, // 3个并发上传
|
||||
metadata: {
|
||||
'original_name': fileName,
|
||||
'upload_time': DateTime.now().toIso8601String(),
|
||||
'file_type': 'large_file',
|
||||
'category': category,
|
||||
},
|
||||
callbacks: UploadCallbacks(
|
||||
onProgress: (sent, total) {
|
||||
onProgress?.call(sent / total);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/// 批量上传文件
|
||||
///
|
||||
/// [files] 文件列表 (文件数据, 文件名)
|
||||
/// [onProgress] 总体进度回调
|
||||
/// [onSingleProgress] 单个文件进度回调
|
||||
/// [category] 文件分类
|
||||
Future<List<String>> uploadMultipleFiles(
|
||||
List<(Uint8List, String)> files, {
|
||||
Function(double progress)? onProgress,
|
||||
Function(int fileIndex, double progress)? onSingleProgress,
|
||||
String category = 'batch',
|
||||
}) async {
|
||||
_checkInitialized();
|
||||
|
||||
final results = <String>[];
|
||||
var completedFiles = 0;
|
||||
|
||||
for (int i = 0; i < files.length; i++) {
|
||||
final (fileData, fileName) = files[i];
|
||||
|
||||
try {
|
||||
final url = await uploadDocument(
|
||||
fileData,
|
||||
fileName,
|
||||
onProgress: (progress) {
|
||||
onSingleProgress?.call(i, progress);
|
||||
},
|
||||
);
|
||||
|
||||
results.add(url);
|
||||
completedFiles++;
|
||||
|
||||
// 更新总体进度
|
||||
onProgress?.call(completedFiles / files.length);
|
||||
} catch (e) {
|
||||
// 单个文件上传失败时,添加空字符串占位
|
||||
results.add('');
|
||||
completedFiles++;
|
||||
onProgress?.call(completedFiles / files.length);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// 检查是否已初始化
|
||||
void _checkInitialized() {
|
||||
if (!_isInitialized) {
|
||||
throw StateError('Adapter not initialized. Call initialize() first.');
|
||||
}
|
||||
}
|
||||
|
||||
/// 释放资源
|
||||
void dispose() {
|
||||
if (_isInitialized) {
|
||||
_client.dispose();
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取客户端是否已初始化
|
||||
bool get isInitialized => _isInitialized;
|
||||
}
|
||||
|
||||
/// 静态项目OSS配置
|
||||
class StaticProjectOSSConfig {
|
||||
final String endpoint;
|
||||
final String bucketName;
|
||||
final String directory;
|
||||
final String? domain;
|
||||
|
||||
const StaticProjectOSSConfig({
|
||||
required this.endpoint,
|
||||
required this.bucketName,
|
||||
required this.directory,
|
||||
this.domain,
|
||||
});
|
||||
}
|
||||
|
||||
/// 项目配置管理器
|
||||
///
|
||||
/// 用于管理不同环境下的OSS配置
|
||||
class ProjectOSSConfigManager {
|
||||
static const Map<String, StaticProjectOSSConfig> _configs = {
|
||||
'development': StaticProjectOSSConfig(
|
||||
endpoint: 'oss-cn-hangzhou.aliyuncs.com',
|
||||
bucketName: 'dev-bucket',
|
||||
directory: 'dev-uploads',
|
||||
),
|
||||
'testing': StaticProjectOSSConfig(
|
||||
endpoint: 'oss-cn-hangzhou.aliyuncs.com',
|
||||
bucketName: 'test-bucket',
|
||||
directory: 'test-uploads',
|
||||
),
|
||||
'production': StaticProjectOSSConfig(
|
||||
endpoint: 'oss-cn-hangzhou.aliyuncs.com',
|
||||
bucketName: 'prod-bucket',
|
||||
directory: 'uploads',
|
||||
domain: 'https://cdn.yourapp.com',
|
||||
),
|
||||
};
|
||||
|
||||
/// 获取指定环境的配置
|
||||
static StaticProjectOSSConfig? getConfig(String environment) {
|
||||
return _configs[environment];
|
||||
}
|
||||
|
||||
/// 获取当前环境的配置
|
||||
///
|
||||
/// 实际项目中应该从环境变量或配置文件中读取
|
||||
static StaticProjectOSSConfig getCurrentConfig() {
|
||||
const environment =
|
||||
String.fromEnvironment('ENV', defaultValue: 'development');
|
||||
return _configs[environment] ?? _configs['development']!;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 基于MyInfo API的集成提供者 ====================
|
||||
|
||||
/// 基于MyInfo API的动态配置提供者
|
||||
class MyInfoConfigProvider implements ConfigProvider {
|
||||
final YxOssServerInterface _api;
|
||||
YxOSSConfigModel? _cachedProjectConfig;
|
||||
DateTime? _cacheTime;
|
||||
|
||||
/// 缓存有效期(分钟)
|
||||
final int cacheValidityMinutes;
|
||||
|
||||
MyInfoConfigProvider(this._api, {this.cacheValidityMinutes = 30});
|
||||
|
||||
/// 检查缓存是否有效
|
||||
bool get _isCacheValid {
|
||||
if (_cachedProjectConfig == null || _cacheTime == null) return false;
|
||||
final now = DateTime.now();
|
||||
return now.difference(_cacheTime!).inMinutes < cacheValidityMinutes;
|
||||
}
|
||||
|
||||
/// 获取或刷新配置
|
||||
Future<YxOSSConfigModel> _getConfig() async {
|
||||
if (_isCacheValid) {
|
||||
return _cachedProjectConfig!;
|
||||
}
|
||||
|
||||
final config = await _api.getOssConfig();
|
||||
_cachedProjectConfig = config;
|
||||
_cacheTime = DateTime.now();
|
||||
return config;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getEndpoint() async {
|
||||
final config = await _getConfig();
|
||||
return config.endpoint ?? 'oss-cn-hangzhou.aliyuncs.com';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getBucketName() async {
|
||||
final config = await _getConfig();
|
||||
return config.bucketName ?? 'default-bucket';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getDirectory() async {
|
||||
// MyInfo API没有提供目录配置,使用默认值
|
||||
return 'app-uploads';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getDomain() async {
|
||||
// MyInfo API没有提供自定义域名配置
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isValid() async {
|
||||
try {
|
||||
final config = await _getConfig();
|
||||
return config.endpoint != null &&
|
||||
config.bucketName != null &&
|
||||
config.endpoint!.isNotEmpty &&
|
||||
config.bucketName!.isNotEmpty;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> refresh() async {
|
||||
clearCache();
|
||||
await _getConfig(); // 强制重新获取配置
|
||||
}
|
||||
|
||||
/// 清除缓存(当需要强制刷新配置时)
|
||||
void clearCache() {
|
||||
_cachedProjectConfig = null;
|
||||
_cacheTime = null;
|
||||
}
|
||||
|
||||
/// 获取文件大小限制
|
||||
Future<int> getFileSizeLimit() async {
|
||||
final config = await _getConfig();
|
||||
return config.size;
|
||||
}
|
||||
}
|
||||
|
||||
/// 基于MyInfo API的动态认证提供者
|
||||
class MyInfoAuthProvider implements AuthProvider {
|
||||
final MyInfoConfigProvider _configProvider;
|
||||
|
||||
MyInfoAuthProvider(this._configProvider);
|
||||
|
||||
@override
|
||||
Future<String> getAccessKeyId() async {
|
||||
final config = await _configProvider._getConfig();
|
||||
return config.accessKeyId ?? '';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getAccessKeySecret() async {
|
||||
final config = await _configProvider._getConfig();
|
||||
return config.accessKeySecret ?? '';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getSecurityToken() async {
|
||||
// MyInfo API目前不支持STS Token
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isValid() async {
|
||||
try {
|
||||
final accessKeyId = await getAccessKeyId();
|
||||
final accessKeySecret = await getAccessKeySecret();
|
||||
return accessKeyId.isNotEmpty && accessKeySecret.isNotEmpty;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> refresh() async {
|
||||
// 清除配置提供者的缓存,强制重新获取认证信息
|
||||
_configProvider.clearCache();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DateTime?> getExpiration() async {
|
||||
// MyInfo API的认证信息没有明确的过期时间,返回null表示永不过期
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目集成的OSS管理器
|
||||
///
|
||||
/// 这是一个高级封装,专门用于集成到现有项目中
|
||||
class ProjectOSSManager {
|
||||
late final YxOSSClient _client;
|
||||
final YxOssServerInterface _api;
|
||||
late final MyInfoConfigProvider _configProvider;
|
||||
late final MyInfoAuthProvider _authProvider;
|
||||
bool _isInitialized = false;
|
||||
|
||||
ProjectOSSManager(this._api);
|
||||
|
||||
/// 初始化管理器
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
_configProvider = MyInfoConfigProvider(_api);
|
||||
_authProvider = MyInfoAuthProvider(_configProvider);
|
||||
|
||||
final config = YxOSSConfig(
|
||||
authProvider: _authProvider,
|
||||
configProvider: _configProvider,
|
||||
);
|
||||
|
||||
_client = YxOSSClient(config);
|
||||
await _client.initialize();
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
/// 确保已初始化
|
||||
void _ensureInitialized() {
|
||||
if (!_isInitialized) {
|
||||
throw StateError(
|
||||
'ProjectOSSManager not initialized. Call initialize() first.');
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传文件字节数组
|
||||
///
|
||||
/// [fileBytes] 文件字节数组
|
||||
/// [fileName] 文件名
|
||||
/// [onProgress] 进度回调
|
||||
/// [enableResume] 是否启用断点续传(默认false)
|
||||
/// [type] 文件模块类型(1:普通文件上传,2:资料收集)
|
||||
Future<String> uploadBytes(
|
||||
Uint8List fileBytes,
|
||||
String fileName, {
|
||||
void Function(int sent, int total)? onProgress,
|
||||
bool enableResume = false,
|
||||
int type = 1,
|
||||
}) async {
|
||||
_ensureInitialized();
|
||||
|
||||
final objectKey = YxOSSFileUtils.generateUniqueFileName(
|
||||
originalFileName: fileName,
|
||||
prefix: 'upload_',
|
||||
);
|
||||
|
||||
final options = UploadOptions(
|
||||
enableResume: enableResume,
|
||||
callbacks:
|
||||
onProgress != null ? UploadCallbacks(onProgress: onProgress) : null,
|
||||
);
|
||||
|
||||
final result =
|
||||
await _client.uploadBytes(fileBytes, objectKey, options: options);
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/// 上传文件路径
|
||||
///
|
||||
/// [filePath] 文件路径
|
||||
/// [onProgress] 进度回调
|
||||
/// [enableResume] 是否启用断点续传(忽略,预签名URL不支持断点续传)
|
||||
/// [type] 文件模块类型
|
||||
///
|
||||
/// 注意:统一使用预签名URL上传方式,不支持断点续传
|
||||
Future<String> uploadFile(
|
||||
String filePath, {
|
||||
void Function(int sent, int total)? onProgress,
|
||||
bool enableResume = true, // 保持兼容性,但实际不使用
|
||||
int type = 1,
|
||||
}) async {
|
||||
_ensureInitialized();
|
||||
|
||||
final file = File(filePath);
|
||||
if (!file.existsSync()) {
|
||||
throw Exception('文件不存在: $filePath');
|
||||
}
|
||||
|
||||
final fileSize = file.lengthSync();
|
||||
final fileName = filePath.split('/').last;
|
||||
final extension = YxOSSFileUtils.getFileExtension(fileName);
|
||||
|
||||
// 统一使用预签名URL上传
|
||||
final logger = OssLogger(_client.config.logConfig);
|
||||
logger.info('使用预签名URL上传');
|
||||
logger.debug('文件大小: ${YxOSSFileUtils.formatFileSize(fileSize)}');
|
||||
|
||||
try {
|
||||
// 获取预签名URL
|
||||
final sign = await _api.getOssSign(objectName: extension, type: type);
|
||||
|
||||
// 确定预签名上传URL和最终文件路径
|
||||
String uploadUrl;
|
||||
String finalPath;
|
||||
|
||||
if (sign.uploadUrl != null && sign.uploadUrl!.isNotEmpty) {
|
||||
// 标准格式:uploadUrl和filePath分开
|
||||
uploadUrl = sign.uploadUrl!;
|
||||
finalPath = sign.filePath ?? '';
|
||||
} else if (sign.filePath != null && sign.filePath!.isNotEmpty) {
|
||||
// 兼容格式:filePath包含预签名URL
|
||||
uploadUrl = sign.filePath!;
|
||||
finalPath = sign.filePath!.split('?')[0]; // 移除查询参数得到最终路径
|
||||
} else {
|
||||
throw Exception('获取预签名URL失败:uploadUrl和filePath都为空');
|
||||
}
|
||||
|
||||
// 调试日志
|
||||
logger.debug('预签名信息');
|
||||
logger.debug('uploadUrl: $uploadUrl');
|
||||
logger.debug('finalPath: $finalPath');
|
||||
|
||||
// 使用预签名URL上传
|
||||
return await _uploadWithPreSignedUrl(
|
||||
filePath,
|
||||
uploadUrl,
|
||||
finalPath,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
} catch (e) {
|
||||
// 预签名失败时抛出异常,不再回退到直接上传
|
||||
throw Exception('预签名URL上传失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用预签名URL上传(仅支持单次PUT,不支持分片和断点续传)
|
||||
///
|
||||
/// 预签名URL的限制:
|
||||
/// 1. 只能进行单次PUT操作,无法分片上传
|
||||
/// 2. 不支持断点续传功能
|
||||
/// 3. 不能添加额外的HTTP headers
|
||||
/// 4. 适用于小文件(<1MB)的快速上传
|
||||
Future<String> _uploadWithPreSignedUrl(
|
||||
String filePath,
|
||||
String presignedUrl,
|
||||
String finalPath, {
|
||||
void Function(int sent, int total)? onProgress,
|
||||
}) async {
|
||||
final file = File(filePath);
|
||||
if (!file.existsSync()) {
|
||||
throw Exception('文件不存在: $filePath');
|
||||
}
|
||||
|
||||
final dio = Dio();
|
||||
|
||||
final response = await dio.put(
|
||||
presignedUrl,
|
||||
data: file.openRead(),
|
||||
options: Options(
|
||||
headers: null, // 关键:预签名URL不能添加额外headers
|
||||
validateStatus: (status) => status != null && status < 400,
|
||||
),
|
||||
onSendProgress: onProgress,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return finalPath.isNotEmpty ? finalPath : presignedUrl.split('?')[0];
|
||||
} else {
|
||||
throw Exception('预签名上传失败: HTTP ${response.statusCode}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用直接方法上传(Access Key签名,支持分片和断点续传)
|
||||
///
|
||||
/// 注意:此方法已不再被标准上传流程使用,仅保留用于测试目的
|
||||
///
|
||||
/// 直接上传的优势:
|
||||
/// 1. 支持分片上传(InitiateMultipartUpload -> UploadPart -> CompleteMultipartUpload)
|
||||
/// 2. 支持断点续传功能,可以从中断点继续上传
|
||||
/// 3. 支持大文件上传(无大小限制)
|
||||
/// 4. 支持并发上传多个分片,提高效率
|
||||
/// 5. 自动重试机制,提高上传成功率
|
||||
// ignore: unused_element
|
||||
Future<String> _uploadWithDirectMethod(
|
||||
String filePath, {
|
||||
void Function(int sent, int total)? onProgress,
|
||||
bool enableResume = false,
|
||||
}) async {
|
||||
final dataSource = UploadDataSource.fromFilePath(filePath);
|
||||
final objectKey = YxOSSFileUtils.generateUniqueFileName(
|
||||
originalFileName: dataSource.fileName,
|
||||
prefix: 'upload_',
|
||||
);
|
||||
|
||||
final options = UploadOptions(
|
||||
enableResume: enableResume,
|
||||
callbacks:
|
||||
onProgress != null ? UploadCallbacks(onProgress: onProgress) : null,
|
||||
);
|
||||
|
||||
final result =
|
||||
await _client.uploadDataSource(dataSource, objectKey, options: options);
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/// 删除OSS文件
|
||||
///
|
||||
/// [fileUrl] 完整的文件URL
|
||||
Future<bool> deleteFile(String fileUrl) async {
|
||||
_ensureInitialized();
|
||||
|
||||
try {
|
||||
return await _api.deleteOSSFile(filePath: fileUrl);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有可恢复的上传任务
|
||||
Future<List<dynamic>> getResumableTasks() async {
|
||||
_ensureInitialized();
|
||||
return await _client.getResumableTasks();
|
||||
}
|
||||
|
||||
/// 恢复指定的上传任务
|
||||
Future<String> resumeUpload(String taskId) async {
|
||||
_ensureInitialized();
|
||||
final result = await _client.resumeUpload(taskId);
|
||||
return result.url;
|
||||
}
|
||||
|
||||
/// 取消上传任务
|
||||
void cancelUpload(String taskId) {
|
||||
_ensureInitialized();
|
||||
_client.cancelUpload(taskId);
|
||||
}
|
||||
|
||||
/// 清理过期的断点续传数据
|
||||
Future<void> cleanupExpiredData({int maxAgeDays = 7}) async {
|
||||
_ensureInitialized();
|
||||
await _client.cleanupExpiredResumeData(maxAgeDays: maxAgeDays);
|
||||
}
|
||||
|
||||
/// 获取底层OSS客户端(用于高级功能和测试)
|
||||
YxOSSClient get client {
|
||||
_ensureInitialized();
|
||||
return _client;
|
||||
}
|
||||
|
||||
/// 刷新配置缓存
|
||||
void refreshConfig() {
|
||||
if (_isInitialized) {
|
||||
_configProvider.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
/// 释放资源
|
||||
void dispose() {
|
||||
if (_isInitialized) {
|
||||
_client.dispose();
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,984 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../interfaces/auth_provider.dart';
|
||||
import '../interfaces/resume_storage.dart';
|
||||
import '../models/oss_error.dart' as error_model;
|
||||
import '../models/upload_options.dart';
|
||||
import '../models/upload_result.dart' as result_model;
|
||||
import '../utils/file_utils.dart';
|
||||
import '../utils/http_utils.dart';
|
||||
import '../utils/local_resume_storage.dart';
|
||||
import '../utils/oss_logger.dart';
|
||||
import 'yx_oss_config.dart';
|
||||
import 'yx_oss_signer.dart';
|
||||
|
||||
/// YX OSS客户端
|
||||
///
|
||||
/// 提供完整的OSS文件上传、下载和管理功能
|
||||
class YxOSSClient {
|
||||
final YxOSSConfig _config;
|
||||
late final Dio _dio;
|
||||
final Map<String, CancelToken> _uploadTasks = {};
|
||||
late final ResumeStorage _resumeStorage;
|
||||
late final OssLogger _logger;
|
||||
|
||||
/// Expose read-only config for integration helpers
|
||||
YxOSSConfig get config => _config;
|
||||
|
||||
YxOSSClient(this._config, {ResumeStorage? resumeStorage}) {
|
||||
_dio = HttpUtils.createDio(
|
||||
timeoutConfig: _config.timeoutConfig,
|
||||
logConfig: _config.logConfig,
|
||||
);
|
||||
_resumeStorage = resumeStorage ?? LocalResumeStorage();
|
||||
_logger = OssLogger(_config.logConfig);
|
||||
}
|
||||
|
||||
/// 初始化客户端
|
||||
/// 验证配置和认证信息
|
||||
Future<void> initialize() async {
|
||||
if (!await _config.validate()) {
|
||||
throw error_model.OSSError.configuration('Invalid OSS configuration');
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传文件(字节数组)
|
||||
///
|
||||
/// [data] 文件数据
|
||||
/// [objectKey] 对象键(文件在OSS中的路径)
|
||||
/// [options] 上传选项
|
||||
Future<result_model.UploadResult> uploadBytes(
|
||||
Uint8List data,
|
||||
String objectKey, {
|
||||
UploadOptions? options,
|
||||
}) async {
|
||||
final dataSource = BytesDataSource(
|
||||
data,
|
||||
YxOSSFileUtils.getFileNameWithoutExtension(objectKey),
|
||||
mimeType: options?.contentType ?? YxOSSFileUtils.guessMimeType(objectKey),
|
||||
);
|
||||
|
||||
return _uploadDataSource(
|
||||
dataSource, objectKey, options ?? const UploadOptions());
|
||||
}
|
||||
|
||||
/// 上传文件(数据源)
|
||||
///
|
||||
/// [dataSource] 数据源
|
||||
/// [objectKey] 对象键
|
||||
/// [options] 上传选项
|
||||
Future<result_model.UploadResult> uploadDataSource(
|
||||
UploadDataSource dataSource,
|
||||
String objectKey, {
|
||||
UploadOptions? options,
|
||||
}) async {
|
||||
return _uploadDataSource(
|
||||
dataSource, objectKey, options ?? const UploadOptions());
|
||||
}
|
||||
|
||||
/// 内部上传实现
|
||||
Future<result_model.UploadResult> _uploadDataSource(
|
||||
UploadDataSource dataSource,
|
||||
String objectKey,
|
||||
UploadOptions options,
|
||||
) async {
|
||||
// 验证选项
|
||||
if (!options.validate()) {
|
||||
throw error_model.OSSError.configuration('Invalid upload options');
|
||||
}
|
||||
|
||||
// 验证对象键
|
||||
if (!YxOSSFileUtils.isValidObjectKey(objectKey)) {
|
||||
throw error_model.OSSError.configuration(
|
||||
'Invalid object key: $objectKey');
|
||||
}
|
||||
|
||||
// 开始上传回调
|
||||
options.callbacks?.onStart?.call();
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
try {
|
||||
result_model.UploadResult result;
|
||||
|
||||
// 根据文件大小选择上传方式
|
||||
if (options.enableMultipart &&
|
||||
dataSource.size > options.multipartThreshold) {
|
||||
result = await _uploadMultipart(dataSource, objectKey, options);
|
||||
} else {
|
||||
result = await _uploadSingle(dataSource, objectKey, options);
|
||||
}
|
||||
|
||||
stopwatch.stop();
|
||||
|
||||
// 更新上传耗时
|
||||
final finalResult = result_model.UploadResult.fromResponse(
|
||||
url: result.url,
|
||||
objectKey: result.objectKey,
|
||||
fileSize: result.fileSize,
|
||||
uploadDuration: stopwatch.elapsedMilliseconds,
|
||||
headers: {'etag': result.etag},
|
||||
mimeType: result.mimeType,
|
||||
md5Hash: result.md5Hash,
|
||||
metadata: result.metadata,
|
||||
);
|
||||
|
||||
// 成功回调
|
||||
options.callbacks?.onSuccess?.call(finalResult);
|
||||
|
||||
return finalResult;
|
||||
} catch (e) {
|
||||
stopwatch.stop();
|
||||
|
||||
final error = e is error_model.OSSError
|
||||
? e
|
||||
: error_model.OSSError.unknown('Upload failed: $e',
|
||||
originalException: e);
|
||||
|
||||
// 错误回调
|
||||
options.callbacks?.onError?.call(error);
|
||||
|
||||
rethrow;
|
||||
} finally {
|
||||
// 完成回调
|
||||
options.callbacks?.onComplete?.call();
|
||||
}
|
||||
}
|
||||
|
||||
/// 单文件上传
|
||||
Future<result_model.UploadResult> _uploadSingle(
|
||||
UploadDataSource dataSource,
|
||||
String objectKey,
|
||||
UploadOptions options,
|
||||
) async {
|
||||
final taskId = _generateTaskId(objectKey, dataSource.size);
|
||||
final cancelToken = CancelToken();
|
||||
_uploadTasks[taskId] = cancelToken;
|
||||
|
||||
try {
|
||||
// 获取配置和认证信息
|
||||
final endpoint = await _config.configProvider.getEndpoint();
|
||||
final bucket = await _config.configProvider.getBucketName();
|
||||
final directory = await _config.configProvider.getDirectory();
|
||||
final domain = await _config.configProvider.getDomain();
|
||||
|
||||
// 构建完整的对象键
|
||||
final fullObjectKey =
|
||||
directory.isEmpty ? objectKey : '$directory/$objectKey';
|
||||
|
||||
// 检查是否使用预签名URL模式
|
||||
if (_config.authProvider is PreSignedAuthProvider) {
|
||||
return await _uploadWithPreSignedUrl(
|
||||
dataSource,
|
||||
fullObjectKey,
|
||||
options,
|
||||
cancelToken,
|
||||
);
|
||||
}
|
||||
|
||||
// 标准签名模式上传
|
||||
return await _uploadWithSignature(
|
||||
dataSource,
|
||||
fullObjectKey,
|
||||
endpoint,
|
||||
bucket,
|
||||
domain,
|
||||
options,
|
||||
cancelToken,
|
||||
);
|
||||
} finally {
|
||||
_uploadTasks.remove(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用预签名URL上传
|
||||
Future<result_model.UploadResult> _uploadWithPreSignedUrl(
|
||||
UploadDataSource dataSource,
|
||||
String objectKey,
|
||||
UploadOptions options,
|
||||
CancelToken cancelToken,
|
||||
) async {
|
||||
final authProvider = _config.authProvider as PreSignedAuthProvider;
|
||||
final preSignedUrl =
|
||||
await authProvider.getPreSignedUrl(dataSource.fileName);
|
||||
|
||||
final data = await dataSource.readAsBytes();
|
||||
|
||||
final response = await _dio.put(
|
||||
preSignedUrl,
|
||||
data: Stream.fromIterable([data]),
|
||||
cancelToken: cancelToken,
|
||||
options: Options(
|
||||
headers: {
|
||||
'Content-Type': dataSource.mimeType ?? 'application/octet-stream',
|
||||
'Content-Length': data.length.toString(),
|
||||
},
|
||||
),
|
||||
onSendProgress: (sent, total) {
|
||||
options.callbacks?.onProgress?.call(sent, total);
|
||||
},
|
||||
);
|
||||
|
||||
if (!HttpUtils.isSuccessStatusCode(response.statusCode)) {
|
||||
throw error_model.OSSError.fromResponse(
|
||||
response.statusCode!,
|
||||
response.data?.toString(),
|
||||
requestId: HttpUtils.extractRequestId(response),
|
||||
);
|
||||
}
|
||||
|
||||
// 从预签名URL中提取实际的OSS URL
|
||||
final uri = Uri.parse(preSignedUrl);
|
||||
final ossUrl = '${uri.scheme}://${uri.host}${uri.path}';
|
||||
|
||||
return result_model.UploadResult.fromResponse(
|
||||
url: ossUrl,
|
||||
objectKey: objectKey,
|
||||
fileSize: data.length,
|
||||
uploadDuration: 0, // 会在外层更新
|
||||
headers: response.headers.map,
|
||||
mimeType: dataSource.mimeType,
|
||||
);
|
||||
}
|
||||
|
||||
/// 使用标准签名上传
|
||||
Future<result_model.UploadResult> _uploadWithSignature(
|
||||
UploadDataSource dataSource,
|
||||
String objectKey,
|
||||
String endpoint,
|
||||
String bucket,
|
||||
String? domain,
|
||||
UploadOptions options,
|
||||
CancelToken cancelToken,
|
||||
) async {
|
||||
final data = await dataSource.readAsBytes();
|
||||
final contentMD5 = YxOSSSigner.calculateMD5(data);
|
||||
final currentTime = DateTime.now();
|
||||
final httpDate = YxOSSSigner.formatHttpDate(currentTime);
|
||||
|
||||
// 构建请求头
|
||||
final headers = <String, String>{
|
||||
'Content-Type': dataSource.mimeType ?? 'application/octet-stream',
|
||||
'Content-Length': data.length.toString(),
|
||||
'Content-MD5': contentMD5,
|
||||
'Date': httpDate,
|
||||
'Host': '$bucket.$endpoint',
|
||||
};
|
||||
|
||||
// 添加自定义头部
|
||||
if (options.headers != null) {
|
||||
headers.addAll(options.headers!);
|
||||
}
|
||||
|
||||
// 添加OSS特定头部
|
||||
if (options.acl != null) {
|
||||
headers['x-oss-object-acl'] = options.acl!.value;
|
||||
}
|
||||
if (options.storageClass != null) {
|
||||
headers['x-oss-storage-class'] = options.storageClass!.value;
|
||||
}
|
||||
if (options.metadata != null) {
|
||||
for (final entry in options.metadata!.entries) {
|
||||
headers['x-oss-meta-${entry.key}'] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取认证信息
|
||||
final accessKeyId = await _config.authProvider.getAccessKeyId();
|
||||
final accessKeySecret = await _config.authProvider.getAccessKeySecret();
|
||||
final securityToken = await _config.authProvider.getSecurityToken();
|
||||
|
||||
// 添加安全令牌
|
||||
if (securityToken != null) {
|
||||
headers['x-oss-security-token'] = securityToken;
|
||||
}
|
||||
|
||||
// 生成签名
|
||||
final signature = YxOSSSigner.generateSignature(
|
||||
method: 'PUT',
|
||||
objectKey: objectKey,
|
||||
bucket: bucket,
|
||||
accessKeyId: accessKeyId,
|
||||
accessKeySecret: accessKeySecret,
|
||||
headers: headers,
|
||||
securityToken: securityToken,
|
||||
);
|
||||
|
||||
headers['Authorization'] = signature;
|
||||
|
||||
// 构建上传URL
|
||||
final uploadUrl = 'https://$bucket.$endpoint/$objectKey';
|
||||
|
||||
// 执行上传
|
||||
final response = await _dio.put(
|
||||
uploadUrl,
|
||||
data: Stream.fromIterable([data]),
|
||||
cancelToken: cancelToken,
|
||||
options: Options(headers: headers),
|
||||
onSendProgress: (sent, total) {
|
||||
options.callbacks?.onProgress?.call(sent, total);
|
||||
},
|
||||
);
|
||||
|
||||
if (!HttpUtils.isSuccessStatusCode(response.statusCode)) {
|
||||
throw error_model.OSSError.fromResponse(
|
||||
response.statusCode!,
|
||||
response.data?.toString(),
|
||||
requestId: HttpUtils.extractRequestId(response),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建访问URL
|
||||
final accessUrl = domain != null ? '$domain/$objectKey' : uploadUrl;
|
||||
|
||||
return result_model.UploadResult.fromResponse(
|
||||
url: accessUrl,
|
||||
objectKey: objectKey,
|
||||
fileSize: data.length,
|
||||
uploadDuration: 0, // 会在外层更新
|
||||
headers: response.headers.map,
|
||||
mimeType: dataSource.mimeType,
|
||||
md5Hash: contentMD5,
|
||||
);
|
||||
}
|
||||
|
||||
/// 分片上传
|
||||
Future<result_model.UploadResult> _uploadMultipart(
|
||||
UploadDataSource dataSource,
|
||||
String objectKey,
|
||||
UploadOptions options,
|
||||
) async {
|
||||
final taskId = _generateTaskId(objectKey, dataSource.size);
|
||||
|
||||
// 1. 尝试恢复断点续传
|
||||
result_model.ResumeUploadInfo? resumeInfo;
|
||||
if (options.enableResume) {
|
||||
resumeInfo = await _loadResumeInfo(taskId);
|
||||
}
|
||||
|
||||
String uploadId;
|
||||
List<result_model.PartInfo> completedParts = [];
|
||||
|
||||
if (resumeInfo != null) {
|
||||
// 使用现有的上传ID和已完成的分片
|
||||
uploadId = resumeInfo.uploadId;
|
||||
completedParts = resumeInfo.completedParts;
|
||||
|
||||
// 通知续传开始
|
||||
options.onStart?.call(taskId, resumeInfo.uploadedSize, dataSource.size);
|
||||
} else {
|
||||
// 初始化新的分片上传
|
||||
uploadId = await _initiateMultipartUpload(dataSource, objectKey, options);
|
||||
|
||||
// 保存初始的断点续传信息
|
||||
if (options.enableResume) {
|
||||
resumeInfo = result_model.ResumeUploadInfo(
|
||||
taskId: taskId,
|
||||
filePath: dataSource.filePath ?? '',
|
||||
objectKey: objectKey,
|
||||
uploadId: uploadId,
|
||||
totalSize: dataSource.size,
|
||||
partSize: options.partSize,
|
||||
completedParts: [],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
uploadOptions: options.toJson(),
|
||||
);
|
||||
await _saveResumeInfo(taskId, resumeInfo);
|
||||
}
|
||||
|
||||
// 通知上传开始
|
||||
options.onStart?.call(taskId, 0, dataSource.size);
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. 计算分片
|
||||
final partRanges =
|
||||
YxOSSFileUtils.calculatePartRanges(dataSource.size, options.partSize);
|
||||
|
||||
// 3. 过滤出需要上传的分片
|
||||
final pendingRanges = partRanges.where((range) {
|
||||
return !completedParts
|
||||
.any((part) => part.partNumber == range.partNumber);
|
||||
}).toList();
|
||||
|
||||
// 4. 并发上传剩余分片
|
||||
if (pendingRanges.isNotEmpty) {
|
||||
final newParts = await _uploadParts(
|
||||
dataSource,
|
||||
objectKey,
|
||||
uploadId,
|
||||
pendingRanges,
|
||||
options,
|
||||
existingParts: completedParts,
|
||||
taskId: taskId,
|
||||
resumeInfo: resumeInfo,
|
||||
);
|
||||
completedParts.addAll(newParts);
|
||||
}
|
||||
|
||||
// 5. 完成分片上传
|
||||
final result = await _completeMultipartUpload(
|
||||
dataSource, objectKey, uploadId, completedParts, options);
|
||||
|
||||
// 6. 清理断点续传信息
|
||||
if (options.enableResume) {
|
||||
await _deleteResumeInfo(taskId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
// 上传失败时,保留断点续传信息但中止上传
|
||||
if (!options.enableResume) {
|
||||
await _abortMultipartUpload(objectKey, uploadId);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化分片上传
|
||||
Future<String> _initiateMultipartUpload(
|
||||
UploadDataSource dataSource,
|
||||
String objectKey,
|
||||
UploadOptions options,
|
||||
) async {
|
||||
final endpoint = await _config.configProvider.getEndpoint();
|
||||
final bucket = await _config.configProvider.getBucketName();
|
||||
|
||||
final url = HttpUtils.buildMultipartUploadUrl(
|
||||
endpoint: endpoint,
|
||||
bucket: bucket,
|
||||
objectKey: objectKey,
|
||||
);
|
||||
|
||||
final headers = <String, String>{
|
||||
'Content-Type': dataSource.mimeType ?? 'application/octet-stream',
|
||||
'Date': YxOSSSigner.formatHttpDate(DateTime.now()),
|
||||
'Host': '$bucket.$endpoint',
|
||||
};
|
||||
|
||||
// 添加OSS特定头部
|
||||
if (options.acl != null) {
|
||||
headers['x-oss-object-acl'] = options.acl!.value;
|
||||
}
|
||||
if (options.storageClass != null) {
|
||||
headers['x-oss-storage-class'] = options.storageClass!.value;
|
||||
}
|
||||
|
||||
// 获取认证信息并生成签名
|
||||
final accessKeyId = await _config.authProvider.getAccessKeyId();
|
||||
final accessKeySecret = await _config.authProvider.getAccessKeySecret();
|
||||
final securityToken = await _config.authProvider.getSecurityToken();
|
||||
|
||||
if (securityToken != null) {
|
||||
headers['x-oss-security-token'] = securityToken;
|
||||
}
|
||||
|
||||
final signature = YxOSSSigner.generateSignature(
|
||||
method: 'POST',
|
||||
objectKey: '$objectKey?uploads',
|
||||
bucket: bucket,
|
||||
accessKeyId: accessKeyId,
|
||||
accessKeySecret: accessKeySecret,
|
||||
headers: headers,
|
||||
securityToken: securityToken,
|
||||
);
|
||||
|
||||
headers['Authorization'] = signature;
|
||||
|
||||
final response = await _dio.post(
|
||||
url,
|
||||
options: Options(
|
||||
headers: headers,
|
||||
responseType: ResponseType.plain,
|
||||
),
|
||||
);
|
||||
|
||||
if (!HttpUtils.isSuccessStatusCode(response.statusCode)) {
|
||||
throw error_model.OSSError.fromResponse(
|
||||
response.statusCode!,
|
||||
response.data?.toString(),
|
||||
requestId: HttpUtils.extractRequestId(response),
|
||||
);
|
||||
}
|
||||
|
||||
// 解析响应中的uploadId
|
||||
final responseBody = response.data as String;
|
||||
final uploadIdMatch =
|
||||
RegExp(r'<UploadId>([^<]+)</UploadId>').firstMatch(responseBody);
|
||||
|
||||
if (uploadIdMatch == null) {
|
||||
throw error_model.OSSError.server(
|
||||
'Failed to extract upload ID from response');
|
||||
}
|
||||
|
||||
return uploadIdMatch.group(1)!;
|
||||
}
|
||||
|
||||
/// 上传分片
|
||||
Future<List<result_model.PartInfo>> _uploadParts(
|
||||
UploadDataSource dataSource,
|
||||
String objectKey,
|
||||
String uploadId,
|
||||
List<PartRange> partRanges,
|
||||
UploadOptions options, {
|
||||
List<result_model.PartInfo> existingParts = const [],
|
||||
String? taskId,
|
||||
result_model.ResumeUploadInfo? resumeInfo,
|
||||
}) async {
|
||||
final parts = <result_model.PartInfo>[];
|
||||
final semaphore = Semaphore(options.concurrency);
|
||||
final futures = <Future<result_model.PartInfo>>[];
|
||||
|
||||
// 计算已上传的总大小(包括现有分片)
|
||||
int totalSent = existingParts.fold<int>(0, (sum, part) => sum + part.size);
|
||||
|
||||
for (final partRange in partRanges) {
|
||||
final future = semaphore.acquire().then((_) async {
|
||||
try {
|
||||
final partInfo = await _uploadPart(
|
||||
dataSource,
|
||||
objectKey,
|
||||
uploadId,
|
||||
partRange,
|
||||
options,
|
||||
(sent, total) {
|
||||
final currentTotalSent = totalSent + sent;
|
||||
options.callbacks?.onProgress
|
||||
?.call(currentTotalSent, dataSource.size);
|
||||
},
|
||||
);
|
||||
|
||||
// 如果启用断点续传,保存分片进度
|
||||
if (options.enableResume && taskId != null && resumeInfo != null) {
|
||||
final updatedResumeInfo = resumeInfo.addCompletedPart(partInfo);
|
||||
await _saveResumeInfo(taskId, updatedResumeInfo);
|
||||
}
|
||||
|
||||
totalSent += partInfo.size;
|
||||
return partInfo;
|
||||
} finally {
|
||||
semaphore.release();
|
||||
}
|
||||
});
|
||||
|
||||
futures.add(future);
|
||||
}
|
||||
|
||||
final results = await Future.wait(futures);
|
||||
parts.addAll(results);
|
||||
|
||||
// 按分片编号排序
|
||||
parts.sort((a, b) => a.partNumber.compareTo(b.partNumber));
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/// 上传单个分片
|
||||
Future<result_model.PartInfo> _uploadPart(
|
||||
UploadDataSource dataSource,
|
||||
String objectKey,
|
||||
String uploadId,
|
||||
PartRange partRange,
|
||||
UploadOptions options,
|
||||
void Function(int sent, int total)? onProgress,
|
||||
) async {
|
||||
final stopwatch = Stopwatch()..start();
|
||||
|
||||
final endpoint = await _config.configProvider.getEndpoint();
|
||||
final bucket = await _config.configProvider.getBucketName();
|
||||
|
||||
final url = HttpUtils.buildMultipartUploadUrl(
|
||||
endpoint: endpoint,
|
||||
bucket: bucket,
|
||||
objectKey: objectKey,
|
||||
uploadId: uploadId,
|
||||
partNumber: partRange.partNumber,
|
||||
);
|
||||
|
||||
// 读取分片数据
|
||||
final partData = Uint8List(partRange.size);
|
||||
await for (final chunk
|
||||
in dataSource.openRead(partRange.start, partRange.end + 1)) {
|
||||
partData.setAll(0, chunk);
|
||||
break; // 假设一次读取完整数据
|
||||
}
|
||||
|
||||
final headers = <String, String>{
|
||||
'Content-Length': partRange.size.toString(),
|
||||
'Date': YxOSSSigner.formatHttpDate(DateTime.now()),
|
||||
'Host': '$bucket.$endpoint',
|
||||
};
|
||||
|
||||
// 获取认证信息并生成签名
|
||||
final accessKeyId = await _config.authProvider.getAccessKeyId();
|
||||
final accessKeySecret = await _config.authProvider.getAccessKeySecret();
|
||||
final securityToken = await _config.authProvider.getSecurityToken();
|
||||
|
||||
if (securityToken != null) {
|
||||
headers['x-oss-security-token'] = securityToken;
|
||||
}
|
||||
|
||||
final signature = YxOSSSigner.generateSignature(
|
||||
method: 'PUT',
|
||||
objectKey:
|
||||
'$objectKey?partNumber=${partRange.partNumber}&uploadId=$uploadId',
|
||||
bucket: bucket,
|
||||
accessKeyId: accessKeyId,
|
||||
accessKeySecret: accessKeySecret,
|
||||
headers: headers,
|
||||
securityToken: securityToken,
|
||||
);
|
||||
|
||||
headers['Authorization'] = signature;
|
||||
|
||||
final response = await _dio.put(
|
||||
url,
|
||||
data: Stream.fromIterable([partData]),
|
||||
options: Options(headers: headers),
|
||||
onSendProgress: onProgress,
|
||||
);
|
||||
|
||||
if (!HttpUtils.isSuccessStatusCode(response.statusCode)) {
|
||||
throw error_model.OSSError.fromResponse(
|
||||
response.statusCode!,
|
||||
response.data?.toString(),
|
||||
requestId: HttpUtils.extractRequestId(response),
|
||||
);
|
||||
}
|
||||
|
||||
stopwatch.stop();
|
||||
|
||||
final etag = HttpUtils.extractETag(response);
|
||||
if (etag == null) {
|
||||
throw error_model.OSSError.server(
|
||||
'Failed to extract ETag from part upload response');
|
||||
}
|
||||
|
||||
return result_model.PartInfo(
|
||||
partNumber: partRange.partNumber,
|
||||
etag: etag,
|
||||
size: partRange.size,
|
||||
uploadDuration: stopwatch.elapsedMilliseconds,
|
||||
);
|
||||
}
|
||||
|
||||
/// 完成分片上传
|
||||
Future<result_model.MultipartUploadResult> _completeMultipartUpload(
|
||||
UploadDataSource dataSource,
|
||||
String objectKey,
|
||||
String uploadId,
|
||||
List<result_model.PartInfo> parts,
|
||||
UploadOptions options,
|
||||
) async {
|
||||
final endpoint = await _config.configProvider.getEndpoint();
|
||||
final bucket = await _config.configProvider.getBucketName();
|
||||
final domain = await _config.configProvider.getDomain();
|
||||
|
||||
final url = HttpUtils.buildMultipartUploadUrl(
|
||||
endpoint: endpoint,
|
||||
bucket: bucket,
|
||||
objectKey: objectKey,
|
||||
uploadId: uploadId,
|
||||
);
|
||||
|
||||
// 构建完成请求的XML
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('<CompleteMultipartUpload>');
|
||||
for (final part in parts) {
|
||||
buffer.writeln('<Part>');
|
||||
buffer.writeln('<PartNumber>${part.partNumber}</PartNumber>');
|
||||
buffer.writeln('<ETag>${part.etag}</ETag>');
|
||||
buffer.writeln('</Part>');
|
||||
}
|
||||
buffer.writeln('</CompleteMultipartUpload>');
|
||||
|
||||
final xmlData = buffer.toString();
|
||||
final xmlBytes = Uint8List.fromList(utf8.encode(xmlData));
|
||||
final contentMD5 = YxOSSSigner.calculateMD5(xmlBytes);
|
||||
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/xml',
|
||||
'Content-Length': xmlBytes.length.toString(),
|
||||
'Content-MD5': contentMD5,
|
||||
'Date': YxOSSSigner.formatHttpDate(DateTime.now()),
|
||||
'Host': '$bucket.$endpoint',
|
||||
};
|
||||
|
||||
// 获取认证信息并生成签名
|
||||
final accessKeyId = await _config.authProvider.getAccessKeyId();
|
||||
final accessKeySecret = await _config.authProvider.getAccessKeySecret();
|
||||
final securityToken = await _config.authProvider.getSecurityToken();
|
||||
|
||||
if (securityToken != null) {
|
||||
headers['x-oss-security-token'] = securityToken;
|
||||
}
|
||||
|
||||
final signature = YxOSSSigner.generateSignature(
|
||||
method: 'POST',
|
||||
objectKey: '$objectKey?uploadId=$uploadId',
|
||||
bucket: bucket,
|
||||
accessKeyId: accessKeyId,
|
||||
accessKeySecret: accessKeySecret,
|
||||
headers: headers,
|
||||
securityToken: securityToken,
|
||||
);
|
||||
|
||||
headers['Authorization'] = signature;
|
||||
|
||||
final response = await _dio.post(
|
||||
url,
|
||||
data: Stream.fromIterable([xmlBytes]),
|
||||
options: Options(
|
||||
headers: headers,
|
||||
responseType: ResponseType.plain,
|
||||
),
|
||||
);
|
||||
|
||||
if (!HttpUtils.isSuccessStatusCode(response.statusCode)) {
|
||||
throw error_model.OSSError.fromResponse(
|
||||
response.statusCode!,
|
||||
response.data?.toString(),
|
||||
requestId: HttpUtils.extractRequestId(response),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建访问URL
|
||||
final accessUrl = domain != null
|
||||
? '$domain/$objectKey'
|
||||
: 'https://$bucket.$endpoint/$objectKey';
|
||||
|
||||
final totalUploadDuration =
|
||||
parts.fold<int>(0, (sum, part) => sum + part.uploadDuration);
|
||||
|
||||
return result_model.MultipartUploadResult(
|
||||
url: accessUrl,
|
||||
objectKey: objectKey,
|
||||
fileSize: dataSource.size,
|
||||
uploadDuration: totalUploadDuration,
|
||||
uploadTime: DateTime.now(),
|
||||
partCount: parts.length,
|
||||
uploadId: uploadId,
|
||||
parts: parts,
|
||||
etag: HttpUtils.extractETag(response),
|
||||
mimeType: dataSource.mimeType,
|
||||
);
|
||||
}
|
||||
|
||||
/// 中止分片上传
|
||||
Future<void> _abortMultipartUpload(String objectKey, String uploadId) async {
|
||||
try {
|
||||
final endpoint = await _config.configProvider.getEndpoint();
|
||||
final bucket = await _config.configProvider.getBucketName();
|
||||
|
||||
final url = HttpUtils.buildMultipartUploadUrl(
|
||||
endpoint: endpoint,
|
||||
bucket: bucket,
|
||||
objectKey: objectKey,
|
||||
uploadId: uploadId,
|
||||
);
|
||||
|
||||
final headers = <String, String>{
|
||||
'Date': YxOSSSigner.formatHttpDate(DateTime.now()),
|
||||
'Host': '$bucket.$endpoint',
|
||||
};
|
||||
|
||||
// 获取认证信息并生成签名
|
||||
final accessKeyId = await _config.authProvider.getAccessKeyId();
|
||||
final accessKeySecret = await _config.authProvider.getAccessKeySecret();
|
||||
final securityToken = await _config.authProvider.getSecurityToken();
|
||||
|
||||
if (securityToken != null) {
|
||||
headers['x-oss-security-token'] = securityToken;
|
||||
}
|
||||
|
||||
final signature = YxOSSSigner.generateSignature(
|
||||
method: 'DELETE',
|
||||
objectKey: '$objectKey?uploadId=$uploadId',
|
||||
bucket: bucket,
|
||||
accessKeyId: accessKeyId,
|
||||
accessKeySecret: accessKeySecret,
|
||||
headers: headers,
|
||||
securityToken: securityToken,
|
||||
);
|
||||
|
||||
headers['Authorization'] = signature;
|
||||
|
||||
await _dio.delete(
|
||||
url,
|
||||
options: Options(headers: headers),
|
||||
);
|
||||
} catch (e) {
|
||||
// 中止操作失败不抛出异常,只记录日志
|
||||
if (_config.logConfig.enabled &&
|
||||
_config.logConfig.level <= LogLevel.warning) {
|
||||
_config.logConfig.customHandler?.call(
|
||||
'Failed to abort multipart upload: $e',
|
||||
LogLevel.warning,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消上传任务
|
||||
void cancelUpload(String taskId) {
|
||||
final cancelToken = _uploadTasks[taskId];
|
||||
if (cancelToken != null && !cancelToken.isCancelled) {
|
||||
cancelToken.cancel('Upload cancelled by user');
|
||||
_uploadTasks.remove(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消所有上传任务
|
||||
void cancelAllUploads() {
|
||||
for (final cancelToken in _uploadTasks.values) {
|
||||
if (!cancelToken.isCancelled) {
|
||||
cancelToken.cancel('All uploads cancelled');
|
||||
}
|
||||
}
|
||||
_uploadTasks.clear();
|
||||
}
|
||||
|
||||
/// 释放资源
|
||||
void dispose() {
|
||||
cancelAllUploads();
|
||||
_dio.close();
|
||||
}
|
||||
|
||||
// ==================== 断点续传相关方法 ====================
|
||||
|
||||
/// 生成任务ID
|
||||
String _generateTaskId(String objectKey, int fileSize) {
|
||||
return YxOSSFileUtils.generateUniqueFileName(
|
||||
originalFileName: '$objectKey-$fileSize',
|
||||
preserveExtension: false,
|
||||
prefix: 'task_',
|
||||
);
|
||||
}
|
||||
|
||||
/// 保存断点续传信息
|
||||
Future<void> _saveResumeInfo(
|
||||
String taskId, result_model.ResumeUploadInfo resumeInfo) async {
|
||||
try {
|
||||
await _resumeStorage.saveResumeInfo(taskId, resumeInfo.toJson());
|
||||
} catch (e) {
|
||||
// 保存失败不应该影响上传,记录错误但继续
|
||||
_logger.warn('Failed to save resume info for task $taskId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载断点续传信息
|
||||
Future<result_model.ResumeUploadInfo?> _loadResumeInfo(String taskId) async {
|
||||
try {
|
||||
final data = await _resumeStorage.loadResumeInfo(taskId);
|
||||
if (data != null) {
|
||||
return result_model.ResumeUploadInfo.fromJson(data);
|
||||
}
|
||||
} catch (e) {
|
||||
// 加载失败时删除可能损坏的数据
|
||||
_logger.warn('Failed to load resume info for task $taskId: $e');
|
||||
await _deleteResumeInfo(taskId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 删除断点续传信息
|
||||
Future<void> _deleteResumeInfo(String taskId) async {
|
||||
try {
|
||||
await _resumeStorage.deleteResumeInfo(taskId);
|
||||
} catch (e) {
|
||||
// 删除失败可以忽略
|
||||
_logger.warn('Failed to delete resume info for task $taskId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取所有可恢复的上传任务
|
||||
Future<List<result_model.ResumeUploadInfo>> getResumableTasks() async {
|
||||
try {
|
||||
final taskIds = await _resumeStorage.getAllTaskIds();
|
||||
final tasks = <result_model.ResumeUploadInfo>[];
|
||||
|
||||
for (final taskId in taskIds) {
|
||||
final resumeInfo = await _loadResumeInfo(taskId);
|
||||
if (resumeInfo != null) {
|
||||
tasks.add(resumeInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return tasks;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 恢复指定的上传任务
|
||||
Future<result_model.UploadResult> resumeUpload(String taskId) async {
|
||||
final resumeInfo = await _loadResumeInfo(taskId);
|
||||
if (resumeInfo == null) {
|
||||
throw error_model.OSSError.configuration(
|
||||
'Resume data not found for task: $taskId');
|
||||
}
|
||||
|
||||
// 重建 UploadDataSource(简化实现,实际项目中可能需要更复杂的恢复逻辑)
|
||||
if (resumeInfo.filePath.isEmpty) {
|
||||
throw error_model.OSSError.configuration(
|
||||
'File path not available in resume data');
|
||||
}
|
||||
|
||||
// 从文件路径创建数据源
|
||||
final dataSource = UploadDataSource.fromFilePath(resumeInfo.filePath);
|
||||
|
||||
// 重建 UploadOptions
|
||||
final options = UploadOptions.fromJson(resumeInfo.uploadOptions);
|
||||
|
||||
// 继续分片上传
|
||||
return await _uploadMultipart(dataSource, resumeInfo.objectKey, options);
|
||||
}
|
||||
|
||||
/// 清理所有断点续传数据
|
||||
Future<void> clearAllResumeData() async {
|
||||
await _resumeStorage.clearAll();
|
||||
}
|
||||
|
||||
/// 清理过期的断点续传数据
|
||||
Future<void> cleanupExpiredResumeData({int maxAgeDays = 7}) async {
|
||||
if (_resumeStorage is LocalResumeStorage) {
|
||||
await (_resumeStorage as LocalResumeStorage)
|
||||
.cleanupExpiredTasks(maxAgeDays);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 信号量实现
|
||||
class Semaphore {
|
||||
final int maxCount;
|
||||
int _currentCount;
|
||||
final Queue<Completer<void>> _waitQueue = Queue<Completer<void>>();
|
||||
|
||||
Semaphore(this.maxCount) : _currentCount = maxCount;
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_currentCount > 0) {
|
||||
_currentCount--;
|
||||
return;
|
||||
}
|
||||
|
||||
final completer = Completer<void>();
|
||||
_waitQueue.addLast(completer);
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_waitQueue.isNotEmpty) {
|
||||
final completer = _waitQueue.removeFirst();
|
||||
completer.complete();
|
||||
} else {
|
||||
_currentCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
import '../interfaces/auth_provider.dart';
|
||||
import '../interfaces/config_provider.dart';
|
||||
|
||||
/// YX OSS客户端配置
|
||||
class YxOSSConfig {
|
||||
/// 认证提供者
|
||||
final AuthProvider authProvider;
|
||||
|
||||
/// 配置提供者
|
||||
final ConfigProvider configProvider;
|
||||
|
||||
/// 默认超时配置
|
||||
final TimeoutConfig timeoutConfig;
|
||||
|
||||
/// 重试配置
|
||||
final RetryConfig retryConfig;
|
||||
|
||||
/// 日志配置
|
||||
final LogConfig logConfig;
|
||||
|
||||
const YxOSSConfig({
|
||||
required this.authProvider,
|
||||
required this.configProvider,
|
||||
this.timeoutConfig = const TimeoutConfig(),
|
||||
this.retryConfig = const RetryConfig(),
|
||||
this.logConfig = const LogConfig(),
|
||||
});
|
||||
|
||||
/// 验证配置有效性
|
||||
Future<bool> validate() async {
|
||||
try {
|
||||
// 验证认证提供者
|
||||
if (!await authProvider.isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证配置提供者
|
||||
if (!await configProvider.isValid()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证超时配置
|
||||
if (!timeoutConfig.validate()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证重试配置
|
||||
if (!retryConfig.validate()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 超时配置
|
||||
class TimeoutConfig {
|
||||
/// 连接超时(毫秒)
|
||||
final int connectTimeout;
|
||||
|
||||
/// 发送超时(毫秒)
|
||||
final int sendTimeout;
|
||||
|
||||
/// 接收超时(毫秒)
|
||||
final int receiveTimeout;
|
||||
|
||||
const TimeoutConfig({
|
||||
this.connectTimeout = 60000, // 60秒
|
||||
this.sendTimeout = 300000, // 5分钟
|
||||
this.receiveTimeout = 300000, // 5分钟
|
||||
});
|
||||
|
||||
bool validate() {
|
||||
return connectTimeout > 0 && sendTimeout > 0 && receiveTimeout > 0;
|
||||
}
|
||||
|
||||
TimeoutConfig copyWith({
|
||||
int? connectTimeout,
|
||||
int? sendTimeout,
|
||||
int? receiveTimeout,
|
||||
}) {
|
||||
return TimeoutConfig(
|
||||
connectTimeout: connectTimeout ?? this.connectTimeout,
|
||||
sendTimeout: sendTimeout ?? this.sendTimeout,
|
||||
receiveTimeout: receiveTimeout ?? this.receiveTimeout,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 重试配置
|
||||
class RetryConfig {
|
||||
/// 最大重试次数
|
||||
final int maxRetries;
|
||||
|
||||
/// 初始重试间隔(毫秒)
|
||||
final int initialInterval;
|
||||
|
||||
/// 重试间隔增长因子
|
||||
final double backoffMultiplier;
|
||||
|
||||
/// 最大重试间隔(毫秒)
|
||||
final int maxInterval;
|
||||
|
||||
/// 是否启用指数退避
|
||||
final bool enableExponentialBackoff;
|
||||
|
||||
/// 可重试的错误类型
|
||||
final Set<Type> retryableErrors;
|
||||
|
||||
const RetryConfig({
|
||||
this.maxRetries = 3,
|
||||
this.initialInterval = 1000,
|
||||
this.backoffMultiplier = 2.0,
|
||||
this.maxInterval = 30000,
|
||||
this.enableExponentialBackoff = true,
|
||||
this.retryableErrors = const {},
|
||||
});
|
||||
|
||||
bool validate() {
|
||||
return maxRetries >= 0 &&
|
||||
initialInterval > 0 &&
|
||||
backoffMultiplier > 1.0 &&
|
||||
maxInterval >= initialInterval;
|
||||
}
|
||||
|
||||
/// 计算重试间隔
|
||||
int calculateInterval(int retryAttempt) {
|
||||
if (!enableExponentialBackoff) {
|
||||
return initialInterval;
|
||||
}
|
||||
|
||||
final interval = (initialInterval *
|
||||
(retryAttempt == 0
|
||||
? 1
|
||||
: (1 << (retryAttempt - 1)) * backoffMultiplier))
|
||||
.toInt();
|
||||
|
||||
return interval > maxInterval ? maxInterval : interval;
|
||||
}
|
||||
|
||||
RetryConfig copyWith({
|
||||
int? maxRetries,
|
||||
int? initialInterval,
|
||||
double? backoffMultiplier,
|
||||
int? maxInterval,
|
||||
bool? enableExponentialBackoff,
|
||||
Set<Type>? retryableErrors,
|
||||
}) {
|
||||
return RetryConfig(
|
||||
maxRetries: maxRetries ?? this.maxRetries,
|
||||
initialInterval: initialInterval ?? this.initialInterval,
|
||||
backoffMultiplier: backoffMultiplier ?? this.backoffMultiplier,
|
||||
maxInterval: maxInterval ?? this.maxInterval,
|
||||
enableExponentialBackoff:
|
||||
enableExponentialBackoff ?? this.enableExponentialBackoff,
|
||||
retryableErrors: retryableErrors ?? this.retryableErrors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志配置
|
||||
class LogConfig {
|
||||
/// 是否启用日志
|
||||
final bool enabled;
|
||||
|
||||
/// 日志级别
|
||||
final LogLevel level;
|
||||
|
||||
/// 是否打印请求详情
|
||||
final bool logRequest;
|
||||
|
||||
/// 是否打印响应详情
|
||||
final bool logResponse;
|
||||
|
||||
/// 是否打印错误详情
|
||||
final bool logError;
|
||||
|
||||
/// 自定义日志处理器
|
||||
final void Function(String message, LogLevel level)? customHandler;
|
||||
|
||||
const LogConfig({
|
||||
this.enabled = true,
|
||||
this.level = LogLevel.info,
|
||||
this.logRequest = false,
|
||||
this.logResponse = false,
|
||||
this.logError = true,
|
||||
this.customHandler,
|
||||
});
|
||||
|
||||
LogConfig copyWith({
|
||||
bool? enabled,
|
||||
LogLevel? level,
|
||||
bool? logRequest,
|
||||
bool? logResponse,
|
||||
bool? logError,
|
||||
void Function(String message, LogLevel level)? customHandler,
|
||||
}) {
|
||||
return LogConfig(
|
||||
enabled: enabled ?? this.enabled,
|
||||
level: level ?? this.level,
|
||||
logRequest: logRequest ?? this.logRequest,
|
||||
logResponse: logResponse ?? this.logResponse,
|
||||
logError: logError ?? this.logError,
|
||||
customHandler: customHandler ?? this.customHandler,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 日志级别
|
||||
enum LogLevel {
|
||||
debug(0),
|
||||
info(1),
|
||||
warning(2),
|
||||
error(3);
|
||||
|
||||
const LogLevel(this.value);
|
||||
final int value;
|
||||
|
||||
bool operator >=(LogLevel other) => value >= other.value;
|
||||
bool operator <=(LogLevel other) => value <= other.value;
|
||||
bool operator >(LogLevel other) => value > other.value;
|
||||
bool operator <(LogLevel other) => value < other.value;
|
||||
}
|
||||
|
||||
/// OSS区域配置
|
||||
class RegionConfig {
|
||||
/// 区域名称
|
||||
final String region;
|
||||
|
||||
/// 是否为内网访问
|
||||
final bool isInternal;
|
||||
|
||||
/// 自定义端点
|
||||
final String? customEndpoint;
|
||||
|
||||
const RegionConfig({
|
||||
required this.region,
|
||||
this.isInternal = false,
|
||||
this.customEndpoint,
|
||||
});
|
||||
|
||||
/// 获取端点URL
|
||||
String getEndpoint() {
|
||||
if (customEndpoint != null) {
|
||||
return customEndpoint!;
|
||||
}
|
||||
|
||||
final prefix = isInternal ? 'oss-' : 'oss-';
|
||||
final suffix = isInternal ? '-internal.aliyuncs.com' : '.aliyuncs.com';
|
||||
return '$prefix$region$suffix';
|
||||
}
|
||||
}
|
||||
|
||||
/// 常用区域配置
|
||||
class CommonRegions {
|
||||
static const RegionConfig hangzhou = RegionConfig(region: 'cn-hangzhou');
|
||||
static const RegionConfig beijing = RegionConfig(region: 'cn-beijing');
|
||||
static const RegionConfig shanghai = RegionConfig(region: 'cn-shanghai');
|
||||
static const RegionConfig shenzhen = RegionConfig(region: 'cn-shenzhen');
|
||||
static const RegionConfig qingdao = RegionConfig(region: 'cn-qingdao');
|
||||
static const RegionConfig zhangjiakou =
|
||||
RegionConfig(region: 'cn-zhangjiakou');
|
||||
static const RegionConfig huhehaote = RegionConfig(region: 'cn-huhehaote');
|
||||
static const RegionConfig chengdu = RegionConfig(region: 'cn-chengdu');
|
||||
static const RegionConfig hongkong = RegionConfig(region: 'cn-hongkong');
|
||||
|
||||
// 海外区域
|
||||
static const RegionConfig singapore = RegionConfig(region: 'ap-southeast-1');
|
||||
static const RegionConfig sydney = RegionConfig(region: 'ap-southeast-2');
|
||||
static const RegionConfig kualaLumpur =
|
||||
RegionConfig(region: 'ap-southeast-3');
|
||||
static const RegionConfig jakarta = RegionConfig(region: 'ap-southeast-5');
|
||||
static const RegionConfig mumbai = RegionConfig(region: 'ap-south-1');
|
||||
static const RegionConfig tokyo = RegionConfig(region: 'ap-northeast-1');
|
||||
static const RegionConfig seoul = RegionConfig(region: 'ap-northeast-2');
|
||||
static const RegionConfig virginia = RegionConfig(region: 'us-east-1');
|
||||
static const RegionConfig oregon = RegionConfig(region: 'us-west-1');
|
||||
static const RegionConfig london = RegionConfig(region: 'eu-west-1');
|
||||
static const RegionConfig frankfurt = RegionConfig(region: 'eu-central-1');
|
||||
static const RegionConfig dubai = RegionConfig(region: 'me-east-1');
|
||||
}
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// OSS签名算法实现
|
||||
/// 基于阿里云OSS官方签名算法
|
||||
class YxOSSSigner {
|
||||
/// 生成OSS授权签名
|
||||
///
|
||||
/// [method] HTTP方法
|
||||
/// [objectKey] 对象键
|
||||
/// [bucket] 存储桶名称
|
||||
/// [accessKeyId] 访问密钥ID
|
||||
/// [accessKeySecret] 访问密钥密码
|
||||
/// [headers] 请求头
|
||||
/// [securityToken] 安全令牌(STS模式)
|
||||
static String generateSignature({
|
||||
required String method,
|
||||
required String objectKey,
|
||||
required String bucket,
|
||||
required String accessKeyId,
|
||||
required String accessKeySecret,
|
||||
required Map<String, String> headers,
|
||||
String? securityToken,
|
||||
}) {
|
||||
// 构建规范化的OSS头部
|
||||
final canonicalizedOSSHeaders =
|
||||
_buildCanonicalizedOSSHeaders(headers, securityToken);
|
||||
|
||||
// 构建规范化的资源
|
||||
final canonicalizedResource =
|
||||
_buildCanonicalizedResource(bucket, objectKey);
|
||||
|
||||
// 获取请求头信息
|
||||
final contentMD5 = headers['content-md5'] ?? headers['Content-MD5'] ?? '';
|
||||
final contentType =
|
||||
headers['content-type'] ?? headers['Content-Type'] ?? '';
|
||||
final date = headers['date'] ?? headers['Date'] ?? '';
|
||||
|
||||
// 构建签名字符串
|
||||
final stringToSign = _buildStringToSign(
|
||||
method: method,
|
||||
contentMD5: contentMD5,
|
||||
contentType: contentType,
|
||||
date: date,
|
||||
canonicalizedOSSHeaders: canonicalizedOSSHeaders,
|
||||
canonicalizedResource: canonicalizedResource,
|
||||
);
|
||||
|
||||
// 计算HMAC-SHA1签名
|
||||
final signature = _calculateSignature(stringToSign, accessKeySecret);
|
||||
|
||||
return 'OSS $accessKeyId:$signature';
|
||||
}
|
||||
|
||||
/// 生成预签名URL
|
||||
///
|
||||
/// [method] HTTP方法
|
||||
/// [objectKey] 对象键
|
||||
/// [bucket] 存储桶名称
|
||||
/// [endpoint] OSS端点
|
||||
/// [accessKeyId] 访问密钥ID
|
||||
/// [accessKeySecret] 访问密钥密码
|
||||
/// [expires] 过期时间(Unix时间戳)
|
||||
/// [securityToken] 安全令牌(STS模式)
|
||||
/// [params] 额外的查询参数
|
||||
static String generatePreSignedUrl({
|
||||
required String method,
|
||||
required String objectKey,
|
||||
required String bucket,
|
||||
required String endpoint,
|
||||
required String accessKeyId,
|
||||
required String accessKeySecret,
|
||||
required int expires,
|
||||
String? securityToken,
|
||||
Map<String, String>? params,
|
||||
}) {
|
||||
// 构建查询参数
|
||||
final queryParams = <String, String>{
|
||||
'OSSAccessKeyId': accessKeyId,
|
||||
'Expires': expires.toString(),
|
||||
'Signature': '',
|
||||
};
|
||||
|
||||
if (securityToken != null) {
|
||||
queryParams['security-token'] = securityToken;
|
||||
}
|
||||
|
||||
if (params != null) {
|
||||
queryParams.addAll(params);
|
||||
}
|
||||
|
||||
// 构建规范化的资源(包含查询参数)
|
||||
final canonicalizedResource = _buildCanonicalizedResourceWithParams(
|
||||
bucket,
|
||||
objectKey,
|
||||
queryParams,
|
||||
);
|
||||
|
||||
// 构建签名字符串
|
||||
final stringToSign = _buildStringToSign(
|
||||
method: method,
|
||||
contentMD5: '',
|
||||
contentType: '',
|
||||
date: expires.toString(),
|
||||
canonicalizedOSSHeaders: '',
|
||||
canonicalizedResource: canonicalizedResource,
|
||||
);
|
||||
|
||||
// 计算签名
|
||||
final signature = _calculateSignature(stringToSign, accessKeySecret);
|
||||
queryParams['Signature'] = signature;
|
||||
|
||||
// 构建URL
|
||||
final queryString = queryParams.entries
|
||||
.map((e) =>
|
||||
'${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
|
||||
.join('&');
|
||||
|
||||
return 'https://$bucket.$endpoint/$objectKey?$queryString';
|
||||
}
|
||||
|
||||
/// 计算文件的MD5哈希值
|
||||
static String calculateMD5(Uint8List data) {
|
||||
final digest = md5.convert(data);
|
||||
return base64Encode(digest.bytes);
|
||||
}
|
||||
|
||||
/// 计算文件的SHA1哈希值
|
||||
static String calculateSHA1(Uint8List data) {
|
||||
final digest = sha1.convert(data);
|
||||
return base64Encode(digest.bytes);
|
||||
}
|
||||
|
||||
/// 计算文件的SHA256哈希值
|
||||
static String calculateSHA256(Uint8List data) {
|
||||
final digest = sha256.convert(data);
|
||||
return base64Encode(digest.bytes);
|
||||
}
|
||||
|
||||
/// 验证签名是否正确
|
||||
static bool verifySignature({
|
||||
required String method,
|
||||
required String objectKey,
|
||||
required String bucket,
|
||||
required String accessKeyId,
|
||||
required String accessKeySecret,
|
||||
required Map<String, String> headers,
|
||||
required String expectedSignature,
|
||||
String? securityToken,
|
||||
}) {
|
||||
final calculatedSignature = generateSignature(
|
||||
method: method,
|
||||
objectKey: objectKey,
|
||||
bucket: bucket,
|
||||
accessKeyId: accessKeyId,
|
||||
accessKeySecret: accessKeySecret,
|
||||
headers: headers,
|
||||
securityToken: securityToken,
|
||||
);
|
||||
|
||||
return calculatedSignature == expectedSignature;
|
||||
}
|
||||
|
||||
/// 构建规范化的OSS头部
|
||||
static String _buildCanonicalizedOSSHeaders(
|
||||
Map<String, String> headers, String? securityToken) {
|
||||
final ossHeaders = <String, String>{};
|
||||
|
||||
// 添加安全令牌头部
|
||||
if (securityToken != null) {
|
||||
ossHeaders['x-oss-security-token'] = securityToken;
|
||||
}
|
||||
|
||||
// 过滤并添加以x-oss-开头的头部
|
||||
for (final entry in headers.entries) {
|
||||
final lowerKey = entry.key.toLowerCase();
|
||||
if (lowerKey.startsWith('x-oss-')) {
|
||||
ossHeaders[lowerKey] = entry.value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// 按字典序排序
|
||||
final sortedKeys = ossHeaders.keys.toList()..sort();
|
||||
|
||||
return sortedKeys.map((key) => '$key:${ossHeaders[key]}').join('\n');
|
||||
}
|
||||
|
||||
/// 构建规范化的资源
|
||||
static String _buildCanonicalizedResource(String bucket, String objectKey) {
|
||||
if (bucket.isEmpty && objectKey.isEmpty) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
// 确保objectKey以/开头
|
||||
final normalizedObjectKey =
|
||||
objectKey.isNotEmpty && objectKey.startsWith('/')
|
||||
? objectKey
|
||||
: '/$objectKey';
|
||||
|
||||
return '/$bucket$normalizedObjectKey';
|
||||
}
|
||||
|
||||
/// 构建带查询参数的规范化资源
|
||||
static String _buildCanonicalizedResourceWithParams(
|
||||
String bucket,
|
||||
String objectKey,
|
||||
Map<String, String> params,
|
||||
) {
|
||||
final baseResource = _buildCanonicalizedResource(bucket, objectKey);
|
||||
|
||||
// OSS签名相关的查询参数
|
||||
const ossParams = {
|
||||
'acl',
|
||||
'cors',
|
||||
'delete',
|
||||
'lifecycle',
|
||||
'location',
|
||||
'logging',
|
||||
'notification',
|
||||
'partNumber',
|
||||
'policy',
|
||||
'requestPayment',
|
||||
'torrent',
|
||||
'uploadId',
|
||||
'uploads',
|
||||
'versionId',
|
||||
'versioning',
|
||||
'versions',
|
||||
'website',
|
||||
'restore',
|
||||
'tagging',
|
||||
'replication',
|
||||
'encryption',
|
||||
'inventory',
|
||||
'select',
|
||||
'select-type',
|
||||
};
|
||||
|
||||
final filteredParams = <String, String>{};
|
||||
for (final entry in params.entries) {
|
||||
if (ossParams.contains(entry.key.toLowerCase())) {
|
||||
filteredParams[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredParams.isEmpty) {
|
||||
return baseResource;
|
||||
}
|
||||
|
||||
final sortedKeys = filteredParams.keys.toList()..sort();
|
||||
final queryString = sortedKeys.map((key) {
|
||||
final value = filteredParams[key] ?? '';
|
||||
return value.isEmpty ? key : '$key=$value';
|
||||
}).join('&');
|
||||
|
||||
return '$baseResource?$queryString';
|
||||
}
|
||||
|
||||
/// 构建签名字符串
|
||||
static String _buildStringToSign({
|
||||
required String method,
|
||||
required String contentMD5,
|
||||
required String contentType,
|
||||
required String date,
|
||||
required String canonicalizedOSSHeaders,
|
||||
required String canonicalizedResource,
|
||||
}) {
|
||||
final components = [
|
||||
method.toUpperCase(),
|
||||
contentMD5,
|
||||
contentType,
|
||||
date,
|
||||
canonicalizedOSSHeaders,
|
||||
canonicalizedResource,
|
||||
];
|
||||
|
||||
return components.join('\n');
|
||||
}
|
||||
|
||||
/// 计算HMAC-SHA1签名
|
||||
static String _calculateSignature(
|
||||
String stringToSign, String accessKeySecret) {
|
||||
final key = utf8.encode(accessKeySecret);
|
||||
final bytes = utf8.encode(stringToSign);
|
||||
final hmac = Hmac(sha1, key);
|
||||
final digest = hmac.convert(bytes);
|
||||
|
||||
return base64Encode(digest.bytes);
|
||||
}
|
||||
|
||||
/// 格式化日期为HTTP日期格式
|
||||
static String formatHttpDate(DateTime dateTime) {
|
||||
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec'
|
||||
];
|
||||
|
||||
final utc = dateTime.toUtc();
|
||||
final weekday = weekdays[utc.weekday - 1];
|
||||
final day = utc.day.toString().padLeft(2, '0');
|
||||
final month = months[utc.month - 1];
|
||||
final year = utc.year.toString();
|
||||
final hour = utc.hour.toString().padLeft(2, '0');
|
||||
final minute = utc.minute.toString().padLeft(2, '0');
|
||||
final second = utc.second.toString().padLeft(2, '0');
|
||||
|
||||
return '$weekday, $day $month $year $hour:$minute:$second GMT';
|
||||
}
|
||||
|
||||
/// 解析HTTP日期格式
|
||||
static DateTime parseHttpDate(String httpDate) {
|
||||
// 支持RFC 822格式:Wed, 09 Jun 2021 10:18:14 GMT
|
||||
final parts = httpDate.split(' ');
|
||||
if (parts.length < 6) {
|
||||
throw FormatException('Invalid HTTP date format: $httpDate');
|
||||
}
|
||||
|
||||
const months = {
|
||||
'Jan': 1,
|
||||
'Feb': 2,
|
||||
'Mar': 3,
|
||||
'Apr': 4,
|
||||
'May': 5,
|
||||
'Jun': 6,
|
||||
'Jul': 7,
|
||||
'Aug': 8,
|
||||
'Sep': 9,
|
||||
'Oct': 10,
|
||||
'Nov': 11,
|
||||
'Dec': 12,
|
||||
};
|
||||
|
||||
final day = int.parse(parts[1]);
|
||||
final month = months[parts[2]] ?? 1;
|
||||
final year = int.parse(parts[3]);
|
||||
final timeParts = parts[4].split(':');
|
||||
final hour = int.parse(timeParts[0]);
|
||||
final minute = int.parse(timeParts[1]);
|
||||
final second = int.parse(timeParts[2]);
|
||||
|
||||
return DateTime.utc(year, month, day, hour, minute, second);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
/// 认证提供者接口
|
||||
///
|
||||
/// 定义获取OSS认证信息的标准接口,支持多种认证方式:
|
||||
/// - 静态AccessKey/SecretKey
|
||||
/// - STS临时凭证
|
||||
/// - 预签名URL
|
||||
/// - 自定义认证逻辑
|
||||
abstract class AuthProvider {
|
||||
/// 获取AccessKey ID
|
||||
Future<String> getAccessKeyId();
|
||||
|
||||
/// 获取Access Key Secret
|
||||
Future<String> getAccessKeySecret();
|
||||
|
||||
/// 获取Security Token (STS模式)
|
||||
/// 非STS模式时返回null
|
||||
Future<String?> getSecurityToken();
|
||||
|
||||
/// 检查认证信息是否有效
|
||||
/// 返回true表示认证信息有效,false表示需要刷新
|
||||
Future<bool> isValid();
|
||||
|
||||
/// 刷新认证信息
|
||||
/// 当认证信息过期时调用此方法获取新的认证信息
|
||||
Future<void> refresh();
|
||||
|
||||
/// 获取认证信息的过期时间
|
||||
/// 返回null表示永不过期
|
||||
Future<DateTime?> getExpiration();
|
||||
}
|
||||
|
||||
/// 静态认证提供者
|
||||
/// 使用固定的AccessKey和SecretKey
|
||||
class StaticAuthProvider implements AuthProvider {
|
||||
final String accessKeyId;
|
||||
final String accessKeySecret;
|
||||
|
||||
const StaticAuthProvider({
|
||||
required this.accessKeyId,
|
||||
required this.accessKeySecret,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<String> getAccessKeyId() async => accessKeyId;
|
||||
|
||||
@override
|
||||
Future<String> getAccessKeySecret() async => accessKeySecret;
|
||||
|
||||
@override
|
||||
Future<String?> getSecurityToken() async => null;
|
||||
|
||||
@override
|
||||
Future<bool> isValid() async => true;
|
||||
|
||||
@override
|
||||
Future<void> refresh() async {
|
||||
// 静态认证不需要刷新
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DateTime?> getExpiration() async => null;
|
||||
}
|
||||
|
||||
/// STS认证提供者
|
||||
/// 使用临时访问凭证
|
||||
class STSAuthProvider implements AuthProvider {
|
||||
String _accessKeyId;
|
||||
String _accessKeySecret;
|
||||
String _securityToken;
|
||||
DateTime _expiration;
|
||||
final Future<STSCredentials> Function() _credentialsGetter;
|
||||
|
||||
STSAuthProvider({
|
||||
required String accessKeyId,
|
||||
required String accessKeySecret,
|
||||
required String securityToken,
|
||||
required DateTime expiration,
|
||||
required Future<STSCredentials> Function() credentialsGetter,
|
||||
}) : _accessKeyId = accessKeyId,
|
||||
_accessKeySecret = accessKeySecret,
|
||||
_securityToken = securityToken,
|
||||
_expiration = expiration,
|
||||
_credentialsGetter = credentialsGetter;
|
||||
|
||||
@override
|
||||
Future<String> getAccessKeyId() async {
|
||||
await _ensureValid();
|
||||
return _accessKeyId;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getAccessKeySecret() async {
|
||||
await _ensureValid();
|
||||
return _accessKeySecret;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getSecurityToken() async {
|
||||
await _ensureValid();
|
||||
return _securityToken;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isValid() async {
|
||||
return DateTime.now()
|
||||
.isBefore(_expiration.subtract(const Duration(minutes: 5)));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> refresh() async {
|
||||
final credentials = await _credentialsGetter();
|
||||
_accessKeyId = credentials.accessKeyId;
|
||||
_accessKeySecret = credentials.accessKeySecret;
|
||||
_securityToken = credentials.securityToken;
|
||||
_expiration = credentials.expiration;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DateTime?> getExpiration() async => _expiration;
|
||||
|
||||
/// 确保认证信息有效
|
||||
Future<void> _ensureValid() async {
|
||||
if (!await isValid()) {
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// STS凭证模型
|
||||
class STSCredentials {
|
||||
final String accessKeyId;
|
||||
final String accessKeySecret;
|
||||
final String securityToken;
|
||||
final DateTime expiration;
|
||||
|
||||
const STSCredentials({
|
||||
required this.accessKeyId,
|
||||
required this.accessKeySecret,
|
||||
required this.securityToken,
|
||||
required this.expiration,
|
||||
});
|
||||
|
||||
factory STSCredentials.fromJson(Map<String, dynamic> json) {
|
||||
return STSCredentials(
|
||||
accessKeyId: json['AccessKeyId'] as String,
|
||||
accessKeySecret: json['AccessKeySecret'] as String,
|
||||
securityToken: json['SecurityToken'] as String,
|
||||
expiration: DateTime.parse(json['Expiration'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 预签名URL认证提供者
|
||||
/// 使用服务端生成的预签名URL进行上传
|
||||
class PreSignedAuthProvider implements AuthProvider {
|
||||
final Future<String> Function(String fileName) _urlGetter;
|
||||
|
||||
const PreSignedAuthProvider({
|
||||
required Future<String> Function(String fileName) urlGetter,
|
||||
}) : _urlGetter = urlGetter;
|
||||
|
||||
/// 获取预签名URL
|
||||
Future<String> getPreSignedUrl(String fileName) => _urlGetter(fileName);
|
||||
|
||||
@override
|
||||
Future<String> getAccessKeyId() async => throw UnsupportedError(
|
||||
'PreSignedAuthProvider does not support getAccessKeyId');
|
||||
|
||||
@override
|
||||
Future<String> getAccessKeySecret() async => throw UnsupportedError(
|
||||
'PreSignedAuthProvider does not support getAccessKeySecret');
|
||||
|
||||
@override
|
||||
Future<String?> getSecurityToken() async => null;
|
||||
|
||||
@override
|
||||
Future<bool> isValid() async => true;
|
||||
|
||||
@override
|
||||
Future<void> refresh() async {
|
||||
// 预签名URL模式不需要刷新认证信息
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DateTime?> getExpiration() async => null;
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
/// OSS配置提供者接口
|
||||
///
|
||||
/// 定义获取OSS配置信息的标准接口,支持动态配置和静态配置
|
||||
abstract class ConfigProvider {
|
||||
/// 获取OSS端点
|
||||
Future<String> getEndpoint();
|
||||
|
||||
/// 获取存储桶名称
|
||||
Future<String> getBucketName();
|
||||
|
||||
/// 获取上传目录前缀
|
||||
Future<String> getDirectory();
|
||||
|
||||
/// 获取访问域名
|
||||
/// 如果为null,则使用默认的 https://{bucket}.{endpoint}
|
||||
Future<String?> getDomain();
|
||||
|
||||
/// 获取配置信息是否有效
|
||||
Future<bool> isValid();
|
||||
|
||||
/// 刷新配置信息
|
||||
Future<void> refresh();
|
||||
}
|
||||
|
||||
/// 静态配置提供者
|
||||
/// 使用固定的配置信息
|
||||
class StaticConfigProvider implements ConfigProvider {
|
||||
final String endpoint;
|
||||
final String bucketName;
|
||||
final String directory;
|
||||
final String? domain;
|
||||
|
||||
const StaticConfigProvider({
|
||||
required this.endpoint,
|
||||
required this.bucketName,
|
||||
this.directory = 'uploads',
|
||||
this.domain,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<String> getEndpoint() async => endpoint;
|
||||
|
||||
@override
|
||||
Future<String> getBucketName() async => bucketName;
|
||||
|
||||
@override
|
||||
Future<String> getDirectory() async => directory;
|
||||
|
||||
@override
|
||||
Future<String?> getDomain() async => domain;
|
||||
|
||||
@override
|
||||
Future<bool> isValid() async => true;
|
||||
|
||||
@override
|
||||
Future<void> refresh() async {
|
||||
// 静态配置不需要刷新
|
||||
}
|
||||
}
|
||||
|
||||
/// 动态配置提供者
|
||||
/// 支持从服务端动态获取配置信息
|
||||
class DynamicConfigProvider implements ConfigProvider {
|
||||
OSSConfig? _config;
|
||||
DateTime? _lastUpdate;
|
||||
final Duration _cacheTimeout;
|
||||
final Future<OSSConfig> Function() _configGetter;
|
||||
|
||||
DynamicConfigProvider({
|
||||
required Future<OSSConfig> Function() configGetter,
|
||||
Duration cacheTimeout = const Duration(hours: 1),
|
||||
}) : _configGetter = configGetter,
|
||||
_cacheTimeout = cacheTimeout;
|
||||
|
||||
@override
|
||||
Future<String> getEndpoint() async {
|
||||
await _ensureConfig();
|
||||
return _config!.endpoint;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getBucketName() async {
|
||||
await _ensureConfig();
|
||||
return _config!.bucketName;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getDirectory() async {
|
||||
await _ensureConfig();
|
||||
return _config!.directory;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getDomain() async {
|
||||
await _ensureConfig();
|
||||
return _config!.domain;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isValid() async {
|
||||
if (_config == null || _lastUpdate == null) return false;
|
||||
return DateTime.now().difference(_lastUpdate!) < _cacheTimeout;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> refresh() async {
|
||||
_config = await _configGetter();
|
||||
_lastUpdate = DateTime.now();
|
||||
}
|
||||
|
||||
/// 确保配置信息有效
|
||||
Future<void> _ensureConfig() async {
|
||||
if (!await isValid()) {
|
||||
await refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OSS配置模型
|
||||
class OSSConfig {
|
||||
final String endpoint;
|
||||
final String bucketName;
|
||||
final String directory;
|
||||
final String? domain;
|
||||
|
||||
const OSSConfig({
|
||||
required this.endpoint,
|
||||
required this.bucketName,
|
||||
this.directory = 'uploads',
|
||||
this.domain,
|
||||
});
|
||||
|
||||
factory OSSConfig.fromJson(Map<String, dynamic> json) {
|
||||
return OSSConfig(
|
||||
endpoint: json['endpoint'] as String,
|
||||
bucketName: json['bucketName'] as String,
|
||||
directory: json['directory'] as String? ?? 'uploads',
|
||||
domain: json['domain'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'endpoint': endpoint,
|
||||
'bucketName': bucketName,
|
||||
'directory': directory,
|
||||
if (domain != null) 'domain': domain,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
/// 断点续传存储接口
|
||||
///
|
||||
/// 提供断点续传信息的持久化存储功能
|
||||
abstract class ResumeStorage {
|
||||
/// 保存断点续传信息
|
||||
///
|
||||
/// [taskId] 任务ID
|
||||
/// [resumeInfo] 断点续传信息
|
||||
Future<void> saveResumeInfo(String taskId, Map<String, dynamic> resumeInfo);
|
||||
|
||||
/// 加载断点续传信息
|
||||
///
|
||||
/// [taskId] 任务ID
|
||||
/// 返回断点续传信息,如果不存在则返回null
|
||||
Future<Map<String, dynamic>?> loadResumeInfo(String taskId);
|
||||
|
||||
/// 删除断点续传信息
|
||||
///
|
||||
/// [taskId] 任务ID
|
||||
Future<void> deleteResumeInfo(String taskId);
|
||||
|
||||
/// 获取所有断点续传任务ID
|
||||
Future<List<String>> getAllTaskIds();
|
||||
|
||||
/// 清空所有断点续传信息
|
||||
Future<void> clearAll();
|
||||
|
||||
/// 获取存储大小(可选实现)
|
||||
Future<int> getStorageSize() async => 0;
|
||||
}
|
||||
|
||||
/// 基于内存的断点续传存储实现(仅用于测试)
|
||||
class MemoryResumeStorage implements ResumeStorage {
|
||||
final Map<String, Map<String, dynamic>> _storage = {};
|
||||
|
||||
@override
|
||||
Future<void> saveResumeInfo(
|
||||
String taskId, Map<String, dynamic> resumeInfo) async {
|
||||
_storage[taskId] = Map<String, dynamic>.from(resumeInfo);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> loadResumeInfo(String taskId) async {
|
||||
return _storage[taskId];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteResumeInfo(String taskId) async {
|
||||
_storage.remove(taskId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> getAllTaskIds() async {
|
||||
return _storage.keys.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearAll() async {
|
||||
_storage.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getStorageSize() async {
|
||||
return _storage.length;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import '../models/oss_error.dart';
|
||||
import '../models/upload_result.dart';
|
||||
|
||||
/// 上传回调接口
|
||||
///
|
||||
/// 定义文件上传过程中的各种回调事件
|
||||
class UploadCallbacks {
|
||||
/// 上传开始回调
|
||||
final void Function()? onStart;
|
||||
|
||||
/// 上传进度回调
|
||||
/// [sent] 已发送的字节数
|
||||
/// [total] 总字节数
|
||||
final void Function(int sent, int total)? onProgress;
|
||||
|
||||
/// 上传成功回调
|
||||
/// [result] 上传结果
|
||||
final void Function(UploadResult result)? onSuccess;
|
||||
|
||||
/// 上传失败回调
|
||||
/// [error] 错误信息
|
||||
final void Function(OSSError error)? onError;
|
||||
|
||||
/// 上传完成回调(无论成功还是失败都会调用)
|
||||
final void Function()? onComplete;
|
||||
|
||||
const UploadCallbacks({
|
||||
this.onStart,
|
||||
this.onProgress,
|
||||
this.onSuccess,
|
||||
this.onError,
|
||||
this.onComplete,
|
||||
});
|
||||
}
|
||||
|
||||
/// 下载回调接口
|
||||
class DownloadCallbacks {
|
||||
/// 下载开始回调
|
||||
final void Function()? onStart;
|
||||
|
||||
/// 下载进度回调
|
||||
/// [received] 已接收的字节数
|
||||
/// [total] 总字节数
|
||||
final void Function(int received, int total)? onProgress;
|
||||
|
||||
/// 下载成功回调
|
||||
/// [filePath] 下载文件的本地路径
|
||||
final void Function(String filePath)? onSuccess;
|
||||
|
||||
/// 下载失败回调
|
||||
/// [error] 错误信息
|
||||
final void Function(OSSError error)? onError;
|
||||
|
||||
/// 下载完成回调(无论成功还是失败都会调用)
|
||||
final void Function()? onComplete;
|
||||
|
||||
const DownloadCallbacks({
|
||||
this.onStart,
|
||||
this.onProgress,
|
||||
this.onSuccess,
|
||||
this.onError,
|
||||
this.onComplete,
|
||||
});
|
||||
}
|
||||
|
||||
// 为了避免循环依赖,这里先声明这些类,具体实现在各自的文件中
|
||||
// UploadResult 和 OSSError 的定义在各自的文件中
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
/// OSS错误类型枚举
|
||||
enum OSSErrorType {
|
||||
/// 网络错误
|
||||
network,
|
||||
|
||||
/// 认证错误
|
||||
authentication,
|
||||
|
||||
/// 权限错误
|
||||
permission,
|
||||
|
||||
/// 文件不存在
|
||||
fileNotFound,
|
||||
|
||||
/// 配置错误
|
||||
configuration,
|
||||
|
||||
/// 签名错误
|
||||
signature,
|
||||
|
||||
/// 服务器错误
|
||||
server,
|
||||
|
||||
/// 客户端错误
|
||||
client,
|
||||
|
||||
/// 取消操作
|
||||
cancelled,
|
||||
|
||||
/// 超时错误
|
||||
timeout,
|
||||
|
||||
/// 未知错误
|
||||
unknown,
|
||||
}
|
||||
|
||||
/// OSS错误类
|
||||
class OSSError implements Exception {
|
||||
/// 错误类型
|
||||
final OSSErrorType type;
|
||||
|
||||
/// 错误代码
|
||||
final String? code;
|
||||
|
||||
/// 错误消息
|
||||
final String message;
|
||||
|
||||
/// 详细错误信息
|
||||
final String? details;
|
||||
|
||||
/// HTTP状态码
|
||||
final int? statusCode;
|
||||
|
||||
/// 请求ID
|
||||
final String? requestId;
|
||||
|
||||
/// 原始异常
|
||||
final Object? originalException;
|
||||
|
||||
/// 堆栈跟踪
|
||||
final StackTrace? stackTrace;
|
||||
|
||||
const OSSError({
|
||||
required this.type,
|
||||
required this.message,
|
||||
this.code,
|
||||
this.details,
|
||||
this.statusCode,
|
||||
this.requestId,
|
||||
this.originalException,
|
||||
this.stackTrace,
|
||||
});
|
||||
|
||||
/// 创建网络错误
|
||||
factory OSSError.network(String message,
|
||||
{Object? originalException, StackTrace? stackTrace}) {
|
||||
return OSSError(
|
||||
type: OSSErrorType.network,
|
||||
message: message,
|
||||
originalException: originalException,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建认证错误
|
||||
factory OSSError.authentication(String message, {String? code}) {
|
||||
return OSSError(
|
||||
type: OSSErrorType.authentication,
|
||||
message: message,
|
||||
code: code,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建权限错误
|
||||
factory OSSError.permission(String message, {String? code, int? statusCode}) {
|
||||
return OSSError(
|
||||
type: OSSErrorType.permission,
|
||||
message: message,
|
||||
code: code,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建文件不存在错误
|
||||
factory OSSError.fileNotFound(String message, {String? filePath}) {
|
||||
return OSSError(
|
||||
type: OSSErrorType.fileNotFound,
|
||||
message: message,
|
||||
details: filePath,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建配置错误
|
||||
factory OSSError.configuration(String message, {String? details}) {
|
||||
return OSSError(
|
||||
type: OSSErrorType.configuration,
|
||||
message: message,
|
||||
details: details,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建签名错误
|
||||
factory OSSError.signature(String message, {String? code}) {
|
||||
return OSSError(
|
||||
type: OSSErrorType.signature,
|
||||
message: message,
|
||||
code: code,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建服务器错误
|
||||
factory OSSError.server(String message,
|
||||
{String? code, int? statusCode, String? requestId}) {
|
||||
return OSSError(
|
||||
type: OSSErrorType.server,
|
||||
message: message,
|
||||
code: code,
|
||||
statusCode: statusCode,
|
||||
requestId: requestId,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建客户端错误
|
||||
factory OSSError.client(String message, {String? code, int? statusCode}) {
|
||||
return OSSError(
|
||||
type: OSSErrorType.client,
|
||||
message: message,
|
||||
code: code,
|
||||
statusCode: statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建取消错误
|
||||
factory OSSError.cancelled(String message) {
|
||||
return OSSError(
|
||||
type: OSSErrorType.cancelled,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建超时错误
|
||||
factory OSSError.timeout(String message) {
|
||||
return OSSError(
|
||||
type: OSSErrorType.timeout,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建未知错误
|
||||
factory OSSError.unknown(String message,
|
||||
{Object? originalException, StackTrace? stackTrace}) {
|
||||
return OSSError(
|
||||
type: OSSErrorType.unknown,
|
||||
message: message,
|
||||
originalException: originalException,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
/// 从HTTP响应创建错误
|
||||
factory OSSError.fromResponse(int statusCode, String? body,
|
||||
{String? requestId}) {
|
||||
OSSErrorType type;
|
||||
String message;
|
||||
String? code;
|
||||
|
||||
if (statusCode >= 400 && statusCode < 500) {
|
||||
type = OSSErrorType.client;
|
||||
message = 'Client error: $statusCode';
|
||||
} else if (statusCode >= 500) {
|
||||
type = OSSErrorType.server;
|
||||
message = 'Server error: $statusCode';
|
||||
} else {
|
||||
type = OSSErrorType.unknown;
|
||||
message = 'Unknown error: $statusCode';
|
||||
}
|
||||
|
||||
// 尝试解析错误详情
|
||||
if (body != null && body.isNotEmpty) {
|
||||
try {
|
||||
// 这里可以根据实际的错误响应格式进行解析
|
||||
// 例如解析XML或JSON格式的错误信息
|
||||
message = body;
|
||||
} catch (e) {
|
||||
// 解析失败,使用原始body
|
||||
message = body;
|
||||
}
|
||||
}
|
||||
|
||||
return OSSError(
|
||||
type: type,
|
||||
message: message,
|
||||
code: code,
|
||||
statusCode: statusCode,
|
||||
requestId: requestId,
|
||||
);
|
||||
}
|
||||
|
||||
/// 检查是否为网络错误
|
||||
bool get isNetworkError => type == OSSErrorType.network;
|
||||
|
||||
/// 检查是否为认证错误
|
||||
bool get isAuthenticationError => type == OSSErrorType.authentication;
|
||||
|
||||
/// 检查是否为权限错误
|
||||
bool get isPermissionError => type == OSSErrorType.permission;
|
||||
|
||||
/// 检查是否为取消错误
|
||||
bool get isCancelledError => type == OSSErrorType.cancelled;
|
||||
|
||||
/// 检查是否为可重试的错误
|
||||
bool get isRetryable {
|
||||
switch (type) {
|
||||
case OSSErrorType.network:
|
||||
case OSSErrorType.timeout:
|
||||
case OSSErrorType.server:
|
||||
return true;
|
||||
case OSSErrorType.authentication:
|
||||
case OSSErrorType.permission:
|
||||
case OSSErrorType.fileNotFound:
|
||||
case OSSErrorType.configuration:
|
||||
case OSSErrorType.signature:
|
||||
case OSSErrorType.client:
|
||||
case OSSErrorType.cancelled:
|
||||
case OSSErrorType.unknown:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final buffer = StringBuffer('OSSError(');
|
||||
buffer.write('type: $type');
|
||||
buffer.write(', message: $message');
|
||||
if (code != null) buffer.write(', code: $code');
|
||||
if (statusCode != null) buffer.write(', statusCode: $statusCode');
|
||||
if (requestId != null) buffer.write(', requestId: $requestId');
|
||||
buffer.write(')');
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,423 @@
|
|||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../interfaces/upload_callbacks.dart';
|
||||
|
||||
/// 上传选项类
|
||||
class UploadOptions {
|
||||
/// 上传回调
|
||||
final UploadCallbacks? callbacks;
|
||||
|
||||
/// 是否覆盖同名文件
|
||||
final bool overwrite;
|
||||
|
||||
/// 访问控制列表
|
||||
final ACLMode? acl;
|
||||
|
||||
/// 存储类型
|
||||
final StorageClass? storageClass;
|
||||
|
||||
/// 自定义HTTP头
|
||||
final Map<String, String>? headers;
|
||||
|
||||
/// 自定义元数据
|
||||
final Map<String, String>? metadata;
|
||||
|
||||
/// 文件过期时间
|
||||
final DateTime? expires;
|
||||
|
||||
/// 缓存控制
|
||||
final String? cacheControl;
|
||||
|
||||
/// 内容类型
|
||||
final String? contentType;
|
||||
|
||||
/// 内容编码
|
||||
final String? contentEncoding;
|
||||
|
||||
/// 内容处置
|
||||
final String? contentDisposition;
|
||||
|
||||
/// 内容语言
|
||||
final String? contentLanguage;
|
||||
|
||||
/// 是否启用服务端加密
|
||||
final bool serverSideEncryption;
|
||||
|
||||
/// 加密算法
|
||||
final String? encryptionAlgorithm;
|
||||
|
||||
/// 加密密钥
|
||||
final String? encryptionKey;
|
||||
|
||||
/// 分片上传的分片大小(字节)
|
||||
/// 默认为4MB,最小为100KB,最大为5GB
|
||||
final int partSize;
|
||||
|
||||
/// 并发上传的线程数
|
||||
final int concurrency;
|
||||
|
||||
/// 重试次数
|
||||
final int retryCount;
|
||||
|
||||
/// 重试间隔(毫秒)
|
||||
final int retryInterval;
|
||||
|
||||
/// 连接超时(毫秒)
|
||||
final int connectTimeout;
|
||||
|
||||
/// 发送超时(毫秒)
|
||||
final int sendTimeout;
|
||||
|
||||
/// 接收超时(毫秒)
|
||||
final int receiveTimeout;
|
||||
|
||||
/// 是否启用分片上传
|
||||
/// 当文件大小超过分片阈值时自动启用
|
||||
final bool enableMultipart;
|
||||
|
||||
/// 分片上传阈值(字节)
|
||||
/// 当文件大小超过此值时使用分片上传
|
||||
final int multipartThreshold;
|
||||
|
||||
/// 是否启用断点续传
|
||||
/// 启用后会保存上传进度,支持中断后恢复
|
||||
final bool enableResume;
|
||||
|
||||
/// 上传开始回调
|
||||
final void Function(String taskId, int uploadedSize, int totalSize)? onStart;
|
||||
|
||||
const UploadOptions({
|
||||
this.callbacks,
|
||||
this.overwrite = true,
|
||||
this.acl,
|
||||
this.storageClass,
|
||||
this.headers,
|
||||
this.metadata,
|
||||
this.expires,
|
||||
this.cacheControl,
|
||||
this.contentType,
|
||||
this.contentEncoding,
|
||||
this.contentDisposition,
|
||||
this.contentLanguage,
|
||||
this.serverSideEncryption = false,
|
||||
this.encryptionAlgorithm,
|
||||
this.encryptionKey,
|
||||
this.partSize = 4 * 1024 * 1024, // 4MB
|
||||
this.concurrency = 3,
|
||||
this.retryCount = 3,
|
||||
this.retryInterval = 1000,
|
||||
this.connectTimeout = 60000,
|
||||
this.sendTimeout = 300000,
|
||||
this.receiveTimeout = 300000,
|
||||
this.enableMultipart = true,
|
||||
this.multipartThreshold = 100 * 1024 * 1024, // 100MB
|
||||
this.enableResume = false,
|
||||
this.onStart,
|
||||
});
|
||||
|
||||
/// 复制并修改选项
|
||||
UploadOptions copyWith({
|
||||
UploadCallbacks? callbacks,
|
||||
bool? overwrite,
|
||||
ACLMode? acl,
|
||||
StorageClass? storageClass,
|
||||
Map<String, String>? headers,
|
||||
Map<String, String>? metadata,
|
||||
DateTime? expires,
|
||||
String? cacheControl,
|
||||
String? contentType,
|
||||
String? contentEncoding,
|
||||
String? contentDisposition,
|
||||
String? contentLanguage,
|
||||
bool? serverSideEncryption,
|
||||
String? encryptionAlgorithm,
|
||||
String? encryptionKey,
|
||||
int? partSize,
|
||||
int? concurrency,
|
||||
int? retryCount,
|
||||
int? retryInterval,
|
||||
int? connectTimeout,
|
||||
int? sendTimeout,
|
||||
int? receiveTimeout,
|
||||
bool? enableMultipart,
|
||||
int? multipartThreshold,
|
||||
bool? enableResume,
|
||||
void Function(String taskId, int uploadedSize, int totalSize)? onStart,
|
||||
}) {
|
||||
return UploadOptions(
|
||||
callbacks: callbacks ?? this.callbacks,
|
||||
overwrite: overwrite ?? this.overwrite,
|
||||
acl: acl ?? this.acl,
|
||||
storageClass: storageClass ?? this.storageClass,
|
||||
headers: headers ?? this.headers,
|
||||
metadata: metadata ?? this.metadata,
|
||||
expires: expires ?? this.expires,
|
||||
cacheControl: cacheControl ?? this.cacheControl,
|
||||
contentType: contentType ?? this.contentType,
|
||||
contentEncoding: contentEncoding ?? this.contentEncoding,
|
||||
contentDisposition: contentDisposition ?? this.contentDisposition,
|
||||
contentLanguage: contentLanguage ?? this.contentLanguage,
|
||||
serverSideEncryption: serverSideEncryption ?? this.serverSideEncryption,
|
||||
encryptionAlgorithm: encryptionAlgorithm ?? this.encryptionAlgorithm,
|
||||
encryptionKey: encryptionKey ?? this.encryptionKey,
|
||||
partSize: partSize ?? this.partSize,
|
||||
concurrency: concurrency ?? this.concurrency,
|
||||
retryCount: retryCount ?? this.retryCount,
|
||||
retryInterval: retryInterval ?? this.retryInterval,
|
||||
connectTimeout: connectTimeout ?? this.connectTimeout,
|
||||
sendTimeout: sendTimeout ?? this.sendTimeout,
|
||||
receiveTimeout: receiveTimeout ?? this.receiveTimeout,
|
||||
enableMultipart: enableMultipart ?? this.enableMultipart,
|
||||
multipartThreshold: multipartThreshold ?? this.multipartThreshold,
|
||||
enableResume: enableResume ?? this.enableResume,
|
||||
onStart: onStart ?? this.onStart,
|
||||
);
|
||||
}
|
||||
|
||||
/// 验证选项有效性
|
||||
bool validate() {
|
||||
if (partSize < 100 * 1024) return false; // 最小100KB
|
||||
if (partSize > 5 * 1024 * 1024 * 1024) return false; // 最大5GB
|
||||
if (concurrency < 1 || concurrency > 10) return false;
|
||||
if (retryCount < 0 || retryCount > 10) return false;
|
||||
if (retryInterval < 0) return false;
|
||||
if (connectTimeout < 0) return false;
|
||||
if (sendTimeout < 0) return false;
|
||||
if (receiveTimeout < 0) return false;
|
||||
if (multipartThreshold < partSize) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 转换为JSON(用于断点续传序列化)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'overwrite': overwrite,
|
||||
'acl': acl?.value,
|
||||
'storageClass': storageClass?.value,
|
||||
'headers': headers,
|
||||
'metadata': metadata,
|
||||
'expires': expires?.toIso8601String(),
|
||||
'cacheControl': cacheControl,
|
||||
'contentType': contentType,
|
||||
'contentEncoding': contentEncoding,
|
||||
'contentDisposition': contentDisposition,
|
||||
'contentLanguage': contentLanguage,
|
||||
'serverSideEncryption': serverSideEncryption,
|
||||
'encryptionAlgorithm': encryptionAlgorithm,
|
||||
'encryptionKey': encryptionKey,
|
||||
'partSize': partSize,
|
||||
'concurrency': concurrency,
|
||||
'retryCount': retryCount,
|
||||
'retryInterval': retryInterval,
|
||||
'connectTimeout': connectTimeout,
|
||||
'sendTimeout': sendTimeout,
|
||||
'receiveTimeout': receiveTimeout,
|
||||
'enableMultipart': enableMultipart,
|
||||
'multipartThreshold': multipartThreshold,
|
||||
'enableResume': enableResume,
|
||||
};
|
||||
}
|
||||
|
||||
/// 从JSON创建实例(用于断点续传恢复)
|
||||
factory UploadOptions.fromJson(Map<String, dynamic> json) {
|
||||
return UploadOptions(
|
||||
overwrite: json['overwrite'] as bool? ?? true,
|
||||
acl: json['acl'] != null
|
||||
? ACLMode.values.firstWhere((e) => e.value == json['acl'])
|
||||
: null,
|
||||
storageClass: json['storageClass'] != null
|
||||
? StorageClass.values
|
||||
.firstWhere((e) => e.value == json['storageClass'])
|
||||
: null,
|
||||
headers: json['headers'] != null
|
||||
? Map<String, String>.from(json['headers'] as Map)
|
||||
: null,
|
||||
metadata: json['metadata'] != null
|
||||
? Map<String, String>.from(json['metadata'] as Map)
|
||||
: null,
|
||||
expires: json['expires'] != null
|
||||
? DateTime.parse(json['expires'] as String)
|
||||
: null,
|
||||
cacheControl: json['cacheControl'] as String?,
|
||||
contentType: json['contentType'] as String?,
|
||||
contentEncoding: json['contentEncoding'] as String?,
|
||||
contentDisposition: json['contentDisposition'] as String?,
|
||||
contentLanguage: json['contentLanguage'] as String?,
|
||||
serverSideEncryption: json['serverSideEncryption'] as bool? ?? false,
|
||||
encryptionAlgorithm: json['encryptionAlgorithm'] as String?,
|
||||
encryptionKey: json['encryptionKey'] as String?,
|
||||
partSize: json['partSize'] as int? ?? 4 * 1024 * 1024,
|
||||
concurrency: json['concurrency'] as int? ?? 3,
|
||||
retryCount: json['retryCount'] as int? ?? 3,
|
||||
retryInterval: json['retryInterval'] as int? ?? 1000,
|
||||
connectTimeout: json['connectTimeout'] as int? ?? 60000,
|
||||
sendTimeout: json['sendTimeout'] as int? ?? 300000,
|
||||
receiveTimeout: json['receiveTimeout'] as int? ?? 300000,
|
||||
enableMultipart: json['enableMultipart'] as bool? ?? true,
|
||||
multipartThreshold:
|
||||
json['multipartThreshold'] as int? ?? 100 * 1024 * 1024,
|
||||
enableResume: json['enableResume'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 访问控制列表模式
|
||||
enum ACLMode {
|
||||
/// 私有
|
||||
private('private'),
|
||||
|
||||
/// 公共读
|
||||
publicRead('public-read'),
|
||||
|
||||
/// 公共读写
|
||||
publicReadWrite('public-read-write'),
|
||||
|
||||
/// 继承存储桶权限
|
||||
bucketOwnerRead('bucket-owner-read'),
|
||||
|
||||
/// 存储桶拥有者完全控制
|
||||
bucketOwnerFullControl('bucket-owner-full-control'),
|
||||
|
||||
/// 继承存储桶权限(默认)
|
||||
inherited('default');
|
||||
|
||||
const ACLMode(this.value);
|
||||
final String value;
|
||||
}
|
||||
|
||||
/// 存储类型
|
||||
enum StorageClass {
|
||||
/// 标准存储
|
||||
standard('Standard'),
|
||||
|
||||
/// 低频访问存储
|
||||
infrequentAccess('IA'),
|
||||
|
||||
/// 归档存储
|
||||
archive('Archive'),
|
||||
|
||||
/// 冷归档存储
|
||||
coldArchive('ColdArchive'),
|
||||
|
||||
/// 深度冷归档存储
|
||||
deepColdArchive('DeepColdArchive');
|
||||
|
||||
const StorageClass(this.value);
|
||||
final String value;
|
||||
}
|
||||
|
||||
/// 文件上传数据源
|
||||
abstract class UploadDataSource {
|
||||
/// 获取文件大小
|
||||
int get size;
|
||||
|
||||
/// 获取文件名
|
||||
String get fileName;
|
||||
|
||||
/// 获取MIME类型
|
||||
String? get mimeType;
|
||||
|
||||
/// 获取文件路径(如果可用)
|
||||
String? get filePath;
|
||||
|
||||
/// 读取文件数据
|
||||
Stream<Uint8List> openRead([int? start, int? end]);
|
||||
|
||||
/// 读取所有数据
|
||||
Future<Uint8List> readAsBytes();
|
||||
|
||||
/// 从文件路径创建数据源
|
||||
static FilePathDataSource fromFilePath(String filePath,
|
||||
{String? fileName, String? mimeType}) {
|
||||
return FilePathDataSource(filePath, fileName: fileName, mimeType: mimeType);
|
||||
}
|
||||
|
||||
/// 从字节数组创建数据源
|
||||
static BytesDataSource fromBytes(Uint8List bytes, String fileName,
|
||||
{String? mimeType}) {
|
||||
return BytesDataSource(bytes, fileName, mimeType: mimeType);
|
||||
}
|
||||
}
|
||||
|
||||
/// 字节数组数据源
|
||||
class BytesDataSource implements UploadDataSource {
|
||||
final Uint8List _bytes;
|
||||
final String _fileName;
|
||||
final String? _mimeType;
|
||||
|
||||
BytesDataSource(this._bytes, this._fileName, {String? mimeType})
|
||||
: _mimeType = mimeType;
|
||||
|
||||
@override
|
||||
int get size => _bytes.length;
|
||||
|
||||
@override
|
||||
String get fileName => _fileName;
|
||||
|
||||
@override
|
||||
String? get mimeType => _mimeType;
|
||||
|
||||
@override
|
||||
String? get filePath => null; // 字节数组数据源没有文件路径
|
||||
|
||||
@override
|
||||
Stream<Uint8List> openRead([int? start, int? end]) async* {
|
||||
final startIndex = start ?? 0;
|
||||
final endIndex = end ?? _bytes.length;
|
||||
yield _bytes.sublist(startIndex, endIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List> readAsBytes() async => _bytes;
|
||||
}
|
||||
|
||||
/// 文件路径数据源
|
||||
class FilePathDataSource implements UploadDataSource {
|
||||
final String _filePath;
|
||||
final String? _fileName;
|
||||
final String? _mimeType;
|
||||
int? _cachedSize;
|
||||
|
||||
FilePathDataSource(this._filePath, {String? fileName, String? mimeType})
|
||||
: _fileName = fileName,
|
||||
_mimeType = mimeType;
|
||||
|
||||
@override
|
||||
int get size {
|
||||
if (_cachedSize == null) {
|
||||
try {
|
||||
final file = File(_filePath);
|
||||
_cachedSize = file.lengthSync();
|
||||
} catch (e) {
|
||||
_cachedSize = 0;
|
||||
}
|
||||
}
|
||||
return _cachedSize!;
|
||||
}
|
||||
|
||||
@override
|
||||
String get fileName => _fileName ?? _filePath.split('/').last;
|
||||
|
||||
@override
|
||||
String? get mimeType => _mimeType;
|
||||
|
||||
@override
|
||||
String? get filePath => _filePath;
|
||||
|
||||
@override
|
||||
Stream<Uint8List> openRead([int? start, int? end]) async* {
|
||||
final file = File(_filePath);
|
||||
final stream = file.openRead(start, end);
|
||||
await for (final chunk in stream) {
|
||||
yield Uint8List.fromList(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Uint8List> readAsBytes() async {
|
||||
final file = File(_filePath);
|
||||
return await file.readAsBytes();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
/// 上传结果类
|
||||
class UploadResult {
|
||||
/// 文件的OSS URL
|
||||
final String url;
|
||||
|
||||
/// 文件的完整路径(包含bucket和目录)
|
||||
final String objectKey;
|
||||
|
||||
/// 文件大小(字节)
|
||||
final int fileSize;
|
||||
|
||||
/// 文件的ETag
|
||||
final String? etag;
|
||||
|
||||
/// 上传耗时(毫秒)
|
||||
final int uploadDuration;
|
||||
|
||||
/// 文件的MIME类型
|
||||
final String? mimeType;
|
||||
|
||||
/// 文件的MD5哈希值
|
||||
final String? md5Hash;
|
||||
|
||||
/// 上传时间
|
||||
final DateTime uploadTime;
|
||||
|
||||
/// 额外的元数据
|
||||
final Map<String, String>? metadata;
|
||||
|
||||
const UploadResult({
|
||||
required this.url,
|
||||
required this.objectKey,
|
||||
required this.fileSize,
|
||||
required this.uploadDuration,
|
||||
required this.uploadTime,
|
||||
this.etag,
|
||||
this.mimeType,
|
||||
this.md5Hash,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
/// 从响应头创建上传结果
|
||||
factory UploadResult.fromResponse({
|
||||
required String url,
|
||||
required String objectKey,
|
||||
required int fileSize,
|
||||
required int uploadDuration,
|
||||
Map<String, dynamic>? headers,
|
||||
String? mimeType,
|
||||
String? md5Hash,
|
||||
Map<String, String>? metadata,
|
||||
}) {
|
||||
String? etag;
|
||||
if (headers != null) {
|
||||
// 从响应头中提取ETag
|
||||
final etagHeader = headers['etag'] ?? headers['ETag'];
|
||||
if (etagHeader is List && etagHeader.isNotEmpty) {
|
||||
etag = etagHeader.first.toString().replaceAll('"', '');
|
||||
} else if (etagHeader is String) {
|
||||
etag = etagHeader.replaceAll('"', '');
|
||||
}
|
||||
}
|
||||
|
||||
return UploadResult(
|
||||
url: url,
|
||||
objectKey: objectKey,
|
||||
fileSize: fileSize,
|
||||
uploadDuration: uploadDuration,
|
||||
uploadTime: DateTime.now(),
|
||||
etag: etag,
|
||||
mimeType: mimeType,
|
||||
md5Hash: md5Hash,
|
||||
metadata: metadata,
|
||||
);
|
||||
}
|
||||
|
||||
/// 转换为JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'url': url,
|
||||
'objectKey': objectKey,
|
||||
'fileSize': fileSize,
|
||||
'uploadDuration': uploadDuration,
|
||||
'uploadTime': uploadTime.toIso8601String(),
|
||||
if (etag != null) 'etag': etag,
|
||||
if (mimeType != null) 'mimeType': mimeType,
|
||||
if (md5Hash != null) 'md5Hash': md5Hash,
|
||||
if (metadata != null) 'metadata': metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/// 从JSON创建上传结果
|
||||
factory UploadResult.fromJson(Map<String, dynamic> json) {
|
||||
return UploadResult(
|
||||
url: json['url'] as String,
|
||||
objectKey: json['objectKey'] as String,
|
||||
fileSize: json['fileSize'] as int,
|
||||
uploadDuration: json['uploadDuration'] as int,
|
||||
uploadTime: DateTime.parse(json['uploadTime'] as String),
|
||||
etag: json['etag'] as String?,
|
||||
mimeType: json['mimeType'] as String?,
|
||||
md5Hash: json['md5Hash'] as String?,
|
||||
metadata:
|
||||
(json['metadata'] as Map<String, dynamic>?)?.cast<String, String>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UploadResult(url: $url, objectKey: $objectKey, fileSize: $fileSize, uploadDuration: ${uploadDuration}ms)';
|
||||
}
|
||||
}
|
||||
|
||||
/// 分片上传结果
|
||||
class MultipartUploadResult extends UploadResult {
|
||||
/// 分片数量
|
||||
final int partCount;
|
||||
|
||||
/// 上传ID
|
||||
final String uploadId;
|
||||
|
||||
/// 每个分片的信息
|
||||
final List<PartInfo> parts;
|
||||
|
||||
const MultipartUploadResult({
|
||||
required super.url,
|
||||
required super.objectKey,
|
||||
required super.fileSize,
|
||||
required super.uploadDuration,
|
||||
required super.uploadTime,
|
||||
required this.partCount,
|
||||
required this.uploadId,
|
||||
required this.parts,
|
||||
super.etag,
|
||||
super.mimeType,
|
||||
super.md5Hash,
|
||||
super.metadata,
|
||||
});
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = super.toJson();
|
||||
json.addAll({
|
||||
'partCount': partCount,
|
||||
'uploadId': uploadId,
|
||||
'parts': parts.map((part) => part.toJson()).toList(),
|
||||
});
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
/// 分片信息
|
||||
class PartInfo {
|
||||
/// 分片编号
|
||||
final int partNumber;
|
||||
|
||||
/// 分片的ETag
|
||||
final String etag;
|
||||
|
||||
/// 分片大小
|
||||
final int size;
|
||||
|
||||
/// 分片上传耗时(毫秒)
|
||||
final int uploadDuration;
|
||||
|
||||
const PartInfo({
|
||||
required this.partNumber,
|
||||
required this.etag,
|
||||
required this.size,
|
||||
required this.uploadDuration,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'partNumber': partNumber,
|
||||
'etag': etag,
|
||||
'size': size,
|
||||
'uploadDuration': uploadDuration,
|
||||
};
|
||||
}
|
||||
|
||||
factory PartInfo.fromJson(Map<String, dynamic> json) {
|
||||
return PartInfo(
|
||||
partNumber: json['partNumber'] as int,
|
||||
etag: json['etag'] as String,
|
||||
size: json['size'] as int,
|
||||
uploadDuration: json['uploadDuration'] as int,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 断点续传信息
|
||||
class ResumeUploadInfo {
|
||||
/// 上传任务ID
|
||||
final String taskId;
|
||||
|
||||
/// 文件路径
|
||||
final String filePath;
|
||||
|
||||
/// 对象键(OSS中的文件路径)
|
||||
final String objectKey;
|
||||
|
||||
/// 上传ID(分片上传的唯一标识)
|
||||
final String uploadId;
|
||||
|
||||
/// 文件总大小
|
||||
final int totalSize;
|
||||
|
||||
/// 分片大小
|
||||
final int partSize;
|
||||
|
||||
/// 已完成的分片列表
|
||||
final List<PartInfo> completedParts;
|
||||
|
||||
/// 创建时间
|
||||
final DateTime createdAt;
|
||||
|
||||
/// 最后更新时间
|
||||
final DateTime updatedAt;
|
||||
|
||||
/// 上传配置(序列化存储)
|
||||
final Map<String, dynamic> uploadOptions;
|
||||
|
||||
const ResumeUploadInfo({
|
||||
required this.taskId,
|
||||
required this.filePath,
|
||||
required this.objectKey,
|
||||
required this.uploadId,
|
||||
required this.totalSize,
|
||||
required this.partSize,
|
||||
required this.completedParts,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.uploadOptions,
|
||||
});
|
||||
|
||||
/// 计算已上传的大小
|
||||
int get uploadedSize {
|
||||
return completedParts.fold<int>(0, (sum, part) => sum + part.size);
|
||||
}
|
||||
|
||||
/// 计算上传进度(0.0 - 1.0)
|
||||
double get progress {
|
||||
if (totalSize == 0) return 0.0;
|
||||
return uploadedSize / totalSize;
|
||||
}
|
||||
|
||||
/// 获取下一个需要上传的分片编号
|
||||
int get nextPartNumber {
|
||||
if (completedParts.isEmpty) return 1;
|
||||
final maxPartNumber =
|
||||
completedParts.map((p) => p.partNumber).reduce((a, b) => a > b ? a : b);
|
||||
return maxPartNumber + 1;
|
||||
}
|
||||
|
||||
/// 检查指定分片是否已完成
|
||||
bool isPartCompleted(int partNumber) {
|
||||
return completedParts.any((part) => part.partNumber == partNumber);
|
||||
}
|
||||
|
||||
/// 添加已完成的分片
|
||||
ResumeUploadInfo addCompletedPart(PartInfo partInfo) {
|
||||
final updatedParts = List<PartInfo>.from(completedParts);
|
||||
|
||||
// 避免重复添加同一分片
|
||||
updatedParts.removeWhere((part) => part.partNumber == partInfo.partNumber);
|
||||
updatedParts.add(partInfo);
|
||||
updatedParts.sort((a, b) => a.partNumber.compareTo(b.partNumber));
|
||||
|
||||
return ResumeUploadInfo(
|
||||
taskId: taskId,
|
||||
filePath: filePath,
|
||||
objectKey: objectKey,
|
||||
uploadId: uploadId,
|
||||
totalSize: totalSize,
|
||||
partSize: partSize,
|
||||
completedParts: updatedParts,
|
||||
createdAt: createdAt,
|
||||
updatedAt: DateTime.now(),
|
||||
uploadOptions: uploadOptions,
|
||||
);
|
||||
}
|
||||
|
||||
/// 转换为JSON(用于持久化存储)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'taskId': taskId,
|
||||
'filePath': filePath,
|
||||
'objectKey': objectKey,
|
||||
'uploadId': uploadId,
|
||||
'totalSize': totalSize,
|
||||
'partSize': partSize,
|
||||
'completedParts': completedParts.map((part) => part.toJson()).toList(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
'uploadOptions': uploadOptions,
|
||||
};
|
||||
}
|
||||
|
||||
/// 从JSON创建实例(用于持久化恢复)
|
||||
factory ResumeUploadInfo.fromJson(Map<String, dynamic> json) {
|
||||
return ResumeUploadInfo(
|
||||
taskId: json['taskId'] as String,
|
||||
filePath: json['filePath'] as String,
|
||||
objectKey: json['objectKey'] as String,
|
||||
uploadId: json['uploadId'] as String,
|
||||
totalSize: json['totalSize'] as int,
|
||||
partSize: json['partSize'] as int,
|
||||
completedParts: (json['completedParts'] as List)
|
||||
.map((part) => PartInfo.fromJson(part as Map<String, dynamic>))
|
||||
.toList(),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
uploadOptions: json['uploadOptions'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ResumeUploadInfo(taskId: $taskId, objectKey: $objectKey, progress: ${(progress * 100).toStringAsFixed(1)}%)';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
import 'package:mime/mime.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// 文件工具类
|
||||
class YxOSSFileUtils {
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// 生成唯一文件名
|
||||
///
|
||||
/// [originalFileName] 原始文件名
|
||||
/// [preserveExtension] 是否保留原始扩展名
|
||||
/// [prefix] 文件名前缀
|
||||
/// [suffix] 文件名后缀(不包含扩展名)
|
||||
static String generateUniqueFileName({
|
||||
String? originalFileName,
|
||||
bool preserveExtension = true,
|
||||
String? prefix,
|
||||
String? suffix,
|
||||
}) {
|
||||
final uuid = _uuid.v4().replaceAll('-', '');
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
String fileName = '${prefix ?? ''}${timestamp}_$uuid${suffix ?? ''}';
|
||||
|
||||
if (preserveExtension && originalFileName != null) {
|
||||
final extension = getFileExtension(originalFileName);
|
||||
if (extension.isNotEmpty) {
|
||||
fileName += '.$extension';
|
||||
}
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
/// 获取文件扩展名
|
||||
///
|
||||
/// [fileName] 文件名或文件路径
|
||||
static String getFileExtension(String fileName) {
|
||||
final lastDotIndex = fileName.lastIndexOf('.');
|
||||
if (lastDotIndex == -1 || lastDotIndex == fileName.length - 1) {
|
||||
return '';
|
||||
}
|
||||
return fileName.substring(lastDotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/// 获取不带扩展名的文件名
|
||||
///
|
||||
/// [fileName] 文件名或文件路径
|
||||
static String getFileNameWithoutExtension(String fileName) {
|
||||
final lastDotIndex = fileName.lastIndexOf('.');
|
||||
final lastSlashIndex = fileName.lastIndexOf('/');
|
||||
final lastBackslashIndex = fileName.lastIndexOf('\\');
|
||||
|
||||
final nameStartIndex = [lastSlashIndex, lastBackslashIndex]
|
||||
.where((index) => index != -1)
|
||||
.fold(-1, (prev, current) => prev > current ? prev : current) +
|
||||
1;
|
||||
|
||||
final nameEndIndex = lastDotIndex == -1 ? fileName.length : lastDotIndex;
|
||||
|
||||
return fileName.substring(nameStartIndex, nameEndIndex);
|
||||
}
|
||||
|
||||
/// 根据文件扩展名猜测MIME类型
|
||||
///
|
||||
/// [fileName] 文件名或文件路径
|
||||
static String? guessMimeType(String fileName) {
|
||||
return lookupMimeType(fileName);
|
||||
}
|
||||
|
||||
/// 根据MIME类型判断是否为图片文件
|
||||
///
|
||||
/// [mimeType] MIME类型
|
||||
static bool isImageMimeType(String? mimeType) {
|
||||
if (mimeType == null) return false;
|
||||
return mimeType.startsWith('image/');
|
||||
}
|
||||
|
||||
/// 根据文件扩展名判断是否为图片文件
|
||||
///
|
||||
/// [fileName] 文件名或文件路径
|
||||
static bool isImageFile(String fileName) {
|
||||
final extension = getFileExtension(fileName);
|
||||
const imageExtensions = {
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'bmp',
|
||||
'webp',
|
||||
'svg',
|
||||
'ico',
|
||||
'tiff',
|
||||
'tif'
|
||||
};
|
||||
return imageExtensions.contains(extension);
|
||||
}
|
||||
|
||||
/// 根据MIME类型判断是否为视频文件
|
||||
///
|
||||
/// [mimeType] MIME类型
|
||||
static bool isVideoMimeType(String? mimeType) {
|
||||
if (mimeType == null) return false;
|
||||
return mimeType.startsWith('video/');
|
||||
}
|
||||
|
||||
/// 根据文件扩展名判断是否为视频文件
|
||||
///
|
||||
/// [fileName] 文件名或文件路径
|
||||
static bool isVideoFile(String fileName) {
|
||||
final extension = getFileExtension(fileName);
|
||||
const videoExtensions = {
|
||||
'mp4',
|
||||
'avi',
|
||||
'mov',
|
||||
'wmv',
|
||||
'flv',
|
||||
'webm',
|
||||
'mkv',
|
||||
'm4v',
|
||||
'3gp',
|
||||
'rmvb'
|
||||
};
|
||||
return videoExtensions.contains(extension);
|
||||
}
|
||||
|
||||
/// 根据MIME类型判断是否为音频文件
|
||||
///
|
||||
/// [mimeType] MIME类型
|
||||
static bool isAudioMimeType(String? mimeType) {
|
||||
if (mimeType == null) return false;
|
||||
return mimeType.startsWith('audio/');
|
||||
}
|
||||
|
||||
/// 根据文件扩展名判断是否为音频文件
|
||||
///
|
||||
/// [fileName] 文件名或文件路径
|
||||
static bool isAudioFile(String fileName) {
|
||||
final extension = getFileExtension(fileName);
|
||||
const audioExtensions = {
|
||||
'mp3',
|
||||
'wav',
|
||||
'flac',
|
||||
'aac',
|
||||
'ogg',
|
||||
'wma',
|
||||
'm4a',
|
||||
'amr',
|
||||
'ape'
|
||||
};
|
||||
return audioExtensions.contains(extension);
|
||||
}
|
||||
|
||||
/// 根据文件扩展名判断是否为文档文件
|
||||
///
|
||||
/// [fileName] 文件名或文件路径
|
||||
static bool isDocumentFile(String fileName) {
|
||||
final extension = getFileExtension(fileName);
|
||||
const documentExtensions = {
|
||||
'pdf',
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'txt',
|
||||
'rtf',
|
||||
'odt'
|
||||
};
|
||||
return documentExtensions.contains(extension);
|
||||
}
|
||||
|
||||
/// 根据文件扩展名判断是否为压缩文件
|
||||
///
|
||||
/// [fileName] 文件名或文件路径
|
||||
static bool isArchiveFile(String fileName) {
|
||||
final extension = getFileExtension(fileName);
|
||||
const archiveExtensions = {
|
||||
'zip',
|
||||
'rar',
|
||||
'7z',
|
||||
'tar',
|
||||
'gz',
|
||||
'bz2',
|
||||
'xz',
|
||||
'tar.gz',
|
||||
'tar.bz2'
|
||||
};
|
||||
return archiveExtensions.contains(extension);
|
||||
}
|
||||
|
||||
/// 格式化文件大小
|
||||
///
|
||||
/// [bytes] 文件大小(字节)
|
||||
/// [decimals] 小数位数
|
||||
static String formatFileSize(int bytes, {int decimals = 1}) {
|
||||
if (bytes <= 0) return '0 B';
|
||||
|
||||
const suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
final i = (bytes.bitLength - 1) ~/ 10;
|
||||
final size = bytes / (1 << (i * 10));
|
||||
|
||||
return '${size.toStringAsFixed(decimals)} ${suffixes[i]}';
|
||||
}
|
||||
|
||||
/// 解析文件大小字符串
|
||||
///
|
||||
/// [sizeString] 文件大小字符串(如 "1.5 MB")
|
||||
static int? parseFileSize(String sizeString) {
|
||||
final regex =
|
||||
RegExp(r'^(\d+(?:\.\d+)?)\s*([KMGTPE]?B?)$', caseSensitive: false);
|
||||
final match = regex.firstMatch(sizeString.trim());
|
||||
|
||||
if (match == null) return null;
|
||||
|
||||
final value = double.tryParse(match.group(1)!);
|
||||
if (value == null) return null;
|
||||
|
||||
final unit = match.group(2)?.toUpperCase() ?? 'B';
|
||||
|
||||
const multipliers = {
|
||||
'B': 1,
|
||||
'KB': 1024,
|
||||
'MB': 1024 * 1024,
|
||||
'GB': 1024 * 1024 * 1024,
|
||||
'TB': 1024 * 1024 * 1024 * 1024,
|
||||
'PB': 1024 * 1024 * 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
final multiplier = multipliers[unit] ?? 1;
|
||||
return (value * multiplier).round();
|
||||
}
|
||||
|
||||
/// 计算分片信息
|
||||
///
|
||||
/// [fileSize] 文件大小
|
||||
/// [partSize] 分片大小
|
||||
static List<PartRange> calculatePartRanges(int fileSize, int partSize) {
|
||||
if (fileSize <= 0 || partSize <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final parts = <PartRange>[];
|
||||
final totalParts = (fileSize / partSize).ceil();
|
||||
|
||||
for (int i = 0; i < totalParts; i++) {
|
||||
final start = i * partSize;
|
||||
final end = (i == totalParts - 1) ? fileSize - 1 : start + partSize - 1;
|
||||
|
||||
parts.add(PartRange(
|
||||
partNumber: i + 1,
|
||||
start: start,
|
||||
end: end,
|
||||
size: end - start + 1,
|
||||
));
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/// 验证文件名是否合法
|
||||
///
|
||||
/// [fileName] 文件名
|
||||
static bool isValidFileName(String fileName) {
|
||||
if (fileName.isEmpty || fileName.length > 255) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查非法字符
|
||||
const invalidChars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
|
||||
for (final char in invalidChars) {
|
||||
if (fileName.contains(char)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查保留名称(Windows)
|
||||
const reservedNames = [
|
||||
'CON',
|
||||
'PRN',
|
||||
'AUX',
|
||||
'NUL',
|
||||
'COM1',
|
||||
'COM2',
|
||||
'COM3',
|
||||
'COM4',
|
||||
'COM5',
|
||||
'COM6',
|
||||
'COM7',
|
||||
'COM8',
|
||||
'COM9',
|
||||
'LPT1',
|
||||
'LPT2',
|
||||
'LPT3',
|
||||
'LPT4',
|
||||
'LPT5',
|
||||
'LPT6',
|
||||
'LPT7',
|
||||
'LPT8',
|
||||
'LPT9'
|
||||
];
|
||||
|
||||
final nameWithoutExt = getFileNameWithoutExtension(fileName).toUpperCase();
|
||||
if (reservedNames.contains(nameWithoutExt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否以点或空格结尾
|
||||
if (fileName.endsWith('.') || fileName.endsWith(' ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 验证OSS对象键是否合法
|
||||
///
|
||||
/// [objectKey] OSS对象键(可以包含路径分隔符)
|
||||
static bool isValidObjectKey(String objectKey) {
|
||||
if (objectKey.isEmpty || objectKey.length > 1024) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// OSS对象键的非法字符(比文件名更宽松,允许路径分隔符)
|
||||
const invalidChars = ['\\', ':', '*', '?', '"', '<', '>', '|'];
|
||||
for (final char in invalidChars) {
|
||||
if (objectKey.contains(char)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否以斜杠开头(OSS不允许)
|
||||
if (objectKey.startsWith('/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否包含连续的斜杠
|
||||
if (objectKey.contains('//')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查每个路径段是否合法
|
||||
final segments = objectKey.split('/');
|
||||
for (final segment in segments) {
|
||||
if (segment.isEmpty) continue; // 允许空段(如末尾的斜杠)
|
||||
|
||||
// 检查段是否以点或空格开头/结尾
|
||||
if (segment.startsWith('.') ||
|
||||
segment.endsWith('.') ||
|
||||
segment.startsWith(' ') ||
|
||||
segment.endsWith(' ')) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 清理文件名,移除非法字符
|
||||
///
|
||||
/// [fileName] 原始文件名
|
||||
/// [replacement] 替换字符
|
||||
static String sanitizeFileName(String fileName, {String replacement = '_'}) {
|
||||
if (fileName.isEmpty) return fileName;
|
||||
|
||||
// 替换非法字符
|
||||
const invalidChars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
|
||||
String sanitized = fileName;
|
||||
for (final char in invalidChars) {
|
||||
sanitized = sanitized.replaceAll(char, replacement);
|
||||
}
|
||||
|
||||
// 移除开头和结尾的空格和点
|
||||
sanitized = sanitized.trim().replaceAll(RegExp(r'^\.+|\.+$'), '');
|
||||
|
||||
// 确保不超过最大长度
|
||||
if (sanitized.length > 255) {
|
||||
final extension = getFileExtension(sanitized);
|
||||
final maxNameLength =
|
||||
255 - (extension.isEmpty ? 0 : extension.length + 1);
|
||||
final nameWithoutExt = getFileNameWithoutExtension(sanitized);
|
||||
sanitized = nameWithoutExt.substring(0, maxNameLength);
|
||||
if (extension.isNotEmpty) {
|
||||
sanitized += '.$extension';
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized.isEmpty ? 'file' : sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
/// 分片范围信息
|
||||
class PartRange {
|
||||
/// 分片编号(从1开始)
|
||||
final int partNumber;
|
||||
|
||||
/// 起始位置(包含)
|
||||
final int start;
|
||||
|
||||
/// 结束位置(包含)
|
||||
final int end;
|
||||
|
||||
/// 分片大小
|
||||
final int size;
|
||||
|
||||
const PartRange({
|
||||
required this.partNumber,
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PartRange(partNumber: $partNumber, start: $start, end: $end, size: $size)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
return other is PartRange &&
|
||||
other.partNumber == partNumber &&
|
||||
other.start == start &&
|
||||
other.end == end &&
|
||||
other.size == size;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return partNumber.hashCode ^ start.hashCode ^ end.hashCode ^ size.hashCode;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../core/yx_oss_config.dart';
|
||||
import '../models/oss_error.dart';
|
||||
|
||||
/// HTTP工具类
|
||||
class HttpUtils {
|
||||
/// 创建Dio实例
|
||||
static Dio createDio({
|
||||
TimeoutConfig? timeoutConfig,
|
||||
LogConfig? logConfig,
|
||||
Map<String, String>? defaultHeaders,
|
||||
}) {
|
||||
final dio = Dio(BaseOptions(
|
||||
connectTimeout:
|
||||
Duration(milliseconds: timeoutConfig?.connectTimeout ?? 60000),
|
||||
sendTimeout: Duration(milliseconds: timeoutConfig?.sendTimeout ?? 300000),
|
||||
receiveTimeout:
|
||||
Duration(milliseconds: timeoutConfig?.receiveTimeout ?? 300000),
|
||||
headers: defaultHeaders,
|
||||
responseType: ResponseType.bytes,
|
||||
));
|
||||
|
||||
// 添加日志拦截器
|
||||
if (logConfig?.enabled == true) {
|
||||
dio.interceptors.add(_createLogInterceptor(logConfig!));
|
||||
}
|
||||
|
||||
// 添加重试拦截器
|
||||
dio.interceptors.add(_RetryInterceptor());
|
||||
|
||||
// 添加错误处理拦截器
|
||||
dio.interceptors.add(_ErrorInterceptor());
|
||||
|
||||
return dio;
|
||||
}
|
||||
|
||||
/// 创建日志拦截器
|
||||
static Interceptor _createLogInterceptor(LogConfig config) {
|
||||
return InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
if (config.logRequest && config.level <= LogLevel.debug) {
|
||||
_log('HTTP Request: ${options.method} ${options.uri}', LogLevel.debug,
|
||||
config);
|
||||
if (options.headers.isNotEmpty) {
|
||||
_log('Request Headers: ${options.headers}', LogLevel.debug, config);
|
||||
}
|
||||
}
|
||||
handler.next(options);
|
||||
},
|
||||
onResponse: (response, handler) {
|
||||
if (config.logResponse && config.level <= LogLevel.debug) {
|
||||
_log(
|
||||
'HTTP Response: ${response.statusCode} ${response.statusMessage}',
|
||||
LogLevel.debug,
|
||||
config);
|
||||
if (response.headers.map.isNotEmpty) {
|
||||
_log('Response Headers: ${response.headers.map}', LogLevel.debug,
|
||||
config);
|
||||
}
|
||||
}
|
||||
handler.next(response);
|
||||
},
|
||||
onError: (error, handler) {
|
||||
if (config.logError && config.level <= LogLevel.error) {
|
||||
_log('HTTP Error: ${error.message}', LogLevel.error, config);
|
||||
if (error.response != null) {
|
||||
_log(
|
||||
'Error Response: ${error.response?.statusCode} ${error.response?.data}',
|
||||
LogLevel.error,
|
||||
config);
|
||||
}
|
||||
}
|
||||
handler.next(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 日志输出
|
||||
static void _log(String message, LogLevel level, LogConfig config) {
|
||||
if (config.customHandler != null) {
|
||||
config.customHandler!(message, level);
|
||||
} else {
|
||||
// 默认输出到控制台
|
||||
print('[YX_OSS] ${level.name.toUpperCase()}: $message');
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建Range头部
|
||||
static String buildRangeHeader(int start, int end) {
|
||||
return 'bytes=$start-$end';
|
||||
}
|
||||
|
||||
/// 解析Content-Range头部
|
||||
static ContentRange? parseContentRange(String? contentRangeHeader) {
|
||||
if (contentRangeHeader == null) return null;
|
||||
|
||||
final regex = RegExp(r'bytes (\d+)-(\d+)/(\d+|\*)');
|
||||
final match = regex.firstMatch(contentRangeHeader);
|
||||
|
||||
if (match == null) return null;
|
||||
|
||||
final start = int.parse(match.group(1)!);
|
||||
final end = int.parse(match.group(2)!);
|
||||
final totalString = match.group(3)!;
|
||||
final total = totalString == '*' ? null : int.parse(totalString);
|
||||
|
||||
return ContentRange(start: start, end: end, total: total);
|
||||
}
|
||||
|
||||
/// 构建分片上传的URL
|
||||
static String buildMultipartUploadUrl({
|
||||
required String endpoint,
|
||||
required String bucket,
|
||||
required String objectKey,
|
||||
String? uploadId,
|
||||
int? partNumber,
|
||||
}) {
|
||||
final uri = Uri.https('$bucket.$endpoint', '/$objectKey');
|
||||
final queryParams = <String, String>{};
|
||||
|
||||
if (uploadId != null) {
|
||||
queryParams['uploadId'] = uploadId;
|
||||
}
|
||||
if (partNumber != null) {
|
||||
queryParams['partNumber'] = partNumber.toString();
|
||||
}
|
||||
if (uploadId == null && partNumber == null) {
|
||||
queryParams['uploads'] = '';
|
||||
}
|
||||
|
||||
if (queryParams.isNotEmpty) {
|
||||
final queryString = queryParams.entries
|
||||
.map((e) => e.value.isEmpty ? e.key : '${e.key}=${e.value}')
|
||||
.join('&');
|
||||
return '${uri.toString()}?$queryString';
|
||||
}
|
||||
|
||||
return uri.toString();
|
||||
}
|
||||
|
||||
/// 从响应中提取ETag
|
||||
static String? extractETag(Response response) {
|
||||
final etag =
|
||||
response.headers.value('etag') ?? response.headers.value('ETag');
|
||||
return etag?.replaceAll('"', '');
|
||||
}
|
||||
|
||||
/// 从响应中提取请求ID
|
||||
static String? extractRequestId(Response response) {
|
||||
return response.headers.value('x-oss-request-id') ??
|
||||
response.headers.value('X-OSS-Request-ID');
|
||||
}
|
||||
|
||||
/// 检查HTTP状态码是否表示成功
|
||||
static bool isSuccessStatusCode(int? statusCode) {
|
||||
return statusCode != null && statusCode >= 200 && statusCode < 300;
|
||||
}
|
||||
|
||||
/// 检查是否为可重试的状态码
|
||||
static bool isRetryableStatusCode(int? statusCode) {
|
||||
if (statusCode == null) return false;
|
||||
|
||||
// 5xx 服务器错误通常可以重试
|
||||
if (statusCode >= 500 && statusCode < 600) return true;
|
||||
|
||||
// 一些特定的4xx错误也可以重试
|
||||
const retryable4xxCodes = {408, 409, 429}; // 超时、冲突、限流
|
||||
return retryable4xxCodes.contains(statusCode);
|
||||
}
|
||||
|
||||
/// 创建上传数据流
|
||||
static Stream<Uint8List> createUploadStream(
|
||||
Uint8List data, {
|
||||
int? start,
|
||||
int? end,
|
||||
int chunkSize = 8192,
|
||||
}) async* {
|
||||
final startIndex = start ?? 0;
|
||||
final endIndex = end ?? data.length;
|
||||
final length = endIndex - startIndex;
|
||||
|
||||
for (int i = 0; i < length; i += chunkSize) {
|
||||
final chunkEnd = (i + chunkSize > length) ? length : i + chunkSize;
|
||||
yield data.sublist(startIndex + i, startIndex + chunkEnd);
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算上传进度百分比
|
||||
static double calculateProgress(int sent, int total) {
|
||||
if (total <= 0) return 0.0;
|
||||
return (sent / total).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
/// 格式化传输速度
|
||||
static String formatTransferSpeed(int bytesPerSecond) {
|
||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||
double speed = bytesPerSecond.toDouble();
|
||||
int unitIndex = 0;
|
||||
|
||||
while (speed >= 1024 && unitIndex < units.length - 1) {
|
||||
speed /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return '${speed.toStringAsFixed(1)} ${units[unitIndex]}';
|
||||
}
|
||||
|
||||
/// 估算剩余时间
|
||||
static Duration? estimateRemainingTime(
|
||||
int sent, int total, int bytesPerSecond) {
|
||||
if (total <= sent || bytesPerSecond <= 0) return null;
|
||||
|
||||
final remaining = total - sent;
|
||||
final secondsRemaining = remaining / bytesPerSecond;
|
||||
|
||||
return Duration(seconds: secondsRemaining.round());
|
||||
}
|
||||
}
|
||||
|
||||
/// Content-Range解析结果
|
||||
class ContentRange {
|
||||
final int start;
|
||||
final int end;
|
||||
final int? total;
|
||||
|
||||
const ContentRange({
|
||||
required this.start,
|
||||
required this.end,
|
||||
this.total,
|
||||
});
|
||||
|
||||
int get size => end - start + 1;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ContentRange(start: $start, end: $end, total: $total)';
|
||||
}
|
||||
}
|
||||
|
||||
/// 重试拦截器
|
||||
class _RetryInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
final retryConfig = err.requestOptions.extra['retryConfig'] as RetryConfig?;
|
||||
|
||||
if (retryConfig == null || !_shouldRetry(err, retryConfig)) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
final retryAttempt =
|
||||
(err.requestOptions.extra['retryAttempt'] as int?) ?? 0;
|
||||
if (retryAttempt >= retryConfig.maxRetries) {
|
||||
handler.next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算重试延迟
|
||||
final delay = retryConfig.calculateInterval(retryAttempt);
|
||||
await Future.delayed(Duration(milliseconds: delay));
|
||||
|
||||
// 更新重试次数
|
||||
err.requestOptions.extra['retryAttempt'] = retryAttempt + 1;
|
||||
|
||||
try {
|
||||
final response = await Dio().fetch(err.requestOptions);
|
||||
handler.resolve(response);
|
||||
} catch (e) {
|
||||
if (e is DioException) {
|
||||
handler.next(e);
|
||||
} else {
|
||||
handler.next(DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
error: e,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldRetry(DioException error, RetryConfig config) {
|
||||
// 检查错误类型
|
||||
if (error.type == DioExceptionType.cancel) return false;
|
||||
|
||||
// 检查状态码
|
||||
final statusCode = error.response?.statusCode;
|
||||
if (statusCode != null && !HttpUtils.isRetryableStatusCode(statusCode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查错误类型是否在重试列表中
|
||||
if (config.retryableErrors.isNotEmpty) {
|
||||
return config.retryableErrors.contains(error.type.runtimeType);
|
||||
}
|
||||
|
||||
// 默认重试网络相关错误
|
||||
return error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.connectionError;
|
||||
}
|
||||
}
|
||||
|
||||
/// 错误处理拦截器
|
||||
class _ErrorInterceptor extends Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
final ossError = _convertToOSSError(err);
|
||||
|
||||
// 将OSS错误附加到原始错误中
|
||||
err.requestOptions.extra['ossError'] = ossError;
|
||||
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
OSSError _convertToOSSError(DioException error) {
|
||||
final statusCode = error.response?.statusCode;
|
||||
final requestId = HttpUtils.extractRequestId(error.response!);
|
||||
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
return OSSError.timeout('Connection timeout');
|
||||
|
||||
case DioExceptionType.sendTimeout:
|
||||
return OSSError.timeout('Send timeout');
|
||||
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return OSSError.timeout('Receive timeout');
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
return OSSError.network('Connection error: ${error.message}',
|
||||
originalException: error.error);
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return OSSError.cancelled('Request cancelled');
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
if (statusCode != null) {
|
||||
return OSSError.fromResponse(
|
||||
statusCode, error.response?.data?.toString(),
|
||||
requestId: requestId);
|
||||
}
|
||||
return OSSError.server('Bad response: ${error.message}');
|
||||
|
||||
default:
|
||||
return OSSError.unknown('Unknown error: ${error.message}',
|
||||
originalException: error.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import '../interfaces/resume_storage.dart';
|
||||
|
||||
// 注意:这个实现需要 path_provider 包
|
||||
// 在实际使用时需要在 pubspec.yaml 中添加 path_provider: ^2.1.0
|
||||
|
||||
/// 基于本地文件的断点续传存储实现
|
||||
class LocalResumeStorage implements ResumeStorage {
|
||||
static const String _storageDir = 'yx_oss_resume';
|
||||
static const String _fileExtension = '.json';
|
||||
|
||||
Directory? _cacheDirectory;
|
||||
|
||||
/// 获取缓存目录
|
||||
Future<Directory> get cacheDirectory async {
|
||||
if (_cacheDirectory != null) {
|
||||
return _cacheDirectory!;
|
||||
}
|
||||
|
||||
// 简化实现:使用临时目录,避免依赖 path_provider
|
||||
// 在实际集成时可以替换为 getApplicationDocumentsDirectory()
|
||||
final tempDir = Directory.systemTemp;
|
||||
_cacheDirectory = Directory('${tempDir.path}/$_storageDir');
|
||||
|
||||
// 确保目录存在
|
||||
if (!await _cacheDirectory!.exists()) {
|
||||
await _cacheDirectory!.create(recursive: true);
|
||||
}
|
||||
|
||||
return _cacheDirectory!;
|
||||
}
|
||||
|
||||
/// 获取任务文件路径
|
||||
Future<File> _getTaskFile(String taskId) async {
|
||||
final dir = await cacheDirectory;
|
||||
return File('${dir.path}/$taskId$_fileExtension');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveResumeInfo(
|
||||
String taskId, Map<String, dynamic> resumeInfo) async {
|
||||
try {
|
||||
final file = await _getTaskFile(taskId);
|
||||
final jsonString = jsonEncode(resumeInfo);
|
||||
await file.writeAsString(jsonString);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to save resume info for task $taskId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> loadResumeInfo(String taskId) async {
|
||||
try {
|
||||
final file = await _getTaskFile(taskId);
|
||||
|
||||
if (!await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final jsonString = await file.readAsString();
|
||||
return jsonDecode(jsonString) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
// 文件损坏或格式错误时删除文件
|
||||
try {
|
||||
final file = await _getTaskFile(taskId);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (_) {
|
||||
// 忽略删除错误
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteResumeInfo(String taskId) async {
|
||||
try {
|
||||
final file = await _getTaskFile(taskId);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略删除错误,可能文件已不存在
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<String>> getAllTaskIds() async {
|
||||
try {
|
||||
final dir = await cacheDirectory;
|
||||
if (!await dir.exists()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final files = await dir.list().toList();
|
||||
final taskIds = <String>[];
|
||||
|
||||
for (final file in files) {
|
||||
if (file is File && file.path.endsWith(_fileExtension)) {
|
||||
final fileName = file.path.split('/').last;
|
||||
final taskId =
|
||||
fileName.substring(0, fileName.length - _fileExtension.length);
|
||||
taskIds.add(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
return taskIds;
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
final dir = await cacheDirectory;
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
_cacheDirectory = null; // 重置缓存目录
|
||||
} catch (e) {
|
||||
// 忽略删除错误
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getStorageSize() async {
|
||||
try {
|
||||
final dir = await cacheDirectory;
|
||||
if (!await dir.exists()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int totalSize = 0;
|
||||
final files = await dir.list().toList();
|
||||
|
||||
for (final file in files) {
|
||||
if (file is File) {
|
||||
final stat = await file.stat();
|
||||
totalSize += stat.size;
|
||||
}
|
||||
}
|
||||
|
||||
return totalSize;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理过期的断点续传信息
|
||||
///
|
||||
/// [maxAge] 最大保留时间(天数)
|
||||
Future<void> cleanupExpiredTasks(int maxAge) async {
|
||||
try {
|
||||
final dir = await cacheDirectory;
|
||||
if (!await dir.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final cutoffTime = DateTime.now().subtract(Duration(days: maxAge));
|
||||
final files = await dir.list().toList();
|
||||
|
||||
for (final file in files) {
|
||||
if (file is File && file.path.endsWith(_fileExtension)) {
|
||||
final stat = await file.stat();
|
||||
if (stat.modified.isBefore(cutoffTime)) {
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {
|
||||
// 忽略删除错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略清理错误
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取任务的文件大小
|
||||
Future<int> getTaskFileSize(String taskId) async {
|
||||
try {
|
||||
final file = await _getTaskFile(taskId);
|
||||
if (await file.exists()) {
|
||||
final stat = await file.stat();
|
||||
return stat.size;
|
||||
}
|
||||
return 0;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import '../core/yx_oss_config.dart';
|
||||
|
||||
/// Lightweight logger that respects LogConfig and LogLevel
|
||||
class OssLogger {
|
||||
final LogConfig config;
|
||||
|
||||
const OssLogger(this.config);
|
||||
|
||||
void debug(String message) => _log(message, LogLevel.debug);
|
||||
void info(String message) => _log(message, LogLevel.info);
|
||||
void warn(String message) => _log(message, LogLevel.warning);
|
||||
void error(String message) => _log(message, LogLevel.error);
|
||||
|
||||
void _log(String message, LogLevel level) {
|
||||
if (!config.enabled) return;
|
||||
if (level < config.level) return;
|
||||
|
||||
if (config.customHandler != null) {
|
||||
config.customHandler!(message, level);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default console output
|
||||
// Keep a consistent tag for easy grep in app logs
|
||||
// Note: keep print for default fallback only
|
||||
// ignore: avoid_print
|
||||
print('[YX_OSS] ${level.name.toUpperCase()}: $message');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/// YX OSS Library
|
||||
///
|
||||
/// A pure OSS (Object Storage Service) client library for Flutter applications.
|
||||
/// Focused on core upload/download functionality with minimal dependencies.
|
||||
///
|
||||
/// Core Features:
|
||||
/// - Multiple authentication providers (Static, STS, PreSigned)
|
||||
/// - Resume upload support with local storage
|
||||
/// - Progress monitoring and cancellation
|
||||
/// - Retry mechanism with exponential backoff
|
||||
/// - Type-safe error handling
|
||||
/// - Clean architecture with dependency injection
|
||||
library;
|
||||
|
||||
// Adapters
|
||||
export 'src/adapters/project_integration_adapter.dart';
|
||||
// Core classes
|
||||
export 'src/core/yx_oss_client.dart';
|
||||
export 'src/core/yx_oss_config.dart';
|
||||
// Interfaces
|
||||
export 'src/interfaces/auth_provider.dart';
|
||||
export 'src/interfaces/config_provider.dart';
|
||||
export 'src/interfaces/resume_storage.dart';
|
||||
export 'src/interfaces/upload_callbacks.dart';
|
||||
export 'src/models/oss_error.dart';
|
||||
// Models
|
||||
export 'src/models/upload_options.dart';
|
||||
export 'src/models/upload_result.dart';
|
||||
// Utils
|
||||
export 'src/utils/file_utils.dart';
|
||||
export 'src/utils/local_resume_storage.dart';
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
crypto:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.6"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "5.9.0"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "10.0.9"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.9"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
meta:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
mime:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mime
|
||||
sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.6"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.18"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform
|
||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.6"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.10.1"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "0.7.4"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
uuid:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "15.0.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
sdks:
|
||||
dart: ">=3.7.0 <4.0.0"
|
||||
flutter: ">=3.29.0"
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
name: yx_oss
|
||||
description: A pure OSS client library focused on file upload/download with resume support, authentication providers, and minimal dependencies.
|
||||
version: 1.0.0
|
||||
homepage: https://github.com/yuanxuan/yx_oss
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
flutter: ">=3.0.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
dio: ^5.8.0
|
||||
crypto: ^3.0.3
|
||||
mime: ^1.0.4
|
||||
uuid: ^3.0.0
|
||||
meta: ^1.11.0
|
||||
path_provider: ^2.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^4.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: false
|
||||
Loading…
Reference in New Issue