const http = require('http') const mysql = require('mysql2/promise') const PORT = 3007 const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000 const pool = mysql.createPool({ host: '56b4c23f6853c.gz.cdb.myqcloud.com', port: 14413, user: 'cdb_outerroot', password: 'Zhiqun1984', database: 'soul_miniprogram', charset: 'utf8mb4', waitForConnections: true, connectionLimit: 5, queueLimit: 0 }) function isExcluded(id, partTitle) { const lid = String(id || '').toLowerCase() if (lid === 'preface' || lid === 'epilogue') return true if (lid.startsWith('appendix-') || lid.startsWith('appendix_')) return true const pt = String(partTitle || '') if (/序言|尾声|附录/.test(pt)) return true return false } function cleanPartTitle(pt) { return (pt || '真实的行业').replace(/^第[一二三四五六七八九十]+篇[||]?/, '').trim() || '真实的行业' } async function getFeaturedSections() { const tags = [ { tag: '热门', tagClass: 'tag-pink' }, { tag: '推荐', tagClass: 'tag-purple' }, { tag: '精选', tagClass: 'tag-free' } ] try { const [rows] = await pool.query(` SELECT c.id, c.section_title, c.part_title, c.is_free, COALESCE(t.cnt, 0) as view_count FROM chapters c LEFT JOIN ( SELECT chapter_id, COUNT(*) as cnt FROM user_tracks WHERE action = 'view_chapter' AND chapter_id IS NOT NULL GROUP BY chapter_id ) t ON c.id = t.chapter_id WHERE c.id NOT IN ('preface','epilogue') AND c.id NOT LIKE 'appendix-%' AND c.id NOT LIKE 'appendix\\_%' AND c.part_title NOT LIKE '%序言%' AND c.part_title NOT LIKE '%尾声%' AND c.part_title NOT LIKE '%附录%' ORDER BY view_count DESC, c.updated_at DESC LIMIT 6 `) if (rows && rows.length > 0) { return rows.slice(0, 3).map((r, i) => ({ id: r.id, title: r.section_title || '', part: cleanPartTitle(r.part_title), tag: tags[i]?.tag || '推荐', tagClass: tags[i]?.tagClass || 'tag-purple' })) } } catch (e) { console.error('[featured] query error:', e.message) } try { const [fallback] = await pool.query(` SELECT id, section_title, part_title, is_free FROM chapters WHERE id NOT IN ('preface','epilogue') AND id NOT LIKE 'appendix-%' AND id NOT LIKE 'appendix\\_%' AND part_title NOT LIKE '%序言%' AND part_title NOT LIKE '%尾声%' AND part_title NOT LIKE '%附录%' ORDER BY updated_at DESC LIMIT 3 `) if (fallback?.length > 0) { return fallback.map((r, i) => ({ id: r.id, title: r.section_title || '', part: cleanPartTitle(r.part_title), tag: tags[i]?.tag || '推荐', tagClass: tags[i]?.tagClass || 'tag-purple' })) } } catch (_) {} return [ { id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', tagClass: 'tag-free', part: '真实的人' }, { id: '3.1', title: '3000万流水如何跑出来', tag: '热门', tagClass: 'tag-pink', part: '真实的行业' }, { id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' } ] } async function handleLatestChapters(res) { try { const [rows] = await pool.query(` SELECT id, part_title, section_title, is_free, price, created_at, updated_at FROM chapters ORDER BY sort_order ASC, id ASC `) let chapters = (rows || []) .map(r => ({ id: r.id, title: r.section_title || '', part: cleanPartTitle(r.part_title), isFree: !!r.is_free, price: r.price || 0, updatedAt: r.updated_at || r.created_at, createdAt: r.created_at })) .filter(c => !isExcluded(c.id, c.part)) if (chapters.length === 0) { return sendJSON(res, { success: true, banner: { id: '1.1', title: '开始阅读', part: '真实的人' }, label: '为你推荐', chapters: [], hasNewUpdates: false }) } const sorted = [...chapters].sort((a, b) => { const ta = a.updatedAt ? new Date(a.updatedAt).getTime() : 0 const tb = b.updatedAt ? new Date(b.updatedAt).getTime() : 0 return tb - ta }) const mostRecentTime = sorted[0]?.updatedAt ? new Date(sorted[0].updatedAt).getTime() : 0 const hasNewUpdates = Date.now() - mostRecentTime < TWO_DAYS_MS let banner, label, selected if (hasNewUpdates) { selected = sorted.slice(0, 3) banner = { id: selected[0].id, title: selected[0].title, part: selected[0].part } label = '最新更新' } else { const free = chapters.filter(c => c.isFree || c.price === 0) const candidates = free.length > 0 ? free : chapters const shuffled = [...candidates].sort(() => Math.random() - 0.5) selected = shuffled.slice(0, 3) banner = { id: selected[0].id, title: selected[0].title, part: selected[0].part } label = '为你推荐' } sendJSON(res, { success: true, banner, label, chapters: selected.map(c => ({ id: c.id, title: c.title, part: c.part, isFree: c.isFree })), hasNewUpdates }) } catch (e) { console.error('[latest-chapters] error:', e.message) sendJSON(res, { success: false, error: '获取失败' }, 500) } } async function handleAllChapters(res) { const featuredSections = await getFeaturedSections() try { const [rows] = await pool.query(` SELECT id, part_id, part_title, chapter_id, chapter_title, section_title, content, is_free, price, word_count, sort_order, created_at, updated_at FROM chapters ORDER BY sort_order ASC, id ASC `) if (rows && rows.length > 0) { const seen = new Set() const data = rows .map(r => ({ mid: r.mid || 0, id: r.id, partId: r.part_id || '', partTitle: r.part_title || '', chapterId: r.chapter_id || '', chapterTitle: r.chapter_title || '', sectionTitle: r.section_title || '', content: r.content || '', wordCount: r.word_count || 0, isFree: !!r.is_free, price: r.price || 0, sortOrder: r.sort_order || 0, status: 'published', createdAt: r.created_at, updatedAt: r.updated_at })) .filter(r => { if (seen.has(r.id)) return false seen.add(r.id) return true }) return sendJSON(res, { success: true, data, total: data.length, featuredSections }) } } catch (e) { console.error('[all-chapters] error:', e.message) } sendJSON(res, { success: true, data: [], total: 0, featuredSections }) } function sendJSON(res, obj, code = 200) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' }) res.end(JSON.stringify(obj)) } const server = http.createServer(async (req, res) => { if (req.method === 'OPTIONS') { return sendJSON(res, {}) } const url = req.url.split('?')[0] if (url === '/api/book/latest-chapters') { return handleLatestChapters(res) } if (url === '/api/book/all-chapters') { return handleAllChapters(res) } if (url === '/health') { return sendJSON(res, { status: 'ok', time: new Date().toISOString() }) } sendJSON(res, { error: 'not found' }, 404) }) server.listen(PORT, '127.0.0.1', () => { console.log(`[soul-book-api] running on port ${PORT}`) })