# API 合约 > MVP 使用 `/api/v1`、UTF-8 JSON over HTTPS。文件上传使用 `multipart/form-data`。 > Web 页面可以直接调用同一 usecase,但不能形成不同的业务规则。 ## 通用约定 - 所有 ID 为 UUID 字符串。 - 时间为 UTC ISO 8601,例如 `2026-07-25T08:30:00Z`。 - 金额使用十进制定点字符串,例如 `"199.00"`,币种固定 `CNY`;不使用浮点数。 - 写接口在合约标注处接收 `Idempotency-Key` 请求头。 - App 的任务执行接口同时需要用户 Bearer Token、设备身份和 `X-Claim-Token`。 - 分页使用 `limit` 和不透明 `cursor`;MVP `limit` 最大 100。 - 客户端不得根据 HTTP 超时判断操作失败,必须查询资源最终状态。 管理 Web/API 使用 ADMIN 服务端会话;App 执行接口使用 BUYER + 设备 Bearer token。 两种身份不能互换。HTTP 明文只允许 loopback 开发监听,非 loopback 服务必须配置 certificate/private key 并直接启用 TLS。 通用错误: ```json { "error": { "code": "TASK_STATE_CONFLICT", "message": "任务当前状态不允许此操作", "retryable": false, "details": {} }, "request_id": "f2cf02e9-57e0-4fca-817b-c85228acfc81" } ``` HTTP 语义: - `400` 请求格式或业务校验失败 - `401` 未建立有效身份 - `403` 身份有效但无权限、设备禁用或 claim 不属于当前设备 - `404` 资源不存在,外部响应不泄露其他人的资源存在性 - `409` 幂等冲突、状态冲突或设备已有活跃任务 - `413` 文件过大 - `415` 不支持的媒体类型 - `422` 结构可解析但字段不满足 schema - `429` 频率限制 - `503` 依赖暂不可用 ## 服务健康 ### `GET /healthz` 不需要业务身份,只检查进程和 SQLite 连接,不返回版本、路径、DSN、连接池统计或内部 错误。响应带 `Cache-Control: no-store`,默认不返回 CORS header。 数据库可用: ```json {"status":"ok"} ``` 返回 `200`。数据库不可用时返回 `503`: ```json {"status":"unavailable"} ``` 健康检查失败不能终止进程;未知路由和不允许的方法分别使用稳定 `404`/`405` JSON。 ## 认证 ### 管理 Web 会话 `POST /login` 接受表单账号密码,成功后设置 `HttpOnly`、`Secure`、`SameSite=Lax` 会话 Cookie。loopback HTTP 开发时不设置 `Secure`,非 loopback 服务必须直接启用 TLS。管理会话固定 8 小时绝对有效期;`POST /logout` 撤销服务端会话并清除 Cookie。 Web 会话不能调用设备执行接口。 未登录页面请求以 `303` 跳转 `/login?next=...`;`next` 只允许 `/tasks` 及其本站 子路径。未授权管理 API 返回 `401 ADMIN_SESSION_REQUIRED`。Cookie 认证的管理 API 写请求除原 Content-Type/幂等要求外,还必须携带与 CSRF Cookie 匹配的 `X-CSRF-Token`。 `POST /login` 在通过表单和 CSRF 校验、执行 bcrypt 前,按服务端观察到的来源地址 限流。5 分钟内最多 10 次;成功登录清零。超限返回 `429`、`Retry-After` 和通用 中文提示,不暴露账号是否存在。 ### `POST /api/v1/auth/token` 采购 App 建立用户和设备联合会话。 ```json { "username": "buyer01", "password": "local-input-only", "device_id": "f0c3d438-1c44-4f80-b898-c645afe7eaa8", "device_token": "local-input-only", "app_version": "0.1.0", "android_version": "待设备填写" } ``` 成功: ```json { "access_token": "opaque-token-returned-once", "token_type": "Bearer", "expires_in": 3600, "user": { "id": "d950db90-cf58-4f21-86fd-067e1a91a3a6", "username": "buyer01", "role": "BUYER" }, "device": { "id": "f0c3d438-1c44-4f80-b898-c645afe7eaa8", "enabled": true } } ``` 密码和设备 token 不得出现在响应、日志或 execution event 中。 T-204 固定使用 256 bit 随机 opaque access token,数据库只保存 SHA-256,固定 1 小时过期。用户必须为有效 `BUYER`,设备必须预授权且启用;未绑定设备在首次成功 登录时原子绑定当前采购员,已绑定其他采购员时拒绝。T-204 不提供设备自助登记、 refresh 或 App logout;Android 安全存储接入属于 T-206。 App token 登录与管理登录使用独立限流 scope,同样为每个来源地址 5 分钟最多 10 次。 超限返回 `429`、`Retry-After` 和稳定错误码 `AUTH_RATE_LIMITED`,`retryable=true`。 ## 资产 ### `POST /api/v1/assets` 管理会话上传任务参考图片;App 也可用设备会话上传执行截图。请求必须带 `Idempotency-Key`。 表单字段: - `file`:必填,MVP 允许可解码 JPEG/PNG/WebP;请求最多 20 MiB、最长边最多 10000 px、总像素最多 25 MP。 - `purpose`:`TASK_REFERENCE` 或 `EXECUTION_EVIDENCE`。 - `task_id`:证据图片必填;参考图创建时为空。 成功: ```json { "id": "2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3", "media_type": "image/jpeg", "size_bytes": 183245, "sha256": "hex-value", "created_at": "2026-07-25T08:30:00Z" } ``` T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容重试时返回同一 资产;同 key 不同内容返回 `409`。 `TASK_REFERENCE` 在后端统一白底合成、缩放到最长边不超过 2048 px,并以质量 90 编码为匿名 JPEG。响应的 `media_type`、`size_bytes` 和 `sha256` 都描述规范化结果, 不描述原始上传文件。T-203 尚不接受 `EXECUTION_EVIDENCE`。 ### `GET /api/v1/assets/{asset_id}/content` 受鉴权的文件流。只能读取用户有权查看的任务资产;不返回服务端文件路径。 ## 采购任务 ### `POST /api/v1/tasks` 采购管理员或授权的外部管理系统创建任务。必须带 `Idempotency-Key`。 ```json { "source_ref": "external-admin-task-10001", "title": "黑色双肩包", "sku": "BLACK-20L", "description": "容量约20L,外观接近参考图", "image_asset_id": "2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3", "quantity": 2, "max_budget": "200.00" } ``` 规则: - `title`、`sku` 和 `image_asset_id` 必填,`description` 可为空。 - `title` 最多 120 个 Unicode 字符且不超过 2048 个 UTF-8 字节;`sku` 不超过 512 个 UTF-8 字节;`description` 不超过 8192 个 UTF-8 字节。 - `quantity` 是正整数。 - `max_budget` 可为空,否则为大于零、最多两位小数的 CNY 金额,表示当前任务全部 数量的最高商品总预算,不含尚无法确认的运费或优惠。 - 同一调用方的 `source_ref` 如填写必须唯一。 成功返回 `201`: ```json { "id": "37c9c715-9b51-4ed5-984e-66dad2710c71", "status": "PENDING", "title": "黑色双肩包", "sku": "BLACK-20L", "quantity": 2, "max_budget": "200.00", "created_at": "2026-07-25T08:30:00Z" } ``` ### `GET /api/v1/tasks` 管理端列表。可选参数:`q`、`status`、`created_from`、`created_to`、`limit`、 `cursor`。`q` 匹配任务 ID、`source_ref`、标题或 SKU;默认 `limit=20`,最大 100。 排序固定为 `created_at DESC, id DESC`,不透明 cursor 同时编码这两个字段。 ```json { "items": [ { "id": "37c9c715-9b51-4ed5-984e-66dad2710c71", "title": "黑色双肩包", "sku": "BLACK-20L", "status": "RUNNING", "quantity": 2, "device_name": "pdd-phone-01", "updated_at": "2026-07-25T08:35:00Z" } ], "next_cursor": null } ``` ### `GET /api/v1/tasks/{task_id}` 管理会话返回完整任务、当前 execution、时间线、候选和资产元数据。App 只能读取 当前设备已领取的任务。响应必须同时包含: - `original_requirement`:不可变原始输入。 - `derived_requirement`:模型输出,可能为空。 - `claim`:管理端可见设备和到期时间;claim token 永不返回。 - `execution`:step、outcome、错误、`order_submitted`。 - `events` 和 `assets`:有权限的摘要。 T-204 已实现的 `TASK_CREATED`、`TASK_CANCELED` 事件包含可空 `actor_user_id`;新管理操作写入真实 ADMIN 用户 ID,T-203 历史事件返回 `null`。 ### `POST /api/v1/tasks/{task_id}/cancel` 管理端取消任务。`PENDING` 可立即取消;执行中只设置取消请求,App 在安全检查点确认 后进入 `CANCELED`。终态返回 `409`。 ```json { "reason": "需求已撤销" } ``` T-203 只实现 `PENDING -> CANCELED`;其他状态返回 `409`。执行中设置取消请求及 App 安全检查点响应属于 T-205。 ## 设备与领取 ### `POST /api/v1/devices/heartbeat` App 空闲或运行时上报设备状态;运行时任务续租使用任务专用 heartbeat。 ```json { "device_id": "f0c3d438-1c44-4f80-b898-c645afe7eaa8", "app_version": "0.1.0", "pdd_version": "device-observed-value", "readiness": { "accessibility_enabled": true, "pdd_installed": true, "active_task_id": null } } ``` ### `POST /api/v1/tasks/claim-next` 由用户点击触发,在单个数据库事务中领取下一条任务。必须带 `Idempotency-Key`。 ```json { "device_id": "f0c3d438-1c44-4f80-b898-c645afe7eaa8" } ``` 有任务时: ```json { "task": { "id": "37c9c715-9b51-4ed5-984e-66dad2710c71", "status": "CLAIMED", "title": "黑色双肩包", "sku": "BLACK-20L", "description": "容量约20L,外观接近参考图", "image_asset_id": "2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3", "quantity": 2, "max_budget": "200.00" }, "claim_token": "returned-once", "claim_expires_at": "2026-07-25T08:40:00Z" } ``` 没有任务返回 `204`。设备未就绪或已有活跃任务返回 `409`。幂等重放返回同一领取 结果;claim token 的安全重放形式在实现前通过测试固定。 ### `POST /api/v1/tasks/{task_id}/start` 把当前设备持有的 `CLAIMED` 任务改为 `RUNNING` 并创建 execution。请求带 `X-Claim-Token` 和 `Idempotency-Key`。 ### `POST /api/v1/tasks/{task_id}/heartbeat` 运行时续租并返回是否请求取消: ```json { "execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f", "step": "SCAN_RESULTS", "client_time": "2026-07-25T08:33:30Z" } ``` ```json { "claim_expires_at": "2026-07-25T08:43:30Z", "cancel_requested": false } ``` ### `POST /api/v1/tasks/{task_id}/release` 只允许尚未开始的 `CLAIMED` 任务释放回 `PENDING`。运行中使用取消/失败流程。 ## AI 合约 ### `POST /api/v1/tasks/{task_id}/ai/extract-requirements` 只允许当前 execution 调用。后端从任务读取原始图片和文字,客户端不能替换硬约束。 ```json { "execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f" } ``` 响应: ```json { "schema_version": 1, "search_query": "黑色 20L 双肩包", "category": "双肩包", "attributes": [ {"name": "color", "value": "black", "source": "text"}, {"name": "capacity", "value": "about 20L", "source": "text"} ], "sku": "BLACK-20L", "quantity": 2, "max_budget": "200.00", "confidence": 0.86, "warnings": [], "manual_review": { "required": false, "reasons": [] }, "provenance": { "provider_id": "configured-provider", "model": "configured-model", "prompt_version": "requirement-extraction-v1", "reference_image_sha256": "64-char-lowercase-hex" } } ``` `sku`、`quantity` 和 `max_budget` 必须来自原始任务,不能采用模型返回值。第一层 `ProbeTask` 尚无预算字段,因此 T-103 输出 `max_budget: null` 并追加 `MAX_BUDGET_NOT_PROVIDED` 警告。属性 `source` 的 API 表示使用小写 `title/image/both`;供应商响应在 adapter 内规范化后才进入此合约。 ### `POST /api/v1/tasks/{task_id}/ai/evaluate-candidate` ```json { "execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f", "candidate_index": 1, "screenshot_asset_id": "7b733922-f90f-4bc4-a9ad-3e8ec4769122", "observed": { "title": "页面可见标题", "price": "189.00" } } ``` 响应: ```json { "schema_version": 1, "candidate_index": 1, "decision": "REVIEW", "score": 0.82, "matched": ["颜色接近", "价格未超预算"], "missing_or_uncertain": ["容量无法从当前页面确认"], "rejection_reasons": [], "confidence": 0.78 } ``` `candidate_index` 必须原样回显当前候选 ordinal;`decision` 只允许 `REVIEW`、 `REJECT`、`MANUAL_REQUIRED`。App 按 ordinal 串行评估,每个候选最多调用一次、整批 最多 5 次,并由本地确定性规则产生建议项。模型不能返回页面动作、建议 ordinal、 人工确认状态或订单授权,也没有“提交订单”权限;原任务未提供预算时,任何价格或 预算匹配声明都视为无效输出。 ## 执行事件与结果 ### `POST /api/v1/tasks/{task_id}/events` 批量追加事件,必须带 `Idempotency-Key`: ```json { "execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f", "events": [ { "event_id": "client-generated-uuid", "step": "SEARCH", "type": "STEP_COMPLETED", "message": "已进入搜索结果页", "occurred_at": "2026-07-25T08:34:00Z" } ] } ``` `message` 不能包含凭证或完整个人敏感信息;同一 `event_id` 重放不重复插入。 ### `POST /api/v1/tasks/{task_id}/complete` 人员完成确认后调用,必须带 `Idempotency-Key`: ```json { "execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f", "outcome": "CANDIDATE_ACCEPTED", "operator_reason": "款式和预算符合验证要求", "candidate": { "title": "页面可见标题", "price": "189.00", "evidence_asset_ids": ["7b733922-f90f-4bc4-a9ad-3e8ec4769122"] }, "order_submitted": false } ``` `outcome` 允许: - `CANDIDATE_ACCEPTED` - `CANDIDATE_REJECTED` - `NO_MATCH` - `MANUAL_REQUIRED` 后端对 MVP 强制 `order_submitted=false`,成功后任务进入 `SUCCEEDED`;这里的成功表示 验证工作流正常结束,业务结果由 `outcome` 表达。 ### `POST /api/v1/tasks/{task_id}/fail` ```json { "execution_id": "e3190742-a24b-441e-b5c1-c7ed10ed342f", "error": { "code": "PDD_RISK_CONTROL", "message": "检测到平台风险提示,已停止自动化", "step": "SCAN_RESULTS", "retryable": false }, "evidence_asset_ids": ["7b733922-f90f-4bc4-a9ad-3e8ec4769122"] } ``` 后端只接受文档登记的错误码族和合法状态迁移。 ## 待实现前固定 - 上传大小、像素和保留期限的具体数值。 - access token、管理会话和 claim lease 的最终时长。 - claim token 幂等重放与轮换细节。 - VLM `confidence` 阈值、模型和提示词版本记录格式。 - 外部管理后台的服务账号认证方式和调用频率。