feat(api): 添加安全上传 Skill 和 COS 图片归一化

This commit is contained in:
yuzhe
2026-07-22 18:12:23 +08:00
parent e7e268d4eb
commit b8b4d7a11c
17 changed files with 997 additions and 18 deletions

View File

@@ -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: `确认按以上目标和内容执行吗?请回复“确认 <confirmation_code>”。`
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.

View File

@@ -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."

View File

@@ -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 <DELIVERY_DESK_API_KEY>`.
| 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=<value>` 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.

View File

@@ -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)

View File

@@ -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` 重新打包。

View File

@@ -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)

View File

@@ -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 });
});

View File

@@ -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<StoredImage[]> {
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<StoredImage[]> {
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<Note> {
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<Note> {
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))!;
},

View File

@@ -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<StorageConfigRecord | undefined> {
return database.one<StorageConfigRecord>(`
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<string> {
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<void> {
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<StoredExternalImage> {
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 };
}

View File

@@ -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 图片统一归一到当前活动 COSURL 与配置的 COS 公开域名或 CDN 域名同源时直接保存;其他公开 URL 经 SSRF 防护、图片类型和 20 MB 大小校验后下载,并按内容哈希转存到 COS。没有活动 COS 配置时拒绝 URL 导入。该规则适用于新作品和新验收轮次,不追溯迁移历史图片记录。

View File

@@ -9,7 +9,8 @@
- 标题、正文和 Tag 选区批注、总体反馈、按作品聚合反馈和验收记录
- 批注回复线程、只能撤回本人反馈并保留撤回记录
- 客户项目链接、密码、姓名、访问期限和验收决定
- API Key、审计日志、COS 前端配置连接测试
- API Key、审计日志、COS 前端配置连接测试及外部 URL 安全转存
- 内置 Agent 安全上传 Skill、双阶段确认脚本、回归测试和可分发 ZIP
- SQLite/PostgreSQL 双运行时、迁移验证和 Docker 部署
- 桌面端与移动端响应式页面

View File

@@ -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` 为 130 个公开 HTTP/HTTPS URL。服务只保存 URL不下载也不转存 COS数组顺序就是展示顺序,第一张为封面。
JSON 请求中的 `images` 为 130 个公开 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 失败

View File

@@ -49,6 +49,19 @@ docker compose logs --tail=100 app
COS 使用公开 URL。必须关闭桶列表功能并使用不可枚举对象名拿到 URL 的人可以直接访问文件。
JSON URL 导入依赖活动 COS 配置。同一 COS/CDN 域名的图片直接使用;其他域名会下载并按内容哈希写入 `<path-prefix>/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。

View File

@@ -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

View File

@@ -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<void>((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<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await closeDatabase();

Binary file not shown.

121
tests/test_upload_skill.py Normal file
View File

@@ -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()