Files
soul/soul-book-api/server.js
卡若 afc2376e96 v1.19 全面改版:VIP会员系统、我的收益、创业老板排行、阅读量排序
- 后端: users表新增VIP字段, 4个VIP API (purchase/status/profile/members)
- 后端: hot接口改按user_tracks阅读量排序
- 后端: orders表支持vip产品类型, migrate新增vip_fields迁移
- 小程序「我的」: 推广中心改为我的收益, 头像VIP标识, VIP入口卡片
- 小程序「我的」: 最近阅读显示真实章节名称
- 小程序首页: 去掉内容概览, 新增创业老板排行(4列网格)
- 小程序首页: 精选推荐从hot接口获取, goToRead增加track记录
- 新增页面: VIP详情页, 会员详情页
- 开发文档精简为10个标准目录, 创建SKILL.md, 需求日志规范化

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 14:07:41 +08:00

244 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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}`)
})