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}` + }) + 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 ( -
- {/* 顶部区域 */} -
-
-
-
- S -
-
-

Soul创业实验

-

来自派对房的真实故事

-
-
-
- {totalSections}章 -
-
- - {/* 搜索栏 */} -
setSearchOpen(true)} - className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[#1c1c1e] border border-white/5 cursor-pointer hover:border-[#00CED1]/30 transition-colors" - > - - 搜索章节... -
-
- - {/* 搜索弹窗 */} - - -
- {/* 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}

-

待读

-
-
-

5

-

篇章

-
-
-

11

-

章节

-
-
-
- - {/* 精选推荐 */} -
-
-

精选推荐

- -
-
- {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 ( +
+
+ + + +

找回密码

+
+
+ +
+

+ 请输入注册时使用的手机号和新密码,重置后请使用新密码登录。 +

+ +
+
+ + setPhone(e.target.value)} + placeholder="手机号" + 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" + /> +
+ +
+ + setNewPassword(e.target.value)} + placeholder="新密码(至少 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" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="再次输入新密码" + 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" + /> +
+ + {error &&

{error}

} + + +
+ +

+ 若该手机号未注册,将提示「该手机号未注册」;重置后请使用新密码在登录页登录。 +

+
+
+ ) +} 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" ? "登录" : "注册"} - {/* 切换模式 */} -
-
diff --git a/app/match/page.tsx b/app/view/match/page.tsx similarity index 94% rename from app/match/page.tsx rename to app/view/match/page.tsx index 08ada9af..067482fd 100644 --- a/app/match/page.tsx +++ b/app/view/match/page.tsx @@ -2,8 +2,7 @@ import { useState, useEffect } from "react" import { motion, AnimatePresence } from "framer-motion" -import { Users, X, CheckCircle, Loader2, Lock, Zap, Gift } from "lucide-react" -import { Home, List, User } from "lucide-react" +import { Users, X, CheckCircle, Loader2, Lock, Zap } from "lucide-react" import { useRouter } from "next/navigation" import { useStore } from "@/lib/store" @@ -122,8 +121,7 @@ export default function MatchPage() { setTodayMatchCount(getTodayMatchCount()) }, [user]) - if (!mounted) return null // 彻底解决 Hydration 错误 - + // 处理函数定义(必须在所有 hooks 之后) const handleJoinClick = (typeId: string) => { setJoinType(typeId) setShowJoinModal(true) @@ -333,6 +331,9 @@ export default function MatchPage() { const currentMatchLabel = currentType?.matchLabel || "创业伙伴" const joinTypeLabel = matchTypes.find((t) => t.id === joinType)?.matchLabel || "" + // 等待挂载完成(必须在所有 hooks 和函数定义之后) + if (!mounted) return null + return (
@@ -365,7 +366,7 @@ export default function MatchPage() { {matchesRemaining <= 0 && !user?.hasFullBook && (
- - {/* 找伙伴按钮 - 当前页面高亮 */} - - -
-
- ) } diff --git a/app/view/my/addresses/[id]/page.tsx b/app/view/my/addresses/[id]/page.tsx new file mode 100644 index 00000000..955917bc --- /dev/null +++ b/app/view/my/addresses/[id]/page.tsx @@ -0,0 +1,161 @@ +"use client" + +import { useState, useEffect } from "react" +import { useRouter, useParams } from "next/navigation" +import { ChevronLeft } from "lucide-react" +import { useStore } from "@/lib/store" + +export default function EditAddressPage() { + const router = useRouter() + const params = useParams() + const id = params?.id as string + const { user } = useStore() + const [loading, setLoading] = useState(false) + const [fetching, setFetching] = useState(true) + const [name, setName] = useState("") + const [phone, setPhone] = useState("") + const [province, setProvince] = useState("") + const [city, setCity] = useState("") + const [district, setDistrict] = useState("") + const [detail, setDetail] = useState("") + const [isDefault, setIsDefault] = useState(false) + + useEffect(() => { + if (!id || !user?.id) { + setFetching(false) + return + } + fetch(`/api/user/addresses/${id}`) + .then((res) => res.json()) + .then((data) => { + if (data.success && data.item) { + const a = data.item + setName(a.name || "") + setPhone(a.phone || "") + setProvince(a.province || "") + setCity(a.city || "") + setDistrict(a.district || "") + setDetail(a.detail || "") + setIsDefault(!!a.isDefault) + } + }) + .finally(() => setFetching(false)) + }, [id, user?.id]) + + if (!user?.id) { + return ( +
+

请先登录

+
+ ) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) { + alert("请输入收货人姓名") + return + } + if (!/^1[3-9]\d{9}$/.test(phone)) { + alert("请输入正确的手机号") + return + } + if (!detail.trim()) { + alert("请输入详细地址") + return + } + setLoading(true) + try { + const res = await fetch(`/api/user/addresses/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: name.trim(), + phone: phone.trim(), + province: (province ?? "").trim(), + city: (city ?? "").trim(), + district: (district ?? "").trim(), + detail: detail.trim(), + isDefault, + }), + }) + const data = await res.json() + if (data.success) { + router.push("/view/my/addresses") + } else { + alert(data.message || "保存失败") + } + } catch { + alert("保存失败") + } finally { + setLoading(false) + } + } + + if (fetching) { + return ( +
+
+
+ ) + } + + return ( +
+
+
+ +

编辑地址

+
+
+
+ +
+
+
+ + setName(e.target.value)} + placeholder="请输入收货人姓名" + className="flex-1 bg-transparent text-white text-sm text-right placeholder-white/30 outline-none" + /> +
+
+ + setPhone(e.target.value.replace(/\D/g, ""))} + placeholder="请输入手机号" + className="flex-1 bg-transparent text-white text-sm text-right placeholder-white/30 outline-none" + /> +
+
+ +
+ setProvince(e.target.value)} placeholder="省" className="flex-1 max-w-24 bg-transparent text-white text-sm text-right placeholder-white/30 outline-none" /> + setCity(e.target.value)} placeholder="市" className="flex-1 max-w-24 bg-transparent text-white text-sm text-right placeholder-white/30 outline-none" /> + setDistrict(e.target.value)} placeholder="区" className="flex-1 max-w-24 bg-transparent text-white text-sm text-right placeholder-white/30 outline-none" /> +
+
+
+ +