本文给出完整的文件传输对接流程:将本地文件上传到云手机、从云手机导出文件、轮询异步任务,以及在超时后安全恢复。
| 步骤 | 开放 API | 本地 API |
|---|---|---|
| Base URL | https://api.morelogin.com | http://127.0.0.1:40000 |
| 认证 | Authorization: Bearer <token> | 启用本地 Token 时传入本地 Token |
| 获取上传地址 | POST /cloudphone/uploadUrl | POST /api/cloudphone/upload/file/signedUrl |
| 下发已上传文件 | POST /cloudphone/uploadFile | POST /api/cloudphone/upload/file |
| 查询上传结果 | POST /cloudphone/uploadFileResult | POST /api/cloudphone/upload/file/result |
| 创建文件导出任务 | POST /cloudphone/download | POST /api/cloudphone/download |
| 查询导出结果 | POST /cloudphone/download/result | POST /api/cloudphone/download/result |
下文以开放 API 为例。本地 API 只需按上表替换 Base URL 和路径。云手机 ID 与任务 ID 必须始终按字符串保存;JavaScript 客户端不能先转换为 Number。
当文件与 MoreLogin 客户端位于同一台电脑时,本地 API 还提供 POST /api/cloudphone/uploadFile,请求类型为 multipart/form-data:
curl --request POST "http://127.0.0.1:40000/api/cloudphone/uploadFile" \
--form 'id=1661515884160372' \
--form 'uploadDest=/Download' \
--form 'file=@./report.csv'调用方位于远程服务器、文件较大,或需要通过 fileId 明确轮询状态时,建议使用下文的预签名 URL 流程。
curl --request POST "https://api.morelogin.com/cloudphone/uploadUrl" \
--header "Authorization: Bearer $MORELOGIN_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"id": "1661515884160372",
"fileName": "report.csv"
}'响应中的地址为 data.presignedUrl。临时对象会在 7 天后自动删除,但不会删除已经成功下发到云手机内的文件。
直接向预签名地址上传文件,不要在这个存储请求中携带 MoreLogin Bearer Token。
curl --location --request PUT \
--upload-file "./report.csv" \
"$PRESIGNED_URL"仅在存储服务返回成功 HTTP 状态后继续。如果请求失败或超时,可以在地址仍有效时对同一个地址重试 PUT。
传入刚刚上传的对象地址。预签名 URL 的查询参数包含凭证;如果对象存储配置允许,请在保存或记录 URL 前移除查询参数。
FILE_URL="${PRESIGNED_URL%%\?*}"
curl --request POST "https://api.morelogin.com/cloudphone/uploadFile" \
--header "Authorization: Bearer $MORELOGIN_TOKEN" \
--header "Content-Type: application/json" \
--data "$(jq -n \
--arg id '1661515884160372' \
--arg url "$FILE_URL" \
--arg dest '/Download' \
'{id: $id, url: $url, uploadDest: $dest, uploadType: 1}')"普通文件使用 uploadType: 1。只有支持直播的 MP4 文件才使用 uploadType: 2。保存响应中的 data.fileId。
curl --request POST "https://api.morelogin.com/cloudphone/uploadFileResult" \
--header "Authorization: Bearer $MORELOGIN_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"id": "1661515884160372",
"fileId": "1661515884160111"
}'data / data.status | 含义 | 处理方式 |
|---|---|---|
null | 任务暂时还不可见 | 等待后继续轮询 |
0 | 上传中 | 等待后继续轮询 |
1 | 上传成功 | 停止轮询 |
2 | 上传失败 | 停止轮询并记录 requestId |
建议每 2 秒查询一次,并根据文件大小设置客户端总超时。状态查询超时不代表上传任务失败,应继续查询已有 fileId,不要直接重复创建任务。
这里的“下载”实际是先将云手机文件导出到临时地址,再由调用方下载,是一个两阶段异步流程。
如果轮询返回 30(失败)或 40(已取消),应停止流程,不要再请求 downUrl。
必须传入包含文件名的 Android 绝对路径。
curl --request POST "https://api.morelogin.com/cloudphone/download" \
--header "Authorization: Bearer $MORELOGIN_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"id": "1661515884160372",
"filePath": "/sdcard/Download/report.csv"
}'保存 data.downId。该请求只创建导出任务,不会直接返回文件内容。
curl --request POST "https://api.morelogin.com/cloudphone/download/result" \
--header "Authorization: Bearer $MORELOGIN_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"id": "1661515884160372",
"downId": "1661515884160999"
}'data.status | 含义 | 处理方式 |
|---|---|---|
10 | 执行中 | 等待后继续轮询 |
20 | 成功 | 使用 data.downUrl 下载 |
30 | 失败 | 停止并记录 requestId |
40 | 已取消 | 停止;仍有需要时再创建新任务 |
状态为 20 后应尽快下载文件:
curl --location "$DOWN_URL" --output "./report.csv"不要向 downUrl 携带 MoreLogin Bearer Token。预签名上传地址和下载地址都应视为敏感信息,不应写入应用日志。
下面的代码需要安装 requests。它会同时检查 HTTP 状态和业务 code,设置轮询截止时间,并始终复用服务端返回的任务 ID。
import os
import time
from pathlib import Path
import requests
BASE_URL = "https://api.morelogin.com"
TOKEN = os.environ["MORELOGIN_TOKEN"]
PHONE_ID = "1661515884160372"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
def api_post(path, payload):
response = requests.post(
f"{BASE_URL}{path}", json=payload, headers=HEADERS, timeout=30
)
response.raise_for_status()
body = response.json()
if body.get("code") != 0:
raise RuntimeError(
f"API error code={body.get('code')} msg={body.get('msg')} "
f"requestId={body.get('requestId')}"
)
return body
def wait_for(path, payload, terminal, timeout=300):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
body = api_post(path, payload)
data = body.get("data")
if data is not None and data.get("status") in terminal:
return body
time.sleep(2)
raise TimeoutError(f"Timed out while polling {path}; keep the task ID")
def upload_to_phone(local_path, destination="/Download"):
path = Path(local_path)
signed = api_post(
"/cloudphone/uploadUrl", {"id": PHONE_ID, "fileName": path.name}
)
presigned_url = signed["data"]["presignedUrl"]
with path.open("rb") as stream:
put_response = requests.put(presigned_url, data=stream, timeout=120)
put_response.raise_for_status()
object_url = presigned_url.split("?", 1)[0]
created = api_post(
"/cloudphone/uploadFile",
{
"id": PHONE_ID,
"url": object_url,
"uploadDest": destination,
"uploadType": 1,
},
)
file_id = created["data"]["fileId"]
result = wait_for(
"/cloudphone/uploadFileResult",
{"id": PHONE_ID, "fileId": file_id},
terminal={1, 2},
)
if result["data"]["status"] != 1:
raise RuntimeError(f"Upload failed; requestId={result.get('requestId')}")
return file_id
def download_from_phone(remote_path, local_path):
created = api_post(
"/cloudphone/download", {"id": PHONE_ID, "filePath": remote_path}
)
down_id = created["data"]["downId"]
result = wait_for(
"/cloudphone/download/result",
{"id": PHONE_ID, "downId": down_id},
terminal={20, 30, 40},
)
if result["data"]["status"] != 20:
raise RuntimeError(f"Download export failed; requestId={result.get('requestId')}")
with requests.get(result["data"]["downUrl"], stream=True, timeout=120) as response:
response.raise_for_status()
with open(local_path, "wb") as output:
for chunk in response.iter_content(1024 * 1024):
if chunk:
output.write(chunk)
return down_id
upload_to_phone("./report.csv")
download_from_phone("/sdcard/Download/report.csv", "./downloaded-report.csv")- 必须检查
code == 0,HTTP200本身不代表业务操作成功。 id、fileId和downId始终按字符串保存。- 状态查询使用退避重试。创建任务请求超时后,如果已经获得任务 ID,应先查询原任务。
- 限制并发传输数量,并遵守限流规则。
- 大文件传输前检查本地文件大小与云手机剩余存储空间。
- 为排障记录
requestId、端点、云手机 ID、任务 ID 和终态,但必须脱敏 Token 与签名 URL。