登录&支付调试优化

This commit is contained in:
Mosijiu 2025-12-24 14:41:11 +08:00
parent 8acfeec678
commit c06c4f2299
11 changed files with 581 additions and 183 deletions

View File

@ -1,105 +1,7 @@
import NextAuth, { AuthOptions } from "next-auth" import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials" import { authOptions } from "@/lib/authOptions"
import type { User } from "next-auth"
import bcrypt from 'bcryptjs'
import { db } from '@/lib/db'
// 认证配置
const authOptions: AuthOptions = {
providers: [
// 邮箱密码登录
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: "邮箱", type: "email", placeholder: "user@example.com" },
password: { label: "密码", type: "password" }
},
async authorize(credentials): Promise<User | null> {
try {
if (!credentials?.email || !credentials?.password) {
console.log('❌ 登录失败: 缺少邮箱或密码')
return null
}
// 从数据库查询用户
const user = await db.findUserByEmail(credentials.email)
if (!user) {
console.log('❌ 登录失败: 用户不存在 -', credentials.email)
return null
}
// 使用 bcrypt 验证密码
const isValidPassword = await bcrypt.compare(
credentials.password,
user.password
)
if (!isValidPassword) {
console.log('❌ 登录失败: 密码错误 -', credentials.email)
return null
}
console.log('✅ 登录成功:', {
id: user.id,
name: user.name,
email: user.email,
})
// 返回用户信息(不包含密码)
return {
id: user.id.toString(),
name: user.name,
email: user.email,
}
} catch (error) {
console.error('登录错误:', error)
return null
}
}
}),
],
// 自定义页面
pages: {
signIn: '/auth/signin',
// error: '/auth/error',
// signOut: '/auth/signout',
},
// 回调函数
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.email = user.email
token.name = user.name
}
return token
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string
session.user.email = token.email as string
session.user.name = token.name as string
}
return session
},
},
// 会话策略
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 天
},
// 密钥
secret: process.env.NEXTAUTH_SECRET,
// 调试模式(生产环境设为false)
debug: process.env.NODE_ENV === 'development',
}
const handler = NextAuth(authOptions) const handler = NextAuth(authOptions)
export { handler as GET, handler as POST } export { handler as GET, handler as POST }

View File

