yf-volcguid-test
397 行
12 KiB
JavaScript
397 行
12 KiB
JavaScript
const {Pool} = require("pg");
|
||
|
||
const CATEGORIES = ["技术共学", "产品交流", "独立开发", "城市漫游", "创意市集", "其他"];
|
||
const DISTRICTS = ["江岸区", "江汉区", "硚口区", "汉阳区", "武昌区", "青山区", "洪山区", "东西湖区", "线上"];
|
||
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 schemaPromise;
|
||
|
||
const MIGRATION_SQL = `
|
||
create schema if not exists app;
|
||
create table if not exists app.opc_events (
|
||
id bigserial primary key,
|
||
title varchar(100) not null,
|
||
organizer varchar(60) not null,
|
||
category varchar(30) not null,
|
||
district varchar(30) not null,
|
||
event_date date not null,
|
||
venue varchar(120) not null,
|
||
description text not null,
|
||
contact varchar(160) not null,
|
||
status varchar(20) not null default '征集中',
|
||
created_at timestamptz not null default now()
|
||
);
|
||
create index if not exists opc_events_event_date_idx
|
||
on app.opc_events (event_date asc, created_at desc);
|
||
`;
|
||
|
||
function json(statusCode, body) {
|
||
return {
|
||
statusCode,
|
||
headers: jsonHeaders,
|
||
body: JSON.stringify(body)
|
||
};
|
||
}
|
||
|
||
function requestPath(event) {
|
||
return (event && (event.path || event.rawPath)) || "/";
|
||
}
|
||
|
||
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) {
|
||
throw new Error("request body must be valid JSON");
|
||
}
|
||
}
|
||
|
||
function requiredEnv(name) {
|
||
const value = process.env[name];
|
||
if (typeof value !== "string" || !value.trim()) {
|
||
throw new Error(`${name} is not configured`);
|
||
}
|
||
return value.trim();
|
||
}
|
||
|
||
function getPool() {
|
||
if (!pool) {
|
||
pool = new Pool({connectionString: requiredEnv("DATABASE_URL")});
|
||
}
|
||
return pool;
|
||
}
|
||
|
||
async function ensureSchema() {
|
||
if (!schemaPromise) {
|
||
schemaPromise = getPool().query(MIGRATION_SQL).catch((error) => {
|
||
schemaPromise = undefined;
|
||
throw new Error(`database migration failed: ${error.message}`);
|
||
});
|
||
}
|
||
await schemaPromise;
|
||
}
|
||
|
||
function cleanString(value) {
|
||
return typeof value === "string" ? value.trim() : "";
|
||
}
|
||
|
||
function validateEvent(body) {
|
||
const event = {
|
||
title: cleanString(body.title),
|
||
organizer: cleanString(body.organizer),
|
||
category: cleanString(body.category),
|
||
district: cleanString(body.district),
|
||
event_date: cleanString(body.event_date),
|
||
venue: cleanString(body.venue),
|
||
description: cleanString(body.description),
|
||
contact: cleanString(body.contact)
|
||
};
|
||
|
||
const required = [
|
||
["title", "活动名称"],
|
||
["organizer", "发起人"],
|
||
["category", "活动类型"],
|
||
["district", "活动区域"],
|
||
["event_date", "活动日期"],
|
||
["venue", "活动地点"],
|
||
["description", "活动说明"],
|
||
["contact", "联系方式"]
|
||
];
|
||
for (const [field, label] of required) {
|
||
if (!event[field]) {
|
||
return {error: `${label}不能为空`};
|
||
}
|
||
}
|
||
|
||
if (event.title.length > 100) {
|
||
return {error: "活动名称不能超过 100 个字"};
|
||
}
|
||
if (event.organizer.length > 60) {
|
||
return {error: "发起人不能超过 60 个字"};
|
||
}
|
||
if (!CATEGORIES.includes(event.category)) {
|
||
return {error: "请选择有效的活动类型"};
|
||
}
|
||
if (!DISTRICTS.includes(event.district)) {
|
||
return {error: "请选择有效的活动区域"};
|
||
}
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(event.event_date)
|
||
|| Number.isNaN(Date.parse(`${event.event_date}T00:00:00Z`))) {
|
||
return {error: "活动日期格式不正确"};
|
||
}
|
||
if (event.venue.length > 120) {
|
||
return {error: "活动地点不能超过 120 个字"};
|
||
}
|
||
if (event.description.length < 20 || event.description.length > 1200) {
|
||
return {error: "活动说明需要 20 到 1200 个字"};
|
||
}
|
||
if (event.contact.length > 160) {
|
||
return {error: "联系方式不能超过 160 个字"};
|
||
}
|
||
return {event};
|
||
}
|
||
|
||
function publicEvent(row) {
|
||
const date = row.event_date instanceof Date
|
||
? row.event_date.toISOString().slice(0, 10)
|
||
: String(row.event_date);
|
||
return {
|
||
id: Number(row.id),
|
||
title: row.title,
|
||
organizer: row.organizer,
|
||
category: row.category,
|
||
district: row.district,
|
||
event_date: date,
|
||
venue: row.venue,
|
||
description: row.description,
|
||
status: row.status,
|
||
created_at: row.created_at instanceof Date
|
||
? row.created_at.toISOString()
|
||
: row.created_at
|
||
};
|
||
}
|
||
|
||
async function listEvents() {
|
||
await ensureSchema();
|
||
const result = await getPool().query(`
|
||
select id, title, organizer, category, district, event_date, venue,
|
||
description, status, created_at
|
||
from app.opc_events
|
||
order by event_date asc, created_at desc
|
||
limit 30
|
||
`);
|
||
return json(200, {ok: true, events: result.rows.map(publicEvent)});
|
||
}
|
||
|
||
async function createEvent(event) {
|
||
const validation = validateEvent(parseBody(event));
|
||
if (validation.error) {
|
||
return json(400, {ok: false, error: validation.error});
|
||
}
|
||
await ensureSchema();
|
||
const value = validation.event;
|
||
const result = await getPool().query(`
|
||
insert into app.opc_events
|
||
(title, organizer, category, district, event_date, venue, description, contact)
|
||
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||
returning id, title, organizer, category, district, event_date, venue,
|
||
description, status, created_at
|
||
`, [
|
||
value.title,
|
||
value.organizer,
|
||
value.category,
|
||
value.district,
|
||
value.event_date,
|
||
value.venue,
|
||
value.description,
|
||
value.contact
|
||
]);
|
||
return json(201, {ok: true, event: publicEvent(result.rows[0])});
|
||
}
|
||
|
||
function parseDraftContent(content) {
|
||
if (typeof content !== "string" || !content.trim()) {
|
||
throw new Error("模型没有返回活动草案");
|
||
}
|
||
const fenced = content.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||
const source = (fenced ? fenced[1] : content).trim();
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(source);
|
||
} catch (error) {
|
||
throw new Error("模型返回的活动草案不是有效 JSON");
|
||
}
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
throw new Error("模型返回的活动草案必须是 JSON 对象");
|
||
}
|
||
|
||
const draft = {
|
||
title: cleanString(parsed.title),
|
||
category: cleanString(parsed.category),
|
||
district: cleanString(parsed.district),
|
||
venue: cleanString(parsed.venue),
|
||
description: cleanString(parsed.description)
|
||
};
|
||
if (!draft.title || draft.title.length > 100) {
|
||
throw new Error("模型返回的活动名称不完整");
|
||
}
|
||
if (!CATEGORIES.includes(draft.category)) {
|
||
throw new Error("模型返回了不支持的活动类型");
|
||
}
|
||
if (!DISTRICTS.includes(draft.district)) {
|
||
throw new Error("模型返回了不支持的活动区域");
|
||
}
|
||
if (!draft.venue || draft.venue.length > 120) {
|
||
throw new Error("模型返回的场地建议不完整");
|
||
}
|
||
if (draft.description.length < 20 || draft.description.length > 1200) {
|
||
throw new Error("模型返回的活动说明长度不符合要求");
|
||
}
|
||
return draft;
|
||
}
|
||
|
||
function draftContext(body) {
|
||
const context = {};
|
||
if (CATEGORIES.includes(cleanString(body.category))) {
|
||
context.category = cleanString(body.category);
|
||
}
|
||
if (DISTRICTS.includes(cleanString(body.district))) {
|
||
context.district = cleanString(body.district);
|
||
}
|
||
const venue = cleanString(body.venue);
|
||
if (venue) {
|
||
context.venue = venue.slice(0, 120);
|
||
}
|
||
const eventDate = cleanString(body.event_date);
|
||
if (/^\d{4}-\d{2}-\d{2}$/.test(eventDate)) {
|
||
context.event_date = eventDate;
|
||
}
|
||
return context;
|
||
}
|
||
|
||
async function draftEvent(event) {
|
||
const body = parseBody(event);
|
||
const idea = cleanString(body.idea);
|
||
if (!idea) {
|
||
return json(400, {ok: false, error: "请先写下一句活动想法"});
|
||
}
|
||
if (idea.length < 8 || idea.length > 800) {
|
||
return json(400, {ok: false, error: "活动想法需要 8 到 800 个字"});
|
||
}
|
||
|
||
const baseURL = requiredEnv("OPENAI_BASE_URL").replace(/\/+$/, "");
|
||
const apiKey = requiredEnv("OPENAI_API_KEY");
|
||
const model = requiredEnv("OPENAI_MODEL");
|
||
const context = draftContext(body);
|
||
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: [
|
||
"你是武汉 OPC 社区的活动共创助手。",
|
||
"OPC 指一人公司与独立创造者社区。",
|
||
"请把用户的一句活动想法整理为可直接提交的活动草案。",
|
||
"只返回一个 JSON 对象,不要使用 Markdown,也不要添加解释。",
|
||
"JSON 必须且只能包含 title、category、district、venue、description。",
|
||
`category 必须是以下值之一:${CATEGORIES.join("、")}。`,
|
||
`district 必须是以下值之一:${DISTRICTS.join("、")}。`,
|
||
"venue 可以是具体场地,也可以是清晰的场地类型建议。",
|
||
"description 用 80 到 300 个中文字符说明活动目标、适合人群和大致流程。",
|
||
"不得编造已确认的合作方、报名链接或场地预订状态。"
|
||
].join("\n")
|
||
},
|
||
{
|
||
role: "user",
|
||
content: JSON.stringify({idea, known_context: context})
|
||
}
|
||
]
|
||
}),
|
||
signal: AbortSignal.timeout(45_000)
|
||
});
|
||
} catch (error) {
|
||
const detail = error instanceof Error ? error.message : String(error);
|
||
return json(502, {ok: false, error: `AI 助手暂时不可用:${detail}`});
|
||
}
|
||
|
||
let payload;
|
||
try {
|
||
payload = await response.json();
|
||
} catch (error) {
|
||
return json(502, {ok: false, error: `模型服务返回了无效响应(${response.status})`});
|
||
}
|
||
if (!response.ok) {
|
||
const detail = payload && payload.error && payload.error.message;
|
||
return json(502, {ok: false, error: detail || `模型服务请求失败(${response.status})`});
|
||
}
|
||
|
||
const answer = payload && payload.choices && payload.choices[0]
|
||
&& payload.choices[0].message && payload.choices[0].message.content;
|
||
try {
|
||
return json(200, {ok: true, draft: parseDraftContent(answer)});
|
||
} catch (error) {
|
||
return json(502, {
|
||
ok: false,
|
||
error: error instanceof Error ? error.message : "模型返回了无效活动草案"
|
||
});
|
||
}
|
||
}
|
||
|
||
async function health() {
|
||
let migrated = false;
|
||
if (process.env.DATABASE_URL) {
|
||
await ensureSchema();
|
||
migrated = true;
|
||
}
|
||
return json(200, {
|
||
ok: true,
|
||
service: "wuhan-opc-community",
|
||
migrated,
|
||
bindings: {
|
||
database: Boolean(process.env.DATABASE_URL),
|
||
model_router: Boolean(
|
||
process.env.OPENAI_BASE_URL
|
||
&& process.env.OPENAI_API_KEY
|
||
&& process.env.OPENAI_MODEL
|
||
)
|
||
},
|
||
routes: [
|
||
"GET /api/cloud/health",
|
||
"GET /api/cloud/events",
|
||
"POST /api/cloud/events",
|
||
"POST /api/cloud/event-draft"
|
||
]
|
||
});
|
||
}
|
||
|
||
exports.handler = async function handler(event) {
|
||
const method = requestMethod(event).toUpperCase();
|
||
const path = requestPath(event);
|
||
try {
|
||
if (method === "OPTIONS") {
|
||
return {statusCode: 204, headers: corsHeaders, body: ""};
|
||
}
|
||
if (method === "GET" && path.endsWith("/health")) {
|
||
return await health();
|
||
}
|
||
if (method === "GET" && path.endsWith("/events")) {
|
||
return await listEvents();
|
||
}
|
||
if (method === "POST" && path.endsWith("/events")) {
|
||
return await createEvent(event);
|
||
}
|
||
if (method === "POST" && path.endsWith("/event-draft")) {
|
||
return await draftEvent(event);
|
||
}
|
||
return json(404, {ok: false, error: "route not found"});
|
||
} catch (error) {
|
||
return json(500, {
|
||
ok: false,
|
||
error: error instanceof Error ? error.message : String(error)
|
||
});
|
||
}
|
||
};
|