功能迭代:用户管理与存客宝同步、管理后台与小程序优化、开发文档更新
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
525
app/api/ckb/sync/route.ts
Normal 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
219
app/api/db/migrate/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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
221
app/api/user/track/route.ts
Normal 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)}个月前`
|
||||
}
|
||||
Reference in New Issue
Block a user