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,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()