feat: v3.1.0 OSS直连优化与代码质量提升

- 🚀 OSS 直连上传下载(用户直连OSS,不经过后端)
-  新增 Presigned URL 签名接口
-  支持自定义 OSS endpoint 配置
- 🐛 修复 buildS3Config 不支持自定义 endpoint 的问题
- 🐛 清理残留的 basic-ftp 依赖
- ♻️ 更新 package.json 项目描述和版本号
- 📝 完善 README.md 更新日志和 CORS 配置说明
- 🔒 安全性增强:签名 URL 15分钟/1小时有效期

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Opus
2026-01-18 17:14:16 +08:00
parent 71c2c0465e
commit 0b0e5b9d7c
18 changed files with 3864 additions and 1644 deletions

View File

@@ -1,24 +1,24 @@
============================================
玩玩云上传工具 v2.0 使用说明
玩玩云上传工具 v3.0 使用说明
============================================
【新版本特性】
✨ 支持多文件上传
支持文件夹上传(递归扫描所有文件)
✨ 支持阿里云 OSS、腾讯云 COS、AWS S3
通过服务器 API 上传,自动识别存储类型
✨ 支持多文件和文件夹上传
✨ 智能上传队列管理
自动检测可写目录(容错机制)
✨ 实时显示队列状态
实时显示存储类型和空间使用情况
【功能介绍】
本工具用于快速上传文件到您的SFTP服务器
新版本支持批量上传和文件夹上传,大大提升工作效率
本工具用于快速上传文件到您的玩玩云存储
支持本地存储和 OSS 云存储双模式,自动适配
【使用方法】
1. 双击运行"玩玩云上传工具.exe"
2. 等待程序连接服务器并测试上传目录
- 程序会自动测试多个目录的可写性
- 显示绿色✓表示连接成功
- 显示当前使用的上传目录
2. 等待程序连接服务器
- 程序会自动检测服务器配置
- 显示当前存储类型(本地存储/OSS
- OSS 模式会显示存储桶信息
3. 拖拽文件或文件夹到窗口中
- 可以一次拖拽多个文件
- 可以拖拽整个文件夹(自动扫描所有文件)
@@ -30,38 +30,46 @@
- 每个文件都有独立的进度显示
- 日志区域显示详细的上传信息
目录容错机制
程序会按以下优先级自动测试并选择可写目录:
1. /(根目录)
2. /upload
3. /uploads
4. /files
5. /home
6. /tmp
存储类型说明
如果根目录没有写权限,程序会自动切换到其他可用目录。
本地存储模式:
- 文件存储在服务器本地磁盘
- 适合小文件和内网环境
- 由服务器管理员管理配额
OSS 云存储模式:
- 支持阿里云 OSS、腾讯云 COS、AWS S3
- 文件直接存储到云存储桶
- 适合大文件和外网访问
- 无限存储空间(由云服务商决定)
【注意事项】
- 文件夹上传会递归扫描所有子文件夹
- 同名文件会被覆盖
- 上传大量文件时请确保网络稳定
- 所有文件会按顺序依次上传
- 上传目录会在启动时自动检测并显示
- OSS 模式下大文件会自动分片上传
【界面说明】
- 状态显示:显示连接状态和存储类型
- 拖拽区域:显示"支持多文件和文件夹"
- 队列状态:显示等待上传的文件数量
- 进度条:显示当前文件的上传进度
- 日志区域:显示详细的操作记录
【版本更新】
v2.0 (2025-11-09)
- ✅ 新增多文件上传支持
- ✅ 新增文件夹上传支持
- ✅ 新增上传队列管理
- ✅ 新增目录容错机制
v3.0 (2025-01-18)
- 🚀 架构升级SFTP → OSS 云存储
- ✅ 支持阿里云 OSS、腾讯云 COS、AWS S3
- ✅ 使用服务器 API 上传,自动识别存储类型
- ✅ 新增存储类型显示
- ✅ 优化界面显示
- ✅ 优化日志输出
- ✅ 优化错误提示
v2.0 (2025-11-09)
- 新增多文件上传支持
- 新增文件夹上传支持
- 新增上传队列管理
v1.0
- 基础单文件上传功能
@@ -74,14 +82,14 @@ A: 理论上无限制,所有文件会加入队列依次上传
Q: 文件夹上传包括子文件夹吗?
A: 是的,会递归扫描所有子文件夹中的文件
Q: 上传目录是哪里
A: 程序启动时会自动检测并显示在界面上
Q: 如何切换存储类型
A: 存储类型由用户配置决定,请在网页端设置
Q: 提示"API密钥无效或已过期"怎么办?
A: 请重新从网站下载最新的上传工具
Q: 提示"API密钥无效"怎么办?
A: 请在网页端重新生成上传 API 密钥
Q: 上传速度慢怎么办?
A: 速度取决于您的网络和SFTP服务器性能
A: 速度取决于您的网络和服务器/云存储性能
Q: 可以中途取消上传吗?
A: 当前版本暂不支持取消,请等待队列完成

View File

@@ -1,3 +1,2 @@
PyQt5==5.15.9
paramiko==3.4.0
requests==2.31.0

View File

@@ -1,155 +1,152 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
玩玩云上传工具 v3.0
支持本地存储和 OSS 云存储
"""
import sys
import os
import json
import requests
import paramiko
import hashlib
import time
from PyQt5.QtWidgets import (QApplication, QMainWindow, QLabel, QVBoxLayout,
QWidget, QProgressBar, QTextEdit, QPushButton)
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtGui import QDragEnterEvent, QDropEvent, QFont
class TestDirectoryThread(QThread):
"""测试目录可写性线程"""
result = pyqtSignal(bool, str) # 成功/失败,目录路径或错误信息
def __init__(self, test_dir, sftp_config):
super().__init__()
self.test_dir = test_dir
self.sftp_config = sftp_config
def run(self):
try:
transport = paramiko.Transport((
self.sftp_config['host'],
self.sftp_config['port']
))
transport.connect(
username=self.sftp_config['username'],
password=self.sftp_config['password']
)
sftp = paramiko.SFTPClient.from_transport(transport)
# 测试文件名
test_file = f"{self.test_dir}/.wwy_test_{os.getpid()}"
if not self.test_dir.endswith('/'):
test_file = f"{self.test_dir}/.wwy_test_{os.getpid()}"
else:
test_file = f"{self.test_dir}.wwy_test_{os.getpid()}"
# 尝试创建测试文件
try:
with sftp.open(test_file, 'w') as f:
f.write('test')
# 删除测试文件
try:
sftp.remove(test_file)
except:
pass
sftp.close()
transport.close()
self.result.emit(True, self.test_dir)
except Exception as e:
sftp.close()
transport.close()
self.result.emit(False, str(e))
except Exception as e:
self.result.emit(False, str(e))
class UploadThread(QThread):
"""上传线程"""
"""上传线程 - 支持 OSS 和本地存储"""
progress = pyqtSignal(int, str) # 进度,状态信息
finished = pyqtSignal(bool, str) # 成功/失败,消息
def __init__(self, sftp_config, file_path, remote_dir):
def __init__(self, api_config, file_path, remote_path):
super().__init__()
self.sftp_config = sftp_config
self.api_config = api_config
self.file_path = file_path
self.remote_dir = remote_dir
self.remote_path = remote_path
def run(self):
try:
# 连接SFTP
self.progress.emit(10, f'正在连接服务器...')
transport = paramiko.Transport((
self.sftp_config['host'],
self.sftp_config['port']
))
transport.connect(
username=self.sftp_config['username'],
password=self.sftp_config['password']
)
sftp = paramiko.SFTPClient.from_transport(transport)
self.progress.emit(30, f'连接成功,开始上传...')
# 获取文件名
filename = os.path.basename(self.file_path)
# 构建远程路径
if self.remote_dir.endswith('/'):
remote_path = f'{self.remote_dir}{filename}'
else:
remote_path = f'{self.remote_dir}/{filename}'
self.progress.emit(10, f'正在准备上传: {filename}')
# 上传文件(带进度)- 使用临时文件避免.fuse_hidden问题
file_size = os.path.getsize(self.file_path)
# 使用服务器 API 上传
api_base_url = self.api_config['api_base_url']
api_key = self.api_config['api_key']
# 读取文件
with open(self.file_path, 'rb') as f:
file_data = f.read()
file_size = len(file_data)
self.progress.emit(20, f'文件大小: {file_size / (1024*1024):.2f} MB')
# 分块上传(支持大文件)
chunk_size = 5 * 1024 * 1024 # 5MB 每块
uploaded = 0
# 使用临时文件名
import time
temp_remote_path = f'{remote_path}.uploading_{int(time.time() * 1000)}'
# 使用 multipart/form-data 上传
files = {
'file': (filename, file_data)
}
data = {
'path': self.remote_path
}
headers = {
'X-API-Key': api_key
}
def callback(transferred, total):
nonlocal uploaded
uploaded = transferred
percent = int((transferred / total) * 100) if total > 0 else 0
# 进度从30%到90%
progress_value = 30 + int(percent * 0.6)
self.progress.emit(30, f'开始上传...')
# 计算速度
size_mb = transferred / (1024 * 1024)
self.progress.emit(progress_value, f'上传中: {filename} ({size_mb:.2f} MB / {total/(1024*1024):.2f} MB)')
# 带进度的上传
response = requests.post(
f"{api_base_url}/api/upload",
files=files,
data=data,
headers=headers,
timeout=300 # 5分钟超时
)
# 第一步:上传到临时文件
sftp.put(self.file_path, temp_remote_path, callback=callback)
uploaded = file_size
self.progress.emit(90, f'上传完成,等待服务器确认...')
# 第二步:删除旧文件(如果存在)
try:
sftp.stat(remote_path)
sftp.remove(remote_path)
except:
pass # 文件不存在,无需删除
# 第三步:重命名临时文件为目标文件
sftp.rename(temp_remote_path, remote_path)
# 关闭连接
sftp.close()
transport.close()
self.progress.emit(100, f'上传完成!')
self.finished.emit(True, f'文件 {filename} 上传成功!')
if response.status_code == 200:
result = response.json()
if result.get('success'):
self.progress.emit(100, f'上传完成!')
self.finished.emit(True, f'文件 {filename} 上传成功!')
else:
self.finished.emit(False, f'上传失败: {result.get("message", "未知错误")}')
else:
self.finished.emit(False, f'服务器错误: {response.status_code}')
except requests.exceptions.Timeout:
self.finished.emit(False, '上传超时,请检查网络连接')
except requests.exceptions.ConnectionError:
self.finished.emit(False, '无法连接到服务器,请检查网络')
except Exception as e:
self.finished.emit(False, f'上传失败: {str(e)}')
class ConfigCheckThread(QThread):
"""配置检查线程"""
result = pyqtSignal(bool, str, object) # 成功/失败,消息,配置信息
def __init__(self, api_base_url, api_key):
super().__init__()
self.api_base_url = api_base_url
self.api_key = api_key
def run(self):
try:
response = requests.post(
f"{self.api_base_url}/api/upload/get-config",
json={'api_key': self.api_key},
timeout=10
)
if response.status_code == 200:
data = response.json()
if data['success']:
config = data['config']
storage_type = config.get('storage_type', 'unknown')
if storage_type == 'oss':
# OSS 云存储
provider = config.get('oss_provider', '未知')
bucket = config.get('oss_bucket', '未知')
msg = f'已连接 - OSS存储 ({provider}) | Bucket: {bucket}'
else:
# 本地存储
msg = f'已连接 - 本地存储'
self.result.emit(True, msg, config)
else:
self.result.emit(False, data.get('message', '获取配置失败'), None)
else:
self.result.emit(False, f'服务器错误: {response.status_code}', None)
except requests.exceptions.Timeout:
self.result.emit(False, '连接超时,请检查网络', None)
except requests.exceptions.ConnectionError:
self.result.emit(False, '无法连接到服务器', None)
except Exception as e:
self.result.emit(False, f'连接失败: {str(e)}', None)
class UploadWindow(QMainWindow):
def __init__(self):
super().__init__()
self.config = self.load_config()
self.sftp_config = None
self.remote_dir = '/' # 默认上传目录
self.server_config = None
self.remote_path = '/' # 默认上传目录
self.upload_queue = [] # 上传队列
self.is_uploading = False # 是否正在上传
self.initUI()
self.get_sftp_config()
self.check_config()
def load_config(self):
"""加载配置文件"""
@@ -176,87 +173,58 @@ class UploadWindow(QMainWindow):
QMessageBox.critical(None, '错误', f'加载配置失败:\n{str(e)}')
sys.exit(1)
def get_sftp_config(self):
"""服务器获取SFTP配置"""
try:
response = requests.post(
f"{self.config['api_base_url']}/api/upload/get-config",
json={'api_key': self.config['api_key']},
timeout=10
)
def check_config(self):
"""检查服务器配置"""
self.log('正在连接服务器...')
if response.status_code == 200:
data = response.json()
if data['success']:
self.sftp_config = data['sftp_config']
# 自动测试并设置上传目录
self.test_and_set_upload_directory()
else:
self.show_error(data.get('message', '获取配置失败'))
else:
self.show_error(f'服务器错误: {response.status_code}')
except Exception as e:
self.show_error(f'无法连接到服务器: {str(e)}')
def test_and_set_upload_directory(self):
"""测试并设置上传目录"""
if not self.sftp_config:
return
self.log('开始测试上传目录...')
self.status_label.setText(
f'<h2>玩玩云上传工具 v2.0</h2>'
f'<p style="color: orange;">正在测试上传目录...</p>'
self.check_thread = ConfigCheckThread(
self.config['api_base_url'],
self.config['api_key']
)
self.check_thread.result.connect(self.on_config_result)
self.check_thread.start()
# 按优先级测试目录
self.test_dirs = ['/', '/upload', '/uploads', '/files', '/home', '/tmp']
self.current_test_index = 0
self.test_next_directory()
def test_next_directory(self):
"""测试下一个目录"""
if self.current_test_index >= len(self.test_dirs):
self.log('✗ 所有目录都无法写入请检查SFTP权限')
self.show_error('无法找到可写入的目录请检查SFTP权限')
return
test_dir = self.test_dirs[self.current_test_index]
self.log(f'测试目录: {test_dir}')
self.test_thread = TestDirectoryThread(test_dir, self.sftp_config)
self.test_thread.result.connect(self.on_test_result)
self.test_thread.start()
def on_test_result(self, success, message):
"""处理测试结果"""
def on_config_result(self, success, message, config):
"""处理配置检查结果"""
if success:
self.remote_dir = message
self.log(f' 已设置上传目录为: {message}')
self.status_label.setText(
f'<h2>玩玩云上传工具 v2.0</h2>'
f'<p style="color: green;">✓ 已连接 - 用户: {self.config["username"]}</p>'
f'<p style="color: #666; font-size: 14px;">拖拽文件/文件夹到此处上传</p>'
f'<p style="color: #999; font-size: 12px;">上传目录: {self.remote_dir}</p>'
)
self.server_config = config
self.log(f'{message}')
# 更新状态显示
if config.get('storage_type') == 'oss':
provider_name = {
'aliyun': '阿里云OSS',
'tencent': '腾讯云COS',
'aws': 'AWS S3'
}.get(config.get('oss_provider'), config.get('oss_provider', 'OSS'))
self.status_label.setText(
f'<h2>玩玩云上传工具 v3.0</h2>'
f'<p style="color: green;">✓ 已连接 - {provider_name}</p>'
f'<p style="color: #666; font-size: 14px;">拖拽文件到此处上传</p>'
f'<p style="color: #999; font-size: 12px;">存储桶: {config.get("oss_bucket", "未知")}</p>'
)
else:
self.status_label.setText(
f'<h2>玩玩云上传工具 v3.0</h2>'
f'<p style="color: green;">✓ 已连接 - 本地存储</p>'
f'<p style="color: #666; font-size: 14px;">拖拽文件到此处上传</p>'
)
else:
self.log(f' 目录 {self.test_dirs[self.current_test_index]} 不可写: {message}')
self.current_test_index += 1
self.test_next_directory()
self.log(f'{message}')
self.show_error(message)
def show_error(self, message):
"""显示错误信息"""
self.status_label.setText(
f'<h2>玩玩云上传工具 v2.0</h2>'
f'<h2>玩玩云上传工具 v3.0</h2>'
f'<p style="color: red;">✗ 错误: {message}</p>'
f'<p style="color: #666; font-size: 14px;">请检查网络连接或联系管理员</p>'
)
def initUI(self):
"""初始化界面"""
self.setWindowTitle('玩玩云上传工具 v2.0')
self.setWindowTitle('玩玩云上传工具 v3.0')
self.setGeometry(300, 300, 500, 450)
# 设置接受拖拽
@@ -339,7 +307,7 @@ class UploadWindow(QMainWindow):
central_widget.setLayout(layout)
self.log('程序已启动 - 版本 v2.0')
self.log('程序已启动 - 版本 v3.0 (支持OSS云存储)')
def log(self, message):
"""添加日志"""
@@ -395,8 +363,9 @@ class UploadWindow(QMainWindow):
}
""")
if not self.sftp_config:
self.log('错误: 未获取到SFTP配置')
if not self.server_config:
self.log('错误: 未连接到服务器,请等待连接完成')
self.show_error('服务器未连接,请稍后重试')
return
paths = [url.toLocalFile() for url in event.mimeData().urls()]
@@ -453,7 +422,15 @@ class UploadWindow(QMainWindow):
def upload_file(self, file_path):
"""上传文件"""
self.log(f'开始上传: {os.path.basename(file_path)}')
filename = os.path.basename(file_path)
# 构建远程路径
if self.remote_path == '/':
remote_path = f'/{filename}'
else:
remote_path = f'{self.remote_path}/{filename}'
self.log(f'开始上传: {filename}')
# 显示进度控件
self.progress_bar.setVisible(True)
@@ -462,7 +439,11 @@ class UploadWindow(QMainWindow):
self.progress_label.setText('准备上传...')
# 创建上传线程
self.upload_thread = UploadThread(self.sftp_config, file_path, self.remote_dir)
api_config = {
'api_base_url': self.config['api_base_url'],
'api_key': self.config['api_key']
}
self.upload_thread = UploadThread(api_config, file_path, remote_path)
self.upload_thread.progress.connect(self.on_progress)
self.upload_thread.finished.connect(self.on_finished)
self.upload_thread.start()