Merge branch 'yongxu-dev' into devlop

# Conflicts:
#	miniprogram/pages/profile-edit/profile-edit.js
#	miniprogram/pages/profile-edit/profile-edit.wxml
#	miniprogram/pages/settings/settings.js
#	miniprogram/utils/ruleEngine.js
#	soul-admin/src/pages/distribution/DistributionPage.tsx
#	soul-admin/src/pages/users/UsersPage.tsx
#	soul-api/.env.production
#	soul-api/.gitignore
#	soul-api/internal/handler/db_ckb_leads.go
#	soul-api/internal/handler/miniprogram.go
#	soul-api/internal/handler/referral.go
#	开发文档/1、需求/archive/链接人与事-存客宝同步-需求规划.md
#	开发文档/1、需求/archive/链接人与事-实现方案.md
This commit is contained in:
Alex-larget
2026-03-20 14:48:02 +08:00
247 changed files with 8990 additions and 6983 deletions

Binary file not shown.

48
soul-api/.dockerignore Normal file
View File

@@ -0,0 +1,48 @@
# Git
.git
.gitignore
# 本地开发(部署时环境文件需进构建上下文,由 Dockerfile COPY ${ENV_FILE} → /app/.env
.env
.env.*.local
*.local
!.env.development
!.env.production
!.env
# 构建产物
# 注意:不可忽略 soul-api —— Dockerfile.local 需 COPY 本地交叉编译的二进制;多阶段 Dockerfile 内 go build 会覆盖
*.exe
__pycache__
*.pyc
# 上传文件(运行时挂载,不打包进镜像)
uploads/
# certs 必须打包进镜像,否则微信支付无效
# 日志
*.log
*.log.gz
wechat/
# 文档 / 脚本(非运行必需)
*.md
scripts/
*.sql
*.bat
*.py
master.py
miner_cleanup.py
disk_analysis.py
vuln_analysis.py
# IDE / 配置
.air.toml
.vscode
.idea
*.swp
*.swo
# 测试
*_test.go
qgL5DeGe9A.txt

60
soul-api/.env.production Normal file
View File

@@ -0,0 +1,60 @@
# 正式环境配置(部署时复制为 .envmaster.py 打包用)
APP_ENV=production
# 服务(启动端口在 .env 中配置,修改 PORT 后重启生效)
PORT=8080
GIN_MODE=debug
# 版本号:打包 zip 前在此填写,上传服务器覆盖 .env 后,访问 /health 会返回此版本
APP_VERSION=0.0.0
# 数据库(与 Next 现网一致:腾讯云 CDB soul_miniprogram
DB_DSN=cdb_outerroot:Zhiqun1984@tcp(56b4c23f6853c.gz.cdb.myqcloud.com:14413)/soul_miniprogram?charset=utf8mb4&parseTime=True
# Redis服务器实例端口 6379密码 ckb@!;同机用 localhost跨机用 Redis 服务器 IP
# 密码含特殊字符需 URL 编码:@ -> %40, ! -> %21
REDIS_URL=redis://:ckb%40%21@localhost:6379/0
# 统一 API 域名支付回调、转账回调、apiDomain 等由此派生;无需尾部斜杠)
API_BASE_URL=https://soulapi.quwanzhi.com
#添加卡若(内部 API用于 /v1/api/scenarios
CKB_LEAD_API_KEY=2y4v5-rjhfc-sg5wy-zklkv-bg0tl
# 存客宝开放 API创建/更新/删除获客计划、拉取设备列表
# - CKB_OPEN_API_KEY开放 API Key开发文档中的 mI9Ol-NO6cS-ho3Py-7Pj22-WyK3A
# - CKB_OPEN_ACCOUNT对应的存客宝登录账号手机号或用户名
CKB_OPEN_API_KEY=mI9Ol-NO6cS-ho3Py-7Pj22-WyK3A
CKB_OPEN_ACCOUNT=karuo1
# 微信小程序配置
WECHAT_APPID=wxb8bbb2b10dec74aa
WECHAT_APPSECRET=3c1fb1f63e6e052222bbcead9d07fe0c
WECHAT_MCH_ID=1318592501
WECHAT_MCH_KEY=wx3e31b068be59ddc131b068be59ddc2
# 支付回调:未设置时由 API_BASE_URL 派生
# WECHAT_NOTIFY_URL=https://soulapi.quwanzhi.com/api/miniprogram/pay/notify
# 微信转账配置API v3
WECHAT_APIV3_KEY=wx3e31b068be59ddc131b068be59ddc2
# 公钥证书(本地或 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
WECHAT_CERT_PATH=certs/apiclient_cert.pem
# 私钥(线上用 OSShttps://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem
WECHAT_KEY_PATH=certs/apiclient_key.pem
WECHAT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
# 转账回调:未设置时由 API_BASE_URL 派生
# WECHAT_TRANSFER_URL=https://soulapi.quwanzhi.com/api/payment/wechat/transfer/notify
# 管理端登录(与 next-project 一致,默认 admin / admin123
# ADMIN_USERNAME=admin
# ADMIN_PASSWORD=admin123
# ADMIN_SESSION_SECRET=soul-admin-secret-change-in-prod
# 可选:信任代理 IP逗号分隔部署在 Nginx 后时填写
# TRUSTED_PROXIES=127.0.0.1,::1
# 跨域 CORS允许的源逗号分隔。未设置时使用默认值含 localhost、soul.quwanzhi.com
CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,https://soul.quwanzhi.com,http://soul.quwanzhi.com,https://souladmin.quwanzhi.com,http://souladmin.quwanzhi.com

3
soul-api/.gitignore vendored
View File

@@ -1,7 +1,6 @@
tmp/
log/
soul-api
soul-api-linux
server.exe
soul-api.exe
wechat/info.log
deploy/

View File

@@ -1 +0,0 @@
# 请将伪静态规则或自定义Apache配置填写到此处

Binary file not shown.

Binary file not shown.

Binary file not shown.

995
soul-api/devloy.py Normal file
View File

