Merge branch 'yongpxu-soul' of https://github.com/fnvtk/Mycontent into yongpxu-soul

# Conflicts:
#	.cursorrules   resolved by yongpxu-soul version
#	miniprogram/app.json   resolved by yongpxu-soul version
#	miniprogram/pages/index/index.js   resolved by yongpxu-soul version
#	miniprogram/pages/index/index.wxml   resolved by yongpxu-soul version
#	miniprogram/pages/my/my.js   resolved by yongpxu-soul version
#	miniprogram/pages/my/my.wxml   resolved by yongpxu-soul version
#	miniprogram/pages/my/my.wxss   resolved by yongpxu-soul version
#	miniprogram/pages/settings/settings.js   resolved by yongpxu-soul version
#	miniprogram/pages/settings/settings.wxml   resolved by yongpxu-soul version
#	miniprogram/上传小程序.py   resolved by yongpxu-soul version
#	next-project/app/admin/error.tsx   resolved by yongpxu-soul version
#	next-project/app/admin/page.tsx   resolved by yongpxu-soul version
#	next-project/app/api/book/all-chapters/route.ts   resolved by yongpxu-soul version
#	next-project/lib/admin-auth.ts   resolved by yongpxu-soul version
#	next-project/lib/db.ts   resolved by yongpxu-soul version
#	next-project/lib/password.ts   resolved by yongpxu-soul version
#	next-project/package.json   resolved by yongpxu-soul version
#	next-project/scripts/.env.feishu.example   resolved by yongpxu-soul version
#	next-project/scripts/fix_souladmin_login.sh   resolved by yongpxu-soul version
#	next-project/scripts/send_poster_to_feishu.py   resolved by yongpxu-soul version
#	next-project/scripts/upload_soul_article.sh   resolved by yongpxu-soul version
#	soul-admin/dist/assets/index-CbOmKBRd.js   resolved by yongpxu-soul version
#	soul-admin/dist/index.html   resolved by yongpxu-soul version
#	开发文档/10、项目管理/产研团队 第21场 20260129 许永平.txt   resolved by yongpxu-soul version
#	开发文档/5、接口/配置清单-完整版.md   resolved by yongpxu-soul version
#	开发文档/服务器管理/references/端口配置表.md   resolved by yongpxu-soul version
This commit is contained in:
2026-02-24 09:42:34 +08:00
46 changed files with 3486 additions and 110 deletions

View File

