feat: harden deployment and public api handoff

This commit is contained in:
inman
2026-05-29 14:00:39 +08:00
parent 63e62d444c
commit 4b21d2999c
16 changed files with 961 additions and 19 deletions

View File

@@ -4,6 +4,12 @@
这是 `智念AIGC平台` 的 Web 极简 MVP。当前产品只保留核心闭环统一创作图片/视频、查看结果、局部重绘、智能超清和必要设置。 这是 `智念AIGC平台` 的 Web 极简 MVP。当前产品只保留核心闭环统一创作图片/视频、查看结果、局部重绘、智能超清和必要设置。
运维部署与 API 对接:
- [部署说明](./docs/DEPLOYMENT.md)
- [开放 API 对接说明](./docs/API.md)
- OpenAPI`GET /api/v1/openapi.json`
## 启动 ## 启动
服务器一键部署: 服务器一键部署:

View File

@@ -2,6 +2,12 @@
智念AIGC平台是一个面向图片与视频创作的 Web 工作台。当前版本聚焦核心生产链路:提示词创作、素材上传、图片生成、视频生成、局部重绘、智能超清、历史资产管理和接口配置。 智念AIGC平台是一个面向图片与视频创作的 Web 工作台。当前版本聚焦核心生产链路:提示词创作、素材上传、图片生成、视频生成、局部重绘、智能超清、历史资产管理和接口配置。
## 运维与对接文档
- [部署说明](./docs/DEPLOYMENT.md)
- [开放 API 对接说明](./docs/API.md)
- OpenAPI JSON`GET /api/v1/openapi.json`
## 功能概览 ## 功能概览
- 统一创作入口:`/create` - 统一创作入口:`/create`

View File

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

View File

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

View File

