Files
delivery-desk/.agents/skills/upload-delivery-desk-work/scripts/delivery_desk_upload.py

535 lines
25 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Two-phase, target-safe Delivery Desk work uploader."""
from __future__ import annotations
import argparse
import hashlib
import http.client
import json
import os
import re
import sys
import tempfile
import uuid
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlsplit
from urllib.request import Request, urlopen
MAX_LOCAL_IMAGE_BYTES = 20 * 1024 * 1024
DEFAULT_BASE_URL = "http://192.168.30.90:3010"
LOCAL_IMAGE_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".jfif": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".avif": "image/avif",
}
class UploadError(RuntimeError):
def __init__(self, message: str, *, action: str = "ask_operator", next_step: str = "停止操作,将错误和目标信息告知调用者并等待处理") -> None:
super().__init__(message)
self.action = action
self.next_step = next_step
def payload(self) -> dict[str, Any]:
return {"success": False, "action": self.action, "error": str(self), "next_step": self.next_step}
def revise(message: str, next_step: str) -> UploadError:
return UploadError(message, action="revise", next_step=next_step)
def retry(message: str, next_step: str) -> UploadError:
return UploadError(message, action="retry", next_step=next_step)
def http_failure(method: str, path: str, status: int, detail: str) -> UploadError:
message = f"{method} {path} 返回 {status}: {detail}"
if status in {400, 413, 422}:
return revise(message, "根据错误修改输入、图片 URL 或本地文件,重新生成计划,并取得新的确认码后再执行")
if status == 401:
return UploadError(message, next_step="停止操作,请调用者配置或更换有效的 DELIVERY_DESK_API_KEY")
if status == 403:
return UploadError(message, next_step="停止操作,向调用者报告已确认的运营组和项目,请管理员修正 Key 权限;不要改选其他项目")
if status == 404:
return UploadError(message, next_step="重新执行只读发现命令核对目标;若目标已删除或不可见,询问调用者,不要猜测新目标")
if status == 409:
return UploadError(message, next_step="停止操作并报告目标当前状态,请调用者或管理员解除冲突;不要更换目标或自动重试")
if method == "GET" and status in {429, 502, 503, 504}:
return retry(message, "等待片刻后原样重试当前只读命令;不要改变目标或参数")
if method == "POST":
return UploadError(message, next_step="写入结果可能不确定,禁止自动重试;先只读检查目标状态,再询问调用者")
return UploadError(message, next_step="停止操作并向调用者报告服务异常;确认服务恢复后再重新执行只读发现")
def api_key() -> str:
value = os.getenv("DELIVERY_DESK_API_KEY", "").strip()
if not value:
raise UploadError("缺少 DELIVERY_DESK_API_KEY请由操作者在环境变量中配置不要粘贴到计划文件")
return value
def base_url(value: str | None = None) -> str:
return (value or DEFAULT_BASE_URL).rstrip("/")
def request_json(base: str, path: str, *, method: str = "GET", body: dict[str, Any] | None = None) -> tuple[int, Any]:
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
headers = {"Authorization": f"Bearer {api_key()}", "Accept": "application/json"}
if data is not None:
headers["Content-Type"] = "application/json"
request = Request(f"{base}{path}", data=data, method=method, headers=headers)
try:
with urlopen(request, timeout=20) as response:
raw = response.read().decode("utf-8")
return response.status, json.loads(raw) if raw else None
except HTTPError as error:
raw = error.read().decode("utf-8", errors="replace")
try:
payload = json.loads(raw)
detail = payload.get("error", raw) if isinstance(payload, dict) else raw
except json.JSONDecodeError:
detail = raw
raise http_failure(method, path, error.code, str(detail)) from error
except URLError as error:
if method == "POST":
raise UploadError(
f"state_unknown: {method} {path} 的结果未知,禁止自动重试;请先检查服务器状态:{error.reason}",
next_step="使用 works --external-id 或 inspect-work 只读核对服务器状态;若仍无法确认,询问调用者后再决定",
) from error
raise retry(f"无法连接 {base}: {error.reason}", "确认服务地址正确且服务可达后,原样重试当前只读命令") from error
def request_multipart(base: str, path: str, *, fields: dict[str, str], files: list[dict[str, Any]]) -> tuple[int, Any]:
boundary = f"delivery-desk-{uuid.uuid4().hex}"
parsed = urlsplit(base)
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.query or parsed.fragment:
raise revise("Delivery Desk base URL 格式无效", "改用有效的 HTTP/HTTPS 服务地址后重新生成计划")
target = f"{parsed.path.rstrip('/')}{path}" or "/"
def write_text(stream: Any, value: str) -> None:
stream.write(value.encode("utf-8"))
with tempfile.TemporaryFile() as body:
for name, value in fields.items():
write_text(body, f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n")
for item in files:
filename = str(item["name"]).replace("\\", "_").replace('"', "_")
write_text(
body,
f"--{boundary}\r\nContent-Disposition: form-data; name=\"images\"; filename=\"{filename}\"\r\n"
f"Content-Type: {item['content_type']}\r\n\r\n",
)
digest = hashlib.sha256()
copied = 0
with Path(str(item["path"])).open("rb") as image:
while chunk := image.read(1024 * 1024):
digest.update(chunk)
copied += len(chunk)
body.write(chunk)
if copied != int(item["size"]) or digest.hexdigest() != item["sha256"]:
raise revise("本地图片在确认后发生变化,旧计划已失效", "重新生成并展示计划,取得新的确认码后再执行")
write_text(body, "\r\n")
write_text(body, f"--{boundary}--\r\n")
length = body.tell()
body.seek(0)
connection_type = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
connection = connection_type(parsed.hostname, parsed.port, timeout=120)
try:
connection.request(
"POST",
target,
body=body,
headers={
"Authorization": f"Bearer {api_key()}",
"Accept": "application/json",
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Content-Length": str(length),
},
)
response = connection.getresponse()
raw = response.read().decode("utf-8", errors="replace")
try:
payload = json.loads(raw) if raw else None
except json.JSONDecodeError:
payload = raw
if response.status >= 400:
detail = payload.get("error", raw) if isinstance(payload, dict) else raw
raise http_failure("POST", path, response.status, str(detail))
return response.status, payload
except UploadError:
raise
except (OSError, TimeoutError, http.client.HTTPException) as error:
raise UploadError(
f"state_unknown: POST {path} 的结果未知,禁止自动重试:{error}",
next_step="使用 works --external-id 或 inspect-work 只读核对服务器状态;若仍无法确认,询问调用者后再决定",
) from error
finally:
connection.close()
def print_json(value: Any) -> None:
print(json.dumps(value, ensure_ascii=False, indent=2))
def projects(base: str) -> list[dict[str, Any]]:
_, value = request_json(base, "/api/projects")
if not isinstance(value, list):
raise UploadError("项目发现接口返回格式无效")
return value
def exact_project(base: str, project_id: int) -> dict[str, Any]:
available = projects(base)
matches = [item for item in available if int(item.get("id", 0)) == project_id]
if len(matches) != 1:
choices = [{"group_id": p.get("group_id"), "group_name": p.get("group_name"), "project_id": p.get("id"), "project_name": p.get("name"), "slug": p.get("slug")} for p in available]
raise UploadError(f"项目 ID {project_id} 不存在或当前 Key 无权访问。可访问项目:{json.dumps(choices, ensure_ascii=False)}")
return matches[0]
def project_identity(project: dict[str, Any]) -> dict[str, Any]:
return {
"group_id": int(project["group_id"]),
"group_name": str(project["group_name"]),
"project_id": int(project["id"]),
"project_name": str(project["name"]),
"project_slug": str(project["slug"]),
"project_status": str(project["status"]),
}
def validate_images(values: list[str]) -> list[str]:
if not 1 <= len(values) <= 30:
raise revise("必须提供 1-30 个图片 URL", "调整图片数量和顺序,重新生成计划并取得新的确认码")
cleaned = [value.strip() for value in values]
if any(not re.match(r"^https?://[^\s]+$", value, re.IGNORECASE) or len(value) > 2048 for value in cleaned):
raise revise("图片必须是长度不超过 2048 的公开 HTTP/HTTPS URL", "替换为可公开访问的 HTTP/HTTPS 图片 URL重新生成计划并取得新的确认码")
return cleaned
def describe_image_files(values: list[str]) -> list[dict[str, Any]]:
if not 1 <= len(values) <= 30:
raise revise("必须提供 1-30 个本地图片文件", "调整图片数量和顺序,重新生成计划并取得新的确认码")
result: list[dict[str, Any]] = []
for value in values:
try:
file_path = Path(value).expanduser().resolve(strict=True)
except OSError as error:
raise revise(f"本地图片不存在或不可读取: {value}", "修正文件路径后重新生成计划") from error
if not file_path.is_file():
raise revise(f"本地图片不是普通文件: {file_path}", "改用有效图片文件后重新生成计划")
size = file_path.stat().st_size
if size < 1 or size > MAX_LOCAL_IMAGE_BYTES:
raise revise(f"本地图片大小必须在 1 字节到 20 MB 之间: {file_path}", "压缩或替换图片后重新生成计划")
content_type = LOCAL_IMAGE_TYPES.get(file_path.suffix.lower(), "")
if not content_type:
raise revise(f"不支持的本地图片类型: {file_path.name}", "改用 JPEG、PNG、GIF、WebP 或 AVIF 图片后重新生成计划")
digest = hashlib.sha256()
with file_path.open("rb") as source:
while chunk := source.read(1024 * 1024):
digest.update(chunk)
result.append({
"path": str(file_path),
"name": file_path.name,
"size": size,
"sha256": digest.hexdigest(),
"content_type": content_type,
})
return result
def verify_planned_files(images: list[dict[str, Any]]) -> list[dict[str, Any]]:
for item in images:
try:
file_path = Path(str(item.get("path", ""))).resolve(strict=True)
except OSError as error:
raise revise("本地图片在确认后不存在或不可读取,旧计划已失效", "修正路径并重新生成计划") from error
content_type = LOCAL_IMAGE_TYPES.get(file_path.suffix.lower(), "")
if (
not file_path.is_file()
or str(file_path) != item.get("path")
or file_path.name != item.get("name")
or file_path.stat().st_size != item.get("size")
or content_type != item.get("content_type")
):
raise revise("本地图片在确认后发生变化,旧计划已失效", "重新生成并展示计划,取得新的确认码后再执行")
return images
def confirmation_code(plan: dict[str, Any]) -> str:
material = {key: value for key, value in plan.items() if key != "confirmation_code"}
canonical = json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12].upper()
def save_plan(plan: dict[str, Any], output: str) -> None:
plan["confirmation_code"] = confirmation_code(plan)
destination = Path(output)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print_json({"plan_file": str(destination), "plan": plan})
def get_work(base: str, work_id: int) -> dict[str, Any]:
_, value = request_json(base, f"/api/works/{work_id}")
if not isinstance(value, dict):
raise UploadError("作品详情接口返回格式无效")
return value
def assert_work_project(work: dict[str, Any], project_id: int) -> None:
actual = int((work.get("project") or {}).get("id", 0))
if actual != project_id:
raise UploadError(f"作品属于项目 {actual},不是已确认项目 {project_id}")
def content_from_args(args: argparse.Namespace, current: dict[str, Any] | None = None) -> dict[str, Any]:
current = current or {}
if args.clear_description and args.description is not None:
raise revise("--description 与 --clear-description 不能同时使用", "只保留其中一个参数后重新生成计划")
if args.clear_tags and args.tags is not None:
raise revise("--tag 与 --clear-tags 不能同时使用", "只保留其中一种 Tag 操作后重新生成计划")
title = args.title if args.title is not None else current.get("title")
description = "" if args.clear_description else args.description if args.description is not None else current.get("description", "")
tags = [] if args.clear_tags else args.tags if args.tags is not None else current.get("tags", [])
if not str(title or "").strip():
raise revise("标题不能为空", "补充非空标题后重新生成计划并取得新的确认码")
if args.image_urls:
image_source = "url"
images: list[Any] = validate_images(args.image_urls)
else:
image_source = "file"
images = describe_image_files(args.image_files or [])
return {
"title": str(title).strip(),
"description": str(description or ""),
"tags": [str(tag).strip() for tag in tags if str(tag).strip()],
"image_source": image_source,
"images": images,
}
def cmd_projects(args: argparse.Namespace) -> None:
base = base_url(args.base_url)
print_json([project_identity(item) | {"review_status": item.get("review_status")} for item in projects(base)])
def cmd_works(args: argparse.Namespace) -> None:
base = base_url(args.base_url)
project = exact_project(base, args.project_id)
query = f"?externalId={quote(args.external_id)}" if args.external_id else ""
_, value = request_json(base, f"/api/projects/{args.project_id}/works{query}")
print_json({"target": project_identity(project), "works": value})
def cmd_inspect(args: argparse.Namespace) -> None:
base = base_url(args.base_url)
project = exact_project(base, args.project_id)
work = get_work(base, args.work_id)
assert_work_project(work, args.project_id)
print_json({"target": project_identity(project), "work": work})
def cmd_plan_work(args: argparse.Namespace) -> None:
base = base_url(args.base_url)
project = exact_project(base, args.project_id)
if project.get("status") != "active":
raise UploadError("目标项目不是 active不能创建作品")
if not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", args.external_id):
raise revise("externalId 格式无效", "改用符合 [A-Za-z0-9._:-]{1,128} 的稳定 externalId 后重新生成计划")
plan = {
"schema_version": 2,
"operation": "create_work",
"base_url": base,
"target": project_identity(project),
"external_id": args.external_id,
"content": content_from_args(args),
}
save_plan(plan, args.output)
def cmd_plan_round(args: argparse.Namespace) -> None:
base = base_url(args.base_url)
project = exact_project(base, args.project_id)
if project.get("status") != "active":
raise UploadError("目标项目不是 active不能创建验收轮次")
work = get_work(base, args.work_id)
assert_work_project(work, args.project_id)
rounds = work.get("rounds") or []
current_round = max((int(item.get("round_number", 0)) for item in rounds), default=0)
plan = {
"schema_version": 2,
"operation": "create_round",
"base_url": base,
"target": project_identity(project),
"work": {
"work_id": args.work_id,
"external_id": work.get("external_id"),
"expected_version_number": int(work.get("version_number", 0)),
"current_round": current_round,
"next_round": current_round + 1,
},
"content": content_from_args(args, work),
}
save_plan(plan, args.output)
def same_content(detail: dict[str, Any], expected: dict[str, Any]) -> bool:
images = [str(item.get("url") or "") for item in detail.get("images", [])]
try:
returned_images = validate_images(images)
except UploadError:
return False
return (
detail.get("title") == expected["title"]
and detail.get("description", "") == expected["description"]
and detail.get("tags", []) == expected["tags"]
and len(returned_images) == len(expected["images"])
)
def upload_content(base: str, path: str, content: dict[str, Any], extra_fields: dict[str, str] | None = None) -> tuple[int, Any]:
image_source = str(content.get("image_source") or "url")
fields = {
"title": str(content["title"]),
"description": str(content.get("description", "")),
"tags": json.dumps(content.get("tags", []), ensure_ascii=False),
**(extra_fields or {}),
}
if image_source == "file":
files = verify_planned_files(content["images"])
return request_multipart(base, path, fields=fields, files=files)
body = {
"title": content["title"],
"description": content.get("description", ""),
"tags": content.get("tags", []),
"images": content["images"],
**(extra_fields or {}),
}
return request_json(base, path, method="POST", body=body)
def cmd_apply(args: argparse.Namespace) -> None:
plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
expected_code = confirmation_code(plan)
if args.confirm != expected_code or plan.get("confirmation_code") != expected_code:
raise revise("确认码不匹配;计划可能已改变,禁止执行", "重新展示当前计划并取得与当前计划一致的新确认码")
if plan.get("schema_version") not in {1, 2} or plan.get("operation") not in {"create_work", "create_round"}:
raise UploadError("不支持的计划格式")
base = base_url(plan.get("base_url"))
target = plan["target"]
project = exact_project(base, int(target["project_id"]))
actual_identity = project_identity(project)
if actual_identity != target:
raise UploadError(f"项目身份或状态已变化,禁止执行。计划={target},当前={actual_identity}")
content = plan["content"]
if plan["operation"] == "create_work":
external_id = plan["external_id"]
_, existing = request_json(base, f"/api/projects/{target['project_id']}/works?externalId={quote(external_id)}")
if existing:
print_json({"success": True, "idempotent": True, "message": "externalId 已存在,未发送创建请求", "target": target, "work": existing[0]})
return
_, created = upload_content(base, f"/api/projects/{target['project_id']}/works", content, {"externalId": external_id})
work_id = int(created["id"])
verified = get_work(base, work_id)
assert_work_project(verified, int(target["project_id"]))
if verified.get("external_id") != external_id or not same_content(verified, content):
raise UploadError("创建请求返回成功,但回读内容不一致")
print_json({"success": True, "idempotent": bool(created.get("idempotent")), "target": target, "work_id": work_id, "external_id": external_id, "round_number": 1, "title": verified["title"], "image_count": len(verified["images"])})
return
work_plan = plan["work"]
before = get_work(base, int(work_plan["work_id"]))
assert_work_project(before, int(target["project_id"]))
if int(before.get("version_number", 0)) != int(work_plan["expected_version_number"]):
raise UploadError("作品当前版本已变化,旧计划失效;请重新生成计划并确认")
_, created = upload_content(base, f"/api/works/{work_plan['work_id']}/rounds", content)
verified = get_work(base, int(work_plan["work_id"]))
if int(verified.get("version_number", 0)) != int(work_plan["expected_version_number"]) + 1 or not same_content(verified, content):
raise UploadError("新轮次请求返回成功,但回读轮次或内容不一致")
rounds = verified.get("rounds") or []
round_number = max((int(item.get("round_number", 0)) for item in rounds), default=0)
print_json({"success": True, "target": target, "work_id": int(work_plan["work_id"]), "external_id": verified.get("external_id"), "round_number": round_number, "title": verified["title"], "image_count": len(verified["images"]), "server_response_id": created.get("id")})
def add_common(command: argparse.ArgumentParser) -> None:
command.add_argument("--base-url", default=None, help=f"Delivery Desk API 地址(默认:{DEFAULT_BASE_URL}")
def add_content(command: argparse.ArgumentParser, *, title_required: bool) -> None:
command.add_argument("--title", required=title_required, default=None)
command.add_argument("--description", default=None)
command.add_argument("--clear-description", action="store_true")
command.add_argument("--tag", action="append", dest="tags", default=None)
command.add_argument("--clear-tags", action="store_true")
image_input = command.add_mutually_exclusive_group(required=True)
image_input.add_argument("--image-url", action="append", dest="image_urls")
image_input.add_argument("--image-file", action="append", dest="image_files")
def build_parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(description="安全发现、规划并上传 Delivery Desk 作品")
commands = root.add_subparsers(dest="command", required=True)
command = commands.add_parser("projects")
add_common(command)
command.set_defaults(handler=cmd_projects)
command = commands.add_parser("works")
add_common(command)
command.add_argument("--project-id", type=int, required=True)
command.add_argument("--external-id")
command.set_defaults(handler=cmd_works)
command = commands.add_parser("inspect-work")
add_common(command)
command.add_argument("--project-id", type=int, required=True)
command.add_argument("--work-id", type=int, required=True)
command.set_defaults(handler=cmd_inspect)
command = commands.add_parser("plan-work")
add_common(command)
command.add_argument("--project-id", type=int, required=True)
command.add_argument("--external-id", required=True)
add_content(command, title_required=True)
command.add_argument("--output", required=True)
command.set_defaults(handler=cmd_plan_work)
command = commands.add_parser("plan-round")
add_common(command)
command.add_argument("--project-id", type=int, required=True)
command.add_argument("--work-id", type=int, required=True)
add_content(command, title_required=False)
command.add_argument("--output", required=True)
command.set_defaults(handler=cmd_plan_round)
command = commands.add_parser("apply")
command.add_argument("--plan", required=True)
command.add_argument("--confirm", required=True)
command.set_defaults(handler=cmd_apply)
return root
def main() -> int:
args = build_parser().parse_args()
args.handler(args)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (UploadError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
failure = error if isinstance(error, UploadError) else UploadError(f"输入或响应格式无效: {error}")
print(json.dumps(failure.payload(), ensure_ascii=False), file=sys.stderr)
raise SystemExit(1)