更新小程序隐私保护机制,新增手机号一键登录功能,用户需同意隐私协议后方可获取手机号。优化多个页面的登录交互,提升用户体验。调整相关配置以支持新功能。

This commit is contained in:
Alex-larget
2026-03-20 13:40:13 +08:00
parent 0bc32deb94
commit 385e47bc55
60 changed files with 2954 additions and 1669 deletions

View File

@@ -0,0 +1,32 @@
# 挖矿病毒排查与服务器操作 Skills 创建
**日期**2026-03-20
## 背景
基于 agent 记录3b9e0fa0、bc781e1b、1c1a81c3 等中挖矿病毒排查经验以及本地部署脚本devloy.py、master.py、soul-admin/deploy.py、Cunkebao/miner_guard_install.py 等),将经验吸收转化为 Skills。
## 新增 Skills
### 1. security-miner-guard
- **路径**`.cursor/skills/security-miner-guard/SKILL.md`
- **触发词**挖矿病毒、xmrig、服务器被入侵、miner_guard、安全排查、杀挖矿
- **内容**挖矿病毒特征、入侵链路、排查脚本、加固建议、miner_guard 安装与检查
### 2. security-server-ops
- **路径**`.cursor/skills/security-server-ops/SKILL.md`
- **触发词**部署、服务器操作、SSH、宝塔、devloy、master、Cunkebao 部署
- **内容**:服务器索引、部署脚本索引、环境变量一览、常用操作(不含明文密码)
## 配置更新
- `paths.py`:新增 `AGENT_SECURITY``ROLE_TO_AGENT["安全工程师"]`
- `老板分身-索引.mdc`:经验自动收集推断增加「挖矿/安全/服务器操作→安全工程师」
- `soul-project-boundary.mdc`:按语义触发词增加安全工程师及对应 Skills
## 安全提醒
- Skills 中**不写入明文密码**,仅说明配置来源(环境变量、脚本 get_cfg()
- 建议将 master.py、devloy.py 等中的默认密码迁移到环境变量

View File

@@ -0,0 +1,24 @@
# 部署管理端触发词约定
**日期**2026-03-20
## 场景
用户说「帮我部署管理端到线上」时,安全工程师应直接执行部署脚本,无需再询问或选择。
## 解决方案
- **触发词**:帮我部署管理端到线上
- **动作**:直接执行 `cd soul-api && python master.py`
- **脚本**`soul-api/master.py`soul-api 正式环境部署)
## 已升级 Skills
1. **security-server-ops**:何时使用表新增该触发词,明确直接执行命令
2. **soul-project-boundary**:按场景触发词表新增,加载 security-server-ops 后执行
## 说明
- `master.py` 部署的是 soul-apiGo 后端),非 soul-admin 前端
- 若需部署 soul-admin 前端,应使用 `soul-admin/deploy.py`
- 用户明确指定「管理端」对应 master.py按约定执行

View File

@@ -0,0 +1,8 @@
# 安全工程师 经验索引
> 挖矿病毒排查、服务器加固、部署与运维相关经验。
| 日期 | 摘要 | 文件 |
|------|------|------|
| 2026-03-20 | 挖矿病毒排查经验转化为 Skills服务器操作 Skill 创建 | 2026-03-20-挖矿与服务器Skills.md |
| 2026-03-20 | 「帮我部署管理端到线上」→ 执行 soul-api/master.py | 2026-03-20-部署管理端触发词.md |

View File

@@ -54,6 +54,7 @@
| 2026-03-18 | 小程序、团队 | 业务规则/最佳实践 | - | 分享链路兼容好友/朋友圈 singlePage单页模式能力降级不支付/不自动领取),引导点击底部“前往小程序”进入完整版 |
| 2026-03-18 | 产品、后端、管理端、测试 | 文档归档/需求口径 | - | 文档归档整理:以《以界面定需求》为基准,各角色重整“功能需求+验收口径+风险点”并写入各自经验库;补齐《项目落地推进表》 |
| 2026-03-19 | 小程序 | 最佳实践 | miniprogram-dev SKILL §11 | 原生按钮覆盖定位chooseAvatar 等用绝对定位 overlay 覆盖,禁止 button 包裹,避免原生样式影响(灰色矩形等) |
| 2026-03-20 | 安全工程师 | 触发词约定 | security-server-ops、soul-project-boundary | 「帮我部署管理端到线上」→ 直接执行 soul-api/master.py |
---
@@ -64,4 +65,4 @@
---
**最后更新**2026-03-18
**最后更新**2026-03-20

View File

@@ -31,9 +31,10 @@ Soul 创业派对全项目架构与约定路由隔离miniprogram/admin/db
| 2026-03-17 | 性能优化与 Redis 缓存方案落地Redis 容灾回退 DB、OSS 上传容灾;/health 返回 database/redis 状态 | 已完成 |
| 2026-03-18 | 吸收经验:分享进入链路需兼容朋友圈 singlePage单页模式不执行支付/自动领取等强动作并引导“前往小程序” | 已完成 |
| 2026-03-18 | 会议:超级个体开通后自动创建@人统一走 Person幂等键绑定 userId默认资料 flags 后端输出 | 已完成 |
| 2026-03-20 | 「帮我部署管理端到线上」→ 安全工程师执行 soul-api/master.pysecurity-server-ops、soul-project-boundary 触发词升级 | 已完成 |
> **格式说明**:每次架构级讨论后在此追加一行,日期格式 YYYY-MM-DD
---
**最后更新**2026-03-18
**最后更新**2026-03-20

View File

@@ -53,6 +53,7 @@ AGENT_BACKEND = AGENT / "后端工程师"
AGENT_PRODUCT = AGENT / "产品经理"
AGENT_TEST = AGENT / "软件测试"
AGENT_TEAM = AGENT / "团队"
AGENT_SECURITY = AGENT / "安全工程师"
# ========== 常用文件 ==========
RULE_MAIN = RULES / "老板分身-索引.mdc"
@@ -80,6 +81,9 @@ ROLE_TO_AGENT = {
"软件测试": "软件测试",
"测试": "软件测试",
"测试人员": "软件测试",
# 安全
"安全工程师": "安全工程师",
"安全": "安全工程师",
# 通用
"团队": "团队",
}

View File

