更新管理员登录和鉴权逻辑,优化用户体验;重构相关API以支持更安全的身份验证;调整数据库初始化以兼容新字段,确保用户信息安全;修复部分组件样式和功能,提升整体可用性。

This commit is contained in:
乘风
2026-01-31 23:25:14 +08:00
parent c7b125535c
commit bd23273190
22 changed files with 861 additions and 150 deletions

View File

@@ -3,17 +3,43 @@
import type React from "react"
import { useState, useEffect } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { usePathname, useRouter } from "next/navigation"
import { LayoutDashboard, FileText, Users, CreditCard, Settings, LogOut, Wallet, Globe, BookOpen } from "lucide-react"
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const router = useRouter()
const [mounted, setMounted] = useState(false)
const [authChecked, setAuthChecked] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
// 非登录页时校验 Cookie未登录则跳转登录页
useEffect(() => {
if (!mounted || pathname === "/admin/login") return
setAuthChecked(false)
let cancelled = false
fetch("/api/admin", { credentials: "include" })
.then((res) => {
if (cancelled) return
if (res.status === 401) router.replace("/admin/login")
else setAuthChecked(true)
})
.catch(() => {
if (!cancelled) setAuthChecked(true)
})
return () => {
cancelled = true
}
}, [mounted, pathname, router])
const handleLogout = async () => {
await fetch("/api/admin/logout", { method: "POST", credentials: "include" })
router.replace("/admin/login")
}
// 简化菜单:按功能归类,保留核心功能
// PDF需求分账管理、分销管理、订单管理三合一 → 交易中心
const menuItems = [
@@ -25,8 +51,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{ icon: Settings, label: "系统设置", href: "/admin/settings" },
]
// 避免hydration错误等待客户端mount
if (!mounted) {
// 登录页:不渲染侧栏,只渲染子页面
if (pathname === "/admin/login") {
return <div className="min-h-screen bg-[#0a1628]">{children}</div>
}
// 避免 hydration 错误,等待客户端 mount 并完成鉴权
if (!mounted || !authChecked) {
return (
<div className="flex min-h-screen bg-[#0a1628]">
<div className="w-64 bg-[#0f2137] border-r border-gray-700/50" />
@@ -66,12 +97,19 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
})}
</nav>
<div className="p-4 border-t border-gray-700/50">
<div className="p-4 border-t border-gray-700/50 space-y-1">
<button
type="button"
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 text-gray-400 hover:text-white rounded-lg hover:bg-gray-700/50 transition-colors"
>
<LogOut className="w-5 h-5" />
<span className="text-sm">退</span>
</button>
<Link
href="/"
className="flex items-center gap-3 px-4 py-3 text-gray-400 hover:text-white rounded-lg hover:bg-gray-700/50 transition-colors"
>
<LogOut className="w-5 h-5" />
<span className="text-sm"></span>
</Link>
</div>

View File

