index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. /**
  2. * 青幕AI写作 - 用户统计 Cloudflare Worker
  3. *
  4. * 功能:
  5. * - POST /open : 用户启动软件时调用(注册 + 标记在线)
  6. * - POST /close : 用户关闭软件时调用(标记离线)
  7. * - GET /stats : 查看统计数据(需要密钥)
  8. * - Cron trigger : 每小时清理超时会话
  9. */
  10. export interface Env {
  11. DB: D1Database
  12. STATS_SECRET: string // 在 Cloudflare Dashboard 设置的密钥,用于查看统计
  13. }
  14. const ACTIVE_ONLINE_WINDOW_MS = 3 * 60 * 1000
  15. type FeedbackType = "bug" | "suggestion" | "other"
  16. interface FeedbackBody {
  17. type?: FeedbackType
  18. message?: string
  19. contact?: string
  20. appVersion?: string
  21. userAgent?: string
  22. }
  23. interface FeedbackRow {
  24. id: number
  25. type: string
  26. message: string
  27. contact: string | null
  28. app_version: string | null
  29. created_at: string
  30. }
  31. // 将 IP 地址哈希化(隐私保护)
  32. async function hashIP(ip: string): Promise<string> {
  33. const encoder = new TextEncoder()
  34. const data = encoder.encode(ip + "qmai-salt-2026")
  35. const hashBuffer = await crypto.subtle.digest("SHA-256", data)
  36. const hashArray = Array.from(new Uint8Array(hashBuffer))
  37. return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")
  38. }
  39. // CORS 响应头
  40. function corsHeaders(): Record<string, string> {
  41. return {
  42. "Access-Control-Allow-Origin": "*",
  43. "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
  44. "Access-Control-Allow-Headers": "Content-Type, Authorization",
  45. }
  46. }
  47. function jsonResponse(data: unknown, status = 200): Response {
  48. return new Response(JSON.stringify(data), {
  49. status,
  50. headers: { "Content-Type": "application/json", ...corsHeaders() },
  51. })
  52. }
  53. function cleanText(value: unknown, maxLength: number): string {
  54. return typeof value === "string" ? value.trim().slice(0, maxLength) : ""
  55. }
  56. function escapeHtml(value: unknown): string {
  57. return String(value ?? "")
  58. .replace(/&/g, "&amp;")
  59. .replace(/</g, "&lt;")
  60. .replace(/>/g, "&gt;")
  61. .replace(/"/g, "&quot;")
  62. .replace(/'/g, "&#39;")
  63. }
  64. function typeLabel(type: string): string {
  65. if (type === "bug") return "问题反馈"
  66. if (type === "suggestion") return "功能建议"
  67. return "其他"
  68. }
  69. function activeCutoff(now = new Date()): string {
  70. return new Date(now.getTime() - ACTIVE_ONLINE_WINDOW_MS).toISOString()
  71. }
  72. async function upsertUserSeen(request: Request, env: Env, uuid: string, now: string): Promise<void> {
  73. const ip = request.headers.get("CF-Connecting-IP") || "unknown"
  74. const ipHash = await hashIP(ip)
  75. await env.DB.prepare(
  76. `INSERT INTO users (uuid, ip_hash, first_seen, last_seen)
  77. VALUES (?1, ?2, ?3, ?3)
  78. ON CONFLICT(uuid) DO UPDATE SET last_seen = ?3`
  79. ).bind(uuid, ipHash, now).run()
  80. }
  81. async function countActiveUsers(env: Env, cutoff: string): Promise<number> {
  82. const onlineCount = await env.DB.prepare(
  83. `SELECT COUNT(DISTINCT uuid) as count FROM sessions
  84. WHERE is_online = 1 AND last_active >= ?1`
  85. ).bind(cutoff).first<{ count: number }>()
  86. return onlineCount?.count ?? 0
  87. }
  88. // POST /feedback - 设置页用户反馈
  89. async function handleFeedback(request: Request, env: Env): Promise<Response> {
  90. const body = await request.json<FeedbackBody>()
  91. const type: FeedbackType = body?.type === "bug" || body?.type === "other" ? body.type : "suggestion"
  92. const message = cleanText(body?.message, 3000)
  93. const contact = cleanText(body?.contact, 200)
  94. const appVersion = cleanText(body?.appVersion, 40)
  95. const userAgent = cleanText(body?.userAgent, 300)
  96. if (!message) return jsonResponse({ error: "请输入反馈内容" }, 400)
  97. const ip = request.headers.get("CF-Connecting-IP") || "unknown"
  98. const ipHash = await hashIP(ip)
  99. const now = new Date().toISOString()
  100. await env.DB.prepare(
  101. `INSERT INTO feedback (type, message, contact, app_version, user_agent, ip_hash, created_at)
  102. VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`
  103. ).bind(type, message, contact || null, appVersion || null, userAgent || null, ipHash, now).run()
  104. return jsonResponse({ ok: true })
  105. }
  106. async function handleDeleteFeedback(request: Request, env: Env): Promise<Response> {
  107. const url = new URL(request.url)
  108. const key = url.searchParams.get("key") ?? ""
  109. if (key !== env.STATS_SECRET) {
  110. return new Response("需要密钥", { status: 401 })
  111. }
  112. const body = new URLSearchParams(await request.text())
  113. const id = Number(body.get("id"))
  114. if (!Number.isInteger(id) || id <= 0) {
  115. return new Response("反馈 ID 无效", { status: 400 })
  116. }
  117. await env.DB.prepare(
  118. `DELETE FROM feedback WHERE id = ?1`
  119. ).bind(id).run()
  120. return Response.redirect(`${url.origin}/dashboard?key=${encodeURIComponent(key)}`, 303)
  121. }
  122. // POST /open - 用户启动软件
  123. async function handleOpen(request: Request, env: Env): Promise<Response> {
  124. const body = await request.json<{ uuid: string }>()
  125. const uuid = body?.uuid
  126. if (!uuid) return jsonResponse({ error: "missing uuid" }, 400)
  127. const now = new Date().toISOString()
  128. // 注册/更新用户(下载人数统计)
  129. await upsertUserSeen(request, env, uuid, now)
  130. // 关闭该 uuid 所有旧的在线会话(防止累积)
  131. await env.DB.prepare(
  132. `UPDATE sessions SET is_online = 0, close_time = ?1 WHERE uuid = ?2 AND is_online = 1`
  133. ).bind(now, uuid).run()
  134. // 创建新会话
  135. await env.DB.prepare(
  136. `INSERT INTO sessions (uuid, open_time, last_active, is_online) VALUES (?1, ?2, ?2, 1)`
  137. ).bind(uuid, now).run()
  138. return jsonResponse({ ok: true })
  139. }
  140. // POST /heartbeat - 用户在线心跳
  141. async function handleHeartbeat(request: Request, env: Env): Promise<Response> {
  142. const body = await request.json<{ uuid: string }>()
  143. const uuid = body?.uuid
  144. if (!uuid) return jsonResponse({ error: "missing uuid" }, 400)
  145. const now = new Date().toISOString()
  146. await upsertUserSeen(request, env, uuid, now)
  147. const current = await env.DB.prepare(
  148. `SELECT id FROM sessions WHERE uuid = ?1 AND is_online = 1
  149. ORDER BY last_active DESC LIMIT 1`
  150. ).bind(uuid).first<{ id: number }>()
  151. if (current?.id) {
  152. await env.DB.prepare(
  153. `UPDATE sessions SET last_active = ?1, is_online = 1 WHERE id = ?2`
  154. ).bind(now, current.id).run()
  155. } else {
  156. await env.DB.prepare(
  157. `INSERT INTO sessions (uuid, open_time, last_active, is_online) VALUES (?1, ?2, ?2, 1)`
  158. ).bind(uuid, now).run()
  159. }
  160. return jsonResponse({ ok: true })
  161. }
  162. // POST /close - 用户关闭软件
  163. async function handleClose(request: Request, env: Env): Promise<Response> {
  164. const body = await request.json<{ uuid: string }>()
  165. const uuid = body?.uuid
  166. if (!uuid) return jsonResponse({ error: "missing uuid" }, 400)
  167. const now = new Date().toISOString()
  168. await env.DB.prepare(
  169. `UPDATE sessions SET is_online = 0, close_time = ?1, last_active = ?1
  170. WHERE uuid = ?2 AND is_online = 1`
  171. ).bind(now, uuid).run()
  172. return jsonResponse({ ok: true })
  173. }
  174. // GET /stats - 查看统计(需要密钥)
  175. async function handleStats(request: Request, env: Env): Promise<Response> {
  176. // 验证密钥
  177. const auth = request.headers.get("Authorization")
  178. if (auth !== `Bearer ${env.STATS_SECRET}`) {
  179. return jsonResponse({ error: "unauthorized" }, 401)
  180. }
  181. // 总下载用户数(按 uuid 去重)
  182. const totalUsers = await env.DB.prepare(
  183. `SELECT COUNT(*) as count FROM users`
  184. ).first<{ count: number }>()
  185. // 独立 IP 数
  186. const uniqueIPs = await env.DB.prepare(
  187. `SELECT COUNT(DISTINCT ip_hash) as count FROM users`
  188. ).first<{ count: number }>()
  189. // 当前在线人数:最近有心跳的去重用户数
  190. const cutoff = activeCutoff()
  191. const onlineCount = await countActiveUsers(env, cutoff)
  192. // 今日新增用户
  193. const today = new Date().toISOString().split("T")[0]
  194. const todayNew = await env.DB.prepare(
  195. `SELECT COUNT(*) as count FROM users WHERE first_seen >= ?1`
  196. ).bind(today).first<{ count: number }>()
  197. // 最近7天每日统计
  198. const dailyStats = await env.DB.prepare(
  199. `SELECT date, new_users, total_users, peak_online FROM daily_stats
  200. ORDER BY date DESC LIMIT 7`
  201. ).all()
  202. // 最近在线的用户列表(最近 20 个)
  203. const recentOnline = await env.DB.prepare(
  204. `SELECT uuid, MAX(last_active) as last_active FROM sessions
  205. WHERE is_online = 1 AND last_active >= ?1
  206. GROUP BY uuid ORDER BY last_active DESC LIMIT 20`
  207. ).bind(cutoff).all()
  208. return jsonResponse({
  209. total_users: totalUsers?.count ?? 0,
  210. unique_ips: uniqueIPs?.count ?? 0,
  211. online_now: onlineCount,
  212. today_new_users: todayNew?.count ?? 0,
  213. daily_stats: dailyStats.results,
  214. recent_online: recentOnline.results,
  215. server_time: new Date().toISOString(),
  216. })
  217. }
  218. function renderDashboard(input: {
  219. totalUsers: number
  220. uniqueIPs: number
  221. onlineCount: number
  222. todayNew: number
  223. feedbackRows: FeedbackRow[]
  224. serverTime: string
  225. dashboardKey: string
  226. }): string {
  227. const feedbackRows = input.feedbackRows.length > 0
  228. ? input.feedbackRows.map((row) => `
  229. <tr>
  230. <td>${escapeHtml(row.created_at)}</td>
  231. <td><span class="tag">${escapeHtml(typeLabel(row.type))}</span></td>
  232. <td class="message">${escapeHtml(row.message)}</td>
  233. <td>${escapeHtml(row.contact || "-")}</td>
  234. <td>${escapeHtml(row.app_version || "-")}</td>
  235. <td>
  236. <form method="POST" action="/feedback/delete?key=${encodeURIComponent(input.dashboardKey)}" onsubmit="return confirm('确定删除这条反馈吗?删除后不可恢复。')">
  237. <input type="hidden" name="id" value="${row.id}">
  238. <button class="danger" type="submit">删除</button>
  239. </form>
  240. </td>
  241. </tr>`).join("")
  242. : `<tr><td colspan="6" class="empty">暂无反馈</td></tr>`
  243. return `<!DOCTYPE html>
  244. <html lang="zh-CN">
  245. <head>
  246. <meta charset="UTF-8">
  247. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  248. <title>青幕AI写作 - 用户统计与反馈</title>
  249. <style>
  250. * { margin: 0; padding: 0; box-sizing: border-box; }
  251. body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0f172a; color: #e2e8f0; padding: 2rem; min-height: 100vh; }
  252. main { max-width: 1100px; margin: 0 auto; }
  253. h1 { margin-bottom: 0.35rem; color: #f8fafc; }
  254. h2 { margin: 2rem 0 1rem; color: #cbd5e1; font-size: 1.1rem; }
  255. .subtle { color: #94a3b8; font-size: 0.9rem; }
  256. .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 1rem; margin-top: 1.25rem; }
  257. .card { background: #111827; border-radius: 8px; padding: 1.25rem; border: 1px solid #334155; }
  258. .card .number { font-size: 2.2rem; font-weight: 700; color: #38bdf8; }
  259. .card .label { margin-top: 0.35rem; color: #94a3b8; font-size: 0.9rem; }
  260. .online .number { color: #4ade80; }
  261. .panel { overflow: hidden; border: 1px solid #334155; border-radius: 8px; background: #111827; }
  262. table { width: 100%; border-collapse: collapse; }
  263. th, td { padding: 0.85rem; border-bottom: 1px solid #1f2937; text-align: left; vertical-align: top; font-size: 0.9rem; }
  264. th { color: #94a3b8; background: #0f172a; font-weight: 600; }
  265. .message { max-width: 460px; white-space: pre-wrap; line-height: 1.55; }
  266. .tag { display: inline-flex; border: 1px solid #475569; border-radius: 999px; padding: 0.15rem 0.55rem; color: #cbd5e1; font-size: 0.8rem; }
  267. .danger { border: 1px solid #7f1d1d; border-radius: 6px; background: #450a0a; color: #fecaca; padding: 0.3rem 0.55rem; cursor: pointer; }
  268. .danger:hover { background: #7f1d1d; }
  269. .empty { color: #64748b; text-align: center; }
  270. .footer { margin-top: 1.5rem; color: #64748b; font-size: 0.8rem; }
  271. </style>
  272. </head>
  273. <body>
  274. <main>
  275. <h1>青幕AI写作后台</h1>
  276. <p class="subtle">用户统计与设置页反馈都在这里查看。</p>
  277. <h2>用户统计</h2>
  278. <div class="grid">
  279. <div class="card">
  280. <div class="number">${input.totalUsers}</div>
  281. <div class="label">总下载用户数</div>
  282. </div>
  283. <div class="card">
  284. <div class="number">${input.uniqueIPs}</div>
  285. <div class="label">独立 IP 数</div>
  286. </div>
  287. <div class="card online">
  288. <div class="number">${input.onlineCount}</div>
  289. <div class="label">当前在线人数</div>
  290. </div>
  291. <div class="card">
  292. <div class="number">${input.todayNew}</div>
  293. <div class="label">今日新增用户</div>
  294. </div>
  295. </div>
  296. <h2>最新反馈</h2>
  297. <div class="panel">
  298. <table>
  299. <thead>
  300. <tr>
  301. <th>提交时间</th>
  302. <th>类型</th>
  303. <th>内容</th>
  304. <th>联系方式</th>
  305. <th>版本</th>
  306. <th>操作</th>
  307. </tr>
  308. </thead>
  309. <tbody>${feedbackRows}</tbody>
  310. </table>
  311. </div>
  312. <div class="footer">
  313. <p>数据更新时间:${escapeHtml(input.serverTime)}</p>
  314. <p>在线人数按最近 3 分钟内有心跳的去重设备计算。</p>
  315. </div>
  316. </main>
  317. </body>
  318. </html>`
  319. }
  320. // GET /dashboard - 简易 HTML 仪表盘
  321. async function handleDashboard(request: Request, env: Env): Promise<Response> {
  322. const auth = new URL(request.url).searchParams.get("key")
  323. if (auth !== env.STATS_SECRET) {
  324. return new Response("需要密钥: ?key=你的密钥", { status: 401 })
  325. }
  326. const totalUsers = await env.DB.prepare(
  327. `SELECT COUNT(*) as count FROM users`
  328. ).first<{ count: number }>()
  329. const uniqueIPs = await env.DB.prepare(
  330. `SELECT COUNT(DISTINCT ip_hash) as count FROM users`
  331. ).first<{ count: number }>()
  332. const cutoff = activeCutoff()
  333. const onlineCount = await countActiveUsers(env, cutoff)
  334. const today = new Date().toISOString().split("T")[0]
  335. const todayNew = await env.DB.prepare(
  336. `SELECT COUNT(*) as count FROM users WHERE first_seen >= ?1`
  337. ).bind(today).first<{ count: number }>()
  338. const feedback = await env.DB.prepare(
  339. `SELECT id, type, message, contact, app_version, created_at
  340. FROM feedback ORDER BY created_at DESC LIMIT 50`
  341. ).all<FeedbackRow>()
  342. const html = renderDashboard({
  343. totalUsers: totalUsers?.count ?? 0,
  344. uniqueIPs: uniqueIPs?.count ?? 0,
  345. onlineCount,
  346. todayNew: todayNew?.count ?? 0,
  347. feedbackRows: feedback.results,
  348. serverTime: new Date().toISOString(),
  349. dashboardKey: auth,
  350. })
  351. return new Response(html, {
  352. headers: { "Content-Type": "text/html;charset=UTF-8" },
  353. })
  354. }
  355. // 定时任务:清理超时会话 + 更新每日统计
  356. async function handleScheduled(env: Env): Promise<void> {
  357. const now = new Date()
  358. const cutoff = activeCutoff(now)
  359. // 超过心跳窗口没有活动的会话标记为离线
  360. await env.DB.prepare(
  361. `UPDATE sessions SET is_online = 0, close_time = ?1
  362. WHERE is_online = 1 AND last_active < ?2`
  363. ).bind(now.toISOString(), cutoff).run()
  364. // 更新今日统计快照
  365. const today = now.toISOString().split("T")[0]
  366. const totalUsers = await env.DB.prepare(
  367. `SELECT COUNT(*) as count FROM users`
  368. ).first<{ count: number }>()
  369. const todayNew = await env.DB.prepare(
  370. `SELECT COUNT(*) as count FROM users WHERE first_seen >= ?1`
  371. ).bind(today).first<{ count: number }>()
  372. const currentOnline = await countActiveUsers(env, cutoff)
  373. // 更新 peak_online(取最大值)
  374. const existingPeak = await env.DB.prepare(
  375. `SELECT peak_online FROM daily_stats WHERE date = ?1`
  376. ).bind(today).first<{ peak_online: number }>()
  377. const peak = Math.max(existingPeak?.peak_online ?? 0, currentOnline)
  378. await env.DB.prepare(
  379. `INSERT INTO daily_stats (date, new_users, total_users, peak_online)
  380. VALUES (?1, ?2, ?3, ?4)
  381. ON CONFLICT(date) DO UPDATE SET
  382. new_users = ?2, total_users = ?3, peak_online = MAX(daily_stats.peak_online, ?4)`
  383. ).bind(today, todayNew?.count ?? 0, totalUsers?.count ?? 0, peak).run()
  384. }
  385. export default {
  386. async fetch(request: Request, env: Env): Promise<Response> {
  387. // 处理 CORS 预检
  388. if (request.method === "OPTIONS") {
  389. return new Response(null, { status: 204, headers: corsHeaders() })
  390. }
  391. const url = new URL(request.url)
  392. const path = url.pathname
  393. try {
  394. if (request.method === "POST" && path === "/open") {
  395. return await handleOpen(request, env)
  396. }
  397. if (request.method === "POST" && path === "/heartbeat") {
  398. return await handleHeartbeat(request, env)
  399. }
  400. if (request.method === "POST" && path === "/close") {
  401. return await handleClose(request, env)
  402. }
  403. if (request.method === "POST" && path === "/feedback") {
  404. return await handleFeedback(request, env)
  405. }
  406. if (request.method === "POST" && path === "/feedback/delete") {
  407. return await handleDeleteFeedback(request, env)
  408. }
  409. if (request.method === "GET" && path === "/stats") {
  410. return await handleStats(request, env)
  411. }
  412. if (request.method === "GET" && path === "/dashboard") {
  413. return await handleDashboard(request, env)
  414. }
  415. return jsonResponse({ error: "not found" }, 404)
  416. } catch (err) {
  417. return jsonResponse({ error: String(err) }, 500)
  418. }
  419. },
  420. async scheduled(_event: ScheduledEvent, env: Env): Promise<void> {
  421. await handleScheduled(env)
  422. },
  423. }