Compare commits

...

6 Commits

Author SHA1 Message Date
yuzhe
850f213aa5 docs: 对齐数据库与 COS 配置说明 2026-07-24 16:40:55 +08:00
yuzhe
fd93c69245 fix(management): 联动对象存储连接状态 2026-07-24 16:24:42 +08:00
yuzhe
2dadeed759 fix(ui): 兼容 HTTP 复制并固化开发启停 2026-07-23 15:09:23 +08:00
yuzhe
4569724cec docs: 同步 Agent 上传与优化接口说明 2026-07-23 13:55:00 +08:00
yuzhe
557e24883d feat(skill): 固定服务地址并强化目标确认 2026-07-23 13:50:24 +08:00
yuzhe
c067881415 feat(upload): 支持 Agent 本地图片安全上传 2026-07-23 13:26:10 +08:00
24 changed files with 591 additions and 67 deletions

View File

@@ -12,12 +12,14 @@ Use a two-phase plan/apply workflow. Optimize for correct placement, not speed.
- Treat the product hierarchy as `operation group -> project -> work -> review round`. - 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 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 infer a project or work from a partial name, page position, recent activity, or a remembered ID.
- At the start of every invocation, discover and explicitly ask the operator to confirm the exact operation group and project. A target confirmation from an earlier invocation cannot be reused.
- Never create a work until the operator confirms the resolved group, project, content, image order, and confirmation code. - 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 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. - 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. - 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. - Accept either 1-30 public `http`/`https` image URLs or 1-30 local image files. Never mix both modes in one plan. Preserve 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. - 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.
- Upload local files as `multipart/form-data` from the bundled script. Never read image bytes into the conversation, print Base64, or put binary data in the plan.
- Put API keys only in `DELIVERY_DESK_API_KEY`. Do not paste keys into chat, plans, source files, or command history. - 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. - 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. - Stop on any mismatch, ambiguity, changed project/work state, or missing input. Ask the operator instead of guessing.
@@ -37,11 +39,12 @@ Do not create groups, projects, API keys, feedback, or review decisions with thi
For both operations, require: 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. - Use the fixed Delivery Desk API address `http://192.168.30.90:3010`. Do not ask the operator to configure it. Use `--base-url` only when the operator explicitly instructs you to migrate or test another environment.
- A valid API key in `DELIVERY_DESK_API_KEY`. - A valid API key in `DELIVERY_DESK_API_KEY`.
- Exact target project, resolved to group ID/name and project ID/name/slug. - Exact target project, resolved to group ID/name and project ID/name/slug.
- Ordered public image URLs. - Ordered public image URLs or ordered local image paths.
- 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 URL mode, confirmation that source URLs remain reachable until import finishes.
- For file mode, confirmation that files remain unchanged until apply. The plan records absolute path, byte size, SHA-256, and content type; it never records image bytes.
For `create_work`, also require: For `create_work`, also require:
@@ -55,7 +58,7 @@ For `create_round`, also require:
When information is missing, ask one concise question listing only the missing fields. Do not proceed to mutation. When information is missing, ask one concise question listing only the missing fields. Do not proceed to mutation.
## 3. Discover authoritative IDs ## 3. Discover and confirm the authoritative target
Use the bundled script from the skill directory: Use the bundled script from the skill directory:
@@ -71,8 +74,9 @@ Resolution rules:
1. Match IDs first. 1. Match IDs first.
2. Validate the project ID against its returned group ID, group name, project name, and slug. 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. 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. 4. Even when the request already names a target or only one project is accessible, show the resolved group name/ID and project name/ID/slug and ask: `本次操作目标是否为:运营组「<group_name>」(ID <group_id>) / 项目「<project_name>」(ID <project_id>, slug <slug>)?请回复“确认目标”。`
5. For a new round, verify the work belongs to the confirmed project. 5. Accept only an explicit target confirmation given during the current invocation. Do not generate `plan-work` or `plan-round` before it.
6. 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. Do not silently choose the only project merely because an API key currently exposes one.
@@ -102,6 +106,20 @@ python $skillScript plan-round `
--output tmp/delivery-plan.json --output tmp/delivery-plan.json
``` ```
Use local files instead of URLs:
```powershell
python $skillScript plan-work `
--project-id 12 `
--external-id client-2026-002 `
--title "本地生成作品" `
--image-file "D:\generated\01.png" `
--image-file "D:\generated\02.png" `
--output tmp/delivery-plan.json
```
Use repeated `--image-url` or repeated `--image-file`, never both. Local files must be JPEG, PNG, GIF, WebP, or AVIF, each no larger than 20 MB.
Optional `plan-round` content flags: Optional `plan-round` content flags:
- `--title`, `--description`, and repeated `--tag` replace current values. - `--title`, `--description`, and repeated `--tag` replace current values.
@@ -120,7 +138,7 @@ Show the plan summary exactly, including:
- Work ID and current/next round for `create_round`. - Work ID and current/next round for `create_round`.
- `externalId` for `create_work`. - `externalId` for `create_work`.
- Title, complete description, complete tag list. - Title, complete description, complete tag list.
- Ordered image URLs with `1` marked as the cover. - Ordered image URLs, or local file path/name/size/SHA-256, with `1` marked as the cover.
- Confirmation code. - Confirmation code.
Ask: `确认按以上目标和内容执行吗?请回复“确认 <confirmation_code>”。` Ask: `确认按以上目标和内容执行吗?请回复“确认 <confirmation_code>”。`
@@ -137,7 +155,7 @@ python $skillScript apply `
--confirm ABCD1234EF56 --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. The script re-fetches the project/work, checks for state drift, revalidates local file hashes when applicable, performs one POST, and reads the created resource back. Local files are streamed by the script as multipart and never enter model context. 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. Report group, project, work ID, `externalId`, created round number, title, image count, and whether the server returned an existing idempotent work.

View File

@@ -1,4 +1,4 @@
interface: interface:
display_name: "上传 Delivery Desk 作品" display_name: "上传 Delivery Desk 作品"
short_description: "精确定位运营组与项目,安全创建作品或提交新的验收轮次" short_description: "精确定位运营组与项目,安全创建作品或提交新的验收轮次"
default_prompt: "Use $upload-delivery-desk-work to safely locate the exact project and upload a work or a new review round." default_prompt: "Use $upload-delivery-desk-work to discover and ask me to confirm the exact operation group and project before uploading a work or a new review round."

View File

@@ -51,7 +51,7 @@ Returns current work content, project identity, images, and rounds. Use `?round=
### `POST /api/projects/:projectId/works` ### `POST /api/projects/:projectId/works`
JSON body: URL mode uses a JSON body:
```json ```json
{ {
@@ -72,6 +72,15 @@ Constraints:
- Cross-origin images must be supported image responses no larger than 20 MB. Local, private, reserved, and non-standard-port targets are rejected. - 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`. - Repeating the same `projectId + externalId` returns the existing work with `idempotent: true`.
Local-file mode uses `multipart/form-data` with text fields `externalId`, `title`, `description`, `tags` and repeated file field `images`.
- Send `tags` as a JSON array string.
- Send 1-30 JPEG, PNG, GIF, WebP, or AVIF files, each no larger than 20 MB.
- File order is display order; file 1 is the cover.
- The server validates actual image content instead of trusting only the filename or declared MIME.
- With active Tencent COS configuration, accepted files are streamed from server temporary storage into COS.
- Do not send Base64 in JSON. The agent script reads local files directly into the multipart request; binary bytes never belong in the plan or conversation.
### `POST /api/works/:workId/rounds` ### `POST /api/works/:workId/rounds`
JSON body: JSON body:
@@ -87,6 +96,7 @@ JSON body:
This endpoint is not idempotent. One successful call creates exactly one new round. Never blindly retry after a timeout. 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. It applies the same COS reuse/import rules as work creation.
It also accepts the same local-file multipart fields, except `externalId` is omitted.
## State guards ## State guards

View File