@@ -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 { jsonOk, readJsonBody } from "@/lib/server/api";
import { authenticatePublicApiRequest } from "@/lib/server/public-api-auth"; 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 { publicApiError } from "@/lib/server/public-api-response";
import { DEFAULT_OWNER_ID, requestOrigin } from "@/lib/server/runtime"; import { DEFAULT_OWNER_ID, requestOrigin } from "@/lib/server/runtime";
import { saveUploadAsset } from "@/lib/server/storage"; import { saveUploadAsset } from "@/lib/server/storage";
@@ -10,8 +11,8 @@ export const runtime = "nodejs";
export async function GET(request: Request) { export async function GET(request: Request) {
try { try {
authenticatePublicApiRequest(request); const client = authenticatePublicApiRequest(request);
return jsonOk({ assets: await listAssets(DEFAULT_OWNER_ID) }); return jsonOk({ assets: await listPublicApiAssets(client.id) });
} catch (error) { } catch (error) {
return publicApiError(error); return publicApiError(error);
} }

View File

@@ -1,47 +1,355 @@
import { jsonOk } from "@/lib/server/api"; import { jsonOk } from "@/lib/server/api";
import { requestOrigin } from "@/lib/server/runtime";
export const runtime = "nodejs"; export const runtime = "nodejs";
export async function GET() { export async function GET(request: Request) {
return jsonOk({ return jsonOk({
openapi: "3.1.0", openapi: "3.1.0",
info: { info: {
title: "智念AIGC平台 Public API", 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: [] }], security: [{ bearerApiKey: [] }, { headerApiKey: [] }],
components: { components: {
securitySchemes: { securitySchemes: {
bearerApiKey: { type: "http", scheme: "bearer" }, bearerApiKey: { type: "http", scheme: "bearer" },
headerApiKey: { type: "apiKey", in: "header", name: "X-Zhinian-Api-Key" } 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: { paths: {
"/api/v1/capabilities": { "/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": { "/api/v1/assets": {
get: { summary: "List assets", responses: { "200": { description: "Assets" } } }, get: {
post: { summary: "Upload files or register an external asset URL", responses: { "201": { description: "Created asset" } } } 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": { "/api/v1/jobs": {
get: { summary: "List jobs", responses: { "200": { description: "Jobs" } } }, get: {
post: { summary: "Create a queued generation job", responses: { "202": { description: "Queued job" }, "409": { description: "Idempotency conflict" } } } 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}": { "/api/v1/jobs/{id}": {
get: { get: {
summary: "Get one job", summary: "Get one job",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], parameters: [pathId()],
responses: { "200": { description: "Job" }, "404": { description: "Not found" } } responses: {
"200": jsonResponse("Job", {
type: "object",
properties: {
job: { $ref: "#/components/schemas/GenerationJob" }
}
}),
"404": errorResponse()
}
} }
}, },
"/api/v1/jobs/{id}/cancel": { "/api/v1/jobs/{id}/cancel": {
post: { post: {
summary: "Cancel a queued or running job", summary: "Cancel a queued or running job",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }], parameters: [pathId()],
responses: { "200": { description: "Cancelled job" }, "404": { description: "Not found" } } 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<string, unknown>) {
return { name, in: "query", required: false, schema };
}
function jsonResponse(description: string, schema: Record<string, unknown> = { type: "object", additionalProperties: true }) {
return {
description,
content: {
"application/json": { schema }
}
};
}
function errorResponse() {
return jsonResponse("Error", { $ref: "#/components/schemas/ErrorResponse" });
}

293
docs/API.md Normal file
View File

@@ -0,0 +1,293 @@
# 智念AIGC平台开放 API 对接说明
本文面向服务端对接方。所有开放接口位于 `/api/v1`,使用 API Key 鉴权。
OpenAPI JSON
```text
GET /api/v1/openapi.json
```
## 鉴权
支持两种方式,任选一种:
```http
Authorization: Bearer <API_KEY>
```
或:
```http
X-Zhinian-Api-Key: <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 <API_KEY>" \
-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 <API_KEY>" \
https://你的域名/api/v1/jobs/job_xxx
```
查询任务列表:
```bash
curl -H "Authorization: Bearer <API_KEY>" \
"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 <API_KEY>" \
https://你的域名/api/v1/assets/asset_xxx
```
下载资产:
```bash
curl -L \
-H "Authorization: Bearer <API_KEY>" \
-o result.png \
https://你的域名/api/v1/assets/asset_xxx/download
```
也可以查询当前 API client 可访问的资产列表:
```bash
curl -H "Authorization: Bearer <API_KEY>" \
https://你的域名/api/v1/assets
```
## 上传或注册素材
上传文件:
```bash
curl -X POST https://你的域名/api/v1/assets \
-H "Authorization: Bearer <API_KEY>" \
-F "files=@./reference.png"
```
注册外部 URL
```bash
curl -X POST https://你的域名/api/v1/assets \
-H "Authorization: Bearer <API_KEY>" \
-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=<hex>
```
签名内容是原始请求体 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` 查询并下载资产。

162
docs/DEPLOYMENT.md Normal file
View File

@@ -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 <API_KEY>" 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 密钥按业务需要配置完成

View File

@@ -132,3 +132,7 @@
- Internal Worker processing goes through `/api/internal/worker/tick` protected by `ZHINIAN_INTERNAL_WORKER_TOKEN` in production. - 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`. - 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. - 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:<clientId>` 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`.

View File

@@ -215,7 +215,8 @@ export async function syncImageJob(jobId: string, origin: string): Promise<Gener
source: sourceForCapability(job.capability), source: sourceForCapability(job.capability),
capability: job.capability, capability: job.capability,
jobId: job.id, jobId: job.id,
index index,
tags: assetTagsForJob(job)
})); }));
} }
if (assets.length) { if (assets.length) {
@@ -300,7 +301,8 @@ async function syncEvolinkImageJob(job: GenerationJob, origin: string): Promise<
source: sourceForCapability(job.capability), source: sourceForCapability(job.capability),
capability: job.capability, capability: job.capability,
jobId: job.id, jobId: job.id,
index index,
tags: assetTagsForJob(job)
})); }));
} }
await recordUsageEvent({ await recordUsageEvent({
@@ -346,6 +348,7 @@ async function completeMockJob(job: GenerationJob, origin: string): Promise<Gene
source: sourceForCapability(job.capability), source: sourceForCapability(job.capability),
capability: job.capability, capability: job.capability,
jobId: job.id, jobId: job.id,
tags: assetTagsForJob(job),
metadata: { metadata: {
mock: true mock: true
} }
@@ -376,6 +379,10 @@ function sourceForCapability(capability: string) {
return "generated"; return "generated";
} }
function assetTagsForJob(job: GenerationJob): string[] {
return job.externalClientId ? [job.capability, `api-client:${job.externalClientId}`] : [job.capability];
}
function asRecord(value: unknown): Record<string, unknown> { function asRecord(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown> ? value as Record<string, unknown>

View File

@@ -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<Asset[]> {
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<Asset | null> {
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<string>): boolean {
return asset.ownerId === DEFAULT_OWNER_ID && (
asset.tags.includes(apiClientTag(clientId)) ||
accessibleIds.has(asset.id)
);
}
function assetIdsFromJobs(jobs: GenerationJob[]): Set<string> {
return new Set(jobs.flatMap((job) => [...job.inputAssetIds, ...job.outputAssetIds]));
}
function apiClientTag(clientId: string): string {
return `api-client:${clientId}`;
}

View File

@@ -87,6 +87,7 @@ export async function importRemoteImageAsAsset(input: {
capability: string; capability: string;
jobId: string; jobId: string;
index: number; index: number;
tags?: string[];
}): Promise<Asset> { }): Promise<Asset> {
const response = await fetch(input.url); const response = await fetch(input.url);
if (!response.ok) { if (!response.ok) {
@@ -104,6 +105,7 @@ export async function importRemoteImageAsAsset(input: {
source: input.source, source: input.source,
capability: input.capability, capability: input.capability,
jobId: input.jobId, jobId: input.jobId,
tags: input.tags,
metadata: { metadata: {
importedFrom: input.url importedFrom: input.url
} }
@@ -119,6 +121,7 @@ export async function importRemoteAssetAsAsset(input: {
jobId: string; jobId: string;
index: number; index: number;
fallbackContentType?: string; fallbackContentType?: string;
tags?: string[];
}): Promise<Asset> { }): Promise<Asset> {
const response = await fetch(input.url); const response = await fetch(input.url);
if (!response.ok) { if (!response.ok) {
@@ -136,6 +139,7 @@ export async function importRemoteAssetAsAsset(input: {
source: input.source, source: input.source,
capability: input.capability, capability: input.capability,
jobId: input.jobId, jobId: input.jobId,
tags: input.tags,
metadata: { metadata: {
importedFrom: input.url importedFrom: input.url
} }

View File

@@ -131,7 +131,8 @@ export async function syncVideoJob(jobId: string, origin: string): Promise<Gener
capability: "video.generate", capability: "video.generate",
jobId: job.id, jobId: job.id,
index: 0, index: 0,
fallbackContentType: "video/mp4" fallbackContentType: "video/mp4",
tags: assetTagsForJob(job)
}); });
await recordUsageEvent({ await recordUsageEvent({
ownerId: job.ownerId, ownerId: job.ownerId,
@@ -171,7 +172,7 @@ async function completeMockVideoJob(job: GenerationJob): Promise<GenerationJob>
name: `mock-video-${job.id}.mp4`, name: `mock-video-${job.id}.mp4`,
url: "/mock/seedance-mock.mp4", url: "/mock/seedance-mock.mp4",
source: "generated", source: "generated",
tags: ["video.generate", "mock"], tags: [...assetTagsForJob(job), "mock"],
metadata: { metadata: {
mock: true, mock: true,
capability: "video.generate", capability: "video.generate",
@@ -203,3 +204,7 @@ function asRecord(value: unknown): Record<string, unknown> {
? value as Record<string, unknown> ? value as Record<string, unknown>
: {}; : {};
} }
function assetTagsForJob(job: GenerationJob): string[] {
return job.externalClientId ? ["video.generate", `api-client:${job.externalClientId}`] : ["video.generate"];
}

View File

@@ -272,6 +272,29 @@
| Local health | `curl --noproxy '*' /api/health` | `ok: true` | `ok: true` | pass | | 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 | | 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 ## Error Log - Server Deployment Support
| Timestamp | Error | Attempt | Resolution | | Timestamp | Error | Attempt | Resolution |
|-----------|-------|---------|------------| |-----------|-------|---------|------------|

View File

@@ -37,6 +37,23 @@ fi
"${COMPOSE[@]}" up -d --build "${COMPOSE[@]}" up -d --build
"${COMPOSE[@]}" ps "${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] 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] 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"

View File

@@ -77,6 +77,15 @@ Complete - latest update: Task management and public API v1
- [x] Add focused tests and run verification commands - [x] Add focused tests and run verification commands
- **Status:** complete - **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 ## Key Questions
1. How should the selected image engine be stored and exposed in settings? 1. How should the selected image engine be stored and exposed in settings?
2. Which current capabilities should EvoLink handle first? 2. Which current capabilities should EvoLink handle first?