import base64 import secrets import time from pathlib import Path from threading import Lock from typing import List, Optional, Tuple, Union from hashlib import sha256 import oss2 import httpx from oss2 import SizedFileAdapter, determine_part_size from oss2.models import PartInfo from cryptography.hazmat.primitives import hashes from app import schemas from app.core.config import settings, global_vars from app.log import logger from app.modules.filemanager import StorageBase from app.modules.filemanager.storages import transfer_process from app.schemas.types import StorageSchema from app.utils.singleton import WeakSingleton from app.utils.string import StringUtils from app.utils.limit import QpsRateLimiter, RateStats lock = Lock() class NoCheckInException(Exception): pass class U115Pan(StorageBase, metaclass=WeakSingleton): """ 115相关操作 """ # 存储类型 schema = StorageSchema.U115 # 支持的整理方式 transtype = {"move": "移动", "copy": "复制"} # 基础url base_url = "https://proapi.115.com" # 文件块大小,默认10MB chunk_size = 10 * 1024 * 1024 # 下载接口单独限流 download_endpoint = "/open/ufile/downurl" # 风控触发后休眠时间(秒) limit_sleep_seconds = 3600 def __init__(self): super().__init__() self._auth_state = {} self.session = httpx.Client(follow_redirects=True, timeout=20.0) self._init_session() # 接口限流 self._download_limiter = QpsRateLimiter(1) self._api_limiter = QpsRateLimiter(3) self._limit_until = 0.0 self._limit_lock = Lock() # 总体 QPS/QPM/QPH 统计 self._rate_stats = RateStats(source="115") def _init_session(self): """ 初始化带速率限制的会话 """ self.session.headers.update( { "User-Agent": "W115Storage/2.0", "Accept-Encoding": "gzip, deflate", "Content-Type": "application/x-www-form-urlencoded", } ) def _check_session(self): """ 检查会话是否过期 """ if not self.access_token: raise NoCheckInException("【115】请先扫码登录!") @property def access_token(self) -> Optional[str]: """ 访问token """ with lock: tokens = self.get_conf() refresh_token = tokens.get("refresh_token") if not refresh_token: return None expires_in = tokens.get("expires_in", 0) refresh_time = tokens.get("refresh_time", 0) if expires_in and refresh_time + expires_in < int(time.time()): tokens = self.__refresh_access_token(refresh_token) if tokens: self.set_config({"refresh_time": int(time.time()), **tokens}) else: return None access_token = tokens.get("access_token") if access_token: self.session.headers.update({"Authorization": f"Bearer {access_token}"}) return access_token def generate_auth_url(self) -> Tuple[dict, str]: """ 生成 OAuth2 授权 URL """ try: resp = self.session.get(f"{settings.U115_AUTH_SERVER}/u115/auth_url") if resp is None: return {}, "无法连接到授权服务器" result = resp.json() if not result.get("success"): return {}, result.get("message", "获取授权URL失败") data = result.get("data", {}) auth_url = data.get("auth_url") state = data.get("state") if not auth_url or not state: return {}, "授权服务器返回数据不完整" self._auth_state = {"state": state} return {"authUrl": auth_url, "state": state}, "" except Exception as e: logger.error(f"【115】获取授权 URL 失败: {str(e)}") return {}, f"获取授权 URL 失败: {str(e)}" def generate_qrcode(self) -> Tuple[dict, str]: """ 实现PKCE规范的设备授权二维码生成 """ # 生成PKCE参数 code_verifier = secrets.token_urlsafe(96)[:128] code_challenge = base64.b64encode( sha256(code_verifier.encode("utf-8")).digest() ).decode("utf-8") # 请求设备码 resp = self.session.post( "https://passportapi.115.com/open/authDeviceCode", data={ "client_id": settings.U115_APP_ID, "code_challenge": code_challenge, "code_challenge_method": "sha256", }, ) if resp is None: return {}, "网络错误" result = resp.json() if result.get("code") != 0: return {}, result.get("message") # 持久化验证参数 self._auth_state = { "code_verifier": code_verifier, "uid": result["data"]["uid"], "time": result["data"]["time"], "sign": result["data"]["sign"], } # 生成二维码内容 return {"codeContent": result["data"]["qrcode"]}, "" def check_login(self) -> Optional[Tuple[dict, str]]: """ 检查授权状态 """ if self._auth_state and self._auth_state.get("state"): return self.__check_oauth_login() if not self._auth_state: return {}, "生成二维码失败" try: resp = self.session.get( "https://qrcodeapi.115.com/get/status/", params={ "uid": self._auth_state["uid"], "time": self._auth_state["time"], "sign": self._auth_state["sign"], }, ) if resp is None: return {}, "网络错误" result = resp.json() if result.get("code") != 0 or not result.get("data"): return {}, result.get("message") if result["data"]["status"] == 2: tokens = self.__get_access_token() self.set_config({"refresh_time": int(time.time()), **tokens}) return { "status": result["data"]["status"], "tip": result["data"]["msg"], }, "" except Exception as e: return {}, str(e) def __check_oauth_login(self) -> Tuple[dict, str]: """ 检查 OAuth2 授权状态 """ state = self._auth_state.get("state") if not state: return {}, "state为空" try: resp = self.session.get( f"{settings.U115_AUTH_SERVER}/u115/token", params={"state": state} ) if resp is None: return {}, "无法连接到授权服务器" result = resp.json() status = result.get("status", "pending") if status == "completed": data = result.get("data", {}) if data: self.set_config( { "refresh_time": int(time.time()), "access_token": data.get("access_token"), "refresh_token": data.get("refresh_token"), "expires_in": data.get("expires_in"), } ) self._auth_state = {} return {"status": 2, "tip": "授权成功"}, "" return {}, "授权服务器返回数据不完整" elif status == "expired": self._auth_state = {} return {"status": -1, "tip": result.get("message", "授权已过期")}, "" else: return {"status": 0, "tip": "等待用户授权"}, "" except Exception as e: logger.error(f"【115】检查授权状态失败: {str(e)}") return {}, f"检查授权状态失败: {str(e)}" def __get_access_token(self) -> dict: """ 确认登录后,获取相关token """ if not self._auth_state: raise Exception("【115】请先生成二维码") resp = self.session.post( "https://passportapi.115.com/open/deviceCodeToToken", data={ "uid": self._auth_state["uid"], "code_verifier": self._auth_state["code_verifier"], }, ) if resp is None: raise Exception("获取 access_token 失败") result = resp.json() if result.get("code") != 0: raise Exception(result.get("message")) return result["data"] def __refresh_access_token(self, refresh_token: str) -> Optional[dict]: """ 刷新access_token """ resp = self.session.post( "https://passportapi.115.com/open/refreshToken", data={"refresh_token": refresh_token}, ) if resp is None: logger.error( f"【115】刷新 access_token 失败:refresh_token={refresh_token}" ) return None result = resp.json() if result.get("code") != 0: logger.warn( f"【115】刷新 access_token 失败:{result.get('code')} - {result.get('message')}!" ) return None return result.get("data") def _request_api( self, method: str, endpoint: str, result_key: Optional[str] = None, **kwargs ) -> Optional[Union[dict, list]]: """ 带错误处理和速率限制的API请求 """ # 检查会话 self._check_session() # 错误日志标志 no_error_log = kwargs.pop("no_error_log", False) # 重试次数 retry_times = kwargs.pop("retry_limit", 3) # 按接口类型限流 if endpoint == self.download_endpoint: self._download_limiter.acquire() else: self._api_limiter.acquire() self._rate_stats.record() # 风控冷却期间阻止所有接口调用,统一等待 with self._limit_lock: wait_until = self._limit_until if wait_until > time.time(): wait_secs = wait_until - time.time() logger.info( f"【115】风控冷却中,本请求等待 {wait_secs:.0f} 秒后再调用接口..." ) time.sleep(wait_secs) try: resp = self.session.request(method, f"{self.base_url}{endpoint}", **kwargs) except httpx.RequestError as e: logger.error(f"【115】{method} 请求 {endpoint} 网络错误: {str(e)}") return None if resp is None: logger.warn(f"【115】{method} 请求 {endpoint} 失败!") return None kwargs["retry_limit"] = retry_times if resp.status_code == 429: self._rate_stats.log_stats("warning") if retry_times <= 0: logger.error( f"【115】{method} 请求 {endpoint} 触发限流(429),重试次数用尽!" ) return None with self._limit_lock: self._limit_until = max( self._limit_until, time.time() + self.limit_sleep_seconds, ) logger.warning( f"【115】触发限流(429),全体接口进入风控冷却 {self.limit_sleep_seconds} 秒,随后重试..." ) time.sleep(self.limit_sleep_seconds) kwargs["retry_limit"] = retry_times - 1 kwargs["no_error_log"] = no_error_log return self._request_api(method, endpoint, result_key, **kwargs) # 处理请求错误 try: resp.raise_for_status() except httpx.HTTPStatusError as e: if retry_times <= 0: logger.error( f"【115】{method} 请求 {endpoint} 错误 {e},重试次数用尽!" ) return None kwargs["retry_limit"] = retry_times - 1 kwargs["no_error_log"] = no_error_log sleep_duration = 2 ** (5 - retry_times + 1) logger.info( f"【115】{method} 请求 {endpoint} 错误 {e},等待 {sleep_duration} 秒后重试..." ) time.sleep(sleep_duration) return self._request_api(method, endpoint, result_key, **kwargs) # 返回数据 ret_data = resp.json() if ret_data.get("code") not in (0, 20004): error_msg = ret_data.get("message", "") if not no_error_log: logger.warn(f"【115】{method} 请求 {endpoint} 出错:{error_msg}") if "已达到当前访问上限" in error_msg: self._rate_stats.log_stats("warning") if retry_times <= 0: logger.error( f"【115】{method} 请求 {endpoint} 触发风控(访问上限),重试次数用尽!" ) return None with self._limit_lock: self._limit_until = max( self._limit_until, time.time() + self.limit_sleep_seconds, ) logger.warning( f"【115】触发风控(访问上限),全体接口进入风控冷却 {self.limit_sleep_seconds} 秒,随后重试..." ) time.sleep(self.limit_sleep_seconds) kwargs["retry_limit"] = retry_times - 1 kwargs["no_error_log"] = no_error_log return self._request_api(method, endpoint, result_key, **kwargs) return None if result_key: return ret_data.get(result_key) return ret_data @staticmethod def _calc_sha1(filepath: Path, size: Optional[int] = None) -> str: """ 计算文件SHA1(符合115规范) size: 前多少字节 """ sha1 = hashes.Hash(hashes.SHA1()) with open(filepath, "rb") as f: if size: chunk = f.read(size) sha1.update(chunk) else: while chunk := f.read(8192): sha1.update(chunk) return sha1.finalize().hex() def init_storage(self): pass def list(self, fileitem: schemas.FileItem) -> List[schemas.FileItem]: """ 目录遍历实现 """ if fileitem.type == "file": item = self.detail(fileitem) if item: return [item] return [] if fileitem.path == "/": cid = "0" else: cid = fileitem.fileid if not cid: _fileitem = self.get_item(Path(fileitem.path)) if not _fileitem: logger.warn(f"【115】获取目录 {fileitem.path} 失败!") return [] cid = _fileitem.fileid items = [] offset = 0 while True: resp = self._request_api( "GET", "/open/ufile/files", "data", params={ "cid": int(cid), "limit": 1000, "offset": offset, "cur": True, "show_dir": 1, }, ) if resp is None: raise FileNotFoundError(f"【115】{fileitem.path} 检索出错!") if not resp: break for item in resp: parent_path = Path(fileitem.path) # noqa item_name = item["fn"] full_path = parent_path / item_name items.append( schemas.FileItem( storage=self.schema.value, fileid=str(item["fid"]), parent_fileid=cid, name=item["fn"], basename=Path(item["fn"]).stem, extension=item["ico"] if item["fc"] == "1" else None, type="dir" if item["fc"] == "0" else "file", path=full_path.as_posix() + ("/" if item["fc"] == "0" else ""), size=item["fs"] if item["fc"] == "1" else None, modify_time=item["upt"], pickcode=item["pc"], ) ) if len(resp) < 1000: break offset += len(resp) return items def create_folder( self, parent_item: schemas.FileItem, name: str ) -> Optional[schemas.FileItem]: """ 创建目录 """ new_path = Path(parent_item.path) / name resp = self._request_api( "POST", "/open/folder/add", data={ "pid": 0 if parent_item.path == "/" else int(parent_item.fileid or 0), "file_name": name, }, ) if not resp: return None if not resp.get("state"): if resp.get("code") == 20004: # 目录已存在 return self.get_item(new_path) logger.warn(f"【115】创建目录失败: {resp.get('error')}") return None return schemas.FileItem( storage=self.schema.value, fileid=str(resp["data"]["file_id"]), path=new_path.as_posix() + "/", name=name, basename=name, type="dir", modify_time=int(time.time()), ) def upload( self, target_dir: schemas.FileItem, local_path: Path, new_name: Optional[str] = None, ) -> Optional[schemas.FileItem]: """ 实现带秒传、断点续传和二次认证的文件上传 """ def encode_callback(cb: str) -> str: return oss2.utils.b64encode_as_string(cb) target_name = new_name or local_path.name target_path = Path(target_dir.path) / target_name # 计算文件特征值 file_size = local_path.stat().st_size file_sha1 = self._calc_sha1(local_path) file_preid = self._calc_sha1(local_path, 128 * 1024 * 1024) # 获取目标目录CID target_cid = target_dir.fileid target_param = f"U_1_{target_cid}" # Step 1: 初始化上传 init_data = { "file_name": target_name, "file_size": file_size, "target": target_param, "fileid": file_sha1, "preid": file_preid, } init_resp = self._request_api("POST", "/open/upload/init", data=init_data) if not init_resp: return None if not init_resp.get("state"): logger.warn(f"【115】初始化上传失败: {init_resp.get('error')}") return None # 结果 init_result = init_resp.get("data") logger.debug(f"【115】上传 Step 1 初始化结果: {init_result}") # 回调信息 bucket_name = init_result.get("bucket") object_name = init_result.get("object") callback = init_result.get("callback") # 二次认证信息 sign_check = init_result.get("sign_check") pick_code = init_result.get("pick_code") sign_key = init_result.get("sign_key") # Step 2: 处理二次认证 if init_result.get("code") in [700, 701] and sign_check: sign_checks = sign_check.split("-") start = int(sign_checks[0]) end = int(sign_checks[1]) # 计算指定区间的SHA1 # sign_check (用下划线隔开,截取上传文内容的sha1)(单位是byte): "2392148-2392298" with open(local_path, "rb") as f: # 取2392148-2392298之间的内容(包含2392148、2392298)的sha1 f.seek(start) chunk = f.read(end - start + 1) sha1 = hashes.Hash(hashes.SHA1()) sha1.update(chunk) sign_val = sha1.finalize().hex().upper() # 重新初始化请求 # sign_key,sign_val(根据sign_check计算的值大写的sha1值) init_data.update( {"pick_code": pick_code, "sign_key": sign_key, "sign_val": sign_val} ) init_resp = self._request_api("POST", "/open/upload/init", data=init_data) if not init_resp: return None if not init_resp.get("state"): logger.warn(f"【115】上传二次认证失败: {init_resp.get('error')}") return None # 二次认证结果 init_result = init_resp.get("data") logger.debug(f"【115】上传 Step 2 二次认证结果: {init_result}") if not pick_code: pick_code = init_result.get("pick_code") if not bucket_name: bucket_name = init_result.get("bucket") if not object_name: object_name = init_result.get("object") if not callback: callback = init_result.get("callback") # Step 3: 秒传 if init_result.get("status") == 2: logger.info(f"【115】{target_name} 秒传成功") file_id = init_result.get("file_id", None) if file_id: logger.debug(f"【115】{target_name} 使用秒传返回ID获取文件信息") time.sleep(2) info_resp = self._request_api( "GET", "/open/folder/get_info", "data", params={"file_id": int(file_id)}, ) if info_resp: return schemas.FileItem( storage=self.schema.value, fileid=str(info_resp["file_id"]), path=target_path.as_posix() + ("/" if info_resp["file_category"] == "0" else ""), type="file" if info_resp["file_category"] == "1" else "dir", name=info_resp["file_name"], basename=Path(info_resp["file_name"]).stem, extension=Path(info_resp["file_name"]).suffix[1:] if info_resp["file_category"] == "1" else None, pickcode=info_resp["pick_code"], size=StringUtils.num_filesize(info_resp["size"]) if info_resp["file_category"] == "1" else None, modify_time=info_resp["utime"], ) return self.get_item(target_path) # Step 4: 获取上传凭证 token_resp = self._request_api("GET", "/open/upload/get_token", "data") if not token_resp: logger.warn("【115】获取上传凭证失败") return None logger.debug(f"【115】上传 Step 4 获取上传凭证结果: {token_resp}") # 上传凭证 endpoint = token_resp.get("endpoint") AccessKeyId = token_resp.get("AccessKeyId") AccessKeySecret = token_resp.get("AccessKeySecret") SecurityToken = token_resp.get("SecurityToken") # Step 5: 断点续传 resume_resp = self._request_api( "POST", "/open/upload/resume", "data", data={ "file_size": file_size, "target": target_param, "fileid": file_sha1, "pick_code": pick_code, }, ) if resume_resp: logger.debug(f"【115】上传 Step 5 断点续传结果: {resume_resp}") if resume_resp.get("callback"): callback = resume_resp["callback"] # Step 6: 对象存储上传 auth = oss2.StsAuth( access_key_id=AccessKeyId, access_key_secret=AccessKeySecret, security_token=SecurityToken, ) bucket = oss2.Bucket(auth, endpoint, bucket_name) # noqa # determine_part_size方法用于确定分片大小,设置分片大小为 10M part_size = determine_part_size(file_size, preferred_size=10 * 1024 * 1024) # 初始化进度条 logger.info( f"【115】开始上传: {local_path} -> {target_path},分片大小:{StringUtils.str_filesize(part_size)}" ) progress_callback = transfer_process(local_path.as_posix()) # 初始化分片 upload_id = bucket.init_multipart_upload( object_name, params={"encoding-type": "url", "sequential": ""} ).upload_id parts = [] # 逐个上传分片 with open(local_path, "rb") as fileobj: part_number = 1 offset = 0 while offset < file_size: if global_vars.is_transfer_stopped(local_path.as_posix()): logger.info(f"【115】{local_path} 上传已取消!") return None num_to_upload = min(part_size, file_size - offset) # 调用SizedFileAdapter(fileobj, size)方法会生成一个新的文件对象,重新计算起始追加位置。 logger.info( f"【115】开始上传 {target_name} 分片 {part_number}: {offset} -> {offset + num_to_upload}" ) result = bucket.upload_part( object_name, upload_id, part_number, data=SizedFileAdapter(fileobj, num_to_upload), ) parts.append(PartInfo(part_number, result.etag)) logger.info(f"【115】{target_name} 分片 {part_number} 上传完成") offset += num_to_upload part_number += 1 # 更新进度 progress = (offset * 100) / file_size progress_callback(progress) # 完成上传 progress_callback(100) # 请求头 headers = { "X-oss-callback": encode_callback(callback["callback"]), "x-oss-callback-var": encode_callback(callback["callback_var"]), "x-oss-forbid-overwrite": "false", } try: result = bucket.complete_multipart_upload( object_name, upload_id, parts, headers=headers ) if result.status == 200: logger.debug( f"【115】上传 Step 6 回调结果:{result.resp.response.json()}" ) logger.info(f"【115】{target_name} 上传成功") else: logger.warn(f"【115】{target_name} 上传失败,错误码: {result.status}") return None except oss2.exceptions.OssError as e: if e.code == "FileAlreadyExists": logger.warn(f"【115】{target_name} 已存在") else: logger.error( f"【115】{target_name} 上传失败: {e.status}, 错误码: {e.code}, 详情: {e.message}" ) return None # 返回结果 return self.get_item(target_path) def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]: """ 带实时进度显示的下载 """ detail = self.get_item(Path(fileitem.path)) if not detail: logger.error(f"【115】获取文件详情失败: {fileitem.name}") return None download_info = self._request_api( "POST", "/open/ufile/downurl", "data", data={"pick_code": detail.pickcode} ) if not download_info: logger.error(f"【115】获取下载链接失败: {fileitem.name}") return None download_url = list(download_info.values())[0].get("url", {}).get("url") if not download_url: logger.error(f"【115】下载链接为空: {fileitem.name}") return None local_path = (path or settings.TEMP_PATH) / fileitem.name # 获取文件大小 file_size = detail.size # 初始化进度条 logger.info(f"【115】开始下载: {fileitem.name} -> {local_path}") progress_callback = transfer_process(Path(fileitem.path).as_posix()) try: with self.session.stream("GET", download_url) as r: r.raise_for_status() downloaded_size = 0 with open(local_path, "wb") as f: for chunk in r.iter_bytes(chunk_size=self.chunk_size): if global_vars.is_transfer_stopped(fileitem.path): logger.info(f"【115】{fileitem.path} 下载已取消!") r.close() return None f.write(chunk) downloaded_size += len(chunk) if file_size: progress = (downloaded_size * 100) / file_size progress_callback(progress) # 完成下载 progress_callback(100) logger.info(f"【115】下载完成: {fileitem.name}") except httpx.RequestError as e: logger.error(f"【115】下载网络错误: {fileitem.name} - {str(e)}") # 删除可能部分下载的文件 if local_path.exists(): local_path.unlink() return None except Exception as e: logger.error(f"【115】下载失败: {fileitem.name} - {str(e)}") # 删除可能部分下载的文件 if local_path.exists(): local_path.unlink() return None return local_path def check(self) -> bool: return self.access_token is not None def delete(self, fileitem: schemas.FileItem) -> bool: """ 删除文件/目录 """ try: self._request_api( "POST", "/open/ufile/delete", data={"file_ids": int(fileitem.fileid)} ) return True except httpx.HTTPError: return False def rename(self, fileitem: schemas.FileItem, name: str) -> bool: """ 重命名文件/目录 """ resp = self._request_api( "POST", "/open/ufile/update", data={"file_id": int(fileitem.fileid), "file_name": name}, ) if not resp: return False if resp["state"]: return True return False def get_item(self, path: Path) -> Optional[schemas.FileItem]: """ 获取指定路径的文件/目录项 """ try: resp = self._request_api( "POST", "/open/folder/get_info", "data", data={"path": path.as_posix()}, no_error_log=True, ) if not resp: return None return schemas.FileItem( storage=self.schema.value, fileid=str(resp["file_id"]), path=path.as_posix() + ("/" if resp["file_category"] == "0" else ""), type="file" if resp["file_category"] == "1" else "dir", name=resp["file_name"], basename=Path(resp["file_name"]).stem, extension=Path(resp["file_name"]).suffix[1:] if resp["file_category"] == "1" else None, pickcode=resp["pick_code"], size=resp["size_byte"] if resp["file_category"] == "1" else None, modify_time=resp["utime"], ) except Exception as e: logger.debug(f"【115】获取文件信息失败: {str(e)}") return None def get_folder(self, path: Path) -> Optional[schemas.FileItem]: """ 获取指定路径的文件夹,如不存在则创建 """ def __find_dir( _fileitem: schemas.FileItem, _name: str ) -> Optional[schemas.FileItem]: """ 查找下级目录中匹配名称的目录 """ for sub_folder in self.list(_fileitem): if sub_folder.type != "dir": continue if sub_folder.name == _name: return sub_folder return None # 是否已存在 folder = self.get_item(path) if folder: return folder # 逐级查找和创建目录 fileitem = schemas.FileItem(storage=self.schema.value, path="/") for part in path.parts[1:]: dir_file = __find_dir(fileitem, part) if dir_file: fileitem = dir_file else: dir_file = self.create_folder(fileitem, part) if not dir_file: logger.warn(f"【115】创建目录 {fileitem.path}{part} 失败!") return None fileitem = dir_file return fileitem def detail(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]: """ 获取文件/目录详细信息 """ return self.get_item(Path(fileitem.path)) def copy(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 复制 """ if fileitem.fileid is None: fileitem = self.get_item(Path(fileitem.path)) if not fileitem: logger.warn(f"【115】获取文件 {fileitem.path} 失败!") return False dest_fileitem = self.get_item(path) if not dest_fileitem or dest_fileitem.type != "dir": logger.warn(f"【115】目标路径 {path} 不是一个有效的目录!") return False resp = self._request_api( "POST", "/open/ufile/copy", data={ "file_id": int(fileitem.fileid), "pid": int(dest_fileitem.fileid), }, ) if not resp: return False if resp["state"]: new_path = Path(path) / fileitem.name new_item = self.get_item(new_path) if not new_item: return False if self.rename(new_item, new_name): return True return False def move(self, fileitem: schemas.FileItem, path: Path, new_name: str) -> bool: """ 移动 """ if fileitem.fileid is None: fileitem = self.get_item(Path(fileitem.path)) if not fileitem: logger.warn(f"【115】获取文件 {fileitem.path} 失败!") return False dest_fileitem = self.get_item(path) if not dest_fileitem or dest_fileitem.type != "dir": logger.warn(f"【115】目标路径 {path} 不是一个有效的目录!") return False resp = self._request_api( "POST", "/open/ufile/move", data={ "file_ids": int(fileitem.fileid), "to_cid": int(dest_fileitem.fileid), }, ) if not resp: return False if resp["state"]: new_path = Path(path) / fileitem.name new_file = self.get_item(new_path) if not new_file: return False if self.rename(new_file, new_name): return True return False def link(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass def softlink(self, fileitem: schemas.FileItem, target_file: Path) -> bool: pass def usage(self) -> Optional[schemas.StorageUsage]: """ 存储使用情况 """ try: resp = self._request_api("GET", "/open/user/info", "data") if not resp: return None space = resp["rt_space_info"] return schemas.StorageUsage( total=space["all_total"]["size"], available=space["all_remain"]["size"] ) except NoCheckInException: return None