This commit is contained in:
乘风
2026-02-05 11:35:57 +08:00
parent 8c2a6391af
commit b96acadf91
31 changed files with 2263 additions and 4933 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
} }

View File

@@ -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
} }

View File

@@ -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,
} }

View File

@@ -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),

View File

@@ -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) {

View File

@@ -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
} }
}) })

View File

@@ -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',

View File

@@ -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
View 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
View 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()

View 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()

View 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()

View File

@@ -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
View 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.gzdeploy 模式用)"""
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_pathdeploy 模式)"""
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 为 zipdevlop 模式用)"""
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 并解压到 dist2devlop 模式)"""
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 已上传,正在服务器解压(约 13 分钟)...")
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
View 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
View 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 强制刷新页面!")

View 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 强制刷新")

View File

@@ -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 }
}); });

View 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 时双写,避免单改其一。 |
---
若要把某一段改成「按步骤」的纯文字版或拆成多张图,可以说明要哪一段(绑定 / 下单 / 分佣 / 概念)。

View File

@@ -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. **添加监控告警**
- 订单创建失败率
- 支付回调失败率
- 佣金分配失败率
---
**支付订单流程已完整修复!** 🎉
**现在可以正常使用小程序支付功能了!**

View File

@@ -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问题即可解决** 🎉

View 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
# 停掉当前 devCtrl+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
目标URLhttp://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`
### 问题 2Nginx 只反代了 `/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。
### 问题 3PM2 工作目录不对
**现象**`server.js` 存在,但运行时找不到 `.next/static`(因为工作目录不是项目根目录)。
**修复**
- 宝塔 PM2 管理器:确保「工作目录」填 `/www/wwwroot/soul`(项目根目录,不是 `.next/standalone`
- 命令行 PM2`pm2 start server.js --name soul --cwd /www/wwwroot/soul`
### 问题 4standalone 部署时 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 运行出现 404woff2 / 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 还是线上**发出来,便于继续排查。

View File

@@ -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`),并在分佣或报表里按章节维度汇总。

View File

@@ -1,7 +0,0 @@
# 部署脚本备份
本目录存放 **scripts/devlop.py** 的备份副本,仅作存档与应急恢复用。
- **日常部署**:请在项目根目录执行 `python scripts/devlop.py`
- **备份说明**:备份内容与 `scripts/devlop.py` 逻辑一致;若脚本有更新,可在此目录同步更新本备份。
- **关联文档**`DEPLOYMENT.md``开发文档/8、部署/宝塔配置检查说明.md``开发文档/8、部署/当前项目部署到线上.md`

View File

@@ -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())
# ========== 备份结束 ==========

View File

@@ -1,5 +0,0 @@
# 落地方案提示词(占位)
用于记录“把需求落到代码/流程”的提示词。
当你后面需要我按固定模板输出落地方案,就把模板写在这里。

View File

@@ -1,5 +0,0 @@
# 说明手册提示词(占位)
用于记录“对外说明/交付手册”的提示词。
当你后面需要我按固定模板输出说明手册,就把模板写在这里。

File diff suppressed because it is too large Load Diff

View 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 | 支付方式与价格体系 |