commit 5794eb93f8f610b320a2abf74b18d981ceefa5cd Author: Max Date: Fri Sep 26 09:56:49 2025 +0800 Initial commit: YX OSS Flutter SDK diff --git a/.dart_tool/extension_discovery/README.md b/.dart_tool/extension_discovery/README.md new file mode 100644 index 0000000..9dc6757 --- /dev/null +++ b/.dart_tool/extension_discovery/README.md @@ -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. diff --git a/.dart_tool/extension_discovery/devtools.json b/.dart_tool/extension_discovery/devtools.json new file mode 100644 index 0000000..ee1b9fa --- /dev/null +++ b/.dart_tool/extension_discovery/devtools.json @@ -0,0 +1 @@ +{"version":2,"entries":[{"package":"yx_oss","rootUri":"../","packageUri":"lib/"}]} \ No newline at end of file diff --git a/.dart_tool/extension_discovery/vs_code.json b/.dart_tool/extension_discovery/vs_code.json new file mode 100644 index 0000000..ee1b9fa --- /dev/null +++ b/.dart_tool/extension_discovery/vs_code.json @@ -0,0 +1 @@ +{"version":2,"entries":[{"package":"yx_oss","rootUri":"../","packageUri":"lib/"}]} \ No newline at end of file diff --git a/.dart_tool/package_config.json b/.dart_tool/package_config.json new file mode 100644 index 0000000..366b549 --- /dev/null +++ b/.dart_tool/package_config.json @@ -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" +} diff --git a/.dart_tool/package_config_subset b/.dart_tool/package_config_subset new file mode 100644 index 0000000..2a93f98 --- /dev/null +++ b/.dart_tool/package_config_subset @@ -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 diff --git a/.dart_tool/package_graph.json b/.dart_tool/package_graph.json new file mode 100644 index 0000000..4dc0ccd --- /dev/null +++ b/.dart_tool/package_graph.json @@ -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 +} \ No newline at end of file diff --git a/.dart_tool/version b/.dart_tool/version new file mode 100644 index 0000000..40fc726 --- /dev/null +++ b/.dart_tool/version @@ -0,0 +1 @@ +3.32.0 \ No newline at end of file diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies new file mode 100644 index 0000000..095d124 --- /dev/null +++ b/.flutter-plugins-dependencies @@ -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}} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c1fa9a5 --- /dev/null +++ b/README.md @@ -0,0 +1,572 @@ +# YX OSS + +[![pub package](https://img.shields.io/pub/v/yx_oss.svg)](https://pub.dev/packages/yx_oss) +[![documentation](https://img.shields.io/badge/documentation-latest-brightgreen.svg)](https://pub.dev/documentation/yx_oss/latest/) +[![license](https://img.shields.io/badge/license-MIT-blue.svg)](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 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 getOssConfig() async { + // 从你的后端获取OSS配置 + final response = await http.get('/api/oss-config'); + return YxOSSConfigModel.fromJson(response.data); + } + + @override + Future 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 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 { + late final ProjectOSSManager ossManager; + + @override + void initState() { + super.initState(); + ossManager = ProjectOSSManager(MyOSSApiImpl()); + } + + Future 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 上传更简单、更可靠! 🚀 diff --git a/example/main.dart b/example/main.dart new file mode 100644 index 0000000..5605a14 --- /dev/null +++ b/example/main.dart @@ -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 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 example2STSAuth() async { + print('\n=== 示例2: STS临时凭证认证 ==='); + + // 模拟从服务器获取STS凭证的函数 + Future 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 example3PreSignedAuth() async { + print('\n=== 示例3: 预签名URL认证 ==='); + + // 模拟从服务器获取预签名URL的函数 + Future 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 example4DynamicConfig() async { + print('\n=== 示例4: 动态配置 ==='); + + // 模拟从服务器获取OSS配置的函数 + Future 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 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 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 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 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 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(); + } +} diff --git a/example/simple_example.dart b/example/simple_example.dart new file mode 100644 index 0000000..4b2d6b4 --- /dev/null +++ b/example/simple_example.dart @@ -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(); + } +} diff --git a/lib/src/adapters/project_integration_adapter.dart b/lib/src/adapters/project_integration_adapter.dart new file mode 100644 index 0000000..e10ac9d --- /dev/null +++ b/lib/src/adapters/project_integration_adapter.dart @@ -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 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 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 getOssConfig(); + + /// 获取OSS预签名 + Future getOssSign({String? objectName, int? type}); + + /// 删除OSS文件 + Future 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 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 initializeWithSTS({ + required String endpoint, + required String bucketName, + required STSCredentials stsCredentials, + required Future 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 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 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 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 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> uploadMultipleFiles( + List<(Uint8List, String)> files, { + Function(double progress)? onProgress, + Function(int fileIndex, double progress)? onSingleProgress, + String category = 'batch', + }) async { + _checkInitialized(); + + final results = []; + 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 _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 _getConfig() async { + if (_isCacheValid) { + return _cachedProjectConfig!; + } + + final config = await _api.getOssConfig(); + _cachedProjectConfig = config; + _cacheTime = DateTime.now(); + return config; + } + + @override + Future getEndpoint() async { + final config = await _getConfig(); + return config.endpoint ?? 'oss-cn-hangzhou.aliyuncs.com'; + } + + @override + Future getBucketName() async { + final config = await _getConfig(); + return config.bucketName ?? 'default-bucket'; + } + + @override + Future getDirectory() async { + // MyInfo API没有提供目录配置,使用默认值 + return 'app-uploads'; + } + + @override + Future getDomain() async { + // MyInfo API没有提供自定义域名配置 + return null; + } + + @override + Future 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 refresh() async { + clearCache(); + await _getConfig(); // 强制重新获取配置 + } + + /// 清除缓存(当需要强制刷新配置时) + void clearCache() { + _cachedProjectConfig = null; + _cacheTime = null; + } + + /// 获取文件大小限制 + Future getFileSizeLimit() async { + final config = await _getConfig(); + return config.size; + } +} + +/// 基于MyInfo API的动态认证提供者 +class MyInfoAuthProvider implements AuthProvider { + final MyInfoConfigProvider _configProvider; + + MyInfoAuthProvider(this._configProvider); + + @override + Future getAccessKeyId() async { + final config = await _configProvider._getConfig(); + return config.accessKeyId ?? ''; + } + + @override + Future getAccessKeySecret() async { + final config = await _configProvider._getConfig(); + return config.accessKeySecret ?? ''; + } + + @override + Future getSecurityToken() async { + // MyInfo API目前不支持STS Token + return null; + } + + @override + Future isValid() async { + try { + final accessKeyId = await getAccessKeyId(); + final accessKeySecret = await getAccessKeySecret(); + return accessKeyId.isNotEmpty && accessKeySecret.isNotEmpty; + } catch (e) { + return false; + } + } + + @override + Future refresh() async { + // 清除配置提供者的缓存,强制重新获取认证信息 + _configProvider.clearCache(); + } + + @override + Future 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 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 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 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 _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 _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 deleteFile(String fileUrl) async { + _ensureInitialized(); + + try { + return await _api.deleteOSSFile(filePath: fileUrl); + } catch (e) { + return false; + } + } + + /// 获取所有可恢复的上传任务 + Future> getResumableTasks() async { + _ensureInitialized(); + return await _client.getResumableTasks(); + } + + /// 恢复指定的上传任务 + Future resumeUpload(String taskId) async { + _ensureInitialized(); + final result = await _client.resumeUpload(taskId); + return result.url; + } + + /// 取消上传任务 + void cancelUpload(String taskId) { + _ensureInitialized(); + _client.cancelUpload(taskId); + } + + /// 清理过期的断点续传数据 + Future 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; + } + } +} diff --git a/lib/src/core/yx_oss_client.dart b/lib/src/core/yx_oss_client.dart new file mode 100644 index 0000000..bb8d17c --- /dev/null +++ b/lib/src/core/yx_oss_client.dart @@ -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 _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 initialize() async { + if (!await _config.validate()) { + throw error_model.OSSError.configuration('Invalid OSS configuration'); + } + } + + /// 上传文件(字节数组) + /// + /// [data] 文件数据 + /// [objectKey] 对象键(文件在OSS中的路径) + /// [options] 上传选项 + Future 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 uploadDataSource( + UploadDataSource dataSource, + String objectKey, { + UploadOptions? options, + }) async { + return _uploadDataSource( + dataSource, objectKey, options ?? const UploadOptions()); + } + + /// 内部上传实现 + Future _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 _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 _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 _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 = { + '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 _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 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 _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 = { + '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'([^<]+)').firstMatch(responseBody); + + if (uploadIdMatch == null) { + throw error_model.OSSError.server( + 'Failed to extract upload ID from response'); + } + + return uploadIdMatch.group(1)!; + } + + /// 上传分片 + Future> _uploadParts( + UploadDataSource dataSource, + String objectKey, + String uploadId, + List partRanges, + UploadOptions options, { + List existingParts = const [], + String? taskId, + result_model.ResumeUploadInfo? resumeInfo, + }) async { + final parts = []; + final semaphore = Semaphore(options.concurrency); + final futures = >[]; + + // 计算已上传的总大小(包括现有分片) + int totalSent = existingParts.fold(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 _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 = { + '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 _completeMultipartUpload( + UploadDataSource dataSource, + String objectKey, + String uploadId, + List 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(''); + for (final part in parts) { + buffer.writeln(''); + buffer.writeln('${part.partNumber}'); + buffer.writeln('${part.etag}'); + buffer.writeln(''); + } + buffer.writeln(''); + + final xmlData = buffer.toString(); + final xmlBytes = Uint8List.fromList(utf8.encode(xmlData)); + final contentMD5 = YxOSSSigner.calculateMD5(xmlBytes); + + final headers = { + '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(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 _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 = { + '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 _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 _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 _deleteResumeInfo(String taskId) async { + try { + await _resumeStorage.deleteResumeInfo(taskId); + } catch (e) { + // 删除失败可以忽略 + _logger.warn('Failed to delete resume info for task $taskId: $e'); + } + } + + /// 获取所有可恢复的上传任务 + Future> getResumableTasks() async { + try { + final taskIds = await _resumeStorage.getAllTaskIds(); + final tasks = []; + + for (final taskId in taskIds) { + final resumeInfo = await _loadResumeInfo(taskId); + if (resumeInfo != null) { + tasks.add(resumeInfo); + } + } + + return tasks; + } catch (e) { + return []; + } + } + + /// 恢复指定的上传任务 + Future 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 clearAllResumeData() async { + await _resumeStorage.clearAll(); + } + + /// 清理过期的断点续传数据 + Future cleanupExpiredResumeData({int maxAgeDays = 7}) async { + if (_resumeStorage is LocalResumeStorage) { + await (_resumeStorage as LocalResumeStorage) + .cleanupExpiredTasks(maxAgeDays); + } + } +} + +/// 信号量实现 +class Semaphore { + final int maxCount; + int _currentCount; + final Queue> _waitQueue = Queue>(); + + Semaphore(this.maxCount) : _currentCount = maxCount; + + Future acquire() async { + if (_currentCount > 0) { + _currentCount--; + return; + } + + final completer = Completer(); + _waitQueue.addLast(completer); + return completer.future; + } + + void release() { + if (_waitQueue.isNotEmpty) { + final completer = _waitQueue.removeFirst(); + completer.complete(); + } else { + _currentCount++; + } + } +} diff --git a/lib/src/core/yx_oss_config.dart b/lib/src/core/yx_oss_config.dart new file mode 100644 index 0000000..7cabc9c --- /dev/null +++ b/lib/src/core/yx_oss_config.dart @@ -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 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 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? 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'); +} diff --git a/lib/src/core/yx_oss_signer.dart b/lib/src/core/yx_oss_signer.dart new file mode 100644 index 0000000..dd1edcb --- /dev/null +++ b/lib/src/core/yx_oss_signer.dart @@ -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 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? params, + }) { + // 构建查询参数 + final queryParams = { + '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 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 headers, String? securityToken) { + final ossHeaders = {}; + + // 添加安全令牌头部 + 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 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 = {}; + 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); + } +} diff --git a/lib/src/interfaces/auth_provider.dart b/lib/src/interfaces/auth_provider.dart new file mode 100644 index 0000000..fe85978 --- /dev/null +++ b/lib/src/interfaces/auth_provider.dart @@ -0,0 +1,186 @@ +/// 认证提供者接口 +/// +/// 定义获取OSS认证信息的标准接口,支持多种认证方式: +/// - 静态AccessKey/SecretKey +/// - STS临时凭证 +/// - 预签名URL +/// - 自定义认证逻辑 +abstract class AuthProvider { + /// 获取AccessKey ID + Future getAccessKeyId(); + + /// 获取Access Key Secret + Future getAccessKeySecret(); + + /// 获取Security Token (STS模式) + /// 非STS模式时返回null + Future getSecurityToken(); + + /// 检查认证信息是否有效 + /// 返回true表示认证信息有效,false表示需要刷新 + Future isValid(); + + /// 刷新认证信息 + /// 当认证信息过期时调用此方法获取新的认证信息 + Future refresh(); + + /// 获取认证信息的过期时间 + /// 返回null表示永不过期 + Future getExpiration(); +} + +/// 静态认证提供者 +/// 使用固定的AccessKey和SecretKey +class StaticAuthProvider implements AuthProvider { + final String accessKeyId; + final String accessKeySecret; + + const StaticAuthProvider({ + required this.accessKeyId, + required this.accessKeySecret, + }); + + @override + Future getAccessKeyId() async => accessKeyId; + + @override + Future getAccessKeySecret() async => accessKeySecret; + + @override + Future getSecurityToken() async => null; + + @override + Future isValid() async => true; + + @override + Future refresh() async { + // 静态认证不需要刷新 + } + + @override + Future getExpiration() async => null; +} + +/// STS认证提供者 +/// 使用临时访问凭证 +class STSAuthProvider implements AuthProvider { + String _accessKeyId; + String _accessKeySecret; + String _securityToken; + DateTime _expiration; + final Future Function() _credentialsGetter; + + STSAuthProvider({ + required String accessKeyId, + required String accessKeySecret, + required String securityToken, + required DateTime expiration, + required Future Function() credentialsGetter, + }) : _accessKeyId = accessKeyId, + _accessKeySecret = accessKeySecret, + _securityToken = securityToken, + _expiration = expiration, + _credentialsGetter = credentialsGetter; + + @override + Future getAccessKeyId() async { + await _ensureValid(); + return _accessKeyId; + } + + @override + Future getAccessKeySecret() async { + await _ensureValid(); + return _accessKeySecret; + } + + @override + Future getSecurityToken() async { + await _ensureValid(); + return _securityToken; + } + + @override + Future isValid() async { + return DateTime.now() + .isBefore(_expiration.subtract(const Duration(minutes: 5))); + } + + @override + Future refresh() async { + final credentials = await _credentialsGetter(); + _accessKeyId = credentials.accessKeyId; + _accessKeySecret = credentials.accessKeySecret; + _securityToken = credentials.securityToken; + _expiration = credentials.expiration; + } + + @override + Future getExpiration() async => _expiration; + + /// 确保认证信息有效 + Future _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 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 Function(String fileName) _urlGetter; + + const PreSignedAuthProvider({ + required Future Function(String fileName) urlGetter, + }) : _urlGetter = urlGetter; + + /// 获取预签名URL + Future getPreSignedUrl(String fileName) => _urlGetter(fileName); + + @override + Future getAccessKeyId() async => throw UnsupportedError( + 'PreSignedAuthProvider does not support getAccessKeyId'); + + @override + Future getAccessKeySecret() async => throw UnsupportedError( + 'PreSignedAuthProvider does not support getAccessKeySecret'); + + @override + Future getSecurityToken() async => null; + + @override + Future isValid() async => true; + + @override + Future refresh() async { + // 预签名URL模式不需要刷新认证信息 + } + + @override + Future getExpiration() async => null; +} diff --git a/lib/src/interfaces/config_provider.dart b/lib/src/interfaces/config_provider.dart new file mode 100644 index 0000000..933592a --- /dev/null +++ b/lib/src/interfaces/config_provider.dart @@ -0,0 +1,150 @@ +/// OSS配置提供者接口 +/// +/// 定义获取OSS配置信息的标准接口,支持动态配置和静态配置 +abstract class ConfigProvider { + /// 获取OSS端点 + Future getEndpoint(); + + /// 获取存储桶名称 + Future getBucketName(); + + /// 获取上传目录前缀 + Future getDirectory(); + + /// 获取访问域名 + /// 如果为null,则使用默认的 https://{bucket}.{endpoint} + Future getDomain(); + + /// 获取配置信息是否有效 + Future isValid(); + + /// 刷新配置信息 + Future 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 getEndpoint() async => endpoint; + + @override + Future getBucketName() async => bucketName; + + @override + Future getDirectory() async => directory; + + @override + Future getDomain() async => domain; + + @override + Future isValid() async => true; + + @override + Future refresh() async { + // 静态配置不需要刷新 + } +} + +/// 动态配置提供者 +/// 支持从服务端动态获取配置信息 +class DynamicConfigProvider implements ConfigProvider { + OSSConfig? _config; + DateTime? _lastUpdate; + final Duration _cacheTimeout; + final Future Function() _configGetter; + + DynamicConfigProvider({ + required Future Function() configGetter, + Duration cacheTimeout = const Duration(hours: 1), + }) : _configGetter = configGetter, + _cacheTimeout = cacheTimeout; + + @override + Future getEndpoint() async { + await _ensureConfig(); + return _config!.endpoint; + } + + @override + Future getBucketName() async { + await _ensureConfig(); + return _config!.bucketName; + } + + @override + Future getDirectory() async { + await _ensureConfig(); + return _config!.directory; + } + + @override + Future getDomain() async { + await _ensureConfig(); + return _config!.domain; + } + + @override + Future isValid() async { + if (_config == null || _lastUpdate == null) return false; + return DateTime.now().difference(_lastUpdate!) < _cacheTimeout; + } + + @override + Future refresh() async { + _config = await _configGetter(); + _lastUpdate = DateTime.now(); + } + + /// 确保配置信息有效 + Future _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 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 toJson() { + return { + 'endpoint': endpoint, + 'bucketName': bucketName, + 'directory': directory, + if (domain != null) 'domain': domain, + }; + } +} diff --git a/lib/src/interfaces/resume_storage.dart b/lib/src/interfaces/resume_storage.dart new file mode 100644 index 0000000..1ff5cb3 --- /dev/null +++ b/lib/src/interfaces/resume_storage.dart @@ -0,0 +1,66 @@ +/// 断点续传存储接口 +/// +/// 提供断点续传信息的持久化存储功能 +abstract class ResumeStorage { + /// 保存断点续传信息 + /// + /// [taskId] 任务ID + /// [resumeInfo] 断点续传信息 + Future saveResumeInfo(String taskId, Map resumeInfo); + + /// 加载断点续传信息 + /// + /// [taskId] 任务ID + /// 返回断点续传信息,如果不存在则返回null + Future?> loadResumeInfo(String taskId); + + /// 删除断点续传信息 + /// + /// [taskId] 任务ID + Future deleteResumeInfo(String taskId); + + /// 获取所有断点续传任务ID + Future> getAllTaskIds(); + + /// 清空所有断点续传信息 + Future clearAll(); + + /// 获取存储大小(可选实现) + Future getStorageSize() async => 0; +} + +/// 基于内存的断点续传存储实现(仅用于测试) +class MemoryResumeStorage implements ResumeStorage { + final Map> _storage = {}; + + @override + Future saveResumeInfo( + String taskId, Map resumeInfo) async { + _storage[taskId] = Map.from(resumeInfo); + } + + @override + Future?> loadResumeInfo(String taskId) async { + return _storage[taskId]; + } + + @override + Future deleteResumeInfo(String taskId) async { + _storage.remove(taskId); + } + + @override + Future> getAllTaskIds() async { + return _storage.keys.toList(); + } + + @override + Future clearAll() async { + _storage.clear(); + } + + @override + Future getStorageSize() async { + return _storage.length; + } +} diff --git a/lib/src/interfaces/upload_callbacks.dart b/lib/src/interfaces/upload_callbacks.dart new file mode 100644 index 0000000..d7a94d0 --- /dev/null +++ b/lib/src/interfaces/upload_callbacks.dart @@ -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 的定义在各自的文件中 diff --git a/lib/src/models/oss_error.dart b/lib/src/models/oss_error.dart new file mode 100644 index 0000000..2b5139f --- /dev/null +++ b/lib/src/models/oss_error.dart @@ -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(); + } +} diff --git a/lib/src/models/upload_options.dart b/lib/src/models/upload_options.dart new file mode 100644 index 0000000..999c003 --- /dev/null +++ b/lib/src/models/upload_options.dart @@ -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? headers; + + /// 自定义元数据 + final Map? 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? headers, + Map? 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 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 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.from(json['headers'] as Map) + : null, + metadata: json['metadata'] != null + ? Map.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 openRead([int? start, int? end]); + + /// 读取所有数据 + Future 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 openRead([int? start, int? end]) async* { + final startIndex = start ?? 0; + final endIndex = end ?? _bytes.length; + yield _bytes.sublist(startIndex, endIndex); + } + + @override + Future 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 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 readAsBytes() async { + final file = File(_filePath); + return await file.readAsBytes(); + } +} diff --git a/lib/src/models/upload_result.dart b/lib/src/models/upload_result.dart new file mode 100644 index 0000000..ecfa342 --- /dev/null +++ b/lib/src/models/upload_result.dart @@ -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? 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? headers, + String? mimeType, + String? md5Hash, + Map? 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 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 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?)?.cast(), + ); + } + + @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 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 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 toJson() { + return { + 'partNumber': partNumber, + 'etag': etag, + 'size': size, + 'uploadDuration': uploadDuration, + }; + } + + factory PartInfo.fromJson(Map 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 completedParts; + + /// 创建时间 + final DateTime createdAt; + + /// 最后更新时间 + final DateTime updatedAt; + + /// 上传配置(序列化存储) + final Map 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(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.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 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 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)) + .toList(), + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + uploadOptions: json['uploadOptions'] as Map, + ); + } + + @override + String toString() { + return 'ResumeUploadInfo(taskId: $taskId, objectKey: $objectKey, progress: ${(progress * 100).toStringAsFixed(1)}%)'; + } +} diff --git a/lib/src/utils/file_utils.dart b/lib/src/utils/file_utils.dart new file mode 100644 index 0000000..bad485c --- /dev/null +++ b/lib/src/utils/file_utils.dart @@ -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 calculatePartRanges(int fileSize, int partSize) { + if (fileSize <= 0 || partSize <= 0) { + return []; + } + + final parts = []; + 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; + } +} diff --git a/lib/src/utils/http_utils.dart b/lib/src/utils/http_utils.dart new file mode 100644 index 0000000..7800f5d --- /dev/null +++ b/lib/src/utils/http_utils.dart @@ -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? 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 = {}; + + 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 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); + } + } +} diff --git a/lib/src/utils/local_resume_storage.dart b/lib/src/utils/local_resume_storage.dart new file mode 100644 index 0000000..a153ac8 --- /dev/null +++ b/lib/src/utils/local_resume_storage.dart @@ -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 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 _getTaskFile(String taskId) async { + final dir = await cacheDirectory; + return File('${dir.path}/$taskId$_fileExtension'); + } + + @override + Future saveResumeInfo( + String taskId, Map 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?> 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; + } catch (e) { + // 文件损坏或格式错误时删除文件 + try { + final file = await _getTaskFile(taskId); + if (await file.exists()) { + await file.delete(); + } + } catch (_) { + // 忽略删除错误 + } + return null; + } + } + + @override + Future deleteResumeInfo(String taskId) async { + try { + final file = await _getTaskFile(taskId); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + // 忽略删除错误,可能文件已不存在 + } + } + + @override + Future> getAllTaskIds() async { + try { + final dir = await cacheDirectory; + if (!await dir.exists()) { + return []; + } + + final files = await dir.list().toList(); + final taskIds = []; + + 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 clearAll() async { + try { + final dir = await cacheDirectory; + if (await dir.exists()) { + await dir.delete(recursive: true); + } + _cacheDirectory = null; // 重置缓存目录 + } catch (e) { + // 忽略删除错误 + } + } + + @override + Future 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 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 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; + } + } +} diff --git a/lib/src/utils/oss_logger.dart b/lib/src/utils/oss_logger.dart new file mode 100644 index 0000000..1db4bbf --- /dev/null +++ b/lib/src/utils/oss_logger.dart @@ -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'); + } +} diff --git a/lib/yx_oss.dart b/lib/yx_oss.dart new file mode 100644 index 0000000..f96acc5 --- /dev/null +++ b/lib/yx_oss.dart @@ -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'; diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..8c0cb04 --- /dev/null +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..9a11e45 --- /dev/null +++ b/pubspec.yaml @@ -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 diff --git a/test_object_key.dart b/test_object_key.dart new file mode 100644 index 0000000..e69de29