/** * DMP API ๆœๅŠกๅ™จ * * ๐Ÿ“š ๆ€ง่ƒฝ็ญ–็•ฅ๏ผš * 1. ๅ†…ๅญ˜็ผ“ๅญ˜๏ผˆLRU๏ผ‰- ้ซ˜้ข‘ๆŸฅ่ฏขไธ่ตฐ DB * 2. ไฝๅ›พไบคๅ‰่ฎก็ฎ— - ๅคšๆ ‡็ญพ AND/OR ็”จ SQL INTERSECT/UNION * 3. ้ข„่ฎก็ฎ—็ปŸ่ฎก - coverage ๅญ—ๆฎตๅœจๅ†™ๅ…ฅๆ—ถ็ปดๆŠค * 4. ่ฟžๆŽฅๆฑ  - ๆฏไธช่ฏทๆฑ‚ๅค็”จๅ•ไธช DB ่ฟžๆŽฅ * * ๐Ÿ“š ๆ•ฐๆฎๅฏผๅ…ฅๆŽฅๅฃ่ฎพ่ฎก๏ผš * POST /api/import/users - ๆ‰น้‡ๅฏผๅ…ฅ็”จๆˆท * POST /api/import/user-tags - ๆ‰น้‡ๅฏผๅ…ฅ็”จๆˆทๆ ‡็ญพ * GET /api/import/batches - ๆŸฅ็œ‹ๅฏผๅ…ฅๅކๅฒ */ const express = require('express'); const cors = require('cors'); const path = require('path'); const { getDb } = require('./db/init'); const app = express(); const PORT = 3456; app.use(cors()); app.use(express.json({ limit: '50mb' })); app.use(express.static(path.join(__dirname, 'public'))); // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // ็ฎ€ๆ˜“ๅ†…ๅญ˜็ผ“ๅญ˜๏ผˆTTL 60s๏ผ‰ // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const cache = new Map(); function cacheGet(key) { const item = cache.get(key); if (!item) return null; if (Date.now() > item.expires) { cache.delete(key); return null; } return item.value; } function cacheSet(key, value, ttlMs = 60_000) { cache.set(key, { value, expires: Date.now() + ttlMs }); } function cacheInvalidate(prefix) { for (const k of cache.keys()) { if (k.startsWith(prefix)) cache.delete(k); } } // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // ๅทฅๅ…ท // โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function asyncHandler(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // 1. ๆ ‡็ญพไฝ“็ณป API // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• /** GET /api/tags โ€” ่ฟ”ๅ›žๆ‰€ๆœ‰ๅˆ†็ฑป+ๆ ‡็ญพ๏ผˆๅซ่ฆ†็›–ๆ•ฐ๏ผ‰๏ผŒๅธฆ็ผ“ๅญ˜ */ app.get('/api/tags', asyncHandler(async (req, res) => { const theme = req.query.theme || 'onion'; const cacheKey = `tags:${theme}:all`; const hit = cacheGet(cacheKey); if (hit) return res.json(hit); const db = getDb(theme); try { const categories = db.prepare('SELECT * FROM tag_categories ORDER BY sort_order').all(); const tags = db.prepare(` SELECT t.*, tc.key as cat_key, tc.color as cat_color FROM tags t JOIN tag_categories tc ON t.category_id = tc.id ORDER BY t.category_id, t.sort_order `).all(); const result = categories.map(cat => ({ ...cat, tags: tags .filter(t => t.category_id === cat.id) .map(t => ({ ...t, source: (cat.key === 'core_problem' && /๏ผˆๆŽจๆ–ญ๏ผ‰$/.test(t.name)) ? 'inferred' : 'original' })) })); const totalUsers = db.prepare('SELECT COUNT(*) as n FROM users').get().n; cacheSet(cacheKey, { categories: result, totalUsers }, 300_000); // 5ๅˆ†้’Ÿ res.json({ categories: result, totalUsers }); } finally { db.close(); } })); // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // 2. ๅฎžๆ—ถไบคๅ‰่ฎก็ฎ— API โ† ๆ ธๅฟƒๅŠŸ่ƒฝ // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• /** * POST /api/compute * Body: { * selected: { tagId: number, mode: 'include'|'exclude' }[], * logic: 'AND' | 'OR' * } * Returns: { count, rate, breakdown: { tagId, count, rate }[] } * * ๐Ÿ“š ไบคๅ‰่ฎก็ฎ—็ฎ—ๆณ•๏ผš * AND๏ผˆๅฟ…้กปๅŒๆ—ถๆ‹ฅๆœ‰๏ผ‰โ†’ INTERSECT * OR๏ผˆๆ‹ฅๆœ‰ไปปไธ€ๅณๅฏ๏ผ‰โ†’ UNION / IN * EXCLUDE๏ผˆๆŽ’้™ค๏ผ‰ โ†’ EXCEPT / NOT EXISTS * SQLite ็š„ INTERSECT ๅบ•ๅฑ‚ๅฐฑๆ˜ฏๅฏนๆœ‰ๅบ้›†ๅˆๅš merge-join๏ผŒO(n log n) */ app.post('/api/compute', asyncHandler(async (req, res) => { const { selected = [] } = req.body; const theme = req.query.theme || 'onion'; // ็ผ“ๅญ˜ key const cacheKey = `compute:${theme}:${selected.map(s => `${s.tagId}:${s.mode}`).sort().join(',')}`; const hit = cacheGet(cacheKey); if (hit) return res.json(hit); const db = getDb(theme); try { const totalUsers = db.prepare('SELECT COUNT(*) as n FROM users').get().n; // ๅˆ†็ฆป include / exclude const includes = selected.filter(s => s.mode !== 'exclude'); const excludes = selected.filter(s => s.mode === 'exclude'); // ๆž„ๅปบๅˆ†็ฑปๆ„Ÿ็Ÿฅ็š„ SQL let baseSql; const baseParams = []; if (includes.length === 0) { baseSql = 'SELECT id as user_id FROM users'; } else { // ่Žทๅ–ๆฏไธชๆ ‡็ญพ็š„ๅˆ†็ฑปไฟกๆฏ const categoryMap = {}; for (const inc of includes) { const tagInfo = db.prepare(` SELECT t.id, t.category_id FROM tags t WHERE t.id = ? `).get(inc.tagId); if (tagInfo) { if (!categoryMap[tagInfo.category_id]) { categoryMap[tagInfo.category_id] = []; } categoryMap[tagInfo.category_id].push(inc.tagId); } } // ไธบๆฏไธชๅˆ†็ฑป็”Ÿๆˆ SQL ๅญๅฅ // ๅŒไธ€ๅˆ†็ฑป๏ผšOR ้€ป่พ‘๏ผˆIN๏ผ‰ // ไธๅŒๅˆ†็ฑป๏ผšAND ้€ป่พ‘๏ผˆINTERSECT๏ผ‰ const categoryParts = []; for (const catId in categoryMap) { const tagIds = categoryMap[catId]; if (tagIds.length === 1) { // ๅ•ไธชๆ ‡็ญพ๏ผš็›ดๆŽฅ็”จ tagId baseParams.push(tagIds[0]); categoryParts.push(`SELECT user_id FROM user_tags WHERE tag_id = ?`); } else { // ๅคšไธชๆ ‡็ญพ๏ผš็”จ IN๏ผˆOR๏ผ‰ baseParams.push(...tagIds); const placeholders = tagIds.map(() => '?').join(','); categoryParts.push(`SELECT user_id FROM user_tags WHERE tag_id IN (${placeholders})`); } } // ็”จ INTERSECT ้“พๆŽฅๅ„ไธชๅˆ†็ฑป็š„็ป“ๆžœ๏ผˆAND๏ผ‰ baseSql = categoryParts.join(' INTERSECT '); } // ๅ ๅŠ  EXCLUDE let finalSql = baseSql; const finalParams = [...baseParams]; for (const ex of excludes) { finalSql = `${finalSql} EXCEPT SELECT user_id FROM user_tags WHERE tag_id = ?`; finalParams.push(ex.tagId); } // ่ฎก็ฎ—ไธป็ป“ๆžœ const countSql = `SELECT COUNT(*) as n FROM (${finalSql})`; const mainCount = db.prepare(countSql).get(...finalParams).n; // ่ฎก็ฎ—ๆฏไธชๅทฒ้€‰ๆ ‡็ญพ็š„็ป†ๅˆ†๏ผˆๆœฌๆฌก็ป“ๆžœ้›†ไธญๆ‹ฅๆœ‰่ฏฅๆ ‡็ญพ็š„ไบบๆ•ฐ๏ผ‰ let breakdown = []; if (selected.length > 0 && mainCount > 0) { // ๅฏนๆฏไธชๅทฒ้€‰ๆ ‡็ญพ๏ผŒ่ฎก็ฎ—ๅฎƒๅœจ็ป“ๆžœ้›†ๅ†…็š„่ฆ†็›– const allTagIds = selected.map(s => s.tagId); const breakdownStmt = db.prepare(` SELECT tag_id, COUNT(*) as n FROM user_tags WHERE user_id IN (${finalSql}) AND tag_id IN (${allTagIds.map(() => '?').join(',')}) GROUP BY tag_id `); const rows = breakdownStmt.all(...finalParams, ...allTagIds); breakdown = rows.map(r => ({ tagId: r.tag_id, count: r.n, rate: mainCount > 0 ? +(r.n / mainCount * 100).toFixed(1) : 0 })); } const result = { count: mainCount, rate: +(mainCount / totalUsers * 100).toFixed(2), totalUsers, breakdown }; cacheSet(cacheKey, result, 30_000); // 30s ็ผ“ๅญ˜ res.json(result); } finally { db.close(); } })); /** * POST /api/compute/cross * ่ฎก็ฎ—ไธคไธชๆ ‡็ญพๅœจๅฝ“ๅ‰็ป“ๆžœ้›†ๅ†…็š„ไบคๅ‰ๅˆ†ๅธƒ๏ผˆ็”จไบŽ็ƒญๅŠ›ๅ›พ/ๆก‘ๅŸบๅ›พ๏ผ‰ * Body: { selected, logic, crossTagIds: number[] } */ app.post('/api/compute/cross', asyncHandler(async (req, res) => { const { selected = [], crossTagIds = [] } = req.body; const theme = req.query.theme || 'onion'; if (crossTagIds.length === 0) return res.json({ matrix: [] }); const db = getDb(theme); try { const includes = selected.filter(s => s.mode !== 'exclude'); const excludes = selected.filter(s => s.mode === 'exclude'); let baseSql, baseParams = []; if (includes.length === 0) { baseSql = 'SELECT id as user_id FROM users'; } else { const parts = includes.map(s => { baseParams.push(s.tagId); return `SELECT user_id FROM user_tags WHERE tag_id = ?`; }); baseSql = parts.join(' INTERSECT '); } let finalSql = baseSql; const finalParams = [...baseParams]; for (const ex of excludes) { finalSql += ` EXCEPT SELECT user_id FROM user_tags WHERE tag_id = ?`; finalParams.push(ex.tagId); } const baseCount = db.prepare(`SELECT COUNT(*) as n FROM (${finalSql})`).get(...finalParams).n; // ๅฏนๆฏไธชไบคๅ‰ๆ ‡็ญพ่ฎก็ฎ—่ฆ†็›– const matrix = []; for (const tagId of crossTagIds) { const n = db.prepare(` SELECT COUNT(*) as n FROM (${finalSql}) WHERE user_id IN (SELECT user_id FROM user_tags WHERE tag_id = ?) `).get(...finalParams, tagId).n; matrix.push({ tagId, count: n, rate: baseCount > 0 ? +(n / baseCount * 100).toFixed(1) : 0 }); } res.json({ baseCount, matrix }); } finally { db.close(); } })); // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // 3. ๆ•ฐๆฎๅฏผๅ…ฅ API โ† ๆŽฅๅ…ฅ็‚น // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• /** * POST /api/import/users * ๆ‰น้‡ๅฏผๅ…ฅ/ๆ›ดๆ–ฐ็”จๆˆทๅŸบ็ก€ๆ•ฐๆฎ * Body: { users: [{ uid, name, email, extra_json? }] } * * ๐Ÿ“š ่ฎพ่ฎกๅŽŸๅˆ™๏ผš * - ไฝฟ็”จ INSERT OR REPLACE ๅš upsert * - ๅˆ†ๆ‰นๆไบคไบ‹ๅŠก๏ผˆๆฏ1000ๆกไธ€ๆ‰น๏ผ‰๏ผŒ้ฟๅ…้”่ถ…ๆ—ถ * - ่ฟ”ๅ›žๅฏผๅ…ฅๆ‰นๆฌก ID๏ผŒไพ›ๅŽ็ปญ่ฟฝ่ธช */ app.post('/api/import/users', asyncHandler(async (req, res) => { const { users = [], source = 'api' } = req.body; const theme = req.query.theme || 'onion'; if (!Array.isArray(users) || users.length === 0) { return res.status(400).json({ error: 'invalid users array' }); } const db = getDb(theme); try { const batchRes = db.prepare( 'INSERT INTO import_batches (source, record_count, status) VALUES (?, ?, ?)' ).run(source, users.length, 'running'); const batchId = batchRes.lastInsertRowid; const stmt = db.prepare( 'INSERT OR REPLACE INTO users (uid, name, email, extra_json) VALUES (?, ?, ?, ?)' ); let imported = 0; const BATCH = 1000; for (let i = 0; i < users.length; i += BATCH) { const chunk = users.slice(i, i + BATCH); const tx = db.transaction(() => { for (const u of chunk) { stmt.run(u.uid, u.name || '', u.email || '', JSON.stringify(u.extra_json || {})); imported++; } }); tx(); } db.prepare( "UPDATE import_batches SET status='done', record_count=?, finished_at=datetime('now') WHERE id=?" ).run(imported, batchId); cacheInvalidate(`tags:${theme}`); res.json({ batchId, imported, total: users.length }); } catch (err) { db.prepare("UPDATE import_batches SET status='error', error_message=? WHERE id=?") .run(err.message, -1); throw err; } finally { db.close(); } })); /** * POST /api/import/user-tags * ๆ‰น้‡ๅฏผๅ…ฅ็”จๆˆทๆ ‡็ญพๅ…ณ่” * Body: { assignments: [{ uid, tagKey }], mode: 'append'|'replace' } * * mode='replace': ๅ…ˆๆธ…้™ค็”จๆˆท็Žฐๆœ‰ๆ ‡็ญพ๏ผŒๅ†ๅ†™ๅ…ฅ๏ผˆๅฎŒๆ•ดๅˆทๆ–ฐ๏ผ‰ * mode='append': ไป…่ฟฝๅŠ ๏ผŒไธๅˆ ้™คๆ—งๆ ‡็ญพ๏ผˆๅขž้‡ๆ›ดๆ–ฐ๏ผ‰ */ app.post('/api/import/user-tags', asyncHandler(async (req, res) => { const { assignments = [], source = 'api', mode = 'append' } = req.body; const theme = req.query.theme || 'onion'; if (!Array.isArray(assignments) || assignments.length === 0) { return res.status(400).json({ error: 'invalid assignments array' }); } const db = getDb(theme); try { // ๆž„ๅปบๆŸฅ่ฏข็ผ“ๅญ˜ const userStmt = db.prepare('SELECT id FROM users WHERE uid = ?'); const tagStmt = db.prepare('SELECT id FROM tags WHERE key = ?'); const insertStmt = db.prepare('INSERT OR IGNORE INTO user_tags (user_id, tag_id) VALUES (?, ?)'); const deleteStmt = db.prepare('DELETE FROM user_tags WHERE user_id = ?'); const batchRes = db.prepare( 'INSERT INTO import_batches (source, record_count, status) VALUES (?, ?, ?)' ).run(source, assignments.length, 'running'); const batchId = batchRes.lastInsertRowid; let imported = 0; let skipped = 0; // ๆŒ‰ uid ๅˆ†็ป„๏ผŒๆ”ฏๆŒ replace ๆจกๅผ const grouped = {}; for (const a of assignments) { if (!grouped[a.uid]) grouped[a.uid] = []; grouped[a.uid].push(a.tagKey); } const tx = db.transaction(() => { for (const [uid, tagKeys] of Object.entries(grouped)) { const user = userStmt.get(uid); if (!user) { skipped += tagKeys.length; continue; } if (mode === 'replace') deleteStmt.run(user.id); for (const tagKey of tagKeys) { const tag = tagStmt.get(tagKey); if (!tag) { skipped++; continue; } insertStmt.run(user.id, tag.id); imported++; } } }); tx(); // ๆ›ดๆ–ฐ่ฆ†็›–็ปŸ่ฎก const totalUsers = db.prepare('SELECT COUNT(*) as n FROM users').get().n; db.exec(` UPDATE tags SET coverage = (SELECT COUNT(*) FROM user_tags WHERE tag_id = tags.id), coverage_rate = ROUND((SELECT COUNT(*) FROM user_tags WHERE tag_id = tags.id) * 100.0 / ${totalUsers}, 2) `); db.prepare("UPDATE import_batches SET status='done', finished_at=datetime('now') WHERE id=?").run(batchId); cacheInvalidate(`tags:${theme}`); cacheInvalidate(`compute:${theme}`); res.json({ batchId, imported, skipped }); } catch (err) { throw err; } finally { db.close(); } })); /** GET /api/import/batches โ€” ๆŸฅ็œ‹ๅฏผๅ…ฅๅކๅฒ */ app.get('/api/import/batches', asyncHandler(async (req, res) => { const theme = req.query.theme || 'onion'; const db = getDb(theme); try { const batches = db.prepare( 'SELECT * FROM import_batches ORDER BY id DESC LIMIT 50' ).all(); res.json(batches); } finally { db.close(); } })); /** DELETE /api/import/reset โ€” ๆธ…็ฉบๆ‰€ๆœ‰็”จๆˆทๆ•ฐๆฎ๏ผˆไป…ๅผ€ๅ‘็”จ๏ผ‰ */ app.delete('/api/import/reset', asyncHandler(async (req, res) => { const theme = req.query.theme || 'onion'; const db = getDb(theme); try { db.exec('DELETE FROM user_tags; DELETE FROM users;'); db.exec('UPDATE tags SET coverage=0, coverage_rate=0'); cacheInvalidate(`tags:${theme}`); cacheInvalidate(`compute:${theme}`); res.json({ ok: true }); } finally { db.close(); } })); // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // 4. ็”จๆˆทๆ˜Ž็ป† API // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• /** * POST /api/users/sample * ่Žทๅ–ๅฝ“ๅ‰ๅœˆ้€‰็ป“ๆžœ็š„็”จๆˆทๆ ทๆœฌ๏ผˆๆœ€ๅคš100ๆก๏ผ‰ */ app.post('/api/users/sample', asyncHandler(async (req, res) => { const { selected = [], limit = 50 } = req.body; const theme = req.query.theme || 'onion'; const db = getDb(theme); try { const includes = selected.filter(s => s.mode !== 'exclude'); const excludes = selected.filter(s => s.mode === 'exclude'); let baseSql, baseParams = []; if (includes.length === 0) { baseSql = 'SELECT id as user_id FROM users'; } else { const parts = includes.map(s => { baseParams.push(s.tagId); return `SELECT user_id FROM user_tags WHERE tag_id = ?`; }); baseSql = parts.join(' INTERSECT '); } let finalSql = baseSql; const finalParams = [...baseParams]; for (const ex of excludes) { finalSql += ` EXCEPT SELECT user_id FROM user_tags WHERE tag_id = ?`; finalParams.push(ex.tagId); } const users = db.prepare(` SELECT u.uid, u.name, u.email, u.created_at, u.extra_json FROM users u WHERE u.id IN (${finalSql}) ORDER BY RANDOM() LIMIT ? `).all(...finalParams, Math.min(limit, 100)); // ่งฃๆž extra_json const enrichedUsers = users.map(u => { try { const extra = JSON.parse(u.extra_json || '{}'); return { ...u, ...extra, extra_json: undefined }; } catch { return u; } }); res.json({ users: enrichedUsers }); } finally { db.close(); } })); /** * GET /api/duration-stats * ่Žทๅ–"ๆŒ‡ๅฏผๅ‘จๆœŸ"็›ธๅ…ณ็š„็ปŸ่ฎกไฟกๆฏ */ app.get('/api/duration-stats', asyncHandler(async (req, res) => { const theme = req.query.theme || 'onion'; const cacheKey = `duration-stats:${theme}`; const hit = cacheGet(cacheKey); if (hit) return res.json(hit); const db = getDb(theme); try { const totalUsers = db.prepare('SELECT COUNT(*) as n FROM users').get().n; // ่Žทๅ–ๅ„ๅคฉๆ•ฐๆ ‡็ญพ็š„็ปŸ่ฎก const durationStats = db.prepare(` SELECT t.id, t.key, t.name, COUNT(ut.user_id) as count, ROUND(COUNT(ut.user_id) * 100.0 / ?, 2) as rate FROM tags t LEFT JOIN user_tags ut ON t.id = ut.tag_id WHERE t.key LIKE 'duration_%' GROUP BY t.id, t.key, t.name ORDER BY t.sort_order `).all(totalUsers); const result = { totalUsers, durationBreakdown: durationStats.map(s => ({ id: s.id, key: s.key, name: s.name, count: s.count || 0, rate: s.rate || 0 })) }; cacheSet(cacheKey, result, 300_000); // 5ๅˆ†้’Ÿ res.json(result); } finally { db.close(); } })); // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• // 5. ้”™่ฏฏๅค„็† // โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• app.use((err, req, res, next) => { console.error(err); res.status(500).json({ error: err.message }); }); // SPA fallback app.get('/{*path}', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.listen(PORT, () => { console.log(`\n๐Ÿš€ DMP ๆœๅŠกๅฏๅŠจ: http://localhost:${PORT}`); console.log(`๐Ÿ“ก ๅฏผๅ…ฅ API: POST /api/import/users`); console.log(`๐Ÿ“ก ๆ ‡็ญพ API: POST /api/import/user-tags`); console.log(`๐Ÿ“ก ่ฎก็ฎ— API: POST /api/compute\n`); });