175 lines
7.7 KiB
Python
175 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
||
"""通过 Delivery Desk API 创建作品或上传作品新版本。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import getpass
|
||
import json
|
||
import os
|
||
import sys
|
||
from datetime import datetime
|
||
from http.cookies import SimpleCookie
|
||
from http.cookiejar import CookieJar
|
||
from urllib.error import HTTPError, URLError
|
||
from urllib.request import HTTPCookieProcessor, Request, build_opener
|
||
|
||
|
||
class ApiError(RuntimeError):
|
||
pass
|
||
|
||
|
||
def request_json(opener, url: str, *, method: str = "GET", data: bytes | None = None, headers: dict[str, str] | None = None):
|
||
request = Request(url, data=data, method=method, headers=headers or {})
|
||
try:
|
||
with opener.open(request, timeout=15) as response:
|
||
body = response.read().decode("utf-8")
|
||
return response.status, json.loads(body) if body else None, response.headers
|
||
except HTTPError as error:
|
||
body = error.read().decode("utf-8", errors="replace")
|
||
try:
|
||
detail = json.loads(body).get("error", body)
|
||
except json.JSONDecodeError:
|
||
detail = body
|
||
raise ApiError(f"{method} {url} 返回 {error.code}: {detail}") from error
|
||
except URLError as error:
|
||
raise ApiError(f"无法连接 {url}: {error.reason}") from error
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(description="通过 API 创建测试作品或上传新版本")
|
||
parser.add_argument("--base-url", default=os.getenv("DELIVERY_DESK_BASE_URL", "http://127.0.0.1:3010"))
|
||
parser.add_argument("--project-id", type=int, default=None, help="不传时自动选择唯一可访问的项目")
|
||
parser.add_argument("--collection-id", type=int, default=None, help="不传时自动选择项目下唯一的作品交付集")
|
||
parser.add_argument("--work-id", type=int, default=None, help="传入后为该作品创建新版本")
|
||
parser.add_argument("--external-id", default=None, help="调用方作品唯一标识,用于幂等创建和找回作品")
|
||
parser.add_argument("--api-key", default=os.getenv("DELIVERY_DESK_API_KEY"))
|
||
parser.add_argument("--username", default=os.getenv("DELIVERY_DESK_USERNAME", "operator"))
|
||
parser.add_argument("--password", default=os.getenv("DELIVERY_DESK_PASSWORD"))
|
||
parser.add_argument("--title", default=None)
|
||
parser.add_argument("--description", default="由 tests/api_create_work.py 通过 API 创建。")
|
||
parser.add_argument("--tags", default="API 测试")
|
||
parser.add_argument(
|
||
"--image-url",
|
||
action="append",
|
||
dest="image_urls",
|
||
default=None,
|
||
help="公开可读的图片 URL,可重复传入;默认使用一张公共占位图",
|
||
)
|
||
return parser.parse_args()
|
||
|
||
|
||
def choose_item(items: list[dict], requested_id: int | None, label: str) -> dict:
|
||
if requested_id is not None:
|
||
item = next((candidate for candidate in items if int(candidate["id"]) == requested_id), None)
|
||
if item is None:
|
||
raise ApiError(f"无权访问或不存在的{label} ID: {requested_id}")
|
||
return item
|
||
if len(items) == 1:
|
||
return items[0]
|
||
choices = ", ".join(f'{item["id"]}:{item["name"]}' for item in items) or "无"
|
||
raise ApiError(f"可访问的{label}不是唯一项,请显式传入对应 ID。当前可选:{choices}")
|
||
|
||
|
||
def main() -> int:
|
||
args = parse_args()
|
||
base_url = args.base_url.rstrip("/")
|
||
opener = build_opener(HTTPCookieProcessor(CookieJar()))
|
||
auth_headers: dict[str, str] = {}
|
||
|
||
if args.api_key:
|
||
auth_headers["Authorization"] = f"Bearer {args.api_key}"
|
||
else:
|
||
password = args.password or getpass.getpass(f"请输入账号 {args.username} 的密码: ")
|
||
login_data = json.dumps({"username": args.username, "password": password}).encode("utf-8")
|
||
_, _, login_headers = request_json(
|
||
opener,
|
||
f"{base_url}/api/auth/login",
|
||
method="POST",
|
||
data=login_data,
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
cookies = SimpleCookie()
|
||
cookies.load(login_headers.get("Set-Cookie", ""))
|
||
session = cookies.get("proofing_session")
|
||
if session is None:
|
||
raise ApiError("登录成功,但接口没有返回会话 Cookie")
|
||
auth_headers["Cookie"] = f"proofing_session={session.value}"
|
||
|
||
_, projects, _ = request_json(opener, f"{base_url}/api/projects", headers=auth_headers)
|
||
project = choose_item(projects, args.project_id, "项目")
|
||
project_id = int(project["id"])
|
||
_, collections, _ = request_json(
|
||
opener,
|
||
f"{base_url}/api/projects/{project_id}/collections",
|
||
headers=auth_headers,
|
||
)
|
||
collection = choose_item(collections, args.collection_id, "作品交付集")
|
||
collection_id = int(collection["id"])
|
||
if int(collection["project_id"]) != project_id:
|
||
raise ApiError(f"作品交付集 {collection_id} 不属于项目 {project_id}")
|
||
|
||
title = args.title or f"API 测试作品 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
||
image_urls = args.image_urls or ["https://placehold.co/1200x800/png?text=Delivery+Desk+API+Test"]
|
||
upload_headers = {**auth_headers, "Content-Type": "application/json"}
|
||
payload = {
|
||
"title": title,
|
||
"description": args.description,
|
||
"tags": [args.tags] if args.tags else [],
|
||
"images": image_urls,
|
||
}
|
||
|
||
if args.work_id is not None:
|
||
_, current, _ = request_json(opener, f"{base_url}/api/notes/{args.work_id}", headers=auth_headers)
|
||
if int(current["project"]["id"]) != project_id or int(current["collection"]["id"]) != collection_id:
|
||
raise ApiError(f"作品 {args.work_id} 不属于选定的项目和作品交付集")
|
||
status, work, _ = request_json(
|
||
opener,
|
||
f"{base_url}/api/notes/{args.work_id}/versions",
|
||
method="POST",
|
||
data=json.dumps(payload).encode("utf-8"),
|
||
headers=upload_headers,
|
||
)
|
||
if status != 201 or not work or int(work.get("id", 0)) != args.work_id:
|
||
raise ApiError("新版本接口没有返回目标作品")
|
||
action = "version_created"
|
||
external_id = work.get("external_id")
|
||
else:
|
||
external_id = args.external_id or f"api-smoke-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
||
create_payload = {**payload, "collectionId": collection_id, "externalId": external_id}
|
||
body = json.dumps(create_payload).encode("utf-8")
|
||
status, work, _ = request_json(opener, f"{base_url}/api/notes", method="POST", data=body, headers=upload_headers)
|
||
if status not in (200, 201) or not work or int(work.get("collection_id", 0)) != collection_id:
|
||
raise ApiError("接口未返回属于目标作品交付集的作品")
|
||
_, repeated, _ = request_json(opener, f"{base_url}/api/notes", method="POST", data=body, headers=upload_headers)
|
||
if int(repeated.get("id", 0)) != int(work["id"]) or not repeated.get("idempotent"):
|
||
raise ApiError("相同 externalId 的重复请求未通过幂等校验")
|
||
action = "work_created" if status == 201 else "existing_work_returned"
|
||
|
||
print(
|
||
json.dumps(
|
||
{
|
||
"success": True,
|
||
"action": action,
|
||
"group": project.get("group_name"),
|
||
"project": project.get("name"),
|
||
"collection": collection.get("name"),
|
||
"work_id": work.get("id"),
|
||
"external_id": external_id,
|
||
"version_number": work.get("version_number"),
|
||
"title": work.get("title"),
|
||
},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
)
|
||
)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
raise SystemExit(main())
|
||
except (ApiError, ValueError) as error:
|
||
print(f"测试失败: {error}", file=sys.stderr)
|
||
raise SystemExit(1)
|