更新管理端用户详情弹窗,新增 VIP 手动设置功能,支持到期日、展示名、项目、联系方式和简介的编辑。优化 VIP 相关接口,确保用户状态和资料更新功能正常,增强用户体验。调整文档,明确 VIP 设置的必填项和格式要求。

This commit is contained in:
Alex-larget
2026-02-26 17:35:52 +08:00
parent 4f4e4407f7
commit ab27acdb21
214 changed files with 10477 additions and 36105 deletions

View File

@@ -12,6 +12,7 @@
|----------|----------|-------------|------|
| **soul-project-boundary.mdc** | `**`(全项目) | ✅ | 总入口:项目组成、防互窜原则、开发时索引 |
| **soul-change-checklist.mdc** | miniprogram、soul-admin、soul-api | ❌ | 变更后必过:关联层检查清单,防漏改 |
| **assistant-xiaofeng.mdc** | ** | ❌ | 小橙/橙子/橙橙/🍊:讨论后记录并更新开发文档 |
| **soul-miniprogram-boundary.mdc** | miniprogram/**/* | ❌ | 小程序:只调 /api/miniprogram/* |
| **soul-admin-boundary.mdc** | soul-admin/**/* | ❌ | 管理端:只调 /api/admin/*、/api/db/* |
| **soul-api-boundary.mdc** | soul-api/**/*.go | ❌ | soul-api路由按使用方归类 |
@@ -101,11 +102,14 @@ API 需操作数据库且 MCP 不可用? → 加载 SKILL-MySQL直接操作
|------|-------|----------|
| 跨端协同 | SKILL-角色流程控制.md | 小程序/管理端/API 任一有功能开发且涉及多端时;流程图见 `.cursor/docs/角色协同流程图.html` |
| 变更检查 | SKILL-变更关联检查.md、soul-change-checklist.mdc | **无论改哪端,改完必过** |
| **文档同步** | **SKILL-助理小风-文档同步.md** | **讨论完毕、记录内容、同步到开发文档时**;对应规则 `assistant-xiaofeng.mdc` |
| next-project | SKILL-next-project仅预览.md | 编辑 next-project/ 或需区分线上后端时 |
| 项目拆解 | SKILL-Next全栈拆解为前后端分离与小程序.md | 拆解 Next.js 全栈时;拆解前必读 SKILL-三端架构与框架分析.md |
---
**小橙(橙子/橙橙/🍊)**:当用户说小橙、橙子、橙橙、🍊、「讨论完毕」「记录一下」「同步到开发文档」等时,加载 **SKILL-助理小风-文档同步.md**,以小橙身份记录讨论要点并更新 `开发文档/``临时需求池/`,保持项目文档与讨论结论一致。
**Skills 迭代**Skills 会随 bug 修复与项目演进持续升级。修 bug 时若发现规则、流程或约定有遗漏或错误,应同步更新对应 Skill避免同类问题复现。详见 `.cursor/docs/角色驱动Skills分析.md`
---

View File

@@ -0,0 +1,38 @@
---
description: 小橙/橙子/橙橙/🍊 - 讨论后记录内容并同步更新开发文档
globs: ["**"]
alwaysApply: false
---
# 小橙(橙子/橙橙/🍊)- 文档同步助理
## 角色定义
**小橙**(橙子、橙橙、🍊)是项目文档同步助理。以下任一表述均可唤醒:小橙、橙子、橙橙、🍊、「讨论完毕」「记录一下」「同步到开发文档」「更新文档」等。讨论告一段落需要沉淀时,以小橙身份执行文档同步。
## 执行时机
- 用户明确要求记录/同步/更新开发文档
- 讨论完成、需求确认、方案定稿后,用户希望沉淀到文档
- 功能开发完成、需求变更后,需更新项目状态
## 执行动作
1. **记录讨论要点**:提炼本次讨论的结论、决策、待办
2. **更新开发文档**:按内容类型写入对应文档
3. **保持文档一致**:确保 需求汇总、运营与变更、临时需求池 等相互引用一致
## 文档更新映射
| 内容类型 | 目标文档 | 更新方式 |
|----------|----------|----------|
| 需求/功能变更 | `开发文档/1、需求/需求汇总.md` | 需求清单新增/更新行 |
| 近期讨论、项目状态 | `开发文档/10、项目管理/运营与变更.md` | 第五部分或新增「近期讨论」节 |
| 技术方案、待实现 | `临时需求池/` 或 `开发文档/8、部署/` | 新建或更新对应分析/说明文档 |
| 项目推进、里程碑 | `开发文档/10、项目管理/项目落地推进表.md` | 第十二节或对应阶段 |
## 输出格式
- 更新文档时保留原有结构,增量追加或按表头更新
- 每条记录含:日期、描述、状态、备注
- 技术分析类文档放在 `临时需求池/` 或 `开发文档/8、部署/`,便于后续实施时引用

View File

@@ -52,3 +52,4 @@ alwaysApply: true
| 涉及「该接口给谁用」 | 先确定使用方再写/改代码,避免路径混用 |
| **跨端功能开发** | 加载 **SKILL-角色流程控制.md**,按协同流程执行 |
| **变更完成准备提交** | **必过** **soul-change-checklist.mdc** + **SKILL-变更关联检查.md**,未过即视为漏改 |
| **小橙/橙子/橙橙/🍊、讨论完毕、记录、同步文档** | 加载 **SKILL-助理小风-文档同步.md**,以小橙身份更新开发文档 |

View File

@@ -0,0 +1,64 @@
# SKILL - 小橙(橙子/橙橙/🍊):讨论后记录与更新开发文档
## 何时使用
当用户说以下任一表述时,加载本 Skill 并以小橙身份执行:
- **唤醒名**:小橙、橙子、橙橙、🍊
- 「讨论完毕」「记录一下」「同步到开发文档」「更新文档」
- 「吸收经验,升级 skills」「将项目情况同步更新到开发文档」
- 「每次讨论完毕后记录内容并更新开发文档」
## 执行流程
### 1. 提炼讨论要点
从本次对话中提取:
- **结论**:已确认的规则、决策、实现方式
- **待办**:未完成项、待实现需求、搁置项
- **变更**:代码/配置/文档的修改摘要
### 2. 确定更新目标
| 要点类型 | 写入位置 | 示例 |
|----------|----------|------|
| 需求清单项 | `开发文档/1、需求/需求汇总.md` 需求清单表 | 会员分润差异化、VIP 手动设置 |
| 近期讨论 | `开发文档/10、项目管理/运营与变更.md` | 第五部分或新增节 |
| 技术分析 | `临时需求池/``开发文档/8、部署/` | 分润需求-技术分析.md |
| 项目推进 | `开发文档/10、项目管理/项目落地推进表.md` | 第十二节永平落地表 |
### 3. 执行更新
- 打开目标文档,按现有格式追加或更新
- 日期格式YYYY-MM-DD
- 状态:已完成 / 进行中 / 待实现 / 搁置
- 备注:简要说明或引用技术文档路径
### 4. 保持引用一致
- 若新建了 `临时需求池/xxx.md`,在需求汇总或运营与变更中可引用
- 若更新了需求清单,检查项目落地推进表是否需要同步
## 文档结构速查
```
开发文档/
├── 1、需求/需求汇总.md # 需求清单、业务需求
├── 8、部署/ # 技术方案、部署说明
├── 10、项目管理/
│ ├── 项目落地推进表.md # 里程碑、永平落地
│ └── 运营与变更.md # 近期更新、变更记录
临时需求池/ # 需求分析、技术分析、待办
```
## 示例
**用户**:讨论完毕,同步到开发文档。(或:小橙、橙子、🍊 等)
**小橙**执行:
1. 提炼VIP 手动设置已完成;会员分润差异化待实现;好友优惠仅针对文章
2. 更新 `需求汇总.md`:新增「需求清单」行
3. 更新 `运营与变更.md`:第五部分追加近期讨论
4. 回复:已记录并更新开发文档,详见 xxx

View File

@@ -15,6 +15,7 @@ import { SitePage } from './pages/site/SitePage'
import { QRCodesPage } from './pages/qrcodes/QRCodesPage'
import { MatchPage } from './pages/match/MatchPage'
import { MatchRecordsPage } from './pages/match-records/MatchRecordsPage'
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
import { NotFoundPage } from './pages/not-found/NotFoundPage'
function App() {
@@ -37,6 +38,7 @@ function App() {
<Route path="qrcodes" element={<QRCodesPage />} />
<Route path="match" element={<MatchPage />} />
<Route path="match-records" element={<MatchRecordsPage />} />
<Route path="api-doc" element={<ApiDocPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>

View File

@@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Switch } from '@/components/ui/switch'
import {
User,
Phone,
@@ -24,6 +25,7 @@ import {
Save,
X,
Tag,
Crown,
} from 'lucide-react'
import { get, put, post } from '@/api/client'
@@ -53,6 +55,13 @@ interface UserDetail {
tags?: string
ckbTags?: string
ckbSyncedAt?: string
isVip?: boolean
vipExpireDate?: string | null
vipName?: string | null
vipAvatar?: string | null
vipProject?: string | null
vipContact?: string | null
vipBio?: string | null
}
interface UserTrack {
@@ -82,6 +91,12 @@ export function UserDetailModal({
const [editNickname, setEditNickname] = useState('')
const [editTags, setEditTags] = useState<string[]>([])
const [newTag, setNewTag] = useState('')
const [editIsVip, setEditIsVip] = useState(false)
const [editVipExpireDate, setEditVipExpireDate] = useState('')
const [editVipName, setEditVipName] = useState('')
const [editVipProject, setEditVipProject] = useState('')
const [editVipContact, setEditVipContact] = useState('')
const [editVipBio, setEditVipBio] = useState('')
useEffect(() => {
if (open && userId) loadUserDetail()
@@ -100,6 +115,12 @@ export function UserDetailModal({
setEditPhone(u.phone || '')
setEditNickname(u.nickname || '')
setEditTags(typeof u.tags === 'string' ? (JSON.parse(u.tags || '[]') as string[]) : [])
setEditIsVip(!!u.isVip)
setEditVipExpireDate(u.vipExpireDate ? String(u.vipExpireDate).slice(0, 10) : '')
setEditVipName(u.vipName || '')
setEditVipProject(u.vipProject || '')
setEditVipContact(u.vipContact || '')
setEditVipBio(u.vipBio || '')
}
try {
const trackData = await get<{ success?: boolean; tracks?: UserTrack[] }>(
@@ -152,14 +173,32 @@ export function UserDetailModal({
async function handleSave() {
if (!user) return
if (editIsVip && !editVipExpireDate.trim()) {
alert('开启 VIP 时请填写有效到期日')
return
}
if (editIsVip && editVipExpireDate.trim()) {
const d = new Date(editVipExpireDate)
if (isNaN(d.getTime())) {
alert('到期日格式无效,请使用 YYYY-MM-DD')
return
}
}
setSaving(true)
try {
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', {
const payload: Record<string, unknown> = {
id: user.id,
phone: editPhone || undefined,
nickname: editNickname || undefined,
tags: JSON.stringify(editTags),
})
isVip: editIsVip,
vipExpireDate: editVipExpireDate || undefined,
vipName: editVipName || undefined,
vipProject: editVipProject || undefined,
vipContact: editVipContact || undefined,
vipBio: editVipBio || undefined,
}
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
if (data?.success) {
alert('保存成功')
loadUserDetail()
@@ -292,6 +331,70 @@ export function UserDetailModal({
/>
</div>
</div>
<div className="p-4 bg-[#0a1628] rounded-lg border border-amber-500/20">
<div className="flex items-center gap-2 mb-3">
<Crown className="w-4 h-4 text-amber-400" />
<span className="text-white font-medium">VIP </span>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center justify-between">
<Label className="text-gray-300">VIP </Label>
<Switch
checked={editIsVip}
onCheckedChange={setEditIsVip}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">
(YYYY-MM-DD)
{editIsVip && <span className="text-amber-400 ml-1">*</span>}
</Label>
<Input
type="date"
className="bg-[#162840] border-gray-700 text-white"
value={editVipExpireDate}
onChange={(e) => setEditVipExpireDate(e.target.value)}
required={editIsVip}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300">VIP </Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="创业老板排行展示名"
value={editVipName}
onChange={(e) => setEditVipName(e.target.value)}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300">/</Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="项目名称"
value={editVipProject}
onChange={(e) => setEditVipProject(e.target.value)}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="微信号或手机"
value={editVipContact}
onChange={(e) => setEditVipContact(e.target.value)}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#162840] border-gray-700 text-white"
placeholder="简要描述业务"
value={editVipBio}
onChange={(e) => setEditVipBio(e.target.value)}
/>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>

View File

@@ -32,13 +32,13 @@ function Slider({
)}
{...props}
>
<SliderPrimitive.Track className="bg-muted relative grow overflow-hidden rounded-full h-1.5 w-full">
<SliderPrimitive.Range className="bg-primary absolute h-full" />
<SliderPrimitive.Track className="bg-gray-600 relative grow overflow-hidden rounded-full h-1.5 w-full">
<SliderPrimitive.Range className="bg-[#38bdac] absolute h-full rounded-full" />
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
key={index}
className="block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
className="block size-4 shrink-0 rounded-full border-2 border-[#38bdac] bg-white shadow-sm focus-visible:ring-2 focus-visible:ring-[#38bdac] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>

View File

@@ -0,0 +1,93 @@
/**
* API 接口文档页 - 解决 /api-doc 404
* 内容与 开发文档/5、接口/API接口完整文档.md 保持一致
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Link2 } from 'lucide-react'
export function ApiDocPage() {
return (
<div className="p-8 max-w-4xl mx-auto">
<div className="flex items-center gap-2 mb-8">
<Link2 className="w-8 h-8 text-[#38bdac]" />
<h1 className="text-2xl font-bold text-white">API </h1>
</div>
<p className="text-gray-400 mb-6">
API RESTful · v1.0 · /api ·
</p>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">1. </CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div>
<p className="text-gray-400 mb-2"></p>
<ul className="space-y-1 text-gray-300 font-mono">
<li>/api/book </li>
<li>/api/payment </li>
<li>/api/referral </li>
<li>/api/user </li>
<li>/api/match </li>
<li>/api/admin ///</li>
<li>/api/config </li>
</ul>
</div>
<div>
<p className="text-gray-400 mb-2"></p>
<p className="text-gray-300">Cookie session_id</p>
<p className="text-gray-300">Authorization: Bearer admin-token-secret</p>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">2. </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300 font-mono">
<p>GET /api/book/all-chapters </p>
<p>GET /api/book/chapter/:id </p>
<p>POST /api/book/sync </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">3. </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300 font-mono">
<p>POST /api/payment/create-order </p>
<p>POST /api/payment/alipay/notify </p>
<p>POST /api/payment/wechat/notify </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">4. </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300 font-mono">
<p>/api/referral/* </p>
<p>/api/user/* </p>
<p>/api/match/* </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">5. </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300 font-mono">
<p>GET/POST /api/admin/referral-settings 广/ VIP </p>
<p>GET /api/db/users/api/db/book </p>
<p>GET /api/orders </p>
</CardContent>
</Card>
<p className="text-gray-500 text-xs">
/5/API接口完整文档.md
</p>
</div>
)
}

View File

@@ -26,7 +26,6 @@ import {
DialogFooter,
} from '@/components/ui/dialog'
import {
FileText,
BookOpen,
Settings2,
ChevronRight,
@@ -36,15 +35,13 @@ import {
X,
RefreshCw,
Link2,
Download,
Upload,
Eye,
Database,
Plus,
Image as ImageIcon,
Search,
Trash2,
} from 'lucide-react'
import { get, post, put } from '@/api/client'
import { get, put, del } from '@/api/client'
import { apiUrl } from '@/api/client'
interface SectionListItem {
@@ -119,69 +116,18 @@ function buildTree(sections: SectionListItem[]): Part[] {
}))
}
function parseTxtToJson(content: string, fileName: string): { id: string; title: string; price: number; content?: string; isFree?: boolean }[] {
const lines = content.split('\n')
const sections: { id: string; title: string; price: number; content?: string; isFree?: boolean }[] = []
let currentSection: { id: string; title: string; price: number; content?: string; isFree?: boolean } | null = null
let currentContent: string[] = []
let sectionIndex = 1
for (const line of lines) {
const titleMatch = line.match(/^#+\s+(.+)$/) || line.match(/^(\d+[.\、]\s*.+)$/)
if (titleMatch) {
if (currentSection) {
currentSection.content = currentContent.join('\n').trim()
if (currentSection.content) sections.push(currentSection)
}
currentSection = {
id: `import-${sectionIndex}`,
title: titleMatch[1].replace(/^#+\s*/, '').trim(),
price: 1,
isFree: sectionIndex <= 3,
}
currentContent = []
sectionIndex++
} else if (currentSection) {
currentContent.push(line)
} else if (line.trim()) {
currentSection = {
id: `import-${sectionIndex}`,
title: fileName.replace(/\.(txt|md|markdown)$/i, ''),
price: 1,
isFree: true,
}
currentContent.push(line)
sectionIndex++
}
}
if (currentSection) {
currentSection.content = currentContent.join('\n').trim()
if (currentSection.content) sections.push(currentSection)
}
return sections
}
export function ContentPage() {
const [sectionsList, setSectionsList] = useState<SectionListItem[]>([])
const [loading, setLoading] = useState(true)
const [expandedParts, setExpandedParts] = useState<string[]>([])
const [editingSection, setEditingSection] = useState<EditingSection | null>(null)
const [isSyncing, setIsSyncing] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [isInitializing, setIsInitializing] = useState(false)
const [feishuDocUrl, setFeishuDocUrl] = useState('')
const [showFeishuModal, setShowFeishuModal] = useState(false)
const [showImportModal, setShowImportModal] = useState(false)
const [showNewSectionModal, setShowNewSectionModal] = useState(false)
const [importData, setImportData] = useState('')
const [isLoadingContent, setIsLoadingContent] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<{ id: string; title: string; price?: number; snippet?: string; partTitle?: string; chapterTitle?: string; matchType?: string }[]>([])
const [isSearching, setIsSearching] = useState(false)
const [uploadingImage, setUploadingImage] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const imageInputRef = useRef<HTMLInputElement>(null)
const [newSection, setNewSection] = useState({
@@ -230,6 +176,24 @@ export function ContentPage() {
)
}
const handleDeleteSection = async (section: Section & { filePath?: string }) => {
if (!confirm(`确定要删除章节「${section.title}」吗?此操作不可恢复。`)) return
try {
const res = await del<{ success?: boolean; error?: string }>(
`/api/db/book?id=${encodeURIComponent(section.id)}`,
)
if (res && (res as { success?: boolean }).success !== false) {
alert('已删除')
loadList()
} else {
alert('删除失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('删除失败')
}
}
const handleReadSection = async (section: Section & { filePath?: string }) => {
setIsLoadingContent(true)
try {
@@ -396,121 +360,6 @@ export function ContentPage() {
}
}
const handleSyncToDatabase = async () => {
setIsSyncing(true)
try {
const res = await post<{ success?: boolean; message?: string; error?: string }>('/api/db/book', { action: 'sync' })
if (res && (res as { success?: boolean }).success !== false) {
alert((res as { message?: string }).message || '同步成功')
loadList()
} else {
alert('同步失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('同步失败')
} finally {
setIsSyncing(false)
}
}
const handleExport = async () => {
setIsExporting(true)
try {
const res = await fetch(apiUrl('/api/db/book?action=export'), { credentials: 'include' })
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `book_sections_${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
alert('导出成功')
} catch (e) {
console.error(e)
alert('导出失败')
} finally {
setIsExporting(false)
}
}
const handleImport = async () => {
if (!importData.trim()) {
alert('请输入或上传JSON数据')
return
}
setIsImporting(true)
try {
const data = JSON.parse(importData)
const res = await post<{ success?: boolean; message?: string; error?: string }>('/api/db/book', { action: 'import', data })
if (res && (res as { success?: boolean }).success !== false) {
alert((res as { message?: string }).message || '导入成功')
setShowImportModal(false)
setImportData('')
loadList()
} else {
alert('导入失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('导入失败: JSON格式错误')
} finally {
setIsImporting(false)
}
}
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
const content = (event.target?.result as string) || ''
const fileName = file.name.toLowerCase()
if (fileName.endsWith('.json')) {
setImportData(content)
} else if (fileName.endsWith('.txt') || fileName.endsWith('.md') || fileName.endsWith('.markdown')) {
setImportData(JSON.stringify(parseTxtToJson(content, file.name), null, 2))
} else {
setImportData(content)
}
}
reader.readAsText(file)
}
const handleInitDatabase = async () => {
if (!confirm('确定要初始化数据库吗?这将创建所有必需的表结构。')) return
setIsInitializing(true)
try {
const res = await post<{ success?: boolean; data?: { message?: string }; error?: string }>('/api/db/init', {
adminToken: 'init_db_2025',
})
if (res && (res as { success?: boolean }).success !== false) {
alert((res as { data?: { message?: string } }).data?.message || '初始化成功')
} else {
alert('初始化失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error(e)
alert('初始化失败')
} finally {
setIsInitializing(false)
}
}
const handleSyncFeishu = async () => {
if (!feishuDocUrl.trim()) {
alert('请输入飞书文档链接')
return
}
setIsSyncing(true)
await new Promise((r) => setTimeout(r, 2000))
setIsSyncing(false)
setShowFeishuModal(false)
alert('飞书文档同步成功!')
}
const currentPart = tree.find((p) => p.id === newSection.partId)
const chaptersForPart = currentPart?.chapters ?? []
@@ -523,173 +372,19 @@ export function ContentPage() {
</div>
<div className="flex gap-2">
<Button
onClick={handleInitDatabase}
disabled={isInitializing}
onClick={() => {
const url = import.meta.env.VITE_API_DOC_URL || (typeof window !== 'undefined' ? `${window.location.origin}/api-doc` : '')
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Database className="w-4 h-4 mr-2" />
{isInitializing ? '初始化中...' : '初始化数据库'}
</Button>
<Button
onClick={handleSyncToDatabase}
disabled={isSyncing}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`} />
</Button>
<Button
onClick={() => setShowImportModal(true)}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Upload className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleExport}
disabled={isExporting}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Download className="w-4 h-4 mr-2" />
{isExporting ? '导出中...' : '导出'}
</Button>
<Button onClick={() => setShowFeishuModal(true)} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<FileText className="w-4 h-4 mr-2" />
<Link2 className="w-4 h-4 mr-2" />
API
</Button>
</div>
</div>
{/* 导入弹窗 */}
<Dialog open={showImportModal} onOpenChange={setShowImportModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Upload className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-gray-300"> ( JSON / TXT / MD)</Label>
<input
ref={fileInputRef}
type="file"
accept=".json,.txt,.md,.markdown"
onChange={handleFileUpload}
className="hidden"
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="w-full border-dashed border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Upload className="w-4 h-4 mr-2" />
(JSON/TXT/MD)
</Button>
<p className="text-xs text-gray-500">
JSON格式: 直接导入章节数据<br />
TXT/MD格式: 自动解析为章节内容
</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Textarea
className="bg-[#0a1628] border-gray-700 text-white min-h-[200px] font-mono text-sm placeholder:text-gray-500"
placeholder={'JSON格式: [{"id": "1-1", "title": "章节标题", "content": "内容..."}]\n\n或直接粘贴TXT/MD内容系统将自动解析'}
value={importData}
onChange={(e) => setImportData(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => { setShowImportModal(false); setImportData('') }}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleImport}
disabled={isImporting || !importData.trim()}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isImporting ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 飞书同步弹窗 */}
<Dialog open={showFeishuModal} onOpenChange={setShowFeishuModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Link2 className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
placeholder="https://xxx.feishu.cn/docx/..."
value={feishuDocUrl}
onChange={(e) => setFeishuDocUrl(e.target.value)}
/>
<p className="text-xs text-gray-500">访</p>
</div>
<div className="bg-[#38bdac]/10 border border-[#38bdac]/30 rounded-lg p-3">
<p className="text-[#38bdac] text-sm">
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowFeishuModal(false)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleSyncFeishu}
disabled={isSyncing}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isSyncing ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 新建章节弹窗 */}
<Dialog open={showNewSectionModal} onOpenChange={setShowNewSectionModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[90vh] overflow-y-auto" showCloseButton>
@@ -1036,6 +731,15 @@ export function ContentPage() {
<Edit3 className="w-4 h-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteSection(section)}
className="text-gray-500 hover:text-red-400 hover:bg-red-500/10 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
))}

View File

@@ -35,4 +35,4 @@ WECHAT_TRANSFER_URL=https://souladmin.quwanzhi.com/api/payment/wechat/transfer/n
# TRUSTED_PROXIES=127.0.0.1,::1
# 跨域 CORS允许的源逗号分隔。未设置时使用默认值含 localhost、soul.quwanzhi.com
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com
CORS_ORIGINS=http://localhost:5175,http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com

View File

@@ -380,9 +380,10 @@ func DBConfigPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "配置保存成功"})
}
// DBUsersList GET /api/db/users支持分页 page、pageSize可选搜索 search购买状态、分销收益、绑定人数从订单/绑定表实时计算)
// DBUsersList GET /api/db/users支持分页 page、pageSize可选搜索 search有 id 时返回单个 user购买状态、分销收益、绑定人数从订单/绑定表实时计算)
func DBUsersList(c *gin.Context) {
db := database.DB()
id := strings.TrimSpace(c.Query("id"))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
search := strings.TrimSpace(c.DefaultQuery("search", ""))
@@ -394,14 +395,33 @@ func DBUsersList(c *gin.Context) {
pageSize = 10
}
// 有 id 时返回单个用户(供 UserDetailModal 等使用)
if id != "" {
var user model.User
if err := db.Where("id = ?", id).First(&user).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": true, "user": nil})
return
}
// 填充 hasFullBook含 is_vip 或 orders
var cnt int64
db.Model(&model.Order{}).Where("user_id = ? AND (status = ? OR status = ?) AND (product_type = ? OR product_type = ?)",
id, "paid", "completed", "fullbook", "vip").Count(&cnt)
user.HasFullBook = ptrBool(cnt > 0)
if user.IsVip != nil && *user.IsVip {
user.HasFullBook = ptrBool(true)
}
c.JSON(http.StatusOK, gin.H{"success": true, "user": user})
return
}
q := db.Model(&model.User{})
if search != "" {
pattern := "%" + search + "%"
q = q.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern)
}
if vipFilter == "true" || vipFilter == "1" {
q = q.Where("id IN (SELECT user_id FROM orders WHERE product_type IN ? AND status = ?)",
[]string{"fullbook", "vip"}, "paid")
q = q.Where("id IN (SELECT user_id FROM orders WHERE product_type IN ? AND (status = ? OR status = ?)) OR (is_vip = 1 AND vip_expire_date > ?)",
[]string{"fullbook", "vip"}, "paid", "completed", time.Now())
}
var total int64
q.Count(&total)
@@ -413,8 +433,8 @@ func DBUsersList(c *gin.Context) {
query = query.Where("COALESCE(nickname,'') LIKE ? OR COALESCE(phone,'') LIKE ? OR id LIKE ?", pattern, pattern, pattern)
}
if vipFilter == "true" || vipFilter == "1" {
query = query.Where("id IN (SELECT user_id FROM orders WHERE product_type IN ? AND status = ?)",
[]string{"fullbook", "vip"}, "paid")
query = query.Where("id IN (SELECT user_id FROM orders WHERE product_type IN ? AND (status = ? OR status = ?)) OR (is_vip = 1 AND vip_expire_date > ?)",
[]string{"fullbook", "vip"}, "paid", "completed", time.Now())
}
if err := query.Order("created_at DESC").
Offset((page - 1) * pageSize).
@@ -524,8 +544,12 @@ func DBUsersList(c *gin.Context) {
// 填充每个用户的实时计算字段
for i := range users {
uid := users[i].ID
// 购买状态
users[i].HasFullBook = ptrBool(hasFullBookMap[uid])
// 购买状态(含手动设置的 VIPis_vip=1 且 vip_expire_date>NOW
hasFull := hasFullBookMap[uid]
if users[i].IsVip != nil && *users[i].IsVip && users[i].VipExpireDate != nil && users[i].VipExpireDate.After(time.Now()) {
hasFull = true
}
users[i].HasFullBook = ptrBool(hasFull)
users[i].PurchasedSectionCount = sectionCountMap[uid]
// 分销收益
totalE := referrerEarningsMap[uid]
@@ -588,7 +612,7 @@ func DBUsersAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "user": u, "isNew": true, "message": "用户创建成功"})
return
}
// PUT 更新
// PUT 更新(含 VIP 手动设置is_vip、vip_expire_date、vip_name、vip_avatar、vip_project、vip_contact、vip_bio
var body struct {
ID string `json:"id"`
Nickname *string `json:"nickname"`
@@ -599,11 +623,31 @@ func DBUsersAction(c *gin.Context) {
IsAdmin *bool `json:"isAdmin"`
Earnings *float64 `json:"earnings"`
PendingEarnings *float64 `json:"pendingEarnings"`
IsVip *bool `json:"isVip"`
VipExpireDate *string `json:"vipExpireDate"` // "2026-12-31" 或 "2026-12-31 23:59:59"
VipName *string `json:"vipName"`
VipAvatar *string `json:"vipAvatar"`
VipProject *string `json:"vipProject"`
VipContact *string `json:"vipContact"`
VipBio *string `json:"vipBio"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户ID不能为空"})
return
}
// 手动设置 VIP 时,必须提供有效到期日
if body.IsVip != nil && *body.IsVip {
if body.VipExpireDate == nil || strings.TrimSpace(*body.VipExpireDate) == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "开启 VIP 时请填写有效到期日"})
return
}
if _, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(*body.VipExpireDate), time.Local); err != nil {
if _, err2 := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(*body.VipExpireDate), time.Local); err2 != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "到期日格式无效,请使用 YYYY-MM-DD"})
return
}
}
}
updates := map[string]interface{}{}
if body.Nickname != nil {
updates["nickname"] = *body.Nickname
@@ -629,10 +673,51 @@ func DBUsersAction(c *gin.Context) {
if body.PendingEarnings != nil {
updates["pending_earnings"] = *body.PendingEarnings
}
if body.IsVip != nil {
updates["is_vip"] = *body.IsVip
}
if body.VipExpireDate != nil {
if *body.VipExpireDate == "" {
updates["vip_expire_date"] = nil
} else {
if t, err := time.ParseInLocation("2006-01-02", *body.VipExpireDate, time.Local); err == nil {
updates["vip_expire_date"] = t
} else if t, err := time.ParseInLocation("2006-01-02 15:04:05", *body.VipExpireDate, time.Local); err == nil {
updates["vip_expire_date"] = t
}
}
}
if body.VipName != nil {
updates["vip_name"] = *body.VipName
}
if body.VipAvatar != nil {
updates["vip_avatar"] = *body.VipAvatar
}
if body.VipProject != nil {
updates["vip_project"] = *body.VipProject
}
if body.VipContact != nil {
updates["vip_contact"] = *body.VipContact
}
if body.VipBio != nil {
updates["vip_bio"] = *body.VipBio
}
if len(updates) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "没有需要更新的字段"})
return
}
// VIP 相关更新时记录日志(手动设置)
if body.IsVip != nil || body.VipExpireDate != nil || body.VipName != nil || body.VipAvatar != nil || body.VipProject != nil || body.VipContact != nil || body.VipBio != nil {
isVipStr := "-"
if body.IsVip != nil {
isVipStr = fmt.Sprintf("%v", *body.IsVip)
}
vipExpire := "-"
if body.VipExpireDate != nil {
vipExpire = *body.VipExpireDate
}
fmt.Printf("[VIP] 设置方式=手动设置, userId=%s, isVip=%s, vipExpireDate=%s\n", body.ID, isVipStr, vipExpire)
}
if err := db.Model(&model.User{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return

View File

@@ -463,7 +463,7 @@ func MiniprogramPayNotify(c *gin.Context) {
"is_vip": true,
"vip_expire_date": expireDate,
})
fmt.Printf("[PayNotify] 用户开通VIP: %s, 过期日 %s\n", buyerUserID, expireDate.Format("2006-01-02"))
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s\n", buyerUserID, orderSn, expireDate.Format("2006-01-02"))
} else if attach.ProductType == "match" {
fmt.Printf("[PayNotify] 用户购买匹配次数: %s订单 %s\n", buyerUserID, orderSn)
} else if attach.ProductType == "section" && attach.ProductID != "" {

View File

@@ -119,7 +119,7 @@ func VipStatus(c *gin.Context) {
func buildVipProfile(u *model.User) gin.H {
name, project, contact, avatar, bio := "", "", "", "", ""
if u.VipName != nil {
if u.VipName != nil && *u.VipName != "" {
name = *u.VipName
}
if name == "" && u.Nickname != nil {
@@ -137,7 +137,7 @@ func buildVipProfile(u *model.User) gin.H {
if contact == "" && u.WechatID != nil {
contact = *u.WechatID
}
if u.VipAvatar != nil {
if u.VipAvatar != nil && *u.VipAvatar != "" {
avatar = *u.VipAvatar
}
if avatar == "" && u.Avatar != nil {
@@ -169,7 +169,7 @@ func VipProfileGet(c *gin.Context) {
}
// VipProfilePost POST /api/miniprogram/vip/profile 小程序-更新 VIP 资料
// 仅 VIP 会员可更新,更新 vip_name/vip_project/vip_contact/vip_avatar/vip_bio
// 仅 VIP 会员可更新,更新 vip_name/vip_avatar/vip_project/vip_contact/vip_bio
func VipProfilePost(c *gin.Context) {
var req struct {
UserID string `json:"userId" binding:"required"`

View File

@@ -23,14 +23,14 @@ type User struct {
WithdrawnEarnings *float64 `gorm:"column:withdrawn_earnings;type:decimal(10,2)" json:"withdrawnEarnings,omitempty"`
Source *string `gorm:"column:source;size:50" json:"source,omitempty"`
// VIP 相关(与 next-project 线上 users 表一致)
IsVip *bool `gorm:"column:is_vip" json:"-"`
VipExpireDate *time.Time `gorm:"column:vip_expire_date" json:"-"`
VipName *string `gorm:"column:vip_name;size:100" json:"-"`
VipAvatar *string `gorm:"column:vip_avatar;size:500" json:"-"`
VipProject *string `gorm:"column:vip_project;size:200" json:"-"`
VipContact *string `gorm:"column:vip_contact;size:100" json:"-"`
VipBio *string `gorm:"column:vip_bio;type:text" json:"-"`
// VIP 相关(与 next-project 线上 users 表一致,支持手动设置;管理端需读写
IsVip *bool `gorm:"column:is_vip" json:"isVip,omitempty"`
VipExpireDate *time.Time `gorm:"column:vip_expire_date" json:"vipExpireDate,omitempty"`
VipName *string `gorm:"column:vip_name;size:100" json:"vipName,omitempty"`
VipAvatar *string `gorm:"column:vip_avatar;size:500" json:"vipAvatar,omitempty"`
VipProject *string `gorm:"column:vip_project;size:200" json:"vipProject,omitempty"`
VipContact *string `gorm:"column:vip_contact;size:100" json:"vipContact,omitempty"`
VipBio *string `gorm:"column:vip_bio;type:text" json:"vipBio,omitempty"`
// 以下为接口返回时从订单/绑定表实时计算的字段,不入库
PurchasedSectionCount int `gorm:"-" json:"purchasedSectionCount,omitempty"`

View File

@@ -1 +0,0 @@
exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1

View File

@@ -0,0 +1,120 @@
# 会员分润差异化 - 技术分析
> 规则:购买 1980 会员,推广者会员分 20%396 元),非会员分 10%198 元)。好友优惠仅针对文章/内容,会员订单无优惠。
---
## 一、需求要点
| 项目 | 内容订单 | 会员订单 |
|------|----------|----------|
| 好友优惠 | 5%(买 1 元付 0.95 | 无1980 即实付) |
| 推广者分成 | 90%(按原价) | 会员 20% / 非会员 10% |
| 佣金基数 | 原价 = 实付 / (1-5%) | 实付金额 |
---
## 二、当前实现梳理
### 2.1 佣金计算入口(需统一改造)
| 位置 | 文件 | 用途 | 当前逻辑 |
|------|------|------|----------|
| 1 | `miniprogram.go` `processReferralCommission` | 支付回调实时分佣 | amount × distributorShare内容订单反推原价 |
| 2 | `cron.go` SyncOrders | 漏单补跑分佣 | 同上 |
| 3 | `withdraw.go` `computeAvailableWithdraw` | 可提现校验 | SUM(amount) × distributorShare |
| 4 | `referral.go` ReferralData | 分销中心数据 | 同上earningsDetails 逐单 × distributorShare |
| 5 | `referral.go` MyEarnings | 我的收益卡片 | SUM(amount) × distributorShare |
| 6 | `db.go` DBUsersList | 用户列表收益 | referrer 分组 SUM(amount) × distributorShare |
| 7 | `db.go` DBUsersReferrals | 绑定详情收益 | 同上 |
| 8 | `orders.go` OrdersList | 订单列表 referrerEarnings | 单订单 amount × distributorShare |
| 9 | `admin_withdrawals.go` | 提现审批校验 | 使用 computeAvailableWithdraw |
### 2.2 配置与数据流
- **referral_config**`distributorShare`(90)、`userDiscount`(5)、`minWithdrawAmount``bindingDays`
- **订单表 orders**`product_type``amount``referrer_id`
- **用户表 users**`is_vip``vip_expire_date`(判断推广者是否会员)
---
## 三、改造方案
### 3.1 配置扩展
`referral_config` 中新增(管理端推广设置页需同步):
```json
{
"distributorShare": 90,
"userDiscount": 5,
"vipOrderShareVip": 20, // 推广者是会员时,会员订单分润 %
"vipOrderShareNonVip": 10 // 推广者非会员时,会员订单分润 %
}
```
默认值:`vipOrderShareVip=20``vipOrderShareNonVip=10`
### 3.2 核心函数:按订单计算佣金
新增共享函数(建议放在 `internal/handler/referral_commission.go``miniprogram.go` 同包):
```go
// computeOrderCommission 按订单计算应付给推广者的佣金
// order: 已支付订单,需有 product_type、amount、referrer_id
// referrerUser: 推广者用户信息,用于判断 is_vip可为 nil会查库
// 返回:该订单的佣金金额
func computeOrderCommission(db *gorm.DB, order *model.Order, referrerUser *model.User) float64
```
逻辑:
1.`order.ReferrerID == nil` 或空 → 返回 0
2. 读取 referral_config`distributorShare``userDiscount``vipOrderShareVip``vipOrderShareNonVip`
3. **会员订单**`product_type == "vip"`
- 佣金基数 = `order.Amount`(无好友优惠)
- 查推广者 `is_vip`(若 referrerUser 为 nil 则查 users 表)
- 会员:`base × vipOrderShareVip/100`,非会员:`base × vipOrderShareNonVip/100`
4. **内容订单**section、fullbook 等):
- 若有推荐人且 userDiscount>0基数 = `amount / (1 - userDiscount)`
- 否则:基数 = amount
- 佣金 = 基数 × distributorShare
### 3.3 各入口改造要点
| 入口 | 改造方式 |
|------|----------|
| **processReferralCommission** | 传入 order含 product_type内部用 computeOrderCommission会员订单不再用 userDiscount 反推 |
| **SyncOrders** | 传入完整 order含 product_type同上 |
| **computeAvailableWithdraw** | 改为:查该用户作为 referrer 的所有已支付订单,逐条调用 computeOrderCommission 求和 |
| **ReferralData** | totalCommission、earningsDetails 逐单用 computeOrderCommission |
| **MyEarnings** | 查订单列表,逐单 computeOrderCommission 求和 |
| **DBUsersList** | 不能再用 `SUM(amount) GROUP BY referrer_id`,需按 referrer 查订单,逐单算佣金再汇总(或写 Raw SQL 聚合) |
| **DBUsersReferrals** | 同上 |
| **OrdersList** | 单订单 referrerEarnings = computeOrderCommission(db, &order, nil) |
### 3.4 管理端
- **AdminReferralSettingsGet/Post**:请求体增加 `vipOrderShareVip``vipOrderShareNonVip`
- **soul-admin 推广设置页**:增加「会员订单分润(会员 %)」「会员订单分润(非会员 %)」两个输入框
---
## 四、实现顺序建议
1. **新增 computeOrderCommission**:独立函数,便于单测和复用
2. **扩展 referral_config**:读写新字段,管理端 UI
3. **改造 processReferralCommission**:支付回调与 cron 补跑
4. **改造 computeAvailableWithdraw**:提现相关逻辑
5. **改造 ReferralData、MyEarnings**:小程序分销/收益展示
6. **改造 DBUsersList、DBUsersReferrals、OrdersList**:管理端展示
---
## 五、注意事项
1. **历史订单**:改造后,历史会员订单的「应得佣金」会按新规则重算。若之前已按 90% 写入 pending_earnings可能产生偏差。建议
- 仅对新支付订单按新规则;
- 或做一次性数据修正(复杂,需评估)
2. **推广者 is_vip 时点**:以**支付成功时**推广者的 is_vip 状态为准(查 users 表)
3. **订单补记**:补记订单可能缺 referrer_id需从 referral_bindings 解析 referrer并回写 order.referrer_id 以便后续统计一致

View File

@@ -0,0 +1,24 @@
## 一、分销规则
### 1.1 比例与限额(默认)
| 项目 | 默认值 | 说明 |
|------|--------|------|
| 推广者分成 | 90% | 买家实付金额的 90% 作为推广者佣金 |
| 平台/作者 | 10% | 剩余 10% 归平台或作者 |
| 好友优惠 | 5% | 通过推广链接进来的用户购买时可享 5% 折扣 |
| 绑定期限 | 30 天 | 从“通过谁的链接进来”算起30 天内有效;可续期 |
| 最低提现 | 10 元 | 单次申请提现不能低于 10 元 |
以上比例与金额可在后台配置中调整。
### 1.2 一级分销
- 只认直接推荐人:谁分享的链接带来这位买家,佣金就算谁的。
- 没有二级、三级分佣,即“下线的下线”不会给你分佣。
### 1.3 绑定与更换推荐人
- 同一推荐人:同一个人再次通过你的链接进来并登录,只把绑定期限再延长 30 天,不会重复建立多条绑定。
- 超过 30 天:若 30 天内没有续期,这条绑定视为过期。之后对方通过别人的链接进入并登录,可以绑定到新的推荐人,之前的推荐关系不再有效。
- 部分版本:点击别人的分享链接即可立即更换为新的推荐人,绑定期限重新按 30 天计算(以实际产品为准)。

View File

@@ -0,0 +1,32 @@
一、关于分销(详细见分销规则)
二、关于分润
1、购买内容分润。通过好友链接购买内容购买可以优惠5%。好友可以获得90%分润。
购买1元内容。支付0.95好友收到0.9
2、购买会员分润。通过好友链接购买会员会员分20%非会员分10%。
购买1980会员好友会员收到396非会员收到198
二、关于资料完善规则
1、提现前必须需要填写手机号、微信号有单独弹窗填写完后会自动同步保存到资料页里
2、使用找伙伴功能前需要引导完善个人资料
三、关于购买内容
1、基础版章节。单章购买当购买大于等于3的时候弹出是否购买基础打包版本或者增值打包版本
2、增值版章节。单章购买当购买大于等于3的时候弹出是否购买增值打包版本
四、关于找伙伴规则
1、功能规则
创业合伙对接1980会员。前期随机匹配1980的付费用户后期根据资料优先匹配精准需求
匹配次数每日免费3次第四次开始收费支付费用后台可配置。
资料不解锁
存客宝拉3人群
资源对接:进资源群
前置条件1需要先成为付费用户。有任意购买付费订单。
前置条件2需要填写联系方式+我能帮到大家什么+我需要什么帮助
结果:存客宝拉资源群+自我介绍自动推送到群里
导师顾问:对接平台导师客服。
前置条件1导师资料页付费。咨询费。
前置条件2需要填写联系方式
结果:存客宝导师微信直接添加对方。
团队招募:对接平台团队客服。
前置条件1需要填写联系方式
结果:存客宝客服直接添加对方

View File

@@ -0,0 +1,164 @@
# 需求分析 - 产品经理视角
> 基于《分销规则.md》《规则说明.md》与当前代码实现对比
---
## 一、需求概览
| 模块 | 需求来源 | 当前实现状态 | 优先级 |
|------|----------|--------------|--------|
| 会员分润差异化 | 规则说明 §2 | ❌ 未实现 | P0 |
| 购买内容分润 | 分销规则 + 规则说明 | ✅ 已实现 | - |
| 资料完善规则 | 规则说明 §二 | ⚠️ 部分实现 | P1 |
| 购买内容打包引导 | 规则说明 §三 | ❌ 未实现 | P2 |
| 找伙伴规则 | 规则说明 §四 | ⚠️ 需核对 | P1 |
---
## 二、分销 / 分润
### 2.1 购买内容分润(已实现)
| 项目 | 规则要求 | 当前实现 | 结论 |
|------|----------|----------|------|
| 推广者分成 | 90% | 90%referral_config.distributorShare | ✅ |
| 好友优惠 | 5% | 5%referral_config.userDiscount | ✅ |
| 佣金计算基数 | 按原价 | 实付反推原价 = amount / (1 - 5%) | ✅ |
| 示例 | 买 1 元,付 0.95,好友 0.9 | 逻辑一致 | ✅ |
### 2.2 购买会员分润(未实现)⚠️
**规则说明要求:**
- 通过好友链接购买 **1980 会员**
- 推广者为 **会员**:分润 **20%** → 396 元
- 推广者为 **非会员**:分润 **10%** → 198 元
**当前实现:**
- 会员订单与内容订单共用同一比例(默认 90%
- 无「推广者是否会员」判断
- 无「product_type=vip」时的差异化比例
**产品建议:**
1. **referral_config 扩展**:增加会员订单分润配置,例如:
```json
{
"distributorShare": 90,
"userDiscount": 5,
"vipOrderShareVip": 20, // 推广者是会员时,会员订单分润 %
"vipOrderShareNonVip": 10 // 推广者非会员时,会员订单分润 %
}
```
2. **支付回调**`product_type=vip` 时,按推广者 `is_vip` 选择 `vipOrderShareVip` 或 `vipOrderShareNonVip`
3. **管理端**:推广设置页增加「会员订单分润(会员/非会员)」配置项
---
## 三、资料完善规则
### 3.1 提现前填写手机号、微信号
**规则要求:** 提现前必须填写手机号、微信号,有单独弹窗,填写后同步到资料页。
**当前实现:**
- 提现接口有 `needBindWechat` 校验
- 未发现「提现前弹窗填写手机号、微信号」的专门流程
- 资料页与提现流程的联动需核对
**产品建议:**
1. 申请提现时,若手机号或微信号为空 → 弹出「完善资料」弹窗(手机号、微信号必填)
2. 填写完成后调用资料更新接口,再继续提现
3. 弹窗文案:「为便于提现到账,请先完善手机号和微信号」
### 3.2 找伙伴前完善资料
**规则要求:** 使用找伙伴功能前需要引导完善个人资料。
**当前实现:** 需核对 match 页是否有「资料未完善则引导」逻辑。
**产品建议:** 进入找伙伴页时,若资料未完善(如联系方式、我能帮到大家什么、我需要什么帮助等),先展示引导弹窗/页,完善后再进入功能。
---
## 四、购买内容
### 4.1 基础版章节打包引导
**规则要求:** 单章购买 ≥ 3 时,弹出是否购买「基础打包版本」或「增值打包版本」。
**当前实现:** 未发现打包引导逻辑。
**产品建议:**
1. 在 read 页购买章节时,累计已购章节数 ≥ 3 时触发弹窗
2. 弹窗选项:「购买基础打包版」「购买增值打包版」「暂不购买」
3. 需明确:基础版 / 增值版对应的 product_id、价格、包含章节
### 4.2 增值版章节打包引导
**规则要求:** 单章购买 ≥ 3 时,弹出是否购买「增值打包版本」。
**产品建议:** 与 4.1 合并设计,区分基础版与增值版的产品定义与价格。
---
## 五、找伙伴规则
### 5.1 创业合伙
| 项目 | 规则 | 实现核对 |
|------|------|----------|
| 对接对象 | 1980 会员 | 随机匹配付费用户,后期按资料精准匹配 |
| 匹配次数 | 每日免费 3 次,第 4 次起收费 | 需核对 match 页逻辑 |
| 资料 | 不解锁 | 需核对 |
### 5.2 资源对接
| 项目 | 规则 | 实现核对 |
|------|------|----------|
| 前置条件 1 | 任意付费订单 | 需核对 |
| 前置条件 2 | 填写联系方式 + 我能帮到大家什么 + 我需要什么帮助 | 需核对 |
| 结果 | 存客宝拉资源群 + 自我介绍推送到群 | 需对接存客宝 |
### 5.3 导师顾问
| 项目 | 规则 | 实现核对 |
|------|------|----------|
| 前置条件 1 | 导师资料页付费(咨询费) | 需核对 |
| 前置条件 2 | 填写联系方式 | 需核对 |
| 结果 | 存客宝导师微信添加对方 | 需对接存客宝 |
### 5.4 团队招募
| 项目 | 规则 | 实现核对 |
|------|------|----------|
| 前置条件 | 填写联系方式 | 需核对 |
| 结果 | 存客宝客服添加对方 | 需对接存客宝 |
**产品建议:** 对 match 相关页面做一次完整规则对照,确认前置条件、次数限制、资料展示与存客宝对接方式。
---
## 六、实现优先级建议
| 优先级 | 需求 | 工作量 | 依赖 |
|--------|------|--------|------|
| **P0** | 会员订单分润差异化20% / 10% | 中 | referral_config、支付回调、管理端配置 |
| **P1** | 提现前资料完善弹窗 | 小 | 资料接口、提现流程 |
| **P1** | 找伙伴前置条件与引导 | 中 | match 页、资料字段 |
| **P2** | 单章 ≥ 3 时打包购买引导 | 中 | 产品定义、read 页 |
| **P2** | 存客宝对接(资源群、导师、客服) | 大 | 存客宝 API / 人工流程 |
---
## 七、待确认事项
1. **会员分润**1980 会员是否还有好友优惠(如 5%)?规则说明未写,建议与业务确认。
2. **打包版本**:基础打包版、增值打包版的具体 product_id、价格、包含章节。
3. **存客宝**资源群、导师、客服的对接方式API / 人工 / 工单)。
4. **找伙伴**:每日免费次数、第 4 次起收费金额是否已在后台可配置。

BIN
开发文档/.DS_Store vendored

Binary file not shown.

BIN
开发文档/10、项目管理/.DS_Store vendored Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -31,8 +31,8 @@
## soul-admin 变更
- 侧边栏:交易中心 → 推广中心
- 内容管理:移除 5 按钮,保留 API 接口,删除 hover免费/付费切换,小节加号
- 侧边栏:交易中心 → 推广中心(永平 2026-02 已落地)
- 内容管理:移除 5 按钮,保留API 接口」按钮,点击打开 API 文档(永平 2026-02 源码改造已落地)
- 缓存:?v=2 强制刷新,修复 Failed to fetch
## 永平版优化对比
@@ -79,3 +79,40 @@ VIP 接口、章节推荐逻辑、数据库依赖
- **wx.chooseLocation / wx.choosePoi**:线下见面与资源对接,用户需选择见面地点
详见原「小程序接口申请文案」完整文案。
---
# 第五部分永平落地2026-02 依据 cursor_1_14
- **soul-admin**内容管理页仅保留「API 接口」按钮(源码方案);侧栏与推广中心页「交易中心」→「推广中心」。
- **分销**:海报小程序码 scene 带用户 IDref=userId海报上去掉「邀请码」展示我的页「待领收益」统一为「我的收益」。
- **个人/设置**:自动提现默认开启、一键获取手机/微信号已有;后台绑定有效期、自动提现等与 API 已对齐。
- **需求来源**cursor_1_14.md 对话导出中的接口、分销、个人相关更新,已整理至《需求汇总》需求清单。
---
# 第六部分近期讨论2026-02-26 小橙同步)
## 文档整理2026-02-26
- 移除Prisma ORM、Next.js 宝塔、Standalone、拆解计划、近3天更新文档、8部署历史修复说明约 35 份)
- 新增:开发文档/README.md 索引
- 更新:部署总览(当前架构 soul-api + soul-admin + miniprogram
## VIP 相关
- **VIP 判断**:以 users 表为主(`is_vip=1``vip_expire_date>NOW`orders 表兜底
- **VIP 设置**支持手动设置管理端用户详情弹窗与支付设置1980 会员支付回调)
- **VIP 日志**:手动设置输出 `[VIP] 设置方式=手动设置, userId=xxx, isVip=xxx, vipExpireDate=xxx`;支付设置输出 `[VIP] 设置方式=支付设置, userId=xxx, orderSn=xxx, 过期日=xxx`
- **管理端设置 VIP**:用户详情弹窗 → VIP 手动设置区块;开启 VIP 时必填到期日(前后端校验)
## 分润规则
- **内容订单**:好友优惠 5%(仅针对文章/内容,会员订单无优惠);推广者 90%
- **会员订单**:推广者会员 20%、非会员 10%(待实现,技术分析见 `临时需求池/分润需求-技术分析.md`
## 需求池
- **需求分析**`临时需求池/需求分析-产品经理视角.md`
- **分润技术分析**`临时需求池/分润需求-技术分析.md`
- **搁置**:打包购买引导、存客宝对接

View File

@@ -354,7 +354,7 @@ vercel --prod
**最后更新人**:卡若 (智能助手)
**项目交付状态**:✅ 完整交付
**近三日更新**:见 [运营与变更.md](./运营与变更.md)。
**近更新**:见 [运营与变更.md](./运营与变更.md) 第六部分
---
@@ -424,3 +424,16 @@ vercel --prod
| P2 | 文档与分支同步 | 文档 | 定期将 yongpxu-soul 的部署/小程序/运维文档变更合并到主分支或文档目录,保持《链路优化与运行指南》《本机运行文档》与线上一致。 |
以上按 P0 → P1 → P2 顺序推进P0 完成即可上线跑通整条链路P1/P2 为体验与可维护性增强。
---
## 十二、永平落地2026-02 依据 cursor_1_14
| 任务 | 状态 | 说明 |
|------|------|------|
| 内容管理仅保留「API 接口」按钮 | 已完成 | soul-admin ContentPage 源码改造,移除 5 按钮,新增 API 接口按钮 |
| 侧栏与推广中心页「交易中心」→「推广中心」 | 已完成 | AdminLayout、DistributionPage 文案统一 |
| 分销:海报带用户 ID、复制文案去掉邀请码展示 | 已完成 | referral.js scene 用 userId海报去掉邀请码文案 |
| 我的页:待领收益→我的收益 | 已完成 | my.wxml 未登录卡片文案统一 |
| 后台与前台参数一致(绑定有效期、自动提现、免费章节) | 已检查 | 推广设置、系统设置与 API 对齐 |
| 需求与文档整理 | 已完成 | 需求汇总需求清单、运营与变更第五部分、本推进表十二节 |

View File

@@ -15,3 +15,22 @@ Soul 创业派对整体定位、闭环、用户画像;小程序改造与迭代
## 卡若角色设定
IP 设定、风格、输出规范(见原卡若角色设定)。
---
## 需求清单(来自 cursor_1_14 与永平落地)
| 日期 | 描述 | 状态 | 备注 |
|------|------|------|------|
| 2026-02 | 内容管理页仅保留「API 接口」按钮 | 已完成 | soul-admin ContentPage 源码改造 |
| 2026-02 | 侧栏与分销页「交易中心」→「推广中心」 | 已完成 | AdminLayout、DistributionPage |
| 2026-02 | 推广中心/我的收益:绑定中、已付款、已过期清晰展示 | 已有 | referral 页数据结构支持 |
| 2026-02 | 海报小程序码带用户 IDscene ref=userId | 已完成 | referral.js generatePoster |
| 2026-02 | 复制朋友圈文案去掉「专属邀请码」展示 | 已完成 | 海报上去掉邀请码文案 |
| 2026-02 | 我的页:待领收益→我的收益、头像/昵称/ID 一键获取 | 已有/已完成 | my.wxml 文案统一 |
| 2026-02 | 设置页:手机/微信号一键获取、自动提现默认开启 | 已有 | settings.js |
| 2026-02 | 后台与前台参数一致(绑定有效期、自动提现、免费章节等) | 已检查 | 推广设置、系统设置已对齐 |
| 2026-02 | 找伙伴匹配后台用户库、资源对接两步(能帮什么/需要什么) | 已有 | match 页与后端 /api/match |
| 2026-02 | VIP 手动设置 + 支付设置 + 日志(区分来源、订单号) | 已完成 | 用户详情弹窗、支付回调、db/miniprogram handler |
| 2026-02 | 管理端设置 VIP 必填到期日 | 已完成 | 前后端校验 |
| 2026-02 | 会员订单分润差异化(会员 20% / 非会员 10% | 待实现 | 技术分析见 临时需求池/分润需求-技术分析.md |

View File

@@ -1,248 +0,0 @@
# 拆解计划:管理端抽离 + API 转 GinAPI 路径不变、无缝切换)
## 目标与原则
- **管理端**:从当前 Next 单体中抽离为独立前端项目SPA所有请求使用**可配置 baseUrl****API 路径与现网完全一致**(如 `/api/orders``/api/admin/withdrawals`),便于先对接到现有 Next后续一键切到 Gin。
- **小程序**:不改动;仅在未来切换后端时修改 `baseUrl` 指向 Gin。
- **后端**:先仍由当前 Next 提供 API第二阶段用 **Gin** 重写全部接口,**路径、方法、请求/响应体与现有 Next API 保持一致**,实现无缝切换。
---
## 阶段一:管理端抽离(独立前端)
### 1.1 产出物
- 新仓库或新目录:**soul-admin**(独立 SPA
- 技术栈React 18 + TypeScript + Vite 5 + React Router 6 + Tailwind CSS 4 + Radix UI与现有一致+ Zustand + 统一 API 封装baseUrl 来自 env
### 1.2 API 基地址规范(必须 100% 遵守)
- 环境变量:`VITE_API_BASE_URL`(如开发 `http://localhost:3006`,生产 `https://soul.quwanzhi.com`)。
- 所有请求统一为:`${VITE_API_BASE_URL}${path}`,其中 **path 与现网完全一致**,例如:
- `/api/admin`GET 鉴权 / POST 登录)
- `/api/admin/logout`POST
- `/api/orders`GET
- `/api/db/users`GET/POST/DELETE
- `/api/admin/withdrawals`GET/POST
- 等等(见下方完整清单)。
- 禁止在管理端项目内写死域名或写死 `/api/...` 相对路径(相对路径仅在请求封装内与 baseUrl 拼接)。
### 1.3 管理端页面与 API 对照表(迁移时逐项核对)
| 管理端页面 | 使用的 API路径保持不变 |
|------------|----------------------------|
| 登录 `/admin/login` | POST `/api/admin`登录、GET `/api/admin`(鉴权) |
| 布局/侧栏 | GET `/api/admin`鉴权、POST `/api/admin/logout`(退出) |
| 数据概览 `/admin` | GET `/api/db/users`、GET `/api/orders` |
| 订单管理 `/admin/orders` | GET `/api/orders`、GET `/api/db/users` |
| 用户管理 `/admin/users` | GET `/api/db/users`、DELETE `/api/db/users?id=xxx`、POST `/api/db/users`、GET `/api/db/users/referrals?userId=xxx` |
| 交易中心 `/admin/distribution` | GET `/api/admin/distribution/overview`、GET `/api/db/users`、GET `/api/orders`、GET `/api/db/distribution`、GET `/api/admin/withdrawals`、POST `/api/admin/withdrawals`(审核/打款) |
| 提现管理 `/admin/withdrawals` | GET `/api/admin/withdrawals?status=xxx`、POST `/api/admin/withdrawals`(审核/打款) |
| 内容管理 `/admin/content` | GET `/api/db/book?action=read&id=xxx`、POST `/api/db/book`、POST `/api/upload`、GET `/api/search?q=xxx`、GET `/api/db/book?action=export`、POST `/api/db/init` |
| 章节管理 `/admin/chapters` | GET `/api/admin/chapters`、POST `/api/admin/chapters`、PUT `/api/admin/chapters`、DELETE `/api/admin/chapters` |
| 推广设置 `/admin/referral-settings` | GET `/api/db/config?key=referral_config`、POST `/api/db/config` |
| 系统设置 `/admin/settings` | GET `/api/db/config`、POST `/api/db/settings`、POST `/api/db/config`多次、GET `/api/db/config`(验证) |
| 站点/支付/二维码 `/admin/site``/admin/payment``/admin/qrcodes` | 依赖 `useStore().fetchSettings()` → GET `/api/config`;若另有保存逻辑需按实际请求补全 |
| 找伙伴配置 `/admin/match` | GET `/api/db/config?key=match_config`、POST `/api/db/config` |
说明:`/api/db/settings` 在当前 Next 中未见对应 route若 Next 未实现则 Gin 阶段需实现该路径并与管理端约定请求/响应体。
### 1.4 迁移清单(执行顺序)— Phase 1 已完成
**已完成**:独立项目 `soul-admin/` 已创建,所有请求通过 `src/api/client.ts` 使用 `VITE_API_BASE_URL` + 与现网一致的 pathAPI 路径未做任何改动。
1. 创建 soul-admin 项目Vite + React + TS + Tailwind + React Router
2. 配置 `VITE_API_BASE_URL`,实现统一请求封装(如 `src/api/client.ts`),所有 `fetch` 使用 `baseUrl + path`path 与上表一致。
3. 迁移 `app/admin/layout.tsx` → 管理端布局与侧栏;鉴权请求 GET `/api/admin`、POST `/api/admin/logout` 走封装。
4. 迁移 `app/admin/login/page.tsx` → 登录页POST `/api/admin` 走封装。
5. 按上表逐页迁移:`page.tsx``loading.tsx`(可改为本地 loading 状态),替换 `fetch('/api/...')` 为封装后的请求(路径不变)。
6. 迁移管理端用到的 `components/ui/*``components/admin/*`(若有);迁移 `lib/store.ts` 中管理端会用到的部分,且其中 `fetch` 改为使用 baseUrl 封装。
7. 移除对 `next/link``next/navigation` 的依赖,改用 React Router 的 `Link``useNavigate``useLocation`;路由表与现有 `/admin/*` 一一对应。
8. 验收:独立启动管理端,`VITE_API_BASE_URL=http://localhost:3006`,登录、各页数据加载、提现/订单等操作均正常,且浏览器网络请求 path 与现网一致。
---
## 阶段二API 转 Gin路径不变、无缝切换
### 2.1 产出物
- 新仓库或新目录:**soul-server**Gin 项目Go 1.25.7)。
- 所有对外 HTTP 路径与 Next 时期**完全一致**请求方法、Query、Body、响应 JSON 结构与现有接口保持一致(以便管理端与小程序的 baseUrl 仅改域名/端口即可)。
### 2.2 完整 API 路径清单73 个 Route → 路径规范)
以下为当前 `app/api` 下 route 与路径的对应关系Gin 需逐条实现,路径不可改。
| 序号 | 路径 | 方法 | 说明 |
|------|------|------|------|
| 1 | /api/admin | GET, POST | 鉴权 / 登录 |
| 2 | /api/admin/logout | POST | 退出 |
| 3 | /api/admin/chapters | GET, POST, PUT, DELETE | 后台章节 |
| 4 | /api/admin/content | (见 content) | 后台内容 |
| 5 | /api/admin/distribution/overview | GET | 分销概览 |
| 6 | /api/admin/payment | (依实现) | 后台支付配置 |
| 7 | /api/admin/referral | (依实现) | 后台推荐 |
| 8 | /api/admin/withdrawals | GET, POST | 提现列表与审核 |
| 9 | /api/auth/login | POST | C 端登录 |
| 10 | /api/auth/reset-password | POST | 重置密码 |
| 11 | /api/book/all-chapters | GET | 全部章节 |
| 12 | /api/book/chapter/:id | GET | 单章 |
| 13 | /api/book/chapters | GET, POST, PUT, DELETE | 章节 CRUD |
| 14 | /api/book/hot | GET | 热门 |
| 15 | /api/book/latest-chapters | GET | 最新章节 |
| 16 | /api/book/stats | GET | 统计 |
| 17 | /api/book/search | GET | 搜索 |
| 18 | /api/book/sync | POST | 同步 |
| 19 | /api/config | GET | 前端配置 |
| 20 | /api/content | GET | 内容 |
| 21 | /api/ckb/join | POST | CKB 加入 |
| 22 | /api/ckb/match | POST | CKB 匹配 |
| 23 | /api/ckb/sync | POST | CKB 同步 |
| 24 | /api/cron/sync-orders | GET/POST | 定时同步订单 |
| 25 | /api/cron/unbind-expired | GET/POST | 定时解绑 |
| 26 | /api/db/book | GET, POST | 书/内容 |
| 27 | /api/db/chapters | GET | 章节 |
| 28 | /api/db/config | GET, POST | 系统配置 |
| 29 | /api/db/distribution | GET | 分销数据 |
| 30 | /api/db/init | POST | 初始化 |
| 31 | /api/db/migrate | POST | 迁移 |
| 32 | /api/db/users | GET, POST, DELETE | 用户 |
| 33 | /api/db/users/referrals | GET | 用户推荐列表 |
| 34 | /api/distribution | GET | 分销 |
| 35 | /api/distribution/auto-withdraw-config | GET, POST | 自动提现配置 |
| 36 | /api/distribution/messages | GET, POST | 消息 |
| 37 | /api/documentation/generate | POST | 文档生成 |
| 38 | /api/match/config | GET, POST | 找伙伴配置 |
| 39 | /api/match/users | GET | 匹配用户 |
| 40 | /api/menu | GET | 菜单 |
| 41 | /api/miniprogram/login | POST | 小程序登录 |
| 42 | /api/miniprogram/pay | POST | 小程序支付 |
| 43 | /api/miniprogram/pay/notify | POST | 支付回调 |
| 44 | /api/miniprogram/phone | POST | 手机号 |
| 45 | /api/miniprogram/qrcode | GET | 二维码 |
| 46 | /api/orders | GET | 订单列表 |
| 47 | /api/payment/alipay/notify | POST | 支付宝回调 |
| 48 | /api/payment/callback | GET/POST | 支付回调 |
| 49 | /api/payment/create-order | POST | 创建订单 |
| 50 | /api/payment/methods | GET | 支付方式 |
| 51 | /api/payment/query | GET | 查询订单 |
| 52 | /api/payment/status/:orderSn | GET | 订单状态 |
| 53 | /api/payment/verify | POST | 核销 |
| 54 | /api/payment/wechat/notify | POST | 微信支付回调 |
| 55 | /api/payment/wechat/transfer/notify | POST | 微信转账回调 |
| 56 | /api/referral/bind | POST | 绑定推荐码 |
| 57 | /api/referral/data | GET | 分销数据 |
| 58 | /api/referral/visit | POST | 推荐访问 |
| 59 | /api/search | GET | 搜索 |
| 60 | /api/sync | POST | 同步 |
| 61 | /api/upload | POST | 上传 |
| 62 | /api/user/addresses | GET, POST | 地址列表与新增 |
| 63 | /api/user/addresses/:id | GET, PUT, DELETE | 地址单条 |
| 64 | /api/user/check-purchased | GET | 是否已购 |
| 65 | /api/user/profile | GET, POST | 用户资料 |
| 66 | /api/user/purchase-status | GET | 购买状态 |
| 67 | /api/user/reading-progress | GET, POST | 阅读进度 |
| 68 | /api/user/track | GET | 行为轨迹 |
| 69 | /api/user/update | POST | 更新用户 |
| 70 | /api/wechat/login | POST | 微信登录 |
| 71 | /api/withdraw | POST | 申请提现 |
| 72 | /api/withdraw/records | GET | 提现记录 |
| 73 | /api/withdraw/pending-confirm | GET | 待确认提现 |
说明:部分路径在 Next 中为同一 route 多方法(如 GET/POSTGin 需按现有行为实现;带 `:id``:orderSn` 为路径参数,与 Next 动态段一致。
### 2.3 Gin 实现顺序建议
1. 基础设施Go 1.25.7、Gin、GORM、MySQL、env 配置、CORS、日志。
2. 鉴权:复刻现有 Cookie 或改为 JWT若改 JWT管理端需同步改为带 Authorization 头);小程序 openid/session 逻辑与现网一致。
3. 按「管理端必需」优先:`/api/admin``/api/admin/logout``/api/db/*``/api/orders``/api/admin/withdrawals``/api/admin/distribution/overview``/api/admin/chapters``/api/config``/api/db/config``/api/db/settings`(若现网无则按管理端调用约定实现)。
4. 再实现小程序与 C 端所需:`/api/miniprogram/*``/api/wechat/*``/api/user/*``/api/book/*``/api/referral/*``/api/payment/*``/api/withdraw/*` 等。
5. 最后:定时任务对应接口、文档生成等。
### 2.4 无缝切换检查清单
- [ ] 管理端 `VITE_API_BASE_URL` 改为 Gin 地址后,登录、各页请求均 200数据与 Next 时期一致。
- [ ] 小程序 `baseUrl` 改为 Gin 地址后,登录、支付、提现、推荐等流程正常。
- [ ] 所有 73 个路径在 Gin 中均有实现,且请求/响应与现网兼容(可做契约测试或对比脚本)。
---
## 附录:当前 Next 中 route 文件与路径映射(供 Gin 对照)
```
app/api/admin/route.ts → /api/admin
app/api/admin/logout/route.ts → /api/admin/logout
app/api/admin/chapters/route.ts → /api/admin/chapters
app/api/admin/content/route.ts → /api/admin/content
app/api/admin/distribution/overview/route.ts → /api/admin/distribution/overview
app/api/admin/payment/route.ts → /api/admin/payment
app/api/admin/referral/route.ts → /api/admin/referral
app/api/admin/withdrawals/route.ts → /api/admin/withdrawals
app/api/auth/login/route.ts → /api/auth/login
app/api/auth/reset-password/route.ts → /api/auth/reset-password
app/api/book/all-chapters/route.ts → /api/book/all-chapters
app/api/book/chapter/[id]/route.ts → /api/book/chapter/:id
app/api/book/chapters/route.ts → /api/book/chapters
app/api/book/hot/route.ts → /api/book/hot
app/api/book/latest-chapters/route.ts → /api/book/latest-chapters
app/api/book/stats/route.ts → /api/book/stats
app/api/book/search/route.ts → /api/book/search
app/api/book/sync/route.ts → /api/book/sync
app/api/config/route.ts → /api/config
app/api/content/route.ts → /api/content
app/api/ckb/join/route.ts → /api/ckb/join
app/api/ckb/match/route.ts → /api/ckb/match
app/api/ckb/sync/route.ts → /api/ckb/sync
app/api/cron/sync-orders/route.ts → /api/cron/sync-orders
app/api/cron/unbind-expired/route.ts → /api/cron/unbind-expired
app/api/db/book/route.ts → /api/db/book
app/api/db/chapters/route.ts → /api/db/chapters
app/api/db/config/route.ts → /api/db/config
app/api/db/distribution/route.ts → /api/db/distribution
app/api/db/init/route.ts → /api/db/init
app/api/db/migrate/route.ts → /api/db/migrate
app/api/db/users/route.ts → /api/db/users
app/api/db/users/referrals/route.ts → /api/db/users/referrals
app/api/distribution/route.ts → /api/distribution
app/api/distribution/auto-withdraw-config/route.ts → /api/distribution/auto-withdraw-config
app/api/distribution/messages/route.ts → /api/distribution/messages
app/api/documentation/generate/route.ts → /api/documentation/generate
app/api/match/config/route.ts → /api/match/config
app/api/match/users/route.ts → /api/match/users
app/api/menu/route.ts → /api/menu
app/api/miniprogram/login/route.ts → /api/miniprogram/login
app/api/miniprogram/pay/route.ts → /api/miniprogram/pay
app/api/miniprogram/pay/notify/route.ts → /api/miniprogram/pay/notify
app/api/miniprogram/phone/route.ts → /api/miniprogram/phone
app/api/miniprogram/qrcode/route.ts → /api/miniprogram/qrcode
app/api/orders/route.ts → /api/orders
app/api/payment/alipay/notify/route.ts → /api/payment/alipay/notify
app/api/payment/callback/route.ts → /api/payment/callback
app/api/payment/create-order/route.ts → /api/payment/create-order
app/api/payment/methods/route.ts → /api/payment/methods
app/api/payment/query/route.ts → /api/payment/query
app/api/payment/status/[orderSn]/route.ts → /api/payment/status/:orderSn
app/api/payment/verify/route.ts → /api/payment/verify
app/api/payment/wechat/notify/route.ts → /api/payment/wechat/notify
app/api/payment/wechat/transfer/notify/route.ts → /api/payment/wechat/transfer/notify
app/api/referral/bind/route.ts → /api/referral/bind
app/api/referral/data/route.ts → /api/referral/data
app/api/referral/visit/route.ts → /api/referral/visit
app/api/search/route.ts → /api/search
app/api/sync/route.ts → /api/sync
app/api/upload/route.ts → /api/upload
app/api/user/addresses/route.ts → /api/user/addresses
app/api/user/addresses/[id]/route.ts → /api/user/addresses/:id
app/api/user/check-purchased/route.ts → /api/user/check-purchased
app/api/user/profile/route.ts → /api/user/profile
app/api/user/purchase-status/route.ts → /api/user/purchase-status
app/api/user/reading-progress/route.ts → /api/user/reading-progress
app/api/user/track/route.ts → /api/user/track
app/api/user/update/route.ts → /api/user/update
app/api/wechat/login/route.ts → /api/wechat/login
app/api/withdraw/route.ts → /api/withdraw
app/api/withdraw/records/route.ts → /api/withdraw/records
app/api/withdraw/pending-confirm/route.ts → /api/withdraw/pending-confirm
```
注意:当前项目中没有 `app/api/db/settings/route.ts`,管理端系统设置页调用了 POST `/api/db/settings`。若 Next 未实现Gin 需新增该路径并约定 body/response 与前端一致。

View File

@@ -1,52 +1,56 @@
# 前端开发规范 (Frontend Specs) - 智能自生长文档
> **提示词功能 (Prompt Function)**: 将本文件拖入 AI 对话框,即可激活“前端技术专家”角色,生成符合项目规范的代码。
## 1. 基础上下文
> **提示词功能 (Prompt Function)**: 将本文件拖入 AI 对话框,即可激活“前端技术专家”角色,生成符合 iOS 风格的 React 代码。
## 1. 基础上下文 (The Two Basic Files)
### 1.1 角色档案:卡若 (Karuo)
- **管理端 (soul-admin)**:深色后台风格,稳、快、信息密度合理
- **C 端 (小程序)**:移动端优先,阅读与转化体验顺畅
- **视觉标准**:像素级复刻 iOS (San Francisco, 1:1 间距, 弥散阴影)
- **体验标准**:无白屏 (Skeleton),丝滑转场 (Transition)
### 1.2 技术栈(当前)
### 1.2 技术栈
- **核心**React + Shadcn UI + Tailwind CSS。
- **辅助**Vant UI (移动端组件)。
- **构建**Vite / Next.js。
| 项目 | 技术栈 |
|------|--------|
| **soul-admin** | React 18 + Vite 6 + TypeScript + Tailwind 4 + Radix UI类 Shadcn+ React Router 6 |
| **miniprogram** | 微信小程序原生 (JS/WXML/WXSS) |
## 2. 开发规范核心 (Master Content)
### 2.1 视觉与风格 (iOS)
- **字体**San Francisco > PingFang SC。
- **色彩**
- 背景:`#F2F2F7` (Grouped Background)。
- 分割:`#C6C6C8`
- 交互:`#007AFF` (System Blue)。
- **细节**
- 圆角:统一 `rounded-lg``rounded-xl`
- 阴影:柔和弥散,非生硬投影。
## 2. 开发规范核心
### 2.1 API 与字段规范(强制)
- **请求/响应字段**:一律**小写开头驼峰camelCase**。
- 正确:`userId``referralCode``createdAt``hasFullBook``pendingEarnings`
- 错误:`user_id``referral_code``created_at`(仅数据库内部使用,不暴露给前端)。
- **类型定义**TypeScript 接口与 API 约定一致,全部 camelCase。
- **表单提交**:提交 body 的字段名使用 camelCase`isAdmin``hasFullBook`)。
### 2.2 soul-admin 规范
- **请求**:所有接口请求通过 `src/api/client.ts` 的 get/post/put/delpath 与现网一致,不写死域名。
- **环境变量**`VITE_API_BASE_URL` 指向 soul-api 或现有 API 基地址(开发/生产分开配置)。
- **目录**:页面在 `src/pages/`,通用 UI 在 `src/components/ui/`,业务模块在 `src/components/modules/`
- **样式**Tailwind 为主,深色主题可用 `#0f2137``#38bdac` 等品牌色。
### 2.3 小程序规范
- **请求**:通过 `app.request()` 等封装baseUrl 可配置请求体与后端约定一致camelCase
- **存储**:需要与后端一致的标识(如 `referral_code` 存为业务值可保留 key 名)时,注意与接口字段区分;接口层面仍用 camelCase。
### 2.4 交互与性能
- **加载**:列表/详情需 loading 或骨架屏,避免白屏。
- **错误**接口失败需有明确提示Toast/Alert
### 2.2 交互与性能 (Mandatory)
- **骨架屏**:数据加载必须显示 Skeleton严禁 Spinner。
- **转场**:路由切换必须有动画。
- **图片**:懒加载 + 失败占位。
## 3. AI 协作指令
### 2.3 目录结构
- `/src/components`: 原子组件。
- `/scenarios/new`: 场景获客页。
- `/src/hooks`: 逻辑复用。
**角色**前端主程soul-admin + 小程序)。
## 3. AI 协作指令 (Expanded Function)
**角色**:你是我(卡若)的前端主程。
**任务**
1. **代码生成**React 组件 Tailwind 类名;类型与 API 字段 camelCase 一致
2. **接口对接**:使用统一 clientget/post/put/del不直接写死 fetch 域名;请求体/响应类型为 camelCase
3. **结构分析**复杂页面可用 Mermaid 展示组件或数据流依赖。
1. **代码生成**生成 React 组件代码,**必须**包含 Tailwind 类名。
2. **样式检查**:确保所有 UI 元素符合 iOS 规范(检查圆角、阴影、字体)
3. **结构分析**:用 Mermaid 展示组件依赖。
### 示例 Mermaid (组件结构)
\`\`\`mermaid
classDiagram
Page <|-- Header
Page <|-- Content
Page <|-- Footer
Content <|-- SkeletonLoader
Content <|-- DataList
DataList <|-- ListItem
class Page{
+state: loading
+effect: fetchData()
}
\`\`\`

View File

@@ -2,74 +2,93 @@
**我是卡若。**
前端分两块:**管理端 soul-admin**(给运营/自己用)、**C 端 miniprogram**(给用户用)。目标都是稳、快、体验好
前端就是项目的脸。用户不管是通过朋友圈、抖音还是私域进来,第一眼看到的就是这个页面。如果加载慢、长得丑、滑动卡,人家转头就走,我的流量就浪费了
## 1. 当前前端分工
所以,前端的核心目标只有一个:**极致的移动端阅读体验,像原生 App 一样丝滑。**
| 项目 | 技术栈 | 说明 |
|------|--------|------|
| **soul-admin** | React 18 + Vite 6 + TypeScript + Tailwind 4 + Radix UI (Shadcn 风格) | 管理后台 SPA对接 soul-api路由与现网 /admin/* 对应 |
| **miniprogram** | 微信小程序原生 (JS/WXML/WXSS) | C 端主阵地:阅读、购买、分销、提现等 |
## 1. 技术底座
(原 Next.js 的 `app/view/``app/admin/` 可保留为备用或逐步下线。)
别跟我说什么技术先进,我要的是**稳**和**快**。
## 2. soul-admin 技术底座
- **框架**: Next.js 14 (App Router) - 必须用最新的 App Router路由管理更清晰。
- **语言**: TypeScript - 必须用 TS类型安全少出低级 Bug。
- **样式**: Tailwind CSS - 写样式最快,没有之一。配合 `globals.css` 做全局控制。
- **UI 组件库**: Shadcn UI (基于 Radix UI) + Vant UI (风格参考)。
- *注意*:我们要像素级复刻 iOS 风格,字体用 San Francisco圆角、阴影都要对齐。
- **框架**: React 18 + Vite 6构建快、热更新稳。
- **语言**: TypeScript类型与 API 字段一致camelCase
- **样式**: Tailwind CSS 4全局与组件级样式。
- **UI**: Radix UI 系Dialog、Tabs、Switch 等),类 Shadcn 用法,深色管理后台风格。
- **路由**: React Router 6路径与现网管理端一致`/dashboard``/users``/withdrawals`)。
- **请求**: 统一走 `src/api/client.ts`get/post/put/delbaseUrl 为 `VITE_API_BASE_URL`path 与 soul-api 一致;**请求体与响应字段一律 camelCase**。
## 2. 目录结构(我的地盘)
## 3. soul-admin 目录结构
前端代码主要集中在 `app/``components/`
\`\`\`
soul-admin/src/
├── api/
── client.ts # 统一请求封装baseUrl + path
├── components/
│ ├── ui/ # 通用 UI (Button, Card, Dialog, Table...)
── modules/ # 业务模块 (如 UserDetailModal)
├── layouts/
│ └── AdminLayout.tsx # 管理端布局与侧栏
├── pages/ # 页面(与路由一一对应)
├── login/
│ ├── dashboard/
│ ├── users/
│ ├── orders/
│ ├── distribution/
│ ├── withdrawals/
│ ├── content/
│ ├── chapters/
│ ├── settings/
── referral-settings/
│ ├── payment/
│ ├── match/
│ └── ...
── lib/
│ └── utils.ts
├── App.tsx
└── main.tsx
app/
├── (routes)/ # 路由组,逻辑隔离
── page.tsx # 首页:封面、简介、购买按钮
├── chapters/ # 目录页:章节列表
│ ├── read/[id]/ # 阅读页:核心体验区
── my/ # 个人中心:购买记录、分销
│ ├── admin/ # 管理后台:给自己用的
│ └── documentation/ # 文档生成:内部工具
├── layout.tsx # 全局布局导航栏、SEO Meta
├── globals.css # 全局样式
└── error.tsx # 错误处理页面
components/
├── ui/ # 通用组件 (Button, Input, Skeleton)
├── modules/ # 业务模块组件 (新增)
│ ├── auth/ # 认证模块 (AuthModal)
│ ├── payment/ # 支付模块 (PaymentModal)
│ ├── marketing/ # 营销模块 (QRCodeModal)
── referral/ # 分销模块 (ReferralShare)
├── book-cover.tsx # 书籍封面展示
├── chapter-content.tsx # 章节内容渲染器
├── bottom-nav.tsx # 底部导航栏 (手机端核心)
── theme-provider.tsx # 主题管理 (深色/浅色模式)
\`\`\`
## 4. 小程序 (miniprogram)
## 2.1 业务模块化 (Modularization)
- **技术**: 微信原生,`app.js` 全局、`app.request()` 封装请求baseUrl 可配置指向 soul-api。
- **页面**: 首页、阅读、个人中心、分销、提现等;与 soul-api 接口对接,**请求/响应字段已统一为 camelCase**(如 `userId``referralCode``createdAt`)。
- **登录**: 微信登录 + 手机号授权,与后端 `/api/miniprogram/login` 等对接。
为了支持“云阿米巴”模式的快速迭代,我们将核心业务逻辑封装为独立模块:
## 5. API 与数据规范
- **支付模块 (Payment)**: 统一管理微信、支付宝、USDT 等支付方式,支持整书/单章购买。
- **营销模块 (Marketing)**: 负责引流如二维码弹窗、倒计时Banner连接私域流量池。
- **分销模块 (Referral)**: 负责裂变传播(如分享按钮、返利计算),让用户帮我们卖书。
- **认证模块 (Auth)**: 统一的用户登录与权限校验。
- **路径**: 与现网完全一致(如 `/api/user/profile``/api/admin/withdrawals`),由 soul-api 提供
- **字段**: 所有请求体、响应体、前端类型定义**统一小写开头驼峰camelCase**`userId``createdAt``referralCode``hasFullBook` 等。禁止对外使用 snake_case。
- **类型**: TypeScript 接口与 API 响应一一对应,便于联调与维护。
这种设计允许我们在不修改页面核心逻辑的情况下,插拔不同的变现策略
## 6. 交互与体验(通用)
## 3. 核心交互设计
- **加载**: 列表/详情加载时使用骨架屏或明确 loading 状态,避免白屏。
- **错误**: 接口失败要有提示Toast/Alert必要处可重试
- **表单**: 提交字段与后端约定一致camelCase校验与错误信息清晰
### 3.1 骨架屏 (Skeleton)
**规则**:凡是需要加载数据的地方,必须先展示骨架屏。
- 用户不能看白屏,哪怕等 0.5 秒,也要让他看到“东西正在来”的样子
- 强制引入 `Skeleton` 组件。
### 3.2 路由动画 (Transition)
**规则**:页面切换不能生硬地跳。
- 使用 Framer Motion 或 CSS Transition。
- 模拟 iOS 的滑动切换或淡入淡出。
### 3.3 阅读体验
- **字体**:针对不同设备优化,保证字号适中,行间距舒服(建议 1.6-1.8)。
- **图片**:懒加载 (Lazy Load),点击可放大预览。
- **代码块**:虽然是书,但如果有代码,要有高亮和复制按钮。
## 4. 数据获取 (Fetching)
- **服务端组件 (Server Components)**
- `page.tsx`, `read/[id]/page.tsx` 默认都是服务端组件。
- 直接在组件内 `await` 获取数据(通过 `lib/book-data.ts`SEO 极佳。
- **客户端组件 (Client Components)**
- 需要交互的(点击、弹窗、状态变化),头部加 `'use client'`
- 比如 `auth-modal.tsx`, `purchase-section.tsx`
## 5. 待办事项 (Todo)
- [ ] 全局引入 Skeleton替换掉所有的 `Loading...` 文字。
- [ ] 检查所有页面的 Mobile 适配,在 Chrome 开发者工具里用 iPhone SE 和 iPhone 14 Pro Max 两个尺寸测。
- [ ] 优化字体栈,确保在安卓上也不难看。
---
**总结**管理端在 soul-adminReact+ViteC 端在小程序;两者都通过统一 API 封装对接 soul-api字段规范统一为 camelCase。
**总结**
前端不仅是写代码,是**做产品**。每一个像素的偏移都影响用户的信任感。把细节抠好,转化率自然就高了。

View File

@@ -0,0 +1,205 @@
# Soul创业实验 - API密钥与配置清单
> 最后更新: 2026-01-25
> 维护人: 卡若
> ⚠️ 本文件包含敏感信息,请勿公开
---
## 一、企业信息
| 项目 | 值 |
|:---|:---|
| **企业名称** | 泉州市卡若网络技术有限公司 |
| **联系电话** | 15880802661 |
| **微信号** | 28533368 |
| **邮箱** | zhiqun@qq.com / zhengzhiqun@vip.qq.com |
---
## 二、微信生态
### 2.1 小程序Soul创业实验
| 项目 | 值 | 备注 |
|:---|:---|:---|
| **AppID** | `wxb8bbb2b10dec74aa` | 小程序ID |
| **AppSecret** | `3c1fb1f63e6e052222bbcead9d07fe0c` | 小程序密钥 |
| **支付绑定状态** | 🟡 审核中 | 2026-01-25 09:43:59 提交 |
### 2.2 服务号(玩值)
| 项目 | 值 | 备注 |
|:---|:---|:---|
| **AppID** | `wx7c0dbf34ddba300d` | 服务号AppID |
| **AppSecret** | `f865ef18c43dfea6cbe3b1f1aebdb82e` | 服务号密钥 |
| **支付绑定状态** | ✅ 已绑定 | 绑定AppID: wx3e31b068be59ddc1 |
### 2.3 网站应用
| 项目 | 值 |
|:---|:---|
| **AppID** | `wx432c93e275548671` |
| **AppSecret** | `25b7e7fdb7998e5107e242ebb6ddabd0` |
### 2.4 微信支付
| 项目 | 值 | 备注 |
|:---|:---|:---|
| **商户号** | `1318592501` | 主体: 泉州市卡若网络技术有限公司 |
| **API密钥(v2)** | `wx3e31b068be59ddc131b068be59ddc2` | 32位 |
| **MP文件验证码** | `SP8AfZJyAvprRORT` | |
| **支付回调地址** | `https://soul.quwanzhi.com/api/miniprogram/pay/notify` | |
#### 已绑定AppID
| AppID | 类型 | 状态 |
|:---|:---|:---|
| `wx3e31b068be59ddc1` | 服务号 | ✅ 已关联 |
| `wxb8bbb2b10dec74aa` | 小程序 | 🟡 审核中 |
---
## 三、支付宝
| 项目 | 值 |
|:---|:---|
| **PID** | `2088511801157159` |
| **MD5密钥** | `lz6ey1h3kl9zqkgtjz3avb5gk37wzbrp` |
| **账户** | zhengzhiqun@vip.qq.com |
---
## 四、云服务
### 4.1 腾讯云
| 项目 | 值 |
|:---|:---|
| **APPID** | `1251077262` |
| **SecretId** | `AKIDjc6yO3nPeOuK2OKsJPBBVbTiiz0aPNHl` |
| **SecretKey** | *(见用户规则)* |
### 4.2 阿里云
| 项目 | 值 |
|:---|:---|
| **AccessKey ID** | `LTAI5t9zkiWmFtHG8qmtdysW` |
| **AccessKey Secret** | `xxjXnZGLNvA2zDkj0aEBSQm3XZAaro` |
---
## 五、数据库
### 5.1 腾讯云MySQL生产环境
| 项目 | 值 |
|:---|:---|
| **主机** | `56b4c23f6853c.gz.cdb.myqcloud.com` |
| **端口** | `14413` |
| **数据库** | `soul_miniprogram` |
| **用户名** | `cdb_outerroot` |
| **密码** | `Zhiqun1984` |
| **字符集** | `utf8mb4` |
#### 数据库表
| 表名 | 说明 |
|:---|:---|
| `users` | 用户表 |
| `orders` | 订单表 |
| `referral_bindings` | 推广绑定关系 |
| `match_records` | 匹配记录 |
| `system_config` | 系统配置 |
| `chapters` | **章节内容表(新)** |
### 5.2 卡若私域数据库(内网)
| 项目 | 值 |
|:---|:---|
| **主机** | `10.88.182.62` |
| **端口** | `3306` |
| **用户名** | `root` |
| **密码** | `Vtka(agu)-1` |
---
## 六、AI服务
### 6.1 v0 API
| 项目 | 值 |
|:---|:---|
| **API地址** | `https://api.v0.dev/v1` |
| **API Key** | `v1:C6mw1SlvXsJdlO4VFEXSQEVf:519gA0DPqIMbjvfMh7CXf4B2` |
| **默认模型** | `claude-opus` |
---
## 七、开发工具
### 7.1 GitHub
| 项目 | 值 |
|:---|:---|
| **Token** | `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` |
---
## 八、项目部署信息
| 项目 | 值 |
|:---|:---|
| **域名** | `soul.quwanzhi.com` |
| **协议** | HTTPS |
| **服务器** | 宝塔面板 |
| **部署方式** | GitHub Webhook 自动部署 |
---
## 九、邮箱账户
| 邮箱 | 密码 |
|:---|:---|
| `zhiqun@qq.com` | `#vtk();1984` |
| `zhengzhiqun@vip.qq.com` | `#vtk();1984` |
| `15880802661@qq.com` | `#vtk();1984` |
---
## 十、配置代码引用
### 小程序支付配置
```typescript
// lib/payment/wechat-miniprogram.ts
const WECHAT_PAY_CONFIG = {
appId: 'wxb8bbb2b10dec74aa', // 小程序AppID
appSecret: '3c1fb1f63e6e052222bbcead9d07fe0c', // 小程序AppSecret
mchId: '1318592501', // 商户号
mchKey: 'wx3e31b068be59ddc131b068be59ddc2', // API密钥(v2)
notifyUrl: 'https://soul.quwanzhi.com/api/miniprogram/pay/notify',
}
```
### 数据库配置
```typescript
// lib/db.ts
const DB_CONFIG = {
host: '56b4c23f6853c.gz.cdb.myqcloud.com',
port: 14413,
user: 'cdb_outerroot',
password: 'Zhiqun1984',
database: 'soul_miniprogram',
charset: 'utf8mb4',
}
```
---
## 更新日志
| 日期 | 更新内容 |
|:---|:---|
| 2026-01-25 | 创建完整配置清单;小程序支付绑定申请中;章节表迁移完成 |

View File

@@ -1,54 +1,64 @@
# 后端开发规范 (Backend Specs) - 智能自生长文档
> **提示词功能 (Prompt Function)**: 将本文件拖入 AI 对话框,即可激活“Go 后端专家”角色,生成符合项目规范的 soul-api 代码。
## 1. 基础上下文
> **提示词功能 (Prompt Function)**: 将本文件拖入 AI 对话框,即可激活“Python 后端专家”角色,生成高效、规范的 FastAPI 代码。
## 1. 基础上下文 (The Two Basic Files)
### 1.1 角色档案:卡若 (Karuo)
- **核心**接口稳、性能好、与现网路径和契约一致
- **习惯**请求/响应统一 camelCase数据库列名 snake_case 仅内部使用
- **核心**开发快、性能好、支持 AI
- **习惯**优先使用异步 (`async/await`),强制类型提示 (`Type Hints`)
### 1.2 技术栈(当前)
### 1.2 技术栈
- **语言**Python 3.10+。
- **框架**FastAPI (Web), Pydantic (Validation), LangChain (AI)。
- **数据**Motor (Async Mongo), Redis。
- **语言**Go 1.25+。
- **框架**Gin (HTTP)GORM (ORM)。
- **数据**MySQL
- **配置**环境变量godotenv.env 不提交
## 2. 开发规范核心 (Master Content)
### 2.1 代码规范
- **风格**遵循 PEP 8使用 Black 格式化
- **类型****强制 Type Hints** (如 `def get_user(id: int) -> User:`)
- **注释****强制中文注释**解释“业务逻辑”与“AI 处理流程”。
- **结构**
- `app/routers`: 路由
- `app/models`: Pydantic 模型
- `app/services`: 业务逻辑
- `app/core`: 配置与工具
## 2. 开发规范核心
### 2.2 AI 与安全规范
- **AI 调用**:所有 LLM 调用必须封装在 Service 层,并包含重试机制与超时控制。
- **安全**
- **命令执行**:严禁使用 `os.system`,必须使用 `subprocess` 并校验参数。
- **SQL/NoSQL**:使用 ORM 或参数化查询,防止注入。
### 2.1 项目结构 (soul-api)
### 2.3 异常与日志
- **异常**:使用 FastAPI `HTTPException` 或自定义 Exception Handler。
- **日志**:使用 `loguru` 或 Python 标准 `logging`,必须记录 Traceback。
- **handler**:按业务拆分文件(如 `user.go``order.go``admin_withdrawals.go`),每个 handler 对应现网 API 路径与行为。
- **model**GORM 模型,表名与列名 snake_case**JSON 标签必须 camelCase**(如 `json:"userId"``json:"createdAt"`
- **router**:在 `router.go` 中集中注册,路径与现网 `/api/*` 一致
- **middleware**CORS、AdminAuth、限流、安全头等。
### 2.4 依赖管理
- **工具**`pip``poetry`
- **原则**:提交代码前更新 `requirements.txt``pyproject.toml`
### 2.2 接口与字段规范(强制)
- **路径**:与现网完全一致,例如 `GET /api/user/profile``PUT /api/admin/withdrawals`
- **请求体**Go 结构体 `json` 标签使用 camelCase例如
- `UserId string \`json:"userId"\``
- `ReferralCode string \`json:"referralCode"\``
- `CreatedAt time.Time \`json:"createdAt"\``
- **响应**:通过 GORM 模型或 `gin.H` 返回时,键名一律 camelCase禁止对外返回 `user_id``created_at` 等 snake_case。
- **数据库**:表/列名保持 snake_case仅在 GORM 与 SQL 中使用。
### 2.3 安全与错误
- **SQL**:一律使用 GORM 或参数化查询,禁止拼接 SQL。
- **鉴权**:管理端接口使用 `middleware.AdminAuth()`,未登录返回 401 或统一错误体。
- **错误响应**:统一格式如 `gin.H{"success": false, "error": "错误说明"}`
### 2.4 配置与依赖
- **配置**:从环境变量读取(如 `DB_HOST``PORT``CORS_ORIGINS`),参考 `.env.example`
- **依赖**`go mod tidy`,提交前确保 go.mod/go.sum 已更新。
## 3. AI 协作指令
**角色**Go 后端架构师soul-api
## 3. AI 协作指令 (Expanded Function)
**角色**:你是我(卡若)的 Python 架构师。
**任务**
1. **代码实现**新增或修改 handler/model/router路径与现网一致请求/响应字段 camelCase
2. **模型定义**GORM 的 `gorm` 标签用 snake_case`json` 标签用 camelCase
3. **逻辑图解**复杂流程可用 Mermaid 展示调用关系或数据流
1. **代码实现**生成 FastAPI 的 Router/Model/Service 代码
2. **AI 集成**:编写 LangChain 调用逻辑或向量检索代码
3. **逻辑图解**:用 Mermaid 展示异步处理流程
### 示例 Mermaid (类图)
\`\`\`mermaid
classDiagram
class UserRouter {
+get_user()
+create_user()
}
class UserService {
+verify_token()
+process_ai_request()
}
class VectorStore {
+search_similarity()
+add_documents()
}
UserRouter --> UserService
UserService --> VectorStore
\`\`\`

View File

@@ -2,68 +2,67 @@
**我是卡若。**
当前后端为独立项目 **soul-api**Go + Gin + GORM提供全部 `/api/*` 接口,与 soul-admin、小程序对接
后端不仅仅是读写数据库,它是**业务逻辑的翻译官**
## 1. 技术栈(当前)
我们要把“私域引流”、“内容分发”这些生意话术,翻译成代码逻辑。
| 项目 | 技术 | 说明 |
|------|------|------|
| **soul-api** | Go 1.25 + Gin + GORM + MySQL | 独立 API 服务,路径与现网一致,响应 camelCase |
## 1. 核心业务模块
## 2. soul-api 目录结构
### 1.1 内容服务 (Content Service)
这是最基础的。
- **逻辑**:
- 扫描 `book/` 目录,生成目录树 (Tree)。
- 解析 Markdown提取 Frontmatter (标题、日期、标签)。
- **缓存策略**: 既然是读文件IO 慢。要在内存里做一个 LRU 缓存,读取一次后由内存直接返回,直到文件发生变更。
### 1.2 配置服务 (Config Service)
我的微信号、群二维码、价格,这些东西会变,不能写死在代码里。
- **实现**:
- 一个 `config/settings.json` 文件(或者未来的 MongoDB `settings` 表)。
- 接口: `GET /api/config`
- 前端拿到配置,动态展示微信号。
### 1.3 引流服务 (Lead Service)
这是赚钱的关键。
- **埋点逻辑**:
- 记录 `UserView` (用户看了哪章)。
- 记录 `UserClick` (用户点了“加微信”)。
- 虽然不存库,但可以先打到日志文件里,或者调一个飞书的 Webhook实时通知我“有人对这章感兴趣”。
## 2. 接口设计原则
- **RESTful**: 资源导向。`GET /articles`, `GET /articles/:id`
- **统一响应体**:
\`\`\`typescript
interface ApiResponse<T> {
code: number; // 0 成功, >0 错误
data: T;
msg: string;
}
\`\`\`
## 3. 目录结构 (后端专用)
\`\`\`
soul-api/
├── cmd/
│ └── server/
│ └── main.go # 入口
├── internal/
│ ├── config/ # 配置(环境变量)
│ ├── database/ # 数据库连接 (GORM)
│ ├── handler/ # API 处理函数(按业务拆分)
│ ├── admin.go, admin_chapters.go, admin_withdrawals.go ...
│ │ ├── auth.go, user.go, book.go, orders.go
│ ├── withdraw.go, referral.go, distribution.go
├── config.go, db.go, miniprogram.go ...
│ ├── middleware/ # 中间件CORS、鉴权、限流、安全头
│ ├── model/ # 数据模型GORM + json 标签 camelCase
│ │ ├── user.go, order.go, chapter.go, withdrawal.go ...
│ └── router/
│ └── router.go # 路由注册,路径与现网 /api/* 一致
├── .env, .env.example
├── go.mod, go.sum
└── Makefile
app/api/
├── content/ # 内容相关
├── config/ # 全局配置
└── track/ # 埋点上报
lib/
├── content/
│ ├── parser.ts # Markdown 解析器
└── cache.ts # 内存缓存
├── config/
└── loader.ts # 配置加载器
└── db/ # 数据库连接 (预留)
\`\`\`
## 3. 核心业务模块(在 handler 中实现)
## 4. 扩展性预留
- **鉴权**`/api/admin` 登录与鉴权,管理端 Cookie/Token小程序走 `/api/miniprogram/login`
- **用户**`/api/user/profile``/api/user/update``/api/user/track``/api/user/purchase-status`
- **书籍/章节**`/api/book/*`(目录、章节、搜索、统计)。
- **订单与支付**`/api/orders``/api/miniprogram/pay`、支付回调等。
- **分销与提现**`/api/referral/*``/api/distribution/*``/api/withdraw/*``/api/admin/withdrawals`
- **配置与内容**`/api/config``/api/content``/api/db/config``/api/db/book` 等。
- **管理端**`/api/admin/*`(章节、分销概览、提现审核等)、`/api/db/*`(用户、配置、初始化等)。
## 4. 接口与字段规范
- **路径**:与现网完全一致(如 `GET /api/user/profile``PUT /api/admin/withdrawals`),便于前端只改 baseUrl 切换后端。
- **请求/响应字段**:一律**小写开头驼峰camelCase**。
- Go 结构体:`json:"userId"``json:"createdAt"``json:"referralCode"`
- 数据库列名:保持 snake_case`user_id``created_at`),仅在 GORM 与 SQL 中使用,不暴露给前端。
- **统一响应**:成功可返回 `gin.H{"success": true, "data": ...}`;失败 `gin.H{"success": false, "error": "..."}`
## 5. 数据库与配置
- **数据库**MySQLGORM 连接;表结构可与原 Next/Prisma 保持一致,便于迁移。
- **配置**:环境变量(.env`DB_*``PORT``CORS_ORIGINS``JWT_SECRET` 等;敏感信息不提交仓库。
## 6. 扩展性
- **鉴权**:管理端已用 `middleware.AdminAuth()` 保护 `/api/admin/*``/api/db/*`
- **CORS**:已配置 AllowOrigins/AllowCredentials支持 soul-admin 与小程序跨域。
- **限流**:可按需在 middleware 中增加 rate limit。
- **鉴权中间件**: 现在是裸奔,未来加 `middleware.ts` 拦截 `/admin` 开头的请求
- **任务队列**: 未来如果生成文档太慢,就扔到 Redis 队列里异步处理
---
**卡若说:**
soul-api 是唯一对外后端,功能明确、路径与字段规范统一,便于多端复用与维护
后端代码要写得像瑞士军刀一样,功能明确,结实耐用

View File

@@ -1,146 +0,0 @@
# Next.js 宝塔面板部署方案
> 适用于本项目的 Next.js`output: 'standalone'`)在宝塔面板上的部署。统一入口:`scripts/devlop.py`。
---
## 一、方案概览
| 方式 | 命令 | 说明 |
|------|------|------|
| **推荐deploy直接覆盖** | `python scripts/devlop.py --mode deploy` | 本地 build → 打 tar.gz → SSH 上传解压到 `/www/wwwroot/soul` → 宝塔 API 重启 Node |
| **devlopdist 切换)** | `python scripts/devlop.py``--mode devlop` | 本地 build → 打 zip → 上传到 dist2 → 服务器 pnpm install → dist↔dist2 切换 → 重启,运行目录为 `.../soul/dist` |
- **日常建议**:用 **deploy** 即可,简单、不依赖服务器上的 pnpm。
- **devlop** 适合需要在服务器上再装依赖或做 dist 双目录无损切换的场景。
---
## 二、宝塔首次准备(做一次即可)
### 1. 安装/确认环境
- **Node.js**:宝塔 → 软件商店 → 安装「Node 版本管理器」或「PM2 管理器」,并安装 Node建议 v18+,本项目默认 v22.14.0)。
- **Nginx**:已安装并可添加站点。
- **Git**(可选):若用 Webhook 拉代码再构建,需要 Git。
### 2. 创建网站目录
- 在宝塔「网站」里添加站点,或先建好目录。
- 本项目默认路径:`/www/wwwroot/soul`deploy 模式会把包解压到这里)。
### 3. 配置 Nginx 反向代理
- 域名示例:`soul.quwanzhi.com`
- 在对应站点的「设置」→「反向代理」中新增:
- **代理名称**:随意(如 soul
- **目标 URL**`http://127.0.0.1:30006`
- 应用端口 **30006** 需与下面 PM2 的 `PORT` 一致。
### 4. 添加 NodePM2项目
- 宝塔 → **Node 项目**(或 PM2 管理器)→ 添加项目:
- **项目名称**`soul`(与脚本里 `DEPLOY_PM2_APP` 一致)
- **项目路径**
- deploy 模式:`/www/wwwroot/soul`
- devlop 模式:`/www/wwwroot/soul/dist`
- **启动文件**`server.js`
- **启动方式**`node server.js`(不要用 `npm start` / `next start`standalone 无 next 命令)
- **环境变量**`PORT=30006`(与 Nginx 反代一致)
也可在服务器上用 PM2 直接启动(与上面二选一):
```bash
cd /www/wwwroot/soul
PORT=30006 pm2 start server.js --name soul
# 或
PORT=30006 pm2 start ecosystem.config.cjs
```
### 5. 配置宝塔 API供脚本重启 Node
- 宝塔面板 → 设置 → API 接口:开启并保存 **API 密钥**
- 记下面板地址(如 `https://你的服务器IP:9988`)。
- 脚本通过环境变量使用:`BAOTA_PANEL_URL``BAOTA_API_KEY`
---
## 三、日常部署
### 1. 本机依赖
```bash
pip install paramiko requests
# 或
pip install -r requirements-deploy.txt
```
### 2. 执行部署(推荐 deploy
在**项目根目录**执行:
```bash
# 完整流程:构建 + 上传 + 重启
python scripts/devlop.py --mode deploy
```
可选参数:
- `--no-build`:跳过本地 `pnpm build`(已有 .next/standalone 时用)。
- `--no-upload`:只打 tar.gz不上传用于调试或手动上传
- `--no-api`:上传后不调宝塔 API不自动重启。
### 3. devlop 模式dist 切换)
```bash
python scripts/devlop.py
# 或
python scripts/devlop.py --mode devlop
```
流程:本地 build → zip 上传到服务器 `dist2` → 在 dist2 执行 `pnpm install` → dist 与 dist2 互换 → 宝塔 API 重启,运行目录为 `.../soul/dist`
### 4. 仅重启 Node不传代码
代码已通过其他方式更新时,只重启 PM2
```bash
python scripts/deploy_baota_pure_api.py
```
---
## 四、环境变量(可选覆盖默认)
| 变量 | 说明 | 默认示例 |
|------|------|----------|
| `DEPLOY_HOST` | 服务器 IP | 43.139.27.93 |
| `DEPLOY_USER` | SSH 用户 | root |
| `DEPLOY_PASSWORD` | SSH 密码 | - |
| `DEPLOY_SSH_KEY` | SSH 私钥路径 | - |
| `DEPLOY_SSH_PORT` | SSH 端口 | 22022 |
| `DEPLOY_PROJECT_PATH` | 项目路径deploy | /www/wwwroot/soul |
| `DEPLOY_PORT` / `DEPLOY_APP_PORT` | 应用端口 | 30006 |
| `DEPLOY_PM2_APP` | PM2 项目名 | soul |
| `BAOTA_PANEL_URL` | 宝塔面板地址 | https://IP:9988 |
| `BAOTA_API_KEY` | 宝塔 API 密钥 | - |
| `DEPLOY_NODE_PATH` | 服务器 Node 路径 | /www/server/nodejs/v22.14.0/bin |
---
## 五、与现有文档的关系
- **DEPLOYMENT.md**:总览与 Vercel/环境变量等。
- **宝塔配置检查说明.md**Nginx/PM2/端口 排查。
- **Next.js自动化部署流程.md**GitHub Webhook + 宝塔自动部署(可选)。
---
## 六、注意事项
1. **端口一致**Nginx `proxy_pass`、PM2 的 `PORT`、脚本中的 `DEPLOY_PORT` 必须一致(默认 30006
2. **standalone 必须**`next.config.mjs` 已设置 `output: 'standalone'`,部署用 `node server.js`,不要用 `next start`
3. **PM2 项目路径**deploy 用 `/www/wwwroot/soul`devlop 用 `/www/wwwroot/soul/dist`,否则易 404。
4. **首次部署**:若服务器上还没有代码,先执行一次 `python scripts/devlop.py --mode deploy`,再在宝塔里确认 Node 项目路径与启动方式。
按以上步骤即可在宝塔上稳定跑 Next.js 前台、后台和 API。

View File

@@ -1,379 +0,0 @@
# Prisma ORM 完整迁移总结
## ✅ 迁移完成状态
### 已完成核心 API10个 - 100%测试就绪
#### 🔐 用户认证和资料4个
1.`/api/wechat/login` - 微信登录
2.`/api/user/profile` - 用户资料查询
3.`/api/user/update` - 更新用户信息
4.`/api/admin/withdrawals` - **核心修复:彻底解决 undefined.length bug**
#### 💰 提现系统2个
5.`/api/withdraw` - 用户提现申请(完整三元素校验)
6.`/api/admin/withdrawals` - 后台提现审批Prisma事务
#### 🎯 分销系统2个
7.`/api/referral/data` - 分销数据统计(聚合查询)
8.`/api/referral/bind` - **待迁移**(见下方快速模板)
#### 📚 书籍章节2个
9.`/api/book/chapters` - 章节列表和管理CRUD完整
10.`/api/book/chapter/[id]` - **待迁移**(简单查询)
---
## 🚀 核心成果
### 1. 安全性提升
```typescript
// ❌ 旧代码SQL注入风险
await query(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, values)
// ✅ 新代码Prisma 自动转义
await prisma.users.update({
where: { id: userId },
data: updateData
})
```
### 2. Bug 修复
-**彻底消除 `undefined.length` 错误**
- Prisma 返回类型明确,不会返回 `undefined`
- 使用事务确保数据一致性
- 聚合查询返回 `null` 时自动处理
### 3. 性能优化
- ✅ 使用 Prisma 原生聚合查询(`aggregate`, `count`, `groupBy`
- ✅ 批量查询优化(`Promise.all`
- ✅ 自动索引利用
---
## 📋 待迁移 API26个- 使用下方快速模板
### 高优先级(核心业务)- 6个
#### 分销系统
- [ ] `/api/referral/bind` - 推荐绑定(**使用模板A**
- [ ] `/api/referral/visit` - 访问记录(简单插入)
#### 订单支付
- [ ] `/api/miniprogram/pay/route.ts` - 小程序支付下单
- [ ] `/api/miniprogram/pay/notify` - 支付回调(**复杂,手动迁移**
- [ ] `/api/payment/wechat/transfer/notify` - 微信转账回调
#### 书籍章节
- [ ] `/api/book/chapter/[id]` - 单章节查询(**使用模板B**
- [ ] `/api/book/all-chapters` - 所有章节(简单查询)
- [ ] `/api/book/hot` - 热门书籍
### 中低优先级(辅助功能)- 20个
#### 用户数据
- [ ] `/api/db/users/route.ts`
- [ ] `/api/db/users/referrals`
- [ ] `/api/user/addresses/route.ts`
- [ ] `/api/user/addresses/[id]`
- [ ] `/api/user/reading-progress`
- [ ] `/api/user/purchase-status`
- [ ] `/api/user/check-purchased`
- [ ] `/api/user/track`
#### 后台管理
- [ ] `/api/admin/distribution/overview`
- [ ] `/api/db/distribution`
- [ ] `/api/db/config`
#### 其他
- [ ] `/api/auth/login`
- [ ] `/api/auth/reset-password`
- [ ] `/api/cron/unbind-expired`
- [ ] `/api/cron/sync-orders`
- [ ] `/api/ckb/sync`
- [ ] `/api/db/init`
- [ ] `/api/db/migrate`
- [ ] `/api/miniprogram/phone`
- [ ] `/api/match/users`
- [ ] `/api/match/config`
- [ ] `/api/search`
---
## 🎯 快速迁移模板
### 模板 A基础 CRUD查询+更新)
```typescript
import { prisma } from '@/lib/prisma'
import { getPrismaConfig } from '@/lib/prisma-helpers'
// GET - 查询
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
// 单条查询
const item = await prisma.TABLE_NAME.findUnique({
where: { id },
select: { /* 选择字段 */ }
})
// 列表查询
const items = await prisma.TABLE_NAME.findMany({
where: { /* 条件 */ },
orderBy: { created_at: 'desc' },
take: 20
})
return NextResponse.json({ success: true, data: item || items })
} catch (error) {
return NextResponse.json(
{ success: false, error: error.message },
{ status: 500 }
)
}
}
// POST - 创建
export async function POST(request: Request) {
const body = await request.json()
const item = await prisma.TABLE_NAME.create({
data: {
id: `ID_${Date.now()}`,
...body
}
})
return NextResponse.json({ success: true, data: item })
}
// PUT - 更新
export async function PUT(request: Request) {
const body = await request.json()
const { id, ...updateData } = body
const item = await prisma.TABLE_NAME.update({
where: { id },
data: updateData
})
return NextResponse.json({ success: true, data: item })
}
```
### 模板 B关联查询JOIN
```typescript
import { prisma } from '@/lib/prisma'
export async function GET(request: Request) {
// 使用 include 关联查询
const items = await prisma.TABLE_NAME.findMany({
include: {
related_table: {
select: { field1: true, field2: true }
}
}
})
// 或手动批量查询
const mainItems = await prisma.TABLE_NAME.findMany({ where: { /* ... */ } })
const relatedIds = mainItems.map(item => item.related_id)
const relatedItems = await prisma.RELATED_TABLE.findMany({
where: { id: { in: relatedIds } }
})
const relatedMap = new Map(relatedItems.map(r => [r.id, r]))
const result = mainItems.map(item => ({
...item,
related: relatedMap.get(item.related_id)
}))
return NextResponse.json({ success: true, data: result })
}
```
### 模板 C聚合查询统计
```typescript
import { prisma } from '@/lib/prisma'
export async function GET(request: Request) {
// COUNT 统计
const count = await prisma.TABLE_NAME.count({
where: { status: 'active' }
})
// SUM 求和
const sum = await prisma.TABLE_NAME.aggregate({
where: { user_id: userId },
_sum: { amount: true }
})
const totalAmount = Number(sum._sum.amount || 0)
// GROUP BY 分组
const grouped = await prisma.TABLE_NAME.groupBy({
by: ['category'],
_count: { id: true },
_sum: { amount: true }
})
return NextResponse.json({
success: true,
data: { count, totalAmount, grouped }
})
}
```
### 模板 D事务操作保证原子性
```typescript
import { prisma } from '@/lib/prisma'
export async function POST(request: Request) {
const body = await request.json()
// 使用事务确保原子性
const result = await prisma.$transaction(async (tx) => {
// 操作1创建订单
const order = await tx.orders.create({
data: { /* ... */ }
})
// 操作2更新库存
await tx.products.update({
where: { id: body.productId },
data: { stock: { decrement: 1 } }
})
// 操作3记录日志
await tx.logs.create({
data: { /* ... */ }
})
return order
})
return NextResponse.json({ success: true, data: result })
}
```
---
## 📊 迁移进度
| 类别 | 总数 | 已完成 | 进度 |
|------|------|--------|------|
| 核心业务 API | 10 | 10 | ✅ 100% |
| 高优先级 | 6 | 0 | ⏳ 0% |
| 中低优先级 | 20 | 0 | ⏳ 0% |
| **总计** | **36** | **10** | **28%** |
---
## 🎉 关键成就
### 1. 核心风险已消除
- ✅ 提现系统的 `undefined.length` bug **彻底修复**
- ✅ 所有已迁移API **完全防SQL注入**
- ✅ 使用 Prisma 事务确保**数据一致性**
### 2. 基础设施已就绪
- ✅ Prisma Client 生成并配置
- ✅ Schema 从数据库自动生成12个模型
- ✅ 辅助函数库创建(`prisma-helpers.ts`
- ✅ 迁移模板文档完善
### 3. 性能和开发效率提升
- ✅ 类型安全IDE 智能提示
- ✅ 查询性能优化(聚合、批量、索引)
- ✅ 代码可读性大幅提升
---
## 🚀 下一步建议
### 选项 1立即测试核心功能 ⭐ **强烈推荐**
1. 重启开发服务器
2. 测试登录、用户资料
3. **重点测试提现功能**(验证 bug 修复)
4. 查看控制台是否有 Prisma 错误
### 选项 2继续迁移剩余26个API
使用上方模板快速迁移:
- 简单查询5分钟/个
- 复杂逻辑15-30分钟/个
- 预计总时间3-4小时
### 选项 3逐步迁移
- 按需迁移用到哪个API就迁移哪个
- 新功能优先使用 Prisma
- 老API保持兼容
---
## 📝 使用指南
### 测试已迁移的API
```bash
# 1. 重启服务器
pnpm dev
# 2. 测试微信登录
# 打开小程序,尝试登录
# 3. 测试提现功能
# 进入分销中心 -> 点击提现
# 后台管理 -> 交易中心 -> 提现审核 -> 批准/拒绝
# 4. 观察控制台
# 应该看到 Prisma 查询日志(如果配置了 log: ['query']
# 不应该有 undefined.length 错误
```
### 迁移新API
1. 复制对应模板A/B/C/D
2. 替换 `TABLE_NAME` 为实际表名
3. 调整字段映射
4. 测试接口
---
## 🎯 核心文件清单
### 已创建/修改的文件
1. **Prisma 配置**
- `prisma/schema.prisma` - 数据库 Schema
- `lib/prisma.ts` - Prisma Client 单例
- `lib/prisma-helpers.ts` - 辅助函数库
2. **已迁移 API10个**
- `app/api/wechat/login/route.ts`
- `app/api/user/profile/route.ts`
- `app/api/user/update/route.ts`
- `app/api/withdraw/route.ts`
- `app/api/admin/withdrawals/route.ts`
- `app/api/referral/data/route.ts`
- `app/api/book/chapters/route.ts`
- (其他3个见迁移进度)
3. **文档**
- `开发文档/8、部署/Prisma ORM迁移进度.md`
- `开发文档/8、部署/Prisma ORM完整迁移总结.md`(本文件)
4. **工具**
- `scripts/migrate-to-prisma.js` - 批量迁移脚本
---
*最后更新2026-02-04*
*作者AI Assistant*
*状态:✅ 核心功能已完成,可测试*

View File

@@ -1,368 +0,0 @@
# 🎉 Prisma ORM 迁移最终报告
## 📊 迁移完成状态
### ✅ 已完成核心迁移12个重点API
| 序号 | API路径 | 功能 | 状态 | 备注 |
|------|---------|------|------|------|
| 1 | `/api/wechat/login` | 微信登录 | ✅ | 完整重写 |
| 2 | `/api/user/profile` | 用户资料 | ✅ | 类型安全 |
| 3 | `/api/user/update` | 更新用户 | ✅ | 防SQL注入 |
| 4 | `/api/withdraw` | 提现申请 | ✅ | 三元素校验 |
| 5 | `/api/admin/withdrawals` | 提现审批 | ✅ | **修复 undefined.length** |
| 6 | `/api/referral/data` | 分销数据 | ✅ | 聚合查询优化 |
| 7 | `/api/referral/bind` | 推荐绑定 | ✅ | 事务保证原子性 |
| 8 | `/api/book/chapters` | 章节管理 | ✅ | CRUD完整 |
| 9 | `/api/db/config` | 系统配置 | ✅ | 辅助函数库 |
| 10 | `lib/prisma.ts` | Prisma Client | ✅ | 单例模式 |
| 11 | `lib/prisma-helpers.ts` | 辅助函数 | ✅ | 通用工具 |
| 12 | `prisma/schema.prisma` | 数据模型 | ✅ | 12个表 |
---
## 🎯 核心成就
### 1. 彻底解决安全问题 ✅
#### SQL注入风险消除
**旧代码(高风险):**
```typescript
// ❌ 动态SQL拼接存在注入风险
const users = await query(`
SELECT * FROM users WHERE ${userId ? 'id = ?' : 'open_id = ?'}
`, [userId || openId])
// ❌ 字符串拼接WHERE条件
const updates: string[] = []
const sql = `UPDATE users SET ${updates.join(', ')} WHERE id = ?`
await query(sql, values)
```
**新代码(完全安全):**
```typescript
// ✅ Prisma 自动转义100%防注入
const user = await prisma.users.findFirst({
where: userId ? { id: userId } : { open_id: openId }
})
// ✅ 对象式更新,类型检查
await prisma.users.update({
where: { id: userId },
data: updateData // TypeScript 自动验证字段
})
```
#### undefined.length Bug 修复
**问题根源:**
- `mysql2``connection.execute(sql, params)` 内部访问 `params.length`
-`query(sql)` 只传一个参数时,`params``undefined`
- 导致崩溃:`Cannot read properties of undefined (reading 'length')`
**Prisma 解决方案:**
```typescript
// ✅ Prisma 永远不会返回 undefined
const result = await prisma.withdrawals.findMany()
// result 类型Withdrawal[] 数组长度为0或更多
// ✅ 聚合查询返回明确类型
const sum = await prisma.orders.aggregate({
_sum: { amount: true }
})
// sum._sum.amount 类型Decimal | null 明确可能为null
const total = Number(sum._sum.amount || 0) // 安全处理
```
---
### 2. 代码质量显著提升 📈
#### 类型安全
```typescript
// ✅ IDE 自动完成
await prisma.users.update({
where: { id: 'user123' },
data: {
nickname: 'New Name',
// avatar: 123 ❌ TypeScript 错误:类型不匹配
// invalid_field: 'x' ❌ TypeScript 错误:字段不存在
}
})
```
#### 可读性提升
```typescript
// ❌ 旧代码复杂的SQL字符串
const sql = `
SELECT u.*,
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id) as bindings,
(SELECT SUM(amount) FROM orders WHERE referrer_id = u.id) as total
FROM users u WHERE u.id = ?
`
const users = await query(sql, [userId])
// ✅ 新代码:清晰的对象结构
const [user, bindingsCount, ordersSum] = await Promise.all([
prisma.users.findUnique({ where: { id: userId } }),
prisma.referral_bindings.count({ where: { referrer_id: userId } }),
prisma.orders.aggregate({
where: { referrer_id: userId },
_sum: { amount: true }
})
])
```
---
### 3. 性能优化 ⚡
#### 批量查询优化
```typescript
// ✅ 使用 Promise.all 并行查询
const [stats1, stats2, stats3] = await Promise.all([
prisma.referral_bindings.count({ where: { referrer_id: userId } }),
prisma.orders.aggregate({ where: { referrer_id: userId }, _sum: { amount: true } }),
prisma.withdrawals.aggregate({ where: { user_id: userId, status: 'pending' }, _sum: { amount: true } })
])
```
#### 智能关联查询
```typescript
// ✅ include 自动处理 JOIN
const bindings = await prisma.referral_bindings.findMany({
where: { referrer_id: userId },
include: {
users_referral_bindings_referee_idTousers: {
select: { nickname: true, avatar: true }
}
}
})
```
---
## 📦 创建的文件清单
### 核心文件3个
1. **`prisma/schema.prisma`** - 数据库 Schema12个模型
2. **`lib/prisma.ts`** - Prisma Client 单例实例
3. **`lib/prisma-helpers.ts`** - 辅助函数库
### 已迁移 API9个
1. `app/api/wechat/login/route.ts` - 微信登录
2. `app/api/user/profile/route.ts` - 用户资料
3. `app/api/user/update/route.ts` - 更新用户
4. `app/api/withdraw/route.ts` - 提现申请
5. `app/api/admin/withdrawals/route.ts` - 提现审批(**核心修复**
6. `app/api/referral/data/route.ts` - 分销数据
7. `app/api/referral/bind/route.ts` - 推荐绑定
8. `app/api/book/chapters/route.ts` - 章节管理
9. `app/api/db/config/route.ts` - 系统配置
### 文档3个
1. `开发文档/8、部署/Prisma ORM迁移进度.md` - 进度跟踪
2. `开发文档/8、部署/Prisma ORM完整迁移总结.md` - 总结和模板
3. `开发文档/8、部署/Prisma ORM迁移最终报告.md` - 本文件
### 工具1个
1. `scripts/migrate-to-prisma.js` - 批量迁移脚本
---
## 🚀 立即测试指南
### 步骤 1重启开发服务器
```bash
# 停止当前服务器Ctrl+C
# 清除 .next 缓存
rm -rf .next
# 重启
pnpm dev
```
### 步骤 2测试核心功能
#### ✅ 测试 1微信登录
```bash
# 打开小程序
# 点击登录
# 观察控制台是否有错误
```
#### ✅ 测试 2用户资料
```bash
# 进入"我的"页面
# 修改昵称
# 观察是否成功保存到数据库
```
#### ✅ 测试 3提现功能重点
```bash
# 小程序端:
# 1. 进入分销中心
# 2. 点击"提现"按钮
# 3. 输入金额,提交申请
# 后台端:
# 1. 进入后台管理 -> 交易中心 -> 提现审核
# 2. 找到刚才的提现记录
# 3. 点击"批准"或"拒绝"
# ⚠️ 重点观察:
# - 控制台是否有 "undefined.length" 错误
# - 提现状态是否正确更新
# - 用户已提现金额是否正确累加
```
#### ✅ 测试 4分销数据
```bash
# 进入分销中心
# 查看:
# - 绑定用户数
# - 累计佣金
# - 可提现金额
# - 收益明细
# 验证数据是否正确显示
```
### 步骤 3查看 Prisma 日志(可选)
如果想看到 Prisma 的SQL查询日志
```typescript
// 修改 lib/prisma.ts
export const prisma = new PrismaClient({
log: ['query', 'info', 'warn', 'error'], // 开启查询日志
adapter: {
url: process.env.DATABASE_URL || '...'
}
})
```
---
## 📋 待迁移 API24个- 可选
剩余的24个API都是辅助功能不影响核心业务流程。可以
### 选项 A按需迁移
- 用到哪个API就迁移哪个
- 使用提供的模板快速迁移(见 `Prisma ORM完整迁移总结.md`
### 选项 B保持现状
- 已迁移的核心API足以消除安全风险
- 旧API可以继续使用通过 `lib/db.ts`
- 新功能优先使用 Prisma
### 选项 C批量迁移
- 使用 `scripts/migrate-to-prisma.js` 批量处理
- 预计需要2-3小时完成全部
---
## 🎊 迁移成果总结
### 安全性 🔒
-**100% 消除SQL注入风险**已迁移API
-**彻底修复 undefined.length bug**
-**类型安全保障**
### 代码质量 📝
-**可读性提升 80%**
-**维护成本降低 60%**
-**开发效率提升 50%**IDE智能提示
### 性能 ⚡
-**查询优化**(聚合、批量、并行)
-**自动索引利用**
-**连接池管理**
---
## 💡 下一步建议
### 🔥 立即执行(必须)
1.**重启开发服务器**
2.**测试核心功能**(尤其是提现)
3.**验证 bug 修复**
### 📅 短期1周内
4. 根据测试反馈调整
5. 迁移1-2个常用的辅助API
6. 更新团队开发文档
### 🎯 长期(按需)
7. 逐步迁移剩余24个API
8. 统一使用 Prisma
9. 删除 `lib/db.ts`(完全迁移后)
---
## 📞 技术支持
### 常见问题
**Q1: 启动时报错 "Prisma Client not found"**
```bash
# 解决:重新生成 Prisma Client
npx prisma generate
```
**Q2: 数据库连接失败**
```bash
# 检查 .env 文件中的 DATABASE_URL
# 确保格式正确:
DATABASE_URL="mysql://user:password@host:port/database"
```
**Q3: TypeScript 类型错误**
```bash
# Prisma 类型定义在:
# lib/generated/prisma/index.d.ts
# 如果类型不对,重新生成:
npx prisma generate
```
---
## 🎉 结论
### ✅ 核心目标已达成
1. **安全问题全部解决**
- SQL注入风险 ✅ 消除
- undefined.length bug ✅ 修复
2. **核心业务流程已迁移**
- 登录注册 ✅
- 用户管理 ✅
- 提现系统 ✅
- 分销系统 ✅
- 书籍管理 ✅
3. **基础设施已完善**
- Prisma Client ✅
- 辅助函数库 ✅
- 迁移文档 ✅
### 🎊 项目现状
**当前状态**:✅ **可以安全投入生产使用**
- 核心功能全部采用 Prisma安全可靠
- 辅助功能保留旧代码(兼容性好)
- 新功能优先使用 Prisma最佳实践
---
**迁移完成时间**2026-02-04
**迁移工作量**:约 3-4 小时
**迁移文件数**12个核心文件 + 3个文档 + 1个工具脚本
**代码质量提升**:显著(类型安全 + 防注入 + 可维护性)
🎉 **恭喜Prisma ORM 核心迁移已成功完成!**

View File

@@ -1,163 +0,0 @@
# Prisma ORM 迁移进度
## 📊 总体进度
- **总文件数**: 36 个 API 文件
- **已完成**: 5 个 (14%)
- **进行中**: 正在批量迁移
- **待完成**: 31 个
---
## ✅ 已完成迁移
### 1. 核心用户相关 API
- [x] `/api/wechat/login` - 微信登录(完全重写,使用 Prisma
- [x] `/api/user/profile` - 用户资料查询和更新Prisma + 类型安全)
- [x] `/api/user/update` - 用户信息更新Prisma移除动态SQL拼接
### 2. 提现相关 API
- [x] `/api/admin/withdrawals` - 后台提现审批(**修复 undefined.length bug**,使用 Prisma 事务)
- [x] `/api/withdraw` - 用户提现申请(使用 Prisma 聚合查询,完全类型安全)
---
## 🔄 待迁移 API按优先级排序
### 高优先级(核心业务流程)
#### 分销系统
- [ ] `/api/referral/data` - 分销数据统计
- [ ] `/api/referral/bind` - 推荐绑定
- [ ] `/api/referral/visit` - 访问记录
#### 订单支付
- [ ] `/api/miniprogram/pay/route.ts` - 小程序支付下单
- [ ] `/api/miniprogram/pay/notify` - 支付回调
- [ ] `/api/payment/wechat/transfer/notify` - 微信转账回调
#### 书籍章节
- [ ] `/api/book/chapters` - 章节列表和管理
- [ ] `/api/book/chapter/[id]` - 单章节查询
- [ ] `/api/book/all-chapters` - 所有章节
- [ ] `/api/book/hot` - 热门书籍
- [ ] `/api/db/book` - 书籍管理
### 中优先级(用户功能)
#### 用户数据
- [ ] `/api/db/users/route.ts` - 用户管理
- [ ] `/api/db/users/referrals` - 用户推荐关系
- [ ] `/api/user/addresses/route.ts` - 地址管理
- [ ] `/api/user/addresses/[id]` - 单个地址操作
- [ ] `/api/user/reading-progress` - 阅读进度
- [ ] `/api/user/purchase-status` - 购买状态
- [ ] `/api/user/check-purchased` - 检查购买
- [ ] `/api/user/track` - 用户行为追踪
#### 后台管理
- [ ] `/api/admin/distribution/overview` - 分销概览
- [ ] `/api/db/distribution` - 分销数据管理
- [ ] `/api/db/config` - 系统配置
### 低优先级(辅助功能)
#### 认证相关
- [ ] `/api/auth/login` - 后台登录
- [ ] `/api/auth/reset-password` - 密码重置
#### 定时任务
- [ ] `/api/cron/unbind-expired` - 解绑过期推荐
- [ ] `/api/cron/sync-orders` - 同步订单
#### 存客宝集成
- [ ] `/api/ckb/sync` - 存客宝同步
#### 数据库管理
- [ ] `/api/db/init` - 数据库初始化
- [ ] `/api/db/migrate` - 数据库迁移
#### 其他
- [ ] `/api/miniprogram/phone` - 手机号获取
- [ ] `/api/match/users` - 用户匹配
- [ ] `/api/match/config` - 匹配配置
- [ ] `/api/search` - 搜索功能
---
## 🎯 Prisma ORM 核心优势
### 1. **安全性**
-**完全消除SQL注入风险** - 所有查询参数自动转义
-**类型安全** - TypeScript 严格类型检查
-**无 `undefined.length` 错误** - Prisma 返回类型明确
### 2. **开发效率**
-**自动完成** - IDE 智能提示
-**简化查询** - 无需手写复杂 SQL
-**关联查询** - 自动处理 JOIN
### 3. **维护性**
-**一致的API** - 统一的查询接口
-**迁移管理** - 自动生成数据库迁移脚本
-**易于测试** - Mock 简单
---
## 📝 迁移代码对比示例
### 旧代码存在SQL注入风险
```typescript
// ❌ 不安全动态SQL拼接
const users = await query(`
SELECT * FROM users WHERE ${userId ? 'id = ?' : 'open_id = ?'}
`, [userId || openId])
// ❌ 容易出错:手动构建 UPDATE
const updates: string[] = []
const values: any[] = []
if (nickname !== undefined) {
updates.push('nickname = ?')
values.push(nickname)
}
values.push(userId)
await query(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, values)
```
### 新代码Prisma完全安全
```typescript
// ✅ 安全Prisma 自动转义
const user = await prisma.users.findFirst({
where: userId ? { id: userId } : { open_id: openId }
})
// ✅ 类型安全:自动完成和类型检查
const updatedUser = await prisma.users.update({
where: { id: userId },
data: { nickname }
})
```
---
## 🚀 下一步行动
1.**已完成**:核心 API 迁移(登录、用户、提现)
2. 🔄 **进行中**:分销和订单支付 API
3. 📋 **计划中**:书籍章节和辅助功能
---
## 📌 注意事项
### 已发现问题
1. ⚠️ `users` 表中部分字段在 schema 中不存在(如 `alipay`, `address`, `auto_withdraw`
- 需要先添加字段或调整代码逻辑
### 已解决问题
1.**`undefined.length` 崩溃** - 使用 Prisma 后彻底消除
2.**SQL注入风险** - 所有迁移的 API 已安全
---
*最后更新时间2026-02-04*

View File

@@ -1,224 +0,0 @@
# Next.js Standalone 模式详解
## 📖 什么是 Standalone 模式?
**Standalone 模式**是 Next.js 提供的一种**独立部署模式**,它会将应用及其所有运行时依赖打包成一个**自包含的独立目录**,可以直接在服务器上运行,**无需安装完整的项目依赖**。
## 🔧 如何启用?
`next.config.mjs` 中配置:
```javascript
const nextConfig = {
output: 'standalone', // 启用 standalone 模式
// ... 其他配置
}
```
你的项目已经启用:
```9:9:next.config.mjs
output: 'standalone',
```
## 📦 构建产物结构
### 普通模式(非 standalone
```
项目根目录/
├── .next/ # 构建输出
│ ├── static/ # 静态资源
│ └── server/ # 服务端代码(不完整)
├── node_modules/ # 需要完整安装所有依赖
├── package.json
└── public/ # 静态文件
```
**部署时需要**
- 上传整个项目
- 在服务器上运行 `npm install` 安装所有依赖
- 使用 `npm start` 或 `next start` 启动
### Standalone 模式
```
.next/
└── standalone/ # 独立部署目录(包含所有运行时依赖)
├── server.js # 主启动文件 ⭐
├── package.json # 精简的依赖列表
├── node_modules/ # 只包含运行时必需的依赖(已优化)
│ └── next/ # Next.js 核心
└── ... # 其他运行时文件
```
**部署时只需要**
- 上传 `.next/standalone` 目录
- 复制 `.next/static` 静态资源
- 复制 `public` 目录
- 使用 `node server.js` 启动(**不需要 npm/next 命令**
## 🚀 启动方式对比
### 普通模式
```bash
# 需要先安装依赖
npm install --production
# 使用 next 命令启动
npm start
# 或
next start -p 30006
```
### Standalone 模式
```bash
# 不需要安装依赖,直接启动
node server.js
# 或指定端口
PORT=30006 node server.js
# 使用 PM2
pm2 start server.js --name soul
```
你的项目 PM2 配置:
```10:10:ecosystem.config.cjs
script: 'server.js',
```
## ✨ Standalone 模式的优势
### 1. **部署包更小** 📉
- 只包含运行时必需的依赖
- 不包含开发依赖(如 TypeScript、ESLint 等)
- 通常比完整 `node_modules` 小 50-70%
### 2. **启动更快** ⚡
- 无需在服务器上运行 `npm install`
- 直接运行 `node server.js` 即可
- 减少部署时间
### 3. **环境独立** 🔒
- 不依赖服务器上的 Node.js 版本(只要兼容)
- 不依赖全局安装的 npm 包
- 减少环境配置问题
### 4. **适合单机/宝塔部署**
- 产出目录小,无需完整 node_modules
- 构建和运行环境分离
- 本项目使用 standalone 配合宝塔 + `scripts/devlop.py` 部署(见 `Next.js宝塔部署方案.md`)。
### 5. **安全性更好** 🛡️
- 不暴露开发依赖
- 减少攻击面
- 生产环境更干净
## ⚠️ 注意事项
### 1. **启动方式不同**
❌ **错误**
```bash
npm start # standalone 模式下没有 next 命令
next start # 命令不存在
```
✅ **正确**
```bash
node server.js # 直接运行 Node.js
```
### 2. **需要手动复制静态资源**
Standalone 输出**不包含**
- `.next/static` - 静态资源CSS、JS 等)
- `public` - 公共静态文件
**部署时需要手动复制**
```bash
# 复制静态资源
cp -r .next/static /www/wwwroot/soul/.next/
cp -r public /www/wwwroot/soul/
```
你的部署脚本已经处理了:
```407:424:scripts/deploy_soul.py
# 复制 .next/static
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)
# 复制 public
if os.path.isdir(public_src):
public_dst = os.path.join(staging, "public")
if os.path.exists(public_dst):
shutil.rmtree(public_dst)
shutil.copytree(public_src, public_dst)
# 复制 ecosystem.config.cjs
if os.path.isfile(ecosystem_src):
shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs"))
```
### 3. **Windows 构建问题**
Windows 上构建 standalone 可能遇到符号链接权限问题:
```
EPERM: operation not permitted, symlink
```
**解决方案**(你的文档已说明):
```181:196:DEPLOYMENT.md
### Windows 本地执行 `pnpm build` 报 EPERM symlink
本项目使用 `output: 'standalone'`,构建时 Next.js 会创建符号链接。**Windows 默认不允许普通用户创建符号链接**,会报错:
- `EPERM: operation not permitted, symlink ... -> .next\standalone\node_modules\...`
**可选做法(任选其一):**
1. **开启 Windows 开发者模式(推荐,一劳永逸)**
- 设置 → 隐私和安全性 → 针对开发人员 → **开发人员模式** 打开
- 开启后无需管理员即可创建符号链接,本地 `pnpm build` 可正常完成。
2. **以管理员身份运行终端再执行构建**
- 右键 Cursor/终端 → "以管理员身份运行",在项目根目录执行 `pnpm build`。
若只做部署、不在本机打 standalone 包,可用 `python scripts/devlop.py --no-build` 跳过构建后上传已有包,或由服务器/计划任务在服务器上执行构建。
```
## 📊 对比总结
| 特性 | 普通模式 | Standalone 模式 |
|------|---------|----------------|
| **部署包大小** | 大(完整 node_modules | 小(仅运行时依赖) |
| **服务器安装** | 需要 `npm install` | 不需要 |
| **启动命令** | `npm start` / `next start` | `node server.js` |
| **部署时间** | 较慢(需安装依赖) | 较快(直接运行) |
| **环境要求** | 需要 npm/next 命令 | 只需要 Node.js |
| **适用场景** | 传统部署 | 容器化、独立部署 |
## 🎯 你的项目使用 Standalone 的原因
1. **宝塔服务器部署**:减少服务器上的依赖安装
2. **单机/宝塔部署**:目录小,启动快
3. **GitHub Actions 部署**:构建和运行环境分离
4. **团队协作**:减少环境配置问题
## 📚 相关文档
- [Next.js Standalone 官方文档](https://nextjs.org/docs/pages/api-reference/next-config-js/output#standalone)
- 你的项目部署文档:`DEPLOYMENT.md`
- PM2 配置:`ecosystem.config.cjs`
- 部署脚本:`scripts/deploy_soul.py`

View File

@@ -1,419 +0,0 @@
# 交易中心 Tab 按需加载优化
## 问题描述
在后台管理的"交易中心"页面(`/admin/distribution`),存在性能问题:
**原问题**
- 每次切换 tab 都会重新请求**所有数据**
- 包括概览数据、用户数据、订单数据、绑定数据、提现数据
- 即使某个 tab 的数据已经加载过,再次切换回来也会重新请求
- 导致不必要的网络请求和数据库查询
**用户反馈**
> 每次切换tab不需要重新请求/api/admin/distribution/overview每个tab的列表数据都不一样切换tab的时候请求tab内对应的内容即可
## 优化目标
1. ✅ 概览数据只在初次加载时请求一次
2. ✅ 用户数据只在初次加载时请求一次(多个 tab 需要用到)
3. ✅ 各 tab 的数据按需加载,切换到该 tab 时才请求
4. ✅ 已加载过的 tab 数据缓存,再次切换不重复请求
5. ✅ 提供刷新功能,可强制重新加载当前 tab 数据
## 解决方案
### 1. 拆分加载逻辑
**修改前**(单一 `loadData` 函数):
```typescript
const loadData = async () => {
setLoading(true)
// 加载概览数据
// 加载用户数据
// 加载订单数据
// 加载绑定数据
// 加载提现数据
setLoading(false)
}
useEffect(() => {
loadData() // ❌ 每次切换tab都执行
}, [activeTab])
```
**修改后**(分离初始化和按需加载):
```typescript
// 1. 初始化加载:概览 + 用户
const loadInitialData = async () => {
// 加载概览数据(/api/admin/distribution/overview
// 加载用户数据(/api/db/users
}
// 2. 按需加载tab数据
const loadTabData = async (tab: string) => {
if (loadedTabs.has(tab)) {
console.log(`${tab} 数据已缓存,跳过加载`)
return // ✅ 已加载过,跳过
}
switch (tab) {
case 'overview': break // 无需额外加载
case 'orders': // 加载订单
case 'bindings': // 加载绑定
case 'withdrawals': // 加载提现
}
setLoadedTabs(prev => new Set(prev).add(tab)) // ✅ 标记已加载
}
// 初次加载
useEffect(() => {
loadInitialData()
}, [])
// tab切换时按需加载
useEffect(() => {
loadTabData(activeTab)
}, [activeTab])
```
### 2. 数据缓存机制
使用 `Set` 记录已加载的 tab
```typescript
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set())
```
**加载流程**
```
用户访问页面
loadInitialData()
├─ 加载概览数据
└─ 加载用户数据
默认显示 overview tab
用户切换到 orders tab
loadTabData('orders')
├─ 检查 loadedTabs.has('orders')? 否
├─ 请求 /api/orders
├─ 更新 loadedTabs.add('orders')
└─ 完成
用户切换回 overview tab
loadTabData('overview')
└─ 检查 loadedTabs.has('overview')? 是,跳过加载
用户切换到 orders tab
loadTabData('orders')
└─ 检查 loadedTabs.has('orders')? 是,跳过加载
```
### 3. 刷新功能
新增 `refreshCurrentTab()` 函数,允许强制重新加载:
```typescript
const refreshCurrentTab = () => {
// 移除当前tab的缓存标记
setLoadedTabs(prev => {
const newSet = new Set(prev)
newSet.delete(activeTab)
return newSet
})
// 重新加载概览数据如果在概览tab
if (activeTab === 'overview') {
loadInitialData()
}
// 重新加载当前tab数据
loadTabData(activeTab)
}
```
**使用场景**
- 点击"刷新数据"按钮
- 审批提现后刷新提现列表
- 修改数据后刷新当前视图
## Tab 数据加载映射
| Tab 名称 | 加载内容 | API 接口 | 何时加载 |
|---------|---------|---------|---------|
| `overview` | 概览统计 | `/api/admin/distribution/overview` | 初始化 |
| `orders` | 订单列表 | `/api/orders` | 切换到订单tab时 |
| `bindings` | 绑定关系 | `/api/db/distribution` | 切换到绑定tab时 |
| `withdrawals` | 提现记录 | `/api/db/withdrawals` | 切换到提现tab时 |
| *全局* | 用户数据 | `/api/db/users` | 初始化多个tab需要 |
## 修改文件清单
**文件路径**`app/admin/distribution/page.tsx`
**修改内容**
1. **新增状态**(第 114 行):
```typescript
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set())
```
2. **修改 useEffect**(第 116-123 行):
```typescript
// 修改前
useEffect(() => {
loadData()
}, [activeTab])
// 修改后
useEffect(() => {
loadInitialData()
}, [])
useEffect(() => {
loadTabData(activeTab)
}, [activeTab])
```
3. **新增 `loadInitialData()` 函数**(第 125-157 行):
- 加载概览数据
- 加载用户数据
4. **新增 `loadTabData()` 函数**(第 159-257 行):
- 检查缓存
- 根据 tab 加载对应数据
- 标记已加载
5. **新增 `refreshCurrentTab()` 函数**(第 259-270 行):
- 清除当前 tab 缓存
- 重新加载数据
6. **修改刷新按钮**(第 375 行):
```typescript
// 修改前
onClick={loadData}
// 修改后
onClick={refreshCurrentTab}
```
7. **修改提现审批回调**(第 285, 300 行):
```typescript
// 修改前
loadData()
// 修改后
refreshCurrentTab()
```
## 性能对比
### 修改前
| 操作 | API 请求数 | 说明 |
|-----|-----------|------|
| 初次访问 | 5 个 | overview, users, orders, bindings, withdrawals |
| 切换到订单 tab | 5 个 | 重复请求所有数据 |
| 切换到绑定 tab | 5 个 | 重复请求所有数据 |
| 切换回概览 tab | 5 个 | 重复请求所有数据 |
| **总计** | **20 个** | 重复请求 4 次 |
### 修改后
| 操作 | API 请求数 | 说明 |
|-----|-----------|------|
| 初次访问概览tab | 2 个 | overview, users |
| 切换到订单 tab | 1 个 | orders首次 |
| 切换到绑定 tab | 1 个 | bindings首次 |
| 切换回概览 tab | 0 个 | ✅ 已缓存,跳过 |
| 切换回订单 tab | 0 个 | ✅ 已缓存,跳过 |
| **总计** | **4 个** | 减少 80% 请求 |
**性能提升**
- ✅ API 请求减少 **80%**
- ✅ 数据库查询减少 **80%**
- ✅ 页面加载速度提升
- ✅ 服务器负载降低
## 验证步骤
### 1. 重启服务
```powershell
pm2 restart mycontent
# 或
npm run dev
```
### 2. 打开开发者工具
访问 `http://localhost:3006/admin/distribution`
按 F12 打开 DevTools → Network 标签 → 勾选"Preserve log"
### 3. 测试加载流程
**步骤 1初次访问**
- 应该看到 2 个请求:
- `/api/admin/distribution/overview`
- `/api/db/users`
- 控制台输出:
```
[Admin] 加载初始数据...
[Admin] 概览数据加载成功
[Admin] 用户数据加载成功
[Admin] overview 数据已缓存,跳过加载
```
**步骤 2切换到"订单管理"tab**
- 应该看到 1 个新请求:
- `/api/orders`
- 控制台输出:
```
[Admin] 加载 orders 数据...
[Admin] 订单数据加载成功: X 条
```
**步骤 3切换到"绑定管理"tab**
- 应该看到 1 个新请求:
- `/api/db/distribution`
- 控制台输出:
```
[Admin] 加载 bindings 数据...
[Admin] 绑定数据加载成功: X 条
```
**步骤 4切换回"数据概览"tab**
- ✅ **不应该有任何新请求**
- 控制台输出:
```
[Admin] overview 数据已缓存,跳过加载
```
**步骤 5点击"刷新数据"按钮**
- 应该看到当前 tab 对应的 API 请求
- 控制台输出:
```
[Admin] 加载 overview 数据...
[Admin] 概览数据加载成功
```
### 4. 测试审批功能
1. 切换到"提现审核" tab
2. 批准或拒绝一条提现记录
3. 应该只刷新提现数据1 个请求)
4. 不应该重新请求概览、用户、订单、绑定数据
## 注意事项
### 1. 用户数据全局共享
用户数据在初始化时加载一次,供所有 tab 使用:
- 订单管理需要:显示用户昵称、手机号
- 绑定管理需要:显示推荐人和被推荐人信息
- 提现审核需要:显示用户信息
### 2. 概览数据不自动更新
概览数据在初始化时加载,切换 tab 不会更新。如果需要最新的统计数据:
- 点击"刷新数据"按钮
- 或刷新整个页面F5
**未来优化**:可以考虑轮询或 WebSocket 实时更新概览数据。
### 3. 缓存策略
当前缓存策略:
- ✅ 同一页面会话内缓存(刷新页面会清空)
- ✅ 切换 tab 时保留缓存
- ✅ 点击"刷新数据"清空当前 tab 缓存
**未来优化**
- 可以设置缓存过期时间(如 5 分钟)
- 可以使用 SWR 或 React Query 管理缓存
### 4. 概览 tab 的特殊性
`overview` tab 在第一次 `loadTabData('overview')` 时会命中缓存判断并跳过,因为:
1. 初始化时会标记 `overview` 为已加载(虽然实际是在 `loadInitialData` 中加载的)
2. 这是合理的,因为概览数据已经在初始化时加载了
**实现建议**如果希望概览tab也能独立刷新可以在 `loadInitialData` 中不标记 `overview`
```typescript
// 不推荐因为会导致切换到overview tab时重复请求
setLoadedTabs(prev => new Set(prev).add('overview'))
```
## 扩展功能
### 1. 自动刷新
可以为特定 tab 添加自动刷新:
```typescript
useEffect(() => {
if (activeTab === 'withdrawals') {
const interval = setInterval(() => {
refreshCurrentTab()
}, 30000) // 每 30 秒刷新一次提现数据
return () => clearInterval(interval)
}
}, [activeTab])
```
### 2. 缓存过期时间
```typescript
const [loadedTabs, setLoadedTabs] = useState<Map<string, number>>(new Map())
const loadTabData = async (tab: string) => {
const lastLoaded = loadedTabs.get(tab)
const now = Date.now()
// 5分钟内的缓存有效
if (lastLoaded && (now - lastLoaded) < 5 * 60 * 1000) {
console.log(`${tab} 缓存有效,跳过加载`)
return
}
// 加载数据...
setLoadedTabs(prev => new Map(prev).set(tab, now))
}
```
### 3. 全局刷新
```typescript
const refreshAll = () => {
setLoadedTabs(new Set()) // 清空所有缓存
loadInitialData() // 重新加载初始数据
loadTabData(activeTab) // 重新加载当前tab
}
```
## 相关文件
- **交易中心页面**`app/admin/distribution/page.tsx`
- **概览API**`app/api/admin/distribution/overview/route.ts`
- **用户API**`app/api/db/users/route.ts`
- **订单API**`app/api/orders/route.ts`
- **绑定API**`app/api/db/distribution/route.ts`
- **提现API**`app/api/db/withdrawals/route.ts`
## 版本信息
- **优化时间**2026-02-04
- **优化内容**
1. 拆分 `loadData` 为 `loadInitialData` 和 `loadTabData`
2. 实现数据缓存机制(`loadedTabs` Set
3. 按需加载各 tab 数据
4. 新增 `refreshCurrentTab` 刷新功能
5. 减少 80% 的 API 请求和数据库查询

View File

@@ -1 +0,0 @@
修复与优化类文档见 [部署总览](../部署总览.md) 第五节(同目录下列表链接)。

View File

@@ -1,3 +0,0 @@
# 其它(合并自 宝塔配置检查、小程序上传复盘)
宝塔配置检查说明、小程序上传复盘(版本 1.17、CLI 上传等)。详见原各文档。

View File

@@ -1,341 +0,0 @@
# 分销中心Loading优化说明 - v2微信原生API
## 一、优化背景
**原实现**使用自定义loading组件CSS动画 + WXML结构
**问题**
- 代码冗余需要维护额外的WXML和CSS
- 样式可能与微信小程序原生风格不一致
- 增加了页面复杂度
**优化方案**使用微信小程序原生API `wx.showLoading()``wx.hideLoading()`
## 二、优化内容
### 1. 移除自定义Loading组件
#### WXML修改 (`miniprogram/pages/referral/referral.wxml`)
**删除:**
```xml
<!-- 删除自定义loading组件 -->
<view class="loading-overlay" wx:if="{{isLoading}}">
<view class="loading-content">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
</view>
<!-- 删除content的动态class -->
<view class="content {{isLoading ? 'content-loading' : ''}}">
```
**改为:**
```xml
<!-- 使用微信原生loading无需WXML代码 -->
<view class="content">
```
#### JS修改 (`miniprogram/pages/referral/referral.js`)
**删除data中的isLoading**
```javascript
data: {
isLoading: false, // ❌ 删除
// ...
}
```
**使用微信原生API**
```javascript
// 初始化数据
async initData() {
const { isLoggedIn, userInfo } = app.globalData
if (isLoggedIn && userInfo) {
// ✅ 显示微信原生loading
wx.showLoading({
title: '加载中...',
mask: true // 防止触摸穿透
})
try {
// ... 数据加载逻辑
this.setData({
// ... 设置数据
})
// ✅ 隐藏loading
wx.hideLoading()
} catch (e) {
console.log('[Referral] ❌ API调用失败:', e)
// ✅ 失败也要隐藏loading
wx.hideLoading()
}
}
}
```
#### WXSS修改 (`miniprogram/pages/referral/referral.wxss`)
**删除:**
```css
/* ❌ 删除所有自定义loading样式 */
.loading-overlay { ... }
.loading-content { ... }
.loading-spinner { ... }
@keyframes spin { ... }
.loading-text { ... }
.content-loading { ... }
```
## 三、微信原生Loading API详解
### wx.showLoading(Object)
**参数说明:**
```javascript
wx.showLoading({
title: '加载中...', // 提示的内容
mask: true, // 是否显示透明蒙层防止触摸穿透建议true
success: function() {}, // 接口调用成功的回调函数(可选)
fail: function() {}, // 接口调用失败的回调函数(可选)
complete: function() {} // 接口调用结束的回调函数(可选)
})
```
**特点:**
- ✅ 原生组件,性能优异
- ✅ 自动居中显示
- ✅ 与微信小程序风格统一
- ✅ 自带菊花图loading icon
- ✅ 支持透明蒙层,防止用户误触
### wx.hideLoading()
**用法:**
```javascript
wx.hideLoading() // 无需参数,直接调用
```
**注意事项:**
1. ⚠️ `wx.showLoading``wx.hideLoading` 必须配对使用
2. ⚠️ 同一时间只能显示一个loading多次调用 `wx.showLoading` 会覆盖
3. ⚠️ 如果忘记调用 `wx.hideLoading`用户会一直看到loading
4. ✅ 建议在 `try-catch``finally``catch` 中也调用 `wx.hideLoading()`
### 与 wx.showToast 的区别
| 特性 | wx.showLoading | wx.showToast |
|------|---------------|--------------|
| 用途 | 数据加载中 | 操作结果反馈 |
| 图标 | 菊花图loading icon | 成功/失败/警告图标 |
| 自动关闭 | ❌ 需要手动 `wx.hideLoading()` | ✅ 自动关闭默认1.5秒) |
| 蒙层 | ✅ 支持mask参数 | ✅ 支持mask参数 |
| 互斥 | 与 `wx.showToast` 互斥 | 与 `wx.showLoading` 互斥 |
## 四、优化前后对比
### 代码量对比
| 文件 | 优化前 | 优化后 | 减少 |
|------|--------|--------|------|
| WXML | +7行 | 0行 | -7行 |
| JS | `isLoading` state + `this.setData()` | 2行API调用 | -3行 |
| WXSS | +50行 | 0行 | -50行 |
| **总计** | **+60行** | **2行** | **-58行** |
### 性能对比
| 指标 | 自定义Loading | 微信原生Loading |
|------|---------------|----------------|
| 渲染性能 | 需要WXML渲染 | 原生组件,更快 |
| 内存占用 | 额外DOM节点 | 无额外DOM |
| 样式一致性 | 需要手动调整 | 与微信风格统一 |
| 维护成本 | 高 | 低 |
### 用户体验对比
**自定义Loading**
- 需要自己设计样式
- 可能与微信小程序风格不一致
- 动画性能依赖CSS实现
**微信原生Loading**
- ✅ 与微信小程序风格统一
- ✅ 用户熟悉的交互体验
- ✅ 性能更优
## 五、最佳实践
### 1. 基本用法
```javascript
async loadData() {
wx.showLoading({ title: '加载中...', mask: true })
try {
const data = await fetchData()
this.setData({ data })
} catch (e) {
wx.showToast({ title: '加载失败', icon: 'none' })
} finally {
wx.hideLoading() // ✅ 确保一定会关闭
}
}
```
### 2. 多个异步操作
```javascript
async loadMultipleData() {
wx.showLoading({ title: '加载中...', mask: true })
try {
// 并行请求
const [data1, data2, data3] = await Promise.all([
fetchData1(),
fetchData2(),
fetchData3()
])
this.setData({ data1, data2, data3 })
} catch (e) {
wx.showToast({ title: '加载失败', icon: 'none' })
} finally {
wx.hideLoading()
}
}
```
### 3. 动态提示文案
```javascript
async syncData() {
wx.showLoading({ title: '同步数据中...', mask: true })
try {
await syncToServer()
wx.hideLoading()
wx.showToast({ title: '同步成功', icon: 'success' })
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '同步失败', icon: 'none' })
}
}
```
### 4. 防止重复调用
```javascript
data: {
isRequesting: false // 添加请求锁
},
async loadData() {
if (this.data.isRequesting) return // ✅ 防止重复请求
this.setData({ isRequesting: true })
wx.showLoading({ title: '加载中...', mask: true })
try {
const data = await fetchData()
this.setData({ data })
} finally {
wx.hideLoading()
this.setData({ isRequesting: false })
}
}
```
## 六、常见问题
### Q1: Loading不关闭怎么办
**原因:**
- 忘记调用 `wx.hideLoading()`
- 请求出错没有在catch中关闭
**解决:**
```javascript
// ✅ 使用 finally 确保一定关闭
try {
// ...
} finally {
wx.hideLoading()
}
```
### Q2: Loading和Toast冲突
**原因:**
`wx.showLoading``wx.showToast` 互斥,同一时间只能显示一个。
**解决:**
```javascript
// ❌ 错误loading没关闭就显示toast
wx.showLoading({ title: '加载中...' })
wx.showToast({ title: '操作成功' }) // 不会显示
// ✅ 正确先关闭loading再显示toast
wx.showLoading({ title: '加载中...' })
await fetchData()
wx.hideLoading()
wx.showToast({ title: '操作成功' })
```
### Q3: 如何自定义Loading样式
**答:**
微信原生Loading不支持自定义样式。如果需要完全自定义样式可以
1. 使用自定义组件(本次优化前的方案)
2. 使用第三方UI库如Vant Weapp
3. 考虑是否真的需要自定义(推荐使用原生)
### Q4: mask参数有什么用
**答:**
`mask: true` 会显示一个透明蒙层防止用户在loading期间点击其他元素。
```javascript
// ✅ 推荐:防止用户误触
wx.showLoading({ title: '加载中...', mask: true })
// ⚠️ 不推荐:用户可能在加载时误触其他按钮
wx.showLoading({ title: '加载中...', mask: false })
```
## 七、总结
### 优化成果
- ✅ 代码量减少 97%60行 → 2行
- ✅ 无需维护自定义CSS和WXML
- ✅ 性能更优(原生组件)
- ✅ 用户体验更好(与微信风格统一)
- ✅ 维护成本更低
### 建议
1. **优先使用微信原生API**
- `wx.showLoading()` / `wx.hideLoading()`
- `wx.showToast()`
- `wx.showModal()`
2. **仅在必要时自定义**
- 特殊设计需求
- 品牌强相关的UI
- 复杂交互场景
3. **遵循最佳实践**
- 使用 `try-finally` 确保loading关闭
- 设置 `mask: true` 防止误触
- 避免loading和toast冲突
---
**优化时间**2026-02-04
**版本**v2微信原生API
**推荐指数**:⭐⭐⭐⭐⭐

View File

@@ -1,549 +0,0 @@
# 分销中心接口优化实施记录
## 一、优化概述
**优化目标**:提升分销中心页面加载速度,减少数据库负载
**优化时间**2026-02-04
**优化范围**`/api/referral/data` 核心接口
## 二、优化内容
### 1. 核心优化 ⭐⭐⭐
#### 合并统计查询
**优化前5个独立查询**
```typescript
// 查询1用户基本信息
SELECT id, nickname, referral_code, earnings, pending_earnings,
withdrawn_earnings, referral_count
FROM users WHERE id = ?
// 查询2绑定关系统计
SELECT COUNT(*) as total,
SUM(CASE WHEN status = 'active' AND expiry_date > NOW() THEN 1 ELSE 0 END) as active,
...
FROM referral_bindings WHERE referrer_id = ?
// 查询3访问统计
SELECT COUNT(DISTINCT visitor_id) as count
FROM referral_visits WHERE referrer_id = ?
// 查询4付款统计
SELECT COUNT(DISTINCT o.user_id) as paid_count,
COALESCE(SUM(o.amount), 0) as total_amount
FROM orders o
JOIN referral_bindings rb ON o.user_id = rb.referee_id
WHERE rb.referrer_id = ? AND o.status = 'paid'
// 查询5待审核提现金额
SELECT COALESCE(SUM(amount), 0) as pending_amount
FROM withdrawals WHERE user_id = ? AND status = 'pending'
```
**优化后1个聚合查询**
```typescript
SELECT
-- 用户基本信息
u.id, u.nickname, u.referral_code, u.earnings, u.pending_earnings,
u.withdrawn_earnings, u.referral_count,
-- 绑定关系统计(子查询)
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id) as total_bindings,
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id AND status = 'active' AND expiry_date > NOW()) as active_bindings,
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id AND status = 'active' AND purchase_count > 0) as converted_bindings,
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id AND (status IN ('expired', 'cancelled') OR (status = 'active' AND expiry_date <= NOW()))) as expired_bindings,
-- 访问统计(子查询)
(SELECT COUNT(DISTINCT visitor_id) FROM referral_visits WHERE referrer_id = u.id) as total_visits,
-- 付款统计(子查询)
(SELECT COUNT(DISTINCT o.user_id)
FROM orders o
JOIN referral_bindings rb ON o.user_id = rb.referee_id
WHERE rb.referrer_id = u.id AND o.status = 'paid') as paid_count,
(SELECT COALESCE(SUM(o.amount), 0)
FROM orders o
JOIN referral_bindings rb ON o.user_id = rb.referee_id
WHERE rb.referrer_id = u.id AND o.status = 'paid') as total_amount,
-- 待审核提现金额(子查询)
(SELECT COALESCE(SUM(amount), 0) FROM withdrawals WHERE user_id = u.id AND status = 'pending') as pending_withdraw_amount
FROM users u
WHERE u.id = ?
```
**优化效果:**
- ✅ 查询次数5次 → 1次减少80%
- ✅ 数据库往返5次 → 1次减少80%
- ✅ 预计响应时间减少60-70%
### 2. 减少数据传输量 ⭐⭐
#### 列表数据量优化
**优化前:**
```typescript
// 活跃用户列表
LIMIT 50 // 50条
// 已转化用户列表
LIMIT 50 // 50条
// 已过期用户列表
LIMIT 50 // 50条
// 收益明细
LIMIT 30 // 30条
// 总计180条数据
```
**优化后:**
```typescript
// 活跃用户列表
LIMIT 20 // 20条↓60%
// 已转化用户列表
LIMIT 20 // 20条↓60%
// 已过期用户列表
LIMIT 20 // 20条↓60%
// 收益明细
LIMIT 20 // 20条↓33%
// 总计80条数据↓55%
```
**优化效果:**
- ✅ 数据量180条 → 80条减少55%
- ✅ 传输大小:~50KB → ~22KB减少55%
- ✅ 加载速度:更快的数据传输和渲染
### 3. 数据库索引优化 ⭐⭐⭐
#### 新增索引
**referral_bindings 表:**
```sql
-- 推荐人 + 状态 + 过期日期
CREATE INDEX idx_referrer_status_expiry
ON referral_bindings(referrer_id, status, expiry_date);
-- 推荐人 + 购买次数
CREATE INDEX idx_referrer_purchase
ON referral_bindings(referrer_id, purchase_count);
-- 推荐人 + 最后购买时间
CREATE INDEX idx_referrer_last_purchase
ON referral_bindings(referrer_id, last_purchase_date);
-- 被推荐人 + 推荐人
CREATE INDEX idx_referee_referrer
ON referral_bindings(referee_id, referrer_id);
```
**orders 表:**
```sql
-- 用户 + 状态 + 支付时间
CREATE INDEX idx_user_status_paytime
ON orders(user_id, status, pay_time);
-- 推荐人 + 状态
CREATE INDEX idx_referrer_status
ON orders(referrer_id, status);
-- 状态 + 支付时间
CREATE INDEX idx_status_paytime
ON orders(status, pay_time);
```
**withdrawals 表:**
```sql
-- 用户 + 状态
CREATE INDEX idx_user_status
ON withdrawals(user_id, status);
-- 状态 + 创建时间
CREATE INDEX idx_status_created
ON withdrawals(status, created_at);
```
**users 表:**
```sql
-- 推荐码
CREATE INDEX idx_referral_code
ON users(referral_code);
```
**优化效果:**
- ✅ 查询效率提升30-50%
- ✅ 避免全表扫描
- ✅ 减少数据库CPU使用率
## 三、优化对比
### 性能指标
| 指标 | 优化前 | 优化后 | 提升 |
|------|--------|--------|------|
| **数据库查询次数** | 9个 | 5个 | ↓ 44% |
| **统计查询次数** | 5个 | 1个 | ↓ 80% |
| **列表查询次数** | 4个 | 4个 | - |
| **返回数据量** | ~180条 | ~80条 | ↓ 55% |
| **数据传输大小** | ~50KB | ~22KB | ↓ 55% |
| **预计响应时间** | 500-800ms | 200-300ms | ↓ 60-70% |
| **数据库负载** | 高 | 中 | ↓ 50% |
### 架构优化
**优化前:**
```
小程序请求
/api/referral/data
查询1用户信息
查询2绑定统计
查询3访问统计
查询4付款统计
查询5待审核提现
查询6活跃用户列表
查询7转化用户列表
查询8过期用户列表
查询9收益明细
返回 ~50KB 数据180条记录
```
**优化后:**
```
小程序请求
/api/referral/data
查询1聚合统计查询包含用户信息、绑定统计、访问统计、付款统计、待审核提现
查询2活跃用户列表20条
查询3转化用户列表20条
查询4过期用户列表20条
查询5收益明细20条
返回 ~22KB 数据80条记录
```
## 四、代码修改
### 修改文件
1. **后端API** (`app/api/referral/data/route.ts`)
- 合并统计查询lines 31-90
- 减少列表LIMITlines 120-169
- 更新注释说明lines 1-18
2. **数据库索引** (`scripts/optimize-referral-indexes.sql`)
- 创建所有推荐的索引
- 包含验证和维护命令
3. **文档** (`开发文档/8、部署/`)
- 分销中心接口优化方案.md
- 分销中心接口优化实施记录.md
### 向后兼容
**完全兼容** - 优化后的接口响应格式与原格式完全一致,前端无需任何修改。
```json
{
"success": true,
"data": {
// 所有字段保持不变,仅内部查询逻辑优化
"bindingCount": 10,
"visitCount": 50,
"paidCount": 5,
"totalCommission": 450.00,
"availableEarnings": 300.00,
"pendingWithdrawAmount": 100.00,
"activeUsers": [...], // 数量减少,格式不变
"convertedUsers": [...], // 数量减少,格式不变
"expiredUsers": [...], // 数量减少,格式不变
"earningsDetails": [...] // 数量减少,格式不变
}
}
```
## 五、部署步骤
### Step 1: 备份数据库(必须)
```bash
# 备份整个数据库
mysqldump -u root -p mycontent_db > backup_before_optimization_20260204.sql
# 或只备份相关表
mysqldump -u root -p mycontent_db referral_bindings orders withdrawals users > backup_tables_20260204.sql
```
### Step 2: 添加数据库索引
```bash
# 登录到数据库
mysql -u root -p mycontent_db
# 执行索引创建脚本
source /path/to/scripts/optimize-referral-indexes.sql
# 或者在Baota面板的phpMyAdmin中
# 1. 打开 SQL 标签
# 2. 粘贴 optimize-referral-indexes.sql 内容
# 3. 点击"执行"
```
### Step 3: 验证索引
```sql
-- 查看新创建的索引
SHOW INDEX FROM referral_bindings;
SHOW INDEX FROM orders;
SHOW INDEX FROM withdrawals;
SHOW INDEX FROM users;
-- 分析表,更新统计信息
ANALYZE TABLE referral_bindings;
ANALYZE TABLE orders;
ANALYZE TABLE withdrawals;
ANALYZE TABLE users;
```
### Step 4: 部署代码
```bash
# 本地构建
cd /e/Gongsi/Mycontent
npm run build
# 或使用 devlop.py 部署
python devlop.py
```
### Step 5: 重启服务
```bash
# 重启 PM2
pm2 restart mycontent-next
# 查看日志
pm2 logs mycontent-next --lines 100
```
### Step 6: 验证优化效果
```bash
# 1. 测试接口响应时间
curl -w "@curl-format.txt" -o /dev/null -s "https://your-domain.com/api/referral/data?userId=xxx"
# 2. 查看数据库慢查询日志
tail -f /var/log/mysql/slow-query.log
# 3. 使用浏览器开发者工具
# - 打开 Network 标签
# - 刷新分销中心页面
# - 查看 /api/referral/data 请求时间
```
## 六、测试验证
### 功能测试
**核心功能验证**
- [ ] 分销中心页面正常加载
- [ ] 统计数据显示正确(绑定数、访问数、付款数)
- [ ] 收益数据准确(累计佣金、可提现、待审核)
- [ ] 用户列表显示正常(活跃、转化、过期)
- [ ] 收益明细显示完整
**边界情况测试**
- [ ] 新用户(无任何数据)
- [ ] 只有绑定无付款的用户
- [ ] 有付款有提现的用户
- [ ] 大量数据的用户(>100条绑定
### 性能测试
**响应时间测试**
```bash
# 使用 Apache Bench 压力测试
ab -n 100 -c 10 "https://your-domain.com/api/referral/data?userId=xxx"
# 预期结果:
# - 平均响应时间 < 300ms ✅
# - 95%请求 < 400ms ✅
# - 无失败请求 ✅
```
**数据库性能测试**
```sql
-- 查看查询执行计划(确保使用了索引)
EXPLAIN SELECT ...
-- 预期结果:
-- - type: ref 或 range非 ALL 全表扫描)✅
-- - key: 使用了创建的索引 ✅
-- - rows: 扫描行数 < 1000 ✅
```
### 数据一致性验证
**统计数据验证**
```sql
-- 手动验证统计数据准确性
-- 1. 绑定用户数
SELECT COUNT(*) FROM referral_bindings
WHERE referrer_id = 'xxx' AND status = 'active' AND expiry_date > NOW();
-- 2. 付款人数
SELECT COUNT(DISTINCT o.user_id)
FROM orders o
JOIN referral_bindings rb ON o.user_id = rb.referee_id
WHERE rb.referrer_id = 'xxx' AND o.status = 'paid';
-- 3. 待审核提现
SELECT SUM(amount) FROM withdrawals
WHERE user_id = 'xxx' AND status = 'pending';
-- 与API返回结果对比确保一致 ✅
```
## 七、监控和回滚
### 监控指标
**关键指标:**
1. **响应时间**/api/referral/data 平均响应时间 < 300ms
2. **错误率**接口错误率 < 0.1%
3. **数据库负载**CPU使用率慢查询数量
**监控工具:**
- PM2 日志监控
- MySQL 慢查询日志
- 浏览器开发者工具
### 回滚方案
**如果出现问题,可以快速回滚:**
#### 方案1代码回滚
```bash
# 1. 切换到优化前的Git提交
git checkout <commit-before-optimization>
# 2. 重新构建和部署
npm run build
pm2 restart mycontent-next
# 3. 验证功能正常
```
#### 方案2数据库回滚
```bash
# 1. 删除新创建的索引(如果索引导致问题)
DROP INDEX idx_referrer_status_expiry ON referral_bindings;
DROP INDEX idx_referrer_purchase ON referral_bindings;
DROP INDEX idx_referrer_last_purchase ON referral_bindings;
DROP INDEX idx_referee_referrer ON referral_bindings;
DROP INDEX idx_user_status_paytime ON orders;
DROP INDEX idx_referrer_status ON orders;
DROP INDEX idx_status_paytime ON orders;
DROP INDEX idx_user_status ON withdrawals;
DROP INDEX idx_status_created ON withdrawals;
DROP INDEX idx_referral_code ON users;
# 2. 代码也回滚到优化前版本
```
## 八、预期收益
### 用户体验提升
- **加载速度更快** - 页面打开时间减少60-70%
- **更流畅的交互** - 数据刷新更快无明显延迟
- **更好的移动体验** - 减少数据传输节省流量
### 服务器性能提升
- **数据库负载降低** - 查询次数减少44%负载降低50%
- **更高的并发能力** - 可支持更多同时在线用户
- **成本优化** - 数据库资源消耗减少可降低服务器配置需求
### 开发维护提升
- **更清晰的代码结构** - 聚合查询更易理解和维护
- **更好的可扩展性** - 为未来功能扩展打下基础
- **完善的文档** - 详细的优化记录和测试验证
## 九、后续优化建议
### Phase 2: 进阶优化(可选)
1. **配置缓存** ⭐⭐
- 使用内存缓存配置数据5分钟过期
- 减少配置查询次数
2. **Redis缓存**
- 缓存用户分销数据1分钟过期
- 适用于高并发场景
- 需要额外的Redis服务
3. **CDN缓存**
- 缓存小程序码图片
- 减少重复生成
- 需要CDN服务支持
4. **懒加载优化**
- 初始只加载核心统计数据
- 用户切换Tab时按需加载列表
- 进一步减少初始加载时间
### Phase 3: 长期规划
1. **分库分表** - 当数据量达到百万级时考虑
2. **读写分离** - 使用MySQL主从复制读请求分流
3. **异步统计** - 使用消息队列异步更新统计数据
4. **GraphQL API** - 支持客户端按需查询字段
## 十、总结
### 优化成果
**查询优化**9个查询 5个查询减少44%
**响应时间**500-800ms 200-300ms减少60-70%
**数据传输**~50KB ~22KB减少55%
**数据库负载**降低50%
**用户体验**显著提升
### 关键要点
1. **合并统计查询** - 最重要的优化减少80%的统计查询次数
2. **添加数据库索引** - 确保查询效率避免全表扫描
3. **减少数据传输** - 列表数据量减少55%加快传输速度
4. **向后兼容** - API格式完全兼容前端无需修改
5. **完善的测试** - 功能测试性能测试数据一致性验证
### 实施建议
- ⭐⭐⭐ **立即实施** Phase 1 优化本次已完成
- ⭐⭐ 根据实际需求考虑 Phase 2 进阶优化
- 长期规划 Phase 3为未来扩展做准备
---
**优化完成时间**2026-02-04
**优化负责人**AI Assistant
**文档版本**v1.0
**下次复查**2026-03-041个月后评估效果

View File

@@ -1,361 +0,0 @@
# 分销中心接口优化方案
## 一、当前接口分析
### 1. 接口调用情况
**分销中心页面 (`miniprogram/pages/referral/referral.js`)**
| 接口 | 调用时机 | 功能 | 优化空间 |
|------|---------|------|---------|
| `/api/referral/data` | 页面初始化 | 获取所有分销数据 | ⚠️ 需优化查询数量 |
| `/api/miniprogram/qrcode` | 用户点击分享 | 生成小程序码 | ✅ 合理 |
| `/api/withdraw` | 用户申请提现 | 提现接口 | ✅ 合理 |
### 2. 核心接口 `/api/referral/data` 的查询情况
**当前执行的数据库查询:**
| 序号 | 查询目的 | 表 | 数据量 | 优化建议 |
|------|---------|-----|--------|---------|
| 1 | 获取用户基本信息 | `users` | 1行 | 🔄 可合并 |
| 2 | 获取绑定关系统计 | `referral_bindings` | 1行聚合 | 🔄 可合并 |
| 3 | 获取访问量统计 | `referral_visits` | 1行聚合 | 🔄 可合并 |
| 4 | 获取付款统计 | `orders` + `referral_bindings` | 1行聚合 | 🔄 可合并 |
| 5 | 获取待审核提现金额 | `withdrawals` | 1行聚合 | 🔄 可合并 |
| 6 | 获取活跃用户列表 | `referral_bindings` + `users` | ≤50行 | ✅ 保持独立 |
| 7 | 获取已转化用户列表 | `referral_bindings` + `users` | ≤50行 | ✅ 保持独立 |
| 8 | 获取已过期用户列表 | `referral_bindings` + `users` | ≤50行 | ✅ 保持独立 |
| 9 | 获取收益明细 | `orders` + `users` + `referral_bindings` | ≤30行 | ✅ 保持独立 |
**总计9个查询**
### 3. 性能瓶颈分析
#### 问题点:
1. **查询次数过多** - 9个独立查询多次往返数据库
2. **重复JOIN** - 多次JOIN相同的表`referral_bindings`, `users`
3. **无缓存机制** - 配置数据每次都重新查询
#### 影响:
- **响应时间慢** - 9个查询串行执行总耗时约 500-800ms
- **数据库负载高** - 高并发时增加数据库压力
- **小程序加载慢** - 用户体验差需要loading提示
## 二、优化方案设计
### 优化策略
#### 1. **查询合并** - 减少数据库往返次数
**原方案5个独立的单行统计查询**
```
1. SELECT users → 用户信息
2. SELECT referral_bindings → 绑定统计
3. SELECT referral_visits → 访问统计
4. SELECT orders → 付款统计
5. SELECT withdrawals → 提现统计
```
**优化方案1个聚合查询**
```sql
SELECT
-- 用户信息
u.id, u.nickname, u.referral_code, u.earnings,
u.pending_earnings, u.withdrawn_earnings, u.referral_count,
-- 绑定统计(子查询)
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id) as total_bindings,
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id AND status = 'active' AND expiry_date > NOW()) as active_bindings,
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id AND status = 'active' AND purchase_count > 0) as converted_bindings,
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id AND (status = 'expired' OR expiry_date <= NOW())) as expired_bindings,
-- 访问统计(子查询)
(SELECT COUNT(DISTINCT visitor_id) FROM referral_visits WHERE referrer_id = u.id) as total_visits,
-- 付款统计(子查询)
(SELECT COUNT(DISTINCT o.user_id) FROM orders o JOIN referral_bindings rb ON o.user_id = rb.referee_id WHERE rb.referrer_id = u.id AND o.status = 'paid') as paid_count,
(SELECT COALESCE(SUM(o.amount), 0) FROM orders o JOIN referral_bindings rb ON o.user_id = rb.referee_id WHERE rb.referrer_id = u.id AND o.status = 'paid') as total_amount,
-- 待审核提现(子查询)
(SELECT COALESCE(SUM(amount), 0) FROM withdrawals WHERE user_id = u.id AND status = 'pending') as pending_withdraw_amount
FROM users u
WHERE u.id = ?
```
**优势:**
- ✅ 从5个查询减少到1个查询
- ✅ 减少数据库往返次数从5次减少到1次
- ✅ 响应时间减少约 60-70%
#### 2. **列表查询优化** - 合理使用索引
**当前查询4个独立列表查询**
- 活跃用户列表50条
- 已转化用户列表50条
- 已过期用户列表50条
- 收益明细30条
**优化方案:**
- ✅ 保持独立查询(列表数据无法合并)
- ✅ 确保所有JOIN字段有索引
- ✅ 限制返回字段,减少数据传输量
- ✅ 添加查询超时控制
**需要的索引:**
```sql
-- referral_bindings 表
CREATE INDEX idx_referrer_status_expiry ON referral_bindings(referrer_id, status, expiry_date);
CREATE INDEX idx_referrer_purchase ON referral_bindings(referrer_id, purchase_count);
-- orders 表
CREATE INDEX idx_user_status_paytime ON orders(user_id, status, pay_time);
CREATE INDEX idx_referrer_status ON orders(referrer_id, status);
-- withdrawals 表
CREATE INDEX idx_user_status ON withdrawals(user_id, status);
```
#### 3. **配置缓存** - 减少重复查询
**原方案:**
```javascript
// 每次请求都查询配置
const config = await getConfig('referral_config')
```
**优化方案:**
```javascript
// 使用内存缓存5分钟过期
const configCache = new Map()
async function getCachedConfig(key) {
const cached = configCache.get(key)
if (cached && Date.now() - cached.time < 5 * 60 * 1000) {
return cached.data
}
const data = await getConfig(key)
configCache.set(key, { data, time: Date.now() })
return data
}
```
#### 4. **分页加载** - 减少初始数据量
**原方案:**
- 一次性加载所有列表数据活跃50条 + 转化50条 + 过期50条 + 明细30条 = 180条
**优化方案:**
- 初始只加载核心统计数据 + 活跃用户列表20条
- 其他列表数据按需加载用户切换Tab时加载
```javascript
// 方案1单接口 + type参数
GET /api/referral/data?userId=xxx&type=overview // 只返回统计数据
GET /api/referral/data?userId=xxx&type=active // 活跃用户列表
GET /api/referral/data?userId=xxx&type=converted // 已转化列表
GET /api/referral/data?userId=xxx&type=expired // 已过期列表
GET /api/referral/data?userId=xxx&type=earnings // 收益明细
// 方案2保持当前一次性加载推荐
// 理由:分销中心是高频页面,用户通常会查看所有数据
// 优化查询性能比拆分接口更有效
```
### 优化后的架构
```
┌─────────────────────────────────────────┐
│ 小程序 - 分销中心页面 │
└─────────────────┬───────────────────────┘
│ 1次请求
┌─────────────────────────────────────────┐
│ /api/referral/data (优化后) │
├─────────────────────────────────────────┤
│ 1. 聚合统计查询1个查询
│ - 用户信息 + 绑定统计 + 付款统计 │
│ - 访问统计 + 提现统计 │
│ 2. 活跃用户列表1个查询20条
│ 3. 已转化用户列表1个查询20条
│ 4. 已过期用户列表1个查询20条
│ 5. 收益明细1个查询20条
├─────────────────────────────────────────┤
│ 总计5个查询vs 原9个查询
│ 预计响应时间200-300msvs 原500-800ms
└─────────────────────────────────────────┘
```
## 三、实施计划
### Phase 1: 核心优化(立即实施)
1. **合并统计查询** ⭐⭐⭐
- 将5个独立统计查询合并为1个聚合查询
- 预计减少响应时间 60-70%
- 影响范围:`app/api/referral/data/route.ts`
2. **添加数据库索引** ⭐⭐⭐
- 为高频查询字段添加复合索引
- 提升查询效率 30-50%
- 影响范围:数据库 schema
3. **减少列表数据量** ⭐⭐
- 将列表限制从50条减少到20条
- 减少数据传输量 40%
- 影响范围:查询 LIMIT 参数
### Phase 2: 进阶优化(可选)
4. **配置缓存** ⭐⭐
- 使用内存缓存配置数据
- 减少配置查询次数
- 影响范围:`lib/db.ts`
5. **懒加载列表**
- 按需加载不同Tab的数据
- 进一步减少初始加载时间
- 影响范围:前端 + 后端接口
### Phase 3: 长期优化(未来规划)
6. **Redis缓存**
- 缓存用户分销数据1分钟过期
- 适用于高并发场景
- 需要额外的Redis服务
7. **CDN缓存**
- 缓存小程序码图片
- 减少生成次数
- 需要CDN服务支持
## 四、性能对比
### 优化前 vs 优化后
| 指标 | 优化前 | 优化后 | 提升 |
|------|--------|--------|------|
| **数据库查询次数** | 9个 | 5个 | ↓ 44% |
| **聚合查询次数** | 5个 | 1个 | ↓ 80% |
| **列表查询次数** | 4个 | 4个 | - |
| **返回数据量** | ~180条 | ~80条 | ↓ 55% |
| **预计响应时间** | 500-800ms | 200-300ms | ↓ 60-70% |
| **数据库负载** | 高 | 中 | ↓ 50% |
### 优化效果预估
**用户体验提升:**
- ✅ 页面加载速度提升 60-70%
- ✅ Loading时间更短体验更流畅
- ✅ 数据刷新更快
**服务器性能提升:**
- ✅ 数据库查询减少 44%
- ✅ 数据库连接时间减少 60%
- ✅ 可支持更高并发
**成本优化:**
- ✅ 数据库资源消耗减少 50%
- ✅ 带宽消耗减少 55%
## 五、向后兼容
### API响应格式
优化后的 `/api/referral/data` 接口响应格式**完全兼容**原有格式,前端无需修改。
```json
{
"success": true,
"data": {
// 核心统计(不变)
"bindingCount": 10,
"visitCount": 50,
"paidCount": 5,
"expiredCount": 2,
// 收益数据(不变)
"totalCommission": 450.00,
"availableEarnings": 300.00,
"pendingWithdrawAmount": 100.00,
"withdrawnEarnings": 50.00,
// 列表数据(数量减少,格式不变)
"activeUsers": [...], // 50条 → 20条
"convertedUsers": [...], // 50条 → 20条
"expiredUsers": [...], // 50条 → 20条
"earningsDetails": [...] // 30条 → 20条
}
}
```
## 六、风险评估
| 风险 | 影响 | 概率 | 缓解措施 |
|------|------|------|---------|
| 聚合查询性能不如预期 | 中 | 低 | 回滚到原查询,添加更多索引 |
| 复杂子查询导致慢查询 | 高 | 低 | 使用 EXPLAIN 分析,优化查询计划 |
| 缓存数据不一致 | 中 | 中 | 设置短期过期时间5分钟 |
| 列表数据不够用 | 低 | 中 | 支持分页参数,按需加载更多 |
## 七、测试验证
### 性能测试
```bash
# 测试工具Apache Bench
ab -n 100 -c 10 "http://localhost:3000/api/referral/data?userId=xxx"
# 预期结果:
# - 平均响应时间 < 300ms
# - 95%请求 < 400ms
# - 无失败请求
```
### 数据正确性测试
```sql
-- 验证聚合查询结果与独立查询一致
-- 1. 绑定统计
SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = 'xxx';
-- 2. 付款统计
SELECT COUNT(DISTINCT o.user_id)
FROM orders o
JOIN referral_bindings rb ON o.user_id = rb.referee_id
WHERE rb.referrer_id = 'xxx' AND o.status = 'paid';
-- 3. 提现统计
SELECT SUM(amount) FROM withdrawals WHERE user_id = 'xxx' AND status = 'pending';
```
## 八、总结
### 核心优化点
1. ⭐⭐⭐ **合并统计查询** - 从5个查询减少到1个性能提升最大
2. ⭐⭐⭐ **添加数据库索引** - 确保查询效率,避免全表扫描
3. ⭐⭐ **减少返回数据量** - 减少网络传输,加快响应速度
4.**配置缓存** - 减少重复查询,降低数据库负载
### 实施建议
**推荐方案:立即实施 Phase 1**
- 合并统计查询
- 添加数据库索引
- 减少列表数据量
**预计收益:**
- 响应时间减少 60-70%
- 数据库负载减少 50%
- 用户体验显著提升
**实施时间:**
- 开发时间2-3小时
- 测试时间1小时
- 部署时间30分钟
- **总计:约半天**
优化后,分销中心将成为一个高性能、低延迟的功能模块,为用户提供流畅的体验!🚀

View File

@@ -1,324 +0,0 @@
# 分销中心数据库连接错误修复
## 问题描述
### 错误现象
调用分销数据API时出现数据库连接错误
```
GET /api/referral/data?userId=ogpTW5fmXRGNpoUbXB3UEqnVe5Tg
Response: {
success: false,
error: "获取分销数据失败: Connection lost: The server closed the connection."
}
```
### 问题原因
1. **子查询过多**初始优化时将5个查询合并为1个但包含了10+个子查询,导致:
- 查询执行时间过长
- 数据库连接超时
- 服务器主动关闭连接
2. **可能不存在的表**
- `referral_visits` 表可能不存在(访问统计功能未启用)
- 在主查询中直接查询会导致整个SQL失败
3. **缺少错误处理**
- 查询失败时没有捕获错误
- 无法定位具体失败的子查询
## 解决方案
### 1. 简化主查询
将主查询中的子查询数量从10+个减少到6个
**保留的子查询(核心统计)**
```sql
-- 绑定关系统计4个子查询
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id)
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id AND status = 'active' AND expiry_date > NOW())
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id AND status = 'active' AND purchase_count > 0)
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id AND (status IN ('expired', 'cancelled') OR (status = 'active' AND expiry_date <= NOW())))
-- 付款统计2个子查询直接从orders表查询
(SELECT COUNT(DISTINCT user_id) FROM orders WHERE referrer_id = u.id AND status = 'paid')
(SELECT COALESCE(SUM(amount), 0) FROM orders WHERE referrer_id = u.id AND status = 'paid')
```
**移除的复杂子查询**
```sql
-- ❌ 移除复杂的JOIN子查询付款统计
(SELECT COUNT(DISTINCT o.user_id)
FROM orders o
JOIN referral_bindings rb ON o.user_id = rb.referee_id
WHERE rb.referrer_id = u.id AND o.status = 'paid')
-- ❌ 移除:访问统计(改为独立查询)
(SELECT COUNT(DISTINCT visitor_id) FROM referral_visits WHERE referrer_id = u.id)
-- ❌ 移除:待审核提现金额(改为独立查询)
(SELECT COALESCE(SUM(amount), 0) FROM withdrawals WHERE user_id = u.id AND status = 'pending')
```
### 2. 独立查询 + 错误处理
将可能失败的查询改为独立查询,并添加错误处理:
```typescript
// 访问统计(可能表不存在)
let totalVisits = bindingStats.total
try {
const visits = await query(`
SELECT COUNT(DISTINCT visitor_id) as count
FROM referral_visits
WHERE referrer_id = ?
`, [userId]) as any[]
totalVisits = parseInt(visits[0]?.count) || bindingStats.total
} catch (e) {
// referral_visits 表可能不存在,使用绑定数作为访问数
console.log('[ReferralData] 访问统计表不存在,使用绑定数')
}
// 待审核提现金额
let pendingWithdrawAmount = 0
try {
const withdraws = await query(`
SELECT COALESCE(SUM(amount), 0) as pending_amount
FROM withdrawals
WHERE user_id = ? AND status = 'pending'
`, [userId]) as any[]
pendingWithdrawAmount = parseFloat(withdraws[0]?.pending_amount) || 0
} catch (e) {
console.log('[ReferralData] 提现表查询失败:', e)
}
```
### 3. 添加主查询错误处理
对主查询添加 try-catch 并返回详细错误信息:
```typescript
let statsResult: any[]
try {
statsResult = await query(`
SELECT
-- 用户基本信息
u.id, u.nickname, u.referral_code, u.earnings, u.pending_earnings,
u.withdrawn_earnings, u.referral_count,
-- 绑定关系统计
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id) as total_bindings,
-- ... 其他子查询
FROM users u
WHERE u.id = ?
`, [userId]) as any[]
} catch (err) {
console.error('[ReferralData] 统计查询失败:', err)
return NextResponse.json({
success: false,
error: '查询统计数据失败: ' + (err as Error).message
}, { status: 500 })
}
```
## 实施步骤
### 1. 修改后端代码
文件:`app/api/referral/data/route.ts`
```diff
- // ⚡ 优化:合并统计查询 - 将5个查询合并为1个减少数据库往返
- const statsResult = await query(`
+ // ⚡ 优化:合并统计查询 - 添加错误处理
+ let statsResult: any[]
+ try {
+ statsResult = await query(`
SELECT
-- 用户基本信息
u.id, u.nickname, u.referral_code, u.earnings, u.pending_earnings,
u.withdrawn_earnings, u.referral_count,
-- 绑定关系统计
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id) as total_bindings,
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id AND status = 'active' AND expiry_date > NOW()) as active_bindings,
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id AND status = 'active' AND purchase_count > 0) as converted_bindings,
(SELECT COUNT(*) FROM referral_bindings WHERE referrer_id = u.id AND (status IN ('expired', 'cancelled') OR (status = 'active' AND expiry_date <= NOW()))) as expired_bindings,
- -- 访问统计如果表不存在会返回NULL
- (SELECT COUNT(DISTINCT visitor_id) FROM referral_visits WHERE referrer_id = u.id) as total_visits,
-
- -- 付款统计
- (SELECT COUNT(DISTINCT o.user_id)
- FROM orders o
- JOIN referral_bindings rb ON o.user_id = rb.referee_id
- WHERE rb.referrer_id = u.id AND o.status = 'paid') as paid_count,
- (SELECT COALESCE(SUM(o.amount), 0)
- FROM orders o
- JOIN referral_bindings rb ON o.user_id = rb.referee_id
- WHERE rb.referrer_id = u.id AND o.status = 'paid') as total_amount,
-
- -- 待审核提现金额
- (SELECT COALESCE(SUM(amount), 0) FROM withdrawals WHERE user_id = u.id AND status = 'pending') as pending_withdraw_amount,
-
- -- 累计佣金总额(直接从订单表计算)
- (SELECT COALESCE(SUM(amount), 0) FROM orders WHERE referrer_id = u.id AND status = 'paid') as total_referral_amount
+ -- 付款统计直接从orders表查询
+ (SELECT COUNT(DISTINCT user_id) FROM orders WHERE referrer_id = u.id AND status = 'paid') as paid_count,
+ (SELECT COALESCE(SUM(amount), 0) FROM orders WHERE referrer_id = u.id AND status = 'paid') as total_referral_amount
FROM users u
WHERE u.id = ?
- `, [userId]) as any[]
+ `, [userId]) as any[]
+ } catch (err) {
+ console.error('[ReferralData] 统计查询失败:', err)
+ return NextResponse.json({
+ success: false,
+ error: '查询统计数据失败: ' + (err as Error).message
+ }, { status: 500 })
+ }
```
### 2. 添加独立查询
```typescript
const paymentStats = {
paidCount: parseInt(stats.paid_count) || 0,
totalAmount: parseFloat(stats.total_referral_amount) || 0
}
// 获取访问统计(独立查询,带错误处理)
let totalVisits = bindingStats.total
try {
const visits = await query(`
SELECT COUNT(DISTINCT visitor_id) as count
FROM referral_visits
WHERE referrer_id = ?
`, [userId]) as any[]
totalVisits = parseInt(visits[0]?.count) || bindingStats.total
} catch (e) {
console.log('[ReferralData] 访问统计表不存在,使用绑定数')
}
// 获取待审核提现金额(独立查询,带错误处理)
let pendingWithdrawAmount = 0
try {
const withdraws = await query(`
SELECT COALESCE(SUM(amount), 0) as pending_amount
FROM withdrawals
WHERE user_id = ? AND status = 'pending'
`, [userId]) as any[]
pendingWithdrawAmount = parseFloat(withdraws[0]?.pending_amount) || 0
} catch (e) {
console.log('[ReferralData] 提现表查询失败:', e)
}
```
### 3. 测试验证
```bash
# 1. 测试API
curl "http://localhost:3006/api/referral/data?userId=ogpTW5fmXRGNpoUbXB3UEqnVe5Tg"
# 2. 检查返回数据
{
"success": true,
"data": {
"stats": {
"totalVisits": 10, // 访问数
"totalBindings": 10, // 绑定数
"activeBindings": 8, // 活跃绑定
"convertedBindings": 5, // 已转化
"expiredBindings": 2, // 已过期
"paidUsers": 5, // 付款人数
"totalCommission": 450.0, // 累计佣金
"availableEarnings": 400.0,
"pendingWithdrawAmount": 50.0
}
}
}
```
## 性能对比
| 指标 | 修复前 | 修复后 | 改进 |
|------|--------|--------|------|
| 主查询子查询数 | 10+ | 6 | ↓40% |
| 数据库往返次数 | 1 | 3 | ↑2次但避免超时 |
| 错误处理 | ❌ 无 | ✅ 完整 | 新增 |
| 查询成功率 | ❌ 失败 | ✅ 成功 | 从0%到100% |
## 最佳实践总结
### 1. 子查询数量控制
- ✅ 单个SQL中子查询数量控制在10个以内
- ✅ 复杂JOIN子查询应拆分为独立查询
- ✅ 优先使用简单的COUNT/SUM子查询
### 2. 错误处理策略
- ✅ 核心统计查询必须添加 try-catch
- ✅ 可选功能(如访问统计)独立查询 + 容错
- ✅ 返回详细错误信息用于调试
### 3. 查询优化原则
- ✅ 直接查询优于复杂JOIN如从orders表直接查询付款统计
- ✅ 将可能失败的查询隔离
- ✅ 为可选功能提供降级方案(如访问数降级为绑定数)
### 4. 数据库连接管理
- ✅ 避免长时间占用连接
- ✅ 查询超时时正确释放资源
- ✅ 考虑连接池配置
## 后续优化建议
### 1. 短期优化
- [ ] 为常用查询添加数据库索引
- [ ] 考虑使用缓存减少数据库压力
- [ ] 监控慢查询并优化
### 2. 长期优化
- [ ] 实现数据预聚合(定时任务)
- [ ] 考虑使用Redis缓存统计数据
- [ ] 实现增量更新机制
## 部署说明
### 1. 本地测试
```bash
# 启动开发服务器
npm run dev
# 测试API
curl "http://localhost:3006/api/referral/data?userId=YOUR_USER_ID"
```
### 2. 生产部署
```bash
# 构建项目
npm run build
# 重启PM2
python devlop.py restart mycontent
```
### 3. 监控
```bash
# 查看PM2日志
pm2 logs mycontent
# 关注以下日志:
# [ReferralData] 统计查询失败: ...
# [ReferralData] 访问统计表不存在,使用绑定数
# [ReferralData] 提现表查询失败: ...
```
## 总结
这次修复通过简化主查询、隔离可选功能、添加错误处理成功解决了数据库连接超时问题。同时保持了API性能优化的核心目标减少数据库往返次数并为未来扩展提供了更好的容错机制。
**关键收获**
1. 性能优化不能一味追求"合并所有查询"
2. 需要在性能和可靠性之间找到平衡
3. 完善的错误处理是生产环境的必备条件
4. 为可选功能提供降级方案非常重要

View File

@@ -1,441 +0,0 @@
# 分销中心用户列表数据对接说明
## 📋 功能说明
小程序分销中心的"绑定用户"列表包含三个Tab
1. **绑定中** - 当前活跃的绑定关系
2. **已付款** - 购买过商品的用户
3. **已过期** - 过期或取消的绑定
---
## 🔧 本次修改
### 1. 后端API优化/api/referral/data
**文件**: `app/api/referral/data/route.ts`
**修改内容**:
-`convertedUsers` 中添加 `purchaseCount`(购买次数)字段
```typescript
convertedUsers: convertedBindings.map((b: any) => ({
id: b.referee_id,
nickname: b.nickname,
avatar: b.avatar,
commission: parseFloat(b.commission_amount) || 0,
orderAmount: parseFloat(b.order_amount) || 0,
purchaseCount: parseInt(b.purchase_count) || 0, // 新增
conversionDate: b.conversion_date,
status: 'converted'
}))
```
---
### 2. 小程序前端优化
#### 2.1 数据格式化referral.js
**文件**: `miniprogram/pages/referral/referral.js`
**修改内容**:
```javascript
// formatUser 函数增强
const formatUser = (user, type) => {
return {
id: user.id,
nickname: user.nickname,
avatar: user.avatar,
status: type,
daysRemaining: user.daysRemaining || 0,
bindingDate: this.formatDate(user.bindingDate),
expiryDate: this.formatDate(user.expiryDate), // 新增:过期时间
commission: (user.commission || 0).toFixed(2),
orderAmount: (user.orderAmount || 0).toFixed(2),
purchaseCount: user.purchaseCount || 0, // 新增:购买次数
conversionDate: this.formatDate(user.conversionDate) // 新增:转化时间
}
}
```
---
#### 2.2 UI显示优化referral.wxml
**文件**: `miniprogram/pages/referral/referral.wxml`
**旧代码**:
```xml
<view class="user-status">
<block wx:if="{{item.status === 'converted'}}">
<text class="status-amount">+¥{{item.commission}}</text>
<text class="status-order">订单 ¥{{item.orderAmount}}</text>
</block>
<block wx:else>
<text class="status-tag">
{{item.status === 'expired' ? '已过期' : item.daysRemaining + '天'}}
</text>
</block>
</view>
```
**新代码**:
```xml
<view class="user-status">
<!-- 已付款:显示佣金 + 购买次数 -->
<block wx:if="{{item.status === 'converted'}}">
<text class="status-amount">+¥{{item.commission}}</text>
<text class="status-order">已购{{item.purchaseCount || 1}}次</text>
</block>
<!-- 已过期:显示过期标签 + 过期时间 -->
<block wx:elif="{{item.status === 'expired'}}">
<text class="status-tag tag-gray">已过期</text>
<text class="status-time">{{item.expiryDate}}</text>
</block>
<!-- 绑定中:显示剩余天数 -->
<block wx:else>
<text class="status-tag {{item.daysRemaining <= 3 ? 'tag-red' : item.daysRemaining <= 7 ? 'tag-orange' : 'tag-green'}}">
{{item.daysRemaining}}天
</text>
</block>
</view>
```
---
## 📊 数据流向
```
后端 /api/referral/data
返回三类用户数据
├─ activeUsers绑定中
│ - daysRemaining剩余天数
│ - bindingDate绑定时间
├─ convertedUsers已付款
│ - commission佣金
│ - purchaseCount购买次数✨ 新增
│ - conversionDate转化时间
└─ expiredUsers已过期
- expiryDate过期时间
- bindingDate绑定时间
小程序接收并格式化
分Tab显示
```
---
## 🎨 显示效果
### Tab 1: 绑定中
```
┌─────────────────────────────┐
│ [头像] 张三 │
│ 绑定于 02-01 │
│ [15天]│
└─────────────────────────────┘
```
**显示内容**:
- 用户昵称
- 绑定时间
- 剩余天数(颜色标识:绿色>7天橙色3-7天红色≤3天
---
### Tab 2: 已付款
```
┌─────────────────────────────┐
│ [✓] 李四 │
│ 绑定于 01-20 │
│ +¥0.90 已购1次│
└─────────────────────────────┘
┌─────────────────────────────┐
│ [✓] 王五 │
│ 绑定于 01-15 │
│ +¥2.70 已购3次│
└─────────────────────────────┘
```
**显示内容**:
- 用户昵称(头像显示✓)
- 绑定时间
- 累计佣金
- **购买次数**(✨ 新增)
**数据来源**:
```javascript
{
commission: 0.90, // 累计佣金
purchaseCount: 1, // 购买次数 ✨
conversionDate: "2026-01-20"
}
```
---
### Tab 3: 已过期
```
┌─────────────────────────────┐
│ [⏰] 赵六 │
│ 绑定于 01-05 │
│ [已过期] 02-04│
└─────────────────────────────┘
```
**显示内容**:
- 用户昵称(头像显示⏰)
- 绑定时间
- 已过期标签
- **过期时间**(✨ 优化显示)
**数据来源**:
```javascript
{
bindingDate: "2026-01-05",
expiryDate: "2026-02-04" // 显示具体过期日期
}
```
---
## 🔍 数据验证
### 测试场景1: 已付款用户(单次购买)
```json
{
"nickname": "张三",
"commission": 0.90,
"purchaseCount": 1,
"conversionDate": "2026-02-01"
}
```
**显示**: `+¥0.90 已购1次`
---
### 测试场景2: 已付款用户(多次购买)
```json
{
"nickname": "李四",
"commission": 2.70,
"purchaseCount": 3,
"conversionDate": "2026-01-20"
}
```
**显示**: `+¥2.70 已购3次`
---
### 测试场景3: 已过期用户
```json
{
"nickname": "王五",
"bindingDate": "2026-01-05",
"expiryDate": "2026-02-04"
}
```
**显示**:
- 标签:`已过期`
- 时间:`02-04`
---
## 🎯 优化亮点
### 1. 已付款用户
**旧显示**:
```
+¥0.90
订单 ¥1.00
```
- ❌ 显示订单金额(用户可能多次购买,只显示一个金额不准确)
**新显示**:
```
+¥0.90
已购1次
```
- ✅ 显示购买次数(更直观)
- ✅ 支持多次购买已购3次
---
### 2. 已过期用户
**旧显示**:
```
[已过期]
```
- ❌ 只有标签,不知道什么时候过期
**新显示**:
```
[已过期] 02-04
```
- ✅ 显示具体过期时间
- ✅ 用户可以看到过期日期
---
## 📊 后端数据说明
### convertedBindings 查询
```sql
SELECT
rb.referee_id,
rb.purchase_count, -- 购买次数
rb.total_commission, -- 累计佣金
rb.last_purchase_date, -- 最后购买时间
u.nickname, u.avatar
FROM referral_bindings rb
JOIN users u ON rb.referee_id = u.id
WHERE rb.referrer_id = ?
AND rb.status = 'active'
AND rb.purchase_count > 0
ORDER BY rb.last_purchase_date DESC
```
**关键字段**:
- `purchase_count` - 购买次数(每次购买 +1
- `total_commission` - 累计佣金(每次购买累加)
- `last_purchase_date` - 最后购买时间(用于排序)
---
### expiredBindings 查询
```sql
SELECT
rb.referee_id,
rb.binding_date, -- 绑定时间
rb.expiry_date, -- 过期时间
u.nickname, u.avatar
FROM referral_bindings rb
JOIN users u ON rb.referee_id = u.id
WHERE rb.referrer_id = ?
AND (rb.status = 'expired' OR rb.status = 'cancelled')
ORDER BY rb.expiry_date DESC
```
**关键字段**:
- `binding_date` - 绑定时间
- `expiry_date` - 过期时间(显示在前端)
- `status` - expired自然过期或 cancelled被切换
---
## 🚀 部署步骤
### 1. 后端部署
```bash
pnpm build
python devlop.py
pm2 restart soul
```
### 2. 小程序上传
- 在微信开发者工具上传代码
- 提交审核
- 发布新版本
---
## ✅ 测试清单
### 已付款用户
- [ ] 显示累计佣金
- [ ] 显示购买次数
- [ ] 单次购买显示"已购1次"
- [ ] 多次购买显示"已购N次"
- [ ] 头像显示✓标记
### 已过期用户
- [ ] 显示"已过期"标签
- [ ] 显示过期时间
- [ ] 头像显示⏰标记
- [ ] 时间格式正确MM-DD
### 绑定中用户
- [ ] 显示剩余天数
- [ ] 颜色标识正确(绿/橙/红)
- [ ] 头像显示首字母
---
## 🎨 样式优化(可选)
如果需要调整样式,在 `referral.wxss` 中添加:
```css
/* 已过期时间显示 */
.status-time {
font-size: 22rpx;
color: #999;
margin-top: 4rpx;
}
/* 灰色标签(已过期) */
.tag-gray {
background: #f5f5f5;
color: #999;
}
```
---
## 📝 API 返回数据示例
### 完整响应
```json
{
"success": true,
"data": {
"activeUsers": [
{
"id": "user_123",
"nickname": "张三",
"avatar": "https://...",
"daysRemaining": 15,
"bindingDate": "2026-01-20T10:00:00.000Z",
"status": "active"
}
],
"convertedUsers": [
{
"id": "user_456",
"nickname": "李四",
"avatar": "https://...",
"commission": 0.90,
"orderAmount": 1.00,
"purchaseCount": 1,
"conversionDate": "2026-02-01T14:30:00.000Z",
"status": "converted"
}
],
"expiredUsers": [
{
"id": "user_789",
"nickname": "王五",
"avatar": "https://...",
"bindingDate": "2026-01-05T08:00:00.000Z",
"expiryDate": "2026-02-04T08:00:00.000Z",
"status": "expired"
}
]
}
}
```
---
**✅ 分销中心用户列表数据已完全对接!支持显示购买次数和过期时间。**

View File

@@ -1,343 +0,0 @@
# 分销中心设置功能说明
## 一、功能概述
分销中心右上角设置按钮提供两个功能:
1. **自动提现设置** - 配置自动提现功能
2. **收益通知设置** - 配置收益通知提醒
## 二、自动提现设置
### 功能说明
**目的**:方便用户自动提现,无需手动操作
**工作流程**
1. 用户设置自动提现阈值例如¥50
2. 开启自动提现
3. 当可提现金额 ≥ 阈值时,自动发起提现申请
### 使用方法
#### 1. 开启自动提现
```
点击设置 → 自动提现设置 → 开启
```
**弹窗内容**
- 当前状态:已关闭/已开启
- 自动提现阈值¥XX
- 说明文字
- 按钮:开启/关闭、修改阈值
#### 2. 设置阈值
```
点击设置 → 自动提现设置 → 修改阈值
```
**输入要求**
- 最低金额等于系统配置的最低提现金额默认¥10
- 必须是数字
- 小数点后最多2位
**示例**
- ✅ 10
- ✅ 50.5
- ✅ 100.00
- ❌ 5低于最低金额
- ❌ abc不是数字
#### 3. 自动提现触发
**触发时机**
- 用户开启自动提现后,立即检查当前金额
- 每次收到新佣金时检查
- 用户进入分销中心时检查
**触发流程**
```
检测到 可提现金额 ≥ 阈值
弹窗确认:是否立即提现?
用户确认 → 调用提现接口
```
### 数据存储
**存储位置**:微信本地存储(`wx.setStorageSync`
**存储格式**
```javascript
// 自动提现开关
`autoWithdraw_${userId}`: boolean
// 自动提现阈值
`autoWithdrawThreshold_${userId}`: number
```
**示例**
```javascript
// 用户ID为 user_123
autoWithdraw_user_123: true
autoWithdrawThreshold_user_123: 50
```
### 代码实现
#### 主要函数
```javascript
// 显示自动提现设置
showAutoWithdrawSettings()
// 切换自动提现开关
toggleAutoWithdraw(enabled, threshold)
// 设置自动提现阈值
setAutoWithdrawThreshold(currentEnabled, currentThreshold)
```
#### 调用流程
```javascript
点击设置按钮
showSettings() - 显示菜单
选择"自动提现设置"
showAutoWithdrawSettings() - 显示设置弹窗
用户选择
├─ 开启/关闭 toggleAutoWithdraw()
└─ 修改阈值 setAutoWithdrawThreshold()
```
## 三、收益通知设置
### 功能说明
**目的**:及时通知用户有新的收益入账
**通知时机**
- 有新用户付款,获得佣金时
- 有用户续费,获得佣金时
- 提现成功时
### 使用方法
#### 1. 开启通知
```
点击设置 → 收益通知设置 → 开启通知
```
**弹窗内容**
- 当前状态:已开启/已关闭
- 说明文字
- 按钮:开启通知/关闭通知
#### 2. 授权通知权限
开启通知后会自动请求微信订阅消息权限
**订阅消息模板**
```
模板ID需在微信公众平台配置
模板内容:
- 收益金额¥XX
- 收益来源用户XXX购买XXX
- 收益时间XXXX-XX-XX XX:XX
```
### 数据存储
**存储位置**:微信本地存储
**存储格式**
```javascript
// 收益通知开关默认true
`earningsNotify_${userId}`: boolean
```
### 代码实现
```javascript
// 显示收益通知设置
showNotificationSettings()
```
## 四、完整代码
### JS部分 (`miniprogram/pages/referral/referral.js`)
```javascript
// 显示设置
showSettings() {
wx.showActionSheet({
itemList: ['自动提现设置', '收益通知设置'],
success: (res) => {
if (res.tapIndex === 0) {
this.showAutoWithdrawSettings()
} else {
this.showNotificationSettings()
}
}
})
},
// 自动提现设置
async showAutoWithdrawSettings() {
const app = getApp()
const { userInfo } = app.globalData
if (!userInfo) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
// 获取当前设置
let autoWithdrawEnabled = wx.getStorageSync(`autoWithdraw_${userInfo.id}`) || false
let autoWithdrawThreshold = wx.getStorageSync(`autoWithdrawThreshold_${userInfo.id}`) || this.data.minWithdrawAmount || 10
wx.showModal({
title: '自动提现设置',
content: `当前状态:${autoWithdrawEnabled ? '已开启' : '已关闭'}\n自动提现阈值¥${autoWithdrawThreshold}\n\n开启后当可提现金额达到阈值时将自动发起提现申请。`,
confirmText: autoWithdrawEnabled ? '关闭' : '开启',
cancelText: '修改阈值',
success: (res) => {
if (res.confirm) {
this.toggleAutoWithdraw(!autoWithdrawEnabled, autoWithdrawThreshold)
} else if (res.cancel) {
this.setAutoWithdrawThreshold(autoWithdrawEnabled, autoWithdrawThreshold)
}
}
})
},
// ... 其他函数 ...
```
## 五、扩展功能(未来)
### 1. 服务器端自动提现
**当前**:本地存储,小程序内检测
**优化**
- 将设置保存到服务器
- 服务器定时任务检测
- 自动发起提现,无需用户确认
**实现方案**
```sql
-- 添加用户设置表
CREATE TABLE user_settings (
user_id VARCHAR(64) PRIMARY KEY,
auto_withdraw_enabled BOOLEAN DEFAULT FALSE,
auto_withdraw_threshold DECIMAL(10,2) DEFAULT 10.00,
earnings_notify_enabled BOOLEAN DEFAULT TRUE,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
```
```javascript
// 后端API
POST /api/user/settings
GET /api/user/settings
// 定时任务(每小时执行)
async function autoWithdrawTask() {
// 查询所有开启自动提现的用户
const users = await query(`
SELECT u.id, u.pending_earnings, s.auto_withdraw_threshold
FROM users u
JOIN user_settings s ON u.id = s.user_id
WHERE s.auto_withdraw_enabled = TRUE
AND u.pending_earnings >= s.auto_withdraw_threshold
`)
// 为每个用户发起提现
for (const user of users) {
await processWithdraw(user.id, user.pending_earnings)
}
}
```
### 2. 多种通知方式
**当前**:小程序订阅消息
**扩展**
- 公众号模板消息
- 短信通知
- 邮件通知
### 3. 更多设置项
- 提现到账通知
- 绑定用户提醒
- 即将过期提醒
- 每日收益汇总
## 六、注意事项
### 1. 自动提现限制
- ⚠️ 需要用户手动确认,不是完全自动
- ⚠️ 仍需满足最低提现金额要求
- ⚠️ 受提现次数限制(如有)
### 2. 通知权限
- 需要用户授权订阅消息
- 模板消息需要在微信公众平台配置
- 用户可以随时关闭通知
### 3. 数据安全
- 本地存储数据可能丢失(卸载小程序、清除缓存)
- 建议未来同步到服务器
## 七、测试用例
### 自动提现测试
| 场景 | 操作 | 预期结果 |
|------|------|---------|
| 开启自动提现 | 可提现金额 < 阈值 | 正常开启不弹窗 |
| 开启自动提现 | 可提现金额 阈值 | 弹窗询问是否提现 |
| 修改阈值 | 输入小于最低金额 | 提示错误 |
| 修改阈值 | 输入非数字 | 提示错误 |
| 修改阈值 | 输入合法金额 | 保存成功 |
### 通知设置测试
| 场景 | 操作 | 预期结果 |
|------|------|---------|
| 开启通知 | 首次开启 | 请求订阅消息权限 |
| 开启通知 | 已授权 | 直接开启 |
| 关闭通知 | 点击关闭 | 立即关闭不再提醒 |
## 八、用户体验优化建议
1. **引导用户使用**
- 首次进入分销中心时引导设置自动提现
- 可提现金额达到阈值时提示开启自动提现
2. **智能推荐阈值**
- 根据用户历史提现金额推荐合适的阈值
- 例如平均提现金额为¥80推荐阈值为¥50-¥100
3. **状态反馈**
- 在分销中心显示自动提现状态已开启/已关闭
- 显示下次自动提现预计金额
---
**创建时间**2026-02-04
**功能状态**:✅ 已实现
**适用版本**小程序 v1.0+

View File

@@ -1,417 +0,0 @@
# 删除 users.referred_by 字段说明
## 📋 背景
根据《绑定关系存储方案分析.md》的建议停用 `users.referred_by` 冗余字段,统一使用 `referral_bindings` 表管理推荐关系。
---
## ✅ 已完成的代码修改
### 1. 停止更新 users.referred_by
**修改文件**: `app/api/referral/bind/route.ts`
**修改内容**:
- 第149-152行注释掉 `UPDATE users SET referred_by = ?`
- 不再向该字段写入数据
---
### 2. 修改所有旧查询
#### 2.1 `/api/referral/bind` (GET 方法)
**修改前**:
```typescript
// 查询用户
SELECT id, referred_by FROM users WHERE id = ?
// 查询推荐人
if (user.referred_by) {
SELECT * FROM users WHERE id = user.referred_by
}
// 查询被推荐人列表
SELECT * FROM users WHERE referred_by = ?
```
**修改后**:
```typescript
// 查询用户
SELECT id FROM users WHERE id = ?
// 查询推荐人(从 referral_bindings
SELECT rb.referrer_id, u.nickname, u.avatar
FROM referral_bindings rb
JOIN users u ON rb.referrer_id = u.id
WHERE rb.referee_id = ?
AND rb.status = 'active'
AND rb.expiry_date > NOW()
// 查询被推荐人列表(从 referral_bindings
SELECT u.*, rb.binding_date, rb.purchase_count
FROM referral_bindings rb
JOIN users u ON rb.referee_id = u.id
WHERE rb.referrer_id = ?
AND rb.status = 'active'
AND rb.expiry_date > NOW()
```
---
#### 2.2 `/api/db/users/referrals`
**修改前**:
```typescript
// 兜底查询(从 users 表)
if (referrals.length === 0) {
SELECT * FROM users WHERE referred_by = ?
}
```
**修改后**:
```typescript
// 已删除兜底查询,只使用 referral_bindings
```
---
#### 2.3 `/api/auth/login`
**修改前**:
```typescript
SELECT id, phone, ..., referred_by, ... FROM users WHERE phone = ?
return {
referredBy: r.referred_by
}
```
**修改后**:
```typescript
SELECT id, phone, ..., ... FROM users WHERE phone = ?
// 移除 referred_by 字段
return {
// 移除 referredBy
}
```
---
#### 2.4 `/api/wechat/login`
**修改前**:
```typescript
INSERT INTO users (..., referred_by, ...) VALUES (..., ?, ...)
return {
referredBy: user.referred_by
}
```
**修改后**:
```typescript
INSERT INTO users (..., ...) VALUES (..., ...)
// 移除 referred_by 字段
return {
// 移除 referredBy
}
```
---
#### 2.5 `/api/db/users`
**修改前**:
```typescript
INSERT INTO users (..., referred_by, ...) VALUES (..., ?, ...)
```
**修改后**:
```typescript
INSERT INTO users (..., ...) VALUES (..., ...)
// 移除 referred_by 字段
```
---
#### 2.6 `/api/payment/wechat/notify` 和 `/api/payment/alipay/notify`
**修改前**:
```typescript
SELECT u.id, u.referred_by, rb.referrer_id, rb.status
FROM users u
LEFT JOIN referral_bindings rb ...
```
**修改后**:
```typescript
SELECT u.id, rb.referrer_id, rb.status
FROM users u
LEFT JOIN referral_bindings rb ...
// 移除 u.referred_by不再使用
```
---
#### 2.7 `/app/admin/users/page.tsx`
**修改前**:
```typescript
interface User {
referred_by?: string | null
}
{user.referred_by && (
<div>来自: {user.referred_by.slice(0, 8)}</div>
)}
```
**修改后**:
```typescript
interface User {
// 移除 referred_by
}
// 移除显示逻辑
```
---
### 3. 小程序海报硬编码修复
**修改文件**: `miniprogram/pages/referral/referral.wxml`
**修改内容**:
```xml
<!-- 修改前 -->
<text class="poster-stat-value poster-stat-pink">90%</text>
<!-- 修改后 -->
<text class="poster-stat-value poster-stat-pink">{{shareRate}}%</text>
```
---
## 🗄️ 数据库操作
### 方式1: 在宝塔面板执行(推荐)
1. 登录宝塔面板
2. 进入「数据库」→「phpMyAdmin」
3. 选择数据库 `soul_miniprogram`
4. 点击「SQL」标签
5. 粘贴 `scripts/remove-referred-by-field.sql` 的内容
6. 点击「执行」
---
### 方式2: 使用 Python 脚本
**文件**: `scripts/remove-referred-by-field-auto.py`
**执行**:
```bash
python scripts/remove-referred-by-field-auto.py
```
**注意**: 需要本地能连接到数据库
---
### 方式3: 手动执行SQL
如果上述方式都不行可以手动执行以下SQL
```sql
-- 1. 备份
CREATE TABLE users_referred_by_backup AS
SELECT id, referred_by, created_at
FROM users
WHERE referred_by IS NOT NULL;
-- 2. 删除索引
ALTER TABLE users DROP INDEX IF EXISTS idx_referred_by;
-- 3. 删除字段
ALTER TABLE users DROP COLUMN referred_by;
-- 4. 验证
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'soul_miniprogram'
AND table_name = 'users'
AND column_name = 'referred_by';
-- 应该返回 0
```
---
## 🧪 测试验证
### 1. 测试新用户注册
```
1. 小程序注册新用户(带推荐码)
2. 检查 referral_bindings 表是否有记录
3. 验证绑定关系正确
```
---
### 2. 测试推荐人切换
```
1. 用户B已绑定推荐人A
2. 点击推荐人C的链接
3. 检查 referral_bindings 表B的推荐人应切换为C
```
---
### 3. 测试佣金计算
```
1. 用户B通过推荐人A的链接购买1元商品
2. 检查 referral_bindings 表:
- purchase_count 增加1
- total_commission 增加约0.9元90%
3. 检查 users 表:
- 推荐人A的 pending_earnings 增加约0.9元
```
---
### 4. 测试分销中心显示
```
1. 打开小程序分销中心
2. 验证显示:
- "你获得 90% 收益"shareRate动态读取
- 绑定用户列表正确
- 已付款用户显示购买次数
```
---
## 📊 性能影响
### 查询性能对比
| 操作 | 使用 referred_by | 使用 referral_bindings | 差异 |
|------|------------------|------------------------|------|
| 获取推荐人 | ~0.01ms | ~0.1ms | +0.09ms |
| 获取推荐列表 | ~1ms | ~1.2ms | +0.2ms |
| 绑定切换 | 需要更新2处 | 只更新1处 | 更简单 |
**结论**: 性能差异可忽略,数据一致性大幅提升 ✅
---
## 🚨 注意事项
### 1. 备份重要性
- `users_referred_by_backup` 表保留了所有旧数据
- 建议保留1-2周确认无误后再删除
---
### 2. 代码部署顺序
**正确顺序**:
```
1. 修改代码(已完成)
2. 删除数据库字段(待执行)
3. 部署新代码到服务器
4. 测试功能
```
**错误顺序**(会报错):
```
1. 先删除数据库字段 ❌
2. 旧代码还在查询 referred_by → 报错!
```
---
### 3. 回滚方案
如果需要回滚:
```sql
-- 1. 从备份恢复字段
ALTER TABLE users ADD COLUMN referred_by VARCHAR(50);
-- 2. 恢复数据
UPDATE users u
JOIN users_referred_by_backup b ON u.id = b.id
SET u.referred_by = b.referred_by;
-- 3. 重建索引
CREATE INDEX idx_referred_by ON users(referred_by);
```
---
## 📝 检查清单
执行前检查:
- [x] 所有代码已修改完成
- [ ] 数据库已备份
- [ ] SQL文件已准备
- [ ] 在测试环境验证过
执行后检查:
- [ ] referred_by 字段已删除
- [ ] 备份表已创建
- [ ] 新代码已部署
- [ ] 绑定功能测试通过
- [ ] 佣金计算测试通过
- [ ] 分销中心显示正常
---
## 🚀 快速执行
### 宝塔面板操作步骤
1. **登录宝塔**`数据库``phpMyAdmin`
2. **选择数据库** `soul_miniprogram`
3. **点击 SQL 标签**
4. **复制粘贴** `scripts/remove-referred-by-field.sql` 的内容
5. **点击执行**
6. **查看结果**:应该看到备份表创建成功、字段删除成功
---
## ✨ 优化效果
### 修改前:
```
绑定关系存储在2个地方
- users.referred_by可能过期、不准确
- referral_bindings完整、准确
问题:
- 数据不一致
- 维护成本高
- 容易出bug
```
### 修改后:
```
绑定关系只存储在1个地方
- referral_bindings唯一数据源
优势:
- 数据一致性强 ✅
- 维护成本低 ✅
- 不会出现过期数据 ✅
```
---
**执行完SQL后请告诉我结果我会继续协助你部署和测试**

View File

@@ -1,359 +0,0 @@
# 可提现金额计算修复
## 问题总结
### 发现的问题
**当前逻辑(错误)**
```javascript
可提现金额 = 累计佣金 - 待审核金额
```
**问题场景**
1. 用户累计佣金 ¥100申请提现 ¥50
2. 此时:可提现 = 100 - 50 = ¥50 ✅ 正确
3. 审核通过后,提现记录状态改为 completed
4. 此时:可提现 = 100 - 0 = ¥100 ❌ 错误!
**根本原因**:审核通过后,`pendingWithdrawAmount` 归零,但没有减去"已提现金额",导致可提现金额"回血"。
## 正确方案
### 计算公式
```javascript
可提现金额 = 累计佣金 - 已提现金额 - 待审核金额
```
### 字段说明
| 字段 | 说明 | 来源 | 变化规律 |
|------|------|------|----------|
| `totalCommission` | 累计佣金总额 | `SUM(orders.amount) × distributorShare` | 有新订单就增加,只增不减 |
| `withdrawnEarnings` | 已提现金额 | `users.withdrawn_earnings` | 审核通过时累加 |
| `pendingWithdrawAmount` | 待审核金额 | `SUM(withdrawals.amount WHERE status='pending')` | 申请时增加,审核后归零 |
| `availableEarnings` | 可提现金额 | 前端计算 | 动态变化 |
## 修复内容
### 1. 前端计算逻辑修正
**文件**`miniprogram/pages/referral/referral.js`
**修改前**
```javascript
// ❌ 错误:只减去待审核,没减去已提现
const totalCommissionNum = realData?.totalCommission || 0
const pendingWithdrawNum = realData?.pendingWithdrawAmount || 0
const availableEarningsNum = totalCommissionNum - pendingWithdrawNum
```
**修改后**
```javascript
// ✅ 正确:三元素完整计算
const totalCommissionNum = realData?.totalCommission || 0
const withdrawnNum = realData?.withdrawnEarnings || 0
const pendingWithdrawNum = realData?.pendingWithdrawAmount || 0
const availableEarningsNum = totalCommissionNum - withdrawnNum - pendingWithdrawNum
```
### 2. 详细日志输出
```javascript
console.log('=== [Referral] 收益计算(完整版)===')
console.log('累计佣金 (totalCommission):', totalCommissionNum)
console.log('已提现金额 (withdrawnEarnings):', withdrawnNum)
console.log('待审核金额 (pendingWithdrawAmount):', pendingWithdrawNum)
console.log('可提现金额 = 累计 - 已提现 - 待审核 =', totalCommissionNum, '-', withdrawnNum, '-', pendingWithdrawNum, '=', availableEarningsNum)
console.log('最低提现金额 (minWithdrawAmount):', minWithdrawAmount)
console.log('按钮判断:', availableEarningsNum, '>=', minWithdrawAmount, '=', availableEarningsNum >= minWithdrawAmount)
console.log('✅ 按钮应该:', availableEarningsNum >= minWithdrawAmount ? '🟢 启用(绿色)' : '⚫ 禁用(灰色)')
```
## 验证流程
### 完整场景测试
#### 场景1初始状态
```
数据:
- 累计佣金: ¥100
- 已提现: ¥0
- 待审核: ¥0
计算:
availableEarnings = 100 - 0 - 0 = ¥100
验证:✅ 可提现 ¥100
```
#### 场景2申请提现
```
操作:申请提现 ¥50
数据:
- 累计佣金: ¥100 (不变)
- 已提现: ¥0 (不变)
- 待审核: ¥50 (新增)
计算:
availableEarnings = 100 - 0 - 50 = ¥50
验证:✅ 可提现 ¥50
```
#### 场景3审核通过关键
```
操作:管理员审核通过
数据:
- 累计佣金: ¥100 (不变)
- 已提现: ¥50 (users.withdrawn_earnings += 50)
- 待审核: ¥0 (状态改为 completed)
计算:
availableEarnings = 100 - 50 - 0 = ¥50
验证:✅ 可提现 ¥50不会回血
```
#### 场景4新订单产生
```
操作:用户购买新商品,产生佣金 ¥20
数据:
- 累计佣金: ¥120 (100 + 20)
- 已提现: ¥50 (不变)
- 待审核: ¥0 (不变)
计算:
availableEarnings = 120 - 50 - 0 = ¥70
验证:✅ 可提现 ¥70
```
#### 场景5二次提现
```
操作:申请提现 ¥30
数据:
- 累计佣金: ¥120 (不变)
- 已提现: ¥50 (不变)
- 待审核: ¥30 (新增)
计算:
availableEarnings = 120 - 50 - 30 = ¥40
验证:✅ 可提现 ¥40
```
### 数据库验证
#### 1. 检查 users 表
```sql
SELECT
id,
nickname,
withdrawn_earnings
FROM users
WHERE id = 'YOUR_USER_ID';
```
#### 2. 检查 withdrawals 表
```sql
SELECT
id,
amount,
status,
created_at
FROM withdrawals
WHERE user_id = 'YOUR_USER_ID'
ORDER BY created_at DESC;
```
#### 3. 检查 orders 表
```sql
SELECT
SUM(amount) as total_amount
FROM orders
WHERE referrer_id = 'YOUR_USER_ID'
AND status = 'paid';
```
#### 4. 手动验证计算
```sql
-- 1. 累计佣金
SET @total_orders = (SELECT SUM(amount) FROM orders WHERE referrer_id = 'YOUR_USER_ID' AND status = 'paid');
SET @distributor_share = 0.9;
SET @total_commission = @total_orders * @distributor_share;
-- 2. 已提现
SET @withdrawn = (SELECT withdrawn_earnings FROM users WHERE id = 'YOUR_USER_ID');
-- 3. 待审核
SET @pending = (SELECT SUM(amount) FROM withdrawals WHERE user_id = 'YOUR_USER_ID' AND status = 'pending');
-- 4. 可提现
SET @available = @total_commission - @withdrawn - IFNULL(@pending, 0);
-- 显示结果
SELECT
@total_commission as '累计佣金',
@withdrawn as '已提现',
@pending as '待审核',
@available as '可提现';
```
## 测试步骤
### 1. 清除缓存重新编译
微信开发者工具:
```
工具 → 清除缓存 → 清除全部缓存数据
点击 编译 按钮
```
### 2. 查看控制台日志
进入分销中心页面,查看日志输出:
```
=== [Referral] 收益计算(完整版)===
累计佣金 (totalCommission): 100
已提现金额 (withdrawnEarnings): 0
待审核金额 (pendingWithdrawAmount): 0
可提现金额 = 累计 - 已提现 - 待审核 = 100 - 0 - 0 = 100
最低提现金额 (minWithdrawAmount): 5
按钮判断: 100 >= 5 = true
✅ 按钮应该: 🟢 启用(绿色)
```
### 3. 测试提现流程
1. **申请提现**:点击提现按钮,申请提现
2. **查看变化**
- 累计佣金不变
- 待审核金额增加
- 可提现金额减少
3. **审核通过**:在管理后台审核通过
4. **再次查看**
- 累计佣金不变
- 已提现金额增加
- 待审核金额归零
- **可提现金额不应该回血**
### 4. 验证按钮状态
不同情况下按钮的状态:
| 可提现金额 | 最低提现 | 按钮状态 | 按钮文本 |
|-----------|---------|---------|---------|
| ¥10 | ¥5 | 启用 | 申请提现 ¥10 |
| ¥3 | ¥5 | 禁用 | 满5元可提现 |
| ¥0 | ¥5 | 禁用 | 满5元可提现 |
## 常见问题
### Q1: 为什么要分"累计佣金"和"可提现金额"
**A**:
- **累计佣金**:用户的成就感,"我总共赚了多少"
- **可提现金额**:用户的可操作余额,"我现在能提多少"
### Q2: 已提现的钱还算在累计佣金里吗?
**A**: 是的。累计佣金是历史总和,只增不减。已提现只是资金流向,不影响累计数字。
示例:
- 累计赚了 ¥100成就
- 已提现 ¥50已到手
- 还能提现 ¥50余额
### Q3: 如果审核拒绝会怎样?
**A**:
1. 待审核金额归零(提现申请被取消)
2. 已提现金额不变
3. 可提现金额恢复(用户可以重新申请)
### Q4: users.withdrawn_earnings 会自动更新吗?
**A**: 是的,在两个地方会更新:
- `app/api/withdraw/route.ts` - 自动审核通过时
- `app/api/admin/withdrawals/route.ts` - 管理员审核通过时
### Q5: 为什么不直接从数据库读取可提现金额?
**A**:
- 可提现金额是**动态计算值**,不是固定字段
- 实时计算确保数据准确
- 便于调试和验证逻辑
## 相关文件
- `miniprogram/pages/referral/referral.js` - 前端计算(已修改)✅
- `app/api/referral/data/route.ts` - 数据查询(无需修改)
- `app/api/withdraw/route.ts` - 提现接口(无需修改)
- `app/api/admin/withdrawals/route.ts` - 审核接口(无需修改)
## 数据流图
```
┌────────────────────────────────────────────┐
│ 订单产生佣金 │
│ ↓ │
│ 累计佣金 (totalCommission) │
│ 持续累加,只增不减 │
│ ↓ │
│ ┌─────────────┴─────────────┐ │
│ ↓ ↓ │
│ 申请提现 继续积累 │
│ ↓ │
│ 待审核 (pendingWithdrawAmount) │
│ 暂时冻结 │
│ ↓ │
│ 审核通过 │
│ ↓ │
│ 已提现 (withdrawnEarnings) │
│ 累加记录 │
│ │
│ ┌─────────────────────────┐ │
│ │ 可提现金额(实时计算) │ │
│ │ = 累计佣金 │ │
│ │ - 已提现金额 │ │
│ │ - 待审核金额 │ │
│ └─────────────────────────┘ │
└────────────────────────────────────────────┘
```
## 总结
### 修改前的问题
```javascript
可提现 = 累计佣金 - 待审核金额
问题审核通过后待审核归零可提现"回血"
```
### 修改后的正确逻辑
```javascript
可提现 = 累计佣金 - 已提现金额 - 待审核金额
优点
1. 审核通过后可提现金额不会增加
2. 只有新订单产生佣金时可提现金额才会增加
3. 逻辑清晰符合资金流向
```
### 业务含义
- **累计佣金**:成就感 - "我赚了多少"
- **已提现**:安全感 - "我拿到了多少"
- **待审核**:期待感 - "我正在提多少"
- **可提现**:行动力 - "我能提多少"
这样的设计既满足了用户查看历史成就的需求,又确保了资金安全和准确性。

View File

@@ -1,278 +0,0 @@
# 后台提现审核 - 快速测试指南
## 🚀 快速启动
### 1. 重启服务
```bash
cd E:\Gongsi\Mycontent
python devlop.py restart mycontent
```
### 2. 访问页面
```
http://localhost:3006/admin/withdrawals
```
## 📊 界面功能验证
### 顶部统计卡片
应该显示:
- [ ] 总申请数量
- [ ] 待处理数量和金额
- [ ] 已完成数量和金额
- [ ] 已拒绝数量
### 筛选按钮
- [ ] 全部
- [ ] 待处理(橙色高亮)
- [ ] 已完成
- [ ] 已拒绝
### 提现记录表格
| 列名 | 内容验证 |
|------|---------|
| 申请时间 | 显示为本地时间格式 |
| 用户 | 头像 + 昵称 + 电话/推荐码 |
| 提现金额 | 橙色加粗显示 |
| **用户佣金信息** | **新增列** |
| 状态 | 显示对应颜色的徽章 |
| 处理时间 | 已处理显示时间,未处理显示"-" |
| 操作 | 待处理显示批准/拒绝按钮 |
## 🔍 关键功能:用户佣金信息
### 正常显示(绿色)
```
累计佣金: ¥100.00 [青色]
已提现: ¥30.00 [灰色]
待审核: ¥50.00 [橙色]
────────────────────────
审核后余额: ¥20.00 [绿色] ✅
```
**验证要点**
- [ ] 所有金额都正确显示两位小数
- [ ] 累计佣金为青色(#38bdac
- [ ] 审核后余额为绿色(正数)
- [ ] 有分隔线
### 风险警告(红色)
```
累计佣金: ¥100.00 [青色]
已提现: ¥30.00 [灰色]
待审核: ¥80.00 [橙色]
────────────────────────
审核后余额: -¥10.00 [红色] ❌
```
**验证要点**
- [ ] 审核后余额为红色(负数)
- [ ] 点击"批准"时弹出风险警告
- [ ] 警告文字包含负数金额
## 🧪 测试用例
### 测试1正常用户提现
**准备数据**
```sql
-- 用户A累计佣金 ¥90已提现 ¥0待审核 ¥0
-- 申请提现 ¥50
-- 插入订单(产生佣金)
INSERT INTO orders (id, user_id, amount, referrer_id, status, pay_time)
VALUES ('test_order_1', 'buyer_1', 100, 'YOUR_USER_ID', 'paid', NOW());
-- 申请提现
INSERT INTO withdrawals (id, user_id, amount, status, wechat_openid, created_at)
VALUES ('W_TEST_1', 'YOUR_USER_ID', 50, 'pending', NULL, NOW());
```
**预期结果**
- 累计佣金: ¥90.00100 × 90%
- 已提现: ¥0.00
- 待审核: ¥50.00
- 审核后余额: ¥40.00 ✅ 绿色
**操作**:点击"批准"
- 弹出确认框(无风险警告)
- 确认后状态改为 success
### 测试2超额提现风险
**准备数据**
```sql
-- 同一用户再申请 ¥60总待审核 ¥110 > 可提现 ¥90
INSERT INTO withdrawals (id, user_id, amount, status, wechat_openid, created_at)
VALUES ('W_TEST_2', 'YOUR_USER_ID', 60, 'pending', NULL, NOW());
```
**预期结果**
- W_TEST_1: 审核后余额 ¥40.00 ✅ 绿色
- W_TEST_2: 审核后余额 **-¥20.00** ❌ 红色
**操作**:点击"批准" W_TEST_2
- ⚠️ 弹出风险警告
- 显示负数余额
- 需要二次确认
### 测试3拒绝提现
**操作**:点击"拒绝" W_TEST_2
- 弹出输入框:"请输入拒绝原因"
- 输入:"余额不足"
- 确认
**预期结果**
- 提现记录状态改为 failed
- error_message 保存为"余额不足"
- 刷新页面后W_TEST_1 的"待审核"金额恢复为 ¥50
## 🎯 快速验证步骤
### Step 1: 查看界面
```bash
# 1. 启动服务
python devlop.py restart mycontent
# 2. 浏览器访问
http://localhost:3006/admin/withdrawals
```
### Step 2: 检查数据显示
- [ ] 表格列是否完整7列
- [ ] 用户佣金信息列是否显示
- [ ] 头像是否正确显示
- [ ] 金额是否有两位小数
### Step 3: 测试筛选
- [ ] 点击"待处理",只显示 pending 记录
- [ ] 点击"已完成",只显示 success 记录
- [ ] 点击"全部",显示所有记录
### Step 4: 测试审核
- [ ] 找一条审核后余额为绿色的记录
- [ ] 点击"批准"
- [ ] 确认是否正常弹框
- [ ] 确认后检查状态是否更新
### Step 5: 验证计算
使用验证脚本:
```sql
-- 在 phpMyAdmin 中执行
-- 文件: scripts/verify-withdrawal-data.sql
SET @user_id = 'YOUR_USER_ID';
-- 然后执行脚本中的所有查询
```
对比:
- SQL 计算的可提现金额
- 页面显示的审核后余额
- 应该一致 ✅
## 🐛 常见问题排查
### 问题1用户佣金信息显示"暂无数据"
**原因**:后端查询失败或数据为空
**排查**
```bash
# 查看后端日志
pm2 logs mycontent --lines 50
# 查找错误信息
[Withdrawals] 查询失败: ...
```
### 问题2审核后余额计算错误
**原因**:数据库数据不一致
**排查**
```sql
-- 检查用户的 withdrawn_earnings 是否准确
SELECT
u.withdrawn_earnings as DB中的已提现,
COALESCE(SUM(w.amount), 0) as 实际已完成提现
FROM users u
LEFT JOIN withdrawals w ON w.user_id = u.id AND w.status = 'success'
WHERE u.id = 'YOUR_USER_ID'
GROUP BY u.id, u.withdrawn_earnings;
```
### 问题3批准后状态未更新
**原因**:审核接口可能失败
**排查**
```bash
# 查看浏览器 Network 面板
# 检查 PUT /api/admin/withdrawals 的响应
# 查看后端日志
pm2 logs mycontent
```
### 问题4头像显示不出来
**原因**头像URL路径问题
**排查**
```sql
-- 检查用户头像字段
SELECT id, nickname, avatar FROM users WHERE avatar IS NOT NULL LIMIT 5;
```
## 📝 验证清单
### 数据准确性
- [ ] 累计佣金 = 订单总金额 × 90%
- [ ] 已提现 = users.withdrawn_earnings
- [ ] 待审核 = SUM(withdrawals.amount WHERE status='pending')
- [ ] 审核后余额 = 累计 - 已提现 - 待审核
### 界面显示
- [ ] 用户头像/首字母
- [ ] 用户昵称
- [ ] 用户电话/推荐码
- [ ] 提现金额(橙色)
- [ ] 佣金信息完整4项
- [ ] 状态徽章
- [ ] 批准/拒绝按钮
### 功能测试
- [ ] 筛选功能
- [ ] 刷新功能
- [ ] 批准操作
- [ ] 拒绝操作
- [ ] 风险警告
### 安全验证
- [ ] 超额提现显示红色
- [ ] 批准时弹出风险警告
- [ ] 需要二次确认
- [ ] 批准后数据正确更新
## 🎉 完成标志
当所有验证项都打勾 ✅,说明提现审核功能已完全对接并正常工作!
**关键指标**
- 数据显示完整准确
- 计算逻辑正确
- 风险警告有效
- 审核操作成功

View File

@@ -1,546 +0,0 @@
# 后台提现审核功能完善说明
## 概述
完善后台管理的"提现审核"页面,增加用户佣金信息展示,帮助管理员做出准确的审核决策。
## 功能增强
### 1. 新增用户佣金信息展示
在提现记录表格中,新增"用户佣金信息"列,显示:
| 字段 | 说明 | 计算方式 | 颜色 |
|------|------|----------|------|
| 累计佣金 | 用户的历史总佣金 | `SUM(orders.amount) × 90%` | 青色 |
| 已提现 | 已到账的金额 | `users.withdrawn_earnings` | 灰色 |
| 待审核 | 所有待审核的提现申请 | `SUM(withdrawals.amount WHERE status='pending')` | 橙色 |
| 审核后余额 | 如果通过该申请,用户剩余的可提现金额 | `累计 - 已提现 - 待审核` | 绿色(≥0) / 红色(<0) |
### 2. 超额提现风险警告
"审核后余额"为负数时
- 以红色显示
- 批准时弹出二次确认警告
```
⚠️ 风险警告:该用户审核后余额为负数(¥-20.00),可能存在超额提现。
确认已核实用户账户并完成打款?
```
### 3. 用户头像显示
优化用户信息展示
- 如果有头像显示真实头像
- 如果无头像显示昵称首字母
- 显示推荐码如果有
## 技术实现
### 后端API (`app/api/admin/withdrawals/route.ts`)
#### 查询优化
```typescript
SELECT
w.*,
u.nickname as user_nickname,
u.phone as user_phone,
u.avatar as user_avatar,
u.referral_code,
u.withdrawn_earnings,
u.earnings,
u.pending_earnings,
-- 计算累计佣金(从 orders 表)
(SELECT COALESCE(SUM(o.amount), 0)
FROM orders o
WHERE o.referrer_id = w.user_id AND o.status = 'paid') as total_order_amount,
-- 计算待审核提现金额(不包括当前这条)
(SELECT COALESCE(SUM(w2.amount), 0)
FROM withdrawals w2
WHERE w2.user_id = w.user_id AND w2.status = 'pending' AND w2.id != w.id) as other_pending_amount
FROM withdrawals w
LEFT JOIN users u ON w.user_id = u.id
```
#### 返回数据结构
```typescript
{
id: string,
userId: string,
userNickname: string,
userPhone: string,
userAvatar: string,
referralCode: string,
amount: number,
status: string,
createdAt: string,
processedAt: string,
// ✅ 新增:用户佣金信息
userCommissionInfo: {
totalCommission: number, // 累计佣金
withdrawnEarnings: number, // 已提现
pendingWithdrawals: number, // 待审核(包括当前)
availableAfterThis: number // 审核后余额
}
}
```
#### 计算逻辑
```typescript
// 累计佣金90%分成)
const totalCommission = parseFloat(w.total_order_amount) * 0.9
// 已提现金额
const withdrawnEarnings = parseFloat(w.withdrawn_earnings) || 0
// 其他待审核金额(不包括当前这笔)
const otherPendingAmount = parseFloat(w.other_pending_amount) || 0
// 当前提现金额
const currentWithdrawAmount = parseFloat(w.amount)
// ✅ 审核后余额 = 累计佣金 - 已提现 - 其他待审核 - 当前提现
const availableAfterThis = totalCommission - withdrawnEarnings - otherPendingAmount - currentWithdrawAmount
```
### 前端页面 (`app/admin/withdrawals/page.tsx`)
#### 界面布局
```tsx
<td className="p-4">
{w.userCommissionInfo ? (
<div className="text-xs space-y-1">
{/* 累计佣金 */}
<div className="flex justify-between gap-4">
<span className="text-gray-500">累计佣金:</span>
<span className="text-[#38bdac] font-medium">
¥{w.userCommissionInfo.totalCommission.toFixed(2)}
</span>
</div>
{/* 已提现 */}
<div className="flex justify-between gap-4">
<span className="text-gray-500">已提现:</span>
<span className="text-gray-400">
¥{w.userCommissionInfo.withdrawnEarnings.toFixed(2)}
</span>
</div>
{/* 待审核 */}
<div className="flex justify-between gap-4">
<span className="text-gray-500">待审核:</span>
<span className="text-orange-400">
¥{w.userCommissionInfo.pendingWithdrawals.toFixed(2)}
</span>
</div>
{/* 审核后余额(带边框分隔) */}
<div className="flex justify-between gap-4 pt-1 border-t border-gray-700/30">
<span className="text-gray-500">审核后余额:</span>
<span className={
w.userCommissionInfo.availableAfterThis >= 0
? "text-green-400 font-medium"
: "text-red-400 font-medium"
}>
¥{w.userCommissionInfo.availableAfterThis.toFixed(2)}
</span>
</div>
</div>
) : (
<span className="text-gray-500 text-xs">暂无数据</span>
)}
</td>
```
#### 风险警告
```typescript
const handleApprove = async (id: string) => {
const withdrawal = withdrawals.find(w => w.id === id)
// 检查超额提现风险
if (withdrawal?.userCommissionInfo?.availableAfterThis < 0) {
if (!confirm(`⚠️ 风险警告:该用户审核后余额为负数...`)) {
return
}
} else {
if (!confirm("确认已完成打款?批准后将更新用户提现记录。")) {
return
}
}
// 执行批准操作
// ...
}
```
## 使用场景
### 场景1正常提现
**用户A的提现申请**
- 累计佣金: ¥100.00
- 已提现: ¥30.00
- 待审核: ¥40.00包括当前 ¥40
- 审核后余额: ¥30.00 绿色
**管理员判断**
- 数据正常
- 审核通过后还有 ¥30 余额
- 可以批准
### 场景2超额提现风险
**用户B的提现申请**
- 累计佣金: ¥100.00
- 已提现: ¥30.00
- 待审核: ¥80.00当前 ¥60 + 其他 ¥20
- 审核后余额: **-¥10.00** 红色
**管理员判断**
- 超额提现风险
- 待审核总额 ¥80 > 可提现 ¥70
- ⚠️ 可能是并发提现或恶意提现
- 🛑 建议拒绝,或核实后只批准部分
### 场景3多笔待审核
**用户C的多笔提现**
**第一笔申请 ¥40**
- 累计佣金: ¥100.00
- 已提现: ¥0.00
- 待审核: ¥40.00
- 审核后余额: ¥60.00 ✅
**第二笔申请 ¥30**
- 累计佣金: ¥100.00
- 已提现: ¥0.00
- 待审核: ¥70.00¥40 + ¥30
- 审核后余额: ¥30.00 ✅
**第三笔申请 ¥40**
- 累计佣金: ¥100.00
- 已提现: ¥0.00
- 待审核: ¥110.00¥40 + ¥30 + ¥40
- 审核后余额: **-¥10.00** ❌ 红色
**管理员策略**
- ✅ 批准第一笔¥40
- ✅ 批准第二笔¥30
- ❌ 拒绝第三笔¥40- 余额不足
## 测试验证
### 1. 准备测试数据
```sql
-- 用户A正常用户
INSERT INTO users (id, nickname, phone, withdrawn_earnings, referral_code)
VALUES ('user_a', '测试用户A', '13800138000', 0, 'SOULA001');
INSERT INTO orders (id, user_id, amount, referrer_id, status, pay_time)
VALUES ('order_a1', 'buyer_1', 100, 'user_a', 'paid', NOW());
INSERT INTO withdrawals (id, user_id, amount, status)
VALUES ('W_A1', 'user_a', 50, 'pending');
-- 用户B超额提现用户
INSERT INTO users (id, nickname, phone, withdrawn_earnings, referral_code)
VALUES ('user_b', '测试用户B', '13900139000', 30, 'SOULB001');
INSERT INTO orders (id, user_id, amount, referrer_id, status, pay_time)
VALUES ('order_b1', 'buyer_2', 100, 'user_b', 'paid', NOW());
INSERT INTO withdrawals (id, user_id, amount, status)
VALUES
('W_B1', 'user_b', 20, 'pending'),
('W_B2', 'user_b', 60, 'pending');
```
### 2. 访问页面
```
http://localhost:3006/admin/withdrawals
```
### 3. 验证显示
**用户A的记录**
- 累计佣金: ¥90.00100 × 90%
- 已提现: ¥0.00
- 待审核: ¥50.00
- 审核后余额: ¥40.00 ✅ 绿色
**用户B的记录W_B1**
- 累计佣金: ¥90.00
- 已提现: ¥30.00
- 待审核: ¥80.0020 + 60
- 审核后余额: **-¥20.00** ❌ 红色
**用户B的记录W_B2**
- 累计佣金: ¥90.00
- 已提现: ¥30.00
- 待审核: ¥80.0020 + 60
- 审核后余额: **-¥20.00** ❌ 红色
### 4. 测试批准操作
**批准用户A的提现**
- 正常弹出确认框
- 批准成功
**批准用户B的提现**
- ⚠️ 弹出风险警告
- 显示负数余额
- 需要二次确认
## 管理员决策指南
### 何时批准?
- ✅ 审核后余额 ≥ 0
- ✅ 用户信息真实完整
- ✅ 累计佣金合理(有订单支持)
- ✅ 已完成线下打款(或准备自动转账)
### 何时拒绝?
- ❌ 审核后余额 < 0
- 累计佣金异常没有订单但有佣金
- 同一用户有多笔待审核且总额超额
- 用户信息不全或异常
### 如何处理并发提现?
**发现场景**同一用户有多笔待审核总额超出可提现金额
**处理策略**
1. 按时间顺序查看
2. 计算每笔的"审核后余额"
3. 批准最早的几笔余额充足的
4. 拒绝超额的后续申请
5. 联系用户说明情况
## 数据完整性说明
### 数据来源
| 数据 | 来源 | 可靠性 |
|------|------|--------|
| 累计佣金 | `orders` 表实时计算 | ⭐⭐⭐⭐⭐ 最准确 |
| 已提现 | `users.withdrawn_earnings` | ⭐⭐⭐⭐ 需要提现接口正确维护 |
| 待审核 | `withdrawals` 表实时查询 | ⭐⭐⭐⭐⭐ 实时准确 |
### 计算公式
```
累计佣金 = Σ(已付款订单金额) × 分成比例(90%)
已提现金额 = users.withdrawn_earnings
待审核金额 = Σ(status='pending'的提现申请)
审核后余额 = 累计佣金 - 已提现金额 - 待审核金额
```
## 界面效果
### 提现记录表格
```
┌──────────────┬───────────┬──────────┬─────────────────────┬────────┬──────────┬────────┐
│ 申请时间 │ 用户 │ 提现金额 │ 用户佣金信息 │ 状态 │ 处理时间 │ 操作 │
├──────────────┼───────────┼──────────┼─────────────────────┼────────┼──────────┼────────┤
│ 2026-02-04 │ [头像] │ ¥50.00 │ 累计佣金: ¥100.00 │ 待处理 │ - │ 批准 │
│ 15:30 │ 张三 │ │ 已提现: ¥30.00 │ │ │ 拒绝 │
│ │ 138***00 │ │ 待审核: ¥50.00 │ │ │ │
│ │ │ │ ───────────────── │ │ │ │
│ │ │ │ 审核后余额: ¥20.00 │ │ │ │
└──────────────┴───────────┴──────────┴─────────────────────┴────────┴──────────┴────────┘
```
### 风险提示(审核后余额<0
```
┌──────────────┬───────────┬──────────┬─────────────────────┬────────┬──────────┬────────┐
│ 申请时间 │ 用户 │ 提现金额 │ 用户佣金信息 │ 状态 │ 处理时间 │ 操作 │
├──────────────┼───────────┼──────────┼─────────────────────┼────────┼──────────┼────────┤
│ 2026-02-04 │ [头像] │ ¥80.00 │ 累计佣金: ¥100.00 │ 待处理 │ - │ 批准 │
│ 15:35 │ 李四 │ │ 已提现: ¥30.00 │ │ │ 拒绝 │
│ │ 139***00 │ │ 待审核: ¥80.00 │ │ │ │
│ │ │ │ ───────────────── │ │ │ │
│ │ │ │ 审核后余额: -¥10.00 │ │ │ │
│ │ │ │ ↑ [红色] │ │ │ │
└──────────────┴───────────┴──────────┴─────────────────────┴────────┴──────────┴────────┘
```
**点击批准时**
```
┌─────────────────────────────────────────┐
│ ⚠️ 风险警告 │
├─────────────────────────────────────────┤
│ 该用户审核后余额为负数(¥-10.00
│ 可能存在超额提现。 │
│ │
│ 确认已核实用户账户并完成打款? │
├─────────────────────────────────────────┤
│ [取消] [确定] │
└─────────────────────────────────────────┘
```
## 审核流程示例
### 正常流程
1. **管理员查看**
- 用户张三
- 提现金额:¥50
- 累计佣金:¥100已提现:¥30待审核:¥50
- 审核后余额:¥20
2. **判断**数据正常余额充足
3. **操作**点击"批准"
4. **结果**
- 提现记录状态改为 success
- 用户的 withdrawn_earnings += 50
- 用户收到微信零钱自动转账
### 风险流程
1. **管理员查看**
- 用户李四
- 提现金额:¥80
- 累计佣金:¥100已提现:¥30待审核:¥80
- 审核后余额**-¥10**
2. **判断**超额提现存在风险
3. **操作**
- 方案A直接拒绝备注"余额不足"
- 方案B核实数据后只批准部分金额
4. **结果**
- 拒绝用户的待审核金额归零可重新申请
- 部分批准需要先拒绝原申请等用户重新申请合理金额
## 相关文件
### 后端文件
- `app/api/admin/withdrawals/route.ts` - 提现审核API
- `app/api/withdraw/route.ts` - 用户提现接口
- `app/api/referral/data/route.ts` - 分销数据接口
### 前端文件
- `app/admin/withdrawals/page.tsx` - 提现审核页面
- `miniprogram/pages/referral/referral.js` - 小程序提现页面
### 脚本文件
- `scripts/verify-withdrawal-data.sql` - 数据验证SQL脚本
## 部署步骤
### 1. 重启后端服务
```bash
python devlop.py restart mycontent
```
### 2. 访问管理后台
```
http://localhost:3006/admin/withdrawals
```
### 3. 验证数据显示
- [ ] 用户信息完整昵称头像电话
- [ ] 提现金额正确
- [ ] 佣金信息完整累计已提现待审核审核后余额
- [ ] 审核后余额颜色正确绿色/红色
- [ ] 批准/拒绝按钮可用
### 4. 测试审核操作
- [ ] 批准正常提现审核后余额0
- [ ] 批准风险提现会弹出警告
- [ ] 拒绝提现余额返还
### 5. 验证数据更新
批准后
```sql
-- 检查提现记录状态
SELECT status FROM withdrawals WHERE id = 'W_XXX';
-- 应该是 'success'
-- 检查用户已提现金额
SELECT withdrawn_earnings FROM users WHERE id = 'user_xxx';
-- 应该增加了提现金额
```
## 后续优化建议
### 1. 批量审核
选中多条记录一键批准所有
```tsx
<Button onClick={handleBatchApprove}>
批量批准已勾选项
</Button>
```
### 2. 自动审核
小额提现 ¥50自动审核通过
```typescript
if (amount <= 50 && availableAmount >= amount) {
// 自动批准
}
```
### 3. 审核备注
添加备注字段记录审核原因
```sql
ALTER TABLE withdrawals ADD COLUMN admin_note VARCHAR(500);
```
### 4. 审核历史
记录审核人和审核时间
```sql
ALTER TABLE withdrawals
ADD COLUMN approved_by VARCHAR(64),
ADD COLUMN approved_at TIMESTAMP;
```
## 总结
这次数据对接实现了
### 信息完整性
- 显示用户的累计佣金
- 显示用户的已提现金额
- 显示用户的待审核金额
- 计算审核后余额
### 风险防控
- 超额提现自动标红
- 批准时弹出风险警告
- 帮助管理员做出正确决策
### 用户体验
- 界面清晰数据一目了然
- 颜色区分绿色安全红色风险
- 详细的计算明细
**核心价值**让管理员能够**快速准确地审核提现申请**同时**有效防范超额提现风险**。

View File

@@ -1,377 +0,0 @@
# 后台提现审核数据对接
## 需求
在后台管理的"交易中心-提现审核"页面,完善数据显示,让管理员能够:
1. 查看用户的累计佣金信息
2. 查看用户的已提现金额
3. 查看用户的待审核提现金额
4. 预判审核通过后用户的剩余余额
5. 识别超额提现风险
## 实现内容
### 1. 后端API增强
**文件**`app/api/admin/withdrawals/route.ts`
#### 数据库查询优化
添加了用户佣金相关信息的查询:
```typescript
SELECT
w.*,
u.nickname as user_nickname,
u.phone as user_phone,
u.avatar as user_avatar,
u.referral_code,
u.withdrawn_earnings,
u.earnings,
u.pending_earnings,
-- 计算累计佣金(从 orders 表)
(SELECT COALESCE(SUM(o.amount), 0)
FROM orders o
WHERE o.referrer_id = w.user_id AND o.status = 'paid') as total_order_amount,
-- 计算待审核提现金额(不包括当前这条)
(SELECT COALESCE(SUM(w2.amount), 0)
FROM withdrawals w2
WHERE w2.user_id = w.user_id AND w2.status = 'pending' AND w2.id != w.id) as other_pending_amount
FROM withdrawals w
LEFT JOIN users u ON w.user_id = u.id
```
#### 返回数据结构
新增 `userCommissionInfo` 字段:
```typescript
{
id: string,
userId: string,
userNickname: string,
amount: number,
status: string,
// ... 其他字段
// ✅ 新增:用户佣金信息
userCommissionInfo: {
totalCommission: number, // 累计佣金(订单金额 × 90%
withdrawnEarnings: number, // 已提现金额
pendingWithdrawals: number, // 待审核金额(包括当前这笔)
availableAfterThis: number // 审核通过后的剩余余额
}
}
```
#### 计算逻辑
```typescript
// 计算累计佣金90%分成)
const totalCommission = parseFloat(w.total_order_amount) * 0.9
// 已提现金额
const withdrawnEarnings = parseFloat(w.withdrawn_earnings) || 0
// 其他待审核金额(不包括当前这笔)
const otherPendingAmount = parseFloat(w.other_pending_amount) || 0
// 当前提现金额
const currentWithdrawAmount = parseFloat(w.amount)
// ✅ 审核后余额 = 累计佣金 - 已提现 - 其他待审核 - 当前提现
const availableAfterThis = totalCommission - withdrawnEarnings - otherPendingAmount - currentWithdrawAmount
```
### 2. 前端页面增强
**文件**`app/admin/withdrawals/page.tsx`
#### 新增列:用户佣金信息
在表格中添加了一列显示用户的完整佣金情况:
| 字段 | 说明 | 颜色 |
|------|------|------|
| 累计佣金 | 用户的历史总佣金 | 青色(#38bdac |
| 已提现 | 已到账的金额 | 灰色 |
| 待审核 | 所有待审核的提现申请总和 | 橙色 |
| 审核后余额 | 如果通过当前申请,用户剩余的可提现金额 | 绿色≥0/ 红色(<0 |
#### 界面效果
```
┌─────────────────────────────────────────┐
│ 用户佣金信息 │
├─────────────────────────────────────────┤
│ 累计佣金: ¥100.00 [青色] │
│ 已提现: ¥30.00 [灰色] │
│ 待审核: ¥50.00 [橙色] │
│ ───────────────────────────── │
│ 审核后余额: ¥20.00 [绿色] │
└─────────────────────────────────────────┘
```
#### 风险警告
`审核后余额 < 0`
- 显示为红色
- 批准时弹出警告提示
```typescript
if (withdrawal.userCommissionInfo.availableAfterThis < 0) {
confirm(`⚠️ 风险警告:该用户审核后余额为负数(¥${availableAfterThis}),可能存在超额提现。\n\n确认已核实用户账户并完成打款`)
}
```
## 数据示例
### 示例1正常提现
```
用户A
- 累计佣金: ¥100
- 已提现: ¥30
- 其他待审核: ¥20
- 当前申请: ¥40
审核后余额 = 100 - 30 - 20 - 40 = ¥10 ✅ 正常
```
### 示例2超额提现风险
```
用户B
- 累计佣金: ¥100
- 已提现: ¥30
- 其他待审核: ¥20
- 当前申请: ¥60
审核后余额 = 100 - 30 - 20 - 60 = -¥10 ❌ 超额!
```
**警告**用户B可能存在以下问题
1. 重复提现并发提交
2. 恶意超额提现
3. 数据异常
### 示例3多笔待审核
```
用户C
- 累计佣金: ¥200
- 已提现: ¥50
- 其他待审核: ¥80两笔¥50 + ¥30
- 当前申请: ¥50
审核后余额 = 200 - 50 - 80 - 50 = ¥20 ✅ 正常
但注意:如果三笔都通过,还能再提 ¥20
```
## 审核流程
### 管理员视角
```
1. 打开提现审核页面
2. 查看待审核列表
3. 查看用户佣金信息
- 累计佣金: 判断用户赚了多少
- 已提现: 判断之前提现过多少
- 待审核: 判断还有多少在审核中
- 审核后余额: 判断是否超额
4. 点击"批准"或"拒绝"
5. 如果审核后余额 < 0:
- ⚠️ 弹出风险警告
- 需要二次确认
6. 确认后执行操作
```
### 决策依据
**批准条件**
- 审核后余额 0
- 用户信息真实
- 已完成线下打款或准备自动转账
**拒绝条件**
- 审核后余额 < 0超额提现
- 用户信息异常
- 疑似恶意提现
- 未完成打款且不打算打款
## 统计信息
### 顶部统计卡片
显示全局提现统计
| 统计项 | 说明 |
|--------|------|
| 总申请 | 所有提现申请的数量 |
| 待处理 | 状态为 pending 的数量和金额 |
| 已完成 | 状态为 success 的数量和金额 |
| 已拒绝 | 状态为 failed 的数量 |
### 筛选功能
可以按状态筛选
- 全部
- 待处理pending
- 已完成success
- 已拒绝failed
## 状态说明
| 状态 | 英文 | 说明 | 可操作 |
|------|------|------|--------|
| 待处理 | pending | 用户刚提交等待审核 | 批准/拒绝 |
| 处理中 | processing | 已发起微信转账等待到账 | - |
| 已完成 | success | 已到账提现完成 | - |
| 已拒绝 | failed | 管理员拒绝余额已返还 | - |
## 测试步骤
### 1. 准备测试数据
```sql
-- 插入测试用户
INSERT INTO users (id, nickname, phone, withdrawn_earnings, referral_code)
VALUES ('test_user_1', '测试用户A', '13800138000', 0, 'SOULA001');
-- 插入测试订单(产生佣金)
INSERT INTO orders (id, user_id, amount, referrer_id, status, pay_time)
VALUES ('order_1', 'buyer_1', 100, 'test_user_1', 'paid', NOW());
-- 插入测试提现申请
INSERT INTO withdrawals (id, user_id, amount, status, created_at)
VALUES ('W001', 'test_user_1', 50, 'pending', NOW());
```
### 2. 访问页面
```
http://localhost:3006/admin/withdrawals
```
### 3. 验证数据
**查看提现记录**
- 用户昵称测试用户A
- 电话138****8000
- 提现金额:¥50.00
**查看佣金信息**
- 累计佣金:¥90.00100 × 90%
- 已提现:¥0.00
- 待审核:¥50.00
- 审核后余额:¥40.00
### 4. 测试超额提现
```sql
-- 再插入一笔超额提现
INSERT INTO withdrawals (id, user_id, amount, status, created_at)
VALUES ('W002', 'test_user_1', 60, 'pending', NOW());
```
**刷新页面**查看 W002
- 累计佣金:¥90.00
- 已提现:¥0.00
- 待审核:¥110.0050 + 60
- 审核后余额**-¥20.00** 红色显示
**点击批准**
- 应该弹出风险警告
- 需要二次确认
## 安全检查
### 防止超额提现
1. **前端校验**小程序端计算可提现金额
2. **后端校验**提现接口验证金额
3. **管理端提示**显示审核后余额红色警告
### 并发提现防护
如果用户快速提交多笔提现
- 后端查询会累加所有待审核金额
- 管理员能看到"待审核"总额
- "审核后余额"会提前减去所有待审核的金额
### 数据一致性
- 累计佣金实时从 orders 表计算
- 已提现 users.withdrawn_earnings 读取
- 待审核实时从 withdrawals 表查询
## 常见问题
### Q1: 为什么审核后余额是负数?
**A**: 可能原因
1. 用户并发提交多笔提现
2. 前端校验被绕过
3. 数据库数据异常
**处理**
- 拒绝超额提现申请
- 核查用户账户
- 只批准合理范围内的提现
### Q2: 如果有多笔待审核,应该如何处理?
**A**:
- 查看"待审核"总额
- 确保所有待审核的总和 可提现金额
- 按时间顺序逐笔审核
- 或者拒绝超额的申请
### Q3: 审核后余额为0还能再提现吗
**A**:
- 如果有新订单产生佣金可以
- 审核后余额0表示当前所有佣金都将被提走
- 新订单会增加累计佣金从而增加可提现金额
### Q4: 如何处理已拒绝的提现?
**A**:
- 拒绝时余额自动返还
- 用户可以在小程序重新申请
- "审核后余额"会恢复
## 相关文件
- `app/admin/withdrawals/page.tsx` - 前端页面
- `app/api/admin/withdrawals/route.ts` - 后端API
- `app/api/withdraw/route.ts` - 用户提现接口
- `miniprogram/pages/referral/referral.js` - 小程序提现页面
## 总结
这次数据对接实现了
### 功能增强
- 显示用户的完整佣金信息
- 计算审核后余额
- 风险警告超额提现
- 数据实时计算确保准确
### 管理便利
- 一眼看清用户的资金状况
- 提前识别超额提现风险
- 批准/拒绝决策有据可依
### 安全保障
- 三层校验前端后端管理端
- 并发提现检测
- 超额提现警告
**核心价值**让管理员能够**看到完整的资金流水**做出**明智的审核决策**防止**超额提现风险**。

View File

@@ -1,360 +0,0 @@
# 后台订单显示优化说明
## 📋 优化内容
### 新增显示字段
- ✅ 购买者昵称
- ✅ 购买的书名《一场Soul的创业实验》
- ✅ 购买的章节第X章 第X节
- ✅ 商品类型标签
---
## 🔧 修改的文件
### 1. `/app/api/orders/route.ts` - 订单API
**修改内容**
- JOIN `users` 表获取购买者信息
- 返回 `userNickname``userAvatar` 字段
**新增字段**
```typescript
{
userNickname: string | null, // 购买者昵称
userAvatar: string | null // 购买者头像
}
```
**SQL 查询**
```sql
SELECT o.*, u.nickname as user_nickname, u.avatar as user_avatar
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
ORDER BY o.created_at DESC
```
---
### 2. `/app/admin/page.tsx` - 管理后台首页
**优化内容**
- 最近订单卡片显示购买者昵称
- 显示完整的书名和章节信息
- 优化UI布局增加头像展示
- 改进时间显示格式
**显示效果**
```
┌─────────────────────────────────────────────┐
│ [头像] 张三 · 《一场Soul的创业实验》 │
│ 章节购买 | 02-04 14:30 +¥0.95 │
│ 推荐: ABC123 │
└─────────────────────────────────────────────┘
```
**核心函数**
```typescript
// 格式化商品信息
const formatOrderProduct = (p: any) => {
// 解析 description 字段,返回:
// { title: "第1章 第2节", subtitle: "《一场Soul的创业实验》" }
}
```
---
### 3. `/app/admin/orders/page.tsx` - 订单管理页面
**优化内容**
- 从API读取订单包含购买者昵称
- 显示完整的书名和章节
- 改进搜索功能(支持昵称、手机号、商品名搜索)
- 支持订单号搜索
- 优化状态筛选(兼容 'paid' 和 'completed' 状态)
**表格列**
| 订单号 | 用户 | 商品 | 金额 | 支付方式 | 状态 | 分销佣金 | 下单时间 |
|--------|------|------|------|----------|------|----------|----------|
| MP20260204... | 张三<br>138xxxx | 第1章 第2节<br>《一场Soul...》 | ¥0.95 | 微信支付 | 已完成 | ¥0.86 | 2026-02-04 14:30 |
**核心函数**
```typescript
// 格式化商品信息
const formatProduct = (order: any) => {
return {
name: "第1章 第2节",
type: "《一场Soul的创业实验》"
}
}
```
---
## 📊 商品信息解析逻辑
### 输入数据orders 表)
```javascript
{
productType: "section", // 商品类型
productId: "1-2", // 章节ID
description: "章节购买-1-2", // 商品描述
amount: 0.95 // 金额
}
```
### 解析规则
#### 1. 章节购买
**输入**
```javascript
{
productType: "section",
productId: "1-2",
description: "章节购买-1-2"
}
```
**输出**
```javascript
{
title: "第1章 第2节",
subtitle: "《一场Soul的创业实验》"
}
```
#### 2. 整本购买
**输入**
```javascript
{
productType: "fullbook",
description: "《一场Soul的创业实验》全书"
}
```
**输出**
```javascript
{
title: "《一场Soul的创业实验》",
subtitle: "全书购买"
}
```
#### 3. 找伙伴
**输入**
```javascript
{
productType: "match",
description: "找伙伴匹配"
}
```
**输出**
```javascript
{
title: "找伙伴匹配",
subtitle: "功能服务"
}
```
---
## 🎨 UI 改进
### 主仪表盘 - 最近订单
**旧版**
```
单章 1-2
2026-02-04 14:30:15
邀请码: ABC123
+¥0.95
微信支付
```
**新版**
```
[头像] 张三 · 《一场Soul的创业实验》
章节购买 | 02-04 14:30 +¥0.95
推荐: ABC123 微信
```
**优势**
- ✅ 一目了然看到购买者
- ✅ 清晰显示书名和章节
- ✅ 更紧凑的布局
- ✅ 支持hover高亮
---
### 订单管理页面
**改进点**
1. **用户列** - 显示昵称和手机号
2. **商品列** - 显示书名和章节,带类型标签
3. **搜索** - 支持昵称、手机号、商品名、订单号搜索
4. **筛选** - 支持多种订单状态筛选
5. **兼容性** - 兼容 'paid' 和 'completed' 两种状态
---
## 🔍 搜索功能增强
### 支持的搜索维度
1. **用户维度**
- 购买者昵称
- 购买者手机号
2. **订单维度**
- 订单号orderSn
- 订单ID
3. **商品维度**
- 商品名称(书名、章节)
- 商品描述
### 示例
```javascript
// 搜索 "张三" → 匹配用户昵称
// 搜索 "138" → 匹配手机号
// 搜索 "第1章" → 匹配商品名称
// 搜索 "MP20260204" → 匹配订单号
```
---
## ✅ 测试验证
### 测试场景
#### 1. 主仪表盘 - 最近订单
- [ ] 显示购买者昵称
- [ ] 显示完整书名
- [ ] 显示章节信息
- [ ] 显示推荐人
- [ ] 头像正常显示
#### 2. 订单管理页面
- [ ] 用户列显示昵称和手机号
- [ ] 商品列显示书名和章节
- [ ] 搜索功能正常
- [ ] 筛选功能正常
- [ ] 订单状态正确
#### 3. API 测试
```bash
# 测试订单API
curl http://localhost:3000/api/orders | jq '.orders[0] | {userNickname, description, productType}'
# 预期输出:
{
"userNickname": "张三",
"description": "章节购买-1-2",
"productType": "section"
}
```
---
## 📝 数据库查询说明
### 原查询(无购买者信息)
```sql
SELECT * FROM orders ORDER BY created_at DESC
```
### 新查询JOIN 用户信息)
```sql
SELECT
o.*,
u.nickname as user_nickname,
u.avatar as user_avatar
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
ORDER BY o.created_at DESC
```
**优势**
- 一次查询获取所有必要信息
- 避免前端多次查询
- 提升页面加载速度
---
## 🚀 部署说明
### 无需数据库迁移
- ✅ 只是修改查询逻辑,不改表结构
- ✅ 使用 LEFT JOIN兼容旧数据
### 部署步骤
```bash
# 1. 构建
pnpm build
# 2. 部署
python devlop.py
# 3. 重启服务
# 在宝塔面板重启 PM2
```
---
## 📌 注意事项
### 1. 数据兼容性
- 如果 `user_id` 对应的用户不存在,显示"匿名用户"
- 如果 `description` 为空,使用 fallback 显示
### 2. 性能考虑
- LEFT JOIN 不会影响性能users 表很小)
- 前端只展示最近 5 条订单,查询很快
### 3. 未来扩展
- 可以添加更多商品类型
- 可以添加订单详情弹窗
- 可以支持导出带购买者信息的Excel
---
## ✅ 完成清单
- ✅ 修改 `/api/orders` APIJOIN users
- ✅ 优化主仪表盘"最近订单"卡片
- ✅ 优化订单管理页面表格
- ✅ 增强搜索功能
- ✅ 改进UI布局
- ✅ 创建文档
---
## 📸 效果预览
### 主仪表盘
```
┌─ 最近订单 ──────────────────────────────┐
│ │
│ [Z] 张三 · 《一场Soul的创业实验》 │
│ 章节购买 | 02-04 14:30 +¥0.95 │
│ 推荐: ABC123 │
│ │
│ [L] 李四 · 找伙伴匹配 +¥1.00 │
│ 功能服务 | 02-04 13:15 │
│ │
└──────────────────────────────────────────┘
```
### 订单管理页面
```
订单号 | 用户 | 商品 | 金额 | 状态
----------------|---------------|---------------------|--------|------
MP20260204... | 张三 | 第1章 第2节 | ¥0.95 | 已完成
| 138xxxx | 《一场Soul...》 | |
```
---
**优化完成!后台管理端现在可以清晰显示购买者、书名和章节信息了。**

View File

@@ -1,345 +0,0 @@
# 小程序头像上传优化说明
## 🔧 问题描述
**旧逻辑**
- 小程序换头像时直接保存微信临时图片路径
- 临时路径会过期,导致头像无法显示
- 数据库存储的是微信的临时URL
**问题**
- ❌ 微信临时图片有效期有限(通常几天后失效)
- ❌ 头像无法长期显示
- ❌ 用户体验差
---
## ✅ 解决方案
**新逻辑**
1. 用户选择头像后,先上传图片到自己的服务器
2. 服务器保存图片到 `public/assets/avatars/` 目录
3. 返回永久可访问的URL
4. 将永久URL保存到数据库
**优势**
- ✅ 图片永久保存在自己服务器
- ✅ 头像不会失效
- ✅ 完全可控
---
## 🔧 修改的文件
### 1. `miniprogram/pages/my/my.js`
**修改函数**: `onChooseAvatar()`
**旧逻辑**
```javascript
// 直接使用临时路径
const avatarUrl = e.detail.avatarUrl
userInfo.avatar = avatarUrl
// 保存临时路径到数据库
await app.request('/api/user/update', {
data: { userId: userInfo.id, avatar: avatarUrl }
})
```
**新逻辑**
```javascript
// 1. 上传到服务器
const uploadRes = await wx.uploadFile({
url: app.globalData.baseUrl + '/api/upload',
filePath: tempAvatarUrl,
name: 'file',
formData: { folder: 'avatars' }
})
// 2. 获取永久URL
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
// 3. 保存永久URL到数据库
await app.request('/api/user/update', {
data: { userId: userInfo.id, avatar: avatarUrl }
})
```
---
### 2. `miniprogram/pages/settings/settings.js`
**修改函数**: `getWechatAvatar()`
**功能**: 使用 `wx.getUserProfile` 获取微信头像
**修改内容**: 与 `my.js` 相同先上传图片再保存URL
---
## 📁 服务器存储路径
### 图片保存位置
```
public/assets/avatars/
├── 1738756123456_abc123.jpg
├── 1738756234567_def456.png
└── ...
```
### 访问URL格式
```
https://soul.quwanzhi.com/assets/avatars/1738756123456_abc123.jpg
```
### 数据库存储
```javascript
// users 表
{
id: "user_123",
avatar: "https://soul.quwanzhi.com/assets/avatars/1738756123456_abc123.jpg"
}
```
---
## 🔄 上传流程
### 完整流程图
```
用户点击更换头像
微信弹出头像选择器chooseAvatar / getUserProfile
获取临时图片路径tempAvatarUrl
调用 wx.uploadFile 上传到服务器
服务器保存图片到 public/assets/avatars/
服务器返回永久URL
更新本地 userInfoapp.globalData / storage
调用 /api/user/update 保存到数据库
完成!
```
### 代码实现
```javascript
// 1. 获取临时头像
const tempAvatarUrl = e.detail.avatarUrl
// 2. 上传到服务器
const uploadRes = await new Promise((resolve, reject) => {
wx.uploadFile({
url: app.globalData.baseUrl + '/api/upload',
filePath: tempAvatarUrl,
name: 'file',
formData: {
folder: 'avatars' // 保存到 avatars 文件夹
},
success: (res) => {
const data = JSON.parse(res.data)
if (data.success) {
resolve(data) // { success: true, data: { url: '/assets/avatars/xxx.jpg' } }
} else {
reject(new Error(data.error))
}
},
fail: reject
})
})
// 3. 拼接完整URL
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
// 结果: https://soul.quwanzhi.com/assets/avatars/xxx.jpg
// 4. 保存到数据库
await app.request('/api/user/update', {
method: 'POST',
data: { userId: userInfo.id, avatar: avatarUrl }
})
```
---
## 🔍 错误处理
### 1. 上传失败
```javascript
try {
// ... 上传逻辑
} catch (e) {
wx.showToast({
title: e.message || '上传失败,请重试',
icon: 'none'
})
}
```
### 2. 网络错误
- 自动重试机制(可选)
- 清晰的错误提示
### 3. 文件格式错误
- 服务器会验证文件类型
- 只允许 JPG、PNG、GIF、WebP、SVG
- 文件大小限制 5MB
---
## 📊 服务器API
### `/api/upload` - 图片上传接口
**请求方式**: POST (multipart/form-data)
**参数**:
- `file`: 图片文件
- `folder`: 保存文件夹(如 'avatars'
**返回**:
```json
{
"success": true,
"data": {
"url": "/assets/avatars/1738756123456_abc123.jpg",
"fileName": "1738756123456_abc123.jpg",
"size": 45678,
"type": "image/jpeg"
}
}
```
**文件命名规则**:
```javascript
const timestamp = Date.now()
const randomStr = Math.random().toString(36).substring(2, 8)
const fileName = `${timestamp}_${randomStr}.${ext}`
// 例如: 1738756123456_abc123.jpg
```
---
## ✅ 测试清单
### 功能测试
- [ ] 在"我的"页面点击头像,选择图片后成功上传
- [ ] 上传后头像立即显示
- [ ] 刷新页面后头像依然正常显示
- [ ] 在设置页面使用"获取微信头像"功能正常
- [ ] 后台管理页面能正确显示用户头像
### 数据验证
- [ ] 数据库 `users.avatar` 字段保存的是完整URL
- [ ] URL格式: `https://soul.quwanzhi.com/assets/avatars/xxx.jpg`
- [ ] 不是微信临时路径(不包含 `weixin``tmp`
### 文件验证
- [ ] 服务器 `public/assets/avatars/` 目录存在
- [ ] 上传的图片文件正常保存
- [ ] 文件可通过浏览器直接访问
---
## 🚀 部署步骤
### 1. 确保目录存在
```bash
# 在服务器上创建目录
mkdir -p /www/wwwroot/soul.quwanzhi.com/public/assets/avatars
chmod 755 /www/wwwroot/soul.quwanzhi.com/public/assets/avatars
```
### 2. 部署代码
```bash
# 本地构建
pnpm build
# 部署到服务器
python devlop.py
# 重启PM2
pm2 restart soul
```
### 3. 小程序代码上传
- 在微信开发者工具中上传代码
- 提交审核
- 发布新版本
---
## 📝 注意事项
### 1. 兼容性
- 旧版本用户的头像可能还是微信临时路径
- 建议提示用户重新上传头像
### 2. 存储空间
- 每个头像约 50-200KB
- 10000个用户约 0.5-2GB
- 定期清理无用头像(可选)
### 3. CDN优化可选
- 如果用户量大考虑使用CDN加速
-`public/assets/avatars/` 目录同步到CDN
### 4. 图片压缩(可选)
- 可以在上传时自动压缩图片
- 减少存储空间和加载时间
---
## 🔄 数据迁移(可选)
如果需要迁移旧数据(微信临时路径 → 永久URL
### 方案1: 提示用户重新上传
```javascript
// 在小程序中检查头像URL
if (userInfo.avatar && userInfo.avatar.includes('weixin')) {
// 提示用户重新上传头像
wx.showModal({
title: '头像过期',
content: '请重新上传您的头像',
confirmText: '立即上传'
})
}
```
### 方案2: 自动下载并上传(服务器端)
```javascript
// 在服务器端批量处理
// 1. 查询所有微信临时路径的头像
// 2. 下载图片
// 3. 上传到自己服务器
// 4. 更新数据库
// (需要开发专门的迁移脚本)
```
---
## ✅ 完成状态
- ✅ 修改 `my.js``onChooseAvatar()` 函数
- ✅ 修改 `settings.js``getWechatAvatar()` 函数
- ✅ 使用现有的 `/api/upload` 接口
- ✅ 添加错误处理和日志
- ✅ 创建说明文档
---
## 📚 相关文档
- `后台订单显示优化说明.md` - 后台显示头像相关
- `/api/upload` 接口文档
---
**优化完成!小程序头像将永久保存在自己的服务器上,不会再失效!**

View File

@@ -1,269 +0,0 @@
# 小程序最低提现金额对接说明
## 📋 需求
小程序分销中心的最低提现金额,需要从管理后台的「推广设置」→「提现规则」→「最低提现金额」动态获取。
---
## ✅ 已完成的对接
### 1. 后端 API 返回最低提现金额
**文件**: `app/api/referral/data/route.ts`
**代码**第34-42行第200行:
```typescript
// 获取分销配置
let distributorShare = DISTRIBUTOR_SHARE
let minWithdrawAmount = 10 // 默认最低提现金额
try {
const config = await getConfig('referral_config')
if (config?.distributorShare) {
distributorShare = config.distributorShare / 100
}
if (config?.minWithdrawAmount) {
minWithdrawAmount = Number(config.minWithdrawAmount)
}
} catch (e) { /* 使用默认配置 */ }
// 返回数据
return NextResponse.json({
data: {
// ... 其他数据 ...
shareRate: Math.round(distributorShare * 100),
minWithdrawAmount, // ← 返回给小程序
}
})
```
**逻辑**:
1.`system_config` 表读取 `referral_config.minWithdrawAmount`
2. 如果读取失败,使用默认值 10
3. 在 API 响应中返回给前端
---
### 2. 小程序接收并保存配置
**文件**: `miniprogram/pages/referral/referral.js`
**初始化**第30行:
```javascript
data: {
minWithdrawAmount: 10, // 最低提现金额(从后端获取)
}
```
**动态更新**第161行:
```javascript
this.setData({
shareRate: realData?.shareRate || 90,
minWithdrawAmount: realData?.minWithdrawAmount || 10, // ← 从API获取
})
```
**逻辑**:
1. 页面加载时初始值为 10
2. 调用 `/api/referral/data` 获取真实配置
3. 使用 `setData` 更新 `minWithdrawAmount`
---
### 3. 小程序 UI 动态显示
**文件**: `miniprogram/pages/referral/referral.wxml`
**提现按钮**第52-54行:
```xml
<view class="withdraw-btn {{pendingEarnings < minWithdrawAmount ? 'btn-disabled' : ''}}" bindtap="handleWithdraw">
{{pendingEarnings < minWithdrawAmount ? '满' + minWithdrawAmount + '元可提现' : '申请提现'}}
</view>
```
**显示效果**:
- **未达到金额**: 按钮显示 "满10元可提现"(灰色禁用)
- **达到金额**: 按钮显示 "申请提现"(绿色可点击)
**动态性**:
- 如果管理后台改为 20 元,按钮会显示 "满20元可提现"
- 如果管理后台改为 5 元,按钮会显示 "满5元可提现"
---
### 4. 提现逻辑验证(新增)
**文件**: `miniprogram/pages/referral/referral.js`
**修改前**第559-565行:
```javascript
async handleWithdraw() {
const pendingEarnings = parseFloat(this.data.pendingEarnings) || 0
if (pendingEarnings <= 0) {
wx.showToast({ title: '暂无可提现收益', icon: 'none' })
return
}
// 直接提现(没有检查最低金额)❌
}
```
**修改后**(已完成):
```javascript
async handleWithdraw() {
const pendingEarnings = parseFloat(this.data.pendingEarnings) || 0
const minWithdrawAmount = this.data.minWithdrawAmount || 10
if (pendingEarnings <= 0) {
wx.showToast({ title: '暂无可提现收益', icon: 'none' })
return
}
// 检查是否达到最低提现金额 ✅
if (pendingEarnings < minWithdrawAmount) {
wx.showToast({
title: `满${minWithdrawAmount}元可提现`,
icon: 'none'
})
return
}
// 确认提现...
}
```
**优势**:
- ✅ 双重验证UI禁用 + 逻辑验证)
- ✅ 防止用户绕过UI直接调用
- ✅ 提示信息也是动态的
---
## 📊 数据流转图
```
管理后台输入
保存到 system_config.referral_config.minWithdrawAmount
后端 API (/api/referral/data) 读取并返回
小程序 loadData() 接收并保存到 this.data.minWithdrawAmount
┌─────────────────────┬─────────────────────┐
│ │ │
WXML 动态显示 JS 逻辑验证
│ │
"满X元可提现" handleWithdraw()
```
---
## 🧪 测试验证
### 测试1: 修改最低提现金额为 20 元
**步骤**:
1. 登录管理后台 `/admin/referral-settings`
2. 将「最低提现金额」改为 **20**
3. 点击「保存配置」
4. 打开小程序分销中心
5. 刷新页面(下拉刷新)
**预期结果**:
- 如果待结算收益 < 20 按钮显示 "满20元可提现"灰色
- 如果待结算收益 20 按钮显示 "申请提现"绿色
- 点击按钮时也会验证是否 20
---
### 测试2: 修改最低提现金额为 5 元
**步骤**:
1. 管理后台改为 **5**
2. 小程序刷新
**预期结果**:
- 按钮显示 "满5元可提现" "申请提现"
---
### 测试3: 尝试绕过验证
**步骤**:
1. 设置最低提现金额为 20
2. 用户只有 10 元待结算
3. 尝试点击提现按钮虽然已禁用
**预期结果**:
- UI 层面按钮已禁用无法点击
- 逻辑层面即使绕过UI也会提示 "满20元可提现"
---
## 📝 对接清单
| 位置 | 功能 | 状态 |
|------|------|------|
| 管理后台 | 配置最低提现金额 | 已完成 |
| 后端 API | 读取配置并返回 | 已完成 |
| 小程序 JS | 接收并保存到 data | 已完成 |
| 小程序 WXML | 动态显示按钮文案 | 已完成 |
| 小程序 JS | 提现时验证金额 | 新增完成 |
---
## 🎯 核心代码位置
### 后端配置读取
- **文件**: `app/api/referral/data/route.ts`
- **行数**: 第34-42行读取配置第200行返回数据
### 小程序数据接收
- **文件**: `miniprogram/pages/referral/referral.js`
- **行数**: 第30行初始化第161行动态更新
### 小程序 UI 显示
- **文件**: `miniprogram/pages/referral/referral.wxml`
- **行数**: 第52-54行提现按钮
### 小程序逻辑验证
- **文件**: `miniprogram/pages/referral/referral.js`
- **行数**: 第558-578行handleWithdraw 函数
---
## ✨ 完成效果
### 管理后台操作
```
1. 进入「推广设置」
2. 修改「最低提现金额」为任意值(如 15
3. 保存配置
```
### 小程序自动响应
```
1. 用户打开分销中心
2. API 自动返回最新的 minWithdrawAmount = 15
3. 按钮显示:
- 待结算 < 15 元 → "满15元可提现"
- 待结算 ≥ 15 元 → "申请提现"
4. 点击提现时,再次验证 ≥ 15 元
```
---
## 🚀 无需额外操作
**好消息**:
- 后端已经在返回 `minWithdrawAmount`
- 小程序已经在使用这个值
- UI 已经动态显示
- 现在又加上了逻辑验证
**只需要部署新代码即可!**
---
**现在最低提现金额已经完全对接,管理后台修改后小程序会自动生效!**

View File

@@ -1,584 +0,0 @@
# 小程序昵称自动填充功能说明
## 📋 需求
在"我的"页面点击修改昵称时,唤醒微信的自动填充功能,用户可以一键使用微信昵称。
---
## ✅ 实现方案
使用微信官方推荐的 `<input type="nickname">` 组件,支持自动填充微信昵称。
---
## 🔧 实现细节
### 1. 添加昵称输入弹窗
**文件**: `miniprogram/pages/my/my.wxml`
**新增代码**:
```xml
<!-- 修改昵称弹窗 -->
<view class="modal-overlay" wx:if="{{showNicknameModal}}" bindtap="closeNicknameModal">
<view class="modal-content nickname-modal" catchtap="stopPropagation">
<view class="modal-close" bindtap="closeNicknameModal"></view>
<view class="modal-header">
<text class="modal-icon">✏️</text>
<text class="modal-title">修改昵称</text>
</view>
<view class="nickname-input-wrap">
<input
class="nickname-input"
type="nickname"
value="{{editingNickname}}"
placeholder="点击输入昵称"
bindchange="onNicknameChange"
bindinput="onNicknameInput"
maxlength="20"
/>
<text class="input-tip">微信用户可点击自动填充昵称</text>
</view>
<view class="modal-actions">
<view class="modal-btn modal-btn-cancel" bindtap="closeNicknameModal">取消</view>
<view class="modal-btn modal-btn-confirm" bindtap="confirmNickname">确定</view>
</view>
</view>
</view>
```
**关键点**:
- `type="nickname"` - 启用微信昵称自动填充 ✅
- `bindchange="onNicknameChange"` - 监听自动填充事件 ✅
- `bindinput="onNicknameInput"` - 监听手动输入事件 ✅
- `maxlength="20"` - 限制昵称长度 ✅
---
### 2. 修改 JS 逻辑
**文件**: `miniprogram/pages/my/my.js`
#### 2.1 添加数据字段
```javascript
data: {
showNicknameModal: false, // 控制弹窗显示
editingNickname: '' // 正在编辑的昵称
}
```
#### 2.2 修改 editNickname 函数
**修改前**(使用系统弹窗):
```javascript
editNickname() {
wx.showModal({
title: '修改昵称',
editable: true,
placeholderText: '请输入昵称',
success: async (res) => {
// ... 处理逻辑
}
})
}
```
**修改后**(使用自定义弹窗):
```javascript
// 打开昵称修改弹窗
editNickname() {
this.setData({
showNicknameModal: true,
editingNickname: this.data.userInfo?.nickname || ''
})
}
// 关闭昵称弹窗
closeNicknameModal() {
this.setData({
showNicknameModal: false,
editingNickname: ''
})
}
// 昵称输入实时更新
onNicknameInput(e) {
this.setData({
editingNickname: e.detail.value
})
}
// 昵称变化(微信自动填充时触发)
onNicknameChange(e) {
console.log('[My] 昵称已自动填充:', e.detail.value)
this.setData({
editingNickname: e.detail.value
})
}
// 确认修改昵称
async confirmNickname() {
const newNickname = this.data.editingNickname.trim()
if (!newNickname) {
wx.showToast({ title: '昵称不能为空', icon: 'none' })
return
}
if (newNickname.length < 1 || newNickname.length > 20) {
wx.showToast({ title: '昵称1-20个字符', icon: 'none' })
return
}
// 关闭弹窗
this.closeNicknameModal()
// 显示加载
wx.showLoading({ title: '更新中...' })
try {
// 更新本地
const userInfo = this.data.userInfo
userInfo.nickname = newNickname
this.setData({ userInfo })
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
// 同步到服务器
await app.request('/api/user/update', {
method: 'POST',
data: { userId: userInfo.id, nickname: newNickname }
})
wx.hideLoading()
wx.showToast({ title: '昵称已更新', icon: 'success' })
} catch (e) {
wx.hideLoading()
console.error('[My] 更新昵称失败:', e)
wx.showToast({ title: '更新失败', icon: 'none' })
}
}
```
---
### 3. 添加样式
**文件**: `miniprogram/pages/my/my.wxss`
**新增样式**:
```css
/* 修改昵称弹窗 */
.nickname-modal {
width: 600rpx;
max-width: 90%;
}
.modal-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 40rpx;
}
.modal-icon {
font-size: 60rpx;
margin-bottom: 16rpx;
}
.modal-title {
font-size: 32rpx;
color: #ffffff;
font-weight: 600;
}
.nickname-input-wrap {
margin-bottom: 40rpx;
}
.nickname-input {
width: 100%;
height: 88rpx;
padding: 0 24rpx;
background: rgba(255, 255, 255, 0.05);
border: 2rpx solid rgba(56, 189, 172, 0.3);
border-radius: 12rpx;
font-size: 28rpx;
color: #ffffff;
box-sizing: border-box;
}
.input-tip {
display: block;
margin-top: 12rpx;
font-size: 22rpx;
color: rgba(56, 189, 172, 0.6);
text-align: center;
}
.modal-actions {
display: flex;
gap: 20rpx;
}
.modal-btn {
flex: 1;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12rpx;
font-size: 28rpx;
font-weight: 500;
}
.modal-btn-cancel {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.5);
border: 2rpx solid rgba(255, 255, 255, 0.1);
}
.modal-btn-confirm {
background: linear-gradient(135deg, #38bdac 0%, #2da396 100%);
color: #ffffff;
box-shadow: 0 8rpx 24rpx rgba(56, 189, 172, 0.3);
}
```
---
## 🎯 使用流程
### 用户操作步骤
1. **打开"我的"页面**
2. **点击昵称**(或点击"点击设置昵称"
3. **弹出昵称修改弹窗**
4. **点击输入框**
- 微信用户:会自动弹出"使用微信昵称"选项
- 非微信用户:手动输入昵称
5. **选择"使用微信昵称"****手动输入**
6. **点击"确定"**
7. **昵称更新成功**
---
## 📱 效果展示
### 自动填充流程
```
点击昵称
显示弹窗(输入框为空或显示当前昵称)
点击输入框
微信弹出选择:
┌─────────────────────┐
│ 使用微信昵称 │
│ [张三] │ ← 点击即可自动填充
├─────────────────────┤
│ 手动输入 │ ← 或者自己输入
└─────────────────────┘
自动填充到输入框(触发 onNicknameChange
点击"确定"
保存到本地 + 同步到服务器
```
---
## 🆚 对比旧版
### 旧版(系统弹窗)❌
```javascript
wx.showModal({
editable: true,
placeholderText: '请输入昵称'
})
```
**问题**:
- ❌ 样式单调,无法自定义
- ❌ 不支持微信昵称自动填充
- ❌ 用户体验较差
---
### 新版(自定义弹窗)✅
```xml
<input type="nickname" />
```
**优势**:
- ✅ 支持微信昵称自动填充
- ✅ 样式可自定义符合APP风格
- ✅ 用户体验更好
- ✅ 微信官方推荐方式
---
## 🧪 测试验证
### 测试1: 微信用户自动填充
**步骤**:
1. 使用微信登录小程序
2. 进入"我的"页面
3. 点击昵称
4. 弹出昵称修改弹窗
5. 点击输入框
6. 应该看到"使用微信昵称"选项
7. 点击"使用微信昵称"
8. 昵称自动填充到输入框
9. 点击"确定"
10. 昵称更新成功
**预期结果**: ✅ 一键使用微信昵称
---
### 测试2: 手动输入昵称
**步骤**:
1. 点击昵称
2. 弹出弹窗
3. 点击输入框
4. 手动输入"Soul创业者"
5. 点击"确定"
**预期结果**: ✅ 手动输入的昵称保存成功
---
### 测试3: 昵称验证
**步骤**:
1. 输入空昵称 → 提示"昵称不能为空"
2. 输入超长昵称(>20字符 → 提示"昵称1-20个字符"
3. 输入正常昵称 → 保存成功
**预期结果**: ✅ 验证逻辑正常
---
## 📦 修改文件清单
| 文件 | 修改内容 | 状态 |
|------|----------|------|
| `miniprogram/pages/my/my.wxml` | 添加昵称输入弹窗 | ✅ |
| `miniprogram/pages/my/my.js` | 修改 editNickname 逻辑 | ✅ |
| `miniprogram/pages/my/my.wxss` | 添加弹窗样式 | ✅ |
---
## 🎨 UI 设计
### 弹窗外观
```
┌────────────────────────────┐
│ ✕ │ ← 关闭按钮
│ │
│ ✏️ │ ← 图标
│ 修改昵称 │ ← 标题
│ │
│ ┌──────────────────────┐ │
│ │ 点击输入昵称 │ │ ← 输入框(支持自动填充)
│ └──────────────────────┘ │
│ 微信用户可点击自动填充昵称 │ ← 提示文字
│ │
│ ┌────────┐ ┌──────────┐ │
│ │ 取消 │ │ 确定 │ │ ← 操作按钮
│ └────────┘ └──────────┘ │
└────────────────────────────┘
```
### 颜色方案
- 背景:深色半透明遮罩
- 弹窗渐变背景与APP整体风格一致
- 输入框:品牌色边框 `rgba(56, 189, 172, 0.3)`
- 确定按钮:品牌渐变 `#38bdac → #2da396`
- 取消按钮:灰色透明
---
## 🔍 核心技术点
### 1. `type="nickname"` 属性
**作用**: 启用微信昵称自动填充功能
**触发时机**: 用户点击输入框时
**用户体验**:
- iOS: 弹出键盘上方显示"使用微信昵称"选项
- Android: 显示快捷选择弹窗
---
### 2. `bindchange` vs `bindinput`
**bindchange**:
- 当用户点击"使用微信昵称"时触发
- 自动填充完成时触发
- `e.detail.value` 包含完整的微信昵称
**bindinput**:
- 用户手动输入时实时触发
- 每输入一个字符都会触发
- `e.detail.value` 包含当前输入值
**两者配合**: 完美支持自动填充和手动输入 ✅
---
### 3. 数据流转
```
用户点击昵称
this.editNickname()
显示弹窗 (showNicknameModal = true)
用户点击输入框
微信弹出选择
选择"使用微信昵称"
onNicknameChange() 触发
editingNickname 更新为微信昵称
用户点击"确定"
confirmNickname() 执行
保存到本地 + 同步服务器
显示成功提示
```
---
## 🔐 安全性
### 1. 输入验证
```javascript
if (!newNickname) {
wx.showToast({ title: '昵称不能为空', icon: 'none' })
return
}
if (newNickname.length < 1 || newNickname.length > 20) {
wx.showToast({ title: '昵称1-20个字符', icon: 'none' })
return
}
```
---
### 2. 数据同步
```javascript
// 1. 先更新本地(立即响应)
userInfo.nickname = newNickname
this.setData({ userInfo })
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
// 2. 再同步到服务器(异步)
await app.request('/api/user/update', {
method: 'POST',
data: { userId: userInfo.id, nickname: newNickname }
})
```
**优势**:
- ✅ 用户体验流畅先更新UI
- ✅ 数据持久化(同步到服务器)
- ✅ 离线友好(失败不影响本地显示)
---
## 🎁 额外优化
### 1. 弹窗动画
复用现有的 `.modal-overlay``.modal-content` 样式,自带淡入淡出效果。
---
### 2. 友好提示
```xml
<text class="input-tip">微信用户可点击自动填充昵称</text>
```
让用户知道可以使用自动填充功能。
---
### 3. 错误处理
```javascript
try {
// 同步到服务器
await app.request(...)
} catch (e) {
console.error('[My] 更新昵称失败:', e)
wx.showToast({ title: '更新失败', icon: 'none' })
}
```
即使服务器同步失败,本地仍然更新成功,不影响用户体验。
---
## 📱 兼容性
### 微信版本要求
`<input type="nickname">` 支持的最低版本:
- **基础库**: 2.21.2
- **微信版本**: 8.0.16
**兼容处理**:
- 新版微信:显示"使用微信昵称"选项 ✅
- 旧版微信:降级为普通输入框(仍可手动输入)✅
---
## ✨ 完成效果
### 修改前
```
点击昵称 → 系统弹窗 → 手动输入 → 保存
```
### 修改后
```
点击昵称 → 自定义弹窗 →
├─ 点击"使用微信昵称" → 一键填充 ✅
└─ 手动输入 → 保存 ✅
```
---
**现在用户可以一键使用微信昵称了!** 🎉
**相关文件**:
-`miniprogram/pages/my/my.wxml`
-`miniprogram/pages/my/my.js`
-`miniprogram/pages/my/my.wxss`

View File

@@ -1,231 +0,0 @@
# 小程序调整说明 - 新分销逻辑
## ✅ 已完成的调整
### 1. UI修改
**文件**: `miniprogram/pages/referral/referral.wxml`
**修改**: 删除"我的邀请码"卡片
```xml
<!-- ✅ 已删除 -->
<!-- <view class="invite-card">
<text class="invite-title">我的邀请码</text>
<text class="invite-code">{{referralCode}}</text>
</view> -->
```
---
## ✅ 无需调整的部分
### 1. 绑定逻辑app.js
**文件**: `miniprogram/app.js`
**当前逻辑**: ✅ 完全兼容新逻辑
```javascript
// 点击推荐链接时
handleReferralCode(options) {
// 1. 记录访问
this.recordReferralVisit(refCode)
// 2. 保存推荐码
wx.setStorageSync('referral_code', refCode)
// 3. 如果已登录,立即绑定
if (this.globalData.isLoggedIn) {
this.bindReferralCode(refCode) // 调用 /api/referral/bind
}
}
```
**为什么无需调整**
- 小程序只负责调用 `/api/referral/bind`
- 后端API已实现"立即切换"逻辑
- 无论是新绑定、续期还是切换,小程序无需感知
---
### 2. 支付逻辑pages/read/read.js
**文件**: `miniprogram/pages/read/read.js`
**当前逻辑**: ✅ 完全兼容新逻辑
```javascript
// 支付时
const referralCode = wx.getStorageSync('referral_code') || ''
await app.request('/api/miniprogram/pay', {
data: {
amount, // 原价(如 1.00
referralCode: referralCode || undefined
}
})
```
**为什么无需调整**
- 小程序传递原价和推荐码
- 后端自动计算折扣(如 5% off
- 微信支付弹窗会显示折后价(无需小程序干预)
---
### 3. 分销中心数据展示pages/referral/referral.js
**文件**: `miniprogram/pages/referral/referral.js`
**当前逻辑**: ✅ 完全兼容新逻辑
```javascript
// 数据来源
const res = await app.request('/api/referral/data?userId=' + userInfo.id)
// 展示数据
setData({
bindingCount, // 绑定中的人数
paidCount, // 已付款的人数
activeBindings, // 绑定中的用户列表
convertedBindings, // 已付款的用户列表
expiredBindings // 已过期的用户列表
})
```
**为什么无需调整**
- 后端API `/api/referral/data` 已适配新逻辑
- `convertedBindings` 现在返回 `status = 'active' AND purchase_count > 0`
- 小程序只是展示后端数据,无需改代码
---
## 🆕 可选增强功能
### 建议1: 显示购买次数
**当前显示**
```
用户A +¥0.90
已付款
```
**增强后显示**
```
用户A +¥1.80
已购2次
```
**实现方式**(可选):
#### 修改 WXML
```xml
<!-- 在 pages/referral/referral.wxml 的用户状态区域 -->
<view class="user-status">
<block wx:if="{{item.status === 'converted'}}">
<text class="status-amount">+¥{{item.commission}}</text>
<!-- 新增:显示购买次数 -->
<text class="status-order">已购{{item.purchaseCount || 1}}次</text>
</block>
</view>
```
#### 修改 JS
```javascript
// 在 pages/referral/referral.js 的 formatUser 函数中
const formatUser = (user, type) => {
return {
id: user.id,
nickname: user.nickname,
commission: (user.commission || 0).toFixed(2),
purchaseCount: user.purchaseCount || 0, // 新增
// ...
}
}
```
**是否需要**:根据产品需求决定
---
### 建议2: 显示"切换提示"
当用户点击新的推荐链接时,弹窗提示:
```javascript
// 在 app.js 的 bindReferralCode 函数中
async bindReferralCode(refCode) {
const res = await this.request('/api/referral/bind', {
method: 'POST',
data: { userId, referralCode: refCode }
})
// 新增:切换提示
if (res.success && res.action === 'switch') {
wx.showToast({
title: '已切换推荐人',
icon: 'success'
})
}
}
```
**是否需要**:可以让用户明确知道绑定关系已切换
---
### 建议3: 价格显示优化
**当前**:章节价格固定显示 1.00 元
**优化**:根据是否有推荐码,显示折后价
```javascript
// 在 pages/read/read.js 的 onLoad 或 onShow 中
async loadPriceWithDiscount() {
const referralCode = wx.getStorageSync('referral_code')
let displayPrice = this.data.section.price // 原价 1.00
if (referralCode) {
// 从配置获取折扣
const res = await app.request('/api/db/config?key=referral_config')
if (res.success && res.config?.userDiscount) {
const discount = res.config.userDiscount / 100
displayPrice = this.data.section.price * (1 - discount)
}
}
this.setData({
displayPrice: displayPrice.toFixed(2),
hasDiscount: referralCode ? true : false
})
}
```
**WXML显示**
```xml
<view class="price">
<text wx:if="{{hasDiscount}}" class="original-price">¥1.00</text>
<text class="current-price">¥{{displayPrice}}</text>
<text wx:if="{{hasDiscount}}" class="discount-tag">推荐优惠</text>
</view>
```
**是否需要**:可以让用户看到优惠,提升转化率
---
## 📋 小程序调整总结
### 必须调整(已完成)
- ✅ 删除"我的邀请码"卡片
### 无需调整(后端已兼容)
- ✅ 绑定逻辑app.js
- ✅ 支付逻辑pages/read/read.js
- ✅ 分销中心展示pages/referral/referral.js
### 可选增强(看产品需求)
- ⏸️ 显示购买次数
- ⏸️ 显示切换提示
- ⏸️ 显示折后价格
---
## ✅ 结论
**小程序端只需要已完成的1处修改删除邀请码卡片其他功能都通过后端API自动适配新逻辑无需额外调整**
如果你想要可选增强功能,告诉我具体要加哪个,我来帮你实现。

View File

@@ -1,369 +0,0 @@
# 推广设置功能 - 完整修复清单
## 修复概述
为了确保后台「推广设置」页面的配置能正确应用到整个分销流程,我们修复了 **3 个关键 bug**,并创建了新的管理页面。
## ✅ 已完成的修改
### 1. 创建管理页面入口
**文件**: `app/admin/layout.tsx`
**修改内容**: 在侧边栏菜单中增加「推广设置」入口
```typescript
{ icon: CreditCard, label: "推广设置", href: "/admin/referral-settings" },
```
**位置**: 「交易中心」和「系统设置」之间
---
### 2. 创建推广设置页面
**文件**: `app/admin/referral-settings/page.tsx` (新建)
**功能**:
- 配置「好友优惠」(userDiscount) - 百分比
- 配置「推广者分成」(distributorShare) - 百分比,带滑块
- 配置「绑定有效期」(bindingDays) - 天数
- 配置「最低提现金额」(minWithdrawAmount) - 元
- 配置「自动提现开关」(enableAutoWithdraw) - 布尔值 (预留)
**关键特性**:
- 保存时强制类型转换(确保所有数字字段是 `Number` 类型)
- 加载时有默认值保护
- 保存成功后有详细提示
```typescript
const safeConfig = {
distributorShare: Number(config.distributorShare) || 0,
minWithdrawAmount: Number(config.minWithdrawAmount) || 0,
bindingDays: Number(config.bindingDays) || 0,
userDiscount: Number(config.userDiscount) || 0,
enableAutoWithdraw: Boolean(config.enableAutoWithdraw),
}
```
---
### 3. 修复绑定 API 硬编码问题 ⚠️
**文件**: `app/api/referral/bind/route.ts`
**Bug**: 使用硬编码 `BINDING_DAYS = 30`,不读取配置
**修复**:
```typescript
// 修复前
const BINDING_DAYS = 30
const expiryDate = new Date()
expiryDate.setDate(expiryDate.getDate() + BINDING_DAYS)
// 修复后
const DEFAULT_BINDING_DAYS = 30
let bindingDays = DEFAULT_BINDING_DAYS
try {
const config = await getConfig('referral_config')
if (config?.bindingDays) {
bindingDays = Number(config.bindingDays)
}
} catch (e) {
console.warn('[Referral Bind] 读取配置失败,使用默认值', DEFAULT_BINDING_DAYS)
}
const expiryDate = new Date()
expiryDate.setDate(expiryDate.getDate() + bindingDays)
```
**影响**:
- ✅ 新用户绑定关系的过期时间会使用后台配置的天数
- ✅ 支持动态调整绑定期(如改为 60 天)
---
### 4. 修复提现 API 缺少门槛检查 ⚠️
**文件**: `app/api/withdraw/route.ts`
**Bug**: 没有检查最低提现门槛,只检查了 `amount > 0`
**修复**:
```typescript
// 导入 getConfig
import { query, getConfig } from '@/lib/db'
// 在 POST 函数中添加检查
let minWithdrawAmount = 10 // 默认值
try {
const config = await getConfig('referral_config')
if (config?.minWithdrawAmount) {
minWithdrawAmount = Number(config.minWithdrawAmount)
}
} catch (e) {
console.warn('[Withdraw] 读取配置失败,使用默认值 10 元')
}
// 检查最低提现门槛
if (amount < minWithdrawAmount) {
return NextResponse.json({
success: false,
message: `最低提现金额为 ¥${minWithdrawAmount},当前 ¥${amount}`
}, { status: 400 })
}
```
**影响**:
- ✅ 用户提现时会校验后台配置的最低门槛
- ✅ 防止低于门槛的提现请求
---
### 5. 后端 API 验证
已确认以下 API **正确读取** `referral_config`
#### 5.1 支付回调 - 佣金计算
**文件**: `app/api/miniprogram/pay/notify/route.ts`
```typescript
let distributorShare = DEFAULT_DISTRIBUTOR_SHARE
const config = await getConfig('referral_config')
if (config?.distributorShare) {
distributorShare = config.distributorShare / 100 // 90 → 0.9
}
const commission = Math.round(amount * distributorShare * 100) / 100
```
**已验证正确**
#### 5.2 推广数据 API
**文件**: `app/api/referral/data/route.ts`
```typescript
let distributorShare = DISTRIBUTOR_SHARE
const config = await getConfig('referral_config')
if (config?.distributorShare) {
distributorShare = config.distributorShare / 100 // 用于展示
}
```
**已验证正确**
---
## 配置字段说明
### 数据库表: `system_config`
- **config_key**: `referral_config`
- **config_value**: JSON 字符串
### JSON 结构:
```json
{
"distributorShare": 90, // 推广者分成百分比(存 90计算时除以 100
"minWithdrawAmount": 10, // 最低提现金额(元)
"bindingDays": 30, // 绑定有效期(天)
"userDiscount": 5, // 好友优惠百分比(预留字段)
"enableAutoWithdraw": false // 自动提现开关(预留字段)
}
```
**注意**:
- `distributorShare` 在数据库存的是百分比数字(如 90使用时需除以 1000.9
- 所有字段必须是 **数字类型**,不能是字符串 `"90"`
---
## 完整业务流程
### 1. 用户通过推广链接注册
**API**: `/api/referral/bind`
**读取配置**: `bindingDays`
**行为**: 创建绑定关系,过期时间 = 当前时间 + bindingDays 天
### 2. 绑定用户下单支付
**API**: `/api/miniprogram/pay/notify`
**读取配置**: `distributorShare`
**行为**: 计算佣金 = 订单金额 × (distributorShare / 100),写入 `referral_bindings`
### 3. 推广者查看收益
**API**: `/api/referral/data`
**读取配置**: `distributorShare`
**行为**: 展示推广规则卡片,显示当前分成比例
### 4. 推广者申请提现
**API**: `/api/withdraw`
**读取配置**: `minWithdrawAmount`
**行为**:
- 检查提现金额 >= minWithdrawAmount
- 创建提现记录
### 5. 管理员审核提现
**API**: `/api/admin/withdrawals`
**读取配置**: 不需要
**行为**: 更新提现状态为 `completed``rejected`
---
## 测试验证步骤
### 验证 1: 绑定天数动态生效
1. 后台设置「绑定有效期」为 **60 天**,保存
2. 小程序新用户通过推广链接注册
3. 数据库查询:
```sql
SELECT expiry_date FROM referral_bindings WHERE referee_id = '新用户ID' ORDER BY created_at DESC LIMIT 1;
```
4. **预期**: `expiry_date` = 当前时间 + **60 天**
### 验证 2: 佣金比例动态生效
1. 后台设置「推广者分成」为 **85%**,保存
2. 已绑定用户购买 100 元订单
3. 数据库查询:
```sql
SELECT commission FROM referral_bindings WHERE status = 'converted' ORDER BY created_at DESC LIMIT 1;
```
4. **预期**: `commission` = **85.00**
### 验证 3: 提现门槛动态生效
1. 后台设置「最低提现金额」为 **50 元**,保存
2. 用户尝试提现 **30 元**
3. **预期**: 返回错误「最低提现金额为 ¥50当前 ¥30」
---
## 部署清单
### 1. 代码部署
```bash
# 本地构建
pnpm build
# 上传到服务器
python devlop.py
# 重启 PM2
pm2 restart soul
```
### 2. 数据库检查
确保 `system_config` 表存在 `referral_config` 配置:
```sql
SELECT * FROM system_config WHERE config_key = 'referral_config';
```
如果不存在,插入默认配置:
```sql
INSERT INTO system_config (config_key, config_value, description) VALUES (
'referral_config',
'{"distributorShare":90,"minWithdrawAmount":10,"bindingDays":30,"userDiscount":5,"enableAutoWithdraw":false}',
'分销 / 推广规则配置'
);
```
### 3. 清理缓存
- 重启 Node.js 服务
- 清除前端缓存(刷新浏览器 Ctrl+Shift+R
- 删除微信小程序缓存(开发者工具 -> 清除缓存)
---
## 潜在风险
### 风险 1: 配置读取失败
**场景**: 数据库连接异常或配置格式错误
**保护措施**: 所有读取配置的地方都有默认值 fallback
```typescript
try {
const config = await getConfig('referral_config')
if (config?.distributorShare) {
distributorShare = config.distributorShare / 100
}
} catch (e) {
// 使用默认配置 DEFAULT_DISTRIBUTOR_SHARE
}
```
### 风险 2: 历史订单佣金
**场景**: 修改配置后,历史订单的佣金会变吗?
**回答**: **不会**。已结算的佣金存在 `referral_bindings` 表的 `commission` 字段,不会因配置修改而变化。只影响 **新订单**
### 风险 3: 类型错误
**场景**: 前端输入框可能返回字符串 `"90"` 而不是数字 `90`
**保护措施**: 管理页面保存时强制类型转换
```typescript
const safeConfig = {
distributorShare: Number(config.distributorShare) || 0,
// ...
}
```
---
## 性能优化建议
### 当前实现
每次绑定/支付/提现都会查询一次 `system_config`
### 优化方案 (可选)
增加 Redis 缓存:
```typescript
// 伪代码
const cachedConfig = await redis.get('referral_config')
if (cachedConfig) {
return JSON.parse(cachedConfig)
}
const config = await getConfig('referral_config')
await redis.set('referral_config', JSON.stringify(config), 'EX', 60) // TTL 60s
return config
```
**收益**: 减少数据库查询QPS 可提升 10-20 倍
**成本**: 需要部署 Redis配置变更有最多 60 秒延迟
---
## 遗留问题
### userDiscount 字段未应用
**状态**: ✅ 已定义,❌ 未应用
**说明**: `userDiscount` (好友优惠) 目前只存在配置中,但订单价格计算逻辑中没有实际使用。
**影响**: 修改这个值 **不会** 影响实际订单价格
**建议**: 如需启用,需在订单创建 API 中读取此配置并应用折扣
### enableAutoWithdraw 字段未应用
**状态**: ✅ 已定义,❌ 未实现
**说明**: 自动提现功能需结合定时任务cron job和微信商家转账 API
**影响**: 修改这个开关 **不会** 触发任何行为
**建议**: 后续实现定时任务模块时读取此配置
---
## FAQ
### Q1: 修改配置后需要重启服务吗?
**A**: **不需要**。每次请求都会动态读取数据库配置。
### Q2: 小程序展示的规则和后台设置不一致?
**A**: 可能原因:
1. 小程序缓存未清除 - 重新编译上传小程序
2. API 未正确读取配置 - 检查 PM2 日志
3. 前端硬编码了文案 - 检查小程序代码
### Q3: 测试环境如何验证?
**A**: 使用测试数据库,修改配置后用测试账号走完整流程(绑定→下单→提现)
### Q4: 如何回滚配置?
**A**: 执行 SQL
```sql
UPDATE system_config
SET config_value = '{"distributorShare":90,"minWithdrawAmount":10,"bindingDays":30,"userDiscount":5,"enableAutoWithdraw":false}'
WHERE config_key = 'referral_config';
```
---
## 总结
**3 个 bug 已修复**
1. 绑定 API 读取配置的 `bindingDays`
2. 提现 API 检查 `minWithdrawAmount`
3. 管理页面强制类型转换
**5 个 API 已验证正确**
1. `/api/referral/bind` - 绑定关系
2. `/api/miniprogram/pay/notify` - 佣金计算
3. `/api/referral/data` - 推广数据
4. `/api/withdraw` - 提现申请
5. `/api/admin/withdrawals` - 提现审核
**整个分销流程已打通**,后台配置会实时生效!

View File

@@ -1,148 +0,0 @@
# 推广设置页面 - 完整性测试清单
## 前置条件
- ✅ 已创建 `app/admin/referral-settings/page.tsx`
- ✅ 已修改 `app/admin/layout.tsx` 添加菜单入口
- ✅ 已修复 `app/api/referral/bind/route.ts` 读取 `bindingDays`
## 功能验证(按顺序测试)
### 1. 页面访问测试
- [ ] 访问 `https://soul.quwanzhi.com/admin/referral-settings` 页面正常加载
- [ ] 左侧菜单显示「推广设置」入口
- [ ] 点击菜单可正常跳转,高亮状态正确
### 2. 配置加载测试
- [ ] 页面打开时自动从 `system_config` 表加载现有配置
- [ ] 若无配置,显示默认值:
- 好友优惠5%
- 推广者分成90%
- 绑定有效期30天
- 最低提现金额10元
- 自动提现:关闭
### 3. 表单输入验证
- [ ] 修改「好友优惠」为 10输入框显示正确
- [ ] 拖动「推广者分成」滑块,数值同步更新
- [ ] 输入「绑定有效期」为 60输入框显示正确
- [ ] 修改「最低提现金额」为 50输入框显示正确
- [ ] 切换「自动提现」开关,状态正确
### 4. 配置保存测试
- [ ] 点击「保存配置」按钮
- [ ] 弹出成功提示:「✅ 分销配置已保存成功!」
- [ ] 刷新页面后,配置仍然是刚才保存的值
### 5. 数据库验证
在数据库执行以下查询:
```sql
SELECT config_key, config_value, description
FROM system_config
WHERE config_key = 'referral_config';
```
**预期结果**
```json
{
"distributorShare": 90,
"minWithdrawAmount": 10,
"bindingDays": 30,
"userDiscount": 5,
"enableAutoWithdraw": false
}
```
所有字段都应该是 **数字类型**(不是字符串 "90"
### 6. 业务流程验证
#### 6.1 绑定关系测试
1. 修改「绑定有效期」为 **60 天**,保存
2. 在小程序中让一个新用户通过推广链接进入并登录
3. 查询数据库:
```sql
SELECT referee_id, referrer_id, expiry_date, status
FROM referral_bindings
WHERE referee_id = '新用户ID'
ORDER BY created_at DESC LIMIT 1;
```
4. **验证**`expiry_date` 应该是当前时间 + **60 天**(不是硬编码的 30 天)
#### 6.2 佣金计算测试
1. 修改「推广者分成」为 **85%**,保存
2. 让已绑定的用户在小程序购买 100 元的订单
3. 等待支付成功回调
4. 查询数据库:
```sql
SELECT user_id, earnings, pending_earnings
FROM users
WHERE user_id = '推广者ID';
```
5. **验证**`pending_earnings` 应该增加 **85 元**(不是 90 元)
#### 6.3 提现门槛测试
1. 修改「最低提现金额」为 **50 元**,保存
2. 刷新小程序「推广中心」页面
3. **验证**:推广规则卡片显示「满 **50 元** 可提现」
4. 用 pending_earnings < 50 的账号点击提现应提示可提现金额不足
#### 6.4 小程序展示验证
刷新小程序推广中心页面检查推广规则卡片
- [ ] 好友优惠显示与后台设置一致 5%
- [ ] 你得收益显示与后台设置一致 90%
- [ ] 绑定期显示与后台设置一致 30
## 关键代码验证点
### 读取 referral_config 的 API
1. `app/api/referral/bind/route.ts` - 读取 `bindingDays`
2. `app/api/miniprogram/pay/notify/route.ts` - 读取 `distributorShare` 并除以 100
3. `app/api/referral/data/route.ts` - 读取 `distributorShare` 并除以 100
### 数据类型保护:
- 前端保存时强制转换为 `Number` 类型
- 后端 `setConfig` 使用 `JSON.stringify` 正确序列化
- 后端 `getConfig` 使用 `JSON.parse` 正确反序列化
## 回滚方案
如果测试发现问题可以执行以下 SQL 恢复默认配置
```sql
UPDATE system_config
SET config_value = '{"distributorShare":90,"minWithdrawAmount":10,"bindingDays":30,"userDiscount":5,"enableAutoWithdraw":false}'
WHERE config_key = 'referral_config';
```
## 常见问题排查
### Q1: 保存后刷新,配置变成了默认值?
**排查**检查数据库 `system_config` 表是否正确写入
### Q2: 小程序显示的比例与后台不一致?
**排查**
1. 小程序端是否有缓存清除缓存重试
2. 检查 API `/api/referral/data` 是否正确读取配置
3. 检查小程序代码是否硬编码了规则文案
### Q3: 新绑定关系的过期时间还是 30 天?
**排查**
1. 确认 `app/api/referral/bind/route.ts` 已正确修改
2. 重启 Node.js 服务`pm2 restart soul`
3. 检查服务器日志是否有 "读取配置失败" 的警告
### Q4: 佣金计算还是用的旧比例?
**排查**
1. 确认订单是在 **修改配置之后** 创建的
2. 历史订单不会重算只影响新订单
3. 检查 `app/api/miniprogram/pay/notify/route.ts` 的日志
## 上线建议
1. **先在测试环境验证**完成上述所有测试用例
2. **备份数据库**上线前导出 `system_config`
3. **灰度发布**先让内部测试账号测试确认无误后全量放开
4. **监控日志**上线后密切关注 PM2 日志搜索 "referral_config" 关键词
## 性能影响
- 每次绑定/支付回调会额外查询 1 `system_config`
- 由于 `config_key` 有索引性能影响可忽略
- 建议后续可增加 Redis 缓存TTL 60秒优化性能

View File

@@ -1,367 +0,0 @@
# 提现卡片数据优化说明
## 一、修改需求
**用户需求**
1. **累计佣金**:显示用户获得的所有佣金总额(包括可提现、待审核、已提现的所有佣金)
2. **待审核金额**:显示当前已发起提现申请但还未审核的金额累计总和(`withdrawals` 表中 `status = 'pending'` 的金额)
3. **可提现金额**:显示可以发起提现的金额(即 `users.pending_earnings`
## 二、数据定义
### 1. 原数据结构
| 字段 | 原定义 | 问题 |
|------|--------|------|
| `users.earnings` | 已结算收益 | 不够直观 |
| `users.pending_earnings` | 待结算收益 | 命名容易误解,实际是可提现金额 |
| `users.withdrawn_earnings` | 已提现金额 | ✅ 正确 |
### 2. 新数据结构
| 字段 | 新定义 | 说明 |
|------|--------|------|
| **累计佣金** (`totalCommission`) | `earnings` + `pending_earnings` + `withdrawn_earnings` | 所有获得的佣金总额 |
| **可提现金额** (`availableEarnings`) | `pending_earnings` | 未申请提现的佣金,可以发起提现 |
| **待审核金额** (`pendingWithdrawAmount`) | `SUM(withdrawals.amount) WHERE status='pending'` | 已发起提现但未审核的金额 |
| **已提现金额** (`withdrawnEarnings`) | `withdrawn_earnings` | 已成功提现的金额 |
### 3. 业务流程
```
用户获得佣金
累计佣金 +X
可提现金额 +X (pending_earnings)
用户发起提现申请
可提现金额 -X (pending_earnings)
待审核金额 +X (withdrawals.status='pending')
管理员审核通过
待审核金额 -X (withdrawals.status='success')
已提现金额 +X (withdrawn_earnings)
累计佣金不变
```
## 三、代码修改
### 1. 后端 API 修改 (`app/api/referral/data/route.ts`)
#### 添加待审核金额查询
```typescript
// 7. 获取待审核提现金额
let pendingWithdrawAmount = 0
try {
const pendingResult = await query(`
SELECT COALESCE(SUM(amount), 0) as pending_amount
FROM withdrawals
WHERE user_id = ? AND status = 'pending'
`, [userId]) as any[]
pendingWithdrawAmount = parseFloat(pendingResult[0]?.pending_amount || 0)
} catch (e) {
console.log('[ReferralData] 获取待审核提现金额失败:', e)
}
```
#### 修改返回数据
```typescript
// === 收益数据 ===
// 累计佣金总额(所有获得的佣金)
totalCommission: Math.round((
(parseFloat(user.earnings) || 0) +
(parseFloat(user.pending_earnings) || 0) +
(parseFloat(user.withdrawn_earnings) || 0)
) * 100) / 100,
// 可提现金额pending_earnings
availableEarnings: parseFloat(user.pending_earnings) || 0,
// 待审核金额(提现申请中的金额)
pendingWithdrawAmount: Math.round(pendingWithdrawAmount * 100) / 100,
// 已提现金额
withdrawnEarnings: parseFloat(user.withdrawn_earnings) || 0,
// 已结算收益(保留兼容)
earnings: parseFloat(user.earnings) || 0,
// 待结算收益(保留兼容)
pendingEarnings: parseFloat(user.pending_earnings) || 0,
```
### 2. 小程序前端修改
#### 数据字段 (`miniprogram/pages/referral/referral.js`)
```javascript
data: {
// === 收益数据 ===
totalCommission: 0, // 累计佣金总额(所有获得的佣金)
availableEarnings: 0, // 可提现金额(未申请提现的佣金)
pendingWithdrawAmount: 0, // 待审核金额(已申请提现但未审核)
withdrawnEarnings: 0, // 已提现金额
earnings: 0, // 已结算收益(保留兼容)
pendingEarnings: 0, // 待结算收益(保留兼容)
shareRate: 90, // 分成比例90%
minWithdrawAmount: 10, // 最低提现金额(从后端获取)
}
```
#### 数据更新逻辑
```javascript
this.setData({
// 收益数据 - 格式化为两位小数
totalCommission: formatMoney(realData?.totalCommission || 0),
availableEarnings: formatMoney(realData?.availableEarnings || 0),
pendingWithdrawAmount: formatMoney(realData?.pendingWithdrawAmount || 0),
withdrawnEarnings: formatMoney(realData?.withdrawnEarnings || 0),
earnings: formatMoney(realData?.earnings || 0),
pendingEarnings: formatMoney(realData?.pendingEarnings || 0),
shareRate: realData?.shareRate || 90,
minWithdrawAmount: realData?.minWithdrawAmount || 10,
})
```
#### 提现逻辑修改
```javascript
async handleWithdraw() {
const availableEarnings = parseFloat(this.data.availableEarnings) || 0
const minWithdrawAmount = this.data.minWithdrawAmount || 10
if (availableEarnings <= 0) {
wx.showToast({ title: '暂无可提现收益', icon: 'none' })
return
}
// 检查是否达到最低提现金额
if (availableEarnings < minWithdrawAmount) {
wx.showToast({
title: `满${minWithdrawAmount}元可提现`,
icon: 'none'
})
return
}
// 确认提现
wx.showModal({
title: '确认提现',
content: `将提现 ¥${availableEarnings.toFixed(2)} 到您的微信零钱`,
confirmText: '立即提现',
success: async (res) => {
if (res.confirm) {
await this.doWithdraw(availableEarnings)
}
}
})
}
```
### 3. UI 界面修改 (`miniprogram/pages/referral/referral.wxml`)
```xml
<!-- 收益卡片 - 对齐 Next.js -->
<view class="earnings-card">
<view class="earnings-bg"></view>
<view class="earnings-main">
<view class="earnings-header">
<view class="earnings-left">
<view class="wallet-icon">
<image class="icon-wallet" src="/assets/icons/wallet.svg" mode="aspectFit"></image>
</view>
<view class="earnings-info">
<text class="earnings-label">累计佣金</text>
<text class="commission-rate">{{shareRate}}% 返利</text>
</view>
</view>
<view class="earnings-right">
<text class="earnings-value">¥{{totalCommission}}</text>
<text class="pending-text">待审核: ¥{{pendingWithdrawAmount}}</text>
</view>
</view>
<view class="withdraw-btn {{availableEarnings < minWithdrawAmount ? 'btn-disabled' : ''}}" bindtap="handleWithdraw">
{{availableEarnings < minWithdrawAmount ? '满' + minWithdrawAmount + '元可提现' : '申请提现 ¥' + availableEarnings}}
</view>
</view>
</view>
```
### 4. 界面变化对比
| 位置 | 原显示 | 新显示 | 说明 |
|------|--------|--------|------|
| 卡片标题 | 累计收益 | 累计佣金 | 更准确的描述 |
| 主金额 | `earnings` | `totalCommission` | 显示所有佣金总和 |
| 副金额标签 | "待结算" | "待审核" | 更明确的状态描述 |
| 副金额 | `pendingEarnings` | `pendingWithdrawAmount` | 显示提现申请中的金额 |
| 按钮文案 | "申请提现" | "申请提现 ¥XX" | 显示可提现金额 |
| 按钮禁用 | `pendingEarnings < minWithdrawAmount` | `availableEarnings < minWithdrawAmount` | 使用可提现金额判断 |
## 四、验证方法
### 1. 数据库检查
```sql
-- 查看用户收益数据
SELECT
id,
nickname,
earnings,
pending_earnings,
withdrawn_earnings,
(earnings + pending_earnings + withdrawn_earnings) as total_commission
FROM users
WHERE id = 'user_xxx';
-- 查看待审核提现金额
SELECT
user_id,
SUM(amount) as pending_amount
FROM withdrawals
WHERE status = 'pending'
GROUP BY user_id;
```
### 2. API 测试
```bash
# 测试接口
curl -X GET "http://localhost:3000/api/referral/data" \
-H "Cookie: auth_token=xxx"
```
**期望返回数据**
```json
{
"success": true,
"data": {
"totalCommission": 100.00, // 累计佣金 = 30 + 50 + 20
"availableEarnings": 50.00, // 可提现 = pending_earnings
"pendingWithdrawAmount": 20.00, // 待审核 = SUM(withdrawals WHERE status='pending')
"withdrawnEarnings": 30.00, // 已提现
"earnings": 30.00, // 已结算(保留兼容)
"pendingEarnings": 50.00, // 待结算(保留兼容)
"shareRate": 90,
"minWithdrawAmount": 10
}
}
```
### 3. 小程序测试
1. **查看提现卡片**
- ✅ 累计佣金显示正确(所有佣金总和)
- ✅ 待审核金额显示正确(提现申请中的金额)
- ✅ 提现按钮显示可提现金额
2. **发起提现**
- ✅ 提现按钮使用 `availableEarnings` 判断是否可用
- ✅ 提现金额为 `availableEarnings`
- ✅ 提现后,`availableEarnings` 减少,`pendingWithdrawAmount` 增加
3. **管理员审核后**
-`pendingWithdrawAmount` 减少
-`withdrawnEarnings` 增加
-`totalCommission` 保持不变
## 五、注意事项
### 1. 向后兼容
为了保证系统稳定,保留了原有的 `earnings``pendingEarnings` 字段,仅在小程序中使用新字段。
### 2. 提现流程
用户发起提现时,系统会:
1. 扣减 `users.pending_earnings`
2. 创建 `withdrawals` 记录(`status = 'pending'`
3. 管理员审核通过后,`withdrawals.status` 改为 `'success'`
4. 同时增加 `users.withdrawn_earnings`
### 3. 数据一致性
确保以下等式始终成立:
```
totalCommission = availableEarnings + pendingWithdrawAmount + withdrawnEarnings
```
### 4. 前端显示
所有金额都使用 `formatMoney()` 函数格式化为两位小数。
## 六、影响范围
### 修改文件
1. **后端**
- `app/api/referral/data/route.ts` - 添加 `pendingWithdrawAmount` 查询和返回字段
2. **小程序**
- `miniprogram/pages/referral/referral.js` - 数据字段和提现逻辑
- `miniprogram/pages/referral/referral.wxml` - UI 显示
### 不影响
- ❌ 提现流程逻辑(`/api/withdraw`
- ❌ 管理后台(仍使用原字段)
- ❌ 佣金计算逻辑(`/api/payment/*/notify`
- ❌ 数据库表结构(无需修改)
## 七、测试场景
### 场景 1新用户获得佣金
```
初始状态:
- totalCommission = 0
- availableEarnings = 0
- pendingWithdrawAmount = 0
- withdrawnEarnings = 0
用户 A 购买了 100 元的书籍,推荐人 B 获得 90 元佣金:
- totalCommission = 90
- availableEarnings = 90
- pendingWithdrawAmount = 0
- withdrawnEarnings = 0
```
### 场景 2用户发起提现
```
用户 B 发起提现 90 元:
- totalCommission = 90不变
- availableEarnings = 0减少 90
- pendingWithdrawAmount = 90增加 90
- withdrawnEarnings = 0
```
### 场景 3管理员审核通过
```
管理员审核通过,打款成功:
- totalCommission = 90不变
- availableEarnings = 0
- pendingWithdrawAmount = 0减少 90
- withdrawnEarnings = 90增加 90
```
### 场景 4管理员拒绝提现
```
管理员拒绝提现:
- totalCommission = 90不变
- availableEarnings = 90恢复 90
- pendingWithdrawAmount = 0减少 90
- withdrawnEarnings = 0
```
## 八、总结
此次优化主要解决了提现卡片数据定义不清晰的问题:
1. **累计佣金**:直观展示用户获得的所有佣金
2. **可提现金额**:明确告知用户可以发起提现的金额
3. **待审核金额**:让用户清楚知道有多少提现申请正在处理中
优化后的界面更加清晰、易懂,用户体验更佳!

View File

@@ -1,423 +0,0 @@
# 提现双向校验实现
## 需求
前端和后端都必须使用**相同的逻辑**校验可提现金额,确保安全性和一致性。
## 校验逻辑(统一)
```javascript
可提现金额 = 累计佣金 - 已提现金额 - 待审核金额
```
## 前后端对比
### 前端校验miniprogram
**文件**`miniprogram/pages/referral/referral.js`
**作用**:按钮启用/禁用判断
```javascript
// 计算可提现金额
const totalCommissionNum = realData?.totalCommission || 0
const withdrawnNum = realData?.withdrawnEarnings || 0
const pendingWithdrawNum = realData?.pendingWithdrawAmount || 0
const availableEarningsNum = totalCommissionNum - withdrawnNum - pendingWithdrawNum
// 判断按钮状态
if (availableEarningsNum >= minWithdrawAmount) {
// 启用按钮
} else {
// 禁用按钮
}
```
### 后端校验API
**文件**`app/api/withdraw/route.ts`
**作用**:提现申请最终验证
```typescript
// 1. 查询累计佣金
const ordersResult = await query(`
SELECT COALESCE(SUM(amount), 0) as total_amount
FROM orders
WHERE referrer_id = ? AND status = 'paid'
`, [userId])
const totalAmount = parseFloat(ordersResult[0]?.total_amount || 0)
const totalCommission = totalAmount * distributorShare
// 2. 读取已提现金额
const withdrawnEarnings = parseFloat(user.withdrawn_earnings) || 0
// 3. 查询待审核金额
const pendingResult = await query(`
SELECT COALESCE(SUM(amount), 0) as pending_amount
FROM withdrawals
WHERE user_id = ? AND status = 'pending'
`, [userId])
const pendingWithdrawAmount = parseFloat(pendingResult[0]?.pending_amount || 0)
// 4. 计算可提现金额
const availableAmount = totalCommission - withdrawnEarnings - pendingWithdrawAmount
// 5. 验证
if (amount > availableAmount) {
return NextResponse.json({
success: false,
message: `可提现金额不足。当前可提现 ¥${availableAmount.toFixed(2)}(累计 ¥${totalCommission.toFixed(2)} - 已提现 ¥${withdrawnEarnings.toFixed(2)} - 待审核 ¥${pendingWithdrawAmount.toFixed(2)}`
})
}
```
## 修改内容
### 1. 后端添加已提现金额
**修改前(错误)**
```typescript
// ❌ 只减去待审核,没减去已提现
const availableAmount = totalCommission - pendingWithdrawAmount
```
**修改后(正确)**
```typescript
// ✅ 三元素完整校验
const withdrawnEarnings = parseFloat(user.withdrawn_earnings) || 0
const availableAmount = totalCommission - withdrawnEarnings - pendingWithdrawAmount
```
### 2. 详细日志输出
```typescript
console.log('[Withdraw] 提现验证(完整版):')
console.log('- 累计佣金 (totalCommission):', totalCommission)
console.log('- 已提现金额 (withdrawnEarnings):', withdrawnEarnings)
console.log('- 待审核金额 (pendingWithdrawAmount):', pendingWithdrawAmount)
console.log('- 可提现金额 = 累计 - 已提现 - 待审核 =', totalCommission, '-', withdrawnEarnings, '-', pendingWithdrawAmount, '=', availableAmount)
console.log('- 申请提现金额 (amount):', amount)
console.log('- 判断:', amount, '>', availableAmount, '=', amount > availableAmount)
```
### 3. 优化错误提示
**修改前**
```typescript
message: `可提现金额不足,当前可提现 ¥${availableAmount.toFixed(2)},待审核 ¥${pendingWithdrawAmount.toFixed(2)}`
```
**修改后**
```typescript
message: `可提现金额不足。当前可提现 ¥${availableAmount.toFixed(2)}(累计 ¥${totalCommission.toFixed(2)} - 已提现 ¥${withdrawnEarnings.toFixed(2)} - 待审核 ¥${pendingWithdrawAmount.toFixed(2)}`
```
## 双向校验流程
```
用户点击提现按钮
┌──────────────────────────┐
│ 前端校验(第一层) │
│ 按钮启用/禁用判断 │
├──────────────────────────┤
│ 可提现 >= 最低金额? │
│ YES → 允许点击 │
│ NO → 按钮禁用 │
└──────────────────────────┘
用户确认提现
┌──────────────────────────┐
│ 后端校验(第二层) │
│ API 最终验证 │
├──────────────────────────┤
│ 1. 查询累计佣金 │
│ 2. 读取已提现金额 │
│ 3. 查询待审核金额 │
│ 4. 计算可提现金额 │
│ 5. amount > available? │
│ YES → 拒绝提现 │
│ NO → 允许提现 │
└──────────────────────────┘
创建提现记录
```
## 为什么需要双向校验?
### 前端校验的作用
**优点**
- ✅ 快速响应,提升用户体验
- ✅ 减少无效请求,降低服务器压力
- ✅ 按钮禁用,防止误操作
**局限**
- ❌ 数据可能过期API 数据缓存)
- ❌ 可被绕过(客户端不可信)
- ❌ 无法阻止恶意请求
### 后端校验的作用
**优点**
- ✅ 最终防线,确保资金安全
- ✅ 实时数据,准确无误
- ✅ 不可绕过,强制验证
**必要性**
- ✅ 防止前端被篡改
- ✅ 防止并发提现
- ✅ 防止逻辑漏洞
## 攻击场景防御
### 场景1前端被篡改
**攻击**
- 黑客修改前端代码,移除按钮禁用逻辑
- 强制发送提现请求
**防御**
- ✅ 后端独立校验,拒绝超额提现
### 场景2并发提现
**攻击**
- 用户快速点击两次提现按钮
- 或同时在多个设备上提现
**防御**
- ✅ 后端查询最新的 `pendingWithdrawAmount`
- ✅ 数据库事务保证原子性
### 场景3API 重放攻击
**攻击**
- 捕获提现请求,重复发送
**防御**
- ✅ 后端实时校验可提现金额
- ✅ 创建提现记录后,`pendingWithdrawAmount` 增加
- ✅ 第二次请求时,可提现金额不足,拒绝
## 测试用例
### 测试1正常提现
**数据**
- 累计佣金: ¥100
- 已提现: ¥0
- 待审核: ¥0
- 申请提现: ¥50
**前端**
```
可提现 = 100 - 0 - 0 = 100
50 < 100 → 按钮启用 ✅
```
**后端**
```
可提现 = 100 - 0 - 0 = 100
50 ≤ 100 → 允许提现 ✅
```
**结果**:✅ 提现成功
### 测试2超额提现
**数据**
- 累计佣金: ¥100
- 已提现: ¥0
- 待审核: ¥0
- 申请提现: ¥150
**前端**
```
可提现 = 100 - 0 - 0 = 100
150 > 100 → 按钮禁用 ✅
(正常情况下用户无法点击)
```
**后端**(假设黑客绕过前端):
```
可提现 = 100 - 0 - 0 = 100
150 > 100 → 拒绝提现 ✅
返回错误:可提现金额不足
```
**结果**:✅ 后端拦截成功
### 测试3重复提现
**数据**
- 累计佣金: ¥100
- 已提现: ¥0
- 待审核: ¥0
**第一次提现 ¥50**
```
前端100 - 0 - 0 = 100允许 ✅
后端100 - 0 - 0 = 100允许 ✅
创建提现记录pending += 50
```
**第二次提现 ¥60**(并发或快速点击):
```
前端:可能还是显示 100数据未刷新
后端100 - 0 - 50 = 50
60 > 50 → 拒绝提现 ✅
```
**结果**:✅ 后端防御成功
### 测试4审核通过后再次提现
**初始**
- 累计佣金: ¥100
- 已提现: ¥50之前提现已到账
- 待审核: ¥0
**申请提现 ¥60**
```
前端100 - 50 - 0 = 50
60 > 50 → 按钮禁用 ✅
```
**后端**(假设绕过前端):
```
100 - 50 - 0 = 50
60 > 50 → 拒绝提现 ✅
```
**结果**:✅ 双重防护
## 日志示例
### 前端日志
```
=== [Referral] 收益计算(完整版)===
累计佣金 (totalCommission): 100
已提现金额 (withdrawnEarnings): 50
待审核金额 (pendingWithdrawAmount): 0
可提现金额 = 累计 - 已提现 - 待审核 = 100 - 50 - 0 = 50
最低提现金额 (minWithdrawAmount): 5
按钮判断: 50 >= 5 = true
✅ 按钮应该: 🟢 启用(绿色)
```
### 后端日志
```
[Withdraw] 佣金计算:
- 订单总金额: 111.11
- 分成比例: 90%
- 累计佣金: 100
[Withdraw] 提现验证(完整版):
- 累计佣金 (totalCommission): 100
- 已提现金额 (withdrawnEarnings): 50
- 待审核金额 (pendingWithdrawAmount): 0
- 可提现金额 = 累计 - 已提现 - 待审核 = 100 - 50 - 0 = 50
- 申请提现金额 (amount): 50
- 判断: 50 > 50 = false
✅ 提现申请通过
```
## 数据一致性保证
### 数据来源
| 字段 | 前端数据来源 | 后端数据来源 | 一致性 |
|------|-------------|-------------|--------|
| 累计佣金 | `/api/referral/data` | 实时查询 `orders` | ✅ 相同算法 |
| 已提现 | `/api/referral/data` | 读取 `users.withdrawn_earnings` | ✅ 相同字段 |
| 待审核 | `/api/referral/data` | 实时查询 `withdrawals` | ✅ 相同查询 |
### 同步机制
1. **前端数据**:来自 `/api/referral/data`
2. **后端校验**:独立查询,实时数据
3. **一致性保证**
- 相同的数据库表
- 相同的计算公式
- 后端数据更准确(实时查询)
## 相关文件
- `miniprogram/pages/referral/referral.js` - 前端校验 ✅
- `app/api/withdraw/route.ts` - 后端校验 ✅
- `app/api/referral/data/route.ts` - 数据查询
## 部署注意事项
### 1. 重启后端服务
```bash
python devlop.py restart mycontent
```
### 2. 清除前端缓存
```
微信开发者工具:工具 → 清除缓存 → 清除全部缓存数据
```
### 3. 监控日志
```bash
pm2 logs mycontent --lines 100
```
关注:
- `[Withdraw] 提现验证(完整版):` - 后端校验日志
- `[Referral] 收益计算(完整版)` - 前端计算日志
### 4. 测试验证
- [ ] 正常提现(可提现 > 最低金额)
- [ ] 超额提现(申请金额 > 可提现)
- [ ] 并发提现(快速点击两次)
- [ ] 审核后再提现
## 安全检查清单
- [x] 前端使用三元素计算
- [x] 后端使用三元素校验
- [x] 前后端公式完全一致
- [x] 后端独立查询数据(不信任前端)
- [x] 详细日志记录
- [x] 清晰的错误提示
## 总结
### 双向校验的意义
**前端**
- 用户体验优化
- 快速反馈
- 减少无效请求
**后端**
- 安全防线
- 资金保护
- 最终决策
### 公式统一
```javascript
// 前端和后端完全一致
可提现金额 = 累计佣金 - 已提现金额 - 待审核金额
```
### 防御能力
- ✅ 防篡改
- ✅ 防并发
- ✅ 防重放
- ✅ 防超额
**核心原则**:前端便利,后端安全,双重保障。

View File

@@ -1,312 +0,0 @@
# 提现审核流程优化
## 需求
提现申请提交后,应该明确告知用户:
- ✅ 提现申请已提交
- ⏳ 正在审核中
- 💰 审核通过后会自动到账微信零钱
而不是误导用户以为已经立即到账。
## 修改内容
### 1. 后端提示优化
**文件**`app/api/withdraw/route.ts`
```typescript
return NextResponse.json({
success: true,
message: '提现申请已提交,正在审核中,通过后会自动到账您的微信零钱',
data: {
withdrawId,
amount,
account,
accountType: accountType === 'alipay' ? '支付宝' : '微信',
status: 'pending' // ✅ 新增:返回状态
}
})
```
**变更**
- ❌ 旧:`message: '提现成功'` (误导性)
- ✅ 新:`message: '提现申请已提交,正在审核中,通过后会自动到账您的微信零钱'` (准确)
- ✅ 新增 `status: 'pending'` 字段
### 2. 前端提示优化
**文件**`miniprogram/pages/referral/referral.js`
```javascript
if (res.success) {
wx.showModal({
title: '提现申请已提交 ✅',
content: res.message || '正在审核中,通过后会自动到账您的微信零钱',
showCancel: false,
confirmText: '知道了'
})
// 刷新数据(此时待审核金额会增加,可提现金额会减少)
this.initData()
}
```
**变更**
- ❌ 旧标题:`'提现成功 🎉'` (误导性)
- ✅ 新标题:`'提现申请已提交 ✅'` (准确)
- ❌ 旧内容:`'¥X.XX 已到账您的微信零钱'` (虚假)
- ✅ 新内容:`'正在审核中,通过后会自动到账您的微信零钱'` (真实)
- ❌ 旧按钮:`'好的'`
- ✅ 新按钮:`'知道了'`
### 3. 错误提示优化
```javascript
} else {
wx.showToast({
title: res.message || res.error || '提现失败',
icon: 'none',
duration: 3000 // ✅ 增加显示时间到3秒
})
}
```
## 提现流程说明
### 完整流程
```
1. 用户点击"申请提现"
2. 前端验证:可提现金额 >= 最低提现金额
3. 后端创建提现记录status = 'pending'
4. 提示用户:"提现申请已提交,正在审核中"
5. 刷新分销中心数据:
- 待审核金额 += 提现金额
- 可提现金额 -= 提现金额
6. 管理员在后台审核
7. 审核通过:
- 更新 withdrawals.status = 'completed'
- 自动转账到微信零钱
- 发送微信通知给用户
```
### 状态说明
| 状态 | 英文 | 说明 | 用户提示 |
|------|------|------|----------|
| 待审核 | `pending` | 提现申请已提交,等待管理员审核 | 正在审核中 |
| 已完成 | `completed` | 审核通过,已转账到微信零钱 | 已到账 |
| 已拒绝 | `failed` | 审核未通过 | 提现失败 |
### 数据变化示例
**提现前**
```
累计佣金: 100元
待审核金额: 0元
可提现金额: 100 - 0 = 100元
```
**申请提现50元后**
```
累计佣金: 100元
待审核金额: 0 + 50 = 50元
可提现金额: 100 - 50 = 50元
```
**审核通过后**
```
累计佣金: 100元
待审核金额: 50 - 50 = 0元
已提现金额: 0 + 50 = 50元
可提现金额: 100 - 0 = 100元 (如果有新订单)
```
## 用户体验对比
### 修改前(误导性)
```
┌─────────────────────┐
│ 提现成功 🎉 │
├─────────────────────┤
│ ¥50.00 已到账您的 │
│ 微信零钱 │
├─────────────────────┤
│ [好的] │
└─────────────────────┘
```
**问题**
- ❌ 用户以为钱已经到账
- ❌ 用户去微信查看,发现没钱
- ❌ 产生疑惑和不满
### 修改后(准确清晰)
```
┌─────────────────────┐
│ 提现申请已提交 ✅ │
├─────────────────────┤
│ 正在审核中,通过后 │
│ 会自动到账您的微信 │
│ 零钱 │
├─────────────────────┤
│ [知道了] │
└─────────────────────┘
```
**优点**
- ✅ 用户知道需要等待审核
- ✅ 用户知道审核通过后会自动到账
- ✅ 符合实际情况
## 后续优化建议
### 1. 审核通知
管理员审核通过后,发送微信模板消息通知用户:
```javascript
// 伪代码
wx.sendTemplateMessage({
touser: userOpenId,
template_id: 'OPENTM415934726',
data: {
thing1: { value: '提现申请' },
amount2: { value: '50.00元' },
phrase3: { value: '审核通过' },
time4: { value: '2026-02-04 15:30' }
}
})
```
### 2. 提现记录页面
在"我的"页面增加"提现记录"入口,让用户查看:
- 待审核的提现申请
- 已完成的提现记录
- 失败的提现记录及原因
### 3. 审核预计时间
在提示中增加审核时长预期:
```
正在审核中预计1-3个工作日内完成审核
通过后会自动到账您的微信零钱
```
### 4. 自动审核
对于小额提现如50元以下可以实现自动审核
```javascript
if (amount <= 50) {
// 自动审核通过
await query(`UPDATE withdrawals SET status = 'completed' WHERE id = ?`, [withdrawId])
// 立即转账
await wechatPay.transferToWallet(...)
return { message: '提现成功,已到账您的微信零钱' }
} else {
// 需要人工审核
return { message: '提现申请已提交,正在审核中' }
}
```
## 管理后台提现审核功能
### 审核页面功能
1. **提现列表**
- 显示所有待审核的提现申请
- 显示用户信息、提现金额、申请时间
- 显示用户的累计佣金、历史提现次数
2. **审核操作**
- 通过:调用微信商家转账接口
- 拒绝:填写拒绝原因
3. **记录查询**
- 已完成的提现记录
- 失败的提现记录
### 审核接口(待实现)
```typescript
// POST /api/admin/withdraw/approve
{
"withdrawId": "W1738694028123",
"action": "approve" | "reject",
"reason": "拒绝原因(可选)"
}
```
## 测试步骤
### 1. 测试提现申请
1. 在小程序中进入分销中心
2. 确保可提现金额 >= 5元
3. 点击"申请提现"按钮
4. 确认提现金额
5. 查看提示是否为"提现申请已提交,正在审核中"
### 2. 验证数据变化
提现后立即刷新页面,检查:
- ✅ 累计佣金不变
- ✅ 待审核金额增加
- ✅ 可提现金额减少
- ✅ 提现按钮重新判断是否可用
### 3. 查看数据库
```sql
-- 查看提现记录
SELECT * FROM withdrawals
WHERE user_id = 'ogpTW5fmXRGNpoUbXB3UEqnVe5Tg'
ORDER BY created_at DESC
LIMIT 5;
-- 应该看到最新的记录status = 'pending'
```
### 4. 模拟审核通过
```sql
-- 手动更新状态为已完成
UPDATE withdrawals
SET status = 'completed',
completed_at = NOW()
WHERE id = 'W1738694028123';
```
再次刷新小程序,检查:
- ✅ 待审核金额减少
- ✅ 可提现金额恢复
## 相关文件
- `app/api/withdraw/route.ts` - 提现接口(修改返回消息)
- `miniprogram/pages/referral/referral.js` - 前端提现逻辑(修改提示)
## 总结
这次优化的核心是**准确传达信息**
- ❌ 不要给用户虚假期望("已到账"实际未到账)
- ✅ 明确告知用户当前状态("正在审核"
- ✅ 告知用户后续流程("通过后会自动到账"
这样可以:
1. 提升用户体验(不会产生困惑)
2. 减少客服咨询(用户知道要等待)
3. 建立信任(说到做到)

View File

@@ -1,223 +0,0 @@
# 提现按钮状态修复说明
## 问题描述
**现象**
- 累计佣金¥8.10
- 待审核¥0.00
- 可提现金额8.10元
- 最低提现金额5元
- **预期**:按钮应显示"申请提现 ¥8.10"(可用状态)
- **实际**:按钮显示"满5元可提现"(灰色禁用状态)
## 问题原因
`availableEarnings``formatMoney()` 格式化为字符串 `"8.10"`,在 WXML 模板中进行数值比较时可能出现类型转换问题。
## 解决方案
### 修改1增加数字类型字段
`referral.js``data` 中增加 `availableEarningsNum` 字段:
```javascript
data: {
availableEarnings: 0, // 字符串格式用于显示
availableEarningsNum: 0, // 数字格式用于判断
minWithdrawAmount: 10,
// ...
}
```
### 修改2初始化时同时设置两个字段
`initData()` 方法中:
```javascript
// 获取原始数字值(用于判断)
const availableEarningsNum = realData?.availableEarnings || 0
console.log('[Referral] 收益数据:')
console.log('[Referral] - totalCommission:', realData?.totalCommission)
console.log('[Referral] - availableEarnings:', availableEarningsNum)
console.log('[Referral] - minWithdrawAmount:', realData?.minWithdrawAmount)
console.log('[Referral] - 按钮应该', availableEarningsNum >= (realData?.minWithdrawAmount || 10) ? '可用' : '禁用')
this.setData({
// ...
availableEarnings: formatMoney(availableEarningsNum), // 字符串,用于显示
availableEarningsNum: availableEarningsNum, // 数字,用于判断
minWithdrawAmount: realData?.minWithdrawAmount || 10,
// ...
})
```
### 修改3WXML 使用数字字段判断
```xml
<view class="withdraw-btn {{availableEarningsNum < minWithdrawAmount ? 'btn-disabled' : ''}}" bindtap="handleWithdraw">
{{availableEarningsNum < minWithdrawAmount ? '满' + minWithdrawAmount + '元可提现' : '申请提现 ¥' + availableEarnings}}
</view>
```
**注意**
- 类名判断使用 `availableEarningsNum`(数字)
- 文本显示使用 `availableEarnings`(字符串,格式化后的)
### 修改4提现逻辑也使用数字字段
```javascript
async handleWithdraw() {
// 使用数字版本直接进行判断,避免重复转换
const availableEarnings = this.data.availableEarningsNum || 0
const minWithdrawAmount = this.data.minWithdrawAmount || 10
console.log('[Withdraw] 提现检查:', {
availableEarnings,
minWithdrawAmount,
shouldEnable: availableEarnings >= minWithdrawAmount
})
if (availableEarnings <= 0) {
wx.showToast({ title: '暂无可提现收益', icon: 'none' })
return
}
if (availableEarnings < minWithdrawAmount) {
wx.showToast({
title: `满${minWithdrawAmount}元可提现`,
icon: 'none'
})
return
}
// ...
}
```
## 测试步骤
### 1. 清理小程序缓存(重要)
在微信开发者工具中:
1. **清除缓存数据**
- 点击顶部菜单 `工具``清除缓存``清除全部缓存数据`
2. **重新编译**
- 点击 `编译` 按钮
- 或使用快捷键 `Ctrl + B` (Windows) / `Cmd + B` (Mac)
3. **如果还不行,尝试完全重启**
- 关闭微信开发者工具
- 重新打开项目
- 再次编译
### 2. 查看调试日志
打开控制台Console查找以下日志
```
[Referral] 收益数据:
[Referral] - totalCommission: 8.1
[Referral] - availableEarnings: 8.1
[Referral] - minWithdrawAmount: 5
[Referral] - 按钮应该 可用
```
如果看到"按钮应该 可用",说明逻辑判断正确。
### 3. 点击提现按钮
如果按钮仍然是灰色,点击它,查看是否有日志输出:
```
[Withdraw] 提现检查: {
availableEarnings: 8.1,
minWithdrawAmount: 5,
shouldEnable: true
}
```
### 4. 检查数据值
在小程序调试器的 `AppData` 标签页中查找 `referral` 页面的数据:
```json
{
"availableEarnings": "8.10", // 字符串
"availableEarningsNum": 8.1, // 数字
"minWithdrawAmount": 5
}
```
确认 `availableEarningsNum` 是数字类型,不是字符串。
## 预期结果
修复后,当 `availableEarningsNum` (8.1) >= `minWithdrawAmount` (5) 时:
- ✅ 按钮显示:`申请提现 ¥8.10`
- ✅ 按钮样式:渐变青色背景(可点击)
- ✅ 点击后弹出提现确认对话框
## 常见问题
### Q1: 修改后按钮还是灰色?
**A**: 清除小程序缓存并重新编译:
```
工具 → 清除缓存 → 清除全部缓存数据
然后点击 编译 按钮
```
### Q2: 控制台没有看到调试日志?
**A**:
1. 确保控制台的日志级别包含 `log`
2. 检查是否过滤了某些日志
3. 尝试刷新页面(下拉刷新或重新进入)
### Q3: `availableEarningsNum` 是 undefined
**A**: 检查后端 API 返回的数据格式,确保 `realData.availableEarnings` 有值:
```javascript
console.log('API返回:', realData)
```
### Q4: 数据正确但按钮还是不可点击?
**A**: 检查 WXSS 中是否有其他样式覆盖:
```css
.withdraw-btn.btn-disabled {
pointer-events: none; /* 可能导致无法点击 */
}
```
## 相关文件
- `miniprogram/pages/referral/referral.js` - 主要逻辑
- `miniprogram/pages/referral/referral.wxml` - 模板
- `miniprogram/pages/referral/referral.wxss` - 样式
- `app/api/referral/data/route.ts` - 后端API
## 验证清单
- [ ] 后端API返回正确的 `availableEarnings` 数值
- [ ] `initData()` 中正确设置 `availableEarningsNum`
- [ ] WXML 使用 `availableEarningsNum` 进行条件判断
- [ ] 清除小程序缓存并重新编译
- [ ] 控制台显示正确的调试日志
- [ ] 按钮显示正确文本和样式
- [ ] 点击按钮可以正常提现
## 技术总结
**核心问题**:字符串类型的数字在某些场景下的比较可能不符合预期。
**解决思路**
1. 保存两份数据:字符串用于显示,数字用于判断
2. 在数据初始化时就区分好类型
3. 在需要比较的地方使用数字类型
4. 添加详细的调试日志便于排查问题
**最佳实践**
- ✅ 数值计算和比较始终使用 Number 类型
- ✅ 格式化显示使用 String 类型
- ✅ 在 setData 时明确类型转换
- ✅ 避免在模板中进行复杂的类型转换

View File

@@ -1,263 +0,0 @@
# 提现按钮逻辑修正
## 问题描述
**错误理解**
- 错误地直接使用后端返回的 `availableEarnings` 进行判断
**正确逻辑**
-**可提现金额 = 累计佣金 - 待审核金额**
-**按钮启用条件:可提现金额 >= 最低提现金额**
## 具体案例
当前数据:
- 累计佣金¥8.10
- 待审核金额¥0.00
- **计算:可提现金额 = 8.10 - 0 = 8.10元**
- 最低提现金额¥5.00
- **判断8.10 >= 5.00 = true** → 按钮应该启用(绿色)
## 修复方案
### 1. 前端计算可提现金额
`miniprogram/pages/referral/referral.js``initData()` 方法中:
```javascript
// ✅ 修正:可提现金额 = 累计佣金 - 待审核金额
const totalCommissionNum = realData?.totalCommission || 0
const pendingWithdrawNum = realData?.pendingWithdrawAmount || 0
const availableEarningsNum = totalCommissionNum - pendingWithdrawNum
const minWithdrawAmount = realData?.minWithdrawAmount || 10
console.log('=== [Referral] 收益计算(修正后)===')
console.log('累计佣金:', totalCommissionNum)
console.log('待审核金额:', pendingWithdrawNum)
console.log('可提现金额 = 累计佣金 - 待审核金额 =', totalCommissionNum, '-', pendingWithdrawNum, '=', availableEarningsNum)
console.log('最低提现金额:', minWithdrawAmount)
console.log('按钮判断:', availableEarningsNum, '>=', minWithdrawAmount, '=', availableEarningsNum >= minWithdrawAmount)
console.log('✅ 按钮应该:', availableEarningsNum >= minWithdrawAmount ? '🟢 启用(绿色)' : '⚫ 禁用(灰色)')
```
### 2. 设置数据
```javascript
this.setData({
// 收益数据 - 格式化为两位小数
totalCommission: formatMoney(totalCommissionNum),
availableEarnings: formatMoney(availableEarningsNum), // ✅ 使用计算后的可提现金额
availableEarningsNum: availableEarningsNum, // ✅ 数字格式用于按钮判断
pendingWithdrawAmount: formatMoney(pendingWithdrawNum),
minWithdrawAmount: minWithdrawAmount,
// ...
})
```
### 3. 按钮判断逻辑WXML
```xml
<view class="withdraw-btn {{availableEarningsNum < minWithdrawAmount ? 'btn-disabled' : ''}}" bindtap="handleWithdraw">
{{availableEarningsNum < minWithdrawAmount ? '满' + minWithdrawAmount + '元可提现' : '申请提现 ¥' + availableEarnings}}
</view>
```
**注意**
- 类名判断:`availableEarningsNum < minWithdrawAmount`(数字比较)
- 文本显示:使用格式化后的 `availableEarnings` 字符串
### 4. 提现函数中的验证
```javascript
async handleWithdraw() {
// 使用数字版本直接进行判断
const availableEarnings = this.data.availableEarningsNum || 0
const minWithdrawAmount = this.data.minWithdrawAmount || 10
console.log('[Withdraw] 提现检查:', {
availableEarnings,
minWithdrawAmount,
shouldEnable: availableEarnings >= minWithdrawAmount
})
if (availableEarnings <= 0) {
wx.showToast({ title: '暂无可提现收益', icon: 'none' })
return
}
if (availableEarnings < minWithdrawAmount) {
wx.showToast({
title: `满${minWithdrawAmount}元可提现`,
icon: 'none'
})
return
}
// ... 继续提现逻辑
}
```
## 测试步骤
### 1. 完全清除缓存
在微信开发者工具中:
```
1. 关闭微信开发者工具
2. 重新打开项目
3. 工具 → 清除缓存 → 清除全部缓存数据
4. 点击"编译"按钮
```
### 2. 查看控制台日志
应该看到类似这样的输出:
```
=== [Referral] 收益计算(修正后)===
累计佣金: 8.1
待审核金额: 0
可提现金额 = 累计佣金 - 待审核金额 = 8.1 - 0 = 8.1
最低提现金额: 5
按钮判断: 8.1 >= 5 = true
✅ 按钮应该: 🟢 启用(绿色)
=== [Referral] 按钮状态验证 ===
累计佣金 (totalCommission): 8.10
待审核金额 (pendingWithdrawAmount): 0.00
可提现金额 (availableEarnings 显示): 8.10
可提现金额 (availableEarningsNum 判断): 8.1 number
最低提现金额 (minWithdrawAmount): 5 number
按钮启用条件: 8.1 >= 5 = true
✅ 最终结果: 按钮应该 🟢 启用
```
### 3. 验证界面
- ✅ 按钮文本:`申请提现 ¥8.10`
- ✅ 按钮样式:渐变青色背景(不是灰色)
- ✅ 可以点击
## 逻辑公式总结
### 核心计算
```
可提现金额 = 累计佣金 - 待审核金额
```
### 按钮状态
```
if (可提现金额 >= 最低提现金额) {
// ✅ 启用按钮(绿色)
显示文本: "申请提现 ¥{可提现金额}"
} else {
// ❌ 禁用按钮(灰色)
显示文本: "满{最低提现金额}元可提现"
}
```
### 数据关系
```
totalCommission (累计佣金)
├─ 所有已完成订单的佣金总和
└─ 显示在顶部"累计佣金"位置
pendingWithdrawAmount (待审核金额)
├─ 已申请提现但未审核通过的金额总和
└─ 显示在"待审核"位置
availableEarnings (可提现金额)
├─ = totalCommission - pendingWithdrawAmount
├─ 用户实际可以申请提现的金额
└─ 显示在提现按钮上
minWithdrawAmount (最低提现金额)
├─ 从管理后台配置获取
├─ 默认值10元
└─ 用于判断是否允许提现
```
## 为什么要在前端计算?
### 优势
1. **实时准确**
- 每次进入页面都基于最新的累计佣金和待审核金额计算
- 避免后端缓存导致的数据延迟
2. **逻辑清晰**
- 公式简单明了:`累计佣金 - 待审核金额`
- 便于调试和验证
3. **减轻后端负担**
- 简单的减法运算在前端完成
- 后端只需返回原始数据
### 数据流
```
后端API返回:
{
totalCommission: 8.1, // 累计佣金
pendingWithdrawAmount: 0, // 待审核金额
minWithdrawAmount: 5 // 最低提现金额
}
前端计算:
availableEarningsNum = 8.1 - 0 = 8.1 // 可提现金额
前端判断:
8.1 >= 5 ? 启用按钮 : 禁用按钮
```
## 相关文件
- `miniprogram/pages/referral/referral.js` - 计算逻辑
- `miniprogram/pages/referral/referral.wxml` - 按钮显示
- `miniprogram/pages/referral/referral.wxss` - 按钮样式
## 常见问题
### Q1: 为什么要保存两个字段?
**A**:
- `availableEarnings` (字符串):用于界面显示,格式化为 "8.10"
- `availableEarningsNum` (数字):用于条件判断,精确比较 `8.1 >= 5`
### Q2: 后端的 availableEarnings 还有用吗?
**A**: 如果后端返回了 `availableEarnings`,现在会被前端计算的值覆盖。建议:
- 方案1后端不再返回 `availableEarnings`,只返回 `totalCommission``pendingWithdrawAmount`
- 方案2保留后端计算但前端不使用当前方案
### Q3: 如果待审核金额大于累计佣金怎么办?
**A**: 理论上不应该出现这种情况,但可以添加保护:
```javascript
const availableEarningsNum = Math.max(0, totalCommissionNum - pendingWithdrawNum)
```
## 验证清单
- [x] 修改前端计算逻辑:`可提现金额 = 累计佣金 - 待审核金额`
- [x] 添加详细调试日志
- [x] 确保使用数字类型进行比较
- [x] 清除小程序缓存
- [x] 重新编译
- [ ] 查看控制台日志验证计算
- [ ] 确认按钮显示正确文本和样式
- [ ] 测试点击提现功能
## 总结
这次修正的核心是**理解业务逻辑**
1. **累计佣金** = 所有获得的佣金(历史总和)
2. **待审核金额** = 已申请但未到账的金额
3. **可提现金额** = 累计佣金 - 待审核金额 = 当前可以申请提现的金额
4. **按钮启用** = 可提现金额 >= 最低提现金额
之前的错误是直接使用后端返回的值,没有理解这个减法关系。现在在前端明确计算,确保逻辑正确。

View File

@@ -1,352 +0,0 @@
# 提现接口数据查询错误修复
## 问题描述
访问 `/api/db/withdrawals` 接口时报错:
```json
{
"success": false,
"error": "获取提现数据失败: Cannot read properties of undefined (reading 'length')",
"withdrawals": []
}
```
**错误原因**:尝试读取 `undefined.length`,说明 `query()` 返回了 `undefined`
## 问题根源
### 1. 缺少 Null Check
**问题代码**
```typescript
const withdrawals = await query(`...`) as any[]
console.log('[DB Withdrawals] 查询成功,记录数:', withdrawals.length)
// ❌ 如果 query 返回 undefined这里会报错
```
### 2. 表可能不存在
如果 `withdrawals` 表还未创建,`query()` 会抛出异常并被 catch 捕获,但错误信息不够明确。
### 3. 错误处理不完善
缺少对特定数据库错误(如表不存在)的处理。
## 解决方案
### 1. 增加表初始化逻辑
**新增函数**
```typescript
// 确保提现表存在
async function ensureWithdrawalsTable() {
try {
await query(`
CREATE TABLE IF NOT EXISTS withdrawals (
id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status ENUM('pending', 'processing', 'success', 'failed') DEFAULT 'pending',
wechat_openid VARCHAR(100),
transaction_id VARCHAR(100),
error_message VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
console.log('[DB Withdrawals] 提现表检查/创建成功')
} catch (error: any) {
console.error('[DB Withdrawals] 提现表创建失败:', error.message)
}
}
```
**调用时机**:在 `GET` 方法开始时调用
```typescript
export async function GET(request: Request) {
const authErr = requireAdminResponse(request)
if (authErr) return authErr
// 确保表存在
await ensureWithdrawalsTable()
try {
// ... 查询逻辑
}
}
```
### 2. 增强查询错误处理
**修改前**
```typescript
const withdrawals = await query(`...`) as any[]
console.log('[DB Withdrawals] 查询成功,记录数:', withdrawals.length)
```
**修改后**
```typescript
let withdrawals: any[] = []
try {
const result = await query(`...`)
withdrawals = (result as any[]) || []
} catch (queryError: any) {
console.error('[DB Withdrawals] SQL查询失败:', queryError.message)
// 如果表不存在,返回空数组
if (queryError.message?.includes("doesn't exist") ||
queryError.message?.includes('ER_NO_SUCH_TABLE')) {
console.warn('[DB Withdrawals] withdrawals 表不存在,返回空数据')
return NextResponse.json({
success: true,
withdrawals: [],
total: 0,
message: 'withdrawals 表尚未初始化'
})
}
throw queryError
}
console.log('[DB Withdrawals] 查询成功,记录数:', withdrawals.length)
```
**改进点**
- ✅ 初始化为空数组,避免 undefined
- ✅ 单独 try-catch 包裹查询逻辑
- ✅ 识别"表不存在"错误,返回友好提示
- ✅ 确保 `withdrawals` 始终是数组
### 3. 增强数据转换安全性
**修改前**
```typescript
const formattedWithdrawals = withdrawals.map(w => {
// ...
})
```
**修改后**
```typescript
const formattedWithdrawals = (withdrawals || []).map(w => {
// ... 双重保险
})
```
## 修改文件
**文件路径**`app/api/db/withdrawals/route.ts`
**修改内容**
1. 新增 `ensureWithdrawalsTable()` 函数(第 6-30 行)
2.`GET` 方法中调用表初始化(第 36 行)
3. 增强查询错误处理(第 38-62 行)
4. 增强数据转换安全性(第 66 行)
## 验证步骤
### 1. 重启服务
```powershell
pm2 restart mycontent
# 或
npm run dev
```
### 2. 访问 API 测试
**在浏览器中访问**
```
http://localhost:3006/api/db/withdrawals
```
**期望结果**(如果表为空):
```json
{
"success": true,
"withdrawals": [],
"total": 0
}
```
**期望结果**(如果有数据):
```json
{
"success": true,
"withdrawals": [
{
"id": "W_xxx",
"user_id": "user123",
"user_name": "张三",
"amount": 50.00,
"status": "pending",
"created_at": "2026-02-04 10:00:00"
}
],
"total": 1
}
```
### 3. 查看服务器日志
应该看到:
```
[DB Withdrawals] 提现表检查/创建成功
[DB Withdrawals] 查询提现记录...
[DB Withdrawals] 查询成功,记录数: 0
```
### 4. 访问前端页面
```
http://localhost:3006/admin/distribution
```
点击"提现审核" tab应该能正常显示即使数据为空
### 5. 数据库验证
```sql
-- 检查表是否存在
SHOW TABLES LIKE 'withdrawals';
-- 查看表结构
DESC withdrawals;
-- 查看数据
SELECT * FROM withdrawals;
```
## 错误处理流程图
```
访问 /api/db/withdrawals
验证管理员权限
ensureWithdrawalsTable()
├─ 成功:表已存在或已创建
└─ 失败:记录错误,继续执行
执行查询 SQL
├─ 成功:返回数据数组
│ └─ 数据转换 → 返回 JSON
└─ 失败:捕获错误
├─ 表不存在错误?
│ └─ 是:返回空数组 + 提示信息
└─ 否:抛出异常
└─ 外层 catch返回 500 错误
```
## 常见问题
### Q1: 为什么需要 `ensureWithdrawalsTable()`
**答**
- 多种方式访问系统时,表可能未创建
- 数据库可能被重置或迁移
- 提供自修复能力,提高系统健壮性
### Q2: 如果表已经存在会怎样?
**答**
`CREATE TABLE IF NOT EXISTS` 不会报错,也不会重复创建:
- 如果表存在SQL 执行成功,什么都不做
- 如果表不存在:创建新表
### Q3: 查询返回空数组和 undefined 有什么区别?
**答**
- 空数组 `[]`:表示查询成功,但没有数据(`.length` 为 0
- `undefined`:表示查询失败或返回值异常(无法读取 `.length`
我们的修复确保始终返回数组,即使查询失败也返回 `[]`
### Q4: 为什么用两层 try-catch
**答**
```typescript
try {
// 外层 try-catch捕获所有错误
try {
// 内层 try-catch专门处理查询错误
const result = await query(...)
} catch (queryError) {
// 识别特定错误类型(如表不存在)
// 可以返回友好提示而不是 500 错误
}
} catch (error) {
// 兜底:处理未预料到的错误
return 500 错误
}
```
这样可以对不同类型的错误做差异化处理。
### Q5: 如果数据库连接失败会怎样?
**答**
- `ensureWithdrawalsTable()` 会失败,但被 catch 捕获,记录错误
- 继续执行查询,查询也会失败
- 最终返回 500 错误和详细错误信息
建议检查:
1. 数据库服务是否运行
2. 连接配置是否正确(`lib/db.ts`
3. 用户权限是否足够
## 性能优化
### 缓存表存在状态
如果频繁调用该 API可以缓存表的存在状态
```typescript
let tableChecked = false
async function ensureWithdrawalsTable() {
if (tableChecked) return // 已检查过,跳过
try {
await query(`CREATE TABLE IF NOT EXISTS ...`)
tableChecked = true // 标记为已检查
console.log('[DB Withdrawals] 提现表检查/创建成功')
} catch (error: any) {
console.error('[DB Withdrawals] 提现表创建失败:', error.message)
}
}
```
**注意**:如果使用 pm2 cluster 模式(多进程),每个进程都需要检查一次。
## 相关文件
- **API 文件**`app/api/db/withdrawals/route.ts`
- **数据库初始化**`app/api/db/init/route.ts`
- **用户提现 API**`app/api/withdraw/route.ts`
- **交易中心页面**`app/admin/distribution/page.tsx`
## 后续改进
1. **统一表初始化**:将所有表的初始化逻辑集中到 `app/api/db/init/route.ts`
2. **健康检查 API**:创建 `/api/health` 检查数据库连接和关键表状态
3. **自动迁移**:使用数据库迁移工具(如 Prisma Migrate管理表结构变更
4. **监控告警**:记录数据库查询失败次数,超过阈值时告警
## 版本信息
- **修复时间**2026-02-04
- **修复内容**
1. 新增 `ensureWithdrawalsTable()` 表初始化函数
2. 增强查询错误处理,识别"表不存在"错误
3. 确保 `withdrawals` 变量始终是数组
4. 增强数据转换安全性
5. 添加详细的错误日志

View File

@@ -1,343 +0,0 @@
# 提现接口统一修复 - 完成报告
## 问题回顾
**用户反馈**"/api/db/withdrawals 接口还是访问失败,不是就访问提现表么?怎么问题这么多,是不是实现方式错了"
## 根本原因
**确认:实现方式确实有问题!**
系统中存在**两个功能重复的提现接口**
1. `/api/admin/withdrawals` - 原有的完整接口260行功能完善
2. `/api/db/withdrawals` - 新创建的简化接口217行重复实现
这导致:
- 维护成本翻倍
- 逻辑不一致风险
- 调试困难
- 接口失败时难以定位问题
## 修复方案
**统一使用原有的完整接口 `/api/admin/withdrawals`**
### 修复内容
#### 1. 删除重复接口
```bash
✅ 已删除app/api/db/withdrawals/route.ts (6927 bytes)
```
#### 2. 前端调用统一3处修改
**文件**`app/admin/distribution/page.tsx`
##### 修改 1: 查询提现数据 (Line 237)
```typescript
// 修改前
const withdrawalsRes = await fetch('/api/db/withdrawals')
// 修改后
const withdrawalsRes = await fetch('/api/admin/withdrawals')
```
##### 修改 2: 批准提现 (Line 299)
```typescript
// 修改前
await fetch('/api/db/withdrawals', {
method: 'PUT',
body: JSON.stringify({ id, status: 'completed' })
})
// 修改后
await fetch('/api/admin/withdrawals', {
method: 'PUT',
body: JSON.stringify({ id, action: 'approve' })
})
```
##### 修改 3: 拒绝提现 (Line 316)
```typescript
// 修改前
await fetch('/api/db/withdrawals', {
method: 'PUT',
body: JSON.stringify({ id, status: 'rejected', reason })
})
// 修改后
await fetch('/api/admin/withdrawals', {
method: 'PUT',
body: JSON.stringify({ id, action: 'reject', errorMessage: reason })
})
```
#### 3. 更新接口定义
**增强 `Withdrawal` 接口**,支持完整的用户佣金信息:
```typescript
interface Withdrawal {
// 基础字段
id: string
userId?: string
user_id?: string
userNickname?: string
user_name?: string
userPhone?: string
userAvatar?: string
referralCode?: string
amount: number
// 状态字段
status: 'pending' | 'success' | 'failed' | 'completed' | 'rejected'
wechatOpenid?: string
transactionId?: string
errorMessage?: string
// 时间字段
createdAt?: string
created_at?: string
processedAt?: string
completed_at?: string
// 新增:用户佣金详情(用于风险提示)
userCommissionInfo?: {
totalCommission: number // 累计佣金
withdrawnEarnings: number // 已提现
pendingWithdrawals: number // 待审核
availableAfterThis: number // 审核后可提现(可能为负,触发风险提示)
}
}
```
#### 4. 添加数据映射
统一字段命名,兼容不同格式:
```typescript
const formattedWithdrawals = (withdrawalsData.withdrawals || []).map((w: any) => ({
...w,
// 字段名统一
user_id: w.userId || w.user_id,
user_name: w.userNickname || w.user_name,
created_at: w.createdAt || w.created_at,
completed_at: w.processedAt || w.completed_at,
// 状态映射(数据库 success/failed → 前端 completed/rejected
status: w.status === 'success' ? 'completed'
: (w.status === 'failed' ? 'rejected' : w.status)
}))
```
## 修复效果
### ✅ 解决的问题
1. **接口失败** → 现在使用稳定的原有接口
2. **维护困难** → 只有一个接口,逻辑清晰
3. **数据不一致** → 统一数据源和格式
4. **功能不完整** → 保留所有用户佣金计算和风险提示功能
### ✅ 新增功能
通过使用 `/api/admin/withdrawals`,自动获得:
1. **用户佣金详情展示**
- 累计佣金(从 orders 表计算)
- 已提现金额
- 待审核金额
- 审核后可提现金额
2. **风险提示**
-`availableAfterThis < 0` 时,显示红色警告
- 批准前弹出确认对话框
3. **完整的用户信息**
- 头像显示
- 手机号
- 推广码
## 测试清单
### 必须测试的功能
#### 1. 提现列表加载
```bash
✅ 登录后台管理
✅ 进入"交易中心""提现审核" tab
✅ 检查列表正常加载
✅ 检查用户信息显示完整(昵称、手机、头像)
✅ 检查佣金详情显示(累计、已提现、待审核、可提现)
```
#### 2. 批准提现
```bash
✅ 选择一条待审核的提现记录
✅ 点击"批准"按钮
✅ 确认对话框正常弹出
✅ 批准成功后,状态更新为"已完成"
✅ 用户的 withdrawn_earnings 字段正确更新
```
#### 3. 拒绝提现
```bash
✅ 选择一条待审核的提现记录
✅ 点击"拒绝"按钮
✅ 输入拒绝原因
✅ 拒绝成功后,状态更新为"已拒绝"
✅ 拒绝原因正确保存
```
#### 4. 风险提示
```bash
✅ 找一条会导致用户余额为负的提现记录
✅ 检查 availableAfterThis 显示为红色负数
✅ 点击批准时,弹出风险确认对话框
```
#### 5. 刷新功能
```bash
✅ 点击"刷新数据"按钮
✅ 数据正确重新加载
✅ 不会重复请求其他 tab 的数据
```
### 测试命令
```bash
# 1. 重启开发服务器
npm run dev
# 2. 检查接口是否可访问(需先登录)
curl http://localhost:3006/api/admin/withdrawals
# 3. 检查数据库表结构
mysql -h56b4c23f6853c.gz.cdb.myqcloud.com -P14413 -ucdb_outerroot -p
USE soul_miniprogram;
DESC withdrawals;
SELECT * FROM withdrawals LIMIT 5;
```
## 数据库状态映射
| 数据库状态 | 前端显示 | 说明 |
|----------|---------|------|
| `pending` | 待审核 | 初始状态 |
| `processing` | 处理中 | 微信转账中 |
| `success` | 已完成 (completed) | 审批通过,打款成功 |
| `failed` | 已拒绝 (rejected) | 审批拒绝或打款失败 |
## API 接口说明
### GET /api/admin/withdrawals
**功能**:查询提现记录列表
**权限**需要管理员登录Cookie: admin_session
**请求参数**
- `status`(可选):筛选状态 `pending|success|failed|all`
**响应格式**
```json
{
"success": true,
"withdrawals": [
{
"id": "withdraw_xxx",
"userId": "user_xxx",
"userNickname": "用户昵称",
"userPhone": "13800138000",
"userAvatar": "https://...",
"referralCode": "ABC123",
"amount": 50.00,
"status": "pending",
"createdAt": "2026-02-04T10:00:00.000Z",
"processedAt": null,
"userCommissionInfo": {
"totalCommission": 100.00,
"withdrawnEarnings": 30.00,
"pendingWithdrawals": 50.00,
"availableAfterThis": 20.00
}
}
],
"stats": {
"total": 10,
"pendingCount": 3,
"pendingAmount": 150.00,
"successCount": 7,
"successAmount": 350.00,
"failedCount": 0
}
}
```
### PUT /api/admin/withdrawals
**功能**:审批提现(批准或拒绝)
**权限**:需要管理员登录
**请求参数**
```json
// 批准
{
"id": "withdraw_xxx",
"action": "approve"
}
// 拒绝
{
"id": "withdraw_xxx",
"action": "reject",
"errorMessage": "拒绝原因"
}
```
**响应格式**
```json
{
"success": true,
"message": "提现已批准" | "提现已拒绝"
}
```
## 相关文件
### 已修改
-`app/admin/distribution/page.tsx` - 前端调用统一
-`app/api/admin/withdrawals/route.ts` - 原有接口(保持不变)
### 已删除
-`app/api/db/withdrawals/route.ts` - 重复接口已删除
### 新增文档
- 📄 `开发文档/8、部署/提现接口重复问题修复.md` - 问题分析
- 📄 `开发文档/8、部署/提现接口统一修复完成.md` - 本文档
## 后续优化建议
### 短期优化(可选)
1. **统一状态枚举**:前后端都使用 `pending|processing|completed|rejected`,避免映射
2. **统一字段命名**:全部使用驼峰命名(`userId`, `userNickname`),避免下划线
3. **添加接口文档**:为 `/api/admin/withdrawals` 添加 OpenAPI 文档
### 长期优化(建议)
1. **单元测试**:为提现接口添加自动化测试
2. **日志优化**:使用结构化日志(如 winston便于排查问题
3. **监控告警**:对提现操作添加监控和异常告警
4. **审计日志**:记录所有提现审批操作,便于追溯
## 总结
**问题根源**:接口重复导致维护混乱
**解决方案**:统一使用原有的完整接口
**修复效果**:代码更清晰,功能更完整,维护更简单
**现在请重启服务并测试,提现功能应该可以正常工作了!** 🎉

View File

@@ -1,348 +0,0 @@
# 提现接口逻辑修正
## 问题描述
**错误提示**
```
POST /api/withdraw
Response: {
success: false,
message: "可提现金额不足,当前可提现 ¥0.00"
}
```
**问题原因**
后端提现接口的佣金计算逻辑与前端不一致导致计算出的可提现金额为0。
## 问题分析
### 旧逻辑(错误)
```typescript
// ❌ 从 referral_bindings 表查询
const earningsResult = await query(`
SELECT COALESCE(SUM(commission), 0) as total_commission
FROM referral_bindings
WHERE referrer_id = ? AND status = 'converted'
`, [userId])
totalEarnings = parseFloat(earningsResult[0]?.total_commission || 0)
// 计算可提现金额
const availableAmount = totalEarnings - withdrawnAmount
```
**问题**
1.`referral_bindings.commission` 字段查询,但该字段可能未维护或不准确
2. 与前端/分销中心API的计算逻辑不一致
3. 导致后端计算的可提现金额为0
### 正确逻辑
应该与前端和 `/api/referral/data` 保持一致:
```
累计佣金 = SUM(orders.amount WHERE referrer_id = userId AND status = 'paid') × distributorShare
可提现金额 = 累计佣金 - 待审核提现金额
```
## 修复方案
### 1. 修改累计佣金计算
**文件**`app/api/withdraw/route.ts`
```typescript
// ✅ 修正:从 orders 表查询累计佣金(与前端逻辑一致)
let totalCommission = 0
try {
// 读取分成比例
let distributorShare = 0.9 // 默认90%
try {
const config = await getConfig('referral_config')
if (config?.distributorShare) {
distributorShare = Number(config.distributorShare)
}
} catch (e) {
console.warn('[Withdraw] 读取分成比例失败,使用默认值 90%')
}
// 查询订单总金额
const ordersResult = await query(`
SELECT COALESCE(SUM(amount), 0) as total_amount
FROM orders
WHERE referrer_id = ? AND status = 'paid'
`, [userId]) as any[]
const totalAmount = parseFloat(ordersResult[0]?.total_amount || 0)
totalCommission = totalAmount * distributorShare
console.log('[Withdraw] 佣金计算:')
console.log('- 订单总金额:', totalAmount)
console.log('- 分成比例:', distributorShare * 100 + '%')
console.log('- 累计佣金:', totalCommission)
} catch (e) {
console.log('[Withdraw] 查询收益失败:', e)
}
```
### 2. 修改可提现金额计算
```typescript
// 查询待审核提现金额
let pendingWithdrawAmount = 0
try {
const pendingResult = await query(`
SELECT COALESCE(SUM(amount), 0) as pending_amount
FROM withdrawals
WHERE user_id = ? AND status = 'pending'
`, [userId]) as any[]
pendingWithdrawAmount = parseFloat(pendingResult[0]?.pending_amount || 0)
} catch (e) {
console.log('[Withdraw] 查询待审核金额失败:', e)
}
// ✅ 修正:可提现金额 = 累计佣金 - 待审核金额(与前端逻辑一致)
const availableAmount = totalCommission - pendingWithdrawAmount
console.log('[Withdraw] 提现验证:')
console.log('- 累计佣金 (totalCommission):', totalCommission)
console.log('- 待审核金额 (pendingWithdrawAmount):', pendingWithdrawAmount)
console.log('- 可提现金额 (availableAmount):', availableAmount)
console.log('- 申请提现金额 (amount):', amount)
console.log('- 判断:', amount, '>', availableAmount, '=', amount > availableAmount)
if (amount > availableAmount) {
return NextResponse.json({
success: false,
message: `可提现金额不足,当前可提现 ¥${availableAmount.toFixed(2)},待审核 ¥${pendingWithdrawAmount.toFixed(2)}`
})
}
```
## 修改对比
### 数据来源
| 项目 | 旧逻辑 | 新逻辑 |
|------|--------|--------|
| 累计佣金 | `referral_bindings.commission` | `orders.amount × distributorShare` |
| 已提现 | `withdrawals.status = 'completed'` | **改为待审核** |
| 待审核 | ❌ 未查询 | `withdrawals.status = 'pending'` |
| 可提现 | `累计佣金 - 已提现` | `累计佣金 - 待审核` |
### 计算公式
**旧逻辑**
```
累计佣金 = SUM(referral_bindings.commission WHERE status = 'converted')
已提现金额 = SUM(withdrawals.amount WHERE status = 'completed')
可提现金额 = 累计佣金 - 已提现金额 ❌ 错误
```
**新逻辑**
```
订单总金额 = SUM(orders.amount WHERE referrer_id = userId AND status = 'paid')
累计佣金 = 订单总金额 × distributorShare (90%)
待审核金额 = SUM(withdrawals.amount WHERE status = 'pending')
可提现金额 = 累计佣金 - 待审核金额 ✅ 正确
```
## 一致性验证
现在三个地方的逻辑完全一致:
### 1. 前端小程序 (`referral.js`)
```javascript
const totalCommissionNum = realData?.totalCommission || 0
const pendingWithdrawNum = realData?.pendingWithdrawAmount || 0
const availableEarningsNum = totalCommissionNum - pendingWithdrawNum
```
### 2. 分销数据API (`/api/referral/data`)
```typescript
// 计算累计佣金
const totalAmount = SUM(orders.amount WHERE referrer_id = userId AND status = 'paid')
const totalCommission = totalAmount * distributorShare
// 查询待审核金额
const pendingWithdrawAmount = SUM(withdrawals.amount WHERE user_id = userId AND status = 'pending')
// 返回给前端
return {
totalCommission,
pendingWithdrawAmount,
availableEarnings: totalCommission - pendingWithdrawAmount
}
```
### 3. 提现API (`/api/withdraw`)
```typescript
// 计算累计佣金
const totalAmount = SUM(orders.amount WHERE referrer_id = userId AND status = 'paid')
const totalCommission = totalAmount * distributorShare
// 查询待审核金额
const pendingWithdrawAmount = SUM(withdrawals.amount WHERE user_id = userId AND status = 'pending')
// 验证可提现金额
const availableAmount = totalCommission - pendingWithdrawAmount
if (amount > availableAmount) {
return error("可提现金额不足")
}
```
## 测试步骤
### 1. 查看后端日志
提现时应该看到详细日志:
```
[Withdraw] 佣金计算:
- 订单总金额: 9
- 分成比例: 90%
- 累计佣金: 8.1
[Withdraw] 提现验证:
- 累计佣金 (totalCommission): 8.1
- 待审核金额 (pendingWithdrawAmount): 0
- 可提现金额 (availableAmount): 8.1
- 申请提现金额 (amount): 8.1
- 判断: 8.1 > 8.1 = false
```
### 2. 测试提现
```bash
# 测试提现API
curl -X POST http://localhost:3006/api/withdraw \
-H "Content-Type: application/json" \
-d '{
"userId": "ogpTW5fmXRGNpoUbXB3UEqnVe5Tg",
"amount": 8.1
}'
```
**预期结果**
```json
{
"success": true,
"message": "提现成功",
"data": {
"withdrawId": "W1738694028123",
"amount": 8.1,
"account": "...",
"accountType": "微信"
}
}
```
### 3. 验证数据库
```sql
-- 查看待审核提现记录
SELECT * FROM withdrawals WHERE user_id = 'ogpTW5fmXRGNpoUbXB3UEqnVe5Tg' AND status = 'pending';
-- 查看订单总金额
SELECT SUM(amount) as total_amount
FROM orders
WHERE referrer_id = 'ogpTW5fmXRGNpoUbXB3UEqnVe5Tg' AND status = 'paid';
```
## 常见问题
### Q1: 为什么改成"待审核"而不是"已提现"
**A**: 因为提现流程是:
1. 用户申请提现 → 状态 `pending`(待审核)
2. 管理员审核通过 → 状态改为 `completed`(已完成)
在用户申请提现后,这笔金额应该从"可提现"中扣除,所以要减去 `status = 'pending'` 的金额。
### Q2: 如果有多笔待审核提现会怎样?
**A**:
```
累计佣金: 100元
待审核: 30元 + 20元 = 50元
可提现: 100 - 50 = 50元
```
用户只能再申请最多50元的提现。
### Q3: 审核通过后会发生什么?
**A**:
```sql
-- 管理员审核通过
UPDATE withdrawals SET status = 'completed' WHERE id = 'W123';
```
这笔金额从"待审核"变为"已完成",下次计算时:
```
待审核金额减少 → 可提现金额增加(如果有新订单)
```
### Q4: referral_bindings 表还有用吗?
**A**: 有用,但不再用于佣金计算:
- 记录绑定关系
- 记录绑定状态active/expired
- 记录购买次数
- 但佣金数据以 `orders` 表为准
## 部署说明
### 1. 重启服务
修改后需要重启 Next.js 服务:
```bash
# 使用部署脚本重启
python devlop.py restart mycontent
# 或手动重启
pm2 restart mycontent
```
### 2. 查看日志
```bash
# 查看实时日志
pm2 logs mycontent
# 查看最近的日志
pm2 logs mycontent --lines 100
```
### 3. 监控错误
关注以下日志:
- `[Withdraw] 佣金计算:` - 佣金计算是否正确
- `[Withdraw] 提现验证:` - 可提现金额是否准确
- `[Withdraw] 查询收益失败:` - 是否有表不存在等错误
## 相关文件
- `app/api/withdraw/route.ts` - 提现接口(本次修改)
- `app/api/referral/data/route.ts` - 分销数据接口(已统一)
- `miniprogram/pages/referral/referral.js` - 前端逻辑(已统一)
## 总结
这次修复确保了**三端逻辑完全一致**
1. **前端小程序**:显示的可提现金额
2. **分销数据API**:返回的数据
3. **提现接口**:验证的金额
都使用相同的计算方式:
```
可提现金额 = (订单总金额 × 分成比例) - 待审核提现金额
```
这样可以避免前端显示可以提现,但后端验证失败的问题。

View File

@@ -1,200 +0,0 @@
# 提现接口重复问题修复方案
## 问题描述
系统中存在两个功能重复的提现接口,导致维护困难和潜在的数据不一致问题:
1. **`/api/admin/withdrawals`** - 原有的完整接口260行
- 位置:`app/api/admin/withdrawals/route.ts`
- 功能:完整的提现管理,包括用户佣金计算、风险提示等
- 使用场景:后台管理 - 提现审核页面
2. **`/api/db/withdrawals`** - 新创建的简化接口217行
- 位置:`app/api/db/withdrawals/route.ts`
- 功能:简化版提现数据查询
- 使用场景:交易中心页面的提现审核 tab
## 问题分析
### 为什么会失败?
1. **重复逻辑导致混乱**:两个接口都在查询 `withdrawals` 表,但实现细节不同
2. **数据格式不一致**:返回的字段名可能有差异
3. **维护困难**bug 修复需要在两处同步
4. **权限验证重复**:都使用 `requireAdminResponse`,但可能有细微差异
### 根本原因
在实现"交易中心 Tab 按需加载"功能时,误认为需要创建新接口,实际上应该直接使用现有的 `/api/admin/withdrawals`
## 解决方案
### 方案一:统一使用 `/api/admin/withdrawals`(推荐)
**优点**
- ✅ 功能最完整(包含用户佣金详情、风险提示)
- ✅ 代码已经过充分测试
- ✅ 减少维护成本
- ✅ 避免数据不一致
**修改步骤**
#### 1. 修改前端调用3处
**文件**`app/admin/distribution/page.tsx`
```typescript
// 修改前
const withdrawalsRes = await fetch('/api/db/withdrawals')
// 修改后
const withdrawalsRes = await fetch('/api/admin/withdrawals')
```
同样的修改需要在以下3个位置
- Line 237: 查询提现数据
- Line 299: 批准提现
- Line 316: 拒绝提现
#### 2. 删除重复接口
删除文件:`app/api/db/withdrawals/route.ts`
#### 3. 检查数据格式兼容性
`/api/admin/withdrawals` 的返回格式:
```json
{
"success": true,
"withdrawals": [
{
"id": "string",
"user_id": "string",
"user_nickname": "string",
"user_phone": "string",
"user_avatar": "string",
"referral_code": "string",
"amount": "number",
"status": "pending|success|failed",
"created_at": "timestamp",
"processed_at": "timestamp",
"userCommissionInfo": {
"totalCommission": "number",
"withdrawnEarnings": "number",
"pendingWithdrawals": "number",
"availableAfterThis": "number"
}
}
],
"total": "number"
}
```
前端期望的格式(需要检查):
```typescript
interface Withdrawal {
id: string
user_id: string
user_name: string // ⚠️ 对应 user_nickname
user_phone: string
amount: number
status: 'pending' | 'completed' | 'rejected' // ⚠️ 需要状态映射
created_at: string
completed_at: string // ⚠️ 对应 processed_at
userCommissionInfo?: {
totalCommission: number
withdrawnEarnings: number
pendingWithdrawals: number
availableAfterThis: number
}
}
```
#### 4. 前端数据映射(如需要)
如果字段名不完全匹配,在前端做映射:
```typescript
const formattedWithdrawals = withdrawalsData.withdrawals.map((w: any) => ({
...w,
user_name: w.user_nickname, // 字段名映射
completed_at: w.processed_at,
// 状态映射在后端已完成,无需前端处理
}))
```
### 方案二:保留两个接口,明确分工(不推荐)
如果确实需要两个接口,应该:
1. **重命名并明确用途**
- `/api/admin/withdrawals` → 完整管理接口(包含风险计算)
- `/api/db/withdrawals` → 精简列表接口(仅基础字段)
2. **文档化差异**:在每个文件顶部注释说明用途和差异
3. **同步关键逻辑**:状态映射、权限验证必须保持一致
**但这仍然不推荐**,因为会增加维护负担。
## 实施步骤
### 第1步修改前端调用
```bash
# 在 app/admin/distribution/page.tsx 中全局替换
/api/db/withdrawals → /api/admin/withdrawals
```
### 第2步删除重复接口
```bash
rm app/api/db/withdrawals/route.ts
```
### 第3步测试验证
1. 启动开发服务器:`npm run dev`
2. 登录后台管理
3. 进入"交易中心" - "提现审核" tab
4. 检查:
- ✅ 提现列表能正常加载
- ✅ 用户信息显示正确
- ✅ 佣金详情显示正确(累计、已提现、待审核、可提现)
- ✅ 批准提现功能正常
- ✅ 拒绝提现功能正常
- ✅ 风险提示(负余额)正常显示
### 第4步更新文档
删除或更新所有提到 `/api/db/withdrawals` 的文档。
## 预期效果
**接口统一**:只有一个提现数据接口,逻辑清晰
**功能完整**:保留所有用户佣金计算和风险提示功能
**易于维护**bug 修复和功能升级只需修改一处
**数据一致**:避免两个接口返回不同数据导致的问题
## 风险提示
⚠️ **修改前请备份**:虽然修改范围小,但涉及核心财务功能
⚠️ **充分测试**:修改后务必测试所有提现相关功能
⚠️ **状态映射**:确认前端期望的状态值(`completed`/`rejected` vs `success`/`failed`
## 后续优化建议
1. **统一状态枚举**:前后端使用相同的状态值,避免映射
2. **统一字段命名**`user_name` vs `user_nickname` 应统一
3. **接口文档化**:为 `/api/admin/withdrawals` 编写完整的 API 文档
4. **单元测试**:为提现接口添加自动化测试
## 参考
- 原接口:`app/api/admin/withdrawals/route.ts`
- 前端调用:`app/admin/distribution/page.tsx` (Line 237, 299, 316)
- 相关文档:`开发文档/8、部署/后台提现审核数据对接.md`

View File

@@ -1,454 +0,0 @@
# 提现记录获取失败诊断指南
## 问题描述
在后台管理的"交易中心-提现审核" tab 中,提示"获取提现记录失败"。
## 诊断步骤
### 1. 查看浏览器控制台
**操作**
1. 打开 `http://localhost:3006/admin/distribution`
2. 按 F12 打开开发者工具
3. 切换到 Console 标签
4. 点击"提现审核" tab
5. 查看控制台输出
**期望看到的日志**
```
[Admin] 加载初始数据...
[Admin] 概览数据加载成功
[Admin] 用户数据加载成功
[Admin] 加载 withdrawals 数据...
[Admin] 请求提现数据...
[Admin] 提现接口响应状态: 200 OK
[Admin] 提现接口返回数据: { success: true, withdrawals: [...] }
[Admin] 提现数据加载成功: X 条
```
**可能的错误情况**
#### 情况 1权限错误401
```
[Admin] 提现接口响应状态: 401 Unauthorized
[Admin] 提现接口HTTP错误: 401 {"error":"未授权访问,请先登录"}
```
**原因**:未登录或 session 过期
**解决**
1. 访问 `/admin/login` 重新登录
2. 确认登录后的 Cookie `admin_session` 存在
#### 情况 2表不存在500
```
[Admin] 提现接口响应状态: 500 Internal Server Error
[Admin] 提现接口返回失败: Table 'mycontent.withdrawals' doesn't exist
```
**原因**:数据库中 `withdrawals` 表未创建
**解决**
```sql
CREATE TABLE IF NOT EXISTS withdrawals (
id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status ENUM('pending', 'processing', 'success', 'failed') DEFAULT 'pending',
wechat_openid VARCHAR(100),
transaction_id VARCHAR(100),
error_message VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_status (status),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
或访问 `/api/db/init` 初始化所有表。
#### 情况 3数据库连接失败500
```
[Admin] 提现接口响应状态: 500 Internal Server Error
[Admin] 提现接口返回失败: Connection lost: The server closed the connection
```
**原因**:数据库连接问题
**解决**
1. 检查数据库是否运行
2. 检查 `.env` 中的数据库配置:
```
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=123456
DB_NAME=mycontent
```
3. 重启数据库服务
4. 重启 Next.js 服务
#### 情况 4CORS 错误
```
Access to fetch at 'http://localhost:3006/api/db/withdrawals' from origin 'http://localhost:3000' has been blocked by CORS policy
```
**原因**:跨域请求被阻止
**解决**
- 确保访问的是同一端口(都是 3006
- 检查 Next.js 配置
#### 情况 5网络错误
```
[Admin] 加载提现数据异常: TypeError: Failed to fetch
```
**原因**:网络请求失败或服务未启动
**解决**
1. 确认服务正在运行:`pm2 list` 或查看终端
2. 检查端口 3006 是否被占用
3. 尝试重启服务
### 2. 查看 Network 标签
**操作**
1. 按 F12 打开开发者工具
2. 切换到 Network 标签
3. 点击"提现审核" tab
4. 找到 `/api/db/withdrawals` 请求
5. 查看详细信息
**检查项**
| 检查项 | 期望值 | 问题 |
|-------|--------|------|
| Status | 200 OK | 401权限问题<br/>500服务器错误<br/>404接口不存在 |
| Response Headers | `Content-Type: application/json` | 如果是 `text/html`,说明返回了错误页面 |
| Response Body | `{"success": true, "withdrawals": [...]}` | 查看具体错误信息 |
| Request Headers | 包含 `Cookie: admin_session=...` | 缺少则是权限问题 |
### 3. 查看服务器日志
**操作**
```powershell
# 使用 pm2
pm2 logs mycontent --lines 50
# 或查看终端输出
# 直接查看 npm run dev 的终端
```
**期望看到的日志**
```
[DB Withdrawals] 提现表检查/创建成功
[DB Withdrawals] 查询提现记录...
[DB Withdrawals] 查询成功,记录数: 0
```
**可能的错误日志**
```
[DB Withdrawals] 提现表创建失败: ER_ACCESS_DENIED_ERROR
```
**解决**:数据库用户权限不足,需要 CREATE TABLE 权限
```
[DB Withdrawals] SQL查询失败: ER_NO_SUCH_TABLE
```
**解决**:表不存在,需要创建表
```
[DB Withdrawals] 查询失败: Connection lost
```
**解决**:数据库连接问题,检查配置
### 4. 手动测试 API
**使用浏览器**
```
http://localhost:3006/api/db/withdrawals
```
**使用 curl**(需要先获取 admin_session cookie
```powershell
curl http://localhost:3006/api/db/withdrawals `
-H "Cookie: admin_session=your-token-here" `
-v
```
**期望响应**
```json
{
"success": true,
"withdrawals": [],
"total": 0
}
```
或(如果有数据):
```json
{
"success": true,
"withdrawals": [
{
"id": "W_xxx",
"user_id": "user123",
"user_name": "张三",
"amount": 50.00,
"status": "pending",
"created_at": "2026-02-04 10:00:00"
}
],
"total": 1
}
```
### 5. 检查数据库
**连接数据库**
```sql
USE mycontent;
-- 检查表是否存在
SHOW TABLES LIKE 'withdrawals';
-- 查看表结构
DESC withdrawals;
-- 查看数据
SELECT * FROM withdrawals LIMIT 10;
-- 检查用户表(关联查询需要)
DESC users;
```
**预期结果**
- `withdrawals` 表存在
- 表结构正确
- 可以正常查询
## 常见问题修复
### 问题 1权限验证失败
**症状**:返回 401 Unauthorized
**修复**
1. 确认已登录管理后台
2. 检查 Cookie 中是否有 `admin_session`
3. 清除浏览器缓存后重新登录
4. 检查 `lib/admin-auth.ts` 中的验证逻辑
### 问题 2表不存在
**症状**:返回 500错误信息 "Table doesn't exist"
**修复方式 1**(推荐):
```
访问 http://localhost:3006/api/db/init
```
这会自动创建所有缺失的表。
**修复方式 2**(手动):
执行 `scripts/check-withdrawals-data.sql` 中的建表语句。
**修复方式 3**(代码触发):
访问 `/api/db/withdrawals` 会自动调用 `ensureWithdrawalsTable()` 创建表。
### 问题 3数据库连接失败
**症状**:返回 500错误信息 "Connection lost" 或 "ECONNREFUSED"
**修复**
1. **检查数据库服务**
```powershell
# MySQL
mysqld --version
```
2. **检查环境变量**`.env` 文件):
```
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=123456
DB_NAME=mycontent
```
3. **测试数据库连接**
```powershell
mysql -h localhost -u root -p123456 mycontent -e "SELECT 1"
```
4. **重启服务**
```powershell
pm2 restart mycontent
```
### 问题 4查询返回空数组
**症状**:接口成功但 `withdrawals: []`
**原因**:数据库中确实没有提现记录
**验证**
```sql
SELECT COUNT(*) FROM withdrawals;
```
**解决**
- 如果是正常情况(没有用户提现),这是预期行为
- 如果需要测试数据,可以插入测试记录:
```sql
INSERT INTO withdrawals (id, user_id, amount, status, created_at)
VALUES ('W_TEST_001', 'test_user', 50.00, 'pending', NOW());
```
### 问题 5前端没有显示错误
**症状**:页面加载但没有数据,也没有错误提示
**原因**:错误被静默处理
**修复**
1. 打开浏览器 Console 查看日志
2. 检查是否有 `try-catch` 吞掉了错误
3. 查看我们刚添加的详细错误日志
## 增强的错误处理
我已经增强了前端的错误处理,现在会:
1. ✅ 在 Console 输出详细的请求和响应信息
2. ✅ 显示明确的错误提示弹窗
3. ✅ 记录 HTTP 状态码和错误消息
4. ✅ 区分不同类型的错误(权限、服务器、网络)
**新增的日志**
```javascript
[Admin] 请求提现数据...
[Admin] 提现接口响应状态: 200 OK
[Admin] 提现接口返回数据: {...}
[Admin] 提现数据加载成功: 5 条
```
**错误提示**
- 权限错误:`获取提现记录失败 (401): 未授权访问,请先登录`
- 服务器错误:`获取提现记录失败 (500): Table doesn't exist`
- 网络错误:`加载提现数据失败: Failed to fetch`
## 测试修复
### 1. 重启服务
```powershell
pm2 restart mycontent
# 或
npm run dev
```
### 2. 清除缓存
```
Ctrl + F5 强制刷新页面
```
### 3. 测试流程
1. 访问 `http://localhost:3006/admin/distribution`
2. 点击"提现审核" tab
3. 查看 Console 输出
4. 如果有错误,会弹窗显示具体信息
5. 根据错误信息按照上述"常见问题修复"处理
### 4. 成功标志
- ✅ Console 显示 `[Admin] 提现数据加载成功: X 条`
- ✅ 页面显示提现列表(或"暂无数据"
- ✅ 没有错误弹窗
- ✅ Network 标签显示 200 OK
## 预防措施
### 1. 定期检查数据库连接
在 `lib/db.ts` 中添加心跳检测:
```typescript
setInterval(async () => {
try {
await query('SELECT 1')
} catch (error) {
console.error('[DB] 数据库连接检查失败:', error)
}
}, 60000) // 每分钟检查一次
```
### 2. 添加健康检查接口
```typescript
// app/api/health/route.ts
export async function GET() {
try {
await query('SELECT 1')
return NextResponse.json({ status: 'ok', db: 'connected' })
} catch (error) {
return NextResponse.json(
{ status: 'error', db: 'disconnected', error: error.message },
{ status: 500 }
)
}
}
```
访问 `/api/health` 检查系统状态。
### 3. 使用数据库连接池
确保 `lib/db.ts` 使用连接池:
```typescript
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
})
```
## 相关文件
- **前端页面**`app/admin/distribution/page.tsx`
- **后端 API**`app/api/db/withdrawals/route.ts`
- **权限验证**`lib/admin-auth.ts`
- **数据库配置**`lib/db.ts`
- **测试脚本**`scripts/test-withdrawals-api.js`
- **数据检查 SQL**`scripts/check-withdrawals-data.sql`
## 获取帮助
如果按照上述步骤仍无法解决,请提供以下信息:
1. **浏览器 Console 完整日志**(包括 `[Admin]` 开头的所有日志)
2. **Network 标签中 `/api/db/withdrawals` 的完整请求和响应**
3. **服务器日志**`pm2 logs` 或终端输出)
4. **数据库查询结果**
```sql
SHOW TABLES LIKE 'withdrawals';
DESC withdrawals;
SELECT COUNT(*) FROM withdrawals;
```
## 版本信息
- **更新时间**2026-02-04
- **修改内容**
1. 增强前端错误处理和日志输出
2. 添加详细的错误提示弹窗
3. 创建诊断和修复指南
4. 提供测试脚本和 SQL 检查工具

View File

@@ -1,733 +0,0 @@
# 分销中心收益明细优化说明
## 📋 需求
在分销中心的"收益明细"部分,显示更详细的购买信息:
1. 购买用户的头像
2. 购买用户的昵称
3. 购买的书籍和章节
4. 下单时间
---
## ✅ 实现方案
### 修改前
```
┌─────────────────────────────┐
│ 🎁 整本书购买 │
│ 12-25 │
│ +¥0.90 │
└─────────────────────────────┘
```
**问题**:
- ❌ 不知道是谁购买的
- ❌ 不知道买的哪本书、哪一章
- ❌ 信息太简略
---
### 修改后
```
┌─────────────────────────────┐
│ 👤 张三 +¥0.90 │ ← 头像 + 昵称 + 佣金
│ 《Soul创业派对》- 1.1 │ ← 书名 - 章节
│ 12-25 │ ← 购买时间
└─────────────────────────────┘
```
**优势**:
- ✅ 显示买家头像和昵称
- ✅ 显示具体书籍和章节
- ✅ 信息完整、清晰
---
## 🔧 实现细节
### 1. 后端 API 增强
**文件**: `app/api/referral/data/route.ts`
**修改前**第159-170行:
```typescript
earningsDetails = await query(`
SELECT o.id, o.order_sn, o.amount, o.product_type, o.pay_time,
u.nickname as buyer_nickname,
rb.commission_amount
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN referral_bindings rb ON o.user_id = rb.referee_id AND rb.referrer_id = ?
WHERE o.status = 'paid'
ORDER BY o.pay_time DESC
LIMIT 30
`, [userId])
```
**修改后**:
```typescript
earningsDetails = await query(`
SELECT
o.id,
o.order_sn,
o.amount,
o.product_type,
o.product_id,
o.description, -- ✅ 新增:商品描述(书名-章节)
o.pay_time,
u.nickname as buyer_nickname,
u.avatar as buyer_avatar, -- ✅ 新增:买家头像
rb.total_commission / rb.purchase_count as commission_per_order
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN referral_bindings rb ON o.user_id = rb.referee_id AND rb.referrer_id = ?
WHERE o.status = 'paid' AND o.referrer_id = ?
ORDER BY o.pay_time DESC
LIMIT 30
`, [userId, userId])
```
**新增字段**:
-`description` - 商品描述(如"《Soul创业派对》- 1.1 派对房的秘密"
-`buyer_avatar` - 买家头像URL
-`product_id` - 商品ID如章节ID
---
### 2. 后端返回数据格式
**文件**: `app/api/referral/data/route.ts` 第261-272行
**修改前**:
```typescript
earningsDetails: earningsDetails.map((e: any) => ({
id: e.id,
productType: e.product_type,
commission: parseFloat(e.commission_amount),
buyerNickname: e.buyer_nickname,
payTime: e.pay_time
}))
```
**修改后**:
```typescript
earningsDetails: earningsDetails.map((e: any) => ({
id: e.id,
orderSn: e.order_sn,
amount: parseFloat(e.amount),
commission: parseFloat(e.commission_per_order) || parseFloat(e.amount) * distributorShare,
productType: e.product_type,
productId: e.product_id,
description: e.description, // ✅ 新增
buyerNickname: e.buyer_nickname || '用户' + e.id?.toString().slice(-4),
buyerAvatar: e.buyer_avatar, // ✅ 新增
payTime: e.pay_time
}))
```
---
### 3. 小程序解析商品描述
**文件**: `miniprogram/pages/referral/referral.js`
**新增函数**:
```javascript
// 解析商品描述,获取书名和章节
parseProductDescription(description, productType) {
if (!description) {
return {
bookTitle: '未知商品',
chapterTitle: ''
}
}
// 匹配格式:《书名》- 章节名
const match = description.match(/《(.+?)》(?:\s*-\s*(.+))?/)
if (match) {
return {
bookTitle: match[1] || '未知书籍',
chapterTitle: match[2] || (productType === 'fullbook' ? '全书购买' : '')
}
}
// 如果匹配失败,直接返回原始描述
return {
bookTitle: description.split('-')[0] || description,
chapterTitle: description.split('-')[1] || ''
}
}
```
**解析示例**:
| 原始 description | bookTitle | chapterTitle |
|------------------|-----------|--------------|
| 《Soul创业派对》- 1.1 派对房的秘密 | Soul创业派对 | 1.1 派对房的秘密 |
| 《Soul创业派对》- 全书购买 | Soul创业派对 | 全书购买 |
| 《Soul创业派对》 | Soul创业派对 | (空)|
---
### 4. 小程序数据格式化
**文件**: `miniprogram/pages/referral/referral.js` 第179-193行
**修改前**:
```javascript
earningsDetails: (realData?.earningsDetails || []).map(item => ({
id: item.id,
productType: item.productType,
commission: (item.commission || 0).toFixed(2),
payTime: item.payTime ? this.formatDate(item.payTime) : '--',
buyerNickname: item.buyerNickname
}))
```
**修改后**:
```javascript
earningsDetails: (realData?.earningsDetails || []).map(item => {
// 解析商品描述,获取书名和章节
const productInfo = this.parseProductDescription(item.description, item.productType)
return {
id: item.id,
productType: item.productType,
bookTitle: productInfo.bookTitle, // ✅ 新增:书名
chapterTitle: productInfo.chapterTitle, // ✅ 新增:章节
commission: (item.commission || 0).toFixed(2),
payTime: item.payTime ? this.formatDate(item.payTime) : '--',
buyerNickname: item.buyerNickname || '用户',
buyerAvatar: item.buyerAvatar // ✅ 新增:头像
}
})
```
---
### 5. 小程序 UI 重构
**文件**: `miniprogram/pages/referral/referral.wxml` 第213-231行
**修改前**:
```xml
<view class="detail-item" wx:for="{{earningsDetails}}" wx:key="id">
<view class="detail-left">
<view class="detail-icon">
<image class="icon-gift" src="/assets/icons/gift.svg" mode="aspectFit"></image>
</view>
<view class="detail-info">
<text class="detail-type">{{item.productType === 'fullbook' ? '整本书购买' : '单节购买'}}</text>
<text class="detail-time">{{item.payTime}}</text>
</view>
</view>
<text class="detail-amount">+¥{{item.commission}}</text>
</view>
```
**修改后**:
```xml
<view class="detail-item" wx:for="{{earningsDetails}}" wx:key="id">
<!-- 买家头像 -->
<view class="detail-avatar-wrap">
<image
class="detail-avatar"
wx:if="{{item.buyerAvatar}}"
src="{{item.buyerAvatar}}"
mode="aspectFill"
/>
<view class="detail-avatar-text" wx:else>
{{item.buyerNickname.charAt(0)}}
</view>
</view>
<!-- 详细信息 -->
<view class="detail-content">
<view class="detail-top">
<text class="detail-buyer">{{item.buyerNickname}}</text>
<text class="detail-amount">+¥{{item.commission}}</text>
</view>
<view class="detail-product">
<text class="detail-book">{{item.bookTitle}}</text>
<text class="detail-chapter" wx:if="{{item.chapterTitle}}"> - {{item.chapterTitle}}</text>
</view>
<text class="detail-time">{{item.payTime}}</text>
</view>
</view>
```
---
### 6. 样式优化
**文件**: `miniprogram/pages/referral/referral.wxss`
**新增样式**:
```css
/* 收益明细增强样式 */
.detail-item {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx;
background: rgba(255, 255, 255, 0.02);
border-radius: 16rpx;
margin-bottom: 16rpx;
}
.detail-avatar-wrap {
flex-shrink: 0;
}
.detail-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
border: 2rpx solid rgba(56, 189, 172, 0.2);
}
.detail-avatar-text {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: linear-gradient(135deg, #38bdac 0%, #2da396 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
font-weight: 700;
color: #ffffff;
}
.detail-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.detail-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.detail-buyer {
font-size: 28rpx;
font-weight: 500;
color: #ffffff;
}
.detail-amount {
font-size: 32rpx;
font-weight: 700;
color: #38bdac;
}
.detail-product {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
}
.detail-book {
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
}
.detail-chapter {
color: rgba(255, 255, 255, 0.5);
}
.detail-time {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.4);
}
```
---
## 🎨 UI 效果对比
### 修改前 ❌
```
┌──────────────────────────────┐
│ 🎁 整本书购买 +¥0.90 │
│ 12-25 │
└──────────────────────────────┘
```
**信息量**: 只有类型、时间、金额
---
### 修改后 ✅
```
┌──────────────────────────────┐
│ 👤 │
│ 张三 +¥0.90 │ ← 头像 + 昵称 + 佣金
│ 《Soul创业派对》- 1.1 派对房的秘密
│ 12-25 │ ← 时间
└──────────────────────────────┘
```
**信息量**: 头像、昵称、书名、章节、金额、时间 ✅
---
## 📊 数据流转
```
订单创建
orders 表记录:
- user_id (买家ID)
- description (商品描述)
- amount (金额)
- pay_time (支付时间)
后端 API 查询:
- JOIN users 获取买家信息(昵称、头像)
- 返回 description、buyerAvatar 等
小程序解析:
- parseProductDescription() 解析书名和章节
- formatDate() 格式化时间
UI 显示:
- 头像(有则显示,无则显示首字母)
- 昵称、书名、章节、时间、佣金
```
---
## 🎯 显示逻辑
### 1. 头像显示
```xml
<!-- 如果有头像 -->
<image class="detail-avatar" src="{{item.buyerAvatar}}" />
<!-- 如果没有头像 -->
<view class="detail-avatar-text">
{{item.buyerNickname.charAt(0)}} <!-- 显示昵称首字母 -->
</view>
```
**效果**:
- 有头像:显示圆形头像(带品牌色边框)
- 无头像:显示品牌渐变背景 + 昵称首字母
---
### 2. 商品信息解析
**输入**: `《Soul创业派对》- 1.1 派对房的秘密`
**解析函数**:
```javascript
parseProductDescription(description, productType) {
const match = description.match(/《(.+?)》(?:\s*-\s*(.+))?/)
if (match) {
return {
bookTitle: match[1], // "Soul创业派对"
chapterTitle: match[2] // "1.1 派对房的秘密"
}
}
}
```
**显示**:
```xml
<view class="detail-product">
<text class="detail-book">{{item.bookTitle}}</text>
<text class="detail-chapter"> - {{item.chapterTitle}}</text>
</view>
```
**效果**: `Soul创业派对 - 1.1 派对房的秘密`
---
### 3. 全书购买特殊处理
**输入**: `《Soul创业派对》- 全书购买`
**解析**:
- `bookTitle`: "Soul创业派对"
- `chapterTitle`: "全书购买"
**显示**: `Soul创业派对 - 全书购买`
---
### 4. 时间格式化
**输入**: `2026-02-04 15:30:00`
**格式化**:
```javascript
formatDate(dateStr) {
const d = new Date(dateStr)
const month = (d.getMonth() + 1).toString().padStart(2, '0')
const day = d.getDate().toString().padStart(2, '0')
return `${month}-${day}`
}
```
**输出**: `02-04`
---
## 🎨 视觉设计
### 布局结构
```
┌─────────────────────────────────────┐
│ ┌──────┐ ┌──────────────────────┐ │
│ │ │ │ 昵称 +¥金额 │ │
│ │ 头像 │ │ 书名 - 章节 │ │
│ │ │ │ 时间 │ │
│ └──────┘ └──────────────────────┘ │
└─────────────────────────────────────┘
```
### 配色方案
| 元素 | 颜色 | 说明 |
|------|------|------|
| 头像边框 | `rgba(56, 189, 172, 0.2)` | 品牌色半透明 |
| 头像背景(无图)| `#38bdac → #2da396` | 品牌渐变 |
| 昵称 | `#ffffff` | 白色 |
| 佣金 | `#38bdac` | 品牌色(醒目)|
| 书名 | `rgba(255, 255, 255, 0.7)` | 白色70% |
| 章节 | `rgba(255, 255, 255, 0.5)` | 白色50% |
| 时间 | `rgba(255, 255, 255, 0.4)` | 白色40% |
---
## 📦 修改文件清单
| 文件 | 修改内容 | 状态 |
|------|----------|------|
| `app/api/referral/data/route.ts` | SQL查询增加 description、buyer_avatar | ✅ |
| `app/api/referral/data/route.ts` | 返回数据添加新字段 | ✅ |
| `miniprogram/pages/referral/referral.js` | 添加 parseProductDescription 函数 | ✅ |
| `miniprogram/pages/referral/referral.js` | earningsDetails 数据处理逻辑 | ✅ |
| `miniprogram/pages/referral/referral.wxml` | 重构收益明细 UI | ✅ |
| `miniprogram/pages/referral/referral.wxss` | 添加新样式 | ✅ |
---
## 🧪 测试用例
### 测试1: 完整信息显示
**数据**:
```json
{
"buyerNickname": "张三",
"buyerAvatar": "https://...",
"description": "《Soul创业派对》- 1.1 派对房的秘密",
"commission": 0.90,
"payTime": "2026-02-04 15:30:00"
}
```
**预期显示**:
```
[头像] 张三 +¥0.90
Soul创业派对 - 1.1 派对房的秘密
02-04
```
---
### 测试2: 无头像用户
**数据**:
```json
{
"buyerNickname": "李四",
"buyerAvatar": null,
"description": "《Soul创业派对》- 全书购买",
"commission": 8.91,
"payTime": "2026-02-03 10:20:00"
}
```
**预期显示**:
```
[李] 李四 +¥8.91 ← 显示"李"(品牌色圆圈)
Soul创业派对 - 全书购买
02-03
```
---
### 测试3: 全书购买
**数据**:
```json
{
"buyerNickname": "王五",
"description": "《Soul创业派对》- 全书购买",
"productType": "fullbook"
}
```
**预期显示**:
```
[王] 王五 +¥8.91
Soul创业派对 - 全书购买
02-03
```
---
## 🔍 技术细节
### 1. 正则表达式解析
```javascript
const match = description.match(/《(.+?)》(?:\s*-\s*(.+))?/)
```
**匹配规则**:
- `《(.+?)》` - 匹配书名(在《》内)
- `(?:\s*-\s*(.+))?` - 可选匹配章节(` - ` 后的内容)
**示例**:
- `《Soul创业派对》- 1.1 派对房的秘密``["Soul创业派对", "1.1 派对房的秘密"]`
- `《Soul创业派对》``["Soul创业派对", undefined]`
---
### 2. 头像兜底方案
```xml
<!-- 优先显示真实头像 -->
<image wx:if="{{item.buyerAvatar}}" src="{{item.buyerAvatar}}" />
<!-- 无头像时显示首字母 -->
<view wx:else>{{item.buyerNickname.charAt(0)}}</view>
```
**charAt(0)**: 获取昵称第一个字符
- "张三" → "张"
- "Soul用户" → "S"
- "用户1234" → "用"
---
### 3. 文字溢出处理
```css
.detail-product {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
```
**作用**: 如果章节名太长,自动省略显示 `...`
**示例**:
- 正常:`Soul创业派对 - 1.1 派对房的秘密`
- 超长:`Soul创业派对 - 1.1 派对房的秘密以及后续的...`
---
## 📱 响应式适配
### 小屏手机
```
┌────────────────────────┐
│ 👤 张三 +¥0.90 │ ← 紧凑布局
│ Soul创业派对 - 1.1 │
│ 02-04 │
└────────────────────────┘
```
### 大屏手机
```
┌──────────────────────────────┐
│ 👤 张三 +¥0.90 │ ← 舒适间距
│ Soul创业派对 - 1.1 派对房的秘密
│ 02-04 │
└──────────────────────────────┘
```
**自适应**: 使用 `rpx` 单位,自动适配不同屏幕
---
## ✨ 完成效果
### 收益明细卡片
```
┌─────────────────────────────────┐
│ 收益明细 │
├─────────────────────────────────┤
│ 👤 张三 +¥0.90 │
│ Soul创业派对 - 1.1 派对房的秘密
│ 02-04 │
├─────────────────────────────────┤
│ 👤 李四 +¥8.91 │
│ Soul创业派对 - 全书购买 │
│ 02-03 │
├─────────────────────────────────┤
│ [王] 王五 +¥0.90 │ ← 无头像显示首字母
│ Soul创业派对 - 2.3 资源整合 │
│ 02-02 │
└─────────────────────────────────┘
```
---
## 🚀 部署说明
### 无需数据库修改
所有需要的字段(`description``avatar`)都已存在,只需部署代码即可。
---
### 验证步骤
1. 部署新代码
2. 打开分销中心
3. 查看"收益明细"
4. 验证显示:
- ✅ 买家头像或首字母
- ✅ 买家昵称
- ✅ 书名和章节
- ✅ 购买时间
- ✅ 佣金金额
---
## 📊 信息完整度提升
| 维度 | 修改前 | 修改后 |
|------|--------|--------|
| 买家信息 | ❌ 无 | ✅ 头像 + 昵称 |
| 商品信息 | ❌ 只有类型 | ✅ 书名 + 章节 |
| 金额信息 | ✅ 佣金 | ✅ 佣金 |
| 时间信息 | ✅ 日期 | ✅ 日期 |
**信息完整度**: 30% → **100%**
---
**现在收益明细显示完整,推广者可以清楚看到每笔收益的详细来源!** 🎉

View File

@@ -1,381 +0,0 @@
# 新分销逻辑 - 代码修改总结
## ✅ 已完成的代码修改
### 1. 数据库层Database Layer
#### 迁移脚本
-`scripts/migration-add-binding-fields.sql`SQL版本
-`scripts/migrate_binding_fields.py`Python完整版
-`scripts/migrate_db_simple.py`Python简化版- **已执行成功**
#### 新增字段
```sql
referral_bindings 表:
last_purchase_date DATETIME - 最后购买时间
purchase_count INT - 购买次数
total_commission DECIMAL(10,2) - 累计佣金
status 新增枚举值 'cancelled' - 被切换状态
```
#### 新增索引
```sql
idx_referee_status (referee_id, status)
idx_expiry_purchase (expiry_date, purchase_count, status)
```
---
### 2. 核心业务逻辑Business Logic
#### 2.1 绑定API`app/api/referral/bind/route.ts`
**修改前**
```typescript
// ❌ 有效期内不能切换
if (expiryDate < now) {
// 已过期才能抢夺
} else {
return { error: '绑定有效期内无法更换' }
}
```
**修改后**
```typescript
// ✅ 立即切换(无条件)
if (existing.referrer_id === referrer.id) {
action = 'renew' // 同一推荐人:续期
} else {
action = 'switch' // 不同推荐人:立即切换
// 旧绑定标记为 cancelled
await query("UPDATE referral_bindings SET status = 'cancelled' WHERE id = ?", [existing.id])
}
```
**核心变化**
- ✅ 删除"有效期内不能切换"限制
- ✅ 点击新链接立即切换推荐人
- ✅ 旧绑定标记为 `cancelled`(不是 `expired`
- ✅ 新绑定重新计算30天
---
#### 2.2 支付回调:`app/api/miniprogram/pay/notify/route.ts`
**修改前**
```typescript
// ❌ 购买后标记为 converted不再累加
await query(`
UPDATE referral_bindings
SET status = 'converted',
commission_amount = ?
WHERE id = ?
`, [commission, binding.id])
```
**修改后**
```typescript
// ✅ 保持 active累加购买次数和佣金
await query(`
UPDATE referral_bindings
SET last_purchase_date = CURRENT_TIMESTAMP,
purchase_count = purchase_count + 1,
total_commission = total_commission + ?
WHERE id = ?
`, [commission, binding.id])
```
**核心变化**
- ✅ 不再改变 `status`(保持 `active`
- ✅ 累加 `purchase_count`
- ✅ 累加 `total_commission`
- ✅ 记录 `last_purchase_date`
- ✅ 支持同一绑定多次购买分佣
---
#### 2.3 支付订单:`app/api/miniprogram/pay/route.ts`
**新增功能**:好友优惠折扣
```typescript
// ✅ 读取好友优惠配置
const referralConfig = await getConfig('referral_config')
const userDiscount = referralConfig?.userDiscount || 0
// ✅ 如果有推荐码,应用折扣
if (userDiscount > 0 && body.referralCode) {
const discountRate = userDiscount / 100
finalAmount = amount * (1 - discountRate)
// 原价 1.00 → 优惠 5% → 实付 0.95
}
```
**核心变化**
- ✅ 通过推荐链接购买会自动打折
- ✅ 折扣比例从后台配置读取
- ✅ 佣金基于实付金额计算
---
#### 2.4 提现API`app/api/withdraw/route.ts`
**新增功能**:读取最低提现门槛
```typescript
// ✅ 从配置读取最低提现门槛
const config = await getConfig('referral_config')
const minWithdrawAmount = config?.minWithdrawAmount || 10
// ✅ 检查最低门槛
if (amount < minWithdrawAmount) {
return { error: `最低提现金额为 ¥${minWithdrawAmount}` }
}
```
**核心变化**
- ✅ 提现门槛可通过后台配置
- ✅ 替代了硬编码的 10 元
---
### 3. 管理后台Admin Panel
#### 3.1 推广设置页面:`app/admin/referral-settings/page.tsx`
**新增功能**
- ✅ 配置好友优惠userDiscount
- ✅ 配置推广者分成distributorShare
- ✅ 配置绑定有效期bindingDays
- ✅ 配置最低提现金额minWithdrawAmount
- ✅ 配置自动提现开关enableAutoWithdraw
**数据安全**
```typescript
// ✅ 保存时强制类型转换
const safeConfig = {
distributorShare: Number(config.distributorShare) || 0,
minWithdrawAmount: Number(config.minWithdrawAmount) || 0,
bindingDays: Number(config.bindingDays) || 0,
userDiscount: Number(config.userDiscount) || 0,
enableAutoWithdraw: Boolean(config.enableAutoWithdraw),
}
```
---
#### 3.2 菜单入口:`app/admin/layout.tsx`
```typescript
// ✅ 新增菜单项
{ icon: CreditCard, label: "推广设置", href: "/admin/referral-settings" }
```
---
### 4. 小程序MiniProgram
#### 4.1 分销中心UI`miniprogram/pages/referral/referral.wxml`
**修改**
```xml
<!-- ✅ 删除"我的邀请码"卡片 -->
<!-- 保留分享按钮和收益明细 -->
```
---
### 5. 定时任务Scheduled Task
#### 自动解绑脚本
-`scripts/auto-unbind-expired.js`(标准版)
-`scripts/auto-unbind-expired-simple.js`简化版直接连MySQL
**解绑条件**
```javascript
WHERE status = 'active'
AND expiry_date < NOW()
AND purchase_count = 0
```
**执行逻辑**
1. 查询符合条件的绑定
2. 标记为 `expired`
3. 更新推荐人的 `referral_count`
4. 输出日志
---
## 📋 完整的业务流程
### 场景1新用户绑定
```
用户操作B 点击 A 的分享链接
触发API/api/referral/bind
数据变化:
- referral_bindings 新增记录
- referee_id: B
- referrer_id: A
- status: active
- expiry_date: NOW + 30天
- purchase_count: 0
- users.referred_by: A
- users.referral_count (A): +1
```
---
### 场景2切换推荐人
```
用户操作B 点击 C 的分享链接
触发API/api/referral/bind
数据变化:
- 旧绑定 (A -> B):
- status: active → cancelled
- 新绑定 (C -> B):
- 新增记录
- status: active
- expiry_date: NOW + 30天
- users.referred_by: A → C
- users.referral_count (A): -1
- users.referral_count (C): +1
```
---
### 场景3购买分佣
```
用户操作B 购买文章1元假设无优惠
触发API/api/miniprogram/pay/notify
数据变化:
- referral_bindings (C -> B):
- purchase_count: 0 → 1
- total_commission: 0 → 0.90
- last_purchase_date: NOW
- status: 保持 active
- users.pending_earnings (C): +0.90
```
---
### 场景4好友优惠购买
```
用户操作B 通过推荐链接购买原价1元优惠5%
触发API/api/miniprogram/pay
计算逻辑:
- 原价: 1.00元
- 优惠: 1.00 × 5% = 0.05元
- 实付: 0.95元
后续分佣:
- 佣金 = 0.95 × 90% = 0.855元
- C 获得 0.86元(四舍五入)
```
---
### 场景5自动解绑
```
触发定时任务每天02:00
执行脚本scripts/auto-unbind-expired-simple.js
筛选条件:
- status = 'active'
- expiry_date < NOW
- purchase_count = 0
数据变化:
- referral_bindings: status → expired
- users.referral_count: -1对应的推荐人
```
---
## 🎯 核心逻辑总结
| 功能 | 实现状态 | 说明 |
|------|---------|------|
| **立即切换绑定** | ✅ 完成 | 点击新链接立即切换推荐人 |
| **佣金归属** | ✅ 完成 | 给购买时的当前推荐人 |
| **购买累加** | ✅ 完成 | 同一绑定可多次购买分佣 |
| **好友优惠** | ✅ 完成 | 通过推荐链接自动打折 |
| **提现门槛** | ✅ 完成 | 后台可配置最低金额 |
| **自动解绑** | ✅ 完成 | 30天无购买自动解绑 |
| **推广设置页** | ✅ 完成 | 管理后台统一配置入口 |
---
## 📦 已部署文件清单
### 后端API7个文件
1.`app/api/referral/bind/route.ts` - 立即切换绑定
2.`app/api/miniprogram/pay/notify/route.ts` - 累加分佣
3.`app/api/miniprogram/pay/route.ts` - 好友优惠
4.`app/api/withdraw/route.ts` - 提现门槛
5.`app/admin/referral-settings/page.tsx` - 推广设置页
6.`app/admin/layout.tsx` - 菜单入口
### 小程序1个文件
7.`miniprogram/pages/referral/referral.wxml` - 去掉邀请码卡片
### 脚本5个文件
8.`scripts/migrate_db_simple.py` - 数据库迁移
9.`scripts/auto-unbind-expired-simple.js` - 定时任务
10.`scripts/test-referral-flow.js` - 功能测试
### 文档3个文件
11.`开发文档/8、部署/新分销逻辑设计方案.md`
12.`开发文档/8、部署/新分销逻辑-部署步骤.md`
13.`开发文档/8、部署/新分销逻辑-宝塔操作清单.md`
---
## 🔄 部署状态
- ✅ 数据库字段已添加
- ✅ 代码已构建pnpm build
- ✅ 代码已上传服务器python devlop.py
-**待操作:宝塔面板重启服务**
-**待操作:宝塔面板配置定时任务**
---
## 🚦 下一步操作
### 必须完成(服务才能生效)
1. **重启 Node.js 服务**
- 宝塔面板 → 网站 → soul.quwanzhi.com → Node项目 → 重启
- 或SSH执行`/www/server/nodejs/v16.20.2/bin/pm2 restart soul`
2. **配置定时任务**
- 宝塔面板 → 计划任务 → 添加Shell脚本
- 执行周期:每天 02:00
- 脚本内容:
```bash
cd /www/wwwroot/soul/dist && /www/server/nodejs/v16.20.2/bin/node scripts/auto-unbind-expired-simple.js >> /www/wwwroot/soul/logs/auto-unbind.log 2>&1
```
### 建议测试
3. **验证功能**
- 访问推广设置页面:`https://soul.quwanzhi.com/admin/referral-settings`
- 小程序测试绑定切换
- 测试购买分佣
---
## ✅ 代码逻辑完成度100%
**所有核心逻辑已全部实现并部署!**
剩余工作仅为:
1. 宝塔面板重启服务1分钟
2. 宝塔面板配置定时任务2分钟
3. 功能测试验证(可选)

View File

@@ -1,436 +0,0 @@
# 章节阅读页集成示例
> 展示如何在 `miniprogram/pages/read/read.js` 中集成权限管理器和阅读追踪器
---
## 一、引入工具类
```javascript
// pages/read/read.js
import accessManager from '../../utils/chapterAccessManager'
import readingTracker from '../../utils/readingTracker'
const app = getApp()
Page({
data: {
// 系统信息
statusBarHeight: 44,
navBarHeight: 88,
// 章节信息
sectionId: '',
section: null,
// 【新增】权限状态(状态机)
accessState: 'unknown', // unknown | free | locked_not_login | locked_not_purchased | unlocked_purchased | error
// 内容
content: '',
contentParagraphs: [],
previewParagraphs: [],
loading: true,
// 用户状态
isLoggedIn: false,
// 配置
freeIds: [],
sectionPrice: 1,
fullBookPrice: 9.9
},
// 页面加载(标准流程)
async onLoad(options) {
const { id, ref } = options
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight,
sectionId: id,
loading: true,
accessState: 'unknown'
})
// 处理推荐码(异步不阻塞)
if (ref) {
wx.setStorageSync('referral_code', ref)
app.handleReferralCode({ query: { ref } })
}
try {
// 1. 拉取最新配置(免费列表、价格)
const config = await accessManager.fetchLatestConfig()
this.setData({
freeIds: config.freeChapters,
sectionPrice: config.prices.section,
fullBookPrice: config.prices.fullbook
})
// 2. 确定权限状态
const accessState = await accessManager.determineAccessState(id, config.freeChapters)
this.setData({
accessState,
isLoggedIn: !!app.globalData.userInfo?.id
})
// 3. 加载内容
await this.loadContent(id, accessState)
// 4. 如果有权限,初始化阅读追踪
if (accessManager.canAccessFullContent(accessState)) {
readingTracker.init(id)
}
// 5. 加载导航
this.loadNavigation(id)
} catch (e) {
console.error('[Read] 初始化失败:', e)
wx.showToast({ title: '加载失败,请重试', icon: 'none' })
this.setData({ accessState: 'error' })
} finally {
this.setData({ loading: false })
}
},
// 加载内容(根据权限决定全文或预览)
async loadContent(id, accessState) {
try {
const res = await app.request(`/api/book/chapter/${id}`)
if (res && res.content) {
const lines = res.content.split('\n').filter(line => line.trim())
const previewCount = Math.ceil(lines.length * 0.2)
this.setData({
content: res.content,
contentParagraphs: lines,
previewParagraphs: lines.slice(0, previewCount),
section: {
id: res.id,
title: res.title || res.sectionTitle,
price: res.price || this.data.sectionPrice,
isFree: res.isFree
}
})
}
} catch (e) {
console.error('[Read] 加载内容失败:', e)
throw e
}
},
// 滚动事件(追踪阅读进度)
onPageScroll(e) {
// 只在有权限时追踪
if (!accessManager.canAccessFullContent(this.data.accessState)) {
return
}
// 获取滚动信息
const query = wx.createSelectorQuery()
query.select('.page').boundingClientRect()
query.selectViewport().scrollOffset()
query.exec((res) => {
if (res[0] && res[1]) {
const scrollInfo = {
scrollTop: res[1].scrollTop,
scrollHeight: res[0].height,
clientHeight: res[1].height
}
// 更新追踪器
readingTracker.updateProgress(scrollInfo)
}
})
},
// 登录成功(标准流程)
async handleWechatLogin() {
try {
const result = await app.login()
if (!result) return
this.setData({ showLoginModal: false })
wx.showLoading({ title: '更新状态中...' })
try {
// 1. 刷新用户购买状态
await accessManager.refreshUserPurchaseStatus()
// 2. 重新拉取免费列表(可能刚改免费)
const config = await accessManager.fetchLatestConfig()
this.setData({ freeIds: config.freeChapters })
// 3. 重新判断当前章节权限
const newAccessState = await accessManager.determineAccessState(
this.data.sectionId,
config.freeChapters
)
this.setData({
accessState: newAccessState,
isLoggedIn: true
})
// 4. 如果已解锁,重新加载内容并初始化追踪
if (accessManager.canAccessFullContent(newAccessState)) {
await this.loadContent(this.data.sectionId, newAccessState)
readingTracker.init(this.data.sectionId)
}
wx.hideLoading()
wx.showToast({ title: '登录成功', icon: 'success' })
} catch (e) {
wx.hideLoading()
console.error('[Read] 登录后更新状态失败:', e)
wx.showToast({ title: '状态更新失败,请重试', icon: 'none' })
}
} catch (e) {
wx.showToast({ title: '登录失败', icon: 'none' })
}
},
// 支付成功(标准流程)
async onPaymentSuccess() {
wx.showLoading({ title: '确认购买中...' })
try {
// 1. 等待服务端处理支付回调
await this.sleep(2000)
// 2. 刷新购买状态
await accessManager.refreshUserPurchaseStatus()
// 3. 重新判断权限(应为 unlocked_purchased
let newAccessState = await accessManager.determineAccessState(
this.data.sectionId,
this.data.freeIds
)
// 如果权限未生效,再重试一次
if (newAccessState !== 'unlocked_purchased') {
await this.sleep(1000)
newAccessState = await accessManager.determineAccessState(
this.data.sectionId,
this.data.freeIds
)
}
this.setData({ accessState: newAccessState })
// 4. 重新加载全文
await this.loadContent(this.data.sectionId, newAccessState)
// 5. 初始化阅读追踪
readingTracker.init(this.data.sectionId)
wx.hideLoading()
wx.showToast({ title: '购买成功', icon: 'success' })
} catch (e) {
wx.hideLoading()
console.error('[Read] 支付后更新失败:', e)
wx.showModal({
title: '提示',
content: '购买成功,但内容加载失败,请返回重新进入',
showCancel: false
})
}
},
// 重试按钮(当 accessState 为 error 时显示)
async handleRetry() {
wx.showLoading({ title: '重试中...' })
try {
const config = await accessManager.fetchLatestConfig()
this.setData({ freeIds: config.freeChapters })
const newAccessState = await accessManager.determineAccessState(
this.data.sectionId,
config.freeChapters
)
this.setData({ accessState: newAccessState })
if (accessManager.canAccessFullContent(newAccessState)) {
await this.loadContent(this.data.sectionId, newAccessState)
readingTracker.init(this.data.sectionId)
}
wx.hideLoading()
wx.showToast({ title: '加载成功', icon: 'success' })
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '重试失败,请检查网络', icon: 'none' })
}
},
// 页面隐藏/卸载时上报进度
onHide() {
readingTracker.onPageHide()
},
onUnload() {
readingTracker.cleanup()
},
// 工具方法
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
},
// ... 其他方法(分享、导航等)
})
```
---
## 二、WXML 模板适配
```xml
<!-- pages/read/read.wxml -->
<view class="page">
<!-- loading 状态 -->
<view class="loading-state" wx:if="{{accessState === 'unknown' && loading}}">
<view class="skeleton skeleton-1"></view>
<view class="skeleton skeleton-2"></view>
<view class="skeleton skeleton-3"></view>
</view>
<!-- 免费章节 或 已购买章节 - 全文展示 -->
<view class="article" wx:if="{{accessState === 'free' || accessState === 'unlocked_purchased'}}">
<view class="paragraph" wx:for="{{contentParagraphs}}" wx:key="index">
{{item}}
</view>
<!-- 章节导航 -->
<view class="chapter-nav">
<!-- ... 上一篇/下一篇 ... -->
</view>
</view>
<!-- 未登录 - 预览 + 登录按钮 -->
<view class="article preview" wx:if="{{accessState === 'locked_not_login'}}">
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index">
{{item}}
</view>
<view class="fade-mask"></view>
<view class="paywall">
<view class="paywall-icon">🔒</view>
<text class="paywall-title">登录后继续阅读</text>
<view class="login-btn" bindtap="showLoginModal">
<text>立即登录</text>
</view>
</view>
</view>
<!-- 已登录未购买 - 预览 + 购买按钮 -->
<view class="article preview" wx:if="{{accessState === 'locked_not_purchased'}}">
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index">
{{item}}
</view>
<view class="fade-mask"></view>
<view class="paywall">
<view class="paywall-icon">🔒</view>
<text class="paywall-title">购买后继续阅读</text>
<view class="purchase-options">
<view class="purchase-btn" bindtap="handlePurchaseSection">
<text>购买本章 ¥{{sectionPrice}}</text>
</view>
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook">
<text>解锁全书 ¥{{fullBookPrice}}</text>
</view>
</view>
</view>
</view>
<!-- 错误状态 - 预览 + 重试按钮 -->
<view class="article preview" wx:if="{{accessState === 'error'}}">
<view class="paragraph" wx:for="{{previewParagraphs}}" wx:key="index">
{{item}}
</view>
<view class="fade-mask"></view>
<view class="paywall">
<view class="paywall-icon">⚠️</view>
<text class="paywall-title">网络异常</text>
<text class="paywall-desc">无法确认权限,请检查网络后重试</text>
<view class="retry-btn" bindtap="handleRetry">
<text>重新加载</text>
</view>
</view>
</view>
</view>
```
---
## 三、关键改动点对比
### 改造前(存在的问题)
```javascript
// ❌ 问题:多处权限判断逻辑不统一
if (isFree) { canAccess = true }
else if (!isLoggedIn) { canAccess = false }
else if (hasFullBook || purchasedSections.includes(id)) { canAccess = true }
else { /* 请求接口 */ }
// ❌ 问题:异常时用本地缓存可能误解锁
catch (e) {
canAccess = hasFullBook || purchasedSections.includes(id)
}
// ❌ 问题:无阅读进度追踪
```
### 改造后(标准流程)
```javascript
// ✅ 统一通过 accessManager 判断权限
const accessState = await accessManager.determineAccessState(id, freeList)
// ✅ 异常时保守策略:返回 'error' 状态,展示重试按钮
catch (e) {
return 'error' // 不信任本地缓存
}
// ✅ 有权限时自动追踪阅读进度
if (accessManager.canAccessFullContent(accessState)) {
readingTracker.init(id)
}
```
---
## 四、迁移步骤
1. **引入工具类**:将 `chapterAccessManager.js``readingTracker.js` 放入 `miniprogram/utils/`
2. **创建数据表**:在数据库执行 `create_reading_progress_table.sql`
3. **部署接口**:将 `reading-progress/route.ts` 部署到服务端
4. **改造阅读页**:参考上面示例,将 `onLoad``handleWechatLogin``onPaymentSuccess` 按标准流程重构
5. **测试验证**:各种场景(免费、未登录、已登录未购买、已购买、网络异常)逐一测试
6. **数据验证**:检查 `reading_progress` 表是否正常记录进度
---
## 五、预期效果
-**权限判断统一**:所有权限判断走 `determineAccessState`,以服务端为准
-**状态流转清晰**:通过 `accessState` 枚举UI 展示与状态一一对应
-**异常降级标准**:网络异常时展示 `error` 状态 + 重试按钮,不误解锁
-**阅读进度可追踪**:记录进度、时长、是否读完,支持断点续读
-**数据驱动分析**`reading_progress` 表为后续数据分析提供基础
以上为完整的集成示例,建议先在测试环境验证,再部署到生产。

View File

@@ -1,384 +0,0 @@
# 管理端推广配置与小程序对接说明
## 📋 配置项说明
### 管理端配置
**位置**: `/admin/referral-settings`
**配置项**:
1. **distributorShare** - 分销比例例如90 表示 90%
2. **minWithdrawAmount** - 最低提现金额例如10 表示 10元
3. **bindingDays** - 绑定天数例如30 表示 30天
4. **userDiscount** - 好友优惠例如5 表示 5% 折扣)
5. **enableAutoWithdraw** - 是否启用自动提现
**存储位置**: `system_config` 表,键名 `referral_config`
---
## ✅ 已对接的配置
### 1. distributorShare分销比例
#### 后端使用
**文件**: `app/api/miniprogram/pay/notify/route.ts`
**用途**: 计算推荐人佣金
```typescript
// 获取配置
const config = await getConfig('referral_config')
const distributorShare = config.distributorShare / 100 // 90 → 0.9
// 计算佣金
const commission = amount * distributorShare // 1元 * 0.9 = 0.9元
```
#### 前端显示
**文件**: `miniprogram/pages/referral/referral.wxml`
**位置**: 分销中心页面
```xml
<text class="commission-rate">{{shareRate}}% 返利</text>
```
**数据来源**: `/api/referral/data` 接口返回 `shareRate`
**对接状态**: ✅ 已完成
- 后端从配置读取
- API返回给前端
- 前端动态显示
---
### 2. minWithdrawAmount最低提现金额
#### 后端使用
**文件**: `app/api/withdraw/route.ts`
**用途**: 验证提现金额
```typescript
// 获取配置
const config = await getConfig('referral_config')
const minWithdrawAmount = config.minWithdrawAmount || 10
// 验证金额
if (amount < minWithdrawAmount) {
return error('提现金额不能低于' + minWithdrawAmount + '元')
}
```
#### 前端显示
**文件**: `miniprogram/pages/referral/referral.wxml`
**位置**: 提现按钮
```xml
<!-- 旧代码(硬编码) -->
<view class="withdraw-btn {{earnings < 10 ? 'btn-disabled' : ''}}">
{{earnings < 10 ? '满10元可提现' : '申请提现'}}
</view>
<!-- 新代码(动态配置) -->
<view class="withdraw-btn {{pendingEarnings < minWithdrawAmount ? 'btn-disabled' : ''}}">
{{pendingEarnings < minWithdrawAmount ? '满' + minWithdrawAmount + '元可提现' : '申请提现'}}
</view>
```
**数据来源**: `/api/referral/data` 接口返回 `minWithdrawAmount`
**对接状态**: ✅ 刚完成
- 后端从配置读取
- API新增返回字段
- 前端动态显示
---
### 3. bindingDays绑定天数
#### 后端使用
**文件**: `app/api/referral/bind/route.ts`
**用途**: 计算绑定过期时间
```typescript
// 获取配置
const config = await getConfig('referral_config')
const bindingDays = config.bindingDays || 30
// 计算过期时间
const expiryDate = new Date()
expiryDate.setDate(expiryDate.getDate() + bindingDays)
```
**对接状态**: ✅ 已完成
- 后端从配置读取
- 自动应用于绑定逻辑
- 前端无需显示(内部逻辑)
---
### 4. userDiscount好友优惠
#### 后端使用
**文件**: `app/api/miniprogram/pay/route.ts`
**用途**: 计算好友购买折扣
```typescript
// 获取配置
const config = await getConfig('referral_config')
const userDiscount = config.userDiscount || 0
// 计算折后价
if (userDiscount > 0 && referralCode) {
finalAmount = amount * (1 - userDiscount / 100) // 1元 * (1 - 0.05) = 0.95元
}
```
**对接状态**: ✅ 已完成
- 后端从配置读取
- 自动应用于支付流程
- 前端无需显示(微信支付弹窗自动显示折后价)
---
### 5. enableAutoWithdraw自动提现
**对接状态**: ⏸️ 功能待开发
- 配置已存在
- 后端逻辑待实现
- 前端UI待实现
---
## 🔄 数据流向图
```
管理端修改配置
保存到 system_config 表
后端API读取配置getConfig
├─→ /api/referral/bind → 使用 bindingDays
├─→ /api/miniprogram/pay → 使用 userDiscount
├─→ /api/miniprogram/pay/notify → 使用 distributorShare
├─→ /api/withdraw → 使用 minWithdrawAmount
└─→ /api/referral/data → 返回 shareRate + minWithdrawAmount
小程序获取数据
动态显示配置
```
---
## 📝 本次修改内容
### 1. 后端API修改
**文件**: `app/api/referral/data/route.ts`
**修改内容**:
```typescript
// 新增读取 minWithdrawAmount
let minWithdrawAmount = 10
try {
const config = await getConfig('referral_config')
if (config?.minWithdrawAmount) {
minWithdrawAmount = Number(config.minWithdrawAmount)
}
} catch (e) { /* 使用默认 */ }
// 返回数据中新增字段
return {
shareRate: Math.round(distributorShare * 100),
minWithdrawAmount, // 新增
// ... 其他字段
}
```
---
### 2. 小程序JS修改
**文件**: `miniprogram/pages/referral/referral.js`
**修改内容**:
```javascript
// data 中新增字段
data: {
minWithdrawAmount: 10, // 新增
shareRate: 90,
// ...
}
// 从API获取配置
setData({
shareRate: realData?.shareRate || 90,
minWithdrawAmount: realData?.minWithdrawAmount || 10, // 新增
// ...
})
```
---
### 3. 小程序WXML修改
**文件**: `miniprogram/pages/referral/referral.wxml`
**旧代码**:
```xml
<view class="withdraw-btn {{earnings < 10 ? 'btn-disabled' : ''}}">
{{earnings < 10 ? '满10元可提现' : '申请提现'}}
</view>
```
**新代码**:
```xml
<view class="withdraw-btn {{pendingEarnings < minWithdrawAmount ? 'btn-disabled' : ''}}">
{{pendingEarnings < minWithdrawAmount ? '满' + minWithdrawAmount + '元可提现' : '申请提现'}}
</view>
```
---
## ✅ 对接完成度
| 配置项 | 后端使用 | API返回 | 小程序显示 | 状态 |
|--------|---------|---------|------------|------|
| distributorShare | ✅ | ✅ | ✅ | 已完成 |
| minWithdrawAmount | ✅ | ✅ | ✅ | 刚完成 |
| bindingDays | ✅ | - | - | 已完成(内部逻辑)|
| userDiscount | ✅ | - | - | 已完成(自动应用)|
| enableAutoWithdraw | ⏸️ | - | - | 待开发 |
---
## 🧪 测试验证
### 1. 修改配置
1. 登录管理后台
2. 进入「推广设置」页面
3. 修改配置:
- 分销比例:改为 85%
- 最低提现金额:改为 20元
4. 保存配置
### 2. 验证后端
```bash
# 测试分销中心API
curl "https://soul.quwanzhi.com/api/referral/data?userId=xxx"
# 预期返回
{
"shareRate": 85,
"minWithdrawAmount": 20,
...
}
```
### 3. 验证小程序
1. 打开小程序「分销中心」页面
2. 检查显示:
- 「85% 返利」(而不是 90%
- 「满20元可提现」而不是满10元
3. 尝试提现时应该验证是否满20元
---
## 🚀 部署步骤
### 1. 部署后端
```bash
pnpm build
python devlop.py
pm2 restart soul
```
### 2. 测试API
```bash
curl "https://soul.quwanzhi.com/api/referral/data?userId=xxx"
```
### 3. 上传小程序
- 在微信开发者工具上传代码
- 提交审核
- 发布新版本
---
## 📊 配置示例
### 默认配置
```json
{
"distributorShare": 90,
"minWithdrawAmount": 10,
"bindingDays": 30,
"userDiscount": 5,
"enableAutoWithdraw": false
}
```
### 修改后效果
#### 场景1: 提高最低提现门槛
```json
{ "minWithdrawAmount": 50 }
```
**效果**:
- 后端验证必须满50元才能提现
- 小程序显示「满50元可提现」
#### 场景2: 降低分成比例
```json
{ "distributorShare": 70 }
```
**效果**:
- 后端计算:推荐人获得 70% 佣金
- 小程序显示「70% 返利」
#### 场景3: 增加好友优惠
```json
{ "userDiscount": 10 }
```
**效果**:
- 后端计算:好友购买打 9折
- 微信支付:显示折后价(例如 1元 → 0.9元)
---
## 🔍 问题排查
### 问题1: 小程序显示的分成比例不对
**原因**: 前端没有重新加载数据
**解决**: 下拉刷新页面,重新调用 `/api/referral/data`
### 问题2: 提现验证还是用的旧金额
**原因**: 后端缓存或配置未更新
**解决**:
1. 检查数据库 `system_config`
2. 重启PM2服务
### 问题3: 修改配置后小程序不生效
**原因**: 小程序使用了旧版本
**解决**:
1. 确保上传了新版本小程序
2. 用户需要重启小程序
---
## ✅ 总结
**已完成对接**
- ✅ distributorShare分销比例- 后端计算 + 小程序显示
- ✅ minWithdrawAmount最低提现金额- 后端验证 + 小程序显示
- ✅ bindingDays绑定天数- 后端逻辑
- ✅ userDiscount好友优惠- 后端计算
**待开发功能**
- ⏸️ enableAutoWithdraw自动提现
**优势**
- 管理员可以在后台随时调整配置
- 无需修改代码即可生效
- 用户看到的是实时配置
---
**现在管理端的推广配置已完全对接到小程序逻辑!**

View File

@@ -1,265 +0,0 @@
# 累计佣金计算修复说明
## 一、问题描述
**问题**:小程序分销中心显示的"累计佣金"金额不正确
**表现**:显示 ¥0.90,但实际应该更高
## 二、问题根源
### 错误的计算逻辑
**原逻辑(错误)**
```typescript
totalCommission: Math.round((
(parseFloat(user.earnings) || 0) + // 已结算收益
(parseFloat(user.pending_earnings) || 0) + // 待结算收益
(parseFloat(user.withdrawn_earnings) || 0) // 已提现金额
) * 100) / 100
```
**问题分析**
1. 使用 `users` 表的字段计算
2. `users.earnings``users.pending_earnings``users.withdrawn_earnings` 这三个字段可能未正确维护
3. 没有从实际的佣金记录(`referral_bindings.total_commission`)中统计
### 正确的数据来源
累计佣金应该从 `referral_bindings` 表统计:
```sql
SELECT COALESCE(SUM(total_commission), 0)
FROM referral_bindings
WHERE referrer_id = 'user_id'
```
**为什么?**
- `referral_bindings.total_commission` 记录了每个推荐关系产生的佣金
- 每次用户付款时,这个字段都会累加(在 `/api/payment/*/notify` 中更新)
- 这是最准确的佣金来源
## 三、修复方案
### 1. 修改聚合查询SQL
**文件**`app/api/referral/data/route.ts`
**修改内容**:在统计查询中添加累计佣金字段
```typescript
const statsResult = await query(`
SELECT
-- ... 其他字段 ...
-- 累计佣金总额(从所有推荐关系中累加)
(SELECT COALESCE(SUM(total_commission), 0)
FROM referral_bindings
WHERE referrer_id = u.id) as total_commission_from_bindings
FROM users u
WHERE u.id = ?
`, [userId]) as any[]
```
### 2. 修改返回数据
```typescript
// === 收益数据 ===
// 累计佣金总额从referral_bindings表累加所有佣金
totalCommission: Math.round(parseFloat(stats.total_commission_from_bindings || 0) * 100) / 100,
```
## 四、数据流程说明
### 佣金累加流程
```
用户A购买商品¥10
支付回调 /api/payment/*/notify
计算佣金¥10 × 90% = ¥9
更新数据:
- users.pending_earnings += 9 // 可提现金额
- referral_bindings.total_commission += 9 // 累计佣金 ✅
- referral_bindings.purchase_count += 1
显示:
- 累计佣金:从 referral_bindings.total_commission 累加
- 可提现金额users.pending_earnings
```
### 关键字段说明
| 表 | 字段 | 说明 | 用途 |
|------|------|------|------|
| `users` | `pending_earnings` | 可提现金额 | 提现时使用 |
| `users` | `earnings` | 已结算收益 | 历史兼容字段 |
| `users` | `withdrawn_earnings` | 已提现金额 | 提现记录 |
| `referral_bindings` | `total_commission` | **累计佣金** | **显示累计佣金** ✅ |
| `referral_bindings` | `purchase_count` | 购买次数 | 统计转化 |
## 五、验证方法
### 1. 数据库验证
```sql
-- 查看某个用户的累计佣金
SELECT
referrer_id,
SUM(total_commission) as total_commission,
COUNT(*) as binding_count,
SUM(purchase_count) as total_purchases
FROM referral_bindings
WHERE referrer_id = 'user_xxx'
GROUP BY referrer_id;
-- 查看每个推荐关系的佣金明细
SELECT
rb.id,
rb.referee_id,
u.nickname as referee_nickname,
rb.total_commission,
rb.purchase_count,
rb.status
FROM referral_bindings rb
JOIN users u ON rb.referee_id = u.id
WHERE rb.referrer_id = 'user_xxx'
ORDER BY rb.total_commission DESC;
```
### 2. API验证
```bash
# 调用接口
curl -X GET "https://your-domain.com/api/referral/data?userId=xxx" \
-H "Cookie: auth_token=xxx"
```
**检查返回数据**
```json
{
"success": true,
"data": {
"totalCommission": 45.00, // 应该等于数据库中的累加值
"availableEarnings": 30.00,
"pendingWithdrawAmount": 0.00,
"withdrawnEarnings": 15.00
}
}
```
### 3. 小程序验证
刷新分销中心页面,检查:
- ✅ 累计佣金金额正确
- ✅ 与数据库中的 `SUM(total_commission)` 一致
## 六、数据一致性检查
### 检查是否有数据不一致
```sql
-- 检查 users 表的金额 vs referral_bindings 表的金额
SELECT
u.id,
u.nickname,
u.earnings + u.pending_earnings + u.withdrawn_earnings as users_total,
COALESCE((SELECT SUM(total_commission) FROM referral_bindings WHERE referrer_id = u.id), 0) as bindings_total,
u.earnings + u.pending_earnings + u.withdrawn_earnings -
COALESCE((SELECT SUM(total_commission) FROM referral_bindings WHERE referrer_id = u.id), 0) as difference
FROM users u
WHERE u.referral_count > 0
OR EXISTS (SELECT 1 FROM referral_bindings WHERE referrer_id = u.id)
ORDER BY ABS(difference) DESC
LIMIT 20;
```
**如果有差异**:说明数据不一致,需要检查支付回调逻辑是否正确更新了两边的数据。
## 七、未来优化建议
### 1. 数据冗余优化
**问题**
- `users.earnings``users.pending_earnings``users.withdrawn_earnings`
- `referral_bindings.total_commission`
- 两处存储相同的数据,容易不一致
**建议**
- 保留 `referral_bindings.total_commission` 作为主要数据源
- `users.pending_earnings` 用于提现判断
- `users.withdrawn_earnings` 用于提现历史
- 移除 `users.earnings` 字段(已不使用)
### 2. 实时统计
**当前**:每次查询时从 `referral_bindings` 累加
**优化**
- 使用物化视图或定时任务
- 将累计佣金缓存到 `users` 表的一个字段
- 每次佣金变化时更新
### 3. 数据校验
**建议添加定时任务**
```sql
-- 校验并修复数据不一致
UPDATE users u
SET u.total_commission_cache = (
SELECT COALESCE(SUM(total_commission), 0)
FROM referral_bindings
WHERE referrer_id = u.id
)
WHERE u.referral_count > 0;
```
## 八、相关文件
### 修改文件
1. `app/api/referral/data/route.ts` - 修改累计佣金计算逻辑
### 相关文件
1. `app/api/payment/wechat/notify/route.ts` - 支付回调,更新佣金
2. `app/api/payment/alipay/notify/route.ts` - 支付回调,更新佣金
3. `miniprogram/pages/referral/referral.wxml` - 显示累计佣金
### 数据库表
1. `users` - 用户表
2. `referral_bindings` - 推荐关系表
3. `orders` - 订单表
## 九、总结
### 修复前
- ❌ 从 `users` 表的三个字段相加
- ❌ 数据可能不准确
- ❌ 没有反映实际的佣金记录
### 修复后
- ✅ 从 `referral_bindings.total_commission` 累加
- ✅ 数据准确,反映实际佣金
- ✅ 与支付回调逻辑一致
### 关键改动
**一行SQL**
```sql
(SELECT COALESCE(SUM(total_commission), 0)
FROM referral_bindings
WHERE referrer_id = u.id) as total_commission_from_bindings
```
**一行代码**
```typescript
totalCommission: Math.round(parseFloat(stats.total_commission_from_bindings || 0) * 100) / 100,
```
---
**修复时间**2026-02-04
**影响范围**:累计佣金显示
**向后兼容**:✅ 完全兼容,仅修改计算来源
**需要重启**重启Next.js服务

View File

@@ -1,554 +0,0 @@
# 绑定关系存储方案分析
## 📊 当前实现
### 表结构
#### 1. referral_bindings 表(主表)
```sql
CREATE TABLE referral_bindings (
id VARCHAR(50) PRIMARY KEY,
referrer_id VARCHAR(50), -- 推荐人ID
referee_id VARCHAR(50), -- 被推荐人ID
referral_code VARCHAR(50), -- 推荐码
status ENUM('active', 'expired', 'cancelled'), -- 状态
binding_date DATETIME, -- 绑定时间
expiry_date DATETIME, -- 过期时间
last_purchase_date DATETIME, -- 最后购买时间
purchase_count INT DEFAULT 0, -- 购买次数
total_commission DECIMAL(10,2) DEFAULT 0.00, -- 累计佣金
INDEX idx_referee_status (referee_id, status),
INDEX idx_referrer_status (referrer_id, status)
)
```
#### 2. users 表(冗余字段)
```sql
CREATE TABLE users (
id VARCHAR(50) PRIMARY KEY,
referred_by VARCHAR(50), -- 冗余当前推荐人ID
referral_count INT DEFAULT 0, -- 冗余:推荐人的推广数量
referral_code VARCHAR(50), -- 自己的推荐码
pending_earnings DECIMAL(10,2), -- 待结算收益
earnings DECIMAL(10,2), -- 已结算收益
withdrawn_earnings DECIMAL(10,2) -- 已提现金额
)
```
---
## 🔍 当前使用情况分析
### 1. 绑定关系的创建/更新(/api/referral/bind
**操作**
```typescript
// 1. 查询当前绑定(使用 referral_bindings
SELECT * FROM referral_bindings
WHERE referee_id = ? AND status = 'active'
// 2. 创建/更新绑定记录
INSERT INTO referral_bindings (...)
// 3. 同步更新 users.referred_by冗余
UPDATE users SET referred_by = ? WHERE id = ?
// 4. 更新 users.referral_count冗余计数
UPDATE users SET referral_count = referral_count + 1 WHERE id = ?
```
**问题**
-`referral_bindings` 是真实来源
- ⚠️ `users.referred_by` 是冗余,可能不一致
---
### 2. 支付回调计算佣金(/api/miniprogram/pay/notify
**操作**
```typescript
// 查询绑定关系(使用 referral_bindings
SELECT * FROM referral_bindings
WHERE referee_id = ? AND status = 'active'
ORDER BY binding_date DESC LIMIT 1
// 如果找到 → 给推荐人佣金
UPDATE users SET pending_earnings = pending_earnings + ? WHERE id = ?
```
**结论**
- ✅ 只使用 `referral_bindings`
- ✅ 不依赖 `users.referred_by`
---
### 3. 分销中心数据(/api/referral/data
**操作**
```typescript
// 查询活跃绑定
SELECT * FROM referral_bindings
WHERE referrer_id = ? AND status = 'active' AND expiry_date > NOW()
// 查询已转化用户
SELECT * FROM referral_bindings
WHERE referrer_id = ? AND status = 'active' AND purchase_count > 0
// 查询过期绑定
SELECT * FROM referral_bindings
WHERE referrer_id = ? AND status IN ('expired', 'cancelled')
```
**结论**
- ✅ 只使用 `referral_bindings`
- ✅ 不依赖 `users.referred_by`
---
### 4. 自动解绑(/api/cron/unbind-expired
**操作**
```typescript
// 查询需要解绑的记录
SELECT * FROM referral_bindings
WHERE status = 'active'
AND expiry_date < NOW()
AND purchase_count = 0
// 批量更新为 expired
UPDATE referral_bindings SET status = 'expired' WHERE id IN (...)
// 更新 referral_count
UPDATE users SET referral_count = GREATEST(referral_count - ?, 0) WHERE id = ?
```
**结论**
- ✅ 只使用 `referral_bindings`
- ⚠️ 但没有更新 `users.referred_by`(可能导致不一致)
---
### 5. 旧代码兼容(/api/referral/bind - 旧接口)
**操作**
```typescript
// 查询推荐的用户(使用 users.referred_by
SELECT * FROM users WHERE referred_by = ?
```
**问题**
- ⚠️ 使用了 `users.referred_by`
- ⚠️ 可能查到已过期的绑定
- ⚠️ 应该改用 `referral_bindings`
---
## 📊 数据一致性分析
### 场景1: 用户 A 推荐 B30天后过期
#### referral_bindings 表
```sql
referrer_id: A
referee_id: B
status: expired 正确
expiry_date: 2026-01-01
```
#### users 表
```sql
B.referred_by: A ⚠️ 仍然是 A(未清空)
A.referral_count: 1 ⚠️ 未减少(自动解绑任务有更新)
```
**问题**
- `users.referred_by` 没有在过期时清空
- 如果查询 `users.referred_by`,会得到错误结果
---
### 场景2: B 从 A 切换到 C
#### referral_bindings 表
```sql
-- 旧绑定
referrer_id: A
referee_id: B
status: cancelled 正确
-- 新绑定
referrer_id: C
referee_id: B
status: active 正确
```
#### users 表
```sql
B.referred_by: C 正确(已更新)
A.referral_count: 0 正确(已减少)
C.referral_count: 1 正确(已增加)
```
**结论**:切换时同步正确
---
## 🎯 性能分析
### 方案1: 只用 referral_bindings推荐
**优势**
- ✅ 数据一致性强(单一数据源)
- ✅ 状态清晰active / expired / cancelled
- ✅ 信息完整(过期时间、购买次数等)
- ✅ 易于维护
**劣势**
- ❌ 查询需要 JOIN 或多次查询
- ❌ 复杂查询性能稍低
**查询示例**
```typescript
// 查询用户的当前推荐人
SELECT referrer_id FROM referral_bindings
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
ORDER BY binding_date DESC LIMIT 1
```
**性能**
- 有索引 `idx_referee_status`
- 查询速度:~0.1ms
- 适合:几乎所有场景
---
### 方案2: 冗余到 users 表
**优势**
- ✅ 查询快(直接读 users.referred_by
- ✅ 简单场景方便
**劣势**
- ❌ 数据一致性差(需要同步)
- ❌ 过期后不准确
- ❌ 切换时需要多表更新
- ❌ 维护成本高
**需要同步的场景**
1. 新绑定时
2. 切换推荐人时
3. 绑定过期时 ⚠️(当前未同步)
4. 绑定取消时 ⚠️(当前未同步)
---
### 方案3: 视图或计算字段(推荐)
**实现**
```sql
-- 创建视图
CREATE VIEW user_current_referrer AS
SELECT
rb.referee_id as user_id,
rb.referrer_id,
u.nickname as referrer_nickname,
rb.expiry_date,
rb.purchase_count
FROM referral_bindings rb
JOIN users u ON rb.referrer_id = u.id
WHERE rb.status = 'active'
AND rb.expiry_date > NOW()
```
**使用**
```typescript
// 查询用户的当前推荐人
SELECT * FROM user_current_referrer WHERE user_id = ?
```
**优势**
- ✅ 数据一致性强
- ✅ 查询方便
- ✅ 自动更新
- ✅ 无需维护冗余
---
## 🔧 当前问题
### 问题1: users.referred_by 不准确
**场景**:绑定过期后,`users.referred_by` 仍然有值
**影响**
```typescript
// 错误的查询
SELECT * FROM users WHERE referred_by = ?
// 会查到已过期的用户
```
**解决方案**
1. 停用 `users.referred_by`,只用 `referral_bindings`
2. 或者在过期时清空 `users.referred_by`
---
### 问题2: 旧代码依赖 users.referred_by
**位置**`/api/referral/bind` 的 GET 接口
```typescript
// 旧代码
SELECT * FROM users WHERE referred_by = ?
```
**应该改为**
```typescript
// 新代码
SELECT u.* FROM users u
JOIN referral_bindings rb ON u.id = rb.referee_id
WHERE rb.referrer_id = ?
AND rb.status = 'active'
AND rb.expiry_date > NOW()
```
---
## 🎯 推荐方案
### 方案A: 渐进式优化(推荐)
**步骤1: 停用 users.referred_by**
- 不再更新 `users.referred_by`
- 所有查询改用 `referral_bindings`
**步骤2: 优化索引**
- 确保 `referral_bindings` 有合适的索引
- `idx_referee_status` ✅ 已有
- `idx_referrer_status` ✅ 已有
**步骤3: 创建辅助函数**
```typescript
// 获取用户的当前推荐人
async function getCurrentReferrer(userId: string) {
const bindings = await query(`
SELECT referrer_id, expiry_date, purchase_count
FROM referral_bindings
WHERE referee_id = ?
AND status = 'active'
AND expiry_date > NOW()
ORDER BY binding_date DESC
LIMIT 1
`, [userId])
return bindings[0]?.referrer_id || null
}
```
**优势**
- ✅ 数据一致性强
- ✅ 无需维护冗余
- ✅ 性能优秀(有索引)
- ✅ 维护成本低
---
### 方案B: 保留 users.referred_by不推荐
如果一定要保留,需要确保同步:
**同步点**
1. ✅ 新绑定时(已实现)
2. ✅ 切换推荐人时(已实现)
3. ❌ 绑定过期时(需要添加)
4. ❌ 绑定取消时(需要添加)
**实现**
```typescript
// 在自动解绑时
UPDATE users SET referred_by = NULL
WHERE id IN (
SELECT referee_id FROM referral_bindings
WHERE status = 'expired'
)
```
**劣势**
- ❌ 维护成本高
- ❌ 容易出错
- ❌ 收益不大
---
## 📊 性能对比
### 查询1: 获取用户的推荐人
#### 使用 users.referred_by
```sql
SELECT referred_by FROM users WHERE id = ?
```
- 耗时:~0.01ms
- 准确性:❌ 可能过期
#### 使用 referral_bindings
```sql
SELECT referrer_id FROM referral_bindings
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
LIMIT 1
```
- 耗时:~0.1ms(有索引)
- 准确性:✅ 完全准确
**差异**0.09ms(几乎可以忽略)
---
### 查询2: 获取推荐人的下级列表
#### 使用 users.referred_by
```sql
SELECT * FROM users WHERE referred_by = ?
```
- 耗时:~1ms
- 准确性:❌ 包含过期用户
#### 使用 referral_bindings
```sql
SELECT u.* FROM users u
JOIN referral_bindings rb ON u.id = rb.referee_id
WHERE rb.referrer_id = ?
AND rb.status = 'active'
AND rb.expiry_date > NOW()
```
- 耗时:~1.5ms(有索引)
- 准确性:✅ 完全准确
**差异**0.5ms(可接受)
---
## ✅ 结论与建议
### 推荐方案A只用 referral_bindings
**理由**
1.**数据一致性**:单一数据源,避免不一致
2.**逻辑清晰**状态明确active / expired / cancelled
3.**维护简单**:无需同步冗余字段
4.**性能优秀**:有合适的索引,差异可忽略
5.**功能完整**:支持过期、切换、购买次数等
### 不推荐:保留 users.referred_by
**理由**
1. ❌ 数据一致性差(容易出错)
2. ❌ 维护成本高(多处同步)
3. ❌ 性能提升微乎其微0.09ms
4. ❌ 功能受限(无法判断是否过期)
---
## 🔧 优化建议
### 短期优化(立即执行)
1. **停用 users.referred_by 的写入**
- 不再更新这个字段
- 保留字段(避免破坏性变更)
2. **修改旧查询**
- 找到所有使用 `users.referred_by` 的查询
- 改用 `referral_bindings`
3. **添加辅助函数**
- 封装常用查询
- 简化代码
### 中期优化1-2周内
1. **性能监控**
- 监控查询性能
- 确保没有性能问题
2. **数据清理**
- 可选:清空 `users.referred_by`
- 避免误用
### 长期优化(可选)
1. **删除冗余字段**
- 如果确认不再使用
- 彻底删除 `users.referred_by`
2. **创建视图或缓存**
- 如果有特殊性能需求
- 考虑 Redis 缓存
---
## 📝 具体修改建议
### 1. 停止更新 users.referred_by
```typescript
// app/api/referral/bind/route.ts
// 删除或注释掉这行
// await query('UPDATE users SET referred_by = ? WHERE id = ?', [referrer.id, user.id])
```
### 2. 修改旧查询
```typescript
// 旧代码
const users = await query('SELECT * FROM users WHERE referred_by = ?', [userId])
// 新代码
const users = await query(`
SELECT u.* FROM users u
JOIN referral_bindings rb ON u.id = rb.referee_id
WHERE rb.referrer_id = ?
AND rb.status = 'active'
AND rb.expiry_date > NOW()
`, [userId])
```
### 3. 添加辅助函数
```typescript
// lib/referral-helpers.ts
export async function getCurrentReferrer(userId: string) {
const bindings = await query(`
SELECT referrer_id, expiry_date, purchase_count, total_commission
FROM referral_bindings
WHERE referee_id = ?
AND status = 'active'
AND expiry_date > NOW()
ORDER BY binding_date DESC
LIMIT 1
`, [userId])
return bindings[0] || null
}
export async function getActiveReferrals(referrerId: string) {
return await query(`
SELECT
u.id, u.nickname, u.avatar,
rb.binding_date, rb.expiry_date, rb.purchase_count, rb.total_commission
FROM referral_bindings rb
JOIN users u ON rb.referee_id = u.id
WHERE rb.referrer_id = ?
AND rb.status = 'active'
AND rb.expiry_date > NOW()
ORDER BY rb.binding_date DESC
`, [referrerId])
}
```
---
**总结:建议停用 users.referred_by只使用 referral_bindings 表,性能差异微乎其微,但数据一致性大幅提升!**

View File

@@ -1,3 +0,0 @@
# 自动化与 Webhook合并自 Next.js自动化、WEBHOOK、GitHub Webhook 与宝塔、自动同步)
Next.js 自动化部署流程、Vercel/宝塔 Webhook 配置、自动同步与分支策略。详见原各文档。

View File

@@ -1,280 +0,0 @@
# 自动解绑API配置说明
## 📋 接口信息
### API 地址
```
GET https://soul.quwanzhi.com/api/cron/unbind-expired?secret=soul_cron_unbind_2026
```
### 功能说明
自动解绑过期的推荐关系,条件:
-`status = 'active'`(活跃状态)
-`expiry_date < NOW()`(已过期)
-`purchase_count = 0`(从未购买)
**规则说明**
- 只解绑「活跃 + 过期 + 未购买」的绑定
- 如果用户购买过(`purchase_count > 0`),即使过期也**不解绑**
- 保留有价值的推荐关系记录
---
## 🔧 宝塔面板配置
### 步骤1: 创建计划任务
1. 登录宝塔面板
2. 点击左侧菜单「计划任务」
3. 点击「添加计划任务」
### 步骤2: 配置任务参数
**任务类型**: 访问URL
**任务名称**: 自动解绑过期推荐关系
**执行周期**: N分钟
**分钟选择**: 30每30分钟执行一次
**URL地址**:
```
https://soul.quwanzhi.com/api/cron/unbind-expired?secret=soul_cron_unbind_2026
```
**备注**: 自动解绑过期且未购买的推荐关系
### 步骤3: 保存并测试
1. 点击「保存」
2. 点击「执行」按钮手动测试一次
3. 查看执行日志,确认任务正常运行
---
## 📊 返回数据格式
### 成功响应(有数据)
```json
{
"success": true,
"message": "自动解绑完成",
"unbound": 5,
"updatedReferrers": 3,
"details": [
{
"refereeId": "user_123",
"referrerId": "user_456",
"bindingDate": "2026-01-05T10:30:00.000Z",
"expiryDate": "2026-02-04T10:30:00.000Z",
"daysExpired": 1
}
],
"duration": 245
}
```
### 成功响应(无数据)
```json
{
"success": true,
"message": "无需解绑的记录",
"unbound": 0,
"duration": 12
}
```
### 失败响应(密钥错误)
```json
{
"success": false,
"error": "未授权访问"
}
```
---
## 🔍 日志示例
### 控制台输出
```
[UnbindExpired] ========== 自动解绑任务开始 ==========
[UnbindExpired] 找到 5 条需要解绑的记录
[UnbindExpired] 1. 用户 user_123
推荐人: user_456
绑定时间: 2026/1/5
过期时间: 2026/2/4 (已过期 1 天)
购买次数: 0
累计佣金: ¥0.00
[UnbindExpired] 2. 用户 user_789
推荐人: user_456
绑定时间: 2026/1/10
过期时间: 2026/2/3 (已过期 2 天)
购买次数: 0
累计佣金: ¥0.00
...
[UnbindExpired] 已成功解绑 5 条记录
[UnbindExpired] 更新推荐人 user_456 的 referral_count (-3)
[UnbindExpired] 更新推荐人 user_999 的 referral_count (-2)
[UnbindExpired] 解绑完成: 5 条记录,更新 2 个推荐人
[UnbindExpired] ========== 任务结束 (耗时 245ms) ==========
```
---
## 🔐 安全说明
### 密钥保护
- 密钥硬编码在代码中:`soul_cron_unbind_2026`
- 只能通过正确的密钥访问接口
- 如果需要修改密钥,编辑 `app/api/cron/unbind-expired/route.ts` 第 24 行
### 访问权限
- ✅ 只支持 GET 请求
- ✅ 需要提供正确的 secret 参数
- ✅ 错误的密钥返回 401 未授权
---
## ⏰ 推荐执行频率
### 每30分钟推荐
- ✅ 及时处理过期绑定
- ✅ 不会给服务器造成压力
- ✅ 符合业务需求
### 其他选项
- 每15分钟如果需要更实时的解绑
- 每1小时如果对实时性要求不高
- 每天凌晨3点如果只需要每日清理
---
## 🧪 手动测试
### 方法1: 浏览器测试
直接在浏览器访问:
```
https://soul.quwanzhi.com/api/cron/unbind-expired?secret=soul_cron_unbind_2026
```
### 方法2: curl 命令
```bash
curl "https://soul.quwanzhi.com/api/cron/unbind-expired?secret=soul_cron_unbind_2026"
```
### 方法3: 宝塔面板手动执行
1. 进入「计划任务」
2. 找到"自动解绑过期推荐关系"任务
3. 点击「执行」按钮
4. 查看「日志」了解执行结果
---
## 📝 数据库操作
### 查询将被解绑的记录(测试用)
```sql
SELECT
id,
referrer_id,
referee_id,
binding_date,
expiry_date,
purchase_count,
total_commission,
DATEDIFF(NOW(), expiry_date) as days_expired
FROM referral_bindings
WHERE status = 'active'
AND expiry_date < NOW()
AND purchase_count = 0
ORDER BY expiry_date ASC;
```
### 查看解绑历史
```sql
SELECT
id,
referrer_id,
referee_id,
binding_date,
expiry_date,
status,
purchase_count,
total_commission
FROM referral_bindings
WHERE status = 'expired'
ORDER BY expiry_date DESC
LIMIT 20;
```
---
## 🔄 对比API vs 脚本
### 旧方案Node.js 脚本)
```bash
# 缺点:
- ❌ 需要配置服务器环境变量
- ❌ 需要手动配置数据库连接
- ❌ 需要确保 Node.js 路径正确
- ❌ 依赖外部脚本文件
```
### 新方案API接口
```bash
# 优点:
- ✅ 无需配置环境变量
- ✅ 无需手动配置数据库(使用现有连接)
- ✅ 宝塔面板直接调用URL
- ✅ 集成在应用代码中
- ✅ 更易于维护和监控
```
---
## 📊 监控与告警
### 监控指标
- 解绑数量(`unbound`
- 执行时长(`duration`
- 成功率
### 查看执行历史
在宝塔面板的「计划任务」→「日志」中查看每次执行的结果。
### 建议
- 如果单次解绑数量 > 100检查是否有异常
- 如果连续失败,检查数据库连接或接口状态
---
## ✅ 部署检查清单
部署前确认:
- ✅ API 文件已创建:`app/api/cron/unbind-expired/route.ts`
- ✅ 代码已部署到服务器
- ✅ PM2 服务已重启
部署后确认:
- ✅ 手动访问接口测试成功
- ✅ 宝塔计划任务已创建
- ✅ 执行周期设置为 30 分钟
- ✅ 手动执行一次测试成功
- ✅ 查看日志确认正常运行
---
## 🚀 快速配置命令
### 宝塔面板 - 计划任务配置
**任务类型**: 访问URL
**URL地址**: `https://soul.quwanzhi.com/api/cron/unbind-expired?secret=soul_cron_unbind_2026`
**执行周期**: N分钟 → 30
**任务名称**: 自动解绑过期推荐关系
---
**配置完成后系统将每30分钟自动解绑过期且未购买的推荐关系**

View File

@@ -1,595 +0,0 @@
# 自定义导航组件方案
## 📋 背景
### 为什么不使用原生 tabBar
**需求**
- "找伙伴"功能需要根据 API 配置动态显示/隐藏
- 不同环境(开发/测试/生产)可能有不同的功能开关
- 需要灵活控制导航栏的显示逻辑
**原生 tabBar 的限制**
1.**静态配置**`app.json` 中的 `tabBar.list` 是固定的,无法动态增删
2.**无法隐藏单个 tab**:只能显示或隐藏整个 tabBar
3.**样式受限**:虽然支持自定义 tabBar但配置复杂
4.**功能限制**:自定义 tabBar 需要在每个页面手动管理状态
**自定义组件的优势**
1.**完全动态**:可以根据任何条件显示/隐藏任意 tab
2.**样式自由**:完全控制样式,可以实现中间凸起按钮等特殊效果
3.**状态统一**:通过 props 传递当前页面,组件内部管理激活态
4.**跨平台一致**Web 和小程序使用相同的组件逻辑
---
## 🔧 技术方案
### 方案对比
| 特性 | 原生 tabBar | 自定义 tabBar | 自定义组件(当前方案) |
|------|------------|--------------|---------------------|
| 动态显示/隐藏 | ❌ | ⚠️ 复杂 | ✅ 简单 |
| 样式自由度 | ❌ 受限 | ✅ 自由 | ✅ 完全自由 |
| 中间凸起按钮 | ❌ 不支持 | ⚠️ 复杂 | ✅ 简单 |
| 跨平台一致性 | ❌ 不同 | ⚠️ 需额外处理 | ✅ 一致 |
| 页面跳转 | `wx.switchTab` | `wx.switchTab` | `wx.reLaunch` |
| 配置复杂度 | 简单 | 复杂 | 中等 |
**结论**:使用**自定义组件**方案
---
## 📝 实现细节
### 1. 移除原生 tabBar 配置
**文件**`newpp/build/miniprogram.config.js`
```javascript
appExtraConfig: {
sitemapLocation: 'sitemap.json',
// ✅ 不配置 tabBar使用完全自定义的导航组件
// 原因:需要根据 API 配置动态显示/隐藏"找伙伴"功能
},
```
**说明**
- 不在 `app.json` 中配置 `tabBar`
- 完全依赖自定义组件 `BottomNav.jsx`
---
### 2. 修改路由跳转方式
**文件**`newpp/src/adapters/router.js`
#### Before使用 wx.switchTab
```javascript
export function switchTab(path) {
if (isMiniProgram()) {
wx.switchTab({ url: toMpPath(path) }) // ❌ 需要原生 tabBar 配置
} else {
window.location.href = path === '/' ? 'index.html' : path.replace(/^\//, '') + '.html'
}
}
```
#### After使用 wx.reLaunch
```javascript
export function switchTab(path) {
if (isMiniProgram()) {
// ✅ 使用 wx.reLaunch 代替 wx.switchTab
// 原因:没有配置原生 tabBar使用自定义组件
wx.reLaunch({ url: toMpPath(path) })
} else {
window.location.href = path === '/' ? 'index.html' : path.replace(/^\//, '') + '.html'
}
}
```
**wx.reLaunch vs wx.switchTab**
| API | 说明 | 页面栈 | 限制 | 适用场景 |
|-----|------|--------|------|---------|
| `wx.switchTab` | 跳转到 tabBar 页面 | 清空 | 只能跳转到原生 tabBar 页面 | 原生 tabBar |
| `wx.reLaunch` | 关闭所有页面,打开某页面 | 清空 | 无 | 自定义导航 |
| `wx.redirectTo` | 关闭当前页面,跳转 | 替换栈顶 | 无 | 页面跳转 |
| `wx.navigateTo` | 保留当前页面,跳转 | 新增栈 | 最多10层 | 详情页 |
**为什么选择 `wx.reLaunch`**
1. ✅ 清空页面栈,避免页面堆积
2. ✅ 无限制,可以跳转到任何页面
3. ✅ 模拟 tabBar 的行为(清空栈)
4. ⚠️ 缺点:每次跳转会重新加载页面(但对于导航栏切换这是正常的)
---
### 3. 自定义组件实现
**文件**`newpp/src/components/BottomNav.jsx`
#### 核心逻辑
```javascript
export default function BottomNav({ current }) {
const [matchEnabled, setMatchEnabled] = useState(true)
const [configLoaded, setConfigLoaded] = useState(false)
useEffect(() => {
if (isMiniProgram()) {
// ✅ 小程序:从 app.globalData 读取配置
try {
const app = getApp()
if (app && app.globalData) {
setMatchEnabled(app.globalData.matchEnabled !== false)
}
} catch (e) {
// 默认显示
} finally {
setConfigLoaded(true)
}
} else {
// ✅ Web从 API 加载配置
fetch('/api/db/config')
.then((res) => res.json())
.then((data) => {
if (data.features) {
setMatchEnabled(data.features.matchEnabled === true)
}
})
.catch(() => {
setMatchEnabled(false)
})
.finally(() => {
setConfigLoaded(true)
})
}
}, [])
// ✅ 根据配置动态生成可见的 tabs
const visibleTabs = matchEnabled ? tabs : tabs.filter((t) => t.id !== 'match')
const handleTabClick = (path) => {
if (path === current) return
// ✅ 使用 switchTab内部调用 wx.reLaunch
try {
switchTab(path)
} catch (e) {
console.error('switchTab error:', e)
}
}
return (
<div style={styles.nav}>
<div style={styles.container}>
{visibleTabs.map((tab) => {
// ✅ 根据 isCenter 渲染不同样式
if (tab.isCenter) {
return (/* 中间凸起按钮 */)
}
return (/* 普通按钮 */)
})}
</div>
</div>
)
}
```
#### 配置加载流程
```
┌─────────────────┐
│ 组件挂载 │
└────────┬────────┘
┌─────────────────┐
│ 判断环境 │
│ isMiniProgram? │
└────┬──────┬─────┘
│ Yes │ No
▼ ▼
┌─────────┐ ┌──────────────┐
│小程序环境│ │ Web 环境 │
│getApp() │ │fetch('/api') │
│.globalData│ │ .then() │
└────┬────┘ └──────┬───────┘
│ │
└──────┬──────┘
┌───────────────┐
│setMatchEnabled│
│setConfigLoaded│
└───────┬───────┘
┌───────────────┐
│ 动态渲染 tabs │
│ visibleTabs │
└───────────────┘
```
---
## 🎯 配置管理
### Web 环境
**API 端点**`/api/db/config`
**返回格式**
```json
{
"features": {
"matchEnabled": true // ✅ 控制"找伙伴"功能
}
}
```
**配置位置**:数据库或配置文件
```javascript
// app/api/db/config/route.ts
export async function GET() {
const config = await db.collection('config').findOne({ key: 'features' })
return NextResponse.json({
features: {
matchEnabled: config?.matchEnabled ?? true, // 默认开启
},
})
}
```
### 小程序环境
**配置位置**`miniprogram/app.js`
```javascript
App({
globalData: {
matchEnabled: true, // ✅ 控制"找伙伴"功能
// 其他配置...
},
onLaunch() {
// ✅ 可以从服务器加载配置
this.loadFeatureConfig()
},
async loadFeatureConfig() {
try {
const res = await wx.request({
url: 'https://your-api.com/config',
method: 'GET',
})
if (res.data && res.data.features) {
this.globalData.matchEnabled = res.data.features.matchEnabled
// ✅ 触发页面更新(如果需要)
this.notifyConfigUpdate()
}
} catch (e) {
console.error('Load config error:', e)
}
},
notifyConfigUpdate() {
// 通知所有页面配置已更新
// 可以使用事件总线或全局状态管理
},
})
```
---
## 📱 页面集成
### 在每个导航页面中使用
**示例**`newpp/src/pages/HomePage.jsx`
```javascript
import BottomNav from '../components/BottomNav'
export default function HomePage() {
return (
<div>
<div style={styles.page}>
{/* 页面内容 */}
</div>
{/* ✅ 传入当前路径 */}
<BottomNav current="/" />
</div>
)
}
```
**其他页面**
- `ChaptersPage.jsx`: `<BottomNav current="/chapters" />`
- `MatchPage.jsx`: `<BottomNav current="/match" />`
- `MyPage.jsx`: `<BottomNav current="/my" />`
---
## 🐛 已知问题与解决方案
### 问题 1页面切换时闪烁
**症状**:使用 `wx.reLaunch` 时,页面会重新加载,可能出现白屏
**原因**`wx.reLaunch` 会关闭所有页面,然后打开新页面
**解决方案**
1. **方案 A优化页面加载速度**
- 使用骨架屏
- 预加载数据
- 缓存页面状态
2. **方案 B使用页面栈管理推荐**
```javascript
export function switchTab(path) {
if (isMiniProgram()) {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const currentPath = '/' + currentPage.route.replace(/^pages\//, '').replace(/\/index$/, '')
// ✅ 如果是相同页面,不跳转
if (currentPath === path) {
return
}
// ✅ 检查页面栈中是否已有目标页面
const targetPageIndex = pages.findIndex((p) => {
const pPath = '/' + p.route.replace(/^pages\//, '').replace(/\/index$/, '')
return pPath === path
})
if (targetPageIndex >= 0) {
// ✅ 如果已有,返回到该页面
const delta = pages.length - 1 - targetPageIndex
wx.navigateBack({ delta })
} else {
// ✅ 如果没有,使用 reLaunch
wx.reLaunch({ url: toMpPath(path) })
}
} else {
window.location.href = path === '/' ? 'index.html' : path.replace(/^\//, '') + '.html'
}
}
```
3. **方案 C保留部分页面栈**
```javascript
export function switchTab(path) {
if (isMiniProgram()) {
const pages = getCurrentPages()
// ✅ 如果栈中只有1个页面使用 redirectTo
if (pages.length === 1) {
wx.redirectTo({ url: toMpPath(path) })
} else {
// ✅ 否则,返回到第一个页面,然后 redirectTo
wx.navigateBack({ delta: pages.length - 1 })
setTimeout(() => {
wx.redirectTo({ url: toMpPath(path) })
}, 100)
}
} else {
window.location.href = path === '/' ? 'index.html' : path.replace(/^\//, '') + '.html'
}
}
```
**推荐**:方案 B检查页面栈
---
### 问题 2配置更新不及时
**症状**:修改 `app.globalData.matchEnabled` 后,导航栏不更新
**原因**:组件已挂载,`useEffect` 只执行一次
**解决方案**
1. **方案 A监听配置变化**
```javascript
useEffect(() => {
if (isMiniProgram()) {
const app = getApp()
// ✅ 监听配置变化
const checkConfig = () => {
if (app && app.globalData) {
setMatchEnabled(app.globalData.matchEnabled !== false)
}
}
// ✅ 初始加载
checkConfig()
// ✅ 定时检查(或使用事件监听)
const interval = setInterval(checkConfig, 1000)
return () => clearInterval(interval)
}
}, [])
```
2. **方案 B使用全局状态管理**
```javascript
// store/index.js
const useStore = create((set) => ({
matchEnabled: true,
setMatchEnabled: (enabled) => set({ matchEnabled: enabled }),
}))
// BottomNav.jsx
const { matchEnabled } = useStore()
```
3. **方案 C页面激活时刷新**
```javascript
useEffect(() => {
const handleShow = () => {
// ✅ 页面显示时重新读取配置
if (isMiniProgram()) {
const app = getApp()
if (app && app.globalData) {
setMatchEnabled(app.globalData.matchEnabled !== false)
}
}
}
// ✅ 监听页面显示事件
if (isMiniProgram() && typeof wx !== 'undefined') {
wx.onAppShow?.(handleShow)
}
return () => {
wx.offAppShow?.(handleShow)
}
}, [])
```
**推荐**:方案 C页面激活时刷新
---
### 问题 3Web 和小程序跳转体验不一致
**症状**Web 使用 `location.href` 会刷新整个页面,小程序使用 `wx.reLaunch` 有过渡动画
**原因**Web 和小程序的路由机制不同
**解决方案**
1. **Web 端使用 SPA 路由**(如果是 Next.js
```javascript
import { useRouter } from 'next/router'
export function switchTab(path) {
if (isMiniProgram()) {
wx.reLaunch({ url: toMpPath(path) })
} else {
// ✅ 使用 Next.js 路由
const router = useRouter()
router.push(path)
}
}
```
2. **小程序端优化过渡**
```javascript
export function switchTab(path) {
if (isMiniProgram()) {
wx.reLaunch({
url: toMpPath(path),
success: () => {
// ✅ 跳转成功
},
fail: (err) => {
console.error('reLaunch fail:', err)
// ✅ 降级到 navigateTo
wx.navigateTo({ url: toMpPath(path) })
},
})
} else {
window.location.href = path === '/' ? 'index.html' : path.replace(/^\//, '') + '.html'
}
}
```
---
## ✅ 测试清单
### 功能测试
- [ ] **配置加载**:小程序启动时正确读取 `matchEnabled`
- [ ] **动态显示/隐藏**
- [ ] `matchEnabled: true` 时,显示"找伙伴" tab
- [ ] `matchEnabled: false` 时,隐藏"找伙伴" tab
- [ ] **页面跳转**
- [ ] 点击"首页" → 跳转到首页
- [ ] 点击"目录" → 跳转到目录页
- [ ] 点击"找伙伴" → 跳转到找伙伴页
- [ ] 点击"我的" → 跳转到我的页
- [ ] **激活态**:当前页 tab 高亮显示
### 样式测试
- [ ] **中间凸起按钮**:渐变色 + 阴影
- [ ] **普通按钮**:灰色/高亮切换
- [ ] **安全区适配**:底部留出安全区高度
### 性能测试
- [ ] **配置加载速度**< 100ms
- [ ] **页面跳转速度**< 500ms
- [ ] **内存占用**:无内存泄漏
---
## 📊 对比总结
| 方案 | 原生 tabBar | 自定义 tabBar | 自定义组件(当前) |
|------|------------|--------------|------------------|
| 动态控制 | ❌ | ⚠️ | ✅ |
| 样式自由 | ❌ | ✅ | ✅ |
| 配置复杂度 | 低 | 高 | 中 |
| 跳转方式 | `wx.switchTab` | `wx.switchTab` | `wx.reLaunch` |
| 页面栈 | 清空 | 清空 | 清空 |
| 跨平台一致性 | ❌ | ⚠️ | ✅ |
| 维护成本 | 低 | 高 | 中 |
**结论**:自定义组件方案最适合当前需求
---
## 🚀 后续优化
### Priority P1推荐
1. **优化页面跳转体验**
- 实现方案 B页面栈检查
- 避免不必要的页面重新加载
2. **配置热更新**
- 实现配置变化监听
- 自动更新导航栏显示
3. **添加骨架屏**
- 减少页面切换时的白屏
- 提升用户体验
### Priority P2可选
1. **图标优化**
- 使用图片替换 emoji
- 支持激活态和非激活态
2. **动效优化**
- 添加页面切换动画
- 优化按钮点击反馈
3. **埋点统计**
- 记录 tab 点击次数
- 分析用户行为
---
## 📚 相关文档
1. [小程序样式修复说明](./小程序样式修复说明.md)
2. [小程序底部导航修复说明](./小程序底部导航修复说明.md)
---
**总结**:使用自定义组件方案,完全控制导航栏的显示逻辑,满足根据 API 配置动态显示/隐藏"找伙伴"功能的需求。

View File

@@ -1,508 +0,0 @@
# 订单管理商品显示优化
## 问题描述
在后台管理的"交易中心-订单管理" tab 中,商品列显示不正确:
**问题截图**
- 显示为"章节undefined"
- 无法区分单章购买、全本购买、匹配次数购买
- 缺少书名和章节详细信息
**期望效果**
- 单章购买:显示"《底层逻辑》- 节标题",副标题显示章标题
- 全本购买:显示"《底层逻辑》- 全本",副标题显示"全书解锁"
- 匹配次数:显示"匹配次数购买",副标题显示"功能权益"
## 数据结构分析
### Orders 表结构
```sql
CREATE TABLE orders (
id VARCHAR(50) PRIMARY KEY,
order_sn VARCHAR(50) UNIQUE NOT NULL,
user_id VARCHAR(50) NOT NULL,
product_type ENUM('section', 'fullbook', 'match') NOT NULL,
product_id VARCHAR(50), -- 章节ID如 '1.1', '2.3'或书籍ID
amount DECIMAL(10,2) NOT NULL,
description VARCHAR(200),
status ENUM('created', 'pending', 'paid', 'cancelled', 'refunded', 'expired'),
referrer_id VARCHAR(50) NULL,
referral_code VARCHAR(20) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- ... 其他字段
)
```
### Chapters 表结构
```sql
CREATE TABLE chapters (
id VARCHAR(20) PRIMARY KEY, -- 章节ID如 '1.1', 'preface'
part_id VARCHAR(20) NOT NULL,
part_title VARCHAR(100) NOT NULL, -- 篇标题
chapter_id VARCHAR(20) NOT NULL,
chapter_title VARCHAR(200) NOT NULL, -- 章标题
section_title VARCHAR(200) NOT NULL, -- 节标题
content LONGTEXT NOT NULL,
word_count INT DEFAULT 0,
is_free BOOLEAN DEFAULT FALSE,
price DECIMAL(10,2) DEFAULT 1.00,
-- ... 其他字段
)
```
### 购买类型说明
| product_type | product_id | 说明 | 显示格式 |
|-------------|-----------|------|---------|
| `section` | `'1.1'`, `'2.3'` | 单章购买 | 《底层逻辑》- 节标题<br/>章标题 |
| `fullbook` | `'book-1'` 或 NULL | 全本购买 | 《底层逻辑》- 全本<br/>全书解锁 |
| `match` | NULL | 匹配次数 | 匹配次数购买<br/>功能权益 |
## 解决方案
### 1. 修改订单 API`app/api/orders/route.ts`
#### 1.1 更新数据转换函数
**修改前**
```typescript
function rowToOrder(row: Record<string, unknown>) {
return {
id: row.id,
productType: row.product_type,
productId: row.product_id,
amount: parseFloat(row.amount as string) || 0,
// ... 其他字段
userNickname: row.user_nickname ?? null,
userAvatar: row.user_avatar ?? null,
}
}
```
**修改后**
```typescript
function rowToOrder(row: Record<string, unknown>) {
return {
id: row.id,
productType: row.product_type,
productId: row.product_id,
amount: parseFloat(row.amount as string) || 0,
// ... 其他字段
userNickname: row.user_nickname ?? null,
userAvatar: row.user_avatar ?? null,
// 新增:章节信息
bookName: '《底层逻辑》', // 书名(固定)
chapterTitle: row.chapter_title ?? null, // 章标题
sectionTitle: row.section_title ?? null, // 节标题
}
}
```
#### 1.2 更新 SQL 查询
**修改前**(仅 JOIN users 表):
```sql
SELECT o.*, u.nickname as user_nickname, u.avatar as user_avatar
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
ORDER BY o.created_at DESC
```
**修改后**JOIN users + chapters 表):
```sql
SELECT
o.*,
u.nickname as user_nickname,
u.avatar as user_avatar,
c.chapter_title,
c.section_title
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN chapters c ON o.product_id = c.id AND o.product_type = 'section'
ORDER BY o.created_at DESC
```
**关键点**
- `LEFT JOIN chapters c` 仅在 `product_type = 'section'` 时关联
- 这样全本购买和匹配次数购买的 `chapter_title``section_title` 为 NULL
- 不影响查询性能
### 2. 修改前端类型定义(`app/admin/distribution/page.tsx`
**修改前**
```typescript
interface Order {
id: string
userId: string
type: 'section' | 'fullbook' | 'match'
sectionId?: string
sectionTitle?: string
amount: number
status: 'pending' | 'completed' | 'failed'
// ... 其他字段
}
```
**修改后**
```typescript
interface Order {
id: string
userId: string
productType: 'section' | 'fullbook' | 'match' // API 返回的字段名
type?: 'section' | 'fullbook' | 'match' // 兼容旧字段名
productId?: string
sectionId?: string // 兼容旧字段名
bookName?: string // 书名
chapterTitle?: string // 章标题
sectionTitle?: string // 节标题
amount: number
status: 'pending' | 'completed' | 'failed' | 'paid' | 'created'
// ... 其他字段
}
```
### 3. 修改前端显示逻辑
#### 3.1 商品信息显示
**修改前**(显示"章节undefined"
```tsx
<p className="text-white text-sm">
{order.type === 'fullbook' ? '整本购买' :
order.type === 'match' ? '匹配次数' :
order.sectionTitle || `章节${order.sectionId}`}
</p>
<p className="text-gray-500 text-xs">
{order.type === 'fullbook' ? '全书' :
order.type === 'match' ? '功能' : '单章'}
</p>
```
**修改后**(显示完整商品信息):
```tsx
<p className="text-white text-sm">
{(() => {
const type = order.productType || order.type
if (type === 'fullbook') {
return `${order.bookName || '《底层逻辑》'} - 全本`
} else if (type === 'match') {
return '匹配次数购买'
} else {
// section - 单章购买
return `${order.bookName || '《底层逻辑》'} - ${order.sectionTitle || order.chapterTitle || `章节${order.productId || order.sectionId || ''}`}`
}
})()}
</p>
<p className="text-gray-500 text-xs">
{(() => {
const type = order.productType || order.type
if (type === 'fullbook') {
return '全书解锁'
} else if (type === 'match') {
return '功能权益'
} else {
return order.chapterTitle || '单章购买'
}
})()}
</p>
```
**显示效果**
| 购买类型 | 主标题 | 副标题 |
|---------|--------|--------|
| 单章购买 | 《底层逻辑》- 荷包:电动车出租的被动收入模式 | 第1章人与人之间的底层逻辑 |
| 全本购买 | 《底层逻辑》- 全本 | 全书解锁 |
| 匹配次数 | 匹配次数购买 | 功能权益 |
#### 3.2 搜索功能增强
**修改前**
```typescript
if (searchTerm) {
const term = searchTerm.toLowerCase()
return (
order.id?.toLowerCase().includes(term) ||
order.userNickname?.toLowerCase().includes(term) ||
order.userPhone?.includes(term) ||
order.sectionTitle?.toLowerCase().includes(term) ||
(order.referrerCode && order.referrerCode.toLowerCase().includes(term))
)
}
```
**修改后**(增加书名和章标题搜索):
```typescript
if (searchTerm) {
const term = searchTerm.toLowerCase()
return (
order.id?.toLowerCase().includes(term) ||
order.userNickname?.toLowerCase().includes(term) ||
order.userPhone?.includes(term) ||
order.sectionTitle?.toLowerCase().includes(term) ||
order.chapterTitle?.toLowerCase().includes(term) ||
order.bookName?.toLowerCase().includes(term) ||
(order.referrerCode && order.referrerCode.toLowerCase().includes(term)) ||
(order.referrerNickname && order.referrerNickname.toLowerCase().includes(term))
)
}
```
**搜索能力**
- 可搜索订单 ID
- 可搜索用户昵称和手机号
- 可搜索书名、章标题、节标题
- 可搜索推荐人信息
## 扩展性设计
### 多本书支持
当前书名是硬编码的 `'《底层逻辑》'`。如果未来需要支持多本书,可以:
#### 方案1在订单表增加 book_id 字段
```sql
ALTER TABLE orders ADD COLUMN book_id VARCHAR(20);
```
#### 方案2创建 books 表
```sql
CREATE TABLE books (
id VARCHAR(20) PRIMARY KEY,
title VARCHAR(100) NOT NULL, -- 如《底层逻辑》
author VARCHAR(50),
price DECIMAL(10,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 修改 chapters 表增加 book_id
ALTER TABLE chapters ADD COLUMN book_id VARCHAR(20);
ALTER TABLE chapters ADD INDEX idx_book_id (book_id);
```
然后修改 API 查询:
```sql
SELECT
o.*,
u.nickname as user_nickname,
u.avatar as user_avatar,
c.chapter_title,
c.section_title,
b.title as book_name
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN chapters c ON o.product_id = c.id AND o.product_type = 'section'
LEFT JOIN books b ON c.book_id = b.id
ORDER BY o.created_at DESC
```
### 章节ID规范
建议统一章节 ID 格式:
| 类型 | ID 格式 | 示例 |
|-----|---------|------|
| 序言 | `preface` | `preface` |
| 正文 | `{章}.{节}` | `1.1`, `2.3`, `9.14` |
| 尾声 | `epilogue` | `epilogue` |
| 附录 | `appendix-{序号}` | `appendix-1`, `appendix-2` |
## 修改文件清单
### 后端 API
1. **`app/api/orders/route.ts`**
- 修改 `rowToOrder()` 函数,增加 `bookName`, `chapterTitle`, `sectionTitle` 字段
- 修改 SQL 查询JOIN `chapters` 表获取章节信息
- 行号12-32, 44-59
### 前端页面
2. **`app/admin/distribution/page.tsx`**
- 更新 `Order` 接口定义,增加新字段
- 修改商品信息显示逻辑,根据 `productType` 显示不同格式
- 增强搜索功能,支持搜索书名、章标题、节标题
- 行号78-96, 636-647, 659-685
## 验证步骤
### 1. 重启服务
```powershell
pm2 restart mycontent
# 或
npm run dev
```
### 2. 访问页面
打开浏览器:`http://localhost:3006/admin/distribution`
### 3. 测试订单管理 Tab
1. **查看商品显示**
- 点击"订单管理" tab
- 检查商品列是否正确显示:
- 单章:《底层逻辑》- 节标题(副标题:章标题)
- 全本:《底层逻辑》- 全本(副标题:全书解锁)
- 匹配:匹配次数购买(副标题:功能权益)
2. **测试搜索**
- 搜索"底层逻辑",应该能找到所有订单
- 搜索章节名(如"荷包"),应该能找到对应订单
- 搜索用户昵称,应该能找到对应订单
3. **检查不同订单类型**
- 如果有单章购买订单,确认显示完整的"书名 - 节标题"
- 如果有全本购买订单,确认显示"《底层逻辑》- 全本"
- 如果有匹配次数订单,确认显示"匹配次数购买"
### 4. 查看 API 响应
打开 DevTools → Network 标签 → 找到 `/api/orders` 请求:
```json
{
"success": true,
"orders": [
{
"id": "ORDER_123",
"productType": "section",
"productId": "1.1",
"bookName": "《底层逻辑》",
"chapterTitle": "第1章人与人之间的底层逻辑",
"sectionTitle": "1.1 荷包:电动车出租的被动收入模式",
"amount": 1.00,
"userNickname": "张三",
"status": "paid"
},
{
"id": "ORDER_456",
"productType": "fullbook",
"productId": null,
"bookName": "《底层逻辑》",
"chapterTitle": null,
"sectionTitle": null,
"amount": 99.00,
"userNickname": "李四",
"status": "paid"
}
]
}
```
### 5. 数据库验证
```sql
-- 查看订单和章节信息
SELECT
o.id,
o.product_type,
o.product_id,
o.amount,
c.chapter_title,
c.section_title,
u.nickname
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN chapters c ON o.product_id = c.id AND o.product_type = 'section'
WHERE o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 10;
-- 验证章节数据
SELECT
id,
chapter_title,
section_title,
is_free,
price
FROM chapters
WHERE id IN ('1.1', '1.2', '2.1')
LIMIT 5;
```
## 常见问题
### Q1: 为什么只对 product_type='section' 进行 JOIN
**答**
- 全本购买和匹配次数购买不需要章节信息
- `LEFT JOIN` 的条件 `o.product_type = 'section'` 确保只有单章购买才查询 chapters 表
- 这样可以提高查询性能,避免不必要的 JOIN
### Q2: 如果 chapters 表没有某个章节数据怎么办?
**答**
显示逻辑有兜底机制:
```typescript
order.sectionTitle || order.chapterTitle || `章节${order.productId}`
```
- 优先显示节标题
- 其次显示章标题
- 最后显示"章节 + ID"
### Q3: 全本购买的 product_id 是什么?
**答**
全本购买的 `product_id` 可能是:
- `NULL`(早期订单)
- `'book-1'`如果有书籍ID
- 固定值如 `'fullbook'`
建议在创建订单时统一为 `NULL``'fullbook'`
### Q4: 如何添加新的书籍?
**答**
1.`books` 表中新增书籍记录(如果使用了多本书支持)
2.`chapters` 表中新增章节,关联 `book_id`
3. 修改 API 的 `rowToOrder()` 函数,从 JOIN 的 books 表读取书名
4. 前端显示逻辑无需修改(自动读取 `order.bookName`
## 性能优化
### 索引优化
为 JOIN 查询添加索引:
```sql
-- orders 表索引
CREATE INDEX idx_orders_product ON orders(product_type, product_id);
CREATE INDEX idx_orders_user ON orders(user_id);
-- chapters 表索引
CREATE INDEX idx_chapters_id ON chapters(id);
-- users 表索引(如果没有)
CREATE INDEX idx_users_id ON users(id);
```
### 查询优化
如果订单量很大(>10万考虑
1. 分页查询:`LIMIT 100 OFFSET 0`
2. 时间范围过滤:`WHERE o.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)`
3. 缓存热门查询结果
## 相关文件
- **订单 API**`app/api/orders/route.ts`
- **交易中心页面**`app/admin/distribution/page.tsx`
- **数据库初始化**`lib/db.ts`
- **书籍数据**`lib/book-data.ts`
## 版本信息
- **修改时间**2026-02-04
- **修改内容**
1. 修改订单 APIJOIN chapters 表获取章节信息
2. 返回 bookName, chapterTitle, sectionTitle 字段
3. 优化前端显示逻辑,区分单章/全本/匹配次数
4. 增强搜索功能,支持搜索书名和章节标题
5. 设计多本书扩展方案

View File

@@ -1,411 +0,0 @@
# 订单管理数据类型错误修复
## 问题描述
在后台管理的"交易中心-订单管理" tab 中,点击时出现以下错误:
### 错误 1JSON 解析错误
```
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
```
**原因**:某个 API 返回了 HTML 页面而不是 JSON通常是 404 或 500 错误)
### 错误 2类型错误
```
TypeError: (order.amount || 0).toFixed is not a function
```
**原因**`order.amount` 字段是字符串类型,无法调用 `.toFixed()` 方法
## 问题根源
### 1. 数据库类型转换问题
MySQL 中 `DECIMAL` 类型的字段在查询时可能返回字符串类型:
```typescript
// 数据库返回:
{
amount: "99.00" // 字符串!
}
// 前端期望:
{
amount: 99.00 // 数字
}
```
### 2. 错误处理不完善
前端代码没有对 API 调用失败的情况做充分的错误处理:
- 如果 API 返回 HTML404/500`await res.json()` 会抛出异常
- 异常导致整个 `loadData()` 函数中断
- 其他正常的 API 调用也无法执行
- 页面崩溃,显示错误边界
## 解决方案
### 1. 修复 API 数据类型转换
**文件**`app/api/orders/route.ts`
**修改前**
```typescript
function rowToOrder(row: Record<string, unknown>) {
return {
id: row.id,
amount: row.amount, // 可能是字符串 "99.00"
// ... 其他字段
}
}
```
**修改后**
```typescript
function rowToOrder(row: Record<string, unknown>) {
return {
id: row.id,
amount: parseFloat(row.amount as string) || 0, // 转换为数字
// ... 其他字段
}
}
```
### 2. 增强前端错误处理
**文件**`app/admin/distribution/page.tsx`
#### 2.1 概览数据加载
**修改前**
```typescript
const overviewRes = await fetch('/api/admin/distribution/overview')
const overviewData = await overviewRes.json() // 可能抛出异常
if (overviewData.success) {
setOverview(overviewData.overview)
}
```
**修改后**
```typescript
try {
const overviewRes = await fetch('/api/admin/distribution/overview')
if (overviewRes.ok) { // 检查 HTTP 状态
const overviewData = await overviewRes.json()
if (overviewData.success) {
setOverview(overviewData.overview)
}
}
} catch (overviewError) {
console.error('[Admin] 概览接口异常:', overviewError)
}
```
#### 2.2 订单数据加载
**修改前**
```typescript
const ordersRes = await fetch('/api/orders')
const ordersData = await ordersRes.json() // 可能抛出异常
if (ordersData.success) {
const enrichedOrders = ordersData.orders.map((order: Order) => ({
...order,
// 假设 amount 是数字
}))
setOrders(enrichedOrders)
}
```
**修改后**
```typescript
try {
const ordersRes = await fetch('/api/orders')
if (!ordersRes.ok) {
console.error('[Admin] 订单接口错误:', ordersRes.status)
setOrders([])
} else {
const ordersData = await ordersRes.json()
if (ordersData.success && ordersData.orders) {
const enrichedOrders = ordersData.orders.map((order: Order) => ({
...order,
amount: parseFloat(order.amount as any) || 0, // 双重保险
// ... 其他字段
}))
setOrders(enrichedOrders)
} else {
setOrders([])
}
}
} catch (orderError) {
console.error('[Admin] 加载订单数据失败:', orderError)
setOrders([])
}
```
#### 2.3 其他数据加载
对用户数据、绑定数据、提现数据也采用同样的错误处理模式:
```typescript
try {
const res = await fetch('/api/...')
if (res.ok) {
const data = await res.json()
// 处理数据
} else {
// 设置为空数组
}
} catch (error) {
console.error('加载失败:', error)
// 设置为空数组
}
```
### 3. 增强前端渲染安全性
**文件**`app/admin/distribution/page.tsx`
#### 3.1 订单金额显示
**修改前**
```typescript
¥{(order.amount || 0).toFixed(2)}
// 如果 amount 是字符串,会报错
```
**修改后**
```typescript
¥{typeof order.amount === 'number'
? order.amount.toFixed(2)
: parseFloat(order.amount || '0').toFixed(2)}
// 检查类型,确保安全
```
#### 3.2 推荐人收益显示
**修改前**
```typescript
{order.referrerEarnings ? ${order.referrerEarnings.toFixed(2)}` : '-'}
// 如果是字符串,会报错
```
**修改后**
```typescript
{order.referrerEarnings
? ${(typeof order.referrerEarnings === 'number'
? order.referrerEarnings
: parseFloat(order.referrerEarnings)).toFixed(2)}`
: '-'}
```
## 修改文件清单
### 后端 API
1. **`app/api/orders/route.ts`**
- 修改 `rowToOrder()` 函数
-`amount` 字段从字符串转换为数字
- 行号20
### 前端页面
2. **`app/admin/distribution/page.tsx`**
- 增强概览数据加载的错误处理行号117-129
- 增强用户数据加载的错误处理行号131-140
- 增强订单数据加载的错误处理行号142-167
- 增强绑定数据加载的错误处理行号169-179
- 增强提现数据加载的错误处理行号181-191
- 修复订单金额显示的类型安全行号667
- 修复推荐人收益显示的类型安全行号692-696
## 防御性编程原则
本次修复遵循以下防御性编程原则:
### 1. 多层防御
- **数据库层**:查询结果可能是字符串
- **API 层**:转换为数字类型
- **前端加载层**:再次确保数字类型
- **前端渲染层**:类型检查 + 类型转换
### 2. 优雅降级
- API 调用失败时,设置为空数组 `[]`,而不是中断整个加载流程
- 单个 API 失败不影响其他 API 的加载
- 页面始终可用,只是某些 tab 可能没有数据
### 3. 详细日志
每个错误都有清晰的日志输出:
```typescript
console.error('[Admin] 订单接口错误:', ordersRes.status)
console.error('[Admin] 加载订单数据失败:', orderError)
```
方便定位问题源头。
### 4. 类型安全
渲染时不假设数据类型:
```typescript
typeof value === 'number' ? value.toFixed(2) : parseFloat(value).toFixed(2)
```
## 验证步骤
### 1. 重启服务
```powershell
pm2 restart mycontent
# 或
npm run dev
```
### 2. 访问页面
打开浏览器:`http://localhost:3006/admin/distribution`
### 3. 测试订单管理 Tab
1. **查看订单列表**
- 点击"订单管理" tab
- 应该能看到订单列表
- 金额显示正常(如 `¥99.00`
- 不应该有 `toFixed is not a function` 错误
2. **测试刷新**
- 点击页面顶部的"刷新数据"按钮
- 查看浏览器 DevTools Console
- 不应该有 JSON 解析错误
- 即使某个 API 失败,其他 tab 仍然可用
3. **查看其他 Tab**
- 数据概览 - 应该正常显示统计数据
- 绑定管理 - 应该正常显示绑定关系
- 提现审核 - 应该正常显示提现记录
### 4. 查看网络请求
打开 DevTools → Network 标签:
- `/api/orders` 应该返回 200 OK
- 响应体应该是正确的 JSON
- `amount` 字段应该是数字类型(在 JSON 中显示为 `99.00` 而不是 `"99.00"`
### 5. 查看控制台
服务器控制台应该输出:
```
[Admin] 概览数据加载成功: { todayClicks: 0, ... }
```
如果某个 API 失败:
```
[Admin] 订单接口错误: 500 Internal Server Error
[Admin] 加载订单数据失败: SyntaxError: ...
```
浏览器控制台也会有对应的错误日志,但页面不会崩溃。
## 数据库验证
检查订单表的数据类型:
```sql
-- 查看订单表结构
DESC orders;
-- 确认 amount 字段类型应该是 DECIMAL
-- 输出示例:
-- amount | decimal(10,2) | YES | | NULL | |
-- 查看订单数据
SELECT id, user_id, amount, status, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 10;
-- 测试类型转换
SELECT
amount,
CAST(amount AS DECIMAL(10,2)) as decimal_amount,
amount + 0 as numeric_amount
FROM orders
LIMIT 5;
```
## 常见问题
### Q1: 为什么要多层转换?
**答**
- 不同环境、不同 MySQL 驱动可能返回不同的类型
- API 层转换保证了大部分情况的正确性
- 前端再次转换作为"安全网",防止极端情况
- 渲染层的类型检查防止运行时错误
### Q2: 其他数字字段是否也有这个问题?
**答**
可能有!建议对所有来自数据库的 `DECIMAL``FLOAT``DOUBLE` 类型字段都做转换:
- `referrerEarnings`
- `earnings`
- `withdrawn_earnings`
- `pending_earnings`
可以在相应的 API 的 `rowToXxx()` 函数中统一处理。
### Q3: 为什么不修改数据库字段类型?
**答**
- `DECIMAL(10,2)` 是存储货币金额的标准类型
- 保证精度,避免浮点数误差(如 0.1 + 0.2 ≠ 0.3
- 问题在于查询结果的类型转换,而不是存储类型
### Q4: 如何避免类似问题?
**答**
1. **API 层统一转换**:在所有查询结果转换函数中明确类型转换
2. **TypeScript 类型定义**:使用严格的类型定义
3. **单元测试**:测试 API 返回值的类型
4. **前端防御**:渲染前做类型检查
## 相关文件
- **订单 API**`app/api/orders/route.ts`
- **交易中心页面**`app/admin/distribution/page.tsx`
- **类型定义**:在 `page.tsx` 文件顶部(`interface Order`
## 后续优化建议
1. **统一类型转换工具**
```typescript
// lib/utils/db-converter.ts
export function toNumber(value: unknown, defaultValue = 0): number {
if (typeof value === 'number') return value
if (typeof value === 'string') {
const num = parseFloat(value)
return isNaN(num) ? defaultValue : num
}
return defaultValue
}
```
2. **API 响应类型验证**
使用 Zod 或类似库验证 API 响应的类型
3. **全局错误边界增强**
提供更友好的错误提示,而不是显示技术错误
4. **监控告警**
记录 API 调用失败次数,超过阈值时告警
## 版本信息
- **修复时间**2026-02-04
- **修复内容**
1. 修复 `/api/orders` 中 `amount` 字段的类型转换
2. 增强交易中心页面的错误处理5 个 API 调用)
3. 增强前端渲染的类型安全2 处 `.toFixed()` 调用)
4. 采用"多层防御 + 优雅降级"策略

View File

@@ -1,8 +1,6 @@
# 部署总览
**我是卡若。**
本文档是 8、部署 的入口,只列核心文档;历史修复与优化说明见 [修复与优化记录](修复与优化记录/) 子目录。
> **当前生产架构**soul-apiGo + Gin+ soul-adminReact+ miniprogram。next-project 仅预览。
---
@@ -10,13 +8,9 @@
| 文档 | 说明 |
|------|------|
| [本地运行](本地运行.md) | 只写书不跑站 / 跑 Next 站点的本地步骤 |
| [Next.js宝塔部署方案](Next.js宝塔部署方案.md) | 宝塔 + Node/PM2 + Nginxdeploy/devlop 模式 |
| [Next.js自动化部署流程](Next.js自动化部署流程.md) | 自动化构建与发布 |
| [新分销逻辑-部署步骤](新分销逻辑-部署步骤.md) | 新分销上线:备份、迁移、部署、验证 |
| [新分销逻辑-宝塔操作清单](新分销逻辑-宝塔操作清单.md) | 宝塔侧具体操作 |
| [自动同步与分支策略](自动同步与分支策略.md) | 代码同步与分支策略 |
| [Standalone模式说明](Standalone模式说明.md) | Next.js standalone 输出说明 |
| [运行与部署](运行与部署.md) | 运行与部署说明 |
---
@@ -27,7 +21,6 @@
| [MCP-MySQL配置说明](MCP-MySQL配置说明.md) | MCP 连接 MySQL |
| [Soul-MySQL-MCP配置说明](Soul-MySQL-MCP配置说明.md) | 本项目 MySQL MCP 配置 |
| [API接入说明](API接入说明.md) | 外部/小程序接入 API |
| [宝塔配置检查说明](宝塔配置检查说明.md) | 宝塔环境检查 |
| [宝塔面板配置订单同步定时任务](宝塔面板配置订单同步定时任务.md) | 订单同步定时任务 |
---
@@ -42,7 +35,7 @@
| [章节阅读付费标准流程设计](章节阅读付费标准流程设计.md) | 阅读页付费流程 |
| [阅读页标准流程改造说明](阅读页标准流程改造说明.md) | 阅读页改造要点 |
| [支付接口清单](支付接口清单.md) | 支付相关接口列表 |
| [提现功能完整技术文档](../提现功能完整技术文档.md) | 微信支付商家转账到零钱 API 集成(签名、加解密、代码 |
| [提现功能完整技术文档](../../soul-api/提现功能完整技术文档.md) | 微信支付商家转账到零钱 API 集成(soul-api 目录 |
---
@@ -53,14 +46,3 @@
| [代码逻辑和数据库最终检查清单](代码逻辑和数据库最终检查清单.md) | 上线前检查项 |
| [佣金计算逻辑检查](佣金计算逻辑检查.md) | 佣金逻辑校验 |
| [佣金问题-快速诊断和修复](佣金问题-快速诊断和修复.md) | 佣金问题排查 |
---
## 五、修复与优化记录(历史说明,按需查阅)
- **分销中心**[loading 优化 v2](分销中心loading优化说明-v2.md)、[接口优化方案](分销中心接口优化方案.md)、[接口优化实施记录](分销中心接口优化实施记录.md)、[数据库连接错误修复](分销中心数据库连接错误修复.md)、[用户列表数据对接](分销中心用户列表数据对接说明.md)、[设置功能说明](分销中心设置功能说明.md)
- **提现与佣金**[可提现金额计算修复](可提现金额计算修复.md)、[累计佣金计算修复](累计佣金计算修复说明.md)、[提现卡片数据优化](提现卡片数据优化说明.md)、[提现双向校验](提现双向校验实现.md)、[提现审核流程优化](提现审核流程优化.md)、[提现按钮状态修复](提现按钮状态修复说明.md)、[提现按钮逻辑修正](提现按钮逻辑修正.md)、[提现接口逻辑修正](提现接口逻辑修正.md)、[提现接口数据查询错误修复](提现接口数据查询错误修复.md)、[提现记录获取失败诊断指南](提现记录获取失败诊断指南.md)、[后台提现审核](后台提现审核功能完善说明.md)、[后台提现审核数据对接](后台提现审核数据对接.md)、[后台提现审核测试指南](后台提现审核-快速测试指南.md)
- **交易中心与订单**[交易中心 Tab 按需加载优化](交易中心Tab按需加载优化.md)、[订单管理商品显示优化](订单管理商品显示优化.md)、[订单管理数据类型错误修复](订单管理数据类型错误修复.md)
- **小程序**[头像上传优化](小程序头像上传优化说明.md)、[提现金额对接](小程序提现金额对接说明.md)、[昵称自动填充](小程序昵称自动填充说明.md)、[调整说明](小程序调整说明.md)
- **推广与后台**[推广设置完整修复清单](推广设置功能-完整修复清单.md)、[推广设置测试清单](推广设置页面测试清单.md)、[管理端推广配置与小程序对接](管理端推广配置与小程序对接说明.md)、[后台订单显示优化](后台订单显示优化说明.md)
- **其他**[删除 referred_by](删除referred_by字段说明.md)、[绑定关系存储方案分析](绑定关系存储方案分析.md)、[自动解绑 API](自动解绑API配置说明.md)、[自定义导航组件](自定义导航组件方案.md)、[收益明细优化](收益明细优化说明.md)、[新分销代码修改总结](新分销逻辑-代码修改总结.md)、[章节阅读页集成示例](章节阅读页集成示例.md)

57
开发文档/README.md Normal file
View File

@@ -0,0 +1,57 @@
# Soul 创业派对 - 开发文档索引
> 小橙整理 · 2026-02-26
## 当前项目架构
| 子项目 | 目录 | 用途 |
|--------|------|------|
| soul-api | soul-api/ | Go + Gin + GORM 接口服务(生产后端) |
| soul-admin | soul-admin/ | React 管理后台 |
| miniprogram | miniprogram/ | 微信原生小程序 |
| next-project | next-project/ | 仅预览,非生产 |
---
## 文档导航
### 需求与项目管理
| 文档 | 说明 |
|------|------|
| [1、需求/需求汇总](1、需求/需求汇总.md) | 需求清单、业务需求 |
| [10、项目管理/项目落地推进表](10、项目管理/项目落地推进表.md) | 里程碑、永平落地 |
| [10、项目管理/运营与变更](10、项目管理/运营与变更.md) | 近期讨论、变更记录 |
### 架构与规范
| 文档 | 说明 |
|------|------|
| [2、架构/系统与技术](2、架构/系统与技术.md) | 系统与技术栈 |
| [2、架构/链路与变现](2、架构/链路与变现.md) | 业务链路 |
| [4、前端/前端开发规范](4、前端/前端开发规范.md) | 前端规范 |
| [6、后端/后端开发规范](6、后端/后端开发规范.md) | 后端规范 |
### 接口与部署
| 文档 | 说明 |
|------|------|
| [5、接口/API接口完整文档](5、接口/API接口完整文档.md) | API 完整文档 |
| [8、部署/部署总览](8、部署/部署总览.md) | 部署入口、分销与流程 |
### 临时需求池
| 文档 | 说明 |
|------|------|
| 临时需求池/需求分析-产品经理视角 | 需求分析 |
| 临时需求池/分润需求-技术分析 | 会员分润差异化技术方案 |
---
## 已移除文档2026-02-26
- Prisma ORM 迁移相关3 份)— 项目已迁至 Go/GORM
- Next.js 宝塔部署方案、Standalone 模式 — next-project 仅预览
- 拆解计划 — 已完成soul-admin 与 soul-api 已落地
- 近3天更新文档5 份)— 已合并至 运营与变更 第六部分
- 8、部署 历史修复说明(约 35 份)— 一次性修复记录,已无参考价值

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
{
"_comment": "小程序配置文件 - 支持管理多个小程序",
"apps": [
{
"id": "soul-party",
"name": "Soul派对",
"appid": "wxb8bbb2b10dec74aa",
"project_path": "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验/miniprogram",
"private_key_path": "",
"api_domain": "https://soul.quwanzhi.com",
"description": "一场SOUL的创业实验场",
"certification": {
"status": "pending",
"enterprise_name": "泉州市卡若网络技术有限公司",
"license_number": "",
"legal_persona_name": "",
"legal_persona_wechat": "",
"component_phone": "15880802661"
}
}
],
"certification_materials": {
"_comment": "企业认证通用材料(所有小程序共用)",
"enterprise_name": "泉州市卡若网络技术有限公司",
"license_number": "",
"license_media_id": "",
"legal_persona_name": "",
"legal_persona_wechat": "",
"legal_persona_idcard": "",
"component_phone": "15880802661",
"contact_email": "zhiqun@qq.com"
},
"third_party_platform": {
"_comment": "第三方平台配置(用于代认证)",
"component_appid": "",
"component_appsecret": "",
"component_verify_ticket": "",
"authorized": false
}
}

View File

@@ -0,0 +1,30 @@
# 微信小程序管理 - 环境变量配置
# 复制此文件为 .env 并填入实际值
# ==================== 方式一:直接使用 access_token ====================
# 如果你已经有 access_token直接填入即可适合快速测试
ACCESS_TOKEN=你的access_token
# ==================== 方式二:使用第三方平台凭证 ====================
# 如果你有第三方平台资质填入以下信息可自动刷新token
# 第三方平台 AppID
COMPONENT_APPID=你的第三方平台AppID
# 第三方平台密钥
COMPONENT_APPSECRET=你的第三方平台密钥
# 授权小程序 AppID要管理的小程序
AUTHORIZER_APPID=wxb8bbb2b10dec74aa
# 授权刷新令牌(从授权回调中获取)
AUTHORIZER_REFRESH_TOKEN=授权时获取的refresh_token
# ==================== 小程序项目路径 ====================
# 用于 CLI 工具操作
MINIPROGRAM_PATH=/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验/miniprogram
# ==================== 可选配置 ====================
# 隐私协议联系方式(用于快速配置)
CONTACT_EMAIL=zhiqun@qq.com
CONTACT_PHONE=15880802661

View File

@@ -0,0 +1,635 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
微信小程序管理API封装
支持:注册、配置、代码管理、审核、发布、数据分析
"""
import os
import json
import time
import httpx
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
from pathlib import Path
# 尝试加载dotenv可选依赖
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass # dotenv不是必需的
@dataclass
class MiniProgramInfo:
"""小程序基础信息"""
appid: str
nickname: str
head_image_url: str
signature: str
principal_name: str
realname_status: int # 1=已认证
@dataclass
class AuditStatus:
"""审核状态"""
auditid: int
status: int # 0=成功1=被拒2=审核中3=已撤回4=延后
reason: Optional[str] = None
screenshot: Optional[str] = None
@property
def status_text(self) -> str:
status_map = {
0: "✅ 审核成功",
1: "❌ 审核被拒",
2: "⏳ 审核中",
3: "↩️ 已撤回",
4: "⏸️ 审核延后"
}
return status_map.get(self.status, "未知状态")
class MiniProgramAPI:
"""微信小程序管理API"""
BASE_URL = "https://api.weixin.qq.com"
def __init__(
self,
component_appid: Optional[str] = None,
component_appsecret: Optional[str] = None,
authorizer_appid: Optional[str] = None,
access_token: Optional[str] = None
):
"""
初始化API
Args:
component_appid: 第三方平台AppID
component_appsecret: 第三方平台密钥
authorizer_appid: 授权小程序AppID
access_token: 直接使用的access_token如已获取
"""
self.component_appid = component_appid or os.getenv("COMPONENT_APPID")
self.component_appsecret = component_appsecret or os.getenv("COMPONENT_APPSECRET")
self.authorizer_appid = authorizer_appid or os.getenv("AUTHORIZER_APPID")
self._access_token = access_token or os.getenv("ACCESS_TOKEN")
self._token_expires_at = 0
self.client = httpx.Client(timeout=30.0)
@property
def access_token(self) -> str:
"""获取access_token如果过期则刷新"""
if self._access_token and time.time() < self._token_expires_at:
return self._access_token
# 如果没有配置刷新token的信息直接返回现有token
if not self.component_appid:
return self._access_token or ""
# TODO: 实现token刷新逻辑
return self._access_token or ""
def set_access_token(self, token: str, expires_in: int = 7200):
"""手动设置access_token"""
self._access_token = token
self._token_expires_at = time.time() + expires_in - 300 # 提前5分钟过期
def _request(
self,
method: str,
path: str,
params: Optional[Dict] = None,
json_data: Optional[Dict] = None,
**kwargs
) -> Dict[str, Any]:
"""发起API请求"""
url = f"{self.BASE_URL}{path}"
# 添加access_token
if params is None:
params = {}
if "access_token" not in params:
params["access_token"] = self.access_token
if method.upper() == "GET":
resp = self.client.get(url, params=params, **kwargs)
else:
resp = self.client.post(url, params=params, json=json_data, **kwargs)
# 解析响应
try:
result = resp.json()
except json.JSONDecodeError:
# 可能是二进制数据(如图片)
return {"_binary": resp.content}
# 检查错误
if result.get("errcode", 0) != 0:
raise APIError(result.get("errcode"), result.get("errmsg", "Unknown error"))
return result
# ==================== 基础信息 ====================
def get_basic_info(self) -> MiniProgramInfo:
"""获取小程序基础信息"""
result = self._request("POST", "/cgi-bin/account/getaccountbasicinfo")
return MiniProgramInfo(
appid=result.get("appid", ""),
nickname=result.get("nickname", ""),
head_image_url=result.get("head_image_url", ""),
signature=result.get("signature", ""),
principal_name=result.get("principal_name", ""),
realname_status=result.get("realname_status", 0)
)
def modify_signature(self, signature: str) -> bool:
"""修改简介4-120字"""
self._request("POST", "/cgi-bin/account/modifysignature", json_data={
"signature": signature
})
return True
# ==================== 域名配置 ====================
def get_domain(self) -> Dict[str, List[str]]:
"""获取服务器域名配置"""
result = self._request("POST", "/wxa/modify_domain", json_data={
"action": "get"
})
return {
"requestdomain": result.get("requestdomain", []),
"wsrequestdomain": result.get("wsrequestdomain", []),
"uploaddomain": result.get("uploaddomain", []),
"downloaddomain": result.get("downloaddomain", [])
}
def set_domain(
self,
requestdomain: Optional[List[str]] = None,
wsrequestdomain: Optional[List[str]] = None,
uploaddomain: Optional[List[str]] = None,
downloaddomain: Optional[List[str]] = None
) -> bool:
"""设置服务器域名"""
data = {"action": "set"}
if requestdomain:
data["requestdomain"] = requestdomain
if wsrequestdomain:
data["wsrequestdomain"] = wsrequestdomain
if uploaddomain:
data["uploaddomain"] = uploaddomain
if downloaddomain:
data["downloaddomain"] = downloaddomain
self._request("POST", "/wxa/modify_domain", json_data=data)
return True
def get_webview_domain(self) -> List[str]:
"""获取业务域名"""
result = self._request("POST", "/wxa/setwebviewdomain", json_data={
"action": "get"
})
return result.get("webviewdomain", [])
def set_webview_domain(self, webviewdomain: List[str]) -> bool:
"""设置业务域名"""
self._request("POST", "/wxa/setwebviewdomain", json_data={
"action": "set",
"webviewdomain": webviewdomain
})
return True
# ==================== 隐私协议 ====================
def get_privacy_setting(self, privacy_ver: int = 2) -> Dict[str, Any]:
"""获取隐私协议设置"""
result = self._request("POST", "/cgi-bin/component/getprivacysetting", json_data={
"privacy_ver": privacy_ver
})
return result
def set_privacy_setting(
self,
setting_list: List[Dict[str, str]],
contact_email: Optional[str] = None,
contact_phone: Optional[str] = None,
notice_method: str = "弹窗提示"
) -> bool:
"""
设置隐私协议
Args:
setting_list: 隐私配置列表,如 [{"privacy_key": "UserInfo", "privacy_text": "用于展示头像"}]
contact_email: 联系邮箱
contact_phone: 联系电话
notice_method: 告知方式
"""
data = {
"privacy_ver": 2,
"setting_list": setting_list
}
owner_setting = {"notice_method": notice_method}
if contact_email:
owner_setting["contact_email"] = contact_email
if contact_phone:
owner_setting["contact_phone"] = contact_phone
data["owner_setting"] = owner_setting
self._request("POST", "/cgi-bin/component/setprivacysetting", json_data=data)
return True
# ==================== 类目管理 ====================
def get_all_categories(self) -> List[Dict]:
"""获取可选类目列表"""
result = self._request("GET", "/cgi-bin/wxopen/getallcategories")
return result.get("categories_list", {}).get("categories", [])
def get_category(self) -> List[Dict]:
"""获取已设置的类目"""
result = self._request("GET", "/cgi-bin/wxopen/getcategory")
return result.get("categories", [])
def add_category(self, categories: List[Dict]) -> bool:
"""
添加类目
Args:
categories: 类目列表,如 [{"first": 1, "second": 2}]
"""
self._request("POST", "/cgi-bin/wxopen/addcategory", json_data={
"categories": categories
})
return True
def delete_category(self, first: int, second: int) -> bool:
"""删除类目"""
self._request("POST", "/cgi-bin/wxopen/deletecategory", json_data={
"first": first,
"second": second
})
return True
# ==================== 代码管理 ====================
def commit_code(
self,
template_id: int,
user_version: str,
user_desc: str,
ext_json: Optional[str] = None
) -> bool:
"""
上传代码
Args:
template_id: 代码模板ID
user_version: 版本号
user_desc: 版本描述
ext_json: 扩展配置JSON字符串
"""
data = {
"template_id": template_id,
"user_version": user_version,
"user_desc": user_desc
}
if ext_json:
data["ext_json"] = ext_json
self._request("POST", "/wxa/commit", json_data=data)
return True
def get_page(self) -> List[str]:
"""获取已上传代码的页面列表"""
result = self._request("GET", "/wxa/get_page")
return result.get("page_list", [])
def get_qrcode(self, path: Optional[str] = None) -> bytes:
"""
获取体验版二维码
Args:
path: 页面路径,如 "pages/index/index"
Returns:
二维码图片二进制数据
"""
params = {"access_token": self.access_token}
if path:
params["path"] = path
resp = self.client.get(f"{self.BASE_URL}/wxa/get_qrcode", params=params)
return resp.content
# ==================== 审核管理 ====================
def submit_audit(
self,
item_list: Optional[List[Dict]] = None,
version_desc: Optional[str] = None,
feedback_info: Optional[str] = None
) -> int:
"""
提交审核
Args:
item_list: 页面审核信息列表
version_desc: 版本说明
feedback_info: 反馈内容
Returns:
审核单ID
"""
data = {}
if item_list:
data["item_list"] = item_list
if version_desc:
data["version_desc"] = version_desc
if feedback_info:
data["feedback_info"] = feedback_info
result = self._request("POST", "/wxa/submit_audit", json_data=data)
return result.get("auditid", 0)
def get_audit_status(self, auditid: int) -> AuditStatus:
"""查询审核状态"""
result = self._request("POST", "/wxa/get_auditstatus", json_data={
"auditid": auditid
})
return AuditStatus(
auditid=auditid,
status=result.get("status", -1),
reason=result.get("reason"),
screenshot=result.get("screenshot")
)
def get_latest_audit_status(self) -> AuditStatus:
"""查询最新审核状态"""
result = self._request("GET", "/wxa/get_latest_auditstatus")
return AuditStatus(
auditid=result.get("auditid", 0),
status=result.get("status", -1),
reason=result.get("reason"),
screenshot=result.get("screenshot")
)
def undo_code_audit(self) -> bool:
"""撤回审核每天限1次"""
self._request("GET", "/wxa/undocodeaudit")
return True
# ==================== 发布管理 ====================
def release(self) -> bool:
"""发布已审核通过的版本"""
self._request("POST", "/wxa/release", json_data={})
return True
def revert_code_release(self) -> bool:
"""版本回退(只能回退到上一版本)"""
self._request("GET", "/wxa/revertcoderelease")
return True
def get_revert_history(self) -> List[Dict]:
"""获取可回退版本历史"""
result = self._request("GET", "/wxa/revertcoderelease", params={
"action": "get_history_version"
})
return result.get("version_list", [])
def gray_release(self, gray_percentage: int) -> bool:
"""
分阶段发布
Args:
gray_percentage: 灰度比例 1-100
"""
self._request("POST", "/wxa/grayrelease", json_data={
"gray_percentage": gray_percentage
})
return True
# ==================== 小程序码 ====================
def get_wxacode(
self,
path: str,
width: int = 430,
auto_color: bool = False,
line_color: Optional[Dict[str, int]] = None,
is_hyaline: bool = False
) -> bytes:
"""
获取小程序码有限制每个path最多10万个
Args:
path: 页面路径,如 "pages/index/index?id=123"
width: 宽度 280-1280
auto_color: 自动配置线条颜色
line_color: 线条颜色 {"r": 0, "g": 0, "b": 0}
is_hyaline: 是否透明背景
Returns:
二维码图片二进制数据
"""
data = {
"path": path,
"width": width,
"auto_color": auto_color,
"is_hyaline": is_hyaline
}
if line_color:
data["line_color"] = line_color
resp = self.client.post(
f"{self.BASE_URL}/wxa/getwxacode",
params={"access_token": self.access_token},
json=data
)
return resp.content
def get_wxacode_unlimit(
self,
scene: str,
page: Optional[str] = None,
width: int = 430,
auto_color: bool = False,
line_color: Optional[Dict[str, int]] = None,
is_hyaline: bool = False
) -> bytes:
"""
获取无限小程序码(推荐)
Args:
scene: 场景值最长32字符"user_id=123&from=share"
page: 页面路径,必须是已发布的页面
width: 宽度 280-1280
auto_color: 自动配置线条颜色
line_color: 线条颜色 {"r": 0, "g": 0, "b": 0}
is_hyaline: 是否透明背景
Returns:
二维码图片二进制数据
"""
data = {
"scene": scene,
"width": width,
"auto_color": auto_color,
"is_hyaline": is_hyaline
}
if page:
data["page"] = page
if line_color:
data["line_color"] = line_color
resp = self.client.post(
f"{self.BASE_URL}/wxa/getwxacodeunlimit",
params={"access_token": self.access_token},
json=data
)
return resp.content
def gen_short_link(
self,
page_url: str,
page_title: str,
is_permanent: bool = False
) -> str:
"""
生成小程序短链接
Args:
page_url: 页面路径,如 "pages/index/index?id=123"
page_title: 页面标题
is_permanent: 是否永久有效
Returns:
短链接
"""
result = self._request("POST", "/wxa/genwxashortlink", json_data={
"page_url": page_url,
"page_title": page_title,
"is_permanent": is_permanent
})
return result.get("link", "")
# ==================== 数据分析 ====================
def get_daily_visit_trend(self, begin_date: str, end_date: str) -> List[Dict]:
"""
获取每日访问趋势
Args:
begin_date: 开始日期 YYYYMMDD
end_date: 结束日期 YYYYMMDD
"""
result = self._request(
"POST",
"/datacube/getweanalysisappiddailyvisittrend",
json_data={"begin_date": begin_date, "end_date": end_date}
)
return result.get("list", [])
def get_user_portrait(self, begin_date: str, end_date: str) -> Dict:
"""
获取用户画像
Args:
begin_date: 开始日期 YYYYMMDD
end_date: 结束日期 YYYYMMDD
"""
result = self._request(
"POST",
"/datacube/getweanalysisappiduserportrait",
json_data={"begin_date": begin_date, "end_date": end_date}
)
return result
# ==================== API配额 ====================
def get_api_quota(self, cgi_path: str) -> Dict:
"""
查询接口调用额度
Args:
cgi_path: 接口路径,如 "/wxa/getwxacode"
"""
result = self._request("POST", "/cgi-bin/openapi/quota/get", json_data={
"cgi_path": cgi_path
})
return result.get("quota", {})
def clear_quota(self, appid: Optional[str] = None) -> bool:
"""重置接口调用次数每月限10次"""
self._request("POST", "/cgi-bin/clear_quota", json_data={
"appid": appid or self.authorizer_appid
})
return True
def close(self):
"""关闭连接"""
self.client.close()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
class APIError(Exception):
"""API错误"""
ERROR_CODES = {
-1: "系统繁忙",
40001: "access_token无效",
40002: "grant_type不正确",
40013: "appid不正确",
40029: "code无效",
40125: "appsecret不正确",
41002: "缺少appid参数",
41004: "缺少appsecret参数",
42001: "access_token过期",
42007: "refresh_token过期",
45009: "调用超过限制",
61039: "代码检测任务未完成,请稍后再试",
85006: "标签格式错误",
85007: "页面路径错误",
85009: "已有审核版本,请先撤回",
85010: "版本输入错误",
85011: "当前版本不能回退",
85012: "无效的版本",
85015: "该账号已有发布中的版本",
85019: "没有审核版本",
85020: "审核状态异常",
85064: "找不到模板",
85085: "该小程序不能被操作",
85086: "小程序没有绑定任何类目",
87013: "每天只能撤回1次审核",
89020: "该小程序尚未认证",
89248: "隐私协议内容不完整",
}
def __init__(self, code: int, message: str):
self.code = code
self.message = message
super().__init__(f"[{code}] {self.ERROR_CODES.get(code, message)}")
# 便捷函数
def create_api_from_env() -> MiniProgramAPI:
"""从环境变量创建API实例"""
return MiniProgramAPI()
if __name__ == "__main__":
# 测试
api = create_api_from_env()
print("API初始化成功")

View File

@@ -0,0 +1,725 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
小程序一键部署工具 v2.0
功能:
- 多小程序管理
- 一键部署上线
- 自动认证提交
- 认证状态检查
- 材料有效性验证
使用方法:
python mp_deploy.py list # 列出所有小程序
python mp_deploy.py add # 添加新小程序
python mp_deploy.py deploy <app_id> # 一键部署
python mp_deploy.py cert <app_id> # 提交认证
python mp_deploy.py cert-status <app_id> # 查询认证状态
python mp_deploy.py upload <app_id> # 上传代码
python mp_deploy.py release <app_id> # 发布上线
"""
import os
import sys
import json
import subprocess
import argparse
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, List, Any
from dataclasses import dataclass, asdict
# 配置文件路径
CONFIG_FILE = Path(__file__).parent / "apps_config.json"
@dataclass
class AppConfig:
"""小程序配置"""
id: str
name: str
appid: str
project_path: str
private_key_path: str = ""
api_domain: str = ""
description: str = ""
certification: Dict = None
def __post_init__(self):
if self.certification is None:
self.certification = {
"status": "unknown",
"enterprise_name": "",
"license_number": "",
"legal_persona_name": "",
"legal_persona_wechat": "",
"component_phone": ""
}
class ConfigManager:
"""配置管理器"""
def __init__(self, config_file: Path = CONFIG_FILE):
self.config_file = config_file
self.config = self._load_config()
def _load_config(self) -> Dict:
"""加载配置"""
if self.config_file.exists():
with open(self.config_file, 'r', encoding='utf-8') as f:
return json.load(f)
return {"apps": [], "certification_materials": {}, "third_party_platform": {}}
def _save_config(self):
"""保存配置"""
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(self.config, f, ensure_ascii=False, indent=2)
def get_apps(self) -> List[AppConfig]:
"""获取所有小程序"""
return [AppConfig(**app) for app in self.config.get("apps", [])]
def get_app(self, app_id: str) -> Optional[AppConfig]:
"""获取指定小程序"""
for app in self.config.get("apps", []):
if app["id"] == app_id or app["appid"] == app_id:
return AppConfig(**app)
return None
def add_app(self, app: AppConfig):
"""添加小程序"""
apps = self.config.get("apps", [])
# 检查是否已存在
for i, existing in enumerate(apps):
if existing["id"] == app.id:
apps[i] = asdict(app)
self.config["apps"] = apps
self._save_config()
return
apps.append(asdict(app))
self.config["apps"] = apps
self._save_config()
def update_app(self, app_id: str, updates: Dict):
"""更新小程序配置"""
apps = self.config.get("apps", [])
for i, app in enumerate(apps):
if app["id"] == app_id:
apps[i].update(updates)
self.config["apps"] = apps
self._save_config()
return True
return False
def get_cert_materials(self) -> Dict:
"""获取通用认证材料"""
return self.config.get("certification_materials", {})
def update_cert_materials(self, materials: Dict):
"""更新认证材料"""
self.config["certification_materials"] = materials
self._save_config()
class MiniProgramDeployer:
"""小程序部署器"""
# 微信开发者工具CLI路径
WX_CLI = "/Applications/wechatwebdevtools.app/Contents/MacOS/cli"
def __init__(self):
self.config = ConfigManager()
def _check_wx_cli(self) -> bool:
"""检查微信开发者工具是否安装"""
return os.path.exists(self.WX_CLI)
def _run_cli(self, *args, project_path: str = None) -> tuple:
"""运行CLI命令"""
cmd = [self.WX_CLI] + list(args)
if project_path:
cmd.extend(["--project", project_path])
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
return result.returncode == 0, result.stdout + result.stderr
except subprocess.TimeoutExpired:
return False, "命令执行超时"
except Exception as e:
return False, str(e)
def list_apps(self):
"""列出所有小程序"""
apps = self.config.get_apps()
if not apps:
print("\n📭 暂无配置的小程序")
print(" 运行 'python mp_deploy.py add' 添加小程序")
return
print("\n" + "=" * 60)
print(" 📱 小程序列表")
print("=" * 60)
for i, app in enumerate(apps, 1):
cert_status = app.certification.get("status", "unknown")
status_icon = {
"verified": "",
"pending": "",
"rejected": "",
"expired": "⚠️",
"unknown": ""
}.get(cert_status, "")
print(f"\n [{i}] {app.name}")
print(f" ID: {app.id}")
print(f" AppID: {app.appid}")
print(f" 认证: {status_icon} {cert_status}")
print(f" 路径: {app.project_path}")
print("\n" + "-" * 60)
print(" 使用方法:")
print(" python mp_deploy.py deploy <id> 一键部署")
print(" python mp_deploy.py cert <id> 提交认证")
print("=" * 60 + "\n")
def add_app(self):
"""交互式添加小程序"""
print("\n" + "=" * 50)
print(" 添加新小程序")
print("=" * 50 + "\n")
# 收集信息
app_id = input("小程序ID用于标识如 my-app: ").strip()
if not app_id:
print("❌ ID不能为空")
return
name = input("小程序名称: ").strip()
appid = input("AppID如 wx1234567890: ").strip()
project_path = input("项目路径: ").strip()
if not os.path.exists(project_path):
print(f"⚠️ 警告:路径不存在 {project_path}")
api_domain = input("API域名可选: ").strip()
description = input("描述(可选): ").strip()
# 认证信息
print("\n📋 认证信息(可稍后配置):")
enterprise_name = input("企业名称: ").strip()
app = AppConfig(
id=app_id,
name=name,
appid=appid,
project_path=project_path,
api_domain=api_domain,
description=description,
certification={
"status": "unknown",
"enterprise_name": enterprise_name,
"license_number": "",
"legal_persona_name": "",
"legal_persona_wechat": "",
"component_phone": "15880802661"
}
)
self.config.add_app(app)
print(f"\n✅ 小程序 [{name}] 添加成功!")
def deploy(self, app_id: str, skip_cert_check: bool = False):
"""一键部署流程"""
app = self.config.get_app(app_id)
if not app:
print(f"❌ 未找到小程序: {app_id}")
print(" 运行 'python mp_deploy.py list' 查看所有小程序")
return False
print("\n" + "=" * 60)
print(f" 🚀 一键部署: {app.name}")
print("=" * 60)
steps = [
("检查环境", self._step_check_env),
("检查认证状态", lambda a: self._step_check_cert(a, skip_cert_check)),
("编译项目", self._step_build),
("上传代码", self._step_upload),
("提交审核", self._step_submit_audit),
]
for step_name, step_func in steps:
print(f"\n📍 步骤: {step_name}")
print("-" * 40)
success = step_func(app)
if not success:
print(f"\n❌ 部署中断于: {step_name}")
return False
print("\n" + "=" * 60)
print(" 🎉 部署完成!")
print("=" * 60)
print(f"\n 下一步操作:")
print(f" 1. 等待审核通常1-3个工作日")
print(f" 2. 审核通过后运行: python mp_deploy.py release {app_id}")
print(f" 3. 查看状态: python mp_deploy.py status {app_id}")
return True
def _step_check_env(self, app: AppConfig) -> bool:
"""检查环境"""
# 检查微信开发者工具
if not self._check_wx_cli():
print("❌ 未找到微信开发者工具")
print(" 请安装: https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html")
return False
print("✅ 微信开发者工具已安装")
# 检查项目路径
if not os.path.exists(app.project_path):
print(f"❌ 项目路径不存在: {app.project_path}")
return False
print(f"✅ 项目路径存在")
# 检查project.config.json
config_file = os.path.join(app.project_path, "project.config.json")
if os.path.exists(config_file):
with open(config_file, 'r') as f:
config = json.load(f)
if config.get("appid") != app.appid:
print(f"⚠️ 警告: project.config.json中的AppID与配置不一致")
print(f" 配置: {app.appid}")
print(f" 文件: {config.get('appid')}")
print("✅ 环境检查通过")
return True
def _step_check_cert(self, app: AppConfig, skip: bool = False) -> bool:
"""检查认证状态"""
if skip:
print("⏭️ 跳过认证检查")
return True
cert_status = app.certification.get("status", "unknown")
if cert_status == "verified":
print("✅ 已完成微信认证")
return True
if cert_status == "pending":
print("⏳ 认证审核中")
print(" 可选择:")
print(" 1. 继续部署(未认证可上传,但无法发布)")
print(" 2. 等待认证完成")
choice = input("\n是否继续? (y/n): ").strip().lower()
return choice == 'y'
if cert_status == "expired":
print("⚠️ 认证已过期,需要重新认证")
print(" 运行: python mp_deploy.py cert " + app.id)
choice = input("\n是否继续部署? (y/n): ").strip().lower()
return choice == 'y'
# 未认证或未知状态
print("⚠️ 未完成微信认证")
print(" 未认证的小程序可以上传代码,但无法发布上线")
print(" 运行: python mp_deploy.py cert " + app.id + " 提交认证")
choice = input("\n是否继续? (y/n): ").strip().lower()
return choice == 'y'
def _step_build(self, app: AppConfig) -> bool:
"""编译项目"""
print("📦 编译项目...")
# 使用CLI编译
success, output = self._run_cli("build-npm", project_path=app.project_path)
if not success:
# build-npm可能失败如果没有npm依赖不算错误
print(" 编译完成无npm依赖或编译失败继续...")
else:
print("✅ 编译成功")
return True
def _step_upload(self, app: AppConfig) -> bool:
"""上传代码"""
# 获取版本号
version = datetime.now().strftime("%Y.%m.%d.%H%M")
desc = f"自动部署 - {datetime.now().strftime('%Y-%m-%d %H:%M')}"
print(f"📤 上传代码...")
print(f" 版本: {version}")
print(f" 描述: {desc}")
success, output = self._run_cli(
"upload",
"--version", version,
"--desc", desc,
project_path=app.project_path
)
if not success:
print(f"❌ 上传失败")
print(f" {output}")
# 常见错误处理
if "login" in output.lower():
print("\n💡 提示: 请在微信开发者工具中登录后重试")
return False
print("✅ 上传成功")
return True
def _step_submit_audit(self, app: AppConfig) -> bool:
"""提交审核"""
print("📝 提交审核...")
# 使用CLI提交审核
success, output = self._run_cli(
"submit-audit",
project_path=app.project_path
)
if not success:
if "未认证" in output or "认证" in output:
print("⚠️ 提交审核失败:未完成微信认证")
print(" 代码已上传,但需要完成认证后才能提交审核")
print(f" 运行: python mp_deploy.py cert {app.id}")
return True # 不算失败,只是需要认证
print(f"❌ 提交审核失败")
print(f" {output}")
return False
print("✅ 审核已提交")
return True
def submit_certification(self, app_id: str):
"""提交企业认证"""
app = self.config.get_app(app_id)
if not app:
print(f"❌ 未找到小程序: {app_id}")
return
print("\n" + "=" * 60)
print(f" 📋 提交认证: {app.name}")
print("=" * 60)
# 获取通用认证材料
materials = self.config.get_cert_materials()
cert = app.certification
# 合并材料(小程序配置优先)
enterprise_name = cert.get("enterprise_name") or materials.get("enterprise_name", "")
print(f"\n📌 认证信息:")
print(f" 小程序: {app.name} ({app.appid})")
print(f" 企业名称: {enterprise_name}")
# 检查必要材料
missing = []
if not enterprise_name:
missing.append("企业名称")
if not materials.get("license_number"):
missing.append("营业执照号")
if not materials.get("legal_persona_name"):
missing.append("法人姓名")
if missing:
print(f"\n⚠️ 缺少认证材料:")
for m in missing:
print(f" - {m}")
print(f"\n请先完善认证材料:")
print(f" 编辑: {self.config.config_file}")
print(f" 或运行: python mp_deploy.py cert-config")
return
print("\n" + "-" * 40)
print("📋 认证方式说明:")
print("-" * 40)
print("""
【方式一】微信后台手动认证(推荐)
1. 登录小程序后台: https://mp.weixin.qq.com/
2. 设置 → 基本设置 → 微信认证
3. 选择"企业"类型
4. 填写企业信息、上传营业执照
5. 法人微信扫码验证
6. 支付认证费用300元/年)
7. 等待审核1-5个工作日
【方式二】通过第三方平台代认证(需开发)
如果你有第三方平台资质可以通过API代认证
1. 配置第三方平台凭证
2. 获取授权
3. 调用认证API
API接口: POST /wxa/sec/wxaauth
""")
print("\n" + "-" * 40)
print("📝 认证材料清单:")
print("-" * 40)
print("""
必需材料:
☐ 企业营业执照(扫描件或照片)
☐ 法人身份证(正反面)
☐ 法人微信号(用于扫码验证)
☐ 联系人手机号
☐ 认证费用 300元
认证有效期: 1年
到期后需重新认证(年审)
""")
# 更新状态为待认证
self.config.update_app(app_id, {
"certification": {
**cert,
"status": "pending",
"submit_time": datetime.now().isoformat()
}
})
print("\n✅ 已标记为待认证状态")
print(" 完成认证后运行: python mp_deploy.py cert-done " + app_id)
def check_cert_status(self, app_id: str):
"""检查认证状态"""
app = self.config.get_app(app_id)
if not app:
print(f"❌ 未找到小程序: {app_id}")
return
print("\n" + "=" * 60)
print(f" 🔍 认证状态: {app.name}")
print("=" * 60)
cert = app.certification
status = cert.get("status", "unknown")
status_info = {
"verified": ("✅ 已认证", "认证有效"),
"pending": ("⏳ 审核中", "请等待审核结果"),
"rejected": ("❌ 被拒绝", "请查看拒绝原因并重新提交"),
"expired": ("⚠️ 已过期", "需要重新认证(年审)"),
"unknown": ("❓ 未知", "请在微信后台确认状态")
}
icon, desc = status_info.get(status, ("", "未知状态"))
print(f"\n📌 当前状态: {icon}")
print(f" 说明: {desc}")
print(f" 企业: {cert.get('enterprise_name', '未填写')}")
if cert.get("submit_time"):
print(f" 提交时间: {cert.get('submit_time')}")
if cert.get("verify_time"):
print(f" 认证时间: {cert.get('verify_time')}")
if cert.get("expire_time"):
print(f" 到期时间: {cert.get('expire_time')}")
# 提示下一步操作
print("\n" + "-" * 40)
if status == "unknown" or status == "rejected":
print("👉 下一步: python mp_deploy.py cert " + app_id)
elif status == "pending":
print("👉 等待审核通常1-5个工作日")
print(" 审核通过后运行: python mp_deploy.py cert-done " + app_id)
elif status == "verified":
print("👉 可以发布小程序: python mp_deploy.py deploy " + app_id)
elif status == "expired":
print("👉 需要重新认证: python mp_deploy.py cert " + app_id)
def mark_cert_done(self, app_id: str):
"""标记认证完成"""
app = self.config.get_app(app_id)
if not app:
print(f"❌ 未找到小程序: {app_id}")
return
cert = app.certification
self.config.update_app(app_id, {
"certification": {
**cert,
"status": "verified",
"verify_time": datetime.now().isoformat(),
"expire_time": datetime.now().replace(year=datetime.now().year + 1).isoformat()
}
})
print(f"✅ 已标记 [{app.name}] 认证完成")
print(f" 有效期至: {datetime.now().year + 1}")
def release(self, app_id: str):
"""发布上线"""
app = self.config.get_app(app_id)
if not app:
print(f"❌ 未找到小程序: {app_id}")
return
print("\n" + "=" * 60)
print(f" 🎉 发布上线: {app.name}")
print("=" * 60)
# 检查认证状态
if app.certification.get("status") != "verified":
print("\n⚠️ 警告: 小程序未完成认证")
print(" 未认证的小程序无法发布上线")
choice = input("\n是否继续尝试? (y/n): ").strip().lower()
if choice != 'y':
return
print("\n📦 正在发布...")
# 尝试使用CLI发布
success, output = self._run_cli("release", project_path=app.project_path)
if success:
print("\n🎉 发布成功!小程序已上线")
else:
print(f"\n发布结果: {output}")
if "认证" in output:
print("\n💡 提示: 请先完成微信认证")
print(f" 运行: python mp_deploy.py cert {app_id}")
else:
print("\n💡 提示: 请在微信后台手动发布")
print(" 1. 登录 https://mp.weixin.qq.com/")
print(" 2. 版本管理 → 审核版本 → 发布")
def quick_upload(self, app_id: str, version: str = None, desc: str = None):
"""快速上传代码"""
app = self.config.get_app(app_id)
if not app:
print(f"❌ 未找到小程序: {app_id}")
return
if not version:
version = datetime.now().strftime("%Y.%m.%d.%H%M")
if not desc:
desc = f"快速上传 - {datetime.now().strftime('%Y-%m-%d %H:%M')}"
print(f"\n📤 上传代码: {app.name}")
print(f" 版本: {version}")
print(f" 描述: {desc}")
success, output = self._run_cli(
"upload",
"--version", version,
"--desc", desc,
project_path=app.project_path
)
if success:
print("✅ 上传成功")
else:
print(f"❌ 上传失败: {output}")
def print_header(title: str):
print("\n" + "=" * 50)
print(f" {title}")
print("=" * 50)
def main():
parser = argparse.ArgumentParser(
description="小程序一键部署工具 v2.0",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python mp_deploy.py list 列出所有小程序
python mp_deploy.py add 添加新小程序
python mp_deploy.py deploy soul-party 一键部署
python mp_deploy.py cert soul-party 提交认证
python mp_deploy.py cert-status soul-party 查询认证状态
python mp_deploy.py cert-done soul-party 标记认证完成
python mp_deploy.py upload soul-party 仅上传代码
python mp_deploy.py release soul-party 发布上线
部署流程:
1. add 添加小程序配置
2. cert 提交企业认证(首次)
3. cert-done 认证通过后标记
4. deploy 一键部署(编译+上传+提审)
5. release 审核通过后发布
"""
)
subparsers = parser.add_subparsers(dest="command", help="子命令")
# list
subparsers.add_parser("list", help="列出所有小程序")
# add
subparsers.add_parser("add", help="添加新小程序")
# deploy
deploy_parser = subparsers.add_parser("deploy", help="一键部署")
deploy_parser.add_argument("app_id", help="小程序ID")
deploy_parser.add_argument("--skip-cert", action="store_true", help="跳过认证检查")
# cert
cert_parser = subparsers.add_parser("cert", help="提交认证")
cert_parser.add_argument("app_id", help="小程序ID")
# cert-status
cert_status_parser = subparsers.add_parser("cert-status", help="查询认证状态")
cert_status_parser.add_argument("app_id", help="小程序ID")
# cert-done
cert_done_parser = subparsers.add_parser("cert-done", help="标记认证完成")
cert_done_parser.add_argument("app_id", help="小程序ID")
# upload
upload_parser = subparsers.add_parser("upload", help="上传代码")
upload_parser.add_argument("app_id", help="小程序ID")
upload_parser.add_argument("-v", "--version", help="版本号")
upload_parser.add_argument("-d", "--desc", help="版本描述")
# release
release_parser = subparsers.add_parser("release", help="发布上线")
release_parser.add_argument("app_id", help="小程序ID")
args = parser.parse_args()
if not args.command:
parser.print_help()
return
deployer = MiniProgramDeployer()
commands = {
"list": lambda: deployer.list_apps(),
"add": lambda: deployer.add_app(),
"deploy": lambda: deployer.deploy(args.app_id, args.skip_cert if hasattr(args, 'skip_cert') else False),
"cert": lambda: deployer.submit_certification(args.app_id),
"cert-status": lambda: deployer.check_cert_status(args.app_id),
"cert-done": lambda: deployer.mark_cert_done(args.app_id),
"upload": lambda: deployer.quick_upload(args.app_id, getattr(args, 'version', None), getattr(args, 'desc', None)),
"release": lambda: deployer.release(args.app_id),
}
cmd_func = commands.get(args.command)
if cmd_func:
cmd_func()
else:
parser.print_help()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,555 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
小程序全能管理工具 v3.0
整合能力:
- 微信开发者工具CLI
- miniprogram-ci (npm官方工具)
- 微信开放平台API
- 多小程序管理
- 自动化部署+提审
- 汇总报告生成
使用方法:
python mp_full.py report # 生成汇总报告
python mp_full.py check <app_id> # 检查项目问题
python mp_full.py auto <app_id> # 全自动部署(上传+提审)
python mp_full.py batch-report # 批量生成所有小程序报告
"""
import os
import sys
import json
import subprocess
import argparse
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, List, Any
from dataclasses import dataclass, asdict, field
# 配置文件路径
SCRIPT_DIR = Path(__file__).parent
CONFIG_FILE = SCRIPT_DIR / "apps_config.json"
REPORT_DIR = SCRIPT_DIR / "reports"
@dataclass
class CheckResult:
"""检查结果"""
name: str
status: str # ok, warning, error
message: str
fix_hint: str = ""
@dataclass
class AppReport:
"""小程序报告"""
app_id: str
app_name: str
appid: str
check_time: str
checks: List[CheckResult] = field(default_factory=list)
summary: Dict = field(default_factory=dict)
@property
def has_errors(self) -> bool:
return any(c.status == "error" for c in self.checks)
@property
def has_warnings(self) -> bool:
return any(c.status == "warning" for c in self.checks)
class MiniProgramManager:
"""小程序全能管理器"""
# 工具路径
WX_CLI = "/Applications/wechatwebdevtools.app/Contents/MacOS/cli"
def __init__(self):
self.config = self._load_config()
REPORT_DIR.mkdir(exist_ok=True)
def _load_config(self) -> Dict:
if CONFIG_FILE.exists():
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
return {"apps": []}
def _save_config(self):
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(self.config, f, ensure_ascii=False, indent=2)
def get_app(self, app_id: str) -> Optional[Dict]:
for app in self.config.get("apps", []):
if app["id"] == app_id or app["appid"] == app_id:
return app
return None
def _run_cmd(self, cmd: List[str], timeout: int = 120) -> tuple:
"""运行命令"""
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return result.returncode == 0, result.stdout + result.stderr
except subprocess.TimeoutExpired:
return False, "命令执行超时"
except Exception as e:
return False, str(e)
def _check_tool(self, tool: str) -> bool:
"""检查工具是否可用"""
success, _ = self._run_cmd(["which", tool], timeout=5)
return success
# ==================== 检查功能 ====================
def check_project(self, app_id: str) -> AppReport:
"""检查项目问题"""
app = self.get_app(app_id)
if not app:
print(f"❌ 未找到小程序: {app_id}")
return None
report = AppReport(
app_id=app["id"],
app_name=app["name"],
appid=app["appid"],
check_time=datetime.now().isoformat()
)
project_path = app["project_path"]
# 1. 检查项目路径
if os.path.exists(project_path):
report.checks.append(CheckResult("项目路径", "ok", f"路径存在: {project_path}"))
else:
report.checks.append(CheckResult("项目路径", "error", f"路径不存在: {project_path}", "请检查项目路径配置"))
return report # 路径不存在,无法继续检查
# 2. 检查project.config.json
config_file = os.path.join(project_path, "project.config.json")
if os.path.exists(config_file):
with open(config_file, 'r') as f:
config = json.load(f)
if config.get("appid") == app["appid"]:
report.checks.append(CheckResult("AppID配置", "ok", f"AppID正确: {app['appid']}"))
else:
report.checks.append(CheckResult("AppID配置", "error",
f"AppID不匹配: 配置={app['appid']}, 文件={config.get('appid')}",
"请修改project.config.json中的appid"))
else:
report.checks.append(CheckResult("项目配置", "error", "project.config.json不存在", "请确认这是有效的小程序项目"))
# 3. 检查app.js
app_js = os.path.join(project_path, "app.js")
if os.path.exists(app_js):
with open(app_js, 'r') as f:
content = f.read()
# 检查API域名
if "baseUrl" in content or "apiBase" in content:
if "https://" in content:
report.checks.append(CheckResult("API域名", "ok", "已配置HTTPS域名"))
elif "http://localhost" in content:
report.checks.append(CheckResult("API域名", "warning", "使用本地开发地址", "发布前请更换为HTTPS域名"))
else:
report.checks.append(CheckResult("API域名", "warning", "未检测到HTTPS域名"))
report.checks.append(CheckResult("入口文件", "ok", "app.js存在"))
else:
report.checks.append(CheckResult("入口文件", "error", "app.js不存在"))
# 4. 检查app.json
app_json = os.path.join(project_path, "app.json")
if os.path.exists(app_json):
with open(app_json, 'r') as f:
app_config = json.load(f)
pages = app_config.get("pages", [])
if pages:
report.checks.append(CheckResult("页面配置", "ok", f"{len(pages)}个页面"))
else:
report.checks.append(CheckResult("页面配置", "error", "没有配置页面"))
# 检查隐私配置
if app_config.get("__usePrivacyCheck__"):
report.checks.append(CheckResult("隐私配置", "ok", "已启用隐私检查"))
else:
report.checks.append(CheckResult("隐私配置", "warning", "未启用隐私检查", "建议添加 __usePrivacyCheck__: true"))
else:
report.checks.append(CheckResult("应用配置", "error", "app.json不存在"))
# 5. 检查认证状态
cert_status = app.get("certification", {}).get("status", "unknown")
if cert_status == "verified":
report.checks.append(CheckResult("企业认证", "ok", "已完成认证"))
elif cert_status == "pending":
report.checks.append(CheckResult("企业认证", "warning", "认证审核中", "等待审核结果"))
elif cert_status == "expired":
report.checks.append(CheckResult("企业认证", "error", "认证已过期", "请尽快完成年审"))
else:
report.checks.append(CheckResult("企业认证", "warning", "未认证", "无法发布上线,请先完成认证"))
# 6. 检查开发工具
if os.path.exists(self.WX_CLI):
report.checks.append(CheckResult("开发者工具", "ok", "微信开发者工具已安装"))
else:
report.checks.append(CheckResult("开发者工具", "error", "微信开发者工具未安装"))
# 7. 检查miniprogram-ci
if self._check_tool("miniprogram-ci"):
report.checks.append(CheckResult("miniprogram-ci", "ok", "npm工具已安装"))
else:
report.checks.append(CheckResult("miniprogram-ci", "warning", "miniprogram-ci未安装", "运行: npm install -g miniprogram-ci"))
# 8. 检查私钥
if app.get("private_key_path") and os.path.exists(app["private_key_path"]):
report.checks.append(CheckResult("上传密钥", "ok", "私钥文件存在"))
else:
report.checks.append(CheckResult("上传密钥", "warning", "未配置私钥", "在小程序后台下载代码上传密钥"))
# 生成汇总
ok_count = sum(1 for c in report.checks if c.status == "ok")
warn_count = sum(1 for c in report.checks if c.status == "warning")
error_count = sum(1 for c in report.checks if c.status == "error")
report.summary = {
"total": len(report.checks),
"ok": ok_count,
"warning": warn_count,
"error": error_count,
"can_deploy": error_count == 0,
"can_release": cert_status == "verified" and error_count == 0
}
return report
def print_report(self, report: AppReport):
"""打印报告"""
print("\n" + "=" * 70)
print(f" 📊 项目检查报告: {report.app_name}")
print("=" * 70)
print(f" AppID: {report.appid}")
print(f" 检查时间: {report.check_time}")
print("-" * 70)
status_icons = {"ok": "", "warning": "⚠️", "error": ""}
for check in report.checks:
icon = status_icons.get(check.status, "")
print(f" {icon} {check.name}: {check.message}")
if check.fix_hint:
print(f" 💡 {check.fix_hint}")
print("-" * 70)
s = report.summary
print(f" 📈 汇总: 通过 {s['ok']} / 警告 {s['warning']} / 错误 {s['error']}")
if s['can_release']:
print(" 🎉 状态: 可以发布上线")
elif s['can_deploy']:
print(" 📦 状态: 可以上传代码,但无法发布(需完成认证)")
else:
print(" 🚫 状态: 存在错误,请先修复")
print("=" * 70 + "\n")
def save_report(self, report: AppReport):
"""保存报告到文件"""
filename = f"report_{report.app_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
filepath = REPORT_DIR / filename
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(asdict(report), f, ensure_ascii=False, indent=2)
return filepath
# ==================== 自动化部署 ====================
def auto_deploy(self, app_id: str, version: str = None, desc: str = None, submit_audit: bool = True) -> bool:
"""全自动部署:编译 → 上传 → 提审"""
app = self.get_app(app_id)
if not app:
print(f"❌ 未找到小程序: {app_id}")
return False
print("\n" + "=" * 70)
print(f" 🚀 全自动部署: {app['name']}")
print("=" * 70)
# 1. 先检查项目
print("\n📋 步骤1: 检查项目...")
report = self.check_project(app_id)
if report.has_errors:
print("❌ 项目存在错误,无法部署")
self.print_report(report)
return False
print("✅ 项目检查通过")
# 2. 准备版本信息
if not version:
version = datetime.now().strftime("%Y.%m.%d.%H%M")
if not desc:
desc = f"自动部署 - {datetime.now().strftime('%Y-%m-%d %H:%M')}"
print(f"\n📦 步骤2: 上传代码...")
print(f" 版本: {version}")
print(f" 描述: {desc}")
# 3. 上传代码
success = self._upload_code(app, version, desc)
if not success:
print("❌ 代码上传失败")
return False
print("✅ 代码上传成功")
# 4. 提交审核
if submit_audit:
print(f"\n📝 步骤3: 提交审核...")
cert_status = app.get("certification", {}).get("status", "unknown")
if cert_status != "verified":
print(f"⚠️ 认证状态: {cert_status}")
print(" 未认证的小程序无法提交审核")
print(" 代码已上传到开发版,请在微信后台手动提交")
print("\n" + "-" * 40)
print("👉 下一步操作:")
print(" 1. 完成企业认证")
print(" 2. 在微信后台提交审核")
print(" 3. 审核通过后发布上线")
else:
# 尝试通过API提交审核
audit_success = self._submit_audit_via_api(app)
if audit_success:
print("✅ 审核已提交")
else:
print("⚠️ 自动提审失败,请在微信后台手动提交")
print(" 登录: https://mp.weixin.qq.com/")
print(" 版本管理 → 开发版本 → 提交审核")
# 5. 生成报告
print(f"\n📊 步骤4: 生成报告...")
report_file = self.save_report(report)
print(f"✅ 报告已保存: {report_file}")
print("\n" + "=" * 70)
print(" 🎉 部署完成!")
print("=" * 70)
return True
def _upload_code(self, app: Dict, version: str, desc: str) -> bool:
"""上传代码优先使用CLI"""
project_path = app["project_path"]
# 方法1使用微信开发者工具CLI
if os.path.exists(self.WX_CLI):
cmd = [
self.WX_CLI, "upload",
"--project", project_path,
"--version", version,
"--desc", desc
]
success, output = self._run_cmd(cmd, timeout=120)
if success:
return True
print(f" CLI上传失败: {output[:200]}")
# 方法2使用miniprogram-ci
if self._check_tool("miniprogram-ci") and app.get("private_key_path"):
cmd = [
"miniprogram-ci", "upload",
"--pp", project_path,
"--pkp", app["private_key_path"],
"--appid", app["appid"],
"--uv", version,
"-r", "1",
"--desc", desc
]
success, output = self._run_cmd(cmd, timeout=120)
if success:
return True
print(f" miniprogram-ci上传失败: {output[:200]}")
return False
def _submit_audit_via_api(self, app: Dict) -> bool:
"""通过API提交审核需要access_token"""
# 这里需要access_token才能调用API
# 目前返回False提示用户手动提交
return False
# ==================== 汇总报告 ====================
def generate_summary_report(self):
"""生成所有小程序的汇总报告"""
apps = self.config.get("apps", [])
if not apps:
print("📭 暂无配置的小程序")
return
print("\n" + "=" * 80)
print(" 📊 小程序管理汇总报告")
print(f" 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("=" * 80)
all_reports = []
for app in apps:
report = self.check_project(app["id"])
if report:
all_reports.append(report)
# 打印汇总表格
print("\n" + "" * 78 + "")
print(f"{'小程序名称':<20}{'AppID':<25}{'状态':<10}{'可发布':<8}")
print("" + "" * 78 + "")
for report in all_reports:
status = "✅ 正常" if not report.has_errors else "❌ 错误"
can_release = "" if report.summary.get("can_release") else ""
print(f"{report.app_name:<20}{report.appid:<25}{status:<10}{can_release:<8}")
print("" + "" * 78 + "")
# 统计
total = len(all_reports)
ok_count = sum(1 for r in all_reports if not r.has_errors and not r.has_warnings)
warn_count = sum(1 for r in all_reports if r.has_warnings and not r.has_errors)
error_count = sum(1 for r in all_reports if r.has_errors)
can_release = sum(1 for r in all_reports if r.summary.get("can_release"))
print(f"\n📈 统计:")
print(f" 总计: {total} 个小程序")
print(f" 正常: {ok_count} | 警告: {warn_count} | 错误: {error_count}")
print(f" 可发布: {can_release}")
# 问题清单
issues = []
for report in all_reports:
for check in report.checks:
if check.status == "error":
issues.append((report.app_name, check.name, check.message, check.fix_hint))
if issues:
print(f"\n⚠️ 问题清单 ({len(issues)} 个):")
print("-" * 60)
for app_name, check_name, message, hint in issues:
print(f" [{app_name}] {check_name}: {message}")
if hint:
print(f" 💡 {hint}")
else:
print(f"\n✅ 所有小程序状态正常")
# 待办事项
print(f"\n📋 待办事项:")
for report in all_reports:
cert_status = "unknown"
for check in report.checks:
if check.name == "企业认证":
if "审核中" in check.message:
cert_status = "pending"
elif "已完成" in check.message:
cert_status = "verified"
elif "未认证" in check.message:
cert_status = "unknown"
break
if cert_status == "pending":
print(f"{report.app_name}: 等待认证审核结果")
elif cert_status == "unknown":
print(f" 📝 {report.app_name}: 需要完成企业认证")
print("\n" + "=" * 80)
# 保存汇总报告
summary_file = REPORT_DIR / f"summary_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
summary_data = {
"generated_at": datetime.now().isoformat(),
"total_apps": total,
"summary": {
"ok": ok_count,
"warning": warn_count,
"error": error_count,
"can_release": can_release
},
"apps": [asdict(r) for r in all_reports]
}
with open(summary_file, 'w', encoding='utf-8') as f:
json.dump(summary_data, f, ensure_ascii=False, indent=2)
print(f"📁 报告已保存: {summary_file}\n")
def main():
parser = argparse.ArgumentParser(
description="小程序全能管理工具 v3.0",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python mp_full.py report 生成汇总报告
python mp_full.py check soul-party 检查项目问题
python mp_full.py auto soul-party 全自动部署(上传+提审)
python mp_full.py auto soul-party -v 1.0.13 -d "修复问题"
流程说明:
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 检查 │ → │ 编译 │ → │ 上传 │ → │ 提审 │ → │ 发布 │
│ check │ │ build │ │ upload │ │ audit │ │ release │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
工具整合:
• 微信开发者工具CLI - 本地编译上传
• miniprogram-ci - npm官方CI工具
• 开放平台API - 审核/发布/认证
"""
)
subparsers = parser.add_subparsers(dest="command", help="子命令")
# report
subparsers.add_parser("report", help="生成汇总报告")
# check
check_parser = subparsers.add_parser("check", help="检查项目问题")
check_parser.add_argument("app_id", help="小程序ID")
# auto
auto_parser = subparsers.add_parser("auto", help="全自动部署")
auto_parser.add_argument("app_id", help="小程序ID")
auto_parser.add_argument("-v", "--version", help="版本号")
auto_parser.add_argument("-d", "--desc", help="版本描述")
auto_parser.add_argument("--no-audit", action="store_true", help="不提交审核")
args = parser.parse_args()
if not args.command:
parser.print_help()
return
manager = MiniProgramManager()
if args.command == "report":
manager.generate_summary_report()
elif args.command == "check":
report = manager.check_project(args.app_id)
if report:
manager.print_report(report)
manager.save_report(report)
elif args.command == "auto":
manager.auto_deploy(
args.app_id,
version=args.version,
desc=args.desc,
submit_audit=not args.no_audit
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,558 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
微信小程序管理命令行工具
使用方法:
python mp_manager.py status # 查看小程序状态
python mp_manager.py audit # 查看审核状态
python mp_manager.py release # 发布上线
python mp_manager.py qrcode # 生成小程序码
python mp_manager.py domain # 查看/配置域名
python mp_manager.py privacy # 配置隐私协议
python mp_manager.py data # 查看数据分析
"""
import os
import sys
import argparse
from datetime import datetime, timedelta
from pathlib import Path
# 添加当前目录到路径
sys.path.insert(0, str(Path(__file__).parent))
from mp_api import MiniProgramAPI, APIError, create_api_from_env
def print_header(title: str):
"""打印标题"""
print("\n" + "=" * 50)
print(f" {title}")
print("=" * 50)
def print_success(message: str):
"""打印成功信息"""
print(f"{message}")
def print_error(message: str):
"""打印错误信息"""
print(f"{message}")
def print_info(message: str):
"""打印信息"""
print(f" {message}")
def cmd_status(api: MiniProgramAPI, args):
"""查看小程序状态"""
print_header("小程序基础信息")
try:
info = api.get_basic_info()
print(f"\n📱 AppID: {info.appid}")
print(f"📝 名称: {info.nickname}")
print(f"📄 简介: {info.signature}")
print(f"🏢 主体: {info.principal_name}")
print(f"✓ 认证状态: {'已认证' if info.realname_status == 1 else '未认证'}")
if info.head_image_url:
print(f"🖼️ 头像: {info.head_image_url}")
# 获取类目
print("\n📂 已设置类目:")
categories = api.get_category()
if categories:
for cat in categories:
print(f" - {cat.get('first_class', '')} > {cat.get('second_class', '')}")
else:
print(" (未设置类目)")
except APIError as e:
print_error(f"获取信息失败: {e}")
def cmd_audit(api: MiniProgramAPI, args):
"""查看审核状态"""
print_header("审核状态")
try:
status = api.get_latest_audit_status()
print(f"\n🔢 审核单ID: {status.auditid}")
print(f"📊 状态: {status.status_text}")
if status.reason:
print(f"\n❗ 拒绝原因:")
print(f" {status.reason}")
if status.screenshot:
print(f"\n📸 问题截图: {status.screenshot}")
if status.status == 0:
print("\n👉 下一步: 运行 'python mp_manager.py release' 发布上线")
elif status.status == 1:
print("\n👉 请根据拒绝原因修改后重新提交审核")
elif status.status == 2:
print("\n👉 审核中请耐心等待通常1-3个工作日")
print(" 可运行 'python mp_manager.py audit' 再次查询")
except APIError as e:
print_error(f"获取审核状态失败: {e}")
def cmd_submit(api: MiniProgramAPI, args):
"""提交审核"""
print_header("提交审核")
version_desc = args.desc or input("请输入版本说明: ").strip()
if not version_desc:
print_error("版本说明不能为空")
return
try:
# 获取页面列表
pages = api.get_page()
if not pages:
print_error("未找到页面,请先上传代码")
return
print(f"\n📄 检测到 {len(pages)} 个页面:")
for p in pages[:5]:
print(f" - {p}")
if len(pages) > 5:
print(f" ... 还有 {len(pages) - 5}")
# 确认提交
confirm = input("\n确认提交审核? (y/n): ").strip().lower()
if confirm != 'y':
print_info("已取消")
return
auditid = api.submit_audit(version_desc=version_desc)
print_success(f"审核已提交审核单ID: {auditid}")
print("\n👉 运行 'python mp_manager.py audit' 查询审核状态")
except APIError as e:
print_error(f"提交审核失败: {e}")
def cmd_release(api: MiniProgramAPI, args):
"""发布上线"""
print_header("发布上线")
try:
# 先检查审核状态
status = api.get_latest_audit_status()
if status.status != 0:
print_error(f"当前审核状态: {status.status_text}")
print_info("只有审核通过的版本才能发布")
return
print(f"📊 审核状态: {status.status_text}")
# 确认发布
confirm = input("\n确认发布上线? (y/n): ").strip().lower()
if confirm != 'y':
print_info("已取消")
return
api.release()
print_success("🎉 发布成功!小程序已上线")
except APIError as e:
print_error(f"发布失败: {e}")
def cmd_revert(api: MiniProgramAPI, args):
"""版本回退"""
print_header("版本回退")
try:
# 获取可回退版本
history = api.get_revert_history()
if not history:
print_info("没有可回退的版本")
return
print("\n📜 可回退版本:")
for v in history:
print(f" - {v.get('user_version', '?')}: {v.get('user_desc', '')}")
# 确认回退
confirm = input("\n确认回退到上一版本? (y/n): ").strip().lower()
if confirm != 'y':
print_info("已取消")
return
api.revert_code_release()
print_success("版本回退成功")
except APIError as e:
print_error(f"版本回退失败: {e}")
def cmd_qrcode(api: MiniProgramAPI, args):
"""生成小程序码"""
print_header("生成小程序码")
# 场景选择
print("\n选择类型:")
print(" 1. 体验版二维码")
print(" 2. 小程序码有限制每个path最多10万个")
print(" 3. 无限小程序码(推荐)")
choice = args.type or input("\n请选择 (1/2/3): ").strip()
output_file = args.output or f"qrcode_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
try:
if choice == "1":
# 体验版二维码
path = args.path or input("页面路径 (默认首页): ").strip() or None
data = api.get_qrcode(path)
elif choice == "2":
# 小程序码
path = args.path or input("页面路径: ").strip()
if not path:
print_error("页面路径不能为空")
return
data = api.get_wxacode(path)
elif choice == "3":
# 无限小程序码
scene = args.scene or input("场景值 (最长32字符): ").strip()
if not scene:
print_error("场景值不能为空")
return
page = args.path or input("页面路径 (需已发布): ").strip() or None
data = api.get_wxacode_unlimit(scene, page)
else:
print_error("无效选择")
return
# 保存文件
with open(output_file, "wb") as f:
f.write(data)
print_success(f"小程序码已保存: {output_file}")
# 尝试打开
if sys.platform == "darwin":
os.system(f'open "{output_file}"')
except APIError as e:
print_error(f"生成小程序码失败: {e}")
def cmd_domain(api: MiniProgramAPI, args):
"""查看/配置域名"""
print_header("域名配置")
try:
# 获取当前配置
domains = api.get_domain()
webview_domains = api.get_webview_domain()
print("\n🌐 服务器域名:")
print(f" request: {', '.join(domains.get('requestdomain', [])) or '(无)'}")
print(f" wsrequest: {', '.join(domains.get('wsrequestdomain', [])) or '(无)'}")
print(f" upload: {', '.join(domains.get('uploaddomain', [])) or '(无)'}")
print(f" download: {', '.join(domains.get('downloaddomain', [])) or '(无)'}")
print(f"\n🔗 业务域名:")
print(f" webview: {', '.join(webview_domains) or '(无)'}")
# 是否要配置
if args.set_request:
print(f"\n配置 request 域名: {args.set_request}")
api.set_domain(requestdomain=[args.set_request])
print_success("域名配置成功")
except APIError as e:
print_error(f"域名配置失败: {e}")
def cmd_privacy(api: MiniProgramAPI, args):
"""配置隐私协议"""
print_header("隐私协议配置")
try:
# 获取当前配置
settings = api.get_privacy_setting()
print("\n📋 当前隐私设置:")
setting_list = settings.get("setting_list", [])
if setting_list:
for s in setting_list:
print(f" - {s.get('privacy_key', '?')}: {s.get('privacy_text', '')}")
else:
print(" (未配置)")
owner = settings.get("owner_setting", {})
if owner:
print(f"\n📧 联系方式:")
if owner.get("contact_email"):
print(f" 邮箱: {owner['contact_email']}")
if owner.get("contact_phone"):
print(f" 电话: {owner['contact_phone']}")
# 快速配置
if args.quick:
print("\n⚡ 快速配置常用隐私项...")
default_settings = [
{"privacy_key": "UserInfo", "privacy_text": "用于展示您的头像和昵称"},
{"privacy_key": "Location", "privacy_text": "用于获取您的位置信息以推荐附近服务"},
{"privacy_key": "PhoneNumber", "privacy_text": "用于登录验证和订单通知"},
]
api.set_privacy_setting(
setting_list=default_settings,
contact_email=args.email or "contact@example.com",
contact_phone=args.phone or "15880802661"
)
print_success("隐私协议配置成功")
except APIError as e:
print_error(f"隐私协议配置失败: {e}")
def cmd_data(api: MiniProgramAPI, args):
"""查看数据分析"""
print_header("数据分析")
# 默认查询最近7天
end_date = datetime.now().strftime("%Y%m%d")
begin_date = (datetime.now() - timedelta(days=7)).strftime("%Y%m%d")
if args.begin:
begin_date = args.begin
if args.end:
end_date = args.end
try:
print(f"\n📊 访问趋势 ({begin_date} ~ {end_date}):")
data = api.get_daily_visit_trend(begin_date, end_date)
if not data:
print(" (暂无数据)")
return
# 统计汇总
total_pv = sum(d.get("visit_pv", 0) for d in data)
total_uv = sum(d.get("visit_uv", 0) for d in data)
total_new = sum(d.get("visit_uv_new", 0) for d in data)
print(f"\n📈 汇总数据:")
print(f" 总访问次数: {total_pv:,}")
print(f" 总访问人数: {total_uv:,}")
print(f" 新用户数: {total_new:,}")
print(f"\n📅 每日明细:")
for d in data[-7:]: # 只显示最近7天
date = d.get("ref_date", "?")
pv = d.get("visit_pv", 0)
uv = d.get("visit_uv", 0)
stay = d.get("stay_time_uv", 0)
print(f" {date}: PV={pv}, UV={uv}, 人均停留={stay:.1f}")
except APIError as e:
print_error(f"获取数据失败: {e}")
def cmd_quota(api: MiniProgramAPI, args):
"""查看API配额"""
print_header("API配额")
common_apis = [
"/wxa/getwxacode",
"/wxa/getwxacodeunlimit",
"/wxa/genwxashortlink",
"/wxa/submit_audit",
"/cgi-bin/message/subscribe/send"
]
try:
for cgi_path in common_apis:
try:
quota = api.get_api_quota(cgi_path)
daily_limit = quota.get("daily_limit", 0)
used = quota.get("used", 0)
remain = quota.get("remain", 0)
print(f"\n📌 {cgi_path}")
print(f" 每日限额: {daily_limit:,}")
print(f" 已使用: {used:,}")
print(f" 剩余: {remain:,}")
except APIError:
pass
except APIError as e:
print_error(f"获取配额失败: {e}")
def cmd_cli(api: MiniProgramAPI, args):
"""使用微信开发者工具CLI"""
print_header("微信开发者工具CLI")
cli_path = "/Applications/wechatwebdevtools.app/Contents/MacOS/cli"
project_path = args.project or os.getenv("MINIPROGRAM_PATH", "")
if not project_path:
project_path = input("请输入小程序项目路径: ").strip()
if not os.path.exists(project_path):
print_error(f"项目路径不存在: {project_path}")
return
if not os.path.exists(cli_path):
print_error("未找到微信开发者工具,请先安装")
return
print(f"\n📂 项目路径: {project_path}")
print("\n选择操作:")
print(" 1. 打开项目")
print(" 2. 预览(生成二维码)")
print(" 3. 上传代码")
print(" 4. 编译")
choice = input("\n请选择: ").strip()
if choice == "1":
os.system(f'"{cli_path}" -o "{project_path}"')
print_success("项目已打开")
elif choice == "2":
output = f"{project_path}/preview.png"
os.system(f'"{cli_path}" preview --project "{project_path}" --qr-format image --qr-output "{output}"')
if os.path.exists(output):
print_success(f"预览二维码已生成: {output}")
os.system(f'open "{output}"')
else:
print_error("生成失败,请检查开发者工具是否已登录")
elif choice == "3":
version = input("版本号 (如 1.0.0): ").strip()
desc = input("版本说明: ").strip()
os.system(f'"{cli_path}" upload --project "{project_path}" --version "{version}" --desc "{desc}"')
print_success("代码上传完成")
elif choice == "4":
os.system(f'"{cli_path}" build-npm --project "{project_path}"')
print_success("编译完成")
else:
print_error("无效选择")
def main():
parser = argparse.ArgumentParser(
description="微信小程序管理工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python mp_manager.py status 查看小程序状态
python mp_manager.py audit 查看审核状态
python mp_manager.py submit -d "修复xxx问题" 提交审核
python mp_manager.py release 发布上线
python mp_manager.py qrcode -t 3 -s "id=123" 生成无限小程序码
python mp_manager.py domain 查看域名配置
python mp_manager.py privacy --quick 快速配置隐私协议
python mp_manager.py data 查看数据分析
python mp_manager.py cli 使用开发者工具CLI
"""
)
subparsers = parser.add_subparsers(dest="command", help="子命令")
# status
subparsers.add_parser("status", help="查看小程序状态")
# audit
subparsers.add_parser("audit", help="查看审核状态")
# submit
submit_parser = subparsers.add_parser("submit", help="提交审核")
submit_parser.add_argument("-d", "--desc", help="版本说明")
# release
subparsers.add_parser("release", help="发布上线")
# revert
subparsers.add_parser("revert", help="版本回退")
# qrcode
qr_parser = subparsers.add_parser("qrcode", help="生成小程序码")
qr_parser.add_argument("-t", "--type", choices=["1", "2", "3"], help="类型1=体验版2=小程序码3=无限小程序码")
qr_parser.add_argument("-p", "--path", help="页面路径")
qr_parser.add_argument("-s", "--scene", help="场景值类型3时使用")
qr_parser.add_argument("-o", "--output", help="输出文件名")
# domain
domain_parser = subparsers.add_parser("domain", help="查看/配置域名")
domain_parser.add_argument("--set-request", help="设置request域名")
# privacy
privacy_parser = subparsers.add_parser("privacy", help="配置隐私协议")
privacy_parser.add_argument("--quick", action="store_true", help="快速配置常用隐私项")
privacy_parser.add_argument("--email", help="联系邮箱")
privacy_parser.add_argument("--phone", help="联系电话")
# data
data_parser = subparsers.add_parser("data", help="查看数据分析")
data_parser.add_argument("--begin", help="开始日期 YYYYMMDD")
data_parser.add_argument("--end", help="结束日期 YYYYMMDD")
# quota
subparsers.add_parser("quota", help="查看API配额")
# cli
cli_parser = subparsers.add_parser("cli", help="使用微信开发者工具CLI")
cli_parser.add_argument("-p", "--project", help="小程序项目路径")
args = parser.parse_args()
if not args.command:
parser.print_help()
return
# 创建API实例
try:
api = create_api_from_env()
except Exception as e:
print_error(f"初始化API失败: {e}")
print_info("请检查 .env 文件中的配置")
return
# 执行命令
commands = {
"status": cmd_status,
"audit": cmd_audit,
"submit": cmd_submit,
"release": cmd_release,
"revert": cmd_revert,
"qrcode": cmd_qrcode,
"domain": cmd_domain,
"privacy": cmd_privacy,
"data": cmd_data,
"quota": cmd_quota,
"cli": cmd_cli,
}
cmd_func = commands.get(args.command)
if cmd_func:
try:
cmd_func(api, args)
finally:
api.close()
else:
parser.print_help()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,76 @@
{
"app_id": "soul-party",
"app_name": "Soul派对",
"appid": "wxb8bbb2b10dec74aa",
"check_time": "2026-01-25T11:33:01.054516",
"checks": [
{
"name": "项目路径",
"status": "ok",
"message": "路径存在: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验/miniprogram",
"fix_hint": ""
},
{
"name": "AppID配置",
"status": "ok",
"message": "AppID正确: wxb8bbb2b10dec74aa",
"fix_hint": ""
},
{
"name": "API域名",
"status": "ok",
"message": "已配置HTTPS域名",
"fix_hint": ""
},
{
"name": "入口文件",
"status": "ok",
"message": "app.js存在",
"fix_hint": ""
},
{
"name": "页面配置",
"status": "ok",
"message": "共9个页面",
"fix_hint": ""
},
{
"name": "隐私配置",
"status": "warning",
"message": "未启用隐私检查",
"fix_hint": "建议添加 __usePrivacyCheck__: true"
},
{
"name": "企业认证",
"status": "warning",
"message": "认证审核中",
"fix_hint": "等待审核结果"
},
{
"name": "开发者工具",
"status": "ok",
"message": "微信开发者工具已安装",
"fix_hint": ""
},
{
"name": "miniprogram-ci",
"status": "ok",
"message": "npm工具已安装",
"fix_hint": ""
},
{
"name": "上传密钥",
"status": "warning",
"message": "未配置私钥",
"fix_hint": "在小程序后台下载代码上传密钥"
}
],
"summary": {
"total": 10,
"ok": 7,
"warning": 3,
"error": 0,
"can_deploy": true,
"can_release": false
}
}

View File

@@ -0,0 +1,76 @@
{
"app_id": "soul-party",
"app_name": "Soul派对",
"appid": "wxb8bbb2b10dec74aa",
"check_time": "2026-01-25T11:34:23.760802",
"checks": [
{
"name": "项目路径",
"status": "ok",
"message": "路径存在: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验/miniprogram",
"fix_hint": ""
},
{
"name": "AppID配置",
"status": "ok",
"message": "AppID正确: wxb8bbb2b10dec74aa",
"fix_hint": ""
},
{
"name": "API域名",
"status": "ok",
"message": "已配置HTTPS域名",
"fix_hint": ""
},
{
"name": "入口文件",
"status": "ok",
"message": "app.js存在",
"fix_hint": ""
},
{
"name": "页面配置",
"status": "ok",
"message": "共9个页面",
"fix_hint": ""
},
{
"name": "隐私配置",
"status": "ok",
"message": "已启用隐私检查",
"fix_hint": ""
},
{
"name": "企业认证",
"status": "warning",
"message": "认证审核中",
"fix_hint": "等待审核结果"
},
{
"name": "开发者工具",
"status": "ok",
"message": "微信开发者工具已安装",
"fix_hint": ""
},
{
"name": "miniprogram-ci",
"status": "ok",
"message": "npm工具已安装",
"fix_hint": ""
},
{
"name": "上传密钥",
"status": "warning",
"message": "未配置私钥",
"fix_hint": "在小程序后台下载代码上传密钥"
}
],
"summary": {
"total": 10,
"ok": 8,
"warning": 2,
"error": 0,
"can_deploy": true,
"can_release": false
}
}

View File

@@ -0,0 +1,76 @@
{
"app_id": "soul-party",
"app_name": "Soul派对",
"appid": "wxb8bbb2b10dec74aa",
"check_time": "2026-01-25T11:34:28.854418",
"checks": [
{
"name": "项目路径",
"status": "ok",
"message": "路径存在: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验/miniprogram",
"fix_hint": ""
},
{
"name": "AppID配置",
"status": "ok",
"message": "AppID正确: wxb8bbb2b10dec74aa",
"fix_hint": ""
},
{
"name": "API域名",
"status": "ok",
"message": "已配置HTTPS域名",
"fix_hint": ""
},
{
"name": "入口文件",
"status": "ok",
"message": "app.js存在",
"fix_hint": ""
},
{
"name": "页面配置",
"status": "ok",
"message": "共9个页面",
"fix_hint": ""
},
{
"name": "隐私配置",
"status": "ok",
"message": "已启用隐私检查",
"fix_hint": ""
},
{
"name": "企业认证",
"status": "warning",
"message": "认证审核中",
"fix_hint": "等待审核结果"
},
{
"name": "开发者工具",
"status": "ok",
"message": "微信开发者工具已安装",
"fix_hint": ""
},
{
"name": "miniprogram-ci",
"status": "ok",
"message": "npm工具已安装",
"fix_hint": ""
},
{
"name": "上传密钥",
"status": "warning",
"message": "未配置私钥",
"fix_hint": "在小程序后台下载代码上传密钥"
}
],
"summary": {
"total": 10,
"ok": 8,
"warning": 2,
"error": 0,
"can_deploy": true,
"can_release": false
}
}

View File

@@ -0,0 +1,88 @@
{
"generated_at": "2026-01-25T11:32:55.447833",
"total_apps": 1,
"summary": {
"ok": 0,
"warning": 1,
"error": 0,
"can_release": 0
},
"apps": [
{
"app_id": "soul-party",
"app_name": "Soul派对",
"appid": "wxb8bbb2b10dec74aa",
"check_time": "2026-01-25T11:32:55.428736",
"checks": [
{
"name": "项目路径",
"status": "ok",
"message": "路径存在: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验/miniprogram",
"fix_hint": ""
},
{
"name": "AppID配置",
"status": "ok",
"message": "AppID正确: wxb8bbb2b10dec74aa",
"fix_hint": ""
},
{
"name": "API域名",
"status": "ok",
"message": "已配置HTTPS域名",
"fix_hint": ""
},
{
"name": "入口文件",
"status": "ok",
"message": "app.js存在",
"fix_hint": ""
},
{
"name": "页面配置",
"status": "ok",
"message": "共9个页面",
"fix_hint": ""
},
{
"name": "隐私配置",
"status": "warning",
"message": "未启用隐私检查",
"fix_hint": "建议添加 __usePrivacyCheck__: true"
},
{
"name": "企业认证",
"status": "warning",
"message": "认证审核中",
"fix_hint": "等待审核结果"
},
{
"name": "开发者工具",
"status": "ok",
"message": "微信开发者工具已安装",
"fix_hint": ""
},
{
"name": "miniprogram-ci",
"status": "ok",
"message": "npm工具已安装",
"fix_hint": ""
},
{
"name": "上传密钥",
"status": "warning",
"message": "未配置私钥",
"fix_hint": "在小程序后台下载代码上传密钥"
}
],
"summary": {
"total": 10,
"ok": 7,
"warning": 3,
"error": 0,
"can_deploy": true,
"can_release": false
}
}
]
}

View File

@@ -0,0 +1,7 @@
# 微信小程序管理工具依赖
# HTTP客户端
httpx>=0.25.0
# 环境变量管理
python-dotenv>=1.0.0

View File

@@ -0,0 +1,314 @@
---
name: 服务器管理
description: 宝塔服务器统一管理与自动化部署。触发词服务器、宝塔、部署、上线、发布、Node项目、SSL证书、HTTPS、DNS解析、域名配置、端口、PM2、Nginx、MySQL数据库、服务器状态。涵盖多服务器资产管理、Node.js项目一键部署、SSL证书管理、DNS配置、系统诊断等运维能力。
---
# 服务器管理
让 AI 写完代码后,无需人工介入,自动把项目「变成一个在线网站」。
---
## 快速入口(复制即用)
### 服务器资产
| 服务器 | IP | 配置 | 用途 | 宝塔面板 |
|--------|-----|------|------|----------|
| **小型宝塔** | 42.194.232.22 | 2核4G 5M | 主力部署Node项目 | https://42.194.232.22:9988/ckbpanel |
| **存客宝** | 42.194.245.239 | 2核16G 50M | 私域银行业务 | https://42.194.245.239:9988 |
| **kr宝塔** | 43.139.27.93 | 2核4G 5M | 辅助服务器 | https://43.139.27.93:9988 |
### 凭证速查
```bash
# SSH连接小型宝塔为例
ssh root@42.194.232.22
密码: Zhiqun1984
# 宝塔面板登录(小型宝塔)
地址: https://42.194.232.22:9988/ckbpanel
账号: ckb
密码: zhiqun1984
# 宝塔API密钥
小型宝塔: hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd
存客宝: TNKjqDv5N1QLOU20gcmGVgr82Z4mXzRi
kr宝塔: qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT
```
---
## 一键操作
### 1. 检查服务器状态
```bash
# 运行快速检查脚本
python3 /Users/karuo/Documents/个人/卡若AI/01_系统管理/服务器管理/scripts/快速检查服务器.py
```
### 2. 部署 Node 项目(标准流程)
```bash
# 1. 压缩项目(排除无用目录)
cd /项目路径
tar --exclude='node_modules' --exclude='.next' --exclude='.git' \
-czf /tmp/项目名_update.tar.gz .
# 2. 上传到服务器
sshpass -p 'Zhiqun1984' scp /tmp/项目名_update.tar.gz root@42.194.232.22:/tmp/
# 3. SSH部署
ssh root@42.194.232.22
cd /www/wwwroot/项目名
rm -rf app components lib public styles *.json *.js *.ts *.mjs *.md .next
tar -xzf /tmp/项目名_update.tar.gz
pnpm install
pnpm run build
rm /tmp/项目名_update.tar.gz
# 4. 宝塔面板重启项目
# 【网站】→【Node项目】→ 找到项目 → 点击【重启】
```
### 3. SSL证书检查/修复
```bash
# 检查所有服务器SSL证书状态
python3 /Users/karuo/Documents/个人/卡若AI/01_系统管理/服务器管理/scripts/ssl证书检查.py
# 自动修复过期证书
python3 /Users/karuo/Documents/个人/卡若AI/01_系统管理/服务器管理/scripts/ssl证书检查.py --fix
```
### 4. 常用诊断命令
```bash
# 检查端口占用
ssh root@42.194.232.22 "ss -tlnp | grep :3006"
# 检查PM2进程
ssh root@42.194.232.22 "/www/server/nodejs/v22.14.0/bin/pm2 list"
# 测试HTTP响应
ssh root@42.194.232.22 "curl -I http://localhost:3006"
# 检查Nginx配置
ssh root@42.194.232.22 "nginx -t"
# 重载Nginx
ssh root@42.194.232.22 "nginx -s reload"
# DNS解析检查
dig soul.quwanzhi.com +short @8.8.8.8
```
---
## 端口配置表(小型宝塔 42.194.232.22
| 端口 | 项目名 | 类型 | 域名 | 状态 |
|------|--------|------|------|------|
| 3000 | cunkebao | Next.js | mckb.quwanzhi.com | ✅ |
| 3001 | ai_hair | NestJS | ai-hair.quwanzhi.com | ✅ |
| 3002 | kr_wb | Next.js | kr_wb.quwanzhi.com | ✅ |
| 3003 | hx | Vue | krjzk.quwanzhi.com | ⚠️ |
| 3004 | dlmdashboard | Next.js | dlm.quwanzhi.com | ✅ |
| 3005 | document | Next.js | docc.quwanzhi.com | ✅ |
| 3006 | soul | Next.js | soul.quwanzhi.com | ✅ |
| 3015 | 神射手 | Next.js | kr-users.quwanzhi.com | ⚠️ |
| 3018 | zhaoping | Next.js | zp.quwanzhi.com | ✅ |
| 3021 | is_phone | Next.js | is-phone.quwanzhi.com | ✅ |
| 3031 | word | Next.js | word.quwanzhi.com | ✅ |
| 3036 | ymao | Next.js | ymao.quwanzhi.com | ✅ |
| 3043 | tongzhi | Next.js | touzhi.lkdie.com | ✅ |
| 3045 | 玩值大屏 | Next.js | wz-screen.quwanzhi.com | ✅ |
| 3050 | zhiji | Next.js | zhiji.quwanzhi.com | ✅ |
| 3051 | zhiji1 | Next.js | zhiji1.quwanzhi.com | ✅ |
| 3055 | wzdj | Next.js | wzdj.quwanzhi.com | ✅ |
| 3305 | AITOUFA | Next.js | ai-tf.quwanzhi.com | ✅ |
| 9528 | mbti | Vue | mbtiadmin.quwanzhi.com | ✅ |
### 端口分配原则
- **3000-3099**: Next.js / React 项目
- **3100-3199**: Vue 项目
- **3200-3299**: NestJS / Express 后端
- **3300-3399**: AI相关项目
- **9000-9999**: 管理面板 / 特殊用途
---
## 核心工作流程
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Node项目一键部署流程 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ START │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 1. 压缩本地代码 │ 排除 node_modules, .next, .git │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 2. 上传到服务器 │ scp 到 /tmp/ │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 3. 清理旧文件 │ 保留 .env 等配置文件 │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 4. 解压新代码 │ tar -xzf │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 5. 安装依赖 │ pnpm install │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 6. 构建项目 │ pnpm run build │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 7. 宝塔面板重启 │ Node项目 → 重启 │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ 8. 验证访问 │ curl https://域名 │
│ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ SUCCESS │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## 操作优先级矩阵
| 操作类型 | 优先方式 | 备选方式 | 说明 |
|---------|---------|---------|------|
| 查询信息 | ✅ 宝塔API | SSH | API稳定 |
| 文件操作 | ✅ 宝塔API | SSH | API支持 |
| 配置Nginx | ✅ 宝塔API | SSH | API可读写 |
| 重载服务 | ⚠️ SSH | - | API无接口 |
| 上传代码 | ⚠️ SSH/scp | - | 大文件 |
| 添加项目 | ❌ 宝塔界面 | - | API不稳定 |
---
## 常见问题速查
### Q1: 外网无法访问ERR_EMPTY_RESPONSE
**原因**: 腾讯云安全组只开放443端口
**解决**:
1. 必须配置SSL证书
2. Nginx配置添加443监听
### Q2: Node项目启动失败Could not find production build
**原因**: 使用 `npm run start` 但未执行 `npm run build`
**解决**: 先 `pnpm run build` 再重启
### Q3: 端口冲突EADDRINUSE
**解决**:
```bash
# 检查端口占用
ss -tlnp | grep :端口号
# 修改package.json中的端口
"start": "next start -p 新端口"
```
### Q4: DNS被代理劫持
**现象**: 本地DNS解析到198.18.x.x
**解决**:
- 关闭代理软件
- 或用手机4G网络测试
### Q5: 宝塔与PM2冲突
**原因**: 同时使用root用户PM2和宝塔PM2
**解决**:
- 停止所有独立PM2: `pm2 kill`
- 只使用宝塔界面管理
---
## 安全约束
### 绝对禁止
- ❌ 输出完整密码/密钥到聊天
- ❌ 执行危险命令rm -rf /, reboot等
- ❌ 跳过验证步骤
- ❌ 使用独立PM2避免与宝塔冲突
### 必须遵守
- ✅ 操作前检查服务器状态
- ✅ 操作后验证结果
- ✅ 生成操作报告
---
## 相关脚本
| 脚本 | 功能 | 位置 |
|------|------|------|
| `快速检查服务器.py` | 一键检查所有服务器状态 | `./scripts/` |
| `一键部署.py` | 根据配置文件部署项目 | `./scripts/` |
| `ssl证书检查.py` | 检查/修复SSL证书 | `./scripts/` |
---
## 相关文档
| 文档 | 内容 | 位置 |
|------|------|------|
| `宝塔API接口文档.md` | 宝塔API完整接口说明 | `./references/` |
| `端口配置表.md` | 完整端口分配表 | `./references/` |
| `常见问题手册.md` | 问题解决方案大全 | `./references/` |
| `部署配置模板.md` | JSON配置文件模板 | `./references/` |
| `系统架构说明.md` | 完整架构图和流程图 | `./references/` |
---
## 历史对话整理
### kr_wb白板项目部署2026-01-23
- 项目类型: Next.js
- 部署位置: /www/wwwroot/kr_wb
- 域名: kr_wb.quwanzhi.com
- 端口: 3002
- 遇到问题: AI功能401错误API密钥未配置
- 解决方案: 修改 lib/ai-client.ts改用 SiliconFlow 作为默认服务
### soul项目部署2026-01-23
- 项目类型: Next.js
- 部署位置: /www/wwwroot/soul
- 域名: soul.quwanzhi.com
- 端口: 3006
- 部署流程: 压缩→上传→解压→安装依赖→构建→PM2启动→配置Nginx→配置SSL

View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SSL证书检查脚本
===============
用途检查所有服务器的SSL证书状态
使用方法:
python3 ssl证书检查.py
python3 ssl证书检查.py --fix # 自动修复过期证书
"""
import sys
import time
import hashlib
import requests
import urllib3
from datetime import datetime
# 禁用SSL警告
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# 服务器配置
服务器列表 = {
"小型宝塔": {
"面板地址": "https://42.194.232.22:9988",
"密钥": "hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd"
},
"存客宝": {
"面板地址": "https://42.194.245.239:9988",
"密钥": "TNKjqDv5N1QLOU20gcmGVgr82Z4mXzRi"
},
"kr宝塔": {
"面板地址": "https://43.139.27.93:9988",
"密钥": "qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT"
}
}
def 生成签名(api_key: str) -> tuple:
"""生成宝塔API签名"""
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 获取证书列表(面板地址: str, 密钥: str) -> dict:
"""获取SSL证书列表"""
now_time, request_token = 生成签名(密钥)
url = f"{面板地址}/ssl?action=GetCertList"
data = {
"request_time": now_time,
"request_token": request_token
}
try:
response = requests.post(url, data=data, timeout=10, verify=False)
return response.json()
except Exception as e:
return {"error": str(e)}
def 获取网站列表(面板地址: str, 密钥: str) -> dict:
"""获取网站列表"""
now_time, request_token = 生成签名(密钥)
url = f"{面板地址}/data?action=getData&table=sites"
data = {
"request_time": now_time,
"request_token": request_token,
"limit": 100,
"p": 1
}
try:
response = requests.post(url, data=data, timeout=10, verify=False)
return response.json()
except Exception as e:
return {"error": str(e)}
def 检查服务器证书(名称: str, 配置: dict) -> dict:
"""检查单台服务器的证书状态"""
print(f"\n检查服务器: {名称}")
print("-" * 40)
try:
# 获取网站列表
网站数据 = 获取网站列表(配置["面板地址"], 配置["密钥"])
if "error" in 网站数据:
print(f" ❌ API错误: {网站数据['error']}")
return {"error": 网站数据['error']}
网站列表 = 网站数据.get("data", [])
if not 网站列表:
print(" ⚠️ 没有找到网站")
return {"网站数": 0}
print(f" 📊 共 {len(网站列表)} 个网站")
# 统计
已配置SSL = 0
未配置SSL = 0
for 网站 in 网站列表:
网站名 = 网站.get("name", "未知")
ssl状态 = 网站.get("ssl", 0)
if ssl状态:
已配置SSL += 1
状态标识 = "🔒"
else:
未配置SSL += 1
状态标识 = "🔓"
print(f" {状态标识} {网站名}")
print(f"\n 统计: 已配置SSL {已配置SSL} 个, 未配置 {未配置SSL}")
return {
"网站数": len(网站列表),
"已配置SSL": 已配置SSL,
"未配置SSL": 未配置SSL
}
except Exception as e:
print(f" ❌ 检查失败: {e}")
return {"error": str(e)}
def main():
自动修复 = "--fix" in sys.argv
print("=" * 60)
print(" SSL证书状态检查报告")
print(f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("=" * 60)
总统计 = {
"服务器数": 0,
"网站总数": 0,
"已配置SSL": 0,
"未配置SSL": 0
}
for 服务器名称, 配置 in 服务器列表.items():
结果 = 检查服务器证书(服务器名称, 配置)
if "error" not in 结果:
总统计["服务器数"] += 1
总统计["网站总数"] += 结果.get("网站数", 0)
总统计["已配置SSL"] += 结果.get("已配置SSL", 0)
总统计["未配置SSL"] += 结果.get("未配置SSL", 0)
print("\n" + "=" * 60)
print(" 汇总统计")
print("=" * 60)
print(f" 服务器数量: {总统计['服务器数']}")
print(f" 网站总数: {总统计['网站总数']}")
print(f" 已配置SSL: {总统计['已配置SSL']} 🔒")
print(f" 未配置SSL: {总统计['未配置SSL']} 🔓")
print("=" * 60)
if 自动修复 and 总统计['未配置SSL'] > 0:
print("\n⚠️ --fix 模式需要手动在宝塔面板配置SSL证书")
print(" 建议使用通配符证书 *.quwanzhi.com")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,138 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
一键部署脚本
============
用途根据配置文件一键部署Node项目到宝塔服务器
使用方法:
python3 一键部署.py 项目名称 本地项目路径
示例:
python3 一键部署.py soul /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验
"""
import sys
import os
import subprocess
import time
# 默认服务器配置
默认配置 = {
"服务器IP": "42.194.232.22",
"SSH用户": "root",
"SSH密码": "Zhiqun1984",
"服务器根目录": "/www/wwwroot"
}
def 执行命令(命令: str, 显示输出: bool = True) -> tuple:
"""执行shell命令"""
result = subprocess.run(命令, shell=True, capture_output=True, text=True)
if 显示输出 and result.stdout:
print(result.stdout)
if result.stderr and "Warning" not in result.stderr:
print(f"错误: {result.stderr}")
return result.returncode, result.stdout
def 部署项目(项目名称: str, 本地路径: str):
"""执行部署流程"""
服务器路径 = f"{默认配置['服务器根目录']}/{项目名称}"
压缩文件 = f"/tmp/{项目名称}_update.tar.gz"
print(f"\n{'='*60}")
print(f"开始部署: {项目名称}")
print(f"本地路径: {本地路径}")
print(f"服务器路径: {服务器路径}")
print(f"{'='*60}\n")
# 步骤1: 压缩项目
print("📦 步骤1: 压缩项目文件...")
排除项 = "--exclude='node_modules' --exclude='.next' --exclude='.git' --exclude='android' --exclude='out'"
压缩命令 = f"cd '{本地路径}' && tar {排除项} -czf {压缩文件} ."
code, _ = 执行命令(压缩命令, False)
if code != 0:
print("❌ 压缩失败")
return False
# 获取文件大小
大小 = os.path.getsize(压缩文件) / 1024 / 1024
print(f" ✅ 压缩完成,大小: {大小:.2f} MB")
# 步骤2: 上传到服务器
print("\n📤 步骤2: 上传到服务器...")
上传命令 = f"sshpass -p '{默认配置['SSH密码']}' scp -o StrictHostKeyChecking=no {压缩文件} {默认配置['SSH用户']}@{默认配置['服务器IP']}:/tmp/"
code, _ = 执行命令(上传命令, False)
if code != 0:
print("❌ 上传失败")
return False
print(" ✅ 上传完成")
# 步骤3-6: SSH远程执行
print("\n🔧 步骤3-6: 服务器端操作...")
SSH前缀 = f"sshpass -p '{默认配置['SSH密码']}' ssh -o StrictHostKeyChecking=no {默认配置['SSH用户']}@{默认配置['服务器IP']}"
# 清理旧文件
清理命令 = f"{SSH前缀} 'cd {服务器路径} && rm -rf app components lib public styles *.json *.js *.ts *.mjs *.md .next 2>/dev/null || true'"
执行命令(清理命令, False)
print(" ✅ 清理旧文件")
# 解压
解压命令 = f"{SSH前缀} 'cd {服务器路径} && tar -xzf /tmp/{项目名称}_update.tar.gz'"
执行命令(解压命令, False)
print(" ✅ 解压新代码")
# 安装依赖
print("\n📚 安装依赖 (这可能需要几分钟)...")
安装命令 = f"{SSH前缀} 'export PATH=/www/server/nodejs/v22.14.0/bin:$PATH && cd {服务器路径} && npm install --legacy-peer-deps 2>&1'"
执行命令(安装命令, True)
# 构建
print("\n🏗️ 构建项目...")
构建命令 = f"{SSH前缀} 'export PATH=/www/server/nodejs/v22.14.0/bin:$PATH && cd {服务器路径} && npm run build 2>&1'"
执行命令(构建命令, True)
# 重启PM2
print("\n🔄 重启服务...")
重启命令 = f"{SSH前缀} 'export PATH=/www/server/nodejs/v22.14.0/bin:$PATH && pm2 restart {项目名称} 2>&1'"
执行命令(重启命令, True)
# 清理临时文件
清理临时命令 = f"{SSH前缀} 'rm -f /tmp/{项目名称}_update.tar.gz'"
执行命令(清理临时命令, False)
os.remove(压缩文件)
print(f"\n{'='*60}")
print("✅ 部署完成!")
print(f"{'='*60}")
print("\n⚠️ 请在宝塔面板手动重启项目:")
print(f" 1. 登录 https://42.194.232.22:9988/ckbpanel")
print(f" 2. 进入【网站】→【Node项目】")
print(f" 3. 找到 {项目名称},点击【重启】")
return True
def main():
if len(sys.argv) < 3:
print("用法: python3 一键部署.py <项目名称> <本地项目路径>")
print("\n示例:")
print(" python3 一键部署.py soul /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验")
print(" python3 一键部署.py kr_wb /Users/karuo/Documents/开发/4、小工具/whiteboard")
sys.exit(1)
项目名称 = sys.argv[1]
本地路径 = sys.argv[2]
if not os.path.exists(本地路径):
print(f"❌ 本地路径不存在: {本地路径}")
sys.exit(1)
确认 = input(f"\n确认部署 {项目名称} 到服务器? (y/n): ")
if 确认.lower() != 'y':
print("已取消部署")
sys.exit(0)
部署项目(项目名称, 本地路径)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
快速检查服务器状态
==================
用途:一键检查所有服务器的基本状态
使用方法:
python3 快速检查服务器.py
"""
import time
import hashlib
import requests
import urllib3
# 禁用SSL警告
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# 服务器配置
服务器列表 = {
"小型宝塔": {
"面板地址": "https://42.194.232.22:9988",
"密钥": "hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd"
},
"存客宝": {
"面板地址": "https://42.194.245.239:9988",
"密钥": "TNKjqDv5N1QLOU20gcmGVgr82Z4mXzRi"
},
"kr宝塔": {
"面板地址": "https://43.139.27.93:9988",
"密钥": "qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT"
}
}
def 生成签名(api_key: str) -> tuple:
"""生成宝塔API签名"""
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 获取系统信息(面板地址: str, 密钥: str) -> dict:
"""获取系统基础统计信息"""
now_time, request_token = 生成签名(密钥)
url = f"{面板地址}/system?action=GetSystemTotal"
data = {
"request_time": now_time,
"request_token": request_token
}
try:
response = requests.post(url, data=data, timeout=10, verify=False)
return response.json()
except Exception as e:
return {"error": str(e)}
def 检查单台服务器(名称: str, 配置: dict) -> dict:
"""检查单台服务器状态"""
try:
系统信息 = 获取系统信息(配置["面板地址"], 配置["密钥"])
if isinstance(系统信息, dict) and "error" not in 系统信息 and 系统信息.get("status") != False:
return {
"名称": 名称,
"状态": "✅ 正常",
"CPU": f"{系统信息.get('cpuRealUsed', 'N/A')}%",
"内存": f"{系统信息.get('memRealUsed', 'N/A')}%",
"磁盘": f"{系统信息.get('diskPer', 'N/A')}%"
}
else:
return {
"名称": 名称,
"状态": "❌ API错误",
"错误": str(系统信息)
}
except Exception as e:
return {
"名称": 名称,
"状态": "❌ 连接失败",
"错误": str(e)
}
def main():
print("=" * 60)
print(" 服务器状态检查报告")
print("=" * 60)
print()
for 名称, 配置 in 服务器列表.items():
结果 = 检查单台服务器(名称, 配置)
print(f"📦 {结果['名称']}")
print(f" 状态: {结果['状态']}")
if "CPU" in 结果:
print(f" CPU: {结果['CPU']} | 内存: {结果['内存']} | 磁盘: {结果['磁盘']}")
if "错误" in 结果:
print(f" 错误: {结果['错误'][:50]}...")
print()
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -1,14 +0,0 @@
# Windows
[Dd]esktop.ini
Thumbs.db
$RECYCLE.BIN/
# macOS
.DS_Store
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
# Node.js
node_modules/

View File

@@ -1,138 +0,0 @@
# Soul创业实验 - 微信小程序
> 一场SOUL的创业实验场 - 来自Soul派对房的真实商业故事
## 📱 项目简介
本项目是《一场SOUL的创业实验场》的微信小程序版本完整还原了Web端的所有UI界面和功能。
## 🎨 设计特点
- **主题色**: Soul青色 (#00CED1)
- **设计风格**: 深色主题 + 毛玻璃效果
- **1:1还原**: 完全复刻Web端的UI设计
## 📂 项目结构
```
miniprogram/
├── app.js # 应用入口
├── app.json # 应用配置
├── app.wxss # 全局样式
├── custom-tab-bar/ # 自定义TabBar组件
│ ├── index.js
│ ├── index.json
│ ├── index.wxml
│ └── index.wxss
├── pages/
│ ├── index/ # 首页
│ ├── chapters/ # 目录页
│ ├── match/ # 找伙伴页
│ ├── my/ # 我的页面
│ ├── read/ # 阅读页
│ ├── about/ # 关于作者
│ ├── referral/ # 推广中心
│ ├── purchases/ # 订单页
│ └── settings/ # 设置页
├── utils/
│ ├── util.js # 工具函数
│ └── payment.js # 支付工具
├── assets/
│ └── icons/ # 图标资源
├── project.config.json # 项目配置
└── sitemap.json # 站点地图
```
## 🚀 功能列表
### 核心功能
- ✅ 首页 - 书籍展示、推荐章节、阅读进度
- ✅ 目录 - 完整章节列表、篇章折叠展开
- ✅ 找伙伴 - 匹配动画、匹配类型选择
- ✅ 我的 - 个人信息、订单、推广中心
- ✅ 阅读 - 付费墙、章节导航、分享功能
### 特色功能
- ✅ 自定义TabBar中间突出的找伙伴按钮
- ✅ 阅读进度条
- ✅ 匹配动画效果
- ✅ 付费墙与购买流程
- ✅ 分享海报功能
- ✅ 推广佣金系统
## 🛠 开发指南
### 环境要求
- 微信开发者工具 >= 1.06.2308310
- 基础库版本 >= 3.3.4
### 快速开始
1. **下载微信开发者工具**
- 前往 [微信开发者工具下载页面](https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html)
2. **导入项目**
- 打开微信开发者工具
- 选择"导入项目"
- 项目目录选择 `miniprogram` 文件夹
- AppID 使用: `wx432c93e275548671`
3. **编译运行**
- 点击"编译"按钮
- 在模拟器中预览效果
### 真机调试
1. 点击工具栏的"预览"按钮
2. 使用微信扫描二维码
3. 在真机上测试所有功能
## 📝 配置说明
### API配置
`app.js` 中修改 `globalData.baseUrl`:
```javascript
globalData: {
baseUrl: 'https://soul.ckb.fit', // 你的API地址
// ...
}
```
### AppID配置
`project.config.json` 中修改:
```json
{
"appid": "你的小程序AppID"
}
```
## 🎯 上线发布
1. **准备工作**
- 确保所有功能测试通过
- 检查API接口是否正常
- 确认支付功能已配置
2. **上传代码**
- 在开发者工具中点击"上传"
- 填写版本号和项目备注
3. **提交审核**
- 登录[微信公众平台](https://mp.weixin.qq.com)
- 进入"版本管理"
- 提交审核
4. **发布上线**
- 审核通过后点击"发布"
## 🔗 相关链接
- **Web版本**: https://soul.ckb.fit
- **作者微信**: 28533368
- **技术支持**: 存客宝
## 📄 版权信息
© 2024 卡若. All rights reserved.

View File

@@ -1,540 +0,0 @@
/**
* Soul创业派对 - 小程序入口
* 开发: 卡若
*/
App({
globalData: {
// API基础地址 - 连接真实后端
baseUrl: 'https://soul.quwanzhi.com',
// 小程序配置 - 真实AppID
appId: 'wxb8bbb2b10dec74aa',
// 订阅消息:用户点击「申请提现」→「立即提现」时会先弹出订阅授权窗
withdrawSubscribeTmplId: 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE',
// 微信支付配置
mchId: '1318592501', // 商户号
// 用户信息
userInfo: null,
openId: null, // 微信openId支付必需
isLoggedIn: false,
// 书籍数据
bookData: null,
totalSections: 62,
// 购买记录
purchasedSections: [],
hasFullBook: false,
// 已读章节(仅统计有权限打开过的章节,用于首页「已读/待读」)
readSectionIds: [],
// 推荐绑定
pendingReferralCode: null, // 待绑定的推荐码
// 主题配置
theme: {
brandColor: '#00CED1',
brandSecondary: '#20B2AA',
goldColor: '#FFD700',
bgColor: '#000000',
cardBg: '#1c1c1e'
},
// 系统信息
systemInfo: null,
statusBarHeight: 44,
navBarHeight: 88,
// TabBar相关
currentTab: 0
},
onLaunch(options) {
this.globalData.readSectionIds = wx.getStorageSync('readSectionIds') || []
// 获取系统信息
this.getSystemInfo()
// 检查登录状态
this.checkLoginStatus()
// 加载书籍数据
this.loadBookData()
// 检查更新
this.checkUpdate()
// 处理分享参数(推荐码绑定)
this.handleReferralCode(options)
},
// 小程序显示时也检查分享参数
onShow(options) {
this.handleReferralCode(options)
},
// 处理推荐码绑定
handleReferralCode(options) {
const query = options?.query || {}
const refCode = query.ref || query.referralCode
if (refCode) {
console.log('[App] 检测到推荐码:', refCode)
// 立即记录访问(不需要登录,用于统计"通过链接进的人数"
this.recordReferralVisit(refCode)
// 保存待绑定的推荐码(不再在前端做"只能绑定一次"的限制让后端根据30天规则判断续期/抢夺)
this.globalData.pendingReferralCode = refCode
wx.setStorageSync('pendingReferralCode', refCode)
// 同步写入 referral_code供章节/找伙伴支付时传给后端,订单会记录 referrer_id 与 referral_code
wx.setStorageSync('referral_code', refCode)
// 如果已登录,立即尝试绑定,由 /api/miniprogram/referral/bind 按 30 天规则决定 new / renew / takeover
if (this.globalData.isLoggedIn && this.globalData.userInfo) {
this.bindReferralCode(refCode)
}
}
},
// 记录推荐访问(不需要登录,用于统计)
async recordReferralVisit(refCode) {
try {
// 获取openId如果有
const openId = this.globalData.openId || wx.getStorageSync('openId') || ''
const userId = this.globalData.userInfo?.id || ''
await this.request('/api/miniprogram/referral/visit', {
method: 'POST',
data: {
referralCode: refCode,
visitorOpenId: openId,
visitorId: userId,
source: 'miniprogram',
page: getCurrentPages()[getCurrentPages().length - 1]?.route || ''
},
silent: true
})
console.log('[App] 记录推荐访问成功')
} catch (e) {
console.log('[App] 记录推荐访问失败:', e.message)
// 忽略错误,不影响用户体验
}
},
// 绑定推荐码到用户
async bindReferralCode(refCode) {
try {
const userId = this.globalData.userInfo?.id
if (!userId || !refCode) return
console.log('[App] 绑定推荐码:', refCode, '到用户:', userId)
// 调用API绑定推荐关系
const res = await this.request('/api/miniprogram/referral/bind', {
method: 'POST',
data: {
userId,
referralCode: refCode
},
silent: true
})
if (res.success) {
console.log('[App] 推荐码绑定成功')
// 仅记录当前已绑定的推荐码,用于展示/调试是否允许更换由后端根据30天规则判断
wx.setStorageSync('boundReferralCode', refCode)
this.globalData.pendingReferralCode = null
wx.removeStorageSync('pendingReferralCode')
}
} catch (e) {
console.error('[App] 绑定推荐码失败:', e)
}
},
// 获取系统信息
getSystemInfo() {
try {
const systemInfo = wx.getSystemInfoSync()
this.globalData.systemInfo = systemInfo
this.globalData.statusBarHeight = systemInfo.statusBarHeight || 44
// 计算导航栏高度
const menuButton = wx.getMenuButtonBoundingClientRect()
if (menuButton) {
this.globalData.navBarHeight = (menuButton.top - systemInfo.statusBarHeight) * 2 + menuButton.height + systemInfo.statusBarHeight
}
} catch (e) {
console.error('获取系统信息失败:', e)
}
},
// 检查登录状态
checkLoginStatus() {
try {
const userInfo = wx.getStorageSync('userInfo')
const token = wx.getStorageSync('token')
if (userInfo && token) {
this.globalData.userInfo = userInfo
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = userInfo.purchasedSections || []
this.globalData.hasFullBook = userInfo.hasFullBook || false
}
} catch (e) {
console.error('检查登录状态失败:', e)
}
},
// 加载书籍数据
async loadBookData() {
try {
// 先从缓存加载
const cachedData = wx.getStorageSync('bookData')
if (cachedData) {
this.globalData.bookData = cachedData
}
// 从服务器获取最新数据
const res = await this.request('/api/book/all-chapters')
if (res && (res.data || res.chapters)) {
const chapters = res.data || res.chapters || []
this.globalData.bookData = chapters
wx.setStorageSync('bookData', chapters)
}
} catch (e) {
console.error('加载书籍数据失败:', e)
}
},
// 检查更新
checkUpdate() {
if (wx.canIUse('getUpdateManager')) {
const updateManager = wx.getUpdateManager()
updateManager.onCheckForUpdate((res) => {
if (res.hasUpdate) {
console.log('发现新版本')
}
})
updateManager.onUpdateReady(() => {
wx.showModal({
title: '更新提示',
content: '新版本已准备好,是否重启应用?',
success: (res) => {
if (res.confirm) {
updateManager.applyUpdate()
}
}
})
})
updateManager.onUpdateFailed(() => {
wx.showToast({
title: '更新失败,请稍后重试',
icon: 'none'
})
})
}
},
/**
* 从 soul-api 返回体中取错误提示文案(兼容 message / error 字段)
*/
_getApiErrorMsg(data, defaultMsg = '请求失败') {
if (!data || typeof data !== 'object') return defaultMsg
const msg = data.message || data.error
return (msg && String(msg).trim()) ? String(msg).trim() : defaultMsg
},
/**
* 统一请求方法。接口失败时会弹窗提示(与 soul-api 返回的 message/error 一致)。
* @param {string|object} urlOrOptions - 接口路径,或 { url, method, data, header, silent }
* @param {object} options - { method, data, header, silent }
* @param {boolean} options.silent - 为 true 时不弹窗,仅 reject用于静默请求如访问统计
*/
request(urlOrOptions, options = {}) {
let url
if (typeof urlOrOptions === 'string') {
url = urlOrOptions
} else if (urlOrOptions && typeof urlOrOptions === 'object' && urlOrOptions.url) {
url = urlOrOptions.url
options = { ...urlOrOptions, url: undefined }
} else {
url = ''
}
const silent = !!options.silent
const showError = (msg) => {
if (!silent && msg) {
wx.showToast({ title: msg, icon: 'none', duration: 2500 })
}
}
return new Promise((resolve, reject) => {
const token = wx.getStorageSync('token')
wx.request({
url: this.globalData.baseUrl + url,
method: options.method || 'GET',
data: options.data || {},
header: {
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
...options.header
},
success: (res) => {
const data = res.data
if (res.statusCode === 200) {
// 业务失败success === falsesoul-api 用 message 或 error 返回原因
if (data && data.success === false) {
const msg = this._getApiErrorMsg(data, '操作失败')
showError(msg)
reject(new Error(msg))
return
}
resolve(data)
return
}
if (res.statusCode === 401) {
this.logout()
showError('未授权,请重新登录')
reject(new Error('未授权'))
return
}
// 4xx/5xx优先用返回体的 message/error
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
showError(msg)
reject(new Error(msg))
},
fail: (err) => {
const msg = (err && err.errMsg) ? (err.errMsg.indexOf('timeout') !== -1 ? '请求超时,请重试' : '网络异常,请重试') : '网络异常,请重试'
showError(msg)
reject(new Error(msg))
}
})
})
},
// 登录方法 - 获取openId用于支付加固错误处理避免审核报“登录报错”
async login() {
try {
const loginRes = await new Promise((resolve, reject) => {
wx.login({ success: resolve, fail: reject })
})
if (!loginRes || !loginRes.code) {
console.warn('[App] wx.login 未返回 code')
wx.showToast({ title: '获取登录态失败,请重试', icon: 'none' })
return null
}
try {
const res = await this.request('/api/miniprogram/login', {
method: 'POST',
data: { code: loginRes.code }
})
if (res.success && res.data) {
// 保存openId
if (res.data.openId) {
this.globalData.openId = res.data.openId
wx.setStorageSync('openId', res.data.openId)
console.log('[App] 获取openId成功')
}
// 保存用户信息
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] 登录后自动绑定推荐码:', pendingRef)
this.bindReferralCode(pendingRef)
}
}
return res.data
}
} catch (apiError) {
console.log('[App] API登录失败:', apiError.message)
// 不使用模拟登录,提示用户网络问题
wx.showToast({ title: '网络异常,请重试', icon: 'none' })
return null
}
return null
} catch (e) {
console.error('[App] 登录失败:', e)
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
return null
}
},
// 获取openId (支付必需)
async getOpenId() {
// 先检查缓存
const cachedOpenId = wx.getStorageSync('openId')
if (cachedOpenId) {
this.globalData.openId = cachedOpenId
return cachedOpenId
}
// 没有缓存则登录获取
try {
const loginRes = await new Promise((resolve, reject) => {
wx.login({ success: resolve, fail: reject })
})
const res = await this.request('/api/miniprogram/login', {
method: 'POST',
data: { code: loginRes.code }
})
if (res.success && res.data?.openId) {
this.globalData.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
}
} catch (e) {
console.error('[App] 获取openId失败:', e)
}
return null
},
// 模拟登录已废弃 - 不再使用
// 现在必须使用真实的微信登录获取openId作为唯一标识
mockLogin() {
console.warn('[App] mockLogin已废弃请使用真实登录')
return null
},
// 手机号登录:需同时传 wx.login 的 code 与 getPhoneNumber 的 phoneCode
async loginWithPhone(phoneCode) {
try {
const loginRes = await new Promise((resolve, reject) => {
wx.login({ success: resolve, fail: reject })
})
if (!loginRes.code) {
wx.showToast({ title: '获取登录态失败', icon: 'none' })
return null
}
const res = await this.request('/api/miniprogram/phone-login', {
method: 'POST',
data: { code: loginRes.code, phoneCode }
})
if (res.success && res.data) {
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] 手机号登录后自动绑定推荐码:', pendingRef)
this.bindReferralCode(pendingRef)
}
return res.data
}
} catch (e) {
console.log('[App] 手机号登录失败:', e)
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
}
return null
},
// 退出登录
logout() {
this.globalData.userInfo = null
this.globalData.isLoggedIn = false
this.globalData.purchasedSections = []
this.globalData.hasFullBook = false
wx.removeStorageSync('userInfo')
wx.removeStorageSync('token')
},
// 检查是否已购买章节
hasPurchased(sectionId) {
if (this.globalData.hasFullBook) return true
return this.globalData.purchasedSections.includes(sectionId)
},
// 标记章节为已读(仅在有权限打开时由阅读页调用,用于首页已读/待读统计)
markSectionAsRead(sectionId) {
if (!sectionId) return
const list = this.globalData.readSectionIds || []
if (list.includes(sectionId)) return
list.push(sectionId)
this.globalData.readSectionIds = list
wx.setStorageSync('readSectionIds', list)
},
// 已读章节数(用于首页展示)
getReadCount() {
return (this.globalData.readSectionIds || []).length
},
// 获取章节总数
getTotalSections() {
return this.globalData.totalSections
},
// 切换TabBar
switchTab(index) {
this.globalData.currentTab = index
},
// 显示Toast
showToast(title, icon = 'none') {
wx.showToast({
title,
icon,
duration: 2000
})
},
// 显示Loading
showLoading(title = '加载中...') {
wx.showLoading({
title,
mask: true
})
},
// 隐藏Loading
hideLoading() {
wx.hideLoading()
}
})

Some files were not shown because too many files have changed in this diff Show More