From b8b4d7a11cbb08b4dc3a5c4d63e71b8bf1a3887f Mon Sep 17 00:00:00 2001 From: yuzhe Date: Wed, 22 Jul 2026 18:12:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=B7=BB=E5=8A=A0=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=B8=8A=E4=BC=A0=20Skill=20=E5=92=8C=20COS=20?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=BD=92=E4=B8=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/upload-delivery-desk-work/SKILL.md | 148 ++++++++ .../agents/openai.yaml | 4 + .../references/api-contract.md | 105 ++++++ .../scripts/delivery_desk_upload.py | 334 ++++++++++++++++++ AGENTS.md | 3 +- README.md | 6 +- api/app.ts | 7 +- api/services/notesService.ts | 16 +- api/storage.ts | 142 +++++++- docs/architecture.md | 10 +- docs/handoff.md | 3 +- docs/integration-guide.md | 22 +- docs/operator-runbook.md | 14 + scripts/package-upload-skill.ps1 | 44 +++ scripts/test-postgres-runtime.ts | 36 +- skill-packages/upload-delivery-desk-work.zip | Bin 0 -> 9903 bytes tests/test_upload_skill.py | 121 +++++++ 17 files changed, 997 insertions(+), 18 deletions(-) create mode 100644 .agents/skills/upload-delivery-desk-work/SKILL.md create mode 100644 .agents/skills/upload-delivery-desk-work/agents/openai.yaml create mode 100644 .agents/skills/upload-delivery-desk-work/references/api-contract.md create mode 100644 .agents/skills/upload-delivery-desk-work/scripts/delivery_desk_upload.py create mode 100644 scripts/package-upload-skill.ps1 create mode 100644 skill-packages/upload-delivery-desk-work.zip create mode 100644 tests/test_upload_skill.py diff --git a/.agents/skills/upload-delivery-desk-work/SKILL.md b/.agents/skills/upload-delivery-desk-work/SKILL.md new file mode 100644 index 0000000..1c4eb70 --- /dev/null +++ b/.agents/skills/upload-delivery-desk-work/SKILL.md @@ -0,0 +1,148 @@ +--- +name: upload-delivery-desk-work +description: Safely locate the exact Delivery Desk operation group and project, then create a work or submit one new review round through the current API. Use for requests such as 上传作品、新增作品、提交新一轮、更新作品, or any agent upload where group/project/work identity may be ambiguous and a wrong target must be prevented. +--- + +# Upload Delivery Desk Work + +Use a two-phase plan/apply workflow. Optimize for correct placement, not speed. + +## Non-negotiable rules + +- Treat the product hierarchy as `operation group -> project -> work -> review round`. +- Never use a collection/delivery-set identifier. `collections`, `notes`, and `versions` are legacy compatibility names. +- Never infer a project or work from a partial name, page position, recent activity, or a remembered ID. +- Never create a work until the operator confirms the resolved group, project, content, image order, and confirmation code. +- Never create a new round until the operator confirms the resolved work and current round. +- Never retry a timed-out create request. First inspect current server state; otherwise a retry can create a duplicate round. +- Require `externalId` for every agent-created work. Reuse the same value for safe retries. +- Accept 1-30 public `http`/`https` image URLs. Preserve their order; the first image is the cover. +- Require an active Tencent COS configuration. URLs already using its public or CDN origin are reused; other public images are downloaded and stored in that COS by the API. +- Put API keys only in `DELIVERY_DESK_API_KEY`. Do not paste keys into chat, plans, source files, or command history. +- Prefer a project-scoped API key. A platform key has a wider blast radius and always requires explicit group verification. +- Stop on any mismatch, ambiguity, changed project/work state, or missing input. Ask the operator instead of guessing. + +## 1. Classify the operation + +Determine exactly one operation: + +- `create_work`: create a new work in a project. +- `create_round`: submit the next review round for an existing work. + +If the request says “update”, “new version”, or “upload again” without identifying whether it is a new work or a new round, ask which operation is intended. + +Do not create groups, projects, API keys, feedback, or review decisions with this skill. + +## 2. Collect required information + +For both operations, require: + +- Delivery Desk base URL. Default to `DELIVERY_DESK_BASE_URL` or `http://127.0.0.1:3010` only for local development. +- A valid API key in `DELIVERY_DESK_API_KEY`. +- Exact target project, resolved to group ID/name and project ID/name/slug. +- Ordered public image URLs. +- Confirmation that the source URLs are reachable until the API finishes importing them. After a cross-origin import succeeds, Delivery Desk uses the resulting COS URL. + +For `create_work`, also require: + +- A stable caller-generated `externalId` matching `[A-Za-z0-9._:-]{1,128}`. +- Title. Description and tags may be empty only when the operator explicitly intends that. + +For `create_round`, also require: + +- Work ID. If only an `externalId` is known, discover the work inside the confirmed project first. +- Whether title, description, and tags should be replaced or retained from the current round. Omitted values are retained by the planning script and must be visible in the confirmation summary. + +When information is missing, ask one concise question listing only the missing fields. Do not proceed to mutation. + +## 3. Discover authoritative IDs + +Use the bundled script from the skill directory: + +```powershell +$skillScript = ".agents/skills/upload-delivery-desk-work/scripts/delivery_desk_upload.py" +python $skillScript projects +python $skillScript works --project-id 12 +python $skillScript inspect-work --project-id 12 --work-id 34 +``` + +Resolution rules: + +1. Match IDs first. +2. Validate the project ID against its returned group ID, group name, project name, and slug. +3. If the operator supplied only names, list exact matches with IDs and ask the operator to choose when zero or multiple matches exist. +4. Even with one match, show the resolved identity before creating the plan. +5. For a new round, verify the work belongs to the confirmed project. + +Do not silently choose the only project merely because an API key currently exposes one. + +## 4. Generate a read-only plan + +Create a new work plan: + +```powershell +python $skillScript plan-work ` + --project-id 12 ` + --external-id client-2026-001 ` + --title "作品标题" ` + --description "正文" ` + --tag "#夏日" ` + --image-url "https://cdn.example.com/01.jpg" ` + --image-url "https://cdn.example.com/02.jpg" ` + --output tmp/delivery-plan.json +``` + +Create a new round plan: + +```powershell +python $skillScript plan-round ` + --project-id 12 ` + --work-id 34 ` + --image-url "https://cdn.example.com/round-2-01.jpg" ` + --output tmp/delivery-plan.json +``` + +Optional `plan-round` content flags: + +- `--title`, `--description`, and repeated `--tag` replace current values. +- `--clear-description` or `--clear-tags` intentionally clear those fields. +- If omitted, the current round value is retained. + +The plan contains no credentials and performs no mutation. + +## 5. Obtain explicit confirmation + +Show the plan summary exactly, including: + +- Operation. +- Group name and ID. +- Project name, ID, and slug. +- Work ID and current/next round for `create_round`. +- `externalId` for `create_work`. +- Title, complete description, complete tag list. +- Ordered image URLs with `1` marked as the cover. +- Confirmation code. + +Ask: `确认按以上目标和内容执行吗?请回复“确认 ”。` + +Accept only an explicit confirmation containing the current code. Any content or target change invalidates the old plan; regenerate it and ask again. + +## 6. Apply and verify + +After exact confirmation: + +```powershell +python $skillScript apply ` + --plan tmp/delivery-plan.json ` + --confirm ABCD1234EF56 +``` + +The script re-fetches the project/work, checks for state drift, performs one POST, and reads the created resource back. Treat only a successful verification result as complete. + +Report group, project, work ID, `externalId`, created round number, title, image count, and whether the server returned an existing idempotent work. + +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. + +## 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/agents/openai.yaml b/.agents/skills/upload-delivery-desk-work/agents/openai.yaml new file mode 100644 index 0000000..5ba1ce0 --- /dev/null +++ b/.agents/skills/upload-delivery-desk-work/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "上传 Delivery Desk 作品" + short_description: "精确定位运营组与项目,安全创建作品或提交新的验收轮次" + default_prompt: "Use $upload-delivery-desk-work to safely locate the exact project and upload a work or a new review round." diff --git a/.agents/skills/upload-delivery-desk-work/references/api-contract.md b/.agents/skills/upload-delivery-desk-work/references/api-contract.md new file mode 100644 index 0000000..566963e --- /dev/null +++ b/.agents/skills/upload-delivery-desk-work/references/api-contract.md @@ -0,0 +1,105 @@ +# Delivery Desk upload contract + +## Current hierarchy and identity + +`operation group -> project -> work -> review round` + +- A project belongs to exactly one operation group. +- A work belongs to exactly one project. +- A review round belongs to exactly one work and contains exactly one proposal. +- `externalId` is unique within a project and is the only supported idempotency key for agent-created works. +- Product-facing operations do not accept a collection/delivery-set ID. + +## Authentication and scope + +Send `Authorization: Bearer `. + +| Key scope | Readable projects | Create work | Create round | +|---|---|---|---| +| `project` | Its bound active project | Only in that project | Only for works in that project | +| `platform` | All non-archived projects | Any active project | Any work in an active project | + +Prefer `project`. Do not store the token in a plan file. + +## Discovery endpoints + +### `GET /api/projects` + +Returns accessible projects. Relevant fields: + +```json +{ + "id": 12, + "group_id": 3, + "group_name": "示例运营组", + "name": "光影内容计划", + "slug": "light-notes", + "status": "active", + "review_status": "reviewing" +} +``` + +### `GET /api/projects/:projectId/works` + +Returns works in the exact project. Use `?externalId=` for exact external-ID lookup. + +### `GET /api/works/:workId` + +Returns current work content, project identity, images, and rounds. Use `?round=N` only when inspecting a historical round. + +## Mutation endpoints + +### `POST /api/projects/:projectId/works` + +JSON body: + +```json +{ + "externalId": "client-2026-001", + "title": "作品标题", + "description": "正文", + "tags": ["#夏日"], + "images": ["https://cdn.example.com/01.jpg"] +} +``` + +Constraints: + +- `title` is required. +- `images` contains 1-30 public HTTP/HTTPS URLs. +- Array order is display order; item 1 is the cover. +- An active Tencent COS configuration is required. URLs on its configured public/CDN origin are reused; other public images are downloaded, validated, and stored in that COS before the work is created. +- Cross-origin images must be supported image responses no larger than 20 MB. Local, private, reserved, and non-standard-port targets are rejected. +- Repeating the same `projectId + externalId` returns the existing work with `idempotent: true`. + +### `POST /api/works/:workId/rounds` + +JSON body: + +```json +{ + "title": "修改后的标题", + "description": "修改后的正文", + "tags": ["#第二轮"], + "images": ["https://cdn.example.com/round-2.jpg"] +} +``` + +This endpoint is not idempotent. One successful call creates exactly one new round. Never blindly retry after a timeout. +It applies the same COS reuse/import rules as work creation. + +## State guards + +- 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. + +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 new file mode 100644 index 0000000..40625a8 --- /dev/null +++ b/.agents/skills/upload-delivery-desk-work/scripts/delivery_desk_upload.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Two-phase, target-safe Delivery Desk work uploader.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import sys +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + + +class UploadError(RuntimeError): + pass + + +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 os.getenv("DELIVERY_DESK_BASE_URL") or "http://127.0.0.1:3010").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 UploadError(f"{method} {path} 返回 {error.code}: {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 + + +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 UploadError("必须提供 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") + return cleaned + + +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 UploadError("--description 与 --clear-description 不能同时使用") + if args.clear_tags and args.tags is not None: + raise UploadError("--tag 与 --clear-tags 不能同时使用") + 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("标题不能为空") + return { + "title": str(title).strip(), + "description": str(description or ""), + "tags": [str(tag).strip() for tag in tags if str(tag).strip()], + "images": validate_images(args.image_urls), + } + + +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 UploadError("externalId 格式无效") + plan = { + "schema_version": 1, + "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": 1, + "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 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("确认码不匹配;计划可能已改变,禁止执行") + 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")) + 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 = request_json(base, f"/api/projects/{target['project_id']}/works", method="POST", body={**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 = request_json(base, f"/api/works/{work_plan['work_id']}/rounds", method="POST", body=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) + + +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") + command.add_argument("--image-url", action="append", dest="image_urls", required=True) + + +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: + print(f"失败: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/AGENTS.md b/AGENTS.md index d1d7fdc..9f13fbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,10 @@ # Delivery Desk 开发约定 -- 包管理器使用 pnpm;提交前运行 `pnpm check`、`pnpm lint`、`pnpm build`、`pnpm test:review-rounds`、`pnpm test:collection-status`、`pnpm test:postgres-runtime` 和 `pnpm db:postgres:validate`。 +- 包管理器使用 pnpm;提交前运行 `pnpm check`、`pnpm lint`、`pnpm build`、`pnpm test:review-rounds`、`pnpm test:collection-status`、`pnpm test:postgres-runtime`、`pnpm db:postgres:validate` 和 `python tests/test_upload_skill.py`。 - 业务术语统一为“运营组 → 项目 → 作品 → 验收轮次”;每轮只有一个方案。`collections` 和 `work_versions` 是迁移期内部兼容结构,不得出现在新产品界面或新 API 命名中。 - 新接口使用 `/api/projects/:projectId/works` 和 `/api/works/:workId/rounds`;`notes`、`collections`、`versions` 路由只做一个兼容周期,不再扩展。 - 数据库结构变更必须同时更新 `api/db.ts`、`db/postgres/schema.sql` 和迁移验证。 - `data/`、`uploads/`、`.env*`、COS 凭证、数据库文件及用户上传内容不得提交。 - 本地开发可使用 SQLite;正式部署使用 PostgreSQL。COS 配置只通过平台管理界面或部署密钥注入。 - 不把 `.trae/` 中的早期原型文档作为现行依据;以 README、`docs/` 和当前代码为准。 +- Agent 通过 API 上传作品或创建验收轮次时必须使用 `.agents/skills/upload-delivery-desk-work`;接口或层级变化后同步更新该 Skill,并运行 `powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1` 重新打包。 diff --git a/README.md b/README.md index 131c48d..7f23e92 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,12 @@ - 平台管理员、组管理员、光影叙事三类账号及运营组数据隔离 - 项目、作品、单方案验收轮次和项目级自动验收状态 -- 手动多图上传、公开图片 URL API、拖拽排序和腾讯云 COS +- 手动多图上传、公开图片 URL 自动归一到腾讯云 COS、拖拽排序 - 作品缩略图浏览;悬浮图片窗格中的原图查看、缩放和坐标批注 - 标题、正文和 Tag 选区批注、作品总体反馈和验收记录 - 客户项目密码、访问期限和独立验收入口 - 平台级/项目级 API Key、审计日志和账号管理 +- 项目内置 Agent 安全上传 Skill,使用双阶段确认防止错组、错项目和错作品 - SQLite 本地开发、PostgreSQL 正式运行及 Docker 部署 未落地范围见 [初版交接说明](docs/handoff.md)。 @@ -52,8 +53,11 @@ pnpm test:review-rounds pnpm test:collection-status pnpm test:postgres-runtime pnpm db:postgres:validate +python tests/test_upload_skill.py ``` +修改内置 Agent 上传 Skill 后,运行 `powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1` 更新 `skill-packages/upload-delivery-desk-work.zip`。 + 更多资料: - [架构与数据模型](docs/architecture.md) diff --git a/api/app.ts b/api/app.ts index ce569a4..800742e 100644 --- a/api/app.ts +++ b/api/app.ts @@ -86,13 +86,18 @@ if (process.env.NODE_ENV === 'production' && fs.existsSync(distDir)) { */ app.use((err: Error, _req: Request, res: Response, _next: unknown) => { void _next; - console.error('[API Error]', err); // multer 文件类型/大小错误 const message = err.message || '服务器内部错误'; + const statusCode = Number((err as Error & { statusCode?: number }).statusCode); + if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599) { + res.status(statusCode).json({ success: false, error: message }); + return; + } if (err.message?.includes('不支持的文件类型')) { res.status(400).json({ success: false, error: message }); return; } + console.error('[API Error]', err); res.status(500).json({ success: false, error: message }); }); diff --git a/api/services/notesService.ts b/api/services/notesService.ts index 93eb6ad..84a11e3 100644 --- a/api/services/notesService.ts +++ b/api/services/notesService.ts @@ -4,7 +4,7 @@ import { notesRepository } from '../repositories/notesRepository.js'; import { imagesRepository } from '../repositories/imagesRepository.js'; import { annotationsRepository } from '../repositories/annotationsRepository.js'; import { database, databaseDialect, withTransaction, type QueryContext } from '../database.js'; -import { storeUploadedFile } from '../storage.js'; +import { storeExternalImageUrl, storeUploadedFile } from '../storage.js'; import { recalculateCollectionStatus } from './collectionsService.js'; import { ensureProjectCompatibilityCollection, recalculateProjectReviewStatus } from './projectsService.js'; @@ -24,8 +24,10 @@ async function prepareFiles(files: UploadedFile[]): Promise { return Promise.all(files.map(async (file) => ({ ...(await readImageSize(file.path)), ...(await storeUploadedFile(file)) }))); } -function externalImages(images: string[]): StoredImage[] { - return images.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external', storageKey: '' })); +async function prepareExternalImages(images: string[]): Promise { + const prepared: StoredImage[] = []; + for (const image of images) prepared.push(await storeExternalImageUrl(image)); + return prepared; } async function createRoundInTransaction( @@ -138,10 +140,11 @@ export const notesService = { }, async createInProjectFromUrls(projectId: number, title: string, description: string, imageUrls: string[], tags: string[], externalId: string | null = null): Promise { + const prepared = await prepareExternalImages(imageUrls); return withTransaction(async (tx) => { const collectionId = await ensureProjectCompatibilityCollection(projectId, tx); const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]); - await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: externalImages(imageUrls) }, undefined, 'draft'); + await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft'); return (await notesRepository.findById(id))!; }); }, @@ -162,9 +165,10 @@ export const notesService = { const collection = await database.one<{ project_id: number }>('SELECT project_id FROM collections WHERE id = ?', [collectionId]); if (!collection) throw new Error('作品交付集不存在'); const projectId = Number(collection.project_id); + const prepared = await prepareExternalImages(imageUrls); return withTransaction(async (tx) => { const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]); - await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: externalImages(imageUrls) }, undefined, 'draft'); + await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft'); return (await notesRepository.findById(id))!; }); }, @@ -183,7 +187,7 @@ export const notesService = { async createRoundFromUrls(id: number, round: UrlRound, createdBy?: number): Promise { const current = await notesRepository.findById(id); if (!current) throw new Error('作品不存在'); - const prepared = { ...round, images: externalImages(round.images) }; + const prepared = { ...round, images: await prepareExternalImages(round.images) }; await withTransaction((tx) => createRoundInTransaction(tx, id, current.project_id, current.collection_id, prepared, createdBy, current.review_status)); return (await notesRepository.findById(id))!; }, diff --git a/api/storage.ts b/api/storage.ts index d6b307b..1e920d6 100644 --- a/api/storage.ts +++ b/api/storage.ts @@ -1,7 +1,10 @@ import COS from 'cos-nodejs-sdk-v5'; import fs from 'fs'; import path from 'path'; -import { randomUUID } from 'crypto'; +import { createHash, randomUUID } from 'crypto'; +import { lookup } from 'dns/promises'; +import { isIP } from 'net'; +import sharp from 'sharp'; import { database } from './database.js'; import { decryptSecret } from './configCrypto.js'; @@ -22,6 +25,28 @@ export interface StoredUpload { storageKey: string; } +export interface StoredExternalImage extends StoredUpload { + width: number; + height: number; +} + +export class StorageImportError extends Error { + constructor(public statusCode: number, message: string) { super(message); } +} + +const MAX_REMOTE_IMAGE_BYTES = 20 * 1024 * 1024; +const MAX_REMOTE_REDIRECTS = 3; +const IMAGE_CONTENT_TYPES = new Map([ + ['image/jpeg', '.jpg'], + ['image/jpg', '.jpg'], + ['image/png', '.png'], + ['image/gif', '.gif'], + ['image/webp', '.webp'], + ['image/avif', '.avif'], + ['image/heic', '.heic'], + ['image/heif', '.heif'], +]); + export async function getActiveStorageConfig(): Promise { return database.one(` SELECT id, region, bucket, public_base_url, cdn_domain, path_prefix, @@ -46,6 +71,95 @@ function objectUrl(config: StorageConfigRecord, key: string): string { return `${base}/${key.split('/').map(encodeURIComponent).join('/')}`; } +function configuredOrigins(config: StorageConfigRecord): Set { + const values = [config.cdn_domain, config.public_base_url, `https://${config.bucket}.cos.${config.region}.myqcloud.com`]; + return new Set(values.filter(Boolean).map((value) => new URL(value).origin)); +} + +function isPrivateIpv4(address: string): boolean { + const parts = address.split('.').map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true; + const [a, b, c] = parts; + return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) + || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) + || (a === 192 && b === 0 && c === 0) || (a === 192 && b === 0 && c === 2) + || (a === 198 && (b === 18 || b === 19)) || (a === 198 && b === 51 && c === 100) + || (a === 203 && b === 0 && c === 113) || a >= 224; +} + +function isPrivateAddress(address: string): boolean { + const normalized = address.toLowerCase().split('%')[0]; + if (isIP(normalized) === 4) return isPrivateIpv4(normalized); + if (isIP(normalized) !== 6) return true; + if (normalized.startsWith('::ffff:')) return isPrivateIpv4(normalized.slice(7)); + return normalized === '::' || normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') + || normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') + || normalized.startsWith('feb') || normalized.startsWith('ff'); +} + +async function assertPublicRemote(url: URL): Promise { + assertRemoteUrlShape(url); + const rawHost = url.hostname.toLowerCase(); + const host = rawHost.startsWith('[') && rawHost.endsWith(']') ? rawHost.slice(1, -1) : rawHost; + if (host === 'localhost' || host.endsWith('.localhost')) throw new StorageImportError(400, '外部图片地址不能指向本机或内网'); + let addresses: Array<{ address: string }>; + try { addresses = isIP(host) ? [{ address: host }] : await lookup(host, { all: true, verbatim: true }); } + catch { throw new StorageImportError(422, '无法解析外部图片地址'); } + if (!addresses.length || addresses.some((item) => isPrivateAddress(item.address))) { + throw new StorageImportError(400, '外部图片地址不能指向本机、内网或保留地址'); + } +} + +function assertRemoteUrlShape(url: URL): void { + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) { + throw new StorageImportError(400, '图片地址必须是公开的 HTTP/HTTPS URL'); + } + if ((url.protocol === 'http:' && url.port && url.port !== '80') || (url.protocol === 'https:' && url.port && url.port !== '443')) { + throw new StorageImportError(400, '外部图片地址只能使用标准 HTTP/HTTPS 端口'); + } +} + +async function downloadRemoteImage(source: URL, redirectCount = 0): Promise<{ body: Buffer; contentType: string }> { + await assertPublicRemote(source); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15_000); + try { + let response: Response; + try { + response = await fetch(source, { redirect: 'manual', signal: controller.signal, headers: { Accept: 'image/*', 'User-Agent': 'Delivery-Desk/1.0' } }); + } catch (error) { + throw new StorageImportError(422, `外部图片下载失败:${error instanceof Error ? error.message : '网络错误'}`); + } + if ([301, 302, 303, 307, 308].includes(response.status)) { + const location = response.headers.get('location'); + await response.body?.cancel(); + if (!location || redirectCount >= MAX_REMOTE_REDIRECTS) throw new StorageImportError(422, '外部图片重定向无效或次数过多'); + return downloadRemoteImage(new URL(location, source), redirectCount + 1); + } + if (!response.ok || !response.body) { await response.body?.cancel(); throw new StorageImportError(422, `外部图片下载失败:HTTP ${response.status}`); } + const contentType = response.headers.get('content-type')?.split(';')[0].trim().toLowerCase() || ''; + if (!IMAGE_CONTENT_TYPES.has(contentType)) { await response.body.cancel(); throw new StorageImportError(422, '外部地址返回的不是支持的图片类型'); } + const declaredLength = Number(response.headers.get('content-length') || 0); + if (declaredLength > MAX_REMOTE_IMAGE_BYTES) { await response.body.cancel(); throw new StorageImportError(413, '外部图片不能超过 20 MB'); } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_REMOTE_IMAGE_BYTES) { await reader.cancel(); throw new StorageImportError(413, '外部图片不能超过 20 MB'); } + chunks.push(value); + } + return { body: Buffer.concat(chunks), contentType }; + } catch (error) { + if (error instanceof StorageImportError) throw error; + throw new StorageImportError(422, `外部图片下载失败:${error instanceof Error ? error.message : '网络错误'}`); + } finally { + clearTimeout(timeout); + } +} + function safeError(error: unknown): string { if (!error || typeof error !== 'object') return 'COS 连接失败'; const item = error as { code?: string; statusCode?: number; message?: string }; @@ -88,3 +202,29 @@ export async function storeUploadedFile(file: { filename: string; originalname?: fs.unlinkSync(file.path); return { url: objectUrl(config, key), storageProvider: 'tencent_cos', storageKey: key }; } + +export async function storeExternalImageUrl(value: string): Promise { + const config = await getActiveStorageConfig(); + if (!config) throw new StorageImportError(409, '未启用腾讯云 COS 配置,无法导入外部图片 URL'); + const source = new URL(value); + assertRemoteUrlShape(source); + if (configuredOrigins(config).has(source.origin)) { + return { url: source.toString(), width: 0, height: 0, storageProvider: 'tencent_cos', storageKey: source.pathname.replace(/^\/+/, '') }; + } + + const { body, contentType } = await downloadRemoteImage(source); + let metadata: sharp.Metadata; + try { metadata = await sharp(body).metadata(); } + catch { throw new StorageImportError(422, '外部地址返回的内容不是有效图片'); } + const extension = IMAGE_CONTENT_TYPES.get(contentType)!; + const prefix = cleanPrefix(config.path_prefix); + const digest = createHash('sha256').update(body).digest('hex'); + const key = `${prefix ? `${prefix}/` : ''}imports/${digest}${extension}`; + const cos = createClient(config); + try { + await cos.putObject({ Bucket: config.bucket, Region: config.region, Key: key, Body: body, ContentLength: body.length, ContentType: contentType }); + } catch (error) { + throw new StorageImportError(502, `外部图片转存 COS 失败:${safeError(error)}`); + } + return { url: objectUrl(config, key), width: metadata.width ?? 0, height: metadata.height ?? 0, storageProvider: 'tencent_cos', storageKey: key }; +} diff --git a/docs/architecture.md b/docs/architecture.md index 78ab387..ff598d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,6 +68,14 @@ flowchart LR - 标题、正文和 Tag 批注保存 `start_offset`、`end_offset`、`selected_text` 及前后文,提交时校验选区仍与轮次快照一致。 - `GET /api/works/:workId/annotations` 按轮次返回图片批注、文字批注、总体反馈和验收事件。 +## Agent 安全上传 + +内置 Agent Skill 采用“发现目标 → 生成计划 → 人工确认 → 单次写入 → 读取核验”的两阶段流程。计划文件只保存目标 ID、待写内容和确认摘要,不保存 API Key,并写入已被 Git 忽略的 `tmp/` 目录。 + +新建作品以 `externalId` 保证幂等;新增验收轮次没有幂等键。轮次写入超时或响应不明确时,必须先重新读取作品状态,不能直接重试,以免重复创建轮次。 + ## 存储 -平台管理员可在管理页保存和测试 COS 配置。SecretId/SecretKey 使用 `COS_CONFIG_ENCRYPTION_KEY` 派生的 AES-256-GCM 密钥加密,读取接口不返回明文。连接测试会上传、读取并删除临时对象。外部 API 提供的公开图片 URL 只保存地址,不下载、不转存 COS。 +平台管理员可在管理页保存和测试 COS 配置。SecretId/SecretKey 使用 `COS_CONFIG_ENCRYPTION_KEY` 派生的 AES-256-GCM 密钥加密,读取接口不返回明文。连接测试会上传、读取并删除临时对象。 + +外部 API 图片统一归一到当前活动 COS:URL 与配置的 COS 公开域名或 CDN 域名同源时直接保存;其他公开 URL 经 SSRF 防护、图片类型和 20 MB 大小校验后下载,并按内容哈希转存到 COS。没有活动 COS 配置时拒绝 URL 导入。该规则适用于新作品和新验收轮次,不追溯迁移历史图片记录。 diff --git a/docs/handoff.md b/docs/handoff.md index be907de..3089392 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -9,7 +9,8 @@ - 标题、正文和 Tag 选区批注、总体反馈、按作品聚合反馈和验收记录 - 批注回复线程、只能撤回本人反馈并保留撤回记录 - 客户项目链接、密码、姓名、访问期限和验收决定 -- API Key、审计日志、COS 前端配置及连接测试 +- API Key、审计日志、COS 前端配置、连接测试及外部 URL 安全转存 +- 内置 Agent 安全上传 Skill、双阶段确认脚本、回归测试和可分发 ZIP - SQLite/PostgreSQL 双运行时、迁移验证和 Docker 部署 - 桌面端与移动端响应式页面 diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 53fff29..2df654d 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -10,6 +10,18 @@ Authorization: Bearer dd_live_xxx 平台级 Key 可跨组创建和查询项目。项目级 Key 只能操作绑定项目,包括在该项目中新建作品和验收轮次。密钥明文只在创建时返回一次。 +## Agent 安全上传 Skill + +项目内置 `.agents/skills/upload-delivery-desk-work`,用于引导 Agent 精确定位运营组、项目和作品后创建作品或提交新验收轮次。它强制执行“发现 → 生成计划 → 操作者确认 → 单次提交 → 回读验证”,不允许根据名称猜测目标。 + +更新 Skill 后重新生成分发包: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1 +``` + +压缩包输出到 `skill-packages/upload-delivery-desk-work.zip`。API Key 只能通过 `DELIVERY_DESK_API_KEY` 环境变量提供,不应写入 Skill、计划文件或命令参数。 + ## 发现资源 ```bash @@ -37,7 +49,11 @@ curl -X POST http://localhost:3010/api/projects \ ## 创建作品 -JSON 请求中的 `images` 为 1–30 个公开 HTTP/HTTPS URL。服务只保存 URL,不下载也不转存 COS;数组顺序就是展示顺序,第一张为封面。 +JSON 请求中的 `images` 为 1–30 个公开 HTTP/HTTPS URL,且平台必须已有活动 COS 配置。数组顺序就是展示顺序,第一张为封面。 + +- URL 与活动 COS 的公开域名或 CDN 域名同源时直接保存,不重复上传。 +- 其他域名的图片会由服务端下载并转存到活动 COS,最终入库 URL 来自该 COS。 +- 外部图片单张不得超过 20 MB,必须返回受支持的图片类型;本机、内网、保留地址和非标准端口会被拒绝。 ```bash curl -X POST http://localhost:3010/api/projects/1/works \ @@ -52,7 +68,7 @@ curl -X POST http://localhost:3010/api/projects/1/works \ }' ``` -相同 `projectId + externalId` 的重试不会重复创建,响应包含 `idempotent: true`。调用方负责保证外部图片 URL 长期公开可用。 +相同 `projectId + externalId` 的重试不会重复创建,响应包含 `idempotent: true`。异源图片成功转存后不再依赖原地址长期可用。 ## 创建新验收轮次 @@ -125,4 +141,4 @@ curl -X POST http://localhost:3010/api/review/july-content/works/12/decision \ 旧的 `/api/notes`、`/api/notes/:id/versions`、`/api/notes/:id/review-rounds` 与 `/api/projects/:id/collections` 暂保留一个兼容周期。旧交付集 URL 会跳转到项目页;旧多候选稿请求会返回 `400`,不会再创建多方案轮次。新接入必须使用项目、作品和轮次接口。 -错误统一为 `{ "error": "错误说明" }`。常见状态码:`400` 输入无效、`401` 未认证、`403` 越权、`404` 不存在、`409` 状态冲突。 +错误响应均包含 `{ "error": "错误说明" }`。常见状态码:`400` 输入无效或地址被安全策略拒绝、`401` 未认证、`403` 越权、`404` 不存在、`409` 状态冲突或未启用 COS、`413` 图片超过 20 MB、`422` 外部图片无法下载或内容无效、`502` 转存 COS 失败。 diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index df177da..dc78c50 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -49,6 +49,19 @@ docker compose logs --tail=100 app COS 使用公开 URL。必须关闭桶列表功能并使用不可枚举对象名;拿到 URL 的人可以直接访问文件。 +JSON URL 导入依赖活动 COS 配置。同一 COS/CDN 域名的图片直接使用;其他域名会下载并按内容哈希写入 `/imports/`。服务会拒绝内网地址、非图片响应和超过 20 MB 的文件,因此部署网络必须允许访问确需导入的公开图片源。 + +## Agent 上传 Skill 维护 + +Agent 通过 API 新建作品或提交验收轮次时,使用 `.agents/skills/upload-delivery-desk-work`。API Key 只通过 `DELIVERY_DESK_API_KEY` 环境变量注入,不写入计划文件、文档或 Git。 + +```powershell +python tests/test_upload_skill.py +powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1 +``` + +打包脚本会先运行回归测试,再生成 `skill-packages/upload-delivery-desk-work.zip`。计划文件写入已忽略的 `tmp/`;接口或层级变化后必须同步更新 Skill、测试和分发包。 + ## 数据备份与恢复 - PostgreSQL 使用托管备份或定期 `pg_dump`,恢复流程需在预发布环境演练。 @@ -67,6 +80,7 @@ pnpm test:review-rounds pnpm test:collection-status pnpm test:postgres-runtime pnpm db:postgres:validate +python tests/test_upload_skill.py ``` 正式切换前还应验证:管理员首次改密、客户访问门禁、COS 上传、客户批注与验收、数据库备份及 HTTPS Cookie。 diff --git a/scripts/package-upload-skill.ps1 b/scripts/package-upload-skill.ps1 new file mode 100644 index 0000000..9c6ba1c --- /dev/null +++ b/scripts/package-upload-skill.ps1 @@ -0,0 +1,44 @@ +$ErrorActionPreference = 'Stop' + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$skillName = 'upload-delivery-desk-work' +$sourcePath = Join-Path $repositoryRoot ".agents\skills\$skillName" +$packageDirectory = Join-Path $repositoryRoot 'skill-packages' +$packagePath = Join-Path $packageDirectory "$skillName.zip" + +if (-not (Test-Path -LiteralPath (Join-Path $sourcePath 'SKILL.md'))) { + throw "Skill not found: $sourcePath" +} + +$previousNoBytecode = $env:PYTHONDONTWRITEBYTECODE +try { + $env:PYTHONDONTWRITEBYTECODE = '1' + python (Join-Path $repositoryRoot 'tests\test_upload_skill.py') + if ($LASTEXITCODE -ne 0) { + throw 'Upload Skill regression tests failed.' + } +} finally { + $env:PYTHONDONTWRITEBYTECODE = $previousNoBytecode +} + +$runtimeFiles = Get-ChildItem -LiteralPath $sourcePath -Recurse -Force | Where-Object { + $_.Name -eq '__pycache__' -or $_.Extension -in @('.pyc', '.pyo') +} +if ($runtimeFiles) { + throw 'Skill contains Python runtime cache. Remove it before packaging.' +} + +New-Item -ItemType Directory -Force -Path $packageDirectory | Out-Null +if (Test-Path -LiteralPath $packagePath) { + Remove-Item -LiteralPath $packagePath -Force +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +[System.IO.Compression.ZipFile]::CreateFromDirectory( + $sourcePath, + $packagePath, + [System.IO.Compression.CompressionLevel]::Optimal, + $true +) + +Write-Output $packagePath diff --git a/scripts/test-postgres-runtime.ts b/scripts/test-postgres-runtime.ts index e14d1ea..4a7a1ee 100644 --- a/scripts/test-postgres-runtime.ts +++ b/scripts/test-postgres-runtime.ts @@ -5,6 +5,7 @@ process.env.COS_CONFIG_ENCRYPTION_KEY = 'test-only-encryption-key-at-least-32-ch const { default: app } = await import('../api/app.js'); const { database, closeDatabase } = await import('../api/database.js'); +const { encryptSecret } = await import('../api/configCrypto.js'); const server = app.listen(0, '127.0.0.1'); await new Promise((resolve) => server.once('listening', resolve)); const address = server.address(); @@ -28,7 +29,6 @@ try { expectStatus(login.response.status, 200, '平台管理员登录'); const adminCookie = login.response.headers.get('set-cookie')?.split(';')[0]; if (!adminCookie) throw new Error('登录未返回会话 Cookie'); - const group = await request('/api/management/groups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '测试运营组', username: 'test_manager', display_name: '测试管理员', password: 'Manager123!' }) }, adminCookie); expectStatus(group.response.status, 201, '创建运营组'); const groupId = Number((group.body as { id: number }).id); @@ -76,12 +76,42 @@ try { expectStatus(project.response.status, 201, '创建项目', project.body); if((project.body as {group_name?:string}).group_name!=='已更名运营组')throw new Error('项目接口未返回所属运营组名称'); const projectId = Number((project.body as { id: number }).id); + const missingStorage=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'缺少 COS 配置',images:['https://cdn.example.com/missing.jpg']})},adminCookie); + 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 导入失败后仍创建了作品记录'); + 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 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); const otherWork=await request(`/api/projects/${otherProjectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'其他项目作品',images:['https://cdn.example.com/isolated.jpg']})},adminCookie); expectStatus(otherWork.response.status,201,'创建其他项目作品',otherWork.body); const otherWorkId=Number((otherWork.body as {id:number}).id); + const nativeFetch=globalThis.fetch; + const {default:COS}=await import('cos-nodejs-sdk-v5'); + const cosPrototype=COS.prototype as unknown as {putObject:(...args:unknown[])=>unknown}; + const nativePutObject=cosPrototype.putObject; + let importedStorageKey=''; + try{ + globalThis.fetch=(async(input:RequestInfo|URL,init?:RequestInit)=>{ + const target=input instanceof Request?input.url:String(input); + if(target==='https://93.184.216.34/source.png')return new Response(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64'),{status:200,headers:{'Content-Type':'image/png'}}); + return nativeFetch(input,init); + }) as typeof fetch; + cosPrototype.putObject=(async(options:unknown)=>{importedStorageKey=String((options as {Key:string}).Key);return{statusCode:200}}) as typeof cosPrototype.putObject; + const importedWork=await request(`/api/projects/${otherProjectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'异源转存作品',images:['https://93.184.216.34/source.png']})},adminCookie); + expectStatus(importedWork.response.status,201,'异源图片转存 COS',importedWork.body); + const importedDetail=await request(`/api/works/${Number((importedWork.body as {id:number}).id)}`,{},adminCookie); + const importedImage=(importedDetail.body as {images:Array<{url:string;storage_provider:string;storage_key:string}>}).images[0]; + if(!importedStorageKey.startsWith('delivery-desk/imports/')||importedImage.url!==`https://cdn.example.com/${importedStorageKey}`||importedImage.storage_provider!=='tencent_cos')throw new Error('异源图片没有归一到当前 COS'); + }finally{ + globalThis.fetch=nativeFetch; + cosPrototype.putObject=nativePutObject; + } const access = await request(`/api/projects/${projectId}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, password: 'Review123!' }) }, adminCookie); expectStatus(access.response.status, 200, '配置客户访问'); @@ -129,7 +159,7 @@ try { const firstRound=(workDetail.body as {rounds:Array<{version_number:number;round_status:string;completion_reason:string}>}).rounds.find((item)=>item.version_number===1); if(firstRound?.round_status!=='completed'||firstRound.completion_reason!=='superseded')throw new Error('新验收轮次未自动收口旧轮次'); const currentImages=(workDetail.body as {images:Array<{url:string;storage_provider:string}>}).images; - if(currentImages.length!==1||currentImages[0].url!=='https://cdn.example.com/runtime-v2.jpg'||currentImages[0].storage_provider!=='external')throw new Error('新版本图片没有按外部 URL 保存'); + if(currentImages.length!==1||currentImages[0].url!=='https://cdn.example.com/runtime-v2.jpg'||currentImages[0].storage_provider!=='tencent_cos')throw new Error('同源 COS 图片没有直接复用'); const reviewLogin = await request('/api/review/postgres-runtime-test/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer_name: '客户测试', password: 'Review123!' }) }); expectStatus(reviewLogin.response.status, 200, '客户登录'); const reviewCookie = reviewLogin.response.headers.get('set-cookie')?.split(';')[0]; @@ -250,7 +280,7 @@ try { const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie); expectStatus(revoke.response.status, 204, '吊销平台 API Key'); - process.stdout.write('PostgreSQL 运行时验证通过:账号角色、项目隔离、客户门禁、项目自动状态、完成只读、API Key 发现、externalId 幂等、JSON URL 作品与单方案轮次\n'); + process.stdout.write('PostgreSQL 运行时验证通过:账号角色、项目隔离、客户门禁、项目自动状态、完成只读、API Key 发现、externalId 幂等、COS 同源复用、异源转存、SSRF 拦截与单方案轮次\n'); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); await closeDatabase(); diff --git a/skill-packages/upload-delivery-desk-work.zip b/skill-packages/upload-delivery-desk-work.zip new file mode 100644 index 0000000000000000000000000000000000000000..37ed241fe4448639342be47524191458762aac1b GIT binary patch literal 9903 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 dict: + value = {"schema_version": 1, "operation": operation, "base_url": "http://example.invalid", "target": IDENTITY, "content": CONTENT} + if operation == "create_work": + value["external_id"] = "agent-test-001" + else: + value["work"] = {"work_id": 34, "external_id": "existing-34", "expected_version_number": 2, "current_round": 2, "next_round": 3} + value["confirmation_code"] = MODULE.confirmation_code(value) + return value + + +def write_plan(folder: Path, value: dict) -> Path: + path = folder / "plan.json" + path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8") + return path + + +def detail(version: int, *, work_id: int = 88, image_url: str | None = None) -> dict: + return {"id": work_id, "project": {"id": 12}, "external_id": "agent-test-001", "version_number": version, "title": CONTENT["title"], "description": CONTENT["description"], "tags": CONTENT["tags"], "images": [{"url": image_url or CONTENT["images"][0]}], "rounds": [{"round_number": version}]} + + +def apply_silently(path: Path, code: str) -> dict: + output = io.StringIO() + with contextlib.redirect_stdout(output): + MODULE.cmd_apply(argparse.Namespace(plan=str(path), confirm=code)) + return json.loads(output.getvalue()) + + +def main() -> None: + original_exact_project = MODULE.exact_project + original_request_json = MODULE.request_json + original_get_work = MODULE.get_work + MODULE.exact_project = lambda _base, _project_id: PROJECT + try: + with tempfile.TemporaryDirectory() as folder_name: + folder = Path(folder_name) + work_plan = make_plan("create_work") + work_path = write_plan(folder, work_plan) + + calls: list[tuple] = [] + MODULE.request_json = lambda *_args, **_kwargs: calls.append((_args, _kwargs)) + try: + apply_silently(work_path, "WRONG") + raise AssertionError("错误确认码未被阻止") + except MODULE.UploadError as error: + assert "确认码不匹配" in str(error) + assert not calls + + MODULE.request_json = lambda *_args, **_kwargs: (200, [{"id": 88, "external_id": "agent-test-001"}]) + result = apply_silently(work_path, work_plan["confirmation_code"]) + assert result["idempotent"] is True and result["work"]["id"] == 88 + + create_calls: list[tuple[str, str]] = [] + def create_request(_base: str, path: str, *, method: str = "GET", body=None): + create_calls.append((method, path)) + return (200, []) if method == "GET" else (201, {"id": 88}) + MODULE.request_json = create_request + MODULE.get_work = lambda *_args, **_kwargs: detail(1, image_url="https://cos.example.com/imports/normalized.jpg") + result = apply_silently(work_path, work_plan["confirmation_code"]) + assert result["work_id"] == 88 and result["image_count"] == 1 + assert [method for method, _path in create_calls].count("POST") == 1 + + round_plan = make_plan("create_round") + round_path = write_plan(folder, round_plan) + MODULE.get_work = lambda *_args, **_kwargs: detail(3, work_id=34) + post_calls: list[tuple] = [] + MODULE.request_json = lambda *_args, **_kwargs: post_calls.append((_args, _kwargs)) + try: + apply_silently(round_path, round_plan["confirmation_code"]) + raise AssertionError("版本漂移未被阻止") + except MODULE.UploadError as error: + assert "当前版本已变化" in str(error) + assert not post_calls + + work_reads = iter([detail(2, work_id=34), detail(3, work_id=34)]) + MODULE.get_work = lambda *_args, **_kwargs: next(work_reads) + MODULE.request_json = lambda *_args, **_kwargs: (201, {"id": 34}) + result = apply_silently(round_path, round_plan["confirmation_code"]) + assert result["work_id"] == 34 and result["round_number"] == 3 + + try: + MODULE.assert_work_project({"project": {"id": 99}}, 12) + raise AssertionError("错误项目归属未被阻止") + except MODULE.UploadError as error: + assert "不是已确认项目" in str(error) + finally: + MODULE.exact_project = original_exact_project + MODULE.request_json = original_request_json + MODULE.get_work = original_get_work + print("Upload Skill regression tests passed") + + +if __name__ == "__main__": + main()