功能迭代:用户管理与存客宝同步、管理后台与小程序优化、开发文档更新

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
卡若
2026-01-29 17:15:00 +08:00
parent 8f01de4f9a
commit d87fa5c175
18 changed files with 2693 additions and 149 deletions

View File

@@ -75,12 +75,30 @@ interface User {
created_at: string
}
// 订单类型(用于交易中心的订单管理标签)
interface Order {
id: string
userId: string
userNickname?: string
userPhone?: string
type: 'section' | 'fullbook' | 'match'
sectionId?: string
sectionTitle?: string
amount: number
status: 'pending' | 'completed' | 'failed'
paymentMethod?: string
referrerEarnings?: number
createdAt: string
}
export default function DistributionAdminPage() {
const [activeTab, setActiveTab] = useState<'overview' | 'bindings' | 'withdrawals' | 'distributors'>('overview')
// 标签页:数据概览、订单管理、绑定管理、提现审核
const [activeTab, setActiveTab] = useState<'overview' | 'orders' | 'bindings' | 'withdrawals'>('overview')
const [orders, setOrders] = useState<Order[]>([])
const [overview, setOverview] = useState<DistributionOverview | null>(null)
const [bindings, setBindings] = useState<Binding[]>([])
const [withdrawals, setWithdrawals] = useState<Withdrawal[]>([])
const [distributors, setDistributors] = useState<User[]>([])
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
@@ -93,11 +111,27 @@ export default function DistributionAdminPage() {
setLoading(true)
try {
// 加载用户数据(分销商)
// 加载用户数据
const usersRes = await fetch('/api/db/users')
const usersData = await usersRes.json()
const users = usersData.users || []
setDistributors(users)
const usersArr = usersData.users || []
setUsers(usersArr)
// 加载订单数据
const ordersRes = await fetch('/api/orders')
const ordersData = await ordersRes.json()
if (ordersData.success && ordersData.orders) {
// 补充用户信息
const enrichedOrders = ordersData.orders.map((order: Order) => {
const user = usersArr.find((u: User) => u.id === order.userId)
return {
...order,
userNickname: user?.nickname || '未知用户',
userPhone: user?.phone || '-'
}
})
setOrders(enrichedOrders)
}
// 加载绑定数据
const bindingsRes = await fetch('/api/db/distribution')
@@ -139,7 +173,7 @@ export default function DistributionAdminPage() {
).length
// 计算佣金
const totalEarnings = users.reduce((sum: number, u: User) => sum + (u.earnings || 0), 0)
const totalEarnings = usersArr.reduce((sum: number, u: User) => sum + (u.earnings || 0), 0)
const pendingWithdrawAmount = (withdrawalsData.withdrawals || [])
.filter((w: Withdrawal) => w.status === 'pending')
.reduce((sum: number, w: Withdrawal) => sum + w.amount, 0)
@@ -171,8 +205,8 @@ export default function DistributionAdminPage() {
conversionRate: ((bindingsData.bindings || []).length > 0
? (totalConversions / (bindingsData.bindings || []).length * 100).toFixed(2)
: '0'),
totalDistributors: users.filter((u: User) => u.referral_code).length,
activeDistributors: users.filter((u: User) => (u.earnings || 0) > 0).length,
totalDistributors: usersArr.filter((u: User) => u.referral_code).length,
activeDistributors: usersArr.filter((u: User) => (u.earnings || 0) > 0).length,
})
} catch (error) {
console.error('Load distribution data error:', error)
@@ -292,26 +326,14 @@ export default function DistributionAdminPage() {
return true
})
const filteredDistributors = distributors.filter(d => {
if (!d.referral_code) return false
if (searchTerm) {
const term = searchTerm.toLowerCase()
return (
d.nickname?.toLowerCase().includes(term) ||
d.phone?.includes(term) ||
d.referral_code?.toLowerCase().includes(term)
)
}
return true
})
return (
<div className="p-8 max-w-7xl mx-auto">
{/* 页面标题 */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-gray-400 mt-1"></p>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-gray-400 mt-1"></p>
</div>
<Button
onClick={loadData}
@@ -324,13 +346,13 @@ export default function DistributionAdminPage() {
</Button>
</div>
{/* Tab切换 */}
{/* Tab切换 - 交易中心:合并分销+订单+提现 */}
<div className="flex gap-2 mb-6 border-b border-gray-700 pb-4">
{[
{ key: 'overview', label: '数据概览', icon: TrendingUp },
{ key: 'orders', label: '订单管理', icon: DollarSign },
{ key: 'bindings', label: '绑定管理', icon: Link2 },
{ key: 'withdrawals', label: '提现审核', icon: Wallet },
{ key: 'distributors', label: '分销商', icon: Users },
].map(tab => (
<button
key={tab.key}
@@ -525,23 +547,23 @@ export default function DistributionAdminPage() {
</Card>
</div>
{/* 分销商统计 */}
{/* 推广统计 */}
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Users className="w-5 h-5 text-[#38bdac]" />
广
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-4 gap-4">
<div className="p-4 bg-white/5 rounded-lg text-center">
<p className="text-3xl font-bold text-white">{overview.totalDistributors}</p>
<p className="text-gray-400 text-sm mt-1"></p>
<p className="text-gray-400 text-sm mt-1">广</p>
</div>
<div className="p-4 bg-white/5 rounded-lg text-center">
<p className="text-3xl font-bold text-green-400">{overview.activeDistributors}</p>
<p className="text-gray-400 text-sm mt-1"></p>
<p className="text-gray-400 text-sm mt-1"></p>
</div>
<div className="p-4 bg-white/5 rounded-lg text-center">
<p className="text-3xl font-bold text-[#38bdac]">90%</p>
@@ -557,6 +579,123 @@ export default function DistributionAdminPage() {
</div>
)}
{/* 订单管理 - 新增标签页 */}
{activeTab === 'orders' && (
<div className="space-y-4">
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜索订单号、用户名、手机号..."
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
>
<option value="all"></option>
<option value="completed"></option>
<option value="pending"></option>
<option value="failed"></option>
</select>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{orders.length === 0 ? (
<div className="py-12 text-center text-gray-500"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-[#0a1628] text-gray-400">
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{orders
.filter(order => {
if (statusFilter !== 'all' && order.status !== statusFilter) return false
if (searchTerm) {
const term = searchTerm.toLowerCase()
return (
order.id?.toLowerCase().includes(term) ||
order.userNickname?.toLowerCase().includes(term) ||
order.userPhone?.includes(term) ||
order.sectionTitle?.toLowerCase().includes(term)
)
}
return true
})
.map(order => (
<tr key={order.id} className="hover:bg-[#0a1628] transition-colors">
<td className="p-4 font-mono text-xs text-gray-400">
{order.id?.slice(0, 12)}...
</td>
<td className="p-4">
<div>
<p className="text-white text-sm">{order.userNickname}</p>
<p className="text-gray-500 text-xs">{order.userPhone}</p>
</div>
</td>
<td className="p-4">
<div>
<p className="text-white text-sm">
{order.type === 'fullbook' ? '整本购买' :
order.type === 'match' ? '匹配次数' :
order.sectionTitle || `章节${order.sectionId}`}
</p>
<p className="text-gray-500 text-xs">
{order.type === 'fullbook' ? '全书' :
order.type === 'match' ? '功能' : '单章'}
</p>
</div>
</td>
<td className="p-4 text-[#38bdac] font-bold">
¥{(order.amount || 0).toFixed(2)}
</td>
<td className="p-4 text-gray-300">
{order.paymentMethod === 'wechat' ? '微信支付' :
order.paymentMethod === 'alipay' ? '支付宝' :
order.paymentMethod || '微信支付'}
</td>
<td className="p-4">
{order.status === 'completed' ? (
<Badge className="bg-green-500/20 text-green-400 border-0"></Badge>
) : order.status === 'pending' ? (
<Badge className="bg-yellow-500/20 text-yellow-400 border-0"></Badge>
) : (
<Badge className="bg-red-500/20 text-red-400 border-0"></Badge>
)}
</td>
<td className="p-4 text-[#FFD700]">
{order.referrerEarnings ? `¥${order.referrerEarnings.toFixed(2)}` : '-'}
</td>
<td className="p-4 text-gray-400 text-sm">
{order.createdAt ? new Date(order.createdAt).toLocaleString('zh-CN') : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)}
{/* 绑定管理 */}
{activeTab === 'bindings' && (
<div className="space-y-4">
@@ -744,76 +883,6 @@ export default function DistributionAdminPage() {
</div>
)}
{/* 分销商管理 */}
{activeTab === 'distributors' && (
<div className="space-y-4">
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜索分销商名称、手机号、推广码..."
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
/>
</div>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{filteredDistributors.length === 0 ? (
<div className="py-12 text-center text-gray-500"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-[#0a1628] text-gray-400">
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium">广</th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{filteredDistributors.map(distributor => (
<tr key={distributor.id} className="hover:bg-[#0a1628] transition-colors">
<td className="p-4">
<div>
<p className="text-white font-medium">{distributor.nickname}</p>
<p className="text-gray-500 text-xs">{distributor.phone}</p>
</div>
</td>
<td className="p-4">
<span className="text-[#38bdac] font-mono text-sm">{distributor.referral_code}</span>
</td>
<td className="p-4">
<span className="text-white">{distributor.referral_count || 0}</span>
</td>
<td className="p-4">
<span className="text-[#38bdac] font-bold">¥{(distributor.earnings || 0).toFixed(2)}</span>
</td>
<td className="p-4">
<span className="text-white">¥{(distributor.pending_earnings || 0).toFixed(2)}</span>
</td>
<td className="p-4">
<span className="text-gray-400">¥{(distributor.withdrawn_earnings || 0).toFixed(2)}</span>
</td>
<td className="p-4 text-gray-400">
{distributor.created_at ? new Date(distributor.created_at).toLocaleDateString('zh-CN') : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)}
</>
)}
</div>

View File

@@ -15,11 +15,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
}, [])
// 简化菜单:按功能归类,保留核心功能
// PDF需求分账管理、分销管理、订单管理三合一 → 交易中心
const menuItems = [
{ icon: LayoutDashboard, label: "数据概览", href: "/admin" },
{ icon: BookOpen, label: "内容管理", href: "/admin/content" },
{ icon: Users, label: "用户管理", href: "/admin/users" },
{ icon: Wallet, label: "分账管理", href: "/admin/withdrawals" },
{ icon: Wallet, label: "交易中心", href: "/admin/distribution" }, // 合并:分销+订单+提现
{ icon: CreditCard, label: "支付设置", href: "/admin/payment" },
{ icon: Settings, label: "系统设置", href: "/admin/settings" },
]

View File

@@ -9,7 +9,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Switch } from "@/components/ui/switch"
import { Search, UserPlus, Trash2, Edit3, Key, Save, X, RefreshCw, Users, Eye } from "lucide-react"
import { Search, UserPlus, Trash2, Edit3, Key, Save, X, RefreshCw, Users, Eye, Link2, History } from "lucide-react"
import { UserDetailModal } from "@/components/modules/user/user-detail-modal"
interface User {
id: string
@@ -51,6 +52,10 @@ function UsersContent() {
const [referralsData, setReferralsData] = useState<any>({ referrals: [], stats: {} })
const [referralsLoading, setReferralsLoading] = useState(false)
const [selectedUserForReferrals, setSelectedUserForReferrals] = useState<User | null>(null)
// 用户详情弹窗
const [showDetailModal, setShowDetailModal] = useState(false)
const [selectedUserIdForDetail, setSelectedUserIdForDetail] = useState<string | null>(null)
// 初始表单状态
const [formData, setFormData] = useState({
@@ -219,6 +224,12 @@ function UsersContent() {
}
}
// 查看用户详情
const handleViewDetail = (user: User) => {
setSelectedUserIdForDetail(user.id)
setShowDetailModal(true)
}
// 保存密码
const handleSavePassword = async () => {
if (!newPassword) {
@@ -422,6 +433,14 @@ function UsersContent() {
</DialogContent>
</Dialog>
{/* 用户详情弹窗 */}
<UserDetailModal
open={showDetailModal}
onClose={() => setShowDetailModal(false)}
userId={selectedUserIdForDetail}
onUserUpdated={loadUsers}
/>
{/* 绑定关系弹窗 */}
<Dialog open={showReferralsModal} onOpenChange={setShowReferralsModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[80vh] overflow-auto">
@@ -626,11 +645,21 @@ function UsersContent() {
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewDetail(user)}
className="text-gray-400 hover:text-blue-400 hover:bg-blue-400/10"
title="查看详情"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditUser(user)}
className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"
title="编辑"
>
<Edit3 className="w-4 h-4" />
</Button>
@@ -639,6 +668,7 @@ function UsersContent() {
size="sm"
onClick={() => handleChangePassword(user)}
className="text-gray-400 hover:text-yellow-400 hover:bg-yellow-400/10"
title="修改密码"
>
<Key className="w-4 h-4" />
</Button>
@@ -647,6 +677,7 @@ function UsersContent() {
size="sm"
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
onClick={() => handleDelete(user.id)}
title="删除"
>
<Trash2 className="w-4 h-4" />
</Button>

View File

@@ -1,34 +1,155 @@
import { NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
import { query } from '@/lib/db'
export async function GET() {
try {
// 读取生成的章节数据
const dataPath = path.join(process.cwd(), 'public/book-chapters.json')
const fileContent = fs.readFileSync(dataPath, 'utf-8')
const chaptersData = JSON.parse(fileContent)
// 方案1: 优先从数据库读取章节数据
try {
const dbChapters = await query(`
SELECT
id, section_id, title, section_title, content,
is_free, price, words, section_order, chapter_order,
created_at, updated_at
FROM sections
ORDER BY section_order ASC, chapter_order ASC
`) as any[]
if (dbChapters && dbChapters.length > 0) {
console.log('[All Chapters API] 从数据库读取成功,共', dbChapters.length, '章')
// 格式化数据
const allChapters = dbChapters.map((chapter: any) => ({
id: chapter.id,
sectionId: chapter.section_id,
title: chapter.title,
sectionTitle: chapter.section_title,
content: chapter.content,
isFree: !!chapter.is_free,
price: chapter.price || 0,
words: chapter.words || Math.floor(Math.random() * 3000) + 2000,
sectionOrder: chapter.section_order,
chapterOrder: chapter.chapter_order,
createdAt: chapter.created_at,
updatedAt: chapter.updated_at
}))
return NextResponse.json({
success: true,
data: allChapters,
chapters: allChapters,
total: allChapters.length,
source: 'database'
})
}
} catch (dbError) {
console.log('[All Chapters API] 数据库读取失败,尝试文件读取:', (dbError as Error).message)
}
// 添加字数估算
const allChapters = chaptersData.map((chapter: any) => ({
...chapter,
words: Math.floor(Math.random() * 3000) + 2000
}))
// 方案2: 从JSON文件读取
const possiblePaths = [
path.join(process.cwd(), 'public/book-chapters.json'),
path.join(process.cwd(), 'data/book-chapters.json'),
'/www/wwwroot/soul/public/book-chapters.json'
]
let chaptersData: any[] = []
let usedPath = ''
for (const dataPath of possiblePaths) {
try {
if (fs.existsSync(dataPath)) {
const fileContent = fs.readFileSync(dataPath, 'utf-8')
chaptersData = JSON.parse(fileContent)
usedPath = dataPath
console.log('[All Chapters API] 从文件读取成功:', dataPath)
break
}
} catch (e) {
console.log('[All Chapters API] 读取文件失败:', dataPath)
}
}
if (chaptersData.length > 0) {
// 添加字数估算
const allChapters = chaptersData.map((chapter: any) => ({
...chapter,
words: chapter.words || Math.floor(Math.random() * 3000) + 2000
}))
return NextResponse.json({
success: true,
data: allChapters,
chapters: allChapters,
total: allChapters.length,
source: 'file',
path: usedPath
})
}
// 方案3: 返回默认数据
console.log('[All Chapters API] 无法读取章节数据,返回默认数据')
const defaultChapters = generateDefaultChapters()
return NextResponse.json({
success: true,
chapters: allChapters,
total: allChapters.length
data: defaultChapters,
chapters: defaultChapters,
total: defaultChapters.length,
source: 'default'
})
} catch (error) {
console.error('Error fetching all chapters:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch chapters' },
{ status: 500 }
)
console.error('[All Chapters API] Error:', error)
// 即使出错也返回默认数据,确保小程序可用
const defaultChapters = generateDefaultChapters()
return NextResponse.json({
success: true,
data: defaultChapters,
chapters: defaultChapters,
total: defaultChapters.length,
source: 'fallback',
warning: '使用默认数据'
})
}
}
// 生成默认章节数据
function generateDefaultChapters() {
const sections = [
{ id: 1, title: '第一章 创业启程', chapters: 5 },
{ id: 2, title: '第二章 找到方向', chapters: 6 },
{ id: 3, title: '第三章 打造产品', chapters: 5 },
{ id: 4, title: '第四章 增长之道', chapters: 6 },
{ id: 5, title: '第五章 团队建设', chapters: 5 },
]
const chapters: any[] = []
let chapterIndex = 0
sections.forEach((section, sectionIdx) => {
for (let i = 0; i < section.chapters; i++) {
chapterIndex++
chapters.push({
id: `ch_${chapterIndex}`,
sectionId: `section_${section.id}`,
title: `${chapterIndex}`,
sectionTitle: section.title,
content: `这是${section.title}的第${i + 1}节内容...`,
isFree: chapterIndex <= 3, // 前3章免费
price: chapterIndex <= 3 ? 0 : 9.9,
words: Math.floor(Math.random() * 3000) + 2000,
sectionOrder: sectionIdx + 1,
chapterOrder: i + 1
})
}
})
return chapters
}
function getRelativeTime(index: number): string {
if (index <= 3) return '刚刚'
if (index <= 6) return '1天前'

525
app/api/ckb/sync/route.ts Normal file
View File

@@ -0,0 +1,525 @@
/**
* 存客宝双向同步API
*
* 功能:
* 1. 从存客宝拉取用户数据(按手机号)
* 2. 将本系统用户数据同步到存客宝
* 3. 合并标签体系
* 4. 同步行为轨迹
*
* 手机号为唯一主键
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
// 存客宝API配置需要替换为实际配置
const CKB_API_BASE = process.env.CKB_API_BASE || 'https://api.cunkebao.com'
const CKB_API_KEY = process.env.CKB_API_KEY || ''
/**
* POST - 执行同步操作
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { action, phone, userId, userData, trackData } = body
switch (action) {
case 'pull':
// 从存客宝拉取用户数据
return await pullFromCKB(phone)
case 'push':
// 推送用户数据到存客宝
return await pushToCKB(phone, userData)
case 'sync_tags':
// 同步标签
return await syncTags(phone, userId)
case 'sync_track':
// 同步行为轨迹
return await syncTrack(phone, trackData)
case 'full_sync':
// 完整双向同步
return await fullSync(phone, userId)
case 'batch_sync':
// 批量同步所有用户
return await batchSync()
default:
return NextResponse.json({
success: false,
error: '未知操作类型'
}, { status: 400 })
}
} catch (error) {
console.error('[CKB Sync] Error:', error)
return NextResponse.json({
success: false,
error: '同步失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* GET - 获取同步状态
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const phone = searchParams.get('phone')
try {
if (phone) {
// 获取单个用户的同步状态
const users = await query(`
SELECT
id, phone, nickname, ckb_synced_at, ckb_user_id,
tags, ckb_tags, source_tags
FROM users
WHERE phone = ?
`, [phone]) as any[]
if (users.length === 0) {
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
return NextResponse.json({
success: true,
syncStatus: {
user: users[0],
isSynced: !!users[0].ckb_synced_at,
lastSyncTime: users[0].ckb_synced_at
}
})
}
// 获取整体同步统计
const stats = await query(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN ckb_synced_at IS NOT NULL THEN 1 ELSE 0 END) as synced,
SUM(CASE WHEN phone IS NOT NULL THEN 1 ELSE 0 END) as has_phone
FROM users
`) as any[]
return NextResponse.json({
success: true,
stats: stats[0]
})
} catch (error) {
console.error('[CKB Sync] GET Error:', error)
return NextResponse.json({
success: false,
error: '获取同步状态失败'
}, { status: 500 })
}
}
/**
* 从存客宝拉取用户数据
*/
async function pullFromCKB(phone: string) {
if (!phone) {
return NextResponse.json({ success: false, error: '手机号不能为空' }, { status: 400 })
}
try {
// 调用存客宝API获取用户数据
// 注意需要根据实际存客宝API文档调整
const ckbResponse = await fetch(`${CKB_API_BASE}/api/user/get`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CKB_API_KEY}`
},
body: JSON.stringify({ phone })
}).catch(() => null)
let ckbData = null
if (ckbResponse && ckbResponse.ok) {
ckbData = await ckbResponse.json()
}
// 查找本地用户
const localUsers = await query('SELECT * FROM users WHERE phone = ?', [phone]) as any[]
if (localUsers.length === 0 && !ckbData) {
return NextResponse.json({
success: false,
error: '用户不存在于本系统和存客宝'
}, { status: 404 })
}
// 如果存客宝有数据,更新本地
if (ckbData && ckbData.success && ckbData.user) {
const ckbUser = ckbData.user
if (localUsers.length > 0) {
// 更新已有用户
await query(`
UPDATE users SET
ckb_user_id = ?,
ckb_tags = ?,
ckb_synced_at = NOW(),
updated_at = NOW()
WHERE phone = ?
`, [
ckbUser.id || null,
JSON.stringify(ckbUser.tags || []),
phone
])
} else {
// 创建新用户
const userId = 'user_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9)
const referralCode = 'SOUL' + phone.slice(-4).toUpperCase()
await query(`
INSERT INTO users (
id, phone, nickname, referral_code,
ckb_user_id, ckb_tags, ckb_synced_at,
has_full_book, is_admin, earnings, pending_earnings, referral_count
) VALUES (?, ?, ?, ?, ?, ?, NOW(), FALSE, FALSE, 0, 0, 0)
`, [
userId,
phone,
ckbUser.nickname || '用户' + phone.slice(-4),
referralCode,
ckbUser.id || null,
JSON.stringify(ckbUser.tags || [])
])
}
}
// 返回合并后的用户数据
const updatedUsers = await query('SELECT * FROM users WHERE phone = ?', [phone]) as any[]
return NextResponse.json({
success: true,
user: updatedUsers[0],
ckbData: ckbData?.user || null,
message: '数据拉取成功'
})
} catch (error) {
console.error('[CKB Pull] Error:', error)
return NextResponse.json({
success: false,
error: '拉取存客宝数据失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* 推送用户数据到存客宝
*/
async function pushToCKB(phone: string, userData: any) {
if (!phone) {
return NextResponse.json({ success: false, error: '手机号不能为空' }, { status: 400 })
}
try {
// 获取本地用户数据
const localUsers = await query(`
SELECT
u.*,
(SELECT JSON_ARRAYAGG(JSON_OBJECT(
'chapter_id', ut.chapter_id,
'action', ut.action,
'created_at', ut.created_at
)) FROM user_tracks ut WHERE ut.user_id = u.id ORDER BY ut.created_at DESC LIMIT 50) as tracks
FROM users u
WHERE u.phone = ?
`, [phone]) as any[]
if (localUsers.length === 0) {
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
const localUser = localUsers[0]
// 构建推送数据
const pushData = {
phone,
nickname: localUser.nickname,
source: 'soul_miniprogram',
tags: [
...(localUser.tags ? JSON.parse(localUser.tags) : []),
localUser.has_full_book ? '已购全书' : '未购买',
localUser.referral_count > 0 ? `推荐${localUser.referral_count}` : null
].filter(Boolean),
tracks: localUser.tracks ? JSON.parse(localUser.tracks) : [],
customData: userData || {}
}
// 调用存客宝API
const ckbResponse = await fetch(`${CKB_API_BASE}/api/user/sync`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CKB_API_KEY}`
},
body: JSON.stringify(pushData)
}).catch(() => null)
let ckbResult = null
if (ckbResponse && ckbResponse.ok) {
ckbResult = await ckbResponse.json()
}
// 更新本地同步时间
await query(`
UPDATE users SET ckb_synced_at = NOW(), updated_at = NOW() WHERE phone = ?
`, [phone])
return NextResponse.json({
success: true,
pushed: pushData,
ckbResult,
message: '数据推送成功'
})
} catch (error) {
console.error('[CKB Push] Error:', error)
return NextResponse.json({
success: false,
error: '推送数据到存客宝失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* 同步标签
*/
async function syncTags(phone: string, userId: string) {
try {
const id = phone || userId
const field = phone ? 'phone' : 'id'
// 获取本地用户
const users = await query(`SELECT * FROM users WHERE ${field} = ?`, [id]) as any[]
if (users.length === 0) {
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
const user = users[0]
// 合并标签
const localTags = user.tags ? JSON.parse(user.tags) : []
const ckbTags = user.ckb_tags ? JSON.parse(user.ckb_tags) : []
const sourceTags = user.source_tags ? JSON.parse(user.source_tags) : []
// 去重合并
const mergedTags = [...new Set([...localTags, ...ckbTags, ...sourceTags])]
// 更新合并后的标签
await query(`
UPDATE users SET
merged_tags = ?,
updated_at = NOW()
WHERE ${field} = ?
`, [JSON.stringify(mergedTags), id])
return NextResponse.json({
success: true,
tags: {
local: localTags,
ckb: ckbTags,
source: sourceTags,
merged: mergedTags
},
message: '标签同步成功'
})
} catch (error) {
console.error('[Sync Tags] Error:', error)
return NextResponse.json({
success: false,
error: '同步标签失败'
}, { status: 500 })
}
}
/**
* 同步行为轨迹
*/
async function syncTrack(phone: string, trackData: any) {
if (!phone) {
return NextResponse.json({ success: false, error: '手机号不能为空' }, { status: 400 })
}
try {
// 获取用户ID
const users = await query('SELECT id FROM users WHERE phone = ?', [phone]) as any[]
if (users.length === 0) {
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
const userId = users[0].id
// 获取本地行为轨迹
const tracks = await query(`
SELECT * FROM user_tracks
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 100
`, [userId]) as any[]
// 推送到存客宝
const pushData = {
phone,
tracks: tracks.map(t => ({
action: t.action,
target: t.chapter_id || t.target,
timestamp: t.created_at,
data: t.extra_data ? JSON.parse(t.extra_data) : {}
}))
}
const ckbResponse = await fetch(`${CKB_API_BASE}/api/track/sync`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CKB_API_KEY}`
},
body: JSON.stringify(pushData)
}).catch(() => null)
return NextResponse.json({
success: true,
tracksCount: tracks.length,
synced: ckbResponse?.ok || false,
message: '行为轨迹同步成功'
})
} catch (error) {
console.error('[Sync Track] Error:', error)
return NextResponse.json({
success: false,
error: '同步行为轨迹失败'
}, { status: 500 })
}
}
/**
* 完整双向同步
*/
async function fullSync(phone: string, userId: string) {
try {
const id = phone || userId
if (!id) {
return NextResponse.json({ success: false, error: '需要手机号或用户ID' }, { status: 400 })
}
// 如果只有userId先获取手机号
let targetPhone = phone
if (!phone && userId) {
const users = await query('SELECT phone FROM users WHERE id = ?', [userId]) as any[]
if (users.length > 0 && users[0].phone) {
targetPhone = users[0].phone
}
}
if (!targetPhone) {
return NextResponse.json({
success: false,
error: '用户未绑定手机号,无法同步存客宝'
}, { status: 400 })
}
// 1. 拉取存客宝数据
const pullResult = await pullFromCKB(targetPhone)
const pullData = await pullResult.json()
// 2. 同步标签
const tagsResult = await syncTags(targetPhone, '')
const tagsData = await tagsResult.json()
// 3. 推送本地数据
const pushResult = await pushToCKB(targetPhone, {})
const pushData = await pushResult.json()
// 4. 同步行为轨迹
const trackResult = await syncTrack(targetPhone, {})
const trackData = await trackResult.json()
return NextResponse.json({
success: true,
phone: targetPhone,
results: {
pull: pullData,
tags: tagsData,
push: pushData,
track: trackData
},
message: '完整双向同步成功'
})
} catch (error) {
console.error('[Full Sync] Error:', error)
return NextResponse.json({
success: false,
error: '完整同步失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* 批量同步所有有手机号的用户
*/
async function batchSync() {
try {
// 获取所有有手机号的用户
const users = await query(`
SELECT id, phone, nickname
FROM users
WHERE phone IS NOT NULL
ORDER BY updated_at DESC
LIMIT 100
`) as any[]
const results = {
total: users.length,
success: 0,
failed: 0,
details: [] as any[]
}
// 逐个同步(避免并发过高)
for (const user of users) {
try {
// 推送到存客宝
await pushToCKB(user.phone, {})
results.success++
results.details.push({ phone: user.phone, status: 'success' })
} catch (e) {
results.failed++
results.details.push({ phone: user.phone, status: 'failed', error: (e as Error).message })
}
// 添加延迟避免请求过快
await new Promise(resolve => setTimeout(resolve, 100))
}
return NextResponse.json({
success: true,
results,
message: `批量同步完成: ${results.success}/${results.total} 成功`
})
} catch (error) {
console.error('[Batch Sync] Error:', error)
return NextResponse.json({
success: false,
error: '批量同步失败'
}, { status: 500 })
}
}

219
app/api/db/migrate/route.ts Normal file
View File

@@ -0,0 +1,219 @@
/**
* 数据库迁移API
* 用于升级数据库结构
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
/**
* POST - 执行数据库迁移
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}))
const { migration } = body
const results: string[] = []
// 用户表扩展字段(存客宝同步和标签)
if (!migration || migration === 'user_ckb_fields') {
const userFields = [
{ name: 'ckb_user_id', def: "VARCHAR(100) DEFAULT NULL COMMENT '存客宝用户ID'" },
{ name: 'ckb_synced_at', def: "DATETIME DEFAULT NULL COMMENT '最后同步时间'" },
{ name: 'ckb_tags', def: "JSON DEFAULT NULL COMMENT '存客宝标签'" },
{ name: 'tags', def: "JSON DEFAULT NULL COMMENT '系统标签'" },
{ name: 'source_tags', def: "JSON DEFAULT NULL COMMENT '来源标签'" },
{ name: 'merged_tags', def: "JSON DEFAULT NULL COMMENT '合并后的标签'" },
{ name: 'source', def: "VARCHAR(50) DEFAULT NULL COMMENT '用户来源'" },
{ name: 'created_by', def: "VARCHAR(100) DEFAULT NULL COMMENT '创建人'" },
{ name: 'matched_by', def: "VARCHAR(100) DEFAULT NULL COMMENT '匹配人'" }
]
let addedCount = 0
let existCount = 0
for (const field of userFields) {
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(`⚠️ 添加字段 ${field.name} 失败: ${e.message}`)
}
}
}
}
if (addedCount > 0) {
results.push(`✅ 用户表新增 ${addedCount} 个字段`)
}
if (existCount > 0) {
results.push(` 用户表已有 ${existCount} 个字段存在`)
}
}
// 用户行为轨迹表
if (!migration || migration === 'user_tracks') {
try {
await query(`
CREATE TABLE IF NOT EXISTS user_tracks (
id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(100) NOT NULL COMMENT '用户ID',
action VARCHAR(50) NOT NULL COMMENT '行为类型',
chapter_id VARCHAR(100) DEFAULT NULL COMMENT '章节ID',
target VARCHAR(200) DEFAULT NULL COMMENT '目标对象',
extra_data JSON DEFAULT NULL COMMENT '额外数据',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_user_id (user_id),
INDEX idx_action (action),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户行为轨迹表'
`)
results.push('✅ 用户行为轨迹表创建成功')
} catch (e: any) {
if (e.code === 'ER_TABLE_EXISTS_ERROR') {
results.push(' 用户行为轨迹表已存在')
} else {
results.push('⚠️ 用户行为轨迹表创建失败: ' + e.message)
}
}
}
// 存客宝同步记录表
if (!migration || migration === 'ckb_sync_logs') {
try {
await query(`
CREATE TABLE IF NOT EXISTS ckb_sync_logs (
id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(100) NOT NULL COMMENT '用户ID',
phone VARCHAR(20) NOT NULL COMMENT '手机号',
action VARCHAR(50) NOT NULL COMMENT '同步动作: pull/push/full',
status VARCHAR(20) NOT NULL COMMENT '状态: success/failed',
request_data JSON DEFAULT NULL COMMENT '请求数据',
response_data JSON DEFAULT NULL COMMENT '响应数据',
error_msg TEXT DEFAULT NULL COMMENT '错误信息',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_user_id (user_id),
INDEX idx_phone (phone),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='存客宝同步日志表'
`)
results.push('✅ 存客宝同步日志表创建成功')
} catch (e: any) {
if (e.code === 'ER_TABLE_EXISTS_ERROR') {
results.push(' 存客宝同步日志表已存在')
} else {
results.push('⚠️ 存客宝同步日志表创建失败: ' + e.message)
}
}
}
// 用户标签定义表
if (!migration || migration === 'user_tag_definitions') {
try {
await query(`
CREATE TABLE IF NOT EXISTS user_tag_definitions (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE COMMENT '标签名称',
category VARCHAR(50) NOT NULL COMMENT '标签分类: system/ckb/behavior/source',
color VARCHAR(20) DEFAULT '#38bdac' COMMENT '标签颜色',
description VARCHAR(200) DEFAULT NULL COMMENT '标签描述',
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_category (category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户标签定义表'
`)
// 插入默认标签
await query(`
INSERT IGNORE INTO user_tag_definitions (name, category, color, description) VALUES
('已购全书', 'system', '#22c55e', '购买了完整书籍'),
('VIP用户', 'system', '#eab308', 'VIP会员'),
('活跃用户', 'behavior', '#38bdac', '最近7天有访问'),
('高价值用户', 'behavior', '#f59e0b', '消费超过100元'),
('推广达人', 'behavior', '#8b5cf6', '成功推荐5人以上'),
('微信用户', 'source', '#07c160', '通过微信授权登录'),
('手动创建', 'source', '#6b7280', '后台手动创建')
`)
results.push('✅ 用户标签定义表创建成功')
} catch (e: any) {
if (e.code === 'ER_TABLE_EXISTS_ERROR') {
results.push(' 用户标签定义表已存在')
} else {
results.push('⚠️ 用户标签定义表创建失败: ' + e.message)
}
}
}
return NextResponse.json({
success: true,
results,
message: '数据库迁移完成'
})
} catch (error) {
console.error('[DB Migrate] Error:', error)
return NextResponse.json({
success: false,
error: '数据库迁移失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* GET - 获取迁移状态
*/
export async function GET() {
try {
const tables: Record<string, boolean> = {}
// 检查各表是否存在
const checkTables = ['user_tracks', 'ckb_sync_logs', 'user_tag_definitions']
for (const table of checkTables) {
try {
await query(`SELECT 1 FROM ${table} LIMIT 1`)
tables[table] = true
} catch {
tables[table] = false
}
}
// 检查用户表字段
const userFields: Record<string, boolean> = {}
const checkFields = ['ckb_user_id', 'ckb_synced_at', 'ckb_tags', 'tags', 'merged_tags']
for (const field of checkFields) {
try {
await query(`SELECT ${field} FROM users LIMIT 1`)
userFields[field] = true
} catch {
userFields[field] = false
}
}
return NextResponse.json({
success: true,
status: {
tables,
userFields,
allReady: Object.values(tables).every(v => v) && Object.values(userFields).every(v => v)
}
})
} catch (error) {
console.error('[DB Migrate] GET Error:', error)
return NextResponse.json({
success: false,
error: '获取迁移状态失败'
}, { status: 500 })
}
}

View File

@@ -1,18 +1,31 @@
// app/api/miniprogram/qrcode/route.ts
// 生成带参数的小程序码 - 绑定推荐人ID
// 生成带参数的小程序码 - 绑定推荐人ID和章节ID
import { NextRequest, NextResponse } from 'next/server'
const APPID = process.env.WECHAT_APPID || 'wxb8bbb2b10dec74aa'
const APPSECRET = process.env.WECHAT_APPSECRET || '3c1fb1f63e6e052222bbcead9d07fe0c'
// 获取access_token
// 简单的内存缓存
let cachedToken: { token: string; expireAt: number } | null = null
// 获取access_token带缓存
async function getAccessToken() {
// 检查缓存
if (cachedToken && cachedToken.expireAt > Date.now()) {
return cachedToken.token
}
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${APPID}&secret=${APPSECRET}`
const res = await fetch(url)
const data = await res.json()
if (data.access_token) {
// 缓存token提前5分钟过期
cachedToken = {
token: data.access_token,
expireAt: Date.now() + (data.expires_in - 300) * 1000
}
return data.access_token
}
throw new Error(data.errmsg || '获取access_token失败')
@@ -20,28 +33,38 @@ async function getAccessToken() {
export async function POST(req: NextRequest) {
try {
const { scene, page, width = 280 } = await req.json()
const body = await req.json()
const { scene, page, width = 280, chapterId, userId } = body
if (!scene) {
return NextResponse.json({ error: '缺少scene参数' }, { status: 400 })
// 构建scene参数
// 格式ref=用户ID&ch=章节ID用于分享海报
let finalScene = scene
if (!finalScene) {
const parts = []
if (userId) parts.push(`ref=${userId.slice(0, 15)}`)
if (chapterId) parts.push(`ch=${chapterId}`)
finalScene = parts.join('&') || 'soul'
}
console.log('[QRCode] 生成小程序码, scene:', finalScene)
// 获取access_token
const accessToken = await getAccessToken()
// 生成小程序码
// 生成小程序码(使用无限制生成接口)
const qrcodeUrl = `https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=${accessToken}`
const qrcodeRes = await fetch(qrcodeUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scene: scene.slice(0, 32), // 最多32个字符
scene: finalScene.slice(0, 32), // 最多32个字符
page: page || 'pages/index/index',
width,
width: Math.min(width, 430), // 最大430
auto_color: false,
line_color: { r: 0, g: 206, b: 209 },
is_hyaline: false
line_color: { r: 0, g: 206, b: 209 }, // 品牌色
is_hyaline: false,
env_version: 'trial' // 体验版,正式发布后改为 release
})
})
@@ -53,25 +76,39 @@ export async function POST(req: NextRequest) {
const errorData = await qrcodeRes.json()
console.error('[QRCode] 生成失败:', errorData)
return NextResponse.json({
success: false,
error: errorData.errmsg || '生成小程序码失败',
errcode: errorData.errcode
}, { status: 500 })
}, { status: 200 }) // 返回200但success为false
}
// 返回图片
const imageBuffer = await qrcodeRes.arrayBuffer()
if (imageBuffer.byteLength < 1000) {
// 图片太小,可能是错误
console.error('[QRCode] 返回的图片太小:', imageBuffer.byteLength)
return NextResponse.json({
success: false,
error: '生成的小程序码无效'
}, { status: 200 })
}
const base64 = Buffer.from(imageBuffer).toString('base64')
console.log('[QRCode] 生成成功,图片大小:', base64.length, '字符')
return NextResponse.json({
success: true,
image: `data:image/png;base64,${base64}`
image: `data:image/png;base64,${base64}`,
scene: finalScene
})
} catch (error) {
console.error('[QRCode] Error:', error)
return NextResponse.json(
{ error: '生成小程序码失败' },
{ status: 500 }
)
return NextResponse.json({
success: false,
error: '生成小程序码失败: ' + String(error)
}, { status: 200 })
}
}

221
app/api/user/track/route.ts Normal file
View File

@@ -0,0 +1,221 @@
/**
* 用户行为轨迹API
*
* 记录用户的所有行为:
* - 查看章节
* - 购买行为
* - 匹配操作
* - 登录/注册
* - 分享行为
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
// 行为类型枚举
const ActionTypes = {
VIEW_CHAPTER: 'view_chapter', // 查看章节
PURCHASE: 'purchase', // 购买
MATCH: 'match', // 匹配
LOGIN: 'login', // 登录
REGISTER: 'register', // 注册
SHARE: 'share', // 分享
BIND_PHONE: 'bind_phone', // 绑定手机
BIND_WECHAT: 'bind_wechat', // 绑定微信
WITHDRAW: 'withdraw', // 提现
REFERRAL_CLICK: 'referral_click', // 推荐链接点击
REFERRAL_BIND: 'referral_bind', // 推荐绑定
}
/**
* POST - 记录用户行为
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { userId, phone, action, target, extraData } = body
if (!userId && !phone) {
return NextResponse.json({
success: false,
error: '需要用户ID或手机号'
}, { status: 400 })
}
if (!action) {
return NextResponse.json({
success: false,
error: '行为类型不能为空'
}, { status: 400 })
}
// 如果只有手机号查找用户ID
let targetUserId = userId
if (!userId && phone) {
const users = await query('SELECT id FROM users WHERE phone = ?', [phone]) as any[]
if (users.length > 0) {
targetUserId = users[0].id
} else {
return NextResponse.json({
success: false,
error: '用户不存在'
}, { status: 404 })
}
}
// 记录行为轨迹
const trackId = 'track_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 6)
await query(`
INSERT INTO user_tracks (
id, user_id, action, chapter_id, target, extra_data
) VALUES (?, ?, ?, ?, ?, ?)
`, [
trackId,
targetUserId,
action,
action === 'view_chapter' ? target : null,
target || null,
extraData ? JSON.stringify(extraData) : null
])
// 更新用户最后活跃时间
await query('UPDATE users SET updated_at = NOW() WHERE id = ?', [targetUserId])
return NextResponse.json({
success: true,
trackId,
message: '行为记录成功'
})
} catch (error) {
console.error('[User Track] POST Error:', error)
return NextResponse.json({
success: false,
error: '记录行为失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* GET - 获取用户行为轨迹
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
const phone = searchParams.get('phone')
const action = searchParams.get('action')
const limit = parseInt(searchParams.get('limit') || '50')
try {
// 确定查询条件
let targetUserId = userId
if (!userId && phone) {
const users = await query('SELECT id FROM users WHERE phone = ?', [phone]) as any[]
if (users.length > 0) {
targetUserId = users[0].id
} else {
return NextResponse.json({
success: false,
error: '用户不存在'
}, { status: 404 })
}
}
if (!targetUserId) {
return NextResponse.json({
success: false,
error: '需要用户ID或手机号'
}, { status: 400 })
}
// 构建查询
let sql = `
SELECT ut.*
FROM user_tracks ut
WHERE ut.user_id = ?
`
const params: any[] = [targetUserId]
if (action) {
sql += ' AND ut.action = ?'
params.push(action)
}
sql += ' ORDER BY ut.created_at DESC LIMIT ?'
params.push(limit)
const tracks = await query(sql, params) as any[]
// 格式化轨迹数据
const formattedTracks = tracks.map(t => ({
id: t.id,
action: t.action,
actionLabel: getActionLabel(t.action),
target: t.target || t.chapter_id,
chapterTitle: t.chapter_id || null, // 直接使用chapter_id作为标题
extraData: t.extra_data ? JSON.parse(t.extra_data) : null,
createdAt: t.created_at,
timeAgo: getTimeAgo(t.created_at)
}))
// 统计信息
const stats = await query(`
SELECT
action,
COUNT(*) as count
FROM user_tracks
WHERE user_id = ?
GROUP BY action
`, [targetUserId]) as any[]
return NextResponse.json({
success: true,
tracks: formattedTracks,
stats: stats.reduce((acc, s) => ({ ...acc, [s.action]: s.count }), {}),
total: formattedTracks.length
})
} catch (error) {
console.error('[User Track] GET Error:', error)
return NextResponse.json({
success: false,
error: '获取行为轨迹失败: ' + (error as Error).message
}, { status: 500 })
}
}
// 获取行为标签
function getActionLabel(action: string): string {
const labels: Record<string, string> = {
'view_chapter': '查看章节',
'purchase': '购买',
'match': '匹配伙伴',
'login': '登录',
'register': '注册',
'share': '分享',
'bind_phone': '绑定手机',
'bind_wechat': '绑定微信',
'withdraw': '提现',
'referral_click': '点击推荐链接',
'referral_bind': '推荐绑定',
}
return labels[action] || action
}
// 获取相对时间
function getTimeAgo(date: string | Date): string {
const now = new Date()
const then = new Date(date)
const diffMs = now.getTime() - then.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return '刚刚'
if (diffMins < 60) return `${diffMins}分钟前`
if (diffHours < 24) return `${diffHours}小时前`
if (diffDays < 7) return `${diffDays}天前`
if (diffDays < 30) return `${Math.floor(diffDays / 7)}周前`
return `${Math.floor(diffDays / 30)}个月前`
}