v0.2.7: 修复Redis连接 + 启动管理后台

- 修复Redis认证 (配置密码)
- 启动Python管理后台 (端口9531, 15个功能开关)
- 统一版本号 0.2.7
- 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
This commit is contained in:
2026-05-17 02:22:18 +08:00
commit 83cbfaf03f
164 changed files with 25195 additions and 0 deletions

View File

@@ -0,0 +1,297 @@
"""
阿里云盘适配器 v1.0.0
AliyunAdapter — 继承 BaseCloudDriveAdapter实现阿里云盘全部转存能力。
组件:
- AliyunCredentialManager: refresh_token 刷新 + 缓存
- AliyunTransfer: 4 步批量转存
- AliyunCleanup: 回收站清理
URL 匹配: aliyundrive.com/s/<share_id>
"""
import re
import logging
from typing import List, Dict, Tuple, Optional
from ..base import BaseCloudDriveAdapter, FileInfo, match_url
from ..config import PlatformConfig, TransferConfig
from ..errors import TransferError, TransferErrorCode
from .credential import AliyunCredentialManager
from .transfer import AliyunTransfer
from .cleanup import AliyunCleanup
logger = logging.getLogger(__name__)
class AliyunAdapter(BaseCloudDriveAdapter):
"""阿里云盘适配器"""
PLATFORM_NAME = "阿里云盘"
PLATFORM_KEY = "aliyun"
URL_PATTERNS = [
r'aliyundrive\.com/s/([a-zA-Z0-9]+)',
r'alipan\.com/s/([a-zA-Z0-9]+)',
]
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Referer": "https://aliyundrive.com",
}
def __init__(self, config: PlatformConfig, transfer_config: TransferConfig):
super().__init__(config, transfer_config)
# 创建凭证管理器AliyunCredentialManager
refresh_token = config.refresh_token or config.cookie or ""
self._credential = AliyunCredentialManager(refresh_token=refresh_token)
# 初始化 drive_id
self._drive_id = ""
# 创建子模块
self._transfer: Optional[AliyunTransfer] = None
self._cleanup: Optional[AliyunCleanup] = None
def _setup_session(self):
"""初始化 session 和凭证"""
if self._credential.refresh_token:
# 验证 refresh_token 并获取 drive_id
if self._credential.validate():
self._drive_id = self._credential.get_drive_id()
logger.info(
f"[AliyunAdapter] 凭证验证成功, drive_id={self._drive_id[:8]}..."
)
else:
logger.warning("[AliyunAdapter] 凭证验证失败,转存功能可能不可用")
else:
logger.warning("[AliyunAdapter] 未配置 refresh_token")
# ─── 核心抽象方法实现 ──────────────────────────────────
def _get_share_detail(self, pwd_id: str, passcode: str = "") -> dict:
"""
获取分享详情。
步骤①②: 先获取匿名分享信息,再获取 share_token。
Returns:
{
"title": "分享标题",
"share_id": "...",
"share_token": "...",
"files": [{"file_id": "...", "name": "...", "size": 0, "type": "file"}, ...],
}
"""
try:
transfer = self._get_transfer()
# ① 获取分享信息(匿名)
share_info = transfer._get_share_info(pwd_id)
if not share_info:
raise TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
platform=self.PLATFORM_KEY,
)
# ② 获取分享令牌Auth
share_token = transfer._get_share_token(pwd_id, passcode)
if not share_token:
raise TransferError(
TransferErrorCode.PASSCODE_WRONG if passcode else TransferErrorCode.SHARE_NOT_EXIST,
platform=self.PLATFORM_KEY,
message="获取分享令牌失败(可能需要提取码)",
)
return {
"title": share_info.get("share_name", share_info.get("share_title", "")),
"share_id": pwd_id,
"share_token": share_token,
"files": share_info.get("file_infos", []),
"creator_name": share_info.get("creator_name", ""),
}
except TransferError:
raise
except Exception as e:
logger.exception(f"[AliyunAdapter] 获取分享详情失败: {e}")
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message=str(e),
platform=self.PLATFORM_KEY,
)
def _save_files(self, pwd_id: str, detail: dict, save_dir: str) -> List[str]:
"""
步骤③: 批量复制文件到自己的网盘。
Args:
pwd_id: 分享 ID
detail: _get_share_detail 的返回值
save_dir: 目标目录(根目录用 "root"
Returns:
新文件 ID 列表
"""
share_token = detail.get("share_token", "")
files = detail.get("files", [])
if not share_token:
raise TransferError(
TransferErrorCode.SHARE_NOT_EXIST,
message="缺少 share_token",
platform=self.PLATFORM_KEY,
)
if not files:
raise TransferError(
TransferErrorCode.RESOURCE_EMPTY,
platform=self.PLATFORM_KEY,
)
file_ids = [f.get("file_id", "") for f in files if f.get("file_id")]
if not file_ids:
raise TransferError(
TransferErrorCode.RESOURCE_EMPTY,
message="无法提取文件 ID",
platform=self.PLATFORM_KEY,
)
# 确定目标目录
to_parent = save_dir if save_dir and save_dir != "/" else "root"
transfer = self._get_transfer()
new_ids = transfer._batch_copy(pwd_id, share_token, file_ids, to_parent)
if not new_ids:
raise TransferError(
TransferErrorCode.NETWORK_ERROR,
message="批量转存失败,所有文件复制均失败",
platform=self.PLATFORM_KEY,
)
return new_ids
def _create_share(
self, file_ids: List[str], title: str, password: str = ""
) -> Tuple[str, str]:
"""
步骤④: 创建新分享链接。
Returns:
(share_url, share_password)
"""
if not file_ids:
raise TransferError(
TransferErrorCode.RESOURCE_EMPTY,
platform=self.PLATFORM_KEY,
)
transfer = self._get_transfer()
result = transfer._create_share(file_ids, password)
share_url = result.get("share_url", "")
share_pwd = result.get("share_pwd", password)
if not share_url:
raise TransferError(
TransferErrorCode.SHARE_LINK_FAIL,
message="创建分享链接失败",
platform=self.PLATFORM_KEY,
)
return share_url, share_pwd
def get_files(self, parent_fid: str = "0") -> List[FileInfo]:
"""
列出网盘目录下的文件。
NOTE: 当前实现为占位。如需完整功能,请调用阿里云盘 /adrive/v3/file/list API。
"""
logger.warning("[AliyunAdapter] get_files() 未完整实现,返回空列表")
return []
def delete(self, file_ids: List[str]) -> bool:
"""
删除文件(移入回收站)。
Args:
file_ids: 要删除的文件 ID 列表
Returns:
是否全部删除成功
"""
if not file_ids:
return True
cleanup = self._get_cleanup()
result = cleanup.delete_files(file_ids)
return result.get("success", False)
# ─── 扩展功能 ──────────────────────────────────────────
def cleanup_files(self, file_ids: List[str]) -> Dict:
"""
清理文件(移入回收站),返回详细结果。
Returns:
AliyunCleanup.delete_files() 的返回字典
"""
cleanup = self._get_cleanup()
return cleanup.delete_files(file_ids)
def force_refresh_token(self) -> bool:
"""强制刷新 access_token"""
return self._credential.refresh()
def get_credential_status(self) -> Dict:
"""获取当前凭证状态"""
return self._credential.to_dict()
# ─── 文件列表提取 ──────────────────────────────────────
def _extract_file_list(self, detail: dict) -> List[FileInfo]:
"""从分享详情中提取 FileInfo 列表"""
files = detail.get("files", [])
result = []
for f in files:
result.append(FileInfo(
fid=f.get("file_id", ""),
name=f.get("name", ""),
size=int(f.get("size", 0)),
is_dir=f.get("type", "") == "folder",
ext=f.get("file_extension", ""),
))
return result
# ─── 内部辅助方法 ──────────────────────────────────────
def _get_transfer(self) -> AliyunTransfer:
"""懒加载获取 AliyunTransfer 实例"""
if self._transfer is None:
drive_id = self._drive_id or self._credential.get_drive_id()
self._transfer = AliyunTransfer(
credential=self._credential,
drive_id=drive_id,
to_parent_file_id=self.config.save_dir or "root",
request_timeout=self.transfer_config.request_timeout,
)
return self._transfer
def _get_cleanup(self) -> AliyunCleanup:
"""懒加载获取 AliyunCleanup 实例"""
if self._cleanup is None:
drive_id = self._drive_id or self._credential.get_drive_id()
self._cleanup = AliyunCleanup(
credential=self._credential,
drive_id=drive_id,
request_timeout=self.transfer_config.request_timeout,
)
return self._cleanup

