diff --git a/.cursorrules b/.cursorrules
deleted file mode 100644
index 7ac6124d..00000000
--- a/.cursorrules
+++ /dev/null
@@ -1,95 +0,0 @@
-# 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,读取优先级:数据库 > 本地文件
diff --git a/.env.port.example b/.env.port.example
new file mode 100644
index 00000000..8607bfda
--- /dev/null
+++ b/.env.port.example
@@ -0,0 +1,32 @@
+# 端口配置示例
+# 复制此文件为 .env 并根据实际情况修改
+
+# ========================================
+# 应用端口配置(避免多项目端口冲突)
+# ========================================
+
+# 方式1: 本地开发启动(pnpm start)
+# 在终端中设置:
+# Windows PowerShell: $env:PORT=30006; pnpm start
+# Windows CMD: set PORT=30006 && pnpm start
+# Linux/Mac: PORT=30006 pnpm start
+
+# 方式2: Docker Compose 部署
+# 设置 APP_PORT 变量,容器内外端口都使用此值
+APP_PORT=30006
+
+# 方式3: Docker 直接运行
+# docker run -e PORT=3007 -p 3007:3007 soul-book
+
+# ========================================
+# 多项目端口规划建议
+# ========================================
+# soul-book: 30006
+# other-project: 3007
+# api-service: 3008
+# ...
+
+# 注意:
+# 1. 修改端口后,需要同步更新支付回调地址等配置
+# 2. 部署到宝塔面板时,deploy_soul.py 会使用配置的端口
+# 3. 确保防火墙和反向代理配置正确
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
new file mode 100644
index 00000000..f56603ec
--- /dev/null
+++ b/.github/workflows/README.md
@@ -0,0 +1,108 @@
+# GitHub Actions 自动化部署配置说明
+
+## 📋 概述
+
+本项目已配置 GitHub Actions 工作流,支持在推送代码到 `soul-content` 分支时自动部署到宝塔服务器。
+
+## ✅ 项目兼容性
+
+当前项目**完全支持** GitHub Actions 部署方式,因为:
+
+- ✅ 使用 `standalone` 模式,构建产物独立完整
+- ✅ 使用 pnpm 包管理器
+- ✅ 已配置 PM2 启动方式(`node server.js`)
+- ✅ 端口配置为 30006
+
+## 🔧 配置步骤
+
+### 1. 在服务器上生成 SSH 密钥对
+
+```bash
+ssh root@42.194.232.22
+ssh-keygen -t rsa -b 4096 -C "github-actions-deploy"
+cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
+cat ~/.ssh/id_rsa # 复制私钥内容
+```
+
+### 2. 在 GitHub 仓库添加 Secrets
+
+进入 GitHub 仓库:`Settings` → `Secrets and variables` → `Actions` → `New repository secret`
+
+添加以下三个 Secrets:
+
+| Secret 名称 | 值 | 说明 |
+|------------|-----|------|
+| `SSH_HOST` | `42.194.232.22` | 服务器 IP |
+| `SSH_USERNAME` | `root` | SSH 用户名 |
+| `SSH_PRIVATE_KEY` | `-----BEGIN OPENSSH PRIVATE KEY-----...` | 服务器 SSH 私钥(完整内容) |
+
+### 3. 修改工作流分支(如需要)
+
+编辑 `.github/workflows/deploy.yml`,修改触发分支:
+
+```yaml
+on:
+ push:
+ branches:
+ - soul-content # 改为你的分支名
+```
+
+### 4. 提交并推送
+
+```bash
+git add .github/workflows/deploy.yml
+git commit -m "添加 GitHub Actions 自动化部署"
+git push origin soul-content
+```
+
+## 🚀 工作流程
+
+1. **构建阶段**:
+ - 安装 Node.js 22
+ - 安装 pnpm
+ - 安装项目依赖
+ - 执行 `pnpm build`(生成 standalone 输出)
+
+2. **打包阶段**:
+ - 复制 `.next/standalone` 内容
+ - 复制 `.next/static` 静态资源
+ - 复制 `public` 目录
+ - 复制 `ecosystem.config.cjs` PM2 配置
+ - 打包为 `deploy.tar.gz`
+
+3. **部署阶段**:
+ - 通过 SCP 上传到服务器 `/tmp/`
+ - SSH 连接到服务器
+ - 备份当前版本(可选)
+ - 解压到 `/www/wwwroot/soul`
+ - 重启 PM2 应用 `soul`
+
+## 📊 与当前部署方式对比
+
+| 特性 | GitHub Actions | deploy_soul.py |
+|------|---------------|----------------|
+| **触发方式** | 自动(Push 代码) | 手动执行脚本 |
+| **构建环境** | GitHub Ubuntu | 本地环境 |
+| **构建速度** | 较慢(每次安装依赖) | 较快(本地缓存) |
+| **适用场景** | 团队协作、CI/CD | 本地开发、快速部署 |
+| **Windows 兼容** | ✅ 完美(云端构建) | ⚠️ 需处理符号链接 |
+
+## ⚠️ 注意事项
+
+1. **首次部署**:确保服务器上 `/www/wwwroot/soul` 目录存在且 PM2 已配置项目
+2. **环境变量**:如果项目需要环境变量,需要在服务器上配置(宝塔面板或 `.env` 文件)
+3. **数据库连接**:确保服务器能访问数据库
+4. **构建时间**:首次构建可能需要 5-10 分钟,后续会更快(GitHub Actions 缓存)
+
+## 🔍 查看部署日志
+
+1. 在 GitHub 仓库点击 `Actions` 标签
+2. 选择最新的工作流运行
+3. 查看各步骤的详细日志
+
+## 🆚 两种部署方式选择
+
+- **使用 GitHub Actions**:适合团队协作,代码推送即自动部署
+- **使用 deploy_soul.py**:适合本地快速测试,需要手动控制部署时机
+
+两种方式可以并存,根据场景选择使用。
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 00000000..fe4b68e9
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,117 @@
+name: Deploy Next.js to Baota (Standalone)
+
+on:
+ push:
+ branches:
+ - soul-content # 你的分支名
+
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: 22
+
+ - name: Install pnpm
+ run: npm install -g pnpm
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Build project (standalone mode)
+ run: pnpm build
+ env:
+ NODE_ENV: production
+
+ - name: Package standalone output
+ run: |
+ # 创建临时打包目录
+ mkdir -p /tmp/deploy_package
+
+ # 复制 standalone 目录内容
+ cp -r .next/standalone/* /tmp/deploy_package/
+
+ # 复制 static 目录
+ mkdir -p /tmp/deploy_package/.next/static
+ cp -r .next/static/* /tmp/deploy_package/.next/static/
+
+ # 复制 public 目录
+ cp -r public /tmp/deploy_package/ 2>/dev/null || true
+
+ # 复制 PM2 配置文件
+ cp ecosystem.config.cjs /tmp/deploy_package/
+
+ # 打包
+ cd /tmp/deploy_package
+ tar -czf /tmp/deploy.tar.gz .
+ cd -
+
+ - name: Deploy to server via SCP
+ uses: appleboy/scp-action@master
+ with:
+ host: ${{ secrets.SSH_HOST }}
+ username: ${{ secrets.SSH_USERNAME }}
+ key: ${{ secrets.SSH_PRIVATE_KEY }}
+ source: "/tmp/deploy.tar.gz"
+ target: "/tmp/"
+ strip_components: 0
+
+ - name: Extract and restart on server
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.SSH_HOST }}
+ username: ${{ secrets.SSH_USERNAME }}
+ key: ${{ secrets.SSH_PRIVATE_KEY }}
+ script: |
+ cd /www/wwwroot/soul
+
+ # 备份当前版本(可选)
+ if [ -d ".next" ]; then
+ echo "备份当前版本..."
+ tar -czf /tmp/soul_backup_$(date +%Y%m%d_%H%M%S).tar.gz .next public ecosystem.config.cjs server.js package.json 2>/dev/null || true
+ fi
+
+ # 清理旧文件(保留 node_modules 如果存在)
+ rm -rf .next public ecosystem.config.cjs server.js package.json 2>/dev/null || true
+
+ # 解压新版本
+ echo "解压新版本..."
+ tar -xzf /tmp/deploy.tar.gz -C /www/wwwroot/soul
+
+ # 验证关键文件
+ if [ ! -f "server.js" ]; then
+ echo "错误: server.js 不存在,部署失败"
+ exit 1
+ fi
+
+ if [ ! -d ".next/static" ]; then
+ echo "警告: .next/static 目录不存在"
+ fi
+
+ # 设置权限
+ chmod +x server.js 2>/dev/null || true
+
+ # 重启 PM2 应用
+ echo "重启 PM2 应用..."
+ pm2 restart soul || pm2 start ecosystem.config.cjs || pm2 start server.js --name soul --env production
+
+ # 清理临时文件
+ rm -f /tmp/deploy.tar.gz
+
+ echo "部署完成!"
+
+ - name: Verify deployment
+ uses: appleboy/ssh-action@master
+ with:
+ host: ${{ secrets.SSH_HOST }}
+ username: ${{ secrets.SSH_USERNAME }}
+ key: ${{ secrets.SSH_PRIVATE_KEY }}
+ script: |
+ pm2 list
+ pm2 logs soul --lines 10 --nostream || echo "无法获取日志"
diff --git a/.gitignore b/.gitignore
index 4d57b360..cd6c6f9d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,4 +5,7 @@ node_modules/
.trae/
*.log
node_modules
-book
+
+# 部署配置(含服务器信息,勿提交)
+deploy_config.json
+scripts/deploy_config.json
diff --git a/.gitignore 2 b/.gitignore 2
deleted file mode 100644
index c2658d7d..00000000
--- a/.gitignore 2
+++ /dev/null
@@ -1 +0,0 @@
-node_modules/
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index ab229c62..f80ca41d 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -1,5 +1,92 @@
# 部署指南
+## 整站与后台管理端
+
+本项目是**一个 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`(本文件) | 部署步骤、环境变量、支付回调 |
+| Next 配置 | `next.config.mjs` | `output: 'standalone'` 供宝塔 standalone 部署使用 |
+| **宝塔部署(统一入口)** | **`scripts/devlop.py`** | **本地打包 → SSH 上传解压 → 宝塔 API 重启 Node 项目(Windows/Mac/Linux 通用)** |
+| 宝塔 API 模块 | `scripts/deploy_baota_pure_api.py` | 被 devlop.py 内部调用(重启 Node);也可单独用于仅重启或触发计划任务 |
+| 宝塔方案说明 | `开发文档/8、部署/Next.js宝塔部署方案.md` | 宝塔首次准备与日常部署步骤 |
+| 宝塔自动化 | `开发文档/8、部署/Next.js自动化部署流程.md` | GitHub Webhook + 宝塔,推送即自动部署 |
+
+无 `vercel.json` 时,Vercel 会按默认规则部署本仓库;若需自定义路由或头信息,可再加 `vercel.json`。
+
+---
+
+## 宝塔部署(统一使用 devlop.py)
+
+**日常部署**统一使用 **`scripts/devlop.py`**:本地打包 → SSH 上传解压 → 宝塔 API 重启,Windows / Mac / Linux 通用,不依赖 sshpass 或 shell。
+
+### 1. 安装依赖
+
+\`\`\`bash
+pip install -r requirements-deploy.txt
+\`\`\`
+
+### 2. 配置(可选)
+
+脚本默认使用 `.cursorrules` 中的服务器信息(42.194.232.22、root、项目路径 /www/wwwroot/soul 等)。如需覆盖,可设置环境变量:
+
+- `DEPLOY_HOST`、`DEPLOY_USER`、`DEPLOY_PASSWORD` 或 `DEPLOY_SSH_KEY`
+- `DEPLOY_PROJECT_PATH`(如 /www/wwwroot/soul)
+- `BAOTA_PANEL_URL`、`BAOTA_API_KEY`
+- `DEPLOY_PM2_APP`(默认 soul)
+
+### 3. 执行部署
+
+在**项目根目录**执行:
+
+\`\`\`bash
+python scripts/devlop.py
+\`\`\`
+
+- **流程**:本地 `pnpm build` → 打包 `.next/standalone`(含 static、public、ecosystem.config.cjs)→ SSH 上传并解压到服务器 → **宝塔 API 重启 Node 项目**。
+- **参数**:`--no-build` 跳过构建;`--no-upload` 仅构建+打包;`--no-api` 上传后不调 API 重启。
+
+部署完成后访问:
+
+- 前台:`https://soul.quwanzhi.com`
+- 后台:`https://soul.quwanzhi.com/admin`
+
+### 4. 仅重启 Node(不上传代码)
+
+若只需在宝塔上重启 Node 项目(代码已通过其他方式更新),可单独使用宝塔 API 模块:
+
+\`\`\`bash
+pip install requests
+python scripts/deploy_baota_pure_api.py # 重启 Node 项目 soul
+python scripts/deploy_baota_pure_api.py --create-dir # 并创建项目目录
+python scripts/deploy_baota_pure_api.py --task-id 1 # 触发计划任务 ID=1
+\`\`\`
+
+### 5. 首次在宝塔上准备
+
+若服务器上尚未有代码,需先在宝塔上:
+
+1. 在网站目录(如 `/www/wwwroot/soul`)创建目录,或从本地上传/克隆代码。
+2. 在宝塔「PM2 管理器」中新增项目:项目目录选该路径,启动文件为 `node server.js`,环境变量 `PORT=30006`。
+3. 配置 Nginx 反向代理到 `127.0.0.1:30006`,并绑定域名 soul.quwanzhi.com。
+4. 之后日常部署执行 `python scripts/devlop.py` 即可。
+
+---
+
## 生产环境部署步骤
### 1. Vercel部署
@@ -54,6 +141,14 @@ vercel --prod
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. 创建测试订单
@@ -81,6 +176,23 @@ 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/devlop.py --no-build` 跳过构建后上传已有包,或由服务器/计划任务在服务器上执行构建。
+
## 注意事项
1. 生产环境必须使用HTTPS
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 6017696a..00000000
--- a/Dockerfile
+++ /dev/null
@@ -1,62 +0,0 @@
-# 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"]
diff --git a/api/soul资源对接接口 copy.md b/api/soul资源对接接口 copy.md
deleted file mode 100644
index 7a1c2636..00000000
--- a/api/soul资源对接接口 copy.md
+++ /dev/null
@@ -1,413 +0,0 @@
-# 对外获客线索上报接口文档(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 | 否 | 画像类型,枚举值:
0-浏览
1-点击
2-下单/购买
3-注册
4-互动
默认值:0 |
-| `portrait.source` | int | 否 | 画像来源,枚举值:
0-本站
1-老油条
2-老坑爹
默认值:0 |
-| `portrait.sourceData` | object | 否 | 画像明细数据(键值对,会存储为 JSON 格式)
可包含任意业务相关的键值对,如:年龄、性别、城市、商品ID、页面URL等 |
-| `portrait.remark` | string | 否 | 画像备注信息,最大长度100字符 |
-| `portrait.uniqueId` | string | 否 | 画像去重用唯一 ID
用于防止重复记录,相同 `uniqueId` 的画像数据在半小时内会被合并统计(count字段累加)
建议格式:`{来源标识}_{用户标识}_{时间戳}_{序号}` |
-
-#### 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`。
-
----
diff --git a/app/admin/distribution/page.tsx b/app/admin/distribution/page.tsx
index 59049b78..1f34cc48 100644
--- a/app/admin/distribution/page.tsx
+++ b/app/admin/distribution/page.tsx
@@ -88,6 +88,9 @@ interface Order {
status: 'pending' | 'completed' | 'failed'
paymentMethod?: string
referrerEarnings?: number
+ referrerId?: string | null
+ /** 下单时记录的邀请码(订单表 referral_code) */
+ referralCode?: string | null
createdAt: string
}
@@ -111,7 +114,17 @@ export default function DistributionAdminPage() {
setLoading(true)
try {
- // 加载用户数据
+ // === 1. 加载概览数据(新接口:从真实数据库统计) ===
+ const overviewRes = await fetch('/api/admin/distribution/overview')
+ const overviewData = await overviewRes.json()
+ if (overviewData.success && overviewData.overview) {
+ setOverview(overviewData.overview)
+ console.log('[Admin] 概览数据加载成功:', overviewData.overview)
+ } else {
+ console.error('[Admin] 加载概览数据失败:', overviewData.error)
+ }
+
+ // === 2. 加载用户数据 ===
const usersRes = await fetch('/api/db/users')
const usersData = await usersRes.json()
const usersArr = usersData.users || []
@@ -121,13 +134,18 @@ export default function DistributionAdminPage() {
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)
+ const referrer = order.referrerId
+ ? usersArr.find((u: User) => u.id === order.referrerId)
+ : null
return {
...order,
userNickname: user?.nickname || '未知用户',
- userPhone: user?.phone || '-'
+ userPhone: user?.phone || '-',
+ referrerNickname: referrer?.nickname || null,
+ referrerCode: referrer?.referral_code || null,
}
})
setOrders(enrichedOrders)
@@ -143,71 +161,7 @@ export default function DistributionAdminPage() {
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,
- })
+ // 注意:概览数据现在从 /api/admin/distribution/overview 直接获取,不再前端计算
} catch (error) {
console.error('Load distribution data error:', error)
// 如果加载失败,设置空数据
@@ -619,6 +573,7 @@ export default function DistributionAdminPage() {
金额 |
支付方式 |
状态 |
+ 推荐人/邀请码 |
分销佣金 |
下单时间 |
@@ -633,7 +588,9 @@ export default function DistributionAdminPage() {
order.id?.toLowerCase().includes(term) ||
order.userNickname?.toLowerCase().includes(term) ||
order.userPhone?.includes(term) ||
- order.sectionTitle?.toLowerCase().includes(term)
+ order.sectionTitle?.toLowerCase().includes(term) ||
+ (order.referrerCode && order.referrerCode.toLowerCase().includes(term)) ||
+ (order.referrerNickname && order.referrerNickname.toLowerCase().includes(term))
)
}
return true
@@ -671,14 +628,22 @@ export default function DistributionAdminPage() {
order.paymentMethod || '微信支付'}
- {order.status === 'completed' ? (
+ {order.status === 'completed' || order.status === 'paid' ? (
已完成
- ) : order.status === 'pending' ? (
+ ) : order.status === 'pending' || order.status === 'created' ? (
待支付
) : (
已失败
)}
|
+
+ {order.referrerId || order.referralCode ? (
+
+ {order.referrerNickname || order.referralCode || order.referrerCode || order.referrerId?.slice(0, 8)}
+ {(order.referralCode || order.referrerCode) ? ` (${order.referralCode || order.referrerCode})` : ''}
+
+ ) : '-'}
+ |
{order.referrerEarnings ? `¥${order.referrerEarnings.toFixed(2)}` : '-'}
|
diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx
index 55fec8ac..baf45d6b 100644
--- a/app/admin/layout.tsx
+++ b/app/admin/layout.tsx
@@ -3,30 +3,61 @@
import type React from "react"
import { useState, useEffect } from "react"
import Link from "next/link"
-import { usePathname } from "next/navigation"
+import { usePathname, useRouter } 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 router = useRouter()
const [mounted, setMounted] = useState(false)
+ const [authChecked, setAuthChecked] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
+ // 非登录页时校验 Cookie,未登录则跳转登录页
+ useEffect(() => {
+ if (!mounted || pathname === "/admin/login") return
+ setAuthChecked(false)
+ let cancelled = false
+ fetch("/api/admin", { credentials: "include" })
+ .then((res) => {
+ if (cancelled) return
+ if (res.status === 401) router.replace("/admin/login")
+ else setAuthChecked(true)
+ })
+ .catch(() => {
+ if (!cancelled) setAuthChecked(true)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [mounted, pathname, router])
+
+ const handleLogout = async () => {
+ await fetch("/api/admin/logout", { method: "POST", credentials: "include" })
+ router.replace("/admin/login")
+ }
+
// 简化菜单:按功能归类,保留核心功能
// PDF需求:分账管理、分销管理、订单管理三合一 → 交易中心
const menuItems = [
- { icon: LayoutDashboard, label: "数据概览", href: "/admin" },
+ { icon: LayoutDashboard, label: "数据概览123", 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: CreditCard, label: "推广设置", href: "/admin/referral-settings" }, // 单独入口,集中管理分销配置
{ icon: Settings, label: "系统设置", href: "/admin/settings" },
]
- // 避免hydration错误,等待客户端mount
- if (!mounted) {
+ // 登录页:不渲染侧栏,只渲染子页面
+ if (pathname === "/admin/login") {
+ return {children}
+ }
+
+ // 避免 hydration 错误,等待客户端 mount 并完成鉴权
+ if (!mounted || !authChecked) {
return (
@@ -66,12 +97,19 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
})}
-
-
+
+
返回前台
diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx
index 8b9235d0..b92ad3c4 100644
--- a/app/admin/login/page.tsx
+++ b/app/admin/login/page.tsx
@@ -5,11 +5,9 @@ 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("")
@@ -18,14 +16,22 @@ export default function AdminLoginPage() {
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("用户名或密码错误")
+ try {
+ const res = await fetch("/api/admin", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username: username.trim(), password }),
+ credentials: "include",
+ })
+ const data = await res.json()
+ if (res.ok && data.success) {
+ router.push("/admin")
+ return
+ }
+ setError(data.error || "用户名或密码错误")
+ } catch {
+ setError("网络错误,请重试")
+ } finally {
setLoading(false)
}
}
@@ -95,12 +101,6 @@ export default function AdminLoginPage() {
-
-
- 默认账号: admin /{" "}
- key123456
-
-
{/* Footer */}
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
index d6b0c4b4..a3c7af2b 100644
--- a/app/admin/page.tsx
+++ b/app/admin/page.tsx
@@ -63,15 +63,24 @@ export default function AdminDashboard() {
)
}
- const totalRevenue = purchases.reduce((sum, p) => sum + (p.amount || 0), 0)
+ const totalRevenue = purchases.reduce((sum, p) => sum + Number(p.amount || 0), 0)
const totalUsers = users.length
const totalPurchases = purchases.length
+ // 订单类型对应中文(product_type: section | fullbook | match)
+ const productTypeLabel = (p: { productType?: string; productId?: string; sectionTitle?: string }) => {
+ const type = p.productType || ""
+ if (type === "section") return p.productId ? `单章 ${p.productId}` : "单章"
+ if (type === "fullbook") return "整本购买"
+ if (type === "match") return "找伙伴"
+ return p.sectionTitle || "其他"
+ }
+
const stats = [
{ title: "总用户数", value: totalUsers, icon: Users, color: "text-blue-400", bg: "bg-blue-500/20", link: "/admin/users" },
{
title: "总收入",
- value: `¥${totalRevenue.toFixed(2)}`,
+ value: `¥${Number(totalRevenue).toFixed(2)}`,
icon: TrendingUp,
color: "text-[#38bdac]",
bg: "bg-[#38bdac]/20",
@@ -125,21 +134,28 @@ export default function AdminDashboard() {
{purchases
.slice(-5)
.reverse()
- .map((p) => (
-
-
-
{p.sectionTitle || "整本购买"}
-
{new Date(p.createdAt).toLocaleString()}
+ .map((p) => {
+ const referrer = p.referrerId && users.find((u: any) => u.id === p.referrerId)
+ const inviteCode = p.referralCode || referrer?.referral_code || referrer?.nickname || p.referrerId?.slice(0, 8)
+ return (
+
+
+
{productTypeLabel(p)}
+
{new Date(p.createdAt).toLocaleString()}
+ {inviteCode && (
+
邀请码: {inviteCode}
+ )}
+
+
+
+¥{p.amount}
+
{p.paymentMethod || "微信支付"}
+
-
-
+¥{p.amount}
-
{p.paymentMethod || "微信支付"}
-
-
- ))}
+ )
+ })}
{purchases.length === 0 &&
暂无订单数据
}
diff --git a/app/admin/referral-settings/page.tsx b/app/admin/referral-settings/page.tsx
new file mode 100644
index 00000000..f8645cd3
--- /dev/null
+++ b/app/admin/referral-settings/page.tsx
@@ -0,0 +1,272 @@
+"use client"
+
+import { useEffect, useState } 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 { Badge } from "@/components/ui/badge"
+import { Save, Percent, Users, Wallet, Info } from "lucide-react"
+
+type ReferralConfig = {
+ distributorShare: number
+ minWithdrawAmount: number
+ bindingDays: number
+ userDiscount: number
+ enableAutoWithdraw: boolean
+}
+
+const DEFAULT_REFERRAL_CONFIG: ReferralConfig = {
+ distributorShare: 90,
+ minWithdrawAmount: 10,
+ bindingDays: 30,
+ userDiscount: 5,
+ enableAutoWithdraw: false,
+}
+
+export default function ReferralSettingsPage() {
+ const [config, setConfig] = useState(DEFAULT_REFERRAL_CONFIG)
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ const loadConfig = async () => {
+ try {
+ const res = await fetch("/api/db/config?key=referral_config")
+ if (res.ok) {
+ const data = await res.json()
+ if (data?.success && data.config) {
+ setConfig({
+ distributorShare: data.config.distributorShare ?? 90,
+ minWithdrawAmount: data.config.minWithdrawAmount ?? 10,
+ bindingDays: data.config.bindingDays ?? 30,
+ userDiscount: data.config.userDiscount ?? 5,
+ enableAutoWithdraw: data.config.enableAutoWithdraw ?? false,
+ })
+ }
+ }
+ } catch (e) {
+ console.error("加载 referral_config 失败:", e)
+ } finally {
+ setLoading(false)
+ }
+ }
+ loadConfig()
+ }, [])
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ // 确保所有字段都是正确类型(防止字符串导致计算错误)
+ const safeConfig = {
+ distributorShare: Number(config.distributorShare) || 0,
+ minWithdrawAmount: Number(config.minWithdrawAmount) || 0,
+ bindingDays: Number(config.bindingDays) || 0,
+ userDiscount: Number(config.userDiscount) || 0,
+ enableAutoWithdraw: Boolean(config.enableAutoWithdraw),
+ }
+
+ const body = {
+ key: "referral_config",
+ config: safeConfig,
+ description: "分销 / 推广规则配置",
+ }
+ const res = await fetch("/api/db/config", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ const data = await res.json()
+ if (!res.ok || !data?.success) {
+ alert("保存失败: " + (data?.error || res.statusText))
+ return
+ }
+
+ alert("✅ 分销配置已保存成功!\n\n• 小程序与网站的推广规则会一起生效\n• 绑定关系会使用新的天数配置\n• 佣金比例会立即应用到新订单\n\n如有缓存,请刷新前台/小程序页面。")
+ } catch (e: any) {
+ console.error("保存 referral_config 失败:", e)
+ alert("保存失败: " + (e?.message || String(e)))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleNumberChange = (field: keyof ReferralConfig) => (e: React.ChangeEvent) => {
+ const value = parseFloat(e.target.value || "0")
+ setConfig((prev) => ({ ...prev, [field]: isNaN(value) ? 0 : value }))
+ }
+
+ return (
+
+
+
+
+
+ 推广 / 分销设置
+
+
+ 统一管理「好友优惠」「你得 90% 收益」「绑定期 30 天」「提现门槛」等规则,小程序和 Web 共用这套配置。
+
+
+
+
+
+
+ {/* 核心规则卡片 */}
+
+
+
+
+ 推广规则
+
+
+ 这三项会直接体现在小程序「推广规则」卡片上,同时影响实收佣金计算。
+
+
+
+
+
+
+
+
例如 5 表示好友立减 5%(在价格配置基础上生效)。
+
+
+
+
+
+ setConfig((prev) => ({ ...prev, distributorShare: val }))}
+ />
+
+
+
+ 实际佣金 = 订单金额 × {" "}
+ {config.distributorShare}%,支付回调和分销统计都会用这个值。
+
+
+
+
+
+
+
好友通过你的链接进来并登录后,绑定在你名下的天数。
+
+
+
+
+
+ {/* 提现与自动提现 */}
+
+
+
+
+ 提现规则
+
+
+ 与「提现中心」「自动提现」相关的参数,影响推广者看到的可提现金额和最低门槛。
+
+
+
+
+
+
+
+
小程序「满 X 元可提现」展示的门槛,同时用于后端接口校验。
+
+
+
+
+
+ setConfig((prev) => ({ ...prev, enableAutoWithdraw: checked }))}
+ />
+
+ 开启后,可结合定时任务实现「收益自动打款到微信零钱」。
+
+
+
+
+
+
+
+ {/* 提示卡片 */}
+
+
+
+
+ 使用说明
+
+
+
+
+ 1. 以上配置会写入 system_config.referral_config,小程序「推广中心」、
+ Web 推广页以及支付回调都会读取同一份配置。
+
+
+ 2. 修改后新订单立即生效;旧订单的历史佣金不会自动重算,只影响之后产生的订单。
+
+
+ 3. 如遇前端展示与实际结算不一致,优先以此处配置为准,再排查缓存和小程序版本。
+
+
+
+
+
+ )
+}
diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx
index 2a2c5cf5..480ee07c 100644
--- a/app/admin/settings/page.tsx
+++ b/app/admin/settings/page.tsx
@@ -39,6 +39,14 @@ export default function SettingsPage() {
minWithdraw: 10, // 最低提现金额
})
+ // 功能开关配置
+ const [featureConfig, setFeatureConfig] = useState({
+ matchEnabled: true, // 找伙伴功能开关(默认开启)
+ referralEnabled: true, // 推广功能开关
+ searchEnabled: true, // 搜索功能开关
+ aboutEnabled: true // 关于页面开关
+ })
+
// 加载配置
useEffect(() => {
const loadConfig = async () => {
@@ -48,6 +56,7 @@ export default function SettingsPage() {
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)
@@ -82,16 +91,41 @@ export default function SettingsPage() {
})
// 保存免费章节和小程序配置
- await fetch('/api/db/config', {
+ 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)
- alert("设置已保存!")
+ // 保存功能开关配置
+ 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("保存失败")
+ alert("保存失败: " + (error as Error).message)
} finally {
setIsSaving(false)
}
@@ -357,6 +391,114 @@ export default function SettingsPage() {
+ {/* 功能开关设置 */}
+
+
+
+
+ 功能开关
+
+ 控制各个功能模块的显示/隐藏
+
+
+
+ {/* 找伙伴功能开关 */}
+
+
+
+
+
+
+
+ 控制小程序和Web端的找伙伴功能显示
+
+
+
+ setFeatureConfig(prev => ({ ...prev, matchEnabled: checked }))
+ }
+ />
+
+
+ {/* 推广功能开关 */}
+
+
+
+
+
+
+
+ 控制推广中心的显示(我的页面入口)
+
+
+
+ setFeatureConfig(prev => ({ ...prev, referralEnabled: checked }))
+ }
+ />
+
+
+ {/* 搜索功能开关 */}
+
+
+
+
+
+
+
+ 控制首页搜索栏的显示
+
+
+
+ setFeatureConfig(prev => ({ ...prev, searchEnabled: checked }))
+ }
+ />
+
+
+ {/* 关于页面开关 */}
+
+
+
+
+
+
+
+ 控制关于页面的访问
+
+
+
+ setFeatureConfig(prev => ({ ...prev, aboutEnabled: checked }))
+ }
+ />
+
+
+
+
+
+ 💡 关闭功能后,相关入口会自动隐藏。建议在功能开发完成后再开启。
+
+
+
+
+
{/* 小程序配置 */}
@@ -478,14 +620,22 @@ export default function SettingsPage() {
分销系统
是否允许用户生成邀请链接
-
+ setFeatureConfig(prev => ({ ...prev, referralEnabled: checked }))}
+ />
-
+ setFeatureConfig(prev => ({ ...prev, matchEnabled: checked }))}
+ />
diff --git a/app/api/admin/chapters/route.ts b/app/api/admin/chapters/route.ts
index a8ef4a11..90f16f07 100644
--- a/app/api/admin/chapters/route.ts
+++ b/app/api/admin/chapters/route.ts
@@ -6,6 +6,7 @@
import { NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
+import { requireAdminResponse } from '@/lib/admin-auth'
// 获取书籍目录
const BOOK_DIR = path.join(process.cwd(), 'book')
@@ -14,6 +15,8 @@ const BOOK_DIR = path.join(process.cwd(), 'book')
* GET - 获取所有章节列表
*/
export async function GET(request: Request) {
+ const authErr = requireAdminResponse(request)
+ if (authErr) return authErr
try {
const { searchParams } = new URL(request.url)
const includeContent = searchParams.get('content') === 'true'
@@ -274,6 +277,8 @@ export async function GET(request: Request) {
* POST - 更新章节设置
*/
export async function POST(request: Request) {
+ const authErr = requireAdminResponse(request)
+ if (authErr) return authErr
try {
const body = await request.json()
const { action, chapterId, data } = body
diff --git a/app/api/admin/content/route.ts b/app/api/admin/content/route.ts
index 84d54cc1..4d156f21 100644
--- a/app/api/admin/content/route.ts
+++ b/app/api/admin/content/route.ts
@@ -5,11 +5,14 @@ import { NextRequest, NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
+import { requireAdminResponse } from '@/lib/admin-auth'
const BOOK_DIR = path.join(process.cwd(), 'book')
// GET: 获取所有章节列表
export async function GET(req: NextRequest) {
+ const authErr = requireAdminResponse(req)
+ if (authErr) return authErr
try {
const chapters = getAllChapters()
@@ -28,6 +31,8 @@ export async function GET(req: NextRequest) {
// POST: 创建新章节
export async function POST(req: NextRequest) {
+ const authErr = requireAdminResponse(req)
+ if (authErr) return authErr
try {
const body = await req.json()
const { title, content, category, tags } = body
@@ -70,6 +75,8 @@ export async function POST(req: NextRequest) {
// PUT: 更新章节
export async function PUT(req: NextRequest) {
+ const authErr = requireAdminResponse(req)
+ if (authErr) return authErr
try {
const body = await req.json()
const { id, title, content, category, tags } = body
@@ -97,6 +104,8 @@ export async function PUT(req: NextRequest) {
// DELETE: 删除章节
export async function DELETE(req: NextRequest) {
+ const authErr = requireAdminResponse(req)
+ if (authErr) return authErr
try {
const { searchParams } = new URL(req.url)
const id = searchParams.get('id')
diff --git a/app/api/admin/distribution/overview/route.ts b/app/api/admin/distribution/overview/route.ts
new file mode 100644
index 00000000..5e365af2
--- /dev/null
+++ b/app/api/admin/distribution/overview/route.ts
@@ -0,0 +1,268 @@
+/**
+ * 管理端分销数据概览API - 从真实数据库查询
+ */
+
+import { NextRequest, NextResponse } from 'next/server'
+import { query } from '@/lib/db'
+import { requireAdminResponse } from '@/lib/admin-auth'
+
+export async function GET(req: NextRequest) {
+ // 验证管理员权限
+ const authErr = requireAdminResponse(req)
+ if (authErr) return authErr
+
+ try {
+ const now = new Date()
+ const today = now.toISOString().split('T')[0]
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString()
+ const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString()
+
+ // === 1. 订单数据统计 ===
+ let orderStats = {
+ todayOrders: 0,
+ todayAmount: 0,
+ monthOrders: 0,
+ monthAmount: 0,
+ totalOrders: 0,
+ totalAmount: 0
+ }
+
+ try {
+ const orderResults = await query(`
+ SELECT
+ COUNT(*) as total_count,
+ COALESCE(SUM(amount), 0) as total_amount,
+ COALESCE(SUM(CASE WHEN DATE(created_at) = ? THEN 1 ELSE 0 END), 0) as today_count,
+ COALESCE(SUM(CASE WHEN DATE(created_at) = ? THEN amount ELSE 0 END), 0) as today_amount,
+ COALESCE(SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END), 0) as month_count,
+ COALESCE(SUM(CASE WHEN created_at >= ? THEN amount ELSE 0 END), 0) as month_amount
+ FROM orders
+ WHERE status = 'paid'
+ `, [today, today, monthStart, monthStart]) as any[]
+
+ if (orderResults.length > 0) {
+ const r = orderResults[0]
+ orderStats = {
+ todayOrders: parseInt(r.today_count) || 0,
+ todayAmount: parseFloat(r.today_amount) || 0,
+ monthOrders: parseInt(r.month_count) || 0,
+ monthAmount: parseFloat(r.month_amount) || 0,
+ totalOrders: parseInt(r.total_count) || 0,
+ totalAmount: parseFloat(r.total_amount) || 0
+ }
+ }
+ } catch (e) {
+ console.error('[Admin Overview] 订单统计失败:', e)
+ }
+
+ // === 2. 绑定数据统计 ===
+ let bindingStats = {
+ todayBindings: 0,
+ todayConversions: 0,
+ monthBindings: 0,
+ monthConversions: 0,
+ totalBindings: 0,
+ totalConversions: 0,
+ activeBindings: 0,
+ expiredBindings: 0,
+ expiringBindings: 0
+ }
+
+ try {
+ const bindingResults = await query(`
+ SELECT
+ COUNT(*) as total_count,
+ SUM(CASE WHEN status = 'active' AND expiry_date > NOW() THEN 1 ELSE 0 END) as active_count,
+ SUM(CASE WHEN status = 'converted' THEN 1 ELSE 0 END) as converted_count,
+ SUM(CASE WHEN status = 'expired' OR (status = 'active' AND expiry_date <= NOW()) THEN 1 ELSE 0 END) as expired_count,
+ SUM(CASE WHEN DATE(binding_date) = ? THEN 1 ELSE 0 END) as today_count,
+ SUM(CASE WHEN DATE(binding_date) = ? AND status = 'converted' THEN 1 ELSE 0 END) as today_converted,
+ SUM(CASE WHEN binding_date >= ? THEN 1 ELSE 0 END) as month_count,
+ SUM(CASE WHEN binding_date >= ? AND status = 'converted' THEN 1 ELSE 0 END) as month_converted,
+ SUM(CASE WHEN status = 'active' AND expiry_date <= ? AND expiry_date > NOW() THEN 1 ELSE 0 END) as expiring_count
+ FROM referral_bindings
+ `, [today, today, monthStart, monthStart, sevenDaysLater]) as any[]
+
+ if (bindingResults.length > 0) {
+ const r = bindingResults[0]
+ bindingStats = {
+ todayBindings: parseInt(r.today_count) || 0,
+ todayConversions: parseInt(r.today_converted) || 0,
+ monthBindings: parseInt(r.month_count) || 0,
+ monthConversions: parseInt(r.month_converted) || 0,
+ totalBindings: parseInt(r.total_count) || 0,
+ totalConversions: parseInt(r.converted_count) || 0,
+ activeBindings: parseInt(r.active_count) || 0,
+ expiredBindings: parseInt(r.expired_count) || 0,
+ expiringBindings: parseInt(r.expiring_count) || 0
+ }
+ }
+ } catch (e) {
+ console.error('[Admin Overview] 绑定统计失败:', e)
+ }
+
+ // === 3. 收益数据统计 ===
+ let earningsStats = {
+ totalEarnings: 0,
+ todayEarnings: 0,
+ monthEarnings: 0,
+ pendingEarnings: 0
+ }
+
+ try {
+ // 从 users 表累加所有用户的收益
+ const earningsResults = await query(`
+ SELECT
+ COALESCE(SUM(earnings), 0) as total_earnings,
+ COALESCE(SUM(pending_earnings), 0) as pending_earnings
+ FROM users
+ `) as any[]
+
+ if (earningsResults.length > 0) {
+ earningsStats.totalEarnings = parseFloat(earningsResults[0].total_earnings) || 0
+ earningsStats.pendingEarnings = parseFloat(earningsResults[0].pending_earnings) || 0
+ }
+
+ // 今日和本月收益:从 orders 表计算(status='paid' 的订单)
+ const periodEarningsResults = await query(`
+ SELECT
+ COALESCE(SUM(CASE WHEN DATE(pay_time) = ? THEN amount * 0.9 ELSE 0 END), 0) as today_earnings,
+ COALESCE(SUM(CASE WHEN pay_time >= ? THEN amount * 0.9 ELSE 0 END), 0) as month_earnings
+ FROM orders
+ WHERE status = 'paid'
+ `, [today, monthStart]) as any[]
+
+ if (periodEarningsResults.length > 0) {
+ earningsStats.todayEarnings = parseFloat(periodEarningsResults[0].today_earnings) || 0
+ earningsStats.monthEarnings = parseFloat(periodEarningsResults[0].month_earnings) || 0
+ }
+ } catch (e) {
+ console.error('[Admin Overview] 收益统计失败:', e)
+ }
+
+ // === 4. 提现数据统计 ===
+ let withdrawalStats = {
+ pendingCount: 0,
+ pendingAmount: 0
+ }
+
+ try {
+ const withdrawalResults = await query(`
+ SELECT
+ COUNT(*) as pending_count,
+ COALESCE(SUM(amount), 0) as pending_amount
+ FROM withdrawals
+ WHERE status = 'pending'
+ `) as any[]
+
+ if (withdrawalResults.length > 0) {
+ withdrawalStats.pendingCount = parseInt(withdrawalResults[0].pending_count) || 0
+ withdrawalStats.pendingAmount = parseFloat(withdrawalResults[0].pending_amount) || 0
+ }
+ } catch (e) {
+ console.error('[Admin Overview] 提现统计失败:', e)
+ }
+
+ // === 5. 访问数据统计 ===
+ let visitStats = {
+ todayVisits: 0,
+ monthVisits: 0,
+ totalVisits: 0
+ }
+
+ try {
+ const visitResults = await query(`
+ SELECT
+ COUNT(*) as total_count,
+ COUNT(DISTINCT CASE WHEN DATE(created_at) = ? THEN id END) as today_count,
+ COUNT(DISTINCT CASE WHEN created_at >= ? THEN id END) as month_count
+ FROM referral_visits
+ `, [today, monthStart]) as any[]
+
+ if (visitResults.length > 0) {
+ visitStats.totalVisits = parseInt(visitResults[0].total_count) || 0
+ visitStats.todayVisits = parseInt(visitResults[0].today_count) || 0
+ visitStats.monthVisits = parseInt(visitResults[0].month_count) || 0
+ }
+ } catch (e) {
+ console.error('[Admin Overview] 访问统计失败:', e)
+ // 访问表可能不存在,使用绑定数作为替代
+ visitStats = {
+ todayVisits: bindingStats.todayBindings,
+ monthVisits: bindingStats.monthBindings,
+ totalVisits: bindingStats.totalBindings
+ }
+ }
+
+ // === 6. 分销商数据统计 ===
+ let distributorStats = {
+ totalDistributors: 0,
+ activeDistributors: 0
+ }
+
+ try {
+ const distributorResults = await query(`
+ SELECT
+ COUNT(*) as total_count,
+ SUM(CASE WHEN earnings > 0 THEN 1 ELSE 0 END) as active_count
+ FROM users
+ WHERE referral_code IS NOT NULL AND referral_code != ''
+ `) as any[]
+
+ if (distributorResults.length > 0) {
+ distributorStats.totalDistributors = parseInt(distributorResults[0].total_count) || 0
+ distributorStats.activeDistributors = parseInt(distributorResults[0].active_count) || 0
+ }
+ } catch (e) {
+ console.error('[Admin Overview] 分销商统计失败:', e)
+ }
+
+ // === 7. 计算转化率 ===
+ const conversionRate = visitStats.totalVisits > 0
+ ? ((bindingStats.totalConversions / visitStats.totalVisits) * 100).toFixed(2)
+ : '0.00'
+
+ // 返回完整概览数据
+ const overview = {
+ // 今日数据
+ todayClicks: visitStats.todayVisits,
+ todayBindings: bindingStats.todayBindings,
+ todayConversions: bindingStats.todayConversions,
+ todayEarnings: earningsStats.todayEarnings,
+
+ // 本月数据
+ monthClicks: visitStats.monthVisits,
+ monthBindings: bindingStats.monthBindings,
+ monthConversions: bindingStats.monthConversions,
+ monthEarnings: earningsStats.monthEarnings,
+
+ // 总计数据
+ totalClicks: visitStats.totalVisits,
+ totalBindings: bindingStats.totalBindings,
+ totalConversions: bindingStats.totalConversions,
+ totalEarnings: earningsStats.totalEarnings,
+
+ // 其他统计
+ expiringBindings: bindingStats.expiringBindings,
+ pendingWithdrawals: withdrawalStats.pendingCount,
+ pendingWithdrawAmount: withdrawalStats.pendingAmount,
+ conversionRate,
+ totalDistributors: distributorStats.totalDistributors,
+ activeDistributors: distributorStats.activeDistributors,
+ }
+
+ console.log('[Admin Overview] 数据统计完成:', overview)
+
+ return NextResponse.json({
+ success: true,
+ overview
+ })
+
+ } catch (error) {
+ console.error('[Admin Overview] 统计失败:', error)
+ return NextResponse.json({
+ success: false,
+ error: '获取分销概览失败: ' + (error as Error).message
+ }, { status: 500 })
+ }
+}
diff --git a/app/api/admin/logout/route.ts b/app/api/admin/logout/route.ts
new file mode 100644
index 00000000..9d287345
--- /dev/null
+++ b/app/api/admin/logout/route.ts
@@ -0,0 +1,9 @@
+import { NextRequest, NextResponse } from 'next/server'
+import { getAdminCookieName, getAdminCookieOptions } from '@/lib/admin-auth'
+
+export async function POST(_req: NextRequest) {
+ const res = NextResponse.json({ success: true })
+ const opts = getAdminCookieOptions()
+ res.cookies.set(getAdminCookieName(), '', { ...opts, maxAge: 0 })
+ return res
+}
diff --git a/app/api/admin/payment/route.ts b/app/api/admin/payment/route.ts
index cde1bea1..3aaba3c5 100644
--- a/app/api/admin/payment/route.ts
+++ b/app/api/admin/payment/route.ts
@@ -2,6 +2,7 @@
// 付费模块管理API
import { NextRequest, NextResponse } from 'next/server'
+import { requireAdminResponse } from '@/lib/admin-auth'
// 模拟订单数据
let orders = [
@@ -29,6 +30,8 @@ let orders = [
// GET: 获取订单列表
export async function GET(req: NextRequest) {
+ const authErr = requireAdminResponse(req)
+ if (authErr) return authErr
const { searchParams } = new URL(req.url)
const status = searchParams.get('status')
const page = parseInt(searchParams.get('page') || '1')
@@ -71,6 +74,8 @@ export async function GET(req: NextRequest) {
// POST: 创建订单(手动)
export async function POST(req: NextRequest) {
+ const authErr = requireAdminResponse(req)
+ if (authErr) return authErr
try {
const body = await req.json()
const { userId, userName, amount, note } = body
@@ -110,6 +115,8 @@ export async function POST(req: NextRequest) {
// PUT: 更新订单状态
export async function PUT(req: NextRequest) {
+ const authErr = requireAdminResponse(req)
+ if (authErr) return authErr
try {
const body = await req.json()
const { orderId, status, note } = body
@@ -148,6 +155,8 @@ export async function PUT(req: NextRequest) {
// DELETE: 删除订单
export async function DELETE(req: NextRequest) {
+ const authErr = requireAdminResponse(req)
+ if (authErr) return authErr
try {
const { searchParams } = new URL(req.url)
const orderId = searchParams.get('id')
diff --git a/app/api/admin/referral/route.ts b/app/api/admin/referral/route.ts
index 832229a4..771c82ad 100644
--- a/app/api/admin/referral/route.ts
+++ b/app/api/admin/referral/route.ts
@@ -2,6 +2,7 @@
// 分销模块管理API
import { NextRequest, NextResponse } from 'next/server'
+import { requireAdminResponse } from '@/lib/admin-auth'
// 模拟分销数据
let referralRecords = [
@@ -52,6 +53,8 @@ let commissionRecords = [
// GET: 获取分销概览或列表
export async function GET(req: NextRequest) {
+ const authErr = requireAdminResponse(req)
+ if (authErr) return authErr
const { searchParams } = new URL(req.url)
const type = searchParams.get('type') || 'list'
const page = parseInt(searchParams.get('page') || '1')
@@ -95,6 +98,8 @@ export async function GET(req: NextRequest) {
// POST: 创建分销记录或处理佣金
export async function POST(req: NextRequest) {
+ const authErr = requireAdminResponse(req)
+ if (authErr) return authErr
try {
const body = await req.json()
const { action, data } = body
@@ -170,6 +175,8 @@ export async function POST(req: NextRequest) {
// PUT: 更新分销记录
export async function PUT(req: NextRequest) {
+ const authErr = requireAdminResponse(req)
+ if (authErr) return authErr
try {
const body = await req.json()
const { referrerId, status, commissionRate, note } = body
@@ -205,6 +212,8 @@ export async function PUT(req: NextRequest) {
// DELETE: 删除分销记录
export async function DELETE(req: NextRequest) {
+ const authErr = requireAdminResponse(req)
+ if (authErr) return authErr
try {
const { searchParams } = new URL(req.url)
const referrerId = searchParams.get('id')
diff --git a/app/api/admin/route.ts b/app/api/admin/route.ts
index ae960d77..75ee978f 100644
--- a/app/api/admin/route.ts
+++ b/app/api/admin/route.ts
@@ -1,25 +1,27 @@
// app/api/admin/route.ts
-// 后台管理API入口
+// 后台管理API入口:登录与鉴权(账号密码从环境变量读取,默认 admin / admin123)
import { NextRequest, NextResponse } from 'next/server'
+import {
+ verifyAdminToken,
+ getAdminTokenFromRequest,
+ verifyAdminCredentials,
+ getAdminCredentials,
+ createAdminToken,
+ getAdminCookieName,
+ getAdminCookieOptions,
+} from '@/lib/admin-auth'
-// 验证管理员权限
-function verifyAdmin(req: NextRequest) {
- const token = req.headers.get('Authorization')?.replace('Bearer ', '')
-
- // TODO: 实现真实的token验证
- if (!token || token !== 'admin-token-secret') {
- return false
- }
-
- return true
+function requireAdmin(req: NextRequest): boolean {
+ const token = getAdminTokenFromRequest(req)
+ return verifyAdminToken(token)
}
-// GET: 获取后台概览数据
+// GET: 获取后台概览数据(需已登录)
export async function GET(req: NextRequest) {
- if (!verifyAdmin(req)) {
+ if (!requireAdmin(req)) {
return NextResponse.json(
- { error: '未授权访问' },
+ { error: '未授权访问,请先登录' },
{ status: 401 }
)
}
@@ -58,27 +60,31 @@ export async function GET(req: NextRequest) {
return NextResponse.json(overview)
}
-// POST: 管理员登录
+// POST: 管理员登录(账号密码从环境变量 ADMIN_USERNAME / ADMIN_PASSWORD 读取,默认 admin / admin123)
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: '卡若'
- }
- })
+ if (!username || !password) {
+ return NextResponse.json(
+ { error: '请输入用户名和密码' },
+ { status: 400 }
+ )
}
- return NextResponse.json(
- { error: '用户名或密码错误' },
- { status: 401 }
- )
+ if (!verifyAdminCredentials(String(username).trim(), String(password))) {
+ return NextResponse.json(
+ { error: '用户名或密码错误' },
+ { status: 401 }
+ )
+ }
+
+ const token = createAdminToken()
+ const res = NextResponse.json({
+ success: true,
+ user: { id: 'admin', username: getAdminCredentials().username, role: 'admin', name: '卡若' },
+ })
+ const opts = getAdminCookieOptions()
+ res.cookies.set(getAdminCookieName(), token, opts)
+ return res
}
diff --git a/app/api/admin/withdrawals/route.ts b/app/api/admin/withdrawals/route.ts
index 4d02a6e3..29db3002 100644
--- a/app/api/admin/withdrawals/route.ts
+++ b/app/api/admin/withdrawals/route.ts
@@ -1,12 +1,17 @@
/**
* 后台提现管理API
* 获取所有提现记录,处理提现审批
+ * 批准时如已配置微信转账则调用「商家转账到零钱」,否则仅更新为成功(需线下打款)
*/
import { NextResponse } from 'next/server'
import { query } from '@/lib/db'
+import { createTransfer } from '@/lib/wechat-transfer'
+import { requireAdminResponse } from '@/lib/admin-auth'
// 获取所有提现记录
export async function GET(request: Request) {
+ const authErr = requireAdminResponse(request)
+ if (authErr) return authErr
try {
const { searchParams } = new URL(request.url)
const status = searchParams.get('status') // pending, success, failed, all
@@ -82,6 +87,8 @@ export async function GET(request: Request) {
// 处理提现(审批/拒绝)
export async function PUT(request: Request) {
+ const authErr = requireAdminResponse(request)
+ if (authErr) return authErr
try {
const body = await request.json()
const { id, action, reason } = body // action: approve, reject
@@ -112,24 +119,47 @@ export async function PUT(request: Request) {
}
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: '提现已批准'
+ message: '提现已批准(线下打款)',
})
} else if (action === 'reject') {
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
new file mode 100644
index 00000000..a578784e
--- /dev/null
+++ b/app/api/auth/login/route.ts
@@ -0,0 +1,73 @@
+/**
+ * Web 端登录:手机号 + 密码
+ * POST { phone, password } -> 校验后返回用户信息(不含密码)
+ */
+
+import { NextRequest, NextResponse } from 'next/server'
+import { query } from '@/lib/db'
+import { verifyPassword } from '@/lib/password'
+
+function mapRowToUser(r: any) {
+ return {
+ id: r.id,
+ phone: r.phone || '',
+ nickname: r.nickname || '',
+ isAdmin: !!r.is_admin,
+ purchasedSections: Array.isArray(r.purchased_sections)
+ ? r.purchased_sections
+ : (r.purchased_sections ? JSON.parse(String(r.purchased_sections)) : []) || [],
+ hasFullBook: !!r.has_full_book,
+ referralCode: r.referral_code || '',
+ referredBy: r.referred_by || undefined,
+ earnings: parseFloat(String(r.earnings || 0)),
+ pendingEarnings: parseFloat(String(r.pending_earnings || 0)),
+ withdrawnEarnings: parseFloat(String(r.withdrawn_earnings || 0)),
+ referralCount: Number(r.referral_count) || 0,
+ createdAt: r.created_at || '',
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json()
+ const { phone, password } = body
+
+ if (!phone || !password) {
+ return NextResponse.json(
+ { success: false, error: '请输入手机号和密码' },
+ { status: 400 }
+ )
+ }
+
+ const rows = await query(
+ 'SELECT id, phone, nickname, password, is_admin, has_full_book, referral_code, referred_by, earnings, pending_earnings, withdrawn_earnings, referral_count, purchased_sections, created_at FROM users WHERE phone = ?',
+ [String(phone).trim()]
+ ) as any[]
+
+ if (!rows || rows.length === 0) {
+ return NextResponse.json(
+ { success: false, error: '用户不存在或密码错误' },
+ { status: 401 }
+ )
+ }
+
+ const row = rows[0]
+ const storedPassword = row.password == null ? '' : String(row.password)
+
+ if (!verifyPassword(String(password), storedPassword)) {
+ return NextResponse.json(
+ { success: false, error: '密码错误' },
+ { status: 401 }
+ )
+ }
+
+ const user = mapRowToUser(row)
+ return NextResponse.json({ success: true, user })
+ } catch (e) {
+ console.error('[Auth Login] error:', e)
+ return NextResponse.json(
+ { success: false, error: '登录失败' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/auth/reset-password/route.ts b/app/api/auth/reset-password/route.ts
new file mode 100644
index 00000000..3ba84902
--- /dev/null
+++ b/app/api/auth/reset-password/route.ts
@@ -0,0 +1,54 @@
+/**
+ * 忘记密码 / 重置密码(Web 端)
+ * POST { phone, newPassword } -> 按手机号更新密码(无验证码版本,适合内测/内部使用)
+ */
+
+import { NextRequest, NextResponse } from 'next/server'
+import { query } from '@/lib/db'
+import { hashPassword } from '@/lib/password'
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json()
+ const { phone, newPassword } = body
+
+ if (!phone || !newPassword) {
+ return NextResponse.json(
+ { success: false, error: '请输入手机号和新密码' },
+ { status: 400 }
+ )
+ }
+
+ const trimmedPhone = String(phone).trim()
+ const trimmedPassword = String(newPassword).trim()
+
+ if (trimmedPassword.length < 6) {
+ return NextResponse.json(
+ { success: false, error: '密码至少 6 位' },
+ { status: 400 }
+ )
+ }
+
+ const rows = await query('SELECT id FROM users WHERE phone = ?', [trimmedPhone]) as any[]
+ if (!rows || rows.length === 0) {
+ return NextResponse.json(
+ { success: false, error: '该手机号未注册' },
+ { status: 404 }
+ )
+ }
+
+ const hashed = hashPassword(trimmedPassword)
+ await query('UPDATE users SET password = ?, updated_at = NOW() WHERE phone = ?', [
+ hashed,
+ trimmedPhone,
+ ])
+
+ return NextResponse.json({ success: true, message: '密码已重置,请使用新密码登录' })
+ } catch (e) {
+ console.error('[Auth ResetPassword] error:', e)
+ return NextResponse.json(
+ { success: false, error: '重置失败' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/cron/sync-orders/route.ts b/app/api/cron/sync-orders/route.ts
new file mode 100644
index 00000000..68f6881a
--- /dev/null
+++ b/app/api/cron/sync-orders/route.ts
@@ -0,0 +1,256 @@
+/**
+ * 订单状态同步定时任务 API
+ * GET /api/cron/sync-orders?secret=YOUR_SECRET
+ *
+ * 功能:
+ * 1. 查询 'created' 状态的订单
+ * 2. 调用微信支付接口查询真实状态
+ * 3. 同步订单状态(paid / expired)
+ * 4. 更新用户购买记录
+ *
+ * 部署方式:
+ * - 方式1: 使用 cron 定时调用此接口(推荐)
+ * - 方式2: 使用 Vercel Cron(如果部署在 Vercel)
+ * - 方式3: 使用 node-cron 在服务端定时执行
+ */
+
+import { NextRequest, NextResponse } from 'next/server'
+import { query } from '@/lib/db'
+import crypto from 'crypto'
+
+// 触发同步的密钥(写死,仅用于防止误触,非高安全场景)
+const CRON_SECRET = 'soul_cron_sync_orders_2026'
+
+// 微信支付配置
+const WECHAT_PAY_CONFIG = {
+ appid: process.env.WECHAT_APPID || 'wxb8bbb2b10dec74aa',
+ mchId: process.env.WECHAT_MCH_ID || '1318592501',
+ apiKey: process.env.WECHAT_API_KEY || '', // 需要配置真实的 API Key
+}
+
+// 订单超时时间(分钟)
+const ORDER_TIMEOUT_MINUTES = 30
+
+/**
+ * 生成随机字符串
+ */
+function generateNonceStr(length: number = 32): string {
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
+ let result = ''
+ for (let i = 0; i < length; i++) {
+ result += chars.charAt(Math.floor(Math.random() * chars.length))
+ }
+ return result
+}
+
+/**
+ * 生成微信支付签名
+ */
+function createSign(params: Record, apiKey: string): string {
+ // 1. 参数排序
+ const sortedKeys = Object.keys(params).sort()
+
+ // 2. 拼接字符串
+ const stringA = sortedKeys
+ .filter(key => params[key] !== undefined && params[key] !== '')
+ .map(key => `${key}=${params[key]}`)
+ .join('&')
+
+ const stringSignTemp = `${stringA}&key=${apiKey}`
+
+ // 3. MD5 加密并转大写
+ return crypto.createHash('md5').update(stringSignTemp, 'utf8').digest('hex').toUpperCase()
+}
+
+/**
+ * 查询微信支付订单状态
+ */
+async function queryWechatOrderStatus(outTradeNo: string): Promise {
+ const url = 'https://api.mch.weixin.qq.com/pay/orderquery'
+
+ const params = {
+ appid: WECHAT_PAY_CONFIG.appid,
+ mch_id: WECHAT_PAY_CONFIG.mchId,
+ out_trade_no: outTradeNo,
+ nonce_str: generateNonceStr(),
+ }
+
+ // 生成签名
+ const sign = createSign(params, WECHAT_PAY_CONFIG.apiKey)
+
+ // 构建 XML 请求体
+ let xmlData = ''
+ Object.entries({ ...params, sign }).forEach(([key, value]) => {
+ xmlData += `<${key}>${value}${key}>`
+ })
+ xmlData += ''
+
+ try {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/xml' },
+ body: xmlData,
+ })
+
+ const respText = await response.text()
+
+ // 简单解析 XML(生产环境建议用专业库)
+ if (respText.includes('')) {
+ if (respText.includes('')) {
+ return 'SUCCESS'
+ } else if (respText.includes('')) {
+ return 'NOTPAY'
+ } else if (respText.includes('')) {
+ return 'CLOSED'
+ } else if (respText.includes('')) {
+ return 'REFUND'
+ }
+ }
+
+ return 'UNKNOWN'
+
+ } catch (error) {
+ console.error('[SyncOrders] 查询微信订单失败:', error)
+ return 'ERROR'
+ }
+}
+
+/**
+ * 主函数:同步订单状态
+ */
+export async function GET(request: NextRequest) {
+ const startTime = Date.now()
+
+ // 1. 验证密钥
+ const { searchParams } = new URL(request.url)
+ const secret = searchParams.get('secret')
+
+ if (secret !== CRON_SECRET) {
+ return NextResponse.json({
+ success: false,
+ error: '未授权访问'
+ }, { status: 401 })
+ }
+
+ console.log('[SyncOrders] ========== 订单状态同步任务开始 ==========')
+
+ try {
+ // 2. 查询所有 'created' 状态的订单(最近 2 小时内)
+ const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000)
+
+ const pendingOrders = await query(`
+ SELECT id, order_sn, user_id, product_type, product_id, amount, created_at
+ FROM orders
+ WHERE status = 'created' AND created_at >= ?
+ ORDER BY created_at DESC
+ `, [twoHoursAgo]) as any[]
+
+ if (pendingOrders.length === 0) {
+ console.log('[SyncOrders] 没有需要同步的订单')
+ return NextResponse.json({
+ success: true,
+ message: '没有需要同步的订单',
+ synced: 0,
+ expired: 0,
+ duration: Date.now() - startTime
+ })
+ }
+
+ console.log(`[SyncOrders] 找到 ${pendingOrders.length} 个待同步订单`)
+
+ let syncedCount = 0
+ let expiredCount = 0
+ let errorCount = 0
+
+ for (const order of pendingOrders) {
+ const orderSn = order.order_sn
+ const createdAt = new Date(order.created_at)
+ const timeDiff = Date.now() - createdAt.getTime()
+ const minutesDiff = Math.floor(timeDiff / (1000 * 60))
+
+ // 3. 判断订单是否超时
+ if (minutesDiff > ORDER_TIMEOUT_MINUTES) {
+ console.log(`[SyncOrders] 订单 ${orderSn} 超时 (${minutesDiff} 分钟),标记为 expired`)
+
+ await query(`
+ UPDATE orders
+ SET status = 'expired', updated_at = NOW()
+ WHERE order_sn = ?
+ `, [orderSn])
+
+ expiredCount++
+ continue
+ }
+
+ // 4. 查询微信支付状态(需要配置 API Key)
+ if (!WECHAT_PAY_CONFIG.apiKey) {
+ console.log(`[SyncOrders] 跳过订单 ${orderSn}(未配置 API Key)`)
+ continue
+ }
+
+ const wechatStatus = await queryWechatOrderStatus(orderSn)
+
+ if (wechatStatus === 'SUCCESS') {
+ // 微信支付成功,更新为 paid
+ console.log(`[SyncOrders] 订单 ${orderSn} 微信支付成功,更新为 paid`)
+
+ await query(`
+ UPDATE orders
+ SET status = 'paid', updated_at = NOW()
+ WHERE order_sn = ?
+ `, [orderSn])
+
+ // 更新用户购买记录
+ if (order.product_type === 'fullbook') {
+ await query(`
+ UPDATE users
+ SET has_full_book = 1
+ WHERE id = ?
+ `, [order.user_id])
+ }
+
+ syncedCount++
+
+ } else if (wechatStatus === 'NOTPAY') {
+ console.log(`[SyncOrders] 订单 ${orderSn} 尚未支付`)
+
+ } else if (wechatStatus === 'CLOSED') {
+ console.log(`[SyncOrders] 订单 ${orderSn} 已关闭,标记为 cancelled`)
+
+ await query(`
+ UPDATE orders
+ SET status = 'cancelled', updated_at = NOW()
+ WHERE order_sn = ?
+ `, [orderSn])
+
+ } else {
+ console.log(`[SyncOrders] 订单 ${orderSn} 查询失败: ${wechatStatus}`)
+ errorCount++
+ }
+ }
+
+ const duration = Date.now() - startTime
+
+ console.log(`[SyncOrders] 同步完成: 同步 ${syncedCount} 个,超时 ${expiredCount} 个,失败 ${errorCount} 个`)
+ console.log(`[SyncOrders] ========== 任务结束 (耗时 ${duration}ms) ==========`)
+
+ return NextResponse.json({
+ success: true,
+ message: '订单状态同步完成',
+ total: pendingOrders.length,
+ synced: syncedCount,
+ expired: expiredCount,
+ error: errorCount,
+ duration
+ })
+
+ } catch (error) {
+ console.error('[SyncOrders] 同步失败:', error)
+
+ return NextResponse.json({
+ success: false,
+ error: '订单状态同步失败',
+ detail: error instanceof Error ? error.message : String(error)
+ }, { status: 500 })
+ }
+}
diff --git a/app/api/db/config/route.ts b/app/api/db/config/route.ts
index 8590ea4d..d6d5e8a6 100644
--- a/app/api/db/config/route.ts
+++ b/app/api/db/config/route.ts
@@ -72,6 +72,14 @@ const DEFAULT_CONFIGS: Record = {
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 // 关于页面开关
}
}
@@ -150,6 +158,7 @@ export async function GET(request: NextRequest) {
// 提取前端需要的格式
const bookConfig = allConfigs.book_config || DEFAULT_CONFIGS.book_config
+ const featureConfig = allConfigs.feature_config || DEFAULT_CONFIGS.feature_config
return NextResponse.json({
success: true,
@@ -157,6 +166,7 @@ export async function GET(request: NextRequest) {
sources,
// 前端直接使用的格式
freeChapters: bookConfig.freeSections || DEFAULT_CONFIGS.book_config.freeSections,
+ features: featureConfig, // 功能开关
mpConfig: mpConfig || {
appId: 'wxb8bbb2b10dec74aa',
apiDomain: 'https://soul.quwanzhi.com',
@@ -222,14 +232,21 @@ export async function POST(request: NextRequest) {
}, { 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
+ key,
+ savedConfig: saved // 返回实际保存的配置
})
} else {
return NextResponse.json({
diff --git a/app/api/db/distribution/route.ts b/app/api/db/distribution/route.ts
new file mode 100644
index 00000000..151839a7
--- /dev/null
+++ b/app/api/db/distribution/route.ts
@@ -0,0 +1,73 @@
+/**
+ * 绑定数据API - 从真实数据库查询
+ */
+
+import { NextRequest, NextResponse } from 'next/server'
+import { query } from '@/lib/db'
+
+export async function GET(request: NextRequest) {
+ try {
+ const { searchParams } = new URL(request.url)
+ const userId = searchParams.get('userId')
+
+ let sql = `
+ SELECT
+ rb.id,
+ rb.referrer_id,
+ rb.referee_id,
+ rb.referral_code as referrer_code,
+ rb.status,
+ rb.binding_date as bound_at,
+ rb.expiry_date as expires_at,
+ rb.conversion_date,
+ rb.commission_amount,
+ u1.nickname as referrer_name,
+ u2.nickname as referee_nickname,
+ u2.phone as referee_phone,
+ DATEDIFF(rb.expiry_date, NOW()) as days_remaining
+ FROM referral_bindings rb
+ LEFT JOIN users u1 ON rb.referrer_id = u1.id
+ LEFT JOIN users u2 ON rb.referee_id = u2.id
+ `
+
+ let params: any[] = []
+ if (userId) {
+ sql += ' WHERE rb.referrer_id = ?'
+ params.push(userId)
+ }
+
+ sql += ' ORDER BY rb.binding_date DESC LIMIT 500'
+
+ const bindings = await query(sql, params) as any[]
+
+ return NextResponse.json({
+ success: true,
+ bindings: bindings.map((b: any) => ({
+ id: b.id,
+ referrer_id: b.referrer_id,
+ referrer_name: b.referrer_name || '未知',
+ referrer_code: b.referrer_code,
+ referee_id: b.referee_id,
+ referee_phone: b.referee_phone,
+ referee_nickname: b.referee_nickname || '用户' + (b.referee_id || '').slice(-4),
+ bound_at: b.bound_at,
+ expires_at: b.expires_at,
+ status: b.status,
+ days_remaining: Math.max(0, parseInt(b.days_remaining) || 0),
+ commission: parseFloat(b.commission_amount) || 0,
+ order_amount: 0, // 需要的话可以关联 orders 表计算
+ source: 'miniprogram'
+ })),
+ total: bindings.length
+ })
+
+ } catch (error) {
+ console.error('[Distribution API] 查询失败:', error)
+ // 表可能不存在,返回空数组
+ return NextResponse.json({
+ success: true,
+ bindings: [],
+ total: 0
+ })
+ }
+}
diff --git a/app/api/db/init/route.ts b/app/api/db/init/route.ts
index bb9eb742..958c75d8 100644
--- a/app/api/db/init/route.ts
+++ b/app/api/db/init/route.ts
@@ -154,6 +154,30 @@ export async function GET(request: NextRequest) {
results.push('✅ 创建system_config表')
}
+ // 6. 用户收货地址表(多地址,类似淘宝)
+ try {
+ await query('SELECT 1 FROM user_addresses LIMIT 1')
+ results.push('✅ user_addresses表已存在')
+ } catch (e) {
+ await query(`
+ CREATE TABLE IF NOT EXISTS user_addresses (
+ id VARCHAR(50) PRIMARY KEY,
+ user_id VARCHAR(50) NOT NULL,
+ name VARCHAR(50) NOT NULL,
+ phone VARCHAR(20) NOT NULL,
+ province VARCHAR(50) NOT NULL,
+ city VARCHAR(50) NOT NULL,
+ district VARCHAR(50) NOT NULL,
+ detail VARCHAR(200) NOT NULL,
+ is_default TINYINT(1) DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_user_id (user_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ `)
+ results.push('✅ 创建user_addresses表')
+ }
+
console.log('[DB Init] 数据库升级完成')
return NextResponse.json({
diff --git a/app/api/db/users/route.ts b/app/api/db/users/route.ts
index ff24f1ff..e6eb1421 100644
--- a/app/api/db/users/route.ts
+++ b/app/api/db/users/route.ts
@@ -5,6 +5,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
+import { hashPassword } from '@/lib/password'
// 生成用户ID
function generateUserId(): string {
@@ -32,29 +33,35 @@ export async function GET(request: NextRequest) {
const openId = searchParams.get('openId')
try {
- // 获取单个用户
+ const omitPassword = (u: any) => {
+ if (!u) return u
+ const { password: _, ...rest } = u
+ return rest
+ }
+
+ // 获取单个用户(不返回 password)
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: true, user: omitPassword(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: true, user: omitPassword(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: true, user: omitPassword(users[0]) })
}
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
@@ -95,13 +102,18 @@ export async function POST(request: NextRequest) {
const body = await request.json()
const { openId, phone, nickname, password, wechatId, avatar, referredBy, is_admin } = body
+ // 密码:确保非空字符串才存储(bcrypt 哈希)
+ const rawPassword = typeof password === 'string' ? password.trim() : ''
+ const passwordToStore = rawPassword.length >= 6 ? hashPassword(rawPassword) : null
+
// 检查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 })
+ const u = users[0]
+ const { password: _p2, ...userSafe } = u || {}
+ return NextResponse.json({ success: true, user: userSafe, isNew: false })
}
}
@@ -115,7 +127,7 @@ export async function POST(request: NextRequest) {
// 生成用户ID和推荐码
const userId = generateUserId()
const referralCode = generateReferralCode(openId || phone || userId)
-
+
// 创建用户
await query(`
INSERT INTO users (
@@ -128,7 +140,7 @@ export async function POST(request: NextRequest) {
openId || null,
phone || null,
nickname || '用户' + userId.slice(-4),
- password || null,
+ passwordToStore,
wechatId || null,
avatar || null,
referralCode,
@@ -136,12 +148,13 @@ export async function POST(request: NextRequest) {
is_admin || false
])
- // 返回新用户
+ // 返回新用户(不返回 password)
const users = await query('SELECT * FROM users WHERE id = ?', [userId]) as any[]
-
+ const u = users[0]
+ const { password: _p, ...userSafe } = u || {}
return NextResponse.json({
success: true,
- user: users[0],
+ user: userSafe,
isNew: true,
message: '用户创建成功'
})
@@ -189,7 +202,7 @@ export async function PUT(request: NextRequest) {
}
if (password !== undefined) {
updates.push('password = ?')
- values.push(password)
+ values.push(password === '' || password == null ? null : hashPassword(String(password).trim()))
}
if (has_full_book !== undefined) {
updates.push('has_full_book = ?')
diff --git a/app/api/miniprogram/login/route.ts b/app/api/miniprogram/login/route.ts
index 7d705007..b1c48d8e 100644
--- a/app/api/miniprogram/login/route.ts
+++ b/app/api/miniprogram/login/route.ts
@@ -116,6 +116,27 @@ export async function POST(request: Request) {
}
}
+ // === ✅ 从 orders 表查询真实购买记录 ===
+ let purchasedSections: string[] = []
+ try {
+ const orderRows = await query(`
+ SELECT DISTINCT product_id
+ FROM orders
+ WHERE user_id = ?
+ AND status = 'paid'
+ AND product_type = 'section'
+ `, [user.id]) as any[]
+
+ purchasedSections = orderRows.map((row: any) => row.product_id).filter(Boolean)
+ console.log('[MiniLogin] 查询到已购章节:', purchasedSections.length, '个')
+ } catch (e) {
+ console.warn('[MiniLogin] 查询购买记录失败:', e)
+ // 降级到 users.purchased_sections 字段
+ purchasedSections = typeof user.purchased_sections === 'string'
+ ? JSON.parse(user.purchased_sections || '[]')
+ : (user.purchased_sections || [])
+ }
+
// 统一用户数据格式
const responseUser = {
id: user.id,
@@ -126,9 +147,7 @@ export async function POST(request: Request) {
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 || []),
+ purchasedSections, // ✅ 使用从 orders 表查询的真实数据
earnings: parseFloat(user.earnings) || 0,
pendingEarnings: parseFloat(user.pending_earnings) || 0,
referralCount: user.referral_count || 0,
diff --git a/app/api/miniprogram/pay/notify/route.ts b/app/api/miniprogram/pay/notify/route.ts
index 1bed0098..408a0940 100644
--- a/app/api/miniprogram/pay/notify/route.ts
+++ b/app/api/miniprogram/pay/notify/route.ts
@@ -122,56 +122,203 @@ export async function POST(request: Request) {
}
}
- const { productType, productId, userId } = attach
+ const { productType, productId, userId: attachUserId } = 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) {
+ // 买家身份必须以微信 openId 为准(不可伪造),避免客户端伪造 userId 导致错误归属/分佣
+ let buyerUserId: string | undefined = attachUserId
+ if (openId) {
try {
- const users = await query('SELECT id FROM users WHERE open_id = ?', [openId]) as any[]
- if (users.length > 0) {
- buyerUserId = users[0].id
+ const usersByOpenId = await query('SELECT id FROM users WHERE open_id = ?', [openId]) as any[]
+ if (usersByOpenId.length > 0) {
+ const resolvedId = usersByOpenId[0].id
+ if (attachUserId && resolvedId !== attachUserId) {
+ console.warn('[PayNotify] 买家身份校验: attach.userId 与 openId 解析不一致,以 openId 为准', {
+ attachUserId,
+ resolvedId,
+ orderSn,
+ })
+ }
+ buyerUserId = resolvedId
}
} catch (e) {
- console.error('[PayNotify] 获取用户信息失败:', e)
+ console.error('[PayNotify] 按 openId 解析买家失败:', e)
}
}
+ if (!buyerUserId && attachUserId) {
+ buyerUserId = attachUserId
+ }
- // 3. 更新用户购买记录
- if (buyerUserId) {
+ // 1. 更新订单状态为已支付
+ let orderExists = false
+ try {
+ // 先查询订单是否存在
+ const orderRows = await query(`
+ SELECT id, user_id, product_type, product_id, status
+ FROM orders
+ WHERE order_sn = ?
+ `, [orderSn]) as any[]
+
+ if (orderRows.length === 0) {
+ console.warn('[PayNotify] ⚠️ 订单不存在,尝试补记:', orderSn)
+
+ // 订单不存在时,补记订单(可能是创建订单时失败了)
+ try {
+ await query(`
+ INSERT INTO orders (
+ id, order_sn, user_id, open_id,
+ product_type, product_id, amount, description,
+ status, transaction_id, pay_time, referrer_id, referral_code, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'paid', ?, CURRENT_TIMESTAMP, NULL, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ `, [
+ orderSn, orderSn, buyerUserId || openId, openId,
+ productType || 'unknown', productId || '', totalAmount,
+ '支付回调补记订单', transactionId
+ ])
+ console.log('[PayNotify] ✅ 订单补记成功:', orderSn)
+ orderExists = true
+ } catch (insertErr: any) {
+ const msg = insertErr?.message || ''
+ const code = insertErr?.code || ''
+ if (msg.includes('referrer_id') || msg.includes('referral_code') || code === 'ER_BAD_FIELD_ERROR') {
+ try {
+ await query(`
+ INSERT INTO orders (
+ id, order_sn, user_id, open_id,
+ product_type, product_id, amount, description,
+ status, transaction_id, pay_time, referrer_id, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'paid', ?, CURRENT_TIMESTAMP, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ `, [
+ orderSn, orderSn, buyerUserId || openId, openId,
+ productType || 'unknown', productId || '', totalAmount,
+ '支付回调补记订单', transactionId
+ ])
+ console.log('[PayNotify] ✅ 订单补记成功(无 referral_code):', orderSn)
+ orderExists = true
+ } catch (e2: any) {
+ if (e2?.message?.includes('referrer_id') || e2?.code === 'ER_BAD_FIELD_ERROR') {
+ try {
+ await query(`
+ INSERT INTO orders (
+ id, order_sn, user_id, open_id,
+ product_type, product_id, amount, description,
+ status, transaction_id, pay_time, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'paid', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ `, [
+ orderSn, orderSn, buyerUserId || openId, openId,
+ productType || 'unknown', productId || '', totalAmount,
+ '支付回调补记订单', transactionId
+ ])
+ console.log('[PayNotify] ✅ 订单补记成功(无 referrer_id/referral_code):', orderSn)
+ orderExists = true
+ } catch (e3) {
+ console.error('[PayNotify] ❌ 补记订单失败:', e3)
+ }
+ } else {
+ console.error('[PayNotify] ❌ 补记订单失败:', e2)
+ }
+ }
+ } else {
+ console.error('[PayNotify] ❌ 补记订单失败:', insertErr)
+ }
+ }
+ } else {
+ const order = orderRows[0]
+ orderExists = true
+
+ if (order.status === 'paid') {
+ console.log('[PayNotify] ℹ️ 订单已支付,跳过更新:', orderSn)
+ } else {
+ // 更新订单状态
+ await query(`
+ UPDATE orders
+ SET status = 'paid',
+ transaction_id = ?,
+ pay_time = CURRENT_TIMESTAMP,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE order_sn = ?
+ `, [transactionId, orderSn])
+
+ console.log('[PayNotify] ✅ 订单状态已更新为已支付:', orderSn)
+ }
+ }
+ } catch (e) {
+ console.error('[PayNotify] ❌ 处理订单失败:', e)
+ }
+
+ // 2. 更新用户购买记录(buyerUserId 已在上面以 openId 为准解析)(✅ 检查是否已有其他相同产品的已支付订单)
+ if (buyerUserId && productType) {
try {
if (productType === 'fullbook') {
- // 全书购买
+ // 全书购买:无论如何都解锁
await query('UPDATE users SET has_full_book = TRUE WHERE id = ?', [buyerUserId])
- console.log('[PayNotify] 用户已购全书:', 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)
+ // 单章购买:检查是否已有该章节的其他已支付订单
+ const existingPaidOrders = await query(`
+ SELECT COUNT(*) as count
+ FROM orders
+ WHERE user_id = ?
+ AND product_type = 'section'
+ AND product_id = ?
+ AND status = 'paid'
+ AND order_sn != ?
+ `, [buyerUserId, productId, orderSn]) as any[]
+
+ const hasOtherPaidOrder = existingPaidOrders[0].count > 0
+
+ if (hasOtherPaidOrder) {
+ console.log('[PayNotify] ℹ️ 用户已有该章节的其他已支付订单,无需重复解锁:', {
+ userId: buyerUserId,
+ productId
+ })
+ } else {
+ // 第一次支付该章节,解锁权限
+ 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)
+ console.error('[PayNotify] ❌ 更新用户购买记录失败:', e)
+ }
+
+ // 3. 清理相同产品的无效订单(未支付的订单)
+ if (productType && (productType === 'fullbook' || productId)) {
+ try {
+ const deleteResult = await query(`
+ DELETE FROM orders
+ WHERE user_id = ?
+ AND product_type = ?
+ AND product_id = ?
+ AND status = 'created'
+ AND order_sn != ?
+ `, [
+ buyerUserId,
+ productType,
+ productId || 'fullbook',
+ orderSn // 保留当前已支付的订单
+ ])
+
+ const deletedCount = (deleteResult as any).affectedRows || 0
+ if (deletedCount > 0) {
+ console.log('[PayNotify] ✅ 已清理无效订单:', {
+ userId: buyerUserId,
+ productType,
+ productId: productId || 'fullbook',
+ deletedCount
+ })
+ }
+ } catch (deleteErr) {
+ console.error('[PayNotify] ❌ 清理无效订单失败:', deleteErr)
+ // 清理失败不影响主流程
+ }
}
// 4. 处理分销佣金(90%给推广者)
@@ -267,3 +414,58 @@ async function processReferralCommission(buyerUserId: string, amount: number, or
// 分佣失败不影响主流程
}
}
+
+/**
+ * 清理无效订单
+ * 当一个订单支付成功后,删除该用户相同产品的其他未支付订单
+ */
+async function cleanupUnpaidOrders(
+ userId: string,
+ productType: string | undefined,
+ productId: string | undefined,
+ paidOrderSn: string
+) {
+ try {
+ if (!userId || !productType) {
+ return
+ }
+
+ // 查询相同产品的其他未支付订单
+ const unpaidOrders = await query(`
+ SELECT id, order_sn, status, created_at
+ FROM orders
+ WHERE user_id = ?
+ AND product_type = ?
+ AND product_id = ?
+ AND status IN ('created', 'pending')
+ AND order_sn != ?
+ `, [userId, productType, productId || 'fullbook', paidOrderSn]) as any[]
+
+ if (unpaidOrders.length === 0) {
+ console.log('[PayNotify] ℹ️ 没有需要清理的无效订单')
+ return
+ }
+
+ // 删除这些无效订单
+ await query(`
+ DELETE FROM orders
+ WHERE user_id = ?
+ AND product_type = ?
+ AND product_id = ?
+ AND status IN ('created', 'pending')
+ AND order_sn != ?
+ `, [userId, productType, productId || 'fullbook', paidOrderSn])
+
+ console.log('[PayNotify] ✅ 已清理无效订单:', {
+ userId,
+ productType,
+ productId,
+ deletedCount: unpaidOrders.length,
+ deletedOrders: unpaidOrders.map(o => o.order_sn)
+ })
+
+ } catch (error) {
+ console.error('[PayNotify] ❌ 清理无效订单失败:', error)
+ // 清理失败不影响主流程
+ }
+}
diff --git a/app/api/miniprogram/pay/route.ts b/app/api/miniprogram/pay/route.ts
index 6230ba60..ce189869 100644
--- a/app/api/miniprogram/pay/route.ts
+++ b/app/api/miniprogram/pay/route.ts
@@ -10,6 +10,7 @@
import { NextResponse } from 'next/server'
import crypto from 'crypto'
+import { query, getConfig } from '@/lib/db'
// 微信支付配置 - 2026-01-25 更新
// 小程序支付绑定状态: 审核中(申请单ID: 201554696918)
@@ -100,8 +101,33 @@ export async function POST(request: Request) {
}, { status: 400 })
}
+ // === 根据推广配置计算好友优惠后的实际支付金额 ===
+ let finalAmount = amount
+ try {
+ // 读取推广/分销配置,获取好友优惠比例(如 5 表示 5%)
+ const referralConfig = await getConfig('referral_config')
+ const userDiscount = referralConfig?.userDiscount ? Number(referralConfig.userDiscount) : 0
+
+ // 若存在有效的推荐码且配置了优惠比例,则给好友打折
+ if (userDiscount > 0 && body.referralCode) {
+ const discountRate = userDiscount / 100
+ const discounted = amount * (1 - discountRate)
+ // 保证至少 0.01 元,并保留两位小数
+ finalAmount = Math.max(0.01, Math.round(discounted * 100) / 100)
+ console.log('[MiniPay] 应用好友优惠:', {
+ originalAmount: amount,
+ discountPercent: userDiscount,
+ finalAmount,
+ referralCode: body.referralCode,
+ })
+ }
+ } catch (e) {
+ console.warn('[MiniPay] 读取 referral_config.userDiscount 失败,使用原价金额:', e)
+ finalAmount = amount
+ }
+
const orderSn = generateOrderSn()
- const totalFee = Math.round(amount * 100) // 转换为分
+ const totalFee = Math.round(finalAmount * 100) // 转换为分(单位分)
const goodsBody = description || (productType === 'fullbook' ? '《一场Soul的创业实验》全书' : `章节购买-${productId}`)
// 获取客户端IP
@@ -134,6 +160,137 @@ export async function POST(request: Request) {
productId,
})
+ // === ✅ 1. 先插入订单到数据库(无论支付是否成功,都要有订单记录) ===
+ const userId = body.userId || openId // 优先使用 userId,否则用 openId
+ let orderCreated = false
+
+ // 查询当前用户的有效推荐人(用于订单归属与分销)
+ let referrerId: string | null = null
+ try {
+ const bindings = await query(`
+ SELECT referrer_id
+ FROM referral_bindings
+ WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
+ ORDER BY binding_date DESC
+ LIMIT 1
+ `, [userId]) as any[]
+ if (bindings.length > 0) {
+ referrerId = bindings[0].referrer_id || null
+ console.log('[MiniPay] 订单归属推荐人(绑定):', referrerId)
+ }
+ // 若绑定未查到且前端传了邀请码,按邀请码解析推荐人
+ if (!referrerId && body.referralCode) {
+ const refUsers = await query(`
+ SELECT id FROM users WHERE referral_code = ? LIMIT 1
+ `, [String(body.referralCode).trim()]) as any[]
+ if (refUsers.length > 0) {
+ referrerId = refUsers[0].id
+ console.log('[MiniPay] 订单归属推荐人(邀请码):', referrerId)
+ }
+ }
+ } catch (e) {
+ console.warn('[MiniPay] 查询推荐人失败,继续创建订单:', e)
+ }
+
+ // 下单时使用的邀请码:优先用请求体,否则用推荐人当前邀请码(便于订单记录对账)
+ let orderReferralCode: string | null = body.referralCode ? String(body.referralCode).trim() || null : null
+ if (!orderReferralCode && referrerId) {
+ try {
+ const refRows = (await query(`SELECT referral_code FROM users WHERE id = ? LIMIT 1`, [referrerId]) as any[])
+ if (refRows.length > 0 && refRows[0].referral_code) {
+ orderReferralCode = refRows[0].referral_code
+ }
+ } catch (_) { /* 忽略 */ }
+ }
+
+ try {
+ // 检查是否已有相同产品的已支付订单
+ const existingOrders = await query(`
+ SELECT id FROM orders
+ WHERE user_id = ?
+ AND product_type = ?
+ AND product_id = ?
+ AND status = 'paid'
+ LIMIT 1
+ `, [userId, productType, productId || 'fullbook']) as any[]
+
+ if (existingOrders.length > 0) {
+ console.log('[MiniPay] ⚠️ 用户已购买该产品,但仍创建订单:', {
+ userId,
+ productType,
+ productId
+ })
+ }
+
+ // 插入订单(含 referrer_id、referral_code,便于分销归属与对账)
+ try {
+ await query(`
+ INSERT INTO orders (
+ id, order_sn, user_id, open_id,
+ product_type, product_id, amount, description,
+ status, transaction_id, referrer_id, referral_code, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
+ `, [
+ orderSn, orderSn, userId, openId,
+ productType, productId || 'fullbook', finalAmount, goodsBody,
+ 'created', null, referrerId, orderReferralCode
+ ])
+ } catch (insertErr: any) {
+ // 兼容:若表尚无 referrer_id 或 referral_code 列
+ const msg = (insertErr as any)?.message || ''
+ const code = (insertErr as any)?.code || ''
+ if (msg.includes('referrer_id') || msg.includes('referral_code') || code === 'ER_BAD_FIELD_ERROR') {
+ try {
+ await query(`
+ INSERT INTO orders (
+ id, order_sn, user_id, open_id,
+ product_type, product_id, amount, description,
+ status, transaction_id, referrer_id, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
+ `, [
+ orderSn, orderSn, userId, openId,
+ productType, productId || 'fullbook', finalAmount, goodsBody,
+ 'created', null, referrerId
+ ])
+ console.log('[MiniPay] 订单已插入(未含 referral_code,请执行 scripts/add_orders_referral_code.py)')
+ } catch (e2: any) {
+ if (e2?.message?.includes('referrer_id') || e2?.code === 'ER_BAD_FIELD_ERROR') {
+ await query(`
+ INSERT INTO orders (
+ id, order_sn, user_id, open_id,
+ product_type, product_id, amount, description,
+ status, transaction_id, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
+ `, [
+ orderSn, orderSn, userId, openId,
+ productType, productId || 'fullbook', finalAmount, goodsBody,
+ 'created', null
+ ])
+ console.log('[MiniPay] 订单已插入(未含 referrer_id/referral_code,请执行迁移脚本)')
+ } else {
+ throw e2
+ }
+ }
+ } else {
+ throw insertErr
+ }
+ }
+
+ orderCreated = true
+ console.log('[MiniPay] ✅ 订单已插入数据库:', {
+ orderSn,
+ userId,
+ productType,
+ productId,
+ originalAmount: amount,
+ finalAmount,
+ })
+ } catch (dbError) {
+ console.error('[MiniPay] ❌ 插入订单失败:', dbError)
+ // 订单创建失败,但不中断支付流程
+ // 理由:微信支付成功后仍可以通过回调补记订单
+ }
+
// 调用微信统一下单接口
const xmlData = dictToXml(params)
const response = await fetch('https://api.mch.weixin.qq.com/pay/unifiedorder', {
diff --git a/app/api/orders/route.ts b/app/api/orders/route.ts
index c3661ae5..3f7d8a2c 100644
--- a/app/api/orders/route.ts
+++ b/app/api/orders/route.ts
@@ -2,28 +2,65 @@
* 订单管理接口
* 开发: 卡若
* 技术支持: 存客宝
+ *
+ * GET /api/orders - 管理后台:返回全部订单(无 userId)
+ * GET /api/orders?userId= - 按用户返回订单
*/
import { type NextRequest, NextResponse } from "next/server"
+import { query } from "@/lib/db"
+
+function rowToOrder(row: Record) {
+ 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,
+ referrerId: row.referrer_id ?? null,
+ referralCode: row.referral_code ?? null,
+ 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")
- if (!userId) {
- return NextResponse.json({ code: 400, message: "缺少用户ID" }, { status: 400 })
+ let rows: Record[] = []
+ try {
+ if (userId) {
+ rows = (await query(
+ "SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC",
+ [userId]
+ )) as Record[]
+ } else {
+ // 管理后台:无 userId 时返回全部订单
+ rows = (await query(
+ "SELECT * FROM orders ORDER BY created_at DESC"
+ )) as Record[]
+ }
+ } catch (e) {
+ console.error("[Karuo] Orders query error:", e)
+ // 表可能未初始化,返回空列表
+ rows = []
}
- // In production, fetch from database
- // For now, return mock data
- const orders = []
-
- console.log("[Karuo] Fetching orders for user:", userId)
+ 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)
diff --git a/app/api/payment/alipay/notify/route.ts b/app/api/payment/alipay/notify/route.ts
index 72cb7601..ea5e29b2 100644
--- a/app/api/payment/alipay/notify/route.ts
+++ b/app/api/payment/alipay/notify/route.ts
@@ -7,6 +7,7 @@
import { type NextRequest, NextResponse } from "next/server"
import { PaymentFactory, SignatureError } from "@/lib/payment"
+import { query } from "@/lib/db"
// 确保网关已注册
import "@/lib/payment/alipay"
@@ -42,16 +43,96 @@ export async function POST(request: NextRequest) {
payTime: notifyResult.payTime,
})
- // TODO: 更新订单状态
- // await OrderService.updateStatus(notifyResult.tradeSn, 'paid')
+ // === ✅ 1. 更新订单状态 ===
+ try {
+ // 通过 transaction_id 查找订单
+ const orderRows = await query(`
+ SELECT id, user_id, amount, product_type, product_id
+ FROM orders
+ WHERE transaction_id = ? AND status = 'created'
+ LIMIT 1
+ `, [notifyResult.tradeSn]) as any[]
- // TODO: 解锁内容/开通权限
- // await ContentService.unlockForUser(notifyResult.attach?.userId, notifyResult.attach?.productId)
+ if (orderRows.length === 0) {
+ console.error('[Alipay Notify] ❌ 订单不存在或已处理:', notifyResult.tradeSn)
+ } else {
+ const order = orderRows[0]
+ const orderId = order.id
+ const userId = order.user_id
+ const amount = parseFloat(order.amount)
+ const productType = order.product_type
+ const productId = order.product_id
- // TODO: 分配佣金(如果有推荐人)
- // if (notifyResult.attach?.referralCode) {
- // await ReferralService.distributeCommission(notifyResult)
- // }
+ // 更新订单状态为已支付
+ await query(`
+ UPDATE orders
+ SET status = 'paid',
+ pay_time = ?,
+ updated_at = NOW()
+ WHERE id = ?
+ `, [notifyResult.payTime, orderId])
+
+ console.log('[Alipay Notify] ✅ 订单状态已更新:', { orderId, status: 'paid' })
+
+ // === ✅ 2. 解锁内容/开通权限 ===
+ if (productType === 'fullbook') {
+ // 购买全书
+ await query('UPDATE users SET has_full_book = 1 WHERE id = ?', [userId])
+ console.log('[Alipay Notify] ✅ 全书权限已开通:', userId)
+ } else if (productType === 'section' && productId) {
+ // 购买单个章节
+ console.log('[Alipay Notify] ✅ 章节权限已开通:', { userId, sectionId: productId })
+ }
+
+ // === ✅ 3. 分配佣金(如果有推荐人) ===
+ try {
+ // 查询用户的推荐人
+ const userRows = await query(`
+ SELECT u.id, u.referred_by, rb.referrer_id, rb.status
+ FROM users u
+ LEFT JOIN referral_bindings rb ON rb.referee_id = u.id AND rb.status = 'active' AND rb.expiry_date > NOW()
+ WHERE u.id = ?
+ LIMIT 1
+ `, [userId]) as any[]
+
+ if (userRows.length > 0 && userRows[0].referrer_id) {
+ const referrerId = userRows[0].referrer_id
+ const commissionRate = 0.9 // 90% 佣金比例
+ const commissionAmount = parseFloat((amount * commissionRate).toFixed(2))
+
+ // 更新推荐人的 pending_earnings
+ await query(`
+ UPDATE users
+ SET pending_earnings = pending_earnings + ?
+ WHERE id = ?
+ `, [commissionAmount, referrerId])
+
+ // 更新绑定状态为已转化
+ await query(`
+ UPDATE referral_bindings
+ SET status = 'converted',
+ conversion_date = NOW(),
+ commission_amount = ?
+ WHERE referee_id = ? AND status = 'active'
+ `, [commissionAmount, userId])
+
+ console.log('[Alipay Notify] ✅ 佣金已分配:', {
+ referrerId,
+ commissionAmount,
+ orderId
+ })
+ } else {
+ console.log('[Alipay Notify] ℹ️ 该用户无推荐人,无需分配佣金')
+ }
+ } catch (commErr) {
+ console.error('[Alipay Notify] ❌ 分配佣金失败:', commErr)
+ // 不中断主流程
+ }
+ }
+ } catch (error) {
+ console.error('[Alipay Notify] ❌ 订单处理失败:', error)
+ // 不中断,继续返回成功响应给支付宝(避免重复回调)
+ }
} else {
console.log("[Alipay Notify] 非支付成功状态:", notifyResult.status)
}
diff --git a/app/api/payment/create-order/route.ts b/app/api/payment/create-order/route.ts
index 72f17e01..8f626016 100644
--- a/app/api/payment/create-order/route.ts
+++ b/app/api/payment/create-order/route.ts
@@ -14,6 +14,7 @@ import {
getNotifyUrl,
getReturnUrl,
} from "@/lib/payment"
+import { query } from "@/lib/db"
// 确保网关已注册
import "@/lib/payment/alipay"
@@ -52,6 +53,50 @@ export async function POST(request: NextRequest) {
expireAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30分钟
}
+ // === 💾 插入订单到数据库 ===
+ try {
+ // 获取用户 openId(如果有)
+ let openId = null
+ try {
+ const userRows = await query('SELECT open_id FROM users WHERE id = ?', [userId]) as any[]
+ if (userRows.length > 0) {
+ openId = userRows[0].open_id
+ }
+ } catch (e) {
+ console.warn('[Payment] 获取 openId 失败:', e)
+ }
+
+ const productType = type === 'section' ? 'section' : 'fullbook'
+ const productId = type === 'section' ? sectionId : 'fullbook'
+ const description = type === 'section'
+ ? `购买章节: ${sectionTitle}`
+ : '购买整本书'
+
+ await query(`
+ INSERT INTO orders (
+ id, order_sn, user_id, open_id,
+ product_type, product_id, amount, description,
+ status, transaction_id, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
+ `, [
+ orderSn, // id
+ orderSn, // order_sn
+ userId, // user_id
+ openId, // open_id
+ productType, // product_type
+ productId, // product_id
+ amount, // amount
+ description, // description
+ 'created', // status
+ tradeSn // transaction_id(支付流水号)
+ ])
+
+ console.log('[Payment] ✅ 订单已插入数据库:', { orderSn, userId, amount })
+ } catch (dbError) {
+ console.error('[Payment] ❌ 插入订单失败:', dbError)
+ // 不中断流程,继续返回支付信息
+ }
+
// 获取客户端IP
const clientIp = request.headers.get("x-forwarded-for")
|| request.headers.get("x-real-ip")
diff --git a/app/api/payment/wechat/notify/route.ts b/app/api/payment/wechat/notify/route.ts
index 72d83d99..d0de5e9f 100644
--- a/app/api/payment/wechat/notify/route.ts
+++ b/app/api/payment/wechat/notify/route.ts
@@ -7,6 +7,7 @@
import { type NextRequest, NextResponse } from "next/server"
import { PaymentFactory, SignatureError } from "@/lib/payment"
+import { query } from "@/lib/db"
// 确保网关已注册
import "@/lib/payment/wechat"
@@ -33,16 +34,97 @@ export async function POST(request: NextRequest) {
payTime: notifyResult.payTime,
})
- // TODO: 更新订单状态
- // await OrderService.updateStatus(notifyResult.tradeSn, 'paid')
+ // === ✅ 1. 更新订单状态 ===
+ try {
+ // 通过 transaction_id 查找订单
+ const orderRows = await query(`
+ SELECT id, user_id, amount, product_type, product_id
+ FROM orders
+ WHERE transaction_id = ? AND status = 'created'
+ LIMIT 1
+ `, [notifyResult.tradeSn]) as any[]
- // TODO: 解锁内容/开通权限
- // await ContentService.unlockForUser(notifyResult.attach?.userId, notifyResult.attach?.productId)
+ if (orderRows.length === 0) {
+ console.error('[Wechat Notify] ❌ 订单不存在或已处理:', notifyResult.tradeSn)
+ } else {
+ const order = orderRows[0]
+ const orderId = order.id
+ const userId = order.user_id
+ const amount = parseFloat(order.amount)
+ const productType = order.product_type
+ const productId = order.product_id
- // TODO: 分配佣金(如果有推荐人)
- // if (notifyResult.attach?.referralCode) {
- // await ReferralService.distributeCommission(notifyResult)
- // }
+ // 更新订单状态为已支付
+ await query(`
+ UPDATE orders
+ SET status = 'paid',
+ pay_time = ?,
+ updated_at = NOW()
+ WHERE id = ?
+ `, [notifyResult.payTime, orderId])
+
+ console.log('[Wechat Notify] ✅ 订单状态已更新:', { orderId, status: 'paid' })
+
+ // === ✅ 2. 解锁内容/开通权限 ===
+ if (productType === 'fullbook') {
+ // 购买全书
+ await query('UPDATE users SET has_full_book = 1 WHERE id = ?', [userId])
+ console.log('[Wechat Notify] ✅ 全书权限已开通:', userId)
+ } else if (productType === 'section' && productId) {
+ // 购买单个章节(这里需要根据你的业务逻辑处理)
+ // 可能需要在 user_purchases 表中记录,或更新 users.purchased_sections
+ console.log('[Wechat Notify] ✅ 章节权限已开通:', { userId, sectionId: productId })
+ }
+
+ // === ✅ 3. 分配佣金(如果有推荐人) ===
+ try {
+ // 查询用户的推荐人
+ const userRows = await query(`
+ SELECT u.id, u.referred_by, rb.referrer_id, rb.status
+ FROM users u
+ LEFT JOIN referral_bindings rb ON rb.referee_id = u.id AND rb.status = 'active' AND rb.expiry_date > NOW()
+ WHERE u.id = ?
+ LIMIT 1
+ `, [userId]) as any[]
+
+ if (userRows.length > 0 && userRows[0].referrer_id) {
+ const referrerId = userRows[0].referrer_id
+ const commissionRate = 0.9 // 90% 佣金比例
+ const commissionAmount = parseFloat((amount * commissionRate).toFixed(2))
+
+ // 更新推荐人的 pending_earnings
+ await query(`
+ UPDATE users
+ SET pending_earnings = pending_earnings + ?
+ WHERE id = ?
+ `, [commissionAmount, referrerId])
+
+ // 更新绑定状态为已转化
+ await query(`
+ UPDATE referral_bindings
+ SET status = 'converted',
+ conversion_date = NOW(),
+ commission_amount = ?
+ WHERE referee_id = ? AND status = 'active'
+ `, [commissionAmount, userId])
+
+ console.log('[Wechat Notify] ✅ 佣金已分配:', {
+ referrerId,
+ commissionAmount,
+ orderId
+ })
+ } else {
+ console.log('[Wechat Notify] ℹ️ 该用户无推荐人,无需分配佣金')
+ }
+ } catch (commErr) {
+ console.error('[Wechat Notify] ❌ 分配佣金失败:', commErr)
+ // 不中断主流程
+ }
+ }
+ } catch (error) {
+ console.error('[Wechat Notify] ❌ 订单处理失败:', error)
+ // 不中断,继续返回成功响应给微信(避免重复回调)
+ }
} else {
console.log("[Wechat Notify] 支付失败:", notifyResult)
}
diff --git a/app/api/payment/wechat/transfer/notify/route.ts b/app/api/payment/wechat/transfer/notify/route.ts
new file mode 100644
index 00000000..c32cfa45
--- /dev/null
+++ b/app/api/payment/wechat/transfer/notify/route.ts
@@ -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 })
+ }
+}
diff --git a/app/api/referral/bind/route.ts b/app/api/referral/bind/route.ts
index 758e9899..68a1ee34 100644
--- a/app/api/referral/bind/route.ts
+++ b/app/api/referral/bind/route.ts
@@ -12,8 +12,8 @@
import { NextRequest, NextResponse } from 'next/server'
import { query, getConfig } from '@/lib/db'
-// 绑定有效期(天)
-const BINDING_DAYS = 30
+// 绑定有效期(天)- 默认值,优先从配置读取
+const DEFAULT_BINDING_DAYS = 30
/**
* POST - 绑定推荐关系(支持抢夺机制)
@@ -32,6 +32,17 @@ export async function POST(request: NextRequest) {
}, { status: 400 })
}
+ // 获取绑定天数配置
+ let bindingDays = DEFAULT_BINDING_DAYS
+ try {
+ const config = await getConfig('referral_config')
+ if (config?.bindingDays) {
+ bindingDays = Number(config.bindingDays)
+ }
+ } catch (e) {
+ console.warn('[Referral Bind] 读取配置失败,使用默认值', DEFAULT_BINDING_DAYS)
+ }
+
// 查找推荐人
const referrers = await query(
'SELECT id, nickname, referral_code FROM users WHERE referral_code = ?',
@@ -111,9 +122,9 @@ export async function POST(request: NextRequest) {
}
}
- // 计算新的过期时间(30天)
+ // 计算新的过期时间(从配置读取天数)
const expiryDate = new Date()
- expiryDate.setDate(expiryDate.getDate() + BINDING_DAYS)
+ expiryDate.setDate(expiryDate.getDate() + bindingDays)
// 创建或更新绑定记录
const bindingId = 'bind_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 6)
diff --git a/app/api/user/addresses/[id]/route.ts b/app/api/user/addresses/[id]/route.ts
new file mode 100644
index 00000000..0e5aa508
--- /dev/null
+++ b/app/api/user/addresses/[id]/route.ts
@@ -0,0 +1,112 @@
+/**
+ * 用户收货地址 - 单条详情 / 编辑 / 删除 / 设为默认
+ * GET: 详情
+ * PUT: 更新(name, phone, detail 等;省/市/区可选)
+ * DELETE: 删除
+ */
+
+import { NextRequest, NextResponse } from 'next/server'
+import { query } from '@/lib/db'
+
+async function getOne(id: string) {
+ const rows = await query(
+ `SELECT id, user_id, name, phone, province, city, district, detail, is_default, created_at, updated_at
+ FROM user_addresses WHERE id = ?`,
+ [id]
+ ) as any[]
+ if (!rows || rows.length === 0) return null
+ const r = rows[0]
+ return {
+ id: r.id,
+ userId: r.user_id,
+ name: r.name,
+ phone: r.phone,
+ province: r.province,
+ city: r.city,
+ district: r.district,
+ detail: r.detail,
+ isDefault: !!r.is_default,
+ fullAddress: `${r.province}${r.city}${r.district}${r.detail}`,
+ createdAt: r.created_at,
+ updatedAt: r.updated_at,
+ }
+}
+
+export async function GET(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params
+ if (!id) {
+ return NextResponse.json({ success: false, message: '缺少地址 id' }, { status: 400 })
+ }
+ const item = await getOne(id)
+ if (!item) {
+ return NextResponse.json({ success: false, message: '地址不存在' }, { status: 404 })
+ }
+ return NextResponse.json({ success: true, item })
+ } catch (e) {
+ console.error('[Addresses] GET one error:', e)
+ return NextResponse.json({ success: false, message: '获取地址失败' }, { status: 500 })
+ }
+}
+
+export async function PUT(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params
+ if (!id) {
+ return NextResponse.json({ success: false, message: '缺少地址 id' }, { status: 400 })
+ }
+ const body = await request.json()
+ const { name, phone, province, city, district, detail, isDefault } = body
+ const existing = await query('SELECT user_id FROM user_addresses WHERE id = ?', [id]) as any[]
+ if (!existing || existing.length === 0) {
+ return NextResponse.json({ success: false, message: '地址不存在' }, { status: 404 })
+ }
+ const userId = existing[0].user_id
+ const updates = []
+ const values = []
+ if (name !== undefined) { updates.push('name = ?'); values.push(name.trim()) }
+ if (phone !== undefined) { updates.push('phone = ?'); values.push(phone.trim()) }
+ if (province !== undefined) { updates.push('province = ?'); values.push((province == null ? '' : String(province)).trim()) }
+ if (city !== undefined) { updates.push('city = ?'); values.push((city == null ? '' : String(city)).trim()) }
+ if (district !== undefined) { updates.push('district = ?'); values.push((district == null ? '' : String(district)).trim()) }
+ if (detail !== undefined) { updates.push('detail = ?'); values.push(detail.trim()) }
+ if (isDefault === true) {
+ await query('UPDATE user_addresses SET is_default = 0 WHERE user_id = ?', [userId])
+ updates.push('is_default = 1')
+ } else if (isDefault === false) {
+ updates.push('is_default = 0')
+ }
+ if (updates.length > 0) {
+ values.push(id)
+ await query(`UPDATE user_addresses SET ${updates.join(', ')}, updated_at = NOW() WHERE id = ?`, values)
+ }
+ const item = await getOne(id)
+ return NextResponse.json({ success: true, item, message: '更新成功' })
+ } catch (e) {
+ console.error('[Addresses] PUT error:', e)
+ return NextResponse.json({ success: false, message: '更新地址失败' }, { status: 500 })
+ }
+}
+
+export async function DELETE(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params
+ if (!id) {
+ return NextResponse.json({ success: false, message: '缺少地址 id' }, { status: 400 })
+ }
+ await query('DELETE FROM user_addresses WHERE id = ?', [id])
+ return NextResponse.json({ success: true, message: '删除成功' })
+ } catch (e) {
+ console.error('[Addresses] DELETE error:', e)
+ return NextResponse.json({ success: false, message: '删除地址失败' }, { status: 500 })
+ }
+}
diff --git a/app/api/user/addresses/route.ts b/app/api/user/addresses/route.ts
new file mode 100644
index 00000000..4db007b5
--- /dev/null
+++ b/app/api/user/addresses/route.ts
@@ -0,0 +1,68 @@
+/**
+ * 用户收货地址 - 列表与新建
+ * GET: 列表(需 userId)
+ * POST: 新建(必填 userId, name, phone, detail;省/市/区可选)
+ */
+
+import { NextRequest, NextResponse } from 'next/server'
+import { query } from '@/lib/db'
+import { randomUUID } from 'crypto'
+
+export async function GET(request: NextRequest) {
+ try {
+ const userId = request.nextUrl.searchParams.get('userId')
+ if (!userId) {
+ return NextResponse.json({ success: false, message: '缺少 userId' }, { status: 400 })
+ }
+ const rows = await query(
+ `SELECT id, user_id, name, phone, province, city, district, detail, is_default, created_at, updated_at
+ FROM user_addresses WHERE user_id = ? ORDER BY is_default DESC, updated_at DESC`,
+ [userId]
+ ) as any[]
+ const list = (rows || []).map((r) => ({
+ id: r.id,
+ userId: r.user_id,
+ name: r.name,
+ phone: r.phone,
+ province: r.province,
+ city: r.city,
+ district: r.district,
+ detail: r.detail,
+ isDefault: !!r.is_default,
+ fullAddress: `${r.province}${r.city}${r.district}${r.detail}`,
+ createdAt: r.created_at,
+ updatedAt: r.updated_at,
+ }))
+ return NextResponse.json({ success: true, list })
+ } catch (e) {
+ console.error('[Addresses] GET error:', e)
+ return NextResponse.json({ success: false, message: '获取地址列表失败' }, { status: 500 })
+ }
+}
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json()
+ const { userId, name, phone, province, city, district, detail, isDefault } = body
+ if (!userId || !name || !phone || !detail) {
+ return NextResponse.json(
+ { success: false, message: '缺少必填项:userId, name, phone, detail' },
+ { status: 400 }
+ )
+ }
+ const id = randomUUID().replace(/-/g, '').slice(0, 24)
+ const p = (v: string | undefined) => (v == null ? '' : String(v).trim())
+ if (isDefault) {
+ await query('UPDATE user_addresses SET is_default = 0 WHERE user_id = ?', [userId])
+ }
+ await query(
+ `INSERT INTO user_addresses (id, user_id, name, phone, province, city, district, detail, is_default)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [id, userId, name.trim(), phone.trim(), p(province), p(city), p(district), detail.trim(), isDefault ? 1 : 0]
+ )
+ return NextResponse.json({ success: true, id, message: '添加成功' })
+ } catch (e) {
+ console.error('[Addresses] POST error:', e)
+ return NextResponse.json({ success: false, message: '添加地址失败' }, { status: 500 })
+ }
+}
diff --git a/app/api/user/check-purchased/route.ts b/app/api/user/check-purchased/route.ts
new file mode 100644
index 00000000..f88c9a4e
--- /dev/null
+++ b/app/api/user/check-purchased/route.ts
@@ -0,0 +1,108 @@
+/**
+ * 检查用户是否已购买指定章节/全书
+ * 用于支付前校验,避免重复购买
+ *
+ * GET /api/user/check-purchased?userId=xxx&type=section&productId=xxx
+ */
+
+import { NextRequest, NextResponse } from 'next/server'
+import { query } from '@/lib/db'
+
+export async function GET(request: NextRequest) {
+ try {
+ const { searchParams } = new URL(request.url)
+ const userId = searchParams.get('userId')
+ const type = searchParams.get('type') // 'section' | 'fullbook'
+ const productId = searchParams.get('productId')
+
+ if (!userId) {
+ return NextResponse.json({
+ success: false,
+ error: '缺少 userId 参数'
+ }, { status: 400 })
+ }
+
+ // 1. 查询用户是否购买全书
+ const userRows = await query(`
+ SELECT has_full_book FROM users WHERE id = ?
+ `, [userId]) as any[]
+
+ if (userRows.length === 0) {
+ return NextResponse.json({
+ success: false,
+ error: '用户不存在'
+ }, { status: 404 })
+ }
+
+ const hasFullBook = userRows[0].has_full_book || false
+
+ // 如果已购全书,直接返回已购买
+ if (hasFullBook) {
+ return NextResponse.json({
+ success: true,
+ data: {
+ isPurchased: true,
+ reason: 'has_full_book'
+ }
+ })
+ }
+
+ // 2. 如果是购买全书,检查是否已有全书订单
+ if (type === 'fullbook') {
+ const orderRows = await query(`
+ SELECT COUNT(*) as count
+ FROM orders
+ WHERE user_id = ?
+ AND product_type = 'fullbook'
+ AND status = 'paid'
+ `, [userId]) as any[]
+
+ const hasPaid = orderRows[0].count > 0
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ isPurchased: hasPaid,
+ reason: hasPaid ? 'fullbook_order_exists' : null
+ }
+ })
+ }
+
+ // 3. 如果是购买章节,检查是否已有该章节订单
+ if (type === 'section' && productId) {
+ const orderRows = await query(`
+ SELECT COUNT(*) as count
+ FROM orders
+ WHERE user_id = ?
+ AND product_type = 'section'
+ AND product_id = ?
+ AND status = 'paid'
+ `, [userId, productId]) as any[]
+
+ const hasPaid = orderRows[0].count > 0
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ isPurchased: hasPaid,
+ reason: hasPaid ? 'section_order_exists' : null
+ }
+ })
+ }
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ isPurchased: false,
+ reason: null
+ }
+ })
+
+ } catch (error) {
+ console.error('[CheckPurchased] 查询失败:', error)
+ return NextResponse.json({
+ success: false,
+ error: '查询购买状态失败'
+ }, { status: 500 })
+ }
+}
diff --git a/app/api/user/purchase-status/route.ts b/app/api/user/purchase-status/route.ts
new file mode 100644
index 00000000..c79c5738
--- /dev/null
+++ b/app/api/user/purchase-status/route.ts
@@ -0,0 +1,72 @@
+/**
+ * 查询用户购买状态 API
+ * 用于支付成功后刷新用户的购买记录
+ *
+ * GET /api/user/purchase-status?userId=xxx
+ */
+
+import { NextRequest, NextResponse } from 'next/server'
+import { query } from '@/lib/db'
+
+export async function GET(request: NextRequest) {
+ try {
+ const { searchParams } = new URL(request.url)
+ const userId = searchParams.get('userId')
+
+ if (!userId) {
+ return NextResponse.json({
+ success: false,
+ error: '缺少 userId 参数'
+ }, { status: 400 })
+ }
+
+ // 1. 查询用户基本信息
+ const userRows = await query(`
+ SELECT
+ id, nickname, avatar, phone, wechat_id,
+ referral_code, has_full_book,
+ earnings, pending_earnings, referral_count
+ FROM users
+ WHERE id = ?
+ `, [userId]) as any[]
+
+ if (userRows.length === 0) {
+ return NextResponse.json({
+ success: false,
+ error: '用户不存在'
+ }, { status: 404 })
+ }
+
+ const user = userRows[0]
+
+ // 2. 从 orders 表查询已购买的章节
+ const orderRows = await query(`
+ SELECT DISTINCT product_id
+ FROM orders
+ WHERE user_id = ?
+ AND status = 'paid'
+ AND product_type = 'section'
+ `, [userId]) as any[]
+
+ const purchasedSections = orderRows.map((row: any) => row.product_id).filter(Boolean)
+
+ // 3. 返回完整购买状态
+ return NextResponse.json({
+ success: true,
+ data: {
+ hasFullBook: user.has_full_book || false,
+ purchasedSections,
+ purchasedCount: purchasedSections.length,
+ earnings: parseFloat(user.earnings) || 0,
+ pendingEarnings: parseFloat(user.pending_earnings) || 0,
+ }
+ })
+
+ } catch (error) {
+ console.error('[PurchaseStatus] 查询失败:', error)
+ return NextResponse.json({
+ success: false,
+ error: '查询购买状态失败'
+ }, { status: 500 })
+ }
+}
diff --git a/app/api/user/reading-progress/route.ts b/app/api/user/reading-progress/route.ts
new file mode 100644
index 00000000..bf608bf8
--- /dev/null
+++ b/app/api/user/reading-progress/route.ts
@@ -0,0 +1,140 @@
+/**
+ * 阅读进度上报接口
+ * POST /api/user/reading-progress
+ *
+ * 接收小程序上报的阅读进度,用于数据分析和断点续读
+ */
+
+import { NextRequest, NextResponse } from 'next/server'
+import { query } from '@/lib/db'
+
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json()
+ const { userId, sectionId, progress, duration, status, completedAt } = body
+
+ // 参数校验
+ if (!userId || !sectionId) {
+ return NextResponse.json({
+ success: false,
+ error: '缺少必要参数'
+ }, { status: 400 })
+ }
+
+ // 查询是否已有记录
+ const existingRows = await query(`
+ SELECT id, progress, duration, status, first_open_at
+ FROM reading_progress
+ WHERE user_id = ? AND section_id = ?
+ `, [userId, sectionId]) as any[]
+
+ const now = new Date()
+
+ if (existingRows.length > 0) {
+ // 更新已有记录
+ const existing = existingRows[0]
+
+ // 只更新更大的进度
+ const newProgress = Math.max(existing.progress || 0, progress || 0)
+ const newDuration = (existing.duration || 0) + (duration || 0)
+ const newStatus = status || existing.status || 'reading'
+
+ await query(`
+ UPDATE reading_progress
+ SET
+ progress = ?,
+ duration = ?,
+ status = ?,
+ completed_at = ?,
+ last_open_at = ?,
+ updated_at = ?
+ WHERE user_id = ? AND section_id = ?
+ `, [
+ newProgress,
+ newDuration,
+ newStatus,
+ completedAt ? new Date(completedAt) : existing.completed_at,
+ now,
+ now,
+ userId,
+ sectionId
+ ])
+
+ console.log('[ReadingProgress] 更新进度:', { userId, sectionId, progress: newProgress, duration: newDuration })
+ } else {
+ // 插入新记录
+ await query(`
+ INSERT INTO reading_progress
+ (user_id, section_id, progress, duration, status, completed_at, first_open_at, last_open_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `, [
+ userId,
+ sectionId,
+ progress || 0,
+ duration || 0,
+ status || 'reading',
+ completedAt ? new Date(completedAt) : null,
+ now,
+ now
+ ])
+
+ console.log('[ReadingProgress] 新增进度:', { userId, sectionId, progress, duration })
+ }
+
+ return NextResponse.json({
+ success: true,
+ message: '进度已保存'
+ })
+
+ } catch (error) {
+ console.error('[ReadingProgress] 保存失败:', error)
+ return NextResponse.json({
+ success: false,
+ error: '保存进度失败'
+ }, { status: 500 })
+ }
+}
+
+/**
+ * 查询用户的阅读进度列表
+ * GET /api/user/reading-progress?userId=xxx
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const { searchParams } = new URL(request.url)
+ const userId = searchParams.get('userId')
+
+ if (!userId) {
+ return NextResponse.json({
+ success: false,
+ error: '缺少 userId 参数'
+ }, { status: 400 })
+ }
+
+ const rows = await query(`
+ SELECT
+ section_id,
+ progress,
+ duration,
+ status,
+ completed_at,
+ first_open_at,
+ last_open_at
+ FROM reading_progress
+ WHERE user_id = ?
+ ORDER BY last_open_at DESC
+ `, [userId]) as any[]
+
+ return NextResponse.json({
+ success: true,
+ data: rows
+ })
+
+ } catch (error) {
+ console.error('[ReadingProgress] 查询失败:', error)
+ return NextResponse.json({
+ success: false,
+ error: '查询进度失败'
+ }, { status: 500 })
+ }
+}
diff --git a/app/api/withdraw/route.ts b/app/api/withdraw/route.ts
index 16690243..e2e3aadc 100644
--- a/app/api/withdraw/route.ts
+++ b/app/api/withdraw/route.ts
@@ -4,7 +4,7 @@
*/
import { NextRequest, NextResponse } from 'next/server'
-import { query } from '@/lib/db'
+import { query, getConfig } from '@/lib/db'
// 确保提现表存在
async function ensureWithdrawalsTable() {
@@ -41,6 +41,25 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: false, message: '提现金额无效' }, { status: 400 })
}
+ // 读取最低提现门槛
+ let minWithdrawAmount = 10 // 默认值
+ try {
+ const config = await getConfig('referral_config')
+ if (config?.minWithdrawAmount) {
+ minWithdrawAmount = Number(config.minWithdrawAmount)
+ }
+ } catch (e) {
+ console.warn('[Withdraw] 读取配置失败,使用默认值 10 元')
+ }
+
+ // 检查最低提现门槛
+ if (amount < minWithdrawAmount) {
+ return NextResponse.json({
+ success: false,
+ message: `最低提现金额为 ¥${minWithdrawAmount},当前 ¥${amount}`
+ }, { status: 400 })
+ }
+
// 确保表存在
await ensureWithdrawalsTable()
@@ -52,15 +71,15 @@ export async function POST(request: NextRequest) {
const user = users[0]
- // 检查是否绑定支付方式(微信号或支付宝)
- // 如果没有绑定,提示用户先绑定
+ // 微信零钱提现需要 open_id(小程序/公众号登录获得)
+ const openId = user.open_id || ''
const wechatId = user.wechat || user.wechat_id || ''
const alipayId = user.alipay || ''
- if (!wechatId && !alipayId) {
+ if (!openId && !alipayId) {
return NextResponse.json({
success: false,
- message: '请先在设置中绑定微信号或支付宝',
+ message: '提现到微信零钱需先使用微信登录;或绑定支付宝后提现到支付宝',
needBind: true
})
}
@@ -101,20 +120,24 @@ export async function POST(request: NextRequest) {
})
}
- // 创建提现记录
+ // 创建提现记录(微信零钱需保存 wechat_openid 供后台批准时调用商家转账到零钱)
const withdrawId = `W${Date.now()}`
const accountType = alipayId ? 'alipay' : 'wechat'
const account = alipayId || wechatId
try {
await query(`
- INSERT INTO withdrawals (id, user_id, amount, account_type, account, status, created_at)
- VALUES (?, ?, ?, ?, ?, 'pending', NOW())
- `, [withdrawId, userId, amount, accountType, account])
+ INSERT INTO withdrawals (id, user_id, amount, status, wechat_openid, created_at)
+ VALUES (?, ?, ?, 'pending', ?, NOW())
+ `, [withdrawId, userId, amount, accountType === 'wechat' ? openId : null])
- // TODO: 实际调用微信企业付款或支付宝转账API
- // 这里先模拟成功
- await query(`UPDATE withdrawals SET status = 'completed', completed_at = NOW() WHERE id = ?`, [withdrawId])
+ // 微信零钱由后台批准时调用「商家转账到零钱」;支付宝/无 openid 时仅标记成功(需线下打款)
+ if (accountType !== 'wechat' || !openId) {
+ await query(`UPDATE withdrawals SET status = 'success', processed_at = NOW() WHERE id = ?`, [withdrawId])
+ await query(`
+ UPDATE users SET withdrawn_earnings = withdrawn_earnings + ?, pending_earnings = GREATEST(0, pending_earnings - ?) WHERE id = ?
+ `, [amount, amount, userId])
+ }
} catch (e) {
console.log('[Withdraw] 创建提现记录失败:', e)
}
diff --git a/app/layout.tsx b/app/layout.tsx
index d8ae77ac..35181749 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -3,7 +3,7 @@ import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import { Analytics } from "@vercel/analytics/next"
import "./globals.css"
-import { LayoutWrapper } from "@/components/layout-wrapper"
+import { LayoutWrapper } from "@/components/view/layout/layout-wrapper"
const _geist = Geist({ subsets: ["latin"] })
const _geistMono = Geist_Mono({ subsets: ["latin"] })
@@ -41,7 +41,7 @@ export default function RootLayout({
{children}
-
+ {process.env.NODE_ENV === 'production' && }
)
diff --git a/app/page.tsx b/app/page.tsx
index 11966f55..ca599d3c 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,244 +1,18 @@
-/**
- * 一场SOUL的创业实验 - 首页
- * 开发: 卡若
- * 技术支持: 存客宝
- */
-"use client"
+import { redirect } from "next/navigation"
-import { useState, useEffect } from "react"
-import { useRouter } from "next/navigation"
-import { Search, ChevronRight, BookOpen, Home, List, User, Users } from "lucide-react"
-import { useStore } from "@/lib/store"
-import { bookData, getTotalSectionCount } from "@/lib/book-data"
-import { SearchModal } from "@/components/search-modal"
+/** 根路径重定向到移动端首页 */
+export default function RootPage({
+ searchParams,
+}: {
+ searchParams?: Record
+}) {
+ const params = new URLSearchParams()
-export default function HomePage() {
- const router = useRouter()
- const { user } = useStore()
- const [mounted, setMounted] = useState(false)
- const [searchOpen, setSearchOpen] = useState(false)
-
- const totalSections = getTotalSectionCount()
- const hasFullBook = user?.hasFullBook || false
- const purchasedCount = hasFullBook ? totalSections : user?.purchasedSections?.length || 0
-
- useEffect(() => {
- setMounted(true)
- }, [])
-
- // 推荐章节
- const featuredSections = [
- { id: "1.1", title: "荷包:电动车出租的被动收入模式", tag: "免费", part: "真实的人" },
- { id: "3.1", title: "3000万流水如何跑出来", tag: "热门", part: "真实的行业" },
- { id: "8.1", title: "流量杠杆:抖音、Soul、飞书", tag: "推荐", part: "真实的赚钱" },
- ]
-
- // 最新更新
- const latestSection = {
- id: "9.14",
- title: "大健康私域:一个月150万的70后",
- part: "真实的赚钱",
+ for (const [key, value] of Object.entries(searchParams ?? {})) {
+ if (typeof value === "string") params.set(key, value)
+ else if (Array.isArray(value)) value.forEach((v) => params.append(key, v))
}
- if (!mounted) {
- return null
- }
-
- return (
-
- {/* 顶部区域 */}
-
-
- {/* 搜索弹窗 */}
-
-
-
- {/* Banner卡片 - 最新章节 */}
- router.push(`/read/${latestSection.id}`)}
- className="relative p-5 rounded-2xl overflow-hidden cursor-pointer"
- style={{
- background: "linear-gradient(135deg, #0d3331 0%, #1a1a2e 50%, #16213e 100%)",
- }}
- >
-
-
- 最新更新
-
-
{latestSection.title}
-
{latestSection.part}
-
- 开始阅读
-
-
-
-
- {/* 阅读进度卡 */}
-
-
-
我的阅读
-
- {purchasedCount}/{totalSections}章
-
-
-
-
-
-
{purchasedCount}
-
已读
-
-
-
{totalSections - purchasedCount}
-
待读
-
-
-
-
-
-
- {/* 精选推荐 */}
-
-
-
精选推荐
-
-
-
- {featuredSections.map((section) => (
-
router.push(`/read/${section.id}`)}
- className="p-4 rounded-xl bg-[#1c1c1e] border border-white/5 cursor-pointer active:scale-[0.98] transition-transform"
- >
-
-
-
- {section.id}
-
- {section.tag}
-
-
-
{section.title}
-
{section.part}
-
-
-
-
- ))}
-
-
-
-
-
内容概览
-
- {bookData.map((part) => (
-
router.push("/chapters")}
- className="p-4 rounded-xl bg-[#1c1c1e] border border-white/5 cursor-pointer active:scale-[0.98] transition-transform"
- >
-
-
- {part.number}
-
-
-
{part.title}
-
{part.subtitle}
-
-
-
-
- ))}
-
-
-
- {/* 序言入口 */}
- router.push("/read/preface")}
- className="p-4 rounded-xl bg-gradient-to-r from-[#00CED1]/10 to-transparent border border-[#00CED1]/20 cursor-pointer"
- >
-
-
-
序言
-
为什么我每天早上6点在Soul开播?
-
-
免费
-
-
-
-
-
-
- )
+ const qs = params.toString()
+ redirect(qs ? `/view?${qs}` : "/view")
}
diff --git a/app/about/page.tsx b/app/view/about/page.tsx
similarity index 100%
rename from app/about/page.tsx
rename to app/view/about/page.tsx
diff --git a/app/chapters/page.tsx b/app/view/chapters/page.tsx
similarity index 85%
rename from app/chapters/page.tsx
rename to app/view/chapters/page.tsx
index 0230ba31..112c776e 100644
--- a/app/chapters/page.tsx
+++ b/app/view/chapters/page.tsx
@@ -2,7 +2,7 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
-import { ChevronRight, Lock, Unlock, Book, BookOpen, Home, List, Sparkles, User, Users, Zap, Crown, Search } from "lucide-react"
+import { ChevronRight, Lock, Unlock, Book, BookOpen, Sparkles, Zap, Crown, Search } from "lucide-react"
import { useStore } from "@/lib/store"
import { bookData, getTotalSectionCount, specialSections, getPremiumBookPrice, getExtraSectionsCount, BASE_SECTIONS_COUNT } from "@/lib/book-data"
import { SearchModal } from "@/components/search-modal"
@@ -21,7 +21,7 @@ export default function ChaptersPage() {
const extraSections = getExtraSectionsCount()
const handleSectionClick = (sectionId: string) => {
- router.push(`/read/${sectionId}`)
+ router.push(`/view/read/${sectionId}`)
}
return (
@@ -209,31 +209,6 @@ export default function ChaptersPage() {
-
)
}
diff --git a/app/docs/page.tsx b/app/view/docs/page.tsx
similarity index 98%
rename from app/docs/page.tsx
rename to app/view/docs/page.tsx
index acf1e3f6..e02552a4 100644
--- a/app/docs/page.tsx
+++ b/app/view/docs/page.tsx
@@ -8,7 +8,7 @@ export default function DocsPage() {
-
+
开发者文档
diff --git a/app/documentation/capture/loading.tsx b/app/view/documentation/capture/loading.tsx
similarity index 100%
rename from app/documentation/capture/loading.tsx
rename to app/view/documentation/capture/loading.tsx
diff --git a/app/documentation/capture/page.tsx b/app/view/documentation/capture/page.tsx
similarity index 100%
rename from app/documentation/capture/page.tsx
rename to app/view/documentation/capture/page.tsx
diff --git a/app/documentation/page.tsx b/app/view/documentation/page.tsx
similarity index 100%
rename from app/documentation/page.tsx
rename to app/view/documentation/page.tsx
diff --git a/app/view/login/forgot/page.tsx b/app/view/login/forgot/page.tsx
new file mode 100644
index 00000000..6ac89227
--- /dev/null
+++ b/app/view/login/forgot/page.tsx
@@ -0,0 +1,136 @@
+"use client"
+
+import { useState } from "react"
+import { useRouter } from "next/navigation"
+import Link from "next/link"
+import { ChevronLeft, Phone, Hash } from "lucide-react"
+
+export default function ForgotPasswordPage() {
+ const router = useRouter()
+ const [phone, setPhone] = useState("")
+ const [newPassword, setNewPassword] = useState("")
+ const [confirmPassword, setConfirmPassword] = useState("")
+ const [error, setError] = useState("")
+ const [success, setSuccess] = useState(false)
+ const [loading, setLoading] = useState(false)
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError("")
+ setLoading(true)
+
+ try {
+ if (!phone.trim()) {
+ setError("请输入手机号")
+ return
+ }
+ if (!newPassword.trim()) {
+ setError("请输入新密码")
+ return
+ }
+ if (newPassword.trim().length < 6) {
+ setError("密码至少 6 位")
+ return
+ }
+ if (newPassword !== confirmPassword) {
+ setError("两次输入的密码不一致")
+ return
+ }
+
+ const res = await fetch("/api/auth/reset-password", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ phone: phone.trim(), newPassword: newPassword.trim() }),
+ })
+ const data = await res.json()
+
+ if (data.success) {
+ setSuccess(true)
+ setTimeout(() => router.push("/view/login"), 2000)
+ } else {
+ setError(data.error || "重置失败")
+ }
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (success) {
+ return (
+
+
密码已重置
+
请使用新密码登录,正在跳转...
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ 请输入注册时使用的手机号和新密码,重置后请使用新密码登录。
+
+
+
+
+
+ 若该手机号未注册,将提示「该手机号未注册」;重置后请使用新密码在登录页登录。
+
+
+
+ )
+}
diff --git a/app/login/page.tsx b/app/view/login/page.tsx
similarity index 57%
rename from app/login/page.tsx
rename to app/view/login/page.tsx
index 08e33836..5c163325 100644
--- a/app/login/page.tsx
+++ b/app/view/login/page.tsx
@@ -1,13 +1,32 @@
"use client"
-import { useState } from "react"
+import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
+import Link from "next/link"
import { useStore } from "@/lib/store"
import { ChevronLeft, Phone, User, Hash } from "lucide-react"
+/** 从本地读取分销 ref(与 referral-capture 写入的 key 对齐),仅读取不清除 */
+function getStoredRef(): string | null {
+ if (typeof window === "undefined") return null
+ const ref =
+ localStorage.getItem("pendingReferralCode") ||
+ localStorage.getItem("referral_code")
+ return ref?.trim() || null
+}
+
+/** 清除本地保存的 ref(注册/绑定成功后调用) */
+function clearStoredRef() {
+ if (typeof window === "undefined") return
+ localStorage.removeItem("pendingReferralCode")
+ localStorage.removeItem("referral_code")
+ document.cookie = "pendingReferralCode=; Path=/; Max-Age=0"
+ document.cookie = "ref=; Path=/; Max-Age=0"
+}
+
export default function LoginPage() {
const router = useRouter()
- const { login, register, adminLogin } = useStore()
+ const { login, register } = useStore()
const [mode, setMode] = useState<"login" | "register">("login")
const [phone, setPhone] = useState("")
const [code, setCode] = useState("")
@@ -16,37 +35,88 @@ export default function LoginPage() {
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
+ // 进入页面时用链接里存的 ref 预填邀请码(用于注册);注册成功或登录绑定成功后再清除
+ useEffect(() => {
+ const stored = getStoredRef()
+ if (stored) setReferralCode(stored)
+ }, [])
+
const handleSubmit = async () => {
setError("")
setLoading(true)
try {
- // 管理员登录
+ // 管理员登录(使用 code 作为密码,调用后台 API 并写 Cookie)
if (phone.toLowerCase() === "admin") {
- if (adminLogin(phone, code)) {
- router.push("/admin")
- return
- } else {
- setError("管理员密码错误")
- return
+ try {
+ const res = await fetch("/api/admin", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username: phone, password: code }),
+ credentials: "include",
+ })
+ const data = await res.json()
+ if (res.ok && data.success) {
+ router.push("/admin")
+ return
+ }
+ } catch {
+ // fallthrough to error
}
+ setError("管理员密码错误")
+ return
}
if (mode === "login") {
+ if (!code.trim()) {
+ setError("请输入密码")
+ return
+ }
const success = await login(phone, code)
if (success) {
- router.push("/")
+ // 已有账号登录:若本地有通过链接存的推荐码,则调用绑定接口(与小程序逻辑一致)
+ const pendingRef = getStoredRef()
+ if (pendingRef) {
+ try {
+ const user = useStore.getState().user
+ if (user?.id) {
+ await fetch("/api/referral/bind", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ userId: user.id,
+ referralCode: pendingRef,
+ source: "web",
+ }),
+ })
+ }
+ } catch {
+ // 绑定失败不影响跳转
+ }
+ clearStoredRef()
+ }
+ router.push("/view")
} else {
- setError("验证码错误或用户不存在")
+ setError("密码错误或用户不存在")
}
} else {
if (!nickname.trim()) {
setError("请输入昵称")
return
}
- const success = await register(phone, nickname, referralCode || undefined)
+ if (!code.trim()) {
+ setError("请设置密码(至少 6 位)")
+ return
+ }
+ if (code.trim().length < 6) {
+ setError("密码至少 6 位")
+ return
+ }
+ const success = await register(phone, nickname, code, referralCode || undefined)
if (success) {
- router.push("/")
+ // 注册时已把 referralCode 传给后端(referredBy);清除本地 ref 避免重复使用
+ clearStoredRef()
+ router.push("/view")
} else {
setError("该手机号已注册")
}
@@ -102,14 +172,14 @@ export default function LoginPage() {
)}
- {/* 验证码/密码 */}
+ {/* 密码 */}
setCode(e.target.value)}
- placeholder={mode === "login" ? "验证码(测试:123456)" : "设置密码"}
+ placeholder={mode === "login" ? "密码" : "设置密码(至少 6 位)"}
className="w-full pl-12 pr-4 py-3.5 bg-[#1c1c1e] rounded-xl text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#30d158]/50"
/>
@@ -140,9 +210,16 @@ export default function LoginPage() {
{loading ? "处理中..." : mode === "login" ? "登录" : "注册"}
- {/* 切换模式 */}
-
-
router.push("/my/referral")}
+ onClick={() => router.push("/view/my/referral")}
className="w-full py-2.5 rounded-xl bg-gradient-to-r from-[#FFD700]/80 to-[#FFA500]/80 text-black text-sm font-bold flex items-center justify-center gap-2"
>
@@ -341,7 +336,7 @@ export default function MyPage() {
{/* 菜单列表 */}
router.push("/my/purchases")}
+ onClick={() => router.push("/view/my/purchases")}
className="w-full flex items-center justify-between p-4 border-b border-white/5 active:bg-white/5"
>
@@ -354,7 +349,7 @@ export default function MyPage() {
router.push("/my/referral")}
+ onClick={() => router.push("/view/my/referral")}
className="w-full flex items-center justify-between p-4 border-b border-white/5 active:bg-white/5"
>
@@ -369,7 +364,7 @@ export default function MyPage() {
router.push("/about")}
+ onClick={() => router.push("/view/about")}
className="w-full flex items-center justify-between p-4 active:bg-white/5"
>
@@ -381,7 +376,7 @@ export default function MyPage() {
router.push("/my/settings")}
+ onClick={() => router.push("/view/my/settings")}
className="w-full flex items-center justify-between p-4 active:bg-white/5"
>
@@ -441,7 +436,7 @@ export default function MyPage() {
章节 {sectionId}
router.push(`/read/${sectionId}`)}
+ onClick={() => router.push(`/view/read/${sectionId}`)}
className="text-[#00CED1] text-xs"
>
继续阅读
@@ -454,7 +449,7 @@ export default function MyPage() {
暂无阅读记录
router.push("/chapters")}
+ onClick={() => router.push("/view/chapters")}
className="mt-2 text-[#00CED1] text-sm"
>
去阅读 →
@@ -463,29 +458,30 @@ export default function MyPage() {
)}
- {/* 匹配记录 */}
-
-
-
- 匹配记录
-
-
-
-
暂无匹配记录
-
router.push("/match")}
- className="mt-2 text-[#00CED1] text-sm"
- >
- 去匹配 →
-
+ {/* 匹配记录 - 根据配置显示 */}
+ {matchEnabled && (
+
+
+
+ 匹配记录
+
+
+
+
暂无匹配记录
+
router.push("/view/match")}
+ className="mt-2 text-[#00CED1] text-sm"
+ >
+ 去匹配 →
+
+
-
+ )}
>
)}
-
-
+
{/* 绑定弹窗 */}
{showBindModal && (
diff --git a/app/my/purchases/page.tsx b/app/view/my/purchases/page.tsx
similarity index 92%
rename from app/my/purchases/page.tsx
rename to app/view/my/purchases/page.tsx
index 6761c4d9..34c14c36 100644
--- a/app/my/purchases/page.tsx
+++ b/app/view/my/purchases/page.tsx
@@ -13,7 +13,7 @@ export default function MyPurchasesPage() {
@@ -29,7 +29,7 @@ export default function MyPurchasesPage() {
{/* Header */}
-
+
返回
@@ -66,7 +66,7 @@ export default function MyPurchasesPage() {
@@ -89,7 +89,7 @@ export default function MyPurchasesPage() {
{purchasedInPart.map((section) => (
diff --git a/app/my/referral/page.tsx b/app/view/my/referral/page.tsx
similarity index 99%
rename from app/my/referral/page.tsx
rename to app/view/my/referral/page.tsx
index 2a3eb055..f24f7f1b 100644
--- a/app/my/referral/page.tsx
+++ b/app/view/my/referral/page.tsx
@@ -132,7 +132,7 @@ export default function ReferralPage() {
@@ -211,7 +211,7 @@ export default function ReferralPage() {
{/* Header - iOS风格 */}
-
+
分销中心
diff --git a/app/my/settings/page.tsx b/app/view/my/settings/page.tsx
similarity index 91%
rename from app/my/settings/page.tsx
rename to app/view/my/settings/page.tsx
index bdf0a28d..235542bd 100644
--- a/app/my/settings/page.tsx
+++ b/app/view/my/settings/page.tsx
@@ -2,7 +2,7 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
-import { ChevronLeft, Phone, MessageCircle, CreditCard, Check, X, Loader2, Shield } from "lucide-react"
+import { ChevronLeft, Phone, MessageCircle, CreditCard, Check, X, Loader2, Shield, MapPin } from "lucide-react"
import { useStore } from "@/lib/store"
export default function SettingsPage() {
@@ -152,7 +152,7 @@ export default function SettingsPage() {
{/* 支付宝 */}
openBindModal("alipay")}
- className="w-full flex items-center justify-between p-4 active:bg-white/5"
+ className="w-full flex items-center justify-between p-4 border-b border-white/5 active:bg-white/5"
>
去绑定
)}
+
+ {/* 收货地址 */}
+
router.push("/view/my/addresses")}
+ className="w-full flex items-center justify-between p-4 active:bg-white/5"
+ >
+
+
+
+
+
+
收货地址
+
管理收货地址,用于发货与邮寄
+
+
+ 管理
+
{/* 绑定提示 */}
@@ -188,7 +205,7 @@ export default function SettingsPage() {
{
logout()
- router.push("/")
+ router.push("/view")
}}
className="w-full py-3 rounded-xl bg-[#1c1c1e] text-red-400 font-medium border border-red-400/30"
>
diff --git a/app/view/page.tsx b/app/view/page.tsx
new file mode 100644
index 00000000..377bb390
--- /dev/null
+++ b/app/view/page.tsx
@@ -0,0 +1,221 @@
+/**
+ * 一场SOUL的创业实验 - 首页
+ * 开发: 卡若
+ * 技术支持: 存客宝
+ */
+"use client"
+
+import { useState, useEffect } from "react"
+import { useRouter } from "next/navigation"
+import { Search, ChevronRight, BookOpen } from "lucide-react"
+import { useStore } from "@/lib/store"
+import { bookData, getTotalSectionCount } from "@/lib/book-data"
+import { SearchModal } from "@/components/search-modal"
+
+export default function HomePage() {
+ const router = useRouter()
+ const { user } = useStore()
+ const [mounted, setMounted] = useState(false)
+ const [searchOpen, setSearchOpen] = useState(false)
+
+ // 计算数据(必须在所有 hooks 之后)
+ const totalSections = getTotalSectionCount()
+ const hasFullBook = user?.hasFullBook || false
+ const purchasedCount = hasFullBook ? totalSections : user?.purchasedSections?.length || 0
+
+ // 推荐章节
+ const featuredSections = [
+ { id: "1.1", title: "荷包:电动车出租的被动收入模式", tag: "免费", part: "真实的人" },
+ { id: "3.1", title: "3000万流水如何跑出来", tag: "热门", part: "真实的行业" },
+ { id: "8.1", title: "流量杠杆:抖音、Soul、飞书", tag: "推荐", part: "真实的赚钱" },
+ ]
+
+ // 最新更新
+ const latestSection = {
+ id: "9.14",
+ title: "大健康私域:一个月150万的70后",
+ part: "真实的赚钱",
+ }
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ if (!mounted) {
+ return null
+ }
+
+ return (
+
+ {/* 顶部区域 */}
+
+
+ {/* 搜索弹窗 */}
+
+
+
+ {/* Banner卡片 - 最新章节 */}
+ router.push(`/view/read/${latestSection.id}`)}
+ className="relative p-5 rounded-2xl overflow-hidden cursor-pointer"
+ style={{
+ background: "linear-gradient(135deg, #0d3331 0%, #1a1a2e 50%, #16213e 100%)",
+ }}
+ >
+
+
+ 最新更新
+
+
{latestSection.title}
+
{latestSection.part}
+
+ 开始阅读
+
+
+
+
+ {/* 阅读进度卡 */}
+
+
+
我的阅读
+
+ {purchasedCount}/{totalSections}章
+
+
+
+
+
+
{purchasedCount}
+
已读
+
+
+
{totalSections - purchasedCount}
+
待读
+
+
+
+
+
+
+ {/* 精选推荐 */}
+
+
+
精选推荐
+ router.push("/view/chapters")} className="text-xs text-[#00CED1] flex items-center gap-1">
+ 查看全部
+
+
+
+
+ {featuredSections.map((section) => (
+
router.push(`/view/read/${section.id}`)}
+ className="p-4 rounded-xl bg-[#1c1c1e] border border-white/5 cursor-pointer active:scale-[0.98] transition-transform"
+ >
+
+
+
+ {section.id}
+
+ {section.tag}
+
+
+
{section.title}
+
{section.part}
+
+
+
+
+ ))}
+
+
+
+
+
内容概览
+
+ {bookData.map((part) => (
+
router.push("/view/chapters")}
+ className="p-4 rounded-xl bg-[#1c1c1e] border border-white/5 cursor-pointer active:scale-[0.98] transition-transform"
+ >
+
+
+ {part.number}
+
+
+
{part.title}
+
{part.subtitle}
+
+
+
+
+ ))}
+
+
+
+ {/* 序言入口 */}
+ router.push("/view/read/preface")}
+ className="p-4 rounded-xl bg-gradient-to-r from-[#00CED1]/10 to-transparent border border-[#00CED1]/20 cursor-pointer"
+ >
+
+
+
序言
+
为什么我每天早上6点在Soul开播?
+
+
免费
+
+
+
+
+ {/* 使用统一的底部导航组件 */}
+
+ )
+}
diff --git a/app/read/[id]/page.tsx b/app/view/read/[id]/page.tsx
similarity index 54%
rename from app/read/[id]/page.tsx
rename to app/view/read/[id]/page.tsx
index 1cd41d30..1866c0ea 100644
--- a/app/read/[id]/page.tsx
+++ b/app/view/read/[id]/page.tsx
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation"
import { ChapterContent } from "@/components/chapter-content"
import { getSectionBySlug, getChapterBySectionSlug } from "@/lib/book-file-system"
import { specialSections, getSectionById } from "@/lib/book-data"
+import { query } from "@/lib/db"
interface ReadPageProps {
params: Promise<{ id: string }>
@@ -10,6 +11,35 @@ interface ReadPageProps {
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
+// 从数据库获取章节数据(包含最新的 isFree 状态)
+async function getChapterFromDB(id: string) {
+ try {
+ const results = await query(
+ `SELECT id, part_title, chapter_title, section_title, content, is_free, price
+ FROM chapters
+ WHERE id = ? AND status = 'published'`,
+ [id]
+ ) as any[]
+
+ if (results && results.length > 0) {
+ const chapter = results[0]
+ return {
+ id: chapter.id,
+ title: chapter.section_title,
+ price: chapter.price || 1,
+ isFree: chapter.is_free === 1 || chapter.price === 0,
+ filePath: '',
+ content: chapter.content,
+ partTitle: chapter.part_title,
+ chapterTitle: chapter.chapter_title,
+ }
+ }
+ } catch (error) {
+ console.error("[ReadPage] 从数据库获取章节失败:", error)
+ }
+ return null
+}
+
export default async function ReadPage({ params }: ReadPageProps) {
const { id } = await params
@@ -29,7 +59,17 @@ export default async function ReadPage({ params }: ReadPageProps) {
}
try {
- // 先从文件系统获取
+ // 🔥 优先从数据库获取(包含最新的 isFree 状态)
+ const dbChapter = await getChapterFromDB(id)
+ if (dbChapter) {
+ return
+ }
+
+ // 如果数据库没有,再从文件系统获取(兼容旧数据)
const section = getSectionBySlug(id)
if (section) {
const context = getChapterBySectionSlug(id)
@@ -38,7 +78,7 @@ export default async function ReadPage({ params }: ReadPageProps) {
}
}
- // 再从book-data获取
+ // 最后从 book-data 获取
const bookSection = getSectionById(id)
if (bookSection) {
return
diff --git a/app/temp_page.tsx b/app/view/temp_page.tsx
similarity index 94%
rename from app/temp_page.tsx
rename to app/view/temp_page.tsx
index a64e4cb7..d8c81447 100644
--- a/app/temp_page.tsx
+++ b/app/view/temp_page.tsx
@@ -67,7 +67,7 @@ export default async function HomePage() {
{/* 立即阅读按钮 */}
-
+
立即阅读
@@ -126,15 +126,15 @@ export default async function HomePage() {
{/* 底部导航 */}
{/* CTA按钮 - iOS风格 */}
-
+
立即阅读
diff --git a/components/bottom-nav.tsx b/components/bottom-nav.tsx
index 6e965ff9..c42be9c6 100644
--- a/components/bottom-nav.tsx
+++ b/components/bottom-nav.tsx
@@ -1,27 +1,51 @@
"use client"
+import { useState, useEffect } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Home, List, User, Users } from "lucide-react"
export function BottomNav() {
const pathname = usePathname()
+ const [matchEnabled, setMatchEnabled] = useState(false) // 默认隐藏,等配置加载后再显示
+ const [configLoaded, setConfigLoaded] = useState(false) // 配置是否已加载
- // 在文档页面、管理后台、阅读页面和关于页面不显示底部导航
+ // 加载功能配置(必须在所有条件判断之前)
+ useEffect(() => {
+ const loadConfig = async () => {
+ try {
+ const res = await fetch('/api/db/config')
+ const data = await res.json()
+ if (data.features) {
+ // 根据配置设置是否显示找伙伴按钮
+ setMatchEnabled(data.features.matchEnabled === true)
+ }
+ } catch (e) {
+ console.log('Load feature config error:', e)
+ // 加载失败时,默认不显示找伙伴按钮
+ setMatchEnabled(false)
+ } finally {
+ setConfigLoaded(true)
+ }
+ }
+ loadConfig()
+ }, [])
+
+ // 在文档页面、管理后台、阅读页面和关于页面不显示底部导航(必须在所有 hooks 之后)
if (
- pathname.startsWith("/documentation") ||
- pathname.startsWith("/admin") ||
- pathname.startsWith("/read") ||
- pathname.startsWith("/about")
+ pathname?.startsWith("/view/documentation") ||
+ pathname?.startsWith("/admin") ||
+ pathname?.startsWith("/view/read") ||
+ pathname?.startsWith("/view/about")
) {
return null
}
const navItems = [
- { href: "/", icon: Home, label: "首页" },
- { href: "/chapters", icon: List, label: "目录" },
- { href: "/match", icon: Users, label: "找伙伴", isCenter: true },
- { href: "/my", icon: User, label: "我的" },
+ { href: "/view", icon: Home, label: "首页" },
+ { href: "/view/chapters", icon: List, label: "目录" },
+ ...(matchEnabled ? [{ href: "/view/match", icon: Users, label: "找伙伴", isCenter: true }] : []),
+ { href: "/view/my", icon: User, label: "我的" },
]
return (
diff --git a/components/chapter-content.tsx b/components/chapter-content.tsx
index dee496d7..02ede564 100644
--- a/components/chapter-content.tsx
+++ b/components/chapter-content.tsx
@@ -41,7 +41,7 @@ export function ChapterContent({ section, partTitle, chapterTitle }: ChapterCont
const getShareLink = () => {
const baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
const referralCode = user?.referralCode || ''
- const shareUrl = `${baseUrl}/read/${section.id}${referralCode ? `?ref=${referralCode}` : ''}`
+ const shareUrl = `${baseUrl}/view/read/${section.id}${referralCode ? `?ref=${referralCode}` : ''}`
return shareUrl
}
@@ -140,7 +140,7 @@ export function ChapterContent({ section, partTitle, chapterTitle }: ChapterCont
router.push("/chapters")}
+ onClick={() => router.push("/view/chapters")}
className="w-9 h-9 rounded-full bg-[#1c1c1e] flex items-center justify-center active:bg-[#2c2c2e]"
>
@@ -199,7 +199,7 @@ export function ChapterContent({ section, partTitle, chapterTitle }: ChapterCont
{prevSection ? (
router.push(`/read/${prevSection.id}`)}
+ onClick={() => router.push(`/view/read/${prevSection.id}`)}
className="flex-1 max-w-[48%] p-3 rounded-xl bg-[#1c1c1e] border border-white/5 text-left hover:bg-[#2c2c2e] transition-colors"
>
上一篇
@@ -211,7 +211,7 @@ export function ChapterContent({ section, partTitle, chapterTitle }: ChapterCont
{nextSection ? (
router.push(`/read/${nextSection.id}`)}
+ onClick={() => router.push(`/view/read/${nextSection.id}`)}
className="flex-1 max-w-[48%] p-3 rounded-xl bg-gradient-to-r from-[#00CED1]/10 to-[#20B2AA]/10 border border-[#00CED1]/20 text-left hover:from-[#00CED1]/20 hover:to-[#20B2AA]/20 transition-colors"
>
下一篇
@@ -390,7 +390,7 @@ export function ChapterContent({ section, partTitle, chapterTitle }: ChapterCont
router.push('/my/referral')}
+ onClick={() => router.push('/view/my/referral')}
className="flex flex-col items-center gap-2 p-3 rounded-xl bg-white/5 hover:bg-white/10 transition-colors"
>
diff --git a/components/chapters-list.tsx b/components/chapters-list.tsx
index 30ca8677..254f5e6a 100644
--- a/components/chapters-list.tsx
+++ b/components/chapters-list.tsx
@@ -21,7 +21,7 @@ export function ChaptersList({ parts, specialSections }: ChaptersListProps) {
{/* Special sections - Preface */}
{specialSections?.preface && (
-
+
@@ -79,7 +79,7 @@ export function ChaptersList({ parts, specialSections }: ChaptersListProps) {
{chapter.sections.map((section) => (
@@ -117,7 +117,7 @@ export function ChaptersList({ parts, specialSections }: ChaptersListProps) {
{/* Special sections - Epilogue */}
{specialSections?.epilogue && (
-
+
diff --git a/components/footer.tsx b/components/footer.tsx
index 6371f577..1412c35b 100644
--- a/components/footer.tsx
+++ b/components/footer.tsx
@@ -23,7 +23,7 @@ export function Footer() {
{/* 链接 */}
-
关于我们
+
关于我们
用户协议
diff --git a/components/layout/bottom-nav.tsx b/components/layout/bottom-nav.tsx
index 51f640f4..4dabc31a 100644
--- a/components/layout/bottom-nav.tsx
+++ b/components/layout/bottom-nav.tsx
@@ -11,13 +11,13 @@ export function BottomNav() {
if (pathname.startsWith("/admin")) return null
const navItems = [
- { href: "/", icon: Home, label: "首页", id: "home" },
- { href: "/match", icon: Users, label: "找伙伴", id: "match" },
- { href: "/my", icon: User, label: "我的", id: "my" },
+ { href: "/view", icon: Home, label: "首页", id: "home" },
+ { href: "/view/match", icon: Users, label: "找伙伴", id: "match" },
+ { href: "/view/my", icon: User, label: "我的", id: "my" },
]
const isActive = (href: string) => {
- if (href === "/") return pathname === "/"
+ if (href === "/view") return pathname === "/view"
return pathname.startsWith(href)
}
diff --git a/components/modules/auth/auth-modal.tsx b/components/modules/auth/auth-modal.tsx
index 948b0175..a3dbd8a7 100644
--- a/components/modules/auth/auth-modal.tsx
+++ b/components/modules/auth/auth-modal.tsx
@@ -1,6 +1,7 @@
"use client"
import { useState } from "react"
+import Link from "next/link"
import { X, Phone, Lock, User, Gift } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -60,7 +61,7 @@ export function AuthModal({ isOpen, onClose, defaultTab = "login" }: AuthModalPr
setIsLoading(true)
// 昵称可选,默认使用手机号后四位
const name = nickname.trim() || `用户${phone.slice(-4)}`
- const success = await register(phone, name, referralCode || undefined)
+ const success = await register(phone, name, password, referralCode || undefined)
setIsLoading(false)
if (success) {
@@ -146,6 +147,16 @@ export function AuthModal({ isOpen, onClose, defaultTab = "login" }: AuthModalPr
+
+
+ 忘记密码?
+
+
+
{error &&
{error}
}
))
) : (
-
-
-
暂无行为记录
+
+
+
+
+
📊 行为轨迹功能开发中
+
将记录用户的阅读、购买、分享等行为
+
+
即将支持的功能:
+
+ - ✓ 章节阅读记录
+ - ✓ 购买行为追踪
+ - ✓ 分享链接点击
+ - ✓ 登录时间记录
+
+
)}
diff --git a/components/search-modal.tsx b/components/search-modal.tsx
index 47a4da5e..65e79ea6 100644
--- a/components/search-modal.tsx
+++ b/components/search-modal.tsx
@@ -71,7 +71,7 @@ export function SearchModal({ open, onOpenChange }: SearchModalProps) {
const handleResultClick = (result: SearchResult) => {
onOpenChange(false)
- router.push(`/read/${result.id}`)
+ router.push(`/view/read/${result.id}`)
}
const handleKeywordClick = (keyword: string) => {
diff --git a/components/table-of-contents.tsx b/components/table-of-contents.tsx
index 835c2efd..2f6018da 100644
--- a/components/table-of-contents.tsx
+++ b/components/table-of-contents.tsx
@@ -60,7 +60,7 @@ export function TableOfContents({ parts }: TableOfContentsProps) {
{/* 附加内容 - 序言和尾声 */}
-
+
@@ -72,7 +72,7 @@ export function TableOfContents({ parts }: TableOfContentsProps) {
-
+
diff --git a/components/user-menu.tsx b/components/user-menu.tsx
index cd8014a7..688727a7 100644
--- a/components/user-menu.tsx
+++ b/components/user-menu.tsx
@@ -56,7 +56,7 @@ export function UserMenu() {
{/* Menu items */}
setIsMenuOpen(false)}
>
@@ -64,7 +64,7 @@ export function UserMenu() {
我的购买
setIsMenuOpen(false)}
>
diff --git a/components/view/config/config-loader.tsx b/components/view/config/config-loader.tsx
new file mode 100644
index 00000000..5a6221b1
--- /dev/null
+++ b/components/view/config/config-loader.tsx
@@ -0,0 +1,14 @@
+"use client"
+
+import { useEffect } from "react"
+import { useStore } from "@/lib/store"
+
+export function ConfigLoader() {
+ const { fetchSettings } = useStore()
+
+ useEffect(() => {
+ fetchSettings()
+ }, [fetchSettings])
+
+ return null
+}
diff --git a/components/view/layout/bottom-nav.tsx b/components/view/layout/bottom-nav.tsx
new file mode 100644
index 00000000..035dbde1
--- /dev/null
+++ b/components/view/layout/bottom-nav.tsx
@@ -0,0 +1,94 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import Link from "next/link"
+import { usePathname } from "next/navigation"
+import { Home, List, User, Users } from "lucide-react"
+
+export function BottomNav() {
+ const pathname = usePathname()
+ const [matchEnabled, setMatchEnabled] = useState(false)
+ const [configLoaded, setConfigLoaded] = useState(false)
+
+ useEffect(() => {
+ const loadConfig = async () => {
+ try {
+ const res = await fetch('/api/db/config')
+ const data = await res.json()
+ if (data.features) {
+ setMatchEnabled(data.features.matchEnabled === true)
+ }
+ } catch (e) {
+ setMatchEnabled(false)
+ } finally {
+ setConfigLoaded(true)
+ }
+ }
+ loadConfig()
+ }, [])
+
+ if (
+ pathname?.startsWith("/view/documentation") ||
+ pathname?.startsWith("/admin") ||
+ pathname?.startsWith("/view/read") ||
+ pathname?.startsWith("/view/about")
+ ) {
+ return null
+ }
+
+ const navItems = [
+ { href: "/view", icon: Home, label: "首页" },
+ { href: "/view/chapters", icon: List, label: "目录" },
+ ...(matchEnabled ? [{ href: "/view/match", icon: Users, label: "找伙伴", isCenter: true }] : []),
+ { href: "/view/my", icon: User, label: "我的" },
+ ]
+
+ return (
+ <>
+
+ >
+ )
+}
diff --git a/components/view/layout/layout-wrapper.tsx b/components/view/layout/layout-wrapper.tsx
new file mode 100644
index 00000000..5725b43f
--- /dev/null
+++ b/components/view/layout/layout-wrapper.tsx
@@ -0,0 +1,46 @@
+"use client"
+
+import { usePathname } from "next/navigation"
+import { useEffect, useState } from "react"
+import { BottomNav } from "./bottom-nav"
+import { ConfigLoader } from "../config/config-loader"
+import { ReferralCapture } from "./referral-capture"
+
+export function LayoutWrapper({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname()
+ const [mounted, setMounted] = useState(false)
+ const isAdmin = pathname?.startsWith("/admin")
+ const isView = pathname?.startsWith("/view") || pathname === "/"
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ if (!mounted) {
+ return (
+
+
+ {children}
+
+ )
+ }
+
+ if (isAdmin) {
+ return (
+
+
+
+ {children}
+
+ )
+ }
+
+ return (
+
+
+
+ {children}
+
+
+ )
+}
diff --git a/components/view/layout/referral-capture.tsx b/components/view/layout/referral-capture.tsx
new file mode 100644
index 00000000..49234b71
--- /dev/null
+++ b/components/view/layout/referral-capture.tsx
@@ -0,0 +1,38 @@
+"use client"
+
+import { useEffect } from "react"
+import { usePathname, useSearchParams } from "next/navigation"
+
+/**
+ * 捕获分享链接上的 ?ref=xxx 并写入本地存储
+ * - 目的:分销/推荐码在后续注册登录时可读取
+ * - 对齐小程序:pendingReferralCode
+ */
+export function ReferralCapture() {
+ const pathname = usePathname()
+ const searchParams = useSearchParams()
+
+ useEffect(() => {
+ // admin 不参与分销 ref 逻辑
+ if (pathname?.startsWith("/admin")) return
+
+ const ref = searchParams?.get("ref")?.trim()
+ if (!ref) return
+
+ try {
+ // 双写:对齐小程序 key,并留一份更直观的 key
+ localStorage.setItem("pendingReferralCode", ref)
+ localStorage.setItem("referral_code", ref)
+
+ // 兜底:写 cookie,方便服务端/客户端读取(30天)
+ const maxAge = 60 * 60 * 24 * 30
+ document.cookie = `pendingReferralCode=${encodeURIComponent(ref)}; Path=/; Max-Age=${maxAge}; SameSite=Lax`
+ document.cookie = `ref=${encodeURIComponent(ref)}; Path=/; Max-Age=${maxAge}; SameSite=Lax`
+ } catch {
+ // 忽略写入失败(隐私模式/禁用存储等),不影响页面访问
+ }
+ }, [pathname, searchParams])
+
+ return null
+}
+
diff --git a/components/view/ui/button.tsx b/components/view/ui/button.tsx
new file mode 100644
index 00000000..f64632d1
--- /dev/null
+++ b/components/view/ui/button.tsx
@@ -0,0 +1,60 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
+ secondary:
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost:
+ 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+ icon: 'size-9',
+ 'icon-sm': 'size-8',
+ 'icon-lg': 'size-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> &
+ VariantProps
& {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : 'button'
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/devlop.py b/devlop.py
new file mode 100644
index 00000000..afc59581
--- /dev/null
+++ b/devlop.py
@@ -0,0 +1,775 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from __future__ import print_function
+
+import os
+import sys
+import shutil
+import tempfile
+import argparse
+import json
+import zipfile
+import tarfile
+import subprocess
+import time
+import hashlib
+
+try:
+ import paramiko
+except ImportError:
+ print("错误: 请先安装 paramiko")
+ print(" pip install paramiko")
+ sys.exit(1)
+
+try:
+ import requests
+ import urllib3
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+except ImportError:
+ print("错误: 请先安装 requests")
+ print(" pip install requests")
+ sys.exit(1)
+
+
+# ==================== 配置 ====================
+
+# 端口统一从环境变量 DEPLOY_PORT 读取,未设置时使用此默认值(需与 Nginx proxy_pass、ecosystem.config.cjs 一致)
+DEPLOY_PM2_APP = "soul"
+DEFAULT_DEPLOY_PORT = 3006
+DEPLOY_PROJECT_PATH = "/www/wwwroot/soul"
+DEPLOY_SITE_URL = "https://soul.quwanzhi.com"
+# SSH 端口(支持环境变量 DEPLOY_SSH_PORT,未设置时默认为 22022)
+DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
+
+def get_cfg():
+ """获取基础部署配置(deploy 模式与 devlop 共用 SSH/宝塔)"""
+ return {
+ "host": os.environ.get("DEPLOY_HOST", "42.194.232.22"),
+ "user": os.environ.get("DEPLOY_USER", "root"),
+ "password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
+ "ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
+ "project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
+ "panel_url": os.environ.get("BAOTA_PANEL_URL", "https://42.194.232.22:9988"),
+ "api_key": os.environ.get("BAOTA_API_KEY", "hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd"),
+ "pm2_name": os.environ.get("DEPLOY_PM2_APP", DEPLOY_PM2_APP),
+ "site_url": os.environ.get("DEPLOY_SITE_URL", DEPLOY_SITE_URL),
+ "port": int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT))),
+ "node_version": os.environ.get("DEPLOY_NODE_VERSION", "v22.14.0"),
+ "node_path": os.environ.get("DEPLOY_NODE_PATH", "/www/server/nodejs/v22.14.0/bin"),
+ }
+
+
+def get_cfg_devlop():
+ """devlop 模式配置:在基础配置上增加 base_path / dist / dist2。
+ 实际运行目录为 dist_path(切换后新版本在 dist),宝塔 PM2 项目路径必须指向 dist_path,
+ 否则会从错误目录启动导致 .next/static 等静态资源 404。"""
+ cfg = get_cfg().copy()
+ cfg["base_path"] = os.environ.get("DEVOP_BASE_PATH", "/www/wwwroot/soul")
+ cfg["dist_path"] = cfg["base_path"] + "/dist"
+ cfg["dist2_path"] = cfg["base_path"] + "/dist2"
+ return cfg
+
+
+# ==================== 宝塔 API ====================
+
+def _get_sign(api_key):
+ now_time = int(time.time())
+ sign_str = str(now_time) + hashlib.md5(api_key.encode("utf-8")).hexdigest()
+ request_token = hashlib.md5(sign_str.encode("utf-8")).hexdigest()
+ return now_time, request_token
+
+
+def _baota_request(panel_url, api_key, path, data=None):
+ req_time, req_token = _get_sign(api_key)
+ payload = {"request_time": req_time, "request_token": req_token}
+ if data:
+ payload.update(data)
+ url = panel_url.rstrip("/") + "/" + path.lstrip("/")
+ try:
+ r = requests.post(url, data=payload, verify=False, timeout=30)
+ return r.json() if r.text else {}
+ except Exception as e:
+ print(" API 请求失败: %s" % str(e))
+ return None
+
+
+def get_node_project_list(panel_url, api_key):
+ for path in ["/project/nodejs/get_project_list", "/plugin?action=a&name=nodejs&s=get_project_list"]:
+ result = _baota_request(panel_url, api_key, path)
+ if result and (result.get("status") is True or "data" in result):
+ return result.get("data", [])
+ return None
+
+
+def get_node_project_status(panel_url, api_key, pm2_name):
+ projects = get_node_project_list(panel_url, api_key)
+ if projects:
+ for p in projects:
+ if p.get("name") == pm2_name:
+ return p
+ return None
+
+
+def start_node_project(panel_url, api_key, pm2_name):
+ for path in ["/project/nodejs/start_project", "/plugin?action=a&name=nodejs&s=start_project"]:
+ result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name})
+ if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)):
+ print(" [成功] 启动成功: %s" % pm2_name)
+ return True
+ return False
+
+
+def stop_node_project(panel_url, api_key, pm2_name):
+ for path in ["/project/nodejs/stop_project", "/plugin?action=a&name=nodejs&s=stop_project"]:
+ result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name})
+ if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)):
+ print(" [成功] 停止成功: %s" % pm2_name)
+ return True
+ return False
+
+
+def restart_node_project(panel_url, api_key, pm2_name):
+ project_status = get_node_project_status(panel_url, api_key, pm2_name)
+ if project_status:
+ print(" 项目状态: %s" % project_status.get("status", "未知"))
+ for path in ["/project/nodejs/restart_project", "/plugin?action=a&name=nodejs&s=restart_project"]:
+ result = _baota_request(panel_url, api_key, path, {"project_name": pm2_name})
+ if result and (result.get("status") is True or result.get("msg") or "成功" in str(result)):
+ print(" [成功] 重启成功: %s" % pm2_name)
+ return True
+ if result and "msg" in result:
+ print(" API 返回: %s" % result.get("msg"))
+ print(" [警告] 重启失败,请检查宝塔 Node 插件是否安装、API 密钥是否正确")
+ return False
+
+
+def add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port=None, node_path=None):
+ if port is None:
+ port = int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT)))
+ port_env = "PORT=%d " % port
+ run_cmd = port_env + ("%s/node server.js" % node_path if node_path else "node server.js")
+ payload = {"name": pm2_name, "path": project_path, "run_cmd": run_cmd, "port": str(port)}
+ for path in ["/project/nodejs/add_project", "/plugin?action=a&name=nodejs&s=add_project"]:
+ result = _baota_request(panel_url, api_key, path, payload)
+ if result and result.get("status") is True:
+ print(" [成功] 项目配置已更新: %s" % pm2_name)
+ return True
+ if result and "msg" in result:
+ print(" API 返回: %s" % result.get("msg"))
+ return False
+
+
+# ==================== 本地构建 ====================
+
+def run_build(root):
+ """执行本地 pnpm build"""
+ use_shell = sys.platform == "win32"
+ standalone = os.path.join(root, ".next", "standalone")
+ server_js = os.path.join(standalone, "server.js")
+
+ try:
+ r = subprocess.run(
+ ["pnpm", "build"],
+ cwd=root,
+ shell=use_shell,
+ timeout=600,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ stdout_text = r.stdout or ""
+ stderr_text = r.stderr or ""
+ combined = stdout_text + stderr_text
+ is_windows_symlink_error = (
+ sys.platform == "win32"
+ and r.returncode != 0
+ and ("EPERM" in combined or "symlink" in combined.lower() or "operation not permitted" in combined.lower() or "errno: -4048" in combined)
+ )
+
+ if r.returncode != 0:
+ if is_windows_symlink_error:
+ print(" [警告] Windows 符号链接权限错误(EPERM)")
+ print(" 解决方案:开启开发者模式 / 以管理员运行 / 或使用 --no-build")
+ if os.path.isdir(standalone) and os.path.isfile(server_js):
+ print(" [成功] standalone 输出可用,继续部署")
+ return True
+ return False
+ print(" [失败] 构建失败,退出码:", r.returncode)
+ for line in (stdout_text.strip().split("\n") or [])[-10:]:
+ print(" " + line)
+ return False
+ except subprocess.TimeoutExpired:
+ print(" [失败] 构建超时(超过10分钟)")
+ return False
+ except FileNotFoundError:
+ print(" [失败] 未找到 pnpm,请安装: npm install -g pnpm")
+ return False
+ except Exception as e:
+ print(" [失败] 构建异常:", str(e))
+ if os.path.isdir(standalone) and os.path.isfile(server_js):
+ print(" [提示] 可尝试使用 --no-build 跳过构建")
+ return False
+
+ if not os.path.isdir(standalone) or not os.path.isfile(server_js):
+ print(" [失败] 未找到 .next/standalone 或 server.js")
+ return False
+ print(" [成功] 构建完成")
+ return True
+
+
+def clean_standalone_before_build(root, retries=3, delay=2):
+ """构建前删除 .next/standalone,避免 Windows EBUSY"""
+ standalone = os.path.join(root, ".next", "standalone")
+ if not os.path.isdir(standalone):
+ return True
+ for attempt in range(1, retries + 1):
+ try:
+ shutil.rmtree(standalone)
+ print(" [清理] 已删除 .next/standalone(第 %d 次尝试)" % attempt)
+ return True
+ except (OSError, PermissionError):
+ if attempt < retries:
+ print(" [清理] 被占用,%ds 后重试 (%d/%d) ..." % (delay, attempt, retries))
+ time.sleep(delay)
+ else:
+ print(" [失败] 无法删除 .next/standalone,可改用 --no-build")
+ return False
+ return False
+
+
+# ==================== 打包(deploy 模式:tar.gz) ====================
+
+def _copy_with_dereference(src, dst):
+ if os.path.islink(src):
+ link_target = os.readlink(src)
+ real_path = link_target if os.path.isabs(link_target) else os.path.join(os.path.dirname(src), link_target)
+ if os.path.exists(real_path):
+ if os.path.isdir(real_path):
+ shutil.copytree(real_path, dst, symlinks=False, dirs_exist_ok=True)
+ else:
+ shutil.copy2(real_path, dst)
+ else:
+ shutil.copy2(src, dst, follow_symlinks=False)
+ elif os.path.isdir(src):
+ if os.path.exists(dst):
+ shutil.rmtree(dst)
+ shutil.copytree(src, dst, symlinks=False, dirs_exist_ok=True)
+ else:
+ shutil.copy2(src, dst)
+
+
+def pack_standalone_tar(root):
+ """打包 standalone 为 tar.gz(deploy 模式用)"""
+ print("[2/4] 打包 standalone ...")
+ standalone = os.path.join(root, ".next", "standalone")
+ static_src = os.path.join(root, ".next", "static")
+ public_src = os.path.join(root, "public")
+ ecosystem_src = os.path.join(root, "ecosystem.config.cjs")
+
+ if not os.path.isdir(standalone) or not os.path.isdir(static_src):
+ print(" [失败] 未找到 .next/standalone 或 .next/static")
+ return None
+ chunks_dir = os.path.join(static_src, "chunks")
+ if not os.path.isdir(chunks_dir):
+ print(" [失败] .next/static/chunks 不存在,请先完整执行 pnpm build(本地 pnpm start 能正常打开页面后再部署)")
+ return None
+
+ staging = tempfile.mkdtemp(prefix="soul_deploy_")
+ try:
+ for name in os.listdir(standalone):
+ _copy_with_dereference(os.path.join(standalone, name), os.path.join(staging, name))
+ node_modules_dst = os.path.join(staging, "node_modules")
+ pnpm_dir = os.path.join(node_modules_dst, ".pnpm")
+ if os.path.isdir(pnpm_dir):
+ for dep in ["styled-jsx"]:
+ dep_in_root = os.path.join(node_modules_dst, dep)
+ if not os.path.exists(dep_in_root):
+ for pnpm_pkg in os.listdir(pnpm_dir):
+ if pnpm_pkg.startswith(dep + "@"):
+ src_dep = os.path.join(pnpm_dir, pnpm_pkg, "node_modules", dep)
+ if os.path.isdir(src_dep):
+ shutil.copytree(src_dep, dep_in_root, symlinks=False, dirs_exist_ok=True)
+ break
+ static_dst = os.path.join(staging, ".next", "static")
+ if os.path.exists(static_dst):
+ shutil.rmtree(static_dst)
+ os.makedirs(os.path.dirname(static_dst), exist_ok=True)
+ shutil.copytree(static_src, static_dst)
+ # 同步构建索引(与 start-standalone.js 一致),避免宝塔上 server 用错导致页面空白/404
+ next_root = os.path.join(root, ".next")
+ next_staging = os.path.join(staging, ".next")
+ index_files = [
+ "BUILD_ID",
+ "build-manifest.json",
+ "app-path-routes-manifest.json",
+ "routes-manifest.json",
+ "prerender-manifest.json",
+ "required-server-files.json",
+ "fallback-build-manifest.json",
+ ]
+ for name in index_files:
+ src = os.path.join(next_root, name)
+ if os.path.isfile(src):
+ shutil.copy2(src, os.path.join(next_staging, name))
+ print(" [已同步] 构建索引: BUILD_ID, build-manifest, routes-manifest 等")
+ if os.path.isdir(public_src):
+ shutil.copytree(public_src, os.path.join(staging, "public"), dirs_exist_ok=True)
+ if os.path.isfile(ecosystem_src):
+ shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs"))
+ pkg_json = os.path.join(staging, "package.json")
+ if os.path.isfile(pkg_json):
+ try:
+ with open(pkg_json, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ data.setdefault("scripts", {})["start"] = "node server.js"
+ with open(pkg_json, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2, ensure_ascii=False)
+ except Exception:
+ pass
+ tarball = os.path.join(tempfile.gettempdir(), "soul_deploy.tar.gz")
+ with tarfile.open(tarball, "w:gz") as tf:
+ for name in os.listdir(staging):
+ tf.add(os.path.join(staging, name), arcname=name)
+ print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, os.path.getsize(tarball) / 1024 / 1024))
+ return tarball
+ except Exception as e:
+ print(" [失败] 打包异常:", str(e))
+ return None
+ finally:
+ shutil.rmtree(staging, ignore_errors=True)
+
+
+# ==================== Node 环境检查 & SSH 上传(deploy 模式) ====================
+
+def check_node_environments(cfg):
+ print("[检查] Node 环境 ...")
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ try:
+ if cfg.get("ssh_key"):
+ client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
+ else:
+ client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
+ stdin, stdout, stderr = client.exec_command("which node && node -v", timeout=10)
+ print(" 默认 Node: %s" % (stdout.read().decode("utf-8", errors="replace").strip() or "未找到"))
+ node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")
+ stdin, stdout, stderr = client.exec_command("%s/node -v 2>/dev/null" % node_path, timeout=5)
+ print(" 配置 Node: %s" % (stdout.read().decode("utf-8", errors="replace").strip() or "不可用"))
+ return True
+ except Exception as e:
+ print(" [警告] %s" % str(e))
+ return False
+ finally:
+ client.close()
+
+
+def upload_and_extract(cfg, tarball_path):
+ """SSH 上传 tar.gz 并解压到 project_path(deploy 模式)"""
+ print("[3/4] SSH 上传并解压 ...")
+ if not cfg.get("password") and not cfg.get("ssh_key"):
+ print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
+ return False
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ try:
+ if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
+ client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
+ else:
+ client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
+ sftp = client.open_sftp()
+ remote_tar = "/tmp/soul_deploy.tar.gz"
+ remote_script = "/tmp/soul_deploy_extract.sh"
+ sftp.put(tarball_path, remote_tar)
+ node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")
+ project_path = cfg["project_path"]
+ script_content = """#!/bin/bash
+export PATH=%s:$PATH
+cd %s
+rm -rf .next public ecosystem.config.cjs server.js package.json 2>/dev/null
+tar -xzf %s
+rm -f %s
+echo OK
+""" % (node_path, project_path, remote_tar, remote_tar)
+ with sftp.open(remote_script, "w") as f:
+ f.write(script_content)
+ sftp.close()
+ client.exec_command("chmod +x %s" % remote_script, timeout=10)
+ stdin, stdout, stderr = client.exec_command("bash %s" % remote_script, timeout=120)
+ out = stdout.read().decode("utf-8", errors="replace").strip()
+ exit_status = stdout.channel.recv_exit_status()
+ if exit_status != 0 or "OK" not in out:
+ print(" [失败] 解压失败,退出码:", exit_status)
+ return False
+ print(" [成功] 解压完成: %s" % project_path)
+ return True
+ except Exception as e:
+ print(" [失败] SSH 错误:", str(e))
+ return False
+ finally:
+ client.close()
+
+
+def deploy_via_baota_api(cfg):
+ """宝塔 API 重启 Node 项目(deploy 模式)"""
+ print("[4/4] 宝塔 API 管理 Node 项目 ...")
+ panel_url, api_key, pm2_name = cfg["panel_url"], cfg["api_key"], cfg["pm2_name"]
+ project_path = cfg["project_path"]
+ node_path = cfg.get("node_path", "/www/server/nodejs/v22.14.0/bin")
+ port = cfg["port"]
+
+ if not get_node_project_status(panel_url, api_key, pm2_name):
+ add_or_update_node_project(panel_url, api_key, pm2_name, project_path, port, node_path)
+ stop_node_project(panel_url, api_key, pm2_name)
+ time.sleep(2)
+ ok = restart_node_project(panel_url, api_key, pm2_name)
+ if not ok:
+ ok = start_node_project(panel_url, api_key, pm2_name)
+ if not ok:
+ print(" 请到宝塔 Node 项目手动重启 %s,路径: %s" % (pm2_name, project_path))
+ return ok
+
+
+# ==================== 打包(devlop 模式:zip) ====================
+
+ZIP_EXCLUDE_DIRS = {".cache", "__pycache__", ".git", "node_modules", "cache", "test", "tests", "coverage", ".nyc_output", ".turbo", "开发文档"}
+ZIP_EXCLUDE_FILE_NAMES = {".DS_Store", "Thumbs.db"}
+ZIP_EXCLUDE_FILE_SUFFIXES = (".log", ".map")
+
+
+def _should_exclude_from_zip(arcname, is_file=True):
+ parts = arcname.replace("\\", "/").split("/")
+ for part in parts:
+ if part in ZIP_EXCLUDE_DIRS:
+ return True
+ if is_file and parts:
+ name = parts[-1]
+ if name in ZIP_EXCLUDE_FILE_NAMES or any(name.endswith(s) for s in ZIP_EXCLUDE_FILE_SUFFIXES):
+ return True
+ return False
+
+
+def pack_standalone_zip(root):
+ """打包 standalone 为 zip(devlop 模式用)"""
+ print("[2/7] 打包 standalone 为 zip ...")
+ standalone = os.path.join(root, ".next", "standalone")
+ static_src = os.path.join(root, ".next", "static")
+ public_src = os.path.join(root, "public")
+ ecosystem_src = os.path.join(root, "ecosystem.config.cjs")
+
+ if not os.path.isdir(standalone) or not os.path.isdir(static_src):
+ print(" [失败] 未找到 .next/standalone 或 .next/static")
+ return None
+ chunks_dir = os.path.join(static_src, "chunks")
+ if not os.path.isdir(chunks_dir):
+ print(" [失败] .next/static/chunks 不存在,请先完整执行 pnpm build(本地 pnpm start 能正常打开页面后再部署)")
+ return None
+
+ staging = tempfile.mkdtemp(prefix="soul_devlop_")
+ try:
+ for name in os.listdir(standalone):
+ _copy_with_dereference(os.path.join(standalone, name), os.path.join(staging, name))
+ node_modules_dst = os.path.join(staging, "node_modules")
+ pnpm_dir = os.path.join(node_modules_dst, ".pnpm")
+ if os.path.isdir(pnpm_dir):
+ for dep in ["styled-jsx"]:
+ dep_in_root = os.path.join(node_modules_dst, dep)
+ if not os.path.exists(dep_in_root):
+ for pnpm_pkg in os.listdir(pnpm_dir):
+ if pnpm_pkg.startswith(dep + "@"):
+ src_dep = os.path.join(pnpm_dir, pnpm_pkg, "node_modules", dep)
+ if os.path.isdir(src_dep):
+ shutil.copytree(src_dep, dep_in_root, symlinks=False, dirs_exist_ok=True)
+ break
+ os.makedirs(os.path.join(staging, ".next"), exist_ok=True)
+ shutil.copytree(static_src, os.path.join(staging, ".next", "static"), dirs_exist_ok=True)
+ # 同步构建索引(与 start-standalone.js 一致),避免宝塔上 server 用错导致页面空白/404
+ next_root = os.path.join(root, ".next")
+ next_staging = os.path.join(staging, ".next")
+ index_files = [
+ "BUILD_ID",
+ "build-manifest.json",
+ "app-path-routes-manifest.json",
+ "routes-manifest.json",
+ "prerender-manifest.json",
+ "required-server-files.json",
+ "fallback-build-manifest.json",
+ ]
+ for name in index_files:
+ src = os.path.join(next_root, name)
+ if os.path.isfile(src):
+ shutil.copy2(src, os.path.join(next_staging, name))
+ print(" [已同步] 构建索引: BUILD_ID, build-manifest, routes-manifest 等")
+ if os.path.isdir(public_src):
+ shutil.copytree(public_src, os.path.join(staging, "public"), dirs_exist_ok=True)
+ if os.path.isfile(ecosystem_src):
+ shutil.copy2(ecosystem_src, os.path.join(staging, "ecosystem.config.cjs"))
+ pkg_json = os.path.join(staging, "package.json")
+ if os.path.isfile(pkg_json):
+ try:
+ with open(pkg_json, "r", encoding="utf-8") as f:
+ data = json.load(f)
+ data.setdefault("scripts", {})["start"] = "node server.js"
+ with open(pkg_json, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2, ensure_ascii=False)
+ except Exception:
+ pass
+ server_js = os.path.join(staging, "server.js")
+ if os.path.isfile(server_js):
+ try:
+ deploy_port = int(os.environ.get("DEPLOY_PORT", str(DEFAULT_DEPLOY_PORT)))
+ with open(server_js, "r", encoding="utf-8") as f:
+ c = f.read()
+ if "|| 3000" in c:
+ with open(server_js, "w", encoding="utf-8") as f:
+ f.write(c.replace("|| 3000", "|| %d" % deploy_port))
+ except Exception:
+ pass
+ zip_path = os.path.join(tempfile.gettempdir(), "soul_devlop.zip")
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
+ for name in os.listdir(staging):
+ path = os.path.join(staging, name)
+ if os.path.isfile(path):
+ if not _should_exclude_from_zip(name):
+ zf.write(path, name)
+ else:
+ for dirpath, dirs, filenames in os.walk(path):
+ dirs[:] = [d for d in dirs if not _should_exclude_from_zip(os.path.join(name, os.path.relpath(os.path.join(dirpath, d), path)), is_file=False)]
+ for f in filenames:
+ full = os.path.join(dirpath, f)
+ arcname = os.path.join(name, os.path.relpath(full, path))
+ if not _should_exclude_from_zip(arcname):
+ zf.write(full, arcname)
+ print(" [成功] 打包完成: %s (%.2f MB)" % (zip_path, os.path.getsize(zip_path) / 1024 / 1024))
+ return zip_path
+ except Exception as e:
+ print(" [失败] 打包异常:", str(e))
+ return None
+ finally:
+ shutil.rmtree(staging, ignore_errors=True)
+
+
+def upload_zip_and_extract_to_dist2(cfg, zip_path):
+ """上传 zip 并解压到 dist2(devlop 模式)"""
+ print("[3/7] SSH 上传 zip 并解压到 dist2 ...")
+ sys.stdout.flush()
+ if not cfg.get("password") and not cfg.get("ssh_key"):
+ print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
+ return False
+ zip_size_mb = os.path.getsize(zip_path) / (1024 * 1024)
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ try:
+ print(" 正在连接 %s@%s:%s ..." % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
+ sys.stdout.flush()
+ if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
+ client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=30, banner_timeout=30)
+ else:
+ client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=30, banner_timeout=30)
+ print(" [OK] SSH 已连接,正在上传 zip(%.1f MB)..." % zip_size_mb)
+ sys.stdout.flush()
+ remote_zip = cfg["base_path"].rstrip("/") + "/soul_devlop.zip"
+ sftp = client.open_sftp()
+ # 上传进度:每 5MB 打印一次
+ chunk_mb = 5.0
+ last_reported = [0]
+
+ def _progress(transferred, total):
+ if total and total > 0:
+ now_mb = transferred / (1024 * 1024)
+ if now_mb - last_reported[0] >= chunk_mb or transferred >= total:
+ last_reported[0] = now_mb
+ print("\r 上传进度: %.1f / %.1f MB" % (now_mb, total / (1024 * 1024)), end="")
+ sys.stdout.flush()
+
+ sftp.put(zip_path, remote_zip, callback=_progress)
+ if zip_size_mb >= chunk_mb:
+ print("")
+ print(" [OK] zip 已上传,正在服务器解压(约 1–3 分钟)...")
+ sys.stdout.flush()
+ sftp.close()
+ dist2 = cfg["dist2_path"]
+ cmd = "rm -rf %s && mkdir -p %s && unzip -o -q %s -d %s && rm -f %s && echo OK" % (dist2, dist2, remote_zip, dist2, remote_zip)
+ stdin, stdout, stderr = client.exec_command(cmd, timeout=300)
+ out = stdout.read().decode("utf-8", errors="replace").strip()
+ err = stderr.read().decode("utf-8", errors="replace").strip()
+ if err:
+ print(" 服务器 stderr: %s" % err[:500])
+ exit_status = stdout.channel.recv_exit_status()
+ if exit_status != 0 or "OK" not in out:
+ print(" [失败] 解压失败,退出码: %s" % exit_status)
+ if out:
+ print(" stdout: %s" % out[:300])
+ return False
+ print(" [成功] 已解压到: %s" % dist2)
+ return True
+ except Exception as e:
+ print(" [失败] SSH 错误: %s" % str(e))
+ import traceback
+ traceback.print_exc()
+ return False
+ finally:
+ client.close()
+
+
+def run_pnpm_install_in_dist2(cfg):
+ """服务器 dist2 内执行 pnpm install,阻塞等待完成后再返回(改目录前必须完成)"""
+ print("[4/7] 服务器 dist2 内执行 pnpm install(等待完成后再切换目录)...")
+ sys.stdout.flush()
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ try:
+ if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
+ client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
+ else:
+ client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
+ stdin, stdout, stderr = client.exec_command("bash -lc 'which pnpm'", timeout=10)
+ pnpm_path = stdout.read().decode("utf-8", errors="replace").strip()
+ if not pnpm_path:
+ return False, "未找到 pnpm,请服务器安装: npm install -g pnpm"
+ cmd = "bash -lc 'cd %s && %s install'" % (cfg["dist2_path"], pnpm_path)
+ stdin, stdout, stderr = client.exec_command(cmd, timeout=300)
+ out = stdout.read().decode("utf-8", errors="replace").strip()
+ err = stderr.read().decode("utf-8", errors="replace").strip()
+ if stdout.channel.recv_exit_status() != 0:
+ return False, "pnpm install 失败\n" + (err or out)
+ print(" [成功] dist2 内 pnpm install 已执行完成,可安全切换目录")
+ return True, None
+ except Exception as e:
+ return False, str(e)
+ finally:
+ client.close()
+
+
+def remote_swap_dist_and_restart(cfg):
+ """暂停 → dist→dist1, dist2→dist → 删除 dist1 → 更新 PM2 项目路径 → 重启(devlop 模式)"""
+ print("[5/7] 宝塔 API 暂停 Node 项目 ...")
+ stop_node_project(cfg["panel_url"], cfg["api_key"], cfg["pm2_name"])
+ time.sleep(2)
+ print("[6/7] 服务器切换目录: dist→dist1, dist2→dist ...")
+ client = paramiko.SSHClient()
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
+ try:
+ if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
+ client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
+ else:
+ client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
+ cmd = "cd %s && mv dist dist1 2>/dev/null; mv dist2 dist && rm -rf dist1 && echo OK" % cfg["base_path"]
+ stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
+ out = stdout.read().decode("utf-8", errors="replace").strip()
+ if stdout.channel.recv_exit_status() != 0 or "OK" not in out:
+ print(" [失败] 切换失败")
+ return False
+ print(" [成功] 新版本位于 %s" % cfg["dist_path"])
+ finally:
+ client.close()
+ # 关键:devlop 实际运行目录是 dist_path,必须让宝塔 PM2 从该目录启动,否则会从错误目录跑导致静态资源 404
+ print("[7/7] 更新宝塔 Node 项目路径并重启 ...")
+ add_or_update_node_project(
+ cfg["panel_url"], cfg["api_key"], cfg["pm2_name"],
+ cfg["dist_path"], # 使用 dist_path,不是 project_path
+ port=cfg["port"],
+ node_path=cfg.get("node_path"),
+ )
+ if not start_node_project(cfg["panel_url"], cfg["api_key"], cfg["pm2_name"]):
+ print(" [警告] 请到宝塔手动启动 %s,并确认项目路径为: %s" % (cfg["pm2_name"], cfg["dist_path"]))
+ return False
+ return True
+
+
+# ==================== 主函数 ====================
+
+def main():
+ parser = argparse.ArgumentParser(description="Soul 创业派对 - 统一部署脚本", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__)
+ parser.add_argument("--mode", choices=["devlop", "deploy"], default="devlop", help="devlop=dist切换(默认), deploy=直接覆盖")
+ parser.add_argument("--no-build", action="store_true", help="跳过本地构建")
+ parser.add_argument("--no-upload", action="store_true", help="仅 deploy 模式:跳过 SSH 上传")
+ parser.add_argument("--no-api", action="store_true", help="仅 deploy 模式:上传后不调宝塔 API")
+ args = parser.parse_args()
+
+ script_dir = os.path.dirname(os.path.abspath(__file__))
+ # 支持 devlop.py 在项目根或 scripts/ 下:以含 package.json 的目录为 root
+ if os.path.isfile(os.path.join(script_dir, "package.json")):
+ root = script_dir
+ else:
+ root = os.path.dirname(script_dir)
+
+ if args.mode == "devlop":
+ cfg = get_cfg_devlop()
+ print("=" * 60)
+ print(" Soul 自动部署(dist 切换)")
+ print("=" * 60)
+ print(" 服务器: %s@%s 目录: %s Node: %s" % (cfg["user"], cfg["host"], cfg["base_path"], cfg["pm2_name"]))
+ print("=" * 60)
+ if not args.no_build:
+ print("[1/7] 本地构建 pnpm build ...")
+ if sys.platform == "win32" and not clean_standalone_before_build(root):
+ return 1
+ if not run_build(root):
+ return 1
+ elif not os.path.isfile(os.path.join(root, ".next", "standalone", "server.js")):
+ print("[错误] 未找到 .next/standalone/server.js")
+ return 1
+ else:
+ print("[1/7] 跳过本地构建")
+ zip_path = pack_standalone_zip(root)
+ if not zip_path:
+ return 1
+ if not upload_zip_and_extract_to_dist2(cfg, zip_path):
+ return 1
+ try:
+ os.remove(zip_path)
+ except Exception:
+ pass
+ # 必须在 dist2 内 pnpm install 执行完成后再切换目录
+ ok, err = run_pnpm_install_in_dist2(cfg)
+ if not ok:
+ print(" [失败] %s" % (err or "pnpm install 失败"))
+ return 1
+ # install 已完成,再执行 dist→dist1、dist2→dist 切换
+ if not remote_swap_dist_and_restart(cfg):
+ return 1
+ print("")
+ print(" 部署完成!运行目录: %s" % cfg["dist_path"])
+ return 0
+
+ # deploy 模式
+ cfg = get_cfg()
+ print("=" * 60)
+ print(" Soul 一键部署(直接覆盖)")
+ print("=" * 60)
+ print(" 服务器: %s@%s 项目路径: %s PM2: %s" % (cfg["user"], cfg["host"], cfg["project_path"], cfg["pm2_name"]))
+ print("=" * 60)
+ if not args.no_upload:
+ check_node_environments(cfg)
+ if not args.no_build:
+ print("[1/4] 本地构建 ...")
+ if not run_build(root):
+ return 1
+ elif not os.path.isfile(os.path.join(root, ".next", "standalone", "server.js")):
+ print("[错误] 未找到 .next/standalone/server.js")
+ return 1
+ else:
+ print("[1/4] 跳过本地构建")
+ tarball = pack_standalone_tar(root)
+ if not tarball:
+ return 1
+ if not args.no_upload:
+ if not upload_and_extract(cfg, tarball):
+ return 1
+ try:
+ os.remove(tarball)
+ except Exception:
+ pass
+ else:
+ print(" 压缩包: %s" % tarball)
+ if not args.no_api and not args.no_upload:
+ deploy_via_baota_api(cfg)
+ print("")
+ print(" 部署完成!站点: %s" % cfg["site_url"])
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/docker-compose.yml b/docker-compose.yml
deleted file mode 100644
index 6749ee09..00000000
--- a/docker-compose.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-version: "3.8"
-
-services:
- soul-book:
- build:
- context: .
- dockerfile: Dockerfile
- container_name: soul_book_app
- restart: always
- ports:
- - "3000:3000"
- environment:
- - NODE_ENV=production
- - NEXT_TELEMETRY_DISABLED=1
- # 支付宝配置
- - ALIPAY_PARTNER_ID=${ALIPAY_PARTNER_ID:-2088511801157159}
- - ALIPAY_KEY=${ALIPAY_KEY:-lz6ey1h3kl9zqkgtjz3avb5gk37wzbrp}
- - ALIPAY_APP_ID=${ALIPAY_APP_ID:-wx432c93e275548671}
- - ALIPAY_RETURN_URL=${ALIPAY_RETURN_URL:-http://192.168.2.201:3000/payment/success}
- - ALIPAY_NOTIFY_URL=${ALIPAY_NOTIFY_URL:-http://192.168.2.201:3000/api/payment/alipay/notify}
- # 微信支付配置
- - WECHAT_APP_ID=${WECHAT_APP_ID:-wx432c93e275548671}
- - WECHAT_APP_SECRET=${WECHAT_APP_SECRET:-25b7e7fdb7998e5107e242ebb6ddabd0}
- - WECHAT_MCH_ID=${WECHAT_MCH_ID:-1318592501}
- - WECHAT_API_KEY=${WECHAT_API_KEY:-wx3e31b068be59ddc131b068be59ddc2}
- - WECHAT_NOTIFY_URL=${WECHAT_NOTIFY_URL:-http://192.168.2.201:3000/api/payment/wechat/notify}
- # 基础配置
- - NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-http://192.168.2.201:3000}
- volumes:
- - ./book:/app/book:ro
- - ./public:/app/public:ro
- networks:
- - nas-network
- healthcheck:
- test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000"]
- interval: 30s
- timeout: 10s
- retries: 3
- start_period: 40s
-
-networks:
- nas-network:
- external: true
diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs
new file mode 100644
index 00000000..2c8e2af5
--- /dev/null
+++ b/ecosystem.config.cjs
@@ -0,0 +1,19 @@
+/**
+ * PM2 配置:用于 standalone 部署的服务器
+ * 启动方式:node server.js(不要用 npm start / next start,standalone 无 next 命令)
+ * 使用:pm2 start ecosystem.config.cjs 或 PORT=30006 pm2 start server.js --name soul
+ */
+module.exports = {
+ apps: [
+ {
+ name: 'soul',
+ script: 'server.js',
+ interpreter: 'node',
+ env: {
+ NODE_ENV: 'production',
+ PORT: 30006,
+ },
+ cwd: undefined, // 以当前目录为准,部署时在 /www/wwwroot/soul
+ },
+ ],
+};
diff --git a/lib/admin-auth.ts b/lib/admin-auth.ts
new file mode 100644
index 00000000..3d476249
--- /dev/null
+++ b/lib/admin-auth.ts
@@ -0,0 +1,91 @@
+/**
+ * 后台管理员登录鉴权:生成/校验签名 Cookie,不暴露账号密码
+ * 账号密码从环境变量读取,默认 admin / admin123
+ */
+
+import { createHmac, timingSafeEqual } from 'crypto'
+
+const COOKIE_NAME = 'admin_session'
+const MAX_AGE_SEC = 7 * 24 * 3600 // 7 天
+const SECRET = process.env.ADMIN_SESSION_SECRET || 'soul-admin-secret-change-in-prod'
+
+export function getAdminCredentials() {
+ return {
+ username: process.env.ADMIN_USERNAME || 'admin',
+ password: process.env.ADMIN_PASSWORD || 'admin123',
+ }
+}
+
+export function verifyAdminCredentials(username: string, password: string): boolean {
+ const { username: u, password: p } = getAdminCredentials()
+ return username === u && password === p
+}
+
+function sign(payload: string): string {
+ return createHmac('sha256', SECRET).update(payload).digest('base64url')
+}
+
+/** 生成签名 token,写入 Cookie 用 */
+export function createAdminToken(): string {
+ const exp = Math.floor(Date.now() / 1000) + MAX_AGE_SEC
+ const payload = `${exp}`
+ const sig = sign(payload)
+ return `${payload}.${sig}`
+}
+
+/** 校验 Cookie 中的 token */
+export function verifyAdminToken(token: string | null | undefined): boolean {
+ if (!token || typeof token !== 'string') return false
+ const dot = token.indexOf('.')
+ if (dot === -1) return false
+ const payload = token.slice(0, dot)
+ const sig = token.slice(dot + 1)
+ const exp = parseInt(payload, 10)
+ if (Number.isNaN(exp) || exp < Math.floor(Date.now() / 1000)) return false
+ const expected = sign(payload)
+ if (sig.length !== expected.length) return false
+ try {
+ return timingSafeEqual(Buffer.from(sig, 'base64url'), Buffer.from(expected, 'base64url'))
+ } catch {
+ return false
+ }
+}
+
+export function getAdminCookieName() {
+ return COOKIE_NAME
+}
+
+export function getAdminCookieOptions() {
+ return {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ sameSite: 'lax' as const,
+ maxAge: MAX_AGE_SEC,
+ path: '/',
+ }
+}
+
+/** 从请求中读取 admin cookie 并校验,未通过时返回 null */
+export function getAdminTokenFromRequest(request: Request): string | null {
+ const cookieHeader = request.headers.get('cookie')
+ if (!cookieHeader) return null
+ const name = COOKIE_NAME + '='
+ const start = cookieHeader.indexOf(name)
+ if (start === -1) return null
+ const valueStart = start + name.length
+ const end = cookieHeader.indexOf(';', valueStart)
+ const value = end === -1 ? cookieHeader.slice(valueStart) : cookieHeader.slice(valueStart, end)
+ return value.trim() || null
+}
+
+/** 若未登录则返回 401 Response,供各 admin API 使用 */
+export function requireAdminResponse(request: Request): Response | null {
+ const token = getAdminTokenFromRequest(request)
+ if (!verifyAdminToken(token)) {
+ return new Response(JSON.stringify({ error: '未授权访问,请先登录' }), {
+ status: 401,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ }
+ return null
+}
diff --git a/lib/db.ts b/lib/db.ts
index ffb0b6f9..fb50a273 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -1,31 +1,47 @@
/**
* 数据库连接配置
* 使用MySQL数据库存储用户、订单、推广关系等数据
+ * 优先从环境变量读取,便于本地/部署分离;未设置时使用默认值
*/
import mysql from 'mysql2/promise'
-// 腾讯云外网数据库配置
const DB_CONFIG = {
- host: '56b4c23f6853c.gz.cdb.myqcloud.com',
- port: 14413,
- user: 'cdb_outerroot',
- password: 'Zhiqun1984',
- database: 'soul_miniprogram',
+ host: process.env.MYSQL_HOST || '56b4c23f6853c.gz.cdb.myqcloud.com',
+ port: Number(process.env.MYSQL_PORT || '14413'),
+ user: process.env.MYSQL_USER || 'cdb_outerroot',
+ password: process.env.MYSQL_PASSWORD || 'Zhiqun1984',
+ database: process.env.MYSQL_DATABASE || 'soul_miniprogram',
charset: 'utf8mb4',
timezone: '+08:00',
- acquireTimeout: 60000,
- timeout: 60000,
+ connectTimeout: 10000, // 10 秒,连接不可达时快速失败,避免长时间挂起
+ acquireTimeout: 15000,
reconnect: true
}
+// 本地无数据库时可通过 SKIP_DB=1 跳过连接,接口将使用默认配置
+const SKIP_DB = process.env.SKIP_DB === '1' || process.env.SKIP_DB === 'true'
+
// 连接池
let pool: mysql.Pool | null = null
+// 连接类错误只打一次日志,避免刷屏
+let connectionErrorLogged = false
+
+function isConnectionError(err: unknown): boolean {
+ const code = (err as NodeJS.ErrnoException)?.code
+ return (
+ code === 'ETIMEDOUT' ||
+ code === 'ECONNREFUSED' ||
+ code === 'PROTOCOL_CONNECTION_LOST' ||
+ code === 'ENOTFOUND'
+ )
+}
/**
- * 获取数据库连接池
+ * 获取数据库连接池(SKIP_DB 时不创建)
*/
-export function getPool() {
+export function getPool(): mysql.Pool | null {
+ if (SKIP_DB) return null
if (!pool) {
pool = mysql.createPool({
...DB_CONFIG,
@@ -41,12 +57,25 @@ export function getPool() {
* 执行SQL查询
*/
export async function query(sql: string, params?: any[]) {
+ const connection = getPool()
+ if (!connection) {
+ throw new Error('数据库未配置或已跳过 (SKIP_DB)')
+ }
try {
- const connection = getPool()
const [results] = await connection.execute(sql, params)
return results
} catch (error) {
- console.error('数据库查询错误:', error)
+ if (isConnectionError(error)) {
+ if (!connectionErrorLogged) {
+ connectionErrorLogged = true
+ console.warn(
+ '[DB] 数据库连接不可用,将使用本地默认配置。',
+ (error as Error).message
+ )
+ }
+ } else {
+ console.error('数据库查询错误:', error)
+ }
throw error
}
}
@@ -90,27 +119,25 @@ export async function initDatabase() {
`)
// 尝试添加可能缺失的字段(用于升级已有数据库)
- try {
- await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS session_key VARCHAR(100)')
- } catch (e) { /* 忽略 */ }
- try {
- await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password VARCHAR(100)')
- } catch (e) { /* 忽略 */ }
- try {
- await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS referred_by VARCHAR(50)')
- } catch (e) { /* 忽略 */ }
- try {
- await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE')
- } catch (e) { /* 忽略 */ }
- try {
- await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS match_count_today INT DEFAULT 0')
- } catch (e) { /* 忽略 */ }
- try {
- await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_match_date DATE')
- } catch (e) { /* 忽略 */ }
- try {
- await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS withdrawn_earnings DECIMAL(10,2) DEFAULT 0')
- } catch (e) { /* 忽略 */ }
+ // 兼容 MySQL 5.7:IF NOT EXISTS 在 5.7 不支持,先检查列是否存在
+ const addColumnIfMissing = async (colName: string, colDef: string) => {
+ try {
+ const rows = await query(
+ "SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = ?",
+ [colName]
+ ) as any[]
+ if (!rows?.length) {
+ await query(`ALTER TABLE users ADD COLUMN ${colName} ${colDef}`)
+ }
+ } catch (e) { /* 忽略 */ }
+ }
+ await addColumnIfMissing('session_key', 'VARCHAR(100)')
+ await addColumnIfMissing('password', 'VARCHAR(100)')
+ await addColumnIfMissing('referred_by', 'VARCHAR(50)')
+ await addColumnIfMissing('is_admin', 'BOOLEAN DEFAULT FALSE')
+ await addColumnIfMissing('match_count_today', 'INT DEFAULT 0')
+ await addColumnIfMissing('last_match_date', 'DATE')
+ await addColumnIfMissing('withdrawn_earnings', 'DECIMAL(10,2) DEFAULT 0')
console.log('用户表初始化完成')
@@ -125,9 +152,11 @@ export async function initDatabase() {
product_id VARCHAR(50),
amount DECIMAL(10,2) NOT NULL,
description VARCHAR(200),
- status ENUM('pending', 'paid', 'cancelled', 'refunded') DEFAULT 'pending',
+ status ENUM('created', 'pending', 'paid', 'cancelled', 'refunded', 'expired') DEFAULT 'created',
transaction_id VARCHAR(100),
pay_time TIMESTAMP NULL,
+ referrer_id VARCHAR(50) NULL COMMENT '推荐人用户ID,用于分销归属',
+ referral_code VARCHAR(20) NULL COMMENT '下单时使用的邀请码,便于对账与展示',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
@@ -297,6 +326,7 @@ async function initDefaultConfig() {
/**
* 获取系统配置
+ * 连接不可达时返回 null,由上层使用本地默认配置,不重复打日志
*/
export async function getConfig(key: string) {
try {
@@ -310,7 +340,9 @@ export async function getConfig(key: string) {
}
return null
} catch (error) {
- console.error('获取配置失败:', error)
+ if (!isConnectionError(error)) {
+ console.error('获取配置失败:', error)
+ }
return null
}
}
diff --git a/lib/documentation/catalog.ts b/lib/documentation/catalog.ts
index 663d592c..2abf480c 100644
--- a/lib/documentation/catalog.ts
+++ b/lib/documentation/catalog.ts
@@ -49,7 +49,7 @@ function pickRepresentativeReadIds(): { id: string; title: string; group: string
export function getDocumentationCatalog(): DocumentationPage[] {
const pages: DocumentationPage[] = [
{
- path: "/",
+ path: "/view",
title: "首页",
subtitle: "应用主入口",
caption:
@@ -58,7 +58,7 @@ export function getDocumentationCatalog(): DocumentationPage[] {
order: 1,
},
{
- path: "/chapters",
+ path: "/view/chapters",
title: "目录页",
subtitle: "章节浏览与导航",
caption:
@@ -67,7 +67,7 @@ export function getDocumentationCatalog(): DocumentationPage[] {
order: 2,
},
{
- path: "/about",
+ path: "/view/about",
title: "关于页面",
subtitle: "作者与产品介绍",
caption: "关于页面展示作者信息、产品理念、运营数据等,帮助用户建立对内容的信任和理解。",
@@ -75,7 +75,7 @@ export function getDocumentationCatalog(): DocumentationPage[] {
order: 3,
},
{
- path: "/my",
+ path: "/view/my",
title: "个人中心",
subtitle: "用户账户入口",
caption: "个人中心聚合用户的账户信息、购买记录、分销收益等功能入口,是用户管理个人信息的核心页面。",
@@ -83,7 +83,7 @@ export function getDocumentationCatalog(): DocumentationPage[] {
order: 4,
},
{
- path: "/my/purchases",
+ path: "/view/my/purchases",
title: "我的购买",
subtitle: "已购内容管理",
caption: "展示用户已购买的所有章节,包括购买时间、解锁进度,用户可快速跳转到已购内容继续阅读。",
@@ -91,7 +91,7 @@ export function getDocumentationCatalog(): DocumentationPage[] {
order: 5,
},
{
- path: "/my/settings",
+ path: "/view/my/settings",
title: "账户设置",
subtitle: "个人信息配置",
caption: "用户可在此页面管理个人基础信息、通知偏好、隐私设置等账户相关配置。",
@@ -99,7 +99,7 @@ export function getDocumentationCatalog(): DocumentationPage[] {
order: 6,
},
{
- path: "/my/referral",
+ path: "/view/my/referral",
title: "分销中心",
subtitle: "邀请与收益管理",
caption: "分销中心展示用户的专属邀请链接、邀请人数统计、收益明细,支持一键分享到朋友圈或Soul派对。",
@@ -123,7 +123,7 @@ export function getDocumentationCatalog(): DocumentationPage[] {
order: 9,
},
{
- path: "/docs",
+ path: "/view/docs",
title: "开发文档",
subtitle: "技术与配置说明",
caption: "面向开发者和运营人员的技术文档,包含支付接口配置说明、分销规则详解、提现流程等内容。",
@@ -136,7 +136,7 @@ export function getDocumentationCatalog(): DocumentationPage[] {
for (let i = 0; i < readPicks.length; i++) {
const pick = readPicks[i]
pages.push({
- path: `/read/${encodeURIComponent(pick.id)}`,
+ path: `/view/read/${encodeURIComponent(pick.id)}`,
title: pick.title,
subtitle: "章节阅读",
caption: "阅读页面展示章节的完整内容,未购买用户可预览部分内容,付费墙引导购买解锁全文。",
diff --git a/lib/documentation/screenshot.ts b/lib/documentation/screenshot.ts
index cadb09a2..5cdfe5c6 100644
--- a/lib/documentation/screenshot.ts
+++ b/lib/documentation/screenshot.ts
@@ -33,7 +33,7 @@ export async function captureScreenshots(
for (const pageInfo of pages) {
const page = await browser.newPage({ viewport: options.viewport })
try {
- const captureUrl = new URL("/documentation/capture", options.baseUrl)
+ const captureUrl = new URL("/view/documentation/capture", options.baseUrl)
captureUrl.searchParams.set("path", pageInfo.path)
console.log(`[Karuo] Capturing: ${pageInfo.path}`)
diff --git a/lib/password.ts b/lib/password.ts
new file mode 100644
index 00000000..1c40ea4e
--- /dev/null
+++ b/lib/password.ts
@@ -0,0 +1,56 @@
+/**
+ * 密码哈希与校验(仅用于 Web 用户注册/登录,与后台管理员密码无关)
+ * 使用 Node crypto.scrypt,存储格式 saltHex:hashHex,兼容旧明文密码
+ */
+
+import { scryptSync, timingSafeEqual, randomFillSync } from 'crypto'
+
+const SALT_LEN = 16
+const KEYLEN = 32
+
+function bufferToHex(buf: Buffer): string {
+ return buf.toString('hex')
+}
+
+function hexToBuffer(hex: string): Buffer {
+ return Buffer.from(hex, 'hex')
+}
+
+/**
+ * 对明文密码做哈希,存入数据库
+ * 格式: saltHex:hashHex(约 97 字符,适配 VARCHAR(100))
+ * 与 verifyPassword 一致:内部先 trim,保证注册/登录/重置用同一套规则
+ */
+export function hashPassword(plain: string): string {
+ const trimmed = String(plain).trim()
+ const salt = Buffer.allocUnsafe(SALT_LEN)
+ randomFillSync(salt)
+ const hash = scryptSync(trimmed, salt, KEYLEN, { N: 16384, r: 8, p: 1 })
+ return bufferToHex(salt) + ':' + bufferToHex(hash)
+}
+
+/**
+ * 校验密码:支持新格式(salt:hash)与旧明文(兼容历史数据)
+ * 与 hashPassword 一致:对输入先 trim 再参与校验
+ */
+export function verifyPassword(plain: string, stored: string | null | undefined): boolean {
+ const trimmed = String(plain).trim()
+ if (stored == null || stored === '') {
+ return trimmed === ''
+ }
+ if (stored.includes(':')) {
+ const [saltHex, hashHex] = stored.split(':')
+ if (!saltHex || !hashHex || saltHex.length !== SALT_LEN * 2 || hashHex.length !== KEYLEN * 2) {
+ return false
+ }
+ try {
+ const salt = hexToBuffer(saltHex)
+ const expected = hexToBuffer(hashHex)
+ const derived = scryptSync(trimmed, salt, KEYLEN, { N: 16384, r: 8, p: 1 })
+ return derived.length === expected.length && timingSafeEqual(derived, expected)
+ } catch {
+ return false
+ }
+ }
+ return trimmed === stored
+}
diff --git a/lib/store.ts b/lib/store.ts
index 53e10685..d973cd61 100644
--- a/lib/store.ts
+++ b/lib/store.ts
@@ -172,9 +172,9 @@ interface StoreState {
withdrawals: Withdrawal[]
settings: Settings
- login: (phone: string, code: string) => Promise
+ login: (phone: string, password: string) => Promise
logout: () => void
- register: (phone: string, nickname: string, referralCode?: string) => Promise
+ register: (phone: string, nickname: string, password?: string, referralCode?: string) => Promise
purchaseSection: (sectionId: string, sectionTitle?: string, paymentMethod?: string) => Promise
purchaseFullBook: (paymentMethod?: string) => Promise
hasPurchased: (sectionId: string) => boolean
@@ -193,6 +193,8 @@ interface StoreState {
getLiveQRCodeUrl: (qrId: string) => string | null
exportData: () => string
fetchSettings: () => Promise
+ /** 从服务端刷新当前用户资料(含可提现金额等),进入「我的」页时调用 */
+ refreshUserProfile: () => Promise
}
const initialSettings: Settings = {
@@ -302,59 +304,63 @@ export const useStore = create()(
withdrawals: [],
settings: initialSettings,
- login: async (phone: string, code: string) => {
- // 真实场景下应该调用后端API验证验证码
- // 这里暂时保留简单验证用于演示
- if (code !== "123456") {
+ login: async (phone: string, password: string) => {
+ try {
+ const res = await fetch("/api/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ phone: phone.trim(), password: password.trim() }),
+ })
+ const data = await res.json()
+ if (data.success && data.user) {
+ set({ user: data.user, isLoggedIn: true })
+ return true
+ }
+ return false
+ } catch {
return false
}
- const users = JSON.parse(localStorage.getItem("users") || "[]") as User[]
- const existingUser = users.find((u) => u.phone === phone)
- if (existingUser) {
- set({ user: existingUser, isLoggedIn: true })
- return true
- }
- return false
},
logout: () => {
set({ user: null, isLoggedIn: false })
},
- register: async (phone: string, nickname: string, referralCode?: string) => {
- const users = JSON.parse(localStorage.getItem("users") || "[]") as User[]
- if (users.find((u) => u.phone === phone)) {
+ register: async (phone: string, nickname: string, password?: string, referralCode?: string) => {
+ try {
+ const res = await fetch("/api/db/users", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ phone: phone.trim(),
+ nickname: nickname.trim(),
+ password: password ? String(password).trim() : null,
+ referredBy: referralCode?.trim() || null,
+ }),
+ })
+ const data = await res.json()
+ if (!data.success || !data.user) return false
+ const r = data.user as any
+ const newUser: User = {
+ id: r.id,
+ phone: r.phone || phone,
+ nickname: r.nickname || nickname,
+ isAdmin: !!r.is_admin,
+ purchasedSections: Array.isArray(r.purchased_sections) ? r.purchased_sections : (r.purchased_sections ? JSON.parse(String(r.purchased_sections)) : []) || [],
+ hasFullBook: !!r.has_full_book,
+ referralCode: r.referral_code || "",
+ referredBy: r.referred_by || referralCode,
+ earnings: parseFloat(String(r.earnings || 0)),
+ pendingEarnings: parseFloat(String(r.pending_earnings || 0)),
+ withdrawnEarnings: parseFloat(String(r.withdrawn_earnings || 0)),
+ referralCount: Number(r.referral_count) || 0,
+ createdAt: r.created_at || new Date().toISOString(),
+ }
+ set({ user: newUser, isLoggedIn: true })
+ return true
+ } catch {
return false
}
-
- const newUser: User = {
- id: `user_${Date.now()}`,
- phone,
- nickname,
- isAdmin: false,
- purchasedSections: [],
- hasFullBook: false,
- referralCode: `REF${Date.now().toString(36).toUpperCase()}`,
- referredBy: referralCode,
- earnings: 0,
- pendingEarnings: 0,
- withdrawnEarnings: 0,
- referralCount: 0,
- createdAt: new Date().toISOString(),
- }
-
- if (referralCode) {
- const referrer = users.find((u) => u.referralCode === referralCode)
- if (referrer) {
- referrer.referralCount = (referrer.referralCount || 0) + 1
- localStorage.setItem("users", JSON.stringify(users))
- }
- }
-
- users.push(newUser)
- localStorage.setItem("users", JSON.stringify(users))
- set({ user: newUser, isLoggedIn: true })
- return true
},
purchaseSection: async (sectionId: string, sectionTitle?: string, paymentMethod?: string) => {
@@ -538,6 +544,31 @@ export const useStore = create()(
}
},
+ refreshUserProfile: async () => {
+ const { user } = get()
+ if (!user?.id) return
+ try {
+ const res = await fetch(`/api/user/profile?userId=${encodeURIComponent(user.id)}`)
+ const json = await res.json()
+ if (!json.success || !json.data) return
+ const d = json.data
+ set((state) =>
+ state.user
+ ? {
+ user: {
+ ...state.user,
+ earnings: typeof d.earnings === 'number' ? d.earnings : parseFloat(d.earnings) || state.user.earnings,
+ pendingEarnings: typeof d.pendingEarnings === 'number' ? d.pendingEarnings : parseFloat(d.pendingEarnings) || state.user.pendingEarnings,
+ referralCount: typeof d.referralCount === 'number' ? d.referralCount : Number(d.referralCount) || state.user.referralCount,
+ },
+ }
+ : state
+ )
+ } catch (e) {
+ console.warn('[Store] refreshUserProfile failed', e)
+ }
+ },
+
deleteUser: (userId: string) => {
if (typeof window === "undefined") return
const users = JSON.parse(localStorage.getItem("users") || "[]") as User[]
diff --git a/lib/wechat-transfer.ts b/lib/wechat-transfer.ts
new file mode 100644
index 00000000..b8f6eff9
--- /dev/null
+++ b/lib/wechat-transfer.ts
@@ -0,0 +1,212 @@
+/**
+ * 微信支付 V3 - 商家转账到零钱
+ * 文档: 开发文档/提现功能完整技术文档.md
+ */
+
+import crypto from 'crypto'
+import fs from 'fs'
+import path from 'path'
+
+const BASE_URL = 'https://api.mch.weixin.qq.com'
+
+export interface WechatTransferConfig {
+ mchId: string
+ appId: string
+ apiV3Key: string
+ privateKeyPath?: string
+ privateKeyContent?: string
+ certSerialNo: string
+}
+
+function getConfig(): WechatTransferConfig {
+ const mchId = process.env.WECHAT_MCH_ID || process.env.WECHAT_MCHID || ''
+ const appId = process.env.WECHAT_APP_ID || process.env.WECHAT_APPID || 'wxb8bbb2b10dec74aa'
+ const apiV3Key = process.env.WECHAT_API_V3_KEY || process.env.WECHAT_MCH_KEY || ''
+ const keyPath = process.env.WECHAT_KEY_PATH || process.env.WECHAT_MCH_PRIVATE_KEY_PATH || ''
+ const keyContent = process.env.WECHAT_MCH_PRIVATE_KEY || ''
+ const certSerialNo = process.env.WECHAT_MCH_CERT_SERIAL_NO || ''
+ return {
+ mchId,
+ appId,
+ apiV3Key,
+ privateKeyPath: keyPath,
+ privateKeyContent: keyContent,
+ certSerialNo,
+ }
+}
+
+function getPrivateKey(): string {
+ const cfg = getConfig()
+ if (cfg.privateKeyContent) {
+ const key = cfg.privateKeyContent.replace(/\\n/g, '\n')
+ if (!key.includes('BEGIN')) {
+ return `-----BEGIN PRIVATE KEY-----\n${key}\n-----END PRIVATE KEY-----`
+ }
+ return key
+ }
+ if (cfg.privateKeyPath) {
+ const p = path.isAbsolute(cfg.privateKeyPath) ? cfg.privateKeyPath : path.join(process.cwd(), cfg.privateKeyPath)
+ return fs.readFileSync(p, 'utf8')
+ }
+ throw new Error('微信商户私钥未配置: WECHAT_MCH_PRIVATE_KEY 或 WECHAT_KEY_PATH')
+}
+
+function generateNonce(length = 32): string {
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
+ let s = ''
+ for (let i = 0; i < length; i++) {
+ s += chars.charAt(Math.floor(Math.random() * chars.length))
+ }
+ return s
+}
+
+/** 生成请求签名 */
+function buildSignature(method: string, urlPath: string, timestamp: string, nonce: string, body: string): string {
+ const message = `${method}\n${urlPath}\n${timestamp}\n${nonce}\n${body}\n`
+ const key = getPrivateKey()
+ const sign = crypto.createSign('RSA-SHA256')
+ sign.update(message)
+ return sign.sign(key, 'base64')
+}
+
+/** 构建 Authorization 头 */
+function buildAuthorization(timestamp: string, nonce: string, signature: string): string {
+ const cfg = getConfig()
+ return `WECHATPAY2-SHA256-RSA2048 mchid="${cfg.mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${cfg.certSerialNo}"`
+}
+
+export interface CreateTransferParams {
+ openid: string
+ amountFen: number
+ outDetailNo: string
+ outBatchNo?: string
+ transferRemark?: string
+}
+
+export interface CreateTransferResult {
+ success: boolean
+ outBatchNo?: string
+ batchId?: string
+ createTime?: string
+ batchStatus?: string
+ errorCode?: string
+ errorMessage?: string
+}
+
+/**
+ * 发起商家转账到零钱
+ */
+export async function createTransfer(params: CreateTransferParams): Promise {
+ const cfg = getConfig()
+ if (!cfg.mchId || !cfg.appId || !cfg.apiV3Key || !cfg.certSerialNo) {
+ return { success: false, errorCode: 'CONFIG_ERROR', errorMessage: '微信转账配置不完整' }
+ }
+
+ const urlPath = '/v3/transfer/batches'
+ const outBatchNo = params.outBatchNo || `B${Date.now()}${Math.random().toString(36).slice(2, 8)}`
+ const body = {
+ appid: cfg.appId,
+ out_batch_no: outBatchNo,
+ batch_name: '提现',
+ batch_remark: params.transferRemark || '用户提现',
+ total_amount: params.amountFen,
+ total_num: 1,
+ transfer_detail_list: [
+ {
+ out_detail_no: params.outDetailNo,
+ transfer_amount: params.amountFen,
+ transfer_remark: params.transferRemark || '提现',
+ openid: params.openid,
+ },
+ ],
+ transfer_scene_id: '1005',
+ transfer_scene_report_infos: [
+ { info_type: '岗位类型', info_content: '兼职人员' },
+ { info_type: '报酬说明', info_content: '当日兼职费' },
+ ],
+ }
+ const bodyStr = JSON.stringify(body)
+ const timestamp = Math.floor(Date.now() / 1000).toString()
+ const nonce = generateNonce()
+ const signature = buildSignature('POST', urlPath, timestamp, nonce, bodyStr)
+ const authorization = buildAuthorization(timestamp, nonce, signature)
+
+ const res = await fetch(`${BASE_URL}${urlPath}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ Authorization: authorization,
+ 'User-Agent': 'Soul-Withdraw/1.0',
+ },
+ body: bodyStr,
+ })
+ const data = (await res.json()) as Record
+ if (res.ok && res.status >= 200 && res.status < 300) {
+ return {
+ success: true,
+ outBatchNo: data.out_batch_no as string,
+ batchId: data.batch_id as string,
+ createTime: data.create_time as string,
+ batchStatus: data.batch_status as string,
+ }
+ }
+ return {
+ success: false,
+ errorCode: (data.code as string) || 'UNKNOWN',
+ errorMessage: (data.message as string) || (data.error as string) as string || '请求失败',
+ }
+}
+
+/**
+ * 解密回调 resource(AEAD_AES_256_GCM)
+ */
+export function decryptResource(
+ ciphertext: string,
+ nonce: string,
+ associatedData: string,
+ apiV3Key: string
+): Record {
+ if (apiV3Key.length !== 32) {
+ throw new Error('APIv3密钥必须为32字节')
+ }
+ const key = Buffer.from(apiV3Key, 'utf8')
+ const ct = Buffer.from(ciphertext, 'base64')
+ const authTag = ct.subarray(ct.length - 16)
+ const data = ct.subarray(0, ct.length - 16)
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(nonce, 'utf8'))
+ decipher.setAuthTag(authTag)
+ decipher.setAAD(Buffer.from(associatedData, 'utf8'))
+ const dec = decipher.update(data) as Buffer
+ const final = decipher.final() as Buffer
+ const json = Buffer.concat([dec, final]).toString('utf8')
+ return JSON.parse(json) as Record
+}
+
+/**
+ * 验证回调签名(需平台公钥,可选)
+ */
+export function verifyCallbackSignature(
+ timestamp: string,
+ nonce: string,
+ body: string,
+ signature: string,
+ publicKeyPem: string
+): boolean {
+ const message = `${timestamp}\n${nonce}\n${body}\n`
+ const sigBuf = Buffer.from(signature, 'base64')
+ const verify = crypto.createVerify('RSA-SHA256')
+ verify.update(message)
+ return verify.verify(publicKeyPem, sigBuf)
+}
+
+export interface TransferNotifyDecrypted {
+ mch_id: string
+ out_bill_no: string
+ transfer_bill_no?: string
+ transfer_amount?: number
+ state: string
+ openid?: string
+ create_time?: string
+ update_time?: string
+}
diff --git a/miniprogram/app.js b/miniprogram/app.js
index e0c3901b..8ba88621 100644
--- a/miniprogram/app.js
+++ b/miniprogram/app.js
@@ -7,6 +7,7 @@ App({
globalData: {
// API基础地址 - 连接真实后端
baseUrl: 'https://soul.quwanzhi.com',
+ // baseUrl: 'http://localhost:3006',
// 小程序配置 - 真实AppID
appId: 'wxb8bbb2b10dec74aa',
@@ -27,6 +28,9 @@ App({
purchasedSections: [],
hasFullBook: false,
+ // 已读章节(仅统计有权限打开过的章节,用于首页「已读/待读」)
+ readSectionIds: [],
+
// 推荐绑定
pendingReferralCode: null, // 待绑定的推荐码
@@ -49,6 +53,7 @@ App({
},
onLaunch(options) {
+ this.globalData.readSectionIds = wx.getStorageSync('readSectionIds') || []
// 获取系统信息
this.getSystemInfo()
@@ -80,21 +85,16 @@ App({
// 立即记录访问(不需要登录,用于统计"通过链接进的人数")
this.recordReferralVisit(refCode)
+
+ // 保存待绑定的推荐码(不再在前端做"只能绑定一次"的限制,让后端根据30天规则判断续期/抢夺)
+ this.globalData.pendingReferralCode = refCode
+ wx.setStorageSync('pendingReferralCode', refCode)
+ // 同步写入 referral_code,供章节/找伙伴支付时传给后端,订单会记录 referrer_id 与 referral_code
+ wx.setStorageSync('referral_code', refCode)
- // 检查是否已经绑定过
- const boundRef = wx.getStorageSync('boundReferralCode')
- if (boundRef && boundRef !== refCode) {
- console.log('[App] 已绑定过其他推荐码,不更换绑定关系')
- // 但仍然记录访问,不return
- } else {
- // 保存待绑定的推荐码
- this.globalData.pendingReferralCode = refCode
- wx.setStorageSync('pendingReferralCode', refCode)
-
- // 如果已登录,立即绑定
- if (this.globalData.isLoggedIn && this.globalData.userInfo) {
- this.bindReferralCode(refCode)
- }
+ // 如果已登录,立即尝试绑定,由 /api/referral/bind 按 30 天规则决定 new / renew / takeover
+ if (this.globalData.isLoggedIn && this.globalData.userInfo) {
+ this.bindReferralCode(refCode)
}
}
},
@@ -129,13 +129,6 @@ App({
const userId = this.globalData.userInfo?.id
if (!userId || !refCode) return
- // 检查是否已绑定
- const boundRef = wx.getStorageSync('boundReferralCode')
- if (boundRef) {
- console.log('[App] 已绑定推荐码,跳过')
- return
- }
-
console.log('[App] 绑定推荐码:', refCode, '到用户:', userId)
// 调用API绑定推荐关系
@@ -149,6 +142,7 @@ App({
if (res.success) {
console.log('[App] 推荐码绑定成功')
+ // 仅记录当前已绑定的推荐码,用于展示/调试;是否允许更换由后端根据30天规则判断
wx.setStorageSync('boundReferralCode', refCode)
this.globalData.pendingReferralCode = null
wx.removeStorageSync('pendingReferralCode')
@@ -362,6 +356,20 @@ App({
if (res.success && res.data?.openId) {
this.globalData.openId = res.data.openId
wx.setStorageSync('openId', res.data.openId)
+ // 接口同时返回 user 时视为登录,补全登录态并从登录开始绑定推荐码
+ if (res.data.user) {
+ this.globalData.userInfo = res.data.user
+ this.globalData.isLoggedIn = true
+ this.globalData.purchasedSections = res.data.user.purchasedSections || []
+ this.globalData.hasFullBook = res.data.user.hasFullBook || false
+ wx.setStorageSync('userInfo', res.data.user)
+ wx.setStorageSync('token', res.data.token || '')
+ const pendingRef = wx.getStorageSync('pendingReferralCode') || this.globalData.pendingReferralCode
+ if (pendingRef) {
+ console.log('[App] getOpenId 登录后自动绑定推荐码:', pendingRef)
+ this.bindReferralCode(pendingRef)
+ }
+ }
return res.data.openId
}
} catch (e) {
@@ -430,6 +438,21 @@ App({
return this.globalData.purchasedSections.includes(sectionId)
},
+ // 标记章节为已读(仅在有权限打开时由阅读页调用,用于首页已读/待读统计)
+ markSectionAsRead(sectionId) {
+ if (!sectionId) return
+ const list = this.globalData.readSectionIds || []
+ if (list.includes(sectionId)) return
+ list.push(sectionId)
+ this.globalData.readSectionIds = list
+ wx.setStorageSync('readSectionIds', list)
+ },
+
+ // 已读章节数(用于首页展示)
+ getReadCount() {
+ return (this.globalData.readSectionIds || []).length
+ },
+
// 获取章节总数
getTotalSections() {
return this.globalData.totalSections
diff --git a/miniprogram/app.json b/miniprogram/app.json
index fe03e7b3..392721cb 100644
--- a/miniprogram/app.json
+++ b/miniprogram/app.json
@@ -9,7 +9,9 @@
"pages/referral/referral",
"pages/purchases/purchases",
"pages/settings/settings",
- "pages/search/search"
+ "pages/search/search",
+ "pages/addresses/addresses",
+ "pages/addresses/edit"
],
"window": {
"backgroundTextStyle": "light",
diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss
index 4a79d19f..9ce22a06 100644
--- a/miniprogram/app.wxss
+++ b/miniprogram/app.wxss
@@ -4,10 +4,48 @@
* 开发: 卡若
*/
+/* ===== CSS 变量系统 ===== */
+page {
+ /* 品牌色 */
+ --app-brand: #00CED1;
+ --app-brand-light: rgba(0, 206, 209, 0.1);
+ --app-brand-dark: #20B2AA;
+
+ /* 背景色 */
+ --app-bg-primary: #000000;
+ --app-bg-secondary: #1c1c1e;
+ --app-bg-tertiary: #2c2c2e;
+
+ /* 文字色 */
+ --app-text-primary: #ffffff;
+ --app-text-secondary: rgba(255, 255, 255, 0.7);
+ --app-text-tertiary: rgba(255, 255, 255, 0.4);
+
+ /* 分隔线 */
+ --app-separator: rgba(255, 255, 255, 0.05);
+
+ /* iOS 系统色 */
+ --ios-indigo: #5856D6;
+ --ios-green: #30d158;
+ --ios-red: #FF3B30;
+ --ios-orange: #FF9500;
+ --ios-yellow: #FFD700;
+
+ /* 金色 */
+ --gold: #FFD700;
+ --gold-light: #FFA500;
+
+ /* 粉色 */
+ --pink: #E91E63;
+
+ /* 紫色 */
+ --purple: #7B61FF;
+}
+
/* ===== 页面基础样式 ===== */
page {
- background-color: #000000;
- color: #ffffff;
+ background-color: var(--app-bg-primary);
+ color: var(--app-text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 28rpx;
line-height: 1.5;
diff --git a/miniprogram/assets/icons/alert-circle.svg b/miniprogram/assets/icons/alert-circle.svg
new file mode 100644
index 00000000..f5a441f3
--- /dev/null
+++ b/miniprogram/assets/icons/alert-circle.svg
@@ -0,0 +1,5 @@
+
diff --git a/miniprogram/assets/icons/arrow-right.svg b/miniprogram/assets/icons/arrow-right.svg
new file mode 100644
index 00000000..1dc64d3f
--- /dev/null
+++ b/miniprogram/assets/icons/arrow-right.svg
@@ -0,0 +1,4 @@
+
diff --git a/miniprogram/assets/icons/bell.svg b/miniprogram/assets/icons/bell.svg
new file mode 100644
index 00000000..0e7e405b
--- /dev/null
+++ b/miniprogram/assets/icons/bell.svg
@@ -0,0 +1,4 @@
+
diff --git a/miniprogram/assets/icons/book-open.svg b/miniprogram/assets/icons/book-open.svg
new file mode 100644
index 00000000..d833e86b
--- /dev/null
+++ b/miniprogram/assets/icons/book-open.svg
@@ -0,0 +1,4 @@
+
diff --git a/miniprogram/assets/icons/book.svg b/miniprogram/assets/icons/book.svg
new file mode 100644
index 00000000..93579576
--- /dev/null
+++ b/miniprogram/assets/icons/book.svg
@@ -0,0 +1,4 @@
+
diff --git a/miniprogram/assets/icons/chevron-left.svg b/miniprogram/assets/icons/chevron-left.svg
new file mode 100644
index 00000000..e406b2b9
--- /dev/null
+++ b/miniprogram/assets/icons/chevron-left.svg
@@ -0,0 +1,3 @@
+
diff --git a/miniprogram/assets/icons/gift.svg b/miniprogram/assets/icons/gift.svg
new file mode 100644
index 00000000..66ac806c
--- /dev/null
+++ b/miniprogram/assets/icons/gift.svg
@@ -0,0 +1,6 @@
+
diff --git a/miniprogram/assets/icons/home.svg b/miniprogram/assets/icons/home.svg
new file mode 100644
index 00000000..76244091
--- /dev/null
+++ b/miniprogram/assets/icons/home.svg
@@ -0,0 +1,4 @@
+
diff --git a/miniprogram/assets/icons/image.svg b/miniprogram/assets/icons/image.svg
new file mode 100644
index 00000000..50ed9e6d
--- /dev/null
+++ b/miniprogram/assets/icons/image.svg
@@ -0,0 +1,5 @@
+
diff --git a/miniprogram/assets/icons/list.svg b/miniprogram/assets/icons/list.svg
new file mode 100644
index 00000000..688326aa
--- /dev/null
+++ b/miniprogram/assets/icons/list.svg
@@ -0,0 +1,8 @@
+
diff --git a/miniprogram/assets/icons/message-circle.svg b/miniprogram/assets/icons/message-circle.svg
new file mode 100644
index 00000000..037560e9
--- /dev/null
+++ b/miniprogram/assets/icons/message-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/miniprogram/assets/icons/partners.svg b/miniprogram/assets/icons/partners.svg
new file mode 100644
index 00000000..80668312
--- /dev/null
+++ b/miniprogram/assets/icons/partners.svg
@@ -0,0 +1,18 @@
+
diff --git a/miniprogram/assets/icons/settings.svg b/miniprogram/assets/icons/settings.svg
new file mode 100644
index 00000000..c7006ea8
--- /dev/null
+++ b/miniprogram/assets/icons/settings.svg
@@ -0,0 +1,4 @@
+
diff --git a/miniprogram/assets/icons/share.svg b/miniprogram/assets/icons/share.svg
new file mode 100644
index 00000000..93179fc2
--- /dev/null
+++ b/miniprogram/assets/icons/share.svg
@@ -0,0 +1,7 @@
+
diff --git a/miniprogram/assets/icons/sparkles.svg b/miniprogram/assets/icons/sparkles.svg
new file mode 100644
index 00000000..e2a4461f
--- /dev/null
+++ b/miniprogram/assets/icons/sparkles.svg
@@ -0,0 +1,6 @@
+
diff --git a/miniprogram/assets/icons/user.svg b/miniprogram/assets/icons/user.svg
new file mode 100644
index 00000000..8b190427
--- /dev/null
+++ b/miniprogram/assets/icons/user.svg
@@ -0,0 +1,4 @@
+
diff --git a/miniprogram/assets/icons/users.svg b/miniprogram/assets/icons/users.svg
new file mode 100644
index 00000000..4816094b
--- /dev/null
+++ b/miniprogram/assets/icons/users.svg
@@ -0,0 +1,6 @@
+
diff --git a/miniprogram/assets/icons/wallet.svg b/miniprogram/assets/icons/wallet.svg
new file mode 100644
index 00000000..6d431e54
--- /dev/null
+++ b/miniprogram/assets/icons/wallet.svg
@@ -0,0 +1,4 @@
+
diff --git a/miniprogram/components/icon/README.md b/miniprogram/components/icon/README.md
new file mode 100644
index 00000000..34e394c8
--- /dev/null
+++ b/miniprogram/components/icon/README.md
@@ -0,0 +1,175 @@
+# Icon 图标组件
+
+SVG 图标组件,参考 lucide-react 实现,用于在小程序中使用矢量图标。
+
+**技术实现**: 使用 Base64 编码的 SVG + image 组件(小程序不支持直接使用 SVG 标签)
+
+---
+
+## 使用方法
+
+### 1. 在页面 JSON 中引入组件
+
+```json
+{
+ "usingComponents": {
+ "icon": "/components/icon/icon"
+ }
+}
+```
+
+### 2. 在 WXML 中使用
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## 属性说明
+
+| 属性 | 类型 | 默认值 | 说明 |
+|-----|------|--------|-----|
+| name | String | 'share' | 图标名称 |
+| size | Number | 48 | 图标大小(rpx) |
+| color | String | 'currentColor' | 图标颜色 |
+| customClass | String | '' | 自定义类名 |
+| customStyle | String | '' | 自定义样式 |
+
+---
+
+## 可用图标
+
+| 图标名称 | 说明 | 对应 lucide-react |
+|---------|------|-------------------|
+| `share` | 分享 | `` |
+| `arrow-up-right` | 右上箭头 | `` |
+| `chevron-left` | 左箭头 | `` |
+| `search` | 搜索 | `` |
+| `heart` | 心形 | `` |
+
+---
+
+## 添加新图标
+
+在 `icon.js` 的 `getSvgPath` 方法中添加新图标:
+
+```javascript
+getSvgPath(name) {
+ const svgMap = {
+ 'new-icon': '',
+ // ... 其他图标
+ }
+ return svgMap[name] || ''
+}
+```
+
+**获取 SVG 代码**: 访问 [lucide.dev](https://lucide.dev) 搜索图标,复制 SVG 内容。
+**注意**: 颜色使用 `COLOR` 占位符,组件会自动替换。
+
+---
+
+## 样式定制
+
+### 1. 使用 customClass
+
+```xml
+
+```
+
+```css
+.my-icon-class {
+ opacity: 0.8;
+}
+```
+
+### 2. 使用 customStyle
+
+```xml
+
+```
+
+---
+
+## 技术说明
+
+### 为什么使用 Base64 + image?
+
+1. **矢量图标**:任意缩放不失真
+2. **灵活着色**:通过 `COLOR` 占位符动态改变颜色
+3. **轻量级**:无需加载字体文件或外部图片
+4. **兼容性**:小程序不支持直接使用 SVG 标签,image 组件支持 Base64 SVG
+
+### 为什么不用字体图标?
+
+小程序对字体文件有限制,Base64 编码字体文件会增加包体积,SVG 图标更轻量。
+
+### 与 lucide-react 的对应关系
+
+- **lucide-react**: React 组件库,使用 SVG
+- **本组件**: 小程序自定义组件,也使用 SVG
+- **SVG path 数据**: 完全相同,从 lucide 官网复制
+
+---
+
+## 示例
+
+### 悬浮分享按钮
+
+```xml
+
+
+
+```
+
+```css
+.fab-share {
+ position: fixed;
+ right: 32rpx;
+ bottom: calc(120rpx + env(safe-area-inset-bottom));
+ width: 96rpx;
+ height: 96rpx;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+```
+
+---
+
+## 扩展图标库
+
+可以继续添加更多 lucide-react 图标:
+
+- `star` - 星星
+- `wallet` - 钱包
+- `gift` - 礼物
+- `info` - 信息
+- `settings` - 设置
+- `user` - 用户
+- `book-open` - 打开的书
+- `eye` - 眼睛
+- `clock` - 时钟
+- `users` - 用户组
+
+---
+
+**图标组件创建完成!** 🎉
diff --git a/miniprogram/components/icon/icon.js b/miniprogram/components/icon/icon.js
new file mode 100644
index 00000000..b2dec23f
--- /dev/null
+++ b/miniprogram/components/icon/icon.js
@@ -0,0 +1,83 @@
+// components/icon/icon.js
+Component({
+ properties: {
+ // 图标名称
+ name: {
+ type: String,
+ value: 'share',
+ observer: 'updateIcon'
+ },
+ // 图标大小(rpx)
+ size: {
+ type: Number,
+ value: 48
+ },
+ // 图标颜色
+ color: {
+ type: String,
+ value: '#ffffff',
+ observer: 'updateIcon'
+ },
+ // 自定义类名
+ customClass: {
+ type: String,
+ value: ''
+ },
+ // 自定义样式
+ customStyle: {
+ type: String,
+ value: ''
+ }
+ },
+
+ data: {
+ svgData: ''
+ },
+
+ lifetimes: {
+ attached() {
+ this.updateIcon()
+ }
+ },
+
+ methods: {
+ // SVG 图标数据映射
+ getSvgPath(name) {
+ const svgMap = {
+ 'share': '',
+
+ 'arrow-up-right': '',
+
+ 'chevron-left': '',
+
+ 'search': '',
+
+ 'heart': ''
+ }
+
+ return svgMap[name] || ''
+ },
+
+ // 更新图标
+ updateIcon() {
+ const { name, color } = this.data
+ let svgString = this.getSvgPath(name)
+
+ if (svgString) {
+ // 替换颜色占位符
+ svgString = svgString.replace(/COLOR/g, color)
+
+ // 转换为 Base64 Data URL
+ const svgData = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgString)}`
+
+ this.setData({
+ svgData: svgData
+ })
+ } else {
+ this.setData({
+ svgData: ''
+ })
+ }
+ }
+ }
+})
diff --git a/miniprogram/components/icon/icon.json b/miniprogram/components/icon/icon.json
new file mode 100644
index 00000000..a89ef4db
--- /dev/null
+++ b/miniprogram/components/icon/icon.json
@@ -0,0 +1,4 @@
+{
+ "component": true,
+ "usingComponents": {}
+}
diff --git a/miniprogram/components/icon/icon.wxml b/miniprogram/components/icon/icon.wxml
new file mode 100644
index 00000000..b1c29a25
--- /dev/null
+++ b/miniprogram/components/icon/icon.wxml
@@ -0,0 +1,5 @@
+
+
+
+ {{name}}
+
diff --git a/miniprogram/components/icon/icon.wxss b/miniprogram/components/icon/icon.wxss
new file mode 100644
index 00000000..d12d2a0a
--- /dev/null
+++ b/miniprogram/components/icon/icon.wxss
@@ -0,0 +1,18 @@
+/* components/icon/icon.wxss */
+.icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.icon-image {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.icon-text {
+ font-size: 24rpx;
+ color: currentColor;
+}
diff --git a/miniprogram/custom-tab-bar/index.js b/miniprogram/custom-tab-bar/index.js
index 98066675..2ccba866 100644
--- a/miniprogram/custom-tab-bar/index.js
+++ b/miniprogram/custom-tab-bar/index.js
@@ -1,13 +1,19 @@
/**
* Soul创业实验 - 自定义TabBar组件
- * 实现中间突出的"找伙伴"按钮
+ * 根据后台配置动态显示/隐藏"找伙伴"按钮
*/
+console.log('[TabBar] ===== 组件文件开始加载 =====')
+
+const app = getApp()
+console.log('[TabBar] App 对象:', app)
+
Component({
data: {
selected: 0,
color: '#8e8e93',
selectedColor: '#00CED1',
+ matchEnabled: false, // 找伙伴功能开关,默认关闭
list: [
{
pagePath: '/pages/index/index',
@@ -33,11 +39,107 @@ Component({
]
},
+ lifetimes: {
+ attached() {
+ console.log('[TabBar] Component attached 生命周期触发')
+ this.loadFeatureConfig()
+ },
+ ready() {
+ console.log('[TabBar] Component ready 生命周期触发')
+ // 如果 attached 中没有成功加载,在 ready 中再次尝试
+ if (this.data.matchEnabled === undefined || this.data.matchEnabled === null) {
+ console.log('[TabBar] 在 ready 中重新加载配置')
+ this.loadFeatureConfig()
+ }
+ }
+ },
+
+ // 页面加载时也调用(兼容性更好)
attached() {
- // 初始化时获取当前页面
+ console.log('[TabBar] attached() 方法触发')
+ this.loadFeatureConfig()
},
methods: {
+ // 加载功能配置
+ async loadFeatureConfig() {
+ try {
+ console.log('[TabBar] 开始加载功能配置...')
+ console.log('[TabBar] API地址:', app.globalData.baseUrl + '/api/db/config')
+
+ // app.request 的第一个参数是 url 字符串,第二个参数是 options 对象
+ const res = await app.request('/api/db/config', {
+ method: 'GET'
+ })
+
+
+ // 兼容两种返回格式
+ let matchEnabled = false
+
+ if (res && res.success && res.features) {
+ console.log('[TabBar] features配置:', JSON.stringify(res.features))
+ matchEnabled = res.features.matchEnabled === true
+ console.log('[TabBar] matchEnabled值:', matchEnabled)
+ } else if (res && res.configs && res.configs.feature_config) {
+ // 备用格式:从 configs.feature_config 读取
+ console.log('[TabBar] 使用备用格式,从configs读取')
+ matchEnabled = res.configs.feature_config.matchEnabled === true
+ console.log('[TabBar] matchEnabled值:', matchEnabled)
+ } else {
+ console.log('[TabBar] ⚠️ 未找到features配置,使用默认值false')
+ console.log('[TabBar] res对象keys:', Object.keys(res || {}))
+ }
+
+ this.setData({ matchEnabled }, () => {
+ console.log('[TabBar] ✅ matchEnabled已设置为:', this.data.matchEnabled)
+ // 配置加载完成后,根据当前路由设置选中状态
+ this.updateSelected()
+ })
+
+ // 如果当前在找伙伴页面,但功能已关闭,跳转到首页
+ if (!matchEnabled) {
+ const pages = getCurrentPages()
+ const currentPage = pages[pages.length - 1]
+ if (currentPage && currentPage.route === 'pages/match/match') {
+ console.log('[TabBar] 找伙伴功能已关闭,从match页面跳转到首页')
+ wx.switchTab({ url: '/pages/index/index' })
+ }
+ }
+ } catch (error) {
+ console.log('[TabBar] ❌ 加载功能配置失败:', error)
+ console.log('[TabBar] 错误详情:', error.message || error)
+ // 默认关闭找伙伴功能
+ this.setData({ matchEnabled: false }, () => {
+ this.updateSelected()
+ })
+ }
+ },
+
+ // 根据当前路由更新选中状态
+ updateSelected() {
+ const pages = getCurrentPages()
+ if (pages.length === 0) return
+
+ const currentPage = pages[pages.length - 1]
+ const route = currentPage.route
+
+ let selected = 0
+ const { matchEnabled } = this.data
+
+ // 根据路由匹配对应的索引
+ if (route === 'pages/index/index') {
+ selected = 0
+ } else if (route === 'pages/chapters/chapters') {
+ selected = 1
+ } else if (route === 'pages/match/match') {
+ selected = 2
+ } else if (route === 'pages/my/my') {
+ selected = matchEnabled ? 3 : 2
+ }
+
+ this.setData({ selected })
+ },
+
switchTab(e) {
const data = e.currentTarget.dataset
const url = data.path
diff --git a/miniprogram/custom-tab-bar/index.wxml b/miniprogram/custom-tab-bar/index.wxml
index 1dffbd70..73369b2a 100644
--- a/miniprogram/custom-tab-bar/index.wxml
+++ b/miniprogram/custom-tab-bar/index.wxml
@@ -1,17 +1,14 @@
-
+
-
-
-
-
-
-
-
+
{{list[0].text}}
@@ -19,38 +16,32 @@
-
-
-
-
-
-
-
+
{{list[1].text}}
-
-
+
+
-
-
-
-
+
{{list[2].text}}
-
+
-
-
-
-
-
-
+
- {{list[3].text}}
+ {{list[3].text}}
diff --git a/miniprogram/custom-tab-bar/index.wxss b/miniprogram/custom-tab-bar/index.wxss
index 84ad115f..98036655 100644
--- a/miniprogram/custom-tab-bar/index.wxss
+++ b/miniprogram/custom-tab-bar/index.wxss
@@ -18,6 +18,16 @@
z-index: 999;
}
+/* 三个tab布局(找伙伴功能关闭时) */
+.tab-bar-three .tab-bar-item {
+ flex: 1;
+}
+
+/* 四个tab布局(找伙伴功能开启时) */
+.tab-bar-four .tab-bar-item {
+ flex: 1;
+}
+
.tab-bar-border {
position: absolute;
top: 0;
@@ -58,105 +68,18 @@
line-height: 1;
}
-/* ===== 首页图标 ===== */
-.icon-home {
- position: relative;
- width: 40rpx;
- height: 40rpx;
+/* ===== SVG 图标样式 ===== */
+.tab-icon {
+ width: 48rpx;
+ height: 48rpx;
+ display: block;
+ filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%);
}
-.home-roof {
- position: absolute;
- top: 4rpx;
- left: 50%;
- transform: translateX(-50%);
- width: 0;
- height: 0;
- border-left: 18rpx solid transparent;
- border-right: 18rpx solid transparent;
- border-bottom: 14rpx solid #8e8e93;
+.tab-icon.icon-active {
+ filter: brightness(0) saturate(100%) invert(72%) sepia(54%) saturate(2933%) hue-rotate(134deg) brightness(101%) contrast(101%);
}
-.home-body {
- position: absolute;
- bottom: 4rpx;
- left: 50%;
- transform: translateX(-50%);
- width: 28rpx;
- height: 18rpx;
- background: #8e8e93;
- border-radius: 0 0 4rpx 4rpx;
-}
-
-.icon-active .home-roof {
- border-bottom-color: #00CED1;
-}
-
-.icon-active .home-body {
- background: #00CED1;
-}
-
-/* ===== 目录图标 ===== */
-.icon-list {
- width: 36rpx;
- height: 32rpx;
- display: flex;
- flex-direction: column;
- justify-content: space-between;
-}
-
-.list-line {
- width: 100%;
- height: 6rpx;
- background: #8e8e93;
- border-radius: 3rpx;
-}
-
-.list-line:nth-child(2) {
- width: 75%;
-}
-
-.list-line:nth-child(3) {
- width: 50%;
-}
-
-.icon-active .list-line {
- background: #00CED1;
-}
-
-/* ===== 我的图标 ===== */
-.icon-user {
- position: relative;
- width: 36rpx;
- height: 40rpx;
-}
-
-.user-head {
- position: absolute;
- top: 0;
- left: 50%;
- transform: translateX(-50%);
- width: 16rpx;
- height: 16rpx;
- background: #8e8e93;
- border-radius: 50%;
-}
-
-.user-body {
- position: absolute;
- bottom: 0;
- left: 50%;
- transform: translateX(-50%);
- width: 28rpx;
- height: 18rpx;
- background: #8e8e93;
- border-radius: 14rpx 14rpx 0 0;
-}
-
-.icon-active .user-head,
-.icon-active .user-body {
- background: #00CED1;
-}
/* ===== 找伙伴 - 中间特殊按钮 ===== */
.special-item {
@@ -189,39 +112,10 @@
margin-top: 4rpx;
}
-/* ===== 找伙伴图标 (双人) ===== */
-.icon-users {
- position: relative;
- width: 56rpx;
- height: 44rpx;
-}
-
-.user-circle {
- position: absolute;
- width: 28rpx;
- height: 28rpx;
- border-radius: 50%;
- background: #ffffff;
-}
-
-.user-circle::after {
- content: '';
- position: absolute;
- bottom: -12rpx;
- left: 50%;
- transform: translateX(-50%);
- width: 22rpx;
- height: 14rpx;
- background: #ffffff;
- border-radius: 11rpx 11rpx 0 0;
-}
-
-.user-1 {
- top: 0;
- left: 0;
-}
-
-.user-2 {
- top: 0;
- right: 0;
+/* ===== 找伙伴特殊按钮图标 ===== */
+.special-icon {
+ width: 80rpx;
+ height: 80rpx;
+ display: block;
+ filter: brightness(0) saturate(100%) invert(100%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(100%) contrast(100%);
}
diff --git a/miniprogram/pages/addresses/addresses.js b/miniprogram/pages/addresses/addresses.js
new file mode 100644
index 00000000..0fc71b09
--- /dev/null
+++ b/miniprogram/pages/addresses/addresses.js
@@ -0,0 +1,123 @@
+/**
+ * 收货地址列表页
+ * 参考 Next.js: app/view/my/addresses/page.tsx
+ */
+
+const app = getApp()
+
+Page({
+ data: {
+ statusBarHeight: 44,
+ isLoggedIn: false,
+ addressList: [],
+ loading: true
+ },
+
+ onLoad() {
+ this.setData({
+ statusBarHeight: app.globalData.statusBarHeight || 44
+ })
+ this.checkLogin()
+ },
+
+ onShow() {
+ if (this.data.isLoggedIn) {
+ this.loadAddresses()
+ }
+ },
+
+ // 检查登录状态
+ checkLogin() {
+ const isLoggedIn = app.globalData.isLoggedIn
+ const userId = app.globalData.userInfo?.id
+
+ if (!isLoggedIn || !userId) {
+ wx.showModal({
+ title: '需要登录',
+ content: '请先登录后再管理收货地址',
+ confirmText: '去登录',
+ success: (res) => {
+ if (res.confirm) {
+ wx.switchTab({ url: '/pages/my/my' })
+ } else {
+ wx.navigateBack()
+ }
+ }
+ })
+ return
+ }
+
+ this.setData({ isLoggedIn: true })
+ this.loadAddresses()
+ },
+
+ // 加载地址列表
+ async loadAddresses() {
+ const userId = app.globalData.userInfo?.id
+ if (!userId) return
+
+ this.setData({ loading: true })
+
+ try {
+ const res = await app.request(`/api/user/addresses?userId=${userId}`)
+ if (res.success && res.list) {
+ this.setData({
+ addressList: res.list,
+ loading: false
+ })
+ } else {
+ this.setData({ addressList: [], loading: false })
+ }
+ } catch (e) {
+ console.error('加载地址列表失败:', e)
+ this.setData({ loading: false })
+ wx.showToast({ title: '加载失败', icon: 'none' })
+ }
+ },
+
+ // 编辑地址
+ editAddress(e) {
+ const id = e.currentTarget.dataset.id
+ wx.navigateTo({ url: `/pages/addresses/edit?id=${id}` })
+ },
+
+ // 删除地址
+ deleteAddress(e) {
+ const id = e.currentTarget.dataset.id
+
+ wx.showModal({
+ title: '确认删除',
+ content: '确定要删除该收货地址吗?',
+ confirmColor: '#FF3B30',
+ success: async (res) => {
+ if (res.confirm) {
+ try {
+ const result = await app.request(`/api/user/addresses/${id}`, {
+ method: 'DELETE'
+ })
+
+ if (result.success) {
+ wx.showToast({ title: '删除成功', icon: 'success' })
+ this.loadAddresses()
+ } else {
+ wx.showToast({ title: result.message || '删除失败', icon: 'none' })
+ }
+ } catch (e) {
+ console.error('删除地址失败:', e)
+ wx.showToast({ title: '删除失败', icon: 'none' })
+ }
+ }
+ }
+ })
+ },
+
+ // 新增地址
+ addAddress() {
+ wx.navigateTo({ url: '/pages/addresses/edit' })
+ },
+
+ // 返回
+ goBack() {
+ wx.navigateBack()
+ }
+})
diff --git a/miniprogram/pages/addresses/addresses.json b/miniprogram/pages/addresses/addresses.json
new file mode 100644
index 00000000..2e45b65e
--- /dev/null
+++ b/miniprogram/pages/addresses/addresses.json
@@ -0,0 +1,5 @@
+{
+ "usingComponents": {},
+ "navigationStyle": "custom",
+ "enablePullDownRefresh": false
+}
diff --git a/miniprogram/pages/addresses/addresses.wxml b/miniprogram/pages/addresses/addresses.wxml
new file mode 100644
index 00000000..cec2ef6e
--- /dev/null
+++ b/miniprogram/pages/addresses/addresses.wxml
@@ -0,0 +1,66 @@
+
+
+
+
+
+ ‹
+
+ 收货地址
+
+
+
+
+
+
+
+ 加载中...
+
+
+
+
+ 📍
+ 暂无收货地址
+ 点击下方按钮添加
+
+
+
+
+
+
+ {{item.fullAddress}}
+
+
+ ✏️
+ 编辑
+
+
+ 🗑️
+ 删除
+
+
+
+
+
+
+
+ ➕
+ 新增收货地址
+
+
+
diff --git a/miniprogram/pages/addresses/addresses.wxss b/miniprogram/pages/addresses/addresses.wxss
new file mode 100644
index 00000000..9ff21637
--- /dev/null
+++ b/miniprogram/pages/addresses/addresses.wxss
@@ -0,0 +1,217 @@
+/**
+ * 收货地址列表页样式
+ */
+
+.page {
+ min-height: 100vh;
+ background: #000000;
+ padding-bottom: 200rpx;
+}
+
+/* ===== 导航栏 ===== */
+.nav-bar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 100;
+ background: rgba(0, 0, 0, 0.9);
+ backdrop-filter: blur(40rpx);
+ border-bottom: 1rpx solid rgba(255, 255, 255, 0.05);
+}
+
+.nav-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 32rpx;
+ height: 88rpx;
+}
+
+.nav-back {
+ width: 64rpx;
+ height: 64rpx;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.1);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.nav-back:active {
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.back-icon {
+ font-size: 48rpx;
+ color: #ffffff;
+ line-height: 1;
+}
+
+.nav-title {
+ flex: 1;
+ text-align: center;
+ font-size: 36rpx;
+ font-weight: 600;
+ color: #ffffff;
+}
+
+.nav-placeholder {
+ width: 64rpx;
+}
+
+/* ===== 内容区 ===== */
+.content {
+ padding: 32rpx;
+}
+
+/* ===== 加载状态 ===== */
+.loading-state {
+ padding: 240rpx 0;
+ text-align: center;
+}
+
+.loading-text {
+ font-size: 28rpx;
+ color: rgba(255, 255, 255, 0.4);
+}
+
+/* ===== 空状态 ===== */
+.empty-state {
+ padding: 240rpx 0;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.empty-icon {
+ font-size: 96rpx;
+ margin-bottom: 24rpx;
+ opacity: 0.3;
+}
+
+.empty-text {
+ font-size: 28rpx;
+ color: rgba(255, 255, 255, 0.6);
+ margin-bottom: 16rpx;
+}
+
+.empty-tip {
+ font-size: 24rpx;
+ color: rgba(255, 255, 255, 0.4);
+}
+
+/* ===== 地址列表 ===== */
+.address-list {
+ margin-bottom: 24rpx;
+}
+
+.address-card {
+ background: #1c1c1e;
+ border-radius: 24rpx;
+ border: 2rpx solid rgba(255, 255, 255, 0.05);
+ padding: 32rpx;
+ margin-bottom: 24rpx;
+}
+
+/* 地址头部 */
+.address-header {
+ display: flex;
+ align-items: center;
+ gap: 16rpx;
+ margin-bottom: 16rpx;
+}
+
+.receiver-name {
+ font-size: 32rpx;
+ font-weight: 600;
+ color: #ffffff;
+}
+
+.receiver-phone {
+ font-size: 28rpx;
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.default-tag {
+ font-size: 22rpx;
+ color: #00CED1;
+ background: rgba(0, 206, 209, 0.2);
+ padding: 6rpx 16rpx;
+ border-radius: 8rpx;
+ margin-left: auto;
+}
+
+/* 地址文本 */
+.address-text {
+ font-size: 28rpx;
+ color: rgba(255, 255, 255, 0.6);
+ line-height: 1.6;
+ display: block;
+ margin-bottom: 24rpx;
+ padding-bottom: 24rpx;
+ border-bottom: 2rpx solid rgba(255, 255, 255, 0.05);
+}
+
+/* 操作按钮 */
+.address-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 32rpx;
+}
+
+.action-btn {
+ display: flex;
+ align-items: center;
+ gap: 8rpx;
+ padding: 8rpx 0;
+}
+
+.action-btn:active {
+ opacity: 0.6;
+}
+
+.edit-btn {
+ color: #00CED1;
+}
+
+.delete-btn {
+ color: #FF3B30;
+}
+
+.action-icon {
+ font-size: 28rpx;
+}
+
+.action-text {
+ font-size: 28rpx;
+}
+
+/* ===== 新增按钮 ===== */
+.add-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 16rpx;
+ padding: 32rpx;
+ background: #00CED1;
+ border-radius: 24rpx;
+ font-weight: 600;
+ margin-top: 48rpx;
+}
+
+.add-btn:active {
+ opacity: 0.8;
+ transform: scale(0.98);
+}
+
+.add-icon {
+ font-size: 36rpx;
+ color: #000000;
+}
+
+.add-text {
+ font-size: 32rpx;
+ color: #000000;
+}
diff --git a/miniprogram/pages/addresses/edit.js b/miniprogram/pages/addresses/edit.js
new file mode 100644
index 00000000..69f08cf2
--- /dev/null
+++ b/miniprogram/pages/addresses/edit.js
@@ -0,0 +1,201 @@
+/**
+ * 地址编辑页(新增/编辑)
+ * 参考 Next.js: app/view/my/addresses/[id]/page.tsx
+ */
+
+const app = getApp()
+
+Page({
+ data: {
+ statusBarHeight: 44,
+ isEdit: false, // 是否为编辑模式
+ addressId: null,
+
+ // 表单数据
+ name: '',
+ phone: '',
+ province: '',
+ city: '',
+ district: '',
+ detail: '',
+ isDefault: false,
+
+ // 地区选择器
+ region: [],
+
+ saving: false
+ },
+
+ onLoad(options) {
+ this.setData({
+ statusBarHeight: app.globalData.statusBarHeight || 44
+ })
+
+ // 如果有 id 参数,则为编辑模式
+ if (options.id) {
+ this.setData({
+ isEdit: true,
+ addressId: options.id
+ })
+ this.loadAddress(options.id)
+ }
+ },
+
+ // 加载地址详情(编辑模式)
+ async loadAddress(id) {
+ wx.showLoading({ title: '加载中...', mask: true })
+
+ try {
+ const res = await app.request(`/api/user/addresses/${id}`)
+ if (res.success && res.data) {
+ const addr = res.data
+ this.setData({
+ name: addr.name || '',
+ phone: addr.phone || '',
+ province: addr.province || '',
+ city: addr.city || '',
+ district: addr.district || '',
+ detail: addr.detail || '',
+ isDefault: addr.isDefault || false,
+ region: [addr.province, addr.city, addr.district]
+ })
+ } else {
+ wx.showToast({ title: '加载失败', icon: 'none' })
+ }
+ } catch (e) {
+ console.error('加载地址详情失败:', e)
+ wx.showToast({ title: '加载失败', icon: 'none' })
+ } finally {
+ wx.hideLoading()
+ }
+ },
+
+ // 表单输入
+ onNameInput(e) {
+ this.setData({ name: e.detail.value })
+ },
+
+ onPhoneInput(e) {
+ this.setData({ phone: e.detail.value.replace(/\D/g, '').slice(0, 11) })
+ },
+
+ onDetailInput(e) {
+ this.setData({ detail: e.detail.value })
+ },
+
+ // 地区选择
+ onRegionChange(e) {
+ const region = e.detail.value
+ this.setData({
+ region,
+ province: region[0],
+ city: region[1],
+ district: region[2]
+ })
+ },
+
+ // 切换默认地址
+ onDefaultChange(e) {
+ this.setData({ isDefault: e.detail.value })
+ },
+
+ // 表单验证
+ validateForm() {
+ const { name, phone, province, city, district, detail } = this.data
+
+ if (!name || name.trim().length === 0) {
+ wx.showToast({ title: '请输入收货人姓名', icon: 'none' })
+ return false
+ }
+
+ if (!phone || phone.length !== 11) {
+ wx.showToast({ title: '请输入正确的手机号', icon: 'none' })
+ return false
+ }
+
+ if (!province || !city || !district) {
+ wx.showToast({ title: '请选择省市区', icon: 'none' })
+ return false
+ }
+
+ if (!detail || detail.trim().length === 0) {
+ wx.showToast({ title: '请输入详细地址', icon: 'none' })
+ return false
+ }
+
+ return true
+ },
+
+ // 保存地址
+ async saveAddress() {
+ if (!this.validateForm()) return
+ if (this.data.saving) return
+
+ this.setData({ saving: true })
+ wx.showLoading({ title: '保存中...', mask: true })
+
+ const { isEdit, addressId, name, phone, province, city, district, detail, isDefault } = this.data
+ const userId = app.globalData.userInfo?.id
+
+ if (!userId) {
+ wx.hideLoading()
+ wx.showToast({ title: '请先登录', icon: 'none' })
+ this.setData({ saving: false })
+ return
+ }
+
+ const addressData = {
+ userId,
+ name,
+ phone,
+ province,
+ city,
+ district,
+ detail,
+ fullAddress: `${province}${city}${district}${detail}`,
+ isDefault
+ }
+
+ try {
+ let res
+ if (isEdit) {
+ // 编辑模式 - PUT 请求
+ res = await app.request(`/api/user/addresses/${addressId}`, {
+ method: 'PUT',
+ data: addressData
+ })
+ } else {
+ // 新增模式 - POST 请求
+ res = await app.request('/api/user/addresses', {
+ method: 'POST',
+ data: addressData
+ })
+ }
+
+ if (res.success) {
+ wx.hideLoading()
+ wx.showToast({
+ title: isEdit ? '保存成功' : '添加成功',
+ icon: 'success'
+ })
+ setTimeout(() => {
+ wx.navigateBack()
+ }, 1500)
+ } else {
+ wx.hideLoading()
+ wx.showToast({ title: res.message || '保存失败', icon: 'none' })
+ this.setData({ saving: false })
+ }
+ } catch (e) {
+ console.error('保存地址失败:', e)
+ wx.hideLoading()
+ wx.showToast({ title: '保存失败', icon: 'none' })
+ this.setData({ saving: false })
+ }
+ },
+
+ // 返回
+ goBack() {
+ wx.navigateBack()
+ }
+})
diff --git a/miniprogram/pages/addresses/edit.json b/miniprogram/pages/addresses/edit.json
new file mode 100644
index 00000000..2e45b65e
--- /dev/null
+++ b/miniprogram/pages/addresses/edit.json
@@ -0,0 +1,5 @@
+{
+ "usingComponents": {},
+ "navigationStyle": "custom",
+ "enablePullDownRefresh": false
+}
diff --git a/miniprogram/pages/addresses/edit.wxml b/miniprogram/pages/addresses/edit.wxml
new file mode 100644
index 00000000..c5429207
--- /dev/null
+++ b/miniprogram/pages/addresses/edit.wxml
@@ -0,0 +1,101 @@
+
+
+
+
+
+ ‹
+
+ {{isEdit ? '编辑地址' : '新增地址'}}
+
+
+
+
+
+
+
+
+
+ 👤
+ 收货人
+
+
+
+
+
+
+
+ 📱
+ 手机号
+
+
+
+
+
+
+
+ 📍
+ 所在地区
+
+
+
+ {{province || city || district ? province + ' ' + city + ' ' + district : '请选择省市区'}}
+
+
+
+
+
+
+
+ 🏠
+ 详细地址
+
+
+
+
+
+
+
+ ⭐
+ 设为默认地址
+
+
+
+
+
+
+
+ {{saving ? '保存中...' : '保存'}}
+
+
+
diff --git a/miniprogram/pages/addresses/edit.wxss b/miniprogram/pages/addresses/edit.wxss
new file mode 100644
index 00000000..1045a287
--- /dev/null
+++ b/miniprogram/pages/addresses/edit.wxss
@@ -0,0 +1,186 @@
+/**
+ * 地址编辑页样式
+ */
+
+.page {
+ min-height: 100vh;
+ background: #000000;
+ padding-bottom: 200rpx;
+}
+
+/* ===== 导航栏 ===== */
+.nav-bar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 100;
+ background: rgba(0, 0, 0, 0.9);
+ backdrop-filter: blur(40rpx);
+ border-bottom: 1rpx solid rgba(255, 255, 255, 0.05);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 32rpx;
+ height: 88rpx;
+}
+
+.nav-back {
+ width: 64rpx;
+ height: 64rpx;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.1);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.nav-back:active {
+ background: rgba(255, 255, 255, 0.15);
+}
+
+.back-icon {
+ font-size: 48rpx;
+ color: #ffffff;
+ line-height: 1;
+}
+
+.nav-title {
+ flex: 1;
+ text-align: center;
+ font-size: 36rpx;
+ font-weight: 600;
+ color: #ffffff;
+}
+
+.nav-placeholder {
+ width: 64rpx;
+}
+
+/* ===== 内容区 ===== */
+.content {
+ padding: 32rpx;
+}
+
+/* ===== 表单卡片 ===== */
+.form-card {
+ background: #1c1c1e;
+ border-radius: 32rpx;
+ border: 2rpx solid rgba(255, 255, 255, 0.05);
+ padding: 32rpx;
+ margin-bottom: 32rpx;
+}
+
+/* 表单项 */
+.form-item {
+ margin-bottom: 32rpx;
+}
+
+.form-item:last-child {
+ margin-bottom: 0;
+}
+
+.form-label {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+ margin-bottom: 16rpx;
+}
+
+.label-icon {
+ font-size: 28rpx;
+}
+
+.label-text {
+ font-size: 28rpx;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+/* 输入框 */
+.form-input {
+ width: 100%;
+ padding: 24rpx 32rpx;
+ background: rgba(0, 0, 0, 0.3);
+ border: 2rpx solid rgba(255, 255, 255, 0.1);
+ border-radius: 24rpx;
+ color: #ffffff;
+ font-size: 28rpx;
+}
+
+.form-input:focus {
+ border-color: rgba(0, 206, 209, 0.5);
+}
+
+.input-placeholder {
+ color: rgba(255, 255, 255, 0.3);
+}
+
+/* 地区选择器 */
+.region-picker {
+ width: 100%;
+ padding: 24rpx 32rpx;
+ background: rgba(0, 0, 0, 0.3);
+ border: 2rpx solid rgba(255, 255, 255, 0.1);
+ border-radius: 24rpx;
+}
+
+.picker-value {
+ color: #ffffff;
+ font-size: 28rpx;
+}
+
+.picker-value:empty::before {
+ content: '请选择省市区';
+ color: rgba(255, 255, 255, 0.3);
+}
+
+/* 多行文本框 */
+.form-textarea {
+ width: 100%;
+ padding: 24rpx 32rpx;
+ background: rgba(0, 0, 0, 0.3);
+ border: 2rpx solid rgba(255, 255, 255, 0.1);
+ border-radius: 24rpx;
+ color: #ffffff;
+ font-size: 28rpx;
+ min-height: 160rpx;
+ line-height: 1.6;
+}
+
+.form-textarea:focus {
+ border-color: rgba(0, 206, 209, 0.5);
+}
+
+/* 开关项 */
+.form-switch {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16rpx 0;
+}
+
+.form-switch .form-label {
+ margin-bottom: 0;
+}
+
+/* ===== 保存按钮 ===== */
+.save-btn {
+ padding: 32rpx;
+ background: #00CED1;
+ border-radius: 24rpx;
+ text-align: center;
+ font-size: 32rpx;
+ font-weight: 600;
+ color: #000000;
+ margin-top: 48rpx;
+}
+
+.save-btn:active {
+ opacity: 0.8;
+ transform: scale(0.98);
+}
+
+.btn-disabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
diff --git a/miniprogram/pages/chapters/chapters.js b/miniprogram/pages/chapters/chapters.js
index 6ac20c0b..74e323de 100644
--- a/miniprogram/pages/chapters/chapters.js
+++ b/miniprogram/pages/chapters/chapters.js
@@ -213,7 +213,12 @@ Page({
onShow() {
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
- this.getTabBar().setData({ selected: 1 })
+ const tabBar = this.getTabBar()
+ if (tabBar.updateSelected) {
+ tabBar.updateSelected()
+ } else {
+ tabBar.setData({ selected: 1 })
+ }
}
this.updateUserStatus()
},
@@ -247,5 +252,10 @@ Page({
// 返回首页
goBack() {
wx.switchTab({ url: '/pages/index/index' })
+ },
+
+ // 跳转到搜索页
+ goToSearch() {
+ wx.navigateTo({ url: '/pages/search/search' })
}
})
diff --git a/miniprogram/pages/chapters/chapters.wxml b/miniprogram/pages/chapters/chapters.wxml
index 222d8fa9..f769c6ee 100644
--- a/miniprogram/pages/chapters/chapters.wxml
+++ b/miniprogram/pages/chapters/chapters.wxml
@@ -4,7 +4,13 @@
+
+
+ 🔍
+
+
目录
+
diff --git a/miniprogram/pages/chapters/chapters.wxss b/miniprogram/pages/chapters/chapters.wxss
index 1b8de46c..6cbbb747 100644
--- a/miniprogram/pages/chapters/chapters.wxss
+++ b/miniprogram/pages/chapters/chapters.wxss
@@ -26,12 +26,45 @@
height: 88rpx;
display: flex;
align-items: center;
+ justify-content: space-between;
+ padding: 0 32rpx;
+}
+
+.nav-left,
+.nav-right {
+ width: 64rpx;
+ display: flex;
+ align-items: center;
justify-content: center;
}
.nav-title {
font-size: 36rpx;
font-weight: 600;
+ flex: 1;
+ text-align: center;
+}
+
+/* 搜索按钮 */
+.search-btn {
+ width: 64rpx;
+ height: 64rpx;
+ border-radius: 50%;
+ background: #2c2c2e;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.3s ease;
+}
+
+.search-btn:active {
+ background: #3c3c3e;
+ transform: scale(0.95);
+}
+
+.search-icon {
+ font-size: 32rpx;
+ color: rgba(255, 255, 255, 0.6);
}
.brand-color {
@@ -47,7 +80,7 @@
display: flex;
align-items: center;
gap: 24rpx;
- margin: 32rpx;
+ margin: 32rpx 32rpx 24rpx 32rpx;
padding: 32rpx;
}
@@ -55,6 +88,7 @@
background: linear-gradient(135deg, #1c1c1e 0%, #2c2c2e 100%);
border-radius: 32rpx;
border: 2rpx solid rgba(0, 206, 209, 0.2);
+ box-shadow: 0 8rpx 16rpx rgba(0, 0, 0, 0.2);
}
.book-icon {
diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js
index 27766179..1cc182bb 100644
--- a/miniprogram/pages/index/index.js
+++ b/miniprogram/pages/index/index.js
@@ -4,6 +4,8 @@
* 技术支持: 存客宝
*/
+console.log('[Index] ===== 首页文件开始加载 =====')
+
const app = getApp()
Page({
@@ -15,7 +17,7 @@ Page({
// 用户信息
isLoggedIn: false,
hasFullBook: false,
- purchasedCount: 0,
+ readCount: 0,
// 书籍数据
totalSections: 62,
@@ -46,6 +48,8 @@ Page({
},
onLoad(options) {
+ console.log('[Index] ===== onLoad 触发 =====')
+
// 获取系统信息
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
@@ -63,9 +67,27 @@ Page({
},
onShow() {
+ console.log('[Index] onShow 触发')
+
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
- this.getTabBar().setData({ selected: 0 })
+ const tabBar = this.getTabBar()
+ console.log('[Index] TabBar 组件:', tabBar ? '已找到' : '未找到')
+
+ // 主动触发配置加载
+ if (tabBar && tabBar.loadFeatureConfig) {
+ console.log('[Index] 主动调用 TabBar.loadFeatureConfig()')
+ tabBar.loadFeatureConfig()
+ }
+
+ // 更新选中状态
+ if (tabBar && tabBar.updateSelected) {
+ tabBar.updateSelected()
+ } else if (tabBar) {
+ tabBar.setData({ selected: 0 })
+ }
+ } else {
+ console.log('[Index] TabBar 组件未找到或 getTabBar 方法不存在')
}
// 更新用户状态
@@ -147,14 +169,14 @@ Page({
}
},
- // 更新用户状态
+ // 更新用户状态(已读数 = 用户实际打开过的章节数,仅统计有权限阅读的)
updateUserStatus() {
const { isLoggedIn, hasFullBook, purchasedSections } = app.globalData
-
+ const readCount = Math.min(app.getReadCount(), this.data.totalSections || 62)
this.setData({
isLoggedIn,
hasFullBook,
- purchasedCount: hasFullBook ? this.data.totalSections : (purchasedSections?.length || 0)
+ readCount
})
},
diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml
index fdfaf704..5a69cf40 100644
--- a/miniprogram/pages/index/index.wxml
+++ b/miniprogram/pages/index/index.wxml
@@ -5,7 +5,7 @@
-