项目文件夹

文件
2026-08-20 11:19:23 +00:00

397 行
12 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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)
});
}
};