- 后端: 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>
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}`)
|
||
})
|