@@ -5,16 +5,31 @@ from __future__ import annotations
import argparse import argparse
import hashlib import hashlib
import http.client
import json import json
import os import os
import re import re
import sys import sys
import tempfile
import uuid
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import quote from urllib.parse import quote, urlsplit
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
MAX_LOCAL_IMAGE_BYTES = 20 * 1024 * 1024
DEFAULT_BASE_URL = "http://192.168.30.90:3010"
LOCAL_IMAGE_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".jfif": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".avif": "image/avif",
}
class UploadError(RuntimeError): class UploadError(RuntimeError):
def __init__(self, message: str, *, action: str = "ask_operator", next_step: str = "停止操作,将错误和目标信息告知调用者并等待处理") -> None: def __init__(self, message: str, *, action: str = "ask_operator", next_step: str = "停止操作,将错误和目标信息告知调用者并等待处理") -> None:
@@ -37,7 +52,7 @@ def retry(message: str, next_step: str) -> UploadError:
def http_failure(method: str, path: str, status: int, detail: str) -> UploadError: def http_failure(method: str, path: str, status: int, detail: str) -> UploadError:
message = f"{method} {path} 返回 {status}: {detail}" message = f"{method} {path} 返回 {status}: {detail}"
if status in {400, 413, 422}: if status in {400, 413, 422}:
return revise(message, "根据错误修改输入图片 URL重新生成计划并取得新的确认码后再执行") return revise(message, "根据错误修改输入图片 URL 或本地文件,重新生成计划,并取得新的确认码后再执行")
if status == 401: if status == 401:
return UploadError(message, next_step="停止操作,请调用者配置或更换有效的 DELIVERY_DESK_API_KEY") return UploadError(message, next_step="停止操作,请调用者配置或更换有效的 DELIVERY_DESK_API_KEY")
if status == 403: if status == 403:
@@ -61,7 +76,7 @@ def api_key() -> str:
def base_url(value: str | None = None) -> str: 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("/") return (value or DEFAULT_BASE_URL).rstrip("/")
def request_json(base: str, path: str, *, method: str = "GET", body: dict[str, Any] | None = None) -> tuple[int, Any]: def request_json(base: str, path: str, *, method: str = "GET", body: dict[str, Any] | None = None) -> tuple[int, Any]:
@@ -91,6 +106,75 @@ def request_json(base: str, path: str, *, method: str = "GET", body: dict[str, A
raise retry(f"无法连接 {base}: {error.reason}", "确认服务地址正确且服务可达后,原样重试当前只读命令") from error raise retry(f"无法连接 {base}: {error.reason}", "确认服务地址正确且服务可达后,原样重试当前只读命令") from error
def request_multipart(base: str, path: str, *, fields: dict[str, str], files: list[dict[str, Any]]) -> tuple[int, Any]:
boundary = f"delivery-desk-{uuid.uuid4().hex}"
parsed = urlsplit(base)
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.query or parsed.fragment:
raise revise("Delivery Desk base URL 格式无效", "改用有效的 HTTP/HTTPS 服务地址后重新生成计划")
target = f"{parsed.path.rstrip('/')}{path}" or "/"
def write_text(stream: Any, value: str) -> None:
stream.write(value.encode("utf-8"))
with tempfile.TemporaryFile() as body:
for name, value in fields.items():
write_text(body, f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n")
for item in files:
filename = str(item["name"]).replace("\\", "_").replace('"', "_")
write_text(
body,
f"--{boundary}\r\nContent-Disposition: form-data; name=\"images\"; filename=\"{filename}\"\r\n"
f"Content-Type: {item['content_type']}\r\n\r\n",
)
digest = hashlib.sha256()
copied = 0
with Path(str(item["path"])).open("rb") as image:
while chunk := image.read(1024 * 1024):
digest.update(chunk)
copied += len(chunk)
body.write(chunk)
if copied != int(item["size"]) or digest.hexdigest() != item["sha256"]:
raise revise("本地图片在确认后发生变化,旧计划已失效", "重新生成并展示计划,取得新的确认码后再执行")
write_text(body, "\r\n")
write_text(body, f"--{boundary}--\r\n")
length = body.tell()
body.seek(0)
connection_type = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
connection = connection_type(parsed.hostname, parsed.port, timeout=120)
try:
connection.request(
"POST",
target,
body=body,
headers={
"Authorization": f"Bearer {api_key()}",
"Accept": "application/json",
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Content-Length": str(length),
},
)
response = connection.getresponse()
raw = response.read().decode("utf-8", errors="replace")
try:
payload = json.loads(raw) if raw else None
except json.JSONDecodeError:
payload = raw
if response.status >= 400:
detail = payload.get("error", raw) if isinstance(payload, dict) else raw
raise http_failure("POST", path, response.status, str(detail))
return response.status, payload
except UploadError:
raise
except (OSError, TimeoutError, http.client.HTTPException) as error:
raise UploadError(
f"state_unknown: POST {path} 的结果未知,禁止自动重试:{error}",
next_step="使用 works --external-id 或 inspect-work 只读核对服务器状态;若仍无法确认,询问调用者后再决定",
) from error
finally:
connection.close()
def print_json(value: Any) -> None: def print_json(value: Any) -> None:
print(json.dumps(value, ensure_ascii=False, indent=2)) print(json.dumps(value, ensure_ascii=False, indent=2))
@@ -131,6 +215,55 @@ def validate_images(values: list[str]) -> list[str]:
return cleaned return cleaned
def describe_image_files(values: list[str]) -> list[dict[str, Any]]:
if not 1 <= len(values) <= 30:
raise revise("必须提供 1-30 个本地图片文件", "调整图片数量和顺序,重新生成计划并取得新的确认码")
result: list[dict[str, Any]] = []
for value in values:
try:
file_path = Path(value).expanduser().resolve(strict=True)
except OSError as error:
raise revise(f"本地图片不存在或不可读取: {value}", "修正文件路径后重新生成计划") from error
if not file_path.is_file():
raise revise(f"本地图片不是普通文件: {file_path}", "改用有效图片文件后重新生成计划")
size = file_path.stat().st_size
if size < 1 or size > MAX_LOCAL_IMAGE_BYTES:
raise revise(f"本地图片大小必须在 1 字节到 20 MB 之间: {file_path}", "压缩或替换图片后重新生成计划")
content_type = LOCAL_IMAGE_TYPES.get(file_path.suffix.lower(), "")
if not content_type:
raise revise(f"不支持的本地图片类型: {file_path.name}", "改用 JPEG、PNG、GIF、WebP 或 AVIF 图片后重新生成计划")
digest = hashlib.sha256()
with file_path.open("rb") as source:
while chunk := source.read(1024 * 1024):
digest.update(chunk)
result.append({
"path": str(file_path),
"name": file_path.name,
"size": size,
"sha256": digest.hexdigest(),
"content_type": content_type,
})
return result
def verify_planned_files(images: list[dict[str, Any]]) -> list[dict[str, Any]]:
for item in images:
try:
file_path = Path(str(item.get("path", ""))).resolve(strict=True)
except OSError as error:
raise revise("本地图片在确认后不存在或不可读取,旧计划已失效", "修正路径并重新生成计划") from error
content_type = LOCAL_IMAGE_TYPES.get(file_path.suffix.lower(), "")
if (
not file_path.is_file()
or str(file_path) != item.get("path")
or file_path.name != item.get("name")
or file_path.stat().st_size != item.get("size")
or content_type != item.get("content_type")
):
raise revise("本地图片在确认后发生变化,旧计划已失效", "重新生成并展示计划,取得新的确认码后再执行")
return images
def confirmation_code(plan: dict[str, Any]) -> str: def confirmation_code(plan: dict[str, Any]) -> str:
material = {key: value for key, value in plan.items() if key != "confirmation_code"} material = {key: value for key, value in plan.items() if key != "confirmation_code"}
canonical = json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":")) canonical = json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
@@ -169,11 +302,18 @@ def content_from_args(args: argparse.Namespace, current: dict[str, Any] | None =
tags = [] if args.clear_tags else args.tags if args.tags is not None else current.get("tags", []) tags = [] if args.clear_tags else args.tags if args.tags is not None else current.get("tags", [])
if not str(title or "").strip(): if not str(title or "").strip():
raise revise("标题不能为空", "补充非空标题后重新生成计划并取得新的确认码") raise revise("标题不能为空", "补充非空标题后重新生成计划并取得新的确认码")
if args.image_urls:
image_source = "url"
images: list[Any] = validate_images(args.image_urls)
else:
image_source = "file"
images = describe_image_files(args.image_files or [])
return { return {
"title": str(title).strip(), "title": str(title).strip(),
"description": str(description or ""), "description": str(description or ""),
"tags": [str(tag).strip() for tag in tags if str(tag).strip()], "tags": [str(tag).strip() for tag in tags if str(tag).strip()],
"images": validate_images(args.image_urls), "image_source": image_source,
"images": images,
} }
@@ -206,7 +346,7 @@ def cmd_plan_work(args: argparse.Namespace) -> None:
if not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", args.external_id): if not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", args.external_id):
raise revise("externalId 格式无效", "改用符合 [A-Za-z0-9._:-]{1,128} 的稳定 externalId 后重新生成计划") raise revise("externalId 格式无效", "改用符合 [A-Za-z0-9._:-]{1,128} 的稳定 externalId 后重新生成计划")
plan = { plan = {
"schema_version": 1, "schema_version": 2,
"operation": "create_work", "operation": "create_work",
"base_url": base, "base_url": base,
"target": project_identity(project), "target": project_identity(project),
@@ -226,7 +366,7 @@ def cmd_plan_round(args: argparse.Namespace) -> None:
rounds = work.get("rounds") or [] rounds = work.get("rounds") or []
current_round = max((int(item.get("round_number", 0)) for item in rounds), default=0) current_round = max((int(item.get("round_number", 0)) for item in rounds), default=0)
plan = { plan = {
"schema_version": 1, "schema_version": 2,
"operation": "create_round", "operation": "create_round",
"base_url": base, "base_url": base,
"target": project_identity(project), "target": project_identity(project),
@@ -256,12 +396,33 @@ def same_content(detail: dict[str, Any], expected: dict[str, Any]) -> bool:
) )
def upload_content(base: str, path: str, content: dict[str, Any], extra_fields: dict[str, str] | None = None) -> tuple[int, Any]:
image_source = str(content.get("image_source") or "url")
fields = {
"title": str(content["title"]),
"description": str(content.get("description", "")),
"tags": json.dumps(content.get("tags", []), ensure_ascii=False),
**(extra_fields or {}),
}
if image_source == "file":
files = verify_planned_files(content["images"])
return request_multipart(base, path, fields=fields, files=files)
body = {
"title": content["title"],
"description": content.get("description", ""),
"tags": content.get("tags", []),
"images": content["images"],
**(extra_fields or {}),
}
return request_json(base, path, method="POST", body=body)
def cmd_apply(args: argparse.Namespace) -> None: def cmd_apply(args: argparse.Namespace) -> None:
plan = json.loads(Path(args.plan).read_text(encoding="utf-8")) plan = json.loads(Path(args.plan).read_text(encoding="utf-8"))
expected_code = confirmation_code(plan) expected_code = confirmation_code(plan)
if args.confirm != expected_code or plan.get("confirmation_code") != expected_code: if args.confirm != expected_code or plan.get("confirmation_code") != expected_code:
raise revise("确认码不匹配;计划可能已改变,禁止执行", "重新展示当前计划并取得与当前计划一致的新确认码") raise revise("确认码不匹配;计划可能已改变,禁止执行", "重新展示当前计划并取得与当前计划一致的新确认码")
if plan.get("schema_version") != 1 or plan.get("operation") not in {"create_work", "create_round"}: if plan.get("schema_version") not in {1, 2} or plan.get("operation") not in {"create_work", "create_round"}:
raise UploadError("不支持的计划格式") raise UploadError("不支持的计划格式")
base = base_url(plan.get("base_url")) base = base_url(plan.get("base_url"))
target = plan["target"] target = plan["target"]
@@ -277,7 +438,7 @@ def cmd_apply(args: argparse.Namespace) -> None:
if existing: if existing:
print_json({"success": True, "idempotent": True, "message": "externalId 已存在,未发送创建请求", "target": target, "work": existing[0]}) print_json({"success": True, "idempotent": True, "message": "externalId 已存在,未发送创建请求", "target": target, "work": existing[0]})
return return
_, created = request_json(base, f"/api/projects/{target['project_id']}/works", method="POST", body={**content, "externalId": external_id}) _, created = upload_content(base, f"/api/projects/{target['project_id']}/works", content, {"externalId": external_id})
work_id = int(created["id"]) work_id = int(created["id"])
verified = get_work(base, work_id) verified = get_work(base, work_id)
assert_work_project(verified, int(target["project_id"])) assert_work_project(verified, int(target["project_id"]))
@@ -291,7 +452,7 @@ def cmd_apply(args: argparse.Namespace) -> None:
assert_work_project(before, int(target["project_id"])) assert_work_project(before, int(target["project_id"]))
if int(before.get("version_number", 0)) != int(work_plan["expected_version_number"]): if int(before.get("version_number", 0)) != int(work_plan["expected_version_number"]):
raise UploadError("作品当前版本已变化,旧计划失效;请重新生成计划并确认") raise UploadError("作品当前版本已变化,旧计划失效;请重新生成计划并确认")
_, created = request_json(base, f"/api/works/{work_plan['work_id']}/rounds", method="POST", body=content) _, created = upload_content(base, f"/api/works/{work_plan['work_id']}/rounds", content)
verified = get_work(base, int(work_plan["work_id"])) 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): if int(verified.get("version_number", 0)) != int(work_plan["expected_version_number"]) + 1 or not same_content(verified, content):
raise UploadError("新轮次请求返回成功,但回读轮次或内容不一致") raise UploadError("新轮次请求返回成功,但回读轮次或内容不一致")
@@ -301,7 +462,7 @@ def cmd_apply(args: argparse.Namespace) -> None:
def add_common(command: argparse.ArgumentParser) -> None: def add_common(command: argparse.ArgumentParser) -> None:
command.add_argument("--base-url", default=None) command.add_argument("--base-url", default=None, help=f"Delivery Desk API 地址(默认:{DEFAULT_BASE_URL}")
def add_content(command: argparse.ArgumentParser, *, title_required: bool) -> None: def add_content(command: argparse.ArgumentParser, *, title_required: bool) -> None:
@@ -310,7 +471,9 @@ def add_content(command: argparse.ArgumentParser, *, title_required: bool) -> No
command.add_argument("--clear-description", action="store_true") command.add_argument("--clear-description", action="store_true")
command.add_argument("--tag", action="append", dest="tags", default=None) command.add_argument("--tag", action="append", dest="tags", default=None)
command.add_argument("--clear-tags", action="store_true") command.add_argument("--clear-tags", action="store_true")
command.add_argument("--image-url", action="append", dest="image_urls", required=True) image_input = command.add_mutually_exclusive_group(required=True)
image_input.add_argument("--image-url", action="append", dest="image_urls")
image_input.add_argument("--image-file", action="append", dest="image_files")
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:

View File

@@ -31,5 +31,3 @@ temp
.idea .idea
.trae .trae
.vercel .vercel
atelier-notes.zip
=

View File

@@ -3,7 +3,8 @@ NODE_ENV=development
CORS_ORIGIN=http://localhost:5180 CORS_ORIGIN=http://localhost:5180
# 正式环境必须填写;本地 SQLite 模式可暂时留空。 # 正式环境必须填写;本地 SQLite 模式可暂时留空。
DATABASE_URL=postgresql://delivery_desk:local_delivery_desk@127.0.0.1:5432/delivery_desk DATABASE_URL=
# PostgreSQL 示例postgresql://delivery_desk:replace-with-a-password@127.0.0.1:5432/delivery_desk
PGSSL=disable PGSSL=disable
PG_POOL_MAX=10 PG_POOL_MAX=10

4
.gitignore vendored
View File

@@ -65,7 +65,3 @@ Thumbs.db
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Local source archives and accidental files
/atelier-notes.zip
/=

View File

@@ -5,6 +5,6 @@
- 新接口使用 `/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 凭证只通过平台管理界面录入;`COS_CONFIG_ENCRYPTION_KEY` 通过部署密钥注入。
- 不把 `.trae/` 中的早期原型文档作为现行依据;以 README、`docs/` 和当前代码为准。 - 不把 `.trae/` 中的早期原型文档作为现行依据;以 README、`docs/` 和当前代码为准。
- Agent 通过 API 上传作品或创建验收轮次时必须使用 `.agents/skills/upload-delivery-desk-work`;接口或层级变化后同步更新该 Skill并运行 `powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1` 重新打包。 - Agent 通过 API 上传作品或创建验收轮次时必须使用 `.agents/skills/upload-delivery-desk-work`;接口或层级变化后同步更新该 Skill并运行 `powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1` 重新打包。

View File

@@ -22,16 +22,19 @@
```bash ```bash
pnpm install pnpm install
pnpm dev pnpm dev:start
``` ```
- 前端http://localhost:5180 - 前端http://localhost:5180
- APIhttp://localhost:3010 - 后端http://localhost:3010
- 重启:`pnpm dev:restart`
- 停止:`pnpm dev:stop`
- 查看状态:`pnpm dev:status`
- 健康检查http://localhost:3010/api/health - 健康检查http://localhost:3010/api/health
未配置 `DATABASE_URL` 时使用 `data/app.db`;本地上传文件保存在 `uploads/`。两者均包含运行数据或用户文件,已排除在 Git 之外。 未配置 `DATABASE_URL` 时使用 `data/app.db`;本地上传文件保存在 `uploads/`。两者均包含运行数据或用户文件,已排除在 Git 之外。
复制 `.env.example``.env` 后配置环境变量。生产环境至少需要 `DATABASE_URL``COS_CONFIG_ENCRYPTION_KEY` 和安全的初始管理员密码。真实 COS 凭证只能通过平台管理界面或部署密钥注入,不能提交到 Git。 复制 `.env.example``.env` 后配置环境变量。生产环境至少需要 `DATABASE_URL``COS_CONFIG_ENCRYPTION_KEY` 和安全的初始管理员密码。真实 COS 凭证只能通过平台管理界面入,不能提交到 Git`COS_CONFIG_ENCRYPTION_KEY` 应通过部署密钥注入
## PostgreSQL 与 Docker ## PostgreSQL 与 Docker

View File

@@ -88,7 +88,16 @@ app.use((err: Error, _req: Request, res: Response, _next: unknown) => {
void _next; void _next;
// multer 文件类型/大小错误 // multer 文件类型/大小错误
const message = err.message || '服务器内部错误'; const message = err.message || '服务器内部错误';
const errorCode = (err as Error & { code?: string }).code;
const statusCode = Number((err as Error & { statusCode?: number }).statusCode); const statusCode = Number((err as Error & { statusCode?: number }).statusCode);
if (errorCode === 'LIMIT_FILE_SIZE') {
res.status(413).json({ success: false, error: '单张图片不能超过 20 MB' });
return;
}
if (errorCode === 'LIMIT_FILE_COUNT' || errorCode === 'LIMIT_UNEXPECTED_FILE') {
res.status(400).json({ success: false, error: '每次最多上传 30 张图片' });
return;
}
if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599) { if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599) {
res.status(statusCode).json({ success: false, error: message }); res.status(statusCode).json({ success: false, error: message });
return; return;

View File

@@ -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 { storeExternalImageUrl, storeUploadedFile } from '../storage.js'; import { StorageImportError, 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';
@@ -15,13 +15,34 @@ export interface UrlRound { title: string; description: string; tags: string[];
type StoredImage = { url: string; width: number; height: number; storageProvider: 'local' | 'tencent_cos' | 'external'; storageKey: string }; type StoredImage = { url: string; width: number; height: number; storageProvider: 'local' | 'tencent_cos' | 'external'; storageKey: string };
type PreparedRound = { title: string; description: string; tags: string[]; images: StoredImage[] }; type PreparedRound = { title: string; description: string; tags: string[]; images: StoredImage[] };
async function readImageSize(filePath: string): Promise<{ width: number; height: number }> {
try { const meta = await sharp(filePath).metadata(); return { width: meta.width ?? 0, height: meta.height ?? 0 }; }
catch { return { width: 0, height: 0 }; }
}
async function prepareFiles(files: UploadedFile[]): Promise<StoredImage[]> { async function prepareFiles(files: UploadedFile[]): Promise<StoredImage[]> {
return Promise.all(files.map(async (file) => ({ ...(await readImageSize(file.path)), ...(await storeUploadedFile(file)) }))); const supported = new Map([
['jpeg', { extension: '.jpg', contentType: 'image/jpeg' }],
['png', { extension: '.png', contentType: 'image/png' }],
['gif', { extension: '.gif', contentType: 'image/gif' }],
['webp', { extension: '.webp', contentType: 'image/webp' }],
['avif', { extension: '.avif', contentType: 'image/avif' }],
]);
const inspected = await Promise.all(files.map(async (file) => {
try {
const metadata = await sharp(file.path).metadata();
const detected = metadata.format ? supported.get(metadata.format) : undefined;
if (!detected || !metadata.width || !metadata.height) throw new Error('invalid image');
return { file, width: metadata.width, height: metadata.height, ...detected };
} catch {
throw new StorageImportError(422, `上传文件不是有效或受支持的图片: ${file.originalname || file.filename}`);
}
}));
const prepared: StoredImage[] = [];
for (const item of inspected) {
const stored = await storeUploadedFile({
...item.file,
originalname: `upload${item.extension}`,
mimetype: item.contentType,
});
prepared.push({ width: item.width, height: item.height, ...stored });
}
return prepared;
} }
async function prepareExternalImages(images: string[]): Promise<StoredImage[]> { async function prepareExternalImages(images: string[]): Promise<StoredImage[]> {

View File

@@ -67,10 +67,11 @@ 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` 按轮次返回图片批注、文字批注、总体反馈和验收事件。
- `GET /api/works/:workId/optimization-context?round=N` 聚合指定轮次的内容、图片和可执行反馈,默认排除已关闭或撤回记录,供外部内容优化流程只读使用。
## Agent 安全上传 ## Agent 安全上传
内置 Agent Skill 采用“发现目标 → 生成计划 → 人工确认 → 单次写入 → 读取核验”的两阶段流程。计划文件只保存目标 ID、待写内容和确认摘要不保存 API Key并写入已被 Git 忽略的 `tmp/` 目录。 内置 Agent Skill 固定连接 `http://192.168.30.90:3010`,采用“发现目标 → 操作者确认运营组和项目 → 生成计划 → 操作者确认内容与确认 → 单次写入 → 读取核验”的两阶段确认流程。计划文件只保存目标 ID、待写内容和确认摘要不保存 API Key并写入已被 Git 忽略的 `tmp/` 目录。
新建作品以 `externalId` 保证幂等;新增验收轮次没有幂等键。轮次写入超时或响应不明确时,必须先重新读取作品状态,不能直接重试,以免重复创建轮次。 新建作品以 `externalId` 保证幂等;新增验收轮次没有幂等键。轮次写入超时或响应不明确时,必须先重新读取作品状态,不能直接重试,以免重复创建轮次。
@@ -79,3 +80,5 @@ flowchart LR
平台管理员可在管理页保存和测试 COS 配置。SecretId/SecretKey 使用 `COS_CONFIG_ENCRYPTION_KEY` 派生的 AES-256-GCM 密钥加密,读取接口不返回明文。连接测试会上传、读取并删除临时对象。 平台管理员可在管理页保存和测试 COS 配置。SecretId/SecretKey 使用 `COS_CONFIG_ENCRYPTION_KEY` 派生的 AES-256-GCM 密钥加密,读取接口不返回明文。连接测试会上传、读取并删除临时对象。
外部 API 图片统一归一到当前活动 COS所有 URL 会先拒绝本机、私有网段、局域网和保留地址;与配置的 COS 公开域名或 CDN 域名同源时直接保存,其他公开 URL 经 DNS SSRF 防护、图片类型和 20 MB 大小校验后下载,并按内容哈希转存到 COS。没有活动 COS 配置时拒绝 URL 导入;全部图片准备成功后才创建作品或新验收轮次。该规则不自动追溯迁移历史图片内容。 外部 API 图片统一归一到当前活动 COS所有 URL 会先拒绝本机、私有网段、局域网和保留地址;与配置的 COS 公开域名或 CDN 域名同源时直接保存,其他公开 URL 经 DNS SSRF 防护、图片类型和 20 MB 大小校验后下载,并按内容哈希转存到 COS。没有活动 COS 配置时拒绝 URL 导入;全部图片准备成功后才创建作品或新验收轮次。该规则不自动追溯迁移历史图片内容。
外部 Agent 只有本地图片时使用现有 multipart 通道,不使用 Base64。Skill 计划只保存文件路径、大小和 SHA-256执行时重新校验后流式上传服务端以 Sharp 验证真实图片内容,再从临时文件写入活动 COS。

View File

@@ -10,7 +10,7 @@
- 批注回复线程、只能撤回本人反馈并保留撤回记录 - 批注回复线程、只能撤回本人反馈并保留撤回记录
- 客户项目链接、密码、姓名、访问期限和验收决定 - 客户项目链接、密码、姓名、访问期限和验收决定
- API Key、审计日志、COS 前端配置、连接测试、私有地址拦截及外部 URL 安全转存 - API Key、审计日志、COS 前端配置、连接测试、私有地址拦截及外部 URL 安全转存
- 内置 Agent 安全上传 Skill、双阶段确认脚本、回归测试和可分发 ZIP - 内置 Agent 安全上传 Skill、URL/本地文件双输入、双阶段确认脚本、回归测试和可分发 ZIP
- SQLite/PostgreSQL 双运行时、迁移验证和 Docker 部署 - SQLite/PostgreSQL 双运行时、迁移验证和 Docker 部署
- 桌面端与移动端响应式页面 - 桌面端与移动端响应式页面
@@ -26,7 +26,7 @@
- 已上传作品在所有阶段的图片重新排序 - 已上传作品在所有阶段的图片重新排序
- 在线人员状态、实时变更通知和并发冲突保护 - 在线人员状态、实时变更通知和并发冲突保护
- HEIC/HEIF 转换、多尺寸缩略图和 EXIF 定位信息清理 - HEIC/HEIF 转换、多尺寸缩略图和 EXIF 定位信息清理
- 真实腾讯云、生产 PostgreSQL、HTTPS 和备份恢复演练 - 独立生产 COS 桶、生产 PostgreSQL、HTTPS 和备份恢复演练
## 上线门槛 ## 上线门槛

View File

@@ -12,7 +12,7 @@ Authorization: Bearer dd_live_xxx
## Agent 安全上传 Skill ## Agent 安全上传 Skill
项目内置 `.agents/skills/upload-delivery-desk-work`,用于引导 Agent 精确定位运营组、项目和作品后创建作品或提交新验收轮次。它强制执行“发现 → 生成计划 → 操作者确认 → 单次提交 → 回读验证”,不允许根据名称猜测目标。 项目内置 `.agents/skills/upload-delivery-desk-work`,用于引导 Agent 精确定位运营组、项目和作品后创建作品或提交新验收轮次。它固定连接 `http://192.168.30.90:3010`,并强制执行“发现 → 操作者确认运营组与项目 → 生成计划 → 操作者确认内容 → 单次提交 → 回读验证”,不允许根据名称猜测目标。
更新 Skill 后重新生成分发包: 更新 Skill 后重新生成分发包:
@@ -71,6 +71,22 @@ curl -X POST http://localhost:3010/api/projects/1/works \
相同 `projectId + externalId` 的重试不会重复创建,响应包含 `idempotent: true`。异源图片成功转存后不再依赖原地址长期可用;任意图片校验或转存失败时不会创建作品记录。 相同 `projectId + externalId` 的重试不会重复创建,响应包含 `idempotent: true`。异源图片成功转存后不再依赖原地址长期可用;任意图片校验或转存失败时不会创建作品记录。
### 本地文件上传
外部 Agent 只有本地图片时,不需要转换为 Base64。相同接口支持 `multipart/form-data`,重复使用 `images` 文件字段,并通过 Bearer API Key 鉴权:
```bash
curl -X POST http://localhost:3010/api/projects/1/works \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
-F "externalId=client-2026-002" \
-F "title=本地生成作品" \
-F 'tags=["#本地文件"]' \
-F "images=@01.png" \
-F "images=@02.png"
```
URL 与本地文件不能在同一次请求中混用。文件顺序即展示顺序,第一张为封面;单张不超过 20 MB服务端会校验真实图片内容并上传到活动 COS。
## 创建新验收轮次 ## 创建新验收轮次
每轮只能提交一个方案。标题、正文、标签和图片会形成不可修改的轮次快照;新轮次自动锁定上一轮。 每轮只能提交一个方案。标题、正文、标签和图片会形成不可修改的轮次快照;新轮次自动锁定上一轮。
@@ -97,8 +113,14 @@ curl "http://localhost:3010/api/works/12?round=2" \
# 按轮返回该作品全部反馈和验收事件 # 按轮返回该作品全部反馈和验收事件
curl http://localhost:3010/api/works/12/annotations \ curl http://localhost:3010/api/works/12/annotations \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" -H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
# 返回用于优化指定轮次内容与图片的精简上下文;默认只含未撤回的开放反馈
curl "http://localhost:3010/api/works/12/optimization-context?round=2" \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
``` ```
`optimization-context` 返回项目、作品、轮次、标题、正文、Tag、图片元信息以及图片批注、文字批注、总体反馈和回复。需要审计已关闭或已撤回的历史反馈时增加 `include_history=true`;该接口只读,不会修改作品。
标题、正文和 Tag 选区批注使用: 标题、正文和 Tag 选区批注使用:
```json ```json

View File

@@ -51,12 +51,16 @@ COS 使用公开 URL。公共访问域名和 CDN 域名必须能够解析到公
JSON URL 导入依赖活动 COS 配置。同一 COS/CDN 域名的图片直接使用;其他域名会下载并按内容哈希写入 `<path-prefix>/imports/`。服务会拒绝内网地址、非图片响应和超过 20 MB 的文件,因此部署网络必须允许访问确需导入的公开图片源。 JSON URL 导入依赖活动 COS 配置。同一 COS/CDN 域名的图片直接使用;其他域名会下载并按内容哈希写入 `<path-prefix>/imports/`。服务会拒绝内网地址、非图片响应和超过 20 MB 的文件,因此部署网络必须允许访问确需导入的公开图片源。
使用 `pnpm server:prod` 运行本地 API 时不会监听源码变化;后端代码更新后必须重启进程。日常开发使用 `pnpm server:dev``pnpm dev` 使用 `pnpm server:prod` 运行本地 API 时不会监听源码变化;后端代码更新后必须重启进程。日常开发统一使用 `pnpm dev:start` 启动前后端,使用 `pnpm dev:restart` 重启、`pnpm dev:stop` 停止、`pnpm dev:status` 检查 5180 和 3010。启动脚本把受管进程 PID 写入已忽略的 `tmp/dev.pid`,避免重启时误杀其他 Node 进程
## Agent 上传 Skill 维护 ## Agent 上传 Skill 维护
Agent 通过 API 新建作品或提交验收轮次时,使用 `.agents/skills/upload-delivery-desk-work`。API Key 只通过 `DELIVERY_DESK_API_KEY` 环境变量注入,不写入计划文件、文档或 Git。 Agent 通过 API 新建作品或提交验收轮次时,使用 `.agents/skills/upload-delivery-desk-work`。API Key 只通过 `DELIVERY_DESK_API_KEY` 环境变量注入,不写入计划文件、文档或 Git。
Skill 默认固定连接 `http://192.168.30.90:3010`,不读取环境变量覆盖服务地址。只有操作者明确要求迁移或测试其他环境时,才使用 `--base-url` 指定另一地址。
Agent 可用 `--image-url` 提交公网图片,也可用 `--image-file` 将生成在运营电脑上的本地图片直接 multipart 上传;两种模式不混用,不把图片转换为 Base64。
```powershell ```powershell
python tests/test_upload_skill.py python tests/test_upload_skill.py
powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1 powershell -ExecutionPolicy Bypass -File scripts/package-upload-skill.ps1

View File

@@ -16,6 +16,10 @@
"test:postgres-runtime": "tsx scripts/test-postgres-runtime.ts", "test:postgres-runtime": "tsx scripts/test-postgres-runtime.ts",
"test:collection-status": "tsx scripts/test-sqlite-collection-status.ts", "test:collection-status": "tsx scripts/test-sqlite-collection-status.ts",
"test:review-rounds": "tsx scripts/test-sqlite-review-rounds.ts", "test:review-rounds": "tsx scripts/test-sqlite-review-rounds.ts",
"dev:start": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/dev.ps1 start",
"dev:restart": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/dev.ps1 restart",
"dev:stop": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/dev.ps1 stop",
"dev:status": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/dev.ps1 status",
"dev": "concurrently \"npm run client:dev\" \"npm run server:dev\"" "dev": "concurrently \"npm run client:dev\" \"npm run server:dev\""
}, },
"dependencies": { "dependencies": {

111
scripts/dev.ps1 Normal file
View File

@@ -0,0 +1,111 @@
param(
[ValidateSet('start', 'restart', 'stop', 'status')]
[string]$Action = 'start'
)
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$runtimeDir = Join-Path $projectRoot 'tmp'
$pidFile = Join-Path $runtimeDir 'dev.pid'
function Get-TrackedProcessId {
if (-not (Test-Path -LiteralPath $pidFile)) {
return $null
}
$value = (Get-Content -LiteralPath $pidFile -Raw).Trim()
try {
if ($value -match '^\d+$') {
$processId = [int]$value
$startTimeTicks = $null
} else {
$state = $value | ConvertFrom-Json
$processId = [int]$state.pid
$startTimeTicks = [long]$state.start_time_ticks
}
} catch {
Remove-Item -LiteralPath $pidFile -Force
return $null
}
$process = Get-Process -Id $processId -ErrorAction SilentlyContinue
if (-not $process -or ($startTimeTicks -and $process.StartTime.ToUniversalTime().Ticks -ne $startTimeTicks)) {
Remove-Item -LiteralPath $pidFile -Force
return $null
}
return $processId
}
function Stop-TrackedProject {
$processId = Get-TrackedProcessId
if (-not $processId) {
Write-Host 'No managed Delivery Desk process is running.' -ForegroundColor Yellow
return
}
Write-Host "Stopping Delivery Desk process tree (PID $processId)..."
& taskkill.exe /PID $processId /T /F | Out-Null
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 300
Write-Host 'Delivery Desk stopped.' -ForegroundColor Green
}
function Test-Endpoint([string]$Url) {
try {
$response = Invoke-WebRequest -UseBasicParsing -Uri $Url -TimeoutSec 2
return $response.StatusCode -eq 200
} catch {
return $false
}
}
if ($Action -eq 'status') {
$processId = Get-TrackedProcessId
$frontendReady = Test-Endpoint 'http://127.0.0.1:5180/'
$backendReady = Test-Endpoint 'http://127.0.0.1:3010/api/health'
$processStatus = if ($processId) { "running (PID $processId)" } else { 'not managed' }
$frontendStatus = if ($frontendReady) { 'ready' } else { 'unavailable' }
$backendStatus = if ($backendReady) { 'ready' } else { 'unavailable' }
Write-Host "Process: $processStatus"
Write-Host "Frontend 5180: $frontendStatus"
Write-Host "Backend 3010: $backendStatus"
if ($frontendReady -and $backendReady) { exit 0 } else { exit 1 }
}
if ($Action -eq 'stop') {
Stop-TrackedProject
exit 0
}
$trackedProcessId = Get-TrackedProcessId
if ($trackedProcessId -and $Action -eq 'start') {
Write-Host "Delivery Desk is already running (PID $trackedProcessId). Use pnpm dev:restart to restart it." -ForegroundColor Yellow
exit 0
}
if ($Action -eq 'restart') {
Stop-TrackedProject
}
$pnpm = Get-Command pnpm -ErrorAction Stop
New-Item -ItemType Directory -Path $runtimeDir -Force | Out-Null
$currentProcess = Get-Process -Id $PID
@{
pid = $PID
start_time_ticks = $currentProcess.StartTime.ToUniversalTime().Ticks
} | ConvertTo-Json -Compress | Set-Content -LiteralPath $pidFile -Encoding ascii -NoNewline
Set-Location -LiteralPath $projectRoot
Write-Host 'Starting Delivery Desk...' -ForegroundColor Cyan
Write-Host 'Frontend: http://localhost:5180 Backend: http://localhost:3010'
Write-Host 'Press Ctrl+C to stop.'
try {
& $pnpm.Source dev
exit $LASTEXITCODE
} finally {
if ((Get-TrackedProcessId) -eq $PID) {
Remove-Item -LiteralPath $pidFile -Force
}
}

View File

@@ -83,6 +83,11 @@ try {
const blockedPrivateStorage=await request('/api/management/storage-configs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({region:'ap-guangzhou',bucket:'blocked-private-1234567890',public_base_url:'http://127.0.0.1:8080',cdn_domain:'',path_prefix:'delivery-desk',secret_id:'test-secret-id',secret_key:'test-secret-key'})},adminCookie); const blockedPrivateStorage=await request('/api/management/storage-configs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({region:'ap-guangzhou',bucket:'blocked-private-1234567890',public_base_url:'http://127.0.0.1:8080',cdn_domain:'',path_prefix:'delivery-desk',secret_id:'test-secret-id',secret_key:'test-secret-key'})},adminCookie);
expectStatus(blockedPrivateStorage.response.status,400,'拒绝私有地址作为对象存储访问域名',blockedPrivateStorage.body); expectStatus(blockedPrivateStorage.response.status,400,'拒绝私有地址作为对象存储访问域名',blockedPrivateStorage.body);
await database.insertId("INSERT INTO storage_configs (region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted,status,test_status,created_by) VALUES (?,?,?,?,?,?,?,?,?,?)", ['ap-guangzhou','runtime-test-1234567890','https://runtime-test-1234567890.cos.ap-guangzhou.myqcloud.com','https://cdn.example.com','delivery-desk',encryptSecret('test-secret-id'),encryptSecret('test-secret-key'),'active','passed',1]); 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 invalidMultipart=new FormData();
invalidMultipart.append('title','伪造图片内容');
invalidMultipart.append('images',new Blob(['not-a-real-image'],{type:'image/png'}),'spoofed.png');
const invalidUpload=await request(`/api/projects/${projectId}/works`,{method:'POST',body:invalidMultipart},adminCookie);
expectStatus(invalidUpload.response.status,422,'拒绝仅伪造 MIME 的上传文件',invalidUpload.body);
const privateImageUrls=['http://localhost/private.jpg','http://127.0.0.1/private.jpg','http://10.0.0.1/private.jpg','http://100.64.0.1/private.jpg','http://169.254.0.1/private.jpg','http://172.16.0.1/private.jpg','http://192.168.1.1/private.jpg','http://[::1]/private.jpg','http://[fd00::1]/private.jpg']; const privateImageUrls=['http://localhost/private.jpg','http://127.0.0.1/private.jpg','http://10.0.0.1/private.jpg','http://100.64.0.1/private.jpg','http://169.254.0.1/private.jpg','http://172.16.0.1/private.jpg','http://192.168.1.1/private.jpg','http://[::1]/private.jpg','http://[fd00::1]/private.jpg'];
for(const [index,imageUrl] of privateImageUrls.entries()){ for(const [index,imageUrl] of privateImageUrls.entries()){
const blockedPrivateImage=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:`禁止私有图片 ${index+1}`,images:[imageUrl]})},adminCookie); const blockedPrivateImage=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:`禁止私有图片 ${index+1}`,images:[imageUrl]})},adminCookie);
@@ -107,7 +112,25 @@ try {
if(target==='https://93.184.216.34/source.png')return new Response(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64'),{status:200,headers:{'Content-Type':'image/png'}}); 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); return nativeFetch(input,init);
}) as typeof fetch; }) as typeof fetch;
cosPrototype.putObject=(async(options:unknown)=>{importedStorageKey=String((options as {Key:string}).Key);return{statusCode:200}}) as typeof cosPrototype.putObject; cosPrototype.putObject=(async(options:unknown)=>{
const item=options as {Key:string;Body?:NodeJS.ReadableStream};
importedStorageKey=String(item.Key);
if(item.Body&&typeof item.Body.on==='function'){
await new Promise<void>((resolve,reject)=>{item.Body!.on('data',()=>undefined);item.Body!.once('end',resolve);item.Body!.once('error',reject)});
}
return{statusCode:200};
}) as typeof cosPrototype.putObject;
const localUpload=new FormData();
localUpload.append('externalId','runtime-local-file');
localUpload.append('title','本地文件上传作品');
localUpload.append('tags',JSON.stringify(['#multipart']));
const validPng=Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='),(character)=>character.charCodeAt(0));
localUpload.append('images',new Blob([validPng],{type:'image/png'}),'generated.png');
const localWork=await request(`/api/projects/${otherProjectId}/works`,{method:'POST',body:localUpload},adminCookie);
expectStatus(localWork.response.status,201,'multipart 本地图片上传 COS',localWork.body);
const localDetail=await request(`/api/works/${Number((localWork.body as {id:number}).id)}`,{},adminCookie);
const localImage=(localDetail.body as {images:Array<{url:string;width:number;height:number;storage_provider:string;storage_key:string}>}).images[0];
if(!importedStorageKey.startsWith('delivery-desk/originals/')||localImage.url!==`https://cdn.example.com/${importedStorageKey}`||localImage.storage_provider!=='tencent_cos'||localImage.width!==1||localImage.height!==1)throw new Error('multipart 本地图片没有校验并写入当前 COS');
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); 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); expectStatus(importedWork.response.status,201,'异源图片转存 COS',importedWork.body);
const importedDetail=await request(`/api/works/${Number((importedWork.body as {id:number}).id)}`,{},adminCookie); const importedDetail=await request(`/api/works/${Number((importedWork.body as {id:number}).id)}`,{},adminCookie);
@@ -285,7 +308,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 幂等、COS 同源复用、异源转存、SSRF 拦截与单方案轮次\n'); process.stdout.write('PostgreSQL 运行时验证通过账号角色、项目隔离、客户门禁、项目自动状态、完成只读、API Key 发现、externalId 幂等、multipart 本地上传、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();

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { CheckCircle2, Cloud, Database, Loader2, LockKeyhole, RadioTower, ShieldCheck } from 'lucide-react'; import { CheckCircle2, Cloud, Database, Loader2, LockKeyhole, RadioTower, ShieldCheck } from 'lucide-react';
import type { StorageConfig } from '@shared/types'; import type { StorageConfig } from '@shared/types';
import { api, ApiError } from '@/api/client'; import { api, ApiError } from '@/api/client';
@@ -8,10 +8,8 @@ const initialForm = {
path_prefix: 'delivery-desk', secret_id: '', secret_key: '', path_prefix: 'delivery-desk', secret_id: '', secret_key: '',
}; };
export default function StorageSettings() { export default function StorageSettings({ items, onItemsChange }: { items: StorageConfig[]; onItemsChange: (items: StorageConfig[]) => void }) {
const [items, setItems] = useState<StorageConfig[]>([]);
const [form, setForm] = useState(initialForm); const [form, setForm] = useState(initialForm);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<'save' | 'test' | 'activate' | null>(null); const [busy, setBusy] = useState<'save' | 'test' | 'activate' | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notice, setNotice] = useState(''); const [notice, setNotice] = useState('');
@@ -19,12 +17,9 @@ export default function StorageSettings() {
const draft = useMemo(() => items.find((item) => item.status === 'draft'), [items]); const draft = useMemo(() => items.find((item) => item.status === 'draft'), [items]);
const load = async () => { const load = async () => {
setLoading(true); try { onItemsChange(await api.listStorageConfigs()); }
try { setItems(await api.listStorageConfigs()); }
catch (reason) { setError(messageOf(reason)); } catch (reason) { setError(messageOf(reason)); }
finally { setLoading(false); }
}; };
useEffect(() => { void load(); }, []);
const save = async () => { const save = async () => {
setBusy('save'); setError(''); setNotice(''); setBusy('save'); setError(''); setNotice('');
@@ -55,8 +50,6 @@ export default function StorageSettings() {
finally { setBusy(null); } finally { setBusy(null); }
}; };
if (loading) return <div className="grid gap-3 py-8">{[1, 2].map((item) => <div key={item} className="h-28 animate-pulse rounded-2xl bg-black/5"/>)}</div>;
return <section className="py-8"> return <section className="py-8">
<div className="mb-7"><p className="font-mono text-[10px] uppercase tracking-[.28em] text-[#d15f37]">Infrastructure / Object Storage</p><h2 className="mt-2 font-display text-4xl tracking-[-.04em]"> COS</h2><p className="mt-2 max-w-2xl text-sm leading-6 text-black/45">稿</p></div> <div className="mb-7"><p className="font-mono text-[10px] uppercase tracking-[.28em] text-[#d15f37]">Infrastructure / Object Storage</p><h2 className="mt-2 font-display text-4xl tracking-[-.04em]"> COS</h2><p className="mt-2 max-w-2xl text-sm leading-6 text-black/45">稿</p></div>
{error && <div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>} {error && <div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}

View File

@@ -4,3 +4,33 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export async function copyText(text: string): Promise<boolean> {
if (window.isSecureContext && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fall through for browsers that expose Clipboard API but reject the write.
}
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.readOnly = true
textarea.style.position = 'fixed'
textarea.style.inset = '0 auto auto -9999px'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.focus()
textarea.select()
textarea.setSelectionRange(0, textarea.value.length)
try {
return document.execCommand('copy')
} catch {
return false
} finally {
textarea.remove()
}
}

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { ArrowRightLeft, ChevronDown, Copy, Fingerprint, History, KeyRound, Plus, RefreshCw, Search, ShieldCheck, Users, X } from 'lucide-react'; import { ArrowRightLeft, ChevronDown, Copy, Fingerprint, History, KeyRound, Plus, RefreshCw, Search, ShieldCheck, Users, X } from 'lucide-react';
import type { AuditLogEntry, ManagedApiKey, ManagedUser, OperationGroup, Project } from '@shared/types'; import type { AuditLogEntry, ManagedApiKey, ManagedUser, OperationGroup, Project, StorageConfig } from '@shared/types';
import { api, ApiError } from '@/api/client'; import { api, ApiError } from '@/api/client';
import { useAuthStore } from '@/store/useAuthStore'; import { useAuthStore } from '@/store/useAuthStore';
import StorageSettings from '@/components/StorageSettings'; import StorageSettings from '@/components/StorageSettings';
import { copyText } from '@/lib/utils';
type Tab = 'groups' | 'accounts' | 'keys' | 'storage' | 'audit'; type Tab = 'groups' | 'accounts' | 'keys' | 'storage' | 'audit';
type Panel = 'group' | 'user' | 'key' | 'rename' | 'rename_group' | 'replace_admin' | 'reset' | null; type Panel = 'group' | 'user' | 'key' | 'rename' | 'rename_group' | 'replace_admin' | 'reset' | null;
@@ -27,10 +28,12 @@ export default function ManagementPage() {
const [keys, setKeys] = useState<ManagedApiKey[]>([]); const [keys, setKeys] = useState<ManagedApiKey[]>([]);
const [logs, setLogs] = useState<AuditLogEntry[]>([]); const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [storageConfigs, setStorageConfigs] = useState<StorageConfig[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [revealedKey, setRevealedKey] = useState(''); const [revealedKey, setRevealedKey] = useState('');
const [copyState, setCopyState] = useState<'idle' | 'copying' | 'success' | 'error'>('idle');
const [resetTarget, setResetTarget] = useState<ManagedUser | null>(null); const [resetTarget, setResetTarget] = useState<ManagedUser | null>(null);
const [renameTarget, setRenameTarget] = useState<ManagedUser | null>(null); const [renameTarget, setRenameTarget] = useState<ManagedUser | null>(null);
const [replaceGroup, setReplaceGroup] = useState<OperationGroup | null>(null); const [replaceGroup, setReplaceGroup] = useState<OperationGroup | null>(null);
@@ -51,11 +54,12 @@ export default function ManagementPage() {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const [nextAccounts, nextKeys, nextLogs, nextProjects, nextGroups] = await Promise.all([ const [nextAccounts, nextKeys, nextLogs, nextProjects, nextGroups, nextStorageConfigs] = await Promise.all([
api.listManagedUsers(), api.listApiKeys(), api.listAuditLogs(), api.listProjects(), api.listManagedUsers(), api.listApiKeys(), api.listAuditLogs(), api.listProjects(),
isPlatform ? api.listGroups() : Promise.resolve([]), isPlatform ? api.listGroups() : Promise.resolve([]),
isPlatform ? api.listStorageConfigs() : Promise.resolve([]),
]); ]);
setAccounts(nextAccounts); setKeys(nextKeys); setLogs(nextLogs); setProjects(nextProjects); setGroups(nextGroups); setAccounts(nextAccounts); setKeys(nextKeys); setLogs(nextLogs); setProjects(nextProjects); setGroups(nextGroups); setStorageConfigs(nextStorageConfigs);
} catch (reason) { setError(messageOf(reason)); } } catch (reason) { setError(messageOf(reason)); }
finally { setLoading(false); } finally { setLoading(false); }
}, [isPlatform, user]); }, [isPlatform, user]);
@@ -65,9 +69,9 @@ export default function ManagementPage() {
...(isPlatform ? [{ id: 'groups' as const, label: '运营组', count: groups.length }] : []), ...(isPlatform ? [{ id: 'groups' as const, label: '运营组', count: groups.length }] : []),
{ id: 'accounts' as const, label: '账号', count: accounts.length }, { id: 'accounts' as const, label: '账号', count: accounts.length },
{ id: 'keys' as const, label: 'API Key', count: keys.filter((item) => item.status === 'active').length }, { id: 'keys' as const, label: 'API Key', count: keys.filter((item) => item.status === 'active').length },
...(isPlatform ? [{ id: 'storage' as const, label: '对象存储', count: 0 }] : []), ...(isPlatform ? [{ id: 'storage' as const, label: '对象存储', count: storageConfigs.some((item) => item.status === 'active') ? '已连接' : '未配置' }] : []),
{ id: 'audit' as const, label: '审计日志', count: logs.length }, { id: 'audit' as const, label: '审计日志', count: logs.length },
], [accounts.length, groups.length, isPlatform, keys, logs.length]); ], [accounts.length, groups.length, isPlatform, keys, logs.length, storageConfigs]);
if (user?.role === 'operator') { if (user?.role === 'operator') {
return <main className="mx-auto max-w-3xl px-5 py-24 text-center"><ShieldCheck className="mx-auto text-black/25"/><h1 className="mt-5 font-display text-4xl"></h1><p className="mt-3 text-sm text-black/45"></p></main>; return <main className="mx-auto max-w-3xl px-5 py-24 text-center"><ShieldCheck className="mx-auto text-black/25"/><h1 className="mt-5 font-display text-4xl"></h1><p className="mt-3 text-sm text-black/45"></p></main>;
@@ -108,8 +112,8 @@ export default function ManagementPage() {
{loading ? <Loading /> : <> {loading ? <Loading /> : <>
{tab === 'groups' && <Groups groups={groups} accounts={accounts} onCreate={() => setPanel('group')} onRename={(item)=>{setRenameGroup(item);setRenameGroupValue(item.name);setPanel('rename_group')}} onShowAccounts={(item) => { setAccountGroupFilter(String(item.id)); setTab('accounts'); }} onReplace={(item) => { setReplaceGroup(item); setReplacementUserId(''); setPreviousAdminAction('demote'); setPanel('replace_admin'); }} onToggle={(item) => { if(item.status==='active'&&!window.confirm(`停用“${item.name}”将影响 ${item.active_user_count} 个启用账号、${item.project_count} 个项目和 ${item.customer_link_count} 个客户访问链接。确认继续吗?`))return;void run(() => api.setGroupStatus(item.id, item.status === 'active' ? 'disabled' : 'active').then(() => undefined)); }}/>} {tab === 'groups' && <Groups groups={groups} accounts={accounts} onCreate={() => setPanel('group')} onRename={(item)=>{setRenameGroup(item);setRenameGroupValue(item.name);setPanel('rename_group')}} onShowAccounts={(item) => { setAccountGroupFilter(String(item.id)); setTab('accounts'); }} onReplace={(item) => { setReplaceGroup(item); setReplacementUserId(''); setPreviousAdminAction('demote'); setPanel('replace_admin'); }} onToggle={(item) => { if(item.status==='active'&&!window.confirm(`停用“${item.name}”将影响 ${item.active_user_count} 个启用账号、${item.project_count} 个项目和 ${item.customer_link_count} 个客户访问链接。确认继续吗?`))return;void run(() => api.setGroupStatus(item.id, item.status === 'active' ? 'disabled' : 'active').then(() => undefined)); }}/>}
{tab === 'accounts' && <Accounts accounts={accounts} groups={groups} groupFilter={accountGroupFilter} onGroupFilter={setAccountGroupFilter} currentId={user?.id} currentRole={user?.role} onCreate={() => setPanel('user')} onAudit={(item) => { setAuditUserId(item.id); setTab('audit'); }} onRename={(item) => { setRenameTarget(item); setRenameValue(item.display_name); setPanel('rename'); }} onToggle={(item) => void run(() => api.setUserStatus(item.id, item.status === 'active' ? 'disabled' : 'active').then(() => undefined))} onReset={(item) => { setResetTarget(item); setResetPassword(''); setPanel('reset'); }}/>} {tab === 'accounts' && <Accounts accounts={accounts} groups={groups} groupFilter={accountGroupFilter} onGroupFilter={setAccountGroupFilter} currentId={user?.id} currentRole={user?.role} onCreate={() => setPanel('user')} onAudit={(item) => { setAuditUserId(item.id); setTab('audit'); }} onRename={(item) => { setRenameTarget(item); setRenameValue(item.display_name); setPanel('rename'); }} onToggle={(item) => void run(() => api.setUserStatus(item.id, item.status === 'active' ? 'disabled' : 'active').then(() => undefined))} onReset={(item) => { setResetTarget(item); setResetPassword(''); setPanel('reset'); }}/>}
{tab === 'keys' && <ApiKeys items={keys} onCreate={() => { setRevealedKey(''); setPanel('key'); }} onRevoke={(item) => void run(() => api.revokeApiKey(item.id))}/>} {tab === 'keys' && <ApiKeys items={keys} onCreate={() => { setRevealedKey(''); setCopyState('idle'); setPanel('key'); }} onRevoke={(item) => void run(() => api.revokeApiKey(item.id))}/>}
{tab === 'storage' && isPlatform && <StorageSettings/>} {tab === 'storage' && isPlatform && <StorageSettings items={storageConfigs} onItemsChange={setStorageConfigs}/>}
{tab === 'audit' && <AuditLogs items={logs} filteredUser={auditUserId ? accounts.find((item)=>item.id===auditUserId) : undefined} onClear={() => setAuditUserId(null)}/>} {tab === 'audit' && <AuditLogs items={logs} filteredUser={auditUserId ? accounts.find((item)=>item.id===auditUserId) : undefined} onClear={() => setAuditUserId(null)}/>}
</>} </>}
@@ -124,7 +128,7 @@ export default function ManagementPage() {
{panel === 'replace_admin' && replaceGroup && <Modal title="更换组管理员" subtitle={`${replaceGroup.name} 当前管理员:${replaceGroup.group_admin_name || '未设置'}`} onClose={() => setPanel(null)}><FormField label="新组管理员" hint="从已启用的光影叙事中选择"><select value={replacementUserId} onChange={(e)=>setReplacementUserId(e.target.value)}><option value=""></option>{accounts.filter((item)=>item.group_id===replaceGroup.id&&item.role==='operator'&&item.status==='active').map((item)=><option key={item.id} value={item.id}>{item.display_name} · {item.username}</option>)}</select></FormField><FormField label="原管理员处理"><select value={previousAdminAction} onChange={(e)=>setPreviousAdminAction(e.target.value as 'demote'|'disable')}><option value="demote"></option><option value="disable"></option></select></FormField><div className="rounded-xl border border-[#d15f37]/15 bg-[#fff7f2] p-3 text-xs leading-5 text-[#91462e]"></div><Submit saving={saving} disabled={!replacementUserId} onClick={() => void run(() => api.replaceGroupAdmin(replaceGroup.id,Number(replacementUserId),previousAdminAction).then(()=>undefined))}></Submit></Modal>} {panel === 'replace_admin' && replaceGroup && <Modal title="更换组管理员" subtitle={`${replaceGroup.name} 当前管理员:${replaceGroup.group_admin_name || '未设置'}`} onClose={() => setPanel(null)}><FormField label="新组管理员" hint="从已启用的光影叙事中选择"><select value={replacementUserId} onChange={(e)=>setReplacementUserId(e.target.value)}><option value=""></option>{accounts.filter((item)=>item.group_id===replaceGroup.id&&item.role==='operator'&&item.status==='active').map((item)=><option key={item.id} value={item.id}>{item.display_name} · {item.username}</option>)}</select></FormField><FormField label="原管理员处理"><select value={previousAdminAction} onChange={(e)=>setPreviousAdminAction(e.target.value as 'demote'|'disable')}><option value="demote"></option><option value="disable"></option></select></FormField><div className="rounded-xl border border-[#d15f37]/15 bg-[#fff7f2] p-3 text-xs leading-5 text-[#91462e]"></div><Submit saving={saving} disabled={!replacementUserId} onClick={() => void run(() => api.replaceGroupAdmin(replaceGroup.id,Number(replacementUserId),previousAdminAction).then(()=>undefined))}></Submit></Modal>}
{panel === 'rename_group' && renameGroup && <Modal title="修改运营组名称" subtitle="只修改名称,不影响组内账号、项目、客户链接和权限。" onClose={()=>setPanel(null)}><FormField label="运营组名称" hint="240 个字符"><input autoFocus value={renameGroupValue} onChange={(e)=>setRenameGroupValue(e.target.value)}/></FormField><Submit saving={saving} disabled={renameGroupValue.trim().length<2||renameGroupValue.trim()===renameGroup.name} onClick={()=>void renameOperationGroup()}></Submit></Modal>} {panel === 'rename_group' && renameGroup && <Modal title="修改运营组名称" subtitle="只修改名称,不影响组内账号、项目、客户链接和权限。" onClose={()=>setPanel(null)}><FormField label="运营组名称" hint="240 个字符"><input autoFocus value={renameGroupValue} onChange={(e)=>setRenameGroupValue(e.target.value)}/></FormField><Submit saving={saving} disabled={renameGroupValue.trim().length<2||renameGroupValue.trim()===renameGroup.name} onClick={()=>void renameOperationGroup()}></Submit></Modal>}
{panel === 'key' && <Modal title={revealedKey ? '保存 API Key' : '创建 API Key'} subtitle={revealedKey ? '密钥只显示这一次。关闭前请复制到安全位置。' : isPlatform ? '平台级 Key 可用于外部系统创建项目。' : '项目级 Key 仅能操作绑定的项目。'} onClose={() => { setPanel(null); setRevealedKey(''); }}>{revealedKey ? <div><div className="break-all rounded-2xl bg-[#171714] p-5 font-mono text-xs leading-6 text-[#f2c38d]">{revealedKey}</div><button onClick={() => void navigator.clipboard.writeText(revealedKey)} className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-full border border-black/15 py-3 text-sm"><Copy size={14}/>复制密钥</button></div> : <><FormField label="Key 名称"><input value={keyForm.name} onChange={(e) => setKeyForm({ ...keyForm, name: e.target.value })} placeholder="例如:自动发布客户端"/></FormField>{!isPlatform && <FormField label="绑定项目"><select value={keyForm.project_id} onChange={(e) => setKeyForm({ ...keyForm, project_id: e.target.value })}><option value="">请选择</option>{projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></FormField>}<Submit saving={saving} disabled={!keyForm.name || (!isPlatform && !keyForm.project_id)} onClick={() => { setSaving(true); setError(''); void api.createApiKey({ name: keyForm.name, project_id: keyForm.project_id ? Number(keyForm.project_id) : undefined }).then(({ token }) => { setRevealedKey(token); setKeyForm({ name: '', project_id: '' }); return load(); }).catch((reason) => setError(messageOf(reason))).finally(() => setSaving(false)); }}></Submit></>}</Modal>} {panel === 'key' && <Modal title={revealedKey ? '保存 API Key' : '创建 API Key'} subtitle={revealedKey ? '密钥只显示这一次。关闭前请复制到安全位置。' : isPlatform ? '平台级 Key 可用于外部系统创建项目。' : '项目级 Key 仅能操作绑定的项目。'} onClose={() => { setPanel(null); setRevealedKey(''); setCopyState('idle'); }}>{revealedKey ? <div><div className="break-all rounded-2xl bg-[#171714] p-5 font-mono text-xs leading-6 text-[#f2c38d]">{revealedKey}</div><button onClick={() => { setCopyState('copying'); void copyText(revealedKey).then((copied) => setCopyState(copied ? 'success' : 'error')).catch(() => setCopyState('error')); }} className={`mt-4 inline-flex w-full items-center justify-center gap-2 rounded-full border py-3 text-sm transition ${copyState === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : copyState === 'error' ? 'border-red-200 bg-red-50 text-red-700' : 'border-black/15'}`}><Copy size={14}/><span aria-live="polite">{copyState === 'copying' ? '复制中…' : copyState === 'success' ? '已复制' : copyState === 'error' ? '复制失败,请手动复制' : '复制密钥'}</span></button></div> : <><FormField label="Key 名称"><input value={keyForm.name} onChange={(e) => setKeyForm({ ...keyForm, name: e.target.value })} placeholder="例如:自动发布客户端"/></FormField>{!isPlatform && <FormField label="绑定项目"><select value={keyForm.project_id} onChange={(e) => setKeyForm({ ...keyForm, project_id: e.target.value })}><option value="">请选择</option>{projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></FormField>}<Submit saving={saving} disabled={!keyForm.name || (!isPlatform && !keyForm.project_id)} onClick={() => { setSaving(true); setError(''); void api.createApiKey({ name: keyForm.name, project_id: keyForm.project_id ? Number(keyForm.project_id) : undefined }).then(({ token }) => { setRevealedKey(token); setCopyState('idle'); setKeyForm({ name: '', project_id: '' }); return load(); }).catch((reason) => setError(messageOf(reason))).finally(() => setSaving(false)); }}></Submit></>}</Modal>}
</main> </main>
); );
} }

View File

@@ -4,6 +4,7 @@ import { ArrowLeft, Copy, ImageIcon, KeyRound, MessageCircle, Pencil, Plus, Sear
import type { Note, Project, ReviewStatus } from '@shared/types'; import type { Note, Project, ReviewStatus } from '@shared/types';
import { api } from '@/api/client'; import { api } from '@/api/client';
import StatusBadge from '@/components/StatusBadge'; import StatusBadge from '@/components/StatusBadge';
import { copyText } from '@/lib/utils';
const projectStatus = { draft: '待提交', reviewing: '验收中', completed: '验收完毕', archived: '已归档' } as const; const projectStatus = { draft: '待提交', reviewing: '验收中', completed: '验收完毕', archived: '已归档' } as const;
@@ -21,6 +22,7 @@ export default function ProjectPage() {
const [accessPassword, setAccessPassword] = useState(''); const [accessPassword, setAccessPassword] = useState('');
const [expiresAt, setExpiresAt] = useState(''); const [expiresAt, setExpiresAt] = useState('');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [copyState, setCopyState] = useState<'idle' | 'copying' | 'success' | 'error'>('idle');
const load = useCallback(async () => { const load = useCallback(async () => {
const [nextProject, nextWorks] = await Promise.all([api.getProject(id), api.listProjectWorks(id)]); const [nextProject, nextWorks] = await Promise.all([api.getProject(id), api.listProjectWorks(id)]);
@@ -32,7 +34,7 @@ export default function ProjectPage() {
if (!project) return <main className="grid min-h-[60vh] place-items-center text-sm text-black/35"></main>; if (!project) return <main className="grid min-h-[60vh] place-items-center text-sm text-black/35"></main>;
const reviewUrl = `${window.location.origin}/review/${project.slug}`; const reviewUrl = `${window.location.origin}/review/${project.slug}`;
const openAccess = () => { setAccessEnabled(Boolean(project.customer_access_enabled)); setAccessPassword(''); setExpiresAt(project.access_expires_at?.slice(0, 16) || ''); setMessage(''); setAccessOpen(true); }; const openAccess = () => { setAccessEnabled(Boolean(project.customer_access_enabled)); setAccessPassword(''); setExpiresAt(project.access_expires_at?.slice(0, 16) || ''); setMessage(''); setCopyState('idle'); setAccessOpen(true); };
return <main> return <main>
<section className="border-b border-black/10 bg-[#171714] text-white"><div className="mx-auto max-w-[1500px] px-5 py-12 lg:px-10 lg:py-16"><Link to="/" className="inline-flex items-center gap-2 text-xs text-white/55"><ArrowLeft size={14}/></Link><div className="mt-9 grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end"><div><div className="flex flex-wrap items-center gap-3"><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ff795f]">Project / {project.slug}</p><span className="rounded-full border border-white/15 px-3 py-1 text-[10px] text-white/60">{projectStatus[project.review_status]}</span></div><h1 className="mt-4 font-display text-5xl tracking-[-.05em] sm:text-7xl">{project.name}</h1><p className="mt-5 max-w-2xl text-sm leading-7 text-white/55">{project.client_description}</p></div><div><div className="grid grid-cols-4 gap-5"><Metric n={project.work_count} label="全部"/><Metric n={project.pending_count} label="待验收"/><Metric n={project.changes_requested_count} label="需修改"/><Metric n={project.approved_count} label="已通过"/></div><div className="mt-6 flex flex-wrap justify-end gap-2"><button onClick={() => { setProjectName(project.name); setProjectDesc(project.client_description); setEditOpen(true); }} className="rounded-full border border-white/20 px-4 py-2 text-xs text-white/70"><Pencil className="mr-2 inline" size={13}/></button><button onClick={openAccess} className="rounded-full border border-white/20 px-4 py-2 text-xs text-white/70"><KeyRound className="mr-2 inline" size={13}/>访</button></div></div></div></div></section> <section className="border-b border-black/10 bg-[#171714] text-white"><div className="mx-auto max-w-[1500px] px-5 py-12 lg:px-10 lg:py-16"><Link to="/" className="inline-flex items-center gap-2 text-xs text-white/55"><ArrowLeft size={14}/></Link><div className="mt-9 grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end"><div><div className="flex flex-wrap items-center gap-3"><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ff795f]">Project / {project.slug}</p><span className="rounded-full border border-white/15 px-3 py-1 text-[10px] text-white/60">{projectStatus[project.review_status]}</span></div><h1 className="mt-4 font-display text-5xl tracking-[-.05em] sm:text-7xl">{project.name}</h1><p className="mt-5 max-w-2xl text-sm leading-7 text-white/55">{project.client_description}</p></div><div><div className="grid grid-cols-4 gap-5"><Metric n={project.work_count} label="全部"/><Metric n={project.pending_count} label="待验收"/><Metric n={project.changes_requested_count} label="需修改"/><Metric n={project.approved_count} label="已通过"/></div><div className="mt-6 flex flex-wrap justify-end gap-2"><button onClick={() => { setProjectName(project.name); setProjectDesc(project.client_description); setEditOpen(true); }} className="rounded-full border border-white/20 px-4 py-2 text-xs text-white/70"><Pencil className="mr-2 inline" size={13}/></button><button onClick={openAccess} className="rounded-full border border-white/20 px-4 py-2 text-xs text-white/70"><KeyRound className="mr-2 inline" size={13}/>访</button></div></div></div></div></section>
@@ -44,7 +46,7 @@ export default function ProjectPage() {
</section> </section>
{editOpen && <Modal title="编辑项目" close={() => setEditOpen(false)}><label className="block text-xs text-black/45"><input className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={projectName} onChange={(event) => setProjectName(event.target.value)}/></label><label className="mt-4 block text-xs text-black/45"><textarea className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" rows={4} value={projectDesc} onChange={(event) => setProjectDesc(event.target.value)}/></label><button onClick={async () => { setProject(await api.updateProject(id, { name: projectName, client_description: projectDesc })); setEditOpen(false); }} className="mt-5 w-full rounded-full bg-black py-3 text-sm text-white"></button></Modal>} {editOpen && <Modal title="编辑项目" close={() => setEditOpen(false)}><label className="block text-xs text-black/45"><input className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={projectName} onChange={(event) => setProjectName(event.target.value)}/></label><label className="mt-4 block text-xs text-black/45"><textarea className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" rows={4} value={projectDesc} onChange={(event) => setProjectDesc(event.target.value)}/></label><button onClick={async () => { setProject(await api.updateProject(id, { name: projectName, client_description: projectDesc })); setEditOpen(false); }} className="mt-5 w-full rounded-full bg-black py-3 text-sm text-white"></button></Modal>}
{accessOpen && <Modal title="客户访问" close={() => setAccessOpen(false)}><div className="rounded-xl bg-black/[.04] p-3 text-xs break-all">{reviewUrl}</div><button onClick={() => void navigator.clipboard.writeText(reviewUrl)} className="mt-2 inline-flex items-center gap-2 text-xs text-black/50"><Copy size={12}/></button><label className="mt-5 flex items-center gap-3 text-sm"><input type="checkbox" checked={accessEnabled} onChange={(event) => setAccessEnabled(event.target.checked)}/>访</label><label className="mt-4 block text-xs text-black/45">访<input type="password" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={accessPassword} onChange={(event) => setAccessPassword(event.target.value)}/></label><label className="mt-4 block text-xs text-black/45"><input type="datetime-local" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)}/></label><button onClick={async () => { try { const updated = await api.updateCustomerAccess(id, { enabled: accessEnabled, password: accessPassword || undefined, expires_at: expiresAt || null }); setProject(updated); setMessage('客户访问设置已保存'); setAccessPassword(''); } catch (error) { setMessage(error instanceof Error ? error.message : '保存失败'); } }} className="mt-5 w-full rounded-full bg-black py-3 text-sm text-white"></button>{message && <p className="mt-3 text-center text-xs text-black/50">{message}</p>}</Modal>} {accessOpen && <Modal title="客户访问" close={() => setAccessOpen(false)}><div className="rounded-xl bg-black/[.04] p-3 text-xs break-all">{reviewUrl}</div><button onClick={() => { setCopyState('copying'); void copyText(reviewUrl).then((copied) => setCopyState(copied ? 'success' : 'error')).catch(() => setCopyState('error')); }} className={`mt-2 inline-flex items-center gap-2 text-xs transition ${copyState === 'success' ? 'text-emerald-700' : copyState === 'error' ? 'text-red-600' : 'text-black/50'}`}><Copy size={12}/><span aria-live="polite">{copyState === 'copying' ? '复制中…' : copyState === 'success' ? '已复制' : copyState === 'error' ? '复制失败,请手动复制' : '复制链接'}</span></button><label className="mt-5 flex items-center gap-3 text-sm"><input type="checkbox" checked={accessEnabled} onChange={(event) => setAccessEnabled(event.target.checked)}/>访</label><label className="mt-4 block text-xs text-black/45">访<input type="password" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={accessPassword} onChange={(event) => setAccessPassword(event.target.value)}/></label><label className="mt-4 block text-xs text-black/45"><input type="datetime-local" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)}/></label><button onClick={async () => { try { const updated = await api.updateCustomerAccess(id, { enabled: accessEnabled, password: accessPassword || undefined, expires_at: expiresAt || null }); setProject(updated); setMessage('客户访问设置已保存'); setAccessPassword(''); } catch (error) { setMessage(error instanceof Error ? error.message : '保存失败'); } }} className="mt-5 w-full rounded-full bg-black py-3 text-sm text-white"></button>{message && <p className="mt-3 text-center text-xs text-black/50">{message}</p>}</Modal>}
</main>; </main>;
} }

View File

@@ -8,8 +8,11 @@ import contextlib
import importlib.util import importlib.util
import io import io
import json import json
import os
import sys import sys
import tempfile import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
@@ -36,6 +39,19 @@ def make_plan(operation: str) -> dict:
return value return value
def make_file_plan(images: list[dict]) -> dict:
value = {
"schema_version": 2,
"operation": "create_work",
"base_url": "http://example.invalid",
"target": IDENTITY,
"external_id": "agent-file-001",
"content": {**CONTENT, "image_source": "file", "images": images},
}
value["confirmation_code"] = MODULE.confirmation_code(value)
return value
def write_plan(folder: Path, value: dict) -> Path: def write_plan(folder: Path, value: dict) -> Path:
path = folder / "plan.json" path = folder / "plan.json"
path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8") path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
@@ -54,8 +70,24 @@ def apply_silently(path: Path, code: str) -> dict:
def main() -> None: def main() -> None:
previous_base_url = os.environ.get("DELIVERY_DESK_BASE_URL")
os.environ["DELIVERY_DESK_BASE_URL"] = "http://should-not-override.invalid"
try:
assert MODULE.base_url() == "http://192.168.30.90:3010"
assert MODULE.base_url("https://delivery.example.com/") == "https://delivery.example.com"
finally:
if previous_base_url is None:
os.environ.pop("DELIVERY_DESK_BASE_URL", None)
else:
os.environ["DELIVERY_DESK_BASE_URL"] = previous_base_url
skill_text = (SCRIPT.parents[1] / "SKILL.md").read_text(encoding="utf-8")
assert "请回复“确认目标”" in skill_text
assert "Do not generate `plan-work` or `plan-round` before it." in skill_text
original_exact_project = MODULE.exact_project original_exact_project = MODULE.exact_project
original_request_json = MODULE.request_json original_request_json = MODULE.request_json
original_request_multipart = MODULE.request_multipart
original_get_work = MODULE.get_work original_get_work = MODULE.get_work
MODULE.exact_project = lambda _base, _project_id: PROJECT MODULE.exact_project = lambda _base, _project_id: PROJECT
try: try:
@@ -88,6 +120,82 @@ def main() -> None:
assert result["work_id"] == 88 and result["image_count"] == 1 assert result["work_id"] == 88 and result["image_count"] == 1
assert [method for method, _path in create_calls].count("POST") == 1 assert [method for method, _path in create_calls].count("POST") == 1
local_image = folder / "generated.png"
local_image.write_bytes(b"local-image-content")
descriptors = MODULE.describe_image_files([str(local_image)])
captured: dict = {}
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
captured["authorization"] = self.headers.get("Authorization")
captured["content_type"] = self.headers.get("Content-Type")
captured["body"] = self.rfile.read(int(self.headers["Content-Length"]))
response = json.dumps({"id": 88}).encode()
self.send_response(201)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(response)))
self.end_headers()
self.wfile.write(response)
def log_message(self, _format, *_args):
return
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
previous_key = os.environ.get("DELIVERY_DESK_API_KEY")
os.environ["DELIVERY_DESK_API_KEY"] = "dd_test"
thread.start()
try:
status, payload = MODULE.request_multipart(
f"http://127.0.0.1:{server.server_port}",
"/api/projects/12/works",
fields={"title": "作品标题", "description": "正文", "tags": "[]"},
files=descriptors,
)
local_image.write_bytes(b"x" * len(b"local-image-content"))
try:
MODULE.request_multipart(
f"http://127.0.0.1:{server.server_port}",
"/api/projects/12/works",
fields={"title": "作品标题", "description": "正文", "tags": "[]"},
files=descriptors,
)
raise AssertionError("同尺寸但哈希变化的文件未被阻止")
except MODULE.UploadError as error:
assert error.action == "revise" and "发生变化" in str(error)
local_image.write_bytes(b"local-image-content")
finally:
server.shutdown()
server.server_close()
thread.join()
if previous_key is None:
os.environ.pop("DELIVERY_DESK_API_KEY", None)
else:
os.environ["DELIVERY_DESK_API_KEY"] = previous_key
assert status == 201 and payload["id"] == 88
assert captured["authorization"] == "Bearer dd_test"
assert "multipart/form-data" in captured["content_type"]
assert b"local-image-content" in captured["body"] and b'name="images"' in captured["body"]
file_plan = make_file_plan(descriptors)
file_path = write_plan(folder, file_plan)
MODULE.request_json = lambda *_args, **_kwargs: (200, [])
multipart_calls: list[tuple] = []
MODULE.request_multipart = lambda *_args, **_kwargs: (multipart_calls.append((_args, _kwargs)) or (201, {"id": 88}))
MODULE.get_work = lambda *_args, **_kwargs: {**detail(1, image_url="https://cos.example.com/originals/generated.png"), "external_id": "agent-file-001"}
result = apply_silently(file_path, file_plan["confirmation_code"])
assert result["work_id"] == 88 and len(multipart_calls) == 1
assert multipart_calls[0][1]["files"][0]["sha256"] == descriptors[0]["sha256"]
local_image.write_bytes(b"changed-after-confirmation")
multipart_calls.clear()
try:
apply_silently(file_path, file_plan["confirmation_code"])
raise AssertionError("确认后变化的文件未被阻止")
except MODULE.UploadError as error:
assert error.action == "revise" and "发生变化" in str(error)
assert not multipart_calls
round_plan = make_plan("create_round") round_plan = make_plan("create_round")
round_path = write_plan(folder, round_plan) round_path = write_plan(folder, round_plan)
MODULE.get_work = lambda *_args, **_kwargs: detail(3, work_id=34) MODULE.get_work = lambda *_args, **_kwargs: detail(3, work_id=34)
@@ -126,6 +234,7 @@ def main() -> None:
finally: finally:
MODULE.exact_project = original_exact_project MODULE.exact_project = original_exact_project
MODULE.request_json = original_request_json MODULE.request_json = original_request_json
MODULE.request_multipart = original_request_multipart
MODULE.get_work = original_get_work MODULE.get_work = original_get_work
print("Upload Skill regression tests passed") print("Upload Skill regression tests passed")