feat: 首页推荐逻辑-排除序言尾声,精选按点击量,小程序接入featuredSections

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
卡若
2026-02-21 20:59:22 +08:00
parent 7551840c86
commit e91a5d9f7a
3 changed files with 105 additions and 18 deletions

View File

@@ -3,7 +3,72 @@ import fs from 'fs'
import path from 'path' import path from 'path'
import { query } from '@/lib/db' import { query } from '@/lib/db'
/** 精选推荐:按 user_tracks 的 view_chapter 点击量排序,排除序言/尾声/附录 */
async function getFeaturedSections(): Promise<Array<{ id: string; title: string; tag: string; tagClass: string; part: string }>> {
const tags = [
{ tag: '热门', tagClass: 'tag-pink' },
{ tag: '推荐', tagClass: 'tag-purple' },
{ tag: '精选', tagClass: 'tag-free' }
]
try {
// 优先按 view_chapter 点击量排序
const rows = (await 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 '%尾声%')
ORDER BY view_count DESC, c.updated_at DESC
LIMIT 3
`)) as any[]
if (rows && rows.length > 0) {
return rows.map((r, i) => ({
id: r.id,
title: r.section_title || r.title || '',
part: (r.part_title || '真实的行业').replace(/^第[一二三四五六七八九十]+篇|?/, '').trim() || '真实的行业',
tag: tags[i]?.tag || '推荐',
tagClass: tags[i]?.tagClass || 'tag-purple'
}))
}
} catch (e) {
console.log('[All Chapters API] 精选推荐查询失败:', (e as Error).message)
}
try {
const fallback = (await 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 '%尾声%')
ORDER BY updated_at DESC
LIMIT 3
`)) as any[]
if (fallback?.length > 0) {
return fallback.map((r, i) => ({
id: r.id,
title: r.section_title || r.title || '',
part: (r.part_title || '真实的行业').replace(/^第[一二三四五六七八九十]+篇|?/, '').trim() || '真实的行业',
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: '真实的赚钱' }
]
}
export async function GET() { export async function GET() {
const featuredSections = await getFeaturedSections()
try { try {
// 方案1: 优先从数据库读取章节数据 // 方案1: 优先从数据库读取章节数据
try { try {
@@ -47,7 +112,8 @@ export async function GET() {
data: allChapters, data: allChapters,
chapters: allChapters, chapters: allChapters,
total: allChapters.length, total: allChapters.length,
source: 'database' source: 'database',
featuredSections
}) })
} }
} catch (dbError) { } catch (dbError) {
@@ -91,7 +157,8 @@ export async function GET() {
data: allChapters, data: allChapters,
chapters: allChapters, chapters: allChapters,
total: allChapters.length, total: allChapters.length,
source: 'database' source: 'database',
featuredSections
}) })
} }
} catch (e2) { } catch (e2) {
@@ -144,7 +211,8 @@ export async function GET() {
chapters: allChapters, chapters: allChapters,
total: allChapters.length, total: allChapters.length,
source: 'file', source: 'file',
path: usedPath path: usedPath,
featuredSections
}) })
} }
@@ -157,7 +225,8 @@ export async function GET() {
data: defaultChapters, data: defaultChapters,
chapters: defaultChapters, chapters: defaultChapters,
total: defaultChapters.length, total: defaultChapters.length,
source: 'default' source: 'default',
featuredSections
}) })
} catch (error) { } catch (error) {
@@ -171,7 +240,8 @@ export async function GET() {
chapters: defaultChapters, chapters: defaultChapters,
total: defaultChapters.length, total: defaultChapters.length,
source: 'fallback', source: 'fallback',
warning: '使用默认数据' warning: '使用默认数据',
featuredSections
}) })
} }
} }

View File

@@ -1,11 +1,22 @@
// app/api/book/latest-chapters/route.ts // app/api/book/latest-chapters/route.ts
// 获取最新章节有2日内更新则取最新3章否则随机取免费章节 // 获取最新章节有2日内更新则取最新3章否则随机取免费章节
// 排除序言、尾声、附录,只推荐正文章节
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { query } from '@/lib/db' import { query } from '@/lib/db'
const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000 const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000
/** 是否应排除(序言、尾声、附录等特殊章节) */
function isExcludedChapter(id: string, partTitle: string): boolean {
const lowerId = String(id || '').toLowerCase()
if (lowerId === 'preface' || lowerId === 'epilogue') return true
if (lowerId.startsWith('appendix-') || lowerId.startsWith('appendix_')) return true
const pt = String(partTitle || '')
if (/序言|尾声/.test(pt)) return true
return false
}
export async function GET() { export async function GET() {
try { try {
let allChapters: Array<{ let allChapters: Array<{
@@ -26,15 +37,17 @@ export async function GET() {
`)) as any[] `)) as any[]
if (dbRows?.length > 0) { if (dbRows?.length > 0) {
allChapters = dbRows.map((row: any) => ({ allChapters = dbRows
id: row.id, .map((row: any) => ({
title: row.section_title || row.title || '', id: row.id,
part: row.part_title || '真实的行业', title: row.section_title || row.title || '',
isFree: !!row.is_free, part: row.part_title || '真实的行业',
price: row.price || 0, isFree: !!row.is_free,
updatedAt: row.updated_at || row.created_at, price: row.price || 0,
createdAt: row.created_at updatedAt: row.updated_at || row.created_at,
})) createdAt: row.created_at
}))
.filter((c) => !isExcludedChapter(c.id, c.part))
} }
} catch (e) { } catch (e) {
console.log('[latest-chapters] 数据库读取失败:', (e as Error).message) console.log('[latest-chapters] 数据库读取失败:', (e as Error).message)

View File

@@ -145,15 +145,19 @@ Page({
this.setData({ latestSection: selected, latestLabel: '为你推荐' }) this.setData({ latestSection: selected, latestLabel: '为你推荐' })
}, },
// 加载书籍数据 // 加载书籍数据(含精选推荐,按后端点击量排序)
async loadBookData() { async loadBookData() {
try { try {
const res = await app.request('/api/book/all-chapters') const res = await app.request('/api/book/all-chapters')
if (res && res.data) { if (res && res.data) {
this.setData({ const setData = {
bookData: res.data, bookData: res.data,
totalSections: res.totalSections || 62 totalSections: res.totalSections || res.data?.length || 62
}) }
if (res.featuredSections && res.featuredSections.length) {
setData.featuredSections = res.featuredSections
}
this.setData(setData)
} }
} catch (e) { } catch (e) {
console.error('加载书籍数据失败:', e) console.error('加载书籍数据失败:', e)