@@ -50,6 +50,7 @@ alwaysApply: true
| 小程序、miniprogram、C 端、微信小程序 | 小程序开发工程师 | `e:\Gongsi\Mycontent\.cursor\skills\miniprogram-dev\SKILL.md` |
| 产品、需求、验收、排期、需求文档 | 产品经理 | `e:\Gongsi\Mycontent\.cursor\skills\product-manager\SKILL.md` |
| 测试、测试用例、回归测试、功能测试、QA | 测试人员 | `e:\Gongsi\Mycontent\.cursor\skills\testing\SKILL.md` |
| 挖矿、安全、服务器操作、部署、miner_guard、xmrig、入侵排查 | 安全工程师 | `e:\Gongsi\Mycontent\.cursor\skills\security-miner-guard\SKILL.md`、`e:\Gongsi\Mycontent\.cursor\skills\security-server-ops\SKILL.md` |
### 按场景触发词
@@ -63,5 +64,6 @@ alwaysApply: true
| 会议结束、散会、会开完了 | `e:\Gongsi\Mycontent\.cursor\skills\assistant-doc-sync\SKILL.md`(会议收尾) |
| **加个需求**、加个需求xxx | `e:\Gongsi\Mycontent\.cursor\skills\product-manager\SKILL.md`(产品经理三端分析 → 功能规划 → 指派) |
| **新版分析**、版本对比、迁移分析、甲方代码分析、快速分析新版、抽取需求 | `e:\Gongsi\Mycontent\.cursor\skills\new-version-analyze\SKILL.md`(新版快速分析 → 差异清单 → 接口冲突 → 迁移迭代) |
| **帮我部署管理端到线上** | `e:\Gongsi\Mycontent\.cursor\skills\security-server-ops\SKILL.md`(安全工程师 → 执行 soul-api/master.py |
**注意**:「必须 Read」= 使用 Read 工具读取**绝对路径**的完整文件内容后执行,不可跳过或仅凭记忆。

View File

@@ -39,6 +39,7 @@ alwaysApply: true
- 产品/需求/config→**产品经理**
- 测试/自检/QA→**软件测试**
- 架构/选型/路由约定/三端协同→**团队**
- 挖矿/安全/服务器操作/部署/入侵排查→**安全工程师**
- 无法判断→**通用**(写入开发助理)
3. **若可写文件**
- **有明确目标角色**:写入 `.cursor/agent/{角色}/evolution/YYYY-MM-DD-简短描述.md`,并更新该目录下的 `索引.md`

View File

@@ -83,6 +83,7 @@ description: 开发团队文档同步与经验升级。小橙、橙子、讨论
| 后端开发 | `agent/后端工程师/evolution/` | `agent/开发助理/项目索引/后端.md` |
| 产品经理 | `agent/产品经理/evolution/` | `agent/开发助理/项目索引/产品.md` |
| 测试人员 | `agent/软件测试/evolution/` | `agent/开发助理/项目索引/测试.md` |
| 安全工程师 | `agent/安全工程师/evolution/` | `agent/开发助理/项目索引/团队.md` |
| 助理橙子 | `agent/开发助理/evolution/` | `agent/开发助理/项目索引/助理橙子.md` |
| 跨角色/团队 | `agent/团队/evolution/` | `agent/开发助理/项目索引/团队.md` |

View File

@@ -0,0 +1,112 @@
---
description: Soul 挖矿病毒排查与防护。xmrig、kdevtmpfsi、kinsing、minerd、miner_guard。Use when 挖矿病毒、xmrig、服务器被入侵、miner_guard、安全排查、杀挖矿.
---
# SKILL - 挖矿病毒排查与防护(安全工程师)
> 基于 Soul 项目历史排查经验沉淀,用于快速识别、清理挖矿病毒并加固服务器。
## 何时使用
| 触发词 | 动作 |
|--------|------|
| 挖矿病毒、xmrig、服务器被入侵 | 执行排查与清理流程 |
| miner_guard、安装挖矿守护 | 安装/检查 miner_guard |
| 安全排查、杀挖矿 | 按本 Skill 执行 |
---
## 一、挖矿病毒特征Soul 项目实测)
### 1.1 进程/路径关键词
| 关键词 | 说明 |
|--------|------|
| xmrig | 门罗币挖矿程序,最常见 |
| xmr-stak, minerd, cpuminer | 其他 CPU 挖矿 |
| kdevtmpfsi, kinsing | Linux 常见挖矿木马 |
| stratum | 矿池协议 |
| libprocesshider, watchbog, ddgs, trace | 隐藏/持久化相关 |
### 1.2 常见路径
- `/tmp/xmrig``/tmp/config.json``/tmp/.x`
- `/tmp/kdevtmpfsi``/tmp/kinsing`
- `/www/wwwroot/**/xmrig*`(宝塔站点目录下残留)
- `/www/wwwroot/self/wanzhi/tongzhi/xmrig-*`(历史发现)
### 1.3 入侵链路Soul 项目根因分析)
```
公网访问宝塔 9988 → 弱口令/漏洞 → 进入面板 → www 用户执行命令
→ 下载 xmrig 到 /tmp → 运行挖矿
```
**最可能入口**:宝塔面板 9988 对公网开放 + 弱口令/历史漏洞。
---
## 二、排查与清理脚本soul-api 目录)
| 脚本 | 用途 |
|------|------|
| `miner_guard_check.py` | 检查 miner_guard 安装状态,手动执行一次脚本,查看日志 |
| `miner_guard_install.py` | 安装挖矿守护到服务器(上传 miner_guard.sh + 配置 cron/systemd |
| `miner_guard.sh` | 守护脚本本体(杀进程、删文件、扫 /tmp、/www/wwwroot |
| `remove_xmrig_self.py` | 删除固定路径 `/www/wwwroot/self/wanzhi/tongzhi/xmrig-6.24.0` |
### 2.1 快速检查(本地执行)
```powershell
cd e:\Gongsi\Mycontent\soul-api
python miner_guard_check.py
```
依赖:`pip install paramiko`。配置来源:`master.py``get_cfg()` 或环境变量 `DEPLOY_HOST``DEPLOY_PASSWORD` 等。
### 2.2 安装挖矿守护
```powershell
cd e:\Gongsi\Mycontent\soul-api
python miner_guard_install.py --yes
```
- 上传 `miner_guard.sh``/root/miner_guard.sh`
- 优先写入 `/etc/cron.d/miner_guard`(每 30 分钟),失败则尝试 crontab 或 systemd timer
- 日志:`/var/log/miner_guard.log`
### 2.3 Cunkebao 服务器
```powershell
cd e:\Gongsi\Mycontent\Cunkebao
python miner_guard_install.py --yes
```
使用 Cunkebao 内嵌配置(非 root 用户,日志在 `~/miner_guard.log`)。
---
## 三、加固建议(防止再次入侵)
| 优先级 | 措施 |
|--------|------|
| 1 | 宝塔面板9988 仅允许指定 IP 访问;改非常规端口(如 29988强密码 |
| 2 | 修改所有密码root、宝塔、宝塔 API、MySQL、Redis |
| 3 | Redis`bind 127.0.0.1`,设置 `requirepass` |
| 4 | 敏感信息:密码/API Key 用环境变量,不提交到 Git |
---
## 四、miner_guard.sh 行为摘要
1. **杀进程**`pgrep -f xmrig|kdevtmpfsi|kinsing|...``kill -9`
2. **删已知路径**`/tmp/xmrig``/tmp/config.json`
3. **扫 /tmp、/var/tmp、/dev/shm**:含挖矿关键词的可执行文件 → `rm -f`
4. **扫 /www/wwwroot**:含 xmrig 的目录/文件 → `rm -rf` / `rm -f`
5. **检查 www 用户 crontab**:可疑项仅提示,不自动删
---
## 五、注意事项
- 脚本会按关键词删除,可能与业务目录重叠,部署前确认扫描范围
- `master.py``devloy.py` 等含默认密码,应改为环境变量并确保 `.env` 不入库

View File

@@ -0,0 +1,137 @@
---
description: Soul 服务器操作与部署。部署脚本、SSH、宝塔、环境变量。Use when 部署、服务器操作、SSH、宝塔、devloy、master、Cunkebao 部署.
---
# SKILL - 服务器操作与部署(安全工程师)
> Soul 项目部署脚本索引与服务器操作规范。**密码等敏感信息仅通过环境变量或脚本内 get_cfg() 读取,不在此文档明文列出。**
## 何时使用
| 触发词 | 动作 |
|--------|------|
| **帮我部署管理端到线上** | **直接执行 `cd soul-api && python master.py`** |
| 部署、服务器操作、SSH | 按本 Skill 选择对应脚本 |
| 宝塔、devloy、master | 查阅部署配置与命令 |
| Cunkebao 部署 | 使用 Cunkebao 专用脚本 |
---
## 一、服务器与配置来源
### 1.1 配置读取优先级
各部署脚本统一约定:
1. **环境变量**(推荐):`DEPLOY_HOST``DEPLOY_USER``DEPLOY_PASSWORD``DEPLOY_SSH_PORT``BT_API_KEY`
2. **脚本内 get_cfg()**:无环境变量时使用脚本默认值
3. **master.py**soul-api 的 miner_guard_check、remove_xmrig_self 等从 `soul-api/master.py``get_cfg()` 读取
### 1.2 服务器索引(配置来源,不含明文密码)
| 项目 | 主机 | 端口 | 用户 | 配置来源 |
|------|------|------|------|----------|
| soul-api 正式 | 43.139.27.93 | 22022 | root | master.py / 环境变量 |
| soul-api 测试 | 43.139.27.93 | 22022 | root | devloy.py / 环境变量 |
| soul-admin | 43.139.27.93 | 22022 | root | soul-admin/deploy.py / 环境变量 |
| Cunkebao | 42.194.245.239 | 6523 | yongpxu | Cunkebao/miner_guard_install.py 内嵌 |
**密码**:从 `DEPLOY_PASSWORD` 或各脚本 `get_cfg()` 默认值读取,**不在此文档记录**。
---
## 二、部署脚本索引
### 2.1 soul-api
| 脚本 | 用途 | 命令示例 |
|------|------|----------|
| `soul-api/devloy.py` | 测试环境部署binary/docker/runner | `python devloy.py --mode runner` |
| `soul-api/master.py` | 正式环境部署 | `python master.py` |
| `soul-api/deploy/runner-init.sh` | Runner 容器首次初始化 | `bash deploy/runner-init.sh` |
| `soul-api/deploy/docker-deploy-remote.sh` | 服务器上执行蓝绿切换 | 由 devloy 自动调用 |
| `soul-api/deploy/deploy-runner-remote.sh` | Runner 模式部署包拷贝 | 由 devloy 自动调用 |
**devloy 模式**
- `--mode runner`:容器内红蓝切换,宝塔固定 9001
- `--mode docker`:宿主机蓝绿,需 Nginx 切换
- `--mode binary`Go 二进制 + 宝塔 soulDev
### 2.2 soul-admin
| 脚本 | 用途 | 命令示例 |
|------|------|----------|
| `soul-admin/deploy.py` | 静态站点部署dist → dist2 互换) | `python deploy.py` |
### 2.3 挖矿防护
| 脚本 | 用途 | 命令示例 |
|------|------|----------|
| `soul-api/miner_guard_install.py` | 安装挖矿守护soul 服务器) | `python miner_guard_install.py --yes` |
| `Cunkebao/miner_guard_install.py` | 安装挖矿守护Cunkebao | `cd Cunkebao && python miner_guard_install.py --yes` |
---
## 三、环境变量一览
| 变量 | 说明 | 默认来源 |
|------|------|----------|
| DEPLOY_HOST | SSH 主机 | 各脚本 get_cfg() |
| DEPLOY_USER | SSH 用户 | root |
| DEPLOY_PASSWORD | SSH 密码 | 脚本默认 / 需设置 |
| DEPLOY_SSH_KEY | SSH 私钥路径 | 空 |
| DEPLOY_SSH_PORT | SSH 端口 | 22022 |
| BT_PANEL_URL | 宝塔面板 URL | https://{host}:9988 |
| BT_API_KEY | 宝塔 API 密钥 | 脚本内默认 |
| BT_GO_PROJECT_NAME | 宝塔 Go 项目名 | soulDev / soulApi |
| DEPLOY_DOCKER_PATH | 部署目录 | /www/wwwroot/self/soul-dev |
| DEPLOY_NGINX_CONF | Nginx 配置路径 | 空(可自动探测) |
---
## 四、常用操作
### 4.1 SSH 连接(示例,密码从环境变量读取)
```powershell
# 设置环境变量后
$env:DEPLOY_HOST="43.139.27.93"
$env:DEPLOY_PASSWORD="<从安全存储读取>"
ssh -p 22022 root@43.139.27.93
```
### 4.2 部署 soul-api 测试环境Runner 模式)
```powershell
cd e:\Gongsi\Mycontent\soul-api
python devloy.py --mode runner
```
### 4.3 部署 soul-api 正式环境
```powershell
cd e:\Gongsi\Mycontent\soul-api
python master.py
```
### 4.4 部署 soul-admin
```powershell
cd e:\Gongsi\Mycontent\soul-admin
python deploy.py
```
### 4.5 检查挖矿守护
```powershell
cd e:\Gongsi\Mycontent\soul-api
python miner_guard_check.py
```
---
## 五、安全提醒
- **不要将密码提交到 Git**master.py、devloy.py 等中的默认密码应迁移到环境变量
- **宝塔 API 密钥**BT_API_KEY 若泄露需在宝塔面板重新生成
- **敏感文件**`.env``master.py` 等应加入 `.gitignore` 或使用 `.env.example` 模板

View File

@@ -95,7 +95,10 @@ App({
this._privacyResolve = resolve
const pages = getCurrentPages()
const cur = pages[pages.length - 1]
if (cur && typeof cur.setData === 'function' && cur.route && (cur.route.includes('avatar-nickname') || cur.route.includes('profile-edit'))) {
const route = (cur && cur.route) || ''
const needPrivacyPages = ['avatar-nickname', 'profile-edit', 'read', 'my', 'gift-pay/detail', 'index', 'settings']
const needShow = needPrivacyPages.some(p => route.includes(p))
if (cur && typeof cur.setData === 'function' && needShow) {
cur.setData({ showPrivacyModal: true })
} else {
resolve({ event: 'disagree' })
@@ -413,12 +416,57 @@ App({
this.globalData.hasFullBook = userInfo.hasFullBook || false
this.globalData.isVip = userInfo.isVip || false
this.globalData.vipExpireDate = userInfo.vipExpireDate || ''
// 若手机号为空,后台静默刷新用户资料以同步最新手机号(可能在其他设备/页面已绑定)
if (!(userInfo.phone || '').trim()) {
this._refreshUserInfoIfPhoneEmpty()
}
}
} catch (e) {
console.error('检查登录状态失败:', e)
}
},
/**
* 手机号登录后:若响应中 user.phone 为空,从 profile 拉取最新资料并更新本地(后端已写入 DB
*/
async _syncPhoneFromProfileAfterLogin(userId) {
try {
if (!userId) return
const res = await this.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
const profile = res?.data
if (!profile) return
const phone = (profile.phone || '').trim()
if (!phone) return
const updated = { ...this.globalData.userInfo, phone }
if (profile.wechatId != null) updated.wechatId = profile.wechatId
this.globalData.userInfo = updated
wx.setStorageSync('userInfo', updated)
wx.setStorageSync('user_phone', phone)
} catch (_) {}
},
/**
* 当本地 userInfo.phone 为空时,静默拉取 profile 并更新(用户可能在设置页或其他入口已绑定手机号)
*/
async _refreshUserInfoIfPhoneEmpty() {
try {
const userId = this.globalData.userInfo?.id
if (!userId) return
const res = await this.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
const profile = res?.data
if (!profile) return
const phone = (profile.phone || '').trim()
if (!phone) return
const updated = { ...this.globalData.userInfo, phone }
if (profile.wechatId != null) updated.wechatId = profile.wechatId
this.globalData.userInfo = updated
wx.setStorageSync('userInfo', updated)
if (phone) wx.setStorageSync('user_phone', phone)
} catch (_) {
// 静默失败,不影响主流程
}
},
/**
* WSS 在线心跳(占位):登录后连接 ws发送 auth + 心跳,供管理端统计在线人数
* 容错任意异常均不向外抛出不影响登录、API 请求等核心功能
@@ -1026,6 +1074,13 @@ App({
wx.setStorageSync('userInfo', user)
wx.setStorageSync('token', res.data.token)
// 手机号登录后:若用户资料中手机号为空,从 profile 刷新并更新(后端已写入 DB可能响应中未带回
const phone = (user.phone || '').trim()
if (!phone) {
this._syncPhoneFromProfileAfterLogin(user.id)
} else {
wx.setStorageSync('user_phone', phone)
}
// 登录成功后绑定推荐码
const pendingRef = wx.getStorageSync('pendingReferralCode') || this.globalData.pendingReferralCode

View File

@@ -28,7 +28,8 @@
"pages/avatar-nickname/avatar-nickname",
"pages/gift-pay/detail",
"pages/gift-pay/list",
"pages/gift-pay/redemption-detail"
"pages/gift-pay/redemption-detail",
"pages/dev-login/dev-login"
],
"window": {
"backgroundTextStyle": "light",

View File

@@ -0,0 +1,80 @@
/**
* 卡若创业派对 - 开发登录页
* 临时:账户=手机号,密码可空,用于切换为对方账号调试
*/
const app = getApp()
const { checkAndExecute } = require('../../utils/ruleEngine.js')
Page({
data: {
statusBarHeight: 44,
account: '',
password: '',
loading: false
},
onLoad() {
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44
})
},
onAccountInput(e) {
this.setData({ account: (e.detail.value || '').trim() })
},
onPasswordInput(e) {
this.setData({ password: e.detail.value || '' })
},
goBack() {
app.goBackOrToHome()
},
async handleLogin() {
const { account, password, loading } = this.data
if (!account || loading) return
const phone = account.replace(/\s/g, '')
if (phone.length < 11) {
wx.showToast({ title: '请输入11位手机号', icon: 'none' })
return
}
this.setData({ loading: true })
try {
const res = await app.request('/api/miniprogram/dev/login-by-phone', {
method: 'POST',
data: { phone, password: password || '' }
})
if (res.success && res.data) {
const user = res.data.user
app.globalData.userInfo = user
app.globalData.isLoggedIn = true
app.globalData.purchasedSections = user.purchasedSections || []
app.globalData.hasFullBook = user.hasFullBook || false
app.globalData.isVip = user.isVip || false
app.globalData.vipExpireDate = user.vipExpireDate || ''
wx.setStorageSync('userInfo', user)
wx.setStorageSync('token', res.data.token)
const pendingRef = wx.getStorageSync('pendingReferralCode') || app.globalData.pendingReferralCode
if (pendingRef) {
app.bindReferralCode(pendingRef)
}
checkAndExecute('after_login', null)
setTimeout(() => app.checkVipContactRequiredAndGuide(), 1200)
wx.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => wx.switchTab({ url: '/pages/index/index' }), 800)
}
} catch (e) {
wx.showToast({ title: e.message || '登录失败', icon: 'none' })
} finally {
this.setData({ loading: false })
}
}
})

View File

@@ -0,0 +1,7 @@
{
"usingComponents": {
"icon": "/components/icon/icon"
},
"navigationStyle": "custom",
"navigationBarTitleText": "账户密码登录"
}

View File

@@ -0,0 +1,51 @@
<!--开发登录页 - 临时:账户=手机号,密码可空-->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack">
<icon name="chevron-left" size="44" color="rgba(255,255,255,0.6)" customClass="back-icon"></icon>
</view>
<text class="nav-title">账户密码登录</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="content">
<view class="tip-banner">
<text class="tip-text">开发专用:输入对方手机号登录,密码可留空。仅开发环境可用。</text>
</view>
<view class="form-card">
<view class="form-item">
<text class="form-label">账户(手机号)</text>
<view class="form-input-wrap">
<input
class="form-input-inner"
type="number"
placeholder="请输入对方手机号"
placeholder-class="input-placeholder"
value="{{account}}"
bindinput="onAccountInput"
maxlength="11"
/>
</view>
</view>
<view class="form-item">
<text class="form-label">密码(可留空)</text>
<view class="form-input-wrap">
<input
class="form-input-inner"
type="text"
password="{{true}}"
placeholder="可选,留空即可"
placeholder-class="input-placeholder"
value="{{password}}"
bindinput="onPasswordInput"
/>
</view>
</view>
<view class="btn-primary {{!account || loading ? 'btn-disabled' : ''}}" bindtap="handleLogin">
{{loading ? '登录中...' : '登录'}}
</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,23 @@
/* 开发登录页 */
.page { min-height: 100vh; background: #000; padding-bottom: 64rpx; }
.nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: rgba(0,0,0,0.9); backdrop-filter: blur(40rpx); display: flex; align-items: center; justify-content: space-between; padding: 0 32rpx; height: 88rpx; }
.nav-back { width: 64rpx; height: 64rpx; background: #1c1c1e; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.back-icon { font-size: 40rpx; color: rgba(255,255,255,0.6); font-weight: 300; }
.nav-title { font-size: 34rpx; font-weight: 600; color: #fff; }
.nav-placeholder { width: 64rpx; }
.content { padding: 24rpx 16rpx; }
.tip-banner { background: rgba(255,165,0,0.1); border: 2rpx solid rgba(255,165,0,0.3); border-radius: 20rpx; padding: 20rpx 24rpx; margin-bottom: 24rpx; }
.tip-text { font-size: 24rpx; color: #FFA500; line-height: 1.5; }
.form-card { background: #1c1c1e; border-radius: 32rpx; padding: 32rpx; border: 2rpx solid rgba(0,206,209,0.2); }
.form-item { margin-bottom: 32rpx; }
.form-item:last-of-type { margin-bottom: 48rpx; }
.form-label { font-size: 28rpx; color: rgba(255,255,255,0.8); display: block; margin-bottom: 16rpx; }
.form-input-wrap { padding: 16rpx 24rpx; background: #1F2937; border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; }
.form-input-inner { width: 100%; font-size: 28rpx; background: transparent; color: #fff; }
.input-placeholder { color: rgba(255,255,255,0.25); }
.btn-primary { padding: 32rpx; background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); color: #000; font-size: 32rpx; font-weight: 600; text-align: center; border-radius: 28rpx; }
.btn-disabled { opacity: 0.5; }

View File

@@ -19,6 +19,7 @@ Page({
isSinglePageMode: false,
showLoginModal: false,
agreeProtocol: false,
showPrivacyModal: false,
// 创建态
isCreateMode: false,
giftQuantity: 1,
@@ -265,7 +266,7 @@ Page({
},
closeLoginModal() {
this.setData({ showLoginModal: false })
this.setData({ showLoginModal: false, showPrivacyModal: false })
},
toggleAgree() {
this.setData({ agreeProtocol: !this.data.agreeProtocol })
@@ -284,6 +285,13 @@ Page({
wx.showToast({ title: '登录失败', icon: 'none' })
}
},
handleAgreePrivacyForPhone() {
if (app._privacyResolve) {
app._privacyResolve({ buttonId: 'agree-privacy-btn', event: 'agree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
async handlePhoneLogin(e) {
if (!e.detail.code) return this.handleWechatLogin()
try {

View File

@@ -152,10 +152,14 @@
<view class="login-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="login-title">登录 卡若创业派对</text>
<text class="login-desc">登录后可免费领取并阅读</text>
<button class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" bindtap="handleWechatLogin" disabled="{{!agreeProtocol}}">
<button id="agree-phone-btn" class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="handlePhoneLogin" bindagreeprivacyauthorization="handleAgreePrivacyForPhone" disabled="{{!agreeProtocol}}">
<text class="btn-wechat-icon">微</text>
<text>微信快捷登录</text>
<text>手机号一键登录</text>
</button>
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="handleAgreePrivacyForPhone">同意</button>
</view>
<view class="login-agree-row" catchtap="toggleAgree">
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}"><icon wx:if="{{agreeProtocol}}" name="check" size="24" color="#34C759"></icon></view>
<text class="agree-text">我已阅读并同意</text>

View File

@@ -637,6 +637,10 @@
}
.btn-wechat-disabled { opacity: 0.5; }
.btn-wechat-icon { font-weight: 700; margin-right: 8rpx; }
.privacy-wechat-row { margin: 24rpx 0; padding: 24rpx; background: rgba(0,206,209,0.1); border-radius: 16rpx; }
.privacy-wechat-desc { display: block; font-size: 26rpx; color: rgba(255,255,255,0.8); margin-bottom: 16rpx; }
.privacy-agree-btn { width: 100%; padding: 20rpx; background: #07C160; color: #fff; font-size: 28rpx; border-radius: 16rpx; border: none; }
.privacy-agree-btn::after { border: none; }
.login-agree-row {
display: flex;
flex-wrap: wrap;

View File

@@ -61,6 +61,7 @@ Page({
// 链接卡若 - 留资弹窗
showLeadModal: false,
leadPhone: '',
showPrivacyModal: false,
// 展开状态(首页精选/最新)
featuredExpanded: false,
@@ -380,7 +381,7 @@ Page({
},
closeLeadModal() {
this.setData({ showLeadModal: false, leadPhone: '' })
this.setData({ showLeadModal: false, leadPhone: '', showPrivacyModal: false })
},
// 阻止弹窗内部点击事件冒泡到遮罩层
@@ -390,6 +391,15 @@ Page({
this.setData({ leadPhone: (e.detail.value || '').trim() })
},
// 微信隐私协议同意getPhoneNumber 需先同意)
onAgreePrivacyForLead() {
if (app._privacyResolve) {
app._privacyResolve({ buttonId: 'agree-privacy-btn', event: 'agree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
// 一键获取手机号(微信能力),成功后直接提交链接卡若
async onGetPhoneNumberForLead(e) {
if (e.detail.errMsg !== 'getPhoneNumber:ok') {

View File

@@ -161,7 +161,11 @@
<view class="lead-box" catchtap="stopPropagation">
<text class="lead-title">留下联系方式</text>
<text class="lead-desc">方便卡若与您联系</text>
<button class="lead-get-phone-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumberForLead">一键获取手机号</button>
<button id="agree-lead-phone-btn" class="lead-get-phone-btn" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onGetPhoneNumberForLead" bindagreeprivacyauthorization="onAgreePrivacyForLead">一键获取手机号</button>
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacyForLead">同意</button>
</view>
<text class="lead-divider">或手动输入</text>
<view class="lead-input-wrap">
<input class="lead-input" placeholder="请输入手机号" type="number" maxlength="11" value="{{leadPhone}}" bindinput="onLeadPhoneInput"/>

View File

@@ -900,6 +900,10 @@
line-height: normal;
}
.lead-get-phone-btn::after { border: none; }
.privacy-wechat-row { margin: 24rpx 0; padding: 24rpx; background: rgba(0,206,209,0.1); border-radius: 16rpx; }
.privacy-wechat-desc { display: block; font-size: 26rpx; color: rgba(255,255,255,0.8); margin-bottom: 16rpx; }
.privacy-agree-btn { width: 100%; padding: 20rpx; background: #07C160; color: #fff; font-size: 28rpx; border-radius: 16rpx; border: none; }
.privacy-agree-btn::after { border: none; }
.lead-divider {
display: block;
font-size: 24rpx;

View File

@@ -63,6 +63,7 @@ Page({
isLoggingIn: false,
// 用户须主动勾选同意协议(审核要求:不得默认同意)
agreeProtocol: false,
showPrivacyModal: false,
// 修改昵称弹窗
showNicknameModal: false,
@@ -715,7 +716,7 @@ Page({
// 关闭登录弹窗
closeLoginModal() {
if (this.data.isLoggingIn) return
this.setData({ showLoginModal: false })
this.setData({ showLoginModal: false, showPrivacyModal: false })
},
// 微信登录(须已勾选同意协议,且做好错误处理避免审核报错)
@@ -742,6 +743,15 @@ Page({
}
},
// 微信隐私协议同意getPhoneNumber 需先同意)
handleAgreePrivacyForPhone() {
if (app._privacyResolve) {
app._privacyResolve({ buttonId: 'agree-privacy-btn', event: 'agree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
// 手机号登录(需要用户授权)
async handlePhoneLogin(e) {
// 检查是否有授权code

View File

@@ -16,7 +16,7 @@
<text wx:else class="guest-avatar-text">{{guestNickname[0] || '游'}}</text>
</view>
<text class="guest-name">{{guestNickname}}</text>
<view class="guest-login-btn" bindtap="showLogin">点击登录</view>
<view class="guest-login-btn" bindtap="showLogin">手机号一键登录</view>
</view>
<!-- 已登录:用户卡片(设计稿布局) -->
@@ -57,7 +57,7 @@
<text class="profile-stat-label">推荐好友</text>
</view>
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
<text class="profile-stat-val">{{earnings === '-' ? '--' : earnings}}</text>
<text class="profile-stat-val">{{pendingEarnings === '-' ? '--' : pendingEarnings}}</text>
<text class="profile-stat-label">我的收益</text>
</view>
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="handleMenuTap" data-id="wallet">
@@ -178,10 +178,14 @@
<view class="login-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="login-title">登录 卡若创业派对</text>
<text class="login-desc">登录后可购买章节、解锁更多内容</text>
<button class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" bindtap="handleWechatLogin" disabled="{{isLoggingIn || !agreeProtocol}}">
<button id="agree-phone-btn" class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="handlePhoneLogin" bindagreeprivacyauthorization="handleAgreePrivacyForPhone" disabled="{{isLoggingIn || !agreeProtocol}}">
<text class="btn-wechat-icon">微</text>
<text>{{isLoggingIn ? '登录中...' : '微信快捷登录'}}</text>
<text>{{isLoggingIn ? '登录中...' : '手机号一键登录'}}</text>
</button>
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="handleAgreePrivacyForPhone">同意</button>
</view>
<view class="login-modal-cancel" bindtap="closeLoginModal">取消</view>
<view class="login-agree-row" catchtap="toggleAgree">
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}"><icon wx:if="{{agreeProtocol}}" name="check" size="24" color="#34C759"></icon></view>

View File

@@ -225,6 +225,10 @@
.agree-text { color: rgba(255,255,255,0.6); }
.agree-link { color: #4FD1C5; text-decoration: underline; padding: 0 4rpx; }
.btn-wechat-disabled { opacity: 0.6; }
.privacy-wechat-row { margin: 24rpx 0; padding: 24rpx; background: rgba(0,206,209,0.1); border-radius: 16rpx; }
.privacy-wechat-desc { display: block; font-size: 26rpx; color: rgba(255,255,255,0.8); margin-bottom: 16rpx; }
.privacy-agree-btn { width: 100%; padding: 20rpx; background: #07C160; color: #fff; font-size: 28rpx; border-radius: 16rpx; border: none; }
.privacy-agree-btn::after { border: none; }
/* 头像弹窗 */
.avatar-modal .avatar-modal-title { display: block; font-size: 36rpx; font-weight: bold; color: #fff; text-align: center; margin-bottom: 16rpx; }

View File

@@ -75,6 +75,7 @@ Page({
giftRequestSn: '',
showLoginModal: false,
agreeProtocol: false,
showPrivacyModal: false,
showPosterModal: false,
isPaying: false,
isGeneratingPoster: false,
@@ -982,7 +983,7 @@ Page({
},
closeLoginModal() {
this.setData({ showLoginModal: false })
this.setData({ showLoginModal: false, showPrivacyModal: false })
},
toggleAgree() {
@@ -1018,6 +1019,16 @@ Page({
}
},
// 微信隐私协议同意getPhoneNumber 需先同意)
handleAgreePrivacyForPhone() {
const app = getApp()
if (app._privacyResolve) {
app._privacyResolve({ buttonId: 'agree-privacy-btn', event: 'agree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
// 【重构】手机号登录(标准流程)
async handlePhoneLogin(e) {
if (!e.detail.code) {

View File

@@ -131,7 +131,7 @@
<text class="paywall-desc">已阅读50%,登录后查看完整内容</text>
<view class="login-btn" bindtap="showLoginModal">
<text class="login-btn-text">立即登录</text>
<text class="login-btn-text">手机号一键登录</text>
</view>
</view>
@@ -341,11 +341,15 @@
<text class="login-title">登录 卡若创业派对</text>
<text class="login-desc">登录后可购买章节、解锁更多内容</text>
<button class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" bindtap="handleWechatLogin" disabled="{{!agreeProtocol}}">
<button id="agree-phone-btn" class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="handlePhoneLogin" bindagreeprivacyauthorization="handleAgreePrivacyForPhone" disabled="{{!agreeProtocol}}">
<text class="btn-wechat-icon">微</text>
<text>微信快捷登录</text>
<text>手机号一键登录</text>
</button>
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="handleAgreePrivacyForPhone">同意</button>
</view>
<view class="login-agree-row" catchtap="toggleAgree">
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}"><icon wx:if="{{agreeProtocol}}" name="check" size="24" color="#34C759"></icon></view>
<text class="agree-text">我已阅读并同意</text>

View File

@@ -1164,6 +1164,10 @@
padding: 0 4rpx;
}
.btn-wechat-disabled { opacity: 0.6; }
.privacy-wechat-row { margin: 24rpx 0; padding: 24rpx; background: rgba(0,206,209,0.1); border-radius: 16rpx; }
.privacy-wechat-desc { display: block; font-size: 26rpx; color: rgba(255,255,255,0.8); margin-bottom: 16rpx; }
.privacy-agree-btn { width: 100%; padding: 20rpx; background: #07C160; color: #fff; font-size: 28rpx; border-radius: 16rpx; border: none; }
.privacy-agree-btn::after { border: none; }
/* ===== 支付中加载 ===== */
.loading-box {

View File

@@ -30,7 +30,8 @@ Page({
// 绑定弹窗
showBindModal: false,
bindType: '', // phone | wechat | alipay
bindValue: ''
bindValue: '',
showPrivacyModal: false
},
onLoad() {
@@ -332,6 +333,15 @@ Page({
}
},
// 微信隐私协议同意getPhoneNumber 需先同意)
onAgreePrivacyForPhone() {
if (app._privacyResolve) {
app._privacyResolve({ buttonId: 'agree-privacy-btn', event: 'agree' })
app._privacyResolve = null
}
this.setData({ showPrivacyModal: false })
},
// 一键获取微信手机号button组件回调
async onGetPhoneNumber(e) {
console.log('[Settings] 获取手机号回调:', e.detail)
@@ -392,6 +402,11 @@ Page({
this.setData({ showBindModal: false })
},
// 跳转账户密码登录页(开发)
goToDevLogin() {
wx.navigateTo({ url: '/pages/dev-login/dev-login' })
},
// 打开切换账号弹窗(开发)
openSwitchAccountModal() {
this.setData({

View File

@@ -32,11 +32,15 @@
</view>
<view class="bind-right">
<icon wx:if="{{phoneNumber}}" name="check" size="36" color="#34C759" customClass="bind-check"></icon>
<button wx:else class="get-phone-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumber">
<button wx:else id="agree-settings-phone-btn" class="get-phone-btn" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onGetPhoneNumber" bindagreeprivacyauthorization="onAgreePrivacyForPhone">
一键获取
</button>
</view>
</view>
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacyForPhone">同意</button>
</view>
<!-- 微信号 - 简化输入 -->
<view class="bind-item">
@@ -117,6 +121,13 @@
<text class="dev-switch-desc">输入 userId 切换为其他账号调试</text>
</view>
</view>
<view class="dev-switch-card" wx:if="{{isDevMode}}" bindtap="goToDevLogin">
<view class="dev-switch-inner">
<icon name="smartphone" size="40" color="#8e8e93" customClass="dev-switch-icon"></icon>
<text class="dev-switch-text">账户密码登录</text>
<text class="dev-switch-desc">输入对方手机号登录,密码可留空</text>
</view>
</view>
<view class="logout-btn" wx:if="{{isLoggedIn}}" bindtap="handleLogout">退出登录</view>
</view>

View File

@@ -49,6 +49,10 @@
line-height: normal;
}
.get-phone-btn::after { border: none; }
.privacy-wechat-row { margin: 24rpx 0; padding: 24rpx; background: rgba(0,206,209,0.1); border-radius: 16rpx; }
.privacy-wechat-desc { display: block; font-size: 26rpx; color: rgba(255,255,255,0.8); margin-bottom: 16rpx; }
.privacy-agree-btn { width: 100%; padding: 20rpx; background: #07C160; color: #fff; font-size: 28rpx; border-radius: 16rpx; border: none; }
.privacy-agree-btn::after { border: none; }
/* 自动提现卡片 */
.auto-withdraw-card { margin-top: 24rpx; }

View File

@@ -23,12 +23,19 @@
"condition": {
"miniprogram": {
"list": [
{
"name": "开发登录",
"pathName": "pages/dev-login/dev-login",
"query": "",
"scene": null,
"launchMode": "default"
},
{
"name": "pages/member-detail/member-detail",
"pathName": "pages/member-detail/member-detail",
"query": "id=ogpTW5cVMxd5afBBtXdvmeMO8aho",
"scene": null,
"launchMode": "default"
"launchMode": "default",
"scene": null
},
{
"name": "pages/my/my",

View File

@@ -0,0 +1,166 @@
import json
import re
from dataclasses import dataclass
from typing import Any
import requests
ROUTER_GO = r"e:\\Gongsi\\Mycontent\\soul-api\\internal\\router\\router.go"
@dataclass
class Route:
group: str # "admin" | "db" | "root"
method: str
path: str # path within the group, e.g. "/chapters" or "/admin"
full_path: str # full path appended to API_BASE_URL
def _read_text(path: str) -> str:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
def extract_admin_and_db_routes() -> list[tuple[str, str]]:
"""
返回 [(method, full_path_template), ...]
full_path_template 已包含 /api/admin 或 /api/db 前缀,保留 :id 占位符。
"""
text = _read_text(ROUTER_GO)
routes: list[tuple[str, str]] = []
# 1) /api/admin 登录/鉴权/登出(不是 admin group 内)
# api.GET("/admin", ...) / api.POST("/admin", ...) / api.POST("/admin/logout", ...)
for m in re.finditer(r'api\.(GET|POST|PUT|DELETE)\("(/admin(?:/[^"]*)?)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api{m.group(2)}"))
# 2) admin groupapi.Group("/admin") + admin.(GET|POST|PUT|DELETE)("/xxx", ...)
for m in re.finditer(r'admin\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api/admin{m.group(2)}"))
# 3) db groupapi.Group("/db") + db.(GET|POST|PUT|DELETE)("/xxx", ...)
for m in re.finditer(r'db\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api/db{m.group(2)}"))
# 去重(同一 handler 可能存在重复注册)
seen: set[tuple[str, str]] = set()
out: list[tuple[str, str]] = []
for method, p in routes:
k = (method, p)
if k in seen:
continue
seen.add(k)
out.append((method, p))
return out
def replace_path_params(path: str) -> str:
# 仅用于 smoke把 :id 替换成一个固定占位
return path.replace(":id", "1")
def request_json(
session: requests.Session,
method: str,
url: str,
headers: dict[str, str],
payload: Any | None = None,
raw_body: str | None = None,
) -> tuple[int, dict[str, Any] | None, str]:
try:
if raw_body is not None:
resp = session.request(method, url, headers=headers, data=raw_body, timeout=10)
elif payload is None:
resp = session.request(method, url, headers=headers, timeout=10)
else:
resp = session.request(method, url, headers=headers, data=json.dumps(payload), timeout=10)
text = resp.text or ""
try:
data = resp.json()
except Exception:
data = None
return resp.status_code, data, text[:300]
except Exception as e:
return 0, None, f"EXC: {e}"
def main() -> None:
api_base = None
# 优先使用本地默认;需要对接测试环境时在 PowerShell 设置 SOUL_API_BASE
import os
api_base = (os.environ.get("SOUL_API_BASE") or "").rstrip("/")
if not api_base:
# 默认本机
api_base = "http://localhost:8080"
admin_username = os.environ.get("SOUL_ADMIN_USERNAME", "admin")
admin_password = os.environ.get("SOUL_ADMIN_PASSWORD", "admin123")
session = requests.Session()
# 本 smoke 默认不验证 TLS如果你用的是 https 且是自签证书,能跑通测试)
session.verify = False
# 登录拿 token
login_url = f"{api_base}/api/admin"
r = session.post(login_url, json={"username": admin_username, "password": admin_password}, timeout=10)
try:
login_data = r.json()
except Exception:
login_data = None
if r.status_code != 200 or not (login_data and login_data.get("success") is True and login_data.get("token")):
print("LOGIN_FAILED", r.status_code, r.text[:200])
return
token = login_data["token"]
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
routes = extract_admin_and_db_routes()
print(f"Found routes: {len(routes)}")
failures: list[dict[str, Any]] = []
unexpected_success: list[dict[str, Any]] = []
for method, path_template in routes:
path = replace_path_params(path_template)
url = f"{api_base}{path}"
payload = None
raw_body = None
if method in ("POST", "PUT", "DELETE"):
# 安全模式:发送明显非法 JSON尽量触发 ShouldBindJSON 失败,避免真实写入。
payload = None
raw_body = "{invalid_json"
status, data, preview = request_json(
session, method, url, headers, payload=payload, raw_body=raw_body
)
ok = status not in (404, 500) and status != 0
# POST/PUT/DELETE 在安全模式下不应返回 success=true
if method in ("POST", "PUT", "DELETE") and data and data.get("success") is True:
unexpected_success.append(
{"method": method, "path": path, "status": status, "data": data, "preview": preview}
)
if not ok:
failures.append({"method": method, "path": path, "status": status, "data": data, "preview": preview})
print("\n=== SMOKE_RESULT ===")
print("Failures(404/500/EXC):", len(failures))
if failures:
for it in failures:
print(f"- {it['method']} {it['path']} -> {it['status']}, preview={it.get('preview')}")
print("\nUnexpected success on write calls:", len(unexpected_success))
if unexpected_success:
for it in unexpected_success:
print(f"- {it['method']} {it['path']} -> success=true (status {it['status']}, preview={it.get('preview')})")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,132 @@
import re
from dataclasses import dataclass
import requests
ROUTER_GO = r"e:\\Gongsi\\Mycontent\\soul-api\\internal\\router\\router.go"
@dataclass
class Check:
method: str
path: str
status: int
preview: str
def _read_text(path: str) -> str:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
def extract_routes() -> list[tuple[str, str]]:
"""
返回 [(method, full_path_template), ...]
full_path_template 保留 :id 占位符。
"""
text = _read_text(ROUTER_GO)
routes: list[tuple[str, str]] = []
# /api/admin 登录/鉴权/登出
for m in re.finditer(r'api\.(GET|POST|PUT|DELETE)\("(/admin(?:/[^"]*)?)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api{m.group(2)}"))
# /api/admin 组
for m in re.finditer(r'admin\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api/admin{m.group(2)}"))
# /api/db 组
for m in re.finditer(r'db\.(GET|POST|PUT|DELETE)\("(/[^"]*)",\s*handler\.[A-Za-z0-9_]+', text):
routes.append((m.group(1), f"/api/db{m.group(2)}"))
# 去重
seen = set()
out = []
for method, p in routes:
if (method, p) in seen:
continue
seen.add((method, p))
out.append((method, p))
return out
def replace_path_params(path: str) -> str:
return path.replace(":id", "1")
def main() -> None:
import os
api_base = (os.environ.get("SOUL_API_BASE") or "http://localhost:8080").rstrip("/")
session = requests.Session()
session.verify = False # 如为 https 自签证书也可探测
routes = extract_routes()
print(f"Found routes: {len(routes)}")
failures: list[Check] = []
unexpected: list[Check] = []
headers = {"Content-Type": "application/json"}
# 先验证登录接口是否通(只对 /api/admin POST 登录做一次带凭证的检查)
admin_username = os.environ.get("SOUL_ADMIN_USERNAME", "admin")
admin_password = os.environ.get("SOUL_ADMIN_PASSWORD", "admin123")
login_url = f"{api_base}/api/admin"
r_login = session.post(
login_url,
json={"username": admin_username, "password": admin_password},
headers=headers,
timeout=10,
)
try:
login_data = r_login.json()
except Exception:
login_data = None
if r_login.status_code != 200 or not (login_data and login_data.get("success") is True and login_data.get("token")):
failures.append(Check("POST", "/api/admin", r_login.status_code, (r_login.text or "")[:200]))
print("LOGIN_CHECK_FAILED后续路由鉴权探测可能不准确。")
for method, path_template in routes:
path = replace_path_params(path_template)
url = f"{api_base}{path}"
# 仅对登录接口放行;其他都不带 token避免触发写操作
json_payload = None
if path == "/api/admin" and method == "POST":
# 已在上面验证登录;这里跳过
continue
if method in ("POST", "PUT"):
# 发空 body通常也会被 AdminAuth 在更早阶段拦截
json_payload = {}
try:
resp = session.request(method, url, headers=headers, json=json_payload, timeout=10)
status = resp.status_code
preview = (resp.text or "")[:200].replace("\n", " ")
except Exception as e:
failures.append(Check(method, path, 0, f"EXC: {e}"))
continue
# 非登录接口:预期 AdminAuth 拦截 => 401 或 403
if status not in (401, 403):
unexpected.append(Check(method, path, status, preview))
print("\n=== AUTHLESS_SMOKE_RESULT ===")
print("Failures(0/404/500 等异常/网络异常):", len(failures))
for it in failures[:30]:
print(f"- {it.method} {it.path} -> {it.status}, preview={it.preview}")
print("Unexpected (非 401/403):", len(unexpected))
for it in unexpected[:30]:
print(f"- {it.method} {it.path} -> {it.status}, preview={it.preview}")
if len(unexpected) > 30:
print("... truncated")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

914
soul-admin/dist/assets/index-uFsIOb55.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-B_-tFdMt.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CCZxs-BW.css">
<script type="module" crossorigin src="/assets/index-uFsIOb55.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BHvHUv2T.css">
</head>
<body>
<div id="root"></div>

View File

@@ -2,7 +2,7 @@
* 章节树 - 仿照 catalog 设计,支持篇、章、节拖拽排序
* 整行可拖拽;节和章可跨篇
*/
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useState } from 'react'
import { ChevronRight, ChevronDown, BookOpen, Edit3, Trash2, GripVertical, Plus, Star } from 'lucide-react'
import { Button } from '@/components/ui/button'

View File

@@ -16,6 +16,7 @@ import {
Eye,
Undo2,
Settings,
Zap,
} from 'lucide-react'
import { ReferralSettingsPage } from '@/pages/referral-settings/ReferralSettingsPage'
import { Pagination } from '@/components/ui/Pagination'
@@ -23,6 +24,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
@@ -86,6 +88,7 @@ interface Withdrawal {
account?: string
name?: string
status: string
remark?: string
createdAt?: string
processedAt?: string
}
@@ -146,6 +149,8 @@ export function DistributionPage() {
const [rejectWithdrawalId, setRejectWithdrawalId] = useState<string | null>(null)
const [rejectReason, setRejectReason] = useState('')
const [rejectLoading, setRejectLoading] = useState(false)
const [enableAutoApprove, setEnableAutoApprove] = useState(false)
const [autoApproveLoading, setAutoApproveLoading] = useState(false)
const [giftPayRequests, setGiftPayRequests] = useState<Array<{
id: string
requestSn: string
@@ -189,6 +194,10 @@ export function DistributionPage() {
}
}, [page, pageSize, statusFilter, searchTerm, giftPayPage, giftPayStatusFilter])
useEffect(() => {
if (activeTab === 'withdrawals') loadAutoApprove()
}, [activeTab])
async function loadInitialData() {
setError(null)
try {
@@ -408,6 +417,39 @@ export function DistributionPage() {
}
}
async function loadAutoApprove() {
try {
const data = await get<{ success?: boolean; enableAutoApprove?: boolean }>(
'/api/admin/withdrawals/auto-approve'
)
if (data?.success && typeof data.enableAutoApprove === 'boolean') {
setEnableAutoApprove(data.enableAutoApprove)
}
} catch {
// ignore
}
}
async function toggleAutoApprove(checked: boolean) {
setAutoApproveLoading(true)
try {
const data = await put<{ success?: boolean; error?: string }>(
'/api/admin/withdrawals/auto-approve',
{ enableAutoApprove: checked }
)
if (data?.success) {
setEnableAutoApprove(checked)
toast.success(checked ? '已开启自动审批,新提现将自动打款' : '已关闭自动审批')
} else {
toast.error('更新失败: ' + (data?.error ?? ''))
}
} catch {
toast.error('更新失败')
} finally {
setAutoApproveLoading(false)
}
}
function closeRejectDialog() {
if (rejectWithdrawalId) toast.info('已取消操作')
setRejectWithdrawalId(null)
@@ -1187,6 +1229,16 @@ export function DistributionPage() {
<option value="completed"></option>
<option value="rejected"></option>
</select>
<div className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[#0f2137] border border-gray-700/50 shrink-0">
<Zap className="w-4 h-4 text-[#38bdac]" />
<span className="text-sm text-gray-300"></span>
<Switch
checked={enableAutoApprove}
onCheckedChange={toggleAutoApprove}
disabled={autoApproveLoading}
className="data-[state=checked]:bg-[#38bdac]"
/>
</div>
<Button
type="button"
variant="outline"
@@ -1213,6 +1265,7 @@ export function DistributionPage() {
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-right font-medium"></th>
</tr>
</thead>
@@ -1267,6 +1320,18 @@ export function DistributionPage() {
: '-'}
</td>
<td className="p-4">{getStatusBadge(withdrawal.status)}</td>
<td className="p-4 max-w-[160px]">
<span
className={`text-xs ${
withdrawal.status === 'rejected' || withdrawal.status === 'failed'
? 'text-red-400'
: 'text-gray-400'
}`}
title={withdrawal.remark}
>
{withdrawal.remark || '-'}
</span>
</td>
<td className="p-4 text-right">
{withdrawal.status === 'pending' && (
<div className="flex gap-2 justify-end">

View File

@@ -1,4 +1,4 @@
import toast from '@/utils/toast'
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
@@ -15,6 +15,7 @@ interface ReferralConfig {
minWithdrawAmount: number
bindingDays: number
userDiscount: number
withdrawFee: number
enableAutoWithdraw: boolean
vipOrderShareVip: number
vipOrderShareNonVip: number
@@ -25,6 +26,7 @@ const DEFAULT: ReferralConfig = {
minWithdrawAmount: 10,
bindingDays: 30,
userDiscount: 5,
withdrawFee: 5,
enableAutoWithdraw: false,
vipOrderShareVip: 20,
vipOrderShareNonVip: 10,
@@ -49,6 +51,7 @@ export function ReferralSettingsPage(_props?: ReferralSettingsPageProps & { embe
minWithdrawAmount: c.minWithdrawAmount ?? 10,
bindingDays: c.bindingDays ?? 30,
userDiscount: c.userDiscount ?? 5,
withdrawFee: c.withdrawFee ?? 5,
enableAutoWithdraw: c.enableAutoWithdraw ?? false,
vipOrderShareVip: c.vipOrderShareVip ?? 20,
vipOrderShareNonVip: c.vipOrderShareNonVip ?? 10,
@@ -67,6 +70,7 @@ export function ReferralSettingsPage(_props?: ReferralSettingsPageProps & { embe
minWithdrawAmount: Number(config.minWithdrawAmount) || 0,
bindingDays: Number(config.bindingDays) || 0,
userDiscount: Number(config.userDiscount) || 0,
withdrawFee: Number(config.withdrawFee) ?? 5,
enableAutoWithdraw: Boolean(config.enableAutoWithdraw),
vipOrderShareVip: Number(config.vipOrderShareVip) || 20,
vipOrderShareNonVip: Number(config.vipOrderShareNonVip) || 10,
@@ -265,6 +269,22 @@ export function ReferralSettingsPage(_props?: ReferralSettingsPageProps & { embe
</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300">%</Label>
<Input
type="number"
min={0}
max={100}
step={0.5}
className="bg-[#0a1628] border-gray-700 text-white"
value={config.withdrawFee}
onChange={handleNumberChange('withdrawFee')}
/>
<p className="text-xs text-gray-500">
5 100 95
</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300 flex items-center gap-2">

View File

@@ -44,7 +44,6 @@ import {
ChevronUp,
Crown,
Tag,
CircleDot,
UserPlus as LeadIcon,
} from 'lucide-react'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'

View File

@@ -33,6 +33,7 @@ interface Withdrawal {
account?: string
name?: string
userConfirmedAt?: string | null
remark?: string
userCommissionInfo?: {
totalCommission: number
withdrawnEarnings: number
@@ -247,14 +248,14 @@ export function WithdrawalsPage() {
<p className="text-gray-400 mt-1"></p>
</div>
<Button
variant="outline"
onClick={loadWithdrawals}
disabled={loading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
variant="outline"
onClick={loadWithdrawals}
disabled={loading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 分账规则说明 */}
@@ -277,7 +278,7 @@ export function WithdrawalsPage() {
</p>
<p>
<span className="text-[#38bdac]"></span>
广-
</p>
</div>
</div>
@@ -365,6 +366,7 @@ export function WithdrawalsPage() {
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-right font-medium"></th>
@@ -444,9 +446,18 @@ export function WithdrawalsPage() {
</td>
<td className="p-4">
{getStatusBadge(w.status)}
{w.errorMessage && (
<p className="text-xs text-red-400 mt-1">{w.errorMessage}</p>
)}
</td>
<td className="p-4 max-w-[180px]">
<span
className={`text-xs ${
w.status === 'rejected' || w.status === 'failed'
? 'text-red-400'
: 'text-gray-400'
}`}
title={w.remark}
>
{w.remark || '-'}
</span>
</td>
<td className="p-4 text-gray-400">
{w.processedAt ? new Date(w.processedAt).toLocaleString() : '-'}

View File

@@ -7,16 +7,19 @@ soul-api 一键部署到宝塔【测试环境】
- 默认:本地 go build → Dockerfile.local 打镜像(--pull=false 不拉 base 镜像)
- 首次部署前请本地先拉好alpine:3.19、redis:7-alpine后续一律用本地缓存
种模式均部署到测试环境 /www/wwwroot/self/soul-dev
种模式:
- runner推荐容器内红蓝切换宝塔固定 proxy_pass 到 9001无需改配置
- 使用 network_mode: host避免 iptables 端口映射问题
- 首次:服务器执行 deploy/runner-init.sh 构建并启动容器
- 部署python devloy.py --mode runner
- docker默认本地 go build → Dockerfile.local 打镜像 → 宿主机蓝绿切换
- 需宿主机改 Nginx proxy_pass 或宝塔 API
- binaryGo 二进制 + 宝塔 soulDev 项目,用 .env.development
- docker默认本地 go build → Dockerfile.local 打镜像 → 蓝绿无缝切换
- 镜像内包含二进制、soul-api/certs/ → /app/certs/、选定环境文件 → /app/.env
- 环境文件:--env-file 或 DOCKER_ENV_FILE否则自动 .env.development > .env.production > .env.dockerignore 已放行)
- 不加 --docker-in-go 时:本地 Go 编译 + 本地 base 镜像,不联网
- 加 --docker-in-go 时:在 Docker 内用 golang 镜像编译(需本地已有 golang:1.25
环境变量DEPLOY_DOCKER_PATH、DEPLOY_NGINX_CONF、DEPLOY_HOST 等
环境变量DEPLOY_DOCKER_PATH、DEPLOY_NGINX_CONF、DEPLOY_HOST、DEPLOY_RUNNER_CONTAINER
"""
from __future__ import print_function
@@ -65,12 +68,14 @@ def get_cfg():
bt_url = (os.environ.get("BT_PANEL_URL") or "").strip().rstrip("/")
if not bt_url:
bt_url = "https://%s:9988" % host
deploy_path = os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH).rstrip("/")
return {
"host": host,
"user": os.environ.get("DEPLOY_USER", "root"),
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH),
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_PATH).rstrip("/"),
"deploy_path": deploy_path,
"bt_panel_url": bt_url,
"bt_api_key": os.environ.get("BT_API_KEY", BT_API_KEY_DEFAULT),
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulDev"),
@@ -171,6 +176,49 @@ def set_env_mini_program_state(env_path, state):
f.writelines(new_lines)
def set_env_key(env_path, key, value):
"""将 .env 中指定 key 设为 value"""
if not os.path.isfile(env_path):
return
with open(env_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
found = False
new_lines = []
for line in lines:
s = line.strip()
if "=" in s and s.split("=", 1)[0].strip() == key:
new_lines.append("%s=%s\n" % (key, value))
found = True
else:
new_lines.append(line)
if not found:
new_lines.append("%s=%s\n" % (key, value))
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
f.writelines(new_lines)
def set_env_redis_url(env_path, url):
"""将 .env 中的 REDIS_URL 设为指定值"""
if not os.path.isfile(env_path):
return
key = "REDIS_URL"
with open(env_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
found = False
new_lines = []
for line in lines:
s = line.strip()
if "=" in s and s.split("=", 1)[0].strip() == key:
new_lines.append("%s=%s\n" % (key, url))
found = True
else:
new_lines.append(line)
if not found:
new_lines.append("%s=%s\n" % (key, url))
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
f.writelines(new_lines)
def resolve_binary_pack_env_src(root):
"""binary 模式 tar 包内 .env 的来源,与 Docker 自动优先级一致。"""
for name in (".env.development", ".env.production", ".env"):
@@ -180,6 +228,52 @@ def resolve_binary_pack_env_src(root):
return None, None
def pack_runner_deploy(root, binary_path, include_env=True):
"""打包 Runner 部署包:二进制 + .env + certs供容器内红蓝切换"""
print("[2/4] 打包 Runner 部署包 ...")
staging = tempfile.mkdtemp(prefix="soul_api_runner_deploy_")
try:
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
staging_env = os.path.join(staging, ".env")
if include_env:
env_src, env_label = resolve_binary_pack_env_src(root)
if env_src:
shutil.copy2(env_src, staging_env)
print(" [已包含] %s -> .env" % env_label)
else:
env_example = os.path.join(root, ".env.example")
if os.path.isfile(env_example):
shutil.copy2(env_example, staging_env)
print(" [已包含] .env.example -> .env")
if os.path.isfile(staging_env):
set_env_port(staging_env, 18081)
set_env_redis_url(staging_env, "redis://:soul-docker-redis@127.0.0.1:6379/0")
set_env_mini_program_state(staging_env, "developer")
set_env_key(staging_env, "UPLOAD_DIR", "/app/uploads")
print(" [已设置] PORT=18081, REDIS_URL, UPLOAD_DIR=/app/uploads, WECHAT_MINI_PROGRAM_STATE=developer")
certs_src = os.path.join(root, "certs")
if os.path.isdir(certs_src):
certs_dst = os.path.join(staging, "certs")
os.makedirs(certs_dst, exist_ok=True)
for f in os.listdir(certs_src):
src = os.path.join(certs_src, f)
if os.path.isfile(src):
shutil.copy2(src, os.path.join(certs_dst, f))
print(" [已包含] certs/")
tarball = os.path.join(tempfile.gettempdir(), "soul_api_deploy.tar.gz")
with tarfile.open(tarball, "w:gz") as tf:
for name in os.listdir(staging):
p = os.path.join(staging, name)
tf.add(p, arcname=name)
print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, os.path.getsize(tarball) / 1024 / 1024))
return tarball
except Exception as e:
print(" [失败] 打包异常:", str(e))
return None
finally:
shutil.rmtree(staging, ignore_errors=True)
def pack_deploy(root, binary_path, include_env=True):
"""打包二进制和 .env 为 tar.gz"""
print("[2/4] 打包部署文件 ...")
@@ -277,8 +371,8 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
else:
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
sftp = client.open_sftp()
remote_tar = "/tmp/soul_api_deploy.tar.gz"
project_path = cfg["project_path"]
remote_tar = project_path + "/soul_api_deploy.tar.gz"
sftp.put(tarball_path, remote_tar)
sftp.close()
@@ -524,7 +618,8 @@ def pack_docker_image(root):
def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_method="ssh"):
"""上传镜像与配置到服务器执行蓝绿部署。deploy_method: ssh=脚本内 Nginx 切换, btapi=宝塔 API 更新 Nginx"""
deploy_path = os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH)
deploy_path = cfg.get("deploy_path") or os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH)
deploy_path = deploy_path.rstrip("/")
nginx_conf = os.environ.get("DEPLOY_NGINX_CONF", DEPLOY_NGINX_CONF)
script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -542,7 +637,7 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=30)
sftp = client.open_sftp()
remote_tar = "/tmp/soul_api_image.tar.gz"
remote_tar = deploy_path + "/soul_api_image.tar.gz"
sftp.put(image_tar_path, remote_tar)
print(" [已上传] 镜像 tar.gz")
@@ -611,13 +706,157 @@ def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_metho
client.close()
# ==================== Runner 部署(容器内红蓝切换) ====================
RUNNER_CONTAINER = os.environ.get("DEPLOY_RUNNER_CONTAINER", "soul-api-runner")
CHUNK_SIZE = 65536
def _ssh_connect(cfg, timeout=30):
"""建立 SSH 连接"""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=timeout)
else:
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=timeout)
return client
def deploy_runner_container(cfg):
"""推送 Runner 容器到服务器:本地构建镜像 → 上传 → docker load → compose up"""
print("=" * 60)
print(" soul-api Runner 容器推送(首次或更新容器)")
print("=" * 60)
deploy_path = cfg.get("deploy_path") or os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH)
deploy_path = deploy_path.rstrip("/")
root = os.path.dirname(os.path.abspath(__file__))
print("[1/4] 本地构建 Runner 镜像 ...")
try:
r = subprocess.run(
["docker", "build", "-f", "deploy/Dockerfile.runner", "-t", "soul-api-runner:latest", "."],
cwd=root, shell=False, timeout=120, capture_output=True, text=True, encoding="utf-8", errors="replace"
)
if r.returncode != 0:
print(" [失败] docker build 失败:", (r.stderr or "")[-500:])
return False
except FileNotFoundError:
print(" [失败] 未找到 docker 命令")
return False
print("[2/4] 导出镜像为 tar.gz ...")
import gzip
img_tar = os.path.join(tempfile.gettempdir(), "soul_runner_image.tar.gz")
try:
r = subprocess.run(["docker", "save", "soul-api-runner:latest"], capture_output=True, timeout=180, cwd=root)
if r.returncode != 0:
print(" [失败] docker save 失败")
return False
with gzip.open(img_tar, "wb") as f:
f.write(r.stdout)
except Exception as e:
print(" [失败] 导出异常:", str(e))
return False
print("[3/4] SSH 上传镜像并加载 ...")
client = None
try:
client = _ssh_connect(cfg, timeout=60)
sftp = client.open_sftp()
remote_img = deploy_path + "/soul_runner_image.tar.gz"
sftp.put(img_tar, remote_img)
sftp.close()
os.remove(img_tar)
script_dir = os.path.dirname(os.path.abspath(__file__))
compose_standalone = os.path.join(script_dir, "deploy", "docker-compose.runner.standalone.yml")
sftp = client.open_sftp()
sftp.put(compose_standalone, deploy_path + "/docker-compose.runner.standalone.yml")
sftp.close()
cmd = (
"mkdir -p %s && cd %s && gunzip -c soul_runner_image.tar.gz | docker load && "
"docker compose -f docker-compose.runner.standalone.yml up -d && "
"rm -f soul_runner_image.tar.gz && echo OK"
) % (deploy_path, deploy_path)
stdin, stdout, stderr = client.exec_command(cmd, timeout=300)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
exit_status = stdout.channel.recv_exit_status()
print(out)
if err:
print(err[:500])
if exit_status != 0 or "OK" not in out:
print(" [失败] 远程执行退出码:", exit_status)
return False
except Exception as e:
print(" [失败] SSH 错误:", str(e))
return False
finally:
if client:
client.close()
print("[4/4] Runner 容器已启动")
print(" 宝塔 proxy_pass 保持 127.0.0.1:9001")
return True
def upload_and_deploy_runner(cfg, tarball_path):
"""将部署包通过 SSH 管道直接传入容器,宿主机不落盘,防止机密泄露"""
print("[3/4] 管道直传容器(宿主机不落盘)...")
if not cfg.get("password") and not cfg.get("ssh_key"):
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return False
client = None
try:
client = _ssh_connect(cfg, timeout=60)
file_size = os.path.getsize(tarball_path)
print(" 传输 %.2f MB 到容器 /tmp/incoming.tar.gz ..." % (file_size / 1024 / 1024))
stdin, stdout, stderr = client.exec_command(
"docker exec -i %s sh -c 'cat > /tmp/incoming.tar.gz'" % RUNNER_CONTAINER,
timeout=300,
)
with open(tarball_path, "rb") as f:
while True:
chunk = f.read(CHUNK_SIZE)
if not chunk:
break
stdin.write(chunk)
stdin.channel.shutdown_write()
stdout.channel.recv_exit_status()
err = stderr.read().decode("utf-8", errors="replace")
if err and "Error" in err:
print(" [失败] 管道写入异常:", err[:300])
return False
print(" [已传入容器] 执行红蓝切换 ...")
stdin, stdout, stderr = client.exec_command(
"docker exec %s /app/deploy.sh /tmp/incoming.tar.gz" % RUNNER_CONTAINER,
timeout=180,
)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
exit_status = stdout.channel.recv_exit_status()
print(out)
if err:
print(err[:800])
if exit_status != 0:
print(" [失败] 远程部署退出码:", exit_status)
return False
return True
except Exception as e:
print(" [失败] SSH 错误: %s" % str(e))
return False
finally:
if client:
client.close()
# ==================== 主函数 ====================
def main():
parser = argparse.ArgumentParser(description="soul-api 测试环境一键部署到宝塔")
parser.add_argument("--mode", choices=("binary", "docker"), default="docker",
help="docker=Docker 蓝绿部署 (默认), binary=Go 二进制")
parser.add_argument("--mode", choices=("binary", "docker", "runner", "start"), default="runner",
help="runner=仅上传代码(默认), start=容器+代码, docker=Docker蓝绿, binary=Go二进制")
parser.add_argument("--no-build", action="store_true", help="跳过本地编译/构建")
parser.add_argument("--no-env", action="store_true",
help="binary: 不打进 tardocker: 不上传服务器目录 .env.production镜像内配置不变")
@@ -630,12 +869,52 @@ def main():
help="[docker] 部署方式: ssh=脚本内 Nginx 切换, btapi=宝塔 API 更新 Nginx 配置并重载 (默认 ssh)")
parser.add_argument("--env-file", default=None, metavar="NAME",
help="[docker] 打入镜像的环境文件名(默认自动:.env.development > .env.production > .env")
parser.add_argument("--init-runner", action="store_true",
help="[runner] 等同于 --mode start先推送容器再部署代码")
args = parser.parse_args()
script_dir = os.path.dirname(os.path.abspath(__file__))
root = script_dir
cfg = get_cfg()
# start = 容器+代码runner = 仅代码
init_runner = args.init_runner or (args.mode == "start")
if args.mode == "start":
args.mode = "runner"
if args.mode == "runner":
print("=" * 60)
print(" soul-api Runner 模式(容器内红蓝切换,宝塔固定 9001")
print("=" * 60)
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
print(" 容器: %s" % RUNNER_CONTAINER)
print("=" * 60)
if init_runner:
if not deploy_runner_container(cfg):
return 1
binary_path = os.path.join(root, "soul-api")
if not args.no_build:
p = run_build(root)
if not p:
return 1
else:
if not os.path.isfile(binary_path):
print("[错误] 未找到 soul-api 二进制")
return 1
print("[1/4] 跳过编译")
tarball = pack_runner_deploy(root, binary_path, include_env=not args.no_env)
if not tarball:
return 1
if not upload_and_deploy_runner(cfg, tarball):
return 1
try:
os.remove(tarball)
except Exception:
pass
print("")
print(" 部署完成!宝塔代理 9001 无需修改")
return 0
if args.mode == "docker":
docker_path = os.environ.get("DEPLOY_DOCKER_PATH", DEPLOY_DOCKER_PATH)
print("=" * 60)

View File

@@ -8,13 +8,66 @@ import (
"strconv"
"time"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AdminWithdrawalsAutoApproveGet GET /api/admin/withdrawals/auto-approve 获取自动审批开关状态
func AdminWithdrawalsAutoApproveGet(c *gin.Context) {
db := database.DB()
enabled := false
var refCfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil {
var val map[string]interface{}
if err := json.Unmarshal(refCfg.ConfigValue, &val); err == nil {
if v, ok := val["enableAutoWithdraw"].(bool); ok {
enabled = v
}
}
}
c.JSON(http.StatusOK, gin.H{"success": true, "enableAutoApprove": enabled})
}
// AdminWithdrawalsAutoApprovePut PUT /api/admin/withdrawals/auto-approve 设置自动审批开关
func AdminWithdrawalsAutoApprovePut(c *gin.Context) {
var body struct {
EnableAutoApprove bool `json:"enableAutoApprove"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
db := database.DB()
var refCfg model.SystemConfig
val := map[string]interface{}{
"distributorShare": float64(90), "minWithdrawAmount": float64(10), "bindingDays": float64(30),
"userDiscount": float64(5), "withdrawFee": float64(5), "enableAutoWithdraw": body.EnableAutoApprove,
"vipOrderShareVip": float64(20), "vipOrderShareNonVip": float64(10),
}
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil {
if err := json.Unmarshal(refCfg.ConfigValue, &val); err == nil {
val["enableAutoWithdraw"] = body.EnableAutoApprove
}
}
valBytes, _ := json.Marshal(val)
desc := "分销 / 推广规则配置"
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err != nil {
refCfg = model.SystemConfig{ConfigKey: "referral_config", ConfigValue: valBytes, Description: &desc}
_ = db.Create(&refCfg)
} else {
refCfg.ConfigValue = valBytes
refCfg.Description = &desc
_ = db.Save(&refCfg)
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "enableAutoApprove": body.EnableAutoApprove, "message": "已更新"})
}
// AdminWithdrawalsList GET /api/admin/withdrawals支持分页 page、pageSize筛选 status
func AdminWithdrawalsList(c *gin.Context) {
statusFilter := c.Query("status")
@@ -97,11 +150,24 @@ func AdminWithdrawalsList(c *gin.Context) {
if userAvatar != nil {
avStr = resolveAvatarURL(*userAvatar)
}
// 备注:失败时显示 failReason/errorMessage否则显示用户 remark
remark := ""
if st == "rejected" || st == "failed" {
if w.FailReason != nil && *w.FailReason != "" {
remark = *w.FailReason
} else if w.ErrorMessage != nil && *w.ErrorMessage != "" {
remark = *w.ErrorMessage
}
}
if remark == "" && w.Remark != nil && *w.Remark != "" {
remark = *w.Remark
}
withdrawals = append(withdrawals, gin.H{
"id": w.ID, "userId": w.UserID, "userName": userName, "userAvatar": avStr,
"amount": w.Amount, "status": st, "createdAt": w.CreatedAt,
"method": "wechat", "account": account,
"userConfirmedAt": userConfirmedAt,
"remark": remark,
})
}
@@ -127,6 +193,109 @@ func AdminWithdrawalsList(c *gin.Context) {
})
}
// doApproveWithdrawal 执行提现审批逻辑(打款),供 AdminWithdrawalsAction 与自动审批共用
// 返回 (successMessage, error),成功时 err 为 nil
func doApproveWithdrawal(db *gorm.DB, id string) (string, error) {
now := time.Now()
var w model.Withdrawal
if err := db.Where("id = ?", id).First(&w).Error; err != nil {
return "", fmt.Errorf("提现记录不存在")
}
st := ""
if w.Status != nil {
st = *w.Status
}
if st != "pending" && st != "processing" && st != "pending_confirm" {
return "", fmt.Errorf("当前状态不允许批准")
}
openID := ""
if w.WechatOpenid != nil && *w.WechatOpenid != "" {
openID = *w.WechatOpenid
}
if openID == "" {
var u model.User
if err := db.Where("id = ?", w.UserID).First(&u).Error; err == nil && u.OpenID != nil {
openID = *u.OpenID
}
}
if openID == "" {
return "", fmt.Errorf("用户未绑定微信 openid无法打款")
}
_, totalCommission, withdrawn, pending, _ := computeAvailableWithdraw(db, w.UserID)
availableRaw := totalCommission - withdrawn - pending
if availableRaw < -0.01 {
return "", fmt.Errorf("用户当前可提现不足,无法批准")
}
remark := "提现"
if w.Remark != nil && *w.Remark != "" {
remark = *w.Remark
}
withdrawFee := 0.0
var refCfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil {
var refVal map[string]interface{}
if err := json.Unmarshal(refCfg.ConfigValue, &refVal); err == nil {
if v, ok := refVal["withdrawFee"].(float64); ok {
withdrawFee = v / 100
}
}
}
actualAmount := w.Amount * (1 - withdrawFee)
if actualAmount < 0.01 {
actualAmount = 0.01
}
amountFen := int(actualAmount * 100)
if amountFen < 1 {
return "", fmt.Errorf("提现金额异常")
}
params := wechat.FundAppTransferParams{
OutBillNo: w.ID, OpenID: openID, Amount: amountFen, Remark: remark,
NotifyURL: "", TransferSceneId: "1005",
}
result, err := wechat.InitiateTransferByFundApp(params)
if err != nil {
errMsg := err.Error()
if errMsg == "支付/转账未初始化,请先调用 wechat.Init" || errMsg == "转账客户端未初始化" {
_ = db.Model(&w).Updates(map[string]interface{}{"status": "success", "processed_at": now}).Error
return "已标记为已打款。当前未接入微信转账,请线下打款。", nil
}
_ = db.Model(&w).Updates(map[string]interface{}{
"status": "failed", "fail_reason": errMsg, "error_message": errMsg, "processed_at": now,
}).Error
return "", fmt.Errorf("%s", errMsg)
}
if result.OutBillNo == "" {
failMsg := "微信未返回商户单号,请检查商户平台(如 IP 白名单)或查看服务端日志"
_ = db.Model(&w).Updates(map[string]interface{}{
"status": "failed", "fail_reason": failMsg, "error_message": failMsg, "processed_at": now,
}).Error
return "", fmt.Errorf("%s", failMsg)
}
rowStatus := "processing"
if result.State == "WAIT_USER_CONFIRM" {
rowStatus = "pending_confirm"
}
upd := map[string]interface{}{
"status": rowStatus, "detail_no": result.OutBillNo, "batch_no": result.OutBillNo,
"batch_id": result.TransferBillNo, "processed_at": now,
}
if result.PackageInfo != "" {
upd["package_info"] = result.PackageInfo
}
if err := db.Model(&w).Updates(upd).Error; err != nil {
return "", fmt.Errorf("更新状态失败: %w", err)
}
if openID != "" {
go func() {
ctx := context.Background()
if e := wechat.SendWithdrawSubscribeMessage(ctx, openID, w.Amount, true); e != nil {
fmt.Printf("[AdminWithdrawals] 订阅消息发送失败 id=%s: %v\n", id, e)
}
}()
}
return "已发起打款,微信处理中", nil
}
// AdminWithdrawalsAction PUT /api/admin/withdrawals 审核/打款
// approve先调微信转账接口打款成功则标为 processing失败则标为 failed 并返回错误。
// 若未初始化微信转账客户端,则仅将状态标为 success线下打款后批准
@@ -169,167 +338,12 @@ func AdminWithdrawalsAction(c *gin.Context) {
return
case "approve":
var w model.Withdrawal
if err := db.Where("id = ?", body.ID).First(&w).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "提现记录不存在"})
return
}
st := ""
if w.Status != nil {
st = *w.Status
}
if st != "pending" && st != "processing" && st != "pending_confirm" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "当前状态不允许批准"})
return
}
openID := ""
if w.WechatOpenid != nil && *w.WechatOpenid != "" {
openID = *w.WechatOpenid
}
if openID == "" {
var u model.User
if err := db.Where("id = ?", w.UserID).First(&u).Error; err == nil && u.OpenID != nil {
openID = *u.OpenID
}
}
if openID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "用户未绑定微信 openid无法打款"})
return
}
// 批准前二次校验可提现金额,与申请时口径一致,防止退款/冲正后超额打款
available, _, _, _, _ := computeAvailableWithdraw(db, w.UserID)
if w.Amount > available {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": "用户当前可提现不足,无法批准",
"message": fmt.Sprintf("用户当前可提现 ¥%.2f,本笔申请 ¥%.2f,可能因退款/冲正导致。请核对后再批或联系用户。", available, w.Amount),
})
return
}
// 调用微信转账接口按提现手续费扣除后打款例如申请100元、手续费5%则实际打款95元
remark := "提现"
if w.Remark != nil && *w.Remark != "" {
remark = *w.Remark
}
withdrawFee := 0.0
var refCfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil {
var refVal map[string]interface{}
if err := json.Unmarshal(refCfg.ConfigValue, &refVal); err == nil {
if v, ok := refVal["withdrawFee"].(float64); ok {
withdrawFee = v / 100
}
}
}
actualAmount := w.Amount * (1 - withdrawFee)
if actualAmount < 0.01 {
actualAmount = 0.01
}
amountFen := int(actualAmount * 100)
if amountFen < 1 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "提现金额异常"})
return
}
outBillNo := w.ID // 商户单号,回调时 out_bill_no 即此值,用于更新该条提现
params := wechat.FundAppTransferParams{
OutBillNo: outBillNo,
OpenID: openID,
Amount: amountFen,
Remark: remark,
NotifyURL: "", // 由 wechat 包从配置读取 WechatTransferURL
TransferSceneId: "1005",
}
result, err := wechat.InitiateTransferByFundApp(params)
msg, err := doApproveWithdrawal(db, body.ID)
if err != nil {
errMsg := err.Error()
fmt.Printf("[AdminWithdrawals] 发起转账失败 id=%s: %s\n", body.ID, errMsg)
// 未初始化或未配置转账:仅标记为已打款并提示线下处理
if errMsg == "支付/转账未初始化,请先调用 wechat.Init" || errMsg == "转账客户端未初始化" {
_ = db.Model(&w).Updates(map[string]interface{}{
"status": "success",
"processed_at": now,
}).Error
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "已标记为已打款。当前未接入微信转账,请线下打款。",
})
return
}
// 微信接口报错或其它失败:把微信/具体原因返回给管理端展示,不返回「微信处理中」
failMsg := errMsg
_ = db.Model(&w).Updates(map[string]interface{}{
"status": "failed",
"fail_reason": failMsg,
"error_message": failMsg,
"processed_at": now,
}).Error
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": "发起打款失败",
"message": failMsg, // 管理端直接展示微信报错信息(如 IP 白名单、参数错误等)
})
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "message": err.Error()})
return
}
// 防护:微信未返回商户单号时也按失败返回,避免管理端显示「已发起打款」却无单号
if result.OutBillNo == "" {
failMsg := "微信未返回商户单号,请检查商户平台(如 IP 白名单)或查看服务端日志"
_ = db.Model(&w).Updates(map[string]interface{}{
"status": "failed",
"fail_reason": failMsg,
"error_message": failMsg,
"processed_at": now,
}).Error
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": "发起打款失败",
"message": failMsg,
})
return
}
// 打款已受理微信同步返回立即落库商户单号、微信单号、package_info、按 state 设 status不依赖回调
fmt.Printf("[AdminWithdrawals] 微信已受理 id=%s out_bill_no=%s transfer_bill_no=%s state=%s\n", body.ID, result.OutBillNo, result.TransferBillNo, result.State)
rowStatus := "processing"
if result.State == "WAIT_USER_CONFIRM" {
rowStatus = "pending_confirm" // 待用户在小程序点击确认收款,回调在用户确认后才触发
}
upd := map[string]interface{}{
"status": rowStatus,
"detail_no": result.OutBillNo,
"batch_no": result.OutBillNo,
"batch_id": result.TransferBillNo,
"processed_at": now,
}
if result.PackageInfo != "" {
upd["package_info"] = result.PackageInfo
}
if err := db.Model(&w).Updates(upd).Error; err != nil {
fmt.Printf("[AdminWithdrawals] 更新提现状态失败 id=%s: %v\n", body.ID, err)
c.JSON(http.StatusOK, gin.H{"success": false, "error": "更新状态失败: " + err.Error()})
return
}
// 发起转账成功后发订阅消息(异步,失败不影响接口返回)
if openID != "" {
go func() {
ctx := context.Background()
if err := wechat.SendWithdrawSubscribeMessage(ctx, openID, w.Amount, true); err != nil {
fmt.Printf("[AdminWithdrawals] 订阅消息发送失败 id=%s: %v\n", body.ID, err)
}
}()
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "已发起打款,微信处理中",
"data": gin.H{
"out_bill_no": result.OutBillNo,
"transfer_bill_no": result.TransferBillNo,
},
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg})
return
default:

View File

@@ -240,6 +240,88 @@ func MiniprogramDevLoginAs(c *gin.Context) {
})
}
// MiniprogramDevLoginByPhone POST /api/miniprogram/dev/login-by-phone 开发专用:按手机号登录(仅 APP_ENV=development 可用,密码可空)
func MiniprogramDevLoginByPhone(c *gin.Context) {
if strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV"))) != "development" {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "仅开发环境可用"})
return
}
var req struct {
Phone string `json:"phone" binding:"required"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少手机号"})
return
}
phone := strings.TrimSpace(strings.ReplaceAll(req.Phone, " ", ""))
if phone == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "手机号不能为空"})
return
}
db := database.DB()
var user model.User
// 支持纯数字或带 +86 前缀
if err := db.Where("phone = ? OR phone = ? OR phone = ?", phone, "+86"+phone, "+86 "+phone).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "该手机号未注册"})
return
}
openID := getStringValue(user.OpenID)
if openID == "" {
openID = user.ID
}
tokenSuffix := openID
if len(openID) >= 8 {
tokenSuffix = openID[len(openID)-8:]
}
token := fmt.Sprintf("tk_%s_%d", tokenSuffix, time.Now().Unix())
var purchasedSections []string
var orderRows []struct {
ProductID string `gorm:"column:product_id"`
}
db.Raw(`SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section'`, user.ID).Scan(&orderRows)
for _, row := range orderRows {
if row.ProductID != "" {
purchasedSections = append(purchasedSections, row.ProductID)
}
}
if purchasedSections == nil {
purchasedSections = []string{}
}
responseUser := map[string]interface{}{
"id": user.ID,
"openId": openID,
"nickname": getStringValue(user.Nickname),
"avatar": resolveAvatarURL(getStringValue(user.Avatar)),
"phone": getStringValue(user.Phone),
"wechatId": getStringValue(user.WechatID),
"referralCode": getStringValue(user.ReferralCode),
"hasFullBook": getBoolValue(user.HasFullBook),
"purchasedSections": purchasedSections,
"earnings": getFloatValue(user.Earnings),
"pendingEarnings": getFloatValue(user.PendingEarnings),
"referralCount": getIntValue(user.ReferralCount),
"createdAt": user.CreatedAt,
}
if user.IsVip != nil {
responseUser["isVip"] = *user.IsVip
}
if user.VipExpireDate != nil {
responseUser["vipExpireDate"] = user.VipExpireDate.Format("2006-01-02")
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": map[string]interface{}{
"openId": openID,
"user": responseUser,
"token": token,
},
})
}
// 辅助函数
func getStringValue(ptr *string) string {
if ptr == nil {

View File

@@ -51,7 +51,7 @@ func generateWithdrawID() string {
}
// WithdrawPost POST /api/withdraw 创建提现申请(仅落库待审核,不调用微信打款接口)
// 可提现逻辑与小程序 referral 页一致;二次查库校验防止超额。打款由管理端审核后手动/后续接入官方接口再处理
// 余额不足时也允许落库,用户侧显示「申请已提交」而非「提现失败」;管理端批准时再校验可提现,不足则拒绝
func WithdrawPost(c *gin.Context) {
var req struct {
UserID string `json:"userId" binding:"required"`
@@ -69,14 +69,8 @@ func WithdrawPost(c *gin.Context) {
}
db := database.DB()
available, _, _, _, minWithdrawAmount := computeAvailableWithdraw(db, req.UserID)
if req.Amount > available {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": fmt.Sprintf("可提现金额不足(当前可提现:%.2f元)", available),
})
return
}
_, _, _, _, minWithdrawAmount := computeAvailableWithdraw(db, req.UserID)
// 不再在此处校验余额:余额不足也落库,由管理端批准时校验并拒绝,避免用户侧直接报「提现失败」
if req.Amount < minWithdrawAmount {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
@@ -119,6 +113,23 @@ func WithdrawPost(c *gin.Context) {
return
}
// 自动审批:若 referral_config.enableAutoWithdraw 为 true异步执行审批打款
var refCfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refCfg).Error; err == nil {
var config map[string]interface{}
if _ = json.Unmarshal(refCfg.ConfigValue, &config); config != nil {
if enabled, ok := config["enableAutoWithdraw"].(bool); ok && enabled {
go func(id string) {
if _, e := doApproveWithdrawal(db, id); e != nil {
fmt.Printf("[WithdrawPost] 自动审批失败 id=%s: %v\n", id, e)
} else {
fmt.Printf("[WithdrawPost] 自动审批成功 id=%s\n", id)
}
}(withdrawal.ID)
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "提现申请已提交,审核通过后将打款至您的微信零钱",

View File

@@ -14,8 +14,9 @@ type Withdrawal struct {
DetailNo *string `gorm:"column:detail_no;size:100" json:"detailNo,omitempty"` // 商家明细单号
BatchID *string `gorm:"column:batch_id;size:100" json:"batchId,omitempty"` // 微信批次单号
PackageInfo *string `gorm:"column:package_info;size:500" json:"packageInfo,omitempty"` // 微信返回的 package_info供小程序 wx.requestMerchantTransfer
Remark *string `gorm:"column:remark;size:200" json:"remark,omitempty"` // 提现备注
FailReason *string `gorm:"column:fail_reason;size:500" json:"failReason,omitempty"` // 失败原因
Remark *string `gorm:"column:remark;size:200" json:"remark,omitempty"` // 提现备注(用户填写)
FailReason *string `gorm:"column:fail_reason;size:500" json:"failReason,omitempty"` // 失败原因(打款失败/拒绝时记录)
ErrorMessage *string `gorm:"column:error_message;size:500" json:"errorMessage,omitempty"` // 错误信息(与 fail_reason 同步)
UserConfirmedAt *time.Time `gorm:"column:user_confirmed_at" json:"userConfirmedAt,omitempty"` // 用户点击「确认收款」时间
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
ProcessedAt *time.Time `gorm:"column:processed_at" json:"processedAt"`

View File

@@ -78,6 +78,8 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.GET("/withdrawals", handler.AdminWithdrawalsList)
admin.PUT("/withdrawals", handler.AdminWithdrawalsAction)
admin.POST("/withdrawals/sync", handler.AdminWithdrawalsSync)
admin.GET("/withdrawals/auto-approve", handler.AdminWithdrawalsAutoApproveGet)
admin.PUT("/withdrawals/auto-approve", handler.AdminWithdrawalsAutoApprovePut)
admin.GET("/withdraw-test", handler.AdminWithdrawTest)
admin.POST("/withdraw-test", handler.AdminWithdrawTest)
admin.GET("/settings", handler.AdminSettingsGet)
@@ -295,7 +297,8 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/config", handler.GetPublicDBConfig)
miniprogram.POST("/login", handler.MiniprogramLogin)
miniprogram.POST("/phone-login", handler.WechatPhoneLogin)
miniprogram.POST("/dev/login-as", handler.MiniprogramDevLoginAs) // 开发专用:按 userId 切换账号
miniprogram.POST("/dev/login-as", handler.MiniprogramDevLoginAs) // 开发专用:按 userId 切换账号
miniprogram.POST("/dev/login-by-phone", handler.MiniprogramDevLoginByPhone) // 开发专用:按手机号登录(密码可空)
miniprogram.POST("/phone", handler.MiniprogramPhone)
miniprogram.GET("/pay", handler.MiniprogramPay)
miniprogram.POST("/pay", handler.MiniprogramPay)

View File

@@ -1,71 +0,0 @@
#!/bin/bash
# 挖矿病毒守护脚本 - 每 30 分钟扫描并清理
# 特征: xmrig, kdevtmpfsi, kinsing, minerd, stratum, libprocesshider, watchbog 等
# 用法: chmod +x miner_guard.sh && ./miner_guard.sh
# 或由 cron 每 30 分钟调用: */30 * * * * /path/to/miner_guard.sh >> /var/log/miner_guard.log 2>&1
LOG="/var/log/miner_guard.log"
LOCK="/var/run/miner_guard.lock"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
# 挖矿病毒特征(进程名/路径关键词)
MINER_KEYWORDS="xmrig|xmr-stak|minerd|cpuminer|stratum|kdevtmpfsi|kinsing|libprocesshider|ddgs|watchbog|trace"
# 可疑路径
MINER_PATHS="/tmp/xmrig /tmp/config.json /tmp/.x /tmp/kdevtmpfsi /tmp/kinsing"
run_lock() {
exec 200>"$LOCK"
flock -n 200 || { echo "[$TIMESTAMP] 已有实例在运行,跳过" >> "$LOG"; exit 0; }
}
log() { echo "[$TIMESTAMP] $1" | tee -a "$LOG"; }
run_lock
# 1. 杀进程
for kw in xmrig kdevtmpfsi kinsing minerd xmr cpuminer; do
pids=$(pgrep -f "$kw" 2>/dev/null)
if [ -n "$pids" ]; then
log "发现挖矿进程: $kw (PID: $pids),执行 kill -9"
echo "$pids" | xargs -r kill -9 2>/dev/null
fi
done
# 2. 删除已知挖矿文件
deleted=0
for p in $MINER_PATHS; do
if [ -e "$p" ]; then
log "删除: $p"
rm -rf "$p" 2>/dev/null && deleted=1
fi
done
# 3. 扫描 /tmp 下含挖矿关键词的可执行文件
find /tmp /var/tmp /dev/shm -maxdepth 2 -type f -executable 2>/dev/null | while read f; do
if echo "$f" | grep -qiE "xmrig|miner|xmr|stratum|kinsing|kdevtmpfsi"; then
log "删除可疑可执行文件: $f"
rm -f "$f" 2>/dev/null
fi
done
# 4. 扫描 /www/wwwroot 下的 xmrig 目录
find /www/wwwroot -maxdepth 6 -type d -name "*xmrig*" 2>/dev/null | while read d; do
log "删除挖矿目录: $d"
rm -rf "$d" 2>/dev/null
done
find /www/wwwroot -maxdepth 6 -type f -name "xmrig" 2>/dev/null | while read f; do
log "删除挖矿文件: $f"
rm -f "$f" 2>/dev/null
done
# 5. 清理 www 用户 crontab 中的挖矿相关项
if crontab -u www -l 2>/dev/null | grep -qiE "xmrig|miner|curl.*tmp|wget.*tmp"; then
log "发现 www 用户 crontab 可疑项,建议人工检查: crontab -u www -l"
fi
# 6. 若本次有清理动作,记录
if [ "$deleted" = "1" ]; then
log "本次已清理挖矿病毒"
fi
exit 0

View File

@@ -1,40 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""检查 miner_guard 安装状态"""
import os, sys
try:
import paramiko
except ImportError:
print("pip install paramiko"); sys.exit(1)
def get_cfg():
try:
import importlib.util
spec = importlib.util.spec_from_file_location("m", os.path.join(os.path.dirname(__file__), "master.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
c = mod.get_cfg()
return {"host": c["host"], "user": c.get("user","root"), "password": c.get("password",""), "port": 22022}
except: return {}
cfg = get_cfg()
if not cfg.get("host"): print("配置失败"); sys.exit(1)
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(cfg["host"], port=cfg["port"], username=cfg["user"], password=cfg["password"], timeout=15)
def run(cmd, to=10):
i, o, e = c.exec_command(cmd, timeout=to)
return o.read().decode("utf-8","replace").strip()
print("=== crontab -l ===")
print(run("crontab -l 2>/dev/null"))
print("\n=== /root/miner_guard.sh 存在? ===")
print(run("ls -la /root/miner_guard.sh 2>/dev/null"))
print("\n=== 手动执行一次脚本 ===")
print(run("/bin/bash /root/miner_guard.sh 2>&1"))
print("\n=== 日志最后5行 ===")
print(run("tail -5 /var/log/miner_guard.log 2>/dev/null"))
c.close()

View File

@@ -1,155 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
挖矿病毒守护 - 安装脚本
将 miner_guard.sh 上传到服务器,并配置每 30 分钟执行一次。
使用: python miner_guard_install.py [--yes]
"""
import os
import sys
import io
try:
import paramiko
except ImportError:
print("错误: pip install paramiko")
sys.exit(1)
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
def get_cfg():
host = os.environ.get("DEPLOY_HOST")
if not host:
try:
import importlib.util
spec = importlib.util.spec_from_file_location(
"master", os.path.join(os.path.dirname(__file__), "master.py")
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
c = mod.get_cfg()
return {
"host": c["host"], "user": c.get("user", "root"),
"password": c.get("password", ""), "ssh_key": c.get("ssh_key", ""),
"port": int(os.environ.get("DEPLOY_SSH_PORT", str(DEFAULT_SSH_PORT))),
}
except Exception:
pass
return {
"host": host or "", "user": os.environ.get("DEPLOY_USER", "root"),
"password": os.environ.get("DEPLOY_PASSWORD", ""),
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
"port": int(os.environ.get("DEPLOY_SSH_PORT", str(DEFAULT_SSH_PORT))),
}
def main():
import argparse
p = argparse.ArgumentParser(description="安装挖矿守护到服务器")
p.add_argument("--yes", "-y", action="store_true", help="跳过确认")
args = p.parse_args()
cfg = get_cfg()
if not cfg.get("host") or (not cfg.get("password") and not cfg.get("ssh_key")):
print("[错误] 需配置 DEPLOY_HOST 和 DEPLOY_PASSWORD")
sys.exit(1)
script_dir = os.path.dirname(os.path.abspath(__file__))
local_sh = os.path.join(script_dir, "miner_guard.sh")
if not os.path.isfile(local_sh):
print("[错误] 未找到 miner_guard.sh")
sys.exit(1)
remote_path = "/root/miner_guard.sh"
cron_line = "*/30 * * * * /bin/bash %s >> /var/log/miner_guard.log 2>&1" % remote_path
print("=" * 60)
print(" 挖矿病毒守护 - 安装")
print("=" * 60)
print(" 服务器: %s" % cfg["host"])
print(" 脚本路径: %s" % remote_path)
print(" Cron: 每 30 分钟执行")
print("=" * 60)
if not args.yes:
print("\n确认安装? 输入 yes 继续: ", end="")
if input().strip().lower() != "yes":
print("已取消")
return
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
client.connect(cfg["host"], port=cfg["port"], username=cfg["user"],
key_filename=cfg["ssh_key"], timeout=15)
else:
client.connect(cfg["host"], port=cfg["port"], username=cfg["user"],
password=cfg["password"], timeout=15)
except Exception as e:
print("[连接失败]", str(e))
sys.exit(1)
sftp = client.open_sftp()
with open(local_sh, "rb") as f:
sftp.putfo(f, remote_path)
sftp.chmod(remote_path, 0o755)
sftp.close()
# 通过 SSH 执行写入 /etc/cron.d/root 有权限)
cron_line = "*/30 * * * * root /bin/bash %s >> /var/log/miner_guard.log 2>&1" % remote_path
cron_body = "SHELL=/bin/bash\nPATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin\n%s\n" % cron_line
cron_d_file = "/etc/cron.d/miner_guard"
# 用 base64 避免 shell 转义问题
import base64
b64 = base64.b64encode(cron_body.encode()).decode()
stdin, stdout, stderr = client.exec_command(
"echo %s | base64 -d | tee %s > /dev/null && chmod 644 %s" % (b64, cron_d_file, cron_d_file),
timeout=10
)
err = stderr.read().decode("utf-8", errors="replace").strip()
if err:
print("\n[备选] /etc/cron.d 失败")
# 尝试 crontab
crontab_line = "*/30 * * * * /bin/bash %s >> /var/log/miner_guard.log 2>&1\n" % remote_path
tmp_cron = "/tmp/miner_guard_cron_%s" % os.getpid()
sftp = client.open_sftp()
try:
with sftp.file(tmp_cron, "w") as f:
f.write(crontab_line)
finally:
sftp.close()
sin, sout, serr = client.exec_command("crontab %s 2>&1; rm -f %s; crontab -l 2>/dev/null" % (tmp_cron, tmp_cron), timeout=10)
out = sout.read().decode("utf-8", errors="replace")
err_msg = serr.read().decode("utf-8", errors="replace").strip()
if "miner_guard" in out:
print("[成功] crontab 已添加")
else:
# 尝试 systemd timer
print(" crontab 不可用,尝试 systemd timer...")
svc = "[Unit]\nDescription=Miner Guard\nAfter=network.target\n[Service]\nType=oneshot\nExecStart=/bin/bash %s\n[Install]\nWantedBy=multi-user.target\n" % remote_path
tmr = "[Unit]\nDescription=Miner Guard 30min\n[Timer]\nOnBootSec=1min\nOnUnitActiveSec=30min\nPersistent=true\n[Install]\nWantedBy=timers.target\n"
try:
sftp = client.open_sftp()
sftp.putfo(io.BytesIO(svc.encode()), "/etc/systemd/system/miner-guard.service")
sftp.putfo(io.BytesIO((tmr.encode())), "/etc/systemd/system/miner-guard.timer")
sftp.close()
sin, sout, serr = client.exec_command("systemctl daemon-reload && systemctl enable miner-guard.timer && systemctl start miner-guard.timer 2>&1", timeout=15)
r = sout.read().decode("utf-8", errors="replace") + serr.read().decode("utf-8", errors="replace")
if "Failed" not in r and "denied" not in r:
print("[成功] systemd timer 已启用,每 30 分钟执行")
else:
raise IOError(r)
except Exception as ex:
print(" systemd 失败: %s" % ex)
print(" 请 SSH 登录后手动执行: (crontab -l 2>/dev/null; echo '%s') | crontab -" % cron_line.strip())
else:
print("\n[成功] 已写入 %s,每 30 分钟执行" % cron_d_file)
client.close()
print("\n日志: /var/log/miner_guard.log")
print("=" * 60)
if __name__ == "__main__":
main()

View File

@@ -1,46 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""删除 /www/wwwroot/self 下发现的 xmrig 挖矿病毒"""
import os
import sys
try:
import paramiko
except ImportError:
print("pip install paramiko"); sys.exit(1)
def get_cfg():
try:
import importlib.util
spec = importlib.util.spec_from_file_location("m", os.path.join(os.path.dirname(__file__), "master.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
c = mod.get_cfg()
return {"host": c["host"], "user": c.get("user","root"), "password": c.get("password",""), "port": 22022}
except Exception:
return {}
cfg = get_cfg()
if not cfg.get("host"):
print("配置失败"); sys.exit(1)
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(cfg["host"], port=cfg["port"], username=cfg["user"], password=cfg["password"], timeout=15)
target = "/www/wwwroot/self/wanzhi/tongzhi/xmrig-6.24.0"
print("删除挖矿病毒: %s" % target)
stdin, stdout, stderr = c.exec_command("rm -rf %s 2>&1" % target, timeout=30)
out = stdout.read().decode("utf-8","replace").strip()
err = stderr.read().decode("utf-8","replace").strip()
if out: print(out)
if err: print("stderr:", err)
# 验证已删除
stdin, stdout, stderr = c.exec_command("ls -la %s 2>&1" % target, timeout=5)
check = stdout.read().decode("utf-8","replace").strip()
if "No such file" in check or "cannot access" in check:
print("\n[成功] xmrig 病毒目录已删除")
else:
print("\n[警告] 删除可能失败:", check[:100])
c.close()

View File

@@ -26,3 +26,51 @@
{"level":"debug","timestamp":"2026-03-19T16:41:36+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Thu, 19 Mar 2026 08:41:35 GMT\r\n\r\n{\"session_key\":\"WgxBUmbDt4ijGryl7tI4jQ==\",\"openid\":\"ogpTW5a9exdEmEwqZsYywvgSpSQg\"}"}
{"level":"debug","timestamp":"2026-03-19T16:45:15+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=102_1s_JNaLepGIJ-yC3eHp8MhR-MLZVGr3Nvcun0XtSe1bHYVhgwXDfdLI3RbOFcf2sG0-17AXUpJNje2tiFvKr4q26kQBa-2OqN2_499sTEFm0QDwY4EuHDS_133ANIBeADAHCV&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0a1ZdL000Viw2W1nc3300clDfj2ZdL0h&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-19T16:45:15+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Thu, 19 Mar 2026 08:45:14 GMT\r\n\r\n{\"session_key\":\"T4ENAqokN4b3TNGiD+7DLA==\",\"openid\":\"ogpTW5a9exdEmEwqZsYywvgSpSQg\"}"}
{"level":"debug","timestamp":"2026-03-20T12:44:07+08:00","caller":"kernel/accessToken.go:381","content":"GET https://api.weixin.qq.com/cgi-bin/token?appid=wxb8bbb2b10dec74aa&grant_type=client_credential&neededText=&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-20T12:44:07+08:00","caller":"kernel/accessToken.go:383","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 174\r\nConnection: keep-alive\r\nContent-Type: application/json; encoding=utf-8\r\nDate: Fri, 20 Mar 2026 04:44:06 GMT\r\n\r\n{\"access_token\":\"102_DPOg4ZBZnQeOr42BvOi0KNAdmD0bF9Xs5CfUzLawTllFy-lgjjcAOxvM1MWziLZQp_rGji8dJR0wWaSf9b5YhqyqUU423evyfBanz29Is-3B_uMCeQMGDjhWRvAXTIdAIAGCR\",\"expires_in\":7200}"}
{"level":"debug","timestamp":"2026-03-20T12:44:07+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=102_DPOg4ZBZnQeOr42BvOi0KNAdmD0bF9Xs5CfUzLawTllFy-lgjjcAOxvM1MWziLZQp_rGji8dJR0wWaSf9b5YhqyqUU423evyfBanz29Is-3B_uMCeQMGDjhWRvAXTIdAIAGCR&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0f3uCfHa1MgSnL007TFa1mP01b4uCfHj&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-20T12:44:07+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Fri, 20 Mar 2026 04:44:07 GMT\r\n\r\n{\"session_key\":\"d/a9oOU7MpOG90wRgBIEHA==\",\"openid\":\"ogpTW5fmXRGNpoUbXB3UEqnVe5Tg\"}"}
{"level":"debug","timestamp":"2026-03-20T12:47:38+08:00","caller":"kernel/baseClient.go:457","content":"POST https://api.mch.weixin.qq.com/v3/fund-app/mch-transfer/transfer-bills request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"VhORiGFxNYTRRUJ5zb4dxXl8oijs63rz\",timestamp=\"1773982057\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"Kczv0Ok0lw289nmaaUjjVJgDDXq4bBOZrlYKQYllkPYXq0NgKcEDGphohbouxYFYEjCy6odEHi9UDWytJVvrVV3RLF3zdtp8Nf8SpLbgeSHaq3iRNlylyAHdhsSeEvakmUFw5QR0k/0xCY1fWYVfvlC+MqVYjPckw2jdAI1AHYEyxtaosM5FIK6zWJGfQGgKs49TlF1dCvHCyfbKp9HYi18RHwSgb+vpknWcsL35OHB6309RoGQWupN57yjj0mjeYGjmaJwTBE1YJAzogU6PfaRkTB29ZqbF92RQRaZl9epwnideYlc7O9KdPz8cn8mOHE9fZUF0jrShVaZTW1pFXg==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-20T12:47:38+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 400 Bad Request\r\nContent-Length: 98\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 04:47:37 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08E9A2F3CD06102618AFE6F8AF0120EEC60128D48101-268491067\r\nServer: nginx\r\nWechatpay-Nonce: 5051256999ad05409d4300cc4f300122\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: P1E9gp4sU0vOErMTyNiwgLBr4coeub0g5UFO8EmLVIkzUOdrasmItGezBmg7xDv1Uo/ENTU4AdX4fS7/hIakahR0awjiQgBy2nbUCVO8tgTwk7l3eINGntHd+CU/G53B1bsr/W+enk4PDrAbAWqVlBgLH2vvkGBqTRAXvm6QrDx4yirY1HgcqJOtCyVUsSgGN6dNGw/D39Xr5i3yDp+Lfmn212lm/lY1Dx4idZi+ePNW4dIgmv836JwqJRXrzDOD8z1b4d8eEapf5MfRRsGo8NHFJMYwFJlSdapmwVxLHmWVaGls25rI3kmPLb8YOgE5YIj2cx47Z4wVB8ZBGNKETw==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773982057\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"code\":\"INVALID_REQUEST\",\"message\":\"此IP地址不允许调用接口请按开发指引设置\"}"}
{"level":"debug","timestamp":"2026-03-20T13:00:32+08:00","caller":"kernel/accessToken.go:381","content":"GET https://api.weixin.qq.com/cgi-bin/token?appid=wxb8bbb2b10dec74aa&grant_type=client_credential&neededText=&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-20T13:00:32+08:00","caller":"kernel/accessToken.go:383","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 174\r\nConnection: keep-alive\r\nContent-Type: application/json; encoding=utf-8\r\nDate: Fri, 20 Mar 2026 05:00:31 GMT\r\n\r\n{\"access_token\":\"102_hGbH_8pS5ttLodC6gMVsF0b_c9IPXlucKff_guY7vt2DG2jaR_YObm6FvDmQ3PozLg6Tp3ihoOQgeGsxV3BZnFo8EkHRN9qBkWeaKmfMn7ptwjqtw-mrKRM57gEQNZaABAEBN\",\"expires_in\":7200}"}
{"level":"debug","timestamp":"2026-03-20T13:00:32+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=102_hGbH_8pS5ttLodC6gMVsF0b_c9IPXlucKff_guY7vt2DG2jaR_YObm6FvDmQ3PozLg6Tp3ihoOQgeGsxV3BZnFo8EkHRN9qBkWeaKmfMn7ptwjqtw-mrKRM57gEQNZaABAEBN&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0c3ihlFa1YX7oL0lXCIa1W46FX1ihlFK&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-20T13:00:32+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Fri, 20 Mar 2026 05:00:31 GMT\r\n\r\n{\"session_key\":\"K5ZPJ6/4q9e/1SvQuEKsHg==\",\"openid\":\"ogpTW5fmXRGNpoUbXB3UEqnVe5Tg\"}"}
{"level":"debug","timestamp":"2026-03-20T13:07:28+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=102_hGbH_8pS5ttLodC6gMVsF0b_c9IPXlucKff_guY7vt2DG2jaR_YObm6FvDmQ3PozLg6Tp3ihoOQgeGsxV3BZnFo8EkHRN9qBkWeaKmfMn7ptwjqtw-mrKRM57gEQNZaABAEBN&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0d3BNTFa1kKIoL083wGa1TJPyY3BNTF1&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-20T13:07:28+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Fri, 20 Mar 2026 05:07:27 GMT\r\n\r\n{\"session_key\":\"JNkHBiUKvcrB3af9Tf7fLQ==\",\"openid\":\"ogpTW5fmXRGNpoUbXB3UEqnVe5Tg\"}"}
{"level":"debug","timestamp":"2026-03-20T13:07:29+08:00","caller":"kernel/baseClient.go:457","content":"POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"Uh6ZLYqxAOzJx3DlhgYXtB5cZZfDZk1O\",timestamp=\"1773983249\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"HNqmQAl10cXL6AY94+LJucsRX+0FVOukP88YCCUD/ex1z2y0Ig8BF+MNkYafaIa5nb24wHmnNFiWRM+L5EI25Qk+51HMMWsQ4AHmu6A1+iHXPk43FMXzI5/EcGYg+FB9LDDaO5EB7jRjr4wyG35n80yZ6c78qrMgsi+Aue98+iItpRzOdrVlkafmJ8y1OeUGK3owkQrdobqSQJA11JaOsc6VzXcRUhxXDrQqwCy1pVcrQELtGos/0207eqRXun42ZDoDjvGTkuP5OPyP62UZvnlLuF1Tv8naUUVyTj3XS9kAQk0CaBduxO2J7D4B2po5sb/hjWKsQtYgVwUJNfAkaw==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-20T13:07:29+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 52\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 05:07:28 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 0890ACF3CD0610EE02189281F8AF0120A8D51828F09004-0\r\nServer: nginx\r\nWechatpay-Nonce: c398601a140d9814c4c4b7ad3909a18b\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: vS+u6Jk0SQrs4t85GRJVKgUM5VKBc3G7TFOQSgujrXmNModtw2QCtSKMz10rerjUx6hE1LlzAA41uxi2cQGC5h7UoCwGbcbODtFdHtRfJOrdMHwQrfpDUsJPUhEfHCQ0XPrf36hQeCsu5nwXnrTIZbl8pif29wGQXFjZSQgzC41KelYjqAhHjUZLM5jTJvT4cLOa2+fT6w/LTKnxvMRz5OGhWFjm9IcB/0CWGYg9v061G1790JpQwsvNW+f5eCU5RSg4wgpQ+kaWveEED5TIo8Iil43YhFpeRQqS+ARoZ2ibHQgfG/MoC72otqh6Ic7ORUH9oggK4VYjhQmAFMR4iw==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773983248\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"prepay_id\":\"wx20130728575617ac637c82a36b9c3d0001\"}"}
{"level":"debug","timestamp":"2026-03-20T13:07:34+08:00","caller":"kernel/baseClient.go:457","content":"POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"r8RlY41EXwM4xPXdOCoBobcLSrHqD7pX\",timestamp=\"1773983254\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"kpi/tTZ5+nx+DjL222lNGS1NwMLlHApWmo0B30SUSdoMBQ1jCh7GEgr6JJygVSMQy7lX9uEXeB78ixQ456ZnKekBGXbtL2MPTbZEzTwSUpWKlZ4f5wAlPDF3JwGbzEWnViNHltoA1pJIXjXSFEbWeFekQzvyWrDBX1ea0Q+e1D3+wXq60nAQSIA2mCw69ANsgcjPstB1vl9pNDqDXyz72/dRp/vqI3tE4pjrdDhG/okxKG/JGWIdy5h7Z90ojqZi/L2lG/fiN4IePza3GTGXSQ8OtCNagnli2+sy2MBVapelmTbCU0oNGghcNLZJHJ+UMQdbQCdHR+zztf9IvlmE/w==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-20T13:07:34+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 52\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 05:07:33 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 0895ACF3CD0610C305188BDA8C5820CEEE1C28A30B-0\r\nServer: nginx\r\nWechatpay-Nonce: 52be8c275f44fdbc7381bc4f8127dfb2\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: Ci3uWJjea2Hw8xppEyuQyqMGAleliOglETHf8UBR3B2rFhny91OkZEmyCpAh32bLrfRyr7MaNxtsnDF49h0KCxf8u7WCehHJbK9M6c2YmtJZ40QrvnCEpgQdyyOTTeBORPpgszdYizycXKhmhMqO7SLWOzEyI0lvkv11ehCS2dsFfZXH7QNTLdFi4/qA1YV5IWfdEqzpj9ZOhcdE2zwyQM8ChTyPL2qi9hnX6xFsTXhIYDnxlcae8yRarvlzwYXUhRZkf3pBd/kzwdvz7ae+6R0DU1hs5hwwILUnMfF0QK7p+VX9CAYJGAqW0kjWXvsY1O60yel3IY9H/92mBDVU+Q==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773983253\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"prepay_id\":\"wx20130733888002cc7db0f278ae9cd60001\"}"}
{"level":"debug","timestamp":"2026-03-20T13:11:18+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260320130728781800?mchid=1318592501 request header: { Accept:*/*Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"gFe13wRPeq6iJSh0I6h2dIJ6QUj7M0Cx\",timestamp=\"1773983478\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"TROPreERjOH5arAw0Lr/KGNfNnRE82ti/7wLix63nEdzTuxnfWGVjM/AucI0PZ0tOGoE0KU8UjR5dfx5S4tthnsn8sAO+4PKkxoXwJp4QWrIX7ra6ZigpxAtUjoDQSzCRsGmB7ePvka/MitScIBxsoo2gT4/50D/8MmsmDgp4YBo9not1bdLTCRqTk9zkC8OQfHu+U2d4V4fcdjPgeYVtQV+9QvM988G9TdiqT4WLObVUDodk9P6FgYrgQz84BnpEsrnrTcSCkpErU7DdX/V/Y3NKQjrr8nF2LPvybWxAPp+73xbsB5Ebo0UHdgJ/epYeT8kzPUyZ86y0w+eWmo9Vw==\"} request body:"}
{"level":"debug","timestamp":"2026-03-20T13:11:18+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 250\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 05:11:18 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08F5ADF3CD0610CB0718F3B5F8AF0120F8A2082899CE01-0\r\nServer: nginx\r\nWechatpay-Nonce: 1328d38594debffbf2fa32729539ab39\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: Nt2U3ril8JbqOc3FSTwCMEWoFOgqHHNExmqxjXyn/lrxCcWlva3nshvPlxtWNWoaDvg6MUY3PnHMvwa5+V7FH5ZwKeommlWTGoi4s/pAKsedczGPOXbdMKIcYeyle61SEg9RQic0i8VHVj6aOx/dRE2qYHebx4VZtsNwlyDFluzAFVvzArKPW5s8Mtqna4HvZgIC5p1zflzXeW717TEM4ALnNI1BYNSkcWZx8XfzMs/ihv8nMDhdKEzizyHV9RojKWueEOkfR9lbRbc08atBQMO2qarSS5QVA/ol3e3YrXqFZFqJWuVGA11a8FgrLP4RjB/CJZJV9kNPK0qdcDsXTA==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773983478\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":1000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260320130728781800\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-20T13:11:19+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260320130734906900?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"UphkApbjx4HuNQ0hDxrJyfELdcbG19Je\",timestamp=\"1773983478\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"0PQ/3CItyMrRFjaU7GR1xEsLsm2k13+eLNuKr1w/GMhBxHGtPgQvrFZ3Axldn3WsvfTRI4uKyw882lnZ2k5Ljiu12JUsmSQJmC+Tl3kN04p1FLUEz0vqXUDRjZ+E4XoTcFpVixcwvEvkZm0M8D3scKj1882OhnnKIv6Y0jHGy3mVGSusW2+DHfJFWsdnbpm2kbEjYHc5YxTLZ1iLii8mX0ZTGWxfczhSue3mFSs79Xg6P3hf4oQVITYdb0esTqdIncLOiSJO23wnKd14FfPrrxyNBj3mW1127FojOLnvvmHcEoUNuJ4wlAkEBHBEvDnDOpcb3bN7MqLinWAPZHuY1A==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-20T13:11:19+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 250\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 05:11:18 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08F6ADF3CD0610E50118B39DECF50120CC9B1528E5D601-0\r\nServer: nginx\r\nWechatpay-Nonce: 399e3c310dbc8fa5e47114c95afd4db7\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: Vj8HAAxXynUyO00gixkU9feTfOqf7VCRMNZF/vDCkNKUvwtFPu5N0EKKcTzWtRp4frlaa8JprOWvoB5yc3NaDPkwnNTof+QxJ5JR2rrvRIoDWRx9R8ZxIK6p8WKS5lnaZ+xKI87bZFX3tomlx/Cy2ESD4F3OTw7TcTVqh864WRCbMaBPqjYdShYj9xDunqgDIe8iYKmkHeL7q6xmnVSW8qO6yvXH/pLFDkxOuHe8T/ydKmHaKo9fCHxscvd4RRu2LLATMtL0zuodO914IUPQ0th8NSytr3iIKRo+jX3s7gVECEredk0l4ZZieOcmM+n7hiJr1LfkiJ9oZY01QC55gQ==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773983478\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":1000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260320130734906900\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-20T13:15:25+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260320130728781800?mchid=1318592501 request header: { Accept:*/*Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"xhkeCroakqZFpKfFZCGpTENfZit5qX4v\",timestamp=\"1773983725\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"iEBERNeyYSBpiqJwroP1JHVLq99utY/rbi6ZAX3INvPhYrkYwvvIOXn+PCbIOMO8/qrDdhZLPJi3HD8g2cfZX28BVN9vejNShwcUzSt/YO62FWNE5SYk1bKEd7xKHL2YHkaCyE0GnMDT+DRcirCUpPAlQGs8aT7GZ8FCpiw1yNAQWMvNcqVSaTfuUTIF0FZfwp0hG5C0sZfdfQBxax8FNwW22T350k+1/KcUZx+0dj80LyzDZJVjWsxWqd9mhs+U0sxqDaWKbd3tvS/QFtp+mUGNLR83DOCN4V9/yREPOp9U9nf4EZFNWksehjAwPUW8zuf/HmNbVc3MtSdJu4tiqQ==\"} request body:"}
{"level":"debug","timestamp":"2026-03-20T13:15:25+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 250\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 05:15:25 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08ECAFF3CD0610EB0618B5A7F8AF012080EE2A28EDEC02-0\r\nServer: nginx\r\nWechatpay-Nonce: 9172717ca8b2e47c8b797a5e212e1ab4\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: IPq7BUIpOWUF6KlQhVJ/esAzaqupDq3bpoCnVrPfPQNyum4B9PD4WFf223IbZ+54UGO3mqyDJf6V9bo7YYnE3UbM49RRxLO5zPRCgUARwvuZzkNJu/q7NZsAfsnaaWloCmS5N+QR34ePnzBFe5yPpRNvcAlsoRt14JNrOumNl7Ib8YHjArPWT2SkpbN2x6aturEZ0rgMB4zdnv5z2yjjdsUBUerg9nHNUz+Qhxlye+mJk10RWYjOFXP1rWvAURTf4N1a3vmQBvqF8zMoxl9ad9EVEKZi9yLezqN1DVVkrR2SUuu/LMIpJXnPtdS83doYC/rA98f4N/BwZfLRFUgGEw==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773983724\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":1000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260320130728781800\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-20T13:15:26+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260320130734906900?mchid=1318592501 request header: { Authorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"owbeqXG9qWFk8i8Oc4HYCjr0e8xHH0ve\",timestamp=\"1773983725\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"up/07Vq5dlG3HogzK8hbEeapdSC6mJnkBarr5b+C6XPDmPGGMif5BPJS7MHgUFB4CdlwgCfYZ392z0A9SL97ebAKFCQ2qRj27YcjSue2Eo/m5DpzRyzOlrJDszzvgNvP0du0no9GD3AfYtnaCe5i3r//qZV7nRWui2HBPa3kBZB0fKLJPeHEKc3ueoLdkwVsfI38miKE05zOSJeBmAQZIOLPFdD5CboAQsDF06lgxLVcR7YnLz+4/Hw1Jbs+HImSewPQt058Xtn3TMF9h/gW4gwSYw1Tzc3wDowxpEcZ7XqqRT1dKJlOiXP4DcmsQTFtlgF7707DAf1HtCGHMW5R/g==\"Accept:*/*Content-Type:application/json} request body:"}
{"level":"debug","timestamp":"2026-03-20T13:15:26+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 250\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 05:15:25 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08EDAFF3CD06105B18B5A7F8AF0120D2800B28B8F405-0\r\nServer: nginx\r\nWechatpay-Nonce: 0ef81b6e156dd0e8e601b777f8df1bc8\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: iNwW2dKzUJm4gpn1Z3G0NJhdanyyBJZjj/KREF6EKw18hRdY0WUQlwYEvEPmWM3y3tMnm5OCeWtDIyTn2mDgpW+SKkVuXWelDFwW703CgBShfqwwRBFCoAgr9X4N+s/cby6ALhy7Iw6cfYDFLzRK/yLGRbfo+ZNqem8PYWM/sZ6SoHcMJJrHXBakCFtwf2+fObK30CgE65xKnMVZNU5Pvs606nXuae4Ws/zAaUBtcMWiGyiqgGg3eicGySR10na1BXO+/1SJUeSpeYYYc3Ji5FWiVSqOgpXooloxRsJ6LUheDK2pgSL4fVn3efWxcUYm2jEp3hvde01BSC2DwiJczA==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773983725\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":1000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260320130734906900\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-20T13:20:25+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260320130728781800?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"EsubDL8LOn17psdMACQIuAvy0u0ZOpFe\",timestamp=\"1773984025\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"eQcyMJ/SmzoxaYBeSvaq3vCUwM58zwLqR+V1Nr2JWReAIU5qt2qd2qXBrvy57vMv/jaOxNsOA4do6eKpQ88ubUQMcWpiJXxjtsi1pqfzAiQdvEKScFBCNyK53gtVUDXz7rNHbQJZbSPJh8Puxg0PcTk5f3U0FdmHd8MF9yinZnyhgtCEDWT/sGQ9bkfbvFmiXfOMnLOJtX1vAQIUVkhLHqrCkFzsUcbs7OVqBBnUjnCql6sCRmnWMIyKtP/bOjsvb0Tk7XaDqObzXQkE/RFmNRhJajutdd7xkbZ65/WDSRkO3AcoagpTjpqs3GG5BsD7ODhaBCLsoy/pI7ZtUGTfUw==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-20T13:20:25+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 250\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 05:20:24 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 0898B2F3CD0610AE0618BCB2F8AF0120EEFF3028C1F703-0\r\nServer: nginx\r\nWechatpay-Nonce: 354c2c44a5980845a63d52ee874905b7\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: DnjICyEVs9ZaKSjX68EutIMK1XRSXRk8rqpXYazRqgzABIyiAbZeRQ6eB9srWmBCgkpThBnp/iN6fXMsQme/91inoukFp2CLVBBWZIkyoEEeUNHMrFaWBn64rlUdOBUz8dtmYC0gGscqlZv3zzducXNvbJx1REqaIHamrUzNq4iv3cC7kRkWBzwM721REOTVoA8piXdogyn6OtIvpMMLEZWr13RTH2wKpAbkgK+NJonFzT6jDzPYH6JFDdC0VPUjkxge8y9k1JeajhiFEby5lSBkk/ZeGA3Kie+oiLNoLy2L7uwc7HEbz2HB1HEv7ERwhFNLJf1fAu6GBlw/S9k1bA==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773984024\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":1000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260320130728781800\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-20T13:20:26+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260320130734906900?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"KgnH4npnqT8HCnFNxEfFmTmLACu39eeH\",timestamp=\"1773984025\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"TCT35xm0PGkEltYMfaMaYE90N6jVkogNdtevJI3ofePvMVKKcCqobyUgaMW2HnXWprlc0IsikIYsP7Tuz3VCFKiLFWqfe9s/TZOmxpeQqRpaEK/E+31WfnIOZWJg8WuYD1iJlb6/NwV80J6pfJDIZQnrBirFU3yX2gsBm0hmi9cI+0mmJntPUMD1/harMgEWrWP1glMHyU4sI90ppVC/4W89bUUQR9PDJLrl1cV17iaB8GBQ8WEfszVLfe/Y8gmAWQ789KWTbHrbTJUXyn3BCKprGFyne3lRWvH/zxizFmxvbENvLDR7HzfPlxMFN2lKkoQgphY/BaVmawzufCoXoQ==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-20T13:20:26+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 250\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 05:20:25 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 0899B2F3CD06103618A294F5AF012086EB3628A559-0\r\nServer: nginx\r\nWechatpay-Nonce: 966e33621df834c72f86754e5d983adb\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: WG+7KqWCytTIe8ysCo+rc0DDIjBHgQ35GNLUW88DZ38EU1jr460wg2GGX0QaAet+nat7qtstau3wlVhWaVNTmk1Ij7zwIawmUah/AN4UWHoIHoQlmo8W/Ymht82eulCgL1EPKbwsY5bHdYjPN2siKg3/5z5pqBzldMHciaEp7HTDpshsEcAlnsjhQZkZDDFDDaKBcRte7+pHUdA9K49raF4BEaIta+SNwuJ8VpYVheWxuhwXDv1s2qw8Um0cyqLEJ/UcRFk6kouzZbZoNYX4GmiddWdNiDjA0DlQN+x2XCQoK888cSPqivEC1vn7VbmJyfTqLXgAIifAe6V4i2AdqA==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773984025\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":1000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260320130734906900\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-20T13:22:29+08:00","caller":"kernel/accessToken.go:381","content":"GET https://api.weixin.qq.com/cgi-bin/token?appid=wxb8bbb2b10dec74aa&grant_type=client_credential&neededText=&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-20T13:22:29+08:00","caller":"kernel/accessToken.go:383","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 174\r\nConnection: keep-alive\r\nContent-Type: application/json; encoding=utf-8\r\nDate: Fri, 20 Mar 2026 05:22:28 GMT\r\n\r\n{\"access_token\":\"102_LYzQoAxwGVSLcEAbTpcQbFxakY42Hg9jT-2zNq3-D5xTP_e-JyXHpNI2K3vJZOI08NpfU8vYdmihs1HF8wmEaladetKA5XrYl66-2CwwrCreSCE7WiVehaR26hcWODiACASKZ\",\"expires_in\":7200}"}
{"level":"debug","timestamp":"2026-03-20T13:22:29+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=102_LYzQoAxwGVSLcEAbTpcQbFxakY42Hg9jT-2zNq3-D5xTP_e-JyXHpNI2K3vJZOI08NpfU8vYdmihs1HF8wmEaladetKA5XrYl66-2CwwrCreSCE7WiVehaR26hcWODiACASKZ&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0d3iFDFa1L2roL0AXTIa166MGF4iFDFH&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-20T13:22:29+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Fri, 20 Mar 2026 05:22:28 GMT\r\n\r\n{\"session_key\":\"+t0l1lw5IRJ3snNlVhHVjA==\",\"openid\":\"ogpTW5fmXRGNpoUbXB3UEqnVe5Tg\"}"}
{"level":"debug","timestamp":"2026-03-20T13:23:00+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=102_LYzQoAxwGVSLcEAbTpcQbFxakY42Hg9jT-2zNq3-D5xTP_e-JyXHpNI2K3vJZOI08NpfU8vYdmihs1HF8wmEaladetKA5XrYl66-2CwwrCreSCE7WiVehaR26hcWODiACASKZ&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0e3S5vll2hhVoh45Xunl2aYh7i2S5vlU&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-20T13:23:00+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Fri, 20 Mar 2026 05:22:59 GMT\r\n\r\n{\"session_key\":\"DgQe8zI8iPAd7BHkKF7NLw==\",\"openid\":\"ogpTW5fmXRGNpoUbXB3UEqnVe5Tg\"}"}
{"level":"debug","timestamp":"2026-03-20T13:25:25+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260320130728781800?mchid=1318592501 request header: { Authorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"khcmZC8SnR9F97GNc62MHsh6rg9lQ4m4\",timestamp=\"1773984325\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"raVbKp7FQWDlwpdfPhFJP3Aocw1ZQ2nJBI+oYNheqvQqyOb0HyTUAX24ICEaGa8wXk55REV+xg2h558PVDdiH1BI/buBPRCAy+NCrpDdYAJe8xVA+kikyAaXn0Wa91YTuivpeJn+wc95ucdqpPeJMOU2p2jGfPBdaieXhWkRHmoXdMJc3jZCInHjpGuXPvMPfznIhvPYc1IXh0wwkL/j5WzXek8VuQAJnVHa0Bnx/kcYRwiNuOnTt8GslzSia0mqoBFaLmSotV7d4oV95/B7tlsKkmDtDFoysxBkv66pfZkh7DAFT/xySs3c4ZhzBWOCDCj46DgPkR4pLI2hnNePnA==\"Accept:*/*Content-Type:application/json} request body:"}
{"level":"debug","timestamp":"2026-03-20T13:25:25+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 250\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 05:25:24 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08C4B4F3CD0610A20618A6B1F8AF0120C4D31228D89405-0\r\nServer: nginx\r\nWechatpay-Nonce: e0a27ed24ef90e75d4f3299b7d6af71e\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: ibvxwB7gmzZg8iAOEMiSi+8muG9DjgW4gEuCd4FrAajTQohSpFde4O0DNUZExgRL6/XWucs29xhnqMCOyqJN1EW8fIUcm0DLryvLcGKvVLFbimHXNK/3OGjAOE1m9Fx+51ZYXer1dgfQD/vKLPwnIudVkk6us7Ip7cjl78dwDHN2i3lEaNqlBEH+2qME+OltwkNvH+nLS2fDm7J0I78gl5bKUzBEhK/9s230jXs8vO8u1txnrto8HmsV5I1UQWoalAQITPt8Dm8l3ogdDi4QFTA9zmVlPvVSSmrxYVB6M1D5GdnTPJL6NY9SGNt3jIP2w3X944U9zN372uOrnt5u6g==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773984324\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":1000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260320130728781800\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-20T13:25:26+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260320130734906900?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"v9QvVQXMAA5bC3WIevcYEq5XqTLoAH0M\",timestamp=\"1773984325\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"KE/+IRw58u0kxhZy/8xMleaEF97dqfLDAp3RJZib5R1cglNahJp/CK8v8uEDqp8C/S9SOojOEeRwdFhcJRGtCSi3ck9CefGxLTPJQdLk5Y+n9YlaYXa1ksJvs7O/HdVNZTMZSLDHOoJ1shQCEuCgP1i+Vj8lfbFecsqrefnla67YneBYVl88rMRM7XUF0xKkBsonxYBcY9tN8tCCcxCDcpBROyOQLxFHOomglErAlx9eWBNTpd1NDZopFR9uVpS9nWFcI0ISj0JO0yS5uhGxcrb9PveRzNZ+YBLzlYIpUF3KurzDlexnW6DXK8u+jGfNNV6kJwLTdJhEO/EXHdyFvA==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-20T13:25:26+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 250\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 05:25:25 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08C5B4F3CD0610191890C5C05520E2FC2B28E89E02-0\r\nServer: nginx\r\nWechatpay-Nonce: 594dbae1c45eca2b8573de992640d642\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: L8uag53oL6xKCKgPnauht0RuVdlnHcrwdiz3pRdtko60xCUEyyqFwLimoCmezjcIgmDz+U8WXgaWsU0Ab4uoVI/QvsyPhx2ekCRiX8y5ZHl6Tg4r1D0s0zRyhG5/E5h6re+HWNg/p79j4izwt7E+t/nQouVLP0S/pYX/M3EDu5nmt7usFk4blsQ/qGi1yygF1CQxzzBiIPRuTBrv6cOIhctOXciUX4GIeOEwHZyj+3f8H0D0qp39CJMF1J63PmVjLFOWuF4qpHRKsh5smiae2Wa1N+6NYfA+dSKRaoe7prCy5Su1JwJ4btjlVSmxx9P4Jzwtf0HUm7alE6I/p8w8cA==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773984325\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":1000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260320130734906900\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-20T13:25:42+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=102_LYzQoAxwGVSLcEAbTpcQbFxakY42Hg9jT-2zNq3-D5xTP_e-JyXHpNI2K3vJZOI08NpfU8vYdmihs1HF8wmEaladetKA5XrYl66-2CwwrCreSCE7WiVehaR26hcWODiACASKZ&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0b3rOmFa1JpaoL0tUhJa1vNtd22rOmFU&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-20T13:25:42+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Fri, 20 Mar 2026 05:25:41 GMT\r\n\r\n{\"session_key\":\"S/U/KzIRlBE9biInVk8t5Q==\",\"openid\":\"ogpTW5fmXRGNpoUbXB3UEqnVe5Tg\"}"}
{"level":"debug","timestamp":"2026-03-20T13:30:25+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260320130728781800?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"9YlIEXeLbdbxUx7CziwTkTrjDgMtXxcx\",timestamp=\"1773984625\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"YZ6fkW6Czn6Jv/VYpi0F1/GIVfG7FsvtNRclkgpQT6KCWhdMMLYNG8Wnx6WikcApvh5flgqObBfJ7nI6fj3bsVo1tlGI3uBvl9ppW1jNlKQNmUbxd/AI01/XLZvGrhE9JpCNxEoIxZzrtLql1niqlLTxwuIG0useg8fp98aiDzYNUu8WpWOaNZGADA7sD5Ro/4WJVFT7QLKkaNk0HGflDOVZZ1EsiPPUq2OfupDEpx4B+tzLge7yuhVqAVvX3O6StSCX1u2clr6K/0qIwqEfL5MaPZdLvqP/r1dhYpAFG7pm7Lf7g2S0gbiBDN3Z62VLYbf9jyyAPalJy5x9oFCLMQ==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-20T13:30:25+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 250\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 05:30:24 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08F0B6F3CD0610B00618C38CA85C2082F11D28AA9303-0\r\nServer: nginx\r\nWechatpay-Nonce: 4bc7e1f9444cdf9ef9ef7c8934a726ee\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: UnjMAU3z2e6s+t9Zx2LCiiL/0QMsB5CdMMphgyCIm9ONWhr48gZ5i9kDLNDAwNj/z8fZWbIMIzQ/UEGUdmKKL6a08NnRoq5ZqqWOUrgwsQi59RwL/KthYeXB/nzTwoldo7ALqgLvk93fJpxMZrdb8BdewVzHr1+eoK2Kj+U11HaAlma1Bknc4y3XC0per7xt6ygSdPaxQqNNhvWa+gOxDD9k56jOYS43ewh94n67vjW4w75LUBQwqxRugpmCMNvH6AQyF0uj8ffjljb1/VllYynpCUWHLXuXYt+A0KUWlCuKqIk0jsoiGJ8M0Mg0SkkfixHQWrO6EFl4zo61WuSCIg==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773984624\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":1000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260320130728781800\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-20T13:30:26+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260320130734906900?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"P0B7crMg1vkPlxmoxU9qB6P0JwIIUVzC\",timestamp=\"1773984625\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"N32Mlc3Xlr1ok3Dt04wLOGZyrTr2yQb74EPuJKGbbx2urCjVY0ThjLi+/qKV89rhTCnhmS4TJnDa/dgshi2fRMO5leLhgx/mYCeOUM0cQinFDEVwE9ynFLpLDrw3i4DN25tXD5Ume3ohxre55IzI5s687hgmaigYPdU4FMqk8/6QtQrTIxg3/7pHD8kyZbCP4aBzwDq8Vej+lMGenkTRcdCKmCCmqDCI5w7sfQfz1YWT+XbpEmkP8iQDcqZb6iyr5QyqIBl6pcO17KizIzpmtihrDUKqg8D7zcRfyyFX7Dxb0AvNQRPnLZEDOYljFFOl3I2VSNaHb22zdeLrdM7V0Q==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-20T13:30:26+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 250\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 05:30:25 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08F1B6F3CD06101D18C2E7F8AF012096B21428F5DB02-0\r\nServer: nginx\r\nWechatpay-Nonce: 420a870ca7ce6a7c37f97d0cb912800c\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: GD2GUJ1wGhAzIA94eS2CURqZ0HM3zVM35G8KXQT8aiGp3sNouQoxOo/+Z1p/u/AkUJHmEAbRqeVlW7+4bD7+y1ZxehL0bEfBhDdHibRvGHe963ueJXJVVq5QblIsEG0W8TKJTsHvStrN6Avg0ZkVYocDFfzAqNix9f7YaFITW0s9TVX8xWC5K8LdV5RO+N2IsbLNzZ40FwuHxTZVqCxgupNcTQ3EPXIu7QqqegxgH/I7iYV3bhnDi7k4dTQBkOUXur7ETvDXLq8Uo9Dj/dax05ccCWIM1kPam8LsNSkugDYLAnfls01zm3pIiLM2zU4yS6YerALTuzRA4qOdxt95VA==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773984625\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":1000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260320130734906900\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-20T13:35:34+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260320130728781800?mchid=1318592501 request header: { Accept:*/*Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"mxtOkbMKqdPqcP0dZWKieInMIZudN8Se\",timestamp=\"1773984933\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"aCb8nEK4nqAXARLScUWoGyX0ye38DgFYMmz/jXbA9iFqo7gwS3SA4RkWyrpJtqlLCmLWgjkTenUk1IzXddm9zVFZiid7PjGsZ/aXKnjtYiYVnQgyymxK3qqrDAy+Hsme1DPQ/ghp4pC4zfHIUBk2JnXWwplf4zVyOtjTWNlQV9ZyOHU1ndbGKiJsLO28E7ynTuFg7EFCe/uudXWhHL/nUamV2v7Dd9zyOVgUJKHO0Nqgw7hxQ9Y89DBiB6y7IV36YkofQUTz+wSr0LycwSsEe94G7N1pKE+sJsNODme6eeaS/jEHsu58A2GXikZgmf4IQlCtWX/DQdHXen+9+45o2Q==\"} request body:"}
{"level":"debug","timestamp":"2026-03-20T13:35:34+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 250\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 05:35:33 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08A5B9F3CD0610D00118C3B9F8AF012098C81228AAF801-0\r\nServer: nginx\r\nWechatpay-Nonce: 3ac56a1aba0043f64f699ffe371ebabe\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: tJsbXVjsXPLgAzH1ETyBrZ+xsrwmgOnT+sH8JmcqlyxUkFGCFkiqm+5DiKhE07f60dHXGeOSS4qrBzR37HzZitTeqodR7E2JB0Ut5xFbBKQL39nMDvB314u3CmklcjRrrUuSayXNUHrINYYwizfaj3GQMWlytWiS/IuC8rKhOSD1LxKQRUZvs9YD/v2EtjJrU8rXqIJdtdpAggMdIxl88oGR9/cPqBE+fSrngtqJtS+8HXfClPhrDOka1ah/faeHsQxYnb9Izglix057mv7W6vUBIjlbPsTxL1dREPQyyZ9UumcbPgnXXHkJ4iufXD61KPDPHtJjMQRaoLyZMCKR7A==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773984933\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":1000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260320130728781800\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
{"level":"debug","timestamp":"2026-03-20T13:35:34+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260320130734906900?mchid=1318592501 request header: { Authorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"F4jAe1o38Lshn82q3fXtEKVPM1DONLof\",timestamp=\"1773984934\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"TIlP8QQWNG3f+RHfZefrC7CIaovaFw6JW0V8O5r6bbPu37/aKwItYZcWKlfIkv/oxQDc1hYEx/u3EU+Wwj51DemXo4ct2phaBjLH7bc2KVM5lJEOFSODme9AKlrWiu8etlYESCjSZ01TXwrD1VFoIO3Ga16TNa7xV9Y4V9MXnkcvd2v1OUHpIwB1VjjrKvpoC31959mQfXOKvJ8aLTdru+IjYuCYlMIlz7hIy6tNTpzfHA0biVsHSU8Y4ap301GeuyRp4GQdS0px8pJR0wN7BXuuXuUA3GO6vNqVCREveYcEAaFOgz0GDyCAqYiVk5MMXNNueLqGmUXBz9mvj9d/Mw==\"Accept:*/*Content-Type:application/json} request body:"}
{"level":"debug","timestamp":"2026-03-20T13:35:34+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 250\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Fri, 20 Mar 2026 05:35:33 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08A5B9F3CD0610A60318FACDC0552094913028E68402-0\r\nServer: nginx\r\nWechatpay-Nonce: c8cd238aa151bc5b3562d983525c9a10\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: cU4LnoqphDCn40rM/L3JSxkg6E3H9TZXBZ2xFed9nrEt885jzrXhZACABPs+o9Pw6yyIoPDbzTukJlAZNQTZT/6zliHHuP4Wp+XF1HnWm/AxbDQwK6t23GtzY8L60ZgCJQN7xbnjqHmwKL2cX86ZB9lWGvoAcMXRhLKN3McoicwTC4268Z7xwSvUGBbIv3zmawbXZkqiYKckowym8kEHP9KXzV/7SEwwvW3CIGmloTukwcpaYOtJSRuit6/A3uz25ryxrgHT2gIudNRpW/SRKV/+M5uCd8gsJgQX27IE+KW58BnUu7oP0JC0PZXtvBHheSXo/9PmfBQVRt4Bgsbqtg==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773984933\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":1000},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260320130734906900\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}

View File

@@ -0,0 +1,226 @@
# 链接人与事 — 置顶功能 技术分析
> 参与角色:管理端开发工程师、后端工程师、小程序开发工程师
> 创建日期2026-03-20
> 需求:管理端「链接人与事」列表增加「置顶」能力,置顶人昵称+头像显示在小程序首页右上角;置顶唯一(只能一人)
---
## 一、现状梳理
| 模块 | 现状 |
|------|------|
| **管理端** | ContentPage「链接人与事」tab列表展示 token、@的人、获客数、planId、apiKey、操作编辑/查看新客户/编辑计划/删除) |
| **Person 模型** | person_id、token、name、label、user_id、ckb_api_key、ckb_plan_id 等;**无 avatar 字段** |
| **小程序首页** | 右上角固定展示 `/assets/images/author-avatar.png` + 文案「点击链接卡若」 |
| **index-lead** | `POST /api/miniprogram/ckb/index-lead` 使用全局 `getCkbLeadApiKey()` 推送到存客宝,文案硬编码「卡若会尽快联系您」 |
| **置顶存储** | 无,需新增 |
---
## 二、改造方案总览
### 2.1 数据层
| 改造项 | 方案 | 说明 |
|--------|------|------|
| 置顶存储 | `system_config` 新增 `pinned_person_token` | config_value 存 `{"token":"xxx"}`token 为 persons.token |
| Person 头像 | 方案 APerson 表加 `avatar` 字段 | 管理端可编辑,小程序展示 |
| Person 头像 | 方案 B有 user_id 时 JOIN users 取 avatar | 无 user_id 时用默认占位图 |
**推荐**Person 表加 `avatar` 字段VARCHAR 255管理端 PersonAddEditModal 可编辑;有 user_id 时可选从 users 同步(后续扩展)。
### 2.2 接口层
| 接口 | 使用方 | 说明 |
|------|--------|------|
| `PUT /api/db/persons/pin` | 管理端 | body: `{ token }`,置顶该人;先清空其他置顶再设置 |
| `GET /api/miniprogram/ckb/pinned-person` | 小程序 | 返回 `{ nickname, avatar, token }` 或空(无置顶时) |
| `POST /api/miniprogram/ckb/index-lead` | 小程序 | 改造:根据 pinned_person_token 查 persons 得 ckb_api_key推送到该人计划无置顶时 fallback 全局密钥 |
### 2.3 管理端
- 操作列新增「置顶」图标Pin 或 Star
- 点击置顶:调用 `PUT /api/db/persons/pin`,成功后刷新列表,已置顶行显示「已置顶」标识
- 置顶互斥:新置顶时,后端自动取消其他人置顶
### 2.4 小程序
- 首页 onLoad/onShow 调用 `GET /api/miniprogram/ckb/pinned-person` 获取置顶人
- 有数据:展示 `avatar`(网络图需配置 downloadFile 域名)+ `nickname` + 文案「点击链接{nickname}」
- 无数据:展示默认头像 + 「点击链接卡若」(兼容旧版)
- 点击留资:仍调 `index-lead`,后端按置顶人 token 推送到对应存客宝计划
---
## 三、流程图
### 3.1 置顶设置流程(管理端)
```mermaid
flowchart TB
subgraph 管理端
A[管理员点击「置顶」] --> B[PUT /api/db/persons/pin]
B --> C{后端处理}
C --> D[读取 system_config.pinned_person_token]
D --> E[更新 config_value = 新 token]
E --> F[返回 success]
F --> G[管理端刷新列表]
G --> H[置顶行显示「已置顶」]
end
subgraph 后端
C --> I[清空原 pinned_person_token]
I --> J[写入新 token]
J --> F
end
```
### 3.2 小程序首页展示与留资流程
```mermaid
flowchart TB
subgraph 小程序首页
A1[onLoad / onShow] --> A2[GET /api/miniprogram/ckb/pinned-person]
A2 --> A3{有置顶人?}
A3 -->|是| A4[展示 nickname + avatar]
A3 -->|否| A5[展示默认「点击链接卡若」]
A4 --> A6[用户点击]
A5 --> A6
A6 --> A7[POST /api/miniprogram/ckb/index-lead]
end
subgraph 后端 index-lead
A7 --> B1[读取 pinned_person_token]
B1 --> B2{有置顶?}
B2 -->|是| B3[查 persons 得 ckb_api_key]
B3 --> B4[用该 key 推存客宝]
B2 -->|否| B5[用全局 getCkbLeadApiKey]
B5 --> B4
B4 --> B6[返回 success]
end
```
### 3.3 三端协同时序图
```mermaid
sequenceDiagram
participant 管理端
participant 后端
participant 小程序
Note over 管理端: 阶段 1置顶设置
管理端->>后端: PUT /api/db/persons/pin { token }
后端->>后端: 更新 system_config.pinned_person_token
后端-->>管理端: success
Note over 小程序: 阶段 2首页展示
小程序->>后端: GET /api/miniprogram/ckb/pinned-person
后端->>后端: 读 pinned_person_token → 查 persons
后端-->>小程序: { nickname, avatar, token }
小程序->>小程序: 渲染右上角
Note over 小程序: 阶段 3用户点击留资
小程序->>后端: POST /api/miniprogram/ckb/index-lead
后端->>后端: 按 pinned token 取 ckb_api_key → 推存客宝
后端-->>小程序: success
```
---
## 四、实施清单(按角色)
### 4.1 后端工程师
| 序号 | 任务 | 说明 |
|------|------|------|
| 1 | Person 模型加 `avatar` | `Avatar string gorm:"column:avatar;size:255" json:"avatar"` |
| 2 | 迁移脚本 | `ALTER TABLE persons ADD COLUMN avatar VARCHAR(255) DEFAULT ''` |
| 3 | `PUT /api/db/persons/pin` | body: `{ token }`,更新 system_config.pinned_person_token |
| 4 | `GET /api/miniprogram/ckb/pinned-person` | 读 pinned token → 查 persons → 返回 nickname、avatar、token |
| 5 | 改造 CKBIndexLead | 有 pinned 时用 persons.ckb_api_key无则 fallback 全局 |
| 6 | PersonAddEditModal 支持 avatar | 管理端提交时传 avatar可选 |
### 4.2 管理端开发工程师
| 序号 | 任务 | 说明 |
|------|------|------|
| 1 | 操作列加「置顶」按钮 | Pin 图标title「设为置顶」 |
| 2 | 调用 PUT /api/db/persons/pin | 成功后 toast + 刷新 loadPersons |
| 3 | 置顶状态展示 | 列表加载后需知「当前置顶是谁」→ 需 GET pinned 接口或列表返回 isPinned |
| 4 | PersonAddEditModal 加 avatar 输入 | 可选URL 输入框 |
**置顶状态获取**:后端 `GET /api/db/persons` 可扩展返回 `pinnedToken`(或单独 `GET /api/db/config?key=pinned_person_token`),管理端据此标亮已置顶行。
### 4.3 小程序开发工程师
| 序号 | 任务 | 说明 |
|------|------|------|
| 1 | 首页 initData 加 loadPinnedPerson | 调用 GET /api/miniprogram/ckb/pinned-person |
| 2 | data 增加 pinnedPerson | `{ nickname, avatar, token }` 或 null |
| 3 | WXML 动态渲染 | `wx:if` 有 pinnedPerson 时用其 avatar + nickname否则默认 |
| 4 | 文案 | 有置顶:「点击链接{{pinnedPerson.nickname}}」;无:「点击链接卡若」 |
| 5 | 头像 | 网络图需配置 downloadFile 合法域名;失败时用占位 |
---
## 五、接口契约
### 5.1 PUT /api/db/persons/pin管理端
**请求**
```json
{ "token": "32位persons.token" }
```
**响应**
```json
{ "success": true }
```
```json
{ "success": false, "error": "该人物不存在" }
```
### 5.2 GET /api/miniprogram/ckb/pinned-person小程序
**响应(有置顶)**
```json
{
"success": true,
"data": {
"nickname": "卡若",
"avatar": "https://xxx/avatar.png",
"token": "xxx"
}
}
```
**响应(无置顶)**
```json
{
"success": true,
"data": null
}
```
### 5.3 管理端获取当前置顶
- 方案 A`GET /api/db/config?key=pinned_person_token` 返回 `{ "token": "xxx" }`
- 方案 B`GET /api/db/persons` 响应增加 `pinnedToken` 字段
推荐方案 A与现有 config 接口一致。
---
## 六、注意事项
1. **缓存**pinned-person 可加入 config 缓存或短 TTL如 1 分钟),置顶变更后需失效。
2. **兼容**无置顶时index-lead 保持现有逻辑(全局密钥),文案可继续「卡若会尽快联系您」或改为通用「提交成功」。
3. **Person 删除**:删除已置顶的 Person 时,后端应同时清空 pinned_person_token。
4. **头像域名**:小程序展示网络头像需在微信后台配置 downloadFile 合法域名。
---
**创建时间**2026-03-20
**适用**:链接人与事置顶、小程序首页动态展示

View File

@@ -0,0 +1,112 @@
# Soul 提现流程图
## 一、整体流程Mermaid
```mermaid
flowchart TB
subgraph 小程序["小程序端"]
A1[用户进入推广中心/我的]
A2[输入提现金额]
A3[POST /api/miniprogram/withdraw]
end
subgraph 后端校验["后端校验"]
B1{参数校验}
B2{金额 > 0?}
B3{金额 ≤ 可提现?}
B4{金额 ≥ 最低门槛?}
B5{用户存在且已绑定微信?}
end
subgraph 落库["落库"]
C1[写入 withdrawals 表<br/>status = pending]
end
subgraph 管理端["管理端"]
D1[管理员查看提现列表<br/>GET /api/admin/withdrawals]
D2{审核决策}
D3[拒绝 → status = failed]
D4[批准 → 调用微信转账]
end
subgraph 微信["微信支付"]
E1[商家转账到零钱]
E2{微信返回 state}
E3[processing<br/>处理中]
E4[pending_confirm<br/>待用户确认收款]
end
subgraph 用户确认["用户确认"]
F1[获取待确认列表<br/>GET /api/miniprogram/withdraw/pending-confirm]
F2[领取零钱<br/>wx.requestMerchantTransfer]
F3[确认收款<br/>POST /api/miniprogram/withdraw/confirm-received]
end
subgraph 终态["终态"]
G1[success<br/>已到账]
G2[failed<br/>已拒绝/打款失败]
end
A1 --> A2 --> A3 --> B1
B1 --> B2 --> B3 --> B4 --> B5
B5 -->|通过| C1
C1 --> D1 --> D2
D2 -->|拒绝| D3 --> G2
D2 -->|批准| D4 --> E1 --> E2
E2 --> E3
E2 --> E4 --> F1 --> F2 --> F3 --> G1
E3 -->|回调/同步| G1
E3 -->|失败| G2
```
## 二、可提现金额计算逻辑
```mermaid
flowchart LR
subgraph 公式["可提现 = 累计佣金 - 已提现 - 待审核"]
A[累计佣金<br/>paid 订单佣金求和]
B[已提现<br/>status=success 的 sum]
C[待审核<br/>pending+processing+pending_confirm 的 sum]
end
A --> D[可提现]
B --> D
C --> D
```
## 三、状态流转
```mermaid
stateDiagram-v2
[*] --> pending: 用户申请
pending --> failed: 管理员拒绝
pending --> processing: 批准且微信直接处理
pending --> pending_confirm: 批准且需用户确认收款
processing --> success: 微信回调/主动同步
processing --> failed: 打款失败
pending_confirm --> success: 用户点击确认收款
success --> [*]
failed --> [*]
```
## 四、关键接口一览
| 端 | 接口 | 用途 |
|---|---|---|
| 小程序 | POST /api/miniprogram/withdraw | 申请提现 |
| 小程序 | GET /api/miniprogram/withdraw/records | 提现记录 |
| 小程序 | GET /api/miniprogram/withdraw/pending-confirm | 待确认收款列表 |
| 小程序 | GET /api/miniprogram/withdraw/confirm-info | 领取零钱参数 |
| 小程序 | POST /api/miniprogram/withdraw/confirm-received | 确认收款 |
| 管理端 | GET /api/admin/withdrawals | 提现列表 |
| 管理端 | PUT /api/admin/withdrawals | 审核/打款approve/reject |
| 管理端 | POST /api/admin/withdrawals/sync | 主动同步微信转账结果 |
## 五、校验规则(申请时)
1. **金额 > 0**
2. **金额 ≤ 可提现余额**(累计佣金 - 已提现 - 待审核)
3. **金额 ≥ 最低提现门槛**(默认 10 元,来自 referral_config.minWithdrawAmount
4. **用户存在**
5. **用户已绑定微信**openid 不为空)
批准时二次校验可提现金额,防止退款/冲正后超额打款。

View File

@@ -1,220 +0,0 @@
# Windows 上通过 WSL2 + QEMU/KVM 运行 macOS 虚拟机(安装方案)
> 目标:在 **Windows 10/11** 上,以 **WSL2Ubuntu+ QEMU/KVM + OpenCore 引导**的方式运行 macOS 虚拟机,用于演示/测试。
>
> 重要说明:
> - **macOS 不能用 Docker 容器运行**Docker 只运行 Linux 容器)。本方案使用的是 **虚拟机**。
> - 本方案 **不依赖 Docker Desktop**,也不会要求你卸载/改动 Docker Desktop。
---
## 1. 前置条件
- **Windows 10/11**(建议较新的版本)
- CPU 支持虚拟化Intel VT-x / AMD-V并在 BIOS/UEFI 中开启
- 管理员权限(启用 Windows 功能、安装 WSL、创建防火墙规则时可能需要
- 具备稳定网络(下载恢复镜像与依赖)
- 一个 VNC 客户端(例如 TightVNC/RealVNC/TigerVNC 等)
---
## 2. 启用 WSL2管理员 PowerShell
> 如果你已经能 `wsl -l -v` 正常看到发行版,可跳过本节。
```powershell
wsl --install
```
重启后,如需安装指定发行版:
```powershell
wsl --install -d Ubuntu-24.04 --web-download
```
验证:
```powershell
wsl -l -v
```
期望看到 `Ubuntu-24.04``VERSION``2`
---
## 3. 目录约定(建议)
建议把虚拟机相关文件统一放到一个目录,便于迁移与清理,例如:
- Windows`%USERPROFILE%\Mycontent\macos-vm`
- WSL`/mnt/c/Users/<你的用户名>/Mycontent/macos-vm`
在 Windows 侧先创建目录:
```powershell
mkdir "$env:USERPROFILE\Mycontent\macos-vm" -Force | Out-Null
```
---
## 4. 在 Ubuntu 中安装 QEMU / KVM 依赖
进入 Ubuntu
```powershell
wsl -d Ubuntu-24.04
```
在 Ubuntu 里执行:
```bash
sudo apt-get update -qq
sudo apt-get install -y qemu-system qemu-utils python3 python3-pip cpu-checker git curl unzip
```
验证 KVM
```bash
kvm-ok
```
期望输出包含:
- `INFO: /dev/kvm exists`
- `KVM acceleration can be used`
若没有 `/dev/kvm`,通常是 BIOS 虚拟化未开、或系统虚拟化相关功能未启用导致。
---
## 5. 获取启动脚本工程(推荐 ZIP 方式)
下面以 OneClick-macOS-Simple-KVM 为例(它主要负责:下载恢复镜像、准备虚拟盘、拼 QEMU 启动参数)。
在 Ubuntu 中进入工作目录:
```bash
cd /mnt/c/Users/<你的用户名>/Mycontent/macos-vm
```
下载并解压ZIP 方式更抗网络波动):
```bash
rm -rf OneClick-macOS-Simple-KVM OneClick.zip
curl -L --retry 8 --retry-delay 2 --connect-timeout 20 --max-time 600 \
-o OneClick.zip \
https://codeload.github.com/notAperson535/OneClick-macOS-Simple-KVM/zip/refs/heads/master
unzip -q OneClick.zip
mv OneClick-macOS-Simple-KVM-master OneClick-macOS-Simple-KVM
```
进入工程目录并授权:
```bash
cd OneClick-macOS-Simple-KVM
chmod +x *.sh *.py
```
---
## 6. 准备虚拟硬盘与恢复镜像(以 Ventura 为例)
创建系统盘(容量可按需调整):
```bash
[ -f macOS.qcow2 ] || qemu-img create -f qcow2 macOS.qcow2 64G
```
下载恢复镜像(脚本会拉取官方 Recovery/Restore 资源并生成/落盘):
```bash
python3 fetch-macOS-v2.py -s ventura
```
常见做法是把下载到的 dmg 统一命名为 `BaseSystem.dmg`,并转 raw
```bash
[ -f RecoveryImage.dmg ] && mv RecoveryImage.dmg BaseSystem.dmg
qemu-img convert BaseSystem.dmg -O raw BaseSystem.img
ls -lah BaseSystem.* macOS.qcow2
```
经验上你会看到:
- `BaseSystem.dmg` 数百 MB
- `BaseSystem.img` 数 GB
- `macOS.qcow2` 初始很小,安装完成后会逐渐变大
---
## 7. 启动虚拟机Headless + VNC
在 OneClick 目录启动Headless 表示不弹 GUI 窗口,改走 VNC
```bash
sudo HEADLESS=1 ./basic.sh
```
在 Windows 侧确认 VNC 端口监听(通常是 `5900`
```powershell
Get-NetTCPConnection -LocalPort 5900 -State Listen
```
然后用 VNC 客户端连接:
- `localhost:5900`
进入图形界面后,常规安装流程是:
- 先打开“磁盘工具”抹掉/格式化虚拟盘APFS
- 退出磁盘工具
- 选择“安装 macOS”
安装过程中会多次重启属正常。
---
## 8. 常见问题
### 8.1 `kvm-ok` 不通过 / 没有 `/dev/kvm`
- 检查 BIOS/UEFI 是否开启虚拟化
- 确保启用了 Windows 虚拟化相关功能WSL2、VirtualMachinePlatform 等)
- 若你已经在用 WSL2但仍没有 `/dev/kvm`,通常是环境限制或虚拟化被占用/策略限制
### 8.2 VNC 连不上
- Windows 侧确认 `5900` 端口确实在 Listen
- 检查防火墙策略(本机连 `localhost` 一般不需要额外放行)
- 确认启动脚本没有退出WSL 窗口关闭会导致 VM 退出)
---
## 9. 卸载/清理(不动 Docker Desktop
### 9.1 只删除 macOS VM 文件(保留 Ubuntu
直接删目录即可(示例路径):
```powershell
Remove-Item -Recurse -Force "$env:USERPROFILE\Mycontent\macos-vm"
```
### 9.2 卸载 Ubuntu保留 Docker Desktop
先确认 WSL 中有哪些发行版:
```powershell
wsl -l -v
```
只卸载 Ubuntu示例 `Ubuntu-24.04`
```powershell
wsl --terminate Ubuntu-24.04
wsl --unregister Ubuntu-24.04
```
> 注意:以上会删除 Ubuntu 发行版的全部数据,但不会卸载 Docker Desktop也不会动 `docker-desktop` 发行版。