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 0000000..37ed241 Binary files /dev/null and b/skill-packages/upload-delivery-desk-work.zip differ diff --git a/tests/test_upload_skill.py b/tests/test_upload_skill.py new file mode 100644 index 0000000..2320bd6 --- /dev/null +++ b/tests/test_upload_skill.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Regression tests for the project-embedded safe upload Skill.""" + +from __future__ import annotations + +import argparse +import contextlib +import importlib.util +import io +import json +import sys +import tempfile +from pathlib import Path + + +sys.dont_write_bytecode = True +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / ".agents" / "skills" / "upload-delivery-desk-work" / "scripts" / "delivery_desk_upload.py" +SPEC = importlib.util.spec_from_file_location("delivery_desk_upload", SCRIPT) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + +PROJECT = {"group_id": 7, "group_name": "精确运营组", "id": 12, "name": "光影项目", "slug": "light-project", "status": "active"} +IDENTITY = MODULE.project_identity(PROJECT) +CONTENT = {"title": "作品标题", "description": "正文", "tags": ["#测试"], "images": ["https://cdn.example.com/01.jpg"]} + + +def make_plan(operation: str) -> 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()