@ -2,31 +2,44 @@ import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db' import { db } from '@/lib/db'
import crypto from 'crypto' import crypto from 'crypto'
// 验证易支付回调签名 // 验证嘟噜支付回调MD5签名
function verifySign(params: Record<string, any>, key: string): boolean { function verifySign(params: Record<string, any>, key: string): boolean {
const sign = params.sign const sign = params.sign
if (!sign) return false if (!sign) return false
// 移除sign和sign_type参数 // 1. 获取所有非空请求参数,剔除sign和sign_type字段
const filtered: Record<string, any> = {} const filtered: Record<string, any> = {}
Object.keys(params) Object.keys(params)
.sort() .sort() // 按照ASCII码递增排序
.forEach((k) => { .forEach((k) => {
if (k !== 'sign' && k !== 'sign_type' && params[k] !== '' && params[k] !== null && params[k] !== undefined) { // 排除空值、数组、字节类型参数,以及sign和sign_type字段
if (
k !== 'sign' &&
k !== 'sign_type' &&
params[k] !== '' &&
params[k] !== null &&
params[k] !== undefined &&
!Array.isArray(params[k]) &&
typeof params[k] !== 'object'
) {
filtered[k] = params[k] filtered[k] = params[k]
} }
}) })
// 拼接字符串 // 2. 将排序后的参数组合成"参数=参数值"的格式,用&连接
// 注意:参数值不要进行URL编码(根据官方文档)
const signStr = Object.keys(filtered) const signStr = Object.keys(filtered)
.map((k) => `${k}=${filtered[k]}`) .map((k) => `${k}=${filtered[k]}`)
.join('&') .join('&')
const signStrWithKey = `${signStr}&key=${key}`
// 3. 直接拼接密钥(无&key=前缀)
// 根据官方文档:sign = md5(a=b&c=d&e=f + KEY)
const signStrWithKey = `${signStr}${key}`
// MD5加密并转大写 // 4. MD5加密(结果为小写,根据官方文档)
const calculatedSign = crypto.createHash('md5').update(signStrWithKey).digest('hex').toUpperCase() const calculatedSign = crypto.createHash('md5').update(signStrWithKey, 'utf8').digest('hex')
return calculatedSign === sign.toUpperCase() return calculatedSign === sign.toLowerCase()
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -51,16 +64,16 @@ export async function POST(request: NextRequest) {
console.log('📥 收到支付回调:', params) console.log('📥 收到支付回调:', params)
const epayKey = process.env.EPAY_KEY const epayKey = process.env.EPAY_KEY // MD5密钥
if (!epayKey) { if (!epayKey) {
console.error('❌ 易支付密钥未配置') console.error('❌ 嘟噜支付MD5密钥未配置')
return NextResponse.text('FAIL', { status: 500 }) return new NextResponse('fail', { status: 500 })
} }
// 验证签名 // 验证签名
if (!verifySign(params, epayKey)) { if (!verifySign(params, epayKey)) {
console.error('❌ 签名验证失败:', params) console.error('❌ 签名验证失败:', params)
return NextResponse.text('FAIL', { status: 400 }) return new NextResponse('fail', { status: 400 })
} }
// 获取订单信息 // 获取订单信息
@ -71,20 +84,20 @@ export async function POST(request: NextRequest) {
if (!orderNo) { if (!orderNo) {
console.error('❌ 订单号缺失') console.error('❌ 订单号缺失')
return NextResponse.text('FAIL', { status: 400 }) return new NextResponse('fail', { status: 400 })
} }
// 查找订单 // 查找订单
const order = await db.findOrderByOrderNo(orderNo) const order = await db.findOrderByOrderNo(orderNo)
if (!order) { if (!order) {
console.error('❌ 订单不存在:', orderNo) console.error('❌ 订单不存在:', orderNo)
return NextResponse.text('FAIL', { status: 404 }) return new NextResponse('fail', { status: 404 })
} }
// 检查订单状态 // 检查订单状态
if (order.status === 'paid') { if (order.status === 'paid') {
console.log('✅ 订单已支付,跳过处理:', orderNo) console.log('✅ 订单已支付,跳过处理:', orderNo)
return NextResponse.text('SUCCESS') return new NextResponse('success')
} }
// 验证支付状态 // 验证支付状态
@ -111,7 +124,7 @@ export async function POST(request: NextRequest) {
// - 更新用户权限 // - 更新用户权限
// - 记录日志等 // - 记录日志等
return NextResponse.text('SUCCESS') return new NextResponse('success')
} else { } else {
// 支付失败 // 支付失败
await db.updateOrderStatus( await db.updateOrderStatus(
@ -128,11 +141,11 @@ export async function POST(request: NextRequest) {
params, params,
}) })
return NextResponse.text('SUCCESS') // 即使失败也要返回SUCCESS,避免重复通知 return new NextResponse('success') // 即使失败也要返回SUCCESS,避免重复通知
} }
} catch (error) { } catch (error) {
console.error('处理支付回调错误:', error) console.error('处理支付回调错误:', error)
return NextResponse.text('FAIL', { status: 500 }) return new NextResponse('FAIL', { status: 500 })
} }
} }
@ -148,16 +161,16 @@ export async function GET(request: NextRequest) {
console.log('📥 收到支付回调(GET):', params) console.log('📥 收到支付回调(GET):', params)
const epayKey = process.env.EPAY_KEY const epayKey = process.env.EPAY_KEY // MD5密钥
if (!epayKey) { if (!epayKey) {
console.error('❌ 易支付密钥未配置') console.error('❌ 嘟噜支付MD5密钥未配置')
return NextResponse.text('FAIL', { status: 500 }) return new NextResponse('fail', { status: 500 })
} }
// 验证签名 // 验证签名
if (!verifySign(params, epayKey)) { if (!verifySign(params, epayKey)) {
console.error('❌ 签名验证失败:', params) console.error('❌ 签名验证失败:', params)
return NextResponse.text('FAIL', { status: 400 }) return new NextResponse('fail', { status: 400 })
} }
// 获取订单信息 // 获取订单信息
@ -168,20 +181,20 @@ export async function GET(request: NextRequest) {
if (!orderNo) { if (!orderNo) {
console.error('❌ 订单号缺失') console.error('❌ 订单号缺失')
return NextResponse.text('FAIL', { status: 400 }) return new NextResponse('fail', { status: 400 })
} }
// 查找订单 // 查找订单
const order = await db.findOrderByOrderNo(orderNo) const order = await db.findOrderByOrderNo(orderNo)
if (!order) { if (!order) {
console.error('❌ 订单不存在:', orderNo) console.error('❌ 订单不存在:', orderNo)
return NextResponse.text('FAIL', { status: 404 }) return new NextResponse('fail', { status: 404 })
} }
// 检查订单状态 // 检查订单状态
if (order.status === 'paid') { if (order.status === 'paid') {
console.log('✅ 订单已支付,跳过处理:', orderNo) console.log('✅ 订单已支付,跳过处理:', orderNo)
return NextResponse.text('SUCCESS') return new NextResponse('success')
} }
// 验证支付状态 // 验证支付状态
@ -202,7 +215,7 @@ export async function GET(request: NextRequest) {
paymentMethod, paymentMethod,
}) })
return NextResponse.text('SUCCESS') return new NextResponse('success')
} else { } else {
// 支付失败 // 支付失败
await db.updateOrderStatus( await db.updateOrderStatus(
@ -219,11 +232,11 @@ export async function GET(request: NextRequest) {
params, params,
}) })
return NextResponse.text('SUCCESS') return new NextResponse('success')
} }
} catch (error) { } catch (error) {
console.error('处理支付回调错误:', error) console.error('处理支付回调错误:', error)
return NextResponse.text('FAIL', { status: 500 }) return new NextResponse('FAIL', { status: 500 })
} }
} }