@@ -0,0 +1,995 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
soul-api 一键部署到宝塔【测试环境】
打包原则:优先使用本地已有资源,不边打包边下载(省时)。
- 默认:本地 go build → Dockerfile.local 打镜像(--pull=false 不拉 base 镜像)
- 首次部署前请本地先拉好alpine:3.19、redis:7-alpine后续一律用本地缓存
三种模式:
- 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
环境变量DEPLOY_DOCKER_PATH、DEPLOY_NGINX_CONF、DEPLOY_HOST、DEPLOY_RUNNER_CONTAINER 等
"""
from __future__ import print_function
import hashlib
import os
import sys
import tempfile
import argparse
import subprocess
import shutil
import tarfile
import time
try:
import paramiko
except ImportError:
print("错误: 请先安装 paramiko")
print(" pip install paramiko")
sys.exit(1)
try:
import requests
try:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except Exception:
pass
except ImportError:
requests = None
# ==================== 配置 ====================
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-dev"
DEPLOY_DOCKER_PATH = os.environ.get("DEPLOY_DOCKER_PATH", "/www/wwwroot/self/soul-dev")
DEPLOY_NGINX_CONF = os.environ.get("DEPLOY_NGINX_CONF", "") # 如 /www/server/panel/vhost/nginx/soulapi.quwanzhi.com.conf
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
# 宝塔 API 密钥(写死,用于部署后重启 Go 项目)
BT_API_KEY_DEFAULT = "qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT"
def get_cfg():
host = os.environ.get("DEPLOY_HOST", "43.139.27.93")
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).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"),
}
# ==================== 本地构建 ====================
def run_build(root):
"""交叉编译 Go 二进制Linux amd64"""
print("[1/4] 本地交叉编译 Go 二进制 ...")
env = os.environ.copy()
env["GOOS"] = "linux"
env["GOARCH"] = "amd64"
env["CGO_ENABLED"] = "0"
cmd = ["go", "build", "-o", "soul-api", "./cmd/server"]
try:
r = subprocess.run(
cmd,
cwd=root,
env=env,
shell=False,
timeout=120,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if r.returncode != 0:
print(" [失败] go build 失败,退出码:", r.returncode)
if r.stderr:
for line in (r.stderr or "").strip().split("\n")[-10:]:
print(" " + line)
return None
out_path = os.path.join(root, "soul-api")
if not os.path.isfile(out_path):
print(" [失败] 未找到编译产物 soul-api")
return None
print(" [成功] 编译完成: %s (%.2f MB)" % (out_path, os.path.getsize(out_path) / 1024 / 1024))
return out_path
except subprocess.TimeoutExpired:
print(" [失败] 编译超时")
return None
except FileNotFoundError:
print(" [失败] 未找到 go 命令,请安装 Go")
return None
except Exception as e:
print(" [失败] 编译异常:", str(e))
return None
# ==================== 打包 ====================
DEPLOY_PORT = 9001
def set_env_port(env_path, port=DEPLOY_PORT):
"""将 .env 文件中的 PORT 设为指定值(用于部署包)"""
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() == "PORT":
new_lines.append("PORT=%s\n" % port)
found = True
else:
new_lines.append(line)
if not found:
new_lines.append("PORT=%s\n" % port)
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
f.writelines(new_lines)
def set_env_mini_program_state(env_path, state):
"""将 .env 中的 WECHAT_MINI_PROGRAM_STATE 设为 developer/formal"""
if not os.path.isfile(env_path):
return
key = "WECHAT_MINI_PROGRAM_STATE"
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, state))
found = True
else:
new_lines.append(line)
if not found:
new_lines.append("%s=%s\n" % (key, state))
with open(env_path, "w", encoding="utf-8", newline="\n") as f:
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"):
p = os.path.join(root, name)
if os.path.isfile(p):
return p, name
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] 打包部署文件 ...")
staging = tempfile.mkdtemp(prefix="soul_api_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, DEPLOY_PORT)
set_env_mini_program_state(staging_env, "developer")
print(" [已设置] PORT=%s, WECHAT_MINI_PROGRAM_STATE=developer测试环境" % DEPLOY_PORT)
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):
tf.add(os.path.join(staging, name), arcname=name)
print(" [成功] 打包完成: %s (%.2f MB)" % (tarball, os.path.getsize(tarball) / 1024 / 1024))
return tarball
except Exception as e:
print(" [失败] 打包异常:", str(e))
return None
finally:
shutil.rmtree(staging, ignore_errors=True)
# ==================== 宝塔 API 重启 ====================
def restart_via_bt_api(cfg):
"""通过宝塔 API 重启 Go 项目"""
url = cfg.get("bt_panel_url") or ""
key = cfg.get("bt_api_key") or ""
name = cfg.get("bt_go_project_name", "soulDev")
if not url or not key:
return False
if not requests:
print(" [提示] 未安装 requestspip install requests")
return False
try:
req_time = int(time.time())
sk_md5 = hashlib.md5(key.encode()).hexdigest()
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
base = url.rstrip("/")
params = {"request_time": req_time, "request_token": req_token}
for action in ("stop_go_project", "start_go_project"):
data = dict(params)
data["action"] = action
data["project_name"] = name
r = requests.post(base + "/plugin?name=go_project", data=data, timeout=15, verify=False)
if r.status_code != 200:
continue
j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
if action == "stop_go_project":
time.sleep(2)
if j.get("status") is False and j.get("msg"):
print(" [宝塔API] %s: %s" % (action, j.get("msg", "")))
data = dict(params)
data["action"] = "start_go_project"
data["project_name"] = name
r = requests.post(base + "/plugin?name=go_project", data=data, timeout=15, verify=False)
if r.status_code == 200:
j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
if j.get("status") is True:
print(" [成功] 已通过宝塔 API 重启 Go 项目: %s" % name)
return True
return False
except Exception as e:
print(" [宝塔API 失败] %s" % str(e))
return False
# ==================== SSH 上传 ====================
def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto"):
"""上传 tar.gz 到服务器并解压、重启"""
print("[3/4] SSH 上传并解压 ...")
if not cfg.get("password") and not cfg.get("ssh_key"):
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return False
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=15)
else:
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=15)
sftp = client.open_sftp()
project_path = cfg["project_path"]
remote_tar = project_path + "/soul_api_deploy.tar.gz"
sftp.put(tarball_path, remote_tar)
sftp.close()
cmd = "mkdir -p %s && cd %s && tar -xzf %s && chmod +x soul-api && rm -f %s && echo OK" % (project_path, project_path, remote_tar, remote_tar)
stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
out = stdout.read().decode("utf-8", errors="replace").strip()
exit_status = stdout.channel.recv_exit_status()
if exit_status != 0 or "OK" not in out:
print(" [失败] 解压失败,退出码:", exit_status)
return False
print(" [成功] 已解压到: %s" % project_path)
if not no_restart:
print("[4/4] 重启 soulDev 服务 ...")
ok = False
if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")):
ok = restart_via_bt_api(cfg)
if not ok and restart_method in ("auto", "ssh"):
restart_cmd = (
"cd %s && T=$(readlink -f .) && for p in $(pgrep -f soul-api 2>/dev/null); do "
'[ "$(readlink -f /proc/$p/cwd 2>/dev/null)" = "$T" ] && kill $p 2>/dev/null; done; '
"sleep 2; setsid nohup ./soul-api >> soul-api.log 2>&1 </dev/null & "
"sleep 3; T=$(readlink -f .) && for p in $(pgrep -f soul-api 2>/dev/null); do "
'[ "$(readlink -f /proc/$p/cwd 2>/dev/null)" = "$T" ] && echo RESTART_OK && exit 0; done; echo RESTART_FAIL'
) % project_path
stdin, stdout, stderr = client.exec_command(restart_cmd, timeout=20)
out = stdout.read().decode("utf-8", errors="replace").strip()
err = (stderr.read().decode("utf-8", errors="replace") or "").strip()
if err:
print(" [stderr] %s" % err[:200])
ok = "RESTART_OK" in out
if ok:
print(" [成功] soulDev 已通过 SSH 重启")
else:
print(" [警告] 请手动启动: cd %s && ./soul-api" % project_path)
else:
print("[4/4] 跳过重启 (--no-restart)")
return True
except Exception as e:
print(" [失败] SSH 错误:", str(e))
return False
finally:
client.close()
# ==================== 宝塔 API - Nginx 配置与重载 ====================
def _bt_request(cfg, endpoint, data):
"""宝塔 API 通用请求request_time + request_token 签名)"""
if not requests:
return None, None
url = (cfg.get("bt_panel_url") or "").rstrip("/")
key = cfg.get("bt_api_key") or ""
if not url or not key:
return None, None
try:
req_time = int(time.time())
sk_md5 = hashlib.md5(key.encode()).hexdigest()
req_token = hashlib.md5(("%s%s" % (req_time, sk_md5)).encode()).hexdigest()
payload = dict(data)
payload["request_time"] = req_time
payload["request_token"] = req_token
r = requests.post(url + endpoint, data=payload, timeout=15, verify=False)
if r.status_code != 200:
return None, r
j = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
return j, r
except Exception as e:
print(" [宝塔API] 请求异常:", str(e))
return None, None
def deploy_nginx_via_bt_api(cfg, nginx_conf_path, new_port):
"""
通过宝塔 API 更新 Nginx 配置并重载。
- 使用 files.GetFileBody 读取配置
- 替换 proxy_pass 端口
- 使用 files.SaveFileBody 保存
- 尝试 service 插件重载 nginx
"""
if not nginx_conf_path or not new_port:
return False
if not requests:
print(" [提示] 未安装 requests无法使用宝塔 Nginx API。pip install requests")
return False
# 1. 读取配置
j, _ = _bt_request(cfg, "/files?action=GetFileBody", {"path": nginx_conf_path})
if not j or "status" in j and j.get("status") is False:
print(" [宝塔API] 读取 Nginx 配置失败:", j.get("msg", "未知错误") if j else "")
return False
content = j.get("data") or j.get("content") or ""
if isinstance(content, bytes):
content = content.decode("utf-8", errors="replace")
# 2. 替换 proxy_pass 端口
import re
new_content = re.sub(
r"proxy_pass\s+http://127\.0\.0\.1:\d+",
"proxy_pass http://127.0.0.1:%s" % new_port,
content,
flags=re.IGNORECASE,
)
if new_content == content:
print(" [宝塔API] 未找到 proxy_pass可能已是目标端口或格式不符")
# 3. 保存配置
j, _ = _bt_request(cfg, "/files?action=SaveFileBody", {
"path": nginx_conf_path,
"data": new_content,
"encoding": "utf-8",
})
if not j or "status" in j and j.get("status") is False:
print(" [宝塔API] 保存 Nginx 配置失败:", j.get("msg", "未知错误") if j else "")
return False
# 4. 重载 Nginx尝试 service 插件)
for try_action, try_name in [
("reload", "nginx"),
("RestartService", "nginx"),
]:
j, _ = _bt_request(cfg, "/service?action=%s" % try_action, {"name": try_name})
if j and j.get("status") is True:
print(" [成功] 已通过宝塔 API 重载 Nginx (端口 %s)" % new_port)
return True
# 部分面板无 service 接口,配置已保存,需手动重载
print(" [提示] Nginx 配置已通过宝塔 API 更新,重载请到面板操作或使用 SSH: nginx -s reload")
return True
# ==================== Docker 部署(蓝绿无缝切换) ====================
def resolve_docker_env_file(root, explicit=None):
"""
选择打入镜像的环境文件(相对 soul-api 根目录,须能被 Docker 构建上下文包含)。
Dockerfile: COPY ${ENV_FILE} /app/.envcerts/ 由 COPY certs/ 一并打入。
优先级explicit → DOCKER_ENV_FILE → 自动 .env.development > .env.production > .env与测试环境默认一致
"""
if explicit:
name = os.path.basename(explicit.replace("\\", "/"))
path = os.path.join(root, name)
if os.path.isfile(path):
print(" [镜像配置] 打入镜像的环境文件: %s--env-file" % name)
return name
print(" [失败] --env-file 不存在: %s" % path)
return None
override = (os.environ.get("DOCKER_ENV_FILE") or "").strip()
if override:
name = os.path.basename(override.replace("\\", "/"))
path = os.path.join(root, name)
if os.path.isfile(path):
print(" [镜像配置] 打入镜像的环境文件: %sDOCKER_ENV_FILE" % name)
return name
print(" [失败] DOCKER_ENV_FILE 指向的文件不存在: %s" % path)
return None
for name in (".env.development", ".env.production", ".env"):
if os.path.isfile(os.path.join(root, name)):
print(" [镜像配置] 打入镜像的环境文件: %s(自动选择)" % name)
return name
print(" [失败] 未找到 .env.development / .env.production / .env无法 COPY 进镜像")
return None
def run_docker_build(root, env_file=".env.development"):
"""本地构建 Docker 镜像(使用 Docker 内的 golang 镜像)"""
print("[1/5] 构建 Docker 镜像 ...(进度见下方 Docker 输出)")
try:
cmd = ["docker", "build", "--pull=false", "-f", "deploy/Dockerfile", "-t", "soul-api:latest", "--build-arg", "ENV_FILE=%s" % env_file, "--progress=plain", "."]
r = subprocess.run(cmd, cwd=root, shell=False, timeout=300)
if r.returncode != 0:
print(" [失败] docker build 失败,退出码:", r.returncode)
return None
print(" [成功] 镜像构建完成 soul-api:latest")
return True
except FileNotFoundError:
print(" [失败] 未找到 docker 命令,请安装 Docker")
return None
except subprocess.TimeoutExpired:
print(" [失败] 构建超时")
return None
except Exception as e:
print(" [失败] 构建异常:", str(e))
return None
def run_docker_build_local(root, env_file=".env.development"):
"""使用本地 Go 交叉编译后构建 Docker 镜像(不拉取 golang 镜像,--pull=false 不拉 base 镜像)"""
print("[1/5] 使用本地 Go 交叉编译 ...")
binary_path = run_build(root)
if not binary_path:
return None
print("[2/5] 使用 Dockerfile.local 构建镜像 ...--pull=false 仅用本地缓存)")
try:
cmd = ["docker", "build", "--pull=false", "-f", "deploy/Dockerfile.local", "-t", "soul-api:latest",
"--build-arg", "ENV_FILE=%s" % env_file, "--progress=plain", "."]
r = subprocess.run(cmd, cwd=root, shell=False, timeout=120)
if r.returncode != 0:
print(" [失败] docker build 失败,退出码:", r.returncode)
return None
print(" [成功] 镜像构建完成 soul-api:latest本地 Go 参与构建)")
return True
except FileNotFoundError:
print(" [失败] 未找到 docker 命令,请安装 Docker")
return None
except subprocess.TimeoutExpired:
print(" [失败] 构建超时")
return None
except Exception as e:
print(" [失败] 构建异常:", str(e))
return None
def pack_docker_image(root):
"""仅导出 soul-api 镜像为 tar.gz线上 Redis 已在运行,不再打包/加载)"""
import gzip
print("[3/5] 导出镜像为 tar.gzsoul-api only...")
out_tar = os.path.join(tempfile.gettempdir(), "soul_api_image.tar.gz")
try:
r = subprocess.run(
["docker", "save", "soul-api:latest"],
capture_output=True,
timeout=180,
cwd=root,
)
if r.returncode != 0:
stderr = (r.stderr or b"").decode("utf-8", errors="replace")[:300]
print(" [失败] docker save 失败:", stderr)
print(" [提示] 请确保本地有 redis 镜像,执行: docker images | findstr redis 查看名称")
return None
with gzip.open(out_tar, "wb") as f:
f.write(r.stdout)
if not os.path.isfile(out_tar) or os.path.getsize(out_tar) < 1000:
print(" [失败] 导出文件异常")
return None
print(" [成功] 导出完成: %.2f MBsoul-api only" % (os.path.getsize(out_tar) / 1024 / 1024))
return out_tar
except subprocess.TimeoutExpired:
print(" [失败] docker save 超时")
return None
except Exception as e:
print(" [失败] 导出异常:", str(e))
return None
def upload_and_deploy_docker(cfg, image_tar_path, include_env=True, deploy_method="ssh"):
"""上传镜像与配置到服务器执行蓝绿部署。deploy_method: ssh=脚本内 Nginx 切换, btapi=宝塔 API 更新 Nginx"""
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__))
print("[4/5] SSH 上传镜像与配置 ...")
if not cfg.get("password") and not cfg.get("ssh_key"):
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return False
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], key_filename=cfg["ssh_key"], timeout=30)
else:
client.connect(cfg["host"], port=DEFAULT_SSH_PORT, username=cfg["user"], password=cfg["password"], timeout=30)
sftp = client.open_sftp()
remote_tar = deploy_path + "/soul_api_image.tar.gz"
sftp.put(image_tar_path, remote_tar)
print(" [已上传] 镜像 tar.gz")
compose_local = os.path.join(script_dir, "deploy", "docker-compose.bluegreen.yml")
deploy_local = os.path.join(script_dir, "deploy", "docker-deploy-remote.sh")
env_local = os.path.join(script_dir, ".env.production")
if os.path.isfile(compose_local):
sftp.put(compose_local, deploy_path + "/docker-compose.bluegreen.yml")
print(" [已上传] docker-compose.bluegreen.yml")
if os.path.isfile(deploy_local):
sftp.put(deploy_local, deploy_path + "/docker-deploy-remote.sh")
print(" [已上传] docker-deploy-remote.sh")
# 注意docker-compose.bluegreen.yml 未配置 env_file容器实际以镜像内 /app/.env 为准;
# 此处上传仅供服务器目录备份或手工改 compose 后使用。
if include_env and os.path.isfile(env_local):
sftp.put(env_local, deploy_path.rstrip("/") + "/.env")
print(" [已上传] .env.production -> 服务器 %s/.env可选默认不挂载进容器" % deploy_path.rstrip("/"))
# btapi 模式:需先读取 .active 计算新端口,脚本内跳过 Nginx
current_active = "blue"
if deploy_method == "btapi" and nginx_conf:
try:
active_file = deploy_path.rstrip("/") + "/.active"
with sftp.open(active_file, "r") as f:
current_active = (f.read().decode("utf-8", errors="replace") or "blue").strip() or "blue"
except Exception:
pass
new_port = 9002 if current_active == "blue" else 9001
sftp.close()
print("[5/5] 执行蓝绿部署 ...")
env_exports = ""
if nginx_conf:
env_exports += "export DEPLOY_NGINX_CONF='%s'; " % nginx_conf.replace("'", "'\\''")
env_exports += "export DEPLOY_DOCKER_PATH='%s'; " % deploy_path.replace("'", "'\\''")
script_args = remote_tar
if deploy_method == "btapi" and nginx_conf:
script_args += " --skip-nginx"
cmd = "mkdir -p %s && %s cd %s && chmod +x docker-deploy-remote.sh && ./docker-deploy-remote.sh %s" % (deploy_path, env_exports, deploy_path, script_args)
stdin, stdout, stderr = client.exec_command(cmd, 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[:500])
if exit_status != 0:
print(" [失败] 远程部署脚本退出码:", exit_status)
return False
# btapi 模式:通过宝塔 API 更新 Nginx 配置并重载new_port 已在上方计算)
if deploy_method == "btapi" and nginx_conf:
try:
print(" [宝塔 API] 更新 Nginx ...")
deploy_nginx_via_bt_api(cfg, nginx_conf, new_port)
except Exception as e:
print(" [警告] 宝塔 Nginx API 失败:", str(e))
print(" 部署完成,蓝绿无缝切换")
return True
except Exception as e:
print(" [失败] SSH 错误:", str(e))
return False
finally:
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", "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镜像内配置不变")
parser.add_argument("--no-restart", action="store_true", help="[binary] 上传后不重启")
parser.add_argument("--restart-method", choices=("auto", "btapi", "ssh"), default="auto",
help="[binary] 重启方式: auto/btapi/ssh")
parser.add_argument("--docker-in-go", action="store_true",
help="[docker] 在 Docker 内用 golang 镜像编译(默认:本地 go build → 再打镜像)")
parser.add_argument("--deploy-method", choices=("ssh", "btapi"), default="ssh",
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)
print(" soul-api 测试环境 Docker 蓝绿部署(无缝切换)")
print("=" * 60)
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
print(" 目标目录: %s" % docker_path)
print("=" * 60)
if not args.no_build:
env_for_image = resolve_docker_env_file(root, explicit=args.env_file)
if env_for_image is None:
return 1
# 默认:本地 go build → Dockerfile.local 打镜像;--docker-in-go 时在容器内编译
ok = (
run_docker_build(root, env_file=env_for_image)
if args.docker_in_go
else run_docker_build_local(root, env_file=env_for_image)
)
if not ok:
return 1
else:
print("[1/5] 跳过构建,使用现有 soul-api:latest无需本地环境文件")
image_tar = pack_docker_image(root)
if not image_tar:
return 1
if not upload_and_deploy_docker(cfg, image_tar, include_env=not args.no_env, deploy_method=args.deploy_method):
return 1
try:
os.remove(image_tar)
except Exception:
pass
print("")
print(" 部署完成!测试环境蓝绿无缝切换")
return 0
# ===== Binary 模式 =====
print("=" * 60)
print(" soul-api 测试环境 部署到宝塔,重启 soulDev")
print("=" * 60)
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
print(" 目标目录: %s" % cfg["project_path"])
print("=" * 60)
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_deploy(root, binary_path, include_env=not args.no_env)
if not tarball:
return 1
if not upload_and_extract(cfg, tarball, no_restart=args.no_restart, restart_method=args.restart_method):
return 1
try:
os.remove(tarball)
except Exception:
pass
print("")
print(" 部署完成!目录: %s" % cfg["project_path"])
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,18 +0,0 @@
# soul-api 本地开发用 Redis
# 启动docker compose up -d
# 停止docker compose down
# 若拉取失败,可配置 Docker Desktop → Settings → Docker Engine → registry-mirrors
services:
redis:
# 使用 DaoCloud 镜像(国内加速);若已配置 daemon 镜像源可改回 redis:7-alpine
image: docker.m.daocloud.io/library/redis:7-alpine
container_name: soul-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
restart: unless-stopped
volumes:
redis_data:

View File

@@ -1,16 +1,16 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
soul-api Go 项目一键部署到宝塔测试环境重启的是宝塔里的 soulDev 项目
- 打包使用 .env.development 作为服务器 .env
soulApp (soul-api) Go 项目一键部署到宝塔正式环境
- 打包使用 .env.production 作为服务器 .env
- 本地交叉编译 Linux 二进制
- 上传到 /www/wwwroot/self/soul-dev
- 重启 soulDev优先宝塔 API需配置否则 SSH setsid nohup 启动
- 上传到 /www/wwwroot/self/soul-api
- 重启优先宝塔 API需配置否则 SSH setsid nohup 启动
宝塔 API 重启可选在环境变量或 .env 中设置
BT_PANEL_URL = https://你的面板地址:9988
BT_API_KEY = 面板 设置 -> API 接口 中的密钥
BT_GO_PROJECT_NAME = soulDev 与宝塔 Go 项目列表里名称一致
BT_GO_PROJECT_NAME = soulApi 与宝塔 Go 项目列表里名称一致
并安装 requests: pip install requests
"""
@@ -45,7 +45,7 @@ except ImportError:
# ==================== 配置 ====================
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-dev"
DEPLOY_PROJECT_PATH = "/www/wwwroot/self/soul-api"
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
@@ -66,7 +66,7 @@ def get_cfg():
"project_path": os.environ.get("DEPLOY_PROJECT_PATH", DEPLOY_PROJECT_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"),
"bt_go_project_name": os.environ.get("BT_GO_PROJECT_NAME", "soulApi"),
}
@@ -119,7 +119,7 @@ def run_build(root):
# ==================== 打包 ====================
DEPLOY_PORT = 8081
DEPLOY_PORT = 8080
def set_env_port(env_path, port=DEPLOY_PORT):
@@ -171,11 +171,11 @@ def pack_deploy(root, binary_path, include_env=True):
staging = tempfile.mkdtemp(prefix="soul_api_deploy_")
try:
shutil.copy2(binary_path, os.path.join(staging, "soul-api"))
env_src = os.path.join(root, ".env.development")
env_src = os.path.join(root, ".env.production")
staging_env = os.path.join(staging, ".env")
if include_env and os.path.isfile(env_src):
shutil.copy2(env_src, staging_env)
print(" [已包含] .env.development -> .env")
print(" [已包含] .env.production -> .env")
else:
env_example = os.path.join(root, ".env.example")
if os.path.isfile(env_example):
@@ -183,8 +183,8 @@ def pack_deploy(root, binary_path, include_env=True):
print(" [已包含] .env.example -> .env (请服务器上检查配置)")
if os.path.isfile(staging_env):
set_env_port(staging_env, DEPLOY_PORT)
set_env_mini_program_state(staging_env, "developer")
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=developer测试环境)" % DEPLOY_PORT)
set_env_mini_program_state(staging_env, "formal")
print(" [已设置] PORT=%s(部署用), WECHAT_MINI_PROGRAM_STATE=formal正式环境)" % DEPLOY_PORT)
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):
@@ -205,7 +205,7 @@ def restart_via_bt_api(cfg):
"""通过宝塔 API 重启 Go 项目(需配置 BT_PANEL_URL、BT_API_KEY、BT_GO_PROJECT_NAME"""
url = cfg.get("bt_panel_url") or ""
key = cfg.get("bt_api_key") or ""
name = cfg.get("bt_go_project_name", "soulDev")
name = cfg.get("bt_go_project_name", "soulApi")
if not url or not key:
return False
if not requests:
@@ -255,32 +255,57 @@ def restart_via_bt_api(cfg):
# ==================== SSH 上传 ====================
def _connect_ssh(cfg):
"""建立 SSH 连接,启用 keepalive 防大文件上传时断连"""
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=15,
)
else:
client.connect(
cfg["host"], port=DEFAULT_SSH_PORT,
username=cfg["user"], password=cfg["password"],
timeout=15,
)
transport = client.get_transport()
if transport:
transport.set_keepalive(15)
return client
def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto"):
"""上传 tar.gz 到服务器并解压、重启"""
print("[3/4] SSH 上传并解压 ...")
if not cfg.get("password") and not cfg.get("ssh_key"):
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return False
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
remote_tar = "/tmp/soul_api_deploy.tar.gz"
project_path = cfg["project_path"]
client = None
try:
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
client.connect(
cfg["host"], port=DEFAULT_SSH_PORT,
username=cfg["user"], key_filename=cfg["ssh_key"],
timeout=15,
)
else:
client.connect(
cfg["host"], port=DEFAULT_SSH_PORT,
username=cfg["user"], password=cfg["password"],
timeout=15,
)
sftp = client.open_sftp()
remote_tar = "/tmp/soul_api_deploy.tar.gz"
project_path = cfg["project_path"]
sftp.put(tarball_path, remote_tar)
sftp.close()
# SFTP 上传易因网络抖动 EOF失败时重连并重试最多 3 次
for attempt in range(1, 4):
try:
if client:
try:
client.close()
except Exception:
pass
client = _connect_ssh(cfg)
sftp = client.open_sftp()
sftp.put(tarball_path, remote_tar)
sftp.close()
break
except (EOFError, ConnectionResetError, OSError) as e:
if attempt < 3:
print(" [重试 %d/3] 上传中断: %s5 秒后重连 ..." % (attempt, e))
time.sleep(5)
else:
raise
cmd = (
"mkdir -p %s && cd %s && tar -xzf %s && "
@@ -295,7 +320,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
print(" [成功] 已解压到: %s" % project_path)
if not no_restart:
print("[4/4] 重启 soulDev 服务 ...")
print("[4/4] 重启 soulApp 服务 ...")
ok = False
if restart_method in ("auto", "btapi") and (cfg.get("bt_panel_url") and cfg.get("bt_api_key")):
ok = restart_via_bt_api(cfg)
@@ -315,7 +340,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
print(" [stderr] %s" % err[:200])
ok = "RESTART_OK" in out
if ok:
print(" [成功] soulDev 已通过 SSH 重启")
print(" [成功] soulApp 已通过 SSH 重启")
else:
print(" [警告] SSH 重启状态未知,请到宝塔 Go 项目里手动点击启动,或执行: cd %s && ./soul-api" % project_path)
else:
@@ -323,10 +348,17 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
return True
except Exception as e:
print(" [失败] SSH 错误:", str(e))
err_msg = str(e) or repr(e) or type(e).__name__
print(" [失败] SSH 错误:", err_msg)
import traceback
traceback.print_exc()
return False
finally:
client.close()
if client:
try:
client.close()
except Exception:
pass
# ==================== 主函数 ====================
@@ -334,7 +366,7 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
def main():
parser = argparse.ArgumentParser(
description="soul-api 一键部署到宝塔,重启 soulDev 项目",
description="soulApp (soul-api) Go 项目一键部署到宝塔",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--no-build", action="store_true", help="跳过本地编译(使用已有 soul-api 二进制)")
@@ -353,7 +385,7 @@ def main():
cfg = get_cfg()
print("=" * 60)
print(" soul-api 部署到宝塔,重启 soulDev")
print(" soulApp 一键部署到宝塔")
print("=" * 60)
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
print(" 目标目录: %s" % cfg["project_path"])

View File

@@ -21,6 +21,7 @@ require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/gin-contrib/gzip v1.2.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect
golang.org/x/mod v0.31.0 // indirect

View File

@@ -76,6 +76,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=

View File

@@ -48,10 +48,15 @@ const KeyConfigAuditMode = "soul:config:audit-mode"
const KeyConfigCore = "soul:config:core"
const KeyConfigReadExtras = "soul:config:read-extras"
// Get 从 Redis 读取,未配置或失败返回 nil(调用方回退 DB
// Get 从 Redis 读取,未配置或失败时尝试内存备用;均失败返回 false(调用方回退 DB
func Get(ctx context.Context, key string, dest interface{}) bool {
client := redis.Client()
if client == nil {
// Redis 不可用,使用内存备用
if data, ok := memoryGet(key); ok && dest != nil && len(data) > 0 {
_ = json.Unmarshal(data, dest)
return true
}
return false
}
if ctx == nil {
@@ -61,6 +66,11 @@ func Get(ctx context.Context, key string, dest interface{}) bool {
defer cancel()
val, err := client.Get(ctx, key).Bytes()
if err != nil {
// Redis 超时/失败时尝试内存备用
if data, ok := memoryGet(key); ok && dest != nil && len(data) > 0 {
_ = json.Unmarshal(data, dest)
return true
}
return false
}
if dest != nil && len(val) > 0 {
@@ -69,10 +79,16 @@ func Get(ctx context.Context, key string, dest interface{}) bool {
return true
}
// Set 写入 Redis失败仅打日志不阻塞
// Set 写入 RedisRedis 不可用时写入内存备用;失败仅打日志不阻塞
func Set(ctx context.Context, key string, val interface{}, ttl time.Duration) {
data, err := json.Marshal(val)
if err != nil {
log.Printf("cache.Set marshal %s: %v", key, err)
return
}
client := redis.Client()
if client == nil {
memorySet(key, data, ttl)
return
}
if ctx == nil {
@@ -80,22 +96,20 @@ func Set(ctx context.Context, key string, val interface{}, ttl time.Duration) {
}
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
data, err := json.Marshal(val)
if err != nil {
log.Printf("cache.Set marshal %s: %v", key, err)
return
}
if err := client.Set(ctx, key, data, ttl).Err(); err != nil {
log.Printf("cache.Set %s: %v (非致命)", key, err)
log.Printf("cache.Set %s: %v (非致命),已写入内存备用", key, err)
memorySet(key, data, ttl)
}
}
// Del 删除 key失败仅打日志
// Del 删除 keyRedis 不可用时删除内存备用
func Del(ctx context.Context, key string) {
client := redis.Client()
if client == nil {
memoryDel(key)
return
}
memoryDel(key)
if ctx == nil {
ctx = context.Background()
}
@@ -106,12 +120,14 @@ func Del(ctx context.Context, key string) {
}
}
// DelPattern 按模式删除 key如 soul:book:chapters-by-part:*),用于批量失效
// DelPattern 按模式删除 key如 soul:book:chapters-by-part:*Redis 不可用时删除内存备
func DelPattern(ctx context.Context, pattern string) {
client := redis.Client()
if client == nil {
memoryDelPattern(pattern)
return
}
memoryDelPattern(pattern)
if ctx == nil {
ctx = context.Background()
}
@@ -183,10 +199,13 @@ func KeyChapterContent(mid int) string { return "soul:chapter:content:" + fmt.Sp
// ChapterContentTTL 章节正文 TTL后台更新时主动 Del
const ChapterContentTTL = 30 * time.Minute
// GetString 读取字符串(不经过 JSON适合大文本 content
// GetString 读取字符串(不经过 JSON适合大文本 contentRedis 不可用时尝试内存备用
func GetString(ctx context.Context, key string) (string, bool) {
client := redis.Client()
if client == nil {
if data, ok := memoryGet(key); ok {
return string(data), true
}
return "", false
}
if ctx == nil {
@@ -196,15 +215,19 @@ func GetString(ctx context.Context, key string) (string, bool) {
defer cancel()
val, err := client.Get(ctx, key).Result()
if err != nil {
if data, ok := memoryGet(key); ok {
return string(data), true
}
return "", false
}
return val, true
}
// SetString 写入字符串(不经过 JSON适合大文本 content
// SetString 写入字符串(不经过 JSON适合大文本 contentRedis 不可用时写入内存备用
func SetString(ctx context.Context, key string, val string, ttl time.Duration) {
client := redis.Client()
if client == nil {
memorySet(key, []byte(val), ttl)
return
}
if ctx == nil {
@@ -213,7 +236,8 @@ func SetString(ctx context.Context, key string, val string, ttl time.Duration) {
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
if err := client.Set(ctx, key, val, ttl).Err(); err != nil {
log.Printf("cache.SetString %s: %v (非致命)", key, err)
log.Printf("cache.SetString %s: %v (非致命),已写入内存备用", key, err)
memorySet(key, []byte(val), ttl)
}
}

52
soul-api/internal/cache/memory.go vendored Normal file
View File

@@ -0,0 +1,52 @@
package cache
import (
"strings"
"sync"
"time"
)
// memoryFallback Redis 不可用时的内存备用缓存,保证服务可用
var (
memoryMu sync.RWMutex
memoryData = make(map[string]*memoryEntry)
)
type memoryEntry struct {
Data []byte
Expiry time.Time
}
func memoryGet(key string) ([]byte, bool) {
memoryMu.RLock()
defer memoryMu.RUnlock()
e, ok := memoryData[key]
if !ok || e == nil || time.Now().After(e.Expiry) {
return nil, false
}
return e.Data, true
}
func memorySet(key string, data []byte, ttl time.Duration) {
memoryMu.Lock()
defer memoryMu.Unlock()
memoryData[key] = &memoryEntry{Data: data, Expiry: time.Now().Add(ttl)}
}
func memoryDel(key string) {
memoryMu.Lock()
defer memoryMu.Unlock()
delete(memoryData, key)
}
// memoryDelPattern 按前缀删除pattern 如 soul:book:chapters-by-part:* 转为前缀 soul:book:chapters-by-part:
func memoryDelPattern(pattern string) {
prefix := strings.TrimSuffix(pattern, "*")
memoryMu.Lock()
defer memoryMu.Unlock()
for k := range memoryData {
if strings.HasPrefix(k, prefix) {
delete(memoryData, k)
}
}
}

View File

@@ -287,7 +287,8 @@ func Load() (*Config, error) {
}
// 生产环境GIN_MODE=release强制校验敏感配置禁止使用默认值
if cfg.Mode == "release" {
// SKIP_PROD_SECRET_CHECK=staging 时跳过校验(仅用于测试/预发环境,正式环境请配置真实密钥)
if cfg.Mode == "release" && strings.TrimSpace(os.Getenv("SKIP_PROD_SECRET_CHECK")) != "staging" {
sensitive := []struct {
name string
val string
@@ -303,7 +304,7 @@ func Load() (*Config, error) {
strings.HasPrefix(s.val, "wx3e31b068") ||
s.val == "admin123" ||
s.val == "soul-admin-secret-change-in-prod" {
log.Fatalf("生产环境必须配置 %s禁止使用默认值", s.name)
log.Fatalf("生产环境必须配置 %s禁止使用默认值。测试环境可设置 SKIP_PROD_SECRET_CHECK=staging 跳过", s.name)
}
}
}

View File

@@ -178,7 +178,7 @@ func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H {
_ = json.Unmarshal(b, &m)
if u := userMap[o.UserID]; u != nil {
m["userNickname"] = dashStr(u.Nickname)
m["userAvatar"] = dashStr(u.Avatar)
m["userAvatar"] = resolveAvatarURL(dashStr(u.Avatar))
} else {
m["userNickname"] = ""
m["userAvatar"] = ""

View File

@@ -168,7 +168,7 @@ func DBUsersRFM(c *gin.Context) {
ru.Phone = *u.Phone
}
if u.Avatar != nil {
ru.Avatar = *u.Avatar
ru.Avatar = resolveAvatarURL(*u.Avatar)
}
result = append(result, ru)
}

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")
@@ -93,11 +146,28 @@ func AdminWithdrawalsList(c *gin.Context) {
if w.UserConfirmedAt != nil && !w.UserConfirmedAt.IsZero() {
userConfirmedAt = w.UserConfirmedAt.Format("2006-01-02 15:04:05")
}
avStr := ""
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": userAvatar,
"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,
})
}
@@ -123,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线下打款后批准
@@ -165,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

@@ -91,6 +91,20 @@ func buildMiniprogramConfig() gin.H {
}
}
}
// 价格以管理端「站点与作者」site_settings 为准(运营唯一配置入口),无则用 chapter_config 或默认值
var siteRow model.SystemConfig
if err := db.Where("config_key = ?", "site_settings").First(&siteRow).Error; err == nil && len(siteRow.ConfigValue) > 0 {
var siteVal map[string]interface{}
if err := json.Unmarshal(siteRow.ConfigValue, &siteVal); err == nil {
cur := out["prices"].(gin.H)
if v, ok := siteVal["sectionPrice"].(float64); ok && v > 0 {
cur["section"] = v
}
if v, ok := siteVal["baseBookPrice"].(float64); ok && v > 0 {
cur["fullbook"] = v
}
}
}
// 好友优惠(用于 read 页展示优惠价)
var refRow model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&refRow).Error; err == nil {
@@ -157,24 +171,36 @@ func GetPublicDBConfig(c *gin.Context) {
}
// GetAuditMode GET /api/miniprogram/config/audit-mode 审核模式独立接口,管理端开关后快速生效
// 缓存未命中时仅查 mp_config 一条记录,避免 buildMiniprogramConfig 全量查询导致超时
// Redis 不可用时 cache 包自动降级到内存备用
func GetAuditMode(c *gin.Context) {
var cached gin.H
if cache.Get(context.Background(), cache.KeyConfigAuditMode, &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, cached)
return
}
full := buildMiniprogramConfig()
auditMode := false
if mp, ok := full["mpConfig"].(gin.H); ok {
if v, ok := mp["auditMode"].(bool); ok && v {
auditMode = true
}
}
auditMode := getAuditModeFromDB()
out := gin.H{"auditMode": auditMode}
cache.Set(context.Background(), cache.KeyConfigAuditMode, out, cache.AuditModeTTL)
c.JSON(http.StatusOK, out)
}
// getAuditModeFromDB 仅查询 mp_config 的 auditMode轻量级避免超时
func getAuditModeFromDB() bool {
var row model.SystemConfig
if err := database.DB().Where("config_key = ?", "mp_config").First(&row).Error; err != nil {
return false
}
var mp map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &mp); err != nil {
return false
}
if v, ok := mp["auditMode"].(bool); ok && v {
return true
}
return false
}
// GetCoreConfig GET /api/miniprogram/config/core 核心配置prices、features、userDiscount、mpConfig首屏/Tab 用
func GetCoreConfig(c *gin.Context) {
var cached gin.H
@@ -949,7 +975,7 @@ func DBUsersAction(c *gin.Context) {
updates["wechat_id"] = *body.WechatID
}
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
updates["avatar"] = avatarToPath(*body.Avatar)
}
if body.Tags != nil {
updates["ckb_tags"] = *body.Tags
@@ -1001,7 +1027,7 @@ func DBUsersAction(c *gin.Context) {
updates["vip_name"] = *body.VipName
}
if body.VipAvatar != nil {
updates["vip_avatar"] = *body.VipAvatar
updates["vip_avatar"] = avatarToPath(*body.VipAvatar)
}
if body.VipProject != nil {
updates["vip_project"] = *body.VipProject
@@ -1112,8 +1138,12 @@ func DBUsersReferrals(c *gin.Context) {
// 已付费:与小程序一致,以绑定记录的 purchase_count > 0 为准(支付回调会更新该字段)
hasPaid := b.PurchaseCount != nil && *b.PurchaseCount > 0
displayStatus := bindingStatusDisplay(hasPaid, hasFullBook) // vip | paid | free供前端徽章展示
avStr := ""
if avatar != nil {
avStr = resolveAvatarURL(*avatar)
}
referrals = append(referrals, gin.H{
"id": b.RefereeID, "nickname": nick, "avatar": avatar, "phone": phone,
"id": b.RefereeID, "nickname": nick, "avatar": avStr, "phone": phone,
"hasFullBook": hasFullBook || status == "converted",
"purchasedSections": getBindingPurchaseCount(b),
"createdAt": b.BindingDate, "bindingStatus": status, "daysRemaining": daysRemaining, "commission": b.TotalCommission,
@@ -1288,8 +1318,8 @@ func DBDistribution(c *gin.Context) {
statusVal = *b.Status
}
out = append(out, gin.H{
"id": b.ID, "referrerId": b.ReferrerID, "referrerName": getStr(referrerName), "referrerCode": b.ReferralCode, "referrerAvatar": getStr(referrerAvatar),
"refereeId": b.RefereeID, "refereeNickname": refNick, "refereePhone": getStr(refereePhone), "refereeAvatar": getStr(refereeAvatar),
"id": b.ID, "referrerId": b.ReferrerID, "referrerName": getStr(referrerName), "referrerCode": b.ReferralCode, "referrerAvatar": resolveAvatarURL(getStr(referrerAvatar)),
"refereeId": b.RefereeID, "refereeNickname": refNick, "refereePhone": getStr(refereePhone), "refereeAvatar": resolveAvatarURL(getStr(refereeAvatar)),
"boundAt": b.BindingDate, "expiresAt": b.ExpiryDate, "status": statusVal,
"daysRemaining": days, "commission": commissionVal, "totalCommission": commissionVal, "source": "miniprogram",
})

View File

@@ -616,6 +616,38 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)})
return
}
// update-chapter-pricing按篇+章批量更新该章下所有「节」行的 price / is_free管理端章节统一定价
if body.Action == "update-chapter-pricing" {
if body.PartID == "" || body.ChapterID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 partId 或 chapterId"})
return
}
p := 1.0
if body.Price != nil {
p = *body.Price
}
free := false
if body.IsFree != nil {
free = *body.IsFree
}
if free {
p = 0
}
up := map[string]interface{}{
"price": p,
"is_free": free,
}
res := db.Model(&model.Chapter{}).Where("part_id = ? AND chapter_id = ?", body.PartID, body.ChapterID).Updates(up)
if res.Error != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": res.Error.Error()})
return
}
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已更新本章全部节的定价", "affected": res.RowsAffected})
return
}
if body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return

View File

@@ -26,55 +26,67 @@ func DBCKBLeadList(c *gin.Context) {
pageSize = 20
}
dedup := c.DefaultQuery("dedup", "true")
if mode == "contact" {
// ckb_lead_records链接卡若留资关联 persons 获取 @人 与获客计划
q := db.Model(&model.CkbLeadRecord{})
var total int64
var records []model.CkbLeadRecord
if dedup == "true" {
subQ := db.Model(&model.CkbLeadRecord{}).
Select("MAX(id) as id").
Group("COALESCE(NULLIF(user_id,''), COALESCE(NULLIF(phone,''), COALESCE(NULLIF(wechat_id,''), CAST(id AS CHAR))))")
q = db.Model(&model.CkbLeadRecord{}).Where("id IN (?)", subQ)
}
q.Count(&total)
var records []model.CkbLeadRecord
if err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
// 批量查 persons 获取 personName、ckbPlanId
personIDs := make([]string, 0)
for _, r := range records {
if r.TargetPersonID != "" {
personIDs = append(personIDs, r.TargetPersonID)
}
}
personMap := make(map[string]*model.Person)
if len(personIDs) > 0 {
var persons []model.Person
db.Where("person_id IN ?", personIDs).Find(&persons)
for i := range persons {
personMap[persons[i].PersonID] = &persons[i]
}
}
out := make([]gin.H, 0, len(records))
for _, r := range records {
personName := ""
ckbPlanId := int64(0)
if p := personMap[r.TargetPersonID]; p != nil {
personName = p.Name
ckbPlanId = p.CkbPlanID
}
out = append(out, gin.H{
"id": r.ID,
"userId": r.UserID,
"userNickname": r.Nickname,
"matchType": "lead",
"phone": r.Phone,
"wechatId": r.WechatID,
"name": r.Name,
"createdAt": r.CreatedAt,
"id": r.ID,
"userId": r.UserID,
"userNickname": r.Nickname,
"matchType": "lead",
"phone": r.Phone,
"wechatId": r.WechatID,
"name": r.Name,
"source": r.Source,
"targetPersonId": r.TargetPersonID,
"personName": personName,
"ckbPlanId": ckbPlanId,
"createdAt": r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
return
}
// mode=submitted: ckb_submit_records
q := db.Model(&model.CkbSubmitRecord{})
if matchType != "" {
// matchType 对应 action: join 时 type 在 params 中match 时 matchType 在 params 中
// 简化:仅按 action 过滤join 时 params 含 type
if matchType == "join" || matchType == "match" {
q = q.Where("action = ?", matchType)
}
}
if dedup == "true" {
subQ := db.Model(&model.CkbSubmitRecord{}).
Select("MAX(id) as id").
Group("COALESCE(NULLIF(user_id,''), CAST(id AS CHAR))")
if matchType == "join" || matchType == "match" {
subQ = subQ.Where("action = ?", matchType)
}
q = db.Model(&model.CkbSubmitRecord{}).Where("id IN (?)", subQ)
}
var total int64
q.Count(&total)
var records []model.CkbSubmitRecord
@@ -121,95 +133,6 @@ func DBCKBLeadList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
}
// CKBPersonLeadStats GET /api/db/ckb-person-leads 每个人物的获客线索统计及明细
func CKBPersonLeadStats(c *gin.Context) {
db := database.DB()
personToken := c.Query("token")
if personToken != "" {
// 返回某人物的线索明细(通过 token → Person → 用 PersonID 和 Token 匹配 CkbLeadRecord.TargetPersonID
var person model.Person
if err := db.Where("token = ?", personToken).First(&person).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
q := db.Model(&model.CkbLeadRecord{}).Where("target_person_id IN ?", []string{person.PersonID, person.Token})
var total int64
q.Count(&total)
var records []model.CkbLeadRecord
q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&records)
out := make([]gin.H, 0, len(records))
for _, r := range records {
out = append(out, gin.H{
"id": r.ID,
"userId": r.UserID,
"nickname": r.Nickname,
"phone": r.Phone,
"wechatId": r.WechatID,
"name": r.Name,
"source": r.Source,
"createdAt": r.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"personName": person.Name,
"records": out,
"total": total,
"page": page,
"pageSize": pageSize,
})
return
}
// 无 token 参数:返回所有人物的获客数量汇总
type PersonLeadStat struct {
TargetPersonID string `gorm:"column:target_person_id"`
Total int64 `gorm:"column:total"`
}
var stats []PersonLeadStat
db.Raw("SELECT target_person_id, COUNT(*) as total FROM ckb_lead_records WHERE target_person_id != '' GROUP BY target_person_id").Scan(&stats)
// 构建 personId/token → Person.Token 的映射,使前端能用 token 匹配
var persons []model.Person
db.Select("person_id, token").Find(&persons)
pidToToken := make(map[string]string, len(persons))
for _, p := range persons {
pidToToken[p.PersonID] = p.Token
pidToToken[p.Token] = p.Token
}
merged := make(map[string]int64)
for _, s := range stats {
key := pidToToken[s.TargetPersonID]
if key == "" {
key = s.TargetPersonID
}
merged[key] += s.Total
}
byPerson := make([]gin.H, 0, len(merged))
for token, total := range merged {
byPerson = append(byPerson, gin.H{"token": token, "total": total})
}
// 同时统计全局(无特定人物的)线索
var globalTotal int64
db.Model(&model.CkbLeadRecord{}).Where("target_person_id = '' OR target_person_id IS NULL").Count(&globalTotal)
c.JSON(http.StatusOK, gin.H{
"success": true,
"byPerson": byPerson,
"globalLeads": globalTotal,
})
}
// CKBPlanStats GET /api/db/ckb-plan-stats 存客宝获客计划统计(基于 ckb_submit_records + ckb_lead_records
func CKBPlanStats(c *gin.Context) {
db := database.DB()

View File

@@ -380,7 +380,7 @@ func GiftPayDetail(c *gin.Context) {
nickname = *initiator.Nickname
}
if initiator.Avatar != nil && *initiator.Avatar != "" {
initiatorAvatar = *initiator.Avatar
initiatorAvatar = resolveAvatarURL(*initiator.Avatar)
}
}
@@ -417,7 +417,7 @@ func GiftPayDetail(c *gin.Context) {
nickname = *u.Nickname
}
if u.Avatar != nil && *u.Avatar != "" {
avatar = *u.Avatar
avatar = resolveAvatarURL(*u.Avatar)
}
}
redeemList = append(redeemList, gin.H{"userId": o.UserID, "nickname": nickname, "avatar": avatar, "redeemAt": o.CreatedAt.Format("2006-01-02 15:04")})
@@ -625,7 +625,7 @@ func GiftPayMyRequests(c *gin.Context) {
nickname = *u.Nickname
}
if u.Avatar != nil && *u.Avatar != "" {
avatar = *u.Avatar
avatar = resolveAvatarURL(*u.Avatar)
}
}
redeemAt := o.CreatedAt.Format("2006-01-02 15:04")

View File

@@ -293,7 +293,7 @@ func MatchUsers(c *gin.Context) {
}
avatar := ""
if r.Avatar != nil {
avatar = *r.Avatar
avatar = resolveAvatarURL(*r.Avatar)
}
wechat := ""
if r.WechatID != nil {

View File

@@ -104,10 +104,10 @@ func DBMatchRecordsList(c *gin.Context) {
userAvatar := ""
matchedUserAvatar := ""
if u != nil && u.Avatar != nil {
userAvatar = *u.Avatar
userAvatar = resolveAvatarURL(*u.Avatar)
}
if mu != nil && mu.Avatar != nil {
matchedUserAvatar = *mu.Avatar
matchedUserAvatar = resolveAvatarURL(*mu.Avatar)
}
userNickname := ""
if u != nil {

View File

@@ -43,6 +43,7 @@ func MiniprogramMentorsList(c *gin.Context) {
result := make([]mentorItem, len(list))
for i, m := range list {
result[i] = mentorItem{Mentor: m}
result[i].Avatar = resolveAvatarURL(m.Avatar) // OSS 时 DB 存路径,返回需解析为完整 URL
if m.Tags != "" {
result[i].TagsArr = strings.Split(m.Tags, ",")
for j := range result[i].TagsArr {
@@ -79,7 +80,7 @@ func MiniprogramMentorsDetail(c *gin.Context) {
"success": true,
"data": gin.H{
"id": m.ID,
"avatar": m.Avatar,
"avatar": resolveAvatarURL(m.Avatar),
"name": m.Name,
"intro": m.Intro,
"tags": m.Tags,
@@ -257,7 +258,7 @@ func DBMentorsAction(c *gin.Context) {
updates["name"] = *body.Name
}
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
updates["avatar"] = avatarToPath(*body.Avatar)
}
if body.Intro != nil {
updates["intro"] = *body.Intro

View File

@@ -98,9 +98,6 @@ func MiniprogramLogin(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
return
}
// 记录注册行为到 user_tracks
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
db.Create(&model.UserTrack{ID: trackID, UserID: user.ID, Action: "register"})
// 新用户异步调用神射手自动打标手机号尚未绑定phone 为空时暂不调用)
AdminShensheShouAutoTag(userID, "")
} else {
@@ -137,7 +134,7 @@ func MiniprogramLogin(c *gin.Context) {
"id": user.ID,
"openId": getStringValue(user.OpenID),
"nickname": getStringValue(user.Nickname),
"avatar": getUrlValue(user.Avatar),
"avatar": resolveAvatarURL(getStringValue(user.Avatar)),
"phone": getStringValue(user.Phone),
"wechatId": getStringValue(user.WechatID),
"referralCode": getStringValue(user.ReferralCode),
@@ -215,7 +212,89 @@ func MiniprogramDevLoginAs(c *gin.Context) {
"id": user.ID,
"openId": openID,
"nickname": getStringValue(user.Nickname),
"avatar": getUrlValue(user.Avatar),
"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,
},
})
}
// 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),
@@ -251,18 +330,6 @@ func getStringValue(ptr *string) string {
return *ptr
}
// getUrlValue 取字符串指针值并修复缺少冒号的 URL"https//..." → "https://..."
func getUrlValue(ptr *string) string {
s := getStringValue(ptr)
if strings.HasPrefix(s, "https//") {
return "https://" + s[7:]
}
if strings.HasPrefix(s, "http//") {
return "http://" + s[6:]
}
return s
}
func getBoolValue(ptr *bool) bool {
if ptr == nil {
return false
@@ -834,13 +901,7 @@ func MiniprogramPhone(c *gin.Context) {
if req.UserID != "" {
db := database.DB()
db.Model(&model.User{}).Where("id = ?", req.UserID).Update("phone", phoneNumber)
// 记录绑定手机号行为到 user_tracks
trackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
db.Create(&model.UserTrack{ID: trackID, UserID: req.UserID, Action: "bind_phone"})
fmt.Printf("[MiniprogramPhone] 手机号已绑定到用户: %s\n", req.UserID)
// 记录绑定手机行为
bindTrackID := fmt.Sprintf("track_%d", time.Now().UnixNano()%100000000)
database.DB().Create(&model.UserTrack{ID: bindTrackID, UserID: req.UserID, Action: "bind_phone"})
// 绑定手机号后,异步调用神射手自动完善标签
AdminShensheShouAutoTag(req.UserID, phoneNumber)
}
@@ -1042,11 +1103,11 @@ func MiniprogramUsers(c *gin.Context) {
item := gin.H{
"id": user.ID,
"nickname": getStringValue(user.Nickname),
"avatar": getUrlValue(user.Avatar),
"avatar": resolveAvatarURL(getStringValue(user.Avatar)),
"phone": getStringValue(user.Phone),
"wechatId": getStringValue(user.WechatID),
"vipName": getStringValue(user.VipName),
"vipAvatar": getStringValue(user.VipAvatar),
"vipAvatar": resolveAvatarURL(getStringValue(user.VipAvatar)),
"vipContact": getStringValue(user.VipContact),
"vipProject": getStringValue(user.VipProject),
"vipBio": getStringValue(user.VipBio),
@@ -1081,7 +1142,7 @@ func MiniprogramUsers(c *gin.Context) {
list = append(list, gin.H{
"id": u.ID,
"nickname": getStringValue(u.Nickname),
"avatar": getUrlValue(u.Avatar),
"avatar": resolveAvatarURL(getStringValue(u.Avatar)),
"is_vip": uvip,
})
}
@@ -1162,7 +1223,7 @@ func getStandardPrice(db *gorm.DB, productType, productID string) (float64, erro
}
return 0, fmt.Errorf("未知商品类型: %s", productType)
case "section", "gift":
case "section":
if productID == "" {
return 0, fmt.Errorf("单章购买缺少 productId")
}
@@ -1178,19 +1239,6 @@ func getStandardPrice(db *gorm.DB, productType, productID string) (float64, erro
}
return *ch.Price, nil
case "balance_recharge":
if productID == "" {
return 0, fmt.Errorf("充值订单号缺失")
}
var order model.Order
if err := db.Where("order_sn = ? AND product_type = ?", productID, "balance_recharge").First(&order).Error; err != nil {
return 0, fmt.Errorf("充值订单不存在: %s", productID)
}
if order.Amount <= 0 {
return 0, fmt.Errorf("充值金额无效")
}
return order.Amount, nil
default:
return 0, fmt.Errorf("未知商品类型: %s", productType)
}

View File

@@ -146,7 +146,7 @@ func OrdersList(c *gin.Context) {
if u := userMap[o.UserID]; u != nil {
m["userNickname"] = getStr(u.Nickname)
m["userPhone"] = getStr(u.Phone)
m["userAvatar"] = getStr(u.Avatar)
m["userAvatar"] = resolveAvatarURL(getStr(u.Avatar))
} else {
m["userNickname"] = ""
m["userPhone"] = ""

View File

@@ -283,7 +283,7 @@ func ReferralData(c *gin.Context) {
activeUsers = append(activeUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getUrlValue(referee.Avatar),
"avatar": resolveAvatarURL(getStringValue(referee.Avatar)),
"daysRemaining": daysRemaining,
"hasFullBook": getBoolValue(referee.HasFullBook),
"bindingDate": b.BindingDate,
@@ -312,7 +312,7 @@ func ReferralData(c *gin.Context) {
convertedUsers = append(convertedUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getUrlValue(referee.Avatar),
"avatar": resolveAvatarURL(getStringValue(referee.Avatar)),
"commission": commission,
"orderAmount": orderAmount,
"purchaseCount": getIntValue(b.PurchaseCount),
@@ -336,7 +336,7 @@ func ReferralData(c *gin.Context) {
expiredUsers = append(expiredUsers, gin.H{
"id": b.RefereeID,
"nickname": getStringValue(referee.Nickname),
"avatar": getUrlValue(referee.Avatar),
"avatar": resolveAvatarURL(getStringValue(referee.Avatar)),
"bindingDate": b.BindingDate,
"expiryDate": b.ExpiryDate,
"status": "expired",
@@ -366,7 +366,7 @@ func ReferralData(c *gin.Context) {
"productId": getStringValue(e.ProductID),
"description": getStringValue(e.Description),
"buyerNickname": getStringValue(buyer.Nickname),
"buyerAvatar": getUrlValue(buyer.Avatar),
"buyerAvatar": resolveAvatarURL(getStringValue(buyer.Avatar)),
"payTime": e.PayTime,
})
}

View File

@@ -5,14 +5,59 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/oss"
"github.com/gin-gonic/gin"
)
// avatarToPath 从头像 URL 提取路径(不含域名),用于保存到 DB
func avatarToPath(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
if idx := strings.Index(s, "/uploads/"); idx >= 0 {
return s[idx:]
}
if strings.HasPrefix(s, "/") {
return s
}
return s
}
// resolveAvatarURL 将路径解析为完整可访问 URL返回时使用
func resolveAvatarURL(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return s
}
// 已是完整 URL直接返回
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
return s
}
path := s
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
// OSS 存储:用 OSS 公网 URL
if oss.IsEnabled() {
if u := oss.PublicURL(path); u != "" {
return u
}
}
// 本地存储:用 BaseURL 拼接
if cfg := config.Get(); cfg != nil && cfg.BaseURL != "" {
return cfg.BaseURLJoin(path)
}
return path
}
// UserAddressesGet GET /api/user/addresses?userId=
func UserAddressesGet(c *gin.Context) {
userId := c.Query("userId")
@@ -243,8 +288,9 @@ func UserProfileGet(c *gin.Context) {
profileComplete := (user.Phone != nil && *user.Phone != "") || (user.WechatID != nil && *user.WechatID != "")
hasAvatar := user.Avatar != nil && *user.Avatar != "" && len(*user.Avatar) > 0
str := func(p *string) interface{} { if p != nil { return *p }; return "" }
avatarVal := resolveAvatarURL(str(user.Avatar).(string))
resp := gin.H{
"id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": str(user.Avatar),
"id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": avatarVal,
"phone": str(user.Phone), "wechatId": str(user.WechatID), "referralCode": user.ReferralCode,
"hasFullBook": user.HasFullBook, "earnings": user.Earnings, "pendingEarnings": user.PendingEarnings,
"referralCount": user.ReferralCount, "profileComplete": profileComplete, "hasAvatar": hasAvatar,
@@ -311,7 +357,7 @@ func UserProfilePost(c *gin.Context) {
updates["nickname"] = *body.Nickname
}
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
updates["avatar"] = avatarToPath(*body.Avatar)
}
if body.Phone != nil {
updates["phone"] = *body.Phone
@@ -343,8 +389,9 @@ func UserProfilePost(c *gin.Context) {
"story_best_month", "story_achievement", "story_turning", "help_offer", "help_need", "project_intro"}
if err := database.DB().Select(profileCols).Where("id = ?", user.ID).First(&user).Error; err == nil {
str := func(p *string) interface{} { if p != nil { return *p }; return "" }
avatarVal := resolveAvatarURL(str(user.Avatar).(string))
resp := gin.H{
"id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": str(user.Avatar),
"id": user.ID, "openId": user.OpenID, "nickname": str(user.Nickname), "avatar": avatarVal,
"phone": str(user.Phone), "wechatId": str(user.WechatID), "referralCode": user.ReferralCode,
"createdAt": user.CreatedAt,
"mbti": str(user.Mbti), "region": str(user.Region), "industry": str(user.Industry),
@@ -355,8 +402,12 @@ func UserProfilePost(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料更新成功", "data": resp})
} else {
avatarVal := ""
if body.Avatar != nil {
avatarVal = resolveAvatarURL(avatarToPath(*body.Avatar))
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "资料更新成功", "data": gin.H{
"id": user.ID, "nickname": body.Nickname, "avatar": body.Avatar, "phone": body.Phone, "wechatId": body.WechatID, "referralCode": user.ReferralCode,
"id": user.ID, "nickname": body.Nickname, "avatar": avatarVal, "phone": body.Phone, "wechatId": body.WechatID, "referralCode": user.ReferralCode,
}})
}
}
@@ -694,7 +745,7 @@ func UserUpdate(c *gin.Context) {
updates["nickname"] = *body.Nickname
}
if body.Avatar != nil {
updates["avatar"] = *body.Avatar
updates["avatar"] = avatarToPath(*body.Avatar)
}
if body.Phone != nil {
updates["phone"] = *body.Phone

View File

@@ -126,7 +126,7 @@ func buildVipProfile(u *model.User) gin.H {
"vipName": getStr(u.VipName),
"vipProject": getStr(u.VipProject),
"vipContact": getStr(u.VipContact),
"vipAvatar": getStr(u.VipAvatar),
"vipAvatar": resolveAvatarURL(getStr(u.VipAvatar)),
"vipBio": getStr(u.VipBio),
}
}
@@ -195,7 +195,7 @@ func VipProfilePost(c *gin.Context) {
updates["vip_contact"] = req.VipContact
}
if req.VipAvatar != "" {
updates["vip_avatar"] = req.VipAvatar
updates["vip_avatar"] = avatarToPath(req.VipAvatar)
}
if req.VipBio != "" {
updates["vip_bio"] = req.VipBio
@@ -290,6 +290,7 @@ func formatVipMember(u *model.User, isVip bool) gin.H {
if avatar == "" {
avatar = getUrlValue(u.VipAvatar)
}
avatar = resolveAvatarURL(avatar)
project := getStringValue(u.VipProject)
if project == "" {
project = getStringValue(u.ProjectIntro)

View File

@@ -112,7 +112,7 @@ func WechatPhoneLogin(c *gin.Context) {
"id": user.ID,
"openId": strVal(user.OpenID),
"nickname": strVal(user.Nickname),
"avatar": strVal(user.Avatar),
"avatar": resolveAvatarURL(strVal(user.Avatar)),
"phone": strVal(user.Phone),
"wechatId": strVal(user.WechatID),
"referralCode": strVal(user.ReferralCode),

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

@@ -0,0 +1,149 @@
// Package handler - WebSocket 占位:用户在线检测
// 小程序连接 WSS 发心跳Redis 记录在线;管理端通过 HTTP 获取在线人数
// 后续可扩展:管理端 WSS 订阅、消息推送等
package handler
import (
"context"
"encoding/json"
"log"
"net/http"
"strings"
"time"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/redis"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
const (
wsOnlinePrefix = "user:online:"
wsOfflineTimeout = 300 // 5 分钟无心跳视为离线(秒)
)
var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// WsMiniprogram 处理小程序 WSS 连接:鉴权后记录心跳,维持在线状态
// 路径GET /ws/miniprogram?token=xxx
// 首条消息需包含 {"type":"auth","userId":"user_xxx"},占位阶段不校验 token
// 容错panic 时 recover 并关闭连接,不影响 HTTP API 及其他请求
func WsMiniprogram(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
log.Printf("[WS] WsMiniprogram panic recovered: %v", r)
}
}()
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
var userID string
authOK := false
// 读取首条消息auth
conn.SetReadDeadline(time.Now().Add(15 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
var authMsg struct {
Type string `json:"type"`
UserID string `json:"userId"`
}
if json.Unmarshal(msg, &authMsg) == nil && authMsg.Type == "auth" && authMsg.UserID != "" {
userID = strings.TrimSpace(authMsg.UserID)
// 占位:校验用户存在即可
db := database.DB()
var u model.User
if db.Where("id = ?", userID).First(&u).Error == nil {
authOK = true
}
}
if !authOK {
conn.WriteJSON(map[string]interface{}{"type": "error", "message": "auth failed"})
return
}
// 鉴权通过,开始处理心跳
conn.SetReadDeadline(time.Time{}) // 取消超时
client := redis.Client()
if client == nil {
log.Printf("[WS] Redis 未启用,在线状态不可用")
return
}
key := wsOnlinePrefix + userID
ctx := context.Background()
ttl := time.Duration(wsOfflineTimeout) * time.Second
// 立即写入一次在线
client.Set(ctx, key, "1", ttl)
// 心跳读取循环
for {
_, msg, err := conn.ReadMessage()
if err != nil {
break
}
var m struct {
Type string `json:"type"`
}
if json.Unmarshal(msg, &m) == nil && (m.Type == "ping" || m.Type == "heartbeat") {
client.Set(ctx, key, "1", ttl)
conn.WriteJSON(map[string]interface{}{"type": "pong"})
}
}
}
// AdminUsersOnlineStats GET /api/admin/users/online-stats 管理端在线人数统计
// 容错Redis 不可用时返回 success + onlineCount: 0不影响管理端其他功能
func AdminUsersOnlineStats(c *gin.Context) {
client := redis.Client()
if client == nil {
c.JSON(http.StatusOK, gin.H{"success": true, "onlineCount": 0})
return
}
ctx := context.Background()
iter := client.Scan(ctx, 0, wsOnlinePrefix+"*", 0).Iterator()
count := 0
for iter.Next(ctx) {
count++
}
if err := iter.Err(); err != nil {
log.Printf("[WS] AdminUsersOnlineStats Redis scan err: %v降级返回 0", err)
c.JSON(http.StatusOK, gin.H{"success": true, "onlineCount": 0})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "onlineCount": count})
}
// GetWsURL 返回小程序可用的 WSS 地址(基于 API_BASE_URL 派生)
func GetWsURL() string {
cfg := config.Get()
if cfg == nil {
return ""
}
base := strings.TrimSuffix(cfg.BaseURL, "/")
if base == "" {
return ""
}
if strings.HasPrefix(base, "https://") {
return "wss" + strings.TrimPrefix(base, "https") + "/ws/miniprogram"
}
if strings.HasPrefix(base, "http://") {
return strings.Replace(base, "http", "ws", 1) + "/ws/miniprogram"
}
return ""
}

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

@@ -122,3 +122,20 @@ func IsOSSURL(rawURL string) bool {
prefix := "https://" + cfg.Bucket + "." + cfg.Endpoint + "/"
return strings.HasPrefix(rawURL, prefix)
}
// PublicURL 将路径转为 OSS 公网访问 URLpath 如 /uploads/avatars/xxx.jpg
// OSS 未配置时返回空字符串
func PublicURL(path string) string {
cfg := LoadConfig()
if cfg == nil {
return ""
}
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if path[0] == '/' {
path = path[1:]
}
return "https://" + cfg.Bucket + "." + cfg.Endpoint + "/" + path
}

View File

@@ -21,6 +21,8 @@ func Init(url string) error {
client = redis.NewClient(opt)
ctx := context.Background()
if err := client.Ping(ctx).Err(); err != nil {
client = nil // 连接失败时清空避免后续使用超时cache 将自动降级到内存备用
log.Printf("redis: 连接失败,已降级到内存缓存(%v", err)
return err
}
log.Printf("redis: connected to %s", opt.Addr)

View File

@@ -40,6 +40,9 @@ func Setup(cfg *config.Config) *gin.Engine {
}
r.Static("/uploads", uploadDir)
// WebSocket小程序在线心跳占位后续可扩展管理端订阅、消息推送
r.GET("/ws/miniprogram", handler.WsMiniprogram)
api := r.Group("/api")
{
// ----- 管理端 -----
@@ -75,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)
@@ -98,6 +103,7 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.GET("/users/:id/balance", handler.AdminUserBalanceGet)
admin.POST("/users/:id/balance/adjust", handler.AdminUserBalanceAdjust)
admin.GET("/balance/summary", handler.AdminBalanceSummary)
admin.GET("/users/online-stats", handler.AdminUsersOnlineStats)
admin.GET("/users", handler.AdminUsersList)
admin.POST("/users", handler.AdminUsersAction)
admin.PUT("/users", handler.AdminUsersAction)
@@ -291,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 +0,0 @@
16d770afdc8b7273eb7a93814af01b23

View File

@@ -1,62 +0,0 @@
# 文章 base64 图片迁移脚本
`chapters` 表中 `content` 字段内嵌的 base64 图片提取为独立文件,并替换为 `/uploads/book-images/xxx` 的 URL减小文章体积。
## 适用场景
- 历史文章中有大量粘贴的 base64 图片
- 保存时因 content 过大导致超时或失败
- 需要将 base64 转为文件存储
## 执行方式
### 1. 测试环境(建议先执行)
```bash
cd soul-api
# 加载测试环境配置(.env.development
$env:APP_ENV="development"
# 先 dry-run 预览,不写入
go run ./cmd/migrate-base64-images --dry-run
# 确认无误后正式执行
go run ./cmd/migrate-base64-images
```
### 2. 生产环境
```bash
cd soul-api
$env:APP_ENV="production"
go run ./cmd/migrate-base64-images --dry-run # 先预览
go run ./cmd/migrate-base64-images # 正式执行
```
### 3. 指定 DSN覆盖 .env
```bash
$env:DB_DSN="user:pass@tcp(host:port)/db?charset=utf8mb4&parseTime=True"
go run ./cmd/migrate-base64-images --dry-run
```
## 参数
| 参数 | 说明 |
|------|------|
| `--dry-run` | 仅统计和预览,不写入文件与数据库 |
## 行为说明
1. 查询 `content LIKE '%data:image%'` 的章节
2. 用正则提取 `src="data:image/xxx;base64,..."``src='...'`
3. 解码 base64保存到 `uploads/book-images/{timestamp}_{random}.{ext}`
4. 将 content 中的 base64 src 替换为 `/uploads/book-images/xxx`
5. 更新数据库
## 注意事项
- **务必先在测试环境验证**,确认无误后再跑生产
- 脚本依赖 `UPLOAD_DIR` 或默认 `uploads` 目录
- 图片格式支持png、jpeg、jpg、gif、webp

View File

@@ -1,96 +0,0 @@
# 数据库与 Go Model 字段对照检查报告
> 后端工程师对照 `soul-api/internal/model` 与 `soul_miniprogram.sql` 建表结构,列出**数据库表里可能缺失、但代码里在用**的字段。
> 若当前库是由旧版 SQL 导入或从未执行过迁移脚本,按本报告执行 `sync-users-vip-and-schema.sql` 或依赖 AutoMigrate 即可补全。
---
## 一、结论摘要
| 表名 | 是否缺字段 | 缺失字段Model 有、SQL 无) | 影响 |
|------|------------|-----------------------------|------|
| **users** | 是(旧库可能缺) | is_vip, vip_expire_date, vip_activated_at, vip_sort, vip_role | 订单列表、用户列表、VIP 设置、提现、匹配记录等接口报错(不含 vip_name/vip_avatar 等,小程序已改为直接读用户资料) |
| **chapters** | 是SQL 导出无此列) | hot_score | 文章排名、热门章节等依赖热度分的接口报错 |
| 其他业务表 | 否 | - | 与当前 SQL 一致 |
---
## 二、users 表
- **Model 文件**`internal/model/user.go`
- **SQL 表**`soul_miniprogram.sql``CREATE TABLE users` 已包含 VIP 相关列;若你的库是**更早的备份**或**未导入最新 SQL**,可能缺少以下列。
| 列名(蛇形) | 类型 | 说明 |
|-------------|------|------|
| is_vip | TINYINT(1) NULL DEFAULT 0 | 是否 VIP |
| vip_expire_date | DATETIME(3) NULL | VIP 到期时间 |
| vip_activated_at | DATETIME(3) NULL | 成为 VIP 时间,排序用 |
| vip_sort | INT NULL | 手动排序,越小越前 |
| vip_role | VARCHAR(50) NULL | 角色:从 vip_roles 选或手动填写 |
vip_name、vip_avatar、vip_project、vip_contact、vip_bio 不再在迁移中新增,小程序已改为直接读用户资料 nickname/avatar/projectIntro/phone 等;已有库可保留该五列作兼容。)
**修复**:执行 `scripts/sync-users-vip-and-schema.sql` 中 users 部分,或重启 soul-api未设 `SKIP_AUTO_MIGRATE` 时 AutoMigrate 会补列)。
---
## 三、chapters 表
- **Model 文件**`internal/model/chapter.go`
- **SQL 表**:当前 `soul_miniprogram.sql``chapters` 仅有 `hot_score_override`decimal**没有** `hot_score`int
Model 使用的是 `hot_score`(热度分,用于排名算法),因此仅按该 SQL 建表时,数据库**缺少** `hot_score`
| 列名(蛇形) | 类型 | 说明 |
|-------------|------|------|
| hot_score | INT NOT NULL DEFAULT 0 | 热度分,用于排名算法 |
**修复**:执行 `scripts/sync-users-vip-and-schema.sql` 中 chapters 部分,或执行 `scripts/add-hot-score.sql`,或依赖 soul-api 启动时对 Chapter 的 AutoMigrate。
---
## 四、已核对无缺列的表
以下表在 `soul_miniprogram.sql` 中的列与对应 Model 一致,**无需补列**(仅列名与类型一致即可,顺序可不同):
- **orders**:与 `model.Order` 一致
- **withdrawals**:与 `model.Withdrawal` 一致(库中多出的 transaction_id、error_message 不影响)
- **admin_users**:与 `model.AdminUser` 一致
- **system_config**:与 `model.SystemConfig` 一致
- **referral_bindings**Model 字段在表中均存在
- **referral_visits**:与 `model.ReferralVisit` 一致
- **user_rules**:与 `model.UserRule` 一致
- **user_tracks**:与 `model.UserTrack` 一致
- **reading_progress**:与 `model.ReadingProgress` 一致(表为 section_idModel 为 section_id
- **match_records**:与 `model.MatchRecord` 一致
- **mentor_consultations**:与 `model.MentorConsultation` 一致
- **mentors**:与 `model.Mentor` 一致
- **link_tags**:与 `model.LinkTag` 一致
- **persons**:与 `model.Person` 一致
- **author_config**:与 `model.AuthorConfig` 一致
- **ckb_lead_records**:与 `model.CkbLeadRecord` 一致
- **ckb_submit_records**:与 `model.CkbSubmitRecord` 一致
- **user_addresses**:与 `model.UserAddress` 一致
- **vip_roles**:与 `model.VipRole` 一致
- **wechat_callback_logs**:与 `model.WechatCallbackLog` 一致
---
## 五、推荐操作
1. **一次性补全(推荐)**
在备份后执行:
```bash
mysql -u 用户 -p 数据库名 < soul-api/scripts/sync-users-vip-and-schema.sql
```
若某条报 `Duplicate column name`,表示该列已存在,可跳过。
2. **依赖 AutoMigrate**
确保 soul-api 的 `database.Init` 中已对 `User`、`SystemConfig`、`Chapter` 执行 `AutoMigrate`,且未设置 `SKIP_AUTO_MIGRATE`,重启服务后会自动补全缺失列。
3. **新建库**
若从零建库,建议用**最新**的 `soul_miniprogram.sql` 导入后,再执行一次 `sync-users-vip-and-schema.sql`,确保 users 与 chapters 与 Model 完全一致。
---
**检查日期**:按代码与 SQL 导出时点生成
**检查范围**soul-api 全部 `internal/model` 与 soul_miniprogram.sql 中对应表结构

View File

@@ -1,18 +0,0 @@
-- 后台管理员用户表(与 soul-api 管理端鉴权配合)
CREATE TABLE IF NOT EXISTS admin_users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(128) NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'admin',
name VARCHAR(64) DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME DEFAULT NULL,
UNIQUE KEY uk_admin_users_username (username),
KEY idx_admin_users_deleted_at (deleted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 初始超级管理员需通过 soul-api 启动时从 ADMIN_USERNAME/ADMIN_PASSWORD 自动迁移
-- 或执行以下命令生成 bcrypt 哈希后插入(示例密码 admin123
-- 在 Go 中: hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)

View File

@@ -1,23 +0,0 @@
-- 作者详情独立表,每个字段单独列,便于编辑与保存
-- 执行mysql -u user -p db < soul-api/scripts/add-author-config-table.sql
CREATE TABLE IF NOT EXISTS author_config (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(80) NOT NULL DEFAULT '卡若',
avatar VARCHAR(4) NOT NULL DEFAULT 'K',
avatar_img VARCHAR(500) NOT NULL DEFAULT '',
title VARCHAR(200) NOT NULL DEFAULT '',
bio TEXT,
stats TEXT COMMENT 'JSON: [{"label":"","value":""}]',
highlights TEXT COMMENT 'JSON: ["a","b"]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 插入默认一行(仅当表为空时)
INSERT INTO author_config (name, avatar, avatar_img, title, bio, stats, highlights)
SELECT '卡若', 'K', '', 'Soul派对房主理人 · 私域运营专家',
'每天早上6点到9点在Soul派对房分享真实的创业故事。专注私域运营与项目变现用云阿米巴模式帮助创业者构建可持续的商业体系。',
'[{"label":"商业案例","value":"62"},{"label":"连续直播","value":"365天"},{"label":"派对分享","value":"1000+"}]',
'["5年私域运营经验","帮助100+品牌从0到1增长","连续创业者,擅长商业模式设计"]'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM author_config LIMIT 1);

View File

@@ -1,28 +0,0 @@
-- 余额相关表(新版迁移)
-- 执行mysql -u user -p database < soul-api/scripts/add-balance-tables.sql
-- user_balances
CREATE TABLE IF NOT EXISTS user_balances (
user_id VARCHAR(50) PRIMARY KEY,
balance DECIMAL(10,2) DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- balance_transactions
CREATE TABLE IF NOT EXISTS balance_transactions (
id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
type VARCHAR(20) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
order_id VARCHAR(50) DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_created (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- orders 增加 payment_method
SET @col_exists = (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE table_schema = DATABASE() AND table_name = 'orders' AND column_name = 'payment_method');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE orders ADD COLUMN payment_method VARCHAR(20) DEFAULT NULL AFTER referrer_id', 'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@@ -1,9 +0,0 @@
-- ============================================================
-- 章节归属字段 - chapters 表(普通版/增值版)
-- 用途:添加文章时区分该章节属于普通版还是增值版
-- 执行mysql -u user -p database < soul-api/scripts/add-chapter-edition-fields.sql
-- ============================================================
-- 新增字段(若列已存在会报错,可忽略)
ALTER TABLE chapters ADD COLUMN edition_standard TINYINT(1) NULL DEFAULT 1 COMMENT '是否属于普通版1=是';
ALTER TABLE chapters ADD COLUMN edition_premium TINYINT(1) NULL DEFAULT 0 COMMENT '是否属于增值版1=是';

View File

@@ -1,5 +0,0 @@
-- 为 all-chapters 接口加速sort_order + id 排序索引
-- 执行node .cursor/scripts/db-exec/run.js -f soul-api/scripts/add-chapters-index-for-all-chapters.sql
-- 若索引已存在会报错,可忽略
CREATE INDEX idx_chapters_sort_id ON chapters(sort_order, id);

View File

@@ -1,8 +0,0 @@
-- ============================================================
-- stitch_soul P0chapters 表新增 is_new 字段
-- 用途:目录/首页「最新新增」标识,管理端可勾选
-- 执行前请先备份数据库!
-- ============================================================
-- 新增 is_new 字段(若列已存在会报 Duplicate column name可忽略
ALTER TABLE chapters ADD COLUMN is_new TINYINT(1) NULL DEFAULT 0 COMMENT '是否标记为最新新增';

View File

@@ -1,2 +0,0 @@
-- 仅添加 ckb_plan_id若 add-persons-ckb-fields.sql 已部分执行或需单独补列)
ALTER TABLE `persons` ADD COLUMN `ckb_plan_id` BIGINT NOT NULL DEFAULT 0 COMMENT '存客宝获客计划ID';

View File

@@ -1,6 +0,0 @@
-- 代付逻辑改造gift_pay_requests 增加 quantity、redeemed_count
-- 执行mysql -u user -p db < soul-api/scripts/add-gift-pay-quantity.sql
-- 若列已存在会报 Duplicate column可忽略
ALTER TABLE gift_pay_requests ADD COLUMN quantity INT NOT NULL DEFAULT 1;
ALTER TABLE gift_pay_requests ADD COLUMN redeemed_count INT NOT NULL DEFAULT 0;

View File

@@ -1,25 +0,0 @@
-- 代付请求表 + 订单表代付字段
-- 执行mysql -u user -p db < soul-api/scripts/add-gift-pay-requests.sql
-- 注orders 表新增字段由 GORM AutoMigrate 自动添加;若需手动执行:
-- ALTER TABLE orders ADD COLUMN gift_pay_request_id VARCHAR(50) DEFAULT NULL;
-- ALTER TABLE orders ADD COLUMN payer_user_id VARCHAR(50) DEFAULT NULL;
CREATE TABLE IF NOT EXISTS gift_pay_requests (
id VARCHAR(50) PRIMARY KEY,
request_sn VARCHAR(32) NOT NULL UNIQUE,
initiator_user_id VARCHAR(50) NOT NULL,
product_type VARCHAR(30) NOT NULL,
product_id VARCHAR(50) NOT NULL DEFAULT '',
amount DECIMAL(10,2) NOT NULL,
description VARCHAR(200) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
payer_user_id VARCHAR(50) DEFAULT NULL,
order_id VARCHAR(50) DEFAULT NULL,
expire_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_initiator (initiator_user_id),
INDEX idx_payer (payer_user_id),
INDEX idx_status (status),
INDEX idx_request_sn (request_sn)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -1,2 +0,0 @@
-- 为 chapters 表新增 hot_score 字段(热度分,用于排名算法)
ALTER TABLE chapters ADD COLUMN hot_score INT NOT NULL DEFAULT 0;

View File

@@ -1,40 +0,0 @@
-- stitch_soul 导师与预约表
-- 执行mysql -u user -p db < soul-api/scripts/add-mentors.sql
CREATE TABLE IF NOT EXISTS `mentors` (
`id` int NOT NULL AUTO_INCREMENT,
`avatar` varchar(500) DEFAULT '',
`name` varchar(80) NOT NULL,
`intro` varchar(500) DEFAULT '',
`tags` varchar(500) DEFAULT '',
`price_single` decimal(10,2) DEFAULT NULL,
`price_half_year` decimal(10,2) DEFAULT NULL,
`price_year` decimal(10,2) DEFAULT NULL,
`quote` varchar(500) DEFAULT '',
`why_find` text,
`offering` text,
`judgment_style` varchar(500) DEFAULT '',
`sort` int DEFAULT 0,
`enabled` tinyint(1) DEFAULT 1,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_enabled_sort` (`enabled`,`sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `mentor_consultations` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`mentor_id` int NOT NULL,
`consultation_type` varchar(20) NOT NULL,
`amount` decimal(10,2) NOT NULL,
`status` varchar(20) DEFAULT 'created',
`order_id` int DEFAULT NULL,
`scheduled_at` datetime DEFAULT NULL,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_mentor_id` (`mentor_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,5 +0,0 @@
-- 订单表新增退款原因列
-- 执行: mysql -u user -p db < soul-api/scripts/add-order-refund-reason.sql
-- 若列已存在(如由 GORM AutoMigrate 创建)会报错,可忽略
ALTER TABLE orders ADD COLUMN refund_reason VARCHAR(500) DEFAULT NULL COMMENT '退款原因' AFTER referrer_id;

View File

@@ -1,15 +0,0 @@
-- orders 表索引优化:提升 /api/orders 列表、营收统计查询性能
-- 执行mysql -u user -p database < soul-api/scripts/add-orders-indexes.sql
-- 幂等:索引已存在时跳过,可重复执行
-- 1. idx_status_created 复合索引
SELECT COUNT(*) INTO @cnt FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'orders' AND index_name = 'idx_status_created';
SET @sql = IF(@cnt = 0, 'ALTER TABLE orders ADD INDEX idx_status_created (status, created_at)', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2. idx_user_id 索引
SELECT COUNT(*) INTO @cnt FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'orders' AND index_name = 'idx_user_id';
SET @sql = IF(@cnt = 0, 'ALTER TABLE orders ADD INDEX idx_user_id (user_id)', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@@ -1,6 +0,0 @@
-- persons 表新增 ckb_api_key 字段
-- 作用:存储该 @人物 在存客宝的接入密钥,点击加好友时用该 Key 推线索;留空则 fallback 全局 CKB_LEAD_API_KEY
-- 执行mysql -u user -p db < soul-api/scripts/add-persons-ckb-api-key.sql
ALTER TABLE persons
ADD COLUMN ckb_api_key VARCHAR(100) NOT NULL DEFAULT '' AFTER label;

View File

@@ -1,12 +0,0 @@
-- 为 persons 表增加存客宝 API 获客相关字段,便于管理端回显与二次编辑
ALTER TABLE `persons`
ADD COLUMN `greeting` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '存客宝打招呼语' AFTER `ckb_api_key`,
ADD COLUMN `tips` TEXT NULL COMMENT '获客成功提示' AFTER `greeting`,
ADD COLUMN `remark_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '备注类型phone/nickname/source' AFTER `tips`,
ADD COLUMN `remark_format` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '备注格式' AFTER `remark_type`,
ADD COLUMN `add_friend_interval` INT NOT NULL DEFAULT 1 COMMENT '添加好友间隔(分钟)' AFTER `remark_format`,
ADD COLUMN `start_time` VARCHAR(10) NOT NULL DEFAULT '09:00' COMMENT '允许加人开始时间 HH:MM' AFTER `add_friend_interval`,
ADD COLUMN `end_time` VARCHAR(10) NOT NULL DEFAULT '18:00' COMMENT '允许加人结束时间 HH:MM' AFTER `start_time`,
ADD COLUMN `device_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '参与计划的设备ID列表逗号分隔' AFTER `end_time`,
ADD COLUMN `ckb_plan_id` BIGINT NOT NULL DEFAULT 0 COMMENT '存客宝获客计划ID' AFTER `device_groups`;

View File

@@ -1,23 +0,0 @@
-- persons、link_tags 表,供 ContentPage @提及人物与链接标签配置
-- 执行mysql -u user -p db < soul-api/scripts/add-persons-link-tags.sql
CREATE TABLE IF NOT EXISTS persons (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
person_id VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL DEFAULT '',
label VARCHAR(200) NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS link_tags (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
tag_id VARCHAR(50) NOT NULL UNIQUE,
label VARCHAR(200) NOT NULL DEFAULT '',
url VARCHAR(500) NOT NULL DEFAULT '',
type VARCHAR(20) NOT NULL DEFAULT 'url',
app_id VARCHAR(100) NOT NULL DEFAULT '',
page_path VARCHAR(500) NOT NULL DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -1,6 +0,0 @@
-- persons 表新增 token 字段32 位唯一,@ 时存此值,小程序用此兑换 ckb_api_key
-- 执行cd 项目根目录 && node .cursor/scripts/db-exec/run.js -f soul-api/scripts/add-persons-token.sql
-- 或mysql -u user -p db < soul-api/scripts/add-persons-token.sql
ALTER TABLE persons ADD COLUMN token VARCHAR(36) NOT NULL DEFAULT '' AFTER person_id;
ALTER TABLE persons ADD UNIQUE INDEX idx_persons_token (token);

View File

@@ -1,10 +0,0 @@
-- persons 表新增 user_id用于“超级个体开通后自动创建@人”等幂等绑定
-- 说明:
-- - 允许为空(历史数据/手工创建不绑定 user
-- - 允许多条 NULLMySQL UNIQUE 对 NULL 不冲突)
-- - 绑定后建议一人仅一条 Person满足“昵称同名@人”需求)
ALTER TABLE persons
ADD COLUMN user_id VARCHAR(50) DEFAULT NULL COMMENT '绑定用户ID幂等创建@人)',
ADD UNIQUE KEY uk_persons_user_id (user_id);

View File

@@ -1,5 +0,0 @@
-- 为 chapters 表添加 sort_order 列(支持拖拽排序)
-- 执行cd 项目根目录 && node .cursor/scripts/db-exec/run.js -f soul-api/scripts/add-sort-order-to-chapters.sql
-- 若 sort_order 已存在会报错,可忽略
ALTER TABLE chapters ADD COLUMN sort_order INT DEFAULT 0;

View File

@@ -1,15 +0,0 @@
-- P3 资料扩展users 表新增个人资料字段stitch_soul
-- 用于 comprehensive_profile_editor、enhanced_professional_profile
ALTER TABLE users ADD COLUMN mbti VARCHAR(16) NULL COMMENT 'MBTI类型' AFTER wechat_id;
ALTER TABLE users ADD COLUMN region VARCHAR(100) NULL COMMENT '地区' AFTER mbti;
ALTER TABLE users ADD COLUMN industry VARCHAR(100) NULL COMMENT '行业' AFTER region;
ALTER TABLE users ADD COLUMN position VARCHAR(100) NULL COMMENT '职位' AFTER industry;
ALTER TABLE users ADD COLUMN business_scale VARCHAR(100) NULL COMMENT '业务体量' AFTER position;
ALTER TABLE users ADD COLUMN skills VARCHAR(500) NULL COMMENT '我擅长' AFTER business_scale;
ALTER TABLE users ADD COLUMN story_best_month TEXT NULL COMMENT '最赚钱的一个月' AFTER skills;
ALTER TABLE users ADD COLUMN story_achievement TEXT NULL COMMENT '最有成就感的事' AFTER story_best_month;
ALTER TABLE users ADD COLUMN story_turning TEXT NULL COMMENT '人生转折点' AFTER story_achievement;
ALTER TABLE users ADD COLUMN help_offer VARCHAR(500) NULL COMMENT '我能帮助大家什么' AFTER story_turning;
ALTER TABLE users ADD COLUMN help_need VARCHAR(500) NULL COMMENT '我需要什么帮助' AFTER help_offer;
ALTER TABLE users ADD COLUMN project_intro TEXT NULL COMMENT '项目介绍' AFTER help_need;

View File

@@ -1,8 +0,0 @@
-- 规则引擎默认数据:插入「注册」规则,供登录后完善头像引导
-- 执行mysql -u user -p db < soul-api/scripts/add-user-rules-default.sql
-- 幂等:若已存在 trigger='注册' 则跳过
INSERT INTO user_rules (title, description, `trigger`, sort, enabled, created_at, updated_at)
SELECT '完善个人信息', '设置头像和昵称,让其他创业者更容易认识你', '注册', 1, 1, NOW(), NOW()
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM user_rules WHERE `trigger` = '注册' LIMIT 1);

View File

@@ -1,5 +0,0 @@
-- 用户软删除:管理端假删除,用户再次登录会新建账号
-- 执行后DELETE 操作改为 SET deleted_at不再物理删除避免外键约束
ALTER TABLE users ADD COLUMN deleted_at DATETIME(3) NULL DEFAULT NULL COMMENT '软删除时间' AFTER updated_at;
CREATE INDEX idx_users_deleted_at ON users (deleted_at);

View File

@@ -1,13 +0,0 @@
-- 新增 users.vip_activated_at成为 VIP 时间,用于排序(后付款/后设置在前)
-- 执行mysql -u user -p database < add-vip-activated-at.sql
-- 若列已存在会报错,可忽略
ALTER TABLE users ADD COLUMN vip_activated_at DATETIME NULL COMMENT '成为VIP时间付款=pay_time手动=now排序用';
-- 可选:为已有 VIP 用户回填 vip_activated_at取该用户最近一次 vip 订单的 pay_time
-- UPDATE users u
-- SET u.vip_activated_at = (
-- SELECT MAX(o.pay_time) FROM orders o
-- WHERE o.user_id = u.id AND o.product_type = 'vip' AND o.status = 'paid'
-- )
-- WHERE u.is_vip = 1 AND u.vip_activated_at IS NULL;

View File

@@ -1,20 +0,0 @@
-- ============================================================
-- 会员资料字段 - users 表
-- 用途VIP 页保存资料、创业老板排行展示(与用户信息 phone/wechat_id 分离)
-- 执行前请先备份数据库!
-- ============================================================
-- 1. 检查:查看 users 表是否已有这些列(可选执行)
-- SHOW COLUMNS FROM users LIKE 'vip_name';
-- SHOW COLUMNS FROM users LIKE 'vip_avatar';
-- SHOW COLUMNS FROM users LIKE 'vip_project';
-- SHOW COLUMNS FROM users LIKE 'vip_contact';
-- SHOW COLUMNS FROM users LIKE 'vip_bio';
-- 2. 新增会员资料字段(若列已存在会报 Duplicate column name可忽略该条
-- --------------------------------------------------------
ALTER TABLE users ADD COLUMN vip_name VARCHAR(100) NULL COMMENT '会员姓名(创业老板排行)';
ALTER TABLE users ADD COLUMN vip_avatar VARCHAR(500) NULL COMMENT '会员头像';
ALTER TABLE users ADD COLUMN vip_project VARCHAR(200) NULL COMMENT '项目名称';
ALTER TABLE users ADD COLUMN vip_contact VARCHAR(100) NULL COMMENT '会员联系方式(展示用,与 phone/wechat_id 分离)';
ALTER TABLE users ADD COLUMN vip_bio TEXT NULL COMMENT '一句话简介';

View File

@@ -1,25 +0,0 @@
-- 1. 新建 vip_roles 表
CREATE TABLE IF NOT EXISTS vip_roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称',
sort INT DEFAULT 0 COMMENT '下拉展示顺序,越小越前',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT '超级个体固定角色';
-- 2. 插入默认角色UNIQUE name 防重复)
INSERT IGNORE INTO vip_roles (name, sort) VALUES
('创始人', 1),
('投资人', 2),
('产品经理', 3),
('技术负责人', 4),
('运营总监', 5),
('销售总监', 6),
('市场总监', 7),
('合伙人', 8),
('顾问', 9),
('品牌主理人', 10);
-- 3. users 表新增 vip_sort、vip_role
ALTER TABLE users ADD COLUMN vip_sort INT NULL COMMENT '手动排序,越小越前';
ALTER TABLE users ADD COLUMN vip_role VARCHAR(50) NULL COMMENT '角色:从 vip_roles 选或手动填写';

View File

@@ -1,9 +0,0 @@
# 补全 persons.ckb_api_key
若存在 ckb_plan_id 但 ckb_api_key 为空的 Person可手动调用 plan/detail 补全。
**执行前**:确保 soul-api 可连接存客宝CKB_OPEN_API_KEY、CKB_OPEN_ACCOUNT 已配置)。
**方式一**:管理端逐个编辑保存(会触发存客宝同步,若 Person 无 ckb_api_key 需在编辑弹窗填写或重新创建)。
**方式二**:写一次性脚本,遍历 `ckb_plan_id > 0 AND (ckb_api_key IS NULL OR ckb_api_key = '')` 的 Person调 ckbOpenGetPlanDetail 获取 apiKey 并 UPDATE。

View File

@@ -1,5 +0,0 @@
-- 修复篇章标题:将 slug 形式的 part_title 更新为展示标题数据来源DB
-- 执行node .cursor/scripts/db-exec/run.js -f soul-api/scripts/fix-part-titles.sql
-- part-2026-daily 的标题应为「2026每日派对干货」
UPDATE chapters SET part_title = '2026每日派对干货' WHERE part_id = 'part-2026-daily' AND (part_title = 'part-2026-daily' OR part_title = '' OR part_title IS NULL);

View File

@@ -1,83 +0,0 @@
-- ============================================================
-- VIP 订单修复脚本
-- 场景:甲方开发的 VIP 支付可能未正确设置 product_type
-- 会员价1980 元
-- 执行前请先备份数据库!
-- ============================================================
-- 1. 诊断:查看当前疑似 VIP 订单的状态(执行后人工确认)
-- --------------------------------------------------------
SELECT id, order_sn, user_id, product_type, amount, status, pay_time, created_at
FROM orders
WHERE amount = 1980
AND (status = 'paid' OR status = 'completed')
ORDER BY pay_time DESC;
-- 2. 统计:有多少条需要修复
-- --------------------------------------------------------
SELECT COUNT(*) AS need_fix_count
FROM orders
WHERE amount = 1980
AND (status = 'paid' OR status = 'completed')
AND (product_type IS NULL OR product_type = '' OR product_type NOT IN ('vip', 'fullbook'));
-- 3. 修复:将 1980 元已支付订单的 product_type 设为 'vip'
-- --------------------------------------------------------
-- 条件:金额=1980 且 已支付 且 product_type 不是 vip/fullbook
UPDATE orders
SET product_type = 'vip'
WHERE amount = 1980
AND (status = 'paid' OR status = 'completed')
AND (product_type IS NULL OR product_type = '' OR product_type NOT IN ('vip', 'fullbook'));
-- 4. 兼容大小写:若 product_type 为 'VIP'、'Vip' 等,统一为小写
-- --------------------------------------------------------
UPDATE orders
SET product_type = 'vip'
WHERE amount = 1980
AND (status = 'paid' OR status = 'completed')
AND LOWER(TRIM(product_type)) = 'vip'
AND product_type != 'vip';
-- 5. 验证:修复后应无遗漏
-- --------------------------------------------------------
SELECT id, order_sn, user_id, product_type, amount, status
FROM orders
WHERE amount = 1980
AND (status = 'paid' OR status = 'completed')
AND product_type NOT IN ('vip', 'fullbook');
-- 期望结果0 行
-- ============================================================
-- 可选:若线上 next-project 用 users 表存 is_vip需确保字段存在
-- 执行前请确认 users 表是否已有这些列!
-- ============================================================
-- 6. 检查 users 表是否有 VIP 相关列MySQL
-- SHOW COLUMNS FROM users LIKE 'is_vip';
-- SHOW COLUMNS FROM users LIKE 'vip_expire_date';
-- 7. 若 users 表无 VIP 列,可执行以下 ALTER按需取消注释
-- --------------------------------------------------------
-- ALTER TABLE users ADD COLUMN is_vip TINYINT(1) DEFAULT 0;
-- ALTER TABLE users ADD COLUMN vip_expire_date DATETIME NULL;
-- ALTER TABLE users ADD COLUMN vip_name VARCHAR(100) NULL;
-- ALTER TABLE users ADD COLUMN vip_avatar VARCHAR(500) NULL;
-- ALTER TABLE users ADD COLUMN vip_project VARCHAR(200) NULL;
-- ALTER TABLE users ADD COLUMN vip_contact VARCHAR(100) NULL;
-- ALTER TABLE users ADD COLUMN vip_bio TEXT NULL;
-- 8. 从 orders 同步到 users仅当用 users 表存 VIP 时)
-- 将 1980 元已支付订单对应的用户标记为 VIP过期日 = pay_time + 365 天
-- --------------------------------------------------------
-- UPDATE users u
-- INNER JOIN (
-- SELECT user_id, MAX(pay_time) AS last_pay
-- FROM orders
-- WHERE amount = 1980
-- AND (status = 'paid' OR status = 'completed')
-- AND product_type IN ('vip', 'fullbook')
-- GROUP BY user_id
-- ) o ON u.id = o.user_id
-- SET u.is_vip = 1,
-- u.vip_expire_date = DATE_ADD(o.last_pay, INTERVAL 365 DAY);

View File

@@ -1,14 +0,0 @@
-- 预置用户旅程引导规则(高频行为锚点)
-- 执行时幂等:如果规则已存在则忽略
INSERT IGNORE INTO user_rules (title, description, trigger, sort, enabled, created_at, updated_at) VALUES
('注册完成 → 填写头像', '用户完成注册后,引导填写头像和昵称,提升个人信息完整度', '注册', 10, 1, NOW(), NOW()),
('完成匹配 → 补充个人资料', '用户完成 Soul 派对房匹配后,引导填写 MBTI、行业、职位等详细信息', '完成匹配', 20, 1, NOW(), NOW()),
('首次浏览章节 → 绑定手机号', '用户点击阅读收费章节时,引导绑定手机号以完成身份验证', '点击收费章节', 30, 1, NOW(), NOW()),
('付款 ¥1980 → 填写完整信息', '购买全书1980元需填写真实姓名、联系方式、所在行业、MBTI以便进入 VIP 群', '完成付款', 40, 1, NOW(), NOW()),
('加入派对房 → 填写项目介绍', '进入 Soul 派对房前,引导填写个人项目介绍和核心需求,便于精准匹配', '加入派对房', 50, 1, NOW(), NOW()),
('浏览 5 个章节 → 分享推广', '用户累计阅读 5 个章节后,触发分享引导,邀请好友可获得收益', '累计浏览5章节', 60, 1, NOW(), NOW()),
('绑定微信 → 开启分销', '绑定微信后,提示用户开启分销功能,生成专属推广码', '绑定微信', 70, 1, NOW(), NOW()),
('收益达到 ¥50 → 申请提现', '累计分销收益超过 50 元时,引导用户申请提现', '收益满50元', 80, 1, NOW(), NOW()),
('完善存客宝信息 → 进入流量池', '引导用户授权存客宝信息同步,进入对应微信流量池,获得精准服务', '手动触发', 90, 1, NOW(), NOW()),
('浏览导师主页 → 预约咨询', '用户浏览导师详情页超过 30 秒,引导预约一对一咨询', '浏览导师页', 100, 1, NOW(), NOW());

View File

@@ -1,12 +0,0 @@
#!/bin/bash
# 订单对账防漏单 - 宝塔定时任务用
# 建议每 10 分钟执行一次
URL="${SYNC_ORDERS_URL:-https://soul.quwanzhi.com/api/cron/sync-orders}"
curl -s -X GET "$URL" \
-H "User-Agent: Baota-Cron/1.0" \
--connect-timeout 10 \
--max-time 30
echo ""

View File

@@ -1,16 +0,0 @@
-- ============================================================
-- 同步 users 表与 Go Model仅 VIP 身份/状态字段,不含单独 VIP 资料列)
-- 小程序已改为直接读用户资料nickname/avatar/projectIntro/phone不再单独存 vip_name/vip_avatar 等。
-- 若某条 ALTER 报 Duplicate column name说明该列已存在跳过即可。
-- 也可直接重启 soul-api未设 SKIP_AUTO_MIGRATE 时会自动补列)。
-- ============================================================
-- users 表VIP 身份与状态(与 internal/model/user.go 一致)
ALTER TABLE users ADD COLUMN is_vip TINYINT(1) NULL DEFAULT 0 COMMENT '是否 VIP';
ALTER TABLE users ADD COLUMN vip_expire_date DATETIME(3) NULL DEFAULT NULL COMMENT 'VIP 到期时间';
ALTER TABLE users ADD COLUMN vip_activated_at DATETIME(3) NULL DEFAULT NULL COMMENT '成为 VIP 时间,排序用';
ALTER TABLE users ADD COLUMN vip_sort INT NULL DEFAULT NULL COMMENT '手动排序,越小越前';
ALTER TABLE users ADD COLUMN vip_role VARCHAR(50) NULL DEFAULT NULL COMMENT '角色:从 vip_roles 选或手动填写';
-- chapters 表Model 使用 hot_score热度分SQL 导出里只有 hot_score_override缺则排名等接口报错
ALTER TABLE chapters ADD COLUMN hot_score INT NOT NULL DEFAULT 0 COMMENT '热度分,用于排名算法';

View File

@@ -1,61 +0,0 @@
# stitch_soul P0 接口验证脚本
# 用法:先启动 soul-api然后执行 .\scripts\test-p0-endpoints.ps1
# 可指定 baseUrl$env:API_BASE = "http://localhost:8080"; .\scripts\test-p0-endpoints.ps1
$base = if ($env:API_BASE) { $env:API_BASE } else { "http://localhost:8080" }
Write-Host "=== stitch_soul P0 接口测试 ===" -ForegroundColor Cyan
Write-Host "Base: $base`n" -ForegroundColor Gray
$passed = 0
$failed = 0
function Test-Endpoint {
param($name, $path)
try {
$r = Invoke-RestMethod -Uri "$base$path" -Method GET -TimeoutSec 5
if ($r.success -eq $true) {
Write-Host "[PASS] $name" -ForegroundColor Green
$script:passed++
return $r
} else {
Write-Host "[FAIL] $name - success != true" -ForegroundColor Red
$script:failed++
}
} catch {
Write-Host "[FAIL] $name - $($_.Exception.Message)" -ForegroundColor Red
$script:failed++
}
return $null
}
# 1. book/all-chapters含 isNew
$r1 = Test-Endpoint "GET /api/miniprogram/book/all-chapters" "/api/miniprogram/book/all-chapters"
if ($r1 -and $r1.data) {
$first = $r1.data[0]
if ($null -ne $first.PSObject.Properties['isNew']) {
Write-Host " -> isNew 字段存在" -ForegroundColor Green
} else {
Write-Host " -> 警告: isNew 字段可能缺失" -ForegroundColor Yellow
}
}
# 2. book/recommended精选推荐前3章+tag
$r2 = Test-Endpoint "GET /api/miniprogram/book/recommended" "/api/miniprogram/book/recommended"
if ($r2 -and $r2.data) {
Write-Host " -> 返回 $($r2.data.Count)tag: $($r2.data[0].tag)" -ForegroundColor Gray
}
# 3. book/latest-chapters最新更新
$r3 = Test-Endpoint "GET /api/miniprogram/book/latest-chapters" "/api/miniprogram/book/latest-chapters"
if ($r3 -and $r3.data) {
Write-Host " -> 返回 $($r3.data.Count)" -ForegroundColor Gray
}
# 4. book/hot热门章节
$r4 = Test-Endpoint "GET /api/miniprogram/book/hot" "/api/miniprogram/book/hot"
if ($r4 -and $r4.data) {
Write-Host " -> 返回 $($r4.data.Count)" -ForegroundColor Gray
}
Write-Host "`n=== 结果: $passed 通过, $failed 失败 ===" -ForegroundColor $(if ($failed -eq 0) { "Green" } else { "Yellow" })

View File

@@ -1,93 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
模拟微信「商家转账到零钱」结果通知回调,请求本地/远程回调接口,
用于验证1接口是否可达 2wechat_callback_logs 表是否会写入一条记录。
说明:未使用真实签名与加密,服务端会验签失败并返回 500
但仍会写入 wechat_callback_logs 一条 handler_result=fail 的记录。
运行前请确保 soul-api 已启动;运行后请查表 wechat_callback_logs 是否有新行。
"""
import json
import ssl
import sys
from datetime import datetime
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
# 默认请求地址(可改环境或命令行)
DEFAULT_URL = "http://localhost:8080/api/payment/wechat/transfer/notify"
def main():
args = [a for a in sys.argv[1:] if a and not a.startswith("-")]
insecure = "--insecure" in sys.argv or "-k" in sys.argv
url = args[0] if args else DEFAULT_URL
if insecure and url.startswith("https://"):
print("已启用 --insecure跳过 SSL 证书校验(仅用于本地/测试)")
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
else:
ctx = None
# 模拟微信回调的请求体结构(真实场景中 resource.ciphertext 为 AEAD_AES_256_GCM 加密,这里用占位)
body = {
"id": "test-notify-id-" + datetime.now().strftime("%Y%m%d%H%M%S"),
"create_time": datetime.now().strftime("%Y-%m-%dT%H:%M:%S+08:00"),
"resource_type": "encrypt-resource",
"event_type": "MCHTRANSFER.BILL.FINISHED",
"summary": "模拟转账结果通知",
"resource": {
"original_type": "mch_payment",
"algorithm": "AEAD_AES_256_GCM",
"ciphertext": "fake-base64-ciphertext-for-test",
"nonce": "fake-nonce",
"associated_data": "mch_payment",
},
}
body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8")
headers = {
"Content-Type": "application/json",
"Wechatpay-Timestamp": str(int(datetime.now().timestamp())),
"Wechatpay-Nonce": "test-nonce-" + datetime.now().strftime("%H%M%S"),
"Wechatpay-Signature": "fake-signature-for-test",
"Wechatpay-Serial": "fake-serial-for-test",
}
req = Request(url, data=body_bytes, headers=headers, method="POST")
print(f"POST {url}")
print(f"Body (摘要): event_type={body['event_type']}, resource_type={body['resource_type']}")
print("-" * 50)
try:
with urlopen(req, timeout=10, context=ctx) as resp:
print(f"HTTP 状态: {resp.status}")
raw = resp.read().decode("utf-8", errors="replace")
try:
parsed = json.loads(raw)
print("响应 JSON:", json.dumps(parsed, ensure_ascii=False, indent=2))
except Exception:
print("响应 body:", raw[:500])
except HTTPError as e:
print(f"HTTP 状态: {e.code}")
raw = e.read().decode("utf-8", errors="replace")
try:
parsed = json.loads(raw)
print("响应 JSON:", json.dumps(parsed, ensure_ascii=False, indent=2))
except Exception:
print("响应 body:", raw[:500])
except URLError as e:
print(f"请求失败: {e.reason}")
sys.exit(1)
print("-" * 50)
print("请检查数据库表 wechat_callback_logs 是否有新记录(本次为模拟请求,预期会有一条 handler_result=fail 的记录)。")
if __name__ == "__main__":
main()

View File

@@ -1,64 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
请求提现测试接口:固定用户提现 1 元(默认),无需 admin_session。
用法:
python test_withdraw.py
python test_withdraw.py https://soul.quwanzhi.com
python test_withdraw.py http://localhost:8080 2
"""
import json
import sys
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
from urllib.parse import urlencode
DEFAULT_BASE = "http://localhost:8080"
DEFAULT_USER_ID = "ogpTW5fmXRGNpoUbXB3UEqnVe5Tg"
DEFAULT_AMOUNT = "1"
def main():
base = DEFAULT_BASE
amount = DEFAULT_AMOUNT
args = [a for a in sys.argv[1:] if a]
if args:
if args[0].startswith("http://") or args[0].startswith("https://"):
base = args[0].rstrip("/")
args = args[1:]
if args:
amount = args[0]
path = "/api/withdraw-test"
if not base.endswith(path):
base = base.rstrip("/") + path
url = f"{base}?{urlencode({'userId': DEFAULT_USER_ID, 'amount': amount})}"
req = Request(url, method="GET")
req.add_header("Accept", "application/json")
print(f"GET {url}")
print("-" * 50)
try:
with urlopen(req, timeout=15) as resp:
raw = resp.read().decode("utf-8", errors="replace")
try:
print(json.dumps(json.loads(raw), ensure_ascii=False, indent=2))
except Exception:
print(raw)
except HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
try:
print(json.dumps(json.loads(raw), ensure_ascii=False, indent=2))
except Exception:
print(raw)
print(f"HTTP {e.code}", file=sys.stderr)
except URLError as e:
print(f"请求失败: {e.reason}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

Binary file not shown.

View File

@@ -1,46 +0,0 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
REM air(make dev) 默认用 .env.development本 bat 用于切到正式环境后 go run / 直接运行
echo.
echo Soul API - 环境切换
echo -------------------
echo air 已默认用开发配置,本工具用于切换 .env 供 go run 等使用
echo.
echo 1. 正式环境 (.env.production) - 切到正式配置
echo 2. 开发环境 (.env.development)
echo 3. 退出
echo.
set /p choice=请选择 (1/2/3):
if "%choice%"=="1" goto prod
if "%choice%"=="2" goto dev
if "%choice%"=="3" goto end
echo 无效选择
goto end
:prod
if not exist .env.production (
echo 错误: .env.production 不存在
goto end
)
copy /y .env.production .env >nul
echo.
echo 已切换到: 正式环境
goto end
:dev
if not exist .env.development (
echo 错误: .env.development 不存在
goto end
)
copy /y .env.development .env >nul
echo.
echo 已切换到: 开发环境
goto end
:end
echo.
pause

View File

@@ -1,24 +0,0 @@
req := &request.RequestTransferBills{
Appid: "Appid",
OutBillNo: "OutBillNo",
TransferSceneId: "TransferSceneId",
Openid: "Openid",
UserName: "UserName",
TransferAmount: 1,
TransferRemark: "TransferRemark",
NotifyUrl: "NotifyUrl",
UserRecvPerception: "UserRecvPerception",
TransferSceneReportInfos: []request.TransferSceneReportInfo{
{
InfoType: "InfoType",
InfoContent: "InfoContent",
},
},
}
ctx := c.Request.Context()
//fmt.Dump(ctx)
rs, err := services.PaymentApp.FundApp.TransferBills(ctx, req)
if err != nil {
panic(err)
}
c.JSON(http.StatusOK, rs)

View File

@@ -1,76 +0,0 @@
# soul-api 域名 404 原因与解决
## 原因
域名请求先到 Nginx若没有把请求转发到本机 8080 的 Go或站点用了 root/静态目录,就会 404。
---
## 一、先确认 Go 是否在跑(必做)
在宝塔终端或 SSH 里执行:
curl -s http://127.0.0.1:8080/health
- 若返回 {"status":"ok"}:说明 Go 正常,问题在 Nginx看下面第二步。
- 若连接被拒绝或超时:说明 8080 没在监听。去 宝塔 → Go项目管理 → soulApi → 服务状态,看是否“运行中”;看“项目日志”是否有报错。
---
## 二、Nginx 必须“整站走代理”,不能走 root
添加了反向代理仍 404多半是
- 站点默认有 location / { root ...; index ...; },请求被当成静态文件处理,/health 找不到就 404
- 或反向代理只绑在了子路径(如 /api/ 和 /health 没被代理。
做法:让 soulapi.quwanzhi.com 的**所有路径**都走 8080不要用 root。
在宝塔:网站 → soulapi.quwanzhi.com → 设置 → 配置文件,找到该站点的 server { ... },按下面两种方式之一改。
### 方式 A只保留一个 location /(推荐)
把 server 里**原来的** location / { ... }(含 root、index 的那段)**删掉或注释掉**,只保留下面这一段:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
保存 → 重载 Nginx或 宝塔 里点“重载配置”)。
### 方式 B整站用下面这一整段 serverHTTPS 示例)
若你希望整站只做反向代理、不混静态,可以把该站点的 server 块整体替换成下面内容(把 your_ssl_cert 等换成你实际的证书路径;没有 SSL 就只用 listen 80 那段):
server {
listen 80;
listen 443 ssl http2;
server_name soulapi.quwanzhi.com;
# SSL 证书路径按宝塔实际填写,例如:
# ssl_certificate /www/server/panel/vhost/cert/soulapi.quwanzhi.com/fullchain.pem;
# ssl_certificate_key /www/server/panel/vhost/cert/soulapi.quwanzhi.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
保存并重载 Nginx。
---
## 三、改完后自测
- 本机curl -s https://soulapi.quwanzhi.com/health
- 或浏览器打开https://soulapi.quwanzhi.com/health
应看到:{"status":"ok"}
- 打开 https://soulapi.quwanzhi.com/ 应看到“部署成功”页面。

View File

@@ -1,55 +0,0 @@
# soul-api 小程序接口补全说明
## 变更背景
miniprogram 功能还原后,需将 VIP 相关接口从 `/api/vip/*` 迁移至 `/api/miniprogram/vip/*`,并补充 miniprogram 组下的 users 接口,符合项目边界(小程序只调 `/api/miniprogram/*`)。
## 新增接口
### 1. VIP 接口handler/vip.go
| 路径 | 方法 | Handler | 用途 |
|------|------|---------|------|
| `/api/miniprogram/vip/status` | GET | VipStatus | 查询用户 VIP 状态 |
| `/api/miniprogram/vip/profile` | GET | VipProfileGet | 获取 VIP 资料 |
| `/api/miniprogram/vip/profile` | POST | VipProfilePost | 更新 VIP 资料 |
| `/api/miniprogram/vip/members` | GET | VipMembers | VIP 会员列表或单个 |
**实现说明**
- **status**:按 orders 表查 `product_type IN ('fullbook','vip')``status='paid'` 判断是否 VIP返回 `isVip``daysRemaining``expireDate``price`
- **profile**GET 从 users 表读 nickname、phonePOST 更新 nickname、phone
- **members**:无 `?id` 时返回有 fullbook/vip 订单的用户列表;有 `?id` 时返回单个用户,含 `vip_name``vip_avatar``vip_contact``is_vip` 等字段
### 2. 用户接口handler/miniprogram.go
| 路径 | 方法 | Handler | 用途 |
|------|------|---------|------|
| `/api/miniprogram/users` | GET | MiniprogramUsers | 用户列表或单个 |
**实现说明**
- `?limit=20`:返回用户列表,用于首页「超级个体」不足 4 人时的补充
- `?id=xxx`:返回单个用户,用于会员详情页在 vip/members 失败时的回退
- 返回格式:`{ success, data }`,与 miniprogram 期望一致
## 已有接口(无需变更)
以下接口已在 miniprogram 组挂载miniprogram 已正确调用:
- `/api/miniprogram/book/all-chapters`
- `/api/miniprogram/book/chapter/:id`
- `/api/miniprogram/book/chapter/by-mid/:mid`
- `/api/miniprogram/book/hot`
- `/api/miniprogram/book/search`
- `/api/miniprogram/book/stats`
## 路由注册位置
`internal/router/router.go` 中 miniprogram 组末尾:
```go
miniprogram.GET("/vip/status", handler.VipStatus)
miniprogram.GET("/vip/profile", handler.VipProfileGet)
miniprogram.POST("/vip/profile", handler.VipProfilePost)
miniprogram.GET("/vip/members", handler.VipMembers)
miniprogram.GET("/users", handler.MiniprogramUsers)
```

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,15 @@
# soul-api 文档索引
> 以下文档已整理至 **开发文档**,此处保留为源码参考
> 相关文档已统一移至 **开发文档**。
| 原文件 | 开发文档位置 |
|--------|--------------|
| 宝塔反向代理说明.txt | [8、部署/宝塔反向代理说明](../开发文档/8、部署/宝塔反向代理说明.md) |
| 订阅消息.md | [8、部署/订阅消息](../开发文档/8、部署/订阅消息.md) |
| 管理端鉴权设计.md | [6、后端/管理端鉴权设计](../开发文档/6、后端/管理端鉴权设计.md) |
| 商家转账.md | [8、部署/商家转账](../开发文档/8、部署/商家转账.md) |
| 提现功能完整技术文档.md | [8、部署/提现功能完整技术文档](../开发文档/8、部署/提现功能完整技术文档.md) |
- [8、部署/部署总览](../开发文档/8、部署/部署总览.md) — 部署入口
- [8、部署/DOCKER部署说明](../开发文档/8、部署/DOCKER部署说明.md)
- [8、部署/宝塔-Docker首次配置指南](../开发文档/8、部署/宝塔-Docker首次配置指南.md)
- [8、部署/宝塔反向代理说明](../开发文档/8、部署/宝塔反向代理说明.md)
- [8、部署/提现功能完整技术文档](../开发文档/8、部署/提现功能完整技术文档.md)
- [8、部署/订阅消息](../开发文档/8、部署/订阅消息.md)
- [8、部署/商家转账](../开发文档/8、部署/商家转账.md)
- [6、后端/管理端鉴权设计](../开发文档/6、后端/管理端鉴权设计.md)
- [6、后端/miniprogram接口补全说明](../开发文档/6、后端/miniprogram接口补全说明.md)
详见 [开发文档 README](../开发文档/README.md)。
详见 [开发文档索引](../开发文档/索引.md)。

View File

@@ -1,116 +0,0 @@
# soul-api 管理端登录判断与权限校验
## 一、有没有登录的依据JWT
**依据:请求中的 JWT。优先从 `Authorization: Bearer <token>` 读取,兼容从 Cookie `admin_session` 读取。**
| 项目 | 说明 |
|------|------|
| 推荐方式 | 请求头 `Authorization: Bearer <JWT>` |
| 兼容方式 | Cookie 名 `admin_session`,值为 JWT 字符串 |
| JWT 算法 | HS256密钥为 `ADMIN_SESSION_SECRET` |
| 有效期 | 7 天exp claim |
| 载荷 | sub=admin, username, role=admin |
| 校验 | 验签 + 未过期 → 视为已登录 |
- 配置:`ADMIN_USERNAME` / `ADMIN_PASSWORD` 用于登录校验;`ADMIN_SESSION_SECRET` 用于签发/校验 JWT。
- 未带有效 JWT → 401。
---
## 二、权限校验设计(路由分层)
- **不校验登录**:只做业务逻辑(登录、登出、鉴权检查)
- `GET /api/admin` → 鉴权检查(读 Cookie有效 200 / 无效 401
- `POST /api/admin` → 登录(校验账号密码,写 Cookie
- `POST /api/admin/logout` → 登出(删 Cookie
- **必须已登录**:挂 `AdminAuth()` 中间件,从请求读 `admin_session` 并验签+过期,不通过直接 401不进入 handler
- `/api/admin/*`(如 chapters、content、withdrawals、settings 等)
- `/api/db/*`
- **其它**:如 `/api/miniprogram/*``/api/book/*` 等不加 AdminAuth按各自接口鉴权如小程序 token
---
## 三、框图
```mermaid
flowchart TB
subgraph 前端["soul-admin 前端"]
A[用户打开后台 / 请求接口]
A --> B{请求类型}
B -->|登录| C[POST /api/admin]
B -->|登出| D[POST /api/admin/logout]
B -->|进后台前检查| E[GET /api/admin]
B -->|业务接口| F[GET/POST /api/admin/xxx]
end
subgraph 请求["每次请求"]
G[浏览器自动携带 Cookie: admin_session]
G --> H[发往 soul-api]
end
subgraph soul-api["soul-api 路由"]
I["/api/admin 三条(无中间件)"]
J["/api/admin/* 与 /api/db/*"]
J --> K[AdminAuth 中间件]
end
subgraph 鉴权["AdminAuth 与 AdminCheck 逻辑"]
K --> L[从请求读 Cookie admin_session]
L --> M{有 Cookie?}
M -->|无| N[401 未授权]
M -->|有| O[解析 exp.signature]
O --> P{未过期 且 验签通过?}
P -->|否| N
P -->|是| Q[放行 / 返回 200]
end
C --> I
D --> I
E --> I
F --> J
H --> soul-api
I --> E2[GET: 同鉴权逻辑 200/401]
I --> C2[POST: 校验账号密码 写 Cookie]
I --> D2[POST: 清 Cookie]
```
**路由与中间件关系(框线):**
```mermaid
flowchart LR
subgraph 无鉴权["不经过 AdminAuth"]
R1[GET /api/admin]
R2[POST /api/admin]
R3[POST /api/admin/logout]
end
subgraph 需登录["经过 AdminAuth"]
R4["/api/admin/chapters"]
R5["/api/admin/withdrawals"]
R6["/api/admin/settings"]
R7["/api/db/*"]
end
subgraph 中间件["AdminAuth()"]
M[读 Cookie → 验 token → 通过/401]
end
H1[直接进 handler]
H2[通过则进 handler]
无鉴权 --> H1
需登录 --> M --> H2
```
---
## 四、相关代码位置
| 作用 | 位置 |
|------|------|
| JWT 签发/校验/从请求取 token | `internal/auth/adminjwt.go` |
| 登录、登出、GET 鉴权检查 | `internal/handler/admin.go` |
| 管理端中间件 | `internal/middleware/admin_auth.go` |
| 路由挂载 | `internal/router/router.go`api.Group + admin.Use(AdminAuth()) |

View File

@@ -1,23 +0,0 @@
data := &power.HashMap{
"phrase4": power.StringMap{
"value": "提现成功",//提现结果:提现成功、提现失败
},
"amount5": pwer.StringMap{
"value": "¥8.6",//提现金额
},
"thing8": power.StringMap{
"value": "微信打款成功,请点击查收",//备注,如果打款失败就提示请联系官方客服
},
}
MiniProgramApp.SubscribeMessage.Send(ctx, &request.RequestSubscribeMessageSend{
ToUser: "OPENID",//需要根据订单号联表查询提现表的user_id就是opend_id
TemplateID: "u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE",//这串是正确的
Page: "/pages/my/my",
// developer为开发版trial为体验版formal为正式版 这块最好根据我的域名区分,
// 开发环境是souldev.quwanzhi.com 正式环境是 soulapi.quwanzhi.com
MiniProgramState: "formal",
Lang: "zh_CN",
Data: data,
})
{"create_time":"2026-02-10T18:02:54+08:00","out_bill_no":"WD1770691555206100","package_info":"ABBQO+oYAAABAAAAAAAk+yPZGrq+hyjETwKLaRAAAADnGpepZahT9IkJjn90+1qg6ZgBGi0Qjs+Pff8cmSa31vfwaewAXCM6F4nJ9wEZRdwDm4QridPWurNI1lWD7iSS7oX/YzP5XOnpeAlYX3tjHLTDdDQ=","state":"WAIT_USER_CONFIRM","transfer_bill_no":"1330000114850082602100071440076263"}