更新管理员登录和鉴权逻辑,优化用户体验;重构相关API以支持更安全的身份验证;调整数据库初始化以兼容新字段,确保用户信息安全;修复部分组件样式和功能,提升整体可用性。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
9
app/api/admin/logout/route.ts
Normal file
9
app/api/admin/logout/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getAdminCookieName, getAdminCookieOptions } from '@/lib/admin-auth'
|
||||
|
||||
export async function POST(_req: NextRequest) {
|
||||
const res = NextResponse.json({ success: true })
|
||||
const opts = getAdminCookieOptions()
|
||||
res.cookies.set(getAdminCookieName(), '', { ...opts, maxAge: 0 })
|
||||
return res
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
73
app/api/auth/login/route.ts
Normal file
73
app/api/auth/login/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
54
app/api/auth/reset-password/route.ts
Normal file
54
app/api/auth/reset-password/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 忘记密码 / 重置密码(Web 端)
|
||||
* POST { phone, newPassword } -> 按手机号更新密码(无验证码版本,适合内测/内部使用)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { query } from '@/lib/db'
|
||||
import { hashPassword } from '@/lib/password'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { phone, newPassword } = body
|
||||
|
||||
if (!phone || !newPassword) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '请输入手机号和新密码' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const trimmedPhone = String(phone).trim()
|
||||
const trimmedPassword = String(newPassword).trim()
|
||||
|
||||
if (trimmedPassword.length < 6) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '密码至少 6 位' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const rows = await query('SELECT id FROM users WHERE phone = ?', [trimmedPhone]) as any[]
|
||||
if (!rows || rows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '该手机号未注册' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const hashed = hashPassword(trimmedPassword)
|
||||
await query('UPDATE users SET password = ?, updated_at = NOW() WHERE phone = ?', [
|
||||
hashed,
|
||||
trimmedPhone,
|
||||
])
|
||||
|
||||
return NextResponse.json({ success: true, message: '密码已重置,请使用新密码登录' })
|
||||
} catch (e) {
|
||||
console.error('[Auth ResetPassword] error:', e)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '重置失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
136
app/login/forgot/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
91
lib/admin-auth.ts
Normal 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
|
||||
}
|
||||
40
lib/db.ts
40
lib/db.ts
@@ -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.7:IF 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
56
lib/password.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 密码哈希与校验(仅用于 Web 用户注册/登录,与后台管理员密码无关)
|
||||
* 使用 Node crypto.scrypt,存储格式 saltHex:hashHex,兼容旧明文密码
|
||||
*/
|
||||
|
||||
import { scryptSync, timingSafeEqual, randomFillSync } from 'crypto'
|
||||
|
||||
const SALT_LEN = 16
|
||||
const KEYLEN = 32
|
||||
|
||||
function bufferToHex(buf: Buffer): string {
|
||||
return buf.toString('hex')
|
||||
}
|
||||
|
||||
function hexToBuffer(hex: string): Buffer {
|
||||
return Buffer.from(hex, 'hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* 对明文密码做哈希,存入数据库
|
||||
* 格式: saltHex:hashHex(约 97 字符,适配 VARCHAR(100))
|
||||
* 与 verifyPassword 一致:内部先 trim,保证注册/登录/重置用同一套规则
|
||||
*/
|
||||
export function hashPassword(plain: string): string {
|
||||
const trimmed = String(plain).trim()
|
||||
const salt = Buffer.allocUnsafe(SALT_LEN)
|
||||
randomFillSync(salt)
|
||||
const hash = scryptSync(trimmed, salt, KEYLEN, { N: 16384, r: 8, p: 1 })
|
||||
return bufferToHex(salt) + ':' + bufferToHex(hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验密码:支持新格式(salt:hash)与旧明文(兼容历史数据)
|
||||
* 与 hashPassword 一致:对输入先 trim 再参与校验
|
||||
*/
|
||||
export function verifyPassword(plain: string, stored: string | null | undefined): boolean {
|
||||
const trimmed = String(plain).trim()
|
||||
if (stored == null || stored === '') {
|
||||
return trimmed === ''
|
||||
}
|
||||
if (stored.includes(':')) {
|
||||
const [saltHex, hashHex] = stored.split(':')
|
||||
if (!saltHex || !hashHex || saltHex.length !== SALT_LEN * 2 || hashHex.length !== KEYLEN * 2) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const salt = hexToBuffer(saltHex)
|
||||
const expected = hexToBuffer(hashHex)
|
||||
const derived = scryptSync(trimmed, salt, KEYLEN, { N: 16384, r: 8, p: 1 })
|
||||
return derived.length === expected.length && timingSafeEqual(derived, expected)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return trimmed === stored
|
||||
}
|
||||
94
lib/store.ts
94
lib/store.ts
@@ -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
2
next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
Binary file not shown.
155
scripts/deploy_baota_pure_api.py
Normal file
155
scripts/deploy_baota_pure_api.py
Normal 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()
|
||||
Reference in New Issue
Block a user