@@ -5,11 +5,9 @@ import { useRouter } from "next/navigation"
import { Lock, User, ShieldCheck } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useStore } from "@/lib/store"
export default function AdminLoginPage() {
const router = useRouter()
const { adminLogin } = useStore()
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
@@ -18,14 +16,22 @@ export default function AdminLoginPage() {
const handleLogin = async () => {
setError("")
setLoading(true)
await new Promise((resolve) => setTimeout(resolve, 500))
const success = adminLogin(username, password)
if (success) {
router.push("/admin")
} else {
setError("用户名或密码错误")
try {
const res = await fetch("/api/admin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.trim(), password }),
credentials: "include",
})
const data = await res.json()
if (res.ok && data.success) {
router.push("/admin")
return
}
setError(data.error || "用户名或密码错误")
} catch {
setError("网络错误,请重试")
} finally {
setLoading(false)
}
}
@@ -95,12 +101,6 @@ export default function AdminLoginPage() {
</Button>
</div>
<div className="mt-6 pt-6 border-t border-gray-700/50">
<p className="text-gray-500 text-xs text-center">
: <span className="text-gray-300 font-mono">admin</span> /{" "}
<span className="text-gray-300 font-mono">key123456</span>
</p>
</div>
</div>
{/* Footer */}

View File

@@ -6,6 +6,7 @@
import { NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
import { requireAdminResponse } from '@/lib/admin-auth'
// 获取书籍目录
const BOOK_DIR = path.join(process.cwd(), 'book')
@@ -14,6 +15,8 @@ const BOOK_DIR = path.join(process.cwd(), 'book')
* GET - 获取所有章节列表
*/
export async function GET(request: Request) {
const authErr = requireAdminResponse(request)
if (authErr) return authErr
try {
const { searchParams } = new URL(request.url)
const includeContent = searchParams.get('content') === 'true'
@@ -274,6 +277,8 @@ export async function GET(request: Request) {
* POST - 更新章节设置
*/
export async function POST(request: Request) {
const authErr = requireAdminResponse(request)
if (authErr) return authErr
try {
const body = await request.json()
const { action, chapterId, data } = body

View File

@@ -5,11 +5,14 @@ import { NextRequest, NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import { requireAdminResponse } from '@/lib/admin-auth'
const BOOK_DIR = path.join(process.cwd(), 'book')
// GET: 获取所有章节列表
export async function GET(req: NextRequest) {
const authErr = requireAdminResponse(req)
if (authErr) return authErr
try {
const chapters = getAllChapters()
@@ -28,6 +31,8 @@ export async function GET(req: NextRequest) {
// POST: 创建新章节
export async function POST(req: NextRequest) {
const authErr = requireAdminResponse(req)
if (authErr) return authErr
try {
const body = await req.json()
const { title, content, category, tags } = body
@@ -70,6 +75,8 @@ export async function POST(req: NextRequest) {
// PUT: 更新章节
export async function PUT(req: NextRequest) {
const authErr = requireAdminResponse(req)
if (authErr) return authErr
try {
const body = await req.json()
const { id, title, content, category, tags } = body
@@ -97,6 +104,8 @@ export async function PUT(req: NextRequest) {
// DELETE: 删除章节
export async function DELETE(req: NextRequest) {
const authErr = requireAdminResponse(req)
if (authErr) return authErr
try {
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')

View File

@@ -0,0 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAdminCookieName, getAdminCookieOptions } from '@/lib/admin-auth'
export async function POST(_req: NextRequest) {
const res = NextResponse.json({ success: true })
const opts = getAdminCookieOptions()
res.cookies.set(getAdminCookieName(), '', { ...opts, maxAge: 0 })
return res
}

View File

@@ -2,6 +2,7 @@
// 付费模块管理API
import { NextRequest, NextResponse } from 'next/server'
import { requireAdminResponse } from '@/lib/admin-auth'
// 模拟订单数据
let orders = [
@@ -29,6 +30,8 @@ let orders = [
// GET: 获取订单列表
export async function GET(req: NextRequest) {
const authErr = requireAdminResponse(req)
if (authErr) return authErr
const { searchParams } = new URL(req.url)
const status = searchParams.get('status')
const page = parseInt(searchParams.get('page') || '1')
@@ -71,6 +74,8 @@ export async function GET(req: NextRequest) {
// POST: 创建订单(手动)
export async function POST(req: NextRequest) {
const authErr = requireAdminResponse(req)
if (authErr) return authErr
try {
const body = await req.json()
const { userId, userName, amount, note } = body
@@ -110,6 +115,8 @@ export async function POST(req: NextRequest) {
// PUT: 更新订单状态
export async function PUT(req: NextRequest) {
const authErr = requireAdminResponse(req)
if (authErr) return authErr
try {
const body = await req.json()
const { orderId, status, note } = body
@@ -148,6 +155,8 @@ export async function PUT(req: NextRequest) {
// DELETE: 删除订单
export async function DELETE(req: NextRequest) {
const authErr = requireAdminResponse(req)
if (authErr) return authErr
try {
const { searchParams } = new URL(req.url)
const orderId = searchParams.get('id')

View File

@@ -2,6 +2,7 @@
// 分销模块管理API
import { NextRequest, NextResponse } from 'next/server'
import { requireAdminResponse } from '@/lib/admin-auth'
// 模拟分销数据
let referralRecords = [
@@ -52,6 +53,8 @@ let commissionRecords = [
// GET: 获取分销概览或列表
export async function GET(req: NextRequest) {
const authErr = requireAdminResponse(req)
if (authErr) return authErr
const { searchParams } = new URL(req.url)
const type = searchParams.get('type') || 'list'
const page = parseInt(searchParams.get('page') || '1')
@@ -95,6 +98,8 @@ export async function GET(req: NextRequest) {
// POST: 创建分销记录或处理佣金
export async function POST(req: NextRequest) {
const authErr = requireAdminResponse(req)
if (authErr) return authErr
try {
const body = await req.json()
const { action, data } = body
@@ -170,6 +175,8 @@ export async function POST(req: NextRequest) {
// PUT: 更新分销记录
export async function PUT(req: NextRequest) {
const authErr = requireAdminResponse(req)
if (authErr) return authErr
try {
const body = await req.json()
const { referrerId, status, commissionRate, note } = body
@@ -205,6 +212,8 @@ export async function PUT(req: NextRequest) {
// DELETE: 删除分销记录
export async function DELETE(req: NextRequest) {
const authErr = requireAdminResponse(req)
if (authErr) return authErr
try {
const { searchParams } = new URL(req.url)
const referrerId = searchParams.get('id')

View File

@@ -1,25 +1,27 @@
// app/api/admin/route.ts
// 后台管理API入口
// 后台管理API入口:登录与鉴权(账号密码从环境变量读取,默认 admin / admin123
import { NextRequest, NextResponse } from 'next/server'
import {
verifyAdminToken,
getAdminTokenFromRequest,
verifyAdminCredentials,
getAdminCredentials,
createAdminToken,
getAdminCookieName,
getAdminCookieOptions,
} from '@/lib/admin-auth'
// 验证管理员权限
function verifyAdmin(req: NextRequest) {
const token = req.headers.get('Authorization')?.replace('Bearer ', '')
// TODO: 实现真实的token验证
if (!token || token !== 'admin-token-secret') {
return false
}
return true
function requireAdmin(req: NextRequest): boolean {
const token = getAdminTokenFromRequest(req)
return verifyAdminToken(token)
}
// GET: 获取后台概览数据
// GET: 获取后台概览数据(需已登录)
export async function GET(req: NextRequest) {
if (!verifyAdmin(req)) {
if (!requireAdmin(req)) {
return NextResponse.json(
{ error: '未授权访问' },
{ error: '未授权访问,请先登录' },
{ status: 401 }
)
}
@@ -58,27 +60,31 @@ export async function GET(req: NextRequest) {
return NextResponse.json(overview)
}
// POST: 管理员登录
// POST: 管理员登录(账号密码从环境变量 ADMIN_USERNAME / ADMIN_PASSWORD 读取,默认 admin / admin123
export async function POST(req: NextRequest) {
const body = await req.json()
const { username, password } = body
// TODO: 实现真实的登录验证
if (username === 'admin' && password === 'admin123') {
return NextResponse.json({
success: true,
token: 'admin-token-secret',
user: {
id: 'admin',
username: 'admin',
role: 'admin',
name: '卡若'
}
})
if (!username || !password) {
return NextResponse.json(
{ error: '请输入用户名和密码' },
{ status: 400 }
)
}
return NextResponse.json(
{ error: '用户名或密码错误' },
{ status: 401 }
)
if (!verifyAdminCredentials(String(username).trim(), String(password))) {
return NextResponse.json(
{ error: '用户名或密码错误' },
{ status: 401 }
)
}
const token = createAdminToken()
const res = NextResponse.json({
success: true,
user: { id: 'admin', username: getAdminCredentials().username, role: 'admin', name: '卡若' },
})
const opts = getAdminCookieOptions()
res.cookies.set(getAdminCookieName(), token, opts)
return res
}

View File

@@ -6,9 +6,12 @@
import { NextResponse } from 'next/server'
import { query } from '@/lib/db'
import { createTransfer } from '@/lib/wechat-transfer'
import { requireAdminResponse } from '@/lib/admin-auth'
// 获取所有提现记录
export async function GET(request: Request) {
const authErr = requireAdminResponse(request)
if (authErr) return authErr
try {
const { searchParams } = new URL(request.url)
const status = searchParams.get('status') // pending, success, failed, all
@@ -84,6 +87,8 @@ export async function GET(request: Request) {
// 处理提现(审批/拒绝)
export async function PUT(request: Request) {
const authErr = requireAdminResponse(request)
if (authErr) return authErr
try {
const body = await request.json()
const { id, action, reason } = body // action: approve, reject

View File

@@ -0,0 +1,73 @@
/**
* Web 端登录:手机号 + 密码
* POST { phone, password } -> 校验后返回用户信息(不含密码)
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
import { verifyPassword } from '@/lib/password'
function mapRowToUser(r: any) {
return {
id: r.id,
phone: r.phone || '',
nickname: r.nickname || '',
isAdmin: !!r.is_admin,
purchasedSections: Array.isArray(r.purchased_sections)
? r.purchased_sections
: (r.purchased_sections ? JSON.parse(String(r.purchased_sections)) : []) || [],
hasFullBook: !!r.has_full_book,
referralCode: r.referral_code || '',
referredBy: r.referred_by || undefined,
earnings: parseFloat(String(r.earnings || 0)),
pendingEarnings: parseFloat(String(r.pending_earnings || 0)),
withdrawnEarnings: parseFloat(String(r.withdrawn_earnings || 0)),
referralCount: Number(r.referral_count) || 0,
createdAt: r.created_at || '',
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { phone, password } = body
if (!phone || !password) {
return NextResponse.json(
{ success: false, error: '请输入手机号和密码' },
{ status: 400 }
)
}
const rows = await query(
'SELECT id, phone, nickname, password, is_admin, has_full_book, referral_code, referred_by, earnings, pending_earnings, withdrawn_earnings, referral_count, purchased_sections, created_at FROM users WHERE phone = ?',
[String(phone).trim()]
) as any[]
if (!rows || rows.length === 0) {
return NextResponse.json(
{ success: false, error: '用户不存在或密码错误' },
{ status: 401 }
)
}
const row = rows[0]
const storedPassword = row.password == null ? '' : String(row.password)
if (!verifyPassword(String(password), storedPassword)) {
return NextResponse.json(
{ success: false, error: '密码错误' },
{ status: 401 }
)
}
const user = mapRowToUser(row)
return NextResponse.json({ success: true, user })
} catch (e) {
console.error('[Auth Login] error:', e)
return NextResponse.json(
{ success: false, error: '登录失败' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,54 @@
/**
* 忘记密码 / 重置密码Web 端)
* POST { phone, newPassword } -> 按手机号更新密码(无验证码版本,适合内测/内部使用)
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
import { hashPassword } from '@/lib/password'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { phone, newPassword } = body
if (!phone || !newPassword) {
return NextResponse.json(
{ success: false, error: '请输入手机号和新密码' },
{ status: 400 }
)
}
const trimmedPhone = String(phone).trim()
const trimmedPassword = String(newPassword).trim()
if (trimmedPassword.length < 6) {
return NextResponse.json(
{ success: false, error: '密码至少 6 位' },
{ status: 400 }
)
}
const rows = await query('SELECT id FROM users WHERE phone = ?', [trimmedPhone]) as any[]
if (!rows || rows.length === 0) {
return NextResponse.json(
{ success: false, error: '该手机号未注册' },
{ status: 404 }
)
}
const hashed = hashPassword(trimmedPassword)
await query('UPDATE users SET password = ?, updated_at = NOW() WHERE phone = ?', [
hashed,
trimmedPhone,
])
return NextResponse.json({ success: true, message: '密码已重置,请使用新密码登录' })
} catch (e) {
console.error('[Auth ResetPassword] error:', e)
return NextResponse.json(
{ success: false, error: '重置失败' },
{ status: 500 }
)
}
}

View File

@@ -5,6 +5,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
import { hashPassword } from '@/lib/password'
// 生成用户ID
function generateUserId(): string {
@@ -32,29 +33,35 @@ export async function GET(request: NextRequest) {
const openId = searchParams.get('openId')
try {
// 获取单个用户
const omitPassword = (u: any) => {
if (!u) return u
const { password: _, ...rest } = u
return rest
}
// 获取单个用户(不返回 password
if (id) {
const users = await query('SELECT * FROM users WHERE id = ?', [id]) as any[]
if (users.length > 0) {
return NextResponse.json({ success: true, user: users[0] })
return NextResponse.json({ success: true, user: omitPassword(users[0]) })
}
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
// 通过手机号查询
if (phone) {
const users = await query('SELECT * FROM users WHERE phone = ?', [phone]) as any[]
if (users.length > 0) {
return NextResponse.json({ success: true, user: users[0] })
return NextResponse.json({ success: true, user: omitPassword(users[0]) })
}
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
// 通过openId查询
if (openId) {
const users = await query('SELECT * FROM users WHERE open_id = ?', [openId]) as any[]
if (users.length > 0) {
return NextResponse.json({ success: true, user: users[0] })
return NextResponse.json({ success: true, user: omitPassword(users[0]) })
}
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
@@ -95,13 +102,18 @@ export async function POST(request: NextRequest) {
const body = await request.json()
const { openId, phone, nickname, password, wechatId, avatar, referredBy, is_admin } = body
// 密码确保非空字符串才存储bcrypt 哈希)
const rawPassword = typeof password === 'string' ? password.trim() : ''
const passwordToStore = rawPassword.length >= 6 ? hashPassword(rawPassword) : null
// 检查openId或手机号是否已存在
if (openId) {
const existing = await query('SELECT id FROM users WHERE open_id = ?', [openId]) as any[]
if (existing.length > 0) {
// 已存在,返回现有用户
const users = await query('SELECT * FROM users WHERE open_id = ?', [openId]) as any[]
return NextResponse.json({ success: true, user: users[0], isNew: false })
const u = users[0]
const { password: _p2, ...userSafe } = u || {}
return NextResponse.json({ success: true, user: userSafe, isNew: false })
}
}
@@ -115,7 +127,7 @@ export async function POST(request: NextRequest) {
// 生成用户ID和推荐码
const userId = generateUserId()
const referralCode = generateReferralCode(openId || phone || userId)
// 创建用户
await query(`
INSERT INTO users (
@@ -128,7 +140,7 @@ export async function POST(request: NextRequest) {
openId || null,
phone || null,
nickname || '用户' + userId.slice(-4),
password || null,
passwordToStore,
wechatId || null,
avatar || null,
referralCode,
@@ -136,12 +148,13 @@ export async function POST(request: NextRequest) {
is_admin || false
])
// 返回新用户
// 返回新用户(不返回 password
const users = await query('SELECT * FROM users WHERE id = ?', [userId]) as any[]
const u = users[0]
const { password: _p, ...userSafe } = u || {}
return NextResponse.json({
success: true,
user: users[0],
user: userSafe,
isNew: true,
message: '用户创建成功'
})
@@ -189,7 +202,7 @@ export async function PUT(request: NextRequest) {
}
if (password !== undefined) {
updates.push('password = ?')
values.push(password)
values.push(password === '' || password == null ? null : hashPassword(String(password).trim()))
}
if (has_full_book !== undefined) {
updates.push('has_full_book = ?')

136
app/login/forgot/page.tsx Normal file
View File

@@ -0,0 +1,136 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ChevronLeft, Phone, Hash } from "lucide-react"
export default function ForgotPasswordPage() {
const router = useRouter()
const [phone, setPhone] = useState("")
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setLoading(true)
try {
if (!phone.trim()) {
setError("请输入手机号")
return
}
if (!newPassword.trim()) {
setError("请输入新密码")
return
}
if (newPassword.trim().length < 6) {
setError("密码至少 6 位")
return
}
if (newPassword !== confirmPassword) {
setError("两次输入的密码不一致")
return
}
const res = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: phone.trim(), newPassword: newPassword.trim() }),
})
const data = await res.json()
if (data.success) {
setSuccess(true)
setTimeout(() => router.push("/login"), 2000)
} else {
setError(data.error || "重置失败")
}
} finally {
setLoading(false)
}
}
if (success) {
return (
<div className="min-h-screen bg-black text-white flex flex-col items-center justify-center px-6">
<p className="text-[#30d158] text-lg mb-4"></p>
<p className="text-gray-500 text-sm">使...</p>
</div>
)
}
return (
<div className="min-h-screen bg-black text-white flex flex-col">
<header className="flex items-center px-4 py-3">
<Link
href="/login"
className="w-9 h-9 rounded-full bg-[#1c1c1e] flex items-center justify-center"
>
<ChevronLeft className="w-5 h-5 text-gray-400" />
</Link>
<h1 className="flex-1 text-center text-lg font-semibold"></h1>
<div className="w-9" />
</header>
<main className="flex-1 px-6 pt-8">
<p className="text-gray-500 text-sm mb-6">
使使
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="relative">
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="手机号"
className="w-full pl-12 pr-4 py-3.5 bg-[#1c1c1e] rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#30d158]/50"
/>
</div>
<div className="relative">
<Hash className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="新密码(至少 6 位)"
className="w-full pl-12 pr-4 py-3.5 bg-[#1c1c1e] rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#30d158]/50"
/>
</div>
<div className="relative">
<Hash className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="再次输入新密码"
className="w-full pl-12 pr-4 py-3.5 bg-[#1c1c1e] rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#30d158]/50"
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<button
type="submit"
disabled={loading || !phone || !newPassword || !confirmPassword}
className="w-full py-3.5 bg-[#30d158] text-white font-medium rounded-xl active:scale-[0.98] transition-transform disabled:opacity-50"
>
{loading ? "提交中..." : "重置密码"}
</button>
</form>
<p className="text-gray-500 text-xs mt-6 text-center">
使
</p>
</main>
</div>
)
}

View File

@@ -2,12 +2,13 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { useStore } from "@/lib/store"
import { ChevronLeft, Phone, User, Hash } from "lucide-react"
export default function LoginPage() {
const router = useRouter()
const { login, register, adminLogin } = useStore()
const { login, register } = useStore()
const [mode, setMode] = useState<"login" | "register">("login")
const [phone, setPhone] = useState("")
const [code, setCode] = useState("")
@@ -21,30 +22,52 @@ export default function LoginPage() {
setLoading(true)
try {
// 管理员登录
// 管理员登录(使用 code 作为密码,调用后台 API 并写 Cookie
if (phone.toLowerCase() === "admin") {
if (adminLogin(phone, code)) {
router.push("/admin")
return
} else {
setError("管理员密码错误")
return
try {
const res = await fetch("/api/admin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: phone, password: code }),
credentials: "include",
})
const data = await res.json()
if (res.ok && data.success) {
router.push("/admin")
return
}
} catch {
// fallthrough to error
}
setError("管理员密码错误")
return
}
if (mode === "login") {
if (!code.trim()) {
setError("请输入密码")
return
}
const success = await login(phone, code)
if (success) {
router.push("/")
} else {
setError("验证码错误或用户不存在")
setError("码错误或用户不存在")
}
} else {
if (!nickname.trim()) {
setError("请输入昵称")
return
}
const success = await register(phone, nickname, referralCode || undefined)
if (!code.trim()) {
setError("请设置密码(至少 6 位)")
return
}
if (code.trim().length < 6) {
setError("密码至少 6 位")
return
}
const success = await register(phone, nickname, code, referralCode || undefined)
if (success) {
router.push("/")
} else {
@@ -102,14 +125,14 @@ export default function LoginPage() {
</div>
)}
{/* 验证码/密码 */}
{/* 密码 */}
<div className="relative">
<Hash className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
type="text"
type="password"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={mode === "login" ? "验证码测试123456" : "设置密码"}
placeholder={mode === "login" ? "密码" : "设置密码(至少 6 位)"}
className="w-full pl-12 pr-4 py-3.5 bg-[#1c1c1e] rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#30d158]/50"
/>
</div>
@@ -140,9 +163,16 @@ export default function LoginPage() {
{loading ? "处理中..." : mode === "login" ? "登录" : "注册"}
</button>
{/* 切换模式 */}
<div className="text-center">
<button onClick={() => setMode(mode === "login" ? "register" : "login")} className="text-[#30d158] text-sm">
{/* 忘记密码 / 切换模式 */}
<div className="text-center space-y-2">
{mode === "login" && (
<div>
<Link href="/login/forgot" className="text-[#30d158] text-sm">
</Link>
</div>
)}
<button onClick={() => setMode(mode === "login" ? "register" : "login")} className="text-[#30d158] text-sm block mx-auto">
{mode === "login" ? "没有账号?去注册" : "已有账号?去登录"}
</button>
</div>

View File

@@ -1,6 +1,7 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { X, Phone, Lock, User, Gift } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -60,7 +61,7 @@ export function AuthModal({ isOpen, onClose, defaultTab = "login" }: AuthModalPr
setIsLoading(true)
// 昵称可选,默认使用手机号后四位
const name = nickname.trim() || `用户${phone.slice(-4)}`
const success = await register(phone, name, referralCode || undefined)
const success = await register(phone, name, password, referralCode || undefined)
setIsLoading(false)
if (success) {
@@ -146,6 +147,16 @@ export function AuthModal({ isOpen, onClose, defaultTab = "login" }: AuthModalPr
</div>
</div>
<div className="text-right">
<Link
href="/login/forgot"
onClick={onClose}
className="text-sm text-[#00CED1] hover:underline"
>
</Link>
</div>
{error && <p className="text-[#00CED1] text-sm">{error}</p>}
<Button

91
lib/admin-auth.ts Normal file
View File

@@ -0,0 +1,91 @@
/**
* 后台管理员登录鉴权:生成/校验签名 Cookie不暴露账号密码
* 账号密码从环境变量读取,默认 admin / admin123
*/
import { createHmac, timingSafeEqual } from 'crypto'
const COOKIE_NAME = 'admin_session'
const MAX_AGE_SEC = 7 * 24 * 3600 // 7 天
const SECRET = process.env.ADMIN_SESSION_SECRET || 'soul-admin-secret-change-in-prod'
export function getAdminCredentials() {
return {
username: process.env.ADMIN_USERNAME || 'admin',
password: process.env.ADMIN_PASSWORD || 'admin123',
}
}
export function verifyAdminCredentials(username: string, password: string): boolean {
const { username: u, password: p } = getAdminCredentials()
return username === u && password === p
}
function sign(payload: string): string {
return createHmac('sha256', SECRET).update(payload).digest('base64url')
}
/** 生成签名 token写入 Cookie 用 */
export function createAdminToken(): string {
const exp = Math.floor(Date.now() / 1000) + MAX_AGE_SEC
const payload = `${exp}`
const sig = sign(payload)
return `${payload}.${sig}`
}
/** 校验 Cookie 中的 token */
export function verifyAdminToken(token: string | null | undefined): boolean {
if (!token || typeof token !== 'string') return false
const dot = token.indexOf('.')
if (dot === -1) return false
const payload = token.slice(0, dot)
const sig = token.slice(dot + 1)
const exp = parseInt(payload, 10)
if (Number.isNaN(exp) || exp < Math.floor(Date.now() / 1000)) return false
const expected = sign(payload)
if (sig.length !== expected.length) return false
try {
return timingSafeEqual(Buffer.from(sig, 'base64url'), Buffer.from(expected, 'base64url'))
} catch {
return false
}
}
export function getAdminCookieName() {
return COOKIE_NAME
}
export function getAdminCookieOptions() {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
maxAge: MAX_AGE_SEC,
path: '/',
}
}
/** 从请求中读取 admin cookie 并校验,未通过时返回 null */
export function getAdminTokenFromRequest(request: Request): string | null {
const cookieHeader = request.headers.get('cookie')
if (!cookieHeader) return null
const name = COOKIE_NAME + '='
const start = cookieHeader.indexOf(name)
if (start === -1) return null
const valueStart = start + name.length
const end = cookieHeader.indexOf(';', valueStart)
const value = end === -1 ? cookieHeader.slice(valueStart) : cookieHeader.slice(valueStart, end)
return value.trim() || null
}
/** 若未登录则返回 401 Response供各 admin API 使用 */
export function requireAdminResponse(request: Request): Response | null {
const token = getAdminTokenFromRequest(request)
if (!verifyAdminToken(token)) {
return new Response(JSON.stringify({ error: '未授权访问,请先登录' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
return null
}

View File

@@ -90,27 +90,25 @@ export async function initDatabase() {
`)
// 尝试添加可能缺失的字段(用于升级已有数据库)
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS session_key VARCHAR(100)')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password VARCHAR(100)')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS referred_by VARCHAR(50)')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS match_count_today INT DEFAULT 0')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_match_date DATE')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS withdrawn_earnings DECIMAL(10,2) DEFAULT 0')
} catch (e) { /* 忽略 */ }
// 兼容 MySQL 5.7IF NOT EXISTS 在 5.7 不支持,先检查列是否存在
const addColumnIfMissing = async (colName: string, colDef: string) => {
try {
const rows = await query(
"SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = ?",
[colName]
) as any[]
if (!rows?.length) {
await query(`ALTER TABLE users ADD COLUMN ${colName} ${colDef}`)
}
} catch (e) { /* 忽略 */ }
}
await addColumnIfMissing('session_key', 'VARCHAR(100)')
await addColumnIfMissing('password', 'VARCHAR(100)')
await addColumnIfMissing('referred_by', 'VARCHAR(50)')
await addColumnIfMissing('is_admin', 'BOOLEAN DEFAULT FALSE')
await addColumnIfMissing('match_count_today', 'INT DEFAULT 0')
await addColumnIfMissing('last_match_date', 'DATE')
await addColumnIfMissing('withdrawn_earnings', 'DECIMAL(10,2) DEFAULT 0')
console.log('用户表初始化完成')

56
lib/password.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* 密码哈希与校验(仅用于 Web 用户注册/登录,与后台管理员密码无关)
* 使用 Node crypto.scrypt存储格式 saltHex:hashHex兼容旧明文密码
*/
import { scryptSync, timingSafeEqual, randomFillSync } from 'crypto'
const SALT_LEN = 16
const KEYLEN = 32
function bufferToHex(buf: Buffer): string {
return buf.toString('hex')
}
function hexToBuffer(hex: string): Buffer {
return Buffer.from(hex, 'hex')
}
/**
* 对明文密码做哈希,存入数据库
* 格式: saltHex:hashHex约 97 字符,适配 VARCHAR(100)
* 与 verifyPassword 一致:内部先 trim保证注册/登录/重置用同一套规则
*/
export function hashPassword(plain: string): string {
const trimmed = String(plain).trim()
const salt = Buffer.allocUnsafe(SALT_LEN)
randomFillSync(salt)
const hash = scryptSync(trimmed, salt, KEYLEN, { N: 16384, r: 8, p: 1 })
return bufferToHex(salt) + ':' + bufferToHex(hash)
}
/**
* 校验密码支持新格式salt:hash与旧明文兼容历史数据
* 与 hashPassword 一致:对输入先 trim 再参与校验
*/
export function verifyPassword(plain: string, stored: string | null | undefined): boolean {
const trimmed = String(plain).trim()
if (stored == null || stored === '') {
return trimmed === ''
}
if (stored.includes(':')) {
const [saltHex, hashHex] = stored.split(':')
if (!saltHex || !hashHex || saltHex.length !== SALT_LEN * 2 || hashHex.length !== KEYLEN * 2) {
return false
}
try {
const salt = hexToBuffer(saltHex)
const expected = hexToBuffer(hashHex)
const derived = scryptSync(trimmed, salt, KEYLEN, { N: 16384, r: 8, p: 1 })
return derived.length === expected.length && timingSafeEqual(derived, expected)
} catch {
return false
}
}
return trimmed === stored
}

View File

@@ -172,9 +172,9 @@ interface StoreState {
withdrawals: Withdrawal[]
settings: Settings
login: (phone: string, code: string) => Promise<boolean>
login: (phone: string, password: string) => Promise<boolean>
logout: () => void
register: (phone: string, nickname: string, referralCode?: string) => Promise<boolean>
register: (phone: string, nickname: string, password?: string, referralCode?: string) => Promise<boolean>
purchaseSection: (sectionId: string, sectionTitle?: string, paymentMethod?: string) => Promise<boolean>
purchaseFullBook: (paymentMethod?: string) => Promise<boolean>
hasPurchased: (sectionId: string) => boolean
@@ -302,59 +302,63 @@ export const useStore = create<StoreState>()(
withdrawals: [],
settings: initialSettings,
login: async (phone: string, code: string) => {
// 真实场景下应该调用后端API验证验证码
// 这里暂时保留简单验证用于演示
if (code !== "123456") {
login: async (phone: string, password: string) => {
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone: phone.trim(), password: password.trim() }),
})
const data = await res.json()
if (data.success && data.user) {
set({ user: data.user, isLoggedIn: true })
return true
}
return false
} catch {
return false
}
const users = JSON.parse(localStorage.getItem("users") || "[]") as User[]
const existingUser = users.find((u) => u.phone === phone)
if (existingUser) {
set({ user: existingUser, isLoggedIn: true })
return true
}
return false
},
logout: () => {
set({ user: null, isLoggedIn: false })
},
register: async (phone: string, nickname: string, referralCode?: string) => {
const users = JSON.parse(localStorage.getItem("users") || "[]") as User[]
if (users.find((u) => u.phone === phone)) {
register: async (phone: string, nickname: string, password?: string, referralCode?: string) => {
try {
const res = await fetch("/api/db/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
phone: phone.trim(),
nickname: nickname.trim(),
password: password ? String(password).trim() : null,
referredBy: referralCode?.trim() || null,
}),
})
const data = await res.json()
if (!data.success || !data.user) return false
const r = data.user as any
const newUser: User = {
id: r.id,
phone: r.phone || phone,
nickname: r.nickname || nickname,
isAdmin: !!r.is_admin,
purchasedSections: Array.isArray(r.purchased_sections) ? r.purchased_sections : (r.purchased_sections ? JSON.parse(String(r.purchased_sections)) : []) || [],
hasFullBook: !!r.has_full_book,
referralCode: r.referral_code || "",
referredBy: r.referred_by || referralCode,
earnings: parseFloat(String(r.earnings || 0)),
pendingEarnings: parseFloat(String(r.pending_earnings || 0)),
withdrawnEarnings: parseFloat(String(r.withdrawn_earnings || 0)),
referralCount: Number(r.referral_count) || 0,
createdAt: r.created_at || new Date().toISOString(),
}
set({ user: newUser, isLoggedIn: true })
return true
} catch {
return false
}
const newUser: User = {
id: `user_${Date.now()}`,
phone,
nickname,
isAdmin: false,
purchasedSections: [],
hasFullBook: false,
referralCode: `REF${Date.now().toString(36).toUpperCase()}`,
referredBy: referralCode,
earnings: 0,
pendingEarnings: 0,
withdrawnEarnings: 0,
referralCount: 0,
createdAt: new Date().toISOString(),
}
if (referralCode) {
const referrer = users.find((u) => u.referralCode === referralCode)
if (referrer) {
referrer.referralCount = (referrer.referralCount || 0) + 1
localStorage.setItem("users", JSON.stringify(users))
}
}
users.push(newUser)
localStorage.setItem("users", JSON.stringify(users))
set({ user: newUser, isLoggedIn: true })
return true
},
purchaseSection: async (sectionId: string, sectionTitle?: string, paymentMethod?: string) => {

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,155 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
宝塔面板 API 模块 - Node 项目重启 / 计划任务触发
被 devlop.py 内部调用;也可单独使用:
python scripts/deploy_baota_pure_api.py # 重启 Node 项目
python scripts/deploy_baota_pure_api.py --create-dir # 并创建项目目录
python scripts/deploy_baota_pure_api.py --task-id 1 # 触发计划任务 ID=1
环境变量:
BAOTA_PANEL_URL # 宝塔面板地址,如 https://42.194.232.22:9988 或带安全入口
BAOTA_API_KEY # 宝塔 API 密钥(面板 → 设置 → API 接口)
DEPLOY_PM2_APP # PM2 项目名称,默认 soul
"""
from __future__ import print_function
import os
import sys
import time
import hashlib
try:
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except ImportError:
print("请先安装: pip install requests")
sys.exit(1)
# 配置:可通过环境变量覆盖
CFG = {
"panel_url": os.environ.get("BAOTA_PANEL_URL", "https://42.194.232.22:9988"),
"api_key": os.environ.get("BAOTA_API_KEY", "hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd"),
"pm2_name": os.environ.get("DEPLOY_PM2_APP", "soul"),
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", "/www/wwwroot/soul"),
"site_url": os.environ.get("DEPLOY_SITE_URL", "https://soul.quwanzhi.com"),
}
def _get_sign(api_key):
"""宝塔鉴权签名request_token = md5(request_time + md5(api_key))"""
now_time = int(time.time())
sign_str = str(now_time) + hashlib.md5(api_key.encode("utf-8")).hexdigest()
request_token = hashlib.md5(sign_str.encode("utf-8")).hexdigest()
return now_time, request_token
def _request(base_url, path, data=None, timeout=30):
"""发起宝塔 API 请求"""
url = base_url.rstrip("/") + "/" + path.lstrip("/")
api_key = CFG["api_key"]
if not api_key:
print("请设置 BAOTA_API_KEY宝塔面板 → 设置 → API 接口)")
return None
req_time, req_token = _get_sign(api_key)
payload = {
"request_time": req_time,
"request_token": req_token,
}
if data:
payload.update(data)
try:
r = requests.post(
url,
data=payload,
verify=False,
timeout=timeout,
)
return r.json() if r.text else None
except Exception as e:
print("请求失败:", e)
return None
def restart_node_project(panel_url, api_key, pm2_name):
"""
通过宝塔 API 重启 Node 项目
返回 True 表示成功False 表示失败
"""
# Node 项目管理为插件接口,路径可能因版本不同
paths_to_try = [
"/plugin?action=a&name=nodejs&s=restart_project",
"/project/nodejs/restart_project",
]
payload = {"project_name": pm2_name}
req_time, req_token = _get_sign(api_key)
payload["request_time"] = req_time
payload["request_token"] = req_token
url_base = panel_url.rstrip("/")
for path in paths_to_try:
url = url_base + path
try:
r = requests.post(url, data=payload, verify=False, timeout=30)
j = r.json() if r.text else {}
if j.get("status") is True or j.get("msg") or r.status_code == 200:
print(" 重启成功: %s" % pm2_name)
return True
# 某些版本返回不同结构
if "msg" in j:
print(" API 返回:", j.get("msg", j))
except Exception as e:
print(" 尝试 %s 失败: %s" % (path, e))
print(" 重启失败,请检查宝塔 Node 插件是否安装、API 密钥是否正确")
return False
def create_project_dir():
"""通过宝塔文件接口创建项目目录"""
path = "/files?action=CreateDir"
data = {"path": CFG["project_path"]}
j = _request(CFG["panel_url"], path, data)
if j and j.get("status") is True:
print(" 目录已创建: %s" % CFG["project_path"])
return True
print(" 创建目录失败:", j)
return False
def trigger_crontab_task(task_id):
"""触发计划任务"""
path = "/crontab?action=StartTask"
data = {"id": str(task_id)}
j = _request(CFG["panel_url"], path, data)
if j and j.get("status") is True:
print(" 计划任务 %s 已触发" % task_id)
return True
print(" 触发失败:", j)
return False
def main():
import argparse
parser = argparse.ArgumentParser(description="宝塔 API - 重启 Node / 触发计划任务")
parser.add_argument("--create-dir", action="store_true", help="创建项目目录")
parser.add_argument("--task-id", type=int, default=0, help="触发计划任务 ID")
args = parser.parse_args()
if args.create_dir:
create_project_dir()
if args.task_id:
ok = trigger_crontab_task(args.task_id)
sys.exit(0 if ok else 1)
ok = restart_node_project(
CFG["panel_url"],
CFG["api_key"],
CFG["pm2_name"],
)
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()