fix(storage): 强化外部图片导入与 Skill 异常引导
This commit is contained in:
@@ -143,6 +143,22 @@ Report group, project, work ID, `externalId`, created round number, title, image
|
||||
|
||||
If the result is `state_unknown`, do not rerun `apply`. Run `inspect-work` and compare server state to the plan. If the result still cannot be proven, ask the operator before any retry.
|
||||
|
||||
## Failure actions
|
||||
|
||||
On failure, the script exits with code `1` and writes one JSON object to stderr:
|
||||
|
||||
```json
|
||||
{"success": false, "action": "revise", "error": "...", "next_step": "..."}
|
||||
```
|
||||
|
||||
Follow `action` exactly:
|
||||
|
||||
- `retry`: Retry only the same read-only command, with the same target and parameters, after completing `next_step`. Never reinterpret this as permission to retry `apply`.
|
||||
- `revise`: Change only the invalid input identified by `error`, regenerate the plan, show the full changed plan, and obtain a new confirmation code before `apply`.
|
||||
- `ask_operator`: Stop. Show the exact target, `error`, and `next_step` to the operator or administrator. Do not change targets, permissions, project state, or credentials yourself.
|
||||
|
||||
If output is `state_unknown`, it is always `ask_operator`: inspect current server state first and never automatically repeat the write.
|
||||
|
||||
## API troubleshooting
|
||||
|
||||
Read [references/api-contract.md](references/api-contract.md) before calling an endpoint directly, interpreting an error, or changing this skill for a new API version.
|
||||
|
||||
@@ -93,13 +93,16 @@ It applies the same COS reuse/import rules as work creation.
|
||||
- Only `active` projects accept new works or rounds.
|
||||
- Closed or archived projects are read-only.
|
||||
- A completed project may require an authorized administrator to reopen the relevant workflow before another round can be created.
|
||||
- `400`: malformed or incomplete input.
|
||||
- `401`: missing/invalid authentication.
|
||||
- `403`: key or account cannot access the target project.
|
||||
- `404`: target does not exist.
|
||||
- `409`: target state disallows mutation or a uniqueness conflict occurred.
|
||||
- `413`: a remote image exceeds 20 MB.
|
||||
- `422`: a remote image cannot be downloaded or is not a supported image response.
|
||||
- `502`: Delivery Desk could not store an imported image in Tencent COS.
|
||||
| Status | Script action | Required next step |
|
||||
|---|---|---|
|
||||
| `400` | `revise` | Correct malformed input, regenerate the plan, and obtain a new confirmation. |
|
||||
| `401` | `ask_operator` | Ask the operator to configure or replace `DELIVERY_DESK_API_KEY`. |
|
||||
| `403` | `ask_operator` | Report the exact group/project and ask an administrator to correct Key scope. Never select another target. |
|
||||
| `404` | `ask_operator` | Re-run read-only discovery, then ask the operator if the confirmed target is gone or inaccessible. |
|
||||
| `409` | `ask_operator` | Report the target state and wait for the operator or administrator to resolve it. |
|
||||
| `413` | `revise` | Replace or reduce the image, regenerate the plan, and obtain a new confirmation. |
|
||||
| `422` | `revise` | Replace the unreachable or unsupported public image URL, regenerate the plan, and obtain a new confirmation. |
|
||||
| `502` | `ask_operator` for writes | Do not retry the write automatically; inspect state and ask the operator to check Tencent COS. |
|
||||
| `429`, `503`, `504` | `retry` for reads only | Wait and retry the same read-only command without changing the target. Writes require `ask_operator`. |
|
||||
|
||||
Do not work around `403` or `409` by selecting a different project. Report the exact target and ask the operator or administrator to resolve access/state.
|
||||
|
||||
@@ -17,7 +17,40 @@ from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
class UploadError(RuntimeError):
|
||||
pass
|
||||
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:
|
||||
@@ -48,11 +81,14 @@ def request_json(base: str, path: str, *, method: str = "GET", body: dict[str, A
|
||||
detail = payload.get("error", raw) if isinstance(payload, dict) else raw
|
||||
except json.JSONDecodeError:
|
||||
detail = raw
|
||||
raise UploadError(f"{method} {path} 返回 {error.code}: {detail}") from error
|
||||
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}") from error
|
||||
raise UploadError(f"无法连接 {base}: {error.reason}") from error
|
||||
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 print_json(value: Any) -> None:
|
||||
@@ -88,10 +124,10 @@ def project_identity(project: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
def validate_images(values: list[str]) -> list[str]:
|
||||
if not 1 <= len(values) <= 30:
|
||||
raise UploadError("必须提供 1-30 个图片 URL")
|
||||
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 UploadError("图片必须是长度不超过 2048 的公开 HTTP/HTTPS URL")
|
||||
raise revise("图片必须是长度不超过 2048 的公开 HTTP/HTTPS URL", "替换为可公开访问的 HTTP/HTTPS 图片 URL,重新生成计划并取得新的确认码")
|
||||
return cleaned
|
||||
|
||||
|
||||
@@ -125,14 +161,14 @@ def assert_work_project(work: dict[str, Any], project_id: int) -> None:
|
||||
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 UploadError("--description 与 --clear-description 不能同时使用")
|
||||
raise revise("--description 与 --clear-description 不能同时使用", "只保留其中一个参数后重新生成计划")
|
||||
if args.clear_tags and args.tags is not None:
|
||||
raise UploadError("--tag 与 --clear-tags 不能同时使用")
|
||||
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 UploadError("标题不能为空")
|
||||
raise revise("标题不能为空", "补充非空标题后重新生成计划并取得新的确认码")
|
||||
return {
|
||||
"title": str(title).strip(),
|
||||
"description": str(description or ""),
|
||||
@@ -168,7 +204,7 @@ def cmd_plan_work(args: argparse.Namespace) -> None:
|
||||
if project.get("status") != "active":
|
||||
raise UploadError("目标项目不是 active,不能创建作品")
|
||||
if not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", args.external_id):
|
||||
raise UploadError("externalId 格式无效")
|
||||
raise revise("externalId 格式无效", "改用符合 [A-Za-z0-9._:-]{1,128} 的稳定 externalId 后重新生成计划")
|
||||
plan = {
|
||||
"schema_version": 1,
|
||||
"operation": "create_work",
|
||||
@@ -224,7 +260,7 @@ 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 UploadError("确认码不匹配;计划可能已改变,禁止执行")
|
||||
raise revise("确认码不匹配;计划可能已改变,禁止执行", "重新展示当前计划并取得与当前计划一致的新确认码")
|
||||
if plan.get("schema_version") != 1 or plan.get("operation") not in {"create_work", "create_round"}:
|
||||
raise UploadError("不支持的计划格式")
|
||||
base = base_url(plan.get("base_url"))
|
||||
@@ -330,5 +366,6 @@ if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (UploadError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"失败: {error}", file=sys.stderr)
|
||||
failure = error if isinstance(error, UploadError) else UploadError(f"输入或响应格式无效: {error}")
|
||||
print(json.dumps(failure.payload(), ensure_ascii=False), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
Reference in New Issue
Block a user