feat: 我的页整合扫一扫/设置与提现、all-chapters去重、内容上传API、文档与后台登录
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
41
app/admin/error.tsx
Normal file
41
app/admin/error.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
export default function AdminError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('[Admin] 页面错误:', error)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#0a1628]">
|
||||||
|
<div className="bg-[#0f2137] border border-gray-700/50 rounded-2xl p-8 max-w-md w-full mx-4 shadow-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-5xl mb-4">😞</div>
|
||||||
|
<h2 className="text-xl font-bold text-white mb-2">哎呀,出错了</h2>
|
||||||
|
<p className="text-gray-400 text-sm mb-6">页面遇到了一些问题,请稍后再试</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="flex-1 py-2.5 rounded-lg bg-[#38bdac] hover:bg-[#2da396] text-white text-sm font-medium"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/admin"
|
||||||
|
className="flex-1 py-2.5 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm font-medium text-center"
|
||||||
|
>
|
||||||
|
返回后台
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,24 +11,25 @@ export default function AdminDashboard() {
|
|||||||
const [users, setUsers] = useState<any[]>([])
|
const [users, setUsers] = useState<any[]>([])
|
||||||
const [purchases, setPurchases] = useState<any[]>([])
|
const [purchases, setPurchases] = useState<any[]>([])
|
||||||
|
|
||||||
// 从API获取数据
|
// 从API获取数据(任意接口失败时仍保持页面可展示,不抛错)
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
try {
|
try {
|
||||||
// 获取用户数据
|
|
||||||
const usersRes = await fetch('/api/db/users')
|
const usersRes = await fetch('/api/db/users')
|
||||||
const usersData = await usersRes.json()
|
const usersData = await usersRes.ok ? usersRes.json().catch(() => ({})) : { success: false }
|
||||||
if (usersData.success && usersData.users) {
|
if (usersData.success && Array.isArray(usersData.users)) {
|
||||||
setUsers(usersData.users)
|
setUsers(usersData.users)
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
// 获取订单数据
|
console.warn('加载用户数据失败', e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
const ordersRes = await fetch('/api/orders')
|
const ordersRes = await fetch('/api/orders')
|
||||||
const ordersData = await ordersRes.json()
|
const ordersData = await ordersRes.ok ? ordersRes.json().catch(() => ({})) : { success: false }
|
||||||
if (ordersData.success && ordersData.orders) {
|
if (ordersData.success && Array.isArray(ordersData.orders)) {
|
||||||
setPurchases(ordersData.orders)
|
setPurchases(ordersData.orders)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('加载数据失败', e)
|
console.warn('加载订单数据失败', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ export default function AdminDashboard() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalRevenue = purchases.reduce((sum, p) => sum + (p.amount || 0), 0)
|
const totalRevenue = purchases.reduce((sum, p) => sum + (Number(p?.amount) || 0), 0)
|
||||||
const totalUsers = users.length
|
const totalUsers = users.length
|
||||||
const totalPurchases = purchases.length
|
const totalPurchases = purchases.length
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ export default function AdminDashboard() {
|
|||||||
{ title: "总用户数", value: totalUsers, icon: Users, color: "text-blue-400", bg: "bg-blue-500/20", link: "/admin/users" },
|
{ title: "总用户数", value: totalUsers, icon: Users, color: "text-blue-400", bg: "bg-blue-500/20", link: "/admin/users" },
|
||||||
{
|
{
|
||||||
title: "总收入",
|
title: "总收入",
|
||||||
value: `¥${totalRevenue.toFixed(2)}`,
|
value: `¥${Number(totalRevenue).toFixed(2)}`,
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
color: "text-[#38bdac]",
|
color: "text-[#38bdac]",
|
||||||
bg: "bg-[#38bdac]/20",
|
bg: "bg-[#38bdac]/20",
|
||||||
@@ -80,7 +81,7 @@ export default function AdminDashboard() {
|
|||||||
{ title: "订单数", value: totalPurchases, icon: ShoppingBag, color: "text-purple-400", bg: "bg-purple-500/20", link: "/admin/orders" },
|
{ title: "订单数", value: totalPurchases, icon: ShoppingBag, color: "text-purple-400", bg: "bg-purple-500/20", link: "/admin/orders" },
|
||||||
{
|
{
|
||||||
title: "转化率",
|
title: "转化率",
|
||||||
value: `${totalUsers > 0 ? ((totalPurchases / totalUsers) * 100).toFixed(1) : 0}%`,
|
value: `${totalUsers > 0 ? (Number(totalPurchases) / Number(totalUsers) * 100).toFixed(1) : 0}%`,
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
color: "text-orange-400",
|
color: "text-orange-400",
|
||||||
bg: "bg-orange-500/20",
|
bg: "bg-orange-500/20",
|
||||||
@@ -132,11 +133,11 @@ export default function AdminDashboard() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-white">{p.sectionTitle || "整本购买"}</p>
|
<p className="text-sm font-medium text-white">{p.sectionTitle || "整本购买"}</p>
|
||||||
<p className="text-xs text-gray-500">{new Date(p.createdAt).toLocaleString()}</p>
|
<p className="text-xs text-gray-500">{p?.createdAt ? new Date(p.createdAt).toLocaleString() : "-"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-bold text-[#38bdac]">+¥{p.amount}</p>
|
<p className="text-sm font-bold text-[#38bdac]">+¥{Number(p?.amount) || 0}</p>
|
||||||
<p className="text-xs text-gray-400">{p.paymentMethod || "微信支付"}</p>
|
<p className="text-xs text-gray-400">{p?.paymentMethod || "微信支付"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -169,7 +170,7 @@ export default function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : "-"}
|
{u?.createdAt ? new Date(u.createdAt).toLocaleDateString() : "-"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
9
app/api/admin/logout/route.ts
Normal file
9
app/api/admin/logout/route.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getAdminCookieName, getAdminCookieOptions } from '@/lib/admin-auth'
|
||||||
|
|
||||||
|
export async function POST(_req: NextRequest) {
|
||||||
|
const res = NextResponse.json({ success: true })
|
||||||
|
const opts = getAdminCookieOptions()
|
||||||
|
res.cookies.set(getAdminCookieName(), '', { ...opts, maxAge: 0 })
|
||||||
|
return res
|
||||||
|
}
|
||||||
72
app/api/auth/login/route.ts
Normal file
72
app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Web 端登录:手机号 + 密码
|
||||||
|
* POST { phone, password } -> 校验后返回用户信息(不含密码)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
import { verifyPassword } from '@/lib/password'
|
||||||
|
|
||||||
|
function mapRowToUser(r: any) {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
phone: r.phone || '',
|
||||||
|
nickname: r.nickname || '',
|
||||||
|
isAdmin: !!r.is_admin,
|
||||||
|
purchasedSections: Array.isArray(r.purchased_sections)
|
||||||
|
? r.purchased_sections
|
||||||
|
: (r.purchased_sections ? JSON.parse(String(r.purchased_sections)) : []) || [],
|
||||||
|
hasFullBook: !!r.has_full_book,
|
||||||
|
referralCode: r.referral_code || '',
|
||||||
|
earnings: parseFloat(String(r.earnings || 0)),
|
||||||
|
pendingEarnings: parseFloat(String(r.pending_earnings || 0)),
|
||||||
|
withdrawnEarnings: parseFloat(String(r.withdrawn_earnings || 0)),
|
||||||
|
referralCount: Number(r.referral_count) || 0,
|
||||||
|
createdAt: r.created_at || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { phone, password } = body
|
||||||
|
|
||||||
|
if (!phone || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '请输入手机号和密码' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT id, phone, nickname, password, is_admin, has_full_book, referral_code, earnings, pending_earnings, withdrawn_earnings, referral_count, purchased_sections, created_at FROM users WHERE phone = ?',
|
||||||
|
[String(phone).trim()]
|
||||||
|
) as any[]
|
||||||
|
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '用户不存在或密码错误' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
const storedPassword = row.password == null ? '' : String(row.password)
|
||||||
|
|
||||||
|
if (!verifyPassword(String(password), storedPassword)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '密码错误' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = mapRowToUser(row)
|
||||||
|
return NextResponse.json({ success: true, user })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Auth Login] error:', e)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '登录失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/api/auth/reset-password/route.ts
Normal file
54
app/api/auth/reset-password/route.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 忘记密码 / 重置密码(Web 端)
|
||||||
|
* POST { phone, newPassword } -> 按手机号更新密码(无验证码版本,适合内测/内部使用)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
import { hashPassword } from '@/lib/password'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { phone, newPassword } = body
|
||||||
|
|
||||||
|
if (!phone || !newPassword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '请输入手机号和新密码' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedPhone = String(phone).trim()
|
||||||
|
const trimmedPassword = String(newPassword).trim()
|
||||||
|
|
||||||
|
if (trimmedPassword.length < 6) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '密码至少 6 位' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query('SELECT id FROM users WHERE phone = ?', [trimmedPhone]) as any[]
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '该手机号未注册' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashed = hashPassword(trimmedPassword)
|
||||||
|
await query('UPDATE users SET password = ?, updated_at = NOW() WHERE phone = ?', [
|
||||||
|
hashed,
|
||||||
|
trimmedPhone,
|
||||||
|
])
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: '密码已重置,请使用新密码登录' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Auth ResetPassword] error:', e)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '重置失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,23 +17,30 @@ export async function GET() {
|
|||||||
`) as any[]
|
`) as any[]
|
||||||
|
|
||||||
if (dbChapters && dbChapters.length > 0) {
|
if (dbChapters && dbChapters.length > 0) {
|
||||||
console.log('[All Chapters API] 从数据库读取成功,共', dbChapters.length, '章')
|
console.log('[All Chapters API] 从数据库读取成功,共', dbChapters.length, '条')
|
||||||
|
|
||||||
// 格式化数据
|
// 格式化并按 id 去重(保留首次出现)
|
||||||
const allChapters = dbChapters.map((chapter: any) => ({
|
const seen = new Set<string>()
|
||||||
id: chapter.id,
|
const allChapters = dbChapters
|
||||||
sectionId: chapter.section_id,
|
.map((chapter: any) => ({
|
||||||
title: chapter.title,
|
id: chapter.id,
|
||||||
sectionTitle: chapter.section_title,
|
sectionId: chapter.section_id ?? chapter.id,
|
||||||
content: chapter.content,
|
title: chapter.title ?? chapter.section_title,
|
||||||
isFree: !!chapter.is_free,
|
sectionTitle: chapter.section_title ?? chapter.title,
|
||||||
price: chapter.price || 0,
|
content: chapter.content,
|
||||||
words: chapter.words || Math.floor(Math.random() * 3000) + 2000,
|
isFree: !!chapter.is_free,
|
||||||
sectionOrder: chapter.section_order,
|
price: chapter.price || 0,
|
||||||
chapterOrder: chapter.chapter_order,
|
words: chapter.words || Math.floor(Math.random() * 3000) + 2000,
|
||||||
createdAt: chapter.created_at,
|
sectionOrder: chapter.section_order,
|
||||||
updatedAt: chapter.updated_at
|
chapterOrder: chapter.chapter_order,
|
||||||
}))
|
createdAt: chapter.created_at,
|
||||||
|
updatedAt: chapter.updated_at
|
||||||
|
}))
|
||||||
|
.filter((row: { id: string }) => {
|
||||||
|
if (seen.has(row.id)) return false
|
||||||
|
seen.add(row.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -44,7 +51,51 @@ export async function GET() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
console.log('[All Chapters API] 数据库读取失败,尝试文件读取:', (dbError as Error).message)
|
console.log('[All Chapters API] sections 表读取失败,尝试 chapters 表:', (dbError as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方案1b: 从 chapters 表读取(与 lib/db 表结构一致)
|
||||||
|
try {
|
||||||
|
const dbChapters = await 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
|
||||||
|
`) as any[]
|
||||||
|
|
||||||
|
if (dbChapters && dbChapters.length > 0) {
|
||||||
|
console.log('[All Chapters API] 从 chapters 表读取成功,共', dbChapters.length, '条')
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const allChapters = dbChapters
|
||||||
|
.map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
sectionId: row.id,
|
||||||
|
title: row.section_title,
|
||||||
|
sectionTitle: row.section_title,
|
||||||
|
content: row.content,
|
||||||
|
isFree: !!row.is_free,
|
||||||
|
price: row.price || 0,
|
||||||
|
words: row.word_count || 0,
|
||||||
|
sectionOrder: row.sort_order ?? 0,
|
||||||
|
chapterOrder: 0,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
}))
|
||||||
|
.filter((row: { id: string }) => {
|
||||||
|
if (seen.has(row.id)) return false
|
||||||
|
seen.add(row.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: allChapters,
|
||||||
|
chapters: allChapters,
|
||||||
|
total: allChapters.length,
|
||||||
|
source: 'database'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e2) {
|
||||||
|
console.log('[All Chapters API] chapters 表读取失败,尝试文件:', (e2 as Error).message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 方案2: 从JSON文件读取
|
// 方案2: 从JSON文件读取
|
||||||
@@ -72,11 +123,20 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (chaptersData.length > 0) {
|
if (chaptersData.length > 0) {
|
||||||
// 添加字数估算
|
// 添加字数估算并按 id 去重
|
||||||
const allChapters = chaptersData.map((chapter: any) => ({
|
const seen = new Set<string>()
|
||||||
...chapter,
|
const allChapters = chaptersData
|
||||||
words: chapter.words || Math.floor(Math.random() * 3000) + 2000
|
.map((chapter: any) => ({
|
||||||
}))
|
...chapter,
|
||||||
|
id: chapter.id ?? chapter.sectionId,
|
||||||
|
words: chapter.words || Math.floor(Math.random() * 3000) + 2000
|
||||||
|
}))
|
||||||
|
.filter((row: any) => {
|
||||||
|
const id = row.id || row.sectionId
|
||||||
|
if (!id || seen.has(String(id))) return false
|
||||||
|
seen.add(String(id))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
97
app/api/content/upload/route.ts
Normal file
97
app/api/content/upload/route.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 内容上传 API
|
||||||
|
* 供科室/Skill 直接上传单篇文章到书籍内容,写入 chapters 表
|
||||||
|
* 字段:标题、定价、内容、格式、插入内容中的图片(URL 列表)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
|
||||||
|
function slug(id: string): string {
|
||||||
|
return id.replace(/\s+/g, '-').replace(/[^\w\u4e00-\u9fa5-]/g, '').slice(0, 30) || 'section'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
price = 1,
|
||||||
|
content = '',
|
||||||
|
format = 'markdown',
|
||||||
|
images = [],
|
||||||
|
partId = 'part-1',
|
||||||
|
partTitle = '真实的人',
|
||||||
|
chapterId = 'chapter-1',
|
||||||
|
chapterTitle = '未分类',
|
||||||
|
isFree = false,
|
||||||
|
sectionId
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!title || typeof title !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '标题 title 不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若内容中含占位符 {{image_0}} {{image_1}},用 images 数组替换
|
||||||
|
let finalContent = typeof content === 'string' ? content : ''
|
||||||
|
if (Array.isArray(images) && images.length > 0) {
|
||||||
|
images.forEach((url: string, i: number) => {
|
||||||
|
finalContent = finalContent.replace(
|
||||||
|
new RegExp(`\\{\\{image_${i}\\}\\}`, 'g'),
|
||||||
|
url.startsWith('http') ? `` : url
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 未替换的占位符去掉
|
||||||
|
finalContent = finalContent.replace(/\{\{image_\d+\}\}/g, '')
|
||||||
|
|
||||||
|
const wordCount = (finalContent || '').length
|
||||||
|
const id = sectionId || `upload.${slug(title)}.${Date.now()}`
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, sort_order, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 9999, 'published')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
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,
|
||||||
|
partId,
|
||||||
|
partTitle,
|
||||||
|
chapterId,
|
||||||
|
chapterTitle,
|
||||||
|
title,
|
||||||
|
finalContent,
|
||||||
|
wordCount,
|
||||||
|
!!isFree,
|
||||||
|
Number(price) || 1
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
id,
|
||||||
|
message: '内容已上传并写入 chapters 表',
|
||||||
|
title,
|
||||||
|
price: Number(price) || 1,
|
||||||
|
isFree: !!isFree,
|
||||||
|
wordCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Content Upload]', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: '上传失败: ' + (error as Error).message
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,13 +146,40 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 列出所有章节(不含内容)
|
// 列出所有章节(不含内容)
|
||||||
|
// 优先从数据库读取,确保新建章节能立即显示
|
||||||
if (action === 'list') {
|
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[] = []
|
const sections: any[] = []
|
||||||
|
|
||||||
for (const part of bookData) {
|
for (const part of bookData) {
|
||||||
for (const chapter of part.chapters) {
|
for (const chapter of part.chapters) {
|
||||||
for (const section of chapter.sections) {
|
for (const section of chapter.sections) {
|
||||||
sections.push({
|
const dbRow = sectionsFromDb.get(section.id)
|
||||||
|
sections.push(dbRow || {
|
||||||
id: section.id,
|
id: section.id,
|
||||||
title: section.title,
|
title: section.title,
|
||||||
price: section.price,
|
price: section.price,
|
||||||
@@ -163,14 +190,25 @@ export async function GET(request: NextRequest) {
|
|||||||
chapterTitle: chapter.title,
|
chapterTitle: chapter.title,
|
||||||
filePath: section.filePath
|
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({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
sections,
|
sections: deduped,
|
||||||
total: sections.length
|
total: deduped.length
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +362,7 @@ export async function POST(request: NextRequest) {
|
|||||||
export async function PUT(request: NextRequest) {
|
export async function PUT(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
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) {
|
if (!id) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -334,28 +372,40 @@ export async function PUT(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sectionInfo = getSectionInfo(id)
|
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 {
|
try {
|
||||||
await query(`
|
await query(`
|
||||||
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, price, status)
|
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, status)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
|
||||||
ON DUPLICATE KEY UPDATE
|
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),
|
section_title = VALUES(section_title),
|
||||||
content = VALUES(content),
|
content = VALUES(content),
|
||||||
word_count = VALUES(word_count),
|
word_count = VALUES(word_count),
|
||||||
|
is_free = VALUES(is_free),
|
||||||
price = VALUES(price),
|
price = VALUES(price),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
`, [
|
`, [
|
||||||
id,
|
id,
|
||||||
sectionInfo?.partId || 'part-1',
|
finalPartId,
|
||||||
sectionInfo?.partTitle || '未分类',
|
finalPartTitle,
|
||||||
sectionInfo?.chapterId || 'chapter-1',
|
finalChapterId,
|
||||||
sectionInfo?.chapterTitle || '未分类',
|
finalChapterTitle,
|
||||||
title || sectionInfo?.section.title || '',
|
title || sectionInfo?.section?.title || '',
|
||||||
content || '',
|
content || '',
|
||||||
(content || '').length,
|
(content || '').length,
|
||||||
price ?? sectionInfo?.section.price ?? 1
|
finalIsFree,
|
||||||
|
finalPrice
|
||||||
])
|
])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Book API] 更新数据库失败:', e)
|
console.error('[Book API] 更新数据库失败:', e)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function RootLayout({
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<body className="bg-black">
|
<body className="bg-black">
|
||||||
<LayoutWrapper>{children}</LayoutWrapper>
|
<LayoutWrapper>{children}</LayoutWrapper>
|
||||||
<Analytics />
|
{process.env.NODE_ENV === 'production' && <Analytics />}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
"每个人都在梦想特斯拉帮他挣钱,我现在电动车帮我挣钱。"
|
|
||||||
|
|
||||||
2025年10月21日,周一,早上6点18分。
|
|
||||||
|
|
||||||
Soul派对房里进来一个人,声音很稳。
|
|
||||||
|
|
||||||
他上麦之后,先听了十分钟。
|
|
||||||
|
|
||||||
然后说了一句话:"你讲的被动收入,我做了好几年了。"
|
|
||||||
|
|
||||||
我愣了一下。
|
|
||||||
|
|
||||||
Soul上吹牛的人太多,但这个人的语气不像吹牛。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
"那你是做什么的?"
|
|
||||||
|
|
||||||
"电动车。"
|
|
||||||
|
|
||||||
"电动车?卖车的?"
|
|
||||||
|
|
||||||
”不是,出租的。"
|
|
||||||
|
|
||||||
"出租电动车?"
|
|
||||||
|
|
||||||
"对,在泉州,我有1000辆电动车。"
|
|
||||||
|
|
||||||
派对房里,突然安静了。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
"1000辆?怎么做的?"
|
|
||||||
|
|
||||||
他笑了。
|
|
||||||
|
|
||||||
"其实很简单。"
|
|
||||||
|
|
||||||
"你找一个工厂、工业园区,那里有很多工人,对吧?"
|
|
||||||
|
|
||||||
"工人上下班需要交通工具,骑电动车最方便。"
|
|
||||||
|
|
||||||
"但买一辆电动车要两三千块,很多人舍不得。"
|
|
||||||
|
|
||||||
"那我就租给他们。"
|
|
||||||
|
|
||||||
他停了一下。
|
|
||||||
|
|
||||||
"一个月三百六十几块,一天算下来才十几块钱。"
|
|
||||||
|
|
||||||
"工人觉得划算,我也稳定赚钱。"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
派对房里,有人打字:"那你一个月能赚多少?"
|
|
||||||
|
|
||||||
他说:"1000辆车,一个月就是三十多万流水。"
|
|
||||||
|
|
||||||
"扣掉成本、维护、人工,净利润大概十几万。"
|
|
||||||
|
|
||||||
"关键是,这是被动收入。"
|
|
||||||
|
|
||||||
"车放在那里,每个月都有钱进来。"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
我问:"那你怎么找到这些工厂的?"
|
|
||||||
|
|
||||||
他说:"一开始是自己一家一家跑。"
|
|
||||||
|
|
||||||
"后来我发现,最好的办法是找做人力的人合作。"
|
|
||||||
|
|
||||||
"做人力的,手上有大量的工厂资源。"
|
|
||||||
|
|
||||||
"他给我介绍工厂,我给他分成。"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
派对房里,有人问:"那你现在还在扩张吗?"
|
|
||||||
|
|
||||||
他说:"刚投了100多万,在河源又铺了500辆。"
|
|
||||||
|
|
||||||
我有点惊讶。
|
|
||||||
|
|
||||||
"河源?那不是广东那边吗?"
|
|
||||||
|
|
||||||
他说:"对,我在Soul上认识了一个小伙伴,姓李,大家叫他犟总。"
|
|
||||||
|
|
||||||
"他在河源那边有个工业园区,5万多平,工人非常多。"
|
|
||||||
|
|
||||||
"我们一聊,觉得这个事情可以做,就直接签了。"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
派对房里,有人说:"等等,你们是在Soul上认识的?"
|
|
||||||
|
|
||||||
他说:"对,就是在这个派对房里。"
|
|
||||||
|
|
||||||
我笑了。
|
|
||||||
|
|
||||||
"这可能是我们派对房第一个真正落地的合作。"
|
|
||||||
|
|
||||||
他说:"可不是嘛。"
|
|
||||||
|
|
||||||
"犟总那边做人力,我这边有车,一拍即合。"
|
|
||||||
|
|
||||||
"他负责场地和工人,我负责车和运营。"
|
|
||||||
|
|
||||||
"500辆车拉过去,直接就开始赚钱了。"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
派对房里,有人问:"那你这个模式能复制吗?"
|
|
||||||
|
|
||||||
他说:"当然能。"
|
|
||||||
|
|
||||||
"你只要找到有大量人口的地方,工厂、学校、工业园区都行。"
|
|
||||||
|
|
||||||
"然后投车进去,租出去就完了。"
|
|
||||||
|
|
||||||
他停了一下。
|
|
||||||
|
|
||||||
"我现在还在看宝盖山那边。"
|
|
||||||
|
|
||||||
"石狮那个理工学校,有两万六的学生。"
|
|
||||||
|
|
||||||
"如果能摆电动车进去,又是一个新的点。"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
我问:"那你这个生意最难的是什么?"
|
|
||||||
|
|
||||||
他想了一下。
|
|
||||||
|
|
||||||
"最难的是找到对的合作伙伴。"
|
|
||||||
|
|
||||||
"你一个人做不了这个事情,你需要有人帮你搞定场地。"
|
|
||||||
|
|
||||||
"场地有了,车铺进去,后面就是运营的事情了。"
|
|
||||||
|
|
||||||
他继续说:"所以我现在花很多时间在Soul上。"
|
|
||||||
|
|
||||||
"因为这里能认识各种各样的人。"
|
|
||||||
|
|
||||||
"做人力的、做地产的、做工厂的,什么人都有。"
|
|
||||||
|
|
||||||
"你多聊,总能找到合适的合作伙伴。"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
派对房里,有人问:"那你还做什么?"
|
|
||||||
|
|
||||||
他说:"车身广告。"
|
|
||||||
|
|
||||||
"我1000辆电动车,每辆车身上都可以贴广告。"
|
|
||||||
|
|
||||||
"一天一辆车才3毛钱,一个月9块钱。"
|
|
||||||
|
|
||||||
"但1000辆车,一个月就是9000块额外收入。"
|
|
||||||
|
|
||||||
"关键是,这个钱几乎没有成本,纯利润。"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
我问:"所以你的生意模式是,车租出去赚租金,车身贴广告赚广告费?"
|
|
||||||
|
|
||||||
他说:"对,两条腿走路。"
|
|
||||||
|
|
||||||
"租金是主要收入,广告是锦上添花。"
|
|
||||||
|
|
||||||
"以后车多了,广告这块收入会越来越高。"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
那天聊完,已经快9点了。
|
|
||||||
|
|
||||||
我在派对房里总结了一下。
|
|
||||||
|
|
||||||
"刚才荷包分享的,是一个非常典型的被动收入模式。"
|
|
||||||
|
|
||||||
"什么叫被动收入?"
|
|
||||||
|
|
||||||
"就是你把资产放在那里,它自己给你赚钱。"
|
|
||||||
|
|
||||||
"可以是房子出租,可以是电动车出租,可以是任何有需求的资产。"
|
|
||||||
|
|
||||||
我停了一下。
|
|
||||||
|
|
||||||
"但被动收入不是躺着赚钱。"
|
|
||||||
|
|
||||||
"前期你要投入资金、要找合作伙伴、要铺设网络。"
|
|
||||||
|
|
||||||
"等这些都做好了,后面才能相对轻松。"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
早上9点12分,荷包说他要去准备出发了。
|
|
||||||
|
|
||||||
"今天500辆车都到河源了,我要过去盯一下。"
|
|
||||||
|
|
||||||
"祝你顺利。"
|
|
||||||
|
|
||||||
"谢了。下次回来给大家汇报进展。"
|
|
||||||
|
|
||||||
派对房里有人说:"这才是Soul的正确用法。"
|
|
||||||
|
|
||||||
我笑了。
|
|
||||||
|
|
||||||
确实,在这里认识的人,在这里谈成的合作,在这里落地的项目。
|
|
||||||
|
|
||||||
这才是商业社会里社交的真正价值。
|
|
||||||
|
|
||||||
不是认识多少人,而是能不能和对的人一起做对的事。
|
|
||||||
|
|
||||||
荷包和犟总,一个有车,一个有场地。
|
|
||||||
|
|
||||||
两个人在Soul上认识,在现实中落地。
|
|
||||||
|
|
||||||
这就是资源整合最简单的样子。
|
|
||||||
92
lib/admin-auth.ts
Normal file
92
lib/admin-auth.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* 后台管理员登录鉴权:生成/校验签名 Cookie,不暴露账号密码
|
||||||
|
* 账号密码从环境变量读取,默认 admin / key123456(与 .cursorrules 一致)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHmac, timingSafeEqual } from 'crypto'
|
||||||
|
|
||||||
|
const COOKIE_NAME = 'admin_session'
|
||||||
|
const MAX_AGE_SEC = 7 * 24 * 3600 // 7 天
|
||||||
|
const SECRET = process.env.ADMIN_SESSION_SECRET || 'soul-admin-secret-change-in-prod'
|
||||||
|
|
||||||
|
export function getAdminCredentials() {
|
||||||
|
return {
|
||||||
|
username: process.env.ADMIN_USERNAME || 'admin',
|
||||||
|
password: process.env.ADMIN_PASSWORD || 'key123456',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyAdminCredentials(username: string, password: string): boolean {
|
||||||
|
const { username: u, password: p } = getAdminCredentials()
|
||||||
|
return username === u && password === p
|
||||||
|
}
|
||||||
|
|
||||||
|
function sign(payload: string): string {
|
||||||
|
return createHmac('sha256', SECRET).update(payload).digest('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成签名 token,写入 Cookie 用 */
|
||||||
|
export function createAdminToken(): string {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + MAX_AGE_SEC
|
||||||
|
const payload = `${exp}`
|
||||||
|
const sig = sign(payload)
|
||||||
|
return `${payload}.${sig}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验 Cookie 中的 token */
|
||||||
|
export function verifyAdminToken(token: string | null | undefined): boolean {
|
||||||
|
if (!token || typeof token !== 'string') return false
|
||||||
|
const dot = token.indexOf('.')
|
||||||
|
if (dot === -1) return false
|
||||||
|
const payload = token.slice(0, dot)
|
||||||
|
const sig = token.slice(dot + 1)
|
||||||
|
const exp = parseInt(payload, 10)
|
||||||
|
if (Number.isNaN(exp) || exp < Math.floor(Date.now() / 1000)) return false
|
||||||
|
const expected = sign(payload)
|
||||||
|
if (typeof expected !== 'string' || typeof sig !== 'string') return false
|
||||||
|
if (sig.length !== expected.length) return false
|
||||||
|
try {
|
||||||
|
return timingSafeEqual(Buffer.from(sig, 'base64url'), Buffer.from(expected, 'base64url'))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminCookieName() {
|
||||||
|
return COOKIE_NAME
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminCookieOptions() {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
maxAge: MAX_AGE_SEC,
|
||||||
|
path: '/',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从请求中读取 admin cookie 并校验,未通过时返回 null */
|
||||||
|
export function getAdminTokenFromRequest(request: Request): string | null {
|
||||||
|
const cookieHeader = request.headers.get('cookie')
|
||||||
|
if (!cookieHeader) return null
|
||||||
|
const name = COOKIE_NAME + '='
|
||||||
|
const start = cookieHeader.indexOf(name)
|
||||||
|
if (start === -1) return null
|
||||||
|
const valueStart = start + name.length
|
||||||
|
const end = cookieHeader.indexOf(';', valueStart)
|
||||||
|
const value = end === -1 ? cookieHeader.slice(valueStart) : cookieHeader.slice(valueStart, end)
|
||||||
|
return value.trim() || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 若未登录则返回 401 Response,供各 admin API 使用 */
|
||||||
|
export function requireAdminResponse(request: Request): Response | null {
|
||||||
|
const token = getAdminTokenFromRequest(request)
|
||||||
|
if (!verifyAdminToken(token)) {
|
||||||
|
return new Response(JSON.stringify({ error: '未授权访问,请先登录' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -510,6 +510,20 @@ export const bookData: Part[] = [
|
|||||||
isFree: false,
|
isFree: false,
|
||||||
filePath: "book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.14 大健康私域:一个月150万的70后.md",
|
filePath: "book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.14 大健康私域:一个月150万的70后.md",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "9.15",
|
||||||
|
title: "第102场|今年第一个红包你发给谁",
|
||||||
|
price: 1,
|
||||||
|
isFree: false,
|
||||||
|
filePath: "book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.15 第102场|今年第一个红包你发给谁.md",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "9.16",
|
||||||
|
title: "第103场|号商、某客与炸房",
|
||||||
|
price: 1,
|
||||||
|
isFree: false,
|
||||||
|
filePath: "book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.16 第103场|号商、某客与炸房.md",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
173
lib/db.ts
173
lib/db.ts
@@ -1,31 +1,47 @@
|
|||||||
/**
|
/**
|
||||||
* 数据库连接配置
|
* 数据库连接配置
|
||||||
* 使用MySQL数据库存储用户、订单、推广关系等数据
|
* 使用MySQL数据库存储用户、订单、推广关系等数据
|
||||||
|
* 优先从环境变量读取,便于本地/部署分离;未设置时使用默认值
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import mysql from 'mysql2/promise'
|
import mysql from 'mysql2/promise'
|
||||||
|
|
||||||
// 腾讯云外网数据库配置
|
|
||||||
const DB_CONFIG = {
|
const DB_CONFIG = {
|
||||||
host: '56b4c23f6853c.gz.cdb.myqcloud.com',
|
host: process.env.MYSQL_HOST || '56b4c23f6853c.gz.cdb.myqcloud.com',
|
||||||
port: 14413,
|
port: Number(process.env.MYSQL_PORT || '14413'),
|
||||||
user: 'cdb_outerroot',
|
user: process.env.MYSQL_USER || 'cdb_outerroot',
|
||||||
password: 'Zhiqun1984',
|
password: process.env.MYSQL_PASSWORD || 'Zhiqun1984',
|
||||||
database: 'soul_miniprogram',
|
database: process.env.MYSQL_DATABASE || 'soul_miniprogram',
|
||||||
charset: 'utf8mb4',
|
charset: 'utf8mb4',
|
||||||
timezone: '+08:00',
|
timezone: '+08:00',
|
||||||
acquireTimeout: 60000,
|
connectTimeout: 10000, // 10 秒,连接不可达时快速失败,避免长时间挂起
|
||||||
timeout: 60000,
|
acquireTimeout: 15000,
|
||||||
reconnect: true
|
reconnect: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 本地无数据库时可通过 SKIP_DB=1 跳过连接,接口将使用默认配置
|
||||||
|
const SKIP_DB = process.env.SKIP_DB === '1' || process.env.SKIP_DB === 'true'
|
||||||
|
|
||||||
// 连接池
|
// 连接池
|
||||||
let pool: mysql.Pool | null = null
|
let pool: mysql.Pool | null = null
|
||||||
|
// 连接类错误只打一次日志,避免刷屏
|
||||||
|
let connectionErrorLogged = false
|
||||||
|
|
||||||
|
function isConnectionError(err: unknown): boolean {
|
||||||
|
const code = (err as NodeJS.ErrnoException)?.code
|
||||||
|
return (
|
||||||
|
code === 'ETIMEDOUT' ||
|
||||||
|
code === 'ECONNREFUSED' ||
|
||||||
|
code === 'PROTOCOL_CONNECTION_LOST' ||
|
||||||
|
code === 'ENOTFOUND'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取数据库连接池
|
* 获取数据库连接池(SKIP_DB 时不创建)
|
||||||
*/
|
*/
|
||||||
export function getPool() {
|
export function getPool(): mysql.Pool | null {
|
||||||
|
if (SKIP_DB) return null
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
pool = mysql.createPool({
|
pool = mysql.createPool({
|
||||||
...DB_CONFIG,
|
...DB_CONFIG,
|
||||||
@@ -41,12 +57,30 @@ export function getPool() {
|
|||||||
* 执行SQL查询
|
* 执行SQL查询
|
||||||
*/
|
*/
|
||||||
export async function query(sql: string, params?: any[]) {
|
export async function query(sql: string, params?: any[]) {
|
||||||
|
const connection = getPool()
|
||||||
|
if (!connection) {
|
||||||
|
throw new Error('数据库未配置或已跳过 (SKIP_DB)')
|
||||||
|
}
|
||||||
|
// mysql2 内部会读 params.length,不能传 undefined
|
||||||
|
const safeParams = Array.isArray(params) ? params : []
|
||||||
try {
|
try {
|
||||||
const connection = getPool()
|
const [results] = await connection.execute(sql, safeParams)
|
||||||
const [results] = await connection.execute(sql, params)
|
// 确保调用方拿到的始终是数组,避免 undefined.length 报错
|
||||||
return results
|
if (Array.isArray(results)) return results
|
||||||
|
if (results != null) return [results]
|
||||||
|
return []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('数据库查询错误:', error)
|
if (isConnectionError(error)) {
|
||||||
|
if (!connectionErrorLogged) {
|
||||||
|
connectionErrorLogged = true
|
||||||
|
console.warn(
|
||||||
|
'[DB] 数据库连接不可用,将使用本地默认配置。',
|
||||||
|
(error as Error).message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('数据库查询错误:', error)
|
||||||
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +91,7 @@ export async function query(sql: string, params?: any[]) {
|
|||||||
export async function initDatabase() {
|
export async function initDatabase() {
|
||||||
try {
|
try {
|
||||||
console.log('开始初始化数据库表结构...')
|
console.log('开始初始化数据库表结构...')
|
||||||
|
|
||||||
// 用户表(完整字段)
|
// 用户表(完整字段)
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
@@ -88,33 +122,31 @@ export async function initDatabase() {
|
|||||||
INDEX idx_referred_by (referred_by)
|
INDEX idx_referred_by (referred_by)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// 尝试添加可能缺失的字段(用于升级已有数据库)
|
// 尝试添加可能缺失的字段(用于升级已有数据库)
|
||||||
try {
|
// 兼容 MySQL 5.7:IF NOT EXISTS 在 5.7 不支持,先检查列是否存在
|
||||||
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS session_key VARCHAR(100)')
|
const addColumnIfMissing = async (colName: string, colDef: string) => {
|
||||||
} catch (e) { /* 忽略 */ }
|
try {
|
||||||
try {
|
const rows = await query(
|
||||||
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password VARCHAR(100)')
|
"SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = ?",
|
||||||
} catch (e) { /* 忽略 */ }
|
[colName]
|
||||||
try {
|
) as any[]
|
||||||
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS referred_by VARCHAR(50)')
|
if (!rows?.length) {
|
||||||
} catch (e) { /* 忽略 */ }
|
await query(`ALTER TABLE users ADD COLUMN ${colName} ${colDef}`)
|
||||||
try {
|
}
|
||||||
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE')
|
} catch (e) { /* 忽略 */ }
|
||||||
} catch (e) { /* 忽略 */ }
|
}
|
||||||
try {
|
await addColumnIfMissing('session_key', 'VARCHAR(100)')
|
||||||
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS match_count_today INT DEFAULT 0')
|
await addColumnIfMissing('password', 'VARCHAR(100)')
|
||||||
} catch (e) { /* 忽略 */ }
|
await addColumnIfMissing('referred_by', 'VARCHAR(50)')
|
||||||
try {
|
await addColumnIfMissing('is_admin', 'BOOLEAN DEFAULT FALSE')
|
||||||
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_match_date DATE')
|
await addColumnIfMissing('match_count_today', 'INT DEFAULT 0')
|
||||||
} catch (e) { /* 忽略 */ }
|
await addColumnIfMissing('last_match_date', 'DATE')
|
||||||
try {
|
await addColumnIfMissing('withdrawn_earnings', 'DECIMAL(10,2) DEFAULT 0')
|
||||||
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS withdrawn_earnings DECIMAL(10,2) DEFAULT 0')
|
|
||||||
} catch (e) { /* 忽略 */ }
|
|
||||||
|
|
||||||
console.log('用户表初始化完成')
|
console.log('用户表初始化完成')
|
||||||
|
|
||||||
// 订单表
|
// 订单表(含 referrer_id/referral_code、status 含 created/expired)
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS orders (
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
id VARCHAR(50) PRIMARY KEY,
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
@@ -125,9 +157,11 @@ export async function initDatabase() {
|
|||||||
product_id VARCHAR(50),
|
product_id VARCHAR(50),
|
||||||
amount DECIMAL(10,2) NOT NULL,
|
amount DECIMAL(10,2) NOT NULL,
|
||||||
description VARCHAR(200),
|
description VARCHAR(200),
|
||||||
status ENUM('pending', 'paid', 'cancelled', 'refunded') DEFAULT 'pending',
|
status ENUM('created', 'pending', 'paid', 'cancelled', 'refunded', 'expired') DEFAULT 'created',
|
||||||
transaction_id VARCHAR(100),
|
transaction_id VARCHAR(100),
|
||||||
pay_time TIMESTAMP NULL,
|
pay_time TIMESTAMP NULL,
|
||||||
|
referrer_id VARCHAR(50) NULL COMMENT '推荐人用户ID,用于分销归属',
|
||||||
|
referral_code VARCHAR(20) NULL COMMENT '下单时使用的邀请码,便于对账与展示',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
@@ -136,7 +170,7 @@ export async function initDatabase() {
|
|||||||
INDEX idx_status (status)
|
INDEX idx_status (status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// 推广绑定关系表
|
// 推广绑定关系表
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS referral_bindings (
|
CREATE TABLE IF NOT EXISTS referral_bindings (
|
||||||
@@ -162,7 +196,7 @@ export async function initDatabase() {
|
|||||||
INDEX idx_expiry_date (expiry_date)
|
INDEX idx_expiry_date (expiry_date)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// 匹配记录表
|
// 匹配记录表
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS match_records (
|
CREATE TABLE IF NOT EXISTS match_records (
|
||||||
@@ -181,7 +215,7 @@ export async function initDatabase() {
|
|||||||
INDEX idx_status (status)
|
INDEX idx_status (status)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// 推广访问记录表(用于统计「通过链接进的人数」)
|
// 推广访问记录表(用于统计「通过链接进的人数」)
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS referral_visits (
|
CREATE TABLE IF NOT EXISTS referral_visits (
|
||||||
@@ -197,7 +231,7 @@ export async function initDatabase() {
|
|||||||
INDEX idx_created_at (created_at)
|
INDEX idx_created_at (created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// 系统配置表
|
// 系统配置表
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS system_config (
|
CREATE TABLE IF NOT EXISTS system_config (
|
||||||
@@ -210,7 +244,7 @@ export async function initDatabase() {
|
|||||||
INDEX idx_config_key (config_key)
|
INDEX idx_config_key (config_key)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// 章节内容表 - 存储书籍所有章节
|
// 章节内容表 - 存储书籍所有章节
|
||||||
await query(`
|
await query(`
|
||||||
CREATE TABLE IF NOT EXISTS chapters (
|
CREATE TABLE IF NOT EXISTS chapters (
|
||||||
@@ -234,12 +268,12 @@ export async function initDatabase() {
|
|||||||
INDEX idx_sort_order (sort_order)
|
INDEX idx_sort_order (sort_order)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
`)
|
`)
|
||||||
|
|
||||||
console.log('数据库表结构初始化完成')
|
console.log('数据库表结构初始化完成')
|
||||||
|
|
||||||
// 插入默认配置
|
// 插入默认配置
|
||||||
await initDefaultConfig()
|
await initDefaultConfig()
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('初始化数据库失败:', error)
|
console.error('初始化数据库失败:', error)
|
||||||
throw error
|
throw error
|
||||||
@@ -267,13 +301,13 @@ async function initDefaultConfig() {
|
|||||||
maxMatchesPerDay: 10
|
maxMatchesPerDay: 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
INSERT INTO system_config (config_key, config_value, description)
|
INSERT INTO system_config (config_key, config_value, description)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)
|
ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)
|
||||||
`, ['match_config', JSON.stringify(matchConfig), '匹配功能配置'])
|
`, ['match_config', JSON.stringify(matchConfig), '匹配功能配置'])
|
||||||
|
|
||||||
// 推广配置
|
// 推广配置
|
||||||
const referralConfig = {
|
const referralConfig = {
|
||||||
distributorShare: 90, // 推广者分成比例
|
distributorShare: 90, // 推广者分成比例
|
||||||
@@ -281,15 +315,15 @@ async function initDefaultConfig() {
|
|||||||
bindingDays: 30, // 绑定有效期(天)
|
bindingDays: 30, // 绑定有效期(天)
|
||||||
userDiscount: 5 // 用户优惠比例
|
userDiscount: 5 // 用户优惠比例
|
||||||
}
|
}
|
||||||
|
|
||||||
await query(`
|
await query(`
|
||||||
INSERT INTO system_config (config_key, config_value, description)
|
INSERT INTO system_config (config_key, config_value, description)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)
|
ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)
|
||||||
`, ['referral_config', JSON.stringify(referralConfig), '推广功能配置'])
|
`, ['referral_config', JSON.stringify(referralConfig), '推广功能配置'])
|
||||||
|
|
||||||
console.log('默认配置初始化完成')
|
console.log('默认配置初始化完成')
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('初始化默认配置失败:', error)
|
console.error('初始化默认配置失败:', error)
|
||||||
}
|
}
|
||||||
@@ -297,20 +331,23 @@ async function initDefaultConfig() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取系统配置
|
* 获取系统配置
|
||||||
|
* 连接不可达时返回 null,由上层使用本地默认配置,不重复打日志
|
||||||
*/
|
*/
|
||||||
export async function getConfig(key: string) {
|
export async function getConfig(key: string) {
|
||||||
try {
|
try {
|
||||||
const results = await query(
|
const results = await query(
|
||||||
'SELECT config_value FROM system_config WHERE config_key = ?',
|
'SELECT config_value FROM system_config WHERE config_key = ?',
|
||||||
[key]
|
[key]
|
||||||
) as any[]
|
)
|
||||||
|
const rows = Array.isArray(results) ? results : (results != null ? [results] : [])
|
||||||
if (results.length > 0) {
|
if (rows != null && rows.length > 0) {
|
||||||
return results[0].config_value
|
return (rows[0] as any)?.config_value ?? null
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取配置失败:', error)
|
if (!isConnectionError(error)) {
|
||||||
|
console.error('获取配置失败:', error)
|
||||||
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,13 +358,13 @@ export async function getConfig(key: string) {
|
|||||||
export async function setConfig(key: string, value: any, description?: string) {
|
export async function setConfig(key: string, value: any, description?: string) {
|
||||||
try {
|
try {
|
||||||
await query(`
|
await query(`
|
||||||
INSERT INTO system_config (config_key, config_value, description)
|
INSERT INTO system_config (config_key, config_value, description)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
config_value = VALUES(config_value),
|
config_value = VALUES(config_value),
|
||||||
description = COALESCE(VALUES(description), description)
|
description = COALESCE(VALUES(description), description)
|
||||||
`, [key, JSON.stringify(value), description])
|
`, [key, JSON.stringify(value), description])
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('设置配置失败:', error)
|
console.error('设置配置失败:', error)
|
||||||
@@ -336,4 +373,4 @@ export async function setConfig(key: string, value: any, description?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 导出数据库实例
|
// 导出数据库实例
|
||||||
export default { getPool, query, initDatabase, getConfig, setConfig }
|
export default { getPool, query, initDatabase, getConfig, setConfig }
|
||||||
|
|||||||
56
lib/password.ts
Normal file
56
lib/password.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* 密码哈希与校验(仅用于 Web 用户注册/登录,与后台管理员密码无关)
|
||||||
|
* 使用 Node crypto.scrypt,存储格式 saltHex:hashHex,兼容旧明文密码
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { scryptSync, timingSafeEqual, randomFillSync } from 'crypto'
|
||||||
|
|
||||||
|
const SALT_LEN = 16
|
||||||
|
const KEYLEN = 32
|
||||||
|
|
||||||
|
function bufferToHex(buf: Buffer): string {
|
||||||
|
return buf.toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToBuffer(hex: string): Buffer {
|
||||||
|
return Buffer.from(hex, 'hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对明文密码做哈希,存入数据库
|
||||||
|
* 格式: saltHex:hashHex(约 97 字符,适配 VARCHAR(100))
|
||||||
|
* 与 verifyPassword 一致:内部先 trim,保证注册/登录/重置用同一套规则
|
||||||
|
*/
|
||||||
|
export function hashPassword(plain: string): string {
|
||||||
|
const trimmed = String(plain).trim()
|
||||||
|
const salt = Buffer.allocUnsafe(SALT_LEN)
|
||||||
|
randomFillSync(salt)
|
||||||
|
const hash = scryptSync(trimmed, salt, KEYLEN, { N: 16384, r: 8, p: 1 })
|
||||||
|
return bufferToHex(salt) + ':' + bufferToHex(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验密码:支持新格式(salt:hash)与旧明文(兼容历史数据)
|
||||||
|
* 与 hashPassword 一致:对输入先 trim 再参与校验
|
||||||
|
*/
|
||||||
|
export function verifyPassword(plain: string, stored: string | null | undefined): boolean {
|
||||||
|
const trimmed = String(plain).trim()
|
||||||
|
if (stored == null || stored === '') {
|
||||||
|
return trimmed === ''
|
||||||
|
}
|
||||||
|
if (stored.includes(':')) {
|
||||||
|
const [saltHex, hashHex] = stored.split(':')
|
||||||
|
if (!saltHex || !hashHex || saltHex.length !== SALT_LEN * 2 || hashHex.length !== KEYLEN * 2) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const salt = hexToBuffer(saltHex)
|
||||||
|
const expected = hexToBuffer(hashHex)
|
||||||
|
const derived = scryptSync(trimmed, salt, KEYLEN, { N: 16384, r: 8, p: 1 })
|
||||||
|
return derived.length === expected.length && timingSafeEqual(derived, expected)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed === stored
|
||||||
|
}
|
||||||
27
middleware.ts
Normal file
27
middleware.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import type { NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
const ALLOWED_ORIGINS = [
|
||||||
|
'https://souladmin.quwanzhi.com',
|
||||||
|
'http://localhost:5174',
|
||||||
|
'http://127.0.0.1:5174',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const origin = request.headers.get('origin')
|
||||||
|
const res = NextResponse.next()
|
||||||
|
if (origin && ALLOWED_ORIGINS.includes(origin)) {
|
||||||
|
res.headers.set('Access-Control-Allow-Origin', origin)
|
||||||
|
}
|
||||||
|
res.headers.set('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS')
|
||||||
|
res.headers.set('Access-Control-Allow-Headers', 'Content-Type,Authorization')
|
||||||
|
res.headers.set('Access-Control-Allow-Credentials', 'true')
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
return new NextResponse(null, { status: 204, headers: res.headers })
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: '/api/:path*',
|
||||||
|
}
|
||||||
@@ -33,12 +33,13 @@ Page({
|
|||||||
// 最近阅读
|
// 最近阅读
|
||||||
recentChapters: [],
|
recentChapters: [],
|
||||||
|
|
||||||
// 菜单列表
|
// 菜单列表:扫一扫整合在「我的」内;提现记录与设置合并为「设置与提现」入口
|
||||||
menuList: [
|
menuList: [
|
||||||
|
{ id: 'scan', title: '扫一扫', icon: '📷', iconBg: 'brand' },
|
||||||
{ id: 'orders', title: '我的订单', icon: '📦', count: 0 },
|
{ id: 'orders', title: '我的订单', icon: '📦', count: 0 },
|
||||||
{ id: 'referral', title: '推广中心', icon: '🎁', badge: '' },
|
{ id: 'referral', title: '推广中心', icon: '🎁', badge: '' },
|
||||||
{ id: 'about', title: '关于作者', icon: '👤', iconBg: 'brand' },
|
{ id: 'about', title: '关于作者', icon: '👤', iconBg: 'brand' },
|
||||||
{ id: 'settings', title: '设置', icon: '⚙️', iconBg: 'gray' }
|
{ id: 'settings', title: '设置与提现', icon: '⚙️', iconBg: 'gray' }
|
||||||
],
|
],
|
||||||
|
|
||||||
// 登录弹窗
|
// 登录弹窗
|
||||||
@@ -281,11 +282,27 @@ Page({
|
|||||||
handleMenuTap(e) {
|
handleMenuTap(e) {
|
||||||
const id = e.currentTarget.dataset.id
|
const id = e.currentTarget.dataset.id
|
||||||
|
|
||||||
if (!this.data.isLoggedIn && id !== 'about') {
|
if (!this.data.isLoggedIn && id !== 'about' && id !== 'scan') {
|
||||||
this.showLogin()
|
this.showLogin()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 扫一扫:在「我的」内直接调起扫码
|
||||||
|
if (id === 'scan') {
|
||||||
|
wx.scanCode({
|
||||||
|
onlyFromCamera: false,
|
||||||
|
scanType: ['qrCode', 'barCode'],
|
||||||
|
success: (res) => {
|
||||||
|
wx.showToast({ title: '已识别', icon: 'success' })
|
||||||
|
if (res.result) {
|
||||||
|
wx.setClipboardData({ data: res.result })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
orders: '/pages/purchases/purchases',
|
orders: '/pages/purchases/purchases',
|
||||||
referral: '/pages/referral/referral',
|
referral: '/pages/referral/referral',
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ const nextConfig = {
|
|||||||
buildActivity: false,
|
buildActivity: false,
|
||||||
appIsrStatus: false,
|
appIsrStatus: false,
|
||||||
},
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/api/:path*',
|
||||||
|
headers: [
|
||||||
|
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' },
|
||||||
|
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type,Authorization' },
|
||||||
|
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
@@ -348,8 +348,77 @@ vercel --prod
|
|||||||
|
|
||||||
**项目状态**:✅ **已完成100%,可直接部署到生产环境**
|
**项目状态**:✅ **已完成100%,可直接部署到生产环境**
|
||||||
|
|
||||||
**建议下一步**:立即部署到Vercel,配置环境变量,测试支付流程
|
**建议下一步**:按需接入永平版可选能力(定时任务、提现记录、地址管理、推广设置页等),见 `开发文档/永平版优化对比与合并说明.md`
|
||||||
|
|
||||||
**最后更新时间**:2025-12-29 23:59
|
**最后更新时间**:2026-02-20
|
||||||
**最后更新人**:卡若 (智能助手)
|
**最后更新人**:卡若 (智能助手)
|
||||||
**项目交付状态**:✅ 完整交付
|
**项目交付状态**:✅ 完整交付
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、永平版优化合并迭代(2026-02-20)
|
||||||
|
|
||||||
|
### 9.1 对比范围
|
||||||
|
|
||||||
|
- **主项目**:`一场soul的创业实验`(单 Next 仓,根目录 app/lib/book/miniprogram)
|
||||||
|
- **永平版**:`一场soul的创业实验-永平`(多仓:soul-api Go、soul-admin Vue、soul Next 在 soul/dist)
|
||||||
|
|
||||||
|
### 9.2 已合并优化项
|
||||||
|
|
||||||
|
| 模块 | 内容 | 路径/说明 |
|
||||||
|
|------|------|------------|
|
||||||
|
| 数据库 | 环境变量 MYSQL_*、SKIP_DB、连接超时与单次错误日志 | `lib/db.ts` |
|
||||||
|
| 数据库 | 订单表 status 含 created/expired,字段 referrer_id/referral_code;用户表 ALTER 兼容 MySQL 5.7 | `lib/db.ts` |
|
||||||
|
| 认证 | 密码哈希/校验(scrypt,兼容旧明文) | `lib/password.ts`(新增) |
|
||||||
|
| 认证 | Web 手机号+密码登录、重置密码 | `app/api/auth/login`、`app/api/auth/reset-password`(新增) |
|
||||||
|
| 后台 | 管理员登出(清除 Cookie) | `app/api/admin/logout`(新增)、`lib/admin-auth.ts`(新增) |
|
||||||
|
| 前端 | 仅生产环境加载 Vercel Analytics | `app/layout.tsx` |
|
||||||
|
| 文档 | 本机/服务器运行说明 | `开发文档/本机运行文档.md`(新增) |
|
||||||
|
| 文档 | 永平 vs 主项目对比与可选合并清单 | `开发文档/永平版优化对比与合并说明.md`(新增) |
|
||||||
|
|
||||||
|
### 9.3 可选后续合并(见永平版优化对比与合并说明)
|
||||||
|
|
||||||
|
定时任务(订单同步/过期解绑)、提现待确认与记录 API、用户购买状态/阅读进度/地址 API、分销概览与推广设置页、忘记密码页与我的地址页、standalone 构建脚本、Prisma 等;主项目保持现有 CORS 与扁平 app 路由。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、链路优化与 yongpxu-soul 对照(2026-02-20)
|
||||||
|
|
||||||
|
### 10.1 链路优化(不改文件结构)
|
||||||
|
|
||||||
|
- **文档**:已新增 `开发文档/链路优化与运行指南.md`,明确四条链路及落地方式:
|
||||||
|
- **后台鉴权**:admin / key123456(store + admin-auth 一致),登出可调 `POST /api/admin/logout`。
|
||||||
|
- **进群**:支付成功后由前端根据 `groupQrCode` / 活码展示或跳转;配置来自 `/api/config` 与后台「二维码管理」(当前存前端 store,刷新以接口为准)。
|
||||||
|
- **营销策略**:推广、海报、分销比例等以 `api/referral/*`、`api/db/config` 及 store 配置为准;内容以 `book/`、`lib/book-data.ts` 为准。
|
||||||
|
- **支付**:create-order → 微信/支付宝 notify → 校验 → 进群/解锁内容;保持现有 `app/api/payment/*` 与 `lib/payment*` 不变。
|
||||||
|
- **协同**:鉴权、进群、营销、支付可多角色并行优化,所有改动限于现有目录与文件,不新增一级目录。
|
||||||
|
- **运行**:以第一目录为基准,`pnpm dev` / 生产 build+standalone,端口 3006;详见 `开发文档/本机运行文档.md` 与链路指南内运行检查清单。
|
||||||
|
|
||||||
|
### 10.2 yongpxu-soul 分支变更要点(已对照)
|
||||||
|
|
||||||
|
- **相对 soul-content**:yongpxu-soul 主要增加部署与文档,业务代码与主项目一致。
|
||||||
|
- 新增:`scripts/deploy_baota.py`、`开发文档/8、部署/宝塔配置检查说明.md`、`开发文档/8、部署/当前项目部署到线上.md`、小程序相关(miniprogram 上传脚本、开发文档/小程序管理、开发文档/服务器管理)、`开发文档/提现功能完整技术文档.md`、`lib/wechat-transfer.ts` 等。
|
||||||
|
- 删除/合并:大量历史部署报告与重复文档(如多份「部署完成」「升级完成」等),功能迭代记录合并精简。
|
||||||
|
- **结论**:业务链路(鉴权→进群→营销→支付)以**第一目录现有实现**为准;yongpxu-soul 的修改用于**部署方式、小程序发布、文档与运维**,不改变主项目文件结构与上述四条链路的代码归属。
|
||||||
|
- **可运行性**:按《链路优化与运行指南》第七节检查清单自检后,项目可在不修改文件结构的前提下完成落地与运行。
|
||||||
|
|
||||||
|
### 10.3 运行检查已执行(2026-02-20)
|
||||||
|
|
||||||
|
- 已执行:`pnpm install`、`pnpm run build`、`pnpm dev` 下验证 `GET /`、`GET /api/config` 返回 200。
|
||||||
|
- 执行记录详见 `开发文档/链路优化与运行指南.md` 第八节。
|
||||||
|
- 结论:构建与开发环境运行正常,链路就绪。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、下一步行动计划(2026-02-20)
|
||||||
|
|
||||||
|
| 优先级 | 行动项 | 负责模块 | 说明 |
|
||||||
|
|--------|--------|----------|------|
|
||||||
|
| P0 | 生产部署与回调配置 | 支付/部署 | 将当前分支部署至宝塔(或现有环境),配置微信/支付宝回调 URL 指向 `/api/payment/wechat/notify`、`/api/payment/alipay/notify`,并验证支付→到账→进群展示。 |
|
||||||
|
| P1 | 进群配置持久化(可选) | 进群/配置 | 若需多环境或刷新不丢失:让 `/api/config` 或单独接口读取/写入 `api/db/config` 的 `payment_config.wechatGroupUrl`、活码链接;或后台「二维码管理」保存时调用 db 配置 API。 |
|
||||||
|
| P1 | 后台「退出登录」对接 | 鉴权 | 在 `app/admin/layout.tsx` 将「返回前台」旁增加「退出登录」按钮,点击请求 `POST /api/admin/logout` 后跳转 `/admin/login`(若后续改为服务端 Cookie 鉴权即可生效)。 |
|
||||||
|
| P2 | Admin 密码环境变量统一(可选) | 鉴权 | 在 `lib/store.ts` 的 `adminLogin` 中从 `process.env.NEXT_PUBLIC_ADMIN_USERNAME` / `NEXT_PUBLIC_ADMIN_PASSWORD` 读取(或通过小 API 校验),与 `lib/admin-auth.ts` 一致。 |
|
||||||
|
| P2 | 营销与内容迭代 | 营销/内容 | 在现有结构内更新:`book/` 下 Markdown、`lib/book-data.ts` 章节与免费列表、`api/referral/*` 与 `api/db/config` 分销/推广配置;后台「系统设置」「内容管理」按需调整。 |
|
||||||
|
| P2 | 文档与分支同步 | 文档 | 定期将 yongpxu-soul 的部署/小程序/运维文档变更合并到主分支或文档目录,保持《链路优化与运行指南》《本机运行文档》与线上一致。 |
|
||||||
|
|
||||||
|
以上按 P0 → P1 → P2 顺序推进;P0 完成即可上线跑通整条链路,P1/P2 为体验与可维护性增强。
|
||||||
|
|||||||
93
开发文档/内容创建问题修复说明.md
Normal file
93
开发文档/内容创建问题修复说明.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# 内容创建问题修复说明
|
||||||
|
|
||||||
|
> 问题:souladmin 添加内容后显示「创建成功」,但目录和数据库未增加,前端也未显示。
|
||||||
|
|
||||||
|
## 根因分析
|
||||||
|
|
||||||
|
1. **两套后台数据源不一致**
|
||||||
|
- souladmin.quwanzhi.com 调用 soulapi.quwanzhi.com(Go API)
|
||||||
|
- soul.quwanzhi.com/admin 使用 Next.js API,list 此前仅从 bookData(静态)读取
|
||||||
|
- 新建章节写入数据库,但 list 不查库,导致新建内容不显示
|
||||||
|
|
||||||
|
2. **PUT 创建未完整支持 partId/chapterId**
|
||||||
|
- 新建章节时 partId、chapterId、partTitle、chapterTitle 未正确写入数据库
|
||||||
|
|
||||||
|
## 已做修复
|
||||||
|
|
||||||
|
### 1. 修改 `/api/db/book` list 接口
|
||||||
|
- **原逻辑**:仅从 bookData 读取
|
||||||
|
- **现逻辑**:优先从数据库 chapters 表读取,再与 bookData 合并
|
||||||
|
- **效果**:新建章节会立即出现在列表中
|
||||||
|
|
||||||
|
### 2. 修改 PUT 接口支持新建章节
|
||||||
|
- 支持 body 传入 `partId`、`chapterId`、`partTitle`、`chapterTitle`、`isFree`
|
||||||
|
- 新建章节能正确写入数据库
|
||||||
|
|
||||||
|
### 3. 在 book-data 中新增 9.15
|
||||||
|
- 章节 ID: 9.15
|
||||||
|
- 标题: 第102场|今年第一个红包你发给谁
|
||||||
|
- 文件: book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.15 第102场|今年第一个红包你发给谁.md
|
||||||
|
|
||||||
|
### 4. soul-admin 改用 soul.quwanzhi.com 作为 API
|
||||||
|
- 修改 soul-admin 的 API 基址:soulapi → soul.quwanzhi.com
|
||||||
|
- 在 Next.js 中为 souladmin.quwanzhi.com 配置 CORS
|
||||||
|
|
||||||
|
## 部署步骤
|
||||||
|
|
||||||
|
### 步骤 1:部署 soul 主站(小型宝塔)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验
|
||||||
|
# 按 .cursorrules 中的流程执行
|
||||||
|
pnpm build
|
||||||
|
# 然后执行部署脚本
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2:同步 9.15 到数据库
|
||||||
|
|
||||||
|
部署后访问 soul.quwanzhi.com/admin,在内容管理页面点击「同步到数据库」,将包含 9.15 的 bookData 同步进库。
|
||||||
|
|
||||||
|
### 步骤 3:部署修改后的 soul-admin(KR 宝塔)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 将 一场soul的创业实验-永平 中的 soul-admin/dist 上传到 KR 宝塔
|
||||||
|
cd /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平
|
||||||
|
tar -czf soul-admin-dist.tar.gz soul-admin/dist
|
||||||
|
sshpass -p 'Zhiqun1984' scp -P 22022 soul-admin-dist.tar.gz root@43.139.27.93:/tmp/
|
||||||
|
sshpass -p 'Zhiqun1984' ssh -p 22022 root@43.139.27.93 "
|
||||||
|
cd /www/wwwroot/自营/soul-admin
|
||||||
|
rm -rf dist.bak
|
||||||
|
mv dist dist.bak 2>/dev/null || true
|
||||||
|
tar -xzf /tmp/soul-admin-dist.tar.gz -C .
|
||||||
|
rm /tmp/soul-admin-dist.tar.gz
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 4:校验
|
||||||
|
|
||||||
|
1. 打开 souladmin.quwanzhi.com/content
|
||||||
|
2. 新建章节,确认创建后列表中立即出现
|
||||||
|
3. 刷新 soul.quwanzhi.com 主站,确认新章节可读
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- souladmin 现改为调用 soul.quwanzhi.com,不再调用 soulapi(Go),需确保 soul 主站可用
|
||||||
|
- 若仍需使用 Go API,需在 soul-api 源码中修复 list/create 逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 内容上传 API(供科室/Skill 调用)
|
||||||
|
|
||||||
|
- **地址**:`POST /api/content/upload`
|
||||||
|
- **Content-Type**:`application/json`
|
||||||
|
- **Body 字段**:
|
||||||
|
- `title`(必填):节标题
|
||||||
|
- `price`:定价,默认 1
|
||||||
|
- `content`:正文(Markdown 或 HTML)
|
||||||
|
- `format`:`markdown` | `html`,默认 `markdown`
|
||||||
|
- `images`:图片 URL 数组;正文中可用 `{{image_0}}`、`{{image_1}}` 占位,会替换为对应图片的 Markdown 图链
|
||||||
|
- `partId`、`partTitle`、`chapterId`、`chapterTitle`:归属篇/章,可选
|
||||||
|
- `isFree`:是否免费,默认 false
|
||||||
|
- `sectionId`:指定节 ID,不传则自动生成(如 `upload.标题slug.时间戳`)
|
||||||
|
- **返回**:`{ success, id, message, title, price, isFree, wordCount }`
|
||||||
|
- 写入数据库 `chapters` 表,list/目录会从库中读取并去重显示。
|
||||||
98
开发文档/本机运行文档.md
Normal file
98
开发文档/本机运行文档.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Soul 主站 · 本机运行文档
|
||||||
|
|
||||||
|
> 主项目(一场soul的创业实验)本机与服务器运行说明。永平版多服务架构见「永平版优化对比与合并说明」中的本机运行文档参考。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、主项目运行架构(单 Next 站)
|
||||||
|
|
||||||
|
### 1.1 进程与端口
|
||||||
|
|
||||||
|
| 说明 | 端口 | 命令 |
|
||||||
|
|----------|------|------|
|
||||||
|
| 开发 | 3000 | `pnpm dev`(Next 默认) |
|
||||||
|
| 生产 | 3006 | `pnpm build` 后 `PORT=3006 HOSTNAME=0.0.0.0 node .next/standalone/server.js` |
|
||||||
|
|
||||||
|
### 1.2 目录与部署
|
||||||
|
|
||||||
|
- **本地开发**:根目录即 Next 源码,`app/`、`lib/`、`components/`、`book/`、`miniprogram/` 同层。
|
||||||
|
- **生产部署**:小型宝塔 42.194.232.22,项目路径 `/www/wwwroot/soul`,PM2 进程名 `soul`,端口 3006。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、本机运行步骤
|
||||||
|
|
||||||
|
### 2.1 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 开发模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- 默认端口 3000,可在 `package.json` 或环境变量中指定 `PORT=3006`。
|
||||||
|
- 访问:http://localhost:3000(或 http://localhost:3006)
|
||||||
|
|
||||||
|
### 2.3 生产模式(本地模拟)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
PORT=3006 HOSTNAME=0.0.0.0 node .next/standalone/server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- 需先完成 `pnpm build`,standalone 输出在 `.next/standalone/`。
|
||||||
|
- 环境变量:`.env.local` 中配置 `MYSQL_*`(可选)、`SKIP_DB`(本地无 DB 时可设 `SKIP_DB=1`,部分接口会报错,适合纯前端联调)。
|
||||||
|
|
||||||
|
### 2.4 数据库
|
||||||
|
|
||||||
|
- 默认使用腾讯云 MySQL(见 `lib/db.ts` 默认值)。
|
||||||
|
- 本地无数据库时:设置 `SKIP_DB=1`,接口中依赖 DB 的会抛错,可配合 Mock 或仅跑静态页。
|
||||||
|
- 环境变量覆盖:`MYSQL_HOST`、`MYSQL_PORT`、`MYSQL_USER`、`MYSQL_PASSWORD`、`MYSQL_DATABASE`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、关键配置
|
||||||
|
|
||||||
|
### 3.1 环境变量(.env.local)
|
||||||
|
|
||||||
|
| 配置项 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| MYSQL_HOST / MYSQL_PORT / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DATABASE | 数据库连接,不设则用代码默认值 |
|
||||||
|
| SKIP_DB | 设为 1 或 true 时跳过 DB 连接,适合无 DB 环境 |
|
||||||
|
| ADMIN_USERNAME / ADMIN_PASSWORD | 后台管理员账号密码(默认 admin / key123456) |
|
||||||
|
| ADMIN_SESSION_SECRET | 管理员 Cookie 签名密钥(生产建议修改) |
|
||||||
|
|
||||||
|
### 3.2 管理后台
|
||||||
|
|
||||||
|
- 登录:http://localhost:3000/admin/login(开发)或 /admin/login(生产)
|
||||||
|
- 默认账号:admin / key123456(与 .cursorrules 一致,可通过环境变量覆盖)
|
||||||
|
- 登出 API:`POST /api/admin/logout`(清除管理员 Cookie,可与「退出登录」按钮对接)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、与永平版差异
|
||||||
|
|
||||||
|
- **永平版**:多服务(Go API 8080、Vue 管理后台 5174、Next 主站 3006),见永平根目录 `本机运行文档.md`。
|
||||||
|
- **本主项目**:单 Next 应用,无独立 Go/Vue,管理后台为 Next 内 `/admin`,API 为 Next 内 `/api/*`。
|
||||||
|
- CORS:主项目在 `middleware.ts` 与 `next.config.mjs` 中配置;永平可能由 Nginx/Go 处理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、常见问题
|
||||||
|
|
||||||
|
1. **端口被占用**
|
||||||
|
修改启动命令:`PORT=3007 pnpm dev` 或 `PORT=3007 node .next/standalone/server.js`
|
||||||
|
|
||||||
|
2. **数据库连接失败**
|
||||||
|
检查 `.env.local` 中 `MYSQL_*` 及本机网络是否能访问腾讯云 MySQL;或设 `SKIP_DB=1` 做无 DB 联调。
|
||||||
|
|
||||||
|
3. **API 跨域**
|
||||||
|
主项目已通过 `middleware.ts` 为 `/api/:path*` 设置 CORS,允许来源见 `ALLOWED_ORIGINS`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档状态**:适用于主项目单站部署与本机开发;多服务架构以永平版文档为准。
|
||||||
91
开发文档/永平版优化对比与合并说明.md
Normal file
91
开发文档/永平版优化对比与合并说明.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# 永平版 vs 主项目:优化对比与合并说明
|
||||||
|
|
||||||
|
> 对比目录:主项目(一场soul的创业实验) vs 永平版(一场soul的创业实验-永平)
|
||||||
|
> 更新日期:2026-02-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、两套目录结构概览
|
||||||
|
|
||||||
|
| 项目 | 根目录特点 | Next 源码位置 |
|
||||||
|
|------|------------|----------------|
|
||||||
|
| **主项目** | 单仓:Next + book + miniprogram + 开发文档 | 根下 `app/`、`lib/`、`components/` |
|
||||||
|
| **永平版** | 多仓:soul-api(Go)、soul-admin(Vue)、soul(Next) | `soul/dist/`(源码与构建同目录) |
|
||||||
|
|
||||||
|
永平版还包含:`本机运行文档.md`、Go API(8080)、Vue 管理后台(静态)、开发 API(8081)。主项目为纯 Next 站 + 宝塔 3006 部署。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、已合并到主项目的优化(本次迭代)
|
||||||
|
|
||||||
|
| 模块 | 优化内容 | 主项目路径 |
|
||||||
|
|------|----------|------------|
|
||||||
|
| **数据库** | 环境变量 `MYSQL_*`、`SKIP_DB`、连接超时与单次连接错误日志 | `lib/db.ts` |
|
||||||
|
| **数据库** | 订单表 status 增加 `created`/`expired`,字段 `referrer_id`/`referral_code`;用户表 ALTER 兼容 MySQL 5.7 | 同上 |
|
||||||
|
| **认证** | 密码哈希/校验(scrypt,兼容旧明文) | `lib/password.ts`(新增) |
|
||||||
|
| **认证** | Web 端手机号+密码登录 | `app/api/auth/login/route.ts`(新增) |
|
||||||
|
| **认证** | 重置密码 | `app/api/auth/reset-password/route.ts`(新增) |
|
||||||
|
| **后台** | 管理员登出(清除 Cookie) | `app/api/admin/logout/route.ts`(新增) |
|
||||||
|
| **前端** | 仅生产环境加载 Vercel Analytics | `app/layout.tsx` |
|
||||||
|
| **文档** | 本机/服务器运行说明(端口、目录、Nginx) | `开发文档/本机运行文档.md`(新增) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、永平有、主项目未合并的(可选后续)
|
||||||
|
|
||||||
|
| 模块 | 说明 | 永平路径 | 合并建议 |
|
||||||
|
|------|------|----------|----------|
|
||||||
|
| 定时任务 | 订单状态同步、过期解绑 | `app/api/cron/sync-orders`、`cron/unbind-expired` | 若需定时同步/解绑再迁入;需配置 CRON_SECRET |
|
||||||
|
| 提现扩展 | 待确认列表、提现记录 API | `withdraw/pending-confirm`、`withdraw/records` | 若后台要做提现工作流与记录查询可迁入 |
|
||||||
|
| 用户 API | 购买状态、阅读进度、收货地址 CRUD | `user/check-purchased`、`user/reading-progress`、`user/addresses` | 按产品需要选择性迁入 |
|
||||||
|
| 后台 | 分销概览 API、推广设置页 | `admin/distribution/overview`、`admin/referral-settings/page.tsx` | 若有分销看板/推广配置页可迁入 |
|
||||||
|
| 前台 | 忘记密码页、我的地址列表/编辑/新增 | `app/view/login/forgot`、`app/view/my/addresses/*` | 主项目路由为 `app/login/`、`app/my/`,可对应新增 |
|
||||||
|
| 构建 | standalone 复制 static/public、clean、write-warning | `scripts/prepare-standalone.js` 等 | 若主项目用 standalone 部署可迁入 |
|
||||||
|
| 数据层 | Prisma 模型与迁移 | `prisma/schema.prisma`、迁移脚本 | 主项目当前为 mysql2;若统一用 Prisma 再迁 |
|
||||||
|
| 路由结构 | 前台统一在 `app/view/` | 整棵 `app/view/` | 主项目保持扁平 `app/`,非必须 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、主项目保留、与永平不同的部分
|
||||||
|
|
||||||
|
- **CORS**:主项目在 `middleware.ts` + `next.config.mjs` 的 headers 中配置 API CORS;永平可能用 Nginx/Go,未在 Next 层做。
|
||||||
|
- **路由**:主项目前台为 `app/page.tsx`、`app/my/`、`app/read/` 等,无 `view` 前缀。
|
||||||
|
- **book**:主项目根下保留 `book/` Markdown 与现有内容体系;永平书内容可能来自 API/DB。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、环境变量说明(合并后)
|
||||||
|
|
||||||
|
主项目 `.env.local` 建议支持(可选):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 数据库(不设则用代码内默认值)
|
||||||
|
MYSQL_HOST=
|
||||||
|
MYSQL_PORT=
|
||||||
|
MYSQL_USER=
|
||||||
|
MYSQL_PASSWORD=
|
||||||
|
MYSQL_DATABASE=
|
||||||
|
|
||||||
|
# 本地无数据库时跳过连接(接口会报错,适合纯前端联调)
|
||||||
|
SKIP_DB=0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、合并与实施注意
|
||||||
|
|
||||||
|
1. **路径**:永平 Next 源码在 `soul/dist/`,合并到主项目时对应到根下 `app/`、`lib/`、`开发文档/`。
|
||||||
|
2. **CORS**:保留主项目现有 `middleware.ts` 与 `next.config.mjs` 的 CORS 配置。
|
||||||
|
3. **数据库**:主项目继续使用 mysql2,未引入 Prisma;`lib/db.ts` 已支持环境变量与 `SKIP_DB`。
|
||||||
|
4. **admin 登出**:后台可增加「退出登录」按钮,请求 `POST /api/admin/logout` 后跳转登录页。
|
||||||
|
|
||||||
|
5. **已有数据库**:若主项目此前已建过 `orders` 表且无 `referrer_id`/`referral_code` 或 status 无 `created`/`expired`,需自行执行迁移,例如:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE orders MODIFY COLUMN status ENUM('created','pending','paid','cancelled','refunded','expired') DEFAULT 'created';
|
||||||
|
ALTER TABLE orders ADD COLUMN referrer_id VARCHAR(50) NULL COMMENT '推荐人用户ID', ADD COLUMN referral_code VARCHAR(20) NULL COMMENT '下单时使用的邀请码';
|
||||||
|
```
|
||||||
|
若表为新建,`initDatabase()` 已包含上述结构。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档状态**:已合并项已落地;未合并项见第三节,按需迭代。
|
||||||
61
开发文档/派对每日数据汇总.md
Normal file
61
开发文档/派对每日数据汇总.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Soul 派对每日数据汇总
|
||||||
|
|
||||||
|
按「派对小助手」表头整理的日维度数据,便于按天相加。
|
||||||
|
|
||||||
|
## 表头说明(与第5张图一致)
|
||||||
|
|
||||||
|
| 时长 | Soul推流人数 | 进房人数 | 人均时长 | 互动数量 | 礼物 | 灵魂力 | 增加关注 | 最高在线 |
|
||||||
|
|------|--------------|----------|----------|----------|------|--------|----------|----------|
|
||||||
|
|
||||||
|
- **时长**:派对总时长(分钟)
|
||||||
|
- **Soul推流人数**:本场获得额外曝光(次)
|
||||||
|
- **进房人数**:派对成员/进房总人数(人)
|
||||||
|
- **人均时长**:人均停留时长(分钟)
|
||||||
|
- **互动数量**:本场互动次数
|
||||||
|
- **礼物**:本场收到礼物(个)
|
||||||
|
- **灵魂力**:收获灵魂力
|
||||||
|
- **增加关注**:新增粉丝(人)
|
||||||
|
- **最高在线**:当日各场中最高同时在线人数(人),取最大值、不相加
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当日汇总(一天相加后的数据)
|
||||||
|
|
||||||
|
**日期**:2026-02-19(根据截图当日多场合并)
|
||||||
|
|
||||||
|
| 时长 | Soul推流人数 | 进房人数 | 人均时长 | 互动数量 | 礼物 | 灵魂力 | 增加关注 | 最高在线 |
|
||||||
|
|------|--------------|----------|----------|----------|------|--------|----------|----------|
|
||||||
|
| 155 | 46749 | 545 | 7 | 34 | 1 | 8 | 13 | 47 |
|
||||||
|
|
||||||
|
**计算说明:**
|
||||||
|
|
||||||
|
- **时长**:54 + 22 + 79 = **155** 分钟(第3张与第4张为同一场,只计一次,取结算 79 分钟)
|
||||||
|
- **Soul推流人数**:12588 + 7695 + 26466 = **46749** 次
|
||||||
|
- **进房人数**:164 + 92 + 289 = **545** 人(同一场取 289,不重复加 279)
|
||||||
|
- **人均时长**:仅一场有数据,取 **7** 分钟
|
||||||
|
- **互动数量**:仅一场有数据,取 **34** 次
|
||||||
|
- **礼物**:0 + 0 + 1 = **1** 个
|
||||||
|
- **灵魂力**:0 + 0 + 8 = **8**
|
||||||
|
- **增加关注**:2 + 4 + 7 = **13** 人(同一场只计 7,不重复加 5)
|
||||||
|
- **最高在线**:取当日各场最高值 max(34, 30, 47) = **47** 人(不相加)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 当日分场明细(便于核对)
|
||||||
|
|
||||||
|
*说明:第3张(派对小助手浮层)与第4张(派对已关闭结算)为同一场派对,只计一场。*
|
||||||
|
|
||||||
|
| 场次 | 时长(min) | 曝光/推流 | 进房人数 | 人均时长 | 互动 | 礼物 | 灵魂力 | 新增关注 | 最高在线 |
|
||||||
|
|------|-----------|-----------|----------|----------|------|------|--------|----------|----------|
|
||||||
|
| 1 | 54 | 12588 | 164 | — | — | 0 | 0 | 2 | 34 |
|
||||||
|
| 2 | 22 | 7695 | 92 | — | — | 0 | 0 | 4 | 30 |
|
||||||
|
| 3(图3+图4同场) | 79 | 26466 | 289 | 7 | 34 | 1 | 8 | 7 | 47 |
|
||||||
|
| **合计** | **155** | **46749** | **545** | 7 | 34 | **1** | **8** | **13** | **47** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**使用方式**:把「当日汇总」那一行加到总表或飞书运营报表。
|
||||||
|
|
||||||
|
**导入飞书运营报表时**(脚本 `soul_party_to_feishu_sheet.py`):
|
||||||
|
- 只填前 10 项(主题、时长、推流、进房、人均时长、互动、礼物、灵魂力、增加关注、最高在线),**按数字填写**。
|
||||||
|
- 推流进房率、1分钟进多少人、加微率 **不填**,由表格公式自动计算。
|
||||||
111
开发文档/运营报表与Soul聊天记录全量分析.md
Normal file
111
开发文档/运营报表与Soul聊天记录全量分析.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# 运营报表 + Soul 聊天记录全量分析(10 月第一场至今)
|
||||||
|
|
||||||
|
> 时间范围:2025 年 10 月第一场 → 2026 年 2 月(当前)。
|
||||||
|
> 数据来源:飞书运营报表多月度汇总 + `聊天记录/soul` 目录下全部派对/会议 txt 与合并稿。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、时间线与数据覆盖
|
||||||
|
|
||||||
|
### 1.1 场次与日历对应(据聊天记录文件名整理)
|
||||||
|
|
||||||
|
| 时期 | 日期范围 | 场次/内容 | 聊天记录文件情况 |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| **2025 年 10 月** | 10/25–10/31 | 最早场次(未统一编号) | 10月25日、26、27、30、31 等 txt;soul202510-20260102 含 10/22 起大段合并 |
|
||||||
|
| **2025 年 11 月** | 11/4–11/27 | 多场,部分有 26场、27场、32场 等 | 11月多日 + 魔兽私服/留学/美业、电竞陪玩、学校创业等主题文件名 |
|
||||||
|
| **2025 年 12 月** | 12/2–12/31 | 41场→50场(12/18)、51–62场 | 12月2日到31日;44场(12/11)、50场(12/18)、51–62场 等 |
|
||||||
|
| **2026 年 1 月** | 1/1–1/31 | 62场(1/1)、83–90场 | 62场 1月1日;83–90场(1/26–2/3);团队会议 17场、39场 等 |
|
||||||
|
| **2026 年 2 月** | 2/16–2/20 | 101–105场 | 101–105场 2/16–2/20;104场 妙记/纪要 等 |
|
||||||
|
|
||||||
|
说明:10 月、11 月部分日期未在文件名中标「第 x 场」,按日期与后续 44–62 场反推,**第一场可视为 2025 年 10 月**;当前至 **105 场**(2026/02/20)。
|
||||||
|
|
||||||
|
### 1.2 聊天记录全量统计
|
||||||
|
|
||||||
|
| 项目 | 数值 |
|
||||||
|
|:---|:---|
|
||||||
|
| **txt 文件数(仅 .txt)** | 约 85+ 个 |
|
||||||
|
| **时间跨度** | 2025-10-22/25 → 2026-02-20 |
|
||||||
|
| **合并长稿** | soul202510-20260102.txt(约 8 万+ 行,10/22–1/2);soul派对会议到12月3日-1月7日.txt |
|
||||||
|
| **含「场」编号的文件** | 41场–62场、83–90场、101–105场 等 |
|
||||||
|
| **团队会议** | 12月11日(第一场)、17场、39场、产研第20场 等 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、运营报表数据(10 月至今)
|
||||||
|
|
||||||
|
以下为飞书运营报表中**可解析的多月度**汇总(含 25年10月、2026年1月、2026年2月 等)。
|
||||||
|
|
||||||
|
### 2.1 按时期汇总
|
||||||
|
|
||||||
|
| 时期 | 有数据场次 | 总时长(分钟) | Soul推流 | 进房人数 | 互动 | 礼物 | 灵魂力 | 增加关注 | 最高在线 | 人均时长(分钟) |
|
||||||
|
|:---|:---|:---|:---|:---|:---|:---|:---|:---|:---|:---|
|
||||||
|
| **25年10月** | 11 | 1,746 | 0* | 905 | 201 | 110 | 124 | 0* | 0* | — |
|
||||||
|
| **2026年1月** | 27 | 3,659 | 772,133 | 9,690 | 4,841 | 177 | 22,644 | 496 | 75 | 10.3 |
|
||||||
|
| **2026年2月** | 12 | 1,477 | 310,078 | 3,721 | 660 | 40 | 5,972 | 174 | 56 | 9.5 |
|
||||||
|
| **合计(报表内)** | **50** | **6,882** | **1,082,211** | **14,316** | **5,702** | **327** | **28,740** | **670** | **75** | **11.2** |
|
||||||
|
|
||||||
|
\* 25年10月 报表中推流/关注/最高在线 多为空或 0,仅部分指标有数。
|
||||||
|
|
||||||
|
### 2.2 全量合计(含 10 月)
|
||||||
|
|
||||||
|
- **总场次(有数据)**:约 50 场(报表填写);若含 10 月、11 月、12 月未完全入表场次,**实际派对场次从 10 月起累计约 105 场**。
|
||||||
|
- **总时长**:报表内 6,882 分钟(约 114.7 小时);加 10 月 1,746 分钟 ≈ **8,628 分钟**(约 143.8 小时)。
|
||||||
|
- **Soul 推流**:约 **108.2 万**(10 月表内未录推流)。
|
||||||
|
- **进房人数**:约 **14,316**(表内)+ 10 月 905 ≈ **15,221**。
|
||||||
|
- **互动 / 礼物 / 灵魂力 / 关注**:见表;10 月贡献 201 互动、110 礼物、124 灵魂力。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、聊天记录内容与主题(全量视角)
|
||||||
|
|
||||||
|
### 3.1 来源与结构
|
||||||
|
|
||||||
|
- **单场/单日**:`soul 2025年10月25日.txt`、`soul 派对 第103场 20260218.txt` 等,多为「关键词 + 文字记录」或飞书妙记导出。
|
||||||
|
- **长合并**:`soul202510-20260102.txt` 从 2025-10-22 到 2026-01-02,含大量逐字稿。
|
||||||
|
- **主题在文件名中的体现**:电竞陪玩、学校创业、魔兽私服、留学、美业、小程序、书、分层与规则 等。
|
||||||
|
|
||||||
|
### 3.2 高频主题与关键词(来自抽样与文件名)
|
||||||
|
|
||||||
|
从 10 月、11 月及合并稿抽样可见,**贯穿全程的主题**包括:
|
||||||
|
|
||||||
|
| 类别 | 关键词/主题 |
|
||||||
|
|:---|:---|
|
||||||
|
| **变现与生意** | 直播、电商、抖音、小红书、流量、粉丝、带货、知识付费、私域、微信、老板、生意、底层逻辑 |
|
||||||
|
| **行业与赛道** | 主播方向、电竞、陪玩、留学、美业、魔兽私服、学校创业、财税、税筹、资源整合、行业整合 |
|
||||||
|
| **组织与协作** | 客户、企业、体量、财务、风险、合作伙伴、中台、赋能、加盟、分账 |
|
||||||
|
| **产品与项目** | 小程序、书、派对、分层、规则、落地执行、朋友圈素材、6980 套餐 |
|
||||||
|
|
||||||
|
与项目「内容 + 私域 + 分销」和「谁在挣钱、怎么挣」的定位一致;聊天记录构成**书稿与运营动作的素材源**。
|
||||||
|
|
||||||
|
### 3.3 与运营报表的对应关系
|
||||||
|
|
||||||
|
- **有「场次」的聊天记录**(如 44–62、83–90、101–105)可与报表中「同场次」或「同日期列」的效果数据一一对应,做单场**内容主题 ↔ 进房/互动/关注**分析。
|
||||||
|
- **10 月、11 月**部分仅有日期无场次号,可按日期与报表「日期列」或后续整理的场次映射做关联。
|
||||||
|
- **团队会议**(12/11 第一场、17场、39场、产研20场)与「内部会议纪要」行、会议图片上传对应,用于复盘与决策追溯。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、全量运营结论(10 月至今)
|
||||||
|
|
||||||
|
1. **规模**
|
||||||
|
- 从 **2025 年 10 月第一场** 到 **2026 年 2 月 105 场**,报表内约 50 场有完整效果数据;总曝光约 **108 万推流**,进房约 **1.5 万+**,总时长约 **144 小时** 量级。
|
||||||
|
2. **流量与沉淀**
|
||||||
|
- 1 月推流与进房最高(约 77 万、9,690);2 月场次与总量下降,人均时长与最高在线仍维持在约 9–10 分钟、56 人,单场质量未明显下滑。
|
||||||
|
3. **内容与记录**
|
||||||
|
- **85+ 个 txt** 覆盖 10/22–2/20,含单场逐字稿与 10/22–1/2 长合并稿;主题覆盖电商、主播、电竞、财税、私域、小程序与书,与项目定位一致,可支撑书稿、复盘与运营分析。
|
||||||
|
4. **数据完整性**
|
||||||
|
- 10 月、11 月报表字段不全(推流/关注/最高在线多缺),建议在报表或本地表中对 10–11 月做「补录或标注」,便于全周期对比;聊天记录与报表的「场次/日期」对应关系可固化为一张映射表,方便后续全量分析自动化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、建议的后续动作
|
||||||
|
|
||||||
|
1. **报表**:对 10 月、11 月能做补录的场次补全推流/关注/最高在线;新场次继续按「日期列 + 场次」填写并发群(竖状)。
|
||||||
|
2. **聊天记录**:保持「按场次/日期」命名;大段合并稿保留,并可与单场 txt 做交叉校验。
|
||||||
|
3. **分析**:每月或每季度跑一次「10 月至今」全量汇总(报表 + 聊天记录文件清单),更新本文档第二节与第四节,并和项目目标(链接数、会员、付费)做对照。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**:v1.0
|
||||||
|
**数据基准**:飞书运营报表(25年10月、2026年1月、2026年2月);聊天记录目录 `聊天记录/soul` 下 85+ 个 txt 及 soul202510-20260102 等合并稿。
|
||||||
|
**更新**:随新场次与补录数据更新上表与结论。
|
||||||
132
开发文档/运营报表与项目运营分析.md
Normal file
132
开发文档/运营报表与项目运营分析.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# 运营报表数据与项目运营分析
|
||||||
|
|
||||||
|
> 基于飞书运营报表全量数据,结合「一场 SOUL 的创业实验」项目目标的运营视角分析。
|
||||||
|
> 数据来源:飞书运营报表(Soul 派对效果数据);项目:推进表、派对定位、书/小程序闭环。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目与派对定位(背景)
|
||||||
|
|
||||||
|
| 维度 | 内容 |
|
||||||
|
|:---|:---|
|
||||||
|
| **项目名称** | 一场 SOUL 的创业实验场 |
|
||||||
|
| **核心目标** | 内容阅读 + 私域引流 + 知识变现,验证「内容 + 私域 + 分销」商业闭环 |
|
||||||
|
| **派对定位** | 晨间 6–9 点 Soul 语音派对,主题:谁在挣钱、怎么挣;链接创业/副业人群 |
|
||||||
|
| **产出** | 《一场soul的创业实验》书籍内容、H5/小程序「卡若的创业派对」、会员群/资源群、线下见面 |
|
||||||
|
| **阶段目标(历史)** | 前 50 场目标链接 1000 人(实际约 270,完成度 27%);51–100 场组建 7 管理、40+ 副业、90+ 老板、会员群;线下见面 30+ 人 |
|
||||||
|
|
||||||
|
派对是**流量与链接入口**,书与小程序是**内容与变现载体**,运营报表衡量的是**入口侧的规模、参与度与沉淀**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、运营报表数据总览
|
||||||
|
|
||||||
|
以下为从飞书运营报表汇总得到的多月度合计(含 1 月、2 月及可解析的其他月份)。
|
||||||
|
|
||||||
|
### 2.1 全量汇总(多个月度合计)
|
||||||
|
|
||||||
|
| 指标 | 数值 | 说明 |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| **有数据场次** | 约 50 场 | 报表内已填写的场次 |
|
||||||
|
| **总时长** | 约 6,882 分钟 | 约 114.7 小时 |
|
||||||
|
| **Soul 推流人数** | 约 108.2 万 | 平台曝光量级 |
|
||||||
|
| **进房人数** | 约 14,316 | 去重后约 1.4 万+ 进房 |
|
||||||
|
| **互动数量** | 约 5,702 | 总互动次数 |
|
||||||
|
| **礼物** | 约 327 | 场均约 6.5 个 |
|
||||||
|
| **灵魂力** | 约 28,740 | 平台内成长值/积分 |
|
||||||
|
| **增加关注** | 约 670 | 新增粉丝合计 |
|
||||||
|
| **最高在线(单场最大)** | 75 人 | 各场峰值取 max |
|
||||||
|
| **人均时长(有数据场平均)** | 约 11.2 分钟 | 停留质量参考 |
|
||||||
|
|
||||||
|
### 2.2 分月对比(第 1 月 vs 第 2 月)
|
||||||
|
|
||||||
|
| 指标 | 第 1 月(约 27 场) | 第 2 月(约 12 场) | 环比变化 |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| 总时长(分钟) | 3,659 | 1,477 | 场次减,总时长降 |
|
||||||
|
| Soul 推流人数 | 772,133 | 310,078 | 约 -60% |
|
||||||
|
| 进房人数 | 9,690 | 3,721 | 约 -62% |
|
||||||
|
| 互动数量 | 4,841 | 660 | 约 -86% |
|
||||||
|
| 礼物 | 177 | 40 | 约 -77% |
|
||||||
|
| 灵魂力 | 22,644 | 5,972 | 约 -74% |
|
||||||
|
| 增加关注 | 496 | 174 | 约 -65% |
|
||||||
|
| 最高在线 | 75 | 56 | 峰值略降 |
|
||||||
|
| 人均时长(分钟) | 10.3 | 9.5 | 基本稳定 |
|
||||||
|
|
||||||
|
第 2 月有数据场次明显少于第 1 月(约 12 场 vs 27 场),各项总量随场次下降而下降;人均时长、最高在线等「单场质量」指标相对稳定,说明单场运营节奏未明显变差。
|
||||||
|
|
||||||
|
### 2.3 单场样本(近期有完整数据的场次)
|
||||||
|
|
||||||
|
| 场次 | 主题(核心干货) | 时长 | 推流 | 进房 | 人均时长 | 互动 | 礼物 | 灵魂力 | 关注 | 最高在线 |
|
||||||
|
|:---|:---|:---|:---|:---|:---|:---|:---|:---|:---|:---|
|
||||||
|
| 99 | — | 116 | 16,976 | 208 | — | — | 4 | 166 | 12 | 39 |
|
||||||
|
| 103 | 号商几毛卖十几 日销两万 | 155 | 46,749 | 545 | 7 | 34 | 1 | 8 | 13 | 47 |
|
||||||
|
| 104 | AI创业最赚钱一月分享 | 140 | 36,221 | 367 | 7 | 49 | 0 | 0 | 11 | 38 |
|
||||||
|
| 105 | 创业社群AI培训6980 电竞私域 | 138 | — | 403 | 10 | 170 | 2 | 24 | 31 | 54 |
|
||||||
|
|
||||||
|
可看出的规律:单场时长约 2–2.5 小时;进房 300–500 量级与项目内「一场 300–600 人」一致;主题明确、有干货的场次(如 103、104、105)互动与关注更好。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、运营视角分析
|
||||||
|
|
||||||
|
### 3.1 流量与规模
|
||||||
|
|
||||||
|
- **推流规模**:累计约 108 万推流,说明 Soul 侧给了可观曝光,派对作为内容形态被平台认可。
|
||||||
|
- **进房转化**:进房约 1.43 万(去重后),相对 108 万推流,**推流→进房** 转化约 1.3% 量级;单场进房 300–500 是常态,与「每天 300–600 人」的定位一致。
|
||||||
|
- **结论**:流量规模足够支撑「链接与内容沉淀」;若要进一步放大,可重点优化推流→进房(标题、时段、话题)和进房→停留(人均时长、互动设计)。
|
||||||
|
|
||||||
|
### 3.2 参与与粘性
|
||||||
|
|
||||||
|
- **人均时长**:有数据场平均约 10–11 分钟,说明多数用户是「短停留、多场次」;与「停留 10–15 分钟能听两三个视角」的设定吻合。
|
||||||
|
- **互动**:总互动 5,702,场均约 114;近期场 34–170 不等,主题清晰、干货强的场互动更高。
|
||||||
|
- **最高在线**:单场峰值 38–54(近期),历史最高 75;反映同时段「强参与」人数,可作为热力与内容爆点的观测指标。
|
||||||
|
- **结论**:当前设计适合「轻参与、多触达」;若要做深链接,可针对「高停留/高互动」用户设计分层(如会员群、资源群入口),与项目「私域引流」目标对齐。
|
||||||
|
|
||||||
|
### 3.3 沉淀与转化(关注、私域)
|
||||||
|
|
||||||
|
- **增加关注**:累计约 670,约 50 场,场均约 13;近期单场 11–31。
|
||||||
|
- **与项目目标的关系**:前 50 场目标链接 1000 人、实际约 270;关注数 670 是「Soul 内关注」口径,与「链接」(加微/进群)不是同一漏斗,但可视为上游指标。
|
||||||
|
- **结论**:关注是私域的前置;若项目 KPI 是「加微/进群/会员」,需在报表或线下增加「进群数/加微数」等字段,并与 Soul 关注、进房、互动做交叉分析,才能看清「派对→私域→变现」的转化率。
|
||||||
|
|
||||||
|
### 3.4 商业化与内容
|
||||||
|
|
||||||
|
- **礼物/灵魂力**:礼物 327、灵魂力 28,740,更多反映平台内参与与打赏,不是项目主变现路径。
|
||||||
|
- **主变现路径**:书/小程序(一天一块钱一节)、会员群、线下;派对侧主要贡献「流量 + 内容素材 + 链接机会」。
|
||||||
|
- **结论**:运营报表侧重「派对效果」;若要全面评估项目,需把「小程序/书籍付费、会员、线下见面」等与报表做联动分析(例如:某月派对进房/关注上涨时,当月付费或进群是否同步变化)。
|
||||||
|
|
||||||
|
### 3.5 稳定性与节奏
|
||||||
|
|
||||||
|
- **开播节奏**:每天 6–9 点固定时段,有利于养成用户习惯和平台推流稳定性。
|
||||||
|
- **场次与总量**:第 2 月有数据场次减少,总时长、推流、进房等随之下降;若 2 月存在春节等客观因素,可视为短期波动;若为主动收缩,需明确是「提质减量」还是「产能不足」,以便调整目标。
|
||||||
|
- **结论**:建议按「周/月」固定复盘:场次、总时长、进房、关注、最高在线;并和「链接数、会员、付费」做简单对照,便于做运营决策。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、与项目目标的对照
|
||||||
|
|
||||||
|
| 项目目标 | 运营报表可支撑的观测 | 建议 |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| **内容沉淀** | 总时长、场次、主题(表格内主题列) | 主题与书/小程序章节对应,便于「派对→内容→付费」追溯 |
|
||||||
|
| **私域引流** | 进房、关注、互动 | 在报表或线下补充「进群/加微」数,与关注做漏斗分析 |
|
||||||
|
| **知识变现** | 报表无直接指标 | 用独立维度记录:小程序付费、会员费、线下活动收入,与派对月度汇总对比 |
|
||||||
|
| **链接人数/质量** | 进房、关注、最高在线 | 前 50 场链接 270 人可与「关注 670」对比口径;51–100 场管理/副业/老板数可作质量维度 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、结论与建议(运营视角)
|
||||||
|
|
||||||
|
1. **报表价值**:当前运营报表已能清晰反映「派对规模、参与度、平台内沉淀」;与项目结合时,需补「私域与变现」口径,才能闭环评估。
|
||||||
|
2. **流量与转化**:108 万推流、1.4 万+ 进房、670 关注,规模足够;下一步可重点看「进房→关注→加微/进群」的转化与节奏。
|
||||||
|
3. **单场质量**:人均 10–11 分钟、最高在线 38–75,与「轻参与、多触达」定位一致;若要做深,可对高停留/高互动用户做分层运营。
|
||||||
|
4. **节奏与目标**:建议每月固定做「场次、总时长、进房、关注、最高在线」与上月对比,并和链接数/会员/付费做简单对照;遇重大节日或策略调整时单独标注,便于归因。
|
||||||
|
5. **数据一致性**:报表按「日期列/场次列」填写、发群用竖状格式,便于前后一致;会议纪要/今日总结用图片入格,有利于保留现场决策与复盘,与运营分析互补。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**:v1.0
|
||||||
|
**数据基准**:飞书运营报表多月度汇总(约 50 场有数据);项目信息来自推进表与派对/书/小程序定位。
|
||||||
|
**更新建议**:每月或每季度在本文末追加「当月/当季小结」与目标达成情况,形成可延续的运营分析记录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**延伸**:从**第一场(10 月)至今**的运营报表全量数据与 **Soul 聊天记录**(85+ 个 txt、合并稿)的全量分析见 → [运营报表与Soul聊天记录全量分析.md](./运营报表与Soul聊天记录全量分析.md)。
|
||||||
104
开发文档/链路优化与运行指南.md
Normal file
104
开发文档/链路优化与运行指南.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# 链路优化与运行指南
|
||||||
|
|
||||||
|
> 以**第一个目录(主项目)**为基准,不修改文件与目录结构,仅明确「后台鉴权 → 进群 → 营销策略 → 支付」整条链路的落地与运行方式。
|
||||||
|
> 更新日期:2026-02-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、链路总览
|
||||||
|
|
||||||
|
```
|
||||||
|
后台鉴权 → 进群(支付后跳转) → 营销策略(推广/活码/配置) → 支付(下单→回调→到账)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **基准**:主项目现有 `app/`、`lib/`、`components/`、`app/api/` 结构不变。
|
||||||
|
- **运行**:本机 `pnpm dev` 或生产 `pnpm build` + `node .next/standalone/server.js`,端口 3006(见 `开发文档/本机运行文档.md`)。
|
||||||
|
- **配置**:前端通过 `ConfigLoader` 调用 `fetchSettings()` → `GET /api/config` 拉取配置并写入 store;后台「系统设置」「支付设置」「二维码管理」等仅改前端 store,刷新后由 `/api/config` 再次覆盖(当前 `/api/config` 为静态实现,如需持久化可后续对接 `GET /api/db/config`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、后台鉴权
|
||||||
|
|
||||||
|
| 项目 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **入口** | `app/admin/login/page.tsx`,账号密码提交后调用 `store.adminLogin(username, password)`。 |
|
||||||
|
| **校验** | `lib/store.ts` 内 `adminLogin`:`username === 'admin'` 且 `password === 'key123456'` 即通过,与 `.cursorrules`、`lib/admin-auth.ts` 默认一致。 |
|
||||||
|
| **登出** | 可调用 `POST /api/admin/logout` 清除管理员 Cookie(当前后台为前端 store 登录,未使用 Cookie 时该接口仅清 Cookie,不影响已登录状态;若后续改为服务端 Cookie 鉴权,再在后台加「退出登录」按钮请求该接口)。 |
|
||||||
|
| **环境变量** | `ADMIN_USERNAME` / `ADMIN_PASSWORD` 在 `lib/admin-auth.ts` 中生效;`store.adminLogin` 仍为写死 `key123456`,若需统一可从环境变量读(需改 store 一处)。 |
|
||||||
|
|
||||||
|
**落地要点**:保持现有结构即可运行;默认 admin / key123456,与文档一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、进群(支付后跳转)
|
||||||
|
|
||||||
|
| 项目 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **配置来源** | 前端:`settings.paymentMethods.wechat.groupQrCode`、`settings.liveQRCodes`(活码多链接)。来源为 `fetchSettings()` → `GET /api/config` 与 store 默认值合并。 |
|
||||||
|
| **后台配置** | 「二维码管理」页(`app/admin/qrcodes/page.tsx`):可改微信群活码多链接、微信群跳转链接;保存后写入 **前端 store**(`updateSettings`),刷新页面会重新从 `/api/config` 拉取,当前接口为静态,故刷新后可能恢复为代码默认;若需持久化,需后续让 `/api/config` 或单独接口读/写 `api/db/config` 的 `payment_config.wechatGroupUrl` 等。 |
|
||||||
|
| **支付成功** | 支付成功后的「进群」行为由前端驱动:如展示群二维码、或跳转 `groupQrCode` / 活码 URL(`getLiveQRCodeUrl`)。 |
|
||||||
|
| **静态配置** | `app/api/config/route.ts` 中 `paymentMethods.wechat.groupQrCode`、`marketing.partyGroup` 等可改代码内默认,部署后生效。 |
|
||||||
|
|
||||||
|
**落地要点**:当前不改文件结构即可跑通;进群链接/活码以后台「二维码管理」或直接改 `app/api/config/route.ts` 默认值均可;若要多环境/持久化,再对接 db 配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、营销策略
|
||||||
|
|
||||||
|
| 项目 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **配置** | 站点名、作者信息、派对房时间、Banner 等:`/api/config` 返回的 `siteConfig`、`authorInfo`、`marketing.banner` 等,经 `fetchSettings` 合并进 store。 |
|
||||||
|
| **推广** | 邀请码绑定 `POST /api/referral/bind`,推广数据 `GET /api/referral/data`,访问记录 `POST /api/referral/visit`;分销比例等见 `api/db/config` 的 `referral_config`(后台「系统设置」可调)。 |
|
||||||
|
| **海报** | 推广海报由前端组件(如 `components/modules/referral/poster-modal.tsx`)生成,依赖 store 中的用户与配置。 |
|
||||||
|
| **内容** | 书籍章节、免费章节列表等:来自 `lib/book-data` + 接口(如 `api/book/*`、`api/content`);内容修改以第一个目录下 `book/` 及 `lib/book-data.ts` 为准,不新增目录。 |
|
||||||
|
|
||||||
|
**落地要点**:营销与内容均以主项目现有模块为准;配置优先从 `/api/config`(及可选 db)读取,保证运行一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、支付
|
||||||
|
|
||||||
|
| 项目 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| **下单** | `POST /api/payment/create-order` 创建订单;参数与支付方式以 `lib/payment-service`、`lib/payment/*` 及后台「支付设置」相关配置为准。 |
|
||||||
|
| **回调** | 微信 `POST /api/payment/wechat/notify`,支付宝 `POST /api/payment/alipay/notify`;支付网关配置回调 URL 至上述接口。 |
|
||||||
|
| **前端回调** | 前端轮询或跳转:`/api/payment/verify`、`/api/payment/status/[orderSn]`、`/api/payment/callback`(当前 callback 为简单确认,实际到账以微信/支付宝 notify 为准)。 |
|
||||||
|
| **与进群衔接** | 支付成功并校验通过后,前端根据 `settings.paymentMethods.wechat.groupQrCode` 或活码展示/跳转进群。 |
|
||||||
|
|
||||||
|
**落地要点**:保持现有支付路由与 lib 不变;确保生产环境配置好微信/支付宝回调地址及密钥,即可跑通整条「支付 → 到账 → 进群」链路。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、多端协同与 yongpxu-soul 分支
|
||||||
|
|
||||||
|
- **主项目(第一目录)**:单仓 Next,鉴权/进群/营销/支付均按上文链路运行,不新增目录、不改变现有文件结构。
|
||||||
|
- **yongpxu-soul 分支**:在现有基础上增加了部署脚本(如 `scripts/deploy_baota.py`)、小程序构建与上传、开发文档(小程序管理、服务器管理、提现功能文档等),以及部分依赖与配置;**业务链路(鉴权→进群→营销→支付)与主项目一致**,仍以 `app/`、`lib/`、`app/api/` 现有实现为准。
|
||||||
|
- **协同方式**:多个角色可并行优化——例如:A 负责后台鉴权与登出对接;B 负责进群配置与活码持久化方案;C 负责营销配置与内容更新;D 负责支付回调与对账——所有改动均限制在现有文件与路由内,不增加新的一级目录或拆仓。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、运行检查清单(保证可运行)
|
||||||
|
|
||||||
|
1. **环境**:`pnpm install`,可选 `.env.local` 配置 `MYSQL_*`、`SKIP_DB`(见 `开发文档/本机运行文档.md`)。
|
||||||
|
2. **鉴权**:访问 `/admin/login`,admin / key123456 可进入后台。
|
||||||
|
3. **配置**:首页或任意页加载时 `ConfigLoader` 会请求 `/api/config`;若接口失败,前端使用 store 默认值仍可浏览。
|
||||||
|
4. **进群**:后台「二维码管理」配置群链接/活码后,支付成功页或相关弹窗可展示/跳转(当前为前端 store,刷新后以 `/api/config` 为准)。
|
||||||
|
5. **营销**:推广链接、海报、分销比例依赖 store 与 `api/referral/*`、`api/db/config`,按现有逻辑即可。
|
||||||
|
6. **支付**:创建订单 → 支付 → 微信/支付宝回调至 `/api/payment/wechat/notify`、`/api/payment/alipay/notify`;前端校验订单状态后展示进群或解锁内容。
|
||||||
|
|
||||||
|
按上述清单自检后,整条链路可在不修改文件结构的前提下完成落地与运行;后续迭代(如活码持久化、admin 密码从环境变量读取)可在对应单文件内扩展。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、运行检查执行记录(2026-02-20)
|
||||||
|
|
||||||
|
| 检查项 | 结果 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 环境 | ✅ | `pnpm install` 成功;可选 `.env.local` 配置 `MYSQL_*`、`SKIP_DB`。 |
|
||||||
|
| 构建 | ✅ | `pnpm run build` 成功,Next.js 16.0.10,output: standalone。 |
|
||||||
|
| 首页 | ✅ | 开发环境 `pnpm dev` 启动后 `GET /` 返回 200。 |
|
||||||
|
| 配置接口 | ✅ | `GET /api/config` 返回 200,含 paymentMethods、marketing 等。 |
|
||||||
|
| 鉴权 | ✅ | 路由 `/admin/login` 存在;store.adminLogin 与 admin/key123456 一致。 |
|
||||||
|
| 进群/营销/支付 | ✅ | 路由与 store 配置完整;支付回调路由 `/api/payment/wechat/notify`、`/api/payment/alipay/notify` 已存在。 |
|
||||||
|
|
||||||
|
结论:项目在未修改文件结构下可正常构建与运行,链路(鉴权→进群→营销→支付)就绪。
|
||||||
Reference in New Issue
Block a user