Compare commits
61 Commits
main
...
09fb67d2af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
183
app/admin/page.tsx
Normal file
183
app/admin/page.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
"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.json()
|
||||
if (usersData.success && usersData.users) {
|
||||
setUsers(usersData.users)
|
||||
}
|
||||
|
||||
// 获取订单数据
|
||||
const ordersRes = await fetch('/api/orders')
|
||||
const ordersData = await ordersRes.json()
|
||||
if (ordersData.success && ordersData.orders) {
|
||||
setPurchases(ordersData.orders)
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('加载数据失败', 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 + (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: `¥${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 ? ((totalPurchases / 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">{new Date(p.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-bold text-[#38bdac]">+¥{p.amount}</p>
|
||||
<p className="text-xs text-gray-400">{p.paymentMethod || "微信支付"}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{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())
|
||||
}
|
||||
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 })
|
||||
}
|
||||
}
|
||||
162
app/api/book/all-chapters/route.ts
Normal file
162
app/api/book/all-chapters/route.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { query } from '@/lib/db'
|
||||
|
||||
export async function GET() {
|
||||
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, '章')
|
||||
|
||||
// 格式化数据
|
||||
const allChapters = dbChapters.map((chapter: any) => ({
|
||||
id: chapter.id,
|
||||
sectionId: chapter.section_id,
|
||||
title: chapter.title,
|
||||
sectionTitle: chapter.section_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
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: allChapters,
|
||||
chapters: allChapters,
|
||||
total: allChapters.length,
|
||||
source: 'database'
|
||||
})
|
||||
}
|
||||
} catch (dbError) {
|
||||
console.log('[All Chapters API] 数据库读取失败,尝试文件读取:', (dbError 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) {
|
||||
// 添加字数估算
|
||||
const allChapters = chaptersData.map((chapter: any) => ({
|
||||
...chapter,
|
||||
words: chapter.words || Math.floor(Math.random() * 3000) + 2000
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: allChapters,
|
||||
chapters: allChapters,
|
||||
total: allChapters.length,
|
||||
source: 'file',
|
||||
path: usedPath
|
||||
})
|
||||
}
|
||||
|
||||
// 方案3: 返回默认数据
|
||||
console.log('[All Chapters API] 无法读取章节数据,返回默认数据')
|
||||
const defaultChapters = generateDefaultChapters()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: defaultChapters,
|
||||
chapters: defaultChapters,
|
||||
total: defaultChapters.length,
|
||||
source: 'default'
|
||||
})
|
||||
|
||||
} 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: '使用默认数据'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 生成默认章节数据
|
||||
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: []
|
||||
})
|
||||
}
|
||||
}
|
||||
55
app/api/book/latest-chapters/route.ts
Normal file
55
app/api/book/latest-chapters/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// app/api/book/latest-chapters/route.ts
|
||||
// 获取最新章节列表
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getBookStructure } from '@/lib/book-file-system'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const bookStructure = getBookStructure()
|
||||
|
||||
// 获取所有章节并按时间排序
|
||||
const allChapters: any[] = []
|
||||
|
||||
bookStructure.forEach((part: any) => {
|
||||
part.chapters.forEach((chapter: any) => {
|
||||
allChapters.push({
|
||||
id: chapter.slug,
|
||||
title: chapter.title,
|
||||
part: part.title,
|
||||
words: Math.floor(Math.random() * 3000) + 1500, // 模拟字数
|
||||
updateTime: getRelativeTime(new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000)),
|
||||
readTime: Math.ceil((Math.random() * 3000 + 1500) / 300)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// 取最新的3章
|
||||
const latestChapters = allChapters.slice(0, 3)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
chapters: latestChapters,
|
||||
total: allChapters.length
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('获取章节失败:', error)
|
||||
return NextResponse.json(
|
||||
{ error: '获取章节失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取相对时间
|
||||
function getRelativeTime(date: Date): string {
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (days === 0) return '今天'
|
||||
if (days === 1) return '昨天'
|
||||
if (days < 7) return `${days}天前`
|
||||
if (days < 30) return `${Math.floor(days / 7)}周前`
|
||||
return `${Math.floor(days / 30)}个月前`
|
||||
}
|
||||
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 })
|
||||
}
|
||||
}
|
||||
425
app/api/db/book/route.ts
Normal file
425
app/api/db/book/route.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* 书籍内容数据库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 sections: any[] = []
|
||||
|
||||
for (const part of bookData) {
|
||||
for (const chapter of part.chapters) {
|
||||
for (const section of chapter.sections) {
|
||||
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,
|
||||
filePath: section.filePath
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sections,
|
||||
total: sections.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 } = body
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '章节ID不能为空'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const sectionInfo = getSectionInfo(id)
|
||||
|
||||
// 更新数据库
|
||||
try {
|
||||
await query(`
|
||||
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, price, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
section_title = VALUES(section_title),
|
||||
content = VALUES(content),
|
||||
word_count = VALUES(word_count),
|
||||
price = VALUES(price),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, [
|
||||
id,
|
||||
sectionInfo?.partId || 'part-1',
|
||||
sectionInfo?.partTitle || '未分类',
|
||||
sectionInfo?.chapterId || 'chapter-1',
|
||||
sectionInfo?.chapterTitle || '未分类',
|
||||
title || sectionInfo?.section.title || '',
|
||||
content || '',
|
||||
(content || '').length,
|
||||
price ?? sectionInfo?.section.price ?? 1
|
||||
])
|
||||
} 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" },
|
||||
})
|
||||
}
|
||||
}
|
||||
65
app/api/payment/wechat/transfer/notify/route.ts
Normal file
65
app/api/payment/wechat/transfer/notify/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 微信支付 - 商家转账到零钱 结果通知
|
||||
* 文档: 开发文档/提现功能完整技术文档.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { decryptResource } from '@/lib/wechat-transfer'
|
||||
import { query } from '@/lib/db'
|
||||
|
||||
const cfg = {
|
||||
apiV3Key: process.env.WECHAT_API_V3_KEY || process.env.WECHAT_MCH_KEY || '',
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const rawBody = await request.text()
|
||||
const data = JSON.parse(rawBody) as {
|
||||
event_type?: string
|
||||
resource?: { ciphertext: string; nonce: string; associated_data: string }
|
||||
}
|
||||
if (data.event_type !== 'MCHTRANSFER.BILL.FINISHED' || !data.resource) {
|
||||
return NextResponse.json({ code: 'SUCCESS' })
|
||||
}
|
||||
const { ciphertext, nonce, associated_data } = data.resource
|
||||
const decrypted = decryptResource(
|
||||
ciphertext,
|
||||
nonce,
|
||||
associated_data,
|
||||
cfg.apiV3Key
|
||||
) as { out_bill_no?: string; state?: string; transfer_bill_no?: string }
|
||||
const outBillNo = decrypted.out_bill_no
|
||||
const state = decrypted.state
|
||||
const transferBillNo = decrypted.transfer_bill_no || ''
|
||||
if (!outBillNo) {
|
||||
return NextResponse.json({ code: 'SUCCESS' })
|
||||
}
|
||||
const rows = await query('SELECT id, user_id, amount, status FROM withdrawals WHERE id = ?', [outBillNo]) as any[]
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ code: 'SUCCESS' })
|
||||
}
|
||||
const w = rows[0]
|
||||
if (w.status !== 'processing') {
|
||||
return NextResponse.json({ code: 'SUCCESS' })
|
||||
}
|
||||
if (state === 'SUCCESS') {
|
||||
await query(`
|
||||
UPDATE withdrawals SET status = 'success', processed_at = NOW(), transaction_id = ? WHERE id = ?
|
||||
`, [transferBillNo, outBillNo])
|
||||
await query(`
|
||||
UPDATE users SET withdrawn_earnings = withdrawn_earnings + ?, pending_earnings = GREATEST(0, pending_earnings - ?) WHERE id = ?
|
||||
`, [w.amount, w.amount, w.user_id])
|
||||
} else {
|
||||
await query(`
|
||||
UPDATE withdrawals SET status = 'failed', processed_at = NOW(), error_message = ? WHERE id = ?
|
||||
`, [state || '转账失败', outBillNo])
|
||||
await query(`
|
||||
UPDATE users SET pending_earnings = pending_earnings + ? WHERE id = ?
|
||||
`, [w.amount, w.user_id])
|
||||
}
|
||||
return NextResponse.json({ code: 'SUCCESS' })
|
||||
} catch (e) {
|
||||
console.error('[WechatTransferNotify]', e)
|
||||
return NextResponse.json({ code: 'FAIL', message: '处理失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
283
app/api/referral/bind/route.ts
Normal file
283
app/api/referral/bind/route.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* 推荐码绑定API - 增强版
|
||||
*
|
||||
* 核心规则:
|
||||
* 1. 链接带ID:谁发的链接,进的人就绑谁
|
||||
* 2. 一级、一月:只有一级分销;绑定有效期一个月
|
||||
* 3. 长期不发:别人发得多,客户会被「抢走」
|
||||
* 4. 每天发:持续发的人绑定一直有效,收益越来越高
|
||||
* 5. 约90%给分发:谁发得多谁拿得多
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { query, getConfig } from '@/lib/db'
|
||||
|
||||
// 绑定有效期(天)
|
||||
const BINDING_DAYS = 30
|
||||
|
||||
/**
|
||||
* POST - 绑定推荐关系(支持抢夺机制)
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { userId, referralCode, openId, source } = body
|
||||
|
||||
// 验证参数
|
||||
const effectiveUserId = userId || (openId ? `user_${openId.slice(-8)}` : null)
|
||||
if (!effectiveUserId || !referralCode) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '用户ID和推荐码不能为空'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// 查找推荐人
|
||||
const referrers = await query(
|
||||
'SELECT id, nickname, referral_code FROM users WHERE referral_code = ?',
|
||||
[referralCode]
|
||||
) as any[]
|
||||
|
||||
if (referrers.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '推荐码无效'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const referrer = referrers[0]
|
||||
|
||||
// 不能自己推荐自己
|
||||
if (referrer.id === effectiveUserId) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '不能使用自己的推荐码'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
const users = await query(
|
||||
'SELECT id, referred_by FROM users WHERE id = ? OR open_id = ?',
|
||||
[effectiveUserId, openId || effectiveUserId]
|
||||
) as any[]
|
||||
|
||||
if (users.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '用户不存在'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const user = users[0]
|
||||
const now = new Date()
|
||||
|
||||
// 检查现有绑定关系
|
||||
const existingBindings = await query(`
|
||||
SELECT id, referrer_id, expiry_date, status
|
||||
FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active'
|
||||
ORDER BY binding_date DESC LIMIT 1
|
||||
`, [user.id]) as any[]
|
||||
|
||||
let action = 'new' // new=新绑定, renew=续期, takeover=抢夺
|
||||
let oldReferrerId = null
|
||||
|
||||
if (existingBindings.length > 0) {
|
||||
const existing = existingBindings[0]
|
||||
const expiryDate = new Date(existing.expiry_date)
|
||||
|
||||
// 同一个推荐人 - 续期
|
||||
if (existing.referrer_id === referrer.id) {
|
||||
action = 'renew'
|
||||
}
|
||||
// 不同推荐人 - 检查是否可以抢夺
|
||||
else if (expiryDate < now) {
|
||||
// 已过期,可以被抢夺
|
||||
action = 'takeover'
|
||||
oldReferrerId = existing.referrer_id
|
||||
|
||||
// 将旧绑定标记为过期
|
||||
await query(
|
||||
"UPDATE referral_bindings SET status = 'expired' WHERE id = ?",
|
||||
[existing.id]
|
||||
)
|
||||
} else {
|
||||
// 未过期,不能被抢夺
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '用户已绑定其他推荐人,绑定有效期内无法更换',
|
||||
expiryDate: expiryDate.toISOString()
|
||||
}, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// 计算新的过期时间(30天)
|
||||
const expiryDate = new Date()
|
||||
expiryDate.setDate(expiryDate.getDate() + BINDING_DAYS)
|
||||
|
||||
// 创建或更新绑定记录
|
||||
const bindingId = 'bind_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 6)
|
||||
|
||||
if (action === 'renew') {
|
||||
// 续期:更新过期时间
|
||||
await query(`
|
||||
UPDATE referral_bindings
|
||||
SET expiry_date = ?, binding_date = CURRENT_TIMESTAMP
|
||||
WHERE referee_id = ? AND referrer_id = ? AND status = 'active'
|
||||
`, [expiryDate, user.id, referrer.id])
|
||||
|
||||
console.log(`[Referral Bind] 续期: ${user.id} -> ${referrer.id}`)
|
||||
} else {
|
||||
// 新绑定或抢夺
|
||||
await query(`
|
||||
INSERT INTO referral_bindings (
|
||||
id, referrer_id, referee_id, referral_code, status, expiry_date, binding_date
|
||||
) VALUES (?, ?, ?, ?, 'active', ?, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
referrer_id = VALUES(referrer_id),
|
||||
referral_code = VALUES(referral_code),
|
||||
expiry_date = VALUES(expiry_date),
|
||||
binding_date = CURRENT_TIMESTAMP,
|
||||
status = 'active'
|
||||
`, [bindingId, referrer.id, user.id, referralCode, expiryDate])
|
||||
|
||||
// 更新用户的推荐人
|
||||
await query(
|
||||
'UPDATE users SET referred_by = ? WHERE id = ?',
|
||||
[referrer.id, user.id]
|
||||
)
|
||||
|
||||
// 更新推荐人的推广数量(仅新绑定时)
|
||||
if (action === 'new') {
|
||||
await query(
|
||||
'UPDATE users SET referral_count = referral_count + 1 WHERE id = ?',
|
||||
[referrer.id]
|
||||
)
|
||||
}
|
||||
|
||||
// 如果是抢夺,减少原推荐人的推广数量
|
||||
if (action === 'takeover' && oldReferrerId) {
|
||||
await query(
|
||||
'UPDATE users SET referral_count = GREATEST(referral_count - 1, 0) WHERE id = ?',
|
||||
[oldReferrerId]
|
||||
)
|
||||
console.log(`[Referral Bind] 抢夺: ${user.id}: ${oldReferrerId} -> ${referrer.id}`)
|
||||
} else {
|
||||
console.log(`[Referral Bind] 新绑定: ${user.id} -> ${referrer.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 记录访问日志(用于统计「通过链接进的人数」)
|
||||
try {
|
||||
await query(`
|
||||
INSERT INTO referral_visits (referrer_id, visitor_id, source, created_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, [referrer.id, user.id, source || 'miniprogram'])
|
||||
} catch (e) {
|
||||
// 访问日志表可能不存在,忽略错误
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: action === 'renew' ? '绑定已续期' : (action === 'takeover' ? '绑定已更新' : '绑定成功'),
|
||||
action,
|
||||
expiryDate: expiryDate.toISOString(),
|
||||
referrer: {
|
||||
id: referrer.id,
|
||||
nickname: referrer.nickname
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Referral Bind] 错误:', 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 userId = searchParams.get('userId')
|
||||
const referralCode = searchParams.get('referralCode')
|
||||
|
||||
try {
|
||||
if (referralCode) {
|
||||
// 查询推荐码对应的用户
|
||||
const users = await query(
|
||||
'SELECT id, nickname, avatar FROM users WHERE referral_code = ?',
|
||||
[referralCode]
|
||||
) as any[]
|
||||
|
||||
if (users.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '推荐码无效'
|
||||
}, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
referrer: users[0]
|
||||
})
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
// 查询用户的推荐关系
|
||||
const users = await query(
|
||||
'SELECT id, referred_by FROM users WHERE id = ?',
|
||||
[userId]
|
||||
) as any[]
|
||||
|
||||
if (users.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '用户不存在'
|
||||
}, { status: 404 })
|
||||
}
|
||||
|
||||
const user = users[0]
|
||||
|
||||
// 如果有推荐人,获取推荐人信息
|
||||
let referrer = null
|
||||
if (user.referred_by) {
|
||||
const referrers = await query(
|
||||
'SELECT id, nickname, avatar FROM users WHERE id = ?',
|
||||
[user.referred_by]
|
||||
) as any[]
|
||||
if (referrers.length > 0) {
|
||||
referrer = referrers[0]
|
||||
}
|
||||
}
|
||||
|
||||
// 获取该用户推荐的人
|
||||
const referees = await query(
|
||||
'SELECT id, nickname, avatar, created_at FROM users WHERE referred_by = ?',
|
||||
[userId]
|
||||
) as any[]
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
referrer,
|
||||
referees,
|
||||
referralCount: referees.length
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '请提供userId或referralCode参数'
|
||||
}, { status: 400 })
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Referral Bind] GET错误:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '查询失败: ' + (error as Error).message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
262
app/api/referral/data/route.ts
Normal file
262
app/api/referral/data/route.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 分销数据API - 增强版
|
||||
*
|
||||
* 可见数据:
|
||||
* - 绑定用户数(当前有效绑定)
|
||||
* - 通过链接进的人数(总访问量)
|
||||
* - 带来的付款人数(已转化购买)
|
||||
* - 收益统计(90%归分发者)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { query, getConfig } from '@/lib/db'
|
||||
|
||||
// 分成比例(默认90%给推广者)
|
||||
const DISTRIBUTOR_SHARE = 0.9
|
||||
|
||||
/**
|
||||
* GET - 获取分销数据
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const userId = searchParams.get('userId')
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '用户ID不能为空'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取分销配置
|
||||
let distributorShare = DISTRIBUTOR_SHARE
|
||||
try {
|
||||
const config = await getConfig('referral_config')
|
||||
if (config?.distributorShare) {
|
||||
distributorShare = config.distributorShare / 100
|
||||
}
|
||||
} catch (e) { /* 使用默认配置 */ }
|
||||
|
||||
// 1. 获取用户基本信息
|
||||
const users = await query(`
|
||||
SELECT id, nickname, referral_code, earnings, pending_earnings,
|
||||
withdrawn_earnings, referral_count
|
||||
FROM users WHERE id = ?
|
||||
`, [userId]) as any[]
|
||||
|
||||
if (users.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '用户不存在'
|
||||
}, { status: 404 })
|
||||
}
|
||||
|
||||
const user = users[0]
|
||||
|
||||
// 2. 获取绑定关系统计(从referral_bindings表)
|
||||
let bindingStats = { total: 0, active: 0, converted: 0, expired: 0 }
|
||||
try {
|
||||
const bindings = await query(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'active' AND expiry_date > NOW() THEN 1 ELSE 0 END) as active,
|
||||
SUM(CASE WHEN status = 'converted' THEN 1 ELSE 0 END) as converted,
|
||||
SUM(CASE WHEN status = 'expired' OR (status = 'active' AND expiry_date <= NOW()) THEN 1 ELSE 0 END) as expired
|
||||
FROM referral_bindings
|
||||
WHERE referrer_id = ?
|
||||
`, [userId]) as any[]
|
||||
|
||||
if (bindings.length > 0) {
|
||||
bindingStats = {
|
||||
total: parseInt(bindings[0].total) || 0,
|
||||
active: parseInt(bindings[0].active) || 0,
|
||||
converted: parseInt(bindings[0].converted) || 0,
|
||||
expired: parseInt(bindings[0].expired) || 0
|
||||
}
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
|
||||
// 3. 获取通过链接进入的总人数(访问日志)
|
||||
let totalVisits = 0
|
||||
try {
|
||||
const visits = await query(`
|
||||
SELECT COUNT(DISTINCT visitor_id) as count
|
||||
FROM referral_visits
|
||||
WHERE referrer_id = ?
|
||||
`, [userId]) as any[]
|
||||
totalVisits = parseInt(visits[0]?.count) || 0
|
||||
} catch (e) { /* 访问记录表可能不存在 */ }
|
||||
|
||||
// 如果没有访问记录表,用绑定总数替代
|
||||
if (totalVisits === 0) {
|
||||
totalVisits = bindingStats.total
|
||||
}
|
||||
|
||||
// 4. 获取带来的付款人数和金额
|
||||
let paymentStats = { paidCount: 0, totalAmount: 0 }
|
||||
try {
|
||||
const payments = await query(`
|
||||
SELECT
|
||||
COUNT(DISTINCT o.user_id) as paid_count,
|
||||
COALESCE(SUM(o.amount), 0) as total_amount
|
||||
FROM orders o
|
||||
JOIN referral_bindings rb ON o.user_id = rb.referee_id
|
||||
WHERE rb.referrer_id = ? AND o.status = 'paid'
|
||||
`, [userId]) as any[]
|
||||
|
||||
if (payments.length > 0) {
|
||||
paymentStats = {
|
||||
paidCount: parseInt(payments[0].paid_count) || 0,
|
||||
totalAmount: parseFloat(payments[0].total_amount) || 0
|
||||
}
|
||||
}
|
||||
} catch (e) { /* 忽略 */ }
|
||||
|
||||
// 5. 获取活跃绑定用户列表
|
||||
const activeBindings = await query(`
|
||||
SELECT rb.id, rb.referee_id, rb.expiry_date, rb.binding_date,
|
||||
u.nickname, u.avatar, u.has_full_book,
|
||||
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 = ? AND rb.status = 'active' AND rb.expiry_date > NOW()
|
||||
ORDER BY rb.binding_date DESC
|
||||
LIMIT 50
|
||||
`, [userId]) as any[]
|
||||
|
||||
// 6. 获取已转化用户列表
|
||||
const convertedBindings = await query(`
|
||||
SELECT rb.id, rb.referee_id, rb.conversion_date, rb.commission_amount,
|
||||
u.nickname, u.avatar,
|
||||
(SELECT COALESCE(SUM(amount), 0) FROM orders WHERE user_id = rb.referee_id AND status = 'paid') as order_amount
|
||||
FROM referral_bindings rb
|
||||
JOIN users u ON rb.referee_id = u.id
|
||||
WHERE rb.referrer_id = ? AND rb.status = 'converted'
|
||||
ORDER BY rb.conversion_date DESC
|
||||
LIMIT 50
|
||||
`, [userId]) as any[]
|
||||
|
||||
// 6.5 获取已过期用户列表
|
||||
const expiredBindings = await query(`
|
||||
SELECT rb.id, rb.referee_id, rb.expiry_date, rb.binding_date,
|
||||
u.nickname, u.avatar
|
||||
FROM referral_bindings rb
|
||||
JOIN users u ON rb.referee_id = u.id
|
||||
WHERE rb.referrer_id = ? AND (rb.status = 'expired' OR (rb.status = 'active' AND rb.expiry_date <= NOW()))
|
||||
ORDER BY rb.expiry_date DESC
|
||||
LIMIT 50
|
||||
`, [userId]) as any[]
|
||||
|
||||
// 7. 获取收益明细
|
||||
let earningsDetails: any[] = []
|
||||
try {
|
||||
earningsDetails = await query(`
|
||||
SELECT o.id, o.order_sn, o.amount, o.product_type, o.pay_time,
|
||||
u.nickname as buyer_nickname,
|
||||
rb.commission_amount
|
||||
FROM orders o
|
||||
JOIN users u ON o.user_id = u.id
|
||||
JOIN referral_bindings rb ON o.user_id = rb.referee_id AND rb.referrer_id = ?
|
||||
WHERE o.status = 'paid'
|
||||
ORDER BY o.pay_time DESC
|
||||
LIMIT 30
|
||||
`, [userId]) as any[]
|
||||
} catch (e) { /* 忽略 */ }
|
||||
|
||||
// 8. 计算预估收益
|
||||
const estimatedEarnings = paymentStats.totalAmount * distributorShare
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
// === 核心可见数据 ===
|
||||
// 绑定用户数(当前有效绑定)
|
||||
bindingCount: bindingStats.active,
|
||||
// 通过链接进的人数
|
||||
visitCount: totalVisits,
|
||||
// 带来的付款人数
|
||||
paidCount: paymentStats.paidCount,
|
||||
// 已过期用户数
|
||||
expiredCount: bindingStats.expired,
|
||||
|
||||
// === 收益数据 ===
|
||||
// 已结算收益
|
||||
earnings: parseFloat(user.earnings) || 0,
|
||||
// 待结算收益
|
||||
pendingEarnings: parseFloat(user.pending_earnings) || 0,
|
||||
// 已提现金额
|
||||
withdrawnEarnings: parseFloat(user.withdrawn_earnings) || 0,
|
||||
// 预估总收益
|
||||
estimatedEarnings: Math.round(estimatedEarnings * 100) / 100,
|
||||
// 分成比例
|
||||
shareRate: Math.round(distributorShare * 100),
|
||||
|
||||
// === 推荐码 ===
|
||||
referralCode: user.referral_code,
|
||||
referralCount: user.referral_count || bindingStats.total,
|
||||
|
||||
// === 详细统计 ===
|
||||
stats: {
|
||||
totalBindings: bindingStats.total,
|
||||
activeBindings: bindingStats.active,
|
||||
convertedBindings: bindingStats.converted,
|
||||
expiredBindings: bindingStats.expired,
|
||||
// 即将过期(7天内)
|
||||
expiringCount: activeBindings.filter((b: any) => b.days_remaining <= 7 && b.days_remaining > 0).length,
|
||||
// 总支付金额
|
||||
totalPaymentAmount: paymentStats.totalAmount
|
||||
},
|
||||
|
||||
// === 用户列表 ===
|
||||
activeUsers: activeBindings.map((b: any) => ({
|
||||
id: b.referee_id,
|
||||
nickname: b.nickname || '用户' + b.referee_id.slice(-4),
|
||||
avatar: b.avatar,
|
||||
daysRemaining: Math.max(0, b.days_remaining),
|
||||
hasFullBook: b.has_full_book,
|
||||
bindingDate: b.binding_date,
|
||||
status: 'active'
|
||||
})),
|
||||
|
||||
convertedUsers: convertedBindings.map((b: any) => ({
|
||||
id: b.referee_id,
|
||||
nickname: b.nickname || '用户' + b.referee_id.slice(-4),
|
||||
avatar: b.avatar,
|
||||
commission: parseFloat(b.commission_amount) || 0,
|
||||
orderAmount: parseFloat(b.order_amount) || 0,
|
||||
conversionDate: b.conversion_date,
|
||||
status: 'converted'
|
||||
})),
|
||||
|
||||
// 已过期用户列表
|
||||
expiredUsers: expiredBindings.map((b: any) => ({
|
||||
id: b.referee_id,
|
||||
nickname: b.nickname || '用户' + b.referee_id.slice(-4),
|
||||
avatar: b.avatar,
|
||||
bindingDate: b.binding_date,
|
||||
expiryDate: b.expiry_date,
|
||||
status: 'expired'
|
||||
})),
|
||||
|
||||
// === 收益明细 ===
|
||||
earningsDetails: earningsDetails.map((e: any) => ({
|
||||
id: e.id,
|
||||
orderSn: e.order_sn,
|
||||
amount: parseFloat(e.amount),
|
||||
commission: parseFloat(e.commission_amount) || parseFloat(e.amount) * distributorShare,
|
||||
productType: e.product_type,
|
||||
buyerNickname: e.buyer_nickname,
|
||||
payTime: e.pay_time
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ReferralData] 错误:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '获取分销数据失败: ' + (error as Error).message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
100
app/api/referral/visit/route.ts
Normal file
100
app/api/referral/visit/route.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 推荐访问记录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 { referralCode, visitorOpenId, visitorId, source, page } = body
|
||||
|
||||
if (!referralCode) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '推荐码不能为空'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// 查找推荐人
|
||||
const referrers = await query(
|
||||
'SELECT id FROM users WHERE referral_code = ?',
|
||||
[referralCode]
|
||||
) as any[]
|
||||
|
||||
if (referrers.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '推荐码无效'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const referrerId = referrers[0].id
|
||||
|
||||
// 记录访问(允许重复访问记录,用于统计总访问次数)
|
||||
try {
|
||||
await query(`
|
||||
INSERT INTO referral_visits (
|
||||
referrer_id, visitor_id, visitor_openid, source, page, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, [
|
||||
referrerId,
|
||||
visitorId || null,
|
||||
visitorOpenId || null,
|
||||
source || 'miniprogram',
|
||||
page || ''
|
||||
])
|
||||
|
||||
console.log(`[Referral Visit] 记录访问: 推荐人=${referrerId}, 访客openId=${visitorOpenId?.slice(0,10) || 'unknown'}`)
|
||||
} catch (insertError) {
|
||||
// 表可能不存在,尝试创建
|
||||
console.log('[Referral Visit] 插入失败,尝试创建表...')
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS referral_visits (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
referrer_id VARCHAR(50) NOT NULL COMMENT '推广者ID',
|
||||
visitor_id VARCHAR(50) COMMENT '访客ID(可能为空)',
|
||||
visitor_openid VARCHAR(100) COMMENT '访客openId',
|
||||
source VARCHAR(50) DEFAULT 'miniprogram' COMMENT '来源:miniprogram/web/share',
|
||||
page VARCHAR(200) COMMENT '落地页路径',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_referrer_id (referrer_id),
|
||||
INDEX idx_visitor_id (visitor_id),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`)
|
||||
|
||||
// 重试插入
|
||||
await query(`
|
||||
INSERT INTO referral_visits (
|
||||
referrer_id, visitor_id, visitor_openid, source, page, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, [
|
||||
referrerId,
|
||||
visitorId || null,
|
||||
visitorOpenId || null,
|
||||
source || 'miniprogram',
|
||||
page || ''
|
||||
])
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '访问已记录'
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Referral Visit] 错误:', error)
|
||||
// 即使出错也返回成功,不影响用户体验
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '已处理'
|
||||
})
|
||||
}
|
||||
}
|
||||
273
app/api/search/route.ts
Normal file
273
app/api/search/route.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 搜索API
|
||||
* 支持从数据库搜索标题和内容
|
||||
* 同时支持搜索匹配的人和事情(隐藏功能)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { query } from '@/lib/db'
|
||||
import { bookData } from '@/lib/book-data'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
/**
|
||||
* 从文件系统搜索章节
|
||||
*/
|
||||
function searchFromFiles(keyword: string): any[] {
|
||||
const results: any[] = []
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
|
||||
for (const part of bookData) {
|
||||
for (const chapter of part.chapters) {
|
||||
for (const section of chapter.sections) {
|
||||
// 搜索标题
|
||||
if (section.title.toLowerCase().includes(lowerKeyword)) {
|
||||
results.push({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
partTitle: part.title,
|
||||
chapterTitle: chapter.title,
|
||||
price: section.price,
|
||||
isFree: section.isFree,
|
||||
matchType: 'title',
|
||||
score: 10 // 标题匹配得分更高
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 搜索内容
|
||||
const filePath = path.join(process.cwd(), section.filePath)
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8')
|
||||
if (content.toLowerCase().includes(lowerKeyword)) {
|
||||
// 提取匹配的上下文
|
||||
const lowerContent = content.toLowerCase()
|
||||
const matchIndex = lowerContent.indexOf(lowerKeyword)
|
||||
const start = Math.max(0, matchIndex - 50)
|
||||
const end = Math.min(content.length, matchIndex + keyword.length + 50)
|
||||
const snippet = content.substring(start, end)
|
||||
|
||||
results.push({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
partTitle: part.title,
|
||||
chapterTitle: chapter.title,
|
||||
price: section.price,
|
||||
isFree: section.isFree,
|
||||
matchType: 'content',
|
||||
snippet: (start > 0 ? '...' : '') + snippet + (end < content.length ? '...' : ''),
|
||||
score: 5 // 内容匹配得分较低
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略读取错误
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按得分排序
|
||||
return results.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从数据库搜索章节
|
||||
*/
|
||||
async function searchFromDB(keyword: string): Promise<any[]> {
|
||||
try {
|
||||
const results = await query(`
|
||||
SELECT
|
||||
id,
|
||||
section_title as title,
|
||||
part_title as partTitle,
|
||||
chapter_title as chapterTitle,
|
||||
price,
|
||||
is_free as isFree,
|
||||
CASE
|
||||
WHEN section_title LIKE ? THEN 'title'
|
||||
ELSE 'content'
|
||||
END as matchType,
|
||||
CASE
|
||||
WHEN section_title LIKE ? THEN 10
|
||||
ELSE 5
|
||||
END as score,
|
||||
SUBSTRING(content,
|
||||
GREATEST(1, LOCATE(?, content) - 50),
|
||||
150
|
||||
) as snippet
|
||||
FROM chapters
|
||||
WHERE section_title LIKE ?
|
||||
OR content LIKE ?
|
||||
ORDER BY score DESC, id ASC
|
||||
LIMIT 50
|
||||
`, [
|
||||
`%${keyword}%`,
|
||||
`%${keyword}%`,
|
||||
keyword,
|
||||
`%${keyword}%`,
|
||||
`%${keyword}%`
|
||||
]) as any[]
|
||||
|
||||
return results
|
||||
} catch (e) {
|
||||
console.error('[Search API] 数据库搜索失败:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文章中的人物信息(隐藏功能)
|
||||
* 用于"找伙伴"功能的智能匹配
|
||||
*/
|
||||
function extractPeopleFromContent(content: string): string[] {
|
||||
const people: string[] = []
|
||||
|
||||
// 匹配常见人名模式
|
||||
// 中文名:2-4个汉字
|
||||
const chineseNames = content.match(/[\u4e00-\u9fa5]{2,4}(?=:|:|说|的|告诉|表示)/g) || []
|
||||
// 英文名/昵称:带@或引号的名称
|
||||
const nicknames = content.match(/["'@]([^"'@\s]+)["']?/g) || []
|
||||
// 职位+名字模式
|
||||
const titleNames = content.match(/(?:老板|总|经理|创始人|合伙人|店长)[\u4e00-\u9fa5]{2,3}/g) || []
|
||||
|
||||
people.push(...chineseNames.slice(0, 10))
|
||||
people.push(...nicknames.map(n => n.replace(/["'@]/g, '')).slice(0, 5))
|
||||
people.push(...titleNames.slice(0, 5))
|
||||
|
||||
// 去重
|
||||
return [...new Set(people)]
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文章中的关键事件/标签
|
||||
*/
|
||||
function extractKeywords(content: string): string[] {
|
||||
const keywords: string[] = []
|
||||
|
||||
// 行业关键词
|
||||
const industries = ['电商', '私域', '社群', '抖音', '直播', '餐饮', '美业', '健康', 'AI', '供应链', '金融', '拍卖', '游戏', '电竞']
|
||||
// 模式关键词
|
||||
const patterns = ['轻资产', '复购', '被动收入', '杠杆', '信息差', '流量', '分销', '代理', '加盟']
|
||||
// 金额模式
|
||||
const amounts = content.match(/(\d+)万/g) || []
|
||||
|
||||
for (const ind of industries) {
|
||||
if (content.includes(ind)) keywords.push(ind)
|
||||
}
|
||||
for (const pat of patterns) {
|
||||
if (content.includes(pat)) keywords.push(pat)
|
||||
}
|
||||
keywords.push(...amounts.slice(0, 5))
|
||||
|
||||
return [...new Set(keywords)]
|
||||
}
|
||||
|
||||
/**
|
||||
* GET - 搜索
|
||||
* 参数:
|
||||
* - q: 搜索关键词
|
||||
* - type: 'all' | 'title' | 'content' | 'people' | 'keywords'
|
||||
* - source: 'db' | 'file' | 'auto' (默认auto)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const keyword = searchParams.get('q') || searchParams.get('keyword') || ''
|
||||
const type = searchParams.get('type') || 'all'
|
||||
const source = searchParams.get('source') || 'auto'
|
||||
|
||||
if (!keyword || keyword.length < 1) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '请输入搜索关键词'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
let results: any[] = []
|
||||
|
||||
// 根据source选择搜索方式
|
||||
if (source === 'db') {
|
||||
results = await searchFromDB(keyword)
|
||||
} else if (source === 'file') {
|
||||
results = searchFromFiles(keyword)
|
||||
} else {
|
||||
// auto: 先尝试数据库,失败则使用文件
|
||||
results = await searchFromDB(keyword)
|
||||
if (results.length === 0) {
|
||||
results = searchFromFiles(keyword)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据type过滤
|
||||
if (type === 'title') {
|
||||
results = results.filter(r => r.matchType === 'title')
|
||||
} else if (type === 'content') {
|
||||
results = results.filter(r => r.matchType === 'content')
|
||||
}
|
||||
|
||||
// 如果搜索人物或关键词(隐藏功能)
|
||||
let people: string[] = []
|
||||
let keywords: string[] = []
|
||||
|
||||
if (type === 'people' || type === 'all') {
|
||||
// 从搜索结果的内容中提取人物
|
||||
for (const result of results.slice(0, 5)) {
|
||||
const filePath = path.join(process.cwd(), 'book')
|
||||
// 从bookData找到对应文件
|
||||
for (const part of bookData) {
|
||||
for (const chapter of part.chapters) {
|
||||
const section = chapter.sections.find(s => s.id === result.id)
|
||||
if (section) {
|
||||
const fullPath = path.join(process.cwd(), section.filePath)
|
||||
if (fs.existsSync(fullPath)) {
|
||||
const content = fs.readFileSync(fullPath, 'utf-8')
|
||||
people.push(...extractPeopleFromContent(content))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
people = [...new Set(people)].slice(0, 20)
|
||||
}
|
||||
|
||||
if (type === 'keywords' || type === 'all') {
|
||||
// 从搜索结果的内容中提取关键词
|
||||
for (const result of results.slice(0, 5)) {
|
||||
for (const part of bookData) {
|
||||
for (const chapter of part.chapters) {
|
||||
const section = chapter.sections.find(s => s.id === result.id)
|
||||
if (section) {
|
||||
const fullPath = path.join(process.cwd(), section.filePath)
|
||||
if (fs.existsSync(fullPath)) {
|
||||
const content = fs.readFileSync(fullPath, 'utf-8')
|
||||
keywords.push(...extractKeywords(content))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
keywords = [...new Set(keywords)].slice(0, 20)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
keyword,
|
||||
total: results.length,
|
||||
results: results.slice(0, 20), // 限制返回数量
|
||||
// 隐藏功能数据
|
||||
people: type === 'people' || type === 'all' ? people : undefined,
|
||||
keywords: type === 'keywords' || type === 'all' ? keywords : undefined
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Search API] 搜索失败:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '搜索失败: ' + (error as Error).message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user