项目文件夹

文件
2026-07-30 10:30:01 +00:00

329 行
11 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
// WeHub 标准项目完整 demo 函数入口。
//
// 覆盖当前支持的全部 5 种资源,每资源一个最小端点:
// - GET /api/cloud/health 报告各资源绑定是否配置(static_site/function 自身 + 三个逻辑资源)
// - GET /api/cloud/db postgres_database:建表 + 读写一条记录
// - POST /api/cloud/presigned-upload object_storagepresigned PUT + GET,运行时凭证刷新
// - POST /api/cloud/chat model_router:经 OpenAI 兼容 endpoint 单轮问答
//
// 入口固定导出 handlerindex.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)});
}
};