View File

@@ -0,0 +1,203 @@
"""
阿里云盘回收站清理模块 v1.0.0
将文件移入回收站(非直接删除),支持批量操作。
"""
import logging
from typing import List, Dict
import requests
from .credential import AliyunCredentialManager, API_HOST
logger = logging.getLogger(__name__)
# ─── API 端点 ──────────────────────────────────────────────
# 批量操作v4
BATCH_URL = f"{API_HOST}/adrive/v4/batch"
# 默认请求头
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Referer": "https://aliyundrive.com",
}
class AliyunCleanup:
"""
阿里云盘回收站清理
将文件移入回收站(放入回收站,非永久删除)。
使用 v4 批量接口,支持一次清理多个文件。
用法:
credential = AliyunCredentialManager(refresh_token="xxx")
cleanup = AliyunCleanup(credential, drive_id="12345")
result = cleanup.delete_files(["file_id_1", "file_id_2"])
"""
def __init__(
self,
credential: AliyunCredentialManager,
drive_id: str = "",
request_timeout: int = 30,
):
self.credential = credential
self.drive_id = drive_id or credential.get_drive_id()
self.request_timeout = request_timeout
self._session = requests.Session()
self._session.headers.update(DEFAULT_HEADERS)
# ─── 公开 API ──────────────────────────────────────────
def delete_files(self, file_ids: List[str]) -> Dict:
"""
将指定文件移入回收站(批量)。
Args:
file_ids: 要删除的文件 ID 列表
Returns:
{
"success": True/False,
"deleted_count": 成功删除数量,
"total_count": 总文件数,
"failed_ids": 失败的文件 ID 列表,
"error": None or "错误信息",
}
实现:
POST /adrive/v4/batch
{
"requests": [
{
"url": "/recyclebin/trash",
"body": {"file_id": "...", "drive_id": "..."},
"headers": {"Content-Type": "application/json"},
"id": "...",
"method": "POST"
}
],
"resource": "file"
}
"""
if not file_ids:
return self._error("文件 ID 列表为空")
drive_id = self.drive_id
if not drive_id:
drive_id = self.credential.get_drive_id()
if not drive_id:
return self._error("缺少 drive_id无法执行删除操作")
# 构建批量请求体
requests_list = []
for fid in file_ids:
requests_list.append({
"url": "/recyclebin/trash",
"body": {
"drive_id": drive_id,
"file_id": fid,
},
"headers": {"Content-Type": "application/json"},
"id": fid,
"method": "POST",
})
try:
headers = self.credential.get_headers()
resp = self._session.post(
BATCH_URL,
json={"requests": requests_list, "resource": "file"},
headers=headers,
timeout=self.request_timeout,
)
data = resp.json()
if resp.status_code != 200:
logger.error(
f"[AliyunCleanup] 批量删除失败: "
f"HTTP {resp.status_code}, {data}"
)
return self._error(f"HTTP {resp.status_code}")
code = data.get("code", "")
if code:
logger.error(
f"[AliyunCleanup] 批量删除 API 错误: "
f"code={code}, message={data.get('message', '')}"
)
return self._error(data.get("message", f"API code={code}"))
# 统计结果
responses = data.get("responses", [])
success_ids = []
failed_ids = []
for item in responses:
status = item.get("status", 0)
fid = item.get("id", "")
if status in (200, 201, 202):
success_ids.append(fid)
else:
logger.warning(
f"[AliyunCleanup] 删除文件失败: "
f"id={fid}, status={status}, body={item.get('body', {})}"
)
failed_ids.append(fid)
logger.info(
f"[AliyunCleanup] 删除完成: "
f"成功={len(success_ids)}, 失败={len(failed_ids)}, 总计={len(file_ids)}"
)
return {
"success": len(failed_ids) == 0,
"deleted_count": len(success_ids),
"total_count": len(file_ids),
"success_ids": success_ids,
"failed_ids": failed_ids,
"error": None,
}
except requests.RequestException as e:
logger.error(f"[AliyunCleanup] 批量删除网络异常: {e}")
return self._error(str(e))
except Exception as e:
logger.exception(f"[AliyunCleanup] 批量删除异常: {e}")
return self._error(str(e))
def empty_recycle_bin(self) -> Dict:
"""
清空回收站(永久删除回收站中的所有文件)。
NOTE: 阿里云盘 API 目前不直接支持清空回收站,
此方法作为占位,需要逐个文件 ID 调用 delete_files。
实际使用请先 list 回收站内容再调用 delete_files。
Returns:
{"success": False, "error": "清空回收站需要通过 list + delete 两步完成"}
"""
logger.warning("[AliyunCleanup] 清空回收站 API 暂未实现,需要 list+delete 两步")
return self._error("清空回收站需要通过列出回收站内容 + 逐个删除两步完成,尚未实现")
# ─── 工具方法 ──────────────────────────────────────────
def _error(self, message: str) -> Dict:
"""构造错误返回"""
return {
"success": False,
"deleted_count": 0,
"total_count": 0,
"success_ids": [],
"failed_ids": [],
"error": message,
}

