test
329 行
11 KiB
JavaScript
329 行
11 KiB
JavaScript
// WeHub 标准项目完整 demo 函数入口。
|
||
//
|
||
// 覆盖当前支持的全部 5 种资源,每资源一个最小端点:
|
||
// - GET /api/cloud/health 报告各资源绑定是否配置(static_site/function 自身 + 三个逻辑资源)
|
||
// - GET /api/cloud/db postgres_database:建表 + 读写一条记录
|
||
// - POST /api/cloud/presigned-upload object_storage:presigned PUT + GET,运行时凭证刷新
|
||
// - POST /api/cloud/chat model_router:经 OpenAI 兼容 endpoint 单轮问答
|
||
//
|
||
// 入口固定导出 handler(index.handler),runtime nodejs20。
|
||
// 函数通过 env 中的 ref:<resource>.<field> 引用逻辑资源输出,密钥只存在 env,
|
||
// 不写入静态产物、public_env 或日志。
|
||
|
||
const crypto = require("crypto");
|
||
const {GetObjectCommand, PutObjectCommand, S3Client} = require("@aws-sdk/client-s3");
|
||
const {getSignedUrl} = require("@aws-sdk/s3-request-presigner");
|
||
const {Pool} = require("pg");
|
||
|
||
const corsHeaders = {
|
||
"access-control-allow-methods": "GET,POST,OPTIONS",
|
||
"access-control-allow-headers": "content-type",
|
||
"access-control-max-age": "86400"
|
||
};
|
||
const jsonHeaders = {...corsHeaders, "content-type": "application/json; charset=utf-8"};
|
||
|
||
let pool;
|
||
let schemaReady = false;
|
||
let cachedCredentials;
|
||
let storageClient;
|
||
|
||
const MIGRATION_SQL = `
|
||
create schema if not exists app;
|
||
create table if not exists app.demo_events (
|
||
id bigserial primary key,
|
||
note text not null,
|
||
created_at timestamptz not null default now()
|
||
);
|
||
`;
|
||
|
||
function hasValue(value) {
|
||
return typeof value === "string" && value.length > 0;
|
||
}
|
||
|
||
function requiredEnv(name) {
|
||
const value = process.env[name];
|
||
if (!hasValue(value)) {
|
||
throw new Error(`${name} is not configured`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function json(statusCode, body) {
|
||
return {statusCode, headers: jsonHeaders, body: JSON.stringify(body)};
|
||
}
|
||
|
||
function requestPath(event) {
|
||
return (event && (event.path || event.rawPath)) || "/api/cloud/health";
|
||
}
|
||
|
||
function requestMethod(event) {
|
||
return (event && (event.method || event.httpMethod)) || "GET";
|
||
}
|
||
|
||
function parseBody(event) {
|
||
if (!event || !event.body) {
|
||
return {};
|
||
}
|
||
const raw = event.isBase64Encoded ? Buffer.from(event.body, "base64").toString("utf8") : event.body;
|
||
try {
|
||
return JSON.parse(raw);
|
||
} catch (error) {
|
||
const detail = error instanceof Error ? error.message : String(error);
|
||
throw new Error(`invalid JSON body: ${detail}`);
|
||
}
|
||
}
|
||
|
||
function getPool() {
|
||
const connectionString = process.env.DATABASE_URL;
|
||
if (!hasValue(connectionString)) {
|
||
throw new Error("DATABASE_URL is not configured");
|
||
}
|
||
if (!pool) {
|
||
pool = new Pool({connectionString});
|
||
}
|
||
return pool;
|
||
}
|
||
|
||
async function ensureSchema() {
|
||
if (schemaReady) {
|
||
return;
|
||
}
|
||
await getPool().query(MIGRATION_SQL);
|
||
schemaReady = true;
|
||
}
|
||
|
||
// --- object_storage:运行时凭证刷新(camelCase/snake_case 归一化) ---
|
||
|
||
function parseCredentials(raw) {
|
||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||
if (!parsed || typeof parsed !== "object") {
|
||
throw new Error("object credentials response must be a JSON object");
|
||
}
|
||
const credentials = {
|
||
accessKeyId: parsed.accessKeyId || parsed.access_key_id || parsed.AccessKeyId,
|
||
secretAccessKey: parsed.secretAccessKey || parsed.secret_access_key || parsed.SecretAccessKey,
|
||
sessionToken: parsed.sessionToken || parsed.session_token || parsed.SessionToken,
|
||
region: parsed.region || parsed.Region,
|
||
expiresAt: parsed.expiresAt || parsed.expires_at || parsed.Expiration
|
||
};
|
||
if (!hasValue(credentials.accessKeyId) || !hasValue(credentials.secretAccessKey)) {
|
||
throw new Error("object credentials response is missing access key or secret key");
|
||
}
|
||
return credentials;
|
||
}
|
||
|
||
function credentialsExpired(credentials) {
|
||
if (!credentials || !credentials.expiresAt) {
|
||
return !credentials;
|
||
}
|
||
const expiresAt = Date.parse(credentials.expiresAt);
|
||
return !Number.isFinite(expiresAt) || Date.now() + 60_000 >= expiresAt;
|
||
}
|
||
|
||
async function objectCredentials() {
|
||
if (cachedCredentials && !credentialsExpired(cachedCredentials)) {
|
||
return cachedCredentials;
|
||
}
|
||
const credentialsToken = process.env.OBJECT_CREDENTIALS_TOKEN || process.env.NANOHUB_RUNTIME_TOKEN;
|
||
if (process.env.OBJECT_CREDENTIALS_URL && credentialsToken) {
|
||
const response = await fetch(process.env.OBJECT_CREDENTIALS_URL, {
|
||
method: "POST",
|
||
headers: {authorization: `Bearer ${credentialsToken}`}
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`refresh object credentials failed: ${response.status} ${await response.text()}`);
|
||
}
|
||
cachedCredentials = parseCredentials(await response.json());
|
||
return cachedCredentials;
|
||
}
|
||
cachedCredentials = parseCredentials(requiredEnv("OBJECT_CREDENTIALS"));
|
||
return cachedCredentials;
|
||
}
|
||
|
||
function getStorageClient() {
|
||
if (storageClient) {
|
||
return storageClient;
|
||
}
|
||
storageClient = new S3Client({
|
||
endpoint: requiredEnv("OBJECT_ENDPOINT"),
|
||
region: requiredEnv("OBJECT_REGION"),
|
||
requestChecksumCalculation: "WHEN_REQUIRED",
|
||
credentials: async () => {
|
||
const credentials = await objectCredentials();
|
||
return {
|
||
accessKeyId: credentials.accessKeyId,
|
||
secretAccessKey: credentials.secretAccessKey,
|
||
sessionToken: credentials.sessionToken,
|
||
expiration: credentials.expiresAt ? new Date(credentials.expiresAt) : undefined
|
||
};
|
||
}
|
||
});
|
||
return storageClient;
|
||
}
|
||
|
||
async function presignedObjectURL(method, key, expiresIn) {
|
||
const input = {Bucket: requiredEnv("OBJECT_BUCKET"), Key: key};
|
||
const command = method === "PUT" ? new PutObjectCommand(input) : new GetObjectCommand(input);
|
||
return getSignedUrl(getStorageClient(), command, {expiresIn});
|
||
}
|
||
|
||
function objectKey(name) {
|
||
const prefix = process.env.OBJECT_PREFIX || "";
|
||
const base = prefix && !prefix.endsWith("/") ? `${prefix}/` : prefix;
|
||
const nonce = crypto.randomBytes(8).toString("hex");
|
||
return `${base}demo/${Date.now()}-${nonce}-${name}`;
|
||
}
|
||
|
||
function safeFileName(name) {
|
||
const cleaned = (name || "object").toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
||
return cleaned.slice(0, 80) || "object";
|
||
}
|
||
|
||
// --- 路由 ---
|
||
|
||
async function health() {
|
||
let migrated = false;
|
||
if (hasValue(process.env.DATABASE_URL)) {
|
||
try {
|
||
await ensureSchema();
|
||
migrated = true;
|
||
} catch (error) {
|
||
// health 不因 DB 暂不可用而 500,只报 migrated=false。
|
||
}
|
||
}
|
||
return json(200, {
|
||
ok: true,
|
||
run_id: "ctyun-e2e-20260730T101505Z",
|
||
service: "wehub-demo-all-components",
|
||
migrated,
|
||
bindings: {
|
||
database: hasValue(process.env.DATABASE_URL),
|
||
object_storage: hasValue(process.env.OBJECT_BUCKET && process.env.OBJECT_ENDPOINT),
|
||
object_credentials: hasValue(process.env.OBJECT_CREDENTIALS || process.env.OBJECT_CREDENTIALS_URL),
|
||
model_router: hasValue(
|
||
process.env.OPENAI_BASE_URL && process.env.OPENAI_API_KEY && process.env.OPENAI_MODEL
|
||
)
|
||
}
|
||
});
|
||
}
|
||
|
||
async function dbDemo(event) {
|
||
const body = parseBody(event);
|
||
const note = typeof body.note === "string" && body.note.trim() ? body.note.trim().slice(0, 200) : "hello";
|
||
await ensureSchema();
|
||
const result = await getPool().query(
|
||
"insert into app.demo_events (note) values ($1) returning id, note, created_at",
|
||
[note]
|
||
);
|
||
const row = result.rows[0];
|
||
return json(200, {
|
||
ok: true,
|
||
event: {
|
||
id: Number(row.id),
|
||
note: row.note,
|
||
created_at: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at
|
||
}
|
||
});
|
||
}
|
||
|
||
async function presignedUpload(event) {
|
||
const body = parseBody(event);
|
||
const contentType = typeof body.content_type === "string" && body.content_type.trim()
|
||
? body.content_type.trim()
|
||
: "application/octet-stream";
|
||
const name = safeFileName(typeof body.filename === "string" ? body.filename : "object");
|
||
const key = objectKey(name);
|
||
return json(200, {
|
||
ok: true,
|
||
method: "PUT",
|
||
content_type: contentType,
|
||
object_key: key,
|
||
upload_url: await presignedObjectURL("PUT", key, 300),
|
||
image_url: await presignedObjectURL("GET", key, 300),
|
||
expires_in: 300
|
||
});
|
||
}
|
||
|
||
async function chat(event) {
|
||
const body = parseBody(event);
|
||
if (typeof body.question !== "string" || !body.question.trim()) {
|
||
return json(400, {ok: false, error: "question is required"});
|
||
}
|
||
const question = body.question.trim().slice(0, 2000);
|
||
|
||
const baseURL = requiredEnv("OPENAI_BASE_URL").replace(/\/+$/, "");
|
||
const apiKey = requiredEnv("OPENAI_API_KEY");
|
||
const model = requiredEnv("OPENAI_MODEL");
|
||
|
||
let response;
|
||
try {
|
||
response = await fetch(`${baseURL}/chat/completions`, {
|
||
method: "POST",
|
||
headers: {authorization: `Bearer ${apiKey}`, "content-type": "application/json"},
|
||
body: JSON.stringify({
|
||
model,
|
||
messages: [
|
||
{role: "system", content: "你是一个简洁、准确的问答助手。请直接回答当前问题。"},
|
||
{role: "user", content: question}
|
||
]
|
||
}),
|
||
signal: AbortSignal.timeout(60_000)
|
||
});
|
||
} catch (error) {
|
||
const detail = error instanceof Error ? error.message : String(error);
|
||
return json(502, {ok: false, error: `model router request failed: ${detail}`});
|
||
}
|
||
|
||
let payload;
|
||
try {
|
||
payload = await response.json();
|
||
} catch (error) {
|
||
return json(502, {ok: false, error: `model router returned invalid JSON (${response.status})`});
|
||
}
|
||
if (!response.ok) {
|
||
const detail = payload && payload.error && payload.error.message;
|
||
return json(502, {ok: false, error: detail || `model router request failed: ${response.status}`});
|
||
}
|
||
|
||
const answer = payload && payload.choices && payload.choices[0]
|
||
&& payload.choices[0].message && payload.choices[0].message.content;
|
||
if (typeof answer !== "string" || !answer.trim()) {
|
||
return json(502, {ok: false, error: "model router returned an empty answer"});
|
||
}
|
||
return json(200, {ok: true, answer: answer.trim()});
|
||
}
|
||
|
||
exports.handler = async function handler(event) {
|
||
const path = requestPath(event);
|
||
const method = requestMethod(event).toUpperCase();
|
||
|
||
try {
|
||
if (method === "OPTIONS") {
|
||
return {statusCode: 204, headers: corsHeaders, body: ""};
|
||
}
|
||
if (method === "GET" && path.endsWith("/health")) {
|
||
return await health();
|
||
}
|
||
if (method === "GET" && path.endsWith("/db")) {
|
||
return await dbDemo(event);
|
||
}
|
||
if (method === "POST" && path.endsWith("/presigned-upload")) {
|
||
return await presignedUpload(event);
|
||
}
|
||
if (method === "POST" && path.endsWith("/chat")) {
|
||
return await chat(event);
|
||
}
|
||
return json(200, {
|
||
ok: true,
|
||
routes: [
|
||
"GET /api/cloud/health",
|
||
"GET /api/cloud/db",
|
||
"POST /api/cloud/presigned-upload",
|
||
"POST /api/cloud/chat"
|
||
]
|
||
});
|
||
} catch (error) {
|
||
return json(500, {ok: false, error: error instanceof Error ? error.message : String(error)});
|
||
}
|
||
};
|