@@ -1,74 +1,86 @@
/**
* 热门章节API
* 返回点击量最高的章节
* 按阅读量user_tracks view_chapter排序
*/
import { NextResponse } from 'next/server'
import { query } from '@/lib/db'
const DEFAULT_CHAPTERS = [
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', tagClass: 'tag-free', part: '真实的人', views: 0 },
{ id: '9.12', title: '美业整合:一个人的公司如何月入十万', tag: '热门', tagClass: 'tag-pink', part: '真实的赚钱', views: 0 },
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', tagClass: 'tag-pink', part: '真实的行业', views: 0 },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱', views: 0 },
{ id: '9.13', title: 'AI工具推广一个隐藏的高利润赛道', tag: '最新', tagClass: 'tag-green', part: '真实的赚钱', views: 0 },
]
const SECTION_INFO: Record<string, any> = {
'1.1': { title: '荷包:电动车出租的被动收入模式', part: '真实的人', tag: '免费', tagClass: 'tag-free' },
'1.2': { title: '老墨:资源整合高手的社交方法', part: '真实的人', tag: '推荐', tagClass: 'tag-purple' },
'2.1': { title: '电商的底层逻辑', part: '真实的行业', tag: '推荐', tagClass: 'tag-purple' },
'3.1': { title: '3000万流水如何跑出来', part: '真实的行业', tag: '热门', tagClass: 'tag-pink' },
'4.1': { title: '我的第一次创业失败', part: '真实的错误', tag: '热门', tagClass: 'tag-pink' },
'5.1': { title: '未来职业的三个方向', part: '真实的社会', tag: '推荐', tagClass: 'tag-purple' },
'8.1': { title: '流量杠杆:抖音、Soul、飞书', part: '真实的赚钱', tag: '推荐', tagClass: 'tag-purple' },
'9.12': { title: '美业整合:一个人的公司如何月入十万', part: '真实的赚钱', tag: '热门', tagClass: 'tag-pink' },
'9.13': { title: 'AI工具推广一个隐藏的高利润赛道', part: '真实的赚钱', tag: '最新', tagClass: 'tag-green' },
'9.14': { title: '大健康私域一个月150万的70后', part: '真实的赚钱', tag: '热门', tagClass: 'tag-pink' },
'9.15': { title: '本地同城运营拿150万投资', part: '真实的赚钱', tag: '热门', tagClass: 'tag-pink' },
}
export async function GET() {
try {
// 从数据库查询点击量高的章节(如果有统计表)
let hotChapters = []
let hotChapters: any[] = []
try {
// 尝试从订单表统计购买量高的章节
// 按 user_tracks 的 view_chapter 阅读量排序
const rows = await query(`
SELECT
section_id as id,
COUNT(*) as purchase_count
FROM orders
WHERE status = 'completed' AND section_id IS NOT NULL
GROUP BY section_id
ORDER BY purchase_count DESC
SELECT chapter_id as id, COUNT(*) as view_count
FROM user_tracks
WHERE action = 'view_chapter' AND chapter_id IS NOT NULL AND chapter_id != ''
GROUP BY chapter_id
ORDER BY view_count DESC
LIMIT 10
`) as any[]
if (rows && rows.length > 0) {
// 补充章节信息
const sectionInfo: Record<string, any> = {
'1.1': { title: '荷包:电动车出租的被动收入模式', part: '真实的人', tag: '免费' },
'9.12': { title: '美业整合:一个人的公司如何月入十万', part: '真实的赚钱', tag: '热门' },
'3.1': { title: '3000万流水如何跑出来', part: '真实的行业', tag: '热门' },
'8.1': { title: '流量杠杆:抖音、Soul、飞书', part: '真实的赚钱', tag: '推荐' },
'9.13': { title: 'AI工具推广一个隐藏的高利润赛道', part: '真实的赚钱', tag: '最新' },
'9.14': { title: '大健康私域一个月150万的70后', part: '真实的赚钱', tag: '热门' },
'1.2': { title: '老墨:资源整合高手的社交方法', part: '真实的人', tag: '推荐' },
'2.1': { title: '电商的底层逻辑', part: '真实的行业', tag: '推荐' },
'4.1': { title: '我的第一次创业失败', part: '真实的错误', tag: '热门' },
'5.1': { title: '未来职业的三个方向', part: '真实的社会', tag: '推荐' }
}
hotChapters = rows.map((row: any) => ({
id: row.id,
...(sectionInfo[row.id] || { title: `章节${row.id}`, part: '', tag: '热门' }),
purchaseCount: row.purchase_count
}))
if (rows?.length) {
hotChapters = rows.map((row: any) => {
const info = SECTION_INFO[row.id] || {}
return {
id: row.id,
title: info.title || `章节 ${row.id}`,
part: info.part || '',
tag: info.tag || '热门',
tagClass: info.tagClass || 'tag-pink',
views: row.view_count
}
})
}
} catch (e) {
console.log('[Hot] 数据库查询失败,使用默认数据')
console.log('[Hot] user_tracks查询失败尝试订单统计')
// 降级:从订单表统计
try {
const rows = await query(`
SELECT product_id as id, COUNT(*) as purchase_count
FROM orders WHERE status = 'paid' AND product_id IS NOT NULL
GROUP BY product_id ORDER BY purchase_count DESC LIMIT 10
`) as any[]
if (rows?.length) {
hotChapters = rows.map((row: any) => ({
id: row.id,
...(SECTION_INFO[row.id] || { title: `章节 ${row.id}`, part: '', tag: '热门', tagClass: 'tag-pink' }),
views: row.purchase_count
}))
}
} catch { /* 使用默认 */ }
}
// 如果没有数据,返回默认热门章节
if (hotChapters.length === 0) {
hotChapters = [
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', part: '真实的人' },
{ id: '9.12', title: '美业整合:一个人的公司如何月入十万', tag: '热门', part: '真实的赚钱' },
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', part: '真实的行业' },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', part: '真实的赚钱' },
{ id: '9.13', title: 'AI工具推广一个隐藏的高利润赛道', tag: '最新', part: '真实的赚钱' }
]
if (!hotChapters.length) {
hotChapters = DEFAULT_CHAPTERS
}
return NextResponse.json({
success: true,
chapters: hotChapters
})
return NextResponse.json({ success: true, chapters: hotChapters })
} catch (error) {
console.error('[Hot] Error:', error)
return NextResponse.json({
success: false,
chapters: []
})
return NextResponse.json({ success: true, chapters: DEFAULT_CHAPTERS })
}
}

View File

@@ -1,55 +1,109 @@
// app/api/book/latest-chapters/route.ts
// 获取最新章节列表
// 获取最新章节有2日内更新则取最新3章否则随机取免费章节
// 排除序言、尾声、附录,只推荐正文章节
import { NextRequest, NextResponse } from 'next/server'
import { getBookStructure } from '@/lib/book-file-system'
import { NextResponse } from 'next/server'
import { query } from '@/lib/db'
export async function GET(req: NextRequest) {
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() {
try {
const bookStructure = getBookStructure()
// 获取所有章节并按时间排序
const allChapters: any[] = []
bookStructure.forEach((part: any) => {
part.chapters.forEach((chapter: any) => {
allChapters.push({
id: chapter.slug,
title: chapter.title,
part: part.title,
words: Math.floor(Math.random() * 3000) + 1500, // 模拟字数
updateTime: getRelativeTime(new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000)),
readTime: Math.ceil((Math.random() * 3000 + 1500) / 300)
})
let allChapters: Array<{
id: string
title: string
part: string
isFree: boolean
price: number
updatedAt: Date | string | null
createdAt: Date | string | null
}> = []
try {
const dbRows = (await query(`
SELECT id, part_title, section_title, is_free, price, created_at, updated_at
FROM chapters
ORDER BY sort_order ASC, id ASC
`)) as any[]
if (dbRows?.length > 0) {
allChapters = dbRows
.map((row: any) => ({
id: row.id,
title: row.section_title || row.title || '',
part: row.part_title || '真实的行业',
isFree: !!row.is_free,
price: row.price || 0,
updatedAt: row.updated_at || row.created_at,
createdAt: row.created_at
}))
.filter((c) => !isExcludedChapter(c.id, c.part))
}
} catch (e) {
console.log('[latest-chapters] 数据库读取失败:', (e as Error).message)
}
if (allChapters.length === 0) {
return NextResponse.json({
success: true,
banner: { id: '1.1', title: '荷包:电动车出租的被动收入模式', part: '真实的人' },
label: '为你推荐',
chapters: [],
hasNewUpdates: false
})
}
const now = Date.now()
const sorted = [...allChapters].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
})
// 取最新的3章
const latestChapters = allChapters.slice(0, 3)
const mostRecentTime = sorted[0]?.updatedAt ? new Date(sorted[0].updatedAt).getTime() : 0
const hasNewUpdates = now - mostRecentTime < TWO_DAYS_MS
let banner: { id: string; title: string; part: string }
let label: string
let chapters: typeof allChapters
if (hasNewUpdates && sorted.length > 0) {
chapters = sorted.slice(0, 3)
banner = { id: chapters[0].id, title: chapters[0].title, part: chapters[0].part }
label = '最新更新'
} else {
const freeChapters = allChapters.filter((c) => c.isFree || c.price === 0)
const candidates = freeChapters.length > 0 ? freeChapters : allChapters
const shuffled = [...candidates].sort(() => Math.random() - 0.5)
chapters = shuffled.slice(0, 3)
banner = chapters[0]
? { id: chapters[0].id, title: chapters[0].title, part: chapters[0].part }
: { id: allChapters[0].id, title: allChapters[0].title, part: allChapters[0].part }
label = '为你推荐'
}
return NextResponse.json({
success: true,
chapters: latestChapters,
total: allChapters.length
banner,
label,
chapters: chapters.map((c) => ({ id: c.id, title: c.title, part: c.part, isFree: c.isFree })),
hasNewUpdates
})
} catch (error) {
console.error('获取章节失败:', error)
console.error('[latest-chapters] Error:', error)
return NextResponse.json(
{ error: '获取章节失败' },
{ success: false, error: '获取失败' },
{ status: 500 }
)
}
}
// 获取相对时间
function getRelativeTime(date: Date): string {
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) return '今天'
if (days === 1) return '昨天'
if (days < 7) return `${days}天前`
if (days < 30) return `${Math.floor(days / 7)}周前`
return `${Math.floor(days / 30)}个月前`
}

View File

@@ -146,13 +146,40 @@ export async function GET(request: NextRequest) {
}
// 列出所有章节(不含内容)
// 优先从数据库读取,确保新建章节能立即显示
if (action === 'list') {
const sectionsFromDb = new Map<string, any>()
try {
const rows = await query(`
SELECT id, part_id, part_title, chapter_id, chapter_title, section_title,
price, is_free, content
FROM chapters ORDER BY part_id, chapter_id, id
`) as any[]
if (rows && rows.length > 0) {
for (const r of rows) {
sectionsFromDb.set(r.id, {
id: r.id,
title: r.section_title || '',
price: r.price ?? 1,
isFree: !!r.is_free,
partId: r.part_id || 'part-1',
partTitle: r.part_title || '',
chapterId: r.chapter_id || 'chapter-1',
chapterTitle: r.chapter_title || '',
filePath: ''
})
}
}
} catch (e) {
console.log('[Book API] list 从数据库读取失败,回退到 bookData:', (e as Error).message)
}
// 合并:以数据库为准,数据库没有的用 bookData 补
const sections: any[] = []
for (const part of bookData) {
for (const chapter of part.chapters) {
for (const section of chapter.sections) {
sections.push({
const dbRow = sectionsFromDb.get(section.id)
sections.push(dbRow || {
id: section.id,
title: section.title,
price: section.price,
@@ -163,14 +190,25 @@ export async function GET(request: NextRequest) {
chapterTitle: chapter.title,
filePath: section.filePath
})
sectionsFromDb.delete(section.id)
}
}
}
// 数据库有但 bookData 没有的(新建章节)
for (const [, v] of sectionsFromDb) {
sections.push(v)
}
// 按 id 去重,避免数据库重复或合并逻辑导致同一文章出现多次
const seen = new Set<string>()
const deduped = sections.filter((s) => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
})
return NextResponse.json({
success: true,
sections,
total: sections.length
sections: deduped,
total: deduped.length
})
}
@@ -324,7 +362,7 @@ export async function POST(request: NextRequest) {
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { id, title, content, price, saveToFile = true } = body
const { id, title, content, price, saveToFile = true, partId, chapterId, partTitle, chapterTitle, isFree } = body
if (!id) {
return NextResponse.json({
@@ -334,28 +372,40 @@ export async function PUT(request: NextRequest) {
}
const sectionInfo = getSectionInfo(id)
const finalPartId = partId || sectionInfo?.partId || 'part-1'
const finalPartTitle = partTitle || sectionInfo?.partTitle || '未分类'
const finalChapterId = chapterId || sectionInfo?.chapterId || 'chapter-1'
const finalChapterTitle = chapterTitle || sectionInfo?.chapterTitle || '未分类'
const finalPrice = price ?? sectionInfo?.section?.price ?? 1
const finalIsFree = isFree ?? sectionInfo?.section?.isFree ?? false
// 更新数据库
// 更新数据库(含新建章节)
try {
await query(`
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, price, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
ON DUPLICATE KEY UPDATE
part_id = VALUES(part_id),
part_title = VALUES(part_title),
chapter_id = VALUES(chapter_id),
chapter_title = VALUES(chapter_title),
section_title = VALUES(section_title),
content = VALUES(content),
word_count = VALUES(word_count),
is_free = VALUES(is_free),
price = VALUES(price),
updated_at = CURRENT_TIMESTAMP
`, [
id,
sectionInfo?.partId || 'part-1',
sectionInfo?.partTitle || '未分类',
sectionInfo?.chapterId || 'chapter-1',
sectionInfo?.chapterTitle || '未分类',
title || sectionInfo?.section.title || '',
finalPartId,
finalPartTitle,
finalChapterId,
finalChapterTitle,
title || sectionInfo?.section?.title || '',
content || '',
(content || '').length,
price ?? sectionInfo?.section.price ?? 1
finalIsFree,
finalPrice
])
} catch (e) {
console.error('[Book API] 更新数据库失败:', e)

View File

@@ -115,6 +115,47 @@ export async function POST(request: NextRequest) {
}
}
// VIP会员字段
if (!migration || migration === 'vip_fields') {
const vipFields = [
{ name: 'is_vip', def: "BOOLEAN DEFAULT FALSE COMMENT 'VIP会员'" },
{ name: 'vip_expire_date', def: "TIMESTAMP NULL COMMENT 'VIP到期时间'" },
{ name: 'vip_name', def: "VARCHAR(100) COMMENT '会员真实姓名'" },
{ name: 'vip_project', def: "VARCHAR(200) COMMENT '会员项目名称'" },
{ name: 'vip_contact', def: "VARCHAR(100) COMMENT '会员联系方式'" },
{ name: 'vip_avatar', def: "VARCHAR(500) COMMENT '会员展示头像'" },
{ name: 'vip_bio', def: "VARCHAR(500) COMMENT '会员简介'" },
]
let addedCount = 0
let existCount = 0
for (const field of vipFields) {
try {
await query(`SELECT ${field.name} FROM users LIMIT 1`)
existCount++
} catch {
try {
await query(`ALTER TABLE users ADD COLUMN ${field.name} ${field.def}`)
addedCount++
} catch (e: any) {
if (e.code !== 'ER_DUP_FIELDNAME') {
results.push(`⚠️ 添加VIP字段 ${field.name} 失败: ${e.message}`)
}
}
}
}
// 扩展 orders.product_type 支持 vip
try {
await query(`ALTER TABLE orders MODIFY COLUMN product_type ENUM('section', 'fullbook', 'match', 'vip') NOT NULL`)
results.push('✅ orders.product_type 已支持 vip')
} catch (e: any) {
results.push(' orders.product_type 更新跳过: ' + e.message)
}
if (addedCount > 0) results.push(`✅ VIP字段新增 ${addedCount}`)
if (existCount > 0) results.push(` VIP字段已有 ${existCount} 个存在`)
}
// 用户标签定义表
if (!migration || migration === 'user_tag_definitions') {
try {
@@ -189,7 +230,7 @@ export async function GET() {
// 检查用户表字段
const userFields: Record<string, boolean> = {}
const checkFields = ['ckb_user_id', 'ckb_synced_at', 'ckb_tags', 'tags', 'merged_tags']
const checkFields = ['ckb_user_id', 'ckb_synced_at', 'ckb_tags', 'tags', 'merged_tags', 'is_vip', 'vip_expire_date', 'vip_name']
for (const field of checkFields) {
try {