244 lines
7.6 KiB
JavaScript
244 lines
7.6 KiB
JavaScript
|
|
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}`)
|
|||
|
|
})
|