View File

@@ -0,0 +1,216 @@
"""
阿里云盘凭证管理器 v1.0.0
refresh_token → access_token 刷新 + 自动缓存 + 过期前自动刷新
"""
import time
import logging
import threading
from typing import Dict, Optional
from dataclasses import dataclass, field
import requests
logger = logging.getLogger(__name__)
# ─── 常量 ──────────────────────────────────────────────────
API_HOST = "https://api.aliyundrive.com"
TOKEN_REFRESH_URL = f"{API_HOST}/token/refresh"
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
}
@dataclass
class TokenInfo:
"""缓存的 Token 信息"""
access_token: str = ""
refresh_token: str = ""
expires_at: float = 0.0 # Unix 时间戳
drive_id: str = ""
user_id: str = ""
nick_name: str = ""
default_sbox_drive_id: str = ""
@property
def is_expired(self) -> bool:
"""检查 access_token 是否已过期(提前 60s 视为过期)"""
return time.time() >= (self.expires_at - 60)
@property
def is_valid(self) -> bool:
return bool(self.access_token) and not self.is_expired
class AliyunCredentialManager:
"""
阿里云盘凭证管理器
职责:
- 使用 refresh_token 换取 access_token
- 缓存 access_token / expires_at / drive_id
- 过期前自动刷新(提前 60s
- 线程安全
用法:
mgr = AliyunCredentialManager(refresh_token="xxx")
mgr.refresh() # 强制刷新
headers = mgr.get_headers() # 获取带 Auth 的请求头
is_ok = mgr.validate() # 验证 refresh_token 有效性
"""
def __init__(self, refresh_token: str = ""):
self._refresh_token = refresh_token.strip()
self._token: Optional[TokenInfo] = None
self._lock = threading.Lock()
self._session = requests.Session()
self._session.headers.update(DEFAULT_HEADERS)
# ─── 公开 API ──────────────────────────────────────────
def refresh(self) -> bool:
"""
使用 refresh_token 换取 access_token。
返回 True 表示成功False 表示失败。
"""
with self._lock:
return self._do_refresh()
def get_headers(self) -> Dict[str, str]:
"""
获取带 Authorization 的请求头。
自动检查 token 有效性,必要时自动刷新。
Returns:
{"Authorization": "Bearer <access_token>", ...}
"""
self._ensure_token_valid()
headers = {}
if self._token and self._token.access_token:
headers["Authorization"] = f"Bearer {self._token.access_token}"
return headers
def get_access_token(self) -> str:
"""获取当前有效的 access_token必要时自动刷新"""
self._ensure_token_valid()
return self._token.access_token if self._token else ""
def get_drive_id(self) -> str:
"""获取默认 drive_id"""
self._ensure_token_valid()
return self._token.drive_id if self._token else ""
def get_sbox_drive_id(self) -> str:
"""获取保险箱 drive_id"""
self._ensure_token_valid()
return self._token.default_sbox_drive_id if self._token else ""
def validate(self) -> bool:
"""
验证 refresh_token 是否有效。
要求 refresh_token 长度 >= 20且能成功换取 access_token。
"""
if not self._refresh_token or len(self._refresh_token) < 20:
logger.warning("[AliyunCredential] refresh_token 长度不足 20验证失败")
return False
return self.refresh()
@property
def refresh_token(self) -> str:
return self._refresh_token
@refresh_token.setter
def refresh_token(self, value: str):
"""更新 refresh_token通常在 API 返回新 refresh_token 后调用)"""
self._refresh_token = value.strip()
# 清除旧缓存,下次请求自动刷新
with self._lock:
self._token = None
# ─── 内部方法 ──────────────────────────────────────────
def _ensure_token_valid(self):
"""确保 token 有效(过期则自动刷新)"""
if self._token is None or self._token.is_expired:
self.refresh()
def _do_refresh(self) -> bool:
"""实际执行 token 刷新"""
if not self._refresh_token:
logger.error("[AliyunCredential] 没有 refresh_token无法刷新")
return False
try:
resp = self._session.post(
TOKEN_REFRESH_URL,
json={"refresh_token": self._refresh_token},
timeout=30,
)
data = resp.json()
if resp.status_code != 200 or "access_token" not in data:
code = data.get("code", "Unknown")
message = data.get("message", "")
logger.error(
f"[AliyunCredential] 刷新 token 失败: "
f"HTTP {resp.status_code} code={code} msg={message}"
)
return False
# 解析响应
access_token = data.get("access_token", "")
expires_in = int(data.get("expires_in", 7200))
new_refresh = data.get("refresh_token", self._refresh_token)
self._token = TokenInfo(
access_token=access_token,
refresh_token=new_refresh,
expires_at=time.time() + expires_in,
drive_id=str(data.get("default_drive_id", "")),
user_id=str(data.get("user_id", "")),
nick_name=str(data.get("nick_name", "")),
default_sbox_drive_id=str(data.get("default_sbox_drive_id", "")),
)
# 更新 refresh_token服务端可能下发新的
if new_refresh != self._refresh_token:
logger.info(
"[AliyunCredential] refresh_token 已轮换,新旧前缀: "
f"{self._refresh_token[:8]}... → {new_refresh[:8]}..."
)
self._refresh_token = new_refresh
logger.info(
f"[AliyunCredential] Token 刷新成功 "
f"(user={self._token.nick_name}, "
f"expires_in={expires_in}s, "
f"drive_id={self._token.drive_id[:8]}...)"
)
return True
except requests.RequestException as e:
logger.error(f"[AliyunCredential] 刷新 token 网络异常: {e}")
return False
except Exception as e:
logger.exception(f"[AliyunCredential] 刷新 token 未知异常: {e}")
return False
def to_dict(self) -> dict:
"""导出当前状态(用于持久化)"""
self._ensure_token_valid()
return {
"refresh_token": self._refresh_token,
"access_token": self._token.access_token if self._token else "",
"expires_at": self._token.expires_at if self._token else 0,
"drive_id": self._token.drive_id if self._token else "",
"user_id": self._token.user_id if self._token else "",
"nick_name": self._token.nick_name if self._token else "",
}

