feat(api): 添加安全上传 Skill 和 COS 图片归一化
This commit is contained in:
148
.agents/skills/upload-delivery-desk-work/SKILL.md
Normal file
148
.agents/skills/upload-delivery-desk-work/SKILL.md
Normal 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.
|
||||||
@@ -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."
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
# Delivery Desk 开发约定
|
# 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 命名中。
|
- 业务术语统一为“运营组 → 项目 → 作品 → 验收轮次”;每轮只有一个方案。`collections` 和 `work_versions` 是迁移期内部兼容结构,不得出现在新产品界面或新 API 命名中。
|
||||||
- 新接口使用 `/api/projects/:projectId/works` 和 `/api/works/:workId/rounds`;`notes`、`collections`、`versions` 路由只做一个兼容周期,不再扩展。
|
- 新接口使用 `/api/projects/:projectId/works` 和 `/api/works/:workId/rounds`;`notes`、`collections`、`versions` 路由只做一个兼容周期,不再扩展。
|
||||||
- 数据库结构变更必须同时更新 `api/db.ts`、`db/postgres/schema.sql` 和迁移验证。
|
- 数据库结构变更必须同时更新 `api/db.ts`、`db/postgres/schema.sql` 和迁移验证。
|
||||||
- `data/`、`uploads/`、`.env*`、COS 凭证、数据库文件及用户上传内容不得提交。
|
- `data/`、`uploads/`、`.env*`、COS 凭证、数据库文件及用户上传内容不得提交。
|
||||||
- 本地开发可使用 SQLite;正式部署使用 PostgreSQL。COS 配置只通过平台管理界面或部署密钥注入。
|
- 本地开发可使用 SQLite;正式部署使用 PostgreSQL。COS 配置只通过平台管理界面或部署密钥注入。
|
||||||
- 不把 `.trae/` 中的早期原型文档作为现行依据;以 README、`docs/` 和当前代码为准。
|
- 不把 `.trae/` 中的早期原型文档作为现行依据;以 README、`docs/` 和当前代码为准。
|
||||||
|
- Agent 通过 API 上传作品或创建验收轮次时必须使用 `.agents/skills/upload-delivery-desk-work`;接口或层级变化后同步更新该 Skill,并运行 `powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1` 重新打包。
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
|
|
||||||
- 平台管理员、组管理员、光影叙事三类账号及运营组数据隔离
|
- 平台管理员、组管理员、光影叙事三类账号及运营组数据隔离
|
||||||
- 项目、作品、单方案验收轮次和项目级自动验收状态
|
- 项目、作品、单方案验收轮次和项目级自动验收状态
|
||||||
- 手动多图上传、公开图片 URL API、拖拽排序和腾讯云 COS
|
- 手动多图上传、公开图片 URL 自动归一到腾讯云 COS、拖拽排序
|
||||||
- 作品缩略图浏览;悬浮图片窗格中的原图查看、缩放和坐标批注
|
- 作品缩略图浏览;悬浮图片窗格中的原图查看、缩放和坐标批注
|
||||||
- 标题、正文和 Tag 选区批注、作品总体反馈和验收记录
|
- 标题、正文和 Tag 选区批注、作品总体反馈和验收记录
|
||||||
- 客户项目密码、访问期限和独立验收入口
|
- 客户项目密码、访问期限和独立验收入口
|
||||||
- 平台级/项目级 API Key、审计日志和账号管理
|
- 平台级/项目级 API Key、审计日志和账号管理
|
||||||
|
- 项目内置 Agent 安全上传 Skill,使用双阶段确认防止错组、错项目和错作品
|
||||||
- SQLite 本地开发、PostgreSQL 正式运行及 Docker 部署
|
- SQLite 本地开发、PostgreSQL 正式运行及 Docker 部署
|
||||||
|
|
||||||
未落地范围见 [初版交接说明](docs/handoff.md)。
|
未落地范围见 [初版交接说明](docs/handoff.md)。
|
||||||
@@ -52,8 +53,11 @@ pnpm test:review-rounds
|
|||||||
pnpm test:collection-status
|
pnpm test:collection-status
|
||||||
pnpm test:postgres-runtime
|
pnpm test:postgres-runtime
|
||||||
pnpm db:postgres:validate
|
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)
|
- [架构与数据模型](docs/architecture.md)
|
||||||
|
|||||||
@@ -86,13 +86,18 @@ if (process.env.NODE_ENV === 'production' && fs.existsSync(distDir)) {
|
|||||||
*/
|
*/
|
||||||
app.use((err: Error, _req: Request, res: Response, _next: unknown) => {
|
app.use((err: Error, _req: Request, res: Response, _next: unknown) => {
|
||||||
void _next;
|
void _next;
|
||||||
console.error('[API Error]', err);
|
|
||||||
// multer 文件类型/大小错误
|
// multer 文件类型/大小错误
|
||||||
const message = err.message || '服务器内部错误';
|
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('不支持的文件类型')) {
|
if (err.message?.includes('不支持的文件类型')) {
|
||||||
res.status(400).json({ success: false, error: message });
|
res.status(400).json({ success: false, error: message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.error('[API Error]', err);
|
||||||
res.status(500).json({ success: false, error: message });
|
res.status(500).json({ success: false, error: message });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { notesRepository } from '../repositories/notesRepository.js';
|
|||||||
import { imagesRepository } from '../repositories/imagesRepository.js';
|
import { imagesRepository } from '../repositories/imagesRepository.js';
|
||||||
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
||||||
import { database, databaseDialect, withTransaction, type QueryContext } from '../database.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 { recalculateCollectionStatus } from './collectionsService.js';
|
||||||
import { ensureProjectCompatibilityCollection, recalculateProjectReviewStatus } from './projectsService.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)) })));
|
return Promise.all(files.map(async (file) => ({ ...(await readImageSize(file.path)), ...(await storeUploadedFile(file)) })));
|
||||||
}
|
}
|
||||||
|
|
||||||
function externalImages(images: string[]): StoredImage[] {
|
async function prepareExternalImages(images: string[]): Promise<StoredImage[]> {
|
||||||
return images.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external', storageKey: '' }));
|
const prepared: StoredImage[] = [];
|
||||||
|
for (const image of images) prepared.push(await storeExternalImageUrl(image));
|
||||||
|
return prepared;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createRoundInTransaction(
|
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> {
|
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) => {
|
return withTransaction(async (tx) => {
|
||||||
const collectionId = await ensureProjectCompatibilityCollection(projectId, 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)]);
|
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))!;
|
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]);
|
const collection = await database.one<{ project_id: number }>('SELECT project_id FROM collections WHERE id = ?', [collectionId]);
|
||||||
if (!collection) throw new Error('作品交付集不存在');
|
if (!collection) throw new Error('作品交付集不存在');
|
||||||
const projectId = Number(collection.project_id);
|
const projectId = Number(collection.project_id);
|
||||||
|
const prepared = await prepareExternalImages(imageUrls);
|
||||||
return withTransaction(async (tx) => {
|
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)]);
|
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))!;
|
return (await notesRepository.findById(id))!;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -183,7 +187,7 @@ export const notesService = {
|
|||||||
async createRoundFromUrls(id: number, round: UrlRound, createdBy?: number): Promise<Note> {
|
async createRoundFromUrls(id: number, round: UrlRound, createdBy?: number): Promise<Note> {
|
||||||
const current = await notesRepository.findById(id);
|
const current = await notesRepository.findById(id);
|
||||||
if (!current) throw new Error('作品不存在');
|
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));
|
await withTransaction((tx) => createRoundInTransaction(tx, id, current.project_id, current.collection_id, prepared, createdBy, current.review_status));
|
||||||
return (await notesRepository.findById(id))!;
|
return (await notesRepository.findById(id))!;
|
||||||
},
|
},
|
||||||
|
|||||||
142
api/storage.ts
142
api/storage.ts
@@ -1,7 +1,10 @@
|
|||||||
import COS from 'cos-nodejs-sdk-v5';
|
import COS from 'cos-nodejs-sdk-v5';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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 { database } from './database.js';
|
||||||
import { decryptSecret } from './configCrypto.js';
|
import { decryptSecret } from './configCrypto.js';
|
||||||
|
|
||||||
@@ -22,6 +25,28 @@ export interface StoredUpload {
|
|||||||
storageKey: string;
|
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> {
|
export async function getActiveStorageConfig(): Promise<StorageConfigRecord | undefined> {
|
||||||
return database.one<StorageConfigRecord>(`
|
return database.one<StorageConfigRecord>(`
|
||||||
SELECT id, region, bucket, public_base_url, cdn_domain, path_prefix,
|
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('/')}`;
|
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 {
|
function safeError(error: unknown): string {
|
||||||
if (!error || typeof error !== 'object') return 'COS 连接失败';
|
if (!error || typeof error !== 'object') return 'COS 连接失败';
|
||||||
const item = error as { code?: string; statusCode?: number; message?: string };
|
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);
|
fs.unlinkSync(file.path);
|
||||||
return { url: objectUrl(config, key), storageProvider: 'tencent_cos', storageKey: key };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ flowchart LR
|
|||||||
- 标题、正文和 Tag 批注保存 `start_offset`、`end_offset`、`selected_text` 及前后文,提交时校验选区仍与轮次快照一致。
|
- 标题、正文和 Tag 批注保存 `start_offset`、`end_offset`、`selected_text` 及前后文,提交时校验选区仍与轮次快照一致。
|
||||||
- `GET /api/works/:workId/annotations` 按轮次返回图片批注、文字批注、总体反馈和验收事件。
|
- `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 导入。该规则适用于新作品和新验收轮次,不追溯迁移历史图片记录。
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
- 标题、正文和 Tag 选区批注、总体反馈、按作品聚合反馈和验收记录
|
- 标题、正文和 Tag 选区批注、总体反馈、按作品聚合反馈和验收记录
|
||||||
- 批注回复线程、只能撤回本人反馈并保留撤回记录
|
- 批注回复线程、只能撤回本人反馈并保留撤回记录
|
||||||
- 客户项目链接、密码、姓名、访问期限和验收决定
|
- 客户项目链接、密码、姓名、访问期限和验收决定
|
||||||
- API Key、审计日志、COS 前端配置及连接测试
|
- API Key、审计日志、COS 前端配置、连接测试及外部 URL 安全转存
|
||||||
|
- 内置 Agent 安全上传 Skill、双阶段确认脚本、回归测试和可分发 ZIP
|
||||||
- SQLite/PostgreSQL 双运行时、迁移验证和 Docker 部署
|
- SQLite/PostgreSQL 双运行时、迁移验证和 Docker 部署
|
||||||
- 桌面端与移动端响应式页面
|
- 桌面端与移动端响应式页面
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ Authorization: Bearer dd_live_xxx
|
|||||||
|
|
||||||
平台级 Key 可跨组创建和查询项目。项目级 Key 只能操作绑定项目,包括在该项目中新建作品和验收轮次。密钥明文只在创建时返回一次。
|
平台级 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
|
```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
|
```bash
|
||||||
curl -X POST http://localhost:3010/api/projects/1/works \
|
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`,不会再创建多方案轮次。新接入必须使用项目、作品和轮次接口。
|
旧的 `/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 失败。
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ docker compose logs --tail=100 app
|
|||||||
|
|
||||||
COS 使用公开 URL。必须关闭桶列表功能并使用不可枚举对象名;拿到 URL 的人可以直接访问文件。
|
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`,恢复流程需在预发布环境演练。
|
- PostgreSQL 使用托管备份或定期 `pg_dump`,恢复流程需在预发布环境演练。
|
||||||
@@ -67,6 +80,7 @@ pnpm test:review-rounds
|
|||||||
pnpm test:collection-status
|
pnpm test:collection-status
|
||||||
pnpm test:postgres-runtime
|
pnpm test:postgres-runtime
|
||||||
pnpm db:postgres:validate
|
pnpm db:postgres:validate
|
||||||
|
python tests/test_upload_skill.py
|
||||||
```
|
```
|
||||||
|
|
||||||
正式切换前还应验证:管理员首次改密、客户访问门禁、COS 上传、客户批注与验收、数据库备份及 HTTPS Cookie。
|
正式切换前还应验证:管理员首次改密、客户访问门禁、COS 上传、客户批注与验收、数据库备份及 HTTPS Cookie。
|
||||||
|
|||||||
44
scripts/package-upload-skill.ps1
Normal file
44
scripts/package-upload-skill.ps1
Normal 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
|
||||||
@@ -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 { default: app } = await import('../api/app.js');
|
||||||
const { database, closeDatabase } = await import('../api/database.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');
|
const server = app.listen(0, '127.0.0.1');
|
||||||
await new Promise<void>((resolve) => server.once('listening', resolve));
|
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||||
const address = server.address();
|
const address = server.address();
|
||||||
@@ -28,7 +29,6 @@ try {
|
|||||||
expectStatus(login.response.status, 200, '平台管理员登录');
|
expectStatus(login.response.status, 200, '平台管理员登录');
|
||||||
const adminCookie = login.response.headers.get('set-cookie')?.split(';')[0];
|
const adminCookie = login.response.headers.get('set-cookie')?.split(';')[0];
|
||||||
if (!adminCookie) throw new Error('登录未返回会话 Cookie');
|
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);
|
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, '创建运营组');
|
expectStatus(group.response.status, 201, '创建运营组');
|
||||||
const groupId = Number((group.body as { id: number }).id);
|
const groupId = Number((group.body as { id: number }).id);
|
||||||
@@ -76,12 +76,42 @@ try {
|
|||||||
expectStatus(project.response.status, 201, '创建项目', project.body);
|
expectStatus(project.response.status, 201, '创建项目', project.body);
|
||||||
if((project.body as {group_name?:string}).group_name!=='已更名运营组')throw new Error('项目接口未返回所属运营组名称');
|
if((project.body as {group_name?:string}).group_name!=='已更名运营组')throw new Error('项目接口未返回所属运营组名称');
|
||||||
const projectId = Number((project.body as { id: number }).id);
|
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);
|
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);
|
expectStatus(otherProject.response.status,201,'创建同组隔离项目',otherProject.body);
|
||||||
const otherProjectId=Number((otherProject.body as {id:number}).id);
|
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);
|
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);
|
expectStatus(otherWork.response.status,201,'创建其他项目作品',otherWork.body);
|
||||||
const otherWorkId=Number((otherWork.body as {id:number}).id);
|
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);
|
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, '配置客户访问');
|
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);
|
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('新验收轮次未自动收口旧轮次');
|
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;
|
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!' }) });
|
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, '客户登录');
|
expectStatus(reviewLogin.response.status, 200, '客户登录');
|
||||||
const reviewCookie = reviewLogin.response.headers.get('set-cookie')?.split(';')[0];
|
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);
|
const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie);
|
||||||
expectStatus(revoke.response.status, 204, '吊销平台 API Key');
|
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 {
|
} finally {
|
||||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||||
await closeDatabase();
|
await closeDatabase();
|
||||||
|
|||||||
BIN
skill-packages/upload-delivery-desk-work.zip
Normal file
BIN
skill-packages/upload-delivery-desk-work.zip
Normal file
Binary file not shown.
121
tests/test_upload_skill.py
Normal file
121
tests/test_upload_skill.py
Normal 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()
|
||||||
Reference in New Issue
Block a user