feat: harden deployment and public api handoff
This commit is contained in:
@@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
这是 `智念AIGC平台` 的 Web 极简 MVP。当前产品只保留核心闭环:统一创作图片/视频、查看结果、局部重绘、智能超清和必要设置。
|
这是 `智念AIGC平台` 的 Web 极简 MVP。当前产品只保留核心闭环:统一创作图片/视频、查看结果、局部重绘、智能超清和必要设置。
|
||||||
|
|
||||||
|
运维部署与 API 对接:
|
||||||
|
|
||||||
|
- [部署说明](./docs/DEPLOYMENT.md)
|
||||||
|
- [开放 API 对接说明](./docs/API.md)
|
||||||
|
- OpenAPI:`GET /api/v1/openapi.json`
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
服务器一键部署:
|
服务器一键部署:
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
智念AIGC平台是一个面向图片与视频创作的 Web 工作台。当前版本聚焦核心生产链路:提示词创作、素材上传、图片生成、视频生成、局部重绘、智能超清、历史资产管理和接口配置。
|
智念AIGC平台是一个面向图片与视频创作的 Web 工作台。当前版本聚焦核心生产链路:提示词创作、素材上传、图片生成、视频生成、局部重绘、智能超清、历史资产管理和接口配置。
|
||||||
|
|
||||||
|
## 运维与对接文档
|
||||||
|
|
||||||
|
- [部署说明](./docs/DEPLOYMENT.md)
|
||||||
|
- [开放 API 对接说明](./docs/API.md)
|
||||||
|
- OpenAPI JSON:`GET /api/v1/openapi.json`
|
||||||
|
|
||||||
## 功能概览
|
## 功能概览
|
||||||
|
|
||||||
- 统一创作入口:`/create`
|
- 统一创作入口:`/create`
|
||||||
|
|||||||
34
app/api/v1/assets/[id]/download/route.ts
Normal file
34
app/api/v1/assets/[id]/download/route.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
18
app/api/v1/assets/[id]/route.ts
Normal file
18
app/api/v1/assets/[id]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
293
docs/API.md
Normal 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
162
docs/DEPLOYMENT.md
Normal 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 密钥按业务需要配置完成
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
45
lib/server/public-api-assets.ts
Normal file
45
lib/server/public-api-assets.ts
Normal 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}`;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
}
|
||||||
|
|||||||
23
progress.md
23
progress.md
@@ -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 |
|
||||||
|-----------|-------|---------|------------|
|
|-----------|-------|---------|------------|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
Reference in New Issue
Block a user