View File

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth/next' import { getServerSession } from 'next-auth/next'
import { authOptions } from '@/app/api/auth/[...nextauth]/route' import { authOptions } from '@/lib/authOptions'
import { db } from '@/lib/db' import { db } from '@/lib/db'
import crypto from 'crypto' import crypto from 'crypto'
@ -18,6 +18,10 @@ const PRODUCTS = {
name: '定制版咨询', name: '定制版咨询',
price: 0, // 需要沟通报价 price: 0, // 需要沟通报价
}, },
test: {
name: '支付测试',
price: 0.1, // 测试用,金额0.1元
},
} }
// 生成订单号 // 生成订单号
@ -27,26 +31,98 @@ function generateOrderNo(): string {
return `FLNXI${timestamp}${random}` return `FLNXI${timestamp}${random}`
} }
// 生成易支付签名 // 生成嘟噜支付MD5签名
function generateSign(params: Record<string, any>, key: string): string { function generateSign(params: Record<string, any>, key: string): string {
// 移除空值和sign参数 // 1. 获取所有非空请求参数,剔除sign和sign_type字段
const filtered: Record<string, any> = {} const filtered: Record<string, string> = {}
Object.keys(params) const sortedKeys = Object.keys(params).sort() // 按照ASCII码递增排序
.sort()
.forEach((k) => { sortedKeys.forEach((k) => {
if (params[k] !== '' && params[k] !== null && params[k] !== undefined && k !== 'sign') { // 排除空值、数组、字节类型参数,以及sign和sign_type字段
filtered[k] = params[k] if (
} k !== 'sign' &&
}) k !== 'sign_type' &&
params[k] !== '' &&
params[k] !== null &&
params[k] !== undefined &&
!Array.isArray(params[k]) &&
typeof params[k] !== 'object'
) {
// 确保所有值都是字符串(不trim,保持原始值)
filtered[k] = String(params[k])
}
})
// 拼接字符串 // 2. 将排序后的参数组合成"参数=参数值"的格式,用&连接
const signStr = Object.keys(filtered) // 注意:签名时使用原始参数值(不编码),因为表单提交时浏览器会自动处理编码
// 但支付平台验证签名时使用的是收到的原始值(浏览器解码后的值)
const sortedFilteredKeys = Object.keys(filtered).sort()
// 使用原始参数值(不编码)进行签名
const signStr = sortedFilteredKeys
.map((k) => `${k}=${filtered[k]}`) .map((k) => `${k}=${filtered[k]}`)
.join('&') .join('&')
const signStrWithKey = `${signStr}&key=${key}`
// 3. 在末尾加上密钥(易支付常见方式:直接拼接密钥,没有&key=前缀)
const signStrWithKey = `${signStr}${key}`
// 对比:如果使用 &key=密钥 的方式
const signStrWithKeyPrefix = `${signStr}&key=${key}`
// MD5加密并转大写 // 详细调试日志
return crypto.createHash('md5').update(signStrWithKey).digest('hex').toUpperCase() console.log('\n========== 签名参数调试信息 ==========')
console.log('📋 原始参数:')
Object.keys(params).sort().forEach(k => {
console.log(` ${k} = ${params[k]} (类型: ${typeof params[k]})`)
})
console.log('\n✅ 参与签名的参数(已过滤和排序):')
sortedFilteredKeys.forEach((k, index) => {
const encodedValue = encodeURIComponent(filtered[k])
console.log(` ${index + 1}. ${k} = "${filtered[k]}"`)
if (encodedValue !== filtered[k]) {
console.log(` (URL编码后: "${encodedValue}")`)
}
})
console.log('\n🔗 待签名字符串(参数值不进行URL编码):')
console.log(` ${signStr}`)
console.log('\n🔑 密钥信息:')
console.log(` 长度: ${key.length} 字符`)
console.log(` 前3个字符: ${key.substring(0, 3)}...`)
console.log(` 后3个字符: ...${key.substring(key.length - 3)}`)
console.log('\n🔐 完整签名字符串(直接拼接密钥,无&key=前缀):')
console.log(` ${signStrWithKey}`)
console.log(` 长度: ${signStrWithKey.length} 字符`)
console.log(' 格式:sign = md5(a=b&c=d&e=f + KEY)')
// 4. MD5加密
// 根据官方文档:sign = md5(a=b&c=d&e=f + KEY),MD5结果为小写
// 方式A:直接拼接密钥(官方文档标准方式)
const signatureA = crypto.createHash('md5').update(signStrWithKey, 'utf8').digest('hex') // 小写
// 方式B:使用&key=前缀(对比)
const signatureB = crypto.createHash('md5').update(signStrWithKeyPrefix, 'utf8').digest('hex') // 小写
// 默认使用方式A(直接拼接密钥,官方文档标准方式)
const signature = signatureA
console.log('\n📝 生成的签名(根据官方文档):')
console.log(' [方式A - 直接拼接密钥(官方文档标准方式)] (当前使用):')
console.log(` 签名字符串: ${signStrWithKey}`)
console.log(` 签名(小写): ${signatureA}`)
console.log(' [方式B - 使用&key=前缀(对比)]:')
console.log(` 签名字符串: ${signStrWithKeyPrefix}`)
console.log(` 签名(小写): ${signatureB}`)
console.log('\n✅ 当前使用方式A(直接拼接密钥,MD5结果为小写)')
console.log(' 根据官方文档:sign = md5(a=b&c=d&e=f + KEY),MD5结果为小写')
console.log('========================================\n')
return signature
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@ -81,10 +157,10 @@ export async function POST(request: NextRequest) {
) )
} }
// 检查易支付配置 // 检查嘟噜支付配置
const epayPid = process.env.EPAY_PID const epayPid = process.env.EPAY_PID
const epayKey = process.env.EPAY_KEY const epayKey = process.env.EPAY_KEY // MD5密钥
const epayApiUrl = process.env.EPAY_API_URL || 'https://your-epay-domain.com/submit.php' const epayApiUrl = process.env.EPAY_API_URL || 'https://www.dulupay.com/api/pay'
if (!epayPid || !epayKey || !epayApiUrl) { if (!epayPid || !epayKey || !epayApiUrl) {
return NextResponse.json( return NextResponse.json(
@ -111,21 +187,33 @@ export async function POST(request: NextRequest) {
const notifyUrl = `${baseUrl}/api/payment/callback` const notifyUrl = `${baseUrl}/api/payment/callback`
const returnUrl = `${baseUrl}/payment/success?order_no=${orderNo}` const returnUrl = `${baseUrl}/payment/success?order_no=${orderNo}`
// 构建易支付参数 // 构建嘟噜支付参数(根据官方文档)
const epayParams = { // 注意:参数名需要与易支付平台要求的一致
pid: epayPid, const epayParams: Record<string, string> = {
type: paymentMethod, // alipay, wxpay, qqpay等 pid: String(epayPid), // 商户ID
out_trade_no: orderNo, type: String(paymentMethod), // 支付方式:alipay, wxpay, qqpay等
notify_url: notifyUrl, out_trade_no: orderNo, // 商户订单号
return_url: returnUrl, notify_url: notifyUrl, // 异步通知地址
name: product.name, return_url: returnUrl, // 同步跳转地址
money: product.price.toFixed(2), name: product.name, // 商品名称
money: product.price.toFixed(2), // 金额(保留2位小数)
} }
// 调试:输出参数名对比
console.log('\n📝 参数名检查(易支付标准参数名,根据官方文档):')
console.log(' pid = 商户ID(必填)')
console.log(' type = 支付方式(可选)')
console.log(' out_trade_no = 商户订单号(必填)')
console.log(' notify_url = 异步通知地址(必填)')
console.log(' return_url = 同步跳转地址(必填)')
console.log(' name = 商品名称(必填)')
console.log(' money = 金额(必填,保留2位小数)')
console.log(' 注意:sign、sign_type、和空值不参与签名\n')
// 生成签名 // 生成MD5签名
const sign = generateSign(epayParams, epayKey) const sign = generateSign(epayParams, epayKey)
epayParams.sign = sign epayParams.sign = sign
epayParams.sign_type = 'MD5' epayParams.sign_type = 'MD5' // 嘟噜支付使用MD5签名
console.log('✅ 创建支付订单:', { console.log('✅ 创建支付订单:', {
orderNo, orderNo,
@ -133,8 +221,23 @@ export async function POST(request: NextRequest) {
product: product.name, product: product.name,
amount: product.price, amount: product.price,
}) })
console.log('📋 支付参数:', epayParams)
// 输出完整的测试信息,方便对比
console.log('\n🔍 完整测试信息(可用于对比Go项目):')
console.log('参数列表(按ASCII排序):')
Object.keys(epayParams).sort().forEach(k => {
console.log(` ${k} = "${epayParams[k]}"`)
})
console.log('\n建议:')
console.log('1. 查看Go项目实际发送给支付平台的参数(网络抓包或日志)')
console.log('2. 对比参数名是否一致')
console.log('3. 对比参数值是否一致')
console.log('4. 查看Go项目实际生成的签名')
console.log('5. 如果Go项目能正常工作,请提供Go项目的实际参数和签名用于对比')
console.log('6. 或联系嘟噜支付技术支持,获取签名示例代码\n')
// 返回支付参数,前端提交表单到易支付 // 返回支付参数,前端提交表单到嘟噜支付
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
orderNo, orderNo,

View File

@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth/next' import { getServerSession } from 'next-auth/next'
import { authOptions } from '@/app/api/auth/[...nextauth]/route' import { authOptions } from '@/lib/authOptions'
import { db } from '@/lib/db' import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
// 验证登录状态 // 验证登录状态

View File

@ -1,6 +1,8 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import './globals.css' import './globals.css'
import SessionProvider from '@/components/SessionProvider' import SessionProvider from '@/components/SessionProvider'
import { getServerSession } from 'next-auth/next'
import { authOptions } from '@/lib/authOptions'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'FLNXI电商素材系统 - 企业级素材管理解决方案', title: 'FLNXI电商素材系统 - 企业级素材管理解决方案',
@ -14,15 +16,17 @@ export const metadata: Metadata = {
}, },
} }
export default function RootLayout({ export default async function RootLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const session = await getServerSession(authOptions)
return ( return (
<html lang="zh-CN"> <html lang="zh-CN">
<body className="font-sf-pro"> <body className="font-sf-pro">
<SessionProvider session={null}> <SessionProvider session={session}>
{children} {children}
</SessionProvider> </SessionProvider>
</body> </body>

View File

@ -1,14 +1,15 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { Suspense, useEffect, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { CheckCircle, XCircle, Loader2 } from 'lucide-react' import { CheckCircle, XCircle, Loader2 } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
export default function PaymentSuccessPage() { export const dynamic = 'force-dynamic'
function PaymentSuccessContent() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter()
const orderNo = searchParams.get('order_no') const orderNo = searchParams.get('order_no')
const [orderStatus, setOrderStatus] = useState<'loading' | 'success' | 'failed' | 'pending'>('loading') const [orderStatus, setOrderStatus] = useState<'loading' | 'success' | 'failed' | 'pending'>('loading')
const [orderInfo, setOrderInfo] = useState<any>(null) const [orderInfo, setOrderInfo] = useState<any>(null)
@ -19,7 +20,6 @@ export default function PaymentSuccessPage() {
return return
} }
// 查询订单状态
const checkOrderStatus = async () => { const checkOrderStatus = async () => {
try { try {
const response = await fetch(`/api/payment/status?order_no=${orderNo}`) const response = await fetch(`/api/payment/status?order_no=${orderNo}`)
@ -27,7 +27,13 @@ export default function PaymentSuccessPage() {
if (data.success) { if (data.success) {
setOrderInfo(data.order) setOrderInfo(data.order)
setOrderStatus(data.order.status === 'paid' ? 'success' : data.order.status === 'failed' ? 'failed' : 'pending') setOrderStatus(
data.order.status === 'paid'
? 'success'
: data.order.status === 'failed'
? 'failed'
: 'pending'
)
} else { } else {
setOrderStatus('failed') setOrderStatus('failed')
} }
@ -189,3 +195,20 @@ export default function PaymentSuccessPage() {
) )
} }
export default function PaymentSuccessPage() {
return (
<Suspense
fallback={
<div className="min-h-screen bg-gradient-to-b from-apple-gray-50 to-white flex items-center justify-center px-4 py-12">
<div className="text-center">
<Loader2 className="w-12 h-12 text-apple-blue animate-spin mx-auto mb-4" />
<p className="text-apple-gray-600">页面加载中...</p>
</div>
</div>
}
>
<PaymentSuccessContent />
</Suspense>
)
}

View File

@ -27,6 +27,12 @@ export default function Header({ scrolled }: HeaderProps) {
} }
}, [mobileMenuOpen]) }, [mobileMenuOpen])
// 调试:输出会话状态和用户
useEffect(() => {
console.log('🔍 useSession status:', status)
console.log('🔍 useSession user:', session?.user)
}, [status, session])
return ( return (
<> <>
<header <header
@ -73,10 +79,7 @@ export default function Header({ scrolled }: HeaderProps) {
</a> </a>
{/* Login/User Info */} {/* Login/User Info */}
{status === 'loading' ? ( {status === 'authenticated' && session ? (
<div className="w-9 h-9 rounded-full bg-apple-gray-200 animate-pulse" />
) : session ? (
// 已登录 - 显示用户信息
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="flex items-center space-x-2 px-4 py-2 bg-apple-gray-50 rounded-full"> <div className="flex items-center space-x-2 px-4 py-2 bg-apple-gray-50 rounded-full">
<User className="w-4 h-4 text-apple-blue" /> <User className="w-4 h-4 text-apple-blue" />
@ -92,8 +95,9 @@ export default function Header({ scrolled }: HeaderProps) {
<LogOut className="w-5 h-5" /> <LogOut className="w-5 h-5" />
</button> </button>
</div> </div>
) : status === 'loading' ? (
<div className="w-9 h-9 rounded-full bg-apple-gray-200 animate-pulse" />
) : ( ) : (
// 未登录 - 显示注册和登录按钮
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<a <a
href="/auth/signup" href="/auth/signup"
@ -207,7 +211,15 @@ export default function Header({ scrolled }: HeaderProps) {
</div> </div>
</div> </div>
</div> </div>
{/* Debug 浮层:直接展示会话状态,方便确认登录(始终显示) */}
<div className="fixed bottom-3 right-3 z-[9999] rounded-xl border border-apple-gray-200 bg-white/90 px-3 py-2 text-xs text-apple-gray-700 shadow-lg space-y-1">
<div className="font-semibold text-apple-gray-800">DEBUG Session</div>
<div>status: {status}</div>
<div>user: {session?.user?.email || session?.user?.name || 'none'}</div>
</div>
</> </>
) )
} }
Header.displayName = 'HeaderWithDebug'

View File

@ -55,7 +55,7 @@ export default function Pricing() {
return return
} }
if (!session) { if (status !== 'authenticated' || !session) {
// 未登录,跳转到登录页 // 未登录,跳转到登录页
router.push('/auth/signin?callbackUrl=' + encodeURIComponent('/#pricing')) router.push('/auth/signin?callbackUrl=' + encodeURIComponent('/#pricing'))
return return
@ -301,13 +301,7 @@ export default function Pricing() {
</svg> </svg>
正在创建订单... 正在创建订单...
</span> </span>
) : status === 'loading' ? ( ) : status === 'authenticated' ? '立即购买' : status === 'loading' ? '加载中...' : '登录后购买'}
'加载中...'
) : session ? (
'立即购买'
) : (
'登录后购买'
)}
</a> </a>
<p className="text-center text-sm text-apple-gray-500 mt-4"> <p className="text-center text-sm text-apple-gray-500 mt-4">
@ -369,6 +363,61 @@ export default function Pricing() {
{/* 增值服务 */} {/* 增值服务 */}
<div className="grid md:grid-cols-2 gap-8 mb-12"> <div className="grid md:grid-cols-2 gap-8 mb-12">
{/* 支付测试 */}
<div className="bg-gradient-to-br from-green-50 to-emerald-50 rounded-3xl p-8 border-2 border-green-200 apple-shadow-lg">
<div className="flex flex-col md:flex-row items-center gap-6">
{/* 左侧图标和价格 */}
<div className="flex-shrink-0 text-center md:text-left">
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-2xl flex items-center justify-center mx-auto md:mx-0 mb-3">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="text-3xl font-bold text-green-600 mb-1">¥0.1</div>
<div className="text-sm text-apple-gray-600">测试专用</div>
</div>
{/* 右侧内容 */}
<div className="flex-1">
<h4 className="text-2xl font-bold text-apple-gray-900 mb-3">
🧪 支付测试
</h4>
<p className="text-apple-gray-700 mb-4">
用于测试支付功能,金额仅<strong>0.1元</strong>,方便验证支付流程是否正常。
</p>
<div className="grid grid-cols-1 gap-3 mb-4">
<div className="flex items-center text-sm text-apple-gray-700">
<Check className="w-4 h-4 text-green-600 mr-2 flex-shrink-0" />
<span>测试支付流程</span>
</div>
<div className="flex items-center text-sm text-apple-gray-700">
<Check className="w-4 h-4 text-green-600 mr-2 flex-shrink-0" />
<span>验证签名功能</span>
</div>
<div className="flex items-center text-sm text-apple-gray-700">
<Check className="w-4 h-4 text-green-600 mr-2 flex-shrink-0" />
<span>检查回调通知</span>
</div>
</div>
<a
href="#contact"
onClick={(e) => handlePurchaseClick(e, 'test')}
className="block w-full py-3 bg-gradient-to-r from-green-500 to-emerald-600 text-white text-center rounded-full font-semibold button-hover apple-shadow disabled:opacity-50 disabled:cursor-not-allowed"
>
{purchasing === 'test' ? (
<span className="flex items-center justify-center">
<svg className="animate-spin h-5 w-5 mr-2" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
正在创建订单...
</span>
) : status === 'authenticated' ? '测试支付' : status === 'loading' ? '加载中...' : '登录后测试'}
</a>
</div>
</div>
</div>
{/* 远程部署服务 */} {/* 远程部署服务 */}
<div className="bg-gradient-to-br from-orange-50 to-amber-50 rounded-3xl p-8 border-2 border-orange-200 apple-shadow-lg"> <div className="bg-gradient-to-br from-orange-50 to-amber-50 rounded-3xl p-8 border-2 border-orange-200 apple-shadow-lg">
<div className="flex flex-col md:flex-row items-center gap-6"> <div className="flex flex-col md:flex-row items-center gap-6">

223
lib/authOptions.ts Normal file
View File

@ -0,0 +1,223 @@
import type { AuthOptions } from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import type { User } from "next-auth"
import bcrypt from 'bcryptjs'
import { db } from '@/lib/db'
import crypto from 'crypto'
type WeChatTokens = {
access_token?: string
refresh_token?: string
expires_in?: number
openid?: string
unionid?: string
scope?: string
[key: string]: unknown
}
function WeChatProvider(): any {
const appId = process.env.WECHAT_APP_ID
const appSecret = process.env.WECHAT_APP_SECRET
if (!appId || !appSecret) {
console.error('❌ 微信登录配置缺失: WECHAT_APP_ID 或 WECHAT_APP_SECRET 未设置')
} else {
console.log('✅ 微信登录配置已加载:', { appId: appId.substring(0, 8) + '...' })
}
return {
id: 'wechat',
name: 'WeChat',
type: 'oauth',
version: '2.0',
checks: ['pkce', 'state'],
authorization: {
url: 'https://open.weixin.qq.com/connect/qrconnect',
params: {
appid: appId,
scope: 'snsapi_login',
response_type: 'code',
// redirect_uri 由 NextAuth 自动注入
},
},
token: {
url: 'https://api.weixin.qq.com/sns/oauth2/access_token',
params: {
appid: appId,
secret: appSecret,
grant_type: 'authorization_code',
},
async request({
params,
provider,
}: {
params: Record<string, string>
provider: any
}) {
const url = `${provider.token!.url}?${new URLSearchParams(
params as Record<string, string>
).toString()}`
const res = await fetch(url, { method: 'GET' })
if (!res.ok) {
throw new Error(`WeChat token request failed: ${res.status}`)
}
const tokens = (await res.json()) as WeChatTokens
return { tokens }
},
},
userinfo: {
// 需要 access_token + openid
async request({ tokens }: { tokens: WeChatTokens }) {
const accessToken = tokens.access_token as string
const openid = (tokens as any).openid as string
if (!accessToken || !openid) {
throw new Error('Missing access_token or openid from WeChat')
}
const url = `https://api.weixin.qq.com/sns/userinfo?${new URLSearchParams({
access_token: accessToken,
openid,
lang: 'zh_CN',
}).toString()}`
const res = await fetch(url)
if (!res.ok) {
throw new Error(`WeChat userinfo failed: ${res.status}`)
}
return res.json()
},
},
profile(profile: any) {
return {
id: profile.unionid || profile.openid,
name: profile.nickname || '微信用户',
email: profile.email || `${profile.openid}@wechat.flnxi.com`,
image: profile.headimgurl,
}
},
clientId: appId,
clientSecret: appSecret,
}
}
export const authOptions: AuthOptions = {
providers: [
// 邮箱密码登录
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: "邮箱", type: "email", placeholder: "user@example.com" },
password: { label: "密码", type: "password" }
},
async authorize(credentials): Promise<User | null> {
try {
if (!credentials?.email || !credentials?.password) {
console.log('❌ 登录失败: 缺少邮箱或密码')
return null
}
// 从数据库查询用户
const user = await db.findUserByEmail(credentials.email)
if (!user) {
console.log('❌ 登录失败: 用户不存在 -', credentials.email)
return null
}
// 使用 bcrypt 验证密码
const isValidPassword = await bcrypt.compare(
credentials.password,
user.password
)
if (!isValidPassword) {
console.log('❌ 登录失败: 密码错误 -', credentials.email)
return null
}
console.log('✅ 登录成功:', {
id: user.id,
name: user.name,
email: user.email,
})
// 返回用户信息(不包含密码)
return {
id: user.id.toString(),
name: user.name,
email: user.email,
}
} catch (error) {
console.error('登录错误:', error)
return null
}
}
}),
// 微信扫码登录
WeChatProvider(),
],
// 在 signIn 阶段将微信用户写入数据库
callbacks: {
async signIn({ user, account, profile }) {
if (account?.provider === 'wechat') {
const unionid = (profile as any)?.unionid || (profile as any)?.id
const openid = (profile as any)?.openid
const name = (profile as any)?.nickname || user.name || '微信用户'
const email =
(profile as any)?.email ||
user.email ||
`${openid || unionid || crypto.randomUUID()}@wechat.flnxi.com`
const randomPassword = crypto.randomBytes(16).toString('hex')
const hashedPassword = await bcrypt.hash(randomPassword, 10)
const dbUser = await db.upsertWechatUser({
name,
email,
unionid: unionid ?? null,
openid: openid ?? null,
hashedPassword,
})
// 将数据库中的 id/email/name 赋回 user,后续 jwt/session 使用
user.id = dbUser.id.toString()
user.email = dbUser.email
user.name = dbUser.name
}
return true
},
// 自定义页面
pages: {
signIn: '/auth/signin',
},
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.email = user.email
token.name = user.name
}
return token
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string
session.user.email = token.email as string
session.user.name = token.name as string
}
return session
},
},
// 会话策略
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 天
},
// 密钥
secret: process.env.NEXTAUTH_SECRET,
// 调试模式(生产环境设为false)
debug: process.env.NODE_ENV === 'development',
}

View File

@ -1,4 +1,5 @@
import { Pool } from 'pg' import { Pool } from 'pg'
import crypto from 'crypto'
// 创建数据库连接池 // 创建数据库连接池
const pool = new Pool({ const pool = new Pool({
@ -28,6 +29,9 @@ export interface User {
email_verified: boolean email_verified: boolean
created_at: Date created_at: Date
updated_at: Date updated_at: Date
provider?: string
wechat_unionid?: string | null
wechat_openid?: string | null
} }
// 订单接口 // 订单接口
@ -125,6 +129,50 @@ export const db = {
return result.rows return result.rows
}, },
// ========== 微信用户相关 ==========
async findUserByWechat(unionid?: string | null, openid?: string | null): Promise<User | null> {
if (unionid) {
const byUnion = await pool.query('SELECT * FROM users WHERE wechat_unionid = $1', [unionid])
if (byUnion.rows[0]) return byUnion.rows[0]
}
if (openid) {
const byOpen = await pool.query('SELECT * FROM users WHERE wechat_openid = $1', [openid])
if (byOpen.rows[0]) return byOpen.rows[0]
}
return null
},
async upsertWechatUser(params: {
name?: string | null
email?: string | null
unionid?: string | null
openid?: string | null
hashedPassword: string
}): Promise<User> {
const { name, email, unionid, openid, hashedPassword } = params
const existing = await this.findUserByWechat(unionid ?? undefined, openid ?? undefined)
if (existing) {
return existing
}
const result = await pool.query(
`INSERT INTO users (name, email, password, email_verified, provider, wechat_unionid, wechat_openid, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING *`,
[
name || '微信用户',
email || `${openid || unionid || crypto.randomUUID()}@wechat.local`,
hashedPassword,
true,
'wechat',
unionid || null,
openid || null,
]
)
return result.rows[0]
},
// ========== 订单相关操作 ========== // ========== 订单相关操作 ==========
// 创建订单 // 创建订单

19
middleware.ts Normal file
View File

@ -0,0 +1,19 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getToken } from 'next-auth/jwt'
export async function middleware(request: NextRequest) {
const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET })
// 仅调试日志,确认是否读到 token
console.log('🔍 middleware token exists:', !!token, 'path:', request.nextUrl.pathname)
// middleware.ts 顶部或回调里
console.log('NEXTAUTH_URL', process.env.NEXTAUTH_URL)
console.log('NEXTAUTH_SECRET exists:', !!process.env.NEXTAUTH_SECRET, 'len:', process.env.NEXTAUTH_SECRET?.length)
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}