Files
pua/landing/functions/api/upload.ts
T
xsser fe1977fbcb feat: complete all HANDOFF tasks — logo + feedback API + eval helpers
Task 2: Landing page logo
- Replace 💢 emoji favicon with pua-skill-logo.svg
- Logo SVG copied to dist/pua-logo.svg

Task 3b: /api/feedback Cloudflare Function
- POST: save rating, task_summary, pua_level, pua_count, flavor, session_data
- GET: aggregate stats (total feedback, breakdown by rating)
- D1 migration: 0002_create_feedback.sql (feedback table + indexes)

Eval补全:
- evals/test-helpers.sh — reusable assertions (run_pua, assert_skill_triggered,
  assert_contains, assert_not_contains, count_matches)
- evals/test-behavior.sh — 4 behavior verification tests
  (红线 knowledge, 阿里味旁白, [PUA生效] markers, pressure escalation)

Deploy note: /tmp/pua-deploy-final/ is ready for wrangler pages deploy.
D1 migration needs: wrangler d1 execute pua-uploads-db --file=migrations/0002_create_feedback.sql
2026-03-19 19:10:50 +08:00

100 lines
3.1 KiB
TypeScript

interface Env {
DB: D1Database
UPLOADS: R2Bucket
}
function getSession(request: Request) {
const cookie = request.headers.get("Cookie") || ""
const match = cookie.match(/pua_session=([^;]+)/)
if (!match) return null
try {
return JSON.parse(atob(match[1])) as {
id: string
login: string
avatar: string
token: string
}
} catch {
return null
}
}
export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
const session = getSession(request)
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const formData = await request.formData()
const file = formData.get("file") as File | null
const wechatId = formData.get("wechat_id") as string | null
if (!file) {
return Response.json({ error: "No file provided" }, { status: 400 })
}
if (!wechatId?.trim()) {
return Response.json({ error: "WeChat ID is required" }, { status: 400 })
}
if (!file.name.endsWith(".jsonl")) {
return Response.json({ error: "Only .jsonl files are accepted" }, { status: 400 })
}
if (file.size > 50 * 1024 * 1024) {
return Response.json({ error: "File too large (max 50MB)" }, { status: 400 })
}
// Upload to R2
const key = `${session.login}/${Date.now()}-${file.name}`
await env.UPLOADS.put(key, file.stream(), {
httpMetadata: { contentType: "application/jsonl" },
customMetadata: {
github_id: session.id,
github_login: session.login,
wechat_id: wechatId.trim(),
},
})
// Record in D1
await env.DB.prepare(
"INSERT INTO uploads (github_id, github_login, wechat_id, file_key, file_name, file_size) VALUES (?, ?, ?, ?, ?, ?)"
).bind(session.id, session.login, wechatId.trim(), key, file.name, file.size).run()
// Send email notification (fire-and-forget)
const sizeMB = (file.size / 1024 / 1024).toFixed(2)
const emailBody = [
`New PUA Skill data upload:`,
``,
`GitHub: ${session.login} (${session.id})`,
`WeChat: ${wechatId.trim()}`,
`File: ${file.name} (${sizeMB} MB)`,
`R2 Key: ${key}`,
`Time: ${new Date().toISOString()}`,
].join("\n")
fetch("https://api.mailchannels.net/tx/v1/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
personalizations: [{ to: [{ email: "xsser.w@gmail.com", name: "PUA Admin" }] }],
from: { email: "noreply@pua-skill.pages.dev", name: "PUA Skill Upload" },
subject: `[PUA Upload] ${session.login} uploaded ${file.name}`,
content: [{ type: "text/plain", value: emailBody }],
}),
}).catch(() => {})
return Response.json({ ok: true, key, file_name: file.name, file_size: file.size })
}
// GET: list user's uploads
export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
const session = getSession(request)
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const { results } = await env.DB.prepare(
"SELECT file_name, file_size, created_at FROM uploads WHERE github_id = ? ORDER BY created_at DESC LIMIT 50"
).bind(session.id).all()
return Response.json({ uploads: results })
}