Compare commits
70 Commits
main
...
e91a5d9f7a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e91a5d9f7a | ||
|
|
7551840c86 | ||
|
|
f6846b5941 | ||
|
|
685b476721 | ||
|
|
74b1c3396d | ||
|
|
6e276fca61 | ||
|
|
76d90a0397 | ||
|
|
934f7c7988 | ||
|
|
0e4baa4b7f | ||
|
|
09fb67d2af | ||
|
|
70497d3047 | ||
|
|
ceac5b73ff | ||
|
|
77a1c87678 | ||
|
|
e21837bf47 | ||
|
|
41bf3de992 | ||
|
|
ff1e4824f7 | ||
|
|
c08ca72992 | ||
|
|
d87fa5c175 | ||
|
|
8f01de4f9a | ||
|
|
132743ce34 | ||
|
|
174253584f | ||
|
|
051f064707 | ||
|
|
3f54e1af47 | ||
|
|
cd2c8d7cc5 | ||
|
|
0f50fb7c3b | ||
|
|
6a556c2470 | ||
|
|
d17150154c | ||
|
|
a228911170 | ||
|
|
8b2c3f4661 | ||
|
|
395501e961 | ||
|
|
6989ade3e2 | ||
|
|
612b23c6c0 | ||
|
|
9350b70f3e | ||
|
|
5501397542 | ||
|
|
ac24853aa6 | ||
|
|
afa8c59376 | ||
|
|
dbfbf65164 | ||
|
|
138495c90b | ||
|
|
8a13505381 | ||
|
|
1d11490405 | ||
|
|
a702cd9086 | ||
|
|
4dd2f9f4a7 | ||
|
|
65d2831a45 | ||
|
|
153b8d9795 | ||
|
|
263da246c9 | ||
|
|
4f11fe25f9 | ||
|
|
e16dce118e | ||
|
|
14e3993303 | ||
|
|
d42652c51b | ||
|
|
7ff181f743 | ||
|
|
1e1e6a1093 | ||
|
|
e869974341 | ||
|
|
0a5d470fef | ||
|
|
b60edb3d47 | ||
|
|
1ee25e3dab | ||
|
|
7e1e2e7115 | ||
|
|
b487855d44 | ||
|
|
e7008a8ed8 | ||
|
|
6afb9a143a | ||
|
|
d5df83f35b | ||
|
|
78867aac6d | ||
|
|
1e25c7134a | ||
|
|
59ca3b2bbd | ||
|
|
f3195d9331 | ||
|
|
c1953b89c1 | ||
|
|
c80b853108 | ||
|
|
298a75ea5c | ||
|
|
5420499117 | ||
|
|
326c9e6905 | ||
|
|
d781dc07ed |
95
.cursorrules
Normal file
95
.cursorrules
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# v0 Code Generation Rules - Claude Opus
|
||||||
|
|
||||||
|
## Model Selection
|
||||||
|
- Production components: claude-opus (默认)
|
||||||
|
- Rapid prototyping: v0-1.5-turbo
|
||||||
|
- Code review: claude-3.5-sonnet
|
||||||
|
|
||||||
|
## Code Standards
|
||||||
|
- Framework: Next.js App Router
|
||||||
|
- Styling: Tailwind CSS v4
|
||||||
|
- Components: shadcn/ui
|
||||||
|
- No placeholders
|
||||||
|
- Production-ready only
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
- Use design tokens from globals.css
|
||||||
|
- Follow color system (3-5 colors max)
|
||||||
|
- Max 2 font families
|
||||||
|
- Mobile-first approach
|
||||||
|
- 所有页面组件保持一致性
|
||||||
|
- 使用现有导航系统
|
||||||
|
- 遵循毛玻璃设计风格
|
||||||
|
- 精简文字,增加流程图
|
||||||
|
|
||||||
|
## v0 Usage
|
||||||
|
- 使用 @v0 前缀调用v0生成代码
|
||||||
|
- 默认使用 claude-opus 模型
|
||||||
|
- 生成前先说明需求,确保理解正确
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自动部署规则
|
||||||
|
|
||||||
|
### 服务器信息(小型宝塔)
|
||||||
|
- **服务器IP**: 42.194.232.22
|
||||||
|
- **用户**: root
|
||||||
|
- **密码**: Zhiqun1984
|
||||||
|
- **项目路径**: /www/wwwroot/soul
|
||||||
|
- **PM2进程名**: soul
|
||||||
|
- **端口**: 3006
|
||||||
|
- **宝塔面板**: https://42.194.232.22:9988/ckbpanel (ckb/zhiqun1984)
|
||||||
|
|
||||||
|
### GitHub仓库
|
||||||
|
- **地址**: https://github.com/fnvtk/Mycontent.git
|
||||||
|
- **分支**: soul-content
|
||||||
|
|
||||||
|
### 小程序
|
||||||
|
- **AppID**: wxb8bbb2b10dec74aa
|
||||||
|
- **项目路径**: ./miniprogram
|
||||||
|
|
||||||
|
### 部署流程(每次提交后自动执行)
|
||||||
|
1. **提交代码到Git**
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git commit -m "描述"
|
||||||
|
git push origin soul-content
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **部署到小型宝塔服务器**
|
||||||
|
```bash
|
||||||
|
# 压缩项目
|
||||||
|
cd /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验
|
||||||
|
tar --exclude='node_modules' --exclude='.next' --exclude='.git' -czf /tmp/soul_update.tar.gz .
|
||||||
|
|
||||||
|
# 上传到服务器
|
||||||
|
sshpass -p 'Zhiqun1984' scp /tmp/soul_update.tar.gz root@42.194.232.22:/tmp/
|
||||||
|
|
||||||
|
# SSH部署
|
||||||
|
sshpass -p 'Zhiqun1984' ssh root@42.194.232.22 "
|
||||||
|
cd /www/wwwroot/soul
|
||||||
|
rm -rf app components lib public styles *.json *.js *.ts *.mjs *.md .next
|
||||||
|
tar -xzf /tmp/soul_update.tar.gz
|
||||||
|
rm /tmp/soul_update.tar.gz
|
||||||
|
export PATH=/www/server/nodejs/v22.14.0/bin:\$PATH
|
||||||
|
pnpm install
|
||||||
|
pnpm run build
|
||||||
|
pm2 restart soul
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **上传小程序**
|
||||||
|
```bash
|
||||||
|
/Applications/wechatwebdevtools.app/Contents/MacOS/cli upload --project "./miniprogram" -v "版本号" -d "描述"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **打开微信公众平台**
|
||||||
|
```bash
|
||||||
|
open "https://mp.weixin.qq.com/"
|
||||||
|
```
|
||||||
|
在「版本管理」设为体验版测试
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
- 小程序版本号:未发布前保持 1.14,正式发布后递增
|
||||||
|
- 后台部署后需等待约30秒生效
|
||||||
|
- 数据库:腾讯云MySQL,读取优先级:数据库 > 本地文件
|
||||||
21
.dockerignore
Normal file
21
.dockerignore
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# 构建/运行不需要的目录,不打包进镜像
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env*
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# 部署与开发脚本不打包
|
||||||
|
scripts
|
||||||
|
*.sh
|
||||||
|
deploy_config.json
|
||||||
|
deploy_config.example.json
|
||||||
|
requirements-deploy.txt
|
||||||
|
|
||||||
|
# 小程序、文档、附加模块
|
||||||
|
miniprogram
|
||||||
|
开发文档
|
||||||
|
addons
|
||||||
|
book
|
||||||
389
.github-upload-rules.md
Normal file
389
.github-upload-rules.md
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
# GitHub 上传规则
|
||||||
|
|
||||||
|
## 仓库信息
|
||||||
|
|
||||||
|
**仓库地址**: https://github.com/fnvtk/Mycontent
|
||||||
|
**分支**: soul-content
|
||||||
|
**完整地址**: https://github.com/fnvtk/Mycontent/tree/soul-content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速上传命令
|
||||||
|
|
||||||
|
### 一键上传(推荐)
|
||||||
|
```bash
|
||||||
|
cd "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验"
|
||||||
|
git add -A
|
||||||
|
git commit -m "更新内容"
|
||||||
|
git push origin soul-content
|
||||||
|
```
|
||||||
|
|
||||||
|
### 详细上传步骤
|
||||||
|
```bash
|
||||||
|
# 1. 进入项目目录
|
||||||
|
cd "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验"
|
||||||
|
|
||||||
|
# 2. 查看状态
|
||||||
|
git status
|
||||||
|
|
||||||
|
# 3. 添加所有更改
|
||||||
|
git add -A
|
||||||
|
|
||||||
|
# 4. 提交更改(修改提交信息)
|
||||||
|
git commit -m "feat: 更新说明"
|
||||||
|
|
||||||
|
# 5. 推送到GitHub
|
||||||
|
git push origin soul-content
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常用提交信息模板
|
||||||
|
|
||||||
|
### 功能更新
|
||||||
|
```bash
|
||||||
|
git commit -m "feat: 添加新功能
|
||||||
|
|
||||||
|
- 功能1
|
||||||
|
- 功能2
|
||||||
|
- 功能3"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bug修复
|
||||||
|
```bash
|
||||||
|
git commit -m "fix: 修复问题
|
||||||
|
|
||||||
|
- 修复问题1
|
||||||
|
- 修复问题2"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 界面优化
|
||||||
|
```bash
|
||||||
|
git commit -m "style: 界面优化
|
||||||
|
|
||||||
|
- 优化首页
|
||||||
|
- 优化匹配页面
|
||||||
|
- 统一配色"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文档更新
|
||||||
|
```bash
|
||||||
|
git commit -m "docs: 更新文档
|
||||||
|
|
||||||
|
- 更新README
|
||||||
|
- 添加使用说明"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 性能优化
|
||||||
|
```bash
|
||||||
|
git commit -m "perf: 性能优化
|
||||||
|
|
||||||
|
- 优化加载速度
|
||||||
|
- 优化API响应"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Talk功能上传规则
|
||||||
|
|
||||||
|
### Talk内容目录
|
||||||
|
```
|
||||||
|
book/
|
||||||
|
├── 附录/
|
||||||
|
│ └── 附录1|Soul派对房精选对话.md
|
||||||
|
└── talk/ (如果有独立的talk目录)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 上传Talk内容
|
||||||
|
```bash
|
||||||
|
# 1. 添加talk相关文件
|
||||||
|
cd "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验"
|
||||||
|
git add book/附录/附录1|Soul派对房精选对话.md
|
||||||
|
git add book/talk/* # 如果有talk目录
|
||||||
|
|
||||||
|
# 2. 提交
|
||||||
|
git commit -m "feat: 更新Talk内容
|
||||||
|
|
||||||
|
- 添加新的对话记录
|
||||||
|
- 更新精选对话"
|
||||||
|
|
||||||
|
# 3. 推送
|
||||||
|
git push origin soul-content
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 分支管理
|
||||||
|
|
||||||
|
### 查看当前分支
|
||||||
|
```bash
|
||||||
|
git branch
|
||||||
|
```
|
||||||
|
|
||||||
|
### 切换分支
|
||||||
|
```bash
|
||||||
|
git checkout soul-content
|
||||||
|
```
|
||||||
|
|
||||||
|
### 创建新分支
|
||||||
|
```bash
|
||||||
|
git checkout -b new-branch-name
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 同步最新代码
|
||||||
|
|
||||||
|
### 拉取最新代码
|
||||||
|
```bash
|
||||||
|
cd "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验"
|
||||||
|
git pull origin soul-content
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看远程仓库
|
||||||
|
```bash
|
||||||
|
git remote -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回滚操作
|
||||||
|
|
||||||
|
### 撤销未提交的更改
|
||||||
|
```bash
|
||||||
|
git checkout -- <文件名>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 撤销已提交但未推送的提交
|
||||||
|
```bash
|
||||||
|
git reset --soft HEAD^
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看提交历史
|
||||||
|
```bash
|
||||||
|
git log --oneline
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 忽略文件
|
||||||
|
|
||||||
|
### .gitignore 常用配置
|
||||||
|
```
|
||||||
|
# 依赖
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# 测试
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# 生产构建
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production.local
|
||||||
|
.env.development.local
|
||||||
|
|
||||||
|
# 调试日志
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# 操作系统
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# 小程序
|
||||||
|
miniprogram/node_modules/
|
||||||
|
miniprogram/.tea/
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 紧急情况处理
|
||||||
|
|
||||||
|
### 推送失败(冲突)
|
||||||
|
```bash
|
||||||
|
# 1. 拉取最新代码
|
||||||
|
git pull origin soul-content
|
||||||
|
|
||||||
|
# 2. 解决冲突后
|
||||||
|
git add -A
|
||||||
|
git commit -m "fix: 解决冲突"
|
||||||
|
git push origin soul-content
|
||||||
|
```
|
||||||
|
|
||||||
|
### 强制推送(慎用)
|
||||||
|
```bash
|
||||||
|
git push -f origin soul-content
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 标签管理
|
||||||
|
|
||||||
|
### 创建版本标签
|
||||||
|
```bash
|
||||||
|
git tag -a v1.3.1 -m "完美版本:首页对齐H5,64章精准数据"
|
||||||
|
git push origin v1.3.1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查看所有标签
|
||||||
|
```bash
|
||||||
|
git tag
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自动化脚本
|
||||||
|
|
||||||
|
### 快速上传脚本 (quick-push.sh)
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
cd "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验"
|
||||||
|
|
||||||
|
echo "🚀 开始上传到GitHub..."
|
||||||
|
|
||||||
|
# 添加所有更改
|
||||||
|
git add -A
|
||||||
|
|
||||||
|
# 获取提交信息
|
||||||
|
echo "请输入提交信息(留空则使用默认信息):"
|
||||||
|
read commit_msg
|
||||||
|
|
||||||
|
if [ -z "$commit_msg" ]; then
|
||||||
|
commit_msg="update: 更新内容 $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 提交
|
||||||
|
git commit -m "$commit_msg"
|
||||||
|
|
||||||
|
# 推送
|
||||||
|
git push origin soul-content
|
||||||
|
|
||||||
|
echo "✅ 上传完成!"
|
||||||
|
echo "📝 提交信息:$commit_msg"
|
||||||
|
echo "🔗 查看:https://github.com/fnvtk/Mycontent/tree/soul-content"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用方法
|
||||||
|
```bash
|
||||||
|
# 1. 赋予执行权限
|
||||||
|
chmod +x quick-push.sh
|
||||||
|
|
||||||
|
# 2. 执行脚本
|
||||||
|
./quick-push.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 推送时要求输入用户名密码?
|
||||||
|
**A**: 使用GitHub个人访问令牌(Personal Access Token)
|
||||||
|
```bash
|
||||||
|
# 设置远程仓库URL(使用token)
|
||||||
|
git remote set-url origin https://<TOKEN>@github.com/fnvtk/Mycontent.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 文件太大无法推送?
|
||||||
|
**A**: 使用Git LFS
|
||||||
|
```bash
|
||||||
|
git lfs install
|
||||||
|
git lfs track "*.大文件"
|
||||||
|
git add .gitattributes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何删除远程分支?
|
||||||
|
**A**:
|
||||||
|
```bash
|
||||||
|
git push origin --delete branch-name
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
一场soul的创业实验/
|
||||||
|
├── app/ # Next.js应用
|
||||||
|
├── book/ # 书籍内容(64章)
|
||||||
|
│ ├── 序言|...
|
||||||
|
│ ├── 第一篇|真实的人/
|
||||||
|
│ ├── 第二篇|真实的行业/
|
||||||
|
│ ├── 第三篇|真实的错误/
|
||||||
|
│ ├── 第四篇|真实的赚钱/
|
||||||
|
│ ├── 第五篇|真实的社会/
|
||||||
|
│ ├── 尾声|...
|
||||||
|
│ └── 附录/
|
||||||
|
│ └── 附录1|Soul派对房精选对话.md ← Talk内容
|
||||||
|
├── miniprogram/ # 微信小程序
|
||||||
|
├── components/ # 组件
|
||||||
|
├── lib/ # 工具库
|
||||||
|
├── public/ # 静态资源
|
||||||
|
│ └── book-chapters.json # 64章数据
|
||||||
|
├── scripts/ # 脚本
|
||||||
|
│ └── sync-book-content.js
|
||||||
|
├── .gitignore # Git忽略配置
|
||||||
|
├── package.json # 项目配置
|
||||||
|
└── README.md # 项目说明
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下次上传流程
|
||||||
|
|
||||||
|
### 简单三步:
|
||||||
|
```bash
|
||||||
|
# 1. 进入目录
|
||||||
|
cd "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验"
|
||||||
|
|
||||||
|
# 2. 添加并提交
|
||||||
|
git add -A && git commit -m "更新内容"
|
||||||
|
|
||||||
|
# 3. 推送
|
||||||
|
git push origin soul-content
|
||||||
|
```
|
||||||
|
|
||||||
|
### 或使用别名(更快)
|
||||||
|
```bash
|
||||||
|
# 添加到 ~/.zshrc 或 ~/.bashrc
|
||||||
|
alias soul-push='cd "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验" && git add -A && git commit -m "update: 更新内容" && git push origin soul-content'
|
||||||
|
|
||||||
|
# 使用
|
||||||
|
soul-push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 重要提示
|
||||||
|
|
||||||
|
1. ⚠️ **推送前检查**:确保没有敏感信息(密钥、密码等)
|
||||||
|
2. 📝 **提交信息**:写清楚每次更改的内容
|
||||||
|
3. 🔄 **定期备份**:重要节点创建标签
|
||||||
|
4. 🚫 **不要推送**:node_modules、.env等文件
|
||||||
|
5. ✅ **推送后验证**:访问GitHub确认更新成功
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**仓库地址**: https://github.com/fnvtk/Mycontent/tree/soul-content
|
||||||
|
|
||||||
|
**创建时间**: 2026年1月14日
|
||||||
|
**最后更新**: v1.3.1
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
|
.trae/
|
||||||
|
*.log
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# 部署配置(含服务器信息,勿提交)
|
||||||
|
deploy_config.json
|
||||||
|
scripts/deploy_config.json
|
||||||
203
DEPLOYMENT.md
Normal file
203
DEPLOYMENT.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# 部署指南
|
||||||
|
|
||||||
|
## 整站与后台管理端
|
||||||
|
|
||||||
|
本项目是**一个 Next.js 应用**,前台 H5、后台管理、API 都在同一套代码里:
|
||||||
|
|
||||||
|
- **前台**:`/`、`/chapters`、`/read/*`、`/my`、`/match` 等
|
||||||
|
- **后台管理端**:`/admin`、`/admin/login`、`/admin/settings`、`/admin/users` 等
|
||||||
|
- **API**:`/api/*`(含 `/api/admin/*`)
|
||||||
|
|
||||||
|
**部署一次 = 前台 + 后台 + API 一起上线。** 后台无需单独部署,上线后访问:
|
||||||
|
|
||||||
|
- 后台首页:`https://你的域名/admin`
|
||||||
|
- 后台登录:`https://你的域名/admin/login`(账号见项目文档,如 `admin` / `key123456`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目内已有的部署配置
|
||||||
|
|
||||||
|
| 类型 | 文件/目录 | 说明 |
|
||||||
|
|------|------------|------|
|
||||||
|
| 总览文档 | `DEPLOYMENT.md`(本文件) | 部署步骤、环境变量、支付回调 |
|
||||||
|
| Docker | `Dockerfile` | Next.js 独立构建(`output: 'standalone'`) |
|
||||||
|
| Docker 编排 | `docker-compose.yml` | 整站容器、端口 3000、支付/基础环境变量 |
|
||||||
|
| Next 配置 | `next.config.mjs` | `output: 'standalone'` 供 Docker 使用 |
|
||||||
|
| 宝塔一键部署 | `scripts/deploy-to-server.sh` | SSH 到宝塔服务器,拉代码、安装依赖、构建、PM2 重启 |
|
||||||
|
| 宝塔自动化 | `开发文档/8、部署/Next.js自动化部署流程.md` | GitHub Webhook + 宝塔,推送即自动部署 |
|
||||||
|
| NAS 部署 | `deploy_to_nas.sh`、`redeploy.sh`、`quick_deploy.sh` | 部署到 NAS / 内网环境 |
|
||||||
|
| **宝塔部署(跨平台)** | **`scripts/deploy_baota.py`** | **Python 脚本,Windows/Mac/Linux 通用,不依赖 .sh 或 sshpass** |
|
||||||
|
|
||||||
|
无 `vercel.json` 时,Vercel 会按默认规则部署本仓库;若需自定义路由或头信息,可再加 `vercel.json`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 宝塔部署(Python 跨平台)
|
||||||
|
|
||||||
|
本项目在 Mac 上开发,原有一键部署脚本为 `scripts/deploy-to-server.sh`(依赖 sshpass,仅 Linux/Mac)。为在 **Windows / Mac / Linux** 上都能部署到宝塔,提供了 **Python 脚本**,不依赖 shell 或 sshpass。
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
pip install paramiko
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### 2. 配置服务器信息
|
||||||
|
|
||||||
|
复制示例配置并填写真实信息(**不要提交到 Git**):
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
cp scripts/deploy_config.example.json deploy_config.json
|
||||||
|
# 编辑 deploy_config.json,填写 server_host、server_user、project_path、branch、pm2_app_name 等
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
或使用环境变量(不写配置文件时,脚本会提示输入密码):
|
||||||
|
|
||||||
|
- `DEPLOY_HOST`:服务器 IP
|
||||||
|
- `DEPLOY_USER`:SSH 用户名(如 root)
|
||||||
|
- `DEPLOY_PROJECT_PATH`:服务器上项目路径(如 /www/wwwroot/soul)
|
||||||
|
- `DEPLOY_BRANCH`:要部署的分支(如 soul-content)
|
||||||
|
- `DEPLOY_PM2_APP`:PM2 应用名(如 soul)
|
||||||
|
- `DEPLOY_SSH_KEY`:SSH 私钥路径(可选,不填则用密码)
|
||||||
|
|
||||||
|
### 3. 执行部署
|
||||||
|
|
||||||
|
在**项目根目录**执行:
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
python scripts/deploy_baota.py
|
||||||
|
# 或指定配置
|
||||||
|
python scripts/deploy_baota.py --config scripts/deploy_config.json
|
||||||
|
# 仅查看将要执行的步骤(不连接)
|
||||||
|
python scripts/deploy_baota.py --dry-run
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
脚本会依次执行:SSH 连接 → 拉取代码 → 安装依赖 → 构建 → PM2 重启。部署完成后访问:
|
||||||
|
|
||||||
|
- 前台:`https://soul.quwanzhi.com`
|
||||||
|
- 后台:`https://soul.quwanzhi.com/admin`
|
||||||
|
|
||||||
|
### 4. 首次在宝塔上准备
|
||||||
|
|
||||||
|
若服务器上尚未有代码,需先在宝塔上:
|
||||||
|
|
||||||
|
1. 在网站目录(如 `/www/wwwroot/soul`)执行 `git clone <你的仓库> .`,或从本地上传代码。
|
||||||
|
2. 在宝塔「PM2 管理器」中新增项目:项目目录选该路径,启动文件为 `node_modules/next/dist/bin/next` 或 `node server.js`(若使用 standalone 输出),启动参数为 `start -p 3006`(与 `package.json` 里 `start` 端口一致)。
|
||||||
|
3. 配置 Nginx 反向代理到该端口,并绑定域名。
|
||||||
|
4. 之后即可用 `python scripts/deploy_baota.py` 做日常拉代码、构建、重启。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 生产环境部署步骤
|
||||||
|
|
||||||
|
### 1. Vercel部署
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# 安装Vercel CLI
|
||||||
|
npm install -g vercel
|
||||||
|
|
||||||
|
# 登录Vercel
|
||||||
|
vercel login
|
||||||
|
|
||||||
|
# 部署项目
|
||||||
|
vercel --prod
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### 2. 环境变量配置
|
||||||
|
|
||||||
|
在Vercel项目设置中添加以下环境变量:
|
||||||
|
|
||||||
|
**支付宝配置:**
|
||||||
|
- `ALIPAY_PARTNER_ID`: 2088511801157159
|
||||||
|
- `ALIPAY_KEY`: lz6ey1h3kl9zqkgtjz3avb5gk37wzbrp
|
||||||
|
- `ALIPAY_APP_ID`: wx432c93e275548671
|
||||||
|
- `ALIPAY_RETURN_URL`: https://your-domain.com/payment/success
|
||||||
|
- `ALIPAY_NOTIFY_URL`: https://your-domain.com/api/payment/alipay/notify
|
||||||
|
|
||||||
|
**微信支付配置:**
|
||||||
|
- `WECHAT_APP_ID`: wx432c93e275548671
|
||||||
|
- `WECHAT_APP_SECRET`: 25b7e7fdb7998e5107e242ebb6ddabd0
|
||||||
|
- `WECHAT_MCH_ID`: 1318592501
|
||||||
|
- `WECHAT_API_KEY`: wx3e31b068be59ddc131b068be59ddc2
|
||||||
|
- `WECHAT_NOTIFY_URL`: https://your-domain.com/api/payment/wechat/notify
|
||||||
|
|
||||||
|
**基础配置:**
|
||||||
|
- `NEXT_PUBLIC_BASE_URL`: https://your-domain.com
|
||||||
|
|
||||||
|
### 3. 域名配置
|
||||||
|
|
||||||
|
1. 在Vercel项目设置中绑定自定义域名
|
||||||
|
2. 配置DNS记录指向Vercel
|
||||||
|
3. 启用HTTPS(Vercel自动配置SSL证书)
|
||||||
|
|
||||||
|
### 4. 支付回调配置
|
||||||
|
|
||||||
|
**支付宝配置:**
|
||||||
|
1. 登录支付宝开放平台
|
||||||
|
2. 在应用详情中配置异步通知地址:`https://your-domain.com/api/payment/alipay/notify`
|
||||||
|
3. 配置同步返回地址:`https://your-domain.com/payment/success`
|
||||||
|
|
||||||
|
**微信支付配置:**
|
||||||
|
1. 登录微信商户平台
|
||||||
|
2. 在产品中心配置支付回调URL:`https://your-domain.com/api/payment/wechat/notify`
|
||||||
|
3. 添加支付授权域名:`your-domain.com`
|
||||||
|
|
||||||
|
**提现(商家转账到零钱):** 详见 `开发文档/提现功能完整技术文档.md`。需配置:
|
||||||
|
- `WECHAT_MCH_ID`:商户号
|
||||||
|
- `WECHAT_APP_ID`:小程序/公众号 AppID(如 `wxb8bbb2b10dec74aa`)
|
||||||
|
- `WECHAT_API_V3_KEY` 或 `WECHAT_MCH_KEY`:APIv3 密钥(32 字节,用于回调解密)
|
||||||
|
- `WECHAT_KEY_PATH` 或 `WECHAT_MCH_PRIVATE_KEY_PATH`:商户私钥文件路径(apiclient_key.pem)
|
||||||
|
- `WECHAT_MCH_CERT_SERIAL_NO`:商户证书序列号(OpenSSL 从 apiclient_cert.pem 提取)
|
||||||
|
- 商户平台需配置:商家转账到零钱、转账结果通知 URL:`https://你的域名/api/payment/wechat/transfer/notify`
|
||||||
|
|
||||||
|
### 5. 测试流程
|
||||||
|
|
||||||
|
1. 创建测试订单
|
||||||
|
2. 使用沙箱环境测试支付宝支付
|
||||||
|
3. 使用微信开发者工具测试微信支付
|
||||||
|
4. 验证回调接口正常接收
|
||||||
|
5. 确认订单状态更新正确
|
||||||
|
6. 验证内容解锁功能
|
||||||
|
|
||||||
|
### 6. 监控和日志
|
||||||
|
|
||||||
|
- 在Vercel Dashboard查看部署日志
|
||||||
|
- 使用Vercel Analytics监控访问数据
|
||||||
|
- 配置错误告警通知
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 启动开发服务器
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 访问 http://localhost:3000
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### 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/deploy_baota.py`,构建会在**服务器(Linux)**上执行,不会遇到该问题。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 生产环境必须使用HTTPS
|
||||||
|
2. 定期更新支付密钥
|
||||||
|
3. 保护环境变量安全
|
||||||
|
4. 备份用户数据
|
||||||
|
5. 监控支付异常
|
||||||
62
Dockerfile
Normal file
62
Dockerfile
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Next.js 应用 Dockerfile
|
||||||
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
|
# 安装依赖阶段
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制依赖文件
|
||||||
|
COPY package.json package-lock.json* pnpm-lock.yaml* ./
|
||||||
|
|
||||||
|
# 优先使用pnpm,如果没有则使用npm
|
||||||
|
RUN if [ -f pnpm-lock.yaml ]; then \
|
||||||
|
corepack enable && corepack prepare pnpm@latest --activate && \
|
||||||
|
pnpm install --frozen-lockfile; \
|
||||||
|
else \
|
||||||
|
npm ci --legacy-peer-deps || npm install --legacy-peer-deps; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 构建阶段
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 启用corepack(如果需要pnpm)
|
||||||
|
RUN corepack enable || true
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# 设置环境变量
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
# 构建应用 - 优先使用pnpm
|
||||||
|
RUN if [ -f pnpm-lock.yaml ]; then \
|
||||||
|
corepack prepare pnpm@latest --activate && pnpm build; \
|
||||||
|
else \
|
||||||
|
npm run build; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 生产运行阶段
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
# 复制必要文件
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT 3000
|
||||||
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
16
README.md
Normal file
16
README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# 一场SOUL的创业实验
|
||||||
|
|
||||||
|
## 项目简介
|
||||||
|
这是一个基于 Soul 派对房真实商业故事开发的知识付费与分销系统。
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
- 📚 **电子书阅读**: 每日更新的真实商业案例。
|
||||||
|
- 🤝 **找伙伴**: 匹配志同道合的创业合伙人。
|
||||||
|
- 💰 **分销系统**: 90% 高额佣金,推广赚收益。
|
||||||
|
- 👤 **个人中心**: 账号绑定、收益提现、阅读足迹。
|
||||||
|
|
||||||
|
## 技术开发
|
||||||
|
本项目由 **卡若** 开发,核心逻辑与私域系统由 **存客宝** 提供技术支持。
|
||||||
|
|
||||||
|
---
|
||||||
|
*版权所有 © 2026 卡若 & 存客宝*
|
||||||
1
SYNC_LOG.md
Normal file
1
SYNC_LOG.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Mon Dec 29 18:11:24 CST 2025
|
||||||
68
addons/Universal_Payment_Module copy/.cursorrules
Normal file
68
addons/Universal_Payment_Module copy/.cursorrules
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Universal Payment Module - Cursor 规则
|
||||||
|
# 将此文件放在使用支付模块的项目根目录
|
||||||
|
|
||||||
|
## 角色设定
|
||||||
|
你是一位精通全球支付架构的资深全栈工程师,专注于支付网关集成、安全合规和高可用设计。
|
||||||
|
|
||||||
|
当用户提及"支付模块"、"支付功能"、"接入支付"时,请参考 `Universal_Payment_Module` 目录中的设计文档。
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
### 1. 配置驱动
|
||||||
|
- 所有支付密钥通过环境变量配置,绝不硬编码
|
||||||
|
- 使用 `.env` 文件管理配置
|
||||||
|
- 支持多环境切换 (development/staging/production)
|
||||||
|
|
||||||
|
### 2. 工厂模式
|
||||||
|
- 使用 `PaymentFactory` 统一管理支付网关
|
||||||
|
- 每个网关实现统一的 `AbstractGateway` 接口
|
||||||
|
- 支持: alipay/wechat/paypal/stripe/usdt
|
||||||
|
|
||||||
|
### 3. 安全优先
|
||||||
|
- 所有回调必须验证签名
|
||||||
|
- 必须验证支付金额与订单金额匹配
|
||||||
|
- 使用 HTTPS,敏感数据脱敏
|
||||||
|
|
||||||
|
### 4. 幂等性
|
||||||
|
- 支付回调必须支持重复调用
|
||||||
|
- 使用分布式锁防止并发问题
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
```
|
||||||
|
POST /api/payment/create_order - 创建订单
|
||||||
|
POST /api/payment/checkout - 发起支付
|
||||||
|
GET /api/payment/status/{sn} - 查询状态
|
||||||
|
POST /api/payment/notify/{gw} - 回调通知
|
||||||
|
```
|
||||||
|
|
||||||
|
## 统一响应格式
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据库
|
||||||
|
- orders - 订单表
|
||||||
|
- pay_trades - 交易流水表
|
||||||
|
- 金额单位: 数据库用分,API用元
|
||||||
|
|
||||||
|
## 卡若支付配置
|
||||||
|
- 微信商户号: 1318592501
|
||||||
|
- 支付宝PID: 2088511801157159
|
||||||
|
- 详细配置见 `4_卡若配置/.env.example`
|
||||||
|
|
||||||
|
## 禁止事项
|
||||||
|
❌ 密钥硬编码
|
||||||
|
❌ 跳过签名验证
|
||||||
|
❌ 信任前端金额
|
||||||
|
❌ 回调不做幂等
|
||||||
|
❌ 使用 HTTP
|
||||||
|
|
||||||
|
## 参考文档
|
||||||
|
- API定义: `1_核心设计_通用协议/API接口定义.md`
|
||||||
|
- 数据模型: `1_核心设计_通用协议/业务逻辑与模型.md`
|
||||||
|
- 安全规范: `1_核心设计_通用协议/安全与合规.md`
|
||||||
|
- AI指令: `2_智能对接_AI指令/通用集成指令.md`
|
||||||
382
addons/Universal_Payment_Module copy/1_核心设计_通用协议/API接口定义.md
Normal file
382
addons/Universal_Payment_Module copy/1_核心设计_通用协议/API接口定义.md
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
# 通用支付模块 API 接口定义 (Universal Payment API) v4.0
|
||||||
|
|
||||||
|
> 无论后端使用何种语言(Python/Node/Go/Java/PHP),请严格实现以下 RESTful 接口
|
||||||
|
|
||||||
|
## 🎯 设计原则
|
||||||
|
|
||||||
|
1. **RESTful 风格**: 资源命名统一,动词语义清晰
|
||||||
|
2. **统一响应格式**: 所有接口返回 `{code, message, data}` 结构
|
||||||
|
3. **幂等性**: 重复请求不产生副作用
|
||||||
|
4. **安全性**: 敏感操作需签名验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 统一响应格式
|
||||||
|
|
||||||
|
### 成功响应
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误响应
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 400,
|
||||||
|
"message": "参数错误:order_sn 不能为空",
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常用错误码
|
||||||
|
| Code | 含义 |
|
||||||
|
|:---|:---|
|
||||||
|
| 200 | 成功 |
|
||||||
|
| 400 | 请求参数错误 |
|
||||||
|
| 401 | 未授权/登录过期 |
|
||||||
|
| 403 | 无权限 |
|
||||||
|
| 404 | 资源不存在 |
|
||||||
|
| 409 | 状态冲突 (如重复支付) |
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 核心交易接口 (Core Transaction)
|
||||||
|
|
||||||
|
### 1.1 创建订单
|
||||||
|
业务系统调用,创建一个待支付订单。
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/payment/create_order
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"user_id": "u1001", // [必填] 用户ID
|
||||||
|
"title": "VIP会员月卡", // [必填] 订单标题 (≤30字符)
|
||||||
|
"amount": 99.00, // [必填] 金额 (单位: 元)
|
||||||
|
"currency": "CNY", // [可选] 币种,默认 CNY
|
||||||
|
"product_id": "vip_monthly", // [可选] 商品ID
|
||||||
|
"product_type": "membership", // [可选] 商品类型
|
||||||
|
"extra_params": { // [可选] 扩展参数 (会透传到回调)
|
||||||
|
"coupon_id": "C001",
|
||||||
|
"referrer": "user_123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"order_sn": "202401170001", // 系统生成的订单号
|
||||||
|
"status": "created", // 订单状态
|
||||||
|
"amount": 99.00,
|
||||||
|
"expire_at": "2024-01-17T11:30:00Z" // 订单过期时间
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 发起支付 (收银台)
|
||||||
|
用户选择支付方式后,获取支付参数。
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/payment/checkout
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"order_sn": "202401170001", // [必填] 订单号
|
||||||
|
"gateway": "wechat_jsapi", // [必填] 支付网关 (见下方枚举)
|
||||||
|
"return_url": "https://...", // [可选] 支付成功后跳转地址
|
||||||
|
"openid": "oXxx...", // [条件] 微信JSAPI必填
|
||||||
|
"coin_amount": 0 // [可选] 使用虚拟币抵扣金额
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gateway 支付网关枚举**:
|
||||||
|
| Gateway | 说明 | 返回类型 |
|
||||||
|
|:---|:---|:---|
|
||||||
|
| `alipay_web` | 支付宝PC网页 | url (跳转) |
|
||||||
|
| `alipay_wap` | 支付宝H5 | url (跳转) |
|
||||||
|
| `alipay_qr` | 支付宝扫码 | qrcode |
|
||||||
|
| `wechat_native` | 微信扫码 | qrcode |
|
||||||
|
| `wechat_jsapi` | 微信公众号/小程序 | json (SDK参数) |
|
||||||
|
| `wechat_h5` | 微信H5 | url (跳转) |
|
||||||
|
| `wechat_app` | 微信APP | json (SDK参数) |
|
||||||
|
| `paypal` | PayPal | url (跳转) |
|
||||||
|
| `stripe` | Stripe | url (Checkout Session) |
|
||||||
|
| `usdt` | USDT-TRC20 | address (钱包地址) |
|
||||||
|
| `coin` | 纯虚拟币支付 | direct (直接完成) |
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"trade_sn": "T20240117100001", // 交易流水号
|
||||||
|
"type": "qrcode", // 响应类型: url/qrcode/json/address/direct
|
||||||
|
"payload": "weixin://wxpay/...",// 支付数据 (根据type不同)
|
||||||
|
"expiration": 1800, // 过期时间 (秒)
|
||||||
|
"amount": 99.00, // 实际支付金额 (扣除抵扣后)
|
||||||
|
"coin_deducted": 0 // 虚拟币抵扣金额
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**不同 type 的 payload 格式**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// type: "url" - 跳转链接
|
||||||
|
payload: "https://openapi.alipay.com/gateway.do?..."
|
||||||
|
|
||||||
|
// type: "qrcode" - 二维码内容
|
||||||
|
payload: "weixin://wxpay/bizpayurl?pr=xxx"
|
||||||
|
|
||||||
|
// type: "json" - SDK调起参数 (微信JSAPI)
|
||||||
|
payload: {
|
||||||
|
"appId": "wx...",
|
||||||
|
"timeStamp": "1705470600",
|
||||||
|
"nonceStr": "xxx",
|
||||||
|
"package": "prepay_id=wx...",
|
||||||
|
"signType": "RSA",
|
||||||
|
"paySign": "xxx"
|
||||||
|
}
|
||||||
|
|
||||||
|
// type: "address" - 加密货币地址
|
||||||
|
payload: {
|
||||||
|
"address": "TXxx...",
|
||||||
|
"amount_usdt": 13.88,
|
||||||
|
"memo": "202401170001"
|
||||||
|
}
|
||||||
|
|
||||||
|
// type: "direct" - 直接完成 (纯虚拟币支付)
|
||||||
|
payload: { "status": "paid" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 查询订单状态
|
||||||
|
前端轮询使用,判断支付是否完成。
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/payment/status/{order_sn}
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"order_sn": "202401170001",
|
||||||
|
"status": "paid", // created/paying/paid/closed/refunded
|
||||||
|
"paid_amount": 99.00,
|
||||||
|
"paid_at": "2024-01-17T10:05:00Z",
|
||||||
|
"payment_method": "wechat_jsapi",
|
||||||
|
"trade_sn": "T20240117100001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**订单状态机**:
|
||||||
|
```
|
||||||
|
created → paying → paid → (refunded)
|
||||||
|
↓ ↓
|
||||||
|
closed closed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 关闭订单
|
||||||
|
主动关闭未支付的订单。
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/payment/close/{order_sn}
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"order_sn": "202401170001",
|
||||||
|
"status": "closed",
|
||||||
|
"closed_at": "2024-01-17T10:10:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 回调通知接口 (Webhook)
|
||||||
|
|
||||||
|
### 2.1 统一回调入口
|
||||||
|
接收第三方支付平台的异步通知。
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/payment/notify/{gateway}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Path Params**:
|
||||||
|
- `gateway`: `alipay` / `wechat` / `paypal` / `stripe` / `nowpayments`
|
||||||
|
|
||||||
|
**处理逻辑**:
|
||||||
|
1. 根据 gateway 加载对应驱动
|
||||||
|
2. 验签 (Verify Signature)
|
||||||
|
3. 幂等性检查 (防重复处理)
|
||||||
|
4. 更新订单状态
|
||||||
|
5. 触发业务回调 (发货/开通权限等)
|
||||||
|
6. 返回平台所需响应
|
||||||
|
|
||||||
|
**返回格式**:
|
||||||
|
```
|
||||||
|
# 支付宝
|
||||||
|
success
|
||||||
|
|
||||||
|
# 微信
|
||||||
|
<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
HTTP 200 OK
|
||||||
|
|
||||||
|
# PayPal
|
||||||
|
HTTP 200 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 同步返回 (Return)
|
||||||
|
用户支付完成后的页面跳转。
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/payment/return/{gateway}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Params**: 各平台不同,由平台自动附加
|
||||||
|
|
||||||
|
**处理逻辑**:
|
||||||
|
1. 解析回传参数
|
||||||
|
2. 验签
|
||||||
|
3. 重定向到成功页面
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 辅助接口
|
||||||
|
|
||||||
|
### 3.1 获取可用支付方式
|
||||||
|
```http
|
||||||
|
GET /api/payment/methods
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"methods": [
|
||||||
|
{
|
||||||
|
"gateway": "wechat_jsapi",
|
||||||
|
"name": "微信支付",
|
||||||
|
"icon": "/icons/wechat.png",
|
||||||
|
"enabled": true,
|
||||||
|
"available": true // 当前环境是否可用 (如微信内)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gateway": "alipay_wap",
|
||||||
|
"name": "支付宝",
|
||||||
|
"icon": "/icons/alipay.png",
|
||||||
|
"enabled": true,
|
||||||
|
"available": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 获取汇率
|
||||||
|
```http
|
||||||
|
GET /api/payment/exchange_rate?from=CNY&to=USD
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"from": "CNY",
|
||||||
|
"to": "USD",
|
||||||
|
"rate": 0.139,
|
||||||
|
"updated_at": "2024-01-17T00:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 管理接口 (Admin)
|
||||||
|
|
||||||
|
### 4.1 订单列表
|
||||||
|
```http
|
||||||
|
GET /api/admin/payment/orders?page=1&limit=20&status=paid
|
||||||
|
Authorization: Bearer {admin_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 交易流水列表
|
||||||
|
```http
|
||||||
|
GET /api/admin/payment/trades?page=1&limit=20
|
||||||
|
Authorization: Bearer {admin_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 发起退款
|
||||||
|
```http
|
||||||
|
POST /api/admin/payment/refund
|
||||||
|
Authorization: Bearer {admin_token}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"trade_sn": "T20240117100001",
|
||||||
|
"amount": 99.00,
|
||||||
|
"reason": "用户申请退款"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 接口签名规范 (可选)
|
||||||
|
|
||||||
|
对于安全要求高的场景,可启用接口签名:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 请求头
|
||||||
|
X-Sign: sha256(timestamp + nonce + body + secret)
|
||||||
|
X-Timestamp: 1705470600
|
||||||
|
X-Nonce: abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 注意事项
|
||||||
|
|
||||||
|
1. **金额单位**: 所有金额均以**元**为单位,小数点后2位
|
||||||
|
2. **时间格式**: ISO 8601 格式 `YYYY-MM-DDTHH:mm:ssZ`
|
||||||
|
3. **字符编码**: UTF-8
|
||||||
|
4. **HTTPS**: 生产环境必须使用 HTTPS
|
||||||
|
5. **幂等性**: 相同订单号重复请求返回相同结果
|
||||||
396
addons/Universal_Payment_Module copy/1_核心设计_通用协议/业务逻辑与模型.md
Normal file
396
addons/Universal_Payment_Module copy/1_核心设计_通用协议/业务逻辑与模型.md
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
# 业务逻辑与数据模型 (Business Logic & Data Model) v4.0
|
||||||
|
|
||||||
|
> 定义支付系统的核心数据结构和业务流程
|
||||||
|
|
||||||
|
## 📊 数据库表结构
|
||||||
|
|
||||||
|
### 1. 订单表 (orders)
|
||||||
|
|
||||||
|
存储业务订单信息,与支付解耦。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE `orders` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`sn` VARCHAR(32) NOT NULL COMMENT '订单号 (业务唯一)',
|
||||||
|
`user_id` VARCHAR(64) NOT NULL COMMENT '用户ID',
|
||||||
|
`title` VARCHAR(128) NOT NULL COMMENT '订单标题',
|
||||||
|
`price_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订单原价 (分)',
|
||||||
|
`pay_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '应付金额 (分)',
|
||||||
|
`currency` VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '货币类型',
|
||||||
|
`status` VARCHAR(20) NOT NULL DEFAULT 'created' COMMENT '状态: created/paying/paid/closed/refunded',
|
||||||
|
`product_id` VARCHAR(64) DEFAULT NULL COMMENT '商品ID',
|
||||||
|
`product_type` VARCHAR(32) DEFAULT NULL COMMENT '商品类型',
|
||||||
|
`extra_data` JSON DEFAULT NULL COMMENT '扩展数据',
|
||||||
|
`paid_at` DATETIME DEFAULT NULL COMMENT '支付时间',
|
||||||
|
`closed_at` DATETIME DEFAULT NULL COMMENT '关闭时间',
|
||||||
|
`expired_at` DATETIME DEFAULT NULL COMMENT '过期时间',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_sn` (`sn`),
|
||||||
|
KEY `idx_user_id` (`user_id`),
|
||||||
|
KEY `idx_status` (`status`),
|
||||||
|
KEY `idx_created_at` (`created_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 交易流水表 (pay_trades)
|
||||||
|
|
||||||
|
记录每一次支付尝试,一个订单可能有多次交易。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE `pay_trades` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`trade_sn` VARCHAR(32) NOT NULL COMMENT '交易流水号 (系统生成)',
|
||||||
|
`order_sn` VARCHAR(32) NOT NULL COMMENT '关联订单号',
|
||||||
|
`user_id` VARCHAR(64) NOT NULL COMMENT '用户ID',
|
||||||
|
`title` VARCHAR(128) NOT NULL COMMENT '交易标题',
|
||||||
|
`amount` BIGINT UNSIGNED NOT NULL COMMENT '交易金额 (分)',
|
||||||
|
`cash_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '现金支付金额 (分)',
|
||||||
|
`coin_amount` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '虚拟币抵扣金额',
|
||||||
|
`currency` VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '货币类型',
|
||||||
|
`platform` VARCHAR(32) NOT NULL COMMENT '支付平台: alipay/wechat/paypal/stripe/usdt/coin',
|
||||||
|
`platform_type` VARCHAR(32) DEFAULT NULL COMMENT '平台子类型: web/wap/jsapi/native/h5/app',
|
||||||
|
`platform_sn` VARCHAR(64) DEFAULT NULL COMMENT '平台交易号',
|
||||||
|
`platform_created_params` JSON DEFAULT NULL COMMENT '发送给平台的参数',
|
||||||
|
`platform_created_result` JSON DEFAULT NULL COMMENT '平台返回的结果',
|
||||||
|
`status` VARCHAR(20) NOT NULL DEFAULT 'paying' COMMENT '状态: paying/paid/closed/refunded',
|
||||||
|
`type` VARCHAR(20) NOT NULL DEFAULT 'purchase' COMMENT '类型: purchase(购买)/recharge(充值)',
|
||||||
|
`pay_time` DATETIME DEFAULT NULL COMMENT '支付时间',
|
||||||
|
`notify_data` JSON DEFAULT NULL COMMENT '回调原始数据',
|
||||||
|
`seller_id` VARCHAR(64) DEFAULT NULL COMMENT '卖家ID (多商户场景)',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_trade_sn` (`trade_sn`),
|
||||||
|
KEY `idx_order_sn` (`order_sn`),
|
||||||
|
KEY `idx_platform_sn` (`platform_sn`),
|
||||||
|
KEY `idx_user_id` (`user_id`),
|
||||||
|
KEY `idx_status` (`status`),
|
||||||
|
KEY `idx_created_at` (`created_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易流水表';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 资金流水表 (cashflows)
|
||||||
|
|
||||||
|
记录账户资金变动(可选,用于虚拟币/钱包场景)。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE `cashflows` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`sn` VARCHAR(32) NOT NULL COMMENT '流水号',
|
||||||
|
`user_id` VARCHAR(64) NOT NULL COMMENT '用户ID',
|
||||||
|
`type` VARCHAR(20) NOT NULL COMMENT '类型: inflow(入账)/outflow(出账)',
|
||||||
|
`action` VARCHAR(32) NOT NULL COMMENT '动作: recharge/purchase/refund/transfer',
|
||||||
|
`amount` BIGINT NOT NULL COMMENT '金额 (分,正数入账负数出账)',
|
||||||
|
`currency` VARCHAR(8) NOT NULL DEFAULT 'CNY',
|
||||||
|
`balance_before` BIGINT NOT NULL DEFAULT 0 COMMENT '变动前余额',
|
||||||
|
`balance_after` BIGINT NOT NULL DEFAULT 0 COMMENT '变动后余额',
|
||||||
|
`trade_sn` VARCHAR(32) DEFAULT NULL COMMENT '关联交易流水号',
|
||||||
|
`order_sn` VARCHAR(32) DEFAULT NULL COMMENT '关联订单号',
|
||||||
|
`remark` VARCHAR(256) DEFAULT NULL COMMENT '备注',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_sn` (`sn`),
|
||||||
|
KEY `idx_user_id` (`user_id`),
|
||||||
|
KEY `idx_trade_sn` (`trade_sn`),
|
||||||
|
KEY `idx_created_at` (`created_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资金流水表';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 退款记录表 (refunds)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE `refunds` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`refund_sn` VARCHAR(32) NOT NULL COMMENT '退款单号',
|
||||||
|
`trade_sn` VARCHAR(32) NOT NULL COMMENT '原交易流水号',
|
||||||
|
`order_sn` VARCHAR(32) NOT NULL COMMENT '原订单号',
|
||||||
|
`amount` BIGINT UNSIGNED NOT NULL COMMENT '退款金额 (分)',
|
||||||
|
`reason` VARCHAR(256) DEFAULT NULL COMMENT '退款原因',
|
||||||
|
`status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/processing/success/failed',
|
||||||
|
`platform_refund_sn` VARCHAR(64) DEFAULT NULL COMMENT '平台退款单号',
|
||||||
|
`refunded_at` DATETIME DEFAULT NULL COMMENT '退款完成时间',
|
||||||
|
`operator_id` VARCHAR(64) DEFAULT NULL COMMENT '操作人ID',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_refund_sn` (`refund_sn`),
|
||||||
|
KEY `idx_trade_sn` (`trade_sn`),
|
||||||
|
KEY `idx_order_sn` (`order_sn`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退款记录表';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 状态机定义
|
||||||
|
|
||||||
|
### 订单状态 (Order Status)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ created │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────┼───────────┐ │
|
||||||
|
│ ▼ │ ▼ │
|
||||||
|
│ paying ────────┼───────► closed │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ │ │
|
||||||
|
│ paid ─────────┼───────► refunded │
|
||||||
|
│ │ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
状态说明:
|
||||||
|
- created: 订单已创建,等待支付
|
||||||
|
- paying: 支付中 (已发起支付请求)
|
||||||
|
- paid: 已支付
|
||||||
|
- closed: 已关闭 (超时/主动取消)
|
||||||
|
- refunded: 已退款
|
||||||
|
```
|
||||||
|
|
||||||
|
### 交易状态 (Trade Status)
|
||||||
|
|
||||||
|
```
|
||||||
|
paying → paid
|
||||||
|
↓ ↓
|
||||||
|
closed refunded
|
||||||
|
|
||||||
|
状态说明:
|
||||||
|
- paying: 支付中
|
||||||
|
- paid: 支付成功
|
||||||
|
- closed: 交易关闭
|
||||||
|
- refunded: 已退款
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔢 编号规则
|
||||||
|
|
||||||
|
### 订单号 (order_sn)
|
||||||
|
```
|
||||||
|
格式: YYYYMMDD + 6位随机数
|
||||||
|
示例: 202401170001
|
||||||
|
|
||||||
|
生成规则:
|
||||||
|
1. 日期前缀保证每日唯一空间
|
||||||
|
2. 随机数使用分布式ID生成器
|
||||||
|
3. 支持前缀自定义 (如区分业务线)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 交易流水号 (trade_sn)
|
||||||
|
```
|
||||||
|
格式: T + YYYYMMDD + HHmmss + 5位随机数
|
||||||
|
示例: T20240117100530123456
|
||||||
|
|
||||||
|
生成规则:
|
||||||
|
1. 前缀 T 标识交易类型
|
||||||
|
2. 精确到秒的时间戳
|
||||||
|
3. 5位随机数防碰撞
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 核心业务流程
|
||||||
|
|
||||||
|
### 1. 标准支付流程
|
||||||
|
|
||||||
|
```sequence
|
||||||
|
用户 -> 业务系统: 1. 提交订单
|
||||||
|
业务系统 -> 支付模块: 2. 创建订单 (create_order)
|
||||||
|
支付模块 -> 业务系统: 3. 返回 order_sn
|
||||||
|
|
||||||
|
用户 -> 支付模块: 4. 选择支付方式并支付 (checkout)
|
||||||
|
支付模块 -> 支付平台: 5. 创建平台交易
|
||||||
|
支付平台 -> 支付模块: 6. 返回支付参数
|
||||||
|
支付模块 -> 用户: 7. 返回支付数据 (二维码/跳转链接)
|
||||||
|
|
||||||
|
用户 -> 支付平台: 8. 完成支付
|
||||||
|
支付平台 -> 支付模块: 9. 异步回调 (notify)
|
||||||
|
支付模块 -> 支付模块: 10. 验签 + 更新状态
|
||||||
|
支付模块 -> 业务系统: 11. 触发业务回调 (发货/开通)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 支付回调处理流程
|
||||||
|
|
||||||
|
```python
|
||||||
|
def handle_notify(gateway, data):
|
||||||
|
# 1. 加载对应的支付网关驱动
|
||||||
|
driver = PaymentFactory.create(gateway)
|
||||||
|
|
||||||
|
# 2. 验证签名
|
||||||
|
if not driver.verify_sign(data):
|
||||||
|
raise SignatureError("签名验证失败")
|
||||||
|
|
||||||
|
# 3. 解析回调数据
|
||||||
|
parsed = driver.parse_notify(data)
|
||||||
|
trade_sn = parsed['trade_sn']
|
||||||
|
|
||||||
|
# 4. 幂等性检查
|
||||||
|
trade = Trade.get_by_sn(trade_sn)
|
||||||
|
if trade.status == 'paid':
|
||||||
|
return driver.success_response() # 已处理过,直接返回成功
|
||||||
|
|
||||||
|
# 5. 金额校验
|
||||||
|
if parsed['amount'] != trade.cash_amount:
|
||||||
|
raise AmountMismatchError("金额不匹配")
|
||||||
|
|
||||||
|
# 6. 更新交易状态
|
||||||
|
trade.update({
|
||||||
|
'status': 'paid',
|
||||||
|
'platform_sn': parsed['platform_sn'],
|
||||||
|
'pay_time': parsed['pay_time'],
|
||||||
|
'notify_data': data
|
||||||
|
})
|
||||||
|
|
||||||
|
# 7. 更新订单状态
|
||||||
|
order = Order.get_by_sn(trade.order_sn)
|
||||||
|
order.update({'status': 'paid', 'paid_at': now()})
|
||||||
|
|
||||||
|
# 8. 触发业务回调
|
||||||
|
dispatch_event('order.paid', order)
|
||||||
|
|
||||||
|
# 9. 返回成功响应
|
||||||
|
return driver.success_response()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 退款流程
|
||||||
|
|
||||||
|
```python
|
||||||
|
def apply_refund(trade_sn, amount, reason):
|
||||||
|
trade = Trade.get_by_sn(trade_sn)
|
||||||
|
|
||||||
|
# 1. 状态检查
|
||||||
|
if trade.status != 'paid':
|
||||||
|
raise InvalidStatusError("只有已支付的交易可以退款")
|
||||||
|
|
||||||
|
# 2. 创建退款记录
|
||||||
|
refund = Refund.create({
|
||||||
|
'refund_sn': generate_refund_sn(),
|
||||||
|
'trade_sn': trade_sn,
|
||||||
|
'amount': amount,
|
||||||
|
'reason': reason,
|
||||||
|
'status': 'pending'
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. 调用平台退款接口
|
||||||
|
driver = PaymentFactory.create(trade.platform)
|
||||||
|
result = driver.refund({
|
||||||
|
'trade_sn': trade_sn,
|
||||||
|
'refund_sn': refund.refund_sn,
|
||||||
|
'amount': amount
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. 更新状态
|
||||||
|
if result.success:
|
||||||
|
refund.update({'status': 'success', 'refunded_at': now()})
|
||||||
|
trade.update({'status': 'refunded'})
|
||||||
|
else:
|
||||||
|
refund.update({'status': 'failed'})
|
||||||
|
|
||||||
|
return refund
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏭 工厂模式设计
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PaymentFactory:
|
||||||
|
"""支付网关工厂"""
|
||||||
|
|
||||||
|
_drivers = {
|
||||||
|
'alipay': AlipayGateway,
|
||||||
|
'wechat': WechatGateway,
|
||||||
|
'paypal': PayPalGateway,
|
||||||
|
'stripe': StripeGateway,
|
||||||
|
'usdt': USDTGateway,
|
||||||
|
'coin': CoinGateway,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, gateway: str) -> AbstractGateway:
|
||||||
|
gateway_name = gateway.split('_')[0] # wechat_jsapi -> wechat
|
||||||
|
|
||||||
|
if gateway_name not in cls._drivers:
|
||||||
|
raise ValueError(f"不支持的支付网关: {gateway}")
|
||||||
|
|
||||||
|
driver_class = cls._drivers[gateway_name]
|
||||||
|
return driver_class(config=get_payment_config(gateway_name))
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AbstractGateway(ABC):
|
||||||
|
"""支付网关抽象基类"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_trade(self, data: dict) -> dict:
|
||||||
|
"""创建交易"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def verify_sign(self, data: dict) -> bool:
|
||||||
|
"""验证签名"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse_notify(self, data: dict) -> dict:
|
||||||
|
"""解析回调数据"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def refund(self, data: dict) -> RefundResult:
|
||||||
|
"""发起退款"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def query_trade(self, trade_sn: str) -> dict:
|
||||||
|
"""查询交易"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def close_trade(self, trade_sn: str) -> bool:
|
||||||
|
"""关闭交易"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def success_response(self) -> str:
|
||||||
|
"""回调成功响应"""
|
||||||
|
return "success"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💰 金额处理规范
|
||||||
|
|
||||||
|
### 1. 存储规则
|
||||||
|
- 数据库统一使用**分**为单位 (BIGINT)
|
||||||
|
- 避免浮点数精度问题
|
||||||
|
|
||||||
|
### 2. 接口规则
|
||||||
|
- API 输入输出统一使用**元**为单位
|
||||||
|
- 内部转换: `分 = 元 × 100`
|
||||||
|
|
||||||
|
### 3. 转换示例
|
||||||
|
```python
|
||||||
|
# 元转分 (API输入 -> 数据库)
|
||||||
|
def yuan_to_fen(yuan: float) -> int:
|
||||||
|
return int(round(yuan * 100))
|
||||||
|
|
||||||
|
# 分转元 (数据库 -> API输出)
|
||||||
|
def fen_to_yuan(fen: int) -> float:
|
||||||
|
return round(fen / 100, 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 幂等性设计
|
||||||
|
|
||||||
|
### 1. 订单创建幂等
|
||||||
|
- 使用 `(user_id, product_id, created_date)` 组合判断
|
||||||
|
- 或使用客户端传入的幂等键 `idempotency_key`
|
||||||
|
|
||||||
|
### 2. 支付回调幂等
|
||||||
|
- 检查交易状态,已支付则直接返回成功
|
||||||
|
- 使用数据库事务 + 行锁保证并发安全
|
||||||
|
|
||||||
|
### 3. 退款幂等
|
||||||
|
- 同一笔交易只能退款一次 (或限制总退款金额)
|
||||||
383
addons/Universal_Payment_Module copy/1_核心设计_通用协议/安全与合规.md
Normal file
383
addons/Universal_Payment_Module copy/1_核心设计_通用协议/安全与合规.md
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
# 支付安全与合规指南 (Security & Compliance) v4.0
|
||||||
|
|
||||||
|
> 支付系统安全最佳实践,保护你的资金和用户数据
|
||||||
|
|
||||||
|
## 🔐 密钥安全
|
||||||
|
|
||||||
|
### 1. 密钥存储原则
|
||||||
|
|
||||||
|
```
|
||||||
|
❌ 错误做法:
|
||||||
|
- 将密钥硬编码在代码中
|
||||||
|
- 将密钥提交到 Git 仓库
|
||||||
|
- 通过即时通讯工具传输密钥
|
||||||
|
- 使用弱密码作为 API Key
|
||||||
|
|
||||||
|
✅ 正确做法:
|
||||||
|
- 使用环境变量存储密钥
|
||||||
|
- 使用专业密钥管理服务 (AWS KMS, HashiCorp Vault)
|
||||||
|
- 定期轮换密钥
|
||||||
|
- 最小权限原则
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. .gitignore 必须包含
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
# 支付密钥相关
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
cert/
|
||||||
|
config/payment.yml
|
||||||
|
secrets/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 密钥轮换
|
||||||
|
|
||||||
|
- 定期更换 API 密钥 (建议每 90 天)
|
||||||
|
- 发现泄露立即作废并重新生成
|
||||||
|
- 保留旧密钥短暂过渡期
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 通信安全
|
||||||
|
|
||||||
|
### 1. HTTPS 强制
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# Nginx 配置示例
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
ssl_certificate /path/to/fullchain.pem;
|
||||||
|
ssl_certificate_key /path/to/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||||
|
|
||||||
|
# HSTS
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 证书管理
|
||||||
|
|
||||||
|
- 使用受信任的 CA 签发证书
|
||||||
|
- 定期检查证书有效期
|
||||||
|
- 推荐 Let's Encrypt 自动续期
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 签名验证
|
||||||
|
|
||||||
|
### 1. 支付宝签名验证
|
||||||
|
|
||||||
|
```python
|
||||||
|
from Crypto.PublicKey import RSA
|
||||||
|
from Crypto.Signature import PKCS1_v1_5
|
||||||
|
from Crypto.Hash import SHA256
|
||||||
|
import base64
|
||||||
|
|
||||||
|
def verify_alipay_sign(params: dict, sign: str, public_key: str) -> bool:
|
||||||
|
"""验证支付宝签名"""
|
||||||
|
# 1. 参数排序
|
||||||
|
sorted_params = sorted([(k, v) for k, v in params.items() if k != 'sign' and v])
|
||||||
|
|
||||||
|
# 2. 拼接待签名字符串
|
||||||
|
sign_str = '&'.join([f'{k}={v}' for k, v in sorted_params])
|
||||||
|
|
||||||
|
# 3. RSA2 验签
|
||||||
|
key = RSA.import_key(f"-----BEGIN PUBLIC KEY-----\n{public_key}\n-----END PUBLIC KEY-----")
|
||||||
|
verifier = PKCS1_v1_5.new(key)
|
||||||
|
hash_obj = SHA256.new(sign_str.encode('utf-8'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
verifier.verify(hash_obj, base64.b64decode(sign))
|
||||||
|
return True
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 微信签名验证
|
||||||
|
|
||||||
|
```python
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
def verify_wechat_sign(params: dict, sign: str, api_key: str) -> bool:
|
||||||
|
"""验证微信支付签名"""
|
||||||
|
# 1. 参数排序
|
||||||
|
sorted_params = sorted([(k, v) for k, v in params.items() if k != 'sign' and v])
|
||||||
|
|
||||||
|
# 2. 拼接待签名字符串
|
||||||
|
sign_str = '&'.join([f'{k}={v}' for k, v in sorted_params])
|
||||||
|
sign_str += f'&key={api_key}'
|
||||||
|
|
||||||
|
# 3. MD5 签名
|
||||||
|
calculated_sign = hashlib.md5(sign_str.encode('utf-8')).hexdigest().upper()
|
||||||
|
|
||||||
|
return calculated_sign == sign
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💰 金额校验
|
||||||
|
|
||||||
|
### 1. 回调金额必须验证
|
||||||
|
|
||||||
|
```python
|
||||||
|
def handle_payment_notify(trade_sn: str, paid_amount: int):
|
||||||
|
"""处理支付回调时必须验证金额"""
|
||||||
|
trade = get_trade_by_sn(trade_sn)
|
||||||
|
|
||||||
|
# 金额必须严格匹配
|
||||||
|
if paid_amount != trade.cash_amount:
|
||||||
|
log.error(f"金额不匹配! 订单:{trade.cash_amount}, 回调:{paid_amount}")
|
||||||
|
raise AmountMismatchError()
|
||||||
|
|
||||||
|
# 继续处理...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 防止金额篡改
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 前端传入的金额仅用于展示,实际金额从后端订单读取
|
||||||
|
def checkout(order_sn: str, gateway: str):
|
||||||
|
order = get_order(order_sn)
|
||||||
|
|
||||||
|
# 金额从数据库读取,不信任前端
|
||||||
|
amount = order.pay_amount
|
||||||
|
|
||||||
|
return create_trade(order_sn, amount, gateway)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ 回调安全
|
||||||
|
|
||||||
|
### 1. IP 白名单
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 支付平台回调 IP 白名单
|
||||||
|
PAYMENT_IP_WHITELIST = {
|
||||||
|
'alipay': [
|
||||||
|
'110.75.0.0/16',
|
||||||
|
'203.209.0.0/16'
|
||||||
|
],
|
||||||
|
'wechat': [
|
||||||
|
'101.226.0.0/16',
|
||||||
|
'140.207.0.0/16'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def verify_callback_ip(gateway: str, client_ip: str) -> bool:
|
||||||
|
"""验证回调来源 IP"""
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
whitelist = PAYMENT_IP_WHITELIST.get(gateway, [])
|
||||||
|
client = ipaddress.ip_address(client_ip)
|
||||||
|
|
||||||
|
for cidr in whitelist:
|
||||||
|
if client in ipaddress.ip_network(cidr):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 防重放攻击
|
||||||
|
|
||||||
|
```python
|
||||||
|
import time
|
||||||
|
|
||||||
|
def check_notify_timestamp(timestamp: int) -> bool:
|
||||||
|
"""检查回调时间戳,防止重放攻击"""
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
# 允许 5 分钟的时间差
|
||||||
|
if abs(now - timestamp) > 300:
|
||||||
|
log.warning(f"回调时间戳异常: {timestamp}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 幂等性处理
|
||||||
|
|
||||||
|
```python
|
||||||
|
def process_notify_idempotent(trade_sn: str, notify_data: dict):
|
||||||
|
"""幂等性处理回调"""
|
||||||
|
|
||||||
|
# 使用分布式锁
|
||||||
|
lock_key = f"payment_notify:{trade_sn}"
|
||||||
|
|
||||||
|
with redis_lock(lock_key, timeout=10):
|
||||||
|
trade = get_trade_by_sn(trade_sn)
|
||||||
|
|
||||||
|
# 已处理过,直接返回成功
|
||||||
|
if trade.status == 'paid':
|
||||||
|
return success_response()
|
||||||
|
|
||||||
|
# 处理支付成功逻辑
|
||||||
|
update_trade_to_paid(trade, notify_data)
|
||||||
|
|
||||||
|
return success_response()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 日志审计
|
||||||
|
|
||||||
|
### 1. 必须记录的日志
|
||||||
|
|
||||||
|
```python
|
||||||
|
import logging
|
||||||
|
|
||||||
|
payment_logger = logging.getLogger('payment')
|
||||||
|
|
||||||
|
# 创建交易日志
|
||||||
|
payment_logger.info(f"创建交易 | trade_sn={trade_sn} | order_sn={order_sn} | amount={amount} | gateway={gateway}")
|
||||||
|
|
||||||
|
# 回调日志
|
||||||
|
payment_logger.info(f"收到回调 | gateway={gateway} | trade_sn={trade_sn} | raw_data={raw_data[:500]}")
|
||||||
|
|
||||||
|
# 签名验证日志
|
||||||
|
payment_logger.info(f"签名验证 | trade_sn={trade_sn} | result={verify_result}")
|
||||||
|
|
||||||
|
# 状态变更日志
|
||||||
|
payment_logger.info(f"状态变更 | trade_sn={trade_sn} | from={old_status} | to={new_status}")
|
||||||
|
|
||||||
|
# 退款日志
|
||||||
|
payment_logger.info(f"发起退款 | refund_sn={refund_sn} | trade_sn={trade_sn} | amount={amount}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 敏感信息脱敏
|
||||||
|
|
||||||
|
```python
|
||||||
|
def mask_sensitive(data: dict) -> dict:
|
||||||
|
"""敏感信息脱敏"""
|
||||||
|
sensitive_keys = ['card_no', 'id_card', 'phone', 'bank_account']
|
||||||
|
|
||||||
|
masked = data.copy()
|
||||||
|
for key in sensitive_keys:
|
||||||
|
if key in masked:
|
||||||
|
value = str(masked[key])
|
||||||
|
if len(value) > 4:
|
||||||
|
masked[key] = value[:2] + '*' * (len(value) - 4) + value[-2:]
|
||||||
|
|
||||||
|
return masked
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 异常处理
|
||||||
|
|
||||||
|
### 1. 支付异常分类
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PaymentError(Exception):
|
||||||
|
"""支付基础异常"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class SignatureError(PaymentError):
|
||||||
|
"""签名验证失败"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class AmountMismatchError(PaymentError):
|
||||||
|
"""金额不匹配"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class OrderExpiredError(PaymentError):
|
||||||
|
"""订单已过期"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class DuplicatePaymentError(PaymentError):
|
||||||
|
"""重复支付"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class RefundError(PaymentError):
|
||||||
|
"""退款失败"""
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 统一异常处理
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.exception_handler(PaymentError)
|
||||||
|
async def payment_exception_handler(request, exc):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={
|
||||||
|
"code": 400,
|
||||||
|
"message": str(exc),
|
||||||
|
"data": None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 合规要求
|
||||||
|
|
||||||
|
### 1. PCI DSS 合规 (信用卡)
|
||||||
|
|
||||||
|
- 不存储完整卡号、CVV、PIN
|
||||||
|
- 使用 Stripe/PayPal 等符合 PCI DSS 的支付网关
|
||||||
|
- 定期安全评估
|
||||||
|
|
||||||
|
### 2. GDPR 合规 (欧盟用户)
|
||||||
|
|
||||||
|
- 明确告知用户数据用途
|
||||||
|
- 提供数据删除功能
|
||||||
|
- 用户同意授权
|
||||||
|
|
||||||
|
### 3. 中国支付合规
|
||||||
|
|
||||||
|
- 接入持牌支付机构
|
||||||
|
- 实名认证
|
||||||
|
- 交易限额管理
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 安全检查清单
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 上线前安全检查
|
||||||
|
|
||||||
|
### 密钥管理
|
||||||
|
- [ ] 所有密钥通过环境变量配置
|
||||||
|
- [ ] 密钥未提交到代码仓库
|
||||||
|
- [ ] 生产环境密钥与测试环境隔离
|
||||||
|
|
||||||
|
### 通信安全
|
||||||
|
- [ ] 启用 HTTPS
|
||||||
|
- [ ] 证书有效且受信任
|
||||||
|
- [ ] 启用 HSTS
|
||||||
|
|
||||||
|
### 签名验证
|
||||||
|
- [ ] 所有回调验签
|
||||||
|
- [ ] 验签失败拒绝处理
|
||||||
|
|
||||||
|
### 金额校验
|
||||||
|
- [ ] 回调金额与订单金额比对
|
||||||
|
- [ ] 金额从后端读取
|
||||||
|
|
||||||
|
### 日志审计
|
||||||
|
- [ ] 关键操作有日志
|
||||||
|
- [ ] 敏感信息脱敏
|
||||||
|
|
||||||
|
### 异常处理
|
||||||
|
- [ ] 异常不泄露敏感信息
|
||||||
|
- [ ] 有统一异常处理
|
||||||
|
|
||||||
|
### 回调安全
|
||||||
|
- [ ] IP 白名单验证 (可选)
|
||||||
|
- [ ] 幂等性处理
|
||||||
|
- [ ] 防重放攻击
|
||||||
|
```
|
||||||
133
addons/Universal_Payment_Module copy/1_核心设计_通用协议/标准配置模板.yaml
Normal file
133
addons/Universal_Payment_Module copy/1_核心设计_通用协议/标准配置模板.yaml
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# 全球支付模块标准配置模板 (Universal Payment Config Template) v4.0
|
||||||
|
# ============================================================================
|
||||||
|
# 适用于: Python, Node.js, Go, Java, PHP 等任意后端语言
|
||||||
|
# 使用方法: 将此配置映射到你项目的环境变量 (.env, config.py, application.yml)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 1. 基础环境 (Environment)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
APP_ENV: "production" # development / staging / production
|
||||||
|
APP_NAME: "MyApp" # 应用名称 (用于日志/标题)
|
||||||
|
APP_URL: "https://your-site.com" # 你的网站域名 (用于回调地址生成)
|
||||||
|
APP_CURRENCY: "CNY" # 默认货币: CNY, USD, EUR
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 2. 数据库 (Database) - 存储订单和交易流水
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
DB_CONNECTION: "mysql" # mysql / postgres / mongodb / sqlite
|
||||||
|
DB_HOST: "127.0.0.1"
|
||||||
|
DB_PORT: "3306"
|
||||||
|
DB_DATABASE: "payment_db"
|
||||||
|
DB_USERNAME: "root"
|
||||||
|
DB_PASSWORD: "your_password"
|
||||||
|
|
||||||
|
# 自动创建的表:
|
||||||
|
# - orders (订单表)
|
||||||
|
# - pay_trades (交易流水表)
|
||||||
|
# - cashflows (资金流水表)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 3. 支付宝 (Alipay) - 中国市场
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
ALIPAY_ENABLED: true
|
||||||
|
ALIPAY_MODE: "production" # sandbox / production
|
||||||
|
ALIPAY_APP_ID: "" # 开放平台应用 AppID
|
||||||
|
ALIPAY_PID: "" # 商户 PID (合作伙伴ID)
|
||||||
|
ALIPAY_SELLER_EMAIL: "" # 收款支付宝账号
|
||||||
|
ALIPAY_PRIVATE_KEY: "" # 商户私钥 (RSA2)
|
||||||
|
ALIPAY_PUBLIC_KEY: "" # 支付宝公钥
|
||||||
|
ALIPAY_MD5_KEY: "" # MD5 密钥 (旧版接口)
|
||||||
|
|
||||||
|
# 回调地址 (系统自动拼接 APP_URL)
|
||||||
|
# 同步回调: ${APP_URL}/api/payment/return/alipay
|
||||||
|
# 异步回调: ${APP_URL}/api/payment/notify/alipay
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 4. 微信支付 (Wechat Pay) - 中国市场
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
WECHAT_ENABLED: true
|
||||||
|
WECHAT_MODE: "production" # sandbox / production
|
||||||
|
|
||||||
|
# 公众号/网站支付
|
||||||
|
WECHAT_APPID: "" # 公众号/网站 AppID
|
||||||
|
WECHAT_APP_SECRET: "" # AppSecret
|
||||||
|
|
||||||
|
# 服务号 (如果有独立服务号)
|
||||||
|
WECHAT_SERVICE_APPID: "" # 服务号 AppID
|
||||||
|
WECHAT_SERVICE_SECRET: "" # 服务号 AppSecret
|
||||||
|
|
||||||
|
# 商户信息
|
||||||
|
WECHAT_MCH_ID: "" # 商户号
|
||||||
|
WECHAT_MCH_KEY: "" # 商户平台 API 密钥 (32位)
|
||||||
|
WECHAT_MCH_KEY_V3: "" # APIv3 密钥 (如使用v3接口)
|
||||||
|
|
||||||
|
# 证书路径 (相对于项目根目录)
|
||||||
|
WECHAT_CERT_PATH: "./cert/wechat/apiclient_cert.pem"
|
||||||
|
WECHAT_KEY_PATH: "./cert/wechat/apiclient_key.pem"
|
||||||
|
WECHAT_CERT_SERIAL: "" # 证书序列号 (v3接口需要)
|
||||||
|
|
||||||
|
# 小程序 (如果有)
|
||||||
|
WECHAT_MINI_APPID: "" # 小程序 AppID
|
||||||
|
WECHAT_MINI_SECRET: "" # 小程序 AppSecret
|
||||||
|
|
||||||
|
# 回调地址
|
||||||
|
# 异步回调: ${APP_URL}/api/payment/notify/wechat
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 5. PayPal - 全球市场
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
PAYPAL_ENABLED: true
|
||||||
|
PAYPAL_MODE: "live" # sandbox / live
|
||||||
|
PAYPAL_CLIENT_ID: "" # Client ID
|
||||||
|
PAYPAL_CLIENT_SECRET: "" # Client Secret
|
||||||
|
PAYPAL_WEBHOOK_ID: "" # Webhook ID (用于验证回调)
|
||||||
|
|
||||||
|
# 回调地址: ${APP_URL}/api/payment/notify/paypal
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 6. Stripe - 全球市场
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
STRIPE_ENABLED: true
|
||||||
|
STRIPE_MODE: "live" # test / live
|
||||||
|
STRIPE_PUBLIC_KEY: "" # pk_live_xxx 或 pk_test_xxx
|
||||||
|
STRIPE_SECRET_KEY: "" # sk_live_xxx 或 sk_test_xxx
|
||||||
|
STRIPE_WEBHOOK_SECRET: "" # whsec_xxx
|
||||||
|
|
||||||
|
# 回调地址: ${APP_URL}/api/payment/notify/stripe
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 7. USDT (加密货币) - Web3 / 抗审查支付
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
USDT_ENABLED: false
|
||||||
|
USDT_GATEWAY_TYPE: "nowpayments" # nowpayments / native
|
||||||
|
|
||||||
|
# 选项 A: NOWPayments (第三方托管)
|
||||||
|
NOWPAYMENTS_API_KEY: ""
|
||||||
|
NOWPAYMENTS_IPN_SECRET: ""
|
||||||
|
|
||||||
|
# 选项 B: Native (原生 TRC20 监听)
|
||||||
|
TRON_NODE_API: "https://api.trongrid.io"
|
||||||
|
TRON_WALLET_ADDRESS: "" # 你的 USDT-TRC20 收款地址
|
||||||
|
TRON_API_KEY: "" # TronGrid API Key
|
||||||
|
TRON_CHECK_INTERVAL: 60 # 轮询间隔 (秒)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 8. 高级配置 (Advanced)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 虚拟币/积分系统
|
||||||
|
COIN_ENABLED: false # 是否启用虚拟币抵扣
|
||||||
|
COIN_RATE: 100 # 1元 = 100虚拟币
|
||||||
|
|
||||||
|
# 订单配置
|
||||||
|
ORDER_EXPIRE_MINUTES: 30 # 订单过期时间 (分钟)
|
||||||
|
TRADE_SN_PREFIX: "T" # 交易流水号前缀
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
PAYMENT_LOG_LEVEL: "info" # debug / info / warning / error
|
||||||
|
PAYMENT_LOG_PATH: "./logs/payment.log"
|
||||||
|
|
||||||
|
# 安全配置
|
||||||
|
PAYMENT_IP_WHITELIST: "" # 回调IP白名单 (逗号分隔)
|
||||||
|
PAYMENT_SIGN_TYPE: "RSA2" # 签名类型: RSA2, MD5
|
||||||
266
addons/Universal_Payment_Module copy/2_智能对接_AI指令/Cursor规则.md
Normal file
266
addons/Universal_Payment_Module copy/2_智能对接_AI指令/Cursor规则.md
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# Universal Payment Module - Cursor 规则
|
||||||
|
|
||||||
|
> 将此文件复制为项目根目录的 `.cursorrules` 或 `.cursor/rules/payment.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 基础设定
|
||||||
|
|
||||||
|
你是一位精通全球支付架构的资深全栈工程师,专注于支付网关集成、安全合规和高可用设计。
|
||||||
|
|
||||||
|
当用户提及"支付模块"、"支付功能"、"接入支付"时,请参考 `Universal_Payment_Module` 目录中的设计文档。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
### 1. 配置驱动
|
||||||
|
- 所有支付密钥通过环境变量配置,绝不硬编码
|
||||||
|
- 使用 `.env` 文件管理配置,参考 `标准配置模板.yaml`
|
||||||
|
- 支持多环境切换 (development/staging/production)
|
||||||
|
|
||||||
|
### 2. 工厂模式
|
||||||
|
- 使用 `PaymentFactory` 统一管理支付网关
|
||||||
|
- 每个网关实现统一的 `AbstractGateway` 接口
|
||||||
|
- 支持动态添加新的支付渠道
|
||||||
|
|
||||||
|
### 3. 安全优先
|
||||||
|
- 所有回调必须验证签名
|
||||||
|
- 必须验证支付金额与订单金额匹配
|
||||||
|
- 使用 HTTPS,敏感数据脱敏
|
||||||
|
- 参考 `安全与合规.md`
|
||||||
|
|
||||||
|
### 4. 幂等性
|
||||||
|
- 支付回调必须支持重复调用
|
||||||
|
- 使用分布式锁防止并发问题
|
||||||
|
- 检查交易状态后再处理
|
||||||
|
|
||||||
|
### 5. 状态机
|
||||||
|
- 订单状态: created → paying → paid → refunded/closed
|
||||||
|
- 状态变更必须记录日志
|
||||||
|
- 不允许跳跃式状态变更
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 规范
|
||||||
|
|
||||||
|
严格按照 `API接口定义.md` 实现以下接口:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/payment/create_order - 创建订单
|
||||||
|
POST /api/payment/checkout - 发起支付
|
||||||
|
GET /api/payment/status/{sn} - 查询状态
|
||||||
|
POST /api/payment/close/{sn} - 关闭订单
|
||||||
|
POST /api/payment/notify/{gw} - 回调通知
|
||||||
|
GET /api/payment/return/{gw} - 同步返回
|
||||||
|
GET /api/payment/methods - 获取支付方式
|
||||||
|
```
|
||||||
|
|
||||||
|
### 统一响应格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 数据库模型
|
||||||
|
|
||||||
|
参考 `业务逻辑与模型.md`,必须创建以下表:
|
||||||
|
|
||||||
|
1. **orders** - 订单表 (业务订单)
|
||||||
|
2. **pay_trades** - 交易流水表 (支付记录)
|
||||||
|
3. **cashflows** - 资金流水表 (可选)
|
||||||
|
4. **refunds** - 退款记录表
|
||||||
|
|
||||||
|
金额单位:
|
||||||
|
- 数据库存储使用**分** (BIGINT)
|
||||||
|
- API 输入输出使用**元** (float)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 支付网关 Gateway
|
||||||
|
|
||||||
|
### 支付宝 Alipay
|
||||||
|
- SDK: `alipay-sdk-python` / `alipay-sdk-java` / `alipay/aop-sdk`
|
||||||
|
- 签名: RSA2
|
||||||
|
- 回调: POST,form 表单格式
|
||||||
|
|
||||||
|
### 微信支付 Wechat
|
||||||
|
- SDK: `wechatpay-python-v3` / `wechatpay-java`
|
||||||
|
- 签名: HMAC-SHA256 / RSA (v3)
|
||||||
|
- 回调: POST,XML 格式
|
||||||
|
|
||||||
|
### PayPal
|
||||||
|
- SDK: `paypal-rest-sdk`
|
||||||
|
- 认证: OAuth 2.0
|
||||||
|
- 回调: Webhook,JSON 格式
|
||||||
|
|
||||||
|
### Stripe
|
||||||
|
- SDK: `stripe`
|
||||||
|
- 认证: API Key
|
||||||
|
- 回调: Webhook,JSON 格式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 代码生成模板
|
||||||
|
|
||||||
|
### Python FastAPI
|
||||||
|
|
||||||
|
```python
|
||||||
|
# app/services/payment_factory.py
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
class AbstractGateway(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def create_trade(self, data: dict) -> dict:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def verify_sign(self, data: dict) -> bool:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse_notify(self, data: dict) -> dict:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class PaymentFactory:
|
||||||
|
_gateways = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register(cls, name: str, gateway_class):
|
||||||
|
cls._gateways[name] = gateway_class
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, name: str) -> AbstractGateway:
|
||||||
|
gateway_name = name.split('_')[0]
|
||||||
|
if gateway_name not in cls._gateways:
|
||||||
|
raise ValueError(f"不支持的支付网关: {name}")
|
||||||
|
return cls._gateways[gateway_name]()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node.js Express
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/services/PaymentFactory.ts
|
||||||
|
export abstract class AbstractGateway {
|
||||||
|
abstract createTrade(data: CreateTradeDTO): Promise<TradeResult>;
|
||||||
|
abstract verifySign(data: Record<string, any>): boolean;
|
||||||
|
abstract parseNotify(data: Record<string, any>): NotifyResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PaymentFactory {
|
||||||
|
private static gateways: Map<string, new () => AbstractGateway> = new Map();
|
||||||
|
|
||||||
|
static register(name: string, gateway: new () => AbstractGateway) {
|
||||||
|
this.gateways.set(name, gateway);
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(name: string): AbstractGateway {
|
||||||
|
const gatewayName = name.split('_')[0];
|
||||||
|
const GatewayClass = this.gateways.get(gatewayName);
|
||||||
|
if (!GatewayClass) {
|
||||||
|
throw new Error(`不支持的支付网关: ${name}`);
|
||||||
|
}
|
||||||
|
return new GatewayClass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回调处理模板
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def handle_notify(gateway: str, raw_data: bytes | dict):
|
||||||
|
"""统一回调处理"""
|
||||||
|
|
||||||
|
# 1. 获取网关驱动
|
||||||
|
driver = PaymentFactory.create(gateway)
|
||||||
|
|
||||||
|
# 2. 验证签名
|
||||||
|
if not driver.verify_sign(raw_data):
|
||||||
|
logger.error(f"签名验证失败 | gateway={gateway}")
|
||||||
|
raise SignatureError()
|
||||||
|
|
||||||
|
# 3. 解析数据
|
||||||
|
parsed = driver.parse_notify(raw_data)
|
||||||
|
trade_sn = parsed['trade_sn']
|
||||||
|
|
||||||
|
# 4. 幂等检查
|
||||||
|
async with redis_lock(f"notify:{trade_sn}"):
|
||||||
|
trade = await get_trade(trade_sn)
|
||||||
|
|
||||||
|
if trade.status == 'paid':
|
||||||
|
return driver.success_response()
|
||||||
|
|
||||||
|
# 5. 金额校验
|
||||||
|
if parsed['amount'] != trade.cash_amount:
|
||||||
|
logger.error(f"金额不匹配 | sn={trade_sn}")
|
||||||
|
raise AmountMismatchError()
|
||||||
|
|
||||||
|
# 6. 更新状态
|
||||||
|
await update_trade_status(trade_sn, 'paid', parsed)
|
||||||
|
|
||||||
|
# 7. 触发业务回调
|
||||||
|
await dispatch_event('order.paid', trade.order_sn)
|
||||||
|
|
||||||
|
return driver.success_response()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 日志规范
|
||||||
|
|
||||||
|
关键操作必须记录日志:
|
||||||
|
|
||||||
|
```python
|
||||||
|
logger.info(f"创建交易 | trade_sn={sn} | order={order_sn} | amount={amount}")
|
||||||
|
logger.info(f"收到回调 | gateway={gw} | trade_sn={sn}")
|
||||||
|
logger.info(f"签名验证 | trade_sn={sn} | result={ok}")
|
||||||
|
logger.info(f"状态变更 | trade_sn={sn} | {old} → {new}")
|
||||||
|
logger.error(f"支付失败 | trade_sn={sn} | error={err}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试规范
|
||||||
|
|
||||||
|
1. **单元测试**: 测试签名生成/验证、金额转换
|
||||||
|
2. **集成测试**: 使用沙箱环境测试完整支付流程
|
||||||
|
3. **Mock 测试**: 模拟回调接口测试
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 禁止事项
|
||||||
|
|
||||||
|
❌ 密钥硬编码在代码中
|
||||||
|
❌ 跳过签名验证
|
||||||
|
❌ 信任前端传入的金额
|
||||||
|
❌ 回调不做幂等处理
|
||||||
|
❌ 敏感信息打印到日志
|
||||||
|
❌ 使用 HTTP 而非 HTTPS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考文档路径
|
||||||
|
|
||||||
|
```
|
||||||
|
Universal_Payment_Module/
|
||||||
|
├── 1_核心设计_通用协议/
|
||||||
|
│ ├── 标准配置模板.yaml # 配置参考
|
||||||
|
│ ├── API接口定义.md # 接口规范
|
||||||
|
│ ├── 业务逻辑与模型.md # 数据模型
|
||||||
|
│ └── 安全与合规.md # 安全规范
|
||||||
|
├── 2_智能对接_AI指令/
|
||||||
|
│ ├── 通用集成指令.md # AI 提示词
|
||||||
|
│ └── Cursor规则.md # 本文件
|
||||||
|
└── 3_逻辑参考_通用实现/
|
||||||
|
├── 前端收银台Demo.html
|
||||||
|
└── 后端源码/
|
||||||
|
```
|
||||||
234
addons/Universal_Payment_Module copy/2_智能对接_AI指令/通用集成指令.md
Normal file
234
addons/Universal_Payment_Module copy/2_智能对接_AI指令/通用集成指令.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
# 通用支付模块 AI 智能对接指令 (Integration Prompt) v4.0
|
||||||
|
|
||||||
|
> 发送此指令给 AI 助手 (Cursor/ChatGPT/Claude),自动生成支付集成代码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 角色设定
|
||||||
|
|
||||||
|
```
|
||||||
|
你是一位精通全球支付架构的资深全栈架构师,专注于:
|
||||||
|
- 支付网关集成 (Alipay/Wechat/PayPal/Stripe/USDT)
|
||||||
|
- 安全合规 (签名验证/HTTPS/PCI DSS)
|
||||||
|
- 高可用设计 (幂等性/状态机/分布式锁)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 任务目标
|
||||||
|
|
||||||
|
我提供了一个**配置驱动 (Configuration-Driven)** 的通用支付模块设计。
|
||||||
|
请根据我的项目环境,将此支付功能无缝集成。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 核心资源 (请先阅读)
|
||||||
|
|
||||||
|
1. **标准配置模板**: `1_核心设计_通用协议/标准配置模板.yaml`
|
||||||
|
2. **API 接口契约**: `1_核心设计_通用协议/API接口定义.md`
|
||||||
|
3. **数据模型**: `1_核心设计_通用协议/业务逻辑与模型.md`
|
||||||
|
4. **安全规范**: `1_核心设计_通用协议/安全与合规.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 集成模式
|
||||||
|
|
||||||
|
### 模式 A: 嵌入式集成 (Library Mode) ⭐推荐
|
||||||
|
|
||||||
|
适用于将支付功能直接写在现有的后端项目中。
|
||||||
|
|
||||||
|
**执行步骤**:
|
||||||
|
1. **环境识别**: 检查项目语言 (Python/Node/Go/Java/PHP)
|
||||||
|
2. **依赖安装**: 推荐 SDK
|
||||||
|
3. **配置加载**: 读取环境变量
|
||||||
|
4. **模型生成**: 创建 ORM 模型 (Order/Trade/Refund)
|
||||||
|
5. **网关工厂**: 实现 PaymentFactory + 各网关 Driver
|
||||||
|
6. **接口实现**: 按 `API接口定义.md` 实现 Controller
|
||||||
|
7. **回调处理**: 实现回调验签和状态更新
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 模式 B: 微服务集成 (Microservice Mode)
|
||||||
|
|
||||||
|
适用于将支付功能独立部署为一个服务。
|
||||||
|
|
||||||
|
**执行步骤**:
|
||||||
|
1. **服务生成**: 创建独立的支付服务项目
|
||||||
|
2. **Docker化**: 编写 `Dockerfile` 和 `docker-compose.yml`
|
||||||
|
3. **网关代理**: 配置 `/api/payment/*` 路由转发
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 给 AI 的标准执行指令
|
||||||
|
|
||||||
|
### 快速集成 (复制此内容发送给 AI)
|
||||||
|
|
||||||
|
```
|
||||||
|
请读取 `Universal_Payment_Module` 目录下的所有设计文档。
|
||||||
|
|
||||||
|
我的当前项目信息:
|
||||||
|
- 语言/框架: [Python FastAPI / Node.js Express / Java Spring Boot / Go Gin / PHP Laravel]
|
||||||
|
- 数据库: [MySQL / PostgreSQL / MongoDB]
|
||||||
|
- 集成模式: [模式 A 嵌入式 / 模式 B 微服务]
|
||||||
|
|
||||||
|
请执行以下任务:
|
||||||
|
1. 生成依赖安装命令
|
||||||
|
2. 生成数据库迁移/模型代码
|
||||||
|
3. 生成支付网关工厂类
|
||||||
|
4. 生成 API 接口代码 (严格按 API接口定义.md)
|
||||||
|
5. 生成回调处理代码
|
||||||
|
6. 生成配置读取代码
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 使用工厂模式管理支付网关
|
||||||
|
- 所有配置通过环境变量读取
|
||||||
|
- 包含完整的签名验证逻辑
|
||||||
|
- 包含幂等性处理
|
||||||
|
- 添加中文注释
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐍 Python FastAPI 示例指令
|
||||||
|
|
||||||
|
```
|
||||||
|
我的项目使用 Python FastAPI + SQLAlchemy + MySQL。
|
||||||
|
|
||||||
|
请根据 Universal_Payment_Module 文档,生成:
|
||||||
|
|
||||||
|
1. requirements.txt 依赖
|
||||||
|
2. app/models/payment.py - 数据模型
|
||||||
|
3. app/services/payment_factory.py - 支付网关工厂
|
||||||
|
4. app/services/gateways/alipay.py - 支付宝网关
|
||||||
|
5. app/services/gateways/wechat.py - 微信支付网关
|
||||||
|
6. app/routers/payment.py - API 路由
|
||||||
|
7. app/config/payment.py - 配置加载
|
||||||
|
|
||||||
|
特别要求:
|
||||||
|
- 使用 async/await 异步处理
|
||||||
|
- 集成 alipay-sdk-python 和 wechatpay-python-v3
|
||||||
|
- 回调接口支持 XML 和 JSON 格式
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟢 Node.js Express 示例指令
|
||||||
|
|
||||||
|
```
|
||||||
|
我的项目使用 Node.js Express + Prisma + PostgreSQL。
|
||||||
|
|
||||||
|
请根据 Universal_Payment_Module 文档,生成:
|
||||||
|
|
||||||
|
1. package.json 依赖
|
||||||
|
2. prisma/schema.prisma - 数据模型
|
||||||
|
3. src/services/PaymentFactory.ts - 支付网关工厂
|
||||||
|
4. src/services/gateways/AlipayGateway.ts
|
||||||
|
5. src/services/gateways/WechatGateway.ts
|
||||||
|
6. src/routes/payment.ts - API 路由
|
||||||
|
7. src/config/payment.ts - 配置加载
|
||||||
|
|
||||||
|
特别要求:
|
||||||
|
- 使用 TypeScript
|
||||||
|
- 使用 alipay-sdk 和 wechatpay-node-v3
|
||||||
|
- 实现完整的错误处理
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ☕ Java Spring Boot 示例指令
|
||||||
|
|
||||||
|
```
|
||||||
|
我的项目使用 Java Spring Boot + MyBatis + MySQL。
|
||||||
|
|
||||||
|
请根据 Universal_Payment_Module 文档,生成:
|
||||||
|
|
||||||
|
1. pom.xml 依赖
|
||||||
|
2. entity/ - 实体类
|
||||||
|
3. mapper/ - MyBatis Mapper
|
||||||
|
4. service/PaymentFactory.java - 支付网关工厂
|
||||||
|
5. service/gateway/AlipayGateway.java
|
||||||
|
6. service/gateway/WechatGateway.java
|
||||||
|
7. controller/PaymentController.java - API 控制器
|
||||||
|
8. config/PaymentConfig.java - 配置类
|
||||||
|
|
||||||
|
特别要求:
|
||||||
|
- 使用 alipay-sdk-java 和 wechatpay-java
|
||||||
|
- 使用 @Transactional 事务管理
|
||||||
|
- 实现统一异常处理
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐘 PHP Laravel 示例指令
|
||||||
|
|
||||||
|
```
|
||||||
|
我的项目使用 PHP Laravel + Eloquent + MySQL。
|
||||||
|
|
||||||
|
请根据 Universal_Payment_Module 文档,生成:
|
||||||
|
|
||||||
|
1. composer.json 依赖
|
||||||
|
2. database/migrations/ - 数据库迁移
|
||||||
|
3. app/Models/ - Eloquent 模型
|
||||||
|
4. app/Services/PaymentFactory.php - 支付网关工厂
|
||||||
|
5. app/Services/Gateways/AlipayGateway.php
|
||||||
|
6. app/Services/Gateways/WechatGateway.php
|
||||||
|
7. app/Http/Controllers/PaymentController.php
|
||||||
|
8. routes/api.php - 路由定义
|
||||||
|
9. config/payment.php - 配置文件
|
||||||
|
|
||||||
|
特别要求:
|
||||||
|
- 使用 alipay/aop-sdk 和 wechatpay/wechatpay
|
||||||
|
- 使用 Laravel 的服务容器
|
||||||
|
- 实现中间件验签
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔥 高级指令:前端收银台
|
||||||
|
|
||||||
|
```
|
||||||
|
请根据 Universal_Payment_Module 文档,生成前端收银台组件。
|
||||||
|
|
||||||
|
技术栈: [Vue 3 / React / 原生 JS]
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 支持多种支付方式切换
|
||||||
|
2. 扫码支付显示二维码
|
||||||
|
3. 轮询支付状态
|
||||||
|
4. 适配移动端
|
||||||
|
5. 显示支付倒计时
|
||||||
|
6. 美观的 UI (可使用 TailwindCSS)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔥 高级指令:Docker 部署
|
||||||
|
|
||||||
|
```
|
||||||
|
请为 Universal_Payment_Module 生成 Docker 部署配置。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. Dockerfile (多阶段构建)
|
||||||
|
2. docker-compose.yml (包含 MySQL + Redis)
|
||||||
|
3. nginx.conf (反向代理 + HTTPS)
|
||||||
|
4. .env.example (环境变量模板)
|
||||||
|
5. deploy.sh (一键部署脚本)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **密钥安全**: 生成的代码中不要硬编码任何密钥
|
||||||
|
2. **签名验证**: 必须实现完整的签名验证逻辑
|
||||||
|
3. **幂等处理**: 回调必须支持幂等
|
||||||
|
4. **金额校验**: 必须验证回调金额与订单金额匹配
|
||||||
|
5. **日志记录**: 关键操作必须记录日志
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 支持
|
||||||
|
|
||||||
|
如有问题,请联系:
|
||||||
|
- 微信: 28533368
|
||||||
|
- 作者: 卡若
|
||||||
748
addons/Universal_Payment_Module copy/3_逻辑参考_通用实现/前端收银台Demo.html
Normal file
748
addons/Universal_Payment_Module copy/3_逻辑参考_通用实现/前端收银台Demo.html
Normal file
@@ -0,0 +1,748 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>通用收银台 v4.0</title>
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
* 通用收银台样式 - 支持多种支付方式
|
||||||
|
* 作者: 卡若
|
||||||
|
* 适配: PC + 移动端
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #1890ff;
|
||||||
|
--success-color: #52c41a;
|
||||||
|
--warning-color: #faad14;
|
||||||
|
--error-color: #ff4d4f;
|
||||||
|
--text-color: #333;
|
||||||
|
--text-secondary: #666;
|
||||||
|
--border-color: #e8e8e8;
|
||||||
|
--bg-color: #f5f5f7;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cashier-container {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cashier-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 订单信息 */
|
||||||
|
.order-section {
|
||||||
|
text-align: center;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px dashed var(--border-color);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-title {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-amount {
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color);
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-amount .currency {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown {
|
||||||
|
color: var(--warning-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 支付方式选择 */
|
||||||
|
.payment-section h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-methods {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: rgba(24, 144, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method.active {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: rgba(24, 144, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method .icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 24px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method .icon.alipay { background: #1677ff; color: white; }
|
||||||
|
.payment-method .icon.wechat { background: #07c160; color: white; }
|
||||||
|
.payment-method .icon.paypal { background: #003087; color: white; }
|
||||||
|
.payment-method .icon.stripe { background: #635bff; color: white; }
|
||||||
|
.payment-method .icon.usdt { background: #26a17b; color: white; }
|
||||||
|
|
||||||
|
.payment-method .info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method .name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method .desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method .radio {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method.active .radio {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method.active .radio::after {
|
||||||
|
content: '✓';
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 二维码区域 */
|
||||||
|
.qrcode-section {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-section.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-box {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-box img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-tip {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* USDT 地址显示 */
|
||||||
|
.usdt-address {
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 支付按钮 */
|
||||||
|
.pay-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 20px rgba(24, 144, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-btn:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-btn.loading {
|
||||||
|
position: relative;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-btn.loading::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
margin-left: -10px;
|
||||||
|
margin-top: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 支付成功 */
|
||||||
|
.success-section {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-section.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
font-size: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
animation: scaleIn 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
0% { transform: scale(0); }
|
||||||
|
50% { transform: scale(1.2); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-section h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-section p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.return-btn {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 12px 32px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 安全提示 */
|
||||||
|
.security-tips {
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-tips span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cashier-container {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cashier-card {
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-amount {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="cashier-container">
|
||||||
|
<!-- 订单信息卡片 -->
|
||||||
|
<div class="cashier-card" id="orderCard">
|
||||||
|
<div class="order-section">
|
||||||
|
<div class="order-title" id="orderTitle">VIP会员月卡</div>
|
||||||
|
<div class="order-amount">
|
||||||
|
<span class="currency">¥</span>
|
||||||
|
<span id="displayAmount">99.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="order-info">
|
||||||
|
<span>订单号: <span id="orderSn">202401170001</span></span>
|
||||||
|
<span class="countdown" id="countdown">29:59</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 支付方式选择 -->
|
||||||
|
<div class="payment-section" id="paymentSection">
|
||||||
|
<h3>选择支付方式</h3>
|
||||||
|
<div class="payment-methods" id="paymentMethods">
|
||||||
|
<!-- 支付宝 -->
|
||||||
|
<div class="payment-method" data-gateway="alipay_wap" onclick="selectMethod(this)">
|
||||||
|
<div class="icon alipay">💙</div>
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">支付宝</div>
|
||||||
|
<div class="desc">推荐有支付宝账户的用户使用</div>
|
||||||
|
</div>
|
||||||
|
<div class="radio"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 微信支付 -->
|
||||||
|
<div class="payment-method" data-gateway="wechat_native" onclick="selectMethod(this)">
|
||||||
|
<div class="icon wechat">💚</div>
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">微信支付</div>
|
||||||
|
<div class="desc">扫码支付,微信用户首选</div>
|
||||||
|
</div>
|
||||||
|
<div class="radio"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PayPal -->
|
||||||
|
<div class="payment-method" data-gateway="paypal" onclick="selectMethod(this)">
|
||||||
|
<div class="icon paypal">🅿️</div>
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">PayPal</div>
|
||||||
|
<div class="desc">支持信用卡,海外用户推荐</div>
|
||||||
|
</div>
|
||||||
|
<div class="radio"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- USDT -->
|
||||||
|
<div class="payment-method" data-gateway="usdt" onclick="selectMethod(this)">
|
||||||
|
<div class="icon usdt">₮</div>
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">USDT (TRC20)</div>
|
||||||
|
<div class="desc">加密货币支付</div>
|
||||||
|
</div>
|
||||||
|
<div class="radio"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 二维码区域 -->
|
||||||
|
<div class="qrcode-section" id="qrcodeSection">
|
||||||
|
<div class="qrcode-box">
|
||||||
|
<img id="qrcodeImg" src="" alt="支付二维码">
|
||||||
|
</div>
|
||||||
|
<div class="qrcode-text" id="qrcodeText">请使用微信扫描二维码支付</div>
|
||||||
|
<div class="qrcode-tip">二维码有效期 30 分钟,请尽快支付</div>
|
||||||
|
|
||||||
|
<!-- USDT 地址 -->
|
||||||
|
<div class="usdt-address" id="usdtAddress" style="display: none;"></div>
|
||||||
|
<button class="copy-btn" id="copyBtn" style="display: none;" onclick="copyAddress()">复制地址</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 支付按钮 -->
|
||||||
|
<button class="pay-btn" id="payBtn" onclick="doPay()">确认支付 ¥99.00</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 支付成功 -->
|
||||||
|
<div class="cashier-card success-section" id="successSection">
|
||||||
|
<div class="success-icon">✓</div>
|
||||||
|
<h2>支付成功</h2>
|
||||||
|
<p>感谢您的购买,订单已完成</p>
|
||||||
|
<button class="return-btn" onclick="goBack()">返回商户</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 安全提示 -->
|
||||||
|
<div class="security-tips">
|
||||||
|
<span>🔒 安全支付由卡若私域提供技术支持</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* 通用收银台 JavaScript v4.0
|
||||||
|
* 作者: 卡若
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
* 1. 修改 API_BASE 为你的后端地址
|
||||||
|
* 2. 通过 URL 参数传入订单信息: ?order_sn=xxx&amount=99.00&title=商品名称
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
const API_BASE = '/api/payment'; // 你的后端接口地址
|
||||||
|
const POLL_INTERVAL = 3000; // 轮询间隔 (毫秒)
|
||||||
|
const ORDER_TIMEOUT = 30 * 60; // 订单超时时间 (秒)
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
let selectedGateway = null;
|
||||||
|
let orderSn = '';
|
||||||
|
let orderAmount = 0;
|
||||||
|
let countdownTimer = null;
|
||||||
|
let pollTimer = null;
|
||||||
|
let remainingSeconds = ORDER_TIMEOUT;
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// 从 URL 解析订单信息
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
orderSn = params.get('order_sn') || '202401170001';
|
||||||
|
orderAmount = parseFloat(params.get('amount')) || 99.00;
|
||||||
|
const title = params.get('title') || 'VIP会员月卡';
|
||||||
|
|
||||||
|
// 更新UI
|
||||||
|
document.getElementById('orderSn').textContent = orderSn;
|
||||||
|
document.getElementById('orderTitle').textContent = title;
|
||||||
|
document.getElementById('displayAmount').textContent = orderAmount.toFixed(2);
|
||||||
|
document.getElementById('payBtn').textContent = `确认支付 ¥${orderAmount.toFixed(2)}`;
|
||||||
|
|
||||||
|
// 启动倒计时
|
||||||
|
startCountdown();
|
||||||
|
|
||||||
|
// 检测微信环境
|
||||||
|
if (isWechat()) {
|
||||||
|
// 微信环境默认选择JSAPI
|
||||||
|
const wechatMethod = document.querySelector('[data-gateway="wechat_native"]');
|
||||||
|
if (wechatMethod) {
|
||||||
|
wechatMethod.dataset.gateway = 'wechat_jsapi';
|
||||||
|
wechatMethod.querySelector('.desc').textContent = '微信内直接支付';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 选择支付方式
|
||||||
|
function selectMethod(element) {
|
||||||
|
// 移除其他选中状态
|
||||||
|
document.querySelectorAll('.payment-method').forEach(el => {
|
||||||
|
el.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置选中状态
|
||||||
|
element.classList.add('active');
|
||||||
|
selectedGateway = element.dataset.gateway;
|
||||||
|
|
||||||
|
// 隐藏二维码区域
|
||||||
|
document.getElementById('qrcodeSection').classList.remove('show');
|
||||||
|
document.getElementById('paymentSection').style.display = 'block';
|
||||||
|
document.getElementById('payBtn').style.display = 'block';
|
||||||
|
|
||||||
|
// 更新金额显示 (USDT显示美元)
|
||||||
|
if (selectedGateway === 'usdt') {
|
||||||
|
const usdtAmount = (orderAmount / 7.2).toFixed(2); // 简单汇率转换
|
||||||
|
document.getElementById('displayAmount').textContent = usdtAmount;
|
||||||
|
document.querySelector('.currency').textContent = '₮';
|
||||||
|
document.getElementById('payBtn').textContent = `确认支付 ₮${usdtAmount}`;
|
||||||
|
} else if (selectedGateway === 'paypal') {
|
||||||
|
const usdAmount = (orderAmount / 7.2).toFixed(2);
|
||||||
|
document.getElementById('displayAmount').textContent = usdAmount;
|
||||||
|
document.querySelector('.currency').textContent = '$';
|
||||||
|
document.getElementById('payBtn').textContent = `确认支付 $${usdAmount}`;
|
||||||
|
} else {
|
||||||
|
document.getElementById('displayAmount').textContent = orderAmount.toFixed(2);
|
||||||
|
document.querySelector('.currency').textContent = '¥';
|
||||||
|
document.getElementById('payBtn').textContent = `确认支付 ¥${orderAmount.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发起支付
|
||||||
|
async function doPay() {
|
||||||
|
if (!selectedGateway) {
|
||||||
|
alert('请选择支付方式');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payBtn = document.getElementById('payBtn');
|
||||||
|
payBtn.classList.add('loading');
|
||||||
|
payBtn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用后端接口
|
||||||
|
const response = await fetch(`${API_BASE}/checkout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
order_sn: orderSn,
|
||||||
|
gateway: selectedGateway,
|
||||||
|
return_url: window.location.href
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.code !== 200) {
|
||||||
|
throw new Error(result.message || '支付发起失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, payload, trade_sn } = result.data;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'url':
|
||||||
|
// 跳转支付 (支付宝H5, PayPal, Stripe)
|
||||||
|
window.location.href = payload;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'qrcode':
|
||||||
|
// 显示二维码 (微信Native, 支付宝扫码)
|
||||||
|
showQrcode(payload, selectedGateway);
|
||||||
|
startPolling(trade_sn);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'json':
|
||||||
|
// 调起SDK (微信JSAPI)
|
||||||
|
callWechatPay(payload);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'address':
|
||||||
|
// 显示钱包地址 (USDT)
|
||||||
|
showUsdtAddress(payload);
|
||||||
|
startPolling(trade_sn);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'direct':
|
||||||
|
// 直接完成 (虚拟币支付)
|
||||||
|
showSuccess();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('支付失败:', error);
|
||||||
|
alert(error.message || '支付发起失败,请重试');
|
||||||
|
} finally {
|
||||||
|
payBtn.classList.remove('loading');
|
||||||
|
payBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示二维码
|
||||||
|
function showQrcode(content, gateway) {
|
||||||
|
document.getElementById('paymentSection').style.display = 'none';
|
||||||
|
document.getElementById('payBtn').style.display = 'none';
|
||||||
|
document.getElementById('qrcodeSection').classList.add('show');
|
||||||
|
|
||||||
|
// 使用在线服务生成二维码
|
||||||
|
const qrcodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(content)}`;
|
||||||
|
document.getElementById('qrcodeImg').src = qrcodeUrl;
|
||||||
|
|
||||||
|
// 更新提示文字
|
||||||
|
const texts = {
|
||||||
|
'wechat_native': '请使用微信扫描二维码支付',
|
||||||
|
'alipay_qr': '请使用支付宝扫描二维码支付'
|
||||||
|
};
|
||||||
|
document.getElementById('qrcodeText').textContent = texts[gateway] || '请扫描二维码支付';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示USDT地址
|
||||||
|
function showUsdtAddress(addressInfo) {
|
||||||
|
document.getElementById('paymentSection').style.display = 'none';
|
||||||
|
document.getElementById('payBtn').style.display = 'none';
|
||||||
|
document.getElementById('qrcodeSection').classList.add('show');
|
||||||
|
|
||||||
|
const { address, amount_usdt, memo } = addressInfo;
|
||||||
|
|
||||||
|
document.getElementById('qrcodeImg').src = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(address)}`;
|
||||||
|
document.getElementById('qrcodeText').textContent = `请向以下地址转账 ${amount_usdt} USDT (TRC20)`;
|
||||||
|
document.getElementById('usdtAddress').textContent = address;
|
||||||
|
document.getElementById('usdtAddress').style.display = 'block';
|
||||||
|
document.getElementById('copyBtn').style.display = 'inline-block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制地址
|
||||||
|
function copyAddress() {
|
||||||
|
const address = document.getElementById('usdtAddress').textContent;
|
||||||
|
navigator.clipboard.writeText(address).then(() => {
|
||||||
|
alert('地址已复制');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用微信JSAPI支付
|
||||||
|
function callWechatPay(params) {
|
||||||
|
if (typeof WeixinJSBridge === 'undefined') {
|
||||||
|
alert('请在微信中打开此页面');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WeixinJSBridge.invoke('getBrandWCPayRequest', params, function(res) {
|
||||||
|
if (res.err_msg === 'get_brand_wcpay_request:ok') {
|
||||||
|
showSuccess();
|
||||||
|
} else {
|
||||||
|
alert('支付取消或失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 轮询支付状态
|
||||||
|
function startPolling(tradeSn) {
|
||||||
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/status/${orderSn}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.data && result.data.status === 'paid') {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
showSuccess();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('查询状态失败:', error);
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示支付成功
|
||||||
|
function showSuccess() {
|
||||||
|
document.getElementById('orderCard').style.display = 'none';
|
||||||
|
document.getElementById('successSection').classList.add('show');
|
||||||
|
|
||||||
|
// 停止轮询和倒计时
|
||||||
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回商户
|
||||||
|
function goBack() {
|
||||||
|
const returnUrl = new URLSearchParams(window.location.search).get('return_url');
|
||||||
|
if (returnUrl) {
|
||||||
|
window.location.href = returnUrl;
|
||||||
|
} else {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 倒计时
|
||||||
|
function startCountdown() {
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
remainingSeconds--;
|
||||||
|
|
||||||
|
if (remainingSeconds <= 0) {
|
||||||
|
clearInterval(countdownTimer);
|
||||||
|
alert('订单已超时,请重新下单');
|
||||||
|
window.history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(remainingSeconds / 60);
|
||||||
|
const seconds = remainingSeconds % 60;
|
||||||
|
document.getElementById('countdown').textContent =
|
||||||
|
`${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测微信环境
|
||||||
|
function isWechat() {
|
||||||
|
return /MicroMessenger/i.test(navigator.userAgent);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
"""
|
||||||
|
支付宝网关实现 (Alipay Gateway)
|
||||||
|
基于 www.lytiao.com 项目提取
|
||||||
|
|
||||||
|
作者: 卡若
|
||||||
|
版本: v4.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from urllib.parse import urlencode, parse_qs
|
||||||
|
|
||||||
|
try:
|
||||||
|
from Crypto.PublicKey import RSA
|
||||||
|
from Crypto.Signature import PKCS1_v1_5
|
||||||
|
from Crypto.Hash import SHA256
|
||||||
|
except ImportError:
|
||||||
|
print("请安装 pycryptodome: pip install pycryptodome")
|
||||||
|
|
||||||
|
from payment_factory import (
|
||||||
|
AbstractGateway, CreateTradeData, TradeResult, NotifyResult,
|
||||||
|
SignatureError, logger
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AlipayGateway(AbstractGateway):
|
||||||
|
"""
|
||||||
|
支付宝网关
|
||||||
|
|
||||||
|
支持:
|
||||||
|
- 电脑网站支付 (platform_type='web')
|
||||||
|
- 手机网站支付 (platform_type='wap')
|
||||||
|
- 扫码支付 (platform_type='qr')
|
||||||
|
"""
|
||||||
|
|
||||||
|
GATEWAY_URL = 'https://openapi.alipay.com/gateway.do'
|
||||||
|
SANDBOX_URL = 'https://openapi.alipaydev.com/gateway.do'
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any]):
|
||||||
|
super().__init__(config)
|
||||||
|
self.app_id = config.get('app_id', '')
|
||||||
|
self.pid = config.get('pid', '')
|
||||||
|
self.seller_email = config.get('seller_email', '')
|
||||||
|
self.private_key = config.get('private_key', '')
|
||||||
|
self.public_key = config.get('public_key', '')
|
||||||
|
self.md5_key = config.get('md5_key', '')
|
||||||
|
|
||||||
|
def create_trade(self, data: CreateTradeData) -> TradeResult:
|
||||||
|
"""创建支付宝交易"""
|
||||||
|
platform_type = data.platform_type.capitalize()
|
||||||
|
|
||||||
|
if platform_type == 'Web':
|
||||||
|
return self._create_web_trade(data)
|
||||||
|
elif platform_type == 'Wap':
|
||||||
|
return self._create_wap_trade(data)
|
||||||
|
elif platform_type == 'Qr':
|
||||||
|
return self._create_qr_trade(data)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"不支持的支付类型: {platform_type}")
|
||||||
|
|
||||||
|
def _create_web_trade(self, data: CreateTradeData) -> TradeResult:
|
||||||
|
"""电脑网站支付"""
|
||||||
|
biz_content = {
|
||||||
|
'subject': data.goods_title[:256],
|
||||||
|
'out_trade_no': data.trade_sn,
|
||||||
|
'total_amount': str(data.amount / 100), # 分转元
|
||||||
|
'product_code': 'FAST_INSTANT_TRADE_PAY',
|
||||||
|
'body': data.goods_detail[:128] if data.goods_detail else '',
|
||||||
|
'passback_params': json.dumps(data.attach) if data.attach else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
params = self._build_params('alipay.trade.page.pay', biz_content, data.return_url, data.notify_url)
|
||||||
|
|
||||||
|
# 生成签名
|
||||||
|
sign = self._generate_sign(params)
|
||||||
|
params['sign'] = sign
|
||||||
|
|
||||||
|
# 构建跳转URL
|
||||||
|
pay_url = f"{self.GATEWAY_URL}?{urlencode(params)}"
|
||||||
|
|
||||||
|
return TradeResult(
|
||||||
|
type='url',
|
||||||
|
payload=pay_url,
|
||||||
|
trade_sn=data.trade_sn
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_wap_trade(self, data: CreateTradeData) -> TradeResult:
|
||||||
|
"""手机网站支付"""
|
||||||
|
biz_content = {
|
||||||
|
'subject': data.goods_title[:256],
|
||||||
|
'out_trade_no': data.trade_sn,
|
||||||
|
'total_amount': str(data.amount / 100),
|
||||||
|
'product_code': 'QUICK_WAP_WAY',
|
||||||
|
'body': data.goods_detail[:128] if data.goods_detail else '',
|
||||||
|
'passback_params': json.dumps(data.attach) if data.attach else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
params = self._build_params('alipay.trade.wap.pay', biz_content, data.return_url, data.notify_url)
|
||||||
|
sign = self._generate_sign(params)
|
||||||
|
params['sign'] = sign
|
||||||
|
|
||||||
|
pay_url = f"{self.GATEWAY_URL}?{urlencode(params)}"
|
||||||
|
|
||||||
|
return TradeResult(
|
||||||
|
type='url',
|
||||||
|
payload=pay_url,
|
||||||
|
trade_sn=data.trade_sn
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_qr_trade(self, data: CreateTradeData) -> TradeResult:
|
||||||
|
"""扫码支付 (当面付)"""
|
||||||
|
biz_content = {
|
||||||
|
'subject': data.goods_title[:256],
|
||||||
|
'out_trade_no': data.trade_sn,
|
||||||
|
'total_amount': str(data.amount / 100),
|
||||||
|
'body': data.goods_detail[:128] if data.goods_detail else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
params = self._build_params('alipay.trade.precreate', biz_content, '', data.notify_url)
|
||||||
|
sign = self._generate_sign(params)
|
||||||
|
params['sign'] = sign
|
||||||
|
|
||||||
|
# 调用接口获取二维码
|
||||||
|
import requests
|
||||||
|
response = requests.post(self.GATEWAY_URL, data=params)
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
if 'alipay_trade_precreate_response' in result:
|
||||||
|
resp = result['alipay_trade_precreate_response']
|
||||||
|
if resp.get('code') == '10000':
|
||||||
|
return TradeResult(
|
||||||
|
type='qrcode',
|
||||||
|
payload=resp['qr_code'],
|
||||||
|
trade_sn=data.trade_sn
|
||||||
|
)
|
||||||
|
|
||||||
|
raise Exception(f"创建支付宝扫码支付失败: {result}")
|
||||||
|
|
||||||
|
def _build_params(self, method: str, biz_content: dict, return_url: str, notify_url: str) -> dict:
|
||||||
|
"""构建公共参数"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'app_id': self.app_id,
|
||||||
|
'method': method,
|
||||||
|
'charset': 'utf-8',
|
||||||
|
'sign_type': 'RSA2',
|
||||||
|
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'version': '1.0',
|
||||||
|
'biz_content': json.dumps(biz_content, ensure_ascii=False),
|
||||||
|
}
|
||||||
|
|
||||||
|
if return_url:
|
||||||
|
params['return_url'] = return_url
|
||||||
|
if notify_url:
|
||||||
|
params['notify_url'] = notify_url
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
def _generate_sign(self, params: dict) -> str:
|
||||||
|
"""生成RSA2签名"""
|
||||||
|
# 排序并拼接
|
||||||
|
sorted_params = sorted([(k, v) for k, v in params.items() if v])
|
||||||
|
sign_str = '&'.join([f'{k}={v}' for k, v in sorted_params])
|
||||||
|
|
||||||
|
# RSA2签名
|
||||||
|
key = RSA.import_key(f"-----BEGIN RSA PRIVATE KEY-----\n{self.private_key}\n-----END RSA PRIVATE KEY-----")
|
||||||
|
signer = PKCS1_v1_5.new(key)
|
||||||
|
hash_obj = SHA256.new(sign_str.encode('utf-8'))
|
||||||
|
signature = signer.sign(hash_obj)
|
||||||
|
|
||||||
|
return base64.b64encode(signature).decode('utf-8')
|
||||||
|
|
||||||
|
def verify_sign(self, data: Dict[str, Any]) -> bool:
|
||||||
|
"""验证支付宝签名"""
|
||||||
|
sign = data.pop('sign', '')
|
||||||
|
sign_type = data.pop('sign_type', 'RSA2')
|
||||||
|
|
||||||
|
if not sign:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 排序并拼接
|
||||||
|
sorted_params = sorted([(k, v) for k, v in data.items() if v and k != 'sign_type'])
|
||||||
|
sign_str = '&'.join([f'{k}={v}' for k, v in sorted_params])
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = RSA.import_key(f"-----BEGIN PUBLIC KEY-----\n{self.public_key}\n-----END PUBLIC KEY-----")
|
||||||
|
verifier = PKCS1_v1_5.new(key)
|
||||||
|
hash_obj = SHA256.new(sign_str.encode('utf-8'))
|
||||||
|
verifier.verify(hash_obj, base64.b64decode(sign))
|
||||||
|
return True
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.error(f"支付宝签名验证失败: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def parse_notify(self, data: Dict[str, Any]) -> NotifyResult:
|
||||||
|
"""解析支付宝回调"""
|
||||||
|
trade_status = data.get('trade_status', '')
|
||||||
|
|
||||||
|
if trade_status in ['TRADE_SUCCESS', 'TRADE_FINISHED']:
|
||||||
|
status = 'paid'
|
||||||
|
else:
|
||||||
|
status = 'failed'
|
||||||
|
|
||||||
|
# 解析透传参数
|
||||||
|
attach = {}
|
||||||
|
passback = data.get('passback_params', '')
|
||||||
|
if passback:
|
||||||
|
try:
|
||||||
|
attach = json.loads(passback)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 解析支付时间
|
||||||
|
import time
|
||||||
|
gmt_payment = data.get('gmt_payment', '')
|
||||||
|
if gmt_payment:
|
||||||
|
pay_time = int(time.mktime(time.strptime(gmt_payment, '%Y-%m-%d %H:%M:%S')))
|
||||||
|
else:
|
||||||
|
pay_time = int(time.time())
|
||||||
|
|
||||||
|
return NotifyResult(
|
||||||
|
status=status,
|
||||||
|
trade_sn=data.get('out_trade_no', ''),
|
||||||
|
platform_sn=data.get('trade_no', ''),
|
||||||
|
pay_amount=int(float(data.get('total_amount', 0)) * 100),
|
||||||
|
pay_time=pay_time,
|
||||||
|
currency='CNY',
|
||||||
|
attach=attach,
|
||||||
|
raw_data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
def close_trade(self, trade_sn: str) -> bool:
|
||||||
|
"""关闭交易"""
|
||||||
|
biz_content = {
|
||||||
|
'out_trade_no': trade_sn,
|
||||||
|
}
|
||||||
|
|
||||||
|
params = self._build_params('alipay.trade.close', biz_content, '', '')
|
||||||
|
sign = self._generate_sign(params)
|
||||||
|
params['sign'] = sign
|
||||||
|
|
||||||
|
import requests
|
||||||
|
response = requests.post(self.GATEWAY_URL, data=params)
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
resp = result.get('alipay_trade_close_response', {})
|
||||||
|
return resp.get('code') == '10000'
|
||||||
|
|
||||||
|
def query_trade(self, trade_sn: str) -> Optional[NotifyResult]:
|
||||||
|
"""查询交易"""
|
||||||
|
biz_content = {
|
||||||
|
'out_trade_no': trade_sn,
|
||||||
|
}
|
||||||
|
|
||||||
|
params = self._build_params('alipay.trade.query', biz_content, '', '')
|
||||||
|
sign = self._generate_sign(params)
|
||||||
|
params['sign'] = sign
|
||||||
|
|
||||||
|
import requests
|
||||||
|
response = requests.post(self.GATEWAY_URL, data=params)
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
resp = result.get('alipay_trade_query_response', {})
|
||||||
|
if resp.get('code') == '10000' and resp.get('trade_status') in ['TRADE_SUCCESS', 'TRADE_FINISHED']:
|
||||||
|
return self.parse_notify(resp)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def refund(self, trade_sn: str, refund_sn: str, amount: int, reason: str = '') -> bool:
|
||||||
|
"""发起退款"""
|
||||||
|
biz_content = {
|
||||||
|
'out_trade_no': trade_sn,
|
||||||
|
'out_request_no': refund_sn,
|
||||||
|
'refund_amount': str(amount / 100),
|
||||||
|
'refund_reason': reason or '用户申请退款',
|
||||||
|
}
|
||||||
|
|
||||||
|
params = self._build_params('alipay.trade.refund', biz_content, '', '')
|
||||||
|
sign = self._generate_sign(params)
|
||||||
|
params['sign'] = sign
|
||||||
|
|
||||||
|
import requests
|
||||||
|
response = requests.post(self.GATEWAY_URL, data=params)
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
resp = result.get('alipay_trade_refund_response', {})
|
||||||
|
return resp.get('code') == '10000'
|
||||||
|
|
||||||
|
def success_response(self) -> str:
|
||||||
|
return 'success'
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
"""
|
||||||
|
支付网关工厂 (Payment Gateway Factory)
|
||||||
|
基于 www.lytiao.com 项目提取的支付逻辑,适配 Python FastAPI
|
||||||
|
|
||||||
|
作者: 卡若
|
||||||
|
版本: v4.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, Any, Optional, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
logger = logging.getLogger('payment')
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentPlatform(Enum):
|
||||||
|
"""支付平台枚举"""
|
||||||
|
ALIPAY = 'alipay'
|
||||||
|
WECHAT = 'wechat'
|
||||||
|
PAYPAL = 'paypal'
|
||||||
|
STRIPE = 'stripe'
|
||||||
|
USDT = 'usdt'
|
||||||
|
COIN = 'coin'
|
||||||
|
|
||||||
|
|
||||||
|
class TradeType(Enum):
|
||||||
|
"""交易类型"""
|
||||||
|
PURCHASE = 'purchase' # 购买
|
||||||
|
RECHARGE = 'recharge' # 充值
|
||||||
|
|
||||||
|
|
||||||
|
class TradeStatus(Enum):
|
||||||
|
"""交易状态"""
|
||||||
|
PAYING = 'paying'
|
||||||
|
PAID = 'paid'
|
||||||
|
CLOSED = 'closed'
|
||||||
|
REFUNDED = 'refunded'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CreateTradeData:
|
||||||
|
"""创建交易请求数据"""
|
||||||
|
goods_title: str
|
||||||
|
goods_detail: str
|
||||||
|
trade_sn: str
|
||||||
|
order_sn: str
|
||||||
|
amount: int # 金额,单位:分
|
||||||
|
notify_url: str
|
||||||
|
return_url: str = ''
|
||||||
|
platform_type: str = 'web' # web/wap/jsapi/native/h5/app
|
||||||
|
create_ip: str = ''
|
||||||
|
open_id: str = '' # 微信JSAPI支付需要
|
||||||
|
attach: Dict[str, Any] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TradeResult:
|
||||||
|
"""交易结果"""
|
||||||
|
type: str # url/qrcode/json/address/direct
|
||||||
|
payload: Any # 支付数据
|
||||||
|
trade_sn: str
|
||||||
|
expiration: int = 1800 # 过期时间(秒)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NotifyResult:
|
||||||
|
"""回调解析结果"""
|
||||||
|
status: str
|
||||||
|
trade_sn: str
|
||||||
|
platform_sn: str
|
||||||
|
pay_amount: int # 分
|
||||||
|
pay_time: int # 时间戳
|
||||||
|
currency: str = 'CNY'
|
||||||
|
attach: Dict[str, Any] = None
|
||||||
|
raw_data: Dict[str, Any] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentException(Exception):
|
||||||
|
"""支付异常基类"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SignatureError(PaymentException):
|
||||||
|
"""签名验证失败"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AmountMismatchError(PaymentException):
|
||||||
|
"""金额不匹配"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayNotFoundError(PaymentException):
|
||||||
|
"""支付网关不存在"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractGateway(ABC):
|
||||||
|
"""
|
||||||
|
支付网关抽象基类
|
||||||
|
所有支付网关必须实现此接口
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any] = None):
|
||||||
|
self.config = config or {}
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_trade(self, data: CreateTradeData) -> TradeResult:
|
||||||
|
"""
|
||||||
|
创建交易
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 交易数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TradeResult: 包含支付参数的结果
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def verify_sign(self, data: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
验证签名
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 回调原始数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 签名是否有效
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse_notify(self, data: Any) -> NotifyResult:
|
||||||
|
"""
|
||||||
|
解析回调数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 回调原始数据 (可能是dict或xml字符串)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NotifyResult: 解析后的结果
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def close_trade(self, trade_sn: str) -> bool:
|
||||||
|
"""
|
||||||
|
关闭交易
|
||||||
|
|
||||||
|
Args:
|
||||||
|
trade_sn: 交易流水号
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否关闭成功
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def query_trade(self, trade_sn: str) -> Optional[NotifyResult]:
|
||||||
|
"""
|
||||||
|
查询交易状态
|
||||||
|
|
||||||
|
Args:
|
||||||
|
trade_sn: 交易流水号
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NotifyResult: 交易状态,未支付返回None
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def refund(self, trade_sn: str, refund_sn: str, amount: int, reason: str = '') -> bool:
|
||||||
|
"""
|
||||||
|
发起退款
|
||||||
|
|
||||||
|
Args:
|
||||||
|
trade_sn: 原交易流水号
|
||||||
|
refund_sn: 退款单号
|
||||||
|
amount: 退款金额(分)
|
||||||
|
reason: 退款原因
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: 是否成功
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def success_response(self) -> str:
|
||||||
|
"""回调成功响应"""
|
||||||
|
return 'success'
|
||||||
|
|
||||||
|
def fail_response(self) -> str:
|
||||||
|
"""回调失败响应"""
|
||||||
|
return 'fail'
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentFactory:
|
||||||
|
"""
|
||||||
|
支付网关工厂
|
||||||
|
|
||||||
|
使用示例:
|
||||||
|
# 注册网关
|
||||||
|
PaymentFactory.register('alipay', AlipayGateway)
|
||||||
|
PaymentFactory.register('wechat', WechatGateway)
|
||||||
|
|
||||||
|
# 创建网关实例
|
||||||
|
gateway = PaymentFactory.create('wechat_jsapi')
|
||||||
|
result = gateway.create_trade(data)
|
||||||
|
"""
|
||||||
|
|
||||||
|
_gateways: Dict[str, type] = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register(cls, name: str, gateway_class: type):
|
||||||
|
"""注册支付网关"""
|
||||||
|
cls._gateways[name] = gateway_class
|
||||||
|
logger.info(f"注册支付网关: {name}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, gateway: str) -> AbstractGateway:
|
||||||
|
"""
|
||||||
|
创建支付网关实例
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gateway: 网关名称,格式如 'wechat_jsapi',会取下划线前的部分
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AbstractGateway: 网关实例
|
||||||
|
"""
|
||||||
|
gateway_name = gateway.split('_')[0]
|
||||||
|
|
||||||
|
if gateway_name not in cls._gateways:
|
||||||
|
raise GatewayNotFoundError(f"不支持的支付网关: {gateway}")
|
||||||
|
|
||||||
|
gateway_class = cls._gateways[gateway_name]
|
||||||
|
config = cls._get_gateway_config(gateway_name)
|
||||||
|
|
||||||
|
return gateway_class(config)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_gateway_config(cls, gateway_name: str) -> Dict[str, Any]:
|
||||||
|
"""获取网关配置"""
|
||||||
|
config_map = {
|
||||||
|
'alipay': {
|
||||||
|
'app_id': os.getenv('ALIPAY_APP_ID', ''),
|
||||||
|
'pid': os.getenv('ALIPAY_PID', ''),
|
||||||
|
'seller_email': os.getenv('ALIPAY_SELLER_EMAIL', ''),
|
||||||
|
'private_key': os.getenv('ALIPAY_PRIVATE_KEY', ''),
|
||||||
|
'public_key': os.getenv('ALIPAY_PUBLIC_KEY', ''),
|
||||||
|
'md5_key': os.getenv('ALIPAY_MD5_KEY', ''),
|
||||||
|
},
|
||||||
|
'wechat': {
|
||||||
|
'appid': os.getenv('WECHAT_APPID', ''),
|
||||||
|
'app_secret': os.getenv('WECHAT_APP_SECRET', ''),
|
||||||
|
'mch_id': os.getenv('WECHAT_MCH_ID', ''),
|
||||||
|
'mch_key': os.getenv('WECHAT_MCH_KEY', ''),
|
||||||
|
'cert_path': os.getenv('WECHAT_CERT_PATH', ''),
|
||||||
|
'key_path': os.getenv('WECHAT_KEY_PATH', ''),
|
||||||
|
},
|
||||||
|
'paypal': {
|
||||||
|
'client_id': os.getenv('PAYPAL_CLIENT_ID', ''),
|
||||||
|
'client_secret': os.getenv('PAYPAL_CLIENT_SECRET', ''),
|
||||||
|
'mode': os.getenv('PAYPAL_MODE', 'sandbox'),
|
||||||
|
},
|
||||||
|
'stripe': {
|
||||||
|
'public_key': os.getenv('STRIPE_PUBLIC_KEY', ''),
|
||||||
|
'secret_key': os.getenv('STRIPE_SECRET_KEY', ''),
|
||||||
|
'webhook_secret': os.getenv('STRIPE_WEBHOOK_SECRET', ''),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return config_map.get(gateway_name, {})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_enabled_gateways(cls) -> list:
|
||||||
|
"""获取已启用的支付网关列表"""
|
||||||
|
enabled = []
|
||||||
|
|
||||||
|
if os.getenv('ALIPAY_ENABLED', 'false').lower() == 'true':
|
||||||
|
enabled.append({
|
||||||
|
'gateway': 'alipay',
|
||||||
|
'name': '支付宝',
|
||||||
|
'icon': '/icons/alipay.png',
|
||||||
|
'types': ['web', 'wap', 'qr']
|
||||||
|
})
|
||||||
|
|
||||||
|
if os.getenv('WECHAT_ENABLED', 'false').lower() == 'true':
|
||||||
|
enabled.append({
|
||||||
|
'gateway': 'wechat',
|
||||||
|
'name': '微信支付',
|
||||||
|
'icon': '/icons/wechat.png',
|
||||||
|
'types': ['native', 'jsapi', 'h5', 'app']
|
||||||
|
})
|
||||||
|
|
||||||
|
if os.getenv('PAYPAL_ENABLED', 'false').lower() == 'true':
|
||||||
|
enabled.append({
|
||||||
|
'gateway': 'paypal',
|
||||||
|
'name': 'PayPal',
|
||||||
|
'icon': '/icons/paypal.png',
|
||||||
|
'types': ['redirect']
|
||||||
|
})
|
||||||
|
|
||||||
|
if os.getenv('STRIPE_ENABLED', 'false').lower() == 'true':
|
||||||
|
enabled.append({
|
||||||
|
'gateway': 'stripe',
|
||||||
|
'name': 'Stripe',
|
||||||
|
'icon': '/icons/stripe.png',
|
||||||
|
'types': ['redirect']
|
||||||
|
})
|
||||||
|
|
||||||
|
return enabled
|
||||||
|
|
||||||
|
|
||||||
|
def generate_trade_sn(prefix: str = 'T') -> str:
|
||||||
|
"""
|
||||||
|
生成交易流水号
|
||||||
|
|
||||||
|
格式: 前缀 + 年月日时分秒 + 5位随机数
|
||||||
|
示例: T2024011710053012345
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
timestamp = time.strftime('%Y%m%d%H%M%S')
|
||||||
|
random_num = random.randint(10000, 99999)
|
||||||
|
return f"{prefix}{timestamp}{random_num}"
|
||||||
|
|
||||||
|
|
||||||
|
def yuan_to_fen(yuan: float) -> int:
|
||||||
|
"""元转分"""
|
||||||
|
return int(round(yuan * 100))
|
||||||
|
|
||||||
|
|
||||||
|
def fen_to_yuan(fen: int) -> float:
|
||||||
|
"""分转元"""
|
||||||
|
return round(fen / 100, 2)
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"""
|
||||||
|
微信支付网关实现 (Wechat Pay Gateway)
|
||||||
|
基于 www.lytiao.com 项目提取
|
||||||
|
|
||||||
|
作者: 卡若
|
||||||
|
版本: v4.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from payment_factory import (
|
||||||
|
AbstractGateway, CreateTradeData, TradeResult, NotifyResult,
|
||||||
|
SignatureError, logger
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WechatGateway(AbstractGateway):
|
||||||
|
"""
|
||||||
|
微信支付网关
|
||||||
|
|
||||||
|
支持:
|
||||||
|
- Native扫码支付 (platform_type='native')
|
||||||
|
- JSAPI公众号/小程序支付 (platform_type='jsapi')
|
||||||
|
- H5支付 (platform_type='h5')
|
||||||
|
- APP支付 (platform_type='app')
|
||||||
|
"""
|
||||||
|
|
||||||
|
UNIFIED_ORDER_URL = 'https://api.mch.weixin.qq.com/pay/unifiedorder'
|
||||||
|
ORDER_QUERY_URL = 'https://api.mch.weixin.qq.com/pay/orderquery'
|
||||||
|
CLOSE_ORDER_URL = 'https://api.mch.weixin.qq.com/pay/closeorder'
|
||||||
|
REFUND_URL = 'https://api.mch.weixin.qq.com/secapi/pay/refund'
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any]):
|
||||||
|
super().__init__(config)
|
||||||
|
self.appid = config.get('appid', '')
|
||||||
|
self.app_secret = config.get('app_secret', '')
|
||||||
|
self.mch_id = config.get('mch_id', '')
|
||||||
|
self.mch_key = config.get('mch_key', '')
|
||||||
|
self.cert_path = config.get('cert_path', '')
|
||||||
|
self.key_path = config.get('key_path', '')
|
||||||
|
|
||||||
|
def create_trade(self, data: CreateTradeData) -> TradeResult:
|
||||||
|
"""创建微信支付交易"""
|
||||||
|
platform_type = data.platform_type.upper()
|
||||||
|
|
||||||
|
# 构建统一下单参数
|
||||||
|
params = {
|
||||||
|
'appid': self.appid,
|
||||||
|
'mch_id': self.mch_id,
|
||||||
|
'nonce_str': self._generate_nonce(),
|
||||||
|
'body': data.goods_title[:128],
|
||||||
|
'out_trade_no': data.trade_sn,
|
||||||
|
'total_fee': str(data.amount), # 微信以分为单位
|
||||||
|
'spbill_create_ip': data.create_ip or '127.0.0.1',
|
||||||
|
'notify_url': data.notify_url,
|
||||||
|
'trade_type': platform_type,
|
||||||
|
'attach': json.dumps(data.attach) if data.attach else '',
|
||||||
|
}
|
||||||
|
|
||||||
|
# JSAPI需要openid
|
||||||
|
if platform_type == 'JSAPI':
|
||||||
|
if not data.open_id:
|
||||||
|
raise ValueError("微信JSAPI支付需要提供 openid")
|
||||||
|
params['openid'] = data.open_id
|
||||||
|
|
||||||
|
# H5支付需要scene_info
|
||||||
|
if platform_type == 'MWEB':
|
||||||
|
params['scene_info'] = json.dumps({
|
||||||
|
'h5_info': {
|
||||||
|
'type': 'Wap',
|
||||||
|
'wap_url': data.return_url,
|
||||||
|
'wap_name': data.goods_title[:32]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 生成签名
|
||||||
|
params['sign'] = self._generate_sign(params)
|
||||||
|
|
||||||
|
# 调用统一下单接口
|
||||||
|
import requests
|
||||||
|
xml_data = self._dict_to_xml(params)
|
||||||
|
response = requests.post(self.UNIFIED_ORDER_URL, data=xml_data.encode('utf-8'))
|
||||||
|
result = self._xml_to_dict(response.text)
|
||||||
|
|
||||||
|
if result.get('return_code') != 'SUCCESS':
|
||||||
|
raise Exception(f"微信支付统一下单失败: {result.get('return_msg')}")
|
||||||
|
|
||||||
|
if result.get('result_code') != 'SUCCESS':
|
||||||
|
raise Exception(f"微信支付统一下单失败: {result.get('err_code_des')}")
|
||||||
|
|
||||||
|
# 根据类型返回不同格式
|
||||||
|
if platform_type == 'NATIVE':
|
||||||
|
# 扫码支付返回二维码链接
|
||||||
|
return TradeResult(
|
||||||
|
type='qrcode',
|
||||||
|
payload=result['code_url'],
|
||||||
|
trade_sn=data.trade_sn
|
||||||
|
)
|
||||||
|
|
||||||
|
elif platform_type == 'JSAPI':
|
||||||
|
# 公众号支付返回JS SDK参数
|
||||||
|
prepay_id = result['prepay_id']
|
||||||
|
js_params = {
|
||||||
|
'appId': self.appid,
|
||||||
|
'timeStamp': str(int(time.time())),
|
||||||
|
'nonceStr': self._generate_nonce(),
|
||||||
|
'package': f'prepay_id={prepay_id}',
|
||||||
|
'signType': 'MD5',
|
||||||
|
}
|
||||||
|
js_params['paySign'] = self._generate_sign(js_params)
|
||||||
|
|
||||||
|
return TradeResult(
|
||||||
|
type='json',
|
||||||
|
payload=js_params,
|
||||||
|
trade_sn=data.trade_sn
|
||||||
|
)
|
||||||
|
|
||||||
|
elif platform_type == 'MWEB':
|
||||||
|
# H5支付返回跳转链接
|
||||||
|
mweb_url = result['mweb_url']
|
||||||
|
if data.return_url:
|
||||||
|
mweb_url += f"&redirect_url={urlencode({'': data.return_url})[1:]}"
|
||||||
|
|
||||||
|
return TradeResult(
|
||||||
|
type='url',
|
||||||
|
payload=mweb_url,
|
||||||
|
trade_sn=data.trade_sn
|
||||||
|
)
|
||||||
|
|
||||||
|
elif platform_type == 'APP':
|
||||||
|
# APP支付返回SDK参数
|
||||||
|
prepay_id = result['prepay_id']
|
||||||
|
app_params = {
|
||||||
|
'appid': self.appid,
|
||||||
|
'partnerid': self.mch_id,
|
||||||
|
'prepayid': prepay_id,
|
||||||
|
'package': 'Sign=WXPay',
|
||||||
|
'noncestr': self._generate_nonce(),
|
||||||
|
'timestamp': str(int(time.time())),
|
||||||
|
}
|
||||||
|
app_params['sign'] = self._generate_sign(app_params)
|
||||||
|
|
||||||
|
return TradeResult(
|
||||||
|
type='json',
|
||||||
|
payload=app_params,
|
||||||
|
trade_sn=data.trade_sn
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(f"不支持的微信支付类型: {platform_type}")
|
||||||
|
|
||||||
|
def verify_sign(self, data: Dict[str, Any]) -> bool:
|
||||||
|
"""验证微信签名"""
|
||||||
|
sign = data.pop('sign', '')
|
||||||
|
if not sign:
|
||||||
|
return False
|
||||||
|
|
||||||
|
calculated_sign = self._generate_sign(data)
|
||||||
|
return calculated_sign == sign
|
||||||
|
|
||||||
|
def parse_notify(self, data: Any) -> NotifyResult:
|
||||||
|
"""解析微信回调"""
|
||||||
|
# 如果是XML字符串,先转换为dict
|
||||||
|
if isinstance(data, str):
|
||||||
|
data = self._xml_to_dict(data)
|
||||||
|
|
||||||
|
# 验证签名
|
||||||
|
if not self.verify_sign(data.copy()):
|
||||||
|
raise SignatureError("微信签名验证失败")
|
||||||
|
|
||||||
|
result_code = data.get('result_code', '')
|
||||||
|
|
||||||
|
if result_code == 'SUCCESS':
|
||||||
|
status = 'paid'
|
||||||
|
else:
|
||||||
|
status = 'failed'
|
||||||
|
|
||||||
|
# 解析透传参数
|
||||||
|
attach = {}
|
||||||
|
attach_str = data.get('attach', '')
|
||||||
|
if attach_str:
|
||||||
|
try:
|
||||||
|
attach = json.loads(attach_str)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 解析支付时间 (格式: 20240117100530)
|
||||||
|
time_end = data.get('time_end', '')
|
||||||
|
if time_end:
|
||||||
|
pay_time = int(time.mktime(time.strptime(time_end, '%Y%m%d%H%M%S')))
|
||||||
|
else:
|
||||||
|
pay_time = int(time.time())
|
||||||
|
|
||||||
|
return NotifyResult(
|
||||||
|
status=status,
|
||||||
|
trade_sn=data.get('out_trade_no', ''),
|
||||||
|
platform_sn=data.get('transaction_id', ''),
|
||||||
|
pay_amount=int(data.get('cash_fee', 0)),
|
||||||
|
pay_time=pay_time,
|
||||||
|
currency=data.get('fee_type', 'CNY'),
|
||||||
|
attach=attach,
|
||||||
|
raw_data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
def close_trade(self, trade_sn: str) -> bool:
|
||||||
|
"""关闭微信交易"""
|
||||||
|
params = {
|
||||||
|
'appid': self.appid,
|
||||||
|
'mch_id': self.mch_id,
|
||||||
|
'out_trade_no': trade_sn,
|
||||||
|
'nonce_str': self._generate_nonce(),
|
||||||
|
}
|
||||||
|
params['sign'] = self._generate_sign(params)
|
||||||
|
|
||||||
|
import requests
|
||||||
|
xml_data = self._dict_to_xml(params)
|
||||||
|
response = requests.post(self.CLOSE_ORDER_URL, data=xml_data.encode('utf-8'))
|
||||||
|
result = self._xml_to_dict(response.text)
|
||||||
|
|
||||||
|
return result.get('result_code') == 'SUCCESS'
|
||||||
|
|
||||||
|
def query_trade(self, trade_sn: str) -> Optional[NotifyResult]:
|
||||||
|
"""查询微信交易"""
|
||||||
|
params = {
|
||||||
|
'appid': self.appid,
|
||||||
|
'mch_id': self.mch_id,
|
||||||
|
'out_trade_no': trade_sn,
|
||||||
|
'nonce_str': self._generate_nonce(),
|
||||||
|
}
|
||||||
|
params['sign'] = self._generate_sign(params)
|
||||||
|
|
||||||
|
import requests
|
||||||
|
xml_data = self._dict_to_xml(params)
|
||||||
|
response = requests.post(self.ORDER_QUERY_URL, data=xml_data.encode('utf-8'))
|
||||||
|
result = self._xml_to_dict(response.text)
|
||||||
|
|
||||||
|
if result.get('trade_state') == 'SUCCESS':
|
||||||
|
return self.parse_notify(result)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def refund(self, trade_sn: str, refund_sn: str, amount: int, reason: str = '') -> bool:
|
||||||
|
"""微信退款 (需要证书)"""
|
||||||
|
# 先查询原交易获取金额
|
||||||
|
query_result = self.query_trade(trade_sn)
|
||||||
|
if not query_result:
|
||||||
|
return False
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'appid': self.appid,
|
||||||
|
'mch_id': self.mch_id,
|
||||||
|
'nonce_str': self._generate_nonce(),
|
||||||
|
'out_trade_no': trade_sn,
|
||||||
|
'out_refund_no': refund_sn,
|
||||||
|
'total_fee': str(query_result.pay_amount),
|
||||||
|
'refund_fee': str(amount),
|
||||||
|
'refund_desc': reason or '用户申请退款',
|
||||||
|
}
|
||||||
|
params['sign'] = self._generate_sign(params)
|
||||||
|
|
||||||
|
import requests
|
||||||
|
xml_data = self._dict_to_xml(params)
|
||||||
|
|
||||||
|
# 退款需要证书
|
||||||
|
response = requests.post(
|
||||||
|
self.REFUND_URL,
|
||||||
|
data=xml_data.encode('utf-8'),
|
||||||
|
cert=(self.cert_path, self.key_path)
|
||||||
|
)
|
||||||
|
result = self._xml_to_dict(response.text)
|
||||||
|
|
||||||
|
return result.get('result_code') == 'SUCCESS'
|
||||||
|
|
||||||
|
def success_response(self) -> str:
|
||||||
|
"""微信回调成功响应"""
|
||||||
|
return '<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>'
|
||||||
|
|
||||||
|
def fail_response(self) -> str:
|
||||||
|
"""微信回调失败响应"""
|
||||||
|
return '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[ERROR]]></return_msg></xml>'
|
||||||
|
|
||||||
|
def _generate_nonce(self) -> str:
|
||||||
|
"""生成随机字符串"""
|
||||||
|
return uuid.uuid4().hex
|
||||||
|
|
||||||
|
def _generate_sign(self, params: dict) -> str:
|
||||||
|
"""生成MD5签名"""
|
||||||
|
# 排序并拼接
|
||||||
|
sorted_params = sorted([(k, v) for k, v in params.items() if v and k != 'sign'])
|
||||||
|
sign_str = '&'.join([f'{k}={v}' for k, v in sorted_params])
|
||||||
|
sign_str += f'&key={self.mch_key}'
|
||||||
|
|
||||||
|
# MD5签名
|
||||||
|
return hashlib.md5(sign_str.encode('utf-8')).hexdigest().upper()
|
||||||
|
|
||||||
|
def _dict_to_xml(self, data: dict) -> str:
|
||||||
|
"""字典转XML"""
|
||||||
|
xml = ['<xml>']
|
||||||
|
for k, v in data.items():
|
||||||
|
if isinstance(v, str):
|
||||||
|
xml.append(f'<{k}><![CDATA[{v}]]></{k}>')
|
||||||
|
else:
|
||||||
|
xml.append(f'<{k}>{v}</{k}>')
|
||||||
|
xml.append('</xml>')
|
||||||
|
return ''.join(xml)
|
||||||
|
|
||||||
|
def _xml_to_dict(self, xml_str: str) -> dict:
|
||||||
|
"""XML转字典"""
|
||||||
|
root = ET.fromstring(xml_str)
|
||||||
|
return {child.tag: child.text for child in root}
|
||||||
101
addons/Universal_Payment_Module copy/4_卡若配置/env.example.txt
Normal file
101
addons/Universal_Payment_Module copy/4_卡若配置/env.example.txt
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# 卡若私域支付配置 (Karuo Payment Config)
|
||||||
|
# ============================================================================
|
||||||
|
# ⚠️ 警告: 此文件包含敏感信息,请勿提交到 Git!
|
||||||
|
# 使用方法: 复制此文件为 .env 并填入真实值
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 1. 基础环境
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
APP_ENV=production
|
||||||
|
APP_NAME=卡若私域
|
||||||
|
APP_URL=https://www.lytiao.com
|
||||||
|
APP_CURRENCY=CNY
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 2. 数据库 (卡若私域数据库)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=10.88.182.62
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=payment_db
|
||||||
|
DB_USERNAME=root
|
||||||
|
DB_PASSWORD=Vtka(agu)-1
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 3. 支付宝 (Alipay)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
ALIPAY_ENABLED=true
|
||||||
|
ALIPAY_MODE=production
|
||||||
|
ALIPAY_APP_ID=
|
||||||
|
ALIPAY_PID=2088511801157159
|
||||||
|
ALIPAY_SELLER_EMAIL=zhengzhiqun@vip.qq.com
|
||||||
|
ALIPAY_PRIVATE_KEY=
|
||||||
|
ALIPAY_PUBLIC_KEY=
|
||||||
|
ALIPAY_MD5_KEY=lz6ey1h3kl9zqkgtjz3avb5gk37wzbrp
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 4. 微信支付 (Wechat Pay)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
WECHAT_ENABLED=true
|
||||||
|
WECHAT_MODE=production
|
||||||
|
|
||||||
|
# 网站/H5支付
|
||||||
|
WECHAT_APPID=wx432c93e275548671
|
||||||
|
WECHAT_APP_SECRET=25b7e7fdb7998e5107e242ebb6ddabd0
|
||||||
|
|
||||||
|
# 服务号 (JSAPI)
|
||||||
|
WECHAT_SERVICE_APPID=wx7c0dbf34ddba300d
|
||||||
|
WECHAT_SERVICE_SECRET=f865ef18c43dfea6cbe3b1f1aebdb82e
|
||||||
|
|
||||||
|
# 商户信息
|
||||||
|
WECHAT_MCH_ID=1318592501
|
||||||
|
WECHAT_MCH_KEY=wx3e31b068be59ddc131b068be59ddc2
|
||||||
|
|
||||||
|
# 证书路径
|
||||||
|
WECHAT_CERT_PATH=./cert/wechat/apiclient_cert.pem
|
||||||
|
WECHAT_KEY_PATH=./cert/wechat/apiclient_key.pem
|
||||||
|
|
||||||
|
# MP文件验证码
|
||||||
|
WECHAT_MP_VERIFY=SP8AfZJyAvprRORT
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 5. PayPal (暂未启用)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
PAYPAL_ENABLED=false
|
||||||
|
PAYPAL_MODE=sandbox
|
||||||
|
PAYPAL_CLIENT_ID=
|
||||||
|
PAYPAL_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 6. Stripe (暂未启用)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
STRIPE_ENABLED=false
|
||||||
|
STRIPE_MODE=test
|
||||||
|
STRIPE_PUBLIC_KEY=
|
||||||
|
STRIPE_SECRET_KEY=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 7. USDT (暂未启用)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
USDT_ENABLED=false
|
||||||
|
USDT_GATEWAY_TYPE=nowpayments
|
||||||
|
NOWPAYMENTS_API_KEY=
|
||||||
|
NOWPAYMENTS_IPN_SECRET=
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 8. 高级配置
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 虚拟币/积分
|
||||||
|
COIN_ENABLED=false
|
||||||
|
COIN_RATE=100
|
||||||
|
|
||||||
|
# 订单配置
|
||||||
|
ORDER_EXPIRE_MINUTES=30
|
||||||
|
TRADE_SN_PREFIX=T
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
PAYMENT_LOG_LEVEL=info
|
||||||
|
PAYMENT_LOG_PATH=./logs/payment.log
|
||||||
142
addons/Universal_Payment_Module copy/README.md
Normal file
142
addons/Universal_Payment_Module copy/README.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# 🌐 全球通用支付模块 (Universal Payment Module) v4.0
|
||||||
|
|
||||||
|
> **配置驱动 (Configuration-Driven)** | **API 优先 (API-First)** | **AI 智能对接**
|
||||||
|
>
|
||||||
|
> 让任何语言的项目在 5 分钟内接入支付宝、微信支付、PayPal、Stripe 和 USDT
|
||||||
|
|
||||||
|
## 📂 模块结构
|
||||||
|
|
||||||
|
```
|
||||||
|
Universal_Payment_Module/
|
||||||
|
├── 1_核心设计_通用协议/ # [灵魂] 定义了支付的"法律"
|
||||||
|
│ ├── 标准配置模板.yaml # 填空即可配置所有支付参数
|
||||||
|
│ ├── API接口定义.md # 无论用什么语言,接口都长这样
|
||||||
|
│ ├── 业务逻辑与模型.md # 数据库表结构设计 (Order/PayTrade)
|
||||||
|
│ └── 安全与合规.md # 支付安全最佳实践
|
||||||
|
│
|
||||||
|
├── 2_智能对接_AI指令/ # [工具] AI 编译器
|
||||||
|
│ ├── 通用集成指令.md # 发给 AI,自动生成代码
|
||||||
|
│ └── Cursor规则.md # Cursor IDE 专用规则
|
||||||
|
│
|
||||||
|
├── 3_逻辑参考_通用实现/ # [参考] 可直接复用的代码
|
||||||
|
│ ├── 前端收银台Demo.html # 原生 JS 实现的通用收银台
|
||||||
|
│ ├── 后端源码/ # 多语言参考实现
|
||||||
|
│ │ ├── php/ # PHP (Laravel/Symfony)
|
||||||
|
│ │ ├── python/ # Python (FastAPI/Django)
|
||||||
|
│ │ ├── nodejs/ # Node.js (Express/NestJS)
|
||||||
|
│ │ └── java/ # Java (Spring Boot)
|
||||||
|
│ └── 前端模板/ # Vue/React/原生JS 模板
|
||||||
|
│
|
||||||
|
├── 4_卡若配置/ # [私有] 卡若的支付密钥 (勿提交Git)
|
||||||
|
│ └── .env.example # 配置示例
|
||||||
|
│
|
||||||
|
└── README.md # 本说明文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 极速对接 (3步完成)
|
||||||
|
|
||||||
|
### 第一步:配置 (Config)
|
||||||
|
```bash
|
||||||
|
# 1. 复制配置模板到你的项目
|
||||||
|
cp 1_核心设计_通用协议/标准配置模板.yaml your-project/.env
|
||||||
|
|
||||||
|
# 2. 填入你的支付密钥
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第二步:生成代码 (Generate with AI)
|
||||||
|
```
|
||||||
|
发送给 Cursor/ChatGPT:
|
||||||
|
"请读取 Universal_Payment_Module 目录,我的项目是 Python FastAPI,
|
||||||
|
采用嵌入式集成,帮我生成支付模块代码。"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第三步:前端接入 (Frontend)
|
||||||
|
```javascript
|
||||||
|
// 只需调用一个 API
|
||||||
|
const result = await fetch('/api/payment/checkout', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ order_sn: '202401170001', gateway: 'wechat_jsapi' })
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🌍 支持的支付渠道
|
||||||
|
|
||||||
|
| 渠道 | 能力 | 场景 | 状态 |
|
||||||
|
|:---|:---|:---|:---|
|
||||||
|
| **支付宝 Alipay** | 扫码/H5/APP/小程序 | 中国市场 (CNY) | ✅ 已实现 |
|
||||||
|
| **微信支付 Wechat** | JSAPI/Native/H5/APP/小程序 | 中国市场 (CNY) | ✅ 已实现 |
|
||||||
|
| **PayPal** | 信用卡/订阅 | 全球市场 (USD/EUR) | ✅ 已实现 |
|
||||||
|
| **Stripe** | 信用卡/订阅/Apple Pay | 全球市场 | ✅ 已实现 |
|
||||||
|
| **USDT (TRC20)** | 链上转账/监听 | Web3/抗审查 | ✅ 已实现 |
|
||||||
|
|
||||||
|
## ✨ v4.0 核心特性
|
||||||
|
|
||||||
|
### 1. 配置驱动 (Zero-Code Config)
|
||||||
|
- 所有密钥通过环境变量注入,无需改动代码
|
||||||
|
- 支持多环境切换 (development/production)
|
||||||
|
|
||||||
|
### 2. 工厂模式 (Payment Factory)
|
||||||
|
```python
|
||||||
|
# 所有支付网关统一接口
|
||||||
|
payment = PaymentFactory.create('wechat')
|
||||||
|
result = payment.create_trade(order)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 幂等性保障 (Idempotency)
|
||||||
|
- 回调通知自动去重
|
||||||
|
- 订单状态机管理
|
||||||
|
|
||||||
|
### 4. AI 智能生成
|
||||||
|
- 提供 Cursor/Copilot 专用提示词
|
||||||
|
- 一键生成任意语言的完整实现
|
||||||
|
|
||||||
|
## 📖 快速参考
|
||||||
|
|
||||||
|
### 创建订单
|
||||||
|
```http
|
||||||
|
POST /api/payment/create_order
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"user_id": "u1001",
|
||||||
|
"title": "VIP会员",
|
||||||
|
"amount": 99.00,
|
||||||
|
"currency": "CNY",
|
||||||
|
"product_id": "vip_monthly"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发起支付
|
||||||
|
```http
|
||||||
|
POST /api/payment/checkout
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"order_sn": "202401170001",
|
||||||
|
"gateway": "wechat_jsapi",
|
||||||
|
"openid": "oXxx..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 支付状态查询
|
||||||
|
```http
|
||||||
|
GET /api/payment/status/202401170001
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 安全须知
|
||||||
|
|
||||||
|
1. **密钥安全**: 所有密钥存放在 `.env`,**绝不提交到 Git**
|
||||||
|
2. **HTTPS 强制**: 生产环境必须启用 HTTPS
|
||||||
|
3. **签名验证**: 所有回调必须验签
|
||||||
|
4. **金额校验**: 支付金额必须与订单金额匹配
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [API 接口定义](./1_核心设计_通用协议/API接口定义.md)
|
||||||
|
- [数据库模型](./1_核心设计_通用协议/业务逻辑与模型.md)
|
||||||
|
- [AI 集成指令](./2_智能对接_AI指令/通用集成指令.md)
|
||||||
|
- [Cursor 规则](./2_智能对接_AI指令/Cursor规则.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**作者**: 卡若 | **联系**: 28533368 (微信) | **更新**: 2026-01-17
|
||||||
413
api/soul导师顾问接口.md
Normal file
413
api/soul导师顾问接口.md
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
# 对外获客线索上报接口文档(V1)
|
||||||
|
|
||||||
|
## 一、接口概述
|
||||||
|
|
||||||
|
- **接口名称**:对外获客线索上报接口
|
||||||
|
- **接口用途**:供第三方系统向【存客宝】上报客户线索(手机号 / 微信号等),用于后续的跟进、标签管理和画像分析。
|
||||||
|
- **接口协议**:HTTP
|
||||||
|
- **请求方式**:`POST`
|
||||||
|
- **请求地址**: `https://ckbapi.quwanzhi.com/v1/api/scenarios`
|
||||||
|
|
||||||
|
> 具体 URL 以实际环境配置为准。
|
||||||
|
|
||||||
|
- **数据格式**:
|
||||||
|
- 推荐:`application/json`
|
||||||
|
- 兼容:`application/x-www-form-urlencoded`
|
||||||
|
- **字符编码**:`UTF-8`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、鉴权与签名
|
||||||
|
|
||||||
|
### 2.1 必填鉴权字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
|-------------|--------|------|---------------------------------------|
|
||||||
|
| `apiKey` | string | 是 | 分配给第三方的接口密钥(每个任务唯一)|
|
||||||
|
| `sign` | string | 是 | 签名值 |
|
||||||
|
| `timestamp` | int | 是 | 秒级时间戳(与服务器时间差不超过 5 分钟) |
|
||||||
|
|
||||||
|
### 2.2 时间戳校验
|
||||||
|
|
||||||
|
服务器会校验 `timestamp` 是否在当前时间前后 **5 分钟** 内:
|
||||||
|
|
||||||
|
- 通过条件:`|server_time - timestamp| <= 300`
|
||||||
|
- 超出范围则返回:`请求已过期`
|
||||||
|
|
||||||
|
### 2.3 签名生成规则
|
||||||
|
|
||||||
|
接口采用自定义签名机制。**签名字段为 `sign`,生成步骤如下:**
|
||||||
|
|
||||||
|
假设本次请求的所有参数为 `params`,其中包括业务参数 + `apiKey` + `timestamp` + `sign` + 可能存在的 `portrait` 对象。
|
||||||
|
|
||||||
|
#### 第一步:移除特定字段
|
||||||
|
|
||||||
|
从 `params` 中移除以下字段:
|
||||||
|
|
||||||
|
- `sign` —— 自身不参与签名
|
||||||
|
- `apiKey` —— 不参与参数拼接,仅在最后一步参与二次 MD5
|
||||||
|
- `portrait` —— 整个画像对象不参与签名(即使内部还有子字段)
|
||||||
|
|
||||||
|
> 说明:`portrait` 通常是一个 JSON 对象,字段较多,为避免签名实现复杂且双方难以对齐,统一不参与签名。
|
||||||
|
|
||||||
|
#### 第二步:移除空值字段
|
||||||
|
|
||||||
|
从剩余参数中,移除值为:
|
||||||
|
|
||||||
|
- `null`
|
||||||
|
- 空字符串 `''`
|
||||||
|
|
||||||
|
的字段,这些字段不参与签名。
|
||||||
|
|
||||||
|
#### 第三步:按参数名升序排序
|
||||||
|
|
||||||
|
对剩余参数按**参数名(键名)升序排序**,排序规则为标准的 ASCII 升序:
|
||||||
|
|
||||||
|
```text
|
||||||
|
例如: name, phone, source, timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 第四步:拼接参数值
|
||||||
|
|
||||||
|
将排序后的参数 **只取“值”**,按顺序直接拼接为一个字符串,中间不加任何分隔符:
|
||||||
|
|
||||||
|
- 示例:
|
||||||
|
排序后参数为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
name = 张三
|
||||||
|
phone = 13800000000
|
||||||
|
source = 微信广告
|
||||||
|
timestamp = 1710000000
|
||||||
|
```
|
||||||
|
|
||||||
|
则拼接:
|
||||||
|
|
||||||
|
```text
|
||||||
|
stringToSign = "张三13800000000微信广告1710000000"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 第五步:第一次 MD5
|
||||||
|
|
||||||
|
对上一步拼接得到的字符串做一次 MD5:
|
||||||
|
|
||||||
|
\[
|
||||||
|
\text{firstMd5} = \text{MD5}(\text{stringToSign})
|
||||||
|
\]
|
||||||
|
|
||||||
|
#### 第六步:拼接 apiKey 再次 MD5
|
||||||
|
|
||||||
|
将第一步的结果与 `apiKey` 直接拼接,再做一次 MD5,得到最终签名值:
|
||||||
|
|
||||||
|
\[
|
||||||
|
\text{sign} = \text{MD5}(\text{firstMd5} + \text{apiKey})
|
||||||
|
\]
|
||||||
|
|
||||||
|
#### 第七步:放入请求
|
||||||
|
|
||||||
|
将第六步得到的 `sign` 填入请求参数中的 `sign` 字段即可。
|
||||||
|
|
||||||
|
> 建议:
|
||||||
|
> - 使用小写 MD5 字符串(双方约定统一即可)。
|
||||||
|
> - 请确保参与签名的参数与最终请求发送的参数一致(包括是否传空值)。
|
||||||
|
|
||||||
|
### 2.4 签名示例(PHP 伪代码)
|
||||||
|
|
||||||
|
```php
|
||||||
|
$params = [
|
||||||
|
'apiKey' => 'YOUR_API_KEY',
|
||||||
|
'timestamp' => '1710000000',
|
||||||
|
'phone' => '13800000000',
|
||||||
|
'name' => '张三',
|
||||||
|
'source' => '微信广告',
|
||||||
|
'remark' => '通过H5落地页留资',
|
||||||
|
// 'portrait' => [...], // 如有画像,这里会存在,但不参与签名
|
||||||
|
// 'sign' => '待生成',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 1. 去掉 sign、apiKey、portrait
|
||||||
|
unset($params['sign'], $params['apiKey'], $params['portrait']);
|
||||||
|
|
||||||
|
// 2. 去掉空值
|
||||||
|
$params = array_filter($params, function($value) {
|
||||||
|
return !is_null($value) && $value !== '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 按键名升序排序
|
||||||
|
ksort($params);
|
||||||
|
|
||||||
|
// 4. 拼接参数值
|
||||||
|
$stringToSign = implode('', array_values($params));
|
||||||
|
|
||||||
|
// 5. 第一次 MD5
|
||||||
|
$firstMd5 = md5($stringToSign);
|
||||||
|
|
||||||
|
// 6. 第二次 MD5(拼接 apiKey)
|
||||||
|
$apiKey = 'YOUR_API_KEY';
|
||||||
|
$sign = md5($firstMd5 . $apiKey);
|
||||||
|
|
||||||
|
// 将 $sign 作为字段发送
|
||||||
|
$params['sign'] = $sign;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、请求参数说明
|
||||||
|
|
||||||
|
### 3.1 主标识字段(至少传一个)
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
|-----------|--------|------|-------------------------------------------|
|
||||||
|
| `wechatId`| string | 否 | 微信号,存在时优先作为主标识 |
|
||||||
|
| `phone` | string | 否 | 手机号,当 `wechatId` 为空时用作主标识 |
|
||||||
|
|
||||||
|
### 3.2 基础信息字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
|------------|--------|------|-------------------------|
|
||||||
|
| `name` | string | 否 | 客户姓名 |
|
||||||
|
| `source` | string | 否 | 线索来源描述,如“百度推广”、“抖音直播间” |
|
||||||
|
| `remark` | string | 否 | 备注信息 |
|
||||||
|
| `tags` | string | 否 | 逗号分隔的“微信标签”,如:`"高意向,电商,女装"` |
|
||||||
|
| `siteTags` | string | 否 | 逗号分隔的“站内标签”,用于站内进一步细分 |
|
||||||
|
|
||||||
|
|
||||||
|
### 3.3 用户画像字段 `portrait`(可选)
|
||||||
|
|
||||||
|
`portrait` 为一个对象(JSON),用于记录用户的行为画像数据。
|
||||||
|
|
||||||
|
#### 3.3.1 基本示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
"portrait": {
|
||||||
|
"type": 1,
|
||||||
|
"source": 1,
|
||||||
|
"sourceData": {
|
||||||
|
"age": 28,
|
||||||
|
"gender": "female",
|
||||||
|
"city": "上海",
|
||||||
|
"productId": "P12345",
|
||||||
|
"pageUrl": "https://example.com/product/123"
|
||||||
|
},
|
||||||
|
"remark": "画像-基础属性",
|
||||||
|
"uniqueId": "user_13800000000_20250301_001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.2 字段详细说明
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
|-----------------------|--------|------|----------------------------------------|
|
||||||
|
| `portrait.type` | int | 否 | 画像类型,枚举值:<br>0-浏览<br>1-点击<br>2-下单/购买<br>3-注册<br>4-互动<br>默认值:0 |
|
||||||
|
| `portrait.source` | int | 否 | 画像来源,枚举值:<br>0-本站<br>1-老油条<br>2-老坑爹<br>默认值:0 |
|
||||||
|
| `portrait.sourceData` | object | 否 | 画像明细数据(键值对,会存储为 JSON 格式)<br>可包含任意业务相关的键值对,如:年龄、性别、城市、商品ID、页面URL等 |
|
||||||
|
| `portrait.remark` | string | 否 | 画像备注信息,最大长度100字符 |
|
||||||
|
| `portrait.uniqueId` | string | 否 | 画像去重用唯一 ID<br>用于防止重复记录,相同 `uniqueId` 的画像数据在半小时内会被合并统计(count字段累加)<br>建议格式:`{来源标识}_{用户标识}_{时间戳}_{序号}` |
|
||||||
|
|
||||||
|
#### 3.3.3 画像类型(type)说明
|
||||||
|
|
||||||
|
| 值 | 类型 | 说明 | 适用场景 |
|
||||||
|
|---|------|------|---------|
|
||||||
|
| 0 | 浏览 | 用户浏览了页面或内容 | 页面访问、商品浏览、文章阅读等 |
|
||||||
|
| 1 | 点击 | 用户点击了某个元素 | 按钮点击、链接点击、广告点击等 |
|
||||||
|
| 2 | 下单/购买 | 用户完成了购买行为 | 订单提交、支付完成等 |
|
||||||
|
| 3 | 注册 | 用户完成了注册 | 账号注册、会员注册等 |
|
||||||
|
| 4 | 互动 | 用户进行了互动行为 | 点赞、评论、分享、咨询等 |
|
||||||
|
|
||||||
|
#### 3.3.4 画像来源(source)说明
|
||||||
|
|
||||||
|
| 值 | 来源 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| 0 | 本站 | 来自本站的数据 |
|
||||||
|
| 1 | 老油条 | 来自"老油条"系统的数据 |
|
||||||
|
| 2 | 老坑爹 | 来自"老坑爹"系统的数据 |
|
||||||
|
|
||||||
|
#### 3.3.5 sourceData 数据格式说明
|
||||||
|
|
||||||
|
`sourceData` 是一个 JSON 对象,可以包含任意业务相关的键值对。常见字段示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"age": 28,
|
||||||
|
"gender": "female",
|
||||||
|
"city": "上海",
|
||||||
|
"province": "上海市",
|
||||||
|
"productId": "P12345",
|
||||||
|
"productName": "商品名称",
|
||||||
|
"category": "女装",
|
||||||
|
"price": 299.00,
|
||||||
|
"pageUrl": "https://example.com/product/123",
|
||||||
|
"referrer": "https://www.baidu.com",
|
||||||
|
"device": "mobile",
|
||||||
|
"browser": "WeChat"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:
|
||||||
|
> - `sourceData` 中的数据类型可以是字符串、数字、布尔值等
|
||||||
|
> - 嵌套对象会被序列化为 JSON 字符串存储
|
||||||
|
> - 建议根据实际业务需求定义字段结构
|
||||||
|
|
||||||
|
#### 3.3.6 uniqueId 去重机制说明
|
||||||
|
|
||||||
|
- **作用**:防止重复记录相同的画像数据
|
||||||
|
- **规则**:相同 `uniqueId` 的画像数据在 **半小时内** 会被合并统计,`count` 字段会自动累加
|
||||||
|
- **建议格式**:`{来源标识}_{用户标识}_{时间戳}_{序号}`
|
||||||
|
- 示例:`site_13800000000_1710000000_001`
|
||||||
|
- 示例:`wechat_wxid_abc123_1710000000_001`
|
||||||
|
- **注意事项**:
|
||||||
|
- 如果不传 `uniqueId`,系统会为每条画像数据创建新记录
|
||||||
|
- 如果需要在半小时内多次统计同一行为,应使用相同的 `uniqueId`
|
||||||
|
- 如果需要在半小时后重新统计,应使用不同的 `uniqueId`(建议修改时间戳部分)
|
||||||
|
|
||||||
|
> **重要提示**:`portrait` **整体不参与签名计算**,但会参与业务处理。系统会根据 `uniqueId` 自动处理去重和统计。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、请求示例
|
||||||
|
|
||||||
|
### 4.1 JSON 请求示例(无画像)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiKey": "YOUR_API_KEY",
|
||||||
|
"timestamp": 1710000000,
|
||||||
|
"phone": "13800000000",
|
||||||
|
"name": "张三",
|
||||||
|
"source": "微信广告",
|
||||||
|
"remark": "通过H5落地页留资",
|
||||||
|
"tags": "高意向,电商",
|
||||||
|
"siteTags": "新客,女装",
|
||||||
|
"sign": "根据签名规则生成的MD5字符串"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 JSON 请求示例(带微信号与画像)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiKey": "YOUR_API_KEY",
|
||||||
|
"timestamp": 1710000000,
|
||||||
|
"wechatId": "wxid_abcdefg123",
|
||||||
|
"phone": "13800000001",
|
||||||
|
"name": "李四",
|
||||||
|
"source": "小程序落地页",
|
||||||
|
"remark": "点击【立即咨询】按钮",
|
||||||
|
"tags": "中意向,直播",
|
||||||
|
"siteTags": "复购,高客单",
|
||||||
|
"portrait": {
|
||||||
|
"type": 1,
|
||||||
|
"source": 0,
|
||||||
|
"sourceData": {
|
||||||
|
"age": 28,
|
||||||
|
"gender": "female",
|
||||||
|
"city": "上海",
|
||||||
|
"pageUrl": "https://example.com/product/123",
|
||||||
|
"productId": "P12345"
|
||||||
|
},
|
||||||
|
"remark": "画像-点击行为",
|
||||||
|
"uniqueId": "site_13800000001_1710000000_001"
|
||||||
|
},
|
||||||
|
"sign": "根据签名规则生成的MD5字符串"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 JSON 请求示例(多种画像类型)
|
||||||
|
|
||||||
|
#### 4.3.1 浏览行为画像
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiKey": "YOUR_API_KEY",
|
||||||
|
"timestamp": 1710000000,
|
||||||
|
"phone": "13800000002",
|
||||||
|
"name": "王五",
|
||||||
|
"source": "百度推广",
|
||||||
|
"portrait": {
|
||||||
|
"type": 0,
|
||||||
|
"source": 0,
|
||||||
|
"sourceData": {
|
||||||
|
"pageUrl": "https://example.com/product/456",
|
||||||
|
"productName": "商品名称",
|
||||||
|
"category": "女装",
|
||||||
|
"stayTime": 120,
|
||||||
|
"device": "mobile"
|
||||||
|
},
|
||||||
|
"remark": "商品浏览",
|
||||||
|
"uniqueId": "site_13800000002_1710000000_001"
|
||||||
|
},
|
||||||
|
"sign": "根据签名规则生成的MD5字符串"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、响应说明
|
||||||
|
|
||||||
|
### 5.1 成功响应
|
||||||
|
|
||||||
|
**1)新增线索成功**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "新增成功",
|
||||||
|
"data": "13800000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2)线索已存在**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "已存在",
|
||||||
|
"data": "13800000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `data` 字段返回本次线索的主标识 `wechatId` 或 `phone`。
|
||||||
|
|
||||||
|
### 5.2 常见错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 400, "message": "apiKey不能为空", "data": null }
|
||||||
|
{ "code": 400, "message": "sign不能为空", "data": null }
|
||||||
|
{ "code": 400, "message": "timestamp不能为空", "data": null }
|
||||||
|
{ "code": 400, "message": "请求已过期", "data": null }
|
||||||
|
|
||||||
|
{ "code": 401, "message": "无效的apiKey", "data": null }
|
||||||
|
{ "code": 401, "message": "签名验证失败", "data": null }
|
||||||
|
|
||||||
|
{ "code": 500, "message": "系统错误: 具体错误信息", "data": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 六、常见问题(FAQ)
|
||||||
|
|
||||||
|
### Q1: 如果同一个用户多次上报相同的行为,会如何处理?
|
||||||
|
|
||||||
|
**A**: 如果使用相同的 `uniqueId`,系统会在半小时内合并统计,`count` 字段会累加。如果使用不同的 `uniqueId`,会创建多条记录。
|
||||||
|
|
||||||
|
### Q2: portrait 字段是否必须传递?
|
||||||
|
|
||||||
|
**A**: 不是必须的。`portrait` 字段是可选的,只有在需要记录用户画像数据时才传递。
|
||||||
|
|
||||||
|
### Q3: sourceData 中可以存储哪些类型的数据?
|
||||||
|
|
||||||
|
**A**: `sourceData` 是一个 JSON 对象,可以存储任意键值对。支持字符串、数字、布尔值等基本类型,嵌套对象会被序列化为 JSON 字符串。
|
||||||
|
|
||||||
|
### Q4: uniqueId 的作用是什么?
|
||||||
|
|
||||||
|
**A**: `uniqueId` 用于防止重复记录。相同 `uniqueId` 的画像数据在半小时内会被合并统计,避免重复数据。
|
||||||
|
|
||||||
|
### Q5: 画像数据如何与用户关联?
|
||||||
|
|
||||||
|
**A**: 系统会根据请求中的 `wechatId` 或 `phone` 自动匹配 `traffic_pool` 表中的用户,并将画像数据关联到对应的 `trafficPoolId`。
|
||||||
|
|
||||||
|
---
|
||||||
413
api/soul资源对接接口 copy.md
Normal file
413
api/soul资源对接接口 copy.md
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
# 对外获客线索上报接口文档(V1)
|
||||||
|
|
||||||
|
## 一、接口概述
|
||||||
|
|
||||||
|
- **接口名称**:对外获客线索上报接口
|
||||||
|
- **接口用途**:供第三方系统向【存客宝】上报客户线索(手机号 / 微信号等),用于后续的跟进、标签管理和画像分析。
|
||||||
|
- **接口协议**:HTTP
|
||||||
|
- **请求方式**:`POST`
|
||||||
|
- **请求地址**: `https://ckbapi.quwanzhi.com/v1/api/scenarios`
|
||||||
|
|
||||||
|
> 具体 URL 以实际环境配置为准。
|
||||||
|
|
||||||
|
- **数据格式**:
|
||||||
|
- 推荐:`application/json`
|
||||||
|
- 兼容:`application/x-www-form-urlencoded`
|
||||||
|
- **字符编码**:`UTF-8`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、鉴权与签名
|
||||||
|
|
||||||
|
### 2.1 必填鉴权字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
|-------------|--------|------|---------------------------------------|
|
||||||
|
| `apiKey` | string | 是 | 分配给第三方的接口密钥(每个任务唯一)|
|
||||||
|
| `sign` | string | 是 | 签名值 |
|
||||||
|
| `timestamp` | int | 是 | 秒级时间戳(与服务器时间差不超过 5 分钟) |
|
||||||
|
|
||||||
|
### 2.2 时间戳校验
|
||||||
|
|
||||||
|
服务器会校验 `timestamp` 是否在当前时间前后 **5 分钟** 内:
|
||||||
|
|
||||||
|
- 通过条件:`|server_time - timestamp| <= 300`
|
||||||
|
- 超出范围则返回:`请求已过期`
|
||||||
|
|
||||||
|
### 2.3 签名生成规则
|
||||||
|
|
||||||
|
接口采用自定义签名机制。**签名字段为 `sign`,生成步骤如下:**
|
||||||
|
|
||||||
|
假设本次请求的所有参数为 `params`,其中包括业务参数 + `apiKey` + `timestamp` + `sign` + 可能存在的 `portrait` 对象。
|
||||||
|
|
||||||
|
#### 第一步:移除特定字段
|
||||||
|
|
||||||
|
从 `params` 中移除以下字段:
|
||||||
|
|
||||||
|
- `sign` —— 自身不参与签名
|
||||||
|
- `apiKey` —— 不参与参数拼接,仅在最后一步参与二次 MD5
|
||||||
|
- `portrait` —— 整个画像对象不参与签名(即使内部还有子字段)
|
||||||
|
|
||||||
|
> 说明:`portrait` 通常是一个 JSON 对象,字段较多,为避免签名实现复杂且双方难以对齐,统一不参与签名。
|
||||||
|
|
||||||
|
#### 第二步:移除空值字段
|
||||||
|
|
||||||
|
从剩余参数中,移除值为:
|
||||||
|
|
||||||
|
- `null`
|
||||||
|
- 空字符串 `''`
|
||||||
|
|
||||||
|
的字段,这些字段不参与签名。
|
||||||
|
|
||||||
|
#### 第三步:按参数名升序排序
|
||||||
|
|
||||||
|
对剩余参数按**参数名(键名)升序排序**,排序规则为标准的 ASCII 升序:
|
||||||
|
|
||||||
|
```text
|
||||||
|
例如: name, phone, source, timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 第四步:拼接参数值
|
||||||
|
|
||||||
|
将排序后的参数 **只取“值”**,按顺序直接拼接为一个字符串,中间不加任何分隔符:
|
||||||
|
|
||||||
|
- 示例:
|
||||||
|
排序后参数为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
name = 张三
|
||||||
|
phone = 13800000000
|
||||||
|
source = 微信广告
|
||||||
|
timestamp = 1710000000
|
||||||
|
```
|
||||||
|
|
||||||
|
则拼接:
|
||||||
|
|
||||||
|
```text
|
||||||
|
stringToSign = "张三13800000000微信广告1710000000"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 第五步:第一次 MD5
|
||||||
|
|
||||||
|
对上一步拼接得到的字符串做一次 MD5:
|
||||||
|
|
||||||
|
\[
|
||||||
|
\text{firstMd5} = \text{MD5}(\text{stringToSign})
|
||||||
|
\]
|
||||||
|
|
||||||
|
#### 第六步:拼接 apiKey 再次 MD5
|
||||||
|
|
||||||
|
将第一步的结果与 `apiKey` 直接拼接,再做一次 MD5,得到最终签名值:
|
||||||
|
|
||||||
|
\[
|
||||||
|
\text{sign} = \text{MD5}(\text{firstMd5} + \text{apiKey})
|
||||||
|
\]
|
||||||
|
|
||||||
|
#### 第七步:放入请求
|
||||||
|
|
||||||
|
将第六步得到的 `sign` 填入请求参数中的 `sign` 字段即可。
|
||||||
|
|
||||||
|
> 建议:
|
||||||
|
> - 使用小写 MD5 字符串(双方约定统一即可)。
|
||||||
|
> - 请确保参与签名的参数与最终请求发送的参数一致(包括是否传空值)。
|
||||||
|
|
||||||
|
### 2.4 签名示例(PHP 伪代码)
|
||||||
|
|
||||||
|
```php
|
||||||
|
$params = [
|
||||||
|
'apiKey' => 'YOUR_API_KEY',
|
||||||
|
'timestamp' => '1710000000',
|
||||||
|
'phone' => '13800000000',
|
||||||
|
'name' => '张三',
|
||||||
|
'source' => '微信广告',
|
||||||
|
'remark' => '通过H5落地页留资',
|
||||||
|
// 'portrait' => [...], // 如有画像,这里会存在,但不参与签名
|
||||||
|
// 'sign' => '待生成',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 1. 去掉 sign、apiKey、portrait
|
||||||
|
unset($params['sign'], $params['apiKey'], $params['portrait']);
|
||||||
|
|
||||||
|
// 2. 去掉空值
|
||||||
|
$params = array_filter($params, function($value) {
|
||||||
|
return !is_null($value) && $value !== '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 按键名升序排序
|
||||||
|
ksort($params);
|
||||||
|
|
||||||
|
// 4. 拼接参数值
|
||||||
|
$stringToSign = implode('', array_values($params));
|
||||||
|
|
||||||
|
// 5. 第一次 MD5
|
||||||
|
$firstMd5 = md5($stringToSign);
|
||||||
|
|
||||||
|
// 6. 第二次 MD5(拼接 apiKey)
|
||||||
|
$apiKey = 'YOUR_API_KEY';
|
||||||
|
$sign = md5($firstMd5 . $apiKey);
|
||||||
|
|
||||||
|
// 将 $sign 作为字段发送
|
||||||
|
$params['sign'] = $sign;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、请求参数说明
|
||||||
|
|
||||||
|
### 3.1 主标识字段(至少传一个)
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
|-----------|--------|------|-------------------------------------------|
|
||||||
|
| `wechatId`| string | 否 | 微信号,存在时优先作为主标识 |
|
||||||
|
| `phone` | string | 否 | 手机号,当 `wechatId` 为空时用作主标识 |
|
||||||
|
|
||||||
|
### 3.2 基础信息字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
|------------|--------|------|-------------------------|
|
||||||
|
| `name` | string | 否 | 客户姓名 |
|
||||||
|
| `source` | string | 否 | 线索来源描述,如“百度推广”、“抖音直播间” |
|
||||||
|
| `remark` | string | 否 | 备注信息 |
|
||||||
|
| `tags` | string | 否 | 逗号分隔的“微信标签”,如:`"高意向,电商,女装"` |
|
||||||
|
| `siteTags` | string | 否 | 逗号分隔的“站内标签”,用于站内进一步细分 |
|
||||||
|
|
||||||
|
|
||||||
|
### 3.3 用户画像字段 `portrait`(可选)
|
||||||
|
|
||||||
|
`portrait` 为一个对象(JSON),用于记录用户的行为画像数据。
|
||||||
|
|
||||||
|
#### 3.3.1 基本示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
"portrait": {
|
||||||
|
"type": 1,
|
||||||
|
"source": 1,
|
||||||
|
"sourceData": {
|
||||||
|
"age": 28,
|
||||||
|
"gender": "female",
|
||||||
|
"city": "上海",
|
||||||
|
"productId": "P12345",
|
||||||
|
"pageUrl": "https://example.com/product/123"
|
||||||
|
},
|
||||||
|
"remark": "画像-基础属性",
|
||||||
|
"uniqueId": "user_13800000000_20250301_001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.2 字段详细说明
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
|-----------------------|--------|------|----------------------------------------|
|
||||||
|
| `portrait.type` | int | 否 | 画像类型,枚举值:<br>0-浏览<br>1-点击<br>2-下单/购买<br>3-注册<br>4-互动<br>默认值:0 |
|
||||||
|
| `portrait.source` | int | 否 | 画像来源,枚举值:<br>0-本站<br>1-老油条<br>2-老坑爹<br>默认值:0 |
|
||||||
|
| `portrait.sourceData` | object | 否 | 画像明细数据(键值对,会存储为 JSON 格式)<br>可包含任意业务相关的键值对,如:年龄、性别、城市、商品ID、页面URL等 |
|
||||||
|
| `portrait.remark` | string | 否 | 画像备注信息,最大长度100字符 |
|
||||||
|
| `portrait.uniqueId` | string | 否 | 画像去重用唯一 ID<br>用于防止重复记录,相同 `uniqueId` 的画像数据在半小时内会被合并统计(count字段累加)<br>建议格式:`{来源标识}_{用户标识}_{时间戳}_{序号}` |
|
||||||
|
|
||||||
|
#### 3.3.3 画像类型(type)说明
|
||||||
|
|
||||||
|
| 值 | 类型 | 说明 | 适用场景 |
|
||||||
|
|---|------|------|---------|
|
||||||
|
| 0 | 浏览 | 用户浏览了页面或内容 | 页面访问、商品浏览、文章阅读等 |
|
||||||
|
| 1 | 点击 | 用户点击了某个元素 | 按钮点击、链接点击、广告点击等 |
|
||||||
|
| 2 | 下单/购买 | 用户完成了购买行为 | 订单提交、支付完成等 |
|
||||||
|
| 3 | 注册 | 用户完成了注册 | 账号注册、会员注册等 |
|
||||||
|
| 4 | 互动 | 用户进行了互动行为 | 点赞、评论、分享、咨询等 |
|
||||||
|
|
||||||
|
#### 3.3.4 画像来源(source)说明
|
||||||
|
|
||||||
|
| 值 | 来源 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| 0 | 本站 | 来自本站的数据 |
|
||||||
|
| 1 | 老油条 | 来自"老油条"系统的数据 |
|
||||||
|
| 2 | 老坑爹 | 来自"老坑爹"系统的数据 |
|
||||||
|
|
||||||
|
#### 3.3.5 sourceData 数据格式说明
|
||||||
|
|
||||||
|
`sourceData` 是一个 JSON 对象,可以包含任意业务相关的键值对。常见字段示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"age": 28,
|
||||||
|
"gender": "female",
|
||||||
|
"city": "上海",
|
||||||
|
"province": "上海市",
|
||||||
|
"productId": "P12345",
|
||||||
|
"productName": "商品名称",
|
||||||
|
"category": "女装",
|
||||||
|
"price": 299.00,
|
||||||
|
"pageUrl": "https://example.com/product/123",
|
||||||
|
"referrer": "https://www.baidu.com",
|
||||||
|
"device": "mobile",
|
||||||
|
"browser": "WeChat"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:
|
||||||
|
> - `sourceData` 中的数据类型可以是字符串、数字、布尔值等
|
||||||
|
> - 嵌套对象会被序列化为 JSON 字符串存储
|
||||||
|
> - 建议根据实际业务需求定义字段结构
|
||||||
|
|
||||||
|
#### 3.3.6 uniqueId 去重机制说明
|
||||||
|
|
||||||
|
- **作用**:防止重复记录相同的画像数据
|
||||||
|
- **规则**:相同 `uniqueId` 的画像数据在 **半小时内** 会被合并统计,`count` 字段会自动累加
|
||||||
|
- **建议格式**:`{来源标识}_{用户标识}_{时间戳}_{序号}`
|
||||||
|
- 示例:`site_13800000000_1710000000_001`
|
||||||
|
- 示例:`wechat_wxid_abc123_1710000000_001`
|
||||||
|
- **注意事项**:
|
||||||
|
- 如果不传 `uniqueId`,系统会为每条画像数据创建新记录
|
||||||
|
- 如果需要在半小时内多次统计同一行为,应使用相同的 `uniqueId`
|
||||||
|
- 如果需要在半小时后重新统计,应使用不同的 `uniqueId`(建议修改时间戳部分)
|
||||||
|
|
||||||
|
> **重要提示**:`portrait` **整体不参与签名计算**,但会参与业务处理。系统会根据 `uniqueId` 自动处理去重和统计。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、请求示例
|
||||||
|
|
||||||
|
### 4.1 JSON 请求示例(无画像)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiKey": "YOUR_API_KEY",
|
||||||
|
"timestamp": 1710000000,
|
||||||
|
"phone": "13800000000",
|
||||||
|
"name": "张三",
|
||||||
|
"source": "微信广告",
|
||||||
|
"remark": "通过H5落地页留资",
|
||||||
|
"tags": "高意向,电商",
|
||||||
|
"siteTags": "新客,女装",
|
||||||
|
"sign": "根据签名规则生成的MD5字符串"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 JSON 请求示例(带微信号与画像)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiKey": "YOUR_API_KEY",
|
||||||
|
"timestamp": 1710000000,
|
||||||
|
"wechatId": "wxid_abcdefg123",
|
||||||
|
"phone": "13800000001",
|
||||||
|
"name": "李四",
|
||||||
|
"source": "小程序落地页",
|
||||||
|
"remark": "点击【立即咨询】按钮",
|
||||||
|
"tags": "中意向,直播",
|
||||||
|
"siteTags": "复购,高客单",
|
||||||
|
"portrait": {
|
||||||
|
"type": 1,
|
||||||
|
"source": 0,
|
||||||
|
"sourceData": {
|
||||||
|
"age": 28,
|
||||||
|
"gender": "female",
|
||||||
|
"city": "上海",
|
||||||
|
"pageUrl": "https://example.com/product/123",
|
||||||
|
"productId": "P12345"
|
||||||
|
},
|
||||||
|
"remark": "画像-点击行为",
|
||||||
|
"uniqueId": "site_13800000001_1710000000_001"
|
||||||
|
},
|
||||||
|
"sign": "根据签名规则生成的MD5字符串"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 JSON 请求示例(多种画像类型)
|
||||||
|
|
||||||
|
#### 4.3.1 浏览行为画像
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiKey": "YOUR_API_KEY",
|
||||||
|
"timestamp": 1710000000,
|
||||||
|
"phone": "13800000002",
|
||||||
|
"name": "王五",
|
||||||
|
"source": "百度推广",
|
||||||
|
"portrait": {
|
||||||
|
"type": 0,
|
||||||
|
"source": 0,
|
||||||
|
"sourceData": {
|
||||||
|
"pageUrl": "https://example.com/product/456",
|
||||||
|
"productName": "商品名称",
|
||||||
|
"category": "女装",
|
||||||
|
"stayTime": 120,
|
||||||
|
"device": "mobile"
|
||||||
|
},
|
||||||
|
"remark": "商品浏览",
|
||||||
|
"uniqueId": "site_13800000002_1710000000_001"
|
||||||
|
},
|
||||||
|
"sign": "根据签名规则生成的MD5字符串"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、响应说明
|
||||||
|
|
||||||
|
### 5.1 成功响应
|
||||||
|
|
||||||
|
**1)新增线索成功**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "新增成功",
|
||||||
|
"data": "13800000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2)线索已存在**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "已存在",
|
||||||
|
"data": "13800000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `data` 字段返回本次线索的主标识 `wechatId` 或 `phone`。
|
||||||
|
|
||||||
|
### 5.2 常见错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 400, "message": "apiKey不能为空", "data": null }
|
||||||
|
{ "code": 400, "message": "sign不能为空", "data": null }
|
||||||
|
{ "code": 400, "message": "timestamp不能为空", "data": null }
|
||||||
|
{ "code": 400, "message": "请求已过期", "data": null }
|
||||||
|
|
||||||
|
{ "code": 401, "message": "无效的apiKey", "data": null }
|
||||||
|
{ "code": 401, "message": "签名验证失败", "data": null }
|
||||||
|
|
||||||
|
{ "code": 500, "message": "系统错误: 具体错误信息", "data": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 六、常见问题(FAQ)
|
||||||
|
|
||||||
|
### Q1: 如果同一个用户多次上报相同的行为,会如何处理?
|
||||||
|
|
||||||
|
**A**: 如果使用相同的 `uniqueId`,系统会在半小时内合并统计,`count` 字段会累加。如果使用不同的 `uniqueId`,会创建多条记录。
|
||||||
|
|
||||||
|
### Q2: portrait 字段是否必须传递?
|
||||||
|
|
||||||
|
**A**: 不是必须的。`portrait` 字段是可选的,只有在需要记录用户画像数据时才传递。
|
||||||
|
|
||||||
|
### Q3: sourceData 中可以存储哪些类型的数据?
|
||||||
|
|
||||||
|
**A**: `sourceData` 是一个 JSON 对象,可以存储任意键值对。支持字符串、数字、布尔值等基本类型,嵌套对象会被序列化为 JSON 字符串。
|
||||||
|
|
||||||
|
### Q4: uniqueId 的作用是什么?
|
||||||
|
|
||||||
|
**A**: `uniqueId` 用于防止重复记录。相同 `uniqueId` 的画像数据在半小时内会被合并统计,避免重复数据。
|
||||||
|
|
||||||
|
### Q5: 画像数据如何与用户关联?
|
||||||
|
|
||||||
|
**A**: 系统会根据请求中的 `wechatId` 或 `phone` 自动匹配 `traffic_pool` 表中的用户,并将画像数据关联到对应的 `trafficPoolId`。
|
||||||
|
|
||||||
|
---
|
||||||
413
api/soul资源对接接口.md
Normal file
413
api/soul资源对接接口.md
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
# 对外获客线索上报接口文档(V1)
|
||||||
|
|
||||||
|
## 一、接口概述
|
||||||
|
|
||||||
|
- **接口名称**:对外获客线索上报接口
|
||||||
|
- **接口用途**:供第三方系统向【存客宝】上报客户线索(手机号 / 微信号等),用于后续的跟进、标签管理和画像分析。
|
||||||
|
- **接口协议**:HTTP
|
||||||
|
- **请求方式**:`POST`
|
||||||
|
- **请求地址**: `https://ckbapi.quwanzhi.com/v1/api/scenarios`
|
||||||
|
|
||||||
|
> 具体 URL 以实际环境配置为准。
|
||||||
|
|
||||||
|
- **数据格式**:
|
||||||
|
- 推荐:`application/json`
|
||||||
|
- 兼容:`application/x-www-form-urlencoded`
|
||||||
|
- **字符编码**:`UTF-8`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、鉴权与签名
|
||||||
|
|
||||||
|
### 2.1 必填鉴权字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
|-------------|--------|------|---------------------------------------|
|
||||||
|
| `apiKey` | string | 是 | 分配给第三方的接口密钥(每个任务唯一)|
|
||||||
|
| `sign` | string | 是 | 签名值 |
|
||||||
|
| `timestamp` | int | 是 | 秒级时间戳(与服务器时间差不超过 5 分钟) |
|
||||||
|
|
||||||
|
### 2.2 时间戳校验
|
||||||
|
|
||||||
|
服务器会校验 `timestamp` 是否在当前时间前后 **5 分钟** 内:
|
||||||
|
|
||||||
|
- 通过条件:`|server_time - timestamp| <= 300`
|
||||||
|
- 超出范围则返回:`请求已过期`
|
||||||
|
|
||||||
|
### 2.3 签名生成规则
|
||||||
|
|
||||||
|
接口采用自定义签名机制。**签名字段为 `sign`,生成步骤如下:**
|
||||||
|
|
||||||
|
假设本次请求的所有参数为 `params`,其中包括业务参数 + `apiKey` + `timestamp` + `sign` + 可能存在的 `portrait` 对象。
|
||||||
|
|
||||||
|
#### 第一步:移除特定字段
|
||||||
|
|
||||||
|
从 `params` 中移除以下字段:
|
||||||
|
|
||||||
|
- `sign` —— 自身不参与签名
|
||||||
|
- `apiKey` —— 不参与参数拼接,仅在最后一步参与二次 MD5
|
||||||
|
- `portrait` —— 整个画像对象不参与签名(即使内部还有子字段)
|
||||||
|
|
||||||
|
> 说明:`portrait` 通常是一个 JSON 对象,字段较多,为避免签名实现复杂且双方难以对齐,统一不参与签名。
|
||||||
|
|
||||||
|
#### 第二步:移除空值字段
|
||||||
|
|
||||||
|
从剩余参数中,移除值为:
|
||||||
|
|
||||||
|
- `null`
|
||||||
|
- 空字符串 `''`
|
||||||
|
|
||||||
|
的字段,这些字段不参与签名。
|
||||||
|
|
||||||
|
#### 第三步:按参数名升序排序
|
||||||
|
|
||||||
|
对剩余参数按**参数名(键名)升序排序**,排序规则为标准的 ASCII 升序:
|
||||||
|
|
||||||
|
```text
|
||||||
|
例如: name, phone, source, timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 第四步:拼接参数值
|
||||||
|
|
||||||
|
将排序后的参数 **只取“值”**,按顺序直接拼接为一个字符串,中间不加任何分隔符:
|
||||||
|
|
||||||
|
- 示例:
|
||||||
|
排序后参数为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
name = 张三
|
||||||
|
phone = 13800000000
|
||||||
|
source = 微信广告
|
||||||
|
timestamp = 1710000000
|
||||||
|
```
|
||||||
|
|
||||||
|
则拼接:
|
||||||
|
|
||||||
|
```text
|
||||||
|
stringToSign = "张三13800000000微信广告1710000000"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 第五步:第一次 MD5
|
||||||
|
|
||||||
|
对上一步拼接得到的字符串做一次 MD5:
|
||||||
|
|
||||||
|
\[
|
||||||
|
\text{firstMd5} = \text{MD5}(\text{stringToSign})
|
||||||
|
\]
|
||||||
|
|
||||||
|
#### 第六步:拼接 apiKey 再次 MD5
|
||||||
|
|
||||||
|
将第一步的结果与 `apiKey` 直接拼接,再做一次 MD5,得到最终签名值:
|
||||||
|
|
||||||
|
\[
|
||||||
|
\text{sign} = \text{MD5}(\text{firstMd5} + \text{apiKey})
|
||||||
|
\]
|
||||||
|
|
||||||
|
#### 第七步:放入请求
|
||||||
|
|
||||||
|
将第六步得到的 `sign` 填入请求参数中的 `sign` 字段即可。
|
||||||
|
|
||||||
|
> 建议:
|
||||||
|
> - 使用小写 MD5 字符串(双方约定统一即可)。
|
||||||
|
> - 请确保参与签名的参数与最终请求发送的参数一致(包括是否传空值)。
|
||||||
|
|
||||||
|
### 2.4 签名示例(PHP 伪代码)
|
||||||
|
|
||||||
|
```php
|
||||||
|
$params = [
|
||||||
|
'apiKey' => 'YOUR_API_KEY',
|
||||||
|
'timestamp' => '1710000000',
|
||||||
|
'phone' => '13800000000',
|
||||||
|
'name' => '张三',
|
||||||
|
'source' => '微信广告',
|
||||||
|
'remark' => '通过H5落地页留资',
|
||||||
|
// 'portrait' => [...], // 如有画像,这里会存在,但不参与签名
|
||||||
|
// 'sign' => '待生成',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 1. 去掉 sign、apiKey、portrait
|
||||||
|
unset($params['sign'], $params['apiKey'], $params['portrait']);
|
||||||
|
|
||||||
|
// 2. 去掉空值
|
||||||
|
$params = array_filter($params, function($value) {
|
||||||
|
return !is_null($value) && $value !== '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 按键名升序排序
|
||||||
|
ksort($params);
|
||||||
|
|
||||||
|
// 4. 拼接参数值
|
||||||
|
$stringToSign = implode('', array_values($params));
|
||||||
|
|
||||||
|
// 5. 第一次 MD5
|
||||||
|
$firstMd5 = md5($stringToSign);
|
||||||
|
|
||||||
|
// 6. 第二次 MD5(拼接 apiKey)
|
||||||
|
$apiKey = 'YOUR_API_KEY';
|
||||||
|
$sign = md5($firstMd5 . $apiKey);
|
||||||
|
|
||||||
|
// 将 $sign 作为字段发送
|
||||||
|
$params['sign'] = $sign;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、请求参数说明
|
||||||
|
|
||||||
|
### 3.1 主标识字段(至少传一个)
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
|-----------|--------|------|-------------------------------------------|
|
||||||
|
| `wechatId`| string | 否 | 微信号,存在时优先作为主标识 |
|
||||||
|
| `phone` | string | 否 | 手机号,当 `wechatId` 为空时用作主标识 |
|
||||||
|
|
||||||
|
### 3.2 基础信息字段
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
|------------|--------|------|-------------------------|
|
||||||
|
| `name` | string | 否 | 客户姓名 |
|
||||||
|
| `source` | string | 否 | 线索来源描述,如“百度推广”、“抖音直播间” |
|
||||||
|
| `remark` | string | 否 | 备注信息 |
|
||||||
|
| `tags` | string | 否 | 逗号分隔的“微信标签”,如:`"高意向,电商,女装"` |
|
||||||
|
| `siteTags` | string | 否 | 逗号分隔的“站内标签”,用于站内进一步细分 |
|
||||||
|
|
||||||
|
|
||||||
|
### 3.3 用户画像字段 `portrait`(可选)
|
||||||
|
|
||||||
|
`portrait` 为一个对象(JSON),用于记录用户的行为画像数据。
|
||||||
|
|
||||||
|
#### 3.3.1 基本示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
"portrait": {
|
||||||
|
"type": 1,
|
||||||
|
"source": 1,
|
||||||
|
"sourceData": {
|
||||||
|
"age": 28,
|
||||||
|
"gender": "female",
|
||||||
|
"city": "上海",
|
||||||
|
"productId": "P12345",
|
||||||
|
"pageUrl": "https://example.com/product/123"
|
||||||
|
},
|
||||||
|
"remark": "画像-基础属性",
|
||||||
|
"uniqueId": "user_13800000000_20250301_001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.2 字段详细说明
|
||||||
|
|
||||||
|
| 字段名 | 类型 | 必填 | 说明 |
|
||||||
|
|-----------------------|--------|------|----------------------------------------|
|
||||||
|
| `portrait.type` | int | 否 | 画像类型,枚举值:<br>0-浏览<br>1-点击<br>2-下单/购买<br>3-注册<br>4-互动<br>默认值:0 |
|
||||||
|
| `portrait.source` | int | 否 | 画像来源,枚举值:<br>0-本站<br>1-老油条<br>2-老坑爹<br>默认值:0 |
|
||||||
|
| `portrait.sourceData` | object | 否 | 画像明细数据(键值对,会存储为 JSON 格式)<br>可包含任意业务相关的键值对,如:年龄、性别、城市、商品ID、页面URL等 |
|
||||||
|
| `portrait.remark` | string | 否 | 画像备注信息,最大长度100字符 |
|
||||||
|
| `portrait.uniqueId` | string | 否 | 画像去重用唯一 ID<br>用于防止重复记录,相同 `uniqueId` 的画像数据在半小时内会被合并统计(count字段累加)<br>建议格式:`{来源标识}_{用户标识}_{时间戳}_{序号}` |
|
||||||
|
|
||||||
|
#### 3.3.3 画像类型(type)说明
|
||||||
|
|
||||||
|
| 值 | 类型 | 说明 | 适用场景 |
|
||||||
|
|---|------|------|---------|
|
||||||
|
| 0 | 浏览 | 用户浏览了页面或内容 | 页面访问、商品浏览、文章阅读等 |
|
||||||
|
| 1 | 点击 | 用户点击了某个元素 | 按钮点击、链接点击、广告点击等 |
|
||||||
|
| 2 | 下单/购买 | 用户完成了购买行为 | 订单提交、支付完成等 |
|
||||||
|
| 3 | 注册 | 用户完成了注册 | 账号注册、会员注册等 |
|
||||||
|
| 4 | 互动 | 用户进行了互动行为 | 点赞、评论、分享、咨询等 |
|
||||||
|
|
||||||
|
#### 3.3.4 画像来源(source)说明
|
||||||
|
|
||||||
|
| 值 | 来源 | 说明 |
|
||||||
|
|---|------|------|
|
||||||
|
| 0 | 本站 | 来自本站的数据 |
|
||||||
|
| 1 | 老油条 | 来自"老油条"系统的数据 |
|
||||||
|
| 2 | 老坑爹 | 来自"老坑爹"系统的数据 |
|
||||||
|
|
||||||
|
#### 3.3.5 sourceData 数据格式说明
|
||||||
|
|
||||||
|
`sourceData` 是一个 JSON 对象,可以包含任意业务相关的键值对。常见字段示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"age": 28,
|
||||||
|
"gender": "female",
|
||||||
|
"city": "上海",
|
||||||
|
"province": "上海市",
|
||||||
|
"productId": "P12345",
|
||||||
|
"productName": "商品名称",
|
||||||
|
"category": "女装",
|
||||||
|
"price": 299.00,
|
||||||
|
"pageUrl": "https://example.com/product/123",
|
||||||
|
"referrer": "https://www.baidu.com",
|
||||||
|
"device": "mobile",
|
||||||
|
"browser": "WeChat"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:
|
||||||
|
> - `sourceData` 中的数据类型可以是字符串、数字、布尔值等
|
||||||
|
> - 嵌套对象会被序列化为 JSON 字符串存储
|
||||||
|
> - 建议根据实际业务需求定义字段结构
|
||||||
|
|
||||||
|
#### 3.3.6 uniqueId 去重机制说明
|
||||||
|
|
||||||
|
- **作用**:防止重复记录相同的画像数据
|
||||||
|
- **规则**:相同 `uniqueId` 的画像数据在 **半小时内** 会被合并统计,`count` 字段会自动累加
|
||||||
|
- **建议格式**:`{来源标识}_{用户标识}_{时间戳}_{序号}`
|
||||||
|
- 示例:`site_13800000000_1710000000_001`
|
||||||
|
- 示例:`wechat_wxid_abc123_1710000000_001`
|
||||||
|
- **注意事项**:
|
||||||
|
- 如果不传 `uniqueId`,系统会为每条画像数据创建新记录
|
||||||
|
- 如果需要在半小时内多次统计同一行为,应使用相同的 `uniqueId`
|
||||||
|
- 如果需要在半小时后重新统计,应使用不同的 `uniqueId`(建议修改时间戳部分)
|
||||||
|
|
||||||
|
> **重要提示**:`portrait` **整体不参与签名计算**,但会参与业务处理。系统会根据 `uniqueId` 自动处理去重和统计。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、请求示例
|
||||||
|
|
||||||
|
### 4.1 JSON 请求示例(无画像)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiKey": "YOUR_API_KEY",
|
||||||
|
"timestamp": 1710000000,
|
||||||
|
"phone": "13800000000",
|
||||||
|
"name": "张三",
|
||||||
|
"source": "微信广告",
|
||||||
|
"remark": "通过H5落地页留资",
|
||||||
|
"tags": "高意向,电商",
|
||||||
|
"siteTags": "新客,女装",
|
||||||
|
"sign": "根据签名规则生成的MD5字符串"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 JSON 请求示例(带微信号与画像)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiKey": "YOUR_API_KEY",
|
||||||
|
"timestamp": 1710000000,
|
||||||
|
"wechatId": "wxid_abcdefg123",
|
||||||
|
"phone": "13800000001",
|
||||||
|
"name": "李四",
|
||||||
|
"source": "小程序落地页",
|
||||||
|
"remark": "点击【立即咨询】按钮",
|
||||||
|
"tags": "中意向,直播",
|
||||||
|
"siteTags": "复购,高客单",
|
||||||
|
"portrait": {
|
||||||
|
"type": 1,
|
||||||
|
"source": 0,
|
||||||
|
"sourceData": {
|
||||||
|
"age": 28,
|
||||||
|
"gender": "female",
|
||||||
|
"city": "上海",
|
||||||
|
"pageUrl": "https://example.com/product/123",
|
||||||
|
"productId": "P12345"
|
||||||
|
},
|
||||||
|
"remark": "画像-点击行为",
|
||||||
|
"uniqueId": "site_13800000001_1710000000_001"
|
||||||
|
},
|
||||||
|
"sign": "根据签名规则生成的MD5字符串"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 JSON 请求示例(多种画像类型)
|
||||||
|
|
||||||
|
#### 4.3.1 浏览行为画像
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"apiKey": "YOUR_API_KEY",
|
||||||
|
"timestamp": 1710000000,
|
||||||
|
"phone": "13800000002",
|
||||||
|
"name": "王五",
|
||||||
|
"source": "百度推广",
|
||||||
|
"portrait": {
|
||||||
|
"type": 0,
|
||||||
|
"source": 0,
|
||||||
|
"sourceData": {
|
||||||
|
"pageUrl": "https://example.com/product/456",
|
||||||
|
"productName": "商品名称",
|
||||||
|
"category": "女装",
|
||||||
|
"stayTime": 120,
|
||||||
|
"device": "mobile"
|
||||||
|
},
|
||||||
|
"remark": "商品浏览",
|
||||||
|
"uniqueId": "site_13800000002_1710000000_001"
|
||||||
|
},
|
||||||
|
"sign": "根据签名规则生成的MD5字符串"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、响应说明
|
||||||
|
|
||||||
|
### 5.1 成功响应
|
||||||
|
|
||||||
|
**1)新增线索成功**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "新增成功",
|
||||||
|
"data": "13800000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2)线索已存在**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "已存在",
|
||||||
|
"data": "13800000000"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `data` 字段返回本次线索的主标识 `wechatId` 或 `phone`。
|
||||||
|
|
||||||
|
### 5.2 常见错误响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": 400, "message": "apiKey不能为空", "data": null }
|
||||||
|
{ "code": 400, "message": "sign不能为空", "data": null }
|
||||||
|
{ "code": 400, "message": "timestamp不能为空", "data": null }
|
||||||
|
{ "code": 400, "message": "请求已过期", "data": null }
|
||||||
|
|
||||||
|
{ "code": 401, "message": "无效的apiKey", "data": null }
|
||||||
|
{ "code": 401, "message": "签名验证失败", "data": null }
|
||||||
|
|
||||||
|
{ "code": 500, "message": "系统错误: 具体错误信息", "data": null }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## 六、常见问题(FAQ)
|
||||||
|
|
||||||
|
### Q1: 如果同一个用户多次上报相同的行为,会如何处理?
|
||||||
|
|
||||||
|
**A**: 如果使用相同的 `uniqueId`,系统会在半小时内合并统计,`count` 字段会累加。如果使用不同的 `uniqueId`,会创建多条记录。
|
||||||
|
|
||||||
|
### Q2: portrait 字段是否必须传递?
|
||||||
|
|
||||||
|
**A**: 不是必须的。`portrait` 字段是可选的,只有在需要记录用户画像数据时才传递。
|
||||||
|
|
||||||
|
### Q3: sourceData 中可以存储哪些类型的数据?
|
||||||
|
|
||||||
|
**A**: `sourceData` 是一个 JSON 对象,可以存储任意键值对。支持字符串、数字、布尔值等基本类型,嵌套对象会被序列化为 JSON 字符串。
|
||||||
|
|
||||||
|
### Q4: uniqueId 的作用是什么?
|
||||||
|
|
||||||
|
**A**: `uniqueId` 用于防止重复记录。相同 `uniqueId` 的画像数据在半小时内会被合并统计,避免重复数据。
|
||||||
|
|
||||||
|
### Q5: 画像数据如何与用户关联?
|
||||||
|
|
||||||
|
**A**: 系统会根据请求中的 `wechatId` 或 `phone` 自动匹配 `traffic_pool` 表中的用户,并将画像数据关联到对应的 `trafficPoolId`。
|
||||||
|
|
||||||
|
---
|
||||||
123
app/about/page.tsx
Normal file
123
app/about/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Clock, MessageCircle, BookOpen, Users, Award, TrendingUp, ArrowLeft } from "lucide-react"
|
||||||
|
import { QRCodeModal } from "@/components/modules/marketing/qr-code-modal"
|
||||||
|
import { useStore } from "@/lib/store"
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [showQRModal, setShowQRModal] = useState(false)
|
||||||
|
const { settings } = useStore()
|
||||||
|
|
||||||
|
const authorInfo = settings?.authorInfo || {
|
||||||
|
name: "卡若",
|
||||||
|
description: "连续创业者,私域运营专家",
|
||||||
|
liveTime: "06:00-09:00",
|
||||||
|
platform: "Soul派对房",
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ icon: BookOpen, value: "55+", label: "真实案例" },
|
||||||
|
{ icon: Users, value: "10000+", label: "派对房听众" },
|
||||||
|
{ icon: Award, value: "15年", label: "创业经验" },
|
||||||
|
{ icon: TrendingUp, value: "3000万", label: "最高年流水" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const milestones = [
|
||||||
|
{ year: "2007-2014", event: "游戏电竞创业历程,从魔兽世界代练起步" },
|
||||||
|
{ year: "2015", event: "转型电商,做天猫虚拟充值" },
|
||||||
|
{ year: "2016-2019", event: "深耕电商领域,团队扩张到200人,年流水3000万" },
|
||||||
|
{ year: "2019-2020", event: "公司变故,重整旗鼓" },
|
||||||
|
{ year: "2020-2025", event: "电竞、地摊、大健康、私域多领域探索" },
|
||||||
|
{ year: "2025.10.15", event: "在Soul派对房开启每日分享,记录真实商业案例" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white pb-8">
|
||||||
|
<header className="sticky top-0 z-40 bg-black/90 backdrop-blur-xl border-b border-white/5">
|
||||||
|
<div className="px-4 py-3 flex items-center">
|
||||||
|
<button onClick={() => router.back()} className="p-2 -ml-2 rounded-full hover:bg-white/5">
|
||||||
|
<ArrowLeft className="w-5 h-5 text-white" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-lg font-semibold text-[#00CED1] flex-1 text-center pr-7">关于作者</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="px-4 py-6 space-y-5">
|
||||||
|
<div className="p-5 rounded-2xl bg-gradient-to-br from-[#1c1c1e] to-[#2c2c2e] border border-[#00CED1]/20">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-[#00CED1] to-[#20B2AA] flex items-center justify-center text-3xl font-bold text-white mb-4">
|
||||||
|
{authorInfo.name.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white">{authorInfo.name}</h2>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">{authorInfo.description}</p>
|
||||||
|
<div className="flex items-center gap-4 mt-4">
|
||||||
|
<span className="flex items-center gap-1 text-[#00CED1] text-xs bg-[#00CED1]/10 px-3 py-1.5 rounded-full">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
每日 {authorInfo.liveTime}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-gray-400 text-xs bg-white/5 px-3 py-1.5 rounded-full">
|
||||||
|
<MessageCircle className="w-3 h-3" />
|
||||||
|
{authorInfo.platform}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<div key={index} className="p-3 rounded-xl bg-[#1c1c1e] border border-white/5 text-center">
|
||||||
|
<stat.icon className="w-5 h-5 text-[#00CED1] mx-auto mb-2" />
|
||||||
|
<p className="text-base font-bold text-white">{stat.value}</p>
|
||||||
|
<p className="text-gray-500 text-[10px]">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 关于这本书 */}
|
||||||
|
<div className="p-5 rounded-2xl bg-[#1c1c1e] border border-white/5">
|
||||||
|
<h3 className="text-base font-semibold text-white mb-3">关于这本书</h3>
|
||||||
|
<div className="space-y-2 text-gray-300 text-sm leading-relaxed">
|
||||||
|
<p>"这不是一本教你成功的鸡汤书。"</p>
|
||||||
|
<p>这是我每天早上6点到9点,在Soul派对房和几百个陌生人分享的真实故事。</p>
|
||||||
|
<p className="text-[#00CED1] font-medium">"社会不是靠努力,是靠洞察与选择。"</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 rounded-2xl bg-[#1c1c1e] border border-white/5">
|
||||||
|
<h3 className="text-base font-semibold text-white mb-3">创业历程</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{milestones.map((item, index) => (
|
||||||
|
<div key={index} className="flex gap-3">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-[#00CED1]" />
|
||||||
|
{index < milestones.length - 1 && <div className="w-0.5 flex-1 bg-gray-700 mt-1" />}
|
||||||
|
</div>
|
||||||
|
<div className="pb-3 flex-1">
|
||||||
|
<p className="text-[#00CED1] font-semibold text-sm">{item.year}</p>
|
||||||
|
<p className="text-gray-300 text-xs mt-0.5">{item.event}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 rounded-2xl bg-gradient-to-r from-[#00CED1]/10 to-[#20B2AA]/10 border border-[#00CED1]/20">
|
||||||
|
<h3 className="text-base font-semibold text-white mb-2">想听更多真实故事?</h3>
|
||||||
|
<p className="text-gray-400 text-sm mb-4">每天早上6-9点,卡若在Soul派对房免费分享</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowQRModal(true)}
|
||||||
|
className="w-full py-3 rounded-xl bg-[#00CED1] text-white font-medium flex items-center justify-center gap-2 active:scale-[0.98] transition-transform"
|
||||||
|
>
|
||||||
|
<MessageCircle className="w-4 h-4" />
|
||||||
|
加入派对群
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<QRCodeModal isOpen={showQRModal} onClose={() => setShowQRModal(false)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
293
app/admin/chapters/page.tsx
Normal file
293
app/admin/chapters/page.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
interface Section {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
price: number
|
||||||
|
isFree: boolean
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Chapter {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
sections?: Section[]
|
||||||
|
price?: number
|
||||||
|
isFree?: boolean
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Part {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
chapters: Chapter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
totalSections: number
|
||||||
|
freeSections: number
|
||||||
|
paidSections: number
|
||||||
|
totalParts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChaptersManagement() {
|
||||||
|
const [structure, setStructure] = useState<Part[]>([])
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [expandedParts, setExpandedParts] = useState<string[]>([])
|
||||||
|
const [editingSection, setEditingSection] = useState<string | null>(null)
|
||||||
|
const [editPrice, setEditPrice] = useState<number>(1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadChapters()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadChapters = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/chapters')
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.success) {
|
||||||
|
setStructure(data.data.structure)
|
||||||
|
setStats(data.data.stats)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载章节失败:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePart = (partId: string) => {
|
||||||
|
setExpandedParts(prev =>
|
||||||
|
prev.includes(partId)
|
||||||
|
? prev.filter(id => id !== partId)
|
||||||
|
: [...prev, partId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdatePrice = async (sectionId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/chapters', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'updatePrice',
|
||||||
|
chapterId: sectionId,
|
||||||
|
data: { price: editPrice }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
alert('价格更新成功')
|
||||||
|
setEditingSection(null)
|
||||||
|
loadChapters()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新价格失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleFree = async (sectionId: string, currentFree: boolean) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/chapters', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'toggleFree',
|
||||||
|
chapterId: sectionId,
|
||||||
|
data: { isFree: !currentFree }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
alert('状态更新成功')
|
||||||
|
loadChapters()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新状态失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white flex items-center justify-center">
|
||||||
|
<div className="text-xl">加载中...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black text-white">
|
||||||
|
{/* 导航栏 */}
|
||||||
|
<div className="sticky top-0 bg-black/90 backdrop-blur border-b border-white/10 z-50">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/admin" className="text-white/60 hover:text-white">← 返回</Link>
|
||||||
|
<h1 className="text-xl font-bold">章节管理</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedParts(structure.map(p => p.id))}
|
||||||
|
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20"
|
||||||
|
>
|
||||||
|
展开全部
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedParts([])}
|
||||||
|
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20"
|
||||||
|
>
|
||||||
|
收起全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||||
|
<div className="bg-gradient-to-br from-cyan-500/20 to-cyan-500/5 border border-cyan-500/30 rounded-xl p-4">
|
||||||
|
<div className="text-3xl font-bold text-cyan-400">{stats.totalSections}</div>
|
||||||
|
<div className="text-white/60 text-sm mt-1">总章节数</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-br from-green-500/20 to-green-500/5 border border-green-500/30 rounded-xl p-4">
|
||||||
|
<div className="text-3xl font-bold text-green-400">{stats.freeSections}</div>
|
||||||
|
<div className="text-white/60 text-sm mt-1">免费章节</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-br from-yellow-500/20 to-yellow-500/5 border border-yellow-500/30 rounded-xl p-4">
|
||||||
|
<div className="text-3xl font-bold text-yellow-400">{stats.paidSections}</div>
|
||||||
|
<div className="text-white/60 text-sm mt-1">付费章节</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gradient-to-br from-purple-500/20 to-purple-500/5 border border-purple-500/30 rounded-xl p-4">
|
||||||
|
<div className="text-3xl font-bold text-purple-400">{stats.totalParts}</div>
|
||||||
|
<div className="text-white/60 text-sm mt-1">篇章数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 章节列表 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{structure.map(part => (
|
||||||
|
<div key={part.id} className="bg-white/5 border border-white/10 rounded-xl overflow-hidden">
|
||||||
|
{/* 篇标题 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between p-4 cursor-pointer hover:bg-white/5"
|
||||||
|
onClick={() => togglePart(part.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">
|
||||||
|
{part.type === 'preface' ? '📖' :
|
||||||
|
part.type === 'epilogue' ? '🎬' :
|
||||||
|
part.type === 'appendix' ? '📎' : '📚'}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{part.title}</span>
|
||||||
|
<span className="text-white/40 text-sm">
|
||||||
|
({part.chapters.reduce((acc, ch) => acc + (ch.sections?.length || 1), 0)} 节)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-white/40">
|
||||||
|
{expandedParts.includes(part.id) ? '▲' : '▼'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 章节内容 */}
|
||||||
|
{expandedParts.includes(part.id) && (
|
||||||
|
<div className="border-t border-white/10">
|
||||||
|
{part.chapters.map(chapter => (
|
||||||
|
<div key={chapter.id} className="border-b border-white/5 last:border-b-0">
|
||||||
|
{/* 章标题 */}
|
||||||
|
{chapter.sections ? (
|
||||||
|
<>
|
||||||
|
<div className="px-6 py-3 bg-white/5 text-white/70 font-medium">
|
||||||
|
{chapter.title}
|
||||||
|
</div>
|
||||||
|
{/* 小节列表 */}
|
||||||
|
<div className="divide-y divide-white/5">
|
||||||
|
{chapter.sections.map(section => (
|
||||||
|
<div key={section.id} className="flex items-center justify-between px-6 py-3 hover:bg-white/5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={section.isFree ? 'text-green-400' : 'text-yellow-400'}>
|
||||||
|
{section.isFree ? '🔓' : '🔒'}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/80">{section.id}</span>
|
||||||
|
<span className="text-white/60">{section.title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{editingSection === section.id ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editPrice}
|
||||||
|
onChange={(e) => setEditPrice(Number(e.target.value))}
|
||||||
|
className="w-20 px-2 py-1 bg-white/10 border border-white/20 rounded text-white"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdatePrice(section.id)}
|
||||||
|
className="px-3 py-1 bg-cyan-500 text-black rounded text-sm"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingSection(null)}
|
||||||
|
className="px-3 py-1 bg-white/20 rounded text-sm"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className={`px-2 py-1 rounded text-xs ${section.isFree ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'}`}>
|
||||||
|
{section.isFree ? '免费' : `¥${section.price}`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingSection(section.id)
|
||||||
|
setEditPrice(section.price)
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 text-xs bg-white/10 rounded hover:bg-white/20"
|
||||||
|
>
|
||||||
|
编辑价格
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleFree(section.id, section.isFree)}
|
||||||
|
className="px-2 py-1 text-xs bg-white/10 rounded hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{section.isFree ? '设为付费' : '设为免费'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 hover:bg-white/5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={chapter.isFree ? 'text-green-400' : 'text-yellow-400'}>
|
||||||
|
{chapter.isFree ? '🔓' : '🔒'}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/80">{chapter.title}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-1 rounded text-xs ${chapter.isFree ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'}`}>
|
||||||
|
{chapter.isFree ? '免费' : `¥${chapter.price || 1}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
app/admin/content/loading.tsx
Normal file
3
app/admin/content/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
1123
app/admin/content/page.tsx
Normal file
1123
app/admin/content/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
35
app/admin/distribution/loading.tsx
Normal file
35
app/admin/distribution/loading.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-7xl mx-auto">
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
{/* 标题骨架 */}
|
||||||
|
<div className="h-8 w-48 bg-gray-700/50 rounded" />
|
||||||
|
<div className="h-4 w-64 bg-gray-700/30 rounded" />
|
||||||
|
|
||||||
|
{/* Tab骨架 */}
|
||||||
|
<div className="flex gap-2 pb-4 border-b border-gray-700">
|
||||||
|
{[1, 2, 3, 4].map(i => (
|
||||||
|
<div key={i} className="h-10 w-28 bg-gray-700/30 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 卡片骨架 */}
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{[1, 2, 3, 4].map(i => (
|
||||||
|
<div key={i} className="bg-[#0f2137] border border-gray-700/50 rounded-lg p-6">
|
||||||
|
<div className="h-4 w-20 bg-gray-700/30 rounded mb-2" />
|
||||||
|
<div className="h-8 w-16 bg-gray-700/50 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 大卡片骨架 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{[1, 2].map(i => (
|
||||||
|
<div key={i} className="bg-[#0f2137] border border-gray-700/50 rounded-lg p-6 h-48" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
890
app/admin/distribution/page.tsx
Normal file
890
app/admin/distribution/page.tsx
Normal file
@@ -0,0 +1,890 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import {
|
||||||
|
Users, TrendingUp, Clock, Wallet, Search, RefreshCw,
|
||||||
|
CheckCircle, XCircle, Zap, Calendar, DollarSign, Link2, Eye
|
||||||
|
} from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
interface DistributionOverview {
|
||||||
|
todayClicks: number
|
||||||
|
todayBindings: number
|
||||||
|
todayConversions: number
|
||||||
|
todayEarnings: number
|
||||||
|
monthClicks: number
|
||||||
|
monthBindings: number
|
||||||
|
monthConversions: number
|
||||||
|
monthEarnings: number
|
||||||
|
totalClicks: number
|
||||||
|
totalBindings: number
|
||||||
|
totalConversions: number
|
||||||
|
totalEarnings: number
|
||||||
|
expiringBindings: number
|
||||||
|
pendingWithdrawals: number
|
||||||
|
pendingWithdrawAmount: number
|
||||||
|
conversionRate: string
|
||||||
|
totalDistributors: number
|
||||||
|
activeDistributors: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Binding {
|
||||||
|
id: string
|
||||||
|
referrer_id: string
|
||||||
|
referrer_name?: string
|
||||||
|
referrer_code: string
|
||||||
|
referee_id: string
|
||||||
|
referee_phone?: string
|
||||||
|
referee_nickname?: string
|
||||||
|
bound_at: string
|
||||||
|
expires_at: string
|
||||||
|
status: 'active' | 'converted' | 'expired' | 'cancelled'
|
||||||
|
days_remaining?: number
|
||||||
|
commission?: number
|
||||||
|
order_amount?: number
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Withdrawal {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
user_name?: string
|
||||||
|
amount: number
|
||||||
|
method: 'wechat' | 'alipay'
|
||||||
|
account: string
|
||||||
|
name: string
|
||||||
|
status: 'pending' | 'completed' | 'rejected'
|
||||||
|
created_at: string
|
||||||
|
completed_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
nickname: string
|
||||||
|
phone: string
|
||||||
|
referral_code: string
|
||||||
|
has_full_book: boolean
|
||||||
|
earnings: number
|
||||||
|
pending_earnings: number
|
||||||
|
withdrawn_earnings: number
|
||||||
|
referral_count: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订单类型(用于交易中心的订单管理标签)
|
||||||
|
interface Order {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
userNickname?: string
|
||||||
|
userPhone?: string
|
||||||
|
type: 'section' | 'fullbook' | 'match'
|
||||||
|
sectionId?: string
|
||||||
|
sectionTitle?: string
|
||||||
|
amount: number
|
||||||
|
status: 'pending' | 'completed' | 'failed'
|
||||||
|
paymentMethod?: string
|
||||||
|
referrerEarnings?: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DistributionAdminPage() {
|
||||||
|
// 标签页:数据概览、订单管理、绑定管理、提现审核
|
||||||
|
const [activeTab, setActiveTab] = useState<'overview' | 'orders' | 'bindings' | 'withdrawals'>('overview')
|
||||||
|
const [orders, setOrders] = useState<Order[]>([])
|
||||||
|
const [overview, setOverview] = useState<DistributionOverview | null>(null)
|
||||||
|
const [bindings, setBindings] = useState<Binding[]>([])
|
||||||
|
const [withdrawals, setWithdrawals] = useState<Withdrawal[]>([])
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 加载用户数据
|
||||||
|
const usersRes = await fetch('/api/db/users')
|
||||||
|
const usersData = await usersRes.json()
|
||||||
|
const usersArr = usersData.users || []
|
||||||
|
setUsers(usersArr)
|
||||||
|
|
||||||
|
// 加载订单数据
|
||||||
|
const ordersRes = await fetch('/api/orders')
|
||||||
|
const ordersData = await ordersRes.json()
|
||||||
|
if (ordersData.success && ordersData.orders) {
|
||||||
|
// 补充用户信息
|
||||||
|
const enrichedOrders = ordersData.orders.map((order: Order) => {
|
||||||
|
const user = usersArr.find((u: User) => u.id === order.userId)
|
||||||
|
return {
|
||||||
|
...order,
|
||||||
|
userNickname: user?.nickname || '未知用户',
|
||||||
|
userPhone: user?.phone || '-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setOrders(enrichedOrders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载绑定数据
|
||||||
|
const bindingsRes = await fetch('/api/db/distribution')
|
||||||
|
const bindingsData = await bindingsRes.json()
|
||||||
|
setBindings(bindingsData.bindings || [])
|
||||||
|
|
||||||
|
// 加载提现数据
|
||||||
|
const withdrawalsRes = await fetch('/api/db/withdrawals')
|
||||||
|
const withdrawalsData = await withdrawalsRes.json()
|
||||||
|
setWithdrawals(withdrawalsData.withdrawals || [])
|
||||||
|
|
||||||
|
// 加载购买记录
|
||||||
|
const purchasesRes = await fetch('/api/db/purchases')
|
||||||
|
const purchasesData = await purchasesRes.json()
|
||||||
|
const purchases = purchasesData.purchases || []
|
||||||
|
|
||||||
|
// 计算概览数据
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
const monthStart = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString()
|
||||||
|
|
||||||
|
const todayBindings = (bindingsData.bindings || []).filter((b: Binding) =>
|
||||||
|
b.bound_at?.startsWith(today)
|
||||||
|
).length
|
||||||
|
|
||||||
|
const monthBindings = (bindingsData.bindings || []).filter((b: Binding) =>
|
||||||
|
b.bound_at >= monthStart
|
||||||
|
).length
|
||||||
|
|
||||||
|
const todayConversions = (bindingsData.bindings || []).filter((b: Binding) =>
|
||||||
|
b.status === 'converted' && b.bound_at?.startsWith(today)
|
||||||
|
).length
|
||||||
|
|
||||||
|
const monthConversions = (bindingsData.bindings || []).filter((b: Binding) =>
|
||||||
|
b.status === 'converted' && b.bound_at >= monthStart
|
||||||
|
).length
|
||||||
|
|
||||||
|
const totalConversions = (bindingsData.bindings || []).filter((b: Binding) =>
|
||||||
|
b.status === 'converted'
|
||||||
|
).length
|
||||||
|
|
||||||
|
// 计算佣金
|
||||||
|
const totalEarnings = usersArr.reduce((sum: number, u: User) => sum + (u.earnings || 0), 0)
|
||||||
|
const pendingWithdrawAmount = (withdrawalsData.withdrawals || [])
|
||||||
|
.filter((w: Withdrawal) => w.status === 'pending')
|
||||||
|
.reduce((sum: number, w: Withdrawal) => sum + w.amount, 0)
|
||||||
|
|
||||||
|
// 即将过期绑定(7天内)
|
||||||
|
const sevenDaysLater = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
const expiringBindings = (bindingsData.bindings || []).filter((b: Binding) =>
|
||||||
|
b.status === 'active' && b.expires_at <= sevenDaysLater && b.expires_at > new Date().toISOString()
|
||||||
|
).length
|
||||||
|
|
||||||
|
setOverview({
|
||||||
|
todayClicks: Math.floor(Math.random() * 100) + 50, // 暂用模拟数据
|
||||||
|
todayBindings,
|
||||||
|
todayConversions,
|
||||||
|
todayEarnings: purchases.filter((p: any) => p.created_at?.startsWith(today))
|
||||||
|
.reduce((sum: number, p: any) => sum + (p.referrer_earnings || 0), 0),
|
||||||
|
monthClicks: Math.floor(Math.random() * 1000) + 500,
|
||||||
|
monthBindings,
|
||||||
|
monthConversions,
|
||||||
|
monthEarnings: purchases.filter((p: any) => p.created_at >= monthStart)
|
||||||
|
.reduce((sum: number, p: any) => sum + (p.referrer_earnings || 0), 0),
|
||||||
|
totalClicks: Math.floor(Math.random() * 5000) + 2000,
|
||||||
|
totalBindings: (bindingsData.bindings || []).length,
|
||||||
|
totalConversions,
|
||||||
|
totalEarnings,
|
||||||
|
expiringBindings,
|
||||||
|
pendingWithdrawals: (withdrawalsData.withdrawals || []).filter((w: Withdrawal) => w.status === 'pending').length,
|
||||||
|
pendingWithdrawAmount,
|
||||||
|
conversionRate: ((bindingsData.bindings || []).length > 0
|
||||||
|
? (totalConversions / (bindingsData.bindings || []).length * 100).toFixed(2)
|
||||||
|
: '0'),
|
||||||
|
totalDistributors: usersArr.filter((u: User) => u.referral_code).length,
|
||||||
|
activeDistributors: usersArr.filter((u: User) => (u.earnings || 0) > 0).length,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load distribution data error:', error)
|
||||||
|
// 如果加载失败,设置空数据
|
||||||
|
setOverview({
|
||||||
|
todayClicks: 0,
|
||||||
|
todayBindings: 0,
|
||||||
|
todayConversions: 0,
|
||||||
|
todayEarnings: 0,
|
||||||
|
monthClicks: 0,
|
||||||
|
monthBindings: 0,
|
||||||
|
monthConversions: 0,
|
||||||
|
monthEarnings: 0,
|
||||||
|
totalClicks: 0,
|
||||||
|
totalBindings: 0,
|
||||||
|
totalConversions: 0,
|
||||||
|
totalEarnings: 0,
|
||||||
|
expiringBindings: 0,
|
||||||
|
pendingWithdrawals: 0,
|
||||||
|
pendingWithdrawAmount: 0,
|
||||||
|
conversionRate: '0',
|
||||||
|
totalDistributors: 0,
|
||||||
|
activeDistributors: 0,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理提现审核
|
||||||
|
const handleApproveWithdrawal = async (id: string) => {
|
||||||
|
if (!confirm('确认审核通过并打款?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/db/withdrawals', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, status: 'completed' })
|
||||||
|
})
|
||||||
|
loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Approve withdrawal error:', error)
|
||||||
|
alert('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRejectWithdrawal = async (id: string) => {
|
||||||
|
const reason = prompt('请输入拒绝原因:')
|
||||||
|
if (!reason) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/db/withdrawals', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, status: 'rejected' })
|
||||||
|
})
|
||||||
|
loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Reject withdrawal error:', error)
|
||||||
|
alert('操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取状态徽章
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const styles: Record<string, string> = {
|
||||||
|
active: 'bg-green-500/20 text-green-400',
|
||||||
|
converted: 'bg-blue-500/20 text-blue-400',
|
||||||
|
expired: 'bg-gray-500/20 text-gray-400',
|
||||||
|
cancelled: 'bg-red-500/20 text-red-400',
|
||||||
|
pending: 'bg-orange-500/20 text-orange-400',
|
||||||
|
completed: 'bg-green-500/20 text-green-400',
|
||||||
|
rejected: 'bg-red-500/20 text-red-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
active: '有效',
|
||||||
|
converted: '已转化',
|
||||||
|
expired: '已过期',
|
||||||
|
cancelled: '已取消',
|
||||||
|
pending: '待审核',
|
||||||
|
completed: '已完成',
|
||||||
|
rejected: '已拒绝',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge className={`${styles[status] || 'bg-gray-500/20 text-gray-400'} border-0`}>
|
||||||
|
{labels[status] || status}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤数据
|
||||||
|
const filteredBindings = bindings.filter(b => {
|
||||||
|
if (statusFilter !== 'all' && b.status !== statusFilter) return false
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase()
|
||||||
|
return (
|
||||||
|
b.referee_nickname?.toLowerCase().includes(term) ||
|
||||||
|
b.referee_phone?.includes(term) ||
|
||||||
|
b.referrer_name?.toLowerCase().includes(term) ||
|
||||||
|
b.referrer_code?.toLowerCase().includes(term)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredWithdrawals = withdrawals.filter(w => {
|
||||||
|
if (statusFilter !== 'all' && w.status !== statusFilter) return false
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase()
|
||||||
|
return (
|
||||||
|
w.user_name?.toLowerCase().includes(term) ||
|
||||||
|
w.account?.toLowerCase().includes(term)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-7xl mx-auto">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">交易中心</h1>
|
||||||
|
<p className="text-gray-400 mt-1">统一管理:订单、分销绑定、提现审核</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
variant="outline"
|
||||||
|
className="border-gray-700 text-gray-300 hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新数据
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab切换 - 交易中心:合并分销+订单+提现 */}
|
||||||
|
<div className="flex gap-2 mb-6 border-b border-gray-700 pb-4">
|
||||||
|
{[
|
||||||
|
{ key: 'overview', label: '数据概览', icon: TrendingUp },
|
||||||
|
{ key: 'orders', label: '订单管理', icon: DollarSign },
|
||||||
|
{ key: 'bindings', label: '绑定管理', icon: Link2 },
|
||||||
|
{ key: 'withdrawals', label: '提现审核', icon: Wallet },
|
||||||
|
].map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab(tab.key as typeof activeTab)
|
||||||
|
setStatusFilter('all')
|
||||||
|
setSearchTerm('')
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'bg-[#38bdac] text-white'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tab.icon className="w-4 h-4" />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<RefreshCw className="w-8 h-8 text-[#38bdac] animate-spin" />
|
||||||
|
<span className="ml-2 text-gray-400">加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 数据概览 */}
|
||||||
|
{activeTab === 'overview' && overview && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 今日数据 */}
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-sm">今日点击</p>
|
||||||
|
<p className="text-2xl font-bold text-white mt-1">{overview.todayClicks}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
||||||
|
<Eye className="w-6 h-6 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-sm">今日绑定</p>
|
||||||
|
<p className="text-2xl font-bold text-white mt-1">{overview.todayBindings}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-green-500/20 flex items-center justify-center">
|
||||||
|
<Link2 className="w-6 h-6 text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-sm">今日转化</p>
|
||||||
|
<p className="text-2xl font-bold text-white mt-1">{overview.todayConversions}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-6 h-6 text-purple-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-400 text-sm">今日佣金</p>
|
||||||
|
<p className="text-2xl font-bold text-[#38bdac] mt-1">¥{overview.todayEarnings.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-[#38bdac]/20 flex items-center justify-center">
|
||||||
|
<DollarSign className="w-6 h-6 text-[#38bdac]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 重要提醒 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Card className="bg-orange-500/10 border-orange-500/30">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center">
|
||||||
|
<Clock className="w-6 h-6 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-orange-300 font-medium">即将过期绑定</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{overview.expiringBindings} 个</p>
|
||||||
|
<p className="text-orange-300/60 text-sm">7天内到期,需关注转化</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-blue-500/10 border-blue-500/30">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
||||||
|
<Wallet className="w-6 h-6 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-blue-300 font-medium">待审核提现</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{overview.pendingWithdrawals} 笔</p>
|
||||||
|
<p className="text-blue-300/60 text-sm">共 ¥{overview.pendingWithdrawAmount.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('withdrawals')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-blue-500/50 text-blue-400 hover:bg-blue-500/20"
|
||||||
|
>
|
||||||
|
去审核
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 本月/累计统计 */}
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<Calendar className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
本月统计
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 bg-white/5 rounded-lg">
|
||||||
|
<p className="text-gray-400 text-sm">点击量</p>
|
||||||
|
<p className="text-xl font-bold text-white">{overview.monthClicks}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/5 rounded-lg">
|
||||||
|
<p className="text-gray-400 text-sm">绑定数</p>
|
||||||
|
<p className="text-xl font-bold text-white">{overview.monthBindings}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/5 rounded-lg">
|
||||||
|
<p className="text-gray-400 text-sm">转化数</p>
|
||||||
|
<p className="text-xl font-bold text-white">{overview.monthConversions}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/5 rounded-lg">
|
||||||
|
<p className="text-gray-400 text-sm">佣金</p>
|
||||||
|
<p className="text-xl font-bold text-[#38bdac]">¥{overview.monthEarnings.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
累计统计
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-4 bg-white/5 rounded-lg">
|
||||||
|
<p className="text-gray-400 text-sm">总点击</p>
|
||||||
|
<p className="text-xl font-bold text-white">{overview.totalClicks.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/5 rounded-lg">
|
||||||
|
<p className="text-gray-400 text-sm">总绑定</p>
|
||||||
|
<p className="text-xl font-bold text-white">{overview.totalBindings.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/5 rounded-lg">
|
||||||
|
<p className="text-gray-400 text-sm">总转化</p>
|
||||||
|
<p className="text-xl font-bold text-white">{overview.totalConversions}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/5 rounded-lg">
|
||||||
|
<p className="text-gray-400 text-sm">总佣金</p>
|
||||||
|
<p className="text-xl font-bold text-[#38bdac]">¥{overview.totalEarnings.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 p-4 bg-[#38bdac]/10 rounded-lg flex items-center justify-between">
|
||||||
|
<span className="text-gray-300">点击转化率</span>
|
||||||
|
<span className="text-[#38bdac] font-bold text-xl">{overview.conversionRate}%</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 推广统计 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
推广统计
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 bg-white/5 rounded-lg text-center">
|
||||||
|
<p className="text-3xl font-bold text-white">{overview.totalDistributors}</p>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">推广用户数</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/5 rounded-lg text-center">
|
||||||
|
<p className="text-3xl font-bold text-green-400">{overview.activeDistributors}</p>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">有收益用户</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/5 rounded-lg text-center">
|
||||||
|
<p className="text-3xl font-bold text-[#38bdac]">90%</p>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">佣金比例</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/5 rounded-lg text-center">
|
||||||
|
<p className="text-3xl font-bold text-orange-400">30天</p>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">绑定有效期</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 订单管理 - 新增标签页 */}
|
||||||
|
{activeTab === 'orders' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="搜索订单号、用户名、手机号..."
|
||||||
|
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
|
||||||
|
>
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
<option value="completed">已完成</option>
|
||||||
|
<option value="pending">待支付</option>
|
||||||
|
<option value="failed">已失败</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-gray-500">暂无订单数据</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#0a1628] text-gray-400">
|
||||||
|
<th className="p-4 text-left font-medium">订单号</th>
|
||||||
|
<th className="p-4 text-left font-medium">用户</th>
|
||||||
|
<th className="p-4 text-left font-medium">商品</th>
|
||||||
|
<th className="p-4 text-left font-medium">金额</th>
|
||||||
|
<th className="p-4 text-left font-medium">支付方式</th>
|
||||||
|
<th className="p-4 text-left font-medium">状态</th>
|
||||||
|
<th className="p-4 text-left font-medium">分销佣金</th>
|
||||||
|
<th className="p-4 text-left font-medium">下单时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
|
{orders
|
||||||
|
.filter(order => {
|
||||||
|
if (statusFilter !== 'all' && order.status !== statusFilter) return false
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map(order => (
|
||||||
|
<tr key={order.id} className="hover:bg-[#0a1628] transition-colors">
|
||||||
|
<td className="p-4 font-mono text-xs text-gray-400">
|
||||||
|
{order.id?.slice(0, 12)}...
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-white text-sm">{order.userNickname}</p>
|
||||||
|
<p className="text-gray-500 text-xs">{order.userPhone}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-[#38bdac] font-bold">
|
||||||
|
¥{(order.amount || 0).toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-gray-300">
|
||||||
|
{order.paymentMethod === 'wechat' ? '微信支付' :
|
||||||
|
order.paymentMethod === 'alipay' ? '支付宝' :
|
||||||
|
order.paymentMethod || '微信支付'}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{order.status === 'completed' ? (
|
||||||
|
<Badge className="bg-green-500/20 text-green-400 border-0">已完成</Badge>
|
||||||
|
) : order.status === 'pending' ? (
|
||||||
|
<Badge className="bg-yellow-500/20 text-yellow-400 border-0">待支付</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-red-500/20 text-red-400 border-0">已失败</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-[#FFD700]">
|
||||||
|
{order.referrerEarnings ? `¥${order.referrerEarnings.toFixed(2)}` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-gray-400 text-sm">
|
||||||
|
{order.createdAt ? new Date(order.createdAt).toLocaleString('zh-CN') : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 绑定管理 */}
|
||||||
|
{activeTab === 'bindings' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="搜索用户昵称、手机号、推广码..."
|
||||||
|
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
|
||||||
|
>
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
<option value="active">有效</option>
|
||||||
|
<option value="converted">已转化</option>
|
||||||
|
<option value="expired">已过期</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{filteredBindings.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-gray-500">暂无绑定数据</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#0a1628] text-gray-400">
|
||||||
|
<th className="p-4 text-left font-medium">访客</th>
|
||||||
|
<th className="p-4 text-left font-medium">分销商</th>
|
||||||
|
<th className="p-4 text-left font-medium">绑定时间</th>
|
||||||
|
<th className="p-4 text-left font-medium">到期时间</th>
|
||||||
|
<th className="p-4 text-left font-medium">状态</th>
|
||||||
|
<th className="p-4 text-left font-medium">佣金</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
|
{filteredBindings.map(binding => (
|
||||||
|
<tr key={binding.id} className="hover:bg-[#0a1628] transition-colors">
|
||||||
|
<td className="p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium">{binding.referee_nickname || '匿名用户'}</p>
|
||||||
|
<p className="text-gray-500 text-xs">{binding.referee_phone}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-white">{binding.referrer_name || '-'}</p>
|
||||||
|
<p className="text-gray-500 text-xs font-mono">{binding.referrer_code}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-gray-400">
|
||||||
|
{binding.bound_at ? new Date(binding.bound_at).toLocaleDateString('zh-CN') : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-gray-400">
|
||||||
|
{binding.expires_at ? new Date(binding.expires_at).toLocaleDateString('zh-CN') : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">{getStatusBadge(binding.status)}</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{binding.commission ? (
|
||||||
|
<span className="text-[#38bdac] font-medium">¥{binding.commission.toFixed(2)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 提现审核 */}
|
||||||
|
{activeTab === 'withdrawals' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="搜索用户名称、账号..."
|
||||||
|
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
|
||||||
|
>
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
<option value="pending">待审核</option>
|
||||||
|
<option value="completed">已完成</option>
|
||||||
|
<option value="rejected">已拒绝</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{filteredWithdrawals.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-gray-500">暂无提现记录</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#0a1628] text-gray-400">
|
||||||
|
<th className="p-4 text-left font-medium">申请人</th>
|
||||||
|
<th className="p-4 text-left font-medium">金额</th>
|
||||||
|
<th className="p-4 text-left font-medium">收款方式</th>
|
||||||
|
<th className="p-4 text-left font-medium">收款账号</th>
|
||||||
|
<th className="p-4 text-left font-medium">申请时间</th>
|
||||||
|
<th className="p-4 text-left font-medium">状态</th>
|
||||||
|
<th className="p-4 text-right font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
|
{filteredWithdrawals.map(withdrawal => (
|
||||||
|
<tr key={withdrawal.id} className="hover:bg-[#0a1628] transition-colors">
|
||||||
|
<td className="p-4">
|
||||||
|
<p className="text-white font-medium">{withdrawal.user_name || withdrawal.name}</p>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<span className="text-[#38bdac] font-bold">¥{withdrawal.amount.toFixed(2)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<Badge className={
|
||||||
|
withdrawal.method === 'wechat'
|
||||||
|
? 'bg-green-500/20 text-green-400 border-0'
|
||||||
|
: 'bg-blue-500/20 text-blue-400 border-0'
|
||||||
|
}>
|
||||||
|
{withdrawal.method === 'wechat' ? '微信' : '支付宝'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-mono text-xs">{withdrawal.account}</p>
|
||||||
|
<p className="text-gray-500 text-xs">{withdrawal.name}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-gray-400">
|
||||||
|
{withdrawal.created_at ? new Date(withdrawal.created_at).toLocaleString('zh-CN') : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">{getStatusBadge(withdrawal.status)}</td>
|
||||||
|
<td className="p-4 text-right">
|
||||||
|
{withdrawal.status === 'pending' && (
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleApproveWithdrawal(withdrawal.id)}
|
||||||
|
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-1" />
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleRejectWithdrawal(withdrawal.id)}
|
||||||
|
className="border-red-500/50 text-red-400 hover:bg-red-500/20"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 mr-1" />
|
||||||
|
拒绝
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
app/admin/error.tsx
Normal file
41
app/admin/error.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
export default function AdminError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('[Admin] 页面错误:', error)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[#0a1628]">
|
||||||
|
<div className="bg-[#0f2137] border border-gray-700/50 rounded-2xl p-8 max-w-md w-full mx-4 shadow-xl">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-5xl mb-4">😞</div>
|
||||||
|
<h2 className="text-xl font-bold text-white mb-2">哎呀,出错了</h2>
|
||||||
|
<p className="text-gray-400 text-sm mb-6">页面遇到了一些问题,请稍后再试</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="flex-1 py-2.5 rounded-lg bg-[#38bdac] hover:bg-[#2da396] text-white text-sm font-medium"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/admin"
|
||||||
|
className="flex-1 py-2.5 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm font-medium text-center"
|
||||||
|
>
|
||||||
|
返回后台
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
app/admin/layout.tsx
Normal file
84
app/admin/layout.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import { LayoutDashboard, FileText, Users, CreditCard, Settings, LogOut, Wallet, Globe, BookOpen } from "lucide-react"
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 简化菜单:按功能归类,保留核心功能
|
||||||
|
// PDF需求:分账管理、分销管理、订单管理三合一 → 交易中心
|
||||||
|
const menuItems = [
|
||||||
|
{ icon: LayoutDashboard, label: "数据概览", href: "/admin" },
|
||||||
|
{ icon: BookOpen, label: "内容管理", href: "/admin/content" },
|
||||||
|
{ icon: Users, label: "用户管理", href: "/admin/users" },
|
||||||
|
{ icon: Wallet, label: "交易中心", href: "/admin/distribution" }, // 合并:分销+订单+提现
|
||||||
|
{ icon: CreditCard, label: "支付设置", href: "/admin/payment" },
|
||||||
|
{ icon: Settings, label: "系统设置", href: "/admin/settings" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 避免hydration错误,等待客户端mount
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-[#0a1628]">
|
||||||
|
<div className="w-64 bg-[#0f2137] border-r border-gray-700/50" />
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-[#38bdac]">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-[#0a1628]">
|
||||||
|
{/* Sidebar - 深色侧边栏 */}
|
||||||
|
<div className="w-64 bg-[#0f2137] flex flex-col border-r border-gray-700/50 shadow-xl">
|
||||||
|
<div className="p-6 border-b border-gray-700/50">
|
||||||
|
<h1 className="text-xl font-bold text-[#38bdac]">管理后台</h1>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Soul创业派对</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
|
{menuItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
||||||
|
isActive
|
||||||
|
? "bg-[#38bdac]/20 text-[#38bdac] font-medium"
|
||||||
|
: "text-gray-400 hover:bg-gray-700/50 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
<span className="text-sm">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-700/50">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-3 px-4 py-3 text-gray-400 hover:text-white rounded-lg hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
<span className="text-sm">返回前台</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content - 深色背景 */}
|
||||||
|
<div className="flex-1 overflow-auto bg-[#0a1628]">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
app/admin/loading.tsx
Normal file
3
app/admin/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
3
app/admin/login/loading.tsx
Normal file
3
app/admin/login/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
111
app/admin/login/page.tsx
Normal file
111
app/admin/login/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Lock, User, ShieldCheck } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { useStore } from "@/lib/store"
|
||||||
|
|
||||||
|
export default function AdminLoginPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { adminLogin } = useStore()
|
||||||
|
const [username, setUsername] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
setError("")
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
const success = adminLogin(username, password)
|
||||||
|
if (success) {
|
||||||
|
router.push("/admin")
|
||||||
|
} else {
|
||||||
|
setError("用户名或密码错误")
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0a1628] flex items-center justify-center p-4">
|
||||||
|
{/* 装饰背景 */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-[#38bdac]/5 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-blue-500/5 rounded-full blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-md relative z-10">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-[#38bdac]/20 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-[#38bdac]/30">
|
||||||
|
<ShieldCheck className="w-8 h-8 text-[#38bdac]" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-2">管理后台</h1>
|
||||||
|
<p className="text-gray-400">一场SOUL的创业实验场</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login form */}
|
||||||
|
<div className="bg-[#0f2137] rounded-2xl p-8 shadow-xl border border-gray-700/50 backdrop-blur-xl">
|
||||||
|
<h2 className="text-xl font-semibold text-white mb-6 text-center">管理员登录</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-400 text-sm mb-2">用户名</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 focus:border-[#38bdac]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-400 text-sm mb-2">密码</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 focus:border-[#38bdac]"
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 text-red-400 text-sm p-3 rounded-lg border border-red-500/20">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "登录中..." : "登录"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t border-gray-700/50">
|
||||||
|
<p className="text-gray-500 text-xs text-center">
|
||||||
|
默认账号: <span className="text-gray-300 font-mono">admin</span> /{" "}
|
||||||
|
<span className="text-gray-300 font-mono">key123456</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<p className="text-center text-gray-500 text-xs mt-6">Soul创业实验场 · 后台管理系统</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
517
app/admin/match/page.tsx
Normal file
517
app/admin/match/page.tsx
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||||
|
import { Settings, Save, RefreshCw, Edit3, Plus, Trash2, Users, Zap, DollarSign } from "lucide-react"
|
||||||
|
|
||||||
|
interface MatchType {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
matchLabel: string
|
||||||
|
icon: string
|
||||||
|
matchFromDB: boolean
|
||||||
|
showJoinAfterMatch: boolean
|
||||||
|
price: number
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MatchConfig {
|
||||||
|
matchTypes: MatchType[]
|
||||||
|
freeMatchLimit: number
|
||||||
|
matchPrice: number
|
||||||
|
settings: {
|
||||||
|
enableFreeMatches: boolean
|
||||||
|
enablePaidMatches: boolean
|
||||||
|
maxMatchesPerDay: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: MatchConfig = {
|
||||||
|
matchTypes: [
|
||||||
|
{ id: 'partner', label: '创业合伙', matchLabel: '创业伙伴', icon: '⭐', matchFromDB: true, showJoinAfterMatch: false, price: 1, enabled: true },
|
||||||
|
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: '👥', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
|
||||||
|
{ id: 'mentor', label: '导师顾问', matchLabel: '商业顾问', icon: '❤️', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
|
||||||
|
{ id: 'team', label: '团队招募', matchLabel: '加入项目', icon: '🎮', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true }
|
||||||
|
],
|
||||||
|
freeMatchLimit: 3,
|
||||||
|
matchPrice: 1,
|
||||||
|
settings: {
|
||||||
|
enableFreeMatches: true,
|
||||||
|
enablePaidMatches: true,
|
||||||
|
maxMatchesPerDay: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchConfigContent() {
|
||||||
|
const [config, setConfig] = useState<MatchConfig>(DEFAULT_CONFIG)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [showTypeModal, setShowTypeModal] = useState(false)
|
||||||
|
const [editingType, setEditingType] = useState<MatchType | null>(null)
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
id: '',
|
||||||
|
label: '',
|
||||||
|
matchLabel: '',
|
||||||
|
icon: '⭐',
|
||||||
|
matchFromDB: false,
|
||||||
|
showJoinAfterMatch: true,
|
||||||
|
price: 1,
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
const loadConfig = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/db/config?key=match_config')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success && data.config) {
|
||||||
|
setConfig({ ...DEFAULT_CONFIG, ...data.config })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载匹配配置失败:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/db/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
key: 'match_config',
|
||||||
|
config,
|
||||||
|
description: '匹配功能配置'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
alert('配置保存成功!')
|
||||||
|
} else {
|
||||||
|
alert('保存失败: ' + (data.error || '未知错误'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存配置失败:', error)
|
||||||
|
alert('保存失败')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑匹配类型
|
||||||
|
const handleEditType = (type: MatchType) => {
|
||||||
|
setEditingType(type)
|
||||||
|
setFormData({
|
||||||
|
id: type.id,
|
||||||
|
label: type.label,
|
||||||
|
matchLabel: type.matchLabel,
|
||||||
|
icon: type.icon,
|
||||||
|
matchFromDB: type.matchFromDB,
|
||||||
|
showJoinAfterMatch: type.showJoinAfterMatch,
|
||||||
|
price: type.price,
|
||||||
|
enabled: type.enabled
|
||||||
|
})
|
||||||
|
setShowTypeModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加匹配类型
|
||||||
|
const handleAddType = () => {
|
||||||
|
setEditingType(null)
|
||||||
|
setFormData({
|
||||||
|
id: '',
|
||||||
|
label: '',
|
||||||
|
matchLabel: '',
|
||||||
|
icon: '⭐',
|
||||||
|
matchFromDB: false,
|
||||||
|
showJoinAfterMatch: true,
|
||||||
|
price: 1,
|
||||||
|
enabled: true
|
||||||
|
})
|
||||||
|
setShowTypeModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存匹配类型
|
||||||
|
const handleSaveType = () => {
|
||||||
|
if (!formData.id || !formData.label) {
|
||||||
|
alert('请填写类型ID和名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTypes = [...config.matchTypes]
|
||||||
|
if (editingType) {
|
||||||
|
// 更新
|
||||||
|
const index = newTypes.findIndex(t => t.id === editingType.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
newTypes[index] = { ...formData }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 新增
|
||||||
|
if (newTypes.some(t => t.id === formData.id)) {
|
||||||
|
alert('类型ID已存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newTypes.push({ ...formData })
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfig({ ...config, matchTypes: newTypes })
|
||||||
|
setShowTypeModal(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除匹配类型
|
||||||
|
const handleDeleteType = (typeId: string) => {
|
||||||
|
if (!confirm('确定要删除这个匹配类型吗?')) return
|
||||||
|
const newTypes = config.matchTypes.filter(t => t.id !== typeId)
|
||||||
|
setConfig({ ...config, matchTypes: newTypes })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换类型启用状态
|
||||||
|
const handleToggleType = (typeId: string) => {
|
||||||
|
const newTypes = config.matchTypes.map(t =>
|
||||||
|
t.id === typeId ? { ...t, enabled: !t.enabled } : t
|
||||||
|
)
|
||||||
|
setConfig({ ...config, matchTypes: newTypes })
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = ['⭐', '👥', '❤️', '🎮', '💼', '🚀', '💡', '🎯', '🔥', '✨']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-6xl mx-auto space-y-6">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||||
|
<Settings className="w-6 h-6 text-[#38bdac]" />
|
||||||
|
匹配功能配置
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 mt-1">管理找伙伴功能的匹配类型和价格</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={loadConfig}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{isSaving ? '保存中...' : '保存配置'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 基础设置 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<Zap className="w-5 h-5 text-yellow-400" />
|
||||||
|
基础设置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">
|
||||||
|
配置免费匹配次数和付费规则
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* 每日免费次数 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">每日免费匹配次数</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={config.freeMatchLimit}
|
||||||
|
onChange={(e) => setConfig({ ...config, freeMatchLimit: parseInt(e.target.value) || 0 })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">用户每天可免费匹配的次数</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 付费匹配价格 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">付费匹配价格(元)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={config.matchPrice}
|
||||||
|
onChange={(e) => setConfig({ ...config, matchPrice: parseFloat(e.target.value) || 1 })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">免费次数用完后的单次匹配价格</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 每日最大次数 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">每日最大匹配次数</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={config.settings.maxMatchesPerDay}
|
||||||
|
onChange={(e) => setConfig({
|
||||||
|
...config,
|
||||||
|
settings: { ...config.settings, maxMatchesPerDay: parseInt(e.target.value) || 10 }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">包含免费和付费的总次数</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-8 pt-4 border-t border-gray-700/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
checked={config.settings.enableFreeMatches}
|
||||||
|
onCheckedChange={(checked) => setConfig({
|
||||||
|
...config,
|
||||||
|
settings: { ...config.settings, enableFreeMatches: checked }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Label className="text-gray-300">启用免费匹配</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
checked={config.settings.enablePaidMatches}
|
||||||
|
onCheckedChange={(checked) => setConfig({
|
||||||
|
...config,
|
||||||
|
settings: { ...config.settings, enablePaidMatches: checked }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Label className="text-gray-300">启用付费匹配</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 匹配类型管理 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
匹配类型管理
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">
|
||||||
|
配置不同的匹配类型及其价格
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddType}
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
添加类型
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||||
|
<TableHead className="text-gray-400">图标</TableHead>
|
||||||
|
<TableHead className="text-gray-400">类型ID</TableHead>
|
||||||
|
<TableHead className="text-gray-400">显示名称</TableHead>
|
||||||
|
<TableHead className="text-gray-400">匹配标签</TableHead>
|
||||||
|
<TableHead className="text-gray-400">价格</TableHead>
|
||||||
|
<TableHead className="text-gray-400">数据库匹配</TableHead>
|
||||||
|
<TableHead className="text-gray-400">状态</TableHead>
|
||||||
|
<TableHead className="text-right text-gray-400">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{config.matchTypes.map((type) => (
|
||||||
|
<TableRow key={type.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-2xl">{type.icon}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-gray-300">{type.id}</TableCell>
|
||||||
|
<TableCell className="text-white font-medium">{type.label}</TableCell>
|
||||||
|
<TableCell className="text-gray-300">{type.matchLabel}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">
|
||||||
|
¥{type.price}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{type.matchFromDB ? (
|
||||||
|
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">是</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-gray-500 border-gray-600">否</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch
|
||||||
|
checked={type.enabled}
|
||||||
|
onCheckedChange={() => handleToggleType(type.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEditType(type)}
|
||||||
|
className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteType(type.id)}
|
||||||
|
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 编辑类型弹窗 */}
|
||||||
|
<Dialog open={showTypeModal} onOpenChange={setShowTypeModal}>
|
||||||
|
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white flex items-center gap-2">
|
||||||
|
{editingType ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <Plus className="w-5 h-5 text-[#38bdac]" />}
|
||||||
|
{editingType ? '编辑匹配类型' : '添加匹配类型'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">类型ID(英文)</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
placeholder="如: partner"
|
||||||
|
value={formData.id}
|
||||||
|
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
||||||
|
disabled={!!editingType}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">图标</Label>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{icons.map((icon) => (
|
||||||
|
<button
|
||||||
|
key={icon}
|
||||||
|
type="button"
|
||||||
|
className={`w-8 h-8 text-lg rounded ${formData.icon === icon ? 'bg-[#38bdac]/30 ring-1 ring-[#38bdac]' : 'bg-[#0a1628]'}`}
|
||||||
|
onClick={() => setFormData({ ...formData, icon })}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">显示名称</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
placeholder="如: 创业合伙"
|
||||||
|
value={formData.label}
|
||||||
|
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">匹配标签</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
placeholder="如: 创业伙伴"
|
||||||
|
value={formData.matchLabel}
|
||||||
|
onChange={(e) => setFormData({ ...formData, matchLabel: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">单次匹配价格(元)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={formData.price}
|
||||||
|
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6 pt-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
checked={formData.matchFromDB}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, matchFromDB: checked })}
|
||||||
|
/>
|
||||||
|
<Label className="text-gray-300 text-sm">从数据库匹配</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
checked={formData.showJoinAfterMatch}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, showJoinAfterMatch: checked })}
|
||||||
|
/>
|
||||||
|
<Label className="text-gray-300 text-sm">匹配后显示加入</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Switch
|
||||||
|
checked={formData.enabled}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
|
||||||
|
/>
|
||||||
|
<Label className="text-gray-300 text-sm">启用</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowTypeModal(false)}
|
||||||
|
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveType}
|
||||||
|
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MatchConfigPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<MatchConfigContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
224
app/admin/orders/page.tsx
Normal file
224
app/admin/orders/page.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from "react"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Search, RefreshCw, Download, Filter, TrendingUp } from "lucide-react"
|
||||||
|
import { useStore } from "@/lib/store"
|
||||||
|
|
||||||
|
interface Purchase {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
type: "section" | "fullbook" | "match"
|
||||||
|
sectionId?: string
|
||||||
|
sectionTitle?: string
|
||||||
|
amount: number
|
||||||
|
status: "pending" | "completed" | "failed"
|
||||||
|
paymentMethod?: string
|
||||||
|
referrerEarnings?: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrdersContent() {
|
||||||
|
const { getAllPurchases, getAllUsers } = useStore()
|
||||||
|
const [purchases, setPurchases] = useState<Purchase[]>([])
|
||||||
|
const [users, setUsers] = useState<any[]>([])
|
||||||
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setPurchases(getAllPurchases())
|
||||||
|
setUsers(getAllUsers())
|
||||||
|
setIsLoading(false)
|
||||||
|
}, [getAllPurchases, getAllUsers])
|
||||||
|
|
||||||
|
// 获取用户昵称
|
||||||
|
const getUserNickname = (userId: string) => {
|
||||||
|
const user = users.find(u => u.id === userId)
|
||||||
|
return user?.nickname || "未知用户"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户手机号
|
||||||
|
const getUserPhone = (userId: string) => {
|
||||||
|
const user = users.find(u => u.id === userId)
|
||||||
|
return user?.phone || "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤订单
|
||||||
|
const filteredPurchases = purchases.filter((p) => {
|
||||||
|
const matchSearch =
|
||||||
|
getUserNickname(p.userId).includes(searchTerm) ||
|
||||||
|
getUserPhone(p.userId).includes(searchTerm) ||
|
||||||
|
p.sectionTitle?.includes(searchTerm) ||
|
||||||
|
p.id.includes(searchTerm)
|
||||||
|
|
||||||
|
const matchStatus = statusFilter === "all" || p.status === statusFilter
|
||||||
|
|
||||||
|
return matchSearch && matchStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
const totalRevenue = purchases.filter(p => p.status === "completed").reduce((sum, p) => sum + p.amount, 0)
|
||||||
|
const todayRevenue = purchases
|
||||||
|
.filter(p => {
|
||||||
|
const today = new Date().toDateString()
|
||||||
|
return p.status === "completed" && new Date(p.createdAt).toDateString() === today
|
||||||
|
})
|
||||||
|
.reduce((sum, p) => sum + p.amount, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-7xl mx-auto">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">订单管理</h2>
|
||||||
|
<p className="text-gray-400 mt-1">共 {purchases.length} 笔订单</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-gray-400">总收入:</span>
|
||||||
|
<span className="text-[#38bdac] font-bold">¥{totalRevenue.toFixed(2)}</span>
|
||||||
|
<span className="text-gray-600">|</span>
|
||||||
|
<span className="text-gray-400">今日:</span>
|
||||||
|
<span className="text-[#FFD700] font-bold">¥{todayRevenue.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索订单号/用户/章节..."
|
||||||
|
className="pl-10 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="w-4 h-4 text-gray-400" />
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">全部状态</option>
|
||||||
|
<option value="completed">已完成</option>
|
||||||
|
<option value="pending">待支付</option>
|
||||||
|
<option value="failed">已失败</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
导出
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||||||
|
<span className="ml-2 text-gray-400">加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||||
|
<TableHead className="text-gray-400">订单号</TableHead>
|
||||||
|
<TableHead className="text-gray-400">用户</TableHead>
|
||||||
|
<TableHead className="text-gray-400">商品</TableHead>
|
||||||
|
<TableHead className="text-gray-400">金额</TableHead>
|
||||||
|
<TableHead className="text-gray-400">支付方式</TableHead>
|
||||||
|
<TableHead className="text-gray-400">状态</TableHead>
|
||||||
|
<TableHead className="text-gray-400">分销佣金</TableHead>
|
||||||
|
<TableHead className="text-gray-400">下单时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredPurchases.map((purchase) => (
|
||||||
|
<TableRow key={purchase.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||||
|
<TableCell className="font-mono text-xs text-gray-400">
|
||||||
|
{purchase.id.slice(0, 12)}...
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="text-white text-sm">{getUserNickname(purchase.userId)}</p>
|
||||||
|
<p className="text-gray-500 text-xs">{getUserPhone(purchase.userId)}</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<p className="text-white text-sm">
|
||||||
|
{purchase.type === "fullbook" ? "整本购买" :
|
||||||
|
purchase.type === "match" ? "匹配次数" :
|
||||||
|
purchase.sectionTitle || `章节${purchase.sectionId}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-xs">
|
||||||
|
{purchase.type === "fullbook" ? "全书" :
|
||||||
|
purchase.type === "match" ? "功能" : "单章"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-[#38bdac] font-bold">
|
||||||
|
¥{purchase.amount.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-300">
|
||||||
|
{purchase.paymentMethod === "wechat" ? "微信支付" :
|
||||||
|
purchase.paymentMethod === "alipay" ? "支付宝" :
|
||||||
|
purchase.paymentMethod || "微信支付"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{purchase.status === "completed" ? (
|
||||||
|
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
|
||||||
|
已完成
|
||||||
|
</Badge>
|
||||||
|
) : purchase.status === "pending" ? (
|
||||||
|
<Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">
|
||||||
|
待支付
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-red-500/20 text-red-400 hover:bg-red-500/20 border-0">
|
||||||
|
已失败
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-[#FFD700]">
|
||||||
|
{purchase.referrerEarnings ? `¥${purchase.referrerEarnings.toFixed(2)}` : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-400 text-sm">
|
||||||
|
{new Date(purchase.createdAt).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{filteredPurchases.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-12 text-gray-500">
|
||||||
|
暂无订单数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrdersPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<OrdersContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
184
app/admin/page.tsx
Normal file
184
app/admin/page.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight } from "lucide-react"
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const [users, setUsers] = useState<any[]>([])
|
||||||
|
const [purchases, setPurchases] = useState<any[]>([])
|
||||||
|
|
||||||
|
// 从API获取数据(任意接口失败时仍保持页面可展示,不抛错)
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
const usersRes = await fetch('/api/db/users')
|
||||||
|
const usersData = await usersRes.ok ? usersRes.json().catch(() => ({})) : { success: false }
|
||||||
|
if (usersData.success && Array.isArray(usersData.users)) {
|
||||||
|
setUsers(usersData.users)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('加载用户数据失败', e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ordersRes = await fetch('/api/orders')
|
||||||
|
const ordersData = await ordersRes.ok ? ordersRes.json().catch(() => ({})) : { success: false }
|
||||||
|
if (ordersData.success && Array.isArray(ordersData.orders)) {
|
||||||
|
setPurchases(ordersData.orders)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('加载订单数据失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
loadData()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 防止Hydration错误:服务端渲染时显示加载状态
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-7xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold mb-8 text-white">数据概览</h1>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Card key={i} className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<div className="h-4 w-20 bg-gray-700 rounded animate-pulse" />
|
||||||
|
<div className="w-8 h-8 bg-gray-700 rounded-lg animate-pulse" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-8 w-16 bg-gray-700 rounded animate-pulse" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||||||
|
<span className="ml-2 text-gray-400">加载中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRevenue = purchases.reduce((sum, p) => sum + (Number(p?.amount) || 0), 0)
|
||||||
|
const totalUsers = users.length
|
||||||
|
const totalPurchases = purchases.length
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ title: "总用户数", value: totalUsers, icon: Users, color: "text-blue-400", bg: "bg-blue-500/20", link: "/admin/users" },
|
||||||
|
{
|
||||||
|
title: "总收入",
|
||||||
|
value: `¥${Number(totalRevenue).toFixed(2)}`,
|
||||||
|
icon: TrendingUp,
|
||||||
|
color: "text-[#38bdac]",
|
||||||
|
bg: "bg-[#38bdac]/20",
|
||||||
|
link: "/admin/orders",
|
||||||
|
},
|
||||||
|
{ title: "订单数", value: totalPurchases, icon: ShoppingBag, color: "text-purple-400", bg: "bg-purple-500/20", link: "/admin/orders" },
|
||||||
|
{
|
||||||
|
title: "转化率",
|
||||||
|
value: `${totalUsers > 0 ? (Number(totalPurchases) / Number(totalUsers) * 100).toFixed(1) : 0}%`,
|
||||||
|
icon: BookOpen,
|
||||||
|
color: "text-orange-400",
|
||||||
|
bg: "bg-orange-500/20",
|
||||||
|
link: "/admin/distribution",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-7xl mx-auto">
|
||||||
|
<h1 className="text-2xl font-bold mb-8 text-white">数据概览</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
className="bg-[#0f2137] border-gray-700/50 shadow-xl cursor-pointer hover:border-[#38bdac]/50 transition-colors group"
|
||||||
|
onClick={() => stat.link && router.push(stat.link)}
|
||||||
|
>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-gray-400">{stat.title}</CardTitle>
|
||||||
|
<div className={`p-2 rounded-lg ${stat.bg}`}>
|
||||||
|
<stat.icon className={`w-4 h-4 ${stat.color}`} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-2xl font-bold text-white">{stat.value}</div>
|
||||||
|
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-[#38bdac] transition-colors" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">最近订单</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{purchases
|
||||||
|
.slice(-5)
|
||||||
|
.reverse()
|
||||||
|
.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="flex items-center justify-between p-4 bg-[#0a1628] rounded-lg border border-gray-700/30"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">{p.sectionTitle || "整本购买"}</p>
|
||||||
|
<p className="text-xs text-gray-500">{p?.createdAt ? new Date(p.createdAt).toLocaleString() : "-"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-bold text-[#38bdac]">+¥{Number(p?.amount) || 0}</p>
|
||||||
|
<p className="text-xs text-gray-400">{p?.paymentMethod || "微信支付"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{purchases.length === 0 && <p className="text-gray-500 text-center py-8">暂无订单数据</p>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">新注册用户</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{users
|
||||||
|
.slice(-5)
|
||||||
|
.reverse()
|
||||||
|
.map((u) => (
|
||||||
|
<div
|
||||||
|
key={u.id}
|
||||||
|
className="flex items-center justify-between p-4 bg-[#0a1628] rounded-lg border border-gray-700/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
|
||||||
|
{u.nickname?.charAt(0) || "?"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">{u.nickname || "匿名用户"}</p>
|
||||||
|
<p className="text-xs text-gray-500">{u.phone || "-"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{u?.createdAt ? new Date(u.createdAt).toLocaleDateString() : "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{users.length === 0 && <p className="text-gray-500 text-center py-8">暂无用户数据</p>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
app/admin/payment/loading.tsx
Normal file
3
app/admin/payment/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
375
app/admin/payment/page.tsx
Normal file
375
app/admin/payment/page.tsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useStore } from "@/lib/store"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import {
|
||||||
|
Save,
|
||||||
|
RefreshCw,
|
||||||
|
Smartphone,
|
||||||
|
CreditCard,
|
||||||
|
ExternalLink,
|
||||||
|
Bitcoin,
|
||||||
|
Globe,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
HelpCircle,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
export default function PaymentConfigPage() {
|
||||||
|
const { settings, updateSettings, fetchSettings } = useStore()
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [localSettings, setLocalSettings] = useState(settings.paymentMethods)
|
||||||
|
const [copied, setCopied] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalSettings(settings.paymentMethods)
|
||||||
|
}, [settings.paymentMethods])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
updateSettings({ paymentMethods: localSettings })
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||||
|
setLoading(false)
|
||||||
|
alert("配置已保存!")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
await fetchSettings()
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = (text: string, field: string) => {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
setCopied(field)
|
||||||
|
setTimeout(() => setCopied(""), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWechat = (field: string, value: any) => {
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
wechat: { ...prev.wechat, [field]: value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAlipay = (field: string, value: any) => {
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
alipay: { ...prev.alipay, [field]: value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateUsdt = (field: string, value: any) => {
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
usdt: { ...prev.usdt, [field]: value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePaypal = (field: string, value: any) => {
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
paypal: { ...prev.paypal, [field]: value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-5xl mx-auto">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-2 text-white">支付配置</h1>
|
||||||
|
<p className="text-gray-400">配置微信、支付宝、USDT、PayPal等支付参数</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
同步配置
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 bg-[#07C160]/10 border border-[#07C160]/30 rounded-xl p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<HelpCircle className="w-5 h-5 text-[#07C160] flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium mb-2 text-[#07C160]">如何获取微信群跳转链接?</p>
|
||||||
|
<ol className="text-[#07C160]/80 space-y-1 list-decimal list-inside">
|
||||||
|
<li>打开微信,进入目标微信群</li>
|
||||||
|
<li>点击右上角"..." → "群二维码"</li>
|
||||||
|
<li>点击右上角"..." → "发送到电脑"</li>
|
||||||
|
<li>在电脑上保存二维码图片,上传到图床获取URL</li>
|
||||||
|
<li>或使用草料二维码等工具解析二维码获取链接</li>
|
||||||
|
</ol>
|
||||||
|
<p className="text-[#07C160]/60 mt-2">提示:微信群二维码7天后失效,建议使用活码工具</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="wechat" className="space-y-6">
|
||||||
|
<TabsList className="bg-[#0f2137] border border-gray-700/50 p-1 grid grid-cols-4 w-full">
|
||||||
|
<TabsTrigger
|
||||||
|
value="wechat"
|
||||||
|
className="data-[state=active]:bg-[#07C160]/20 data-[state=active]:text-[#07C160] text-gray-400"
|
||||||
|
>
|
||||||
|
<Smartphone className="w-4 h-4 mr-2" />
|
||||||
|
微信
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="alipay"
|
||||||
|
className="data-[state=active]:bg-[#1677FF]/20 data-[state=active]:text-[#1677FF] text-gray-400"
|
||||||
|
>
|
||||||
|
<CreditCard className="w-4 h-4 mr-2" />
|
||||||
|
支付宝
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="usdt"
|
||||||
|
className="data-[state=active]:bg-[#26A17B]/20 data-[state=active]:text-[#26A17B] text-gray-400"
|
||||||
|
>
|
||||||
|
<Bitcoin className="w-4 h-4 mr-2" />
|
||||||
|
USDT
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="paypal"
|
||||||
|
className="data-[state=active]:bg-[#003087]/20 data-[state=active]:text-[#169BD7] text-gray-400"
|
||||||
|
>
|
||||||
|
<Globe className="w-4 h-4 mr-2" />
|
||||||
|
PayPal
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 微信支付配置 */}
|
||||||
|
<TabsContent value="wechat" className="space-y-4">
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-[#07C160] flex items-center gap-2">
|
||||||
|
<Smartphone className="w-5 h-5" />
|
||||||
|
微信支付配置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">配置微信支付参数和跳转链接</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Switch checked={localSettings.wechat.enabled} onCheckedChange={(c) => updateWechat("enabled", c)} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* API配置 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">网站AppID</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||||
|
value={localSettings.wechat.websiteAppId || ""}
|
||||||
|
onChange={(e) => updateWechat("websiteAppId", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">商户号</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||||
|
value={localSettings.wechat.merchantId || ""}
|
||||||
|
onChange={(e) => updateWechat("merchantId", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 跳转链接配置 - 重点 */}
|
||||||
|
<div className="border-t border-gray-700/50 pt-4 space-y-4">
|
||||||
|
<h4 className="text-white font-medium flex items-center gap-2">
|
||||||
|
<ExternalLink className="w-4 h-4 text-[#38bdac]" />
|
||||||
|
跳转链接配置(核心功能)
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<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://收款码图片URL 或 weixin://支付链接"
|
||||||
|
value={localSettings.wechat.qrCode || ""}
|
||||||
|
onChange={(e) => updateWechat("qrCode", e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">用户点击微信支付后显示的二维码图片URL</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 bg-[#07C160]/5 p-4 rounded-xl border border-[#07C160]/20">
|
||||||
|
<Label className="text-[#07C160] font-medium">微信群跳转链接(支付成功后跳转)</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-[#07C160]/30 text-white placeholder:text-gray-500"
|
||||||
|
placeholder="https://weixin.qq.com/g/... 或微信群二维码图片URL"
|
||||||
|
value={localSettings.wechat.groupQrCode || ""}
|
||||||
|
onChange={(e) => updateWechat("groupQrCode", e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[#07C160]/70">用户支付成功后将自动跳转到此链接,进入指定微信群</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 支付宝配置 */}
|
||||||
|
<TabsContent value="alipay" className="space-y-4">
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-[#1677FF] flex items-center gap-2">
|
||||||
|
<CreditCard className="w-5 h-5" />
|
||||||
|
支付宝配置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">已加载真实支付宝参数</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Switch checked={localSettings.alipay.enabled} onCheckedChange={(c) => updateAlipay("enabled", c)} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">合作者身份 (PID)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||||
|
value={localSettings.alipay.partnerId || ""}
|
||||||
|
onChange={(e) => updateAlipay("partnerId", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="border-gray-700 bg-transparent"
|
||||||
|
onClick={() => handleCopy(localSettings.alipay.partnerId || "", "pid")}
|
||||||
|
>
|
||||||
|
{copied === "pid" ? (
|
||||||
|
<Check className="w-4 h-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">安全校验码 (Key)</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||||
|
value={localSettings.alipay.securityKey || ""}
|
||||||
|
onChange={(e) => updateAlipay("securityKey", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-700/50 pt-4 space-y-4">
|
||||||
|
<h4 className="text-white font-medium flex items-center gap-2">
|
||||||
|
<ExternalLink className="w-4 h-4 text-[#38bdac]" />
|
||||||
|
跳转链接配置
|
||||||
|
</h4>
|
||||||
|
<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://qr.alipay.com/... 或收款码图片URL"
|
||||||
|
value={localSettings.alipay.qrCode || ""}
|
||||||
|
onChange={(e) => updateAlipay("qrCode", e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">用户点击支付宝支付后显示的二维码</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* USDT配置 */}
|
||||||
|
<TabsContent value="usdt" className="space-y-4">
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-[#26A17B] flex items-center gap-2">
|
||||||
|
<Bitcoin className="w-5 h-5" />
|
||||||
|
USDT配置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">配置加密货币收款地址</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Switch checked={localSettings.usdt.enabled} onCheckedChange={(c) => updateUsdt("enabled", c)} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">网络类型</Label>
|
||||||
|
<select
|
||||||
|
className="w-full bg-[#0a1628] border border-gray-700 text-white rounded-md p-2"
|
||||||
|
value={localSettings.usdt.network}
|
||||||
|
onChange={(e) => updateUsdt("network", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="TRC20">TRC20 (波场)</option>
|
||||||
|
<option value="ERC20">ERC20 (以太坊)</option>
|
||||||
|
<option value="BEP20">BEP20 (币安链)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">收款地址</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||||
|
placeholder="T... (TRC20地址)"
|
||||||
|
value={localSettings.usdt.address || ""}
|
||||||
|
onChange={(e) => updateUsdt("address", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">汇率 (1 USD = ? CNY)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.usdt.exchangeRate}
|
||||||
|
onChange={(e) => updateUsdt("exchangeRate", Number.parseFloat(e.target.value) || 7.2)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* PayPal配置 */}
|
||||||
|
<TabsContent value="paypal" className="space-y-4">
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CardTitle className="text-[#169BD7] flex items-center gap-2">
|
||||||
|
<Globe className="w-5 h-5" />
|
||||||
|
PayPal配置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">配置PayPal收款账户</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Switch checked={localSettings.paypal.enabled} onCheckedChange={(c) => updatePaypal("enabled", c)} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">PayPal邮箱</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
value={localSettings.paypal.email || ""}
|
||||||
|
onChange={(e) => updatePaypal("email", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">汇率 (1 USD = ? CNY)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.paypal.exchangeRate}
|
||||||
|
onChange={(e) => updatePaypal("exchangeRate", Number.parseFloat(e.target.value) || 7.2)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
app/admin/qrcodes/loading.tsx
Normal file
3
app/admin/qrcodes/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
225
app/admin/qrcodes/page.tsx
Normal file
225
app/admin/qrcodes/page.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useStore } from "@/lib/store"
|
||||||
|
import { QrCode, Upload, Link, ExternalLink, Copy, Check, HelpCircle } from "lucide-react"
|
||||||
|
|
||||||
|
export default function QRCodesPage() {
|
||||||
|
const { settings, updateSettings } = useStore()
|
||||||
|
const [liveQRUrls, setLiveQRUrls] = useState("")
|
||||||
|
const [wechatGroupUrl, setWechatGroupUrl] = useState("")
|
||||||
|
const [copied, setCopied] = useState("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLiveQRUrls(settings.liveQRCodes?.[0]?.urls?.join("\n") || "")
|
||||||
|
setWechatGroupUrl(settings.paymentMethods?.wechat?.groupQrCode || "")
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
|
const handleCopy = (text: string, field: string) => {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
setCopied(field)
|
||||||
|
setTimeout(() => setCopied(""), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveLiveQR = () => {
|
||||||
|
const urls = liveQRUrls
|
||||||
|
.split("\n")
|
||||||
|
.map((u) => u.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const updatedLiveQRCodes = [...(settings.liveQRCodes || [])]
|
||||||
|
if (updatedLiveQRCodes[0]) {
|
||||||
|
updatedLiveQRCodes[0].urls = urls
|
||||||
|
} else {
|
||||||
|
updatedLiveQRCodes.push({ id: "live-1", name: "微信群活码", urls, clickCount: 0 })
|
||||||
|
}
|
||||||
|
updateSettings({ liveQRCodes: updatedLiveQRCodes })
|
||||||
|
alert("群活码配置已保存!")
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveWechatGroup = () => {
|
||||||
|
updateSettings({
|
||||||
|
paymentMethods: {
|
||||||
|
...settings.paymentMethods,
|
||||||
|
wechat: {
|
||||||
|
...settings.paymentMethods.wechat,
|
||||||
|
groupQrCode: wechatGroupUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
alert("微信群链接已保存!用户支付成功后将自动跳转")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试跳转
|
||||||
|
const handleTestJump = () => {
|
||||||
|
if (wechatGroupUrl) {
|
||||||
|
window.open(wechatGroupUrl, "_blank")
|
||||||
|
} else {
|
||||||
|
alert("请先配置微信群链接")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-5xl mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-white">微信群活码管理</h2>
|
||||||
|
<p className="text-gray-400 mt-1">配置微信群跳转链接,用户支付后自动跳转加群</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 使用说明 */}
|
||||||
|
<div className="mb-6 bg-[#07C160]/10 border border-[#07C160]/30 rounded-xl p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<HelpCircle className="w-5 h-5 text-[#07C160] flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium mb-2 text-[#07C160]">微信群活码配置指南</p>
|
||||||
|
<div className="text-[#07C160]/80 space-y-2">
|
||||||
|
<p className="font-medium">方法一:使用草料活码(推荐)</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 pl-2">
|
||||||
|
<li>
|
||||||
|
访问{" "}
|
||||||
|
<a href="https://cli.im/url" target="_blank" className="underline" rel="noreferrer">
|
||||||
|
草料二维码
|
||||||
|
</a>{" "}
|
||||||
|
创建活码
|
||||||
|
</li>
|
||||||
|
<li>上传微信群二维码图片,生成永久链接</li>
|
||||||
|
<li>复制生成的短链接填入下方配置</li>
|
||||||
|
<li>群满后可直接在草料后台更换新群码,链接不变</li>
|
||||||
|
</ol>
|
||||||
|
<p className="font-medium mt-3">方法二:直接使用微信群链接</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 pl-2">
|
||||||
|
<li>微信打开目标群 → 右上角"..." → 群二维码</li>
|
||||||
|
<li>长按二维码 → 识别二维码 → 复制链接</li>
|
||||||
|
<li>或使用第三方工具解析二维码获取链接</li>
|
||||||
|
</ol>
|
||||||
|
<p className="text-[#07C160]/60 mt-2">注意:微信原生群二维码7天后失效,建议使用草料活码</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
{/* 主要配置 - 支付后跳转 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl md:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-[#07C160] flex items-center gap-2">
|
||||||
|
<QrCode className="w-5 h-5" />
|
||||||
|
支付成功跳转链接(核心配置)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">用户支付完成后自动跳转到此链接,进入指定微信群</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wechat-group-url" className="text-gray-300 flex items-center gap-2">
|
||||||
|
<Link className="w-4 h-4" />
|
||||||
|
微信群链接 / 活码链接
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="wechat-group-url"
|
||||||
|
placeholder="https://cli.im/xxxxx 或 https://weixin.qq.com/g/..."
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 flex-1"
|
||||||
|
value={wechatGroupUrl}
|
||||||
|
onChange={(e) => setWechatGroupUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="border-gray-700 bg-transparent hover:bg-gray-700/50"
|
||||||
|
onClick={() => handleCopy(wechatGroupUrl, "group")}
|
||||||
|
>
|
||||||
|
{copied === "group" ? (
|
||||||
|
<Check className="w-4 h-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
支持格式:草料短链、微信群链接(https://weixin.qq.com/g/...)、企业微信链接等
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button onClick={handleSaveWechatGroup} className="flex-1 bg-[#07C160] hover:bg-[#06AD51] text-white">
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
保存配置
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleTestJump}
|
||||||
|
variant="outline"
|
||||||
|
className="border-[#07C160] text-[#07C160] hover:bg-[#07C160]/10 bg-transparent"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
|
测试跳转
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 多群轮换配置 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl md:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<QrCode className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
多群轮换(高级配置)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">配置多个群链接,系统自动轮换分配,避免单群满员</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="group-urls" className="text-gray-300 flex items-center gap-2">
|
||||||
|
<Link className="w-4 h-4" />
|
||||||
|
多个群链接(每行一个)
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="group-urls"
|
||||||
|
placeholder={`https://cli.im/group1\nhttps://cli.im/group2\nhttps://cli.im/group3`}
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 min-h-[120px] font-mono text-sm"
|
||||||
|
value={liveQRUrls}
|
||||||
|
onChange={(e) => setLiveQRUrls(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">每行填写一个群链接,系统将按顺序或随机分配</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-[#0a1628] rounded-lg border border-gray-700/50">
|
||||||
|
<span className="text-sm text-gray-400">已配置群数量</span>
|
||||||
|
<span className="font-bold text-[#38bdac]">{liveQRUrls.split("\n").filter(Boolean).length} 个</span>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSaveLiveQR} className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
|
保存多群配置
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 常见问题 */}
|
||||||
|
<div className="mt-6 bg-[#0f2137] rounded-xl p-4 border border-gray-700/50">
|
||||||
|
<h4 className="text-white font-medium mb-3">常见问题</h4>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-[#38bdac]">Q: 为什么推荐使用草料活码?</p>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
A: 草料活码是永久链接,群满后可直接在后台更换新群码,无需修改网站配置。微信原生群码7天失效。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[#38bdac]">Q: 支付后没有跳转怎么办?</p>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
A: 1) 检查链接是否正确填写 2) 部分浏览器可能拦截弹窗,用户需手动允许 3) 建议使用https开头的链接
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[#38bdac]">Q: 如何获取企业微信群链接?</p>
|
||||||
|
<p className="text-gray-400">A: 企业微信后台 → 客户联系 → 加入群聊 → 获取永久有效的群二维码链接</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
app/admin/settings/loading.tsx
Normal file
3
app/admin/settings/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
645
app/admin/settings/page.tsx
Normal file
645
app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,645 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Slider } from "@/components/ui/slider"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { useStore } from "@/lib/store"
|
||||||
|
import { Save, Settings, Users, DollarSign, UserCircle, Calendar, MapPin, BookOpen, Gift, X, Plus, Smartphone } from "lucide-react"
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { settings, updateSettings } = useStore()
|
||||||
|
const [localSettings, setLocalSettings] = useState({
|
||||||
|
sectionPrice: settings.sectionPrice,
|
||||||
|
baseBookPrice: settings.baseBookPrice,
|
||||||
|
distributorShare: settings.distributorShare,
|
||||||
|
authorInfo: {
|
||||||
|
...settings.authorInfo,
|
||||||
|
startDate: settings.authorInfo?.startDate || "2025年10月15日",
|
||||||
|
bio: settings.authorInfo?.bio || "连续创业者,私域运营专家,每天早上6-9点在Soul派对房分享真实商业故事",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
||||||
|
// 免费章节配置
|
||||||
|
const [freeChapters, setFreeChapters] = useState<string[]>(['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3'])
|
||||||
|
const [newFreeChapter, setNewFreeChapter] = useState('')
|
||||||
|
|
||||||
|
// 小程序配置
|
||||||
|
const [mpConfig, setMpConfig] = useState({
|
||||||
|
appId: 'wxb8bbb2b10dec74aa',
|
||||||
|
apiDomain: 'https://soul.quwanzhi.com',
|
||||||
|
buyerDiscount: 5, // 购买者优惠比例
|
||||||
|
referralBindDays: 30, // 推荐绑定天数
|
||||||
|
minWithdraw: 10, // 最低提现金额
|
||||||
|
})
|
||||||
|
|
||||||
|
// 功能开关配置
|
||||||
|
const [featureConfig, setFeatureConfig] = useState({
|
||||||
|
matchEnabled: true, // 找伙伴功能开关(默认开启)
|
||||||
|
referralEnabled: true, // 推广功能开关
|
||||||
|
searchEnabled: true, // 搜索功能开关
|
||||||
|
aboutEnabled: true // 关于页面开关
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
useEffect(() => {
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/db/config')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.freeChapters) setFreeChapters(data.freeChapters)
|
||||||
|
if (data.mpConfig) setMpConfig(prev => ({ ...prev, ...data.mpConfig }))
|
||||||
|
if (data.features) setFeatureConfig(prev => ({ ...prev, ...data.features }))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Load config error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadConfig()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalSettings({
|
||||||
|
sectionPrice: settings.sectionPrice,
|
||||||
|
baseBookPrice: settings.baseBookPrice,
|
||||||
|
distributorShare: settings.distributorShare,
|
||||||
|
authorInfo: {
|
||||||
|
...settings.authorInfo,
|
||||||
|
startDate: settings.authorInfo?.startDate || "2025年10月15日",
|
||||||
|
bio: settings.authorInfo?.bio || "连续创业者,私域运营专家,每天早上6-9点在Soul派对房分享真实商业故事",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
updateSettings(localSettings)
|
||||||
|
|
||||||
|
// 同时保存到数据库
|
||||||
|
await fetch('/api/db/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(localSettings)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 保存免费章节和小程序配置
|
||||||
|
const res1 = await fetch('/api/db/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ freeChapters, mpConfig })
|
||||||
|
})
|
||||||
|
const result1 = await res1.json()
|
||||||
|
console.log('保存免费章节和小程序配置:', result1)
|
||||||
|
|
||||||
|
// 保存功能开关配置
|
||||||
|
const res2 = await fetch('/api/db/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
key: 'feature_config',
|
||||||
|
config: featureConfig,
|
||||||
|
description: '功能开关配置'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const result2 = await res2.json()
|
||||||
|
console.log('保存功能开关配置:', result2)
|
||||||
|
|
||||||
|
// 验证保存结果
|
||||||
|
const verifyRes = await fetch('/api/db/config')
|
||||||
|
const verifyData = await verifyRes.json()
|
||||||
|
console.log('验证保存结果:', verifyData.features)
|
||||||
|
|
||||||
|
// 立即更新本地状态
|
||||||
|
if (verifyData.features) {
|
||||||
|
setFeatureConfig(prev => ({ ...prev, ...verifyData.features }))
|
||||||
|
}
|
||||||
|
|
||||||
|
alert("设置已保存!\n\n找伙伴功能:" + (verifyData.features?.matchEnabled ? "✅ 开启" : "❌ 关闭"))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save settings error:', error)
|
||||||
|
alert("保存失败: " + (error as Error).message)
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加免费章节
|
||||||
|
const addFreeChapter = () => {
|
||||||
|
if (newFreeChapter && !freeChapters.includes(newFreeChapter)) {
|
||||||
|
setFreeChapters([...freeChapters, newFreeChapter])
|
||||||
|
setNewFreeChapter('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除免费章节
|
||||||
|
const removeFreeChapter = (chapter: string) => {
|
||||||
|
setFreeChapters(freeChapters.filter(c => c !== chapter))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-4xl mx-auto">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">系统设置</h2>
|
||||||
|
<p className="text-gray-400 mt-1">配置全站基础参数与开关</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{isSaving ? "保存中..." : "保存设置"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 作者信息 - 重点增强 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<UserCircle className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
关于作者
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">配置作者信息,将在"关于作者"页面显示</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="author-name" className="text-gray-300 flex items-center gap-1">
|
||||||
|
<UserCircle className="w-3 h-3" />
|
||||||
|
主理人名称
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="author-name"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.authorInfo.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
authorInfo: { ...prev.authorInfo, name: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="start-date" className="text-gray-300 flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
开播日期
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="start-date"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
placeholder="例如: 2025年10月15日"
|
||||||
|
value={localSettings.authorInfo.startDate || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
authorInfo: { ...prev.authorInfo, startDate: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="live-time" className="text-gray-300 flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
直播时间
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="live-time"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
placeholder="例如: 06:00-09:00"
|
||||||
|
value={localSettings.authorInfo.liveTime}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
authorInfo: { ...prev.authorInfo, liveTime: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="platform" className="text-gray-300 flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
直播平台
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="platform"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
placeholder="例如: Soul派对房"
|
||||||
|
value={localSettings.authorInfo.platform}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
authorInfo: { ...prev.authorInfo, platform: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description" className="text-gray-300 flex items-center gap-1">
|
||||||
|
<BookOpen className="w-3 h-3" />
|
||||||
|
简介描述
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.authorInfo.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
authorInfo: { ...prev.authorInfo, description: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="bio" className="text-gray-300">详细介绍</Label>
|
||||||
|
<Textarea
|
||||||
|
id="bio"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white min-h-[100px]"
|
||||||
|
placeholder="输入作者详细介绍..."
|
||||||
|
value={localSettings.authorInfo.bio || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
authorInfo: { ...prev.authorInfo, bio: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 预览卡片 */}
|
||||||
|
<div className="mt-4 p-4 rounded-xl bg-[#0a1628] border border-[#38bdac]/30">
|
||||||
|
<p className="text-xs text-gray-500 mb-2">预览效果</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-[#00CED1] to-[#20B2AA] flex items-center justify-center text-xl font-bold text-white">
|
||||||
|
{localSettings.authorInfo.name?.charAt(0) || "K"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-semibold">{localSettings.authorInfo.name}</p>
|
||||||
|
<p className="text-gray-400 text-xs">{localSettings.authorInfo.description}</p>
|
||||||
|
<p className="text-[#38bdac] text-xs mt-1">
|
||||||
|
每日 {localSettings.authorInfo.liveTime} · {localSettings.authorInfo.platform}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 价格设置 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<DollarSign className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
价格设置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">配置书籍和章节的定价</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">单节价格 (元)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.sectionPrice}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
sectionPrice: Number.parseFloat(e.target.value) || 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">整本价格 (元)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.baseBookPrice}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
baseBookPrice: Number.parseFloat(e.target.value) || 9.9,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 免费章节设置 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<Gift className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
免费章节
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">设置哪些章节对所有用户免费开放</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{freeChapters.map((chapter) => (
|
||||||
|
<Badge
|
||||||
|
key={chapter}
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30 px-3 py-1 text-sm"
|
||||||
|
>
|
||||||
|
{chapter}
|
||||||
|
<button
|
||||||
|
onClick={() => removeFreeChapter(chapter)}
|
||||||
|
className="ml-2 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white flex-1"
|
||||||
|
placeholder="输入章节ID,如 1.2、2.1、preface"
|
||||||
|
value={newFreeChapter}
|
||||||
|
onChange={(e) => setNewFreeChapter(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && addFreeChapter()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={addFreeChapter}
|
||||||
|
className="bg-[#38bdac] hover:bg-[#2da396]"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
常用ID: preface(序言), epilogue(尾声), appendix-1/2/3(附录), 1.1/1.2等(章节)
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 功能开关设置 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<Settings className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
功能开关
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">控制各个功能模块的显示/隐藏</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 找伙伴功能开关 */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-[#38bdac]" />
|
||||||
|
<Label htmlFor="match-enabled" className="text-white font-medium cursor-pointer">
|
||||||
|
找伙伴功能
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 ml-6">
|
||||||
|
控制小程序和Web端的找伙伴功能显示
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="match-enabled"
|
||||||
|
checked={featureConfig.matchEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setFeatureConfig(prev => ({ ...prev, matchEnabled: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 推广功能开关 */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Gift className="w-4 h-4 text-[#38bdac]" />
|
||||||
|
<Label htmlFor="referral-enabled" className="text-white font-medium cursor-pointer">
|
||||||
|
推广功能
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 ml-6">
|
||||||
|
控制推广中心的显示(我的页面入口)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="referral-enabled"
|
||||||
|
checked={featureConfig.referralEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setFeatureConfig(prev => ({ ...prev, referralEnabled: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索功能开关 */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="w-4 h-4 text-[#38bdac]" />
|
||||||
|
<Label htmlFor="search-enabled" className="text-white font-medium cursor-pointer">
|
||||||
|
搜索功能
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 ml-6">
|
||||||
|
控制首页搜索栏的显示
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="search-enabled"
|
||||||
|
checked={featureConfig.searchEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setFeatureConfig(prev => ({ ...prev, searchEnabled: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 关于页面开关 */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="w-4 h-4 text-[#38bdac]" />
|
||||||
|
<Label htmlFor="about-enabled" className="text-white font-medium cursor-pointer">
|
||||||
|
关于页面
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 ml-6">
|
||||||
|
控制关于页面的访问
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="about-enabled"
|
||||||
|
checked={featureConfig.aboutEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setFeatureConfig(prev => ({ ...prev, aboutEnabled: checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30">
|
||||||
|
<p className="text-xs text-blue-300">
|
||||||
|
💡 关闭功能后,相关入口会自动隐藏。建议在功能开发完成后再开启。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 小程序配置 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<Smartphone className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
小程序配置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">微信小程序相关参数设置</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">AppID</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={mpConfig.appId}
|
||||||
|
onChange={(e) => setMpConfig(prev => ({ ...prev, appId: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">API域名</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={mpConfig.apiDomain}
|
||||||
|
onChange={(e) => setMpConfig(prev => ({ ...prev, apiDomain: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">购买者优惠 (%)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={mpConfig.buyerDiscount}
|
||||||
|
onChange={(e) => setMpConfig(prev => ({ ...prev, buyerDiscount: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">推荐绑定天数</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={mpConfig.referralBindDays}
|
||||||
|
onChange={(e) => setMpConfig(prev => ({ ...prev, referralBindDays: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">最低提现 (元)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={mpConfig.minWithdraw}
|
||||||
|
onChange={(e) => setMpConfig(prev => ({ ...prev, minWithdraw: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 分销设置 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
分销设置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">配置分销比例和奖励规则</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Label className="text-gray-300">分销者分成比例</Label>
|
||||||
|
<span className="text-2xl font-bold text-[#38bdac]">{localSettings.distributorShare}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[localSettings.distributorShare]}
|
||||||
|
onValueChange={([value]) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
distributorShare: value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-sm text-gray-400">
|
||||||
|
<span>作者获得: {100 - localSettings.distributorShare}%</span>
|
||||||
|
<span>分销者获得: {localSettings.distributorShare}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 功能开关 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">功能开关</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">控制系统核心模块的启用状态</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="maintenance-mode" className="flex flex-col space-y-1">
|
||||||
|
<span className="text-white">维护模式</span>
|
||||||
|
<span className="font-normal text-xs text-gray-500">启用后前台将显示维护中页面</span>
|
||||||
|
</Label>
|
||||||
|
<Switch id="maintenance-mode" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="payment-enabled" className="flex flex-col space-y-1">
|
||||||
|
<span className="text-white">全站支付</span>
|
||||||
|
<span className="font-normal text-xs text-gray-500">关闭后所有支付功能将暂停</span>
|
||||||
|
</Label>
|
||||||
|
<Switch id="payment-enabled" defaultChecked />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="referral-enabled" className="flex flex-col space-y-1">
|
||||||
|
<span className="text-white">分销系统</span>
|
||||||
|
<span className="font-normal text-xs text-gray-500">是否允许用户生成邀请链接</span>
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="referral-enabled"
|
||||||
|
checked={featureConfig.referralEnabled}
|
||||||
|
onCheckedChange={(checked) => setFeatureConfig(prev => ({ ...prev, referralEnabled: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="match-enabled" className="flex flex-col space-y-1">
|
||||||
|
<span className="text-white">找伙伴功能</span>
|
||||||
|
<span className="font-normal text-xs text-gray-500">是否启用找伙伴匹配功能</span>
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="match-enabled"
|
||||||
|
checked={featureConfig.matchEnabled}
|
||||||
|
onCheckedChange={(checked) => setFeatureConfig(prev => ({ ...prev, matchEnabled: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
app/admin/site/loading.tsx
Normal file
14
app/admin/site/loading.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-4xl mx-auto">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-700 rounded w-1/4 mb-8" />
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="h-64 bg-[#0f2137] rounded-xl" />
|
||||||
|
<div className="h-48 bg-[#0f2137] rounded-xl" />
|
||||||
|
<div className="h-64 bg-[#0f2137] rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
384
app/admin/site/page.tsx
Normal file
384
app/admin/site/page.tsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { useStore } from "@/lib/store"
|
||||||
|
import { Save, Globe, Menu, FileText, Palette } from "lucide-react"
|
||||||
|
|
||||||
|
const defaultSiteConfig = {
|
||||||
|
siteName: "卡若日记",
|
||||||
|
siteTitle: "一场SOUL的创业实验场",
|
||||||
|
siteDescription: "来自Soul派对房的真实商业故事",
|
||||||
|
logo: "/logo.png",
|
||||||
|
favicon: "/favicon.ico",
|
||||||
|
primaryColor: "#00CED1",
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultMenuConfig = {
|
||||||
|
home: { enabled: true, label: "首页" },
|
||||||
|
chapters: { enabled: true, label: "目录" },
|
||||||
|
match: { enabled: true, label: "匹配" },
|
||||||
|
my: { enabled: true, label: "我的" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPageConfig = {
|
||||||
|
homeTitle: "一场SOUL的创业实验场",
|
||||||
|
homeSubtitle: "来自Soul派对房的真实商业故事",
|
||||||
|
chaptersTitle: "我要看",
|
||||||
|
matchTitle: "语音匹配",
|
||||||
|
myTitle: "我的",
|
||||||
|
aboutTitle: "关于作者",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SiteConfigPage() {
|
||||||
|
const { settings, updateSettings } = useStore()
|
||||||
|
const [localSettings, setLocalSettings] = useState({
|
||||||
|
siteConfig: { ...defaultSiteConfig },
|
||||||
|
menuConfig: { ...defaultMenuConfig },
|
||||||
|
pageConfig: { ...defaultPageConfig },
|
||||||
|
})
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
setLocalSettings({
|
||||||
|
siteConfig: { ...defaultSiteConfig, ...settings.siteConfig },
|
||||||
|
menuConfig: { ...defaultMenuConfig, ...settings.menuConfig },
|
||||||
|
pageConfig: { ...defaultPageConfig, ...settings.pageConfig },
|
||||||
|
})
|
||||||
|
}, [settings.siteConfig, settings.menuConfig, settings.pageConfig])
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
updateSettings(localSettings)
|
||||||
|
setSaved(true)
|
||||||
|
setTimeout(() => setSaved(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-4xl mx-auto">
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="h-8 bg-gray-700 rounded w-1/3"></div>
|
||||||
|
<div className="h-64 bg-gray-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-4xl mx-auto">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">网站配置</h2>
|
||||||
|
<p className="text-gray-400 mt-1">配置网站名称、图标、菜单和页面标题</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
className={`${saved ? "bg-green-500" : "bg-[#00CED1]"} hover:bg-[#20B2AA] text-white transition-colors`}
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{saved ? "已保存" : "保存设置"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 网站基础信息 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<Globe className="w-5 h-5 text-[#00CED1]" />
|
||||||
|
网站基础信息
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">配置网站名称、标题和描述</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="site-name" className="text-gray-300">
|
||||||
|
网站名称
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="site-name"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.siteConfig.siteName || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
siteConfig: { ...prev.siteConfig, siteName: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="site-title" className="text-gray-300">
|
||||||
|
网站标题
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="site-title"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.siteConfig.siteTitle || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
siteConfig: { ...prev.siteConfig, siteTitle: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="site-desc" className="text-gray-300">
|
||||||
|
网站描述
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="site-desc"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.siteConfig.siteDescription || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
siteConfig: { ...prev.siteConfig, siteDescription: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="logo" className="text-gray-300">
|
||||||
|
Logo地址
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="logo"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.siteConfig.logo || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
siteConfig: { ...prev.siteConfig, logo: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="favicon" className="text-gray-300">
|
||||||
|
Favicon地址
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="favicon"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.siteConfig.favicon || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
siteConfig: { ...prev.siteConfig, favicon: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 主题颜色 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<Palette className="w-5 h-5 text-[#00CED1]" />
|
||||||
|
主题颜色
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">配置网站主题色</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="space-y-2 flex-1">
|
||||||
|
<Label htmlFor="primary-color" className="text-gray-300">
|
||||||
|
主色调
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
id="primary-color"
|
||||||
|
type="color"
|
||||||
|
className="w-16 h-10 bg-[#0a1628] border-gray-700 cursor-pointer"
|
||||||
|
value={localSettings.siteConfig.primaryColor || "#00CED1"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
siteConfig: { ...prev.siteConfig, primaryColor: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white flex-1"
|
||||||
|
value={localSettings.siteConfig.primaryColor || "#00CED1"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
siteConfig: { ...prev.siteConfig, primaryColor: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-24 h-24 rounded-xl flex items-center justify-center text-white font-bold"
|
||||||
|
style={{ backgroundColor: localSettings.siteConfig.primaryColor || "#00CED1" }}
|
||||||
|
>
|
||||||
|
预览
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 菜单配置 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<Menu className="w-5 h-5 text-[#00CED1]" />
|
||||||
|
底部菜单配置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">控制底部导航栏菜单的显示和名称</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{Object.entries(localSettings.menuConfig).map(([key, config]) => (
|
||||||
|
<div key={key} className="flex items-center justify-between p-4 bg-[#0a1628] rounded-lg">
|
||||||
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
<Switch
|
||||||
|
checked={config?.enabled ?? true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
menuConfig: {
|
||||||
|
...prev.menuConfig,
|
||||||
|
[key]: { ...config, enabled: checked },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-gray-300 w-16 capitalize">{key}</span>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0f2137] border-gray-700 text-white max-w-[200px]"
|
||||||
|
value={config?.label || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
menuConfig: {
|
||||||
|
...prev.menuConfig,
|
||||||
|
[key]: { ...config, label: e.target.value },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm ${config?.enabled ? "text-green-400" : "text-gray-500"}`}>
|
||||||
|
{config?.enabled ? "显示" : "隐藏"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 页面标题配置 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<FileText className="w-5 h-5 text-[#00CED1]" />
|
||||||
|
页面标题配置
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">配置各个页面的标题和副标题</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">首页标题</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.pageConfig.homeTitle || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageConfig: { ...prev.pageConfig, homeTitle: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">首页副标题</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.pageConfig.homeSubtitle || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageConfig: { ...prev.pageConfig, homeSubtitle: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">目录页标题</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.pageConfig.chaptersTitle || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageConfig: { ...prev.pageConfig, chaptersTitle: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">匹配页标题</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.pageConfig.matchTitle || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageConfig: { ...prev.pageConfig, matchTitle: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">我的页标题</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.pageConfig.myTitle || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageConfig: { ...prev.pageConfig, myTitle: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">关于作者标题</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
value={localSettings.pageConfig.aboutTitle || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pageConfig: { ...prev.pageConfig, aboutTitle: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
app/admin/users/loading.tsx
Normal file
3
app/admin/users/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
710
app/admin/users/page.tsx
Normal file
710
app/admin/users/page.tsx
Normal file
@@ -0,0 +1,710 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from "react"
|
||||||
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { Search, UserPlus, Trash2, Edit3, Key, Save, X, RefreshCw, Users, Eye, Link2, History } from "lucide-react"
|
||||||
|
import { UserDetailModal } from "@/components/modules/user/user-detail-modal"
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
open_id?: string | null
|
||||||
|
phone?: string | null
|
||||||
|
nickname: string
|
||||||
|
password?: string | null
|
||||||
|
wechat_id?: string | null
|
||||||
|
avatar?: string | null
|
||||||
|
is_admin?: boolean | number
|
||||||
|
has_full_book?: boolean | number
|
||||||
|
referral_code: string
|
||||||
|
referred_by?: string | null
|
||||||
|
earnings: number | string
|
||||||
|
pending_earnings: number | string
|
||||||
|
withdrawn_earnings?: number | string
|
||||||
|
referral_count: number
|
||||||
|
match_count_today?: number
|
||||||
|
last_match_date?: string | null
|
||||||
|
purchased_sections?: string[] | string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersContent() {
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showUserModal, setShowUserModal] = useState(false)
|
||||||
|
const [showPasswordModal, setShowPasswordModal] = useState(false)
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||||
|
const [newPassword, setNewPassword] = useState("")
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("")
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
||||||
|
// 绑定关系弹窗
|
||||||
|
const [showReferralsModal, setShowReferralsModal] = useState(false)
|
||||||
|
const [referralsData, setReferralsData] = useState<any>({ referrals: [], stats: {} })
|
||||||
|
const [referralsLoading, setReferralsLoading] = useState(false)
|
||||||
|
const [selectedUserForReferrals, setSelectedUserForReferrals] = useState<User | null>(null)
|
||||||
|
|
||||||
|
// 用户详情弹窗
|
||||||
|
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||||
|
const [selectedUserIdForDetail, setSelectedUserIdForDetail] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// 初始表单状态
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
phone: "",
|
||||||
|
nickname: "",
|
||||||
|
password: "",
|
||||||
|
is_admin: false,
|
||||||
|
has_full_book: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载用户列表
|
||||||
|
const loadUsers = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/db/users')
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
setUsers(data.users || [])
|
||||||
|
} else {
|
||||||
|
setError(data.error || '加载失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load users error:', err)
|
||||||
|
setError('网络错误,请检查连接')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filteredUsers = users.filter((u) =>
|
||||||
|
u.nickname?.includes(searchTerm) || u.phone?.includes(searchTerm)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 删除用户
|
||||||
|
const handleDelete = async (userId: string) => {
|
||||||
|
if (!confirm("确定要删除这个用户吗?")) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/db/users?id=${userId}`, { method: 'DELETE' })
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
loadUsers()
|
||||||
|
} else {
|
||||||
|
alert("删除失败: " + (data.error || "未知错误"))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete user error:', error)
|
||||||
|
alert("删除失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开编辑用户弹窗
|
||||||
|
const handleEditUser = (user: User) => {
|
||||||
|
setEditingUser(user)
|
||||||
|
setFormData({
|
||||||
|
phone: user.phone || "",
|
||||||
|
nickname: user.nickname || "",
|
||||||
|
password: "",
|
||||||
|
is_admin: user.is_admin || false,
|
||||||
|
has_full_book: user.has_full_book || false,
|
||||||
|
})
|
||||||
|
setShowUserModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开新建用户弹窗
|
||||||
|
const handleAddUser = () => {
|
||||||
|
setEditingUser(null)
|
||||||
|
setFormData({
|
||||||
|
phone: "",
|
||||||
|
nickname: "",
|
||||||
|
password: "",
|
||||||
|
is_admin: false,
|
||||||
|
has_full_book: false,
|
||||||
|
})
|
||||||
|
setShowUserModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存用户
|
||||||
|
const handleSaveUser = async () => {
|
||||||
|
if (!formData.phone || !formData.nickname) {
|
||||||
|
alert("请填写手机号和昵称")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
if (editingUser) {
|
||||||
|
// 更新用户
|
||||||
|
const res = await fetch('/api/db/users', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: editingUser.id,
|
||||||
|
nickname: formData.nickname,
|
||||||
|
is_admin: formData.is_admin,
|
||||||
|
has_full_book: formData.has_full_book,
|
||||||
|
...(formData.password && { password: formData.password }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!data.success) {
|
||||||
|
alert("更新失败: " + (data.error || "未知错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 创建用户
|
||||||
|
const res = await fetch('/api/db/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
phone: formData.phone,
|
||||||
|
nickname: formData.nickname,
|
||||||
|
password: formData.password,
|
||||||
|
is_admin: formData.is_admin,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!data.success) {
|
||||||
|
alert("创建失败: " + (data.error || "未知错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowUserModal(false)
|
||||||
|
loadUsers()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save user error:', error)
|
||||||
|
alert("保存失败")
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开修改密码弹窗
|
||||||
|
const handleChangePassword = (user: User) => {
|
||||||
|
setEditingUser(user)
|
||||||
|
setNewPassword("")
|
||||||
|
setConfirmPassword("")
|
||||||
|
setShowPasswordModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看绑定关系
|
||||||
|
const handleViewReferrals = async (user: User) => {
|
||||||
|
setSelectedUserForReferrals(user)
|
||||||
|
setShowReferralsModal(true)
|
||||||
|
setReferralsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/db/users/referrals?userId=${user.id}`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
setReferralsData(data)
|
||||||
|
} else {
|
||||||
|
setReferralsData({ referrals: [], stats: {} })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load referrals error:', err)
|
||||||
|
setReferralsData({ referrals: [], stats: {} })
|
||||||
|
} finally {
|
||||||
|
setReferralsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看用户详情
|
||||||
|
const handleViewDetail = (user: User) => {
|
||||||
|
setSelectedUserIdForDetail(user.id)
|
||||||
|
setShowDetailModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存密码
|
||||||
|
const handleSavePassword = async () => {
|
||||||
|
if (!newPassword) {
|
||||||
|
alert("请输入新密码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
alert("两次输入的密码不一致")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
alert("密码长度不能少于6位")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/db/users', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: editingUser?.id,
|
||||||
|
password: newPassword,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
alert("密码修改成功")
|
||||||
|
setShowPasswordModal(false)
|
||||||
|
} else {
|
||||||
|
alert("密码修改失败: " + (data.error || "未知错误"))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Change password error:', error)
|
||||||
|
alert("密码修改失败")
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-7xl mx-auto">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">用户管理</h2>
|
||||||
|
<p className="text-gray-400 mt-1">共 {users.length} 位注册用户</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={loadUsers}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索用户..."
|
||||||
|
className="pl-10 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500 w-64"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAddUser} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
添加用户
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 用户编辑弹窗 */}
|
||||||
|
<Dialog open={showUserModal} onOpenChange={setShowUserModal}>
|
||||||
|
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white flex items-center gap-2">
|
||||||
|
{editingUser ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <UserPlus className="w-5 h-5 text-[#38bdac]" />}
|
||||||
|
{editingUser ? "编辑用户" : "添加用户"}
|
||||||
|
</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="请输入手机号"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
disabled={!!editingUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">昵称</Label>
|
||||||
|
<Input
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
placeholder="请输入昵称"
|
||||||
|
value={formData.nickname}
|
||||||
|
onChange={(e) => setFormData({ ...formData, nickname: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">{editingUser ? "新密码 (留空则不修改)" : "密码"}</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
placeholder={editingUser ? "留空则不修改" : "请输入密码"}
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-gray-300">管理员权限</Label>
|
||||||
|
<Switch
|
||||||
|
checked={formData.is_admin}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, is_admin: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-gray-300">已购全书</Label>
|
||||||
|
<Switch
|
||||||
|
checked={formData.has_full_book}
|
||||||
|
onCheckedChange={(checked) => setFormData({ ...formData, has_full_book: checked })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowUserModal(false)}
|
||||||
|
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-2" />
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveUser}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
{isSaving ? "保存中..." : "保存"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 修改密码弹窗 */}
|
||||||
|
<Dialog open={showPasswordModal} onOpenChange={setShowPasswordModal}>
|
||||||
|
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white flex items-center gap-2">
|
||||||
|
<Key className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
修改密码
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="bg-[#0a1628] rounded-lg p-3">
|
||||||
|
<p className="text-gray-400 text-sm">用户:{editingUser?.nickname}</p>
|
||||||
|
<p className="text-gray-400 text-sm">手机号:{editingUser?.phone}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">新密码</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
placeholder="请输入新密码 (至少6位)"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">确认密码</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
className="bg-[#0a1628] border-gray-700 text-white"
|
||||||
|
placeholder="请再次输入新密码"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowPasswordModal(false)}
|
||||||
|
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSavePassword}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||||
|
>
|
||||||
|
{isSaving ? "保存中..." : "确认修改"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 用户详情弹窗 */}
|
||||||
|
<UserDetailModal
|
||||||
|
open={showDetailModal}
|
||||||
|
onClose={() => setShowDetailModal(false)}
|
||||||
|
userId={selectedUserIdForDetail}
|
||||||
|
onUserUpdated={loadUsers}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 绑定关系弹窗 */}
|
||||||
|
<Dialog open={showReferralsModal} onOpenChange={setShowReferralsModal}>
|
||||||
|
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[80vh] overflow-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-[#38bdac]" />
|
||||||
|
绑定关系详情 - {selectedUserForReferrals?.nickname}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* 统计信息 */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-[#38bdac]">{referralsData.stats?.total || 0}</div>
|
||||||
|
<div className="text-xs text-gray-400">绑定总数</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-400">{referralsData.stats?.purchased || 0}</div>
|
||||||
|
<div className="text-xs text-gray-400">已付费</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-yellow-400">¥{(referralsData.stats?.earnings || 0).toFixed(2)}</div>
|
||||||
|
<div className="text-xs text-gray-400">累计收益</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-orange-400">¥{(referralsData.stats?.pendingEarnings || 0).toFixed(2)}</div>
|
||||||
|
<div className="text-xs text-gray-400">待提现</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 绑定用户列表 */}
|
||||||
|
{referralsLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" />
|
||||||
|
<span className="ml-2 text-gray-400">加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : referralsData.referrals?.length > 0 ? (
|
||||||
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||||
|
{referralsData.referrals.map((ref: any) => (
|
||||||
|
<div key={ref.id} className="flex items-center justify-between bg-[#0a1628] rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm text-[#38bdac]">
|
||||||
|
{ref.nickname?.charAt(0) || "?"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-white text-sm">{ref.nickname}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{ref.phone || (ref.hasOpenId ? '微信用户' : '未绑定')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{ref.status === 'vip' && (
|
||||||
|
<Badge className="bg-green-500/20 text-green-400 border-0 text-xs">全书已购</Badge>
|
||||||
|
)}
|
||||||
|
{ref.status === 'paid' && (
|
||||||
|
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-xs">已付费{ref.purchasedSections}章</Badge>
|
||||||
|
)}
|
||||||
|
{ref.status === 'free' && (
|
||||||
|
<Badge className="bg-gray-500/20 text-gray-400 border-0 text-xs">未付费</Badge>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{ref.createdAt ? new Date(ref.createdAt).toLocaleDateString() : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
暂无绑定用户
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowReferralsModal(false)}
|
||||||
|
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||||||
|
<span className="ml-2 text-gray-400">加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||||
|
<TableHead className="text-gray-400">用户信息</TableHead>
|
||||||
|
<TableHead className="text-gray-400">绑定信息</TableHead>
|
||||||
|
<TableHead className="text-gray-400">购买状态</TableHead>
|
||||||
|
<TableHead className="text-gray-400">分销收益</TableHead>
|
||||||
|
<TableHead className="text-gray-400">推广码</TableHead>
|
||||||
|
<TableHead className="text-gray-400">注册时间</TableHead>
|
||||||
|
<TableHead className="text-right text-gray-400">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredUsers.map((user) => (
|
||||||
|
<TableRow key={user.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
|
||||||
|
{user.avatar ? (
|
||||||
|
<img src={user.avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
||||||
|
) : (
|
||||||
|
user.nickname?.charAt(0) || "?"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium text-white">{user.nickname}</p>
|
||||||
|
{user.is_admin && (
|
||||||
|
<Badge className="bg-purple-500/20 text-purple-400 hover:bg-purple-500/20 border-0 text-xs">
|
||||||
|
管理员
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{user.open_id && !user.id?.startsWith('user_') && (
|
||||||
|
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0 text-xs">
|
||||||
|
微信
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 font-mono">
|
||||||
|
{user.open_id ? user.open_id.slice(0, 12) + '...' : user.id?.slice(0, 12)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{user.phone && (
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
<span className="text-gray-500">📱</span>
|
||||||
|
<span className="text-gray-300">{user.phone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{user.wechat_id && (
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
<span className="text-gray-500">💬</span>
|
||||||
|
<span className="text-gray-300">{user.wechat_id}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{user.open_id && (
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
<span className="text-gray-500">🔗</span>
|
||||||
|
<span className="text-gray-500 truncate max-w-[100px]" title={user.open_id}>
|
||||||
|
{user.open_id.slice(0, 12)}...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!user.phone && !user.wechat_id && !user.open_id && (
|
||||||
|
<span className="text-gray-600 text-xs">未绑定</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.has_full_book ? (
|
||||||
|
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">全书已购</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-gray-500 border-gray-600">
|
||||||
|
未购买
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-white font-medium">¥{parseFloat(String(user.earnings || 0)).toFixed(2)}</div>
|
||||||
|
{parseFloat(String(user.pending_earnings || 0)) > 0 && (
|
||||||
|
<div className="text-xs text-yellow-400">待提现: ¥{parseFloat(String(user.pending_earnings || 0)).toFixed(2)}</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="text-xs text-[#38bdac] cursor-pointer hover:underline flex items-center gap-1"
|
||||||
|
onClick={() => handleViewReferrals(user)}
|
||||||
|
>
|
||||||
|
<Users className="w-3 h-3" />
|
||||||
|
绑定{user.referral_count || 0}人
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<code className="text-[#38bdac] text-xs bg-[#38bdac]/10 px-2 py-0.5 rounded">
|
||||||
|
{user.referral_code || '-'}
|
||||||
|
</code>
|
||||||
|
{user.referred_by && (
|
||||||
|
<div className="text-xs text-gray-500">来自: {user.referred_by.slice(0, 8)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-400">
|
||||||
|
{user.created_at ? new Date(user.created_at).toLocaleDateString() : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleViewDetail(user)}
|
||||||
|
className="text-gray-400 hover:text-blue-400 hover:bg-blue-400/10"
|
||||||
|
title="查看详情"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEditUser(user)}
|
||||||
|
className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleChangePassword(user)}
|
||||||
|
className="text-gray-400 hover:text-yellow-400 hover:bg-yellow-400/10"
|
||||||
|
title="修改密码"
|
||||||
|
>
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||||
|
onClick={() => handleDelete(user.id)}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{filteredUsers.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
|
||||||
|
暂无用户数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<UsersContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
app/admin/withdrawals/loading.tsx
Normal file
3
app/admin/withdrawals/loading.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Loading() {
|
||||||
|
return null
|
||||||
|
}
|
||||||
305
app/admin/withdrawals/page.tsx
Normal file
305
app/admin/withdrawals/page.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Check, X, Clock, Wallet, History, RefreshCw, AlertCircle, DollarSign } from "lucide-react"
|
||||||
|
|
||||||
|
interface Withdrawal {
|
||||||
|
id: string
|
||||||
|
userId: string
|
||||||
|
userNickname: string
|
||||||
|
userPhone?: string
|
||||||
|
userAvatar?: string
|
||||||
|
referralCode?: string
|
||||||
|
amount: number
|
||||||
|
status: 'pending' | 'processing' | 'success' | 'failed'
|
||||||
|
wechatOpenid?: string
|
||||||
|
transactionId?: string
|
||||||
|
errorMessage?: string
|
||||||
|
createdAt: string
|
||||||
|
processedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
total: number
|
||||||
|
pendingCount: number
|
||||||
|
pendingAmount: number
|
||||||
|
successCount: number
|
||||||
|
successAmount: number
|
||||||
|
failedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WithdrawalsPage() {
|
||||||
|
const [withdrawals, setWithdrawals] = useState<Withdrawal[]>([])
|
||||||
|
const [stats, setStats] = useState<Stats>({ total: 0, pendingCount: 0, pendingAmount: 0, successCount: 0, successAmount: 0, failedCount: 0 })
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [filter, setFilter] = useState<'all' | 'pending' | 'success' | 'failed'>('all')
|
||||||
|
const [processing, setProcessing] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// 加载提现记录
|
||||||
|
const loadWithdrawals = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/withdrawals?status=${filter}`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
setWithdrawals(data.withdrawals || [])
|
||||||
|
setStats(data.stats || {})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Load withdrawals error:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWithdrawals()
|
||||||
|
}, [filter])
|
||||||
|
|
||||||
|
// 批准提现
|
||||||
|
const handleApprove = async (id: string) => {
|
||||||
|
if (!confirm("确认已完成打款?批准后将更新用户提现记录。")) return
|
||||||
|
|
||||||
|
setProcessing(id)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/withdrawals', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, action: 'approve' })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
loadWithdrawals()
|
||||||
|
} else {
|
||||||
|
alert('操作失败: ' + data.error)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('操作失败')
|
||||||
|
} finally {
|
||||||
|
setProcessing(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拒绝提现
|
||||||
|
const handleReject = async (id: string) => {
|
||||||
|
const reason = prompt("请输入拒绝原因(将返还用户余额):")
|
||||||
|
if (!reason) return
|
||||||
|
|
||||||
|
setProcessing(id)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/withdrawals', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id, action: 'reject', reason })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
loadWithdrawals()
|
||||||
|
} else {
|
||||||
|
alert('操作失败: ' + data.error)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('操作失败')
|
||||||
|
} finally {
|
||||||
|
setProcessing(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return <Badge className="bg-orange-500/20 text-orange-400 hover:bg-orange-500/20 border-0">待处理</Badge>
|
||||||
|
case 'processing':
|
||||||
|
return <Badge className="bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0">处理中</Badge>
|
||||||
|
case 'success':
|
||||||
|
return <Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">已完成</Badge>
|
||||||
|
case 'failed':
|
||||||
|
return <Badge className="bg-red-500/20 text-red-400 hover:bg-red-500/20 border-0">已拒绝</Badge>
|
||||||
|
default:
|
||||||
|
return <Badge className="bg-gray-500/20 text-gray-400 border-0">{status}</Badge>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-6xl mx-auto">
|
||||||
|
<div className="flex justify-between items-start mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">分账提现管理</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
管理用户分销收益的提现申请
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={loadWithdrawals}
|
||||||
|
disabled={loading}
|
||||||
|
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分账规则说明 */}
|
||||||
|
<Card className="bg-gradient-to-r from-[#38bdac]/10 to-[#0f2137] border-[#38bdac]/30 mb-6">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<DollarSign className="w-5 h-5 text-[#38bdac] mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium mb-2">自动分账规则</h3>
|
||||||
|
<div className="text-sm text-gray-400 space-y-1">
|
||||||
|
<p>• <span className="text-[#38bdac]">分销比例</span>:推广者获得订单金额的 <span className="text-white font-medium">90%</span></p>
|
||||||
|
<p>• <span className="text-[#38bdac]">结算方式</span>:用户付款后,分销收益自动计入推广者账户</p>
|
||||||
|
<p>• <span className="text-[#38bdac]">提现方式</span>:用户在小程序端点击提现,系统自动转账到微信零钱</p>
|
||||||
|
<p>• <span className="text-[#38bdac]">审批流程</span>:待处理的提现需管理员手动确认打款后批准</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="text-3xl font-bold text-[#38bdac]">{stats.total}</div>
|
||||||
|
<div className="text-sm text-gray-400">总申请</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="text-3xl font-bold text-orange-400">{stats.pendingCount}</div>
|
||||||
|
<div className="text-sm text-gray-400">待处理</div>
|
||||||
|
<div className="text-xs text-orange-400 mt-1">¥{stats.pendingAmount.toFixed(2)}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="text-3xl font-bold text-green-400">{stats.successCount}</div>
|
||||||
|
<div className="text-sm text-gray-400">已完成</div>
|
||||||
|
<div className="text-xs text-green-400 mt-1">¥{stats.successAmount.toFixed(2)}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="text-3xl font-bold text-red-400">{stats.failedCount}</div>
|
||||||
|
<div className="text-sm text-gray-400">已拒绝</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 筛选按钮 */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
{(['all', 'pending', 'success', 'failed'] as const).map((f) => (
|
||||||
|
<Button
|
||||||
|
key={f}
|
||||||
|
variant={filter === f ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
className={filter === f
|
||||||
|
? "bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||||
|
: "border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{f === 'all' ? '全部' : f === 'pending' ? '待处理' : f === 'success' ? '已完成' : '已拒绝'}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提现记录表格 */}
|
||||||
|
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||||||
|
<span className="ml-2 text-gray-400">加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : withdrawals.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Wallet className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500">暂无提现记录</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#0a1628] text-gray-400">
|
||||||
|
<th className="p-4 text-left font-medium">申请时间</th>
|
||||||
|
<th className="p-4 text-left font-medium">用户</th>
|
||||||
|
<th className="p-4 text-left font-medium">金额</th>
|
||||||
|
<th className="p-4 text-left font-medium">状态</th>
|
||||||
|
<th className="p-4 text-left font-medium">处理时间</th>
|
||||||
|
<th className="p-4 text-right font-medium">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
|
{withdrawals.map((w) => (
|
||||||
|
<tr key={w.id} className="hover:bg-[#0a1628] transition-colors">
|
||||||
|
<td className="p-4 text-gray-400">
|
||||||
|
{new Date(w.createdAt).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm text-[#38bdac]">
|
||||||
|
{w.userNickname?.charAt(0) || "?"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">{w.userNickname}</p>
|
||||||
|
<p className="text-xs text-gray-500">{w.userPhone || w.userId.slice(0, 10)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
<span className="font-bold text-orange-400">¥{w.amount.toFixed(2)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{getStatusBadge(w.status)}
|
||||||
|
{w.errorMessage && (
|
||||||
|
<p className="text-xs text-red-400 mt-1">{w.errorMessage}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-gray-400">
|
||||||
|
{w.processedAt ? new Date(w.processedAt).toLocaleString() : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="p-4 text-right">
|
||||||
|
{w.status === 'pending' && (
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleApprove(w.id)}
|
||||||
|
disabled={processing === w.id}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
>
|
||||||
|
<Check className="w-4 h-4 mr-1" />
|
||||||
|
批准
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleReject(w.id)}
|
||||||
|
disabled={processing === w.id}
|
||||||
|
className="border-red-500/50 text-red-400 hover:bg-red-500/10 bg-transparent"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 mr-1" />
|
||||||
|
拒绝
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{w.status === 'success' && w.transactionId && (
|
||||||
|
<span className="text-xs text-gray-500 font-mono">{w.transactionId}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
320
app/api/admin/chapters/route.ts
Normal file
320
app/api/admin/chapters/route.ts
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
/**
|
||||||
|
* 章节管理API - 后台管理功能
|
||||||
|
* 用于管理书籍章节、价格、状态等
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// 获取书籍目录
|
||||||
|
const BOOK_DIR = path.join(process.cwd(), 'book')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - 获取所有章节列表
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const includeContent = searchParams.get('content') === 'true'
|
||||||
|
|
||||||
|
// 定义书籍结构
|
||||||
|
const bookStructure = [
|
||||||
|
{
|
||||||
|
id: 'part-preface',
|
||||||
|
title: '序言',
|
||||||
|
type: 'preface',
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
id: 'preface',
|
||||||
|
title: '序言|为什么我每天早上6点在Soul开播?',
|
||||||
|
price: 0,
|
||||||
|
isFree: true,
|
||||||
|
status: 'published',
|
||||||
|
file: '序言|为什么我每天早上6点在Soul开播?.md'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'part-1',
|
||||||
|
title: '第一篇|真实的人',
|
||||||
|
type: 'part',
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
id: 'chapter-1',
|
||||||
|
title: '第1章|人与人之间的底层逻辑',
|
||||||
|
sections: [
|
||||||
|
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', price: 1, isFree: true, status: 'published' },
|
||||||
|
{ id: '1.2', title: '老墨:资源整合高手的社交方法', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '1.3', title: '笑声背后的MBTI:为什么ENTJ适合做资源,INTP适合做系统', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '1.4', title: '人性的三角结构:利益、情感、价值观', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '1.5', title: '沟通差的问题:为什么你说的别人听不懂', price: 1, isFree: false, status: 'published' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chapter-2',
|
||||||
|
title: '第2章|人性困境案例',
|
||||||
|
sections: [
|
||||||
|
{ id: '2.1', title: '相亲故事:你以为找的是人,实际是在找模式', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '2.2', title: '找工作迷茫者:为什么简历解决不了人生', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '2.3', title: '撸运费险:小钱困住大脑的真实心理', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '2.4', title: '游戏上瘾的年轻人:不是游戏吸引他,是生活没吸引力', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '2.5', title: '健康焦虑(我的糖尿病经历):疾病是人生的第一次清醒', price: 1, isFree: false, status: 'published' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'part-2',
|
||||||
|
title: '第二篇|真实的行业',
|
||||||
|
type: 'part',
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
id: 'chapter-3',
|
||||||
|
title: '第3章|电商篇',
|
||||||
|
sections: [
|
||||||
|
{ id: '3.1', title: '3000万流水如何跑出来(退税模式解析)', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '3.2', title: '供应链之王 vs 打工人:利润不在前端', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '3.3', title: '社区团购的底层逻辑', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '3.4', title: '跨境电商与退税套利', price: 1, isFree: false, status: 'published' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chapter-4',
|
||||||
|
title: '第4章|内容商业篇',
|
||||||
|
sections: [
|
||||||
|
{ id: '4.1', title: '旅游号:30天10万粉的真实逻辑', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '4.2', title: '做号工厂:如何让一个号变成一个机器', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '4.3', title: '情绪内容为什么比专业内容更赚钱', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '4.4', title: '猫与宠物号:为什么宠物赛道永不过时', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '4.5', title: '直播间里的三种人:演员、技术工、系统流', price: 1, isFree: false, status: 'published' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chapter-5',
|
||||||
|
title: '第5章|传统行业篇',
|
||||||
|
sections: [
|
||||||
|
{ id: '5.1', title: '拍卖行抱朴:一天240万的摇号生意', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '5.2', title: '土地拍卖:招拍挂背后的游戏规则', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '5.3', title: '地摊经济数字化:一个月900块的餐车生意', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '5.4', title: '不良资产拍卖:我错过的一个亿佣金', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '5.5', title: '桶装水李总:跟物业合作的轻资产模式', price: 1, isFree: false, status: 'published' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'part-3',
|
||||||
|
title: '第三篇|真实的错误',
|
||||||
|
type: 'part',
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
id: 'chapter-6',
|
||||||
|
title: '第6章|我人生错过的4件大钱',
|
||||||
|
sections: [
|
||||||
|
{ id: '6.1', title: '电商财税窗口:2016年的千万级机会', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '6.2', title: '供应链金融:我不懂的杠杆游戏', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '6.3', title: '内容红利:2019年我为什么没做抖音', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '6.4', title: '数据资产化:我还在观望的未来机会', price: 1, isFree: false, status: 'published' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chapter-7',
|
||||||
|
title: '第7章|别人犯的错误',
|
||||||
|
sections: [
|
||||||
|
{ id: '7.1', title: '投资房年轻人的迷茫:资金 vs 能力', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '7.2', title: '信息差骗局:永远有人靠卖学习赚钱', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '7.3', title: '在Soul找恋爱但想赚钱的人', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '7.4', title: '创业者的三种死法:冲动、轻信、没结构', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '7.5', title: '人情生意的终点:关系越多亏得越多', price: 1, isFree: false, status: 'published' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'part-4',
|
||||||
|
title: '第四篇|真实的赚钱',
|
||||||
|
type: 'part',
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
id: 'chapter-8',
|
||||||
|
title: '第8章|底层结构',
|
||||||
|
sections: [
|
||||||
|
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '8.2', title: '价格杠杆:供应链与信息差', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '8.3', title: '时间杠杆:自动化 + AI', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '8.4', title: '情绪杠杆:咨询、婚恋、生意场', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '8.5', title: '社交杠杆:认识谁比你会什么更重要', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '8.6', title: '云阿米巴:分不属于自己的钱', price: 1, isFree: false, status: 'published' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chapter-9',
|
||||||
|
title: '第9章|我在Soul上亲访的赚钱案例',
|
||||||
|
sections: [
|
||||||
|
{ id: '9.1', title: '游戏账号私域:账号即资产', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '9.2', title: '健康包模式:高复购、高毛利', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '9.3', title: '药物私域:长期关系赛道', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '9.4', title: '残疾机构合作:退税 × AI × 人力成本', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '9.5', title: '私域银行:粉丝即小股东', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '9.6', title: 'Soul派对房:陌生人成交的最快场景', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '9.7', title: '飞书中台:从聊天到成交的流程化体系', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '9.8', title: '餐饮女孩:6万营收、1万利润的死撑生意', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '9.9', title: '电竞生态:从陪玩到签约到酒店的完整链条', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '9.10', title: '淘客大佬:损耗30%的白色通道', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '9.11', title: '蔬菜供应链:农户才是最赚钱的人', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '9.12', title: '美业整合:一个人的公司如何月入十万', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '9.13', title: 'AI工具推广:一个隐藏的高利润赛道', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '9.14', title: '大健康私域:一个月150万的70后', price: 1, isFree: false, status: 'published' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'part-5',
|
||||||
|
title: '第五篇|真实的社会',
|
||||||
|
type: 'part',
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
id: 'chapter-10',
|
||||||
|
title: '第10章|未来职业的变化趋势',
|
||||||
|
sections: [
|
||||||
|
{ id: '10.1', title: 'AI时代:哪些工作会消失,哪些会崛起', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '10.2', title: '一人公司:为什么越来越多人选择单干', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '10.3', title: '为什么链接能力会成为第一价值', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '10.4', title: '新型公司:Soul-飞书-线下的三位一体', price: 1, isFree: false, status: 'published' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chapter-11',
|
||||||
|
title: '第11章|中国社会商业生态的未来',
|
||||||
|
sections: [
|
||||||
|
{ id: '11.1', title: '私域经济:为什么流量越来越贵', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '11.2', title: '银发经济与孤独经济:两个被忽视的万亿市场', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '11.3', title: '流量红利的终局', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '11.4', title: '大模型 + 供应链的组合拳', price: 1, isFree: false, status: 'published' },
|
||||||
|
{ id: '11.5', title: '社会分层的最终逻辑', price: 1, isFree: false, status: 'published' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'part-epilogue',
|
||||||
|
title: '尾声',
|
||||||
|
type: 'epilogue',
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
id: 'epilogue',
|
||||||
|
title: '尾声|这本书的真实目的',
|
||||||
|
price: 0,
|
||||||
|
isFree: true,
|
||||||
|
status: 'published',
|
||||||
|
file: '尾声|这本书的真实目的.md'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'part-appendix',
|
||||||
|
title: '附录',
|
||||||
|
type: 'appendix',
|
||||||
|
chapters: [
|
||||||
|
{ id: 'appendix-1', title: '附录1|Soul派对房精选对话', price: 0, isFree: true, status: 'published' },
|
||||||
|
{ id: 'appendix-2', title: '附录2|创业者自检清单', price: 0, isFree: true, status: 'published' },
|
||||||
|
{ id: 'appendix-3', title: '附录3|本书提到的工具和资源', price: 0, isFree: true, status: 'published' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 计算统计数据
|
||||||
|
let totalSections = 0
|
||||||
|
let freeSections = 0
|
||||||
|
let paidSections = 0
|
||||||
|
|
||||||
|
bookStructure.forEach(part => {
|
||||||
|
if (part.chapters) {
|
||||||
|
part.chapters.forEach(chapter => {
|
||||||
|
if (chapter.sections) {
|
||||||
|
totalSections += chapter.sections.length
|
||||||
|
chapter.sections.forEach(s => {
|
||||||
|
if (s.isFree) freeSections++
|
||||||
|
else paidSections++
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
totalSections++
|
||||||
|
if (chapter.isFree) freeSections++
|
||||||
|
else paidSections++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
structure: bookStructure,
|
||||||
|
stats: {
|
||||||
|
totalSections,
|
||||||
|
freeSections,
|
||||||
|
paidSections,
|
||||||
|
totalParts: bookStructure.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AdminChapters] 获取章节失败:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '获取章节失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - 更新章节设置
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { action, chapterId, data } = body
|
||||||
|
|
||||||
|
console.log('[AdminChapters] 更新章节:', { action, chapterId })
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'updatePrice':
|
||||||
|
// 更新章节价格
|
||||||
|
// TODO: 保存到数据库
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: { message: '价格更新成功', chapterId, price: data.price }
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'toggleFree':
|
||||||
|
// 切换免费状态
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: { message: '免费状态更新成功', chapterId, isFree: data.isFree }
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'updateStatus':
|
||||||
|
// 更新发布状态
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: { message: '发布状态更新成功', chapterId, status: data.status }
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '未知操作'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AdminChapters] 更新章节失败:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '更新章节失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
158
app/api/admin/content/route.ts
Normal file
158
app/api/admin/content/route.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
// app/api/admin/content/route.ts
|
||||||
|
// 内容模块管理API
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import matter from 'gray-matter'
|
||||||
|
|
||||||
|
const BOOK_DIR = path.join(process.cwd(), 'book')
|
||||||
|
|
||||||
|
// GET: 获取所有章节列表
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const chapters = getAllChapters()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
chapters,
|
||||||
|
total: chapters.length
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取章节列表失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: 创建新章节
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const { title, content, category, tags } = body
|
||||||
|
|
||||||
|
if (!title || !content) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '标题和内容不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成文件名
|
||||||
|
const fileName = `${title}.md`
|
||||||
|
const filePath = path.join(BOOK_DIR, category || '第一篇|真实的人', fileName)
|
||||||
|
|
||||||
|
// 创建Markdown内容
|
||||||
|
const markdownContent = matter.stringify(content, {
|
||||||
|
title,
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
tags: tags || [],
|
||||||
|
draft: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 写入文件
|
||||||
|
fs.writeFileSync(filePath, markdownContent, 'utf-8')
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '章节创建成功',
|
||||||
|
filePath
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建章节失败:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '创建章节失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT: 更新章节
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const { id, title, content, category, tags } = body
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '章节ID不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 根据ID找到文件并更新
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '章节更新成功'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '更新章节失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: 删除章节
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const id = searchParams.get('id')
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '章节ID不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 根据ID删除文件
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '章节删除成功'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '删除章节失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 辅助函数:获取所有章节
|
||||||
|
function getAllChapters() {
|
||||||
|
const chapters: any[] = []
|
||||||
|
|
||||||
|
// 遍历book目录下的所有子目录
|
||||||
|
const categories = fs.readdirSync(BOOK_DIR).filter(item => {
|
||||||
|
const itemPath = path.join(BOOK_DIR, item)
|
||||||
|
return fs.statSync(itemPath).isDirectory()
|
||||||
|
})
|
||||||
|
|
||||||
|
categories.forEach(category => {
|
||||||
|
const categoryPath = path.join(BOOK_DIR, category)
|
||||||
|
const files = fs.readdirSync(categoryPath).filter(file => file.endsWith('.md'))
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
const filePath = path.join(categoryPath, file)
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
const { data, content } = matter(fileContent)
|
||||||
|
|
||||||
|
chapters.push({
|
||||||
|
id: `${category}/${file.replace('.md', '')}`,
|
||||||
|
title: data.title || file.replace('.md', ''),
|
||||||
|
category,
|
||||||
|
words: content.length,
|
||||||
|
date: data.date || fs.statSync(filePath).mtime.toISOString(),
|
||||||
|
tags: data.tags || [],
|
||||||
|
draft: data.draft || false,
|
||||||
|
filePath
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return chapters.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||||
|
}
|
||||||
9
app/api/admin/logout/route.ts
Normal file
9
app/api/admin/logout/route.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getAdminCookieName, getAdminCookieOptions } from '@/lib/admin-auth'
|
||||||
|
|
||||||
|
export async function POST(_req: NextRequest) {
|
||||||
|
const res = NextResponse.json({ success: true })
|
||||||
|
const opts = getAdminCookieOptions()
|
||||||
|
res.cookies.set(getAdminCookieName(), '', { ...opts, maxAge: 0 })
|
||||||
|
return res
|
||||||
|
}
|
||||||
182
app/api/admin/payment/route.ts
Normal file
182
app/api/admin/payment/route.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// app/api/admin/payment/route.ts
|
||||||
|
// 付费模块管理API
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
// 模拟订单数据
|
||||||
|
let orders = [
|
||||||
|
{
|
||||||
|
id: 'ORDER_001',
|
||||||
|
userId: 'user_001',
|
||||||
|
userName: '张三',
|
||||||
|
amount: 9.9,
|
||||||
|
status: 'paid',
|
||||||
|
paymentMethod: 'wechat',
|
||||||
|
createdAt: new Date('2025-01-10').toISOString(),
|
||||||
|
paidAt: new Date('2025-01-10').toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ORDER_002',
|
||||||
|
userId: 'user_002',
|
||||||
|
userName: '李四',
|
||||||
|
amount: 10.9,
|
||||||
|
status: 'paid',
|
||||||
|
paymentMethod: 'wechat',
|
||||||
|
createdAt: new Date('2025-01-11').toISOString(),
|
||||||
|
paidAt: new Date('2025-01-11').toISOString()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// GET: 获取订单列表
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const status = searchParams.get('status')
|
||||||
|
const page = parseInt(searchParams.get('page') || '1')
|
||||||
|
const pageSize = parseInt(searchParams.get('pageSize') || '20')
|
||||||
|
|
||||||
|
// 过滤订单
|
||||||
|
let filteredOrders = orders
|
||||||
|
if (status) {
|
||||||
|
filteredOrders = orders.filter(order => order.status === status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const start = (page - 1) * pageSize
|
||||||
|
const end = start + pageSize
|
||||||
|
const paginatedOrders = filteredOrders.slice(start, end)
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
const stats = {
|
||||||
|
total: orders.length,
|
||||||
|
paid: orders.filter(o => o.status === 'paid').length,
|
||||||
|
pending: orders.filter(o => o.status === 'pending').length,
|
||||||
|
refunded: orders.filter(o => o.status === 'refunded').length,
|
||||||
|
totalRevenue: orders
|
||||||
|
.filter(o => o.status === 'paid')
|
||||||
|
.reduce((sum, o) => sum + o.amount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
orders: paginatedOrders,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: filteredOrders.length,
|
||||||
|
totalPages: Math.ceil(filteredOrders.length / pageSize)
|
||||||
|
},
|
||||||
|
stats
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: 创建订单(手动)
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const { userId, userName, amount, note } = body
|
||||||
|
|
||||||
|
if (!userId || !amount) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '用户ID和金额不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOrder = {
|
||||||
|
id: `ORDER_${Date.now()}`,
|
||||||
|
userId,
|
||||||
|
userName: userName || '未知用户',
|
||||||
|
amount,
|
||||||
|
status: 'pending',
|
||||||
|
paymentMethod: 'manual',
|
||||||
|
note,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
orders.push(newOrder)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '订单创建成功',
|
||||||
|
order: newOrder
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '创建订单失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT: 更新订单状态
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const { orderId, status, note } = body
|
||||||
|
|
||||||
|
const orderIndex = orders.findIndex(o => o.id === orderId)
|
||||||
|
if (orderIndex === -1) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '订单不存在' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
orders[orderIndex] = {
|
||||||
|
...orders[orderIndex],
|
||||||
|
status,
|
||||||
|
note: note || orders[orderIndex].note,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'paid') {
|
||||||
|
orders[orderIndex].paidAt = new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '订单状态更新成功',
|
||||||
|
order: orders[orderIndex]
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '更新订单失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: 删除订单
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const orderId = searchParams.get('id')
|
||||||
|
|
||||||
|
if (!orderId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '订单ID不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderIndex = orders.findIndex(o => o.id === orderId)
|
||||||
|
if (orderIndex === -1) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '订单不存在' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
orders.splice(orderIndex, 1)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '订单删除成功'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '删除订单失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
249
app/api/admin/referral/route.ts
Normal file
249
app/api/admin/referral/route.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
// app/api/admin/referral/route.ts
|
||||||
|
// 分销模块管理API
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
// 模拟分销数据
|
||||||
|
let referralRecords = [
|
||||||
|
{
|
||||||
|
id: 'REF_001',
|
||||||
|
referrerId: 'user_001',
|
||||||
|
referrerName: '张三',
|
||||||
|
inviteCode: 'ABC123',
|
||||||
|
totalReferrals: 5,
|
||||||
|
totalOrders: 3,
|
||||||
|
totalCommission: 267.00,
|
||||||
|
paidCommission: 200.00,
|
||||||
|
pendingCommission: 67.00,
|
||||||
|
commissionRate: 0.9,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date('2025-01-01').toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'REF_002',
|
||||||
|
referrerId: 'user_002',
|
||||||
|
referrerName: '李四',
|
||||||
|
inviteCode: 'DEF456',
|
||||||
|
totalReferrals: 8,
|
||||||
|
totalOrders: 6,
|
||||||
|
totalCommission: 534.00,
|
||||||
|
paidCommission: 400.00,
|
||||||
|
pendingCommission: 134.00,
|
||||||
|
commissionRate: 0.9,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date('2025-01-03').toISOString()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
let commissionRecords = [
|
||||||
|
{
|
||||||
|
id: 'COMM_001',
|
||||||
|
referrerId: 'user_001',
|
||||||
|
referrerName: '张三',
|
||||||
|
orderId: 'ORDER_001',
|
||||||
|
orderAmount: 9.9,
|
||||||
|
commissionAmount: 8.91,
|
||||||
|
commissionRate: 0.9,
|
||||||
|
status: 'paid',
|
||||||
|
createdAt: new Date('2025-01-10').toISOString(),
|
||||||
|
paidAt: new Date('2025-01-12').toISOString()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// GET: 获取分销概览或列表
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const type = searchParams.get('type') || 'list'
|
||||||
|
const page = parseInt(searchParams.get('page') || '1')
|
||||||
|
const pageSize = parseInt(searchParams.get('pageSize') || '20')
|
||||||
|
|
||||||
|
if (type === 'overview') {
|
||||||
|
// 返回概览数据
|
||||||
|
const overview = {
|
||||||
|
totalReferrers: referralRecords.length,
|
||||||
|
activeReferrers: referralRecords.filter(r => r.status === 'active').length,
|
||||||
|
totalReferrals: referralRecords.reduce((sum, r) => sum + r.totalReferrals, 0),
|
||||||
|
totalOrders: referralRecords.reduce((sum, r) => sum + r.totalOrders, 0),
|
||||||
|
totalCommission: referralRecords.reduce((sum, r) => sum + r.totalCommission, 0),
|
||||||
|
paidCommission: referralRecords.reduce((sum, r) => sum + r.paidCommission, 0),
|
||||||
|
pendingCommission: referralRecords.reduce((sum, r) => sum + r.pendingCommission, 0),
|
||||||
|
averageCommission: referralRecords.reduce((sum, r) => sum + r.totalCommission, 0) / referralRecords.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
overview
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回列表数据
|
||||||
|
const start = (page - 1) * pageSize
|
||||||
|
const end = start + pageSize
|
||||||
|
const paginatedRecords = referralRecords.slice(start, end)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
records: paginatedRecords,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: referralRecords.length,
|
||||||
|
totalPages: Math.ceil(referralRecords.length / pageSize)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: 创建分销记录或处理佣金
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const { action, data } = body
|
||||||
|
|
||||||
|
if (action === 'create_referrer') {
|
||||||
|
// 创建推广者
|
||||||
|
const { userId, userName, commissionRate } = data
|
||||||
|
|
||||||
|
const newReferrer = {
|
||||||
|
id: `REF_${Date.now()}`,
|
||||||
|
referrerId: userId,
|
||||||
|
referrerName: userName,
|
||||||
|
inviteCode: generateInviteCode(),
|
||||||
|
totalReferrals: 0,
|
||||||
|
totalOrders: 0,
|
||||||
|
totalCommission: 0,
|
||||||
|
paidCommission: 0,
|
||||||
|
pendingCommission: 0,
|
||||||
|
commissionRate: commissionRate || 0.9,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
referralRecords.push(newReferrer)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '推广者创建成功',
|
||||||
|
referrer: newReferrer
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'pay_commission') {
|
||||||
|
// 支付佣金
|
||||||
|
const { referrerId, amount, note } = data
|
||||||
|
|
||||||
|
const referrer = referralRecords.find(r => r.referrerId === referrerId)
|
||||||
|
if (!referrer) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '推广者不存在' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount > referrer.pendingCommission) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '支付金额超过待支付佣金' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
referrer.paidCommission += amount
|
||||||
|
referrer.pendingCommission -= amount
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '佣金支付成功',
|
||||||
|
referrer
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '未知操作' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '操作失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT: 更新分销记录
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const { referrerId, status, commissionRate, note } = body
|
||||||
|
|
||||||
|
const referrerIndex = referralRecords.findIndex(r => r.referrerId === referrerId)
|
||||||
|
if (referrerIndex === -1) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '推广者不存在' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
referralRecords[referrerIndex] = {
|
||||||
|
...referralRecords[referrerIndex],
|
||||||
|
status: status || referralRecords[referrerIndex].status,
|
||||||
|
commissionRate: commissionRate !== undefined ? commissionRate : referralRecords[referrerIndex].commissionRate,
|
||||||
|
note: note || referralRecords[referrerIndex].note,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '推广者信息更新成功',
|
||||||
|
referrer: referralRecords[referrerIndex]
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '更新失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: 删除分销记录
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const referrerId = searchParams.get('id')
|
||||||
|
|
||||||
|
if (!referrerId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '推广者ID不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const referrerIndex = referralRecords.findIndex(r => r.referrerId === referrerId)
|
||||||
|
if (referrerIndex === -1) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '推广者不存在' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
referralRecords.splice(referrerIndex, 1)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '推广者删除成功'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '删除失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成邀请码
|
||||||
|
function generateInviteCode(): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
|
let code = ''
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
code += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
84
app/api/admin/route.ts
Normal file
84
app/api/admin/route.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// app/api/admin/route.ts
|
||||||
|
// 后台管理API入口
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
// 验证管理员权限
|
||||||
|
function verifyAdmin(req: NextRequest) {
|
||||||
|
const token = req.headers.get('Authorization')?.replace('Bearer ', '')
|
||||||
|
|
||||||
|
// TODO: 实现真实的token验证
|
||||||
|
if (!token || token !== 'admin-token-secret') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: 获取后台概览数据
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
if (!verifyAdmin(req)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '未授权访问' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有模块的概览数据
|
||||||
|
const overview = {
|
||||||
|
content: {
|
||||||
|
totalChapters: 65,
|
||||||
|
totalWords: 120000,
|
||||||
|
publishedChapters: 60,
|
||||||
|
draftChapters: 5,
|
||||||
|
lastUpdate: new Date().toISOString()
|
||||||
|
},
|
||||||
|
payment: {
|
||||||
|
totalRevenue: 12800.50,
|
||||||
|
todayRevenue: 560.00,
|
||||||
|
totalOrders: 128,
|
||||||
|
todayOrders: 12,
|
||||||
|
averagePrice: 100.00
|
||||||
|
},
|
||||||
|
referral: {
|
||||||
|
totalReferrers: 45,
|
||||||
|
activeReferrers: 28,
|
||||||
|
totalCommission: 11520.45,
|
||||||
|
paidCommission: 8500.00,
|
||||||
|
pendingCommission: 3020.45
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
totalUsers: 1200,
|
||||||
|
purchasedUsers: 128,
|
||||||
|
activeUsers: 456,
|
||||||
|
todayNewUsers: 23
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(overview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: 管理员登录
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.json()
|
||||||
|
const { username, password } = body
|
||||||
|
|
||||||
|
// TODO: 实现真实的登录验证
|
||||||
|
if (username === 'admin' && password === 'admin123') {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
token: 'admin-token-secret',
|
||||||
|
user: {
|
||||||
|
id: 'admin',
|
||||||
|
username: 'admin',
|
||||||
|
role: 'admin',
|
||||||
|
name: '卡若'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '用户名或密码错误' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
194
app/api/admin/withdrawals/route.ts
Normal file
194
app/api/admin/withdrawals/route.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* 后台提现管理API
|
||||||
|
* 获取所有提现记录,处理提现审批
|
||||||
|
* 批准时如已配置微信转账则调用「商家转账到零钱」,否则仅更新为成功(需线下打款)
|
||||||
|
*/
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
import { createTransfer } from '@/lib/wechat-transfer'
|
||||||
|
|
||||||
|
// 获取所有提现记录
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const status = searchParams.get('status') // pending, success, failed, all
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT
|
||||||
|
w.*,
|
||||||
|
u.nickname as user_nickname,
|
||||||
|
u.phone as user_phone,
|
||||||
|
u.avatar as user_avatar,
|
||||||
|
u.referral_code
|
||||||
|
FROM withdrawals w
|
||||||
|
LEFT JOIN users u ON w.user_id = u.id
|
||||||
|
`
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
sql += ` WHERE w.status = '${status}'`
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY w.created_at DESC LIMIT 100`
|
||||||
|
|
||||||
|
const withdrawals = await query(sql) as any[]
|
||||||
|
|
||||||
|
// 统计信息
|
||||||
|
const statsResult = await query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count,
|
||||||
|
SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) as pending_amount,
|
||||||
|
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count,
|
||||||
|
SUM(CASE WHEN status = 'success' THEN amount ELSE 0 END) as success_amount,
|
||||||
|
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_count
|
||||||
|
FROM withdrawals
|
||||||
|
`) as any[]
|
||||||
|
|
||||||
|
const stats = statsResult[0] || {}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
withdrawals: withdrawals.map(w => ({
|
||||||
|
id: w.id,
|
||||||
|
userId: w.user_id,
|
||||||
|
userNickname: w.user_nickname || '未知用户',
|
||||||
|
userPhone: w.user_phone,
|
||||||
|
userAvatar: w.user_avatar,
|
||||||
|
referralCode: w.referral_code,
|
||||||
|
amount: parseFloat(w.amount),
|
||||||
|
status: w.status,
|
||||||
|
wechatOpenid: w.wechat_openid,
|
||||||
|
transactionId: w.transaction_id,
|
||||||
|
errorMessage: w.error_message,
|
||||||
|
createdAt: w.created_at,
|
||||||
|
processedAt: w.processed_at
|
||||||
|
})),
|
||||||
|
stats: {
|
||||||
|
total: parseInt(stats.total) || 0,
|
||||||
|
pendingCount: parseInt(stats.pending_count) || 0,
|
||||||
|
pendingAmount: parseFloat(stats.pending_amount) || 0,
|
||||||
|
successCount: parseInt(stats.success_count) || 0,
|
||||||
|
successAmount: parseFloat(stats.success_amount) || 0,
|
||||||
|
failedCount: parseInt(stats.failed_count) || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get withdrawals error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '获取提现记录失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理提现(审批/拒绝)
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { id, action, reason } = body // action: approve, reject
|
||||||
|
|
||||||
|
if (!id || !action) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '缺少必要参数'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取提现记录
|
||||||
|
const withdrawals = await query('SELECT * FROM withdrawals WHERE id = ?', [id]) as any[]
|
||||||
|
if (withdrawals.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '提现记录不存在'
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const withdrawal = withdrawals[0]
|
||||||
|
|
||||||
|
if (withdrawal.status !== 'pending') {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '该提现记录已处理'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'approve') {
|
||||||
|
const openid = withdrawal.wechat_openid || ''
|
||||||
|
const amountFen = Math.round(parseFloat(withdrawal.amount) * 100)
|
||||||
|
if (openid && amountFen > 0) {
|
||||||
|
const result = await createTransfer({
|
||||||
|
openid,
|
||||||
|
amountFen,
|
||||||
|
outDetailNo: id,
|
||||||
|
transferRemark: 'Soul创业派对-提现',
|
||||||
|
})
|
||||||
|
if (result.success) {
|
||||||
|
await query(`
|
||||||
|
UPDATE withdrawals
|
||||||
|
SET status = 'processing', transaction_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, [result.batchId || result.outBatchNo || '', id])
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '已发起微信转账,等待到账后自动更新状态',
|
||||||
|
batchId: result.batchId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: result.errorMessage || '微信转账发起失败',
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
// 无 openid 或金额为 0:仅标记为成功(线下打款)
|
||||||
|
await query(`
|
||||||
|
UPDATE withdrawals
|
||||||
|
SET status = 'success', processed_at = NOW(), transaction_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, [`manual_${Date.now()}`, id])
|
||||||
|
await query(`
|
||||||
|
UPDATE users
|
||||||
|
SET withdrawn_earnings = withdrawn_earnings + ?,
|
||||||
|
pending_earnings = pending_earnings - ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, [withdrawal.amount, withdrawal.amount, withdrawal.user_id])
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '提现已批准(线下打款)',
|
||||||
|
})
|
||||||
|
|
||||||
|
} else if (action === 'reject') {
|
||||||
|
// 拒绝提现 - 返还用户余额
|
||||||
|
await query(`
|
||||||
|
UPDATE withdrawals
|
||||||
|
SET status = 'failed', processed_at = NOW(), error_message = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, [reason || '管理员拒绝', id])
|
||||||
|
|
||||||
|
// 返还用户余额
|
||||||
|
await query(`
|
||||||
|
UPDATE users
|
||||||
|
SET earnings = earnings + ?,
|
||||||
|
pending_earnings = pending_earnings - ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, [withdrawal.amount, withdrawal.amount, withdrawal.user_id])
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '提现已拒绝,余额已返还'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '无效的操作'
|
||||||
|
}, { status: 400 })
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Process withdrawal error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '处理提现失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/api/auth/login/route.ts
Normal file
72
app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Web 端登录:手机号 + 密码
|
||||||
|
* POST { phone, password } -> 校验后返回用户信息(不含密码)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
import { verifyPassword } from '@/lib/password'
|
||||||
|
|
||||||
|
function mapRowToUser(r: any) {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
phone: r.phone || '',
|
||||||
|
nickname: r.nickname || '',
|
||||||
|
isAdmin: !!r.is_admin,
|
||||||
|
purchasedSections: Array.isArray(r.purchased_sections)
|
||||||
|
? r.purchased_sections
|
||||||
|
: (r.purchased_sections ? JSON.parse(String(r.purchased_sections)) : []) || [],
|
||||||
|
hasFullBook: !!r.has_full_book,
|
||||||
|
referralCode: r.referral_code || '',
|
||||||
|
earnings: parseFloat(String(r.earnings || 0)),
|
||||||
|
pendingEarnings: parseFloat(String(r.pending_earnings || 0)),
|
||||||
|
withdrawnEarnings: parseFloat(String(r.withdrawn_earnings || 0)),
|
||||||
|
referralCount: Number(r.referral_count) || 0,
|
||||||
|
createdAt: r.created_at || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { phone, password } = body
|
||||||
|
|
||||||
|
if (!phone || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '请输入手机号和密码' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT id, phone, nickname, password, is_admin, has_full_book, referral_code, earnings, pending_earnings, withdrawn_earnings, referral_count, purchased_sections, created_at FROM users WHERE phone = ?',
|
||||||
|
[String(phone).trim()]
|
||||||
|
) as any[]
|
||||||
|
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '用户不存在或密码错误' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = rows[0]
|
||||||
|
const storedPassword = row.password == null ? '' : String(row.password)
|
||||||
|
|
||||||
|
if (!verifyPassword(String(password), storedPassword)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '密码错误' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = mapRowToUser(row)
|
||||||
|
return NextResponse.json({ success: true, user })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Auth Login] error:', e)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '登录失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/api/auth/reset-password/route.ts
Normal file
54
app/api/auth/reset-password/route.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 忘记密码 / 重置密码(Web 端)
|
||||||
|
* POST { phone, newPassword } -> 按手机号更新密码(无验证码版本,适合内测/内部使用)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
import { hashPassword } from '@/lib/password'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { phone, newPassword } = body
|
||||||
|
|
||||||
|
if (!phone || !newPassword) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '请输入手机号和新密码' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedPhone = String(phone).trim()
|
||||||
|
const trimmedPassword = String(newPassword).trim()
|
||||||
|
|
||||||
|
if (trimmedPassword.length < 6) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '密码至少 6 位' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await query('SELECT id FROM users WHERE phone = ?', [trimmedPhone]) as any[]
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '该手机号未注册' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashed = hashPassword(trimmedPassword)
|
||||||
|
await query('UPDATE users SET password = ?, updated_at = NOW() WHERE phone = ?', [
|
||||||
|
hashed,
|
||||||
|
trimmedPhone,
|
||||||
|
])
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: '密码已重置,请使用新密码登录' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Auth ResetPassword] error:', e)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '重置失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
292
app/api/book/all-chapters/route.ts
Normal file
292
app/api/book/all-chapters/route.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
|
||||||
|
/** 精选推荐:按 user_tracks 的 view_chapter 点击量排序,排除序言/尾声/附录 */
|
||||||
|
async function getFeaturedSections(): Promise<Array<{ id: string; title: string; tag: string; tagClass: string; part: string }>> {
|
||||||
|
const tags = [
|
||||||
|
{ tag: '热门', tagClass: 'tag-pink' },
|
||||||
|
{ tag: '推荐', tagClass: 'tag-purple' },
|
||||||
|
{ tag: '精选', tagClass: 'tag-free' }
|
||||||
|
]
|
||||||
|
try {
|
||||||
|
// 优先按 view_chapter 点击量排序
|
||||||
|
const rows = (await query(`
|
||||||
|
SELECT c.id, c.section_title, c.part_title, c.is_free,
|
||||||
|
COALESCE(t.cnt, 0) as view_count
|
||||||
|
FROM chapters c
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT chapter_id, COUNT(*) as cnt
|
||||||
|
FROM user_tracks
|
||||||
|
WHERE action = 'view_chapter' AND chapter_id IS NOT NULL
|
||||||
|
GROUP BY chapter_id
|
||||||
|
) t ON c.id = t.chapter_id
|
||||||
|
WHERE c.id NOT IN ('preface','epilogue')
|
||||||
|
AND c.id NOT LIKE 'appendix-%' AND c.id NOT LIKE 'appendix_%'
|
||||||
|
AND (c.part_title NOT LIKE '%序言%' AND c.part_title NOT LIKE '%尾声%')
|
||||||
|
ORDER BY view_count DESC, c.updated_at DESC
|
||||||
|
LIMIT 3
|
||||||
|
`)) as any[]
|
||||||
|
if (rows && rows.length > 0) {
|
||||||
|
return rows.map((r, i) => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.section_title || r.title || '',
|
||||||
|
part: (r.part_title || '真实的行业').replace(/^第[一二三四五六七八九十]+篇|?/, '').trim() || '真实的行业',
|
||||||
|
tag: tags[i]?.tag || '推荐',
|
||||||
|
tagClass: tags[i]?.tagClass || 'tag-purple'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[All Chapters API] 精选推荐查询失败:', (e as Error).message)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fallback = (await query(`
|
||||||
|
SELECT id, section_title, part_title, is_free
|
||||||
|
FROM chapters
|
||||||
|
WHERE id NOT IN ('preface','epilogue')
|
||||||
|
AND id NOT LIKE 'appendix-%' AND id NOT LIKE 'appendix_%'
|
||||||
|
AND (part_title NOT LIKE '%序言%' AND part_title NOT LIKE '%尾声%')
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 3
|
||||||
|
`)) as any[]
|
||||||
|
if (fallback?.length > 0) {
|
||||||
|
return fallback.map((r, i) => ({
|
||||||
|
id: r.id,
|
||||||
|
title: r.section_title || r.title || '',
|
||||||
|
part: (r.part_title || '真实的行业').replace(/^第[一二三四五六七八九十]+篇|?/, '').trim() || '真实的行业',
|
||||||
|
tag: tags[i]?.tag || '推荐',
|
||||||
|
tagClass: tags[i]?.tagClass || 'tag-purple'
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return [
|
||||||
|
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', tagClass: 'tag-free', part: '真实的人' },
|
||||||
|
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', tagClass: 'tag-pink', part: '真实的行业' },
|
||||||
|
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const featuredSections = await getFeaturedSections()
|
||||||
|
try {
|
||||||
|
// 方案1: 优先从数据库读取章节数据
|
||||||
|
try {
|
||||||
|
const dbChapters = await query(`
|
||||||
|
SELECT
|
||||||
|
id, section_id, title, section_title, content,
|
||||||
|
is_free, price, words, section_order, chapter_order,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM sections
|
||||||
|
ORDER BY section_order ASC, chapter_order ASC
|
||||||
|
`) as any[]
|
||||||
|
|
||||||
|
if (dbChapters && dbChapters.length > 0) {
|
||||||
|
console.log('[All Chapters API] 从数据库读取成功,共', dbChapters.length, '条')
|
||||||
|
|
||||||
|
// 格式化并按 id 去重(保留首次出现)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const allChapters = dbChapters
|
||||||
|
.map((chapter: any) => ({
|
||||||
|
id: chapter.id,
|
||||||
|
sectionId: chapter.section_id ?? chapter.id,
|
||||||
|
title: chapter.title ?? chapter.section_title,
|
||||||
|
sectionTitle: chapter.section_title ?? chapter.title,
|
||||||
|
content: chapter.content,
|
||||||
|
isFree: !!chapter.is_free,
|
||||||
|
price: chapter.price || 0,
|
||||||
|
words: chapter.words || Math.floor(Math.random() * 3000) + 2000,
|
||||||
|
sectionOrder: chapter.section_order,
|
||||||
|
chapterOrder: chapter.chapter_order,
|
||||||
|
createdAt: chapter.created_at,
|
||||||
|
updatedAt: chapter.updated_at
|
||||||
|
}))
|
||||||
|
.filter((row: { id: string }) => {
|
||||||
|
if (seen.has(row.id)) return false
|
||||||
|
seen.add(row.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: allChapters,
|
||||||
|
chapters: allChapters,
|
||||||
|
total: allChapters.length,
|
||||||
|
source: 'database',
|
||||||
|
featuredSections
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (dbError) {
|
||||||
|
console.log('[All Chapters API] sections 表读取失败,尝试 chapters 表:', (dbError as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方案1b: 从 chapters 表读取(与 lib/db 表结构一致)
|
||||||
|
try {
|
||||||
|
const dbChapters = await query(`
|
||||||
|
SELECT id, part_id, part_title, chapter_id, chapter_title, section_title, content,
|
||||||
|
is_free, price, word_count, sort_order, created_at, updated_at
|
||||||
|
FROM chapters
|
||||||
|
ORDER BY sort_order ASC, id ASC
|
||||||
|
`) as any[]
|
||||||
|
|
||||||
|
if (dbChapters && dbChapters.length > 0) {
|
||||||
|
console.log('[All Chapters API] 从 chapters 表读取成功,共', dbChapters.length, '条')
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const allChapters = dbChapters
|
||||||
|
.map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
sectionId: row.id,
|
||||||
|
title: row.section_title,
|
||||||
|
sectionTitle: row.section_title,
|
||||||
|
content: row.content,
|
||||||
|
isFree: !!row.is_free,
|
||||||
|
price: row.price || 0,
|
||||||
|
words: row.word_count || 0,
|
||||||
|
sectionOrder: row.sort_order ?? 0,
|
||||||
|
chapterOrder: 0,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at
|
||||||
|
}))
|
||||||
|
.filter((row: { id: string }) => {
|
||||||
|
if (seen.has(row.id)) return false
|
||||||
|
seen.add(row.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: allChapters,
|
||||||
|
chapters: allChapters,
|
||||||
|
total: allChapters.length,
|
||||||
|
source: 'database',
|
||||||
|
featuredSections
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e2) {
|
||||||
|
console.log('[All Chapters API] chapters 表读取失败,尝试文件:', (e2 as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方案2: 从JSON文件读取
|
||||||
|
const possiblePaths = [
|
||||||
|
path.join(process.cwd(), 'public/book-chapters.json'),
|
||||||
|
path.join(process.cwd(), 'data/book-chapters.json'),
|
||||||
|
'/www/wwwroot/soul/public/book-chapters.json'
|
||||||
|
]
|
||||||
|
|
||||||
|
let chaptersData: any[] = []
|
||||||
|
let usedPath = ''
|
||||||
|
|
||||||
|
for (const dataPath of possiblePaths) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(dataPath)) {
|
||||||
|
const fileContent = fs.readFileSync(dataPath, 'utf-8')
|
||||||
|
chaptersData = JSON.parse(fileContent)
|
||||||
|
usedPath = dataPath
|
||||||
|
console.log('[All Chapters API] 从文件读取成功:', dataPath)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[All Chapters API] 读取文件失败:', dataPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chaptersData.length > 0) {
|
||||||
|
// 添加字数估算并按 id 去重
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const allChapters = chaptersData
|
||||||
|
.map((chapter: any) => ({
|
||||||
|
...chapter,
|
||||||
|
id: chapter.id ?? chapter.sectionId,
|
||||||
|
words: chapter.words || Math.floor(Math.random() * 3000) + 2000
|
||||||
|
}))
|
||||||
|
.filter((row: any) => {
|
||||||
|
const id = row.id || row.sectionId
|
||||||
|
if (!id || seen.has(String(id))) return false
|
||||||
|
seen.add(String(id))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: allChapters,
|
||||||
|
chapters: allChapters,
|
||||||
|
total: allChapters.length,
|
||||||
|
source: 'file',
|
||||||
|
path: usedPath,
|
||||||
|
featuredSections
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方案3: 返回默认数据
|
||||||
|
console.log('[All Chapters API] 无法读取章节数据,返回默认数据')
|
||||||
|
const defaultChapters = generateDefaultChapters()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: defaultChapters,
|
||||||
|
chapters: defaultChapters,
|
||||||
|
total: defaultChapters.length,
|
||||||
|
source: 'default',
|
||||||
|
featuredSections
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[All Chapters API] Error:', error)
|
||||||
|
|
||||||
|
// 即使出错也返回默认数据,确保小程序可用
|
||||||
|
const defaultChapters = generateDefaultChapters()
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: defaultChapters,
|
||||||
|
chapters: defaultChapters,
|
||||||
|
total: defaultChapters.length,
|
||||||
|
source: 'fallback',
|
||||||
|
warning: '使用默认数据',
|
||||||
|
featuredSections
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成默认章节数据
|
||||||
|
function generateDefaultChapters() {
|
||||||
|
const sections = [
|
||||||
|
{ id: 1, title: '第一章 创业启程', chapters: 5 },
|
||||||
|
{ id: 2, title: '第二章 找到方向', chapters: 6 },
|
||||||
|
{ id: 3, title: '第三章 打造产品', chapters: 5 },
|
||||||
|
{ id: 4, title: '第四章 增长之道', chapters: 6 },
|
||||||
|
{ id: 5, title: '第五章 团队建设', chapters: 5 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const chapters: any[] = []
|
||||||
|
let chapterIndex = 0
|
||||||
|
|
||||||
|
sections.forEach((section, sectionIdx) => {
|
||||||
|
for (let i = 0; i < section.chapters; i++) {
|
||||||
|
chapterIndex++
|
||||||
|
chapters.push({
|
||||||
|
id: `ch_${chapterIndex}`,
|
||||||
|
sectionId: `section_${section.id}`,
|
||||||
|
title: `第${chapterIndex}节`,
|
||||||
|
sectionTitle: section.title,
|
||||||
|
content: `这是${section.title}的第${i + 1}节内容...`,
|
||||||
|
isFree: chapterIndex <= 3, // 前3章免费
|
||||||
|
price: chapterIndex <= 3 ? 0 : 9.9,
|
||||||
|
words: Math.floor(Math.random() * 3000) + 2000,
|
||||||
|
sectionOrder: sectionIdx + 1,
|
||||||
|
chapterOrder: i + 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return chapters
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRelativeTime(index: number): string {
|
||||||
|
if (index <= 3) return '刚刚'
|
||||||
|
if (index <= 6) return '1天前'
|
||||||
|
if (index <= 10) return '2天前'
|
||||||
|
if (index <= 15) return '3天前'
|
||||||
|
if (index <= 20) return '5天前'
|
||||||
|
if (index <= 30) return '1周前'
|
||||||
|
if (index <= 40) return '2周前'
|
||||||
|
return '1个月前'
|
||||||
|
}
|
||||||
63
app/api/book/chapter/[id]/route.ts
Normal file
63
app/api/book/chapter/[id]/route.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// app/api/book/chapter/[id]/route.ts
|
||||||
|
// 获取章节详情 - 从数据库读取,支持小程序和Web端
|
||||||
|
// 更新: 2026-01-25 改为从MySQL数据库读取章节内容
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
|
||||||
|
// 免费章节列表
|
||||||
|
const FREE_CHAPTERS = ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3']
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { id: chapterId } = await params
|
||||||
|
console.log('[Chapter API] 请求章节:', chapterId)
|
||||||
|
|
||||||
|
// 从数据库查询章节
|
||||||
|
const results = await query(
|
||||||
|
`SELECT id, part_id, part_title, chapter_id, chapter_title, section_title,
|
||||||
|
content, word_count, is_free, price, sort_order, status, updated_at
|
||||||
|
FROM chapters
|
||||||
|
WHERE id = ? AND status = 'published'`,
|
||||||
|
[chapterId]
|
||||||
|
) as any[]
|
||||||
|
|
||||||
|
if (!results || results.length === 0) {
|
||||||
|
console.log('[Chapter API] 章节不存在:', chapterId)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '章节不存在', success: false },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapter = results[0]
|
||||||
|
const isFree = chapter.is_free || FREE_CHAPTERS.includes(chapterId)
|
||||||
|
|
||||||
|
console.log('[Chapter API] 返回章节内容:', chapterId, '长度:', chapter.content?.length || 0)
|
||||||
|
|
||||||
|
// 返回小程序兼容的格式
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
id: chapter.id,
|
||||||
|
title: chapter.section_title,
|
||||||
|
content: chapter.content,
|
||||||
|
partTitle: chapter.part_title,
|
||||||
|
chapterTitle: chapter.chapter_title,
|
||||||
|
sectionTitle: chapter.section_title,
|
||||||
|
words: chapter.word_count,
|
||||||
|
updateTime: chapter.updated_at,
|
||||||
|
isFree,
|
||||||
|
price: chapter.price,
|
||||||
|
needPurchase: !isFree
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chapter API] 获取章节失败:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取章节失败', success: false },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
263
app/api/book/chapters/route.ts
Normal file
263
app/api/book/chapters/route.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
// app/api/book/chapters/route.ts
|
||||||
|
// 章节管理API - 支持列表查询、新增、编辑
|
||||||
|
// 开发: 卡若
|
||||||
|
// 日期: 2026-01-25
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - 获取章节列表
|
||||||
|
* 支持参数:
|
||||||
|
* - partId: 按篇筛选
|
||||||
|
* - status: 按状态筛选 (draft/published/archived)
|
||||||
|
* - page: 页码
|
||||||
|
* - pageSize: 每页数量
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const partId = searchParams.get('partId')
|
||||||
|
const status = searchParams.get('status') || 'published'
|
||||||
|
const page = parseInt(searchParams.get('page') || '1')
|
||||||
|
const pageSize = parseInt(searchParams.get('pageSize') || '100')
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT id, part_id, part_title, chapter_id, chapter_title, section_title,
|
||||||
|
word_count, is_free, price, sort_order, status, created_at, updated_at
|
||||||
|
FROM chapters
|
||||||
|
WHERE 1=1
|
||||||
|
`
|
||||||
|
const params: any[] = []
|
||||||
|
|
||||||
|
if (partId) {
|
||||||
|
sql += ' AND part_id = ?'
|
||||||
|
params.push(partId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
sql += ' AND status = ?'
|
||||||
|
params.push(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY sort_order ASC'
|
||||||
|
sql += ' LIMIT ? OFFSET ?'
|
||||||
|
params.push(pageSize, (page - 1) * pageSize)
|
||||||
|
|
||||||
|
const results = await query(sql, params) as any[]
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
let countSql = 'SELECT COUNT(*) as total FROM chapters WHERE 1=1'
|
||||||
|
const countParams: any[] = []
|
||||||
|
if (partId) {
|
||||||
|
countSql += ' AND part_id = ?'
|
||||||
|
countParams.push(partId)
|
||||||
|
}
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
countSql += ' AND status = ?'
|
||||||
|
countParams.push(status)
|
||||||
|
}
|
||||||
|
const countResult = await query(countSql, countParams) as any[]
|
||||||
|
const total = countResult[0]?.total || 0
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
list: results,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chapters API] 获取列表失败:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '获取章节列表失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - 新增章节
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
partId,
|
||||||
|
partTitle,
|
||||||
|
chapterId,
|
||||||
|
chapterTitle,
|
||||||
|
sectionTitle,
|
||||||
|
content,
|
||||||
|
isFree = false,
|
||||||
|
price = 1,
|
||||||
|
sortOrder,
|
||||||
|
status = 'published'
|
||||||
|
} = body
|
||||||
|
|
||||||
|
// 验证必填字段
|
||||||
|
if (!id || !partId || !partTitle || !chapterId || !chapterTitle || !sectionTitle || !content) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '缺少必填字段' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查ID是否已存在
|
||||||
|
const existing = await query('SELECT id FROM chapters WHERE id = ?', [id]) as any[]
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '章节ID已存在' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算字数
|
||||||
|
const wordCount = content.replace(/\s/g, '').length
|
||||||
|
|
||||||
|
// 计算排序顺序(如果未提供)
|
||||||
|
let order = sortOrder
|
||||||
|
if (order === undefined || order === null) {
|
||||||
|
const maxOrder = await query(
|
||||||
|
'SELECT MAX(sort_order) as maxOrder FROM chapters WHERE part_id = ?',
|
||||||
|
[partId]
|
||||||
|
) as any[]
|
||||||
|
order = (maxOrder[0]?.maxOrder || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, sort_order, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, [id, partId, partTitle, chapterId, chapterTitle, sectionTitle, content, wordCount, isFree, isFree ? 0 : price, order, status])
|
||||||
|
|
||||||
|
console.log('[Chapters API] 新增章节成功:', id)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '章节创建成功',
|
||||||
|
data: { id, wordCount, sortOrder: order }
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chapters API] 新增章节失败:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '新增章节失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT - 编辑章节
|
||||||
|
*/
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const { id, ...updates } = body
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '缺少章节ID' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查章节是否存在
|
||||||
|
const existing = await query('SELECT id FROM chapters WHERE id = ?', [id]) as any[]
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '章节不存在' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建更新语句
|
||||||
|
const allowedFields = ['part_id', 'part_title', 'chapter_id', 'chapter_title', 'section_title', 'content', 'is_free', 'price', 'sort_order', 'status']
|
||||||
|
const fieldMapping: Record<string, string> = {
|
||||||
|
partId: 'part_id',
|
||||||
|
partTitle: 'part_title',
|
||||||
|
chapterId: 'chapter_id',
|
||||||
|
chapterTitle: 'chapter_title',
|
||||||
|
sectionTitle: 'section_title',
|
||||||
|
isFree: 'is_free',
|
||||||
|
sortOrder: 'sort_order'
|
||||||
|
}
|
||||||
|
|
||||||
|
const setClauses: string[] = []
|
||||||
|
const params: any[] = []
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
const dbField = fieldMapping[key] || key
|
||||||
|
if (allowedFields.includes(dbField) && value !== undefined) {
|
||||||
|
setClauses.push(`${dbField} = ?`)
|
||||||
|
params.push(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果更新了content,重新计算字数
|
||||||
|
if (updates.content) {
|
||||||
|
const wordCount = updates.content.replace(/\s/g, '').length
|
||||||
|
setClauses.push('word_count = ?')
|
||||||
|
params.push(wordCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setClauses.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '没有可更新的字段' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id)
|
||||||
|
await query(`UPDATE chapters SET ${setClauses.join(', ')} WHERE id = ?`, params)
|
||||||
|
|
||||||
|
console.log('[Chapters API] 更新章节成功:', id)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '章节更新成功'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chapters API] 更新章节失败:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '更新章节失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE - 删除章节(软删除,改状态为archived)
|
||||||
|
*/
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url)
|
||||||
|
const id = searchParams.get('id')
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '缺少章节ID' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 软删除:改状态为archived
|
||||||
|
await query("UPDATE chapters SET status = 'archived' WHERE id = ?", [id])
|
||||||
|
|
||||||
|
console.log('[Chapters API] 删除章节成功:', id)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '章节已删除'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Chapters API] 删除章节失败:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '删除章节失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/api/book/hot/route.ts
Normal file
74
app/api/book/hot/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 热门章节API
|
||||||
|
* 返回点击量最高的章节
|
||||||
|
*/
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// 从数据库查询点击量高的章节(如果有统计表)
|
||||||
|
let hotChapters = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试从订单表统计购买量高的章节
|
||||||
|
const rows = await query(`
|
||||||
|
SELECT
|
||||||
|
section_id as id,
|
||||||
|
COUNT(*) as purchase_count
|
||||||
|
FROM orders
|
||||||
|
WHERE status = 'completed' AND section_id IS NOT NULL
|
||||||
|
GROUP BY section_id
|
||||||
|
ORDER BY purchase_count DESC
|
||||||
|
LIMIT 10
|
||||||
|
`) as any[]
|
||||||
|
|
||||||
|
if (rows && rows.length > 0) {
|
||||||
|
// 补充章节信息
|
||||||
|
const sectionInfo: Record<string, any> = {
|
||||||
|
'1.1': { title: '荷包:电动车出租的被动收入模式', part: '真实的人', tag: '免费' },
|
||||||
|
'9.12': { title: '美业整合:一个人的公司如何月入十万', part: '真实的赚钱', tag: '热门' },
|
||||||
|
'3.1': { title: '3000万流水如何跑出来', part: '真实的行业', tag: '热门' },
|
||||||
|
'8.1': { title: '流量杠杆:抖音、Soul、飞书', part: '真实的赚钱', tag: '推荐' },
|
||||||
|
'9.13': { title: 'AI工具推广:一个隐藏的高利润赛道', part: '真实的赚钱', tag: '最新' },
|
||||||
|
'9.14': { title: '大健康私域:一个月150万的70后', part: '真实的赚钱', tag: '热门' },
|
||||||
|
'1.2': { title: '老墨:资源整合高手的社交方法', part: '真实的人', tag: '推荐' },
|
||||||
|
'2.1': { title: '电商的底层逻辑', part: '真实的行业', tag: '推荐' },
|
||||||
|
'4.1': { title: '我的第一次创业失败', part: '真实的错误', tag: '热门' },
|
||||||
|
'5.1': { title: '未来职业的三个方向', part: '真实的社会', tag: '推荐' }
|
||||||
|
}
|
||||||
|
|
||||||
|
hotChapters = rows.map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
...(sectionInfo[row.id] || { title: `章节${row.id}`, part: '', tag: '热门' }),
|
||||||
|
purchaseCount: row.purchase_count
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[Hot] 数据库查询失败,使用默认数据')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有数据,返回默认热门章节
|
||||||
|
if (hotChapters.length === 0) {
|
||||||
|
hotChapters = [
|
||||||
|
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', part: '真实的人' },
|
||||||
|
{ id: '9.12', title: '美业整合:一个人的公司如何月入十万', tag: '热门', part: '真实的赚钱' },
|
||||||
|
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', part: '真实的行业' },
|
||||||
|
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', part: '真实的赚钱' },
|
||||||
|
{ id: '9.13', title: 'AI工具推广:一个隐藏的高利润赛道', tag: '最新', part: '真实的赚钱' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
chapters: hotChapters
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Hot] Error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
chapters: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
109
app/api/book/latest-chapters/route.ts
Normal file
109
app/api/book/latest-chapters/route.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// app/api/book/latest-chapters/route.ts
|
||||||
|
// 获取最新章节:有2日内更新则取最新3章,否则随机取免费章节
|
||||||
|
// 排除序言、尾声、附录,只推荐正文章节
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
|
||||||
|
const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** 是否应排除(序言、尾声、附录等特殊章节) */
|
||||||
|
function isExcludedChapter(id: string, partTitle: string): boolean {
|
||||||
|
const lowerId = String(id || '').toLowerCase()
|
||||||
|
if (lowerId === 'preface' || lowerId === 'epilogue') return true
|
||||||
|
if (lowerId.startsWith('appendix-') || lowerId.startsWith('appendix_')) return true
|
||||||
|
const pt = String(partTitle || '')
|
||||||
|
if (/序言|尾声/.test(pt)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
let allChapters: Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
part: string
|
||||||
|
isFree: boolean
|
||||||
|
price: number
|
||||||
|
updatedAt: Date | string | null
|
||||||
|
createdAt: Date | string | null
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbRows = (await query(`
|
||||||
|
SELECT id, part_title, section_title, is_free, price, created_at, updated_at
|
||||||
|
FROM chapters
|
||||||
|
ORDER BY sort_order ASC, id ASC
|
||||||
|
`)) as any[]
|
||||||
|
|
||||||
|
if (dbRows?.length > 0) {
|
||||||
|
allChapters = dbRows
|
||||||
|
.map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.section_title || row.title || '',
|
||||||
|
part: row.part_title || '真实的行业',
|
||||||
|
isFree: !!row.is_free,
|
||||||
|
price: row.price || 0,
|
||||||
|
updatedAt: row.updated_at || row.created_at,
|
||||||
|
createdAt: row.created_at
|
||||||
|
}))
|
||||||
|
.filter((c) => !isExcludedChapter(c.id, c.part))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[latest-chapters] 数据库读取失败:', (e as Error).message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allChapters.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
banner: { id: '1.1', title: '荷包:电动车出租的被动收入模式', part: '真实的人' },
|
||||||
|
label: '为你推荐',
|
||||||
|
chapters: [],
|
||||||
|
hasNewUpdates: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const sorted = [...allChapters].sort((a, b) => {
|
||||||
|
const ta = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
|
||||||
|
const tb = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
|
||||||
|
return tb - ta
|
||||||
|
})
|
||||||
|
|
||||||
|
const mostRecentTime = sorted[0]?.updatedAt ? new Date(sorted[0].updatedAt).getTime() : 0
|
||||||
|
const hasNewUpdates = now - mostRecentTime < TWO_DAYS_MS
|
||||||
|
|
||||||
|
let banner: { id: string; title: string; part: string }
|
||||||
|
let label: string
|
||||||
|
let chapters: typeof allChapters
|
||||||
|
|
||||||
|
if (hasNewUpdates && sorted.length > 0) {
|
||||||
|
chapters = sorted.slice(0, 3)
|
||||||
|
banner = { id: chapters[0].id, title: chapters[0].title, part: chapters[0].part }
|
||||||
|
label = '最新更新'
|
||||||
|
} else {
|
||||||
|
const freeChapters = allChapters.filter((c) => c.isFree || c.price === 0)
|
||||||
|
const candidates = freeChapters.length > 0 ? freeChapters : allChapters
|
||||||
|
const shuffled = [...candidates].sort(() => Math.random() - 0.5)
|
||||||
|
chapters = shuffled.slice(0, 3)
|
||||||
|
banner = chapters[0]
|
||||||
|
? { id: chapters[0].id, title: chapters[0].title, part: chapters[0].part }
|
||||||
|
: { id: allChapters[0].id, title: allChapters[0].title, part: allChapters[0].part }
|
||||||
|
label = '为你推荐'
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
banner,
|
||||||
|
label,
|
||||||
|
chapters: chapters.map((c) => ({ id: c.id, title: c.title, part: c.part, isFree: c.isFree })),
|
||||||
|
hasNewUpdates
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[latest-chapters] Error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '获取失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
150
app/api/book/search/route.ts
Normal file
150
app/api/book/search/route.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* 章节搜索API
|
||||||
|
* 搜索章节标题和内容,不返回用户敏感信息
|
||||||
|
*/
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const keyword = searchParams.get('q') || ''
|
||||||
|
|
||||||
|
if (!keyword || keyword.trim().length < 1) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
results: [],
|
||||||
|
total: 0,
|
||||||
|
message: '请输入搜索关键词'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTerm = keyword.trim().toLowerCase()
|
||||||
|
|
||||||
|
// 读取章节数据
|
||||||
|
const dataPath = path.join(process.cwd(), 'public/book-chapters.json')
|
||||||
|
const fileContent = fs.readFileSync(dataPath, 'utf-8')
|
||||||
|
const chaptersData = JSON.parse(fileContent)
|
||||||
|
|
||||||
|
// 读取书籍内容目录
|
||||||
|
const bookDir = path.join(process.cwd(), 'book')
|
||||||
|
|
||||||
|
const results: any[] = []
|
||||||
|
|
||||||
|
// 遍历章节搜索
|
||||||
|
for (const chapter of chaptersData) {
|
||||||
|
const titleMatch = chapter.title?.toLowerCase().includes(searchTerm)
|
||||||
|
const idMatch = chapter.id?.toLowerCase().includes(searchTerm)
|
||||||
|
const partMatch = chapter.partTitle?.toLowerCase().includes(searchTerm)
|
||||||
|
|
||||||
|
// 尝试读取章节内容进行搜索
|
||||||
|
let contentMatch = false
|
||||||
|
let matchedContent = ''
|
||||||
|
|
||||||
|
// 兼容两种字段名: file 或 filePath
|
||||||
|
const filePathField = chapter.filePath || chapter.file
|
||||||
|
if (filePathField) {
|
||||||
|
try {
|
||||||
|
// 如果是绝对路径,直接使用;否则相对于项目根目录
|
||||||
|
const filePath = filePathField.startsWith('/') ? filePathField : path.join(process.cwd(), filePathField)
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
// 移除敏感信息(手机号、微信号等)
|
||||||
|
const cleanContent = content
|
||||||
|
.replace(/1[3-9]\d{9}/g, '***') // 手机号
|
||||||
|
.replace(/微信[::]\s*\S+/g, '微信:***') // 微信号
|
||||||
|
.replace(/QQ[::]\s*\d+/g, 'QQ:***') // QQ号
|
||||||
|
.replace(/邮箱[::]\s*\S+@\S+/g, '邮箱:***') // 邮箱
|
||||||
|
|
||||||
|
if (cleanContent.toLowerCase().includes(searchTerm)) {
|
||||||
|
contentMatch = true
|
||||||
|
// 提取匹配的上下文(前后50个字符)
|
||||||
|
const lowerContent = cleanContent.toLowerCase()
|
||||||
|
const matchIndex = lowerContent.indexOf(searchTerm)
|
||||||
|
if (matchIndex !== -1) {
|
||||||
|
const start = Math.max(0, matchIndex - 30)
|
||||||
|
const end = Math.min(cleanContent.length, matchIndex + searchTerm.length + 50)
|
||||||
|
matchedContent = (start > 0 ? '...' : '') +
|
||||||
|
cleanContent.slice(start, end).replace(/\n/g, ' ') +
|
||||||
|
(end < cleanContent.length ? '...' : '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 文件读取失败,跳过内容搜索
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (titleMatch || idMatch || partMatch || contentMatch) {
|
||||||
|
// 从标题中提取章节号(如 "1.1 荷包:..." -> "1.1")
|
||||||
|
const sectionIdMatch = chapter.title?.match(/^(\d+\.\d+)\s/)
|
||||||
|
const sectionId = sectionIdMatch ? sectionIdMatch[1] : chapter.id
|
||||||
|
|
||||||
|
// 处理特殊ID
|
||||||
|
let finalId = sectionId
|
||||||
|
if (chapter.id === 'preface' || chapter.title?.includes('序言')) {
|
||||||
|
finalId = 'preface'
|
||||||
|
} else if (chapter.id === 'epilogue') {
|
||||||
|
finalId = 'epilogue'
|
||||||
|
} else if (chapter.id?.startsWith('appendix')) {
|
||||||
|
finalId = chapter.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否免费章节
|
||||||
|
const freeIds = ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3']
|
||||||
|
const isFree = freeIds.includes(finalId)
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
id: finalId, // 使用提取的章节号
|
||||||
|
title: chapter.title,
|
||||||
|
part: chapter.partTitle || chapter.part || '',
|
||||||
|
chapter: chapter.chapterDir || chapter.chapter || '',
|
||||||
|
isFree: isFree,
|
||||||
|
matchType: titleMatch ? 'title' : (idMatch ? 'id' : (partMatch ? 'part' : 'content')),
|
||||||
|
matchedContent: contentMatch ? matchedContent : '',
|
||||||
|
// 格式化章节号
|
||||||
|
chapterLabel: formatChapterLabel(finalId, chapter.index)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按匹配类型排序:标题匹配 > ID匹配 > 内容匹配
|
||||||
|
results.sort((a, b) => {
|
||||||
|
const order = { title: 0, id: 1, content: 2 }
|
||||||
|
return (order[a.matchType as keyof typeof order] || 2) - (order[b.matchType as keyof typeof order] || 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
results: results.slice(0, 20), // 最多返回20条
|
||||||
|
total: results.length,
|
||||||
|
keyword: keyword
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '搜索失败',
|
||||||
|
results: []
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化章节标签
|
||||||
|
function formatChapterLabel(id: string, index?: number): string {
|
||||||
|
if (!id) return ''
|
||||||
|
if (id === 'preface') return '序言'
|
||||||
|
if (id.startsWith('chapter-') && index) return `第${index}节`
|
||||||
|
if (id.startsWith('appendix')) return '附录'
|
||||||
|
if (id === 'epilogue') return '后记'
|
||||||
|
|
||||||
|
// 处理 1.1, 3.2 这样的格式
|
||||||
|
const match = id.match(/^(\d+)\.(\d+)$/)
|
||||||
|
if (match) {
|
||||||
|
return `${match[1]}.${match[2]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
72
app/api/book/sync/route.ts
Normal file
72
app/api/book/sync/route.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { exec } from 'child_process'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
|
||||||
|
const execAsync = promisify(exec)
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
// 执行同步脚本
|
||||||
|
const { stdout, stderr } = await execAsync('node scripts/sync-book-content.js')
|
||||||
|
|
||||||
|
if (stderr) {
|
||||||
|
console.error('Sync stderr:', stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Sync stdout:', stdout)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '章节同步成功',
|
||||||
|
output: stdout
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sync error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: '同步失败',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取同步状态
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const dataPath = path.join(process.cwd(), 'public/book-chapters.json')
|
||||||
|
|
||||||
|
if (!fs.existsSync(dataPath)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
synced: false,
|
||||||
|
message: '章节数据未生成'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(dataPath)
|
||||||
|
const fileContent = fs.readFileSync(dataPath, 'utf-8')
|
||||||
|
const chapters = JSON.parse(fileContent)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
synced: true,
|
||||||
|
totalChapters: chapters.length,
|
||||||
|
lastSyncTime: stats.mtime,
|
||||||
|
message: '章节数据已同步'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
synced: false,
|
||||||
|
error: '获取状态失败'
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
137
app/api/ckb/join/route.ts
Normal file
137
app/api/ckb/join/route.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
// 存客宝API配置
|
||||||
|
const CKB_API_KEY = process.env.CKB_API_KEY || "fyngh-ecy9h-qkdae-epwd5-rz6kd"
|
||||||
|
const CKB_API_URL = "https://ckbapi.quwanzhi.com/v1/api/scenarios"
|
||||||
|
|
||||||
|
// 生成签名 - 根据文档实现
|
||||||
|
function generateSign(params: Record<string, any>, apiKey: string): string {
|
||||||
|
// 1. 移除 sign、apiKey、portrait 字段
|
||||||
|
const filteredParams = { ...params }
|
||||||
|
delete filteredParams.sign
|
||||||
|
delete filteredParams.apiKey
|
||||||
|
delete filteredParams.portrait
|
||||||
|
|
||||||
|
// 2. 移除空值字段
|
||||||
|
const nonEmptyParams: Record<string, any> = {}
|
||||||
|
for (const [key, value] of Object.entries(filteredParams)) {
|
||||||
|
if (value !== null && value !== "") {
|
||||||
|
nonEmptyParams[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 按参数名升序排序
|
||||||
|
const sortedKeys = Object.keys(nonEmptyParams).sort()
|
||||||
|
|
||||||
|
// 4. 拼接参数值
|
||||||
|
const stringToSign = sortedKeys.map(key => nonEmptyParams[key]).join("")
|
||||||
|
|
||||||
|
// 5. 第一次 MD5
|
||||||
|
const firstMd5 = crypto.createHash("md5").update(stringToSign).digest("hex")
|
||||||
|
|
||||||
|
// 6. 拼接 apiKey 再次 MD5
|
||||||
|
const sign = crypto.createHash("md5").update(firstMd5 + apiKey).digest("hex")
|
||||||
|
|
||||||
|
return sign
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不同类型对应的source标签
|
||||||
|
const sourceMap: Record<string, string> = {
|
||||||
|
team: "团队招募",
|
||||||
|
investor: "资源对接",
|
||||||
|
mentor: "导师顾问",
|
||||||
|
partner: "创业合伙",
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsMap: Record<string, string> = {
|
||||||
|
team: "切片团队,团队招募",
|
||||||
|
investor: "资源对接,资源群",
|
||||||
|
mentor: "导师顾问,咨询服务",
|
||||||
|
partner: "创业合伙,创业伙伴",
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { type, phone, wechat, name, userId, remark } = body
|
||||||
|
|
||||||
|
// 验证必填参数 - 手机号或微信号至少一个
|
||||||
|
if (!phone && !wechat) {
|
||||||
|
return NextResponse.json({ success: false, message: "请提供手机号或微信号" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证类型
|
||||||
|
if (!["team", "investor", "mentor", "partner"].includes(type)) {
|
||||||
|
return NextResponse.json({ success: false, message: "无效的加入类型" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成时间戳(秒级)
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
// 构建请求参数(不包含sign)
|
||||||
|
const requestParams: Record<string, any> = {
|
||||||
|
timestamp,
|
||||||
|
source: `创业实验-${sourceMap[type]}`,
|
||||||
|
tags: tagsMap[type],
|
||||||
|
siteTags: "创业实验APP",
|
||||||
|
remark: remark || `用户通过创业实验APP申请${sourceMap[type]}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加可选字段
|
||||||
|
if (phone) requestParams.phone = phone
|
||||||
|
if (wechat) requestParams.wechatId = wechat
|
||||||
|
if (name) requestParams.name = name
|
||||||
|
|
||||||
|
// 生成签名
|
||||||
|
const sign = generateSign(requestParams, CKB_API_KEY)
|
||||||
|
|
||||||
|
// 构建最终请求体
|
||||||
|
const requestBody = {
|
||||||
|
...requestParams,
|
||||||
|
apiKey: CKB_API_KEY,
|
||||||
|
sign,
|
||||||
|
portrait: {
|
||||||
|
type: 4, // 互动行为
|
||||||
|
source: 0, // 本站
|
||||||
|
sourceData: {
|
||||||
|
joinType: type,
|
||||||
|
joinLabel: sourceMap[type],
|
||||||
|
userId: userId || "",
|
||||||
|
device: "webapp",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
remark: `${sourceMap[type]}申请`,
|
||||||
|
uniqueId: `soul_${phone || wechat}_${timestamp}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用存客宝API
|
||||||
|
const response = await fetch(CKB_API_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: result.message === "已存在" ? "您已加入,我们会尽快联系您" : `成功加入${sourceMap[type]}`,
|
||||||
|
data: result.data,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.error("CKB API Error:", result)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: result.message || "加入失败,请稍后重试",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("存客宝API调用失败:", error)
|
||||||
|
return NextResponse.json({ success: false, message: "服务器错误,请稍后重试" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
131
app/api/ckb/match/route.ts
Normal file
131
app/api/ckb/match/route.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
// 存客宝API配置
|
||||||
|
const CKB_API_KEY = process.env.CKB_API_KEY || "fyngh-ecy9h-qkdae-epwd5-rz6kd"
|
||||||
|
const CKB_API_URL = "https://ckbapi.quwanzhi.com/v1/api/scenarios"
|
||||||
|
|
||||||
|
// 生成签名 - 根据文档实现
|
||||||
|
function generateSign(params: Record<string, any>, apiKey: string): string {
|
||||||
|
// 1. 移除 sign、apiKey、portrait 字段
|
||||||
|
const filteredParams = { ...params }
|
||||||
|
delete filteredParams.sign
|
||||||
|
delete filteredParams.apiKey
|
||||||
|
delete filteredParams.portrait
|
||||||
|
|
||||||
|
// 2. 移除空值字段
|
||||||
|
const nonEmptyParams: Record<string, any> = {}
|
||||||
|
for (const [key, value] of Object.entries(filteredParams)) {
|
||||||
|
if (value !== null && value !== "") {
|
||||||
|
nonEmptyParams[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 按参数名升序排序
|
||||||
|
const sortedKeys = Object.keys(nonEmptyParams).sort()
|
||||||
|
|
||||||
|
// 4. 拼接参数值
|
||||||
|
const stringToSign = sortedKeys.map(key => nonEmptyParams[key]).join("")
|
||||||
|
|
||||||
|
// 5. 第一次 MD5
|
||||||
|
const firstMd5 = crypto.createHash("md5").update(stringToSign).digest("hex")
|
||||||
|
|
||||||
|
// 6. 拼接 apiKey 再次 MD5
|
||||||
|
const sign = crypto.createHash("md5").update(firstMd5 + apiKey).digest("hex")
|
||||||
|
|
||||||
|
return sign
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配类型映射
|
||||||
|
const matchTypeMap: Record<string, string> = {
|
||||||
|
partner: "创业合伙",
|
||||||
|
investor: "资源对接",
|
||||||
|
mentor: "导师顾问",
|
||||||
|
team: "团队招募",
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { matchType, phone, wechat, userId, nickname, matchedUser } = body
|
||||||
|
|
||||||
|
// 验证必填参数 - 手机号或微信号至少一个
|
||||||
|
if (!phone && !wechat) {
|
||||||
|
return NextResponse.json({ success: false, message: "请提供手机号或微信号" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成时间戳(秒级)
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000)
|
||||||
|
|
||||||
|
// 构建请求参数(不包含sign)
|
||||||
|
const requestParams: Record<string, any> = {
|
||||||
|
timestamp,
|
||||||
|
source: `创业实验-找伙伴匹配`,
|
||||||
|
tags: `找伙伴,${matchTypeMap[matchType] || "创业合伙"}`,
|
||||||
|
siteTags: "创业实验APP,匹配用户",
|
||||||
|
remark: `用户发起${matchTypeMap[matchType] || "创业合伙"}匹配`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加联系方式
|
||||||
|
if (phone) requestParams.phone = phone
|
||||||
|
if (wechat) requestParams.wechatId = wechat
|
||||||
|
if (nickname) requestParams.name = nickname
|
||||||
|
|
||||||
|
// 生成签名
|
||||||
|
const sign = generateSign(requestParams, CKB_API_KEY)
|
||||||
|
|
||||||
|
// 构建最终请求体
|
||||||
|
const requestBody = {
|
||||||
|
...requestParams,
|
||||||
|
apiKey: CKB_API_KEY,
|
||||||
|
sign,
|
||||||
|
portrait: {
|
||||||
|
type: 4, // 互动行为
|
||||||
|
source: 0, // 本站
|
||||||
|
sourceData: {
|
||||||
|
action: "match",
|
||||||
|
matchType: matchType,
|
||||||
|
matchLabel: matchTypeMap[matchType] || "创业合伙",
|
||||||
|
userId: userId || "",
|
||||||
|
matchedUserId: matchedUser?.id || "",
|
||||||
|
matchedUserNickname: matchedUser?.nickname || "",
|
||||||
|
matchScore: matchedUser?.matchScore || 0,
|
||||||
|
device: "webapp",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
remark: `找伙伴匹配-${matchTypeMap[matchType] || "创业合伙"}`,
|
||||||
|
uniqueId: `soul_match_${phone || wechat}_${timestamp}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用存客宝API
|
||||||
|
const response = await fetch(CKB_API_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.code === 200) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "匹配记录已上报",
|
||||||
|
data: result.data,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.error("CKB Match API Error:", result)
|
||||||
|
// 即使上报失败也返回成功,不影响用户体验
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "匹配成功",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("存客宝匹配API调用失败:", error)
|
||||||
|
// 即使出错也返回成功,不影响用户体验
|
||||||
|
return NextResponse.json({ success: true, message: "匹配成功" })
|
||||||
|
}
|
||||||
|
}
|
||||||
525
app/api/ckb/sync/route.ts
Normal file
525
app/api/ckb/sync/route.ts
Normal file
@@ -0,0 +1,525 @@
|
|||||||
|
/**
|
||||||
|
* 存客宝双向同步API
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* 1. 从存客宝拉取用户数据(按手机号)
|
||||||
|
* 2. 将本系统用户数据同步到存客宝
|
||||||
|
* 3. 合并标签体系
|
||||||
|
* 4. 同步行为轨迹
|
||||||
|
*
|
||||||
|
* 手机号为唯一主键
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
|
||||||
|
// 存客宝API配置(需要替换为实际配置)
|
||||||
|
const CKB_API_BASE = process.env.CKB_API_BASE || 'https://api.cunkebao.com'
|
||||||
|
const CKB_API_KEY = process.env.CKB_API_KEY || ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - 执行同步操作
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { action, phone, userId, userData, trackData } = body
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'pull':
|
||||||
|
// 从存客宝拉取用户数据
|
||||||
|
return await pullFromCKB(phone)
|
||||||
|
|
||||||
|
case 'push':
|
||||||
|
// 推送用户数据到存客宝
|
||||||
|
return await pushToCKB(phone, userData)
|
||||||
|
|
||||||
|
case 'sync_tags':
|
||||||
|
// 同步标签
|
||||||
|
return await syncTags(phone, userId)
|
||||||
|
|
||||||
|
case 'sync_track':
|
||||||
|
// 同步行为轨迹
|
||||||
|
return await syncTrack(phone, trackData)
|
||||||
|
|
||||||
|
case 'full_sync':
|
||||||
|
// 完整双向同步
|
||||||
|
return await fullSync(phone, userId)
|
||||||
|
|
||||||
|
case 'batch_sync':
|
||||||
|
// 批量同步所有用户
|
||||||
|
return await batchSync()
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '未知操作类型'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CKB Sync] Error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '同步失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - 获取同步状态
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const phone = searchParams.get('phone')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (phone) {
|
||||||
|
// 获取单个用户的同步状态
|
||||||
|
const users = await query(`
|
||||||
|
SELECT
|
||||||
|
id, phone, nickname, ckb_synced_at, ckb_user_id,
|
||||||
|
tags, ckb_tags, source_tags
|
||||||
|
FROM users
|
||||||
|
WHERE phone = ?
|
||||||
|
`, [phone]) as any[]
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
syncStatus: {
|
||||||
|
user: users[0],
|
||||||
|
isSynced: !!users[0].ckb_synced_at,
|
||||||
|
lastSyncTime: users[0].ckb_synced_at
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取整体同步统计
|
||||||
|
const stats = await query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN ckb_synced_at IS NOT NULL THEN 1 ELSE 0 END) as synced,
|
||||||
|
SUM(CASE WHEN phone IS NOT NULL THEN 1 ELSE 0 END) as has_phone
|
||||||
|
FROM users
|
||||||
|
`) as any[]
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
stats: stats[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CKB Sync] GET Error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '获取同步状态失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从存客宝拉取用户数据
|
||||||
|
*/
|
||||||
|
async function pullFromCKB(phone: string) {
|
||||||
|
if (!phone) {
|
||||||
|
return NextResponse.json({ success: false, error: '手机号不能为空' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用存客宝API获取用户数据
|
||||||
|
// 注意:需要根据实际存客宝API文档调整
|
||||||
|
const ckbResponse = await fetch(`${CKB_API_BASE}/api/user/get`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${CKB_API_KEY}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ phone })
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
let ckbData = null
|
||||||
|
if (ckbResponse && ckbResponse.ok) {
|
||||||
|
ckbData = await ckbResponse.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找本地用户
|
||||||
|
const localUsers = await query('SELECT * FROM users WHERE phone = ?', [phone]) as any[]
|
||||||
|
|
||||||
|
if (localUsers.length === 0 && !ckbData) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '用户不存在于本系统和存客宝'
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果存客宝有数据,更新本地
|
||||||
|
if (ckbData && ckbData.success && ckbData.user) {
|
||||||
|
const ckbUser = ckbData.user
|
||||||
|
|
||||||
|
if (localUsers.length > 0) {
|
||||||
|
// 更新已有用户
|
||||||
|
await query(`
|
||||||
|
UPDATE users SET
|
||||||
|
ckb_user_id = ?,
|
||||||
|
ckb_tags = ?,
|
||||||
|
ckb_synced_at = NOW(),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE phone = ?
|
||||||
|
`, [
|
||||||
|
ckbUser.id || null,
|
||||||
|
JSON.stringify(ckbUser.tags || []),
|
||||||
|
phone
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
// 创建新用户
|
||||||
|
const userId = 'user_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9)
|
||||||
|
const referralCode = 'SOUL' + phone.slice(-4).toUpperCase()
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
INSERT INTO users (
|
||||||
|
id, phone, nickname, referral_code,
|
||||||
|
ckb_user_id, ckb_tags, ckb_synced_at,
|
||||||
|
has_full_book, is_admin, earnings, pending_earnings, referral_count
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, NOW(), FALSE, FALSE, 0, 0, 0)
|
||||||
|
`, [
|
||||||
|
userId,
|
||||||
|
phone,
|
||||||
|
ckbUser.nickname || '用户' + phone.slice(-4),
|
||||||
|
referralCode,
|
||||||
|
ckbUser.id || null,
|
||||||
|
JSON.stringify(ckbUser.tags || [])
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回合并后的用户数据
|
||||||
|
const updatedUsers = await query('SELECT * FROM users WHERE phone = ?', [phone]) as any[]
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
user: updatedUsers[0],
|
||||||
|
ckbData: ckbData?.user || null,
|
||||||
|
message: '数据拉取成功'
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CKB Pull] Error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '拉取存客宝数据失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推送用户数据到存客宝
|
||||||
|
*/
|
||||||
|
async function pushToCKB(phone: string, userData: any) {
|
||||||
|
if (!phone) {
|
||||||
|
return NextResponse.json({ success: false, error: '手机号不能为空' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取本地用户数据
|
||||||
|
const localUsers = await query(`
|
||||||
|
SELECT
|
||||||
|
u.*,
|
||||||
|
(SELECT JSON_ARRAYAGG(JSON_OBJECT(
|
||||||
|
'chapter_id', ut.chapter_id,
|
||||||
|
'action', ut.action,
|
||||||
|
'created_at', ut.created_at
|
||||||
|
)) FROM user_tracks ut WHERE ut.user_id = u.id ORDER BY ut.created_at DESC LIMIT 50) as tracks
|
||||||
|
FROM users u
|
||||||
|
WHERE u.phone = ?
|
||||||
|
`, [phone]) as any[]
|
||||||
|
|
||||||
|
if (localUsers.length === 0) {
|
||||||
|
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const localUser = localUsers[0]
|
||||||
|
|
||||||
|
// 构建推送数据
|
||||||
|
const pushData = {
|
||||||
|
phone,
|
||||||
|
nickname: localUser.nickname,
|
||||||
|
source: 'soul_miniprogram',
|
||||||
|
tags: [
|
||||||
|
...(localUser.tags ? JSON.parse(localUser.tags) : []),
|
||||||
|
localUser.has_full_book ? '已购全书' : '未购买',
|
||||||
|
localUser.referral_count > 0 ? `推荐${localUser.referral_count}人` : null
|
||||||
|
].filter(Boolean),
|
||||||
|
tracks: localUser.tracks ? JSON.parse(localUser.tracks) : [],
|
||||||
|
customData: userData || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用存客宝API
|
||||||
|
const ckbResponse = await fetch(`${CKB_API_BASE}/api/user/sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${CKB_API_KEY}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(pushData)
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
let ckbResult = null
|
||||||
|
if (ckbResponse && ckbResponse.ok) {
|
||||||
|
ckbResult = await ckbResponse.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新本地同步时间
|
||||||
|
await query(`
|
||||||
|
UPDATE users SET ckb_synced_at = NOW(), updated_at = NOW() WHERE phone = ?
|
||||||
|
`, [phone])
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
pushed: pushData,
|
||||||
|
ckbResult,
|
||||||
|
message: '数据推送成功'
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CKB Push] Error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '推送数据到存客宝失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步标签
|
||||||
|
*/
|
||||||
|
async function syncTags(phone: string, userId: string) {
|
||||||
|
try {
|
||||||
|
const id = phone || userId
|
||||||
|
const field = phone ? 'phone' : 'id'
|
||||||
|
|
||||||
|
// 获取本地用户
|
||||||
|
const users = await query(`SELECT * FROM users WHERE ${field} = ?`, [id]) as any[]
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users[0]
|
||||||
|
|
||||||
|
// 合并标签
|
||||||
|
const localTags = user.tags ? JSON.parse(user.tags) : []
|
||||||
|
const ckbTags = user.ckb_tags ? JSON.parse(user.ckb_tags) : []
|
||||||
|
const sourceTags = user.source_tags ? JSON.parse(user.source_tags) : []
|
||||||
|
|
||||||
|
// 去重合并
|
||||||
|
const mergedTags = [...new Set([...localTags, ...ckbTags, ...sourceTags])]
|
||||||
|
|
||||||
|
// 更新合并后的标签
|
||||||
|
await query(`
|
||||||
|
UPDATE users SET
|
||||||
|
merged_tags = ?,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE ${field} = ?
|
||||||
|
`, [JSON.stringify(mergedTags), id])
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
tags: {
|
||||||
|
local: localTags,
|
||||||
|
ckb: ckbTags,
|
||||||
|
source: sourceTags,
|
||||||
|
merged: mergedTags
|
||||||
|
},
|
||||||
|
message: '标签同步成功'
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Sync Tags] Error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '同步标签失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步行为轨迹
|
||||||
|
*/
|
||||||
|
async function syncTrack(phone: string, trackData: any) {
|
||||||
|
if (!phone) {
|
||||||
|
return NextResponse.json({ success: false, error: '手机号不能为空' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取用户ID
|
||||||
|
const users = await query('SELECT id FROM users WHERE phone = ?', [phone]) as any[]
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = users[0].id
|
||||||
|
|
||||||
|
// 获取本地行为轨迹
|
||||||
|
const tracks = await query(`
|
||||||
|
SELECT * FROM user_tracks
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`, [userId]) as any[]
|
||||||
|
|
||||||
|
// 推送到存客宝
|
||||||
|
const pushData = {
|
||||||
|
phone,
|
||||||
|
tracks: tracks.map(t => ({
|
||||||
|
action: t.action,
|
||||||
|
target: t.chapter_id || t.target,
|
||||||
|
timestamp: t.created_at,
|
||||||
|
data: t.extra_data ? JSON.parse(t.extra_data) : {}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const ckbResponse = await fetch(`${CKB_API_BASE}/api/track/sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${CKB_API_KEY}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(pushData)
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
tracksCount: tracks.length,
|
||||||
|
synced: ckbResponse?.ok || false,
|
||||||
|
message: '行为轨迹同步成功'
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Sync Track] Error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '同步行为轨迹失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整双向同步
|
||||||
|
*/
|
||||||
|
async function fullSync(phone: string, userId: string) {
|
||||||
|
try {
|
||||||
|
const id = phone || userId
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ success: false, error: '需要手机号或用户ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果只有userId,先获取手机号
|
||||||
|
let targetPhone = phone
|
||||||
|
if (!phone && userId) {
|
||||||
|
const users = await query('SELECT phone FROM users WHERE id = ?', [userId]) as any[]
|
||||||
|
if (users.length > 0 && users[0].phone) {
|
||||||
|
targetPhone = users[0].phone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetPhone) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '用户未绑定手机号,无法同步存客宝'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 拉取存客宝数据
|
||||||
|
const pullResult = await pullFromCKB(targetPhone)
|
||||||
|
const pullData = await pullResult.json()
|
||||||
|
|
||||||
|
// 2. 同步标签
|
||||||
|
const tagsResult = await syncTags(targetPhone, '')
|
||||||
|
const tagsData = await tagsResult.json()
|
||||||
|
|
||||||
|
// 3. 推送本地数据
|
||||||
|
const pushResult = await pushToCKB(targetPhone, {})
|
||||||
|
const pushData = await pushResult.json()
|
||||||
|
|
||||||
|
// 4. 同步行为轨迹
|
||||||
|
const trackResult = await syncTrack(targetPhone, {})
|
||||||
|
const trackData = await trackResult.json()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
phone: targetPhone,
|
||||||
|
results: {
|
||||||
|
pull: pullData,
|
||||||
|
tags: tagsData,
|
||||||
|
push: pushData,
|
||||||
|
track: trackData
|
||||||
|
},
|
||||||
|
message: '完整双向同步成功'
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Full Sync] Error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '完整同步失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量同步所有有手机号的用户
|
||||||
|
*/
|
||||||
|
async function batchSync() {
|
||||||
|
try {
|
||||||
|
// 获取所有有手机号的用户
|
||||||
|
const users = await query(`
|
||||||
|
SELECT id, phone, nickname
|
||||||
|
FROM users
|
||||||
|
WHERE phone IS NOT NULL
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
`) as any[]
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
total: users.length,
|
||||||
|
success: 0,
|
||||||
|
failed: 0,
|
||||||
|
details: [] as any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐个同步(避免并发过高)
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
// 推送到存客宝
|
||||||
|
await pushToCKB(user.phone, {})
|
||||||
|
results.success++
|
||||||
|
results.details.push({ phone: user.phone, status: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
results.failed++
|
||||||
|
results.details.push({ phone: user.phone, status: 'failed', error: (e as Error).message })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加延迟避免请求过快
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
results,
|
||||||
|
message: `批量同步完成: ${results.success}/${results.total} 成功`
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Batch Sync] Error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '批量同步失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/api/config/route.ts
Normal file
70
app/api/config/route.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const config = {
|
||||||
|
paymentMethods: {
|
||||||
|
wechat: {
|
||||||
|
enabled: true,
|
||||||
|
qrCode: "/images/wechat-pay.png",
|
||||||
|
account: "卡若",
|
||||||
|
websiteAppId: "wx432c93e275548671",
|
||||||
|
websiteAppSecret: "25b7e7fdb7998e5107e242ebb6ddabd0",
|
||||||
|
serviceAppId: "wx7c0dbf34ddba300d",
|
||||||
|
serviceAppSecret: "f865ef18c43dfea6cbe3b1f1aebdb82e",
|
||||||
|
mpVerifyCode: "SP8AfZJyAvprRORT",
|
||||||
|
merchantId: "1318592501",
|
||||||
|
apiKey: "wx3e31b068be59ddc131b068be59ddc2",
|
||||||
|
groupQrCode: "/images/party-group-qr.png",
|
||||||
|
},
|
||||||
|
alipay: {
|
||||||
|
enabled: true,
|
||||||
|
qrCode: "/images/alipay.png",
|
||||||
|
account: "卡若",
|
||||||
|
partnerId: "2088511801157159",
|
||||||
|
securityKey: "lz6ey1h3kl9zqkgtjz3avb5gk37wzbrp",
|
||||||
|
mobilePayEnabled: true,
|
||||||
|
paymentInterface: "official_instant",
|
||||||
|
},
|
||||||
|
usdt: {
|
||||||
|
enabled: false,
|
||||||
|
network: "TRC20",
|
||||||
|
address: "",
|
||||||
|
exchangeRate: 7.2
|
||||||
|
},
|
||||||
|
paypal: {
|
||||||
|
enabled: false,
|
||||||
|
email: "",
|
||||||
|
exchangeRate: 7.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
marketing: {
|
||||||
|
partyGroup: {
|
||||||
|
url: "https://soul.cn/party",
|
||||||
|
liveCodeUrl: "https://soul.cn/party-live",
|
||||||
|
qrCode: "/images/party-group-qr.png"
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
text: "每日早上6-9点,Soul派对房不见不散",
|
||||||
|
visible: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
authorInfo: {
|
||||||
|
name: "卡若",
|
||||||
|
description: "连续创业者,私域运营专家,每天早上6-9点在Soul派对房分享真实商业故事",
|
||||||
|
liveTime: "06:00-09:00",
|
||||||
|
platform: "Soul派对房"
|
||||||
|
},
|
||||||
|
siteConfig: {
|
||||||
|
siteName: "一场soul的创业实验",
|
||||||
|
siteTitle: "一场soul的创业实验",
|
||||||
|
siteDescription: "来自Soul派对房的真实商业故事",
|
||||||
|
primaryColor: "#00CED1"
|
||||||
|
},
|
||||||
|
system: {
|
||||||
|
version: "1.0.0",
|
||||||
|
maintenance: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(config);
|
||||||
|
}
|
||||||
36
app/api/content/route.ts
Normal file
36
app/api/content/route.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
import fs from "fs"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const searchParams = request.nextUrl.searchParams
|
||||||
|
const filePath = searchParams.get("path")
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return NextResponse.json({ error: "Path is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filePath.startsWith("custom/")) {
|
||||||
|
return NextResponse.json({ content: "", isCustom: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalizedPath = filePath.replace(/^\/+/, "")
|
||||||
|
const fullPath = path.join(process.cwd(), normalizedPath)
|
||||||
|
|
||||||
|
if (!fs.existsSync(fullPath)) {
|
||||||
|
return NextResponse.json({ error: "File not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(fullPath)
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
return NextResponse.json({ error: "Path is a directory" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(fullPath, "utf-8")
|
||||||
|
return NextResponse.json({ content, isCustom: false })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Karuo] Error reading file:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to read file" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/api/content/upload/route.ts
Normal file
97
app/api/content/upload/route.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 内容上传 API
|
||||||
|
* 供科室/Skill 直接上传单篇文章到书籍内容,写入 chapters 表
|
||||||
|
* 字段:标题、定价、内容、格式、插入内容中的图片(URL 列表)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
|
||||||
|
function slug(id: string): string {
|
||||||
|
return id.replace(/\s+/g, '-').replace(/[^\w\u4e00-\u9fa5-]/g, '').slice(0, 30) || 'section'
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
price = 1,
|
||||||
|
content = '',
|
||||||
|
format = 'markdown',
|
||||||
|
images = [],
|
||||||
|
partId = 'part-1',
|
||||||
|
partTitle = '真实的人',
|
||||||
|
chapterId = 'chapter-1',
|
||||||
|
chapterTitle = '未分类',
|
||||||
|
isFree = false,
|
||||||
|
sectionId
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!title || typeof title !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: '标题 title 不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若内容中含占位符 {{image_0}} {{image_1}},用 images 数组替换
|
||||||
|
let finalContent = typeof content === 'string' ? content : ''
|
||||||
|
if (Array.isArray(images) && images.length > 0) {
|
||||||
|
images.forEach((url: string, i: number) => {
|
||||||
|
finalContent = finalContent.replace(
|
||||||
|
new RegExp(`\\{\\{image_${i}\\}\\}`, 'g'),
|
||||||
|
url.startsWith('http') ? `` : url
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 未替换的占位符去掉
|
||||||
|
finalContent = finalContent.replace(/\{\{image_\d+\}\}/g, '')
|
||||||
|
|
||||||
|
const wordCount = (finalContent || '').length
|
||||||
|
const id = sectionId || `upload.${slug(title)}.${Date.now()}`
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, sort_order, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 9999, 'published')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
section_title = VALUES(section_title),
|
||||||
|
content = VALUES(content),
|
||||||
|
word_count = VALUES(word_count),
|
||||||
|
is_free = VALUES(is_free),
|
||||||
|
price = VALUES(price),
|
||||||
|
updated_at = CURRENT_TIMESTAMP`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
partId,
|
||||||
|
partTitle,
|
||||||
|
chapterId,
|
||||||
|
chapterTitle,
|
||||||
|
title,
|
||||||
|
finalContent,
|
||||||
|
wordCount,
|
||||||
|
!!isFree,
|
||||||
|
Number(price) || 1
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
id,
|
||||||
|
message: '内容已上传并写入 chapters 表',
|
||||||
|
title,
|
||||||
|
price: Number(price) || 1,
|
||||||
|
isFree: !!isFree,
|
||||||
|
wordCount
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Content Upload]', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: '上传失败: ' + (error as Error).message
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
475
app/api/db/book/route.ts
Normal file
475
app/api/db/book/route.ts
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
/**
|
||||||
|
* 书籍内容数据库API
|
||||||
|
* 支持完整的CRUD操作 - 读取/写入/修改/删除章节
|
||||||
|
* 同时支持文件系统和数据库双写
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { bookData } from '@/lib/book-data'
|
||||||
|
|
||||||
|
// 获取章节内容(从数据库或文件系统)
|
||||||
|
async function getSectionContent(id: string): Promise<{content: string, source: 'db' | 'file'} | null> {
|
||||||
|
try {
|
||||||
|
// 先从数据库查询
|
||||||
|
const results = await query(
|
||||||
|
'SELECT content, section_title FROM chapters WHERE id = ?',
|
||||||
|
[id]
|
||||||
|
) as any[]
|
||||||
|
|
||||||
|
if (results.length > 0 && results[0].content) {
|
||||||
|
return { content: results[0].content, source: 'db' }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[Book API] 数据库查询失败,尝试从文件读取:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从文件系统读取
|
||||||
|
const filePath = findSectionFilePath(id)
|
||||||
|
if (filePath && fs.existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
return { content, source: 'file' }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Book API] 读取文件失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据section ID查找对应的文件路径
|
||||||
|
function findSectionFilePath(id: string): string | null {
|
||||||
|
for (const part of bookData) {
|
||||||
|
for (const chapter of part.chapters) {
|
||||||
|
const section = chapter.sections.find(s => s.id === id)
|
||||||
|
if (section?.filePath) {
|
||||||
|
return path.join(process.cwd(), section.filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取section的完整信息
|
||||||
|
function getSectionInfo(id: string) {
|
||||||
|
for (const part of bookData) {
|
||||||
|
for (const chapter of part.chapters) {
|
||||||
|
const section = chapter.sections.find(s => s.id === id)
|
||||||
|
if (section) {
|
||||||
|
return {
|
||||||
|
section,
|
||||||
|
chapter,
|
||||||
|
part,
|
||||||
|
partId: part.id,
|
||||||
|
chapterId: chapter.id,
|
||||||
|
partTitle: part.title,
|
||||||
|
chapterTitle: chapter.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - 读取章节内容
|
||||||
|
* 支持参数:
|
||||||
|
* - id: 章节ID
|
||||||
|
* - action: 'read' | 'export' | 'list'
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const action = searchParams.get('action') || 'read'
|
||||||
|
const id = searchParams.get('id')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 读取单个章节
|
||||||
|
if (action === 'read' && id) {
|
||||||
|
const result = await getSectionContent(id)
|
||||||
|
const sectionInfo = getSectionInfo(id)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
section: {
|
||||||
|
id,
|
||||||
|
content: result.content,
|
||||||
|
source: result.source,
|
||||||
|
title: sectionInfo?.section.title || '',
|
||||||
|
price: sectionInfo?.section.price || 1,
|
||||||
|
partTitle: sectionInfo?.partTitle,
|
||||||
|
chapterTitle: sectionInfo?.chapterTitle
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '章节不存在或无法读取'
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出所有章节
|
||||||
|
if (action === 'export') {
|
||||||
|
const sections: any[] = []
|
||||||
|
|
||||||
|
for (const part of bookData) {
|
||||||
|
for (const chapter of part.chapters) {
|
||||||
|
for (const section of chapter.sections) {
|
||||||
|
const content = await getSectionContent(section.id)
|
||||||
|
sections.push({
|
||||||
|
id: section.id,
|
||||||
|
title: section.title,
|
||||||
|
price: section.price,
|
||||||
|
isFree: section.isFree,
|
||||||
|
partId: part.id,
|
||||||
|
partTitle: part.title,
|
||||||
|
chapterId: chapter.id,
|
||||||
|
chapterTitle: chapter.title,
|
||||||
|
content: content?.content || '',
|
||||||
|
source: content?.source || 'none'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = JSON.stringify(sections, null, 2)
|
||||||
|
return new NextResponse(blob, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Disposition': `attachment; filename="book_sections_${new Date().toISOString().split('T')[0]}.json"`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列出所有章节(不含内容)
|
||||||
|
// 优先从数据库读取,确保新建章节能立即显示
|
||||||
|
if (action === 'list') {
|
||||||
|
const sectionsFromDb = new Map<string, any>()
|
||||||
|
try {
|
||||||
|
const rows = await query(`
|
||||||
|
SELECT id, part_id, part_title, chapter_id, chapter_title, section_title,
|
||||||
|
price, is_free, content
|
||||||
|
FROM chapters ORDER BY part_id, chapter_id, id
|
||||||
|
`) as any[]
|
||||||
|
if (rows && rows.length > 0) {
|
||||||
|
for (const r of rows) {
|
||||||
|
sectionsFromDb.set(r.id, {
|
||||||
|
id: r.id,
|
||||||
|
title: r.section_title || '',
|
||||||
|
price: r.price ?? 1,
|
||||||
|
isFree: !!r.is_free,
|
||||||
|
partId: r.part_id || 'part-1',
|
||||||
|
partTitle: r.part_title || '',
|
||||||
|
chapterId: r.chapter_id || 'chapter-1',
|
||||||
|
chapterTitle: r.chapter_title || '',
|
||||||
|
filePath: ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[Book API] list 从数据库读取失败,回退到 bookData:', (e as Error).message)
|
||||||
|
}
|
||||||
|
// 合并:以数据库为准,数据库没有的用 bookData 补
|
||||||
|
const sections: any[] = []
|
||||||
|
for (const part of bookData) {
|
||||||
|
for (const chapter of part.chapters) {
|
||||||
|
for (const section of chapter.sections) {
|
||||||
|
const dbRow = sectionsFromDb.get(section.id)
|
||||||
|
sections.push(dbRow || {
|
||||||
|
id: section.id,
|
||||||
|
title: section.title,
|
||||||
|
price: section.price,
|
||||||
|
isFree: section.isFree,
|
||||||
|
partId: part.id,
|
||||||
|
partTitle: part.title,
|
||||||
|
chapterId: chapter.id,
|
||||||
|
chapterTitle: chapter.title,
|
||||||
|
filePath: section.filePath
|
||||||
|
})
|
||||||
|
sectionsFromDb.delete(section.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 数据库有但 bookData 没有的(新建章节)
|
||||||
|
for (const [, v] of sectionsFromDb) {
|
||||||
|
sections.push(v)
|
||||||
|
}
|
||||||
|
// 按 id 去重,避免数据库重复或合并逻辑导致同一文章出现多次
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const deduped = sections.filter((s) => {
|
||||||
|
if (seen.has(s.id)) return false
|
||||||
|
seen.add(s.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
sections: deduped,
|
||||||
|
total: deduped.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '无效的操作或缺少参数'
|
||||||
|
}, { status: 400 })
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Book API] GET错误:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '获取章节失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - 同步/导入章节
|
||||||
|
* 支持action:
|
||||||
|
* - sync: 同步文件系统到数据库
|
||||||
|
* - import: 批量导入章节数据
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { action, data } = body
|
||||||
|
|
||||||
|
// 同步到数据库
|
||||||
|
if (action === 'sync') {
|
||||||
|
let synced = 0
|
||||||
|
let failed = 0
|
||||||
|
|
||||||
|
for (const part of bookData) {
|
||||||
|
for (const chapter of part.chapters) {
|
||||||
|
for (const section of chapter.sections) {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(process.cwd(), section.filePath)
|
||||||
|
let content = ''
|
||||||
|
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
content = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入或更新到数据库
|
||||||
|
await query(`
|
||||||
|
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, sort_order, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
part_title = VALUES(part_title),
|
||||||
|
chapter_title = VALUES(chapter_title),
|
||||||
|
section_title = VALUES(section_title),
|
||||||
|
content = VALUES(content),
|
||||||
|
word_count = VALUES(word_count),
|
||||||
|
is_free = VALUES(is_free),
|
||||||
|
price = VALUES(price),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, [
|
||||||
|
section.id,
|
||||||
|
part.id,
|
||||||
|
part.title,
|
||||||
|
chapter.id,
|
||||||
|
chapter.title,
|
||||||
|
section.title,
|
||||||
|
content,
|
||||||
|
content.length,
|
||||||
|
section.isFree,
|
||||||
|
section.price,
|
||||||
|
synced
|
||||||
|
])
|
||||||
|
|
||||||
|
synced++
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Book API] 同步章节${section.id}失败:`, e)
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `同步完成:成功 ${synced} 个章节,失败 ${failed} 个`,
|
||||||
|
synced,
|
||||||
|
failed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导入数据
|
||||||
|
if (action === 'import' && data) {
|
||||||
|
let imported = 0
|
||||||
|
let failed = 0
|
||||||
|
|
||||||
|
for (const item of data) {
|
||||||
|
try {
|
||||||
|
await query(`
|
||||||
|
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
section_title = VALUES(section_title),
|
||||||
|
content = VALUES(content),
|
||||||
|
word_count = VALUES(word_count),
|
||||||
|
is_free = VALUES(is_free),
|
||||||
|
price = VALUES(price),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, [
|
||||||
|
item.id,
|
||||||
|
item.partId || 'part-1',
|
||||||
|
item.partTitle || '未分类',
|
||||||
|
item.chapterId || 'chapter-1',
|
||||||
|
item.chapterTitle || '未分类',
|
||||||
|
item.title,
|
||||||
|
item.content || '',
|
||||||
|
(item.content || '').length,
|
||||||
|
item.is_free || false,
|
||||||
|
item.price || 1
|
||||||
|
])
|
||||||
|
imported++
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Book API] 导入章节${item.id}失败:`, e)
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `导入完成:成功 ${imported} 个章节,失败 ${failed} 个`,
|
||||||
|
imported,
|
||||||
|
failed
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '无效的操作'
|
||||||
|
}, { status: 400 })
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Book API] POST错误:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '操作失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT - 更新章节内容
|
||||||
|
* 支持同时更新数据库和文件系统
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { id, title, content, price, saveToFile = true, partId, chapterId, partTitle, chapterTitle, isFree } = body
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '章节ID不能为空'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionInfo = getSectionInfo(id)
|
||||||
|
const finalPartId = partId || sectionInfo?.partId || 'part-1'
|
||||||
|
const finalPartTitle = partTitle || sectionInfo?.partTitle || '未分类'
|
||||||
|
const finalChapterId = chapterId || sectionInfo?.chapterId || 'chapter-1'
|
||||||
|
const finalChapterTitle = chapterTitle || sectionInfo?.chapterTitle || '未分类'
|
||||||
|
const finalPrice = price ?? sectionInfo?.section?.price ?? 1
|
||||||
|
const finalIsFree = isFree ?? sectionInfo?.section?.isFree ?? false
|
||||||
|
|
||||||
|
// 更新数据库(含新建章节)
|
||||||
|
try {
|
||||||
|
await query(`
|
||||||
|
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
part_id = VALUES(part_id),
|
||||||
|
part_title = VALUES(part_title),
|
||||||
|
chapter_id = VALUES(chapter_id),
|
||||||
|
chapter_title = VALUES(chapter_title),
|
||||||
|
section_title = VALUES(section_title),
|
||||||
|
content = VALUES(content),
|
||||||
|
word_count = VALUES(word_count),
|
||||||
|
is_free = VALUES(is_free),
|
||||||
|
price = VALUES(price),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, [
|
||||||
|
id,
|
||||||
|
finalPartId,
|
||||||
|
finalPartTitle,
|
||||||
|
finalChapterId,
|
||||||
|
finalChapterTitle,
|
||||||
|
title || sectionInfo?.section?.title || '',
|
||||||
|
content || '',
|
||||||
|
(content || '').length,
|
||||||
|
finalIsFree,
|
||||||
|
finalPrice
|
||||||
|
])
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Book API] 更新数据库失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同时保存到文件系统
|
||||||
|
if (saveToFile && sectionInfo?.section.filePath) {
|
||||||
|
const filePath = path.join(process.cwd(), sectionInfo.section.filePath)
|
||||||
|
try {
|
||||||
|
// 确保目录存在
|
||||||
|
const dir = path.dirname(filePath)
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
fs.writeFileSync(filePath, content || '', 'utf-8')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Book API] 保存文件失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '章节更新成功',
|
||||||
|
id
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Book API] PUT错误:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '更新章节失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE - 删除章节
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const id = searchParams.get('id')
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '章节ID不能为空'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 从数据库删除
|
||||||
|
await query('DELETE FROM chapters WHERE id = ?', [id])
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '章节删除成功',
|
||||||
|
id
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Book API] DELETE错误:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '删除章节失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
272
app/api/db/chapters/route.ts
Normal file
272
app/api/db/chapters/route.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* Soul创业实验 - 文章数据API
|
||||||
|
* 用于存储和获取章节数据
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// 文章数据结构
|
||||||
|
interface Section {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
isFree: boolean
|
||||||
|
price: number
|
||||||
|
content?: string
|
||||||
|
filePath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Chapter {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
sections: Section[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Part {
|
||||||
|
id: string
|
||||||
|
number: string
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
chapters: Chapter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 书籍目录结构映射
|
||||||
|
const BOOK_STRUCTURE = [
|
||||||
|
{
|
||||||
|
id: 'part-1',
|
||||||
|
number: '一',
|
||||||
|
title: '真实的人',
|
||||||
|
subtitle: '人与人之间的底层逻辑',
|
||||||
|
folder: '第一篇|真实的人',
|
||||||
|
chapters: [
|
||||||
|
{ id: 'chapter-1', title: '第1章|人与人之间的底层逻辑', folder: '第1章|人与人之间的底层逻辑' },
|
||||||
|
{ id: 'chapter-2', title: '第2章|人性困境案例', folder: '第2章|人性困境案例' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'part-2',
|
||||||
|
number: '二',
|
||||||
|
title: '真实的行业',
|
||||||
|
subtitle: '电商、内容、传统行业解析',
|
||||||
|
folder: '第二篇|真实的行业',
|
||||||
|
chapters: [
|
||||||
|
{ id: 'chapter-3', title: '第3章|电商篇', folder: '第3章|电商篇' },
|
||||||
|
{ id: 'chapter-4', title: '第4章|内容商业篇', folder: '第4章|内容商业篇' },
|
||||||
|
{ id: 'chapter-5', title: '第5章|传统行业篇', folder: '第5章|传统行业篇' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'part-3',
|
||||||
|
number: '三',
|
||||||
|
title: '真实的错误',
|
||||||
|
subtitle: '我和别人犯过的错',
|
||||||
|
folder: '第三篇|真实的错误',
|
||||||
|
chapters: [
|
||||||
|
{ id: 'chapter-6', title: '第6章|我人生错过的4件大钱', folder: '第6章|我人生错过的4件大钱' },
|
||||||
|
{ id: 'chapter-7', title: '第7章|别人犯的错误', folder: '第7章|别人犯的错误' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'part-4',
|
||||||
|
number: '四',
|
||||||
|
title: '真实的赚钱',
|
||||||
|
subtitle: '底层结构与真实案例',
|
||||||
|
folder: '第四篇|真实的赚钱',
|
||||||
|
chapters: [
|
||||||
|
{ id: 'chapter-8', title: '第8章|底层结构', folder: '第8章|底层结构' },
|
||||||
|
{ id: 'chapter-9', title: '第9章|我在Soul上亲访的赚钱案例', folder: '第9章|我在Soul上亲访的赚钱案例' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'part-5',
|
||||||
|
number: '五',
|
||||||
|
title: '真实的社会',
|
||||||
|
subtitle: '未来职业与商业生态',
|
||||||
|
folder: '第五篇|真实的社会',
|
||||||
|
chapters: [
|
||||||
|
{ id: 'chapter-10', title: '第10章|未来职业的变化趋势', folder: '第10章|未来职业的变化趋势' },
|
||||||
|
{ id: 'chapter-11', title: '第11章|中国社会商业生态的未来', folder: '第11章|中国社会商业生态的未来' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 免费章节ID
|
||||||
|
const FREE_SECTIONS = ['1.1', 'preface', 'epilogue', 'appendix-1', 'appendix-2', 'appendix-3']
|
||||||
|
|
||||||
|
// 从book目录读取真实文章数据
|
||||||
|
function loadBookData(): Part[] {
|
||||||
|
const bookPath = path.join(process.cwd(), 'book')
|
||||||
|
const parts: Part[] = []
|
||||||
|
|
||||||
|
for (const partConfig of BOOK_STRUCTURE) {
|
||||||
|
const part: Part = {
|
||||||
|
id: partConfig.id,
|
||||||
|
number: partConfig.number,
|
||||||
|
title: partConfig.title,
|
||||||
|
subtitle: partConfig.subtitle,
|
||||||
|
chapters: []
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const chapterConfig of partConfig.chapters) {
|
||||||
|
const chapter: Chapter = {
|
||||||
|
id: chapterConfig.id,
|
||||||
|
title: chapterConfig.title,
|
||||||
|
sections: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapterPath = path.join(bookPath, partConfig.folder, chapterConfig.folder)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(chapterPath)
|
||||||
|
const mdFiles = files.filter(f => f.endsWith('.md')).sort()
|
||||||
|
|
||||||
|
for (const file of mdFiles) {
|
||||||
|
// 从文件名提取ID和标题
|
||||||
|
const match = file.match(/^(\d+\.\d+)\s+(.+)\.md$/)
|
||||||
|
if (match) {
|
||||||
|
const [, id, title] = match
|
||||||
|
const filePath = path.join(chapterPath, file)
|
||||||
|
|
||||||
|
chapter.sections.push({
|
||||||
|
id,
|
||||||
|
title: title.replace(/[::]/g, ':'), // 统一冒号格式
|
||||||
|
isFree: FREE_SECTIONS.includes(id),
|
||||||
|
price: 1,
|
||||||
|
filePath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按ID数字排序
|
||||||
|
chapter.sections.sort((a, b) => {
|
||||||
|
const [aMajor, aMinor] = a.id.split('.').map(Number)
|
||||||
|
const [bMajor, bMinor] = b.id.split('.').map(Number)
|
||||||
|
return aMajor !== bMajor ? aMajor - bMajor : aMinor - bMinor
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`读取章节目录失败: ${chapterPath}`, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
part.chapters.push(chapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文章内容
|
||||||
|
function getArticleContent(sectionId: string): string | null {
|
||||||
|
const bookData = loadBookData()
|
||||||
|
|
||||||
|
for (const part of bookData) {
|
||||||
|
for (const chapter of part.chapters) {
|
||||||
|
const section = chapter.sections.find(s => s.id === sectionId)
|
||||||
|
if (section?.filePath) {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(section.filePath, 'utf-8')
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`读取文章内容失败: ${section.filePath}`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET - 获取所有章节数据
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const sectionId = searchParams.get('id')
|
||||||
|
const includeContent = searchParams.get('content') === 'true'
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 如果指定了章节ID,返回单篇文章内容
|
||||||
|
if (sectionId) {
|
||||||
|
const content = getArticleContent(sectionId)
|
||||||
|
if (content) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: { id: sectionId, content }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '文章不存在'
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回完整书籍结构
|
||||||
|
const bookData = loadBookData()
|
||||||
|
|
||||||
|
// 统计总章节数
|
||||||
|
let totalSections = 0
|
||||||
|
for (const part of bookData) {
|
||||||
|
for (const chapter of part.chapters) {
|
||||||
|
totalSections += chapter.sections.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 加上序言、尾声和3个附录
|
||||||
|
totalSections += 5
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalSections,
|
||||||
|
parts: bookData,
|
||||||
|
appendix: [
|
||||||
|
{ id: 'appendix-1', title: '附录1|Soul派对房精选对话', isFree: true },
|
||||||
|
{ id: 'appendix-2', title: '附录2|创业者自检清单', isFree: true },
|
||||||
|
{ id: 'appendix-3', title: '附录3|本书提到的工具和资源', isFree: true }
|
||||||
|
],
|
||||||
|
preface: { id: 'preface', title: '序言|为什么我每天早上6点在Soul开播?', isFree: true },
|
||||||
|
epilogue: { id: 'epilogue', title: '尾声|这本书的真实目的', isFree: true }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取章节数据失败:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '获取数据失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST - 同步章节数据到数据库(预留接口)
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const bookData = loadBookData()
|
||||||
|
|
||||||
|
// 这里可以添加数据库写入逻辑
|
||||||
|
// 目前先返回成功,数据已从文件系统读取
|
||||||
|
|
||||||
|
let totalSections = 0
|
||||||
|
for (const part of bookData) {
|
||||||
|
for (const chapter of part.chapters) {
|
||||||
|
totalSections += chapter.sections.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalSections += 5 // 序言、尾声、3个附录
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '章节数据同步成功',
|
||||||
|
data: {
|
||||||
|
totalSections,
|
||||||
|
partsCount: bookData.length,
|
||||||
|
chaptersCount: bookData.reduce((acc, p) => acc + p.chapters.length, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('同步章节数据失败:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '同步数据失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
367
app/api/db/config/route.ts
Normal file
367
app/api/db/config/route.ts
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
/**
|
||||||
|
* 系统配置API
|
||||||
|
* 优先读取数据库配置,失败时读取本地默认配置
|
||||||
|
* 支持配置的增删改查
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query, getConfig, setConfig } from '@/lib/db'
|
||||||
|
|
||||||
|
// 本地默认配置(作为数据库备份)
|
||||||
|
const DEFAULT_CONFIGS: Record<string, any> = {
|
||||||
|
// 站点配置
|
||||||
|
site_config: {
|
||||||
|
siteName: 'Soul创业派对',
|
||||||
|
siteDescription: '来自派对房的真实商业故事',
|
||||||
|
logo: '/icon.svg',
|
||||||
|
keywords: ['创业', 'Soul', '私域运营', '商业案例'],
|
||||||
|
icp: '',
|
||||||
|
analytics: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
// 匹配功能配置
|
||||||
|
match_config: {
|
||||||
|
matchTypes: [
|
||||||
|
{ id: 'partner', label: '创业合伙', matchLabel: '创业伙伴', icon: '⭐', matchFromDB: true, showJoinAfterMatch: false, enabled: true },
|
||||||
|
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: '👥', matchFromDB: false, showJoinAfterMatch: true, enabled: true },
|
||||||
|
{ id: 'mentor', label: '导师顾问', matchLabel: '商业顾问', icon: '❤️', matchFromDB: false, showJoinAfterMatch: true, enabled: true },
|
||||||
|
{ id: 'team', label: '团队招募', matchLabel: '加入项目', icon: '🎮', matchFromDB: false, showJoinAfterMatch: true, enabled: true }
|
||||||
|
],
|
||||||
|
freeMatchLimit: 3,
|
||||||
|
matchPrice: 1,
|
||||||
|
settings: {
|
||||||
|
enableFreeMatches: true,
|
||||||
|
enablePaidMatches: true,
|
||||||
|
maxMatchesPerDay: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 分销配置
|
||||||
|
referral_config: {
|
||||||
|
distributorShare: 90,
|
||||||
|
minWithdrawAmount: 10,
|
||||||
|
bindingDays: 30,
|
||||||
|
userDiscount: 5,
|
||||||
|
enableAutoWithdraw: false
|
||||||
|
},
|
||||||
|
|
||||||
|
// 价格配置
|
||||||
|
price_config: {
|
||||||
|
sectionPrice: 1,
|
||||||
|
fullBookPrice: 9.9,
|
||||||
|
premiumBookPrice: 19.9,
|
||||||
|
matchPrice: 1
|
||||||
|
},
|
||||||
|
|
||||||
|
// 支付配置
|
||||||
|
payment_config: {
|
||||||
|
wechat: {
|
||||||
|
enabled: true,
|
||||||
|
appId: 'wx432c93e275548671',
|
||||||
|
mchId: '1318592501'
|
||||||
|
},
|
||||||
|
alipay: {
|
||||||
|
enabled: true,
|
||||||
|
pid: '2088511801157159'
|
||||||
|
},
|
||||||
|
wechatGroupUrl: '' // 支付成功后跳转的微信群链接
|
||||||
|
},
|
||||||
|
|
||||||
|
// 书籍配置
|
||||||
|
book_config: {
|
||||||
|
totalSections: 62,
|
||||||
|
freeSections: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3'],
|
||||||
|
latestSectionId: '9.14'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 功能开关配置
|
||||||
|
feature_config: {
|
||||||
|
matchEnabled: true, // 找伙伴功能开关(默认开启)
|
||||||
|
referralEnabled: true, // 推广功能开关
|
||||||
|
searchEnabled: true, // 搜索功能开关
|
||||||
|
aboutEnabled: true // 关于页面开关
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - 获取配置
|
||||||
|
* 参数: key - 配置键名,不传则返回所有配置
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const key = searchParams.get('key')
|
||||||
|
const forceLocal = searchParams.get('forceLocal') === 'true'
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (key) {
|
||||||
|
// 获取单个配置
|
||||||
|
let config = null
|
||||||
|
|
||||||
|
if (!forceLocal) {
|
||||||
|
// 优先从数据库读取
|
||||||
|
try {
|
||||||
|
config = await getConfig(key)
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Config API] 数据库读取${key}失败,使用本地配置`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据库没有则使用本地默认
|
||||||
|
if (!config) {
|
||||||
|
config = DEFAULT_CONFIGS[key] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
key,
|
||||||
|
config,
|
||||||
|
source: config === DEFAULT_CONFIGS[key] ? 'local' : 'database'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '配置不存在'
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有配置
|
||||||
|
const allConfigs: Record<string, any> = {}
|
||||||
|
const sources: Record<string, string> = {}
|
||||||
|
|
||||||
|
for (const configKey of Object.keys(DEFAULT_CONFIGS)) {
|
||||||
|
let config = null
|
||||||
|
|
||||||
|
if (!forceLocal) {
|
||||||
|
try {
|
||||||
|
config = await getConfig(configKey)
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略数据库错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
allConfigs[configKey] = config
|
||||||
|
sources[configKey] = 'database'
|
||||||
|
} else {
|
||||||
|
allConfigs[configKey] = DEFAULT_CONFIGS[configKey]
|
||||||
|
sources[configKey] = 'local'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取小程序配置
|
||||||
|
let mpConfig = null
|
||||||
|
try {
|
||||||
|
mpConfig = await getConfig('mp_config')
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// 提取前端需要的格式
|
||||||
|
const bookConfig = allConfigs.book_config || DEFAULT_CONFIGS.book_config
|
||||||
|
const featureConfig = allConfigs.feature_config || DEFAULT_CONFIGS.feature_config
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
configs: allConfigs,
|
||||||
|
sources,
|
||||||
|
// 前端直接使用的格式
|
||||||
|
freeChapters: bookConfig.freeSections || DEFAULT_CONFIGS.book_config.freeSections,
|
||||||
|
features: featureConfig, // 功能开关
|
||||||
|
mpConfig: mpConfig || {
|
||||||
|
appId: 'wxb8bbb2b10dec74aa',
|
||||||
|
apiDomain: 'https://soul.quwanzhi.com',
|
||||||
|
buyerDiscount: 5,
|
||||||
|
referralBindDays: 30,
|
||||||
|
minWithdraw: 10
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Config API] GET错误:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '获取配置失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - 保存配置到数据库
|
||||||
|
* 支持两种格式:
|
||||||
|
* 1. { key, config } - 单个配置
|
||||||
|
* 2. { freeChapters, mpConfig } - 批量配置
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
// 支持批量配置格式
|
||||||
|
if (body.freeChapters || body.mpConfig) {
|
||||||
|
let successCount = 0
|
||||||
|
|
||||||
|
// 保存免费章节配置
|
||||||
|
if (body.freeChapters) {
|
||||||
|
const bookConfig = {
|
||||||
|
...DEFAULT_CONFIGS.book_config,
|
||||||
|
freeSections: body.freeChapters
|
||||||
|
}
|
||||||
|
const success = await setConfig('book_config', bookConfig, '书籍配置-免费章节')
|
||||||
|
if (success) successCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存小程序配置
|
||||||
|
if (body.mpConfig) {
|
||||||
|
const success = await setConfig('mp_config', body.mpConfig, '小程序配置')
|
||||||
|
if (success) successCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `配置保存成功 (${successCount}项)`,
|
||||||
|
successCount
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 原有的单配置格式
|
||||||
|
const { key, config, description } = body
|
||||||
|
|
||||||
|
if (!key || !config) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '配置键名和配置值不能为空'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Config API] 保存配置 ${key}:`, config)
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
const success = await setConfig(key, config, description)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// 验证保存结果
|
||||||
|
const saved = await getConfig(key)
|
||||||
|
console.log(`[Config API] 验证保存结果 ${key}:`, saved)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '配置保存成功',
|
||||||
|
key,
|
||||||
|
savedConfig: saved // 返回实际保存的配置
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '配置保存失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Config API] POST错误:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '保存配置失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT - 批量更新配置
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { configs } = body
|
||||||
|
|
||||||
|
if (!configs || typeof configs !== 'object') {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '配置数据格式错误'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
|
||||||
|
for (const [key, config] of Object.entries(configs)) {
|
||||||
|
try {
|
||||||
|
const success = await setConfig(key, config)
|
||||||
|
if (success) {
|
||||||
|
successCount++
|
||||||
|
} else {
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `配置更新完成:成功${successCount}个,失败${failedCount}个`,
|
||||||
|
successCount,
|
||||||
|
failedCount
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Config API] PUT错误:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '更新配置失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE - 删除配置(恢复为本地默认)
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const key = searchParams.get('key')
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '配置键名不能为空'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await query('DELETE FROM system_config WHERE config_key = ?', [key])
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '配置已删除,将使用本地默认值',
|
||||||
|
key
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Config API] DELETE错误:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '删除配置失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化:将本地配置同步到数据库
|
||||||
|
*/
|
||||||
|
export async function syncLocalToDatabase() {
|
||||||
|
console.log('[Config] 开始同步本地配置到数据库...')
|
||||||
|
|
||||||
|
for (const [key, config] of Object.entries(DEFAULT_CONFIGS)) {
|
||||||
|
try {
|
||||||
|
// 检查数据库是否已有该配置
|
||||||
|
const existing = await getConfig(key)
|
||||||
|
if (!existing) {
|
||||||
|
// 数据库没有,则写入
|
||||||
|
await setConfig(key, config, `默认${key}配置`)
|
||||||
|
console.log(`[Config] 同步配置: ${key}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Config] 同步${key}失败:`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Config] 配置同步完成')
|
||||||
|
}
|
||||||
173
app/api/db/init/route.ts
Normal file
173
app/api/db/init/route.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* 数据库初始化/升级API
|
||||||
|
* 用于添加缺失的字段,确保表结构完整
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - 初始化/升级数据库表结构
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const results: string[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[DB Init] 开始检查并升级数据库结构...')
|
||||||
|
|
||||||
|
// 1. 检查users表是否存在
|
||||||
|
try {
|
||||||
|
await query('SELECT 1 FROM users LIMIT 1')
|
||||||
|
results.push('✅ users表已存在')
|
||||||
|
} catch (e) {
|
||||||
|
// 创建users表
|
||||||
|
await query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
open_id VARCHAR(100) UNIQUE,
|
||||||
|
session_key VARCHAR(100),
|
||||||
|
nickname VARCHAR(100),
|
||||||
|
avatar VARCHAR(500),
|
||||||
|
phone VARCHAR(20),
|
||||||
|
password VARCHAR(100),
|
||||||
|
wechat_id VARCHAR(100),
|
||||||
|
referral_code VARCHAR(20) UNIQUE,
|
||||||
|
referred_by VARCHAR(50),
|
||||||
|
purchased_sections JSON DEFAULT '[]',
|
||||||
|
has_full_book BOOLEAN DEFAULT FALSE,
|
||||||
|
is_admin BOOLEAN DEFAULT FALSE,
|
||||||
|
earnings DECIMAL(10,2) DEFAULT 0,
|
||||||
|
pending_earnings DECIMAL(10,2) DEFAULT 0,
|
||||||
|
withdrawn_earnings DECIMAL(10,2) DEFAULT 0,
|
||||||
|
referral_count INT DEFAULT 0,
|
||||||
|
match_count_today INT DEFAULT 0,
|
||||||
|
last_match_date DATE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`)
|
||||||
|
results.push('✅ 创建users表')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 修改open_id字段允许NULL(后台添加用户时可能没有openId)
|
||||||
|
try {
|
||||||
|
await query('ALTER TABLE users MODIFY COLUMN open_id VARCHAR(100) NULL')
|
||||||
|
results.push('✅ 修改open_id允许NULL')
|
||||||
|
} catch (e: any) {
|
||||||
|
results.push(`⏭️ open_id字段: ${e.message?.includes('Duplicate') ? '已处理' : e.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 添加可能缺失的字段(用ALTER TABLE)
|
||||||
|
const columnsToAdd = [
|
||||||
|
{ name: 'password', type: 'VARCHAR(100)' },
|
||||||
|
{ name: 'session_key', type: 'VARCHAR(100)' },
|
||||||
|
{ name: 'referred_by', type: 'VARCHAR(50)' },
|
||||||
|
{ name: 'is_admin', type: 'BOOLEAN DEFAULT FALSE' },
|
||||||
|
{ name: 'match_count_today', type: 'INT DEFAULT 0' },
|
||||||
|
{ name: 'last_match_date', type: 'DATE' },
|
||||||
|
{ name: 'withdrawn_earnings', type: 'DECIMAL(10,2) DEFAULT 0' },
|
||||||
|
{ name: 'avatar', type: 'VARCHAR(500)' },
|
||||||
|
{ name: 'wechat_id', type: 'VARCHAR(100)' }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const col of columnsToAdd) {
|
||||||
|
try {
|
||||||
|
// 先检查列是否存在
|
||||||
|
const checkResult = await query(`
|
||||||
|
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = ?
|
||||||
|
`, [col.name]) as any[]
|
||||||
|
|
||||||
|
if (checkResult.length === 0) {
|
||||||
|
// 列不存在,添加
|
||||||
|
await query(`ALTER TABLE users ADD COLUMN ${col.name} ${col.type}`)
|
||||||
|
results.push(`✅ 添加字段: ${col.name}`)
|
||||||
|
} else {
|
||||||
|
results.push(`⏭️ 字段已存在: ${col.name}`)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
results.push(`⚠️ 处理字段${col.name}时出错: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 添加索引(如果不存在)
|
||||||
|
const indexesToAdd = [
|
||||||
|
{ name: 'idx_open_id', column: 'open_id' },
|
||||||
|
{ name: 'idx_phone', column: 'phone' },
|
||||||
|
{ name: 'idx_referral_code', column: 'referral_code' },
|
||||||
|
{ name: 'idx_referred_by', column: 'referred_by' }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const idx of indexesToAdd) {
|
||||||
|
try {
|
||||||
|
const checkResult = await query(`
|
||||||
|
SHOW INDEX FROM users WHERE Key_name = ?
|
||||||
|
`, [idx.name]) as any[]
|
||||||
|
|
||||||
|
if (checkResult.length === 0) {
|
||||||
|
await query(`CREATE INDEX ${idx.name} ON users(${idx.column})`)
|
||||||
|
results.push(`✅ 添加索引: ${idx.name}`)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// 忽略索引错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 检查提现记录表
|
||||||
|
try {
|
||||||
|
await query('SELECT 1 FROM withdrawals LIMIT 1')
|
||||||
|
results.push('✅ withdrawals表已存在')
|
||||||
|
} catch (e) {
|
||||||
|
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)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`)
|
||||||
|
results.push('✅ 创建withdrawals表')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 检查系统配置表
|
||||||
|
try {
|
||||||
|
await query('SELECT 1 FROM system_config LIMIT 1')
|
||||||
|
results.push('✅ system_config表已存在')
|
||||||
|
} catch (e) {
|
||||||
|
await query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS system_config (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
config_key VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
config_value JSON NOT NULL,
|
||||||
|
description VARCHAR(200),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
`)
|
||||||
|
results.push('✅ 创建system_config表')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[DB Init] 数据库升级完成')
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '数据库初始化/升级完成',
|
||||||
|
results
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DB Init] 错误:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '数据库初始化失败: ' + (error as Error).message,
|
||||||
|
results
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
219
app/api/db/migrate/route.ts
Normal file
219
app/api/db/migrate/route.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* 数据库迁移API
|
||||||
|
* 用于升级数据库结构
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - 执行数据库迁移
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}))
|
||||||
|
const { migration } = body
|
||||||
|
|
||||||
|
const results: string[] = []
|
||||||
|
|
||||||
|
// 用户表扩展字段(存客宝同步和标签)
|
||||||
|
if (!migration || migration === 'user_ckb_fields') {
|
||||||
|
const userFields = [
|
||||||
|
{ name: 'ckb_user_id', def: "VARCHAR(100) DEFAULT NULL COMMENT '存客宝用户ID'" },
|
||||||
|
{ name: 'ckb_synced_at', def: "DATETIME DEFAULT NULL COMMENT '最后同步时间'" },
|
||||||
|
{ name: 'ckb_tags', def: "JSON DEFAULT NULL COMMENT '存客宝标签'" },
|
||||||
|
{ name: 'tags', def: "JSON DEFAULT NULL COMMENT '系统标签'" },
|
||||||
|
{ name: 'source_tags', def: "JSON DEFAULT NULL COMMENT '来源标签'" },
|
||||||
|
{ name: 'merged_tags', def: "JSON DEFAULT NULL COMMENT '合并后的标签'" },
|
||||||
|
{ name: 'source', def: "VARCHAR(50) DEFAULT NULL COMMENT '用户来源'" },
|
||||||
|
{ name: 'created_by', def: "VARCHAR(100) DEFAULT NULL COMMENT '创建人'" },
|
||||||
|
{ name: 'matched_by', def: "VARCHAR(100) DEFAULT NULL COMMENT '匹配人'" }
|
||||||
|
]
|
||||||
|
|
||||||
|
let addedCount = 0
|
||||||
|
let existCount = 0
|
||||||
|
|
||||||
|
for (const field of userFields) {
|
||||||
|
try {
|
||||||
|
// 检查字段是否存在
|
||||||
|
await query(`SELECT ${field.name} FROM users LIMIT 1`)
|
||||||
|
existCount++
|
||||||
|
} catch {
|
||||||
|
// 字段不存在,添加它
|
||||||
|
try {
|
||||||
|
await query(`ALTER TABLE users ADD COLUMN ${field.name} ${field.def}`)
|
||||||
|
addedCount++
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.code !== 'ER_DUP_FIELDNAME') {
|
||||||
|
results.push(`⚠️ 添加字段 ${field.name} 失败: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addedCount > 0) {
|
||||||
|
results.push(`✅ 用户表新增 ${addedCount} 个字段`)
|
||||||
|
}
|
||||||
|
if (existCount > 0) {
|
||||||
|
results.push(`ℹ️ 用户表已有 ${existCount} 个字段存在`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户行为轨迹表
|
||||||
|
if (!migration || migration === 'user_tracks') {
|
||||||
|
try {
|
||||||
|
await query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_tracks (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(100) NOT NULL COMMENT '用户ID',
|
||||||
|
action VARCHAR(50) NOT NULL COMMENT '行为类型',
|
||||||
|
chapter_id VARCHAR(100) DEFAULT NULL COMMENT '章节ID',
|
||||||
|
target VARCHAR(200) DEFAULT NULL COMMENT '目标对象',
|
||||||
|
extra_data JSON DEFAULT NULL COMMENT '额外数据',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_action (action),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户行为轨迹表'
|
||||||
|
`)
|
||||||
|
results.push('✅ 用户行为轨迹表创建成功')
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.code === 'ER_TABLE_EXISTS_ERROR') {
|
||||||
|
results.push('ℹ️ 用户行为轨迹表已存在')
|
||||||
|
} else {
|
||||||
|
results.push('⚠️ 用户行为轨迹表创建失败: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存客宝同步记录表
|
||||||
|
if (!migration || migration === 'ckb_sync_logs') {
|
||||||
|
try {
|
||||||
|
await query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS ckb_sync_logs (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
user_id VARCHAR(100) NOT NULL COMMENT '用户ID',
|
||||||
|
phone VARCHAR(20) NOT NULL COMMENT '手机号',
|
||||||
|
action VARCHAR(50) NOT NULL COMMENT '同步动作: pull/push/full',
|
||||||
|
status VARCHAR(20) NOT NULL COMMENT '状态: success/failed',
|
||||||
|
request_data JSON DEFAULT NULL COMMENT '请求数据',
|
||||||
|
response_data JSON DEFAULT NULL COMMENT '响应数据',
|
||||||
|
error_msg TEXT DEFAULT NULL COMMENT '错误信息',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_phone (phone),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='存客宝同步日志表'
|
||||||
|
`)
|
||||||
|
results.push('✅ 存客宝同步日志表创建成功')
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.code === 'ER_TABLE_EXISTS_ERROR') {
|
||||||
|
results.push('ℹ️ 存客宝同步日志表已存在')
|
||||||
|
} else {
|
||||||
|
results.push('⚠️ 存客宝同步日志表创建失败: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户标签定义表
|
||||||
|
if (!migration || migration === 'user_tag_definitions') {
|
||||||
|
try {
|
||||||
|
await query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_tag_definitions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL UNIQUE COMMENT '标签名称',
|
||||||
|
category VARCHAR(50) NOT NULL COMMENT '标签分类: system/ckb/behavior/source',
|
||||||
|
color VARCHAR(20) DEFAULT '#38bdac' COMMENT '标签颜色',
|
||||||
|
description VARCHAR(200) DEFAULT NULL COMMENT '标签描述',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_category (category)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户标签定义表'
|
||||||
|
`)
|
||||||
|
|
||||||
|
// 插入默认标签
|
||||||
|
await query(`
|
||||||
|
INSERT IGNORE INTO user_tag_definitions (name, category, color, description) VALUES
|
||||||
|
('已购全书', 'system', '#22c55e', '购买了完整书籍'),
|
||||||
|
('VIP用户', 'system', '#eab308', 'VIP会员'),
|
||||||
|
('活跃用户', 'behavior', '#38bdac', '最近7天有访问'),
|
||||||
|
('高价值用户', 'behavior', '#f59e0b', '消费超过100元'),
|
||||||
|
('推广达人', 'behavior', '#8b5cf6', '成功推荐5人以上'),
|
||||||
|
('微信用户', 'source', '#07c160', '通过微信授权登录'),
|
||||||
|
('手动创建', 'source', '#6b7280', '后台手动创建')
|
||||||
|
`)
|
||||||
|
|
||||||
|
results.push('✅ 用户标签定义表创建成功')
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.code === 'ER_TABLE_EXISTS_ERROR') {
|
||||||
|
results.push('ℹ️ 用户标签定义表已存在')
|
||||||
|
} else {
|
||||||
|
results.push('⚠️ 用户标签定义表创建失败: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
results,
|
||||||
|
message: '数据库迁移完成'
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DB Migrate] Error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '数据库迁移失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - 获取迁移状态
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const tables: Record<string, boolean> = {}
|
||||||
|
|
||||||
|
// 检查各表是否存在
|
||||||
|
const checkTables = ['user_tracks', 'ckb_sync_logs', 'user_tag_definitions']
|
||||||
|
|
||||||
|
for (const table of checkTables) {
|
||||||
|
try {
|
||||||
|
await query(`SELECT 1 FROM ${table} LIMIT 1`)
|
||||||
|
tables[table] = true
|
||||||
|
} catch {
|
||||||
|
tables[table] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户表字段
|
||||||
|
const userFields: Record<string, boolean> = {}
|
||||||
|
const checkFields = ['ckb_user_id', 'ckb_synced_at', 'ckb_tags', 'tags', 'merged_tags']
|
||||||
|
|
||||||
|
for (const field of checkFields) {
|
||||||
|
try {
|
||||||
|
await query(`SELECT ${field} FROM users LIMIT 1`)
|
||||||
|
userFields[field] = true
|
||||||
|
} catch {
|
||||||
|
userFields[field] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
status: {
|
||||||
|
tables,
|
||||||
|
userFields,
|
||||||
|
allReady: Object.values(tables).every(v => v) && Object.values(userFields).every(v => v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DB Migrate] GET Error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '获取迁移状态失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
139
app/api/db/users/referrals/route.ts
Normal file
139
app/api/db/users/referrals/route.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* 用户绑定关系API
|
||||||
|
* 获取指定用户的所有绑定用户列表
|
||||||
|
*
|
||||||
|
* 优先从referral_bindings表查询,同时兼容users表的referred_by字段
|
||||||
|
*/
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const userId = searchParams.get('userId')
|
||||||
|
const referralCode = searchParams.get('code')
|
||||||
|
|
||||||
|
if (!userId && !referralCode) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '缺少用户ID或推广码'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果传入userId,先获取该用户的推广码
|
||||||
|
let code = referralCode
|
||||||
|
if (userId && !referralCode) {
|
||||||
|
const userRows = await query('SELECT referral_code FROM users WHERE id = ?', [userId]) as any[]
|
||||||
|
if (userRows.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '用户不存在'
|
||||||
|
}, { status: 404 })
|
||||||
|
}
|
||||||
|
code = userRows[0].referral_code
|
||||||
|
}
|
||||||
|
|
||||||
|
let referrals: any[] = []
|
||||||
|
|
||||||
|
// 1. 首先从referral_bindings表查询绑定关系
|
||||||
|
try {
|
||||||
|
const bindingsReferrals = await query(`
|
||||||
|
SELECT
|
||||||
|
rb.id as binding_id,
|
||||||
|
rb.referee_id,
|
||||||
|
rb.status as binding_status,
|
||||||
|
rb.binding_date,
|
||||||
|
rb.expiry_date,
|
||||||
|
rb.commission_amount,
|
||||||
|
u.id, u.nickname, u.avatar, u.phone, u.open_id,
|
||||||
|
u.has_full_book, u.purchased_sections,
|
||||||
|
u.created_at, u.updated_at,
|
||||||
|
DATEDIFF(rb.expiry_date, NOW()) as days_remaining
|
||||||
|
FROM referral_bindings rb
|
||||||
|
JOIN users u ON rb.referee_id = u.id
|
||||||
|
WHERE rb.referrer_id = ?
|
||||||
|
ORDER BY rb.binding_date DESC
|
||||||
|
`, [userId]) as any[]
|
||||||
|
|
||||||
|
if (bindingsReferrals.length > 0) {
|
||||||
|
referrals = bindingsReferrals
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[Referrals] referral_bindings表查询失败,使用users表')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果referral_bindings表没有数据,再从users表查询
|
||||||
|
if (referrals.length === 0 && code) {
|
||||||
|
referrals = await query(`
|
||||||
|
SELECT
|
||||||
|
id, nickname, avatar, phone, open_id,
|
||||||
|
has_full_book, purchased_sections,
|
||||||
|
created_at, updated_at,
|
||||||
|
NULL as binding_status,
|
||||||
|
NULL as binding_date,
|
||||||
|
NULL as expiry_date,
|
||||||
|
NULL as days_remaining,
|
||||||
|
NULL as commission_amount
|
||||||
|
FROM users
|
||||||
|
WHERE referred_by = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, [code]) as any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计信息
|
||||||
|
const purchasedCount = referrals.filter(r =>
|
||||||
|
r.has_full_book ||
|
||||||
|
r.binding_status === 'converted' ||
|
||||||
|
(r.purchased_sections && r.purchased_sections !== '[]')
|
||||||
|
).length
|
||||||
|
|
||||||
|
// 查询该用户的收益信息
|
||||||
|
const earningsRows = await query(`
|
||||||
|
SELECT earnings, pending_earnings, withdrawn_earnings
|
||||||
|
FROM users WHERE id = ?
|
||||||
|
`, [userId]) as any[]
|
||||||
|
|
||||||
|
const earnings = earningsRows[0] || { earnings: 0, pending_earnings: 0, withdrawn_earnings: 0 }
|
||||||
|
|
||||||
|
// 格式化返回数据
|
||||||
|
const formattedReferrals = referrals.map(r => ({
|
||||||
|
id: r.referee_id || r.id,
|
||||||
|
nickname: r.nickname || '微信用户',
|
||||||
|
avatar: r.avatar,
|
||||||
|
phone: r.phone ? r.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : null,
|
||||||
|
hasOpenId: !!r.open_id,
|
||||||
|
hasPurchased: r.has_full_book || r.binding_status === 'converted' || (r.purchased_sections && r.purchased_sections !== '[]'),
|
||||||
|
hasFullBook: !!r.has_full_book,
|
||||||
|
purchasedSections: typeof r.purchased_sections === 'string'
|
||||||
|
? JSON.parse(r.purchased_sections || '[]').length
|
||||||
|
: 0,
|
||||||
|
createdAt: r.binding_date || r.created_at,
|
||||||
|
bindingStatus: r.binding_status || 'active',
|
||||||
|
daysRemaining: r.days_remaining,
|
||||||
|
commission: parseFloat(r.commission_amount) || 0,
|
||||||
|
status: r.binding_status === 'converted' ? 'converted'
|
||||||
|
: r.has_full_book ? 'vip'
|
||||||
|
: (r.purchased_sections && r.purchased_sections !== '[]' ? 'paid' : 'active')
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
referrals: formattedReferrals,
|
||||||
|
stats: {
|
||||||
|
total: referrals.length,
|
||||||
|
purchased: purchasedCount,
|
||||||
|
free: referrals.length - purchasedCount,
|
||||||
|
earnings: parseFloat(earnings.earnings) || 0,
|
||||||
|
pendingEarnings: parseFloat(earnings.pending_earnings) || 0,
|
||||||
|
withdrawnEarnings: parseFloat(earnings.withdrawn_earnings) || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get referrals error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '获取绑定关系失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
262
app/api/db/users/route.ts
Normal file
262
app/api/db/users/route.ts
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* 用户管理API
|
||||||
|
* 提供用户的CRUD操作
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
|
||||||
|
// 生成用户ID
|
||||||
|
function generateUserId(): string {
|
||||||
|
return 'user_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成推荐码
|
||||||
|
function generateReferralCode(seed: string): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
|
const hash = seed.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||||
|
let code = 'SOUL'
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
code += chars.charAt((hash + i * 7) % chars.length)
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - 获取用户列表
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const id = searchParams.get('id')
|
||||||
|
const phone = searchParams.get('phone')
|
||||||
|
const openId = searchParams.get('openId')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取单个用户
|
||||||
|
if (id) {
|
||||||
|
const users = await query('SELECT * FROM users WHERE id = ?', [id]) as any[]
|
||||||
|
if (users.length > 0) {
|
||||||
|
return NextResponse.json({ success: true, user: users[0] })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过手机号查询
|
||||||
|
if (phone) {
|
||||||
|
const users = await query('SELECT * FROM users WHERE phone = ?', [phone]) as any[]
|
||||||
|
if (users.length > 0) {
|
||||||
|
return NextResponse.json({ success: true, user: users[0] })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过openId查询
|
||||||
|
if (openId) {
|
||||||
|
const users = await query('SELECT * FROM users WHERE open_id = ?', [openId]) as any[]
|
||||||
|
if (users.length > 0) {
|
||||||
|
return NextResponse.json({ success: true, user: users[0] })
|
||||||
|
}
|
||||||
|
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有用户
|
||||||
|
const users = await query(`
|
||||||
|
SELECT
|
||||||
|
id, open_id, nickname, phone, wechat_id, avatar,
|
||||||
|
referral_code, has_full_book, is_admin,
|
||||||
|
earnings, pending_earnings, referral_count,
|
||||||
|
match_count_today, last_match_date,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 500
|
||||||
|
`) as any[]
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
users,
|
||||||
|
total: users.length
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Users API] GET错误:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '获取用户失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - 创建用户(注册)
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { openId, phone, nickname, password, wechatId, avatar, referredBy, is_admin } = body
|
||||||
|
|
||||||
|
// 检查openId或手机号是否已存在
|
||||||
|
if (openId) {
|
||||||
|
const existing = await query('SELECT id FROM users WHERE open_id = ?', [openId]) as any[]
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// 已存在,返回现有用户
|
||||||
|
const users = await query('SELECT * FROM users WHERE open_id = ?', [openId]) as any[]
|
||||||
|
return NextResponse.json({ success: true, user: users[0], isNew: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phone) {
|
||||||
|
const existing = await query('SELECT id FROM users WHERE phone = ?', [phone]) as any[]
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return NextResponse.json({ success: false, error: '该手机号已注册' }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成用户ID和推荐码
|
||||||
|
const userId = generateUserId()
|
||||||
|
const referralCode = generateReferralCode(openId || phone || userId)
|
||||||
|
|
||||||
|
// 创建用户
|
||||||
|
await query(`
|
||||||
|
INSERT INTO users (
|
||||||
|
id, open_id, phone, nickname, password, wechat_id, avatar,
|
||||||
|
referral_code, referred_by, has_full_book, is_admin,
|
||||||
|
earnings, pending_earnings, referral_count
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE, ?, 0, 0, 0)
|
||||||
|
`, [
|
||||||
|
userId,
|
||||||
|
openId || null,
|
||||||
|
phone || null,
|
||||||
|
nickname || '用户' + userId.slice(-4),
|
||||||
|
password || null,
|
||||||
|
wechatId || null,
|
||||||
|
avatar || null,
|
||||||
|
referralCode,
|
||||||
|
referredBy || null,
|
||||||
|
is_admin || false
|
||||||
|
])
|
||||||
|
|
||||||
|
// 返回新用户
|
||||||
|
const users = await query('SELECT * FROM users WHERE id = ?', [userId]) as any[]
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
user: users[0],
|
||||||
|
isNew: true,
|
||||||
|
message: '用户创建成功'
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Users API] POST错误:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '创建用户失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT - 更新用户
|
||||||
|
*/
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { id, nickname, phone, wechatId, avatar, password, has_full_book, is_admin, purchasedSections, earnings, pending_earnings } = body
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ success: false, error: '用户ID不能为空' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建更新字段
|
||||||
|
const updates: string[] = []
|
||||||
|
const values: any[] = []
|
||||||
|
|
||||||
|
if (nickname !== undefined) {
|
||||||
|
updates.push('nickname = ?')
|
||||||
|
values.push(nickname)
|
||||||
|
}
|
||||||
|
if (phone !== undefined) {
|
||||||
|
updates.push('phone = ?')
|
||||||
|
values.push(phone)
|
||||||
|
}
|
||||||
|
if (wechatId !== undefined) {
|
||||||
|
updates.push('wechat_id = ?')
|
||||||
|
values.push(wechatId)
|
||||||
|
}
|
||||||
|
if (avatar !== undefined) {
|
||||||
|
updates.push('avatar = ?')
|
||||||
|
values.push(avatar)
|
||||||
|
}
|
||||||
|
if (password !== undefined) {
|
||||||
|
updates.push('password = ?')
|
||||||
|
values.push(password)
|
||||||
|
}
|
||||||
|
if (has_full_book !== undefined) {
|
||||||
|
updates.push('has_full_book = ?')
|
||||||
|
values.push(has_full_book)
|
||||||
|
}
|
||||||
|
if (is_admin !== undefined) {
|
||||||
|
updates.push('is_admin = ?')
|
||||||
|
values.push(is_admin)
|
||||||
|
}
|
||||||
|
if (purchasedSections !== undefined) {
|
||||||
|
updates.push('purchased_sections = ?')
|
||||||
|
values.push(JSON.stringify(purchasedSections))
|
||||||
|
}
|
||||||
|
if (earnings !== undefined) {
|
||||||
|
updates.push('earnings = ?')
|
||||||
|
values.push(earnings)
|
||||||
|
}
|
||||||
|
if (pending_earnings !== undefined) {
|
||||||
|
updates.push('pending_earnings = ?')
|
||||||
|
values.push(pending_earnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return NextResponse.json({ success: false, error: '没有需要更新的字段' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id)
|
||||||
|
await query(`UPDATE users SET ${updates.join(', ')}, updated_at = NOW() WHERE id = ?`, values)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '用户更新成功'
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Users API] PUT错误:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '更新用户失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE - 删除用户
|
||||||
|
*/
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const id = searchParams.get('id')
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ success: false, error: '用户ID不能为空' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await query('DELETE FROM users WHERE id = ?', [id])
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '用户删除成功'
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Users API] DELETE错误:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '删除用户失败: ' + (error as Error).message
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
108
app/api/distribution/auto-withdraw-config/route.ts
Normal file
108
app/api/distribution/auto-withdraw-config/route.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* 自动提现配置API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
// 内存存储(实际应用中应该存入数据库)
|
||||||
|
const autoWithdrawConfigs: Map<string, {
|
||||||
|
userId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
minAmount: number;
|
||||||
|
method: 'wechat' | 'alipay';
|
||||||
|
account: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}> = new Map();
|
||||||
|
|
||||||
|
// GET: 获取用户自动提现配置
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const userId = searchParams.get('userId');
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: '缺少用户ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = autoWithdrawConfigs.get(userId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
config: config || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: 保存/更新自动提现配置
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { userId, enabled, minAmount, method, account, name } = body;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: '缺少用户ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证参数
|
||||||
|
if (enabled) {
|
||||||
|
if (!minAmount || minAmount < 10) {
|
||||||
|
return NextResponse.json({ error: '最低提现金额不能少于10元' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!account) {
|
||||||
|
return NextResponse.json({ error: '请填写提现账号' }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json({ error: '请填写真实姓名' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const existingConfig = autoWithdrawConfigs.get(userId);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
userId,
|
||||||
|
enabled: Boolean(enabled),
|
||||||
|
minAmount: Number(minAmount) || 100,
|
||||||
|
method: method || 'wechat',
|
||||||
|
account: account || '',
|
||||||
|
name: name || '',
|
||||||
|
createdAt: existingConfig?.createdAt || now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
autoWithdrawConfigs.set(userId, config);
|
||||||
|
|
||||||
|
console.log('[AutoWithdrawConfig] 保存配置:', {
|
||||||
|
userId,
|
||||||
|
enabled: config.enabled,
|
||||||
|
minAmount: config.minAmount,
|
||||||
|
method: config.method,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
config,
|
||||||
|
message: enabled ? '自动提现已启用' : '自动提现已关闭',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AutoWithdrawConfig] 保存失败:', error);
|
||||||
|
return NextResponse.json({ error: '保存配置失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: 删除自动提现配置
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const userId = searchParams.get('userId');
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: '缺少用户ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
autoWithdrawConfigs.delete(userId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '配置已删除',
|
||||||
|
});
|
||||||
|
}
|
||||||
53
app/api/distribution/messages/route.ts
Normal file
53
app/api/distribution/messages/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 分销消息API
|
||||||
|
* 用于WebSocket轮询获取实时消息
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getMessages, clearMessages } from '@/lib/modules/distribution/websocket';
|
||||||
|
|
||||||
|
// GET: 获取用户消息
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const userId = searchParams.get('userId');
|
||||||
|
const since = searchParams.get('since');
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: '缺少用户ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messages = getMessages(userId, since || undefined);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
messages,
|
||||||
|
count: messages.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessagesAPI] 获取消息失败:', error);
|
||||||
|
return NextResponse.json({ error: '获取消息失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: 标记消息已读
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { userId, messageIds } = body;
|
||||||
|
|
||||||
|
if (!userId || !messageIds || !Array.isArray(messageIds)) {
|
||||||
|
return NextResponse.json({ error: '参数错误' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMessages(userId, messageIds);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '消息已标记为已读',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessagesAPI] 标记已读失败:', error);
|
||||||
|
return NextResponse.json({ error: '操作失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
885
app/api/distribution/route.ts
Normal file
885
app/api/distribution/route.ts
Normal file
@@ -0,0 +1,885 @@
|
|||||||
|
/**
|
||||||
|
* 分销模块API
|
||||||
|
* 功能:绑定追踪、提现管理、统计概览
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
// 模拟数据存储(实际项目应使用数据库)
|
||||||
|
let distributionBindings: Array<{
|
||||||
|
id: string;
|
||||||
|
referrerId: string;
|
||||||
|
referrerCode: string;
|
||||||
|
visitorId: string;
|
||||||
|
visitorPhone?: string;
|
||||||
|
visitorNickname?: string;
|
||||||
|
bindingTime: string;
|
||||||
|
expireTime: string;
|
||||||
|
status: 'active' | 'converted' | 'expired' | 'cancelled';
|
||||||
|
convertedAt?: string;
|
||||||
|
orderId?: string;
|
||||||
|
orderAmount?: number;
|
||||||
|
commission?: number;
|
||||||
|
source: 'link' | 'miniprogram' | 'poster' | 'qrcode';
|
||||||
|
createdAt: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
let clickRecords: Array<{
|
||||||
|
id: string;
|
||||||
|
referralCode: string;
|
||||||
|
referrerId: string;
|
||||||
|
visitorId: string;
|
||||||
|
source: string;
|
||||||
|
clickTime: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
let distributors: Array<{
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
nickname: string;
|
||||||
|
phone: string;
|
||||||
|
referralCode: string;
|
||||||
|
totalClicks: number;
|
||||||
|
totalBindings: number;
|
||||||
|
activeBindings: number;
|
||||||
|
convertedBindings: number;
|
||||||
|
expiredBindings: number;
|
||||||
|
totalEarnings: number;
|
||||||
|
pendingEarnings: number;
|
||||||
|
withdrawnEarnings: number;
|
||||||
|
autoWithdraw: boolean;
|
||||||
|
autoWithdrawThreshold: number;
|
||||||
|
autoWithdrawAccount?: {
|
||||||
|
type: 'wechat' | 'alipay';
|
||||||
|
account: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
level: 'normal' | 'silver' | 'gold' | 'diamond';
|
||||||
|
commissionRate: number;
|
||||||
|
status: 'active' | 'frozen' | 'disabled';
|
||||||
|
createdAt: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
let withdrawRecords: Array<{
|
||||||
|
id: string;
|
||||||
|
distributorId: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
amount: number;
|
||||||
|
fee: number;
|
||||||
|
actualAmount: number;
|
||||||
|
method: 'wechat' | 'alipay';
|
||||||
|
account: string;
|
||||||
|
accountName: string;
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'failed' | 'rejected';
|
||||||
|
isAuto: boolean;
|
||||||
|
paymentNo?: string;
|
||||||
|
paymentTime?: string;
|
||||||
|
reviewNote?: string;
|
||||||
|
createdAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
const BINDING_DAYS = 30;
|
||||||
|
const MIN_WITHDRAW_AMOUNT = 10;
|
||||||
|
const DEFAULT_COMMISSION_RATE = 90;
|
||||||
|
|
||||||
|
// 生成ID
|
||||||
|
function generateId(prefix: string = ''): string {
|
||||||
|
return `${prefix}${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET: 获取分销数据
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const type = searchParams.get('type') || 'overview';
|
||||||
|
const userId = searchParams.get('userId');
|
||||||
|
const page = parseInt(searchParams.get('page') || '1');
|
||||||
|
const pageSize = parseInt(searchParams.get('pageSize') || '20');
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case 'overview':
|
||||||
|
return getOverview();
|
||||||
|
|
||||||
|
case 'bindings':
|
||||||
|
return getBindings(userId, page, pageSize);
|
||||||
|
|
||||||
|
case 'my-bindings':
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: '缺少用户ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
return getMyBindings(userId);
|
||||||
|
|
||||||
|
case 'withdrawals':
|
||||||
|
return getWithdrawals(userId, page, pageSize);
|
||||||
|
|
||||||
|
case 'reminders':
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: '缺少用户ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
return getReminders(userId);
|
||||||
|
|
||||||
|
case 'distributor':
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: '缺少用户ID' }, { status: 400 });
|
||||||
|
}
|
||||||
|
return getDistributor(userId);
|
||||||
|
|
||||||
|
case 'ranking':
|
||||||
|
return getRanking();
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: '未知类型' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('分销API错误:', error);
|
||||||
|
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: 分销操作
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { action } = body;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'record_click':
|
||||||
|
return recordClick(body);
|
||||||
|
|
||||||
|
case 'convert':
|
||||||
|
return convertBinding(body);
|
||||||
|
|
||||||
|
case 'request_withdraw':
|
||||||
|
return requestWithdraw(body);
|
||||||
|
|
||||||
|
case 'set_auto_withdraw':
|
||||||
|
return setAutoWithdraw(body);
|
||||||
|
|
||||||
|
case 'process_expired':
|
||||||
|
return processExpiredBindings();
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('分销API错误:', error);
|
||||||
|
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT: 更新操作(后台管理)
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { action } = body;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'approve_withdraw':
|
||||||
|
return approveWithdraw(body);
|
||||||
|
|
||||||
|
case 'reject_withdraw':
|
||||||
|
return rejectWithdraw(body);
|
||||||
|
|
||||||
|
case 'update_distributor':
|
||||||
|
return updateDistributor(body);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: '未知操作' }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('分销API错误:', error);
|
||||||
|
return NextResponse.json({ error: '服务器错误' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 具体实现 ==========
|
||||||
|
|
||||||
|
// 获取概览数据
|
||||||
|
function getOverview() {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const weekFromNow = new Date();
|
||||||
|
weekFromNow.setDate(weekFromNow.getDate() + 7);
|
||||||
|
|
||||||
|
const overview = {
|
||||||
|
// 今日数据
|
||||||
|
todayClicks: clickRecords.filter(c => new Date(c.clickTime) >= today).length,
|
||||||
|
todayBindings: distributionBindings.filter(b => new Date(b.createdAt) >= today).length,
|
||||||
|
todayConversions: distributionBindings.filter(b =>
|
||||||
|
b.status === 'converted' && b.convertedAt && new Date(b.convertedAt) >= today
|
||||||
|
).length,
|
||||||
|
todayEarnings: distributionBindings
|
||||||
|
.filter(b => b.status === 'converted' && b.convertedAt && new Date(b.convertedAt) >= today)
|
||||||
|
.reduce((sum, b) => sum + (b.commission || 0), 0),
|
||||||
|
|
||||||
|
// 本月数据
|
||||||
|
monthClicks: clickRecords.filter(c => new Date(c.clickTime) >= monthStart).length,
|
||||||
|
monthBindings: distributionBindings.filter(b => new Date(b.createdAt) >= monthStart).length,
|
||||||
|
monthConversions: distributionBindings.filter(b =>
|
||||||
|
b.status === 'converted' && b.convertedAt && new Date(b.convertedAt) >= monthStart
|
||||||
|
).length,
|
||||||
|
monthEarnings: distributionBindings
|
||||||
|
.filter(b => b.status === 'converted' && b.convertedAt && new Date(b.convertedAt) >= monthStart)
|
||||||
|
.reduce((sum, b) => sum + (b.commission || 0), 0),
|
||||||
|
|
||||||
|
// 总计
|
||||||
|
totalClicks: clickRecords.length,
|
||||||
|
totalBindings: distributionBindings.length,
|
||||||
|
totalConversions: distributionBindings.filter(b => b.status === 'converted').length,
|
||||||
|
totalEarnings: distributionBindings
|
||||||
|
.filter(b => b.status === 'converted')
|
||||||
|
.reduce((sum, b) => sum + (b.commission || 0), 0),
|
||||||
|
|
||||||
|
// 即将过期
|
||||||
|
expiringBindings: distributionBindings.filter(b =>
|
||||||
|
b.status === 'active' &&
|
||||||
|
new Date(b.expireTime) <= weekFromNow &&
|
||||||
|
new Date(b.expireTime) > now
|
||||||
|
).length,
|
||||||
|
|
||||||
|
// 待处理提现
|
||||||
|
pendingWithdrawals: withdrawRecords.filter(w => w.status === 'pending').length,
|
||||||
|
pendingWithdrawAmount: withdrawRecords
|
||||||
|
.filter(w => w.status === 'pending')
|
||||||
|
.reduce((sum, w) => sum + w.amount, 0),
|
||||||
|
|
||||||
|
// 转化率
|
||||||
|
conversionRate: clickRecords.length > 0
|
||||||
|
? (distributionBindings.filter(b => b.status === 'converted').length / clickRecords.length * 100).toFixed(2)
|
||||||
|
: '0.00',
|
||||||
|
|
||||||
|
// 分销商数量
|
||||||
|
totalDistributors: distributors.length,
|
||||||
|
activeDistributors: distributors.filter(d => d.status === 'active').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, overview });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取绑定列表(后台)
|
||||||
|
function getBindings(userId: string | null, page: number, pageSize: number) {
|
||||||
|
let filteredBindings = [...distributionBindings];
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
filteredBindings = filteredBindings.filter(b => b.referrerId === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按创建时间倒序
|
||||||
|
filteredBindings.sort((a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = filteredBindings.length;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const paginatedBindings = filteredBindings.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
// 添加剩余天数
|
||||||
|
const now = new Date();
|
||||||
|
const bindingsWithDays = paginatedBindings.map(b => ({
|
||||||
|
...b,
|
||||||
|
daysRemaining: b.status === 'active'
|
||||||
|
? Math.max(0, Math.ceil((new Date(b.expireTime).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)))
|
||||||
|
: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
bindings: bindingsWithDays,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取我的绑定用户(分销中心)
|
||||||
|
function getMyBindings(userId: string) {
|
||||||
|
const myBindings = distributionBindings.filter(b => b.referrerId === userId);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// 按状态分类
|
||||||
|
const active = myBindings
|
||||||
|
.filter(b => b.status === 'active')
|
||||||
|
.map(b => ({
|
||||||
|
...b,
|
||||||
|
daysRemaining: Math.max(0, Math.ceil((new Date(b.expireTime).getTime() - now.getTime()) / (1000 * 60 * 60 * 24))),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.daysRemaining - b.daysRemaining); // 即将过期的排前面
|
||||||
|
|
||||||
|
const converted = myBindings.filter(b => b.status === 'converted');
|
||||||
|
const expired = myBindings.filter(b => b.status === 'expired');
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
const stats = {
|
||||||
|
totalBindings: myBindings.length,
|
||||||
|
activeCount: active.length,
|
||||||
|
convertedCount: converted.length,
|
||||||
|
expiredCount: expired.length,
|
||||||
|
expiringCount: active.filter(b => b.daysRemaining <= 7).length,
|
||||||
|
totalCommission: converted.reduce((sum, b) => sum + (b.commission || 0), 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
bindings: {
|
||||||
|
active,
|
||||||
|
converted,
|
||||||
|
expired,
|
||||||
|
},
|
||||||
|
stats,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取提现记录
|
||||||
|
function getWithdrawals(userId: string | null, page: number, pageSize: number) {
|
||||||
|
let filteredWithdrawals = [...withdrawRecords];
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
filteredWithdrawals = filteredWithdrawals.filter(w => w.userId === userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按创建时间倒序
|
||||||
|
filteredWithdrawals.sort((a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = filteredWithdrawals.length;
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
const paginatedWithdrawals = filteredWithdrawals.slice(start, start + pageSize);
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
const stats = {
|
||||||
|
pending: filteredWithdrawals.filter(w => w.status === 'pending').length,
|
||||||
|
pendingAmount: filteredWithdrawals
|
||||||
|
.filter(w => w.status === 'pending')
|
||||||
|
.reduce((sum, w) => sum + w.amount, 0),
|
||||||
|
completed: filteredWithdrawals.filter(w => w.status === 'completed').length,
|
||||||
|
completedAmount: filteredWithdrawals
|
||||||
|
.filter(w => w.status === 'completed')
|
||||||
|
.reduce((sum, w) => sum + w.amount, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
withdrawals: paginatedWithdrawals,
|
||||||
|
stats,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取提醒
|
||||||
|
function getReminders(userId: string) {
|
||||||
|
const now = new Date();
|
||||||
|
const weekFromNow = new Date();
|
||||||
|
weekFromNow.setDate(weekFromNow.getDate() + 7);
|
||||||
|
|
||||||
|
const myBindings = distributionBindings.filter(b =>
|
||||||
|
b.referrerId === userId && b.status === 'active'
|
||||||
|
);
|
||||||
|
|
||||||
|
const expiringSoon = myBindings.filter(b => {
|
||||||
|
const expireTime = new Date(b.expireTime);
|
||||||
|
return expireTime <= weekFromNow && expireTime > now;
|
||||||
|
}).map(b => ({
|
||||||
|
type: 'expiring_soon',
|
||||||
|
binding: b,
|
||||||
|
daysRemaining: Math.ceil((new Date(b.expireTime).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)),
|
||||||
|
message: `用户 ${b.visitorNickname || b.visitorPhone || '未知'} 的绑定将在 ${Math.ceil((new Date(b.expireTime).getTime() - now.getTime()) / (1000 * 60 * 60 * 24))} 天后过期`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
reminders: expiringSoon,
|
||||||
|
count: expiringSoon.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分销商信息
|
||||||
|
function getDistributor(userId: string) {
|
||||||
|
const distributor = distributors.find(d => d.userId === userId);
|
||||||
|
|
||||||
|
if (!distributor) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
distributor: null,
|
||||||
|
message: '用户尚未成为分销商',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, distributor });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取排行榜
|
||||||
|
function getRanking() {
|
||||||
|
const ranking = [...distributors]
|
||||||
|
.filter(d => d.status === 'active')
|
||||||
|
.sort((a, b) => b.totalEarnings - a.totalEarnings)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((d, index) => ({
|
||||||
|
rank: index + 1,
|
||||||
|
distributorId: d.id,
|
||||||
|
nickname: d.nickname,
|
||||||
|
totalEarnings: d.totalEarnings,
|
||||||
|
totalConversions: d.convertedBindings,
|
||||||
|
level: d.level,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, ranking });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录点击
|
||||||
|
function recordClick(body: {
|
||||||
|
referralCode: string;
|
||||||
|
referrerId: string;
|
||||||
|
visitorId: string;
|
||||||
|
visitorPhone?: string;
|
||||||
|
visitorNickname?: string;
|
||||||
|
source: 'link' | 'miniprogram' | 'poster' | 'qrcode';
|
||||||
|
}) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// 1. 记录点击
|
||||||
|
const click = {
|
||||||
|
id: generateId('click_'),
|
||||||
|
referralCode: body.referralCode,
|
||||||
|
referrerId: body.referrerId,
|
||||||
|
visitorId: body.visitorId,
|
||||||
|
source: body.source,
|
||||||
|
clickTime: now.toISOString(),
|
||||||
|
};
|
||||||
|
clickRecords.push(click);
|
||||||
|
|
||||||
|
// 2. 检查现有绑定
|
||||||
|
const existingBinding = distributionBindings.find(b =>
|
||||||
|
b.visitorId === body.visitorId &&
|
||||||
|
b.status === 'active' &&
|
||||||
|
new Date(b.expireTime) > now
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingBinding) {
|
||||||
|
// 已有有效绑定,只记录点击
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '点击已记录,用户已被其他分销商绑定',
|
||||||
|
click,
|
||||||
|
binding: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 创建新绑定
|
||||||
|
const expireDate = new Date(now);
|
||||||
|
expireDate.setDate(expireDate.getDate() + BINDING_DAYS);
|
||||||
|
|
||||||
|
const binding = {
|
||||||
|
id: generateId('bind_'),
|
||||||
|
referrerId: body.referrerId,
|
||||||
|
referrerCode: body.referralCode,
|
||||||
|
visitorId: body.visitorId,
|
||||||
|
visitorPhone: body.visitorPhone,
|
||||||
|
visitorNickname: body.visitorNickname,
|
||||||
|
bindingTime: now.toISOString(),
|
||||||
|
expireTime: expireDate.toISOString(),
|
||||||
|
status: 'active' as const,
|
||||||
|
source: body.source,
|
||||||
|
createdAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
distributionBindings.push(binding);
|
||||||
|
|
||||||
|
// 4. 更新分销商统计
|
||||||
|
const distributorIndex = distributors.findIndex(d => d.userId === body.referrerId);
|
||||||
|
if (distributorIndex !== -1) {
|
||||||
|
distributors[distributorIndex].totalClicks++;
|
||||||
|
distributors[distributorIndex].totalBindings++;
|
||||||
|
distributors[distributorIndex].activeBindings++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '点击已记录,绑定创建成功',
|
||||||
|
click,
|
||||||
|
binding,
|
||||||
|
expireTime: expireDate.toISOString(),
|
||||||
|
bindingDays: BINDING_DAYS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转化绑定(用户付款)
|
||||||
|
function convertBinding(body: {
|
||||||
|
visitorId: string;
|
||||||
|
orderId: string;
|
||||||
|
orderAmount: number;
|
||||||
|
}) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// 查找有效绑定
|
||||||
|
const bindingIndex = distributionBindings.findIndex(b =>
|
||||||
|
b.visitorId === body.visitorId &&
|
||||||
|
b.status === 'active' &&
|
||||||
|
new Date(b.expireTime) > now
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bindingIndex === -1) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '未找到有效绑定,该订单不计入分销',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding = distributionBindings[bindingIndex];
|
||||||
|
|
||||||
|
// 查找分销商
|
||||||
|
const distributorIndex = distributors.findIndex(d => d.userId === binding.referrerId);
|
||||||
|
const commissionRate = distributorIndex !== -1
|
||||||
|
? distributors[distributorIndex].commissionRate
|
||||||
|
: DEFAULT_COMMISSION_RATE;
|
||||||
|
|
||||||
|
const commission = body.orderAmount * (commissionRate / 100);
|
||||||
|
|
||||||
|
// 更新绑定
|
||||||
|
distributionBindings[bindingIndex] = {
|
||||||
|
...binding,
|
||||||
|
status: 'converted',
|
||||||
|
convertedAt: now.toISOString(),
|
||||||
|
orderId: body.orderId,
|
||||||
|
orderAmount: body.orderAmount,
|
||||||
|
commission,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新分销商
|
||||||
|
if (distributorIndex !== -1) {
|
||||||
|
distributors[distributorIndex].activeBindings--;
|
||||||
|
distributors[distributorIndex].convertedBindings++;
|
||||||
|
distributors[distributorIndex].totalEarnings += commission;
|
||||||
|
distributors[distributorIndex].pendingEarnings += commission;
|
||||||
|
|
||||||
|
// 检查是否需要自动提现
|
||||||
|
checkAutoWithdraw(distributors[distributorIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '订单转化成功',
|
||||||
|
binding: distributionBindings[bindingIndex],
|
||||||
|
commission,
|
||||||
|
referrerId: binding.referrerId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 申请提现
|
||||||
|
function requestWithdraw(body: {
|
||||||
|
userId: string;
|
||||||
|
amount: number;
|
||||||
|
method: 'wechat' | 'alipay';
|
||||||
|
account: string;
|
||||||
|
accountName: string;
|
||||||
|
}) {
|
||||||
|
// 查找分销商
|
||||||
|
const distributorIndex = distributors.findIndex(d => d.userId === body.userId);
|
||||||
|
|
||||||
|
if (distributorIndex === -1) {
|
||||||
|
return NextResponse.json({ success: false, error: '分销商不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const distributor = distributors[distributorIndex];
|
||||||
|
|
||||||
|
if (body.amount < MIN_WITHDRAW_AMOUNT) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: `最低提现金额为 ${MIN_WITHDRAW_AMOUNT} 元`
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.amount > distributor.pendingEarnings) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '提现金额超过可提现余额'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建提现记录
|
||||||
|
const withdrawal = {
|
||||||
|
id: generateId('withdraw_'),
|
||||||
|
distributorId: distributor.id,
|
||||||
|
userId: body.userId,
|
||||||
|
userName: distributor.nickname,
|
||||||
|
amount: body.amount,
|
||||||
|
fee: 0,
|
||||||
|
actualAmount: body.amount,
|
||||||
|
method: body.method,
|
||||||
|
account: body.account,
|
||||||
|
accountName: body.accountName,
|
||||||
|
status: 'pending' as const,
|
||||||
|
isAuto: false,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
withdrawRecords.push(withdrawal);
|
||||||
|
|
||||||
|
// 扣除待提现金额
|
||||||
|
distributors[distributorIndex].pendingEarnings -= body.amount;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '提现申请已提交',
|
||||||
|
withdrawal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置自动提现
|
||||||
|
function setAutoWithdraw(body: {
|
||||||
|
userId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
threshold?: number;
|
||||||
|
account?: {
|
||||||
|
type: 'wechat' | 'alipay';
|
||||||
|
account: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const distributorIndex = distributors.findIndex(d => d.userId === body.userId);
|
||||||
|
|
||||||
|
if (distributorIndex === -1) {
|
||||||
|
return NextResponse.json({ success: false, error: '分销商不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
distributors[distributorIndex] = {
|
||||||
|
...distributors[distributorIndex],
|
||||||
|
autoWithdraw: body.enabled,
|
||||||
|
autoWithdrawThreshold: body.threshold || distributors[distributorIndex].autoWithdrawThreshold,
|
||||||
|
autoWithdrawAccount: body.account || distributors[distributorIndex].autoWithdrawAccount,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: body.enabled ? '自动提现已开启' : '自动提现已关闭',
|
||||||
|
distributor: distributors[distributorIndex],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理过期绑定
|
||||||
|
function processExpiredBindings() {
|
||||||
|
const now = new Date();
|
||||||
|
let expiredCount = 0;
|
||||||
|
|
||||||
|
distributionBindings.forEach((binding, index) => {
|
||||||
|
if (binding.status === 'active' && new Date(binding.expireTime) <= now) {
|
||||||
|
distributionBindings[index].status = 'expired';
|
||||||
|
expiredCount++;
|
||||||
|
|
||||||
|
// 更新分销商统计
|
||||||
|
const distributorIndex = distributors.findIndex(d => d.userId === binding.referrerId);
|
||||||
|
if (distributorIndex !== -1) {
|
||||||
|
distributors[distributorIndex].activeBindings--;
|
||||||
|
distributors[distributorIndex].expiredBindings++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `已处理 ${expiredCount} 个过期绑定`,
|
||||||
|
expiredCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 审核通过提现
|
||||||
|
async function approveWithdraw(body: { withdrawalId: string; reviewedBy?: string }) {
|
||||||
|
const withdrawalIndex = withdrawRecords.findIndex(w => w.id === body.withdrawalId);
|
||||||
|
|
||||||
|
if (withdrawalIndex === -1) {
|
||||||
|
return NextResponse.json({ success: false, error: '提现记录不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const withdrawal = withdrawRecords[withdrawalIndex];
|
||||||
|
|
||||||
|
if (withdrawal.status !== 'pending') {
|
||||||
|
return NextResponse.json({ success: false, error: '该提现申请已处理' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态为处理中
|
||||||
|
withdrawRecords[withdrawalIndex].status = 'processing';
|
||||||
|
|
||||||
|
// 模拟打款(实际项目中调用支付接口)
|
||||||
|
try {
|
||||||
|
// 模拟延迟
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// 打款成功
|
||||||
|
withdrawRecords[withdrawalIndex] = {
|
||||||
|
...withdrawRecords[withdrawalIndex],
|
||||||
|
status: 'completed',
|
||||||
|
paymentNo: `PAY${Date.now()}`,
|
||||||
|
paymentTime: new Date().toISOString(),
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新分销商已提现金额
|
||||||
|
const distributorIndex = distributors.findIndex(d => d.userId === withdrawal.userId);
|
||||||
|
if (distributorIndex !== -1) {
|
||||||
|
distributors[distributorIndex].withdrawnEarnings += withdrawal.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '打款成功',
|
||||||
|
withdrawal: withdrawRecords[withdrawalIndex],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 打款失败,退还金额
|
||||||
|
withdrawRecords[withdrawalIndex].status = 'failed';
|
||||||
|
|
||||||
|
const distributorIndex = distributors.findIndex(d => d.userId === withdrawal.userId);
|
||||||
|
if (distributorIndex !== -1) {
|
||||||
|
distributors[distributorIndex].pendingEarnings += withdrawal.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: false, error: '打款失败' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拒绝提现
|
||||||
|
function rejectWithdraw(body: { withdrawalId: string; reason: string; reviewedBy?: string }) {
|
||||||
|
const withdrawalIndex = withdrawRecords.findIndex(w => w.id === body.withdrawalId);
|
||||||
|
|
||||||
|
if (withdrawalIndex === -1) {
|
||||||
|
return NextResponse.json({ success: false, error: '提现记录不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const withdrawal = withdrawRecords[withdrawalIndex];
|
||||||
|
|
||||||
|
if (withdrawal.status !== 'pending') {
|
||||||
|
return NextResponse.json({ success: false, error: '该提现申请已处理' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
withdrawRecords[withdrawalIndex] = {
|
||||||
|
...withdrawal,
|
||||||
|
status: 'rejected',
|
||||||
|
reviewNote: body.reason,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 退还金额
|
||||||
|
const distributorIndex = distributors.findIndex(d => d.userId === withdrawal.userId);
|
||||||
|
if (distributorIndex !== -1) {
|
||||||
|
distributors[distributorIndex].pendingEarnings += withdrawal.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '提现申请已拒绝',
|
||||||
|
withdrawal: withdrawRecords[withdrawalIndex],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新分销商信息
|
||||||
|
function updateDistributor(body: {
|
||||||
|
userId: string;
|
||||||
|
commissionRate?: number;
|
||||||
|
level?: 'normal' | 'silver' | 'gold' | 'diamond';
|
||||||
|
status?: 'active' | 'frozen' | 'disabled';
|
||||||
|
}) {
|
||||||
|
const distributorIndex = distributors.findIndex(d => d.userId === body.userId);
|
||||||
|
|
||||||
|
if (distributorIndex === -1) {
|
||||||
|
return NextResponse.json({ success: false, error: '分销商不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
distributors[distributorIndex] = {
|
||||||
|
...distributors[distributorIndex],
|
||||||
|
...(body.commissionRate !== undefined && { commissionRate: body.commissionRate }),
|
||||||
|
...(body.level && { level: body.level }),
|
||||||
|
...(body.status && { status: body.status }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '分销商信息已更新',
|
||||||
|
distributor: distributors[distributorIndex],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查自动提现
|
||||||
|
function checkAutoWithdraw(distributor: typeof distributors[0]) {
|
||||||
|
if (!distributor.autoWithdraw || !distributor.autoWithdrawAccount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distributor.pendingEarnings >= distributor.autoWithdrawThreshold) {
|
||||||
|
// 创建自动提现记录
|
||||||
|
const withdrawal = {
|
||||||
|
id: generateId('withdraw_'),
|
||||||
|
distributorId: distributor.id,
|
||||||
|
userId: distributor.userId,
|
||||||
|
userName: distributor.nickname,
|
||||||
|
amount: distributor.pendingEarnings,
|
||||||
|
fee: 0,
|
||||||
|
actualAmount: distributor.pendingEarnings,
|
||||||
|
method: distributor.autoWithdrawAccount.type,
|
||||||
|
account: distributor.autoWithdrawAccount.account,
|
||||||
|
accountName: distributor.autoWithdrawAccount.name,
|
||||||
|
status: 'processing' as const,
|
||||||
|
isAuto: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
withdrawRecords.push(withdrawal);
|
||||||
|
|
||||||
|
// 扣除待提现金额
|
||||||
|
const distributorIndex = distributors.findIndex(d => d.id === distributor.id);
|
||||||
|
if (distributorIndex !== -1) {
|
||||||
|
distributors[distributorIndex].pendingEarnings = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟打款(实际项目中调用支付接口)
|
||||||
|
processAutoWithdraw(withdrawal.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理自动提现打款
|
||||||
|
async function processAutoWithdraw(withdrawalId: string) {
|
||||||
|
const withdrawalIndex = withdrawRecords.findIndex(w => w.id === withdrawalId);
|
||||||
|
if (withdrawalIndex === -1) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 模拟延迟
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// 打款成功
|
||||||
|
const withdrawal = withdrawRecords[withdrawalIndex];
|
||||||
|
withdrawRecords[withdrawalIndex] = {
|
||||||
|
...withdrawal,
|
||||||
|
status: 'completed',
|
||||||
|
paymentNo: `AUTO_PAY${Date.now()}`,
|
||||||
|
paymentTime: new Date().toISOString(),
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新分销商已提现金额
|
||||||
|
const distributorIndex = distributors.findIndex(d => d.userId === withdrawal.userId);
|
||||||
|
if (distributorIndex !== -1) {
|
||||||
|
distributors[distributorIndex].withdrawnEarnings += withdrawal.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`自动提现成功: ${withdrawal.userName}, 金额: ¥${withdrawal.amount}`);
|
||||||
|
} catch (error) {
|
||||||
|
// 打款失败
|
||||||
|
withdrawRecords[withdrawalIndex].status = 'failed';
|
||||||
|
|
||||||
|
const withdrawal = withdrawRecords[withdrawalIndex];
|
||||||
|
const distributorIndex = distributors.findIndex(d => d.userId === withdrawal.userId);
|
||||||
|
if (distributorIndex !== -1) {
|
||||||
|
distributors[distributorIndex].pendingEarnings += withdrawal.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`自动提现失败: ${withdrawal.userName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/api/documentation/generate/route.ts
Normal file
55
app/api/documentation/generate/route.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server"
|
||||||
|
import { getDocumentationCatalog } from "@/lib/documentation/catalog"
|
||||||
|
import { captureScreenshots } from "@/lib/documentation/screenshot"
|
||||||
|
import { renderDocumentationDocx } from "@/lib/documentation/docx"
|
||||||
|
|
||||||
|
export const runtime = "nodejs"
|
||||||
|
|
||||||
|
function getBaseUrl(request: NextRequest) {
|
||||||
|
const proto = request.headers.get("x-forwarded-proto") || "http"
|
||||||
|
const host = request.headers.get("x-forwarded-host") || request.headers.get("host")
|
||||||
|
if (!host) return null
|
||||||
|
return `${proto}://${host}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthorized(request: NextRequest) {
|
||||||
|
const token = process.env.DOCUMENTATION_TOKEN
|
||||||
|
// If no token is configured, allow access (internal tool)
|
||||||
|
if (!token || token === "") return true
|
||||||
|
const header = request.headers.get("x-documentation-token")
|
||||||
|
const query = request.nextUrl.searchParams.get("token")
|
||||||
|
return header === token || query === token
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!isAuthorized(request)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = getBaseUrl(request)
|
||||||
|
if (!baseUrl) {
|
||||||
|
return NextResponse.json({ error: "Host is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pages = getDocumentationCatalog()
|
||||||
|
const screenshots = await captureScreenshots(pages, {
|
||||||
|
baseUrl,
|
||||||
|
timeoutMs: 60000,
|
||||||
|
viewport: { width: 430, height: 932 },
|
||||||
|
})
|
||||||
|
const docxBuffer = await renderDocumentationDocx(screenshots)
|
||||||
|
|
||||||
|
return new NextResponse(docxBuffer, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"Content-Disposition": `attachment; filename="app-documentation.docx"`,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/api/match/config/route.ts
Normal file
74
app/api/match/config/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 匹配配置API
|
||||||
|
* 获取匹配类型和价格配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getConfig } from '@/lib/db'
|
||||||
|
|
||||||
|
// 默认匹配配置
|
||||||
|
const DEFAULT_MATCH_CONFIG = {
|
||||||
|
matchTypes: [
|
||||||
|
{ id: 'partner', label: '创业合伙', matchLabel: '创业伙伴', icon: '⭐', matchFromDB: true, showJoinAfterMatch: false, price: 1, enabled: true },
|
||||||
|
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: '👥', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
|
||||||
|
{ id: 'mentor', label: '导师顾问', matchLabel: '商业顾问', icon: '❤️', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
|
||||||
|
{ id: 'team', label: '团队招募', matchLabel: '加入项目', icon: '🎮', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true }
|
||||||
|
],
|
||||||
|
freeMatchLimit: 3,
|
||||||
|
matchPrice: 1,
|
||||||
|
settings: {
|
||||||
|
enableFreeMatches: true,
|
||||||
|
enablePaidMatches: true,
|
||||||
|
maxMatchesPerDay: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - 获取匹配配置
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 优先从数据库读取
|
||||||
|
let config = null
|
||||||
|
try {
|
||||||
|
config = await getConfig('match_config')
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[MatchConfig] 数据库读取失败,使用默认配置')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并默认配置
|
||||||
|
const finalConfig = {
|
||||||
|
...DEFAULT_MATCH_CONFIG,
|
||||||
|
...(config || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只返回启用的匹配类型
|
||||||
|
const enabledTypes = finalConfig.matchTypes.filter((t: any) => t.enabled !== false)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
matchTypes: enabledTypes,
|
||||||
|
freeMatchLimit: finalConfig.freeMatchLimit,
|
||||||
|
matchPrice: finalConfig.matchPrice,
|
||||||
|
settings: finalConfig.settings
|
||||||
|
},
|
||||||
|
source: config ? 'database' : 'default'
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MatchConfig] GET错误:', error)
|
||||||
|
|
||||||
|
// 出错时返回默认配置
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
matchTypes: DEFAULT_MATCH_CONFIG.matchTypes,
|
||||||
|
freeMatchLimit: DEFAULT_MATCH_CONFIG.freeMatchLimit,
|
||||||
|
matchPrice: DEFAULT_MATCH_CONFIG.matchPrice,
|
||||||
|
settings: DEFAULT_MATCH_CONFIG.settings
|
||||||
|
},
|
||||||
|
source: 'fallback'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/api/match/users/route.ts
Normal file
84
app/api/match/users/route.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* 匹配用户API
|
||||||
|
* 从数据库中查询用户进行匹配
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - 获取匹配的用户
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { userId, matchType } = body
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ success: false, message: '缺少用户ID' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从数据库查询其他用户(排除自己)
|
||||||
|
// 宽松条件:只要是注册用户就可以匹配
|
||||||
|
const users = await query(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
nickname,
|
||||||
|
avatar,
|
||||||
|
wechat as wechatId,
|
||||||
|
wechat_id,
|
||||||
|
phone,
|
||||||
|
introduction,
|
||||||
|
created_at
|
||||||
|
FROM users
|
||||||
|
WHERE id != ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
`, [userId]) as any[]
|
||||||
|
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '暂无匹配用户',
|
||||||
|
data: null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 随机选择一个用户
|
||||||
|
const randomUser = users[Math.floor(Math.random() * users.length)]
|
||||||
|
|
||||||
|
// 构建匹配结果
|
||||||
|
const wechat = randomUser.wechatId || randomUser.wechat_id || ''
|
||||||
|
const matchResult = {
|
||||||
|
id: randomUser.id,
|
||||||
|
nickname: randomUser.nickname || '微信用户',
|
||||||
|
avatar: randomUser.avatar || '',
|
||||||
|
wechat: wechat,
|
||||||
|
phone: randomUser.phone ? randomUser.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '',
|
||||||
|
introduction: randomUser.introduction || '来自Soul创业派对的伙伴',
|
||||||
|
tags: ['创业者', matchType === 'partner' ? '找伙伴' :
|
||||||
|
matchType === 'investor' ? '资源对接' :
|
||||||
|
matchType === 'mentor' ? '导师顾问' : '团队招募'],
|
||||||
|
matchScore: Math.floor(Math.random() * 20) + 80,
|
||||||
|
commonInterests: [
|
||||||
|
{ icon: '📚', text: '都在读《创业派对》' },
|
||||||
|
{ icon: '💼', text: '对创业感兴趣' },
|
||||||
|
{ icon: '🎯', text: '相似的发展方向' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: matchResult,
|
||||||
|
totalUsers: users.length
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Match Users] Error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '匹配失败',
|
||||||
|
error: String(error)
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/api/menu/route.ts
Normal file
12
app/api/menu/route.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { getBookStructure } from "@/lib/book-file-system"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const structure = getBookStructure()
|
||||||
|
return NextResponse.json(structure)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating menu:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to generate menu" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
160
app/api/miniprogram/login/route.ts
Normal file
160
app/api/miniprogram/login/route.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* 小程序登录API
|
||||||
|
* 使用code换取openId和session_key
|
||||||
|
*
|
||||||
|
* 小程序配置:
|
||||||
|
* - AppID: wxb8bbb2b10dec74aa
|
||||||
|
* - AppSecret: 85d3fa31584d06acdb1de4a597d25b7b
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const MINIPROGRAM_CONFIG = {
|
||||||
|
appId: 'wxb8bbb2b10dec74aa',
|
||||||
|
appSecret: '3c1fb1f63e6e052222bbcead9d07fe0c', // 2026-01-25 修正
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - 小程序登录,获取openId
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { code } = body
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '缺少登录code'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[MiniLogin] 收到登录请求, code:', code.slice(0, 10) + '...')
|
||||||
|
|
||||||
|
// 调用微信接口获取openId
|
||||||
|
const wxUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${MINIPROGRAM_CONFIG.appId}&secret=${MINIPROGRAM_CONFIG.appSecret}&js_code=${code}&grant_type=authorization_code`
|
||||||
|
|
||||||
|
const response = await fetch(wxUrl)
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
console.log('[MiniLogin] 微信接口返回:', {
|
||||||
|
errcode: data.errcode,
|
||||||
|
errmsg: data.errmsg,
|
||||||
|
hasOpenId: !!data.openid,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data.errcode) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: `微信登录失败: ${data.errmsg || data.errcode}`
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const openId = data.openid
|
||||||
|
const sessionKey = data.session_key
|
||||||
|
const unionId = data.unionid
|
||||||
|
|
||||||
|
if (!openId) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '获取openId失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建或更新用户 - 连接数据库
|
||||||
|
let user: any = null
|
||||||
|
let isNewUser = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { query } = await import('@/lib/db')
|
||||||
|
|
||||||
|
// 查询用户是否存在
|
||||||
|
const existingUsers = await query('SELECT * FROM users WHERE open_id = ?', [openId]) as any[]
|
||||||
|
|
||||||
|
if (existingUsers.length > 0) {
|
||||||
|
// 用户已存在,更新session_key
|
||||||
|
user = existingUsers[0]
|
||||||
|
await query('UPDATE users SET session_key = ?, updated_at = NOW() WHERE open_id = ?', [sessionKey, openId])
|
||||||
|
console.log('[MiniLogin] 用户已存在:', user.id)
|
||||||
|
} else {
|
||||||
|
// 创建新用户 - 使用openId作为用户ID(与微信官方标识保持一致)
|
||||||
|
isNewUser = true
|
||||||
|
const userId = openId // 直接使用openId作为用户ID
|
||||||
|
const referralCode = 'SOUL' + openId.slice(-6).toUpperCase()
|
||||||
|
const nickname = '微信用户' + openId.slice(-4)
|
||||||
|
|
||||||
|
await query(`
|
||||||
|
INSERT INTO users (
|
||||||
|
id, open_id, session_key, nickname, avatar, referral_code,
|
||||||
|
has_full_book, purchased_sections, earnings, pending_earnings, referral_count
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, FALSE, '[]', 0, 0, 0)
|
||||||
|
`, [
|
||||||
|
userId, openId, sessionKey, nickname,
|
||||||
|
'', // 头像留空,等用户授权
|
||||||
|
referralCode
|
||||||
|
])
|
||||||
|
|
||||||
|
const newUsers = await query('SELECT * FROM users WHERE id = ?', [userId]) as any[]
|
||||||
|
user = newUsers[0]
|
||||||
|
console.log('[MiniLogin] 新用户创建成功, ID=openId:', userId.slice(0, 10) + '...')
|
||||||
|
}
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error('[MiniLogin] 数据库操作失败:', dbError)
|
||||||
|
// 数据库失败时使用openId作为临时用户ID
|
||||||
|
user = {
|
||||||
|
id: openId, // 使用openId作为用户ID
|
||||||
|
open_id: openId,
|
||||||
|
nickname: '微信用户',
|
||||||
|
avatar: '',
|
||||||
|
referral_code: 'SOUL' + openId.slice(-6).toUpperCase(),
|
||||||
|
purchased_sections: '[]',
|
||||||
|
has_full_book: false,
|
||||||
|
earnings: 0,
|
||||||
|
pending_earnings: 0,
|
||||||
|
referral_count: 0,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一用户数据格式
|
||||||
|
const responseUser = {
|
||||||
|
id: user.id,
|
||||||
|
openId: user.open_id || openId,
|
||||||
|
nickname: user.nickname,
|
||||||
|
avatar: user.avatar,
|
||||||
|
phone: user.phone,
|
||||||
|
wechatId: user.wechat_id,
|
||||||
|
referralCode: user.referral_code,
|
||||||
|
hasFullBook: user.has_full_book || false,
|
||||||
|
purchasedSections: typeof user.purchased_sections === 'string'
|
||||||
|
? JSON.parse(user.purchased_sections || '[]')
|
||||||
|
: (user.purchased_sections || []),
|
||||||
|
earnings: parseFloat(user.earnings) || 0,
|
||||||
|
pendingEarnings: parseFloat(user.pending_earnings) || 0,
|
||||||
|
referralCount: user.referral_count || 0,
|
||||||
|
createdAt: user.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成token
|
||||||
|
const token = `tk_${openId.slice(-8)}_${Date.now()}`
|
||||||
|
|
||||||
|
console.log('[MiniLogin] 登录成功, userId:', responseUser.id, isNewUser ? '(新用户)' : '(老用户)')
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
openId,
|
||||||
|
user: responseUser,
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
isNewUser
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MiniLogin] 登录失败:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '登录失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
269
app/api/miniprogram/pay/notify/route.ts
Normal file
269
app/api/miniprogram/pay/notify/route.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* 小程序支付回调通知处理
|
||||||
|
* 微信支付成功后会调用此接口
|
||||||
|
*
|
||||||
|
* 分销规则:
|
||||||
|
* - 约90%给分发者(可在system_config配置)
|
||||||
|
* - 一级分销,只算直接推荐人
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { query, getConfig } from '@/lib/db'
|
||||||
|
|
||||||
|
const WECHAT_PAY_CONFIG = {
|
||||||
|
appId: 'wxb8bbb2b10dec74aa',
|
||||||
|
mchId: '1318592501',
|
||||||
|
mchKey: 'wx3e31b068be59ddc131b068be59ddc2',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认分成比例(90%给推广者)
|
||||||
|
const DEFAULT_DISTRIBUTOR_SHARE = 0.9
|
||||||
|
|
||||||
|
// 生成签名
|
||||||
|
function generateSign(params: Record<string, string>, key: string): string {
|
||||||
|
const sortedKeys = Object.keys(params).sort()
|
||||||
|
const signString = sortedKeys
|
||||||
|
.filter((k) => params[k] && k !== 'sign')
|
||||||
|
.map((k) => `${k}=${params[k]}`)
|
||||||
|
.join('&')
|
||||||
|
|
||||||
|
const signWithKey = `${signString}&key=${key}`
|
||||||
|
return crypto.createHash('md5').update(signWithKey, 'utf8').digest('hex').toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
function verifySign(params: Record<string, string>, key: string): boolean {
|
||||||
|
const receivedSign = params.sign
|
||||||
|
if (!receivedSign) return false
|
||||||
|
|
||||||
|
const data = { ...params }
|
||||||
|
delete data.sign
|
||||||
|
|
||||||
|
const calculatedSign = generateSign(data, key)
|
||||||
|
return receivedSign === calculatedSign
|
||||||
|
}
|
||||||
|
|
||||||
|
// XML转对象
|
||||||
|
function xmlToDict(xml: string): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
const regex = /<(\w+)><!\[CDATA\[(.*?)\]\]><\/\1>/g
|
||||||
|
let match
|
||||||
|
while ((match = regex.exec(xml)) !== null) {
|
||||||
|
result[match[1]] = match[2]
|
||||||
|
}
|
||||||
|
const simpleRegex = /<(\w+)>([^<]*)<\/\1>/g
|
||||||
|
while ((match = simpleRegex.exec(xml)) !== null) {
|
||||||
|
if (!result[match[1]]) {
|
||||||
|
result[match[1]] = match[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功响应
|
||||||
|
const SUCCESS_RESPONSE = `<xml>
|
||||||
|
<return_code><![CDATA[SUCCESS]]></return_code>
|
||||||
|
<return_msg><![CDATA[OK]]></return_msg>
|
||||||
|
</xml>`
|
||||||
|
|
||||||
|
// 失败响应
|
||||||
|
const FAIL_RESPONSE = `<xml>
|
||||||
|
<return_code><![CDATA[FAIL]]></return_code>
|
||||||
|
<return_msg><![CDATA[ERROR]]></return_msg>
|
||||||
|
</xml>`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - 接收微信支付回调
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const xmlData = await request.text()
|
||||||
|
console.log('[PayNotify] 收到支付回调')
|
||||||
|
|
||||||
|
const data = xmlToDict(xmlData)
|
||||||
|
|
||||||
|
// 验证签名
|
||||||
|
if (!verifySign(data, WECHAT_PAY_CONFIG.mchKey)) {
|
||||||
|
console.error('[PayNotify] 签名验证失败')
|
||||||
|
return new Response(FAIL_RESPONSE, {
|
||||||
|
headers: { 'Content-Type': 'application/xml' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查支付结果
|
||||||
|
if (data.return_code !== 'SUCCESS' || data.result_code !== 'SUCCESS') {
|
||||||
|
console.log('[PayNotify] 支付未成功:', data.err_code, data.err_code_des)
|
||||||
|
return new Response(SUCCESS_RESPONSE, {
|
||||||
|
headers: { 'Content-Type': 'application/xml' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderSn = data.out_trade_no
|
||||||
|
const transactionId = data.transaction_id
|
||||||
|
const totalFee = parseInt(data.total_fee || '0', 10)
|
||||||
|
const totalAmount = totalFee / 100 // 转为元
|
||||||
|
const openId = data.openid
|
||||||
|
|
||||||
|
console.log('[PayNotify] 支付成功:', {
|
||||||
|
orderSn,
|
||||||
|
transactionId,
|
||||||
|
totalFee,
|
||||||
|
openId: openId?.slice(0, 10) + '...',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 解析附加数据
|
||||||
|
let attach: Record<string, string> = {}
|
||||||
|
if (data.attach) {
|
||||||
|
try {
|
||||||
|
attach = JSON.parse(data.attach)
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略解析错误
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { productType, productId, userId } = attach
|
||||||
|
|
||||||
|
// 1. 更新订单状态为已支付
|
||||||
|
try {
|
||||||
|
await query(`
|
||||||
|
UPDATE orders
|
||||||
|
SET status = 'paid',
|
||||||
|
transaction_id = ?,
|
||||||
|
pay_time = CURRENT_TIMESTAMP
|
||||||
|
WHERE order_sn = ? AND status = 'pending'
|
||||||
|
`, [transactionId, orderSn])
|
||||||
|
console.log('[PayNotify] 订单状态已更新:', orderSn)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PayNotify] 更新订单状态失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取用户信息
|
||||||
|
let buyerUserId = userId
|
||||||
|
if (!buyerUserId && openId) {
|
||||||
|
try {
|
||||||
|
const users = await query('SELECT id FROM users WHERE open_id = ?', [openId]) as any[]
|
||||||
|
if (users.length > 0) {
|
||||||
|
buyerUserId = users[0].id
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PayNotify] 获取用户信息失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 更新用户购买记录
|
||||||
|
if (buyerUserId) {
|
||||||
|
try {
|
||||||
|
if (productType === 'fullbook') {
|
||||||
|
// 全书购买
|
||||||
|
await query('UPDATE users SET has_full_book = TRUE WHERE id = ?', [buyerUserId])
|
||||||
|
console.log('[PayNotify] 用户已购全书:', buyerUserId)
|
||||||
|
} else if (productType === 'section' && productId) {
|
||||||
|
// 单章购买
|
||||||
|
await query(`
|
||||||
|
UPDATE users
|
||||||
|
SET purchased_sections = JSON_ARRAY_APPEND(
|
||||||
|
COALESCE(purchased_sections, '[]'),
|
||||||
|
'$', ?
|
||||||
|
)
|
||||||
|
WHERE id = ? AND NOT JSON_CONTAINS(COALESCE(purchased_sections, '[]'), ?)
|
||||||
|
`, [productId, buyerUserId, JSON.stringify(productId)])
|
||||||
|
console.log('[PayNotify] 用户已购章节:', buyerUserId, productId)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PayNotify] 更新用户购买记录失败:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 处理分销佣金(90%给推广者)
|
||||||
|
await processReferralCommission(buyerUserId, totalAmount, orderSn)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[PayNotify] 订单处理完成:', {
|
||||||
|
orderSn,
|
||||||
|
productType,
|
||||||
|
productId,
|
||||||
|
userId: buyerUserId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 返回成功响应给微信
|
||||||
|
return new Response(SUCCESS_RESPONSE, {
|
||||||
|
headers: { 'Content-Type': 'application/xml' }
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PayNotify] 处理回调失败:', error)
|
||||||
|
return new Response(FAIL_RESPONSE, {
|
||||||
|
headers: { 'Content-Type': 'application/xml' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理分销佣金
|
||||||
|
* 规则:约90%给分发者(一级分销)
|
||||||
|
*/
|
||||||
|
async function processReferralCommission(buyerUserId: string, amount: number, orderSn: string) {
|
||||||
|
try {
|
||||||
|
// 获取分成配置
|
||||||
|
let distributorShare = DEFAULT_DISTRIBUTOR_SHARE
|
||||||
|
try {
|
||||||
|
const config = await getConfig('referral_config')
|
||||||
|
if (config?.distributorShare) {
|
||||||
|
distributorShare = config.distributorShare / 100
|
||||||
|
}
|
||||||
|
} catch (e) { /* 使用默认配置 */ }
|
||||||
|
|
||||||
|
// 查找有效的推广绑定关系
|
||||||
|
const bindings = await query(`
|
||||||
|
SELECT rb.id, rb.referrer_id, rb.referee_id, rb.expiry_date, rb.status
|
||||||
|
FROM referral_bindings rb
|
||||||
|
WHERE rb.referee_id = ?
|
||||||
|
AND rb.status = 'active'
|
||||||
|
AND rb.expiry_date > NOW()
|
||||||
|
ORDER BY rb.binding_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, [buyerUserId]) as any[]
|
||||||
|
|
||||||
|
if (bindings.length === 0) {
|
||||||
|
console.log('[PayNotify] 用户无有效推广绑定,跳过分佣:', buyerUserId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const binding = bindings[0]
|
||||||
|
const referrerId = binding.referrer_id
|
||||||
|
|
||||||
|
// 计算佣金(90%)
|
||||||
|
const commission = Math.round(amount * distributorShare * 100) / 100
|
||||||
|
|
||||||
|
console.log('[PayNotify] 处理分佣:', {
|
||||||
|
referrerId,
|
||||||
|
buyerUserId,
|
||||||
|
amount,
|
||||||
|
commission,
|
||||||
|
shareRate: `${distributorShare * 100}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新推广者的待结算收益
|
||||||
|
await query(`
|
||||||
|
UPDATE users
|
||||||
|
SET pending_earnings = pending_earnings + ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, [commission, referrerId])
|
||||||
|
|
||||||
|
// 更新绑定记录状态为已转化
|
||||||
|
await query(`
|
||||||
|
UPDATE referral_bindings
|
||||||
|
SET status = 'converted',
|
||||||
|
conversion_date = CURRENT_TIMESTAMP,
|
||||||
|
commission_amount = ?,
|
||||||
|
order_id = (SELECT id FROM orders WHERE order_sn = ? LIMIT 1)
|
||||||
|
WHERE id = ?
|
||||||
|
`, [commission, orderSn, binding.id])
|
||||||
|
|
||||||
|
console.log('[PayNotify] 分佣完成: 推广者', referrerId, '获得', commission, '元')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PayNotify] 处理分佣失败:', error)
|
||||||
|
// 分佣失败不影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
280
app/api/miniprogram/pay/route.ts
Normal file
280
app/api/miniprogram/pay/route.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
/**
|
||||||
|
* 小程序支付API
|
||||||
|
* 对接真实微信支付接口
|
||||||
|
*
|
||||||
|
* 配置来源: 用户规则
|
||||||
|
* - 小程序AppID: wxb8bbb2b10dec74aa
|
||||||
|
* - 商户号: 1318592501
|
||||||
|
* - API密钥: wx3e31b068be59ddc131b068be59ddc2
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
// 微信支付配置 - 2026-01-25 更新
|
||||||
|
// 小程序支付绑定状态: 审核中(申请单ID: 201554696918)
|
||||||
|
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', // 支付回调地址
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成随机字符串
|
||||||
|
function generateNonceStr(): string {
|
||||||
|
return crypto.randomBytes(16).toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成签名
|
||||||
|
function generateSign(params: Record<string, string>, key: string): string {
|
||||||
|
const sortedKeys = Object.keys(params).sort()
|
||||||
|
const signString = sortedKeys
|
||||||
|
.filter((k) => params[k] && k !== 'sign')
|
||||||
|
.map((k) => `${k}=${params[k]}`)
|
||||||
|
.join('&')
|
||||||
|
|
||||||
|
const signWithKey = `${signString}&key=${key}`
|
||||||
|
return crypto.createHash('md5').update(signWithKey, 'utf8').digest('hex').toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对象转XML
|
||||||
|
function dictToXml(data: Record<string, string>): string {
|
||||||
|
const xml = ['<xml>']
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
xml.push(`<${key}><![CDATA[${value}]]></${key}>`)
|
||||||
|
}
|
||||||
|
xml.push('</xml>')
|
||||||
|
return xml.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// XML转对象
|
||||||
|
function xmlToDict(xml: string): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
const regex = /<(\w+)><!\[CDATA\[(.*?)\]\]><\/\1>/g
|
||||||
|
let match
|
||||||
|
while ((match = regex.exec(xml)) !== null) {
|
||||||
|
result[match[1]] = match[2]
|
||||||
|
}
|
||||||
|
const simpleRegex = /<(\w+)>([^<]*)<\/\1>/g
|
||||||
|
while ((match = simpleRegex.exec(xml)) !== null) {
|
||||||
|
if (!result[match[1]]) {
|
||||||
|
result[match[1]] = match[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成订单号
|
||||||
|
function generateOrderSn(): string {
|
||||||
|
const now = new Date()
|
||||||
|
const timestamp = now.getFullYear().toString() +
|
||||||
|
(now.getMonth() + 1).toString().padStart(2, '0') +
|
||||||
|
now.getDate().toString().padStart(2, '0') +
|
||||||
|
now.getHours().toString().padStart(2, '0') +
|
||||||
|
now.getMinutes().toString().padStart(2, '0') +
|
||||||
|
now.getSeconds().toString().padStart(2, '0')
|
||||||
|
const random = Math.floor(Math.random() * 1000000).toString().padStart(6, '0')
|
||||||
|
return `MP${timestamp}${random}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - 创建小程序支付订单
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { openId, productType, productId, amount, description } = body
|
||||||
|
|
||||||
|
if (!openId) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '缺少openId参数,请先登录'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '支付金额无效'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderSn = generateOrderSn()
|
||||||
|
const totalFee = Math.round(amount * 100) // 转换为分
|
||||||
|
const goodsBody = description || (productType === 'fullbook' ? '《一场Soul的创业实验》全书' : `章节购买-${productId}`)
|
||||||
|
|
||||||
|
// 获取客户端IP
|
||||||
|
const forwarded = request.headers.get('x-forwarded-for')
|
||||||
|
const clientIp = forwarded ? forwarded.split(',')[0] : '127.0.0.1'
|
||||||
|
|
||||||
|
// 构建统一下单参数
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
appid: WECHAT_PAY_CONFIG.appId,
|
||||||
|
mch_id: WECHAT_PAY_CONFIG.mchId,
|
||||||
|
nonce_str: generateNonceStr(),
|
||||||
|
body: goodsBody.slice(0, 128),
|
||||||
|
out_trade_no: orderSn,
|
||||||
|
total_fee: totalFee.toString(),
|
||||||
|
spbill_create_ip: clientIp,
|
||||||
|
notify_url: WECHAT_PAY_CONFIG.notifyUrl,
|
||||||
|
trade_type: 'JSAPI',
|
||||||
|
openid: openId,
|
||||||
|
attach: JSON.stringify({ productType, productId, userId: body.userId || '' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成签名
|
||||||
|
params.sign = generateSign(params, WECHAT_PAY_CONFIG.mchKey)
|
||||||
|
|
||||||
|
console.log('[MiniPay] 创建订单:', {
|
||||||
|
orderSn,
|
||||||
|
totalFee,
|
||||||
|
openId: openId.slice(0, 10) + '...',
|
||||||
|
productType,
|
||||||
|
productId,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 调用微信统一下单接口
|
||||||
|
const xmlData = dictToXml(params)
|
||||||
|
const response = await fetch('https://api.mch.weixin.qq.com/pay/unifiedorder', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/xml' },
|
||||||
|
body: xmlData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const responseText = await response.text()
|
||||||
|
const result = xmlToDict(responseText)
|
||||||
|
|
||||||
|
console.log('[MiniPay] 微信响应:', {
|
||||||
|
return_code: result.return_code,
|
||||||
|
result_code: result.result_code,
|
||||||
|
err_code: result.err_code,
|
||||||
|
err_code_des: result.err_code_des,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查返回结果
|
||||||
|
if (result.return_code !== 'SUCCESS') {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: `微信支付请求失败: ${result.return_msg || '未知错误'}`
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.result_code !== 'SUCCESS') {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: `微信支付失败: ${result.err_code_des || result.err_code || '未知错误'}`
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建小程序支付参数
|
||||||
|
const prepayId = result.prepay_id
|
||||||
|
if (!prepayId) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '微信支付返回数据异常'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||||
|
const nonceStr = generateNonceStr()
|
||||||
|
|
||||||
|
const payParams: Record<string, string> = {
|
||||||
|
appId: WECHAT_PAY_CONFIG.appId,
|
||||||
|
timeStamp: timestamp,
|
||||||
|
nonceStr,
|
||||||
|
package: `prepay_id=${prepayId}`,
|
||||||
|
signType: 'MD5',
|
||||||
|
}
|
||||||
|
payParams.paySign = generateSign(payParams, WECHAT_PAY_CONFIG.mchKey)
|
||||||
|
|
||||||
|
console.log('[MiniPay] 支付参数生成成功:', { orderSn, prepayId: prepayId.slice(0, 20) + '...' })
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
orderSn,
|
||||||
|
prepayId,
|
||||||
|
payParams: {
|
||||||
|
timeStamp: timestamp,
|
||||||
|
nonceStr,
|
||||||
|
package: `prepay_id=${prepayId}`,
|
||||||
|
signType: 'MD5',
|
||||||
|
paySign: payParams.paySign,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MiniPay] 创建订单失败:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '创建支付订单失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - 查询订单状态
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const orderSn = searchParams.get('orderSn')
|
||||||
|
|
||||||
|
if (!orderSn) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '缺少订单号'
|
||||||
|
}, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
appid: WECHAT_PAY_CONFIG.appId,
|
||||||
|
mch_id: WECHAT_PAY_CONFIG.mchId,
|
||||||
|
out_trade_no: orderSn,
|
||||||
|
nonce_str: generateNonceStr(),
|
||||||
|
}
|
||||||
|
params.sign = generateSign(params, WECHAT_PAY_CONFIG.mchKey)
|
||||||
|
|
||||||
|
const xmlData = dictToXml(params)
|
||||||
|
const response = await fetch('https://api.mch.weixin.qq.com/pay/orderquery', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/xml' },
|
||||||
|
body: xmlData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const responseText = await response.text()
|
||||||
|
const result = xmlToDict(responseText)
|
||||||
|
|
||||||
|
if (result.return_code !== 'SUCCESS' || result.result_code !== 'SUCCESS') {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: { status: 'unknown', orderSn }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tradeState = result.trade_state
|
||||||
|
let status = 'paying'
|
||||||
|
if (tradeState === 'SUCCESS') status = 'paid'
|
||||||
|
else if (['CLOSED', 'REVOKED', 'PAYERROR'].includes(tradeState)) status = 'failed'
|
||||||
|
else if (tradeState === 'REFUND') status = 'refunded'
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
orderSn,
|
||||||
|
transactionId: result.transaction_id,
|
||||||
|
totalFee: parseInt(result.total_fee || '0', 10),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MiniPay] 查询订单失败:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '查询订单失败'
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/api/miniprogram/phone/route.ts
Normal file
86
app/api/miniprogram/phone/route.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* 微信手机号解密API
|
||||||
|
* 获取用户手机号(需要小程序 getPhoneNumber 授权)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { query } from '@/lib/db'
|
||||||
|
|
||||||
|
const APPID = process.env.WECHAT_APPID || 'wxb8bbb2b10dec74aa'
|
||||||
|
const APPSECRET = process.env.WECHAT_APPSECRET || '3c1fb1f63e6e052222bbcead9d07fe0c'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - 解密手机号
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { code, userId } = body
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
return NextResponse.json({ success: false, message: '缺少code参数' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 获取 access_token
|
||||||
|
const tokenUrl = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${APPID}&secret=${APPSECRET}`
|
||||||
|
const tokenRes = await fetch(tokenUrl)
|
||||||
|
const tokenData = await tokenRes.json()
|
||||||
|
|
||||||
|
if (!tokenData.access_token) {
|
||||||
|
console.error('[Phone] 获取access_token失败:', tokenData)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '获取access_token失败',
|
||||||
|
error: tokenData.errmsg
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取手机号
|
||||||
|
const phoneUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${tokenData.access_token}`
|
||||||
|
const phoneRes = await fetch(phoneUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code })
|
||||||
|
})
|
||||||
|
const phoneData = await phoneRes.json()
|
||||||
|
|
||||||
|
if (phoneData.errcode !== 0) {
|
||||||
|
console.error('[Phone] 获取手机号失败:', phoneData)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '获取手机号失败',
|
||||||
|
error: phoneData.errmsg
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const phoneNumber = phoneData.phone_info?.phoneNumber || phoneData.phone_info?.purePhoneNumber
|
||||||
|
|
||||||
|
if (!phoneNumber) {
|
||||||
|
return NextResponse.json({ success: false, message: '未获取到手机号' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 如果有userId,更新到数据库
|
||||||
|
if (userId) {
|
||||||
|
try {
|
||||||
|
await query('UPDATE users SET phone = ? WHERE id = ?', [phoneNumber, userId])
|
||||||
|
console.log('[Phone] 手机号已绑定到用户:', userId)
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[Phone] 更新数据库失败,但返回手机号成功')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
phoneNumber,
|
||||||
|
countryCode: phoneData.phone_info?.countryCode || '86'
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Phone] Error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
message: '服务器错误',
|
||||||
|
error: String(error)
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
114
app/api/miniprogram/qrcode/route.ts
Normal file
114
app/api/miniprogram/qrcode/route.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// app/api/miniprogram/qrcode/route.ts
|
||||||
|
// 生成带参数的小程序码 - 绑定推荐人ID和章节ID
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const APPID = process.env.WECHAT_APPID || 'wxb8bbb2b10dec74aa'
|
||||||
|
const APPSECRET = process.env.WECHAT_APPSECRET || '3c1fb1f63e6e052222bbcead9d07fe0c'
|
||||||
|
|
||||||
|
// 简单的内存缓存
|
||||||
|
let cachedToken: { token: string; expireAt: number } | null = null
|
||||||
|
|
||||||
|
// 获取access_token(带缓存)
|
||||||
|
async function getAccessToken() {
|
||||||
|
// 检查缓存
|
||||||
|
if (cachedToken && cachedToken.expireAt > Date.now()) {
|
||||||
|
return cachedToken.token
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${APPID}&secret=${APPSECRET}`
|
||||||
|
const res = await fetch(url)
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (data.access_token) {
|
||||||
|
// 缓存token,提前5分钟过期
|
||||||
|
cachedToken = {
|
||||||
|
token: data.access_token,
|
||||||
|
expireAt: Date.now() + (data.expires_in - 300) * 1000
|
||||||
|
}
|
||||||
|
return data.access_token
|
||||||
|
}
|
||||||
|
throw new Error(data.errmsg || '获取access_token失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await req.json()
|
||||||
|
const { scene, page, width = 280, chapterId, userId } = body
|
||||||
|
|
||||||
|
// 构建scene参数
|
||||||
|
// 格式:ref=用户ID&ch=章节ID(用于分享海报)
|
||||||
|
let finalScene = scene
|
||||||
|
if (!finalScene) {
|
||||||
|
const parts = []
|
||||||
|
if (userId) parts.push(`ref=${userId.slice(0, 15)}`)
|
||||||
|
if (chapterId) parts.push(`ch=${chapterId}`)
|
||||||
|
finalScene = parts.join('&') || 'soul'
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[QRCode] 生成小程序码, scene:', finalScene)
|
||||||
|
|
||||||
|
// 获取access_token
|
||||||
|
const accessToken = await getAccessToken()
|
||||||
|
|
||||||
|
// 生成小程序码(使用无限制生成接口)
|
||||||
|
const qrcodeUrl = `https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=${accessToken}`
|
||||||
|
|
||||||
|
const qrcodeRes = await fetch(qrcodeUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
scene: finalScene.slice(0, 32), // 最多32个字符
|
||||||
|
page: page || 'pages/index/index',
|
||||||
|
width: Math.min(width, 430), // 最大430
|
||||||
|
auto_color: false,
|
||||||
|
line_color: { r: 0, g: 206, b: 209 }, // 品牌色
|
||||||
|
is_hyaline: false,
|
||||||
|
env_version: 'trial' // 体验版,正式发布后改为 release
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查响应类型
|
||||||
|
const contentType = qrcodeRes.headers.get('content-type')
|
||||||
|
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
// 返回了错误信息
|
||||||
|
const errorData = await qrcodeRes.json()
|
||||||
|
console.error('[QRCode] 生成失败:', errorData)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: errorData.errmsg || '生成小程序码失败',
|
||||||
|
errcode: errorData.errcode
|
||||||
|
}, { status: 200 }) // 返回200但success为false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回图片
|
||||||
|
const imageBuffer = await qrcodeRes.arrayBuffer()
|
||||||
|
|
||||||
|
if (imageBuffer.byteLength < 1000) {
|
||||||
|
// 图片太小,可能是错误
|
||||||
|
console.error('[QRCode] 返回的图片太小:', imageBuffer.byteLength)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '生成的小程序码无效'
|
||||||
|
}, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64 = Buffer.from(imageBuffer).toString('base64')
|
||||||
|
|
||||||
|
console.log('[QRCode] 生成成功,图片大小:', base64.length, '字符')
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
image: `data:image/png;base64,${base64}`,
|
||||||
|
scene: finalScene
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[QRCode] Error:', error)
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '生成小程序码失败: ' + String(error)
|
||||||
|
}, { status: 200 })
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/api/orders/route.ts
Normal file
67
app/api/orders/route.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 订单管理接口
|
||||||
|
* 开发: 卡若
|
||||||
|
* 技术支持: 存客宝
|
||||||
|
*
|
||||||
|
* GET /api/orders - 管理后台:返回全部订单(无 userId)
|
||||||
|
* GET /api/orders?userId= - 按用户返回订单
|
||||||
|
*/
|
||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
function rowToOrder(row: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
orderSn: row.order_sn,
|
||||||
|
userId: row.user_id,
|
||||||
|
openId: row.open_id,
|
||||||
|
productType: row.product_type,
|
||||||
|
productId: row.product_id,
|
||||||
|
amount: row.amount,
|
||||||
|
description: row.description,
|
||||||
|
status: row.status,
|
||||||
|
transactionId: row.transaction_id,
|
||||||
|
payTime: row.pay_time,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const userId = searchParams.get("userId")
|
||||||
|
|
||||||
|
let rows: Record<string, unknown>[] = []
|
||||||
|
try {
|
||||||
|
if (userId) {
|
||||||
|
rows = (await query(
|
||||||
|
"SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC",
|
||||||
|
[userId]
|
||||||
|
)) as Record<string, unknown>[]
|
||||||
|
} else {
|
||||||
|
// 管理后台:无 userId 时返回全部订单
|
||||||
|
rows = (await query(
|
||||||
|
"SELECT * FROM orders ORDER BY created_at DESC"
|
||||||
|
)) as Record<string, unknown>[]
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Karuo] Orders query error:", e)
|
||||||
|
// 表可能未初始化,返回空列表
|
||||||
|
rows = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const orders = rows.map(rowToOrder)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 0,
|
||||||
|
message: "获取成功",
|
||||||
|
data: orders,
|
||||||
|
success: true,
|
||||||
|
orders,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Karuo] Get orders error:", error)
|
||||||
|
return NextResponse.json({ code: 500, message: "服务器错误" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/api/payment/alipay/notify/route.ts
Normal file
74
app/api/payment/alipay/notify/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 支付宝回调通知 API
|
||||||
|
* 基于 Universal_Payment_Module v4.0 设计
|
||||||
|
*
|
||||||
|
* POST /api/payment/alipay/notify
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
import { PaymentFactory, SignatureError } from "@/lib/payment"
|
||||||
|
|
||||||
|
// 确保网关已注册
|
||||||
|
import "@/lib/payment/alipay"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 获取表单数据
|
||||||
|
const formData = await request.formData()
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
params[key] = value.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("[Alipay Notify] 收到回调:", {
|
||||||
|
out_trade_no: params.out_trade_no,
|
||||||
|
trade_status: params.trade_status,
|
||||||
|
total_amount: params.total_amount,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建支付宝网关
|
||||||
|
const gateway = PaymentFactory.create("alipay_wap")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 解析并验证回调数据
|
||||||
|
const notifyResult = gateway.parseNotify(params)
|
||||||
|
|
||||||
|
if (notifyResult.status === "paid") {
|
||||||
|
console.log("[Alipay Notify] 支付成功:", {
|
||||||
|
tradeSn: notifyResult.tradeSn,
|
||||||
|
platformSn: notifyResult.platformSn,
|
||||||
|
amount: notifyResult.payAmount / 100, // 转换为元
|
||||||
|
payTime: notifyResult.payTime,
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: 更新订单状态
|
||||||
|
// await OrderService.updateStatus(notifyResult.tradeSn, 'paid')
|
||||||
|
|
||||||
|
// TODO: 解锁内容/开通权限
|
||||||
|
// await ContentService.unlockForUser(notifyResult.attach?.userId, notifyResult.attach?.productId)
|
||||||
|
|
||||||
|
// TODO: 分配佣金(如果有推荐人)
|
||||||
|
// if (notifyResult.attach?.referralCode) {
|
||||||
|
// await ReferralService.distributeCommission(notifyResult)
|
||||||
|
// }
|
||||||
|
} else {
|
||||||
|
console.log("[Alipay Notify] 非支付成功状态:", notifyResult.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回成功响应
|
||||||
|
return new NextResponse(gateway.successResponse())
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SignatureError) {
|
||||||
|
console.error("[Alipay Notify] 签名验证失败")
|
||||||
|
return new NextResponse(gateway.failResponse())
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Alipay Notify] 处理失败:", error)
|
||||||
|
return new NextResponse("fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/payment/callback/route.ts
Normal file
51
app/api/payment/callback/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 支付回调接口
|
||||||
|
* 开发: 卡若
|
||||||
|
* 技术支持: 存客宝
|
||||||
|
*/
|
||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { orderId, status, transactionId, amount, paymentMethod, signature } = body
|
||||||
|
|
||||||
|
console.log("[Karuo] Payment callback received:", {
|
||||||
|
orderId,
|
||||||
|
status,
|
||||||
|
transactionId,
|
||||||
|
amount,
|
||||||
|
paymentMethod,
|
||||||
|
})
|
||||||
|
|
||||||
|
// In production:
|
||||||
|
// 1. Verify signature from payment gateway
|
||||||
|
// 2. Update order status in database
|
||||||
|
// 3. Grant user access to content
|
||||||
|
// 4. Calculate and distribute referral commission
|
||||||
|
|
||||||
|
// Mock signature verification
|
||||||
|
// const isValid = verifySignature(body, signature)
|
||||||
|
|
||||||
|
// For now, accept all callbacks
|
||||||
|
const isValid = true
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return NextResponse.json({ code: 403, message: "签名验证失败" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order status
|
||||||
|
if (status === "success") {
|
||||||
|
// Grant access
|
||||||
|
console.log("[Karuo] Payment successful, granting access for order:", orderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 0,
|
||||||
|
message: "回调处理成功",
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Karuo] Payment callback error:", error)
|
||||||
|
return NextResponse.json({ code: 500, message: "服务器错误" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/api/payment/create-order/route.ts
Normal file
129
app/api/payment/create-order/route.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* 创建支付订单 API
|
||||||
|
* 基于 Universal_Payment_Module v4.0 设计
|
||||||
|
*
|
||||||
|
* POST /api/payment/create-order
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
import {
|
||||||
|
PaymentFactory,
|
||||||
|
generateOrderSn,
|
||||||
|
generateTradeSn,
|
||||||
|
yuanToFen,
|
||||||
|
getNotifyUrl,
|
||||||
|
getReturnUrl,
|
||||||
|
} from "@/lib/payment"
|
||||||
|
|
||||||
|
// 确保网关已注册
|
||||||
|
import "@/lib/payment/alipay"
|
||||||
|
import "@/lib/payment/wechat"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { userId, type, sectionId, sectionTitle, amount, paymentMethod, referralCode } = body
|
||||||
|
|
||||||
|
// 验证必要参数
|
||||||
|
if (!userId || !type || !amount || !paymentMethod) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ code: 400, message: "缺少必要参数", data: null },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成订单号
|
||||||
|
const orderSn = generateOrderSn()
|
||||||
|
const tradeSn = generateTradeSn()
|
||||||
|
|
||||||
|
// 创建订单对象
|
||||||
|
const order = {
|
||||||
|
orderSn,
|
||||||
|
tradeSn,
|
||||||
|
userId,
|
||||||
|
type, // "section" | "fullbook"
|
||||||
|
sectionId: type === "section" ? sectionId : undefined,
|
||||||
|
sectionTitle: type === "section" ? sectionTitle : undefined,
|
||||||
|
amount,
|
||||||
|
paymentMethod, // "wechat" | "alipay" | "usdt" | "paypal"
|
||||||
|
referralCode,
|
||||||
|
status: "created",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
expireAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30分钟
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取客户端IP
|
||||||
|
const clientIp = request.headers.get("x-forwarded-for")
|
||||||
|
|| request.headers.get("x-real-ip")
|
||||||
|
|| "127.0.0.1"
|
||||||
|
|
||||||
|
// 根据支付方式创建支付网关
|
||||||
|
let paymentData = null
|
||||||
|
const goodsTitle = type === "section" ? `购买章节: ${sectionTitle}` : "购买整本书"
|
||||||
|
|
||||||
|
// 确定网关类型
|
||||||
|
let gateway: string
|
||||||
|
if (paymentMethod === "alipay") {
|
||||||
|
gateway = "alipay_wap"
|
||||||
|
} else if (paymentMethod === "wechat") {
|
||||||
|
gateway = "wechat_native"
|
||||||
|
} else {
|
||||||
|
gateway = paymentMethod
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建支付网关
|
||||||
|
const paymentGateway = PaymentFactory.create(gateway)
|
||||||
|
|
||||||
|
// 创建交易
|
||||||
|
const tradeResult = await paymentGateway.createTrade({
|
||||||
|
goodsTitle,
|
||||||
|
goodsDetail: `知识付费-书籍购买`,
|
||||||
|
tradeSn,
|
||||||
|
orderSn,
|
||||||
|
amount: yuanToFen(amount),
|
||||||
|
notifyUrl: getNotifyUrl(paymentMethod === "wechat" ? "wechat" : "alipay"),
|
||||||
|
returnUrl: getReturnUrl(orderSn),
|
||||||
|
createIp: clientIp.split(",")[0].trim(),
|
||||||
|
platformType: paymentMethod === "wechat" ? "native" : "wap",
|
||||||
|
})
|
||||||
|
|
||||||
|
paymentData = {
|
||||||
|
type: tradeResult.type,
|
||||||
|
payload: tradeResult.payload,
|
||||||
|
tradeSn: tradeResult.tradeSn,
|
||||||
|
expiration: tradeResult.expiration,
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (gatewayError) {
|
||||||
|
console.error("[Payment] Gateway error:", gatewayError)
|
||||||
|
// 如果网关创建失败,返回模拟数据(开发测试用)
|
||||||
|
paymentData = {
|
||||||
|
type: "qrcode",
|
||||||
|
payload: `mock://pay/${paymentMethod}/${orderSn}`,
|
||||||
|
tradeSn,
|
||||||
|
expiration: 1800,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 200,
|
||||||
|
message: "订单创建成功",
|
||||||
|
data: {
|
||||||
|
...order,
|
||||||
|
paymentData,
|
||||||
|
gateway, // 返回网关信息,用于前端轮询时指定
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Payment] Create order error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
code: 500,
|
||||||
|
message: error instanceof Error ? error.message : "服务器错误",
|
||||||
|
data: null,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/api/payment/methods/route.ts
Normal file
37
app/api/payment/methods/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 获取可用支付方式 API
|
||||||
|
* 基于 Universal_Payment_Module v4.0 设计
|
||||||
|
*
|
||||||
|
* GET /api/payment/methods
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { PaymentFactory } from "@/lib/payment"
|
||||||
|
|
||||||
|
// 确保网关已注册
|
||||||
|
import "@/lib/payment/alipay"
|
||||||
|
import "@/lib/payment/wechat"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const methods = PaymentFactory.getEnabledGateways()
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
data: {
|
||||||
|
methods,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Payment] Get methods error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
code: 500,
|
||||||
|
message: error instanceof Error ? error.message : "服务器错误",
|
||||||
|
data: null,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
143
app/api/payment/query/route.ts
Normal file
143
app/api/payment/query/route.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* 查询支付状态 API
|
||||||
|
* 基于 Universal_Payment_Module v4.0 设计
|
||||||
|
*
|
||||||
|
* GET /api/payment/query?tradeSn=xxx&gateway=wechat_native|alipay_wap
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
import { PaymentFactory } from "@/lib/payment"
|
||||||
|
|
||||||
|
// 确保网关已注册
|
||||||
|
import "@/lib/payment/alipay"
|
||||||
|
import "@/lib/payment/wechat"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const tradeSn = searchParams.get("tradeSn")
|
||||||
|
const gateway = searchParams.get("gateway") // 可选:指定支付网关
|
||||||
|
|
||||||
|
if (!tradeSn) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ code: 400, message: "缺少交易号", data: null },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[Payment Query] 查询交易状态:", { tradeSn, gateway })
|
||||||
|
|
||||||
|
let wechatResult = null
|
||||||
|
let alipayResult = null
|
||||||
|
|
||||||
|
// 如果指定了网关,只查询该网关
|
||||||
|
if (gateway) {
|
||||||
|
try {
|
||||||
|
const paymentGateway = PaymentFactory.create(gateway)
|
||||||
|
const result = await paymentGateway.queryTrade(tradeSn)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
data: {
|
||||||
|
tradeSn: result?.tradeSn || tradeSn,
|
||||||
|
status: result?.status || "paying",
|
||||||
|
platformSn: result?.platformSn || null,
|
||||||
|
payAmount: result?.payAmount || null,
|
||||||
|
payTime: result?.payTime || null,
|
||||||
|
gateway,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[Payment Query] ${gateway} 查询失败:`, e)
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
data: {
|
||||||
|
tradeSn,
|
||||||
|
status: "paying",
|
||||||
|
platformSn: null,
|
||||||
|
payAmount: null,
|
||||||
|
payTime: null,
|
||||||
|
gateway,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有指定网关时,先查询微信
|
||||||
|
try {
|
||||||
|
const wechatGateway = PaymentFactory.create("wechat_native")
|
||||||
|
wechatResult = await wechatGateway.queryTrade(tradeSn)
|
||||||
|
|
||||||
|
if (wechatResult && wechatResult.status === "paid") {
|
||||||
|
console.log("[Payment Query] 微信支付成功:", { tradeSn, status: wechatResult.status })
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
data: {
|
||||||
|
tradeSn: wechatResult.tradeSn,
|
||||||
|
status: wechatResult.status,
|
||||||
|
platformSn: wechatResult.platformSn,
|
||||||
|
payAmount: wechatResult.payAmount,
|
||||||
|
payTime: wechatResult.payTime,
|
||||||
|
gateway: "wechat_native",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[Payment Query] 微信查询异常:", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 再查询支付宝
|
||||||
|
try {
|
||||||
|
const alipayGateway = PaymentFactory.create("alipay_wap")
|
||||||
|
alipayResult = await alipayGateway.queryTrade(tradeSn)
|
||||||
|
|
||||||
|
if (alipayResult && alipayResult.status === "paid") {
|
||||||
|
console.log("[Payment Query] 支付宝支付成功:", { tradeSn, status: alipayResult.status })
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
data: {
|
||||||
|
tradeSn: alipayResult.tradeSn,
|
||||||
|
status: alipayResult.status,
|
||||||
|
platformSn: alipayResult.platformSn,
|
||||||
|
payAmount: alipayResult.payAmount,
|
||||||
|
payTime: alipayResult.payTime,
|
||||||
|
gateway: "alipay_wap",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[Payment Query] 支付宝查询异常:", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果都未支付,优先返回微信的状态(因为更可靠)
|
||||||
|
const result = wechatResult || alipayResult
|
||||||
|
|
||||||
|
// 返回等待支付状态
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
data: {
|
||||||
|
tradeSn,
|
||||||
|
status: result?.status || "paying",
|
||||||
|
platformSn: result?.platformSn || null,
|
||||||
|
payAmount: result?.payAmount || null,
|
||||||
|
payTime: result?.payTime || null,
|
||||||
|
gateway: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Payment Query] Error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
code: 500,
|
||||||
|
message: error instanceof Error ? error.message : "服务器错误",
|
||||||
|
data: null,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/api/payment/status/[orderSn]/route.ts
Normal file
53
app/api/payment/status/[orderSn]/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 查询订单支付状态 API
|
||||||
|
* 基于 Universal_Payment_Module v4.0 设计
|
||||||
|
*
|
||||||
|
* GET /api/payment/status/{orderSn}
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ orderSn: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { orderSn } = await params
|
||||||
|
|
||||||
|
if (!orderSn) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ code: 400, message: "缺少订单号", data: null },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 从数据库查询订单状态
|
||||||
|
// const order = await OrderService.getByOrderSn(orderSn)
|
||||||
|
|
||||||
|
// 模拟返回数据(开发测试用)
|
||||||
|
const mockOrder = {
|
||||||
|
orderSn,
|
||||||
|
status: "created", // created | paying | paid | closed | refunded
|
||||||
|
paidAmount: null,
|
||||||
|
paidAt: null,
|
||||||
|
paymentMethod: null,
|
||||||
|
tradeSn: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 200,
|
||||||
|
message: "success",
|
||||||
|
data: mockOrder,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Payment] Query status error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
code: 500,
|
||||||
|
message: error instanceof Error ? error.message : "服务器错误",
|
||||||
|
data: null,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/api/payment/verify/route.ts
Normal file
51
app/api/payment/verify/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 支付验证接口
|
||||||
|
* 开发: 卡若
|
||||||
|
* 技术支持: 存客宝
|
||||||
|
*/
|
||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { orderId, paymentMethod, transactionId } = body
|
||||||
|
|
||||||
|
if (!orderId) {
|
||||||
|
return NextResponse.json({ code: 400, message: "缺少订单号" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, verify with payment gateway API
|
||||||
|
// For now, simulate verification
|
||||||
|
console.log("[Karuo] Verifying payment:", { orderId, paymentMethod, transactionId })
|
||||||
|
|
||||||
|
// Simulate verification delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
|
|
||||||
|
// Mock verification result (95% success rate)
|
||||||
|
const isVerified = Math.random() > 0.05
|
||||||
|
|
||||||
|
if (isVerified) {
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 0,
|
||||||
|
message: "支付验证成功",
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
status: "completed",
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return NextResponse.json({
|
||||||
|
code: 1,
|
||||||
|
message: "支付未完成,请稍后再试",
|
||||||
|
data: {
|
||||||
|
orderId,
|
||||||
|
status: "pending",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Karuo] Verify payment error:", error)
|
||||||
|
return NextResponse.json({ code: 500, message: "服务器错误" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/api/payment/wechat/notify/route.ts
Normal file
74
app/api/payment/wechat/notify/route.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 微信支付回调通知 API
|
||||||
|
* 基于 Universal_Payment_Module v4.0 设计
|
||||||
|
*
|
||||||
|
* POST /api/payment/wechat/notify
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type NextRequest, NextResponse } from "next/server"
|
||||||
|
import { PaymentFactory, SignatureError } from "@/lib/payment"
|
||||||
|
|
||||||
|
// 确保网关已注册
|
||||||
|
import "@/lib/payment/wechat"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 获取XML原始数据
|
||||||
|
const xmlData = await request.text()
|
||||||
|
|
||||||
|
console.log("[Wechat Notify] 收到回调:", xmlData.slice(0, 200))
|
||||||
|
|
||||||
|
// 创建微信支付网关
|
||||||
|
const gateway = PaymentFactory.create("wechat_native")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 解析并验证回调数据
|
||||||
|
const notifyResult = gateway.parseNotify(xmlData)
|
||||||
|
|
||||||
|
if (notifyResult.status === "paid") {
|
||||||
|
console.log("[Wechat Notify] 支付成功:", {
|
||||||
|
tradeSn: notifyResult.tradeSn,
|
||||||
|
platformSn: notifyResult.platformSn,
|
||||||
|
amount: notifyResult.payAmount / 100, // 转换为元
|
||||||
|
payTime: notifyResult.payTime,
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO: 更新订单状态
|
||||||
|
// await OrderService.updateStatus(notifyResult.tradeSn, 'paid')
|
||||||
|
|
||||||
|
// TODO: 解锁内容/开通权限
|
||||||
|
// await ContentService.unlockForUser(notifyResult.attach?.userId, notifyResult.attach?.productId)
|
||||||
|
|
||||||
|
// TODO: 分配佣金(如果有推荐人)
|
||||||
|
// if (notifyResult.attach?.referralCode) {
|
||||||
|
// await ReferralService.distributeCommission(notifyResult)
|
||||||
|
// }
|
||||||
|
} else {
|
||||||
|
console.log("[Wechat Notify] 支付失败:", notifyResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回成功响应
|
||||||
|
return new NextResponse(gateway.successResponse(), {
|
||||||
|
headers: { "Content-Type": "application/xml" },
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SignatureError) {
|
||||||
|
console.error("[Wechat Notify] 签名验证失败")
|
||||||
|
return new NextResponse(gateway.failResponse(), {
|
||||||
|
headers: { "Content-Type": "application/xml" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Wechat Notify] 处理失败:", error)
|
||||||
|
|
||||||
|
// 返回失败响应
|
||||||
|
const failXml = '<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[系统错误]]></return_msg></xml>'
|
||||||
|
return new NextResponse(failXml, {
|
||||||
|
headers: { "Content-Type": "application/xml" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user