头铁
This commit is contained in:
@@ -88,6 +88,9 @@ interface Order {
|
|||||||
status: 'pending' | 'completed' | 'failed'
|
status: 'pending' | 'completed' | 'failed'
|
||||||
paymentMethod?: string
|
paymentMethod?: string
|
||||||
referrerEarnings?: number
|
referrerEarnings?: number
|
||||||
|
referrerId?: string | null
|
||||||
|
/** 下单时记录的邀请码(订单表 referral_code) */
|
||||||
|
referralCode?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,13 +134,18 @@ export default function DistributionAdminPage() {
|
|||||||
const ordersRes = await fetch('/api/orders')
|
const ordersRes = await fetch('/api/orders')
|
||||||
const ordersData = await ordersRes.json()
|
const ordersData = await ordersRes.json()
|
||||||
if (ordersData.success && ordersData.orders) {
|
if (ordersData.success && ordersData.orders) {
|
||||||
// 补充用户信息
|
// 补充用户信息与推荐人信息
|
||||||
const enrichedOrders = ordersData.orders.map((order: Order) => {
|
const enrichedOrders = ordersData.orders.map((order: Order) => {
|
||||||
const user = usersArr.find((u: User) => u.id === order.userId)
|
const user = usersArr.find((u: User) => u.id === order.userId)
|
||||||
|
const referrer = order.referrerId
|
||||||
|
? usersArr.find((u: User) => u.id === order.referrerId)
|
||||||
|
: null
|
||||||
return {
|
return {
|
||||||
...order,
|
...order,
|
||||||
userNickname: user?.nickname || '未知用户',
|
userNickname: user?.nickname || '未知用户',
|
||||||
userPhone: user?.phone || '-'
|
userPhone: user?.phone || '-',
|
||||||
|
referrerNickname: referrer?.nickname || null,
|
||||||
|
referrerCode: referrer?.referral_code || null,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
setOrders(enrichedOrders)
|
setOrders(enrichedOrders)
|
||||||
@@ -565,6 +573,7 @@ export default function DistributionAdminPage() {
|
|||||||
<th className="p-4 text-left font-medium">金额</th>
|
<th className="p-4 text-left font-medium">金额</th>
|
||||||
<th className="p-4 text-left font-medium">支付方式</th>
|
<th className="p-4 text-left font-medium">支付方式</th>
|
||||||
<th className="p-4 text-left font-medium">状态</th>
|
<th className="p-4 text-left font-medium">状态</th>
|
||||||
|
<th className="p-4 text-left font-medium">推荐人/邀请码</th>
|
||||||
<th className="p-4 text-left font-medium">分销佣金</th>
|
<th className="p-4 text-left font-medium">分销佣金</th>
|
||||||
<th className="p-4 text-left font-medium">下单时间</th>
|
<th className="p-4 text-left font-medium">下单时间</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -579,7 +588,9 @@ export default function DistributionAdminPage() {
|
|||||||
order.id?.toLowerCase().includes(term) ||
|
order.id?.toLowerCase().includes(term) ||
|
||||||
order.userNickname?.toLowerCase().includes(term) ||
|
order.userNickname?.toLowerCase().includes(term) ||
|
||||||
order.userPhone?.includes(term) ||
|
order.userPhone?.includes(term) ||
|
||||||
order.sectionTitle?.toLowerCase().includes(term)
|
order.sectionTitle?.toLowerCase().includes(term) ||
|
||||||
|
(order.referrerCode && order.referrerCode.toLowerCase().includes(term)) ||
|
||||||
|
(order.referrerNickname && order.referrerNickname.toLowerCase().includes(term))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -617,14 +628,22 @@ export default function DistributionAdminPage() {
|
|||||||
order.paymentMethod || '微信支付'}
|
order.paymentMethod || '微信支付'}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{order.status === 'completed' ? (
|
{order.status === 'completed' || order.status === 'paid' ? (
|
||||||
<Badge className="bg-green-500/20 text-green-400 border-0">已完成</Badge>
|
<Badge className="bg-green-500/20 text-green-400 border-0">已完成</Badge>
|
||||||
) : order.status === 'pending' ? (
|
) : order.status === 'pending' || order.status === 'created' ? (
|
||||||
<Badge className="bg-yellow-500/20 text-yellow-400 border-0">待支付</Badge>
|
<Badge className="bg-yellow-500/20 text-yellow-400 border-0">待支付</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge className="bg-red-500/20 text-red-400 border-0">已失败</Badge>
|
<Badge className="bg-red-500/20 text-red-400 border-0">已失败</Badge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="p-4 text-gray-300 text-sm">
|
||||||
|
{order.referrerId || order.referralCode ? (
|
||||||
|
<span title={order.referralCode || order.referrerCode || order.referrerId}>
|
||||||
|
{order.referrerNickname || order.referralCode || order.referrerCode || order.referrerId?.slice(0, 8)}
|
||||||
|
{(order.referralCode || order.referrerCode) ? ` (${order.referralCode || order.referrerCode})` : ''}
|
||||||
|
</span>
|
||||||
|
) : '-'}
|
||||||
|
</td>
|
||||||
<td className="p-4 text-[#FFD700]">
|
<td className="p-4 text-[#FFD700]">
|
||||||
{order.referrerEarnings ? `¥${order.referrerEarnings.toFixed(2)}` : '-'}
|
{order.referrerEarnings ? `¥${order.referrerEarnings.toFixed(2)}` : '-'}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -67,6 +67,15 @@ export default function AdminDashboard() {
|
|||||||
const totalUsers = users.length
|
const totalUsers = users.length
|
||||||
const totalPurchases = purchases.length
|
const totalPurchases = purchases.length
|
||||||
|
|
||||||
|
// 订单类型对应中文(product_type: section | fullbook | match)
|
||||||
|
const productTypeLabel = (p: { productType?: string; productId?: string; sectionTitle?: string }) => {
|
||||||
|
const type = p.productType || ""
|
||||||
|
if (type === "section") return p.productId ? `单章 ${p.productId}` : "单章"
|
||||||
|
if (type === "fullbook") return "整本购买"
|
||||||
|
if (type === "match") return "找伙伴"
|
||||||
|
return p.sectionTitle || "其他"
|
||||||
|
}
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ title: "总用户数", value: totalUsers, icon: Users, color: "text-blue-400", bg: "bg-blue-500/20", link: "/admin/users" },
|
{ title: "总用户数", value: totalUsers, icon: Users, color: "text-blue-400", bg: "bg-blue-500/20", link: "/admin/users" },
|
||||||
{
|
{
|
||||||
@@ -125,21 +134,28 @@ export default function AdminDashboard() {
|
|||||||
{purchases
|
{purchases
|
||||||
.slice(-5)
|
.slice(-5)
|
||||||
.reverse()
|
.reverse()
|
||||||
.map((p) => (
|
.map((p) => {
|
||||||
<div
|
const referrer = p.referrerId && users.find((u: any) => u.id === p.referrerId)
|
||||||
key={p.id}
|
const inviteCode = p.referralCode || referrer?.referral_code || referrer?.nickname || p.referrerId?.slice(0, 8)
|
||||||
className="flex items-center justify-between p-4 bg-[#0a1628] rounded-lg border border-gray-700/30"
|
return (
|
||||||
>
|
<div
|
||||||
<div>
|
key={p.id}
|
||||||
<p className="text-sm font-medium text-white">{p.sectionTitle || "整本购买"}</p>
|
className="flex items-center justify-between p-4 bg-[#0a1628] rounded-lg border border-gray-700/30"
|
||||||
<p className="text-xs text-gray-500">{new Date(p.createdAt).toLocaleString()}</p>
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">{productTypeLabel(p)}</p>
|
||||||
|
<p className="text-xs text-gray-500">{new Date(p.createdAt).toLocaleString()}</p>
|
||||||
|
{inviteCode && (
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">邀请码: {inviteCode}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-bold text-[#38bdac]">+¥{p.amount}</p>
|
||||||
|
<p className="text-xs text-gray-400">{p.paymentMethod || "微信支付"}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
)
|
||||||
<p className="text-sm font-bold text-[#38bdac]">+¥{p.amount}</p>
|
})}
|
||||||
<p className="text-xs text-gray-400">{p.paymentMethod || "微信支付"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{purchases.length === 0 && <p className="text-gray-500 text-center py-8">暂无订单数据</p>}
|
{purchases.length === 0 && <p className="text-gray-500 text-center py-8">暂无订单数据</p>}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -122,7 +122,31 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { productType, productId, userId } = attach
|
const { productType, productId, userId: attachUserId } = attach
|
||||||
|
|
||||||
|
// 买家身份必须以微信 openId 为准(不可伪造),避免客户端伪造 userId 导致错误归属/分佣
|
||||||
|
let buyerUserId: string | undefined = attachUserId
|
||||||
|
if (openId) {
|
||||||
|
try {
|
||||||
|
const usersByOpenId = await query('SELECT id FROM users WHERE open_id = ?', [openId]) as any[]
|
||||||
|
if (usersByOpenId.length > 0) {
|
||||||
|
const resolvedId = usersByOpenId[0].id
|
||||||
|
if (attachUserId && resolvedId !== attachUserId) {
|
||||||
|
console.warn('[PayNotify] 买家身份校验: attach.userId 与 openId 解析不一致,以 openId 为准', {
|
||||||
|
attachUserId,
|
||||||
|
resolvedId,
|
||||||
|
orderSn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
buyerUserId = resolvedId
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PayNotify] 按 openId 解析买家失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!buyerUserId && attachUserId) {
|
||||||
|
buyerUserId = attachUserId
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 更新订单状态为已支付
|
// 1. 更新订单状态为已支付
|
||||||
let orderExists = false
|
let orderExists = false
|
||||||
@@ -143,33 +167,55 @@ export async function POST(request: Request) {
|
|||||||
INSERT INTO orders (
|
INSERT INTO orders (
|
||||||
id, order_sn, user_id, open_id,
|
id, order_sn, user_id, open_id,
|
||||||
product_type, product_id, amount, description,
|
product_type, product_id, amount, description,
|
||||||
status, transaction_id, pay_time, referrer_id, created_at, updated_at
|
status, transaction_id, pay_time, referrer_id, referral_code, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'paid', ?, CURRENT_TIMESTAMP, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'paid', ?, CURRENT_TIMESTAMP, NULL, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
`, [
|
`, [
|
||||||
orderSn, orderSn, userId || openId, openId,
|
orderSn, orderSn, buyerUserId || openId, openId,
|
||||||
productType || 'unknown', productId || '', totalAmount,
|
productType || 'unknown', productId || '', totalAmount,
|
||||||
'支付回调补记订单', transactionId
|
'支付回调补记订单', transactionId
|
||||||
])
|
])
|
||||||
console.log('[PayNotify] ✅ 订单补记成功:', orderSn)
|
console.log('[PayNotify] ✅ 订单补记成功:', orderSn)
|
||||||
orderExists = true
|
orderExists = true
|
||||||
} catch (insertErr: any) {
|
} catch (insertErr: any) {
|
||||||
if (insertErr?.message?.includes('referrer_id') || insertErr?.code === 'ER_BAD_FIELD_ERROR') {
|
const msg = insertErr?.message || ''
|
||||||
|
const code = insertErr?.code || ''
|
||||||
|
if (msg.includes('referrer_id') || msg.includes('referral_code') || code === 'ER_BAD_FIELD_ERROR') {
|
||||||
try {
|
try {
|
||||||
await query(`
|
await query(`
|
||||||
INSERT INTO orders (
|
INSERT INTO orders (
|
||||||
id, order_sn, user_id, open_id,
|
id, order_sn, user_id, open_id,
|
||||||
product_type, product_id, amount, description,
|
product_type, product_id, amount, description,
|
||||||
status, transaction_id, pay_time, created_at, updated_at
|
status, transaction_id, pay_time, referrer_id, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'paid', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'paid', ?, CURRENT_TIMESTAMP, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
`, [
|
`, [
|
||||||
orderSn, orderSn, userId || openId, openId,
|
orderSn, orderSn, buyerUserId || openId, openId,
|
||||||
productType || 'unknown', productId || '', totalAmount,
|
productType || 'unknown', productId || '', totalAmount,
|
||||||
'支付回调补记订单', transactionId
|
'支付回调补记订单', transactionId
|
||||||
])
|
])
|
||||||
console.log('[PayNotify] ✅ 订单补记成功(无 referrer_id):', orderSn)
|
console.log('[PayNotify] ✅ 订单补记成功(无 referral_code):', orderSn)
|
||||||
orderExists = true
|
orderExists = true
|
||||||
} catch (e2) {
|
} catch (e2: any) {
|
||||||
console.error('[PayNotify] ❌ 补记订单失败:', e2)
|
if (e2?.message?.includes('referrer_id') || e2?.code === 'ER_BAD_FIELD_ERROR') {
|
||||||
|
try {
|
||||||
|
await query(`
|
||||||
|
INSERT INTO orders (
|
||||||
|
id, order_sn, user_id, open_id,
|
||||||
|
product_type, product_id, amount, description,
|
||||||
|
status, transaction_id, pay_time, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'paid', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`, [
|
||||||
|
orderSn, orderSn, buyerUserId || openId, openId,
|
||||||
|
productType || 'unknown', productId || '', totalAmount,
|
||||||
|
'支付回调补记订单', transactionId
|
||||||
|
])
|
||||||
|
console.log('[PayNotify] ✅ 订单补记成功(无 referrer_id/referral_code):', orderSn)
|
||||||
|
orderExists = true
|
||||||
|
} catch (e3) {
|
||||||
|
console.error('[PayNotify] ❌ 补记订单失败:', e3)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('[PayNotify] ❌ 补记订单失败:', e2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('[PayNotify] ❌ 补记订单失败:', insertErr)
|
console.error('[PayNotify] ❌ 补记订单失败:', insertErr)
|
||||||
@@ -199,20 +245,7 @@ export async function POST(request: Request) {
|
|||||||
console.error('[PayNotify] ❌ 处理订单失败:', e)
|
console.error('[PayNotify] ❌ 处理订单失败:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 获取用户信息
|
// 2. 更新用户购买记录(buyerUserId 已在上面以 openId 为准解析)(✅ 检查是否已有其他相同产品的已支付订单)
|
||||||
let buyerUserId = userId
|
|
||||||
if (!buyerUserId && openId) {
|
|
||||||
try {
|
|
||||||
const users = await query('SELECT id FROM users WHERE open_id = ?', [openId]) as any[]
|
|
||||||
if (users.length > 0) {
|
|
||||||
buyerUserId = users[0].id
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[PayNotify] 获取用户信息失败:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 更新用户购买记录(✅ 检查是否已有其他相同产品的已支付订单)
|
|
||||||
if (buyerUserId && productType) {
|
if (buyerUserId && productType) {
|
||||||
try {
|
try {
|
||||||
if (productType === 'fullbook') {
|
if (productType === 'fullbook') {
|
||||||
@@ -256,7 +289,7 @@ export async function POST(request: Request) {
|
|||||||
console.error('[PayNotify] ❌ 更新用户购买记录失败:', e)
|
console.error('[PayNotify] ❌ 更新用户购买记录失败:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 清理相同产品的无效订单(未支付的订单)
|
// 3. 清理相同产品的无效订单(未支付的订单)
|
||||||
if (productType && (productType === 'fullbook' || productId)) {
|
if (productType && (productType === 'fullbook' || productId)) {
|
||||||
try {
|
try {
|
||||||
const deleteResult = await query(`
|
const deleteResult = await query(`
|
||||||
@@ -288,7 +321,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 处理分销佣金(90%给推广者)
|
// 4. 处理分销佣金(90%给推广者)
|
||||||
await processReferralCommission(buyerUserId, totalAmount, orderSn)
|
await processReferralCommission(buyerUserId, totalAmount, orderSn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,17 @@ export async function POST(request: Request) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[MiniPay] 查询推荐人失败,继续创建订单:', e)
|
console.warn('[MiniPay] 查询推荐人失败,继续创建订单:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 下单时使用的邀请码:优先用请求体,否则用推荐人当前邀请码(便于订单记录对账)
|
||||||
|
let orderReferralCode: string | null = body.referralCode ? String(body.referralCode).trim() || null : null
|
||||||
|
if (!orderReferralCode && referrerId) {
|
||||||
|
try {
|
||||||
|
const refRows = (await query(`SELECT referral_code FROM users WHERE id = ? LIMIT 1`, [referrerId]) as any[])
|
||||||
|
if (refRows.length > 0 && refRows[0].referral_code) {
|
||||||
|
orderReferralCode = refRows[0].referral_code
|
||||||
|
}
|
||||||
|
} catch (_) { /* 忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 检查是否已有相同产品的已支付订单
|
// 检查是否已有相同产品的已支付订单
|
||||||
@@ -186,34 +197,55 @@ export async function POST(request: Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 插入订单(含 referrer_id,便于分销归属与统计)
|
// 插入订单(含 referrer_id、referral_code,便于分销归属与对账)
|
||||||
try {
|
try {
|
||||||
await query(`
|
await query(`
|
||||||
INSERT INTO orders (
|
INSERT INTO orders (
|
||||||
id, order_sn, user_id, open_id,
|
id, order_sn, user_id, open_id,
|
||||||
product_type, product_id, amount, description,
|
product_type, product_id, amount, description,
|
||||||
status, transaction_id, referrer_id, created_at, updated_at
|
status, transaction_id, referrer_id, referral_code, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||||
`, [
|
`, [
|
||||||
orderSn, orderSn, userId, openId,
|
orderSn, orderSn, userId, openId,
|
||||||
productType, productId || 'fullbook', amount, goodsBody,
|
productType, productId || 'fullbook', amount, goodsBody,
|
||||||
'created', null, referrerId
|
'created', null, referrerId, orderReferralCode
|
||||||
])
|
])
|
||||||
} catch (insertErr: any) {
|
} catch (insertErr: any) {
|
||||||
// 兼容:若表尚无 referrer_id 列,则用不含该字段的 INSERT
|
// 兼容:若表尚无 referrer_id 或 referral_code 列
|
||||||
if (insertErr?.message?.includes('referrer_id') || insertErr?.code === 'ER_BAD_FIELD_ERROR') {
|
const msg = (insertErr as any)?.message || ''
|
||||||
await query(`
|
const code = (insertErr as any)?.code || ''
|
||||||
INSERT INTO orders (
|
if (msg.includes('referrer_id') || msg.includes('referral_code') || code === 'ER_BAD_FIELD_ERROR') {
|
||||||
id, order_sn, user_id, open_id,
|
try {
|
||||||
product_type, product_id, amount, description,
|
await query(`
|
||||||
status, transaction_id, created_at, updated_at
|
INSERT INTO orders (
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
id, order_sn, user_id, open_id,
|
||||||
`, [
|
product_type, product_id, amount, description,
|
||||||
orderSn, orderSn, userId, openId,
|
status, transaction_id, referrer_id, created_at, updated_at
|
||||||
productType, productId || 'fullbook', amount, goodsBody,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||||
'created', null
|
`, [
|
||||||
])
|
orderSn, orderSn, userId, openId,
|
||||||
console.log('[MiniPay] 订单已插入(未含 referrer_id,请执行 scripts/add_orders_referrer_id.py)')
|
productType, productId || 'fullbook', amount, goodsBody,
|
||||||
|
'created', null, referrerId
|
||||||
|
])
|
||||||
|
console.log('[MiniPay] 订单已插入(未含 referral_code,请执行 scripts/add_orders_referral_code.py)')
|
||||||
|
} catch (e2: any) {
|
||||||
|
if (e2?.message?.includes('referrer_id') || e2?.code === 'ER_BAD_FIELD_ERROR') {
|
||||||
|
await query(`
|
||||||
|
INSERT INTO orders (
|
||||||
|
id, order_sn, user_id, open_id,
|
||||||
|
product_type, product_id, amount, description,
|
||||||
|
status, transaction_id, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||||
|
`, [
|
||||||
|
orderSn, orderSn, userId, openId,
|
||||||
|
productType, productId || 'fullbook', amount, goodsBody,
|
||||||
|
'created', null
|
||||||
|
])
|
||||||
|
console.log('[MiniPay] 订单已插入(未含 referrer_id/referral_code,请执行迁移脚本)')
|
||||||
|
} else {
|
||||||
|
throw e2
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw insertErr
|
throw insertErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ function rowToOrder(row: Record<string, unknown>) {
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
transactionId: row.transaction_id,
|
transactionId: row.transaction_id,
|
||||||
payTime: row.pay_time,
|
payTime: row.pay_time,
|
||||||
|
referrerId: row.referrer_id ?? null,
|
||||||
|
referralCode: row.referral_code ?? null,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export async function initDatabase() {
|
|||||||
transaction_id VARCHAR(100),
|
transaction_id VARCHAR(100),
|
||||||
pay_time TIMESTAMP NULL,
|
pay_time TIMESTAMP NULL,
|
||||||
referrer_id VARCHAR(50) NULL COMMENT '推荐人用户ID,用于分销归属',
|
referrer_id VARCHAR(50) NULL COMMENT '推荐人用户ID,用于分销归属',
|
||||||
|
referral_code VARCHAR(20) NULL COMMENT '下单时使用的邀请码,便于对账与展示',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ App({
|
|||||||
// 保存待绑定的推荐码(不再在前端做"只能绑定一次"的限制,让后端根据30天规则判断续期/抢夺)
|
// 保存待绑定的推荐码(不再在前端做"只能绑定一次"的限制,让后端根据30天规则判断续期/抢夺)
|
||||||
this.globalData.pendingReferralCode = refCode
|
this.globalData.pendingReferralCode = refCode
|
||||||
wx.setStorageSync('pendingReferralCode', refCode)
|
wx.setStorageSync('pendingReferralCode', refCode)
|
||||||
|
// 同步写入 referral_code,供章节/找伙伴支付时传给后端,订单会记录 referrer_id 与 referral_code
|
||||||
|
wx.setStorageSync('referral_code', refCode)
|
||||||
|
|
||||||
// 如果已登录,立即尝试绑定,由 /api/referral/bind 按 30 天规则决定 new / renew / takeover
|
// 如果已登录,立即尝试绑定,由 /api/referral/bind 按 30 天规则决定 new / renew / takeover
|
||||||
if (this.globalData.isLoggedIn && this.globalData.userInfo) {
|
if (this.globalData.isLoggedIn && this.globalData.userInfo) {
|
||||||
@@ -354,6 +356,20 @@ App({
|
|||||||
if (res.success && res.data?.openId) {
|
if (res.success && res.data?.openId) {
|
||||||
this.globalData.openId = res.data.openId
|
this.globalData.openId = res.data.openId
|
||||||
wx.setStorageSync('openId', res.data.openId)
|
wx.setStorageSync('openId', res.data.openId)
|
||||||
|
// 接口同时返回 user 时视为登录,补全登录态并从登录开始绑定推荐码
|
||||||
|
if (res.data.user) {
|
||||||
|
this.globalData.userInfo = res.data.user
|
||||||
|
this.globalData.isLoggedIn = true
|
||||||
|
this.globalData.purchasedSections = res.data.user.purchasedSections || []
|
||||||
|
this.globalData.hasFullBook = res.data.user.hasFullBook || false
|
||||||
|
wx.setStorageSync('userInfo', res.data.user)
|
||||||
|
wx.setStorageSync('token', res.data.token || '')
|
||||||
|
const pendingRef = wx.getStorageSync('pendingReferralCode') || this.globalData.pendingReferralCode
|
||||||
|
if (pendingRef) {
|
||||||
|
console.log('[App] getOpenId 登录后自动绑定推荐码:', pendingRef)
|
||||||
|
this.bindReferralCode(pendingRef)
|
||||||
|
}
|
||||||
|
}
|
||||||
return res.data.openId
|
return res.data.openId
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -591,6 +591,8 @@ Page({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 邀请码:与章节支付一致,写入订单便于分销归属与对账
|
||||||
|
const referralCode = wx.getStorageSync('referral_code') || ''
|
||||||
// 调用支付接口购买匹配次数
|
// 调用支付接口购买匹配次数
|
||||||
const res = await app.request('/api/miniprogram/pay', {
|
const res = await app.request('/api/miniprogram/pay', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -600,7 +602,8 @@ Page({
|
|||||||
productId: 'match_1',
|
productId: 'match_1',
|
||||||
amount: 1,
|
amount: 1,
|
||||||
description: '匹配次数x1',
|
description: '匹配次数x1',
|
||||||
userId: app.globalData.userInfo?.id || ''
|
userId: app.globalData.userInfo?.id || '',
|
||||||
|
referralCode: referralCode || undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -91,8 +91,8 @@ Page({
|
|||||||
const config = await accessManager.fetchLatestConfig()
|
const config = await accessManager.fetchLatestConfig()
|
||||||
this.setData({
|
this.setData({
|
||||||
freeIds: config.freeChapters,
|
freeIds: config.freeChapters,
|
||||||
sectionPrice: config.prices.section,
|
sectionPrice: config.prices?.section ?? 1,
|
||||||
fullBookPrice: config.prices.fullbook
|
fullBookPrice: config.prices?.fullbook ?? 9.9
|
||||||
})
|
})
|
||||||
|
|
||||||
// 【标准流程】2. 确定权限状态
|
// 【标准流程】2. 确定权限状态
|
||||||
@@ -162,6 +162,10 @@ Page({
|
|||||||
async loadContent(id, accessState) {
|
async loadContent(id, accessState) {
|
||||||
try {
|
try {
|
||||||
const section = this.getSectionInfo(id)
|
const section = this.getSectionInfo(id)
|
||||||
|
const sectionPrice = this.data.sectionPrice ?? 1
|
||||||
|
if (section.price === undefined || section.price === null) {
|
||||||
|
section.price = sectionPrice
|
||||||
|
}
|
||||||
this.setData({ section })
|
this.setData({ section })
|
||||||
|
|
||||||
// 从 API 获取内容
|
// 从 API 获取内容
|
||||||
@@ -689,7 +693,7 @@ Page({
|
|||||||
? '《一场Soul的创业实验》全书'
|
? '《一场Soul的创业实验》全书'
|
||||||
: `章节${sectionId}-${sectionTitle.length > 20 ? sectionTitle.slice(0, 20) + '...' : sectionTitle}`
|
: `章节${sectionId}-${sectionTitle.length > 20 ? sectionTitle.slice(0, 20) + '...' : sectionTitle}`
|
||||||
|
|
||||||
// 邀请码:谁邀请了我(从落地页 ref 或 storage 带入),用于订单分销归属
|
// 邀请码:谁邀请了我(从落地页 ref 或 storage 带入),会写入订单 referrer_id / referral_code 便于分销与对账
|
||||||
const referralCode = wx.getStorageSync('referral_code') || ''
|
const referralCode = wx.getStorageSync('referral_code') || ''
|
||||||
const res = await app.request('/api/miniprogram/pay', {
|
const res = await app.request('/api/miniprogram/pay', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -166,7 +166,7 @@
|
|||||||
<!-- 购买本章 - 直接调起支付 -->
|
<!-- 购买本章 - 直接调起支付 -->
|
||||||
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
|
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
|
||||||
<text class="btn-label">购买本章</text>
|
<text class="btn-label">购买本章</text>
|
||||||
<text class="btn-price brand-color">¥{{section.price}}</text>
|
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 解锁全书 - 只有购买超过3章才显示 -->
|
<!-- 解锁全书 - 只有购买超过3章才显示 -->
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
|
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="btn-right">
|
<view class="btn-right">
|
||||||
<text class="btn-price">¥{{fullBookPrice}}</text>
|
<text class="btn-price">¥{{fullBookPrice || 9.9}}</text>
|
||||||
<text class="btn-discount">省82%</text>
|
<text class="btn-discount">省82%</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
46
scripts/check_chunks.py
Normal file
46
scripts/check_chunks.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect('42.194.232.22', port=22022, username='root', password='Zhiqun1984', timeout=15)
|
||||||
|
|
||||||
|
print("=== 检查 chunks 目录文件(前20个)===")
|
||||||
|
cmd = "ls -la /www/wwwroot/soul/.next/static/chunks/ 2>/dev/null | head -25"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result if result else "目录不存在")
|
||||||
|
|
||||||
|
print("\n=== 是否有 turbopack 文件 ===")
|
||||||
|
cmd = "find /www/wwwroot/soul/.next/static -name '*turbopack*' 2>/dev/null"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result if result else "无 turbopack 文件(正常,这是生产模式)")
|
||||||
|
|
||||||
|
print("\n=== 检查请求的具体文件 ===")
|
||||||
|
files_to_check = [
|
||||||
|
"a954454d2ab1d3ca.css",
|
||||||
|
"6a98f5c6b2554ef3.js",
|
||||||
|
"turbopack-0d89ab930ad9d74d.js",
|
||||||
|
]
|
||||||
|
for f in files_to_check:
|
||||||
|
cmd = "find /www/wwwroot/soul/.next/static -name '%s' 2>/dev/null" % f
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace').strip()
|
||||||
|
status = "[OK] 存在" if result else "[X] 不存在"
|
||||||
|
print("%s: %s" % (f, status))
|
||||||
|
|
||||||
|
print("\n=== 检查实际可用的 css 文件 ===")
|
||||||
|
cmd = "ls /www/wwwroot/soul/.next/static/css/ 2>/dev/null | head -10"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result if result else "无 css 文件")
|
||||||
|
|
||||||
|
print("\n=== 构建模式检查 ===")
|
||||||
|
cmd = "head -5 /www/wwwroot/soul/.next/BUILD_ID 2>/dev/null"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print("BUILD_ID: %s" % (result if result else "不存在"))
|
||||||
|
|
||||||
|
client.close()
|
||||||
24
scripts/check_nginx.py
Normal file
24
scripts/check_nginx.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect('42.194.232.22', port=22022, username='root', password='Zhiqun1984', timeout=15)
|
||||||
|
|
||||||
|
print("=== soul.quwanzhi.com.conf ===")
|
||||||
|
stdin, stdout, stderr = client.exec_command('cat /www/server/panel/vhost/nginx/soul.quwanzhi.com.conf 2>/dev/null', timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result if result else "文件不存在")
|
||||||
|
|
||||||
|
print("\n=== 检查 include 配置 ===")
|
||||||
|
stdin, stdout, stderr = client.exec_command('ls -la /www/server/panel/vhost/nginx/ | grep soul', timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result if result else "无 soul 相关配置")
|
||||||
|
|
||||||
|
print("\n=== node_soul.conf ===")
|
||||||
|
stdin, stdout, stderr = client.exec_command('cat /www/server/panel/vhost/nginx/node_soul.conf 2>/dev/null', timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result if result else "文件不存在")
|
||||||
|
|
||||||
|
client.close()
|
||||||
41
scripts/check_server_logs.py
Normal file
41
scripts/check_server_logs.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect('42.194.232.22', port=22022, username='root', password='Zhiqun1984', timeout=15)
|
||||||
|
|
||||||
|
print("=== PM2 soul 日志(最后 50 行)===")
|
||||||
|
cmd = "pm2 logs soul --lines 50 --nostream 2>&1 | tail -50"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
result = result.encode('ascii', errors='replace').decode('ascii')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
print("\n=== PM2 soul 错误日志 ===")
|
||||||
|
cmd = "pm2 logs soul --err --lines 30 --nostream 2>&1 | tail -30"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
result = result.encode('ascii', errors='replace').decode('ascii')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
print("\n=== 检查 server.js 文件 ===")
|
||||||
|
cmd = "ls -lh /www/wwwroot/soul/server.js"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
print("\n=== 检查 .next 目录结构 ===")
|
||||||
|
cmd = "ls -lh /www/wwwroot/soul/.next/ | head -20"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
print("\n=== 检查端口 30006 ===")
|
||||||
|
cmd = "curl -I http://127.0.0.1:30006 2>&1 | head -10"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
client.close()
|
||||||
102
scripts/check_static_files.py
Normal file
102
scripts/check_static_files.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
快速检查服务器上静态资源是否存在
|
||||||
|
用于排查管理端 404 问题
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
import paramiko
|
||||||
|
except ImportError:
|
||||||
|
print("请安装: pip install paramiko")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 配置(与 devlop.py 一致)
|
||||||
|
DEPLOY_PROJECT_PATH = os.environ.get("DEPLOY_PROJECT_PATH", "/www/wwwroot/soul")
|
||||||
|
DEVLOP_DIST_PATH = "/www/wwwroot/auto-devlop/soul/dist"
|
||||||
|
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
|
||||||
|
|
||||||
|
def get_cfg():
|
||||||
|
return {
|
||||||
|
"host": os.environ.get("DEPLOY_HOST", "42.194.232.22"),
|
||||||
|
"user": os.environ.get("DEPLOY_USER", "root"),
|
||||||
|
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
|
||||||
|
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
|
||||||
|
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
|
||||||
|
}
|
||||||
|
|
||||||
|
def check_static_files():
|
||||||
|
cfg = get_cfg()
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("正在连接服务器...")
|
||||||
|
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||||
|
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
|
||||||
|
else:
|
||||||
|
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
|
||||||
|
|
||||||
|
print("\n=== 检查静态资源目录 ===")
|
||||||
|
|
||||||
|
# 检查多个可能的路径(deploy 模式和 devlop 模式)
|
||||||
|
checks = [
|
||||||
|
("%s/.next/static" % DEVLOP_DIST_PATH, "devlop 模式 dist 目录"),
|
||||||
|
("%s/.next/static" % DEPLOY_PROJECT_PATH, "deploy 模式项目目录"),
|
||||||
|
("%s/server.js" % DEVLOP_DIST_PATH, "devlop server.js"),
|
||||||
|
("%s/server.js" % DEPLOY_PROJECT_PATH, "deploy server.js"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for path, desc in checks:
|
||||||
|
# 检查文件或目录是否存在
|
||||||
|
cmd = "test -e '%s' && echo 'EXISTS' || echo 'NOT_FOUND'" % path
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode("utf-8", errors="replace").strip()
|
||||||
|
status = "[OK]" if "EXISTS" in result else "[X]"
|
||||||
|
print("%s %s" % (status, desc))
|
||||||
|
print(" 路径: %s" % path)
|
||||||
|
|
||||||
|
if "EXISTS" in result and "static" in path:
|
||||||
|
# 列出文件数量
|
||||||
|
cmd2 = "find '%s' -type f 2>/dev/null | wc -l" % path
|
||||||
|
stdin2, stdout2, stderr2 = client.exec_command(cmd2, timeout=10)
|
||||||
|
file_count = stdout2.read().decode("utf-8", errors="replace").strip()
|
||||||
|
print(" 文件数: %s" % file_count)
|
||||||
|
|
||||||
|
print("\n=== 检查 PM2 项目配置 ===")
|
||||||
|
cmd = "pm2 describe soul 2>/dev/null | grep -E 'cwd|script|status' | head -5 || echo 'PM2 soul 不存在'"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
pm2_info = stdout.read().decode("utf-8", errors="replace").strip()
|
||||||
|
print(pm2_info)
|
||||||
|
|
||||||
|
print("\n=== 检查端口监听 ===")
|
||||||
|
cmd = "ss -tlnp | grep 30006 || echo '端口 30006 未监听'"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
port_info = stdout.read().decode("utf-8", errors="replace").strip()
|
||||||
|
print(port_info)
|
||||||
|
|
||||||
|
print("\n=== 检查 Nginx 反向代理 ===")
|
||||||
|
cmd = "grep -r 'proxy_pass' /www/server/panel/vhost/nginx/*soul* 2>/dev/null | head -3 || echo '未找到 soul Nginx 配置'"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
nginx_info = stdout.read().decode("utf-8", errors="replace").strip()
|
||||||
|
print(nginx_info)
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("诊断建议:")
|
||||||
|
print("1. devlop 模式部署后,PM2 的 cwd 应为: %s" % DEVLOP_DIST_PATH)
|
||||||
|
print("2. .next/static 必须在 PM2 的 cwd 目录下")
|
||||||
|
print("3. Nginx 必须整站反代(location /),不能只反代 /api")
|
||||||
|
print("4. 浏览器强刷: Ctrl+Shift+R 清除缓存")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print("错误: %s" % str(e))
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
check_static_files()
|
||||||
@@ -36,7 +36,7 @@ except ImportError:
|
|||||||
|
|
||||||
# 端口统一从环境变量 DEPLOY_PORT 读取,未设置时使用此默认值
|
# 端口统一从环境变量 DEPLOY_PORT 读取,未设置时使用此默认值
|
||||||
DEPLOY_PM2_APP = "soul"
|
DEPLOY_PM2_APP = "soul"
|
||||||
DEFAULT_DEPLOY_PORT = 30006
|
DEFAULT_DEPLOY_PORT = 3888
|
||||||
DEPLOY_PROJECT_PATH = "/www/wwwroot/soul"
|
DEPLOY_PROJECT_PATH = "/www/wwwroot/soul"
|
||||||
DEPLOY_SITE_URL = "https://soul.quwanzhi.com"
|
DEPLOY_SITE_URL = "https://soul.quwanzhi.com"
|
||||||
# SSH 端口(支持环境变量 DEPLOY_SSH_PORT,未设置时默认为 22022)
|
# SSH 端口(支持环境变量 DEPLOY_SSH_PORT,未设置时默认为 22022)
|
||||||
@@ -61,7 +61,9 @@ def get_cfg():
|
|||||||
|
|
||||||
|
|
||||||
def get_cfg_devlop():
|
def get_cfg_devlop():
|
||||||
"""devlop 模式配置:在基础配置上增加 base_path / dist / dist2"""
|
"""devlop 模式配置:在基础配置上增加 base_path / dist / dist2。
|
||||||
|
实际运行目录为 dist_path(切换后新版本在 dist),宝塔 PM2 项目路径必须指向 dist_path,
|
||||||
|
否则会从错误目录启动导致 .next/static 等静态资源 404。"""
|
||||||
cfg = get_cfg().copy()
|
cfg = get_cfg().copy()
|
||||||
cfg["base_path"] = os.environ.get("DEVOP_BASE_PATH", "/www/wwwroot/auto-devlop/soul")
|
cfg["base_path"] = os.environ.get("DEVOP_BASE_PATH", "/www/wwwroot/auto-devlop/soul")
|
||||||
cfg["dist_path"] = cfg["base_path"] + "/dist"
|
cfg["dist_path"] = cfg["base_path"] + "/dist"
|
||||||
@@ -269,6 +271,10 @@ def pack_standalone_tar(root):
|
|||||||
if not os.path.isdir(standalone) or not os.path.isdir(static_src):
|
if not os.path.isdir(standalone) or not os.path.isdir(static_src):
|
||||||
print(" [失败] 未找到 .next/standalone 或 .next/static")
|
print(" [失败] 未找到 .next/standalone 或 .next/static")
|
||||||
return None
|
return None
|
||||||
|
chunks_dir = os.path.join(static_src, "chunks")
|
||||||
|
if not os.path.isdir(chunks_dir):
|
||||||
|
print(" [失败] .next/static/chunks 不存在,请先完整执行 pnpm build(本地 pnpm start 能正常打开页面后再部署)")
|
||||||
|
return None
|
||||||
|
|
||||||
staging = tempfile.mkdtemp(prefix="soul_deploy_")
|
staging = tempfile.mkdtemp(prefix="soul_deploy_")
|
||||||
try:
|
try:
|
||||||
@@ -291,6 +297,13 @@ def pack_standalone_tar(root):
|
|||||||
shutil.rmtree(static_dst)
|
shutil.rmtree(static_dst)
|
||||||
os.makedirs(os.path.dirname(static_dst), exist_ok=True)
|
os.makedirs(os.path.dirname(static_dst), exist_ok=True)
|
||||||
shutil.copytree(static_src, static_dst)
|
shutil.copytree(static_src, static_dst)
|
||||||
|
# 同步构建索引,避免 server 用错 BUILD_ID/manifest 导致静态 404
|
||||||
|
next_root = os.path.join(root, ".next")
|
||||||
|
next_staging = os.path.join(staging, ".next")
|
||||||
|
for name in ["BUILD_ID", "build-manifest.json", "app-path-routes-manifest.json", "routes-manifest.json"]:
|
||||||
|
src = os.path.join(next_root, name)
|
||||||
|
if os.path.isfile(src):
|
||||||
|
shutil.copy2(src, os.path.join(next_staging, name))
|
||||||
if os.path.isdir(public_src):
|
if os.path.isdir(public_src):
|
||||||
shutil.copytree(public_src, os.path.join(staging, "public"), dirs_exist_ok=True)
|
shutil.copytree(public_src, os.path.join(staging, "public"), dirs_exist_ok=True)
|
||||||
if os.path.isfile(ecosystem_src):
|
if os.path.isfile(ecosystem_src):
|
||||||
@@ -410,7 +423,7 @@ def deploy_via_baota_api(cfg):
|
|||||||
|
|
||||||
# ==================== 打包(devlop 模式:zip) ====================
|
# ==================== 打包(devlop 模式:zip) ====================
|
||||||
|
|
||||||
ZIP_EXCLUDE_DIRS = {".cache", "__pycache__", ".git", "node_modules", "cache", "test", "tests", "coverage", ".nyc_output", ".turbo", "开发文档", "miniprogramPre", "my-app", "newpp"}
|
ZIP_EXCLUDE_DIRS = {".cache", "__pycache__", ".git", "node_modules", "cache", "test", "tests", "coverage", ".nyc_output", ".turbo", "开发文档"}
|
||||||
ZIP_EXCLUDE_FILE_NAMES = {".DS_Store", "Thumbs.db"}
|
ZIP_EXCLUDE_FILE_NAMES = {".DS_Store", "Thumbs.db"}
|
||||||
ZIP_EXCLUDE_FILE_SUFFIXES = (".log", ".map")
|
ZIP_EXCLUDE_FILE_SUFFIXES = (".log", ".map")
|
||||||
|
|
||||||
@@ -438,6 +451,10 @@ def pack_standalone_zip(root):
|
|||||||
if not os.path.isdir(standalone) or not os.path.isdir(static_src):
|
if not os.path.isdir(standalone) or not os.path.isdir(static_src):
|
||||||
print(" [失败] 未找到 .next/standalone 或 .next/static")
|
print(" [失败] 未找到 .next/standalone 或 .next/static")
|
||||||
return None
|
return None
|
||||||
|
chunks_dir = os.path.join(static_src, "chunks")
|
||||||
|
if not os.path.isdir(chunks_dir):
|
||||||
|
print(" [失败] .next/static/chunks 不存在,请先完整执行 pnpm build(本地 pnpm start 能正常打开页面后再部署)")
|
||||||
|
return None
|
||||||
|
|
||||||
staging = tempfile.mkdtemp(prefix="soul_devlop_")
|
staging = tempfile.mkdtemp(prefix="soul_devlop_")
|
||||||
try:
|
try:
|
||||||
@@ -457,6 +474,13 @@ def pack_standalone_zip(root):
|
|||||||
break
|
break
|
||||||
os.makedirs(os.path.join(staging, ".next"), exist_ok=True)
|
os.makedirs(os.path.join(staging, ".next"), exist_ok=True)
|
||||||
shutil.copytree(static_src, os.path.join(staging, ".next", "static"), dirs_exist_ok=True)
|
shutil.copytree(static_src, os.path.join(staging, ".next", "static"), dirs_exist_ok=True)
|
||||||
|
# 同步构建索引,避免 server 用错 BUILD_ID/manifest 导致静态 404
|
||||||
|
next_root = os.path.join(root, ".next")
|
||||||
|
next_staging = os.path.join(staging, ".next")
|
||||||
|
for name in ["BUILD_ID", "build-manifest.json", "app-path-routes-manifest.json", "routes-manifest.json"]:
|
||||||
|
src = os.path.join(next_root, name)
|
||||||
|
if os.path.isfile(src):
|
||||||
|
shutil.copy2(src, os.path.join(next_staging, name))
|
||||||
if os.path.isdir(public_src):
|
if os.path.isdir(public_src):
|
||||||
shutil.copytree(public_src, os.path.join(staging, "public"), dirs_exist_ok=True)
|
shutil.copytree(public_src, os.path.join(staging, "public"), dirs_exist_ok=True)
|
||||||
if os.path.isfile(ecosystem_src):
|
if os.path.isfile(ecosystem_src):
|
||||||
@@ -599,7 +623,7 @@ def run_pnpm_install_in_dist2(cfg):
|
|||||||
|
|
||||||
|
|
||||||
def remote_swap_dist_and_restart(cfg):
|
def remote_swap_dist_and_restart(cfg):
|
||||||
"""暂停 → dist→dist1, dist2→dist → 删除 dist1 → 重启(devlop 模式)"""
|
"""暂停 → dist→dist1, dist2→dist → 删除 dist1 → 更新 PM2 项目路径 → 重启(devlop 模式)"""
|
||||||
print("[5/7] 宝塔 API 暂停 Node 项目 ...")
|
print("[5/7] 宝塔 API 暂停 Node 项目 ...")
|
||||||
stop_node_project(cfg["panel_url"], cfg["api_key"], cfg["pm2_name"])
|
stop_node_project(cfg["panel_url"], cfg["api_key"], cfg["pm2_name"])
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
@@ -620,9 +644,16 @@ def remote_swap_dist_and_restart(cfg):
|
|||||||
print(" [成功] 新版本位于 %s" % cfg["dist_path"])
|
print(" [成功] 新版本位于 %s" % cfg["dist_path"])
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
print("[7/7] 宝塔 API 重启 Node 项目 ...")
|
# 关键:devlop 实际运行目录是 dist_path,必须让宝塔 PM2 从该目录启动,否则会从错误目录跑导致静态资源 404
|
||||||
|
print("[7/7] 更新宝塔 Node 项目路径并重启 ...")
|
||||||
|
add_or_update_node_project(
|
||||||
|
cfg["panel_url"], cfg["api_key"], cfg["pm2_name"],
|
||||||
|
cfg["dist_path"], # 使用 dist_path,不是 project_path
|
||||||
|
port=cfg["port"],
|
||||||
|
node_path=cfg.get("node_path"),
|
||||||
|
)
|
||||||
if not start_node_project(cfg["panel_url"], cfg["api_key"], cfg["pm2_name"]):
|
if not start_node_project(cfg["panel_url"], cfg["api_key"], cfg["pm2_name"]):
|
||||||
print(" [警告] 请到宝塔手动启动 %s" % cfg["pm2_name"])
|
print(" [警告] 请到宝塔手动启动 %s,并确认项目路径为: %s" % (cfg["pm2_name"], cfg["dist_path"]))
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
729
scripts/devlopTest.py
Normal file
729
scripts/devlopTest.py
Normal file
@@ -0,0 +1,729 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from __future__ import print_function
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
import tarfile
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
try:
|
||||||
|
import paramiko
|
||||||
|
except ImportError:
|
||||||
|
print("错误: 请先安装 paramiko")
|
||||||
|
print(" pip install paramiko")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
except ImportError:
|
||||||
|
print("错误: 请先安装 requests")
|
||||||
|
print(" pip install requests")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 配置 ====================
|
||||||
|
|
||||||
|
# 端口统一从环境变量 DEPLOY_PORT 读取,未设置时使用此默认值
|
||||||
|
DEPLOY_PM2_APP = "testsoul"
|
||||||
|
DEFAULT_DEPLOY_PORT = 30006
|
||||||
|
DEPLOY_PROJECT_PATH = "/www/wwwroot/soul"
|
||||||
|
DEPLOY_SITE_URL = "https://soul.quwanzhi.com"
|
||||||
|
# SSH 端口(支持环境变量 DEPLOY_SSH_PORT,未设置时默认为 22022)
|
||||||
|
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
|
||||||
|
|
||||||
|
def get_cfg():
|
||||||
|
"""获取基础部署配置(deploy 模式与 devlop 共用 SSH/宝塔)"""
|
||||||
|
return {
|
||||||
|
"host": os.environ.get("DEPLOY_HOST", "42.194.232.22"),
|
||||||
|
"user": os.environ.get("DEPLOY_USER", "root"),
|
||||||
|
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
|
||||||
|
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
|
||||||
|
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
|
||||||
|
"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", DEPLOY_PM2_APP),
|
||||||
|
"site_url": os.environ.get("DEPLOY_SITE_URL", DEPLOY_SITE_URL),
|
||||||
|
"port": int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT))),
|
||||||
|
"node_version": os.environ.get("DEPLOY_NODE_VERSION", "v22.14.0"),
|
||||||
|
"node_path": os.environ.get("DEPLOY_NODE_PATH", "/www/server/nodejs/v22.14.0/bin"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_cfg_devlop():
|
||||||
|
"""devlop 模式配置:在基础配置上增加 base_path / dist / dist2。
|
||||||
|
实际运行目录为 dist_path(切换后新版本在 dist),宝塔 PM2 项目路径必须指向 dist_path,
|
||||||
|
否则会从错误目录启动导致 .next/static 等静态资源 404。"""
|
||||||
|
cfg = get_cfg().copy()
|
||||||
|
cfg["base_path"] = os.environ.get("DEVOP_BASE_PATH", "/www/wwwroot/auto-devlop/soulTest")
|
||||||
|
cfg["dist_path"] = cfg["base_path"] + "/dist"
|
||||||
|
cfg["dist2_path"] = cfg["base_path"] + "/dist2"
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 宝塔 API ====================
|
||||||
|
|
||||||
|
def _get_sign(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 _baota_request(panel_url, api_key, path, data=None):
|
||||||
|
req_time, req_token = _get_sign(api_key)
|
||||||
|
payload = {"request_time": req_time, "request_token": req_token}
|
||||||
|
if data:
|
||||||
|
payload.update(data)
|
||||||
|
url = panel_url.rstrip("/") + "/" + path.lstrip("/")
|
||||||
|
try:
|
||||||
|
r = requests.post(url, data=payload, verify=False, timeout=30)
|
||||||
|
return r.json() if r.text else {}
|
||||||
|
except Exception as e:
|
||||||
|
print(" API 请求失败: %s" % str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_project_list(panel_url, api_key):
|
||||||
|
for path in ["/project/nodejs/get_project_list", "/plugin?action=a&name=nodejs&s=get_project_list"]:
|
||||||
|
result = _baota_request(panel_url, api_key, path)
|
||||||
|
if result and (result.get("status") is True or "data" in result):
|
||||||
|
return result.get("data", [])
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_project_status(panel_url, api_key, pm2_name):
|
||||||
|
projects = get_node_project_list(panel_url, api_key)
|
||||||
|
if projects:
|
||||||
|
for p in projects:
|
||||||
|
if p.get("name") == pm2_name:
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def start_node_project(panel_url, api_key, pm2_name):
|
||||||
|
for path in ["/project/nodejs/start_project", "/plugin?action=a&name=nodejs&s=start_project"]:
|
||||||
|
result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name})
|
||||||
|
if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)):
|
||||||
|
print(" [成功] 启动成功: %s" % pm2_name)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def stop_node_project(panel_url, api_key, pm2_name):
|
||||||
|
for path in ["/project/nodejs/stop_project", "/plugin?action=a&name=nodejs&s=stop_project"]:
|
||||||
|
result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name})
|
||||||
|
if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)):
|
||||||
|
print(" [成功] 停止成功: %s" % pm2_name)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def restart_node_project(panel_url, api_key, pm2_name):
|
||||||
|
project_status = get_node_project_status(panel_url, api_key, pm2_name)
|
||||||
|
if project_status:
|
||||||
|
print(" 项目状态: %s" % project_status.get("status", "未知"))
|
||||||
|
for path in ["/project/nodejs/restart_project", "/plugin?action=a&name=nodejs&s=restart_project"]:
|
||||||
|
result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name})
|
||||||
|
if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)):
|
||||||
|
print(" [成功] 重启成功: %s" % pm2_name)
|
||||||
|
return True
|
||||||
|
if result and "msg" in result:
|
||||||
|
print(" API 返回: %s" % result.get("msg"))
|
||||||
|
print(" [警告] 重启失败,请检查宝塔 Node 插件是否安装、API 密钥是否正确")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port=None, node_path=None):
|
||||||
|
if port is None:
|
||||||
|
port = int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT)))
|
||||||
|
port_env = "PORT=%d " % port
|
||||||
|
run_cmd = port_env + ("%s/node server.js" % node_path if node_path else "node server.js")
|
||||||
|
payload = {"name": pm2_name, "path": project_path, "run_cmd": run_cmd, "port": str(port)}
|
||||||
|
for path in ["/project/nodejs/add_project", "/plugin?action=a&name=nodejs&s=add_project"]:
|
||||||
|
result = _baota_request(panel_url, api_key, path, payload)
|
||||||
|
if result and result.get("status") is True:
|
||||||
|
print(" [成功] 项目配置已更新: %s" % pm2_name)
|
||||||
|
return True
|
||||||
|
if result and "msg" in result:
|
||||||
|
print(" API 返回: %s" % result.get("msg"))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 本地构建 ====================
|
||||||
|
|
||||||
|
def run_build(root):
|
||||||
|
"""执行本地 pnpm build"""
|
||||||
|
use_shell = sys.platform == "win32"
|
||||||
|
standalone = os.path.join(root, ".next", "standalone")
|
||||||
|
server_js = os.path.join(standalone, "server.js")
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["pnpm", "build"],
|
||||||
|
cwd=root,
|
||||||
|
shell=use_shell,
|
||||||
|
timeout=600,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
)
|
||||||
|
stdout_text = r.stdout or ""
|
||||||
|
stderr_text = r.stderr or ""
|
||||||
|
combined = stdout_text + stderr_text
|
||||||
|
is_windows_symlink_error = (
|
||||||
|
sys.platform == "win32"
|
||||||
|
and r.returncode != 0
|
||||||
|
and ("EPERM" in combined or "symlink" in combined.lower() or "operation not permitted" in combined.lower() or "errno: -4048" in combined)
|
||||||
|
)
|
||||||
|
|
||||||
|
if r.returncode != 0:
|
||||||
|
if is_windows_symlink_error:
|
||||||
|
print(" [警告] Windows 符号链接权限错误(EPERM)")
|
||||||
|
print(" 解决方案:开启开发者模式 / 以管理员运行 / 或使用 --no-build")
|
||||||
|
if os.path.isdir(standalone) and os.path.isfile(server_js):
|
||||||
|
print(" [成功] standalone 输出可用,继续部署")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
print(" [失败] 构建失败,退出码:", r.returncode)
|
||||||
|
for line in (stdout_text.strip().split("\n") or [])[-10:]:
|
||||||
|
print(" " + line)
|
||||||
|
return False
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print(" [失败] 构建超时(超过10分钟)")
|
||||||
|
return False
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(" [失败] 未找到 pnpm,请安装: npm install -g pnpm")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(" [失败] 构建异常:", str(e))
|
||||||
|
if os.path.isdir(standalone) and os.path.isfile(server_js):
|
||||||
|
print(" [提示] 可尝试使用 --no-build 跳过构建")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not os.path.isdir(standalone) or not os.path.isfile(server_js):
|
||||||
|
print(" [失败] 未找到 .next/standalone 或 server.js")
|
||||||
|
return False
|
||||||
|
print(" [成功] 构建完成")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def clean_standalone_before_build(root, retries=3, delay=2):
|
||||||
|
"""构建前删除 .next/standalone,避免 Windows EBUSY"""
|
||||||
|
standalone = os.path.join(root, ".next", "standalone")
|
||||||
|
if not os.path.isdir(standalone):
|
||||||
|
return True
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
shutil.rmtree(standalone)
|
||||||
|
print(" [清理] 已删除 .next/standalone(第 %d 次尝试)" % attempt)
|
||||||
|
return True
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
if attempt < retries:
|
||||||
|
print(" [清理] 被占用,%ds 后重试 (%d/%d) ..." % (delay, attempt, retries))
|
||||||
|
time.sleep(delay)
|
||||||
|
else:
|
||||||
|
print(" [失败] 无法删除 .next/standalone,可改用 --no-build")
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 打包(deploy 模式:tar.gz) ====================
|
||||||
|
|
||||||
|
def _copy_with_dereference(src, dst):
|
||||||
|
if os.path.islink(src):
|
||||||
|
link_target = os.readlink(src)
|
||||||
|
real_path = link_target if os.path.isabs(link_target) else os.path.join(os.path.dirname(src), link_target)
|
||||||
|
if os.path.exists(real_path):
|
||||||
|
if os.path.isdir(real_path):
|
||||||
|
shutil.copytree(real_path, dst, symlinks=False, dirs_exist_ok=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(real_path, dst)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src, dst, follow_symlinks=False)
|
||||||
|
elif os.path.isdir(src):
|
||||||
|
if os.path.exists(dst):
|
||||||
|
shutil.rmtree(dst)
|
||||||
|
shutil.copytree(src, dst, symlinks=False, dirs_exist_ok=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
|
||||||
|
|
||||||
|
def pack_standalone_tar(root):
|
||||||
|
"""打包 standalone 为 tar.gz(deploy 模式用)"""
|
||||||
|
print("[2/4] 打包 standalone ...")
|
||||||
|
standalone = os.path.join(root, ".next", "standalone")
|
||||||
|
static_src = os.path.join(root, ".next", "static")
|
||||||
|
public_src = os.path.join(root, "public")
|
||||||
|
ecosystem_src = os.path.join(root, "ecosystem.config.cjs")
|
||||||
|
|
||||||
|
if not os.path.isdir(standalone) or not os.path.isdir(static_src):
|
||||||
|
print(" [失败] 未找到 .next/standalone 或 .next/static")
|
||||||
|
return None
|
||||||
|
|
||||||
|
staging = tempfile.mkdtemp(prefix="soul_deploy_")
|
||||||
|
try:
|
||||||
|
for name in os.listdir(standalone):
|
||||||
|
_copy_with_dereference(os.path.join(standalone, name), os.path.join(staging, name))
|
||||||
|
node_modules_dst = os.path.join(staging, "node_modules")
|
||||||
|
pnpm_dir = os.path.join(node_modules_dst, ".pnpm")
|
||||||
|
if os.path.isdir(pnpm_dir):
|
||||||
|
for dep in ["styled-jsx"]:
|
||||||
|
dep_in_root = os.path.join(node_modules_dst, dep)
|
||||||
|
if not os.path.exists(dep_in_root):
|
||||||
|
for pnpm_pkg in os.listdir(pnpm_dir):
|
||||||
|
if pnpm_pkg.startswith(dep + "@"):
|
||||||
|
src_dep = os.path.join(pnpm_dir, pnpm_pkg, "node_modules", dep)
|
||||||
|
if os.path.isdir(src_dep):
|
||||||
|
shutil.copytree(src_dep, dep_in_root, symlinks=False, dirs_exist_ok=True)
|
||||||
|
break
|
||||||
|
static_dst = os.path.join(staging, ".next", "static")
|
||||||
|
if os.path.exists(static_dst):
|
||||||
|
shutil.rmtree(static_dst)
|
||||||
|
os.makedirs(os.path.dirname(static_dst), exist_ok=True)
|
||||||
|
shutil.copytree(static_src, static_dst)
|
||||||
|
if os.path.isdir(public_src):
|
||||||
|
shutil.copytree(public_src, os.path.join(staging, "public"), dirs_exist_ok=True)
|
||||||
|
if os.path.isfile(ecosystem_src):
|
||||||
|
shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs"))
|
||||||
|
pkg_json = os.path.join(staging, "package.json")
|
||||||
|
if os.path.isfile(pkg_json):
|
||||||
|
try:
|
||||||
|
with open(pkg_json, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
data.setdefault("scripts", {})["start"] = "node server.js"
|
||||||
|
with open(pkg_json, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
tarball = os.path.join(tempfile.gettempdir(), "soul_deploy.tar.gz")
|
||||||
|
with tarfile.open(tarball, "w:gz") as tf:
|
||||||
|
for name in os.listdir(staging):
|
||||||
|
tf.add(os.path.join(staging, name), arcname=name)
|
||||||
|
print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, os.path.getsize(tarball) / 1024 / 1024))
|
||||||
|
return tarball
|
||||||
|
except Exception as e:
|
||||||
|
print(" [失败] 打包异常:", str(e))
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Node 环境检查 & SSH 上传(deploy 模式) ====================
|
||||||
|
|
||||||
|
def check_node_environments(cfg):
|
||||||
|
print("[检查] Node 环境 ...")
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
if cfg.get("ssh_key"):
|
||||||
|
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
|
||||||
|
else:
|
||||||
|
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
|
||||||
|
stdin, stdout, stderr = client.exec_command("which node && node -v", timeout=10)
|
||||||
|
print(" 默认 Node: %s" % (stdout.read().decode("utf-8", errors="replace").strip() or "未找到"))
|
||||||
|
node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")
|
||||||
|
stdin, stdout, stderr = client.exec_command("%s/node -v 2>/dev/null" % node_path, timeout=5)
|
||||||
|
print(" 配置 Node: %s" % (stdout.read().decode("utf-8", errors="replace").strip() or "不可用"))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(" [警告] %s" % str(e))
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def upload_and_extract(cfg, tarball_path):
|
||||||
|
"""SSH 上传 tar.gz 并解压到 project_path(deploy 模式)"""
|
||||||
|
print("[3/4] SSH 上传并解压 ...")
|
||||||
|
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||||||
|
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
||||||
|
return False
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||||
|
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
|
||||||
|
else:
|
||||||
|
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
remote_tar = "/tmp/soul_deploy.tar.gz"
|
||||||
|
remote_script = "/tmp/soul_deploy_extract.sh"
|
||||||
|
sftp.put(tarball_path, remote_tar)
|
||||||
|
node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")
|
||||||
|
project_path = cfg["project_path"]
|
||||||
|
script_content = """#!/bin/bash
|
||||||
|
export PATH=%s:$PATH
|
||||||
|
cd %s
|
||||||
|
rm -rf .next public ecosystem.config.cjs server.js package.json 2>/dev/null
|
||||||
|
tar -xzf %s
|
||||||
|
rm -f %s
|
||||||
|
echo OK
|
||||||
|
""" % (node_path, project_path, remote_tar, remote_tar)
|
||||||
|
with sftp.open(remote_script, "w") as f:
|
||||||
|
f.write(script_content)
|
||||||
|
sftp.close()
|
||||||
|
client.exec_command("chmod +x %s" % remote_script, timeout=10)
|
||||||
|
stdin, stdout, stderr = client.exec_command("bash %s" % remote_script, timeout=120)
|
||||||
|
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||||
|
exit_status = stdout.channel.recv_exit_status()
|
||||||
|
if exit_status != 0 or "OK" not in out:
|
||||||
|
print(" [失败] 解压失败,退出码:", exit_status)
|
||||||
|
return False
|
||||||
|
print(" [成功] 解压完成: %s" % project_path)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(" [失败] SSH 错误:", str(e))
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def deploy_via_baota_api(cfg):
|
||||||
|
"""宝塔 API 重启 Node 项目(deploy 模式)"""
|
||||||
|
print("[4/4] 宝塔 API 管理 Node 项目 ...")
|
||||||
|
panel_url, api_key, pm2_name = cfg["panel_url"], cfg["api_key"], cfg["pm2_name"]
|
||||||
|
project_path = cfg["project_path"]
|
||||||
|
node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")
|
||||||
|
port = cfg["port"]
|
||||||
|
|
||||||
|
if not get_node_project_status(panel_url, api_key, pm2_name):
|
||||||
|
add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port, node_path)
|
||||||
|
stop_node_project(panel_url, api_key, pm2_name)
|
||||||
|
time.sleep(2)
|
||||||
|
ok = restart_node_project(panel_url, api_key, pm2_name)
|
||||||
|
if not ok:
|
||||||
|
ok = start_node_project(panel_url, api_key, pm2_name)
|
||||||
|
if not ok:
|
||||||
|
print(" 请到宝塔 Node 项目手动重启 %s,路径: %s" % (pm2_name, project_path))
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 打包(devlop 模式:zip) ====================
|
||||||
|
|
||||||
|
ZIP_EXCLUDE_DIRS = {".cache", "__pycache__", ".git", "node_modules", "cache", "test", "tests", "coverage", ".nyc_output", ".turbo", "开发文档"}
|
||||||
|
ZIP_EXCLUDE_FILE_NAMES = {".DS_Store", "Thumbs.db"}
|
||||||
|
ZIP_EXCLUDE_FILE_SUFFIXES = (".log", ".map")
|
||||||
|
|
||||||
|
|
||||||
|
def _should_exclude_from_zip(arcname, is_file=True):
|
||||||
|
parts = arcname.replace("\\", "/").split("/")
|
||||||
|
for part in parts:
|
||||||
|
if part in ZIP_EXCLUDE_DIRS:
|
||||||
|
return True
|
||||||
|
if is_file and parts:
|
||||||
|
name = parts[-1]
|
||||||
|
if name in ZIP_EXCLUDE_FILE_NAMES or any(name.endswith(s) for s in ZIP_EXCLUDE_FILE_SUFFIXES):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def pack_standalone_zip(root):
|
||||||
|
"""打包 standalone 为 zip(devlop 模式用)"""
|
||||||
|
print("[2/7] 打包 standalone 为 zip ...")
|
||||||
|
standalone = os.path.join(root, ".next", "standalone")
|
||||||
|
static_src = os.path.join(root, ".next", "static")
|
||||||
|
public_src = os.path.join(root, "public")
|
||||||
|
ecosystem_src = os.path.join(root, "ecosystem.config.cjs")
|
||||||
|
|
||||||
|
if not os.path.isdir(standalone) or not os.path.isdir(static_src):
|
||||||
|
print(" [失败] 未找到 .next/standalone 或 .next/static")
|
||||||
|
return None
|
||||||
|
|
||||||
|
staging = tempfile.mkdtemp(prefix="soul_devlop_")
|
||||||
|
try:
|
||||||
|
for name in os.listdir(standalone):
|
||||||
|
_copy_with_dereference(os.path.join(standalone, name), os.path.join(staging, name))
|
||||||
|
node_modules_dst = os.path.join(staging, "node_modules")
|
||||||
|
pnpm_dir = os.path.join(node_modules_dst, ".pnpm")
|
||||||
|
if os.path.isdir(pnpm_dir):
|
||||||
|
for dep in ["styled-jsx"]:
|
||||||
|
dep_in_root = os.path.join(node_modules_dst, dep)
|
||||||
|
if not os.path.exists(dep_in_root):
|
||||||
|
for pnpm_pkg in os.listdir(pnpm_dir):
|
||||||
|
if pnpm_pkg.startswith(dep + "@"):
|
||||||
|
src_dep = os.path.join(pnpm_dir, pnpm_pkg, "node_modules", dep)
|
||||||
|
if os.path.isdir(src_dep):
|
||||||
|
shutil.copytree(src_dep, dep_in_root, symlinks=False, dirs_exist_ok=True)
|
||||||
|
break
|
||||||
|
os.makedirs(os.path.join(staging, ".next"), exist_ok=True)
|
||||||
|
shutil.copytree(static_src, os.path.join(staging, ".next", "static"), dirs_exist_ok=True)
|
||||||
|
if os.path.isdir(public_src):
|
||||||
|
shutil.copytree(public_src, os.path.join(staging, "public"), dirs_exist_ok=True)
|
||||||
|
if os.path.isfile(ecosystem_src):
|
||||||
|
shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs"))
|
||||||
|
pkg_json = os.path.join(staging, "package.json")
|
||||||
|
if os.path.isfile(pkg_json):
|
||||||
|
try:
|
||||||
|
with open(pkg_json, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
data.setdefault("scripts", {})["start"] = "node server.js"
|
||||||
|
with open(pkg_json, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
server_js = os.path.join(staging, "server.js")
|
||||||
|
if os.path.isfile(server_js):
|
||||||
|
try:
|
||||||
|
deploy_port = int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT)))
|
||||||
|
with open(server_js, "r", encoding="utf-8") as f:
|
||||||
|
c = f.read()
|
||||||
|
if "|| 3000" in c:
|
||||||
|
with open(server_js, "w", encoding="utf-8") as f:
|
||||||
|
f.write(c.replace("|| 3000", "|| %d" % deploy_port))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
zip_path = os.path.join(tempfile.gettempdir(), "soul_devlop.zip")
|
||||||
|
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for name in os.listdir(staging):
|
||||||
|
path = os.path.join(staging, name)
|
||||||
|
if os.path.isfile(path):
|
||||||
|
if not _should_exclude_from_zip(name):
|
||||||
|
zf.write(path, name)
|
||||||
|
else:
|
||||||
|
for dirpath, dirs, filenames in os.walk(path):
|
||||||
|
dirs[:] = [d for d in dirs if not _should_exclude_from_zip(os.path.join(name, os.path.relpath(os.path.join(dirpath, d), path)), is_file=False)]
|
||||||
|
for f in filenames:
|
||||||
|
full = os.path.join(dirpath, f)
|
||||||
|
arcname = os.path.join(name, os.path.relpath(full, path))
|
||||||
|
if not _should_exclude_from_zip(arcname):
|
||||||
|
zf.write(full, arcname)
|
||||||
|
print(" [成功] 打包完成: %s (%.2f MB)" % (zip_path, os.path.getsize(zip_path) / 1024 / 1024))
|
||||||
|
return zip_path
|
||||||
|
except Exception as e:
|
||||||
|
print(" [失败] 打包异常:", str(e))
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_zip_and_extract_to_dist2(cfg, zip_path):
|
||||||
|
"""上传 zip 并解压到 dist2(devlop 模式)"""
|
||||||
|
print("[3/7] SSH 上传 zip 并解压到 dist2 ...")
|
||||||
|
sys.stdout.flush()
|
||||||
|
if not cfg.get("password") and not cfg.get("ssh_key"):
|
||||||
|
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
||||||
|
return False
|
||||||
|
zip_size_mb = os.path.getsize(zip_path) / (1024 * 1024)
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
print(" 正在连接 %s@%s:%s ..." % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
|
||||||
|
sys.stdout.flush()
|
||||||
|
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||||
|
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=30, banner_timeout=30)
|
||||||
|
else:
|
||||||
|
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=30, banner_timeout=30)
|
||||||
|
print(" [OK] SSH 已连接,正在上传 zip(%.1f MB)..." % zip_size_mb)
|
||||||
|
sys.stdout.flush()
|
||||||
|
remote_zip = cfg["base_path"].rstrip("/") + "/soul_devlop.zip"
|
||||||
|
sftp = client.open_sftp()
|
||||||
|
# 上传进度:每 5MB 打印一次
|
||||||
|
chunk_mb = 5.0
|
||||||
|
last_reported = [0]
|
||||||
|
|
||||||
|
def _progress(transferred, total):
|
||||||
|
if total and total > 0:
|
||||||
|
now_mb = transferred / (1024 * 1024)
|
||||||
|
if now_mb - last_reported[0] >= chunk_mb or transferred >= total:
|
||||||
|
last_reported[0] = now_mb
|
||||||
|
print("\r 上传进度: %.1f / %.1f MB" % (now_mb, total / (1024 * 1024)), end="")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
sftp.put(zip_path, remote_zip, callback=_progress)
|
||||||
|
if zip_size_mb >= chunk_mb:
|
||||||
|
print("")
|
||||||
|
print(" [OK] zip 已上传,正在服务器解压(约 1–3 分钟)...")
|
||||||
|
sys.stdout.flush()
|
||||||
|
sftp.close()
|
||||||
|
dist2 = cfg["dist2_path"]
|
||||||
|
cmd = "rm -rf %s && mkdir -p %s && unzip -o -q %s -d %s && rm -f %s && echo OK" % (dist2, dist2, remote_zip, dist2, remote_zip)
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=300)
|
||||||
|
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||||
|
err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||||
|
if err:
|
||||||
|
print(" 服务器 stderr: %s" % err[:500])
|
||||||
|
exit_status = stdout.channel.recv_exit_status()
|
||||||
|
if exit_status != 0 or "OK" not in out:
|
||||||
|
print(" [失败] 解压失败,退出码: %s" % exit_status)
|
||||||
|
if out:
|
||||||
|
print(" stdout: %s" % out[:300])
|
||||||
|
return False
|
||||||
|
print(" [成功] 已解压到: %s" % dist2)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(" [失败] SSH 错误: %s" % str(e))
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def run_pnpm_install_in_dist2(cfg):
|
||||||
|
"""服务器 dist2 内执行 pnpm install,阻塞等待完成后再返回(改目录前必须完成)"""
|
||||||
|
print("[4/7] 服务器 dist2 内执行 pnpm install(等待完成后再切换目录)...")
|
||||||
|
sys.stdout.flush()
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||||
|
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
|
||||||
|
else:
|
||||||
|
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
|
||||||
|
stdin, stdout, stderr = client.exec_command("bash -lc 'which pnpm'", timeout=10)
|
||||||
|
pnpm_path = stdout.read().decode("utf-8", errors="replace").strip()
|
||||||
|
if not pnpm_path:
|
||||||
|
return False, "未找到 pnpm,请服务器安装: npm install -g pnpm"
|
||||||
|
cmd = "bash -lc 'cd %s && %s install'" % (cfg["dist2_path"], pnpm_path)
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=300)
|
||||||
|
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||||
|
err = stderr.read().decode("utf-8", errors="replace").strip()
|
||||||
|
if stdout.channel.recv_exit_status() != 0:
|
||||||
|
return False, "pnpm install 失败\n" + (err or out)
|
||||||
|
print(" [成功] dist2 内 pnpm install 已执行完成,可安全切换目录")
|
||||||
|
return True, None
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e)
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def remote_swap_dist_and_restart(cfg):
|
||||||
|
"""暂停 → dist→dist1, dist2→dist → 删除 dist1 → 更新 PM2 项目路径 → 重启(devlop 模式)"""
|
||||||
|
print("[5/7] 宝塔 API 暂停 Node 项目 ...")
|
||||||
|
stop_node_project(cfg["panel_url"], cfg["api_key"], cfg["pm2_name"])
|
||||||
|
time.sleep(2)
|
||||||
|
print("[6/7] 服务器切换目录: dist→dist1, dist2→dist ...")
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
|
||||||
|
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
|
||||||
|
else:
|
||||||
|
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
|
||||||
|
cmd = "cd %s && mv dist dist1 2>/dev/null; mv dist2 dist && rm -rf dist1 && echo OK" % cfg["base_path"]
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
|
||||||
|
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||||
|
if stdout.channel.recv_exit_status() != 0 or "OK" not in out:
|
||||||
|
print(" [失败] 切换失败")
|
||||||
|
return False
|
||||||
|
print(" [成功] 新版本位于 %s" % cfg["dist_path"])
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
# 关键:devlop 实际运行目录是 dist_path,必须让宝塔 PM2 从该目录启动,否则会从错误目录跑导致静态资源 404
|
||||||
|
print("[7/7] 更新宝塔 Node 项目路径并重启 ...")
|
||||||
|
add_or_update_node_project(
|
||||||
|
cfg["panel_url"], cfg["api_key"], cfg["pm2_name"],
|
||||||
|
cfg["dist_path"], # 使用 dist_path,不是 project_path
|
||||||
|
port=cfg["port"],
|
||||||
|
node_path=cfg.get("node_path"),
|
||||||
|
)
|
||||||
|
if not start_node_project(cfg["panel_url"], cfg["api_key"], cfg["pm2_name"]):
|
||||||
|
print(" [警告] 请到宝塔手动启动 %s,并确认项目路径为: %s" % (cfg["pm2_name"], cfg["dist_path"]))
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 主函数 ====================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Soul 创业派对 - 统一部署脚本", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)
|
||||||
|
parser.add_argument("--mode", choices=["devlop", "deploy"], default="devlop", help="devlop=dist切换(默认), deploy=直接覆盖")
|
||||||
|
parser.add_argument("--no-build", action="store_true", help="跳过本地构建")
|
||||||
|
parser.add_argument("--no-upload", action="store_true", help="仅 deploy 模式:跳过 SSH 上传")
|
||||||
|
parser.add_argument("--no-api", action="store_true", help="仅 deploy 模式:上传后不调宝塔 API")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
root = os.path.dirname(script_dir)
|
||||||
|
|
||||||
|
if args.mode == "devlop":
|
||||||
|
cfg = get_cfg_devlop()
|
||||||
|
print("=" * 60)
|
||||||
|
print(" Soul 自动部署(dist 切换)")
|
||||||
|
print("=" * 60)
|
||||||
|
print(" 服务器: %s@%s 目录: %s Node: %s" % (cfg["user"], cfg["host"], cfg["base_path"], cfg["pm2_name"]))
|
||||||
|
print("=" * 60)
|
||||||
|
if not args.no_build:
|
||||||
|
print("[1/7] 本地构建 pnpm build ...")
|
||||||
|
if sys.platform == "win32" and not clean_standalone_before_build(root):
|
||||||
|
return 1
|
||||||
|
if not run_build(root):
|
||||||
|
return 1
|
||||||
|
elif not os.path.isfile(os.path.join(root, ".next", "standalone", "server.js")):
|
||||||
|
print("[错误] 未找到 .next/standalone/server.js")
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
print("[1/7] 跳过本地构建")
|
||||||
|
zip_path = pack_standalone_zip(root)
|
||||||
|
if not zip_path:
|
||||||
|
return 1
|
||||||
|
if not upload_zip_and_extract_to_dist2(cfg, zip_path):
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
os.remove(zip_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# 必须在 dist2 内 pnpm install 执行完成后再切换目录
|
||||||
|
ok, err = run_pnpm_install_in_dist2(cfg)
|
||||||
|
if not ok:
|
||||||
|
print(" [失败] %s" % (err or "pnpm install 失败"))
|
||||||
|
return 1
|
||||||
|
# install 已完成,再执行 dist→dist1、dist2→dist 切换
|
||||||
|
if not remote_swap_dist_and_restart(cfg):
|
||||||
|
return 1
|
||||||
|
print("")
|
||||||
|
print(" 部署完成!运行目录: %s" % cfg["dist_path"])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# deploy 模式
|
||||||
|
cfg = get_cfg()
|
||||||
|
print("=" * 60)
|
||||||
|
print(" Soul 一键部署(直接覆盖)")
|
||||||
|
print("=" * 60)
|
||||||
|
print(" 服务器: %s@%s 项目路径: %s PM2: %s" % (cfg["user"], cfg["host"], cfg["project_path"], cfg["pm2_name"]))
|
||||||
|
print("=" * 60)
|
||||||
|
if not args.no_upload:
|
||||||
|
check_node_environments(cfg)
|
||||||
|
if not args.no_build:
|
||||||
|
print("[1/4] 本地构建 ...")
|
||||||
|
if not run_build(root):
|
||||||
|
return 1
|
||||||
|
elif not os.path.isfile(os.path.join(root, ".next", "standalone", "server.js")):
|
||||||
|
print("[错误] 未找到 .next/standalone/server.js")
|
||||||
|
return 1
|
||||||
|
else:
|
||||||
|
print("[1/4] 跳过本地构建")
|
||||||
|
tarball = pack_standalone_tar(root)
|
||||||
|
if not tarball:
|
||||||
|
return 1
|
||||||
|
if not args.no_upload:
|
||||||
|
if not upload_and_extract(cfg, tarball):
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
os.remove(tarball)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
print(" 压缩包: %s" % tarball)
|
||||||
|
if not args.no_api and not args.no_upload:
|
||||||
|
deploy_via_baota_api(cfg)
|
||||||
|
print("")
|
||||||
|
print(" 部署完成!站点: %s" % cfg["site_url"])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
60
scripts/fix_port_issue.py
Normal file
60
scripts/fix_port_issue.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import paramiko
|
||||||
|
import time
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect('42.194.232.22', port=22022, username='root', password='Zhiqun1984', timeout=15)
|
||||||
|
|
||||||
|
print("=== 1. 检查端口占用 ===")
|
||||||
|
cmd = "ss -tlnp | grep ':300' | head -10"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result if result else "无 300x 端口监听")
|
||||||
|
|
||||||
|
print("\n=== 2. 检查 server.js 中的端口配置 ===")
|
||||||
|
cmd = "grep -n 'PORT\\|port\\|3006\\|30006' /www/wwwroot/soul/server.js | head -10"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
print("\n=== 3. 检查环境变量配置 ===")
|
||||||
|
cmd = "cat /www/wwwroot/soul/.env 2>/dev/null | grep -i port || echo '无 .env 文件'"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
print("\n=== 4. 停止 PM2 soul ===")
|
||||||
|
cmd = "pm2 stop soul 2>&1"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
result = result.encode('ascii', errors='replace').decode('ascii')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
print("\n=== 5. 杀死占用 3006 端口的进程 ===")
|
||||||
|
cmd = "lsof -ti:3006 | xargs kill -9 2>/dev/null || echo '无进程占用 3006'"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
print("\n=== 6. 杀死占用 30006 端口的进程 ===")
|
||||||
|
cmd = "lsof -ti:30006 | xargs kill -9 2>/dev/null || echo '无进程占用 30006'"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
print("\n=== 7. 确认端口已释放 ===")
|
||||||
|
cmd = "ss -tlnp | grep ':300'"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result if result else "[OK] 端口已全部释放")
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("下一步:修复 server.js 的端口配置为 30006")
|
||||||
29
scripts/restart_pm2.py
Normal file
29
scripts/restart_pm2.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import paramiko
|
||||||
|
import time
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect('42.194.232.22', port=22022, username='root', password='Zhiqun1984', timeout=15)
|
||||||
|
|
||||||
|
print("=== PM2 restart soul ===")
|
||||||
|
stdin, stdout, stderr = client.exec_command('pm2 restart soul 2>&1', timeout=30)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
# 移除可能导致编码问题的特殊字符
|
||||||
|
result = result.encode('ascii', errors='replace').decode('ascii')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
print("\n=== 等待 3 秒 ===")
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
print("=== PM2 status ===")
|
||||||
|
stdin, stdout, stderr = client.exec_command('pm2 status soul 2>&1', timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
result = result.encode('ascii', errors='replace').decode('ascii')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("请在浏览器按 Ctrl+Shift+R 强制刷新页面!")
|
||||||
79
scripts/restart_soul_correctly.py
Normal file
79
scripts/restart_soul_correctly.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import paramiko
|
||||||
|
import time
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect('42.194.232.22', port=22022, username='root', password='Zhiqun1984', timeout=15)
|
||||||
|
|
||||||
|
print("=== 1. 杀死所有相关进程 ===")
|
||||||
|
cmd = "kill -9 1822 2>/dev/null || echo 'Process 1822 already killed'"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
print("\n=== 2. 确认端口清理完成 ===")
|
||||||
|
cmd = "ss -tlnp | grep ':300' || echo '[OK] All ports cleared'"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
print("\n=== 3. 删除 PM2 soul 配置 ===")
|
||||||
|
cmd = "pm2 delete soul 2>&1"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
result = result.encode('ascii', errors='replace').decode('ascii')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
print("\n=== 4. 使用正确配置重新启动 ===")
|
||||||
|
cmd = """cd /www/wwwroot/soul && PORT=30006 pm2 start server.js --name soul --update-env 2>&1"""
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
result = result.encode('ascii', errors='replace').decode('ascii')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
print("\n=== 5. 检查 PM2 状态 ===")
|
||||||
|
cmd = "pm2 status soul 2>&1"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
result = result.encode('ascii', errors='replace').decode('ascii')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
print("\n=== 6. 确认端口 30006 监听 ===")
|
||||||
|
cmd = "ss -tlnp | grep ':30006'"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result if result else "[X] Port 30006 not listening!")
|
||||||
|
|
||||||
|
print("\n=== 7. 测试 HTTP 响应 ===")
|
||||||
|
cmd = "curl -I http://127.0.0.1:30006 2>&1 | head -5"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
print("\n=== 8. 查看最新日志 ===")
|
||||||
|
cmd = "pm2 logs soul --lines 10 --nostream 2>&1 | tail -15"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
result = result.encode('ascii', errors='replace').decode('ascii')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
print("\n=== 9. 保存 PM2 配置 ===")
|
||||||
|
cmd = "pm2 save 2>&1"
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=10)
|
||||||
|
result = stdout.read().decode('utf-8', errors='replace')
|
||||||
|
result = result.encode('ascii', errors='replace').decode('ascii')
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("完成!请在浏览器访问: https://soul.quwanzhi.com")
|
||||||
|
print("如果仍是空白,按 Ctrl+Shift+R 强制刷新")
|
||||||
@@ -55,34 +55,52 @@ if (!fs.existsSync(standaloneDir)) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制静态资源
|
// 复制静态资源(缺一不可,否则部署到线上也会 404)
|
||||||
console.log('📦 复制静态资源...');
|
console.log('📦 复制静态资源...');
|
||||||
|
if (!fs.existsSync(staticSrc)) {
|
||||||
|
console.error('❌ 错误:.next/static 不存在,请先执行 pnpm build');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
console.log(' .next/static → .next/standalone/.next/static');
|
console.log(' .next/static → .next/standalone/.next/static');
|
||||||
copyDir(staticSrc, staticDst);
|
copyDir(staticSrc, staticDst);
|
||||||
|
|
||||||
|
const chunksDir = path.join(staticDst, 'chunks');
|
||||||
|
if (!fs.existsSync(chunksDir)) {
|
||||||
|
console.error('❌ 错误:复制后 .next/standalone/.next/static/chunks 不存在,本地会 404,部署线上也会报错');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(' public → .next/standalone/public');
|
console.log(' public → .next/standalone/public');
|
||||||
copyDir(publicSrc, publicDst);
|
copyDir(publicSrc, publicDst);
|
||||||
|
|
||||||
console.log('✅ 静态资源复制完成\n');
|
// 同步构建索引:BUILD_ID、build-manifest 等,避免服务器用错版本导致 404
|
||||||
|
const nextRoot = path.join(rootDir, '.next');
|
||||||
// 启动服务器
|
const nextStandalone = path.join(standaloneDir, '.next');
|
||||||
const serverPath = path.join(standaloneDir, 'server.js');
|
const indexFiles = ['BUILD_ID', 'build-manifest.json', 'app-path-routes-manifest.json', 'routes-manifest.json'];
|
||||||
// 优先使用环境变量 PORT,未设置时提示并退出
|
for (const name of indexFiles) {
|
||||||
const port = process.env.PORT;
|
const src = path.join(nextRoot, name);
|
||||||
|
const dst = path.join(nextStandalone, name);
|
||||||
if (!port) {
|
if (fs.existsSync(src)) {
|
||||||
console.error('❌ 错误:未设置 PORT 环境变量');
|
try {
|
||||||
console.error(' 请设置端口后启动,例如:');
|
fs.copyFileSync(src, dst);
|
||||||
console.error(' PORT=30006 pnpm start');
|
} catch (e) {
|
||||||
console.error(' 或:');
|
console.warn(' [警告] 复制索引失败 %s: %s', name, e.message);
|
||||||
console.error(' export PORT=30006 && pnpm start');
|
}
|
||||||
process.exit(1);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🌐 启动服务器: http://localhost:${port}`);
|
console.log('✅ 静态资源与构建索引已同步\n');
|
||||||
console.log('');
|
|
||||||
|
|
||||||
const server = spawn('node', [serverPath], {
|
// 启动服务器(必须在 standalone 目录下运行,否则 _next/static 会 404)
|
||||||
|
const serverPath = path.join(standaloneDir, 'server.js');
|
||||||
|
const DEFAULT_PORT = 30006;
|
||||||
|
const port = process.env.PORT || String(DEFAULT_PORT);
|
||||||
|
|
||||||
|
console.log(`🌐 启动服务器: http://localhost:${port}`);
|
||||||
|
console.log(' (静态资源已复制到 .next/standalone,请勿直接 cd 到 standalone 用 node server.js)\n');
|
||||||
|
|
||||||
|
const server = spawn('node', ['server.js'], {
|
||||||
|
cwd: standaloneDir, // 关键:工作目录必须是 standalone,否则找不到 .next/static
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: { ...process.env, PORT: port }
|
env: { ...process.env, PORT: port }
|
||||||
});
|
});
|
||||||
|
|||||||
383
开发文档/8、部署/分销与绑定流程图.md
Normal file
383
开发文档/8、部署/分销与绑定流程图.md
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
# 分销与绑定流程图
|
||||||
|
|
||||||
|
> 用流程图把「绑定」和「推荐人/邀请码」在系统中的用法讲清楚。
|
||||||
|
> 建议配合《邀请码分销规则说明》一起看。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、概念速查
|
||||||
|
|
||||||
|
| 名词 | 是什么 | 存哪儿 | 谁用 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| **邀请码** | 一串码,如 `SOULABC123` | 每个用户一条:`users.referral_code` | 链接里 `ref=邀请码`,用来**认出**是谁推荐的 |
|
||||||
|
| **推荐人** | 拿佣金的那个人(用户) | 用**用户ID**存:`referrer_id` | 绑定表、订单表、分佣都只认这个 ID |
|
||||||
|
| **被推荐人** | 通过链接进来的访客/买家 | 用**用户ID**存:`referee_id` | 绑定表里「谁被谁推荐」 |
|
||||||
|
|
||||||
|
关系:**邀请码** → 查 `users` 表 → 得到**推荐人用户ID**(referrer_id)。系统里所有「归属、分佣」只认 referrer_id,不直接认邀请码字符串。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、整体流程总览(一图看懂)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 分销全流程:从分享到分佣 │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
推广者 A(推荐人) 访客/买家 B(被推荐人) 系统
|
||||||
|
|
||||||
|
│ │ │
|
||||||
|
│ 1. 分享带 ref 的链接 │ │
|
||||||
|
│ ?ref=A的邀请码 │ │
|
||||||
|
├─────────────────────────────────────>│ 2. 点击链接进入小程序/阅读页 │
|
||||||
|
│ ├─────────────────────────────────>│
|
||||||
|
│ │ app.js: 存 referral_code │
|
||||||
|
│ │ 可选: 记录访问 referral_visit │
|
||||||
|
│ │ │
|
||||||
|
│ │ 3. 登录(微信/手机号/getOpenId 拿到 user) │
|
||||||
|
│ ├─────────────────────────────────>│
|
||||||
|
│ │ 登录成功即调 /api/referral/bind │
|
||||||
|
│ │ 入参: userId, referralCode │
|
||||||
|
│ │ │
|
||||||
|
│ │ 4. 绑定逻辑 │
|
||||||
|
│ │ referral_code │
|
||||||
|
│ │ → 查 users 得 │
|
||||||
|
│ │ referrer_id=A │
|
||||||
|
│ │ 写 referral_ │
|
||||||
|
│ │ bindings │
|
||||||
|
│ │ (referee=B, │
|
||||||
|
│ │ referrer=A) │
|
||||||
|
│ │<─────────────────────────────────┤
|
||||||
|
│ │ 绑定成功(new/renew/takeover) │
|
||||||
|
│ │ │
|
||||||
|
│ │ 5. 下单(章节/找伙伴) │
|
||||||
|
│ │ POST /api/miniprogram/pay │
|
||||||
|
│ │ body: referralCode(可选) │
|
||||||
|
│ ├─────────────────────────────────>│
|
||||||
|
│ │ 6. 定推荐人 │
|
||||||
|
│ │ 先查 bindings │
|
||||||
|
│ │ (referee=B)→A │
|
||||||
|
│ │ 无则用 referral│
|
||||||
|
│ │ Code 解析→A │
|
||||||
|
│ │ 写 orders. │
|
||||||
|
│ │ referrer_id=A,│
|
||||||
|
│ │ referral_code │
|
||||||
|
│ │<─────────────────────────────────┤
|
||||||
|
│ │ 返回支付参数 │
|
||||||
|
│ │ │
|
||||||
|
│ │ 7. 调起微信支付 │
|
||||||
|
│ ├───────────────────────────────> 微信
|
||||||
|
│ │ 8. 用户付款成功 │
|
||||||
|
│ │<─────────────────────────────── 微信
|
||||||
|
│ │ │
|
||||||
|
│ │ 9. 支付回调 │
|
||||||
|
│ │ POST .../notify│
|
||||||
|
│ │ 查 bindings │
|
||||||
|
│ │ (referee=B)→A │
|
||||||
|
│ │ 佣金=金额×90% │
|
||||||
|
│ │ A.pending_ │
|
||||||
|
│ │ earnings += 佣金│
|
||||||
|
│ │ binding→ │
|
||||||
|
│ │ converted │
|
||||||
|
│ │ │
|
||||||
|
│ 10. 推广者 A 看到待结算收益 +90% │ │
|
||||||
|
│<─────────────────────────────────────│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、绑定流程(邀请码 → 推荐关系)
|
||||||
|
|
||||||
|
绑定解决的是:**「谁(B)是通过谁(A)的链接来的」**,并写入 `referral_bindings`。
|
||||||
|
|
||||||
|
**绑定规则(后端统一保证):**
|
||||||
|
- **不重复绑定**:被推荐人 B 已有**当前推荐人 A** 的有效绑定时,再次用 A 的邀请码调用 bind → **不新建记录**,只做**续期**(把过期时间再延长 30 天)。
|
||||||
|
- **有时效**:每条绑定的有效期为 **30 天**(`expiry_date`);分佣、下单定推荐人时只认「未过期」的绑定。
|
||||||
|
- **超时可重新绑定**:超过 30 天未续期的绑定视为过期;此时 B 再通过**其他人 C** 的链接进来并登录 → 允许绑定到 C(旧绑定标记过期,新绑定 C,即「抢夺」);若仍通过 A 的链接 → 续期 A 的绑定。
|
||||||
|
|
||||||
|
**绑定从登录就开始**:只要前端拿到 userId(登录成功),就立刻用当前的 `pendingReferralCode` 调 `/api/referral/bind`,不等到下单。后端根据上述规则决定是**新绑定 / 续期 / 抢夺 / 拒绝**。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph 入口
|
||||||
|
A1["推广者 A 分享链接<br/>带 ref=A的邀请码"]
|
||||||
|
A2["访客 B 点击链接进入"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 前端
|
||||||
|
B1["app.js: 检测到 ref"]
|
||||||
|
B2["写入 storage: referral_code + pendingReferralCode"]
|
||||||
|
B3["若已登录 → 立即调 bind"]
|
||||||
|
B4["若未登录 → 等任意登录成功后再调 bind"]
|
||||||
|
B5["登录含: login / loginWithPhone / getOpenId 拿到 user 时"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 后端绑定API["POST /api/referral/bind"]
|
||||||
|
C1["入参: userId(B), referralCode"]
|
||||||
|
C2["用 referralCode 查 users 表 → 推荐人 A"]
|
||||||
|
C3["不能自己推荐自己"]
|
||||||
|
C4["查 B 是否已有有效绑定(active)"]
|
||||||
|
C5{"已有绑定?"}
|
||||||
|
C6["同一推荐人 A → 只续期,不重复绑定<br/>expiry = 当前+30天"]
|
||||||
|
C7["不同人且已过期(>30天) → 可重新绑定<br/>旧绑定过期,新绑定 A"]
|
||||||
|
C8["不同人且未过期 → 拒绝"]
|
||||||
|
C9["无绑定 / 续期 / 抢夺后 → 写 binding<br/>referrer_id=A, referee_id=B, expiry=+30天"]
|
||||||
|
end
|
||||||
|
|
||||||
|
A1 --> A2 --> B1 --> B2
|
||||||
|
B2 --> B3
|
||||||
|
B2 --> B4
|
||||||
|
B4 --> B5
|
||||||
|
B3 --> C1
|
||||||
|
B5 --> C1
|
||||||
|
C1 --> C2 --> C3 --> C4 --> C5
|
||||||
|
C5 -->|无| C9
|
||||||
|
C5 -->|有,同一人| C6 --> C9
|
||||||
|
C5 -->|有,另一人已过期| C7 --> C9
|
||||||
|
C5 -->|有,另一人未过期| C8
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
- **绑定表**是「谁推荐了谁」的**唯一权威**;分佣只看这张表。
|
||||||
|
- **已有绑定不重复**:同一推荐人再次绑只续期;**30 天**内不能换绑其他推荐人,超过 30 天可重新绑定(被新推荐人「抢夺」或原推荐人续期)。
|
||||||
|
- **邀请码**只在「解析出推荐人是谁」时用,解析完得到的是 **referrer_id**(用户ID)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、下单时「推荐人」怎么定(写订单)
|
||||||
|
|
||||||
|
创建订单时要把「这笔单算谁的推广」记在 `orders.referrer_id` 和 `orders.referral_code`。逻辑是:**先认绑定,再认邀请码**。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph 请求
|
||||||
|
R1["POST /api/miniprogram/pay"]
|
||||||
|
R2["body: userId(B), referralCode(可选)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 定推荐人
|
||||||
|
S1["查 referral_bindings"]
|
||||||
|
S2["WHERE referee_id = B<br/>AND status='active'<br/>AND expiry_date > NOW()"]
|
||||||
|
S3{"查到有效绑定?"}
|
||||||
|
S4["referrer_id = 绑定里的 referrer_id"]
|
||||||
|
S5["referrer_id = 用 referralCode<br/>查 users 得到的 id"]
|
||||||
|
S6["都无 → referrer_id = null"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 写订单
|
||||||
|
T1["INSERT orders"]
|
||||||
|
T2["referrer_id = 上面得到的"]
|
||||||
|
T3["referral_code = 请求里的 referralCode<br/>或推荐人当前 users.referral_code"]
|
||||||
|
end
|
||||||
|
|
||||||
|
R1 --> R2 --> S1 --> S2 --> S3
|
||||||
|
S3 -->|是| S4
|
||||||
|
S3 -->|否,但有 referralCode| S5
|
||||||
|
S3 -->|否且无| S6
|
||||||
|
S4 --> T1
|
||||||
|
S5 --> T1
|
||||||
|
S6 --> T1
|
||||||
|
T1 --> T2 --> T3
|
||||||
|
```
|
||||||
|
|
||||||
|
结论:
|
||||||
|
- **有绑定** → 订单的推荐人 = 绑定里的推荐人(与下单时传不传 referralCode 无关)。
|
||||||
|
- **无绑定但传了 referralCode** → 用邀请码解析出推荐人,写入订单。
|
||||||
|
- 订单上的 **referrer_id** 用于后台展示、对账;**分佣不看订单**,只看绑定表。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、分佣流程(支付成功后)
|
||||||
|
|
||||||
|
分佣**只看绑定表**,不看订单上的 referrer_id。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph 触发
|
||||||
|
P1["微信支付成功"]
|
||||||
|
P2["POST /api/miniprogram/pay/notify"]
|
||||||
|
P3["body: 订单号、金额、买家等"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 回调逻辑
|
||||||
|
Q1["更新订单 status=paid"]
|
||||||
|
Q2["解锁用户权限(章节/全书)"]
|
||||||
|
Q3["查 referral_bindings"]
|
||||||
|
Q4["WHERE referee_id = 买家"]
|
||||||
|
Q5["AND status='active'"]
|
||||||
|
Q6["AND expiry_date > NOW()"]
|
||||||
|
Q7{"查到有效绑定?"}
|
||||||
|
Q8["取 referrer_id = 推广者 A"]
|
||||||
|
Q9["佣金 = 订单金额 × 90%"]
|
||||||
|
Q10["A.pending_earnings += 佣金"]
|
||||||
|
Q11["该绑定 status → converted"]
|
||||||
|
Q12["记录 commission_amount, order_id"]
|
||||||
|
Q13["不分佣"]
|
||||||
|
end
|
||||||
|
|
||||||
|
P1 --> P2 --> P3 --> Q1 --> Q2 --> Q3 --> Q4 --> Q5 --> Q6 --> Q7
|
||||||
|
Q7 -->|是| Q8 --> Q9 --> Q10 --> Q11 --> Q12
|
||||||
|
Q7 -->|否| Q13
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
- 分佣**只认** `referral_bindings` 里「买家 → 有效绑定 → 推荐人」。
|
||||||
|
- 订单里的 referrer_id / referral_code **不参与**分佣计算,只用于统计和展示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 什么情况下能拿到佣金(推广者视角)
|
||||||
|
|
||||||
|
满足下面**全部**条件时,你(推广者)才能拿到这笔订单的佣金:
|
||||||
|
|
||||||
|
1. **对方是通过你的链接进来的**
|
||||||
|
对方点击的链接里带有你的邀请码(如 `?ref=你的邀请码`),进入小程序后系统会记下推荐码,并在登录时用于绑定。
|
||||||
|
|
||||||
|
2. **对方已经绑定到你**
|
||||||
|
对方完成登录后,系统成功调用了绑定接口(新绑定或续期),且当前存在一条「被推荐人 = 对方、推荐人 = 你」的绑定记录,且该绑定 **status = active**、**expiry_date > 当前时间**(在 30 天有效期内或已续期)。
|
||||||
|
|
||||||
|
3. **对方在绑定有效期内下单并支付成功**
|
||||||
|
对方在上述有效期内发起了购买(章节或全书),并完成微信支付;支付成功后,微信会回调我们的接口。
|
||||||
|
|
||||||
|
4. **支付回调时仍能查到你的有效绑定**
|
||||||
|
支付成功回调执行时,系统按「买家 = 对方」查 `referral_bindings`,能查到一条有效绑定且推荐人是你,才会把约 90% 的佣金计入你的待结算收益(pending_earnings),并把该绑定标记为已转化(converted)。
|
||||||
|
|
||||||
|
**简单记**:你的链接 → 对方进来并登录绑定到你 → 有效期内对方付款 → 你拿佣金。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 章节分享这块的分销收益方式
|
||||||
|
|
||||||
|
章节页分享(读某一章时分享给好友/朋友圈)与首页、推广中心的分享**用同一套绑定与分佣规则**,只是落地页是「某一章」的阅读页。收益方式如下:
|
||||||
|
|
||||||
|
1. **入口与绑定**
|
||||||
|
你从阅读页分享出去的链接带 `ref=你的邀请码`(例如 `/pages/read/read?id=1.2&ref=你的邀请码`)。对方点进后进入**该章节**阅读页,系统记下推荐码;对方**登录**后即完成绑定(新绑定或续期)。绑定规则(30 天、不重复绑、超时可重绑)与其它分享入口一致。
|
||||||
|
|
||||||
|
2. **收益比例**
|
||||||
|
订单实付金额的**约 90%** 给推广者(与全书、其它章节一致,由 `referral_config.distributorShare` 配置)。对方买的是**这一章、别的章还是全书**,都按该笔订单金额 × 90% 计算佣金。
|
||||||
|
|
||||||
|
3. **计佣次数(每个被推荐人只计一次)**
|
||||||
|
系统在支付成功回调里会查「该买家」的**有效绑定**,有则给推荐人加佣金,并把这条绑定标记为**已转化(converted)**。
|
||||||
|
因此:**同一个被推荐人在绑定有效期内,只有其「第一笔」支付会给你分佣**;该用户之后再买其它章节或全书,**不再**重复给你分佣(绑定已用掉)。
|
||||||
|
|
||||||
|
4. **小结**
|
||||||
|
**章节分享的收益**:你分享章节链接(带 ref)→ 对方进来并登录绑定到你 → 对方在有效期内**第一次**支付(可以是这一章、别的章或全书)→ 你获得**该笔订单金额的约 90%**;该用户后续订单不再给你分佣。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、推荐人 vs 邀请码(怎么用、不混用)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph 入口
|
||||||
|
L1["链接 ref=SOULABC123"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 解析
|
||||||
|
L2["邀请码 = SOULABC123"]
|
||||||
|
L3["users WHERE referral_code = ?"]
|
||||||
|
L4["推荐人 = 该用户的 id"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 存储
|
||||||
|
M1["referral_bindings.referrer_id"]
|
||||||
|
M2["orders.referrer_id"]
|
||||||
|
M3["分佣发给谁"]
|
||||||
|
end
|
||||||
|
|
||||||
|
L1 --> L2 --> L3 --> L4
|
||||||
|
L4 --> M1
|
||||||
|
L4 --> M2
|
||||||
|
L4 --> M3
|
||||||
|
|
||||||
|
style L2 fill:#f9f,stroke:#333
|
||||||
|
style L4 fill:#9f9,stroke:#333
|
||||||
|
```
|
||||||
|
|
||||||
|
- **邀请码**:只在「从链接/请求里认出是谁」这一步用,用完就解析成 **referrer_id**。
|
||||||
|
- **推荐人**:所有「归属、分佣、统计」都只用 **referrer_id**,不会把邀请码字符串当推荐人存。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、表与字段关系简图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
users ||--o{ referral_bindings : "referrer_id"
|
||||||
|
users ||--o{ referral_bindings : "referee_id"
|
||||||
|
users {
|
||||||
|
string id PK
|
||||||
|
string referral_code "自己的邀请码"
|
||||||
|
}
|
||||||
|
referral_bindings {
|
||||||
|
string referrer_id "推荐人(谁拿佣金)"
|
||||||
|
string referee_id "被推荐人(买家)"
|
||||||
|
string status "active|converted|expired"
|
||||||
|
timestamp expiry_date
|
||||||
|
}
|
||||||
|
orders {
|
||||||
|
string user_id "买家"
|
||||||
|
string referrer_id "推荐人ID(展示/对账)"
|
||||||
|
string referral_code "下单时邀请码(展示)"
|
||||||
|
}
|
||||||
|
referral_bindings ||--o{ orders : "分佣时关联"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **绑定**:`referrer_id` = 推荐人,`referee_id` = 被推荐人;分佣只看这张表。
|
||||||
|
- **订单**:`referrer_id`、`referral_code` 只做展示和对账,不参与分佣计算。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、逻辑漏洞与注意点
|
||||||
|
|
||||||
|
以下为与流程图、实现对照后容易出现的漏洞和设计注意点,便于排查与加固。
|
||||||
|
|
||||||
|
### 8.1 严重:支付回调中买家身份不能信任客户端
|
||||||
|
|
||||||
|
**问题**:支付回调(`/api/miniprogram/pay/notify`)里若**优先**使用请求体/attach 里的 `userId` 作为买家,则该 `userId` 来自**创建订单时客户端传入**的 `body.userId`。若被篡改(如传成他人 userId),会导致:
|
||||||
|
- 订单归属、解锁权限记到错误用户;
|
||||||
|
- 分佣按「错误买家」查绑定表,可能把佣金算到错误推荐人或不分佣。
|
||||||
|
|
||||||
|
**正确做法**:**买家身份必须以微信回调中的 `openId` 为准**(微信侧不可伪造),用 `openId` 查 `users` 得到 `buyerUserId`;attach 中的 `userId` 仅作辅助或校验,不一致时以 openId 解析结果为准。
|
||||||
|
|
||||||
|
**实现建议**:在 notify 中先 `buyerUserId = 由 openId 查 users 得到`;若查不到再回退到 attach.userId,并打日志告警。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.2 设计缺口:先下单、后绑定会导致无分佣
|
||||||
|
|
||||||
|
**问题**:流程图要求「先绑定、再下单」分佣才生效。若用户通过 A 的链接进入但**未调用** `/api/referral/bind`(未登录就下单、或 bind 失败/漏调),下单时传了 `referralCode`,订单上会有 `referrer_id=A`,但**分佣只看绑定表**,此时无绑定 → 不会给 A 分佣。
|
||||||
|
|
||||||
|
**结论**:这是当前设计下的预期行为,不是 bug,但需要在产品/运营上保证「进入后尽快登录并完成绑定」,或在文档中明确写清:**只有存在有效绑定时支付成功才会分佣**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.3 重复回调与重复分佣
|
||||||
|
|
||||||
|
**现状**:微信可能对同一笔支付多次回调。当前实现:
|
||||||
|
- 订单状态已为 `paid` 时跳过订单更新;
|
||||||
|
- 分佣时只取 `status='active'` 的绑定,且分佣后将该绑定置为 `converted`,同一买家不会再有第二条 active 绑定参与分佣。
|
||||||
|
|
||||||
|
因此**不会重复加佣**。无需改流程图,实现已防护。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.4 绑定表与 users.referred_by 双写
|
||||||
|
|
||||||
|
**现状**:绑定 API 在「新绑定」或「抢夺」时会写 `users.referred_by`,与 `referral_bindings` 双写;「续期」只更新绑定表,不改 `referred_by`。
|
||||||
|
分佣、下单定推荐人**只读绑定表**;GET 查询「我的推荐人」等可能读 `users.referred_by`。只要绑定接口保证 new/takeover 时双写一致,则无逻辑漏洞。若以后有接口只改 `referred_by` 而不改绑定表,就会不一致,需避免。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8.5 小结
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|------------|------|
|
||||||
|
| 必须修 | 支付回调中买家身份以 openId 解析为准,不信任 attach.userId。 |
|
||||||
|
| 文档/产品 | 明确「先绑定再下单才能分佣」;未绑定仅下单只记订单归属、不分佣。 |
|
||||||
|
| 已防护 | 重复回调不会导致重复分佣。 |
|
||||||
|
| 需长期一致 | 绑定表与 users.referred_by 在 new/takeover 时双写,避免单改其一。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
若要把某一段改成「按步骤」的纯文字版或拆成多张图,可以说明要哪一段(绑定 / 下单 / 分佣 / 概念)。
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
# 支付订单修复总结
|
|
||||||
|
|
||||||
**日期**: 2026-02-04
|
|
||||||
**修复范围**: 小程序支付的完整流程
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 已完成的修复
|
|
||||||
|
|
||||||
### 1. 订单创建机制
|
|
||||||
- ✅ 支付前先创建订单(`status='created'`)
|
|
||||||
- ✅ 订单立即插入 `orders` 表
|
|
||||||
- ✅ 即使数据库插入失败,仍继续支付流程
|
|
||||||
- ✅ 订单包含完整信息(用户ID、产品类型、金额等)
|
|
||||||
|
|
||||||
**文件**: `app/api/miniprogram/pay/route.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 权限检查逻辑
|
|
||||||
- ✅ 支付前查询真实购买记录(基于 `orders` 表)
|
|
||||||
- ✅ 调用 `/api/user/purchase-status` 接口
|
|
||||||
- ✅ 避免重复购买相同产品
|
|
||||||
- ✅ 更新本地缓存后再进行支付
|
|
||||||
|
|
||||||
**文件**: `miniprogram/pages/read/read.js`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 支付回调处理
|
|
||||||
- ✅ 更新订单状态为 `paid`
|
|
||||||
- ✅ 订单不存在时自动补记
|
|
||||||
- ✅ 解锁用户权限(全书/章节)
|
|
||||||
- ✅ 分配推荐佣金(90%)
|
|
||||||
- ✅ **清理相同产品的其他未支付订单**
|
|
||||||
|
|
||||||
**文件**: `app/api/miniprogram/pay/notify/route.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 多订单处理逻辑
|
|
||||||
- ✅ 检查是否已有相同产品的已支付订单
|
|
||||||
- ✅ 只在首次购买时解锁权限
|
|
||||||
- ✅ 支付成功后删除其他未支付订单
|
|
||||||
- ✅ 避免数据库中积累大量无效订单
|
|
||||||
|
|
||||||
**文件**: `app/api/miniprogram/pay/notify/route.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. 购买状态刷新
|
|
||||||
- ✅ 支付成功后自动刷新用户购买状态
|
|
||||||
- ✅ 调用 `/api/user/purchase-status` 接口
|
|
||||||
- ✅ 更新 `globalData` 和本地缓存
|
|
||||||
- ✅ 确保下次访问不会被要求二次付费
|
|
||||||
|
|
||||||
**文件**: `miniprogram/pages/read/read.js`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 完整流程
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 用户点击购买按钮 │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 1. 前端检查购买状态 │
|
|
||||||
│ GET /api/user/purchase-status │
|
|
||||||
│ ├─ 已购买 → 提示"已购买",停止 │
|
|
||||||
│ └─ 未购买 → 继续 │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 2. 前端创建支付订单 │
|
|
||||||
│ POST /api/miniprogram/pay │
|
|
||||||
│ ├─ ✅ 插入 orders 表 (status='created') │
|
|
||||||
│ ├─ ✅ 检查是否已有已支付订单(记录日志) │
|
|
||||||
│ ├─ 调用微信统一下单接口 │
|
|
||||||
│ └─ 返回支付参数 │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 3. 小程序调起微信支付 │
|
|
||||||
│ wx.requestPayment(...) │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 4. 用户完成支付 / 取消支付 │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 5. 微信支付回调(支付成功) │
|
|
||||||
│ POST /api/miniprogram/pay/notify │
|
|
||||||
│ ├─ ✅ 验证签名 │
|
|
||||||
│ ├─ ✅ 更新订单 (status='paid') 或补记订单 │
|
|
||||||
│ ├─ ✅ 解锁用户权限 │
|
|
||||||
│ │ ├─ 全书 → users.has_full_book = TRUE │
|
|
||||||
│ │ └─ 章节 → 添加到 purchased_sections │
|
|
||||||
│ ├─ ✅ 分配推荐佣金(90%) │
|
|
||||||
│ │ ├─ 更新 users.pending_earnings │
|
|
||||||
│ │ └─ 更新 referral_bindings.status │
|
|
||||||
│ └─ ✅ 清理相同产品的其他未支付订单 │
|
|
||||||
│ └─ DELETE FROM orders WHERE status='created' │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 6. 前端刷新购买状态 │
|
|
||||||
│ GET /api/user/purchase-status │
|
|
||||||
│ ├─ 更新 globalData.purchasedSections │
|
|
||||||
│ ├─ 更新本地缓存 │
|
|
||||||
│ └─ 刷新页面,显示已购买内容 │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ ✅ 完成 │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 修改的文件清单
|
|
||||||
|
|
||||||
### 后端接口(5个)
|
|
||||||
|
|
||||||
| 文件 | 类型 | 说明 |
|
|
||||||
|-----|------|------|
|
|
||||||
| `app/api/miniprogram/pay/route.ts` | 修改 | 添加订单插入逻辑 |
|
|
||||||
| `app/api/miniprogram/pay/notify/route.ts` | 修改 | 完善回调处理逻辑 |
|
|
||||||
| `app/api/user/purchase-status/route.ts` | 新建 | 查询购买状态 |
|
|
||||||
| `app/api/user/check-purchased/route.ts` | 新建 | 检查是否已购买 |
|
|
||||||
| `app/api/payment/create-order/route.ts` | 修改 | 通用支付接口(未使用) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 前端逻辑(1个)
|
|
||||||
|
|
||||||
| 文件 | 类型 | 说明 |
|
|
||||||
|-----|------|------|
|
|
||||||
| `miniprogram/pages/read/read.js` | 修改 | 购买检查 + 状态刷新 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 文档(3个)
|
|
||||||
|
|
||||||
| 文件 | 说明 |
|
|
||||||
|-----|------|
|
|
||||||
| `开发文档/8、部署/支付订单完整修复方案.md` | 完整修复方案 |
|
|
||||||
| `开发文档/8、部署/支付接口清单.md` | 所有接口清单 |
|
|
||||||
| `开发文档/8、部署/支付订单修复总结.md` | 本文档 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 关键特性
|
|
||||||
|
|
||||||
### 1. 订单必创建
|
|
||||||
无论支付是否成功,都会先创建订单记录:
|
|
||||||
- ✅ 便于统计转化率
|
|
||||||
- ✅ 记录用户支付意图
|
|
||||||
- ✅ 支持订单补记机制
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 多订单智能处理
|
|
||||||
如果用户多次点击支付:
|
|
||||||
- ✅ 每次都会创建新订单
|
|
||||||
- ✅ 但只要有一单支付成功,就算已购买
|
|
||||||
- ✅ 其他未支付订单会被自动删除
|
|
||||||
- ✅ 不会重复扣款或重复解锁
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 权限判断准确
|
|
||||||
基于 `orders` 表的 `status='paid'` 记录:
|
|
||||||
- ✅ 不依赖本地缓存
|
|
||||||
- ✅ 不会因为换设备/清缓存而丢失购买记录
|
|
||||||
- ✅ 支持跨设备同步
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 订单补记机制
|
|
||||||
如果创建订单时失败:
|
|
||||||
- ✅ 不影响用户支付
|
|
||||||
- ✅ 回调时自动补记订单
|
|
||||||
- ✅ 确保每个支付都有记录
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. 自动清理无效订单
|
|
||||||
支付成功后:
|
|
||||||
- ✅ 自动删除相同产品的其他未支付订单
|
|
||||||
- ✅ 避免数据库中积累大量无效数据
|
|
||||||
- ✅ 只保留已支付的订单
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 测试要点
|
|
||||||
|
|
||||||
### 1. 正常支付流程
|
|
||||||
```
|
|
||||||
1. 打开未购买的章节
|
|
||||||
2. 点击"购买章节"
|
|
||||||
3. 完成微信支付
|
|
||||||
4. 验证:
|
|
||||||
- orders 表有一条 status='paid' 的订单
|
|
||||||
- 用户可以阅读该章节
|
|
||||||
- 推荐人获得佣金(如果有)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 重复购买测试
|
|
||||||
```
|
|
||||||
1. 购买章节1-1并完成支付
|
|
||||||
2. 再次打开章节1-1
|
|
||||||
3. 点击"购买章节"
|
|
||||||
4. 验证:
|
|
||||||
- 提示"已购买过此章节"
|
|
||||||
- 未创建新订单
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 多次点击测试
|
|
||||||
```
|
|
||||||
1. 快速点击"购买章节"3次(不完成支付)
|
|
||||||
2. 验证 orders 表有3条 status='created' 的订单
|
|
||||||
3. 第4次点击并完成支付
|
|
||||||
4. 验证:
|
|
||||||
- orders 表只有1条 status='paid' 的订单
|
|
||||||
- 前3条订单被删除
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 换设备测试
|
|
||||||
```
|
|
||||||
1. 设备A购买章节1-1并完成支付
|
|
||||||
2. 设备B登录相同账号
|
|
||||||
3. 打开章节1-1
|
|
||||||
4. 验证:
|
|
||||||
- 无需购买,直接可阅读
|
|
||||||
- 基于 orders 表的 status='paid' 判断
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. 网络故障测试
|
|
||||||
```
|
|
||||||
1. 模拟创建订单时数据库失败
|
|
||||||
2. 用户仍完成微信支付
|
|
||||||
3. 验证:
|
|
||||||
- 回调时自动补记订单
|
|
||||||
- 用户权限正常解锁
|
|
||||||
- 推荐人正常获得佣金
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 注意事项
|
|
||||||
|
|
||||||
### 1. 必须重启服务器
|
|
||||||
所有修改需要重启 Next.js 服务器才能生效:
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 订单号格式
|
|
||||||
小程序订单号格式:`MP + 时间戳(14位) + 随机数(6位)`
|
|
||||||
- 示例:`MP20260204123456789012`
|
|
||||||
- 与通用支付模块不同
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 支付回调异步性
|
|
||||||
- 微信支付回调可能有1-3秒延迟
|
|
||||||
- 前端支付成功后等待2秒再刷新状态
|
|
||||||
- 如果回调失败,微信会重试多次
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. 佣金分配规则
|
|
||||||
- 佣金比例:90%
|
|
||||||
- 只分配给有效绑定关系的推荐人
|
|
||||||
- 绑定必须在30天有效期内
|
|
||||||
- 佣金先进入 `pending_earnings`(待结算)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. 数据库事务
|
|
||||||
目前未使用事务,建议后续优化:
|
|
||||||
- 订单创建 + 权限解锁 + 佣金分配应该在一个事务中
|
|
||||||
- 避免部分成功、部分失败的情况
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 数据统计
|
|
||||||
|
|
||||||
### 订单状态分布
|
|
||||||
```sql
|
|
||||||
SELECT status, COUNT(*) as count, SUM(amount) as total_amount
|
|
||||||
FROM orders
|
|
||||||
GROUP BY status;
|
|
||||||
```
|
|
||||||
|
|
||||||
**预期结果**:
|
|
||||||
- `created`: 少量(等待支付或被清理)
|
|
||||||
- `paid`: 多数(真实订单)
|
|
||||||
- `expired`: 极少(超时未支付)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 用户购买统计
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
COUNT(DISTINCT user_id) as total_buyers,
|
|
||||||
COUNT(*) as total_orders,
|
|
||||||
SUM(amount) as total_revenue
|
|
||||||
FROM orders
|
|
||||||
WHERE status = 'paid';
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 章节购买排行
|
|
||||||
```sql
|
|
||||||
SELECT
|
|
||||||
product_id,
|
|
||||||
COUNT(*) as purchase_count,
|
|
||||||
SUM(amount) as revenue
|
|
||||||
FROM orders
|
|
||||||
WHERE status = 'paid' AND product_type = 'section'
|
|
||||||
GROUP BY product_id
|
|
||||||
ORDER BY purchase_count DESC
|
|
||||||
LIMIT 10;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 总结
|
|
||||||
|
|
||||||
### ✅ 已实现的功能
|
|
||||||
|
|
||||||
1. ✅ 支付前创建订单
|
|
||||||
2. ✅ 支付成功更新订单
|
|
||||||
3. ✅ 基于真实订单判断购买状态
|
|
||||||
4. ✅ 多订单智能处理
|
|
||||||
5. ✅ 自动清理无效订单
|
|
||||||
6. ✅ 订单补记机制
|
|
||||||
7. ✅ 权限正确解锁
|
|
||||||
8. ✅ 佣金自动分配
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 📈 改进效果
|
|
||||||
|
|
||||||
| 指标 | 修复前 | 修复后 |
|
|
||||||
|-----|--------|--------|
|
|
||||||
| 订单记录完整性 | ❌ 0% | ✅ 100% |
|
|
||||||
| 重复购买概率 | ⚠️ 高 | ✅ 0% |
|
|
||||||
| 无效订单积累 | ⚠️ 大量 | ✅ 自动清理 |
|
|
||||||
| 跨设备购买状态 | ❌ 不同步 | ✅ 同步 |
|
|
||||||
| 管理端数据准确性 | ❌ 0% | ✅ 100% |
|
|
||||||
| 佣金分配准确性 | ⚠️ 不稳定 | ✅ 稳定 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🚀 后续优化建议
|
|
||||||
|
|
||||||
1. **添加数据库事务**
|
|
||||||
- 订单创建 + 权限解锁 + 佣金分配 在一个事务中
|
|
||||||
- 避免部分成功的情况
|
|
||||||
|
|
||||||
2. **添加订单过期定时任务**
|
|
||||||
- 定期清理超过30分钟的未支付订单
|
|
||||||
- 将 status 更新为 `expired`
|
|
||||||
|
|
||||||
3. **添加订单查询接口**
|
|
||||||
- 用户可以查看自己的订单历史
|
|
||||||
- 支持订单详情、退款等功能
|
|
||||||
|
|
||||||
4. **优化支付体验**
|
|
||||||
- 支付前显示订单预览
|
|
||||||
- 支付过程中显示进度
|
|
||||||
- 支付失败时提供更详细的错误信息
|
|
||||||
|
|
||||||
5. **添加监控告警**
|
|
||||||
- 订单创建失败率
|
|
||||||
- 支付回调失败率
|
|
||||||
- 佣金分配失败率
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**支付订单流程已完整修复!** 🎉
|
|
||||||
|
|
||||||
**现在可以正常使用小程序支付功能了!**
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
# 支付订单未创建问题完整分析
|
|
||||||
|
|
||||||
**日期**: 2026-02-04
|
|
||||||
**问题**: 小程序支付成功但 orders 表没有订单记录
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 问题现象
|
|
||||||
|
|
||||||
1. ❌ 用户在小程序完成支付
|
|
||||||
2. ❌ 查询 orders 表,没有任何记录
|
|
||||||
3. ✅ 支付成功(微信回调可能已触发)
|
|
||||||
4. ❌ 用户权限未解锁
|
|
||||||
5. ❌ 管理端数据为 0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 根本原因
|
|
||||||
|
|
||||||
### **核心问题:数据库 ENUM 类型不匹配**
|
|
||||||
|
|
||||||
**数据库表定义** (`lib/db.ts` 第155行):
|
|
||||||
```sql
|
|
||||||
status ENUM('pending', 'paid', 'cancelled', 'refunded') DEFAULT 'pending'
|
|
||||||
```
|
|
||||||
|
|
||||||
**代码中使用的值** (`app/api/miniprogram/pay/route.ts` 第177行):
|
|
||||||
```typescript
|
|
||||||
status: 'created' // ❌ 这个值不在 ENUM 中!
|
|
||||||
```
|
|
||||||
|
|
||||||
**MySQL 行为**:
|
|
||||||
- 当插入不在 ENUM 定义中的值时,MySQL 会:
|
|
||||||
- **严格模式**: 报错并拒绝插入
|
|
||||||
- **非严格模式**: 插入空字符串或默认值
|
|
||||||
- 无论哪种情况,数据都**无法正确插入**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 完整支付流程分析
|
|
||||||
|
|
||||||
### 流程图
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 1. 用户点击"购买章节" │
|
|
||||||
│ miniprogram/pages/read/read.js (line 457) │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 2. 调用 processPayment() │
|
|
||||||
│ - 检查是否已登录 │
|
|
||||||
│ - 获取 openId │
|
|
||||||
│ - 准备支付参数 │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 3. 调用后端 POST /api/miniprogram/pay │
|
|
||||||
│ 请求参数: │
|
|
||||||
│ { │
|
|
||||||
│ openId: "oXXXX...", │
|
|
||||||
│ productType: "section", │
|
|
||||||
│ productId: "1-1", │
|
|
||||||
│ amount: 9.9, │
|
|
||||||
│ description: "章节1-1...", │
|
|
||||||
│ userId: "user_xxx" │
|
|
||||||
│ } │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 4. 后端生成订单号 │
|
|
||||||
│ orderSn = "MP20260204123456789012" │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 5. ❌ 尝试插入订单到数据库 │
|
|
||||||
│ app/api/miniprogram/pay/route.ts (line 162) │
|
|
||||||
│ │
|
|
||||||
│ INSERT INTO orders ( │
|
|
||||||
│ id, order_sn, user_id, open_id, │
|
|
||||||
│ product_type, product_id, amount, │
|
|
||||||
│ description, status, ... │
|
|
||||||
│ ) VALUES ( │
|
|
||||||
│ ..., 'created', ... ← ❌ ENUM不匹配 │
|
|
||||||
│ ) │
|
|
||||||
│ │
|
|
||||||
│ 结果: 插入失败(被 try-catch 捕获) │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 6. 错误被捕获但不中断支付流程 │
|
|
||||||
│ app/api/miniprogram/pay/route.ts (line 189) │
|
|
||||||
│ │
|
|
||||||
│ } catch (dbError) { │
|
|
||||||
│ console.error('[MiniPay] ❌ 插入订单失败') │
|
|
||||||
│ // 继续执行,不抛出异常 │
|
|
||||||
│ } │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 7. ✅ 调用微信支付接口 │
|
|
||||||
│ - 统一下单 API │
|
|
||||||
│ - 获取 prepay_id │
|
|
||||||
│ - 生成支付参数 │
|
|
||||||
│ - 返回给小程序 │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 8. ✅ 小程序调起微信支付 │
|
|
||||||
│ wx.requestPayment(payParams) │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 9. ✅ 用户完成支付 │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 10. 微信回调 POST /api/miniprogram/pay/notify │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 11. ❌ 回调处理失败 │
|
|
||||||
│ - 查询订单 → ❌ 找不到(因为未创建) │
|
|
||||||
│ - 尝试补记订单 → ❌ 仍然失败(ENUM不匹配) │
|
|
||||||
│ - 无法解锁用户权限 │
|
|
||||||
│ - 无法分配推荐佣金 │
|
|
||||||
└──────────────┬──────────────────────────────────┘
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ 结果: 用户支付了,但没有任何记录! │
|
|
||||||
│ - orders 表: 空 │
|
|
||||||
│ - users.purchased_sections: 未更新 │
|
|
||||||
│ - users.pending_earnings: 未更新 │
|
|
||||||
│ - referral_bindings: 未更新 │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 为什么错误没有暴露?
|
|
||||||
|
|
||||||
### 1. 错误被静默捕获
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// app/api/miniprogram/pay/route.ts (line 189-193)
|
|
||||||
} catch (dbError) {
|
|
||||||
console.error('[MiniPay] ❌ 插入订单失败:', dbError)
|
|
||||||
// ⚠️ 错误被捕获,但不中断支付流程
|
|
||||||
// 理由:微信支付成功后仍可以通过回调补记订单
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**设计意图**: 即使数据库插入失败,也让用户继续支付,回调时补记订单
|
|
||||||
|
|
||||||
**实际问题**:
|
|
||||||
- 回调时补记订单**同样会失败**(ENUM 不匹配)
|
|
||||||
- 用户支付了但系统没有任何记录
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 日志可能不明显
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
console.error('[MiniPay] ❌ 插入订单失败:', dbError)
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**:
|
|
||||||
- 日志可能被大量其他日志淹没
|
|
||||||
- 如果没有监控告警,容易被忽略
|
|
||||||
- 错误信息可能不够明确
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. 微信支付接口仍然成功
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 调用微信统一下单接口 (line 195-204)
|
|
||||||
const response = await fetch('https://api.mch.weixin.qq.com/pay/unifiedorder', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/xml' },
|
|
||||||
body: xmlData,
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**结果**:
|
|
||||||
- 微信支付接口返回成功
|
|
||||||
- 小程序正常调起支付
|
|
||||||
- 用户完成支付
|
|
||||||
- **但系统内部没有订单记录**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 为什么回调也无法补记?
|
|
||||||
|
|
||||||
### 回调补记逻辑
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// app/api/miniprogram/pay/notify/route.ts (line 145-188)
|
|
||||||
try {
|
|
||||||
const orderRows = await query(`
|
|
||||||
SELECT id, user_id, product_type, product_id, status
|
|
||||||
FROM orders
|
|
||||||
WHERE order_sn = ?
|
|
||||||
`, [orderSn]) as any[]
|
|
||||||
|
|
||||||
if (orderRows.length === 0) {
|
|
||||||
// ❌ 订单不存在,尝试补记
|
|
||||||
console.warn('[PayNotify] ⚠️ 订单不存在,尝试补记:', orderSn)
|
|
||||||
|
|
||||||
await query(`
|
|
||||||
INSERT INTO orders (
|
|
||||||
id, order_sn, user_id, open_id,
|
|
||||||
product_type, product_id, amount, description,
|
|
||||||
status, transaction_id, pay_time, created_at, updated_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'paid', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
||||||
// ^^^^^ ❌ 仍然会失败(ENUM不匹配)
|
|
||||||
`, [/* ... */])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**问题**: 补记时使用的是 `'paid'` 状态,这个值**在旧的 ENUM 定义中存在**,所以补记**可能会成功**!
|
|
||||||
|
|
||||||
**但是**: 如果你的生产数据库还是旧的 ENUM 定义(没有 'created'),那么:
|
|
||||||
- 初始插入失败('created' 不在 ENUM 中)
|
|
||||||
- 回调补记**可能成功**('paid' 在 ENUM 中)
|
|
||||||
|
|
||||||
**检查方法**:
|
|
||||||
```sql
|
|
||||||
-- 查看是否有 status='paid' 的订单(但没有 status='created' 的)
|
|
||||||
SELECT status, COUNT(*) as count
|
|
||||||
FROM orders
|
|
||||||
GROUP BY status;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 完整修复方案
|
|
||||||
|
|
||||||
### 第一步:修改数据库表结构(必须!)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 连接到生产数据库
|
|
||||||
mysql -h sh-cynosdbmysql-grp-27q7yv6u.sql.tencentcdb.com \
|
|
||||||
-P 28329 \
|
|
||||||
-u soul \
|
|
||||||
-p soulTest
|
|
||||||
|
|
||||||
-- 修改 status 字段
|
|
||||||
ALTER TABLE orders
|
|
||||||
MODIFY COLUMN status ENUM('created', 'pending', 'paid', 'cancelled', 'refunded', 'expired')
|
|
||||||
DEFAULT 'created';
|
|
||||||
|
|
||||||
-- 验证修改
|
|
||||||
DESCRIBE orders;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 第二步:重新部署代码(已完成)
|
|
||||||
|
|
||||||
- ✅ `lib/db.ts` 已更新
|
|
||||||
- ✅ 代码已重新部署
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 第三步:测试验证
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 小程序测试购买
|
|
||||||
# 2. 查询数据库
|
|
||||||
SELECT * FROM orders ORDER BY created_at DESC LIMIT 5;
|
|
||||||
|
|
||||||
# 3. 检查订单状态
|
|
||||||
SELECT
|
|
||||||
order_sn,
|
|
||||||
status,
|
|
||||||
product_type,
|
|
||||||
product_id,
|
|
||||||
amount,
|
|
||||||
created_at,
|
|
||||||
pay_time
|
|
||||||
FROM orders
|
|
||||||
WHERE created_at > NOW() - INTERVAL 1 HOUR;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 数据库状态检查
|
|
||||||
|
|
||||||
### 检查当前 orders 表定义
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 查看表结构
|
|
||||||
SHOW CREATE TABLE orders;
|
|
||||||
|
|
||||||
-- 查看 status 字段定义
|
|
||||||
SELECT
|
|
||||||
COLUMN_NAME,
|
|
||||||
COLUMN_TYPE,
|
|
||||||
COLUMN_DEFAULT,
|
|
||||||
IS_NULLABLE
|
|
||||||
FROM INFORMATION_SCHEMA.COLUMNS
|
|
||||||
WHERE TABLE_SCHEMA = 'soulTest'
|
|
||||||
AND TABLE_NAME = 'orders'
|
|
||||||
AND COLUMN_NAME = 'status';
|
|
||||||
```
|
|
||||||
|
|
||||||
**预期结果(修复后)**:
|
|
||||||
```
|
|
||||||
COLUMN_TYPE: enum('created','pending','paid','cancelled','refunded','expired')
|
|
||||||
COLUMN_DEFAULT: 'created'
|
|
||||||
```
|
|
||||||
|
|
||||||
**如果仍是旧的**:
|
|
||||||
```
|
|
||||||
COLUMN_TYPE: enum('pending','paid','cancelled','refunded')
|
|
||||||
COLUMN_DEFAULT: 'pending'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 紧急检查清单
|
|
||||||
|
|
||||||
如果用户已经支付但没有记录,需要紧急检查:
|
|
||||||
|
|
||||||
### 1. 检查微信支付商户平台
|
|
||||||
|
|
||||||
登录微信支付商户平台,查看:
|
|
||||||
- 是否有实际的交易记录?
|
|
||||||
- 交易金额是多少?
|
|
||||||
- 交易状态是否为"支付成功"?
|
|
||||||
|
|
||||||
### 2. 检查服务器日志
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# SSH 连接到服务器
|
|
||||||
ssh root@42.194.232.22 -p 22022
|
|
||||||
|
|
||||||
# 查看 Node.js 日志
|
|
||||||
pm2 logs soul --lines 100
|
|
||||||
|
|
||||||
# 搜索错误日志
|
|
||||||
pm2 logs soul | grep "插入订单失败"
|
|
||||||
pm2 logs soul | grep "PayNotify"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 检查数据库
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 查看所有订单(包括可能补记成功的)
|
|
||||||
SELECT * FROM orders WHERE created_at > '2026-02-04 00:00:00';
|
|
||||||
|
|
||||||
-- 查看用户购买记录
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
nickname,
|
|
||||||
has_full_book,
|
|
||||||
purchased_sections,
|
|
||||||
pending_earnings
|
|
||||||
FROM users
|
|
||||||
WHERE id IN (
|
|
||||||
SELECT DISTINCT user_id FROM orders WHERE status = 'paid'
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💰 用户已支付但无记录怎么办?
|
|
||||||
|
|
||||||
如果确认用户已支付但系统无记录:
|
|
||||||
|
|
||||||
### 方案一:手动补记订单
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 从微信商户平台获取信息:
|
|
||||||
-- - 商户订单号(order_sn)
|
|
||||||
-- - 微信订单号(transaction_id)
|
|
||||||
-- - 用户openId
|
|
||||||
-- - 支付金额
|
|
||||||
-- - 支付时间
|
|
||||||
|
|
||||||
INSERT INTO orders (
|
|
||||||
id, order_sn, user_id, open_id,
|
|
||||||
product_type, product_id, amount, description,
|
|
||||||
status, transaction_id, pay_time, created_at, updated_at
|
|
||||||
) VALUES (
|
|
||||||
'MP20260204123456789012', -- order_sn(从微信平台获取)
|
|
||||||
'MP20260204123456789012',
|
|
||||||
'user_xxx', -- 从用户表查询
|
|
||||||
'oXXXX...', -- 用户的openId
|
|
||||||
'section', -- 或 'fullbook'
|
|
||||||
'1-1', -- 章节ID(询问用户)
|
|
||||||
9.9,
|
|
||||||
'章节1-1购买',
|
|
||||||
'paid', -- ✅ 使用 'paid'(已支付)
|
|
||||||
'4200002345678901234', -- 微信交易号(从微信平台获取)
|
|
||||||
'2026-02-04 12:34:56', -- 支付时间(从微信平台获取)
|
|
||||||
NOW(),
|
|
||||||
NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 解锁用户权限
|
|
||||||
UPDATE users
|
|
||||||
SET purchased_sections = JSON_ARRAY_APPEND(
|
|
||||||
COALESCE(purchased_sections, '[]'),
|
|
||||||
'$',
|
|
||||||
'1-1'
|
|
||||||
)
|
|
||||||
WHERE id = 'user_xxx'
|
|
||||||
AND NOT JSON_CONTAINS(COALESCE(purchased_sections, '[]'), '"1-1"');
|
|
||||||
|
|
||||||
-- 如果有推荐人,分配佣金
|
|
||||||
UPDATE users
|
|
||||||
SET pending_earnings = pending_earnings + (9.9 * 0.9)
|
|
||||||
WHERE id = (
|
|
||||||
SELECT referred_by FROM users WHERE id = 'user_xxx'
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 方案二:为用户直接解锁权限
|
|
||||||
|
|
||||||
如果无法补记订单,至少要解锁用户的阅读权限:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- 解锁章节
|
|
||||||
UPDATE users
|
|
||||||
SET purchased_sections = JSON_ARRAY_APPEND(
|
|
||||||
COALESCE(purchased_sections, '[]'),
|
|
||||||
'$',
|
|
||||||
'1-1'
|
|
||||||
)
|
|
||||||
WHERE id = 'user_xxx';
|
|
||||||
|
|
||||||
-- 或者直接给全书权限(作为补偿)
|
|
||||||
UPDATE users
|
|
||||||
SET has_full_book = TRUE
|
|
||||||
WHERE id = 'user_xxx';
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 核心问题总结
|
|
||||||
|
|
||||||
| 问题 | 原因 | 影响 | 修复 |
|
|
||||||
|-----|------|------|------|
|
|
||||||
| 订单未创建 | status='created' 不在 ENUM 中 | 数据库插入失败 | 修改 ENUM 定义 |
|
|
||||||
| 错误被隐藏 | try-catch 捕获但不抛出 | 问题不明显 | 添加告警监控 |
|
|
||||||
| 支付仍成功 | 微信支付独立于订单创建 | 用户扣款但无权限 | 修复 ENUM |
|
|
||||||
| 回调可能成功 | 补记时用 'paid'(在 ENUM 中) | 部分订单有记录 | 统一状态流程 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 修复后的正常流程
|
|
||||||
|
|
||||||
```
|
|
||||||
用户购买
|
|
||||||
↓
|
|
||||||
创建订单(status='created')✅
|
|
||||||
↓
|
|
||||||
调用微信支付 ✅
|
|
||||||
↓
|
|
||||||
用户完成支付 ✅
|
|
||||||
↓
|
|
||||||
微信回调 ✅
|
|
||||||
↓
|
|
||||||
更新订单(status='paid')✅
|
|
||||||
↓
|
|
||||||
解锁用户权限 ✅
|
|
||||||
↓
|
|
||||||
分配推荐佣金 ✅
|
|
||||||
↓
|
|
||||||
清理无效订单 ✅
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔗 相关文档
|
|
||||||
|
|
||||||
- [支付订单完整修复方案](./支付订单完整修复方案.md)
|
|
||||||
- [订单表状态字段修复说明](./订单表状态字段修复说明.md)
|
|
||||||
- [支付接口清单](./支付接口清单.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**立即执行数据库修复SQL,问题即可解决!** 🎉
|
|
||||||
263
开发文档/8、部署/管理端静态资源404排查.md
Normal file
263
开发文档/8、部署/管理端静态资源404排查.md
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# 管理端 / 前台静态资源 404 排查
|
||||||
|
|
||||||
|
> 现象:打开管理后台(或前台)时控制台报错:
|
||||||
|
> `Failed to load resource: the server responded with a status of 404 ()`
|
||||||
|
> 涉及文件如:`6a98f5c6b2554ef3.js`、`turbopack-0d89ab930ad9d74d.js`、`xxx.css` 等。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署前本地自检(避免本地有问题就部署导致线上报错)
|
||||||
|
|
||||||
|
**每次部署前建议按下面做一遍,本地不 404 再部署:**
|
||||||
|
|
||||||
|
1. 在项目根目录执行:
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
2. 浏览器打开 `http://localhost:30006`(或你设置的 PORT),确认页面正常、控制台无 `_next/static` 的 404。
|
||||||
|
3. 若有 404:先按本文下面的「开发环境」或「问题 5」处理,修好后再部署。
|
||||||
|
4. 确认无误后再执行 `python scripts/devlop.py` 或你的部署命令。
|
||||||
|
|
||||||
|
若跳过自检,本地缺失 `.next/static` 或跑错目录,部署到线上后同样会 404。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、先区分环境
|
||||||
|
|
||||||
|
| 报错里出现的文件名 | 说明 |
|
||||||
|
|--------------------|------|
|
||||||
|
| **turbopack-*.js** | **开发环境**(Next.js 16 默认用 Turbopack)。说明你是在跑 `pnpm dev`,且浏览器在请求开发服务器的 chunk。 |
|
||||||
|
| **只有一长串 hash 的 .js / .css**(如 `6a98f5c6b2554ef3.js`) | 可能是**开发**也可能是**生产**;生产里不会有 turbopack 这个名字。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、开发环境(本机 `pnpm dev`)出现 404
|
||||||
|
|
||||||
|
### 原因简述
|
||||||
|
|
||||||
|
- 开发服务器重启后,chunk 的**文件名(hash)会变**,但浏览器可能还在用**旧页面**里的 script 地址去请求,导致 404。
|
||||||
|
- 或者访问的地址/端口不对(例如打开了生产地址,却期望加载本机 dev 的 chunk)。
|
||||||
|
|
||||||
|
### 处理步骤
|
||||||
|
|
||||||
|
1. **强刷、清缓存**
|
||||||
|
- Windows:`Ctrl + Shift + R` 或 `Ctrl + F5`
|
||||||
|
- Mac:`Cmd + Shift + R`
|
||||||
|
- 或:开发者工具 → Network → 勾选「Disable cache」后再刷新
|
||||||
|
|
||||||
|
2. **确认访问地址**
|
||||||
|
- 管理端:`http://localhost:30006/admin`(本项目 dev 端口为 30006)
|
||||||
|
- 不要用 `https://soul.quwanzhi.com/admin` 时还指望加载本机的 turbopack chunk
|
||||||
|
|
||||||
|
3. **重启开发服务器**
|
||||||
|
```bash
|
||||||
|
# 停掉当前 dev(Ctrl+C),再起
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
然后再强刷一次 `http://localhost:30006/admin`
|
||||||
|
|
||||||
|
4. **仍 404**
|
||||||
|
- 看浏览器里 404 的请求 URL:是相对路径 `/_next/static/...` 还是别的?
|
||||||
|
- 若是 `/_next/static/...` 且域名是 localhost:30006,说明请求没到本机 dev 服务器,检查是否有代理/ hosts 把请求指到了别的机器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、生产环境(线上 soul.quwanzhi.com)出现 404 - 快速修复
|
||||||
|
|
||||||
|
### ⚡ 最快修复方法(推荐)
|
||||||
|
|
||||||
|
**在宝塔面板操作:**
|
||||||
|
|
||||||
|
1. 登录宝塔面板 → **网站** → **soul.quwanzhi.com**
|
||||||
|
2. 点击 **设置** → **反向代理**
|
||||||
|
3. 检查是否有 **location /** 的配置
|
||||||
|
4. 如果没有,点击 **添加反向代理**,配置如下:
|
||||||
|
|
||||||
|
```
|
||||||
|
代理名称:soul
|
||||||
|
目标URL:http://127.0.0.1:30006
|
||||||
|
发送域名:$host
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 保存后,点击 **配置文件**,确保配置类似这样:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:30006;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
6. 保存配置,点击 **重载配置** 或 **重启 Nginx**
|
||||||
|
|
||||||
|
**关键点**:必须有 `location /`(整站反代),不能只有 `location /api`,否则 `/_next/static/...` 会 404。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、生产环境(线上 soul.quwanzhi.com)出现 404 - 详细排查
|
||||||
|
|
||||||
|
### 原因简述
|
||||||
|
|
||||||
|
- 线上是 **standalone 部署**,HTML 由 Node 输出,**JS/CSS 分片**来自 `.next/static`。
|
||||||
|
- 若 404 的是 `/_next/static/chunks/xxx.js` 或 `/_next/static/css/xxx.css`,多半是:
|
||||||
|
1. 部署时 **没有把 `.next/static` 完整拷到服务器**,或
|
||||||
|
2. **Nginx 没有把 `/_next` 请求反代到 Node**,或
|
||||||
|
3. 部署后**用了旧 HTML 缓存**(HTML 里引用的 chunk 名已在新构建里不存在)。
|
||||||
|
|
||||||
|
### 处理步骤
|
||||||
|
|
||||||
|
1. **确认静态资源是否在服务器**
|
||||||
|
```bash
|
||||||
|
# SSH 到服务器后
|
||||||
|
ls -la /www/wwwroot/soul/.next/static/chunks/
|
||||||
|
ls -la /www/wwwroot/soul/.next/static/css/
|
||||||
|
```
|
||||||
|
- 若目录不存在或很少文件,说明部署时 **没有正确复制 `.next/static`**。
|
||||||
|
|
||||||
|
2. **部署脚本是否复制了 static**
|
||||||
|
- 本仓库:`scripts/start-standalone.js` 会在**本机**启动前把 `.next/static` 拷到 standalone 目录。
|
||||||
|
- 若用 `scripts/devlop.py` 部署:确认脚本里会把 **`.next/static`** 一起打包/上传到服务器(例如打包进 zip 或单独 rsync),且解压后路径为 `项目根/.next/static`。
|
||||||
|
- 若用宝塔 Node 项目:启动的是 `node server.js`,工作目录里必须包含 `.next/standalone` 及其中拷贝好的 `.next/static`(standalone 的 server 会从当前目录下的 `.next/static` 提供静态资源)。
|
||||||
|
|
||||||
|
3. **Nginx 必须把整站反代到 Node**
|
||||||
|
- 管理端和前台都是同一套 Next 应用,**所有路径**(含 `/_next/static/...`)都应反代到 Node,例如:
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:30006;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **不要**只配 `location /api` 反代而把 `/` 指到别的目录,否则 `/_next/static/...` 会 404。
|
||||||
|
|
||||||
|
4. **清缓存后再试**
|
||||||
|
- 浏览器强刷:`Ctrl+Shift+R` / `Cmd+Shift+R`。
|
||||||
|
- 若用了 CDN 或 Nginx 缓存,需 purge 或暂时关缓存,避免旧 HTML 引用已不存在的 chunk。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、小结
|
||||||
|
|
||||||
|
| 环境 | 优先检查 |
|
||||||
|
|------|----------|
|
||||||
|
| **开发** | 强刷 / 清缓存;确认访问 `http://localhost:30006/admin`;重启 `pnpm dev` |
|
||||||
|
| **生产** | 服务器上是否存在 `.next/static`;Nginx 是否整站反代到 Node;浏览器/CDN 缓存是否导致旧 HTML |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、快速检查脚本(推荐)
|
||||||
|
|
||||||
|
### 检查服务器状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/check_static_files.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 检查 Nginx 配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/fix_nginx_static.py
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会检查 Nginx 配置是否正确,并给出修复建议。
|
||||||
|
|
||||||
|
本仓库提供了检查脚本,可快速查看服务器上静态资源是否存在:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/check_static_files.py
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会检查:
|
||||||
|
- `.next/static` 是否存在
|
||||||
|
- PM2 项目的工作目录
|
||||||
|
- Nginx 配置
|
||||||
|
- 端口 30006 是否在监听
|
||||||
|
|
||||||
|
根据输出结果,可以快速定位问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、常见问题与修复
|
||||||
|
|
||||||
|
### 问题 1:部署后 `.next/static` 不存在
|
||||||
|
|
||||||
|
**原因**:部署脚本在打包时复制了 static,但解压后路径不对或文件丢失。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
1. SSH 到服务器检查:`ls -la /www/wwwroot/soul/.next/static/`
|
||||||
|
2. 若不存在,重新部署:`python scripts/devlop.py`
|
||||||
|
3. 部署后再次检查:`python scripts/check_static_files.py`
|
||||||
|
|
||||||
|
### 问题 2:Nginx 只反代了 `/api`,没有反代 `/_next`
|
||||||
|
|
||||||
|
**现象**:HTML 能加载,但所有 `/_next/static/...` 的请求都 404。
|
||||||
|
|
||||||
|
**修复**:Nginx 配置需要**整站反代**,例如:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:30006;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**不要**只配 `location /api`,否则 `/_next` 路径会 404。
|
||||||
|
|
||||||
|
### 问题 3:PM2 工作目录不对
|
||||||
|
|
||||||
|
**现象**:`server.js` 存在,但运行时找不到 `.next/static`(因为工作目录不是项目根目录)。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
- 宝塔 PM2 管理器:确保「工作目录」填 `/www/wwwroot/soul`(项目根目录,不是 `.next/standalone`)
|
||||||
|
- 命令行 PM2:`pm2 start server.js --name soul --cwd /www/wwwroot/soul`
|
||||||
|
|
||||||
|
### 问题 4:standalone 部署时 static 路径问题
|
||||||
|
|
||||||
|
**说明**:standalone 模式下,`server.js` 会从**当前工作目录**下的 `.next/static` 提供静态资源。
|
||||||
|
|
||||||
|
- ✅ 正确:工作目录 = 项目根(如 `/www/wwwroot/soul`),且该目录下有 `server.js`、`.next/static`、`public`
|
||||||
|
- ❌ 错误:工作目录 = `/www/wwwroot/soul/.next/standalone`,而 static 在项目根下(server.js 会从 cwd 找 `.next/static`,找不到就 404)
|
||||||
|
|
||||||
|
**宝塔用 `node server.js` 没问题**:本仓库的 `scripts/devlop.py`(及 deploy 打包)会把 **server.js、.next/static、public** 一起打进同一层目录,解压到服务器后,项目路径里就同时有这三者;宝塔 Node 项目填「项目路径」= 该目录、启动命令 = `node server.js` 即可,无需改成别的。
|
||||||
|
|
||||||
|
**修复**:确保宝塔里「项目路径」是**解压后的项目根目录**(包含 server.js 和 .next/static 的那一层),不要填成 `.next/standalone` 子目录。
|
||||||
|
|
||||||
|
### 问题 5:本地打包后用 node 运行出现 404(woff2 / css / js / turbopack 等)
|
||||||
|
|
||||||
|
**现象**:`pnpm build` 后直接 `cd .next/standalone && node server.js`,浏览器访问 localhost 报 404(如 `797e433ab948586e-s.p.xxx.woff2`、`turbopack-xxx.js`、各种 chunk 的 css/js)。
|
||||||
|
|
||||||
|
**原因**:
|
||||||
|
1. standalone 目录里**没有** `.next/static` 和 `public`,需要先复制进去。
|
||||||
|
2. 若用**项目根**执行 `node .next/standalone/server.js` 且未复制 static 到 standalone,服务器会从项目根找 `.next/static`(有则可用);若在 standalone 目录下执行且未复制,则 404。
|
||||||
|
3. 若 404 里出现 **turbopack-*.js**,多半是浏览器缓存了之前 **开发环境**的 HTML,强刷即可。
|
||||||
|
|
||||||
|
**修复**:
|
||||||
|
1. **不要**直接 `cd .next/standalone && node server.js`。应**在项目根**执行:
|
||||||
|
```bash
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
或:
|
||||||
|
```bash
|
||||||
|
node scripts/start-standalone.js
|
||||||
|
```
|
||||||
|
脚本会先把 `.next/static` 和 `public` 复制到 `.next/standalone`,并以正确工作目录启动,避免 404。
|
||||||
|
2. 浏览器**强刷**:`Ctrl+Shift+R` / `Cmd+Shift+R`,避免旧 HTML 引用 turbopack 或已失效的 chunk。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
按上述步骤仍 404 时,可把**具体 404 的完整 URL** 和**当前是 dev 还是线上**发出来,便于继续排查。
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
**配置来源**: 数据库 `system_config.config_key = 'referral_config'`
|
**配置来源**: 数据库 `system_config.config_key = 'referral_config'`
|
||||||
**分佣逻辑**: `app/api/miniprogram/pay/notify/route.ts` 中 `processReferralCommission`
|
**分佣逻辑**: `app/api/miniprogram/pay/notify/route.ts` 中 `processReferralCommission`
|
||||||
|
|
||||||
|
> 📌 **流程图**:绑定与分销的完整流程(谁推荐谁、下单怎么写推荐人、分佣怎么算)见 → [分销与绑定流程图](./分销与绑定流程图.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一、分销规则(当前实现)
|
## 一、分销规则(当前实现)
|
||||||
@@ -37,15 +39,17 @@
|
|||||||
### 处理方式(已实现)
|
### 处理方式(已实现)
|
||||||
1. **orders 表增加字段**
|
1. **orders 表增加字段**
|
||||||
- `referrer_id`(VARCHAR(50) NULL):下单时若存在有效绑定或邀请码,则写入推荐人 user_id。
|
- `referrer_id`(VARCHAR(50) NULL):下单时若存在有效绑定或邀请码,则写入推荐人 user_id。
|
||||||
- **迁移脚本**:`python scripts/add_orders_referrer_id.py`(首次部署或表已存在时执行一次)。
|
- `referral_code`(VARCHAR(20) NULL):**下单时使用的邀请码**,直接记录在订单上便于对账与后台展示。
|
||||||
|
- **迁移脚本**:`python scripts/add_orders_referrer_id.py`、`python scripts/add_orders_referral_code.py`(表已存在时各执行一次)。
|
||||||
|
|
||||||
2. **下单时写推荐人**
|
2. **下单时写推荐人与邀请码**
|
||||||
- 创建订单时先按**买家 user_id** 查 `referral_bindings`(referee_id = 买家、有效且未过期),取 `referrer_id`。
|
- 创建订单时先按**买家 user_id** 查 `referral_bindings`(referee_id = 买家、有效且未过期),取 `referrer_id`。
|
||||||
- 若未查到且请求体带了 `referralCode`,则用 `users.referral_code = referralCode` 解析出推荐人 id,写入 `orders.referrer_id`。
|
- 若未查到且请求体带了 `referralCode`,则用 `users.referral_code = referralCode` 解析出推荐人 id,写入 `orders.referrer_id`。
|
||||||
- 以服务端绑定为主,邀请码为补充。
|
- **邀请码**:优先存请求体里的 `referralCode`(用户章节支付时传的);若未传但已有 `referrer_id`,则存该推荐人当前的 `users.referral_code`,保证订单上有一份当时使用的邀请码记录。
|
||||||
|
|
||||||
3. **小程序传参**
|
3. **小程序传参**
|
||||||
- 支付请求会传 `referralCode`:来自 `wx.getStorageSync('referral_code')`(落地页 ref 带入的“谁邀请了我”的邀请码),供后端在无绑定时解析推荐人。
|
- 支付请求会传 `referralCode`:来自 `wx.getStorageSync('referral_code')`(落地页 ref 带入的“谁邀请了我”的邀请码),供后端解析推荐人并写入 `orders.referrer_id` 与 `orders.referral_code`。
|
||||||
|
- **同步约定**:`app.js` 在检测到 `ref` / `referralCode` 时除写入 `pendingReferralCode` 外,会同步写入 `referral_code`;**章节支付**(`pages/read/read.js`)与**找伙伴支付**(`pages/match/match.js`)创建订单时都会带上 `referralCode`,保证两类订单都会记录邀请码。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -65,7 +69,7 @@
|
|||||||
|-----------|------|
|
|-----------|------|
|
||||||
| **users** | referral_code(自己的邀请码), referred_by(可选), pending_earnings, earnings |
|
| **users** | referral_code(自己的邀请码), referred_by(可选), pending_earnings, earnings |
|
||||||
| **referral_bindings** | referrer_id, referee_id, status(active/converted/expired), expiry_date, commission_amount, order_id |
|
| **referral_bindings** | referrer_id, referee_id, status(active/converted/expired), expiry_date, commission_amount, order_id |
|
||||||
| **orders** | referrer_id(推荐人用户ID,下单时写入,用于分销归属与统计) |
|
| **orders** | referrer_id(推荐人用户ID), referral_code(下单时使用的邀请码,便于对账与展示) |
|
||||||
| **system_config** | config_key = 'referral_config',含 distributorShare、bindingDays 等 |
|
| **system_config** | config_key = 'referral_config',含 distributorShare、bindingDays 等 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -77,3 +81,67 @@
|
|||||||
3. 支付成功回调:`/api/miniprogram/pay/notify` 再根据 B 查绑定,给 A 结算 90% 佣金,更新 `referral_bindings` 与 `users.pending_earnings`。
|
3. 支付成功回调:`/api/miniprogram/pay/notify` 再根据 B 查绑定,给 A 结算 90% 佣金,更新 `referral_bindings` 与 `users.pending_earnings`。
|
||||||
|
|
||||||
这样订单上就有邀请/分销关系(referrer_id),且分佣规则不变。
|
这样订单上就有邀请/分销关系(referrer_id),且分佣规则不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、推荐人 vs 邀请码:会不会乱?
|
||||||
|
|
||||||
|
**结论:不会乱。** 全局只认「推荐人 = 用户ID」,邀请码只用于解析出这个 ID。
|
||||||
|
|
||||||
|
### 概念区分
|
||||||
|
|
||||||
|
| 概念 | 含义 | 存储位置 | 用途 |
|
||||||
|
|------|------|----------|------|
|
||||||
|
| **邀请码** | 一串码(如 SOULABC123) | `users.referral_code`(每个用户一条) | 链接里带 `ref=邀请码`,用来**识别**是谁推荐的 |
|
||||||
|
| **推荐人** | 拿佣金的那个人 | 用**用户ID** `referrer_id` 存 | 订单归属、分佣、统计都只认 ID,不认字符串 |
|
||||||
|
|
||||||
|
- 邀请码 → 通过 `users WHERE referral_code = ?` 可唯一解析出 → **推荐人用户ID**。
|
||||||
|
- 订单表、绑定表里存的都是 **referrer_id**,从不存邀请码字符串;展示时再用 referrer_id 去查昵称/邀请码即可。
|
||||||
|
|
||||||
|
### 绑定与订单归属的优先级(唯一权威)
|
||||||
|
|
||||||
|
1. **下单时**(`/api/miniprogram/pay`)
|
||||||
|
- **先**查 `referral_bindings`:当前买家是否有有效绑定 → 得到 `referrer_id`。
|
||||||
|
- **仅当没有绑定**时,才用请求体里的 `referralCode` 去 `users` 表解析出 `referrer_id`。
|
||||||
|
- 最终写入订单的**只有** `orders.referrer_id`(用户ID),不会写邀请码。
|
||||||
|
|
||||||
|
2. **分佣时**(支付成功回调)
|
||||||
|
- 只查 `referral_bindings`(买家 → 有效绑定的推荐人),**不看**订单上的 referrer_id,也不看邀请码。
|
||||||
|
- 佣金发给绑定表里的 `referrer_id`。
|
||||||
|
|
||||||
|
因此:
|
||||||
|
- **绑定表** = 权威的「谁推荐了谁」;
|
||||||
|
- **订单上的 referrer_id** = 下单时根据「绑定表 + 兜底邀请码」算出来的结果,只用于展示/对账;
|
||||||
|
- **邀请码** = 仅作为入口参数,解析成 referrer_id 后就不再参与逻辑,不会和推荐人 ID 混用。
|
||||||
|
|
||||||
|
### 前端 storage 说明(避免混用)
|
||||||
|
|
||||||
|
- 落地页/分享带 `ref`:写入 `referral_code`(下划线),支付时读 `referral_code` 传给后端作兜底。
|
||||||
|
- App 层待绑定:`pendingReferralCode`;绑定成功后可选写 `boundReferralCode`。
|
||||||
|
- 绑定接口、支付接口请求体里统一用 **referralCode**(驼峰)。
|
||||||
|
|
||||||
|
只要后端始终用「绑定表优先、邀请码兜底」且只落库 referrer_id,全局绑定逻辑就不会乱。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、文章/章节分销
|
||||||
|
|
||||||
|
**结论:和全局分销是同一套逻辑,没有单独的「按文章维度」分销。**
|
||||||
|
|
||||||
|
### 当前实现
|
||||||
|
|
||||||
|
- **分享首页**:链接形如 `https://xxx/?ref=邀请码`,点击后 ref 写入 storage,绑定与订单归属按上文规则。
|
||||||
|
- **分享某篇文章/章节**:
|
||||||
|
- 小程序:`/pages/read/read?id=章节ID&ref=邀请码`(阅读页 `onShareAppMessage` / `onShareTimeline` 会带上当前用户邀请码)。
|
||||||
|
- Web:`/view/read/章节ID?ref=邀请码`。
|
||||||
|
- 访客从**任意**带 ref 的链接进入(首页或某篇文章),都会:
|
||||||
|
1. 用 ref 解析出推荐人并完成绑定(`referral_bindings`);
|
||||||
|
2. 之后该用户下单,订单归属与分佣都按**同一套**「绑定表优先、邀请码兜底」规则,与**从哪篇文章点进来**无关。
|
||||||
|
|
||||||
|
也就是说:**文章/章节只决定落地页内容,不改变绑定与分佣规则**。谁发的链接(ref=谁),谁就是推荐人;买的是哪一章、哪本书,都按 90% 给该推荐人,没有「这篇文章单独分成」或「按章节统计推广效果」的单独逻辑。
|
||||||
|
|
||||||
|
### 未实现的部分(若以后要做)
|
||||||
|
|
||||||
|
- **按文章/章节维度的统计**:例如「通过《1.2 某某章》链接带来的访问/绑定/订单数」—— 当前未记录分享时的章节 id,无法区分。
|
||||||
|
- **按文章的分成策略**:例如某章单独 95%、其他 90% —— 当前未实现,所有订单统一 90%。
|
||||||
|
- 若需要「文章分销」统计或差异化分成,需要:在访问/绑定/订单上记录「来源章节」(如 `landing_section_id`),并在分佣或报表里按章节维度汇总。
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# 部署脚本备份
|
|
||||||
|
|
||||||
本目录存放 **scripts/devlop.py** 的备份副本,仅作存档与应急恢复用。
|
|
||||||
|
|
||||||
- **日常部署**:请在项目根目录执行 `python scripts/devlop.py`。
|
|
||||||
- **备份说明**:备份内容与 `scripts/devlop.py` 逻辑一致;若脚本有更新,可在此目录同步更新本备份。
|
|
||||||
- **关联文档**:`DEPLOYMENT.md`、`开发文档/8、部署/宝塔配置检查说明.md`、`开发文档/8、部署/当前项目部署到线上.md`。
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Soul 创业派对 - 本地打包 + SSH 上传 + 宝塔 API 部署(备份)
|
|
||||||
|
|
||||||
本文件为 scripts/devlop.py 的备份,仅作存档。日常部署请使用项目根目录下:
|
|
||||||
python scripts/devlop.py
|
|
||||||
|
|
||||||
备份时间说明见同目录 README.md
|
|
||||||
"""
|
|
||||||
# 以下为 scripts/devlop.py 的完整内容备份
|
|
||||||
# ========== 备份开始 ==========
|
|
||||||
|
|
||||||
from __future__ import print_function
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import shutil
|
|
||||||
import tarfile
|
|
||||||
import tempfile
|
|
||||||
import subprocess
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) # 备份版:从 开发文档/8、部署/部署脚本备份 回项目根
|
|
||||||
|
|
||||||
try:
|
|
||||||
import paramiko
|
|
||||||
except ImportError:
|
|
||||||
print("请先安装: pip install paramiko")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
import requests
|
|
||||||
import urllib3
|
|
||||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
||||||
except ImportError:
|
|
||||||
print("请先安装: pip install requests")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
scripts_dir = os.path.join(ROOT, "scripts")
|
|
||||||
sys.path.insert(0, scripts_dir)
|
|
||||||
from deploy_baota_pure_api import CFG as BAOTA_CFG, restart_node_project
|
|
||||||
|
|
||||||
|
|
||||||
def get_cfg():
|
|
||||||
return {
|
|
||||||
"host": os.environ.get("DEPLOY_HOST", "42.194.232.22"),
|
|
||||||
"user": os.environ.get("DEPLOY_USER", "root"),
|
|
||||||
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
|
|
||||||
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
|
|
||||||
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", "/www/wwwroot/soul"),
|
|
||||||
"app_port": os.environ.get("DEPLOY_APP_PORT", "30006"),
|
|
||||||
"pm2_name": os.environ.get("DEPLOY_PM2_APP", BAOTA_CFG["pm2_name"]),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def run_build(root):
|
|
||||||
print("[1/4] 本地构建 pnpm build ...")
|
|
||||||
use_shell = sys.platform == "win32"
|
|
||||||
r = subprocess.run(["pnpm", "build"], cwd=root, shell=use_shell, timeout=300)
|
|
||||||
if r.returncode != 0:
|
|
||||||
print("构建失败,退出码:", r.returncode)
|
|
||||||
return False
|
|
||||||
standalone = os.path.join(root, ".next", "standalone")
|
|
||||||
if not os.path.isdir(standalone) or not os.path.isfile(os.path.join(standalone, "server.js")):
|
|
||||||
print("未找到 .next/standalone 或 server.js,请确认 next.config 中 output: 'standalone'")
|
|
||||||
return False
|
|
||||||
print(" 构建完成.")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def pack_standalone(root):
|
|
||||||
print("[2/4] 打包 standalone ...")
|
|
||||||
standalone = os.path.join(root, ".next", "standalone")
|
|
||||||
static_src = os.path.join(root, ".next", "static")
|
|
||||||
public_src = os.path.join(root, "public")
|
|
||||||
ecosystem_src = os.path.join(root, "ecosystem.config.cjs")
|
|
||||||
staging = tempfile.mkdtemp(prefix="soul_deploy_")
|
|
||||||
try:
|
|
||||||
for name in os.listdir(standalone):
|
|
||||||
src = os.path.join(standalone, name)
|
|
||||||
dst = os.path.join(staging, name)
|
|
||||||
if os.path.isdir(src):
|
|
||||||
shutil.copytree(src, dst)
|
|
||||||
else:
|
|
||||||
shutil.copy2(src, dst)
|
|
||||||
static_dst = os.path.join(staging, ".next", "static")
|
|
||||||
shutil.rmtree(static_dst, ignore_errors=True)
|
|
||||||
os.makedirs(os.path.dirname(static_dst), exist_ok=True)
|
|
||||||
shutil.copytree(static_src, static_dst)
|
|
||||||
public_dst = os.path.join(staging, "public")
|
|
||||||
shutil.rmtree(public_dst, ignore_errors=True)
|
|
||||||
shutil.copytree(public_src, public_dst)
|
|
||||||
shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs"))
|
|
||||||
tarball = os.path.join(tempfile.gettempdir(), "soul_deploy.tar.gz")
|
|
||||||
with tarfile.open(tarball, "w:gz") as tf:
|
|
||||||
for name in os.listdir(staging):
|
|
||||||
tf.add(os.path.join(staging, name), arcname=name)
|
|
||||||
print(" 打包完成: %s" % tarball)
|
|
||||||
return tarball
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
def upload_and_extract(cfg, tarball_path):
|
|
||||||
print("[3/4] SSH 上传并解压 ...")
|
|
||||||
host, user, password, key_path = cfg["host"], cfg["user"], cfg["password"], cfg["ssh_key"]
|
|
||||||
project_path = cfg["project_path"]
|
|
||||||
if not password and not key_path:
|
|
||||||
print("请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
|
|
||||||
return False
|
|
||||||
client = paramiko.SSHClient()
|
|
||||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
try:
|
|
||||||
if key_path:
|
|
||||||
client.connect(host, username=user, key_filename=key_path, timeout=15)
|
|
||||||
else:
|
|
||||||
client.connect(host, username=user, password=password, timeout=15)
|
|
||||||
sftp = client.open_sftp()
|
|
||||||
remote_tar = "/tmp/soul_deploy.tar.gz"
|
|
||||||
sftp.put(tarball_path, remote_tar)
|
|
||||||
sftp.close()
|
|
||||||
cmd = (
|
|
||||||
"cd %s && "
|
|
||||||
"rm -rf .next server.js node_modules public ecosystem.config.cjs 2>/dev/null; "
|
|
||||||
"tar -xzf %s && rm -f %s"
|
|
||||||
) % (project_path, remote_tar, remote_tar)
|
|
||||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
|
|
||||||
err = stderr.read().decode("utf-8", errors="replace").strip()
|
|
||||||
if err:
|
|
||||||
print(" 服务器 stderr:", err)
|
|
||||||
if stdout.channel.recv_exit_status() != 0:
|
|
||||||
return False
|
|
||||||
print(" 上传并解压完成: %s" % project_path)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
print(" SSH 错误:", e)
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
client.close()
|
|
||||||
|
|
||||||
|
|
||||||
def deploy_via_baota_api(cfg):
|
|
||||||
print("[4/4] 宝塔 API 重启 Node 项目 ...")
|
|
||||||
ok = restart_node_project(BAOTA_CFG["panel_url"], BAOTA_CFG["api_key"], cfg["pm2_name"])
|
|
||||||
if not ok:
|
|
||||||
print("提示:若 Node 接口不可用,请在宝塔面板【Node 项目】中手动重启 %s" % cfg["pm2_name"])
|
|
||||||
return ok
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description="本地打包 + SSH 上传 + 宝塔 API 部署")
|
|
||||||
parser.add_argument("--no-build", action="store_true", help="跳过本地构建")
|
|
||||||
parser.add_argument("--no-upload", action="store_true", help="跳过 SSH 上传")
|
|
||||||
parser.add_argument("--no-api", action="store_true", help="上传后不调宝塔 API 重启")
|
|
||||||
args = parser.parse_args()
|
|
||||||
cfg = get_cfg()
|
|
||||||
print("=" * 60)
|
|
||||||
print(" Soul 创业派对 - 本地打包 + SSH 上传 + 宝塔 API 部署")
|
|
||||||
print("=" * 60)
|
|
||||||
print(" 服务器: %s@%s | 路径: %s | PM2: %s" % (cfg["user"], cfg["host"], cfg["project_path"], cfg["pm2_name"]))
|
|
||||||
print("=" * 60)
|
|
||||||
if not args.no_build:
|
|
||||||
if not run_build(ROOT):
|
|
||||||
return 1
|
|
||||||
else:
|
|
||||||
if not os.path.isfile(os.path.join(ROOT, ".next", "standalone", "server.js")):
|
|
||||||
print("跳过构建但未找到 .next/standalone/server.js")
|
|
||||||
return 1
|
|
||||||
tarball_path = pack_standalone(ROOT)
|
|
||||||
if not tarball_path:
|
|
||||||
return 1
|
|
||||||
if not args.no_upload:
|
|
||||||
if not upload_and_extract(cfg, tarball_path):
|
|
||||||
return 1
|
|
||||||
if os.path.isfile(tarball_path):
|
|
||||||
try:
|
|
||||||
os.remove(tarball_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if not args.no_api and not args.no_upload:
|
|
||||||
deploy_via_baota_api(cfg)
|
|
||||||
print("")
|
|
||||||
print(" 站点: %s | 后台: %s/admin" % (BAOTA_CFG["site_url"], BAOTA_CFG["site_url"]))
|
|
||||||
print("=" * 60)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
|
|
||||||
# ========== 备份结束 ==========
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# 落地方案提示词(占位)
|
|
||||||
|
|
||||||
用于记录“把需求落到代码/流程”的提示词。
|
|
||||||
|
|
||||||
当你后面需要我按固定模板输出落地方案,就把模板写在这里。
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# 说明手册提示词(占位)
|
|
||||||
|
|
||||||
用于记录“对外说明/交付手册”的提示词。
|
|
||||||
|
|
||||||
当你后面需要我按固定模板输出说明手册,就把模板写在这里。
|
|
||||||
File diff suppressed because it is too large
Load Diff
165
开发文档/当前小程序开发细则.md
Normal file
165
开发文档/当前小程序开发细则.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# 当前小程序开发细则
|
||||||
|
|
||||||
|
> 汇总当前 Soul 创业派对小程序的架构、经验与规划,便于新人上手与后续迭代。
|
||||||
|
> 最后整理:2026-02
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、概述与定位
|
||||||
|
|
||||||
|
- **产品**:Soul 创业派对 — 微信小程序,内容为《一场 SOUL 的创业实验场》章节阅读 + 找伙伴 + 分销。
|
||||||
|
- **技术**:原生微信小程序(非 uni-app / Taro),与 Next.js 后端同仓,接口统一走 `apiBase`(如 `https://soul.quwanzhi.com`)。
|
||||||
|
- **核心能力**:章节阅读(免费/付费)、单章/全书支付、邀请码分销、找伙伴(匹配次数购买)、推广中心、我的订单/设置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、目录与页面结构
|
||||||
|
|
||||||
|
### 2.1 目录结构(miniprogram/)
|
||||||
|
|
||||||
|
```
|
||||||
|
miniprogram/
|
||||||
|
├── app.js / app.json / app.wxss # 入口、全局配置、样式
|
||||||
|
├── custom-tab-bar/ # 自定义底部 tabBar
|
||||||
|
├── pages/
|
||||||
|
│ ├── index/ # 首页(推荐章节、已读统计)
|
||||||
|
│ ├── chapters/ # 目录(全书章节列表)
|
||||||
|
│ ├── read/ # 阅读页(核心:权限、支付、分享)
|
||||||
|
│ ├── match/ # 找伙伴(匹配 + 购买次数)
|
||||||
|
│ ├── my/ # 我的(入口:订单、推广、设置)
|
||||||
|
│ ├── referral/ # 推广中心(邀请码、收益、海报)
|
||||||
|
│ ├── purchases/ # 我的订单(当前为本地已购章节列表)
|
||||||
|
│ ├── settings/ # 设置(提现、绑定账号等)
|
||||||
|
│ ├── search/ # 搜索
|
||||||
|
│ ├── about/ # 关于
|
||||||
|
│ └── addresses/ # 地址(若启用)
|
||||||
|
├── utils/
|
||||||
|
│ ├── chapterAccessManager.js # 章节权限与状态
|
||||||
|
│ ├── readingTracker.js # 阅读进度追踪
|
||||||
|
│ ├── payment.js # 旧支付封装(部分场景仍用)
|
||||||
|
│ └── util.js
|
||||||
|
└── assets/ # 图标等静态资源
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 TabBar 与主要页面
|
||||||
|
|
||||||
|
| Tab | 页面 | 说明 |
|
||||||
|
|-----|------|------|
|
||||||
|
| 首页 | pages/index/index | 推荐章节、已读/待读、入口到阅读 |
|
||||||
|
| 目录 | pages/chapters/chapters | 全书章节列表 |
|
||||||
|
| 找伙伴 | pages/match/match | 匹配 + 购买匹配次数 |
|
||||||
|
| 我的 | pages/my/my | 已读/已购、推广中心、订单、设置 |
|
||||||
|
|
||||||
|
非 Tab 页:阅读页 read、推广中心 referral、订单 purchases、设置 settings、搜索 search、关于 about。
|
||||||
|
|
||||||
|
### 2.3 全局状态(app.js globalData)
|
||||||
|
|
||||||
|
- `userInfo`:登录后用户信息(id、openId、nickname、purchasedSections、hasFullBook、referralCode、referralCount 等)。
|
||||||
|
- `openId` / `isLoggedIn`:微信登录态。
|
||||||
|
- `readSectionIds`:已读章节 ID 列表(有权限打开全文时打点)。
|
||||||
|
- `pendingReferralCode`:待绑定推荐码(带 ref 进入时写入,登录后绑定)。
|
||||||
|
- `apiBase`:后端 API 根地址。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、核心流程与经验
|
||||||
|
|
||||||
|
### 3.1 阅读与章节权限
|
||||||
|
|
||||||
|
- **权威数据源**:章节是否可读以**服务端**为准(`/api/user/check-purchased`、`/api/user/purchase-status`),不依赖前端缓存做最终判断。
|
||||||
|
- **权限状态**:设计上支持 `unknown / free / locked_not_login / locked_not_purchased / unlocked_purchased / error`(见《章节阅读付费标准流程设计》);阅读页通过 `chapterAccessManager` 与 `determineAccessState` 等做状态判断。
|
||||||
|
- **免费章节**:来自后端配置(如 `/api/db/config` 的 freeChapters),页面 onLoad 时拉取最新免费列表再判断;首帧可用本地默认 freeIds,拉取完成后需能刷新当前章状态,避免竞态误判。
|
||||||
|
- **已读 vs 已购**:
|
||||||
|
- **已读**:仅统计“有权限打开并看到全文”的章节(`canAccess=true` 时 `app.markSectionAsRead(sectionId)`),存 `readSectionIds`。
|
||||||
|
- **已购**:来自服务端 `userInfo.purchasedSections`、`hasFullBook` 及 orders 表,用于付费墙与购买按钮展示。
|
||||||
|
- **登录后防误解锁**:登录成功(含支付流程中登录)后必须 `refreshPurchaseFromServer()`,再对当前章节做 `recheckCurrentSectionAndRefresh()`(先拉最新免费列表,再请求 check-purchased),避免“刚登录”时误用旧缓存解锁付费章。
|
||||||
|
- **内容接口**:当前 `GET /api/book/chapter/[id]` 不校验登录与购买,前端按 `canAccess` 控制展示全文或预览;若未来开放 Web/API,建议章节接口侧做鉴权。
|
||||||
|
|
||||||
|
详见:`开发文档/8、部署/阅读逻辑分析.md`、`开发文档/8、部署/章节阅读付费标准流程设计.md`。
|
||||||
|
|
||||||
|
### 3.2 支付流程(章节 + 找伙伴)
|
||||||
|
|
||||||
|
- **统一入口**:章节支付 `pages/read/read.js` 调 `POST /api/miniprogram/pay`;找伙伴支付 `pages/match/match.js` 同样调该接口,传 `productType: 'match'`。
|
||||||
|
- **订单先行**:支付前**必须先创建订单**并插入 `orders` 表(status=`created`),再调微信统一下单;即使插库失败也继续支付流程,避免用户卡在“创建订单失败”。
|
||||||
|
- **请求体约定**:`openId`、`userId`、`productType`(section/fullbook/match)、`productId`、`amount`、`description`;**必须带 `referralCode`**(见下节)。
|
||||||
|
- **支付成功**:依赖微信回调 `POST /api/miniprogram/pay/notify` 更新订单为 `paid`、解锁用户权限、分佣、清理同产品未支付订单;前端支付成功后调用 `refreshUserPurchaseStatus()` 再 `initSection()` 刷新当前页。
|
||||||
|
- **兜底**:若回调丢失,依赖**订单状态同步定时任务**(如每 5 分钟调 `GET /api/cron/sync-orders`)查询微信侧状态并同步到本地 orders,保证最终一致。详见 `开发文档/8、部署/订单状态同步定时任务.md`。
|
||||||
|
|
||||||
|
详见:`开发文档/8、部署/支付订单修复总结.md`、`开发文档/8、部署/支付订单完整修复方案.md`。
|
||||||
|
|
||||||
|
### 3.3 邀请码与分销(必传、必记)
|
||||||
|
|
||||||
|
- **绑定逻辑**:带 `ref` 或 `referralCode` 的链接进入 → `app.js` 的 `handleReferralCode` 写入 `pendingReferralCode` 并**同步写入 `referral_code`**(`wx.setStorageSync('referral_code', refCode)`);登录后调用 `/api/referral/bind` 完成绑定(30 天有效、可续期/抢夺)。
|
||||||
|
- **支付必带邀请码**:章节支付、找伙伴支付创建订单时都要传 `referralCode`,来源为 `wx.getStorageSync('referral_code')`;后端据此(或先查 referral_bindings)写入订单的 `referrer_id` 与 **`referral_code`**(下单时使用的邀请码,便于对账与后台展示)。
|
||||||
|
- **订单表字段**:`orders.referrer_id`(推荐人用户ID)、`orders.referral_code`(下单时邀请码)。若表尚未加字段,需执行 `scripts/add_orders_referrer_id.py`、`scripts/add_orders_referral_code.py`。
|
||||||
|
- **推荐人 vs 邀请码**:全局只认「推荐人 = 用户ID」;邀请码仅用于解析出 referrer_id,不会混用。分佣以 `referral_bindings` 为准,不依赖订单上的 referrer_id。详见 `开发文档/8、部署/邀请码分销规则说明.md`。
|
||||||
|
|
||||||
|
### 3.4 分享与落地
|
||||||
|
|
||||||
|
- 阅读页分享:`onShareAppMessage` / `onShareTimeline` 带 `id=章节ID&ref=当前用户邀请码`,落地后 ref 写入 storage,绑定与订单归属同上。
|
||||||
|
- 文章/章节分销与全局同一套:不按“哪篇文章带来”单独分成或统计,仅按“谁发的链接(ref=谁)”归属。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、数据与接口约定
|
||||||
|
|
||||||
|
### 4.1 关键接口
|
||||||
|
|
||||||
|
| 接口 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| POST /api/miniprogram/login | 微信登录,返回 userInfo(含 purchasedSections、referralCode 等) |
|
||||||
|
| GET /api/user/purchase-status | 拉取购买状态(已购章节、全书) |
|
||||||
|
| GET /api/user/check-purchased | 校验指定章节/全书是否已购买 |
|
||||||
|
| POST /api/miniprogram/pay | 创建订单 + 微信预支付,**需传 referralCode** |
|
||||||
|
| POST /api/miniprogram/pay/notify | 微信支付回调(服务端) |
|
||||||
|
| GET /api/cron/sync-orders | 订单状态同步(定时任务,需 secret) |
|
||||||
|
| POST /api/referral/bind | 绑定推荐关系 |
|
||||||
|
| GET /api/db/config | 免费章节等配置 |
|
||||||
|
|
||||||
|
### 4.2 Storage 约定
|
||||||
|
|
||||||
|
- `referral_code`:落地 ref 或 app 检测到 ref 时写入;支付时读取并传后端。
|
||||||
|
- `pendingReferralCode` / `boundReferralCode`:待绑定/已绑定推荐码(app 层)。
|
||||||
|
- `readSectionIds`:已读章节 ID 列表。
|
||||||
|
- `openId`、用户信息等由 app 与登录逻辑维护。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、已知问题与修复要点
|
||||||
|
|
||||||
|
- **免费章节配置竞态**:onLoad 时若未 await 免费列表再 initSection,首帧可能用默认 freeIds 误判;建议先拉配置再判断当前章,或拉取完成后对当前页再刷一次权限。
|
||||||
|
- **check-purchased 失败降级**:失败时应**保守**设为无权限(不信任本地缓存),避免误解锁。
|
||||||
|
- **支付回调丢失**:必须部署订单同步定时任务(如宝塔 crontab 调 `/api/cron/sync-orders`),否则会出现“已扣款但订单仍 created、内容未解锁”。
|
||||||
|
- **订单表缺字段**:新环境或老库需执行 `add_orders_referrer_id.py`、`add_orders_referral_code.py`,否则下单可能 fallback 成不写这两列(功能仍可用,但订单无推荐人/邀请码记录)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、部署与运维
|
||||||
|
|
||||||
|
- **后端**:Next.js 部署至 soul.quwanzhi.com,小程序 `app.globalData.apiBase` 指向该域名。
|
||||||
|
- **订单同步**:生产环境配置 crontab 每 5 分钟请求 `GET /api/cron/sync-orders?secret=YOUR_SECRET`(如宝塔定时任务),保证回调丢失时仍能同步为 paid 并解锁。见 `开发文档/8、部署/订单状态同步定时任务.md`、`开发文档/8、部署/宝塔面板配置订单同步定时任务.md`。
|
||||||
|
- **数据库**:orders 表需含 `referrer_id`、`referral_code`;若为已有表,执行上述两个 Python 迁移脚本。
|
||||||
|
- **小程序发布**:按微信后台流程上传代码、提交审核;注意域名白名单、支付商户号与回调 URL 配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、规划与待办(可选)
|
||||||
|
|
||||||
|
- **我的订单页**:当前 `purchases` 页为本地已购章节列表;若需“真实订单列表+邀请码展示”,可对接 `GET /api/orders?userId=xxx` 并展示订单维度数据。
|
||||||
|
- **阅读进度**:已设计阅读进度状态与 `readingTracker`、可上报服务端;是否全量接入与埋点可按产品需求推进。
|
||||||
|
- **章节接口鉴权**:若开放 Web 或对外 API,建议在章节内容接口侧按用户与购买记录返回全文/预览,防止直连拿全文。
|
||||||
|
- **按文章/章节维度的分销统计**:当前未实现;若需要“某章节带来的访问/订单数”,需在访问或订单上增加来源章节等字段并在报表中汇总。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、相关文档索引
|
||||||
|
|
||||||
|
| 文档 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 开发文档/8、部署/邀请码分销规则说明.md | 分销规则、订单 referrer_id/referral_code、推荐人 vs 邀请码 |
|
||||||
|
| 开发文档/8、部署/章节阅读付费标准流程设计.md | 阅读状态机、权限判断、阅读进度设计 |
|
||||||
|
| 开发文档/8、部署/阅读逻辑分析.md | 阅读流程结论、权限数据源、风险点 |
|
||||||
|
| 开发文档/8、部署/支付订单修复总结.md | 支付全流程与修复要点 |
|
||||||
|
| 开发文档/8、部署/订单状态同步定时任务.md | 回调兜底、cron 配置 |
|
||||||
|
| 开发文档/4、前端/ui/06-分销系统说明.md | 分销规则与推广方式 |
|
||||||
|
| 开发文档/4、前端/ui/08-支付系统说明.md | 支付方式与价格体系 |
|
||||||
Reference in New Issue
Block a user