diff --git a/.agents/skills/upload-delivery-desk-work/SKILL.md b/.agents/skills/upload-delivery-desk-work/SKILL.md index 1c4eb70..3f98a7e 100644 --- a/.agents/skills/upload-delivery-desk-work/SKILL.md +++ b/.agents/skills/upload-delivery-desk-work/SKILL.md @@ -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. diff --git a/.agents/skills/upload-delivery-desk-work/references/api-contract.md b/.agents/skills/upload-delivery-desk-work/references/api-contract.md index 566963e..c277d0a 100644 --- a/.agents/skills/upload-delivery-desk-work/references/api-contract.md +++ b/.agents/skills/upload-delivery-desk-work/references/api-contract.md @@ -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. diff --git a/.agents/skills/upload-delivery-desk-work/scripts/delivery_desk_upload.py b/.agents/skills/upload-delivery-desk-work/scripts/delivery_desk_upload.py index 40625a8..ab515c2 100644 --- a/.agents/skills/upload-delivery-desk-work/scripts/delivery_desk_upload.py +++ b/.agents/skills/upload-delivery-desk-work/scripts/delivery_desk_upload.py @@ -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) diff --git a/api/routes/storage.ts b/api/routes/storage.ts index 64c5281..98ae56e 100644 --- a/api/routes/storage.ts +++ b/api/routes/storage.ts @@ -2,7 +2,7 @@ import { Router, type Response } from 'express'; import { audit, requireRole, type AuthRequest } from '../auth.js'; import { encryptSecret } from '../configCrypto.js'; import { database, withTransaction } from '../database.js'; -import { testStorageConfig, type StorageConfigRecord } from '../storage.js'; +import { assertPublicHttpUrl, testStorageConfig, type StorageConfigRecord } from '../storage.js'; const router=Router(); type PublicRow=Record&{id:number|string;has_credentials:boolean|number}; @@ -10,7 +10,7 @@ async function publicRows(){return(await database.all(`SELECT s.id,s. function validUrl(value:string){if(!value)return true;try{return['http:','https:'].includes(new URL(value).protocol)}catch{return false}} router.get('/',requireRole('platform_admin'),async(_req,res)=>res.json(await publicRows())); -router.post('/',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const region=String(req.body?.region??'').trim().toLowerCase();const bucket=String(req.body?.bucket??'').trim().toLowerCase();const publicBaseUrl=String(req.body?.public_base_url??'').trim();const cdnDomain=String(req.body?.cdn_domain??'').trim();const pathPrefix=String(req.body?.path_prefix??'delivery-desk').trim().replace(/^\/+|\/+$/g,'');const secretId=String(req.body?.secret_id??'').trim();const secretKey=String(req.body?.secret_key??'').trim();if(!/^[a-z0-9-]+$/.test(region)){res.status(400).json({error:'COS 地域格式不正确,例如 ap-guangzhou'});return}if(!/^[a-z0-9][a-z0-9-]+-\d+$/.test(bucket)){res.status(400).json({error:'存储桶名称需要包含 APPID'});return}if(!secretId||!secretKey){res.status(400).json({error:'SecretId 和 SecretKey 均为必填项'});return}if(!validUrl(publicBaseUrl)||!validUrl(cdnDomain)){res.status(400).json({error:'访问域名必须是有效的 HTTP 或 HTTPS 地址'});return}if(pathPrefix.includes('..')||pathPrefix.startsWith('/')){res.status(400).json({error:'文件路径前缀格式不正确'});return}const id=await database.insertId('INSERT INTO storage_configs (region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted,created_by) VALUES (?,?,?,?,?,?,?,?)',[region,bucket,publicBaseUrl,cdnDomain,pathPrefix,encryptSecret(secretId),encryptSecret(secretKey),req.authUser?.id]);await audit(req,'storage_config.create','storage_config',id,{region,bucket,pathPrefix});res.status(201).json((await publicRows()).find((item)=>item.id===id))}); +router.post('/',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const region=String(req.body?.region??'').trim().toLowerCase();const bucket=String(req.body?.bucket??'').trim().toLowerCase();const publicBaseUrl=String(req.body?.public_base_url??'').trim();const cdnDomain=String(req.body?.cdn_domain??'').trim();const pathPrefix=String(req.body?.path_prefix??'delivery-desk').trim().replace(/^\/+|\/+$/g,'');const secretId=String(req.body?.secret_id??'').trim();const secretKey=String(req.body?.secret_key??'').trim();if(!/^[a-z0-9-]+$/.test(region)){res.status(400).json({error:'COS 地域格式不正确,例如 ap-guangzhou'});return}if(!/^[a-z0-9][a-z0-9-]+-\d+$/.test(bucket)){res.status(400).json({error:'存储桶名称需要包含 APPID'});return}if(!secretId||!secretKey){res.status(400).json({error:'SecretId 和 SecretKey 均为必填项'});return}if(!validUrl(publicBaseUrl)||!validUrl(cdnDomain)){res.status(400).json({error:'访问域名必须是有效的 HTTP 或 HTTPS 地址'});return}await assertPublicHttpUrl(publicBaseUrl||`https://${bucket}.cos.${region}.myqcloud.com`);if(cdnDomain)await assertPublicHttpUrl(cdnDomain);if(pathPrefix.includes('..')||pathPrefix.startsWith('/')){res.status(400).json({error:'文件路径前缀格式不正确'});return}const id=await database.insertId('INSERT INTO storage_configs (region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted,created_by) VALUES (?,?,?,?,?,?,?,?)',[region,bucket,publicBaseUrl,cdnDomain,pathPrefix,encryptSecret(secretId),encryptSecret(secretKey),req.authUser?.id]);await audit(req,'storage_config.create','storage_config',id,{region,bucket,pathPrefix});res.status(201).json((await publicRows()).find((item)=>item.id===id))}); router.post('/:configId/test',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const id=Number(req.params.configId);const config=await database.one('SELECT id,region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted FROM storage_configs WHERE id=? AND status!=?',[id,'archived']);if(!config){res.status(404).json({error:'存储配置不存在'});return}try{const message=await testStorageConfig(config);await database.execute("UPDATE storage_configs SET test_status='passed',test_message=?,last_tested_at=? WHERE id=?",[message,new Date().toISOString(),id]);await audit(req,'storage_config.test_passed','storage_config',id,{bucket:config.bucket});res.json({success:true,test_status:'passed',test_message:message})}catch(error){const message=error instanceof Error?error.message:'COS 连接测试失败';await database.execute("UPDATE storage_configs SET test_status='failed',test_message=?,last_tested_at=? WHERE id=?",[message,new Date().toISOString(),id]);await audit(req,'storage_config.test_failed','storage_config',id,{bucket:config.bucket,message});res.status(400).json({error:message,test_status:'failed'})}}); router.post('/:configId/activate',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const id=Number(req.params.configId);const config=await database.one<{id:number;bucket:string;test_status:string}>('SELECT id,bucket,test_status FROM storage_configs WHERE id=? AND status=?',[id,'draft']);if(!config){res.status(404).json({error:'待启用的存储配置不存在'});return}if(config.test_status!=='passed'){res.status(409).json({error:'连接测试通过后才能启用该配置'});return}await withTransaction(async(tx)=>{await tx.execute("UPDATE storage_configs SET status='archived' WHERE status='active'");await tx.execute("UPDATE storage_configs SET status='active',activated_at=? WHERE id=?",[new Date().toISOString(),id])});await audit(req,'storage_config.activate','storage_config',id,{bucket:config.bucket});res.json({success:true})}); export default router; diff --git a/api/storage.ts b/api/storage.ts index 1e920d6..47a16e1 100644 --- a/api/storage.ts +++ b/api/storage.ts @@ -110,6 +110,10 @@ async function assertPublicRemote(url: URL): Promise { } } +export async function assertPublicHttpUrl(value: string): Promise { + await assertPublicRemote(new URL(value)); +} + function assertRemoteUrlShape(url: URL): void { if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) { throw new StorageImportError(400, '图片地址必须是公开的 HTTP/HTTPS URL'); @@ -207,7 +211,7 @@ export async function storeExternalImageUrl(value: string): Promise/.delivery-desk-check/` 是否残留临时文件。 -COS 使用公开 URL。必须关闭桶列表功能并使用不可枚举对象名;拿到 URL 的人可以直接访问文件。 +COS 使用公开 URL。公共访问域名和 CDN 域名必须能够解析到公网地址,本机、私有网段、局域网或保留地址会在保存配置时被拒绝。必须关闭桶列表功能并使用不可枚举对象名;拿到 URL 的人可以直接访问文件。 JSON URL 导入依赖活动 COS 配置。同一 COS/CDN 域名的图片直接使用;其他域名会下载并按内容哈希写入 `/imports/`。服务会拒绝内网地址、非图片响应和超过 20 MB 的文件,因此部署网络必须允许访问确需导入的公开图片源。 +使用 `pnpm server:prod` 运行本地 API 时不会监听源码变化;后端代码更新后必须重启进程。日常开发应使用 `pnpm server:dev` 或 `pnpm dev`。 + ## Agent 上传 Skill 维护 Agent 通过 API 新建作品或提交验收轮次时,使用 `.agents/skills/upload-delivery-desk-work`。API Key 只通过 `DELIVERY_DESK_API_KEY` 环境变量注入,不写入计划文件、文档或 Git。 diff --git a/scripts/test-postgres-runtime.ts b/scripts/test-postgres-runtime.ts index 4a7a1ee..1dda495 100644 --- a/scripts/test-postgres-runtime.ts +++ b/scripts/test-postgres-runtime.ts @@ -80,11 +80,16 @@ try { expectStatus(missingStorage.response.status,409,'没有活动 COS 时拒绝 URL 导入',missingStorage.body); const worksWithoutStorage=await request(`/api/projects/${projectId}/works`,{},adminCookie); if((worksWithoutStorage.body as unknown[]).length!==0)throw new Error('URL 导入失败后仍创建了作品记录'); + const blockedPrivateStorage=await request('/api/management/storage-configs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({region:'ap-guangzhou',bucket:'blocked-private-1234567890',public_base_url:'http://127.0.0.1:8080',cdn_domain:'',path_prefix:'delivery-desk',secret_id:'test-secret-id',secret_key:'test-secret-key'})},adminCookie); + expectStatus(blockedPrivateStorage.response.status,400,'拒绝私有地址作为对象存储访问域名',blockedPrivateStorage.body); await database.insertId("INSERT INTO storage_configs (region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted,status,test_status,created_by) VALUES (?,?,?,?,?,?,?,?,?,?)", ['ap-guangzhou','runtime-test-1234567890','https://runtime-test-1234567890.cos.ap-guangzhou.myqcloud.com','https://cdn.example.com','delivery-desk',encryptSecret('test-secret-id'),encryptSecret('test-secret-key'),'active','passed',1]); - const blockedPrivateImage=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'禁止内网图片',images:['http://127.0.0.1/private.jpg']})},adminCookie); - expectStatus(blockedPrivateImage.response.status,400,'拒绝内网图片转存',blockedPrivateImage.body); - const blockedPrivateIpv6=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'禁止 IPv6 本机图片',images:['http://[::1]/private.jpg']})},adminCookie); - expectStatus(blockedPrivateIpv6.response.status,400,'拒绝 IPv6 本机图片转存',blockedPrivateIpv6.body); + const privateImageUrls=['http://localhost/private.jpg','http://127.0.0.1/private.jpg','http://10.0.0.1/private.jpg','http://100.64.0.1/private.jpg','http://169.254.0.1/private.jpg','http://172.16.0.1/private.jpg','http://192.168.1.1/private.jpg','http://[::1]/private.jpg','http://[fd00::1]/private.jpg']; + for(const [index,imageUrl] of privateImageUrls.entries()){ + const blockedPrivateImage=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:`禁止私有图片 ${index+1}`,images:[imageUrl]})},adminCookie); + expectStatus(blockedPrivateImage.response.status,400,`拒绝私有图片地址 ${imageUrl}`,blockedPrivateImage.body); + } + const worksAfterPrivateImages=await request(`/api/projects/${projectId}/works`,{},adminCookie); + if((worksAfterPrivateImages.body as unknown[]).length!==0)throw new Error('私有图片地址被拒绝后仍创建了作品记录'); const otherProject=await request('/api/projects',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'同组隔离项目',slug:'isolated-project',client_description:'不应被项目 Key 看见',groupId})},adminCookie); expectStatus(otherProject.response.status,201,'创建同组隔离项目',otherProject.body); const otherProjectId=Number((otherProject.body as {id:number}).id); diff --git a/skill-packages/upload-delivery-desk-work.zip b/skill-packages/upload-delivery-desk-work.zip index 4361fbf..ceb7303 100644 Binary files a/skill-packages/upload-delivery-desk-work.zip and b/skill-packages/upload-delivery-desk-work.zip differ diff --git a/tests/test_upload_skill.py b/tests/test_upload_skill.py index 2320bd6..8fc52de 100644 --- a/tests/test_upload_skill.py +++ b/tests/test_upload_skill.py @@ -71,6 +71,7 @@ def main() -> None: raise AssertionError("错误确认码未被阻止") except MODULE.UploadError as error: assert "确认码不匹配" in str(error) + assert error.action == "revise" and "确认码" in error.next_step assert not calls MODULE.request_json = lambda *_args, **_kwargs: (200, [{"id": 88, "external_id": "agent-test-001"}]) @@ -97,6 +98,7 @@ def main() -> None: raise AssertionError("版本漂移未被阻止") except MODULE.UploadError as error: assert "当前版本已变化" in str(error) + assert error.action == "ask_operator" assert not post_calls work_reads = iter([detail(2, work_id=34), detail(3, work_id=34)]) @@ -110,6 +112,17 @@ def main() -> None: raise AssertionError("错误项目归属未被阻止") except MODULE.UploadError as error: assert "不是已确认项目" in str(error) + + revised = MODULE.http_failure("POST", "/api/projects/12/works", 422, "图片不可用") + assert revised.action == "revise" and "重新生成计划" in revised.next_step + unauthorized = MODULE.http_failure("GET", "/api/projects", 401, "无效 Key") + assert unauthorized.action == "ask_operator" and "DELIVERY_DESK_API_KEY" in unauthorized.next_step + transient_read = MODULE.http_failure("GET", "/api/projects", 503, "暂时不可用") + assert transient_read.action == "retry" and "只读" in transient_read.next_step + uncertain_write = MODULE.http_failure("POST", "/api/works/34/rounds", 503, "暂时不可用") + assert uncertain_write.action == "ask_operator" and "禁止自动重试" in uncertain_write.next_step + private_url = MODULE.http_failure("POST", "/api/projects/12/works", 400, "图片地址不允许访问私有网络") + assert private_url.action == "revise" and "修改输入" in private_url.next_step finally: MODULE.exact_project = original_exact_project MODULE.request_json = original_request_json