View File

@@ -0,0 +1,493 @@
"""
阿里云盘转存模块 v1.0.0
实现 4 步批量转存流程:获取分享详情 → 获取分享令牌 → 批量复制文件 → 创建新分享
"""
import re
import time
import logging
from typing import List, Dict, Tuple, Optional
import requests
from .credential import AliyunCredentialManager, API_HOST
logger = logging.getLogger(__name__)
# ─── API 端点 ──────────────────────────────────────────────
# ① 获取分享详情(匿名)
SHARE_INFO_URL = f"{API_HOST}/adrive/v3/share_link/get_share_by_anonymous"
# ② 获取分享令牌(需 Auth
SHARE_TOKEN_URL = f"{API_HOST}/v2/share_link/get_share_token"
# ③ 批量操作(复制文件)
BATCH_URL = f"{API_HOST}/adrive/v4/batch"
# ④ 创建分享
CREATE_SHARE_URL = f"{API_HOST}/adrive/v2/share_link/create"
# ─── URL 模式 ──────────────────────────────────────────────
# 匹配 aliyundrive.com/s/<share_id>
URL_PATTERN = re.compile(r'aliyundrive\.com/s/([a-zA-Z0-9]+)')
# ─── 默认请求头 ────────────────────────────────────────────
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/135.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"Referer": "https://aliyundrive.com",
}
class AliyunTransfer:
"""
阿里云盘批量转存
四步流程:
① 获取分享详情匿名POST /adrive/v3/share_link/get_share_by_anonymous
② 获取分享令牌AuthPOST /v2/share_link/get_share_token
③ 批量复制文件POST /adrive/v4/batch (X-Share-Token 头)
④ 创建新分享POST /adrive/v2/share_link/create
用法:
credential = AliyunCredentialManager(refresh_token="xxx")
transfer = AliyunTransfer(credential, drive_id="12345")
result = transfer.transfer(
share_url="https://www.aliyundrive.com/s/abc123",
share_password="",
to_parent_file_id="root",
)
"""
def __init__(
self,
credential: AliyunCredentialManager,
drive_id: str = "",
to_parent_file_id: str = "root",
request_timeout: int = 30,
):
self.credential = credential
self.drive_id = drive_id or credential.get_drive_id()
self.to_parent_file_id = to_parent_file_id
self.request_timeout = request_timeout
self._session = requests.Session()
self._session.headers.update(DEFAULT_HEADERS)
# ─── 公开 API ──────────────────────────────────────────
def transfer(
self,
share_url: str,
share_password: str = "",
to_parent_file_id: str = None,
new_share_password: str = "",
expiration: str = "",
) -> Dict:
"""
执行完整的转存流程。
Args:
share_url: 阿里云盘分享链接(如 https://www.aliyundrive.com/s/abc123
share_password: 分享提取码(如有)
to_parent_file_id: 转存目标目录 file_id默认用初始化时的值
new_share_password: 新分享的密码(空=无密码)
expiration: 分享有效期,空=永久
Returns:
{
"success": True/False,
"share_name": "...",
"new_file_ids": ["id1", "id2"],
"new_share_url": "https://...",
"new_share_password": "...",
"error": None or "...",
}
"""
parent_id = to_parent_file_id or self.to_parent_file_id
try:
# ① 获取分享详情
share_id = self._extract_share_id(share_url)
if not share_id:
return self._error("无法从 URL 提取分享 ID")
share_info = self._get_share_info(share_id)
if not share_info:
return self._error("分享不存在或已失效")
share_name = share_info.get("share_name", "")
file_infos = share_info.get("file_infos", [])
if not file_infos:
return self._error("分享内容为空")
logger.info(
f"[AliyunTransfer] 分享详情获取成功: "
f"name={share_name}, files={len(file_infos)}"
)
# ② 获取分享令牌
share_token = self._get_share_token(share_id, share_password)
if not share_token:
return self._error("获取分享令牌失败(可能需要提取码)")
logger.info(f"[AliyunTransfer] 分享令牌获取成功")
# ③ 批量复制文件
file_ids = [fi.get("file_id", "") for fi in file_infos if fi.get("file_id")]
if not file_ids:
return self._error("无法提取文件 ID")
new_file_ids = self._batch_copy(share_id, share_token, file_ids, parent_id)
if not new_file_ids:
return self._error("批量转存失败,请检查权限或容量")
logger.info(f"[AliyunTransfer] 批量转存成功: {len(new_file_ids)} 个文件")
# ④ 创建新分享
share_result = self._create_share(
new_file_ids,
share_password=new_share_password,
expiration=expiration,
)
new_share_url = share_result.get("share_url", "")
new_share_pwd = share_result.get("share_pwd", new_share_password)
logger.info(f"[AliyunTransfer] 新分享创建成功: {new_share_url}")
return {
"success": True,
"share_name": share_name,
"share_id": share_id,
"new_file_ids": new_file_ids,
"new_share_url": new_share_url,
"new_share_password": new_share_pwd,
"error": None,
}
except Exception as e:
logger.exception(f"[AliyunTransfer] 转存异常: {e}")
return self._error(str(e))
def get_share_info(self, share_url: str) -> Optional[Dict]:
"""
仅获取分享详情(不转存)。
Returns:
{"share_name": "...", "file_infos": [...]} or None
"""
share_id = self._extract_share_id(share_url)
if not share_id:
logger.error(f"[AliyunTransfer] 无法从 URL 提取 share_id: {share_url}")
return None
return self._get_share_info(share_id)
# ─── 步骤 ①:获取分享详情 ───────────────────────────────
def _get_share_info(self, share_id: str) -> Optional[Dict]:
"""
POST /adrive/v3/share_link/get_share_by_anonymous
请求体: {"share_id": "..."}
响应: {"share_name": "...", "file_infos": [{"file_id": "...", "name": "...", ...}]}
"""
try:
resp = self._session.post(
SHARE_INFO_URL,
json={"share_id": share_id},
timeout=self.request_timeout,
)
data = resp.json()
if resp.status_code != 200:
logger.error(
f"[AliyunTransfer] 获取分享详情失败: "
f"HTTP {resp.status_code}, {data}"
)
return None
# 检查业务错误码
code = data.get("code", "")
if code:
logger.error(
f"[AliyunTransfer] 获取分享详情 API 错误: "
f"code={code}, message={data.get('message', '')}"
)
return None
return {
"share_name": data.get("share_name", ""),
"share_title": data.get("share_title", data.get("share_name", "")),
"file_infos": data.get("file_infos", []),
"expiration": data.get("expiration", ""),
"creator_name": data.get("creator_name", ""),
"creator_id": data.get("creator_id", ""),
}
except requests.RequestException as e:
logger.error(f"[AliyunTransfer] 获取分享详情网络异常: {e}")
return None
except Exception as e:
logger.exception(f"[AliyunTransfer] 获取分享详情异常: {e}")
return None
# ─── 步骤 ②:获取分享令牌 ────────────────────────────────
def _get_share_token(self, share_id: str, share_password: str = "") -> Optional[str]:
"""
POST /v2/share_link/get_share_token
请求体: {"share_id": "..."}
需要 Auth 头
响应: {"share_token": "..."}
"""
try:
headers = self.credential.get_headers()
resp = self._session.post(
SHARE_TOKEN_URL,
json={
"share_id": share_id,
"share_pwd": share_password,
},
headers=headers,
timeout=self.request_timeout,
)
data = resp.json()
if resp.status_code != 200:
logger.error(
f"[AliyunTransfer] 获取分享令牌失败: "
f"HTTP {resp.status_code}, {data}"
)
return None
code = data.get("code", "")
if code:
logger.error(
f"[AliyunTransfer] 获取分享令牌 API 错误: "
f"code={code}, message={data.get('message', '')}"
)
return None
share_token = data.get("share_token", "")
if not share_token:
logger.error("[AliyunTransfer] 响应中缺少 share_token")
return None
return share_token
except requests.RequestException as e:
logger.error(f"[AliyunTransfer] 获取分享令牌网络异常: {e}")
return None
except Exception as e:
logger.exception(f"[AliyunTransfer] 获取分享令牌异常: {e}")
return None
# ─── 步骤 ③:批量复制文件 ────────────────────────────────
def _batch_copy(
self,
share_id: str,
share_token: str,
file_ids: List[str],
to_parent_file_id: str = "root",
) -> List[str]:
"""
POST /adrive/v4/batch
头: X-Share-Token: <share_token>
请求体:
{
"requests": [
{
"url": "/file/copy",
"body": {
"file_id": "...",
"share_id": "...",
"to_drive_id": "...",
"to_parent_file_id": "..."
}
}
]
}
响应: {"responses": [{"status": 200, "body": {"file_id": "new_id"}}, ...]}
返回新的 file_id 列表
"""
drive_id = self.drive_id
if not drive_id:
drive_id = self.credential.get_drive_id()
if not drive_id:
logger.error("[AliyunTransfer] 缺少 drive_id无法转存")
return []
# 构建批量请求体
requests_list = []
for fid in file_ids:
requests_list.append({
"url": "/file/copy",
"body": {
"file_id": fid,
"share_id": share_id,
"to_drive_id": drive_id,
"to_parent_file_id": to_parent_file_id,
},
"headers": {"Content-Type": "application/json"},
"id": fid,
"method": "POST",
})
try:
headers = self.credential.get_headers()
headers["X-Share-Token"] = share_token
resp = self._session.post(
BATCH_URL,
json={"requests": requests_list, "resource": "file"},
headers=headers,
timeout=self.request_timeout * 2, # 批量操作可能较慢
)
data = resp.json()
if resp.status_code != 200:
logger.error(
f"[AliyunTransfer] 批量复制失败: "
f"HTTP {resp.status_code}, {data}"
)
return []
code = data.get("code", "")
if code:
logger.error(
f"[AliyunTransfer] 批量复制 API 错误: "
f"code={code}, message={data.get('message', '')}"
)
return []
# 提取新 file_id
new_ids = []
responses = data.get("responses", [])
for item in responses:
status = item.get("status", 0)
body = item.get("body", {})
if status in (200, 201, 202):
new_fid = body.get("file_id", "")
if new_fid:
new_ids.append(new_fid)
else:
logger.warning(
f"[AliyunTransfer] 单个文件复制失败: "
f"id={item.get('id')}, status={status}, body={body}"
)
if not new_ids:
logger.error("[AliyunTransfer] 所有文件复制均失败")
elif len(new_ids) < len(file_ids):
logger.warning(
f"[AliyunTransfer] 部分文件复制成功: "
f"{len(new_ids)}/{len(file_ids)}"
)
return new_ids
except requests.RequestException as e:
logger.error(f"[AliyunTransfer] 批量复制网络异常: {e}")
return []
except Exception as e:
logger.exception(f"[AliyunTransfer] 批量复制异常: {e}")
return []
# ─── 步骤 ④:创建新分享 ──────────────────────────────────
def _create_share(
self,
file_ids: List[str],
share_password: str = "",
expiration: str = "",
) -> Dict:
"""
POST /adrive/v2/share_link/create
请求体: {"drive_id": "...", "file_id_list": [...], "share_pwd": "...", "expiration": "..."}
响应: {"share_url": "...", "share_id": "..."}
"""
drive_id = self.drive_id or self.credential.get_drive_id()
if not drive_id:
logger.error("[AliyunTransfer] 缺少 drive_id无法创建分享")
return {"share_url": "", "share_pwd": ""}
body = {
"drive_id": drive_id,
"file_id_list": file_ids,
"share_pwd": share_password or "",
"expiration": expiration or "",
}
try:
headers = self.credential.get_headers()
resp = self._session.post(
CREATE_SHARE_URL,
json=body,
headers=headers,
timeout=self.request_timeout,
)
data = resp.json()
if resp.status_code != 200:
logger.error(
f"[AliyunTransfer] 创建分享失败: "
f"HTTP {resp.status_code}, {data}"
)
return {"share_url": "", "share_pwd": share_password}
code = data.get("code", "")
if code:
logger.error(
f"[AliyunTransfer] 创建分享 API 错误: "
f"code={code}, message={data.get('message', '')}"
)
return {"share_url": "", "share_pwd": share_password}
share_url = data.get("share_url", "")
share_pwd = data.get("share_pwd", share_password)
return {"share_url": share_url, "share_pwd": share_pwd}
except requests.RequestException as e:
logger.error(f"[AliyunTransfer] 创建分享网络异常: {e}")
return {"share_url": "", "share_pwd": share_password}
except Exception as e:
logger.exception(f"[AliyunTransfer] 创建分享异常: {e}")
return {"share_url": "", "share_pwd": share_password}
# ─── URL 解析 ──────────────────────────────────────────
@staticmethod
def _extract_share_id(url: str) -> Optional[str]:
"""从阿里云盘分享 URL 中提取 share_id"""
m = URL_PATTERN.search(url)
if m:
return m.group(1)
return None
@staticmethod
def extract_share_id_static(url: str) -> Optional[str]:
"""静态方法:提取 share_id"""
return AliyunTransfer._extract_share_id(url)
# ─── 工具方法 ──────────────────────────────────────────
def _error(self, message: str) -> Dict:
"""构造错误返回"""
return {
"success": False,
"share_name": "",
"share_id": "",
"new_file_ids": [],
"new_share_url": "",
"new_share_password": "",
"error": message,
}