feat: harden deployment and public api handoff
This commit is contained in:
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 { 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);
|
||||
}
|
||||
|
||||
@@ -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<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" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user