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:
Binary file not shown.
48
soul-api/.dockerignore
Normal file
48
soul-api/.dockerignore
Normal 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
60
soul-api/.env.production
Normal file
@@ -0,0 +1,60 @@
|
||||
# 正式环境配置(部署时复制为 .env,master.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
|
||||
# 公钥证书(本地或 OSS):https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem
|
||||
WECHAT_CERT_PATH=certs/apiclient_cert.pem
|
||||
# 私钥(线上用 OSS):https://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
3
soul-api/.gitignore
vendored
@@ -1,7 +1,6 @@
|
||||
tmp/
|
||||
log/
|
||||
soul-api
|
||||
soul-api-linux
|
||||
server.exe
|
||||
soul-api.exe
|
||||
wechat/info.log
|
||||
deploy/
|
||||
@@ -1 +0,0 @@
|
||||
# 请将伪静态规则或自定义Apache配置填写到此处
|
||||
Binary file not shown.
BIN
soul-api/__pycache__/devloy.cpython-311.pyc
Normal file
BIN
soul-api/__pycache__/devloy.cpython-311.pyc
Normal file
Binary file not shown.
BIN
soul-api/__pycache__/master.cpython-311.pyc
Normal file
BIN
soul-api/__pycache__/master.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
995
soul-api/devloy.py
Normal file
995
soul-api/devloy.py
Normal 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
|
||||
|
||||
- binary:Go 二进制 + 宝塔 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(" [提示] 未安装 requests,pip 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/.env;certs/ 由 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(" [镜像配置] 打入镜像的环境文件: %s(DOCKER_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.gz(soul-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 MB(soul-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: 不打进 tar;docker: 不上传服务器目录 .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())
|
||||
@@ -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:
|
||||
@@ -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] 上传中断: %s,5 秒后重连 ..." % (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"])
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
50
soul-api/internal/cache/cache.go
vendored
50
soul-api/internal/cache/cache.go
vendored
@@ -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 写入 Redis,Redis 不可用时写入内存备用;失败仅打日志不阻塞
|
||||
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 删除 key,Redis 不可用时删除内存备用
|
||||
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,适合大文本 content),Redis 不可用时尝试内存备用
|
||||
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,适合大文本 content),Redis 不可用时写入内存备用
|
||||
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
52
soul-api/internal/cache/memory.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] = ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"] = ""
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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": "提现申请已提交,审核通过后将打款至您的微信零钱",
|
||||
|
||||
149
soul-api/internal/handler/ws.go
Normal file
149
soul-api/internal/handler/ws.go
Normal 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 ""
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -122,3 +122,20 @@ func IsOSSURL(rawURL string) bool {
|
||||
prefix := "https://" + cfg.Bucket + "." + cfg.Endpoint + "/"
|
||||
return strings.HasPrefix(rawURL, prefix)
|
||||
}
|
||||
|
||||
// PublicURL 将路径转为 OSS 公网访问 URL,path 如 /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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
16d770afdc8b7273eb7a93814af01b23
|
||||
@@ -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
|
||||
@@ -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_id,Model 为 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 中对应表结构
|
||||
Binary file not shown.
@@ -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)
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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=是';
|
||||
@@ -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);
|
||||
@@ -1,8 +0,0 @@
|
||||
-- ============================================================
|
||||
-- stitch_soul P0:chapters 表新增 is_new 字段
|
||||
-- 用途:目录/首页「最新新增」标识,管理端可勾选
|
||||
-- 执行前请先备份数据库!
|
||||
-- ============================================================
|
||||
|
||||
-- 新增 is_new 字段(若列已存在会报 Duplicate column name,可忽略)
|
||||
ALTER TABLE chapters ADD COLUMN is_new TINYINT(1) NULL DEFAULT 0 COMMENT '是否标记为最新新增';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- 为 chapters 表新增 hot_score 字段(热度分,用于排名算法)
|
||||
ALTER TABLE chapters ADD COLUMN hot_score INT NOT NULL DEFAULT 0;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -1,10 +0,0 @@
|
||||
-- persons 表新增 user_id:用于“超级个体开通后自动创建@人”等幂等绑定
|
||||
-- 说明:
|
||||
-- - 允许为空(历史数据/手工创建不绑定 user)
|
||||
-- - 允许多条 NULL(MySQL 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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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 '一句话简介';
|
||||
@@ -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 选或手动填写';
|
||||
@@ -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。
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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());
|
||||
@@ -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 ""
|
||||
@@ -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 '热度分,用于排名算法';
|
||||
@@ -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" })
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
模拟微信「商家转账到零钱」结果通知回调,请求本地/远程回调接口,
|
||||
用于验证:1)接口是否可达 2)wechat_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()
|
||||
@@ -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.
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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:整站用下面这一整段 server(HTTPS 示例)
|
||||
|
||||
若你希望整站只做反向代理、不混静态,可以把该站点的 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/ 应看到“部署成功”页面。
|
||||
@@ -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、phone;POST 更新 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
@@ -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)。
|
||||
|
||||
@@ -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())) |
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user