From 4b21d2999c596742eac0628c094dbab521a77cee Mon Sep 17 00:00:00 2001 From: inman Date: Fri, 29 May 2026 14:00:39 +0800 Subject: [PATCH] feat: harden deployment and public api handoff --- README.md | 6 + README.zh-CN.md | 6 + app/api/v1/assets/[id]/download/route.ts | 34 +++ app/api/v1/assets/[id]/route.ts | 18 ++ app/api/v1/assets/route.ts | 7 +- app/api/v1/openapi.json/route.ts | 330 ++++++++++++++++++++++- docs/API.md | 293 ++++++++++++++++++++ docs/DEPLOYMENT.md | 162 +++++++++++ findings.md | 4 + lib/server/generation-service.ts | 11 +- lib/server/public-api-assets.ts | 45 ++++ lib/server/storage.ts | 4 + lib/server/video-generation-service.ts | 9 +- progress.md | 23 ++ scripts/deploy.sh | 19 +- task_plan.md | 9 + 16 files changed, 961 insertions(+), 19 deletions(-) create mode 100644 app/api/v1/assets/[id]/download/route.ts create mode 100644 app/api/v1/assets/[id]/route.ts create mode 100644 docs/API.md create mode 100644 docs/DEPLOYMENT.md create mode 100644 lib/server/public-api-assets.ts diff --git a/README.md b/README.md index 9cb4e87..6f5f38e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ 这是 `智念AIGC平台` 的 Web 极简 MVP。当前产品只保留核心闭环:统一创作图片/视频、查看结果、局部重绘、智能超清和必要设置。 +运维部署与 API 对接: + +- [部署说明](./docs/DEPLOYMENT.md) +- [开放 API 对接说明](./docs/API.md) +- OpenAPI:`GET /api/v1/openapi.json` + ## 启动 服务器一键部署: diff --git a/README.zh-CN.md b/README.zh-CN.md index 4bcaa50..37a2357 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,6 +2,12 @@ 智念AIGC平台是一个面向图片与视频创作的 Web 工作台。当前版本聚焦核心生产链路:提示词创作、素材上传、图片生成、视频生成、局部重绘、智能超清、历史资产管理和接口配置。 +## 运维与对接文档 + +- [部署说明](./docs/DEPLOYMENT.md) +- [开放 API 对接说明](./docs/API.md) +- OpenAPI JSON:`GET /api/v1/openapi.json` + ## 功能概览 - 统一创作入口:`/create` diff --git a/app/api/v1/assets/[id]/download/route.ts b/app/api/v1/assets/[id]/download/route.ts new file mode 100644 index 0000000..0c81966 --- /dev/null +++ b/app/api/v1/assets/[id]/download/route.ts @@ -0,0 +1,34 @@ +import { jsonError } from "@/lib/server/api"; +import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; +import { getPublicApiAsset } from "@/lib/server/public-api-assets"; +import { publicApiError } from "@/lib/server/public-api-response"; +import { readAssetForDownload } from "@/lib/server/storage"; + +export const runtime = "nodejs"; + +export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { + try { + const client = authenticatePublicApiRequest(request); + const { id } = await context.params; + const asset = await getPublicApiAsset(client.id, id); + if (!asset) return jsonError("Asset not found.", 404); + const file = await readAssetForDownload(asset); + if (!file) return jsonError("Asset file is not downloadable.", 404); + return new Response(new Uint8Array(file.bytes), { + headers: { + "Content-Type": file.contentType, + "Content-Length": String(file.bytes.length), + "Content-Disposition": contentDisposition(asset.name), + "Cache-Control": "private, no-store" + } + }); + } catch (error) { + return publicApiError(error); + } +} + +function contentDisposition(fileName: string): string { + const clean = fileName.replace(/[\r\n/\\]/g, "_").trim() || "download"; + const ascii = clean.replace(/[^\x20-\x7E]/g, "_").replace(/"/g, "_"); + return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(clean)}`; +} diff --git a/app/api/v1/assets/[id]/route.ts b/app/api/v1/assets/[id]/route.ts new file mode 100644 index 0000000..268b4bc --- /dev/null +++ b/app/api/v1/assets/[id]/route.ts @@ -0,0 +1,18 @@ +import { jsonError, jsonOk } from "@/lib/server/api"; +import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; +import { getPublicApiAsset } from "@/lib/server/public-api-assets"; +import { publicApiError } from "@/lib/server/public-api-response"; + +export const runtime = "nodejs"; + +export async function GET(request: Request, context: { params: Promise<{ id: string }> }) { + try { + const client = authenticatePublicApiRequest(request); + const { id } = await context.params; + const asset = await getPublicApiAsset(client.id, id); + if (!asset) return jsonError("Asset not found.", 404); + return jsonOk({ asset }); + } catch (error) { + return publicApiError(error); + } +} diff --git a/app/api/v1/assets/route.ts b/app/api/v1/assets/route.ts index b87310a..9e16212 100644 --- a/app/api/v1/assets/route.ts +++ b/app/api/v1/assets/route.ts @@ -1,6 +1,7 @@ -import { createAsset, listAssets } from "@/lib/server/data-store"; +import { createAsset } from "@/lib/server/data-store"; import { jsonOk, readJsonBody } from "@/lib/server/api"; import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; +import { listPublicApiAssets } from "@/lib/server/public-api-assets"; import { publicApiError } from "@/lib/server/public-api-response"; import { DEFAULT_OWNER_ID, requestOrigin } from "@/lib/server/runtime"; import { saveUploadAsset } from "@/lib/server/storage"; @@ -10,8 +11,8 @@ export const runtime = "nodejs"; export async function GET(request: Request) { try { - authenticatePublicApiRequest(request); - return jsonOk({ assets: await listAssets(DEFAULT_OWNER_ID) }); + const client = authenticatePublicApiRequest(request); + return jsonOk({ assets: await listPublicApiAssets(client.id) }); } catch (error) { return publicApiError(error); } diff --git a/app/api/v1/openapi.json/route.ts b/app/api/v1/openapi.json/route.ts index 829afd3..e5a4ccf 100644 --- a/app/api/v1/openapi.json/route.ts +++ b/app/api/v1/openapi.json/route.ts @@ -1,47 +1,355 @@ import { jsonOk } from "@/lib/server/api"; +import { requestOrigin } from "@/lib/server/runtime"; export const runtime = "nodejs"; -export async function GET() { +export async function GET(request: Request) { return jsonOk({ openapi: "3.1.0", info: { title: "智念AIGC平台 Public API", - version: "1.0.0" + version: "1.0.0", + description: "Public server-to-server API for uploading assets, creating image/video generation jobs, polling job status, downloading outputs, and receiving webhooks." }, + servers: [ + { url: requestOrigin(request), description: "Current deployment" } + ], security: [{ bearerApiKey: [] }, { headerApiKey: [] }], components: { securitySchemes: { bearerApiKey: { type: "http", scheme: "bearer" }, headerApiKey: { type: "apiKey", in: "header", name: "X-Zhinian-Api-Key" } + }, + schemas: { + ErrorResponse: { + type: "object", + required: ["error"], + properties: { + error: { type: "string" } + } + }, + Asset: { + type: "object", + required: ["id", "kind", "name", "url", "source", "createdAt"], + properties: { + id: { type: "string", example: "asset_mpqe9g85_12635f8cd8" }, + ownerId: { type: "string" }, + kind: { type: "string", enum: ["image", "video", "mask", "reference", "other"] }, + name: { type: "string", example: "result.png" }, + url: { type: "string", format: "uri" }, + storagePath: { type: "string" }, + source: { type: "string", enum: ["upload", "generated", "edited", "upscaled", "external", "seed"] }, + tags: { type: "array", items: { type: "string" } }, + metadata: { type: "object", additionalProperties: true }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" } + } + }, + GenerationJob: { + type: "object", + required: ["id", "capability", "provider", "status", "createdAt", "updatedAt"], + properties: { + id: { type: "string", example: "job_mpqe3wtt_12ed738079" }, + externalClientId: { type: "string" }, + capability: { $ref: "#/components/schemas/GenerationCapability" }, + provider: { type: "string", enum: ["volcengine-visual", "evolink", "seedance", "mock"] }, + reqKey: { type: "string" }, + status: { $ref: "#/components/schemas/GenerationStatus" }, + prompt: { type: "string" }, + inputAssetIds: { type: "array", items: { type: "string" } }, + inputUrls: { type: "array", items: { type: "string", format: "uri" } }, + outputAssetIds: { type: "array", items: { type: "string" } }, + providerTaskId: { type: "string" }, + error: { + type: "object", + properties: { + code: { oneOf: [{ type: "string" }, { type: "number" }] }, + message: { type: "string" }, + retryable: { type: "boolean" } + } + }, + idempotencyKey: { type: "string" }, + priority: { type: "integer" }, + attempts: { type: "integer" }, + scheduledAt: { type: "string", format: "date-time" }, + completedAt: { type: "string", format: "date-time" }, + webhookUrl: { type: "string", format: "uri" }, + webhookAttempts: { type: "integer" }, + webhookLastStatus: { type: "object", additionalProperties: true }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" } + } + }, + GenerationCapability: { + type: "string", + enum: ["image.generate", "image.inpaint", "image.upscale", "video.generate"] + }, + GenerationStatus: { + type: "string", + enum: ["queued", "running", "succeeded", "failed", "expired", "cancelled"] + }, + PromptMaterial: { + type: "object", + properties: { + id: { type: "string" }, + url: { type: "string", format: "uri" }, + type: { type: "string", enum: ["image", "video", "audio"] }, + role: { type: "string" }, + label: { type: "string" }, + name: { type: "string" } + } + }, + CreateJobRequest: { + type: "object", + required: ["capability"], + properties: { + capability: { $ref: "#/components/schemas/GenerationCapability" }, + prompt: { type: "string", description: "Prompt text. Required for image.generate unless promptAssembly is supplied." }, + inputUrls: { type: "array", items: { type: "string", format: "uri" }, description: "Reference image URLs for image capabilities." }, + imageUrls: { type: "array", items: { type: "string", format: "uri" }, description: "Alias for image input URLs." }, + inputAssetIds: { type: "array", items: { type: "string" } }, + materials: { type: "array", items: { $ref: "#/components/schemas/PromptMaterial" } }, + settings: { + type: "object", + description: "Video settings for video.generate.", + properties: { + ratio: { type: "string", enum: ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"] }, + duration: { type: "integer", minimum: 4, maximum: 15 }, + resolution: { type: "string", enum: ["480p", "720p", "1080p"] } + } + }, + width: { type: "integer", example: 1440 }, + height: { type: "integer", example: 2560 }, + scale: { type: "number", minimum: 1, maximum: 100 }, + force_single: { type: "boolean" }, + resolution: { type: "string", enum: ["4k", "8k"], description: "Upscale resolution for image.upscale." }, + seed: { type: "integer" }, + priority: { type: "integer", minimum: -100, maximum: 100 }, + webhookUrl: { type: "string", format: "uri" }, + idempotencyKey: { type: "string", description: "Optional body-level idempotency key. Header Idempotency-Key is preferred." } + } + }, + RegisterAssetRequest: { + type: "object", + required: ["url"], + properties: { + url: { type: "string", format: "uri" }, + name: { type: "string" }, + kind: { type: "string", enum: ["image", "video", "mask", "reference", "other"] }, + tags: { type: "array", items: { type: "string" } } + } + }, + WebhookPayload: { + type: "object", + required: ["event", "job"], + properties: { + event: { type: "string", example: "generation.succeeded" }, + job: { $ref: "#/components/schemas/GenerationJob" } + } + } } }, paths: { "/api/v1/capabilities": { - get: { summary: "List generation capabilities", responses: { "200": { description: "Capabilities" } } } + get: { + summary: "List generation capabilities", + responses: { + "200": jsonResponse("Capabilities and active providers") + } + } }, "/api/v1/assets": { - get: { summary: "List assets", responses: { "200": { description: "Assets" } } }, - post: { summary: "Upload files or register an external asset URL", responses: { "201": { description: "Created asset" } } } + get: { + summary: "List assets visible to the authenticated API client", + responses: { + "200": jsonResponse("Assets", { + type: "object", + properties: { + assets: { type: "array", items: { $ref: "#/components/schemas/Asset" } } + } + }) + } + }, + post: { + summary: "Upload files or register an external asset URL", + requestBody: { + required: true, + content: { + "application/json": { schema: { $ref: "#/components/schemas/RegisterAssetRequest" } }, + "multipart/form-data": { + schema: { + type: "object", + properties: { + files: { type: "array", items: { type: "string", format: "binary" } } + } + } + } + } + }, + responses: { + "201": jsonResponse("Created asset"), + "400": errorResponse(), + "401": errorResponse() + } + } + }, + "/api/v1/assets/{id}": { + get: { + summary: "Get one asset visible to the authenticated API client", + parameters: [pathId()], + responses: { + "200": jsonResponse("Asset", { + type: "object", + properties: { + asset: { $ref: "#/components/schemas/Asset" } + } + }), + "404": errorResponse() + } + } + }, + "/api/v1/assets/{id}/download": { + get: { + summary: "Download an output or uploaded asset", + parameters: [pathId()], + responses: { + "200": { + description: "Binary file", + headers: { + "Content-Disposition": { schema: { type: "string" } }, + "Content-Length": { schema: { type: "string" } } + }, + content: { + "application/octet-stream": { schema: { type: "string", format: "binary" } }, + "image/png": { schema: { type: "string", format: "binary" } }, + "image/jpeg": { schema: { type: "string", format: "binary" } }, + "video/mp4": { schema: { type: "string", format: "binary" } } + } + }, + "404": errorResponse() + } + } }, "/api/v1/jobs": { - get: { summary: "List jobs", responses: { "200": { description: "Jobs" } } }, - post: { summary: "Create a queued generation job", responses: { "202": { description: "Queued job" }, "409": { description: "Idempotency conflict" } } } + get: { + summary: "List jobs for the authenticated API client", + parameters: [ + queryParam("status", { $ref: "#/components/schemas/GenerationStatus" }), + queryParam("capability", { $ref: "#/components/schemas/GenerationCapability" }), + queryParam("limit", { type: "integer", minimum: 1, maximum: 200 }), + queryParam("before", { type: "string", format: "date-time" }) + ], + responses: { + "200": jsonResponse("Jobs", { + type: "object", + properties: { + jobs: { type: "array", items: { $ref: "#/components/schemas/GenerationJob" } } + } + }) + } + }, + post: { + summary: "Create a queued generation job", + parameters: [ + { + name: "Idempotency-Key", + in: "header", + required: false, + schema: { type: "string" }, + description: "Reuse the same key for safe retries with the same request body." + } + ], + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CreateJobRequest" }, + examples: { + imageGenerate: { + summary: "Image generation", + value: { + capability: "image.generate", + prompt: "生成一张专业产品主图", + width: 1440, + height: 2560, + webhookUrl: "https://example.com/zhinian/webhook" + } + }, + videoGenerate: { + summary: "Video generation", + value: { + capability: "video.generate", + prompt: "生成一条 9:16 品牌短视频", + settings: { ratio: "9:16", duration: 5, resolution: "720p" } + } + } + } + } + } + }, + responses: { + "202": jsonResponse("Queued job", { + type: "object", + properties: { + job: { $ref: "#/components/schemas/GenerationJob" }, + reused: { type: "boolean" } + } + }), + "409": errorResponse() + } + } }, "/api/v1/jobs/{id}": { get: { summary: "Get one job", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], - responses: { "200": { description: "Job" }, "404": { description: "Not found" } } + parameters: [pathId()], + responses: { + "200": jsonResponse("Job", { + type: "object", + properties: { + job: { $ref: "#/components/schemas/GenerationJob" } + } + }), + "404": errorResponse() + } } }, "/api/v1/jobs/{id}/cancel": { post: { summary: "Cancel a queued or running job", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], - responses: { "200": { description: "Cancelled job" }, "404": { description: "Not found" } } + parameters: [pathId()], + responses: { + "200": jsonResponse("Cancelled job", { + type: "object", + properties: { + job: { $ref: "#/components/schemas/GenerationJob" } + } + }), + "404": errorResponse() + } } } } }); } + +function pathId() { + return { name: "id", in: "path", required: true, schema: { type: "string" } }; +} + +function queryParam(name: string, schema: Record) { + return { name, in: "query", required: false, schema }; +} + +function jsonResponse(description: string, schema: Record = { type: "object", additionalProperties: true }) { + return { + description, + content: { + "application/json": { schema } + } + }; +} + +function errorResponse() { + return jsonResponse("Error", { $ref: "#/components/schemas/ErrorResponse" }); +} diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..3934e20 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,293 @@ +# 智念AIGC平台开放 API 对接说明 + +本文面向服务端对接方。所有开放接口位于 `/api/v1`,使用 API Key 鉴权。 + +OpenAPI JSON: + +```text +GET /api/v1/openapi.json +``` + +## 鉴权 + +支持两种方式,任选一种: + +```http +Authorization: Bearer +``` + +或: + +```http +X-Zhinian-Api-Key: +``` + +服务端配置示例: + +```env +ZHINIAN_API_KEYS=partner-a:key-a,partner-b:key-b +``` + +冒号前是 clientId,冒号后是 API Key。任务和资产会按 clientId 做基本隔离。 + +## 任务生命周期 + +创建任务后不会同步生成结果,而是进入任务队列: + +```text +queued -> running -> succeeded + -> failed + -> expired + -> cancelled +``` + +必须运行 Worker: + +```bash +npm run worker +``` + +或 Docker Compose 中的 `zhinian-worker` 服务。 + +## 创建任务 + +```bash +curl -X POST https://你的域名/api/v1/jobs \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: demo-job-001" \ + -d '{ + "capability": "image.generate", + "prompt": "生成一张专业产品主图", + "width": 1440, + "height": 2560, + "webhookUrl": "https://example.com/zhinian/webhook" + }' +``` + +响应: + +```json +{ + "job": { + "id": "job_xxx", + "capability": "image.generate", + "status": "queued", + "outputAssetIds": [] + }, + "reused": false +} +``` + +### 支持的 capability + +| capability | 说明 | +| --- | --- | +| `image.generate` | 图片生成 | +| `image.inpaint` | 局部重绘 | +| `image.upscale` | 智能超清 | +| `video.generate` | Seedance 视频生成 | + +## 查询任务 + +查询单个任务: + +```bash +curl -H "Authorization: Bearer " \ + https://你的域名/api/v1/jobs/job_xxx +``` + +查询任务列表: + +```bash +curl -H "Authorization: Bearer " \ + "https://你的域名/api/v1/jobs?status=succeeded&limit=20" +``` + +可用筛选: + +- `status`:`queued`、`running`、`succeeded`、`failed`、`expired`、`cancelled` +- `capability`:见上表 +- `limit`:`1` 到 `200` +- `before`:ISO 时间,用于翻页 + +## 获取输出资产 + +任务成功后,`job.outputAssetIds` 会包含输出资产 ID。 + +查询资产: + +```bash +curl -H "Authorization: Bearer " \ + https://你的域名/api/v1/assets/asset_xxx +``` + +下载资产: + +```bash +curl -L \ + -H "Authorization: Bearer " \ + -o result.png \ + https://你的域名/api/v1/assets/asset_xxx/download +``` + +也可以查询当前 API client 可访问的资产列表: + +```bash +curl -H "Authorization: Bearer " \ + https://你的域名/api/v1/assets +``` + +## 上传或注册素材 + +上传文件: + +```bash +curl -X POST https://你的域名/api/v1/assets \ + -H "Authorization: Bearer " \ + -F "files=@./reference.png" +``` + +注册外部 URL: + +```bash +curl -X POST https://你的域名/api/v1/assets \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://example.com/reference.png", + "name": "reference.png", + "kind": "image" + }' +``` + +返回的资产 ID 可放入后续任务的 `inputAssetIds`,资产 URL 可放入 `inputUrls` 或 `imageUrls`。 + +## 图片任务示例 + +图片生成: + +```json +{ + "capability": "image.generate", + "prompt": "参考 @图片1 的风格,生成 9:16 旅游海报", + "imageUrls": ["https://example.com/reference.png"], + "width": 1440, + "height": 2560, + "force_single": true +} +``` + +智能超清: + +```json +{ + "capability": "image.upscale", + "imageUrls": ["https://example.com/input.png"], + "resolution": "4k" +} +``` + +局部重绘: + +```json +{ + "capability": "image.inpaint", + "prompt": "把选中区域文字替换成新品上市", + "imageUrls": [ + "https://example.com/original.png", + "https://example.com/mask.png" + ] +} +``` + +## 视频任务示例 + +```json +{ + "capability": "video.generate", + "prompt": "生成一条 9:16 品牌短视频,节奏明快,适合信息流投放", + "settings": { + "ratio": "9:16", + "duration": 5, + "resolution": "720p" + }, + "materials": [ + { + "type": "image", + "url": "https://example.com/product.png", + "label": "@图片1" + } + ] +} +``` + +视频参数限制: + +- `duration`:`4` 到 `15` 秒 +- `ratio`:`16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive` +- `resolution`:`480p`、`720p`、`1080p` + +## 幂等 + +建议所有创建任务请求都带: + +```http +Idempotency-Key: <业务唯一请求ID> +``` + +同一个 API client 使用相同 key 和相同请求体会返回已有任务;相同 key 但请求体不同会返回 `409`。 + +## Webhook + +创建任务时传 `webhookUrl`。任务进入终态后会回调: + +```json +{ + "event": "generation.succeeded", + "job": { + "id": "job_xxx", + "status": "succeeded", + "outputAssetIds": ["asset_xxx"] + } +} +``` + +如果服务端配置了: + +```env +ZHINIAN_WEBHOOK_SECRET=your-secret +``` + +Webhook 请求会带: + +```http +X-Zhinian-Signature: sha256= +``` + +签名内容是原始请求体 HMAC-SHA256。 + +## 错误格式 + +```json +{ + "error": "Invalid API key." +} +``` + +常见状态码: + +- `400`:请求参数错误 +- `401`:API Key 缺失或错误 +- `404`:任务或资产不存在,或不属于当前 API client +- `409`:幂等 key 冲突 +- `500`:服务端错误或 Worker token 配置错误 + +## 最小对接流程 + +1. 运维提供域名和 API Key。 +2. 对接方调用 `GET /api/v1/capabilities` 确认能力。 +3. 对接方上传素材或注册外部 URL。 +4. 对接方调用 `POST /api/v1/jobs` 创建任务。 +5. 对接方轮询 `GET /api/v1/jobs/:id`,或等待 Webhook。 +6. 任务成功后用 `outputAssetIds` 查询并下载资产。 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..78b6360 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,162 @@ +# 智念AIGC平台部署说明 + +本文面向运维部署。推荐使用 Docker Compose,同一套编排会启动 Web 服务和任务 Worker。 + +## 服务器要求 + +- Linux 服务器 +- Docker +- Docker Compose v2(`docker compose`)或旧版 `docker-compose` +- 可访问外网供应商接口:火山 Visual、EvoLink、Seedance、OSS + +## 一键部署 + +```bash +git clone <仓库地址> +cd NianAIGC +bash scripts/deploy.sh +``` + +脚本会自动: + +- 从 `.env.example` 创建 `.env.local`(如果不存在) +- 创建 `.runtime/data`、`.runtime/uploads`、`.runtime/generated-results` +- 构建镜像 +- 启动 `zhinian-aigc` Web 服务 +- 启动 `zhinian-worker` 任务 Worker +- 输出容器状态 + +默认访问: + +```text +http://服务器IP:3000 +``` + +## 必填生产配置 + +部署前编辑 `.env.local`: + +```env +APP_PORT=3000 +PORT=3000 +HOSTNAME=0.0.0.0 +NEXT_PUBLIC_APP_URL=https://你的域名 + +ZHINIAN_API_KEYS=partner-a:请替换为强随机key +ZHINIAN_INTERNAL_WORKER_TOKEN=请替换为强随机token +ZHINIAN_WEBHOOK_SECRET=请替换为webhook签名密钥 +``` + +真实生成能力按需配置: + +```env +IMAGE_GENERATE_ENGINE=evolink +IMAGE_INPAINT_ENGINE=jimeng + +EVOLINK_API_KEY= + +VOLCENGINE_ACCESS_KEY_ID= +VOLCENGINE_SECRET_ACCESS_KEY= + +SEEDANCE_API_KEY= + +ALI_OSS_ENDPOINT= +ALI_OSS_BUCKET= +ALI_OSS_ACCESS_KEY_ID= +ALI_OSS_ACCESS_KEY_SECRET= +ALI_OSS_PUBLIC_BASE_URL= +``` + +如果不配置真实供应商密钥,mock 配置会保留本地验收能力,但生产对接应配置真实密钥。 + +## 常用运维命令 + +```bash +docker compose ps +docker compose logs -f zhinian-aigc +docker compose logs -f zhinian-worker +docker compose restart +docker compose down +``` + +更新部署: + +```bash +git pull +bash scripts/deploy.sh +``` + +健康检查: + +```bash +curl -f http://127.0.0.1:${APP_PORT:-3000}/api/health +``` + +OpenAPI: + +```bash +curl http://127.0.0.1:${APP_PORT:-3000}/api/v1/openapi.json +``` + +## 数据持久化 + +Docker Compose 会挂载: + +```text +./.runtime:/app/.runtime +``` + +本地 JSON 数据层、上传文件和生成结果都会放在 `.runtime/` 下。生产环境如果未启用 Supabase/Postgres,请定期备份该目录。 + +建议备份: + +```bash +tar -czf zhinian-runtime-$(date +%Y%m%d%H%M%S).tar.gz .runtime +``` + +## 服务组成 + +- `zhinian-aigc`:Next.js Web/API 服务,默认容器端口 `3000` +- `zhinian-worker`:后台任务 Worker,负责提交供应商任务、轮询结果、导入资产和触发 Webhook + +注意:只启动 Web 服务时,任务会停留在 `queued` 或 `running`,必须同时运行 Worker。 + +## 反向代理建议 + +Nginx 示例: + +```nginx +server { + listen 80; + server_name your-domain.com; + + client_max_body_size 100m; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +配置反向代理后,将 `.env.local` 里的 `NEXT_PUBLIC_APP_URL` 设置成公网 HTTPS 地址。 + +## 验收清单 + +部署后执行: + +```bash +curl -f https://你的域名/api/health +curl -H "Authorization: Bearer " https://你的域名/api/v1/capabilities +curl https://你的域名/api/v1/openapi.json +``` + +确认: + +- Web 页面可访问 +- `/api/health` 返回 `ok: true` +- `/api/v1/capabilities` 使用 API Key 可访问 +- `zhinian-worker` 日志持续输出 `claimed=...` +- OSS、EvoLink、火山、Seedance 密钥按业务需要配置完成 diff --git a/findings.md b/findings.md index 54db6e0..9cb11f6 100644 --- a/findings.md +++ b/findings.md @@ -132,3 +132,7 @@ - Internal Worker processing goes through `/api/internal/worker/tick` protected by `ZHINIAN_INTERNAL_WORKER_TOKEN` in production. - API v1 routes are `/api/v1/capabilities`, `/api/v1/assets`, `/api/v1/jobs`, `/api/v1/jobs/:id`, `/api/v1/jobs/:id/cancel`, and `/api/v1/openapi.json`. - Local verification created a public API job and processed it to `succeeded` through `npm run worker:once` in mock mode. +- Public API asset access now includes `/api/v1/assets/:id` and `/api/v1/assets/:id/download`. +- Uploaded and generated assets created through public API flows are tagged as `api-client:` so integrations can query and download their own results later. +- OpenAPI is generated dynamically from the current deployment origin at `/api/v1/openapi.json`. +- Operations handoff docs live in `docs/DEPLOYMENT.md`; partner API docs live in `docs/API.md`. diff --git a/lib/server/generation-service.ts b/lib/server/generation-service.ts index 0b21a0c..5354da5 100644 --- a/lib/server/generation-service.ts +++ b/lib/server/generation-service.ts @@ -215,7 +215,8 @@ export async function syncImageJob(jobId: string, origin: string): Promise { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record diff --git a/lib/server/public-api-assets.ts b/lib/server/public-api-assets.ts new file mode 100644 index 0000000..490bbb2 --- /dev/null +++ b/lib/server/public-api-assets.ts @@ -0,0 +1,45 @@ +import { getAsset, listAssets, listGenerationJobsFiltered } from "@/lib/server/data-store"; +import { DEFAULT_OWNER_ID } from "@/lib/server/runtime"; +import type { Asset, GenerationJob } from "@/lib/types"; + +const JOB_LOOKUP_LIMIT = 200; + +export async function listPublicApiAssets(clientId: string): Promise { + const [assets, jobs] = await Promise.all([ + listAssets(DEFAULT_OWNER_ID), + listGenerationJobsFiltered({ + ownerId: DEFAULT_OWNER_ID, + externalClientId: clientId, + limit: JOB_LOOKUP_LIMIT + }) + ]); + const accessibleIds = assetIdsFromJobs(jobs); + return assets.filter((asset) => canAccessAsset(clientId, asset, accessibleIds)); +} + +export async function getPublicApiAsset(clientId: string, assetId: string): Promise { + const asset = await getAsset(assetId); + if (!asset || asset.ownerId !== DEFAULT_OWNER_ID) return null; + if (asset.tags.includes(apiClientTag(clientId))) return asset; + const jobs = await listGenerationJobsFiltered({ + ownerId: DEFAULT_OWNER_ID, + externalClientId: clientId, + limit: JOB_LOOKUP_LIMIT + }); + return assetIdsFromJobs(jobs).has(asset.id) ? asset : null; +} + +function canAccessAsset(clientId: string, asset: Asset, accessibleIds: Set): boolean { + return asset.ownerId === DEFAULT_OWNER_ID && ( + asset.tags.includes(apiClientTag(clientId)) || + accessibleIds.has(asset.id) + ); +} + +function assetIdsFromJobs(jobs: GenerationJob[]): Set { + return new Set(jobs.flatMap((job) => [...job.inputAssetIds, ...job.outputAssetIds])); +} + +function apiClientTag(clientId: string): string { + return `api-client:${clientId}`; +} diff --git a/lib/server/storage.ts b/lib/server/storage.ts index f685a5e..17527aa 100644 --- a/lib/server/storage.ts +++ b/lib/server/storage.ts @@ -87,6 +87,7 @@ export async function importRemoteImageAsAsset(input: { capability: string; jobId: string; index: number; + tags?: string[]; }): Promise { const response = await fetch(input.url); if (!response.ok) { @@ -104,6 +105,7 @@ export async function importRemoteImageAsAsset(input: { source: input.source, capability: input.capability, jobId: input.jobId, + tags: input.tags, metadata: { importedFrom: input.url } @@ -119,6 +121,7 @@ export async function importRemoteAssetAsAsset(input: { jobId: string; index: number; fallbackContentType?: string; + tags?: string[]; }): Promise { const response = await fetch(input.url); if (!response.ok) { @@ -136,6 +139,7 @@ export async function importRemoteAssetAsAsset(input: { source: input.source, capability: input.capability, jobId: input.jobId, + tags: input.tags, metadata: { importedFrom: input.url } diff --git a/lib/server/video-generation-service.ts b/lib/server/video-generation-service.ts index 349a52c..aa8e03d 100644 --- a/lib/server/video-generation-service.ts +++ b/lib/server/video-generation-service.ts @@ -131,7 +131,8 @@ export async function syncVideoJob(jobId: string, origin: string): Promise name: `mock-video-${job.id}.mp4`, url: "/mock/seedance-mock.mp4", source: "generated", - tags: ["video.generate", "mock"], + tags: [...assetTagsForJob(job), "mock"], metadata: { mock: true, capability: "video.generate", @@ -203,3 +204,7 @@ function asRecord(value: unknown): Record { ? value as Record : {}; } + +function assetTagsForJob(job: GenerationJob): string[] { + return job.externalClientId ? ["video.generate", `api-client:${job.externalClientId}`] : ["video.generate"]; +} diff --git a/progress.md b/progress.md index bd7b135..79e3d70 100644 --- a/progress.md +++ b/progress.md @@ -272,6 +272,29 @@ | Local health | `curl --noproxy '*' /api/health` | `ok: true` | `ok: true` | pass | | Docker CLI availability | `docker --version` | Docker version if installed | No Docker CLI output in current environment | not run | +## Session: 2026-05-29 - Deployable Handoff and Integration Surface + +### Implementation +- **Status:** complete +- Actions taken: + - Added `docs/DEPLOYMENT.md` for operations deployment, environment variables, health checks, runtime persistence, reverse proxy guidance, and validation checklist. + - Added `docs/API.md` for partner authentication, task lifecycle, job creation, asset upload/register, asset query/download, idempotency, webhook signing, and error handling. + - Linked deployment/API docs and the OpenAPI route from both README files. + - Expanded `/api/v1/openapi.json` to document capabilities, assets, asset download, jobs, job detail, cancel, request schemas, response schemas, and API key auth. + - Added authenticated `/api/v1/assets/:id` and `/api/v1/assets/:id/download` endpoints. + - Restricted public asset listing/detail/download to assets visible to the authenticated API client. + - Added API client tags to uploaded assets and API-generated output assets so long-lived integrations can query/download their own results. + - Added a deploy-script health check and printed API documentation/OpenAPI hints after startup. + +### Verification +- **Status:** complete +- Results: + - `npm test`: 7 files / 21 tests passed. + - `npm run build`: production build succeeded and included `/api/v1/assets/:id`, `/api/v1/assets/:id/download`, and `/api/v1/openapi.json`. + - Local HTTP smoke test with a temporary API key returned `/api/v1/capabilities` and expanded OpenAPI paths. + - Local HTTP smoke test uploaded a PNG through `/api/v1/assets`, fetched `/api/v1/assets/:id`, and downloaded matching binary bytes from `/api/v1/assets/:id/download`. + - Mock-provider task flow created a queued job through `/api/v1/jobs`, Worker processed it to `succeeded`, generated output asset carried the API client tag, and download returned an attachment response. + ## Error Log - Server Deployment Support | Timestamp | Error | Attempt | Resolution | |-----------|-------|---------|------------| diff --git a/scripts/deploy.sh b/scripts/deploy.sh index e2bc4e0..9a6c160 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -37,6 +37,23 @@ fi "${COMPOSE[@]}" up -d --build "${COMPOSE[@]}" ps -echo "[deploy] 智念AIGC平台 is starting at http://127.0.0.1:${APP_PORT:-3000}" +HEALTH_URL="http://127.0.0.1:${APP_PORT:-3000}/api/health" +if command -v curl >/dev/null 2>&1; then + echo "[deploy] Waiting for health check: ${HEALTH_URL}" + for _ in $(seq 1 40); do + if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then + echo "[deploy] Health check passed." + break + fi + sleep 2 + done + if ! curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then + echo "[deploy] Health check did not pass yet. Inspect logs with: ${COMPOSE[*]} logs -f zhinian-aigc" + fi +fi + +echo "[deploy] 智念AIGC平台 is available at http://127.0.0.1:${APP_PORT:-3000}" echo "[deploy] If this is a public server, set NEXT_PUBLIC_APP_URL in .env.local to your domain." echo "[deploy] Web service and zhinian-worker are both managed by Docker Compose." +echo "[deploy] API docs: docs/API.md" +echo "[deploy] OpenAPI: ${HEALTH_URL%/api/health}/api/v1/openapi.json" diff --git a/task_plan.md b/task_plan.md index 95d45b8..7518739 100644 --- a/task_plan.md +++ b/task_plan.md @@ -77,6 +77,15 @@ Complete - latest update: Task management and public API v1 - [x] Add focused tests and run verification commands - **Status:** complete +### Phase 11: Deployable Handoff and Integration Surface +- [x] Add operations-facing deployment documentation +- [x] Add partner-facing API integration documentation +- [x] Expand OpenAPI output for task, asset, upload, download, and webhook flows +- [x] Add authenticated public asset detail and download endpoints +- [x] Tag API-generated assets by client for stable follow-up access +- [x] Verify real HTTP API calls, Worker task processing, tests, and production build +- **Status:** complete + ## Key Questions 1. How should the selected image engine be stored and exposed in settings? 2. Which current capabilities should EvoLink handle first?